aboutsummaryrefslogtreecommitdiff
path: root/tests/DESIGN-forest-from-spans.md
diff options
context:
space:
mode:
Diffstat (limited to 'tests/DESIGN-forest-from-spans.md')
-rw-r--r--tests/DESIGN-forest-from-spans.md413
1 files changed, 413 insertions, 0 deletions
diff --git a/tests/DESIGN-forest-from-spans.md b/tests/DESIGN-forest-from-spans.md
new file mode 100644
index 0000000..8ff81d4
--- /dev/null
+++ b/tests/DESIGN-forest-from-spans.md
@@ -0,0 +1,413 @@
+# Design: exact recognizer and forest from facts
+
+Written by Claude Code (Opus). This is the plan to follow; the source
+changes are for the user to write by hand. Claims are marked MEASURED
+(shown by a run in the scratch copy), READ (read from the code) or
+INFERRED.
+
+## 1. Goal and approach
+
+The parser's forest must represent exactly the derivation trees of the
+input. The approach:
+
+- **Part A, an exact recognizer.** Fix five recognizer bugs (section 3).
+ Recognition alone must be right and fast; it is also the base of Part
+ B.
+- **Part B, the forest from facts.** While recognizing, record facts
+ `(X, i, j)` ("the nonterminal `X` derives `input[i..j]`"). After the
+ input, build the forest top-down from `(S, 0, n)` using only the facts,
+ the grammar and the input (section 4). The forest is then a function
+ of a set of facts: it does not depend on the order in which edges are
+ processed. Extra facts are harmless, because every forest node is
+ checked down to terminals and ε, so correctness reduces to "no fact is
+ missing", which the tests check against the oracle.
+
+The current incremental forest code (`insert_item` and everything under
+it) is replaced, not repaired. MEASURED: its result depends on the hash
+order of `Edge` (`labels_of` iterates a hash map,
+`graph/src/labelled/double.rs:20`).
+
+Everything in this document is prototyped and verified in the scratch
+copy (section 7). No step needs further research.
+
+## 2. How the recognizer works (READ, partly INFERRED)
+
+From `chain/src/default.rs` (`derive`, `generate_edges`, `extend`),
+`chain/src/lib.rs` (`Collapsed`, `chain`) and
+`chain/src/atom/default.rs` (`from_grammar`).
+
+- **Chain graph.** A graph-structured stack. An edge `(label, target)`
+ out of a node means: "inside a nonterminal invocation (a *frame*), the
+ atom NFA is in state `label`; when the frame completes, continue with
+ the edges of `target`".
+- **Nodes never change.** `DLGraph::extend`
+ (`graph/src/labelled/double.rs:395`) builds a node whole from edges
+ whose targets are older nodes. Nothing adds edges to a node later.
+- **NFA states.** NFA nodes `2·pos` and `2·pos+1` belong to rule
+ position `pos`; `get_rule_num(pos)` is the nonterminal owning it.
+ **These states are shared by all nonterminals** (MEASURED): for
+ `S -> X d; X -> Y c; Y -> a`, the state `Y -> a ·` has the edge
+ `c -> X -> Y c ·` in every frame. Virtual nodes `[nt]^t` come after
+ all positions. Within one frame the NFA runs through left-corner
+ chains, so every nonterminal completed inside a frame starts at the
+ frame's start.
+- **Calls.** A nonterminal in a non-leading position starts a new frame
+ at the current position `p`. `derive` groups all callers of one
+ virtual node (`add_second_layer`); `Collapsed` then creates one
+ *second-layer* node `M` with their continuations, and the edge
+ `(v, M)`.
+- **Pops.** A transition to an accepting state "pulls in" the target's
+ edges: the frame completes and the caller continues. Reaching the end
+ of a rule with nothing after it (an *empty node*) gives no real edge,
+ only an *imaginary* edge `Im(target)` that carries acceptance.
+- **Tail calls.** If the caller has nothing left after the call, no node
+ is kept for it. So the target of an edge does not tell where its frame
+ started (for `S -> a S | a` every edge points to the root).
+
+## 3. Part A: recognizer bugs and fixes
+
+Each bug has a test in `tests/oracle.rs` (section 6).
+
+### 3.1 Add `Edge::frame_nt`
+
+The nonterminal of the edge's frame, part of `Eq` and `Hash`. Needed by
+3.4 and 3.5.
+
+| Edge | `frame_nt` |
+|---|---|
+| Continuing edge made by `generate_edges` (`Edge::new(atom_child, …)`) | the `frame_nt` of the edge it comes from |
+| Edge into a new frame: `Collapsed`, label = virtual node `[nt]^t` | `nt` |
+| Edge into a new frame: the tail-call edge `Edge::new(virtual_node, …)` in `derive` | `non` |
+| Pulled-in edge | its own |
+| Initial edge from `unit` | the start symbol |
+
+### 3.2 `DefaultChain::extend` decides acceptance from the first label
+
+- **Bug:** the loop over the new node's labels (hash order) stops at the
+ first label: "not accepting" if none of its targets accepts,
+ "accepting" if the label accepts. The result depends on the order.
+- **Fix:** the node accepts iff some accepting label has an accepting
+ target.
+- Latent today; it shows once edges with the same label are not merged
+ (3.1).
+
+### 3.3 Moves into rules outside the frame
+
+- **Bug:** because states are shared, inside a frame of `T` the end of
+ `T` continues into `E -> E + · T` (`T` is a left corner of `E`).
+ - `S -> b | a; A -> A S A | eps` accepts `a b b b`.
+ - `E -> E + T | T; T -> T * n | n` on `n + n + … + n`: every `+`
+ leaves a phantom `E` inside a `T` frame; maximum node degree 21 at 41
+ tokens, total edges quadratic (MEASURED).
+- **Fix:** inside a frame of `X`, allow a transition `label -> a` only if
+ `a` is not a rule position, or the nonterminal owning `a`
+ (`get_rule_num(a/2)`) is in `LC*(X)`.
+ - `LC*(X)`: `X` itself, and for every rule `X -> Y1 … Ym` and every `k`
+ such that `Y1 … Y(k-1)` are nullable and `Yk` is a nonterminal,
+ everything in `LC*(Yk)`. Compute it once from the grammar (a boolean
+ matrix in the grammar or the atom).
+ - Apply it to the atom children in the main loop of `derive`, and in
+ the fact recording (4.3). Checking the target state is enough
+ (INFERRED): a composed edge climbs only through left corners of the
+ target's nonterminal, which are then in `LC*(X)` too.
+- With the fix the expression grammar has maximum degree 2 and linear
+ edges (MEASURED).
+
+### 3.4 Acceptance is one flag per state
+
+- **Bugs:**
+ - `S -> X d; X -> Y c; Y -> a` accepts `a c`: `X -> Y c ·` accepts
+ because `X` ends there, and that flag also ends the frame of `S`.
+ - `S -> A A; A -> a | eps` rejects `a`: the virtual node `[S]^a`
+ ignores its left-linearly expanded children ("should not affect the
+ nullability" block in `chain/src/atom/default.rs`). Also
+ `S -> Y; Y -> A | B; A -> x; B -> x` on `x`, and
+ `S -> A; A -> B C; B -> x | eps; C -> y | eps` on `x`.
+- **Fix:** acceptance relative to the frame's nonterminal.
+ - In `DefaultAtom::from_grammar`, before the closure, compute for every
+ nonterminal `x` the NFA nodes that reach the end of `x` (node
+ `accumulators[x] + 1`) through nullable edges: the same propagation
+ loop as `accepting_vec`, seeded with that one node. Store it as
+ `acc_by_nt[x][node]`.
+ - `is_accepting_in(x, node)`: `acc_by_nt[x][node]` for a node of a rule
+ position; `accepting_vec[node]` for other nodes (end of rules,
+ virtual nodes).
+ - `generate_edges` takes `frame_nt` and uses
+ `is_accepting_in(frame_nt, atom_child)` for the edge's `accepting`
+ flag and for pulling in the target's edges.
+ - The virtual node `[nt]^t` accepts iff some child, left-linear or not,
+ has `acc_by_nt[nt][child]`.
+
+### 3.5 Imaginary edges are ignored next to real edges
+
+- **Bug:** `Chain::chain` calls `update_epsilon` only when the new node
+ has no real edges, and `Collapsed` keeps only the real edges of a
+ second-layer group. So a completion exactly at the end of a rule
+ (`Im(target)`) is lost when other edges exist. Hidden today by the
+ global acceptance of 3.4: with 3.4 alone, `S -> B a; B -> a B | eps`
+ rejects `a a`.
+- **Fix:** in `chain`, always call `update_epsilon` (it only ever sets
+ acceptance to true); in `Collapsed`, after creating `M`, call
+ `update_epsilon(M, …)` with the group's imaginary edges. Do 3.4 and
+ 3.5 in the same change.
+
+### 3.6 Reduction lists lose nonterminals (needed by Part B)
+
+- **Bug:** `transform_label_null_epsilon` (`grammar/src/lib.rs`) keeps
+ one list per pair of states: the longest, and on composition only the
+ second edge's list plus the end crossed in the middle.
+ `S -> Y c; Y -> A | B; A -> x; B -> x` on `x c` loses `B[0,1]`.
+- **Fix:** keep, per pair of NFA states, the *union* of all nonterminals
+ ended on any path (a reduction set): during the closure, union the
+ sets of both composed edges and the end crossed in the middle; when the
+ atom builds a virtual node, copy the union onto the virtual node's
+ edges. Keep the lists while the old forest code still uses them.
+
+## 4. Part B: the forest
+
+### 4.1 Start sets (only when a forest is requested)
+
+Edges do not carry their start. A side table maps each chain edge
+`(node, label, target, frame_nt)` to the set of starts of its frame,
+filled when the node is created, from edges of older nodes:
+
+| New edge | Its start set |
+|---|---|
+| continuing edge `(a, target)` from the edge `e` | `starts(e)` |
+| pulled-in edge copied from node `c` | the set of that edge in `c` |
+| edge into a new frame (`(v, M)`, tail-call edge `(v, target)`) | `{p}` |
+| edges of a second-layer node `M` | by the same rules |
+| initial edge from `unit` | `{0}` |
+
+Edges that merge in `extend` take the union of their sets.
+
+**Why this is exact (READ, MEASURED).** A node is built whole from
+older nodes and never changes, and all callers of one call are grouped
+before `M` is created. So no start can reach an edge after the edge's
+successors exist. Keep the check "every real edge has a non-empty set"
+as a debug assertion. If nodes are ever shared across positions
+(memoization), this argument must be redone.
+
+### 4.2 Facts recorded while reading token `t` at position `p`
+
+For every edge `e = (label, children)` of the current node, and every
+transition `label -> a` on `t` or on a call `Non(non)` with `t` in
+FIRST(non), after the left-corner filter (3.3):
+
+- **E1, inner completions.** For every `X` in the reduction set of
+ `(label, a)` (3.6) and every `s` in `starts(e)`: `(X, s, p)`.
+- **E2, frame completion after a terminal.** If `t` is read and
+ `is_accepting_in(e.frame_nt, a)`:
+ - for every `s` in `starts(e)`: `(e.frame_nt, s, p+1)`, and the *head
+ event* `(H, s, p+1)` with `H = get_rule_num(a / 2)`;
+ - walk down the stack: for every child node of `e`, every edge `l` of
+ it with `is_accepting_in(l.frame_nt, l.label)` completes too; record
+ the same for `l` with its start set, and continue from `l`'s targets.
+- **E3, calls.**
+ - Every call: the single-token event `(t, p)`, standing for
+ `(X, p, p+1)` for every `X` that derives exactly `t` (precompute per
+ terminal from the grammar).
+ - If the virtual node `v` accepts: `(non, p, p+1)`.
+ - If `a` also accepts for the caller's frame: as E2 with `e`.
+- **E4, tail calls.** If `a` is an empty node: for every `s` in
+ `starts(e)`, the link `(p → s, e.frame_nt, H = get_rule_num(a/2))`.
+
+Facts with `i == j` are not recorded: nullability comes from the
+grammar. Facts are not closed during the parse (4.3).
+
+### 4.3 Building the forest top-down
+
+- **`closed(j)`**: the facts ending at `j` (E1-E3, single-token events
+ expanded), closed
+ - *upward*: a head event `(H, i, j)` also gives `(Y, i, j)` for every
+ `Y` reachable from `H` through rules `Y -> α H β` with `α`, `β`
+ nullable;
+ - *through tail links*: if a fact in `closed(j)` has start `p` and a
+ link starts at `p`, add `(caller_nt, caller_start, j)` and the upward
+ closure of `H` at `(caller_start, j)`; repeat.
+
+ Compute `closed(j)` once per `j`, on the first demand. Closing all
+ facts eagerly would be quadratic on right recursion (`S -> a S | eps`:
+ 3240 facts at 80 tokens, against 80 needed; MEASURED).
+- **Allowed keys.** `(Y, k, l)` is allowed if `k < l` and `(Y, k)` is in
+ `closed(l)`, or `k == l` and `Y` is nullable.
+- **Alternatives of `(X, i, j)`.** For each production `X -> Y1 … Ym`,
+ find split points from right to left: `Ym` ends at `j` (a terminal
+ equal to `input[j-1]`, or an allowed key `(Ym, k, j)`), then `Y(m-1)`
+ ends at `k`, and so on; `Y1` must start at `i`. An ε production is the
+ alternative with `m = 0` and `i == j`. Right to left makes every query
+ "facts ending at `k`", the unit of the lazy closure.
+- **Visit** from `(S, 0, n)`, computing the alternatives of each key
+ reached. This gives a finite graph of keys and alternatives.
+- **Productive keys**: least fixpoint on that graph (a key is productive
+ if one of its alternatives has only productive key children). Cycles
+ over equal spans (Case 5) are kept; keys with no finite derivation are
+ dropped.
+- **The forest**: the productive keys reachable from `(S, 0, n)` through
+ productive alternatives. If `(S, 0, n)` is not productive there is no
+ root. After Part A this agrees with `chain.epsilon()`; keep a debug
+ assertion.
+
+### 4.4 Output format
+
+The format `unpack` in `tests/oracle.rs` reads:
+
+- one nonterminal node per key, `start = i`, `end = Some(j)`;
+- one alternative: a plain node whose children are, in order, one
+ `Rule(slot)` wrapper per symbol of the production, each wrapper with
+ exactly one child;
+- several alternatives: a packed node with one cloned node per
+ alternative;
+- an ε alternative: a nonterminal node with no children;
+- terminal leaves shared per position, wrappers per `(slot, start, end)`;
+- `slot` is the atom's position number (`print_rule_positions`);
+- check with `validate_forest`.
+
+### 4.5 Why this is exact (INFERRED from the construction)
+
+- **No extra trees:** every node is checked down to terminals and ε.
+- **No missing trees:** if the facts cover the oracle, every node of
+ every tree is a productive key with that alternative, reached from the
+ root.
+- **No duplicates:** alternatives are unique per (production, splits),
+ nodes per key.
+
+### 4.6 Cost
+
+- **Recognition alone:** no side table, no facts. The fixes of Part A
+ make the chain smaller where states leaked between frames.
+- **Recording:** O(edges + start-set entries + facts E1-E3).
+- **Forest:** about the facts in the visited `closed(j)` plus the
+ alternatives built.
+- **Measured sizes** (scratch, all fixes):
+
+ | Grammar | tokens | chain edges | max degree | start-set entries | facts needed | keys visited |
+ |---|---|---|---|---|---|---|
+ | `S -> a S \| eps` | 80 | 81 | 1 | 80 | 80 | 81 |
+ | `S -> S a \| a` | 80 | 81 | 1 | 80 | 79 | 80 |
+ | `S -> a S b \| eps` | 80 | 119 | 1 | 119 | 78 | 41 |
+ | `E -> E + T \| T; T -> T * n \| n` | 79 | 158 | 2 | 155 | 119 | 80 |
+ | `S -> S S \| a` | 80 | 6401 | 80 | 6242 | 3240 | 3240 |
+
+ Linear for the first four (also at 20 and 40 tokens); quadratic for
+ `S -> S S | a`, whose forest has n²/2 keys anyway.
+- **Flat output:** a node may have O(n^(m-1)) alternatives for a
+ production of length m. Binarised nodes `(slot, i, k)` would give
+ O(n³) but need `unpack` changes; later.
+
+## 5. Plan
+
+Each step has a test that fails before it and passes after it.
+
+### Part A: exact recognizer
+
+1. **Recognition gate**: port the scratch recognition check into
+ `tests/oracle.rs`. On the hand cases and the random grammars, "the
+ recognizer accepts" must equal exact derivability (no height bound).
+ It fails today.
+2. **`extend` acceptance** (3.2).
+3. **`Edge::frame_nt`** (3.1).
+4. **Left-corner filter** (3.3).
+5. **Frame-relative acceptance and imaginary edges** (3.4 and 3.5, one
+ change).
+6. **Gate passes** with zero mismatches; all recognition tests pass.
+ Time the recognizer on long inputs before and after Part A (only
+ chain size has been measured so far).
+
+### Part B: forest
+
+7. **Reduction sets** (3.6).
+8. **Start-set side table** (4.1), only when a forest is requested.
+9. **Record facts E1-E4** (4.2).
+10. **Facts gate**: port `probe_facts` from scratch into
+ `tests/oracle.rs` as real tests on the hand cases and random
+ grammars. A test-only eager closure of the recorded facts must
+ satisfy "no missing fact" and "derivable from facts == exactly
+ derivable". This checks the recording alone.
+11. **Top-down forest in `end_of_input`** (4.3, 4.4), with the chain run
+ with `no_item = true`. Compare the forest with the oracle on the
+ same cases. Delete `insert_item` and the old forest code once the
+ suite passes.
+12. **Full suite**, including layers 3 to 6 and every regression test
+ (they compare derivation sets, so they stay valid).
+13. **Later:** binarised nodes, memoization (redo 4.1's argument),
+ incremental reparsing from the saved chain node at an edit point
+ (facts and start sets before it stay valid).
+
+## 6. Tests
+
+In `tests/oracle.rs` (and the scratch copy):
+
+| Test | Today | After |
+|---|---|---|
+| `open_recognition_nullable_rest_of_virtual_node` | fails | passes after step 5 |
+| `open_recognition_inner_rule_end_is_not_frame_end` | fails | passes after step 5 |
+| `open_recognition_unreachable_rule_accepts_too_much` | fails | passes after step 5 |
+| `open_recognition_expression_grammar_node_degree_stays_bounded` | fails (degree 21) | passes after step 4 |
+| `guard_recognition_rule_end_completion_next_to_other_edges` | passes | must keep passing; fails if 3.4 is done without 3.5 |
+
+**The 9 forest tests that fail today** (`layer3_case_3`,
+`layer3_case_4`, `layer3_nullable_in_the_middle`,
+`layer4_hidden_left_recursion`, `layer5_case_5a`, `layer5_case_5c`,
+`layer5_case_5d`, `layer5_highly_ambiguous_with_epsilon`,
+`open_indirect_left_recursion`) fail in the old forest code. Part A
+does not change them (MEASURED). The top-down prototype gives exactly
+the oracle's trees on every grammar and input of those tests (MEASURED,
+height 5), so they are expected to pass after step 11. If one does not,
+the step 10 tests tell whether the recording or the construction is
+wrong.
+
+## 7. Evidence (MEASURED in scratch, recognizer run with `no_item = true`)
+
+With all fixes, start sets in a side table and `frame_nt` on edges:
+
+- **27 hand cases** (right, left, hidden, indirect and nested left
+ recursion; `S S`, `S S S`, `S S | S a`, `E + E`, `E + T / T * n`;
+ nullable rules; the cyclic grammars of Case 5; unit chains through two
+ nonterminals; 8-11-token ambiguous inputs) and **1194 random grammars**
+ (the layer-6 generator, six seeds, height 4):
+ - recognition equals exact derivability (no false acceptance or
+ rejection);
+ - no fact missing; "derivable from facts" equals exact derivability;
+ - every real edge has a non-empty start set;
+ - the top-down construction's trees equal the oracle's.
+- With the fixes on, the old forest code's suite results do not change.
+- Tree sets were compared at oracle height 4 to 6; recognition and
+ derivability exactly.
+
+## 8. Open points
+
+- That a pulled-in edge keeps the start set and frame nonterminal of its
+ own frame is INFERRED from the code and supported by the measurements,
+ not proven; the step 10 tests guard it.
+- The empty input is not supported by the test harness yet; `(S, 0, 0)`
+ must be allowed when `S` is nullable.
+
+## 9. Scratch reference
+
+The scratch copy `<repo>/claude scratch/ws` (build dir
+`<repo>/claude scratch/target`) holds the prototypes. It is not the
+user's tree: it also contains obsolete forest edits and `NOFIX_*`
+switches, which must not be ported. The prototypes are probes that
+mirror `derive` in a separate pass; the real code should record inside
+the actual edge generation.
+
+| What | Where (scratch) | Switch |
+|---|---|---|
+| `Edge::frame_nt` | `chain/src/lib.rs` | `PROBE_START=1 NO_START=1` |
+| 3.2 `extend` | `chain/src/default.rs` | `FIX_EXTEND=1` |
+| 3.3 left-corner filter (`lc_ok`) | `chain/src/default.rs`; table from the test via `LC_TABLE` | `FIX_LC=1` |
+| 3.4 `acc_by_nt`, `is_accepting_in` | `chain/src/atom/default.rs` | `FIX_FACC=1` |
+| 3.5 imaginary edges | `chain/src/lib.rs` (`Collapsed`, `chain`) | `FIX_IMAG=1` |
+| 3.6 reduction sets | `grammar/src/lib.rs` (`reduction_sets`) | `FIX_REDSET=1` |
+| 4.1 start sets (`resolve_origins`, `orig`) and 4.2 facts | `chain/src/default.rs` | `ORIGINS=1` |
+| 4.3 top-down prototype | `tests/oracle.rs`, test `probe_facts` | `PF_TOPDOWN=1` |
+
+Full configuration:
+`env PROBE_START=1 NO_START=1 ORIGINS=1 FIX_REDSET=1 FIX_EXTEND=1 FIX_LC=1 FIX_FACC=1 FIX_IMAG=1 PF_TOPDOWN=1`
+(write the switches literally; zsh does not split `env $v`).
+`probe_facts` options: `PF_GRAMMAR`/`PF_INPUT` (one case),
+`PF_RANDOM=n` with `REP_SEED`, `PF_NOORACLE=1` (sizes only).
+`probe_atom` prints the atom NFA of `PF_GRAMMAR`. Recognition tests run
+with `FIX_LC` need `LC_TABLE` by hand (`"0,1;1"` for the expression
+grammar).