aboutsummaryrefslogtreecommitdiff
path: root/tests/DESIGN-leading-chains.md
diff options
context:
space:
mode:
Diffstat (limited to 'tests/DESIGN-leading-chains.md')
-rw-r--r--tests/DESIGN-leading-chains.md153
1 files changed, 153 insertions, 0 deletions
diff --git a/tests/DESIGN-leading-chains.md b/tests/DESIGN-leading-chains.md
new file mode 100644
index 0000000..72421ad
--- /dev/null
+++ b/tests/DESIGN-leading-chains.md
@@ -0,0 +1,153 @@
+# Design proposal: record leading chains directly (replaces `expansion_map`)
+
+Written by Claude Code (session 2, 2026-10-14) at the user's request, as a
+PROPOSAL for the user to confirm and implement by hand. Nothing here has been
+applied to the repository. Items marked MEASURED come from runs on a scratch
+copy; INFERRED means read from code; OPEN means a decision for the user.
+
+## 1. Problem (MEASURED)
+
+For `T -> S x ; S -> S a | a` the stored virtual fragment of `[T]^a` is
+`S -> {R8 -> S (back edge), R6 -> a}`: it has no `T` node and no `T -> R2 -> S`
+link, whereas for the non-recursive `S -> A b ; A -> a` the fragment of `[S]^a`
+is `S -> R2 -> A -> R6 -> a`. Tracing the writes to `expansion_map` (debug
+prints, key `(0,13)` = start state of `T` to the `a`-edge target):
+
+1. The composition through `second_source = 8` (S's start state) writes the
+ correct `[(1,6),(0,2)]`.
+2. A later composition through `second_source = 16` takes the branch
+ `!second_nt_start`, and since the existing entry (2 pairs) is shorter than
+ `(16,13)`'s `[(1,6),(1,8),(1,8)]` (3 pairs), the guard "longer wins"
+ overwrites the correct entry. The copy has no `(0,2)`.
+
+INFERRED root cause: the entries are written as a side effect of the iterative
+null-closure; with a left-recursive nonterminal the lists grow with repeated
+`(S, R8)` pairs, so "longer" no longer means "more complete", and the branch
+that copies `(second_source, second_target)` forgets what the first edge added.
+
+## 2. What is actually consumed (INFERRED, by grep)
+
+- `query_expansion` is called from `atom/default.rs` (`from_grammar`, the loop
+ that fills `virtual_frags`) always as `(nt_start, child)`, and from
+ `genins.rs::virtual_generate_fragment`, which only the unit tests use.
+ So only entries whose first component is the START STATE of a nonterminal
+ are ever read.
+- The consumer wants, for each virtual node `[N]^t` and each final slot `r` (the
+ slot of the terminal `t`): a forest fragment
+ `N -> Rule(w1) -> X1 -> Rule(w2) -> X2 ... -> Rule(r) -> t`.
+ `virtual_frags[VirtualNode(N,t)][r]` stores it, `trace(N,t,target)` gives `r`.
+- The climb needs, for condition (1): given the closed nonterminal `Y`, the
+ fragment slot `r` and the head `h = get_rule_num(r)`, the wrapper slot `w` of
+ `Y` as leading symbol of `h`'s rule. Today `left_wrapper` (my scratch code)
+ scans the closure NFA's `left_p` edges for this on every call.
+
+## 3. Proposed record: the leading graph, read from the NFA BEFORE the closure
+
+In `DefaultAtom::from_grammar`, right after `grammar.left_closure_to_nfa(&regexp)`
+and before `nfa.closure(...)`, the NFA of each nonterminal `N` has at its start
+state `start(N)` exactly one edge per possible first literal of `N`
+(`first_nodes_of`), labelled with the symbol (`Ter(t)` or `Non(Y)`) and carrying
+the slot `w` (`get_moved()`). Read these once:
+
+```text
+lead[N] : Vec<LeadEdge>
+LeadEdge { symbol: TNT, slot: usize, target: usize /* NFA state after it */ }
+```
+
+From `lead` compute two tables, once, at construction:
+
+### 3.1 `chains[(N, t)] : Vec<Chain>` (replaces the use of `expansion_map`)
+
+`Chain = Vec<(nt, slot)>`, outermost first: `nt` is a nonterminal node and `slot`
+is the wrapper that is its child; the last slot is the one of the terminal.
+
+```text
+fn collect(N, t, path: Vec<(nt,slot)>, on_path: Set<nt>):
+ for e in lead[N]:
+ match e.symbol:
+ Ter(t') if t' == t => emit path ++ [(N, e.slot)]
+ Non(Y) if Y not in on_path => collect(Y, t, path ++ [(N, e.slot)], on_path + {N})
+ Non(Y) (Y on the path) => record back edge (N, e.slot, Y); do not recurse
+```
+
+Chains are SIMPLE paths: no nonterminal repeats, so no cycle, and the fragment
+needs no cut (`remove_prefix_cut_cycles` and the cycle problem disappear).
+The fragment is `generate_fragment(chain flattened, 0)` as today; the key
+`r` is the last slot, as today. For `T -> S x; S -> S a | a` this yields
+`[(T,2),(S,6)]` = `T -> R2 -> S -> R6 -> a`, which is the missing fragment.
+Left recursion (`S -> S a`) shows up only as a back edge, not as a chain.
+
+### 3.2 `left_wrapper[(h, Y, r)] -> w` (replaces my scan in `left_wrapper`)
+
+```text
+for each head h, each e in lead[h] with e.symbol = Non(Y):
+ for each out-edge (moved = r) of NFA state e.target:
+ left_wrapper[(h, Y, r)] = e.slot
+```
+
+Meaning: in a rule of `h`, `Y` is a leading symbol with wrapper slot `w =
+e.slot`, and slot `r` is what may follow it. The climb's condition (1) becomes
+a single lookup. This also covers the back edges of 3.1.
+
+## 4. What changes in the existing code (for the user to decide)
+
+1. `grammar/src/lib.rs::transform_label_null_epsilon`: stop writing
+ `expansion_map` (keep the `left_p` computation and the reduction recording).
+ `query_expansion` / the field can go once `virtual_generate_fragment` (test
+ only) is rewritten on top of `chains`.
+2. `chain/src/atom/default.rs` (~487): build `virtual_frags` from `chains`
+ instead of `grammar.query_expansion(nt_start, child)`.
+3. `chain/src/item/genins.rs`: `left_wrapper` becomes a lookup in 3.2 (an
+ accessor on `DefaultAtom`).
+4. `plant_at_start`: `remove_prefix(1)` stays as it was; no cycle cut needed.
+ NOTE the existing assumption "the fragment root is the owner of the virtual
+ node": with `chains` the root is `N` itself, so it is true again also for
+ `T -> S x` (today the root is `S`, which is wrong).
+
+## 5. OPEN questions for the user
+
+1. Several chains with the same final slot `r` (e.g. `N -> A -> X`,
+ `N -> B -> X`): `virtual_frags.insert(rule, frag)` today keeps only the last.
+ They are different derivations, so they should become alternatives (clones
+ of the root, as `virtual_generate_fragment` in genins.rs already does).
+ Should `virtual_frags` store `Vec<frag>` per `(node, r)`?
+2. Nullable leading symbols (a nullable `A` before `x`): `first_nodes_of`
+ already lists the literal after it as a first literal, but the forest needs
+ the epsilon derivation of `A` in the chain. Defer to layer 3?
+3. Is it acceptable to read `lead` from the NFA before the closure, or do you
+ prefer to compute it in the `grammar` crate (`left_closure.rs`)?
+4. `reduction_map` is built by the same iterative closure with the same
+ "longer wins" guard. It may have an analogous defect (not measured). A
+ similar direct computation would be: for an edge `q -> q'` the list of
+ nonterminals whose exit state is crossed on the null path. Out of scope
+ until the expansion part is settled.
+
+## 6. Test plan (before any change to the climb)
+
+- Unit test on the atom: `chains` for
+ `S -> A b; A -> a` = `[[(S,2),(A,6)]]`,
+ `T -> S x; S -> S a | a` = `[[(T,2),(S,6)]]` with back edge `(S, 8, S)`,
+ `S -> S a | a` = `[[(S,2)]]` with back edge `(S, 4, S)`,
+ indirect `S -> A b | d; A -> S c` for `(S, d)` = `[[(S,slot of d)]]` and
+ for `(A, d)` = `[[(A,w),(S,slot of d)]]`.
+ (Slot numbers vary with the grammar encoding; assert structure, not numbers.)
+- Then in the scratch copy: swap the source of `virtual_frags`, rerun the oracle
+ probe set (`T -> S x`, indirect cases first), and compare with the current
+ table of results.
+
+## 7. Status (end of session 2)
+
+Prototyped in a scratch copy as described in `tests/CLAUDE-PROGRESS.md`
+("LEADING CHAINS PROTOTYPE"). Findings that refine this design:
+
+- The leading edges must be read through epsilon edges, and unlabelled edges
+ count as epsilon (see 3. above); do not follow an epsilon edge that enters
+ another nonterminal's start state.
+- `chains[(T,a)]` for `T -> S x; S -> S a | a` is `[(T,2),(S,6)]`, which
+ fixes `T -> S x` and the indirect case `S -> A b; A -> S c | d` on `d b`.
+- Consumers of fragments: `insert_item` (virtual branch) now loops over all
+ alternatives; `close_pavi` already iterated. Parents pushed by several
+ alternatives with the same final wrapper must be deduplicated.
+- Back edges are dropped by `chains`. For cyclic grammars (`S -> S | a`) the
+ cycle then has to be created by the completer; this is the remaining design
+ question of layer 5.