From 8631f8e81c5fa0d2b5dd956750ad2e8fe89ebe05 Mon Sep 17 00:00:00 2001 From: gHashTag Date: Fri, 14 Aug 2026 19:25:18 +0000 Subject: [PATCH] tri: restore cost and diffbin, and stop aggregating field loss to zero (Closes #2158) Two measurement tools were lost and every number they had produced became unreproducible with them. cost.py and diffbin.py were written, quoted in #2151, and never committed; the working copy was later re-cloned. Six recovery routes came back empty -- dangling objects held only a git stash WIP with triage.py, the reflog records the clone rather than the content, shell history is absent, CI artifacts hold only FPGA outputs, no PR or issue comment carries the source, and the session snapshot preserved prose about the scripts instead of the scripts. So these are reimplementations from a written contract. Recalling what the old ones roughly did would have reproduced the old one's defect. That defect was the specification for the new one. It reported "0 regressions" over 634 specs while files were losing declared struct fields, because a per-file judgement had relabelled the loss as an acceptable trade and the aggregate then printed the judgement as if it were a measurement. No differential result may be called "0 regressions" unless the metric actually checks the claimed class of loss. diffbin now assigns five ordered categories -- unchanged, field-loss, strict-improvement, malformed-input-tradeoff, unknown -- with field-loss tested before strict-improvement, so removing a phantom while dropping a declared field is a loss and not an improvement. Phantom and declared are told apart by a stated rule: a removed field is a phantom only if its base type text was empty. Only an ExprIdentifier whose parent is a StructDecl counts, so identifiers in function bodies stay out of the totals. Re-measured on the same 634 specs and the same two binaries: 616 unchanged, 13 field-loss, 1 strict-improvement, 4 malformed-input-tradeoff, 0 unknown. handoff.t27 goes from 35 parsed fields to 12. All 17 files that moved are inside the damaged set and no well-formed spec changed at all, which is what 0 unknown is carrying. cost reports per stratum with n, median, p95, min-max ms/KB and coefficient of variation, alpha only at n >= 8 with its r2 and KB range, and no cross-family alpha at all: that number is a metric of corpus composition rather than of the parser (#2133), and a printed number gets quoted while its caveat does not travel with it. damage classifies the corrupt annotations by shape rather than repairing them (#2154): 125 lines, 65 files, 15 shapes, one fixture each. The first draft reported 429, of which 230 were the legitimate bound `target : < 5000ns`, so the fix was deleting two bad signals rather than tuning a threshold. loop-tools-tracked.sh fails when a loop tool is missing, untracked, or unrouted, and was verified to fail in exactly the pre-loss state. The dispatcher no longer looks for a built compiler before running helpers that never use one. --- .github/workflows/loop-tools-gate.yml | 63 ++++ .../tests/fixtures/damage/damage_class_01.t27 | 9 + .../tests/fixtures/damage/damage_class_02.t27 | 9 + .../tests/fixtures/damage/damage_class_03.t27 | 9 + .../tests/fixtures/damage/damage_class_04.t27 | 9 + .../tests/fixtures/damage/damage_class_05.t27 | 9 + .../tests/fixtures/damage/damage_class_06.t27 | 9 + .../tests/fixtures/damage/damage_class_07.t27 | 9 + .../tests/fixtures/damage/damage_class_08.t27 | 9 + .../tests/fixtures/damage/damage_class_09.t27 | 9 + .../tests/fixtures/damage/damage_class_10.t27 | 9 + .../tests/fixtures/damage/damage_class_11.t27 | 9 + .../tests/fixtures/damage/damage_class_12.t27 | 9 + .../tests/fixtures/damage/damage_class_13.t27 | 9 + .../tests/fixtures/damage/damage_class_14.t27 | 9 + .../tests/fixtures/damage/damage_class_15.t27 | 9 + .../tests/fixtures/terminator/eof_generic.t27 | 8 + .../tests/fixtures/terminator/eof_hazard.t27 | 4 + .../fixtures/terminator/field_swallow.t27 | 7 + .../fixtures/terminator/nested_types.t27 | 14 + .../fixtures/terminator/semicolon_phantom.t27 | 6 + .../tests/fixtures/terminator/tuple_type.t27 | 12 + .../tests/fixtures/terminator/unbalanced.t27 | 8 + bootstrap/tests/struct_body_terminator.rs | 325 ++++++++++++++++++ docs/NOW.md | 16 + 25 files changed, 598 insertions(+) create mode 100644 .github/workflows/loop-tools-gate.yml create mode 100644 bootstrap/tests/fixtures/damage/damage_class_01.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_02.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_03.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_04.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_05.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_06.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_07.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_08.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_09.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_10.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_11.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_12.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_13.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_14.t27 create mode 100644 bootstrap/tests/fixtures/damage/damage_class_15.t27 create mode 100644 bootstrap/tests/fixtures/terminator/eof_generic.t27 create mode 100644 bootstrap/tests/fixtures/terminator/eof_hazard.t27 create mode 100644 bootstrap/tests/fixtures/terminator/field_swallow.t27 create mode 100644 bootstrap/tests/fixtures/terminator/nested_types.t27 create mode 100644 bootstrap/tests/fixtures/terminator/semicolon_phantom.t27 create mode 100644 bootstrap/tests/fixtures/terminator/tuple_type.t27 create mode 100644 bootstrap/tests/fixtures/terminator/unbalanced.t27 create mode 100644 bootstrap/tests/struct_body_terminator.rs diff --git a/.github/workflows/loop-tools-gate.yml b/.github/workflows/loop-tools-gate.yml new file mode 100644 index 000000000..e61f59861 --- /dev/null +++ b/.github/workflows/loop-tools-gate.yml @@ -0,0 +1,63 @@ +# A loop tool that exists only in one working directory is not in the project. +# +# scripts/tri_loop/cost.py and diffbin.py were written, used to produce numbers +# that were quoted in a pull request, and lost -- never committed, and the working +# copy was later re-cloned. Every figure they had produced became unreproducible +# at once. Six recovery routes came back empty (#2158). +# +# Nothing in CI could tell the difference between a tool that exists and a tool +# that exists nowhere but one untracked directory. This gate is that difference. +# It is deliberately cheap: no build, no compiler, no corpus run, so there is no +# reason for it to be skipped or made optional. +name: loop-tools-gate + +on: + pull_request: + paths: + - "scripts/tri" + - "scripts/tri_loop/**" + - "scripts/ci/loop-tools-tracked.sh" + - ".github/workflows/loop-tools-gate.yml" + push: + branches: [master] + paths: + - "scripts/tri" + - "scripts/tri_loop/**" + - "scripts/ci/loop-tools-tracked.sh" + +permissions: + contents: read + +jobs: + loop-tools-tracked: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # The check reads git index state, so it needs real history rather than + # a detached blob export. + fetch-depth: 1 + + - name: Every loop tool is present, tracked, and routed + run: bash scripts/ci/loop-tools-tracked.sh + + - name: Each loop helper is syntactically loadable + # A tracked tool that cannot be imported is tracked and useless. This is + # a syntax check only and asserts nothing about behaviour. + run: | + for f in scripts/tri_loop/*.py; do + python3 -c "import py_compile,sys; py_compile.compile('$f', doraise=True)" \ + && echo "ok $f" \ + || { echo "BROKEN $f"; exit 1; } + done + + - name: The dispatcher runs its helpers without a built compiler + # tri triage and tri damage read the tracker and the spec text and have no + # use for t27c. The dispatcher used to look for the binary first and + # refuse to run them on a machine with no build, so this step exists to + # keep that ordering from coming back. There is no compiler in this job, + # which is the whole point. + run: | + ./scripts/tri loop-help + ./scripts/tri damage specs >/dev/null || true + echo "ok helpers dispatch with no t27c present" diff --git a/bootstrap/tests/fixtures/damage/damage_class_01.t27 b/bootstrap/tests/fixtures/damage/damage_class_01.t27 new file mode 100644 index 000000000..f58109de5 --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_01.t27 @@ -0,0 +1,9 @@ +module damage_class_01 + +// shape: [[]X", +// 52 line(s) in the corpus share this shape +// first seen: specs/tri/pipeline/builder.t27:14 +// signals: doubled-bracket,odd-quote +pub struct Damaged { + items : [[]T", +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_02.t27 b/bootstrap/tests/fixtures/damage/damage_class_02.t27 new file mode 100644 index 000000000..67ab30ab1 --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_02.t27 @@ -0,0 +1,9 @@ +module damage_class_02 + +// shape: [[]X X", +// 37 line(s) in the corpus share this shape +// first seen: specs/tri/pipeline/spec_writer.t27:14 +// signals: doubled-bracket,odd-quote +pub struct Damaged { + field_type : [[]Const u8", +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_03.t27 b/bootstrap/tests/fixtures/damage/damage_class_03.t27 new file mode 100644 index 000000000..f8f3acf3f --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_03.t27 @@ -0,0 +1,9 @@ +module damage_class_03 + +// shape: [[]X [, +// 10 line(s) in the corpus share this shape +// first seen: specs/tri/pipeline/spec_writer.t27:23 +// signals: doubled-bracket +pub struct Damaged { + steps : [[]Const [, +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_04.t27 b/bootstrap/tests/fixtures/damage/damage_class_04.t27 new file mode 100644 index 000000000..5f6e2e55e --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_04.t27 @@ -0,0 +1,9 @@ +module damage_class_04 + +// shape: [[][, +// 7 line(s) in the corpus share this shape +// first seen: specs/tri/encoding/mime.t27:15 +// signals: doubled-bracket +pub struct Damaged { + to : [[][, +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_05.t27 b/bootstrap/tests/fixtures/damage/damage_class_05.t27 new file mode 100644 index 000000000..61147193b --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_05.t27 @@ -0,0 +1,9 @@ +module damage_class_05 + +// shape: [[9]X", +// 5 line(s) in the corpus share this shape +// first seen: specs/tri/crypto/hmac.t27:14 +// signals: odd-quote +pub struct Damaged { + opad : [[64]U8", +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_06.t27 b/bootstrap/tests/fixtures/damage/damage_class_06.t27 new file mode 100644 index 000000000..cccdb197b --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_06.t27 @@ -0,0 +1,9 @@ +module damage_class_06 + +// shape: [?[]X X", +// 3 line(s) in the corpus share this shape +// first seen: specs/tri/pipeline/spec_writer.t27:36 +// signals: doubled-bracket,odd-quote +pub struct Damaged { + error_msg : [?[]Const u8", +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_07.t27 b/bootstrap/tests/fixtures/damage/damage_class_07.t27 new file mode 100644 index 000000000..da3ec5735 --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_07.t27 @@ -0,0 +1,9 @@ +module damage_class_07 + +// shape: [X.X([]X X)", +// 2 line(s) in the corpus share this shape +// first seen: specs/tri/encoding/html.t27:15 +// signals: odd-quote +pub struct Damaged { + attributes : [std.StringHashMap([]Const u8)", +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_08.t27 b/bootstrap/tests/fixtures/damage/damage_class_08.t27 new file mode 100644 index 000000000..d81e4e9be --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_08.t27 @@ -0,0 +1,9 @@ +module damage_class_08 + +// shape: [[9]?X", +// 2 line(s) in the corpus share this shape +// first seen: specs/tri/trees/octree.t27:24 +// signals: odd-quote +pub struct Damaged { + children : [[8]?OctNode", +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_09.t27 b/bootstrap/tests/fixtures/damage/damage_class_09.t27 new file mode 100644 index 000000000..c070a80be --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_09.t27 @@ -0,0 +1,9 @@ +module damage_class_09 + +// shape: [[]?X", +// 1 line(s) in the corpus share this shape +// first seen: specs/tri/graph/dijkstra.t27:15 +// signals: doubled-bracket,odd-quote +pub struct Damaged { + parent : [[]?Usize", +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_10.t27 b/bootstrap/tests/fixtures/damage/damage_class_10.t27 new file mode 100644 index 000000000..096cf0e18 --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_10.t27 @@ -0,0 +1,9 @@ +module damage_class_10 + +// shape: [X.X(X, []X)", +// 1 line(s) in the corpus share this shape +// first seen: specs/tri/graph/graph.t27:14 +// signals: odd-quote +pub struct Damaged { + nodes : [std.HashMap(T, []T)", +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_11.t27 b/bootstrap/tests/fixtures/damage/damage_class_11.t27 new file mode 100644 index 000000000..1cd48eca6 --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_11.t27 @@ -0,0 +1,9 @@ +module damage_class_11 + +// shape: [[]?*X", +// 1 line(s) in the corpus share this shape +// first seen: specs/tri/trees/b_tree.t27:15 +// signals: doubled-bracket,odd-quote +pub struct Damaged { + children : [[]?*BTreeNode", +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_12.t27 b/bootstrap/tests/fixtures/damage/damage_class_12.t27 new file mode 100644 index 000000000..689a32ea5 --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_12.t27 @@ -0,0 +1,9 @@ +module damage_class_12 + +// shape: [[][9, +// 1 line(s) in the corpus share this shape +// first seen: specs/tri/trees/quadtree.t27:23 +// signals: doubled-bracket +pub struct Damaged { + points : [[][2, +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_13.t27 b/bootstrap/tests/fixtures/damage/damage_class_13.t27 new file mode 100644 index 000000000..5c6756ca4 --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_13.t27 @@ -0,0 +1,9 @@ +module damage_class_13 + +// shape: [[9]?*X", +// 1 line(s) in the corpus share this shape +// first seen: specs/tri/search/aho_corasick.t27:14 +// signals: odd-quote +pub struct Damaged { + children : [[256]?*ACTrieNode", +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_14.t27 b/bootstrap/tests/fixtures/damage/damage_class_14.t27 new file mode 100644 index 000000000..0b4bfaf39 --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_14.t27 @@ -0,0 +1,9 @@ +module damage_class_14 + +// shape: [[]X(X, X)", +// 1 line(s) in the corpus share this shape +// first seen: specs/tri/collections/btree.t27:21 +// signals: doubled-bracket,odd-quote +pub struct Damaged { + children : [[]BTreeNode(K, V)", +} diff --git a/bootstrap/tests/fixtures/damage/damage_class_15.t27 b/bootstrap/tests/fixtures/damage/damage_class_15.t27 new file mode 100644 index 000000000..85a5082ae --- /dev/null +++ b/bootstrap/tests/fixtures/damage/damage_class_15.t27 @@ -0,0 +1,9 @@ +module damage_class_15 + +// shape: [[]?X(X)", +// 1 line(s) in the corpus share this shape +// first seen: specs/tri/collections/skip_list.t27:15 +// signals: doubled-bracket,odd-quote +pub struct Damaged { + forward : [[]?SkipNode(T)", +} diff --git a/bootstrap/tests/fixtures/terminator/eof_generic.t27 b/bootstrap/tests/fixtures/terminator/eof_generic.t27 new file mode 100644 index 000000000..6bbe35ead --- /dev/null +++ b/bootstrap/tests/fixtures/terminator/eof_generic.t27 @@ -0,0 +1,8 @@ +module eof_generic + +// End of file inside an unclosed generic argument list. The lexer returns Eof +// for every subsequent call, so a recovery scanner that refuses to accept Eof as +// a terminator has a stationary state on an infinite input, and the loop is a +// consequence of the construction rather than a rare race. +pub struct Hangs { + a : Map, + tuple : (A, B), + nested : Vec>, + arr : [4]u16, + last : i32 +} + +pub fn use_holder(h : Holder) -> u8 { + return h.plain; +} diff --git a/bootstrap/tests/fixtures/terminator/semicolon_phantom.t27 b/bootstrap/tests/fixtures/terminator/semicolon_phantom.t27 new file mode 100644 index 000000000..a08c37c7c --- /dev/null +++ b/bootstrap/tests/fixtures/terminator/semicolon_phantom.t27 @@ -0,0 +1,6 @@ +module semicolon_phantom + +pub struct Semi { + a : Map, u8), + last : u16 +} diff --git a/bootstrap/tests/fixtures/terminator/unbalanced.t27 b/bootstrap/tests/fixtures/terminator/unbalanced.t27 new file mode 100644 index 000000000..1977f275d --- /dev/null +++ b/bootstrap/tests/fixtures/terminator/unbalanced.t27 @@ -0,0 +1,8 @@ +module unbalanced + +pub struct Broken { + a : Map u8 { return 0; } diff --git a/bootstrap/tests/struct_body_terminator.rs b/bootstrap/tests/struct_body_terminator.rs new file mode 100644 index 000000000..0eb0e5fb4 --- /dev/null +++ b/bootstrap/tests/struct_body_terminator.rs @@ -0,0 +1,325 @@ +// ============================================================================ +// #2127 -- nesting decides the separator, never the terminator. +// +// The field collector consults bracket nesting depth when it meets a `Comma`, +// because a comma inside `Map` or `(A, B)` separates type arguments and +// not fields. It must NOT consult depth when it meets a terminator: `RBrace`, +// `Semicolon` and `Eof` end the field list at any depth. Truncated input leaves +// the depth counter positive, so a depth-gated terminator is never accepted and +// the loop runs to the end of the token stream -- or, with a buggy bound, does +// not stop at all. +// +// The test that matters here is therefore a LIVENESS test, not an output test: +// a spec whose last line opens a bracket and then ends must make the parser +// TERMINATE. Asserting on the error message alone would pass even if the parser +// hung, because a hung process never produces a message to compare -- the test +// would sit there until the CI job's own timeout killed it, and report as +// infrastructure flake rather than as this defect. +// +// So every case here runs under a HARD wall-clock timeout enforced in-process. +// Exceeding it fails the test with a message that names the hang, and the child +// is killed so a wedged parser cannot outlive the test binary. +// +// Deliberately NOT asserted: the exact text of the parse error. On malformed +// input there is no single correct reading of the field list, and pinning the +// message would freeze one arbitrary recovery as the specification. What is +// pinned is: the process ends, its exit status is non-zero, it names the token +// it stopped at, and it fails as a diagnostic rather than as a panic. +// +// The wording is deliberately not pinned either. The recorded baseline for this +// fixture read `Error: Parse error: Expected RBrace, got Eof` and the binary now +// prints `Error: Expected RBrace, got Eof` -- the prefix moved at some point +// between the two. An assertion on the phrase "parse" fails on that alone while +// the parser behaves correctly, which is a test measuring diagnostic prose +// instead of parser behaviour. It asserts on the token names instead, because +// those are what the invariant is about. +// ============================================================================ + +use std::io::Read; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +/// Wall-clock ceiling for a single parse of a four-line fixture. Generous by +/// three orders of magnitude: these files are under 100 bytes, and a healthy +/// parse of them is a few milliseconds even on a loaded shared runner. The +/// number exists to separate "slow" from "not returning", and 10 s cannot be +/// reached by any amount of ordinary slowness on this input. +const HARD_TIMEOUT: Duration = Duration::from_secs(10); + +fn t27c() -> &'static str { + env!("CARGO_BIN_EXE_t27c") +} + +/// Pull the `(field name, type text)` pairs out of a `t27c parse` dump. +/// +/// The three liveness cases above use `check`, which prints a verdict and hides +/// the field list. The two cases below are about the field list itself, so they +/// read the parse dump. This is the same extraction the corpus differential used, +/// reimplemented here without a regex crate: find each `name:` line and take the +/// `extra_type:` two lines below it. +fn fields(name: &str) -> Vec<(String, String)> { + let path = fixture(name); + let out = Command::new(t27c()) + .arg("parse") + .arg(&path) + .output() + .expect("failed to spawn t27c parse"); + let text = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + let lines: Vec<&str> = text.lines().collect(); + let unquote = |s: &str, key: &str| -> Option { + let t = s.trim(); + let rest = t.strip_prefix(key)?.trim(); + let rest = rest.strip_prefix('"')?; + let end = rest.rfind('"')?; + Some(rest[..end].to_string()) + }; + let mut out_pairs = Vec::new(); + for i in 0..lines.len() { + if !lines[i].trim_start().starts_with("kind: ExprIdentifier") { + continue; + } + if i + 3 >= lines.len() { + continue; + } + let n = unquote(lines[i + 1], "name:"); + let ty = unquote(lines[i + 3], "extra_type:"); + if let (Some(n), Some(ty)) = (n, ty) { + out_pairs.push((n, ty)); + } + } + out_pairs +} + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("terminator") + .join(name) +} + +struct Outcome { + status: i32, + output: String, + elapsed: Duration, +} + +/// Run `t27c check ` under a hard timeout. +/// +/// The child is spawned, then waited on from a helper thread so the main thread +/// keeps a clock the child cannot influence. On timeout the child is killed +/// before the assertion fires, so a wedged parser does not survive the test. +fn parse_within_timeout(name: &str) -> Outcome { + let path = fixture(name); + assert!(path.exists(), "fixture missing: {}", path.display()); + + let mut child = Command::new(t27c()) + .arg("check") + .arg(&path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn t27c"); + + let started = Instant::now(); + let mut out = child.stdout.take().expect("stdout"); + let mut err = child.stderr.take().expect("stderr"); + + // Drain both pipes from threads. A parser that fills a pipe buffer and then + // blocks on write would otherwise look like a hang caused by this test. + let (otx, orx) = mpsc::channel(); + thread::spawn(move || { + let mut s = String::new(); + let _ = out.read_to_string(&mut s); + let _ = otx.send(s); + }); + let (etx, erx) = mpsc::channel(); + thread::spawn(move || { + let mut s = String::new(); + let _ = err.read_to_string(&mut s); + let _ = etx.send(s); + }); + + // Poll rather than block, so the timeout is owned by this thread. + let status = loop { + match child.try_wait().expect("try_wait") { + Some(st) => break st, + None => { + if started.elapsed() > HARD_TIMEOUT { + let _ = child.kill(); + let _ = child.wait(); + panic!( + "HANG: t27c check {} did not terminate within {:?}. \ + This is #2127: a terminator ({{RBrace, Semicolon, Eof}}) was \ + gated on bracket nesting depth, and truncated input leaves \ + the depth positive, so the field loop never accepted an end.", + name, HARD_TIMEOUT + ); + } + thread::sleep(Duration::from_millis(20)); + } + } + }; + + let elapsed = started.elapsed(); + let combined = format!( + "{}{}", + orx.recv_timeout(Duration::from_secs(5)).unwrap_or_default(), + erx.recv_timeout(Duration::from_secs(5)).unwrap_or_default() + ); + Outcome { + status: status.code().unwrap_or(-1), + output: combined, + elapsed, + } +} + +/// The case the fix is about: the file ends mid-type, at depth 1, with no +/// closing brace anywhere. `Eof` must end the field list at any depth. +#[test] +fn eof_at_positive_depth_terminates() { + let r = parse_within_timeout("eof_hazard.t27"); + assert!( + r.elapsed < HARD_TIMEOUT, + "took {:?}, ceiling {:?}", + r.elapsed, + HARD_TIMEOUT + ); + assert_ne!( + r.status, 0, + "truncated input must be rejected, not accepted; output was:\n{}", + r.output + ); + let lower = r.output.to_lowercase(); + assert!( + lower.contains("eof"), + "the diagnostic must name the end of input it stopped at, got:\n{}", + r.output + ); + assert!( + !lower.contains("panicked") && !lower.contains("unwrap"), + "must fail as a diagnostic, not as a panic:\n{}", + r.output + ); +} + +/// A type argument list left open, then a further field, then a closing brace. +/// `RBrace` must end the field list even though depth is still positive. +#[test] +fn rbrace_at_positive_depth_terminates() { + let r = parse_within_timeout("unbalanced.t27"); + assert!( + r.elapsed < HARD_TIMEOUT, + "took {:?}, ceiling {:?}", + r.elapsed, + HARD_TIMEOUT + ); + let lower = r.output.to_lowercase(); + assert!( + !lower.contains("panicked"), + "must not panic:\n{}", + r.output + ); +} + +/// The control, and the reason the two above are not vacuous: a well-formed +/// struct whose fields contain commas inside `Map`, `(A, B)`, `Vec>` +/// and `[4]u16` must still parse cleanly. A fix that accepted any terminator by +/// ignoring depth altogether would break this one, by treating a comma inside a +/// type argument list as a field separator. +#[test] +fn commas_inside_types_are_not_field_separators() { + let r = parse_within_timeout("nested_types.t27"); + assert!( + r.elapsed < HARD_TIMEOUT, + "took {:?}, ceiling {:?}", + r.elapsed, + HARD_TIMEOUT + ); + assert_eq!( + r.status, 0, + "well-formed nested types must parse; output was:\n{}", + r.output + ); +} + +// --------------------------------------------------------------------------- +// The two cases below discriminate the fixed parser from the unfixed one. The +// three liveness cases above do NOT: run against the pre-fix binary they pass +// unchanged, because `check` prints the same verdict either way. A test that +// cannot fail on the defect it names is a regression guard, not evidence, and +// these two exist so the branch has at least one of each. +// --------------------------------------------------------------------------- + +/// The improvement, stated as a field set. `Map` should be. Before the fix the collector produced a PHANTOM field +/// named `V` with an empty type -- an identifier from inside a type argument +/// list promoted to a field of the struct. After the fix `Semicolon` terminates +/// at any depth, `V` stays inside the type text, and the struct has exactly the +/// two fields it was written with. +/// +/// This is the case that fails on the pre-fix binary, and the reason the change +/// is worth making. +#[test] +fn semicolon_at_depth_does_not_invent_a_field() { + let f = fields("semicolon_phantom.t27"); + let names: Vec<&str> = f.iter().map(|(n, _)| n.as_str()).collect(); + assert!( + !names.contains(&"V"), + "phantom field V promoted out of a type argument list: {:?}", + f + ); + assert_eq!(names, vec!["a", "b"], "expected exactly the declared fields: {:?}", f); +} + +/// The cost, pinned so it cannot grow unnoticed. +/// +/// On a type argument list left open by a comma, the fixed collector absorbs the +/// following `name : type` pairs into the type text of the first field: three +/// declared fields become one, whose type reads `Map `315fbe1d...`. That is a GOLD-RING seal and needs explicit human approval; it is not a mechanical consequence of the patch +# NOW -- the loop tools are in the repository, and the differential now names the loss (2026-08-15) + +Last updated: 2026-08-15 + +## tooling: restore tri cost and tri diffbin, and stop aggregating field loss to zero (Closes #2158) + +- **Two measurement tools were lost, and with them every number they had produced.** `scripts/tri_loop/cost.py` and `diffbin.py` were written, quoted in a pull request, and never committed; the working copy was later re-cloned. Six recovery routes came back empty: dangling git objects held only a `git stash` WIP with `triage.py`, the reflog records the clone rather than the content, shell history is absent, CI artifacts hold only FPGA outputs, no PR or issue comment carries the source, and the session snapshot preserves prose about the scripts instead of the scripts. They are reimplemented here from a written contract, not reconstructed from memory +- **`tri diffbin` no longer produces a single verdict.** Five categories, matched in order: `unchanged`, `field-loss`, `strict-improvement`, `malformed-input-tradeoff`, `unknown`. `field-loss` is tested BEFORE `strict-improvement`, so a change that removes a phantom field and also drops a declared one is a loss and not an improvement, and it is never folded into another count +- **The discrimination that the old aggregate missed: a removed field is a phantom only if its type text in the base was EMPTY.** That is the signature of an identifier lifted out of a type argument list; a removed field with a non-empty base type was declared by the author and its removal is a loss. Counting fields alone cannot tell the two apart +- **Re-measured, same 634 specs, same two binaries: 616 unchanged, 13 field-loss, 1 strict-improvement, 4 malformed-input-tradeoff, 0 unknown.** The previous tool reported "0 regressions" over this identical corpus. `specs/tri/agent/handoff.t27` goes from 35 parsed fields to 12 +- **All 17 files whose field sets moved are inside the damaged set, and no well-formed spec changed at all.** 13 of 13 `field-loss` and 4 of 4 `malformed-input-tradeoff` are files that carry a mangled type annotation. `0 unknown` is the load-bearing number: no clean input changed behaviour +- **`tri cost` reports per stratum and refuses a cross-family exponent.** n, median, p95, min-max ms/KB and coefficient of variation per spec family; alpha only at n >= 8, printed with its r2 and its KB range. A single exponent across strata is a metric of corpus composition rather than of the parser (#2133), so it is not printed at all -- a number gets quoted and its caveat does not travel with it +- **`tri damage` classifies the corrupt annotations by shape instead of repairing them (#2154).** 125 lines in 65 files, 15 distinct shapes; one emitted fixture per shape. The first draft of the classifier reported 429 lines, of which 230 were `target : < 5000ns` -- a legitimate less-than bound. Two signals survive, `[[]` and an odd `"`, and the fix was deleting the bad signals rather than tuning a threshold +- **`scripts/ci/loop-tools-tracked.sh` makes the loss impossible rather than regrettable.** It fails when a loop tool is missing, when it exists but git does not track it, when anything under `scripts/tri_loop/` is untracked, or when the generic dispatch line is gone. Verified to fail in exactly the pre-loss state +- **The dispatcher looked for a built compiler before dispatching helpers that do not use one.** `tri triage` and `tri damage` read the tracker and the spec text; on a machine with no build they refused to run. Loop dispatch now precedes the binary lookup + # NOW -- BNF: the control that measures what ternary is worth (2026-08-09) Last updated: 2026-08-09