Skip to content

fix(#505): fixed-length list patterns must match exactly n under --bytecode - #684

Merged
sunholo-voight-kampff merged 6 commits into
devfrom
sprint/iter187-bytecode-pattern-arity
Aug 12, 2026
Merged

fix(#505): fixed-length list patterns must match exactly n under --bytecode#684
sunholo-voight-kampff merged 6 commits into
devfrom
sprint/iter187-bytecode-pattern-arity

Conversation

@sunholo-voight-kampff

Copy link
Copy Markdown
Collaborator

Closes #505.

The bug

Under --bytecode, a fixed-length list pattern matched any longer list, binding the first n elements and silently discarding the tail. recursion_quicksort.ail printed Quicksort: [3] instead of [1, 1, 2, 3, 4, 5, 6, 9]rc=0, no error, no fallback warning. A direct NO-SILENT-FALLBACKS and A1 (determinism) violation, and it affected every fixed-length list pattern in every --bytecode program.

Root cause — one function, one operator

internal/gen/lower/match.go, lowerPatternCond, case *core.ListPattern: the empty pattern was special-cased to OpEq while every other list pattern got OpGte, tail or no tail.

The discriminator was already on the node. core.ListPattern.Tail == nil is a closed […] pattern and must be OpEq; Tail != nil (a ...rest pattern, or the :: cons form) genuinely means at-least-n and keeps OpGte. The old empty-list case is just the Tail == nil, n == 0 instance of that rule, so it collapses into it.

internal/vm and internal/bytecode are not involved — neither contains a single Pattern reference; matching is lowered away in internal/gen.

Verification (controller-run, outside the executor sandbox)

  • AC1 — quicksort under --bytecode now prints the correct list.
  • AC3 — stderr has 0 falling back to evaluator lines (control: bytecode VM present = 1, so the grep works).
  • AC2 — isolated single-arm modules confirm overflow is now rejected at n=2 and n=3, while underflow was already correct. Verified with the controller's own fixtures, independent of the shipped tests.
  • Blast radius: seven examples MATCH across both engines; parity harness MATCH 151 → 152, DIVERGE 6 → 5, delta exactly recursion_quicksort.ail.
  • Gates rc=0: go build, go vet, go test ./internal/gen/... ./tests/golden/bytecode/ ./cmd/ailang, gofmt, check-file-sizes, check-boundaries, check-changelog, check-golden-drift, test-lowering, test-stdlib-ail.

The pin is real, not decorative

Restoring unconditional OpGte LANDS (sha256 5142335dfd282d4d) and BUILDS (go build rc=0, so the red is behavioural, not a compile error), and reds both new tests with the right mechanism. The inverse arm — the same mutant with those two tests -skip'd — is rc=0 across all of ./cmd/ailang. So the new tests are the sole killers; nothing else in the suite catches #505.

That run also confirmed the executor's self-reported deviation: the plan prescribed if false && p.Tail == nil as the drill mutant, which is not #505 — it makes the empty pattern match everything too, so quicksort prints [] rather than [3]. The executor spotted it and substituted the exact measured pre-fix lowering.

Also in this PR

  • The design doc gained a Verification Log (V-A..V-I) after a two-round quorum block; both rounds were premise objections the controller measured rather than forwarded, which shrank the fix site from three packages to one function.
  • Test location correction: the doc prescribed tests/golden/bytecode/, but that is a Go test package driving pipeline→lower→compiler→vm in-process — it can never observe the CLI's stderr, --caps IO or exit code, so AC1 and AC3 were unimplementable there. The .ail fixture lives there; the tests that run it shell out from cmd/ailang/run_bytecode_test.go.
  • A mission-control skill rule (enumerator blind spots) with its two supporting frictions, one of which is filed as make fmt-check-ail enumerates a directory that does not exist — 46 stdlib .ail files are silently outside the gate #683.

🤖 Generated with Claude Code

sunholo-voight-kampff and others added 6 commits August 12, 2026 22:27
… and one operator

The doc claimed a known root cause while leaving the fix site "unknown" across
three packages, and cited the parent doc's verification log rather than
verifying anything in place. A two-reviewer quorum blocked it twice on exactly
that; both rounds were premise objections, so the controller measured them
rather than forwarding them.

Root cause is internal/gen/lower/match.go:lowerPatternCond, case
*core.ListPattern: the empty pattern is special-cased to OpEq while every other
list pattern gets OpGte, tail or no tail. internal/vm and internal/bytecode are
not involved — neither contains a single Pattern reference; matching is lowered
away in internal/gen.

Verification log V-A..V-I added, all measured at 8ecebc0:
  - the bug reproduces (quicksort prints [3] under --bytecode, rc=0, no fallback)
  - a probe at the pinned site LANDS, BUILDS, and flips both the minimal repro
    and the flagship symptom to byte-identical-with-eval; restored byte-identical
  - V-H: isolated single-arm modules confirm the overflow bug independently at
    n=2 and n=3 and confirm underflow is already correct — V-B's ordered block
    let [x] intercept everything, which the round-2 quorum correctly refused to
    accept as proof
  - V-I: core.ListPattern is constructed in exactly two places, both in
    internal/elaborate/patterns.go, so Tail == nil <=> exact-length holds over
    the codebase and not merely over the type declaration

Resolved via the narrow-refinement carve-out: both surviving objections asked
for a measurement, neither disputed the design direction, and the milestone, AC
numbering, estimate and scope are unchanged.

Co-Authored-By: codex gpt-5.6-sol <noreply@openai.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three milestones: red-first AC2, the fix + AC1/AC3, then the mutation drills,
blast radius and gate sweep.

The planner refuted two things the controller handed it, both load-bearing and
both confirmed first-party before this commit:

  - `go build ./...` is RED AT BASE (cmd/wasm only builds under GOOS=js), so the
    mutation drill's "assert the mutant BUILDS" step could never have passed for
    any mutation. Substituted `go build ./internal/... ./cmd/ailang`, rc=0 at base.
  - the design doc's prescribed home for the fixtures, tests/golden/bytecode/, is
    a Go test package (4 .go files, package bytecode_golden_test) that drives
    pipeline->lower->compiler->vm in-process. It can never observe the CLI's
    stderr, --caps IO or exit code, so AC1 and AC3 are unimplementable there.
    Correct home is cmd/ailang/run_bytecode_test.go, which already shells out.

Planner also proved the AC2 fixture red at HEAD (exactly 3 diverging rows) and
green under the probe, which is what shows the fixture actually executes in the
VM rather than being silently bridged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AC2's table lives in cmd/ailang/run_bytecode_test.go, not tests/golden/bytecode/
as the design doc said: that directory is a Go test package driving
pipeline->lower->compiler->vm in-process, so it can never observe the CLI's
stderr, --caps IO or exit code, which AC1 and AC3 both require. The .ail fixture
goes there; the test that runs it shells out.

This commit is RED BY DESIGN — the fixture reproduces #505.
…tecode (M2)

internal/gen/lower/match.go, lowerPatternCond, case *core.ListPattern emitted
stmt.OpGte for every list pattern except the empty one, so [x] matched any
non-empty list and silently discarded the tail. recursion_quicksort printed
Quicksort: [3] instead of [1, 1, 2, 3, 4, 5, 6, 9] at rc=0, with no error and
no fallback warning.

The discriminator was already on the node: core.ListPattern.Tail. Tail == nil is
a closed pattern and must be OpEq; Tail != nil (a ...rest pattern, or the ::
cons form) genuinely means at-least-n and keeps OpGte. The old empty-list
special case is just the Tail == nil, n == 0 instance of that rule, so it
collapses into it.

Verified by the controller outside the executor sandbox, through the real CLI:
quicksort now prints the correct list, stderr has zero 'falling back to
evaluator' lines (control: 'bytecode VM' present = 1), and isolated single-arm
modules confirm overflow is now rejected at n=2 and n=3 while underflow was
already correct.

Fixes #505
Six drills, each asserted LANDED by sha256 and BUILDS by go build before its red
was read, with the failing test and assertion text quoted rather than a bare
exit code.

The controller re-ran the load-bearing one itself: restoring unconditional
OpGte LANDS (5142335d -> fd282d4d), BUILDS (rc=0), and reds both new tests with
the right mechanism -- while the INVERSE ARM, the same mutant with those two
tests -skip'd, is rc=0 over all of ./cmd/ailang. So the new tests are the sole
killers and nothing else in the suite catches #505.

That run also independently confirmed the executor's self-reported deviation:
the plan prescribed 'if false && p.Tail == nil' as the drill mutant, which is
NOT #505 -- it makes the empty pattern match everything too, so quicksort prints
[] rather than [3]. The executor noticed and substituted the exact measured
pre-fix lowering. Rule 3h(d): the self-reported deviation was better than the
plan.

Blast radius: seven examples MATCH across both engines; parity harness
MATCH 151 -> 152, DIVERGE 6 -> 5, delta exactly recursion_quicksort.ail.
…ATOR

Rule 3j asks how many ways a mechanism can refuse; iter-75's dual asks how many
ways the forbidden thing can be spelled. Neither asks who decides what counts as
an input at all — and an enumerator's blind spot is invisible to every
downstream assertion by construction, so a full set of pinned branches, ten
mutations and a 93/100 evaluator can all agree while the input never arrives.

Two frictions, one per mission:

  - mission-world iter-77 (proposed; World shares this skill but cannot edit it):
    an allowlist gate with four mutation-killed refusal branches, defeated by
    SNEAKY.AIL, because the enumerator is `find -name '*.ail'` and -name is
    case-sensitive. rc=0, success line byte-identical to the pristine baseline.

  - V1, corroborated first-party before adoption per the sibling-claim ghost
    discipline, and a different mechanism — wrong SCOPE, not wrong case:
    `make fmt-check-ail` enumerates `find examples stdlib -name '*.ail'` and
    stdlib/ has never existed in this repo (the path is std/). Measured in one
    call: 400 files as written, 446 with the correct root. 46 stdlib files sit
    outside a gate that prints a green checkmark, and its empty-set branch is
    itself a vacuous pass. Filed as #683.

Remedy: pair every enumeration with a deliberately widened control in the same
call and require the counts to agree, assert roots exist rather than reading
their emptiness, and give any enumerator-fed gate an anti-vacuity floor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@sunholo-voight-kampff
sunholo-voight-kampff merged commit 0625059 into dev Aug 12, 2026
22 checks passed
@sunholo-voight-kampff
sunholo-voight-kampff deleted the sprint/iter187-bytecode-pattern-arity branch August 12, 2026 21:21
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.

Bytecode VM: fixed-length list patterns ignore length — silent wrong results ([x] matches any non-empty list)

1 participant