HELD: fix(perl): bareword-call derivation rescue, parked pending merge-time election - #645
Closed
odvcencio wants to merge 3 commits into
Closed
HELD: fix(perl): bareword-call derivation rescue, parked pending merge-time election#645odvcencio wants to merge 3 commits into
odvcencio wants to merge 3 commits into
Conversation
- 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
force-pushed
the
willow/perl-bareword-call
branch
from
August 3, 2026 04:12
2b3b72b to
54a46a7
Compare
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
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What is here
parser_dfa_token_source.go).Sound, measured, and the part worth re-attempting alone.
parser.go) the repair needs today. These are whatthe 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 blobholds two actions: reduce to
function, and shift into state 4185. State4185 has an action only on
_NONASSOC(symbol 289), the zero-width externaltoken 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 wholeGLR frontier and then arbitrates in
preferGLRUnionDFAOverExternalToken. Withsupport tied 1-1 the tie-break is
tokenSymbolSpecificity, a name-shapeheuristic that scores a one-character name 3 and an underscore-prefixed name
)beat_NONASSOC, and the fork died one token later.Traced receipts on
g();, production route: the fork IS created and DOESshift into 4185; action-table probing DOES mark
_NONASSOCvalid(
EXT valid pos=2 state=285 glr=[285 4185] valid=...,37:_NONASSOC); thearbitration 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-
ExternalLexStateshypothesis is refuted: the arbitration neverreads that table, probing already found the token, and perl's upstream
repository checks in no
src/parser.cfor 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 bythe existing
dfaSupport < extSupportrung.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:
try { A(;) } catch($e) { B(); }acceptedno_stacks_aliveacceptedno_stacks_alivetry { A(); } catch$e) { B(); }acceptedno_stacks_aliveacceptedno_stacks_aliveThe earlier note that
catch$e)"enters a reduce-chain cycle" describes thismechanism. 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_aliveinstead. Same defect, same magnitude class: the survivingversion 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:
acceptedno_stacks_aliveTransitions base to both-gates: 47 cases move
acceptedtono_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:
The kill gate alone is measurably a no-op; the resync gate does the work.
The change is NOT Perl-scoped
anotherLiveParseStackRemainsreplaceslen(stacks)in the SHARED no-actionrecovery ladder (
parser.go), so it applies to all 206 grammars whether ornot they ever reach the zero-width rescue.
Julia receipt, four-variant attribution
corpus_real/julia/large__abstractinterpretation.jl, 224,616 bytes,production route:
source_filedbb7d086source_filedbb7d086source_file5783eecesource_file8cadacf0The digests reproduce the reviewer's exactly. Two attribution facts:
dbb7d086, identical to base).The tree change is entirely the gates.
5783eece), which refines theearlier 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:
g();g($x);foo(1, 2);my $y = f(3);Foo::bar(1);{ A(); }sub f { A(); }if (1) { A(); }local_dynamic_scope&g();(control)print("x");(control)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_mergeand elects one subtree withts_parser__select_tree, so C'sfrontier returns to one version immediately. Go refuses the merge because the
shapes differ (
gssStacksHaveDistinctMaterializingShapes), so the duplicatefrontier 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
every digest.
PASS=198 DIVERGE=0 FALLBACK=3 SKIP=5 ERROR=0. Note this gatecannot see the julia change: julia's smoke sample is one line.
GTS_PARITY_MODE=exhaustive TestParityFreshParseandTestParityStructuralCorpus: pass, 0 divergences.go test .,go test ./grammars,go vet ./...: pass.go test -tags gts_parsercorephase0 .: 15 failures, the identical setorigin/main produces.
cgo_harnesssuite: 9 failures, the identical set origin/mainproduces.
-raceover the lexer, external-scanner, GLR, relex, recovery, and resyncpaths: pass.
Out of scope, reported for its own lane
The 11-byte Perl input
mb #$x =1;$parses for more than two minutes on bothorigin/main and this branch. Pre-existing and unrelated.