diff options
Diffstat (limited to 'tests/oracle.rs')
| -rw-r--r-- | tests/oracle.rs | 1803 |
1 files changed, 1803 insertions, 0 deletions
diff --git a/tests/oracle.rs b/tests/oracle.rs new file mode 100644 index 0000000..cab790f --- /dev/null +++ b/tests/oracle.rs @@ -0,0 +1,1803 @@ +//! Brute-force derivation-tree oracle. +//! +//! WRITTEN BY CLAUDE CODE (an AI assistant), at the user's request, as +//! step 1 of the debugging workflow in `CLAUDE.md`. It is deliberately +//! naive and independent of the parser: it knows nothing about the +//! `chain`, `forest` or `grammar` crates. +//! +//! Input: a context-free grammar, a token sequence and a height bound +//! `h`. Output: the set of ALL derivation trees of the whole token +//! sequence whose height is at most `h`, as canonical S-expression +//! strings in the notation of `forest-examples.md`: +//! +//! - a nonterminal node is `(Symbol child child ...)`; +//! - a terminal is the bare token; +//! - an empty production is `eps`, so `A -> eps` gives `(A eps)`. +//! +//! The height of a tree is the maximum number of nonterminal nodes on +//! any root-to-leaf path. Cyclic derivations (such as `S -> S`) are not +//! pruned: the height bound is what makes the search terminate. +//! +//! Method: top-down. For a nonterminal over a span, try every +//! production, and for every production try every way of cutting the +//! span into consecutive pieces for its symbols. Nothing is memoized. +//! +//! Every case of `forest-examples.md` is checked against this oracle +//! at the bottom of this file, before the oracle is trusted. + +use std::collections::BTreeSet; + +/// A grammar: rules per nonterminal; the first rule's left-hand side +/// is the start symbol. An empty alternative is an epsilon production. +#[derive(Debug, Clone)] +pub struct Grammar { + start: String, + rules: Vec<(String, Vec<Vec<String>>)>, +} + +impl Grammar { + /// Parse a grammar from lines such as `S -> S S | a`. Symbols are + /// separated by whitespace. A symbol is a nonterminal iff it is the + /// left-hand side of some line. An alternative that is empty or is + /// the single word `eps` is an epsilon production. Several lines + /// with the same left-hand side are merged. + pub fn parse(text: &str) -> Grammar { + let mut rules: Vec<(String, Vec<Vec<String>>)> = Vec::new(); + + for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) { + let (lhs, rhs) = line + .split_once("->") + .unwrap_or_else(|| panic!("bad grammar line: {line}")); + let lhs = lhs.trim().to_string(); + + let alternatives: Vec<Vec<String>> = rhs + .split('|') + .map(|alt| { + let symbols: Vec<String> = + alt.split_whitespace().map(str::to_string).collect(); + + if symbols == ["eps"] { + Vec::new() + } else { + symbols + } + }) + .collect(); + + match rules.iter_mut().find(|(name, _)| *name == lhs) { + Some((_, existing)) => existing.extend(alternatives), + None => rules.push((lhs, alternatives)), + } + } + + let start = rules.first().expect("empty grammar").0.clone(); + + Grammar { start, rules } + } + + fn is_nonterminal(&self, symbol: &str) -> bool { + self.rules.iter().any(|(name, _)| name == symbol) + } + + fn alternatives(&self, nonterminal: &str) -> &[Vec<String>] { + self.rules + .iter() + .find(|(name, _)| name == nonterminal) + .map(|(_, alts)| alts.as_slice()) + .unwrap_or(&[]) + } +} + +/// Split a whitespace-separated token string. `""` is the empty input. +pub fn tokens(text: &str) -> Vec<String> { + text.split_whitespace().map(str::to_string).collect() +} + +/// All derivation trees of `tokens` from the start symbol of `grammar` +/// whose height is at most `h`. +pub fn oracle_trees(grammar: &Grammar, tokens: &[String], h: usize) -> BTreeSet<String> { + nonterminal_trees(grammar, &grammar.start, tokens, 0, tokens.len(), h) +} + +/// Trees for `nonterminal` deriving `tokens[i..j]` with height <= `h`. +fn nonterminal_trees( + grammar: &Grammar, + nonterminal: &str, + tokens: &[String], + i: usize, + j: usize, + h: usize, +) -> BTreeSet<String> { + let mut out = BTreeSet::new(); + + // A nonterminal node itself counts one towards the height. + if h == 0 { + return out; + } + + for alternative in grammar.alternatives(nonterminal) { + if alternative.is_empty() { + if i == j { + out.insert(format!("({nonterminal} eps)")); + } + + continue; + } + + for children in sequence_trees(grammar, alternative, tokens, i, j, h - 1) { + out.insert(format!("({nonterminal} {})", children.join(" "))); + } + } + + out +} + +/// Every way to derive `tokens[i..j]` from the sequence of `symbols`, +/// each child having height at most `child_h`. Each result is the +/// list of the children's trees, in order. +fn sequence_trees( + grammar: &Grammar, + symbols: &[String], + tokens: &[String], + i: usize, + j: usize, + child_h: usize, +) -> Vec<Vec<String>> { + let Some((first, rest)) = symbols.split_first() else { + return if i == j { vec![Vec::new()] } else { Vec::new() }; + }; + + let mut out = Vec::new(); + + // Try every cut point for the first symbol. + for k in i..=j { + let first_trees: BTreeSet<String> = if grammar.is_nonterminal(first) { + nonterminal_trees(grammar, first, tokens, i, k, child_h) + } else if k == i + 1 && tokens[i] == *first { + BTreeSet::from([first.clone()]) + } else { + BTreeSet::new() + }; + + if first_trees.is_empty() { + continue; + } + + let rest_trees = sequence_trees(grammar, rest, tokens, k, j, child_h); + + for first_tree in &first_trees { + for tail in &rest_trees { + let mut children = Vec::with_capacity(1 + tail.len()); + children.push(first_tree.clone()); + children.extend(tail.iter().cloned()); + out.push(children); + } + } + } + + out +} + +// --------------------------------------------------------------------- +// Validation of the oracle against `forest-examples.md`. +// --------------------------------------------------------------------- + +#[cfg(test)] +mod validate_oracle { + use super::*; + + fn set(items: &[&str]) -> BTreeSet<String> { + items.iter().map(|s| s.to_string()).collect() + } + + fn trees(grammar: &str, input: &str, h: usize) -> BTreeSet<String> { + oracle_trees(&Grammar::parse(grammar), &tokens(input), h) + } + + /// Case 1: `S -> S S | a` on `aaa`: exactly two trees. + #[test] + fn case_1_trees() { + assert_eq!( + trees("S -> S S | a", "a a a", 4), + set(&[ + "(S (S a) (S (S a) (S a)))", + "(S (S (S a) (S a)) (S a))" + ]) + ); + } + + /// Case 1: tree counts by input length are Catalan(n - 1). + #[test] + fn case_1_counts() { + for (n, expected) in [(1usize, 1usize), (2, 1), (3, 2), (4, 5), (5, 14)] { + let input = vec!["a"; n].join(" "); + + // Height n is enough for any binary tree with n leaves. + assert_eq!(trees("S -> S S | a", &input, n).len(), expected, "n = {n}"); + } + } + + /// Case 2: `E -> E + E | n` on `n + n + n`: exactly two trees. + #[test] + fn case_2() { + assert_eq!( + trees("E -> E + E | n", "n + n + n", 4), + set(&[ + "(E (E n) + (E (E n) + (E n)))", + "(E (E (E n) + (E n)) + (E n))" + ]) + ); + } + + /// Case 3: `S -> A A ; A -> a | eps`. + #[test] + fn case_3() { + let grammar = "S -> A A\nA -> a | eps"; + + assert_eq!( + trees(grammar, "a", 4), + set(&["(S (A a) (A eps))", "(S (A eps) (A a))"]) + ); + + // The empty input gives exactly ONE tree. + assert_eq!(trees(grammar, "", 4), set(&["(S (A eps) (A eps))"])); + } + + /// Case 4: `S -> a S | eps` on `a a`: exactly one tree. + #[test] + fn case_4() { + assert_eq!( + trees("S -> a S | eps", "a a", 6), + set(&["(S a (S a (S eps)))"]) + ); + } + + /// Case 5a: `S -> S | a` on `a`: exactly h trees for height h. + #[test] + fn case_5a() { + let g = "S -> S | a"; + + assert_eq!(trees(g, "a", 1), set(&["(S a)"])); + assert_eq!(trees(g, "a", 2), set(&["(S a)", "(S (S a))"])); + assert_eq!( + trees(g, "a", 3), + set(&["(S a)", "(S (S a))", "(S (S (S a)))"]) + ); + assert_eq!( + trees(g, "a", 4), + set(&["(S a)", "(S (S a))", "(S (S (S a)))", "(S (S (S (S a))))"]) + ); + } + + /// Case 5b: `A -> A | eps` on the empty input. + #[test] + fn case_5b() { + let g = "A -> A | eps"; + + assert_eq!(trees(g, "", 1), set(&["(A eps)"])); + assert_eq!(trees(g, "", 2), set(&["(A eps)", "(A (A eps))"])); + assert_eq!( + trees(g, "", 3), + set(&["(A eps)", "(A (A eps))", "(A (A (A eps)))"]) + ); + } + + /// Case 5c: `S -> A | a ; A -> S` on `a`; each trip adds 2 to the height. + #[test] + fn case_5c() { + let g = "S -> A | a\nA -> S"; + + assert_eq!(trees(g, "a", 1), set(&["(S a)"])); + assert_eq!(trees(g, "a", 2), set(&["(S a)"])); + assert_eq!(trees(g, "a", 3), set(&["(S a)", "(S (A (S a)))"])); + assert_eq!(trees(g, "a", 4), set(&["(S a)", "(S (A (S a)))"])); + assert_eq!( + trees(g, "a", 5), + set(&["(S a)", "(S (A (S a)))", "(S (A (S (A (S a)))))"]) + ); + } + + /// Case 5d: `S -> S S | S | a` on `a a`. `forest-examples.md` gives + /// no expected forest for this case. The counts below were derived + /// by hand by Claude Code, not taken from that file: + /// + /// - h = 1: none (`S -> S S` already needs height 2); + /// - h = 2: only `(S (S a) (S a))`; + /// - h = 3: the 4 trees `(S X Y)` with `X`, `Y` in `{(S a), (S (S a))}`, + /// and `(S (S (S a) (S a)))`: 5 trees. + #[test] + fn case_5d() { + let g = "S -> S S | S | a"; + + assert_eq!(trees(g, "a a", 1), set(&[])); + assert_eq!(trees(g, "a a", 2), set(&["(S (S a) (S a))"])); + + let h3 = trees(g, "a a", 3); + + assert_eq!(h3.len(), 5); + assert!(h3.contains("(S (S a) (S a))")); + assert!(h3.contains("(S (S (S a)) (S (S a)))")); + assert!(h3.contains("(S (S (S a) (S a)))")); + } + + /// Larger bounds only add trees. + #[test] + fn monotone_in_height() { + for (g, input) in [ + ("S -> S S | a", "a a a"), + ("S -> S | a", "a"), + ("S -> S S | S | a | eps", "a a"), + ] { + let mut previous = BTreeSet::new(); + + for h in 1..=4 { + let current = trees(g, input, h); + + assert!(previous.is_subset(¤t), "{g} on {input:?}, h = {h}"); + + previous = current; + } + } + } +} + +// --------------------------------------------------------------------- +// The parser side: build the parser, unpack its forest, compare it with +// the oracle, and check structural invariants. +// +// Also written by Claude Code. Everything below is TEST CODE ONLY; it +// reads the parser's forest through the public traits of `graph` and +// `forest` and never changes the parser. +// --------------------------------------------------------------------- + +#[cfg(test)] +mod parser_side { + use super::*; + + use chain::atom::DefaultAtom; + use chain::default::DefaultChain; + use chain::Chain; + use forest::Forest as ForestTrait; + use grammar::test_grammar_helper::scan_tnt; + use grammar::{GrammarLabelType, Nonterminal, Rule, Terminal, TNT}; + use graph::{Graph, LabelGraph}; + use nfa::default::regex::DefaultRegParser; + use nfa::DesRec; + use std::collections::{BTreeMap, HashMap}; + + type Forest = chain::item::default::DefaultForest<forest::ForestLabel<grammar::GrammarLabel>>; + + // ------------------------------------------------------------ + // Running the parser. + // ------------------------------------------------------------ + + /// The parser prints debug dumps to `output/...` relative to the + /// current directory. Run from a temporary directory so that they + /// neither fail nor litter the source tree. + fn init() { + static INIT: std::sync::Once = std::sync::Once::new(); + + INIT.call_once(|| { + let dir = std::env::temp_dir().join("rep-tests"); + std::fs::create_dir_all(dir.join("output")).unwrap(); + std::env::set_current_dir(dir).unwrap(); + }); + } + + /// Names for the symbols of a grammar, in the order of the parser's + /// numbering: nonterminal 0 is the start symbol. + #[derive(Debug, Clone)] + struct Names { + nons: Vec<String>, + ters: Vec<String>, + } + + /// A grammar together with its terminal alphabet. The alphabet is + /// explicit so that terminal numbers are stable between the grammar + /// and the input, even if some terminal is not used by any rule. + #[derive(Debug, Clone)] + struct Setup { + grammar: Grammar, + names: Names, + } + + impl Setup { + fn new(grammar_text: &str, input: &[String]) -> Setup { + let grammar = Grammar::parse(grammar_text); + + let nons: Vec<String> = grammar.rules.iter().map(|(n, _)| n.clone()).collect(); + + let mut ters: Vec<String> = Vec::new(); + + for (_, alternatives) in &grammar.rules { + for symbol in alternatives.iter().flatten() { + if !grammar.is_nonterminal(symbol) && !ters.contains(symbol) { + ters.push(symbol.clone()); + } + } + } + + for token in input { + if !ters.contains(token) { + ters.push(token.clone()); + } + } + + Setup { + grammar, + names: Names { nons, ters }, + } + } + } + + /// Letters only: `scan_tnt` accepts nothing else in a name. + fn letters(mut i: usize) -> String { + let mut out = String::new(); + + loop { + out.insert(0, (b'a' + (i % 26) as u8) as char); + + if i < 26 { + break; + } + + i = i / 26 - 1; + } + + out + } + + fn parser_grammar(setup: &Setup) -> grammar::Grammar { + let names = &setup.names; + + // `scan_tnt` reads `T<name>` / `N<name>` and looks `<name>` up + // among terminals / nonterminals respectively, so the two + // namespaces may reuse the same letters. + let ter_name = |i: usize| letters(i); + let non_name = |i: usize| letters(i); + + let mut parser: DefaultRegParser<TNT> = Default::default(); + + for i in 0..names.ters.len() { + parser.add_tnt(&ter_name(i), true); + } + for i in 0..names.nons.len() { + parser.add_tnt(&non_name(i), false); + } + + let rules: Vec<Rule> = names + .nons + .iter() + .map(|nt| { + let alternatives = setup.grammar.alternatives(nt); + + // Epsilon alternatives go last: `scan_tnt` handles a + // trailing empty alternative (as in `new_paren_grammar`). + let mut texts: Vec<String> = alternatives + .iter() + .filter(|alt| !alt.is_empty()) + .map(|alt| { + alt.iter() + .map(|symbol| match names.nons.iter().position(|n| n == symbol) { + Some(i) => format!("N{}", non_name(i)), + None => { + let i = names.ters.iter().position(|t| t == symbol).unwrap(); + format!("T{}", ter_name(i)) + } + }) + .collect::<Vec<_>>() + .join(" ") + }) + .collect(); + + if alternatives.iter().any(|alt| alt.is_empty()) { + texts.push(String::new()); + } + + let text = texts.join(" | "); + + Rule::new( + parser + .parse(&text, Box::new(scan_tnt), true) + .unwrap_or_else(|e| panic!("cannot parse rule {text:?}: {e:?}")) + .unwrap_or_else(|| panic!("rule {text:?} parsed to nothing")) + .0, + ) + }) + .collect(); + + grammar::Grammar::new( + (0..names.ters.len()) + .map(|i| Terminal::new(ter_name(i))) + .collect(), + (0..names.nons.len()) + .map(|i| Nonterminal::new(non_name(i))) + .collect(), + rules, + ) + } + + /// Run the parser on `input`, giving up after a timeout (default 20 + /// seconds, `REP_TIMEOUT` seconds to override). A parser that does + /// not terminate is reported as an error like any other failure, + /// instead of blocking the whole test run. The abandoned thread + /// keeps running until the test process exits. + fn run_parser(setup: &Setup, input: &[String]) -> Result<Forest, String> { + init(); + + if input.is_empty() { + return Err("empty input is not supported yet (see CLAUDE SUGGESTIONS.org)".into()); + } + + let seconds: u64 = std::env::var("REP_TIMEOUT") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(20); + + let (sender, receiver) = std::sync::mpsc::channel(); + let (setup, input) = (setup.clone(), input.to_vec()); + + std::thread::spawn(move || { + let _ = sender.send(run_parser_unguarded(&setup, &input)); + }); + + match receiver.recv_timeout(std::time::Duration::from_secs(seconds)) { + Ok(result) => result, + Err(_) => Err(format!( + "TIMEOUT after {seconds}s: the parser does not terminate on this input" + )), + } + } + + fn run_parser_unguarded(setup: &Setup, input: &[String]) -> Result<Forest, String> { + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let atom = DefaultAtom::from_grammar(parser_grammar(setup)) + .map_err(|e| format!("from_grammar: {e:?}"))?; + + let mut chain = DefaultChain::unit(atom).map_err(|e| format!("unit: {e:?}"))?; + + let ids: Vec<usize> = input + .iter() + .map(|token| setup.names.ters.iter().position(|t| t == token).unwrap()) + .collect(); + + for (pos, id) in ids.iter().copied().enumerate() { + chain + .chain(id, pos, false) + .map_err(|e| format!("chain() failed at token {pos}: {e:?}"))?; + } + + chain + .end_of_input(ids.len(), *ids.last().unwrap()) + .map_err(|e| format!("end_of_input: {e:?}")) + })); + + match outcome { + Ok(result) => result, + Err(payload) => { + let message = payload + .downcast_ref::<String>() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string())) + .unwrap_or_else(|| "unknown panic".into()); + + Err(format!("parser panicked: {message}")) + } + } + } + + // ------------------------------------------------------------ + // Reading the forest. + // + // Convention (observed on forests of passing grammars, see + // `tests/CLAUDE SUGGESTIONS.org`): + // + // - a nonterminal node's children are "position wrappers", + // nodes labelled `Rule(_)`, one per right-hand-side symbol, in + // order; each wrapper has exactly one child, the symbol; + // - an ambiguous node is labelled like a nonterminal but is + // `Packed`; its children are `Cloned` nodes, one per + // alternative, each decoded like a plain nonterminal node. + // + // ASSUMPTION, NOT YET VERIFIED: an epsilon production appears as a + // nonterminal node with no children at all. + // ------------------------------------------------------------ + + #[derive(Debug)] + pub enum UnpackError { + TooMany(usize), + Decode(String), + } + + struct NodeInfo { + status: forest::ForestLabelType, + label: GrammarLabelType, + start: usize, + end: Option<usize>, + children: Vec<usize>, + } + + fn info(forest: &Forest, node: usize) -> Result<NodeInfo, UnpackError> { + let decode = |e: &dyn std::fmt::Debug| UnpackError::Decode(format!("{e:?}")); + + let label = forest + .vertex_label(node) + .map_err(|e| decode(&e))? + .ok_or_else(|| UnpackError::Decode(format!("node {node} has no label")))?; + + Ok(NodeInfo { + status: label.status(), + label: label.label().label(), + start: label.label().start(), + end: label.label().end(), + children: forest + .children_of(node) + .map_err(|e| decode(&e))? + .collect(), + }) + } + + /// Expand the forest into the set of trees of height at most `h`, + /// as a sorted list WITH multiplicity (so that duplicates can be + /// reported). Alternatives are chosen per occurrence of a node. + /// Cycles are cut by the height bound. More than `limit` trees at + /// any node is an error. + pub fn unpack( + forest: &Forest, + names: &Names, + h: usize, + limit: usize, + ) -> Result<Vec<String>, UnpackError> { + let Some(root) = forest.root() else { + return Ok(Vec::new()); + }; + + let mut memo: HashMap<(usize, usize), Vec<String>> = HashMap::new(); + + let mut trees = go(forest, names, root, h, limit, &mut memo)?; + + trees.sort(); + + Ok(trees) + } + + fn go( + forest: &Forest, + names: &Names, + node: usize, + h: usize, + limit: usize, + memo: &mut HashMap<(usize, usize), Vec<String>>, + ) -> Result<Vec<String>, UnpackError> { + if let Some(done) = memo.get(&(node, h)) { + return Ok(done.clone()); + } + + let me = info(forest, node)?; + + let result = match me.label { + GrammarLabelType::TNT(TNT::Ter(t)) => vec![names.ters[t].clone()], + GrammarLabelType::TNT(TNT::Non(n)) if me.status == forest::ForestLabelType::Packed => { + // A packed node costs no height: it only chooses. + let mut all = Vec::new(); + + for alternative in me.children { + all.extend(go(forest, names, alternative, h, limit, memo)?); + } + + let _ = n; + + all + } + GrammarLabelType::TNT(TNT::Non(n)) => { + if h == 0 { + Vec::new() + } else if me.children.is_empty() { + vec![format!("({} eps)", names.nons[n])] + } else { + let mut partial: Vec<Vec<String>> = vec![Vec::new()]; + + for wrapper in me.children { + let w = info(forest, wrapper)?; + + if w.label.rule().is_none() || w.children.len() != 1 { + return Err(UnpackError::Decode(format!( + "child {wrapper} of {node} is not a one-child position wrapper" + ))); + } + + let options = go(forest, names, w.children[0], h - 1, limit, memo)?; + + let mut next = Vec::new(); + + for prefix in &partial { + for option in &options { + let mut extended = prefix.clone(); + extended.push(option.clone()); + next.push(extended); + } + } + + if next.len() > limit { + return Err(UnpackError::TooMany(next.len())); + } + + partial = next; + } + + partial + .into_iter() + .map(|children| format!("({} {})", names.nons[n], children.join(" "))) + .collect() + } + } + GrammarLabelType::Rule(_) => { + return Err(UnpackError::Decode(format!( + "node {node} is a position wrapper where a symbol was expected" + ))); + } + }; + + if result.len() > limit { + return Err(UnpackError::TooMany(result.len())); + } + + memo.insert((node, h), result.clone()); + + Ok(result) + } + + // ------------------------------------------------------------ + // Structural invariants. + // ------------------------------------------------------------ + + /// Check the invariants of `CLAUDE.md` step 4 on a finished forest. + /// Returns the list of violations (empty if the forest is valid). + fn validate_forest(forest: &Forest, setup: &Setup, input: &[String]) -> Vec<String> { + let mut bad: Vec<String> = Vec::new(); + + let Some(root) = forest.root() else { + return vec!["the forest is empty (no root)".into()]; + }; + + let n = input.len(); + let names = &setup.names; + + let mut infos: Vec<Option<NodeInfo>> = Vec::new(); + + for node in forest.nodes() { + match info(forest, node) { + Ok(i) => infos.push(Some(i)), + Err(e) => { + bad.push(format!("node {node} unreadable: {e:?}")); + infos.push(None); + } + } + } + + // Which nodes are reachable from the root? Unreachable nodes + // are reported, not checked further. + let mut reachable = vec![false; infos.len()]; + let mut stack = vec![root]; + + while let Some(node) = stack.pop() { + if node >= infos.len() || reachable[node] { + continue; + } + + reachable[node] = true; + + if let Some(i) = &infos[node] { + stack.extend(i.children.iter().copied()); + } + } + + let describe = |node: usize| -> String { + match &infos[node] { + Some(i) => { + let name = match i.label { + GrammarLabelType::TNT(TNT::Non(k)) => names.nons[k].clone(), + GrammarLabelType::TNT(TNT::Ter(k)) => names.ters[k].clone(), + GrammarLabelType::Rule(r) => format!("rule@{r}"), + }; + + format!("{name}[{},{:?}]#{node}", i.start, i.end) + } + None => format!("#{node}"), + } + }; + + let unreachable: Vec<usize> = (0..infos.len()).filter(|k| !reachable[*k]).collect(); + + if !unreachable.is_empty() { + bad.push(format!( + "{} node(s) not reachable from the root: {}", + unreachable.len(), + unreachable + .iter() + .map(|k| describe(*k)) + .collect::<Vec<_>>() + .join(", ") + )); + } + + // Nodes are unique per (symbol, start, end); clones are exempt. + let mut seen: BTreeMap<(String, usize, Option<usize>), usize> = BTreeMap::new(); + + for node in 0..infos.len() { + if !reachable[node] { + continue; + } + + let Some(i) = &infos[node] else { continue }; + + // All nodes must be closed in a finished forest. + if i.end.is_none() { + bad.push(format!("{} is not closed (no end position)", describe(node))); + } + + if matches!(i.status, forest::ForestLabelType::Cloned(_)) { + continue; + } + + if let GrammarLabelType::TNT(tnt) = i.label { + let key = (format!("{tnt:?}"), i.start, i.end); + + if let Some(other) = seen.insert(key, node) { + bad.push(format!( + "duplicate node for (symbol, start, end): {} and {}", + describe(other), + describe(node) + )); + } + } + } + + // Per-node structure. + let signature = |wrappers: &[usize]| -> Option<Vec<String>> { + let mut out = Vec::new(); + + for w in wrappers { + let wi = infos[*w].as_ref()?; + let child = infos[*wi.children.first()?].as_ref()?; + + out.push(match child.label { + GrammarLabelType::TNT(TNT::Non(k)) => names.nons[k].clone(), + GrammarLabelType::TNT(TNT::Ter(k)) => names.ters[k].clone(), + GrammarLabelType::Rule(_) => return None, + }); + } + + Some(out) + }; + + for node in 0..infos.len() { + if !reachable[node] { + continue; + } + + let Some(i) = &infos[node] else { continue }; + + match i.label { + GrammarLabelType::TNT(TNT::Ter(t)) => { + // Terminal children match the input. + if i.end != Some(i.start + 1) || i.start >= n || input[i.start] != names.ters[t] + { + bad.push(format!( + "terminal {} does not match the input {:?}", + describe(node), + input + )); + } + } + GrammarLabelType::TNT(TNT::Non(k)) if i.status == forest::ForestLabelType::Packed => { + let mut signatures: Vec<(Vec<String>, Vec<(usize, Option<usize>)>)> = + Vec::new(); + + for clone in &i.children { + let Some(c) = &infos[*clone] else { continue }; + + if !matches!(c.status, forest::ForestLabelType::Cloned(_)) + || c.label != i.label + || c.start != i.start + || c.end != i.end + { + bad.push(format!( + "{}: child {} is not a clone with the same label and span", + describe(node), + describe(*clone) + )); + } + + let spans: Vec<(usize, Option<usize>)> = c + .children + .iter() + .filter_map(|w| infos[*w].as_ref().map(|wi| (wi.start, wi.end))) + .collect(); + + if let Some(sig) = signature(&c.children) { + if signatures.iter().any(|(s, sp)| *s == sig && *sp == spans) { + bad.push(format!( + "{}: two packed alternatives are identical ({} {:?})", + describe(node), + names.nons[k], + sig + )); + } + + signatures.push((sig, spans)); + } + } + } + GrammarLabelType::TNT(TNT::Non(k)) => { + // Children cover exactly [start, end], in order. + let mut at = i.start; + + for w in &i.children { + let Some(wi) = &infos[*w] else { continue }; + + if wi.label.rule().is_none() || wi.children.len() != 1 { + bad.push(format!( + "{}: child {} is not a one-child position wrapper", + describe(node), + describe(*w) + )); + + continue; + } + + if wi.start != at { + bad.push(format!( + "{}: gap or overlap before {} (expected start {at})", + describe(node), + describe(*w) + )); + } + + if let Some(sym) = &infos[wi.children[0]] { + if (sym.start, sym.end) != (wi.start, wi.end) { + bad.push(format!( + "{}: wrapper {} and its symbol {} disagree on the span", + describe(node), + describe(*w), + describe(wi.children[0]) + )); + } + } + + at = wi.end.unwrap_or(at); + } + + if i.end.is_some() && Some(at) != i.end { + bad.push(format!( + "{}: children end at {at}, not at the node's end", + describe(node) + )); + } + + // The alternative is a real production. + if let Some(sig) = signature(&i.children) { + let ok = setup + .grammar + .alternatives(&names.nons[k]) + .iter() + .any(|alt| *alt == sig); + + if !ok { + bad.push(format!( + "{}: {} -> {:?} is not a production of the grammar", + describe(node), + names.nons[k], + sig + )); + } + } + } + GrammarLabelType::Rule(_) => {} + } + } + + // Cycles: every node on a cycle has the same span. Tarjan. + let count = infos.len(); + let edges: Vec<Vec<usize>> = infos + .iter() + .map(|i| i.as_ref().map(|i| i.children.clone()).unwrap_or_default()) + .collect(); + + let mut index = vec![usize::MAX; count]; + let mut low = vec![0usize; count]; + let mut on_stack = vec![false; count]; + let mut scc_stack: Vec<usize> = Vec::new(); + let mut counter = 0usize; + let mut sccs: Vec<Vec<usize>> = Vec::new(); + + // Iterative Tarjan: (node, next child index). + for start in 0..count { + if index[start] != usize::MAX || !reachable[start] { + continue; + } + + let mut call: Vec<(usize, usize)> = vec![(start, 0)]; + + index[start] = counter; + low[start] = counter; + counter += 1; + scc_stack.push(start); + on_stack[start] = true; + + while let Some(&(v, next)) = call.last() { + if next < edges[v].len() { + call.last_mut().unwrap().1 += 1; + + let w = edges[v][next]; + + if w >= count { + continue; + } + + if index[w] == usize::MAX { + index[w] = counter; + low[w] = counter; + counter += 1; + scc_stack.push(w); + on_stack[w] = true; + call.push((w, 0)); + } else if on_stack[w] { + low[v] = low[v].min(index[w]); + } + } else { + call.pop(); + + if let Some(&(parent, _)) = call.last() { + low[parent] = low[parent].min(low[v]); + } + + if low[v] == index[v] { + let mut component = Vec::new(); + + while let Some(w) = scc_stack.pop() { + on_stack[w] = false; + component.push(w); + + if w == v { + break; + } + } + + sccs.push(component); + } + } + } + } + + for component in sccs { + let cyclic = component.len() > 1 || edges[component[0]].contains(&component[0]); + + if !cyclic { + continue; + } + + let spans: std::collections::BTreeSet<(usize, Option<usize>)> = component + .iter() + .filter_map(|k| infos[*k].as_ref().map(|i| (i.start, i.end))) + .collect(); + + if spans.len() > 1 { + bad.push(format!( + "a cycle passes through nodes of different spans: {}", + component + .iter() + .map(|k| describe(*k)) + .collect::<Vec<_>>() + .join(", ") + )); + } + } + + // The forest is polynomial in the input length. + let symbols: usize = setup + .grammar + .rules + .iter() + .flat_map(|(_, alts)| alts.iter()) + .map(|alt| alt.len() + 1) + .sum(); + let bound = (symbols + names.ters.len() + names.nons.len() + 1) * (n + 1).pow(3); + + if infos.len() > bound { + bad.push(format!( + "the forest has {} nodes, above the polynomial bound {bound}", + infos.len() + )); + } + + bad + } + + // ------------------------------------------------------------ + // Comparison and classification. + // ------------------------------------------------------------ + + /// Compare the parser's trees (with multiplicity) with the oracle's. + /// Each kind of difference points to a different bug class. + fn classify(oracle: &BTreeSet<String>, parser: &[String]) -> Vec<String> { + let mut report = Vec::new(); + + let parser_set: BTreeSet<String> = parser.iter().cloned().collect(); + + let missing: Vec<&String> = oracle.difference(&parser_set).collect(); + let extra: Vec<&String> = parser_set.difference(oracle).collect(); + + if !missing.is_empty() { + report.push(format!( + "MISSING trees (lost derivations): {} e.g. {:?}", + missing.len(), + missing.iter().take(3).collect::<Vec<_>>() + )); + } + + if !extra.is_empty() { + report.push(format!( + "EXTRA trees (spurious derivations): {} e.g. {:?}", + extra.len(), + extra.iter().take(3).collect::<Vec<_>>() + )); + } + + if parser.len() != parser_set.len() { + let mut duplicated: Vec<&String> = Vec::new(); + + for pair in parser.windows(2) { + if pair[0] == pair[1] && !duplicated.contains(&&pair[0]) { + duplicated.push(&pair[0]); + } + } + + report.push(format!( + "DUPLICATE trees (wrong multiplicity): {} extra copies, e.g. {:?}", + parser.len() - parser_set.len(), + duplicated.iter().take(3).collect::<Vec<_>>() + )); + } + + report + } + + /// Run one case end to end. `Err` carries a full report: the + /// grammar, the input, and the classified differences. + pub fn check_case(grammar_text: &str, input: &str, heights: &[usize]) -> Result<(), String> { + let input = tokens(input); + let setup = Setup::new(grammar_text, &input); + + let header = format!( + "grammar:\n{}\ninput: {:?}", + grammar_text.trim(), + input.join(" ") + ); + + let forest = run_parser(&setup, &input).map_err(|e| format!("{header}\nPARSER: {e}"))?; + + // No root: correct exactly when the input has no derivation at + // all (the oracle finds no tree at any of the heights). + if forest.root().is_none() { + let total: usize = heights.iter().map(|&h| oracle_trees(&setup.grammar, &input, h).len()).sum(); + return if total == 0 { + Ok(()) + } else { + Err(format!("{header}\nthe forest is empty but the oracle finds {total} tree(s)")) + }; + } + + let mut problems: Vec<String> = Vec::new(); + + for &h in heights { + let expected = oracle_trees(&setup.grammar, &input, h); + + match unpack(&forest, &setup.names, h, 100_000) { + Ok(parsed) => { + for line in classify(&expected, &parsed) { + problems.push(format!("h = {h}: {line}")); + } + } + Err(e) => problems.push(format!("h = {h}: cannot unpack: {e:?}")), + } + } + + for violation in validate_forest(&forest, &setup, &input) { + problems.push(format!("INVARIANT: {violation}")); + } + + if problems.is_empty() { + Ok(()) + } else { + Err(format!("{header}\n{}", problems.join("\n"))) + } + } + + fn assert_case(grammar_text: &str, input: &str, heights: &[usize]) { + if let Err(report) = check_case(grammar_text, input, heights) { + panic!("\n{report}\n"); + } + } + + // ------------------------------------------------------------ + // The test layers, in the order of CLAUDE.md. Stop at the first + // failing layer and fix it before moving on. + // ------------------------------------------------------------ + + const H: [usize; 4] = [1, 2, 3, 4]; + + // Layer 1: unambiguous, no epsilon. + // Probes (added in session 2): run with + // `cargo test --test oracle -- --ignored probe --nocapture --test-threads=1`. + // They print one line per case instead of stopping at the first failure. + #[test] + #[ignore] + fn probe_cases() { + let cases: Vec<(&str, &str)> = vec![ + ("S -> S a | b", "b a a"), + ("S -> S a b | c", "c a b a b"), + ("S -> S a | a | b", "b a a"), + ("T -> x S\nS -> S a | a", "x a"), + ("T -> x S\nS -> S a | a", "x a a"), + ("T -> x S\nS -> S a | a", "x a a a"), + ("T -> S x\nS -> S a | a", "a a x"), + ("S -> x S | S a | a", "x a a"), + ("S -> x S | S a | a", "x x a a a"), + ("S -> S a | a", "a a a a"), + ("S -> S S | a", "a a"), + ("S -> S S | a", "a a a"), + ("S -> A b\nA -> S c | d", "d b"), + ("S -> A b | d\nA -> S c", "d c b"), + ("S -> A b | d\nA -> S c", "d c b c b"), + ("S -> a A | A b\nA -> a | b", "a b"), + ]; + + for (g, i) in cases { + let r = check_case(g, i, &[1, 2, 3, 4, 5]); + println!( + "PROBE {:?} {:?} => {}", + g, + i, + match r { + Ok(()) => "OK".to_string(), + Err(e) => format!("FAIL {}", e.replace('\n', " | ")), + } + ); + } + } + + // One probe: env `DIAG_GRAMMAR` (default `S -> S a | a`) and `DIAG_INPUT` + // (default `a a`). + #[test] + #[ignore] + fn probe_one() { + let g = std::env::var("DIAG_GRAMMAR").unwrap_or("S -> S a | a".into()); + let i = std::env::var("DIAG_INPUT").unwrap_or("a a".into()); + + match check_case(&g, &i, &[1, 2, 3, 4, 5]) { + Ok(()) => println!("ONE OK"), + Err(e) => println!("ONE FAIL\n{e}"), + } + } + + // ------------------------------------------------------------ + // Regression tests for left-recursive forest construction + // (session rep-3). Written by Claude Code. Each one failed + // before the fix named in its comment; see tests/CLAUDE-PROGRESS.md. + // Heights are capped at 5 to keep the oracle fast. + // ------------------------------------------------------------ + + const H5: [usize; 5] = [1, 2, 3, 4, 5]; + + /// `graph/src/labelled/binary.rs`, `PLChildren::remove` did not + /// remove the key from `indices`: `has_edge` stayed true after + /// `remove_edge`, so a later `add_edge` to the same child was + /// silently skipped. + #[test] + fn regression_graph_remove_edge_clears_index() { + use graph::builder::BuilderMut; + use graph::labelled::binary::{PLGBuilderMut, PLGraph}; + + let mut g = PLGraph::<usize>::default(); + let mut b = PLGBuilderMut::from_graph_mut(&mut g); + b.add_vertex(0); + b.add_vertex(1); + b.add_edge(0, 1, 0).unwrap(); + b.remove_edge(0, 1, |_| true).unwrap(); + assert!(!b.has_edge(0, 1).unwrap(), "edge still reported after removal"); + b.add_edge(0, 1, 0).unwrap(); + assert!(b.has_edge(0, 1).unwrap()); + assert_eq!(b.degree(0).unwrap(), 1, "re-added edge was skipped"); + } + + /// `splone_left` must be idempotent: several `insert_item` calls + /// visit the same reduction in one step (early return when the open + /// node's first child is already the wrapper). + #[test] + fn regression_case1_three_tokens_idempotent_hook() { + assert_case("S -> S S | a", "a a a", &H5); + } + + /// The hook in `insert_item` must also fire for cloned nodes + /// (ambiguous grammars), and a node already hooked under its wrapper + /// must be reused rather than climbed (`HOOKED_ALREADY`). Both + /// fixes are needed from 4 tokens on. + #[test] + fn regression_case1_four_tokens_clone_hook() { + assert_case("S -> S S | a", "a a a a", &H5); + } + + /// Right-spine cascade (open `h` ancestors whose last child is the + /// wrapper over the node just closed complete too), the dedupe of + /// identical continuations (`bury`), and the stale-last-child + /// repair in `reduction`. `S[0,5]` loses its split-at-2 + /// alternative without them. + #[test] + fn regression_case1_five_tokens_cascade() { + assert_case("S -> S S | a", "a a a a a", &H5); + } + + /// `close_pavi(Virtual)` must not replant the virtual alternative + /// into a node that is already a left-recursive continuation, and + /// `open_ancestors` must not append to an existing open node that + /// already has a child at the edge (`Eon::Ex`). Seen from 7 tokens. + #[test] + fn regression_case1_seven_tokens_virtual_and_ex() { + assert_case("S -> S S | a", "a a a a a a a", &H5); + } + + /// Two left-recursive rules for one head (`S -> S S | S a | a`). + /// `insert_item`, hook: only a wrapper of the SAME rule counts as + /// "already hooked" (`has_h_parent`); a node hooked for the other + /// rule used to block this rule's hook, and the climb planted the + /// wrong continuation (the mixed alternative `[R4[0,1], R8[1,2]]`). + #[test] + fn regression_two_left_rules_two_tokens_hook_per_rule() { + assert_case("S -> S S | S a | a", "a a", &H5); + } + + /// Same grammar. Fails without the per-rule `has_h_parent` and + /// without the other-wrapper check in `splone_left` (MEASURED with + /// the scratch switches NOFIX_HASHP / NOFIX_OTHERW). It no longer + /// isolates the `buried` map: that panic (assertion in `insert_item` + /// on `a a a`) was removed by the other-wrapper check. + #[test] + fn regression_two_left_rules_three_tokens() { + assert_case("S -> S S | S a | a", "a a a", &H5); + } + + /// Same grammar. `splone_left` must not close again a node whose + /// first child is a closed wrapper of ANOTHER left rule: it is + /// already a continuation with incomplete children (it gave + /// `S[1,2] -> [R7[1,2]]`, i.e. the extra trees `(S (S a))`). + #[test] + fn regression_two_left_rules_four_tokens_other_wrapper() { + assert_case("S -> S S | S a | a", "a a a a", &H5); + } + + /// Same grammar. A frame closed at an earlier position was already + /// turned into open continuations by the hook; the obsolete item + /// that still names it must drop it from the stack instead of + /// splitting a closed node (it gave nodes whose children end + /// before the node does). Seen from 5 tokens. Also fails without + /// the `buried` map (`DefaultForest::buried`, `resolve_buried`): + /// MEASURED with NOFIX_BURIED, the only test of the suite that does. + #[test] + fn regression_two_left_rules_five_tokens_stale_frame() { + assert_case("S -> S S | S a | a", "a a a a a", &H5); + assert_case("S -> S S | S a | a", "a a a a a a", &H5); + } + + /// Same grammar, 7 tokens. An open continuation + /// `S[0,None] -> [R7[0,j], R8[j,6]]` whose last child (the second + /// `S` of `S S`) is complete never got its closed twin `S[0,6]`: + /// the cascade after a hook only followed wrappers of the item's + /// own fragment-root rule, and the stale frames dropped for the + /// item were not replaced by their live continuations. Lost the + /// splits k=2..4 of `S[0,6]` (104 trees at h = 5). Needs both + /// the frame remap (`hooked`) and the generalised cascade + /// (`casc_rule`); see tests/CLAUDE-PROGRESS.md. + #[test] + fn regression_two_left_rules_seven_tokens_cascade() { + assert_case("S -> S S | S a | a", "a a a a a a a", &H5); + } + + /// Same grammar, 8 tokens. `reduction()` (step 2) treated a closed + /// hook wrapper (`Rule(w)`, the first symbol of a left-recursive + /// rule) ending at `pos` as a complete child, so an open + /// continuation `S[k,None] -> [R7[k,pos]]` was closed with that + /// single child: nodes `S -> ["S"]`, not a production. Fix in + /// `chain/src/item/reduction.rs`: a closed left-wrapper rule node + /// does not count as correct (`NOFIX_WRAPLAST` in scratch). + #[test] + fn regression_two_left_rules_eight_tokens_wrapper_last_child() { + assert_case("S -> S S | S a | a", "a a a a a a a a", &H5); + } + + /// `E -> E + E | n`, 5 operands (a three-symbol left-recursive + /// rule). Four causes, all needed (MEASURED with the switches): + /// (1) cascade nodes of another rule were hooked with the rule of + /// their LAST slot (`R6`), for which no wrapper exists + /// (`left_wrapper` is keyed by the first literal after the + /// wrapper): the wrapper is the rule of the node's own first + /// child and is now passed through directly (`NOFIX_CASCWRAP`); (2) a stale last child (open, or closed at a + /// later position) of a closed node must be repointed to the closed + /// twin by a sweep at every `insert_item` entry + /// (`NOFIX_REPAIRALL`), widened to any mismatching end + /// (`NOFIX_REPAIRANY`) and run once more at the end of input + /// (`NOFIX_EOIREPAIR`); (3) `splone_left` on an in-progress open + /// node repurposed it as the hook continuation, losing the + /// in-progress node (`NOFIX_INPROG`). + #[test] + fn regression_three_symbol_left_rule_five_operands() { + assert_case("E -> E + E | n", "n + n + n + n + n", &H5); + } + + /// Coverage (they passed when written; not tied to a fix): longer + /// left-recursive rules and productions of different lengths that + /// share the leading symbol. + #[test] + fn coverage_longer_left_recursive_rules() { + assert_case("S -> S a b c | d", "d a b c a b c a b c", &H5); + assert_case("S -> S a | S b c | d", "d a b c a", &H5); + assert_case("S -> S S a b | S c | d", "d d a b c d a b", &H5); + assert_case("S -> S x S y S | a", "a x a y a x a y a", &H5); + } + + // ------------------------------------------------------------ + // Known-open cases (session rep-3). These FAIL at the time of + // writing; each names the symptom measured. They are not + // regression tests: they become regression tests once fixed. + // Written by Claude Code. + // ------------------------------------------------------------ + + + /// `S -> S S S | a`, 4 and 5 tokens (derivable lengths are odd, so + /// 4 must give NO forest). The cascade after a hook (edit 6, + /// `insert_item`) followed any wrapper whose rule equals the + /// item's rule, assuming it is the last slot of its rule (true + /// for two-symbol rules); for a three-symbol rule the middle slot + /// closed the node after two children (`S -> [R4, R5]`). Fix: the + /// wrapper must be accepting, `atom.is_accepting(r*2+1)` + /// (`NOFIX_CASCACCEPT`). + #[test] + fn regression_three_symbol_rule_incomplete_cascade() { + assert_case("S -> S S S | a", "a a a a", &H5); + assert_case("S -> S S S | a", "a a a a a", &H5); + assert_case("S -> S S S | a", "a a a a a a", &H5); + } + + /// `S -> S S S | a`, 8 tokens (not derivable: must give no + /// forest). A frame completes its node only if the child at + /// `edge` fills the last slot of its rule (hook wrapper slots + /// exempt); the hook closed `S -> [R4, R5]` after two of three + /// children (`NOFIX_ACCFRAME` in scratch). The companion filter + /// `atom.can_follow` (`NOFIX_FOLLOW`) only shows at 7 and 9 tokens + /// (invalid nodes and extras without it); see the open 7-token test. + #[test] + fn regression_three_symbol_rule_eight_tokens_frame_filters() { + assert_case("S -> S S S | a", "a a a a a a a a", &H5); + } + + /// `S -> S S S | a`, 7 tokens: the tree `(S (S a a a) (S a a a) a)` + /// was missing, because the open node + /// `S[0,None] -> [R4[0,3], R5[3,6]]` never got its third child: + /// a frame whose node is already closed at `pos` (here the cascade + /// frame for `S[3,6]`) was dropped by the planting filter instead of + /// being replaced by the open containers of its wrapper. + #[test] + fn regression_three_symbol_rule_seven_tokens_closed_frame_climb() { + assert_case("S -> S S S | a", "a a a a a a a", &H5); + } + + /// `S -> S S S | a`, 9 and 11 tokens: trees were missing (3 at h=4 + /// for 9 tokens), e.g. `(S (S a a a) (S (S a a a) a a) a)`. The item + /// that plants the third child started from the rule node of the + /// slot-5 occurrence of a pavi child that was already closed at `pos`, + /// so it only saw the parents of that rule node and not those of the + /// other wrappers of the same (shared) closed node, which lead to the + /// open container waiting for its third child. + #[test] + fn regression_three_symbol_rule_nine_and_eleven_tokens_closed_pavi_child() { + assert_case("S -> S S S | a", "a a a a a a a a a", &H5); + assert_case("S -> S S S | a", "a a a a a a a a a a a", &H5); + } + + /// Recognition bug (not forest): `S -> A A; A -> a | eps` must accept + /// `a` (`S => A A => a eps`, or `eps a`), but after the input + /// `chain.epsilon()` is false. Measured: the virtual node `[S]^a` + /// is not accepting although the rest of `S` (one `A`) is nullable. + /// Run with the forest disabled (`no_item = true`), so only the + /// recognizer is involved. + #[test] + fn open_recognition_nullable_rest_of_virtual_node() { + init(); + let toks = tokens("a"); + let setup = Setup::new("S -> A A\nA -> a | eps", &toks); + let atom = DefaultAtom::from_grammar(parser_grammar(&setup)).unwrap(); + let mut chain = DefaultChain::unit(atom).unwrap(); + for (pos, token) in toks.iter().enumerate() { + let id = setup.names.ters.iter().position(|t| t == token).unwrap(); + chain.chain(id, pos, true).unwrap(); + } + assert!(chain.epsilon().unwrap(), "the recognizer rejects a derivable input"); + } + + /// Recognition bug (not forest): `S -> b | a` derives only one-token + /// strings, but with the unreachable rule `A -> A S A | eps` the + /// recognizer accepts `a b b b`. Without that rule, or with + /// `A -> S A | eps` instead, it rejects correctly. Traced in scratch + /// (2026-10-16): NFA states belong to rule positions and are shared by + /// all nonterminals, so inside a frame of `S` the end of `S` continues + /// into `A -> A S . A` (`S` is a left corner of `A`). Fixed in scratch + /// by allowing, inside a frame of `X`, only states of rules whose + /// nonterminal is a left corner of `X` (design doc 6.4). + #[test] + fn open_recognition_unreachable_rule_accepts_too_much() { + init(); + let toks = tokens("a b b b"); + let setup = Setup::new("S -> b | a\nA -> A S A | eps", &toks); + let atom = DefaultAtom::from_grammar(parser_grammar(&setup)).unwrap(); + let mut chain = DefaultChain::unit(atom).unwrap(); + let mut rejected_early = false; + for (pos, token) in toks.iter().enumerate() { + let id = setup.names.ters.iter().position(|t| t == token).unwrap(); + if chain.chain(id, pos, true).is_err() { rejected_early = true; break; } + } + assert!(rejected_early || !chain.epsilon().unwrap(), "the recognizer accepts a non-derivable input"); + } + + /// Recognition bug (not forest), written by Claude Code 2026-10-16: + /// `S -> X d; X -> Y c; Y -> a` derives only `a c d`, but the + /// recognizer accepts `a c`. The state `X -> Y c .` is accepting + /// because `X` ends there, and acceptance is one flag per NFA state, so + /// it also counts as the end of the frame of `S`. Acceptance must be + /// relative to the frame's nonterminal (design doc 6.4). + #[test] + fn open_recognition_inner_rule_end_is_not_frame_end() { + init(); + let toks = tokens("a c"); + let setup = Setup::new("S -> X d\nX -> Y c\nY -> a", &toks); + let atom = DefaultAtom::from_grammar(parser_grammar(&setup)).unwrap(); + let mut chain = DefaultChain::unit(atom).unwrap(); + let mut rejected_early = false; + for (pos, token) in toks.iter().enumerate() { + let id = setup.names.ters.iter().position(|t| t == token).unwrap(); + if chain.chain(id, pos, true).is_err() { rejected_early = true; break; } + } + assert!(rejected_early || !chain.epsilon().unwrap(), "the recognizer accepts a non-derivable input"); + } + + /// Guard for the recognizer (passes today), written by Claude Code + /// 2026-10-16: `S -> B a; B -> a B | eps` accepts `a a`. The accepting + /// path ends exactly at the end of `S -> B a`, so it is an imaginary + /// edge `Im(target)`, and the new node also has real edges. Today the + /// node is accepting only by accident (the global acceptance of + /// `B -> a B .`). With frame-relative acceptance (6.4) alone this test + /// fails, because `chain()` applies imaginary edges only to nodes + /// without real edges and `Collapsed` drops them (design doc 6.5). + #[test] + fn guard_recognition_rule_end_completion_next_to_other_edges() { + init(); + let toks = tokens("a a"); + let setup = Setup::new("S -> B a\nB -> a B | eps", &toks); + let atom = DefaultAtom::from_grammar(parser_grammar(&setup)).unwrap(); + let mut chain = DefaultChain::unit(atom).unwrap(); + for (pos, token) in toks.iter().enumerate() { + let id = setup.names.ters.iter().position(|t| t == token).unwrap(); + chain.chain(id, pos, true).unwrap(); + } + assert!(chain.epsilon().unwrap(), "the recognizer rejects a derivable input"); + } + + /// Recognition speed (not forest), written by Claude Code 2026-10-16: + /// on `E -> E + T | T; T -> T * n | n` with `n + n + ... + n` (41 + /// tokens) the chain nodes keep growing: measured maximum degree 20 at + /// 39 tokens, total edges quadratic in the input. Every `+` leaves a + /// phantom `E -> E + . T` inside a frame of `T` (shared NFA states, + /// the same cause as `open_recognition_unreachable_rule_accepts_too_much`). + /// With the left-corner filter the maximum degree is 2 (measured in + /// scratch); the bound 4 leaves room. + #[test] + fn open_recognition_expression_grammar_node_degree_stays_bounded() { + init(); + let input = vec!["n"; 21].join(" + "); + let toks = tokens(&input); + let setup = Setup::new("E -> E + T | T\nT -> T * n | n", &toks); + let atom = DefaultAtom::from_grammar(parser_grammar(&setup)).unwrap(); + let mut chain = DefaultChain::unit(atom).unwrap(); + for (pos, token) in toks.iter().enumerate() { + let id = setup.names.ters.iter().position(|t| t == token).unwrap(); + chain.chain(id, pos, true).unwrap(); + } + assert!(chain.epsilon().unwrap()); + let max = (0..chain.nodes_len()).map(|v| chain.degree(v).unwrap_or(0)).max().unwrap_or(0); + assert!(max <= 4, "maximum node degree {max} grows with the input"); + } + + /// Indirect left recursion: `CannotPlant` at token 1. + #[test] + fn open_indirect_left_recursion() { + assert_case("S -> A b | d\nA -> S c", "d c b", &H5); + assert_case("S -> A b | d\nA -> S c", "d c b c b", &H5); + } + + /// Nested left recursion (`S -> x S | S a | a`): the same stale + /// frame, reached through the `x S` rule. + #[test] + fn regression_nested_left_recursion_stale_frame() { + assert_case("S -> x S | S a | a", "x x a a a", &H5); + } + + #[test] + fn layer1_fixed_length() { + assert_case("S -> a a", "a a", &H); + assert_case("S -> a b c", "a b c", &H); + } + + #[test] + fn layer1_right_recursion() { + // n = 1 is the one-token case: the forest used to come out empty. + for n in 1..=4 { + assert_case("S -> a S | a", &vec!["a"; n].join(" "), &H); + } + } + + #[test] + fn layer1_nested_nonterminals() { + assert_case("S -> A b\nA -> a", "a b", &H); + assert_case("S -> A B\nA -> a\nB -> b", "a b", &H); + } + + // Layer 2: ambiguous, no epsilon (Cases 1 and 2). + #[test] + fn layer2_case_1() { + for n in 1..=5 { + assert_case("S -> S S | a", &vec!["a"; n].join(" "), &[1, 2, 3, 4, 5]); + } + } + + #[test] + fn layer2_case_2() { + assert_case("E -> E + E | n", "n + n + n", &H); + } + + // Layer 3: epsilon rules and nullable nonterminals (Cases 3 and 4). + // The empty-input halves of these cases are not supported by the + // parser's API yet, so only the non-empty inputs are run. + #[test] + fn layer3_case_3() { + assert_case("S -> A A\nA -> a | eps", "a", &H); + } + + #[test] + fn layer3_case_4() { + assert_case("S -> a S | eps", "a a", &[1, 2, 3, 4, 5]); + } + + #[test] + fn layer3_nullable_in_the_middle() { + assert_case("S -> a A b\nA -> eps | A c", "a b", &H); + assert_case("S -> a A b\nA -> eps | A c", "a c c b", &[1, 2, 3, 4, 5]); + } + + // Layer 4: left, right and hidden left recursion. + #[test] + fn layer4_left_recursion() { + for n in 1..=4 { + assert_case("S -> S a | a", &vec!["a"; n].join(" "), &[1, 2, 3, 4, 5]); + } + } + + #[test] + fn layer4_hidden_left_recursion() { + assert_case("S -> A S a | a\nA -> eps", "a a a", &H); + } + + // Layer 5: cyclic grammars (Case 5). + #[test] + fn layer5_case_5a() { + assert_case("S -> S | a", "a", &H); + } + + #[test] + fn layer5_case_5c() { + assert_case("S -> A | a\nA -> S", "a", &[1, 2, 3, 4, 5]); + } + + #[test] + fn layer5_case_5d() { + assert_case("S -> S S | S | a", "a a", &H); + } + + #[test] + fn layer5_highly_ambiguous_with_epsilon() { + assert_case("S -> S S | a | eps", "a a", &H); + } + + // Layer 6: random small grammars. Slow and noisy while earlier + // layers fail, so it is ignored by default: run it with + // `cargo test --test oracle -- --ignored`. Reproducible: the seed + // is printed, and can be set with `REP_SEED`; `REP_CASES` sets the + // number of cases. + struct Rng(u64); + + impl Rng { + fn next(&mut self) -> u64 { + // xorshift64* + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545F4914F6CDD1D) + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n as u64) as usize + } + } + + fn random_grammar(rng: &mut Rng) -> String { + let nons = ["S", "A", "B"]; + let count = 1 + rng.below(3); + let symbols: Vec<&str> = ["a", "b"].into_iter().chain(nons[..count].iter().copied()).collect(); + + let mut lines = Vec::new(); + + for nt in &nons[..count] { + let alternatives: Vec<String> = (0..1 + rng.below(3)) + .map(|_| { + let len = rng.below(4); + + if len == 0 { + "eps".to_string() + } else { + (0..len) + .map(|_| symbols[rng.below(symbols.len())]) + .collect::<Vec<_>>() + .join(" ") + } + }) + .collect(); + + lines.push(format!("{nt} -> {}", alternatives.join(" | "))); + } + + lines.join("\n") + } + + #[test] + #[ignore] + fn layer6_random_grammars() { + let seed: u64 = std::env::var("REP_SEED") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0x5EED); + let cases: usize = std::env::var("REP_CASES") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(50); + + eprintln!("layer 6: seed = {seed}, cases = {cases}"); + + let mut rng = Rng(seed | 1); + let mut failures = Vec::new(); + + for case in 0..cases { + let grammar_text = random_grammar(&mut rng); + let len = 1 + rng.below(6); + let input: Vec<&str> = (0..len).map(|_| ["a", "b"][rng.below(2)]).collect(); + + if let Err(report) = check_case(&grammar_text, &input.join(" "), &[1, 2, 3, 4]) { + failures.push(format!("case {case} (seed {seed}):\n{report}")); + } + } + + assert!( + failures.is_empty(), + "\n{} of {cases} random cases failed; first:\n{}\n", + failures.len(), + failures[0] + ); + } +} |
