executor.go raw

   1  //go:build !(js && wasm)
   2  
   3  // Package graph implements NIP-XX Graph Query protocol support.
   4  // This file contains the executor that runs graph traversal queries.
   5  package graph
   6  
   7  import (
   8  	"encoding/json"
   9  	"fmt"
  10  	"sort"
  11  	"strconv"
  12  	"time"
  13  
  14  	"next.orly.dev/pkg/lol/chk"
  15  	"next.orly.dev/pkg/lol/log"
  16  
  17  	"next.orly.dev/pkg/nostr/encoders/event"
  18  	"next.orly.dev/pkg/nostr/encoders/tag"
  19  	"next.orly.dev/pkg/nostr/interfaces/signer"
  20  	"next.orly.dev/pkg/nostr/interfaces/signer/p8k"
  21  )
  22  
  23  // Response kind for graph queries (ephemeral, relay-signed)
  24  const KindGraphResult = 39000
  25  
  26  // GraphResultI is the interface that database.GraphResult implements.
  27  type GraphResultI interface {
  28  	ToDepthArrays() [][]string
  29  	ToEventDepthArrays() [][]string
  30  	GetAllPubkeys() []string
  31  	GetAllEvents() []string
  32  	GetPubkeysByDepth() map[int][]string
  33  	GetEventsByDepth() map[int][]string
  34  	GetTotalPubkeys() int
  35  	GetTotalEvents() int
  36  	GetInboundRefs() map[uint16]map[string][]string
  37  	GetOutboundRefs() map[uint16]map[string][]string
  38  }
  39  
  40  // GraphDatabase defines the interface for graph traversal operations.
  41  // Each method corresponds to one cell in the edge × direction matrix.
  42  type GraphDatabase interface {
  43  	// Pubkey↔Pubkey (pp) — noun-noun edges via ppg/gpp index
  44  	TraversePubkeyPubkey(seedPubkey []byte, maxDepth int, direction string) (GraphResultI, error)
  45  
  46  	// Pubkey↔Event (pe) — adverb edges via peg/epg index
  47  	TraversePubkeyEvent(seedPubkey []byte, maxDepth int, direction string) (GraphResultI, error)
  48  
  49  	// Event↔Event (ee) — adjective edges via eeg/gee index
  50  	TraverseEventEvent(seedEventID []byte, maxDepth int, direction string) (GraphResultI, error)
  51  
  52  	// Baseline (no graph indexes) — for benchmark comparison.
  53  	// Same semantics as TraversePubkeyPubkey but uses multi-hop NIP-01 queries.
  54  	TraversePubkeyPubkeyBaseline(seedPubkey []byte, maxDepth int, direction string) (GraphResultI, error)
  55  }
  56  
  57  // Executor handles graph query execution and response generation.
  58  type Executor struct {
  59  	db          GraphDatabase
  60  	relaySigner signer.I
  61  	relayPubkey []byte
  62  	baseline    bool // when true, use baseline (no ppg/gpp) for pp queries
  63  }
  64  
  65  // NewExecutor creates a new graph query executor.
  66  func NewExecutor(db GraphDatabase, secretKey []byte) (*Executor, error) {
  67  	s, err := p8k.New()
  68  	if err != nil {
  69  		return nil, err
  70  	}
  71  	if err = s.InitSec(secretKey); err != nil {
  72  		return nil, err
  73  	}
  74  	return &Executor{
  75  		db:          db,
  76  		relaySigner: s,
  77  		relayPubkey: s.Pub(),
  78  	}, nil
  79  }
  80  
  81  // SetBaseline enables or disables baseline mode for pp queries.
  82  // In baseline mode, pubkey↔pubkey traversal uses multi-hop NIP-01 queries
  83  // instead of the ppg/gpp materialized index, for benchmark comparison.
  84  func (e *Executor) SetBaseline(enabled bool) {
  85  	e.baseline = enabled
  86  }
  87  
  88  // Execute runs a graph query and returns a relay-signed event with results.
  89  func (e *Executor) Execute(q *Query) (*event.E, error) {
  90  	var result GraphResultI
  91  	var err error
  92  
  93  	start := time.Now()
  94  
  95  	switch q.Edge {
  96  	case "pp":
  97  		// Pubkey↔pubkey: decode seed as pubkey hex
  98  		seedBytes, decErr := decodeHex(q.Pubkey)
  99  		if decErr != nil {
 100  			return nil, decErr
 101  		}
 102  		if e.baseline {
 103  			result, err = e.db.TraversePubkeyPubkeyBaseline(seedBytes, q.Depth, q.Direction)
 104  		} else {
 105  			result, err = e.db.TraversePubkeyPubkey(seedBytes, q.Depth, q.Direction)
 106  		}
 107  
 108  	case "pe":
 109  		// Pubkey↔event: decode seed as pubkey hex
 110  		seedBytes, decErr := decodeHex(q.Pubkey)
 111  		if decErr != nil {
 112  			return nil, decErr
 113  		}
 114  		result, err = e.db.TraversePubkeyEvent(seedBytes, q.Depth, q.Direction)
 115  
 116  	case "ee":
 117  		// Event↔event: seed is still a pubkey in our spec, but the traversal
 118  		// seeds from events authored by that pubkey. For direct event→event
 119  		// traversal, the adapter can resolve this.
 120  		seedBytes, decErr := decodeHex(q.Pubkey)
 121  		if decErr != nil {
 122  			return nil, decErr
 123  		}
 124  		result, err = e.db.TraverseEventEvent(seedBytes, q.Depth, q.Direction)
 125  
 126  	default:
 127  		return nil, ErrInvalidEdge
 128  	}
 129  
 130  	if err != nil {
 131  		return nil, err
 132  	}
 133  
 134  	elapsed := time.Since(start)
 135  	log.D.F("graph query: edge=%s dir=%s depth=%d elapsed=%s pubkeys=%d events=%d baseline=%v",
 136  		q.Edge, q.Direction, q.Depth, elapsed, result.GetTotalPubkeys(), result.GetTotalEvents(), e.baseline)
 137  
 138  	return e.generateResponse(q, result, elapsed)
 139  }
 140  
 141  // generateResponse creates a relay-signed event containing the query results.
 142  func (e *Executor) generateResponse(q *Query, result GraphResultI, elapsed time.Duration) (*event.E, error) {
 143  	var content ResponseContent
 144  	content.Elapsed = elapsed.String()
 145  
 146  	if q.IsPubkeyPubkey() || (q.IsPubkeyEvent() && q.IsOutbound()) {
 147  		content.PubkeysByDepth = result.ToDepthArrays()
 148  		content.TotalPubkeys = result.GetTotalPubkeys()
 149  	}
 150  	if q.IsEventEvent() || (q.IsPubkeyEvent() && q.IsInbound()) {
 151  		content.EventsByDepth = result.ToEventDepthArrays()
 152  		content.TotalEvents = result.GetTotalEvents()
 153  	}
 154  
 155  	contentBytes, err := json.Marshal(content)
 156  	if err != nil {
 157  		return nil, err
 158  	}
 159  
 160  	tags := tag.NewS(
 161  		tag.NewFromAny("edge", q.Edge),
 162  		tag.NewFromAny("direction", q.Direction),
 163  		tag.NewFromAny("seed", q.Pubkey),
 164  		tag.NewFromAny("depth", strconv.Itoa(q.Depth)),
 165  		tag.NewFromAny("elapsed", elapsed.String()),
 166  	)
 167  	if e.baseline {
 168  		tags.Append(tag.NewFromAny("baseline", "true"))
 169  	}
 170  
 171  	ev := &event.E{
 172  		Kind:      KindGraphResult,
 173  		CreatedAt: time.Now().Unix(),
 174  		Tags:      tags,
 175  		Content:   contentBytes,
 176  	}
 177  
 178  	if err = ev.Sign(e.relaySigner); chk.E(err) {
 179  		return nil, err
 180  	}
 181  
 182  	return ev, nil
 183  }
 184  
 185  // ResponseContent is the JSON structure for graph query responses.
 186  type ResponseContent struct {
 187  	PubkeysByDepth [][]string `json:"pubkeys_by_depth,omitempty"`
 188  	EventsByDepth  [][]string `json:"events_by_depth,omitempty"`
 189  	TotalPubkeys   int        `json:"total_pubkeys,omitempty"`
 190  	TotalEvents    int        `json:"total_events,omitempty"`
 191  	Elapsed        string     `json:"elapsed,omitempty"`
 192  }
 193  
 194  // RefSummary represents aggregated reference data.
 195  type RefSummary struct {
 196  	Kind   uint16   `json:"kind"`
 197  	Target string   `json:"target"`
 198  	Count  int      `json:"count"`
 199  	Refs   []string `json:"refs,omitempty"`
 200  }
 201  
 202  func buildRefSummaries(refs map[uint16]map[string][]string) []RefSummary {
 203  	var summaries []RefSummary
 204  	for kind, targets := range refs {
 205  		for targetID, refIDs := range targets {
 206  			summaries = append(summaries, RefSummary{
 207  				Kind:   kind,
 208  				Target: targetID,
 209  				Count:  len(refIDs),
 210  				Refs:   refIDs,
 211  			})
 212  		}
 213  	}
 214  	sort.Slice(summaries, func(i, j int) bool {
 215  		if summaries[i].Count != summaries[j].Count {
 216  			return summaries[i].Count > summaries[j].Count
 217  		}
 218  		return summaries[i].Kind < summaries[j].Kind
 219  	})
 220  	return summaries
 221  }
 222  
 223  // decodeHex decodes a hex string to bytes, with validation.
 224  func decodeHex(hexStr string) ([]byte, error) {
 225  	if len(hexStr) != 64 {
 226  		return nil, fmt.Errorf("expected 64-char hex, got %d chars", len(hexStr))
 227  	}
 228  	b := make([]byte, 32)
 229  	for i := 0; i < 32; i++ {
 230  		hi := unhex(hexStr[i*2])
 231  		lo := unhex(hexStr[i*2+1])
 232  		if hi == 0xFF || lo == 0xFF {
 233  			return nil, fmt.Errorf("invalid hex char at position %d", i*2)
 234  		}
 235  		b[i] = hi<<4 | lo
 236  	}
 237  	return b, nil
 238  }
 239  
 240  func unhex(c byte) byte {
 241  	switch {
 242  	case c >= '0' && c <= '9':
 243  		return c - '0'
 244  	case c >= 'a' && c <= 'f':
 245  		return c - 'a' + 10
 246  	case c >= 'A' && c <= 'F':
 247  		return c - 'A' + 10
 248  	default:
 249  		return 0xFF
 250  	}
 251  }
 252