1 package grow
2 3 import (
4 "context"
5 6 "git.mleku.dev/mleku/dendrite/pkg/axiom"
7 "git.mleku.dev/mleku/dendrite/pkg/lattice"
8 )
9 10 // SeqProbe walks a trained (saturated) lattice sequentially, checking whether
11 // each element from the input stream matches an occupant reachable from the
12 // previous match position. This tests whether the input text follows the
13 // structural patterns encoded in the lattice topology.
14 //
15 // Unlike Probe (parallel, position-independent), SeqProbe maintains a cursor
16 // position in the lattice. Each new element searches outward from the cursor
17 // for a matching occupant. If found, the cursor moves there and a bonded event
18 // is emitted. If not found within MaxSteps, an expired event is emitted and
19 // the cursor jumps to a random matching occupant (if any exist) to resync.
20 //
21 // The lattice is never modified — this is purely read-only.
22 func SeqProbe(ctx context.Context, l *lattice.Lattice, solution <-chan axiom.Element, cfg Config, events chan<- Event) {
23 var cursor *lattice.Node
24 25 for {
26 select {
27 case <-ctx.Done():
28 return
29 case elem, ok := <-solution:
30 if !ok {
31 return
32 }
33 ev := seqProbeStep(l, cursor, elem, cfg.MaxSteps)
34 if ev.Type == EventBonded {
35 cursor = l.Node(ev.NodeID)
36 } else {
37 // Resync: find any matching occupant.
38 cursor = findMatchingOccupant(l, elem.Type())
39 }
40 select {
41 case events <- ev:
42 case <-ctx.Done():
43 return
44 }
45 }
46 }
47 }
48 49 // seqProbeStep searches outward from cursor for a node whose occupant
50 // matches the element's type. Returns a bonded event if found within
51 // MaxSteps, or expired if not.
52 func seqProbeStep(l *lattice.Lattice, cursor *lattice.Node, elem axiom.Element, maxSteps int) Event {
53 tag := elem.Type()
54 55 // If no cursor yet (first element), find any matching occupant.
56 if cursor == nil {
57 n := findMatchingOccupant(l, tag)
58 if n != nil {
59 return Event{Type: EventBonded, NodeID: n.ID(), Element: elem, Steps: 0}
60 }
61 return Event{Type: EventExpired, Element: elem, Steps: 0}
62 }
63 64 // BFS-like expansion from cursor: check cursor itself, then neighbors,
65 // then neighbors of neighbors, etc. The walk distance (steps) measures
66 // how far we had to go in the lattice topology to find a matching occupant.
67 // Short distances = the input follows the lattice structure.
68 // Long distances = the input deviates from trained patterns.
69 70 visited := make(map[lattice.NodeID]bool)
71 current := []*lattice.Node{cursor}
72 73 for step := 0; step < maxSteps && len(current) > 0; step++ {
74 var next []*lattice.Node
75 for _, n := range current {
76 if visited[n.ID()] {
77 continue
78 }
79 visited[n.ID()] = true
80 81 occ := n.Occupant()
82 if occ != nil && occ.Type() == tag {
83 return Event{
84 Type: EventBonded,
85 NodeID: n.ID(),
86 Element: elem,
87 Steps: step,
88 }
89 }
90 91 for _, nb := range n.Neighbors() {
92 if !visited[nb.ID()] {
93 next = append(next, nb)
94 }
95 }
96 }
97 current = next
98 }
99 100 return Event{Type: EventExpired, Element: elem, Steps: maxSteps}
101 }
102 103 // findMatchingOccupant scans the lattice for any node whose occupant
104 // matches the given type tag. Used for resyncing after a miss.
105 func findMatchingOccupant(l *lattice.Lattice, tag string) *lattice.Node {
106 // Random start to avoid always landing on the same node.
107 start := l.RandomNode()
108 if start == nil {
109 return nil
110 }
111 // Walk from random start looking for a match.
112 current := start
113 for i := 0; i < 100; i++ {
114 occ := current.Occupant()
115 if occ != nil && occ.Type() == tag {
116 return current
117 }
118 next := lattice.RandomNeighbor(current)
119 if next == nil {
120 break
121 }
122 current = next
123 }
124 return nil
125 }
126