Skip to content

HELD: fix(perl): bareword-call derivation rescue, parked pending merge-time election - #645

Closed
odvcencio wants to merge 3 commits into
mainfrom
willow/perl-bareword-call
Closed

HELD: fix(perl): bareword-call derivation rescue, parked pending merge-time election#645
odvcencio wants to merge 3 commits into
mainfrom
willow/perl-bareword-call

Conversation

@odvcencio

@odvcencio odvcencio commented Aug 2, 2026

Copy link
Copy Markdown
Owner

HELD. Do not merge. This branch is parked pending the merge-time
derivation-election lane. The zero-width external-token rescue is sound and
should be re-attempted on its own once that lane lands. The two recovery
gates it needs today are heuristics about how long to keep a version alive,
which is a proxy for the merge-time election Go never performs. This PR
exists as the record and as the acceptance instrument for that lane.

What is here

  1. The Perl bareword-call derivation repair (parser_dfa_token_source.go).
    Sound, measured, and the part worth re-attempting alone.
  2. Two recovery gates (parser.go) the repair needs today. These are what
    the hold is about.

Root cause of the derivation loss

Perl declares the conflict [$.function, $.function_call_expression]
(tree-sitter-perl grammar.js:170). At state 873 with lookahead ( the blob
holds two actions: reduce to function, and shift into state 4185. State
4185 has an action only on _NONASSOC (symbol 289), the zero-width external
token that commits to the unambiguous form.

C lexes every stack version with that version's own lex mode, so the version
parked on 4185 always receives _NONASSOC. Go lexes one token for the whole
GLR frontier and then arbitrates in preferGLRUnionDFAOverExternalToken. With
support tied 1-1 the tie-break is tokenSymbolSpecificity, a name-shape
heuristic that scores a one-character name 3 and an underscore-prefixed name

  1. ) beat _NONASSOC, and the fork died one token later.

Traced receipts on g();, production route: the fork IS created and DOES
shift into 4185; action-table probing DOES mark _NONASSOC valid
(EXT valid pos=2 state=285 glr=[285 4185] valid=...,37:_NONASSOC); the
arbitration discards it (GLR ext/dfa choose dfa: ext=_NONASSOC(289)[2-2] support=1 dfa=)(17)[2-3] support=1); the fork dies (stack[1] KILLED: no action for sym=17 in state=4185).

The missing-ExternalLexStates hypothesis is refuted: the arbitration never
reads that table, probing already found the token, and perl's upstream
repository checks in no src/parser.c for the generator to read.

The reviewer's preferred narrowing -- require that no live stack is exclusive
to the DFA token -- was tested and reverts the repair outright, because perl's
own case has BOTH tokens exclusive on different stacks (4185 exclusive to
_NONASSOC, 285 exclusive to )). That condition is also already covered by
the existing dfaSupport < extSupport rung.

CORRECTED residual measurement

An earlier revision of this body reported the residual as "5 cases, max 32
bytes, mean 14". That was wrong, and wrong in the direction that matters.
It was an artifact of the mutation corpus using one-line bases for the two
residual shapes. Put a realistic tail behind the same defect and the loss is
the whole file. Measured against origin/main at 4347068, production route:

witness bytes main reaches this branch reaches lost
try { A(;) } catch($e) { B(); } 32 32, accepted 9, no_stacks_alive 23 (71%)
same + 2540 B valid tail 2572 2572, accepted 9, no_stacks_alive 2563 (99%)
try { A(); } catch$e) { B(); } 31 31, accepted 26, no_stacks_alive 5 (16%)
same + 2540 B valid tail 2571 2571, accepted 26, no_stacks_alive 2545 (98%)

The earlier note that catch$e) "enters a reduce-chain cycle" describes this
mechanism. On a one-line file it costs five bytes; on a real file it costs the
file. The reviewer independently measured the same class as iteration-limit
trips costing 1990-2196 bytes each (66-73% of file); my corpus surfaces it as
no_stacks_alive instead. Same defect, same magnitude class: the surviving
version resyncs into a state that cannot make progress and nothing after the
defect is parsed.

These four are now pinned as
TestPerlForkedFrontierRecoveryResiduals.

Stop-reason census, which coverage alone hid

1170-case deterministic Perl mutation corpus, production route, C-adjudicated:

stop reason base (4347068) rescue only kill gate only both gates
accepted 1073 973 973 1044
no_stacks_alive 97 197 197 126

Transitions base to both-gates: 47 cases move accepted to
no_stacks_alive, 18 move the other way. So the headline coverage number
(5 truncations against 24 repairs) is not the whole cost: a case can keep
its byte coverage and still change how the parse terminated. Pinned as
TestPerlForkedFrontierRecoveryStopReasonCensus.

Coverage table for completeness:

configuration truncates where main is complete complete where main truncates
rescue alone 69 (max 2576 B, mean 97) 6
kill gate only 69 (max 2576 B, mean 97) -- identical to rescue alone 6
both gates 5 (max 32 B on this corpus) 24

The kill gate alone is measurably a no-op; the resync gate does the work.

The change is NOT Perl-scoped

anotherLiveParseStackRemains replaces len(stacks) in the SHARED no-action
recovery ladder (parser.go), so it applies to all 206 grammars whether or
not they ever reach the zero-width rescue.

Julia receipt, four-variant attribution

corpus_real/julia/large__abstractinterpretation.jl, 224,616 bytes,
production route:

variant root coverage all nodes named ERROR nodes digest
base 4347068 source_file 224616/224616 47580 32057 59 dbb7d086
rescue only source_file 224616/224616 47626 32087 54 dbb7d086
kill gate only source_file 224616/224616 47626 32087 54 5783eece
both gates source_file 224616/224616 47630 32083 43 8cadacf0

