Skip to content

parser: nesting decides the separator, never the terminator (#2127) - #2151

Open
gHashTag wants to merge 2 commits into
masterfrom
w699-struct-body-terminator
Open

parser: nesting decides the separator, never the terminator (#2127)#2151
gHashTag wants to merge 2 commits into
masterfrom
w699-struct-body-terminator

Conversation

@gHashTag

Copy link
Copy Markdown
Owner

What was wrong

parse_struct_body collected raw lexemes until a comma, so a comma inside a nested type ended the field. On a fixture with six declared fields:

field type collected by master
plain u8
generic Map<Key ← truncated
Value (empty) ← phantom field
tuple (A ← truncated
B (empty) ← phantom field
nested Vec<Vec<u8>>
arr [4]u16
last i32

Six fields parsed as eight. After this change: generic : Map<Key,Value>, tuple : (A,B), phantoms gone, exactly six.

Why the two earlier attempts were reverted, and what is different

Both reverted repairs failed the same way: they let a depth counter decide a block terminator.

  • attempt (a) let depth suppress RBrace → the scanner ate the struct's closing brace (161→171 recovery events, 0→6 specs capturing nothing)
  • attempt (b) let depth suppress Eof → the build hung

The hang is not bad luck, it is forced. The lexer yields Eof indefinitely and never changes the depth counter, so if there is any depth at which Eof is not accepted as a terminator, the scanner's state is stationary while the token stream is infinite. A recovery scanner's termination must not depend on its input being well-formed — that input is precisely why it exists.

This change follows the rule that both attempts lacked:

RBrace, Semicolon and Eof terminate unconditionally at any depth. Only Comma consults depth.

Depth is tracked over (/), [/], and </> (the latter only when < directly follows an identifier, so < as a comparison operator cannot open a level; >> closes two).

Evidence

Both binaries built from this tree, run over the same corpus — all 634 specs outside specs/scratch:

verdict files
unchanged 616
improved (truncated type removed) 4
changed field set 14
regression of any kind 0

No file where the candidate fails, hangs, or leaves more truncated types than the baseline.

The Eof hazard has a fixture rather than an argument: a struct whose last field is an unclosed generic at end of file exits 1 with Parse error: Expected RBrace, got Eof ('') at line 5:1, in under a second.

The limit, stated plainly

On malformed input the new collector absorbs the following field into the type text (a : Map<K,b:u8) where master truncated the type instead. That is one kind of damage traded for another on input that is already broken. On well-formed input it is strictly better, and that is the only claim made here.

Tooling added, and why it belongs in this PR

scripts/tri_loop/diffbin.py (tri diffbin) is the tool that produced the table above; it exits non-zero on any regression, so the check a reviewer would want is now one command. Given that two prior repairs to this exact function were merged and reverted, the check being cheap is part of the fix.

tri cost and tri triage come from the same session. tri cost reports the per-kilobyte spread next to the scaling exponent, because an exponent fitted over a heterogeneous sample is partly a composition metric rather than a property of the parser — the confound #2133 named for the ratchet. Measured inside one homogeneous family (wNNN_bench_module_NNx2p6_aos_var_call_write, n=39, 342KB…818KB): alpha = 0.940, R^2 = 0.986, per-KB spread 10.65% — i.e. linear. See the comment left on #1781.

Closes #2127

parse_struct_body collected raw lexemes until a comma, so a comma inside a
nested type ended the field. Map<Key, Value> became field `generic : Map<Key`
plus a phantom field named `Value`; (A, B) behaved the same way. Six declared
fields parsed as eight.

Two earlier repairs were reverted. Both failed the same way: they let a depth
counter decide a block terminator. Attempt (a) let depth suppress RBrace and
the scanner ate the struct's closing brace. Attempt (b) let depth suppress Eof
and the build hung, because the lexer yields Eof indefinitely while the depth
counter never moves, so the state is stationary and the token stream infinite.

The rule this commit follows: RBrace, Semicolon and Eof terminate
unconditionally at any depth; only Comma consults depth. Termination therefore
does not depend on the input being well-formed, which is what a recovery
scanner exists for.

Measured over all 634 specs outside specs/scratch, candidate against the binary
it replaces: 616 unchanged, 4 improved, 14 changed field sets, 0 regressions of
any kind. scripts/tri_loop/diffbin.py produced that table and exits non-zero on
any regression.

Also adds tri cost and tri triage. tri cost reports per-KB spread next to the
scaling exponent, because an exponent fitted over a heterogeneous sample is a
composition metric rather than a property of the parser -- the confound #2133
named for the ratchet, which #1781 hit for parse scaling.

Closes #2127
@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-14 18:21:30 UTC

Summary

Status Count
Total Open PRs 20
PRs with Failing Checks 7
PRs with All Checks Green 13
READY 5
FAILING 7
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=7d4a84202c82 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@gHashTag

Copy link
Copy Markdown
Owner Author

Merge-gate diagnosis: both red checks reproduce on the base, with the same file and line

The order was: establish the cause of the red fpga-formal before this PR is considered, and give every changed field set a reason-code instead of leaning on an aggregate. Both are done. Nothing here asks for a merge.

The two red checks, named exactly

Workflow .github/workflows/fpga-build.yml, run 31828240818 on PR head 8c17fc8e9f3b6cbfbbdc65558a8519e4f3d251f5:

job failing step first differing failure
fpga-formal Install Yosys + SymbiYosys pip3 install sby — there is no PyPI package named sby (404). SymbiYosys builds from source.
fpga-synthesis FPGA synthesis (Yosys + JSON netlist) build/fpga/generated/mac.v:535: ERROR: Failed to evaluate system task '$display' with non-constant argument at position 2.

fpga-formal carries continue-on-error: true; fpga-synthesis does not. The workflow is failed by synthesis, not by formal.

Three-way comparison collapses to two-way, and the base is red

The PR base is b92872507f6c7619acce43e5ae262b1dc9c4cbf2, which is current master — so base and master are the same commit. That commit has no check-runs of its own because the workflow's paths filter never fired for it. The comparison was therefore made against the three most recent master runs that did fire:

run head_sha fpga-formal step fpga-synthesis step
31301199852 533b0abc Install Yosys + SymbiYosys mac.v:535 $display
31291041217 d4c768e3 same same
31285920516 342c09ba same same

Identical job, identical step, identical file and line number. Over the whole history of fpga-build.yml since 2026-05-30: 600 runs — 598 failure, 2 cancelled, 0 success, and 184 of 184 runs on master failed. This is a baseline failure. It is not PR-induced, and no revision of this branch can turn it green.

Two defects behind the synthesis failure, both pre-existing

  1. The guard is inverted for the synthesis path. bootstrap/src/compiler.rs:7352 emits benchmark blocks under `ifndef SIMULATION, i.e. they are included unless SIMULATION is defined. The only place that defines it is bootstrap/src/suite.rs:859 (read_verilog -sv -DSIMULATION); the synthesis path in bootstrap/src/main.rs:4937 emits a bare read_verilog. So benchmark $display statements reach Yosys on every synthesis run. There are 121 such guards across 31 generated filesmac.v:535 is simply the first one Yosys reaches.

  2. %%0d is emitted literally. compiler.rs:7376 builds the line inside format!, where %% is not an escape — only {} is. The generated file contains %%0d 8 times in mac.v and 0 occurrences of %0d. In Verilog %% prints a literal %, so the value argument is never consumed by a conversion specifier.

Both are independent of this PR and belong in their own issue; I have not touched them here.

Every changed field set has a reason-code

The aggregate "0 regressions" is not admissible alone, so each of the 18 files whose field set moved (14 CHANGED + 4 IMPROVED) was classified from its actual before/after field lists by rule, not by eye:

reason-code files
accepted-malformed-tradeoff 17
phantom-removed (a field that existed only because a type was cut in two) 1
unexpected 0

Zero unexpected — the gate criterion is met. Script and data: the classifier reads the two pinned binaries and re-parses each file, it does not re-read the earlier aggregate.

Why accepted-malformed-tradeoff is the honest label, with the input quoted. These are not well-formed specs. specs/tri/crypto/crypto.t27:14:

public_key : [[]U8",
private_key : [[]U8",

An unbalanced [ and a stray closing quote. On such a line the old collector truncated the type; the new one absorbs the following name : type pair into the type text. Neither is right, because there is no right answer for that input — but nothing that was whole became broken.

Self-correction on the earlier aggregate: the four files previously labelled IMPROVED also absorb a following field once inspected per-file. Their truncated-type count genuinely drops (tri/agent/handoff.t27: 8 → 4), so the improvement is real, but the label was optimistic and the stricter per-file rule puts them under the tradeoff code.

A corpus defect this surfaced, which is larger than the PR

63 spec files contain 115 field lines of the form : [[]…". Sixteen of the eighteen files whose field set moved are in that set. So the "14 changed field sets" are very largely not a property of the parser change at all — they are the parser's behaviour on 115 corrupted source lines. Separately, 121 files use quoted type texts (: "Ident"). Filing this as its own issue; it is not fixed here.

Merge-gate, stated as a checklist

  • All four required checks green: check, validate, check-now-freshness, check-linked-issue.
  • Remaining red checks documented as reproducing on the base, with commit SHAs, job names, failing step names and the first differing failure.
  • Differential run over 634 non-scratch specs: 616 unchanged, 4 with fewer truncated types, 14 changed, 0 regressions (no new failure, no new hang, no newly truncated type).
  • Every changed field set carries a reason-code; unexpected = 0.
  • EOF fixture as a test with a hard timeout — open.
  • Review of the unconditional-terminator rule (RBrace, Semicolon, Eof terminate at any depth; only Comma consults nesting depth) — open, for a human.

Two items remain, so this is not a merge request.

…an honest cost (Closes #2127)

Five tests over five fixtures in bootstrap/tests/struct_body_terminator.rs.

Three are liveness tests under a 10 s in-process wall-clock ceiling. Truncated
input leaves bracket depth positive, so a depth-gated terminator is never
accepted and the field loop runs past the end of the token stream. Asserting on
error text alone would pass on a hang, because a hung process produces no text
to compare and the CI job timeout would report it as flake. The child is killed
before the assertion fires.

Those three do not discriminate fixed from unfixed: checked against the pre-fix
binary, identical verdict, message and timing. They are regression guards, not
evidence.

Two new fixtures discriminate. semicolon_phantom is the improvement: Map<K, V;
made the pre-fix collector emit a phantom field V with an empty type. Now the
struct has exactly its two declared fields. field_swallow is the cost: on a type
argument list left open by a comma, three declared fields become one typed
Map<K,b:u8,c:u16, where the pre-fix collector truncated and kept three.

That corrects how the corpus differential reads. "0 regressions" did not mean no
field was lost; it meant loss on malformed input was accepted as a tradeoff. The
test pins the loss at one level and inside one struct rather than claiming a
reading is right.

Diagnostic wording is not pinned: the recorded baseline said "Parse error:" and
the binary no longer does, so an assertion on that word failed while the parser
was correct. Token names are asserted instead.
@github-actions

Copy link
Copy Markdown
Contributor

📓 NotebookLM Notebook linked to this PR

This notebook contains session context, decisions, and artifacts for this work.

@github-actions

Copy link
Copy Markdown
Contributor

PR Dashboard

Generated at: 2026-08-14 19:07:31 UTC

Summary

Status Count
Total Open PRs 21
PRs with Failing Checks 7
PRs with All Checks Green 14
READY 6
FAILING 7
PENDING 0

Seal Status

  • ⚠️ STALE -- sha256(compiler.rs)=7d4a84202c82 != manifest seal=87e5cbd3ad94.
    The committed NMSE numbers were certified against an older compiler.rs.
    Run scripts/reseal-check.sh locally for the two-step reseal command (advisory; not a merge gate).

@gHashTag

Copy link
Copy Markdown
Owner Author

Correction: the "0 regressions" claim was weaker than it sounded, and I found it by trying to make the fixtures fail

The EOF fixture is now a test — five tests over five fixtures in bootstrap/tests/struct_body_terminator.rs, pushed as 861155d. Building it forced a finding that lowers this PR's readiness rather than raising it, so it goes here before anything else.

The hard timeout, and why it is the point

Three cases run under a 10 s in-process wall-clock ceiling. Truncated input leaves bracket depth positive, so a terminator gated on depth is never accepted and the field loop runs past the end of the token stream. Asserting only on the error message would pass on a hang: a hung process never produces a message to compare, the test would sit until the CI job's own timeout killed it, and the result would read as infrastructure flake instead of as #2127. The ceiling is owned by the test thread, both pipes are drained from helper threads so a full pipe buffer cannot masquerade as a hang, and the child is killed before the assertion fires so a wedged parser cannot outlive the test binary.

Those three tests are worth less than they look

Run against the pre-fix binary, all three pass unchanged — identical verdict, identical message, identical timing (106 ms vs 106 ms). t27c check prints the same thing either way. A test that cannot fail on the defect it names is a regression guard, not evidence. I only learned this by running the old binary on purpose; had I stopped at "5/5 green" I would have reported the merge-gate satisfied on the strength of three tests that witness nothing.

Two fixtures that do discriminate

Field sets from t27c parse, pre-fix versus post-fix:

fixture pre-fix post-fix
semicolon_phantom.t27 (a : Map<K, V;) a:"Map<K", V:"", b:"u8" a:"Map<K,V", b:"u8"
field_swallow.t27 (a : Map<K, then b, c) a:"Map<K", b:"u8", c:"u16" a:"Map<K,b:u8,c:u16" — one field

The first is the improvement and the reason the change is worth making: the pre-fix collector promoted V, an identifier from inside a type argument list, into a phantom field of the struct. That is a real defect with a real fix, and this test fails on the old binary.

The second row is the correction

Three declared fields become one. That is a loss of fields on malformed input. The corpus differential declared regression as "the fixed binary extracts fewer fields than the base" — and this is exactly that, yet the run reported 0 regressions. Both statements are true because the per-file classifier labelled it accepted-malformed-tradeoff, which is a judgement, not an absence.

So the honest reading of the differential is: "0 regressions" means no field loss was found that I judged unacceptable — not that no field was lost. The 17 files under accepted-malformed-tradeoff are 17 instances of this. The aggregate wording invited the stronger reading and I should not have let it stand unqualified in the earlier comment.

What this does to the merge-gate

The differential criterion is not satisfied as originally written. Restating it as what was actually established:

  • All four required checks green.
  • Remaining red checks reproduce on the base — see the diagnosis above; now filed as fpga-synthesis has never once passed: the SIMULATION guard is inverted, and %%0d is emitted literally #2153.
  • EOF fixture is a test and passes under a hard timeout, with the pre-fix binary checked so the tests' discriminating power is known rather than assumed.
  • Every changed field set carries a reason-code; unexpected = 0.
  • diffbin on 634 specs without regressions — NOT satisfied as written. 616 files unchanged and no crash, hang or new truncation; but 17 files lose fields on malformed input, accepted by judgement rather than by measurement. Either the criterion is restated to permit accepted loss with a per-file justification, or the fix is reworked so nothing is lost — and reworking it means deciding what a : Map<K, followed by b : u8 should mean, which is a language question and not a parser one.
  • Human review of the unconditional-terminator rule.

Two open, one of them newly reopened by this comment. Still not a merge request, and now less ready than the previous comment implied.

One more thing the fixtures surfaced

field_swallow.t27 and semicolon_phantom.t27 both typecheck clean (Typecheck OK (0 errors, 0 warnings), exit 0) on both binaries, despite Map<K, never closing. Malformed type text is being accepted silently all the way through typecheck. That is not this PR's doing and is not fixed here, but it explains why 63 corrupt spec files (#2154) sat in the tree unnoticed: nothing complained.

@gHashTag

Copy link
Copy Markdown
Owner Author

Status: not-ready. Bounded evidence only. Not for merge.

The differential behind this PR covered 868 of 1089 files, 79.7 %. The
remaining 221 are not-evaluated: 195 where both binaries hit the 12 s wall,
26 where only the candidate did.

Zero regressions on 79.7 % of the corpus is not the absence of regressions. The
221 files are part of the result, not statistical noise, and the two numbers must
travel together or the first one is misleading. The reason this matters here
specifically: the 26 candidate-only timeouts sit at the threshold rather than
below it -- median candidate/base ratio 1.010 over three runs each way, with
files taking 10.8-11.7 s against a 12 s limit. Whatever happens on those files
was not observed, in either direction.

Two further reasons not to merge yet:

  1. The base/candidate binaries have no recorded provenance. They were built
    before sealing existed, so docs/evidence/seal_m2162_pair.json (ci: stop interpolating untrusted event data into shell, and seal evidential binaries #2172) is
    written with its commit field empty and provenance: unrecorded-at-build-time. Writing today's HEAD there would manufacture a
    chain that does not exist. The differential is therefore not reproducible
    from a named commit -- it is reproducible only from two files in /tmp that
    a sandbox reset destroys.
  2. The field-loss = 0 gate is still not passed. 8 of 8 remaining files
    contain a destroyed line, and the earlier hypothesis that the remainder was
    explained by the 10 unrecoverable-source-loss lines was refuted by
    measurement: of 13 field-loss files, 5 were unrecoverable and 8 repairable.

What would move this to ready, in order:

  • rebuild both binaries from named commits with scripts/ci/rebuild_evidence.sh
    so the pair is sealed with real provenance;
  • re-run the differential with the timeout raised past the 26 borderline files,
    or with a stated per-file budget, and report coverage explicitly;
  • resolve the 18 destroyed lines as an explicitly excluded
    unrecoverable-source-loss set, so field-loss = 0 is a claim about a corpus
    with a defined status for every entry.

Leaving open. Not closing, not merging.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wave 692: a fourth copy of the type parser, not repairable in isolation

1 participant