The digests reproduce the reviewer's exactly. Two attribution facts:

  • The rescue does not move julia at all (dbb7d086, identical to base).
    The tree change is entirely the gates.
  • The kill gate alone already moves it (5783eece), which refines the
    earlier attribution to the resync gate: both gates move julia, the resync
    gate moves it further.

One discrepancy is open and the next attempt must settle it. The reviewer
measured 729 nodes against base's 1364 (46% fewer) with C at 31,721. On the
production route and on the compact route I measure 47,580 versus 47,630 all
nodes and 32,057 versus 32,083 named, with the branch producing FEWER ERROR
nodes (43 versus 59) and identical full coverage. I could not reproduce the
729/1364 figure by route. Whatever counter produced it is not the plain
tree walk, and it needs identifying before this lands.

Scope of the derivation repair, measured

This does NOT make every bareword call in Perl match C. It restores the
derivation for a bareword call whose fork does not compete with another
bareword call's fork. When two compete, one still loses the election:
A();\nB(); leaves the FIRST call ambiguous, sub f { A(); }\nsub g { B(); }
leaves the SECOND. Pinned in
TestPerlBarewordCallResidualDivergenceCOracleParity.

Witnesses that do flip, production route, versus the locked C oracle:

Source Before After
g(); 2 divergences 0
g($x); 3 0
foo(1, 2); 3 0
my $y = f(3); 3 0
Foo::bar(1); 3 0
{ A(); } divergent 0
sub f { A(); } divergent 0
if (1) { A(); } divergent 0
local_dynamic_scope 2 0
&g(); (control) 0 0
print("x"); (control) 0 0

Why this is held, in one paragraph

Every residual shares one shape: two versions at the same state, byte offset,
and depth, differing only in a buried subtree. C collapses that pair at
ts_stack_merge and elects one subtree with ts_parser__select_tree, so C's
frontier returns to one version immediately. Go refuses the merge because the
shapes differ (gssStacksHaveDistinctMaterializingShapes), so the duplicate
frontier persists and perturbs every stack-count-sensitive decision
downstream. Both gates in this PR are heuristics about version lifetime, which
is a proxy for that election. Landing the proxy buys about 21 net repairs
today and installs two shared-path heuristics the real lane must unwind, plus
a julia tree change it may not absorb.

Gates, against origin/main at 4347068

  • 206-language admission scorecard: byte-identical on every row including
    every digest. PASS=198 DIVERGE=0 FALLBACK=3 SKIP=5 ERROR=0. Note this gate
    cannot see the julia change: julia's smoke sample is one line.
  • GTS_PARITY_MODE=exhaustive TestParityFreshParse and
    TestParityStructuralCorpus: pass, 0 divergences.
  • go test ., go test ./grammars, go vet ./...: pass.
  • go test -tags gts_parsercorephase0 .: 15 failures, the identical set
    origin/main produces.
  • Full cgo_harness suite: 9 failures, the identical set origin/main
    produces.
  • -race over the lexer, external-scanner, GLR, relex, recovery, and resync
    paths: pass.

Out of scope, reported for its own lane

The 11-byte Perl input mb #$x =1;$ parses for more than two minutes on both
origin/main and this branch. Pre-existing and unrelated.

- Add logic to keep zero-width external tokens when a live GLR state requires them.
- Prevent the shared-frontier lexer from dropping these tokens in favor of higher-specificity DFA tokens.
- Add unit tests to verify the fork rescue and the existing specificity ladder.
- Add a parity test for Perl bareword calls against the C oracle.
- Remove two resolved witnesses from the known divergences list for Perl A3.
- Replace `len(stacks)` checks with a live-sibling predicate in the no-action recovery ladder
- The old gate counted versions already killed in the same dispatch pass, so a frontier that had ever forked lost recovery even when one live version remained and the parse ended at ParseStopNoStacksAlive with the rest of the file unparsed
- Add `anotherLiveParseStackRemains` predicate that skips dead siblings
- Add regression witnesses for the rescue scope and the recovery fix
- Update comments to document the external validity set mechanism and the cost of keeping forks alive
@odvcencio
odvcencio force-pushed the willow/perl-bareword-call branch from 2b3b72b to 54a46a7 Compare August 3, 2026 04:12
@odvcencio odvcencio changed the title fix(perl): restore the function-call derivation for bareword calls fix(perl): restore the function-call derivation for bareword calls, and keep recovery after a fork Aug 3, 2026
@odvcencio odvcencio changed the title fix(perl): restore the function-call derivation for bareword calls, and keep recovery after a fork HELD: fix(perl): bareword-call derivation rescue, parked pending merge-time election Aug 3, 2026
@odvcencio
odvcencio marked this pull request as draft August 3, 2026 04:51
- Pin two Perl recovery-gate defects where Go loses coverage compared to C
- Append a realistic tail to expose the true cost of losing the whole file
- Record the stop-reason shift from accepted to no_stacks_alive
- Fail explicitly when the merge-election lane repairs the defects

Copy link
Copy Markdown
Owner Author

Re-evaluated against the current head. Keep this PR held. The merge-event census is now integrated, but merge-time election is not. Do not merge the recovery gates yet.

@odvcencio odvcencio added bug Something isn't working area/parser Parser runtime, recovery, and GLR behavior area/grammars Grammar, scanner, and query support correctness Parser correctness and reference parity labels Aug 9, 2026
@odvcencio odvcencio closed this Aug 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/grammars Grammar, scanner, and query support area/parser Parser runtime, recovery, and GLR behavior bug Something isn't working correctness Parser correctness and reference parity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant