Skip to content

feat(engine): loop-shortcut bounded-offer core, pin injection, owner firewall, and measured 4p rows (combo-fb phases 5a-5d, chain 3) - #6886

Merged
matthewevans merged 68 commits into
phase-rs:mainfrom
lgray:feat/combo-detector-player-feedback
Aug 3, 2026
Merged

feat(engine): loop-shortcut bounded-offer core, pin injection, owner firewall, and measured 4p rows (combo-fb phases 5a-5d, chain 3)#6886
matthewevans merged 68 commits into
phase-rs:mainfrom
lgray:feat/combo-detector-player-feedback

Conversation

@lgray

@lgray lgray commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🤖 AI text below 🤖

Summary

Chain 3 of 3 of the combo-feedback series (phase 5, sub-phases 5a–5d). Splits LoopDetectSample into normalized and live ring halves, derives the resolution obligation from the event record rather than from the prompt (CR 616.1), adds the shape-B mint conjuncts and the declare-time template.owner firewall (CR 732.2a + CR 603.5), re-derives the loop-shortcut probe budget from the beat the corpus actually offers on, routes 5c player choice through one legality authority, and pins the resulting behaviour with rows driven beat-by-beat through the public apply() on real captured 4p dumps. It also migrates the six 4p dump fixtures onto the mandatory CR 603.7 firing carrier that upstream #6842 introduced after this work was reviewed.

Every headline claim below is the measured version, not the planned version. Where the tree falsified a prediction, the row is keyed to the measurement and the deviation is listed in the disclosures rather than being written as a passing row.

Files changed

Engine — loop-shortcut / CR 732.2a surface:

  • crates/engine/src/game/engine.rs (+4356/−394)
  • crates/engine/src/analysis/resource.rs (+6441/−345)
  • crates/engine/src/analysis/decision_template.rs (+359/−24)
  • crates/engine/src/analysis/loop_check.rs (+16/−0)
  • crates/engine/src/analysis/corpus_tests.rs (+3/−0)
  • crates/engine/src/ai_support/candidates.rs (+29/−6)

Engine — resolution-obligation partition (5d U1) and its 5c legality routing:

  • crates/engine/src/game/resolution_prompt.rs (+1503/−0, new module)
  • crates/engine/src/game/ability_scan.rs (+17/−564)
  • crates/engine/src/game/replacement.rs (+350/−0)
  • crates/engine/src/game/stack.rs (+319/−56)
  • crates/engine/src/game/effects/separate_piles.rs (+162/−4)
  • crates/engine/src/game/effects/choose.rs (+108/−2)
  • crates/engine/src/game/effects/choose_from_zone.rs (+89/−1)
  • crates/engine/src/game/effects/proliferate.rs (+80/−1)
  • crates/engine/src/game/effects/mod.rs (+60/−4)
  • crates/engine/src/game/effects/token.rs (+12/−17)
  • crates/engine/src/game/effects/clash.rs (+7/−2)
  • crates/engine/src/game/players.rs (+53/−0)
  • crates/engine/src/game/targeting.rs (+50/−51)
  • crates/engine/src/game/casting_costs.rs (+12/−3)
  • crates/engine/src/game/filter.rs (+11/−3)
  • crates/engine/src/game/sba.rs (+10/−1)
  • crates/engine/src/game/ability_utils.rs (+6/−6)
  • crates/engine/src/game/phasing.rs (+6/−5)
  • crates/engine/src/game/interaction.rs (+15/−1)
  • crates/engine/src/game/zone_pipeline.rs (+5/−1)
  • crates/engine/src/game/casting.rs (+3/−1)
  • crates/engine/src/game/mod.rs (+1/−0)
  • crates/engine/src/types/game_state.rs (+96/−4)
  • crates/engine/src/types/mod.rs (+4/−4)
  • crates/engine/src/game/casting_tests.rs (+73/−0)

AI:

  • crates/phase-ai/src/policies/loop_shortcut.rs (+185/−16)
  • crates/phase-ai/src/projection.rs (+1/−0)
  • crates/phase-ai/src/search.rs (+1/−0)

Tests (integration):

  • crates/engine/tests/integration/loop_shortcut.rs (+4116/−32)
  • crates/engine/tests/integration/fantastic_four_bounded_loop.rs (+1403/−0)
  • crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs (+387/−0)
  • crates/engine/tests/integration/gift_recipient_phased_out_opponent.rs (+202/−0)
  • crates/engine/tests/integration/rules/battle.rs (+144/−0)
  • crates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rs (+104/−0)
  • crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs (+21/−3)
  • crates/engine/tests/integration/sprout_inalla_realistic_offer.rs (+7/−3)
  • crates/engine/tests/integration/interaction_contract.rs (+5/−1)
  • crates/engine/tests/integration/main.rs (+3/−0)
  • crates/engine/tests/integration/loop_shortcut_mana_engine.rs (+1/−0)

Fixtures (binary; re-stamped for the #6842 carrier — see disclosure 9):

  • crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz
  • crates/engine/tests/fixtures/dina_conqueror_4p.json.gz
  • crates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gz (new)
  • crates/engine/tests/fixtures/tenacity_exquisite_blood_4p.json.gz
  • crates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gz
  • crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz

Fixture-migration mechanism:

  • scripts/lib/trigger-firing.jq (+114/−0)
  • scripts/stamp-fixture-firing.sh (+130/−0)
  • scripts/migrate-dump-fixture.sh (+201/−0)

54 paths, +21281/−1555.

Track

Developer

LLM

Model: claude-opus-5
Tier: Frontier
Thinking: max

Implementation method (required)

Method: /engine-implementer

CR references

95 distinct CR numbers appear on lines this PR adds. Load-bearing ones: CR 732.1b, CR 732.2a, CR 732.2b, CR 732.2c, CR 732.3, CR 732.4, CR 732.5 (loop shortcuts), CR 603.1, CR 603.2c, CR 603.3c, CR 603.3d, CR 603.4, CR 603.5, CR 603.12, CR 603.12a (triggered abilities and the "may" beat), CR 616.1 (the resolution obligation the partition derives), CR 700.2b, CR 700.3, CR 800.4, CR 800.4a (handback/rollback), CR 704.x (state-based actions), CR 608.2x (resolution).

Full list: CR 101.2, 102.1, 102.2, 102.3, 104.2a, 104.3c, 104.4b, 107.1c, 107.3m, 111.1, 113.3b, 113.7a, 114.2, 115.1, 115.2, 115.10a, 117.1b, 118.12, 119.3, 119.8, 120.3a, 121.1, 121.2, 121.4, 310.10, 310.11a, 400.7, 405.5, 500.8, 503.1a, 504.1, 506.1, 510.2, 601.2b, 601.2c, 601.2d, 601.2f, 601.2h, 603.2c, 603.3c, 603.3d, 603.4, 603.5, 603.12, 603.12a, 608.1, 608.2b, 608.2c, 608.2d, 608.2h, 608.2k, 613.1, 614.1, 614.1a, 616.1, 700.2b, 700.3, 701.4a, 701.21a, 701.30b, 701.34a, 702.6a, 702.11c, 702.16b, 702.18a, 702.26b, 702.52a, 702.132a, 702.150a, 702.174a, 703.1, 703.2, 703.3, 703.4d, 704, 704.3, 704.5a, 704.5c, 704.5j, 704.5w, 704.5x, 706.2, 706.4, 707.2c, 707.10, 710.4, 732.1b, 732.2a, 732.2b, 732.2c, 732.3, 732.4, 732.5, 800.4, 800.4a. Every number was verified against docs/MagicCompRules.txt; the control string CR 999.99z was correctly not found.

Verification

  • Required checks ran clean, or the exact CI-owned alternative is stated below.
  • Gate A output below is for the current committed head.
  • Final review-impl below is clean for the current committed head.
  • Both anchors cite existing analogous code at the same seam.

This box is deliberately unchecked and the reason is stated rather than papered over. The last independent review-impl gate to run to completion ran at dbc81821df74a5ad63b6a928ca80a5a720603522, a pre-rebase tree, and returned zero correctness findings. That SHA is not in this branch's current history.

Since then the head has moved for reasons largely outside this PR's own changes, and each move is named rather than averaged away:

  1. a rebase onto e12447f4f, whose carried-commit delta was measured with git range-diff 73fd7f6de..dbc81821d e12447f4f..dd986e404 — of 25 reviewed commits, 20 content-identical (=) and 5 changed (!) (ec3f88c2d, ed4c158d0, 89eacc318, 254e11183, 0512fccf6), exactly the four conflict resolutions plus the three commits the drift fixes were autosquashed into; eight pre-boundary commits and one empty rustfmt commit dropped out as already-merged upstream (fix(engine): loop-ring retention across forced windows + observer-negative rulings (combo-fb phases 0-2, chain 1) #6838/fix(engine): infinity-badge/offer correctness + per-entry token occurrence index (combo-fb phases 3-4, chain 2) #6839);
  2. the maintainer's own merge of main folding ship/canonicalize delayed trigger lifecycle #6933 (d1a5270a4), plus five additive commits closing two seams that fold exposed and one census re-pin (6face1fdd);
  3. the repository author's "Update branch" merge (a62782802);
  4. the current round-5 fix (84992d9e5).

None of those has been through a completed independent review pass. All of them have been through the current-head gate below. A fresh adversarial review at 84992d9e5 is running now and its result is posted to the thread when it lands — including if it is not clean.

Run at the CURRENT head 84992d9e52cd664cfc7d117194e57d3d21c84304, directly in the branch worktree (Tilt watches the main checkout, not this worktree):

  • cargo fmt --all --check — exit 0.
  • cargo clippy --workspace --all-targets -- -D warnings — exit 0, 0 warnings.
  • cargo test -p phase-engine --test integration — exit 0; 4442 passed, 0 failed, 2 ignored.
  • cargo test -p phase-engine --lib — exit 0; 18426 passed, 0 failed, 6 ignored.
  • cargo test -p phase-ai — exit 0; 2069 passed, 0 failed summed across the crate's test binaries.
  • scripts/check-parser-combinators.sh 7fa1fc4a1 — exit 0 (Gate A PASS + Gate G PASS, run against the true merge base rather than the hook's fork-relative default, which on a stale fork resolves to the wrong base and yields a vacuous PASS). 0 files under crates/engine/src/parser/ are touched by this PR.
  • Pre-commit hook re-ran Gate A / Gate G / Gate P (PreLowered ratchet) independently — all PASS.

cargo ai-gate has not returned at this head, and the last commit was pushed ahead of it at the repository author's direction. Stated rather than omitted, because the repo's own rule is that AI behaviour changes run that gate. Repo-wide, ai-gate's pull_request runs hit the 60-minute timeout-minutes ceiling on 22 of 22 runs on 2026-08-02 and 12 of 14 on 08-03 (median cancelled duration 60.4m — the cap itself), so a red or absent ai-gate result on this head reflects that ceiling rather than this change. A local run is in progress and its outcome is posted to the thread either way.

Scope of that gate, measured after the above was written: cargo ai-gate cannot exercise this PR's loop-shortcut policy arm at all. MatchConfig.loop_detection defaults Off (the #4603 opt-in invariant) and phase-ai never sets it — 0 mentions of loop_detection / LoopDetectionMode crate-wide including the ai-gate binary, against a positive control of 23 assignments in crates/engine/src. Every loop-shortcut offer is gated on loop_detection.samples(), false for Off, so LoopShortcutPolicy returns before reaching any arm in every duel-suite game. A green or red ai-gate on this head is therefore evidence about the suite, not about the policy change; the behavioural evidence for that change is its discriminating test set (Fixed(10)-rejects / Fixed(9)-scores threshold pair, opponent-seat contrast, life-axis row). Full proof in the thread.

Measured at earlier heads and NOT re-run at the current head — listed separately rather than restated as current:

  • cargo test -p phase-engine --doc — exit 0; 7 doc tests, all ignore-annotated, so 0 run and 0 failed.
  • Per-commit compile sweep, cargo check -p phase-engine --all-targets at each of the 27 commits in upstream/main..HEAD27 OK / 0 FAIL. This is the check that catches the exhaustiveness-arm class, where an early commit's match gains arms only a later commit defines, so the branch is bisectable rather than only green at the tip.
  • Rebase cleanliness re-measured at dispatch time against current upstream main (96e41b3ab, fix(engine): record a token battlefield entry even when its events are suppressed (CR 403.3) #6851): git merge-tree upstream/main HEAD ⇒ rc=0, 0 conflicts. File intersection with fix(engine): record a token battlefield entry even when its events are suppressed (CR 403.3) #6851 = 3 paths.
  • Fixture carriers re-derived independently of the stamping script, using the shipped trigger_carrier_count definition: needed == stamped on all six fixtures (154 / 7 / 3 / 6 / 1 / 1 = 172), every value Ordinary, delayed_triggers: 0 everywhere, allocators at 1/1.

Discriminating revert-probes on the declare-time owner firewall. Each mutates one guard, observes the named rows, then restores — both restores verified byte-identical (git diff empty, and sha256sum of the restored file equal to git show HEAD: of it):

  • Probe 1 — the guard DELETED (if template.as_ref().is_some_and(|t| t.owner != offer.proposer) { .. } removed from handle_declare_shortcut): r28_a_declared_template_owning_another_seat_is_refused_at_declare FAILED, r28_a_the_owner_firewall_is_reached_on_an_empty_schema_offer_too FAILED, and r28_b_the_drive_seat_guard_compares_a_client_supplied_owner_against_itself FAILED at exactly its (b2) assertion — its (b1) half runs first and still passed, which is the documented behaviour, since the injector reads no firewall. Control: r28_c_a_restored_proposal_with_a_foreign_template_owner_is_refused_at_consumption stayed ok, so the consumption ingress is a genuinely different seam and not the same guard asserted twice.
  • Probe 2 — the guard MOVED inside the !offer.schema.points.is_empty() block (existence unchanged, placement changed): r28_a_the_owner_firewall_is_reached_on_an_empty_schema_offer_too FAILED while r28_a_declared_template_owning_another_seat_is_refused_at_declare and r28_c_... both stayed ok. The firewall's placement outside that block is therefore independently load-bearing and independently measured — an empty-schema offer skips predictability_gate and validate_pins entirely, so a guard inside the block would let an unvalidated owner reach the proposal.

Gate A

Gate A PASS head=84992d9e52cd664cfc7d117194e57d3d21c84304 base=7fa1fc4a106f8356b7b5c8cf4c7f97df3cb1de56

Anchored on

  • crates/engine/src/game/engine.rs:4201 — reject_shortcut_declaration, the single authority the five pre-existing declaration-refusal arms already land on (upstream/main:crates/engine/src/game/engine.rs:2988). The new template.owner firewall is its sixth call site, so no row can observe which refusal fired first — the "sixth reject path added later" that authority's own doc anticipates.
  • crates/engine/src/types/game_state.rs:7817 — upstream's own migrate_legacy_trigger_firing_carriers, which derives Ordinary for DEFERRED contexts from the historically-omitted dispatch_origin: Normal default. The fixture stamp derives the same CR 603.1-vs-CR 603.7a discriminant for the ACTIVE-pending and stack cases, which that function's (None, None) arm deliberately refuses to infer.

Final review-impl

Final review-impl PASS head=dbc81821df74a5ad63b6a928ca80a5a720603522

That SHA is the pre-rebase reviewed tree and is not in this branch's history. It is stated as the true head of the review rather than restated as the current head, which would be the easy and wrong thing to write here. The four head-moves between it and 84992d9e5 are enumerated in the Verification section above; a fresh adversarial review at the current head is in flight and its outcome is posted to the thread either way.

Claimed parse impact

None. 0 files under crates/engine/src/parser/ are touched.

Scope Expansion

The fixture migration (scripts/lib/trigger-firing.jq, scripts/stamp-fixture-firing.sh, scripts/migrate-dump-fixture.sh, and the six re-stamped fixtures) is outside the phase-5 (5a–5d) implementation scope. It was forced by the base advance, not chosen: upstream 8121fd1c6 (#6842) made a CR 603.7 TriggerFiring carrier mandatory on persisted triggered records and fails closed without one, which turned 44 rows red on rebase (41 decoder rejections + 3 assertion rows) on a tree that was green on its own base. See disclosures 9 and 10.

Within that migration, the delayed-trigger allocator stamp is a third migration stage beyond the two that were pre-agreed (effect_kind, firing carriers). It is disclosed in item 9 rather than folded in silently.

Validation Failures

None.

CI Failures

None.


Series

chain phases PR state
1 0–2 #6838 — loop-ring retention across forced windows + observer-negative rulings merged (ed0a8e55c)
2 3–4 #6839 — infinity-badge/offer correctness + per-entry token occurrence index merged (9169d8f44)
3 5a–5d this PR open

Predecessor: #6839.

Why the span is 5a–5d and not just 5c+5d. The 10 commits at the base of this stack — ec3f88c2d (5a, the per-iteration pin machinery) through 62718fe01 — are sub-phases 5a and 5b: the bounded-offer core, its amendment round (ed4c158d0, 8c3966e7c, 813ffef0d, 6d12c65e6), four review-loop documentation commits, and one rebase-hygiene docs commit (62718fe01, pointing repro commands at the renamed phase-engine package after upstream #6739 — rebase adaptation, never part of the review loop), authored 2026-07-28 to 07-30 on the lane branch. They merged nowhere else: #6838/#6839 carried only the 8 pre-boundary commits (phases 0–4), and PeriodicDelta — introduced by ed4c158d0 — does not exist on main at all. Unlike the 5c and 5d commits, none of those 10 carries a sub-phase tag in its subject line, which is why earlier drafts of this description labeled the chain by its two newest sub-phases only. The code content is unchanged by this correction; only the label was wrong.

Disclosures

Each of these bounds a claim a reader would otherwise over-read. They are carried up from the commit messages and the executor journals, in the measured wording.

1. The 854× frozen-exemption headline does not describe the corpus's offering beat. Verbatim from 0512fccf6:

Consequence for the cost table (§3 D4.3): the frozen exemption's speed-up applies at dellian-shaped beats, which exist in-corpus but never offer under the shipped driver. THE OFFERING BEAT PAYS THE FULL UNEXEMPTED SWEEP — skips=0 there — so the 854x headline no longer describes the beat this class actually offers on.

The offering beat is dina beat 19 (spent=13 asks=13 skips=0 ring=3 stack=10), and its certifying basis is B (ResourceSignatureOnly). Basis A certified 0 times across all three dumps against 129 basis-B certifications, so the within-basis-A disjunct has no value on this corpus.

2. The F4 bounded offer FIRES, but an accepted declaration commits ZERO cycles.

The bounded offer FIRES on the real dump (beat 43, P0, max_iterations = 35, basis B) but publishes ONE decision point, not the three §6 R1 predicted, and an accepted declaration therefore commits ZERO cycles at both n = 1 and n = 3 — it answers Sue's may from the pin and then aborts on Reed's unpinned may. Fail-CLOSED (rollback, CR 800.4a handback): nothing rules-wrong ships, but it is not a grant. R1/R1b are labelled PARTIAL and §6 R2a/R2b/R3/R4/R5 plus the interruptibility matched pair are deliberately NOT written — no row asserts a falsified prediction.

r1b is the armed tripwire: it fails loudly the moment any remedy widens the announced set. Remedy sizing was measured, not guessed — widening announced alone is insufficient; the sampler gate is not the seam (two relaxations left the frame census unchanged); the resolution-order sequence is empty at the offering beat. Closing it needs a new prompt-window sampling site. Named follow-up: announced-set widening plus an engine authority for AI pin CONTENT. Not closed here.

3. dellian never offers at PROBE_BUDGET = 26. Verbatim from .combofb-5d-executor-journal.md:1169-1174:

CONSEQUENCE-DISCLOSURE of the ratified PROBE_BUDGET = 26: §6 D1/D2 (dellian) are unreachable and are NOT written. dellian drives 309 beats through apply() to GameOver with no offer ever raised; every mintable beat refuses UnspecifiedChoiceWindow with spent = 26 = PROBE_BUDGET, denied = true. dellian's measured demand at that seam was 96–107, so this is budget exhaustion by design of the cap, not a detector fault.

4. The AI's only effective action at the F4 offer is decline, and the candidate-generator gap is reported, not closed. The engine-side seam is exactly one: the WaitingFor::LoopShortcut arm of crates/engine/src/ai_support/candidates.rs, whose Fixed(max_iterations) candidate — the one that exists precisely for bounded offers — is gated on schema.points.is_empty(). F4 publishes one point, so that candidate is never generated and the legal set collapses to DeclareShortcut { count: UntilLethal, template: None } (refused outright by handle_declare_shortcut) plus DeclineShortcut. The phase-ai policy independently reaches decline: the offer latches predicted_winner: None, routing LoopShortcutPolicy to its (None, UntilLethal) => reject arm. Note for whoever closes it: that file holds R8's ai_support/candidates.rs 1 production-multiset entry, so a fix that ADDS a WaitingFor::LoopShortcut { construction there moves the multiset and must update R8's expected counts in the SAME commit.

5. ShortcutProposal.per_cycle cannot round-trip, and this PR introduces the defect. ShortcutProposal is plain serde inside GameState.waiting_for, but per_cycle carries a PlayerId-keyed resource map and PlayerId cannot deserialize from a JSON object KEY (invalid type: string "0", expected u8). A bounded-shortcut save is therefore unloadable, and the persisted-ingress row is reachable only for per_cycle: None proposals.

Ownership stated precisely, because the journal's wording invites the wrong reading: the field is pre-existing relative to the 5d sub-phase but not relative to main. Measured on upstream/main: the PeriodicDelta type is absent entirely, and ShortcutProposal (crates/engine/src/analysis/loop_check.rs:154) has exactly six fields, none of them per_cycle. (Grepping per_cycle on main does hit — those are per_cycle_delta: u32, an unrelated field with no map key to decode.) git log -S'pub per_cycle' upstream/main..HEAD bisects the field — on both LoopCertificate and ShortcutProposal — to a single commit at the base of this PR's stack (the 2nd of 27, sub-phase 5b): ed4c158d0 feat(engine): bounded CR 732.2a cycle fast-forward for a multiplayer drain. So the defect ships with this PR. Both arms of the affected row null per_cycle, so they stay byte-identical except owner. The fix is deferred to its own lane; say the word if it should land here instead.

6. Plan-deviation ledger. Rows the plan predicted but the tree falsified were not written as passing rows:

  • R21(a3) — falsified, not written.
  • R27(d) — deleted after measuring it vacuous.
  • R28(b) — re-keyed on measurement. The plan predicted the drive seam refuses a hostile template.owner; it does not and cannot, because inject_pinned_answer holds the template but not the offer, so the seat guard compares a client-supplied value against itself. (b1) asserts that measured breach; (b2) supplies the refusal at the seam that does have the engine-issued comparand. If a future change closes the drive seam, (b1) flips and must be re-keyed, not deleted — its doc says so.
  • R9 — re-keyed (CR 616.1).
  • R16(ii-a) — vacuity replaced with an exact-demand sweep.
  • R16(iii) — partial: (iii-b) landed, (iii-a) is a derivation.
  • D1/D2 — not written (see disclosure 3).
  • The frozen plan document retains a falsified Braids quote at :2832, superseded by the tree.

7. Review record. Independent full-diff review-impl; Maintainer-Simulation Gate PASS; zero correctness findings; 3 LOW documentation fixes applied and delta-verified (79f485728). The review was audited, not sampled. Its head and the post-review delta are stated exactly in the Verification section — please read that before treating the review as covering the current head.

The 7-commit post-review delta (5 adapted + 2 new) was then put through a second, independent read-only review, which returned CLEAN — 0 defects. Its measured findings: the 5 adapted commits' deltas are mechanical only (the four refusal statements are byte-identical to upstream's reject_shortcut_declaration; the dropped 85cadabd3 was whitespace-only rustfmt); all six fixtures are additions-only, a subset of the 5 stamped keys, with zero removals and canonical identity after stripping (the dina / witherbloom_..._simple byte difference is a trailing jq newline, and the .gz growth is gzip-level only); the CR 603.5 census was re-derived independently and equals the committed expectation (same 5 producers, coordinates only); CR 603.1 / 603.5 / 603.7 / 603.7a all verified; hard-stop scan zero; and no test was weakened anywhere in the range.

Scope caveat, stated so this is not over-read: that second review is static and structural — it ran no builds and no tests. Runtime green is carried entirely by the gate table in the Verification section above, not by it.

Known documentation nit, disclosed rather than force-pushed. scripts/stamp-fixture-firing.sh:14 says arm 1 strips "the three new keys", but the executable del() list at :60 names five (3 carrier keys + 2 allocator keys). The comment undercounts; the code is correct, and the control it actually runs is stronger than the comment claims. This is comment-only, found after the branch was pushed, and is deliberately not rewritten here — amending it would rewrite a published tip for a one-word change. It will be folded into the first requested change if there is one.

8. Fold adaptation ledger. The rebase onto e12447f4f hit four conflicts, each resolved preserving both intents rather than by taking a side:

  • analysis/resource.rs in two commits — neither side compiled alone; took upstream's class-quantified arg2 plus the widened window_scope_from_cover_frames argument list.
  • game/engine.rs + game/interaction.rs + types/game_state.rs in the 5c commit — 5c moves pin-validation after the count cap; upstream factored the handback into reject_shortcut_declaration. Composed. Keeping HEAD's copy (the pre-move block) would have run pin validation twice on an unchecked count, silently defeating the hostile-Fixed(4e9) guard. game_state.rs kept BOTH independent validators.
  • game/engine.rs in the 5d U2 commit — the owner firewall routed through reject_shortcut_declaration.

Separately, three upstream-blamed test-literal sites broke only under composition and were fixed by content-neutral --autosquash into their owning commits (diff against the known-good pre-fold tree empty, 0 fixup! left). One local commit (85cadabd3, a rustfmt of triggers.rs) dropped as empty, verified redundant with upstream 683a6e66b by reverse-apply, with an unapplied-commit control that reverse-applied non-clean.

9. Fixture migration for #6842's mandatory CR 603.7 carrier. Derived per record, never defaulted:

  • Ordinary ⟸ the fired trigger's definition is present on its SOURCE OBJECT's own trigger_definitions / base_trigger_definitions, matched by exact description (CR 603.1: a printed or granted triggered ability of a permanent is an ordinary triggered ability). Delayed ⟸ an install receipt in delayed_triggers (CR 603.7a). Anything else aborts by name; there is deliberately no fallback stamp.
  • 172 carrier entries across 22 witnessed (source, description) classes, zero defaulted, all Ordinary — 154 dellian / 7 dina / 3 F4 / 6 tenacity / 1 + 1 witherbloom. All six fixtures record delayed_triggers: 0, so Delayed(Some(..)) could not have validated regardless.
  • UnknownLegacy was REFUTED as an option, not skipped. validate_firing (crates/engine/src/types/game_state.rs, 7 call sites) returns Err("{carrier} has no canonical trigger firing discriminator") for it: it is the field-absent marker (skip_serializing_if) and the redaction default, never a legal persisted value.
  • In-place stamping over pristine regeneration, measured: dina_conqueror_4p and witherbloom_sprout_lumaret_simple_4p differ from their pristine regeneration in exactly one object each (Priest of Forgotten Gods' abilities/base_abilities AST), because the committed fixture carries a LATER parser state than the capture. Regenerating would have silently reverted that. fantastic_four_bounded_loop_4p is byte-identical across both paths.
  • Third stage, disclosed: the delayed-trigger allocator stamp. next_delayed_trigger_token carries #[serde(default)], so a bare GameState decode restores 0 while the production PersistedGameState path runs a load-time repair to 1 — and 0 is invalid on its face, since validate_trigger_firing_coherence rejects next_delayed_trigger_token <= max_token. Adjudicated by migrating, not by relaxing the assertion. Only the collapsed no-install-roots case is stamped; anything else aborts by name, because the general used-token walk is engine logic and re-deriving it in jq is the exact mistake migrate-dump-fixture.sh refuses to make for EffectKind. Non-vacuity proven in both directions: all six dumps show 0 install commands across 158/30/2/516/50/5094 journal entries, and injecting one synthetic install command flips the selector to 1 AND flips the script to exit 1, nothing written.
  • Disclosed unreachable residual in the discriminant. A delayed trigger whose receipt had already been consumed, with no install command left in the journal, and whose description collided exactly with a printed trigger on the same source object, would be stamped Ordinary. That conjunction is measured unreachable on this corpus — all six fixtures record delayed_triggers: 0 and zero DelayedTriggerInstall commands across 158 / 30 / 2 / 516 / 50 / 5094 journal entries — so it is stated as a known bound of the derivation rather than left implicit.
  • scripts/lib/trigger-firing.jq is the single definition of both derivations, loaded by both the in-place and the pristine-regeneration path, so neither path certifies its own copy. scripts/stamp-fixture-firing.sh has three control arms and refuses to write if any fails. Two self-caught errors are worth recording: an arm keyed on byte difference gave a false pass on zero-carrier fixtures (jq re-serialization changes bytes without stamping) and was re-keyed on carrier count; and the filter would have CREATED a gameState key on the dumps stored in the turn_number envelope — caught by a control before any write, and guarded. migrate-dump-fixture.sh also fixes a pre-existing bug where an unguarded |= aborted on the 4 dumps lacking target_slots, so it had only ever been usable on 2 of 6.
  • CR 603.5 census re-anchored, not relaxed. The producer count is still 5, not 6. game/effects/mod.rs:5896/5973/8927 ⇒ :5918/5995/8949 (uniform +22), game/engine.rs:10500 ⇒ :10589 (+89), game/effects/scoped_library_search.rs:452 UNMOVED. All five were re-read at their new coordinates and are byte-identical to the pre-rebase tree at the old ones; a genuinely new producer could not leave an untouched file's coordinate fixed while shifting the others by a constant. Correcting my own earlier report for the record: I previously said upstream had ADDED a CR 603.5 producer. That was wrong — the row fired on coordinates. The correction is carried in the tripwire's own doc block so the next reader does not inherit the mistake.

10. Framing. Phase 5d was green on its own base. The incompatibility was introduced by the base advance (#6842), and it was resolved by DERIVING the missing discriminator per record, not by relaxing the assertion that demands it. Upstream's own migration deliberately stops short here: for an ACTIVE pending trigger its (None, None) arm returns Err("active legacy pending trigger has no firing discriminator") — the exact text of the observed failures, which is empirical proof the manual stamp was necessary rather than redundant.

11. Adjacent lane. #6851 (96e41b3ab, merged) touches the same token-entry seam as chain 2's occurrence index. Re-measured at dispatch time: file intersection 3, git merge-tree upstream/main HEAD ⇒ rc=0 with 0 conflicts. It is not a dependency of this PR.

Not enqueued — leaving disposition to the maintainer.

Summary by CodeRabbit

  • New Features
    • Added support for bounded loop shortcuts with fixed iteration counts.
    • Improved resolution-choice detection for complex abilities and replacement effects.
  • Bug Fixes
    • Player and opponent choices now consistently exclude eliminated or phased-out players.
    • Targeting and automatic selections apply the same eligibility rules across gameplay effects.
    • Improved loop-shortcut validation and AI decision-making for bounded offers.
  • Style
    • Combo Detector labels now identify the feature as experimental across supported languages.

@lgray
lgray requested a review from matthewevans as a code owner August 2, 2026 14:15
@github-actions github-actions Bot added the needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) label Aug 2, 2026
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds bounded loop-shortcut offers and replay validation, moves resolution-choice classification into board-aware probing, centralizes player-choice eligibility, updates loop-state persistence, adds integration coverage, and introduces fixture migration and stamping tools.

Changes

Bounded loop shortcuts and probing

Layer / File(s) Summary
Loop certification and state representation
crates/engine/src/analysis/loop_check.rs, crates/engine/src/types/game_state.rs
Loop certificates carry optional periodic metadata. Loop detection stores normalized and live samples. Persisted shortcut bounds are validated during decoding.
Resolution probing and replacement accounting
crates/engine/src/game/resolution_prompt.rs, crates/engine/src/game/replacement.rs, crates/engine/src/game/stack.rs
Resolution-time choice classification probes cloned states. Replacement events are recorded and classified. Resolution context binding is centralized.
Bounded shortcut offers and replay
crates/engine/src/ai_support/candidates.rs, crates/engine/src/analysis/decision_template.rs, crates/phase-ai/src/policies/loop_shortcut.rs
Bounded schemas produce fixed-count candidates. Pin validation carries complete pins and accepts zero-length ranges. Phase AI scores in-bound fixed declarations.
Integration validation
crates/engine/tests/integration/*
Integration tests cover bounded offers, replay, resolution probing, replacement discharge, persistence samples, and offer-writer census checks.

Choice eligibility

Layer / File(s) Summary
Shared player predicates
crates/engine/src/game/players.rs, crates/engine/src/game/targeting.rs
Choice eligibility now requires an existing, alive, non-phased player. Target legality combines choice eligibility with targeting restrictions.
Choice call sites and regression coverage
crates/engine/src/game/casting*.rs, crates/engine/src/game/effects/*, crates/engine/src/game/sba.rs, crates/engine/src/game/zone_pipeline.rs, crates/engine/tests/integration/*
Casting, effects, combat, proliferate, pile selection, aura attachment, and target enumeration use the shared predicates. Tests cover phased-out and eliminated players.

Fixture tooling and client labels

Layer / File(s) Summary
Fixture migration and stamping
scripts/lib/trigger-firing.jq, scripts/migrate-dump-fixture.sh, scripts/stamp-fixture-firing.sh
The new tools derive trigger carriers, repair delayed-trigger allocators, validate transformations, preserve envelopes, and write outputs atomically.
Localized combo-detector labels
client/src/i18n/locales/*/common.json
Combo-detector labels now identify the feature as experimental.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • phase-rs/phase#6322: Both changes extend validate_pins and shortcut decision validation.
  • phase-rs/phase#6539: Both changes concern resolution-choice classification and its relocation to resolution_prompt.rs.
  • phase-rs/phase#6838: Both changes modify loop-shortcut schema bounds and bounded iteration handling.

Suggested reviewers: matthewevans

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies the bounded loop-shortcut work and related engine changes, although it is longer than necessary.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/engine/src/game/effects/choose_from_zone.rs (1)

103-132: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use choosable_opponents in the fallback.

When fewer than two candidates exist, resolve_chooser uses players::opponents, which includes phased-out players. A phased-out opponent can therefore receive the choice instead of the only legal opponent.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/effects/choose_from_zone.rs` around lines 103 - 132,
Update the fallback chooser flow after the candidate-count check so it does not
select phased-out opponents through resolve_chooser. When fewer than two
choosable opponents exist, reuse the eligible opponent list from
players::choosable_opponents or otherwise resolve the chooser against that
filtered set, while preserving the existing targeted-opponent behavior.
🧹 Nitpick comments (8)
scripts/lib/trigger-firing.jq (1)

82-86: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard the install-command probe against non-object command values.

select(.command.DelayedTriggerInstall) indexes .command with a key. Serde serializes an externally tagged unit variant as a bare JSON string. If any journal entry carries such a command, jq aborts with Cannot index string with "DelayedTriggerInstall". The file states that undetermined cases abort by name; a jq type error is not a named abort, and the operator cannot tell a shape problem from a real install root.

Filter to objects first so the probe stays total.

♻️ Proposed fix
     ([ (.gameState.resolved_rules_journal.entries // [])[]
-       | select(.command.DelayedTriggerInstall) ] | length) as $installs
+       | select((.command | objects | has("DelayedTriggerInstall")) // false) ] | length) as $installs
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/lib/trigger-firing.jq` around lines 82 - 86, Update the
install-command probe in the resolved-rules journal calculation to inspect
DelayedTriggerInstall only when .command is an object, preventing jq type errors
for serialized string commands. Preserve the existing install count and named
undetermined error behavior for valid object-shaped commands.
scripts/stamp-fixture-firing.sh (1)

79-82: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Arm 2 compares aggregate carrier sums, so two opposite errors can cancel.

NEED collapses the pending, stack, and resolving carrier counts into one integer. GOT collapses the same three classes into one integer. Arm 2 then compares only the totals.

A missing carrier in one class and a surplus carrier in another produce equal totals and a green arm. The current derivation in scripts/lib/trigger-firing.jq cannot produce that combination, because _firing aborts rather than skipping a record. The weakness is therefore latent today. It becomes live as soon as the derivation gains any non-aborting path, or when a committed fixture already carries a stale stack_trigger_firings entry.

Compare the three classes separately.

♻️ Proposed fix

Emit a per-class shape from both sides and compare those.

-  NEED="$(gzip -dc "$FIX" | jq -c -f <(printf '%s\ntrigger_carrier_count\n' "$(cat "$LIB")"))"
-  GOT="$(gzip -dc "$TMP" | jq -c '((if .gameState.pending_trigger_firing then 1 else 0 end)
-                                   + (.gameState.stack_trigger_firings // {} | length)
-                                   + (if .gameState.resolving_trigger_firing then 1 else 0 end))')"
+  NEED_SHAPE="$(gzip -dc "$FIX" | jq -S -c '
+    {pending: (if (.gameState.pending_trigger // null) != null then 1 else 0 end),
+     stack:   ([ (.gameState.stack // [])[] | select(.kind.type == "TriggeredAbility") ] | length),
+     resolving: (if (((.gameState.resolving_stack_entry // .gameState.resolving_trigger).kind.type? // "")
+                     == "TriggeredAbility") then 1 else 0 end)}')"
+  GOT_SHAPE="$(gzip -dc "$TMP" | jq -S -c '
+    {pending: (if .gameState.pending_trigger_firing then 1 else 0 end),
+     stack:   (.gameState.stack_trigger_firings // {} | length),
+     resolving: (if .gameState.resolving_trigger_firing then 1 else 0 end)}')"
+  NEED="$(printf '%s' "$NEED_SHAPE" | jq -c 'add')"

Then key arm 2 on the shapes:

-  if [ "$GOT" -eq "$NEED" ]; then ARM2=true; else ARM2=false; fi
+  if [ "$GOT_SHAPE" = "$NEED_SHAPE" ]; then ARM2=true; else ARM2=false; fi

trigger_carrier_count in scripts/lib/trigger-firing.jq can expose the per-class object and keep the scalar as trigger_carrier_count | add, so the single-definition property holds.

Also applies to: 105-108

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/stamp-fixture-firing.sh` around lines 79 - 82, Update the NEED and
GOT derivations in the arm 2 comparison to emit matching per-class objects for
pending, stack, and resolving trigger-carrier counts instead of only aggregate
totals. Preserve trigger_carrier_count as the scalar sum by deriving it from the
per-class object, and compare the complete shapes in both arm 2 locations so
mismatches between classes cannot cancel out.
crates/engine/src/analysis/resource.rs (3)

12195-12227: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

drive_one_beat duplicates dump_drive_one_beat from the same module.

dump_drive_one_beat is defined at the top of this mod tests and is in scope here. The nested drive_one_beat is the same policy with the same body: pass at Priority, otherwise take the first legal non-terminal action, excluding Concede and Debug.

The doc comment justifies copying the policy from tests/integration/loop_shortcut.rs, which is a separate crate and cannot be imported. That justification does not apply to a second copy inside the module that already has one. Both copies encode the drive policy the measurements in this file depend on, so a change to one silently invalidates the other's recorded beat counts.

Call the module-level helper instead.

♻️ Proposed change
-        /// One beat of the shared dump drive policy (`tests/integration/loop_shortcut.rs`'s
-        /// `dump_drive_one_beat`): at `Priority` always pass — the mandatory triggers resolve
-        /// and re-trigger, which IS the loop when there is one — and otherwise take the first
-        /// legal non-terminal action.
-        fn drive_one_beat(state: &mut GameState) -> Result<(), String> {
-            // ... duplicated body ...
-        }
-

Then replace the two call sites in this test with dump_drive_one_beat(&mut state).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/analysis/resource.rs` around lines 12195 - 12227, Remove
the nested drive_one_beat implementation and reuse the existing module-level
dump_drive_one_beat helper. Update both call sites in this test to pass &mut
state to dump_drive_one_beat, preserving the current drive policy and error
behavior.

57-73: 🚀 Performance & Scalability | 🔵 Trivial

Consider emitting a metric when the probe budget denies a charge.

PROBE_BUDGET is derived from a single measured offering beat (13 charges) on the current corpus, doubled. The PR body already records one consequence: dellian does not offer under this budget. Exhaustion is fail-closed, so correctness is preserved, but a starved acceptance in production is silent — MintMeter carries denied, and nothing surfaces it outside tests.

A counter or structured log on denied() == true would let you see whether real tables hit the cap before a user reports a missing offer. That measurement is also what would justify the next re-derivation of the constant.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/analysis/resource.rs` around lines 57 - 73, Emit an
operational metric or structured log whenever MintMeter reports denied() == true
during probe charging, so probe-budget exhaustion is observable outside tests.
Locate the charge-denial handling that consumes MintMeter and record the event
there, including enough context to identify the affected classification or run;
preserve the existing fail-closed behavior and PROBE_BUDGET semantics.

594-613: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

State and enforce the frames_per_period >= 1 invariant.

frames_per_period delimits a committed cycle in game::engine::drive_one_shortcut_cycle and, per the doc above, supplies the per-period magnitude the CR 704 count bound divides by. A value of 0 is meaningless for both roles: as a delimiter it commits a cycle after no frames, as a divisor it is a panic.

PeriodicDelta derives Default, so frames_per_period: 0 is constructible in-crate today. Both current producers yield at least 1 (ring_delta_signature searches k from 1, and the direct-recurrence basis derives from a ring index), so this is a defence against a future producer rather than a live defect. Deserialization is already safe — the field carries no #[serde(default)], so an omitted key is a hard error.

Make the invariant load-bearing rather than incidental: add a checked constructor, or have the consumer treat 0 as a refusal.

♻️ Proposed constructor
 pub struct PeriodicDelta {
     pub frames_per_period: u32,
     pub delta: ResourceVector,
     pub victim_slot: Vec<(DecisionSlot, i64)>,
 }
+
+impl PeriodicDelta {
+    /// CR 732.2a: a period spans at least one retained ring frame. A zero
+    /// period is neither a cycle delimiter nor a divisor, so it is refused
+    /// here rather than at the consumer.
+    pub(crate) fn new(
+        frames_per_period: u32,
+        delta: ResourceVector,
+        victim_slot: Vec<(DecisionSlot, i64)>,
+    ) -> Option<Self> {
+        (frames_per_period >= 1).then_some(Self {
+            frames_per_period,
+            delta,
+            victim_slot,
+        })
+    }
+}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/analysis/resource.rs` around lines 594 - 613, Make the
frames_per_period >= 1 invariant explicit for PeriodicDelta: add a checked
constructor that rejects zero and update producers/deserialization-related
creation paths to use it, or ensure game::engine::drive_one_shortcut_cycle
refuses PeriodicDelta values with frames_per_period == 0 before using the value
as a cycle delimiter or divisor. Preserve valid nonzero periods and avoid
relying on the derived Default to create executable PeriodicDelta values.
crates/engine/src/types/game_state.rs (1)

18954-18974: 🚀 Performance & Scalability | 🔵 Trivial

record_loop_detect_sample now clones the whole game state twice per call.

Previously this function produced one normalized snapshot via normalize_for_loop. It now produces two full snapshots: normalized (via normalize_for_loop, which itself starts with self.clone()) and live (via the new loop_detect_live_sample, which also does self.clone()). Both clones are rooted in the same unmodified self, so this is correct, but it doubles the clone cost of GameState on what the surrounding documentation describes as a per-resolution hot path (the post-pipeline priority frame). With the ring capped at 16 entries, this can retain up to 32 GameState-equivalent payloads instead of 16.

Many GameState fields are plain Vec/HashMap/HashSet (not im-backed), so this cost is not fully amortized by structural sharing. Confirm this was profiled on a representative long game (many resolutions, large board) to be an acceptable trade-off, since there is no way to reduce this to one clone without losing either the raw or the normalized view.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/types/game_state.rs` around lines 18954 - 18974, Profile
the per-resolution loop-detection path around record_loop_detect_sample on
representative long games with large boards, measuring clone time and memory
retention for the normalized and live snapshots. Confirm that retaining both
GameState snapshots is an acceptable trade-off, and document the profiling
result or adjust the design if the added cost is not acceptable.
crates/engine/src/game/engine.rs (1)

13886-14021: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Key the source-census tests to symbols, not to line numbers.

Three rows assert facts about this file's own text: the_cr_603_5_prompt_census_is_pinned_so_a_sixth_producer_is_a_counted_event, arc_as_ptr_beat_identity_is_the_sample_not_one_of_its_halves, and the_period_touch_window_is_carried_by_the_live_half.

The first pins exact file:line coordinates for all five producers. Its own doc records three re-baselines inside this one PR — U4, the 5d LOW-fix, and the rebase onto #6842 — and each time the producer set was unchanged and only line numbers moved. The test therefore fires on unrelated edits above a producer, which trains a maintainer to re-baseline the number rather than to adjudicate the set. That defeats the row's stated purpose.

Keep the invariant, drop the coordinate. Assert the sorted list of producer FILE PATHS plus the per-file producer count, and keep the total/partition assertion. A sixth producer still fails the row; a comment added above an existing one does not.

The other two rows already use engine_fn_extent to anchor by signature, which is the right shape. Reuse that anchoring for the census row.

Also applies to: 15966-15996, 16011-16066

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/engine.rs` around lines 13886 - 14021, The
source-census test
the_cr_603_5_prompt_census_is_pinned_so_a_sixth_producer_is_a_counted_event
currently pins producer line numbers, causing unrelated edits to fail. Reuse
engine_fn_extent or equivalent symbol-based anchoring to assert the sorted
producer file paths and per-file counts, while preserving the total and
producers/readers/in_test partition checks; remove coordinate-specific
expectations and keep the existing symbol-based approach used by
arc_as_ptr_beat_identity_is_the_sample_not_one_of_its_halves and
the_period_touch_window_is_carried_by_the_live_half.

Source: Path instructions

crates/phase-ai/src/policies/loop_shortcut.rs (1)

508-543: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the n == max_iterations boundary case.

The row tests Fixed(4) and Fixed(11) against max_iterations == 10. It never tests Fixed(10) — the exact count ai_support::candidates emits for a bounded offer.

A policy that used >= instead of > would reject the AI's own generated candidate, and every assertion in this file would still pass. The engine-side row ai_bounded_declare_candidate_is_generated_legal_and_drives covers the boundary at the engine, not at this policy.

Add a third arm at the bound.

💚 Proposed boundary arm
         assert_eq!(kind_of(&outside), "loop_shortcut_bounded_declare_over_bound");
+
+        // (iii) AT the bound — the exact count `ai_support::candidates` emits for a bounded
+        // offer. A `>=` comparison in the policy would reject the AI's own candidate and no
+        // other assertion in this file would notice.
+        let at_bound = verdict_for(&state, &declare(IterationCount::Fixed(10)));
+        assert!(
+            matches!(at_bound, PolicyVerdict::Score { .. }),
+            "`max_iterations` itself is WITHIN the offered bound, got {at_bound:?}"
+        );
+        assert_eq!(kind_of(&at_bound), "loop_shortcut_bounded_declare_progress");
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/phase-ai/src/policies/loop_shortcut.rs` around lines 508 - 543, Add a
third boundary-case arm to the test
loop_shortcut_bounded_declare_scores_and_rejects_over_bound using Fixed(10), the
state’s max_iterations value. Assert that this exact-bound declaration receives
the same scoring verdict and bounded-progress kind as the within-bound case,
with the critical-band delta check preserved, while keeping the existing
over-bound rejection assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/ai_support/candidates.rs`:
- Around line 3238-3253: Update the candidate emission condition around
schema.points.is_empty() to also require !schema.is_bounded(), preventing the
UntilLethal DeclareShortcut candidate from being generated for bounded schemas
while preserving it for unbounded schemas.

In `@crates/engine/src/analysis/loop_check.rs`:
- Around line 183-187: Update the `ShortcutProposal.per_cycle` serialization
path so populated `PeriodicDelta` values with `PlayerId`-keyed maps can be
serialized and deserialized through JSON, including persisted
`WaitingFor::RespondToShortcut` and the WASM `to_js` path. Add or reuse a
wire-safe map representation or serde adapter while preserving `None` omission
and the existing `PeriodicDelta` semantics.

In `@crates/engine/src/analysis/resource.rs`:
- Around line 4237-4243: Move the probe-budget charge to precede state cloning
in stack_entry_resolution_choice_freedom, returning MayPrompt when charging is
refused; apply the same charge-before-clone ordering in
optional_cleared_classification. Update
crates/engine/src/analysis/resource.rs:4237-4243 and :1636-1642 so spent()
counts every probe attempt and exhausted budgets prevent further board
allocations.
- Around line 276-312: Bind FrameIx to its owning container rather than
representing it as a reusable bare index: update the FrameIx definition and
frame_ix to mint a branded key, then make verdict validate ownership and return
None for foreign or invalid keys instead of indexing or computing a verdict.
Update every verdict consumer to propagate this refusal as the existing frame_ix
failure path, while preserving successful same-container memoization and verdict
computation.
- Around line 2099-2115: Make the empty-input guard in the
ResolutionChoiceFreedom::FreeUnlessReplacements branch load-bearing in release
builds by returning false when events is empty, while retaining the existing
debug_assert! for diagnostics. Ensure the any-based replacement check runs only
for non-empty events.
- Around line 1281-1286: Correct the explanatory comment beside the zero-delta
check in the candidate-period search, without changing the existing early return
behavior. Remove the incorrect claim that larger periods are multiples of the
smallest period, and state only that returning on a zero validated per-period
delta is an intentional fail-closed choice that may miss later candidates
because each period is checked independently.

In `@crates/engine/src/game/resolution_prompt.rs`:
- Around line 613-624: Update the choice-freedom traversal around
effect_resolution_choice_freedom so the chain root is probed only once: stop
passing the full ResolvedAbility a through effect_resolution_choice_freedom and
its allow-listed branches when that probe already resolves sub_ability and
else_ability. Retain recursion only for ability-level gates handled by the
checks before this block, avoiding repeated chain resolution, duplicated events,
and extra ProbeBudget charges.
- Around line 119-123: Update the prompt guard in probe_resolution to reject
whenever work.waiting_for is any non-priority variant, rather than comparing its
discriminant with state.waiting_for. Preserve the existing
ResolutionProbe::Prompted result and allow only the priority/absence case on the
incoming resolution board.
- Around line 84-96: Introduce a private resolution-board type produced by
stack::bind_resolution_scope, then change probe_resolution and
ability_resolution_choice_freedom to accept that type instead of &GameState.
Update production callers to pass the bound board returned by
bind_resolution_scope, preserving the existing resolution-scope binding and
preventing raw GameState values from reaching either API.

In `@crates/engine/src/game/targeting.rs`:
- Around line 3651-3653: The regression test
find_legal_targets_excludes_eliminated_player needs positive reach guards before
its exclusion checks. Assert that the eligible player is present in the Player
and Any results, and add an alive opponent before asserting the eliminated
player is absent from the ControllerRef::Opponent results.

In `@crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs`:
- Around line 11-14: Update the stale test-site count in the documentation for
the loop shortcut offer-writer census from 12 to 14, including the nearby
repeated “22/12” comment, so both prose references match the authoritative
assertion `(22, 14)`.

In `@crates/phase-ai/src/policies/loop_shortcut.rs`:
- Around line 227-247: Update the bounded-declaration match in the policy logic
around IterationCount::Fixed so Fixed(0) is handled by a separate guard inserted
after the over-bound rejection and before the scoring arm. Ensure zero-count
declarations do not receive the critical-band winning-declare score, while
preserving the existing scoring behavior for positive in-bound counts.

In `@scripts/lib/trigger-firing.jq`:
- Around line 98-112: Update the trigger-firing transformation to preserve
existing canonical carriers by binding the current stack trigger firings as
$sf_existing alongside $objs, then derive and assign pending_trigger_firing,
stack_trigger_firings, and resolving_trigger_firing only when their
corresponding values are absent. Retain existing values unchanged, including
delayed carriers, and avoid calling _firing for records that already have a
carrier.
- Around line 34-37: Update _defs to read serialized trigger_definitions
descriptions from each entry’s nested .definition.description, while continuing
to read base_trigger_definitions from their direct .description field. Preserve
the existing empty-array fallback and ensure granted or copied trigger
descriptions are retained without causing UNDETERMINED firing carrier failures.

In `@scripts/migrate-dump-fixture.sh`:
- Around line 138-140: Update the fixture-writing pipeline around the
destination assignment and its production call using $OUT so output is first
written to a mktemp file in the destination directory, then atomically moved to
$dest only after unzip, jq, and gzip complete successfully. Clean up the
temporary file on failure, preserving the existing destination when the jq
recipe aborts.
- Around line 181-188: Update Arm 2 in the migration validation flow to compare
only the canonical target_slots projection, specifically the effect_kind values,
between PATCHED and UNPATCHED; do not use whole-document equality because
stamp_trigger_firing and stamp_delayed_allocators introduce unrelated
differences. Report and fail the vacuous case when that projection is unchanged,
while preserving the existing success output for a real target_slots difference.
Add a separate validation arm for allocator and firing changes, equivalent to
arms 2 and 3 in stamp-fixture-firing.sh.
- Around line 125-136: Update the filter initialization and pristine processing
in the migration script to validate that the input envelope contains a non-null
gameState before applying the final {gameState:.gameState} projection. Reject
non-gameState envelopes explicitly, while preserving the existing patched-mode
transformations and valid gameState output.

---

Outside diff comments:
In `@crates/engine/src/game/effects/choose_from_zone.rs`:
- Around line 103-132: Update the fallback chooser flow after the
candidate-count check so it does not select phased-out opponents through
resolve_chooser. When fewer than two choosable opponents exist, reuse the
eligible opponent list from players::choosable_opponents or otherwise resolve
the chooser against that filtered set, while preserving the existing
targeted-opponent behavior.

---

Nitpick comments:
In `@crates/engine/src/analysis/resource.rs`:
- Around line 12195-12227: Remove the nested drive_one_beat implementation and
reuse the existing module-level dump_drive_one_beat helper. Update both call
sites in this test to pass &mut state to dump_drive_one_beat, preserving the
current drive policy and error behavior.
- Around line 57-73: Emit an operational metric or structured log whenever
MintMeter reports denied() == true during probe charging, so probe-budget
exhaustion is observable outside tests. Locate the charge-denial handling that
consumes MintMeter and record the event there, including enough context to
identify the affected classification or run; preserve the existing fail-closed
behavior and PROBE_BUDGET semantics.
- Around line 594-613: Make the frames_per_period >= 1 invariant explicit for
PeriodicDelta: add a checked constructor that rejects zero and update
producers/deserialization-related creation paths to use it, or ensure
game::engine::drive_one_shortcut_cycle refuses PeriodicDelta values with
frames_per_period == 0 before using the value as a cycle delimiter or divisor.
Preserve valid nonzero periods and avoid relying on the derived Default to
create executable PeriodicDelta values.

In `@crates/engine/src/game/engine.rs`:
- Around line 13886-14021: The source-census test
the_cr_603_5_prompt_census_is_pinned_so_a_sixth_producer_is_a_counted_event
currently pins producer line numbers, causing unrelated edits to fail. Reuse
engine_fn_extent or equivalent symbol-based anchoring to assert the sorted
producer file paths and per-file counts, while preserving the total and
producers/readers/in_test partition checks; remove coordinate-specific
expectations and keep the existing symbol-based approach used by
arc_as_ptr_beat_identity_is_the_sample_not_one_of_its_halves and
the_period_touch_window_is_carried_by_the_live_half.

In `@crates/engine/src/types/game_state.rs`:
- Around line 18954-18974: Profile the per-resolution loop-detection path around
record_loop_detect_sample on representative long games with large boards,
measuring clone time and memory retention for the normalized and live snapshots.
Confirm that retaining both GameState snapshots is an acceptable trade-off, and
document the profiling result or adjust the design if the added cost is not
acceptable.

In `@crates/phase-ai/src/policies/loop_shortcut.rs`:
- Around line 508-543: Add a third boundary-case arm to the test
loop_shortcut_bounded_declare_scores_and_rejects_over_bound using Fixed(10), the
state’s max_iterations value. Assert that this exact-bound declaration receives
the same scoring verdict and bounded-progress kind as the within-bound case,
with the critical-band delta check preserved, while keeping the existing
over-bound rejection assertions unchanged.

In `@scripts/lib/trigger-firing.jq`:
- Around line 82-86: Update the install-command probe in the resolved-rules
journal calculation to inspect DelayedTriggerInstall only when .command is an
object, preventing jq type errors for serialized string commands. Preserve the
existing install count and named undetermined error behavior for valid
object-shaped commands.

In `@scripts/stamp-fixture-firing.sh`:
- Around line 79-82: Update the NEED and GOT derivations in the arm 2 comparison
to emit matching per-class objects for pending, stack, and resolving
trigger-carrier counts instead of only aggregate totals. Preserve
trigger_carrier_count as the scalar sum by deriving it from the per-class
object, and compare the complete shapes in both arm 2 locations so mismatches
between classes cannot cancel out.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 8c5ac462-43d1-44b8-8ee5-40efe02d18ca

📥 Commits

Reviewing files that changed from the base of the PR and between 96e41b3 and dd986e4.

⛔ Files ignored due to path filters (6)
  • crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/dina_conqueror_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/tenacity_exquisite_blood_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz is excluded by !**/*.gz
📒 Files selected for processing (48)
  • crates/engine/src/ai_support/candidates.rs
  • crates/engine/src/analysis/corpus_tests.rs
  • crates/engine/src/analysis/decision_template.rs
  • crates/engine/src/analysis/loop_check.rs
  • crates/engine/src/analysis/resource.rs
  • crates/engine/src/game/ability_scan.rs
  • crates/engine/src/game/ability_utils.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/game/casting_costs.rs
  • crates/engine/src/game/casting_tests.rs
  • crates/engine/src/game/effects/choose.rs
  • crates/engine/src/game/effects/choose_from_zone.rs
  • crates/engine/src/game/effects/clash.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/effects/proliferate.rs
  • crates/engine/src/game/effects/separate_piles.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/filter.rs
  • crates/engine/src/game/interaction.rs
  • crates/engine/src/game/mod.rs
  • crates/engine/src/game/phasing.rs
  • crates/engine/src/game/players.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/game/resolution_prompt.rs
  • crates/engine/src/game/sba.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/game/targeting.rs
  • crates/engine/src/game/zone_pipeline.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/mod.rs
  • crates/engine/tests/integration/fantastic_four_bounded_loop.rs
  • crates/engine/tests/integration/gift_recipient_phased_out_opponent.rs
  • crates/engine/tests/integration/interaction_contract.rs
  • crates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rs
  • crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs
  • crates/engine/tests/integration/loop_shortcut.rs
  • crates/engine/tests/integration/loop_shortcut_mana_engine.rs
  • crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/rules/battle.rs
  • crates/engine/tests/integration/sprout_inalla_realistic_offer.rs
  • crates/phase-ai/src/policies/loop_shortcut.rs
  • crates/phase-ai/src/projection.rs
  • crates/phase-ai/src/search.rs
  • scripts/lib/trigger-firing.jq
  • scripts/migrate-dump-fixture.sh
  • scripts/stamp-fixture-firing.sh

Comment thread crates/engine/src/ai_support/candidates.rs Outdated
Comment thread crates/engine/src/analysis/loop_check.rs
Comment on lines +276 to +312
pub(crate) fn frame_ix(&self, frame: &GameState) -> Option<FrameIx> {
self.frames
.iter()
.rposition(|f| std::ptr::eq(*f, frame))
.map(FrameIx)
}

/// THE ONE DOOR. Computes on miss against `self.frames[f.0]` — the memo,
/// never the caller, converts `FrameIx` back to a board — charges the
/// OWNED budget, and memoizes. Total over minted keys: it returns a
/// value, never an `Option`, so there is no miss contract to get wrong.
pub(crate) fn verdict(&mut self, f: FrameIx, entry: &StackEntry) -> &EntryVerdict {
let key = (f, entry.id);
if !self.memo.contains_key(&key) {
let frame = self.frames[f.0];
let published = self
.proposer
.and_then(|p| crate::game::engine::entry_publishes_pin_slots(frame, entry, p));
let primary =
super::stack_entry_resolution_choice_freedom(frame, entry, &mut self.budget);
let residual = match published.as_ref().and_then(|p| p.may.as_ref()) {
Some(_) => {
super::optional_cleared_classification(frame, entry, &mut self.budget)
}
None => None,
};
self.memo.insert(
key,
EntryVerdict {
published,
primary,
residual,
},
);
}
&self.memo[&key]
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

FrameIx is not bound to the container that minted it.

FrameIx is pub(crate) and Copy, and its only correctness property is that frame_ix resolved it against self.frames. Nothing in the type prevents a FrameIx minted by container A from being passed to container B's verdict. Two failure modes follow:

  • If B's frames is shorter, self.frames[f.0] panics on an index out of bounds.
  • If B's frames is at least as long, the memo silently computes and caches a verdict for the wrong frame — the exact class the doc comment claims is "unconstructible rather than merely unobserved".

The doc comment covers forging (E0603) but not cross-container reuse. Every call site currently re-mints per container by convention only; r22_conjunct4_the_effective_key_carries_the_containers_proposer relies on that convention in prose rather than in the type.

Bind the index to its container so a mismatch is a refusal, not a panic or a wrong verdict.

🛡️ Proposed fix: brand `FrameIx` with the owning container
+    /// A process-unique id per container, so a `FrameIx` cannot be spent
+    /// against a container that did not mint it.
+    static NEXT_CONTAINER_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
+
     #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
-    pub(crate) struct FrameIx(usize);
+    pub(crate) struct FrameIx {
+        container: u64,
+        index: usize,
+    }
         pub(crate) fn frame_ix(&self, frame: &GameState) -> Option<FrameIx> {
             self.frames
                 .iter()
                 .rposition(|f| std::ptr::eq(*f, frame))
-                .map(FrameIx)
+                .map(|index| FrameIx {
+                    container: self.container_id,
+                    index,
+                })
         }

verdict then refuses a foreign index rather than indexing with it. That requires verdict to answer for a miss; the fail-closed reading matching every other seam in this module is to return None and have each consumer treat it as a refusal, the same way they already treat frame_ix returning None.

Run the following to confirm no call site already crosses containers:

#!/bin/bash
# Every frame_ix mint and every verdict consumption, with enough context to pair them.
rg -nP --type=rust -C6 '\bframe_ix\s*\(' crates/engine/src
echo '--- verdict consumers ---'
rg -nP --type=rust -C6 '\.verdict\s*\(' crates/engine/src
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/analysis/resource.rs` around lines 276 - 312, Bind FrameIx
to its owning container rather than representing it as a reusable bare index:
update the FrameIx definition and frame_ix to mint a branded key, then make
verdict validate ownership and return None for foreign or invalid keys instead
of indexing or computing a verdict. Update every verdict consumer to propagate
this refusal as the existing frame_ix failure path, while preserving successful
same-container memoization and verdict computation.

Comment thread crates/engine/src/analysis/resource.rs
Comment on lines +2099 to +2115
ResolutionChoiceFreedom::FreeUnlessReplacements(events) => {
// `events` came from the RESOLVER, never from a per-arm list, and is
// non-empty by construction — `probe_resolution` returns `Prompted`
// on an empty derivation, so `any()` can never discharge vacuously.
debug_assert!(
!events.is_empty(),
"empty derivations are MayPrompt, never FreeUnlessReplacements"
);
!events.iter().any(|ev| {
!crate::game::replacement::proposed_event_prompt_cause(
board,
ev,
crate::game::replacement::replacement_registry(),
)
.is_empty()
})
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

An empty events vector discharges vacuously in release builds.

FreeUnlessReplacements(events) is discharged with !events.iter().any(...), which is true for an empty vector. The non-empty invariant is held only by a debug_assert!, which is compiled out of release. If probe_resolution ever returns FreeUnlessReplacements(vec![]) — its contract says it returns Prompted instead, but that contract lives in another module and is not enforced here — the release build certifies the entry with no CR 616.1 check at all.

This is the fail-open direction, and it is the one direction this predicate exists to prevent. Make the emptiness check load-bearing in every build; keep the debug_assert! so a violation is still loud in tests.

🛡️ Proposed fix
             debug_assert!(
                 !events.is_empty(),
                 "empty derivations are MayPrompt, never FreeUnlessReplacements"
             );
-            !events.iter().any(|ev| {
+            // Fail-closed in EVERY build: an empty derivation would make the `any()`
+            // below vacuously discharge, which is the one direction this predicate
+            // exists to prevent. The `debug_assert!` keeps the contract violation
+            // loud in tests; this keeps it safe in release.
+            !events.is_empty()
+                && !events.iter().any(|ev| {
                     !crate::game::replacement::proposed_event_prompt_cause(
                         board,
                         ev,
                         crate::game::replacement::replacement_registry(),
                     )
                     .is_empty()
                 })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ResolutionChoiceFreedom::FreeUnlessReplacements(events) => {
// `events` came from the RESOLVER, never from a per-arm list, and is
// non-empty by construction — `probe_resolution` returns `Prompted`
// on an empty derivation, so `any()` can never discharge vacuously.
debug_assert!(
!events.is_empty(),
"empty derivations are MayPrompt, never FreeUnlessReplacements"
);
!events.iter().any(|ev| {
!crate::game::replacement::proposed_event_prompt_cause(
board,
ev,
crate::game::replacement::replacement_registry(),
)
.is_empty()
})
}
ResolutionChoiceFreedom::FreeUnlessReplacements(events) => {
// `events` came from the RESOLVER, never from a per-arm list, and is
// non-empty by construction — `probe_resolution` returns `Prompted`
// on an empty derivation, so `any()` can never discharge vacuously.
debug_assert!(
!events.is_empty(),
"empty derivations are MayPrompt, never FreeUnlessReplacements"
);
// Fail-closed in EVERY build: an empty derivation would make the `any()`
// below vacuously discharge, which is the one direction this predicate
// exists to prevent. The `debug_assert!` keeps the contract violation
// loud in tests; this keeps it safe in release.
!events.is_empty()
&& !events.iter().any(|ev| {
!crate::game::replacement::proposed_event_prompt_cause(
board,
ev,
crate::game::replacement::replacement_registry(),
)
.is_empty()
})
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/analysis/resource.rs` around lines 2099 - 2115, Make the
empty-input guard in the ResolutionChoiceFreedom::FreeUnlessReplacements branch
load-bearing in release builds by returning false when events is empty, while
retaining the existing debug_assert! for diagnostics. Ensure the any-based
replacement check runs only for non-empty events.

Comment thread scripts/lib/trigger-firing.jq Outdated
Comment thread scripts/lib/trigger-firing.jq
Comment thread scripts/migrate-dump-fixture.sh Outdated
Comment thread scripts/migrate-dump-fixture.sh
Comment thread scripts/migrate-dump-fixture.sh Outdated
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Generated for head 9d00d2d5c8f277454535ffe6f2d22f78e54785a6.

Parse changes introduced by this PR

✓ No card-parse changes detected.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes — several serialization, prompt-state, analysis, and tooling paths remain unsafe on the current head.

Critical — loop proposal JSON cannot round-trip

loop_check.rs:160-187 derives serde for ShortcutProposal with per_cycle: PeriodicDelta, while resource.rs:481-502 and :594-621 retain BTreeMap<PlayerId, ...> values without serialization adapters. The proposal therefore cannot reliably survive a JSON round trip. Add the appropriate adapters or a transport representation and cover an actual JSON round trip.

Critical — speculative resolution can miss a re-parked prompt

resolution_prompt.rs:109-123 compares only WaitingFor discriminants after speculative resolution. A newly re-parked prompt with the same variant is therefore treated as unchanged. Compare the meaningful prompt state/identity instead, with a regression for a same-variant re-park.

Medium — trigger firing evidence reads the wrong field

scripts/lib/trigger-firing.jq:34-42 reads .description, but TriggerEntry owns the description under its nested definition (ability.rs:20703-20705). Use the definition description and add a fixture proving the firing evidence remains populated.

Medium — fixture migration is non-atomic and corrupts pass-through fixtures

migrate-dump-fixture.sh:124-140 writes directly to its destination before jq succeeds, and wraps a non-gameState fixture as {gameState:.gameState} despite its stated pass-through behavior. Write atomically only after jq succeeds and preserve non-gameState inputs unchanged; add fixtures for both failure and pass-through cases.

Medium — bounded choices still expose UntilLethal

candidates.rs:3238-3264 always offers UntilLethal even when the action is bounded, then additionally supplies Fixed. Do not expose the unbounded choice for a bounded action; represent only legal quantity choices and add a bounded-action regression.

Medium — empty resolution-event proof fails open

resource.rs:2092-2115 uses a debug assertion to claim FreeUnlessReplacements events are nonempty, then uses !events.iter().any(...). In release an empty vector returns true and incorrectly discharges the proof. Make empty events fail closed and add coverage.

Recommendation: address these substantive runtime and data-contract defects, restore the canceled/failing Rust verification, and then resubmit.

@lgray lgray changed the title feat(engine): loop-shortcut pin injection, owner firewall, and measured 4p bounded-offer rows (5c + 5d) feat(engine): loop-shortcut bounded-offer core, pin injection, owner firewall, and measured 4p rows (combo-fb phases 5a-5d, chain 3) Aug 2, 2026
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
…proofs

Addresses maintainer Critical 1 and Medium 6 on phase-rs#6886, plus CodeRabbit
3699361027 / 3699361052 / 3699361032, all in `analysis/resource.rs`.

1. CRITICAL — `ShortcutProposal.per_cycle` could not survive the PRODUCTION
   persistence path. Adds a serde adaptor riding the four `PlayerId`-keyed
   `ResourceVector` maps as pair SEQUENCES, generalizing the existing
   `counter_key_pairs` into `map_key_pairs` so one definition covers the tuple
   key and the player keys.

   MEASURED MECHANISM (the old doc comment asserted the opposite, and was
   wrong): a bare `BTreeMap<PlayerId, i64>` is fine through `from_str` and
   through `from_value` IN ISOLATION — which is why the existing
   `periodic_delta_survives_the_serde_json_wire` arm passed and gave false
   confidence. It breaks only under the ENCLOSING shape: `WaitingFor` is
   `#[serde(tag, content)]`, so its payload is buffered through serde's
   `Content`, which stringifies map keys, and `PlayerId` is
   `#[serde(transparent)]` over `u8`. `PersistedGameState::deserialize` routes
   EVERY decode through `serde_json::Value` + `from_value`, including the WASM
   restore at `engine-wasm/src/lib.rs`'s `from_str::<PersistedGameState>` — so
   `from_str` at the boundary does not save it. Measured on serde_json 1.0.149;
   the failure text is `invalid type: string "0", expected u8`, exactly what
   `tests/integration/loop_shortcut.rs` had already recorded as a standing
   limitation. `generic_triggers` keeps its bare map: `TriggerKind` is a
   unit-variant enum, measured Ok through the same path.

   NEW ROW `a_populated_per_cycle_proposal_survives_the_production_persistence_
   boundary` drives `from_value`, the `PersistedGameState` boundary, and the
   WASM bridge's own `from_str::<PersistedGameState>`, with all four
   `PlayerId`-keyed maps populated behind a reach-guard.
   REVERT-PROBE, run: dropping `#[serde(with = "map_key_pairs")]` from `life`
   ⇒ FAILS with that exact error text; restored byte-identical.

2. MEDIUM — an EMPTY `FreeUnlessReplacements` derivation discharged the
   CR 616.1 obligation vacuously. `!events.iter().any(..)` is `true` for an
   empty slice, and the only thing preventing it was a `debug_assert!`, which
   compiles out of release — so the fail-open case was live in the build that
   ships. Now a first-class refusal in every build. A refusal rather than a
   panic on purpose: it matches every other seam in the module, and a
   `debug_assert!` could not be covered at all, since it aborts the build tests
   run in.
   REVERT-PROBE, run: deleting the arm ⇒ the empty case flips to `true` and the
   new row FAILS while the non-empty arms stay green; restored byte-identical.

3. Comment defect (CodeRabbit 3699361032): the zero-delta early return claimed
   "every longer period is a whole number of copies of this one". The
   repetition test inspects only the most recent `2k` deltas, so a larger `k'`
   need not be a multiple of `k`. Behaviour is unchanged and still fail-closed;
   the false justification is corrected in place, with the counter-example,
   because it is the kind of claim a later reader would lean on to widen the
   search while keeping the early return.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
Maintainer Critical 2 on phase-rs#6886, and CodeRabbit 3699361064.

`probe_resolution` compared only the `WaitingFor` DISCRIMINANT of the probed
clone against the incoming board. When the incoming board already carries a
non-priority variant, a resolution that re-parks the SAME variant leaves the
two discriminants equal, so the probe reported the resolution CHOICE-FREE while
an unanswered choice sat on the board. That is fail-open in the one direction
this function exists to close, and no comparison against the incoming variant
can see it — the incoming variant is exactly what masks it.

Now keyed on "is there a prompt at all": a non-`Priority` `waiting_for` on the
probed board is itself a refusal. The incoming board is a RESOLUTION BOARD by
this function's own documented contract, so a standing prompt is a reason to
refuse rather than a baseline to compare against. The discriminant test is kept
alongside it, so the guard is STRICTLY STRONGER on every input than the struck
form: it can only ever cost coverage (a missed offer), never soundness — the
same direction as the budget-exceeded and empty-derivation arms beside it.

NEW ROW `a_prompt_standing_on_the_incoming_board_refuses_the_probe`, a MATCHED
PAIR over all six allow-listed arms: each arm must still reach `Events` from a
priority board (positive control — without it a probe that refused everything
would pass), and must return `Prompted` when the same board carries a standing
`ReplacementChoice`. The row asserts the resolution does NOT clear that prompt,
so the discriminants really are equal and the struck guard could not have
caught it.

REVERT-PROBE, run: restoring the bare discriminant comparison ⇒ the negative
arm FLIPS TO FAIL while the positive control stays green; restored
byte-identical.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
Maintainer Medium 5 on phase-rs#6886, and CodeRabbit 3699361023 / 3699361081.

The candidate generator emitted `IterationCount::UntilLethal` unconditionally
for every `WaitingFor::LoopShortcut` node, including bounded offers.
`handle_declare_shortcut` rejects that combination outright
(`IterationCount::UntilLethal if offer.schema.is_bounded()` =>
`reject_shortcut_declaration`, `crates/engine/src/game/engine.rs`), and that
reject is a SUCCESSFUL fail-closed handback — `Ok(result)`, not an `Err`. So
the candidate was not merely a wasted search node: the simulation layer was
handed an action the engine accepts and then silently discards, i.e. an illegal
quantity choice wearing the shape of a legal one, which the policy layer then
had to know to score away.

`UntilLethal` is now gated on `!schema.is_bounded()`. A bounded offer still
gets `Fixed(max_iterations)` where its pin set permits a `template: None`
declaration; where neither applies, `DeclineShortcut` genuinely is the only
legal answer at the node, and representing that honestly is the point.

Paired AI-side guard: `LoopShortcutPolicy`'s final bounded arm matched every
remaining `Fixed(n)` INCLUDING `n == 0`, so a zero-count declaration — legal
and representable, per
`a_zero_count_declaration_validates_over_an_empty_range_but_still_checks_
cardinality` — would have been scored into the CRITICAL band. It commits no
cycles while spending the CR 732.2b response window, the same weak-domination
shape the over-bound and `(None, UntilLethal)` arms already reject. Unreachable
from today's generator, so no current ranking moves; the arm now states its own
precondition instead of relying on an invariant maintained a crate away. Uses
`PolicyVerdict::reject`, not a raw sentinel.

R8 OFFER-WRITER CENSUS: unaffected, and checked rather than assumed. The census
counts occurrences of the `WaitingFor::LoopShortcut {` token; this change edits
the BODY of an existing match arm and adds or removes no such token, so the
pinned (22, 14) pair and the per-file production multiset are untouched.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
…g carriers

Maintainer Medium 3 on phase-rs#6886, and CodeRabbit 3699361085 / 3699361087 plus the
non-object `command` nitpick. Also folds in the promised doc correction.

1. `_defs` read `.description` across BOTH definition lists, but they do not
   serialize alike. `trigger_definitions` is `Definitions<TriggerEntry>` and
   `TriggerEntry` is `{occurrence, definition}`, so its text is at
   `.definition.description`; only `base_trigger_definitions`
   (`Vec<TriggerDefinition>`) exposes `.description` directly.

   MEASURED on the committed corpus, which is what the maintainer asked for and
   what the earlier "all 172 carriers matched non-null" claim did not
   establish: of the `trigger_definitions` entries, ZERO expose a direct
   `.description` and 100% nest it (145 / 165 / 132 on dellian / dina /
   witherbloom). The live list therefore contributed NOTHING — every entry
   collapsed to the `// ""` fallback — and all 172 carriers resolved through
   `base_trigger_definitions` alone. Total descriptions visible to the
   derivation across the corpus: 875 before, 1755 after.

   REACHABLE, not theoretical: `dellian_emblem_conqueror_4p` carries a GRANTED
   trigger ("When ~ dies, you gain 1 life.") present in the live list and
   absent from the base list. A firing whose description existed only there
   would have aborted the whole stamp on a classifiable fixture.

   BEHAVIOUR-PRESERVING on the corpus: 172 carriers resolve before AND after,
   all `Ordinary`; the pristine regeneration stays BYTE_IDENTICAL=true.

2. `stamp_trigger_firing` assigned all three carrier keys unconditionally
   without reading them, so `stamp-fixture-firing.sh`'s header claim that
   in-place stamping "cannot revert anything" was false for exactly those keys
   — and arm 1 structurally cannot catch it, because it deletes them from both
   sides before comparing. An already-canonical `Delayed` carrier would either
   abort the stamp or be silently rewritten to `Ordinary`, the CR 603.7a to
   CR 603.1 re-classification this library exists to refuse. Now derives only
   into an ABSENT slot, which also makes the stamp idempotent. Preservation is
   scoped to stack entries that are still on the stack, so a stale key cannot
   accumulate and inflate the carrier total past the number of triggered
   records — the one shape that could have let arm 2's aggregate comparison
   cancel a surplus against a deficit.

3. `select(.command.DelayedTriggerInstall)` indexes `.command` with a key,
   which aborts jq with a raw type error on a serde unit variant (a bare JSON
   string). This file's contract is that undetermined cases abort BY NAME;
   filtered to objects first so the probe stays total.

TWO NEW PRE-FLIGHT CONTROL ARMS, both with negative controls, because no
fixture-level arm can witness either property:
  arm 4 DEFINITION_SHAPES  — `_defs` resolves a nested-only description AND a
        direct-only one, and still ABORTS on one present in neither.
  arm 5 CARRIER_PRESERVED  — an existing canonical carrier survives, while an
        absent one is still derived.
REVERT-PROBE, run: restoring the old `_defs` ⇒ arm 4 reports `nested=FAILED`
and the script refuses to stamp (rc=1), while `direct` stays green and the
negative control still aborts; restored byte-identical.

DOC: the header said the stamped artifact minus "the three new keys"; the
executable `del()` names FIVE (three carriers + two allocators). Corrected, as
promised in the PR body.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
Maintainer Medium 4 on phase-rs#6886, and CodeRabbit 3699361092 (Critical) /
3699361088 / 3699361096.

1. NON-ATOMIC WRITE, and the destination is the committed fixture. `regenerate`
   redirected the pipeline straight into `$dest`; the shell creates and
   TRUNCATES a redirection target before the first command in the pipeline
   runs, and the production path passes `$OUT`. This recipe aborts BY DESIGN —
   `_firing` raises `UNDETERMINED firing carrier`, `stamp_delayed_allocators`
   raises `UNDETERMINED delayed-trigger allocators` — so `set -e` / `pipefail`
   stopped the script only AFTER the fixture had been truncated and a partial
   gzip stream written over it. The failure mode of a fail-closed recipe was
   destruction of the artifact it was refusing to rewrite. Now stages to
   `mktemp` and `mv`s only on success, matching `stamp-fixture-firing.sh`.

2. PASS-THROUGH CORRUPTION. The final `{gameState:.gameState}` is a REWRITE,
   not a projection, for a dump that is not `gameState`-shaped: `.gameState` is
   null on those, so the document became `{"gameState":null}` — silently, since
   that is valid JSON. Several fixtures in this corpus really do use the other
   envelope (top level `turn_number`), which is why `lib/trigger-firing.jq`
   already passes them through. Keyed on `has("gameState")` rather than
   truthiness, so an explicitly-null `gameState` is not quietly normalised into
   the husk shape. Preserves the input unchanged, per the maintainer's
   disposition.

3. ARM 2 HAD GONE VACUOUS — it was reporting the opposite of its claim. It
   compared the patched and unpatched regenerations wholesale and required a
   difference, and inferred from that difference that the `effect_kind` filter
   had teeth. Stage 2b broke the inference: the patched filter also runs
   `stamp_trigger_firing` and `stamp_delayed_allocators`, and the allocator
   stage rewrites the two allocator keys on EVERY `gameState`-shaped dump in
   this corpus (measured: all six move to 1), while the unpatched filter runs
   neither. So the documents differed unconditionally, including on the dumps
   that carry no target prompt at all, and arm 2 printed `PATCHED_DIFFERS=true`
   while `effect_kind` had matched nothing. Now compares the `target_slots`
   projection specifically, and NAMES the no-prompt case instead of counting it
   as a pass. New arm 3 `STAGE_2B_LANDED` covers what arm 2 no longer claims.

TWO PRE-FLIGHT SELF-TESTS, on synthetic inputs through the SAME `transform` the
migration uses, because neither property can be witnessed by a corpus fixture —
both are about inputs this corpus does not contain. Each has a paired positive
control:
  ATOMIC_ON_FAILURE   — a real abort leaves the destination byte-identical to a
        sentinel (and a SUCCEEDING run must still replace it, or the row would
        pass because nothing ever writes).
  ENVELOPE_PRESERVED  — a `turn_number` dump survives unchanged (and a
        `gameState` dump must still be projected).

VERIFIED NEUTRAL against the pre-change script, run side by side: dellian
reproduces `BYTE_IDENTICAL=true` and the same `PATCHED_DIFFERS=true
stamped=["LoseLife"]`. dina's `BYTE_IDENTICAL=false` is PRE-EXISTING and
reproduces identically under the original script — it is the documented
later-parser-state divergence, not a regression from this change.

Assisted-by: ClaudeCode:claude-opus-5
@lgray
lgray force-pushed the feat/combo-detector-player-feedback branch from dd986e4 to 34f5823 Compare August 2, 2026 17:36
@lgray

lgray commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Thanks — all six findings are addressed, each with a discriminating test whose revert-probe was
actually run. The branch is also rebased onto 96e41b3ab (#6851), which is what the red Rust
shard was about.

The failing Rust verification

The census row the_cr_603_5_prompt_census_is_pinned_so_a_sixth_producer_is_a_counted_event
failed in CI but not locally because CI builds the merge ref while the branch was still based
on e12447f4f. #6851 inserts 51 lines above one of the five pinned CR 603.5 producers. Re-anchored
per the row's own protocol — never relaxed — and verified three independent ways: the producer SET
is still 5 and the partition still 5/7/25; all five producers are byte-identical at their old and
new coordinates (with a negative control: the new tree at the old coordinate :10589 is a bare
}); and git diff -U0 e12447f4f 96e41b3ab nets exactly +51 above that line and +51 whole-file,
so the predicted coordinate equals the observed one. The other Rust shard failures cascaded from
this one row via fail-fast.

Findings → commits

# finding commit
Critical ShortcutProposal.per_cycle JSON round trip c30f88101
Critical speculative resolution misses a re-parked prompt 92cc9c625
Medium trigger-firing evidence reads the wrong field 21187bc37
Medium fixture migration non-atomic / corrupts pass-through 72f7360ea
Medium bounded choices still expose UntilLethal 2fed2111d + da266a8d3
Medium empty resolution-event proof fails open c30f88101

Plus de38ea951 (census re-anchor), e18dba90c (choose_from_zone chooser authority + targeting
reach-guards), 361cfca25 and 34f582386 (lint and doc-attachment cleanups).

per_cycle serde. You were right, and the mechanism is worth stating precisely because it
explains why the existing round-trip test passed: a bare BTreeMap<PlayerId, i64> survives
from_str and from_value in isolation. It breaks only under the enclosing shape — WaitingFor
is #[serde(tag, content)], so serde buffers the payload through Content, which stringifies map
keys, and PlayerId is #[serde(transparent)] over u8. PersistedGameState::deserialize routes
every decode through serde_json::Value + from_value, including the WASM restore whose outer
call is from_str::<PersistedGameState> — so from_str at the boundary does not save it. The new
row drives from_value, the PersistedGameState boundary, and the WASM bridge's own entry point
with all four PlayerId-keyed maps populated; deleting the adaptor makes it fail with
invalid type: string "0", expected u8, which is the exact text this repo had already recorded as
a standing limitation in tests/integration/loop_shortcut.rs.

Trigger-firing evidence. Confirmed on the committed fixtures before changing anything: of the
trigger_definitions entries, zero expose a direct .description and 100% nest it under
.definition (145/165/132 on dellian/dina/witherbloom). So the live list was contributing nothing
and all 172 carriers were resolving through base_trigger_definitions alone — "every carrier
resolved" was true but was not evidence the read was correct. It is reachable, not theoretical:
dellian_emblem_conqueror_4p carries a granted trigger present only in the live list. The fix
reads each entry by its own shape; 172 carriers still resolve, descriptions visible to the
derivation go from 875 to 1755, and the pristine regeneration stays byte-identical.

On the fixture control you asked for: I did not add one, and I want to be straight about why rather
than quietly substitute. A committed fixture cannot discriminate this fix. The corrected recipe
derives byte-identical carriers on all six committed fixtures — that is the same measurement as
"172 resolve before and after", and it is exactly what makes the change safe to land. What the old
read got wrong is invisible in the OUTPUT and visible only in the INPUT the derivation could see,
which is the 875 → 1755 number above. So the control is placed where the discrimination actually
lives: two pre-flight arms in stamp-fixture-firing.sh, each with a negative control — arm 4 feeds
the derivation a nested-shape entry and a direct-shape entry and requires both to resolve (under
the old _defs the nested arm reports FAILED and the script exits 1, while the direct arm stays
green, so the arm is discriminating and not merely present), and arm 5 pins carrier preservation.
If you would rather have a fixture that fails loudly instead, say so and I will add one whose
expected output is the description COUNT rather than the carriers.

Bounded UntilLethal. Gated on !schema.is_bounded(). Worth flagging what this broke, because
it is the useful part: two of this PR's own §5 U6 rows failed, and they failed because they had
pinned exactly the behaviour you identified as wrong (the file's own commit message says "pin the
bounded-offer behaviour it actually has"). Re-pinned them, and that re-pin is now the discriminating
test the gating commit was missing. At the real F4 4p board legal_actions is [DeclineShortcut]
alone, and the row carries one revert-probe per exclusion — UntilLethal is excluded by the new
!is_bounded() gate, Fixed(max) by the pre-existing points.is_empty() one, and since F4 trips
both at once a single probe would leave the other holding the assertion up and report a false pass.
Both were run and both flip. One of those rows also claimed its predicted_winner == None guard
supplied reachability for the (None, UntilLethal) policy reject arm; my own fix makes that claim
false, so it is restated rather than left as a green test carrying a false statement. Separately,
the LoopCollapse comment at candidates.rs:3040 said the AI could never reach that prompt because
the arm "only ever proposes UntilLethal" — this PR's Fixed candidate stale'd that, and Fixed
is precisely the count that routes through materialize_fixed_shortcut into the stash, so the
prompt is AI-reachable now and the comment says so.

Fixture migration. Writes now stage to a temp file and mv only on success, and the final
projection preserves a non-gameState envelope instead of rewriting it to {"gameState":null}.
Both cases have self-tests with paired positive controls. While in there: control arm 2 had gone
vacuous — stage 2b made the patched and unpatched regenerations differ unconditionally, so it was
reporting PATCHED_DIFFERS=true on dumps where the effect_kind filter matched nothing. It now
compares the target_slots projection and names the no-prompt case instead of counting it as a
pass.

Empty events. Made a first-class refusal in every build rather than a louder assert — a
debug_assert! cannot be covered by a test, since it aborts the build tests run in.

Two CI jobs I could not fix, with the measurement

The Paired-seed AI gate and Decision-cost perf gate were cancelled, and this is
pre-existing infrastructure rather than anything this PR does. Both hit timeout-minutes: 60
(ai-gate.yml:32 and :121) at exactly ~60m00s from their own start times — not a fail-fast
cancel, which would land at one wall-clock instant instead of at equal per-job durations. The same
~60m cancellation is occurring on roughly fifteen unrelated branches over 2026-08-01/02
(mech/static-forced-attack, fix/ai-target-sourced-damage-lethality,
fix/6507-depletion-sacrifice-rider, ship/ai-reliability-fixes, and others); I checked runs
30750621663 and 30724573889 at job level and both gates cancel at ~60m there too. This PR's
production phase-ai surface also cannot account for it: of the three phase-ai files in the
diff, search.rs and projection.rs are one line each and both inside mod tests. Separately,
the AI gate log carries a pre-existing panic unrelated to loop shortcuts —
search.rs:921, AI fallback reached during pending cast (variant ManaPayment, spell Kappa Cannoneer) — whose debug_assert! came from #4325 and is untouched here; and cargo ai-perf-gate
emitted no output at all for 50 minutes before the kill. Happy to open a separate issue for both if
that is useful.

CodeRabbit

Thirteen of the seventeen inline comments are fixed in the commits above (several coincide with
your findings), as is the outside-diff choose_from_zone.rs one — the < 2 fallback was still
using players::opponents while the prompt path used choosable_opponents, so the two disagreed
about phased-out seats in the one case that gets no prompt. Two of the nitpicks are folded in as
well (the non-object command guard in the jq probe, and the aggregate-cancel weakness in the
stamper's arm 2, closed structurally by scoping carrier preservation to live stack ids).

Four are declined as follow-ups rather than landed in this round, each with its measurement in the
review thread: the FrameIx container branding, the resolution-board newtype, probing the chain
once at its root, and charging the probe budget before the board clone. The short version: the
first two are unreachable on every production call path I could measure (the FrameIx mint is
already container-scoped and returns None for a foreign frame; both EXTERNAL callers of
ability_resolution_choice_freedom bind the scope immediately before calling, and its two
recursive self-calls inherit an already-bound board), and the last two
move the shipped detector's coverage — which is not something I want to change silently in the
same round that restores the Rust verification, and for which the paired-seed and perf gates would
be the right instrument once they are running again.

One note on the base: this is rebased onto 96e41b3ab (#6851), and main has since moved to
70c6f4c51 (#6849). I measured that delta rather than re-rebasing onto it — it touches zero .rs
files (card data only), game/engine.rs is byte-identical across it, and git merge-tree returns
clean — so it cannot move the census anchor and the merge queue can own it.

Local gate, run in full at da266a8d3:

check result
cargo fmt --all --check rc=0
cargo clippy --workspace --all-targets -- -D warnings rc=0
cargo test -p phase-engine --lib 18355 passed, 0 failed, 6 ignored
cargo test -p phase-engine --test integration 4422 passed, 0 failed, 2 ignored
cargo test -p phase-ai 0 failed

The tip 34f582386 is one doc-only commit on top of that, verified with cargo fmt --all --check
and cargo check -p phase-engine --all-targets (both rc=0) rather than a second full run — it moves
twelve /// lines and no code, which I checked by sorting the diff's added and removed lines and
comparing them for equality.

Every commit in the range also compiles on its own (cargo check -p phase-engine --all-targets
across all 36, all OK) — worth having after a rebase, since the failure mode there is a commit whose
match arms depend on a later commit and which the three-way merge accepts silently.

Re-requesting review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (2)
crates/engine/src/game/resolution_prompt.rs (1)

53-64: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Make the join match wildcard-free, like the rest of this module.

Line 62 uses _. The rest of this file enforces classification at compile time (the wildcard-free Effect match, the ..-free ResolvedAbility destructure). A future third ResolutionChoiceFreedom variant would silently collapse into MayPrompt here instead of failing to compile. The fallback direction is fail-closed, so this is a compile-time-enforcement gap, not a soundness gap.

♻️ Proposed refactor
-            _ => ResolutionChoiceFreedom::MayPrompt,
+            (ResolutionChoiceFreedom::MayPrompt, _)
+            | (_, ResolutionChoiceFreedom::MayPrompt) => ResolutionChoiceFreedom::MayPrompt,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/engine/src/game/resolution_prompt.rs` around lines 53 - 64, Update
ResolutionChoiceFreedom::join to explicitly match every remaining pair of
variants instead of using the wildcard arm, returning MayPrompt for each
non-FreeUnlessReplacements combination. Preserve the existing merge behavior for
two FreeUnlessReplacements values while ensuring any future variant causes a
compile-time exhaustiveness error.

Source: Coding guidelines

crates/phase-ai/src/policies/loop_shortcut.rs (1)

525-560: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a row for the Fixed(0) reject arm.

The new zero-count arm at line 246 has no test. Deleting that arm flips nothing in this module: loop_shortcut_bounded_declare_scores_and_rejects_over_bound asserts only Fixed(4) and Fixed(11), and loop_shortcut_unbounded_offer_keeps_fixed_neutral asserts only Fixed(4). The arm therefore ships without a revert probe.

Assert the reject kind on the bounded schema. If you also apply the arm reorder proposed above, assert the same kind on the unbounded schema in the same row, so the two shapes are pinned together.

💚 Proposed row
/// CR 732.2a — a zero-repetition declaration commits nothing while spending the
/// CR 732.2b response window, so it is weakly dominated by declining.
///
/// REVERT-PROBE: delete the `(_, IterationCount::Fixed(0))` arm ⇒ the bounded case takes
/// the scoring arm and returns the CRITICAL band for a guaranteed no-op ⇒ this row FAILS.
#[test]
fn loop_shortcut_zero_count_declare_is_rejected() {
    let state = bounded_offer_state(10);
    let v = verdict_for(&state, &declare(IterationCount::Fixed(0)));
    assert!(
        matches!(v, PolicyVerdict::Reject { .. }),
        "a zero-count declaration commits no cycles and must be vetoed, got {v:?}"
    );
    assert_eq!(kind_of(&v), "loop_shortcut_bounded_declare_zero_count");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/phase-ai/src/policies/loop_shortcut.rs` around lines 525 - 560, Add a
regression test near loop_shortcut_bounded_declare_scores_and_rejects_over_bound
named loop_shortcut_zero_count_declare_is_rejected. On a bounded_offer_state,
evaluate declare(IterationCount::Fixed(0)), assert it returns
PolicyVerdict::Reject, and verify kind_of returns
loop_shortcut_bounded_declare_zero_count; if the related arm reorder is present,
add the corresponding unbounded-schema assertion in the same test row.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/engine/src/game/resolution_prompt.rs`:
- Line 562: The quantity prompt resolver must cover both currently unguarded
positions: in the ability-level handling around repeat_for, replace the
unconditional ignored binding with the quantity_offers_up_to_choice gate used by
the other count/amount fields, and in the Power arm recurse through base as well
as exponent, matching the Difference and Sum/Max handling. Apply these changes
at crates/engine/src/game/resolution_prompt.rs:562-562 and
crates/engine/src/game/resolution_prompt.rs:196-199.
- Around line 1279-1288: Update the attribution (i) assertion in the relevant
resolution test to cover both production guard legs: verify that probe_board()
starts with a non-Priority waiting_for or that the waiting_for discriminants
differ, matching the guard’s OR condition. Keep the existing assertion message
and surrounding event-accounting checks unchanged.
- Around line 1540-1560: Add an `optional_for` mutation entry to the mutation
table alongside `optional` and `optional_targeting`, assigning a value matching
the field’s actual type so the corresponding gate is exercised.
- Around line 371-374: Update the CR citation in the Effect::ChoosePermanent
match arm to CR 707.6, while preserving the existing explanation and behavior.

In `@crates/phase-ai/src/policies/loop_shortcut.rs`:
- Line 218: Reorder the `IterationCount::Fixed` match arms in the loop-shortcut
policy so the `Fixed(0)` rejection is evaluated before the
`!schema.is_bounded()` neutral result, covering unbounded offers as well. Remove
the later duplicate `Fixed(0)` arm while preserving the existing behavior for
nonzero fixed counts and other iteration variants.

In `@scripts/lib/trigger-firing.jq`:
- Around line 156-174: The stack carrier update in
scripts/lib/trigger-firing.jq:156-174 must compare $sf_existing + $sf with the
stored stack_trigger_firings map and assign the result whenever they differ,
including when the rebuilt map is empty, so stale departed-entry keys are
removed. Extend carrier_preservation_control in
scripts/stamp-fixture-firing.sh:121-145 with stack and resolving cases, plus a
stale-key case that verifies absent stack entries are dropped.

In `@scripts/migrate-dump-fixture.sh`:
- Around line 170-180: Update regenerate() to create the staged temporary file
in the destination directory rather than via system TMPDIR, ensuring the final
mv remains an atomic same-filesystem rename. Preserve the existing cleanup on
transformation failure and remove any staged file if temporary-file creation or
subsequent processing fails.

---

Nitpick comments:
In `@crates/engine/src/game/resolution_prompt.rs`:
- Around line 53-64: Update ResolutionChoiceFreedom::join to explicitly match
every remaining pair of variants instead of using the wildcard arm, returning
MayPrompt for each non-FreeUnlessReplacements combination. Preserve the existing
merge behavior for two FreeUnlessReplacements values while ensuring any future
variant causes a compile-time exhaustiveness error.

In `@crates/phase-ai/src/policies/loop_shortcut.rs`:
- Around line 525-560: Add a regression test near
loop_shortcut_bounded_declare_scores_and_rejects_over_bound named
loop_shortcut_zero_count_declare_is_rejected. On a bounded_offer_state, evaluate
declare(IterationCount::Fixed(0)), assert it returns PolicyVerdict::Reject, and
verify kind_of returns loop_shortcut_bounded_declare_zero_count; if the related
arm reorder is present, add the corresponding unbounded-schema assertion in the
same test row.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ba672bf6-c69d-422b-8310-87fd2cfd21c5

📥 Commits

Reviewing files that changed from the base of the PR and between dd986e4 and 34f5823.

⛔ Files ignored due to path filters (6)
  • crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/dina_conqueror_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/tenacity_exquisite_blood_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz is excluded by !**/*.gz
📒 Files selected for processing (48)
  • crates/engine/src/ai_support/candidates.rs
  • crates/engine/src/analysis/corpus_tests.rs
  • crates/engine/src/analysis/decision_template.rs
  • crates/engine/src/analysis/loop_check.rs
  • crates/engine/src/analysis/resource.rs
  • crates/engine/src/game/ability_scan.rs
  • crates/engine/src/game/ability_utils.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/game/casting_costs.rs
  • crates/engine/src/game/casting_tests.rs
  • crates/engine/src/game/effects/choose.rs
  • crates/engine/src/game/effects/choose_from_zone.rs
  • crates/engine/src/game/effects/clash.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/effects/proliferate.rs
  • crates/engine/src/game/effects/separate_piles.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/filter.rs
  • crates/engine/src/game/interaction.rs
  • crates/engine/src/game/mod.rs
  • crates/engine/src/game/phasing.rs
  • crates/engine/src/game/players.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/game/resolution_prompt.rs
  • crates/engine/src/game/sba.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/game/targeting.rs
  • crates/engine/src/game/zone_pipeline.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/mod.rs
  • crates/engine/tests/integration/fantastic_four_bounded_loop.rs
  • crates/engine/tests/integration/gift_recipient_phased_out_opponent.rs
  • crates/engine/tests/integration/interaction_contract.rs
  • crates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rs
  • crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs
  • crates/engine/tests/integration/loop_shortcut.rs
  • crates/engine/tests/integration/loop_shortcut_mana_engine.rs
  • crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/rules/battle.rs
  • crates/engine/tests/integration/sprout_inalla_realistic_offer.rs
  • crates/phase-ai/src/policies/loop_shortcut.rs
  • crates/phase-ai/src/projection.rs
  • crates/phase-ai/src/search.rs
  • scripts/lib/trigger-firing.jq
  • scripts/migrate-dump-fixture.sh
  • scripts/stamp-fixture-firing.sh
🚧 Files skipped from review as they are similar to previous changes (40)
  • crates/engine/tests/integration/main.rs
  • crates/engine/src/game/effects/proliferate.rs
  • crates/phase-ai/src/search.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/game/effects/clash.rs
  • crates/engine/tests/integration/gift_recipient_phased_out_opponent.rs
  • crates/engine/tests/integration/loop_shortcut_mana_engine.rs
  • crates/engine/tests/integration/sprout_inalla_realistic_offer.rs
  • crates/engine/src/analysis/corpus_tests.rs
  • crates/engine/src/game/ability_utils.rs
  • crates/phase-ai/src/projection.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/effects/choose.rs
  • crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs
  • crates/engine/src/game/casting_tests.rs
  • crates/engine/src/types/mod.rs
  • crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs
  • crates/engine/src/game/phasing.rs
  • crates/engine/src/analysis/loop_check.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/mod.rs
  • crates/engine/src/game/players.rs
  • crates/engine/src/game/interaction.rs
  • crates/engine/src/game/effects/separate_piles.rs
  • crates/engine/src/game/effects/choose_from_zone.rs
  • crates/engine/src/game/filter.rs
  • crates/engine/tests/integration/rules/battle.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/tests/integration/interaction_contract.rs
  • crates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/game/sba.rs
  • crates/engine/src/game/targeting.rs
  • crates/engine/src/analysis/decision_template.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/game/casting_costs.rs
  • crates/engine/src/game/zone_pipeline.rs
  • crates/engine/tests/integration/loop_shortcut.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/analysis/resource.rs

Comment thread crates/engine/src/game/resolution_prompt.rs Outdated
Comment thread crates/engine/src/game/resolution_prompt.rs Outdated
Comment thread crates/engine/src/game/resolution_prompt.rs
Comment thread crates/engine/src/game/resolution_prompt.rs
Comment thread crates/phase-ai/src/policies/loop_shortcut.rs
Comment thread scripts/lib/trigger-firing.jq
@matthewevans matthewevans self-assigned this Aug 2, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes — the current head still has fail-open classifier coverage and unbounded probe-cost gaps.

[HIGH] UpTo resolution choices can still be certified as choice-free through repeat_for. Evidence: crates/engine/src/game/resolution_prompt.rs:562 deliberately ignores repeat_for: Option<QuantityExpr>, while quantity_offers_up_to_choice only guards the other quantity positions (:181-202); QuantityExpr::UpTo is a resolution-time player choice and generic quantity resolution transparently takes its maximum (crates/engine/src/types/ability.rs:7027-7050). Why it matters: an allow-listed repeated ability with an UpTo repeat count can be probed as free and admitted to a loop certificate despite a CR 608.2d choice. Suggested fix: route repeat_for.as_ref().is_some_and(quantity_offers_up_to_choice) to MayPrompt and add a reach-guarded regression; add the missing optional_for mutation row in the same classifier test.

[MED] The probe budget does not bound the dominant work, and chain traversal re-resolves subchains. Evidence: crates/engine/src/analysis/resource.rs:4237-4252 clones the full GameState before the classifier can charge its budget; crates/engine/src/game/resolution_prompt.rs:613-624 probes the full ResolvedAbility and then recursively probes its sub_ability/else_ability, although the full resolver already resolves those branches. Why it matters: the loop detector can perform repeated whole-board clones/resolutions per frame-entry and exhaust its logical budget after paying the work, producing unbounded hot-path cost and avoidable coverage loss. Suggested fix: make the budget (or an equally cheap binding precondition) precede cloning and probe a chain root once, retaining recursion only for ability-level choice gates.

[MED] Fixture migration can retain stale trigger carriers and its advertised atomic write is not guaranteed atomic. Evidence: scripts/lib/trigger-firing.jq:170-173 assigns stack_trigger_firings only when new carriers exist, so a pruned $sf_existing is not written when $sf is empty; scripts/migrate-dump-fixture.sh:176-185 stages with mktemp -t, which may be on a different filesystem from $dest before mv. Why it matters: persisted fixtures can retain departed stack-entry metadata, and interruption during a cross-filesystem move can corrupt the destination. Suggested fix: assign whenever the rebuilt map differs (including {}), cover stale stack/resolving cases, and create/clean the stage file in dirname "$dest".

The required Rust aggregate check is still failed because both Rust test shards are cancelled, and the branch is behind current main; this review does not treat the author’s prior local run as a replacement for a current required-check success. Please address or substantively refute the findings, rebase as appropriate, and rerun the current-head review and required checks.

@matthewevans matthewevans added the enhancement New feature or request label Aug 2, 2026
@matthewevans matthewevans removed their assignment Aug 2, 2026
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
…proofs

Addresses maintainer Critical 1 and Medium 6 on phase-rs#6886, plus CodeRabbit
3699361027 / 3699361052 / 3699361032, all in `analysis/resource.rs`.

1. CRITICAL — `ShortcutProposal.per_cycle` could not survive the PRODUCTION
   persistence path. Adds a serde adaptor riding the four `PlayerId`-keyed
   `ResourceVector` maps as pair SEQUENCES, generalizing the existing
   `counter_key_pairs` into `map_key_pairs` so one definition covers the tuple
   key and the player keys.

   MEASURED MECHANISM (the old doc comment asserted the opposite, and was
   wrong): a bare `BTreeMap<PlayerId, i64>` is fine through `from_str` and
   through `from_value` IN ISOLATION — which is why the existing
   `periodic_delta_survives_the_serde_json_wire` arm passed and gave false
   confidence. It breaks only under the ENCLOSING shape: `WaitingFor` is
   `#[serde(tag, content)]`, so its payload is buffered through serde's
   `Content`, which stringifies map keys, and `PlayerId` is
   `#[serde(transparent)]` over `u8`. `PersistedGameState::deserialize` routes
   EVERY decode through `serde_json::Value` + `from_value`, including the WASM
   restore at `engine-wasm/src/lib.rs`'s `from_str::<PersistedGameState>` — so
   `from_str` at the boundary does not save it. Measured on serde_json 1.0.149;
   the failure text is `invalid type: string "0", expected u8`, exactly what
   `tests/integration/loop_shortcut.rs` had already recorded as a standing
   limitation. `generic_triggers` keeps its bare map: `TriggerKind` is a
   unit-variant enum, measured Ok through the same path.

   NEW ROW `a_populated_per_cycle_proposal_survives_the_production_persistence_
   boundary` drives `from_value`, the `PersistedGameState` boundary, and the
   WASM bridge's own `from_str::<PersistedGameState>`, with all four
   `PlayerId`-keyed maps populated behind a reach-guard.
   REVERT-PROBE, run: dropping `#[serde(with = "map_key_pairs")]` from `life`
   ⇒ FAILS with that exact error text; restored byte-identical.

2. MEDIUM — an EMPTY `FreeUnlessReplacements` derivation discharged the
   CR 616.1 obligation vacuously. `!events.iter().any(..)` is `true` for an
   empty slice, and the only thing preventing it was a `debug_assert!`, which
   compiles out of release — so the fail-open case was live in the build that
   ships. Now a first-class refusal in every build. A refusal rather than a
   panic on purpose: it matches every other seam in the module, and a
   `debug_assert!` could not be covered at all, since it aborts the build tests
   run in.
   REVERT-PROBE, run: deleting the arm ⇒ the empty case flips to `true` and the
   new row FAILS while the non-empty arms stay green; restored byte-identical.

3. Comment defect (CodeRabbit 3699361032): the zero-delta early return claimed
   "every longer period is a whole number of copies of this one". The
   repetition test inspects only the most recent `2k` deltas, so a larger `k'`
   need not be a multiple of `k`. Behaviour is unchanged and still fail-closed;
   the false justification is corrected in place, with the counter-example,
   because it is the kind of claim a later reader would lean on to widen the
   search while keeping the early return.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
Maintainer Critical 2 on phase-rs#6886, and CodeRabbit 3699361064.

`probe_resolution` compared only the `WaitingFor` DISCRIMINANT of the probed
clone against the incoming board. When the incoming board already carries a
non-priority variant, a resolution that re-parks the SAME variant leaves the
two discriminants equal, so the probe reported the resolution CHOICE-FREE while
an unanswered choice sat on the board. That is fail-open in the one direction
this function exists to close, and no comparison against the incoming variant
can see it — the incoming variant is exactly what masks it.

Now keyed on "is there a prompt at all": a non-`Priority` `waiting_for` on the
probed board is itself a refusal. The incoming board is a RESOLUTION BOARD by
this function's own documented contract, so a standing prompt is a reason to
refuse rather than a baseline to compare against. The discriminant test is kept
alongside it, so the guard is STRICTLY STRONGER on every input than the struck
form: it can only ever cost coverage (a missed offer), never soundness — the
same direction as the budget-exceeded and empty-derivation arms beside it.

NEW ROW `a_prompt_standing_on_the_incoming_board_refuses_the_probe`, a MATCHED
PAIR over all six allow-listed arms: each arm must still reach `Events` from a
priority board (positive control — without it a probe that refused everything
would pass), and must return `Prompted` when the same board carries a standing
`ReplacementChoice`. The row asserts the resolution does NOT clear that prompt,
so the discriminants really are equal and the struck guard could not have
caught it.

REVERT-PROBE, run: restoring the bare discriminant comparison ⇒ the negative
arm FLIPS TO FAIL while the positive control stays green; restored
byte-identical.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
Maintainer Medium 5 on phase-rs#6886, and CodeRabbit 3699361023 / 3699361081.

The candidate generator emitted `IterationCount::UntilLethal` unconditionally
for every `WaitingFor::LoopShortcut` node, including bounded offers.
`handle_declare_shortcut` rejects that combination outright
(`IterationCount::UntilLethal if offer.schema.is_bounded()` =>
`reject_shortcut_declaration`, `crates/engine/src/game/engine.rs`), and that
reject is a SUCCESSFUL fail-closed handback — `Ok(result)`, not an `Err`. So
the candidate was not merely a wasted search node: the simulation layer was
handed an action the engine accepts and then silently discards, i.e. an illegal
quantity choice wearing the shape of a legal one, which the policy layer then
had to know to score away.

`UntilLethal` is now gated on `!schema.is_bounded()`. A bounded offer still
gets `Fixed(max_iterations)` where its pin set permits a `template: None`
declaration; where neither applies, `DeclineShortcut` genuinely is the only
legal answer at the node, and representing that honestly is the point.

Paired AI-side guard: `LoopShortcutPolicy`'s final bounded arm matched every
remaining `Fixed(n)` INCLUDING `n == 0`, so a zero-count declaration — legal
and representable, per
`a_zero_count_declaration_validates_over_an_empty_range_but_still_checks_
cardinality` — would have been scored into the CRITICAL band. It commits no
cycles while spending the CR 732.2b response window, the same weak-domination
shape the over-bound and `(None, UntilLethal)` arms already reject. Unreachable
from today's generator, so no current ranking moves; the arm now states its own
precondition instead of relying on an invariant maintained a crate away. Uses
`PolicyVerdict::reject`, not a raw sentinel.

R8 OFFER-WRITER CENSUS: unaffected, and checked rather than assumed. The census
counts occurrences of the `WaitingFor::LoopShortcut {` token; this change edits
the BODY of an existing match arm and adds or removes no such token, so the
pinned (22, 14) pair and the per-file production multiset are untouched.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
…g carriers

Maintainer Medium 3 on phase-rs#6886, and CodeRabbit 3699361085 / 3699361087 plus the
non-object `command` nitpick. Also folds in the promised doc correction.

1. `_defs` read `.description` across BOTH definition lists, but they do not
   serialize alike. `trigger_definitions` is `Definitions<TriggerEntry>` and
   `TriggerEntry` is `{occurrence, definition}`, so its text is at
   `.definition.description`; only `base_trigger_definitions`
   (`Vec<TriggerDefinition>`) exposes `.description` directly.

   MEASURED on the committed corpus, which is what the maintainer asked for and
   what the earlier "all 172 carriers matched non-null" claim did not
   establish: of the `trigger_definitions` entries, ZERO expose a direct
   `.description` and 100% nest it (145 / 165 / 132 on dellian / dina /
   witherbloom). The live list therefore contributed NOTHING — every entry
   collapsed to the `// ""` fallback — and all 172 carriers resolved through
   `base_trigger_definitions` alone. Total descriptions visible to the
   derivation across the corpus: 875 before, 1755 after.

   REACHABLE, not theoretical: `dellian_emblem_conqueror_4p` carries a GRANTED
   trigger ("When ~ dies, you gain 1 life.") present in the live list and
   absent from the base list. A firing whose description existed only there
   would have aborted the whole stamp on a classifiable fixture.

   BEHAVIOUR-PRESERVING on the corpus: 172 carriers resolve before AND after,
   all `Ordinary`; the pristine regeneration stays BYTE_IDENTICAL=true.

2. `stamp_trigger_firing` assigned all three carrier keys unconditionally
   without reading them, so `stamp-fixture-firing.sh`'s header claim that
   in-place stamping "cannot revert anything" was false for exactly those keys
   — and arm 1 structurally cannot catch it, because it deletes them from both
   sides before comparing. An already-canonical `Delayed` carrier would either
   abort the stamp or be silently rewritten to `Ordinary`, the CR 603.7a to
   CR 603.1 re-classification this library exists to refuse. Now derives only
   into an ABSENT slot, which also makes the stamp idempotent. Preservation is
   scoped to stack entries that are still on the stack, so a stale key cannot
   accumulate and inflate the carrier total past the number of triggered
   records — the one shape that could have let arm 2's aggregate comparison
   cancel a surplus against a deficit.

3. `select(.command.DelayedTriggerInstall)` indexes `.command` with a key,
   which aborts jq with a raw type error on a serde unit variant (a bare JSON
   string). This file's contract is that undetermined cases abort BY NAME;
   filtered to objects first so the probe stays total.

TWO NEW PRE-FLIGHT CONTROL ARMS, both with negative controls, because no
fixture-level arm can witness either property:
  arm 4 DEFINITION_SHAPES  — `_defs` resolves a nested-only description AND a
        direct-only one, and still ABORTS on one present in neither.
  arm 5 CARRIER_PRESERVED  — an existing canonical carrier survives, while an
        absent one is still derived.
REVERT-PROBE, run: restoring the old `_defs` ⇒ arm 4 reports `nested=FAILED`
and the script refuses to stamp (rc=1), while `direct` stays green and the
negative control still aborts; restored byte-identical.

DOC: the header said the stamped artifact minus "the three new keys"; the
executable `del()` names FIVE (three carriers + two allocators). Corrected, as
promised in the PR body.

Assisted-by: ClaudeCode:claude-opus-5
lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
Maintainer Medium 4 on phase-rs#6886, and CodeRabbit 3699361092 (Critical) /
3699361088 / 3699361096.

1. NON-ATOMIC WRITE, and the destination is the committed fixture. `regenerate`
   redirected the pipeline straight into `$dest`; the shell creates and
   TRUNCATES a redirection target before the first command in the pipeline
   runs, and the production path passes `$OUT`. This recipe aborts BY DESIGN —
   `_firing` raises `UNDETERMINED firing carrier`, `stamp_delayed_allocators`
   raises `UNDETERMINED delayed-trigger allocators` — so `set -e` / `pipefail`
   stopped the script only AFTER the fixture had been truncated and a partial
   gzip stream written over it. The failure mode of a fail-closed recipe was
   destruction of the artifact it was refusing to rewrite. Now stages to
   `mktemp` and `mv`s only on success, matching `stamp-fixture-firing.sh`.

2. PASS-THROUGH CORRUPTION. The final `{gameState:.gameState}` is a REWRITE,
   not a projection, for a dump that is not `gameState`-shaped: `.gameState` is
   null on those, so the document became `{"gameState":null}` — silently, since
   that is valid JSON. Several fixtures in this corpus really do use the other
   envelope (top level `turn_number`), which is why `lib/trigger-firing.jq`
   already passes them through. Keyed on `has("gameState")` rather than
   truthiness, so an explicitly-null `gameState` is not quietly normalised into
   the husk shape. Preserves the input unchanged, per the maintainer's
   disposition.

3. ARM 2 HAD GONE VACUOUS — it was reporting the opposite of its claim. It
   compared the patched and unpatched regenerations wholesale and required a
   difference, and inferred from that difference that the `effect_kind` filter
   had teeth. Stage 2b broke the inference: the patched filter also runs
   `stamp_trigger_firing` and `stamp_delayed_allocators`, and the allocator
   stage rewrites the two allocator keys on EVERY `gameState`-shaped dump in
   this corpus (measured: all six move to 1), while the unpatched filter runs
   neither. So the documents differed unconditionally, including on the dumps
   that carry no target prompt at all, and arm 2 printed `PATCHED_DIFFERS=true`
   while `effect_kind` had matched nothing. Now compares the `target_slots`
   projection specifically, and NAMES the no-prompt case instead of counting it
   as a pass. New arm 3 `STAGE_2B_LANDED` covers what arm 2 no longer claims.

TWO PRE-FLIGHT SELF-TESTS, on synthetic inputs through the SAME `transform` the
migration uses, because neither property can be witnessed by a corpus fixture —
both are about inputs this corpus does not contain. Each has a paired positive
control:
  ATOMIC_ON_FAILURE   — a real abort leaves the destination byte-identical to a
        sentinel (and a SUCCEEDING run must still replace it, or the row would
        pass because nothing ever writes).
  ENVELOPE_PRESERVED  — a `turn_number` dump survives unchanged (and a
        `gameState` dump must still be projected).

VERIFIED NEUTRAL against the pre-change script, run side by side: dellian
reproduces `BYTE_IDENTICAL=true` and the same `PATCHED_DIFFERS=true
stamped=["LoseLife"]`. dina's `BYTE_IDENTICAL=false` is PRE-EXISTING and
reproduces identically under the original script — it is the documented
later-parser-state divergence, not a regression from this change.

Assisted-by: ClaudeCode:claude-opus-5
@lgray
lgray force-pushed the feat/combo-detector-player-feedback branch from 34f5823 to 94bb153 Compare August 2, 2026 21:32
@lgray

lgray commented Aug 2, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

All three findings are addressed, and probing one of them turned up a fourth defect you did not
name — worse than either of the two you found in that file. Rebased onto bc0d3264d.

Findings → commits

# finding commit
HIGH UpTo certified choice-free through repeat_for 276265e93
MED probe budget after the clone; chain re-resolves subchains d0cf597b8, 5017bce35
MED fixture migration retains stale carriers / non-atomic write eff6afbf1
(found while probing the above) canonical stack carriers silently overwritten eff6afbf1
3699361056's second bullet, optional_cleared_classification — adopted late 5017bce35
(found on review) the atomicity control could not fail on its own subject 5017bce35
clippy fallout from the d0cf597b8 refactor 7299db504
ChoosePermanent cited the wrong CR rule (707.2c → 707.6) 94bb153b0
attribution asserted one of the guard's two legs 94bb153b0

HIGH — UpTo via repeat_for

Confirmed, and the file was already contradicting itself. repeat_for was bound _ with the
justification "for each count, pure quantity eval (game/quantity.rs)" — while the doc comment on
quantity_offers_up_to_choice, twenty lines above, exists precisely to distrust that mechanism:
game/quantity.rs resolves UpTo { max } => recurse(max), silently answering the count as the
maximum, and none of the six allow-listed classes calls peel_up_to. So the dismissal cited the
exact behaviour the guard was built around.

Routed through that same shared authority rather than a local match, so a future QuantityExpr
wrapper is classified in one place instead of four. Three mutation rows added, each revert-probed
and each flipped: repeat_for_up_to; optional_for, the row you named as missing — the gate is
*optional || *optional_targeting || optional_for.is_some() and only two of three disjuncts had a
row, so the third could have been deleted with everything still green; and an UpTo nested under
Multiply, whose probe (swap the shared authority for a bespoke matches!) is what earns the
delegation. Plus a negative reach-guard: a non-UpTo repeat_for must stay probe-backed, or the
fix would be a coverage loss wearing the shape of a fix.

CR 608.2d and CR 107.1c verified against docs/MagicCompRules.txt.

MED — probe cost. Declined last round; your ruling adopted

I declined both of these last round and I was wrong to. My previous comment on this PR declined
3699361056 (charge the probe budget before the board clone) and 3699361067 (probe the chain once
at its root) as follow-ups, on the grounds that they move the shipped detector's coverage. Your
review overrules that, and the reversal is adopted here rather than argued: both are implemented in
d0cf597b8.

One correction I owe that comment, since a half-adoption is worse than a clean one: 3699361056
has two bullets, and d0cf597b8 implemented only the first. Its second names
optional_cleared_classification (resource.rs#L1636-L1642) as carrying the identical
clone-before-gate shape, and it was right — that site is closed in 5017bce35. I found it late,
through internal review rather than by reading the comment I was already retracting.

Since my stated reason for declining was coverage movement, coverage is what I measured — zero
movement
: engine lib 18355 pass, integration 4422 pass, including every F4 certification row and
R16's exact probe-demand equality.

The chain now gate-checks as a pure AST walk (chain_offers_choice: no state, no clone, no budget)
that stays exhaustive over both branches, and the probe runs once at the root, since
resolve_ability_chain already resolves the taken branch.

On which of your two remedies I took, because I took the second and you should have the
measurement.
You offered "the budget (or an equally cheap binding precondition) precede cloning",
and 3699361056 closes with the same alternative — "the cheaper alternative is to hoist the binding
precondition ahead of the clone so only entries that can bind pay for a board".
I implemented the budget form first. It fails
r16_the_f4_offering_beats_probe_demand_is_exactly_measured, which pins spent == conjunct6_asks
and that row's own assertion message predicts the failure: the meter's charge sits below the
optional gate, so hoisting a charge above it makes every optional ask charge twice. Measured, not
reasoned: with the charge in that position the row goes red exactly as written. The precondition
form bounds the same dominant work — gated entries now clone nothing at all — while the meter stays
where R16 pins it, and R16's exact equality holds.

If you would rather have the charge form, say so and I will land it together with an adjudicated
R16; I did not want to move a row you can't see me moving.

One semantic consequence, stated rather than buried. The old shape joined the branch verdicts,
and join unioned event sets, so the derived set included events from the branch not taken on
the probed board. The single root probe reports only what the resolution actually proposes — a
smaller and more accurate set, since the old one described a resolution that cannot happen, but a
genuinely different one. join had no remaining caller and is deleted rather than kept-and-allowed:
a dead combinator documenting superseded union semantics is how the next reader concludes the union
still happens.

MED — fixture migration. Both halves are mine, and there was a third

Both of the things you found are regressions I introduced last round, and I am not going to soften
that.

Stale carriers. The prune scoping $sf_existing to live stack ids was right, but the write-back
was gated on ($sf | length) > 0 — "did we derive any new carriers". In exactly the case the
prune exists for, a dump whose only change is that an entry left the stack, $sf is empty, so the
assignment was skipped and the stale key survived. Now assigns whenever the rebuilt map differs from
what is on the fixture, which covers your {} case, stays idempotent, and leaves trigger-free
fixtures untouched.

The write was not atomic. mktemp -t resolves to $TMPDIR; measured here, df --output=target
gives /tmp for the stage file and /home for the checkout. Across that boundary mv degrades to
copy-then-unlink and an interruption truncates the destination — the destruction the staging exists
to prevent. The commit that introduced it claimed atomicity the mechanism never delivered.

The reason the atomicity self-test did not catch its own subject is worth recording, and it turned
out to have two independent causes. The first: there were three copies of the staging recipe,
and the self-tests re-spelled it with their own mktemp -t, so the test exercised its own copy. A
control that re-implements the thing it controls is not a control; there is now one stage_path
helper used by the production path and both self-tests.

The second only surfaced on internal review, and it would have survived the first fix. The
self-test's destination lives under mktemp -d -t, so even with a single shared recipe, reverting
stage_path to mktemp -t puts stage and destination on the same filesystem (measured: both device
50 here) and the mv is atomic by accident of where the test put its own files. Both arms stayed
green against the exact defect they guard. There is now an arm that compares the stage and
destination directories — deliberately not their devices, because under a -t revert the stage
lands in /tmp while the destination is a subdirectory of /tmp, so a device check still passes.
Revert-probed: the arm reports STAGE_BESIDE_DEST=false — stage /tmp vs dest /tmp/migrate-dump-selftest-… and the script exits 1.

The third one, which you did not report. Writing the probe for the stale-carrier case surfaced
it: $sf_existing | has((.id|tostring)) reads as "is this entry already carried?", but jq evaluates
a function argument against the input of the pipe it sits in — here $sf_existing, not the stack
entry. .id is absent there, so the argument was the literal string "null", has was always
false, every live entry was re-derived, and $sf_existing + $sf let the fresh value override the
canonical marker. That silently rewrote Delayed to Ordinary — the CR 603.7a-to-CR 603.1
re-classification trigger-firing.jq's own header exists to refuse. Fixed by binding the key first;
I swept the file and the other three has/index sites already pass bound variables or literals.

New pre-flight arm STALE_CARRIER_PRUNED, three sub-arms, all keyed on dumps whose live carriers
already exist because that is what forces $sf empty and reaches the gate. Against the previous
library all three flip: pruned={"9":"Ordinary"} (the overwrite), all_stale={"5":"Ordinary"}
(your stale case), unchanged={"9":"Ordinary"} (the overwrite again, on a re-run). The existing
arm 5 could not see any of this — it only exercises pending_trigger_firing, never
stack_trigger_firings.

No shipped fixture was corrupted, measured: derivation output is byte-identical old-vs-new on
all 12 committed fixtures, and the full control passes (BYTE_IDENTICAL, PATCHED_DIFFERS,
STAGE_2B_LANDED, NO_COLLATERAL, needs==got everywhere). Reaching either defect requires a
departed stack entry or an existing Delayed stack carrier and the corpus has neither — which is
also why no fixture-based control could have caught them, and why the discriminating instrument has
to be the synthetic arm.

The CodeRabbit batch I had not triaged

Separate from your review, there is a CodeRabbit pass from 17:49Z — after my last comment, before
yours — whose seven inline comments I had not gone through. Three are already closed by the commits
above: it found the repeat_for gap independently of you, it found the missing optional_for
mutation row, and it found both fixture-migration defects. Of the four remaining:

3699911177 [Major] — taken. The attribution assertion in
an_unaccounted_derived_event_is_prompted_in_the_resolver pinned only one of the production guard's
two legs. The guard refuses when the probe board is not at Priority or when the discriminants
differ; the row asserted only the second. Both legs are now asserted, with separate messages so a
failure names the leg that actually fired.

The reasoning is right, but I could not reproduce the consequence, and I would rather report that
than let the fix carry a claim I did not measure. Four runs of the row, each mutating one thing:

run board leg assert accounting arm result
M1 as shipped present deleted RED
M2 as shipped absent deleted RED
M3 parked non-priority absent deleted RED — but not for the reason claimed
M4 parked non-priority present intact RED

M1 vs M2 is the pair that matters: deleting the accounting arm flips the row red with and without
the new assert, so the row was never passing for the wrong reason as it stood. And the scenario the
comment describes turns out not to be expressible on this fixture — M3 fails at the row's own
reach-guard, not at any arm attribution:

reach-guard: the derived set must actually carry the Unaccounted variant
([Draw { .. }, ZoneChange { .. }])

Parking waiting_for at ReplacementChoice changes what the chain proposes — the sub-ability's
Tap stops being derived — so the row dies before the two legs can diverge. So this is a
completeness guard against future drift, not a fix for a reachable defect, and the code comment says
exactly that rather than implying a bug was closed. Worth adding that leg one is not unguarded
overall: a_prompt_standing_on_the_incoming_board_refuses_the_probe covers it directly at the
production level by parking a prompt on the incoming board. What was missing was only its
attribution inside this row.

3699911169 [Minor] — taken, and it is the kind of error worth more than its severity label.
resolution_prompt.rs cited CR 707.2c for Effect::ChoosePermanent. Verified against
docs/MagicCompRules.txt: 707.2c is "If a static ability generates a continuous effect that's a
copy effect, the copiable values that effect grants are determined only at the time that effect
first starts to apply"
— a copiable-values timing rule with nothing to say about resolution-time
choices. The correct rule is CR 707.6: "if an object enters the battlefield as a copy of
another permanent, the object's controller will get to make any 'as [this] enters the battlefield'
choices for it"
— which is precisely the fresh choice that raises WaitingFor::CopyTargetChoice.
Corrected, with the rule's actual content in the annotation rather than a bare number. (Negative
control: 999.99z returns 0 hits, so the grep is discriminating and not matching everything.)

3699911175 [Major] — declined, refuted by the type. It asks that
quantity_offers_up_to_choice recurse into QuantityExpr::Power's base "matching the
Difference and Sum/Max arms". Power is declared { base: i32, exponent: Box<QuantityExpr> }
(types/ability.rs:7059-7060). base is a plain i32, structurally incapable of carrying an
UpTo, and the suggested quantity_offers_up_to_choice(base) would not compile — it would be a
type error, not a behaviour change. Its own fallback, "or document the invariant", is the applicable
half, and I have taken that: the arm now carries a one-line note saying why base: _ is not an
omission, so the asymmetry with Difference/Sum/Max stops reading like a bug to the next
reviewer. Worth noting the same comment's other bullet is your HIGH repeat_for finding, which is
real and fixed in 276265e93 — it found one true and one false in a single comment.

3699911189 [Minor] — deferred, with the trigger named. The claim is correct as stated: in
policies/loop_shortcut.rs the arm Fixed(_) if !schema.is_bounded() => na() precedes
Fixed(0) => reject, so a zero-count declaration against an unbounded offer scores neutral rather
than reject, and the class-bonus table ranks DeclareShortcut above DeclineShortcut. Two reasons
it is not in this PR. First, reachability: the arm's own comment already records that today's
generator emits Fixed only for bounded schemas, and the M5 fix in this PR gates that push on
schema.is_bounded() as well, so the branch is now unreachable through two independent conditions
rather than one. Second, cost: it is a phase-ai scoring change, which carries the cargo ai-gate
paired-seed obligation, and the AI gates are exactly the checks currently dying at the 60-minute
timeout described below — I would be buying a gate I cannot read in order to reorder an arm nothing
can reach. It goes in with the Fixed-candidate generator-gap follow-up, where the reachability and
the gate run land together.

The required Rust check — it is a timeout, not a failing test

Understood on not substituting a local run for a required check; that is why this is pushed and the
checks are running at head. But the red rows on the previous head were not test failures, and I
would rather you not go hunting for a panic that does not exist:

check conclusion duration failed steps
Rust tests (shard 1/2) cancelled 20m18s 0
Rust tests (shard 2/2) cancelled 20m16s 0
Rust (fmt, clippy, test, coverage-gate) failure 3s

ci.yml:123 sets timeout-minutes: 20 on the shards, and GitHub records a timeout-minutes kill
as cancelled. Equal per-job durations are the timeout signature; a fail-fast cancel lands at one
wall-clock instant with unequal durations. The whole run's conclusion is completed/cancelled with
no panic in the logs. The aggregate row is downstream, not independent: it started at 17:56:59,
after both shards were killed at 17:56:52 and 17:56:55.

Whose fault, both facts:

ref shard 1 shard 2
main run 30746439872 17m17s ✅ 16m24s ✅
main run 30755708363 16m42s ✅ 17m43s ✅
this PR @ dd986e404 (before this round) timeout 19m40s ✅ — 20s of headroom
this PR @ 34f582386 (after) timeout timeout
this PR @ 94bb153b0 (this comment's head) running at post time running at post time

Shard 2 had twenty seconds of margin and this round's commits consumed it; that crossing is mine.
And main runs at 82–89% of the cap, so the PR is spending headroom that was already nearly gone.
I did not trim tests to fit: local integration is ~190s of wall time, so the shard is dominated by
build rather than execution, and deleting test bodies would cost real coverage for very little clock.

But shard 1 was ALREADY timing out at dd986e404, before any of this round's work — and that is
what makes this a blocker rather than a courtesy.
No change I can make inside the repository
produces a green required Rust aggregate: even if I reverted every commit of this round, shard 1
still times out and the aggregate job still reports its cancelled dependency. The required-green you
are asking for is reachable only through .github/workflows/, which is yours and which I have not
touched. In ci.yml, the shard job at :121:

    timeout-minutes: 20    # -> 30

That cures shard 1 as well as shard 2. If you would rather add a third shard, or split the slow
dump-driven rows into their own job, I will prepare whichever you prefer — but one of them has to
land on your side before this PR can show the green aggregate the review asks for. I am not going to
pretend otherwise or quietly leave the check red without saying why.

The two AI gates were killed the same way one tier up: Paired-seed AI gate cancelled at 60m17s
and Decision-cost perf gate cancelled at 60m16s, against timeout-minutes: 60
(ai-gate.yml:32, :121). That is the third head on which I have measured it, alongside the ~15
unrelated branches cited in my previous comment, so I am treating it as infrastructure rather than
PR signal.

Local gate

check result
cargo fmt --all --check rc=0
cargo clippy --workspace --all-targets -- -D warnings rc=0
cargo test -p phase-engine --lib 18355 passed, 0 failed
cargo test -p phase-engine --test integration 4422 passed, 0 failed
cargo test -p phase-ai 0 failed

All 43 commits in the range also compile on their own — swept one by one at the pushed tip, not
inferred from the tip building. Re-requesting review — though the request itself 404s for this
account (pull only, no triage), so consider this the ask.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/migrate-dump-fixture.sh`:
- Around line 189-199: Update regenerate to register each staged path in the
shared cleanup list used by the EXIT, INT, and TERM traps, ensuring the stage
file is removed on interruption and failed mv. Replace the per-function cleanup
approach as needed, and update the control-mode trap near the existing EXIT trap
to append PATCHED and UNPATCHED to STAGE_FILES rather than replacing the shared
cleanup behavior.
- Around line 368-378: Update the ARM 3 validation around the STAGE_2B_LANDED
check to compare the allocator projection from $PATCHED with $UNPATCHED,
including both next_delayed_trigger_token and next_delayed_trigger_instance.
Treat identical projections as an already-satisfied control case rather than
reporting the allocator repair as landed, while preserving the existing failure
for patched output that still lacks valid allocator values and the success
logging for a genuine change.

In `@scripts/stamp-fixture-firing.sh`:
- Around line 229-253: Update the ALLOC_NEED calculation in the fixture loop to
skip allocator repair when .gameState is absent, matching the guard used by
stamp_delayed_allocators. Ensure non-gameState fixtures produce ALLOC_NEED=0 so
they remain unchanged and are skipped rather than failing ARM3.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: f0f24b22-a6fe-4e7d-9b4e-e82b373703e0

📥 Commits

Reviewing files that changed from the base of the PR and between 34f5823 and 94bb153.

⛔ Files ignored due to path filters (6)
  • crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/dina_conqueror_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/tenacity_exquisite_blood_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gz is excluded by !**/*.gz
  • crates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gz is excluded by !**/*.gz
📒 Files selected for processing (48)
  • crates/engine/src/ai_support/candidates.rs
  • crates/engine/src/analysis/corpus_tests.rs
  • crates/engine/src/analysis/decision_template.rs
  • crates/engine/src/analysis/loop_check.rs
  • crates/engine/src/analysis/resource.rs
  • crates/engine/src/game/ability_scan.rs
  • crates/engine/src/game/ability_utils.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/game/casting_costs.rs
  • crates/engine/src/game/casting_tests.rs
  • crates/engine/src/game/effects/choose.rs
  • crates/engine/src/game/effects/choose_from_zone.rs
  • crates/engine/src/game/effects/clash.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/effects/proliferate.rs
  • crates/engine/src/game/effects/separate_piles.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/src/game/filter.rs
  • crates/engine/src/game/interaction.rs
  • crates/engine/src/game/mod.rs
  • crates/engine/src/game/phasing.rs
  • crates/engine/src/game/players.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/game/resolution_prompt.rs
  • crates/engine/src/game/sba.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/game/targeting.rs
  • crates/engine/src/game/zone_pipeline.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/types/mod.rs
  • crates/engine/tests/integration/fantastic_four_bounded_loop.rs
  • crates/engine/tests/integration/gift_recipient_phased_out_opponent.rs
  • crates/engine/tests/integration/interaction_contract.rs
  • crates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rs
  • crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs
  • crates/engine/tests/integration/loop_shortcut.rs
  • crates/engine/tests/integration/loop_shortcut_mana_engine.rs
  • crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/rules/battle.rs
  • crates/engine/tests/integration/sprout_inalla_realistic_offer.rs
  • crates/phase-ai/src/policies/loop_shortcut.rs
  • crates/phase-ai/src/projection.rs
  • crates/phase-ai/src/search.rs
  • scripts/lib/trigger-firing.jq
  • scripts/migrate-dump-fixture.sh
  • scripts/stamp-fixture-firing.sh
🚧 Files skipped from review as they are similar to previous changes (44)
  • crates/phase-ai/src/search.rs
  • crates/engine/src/game/effects/proliferate.rs
  • crates/phase-ai/src/projection.rs
  • crates/engine/src/game/casting_costs.rs
  • crates/engine/src/types/mod.rs
  • crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs
  • crates/engine/src/game/casting_tests.rs
  • crates/engine/src/game/effects/clash.rs
  • crates/engine/src/game/effects/separate_piles.rs
  • crates/engine/src/game/casting.rs
  • crates/phase-ai/src/policies/loop_shortcut.rs
  • crates/engine/tests/integration/sprout_inalla_realistic_offer.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/analysis/corpus_tests.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/src/analysis/loop_check.rs
  • crates/engine/src/game/filter.rs
  • crates/engine/src/game/zone_pipeline.rs
  • crates/engine/src/game/ability_utils.rs
  • crates/engine/src/game/sba.rs
  • crates/engine/src/game/mod.rs
  • crates/engine/tests/integration/interaction_contract.rs
  • crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs
  • crates/engine/src/game/players.rs
  • crates/engine/src/game/phasing.rs
  • crates/engine/src/ai_support/candidates.rs
  • crates/engine/src/game/effects/choose.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/tests/integration/rules/battle.rs
  • crates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rs
  • crates/engine/tests/integration/loop_shortcut_mana_engine.rs
  • crates/engine/src/game/interaction.rs
  • crates/engine/src/game/ability_scan.rs
  • crates/engine/src/game/targeting.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/tests/integration/fantastic_four_bounded_loop.rs
  • crates/engine/src/analysis/decision_template.rs
  • crates/engine/tests/integration/gift_recipient_phased_out_opponent.rs
  • crates/engine/src/game/engine.rs
  • crates/engine/tests/integration/loop_shortcut.rs
  • crates/engine/src/analysis/resource.rs
  • crates/engine/src/game/effects/choose_from_zone.rs

Comment thread scripts/migrate-dump-fixture.sh
Comment thread scripts/migrate-dump-fixture.sh Outdated
Comment thread scripts/stamp-fixture-firing.sh Outdated

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MED] The fixture stamper rejects valid non-gameState envelopes. Evidence: scripts/stamp-fixture-firing.sh:229-253 reads allocator fields unconditionally, although scripts/lib/trigger-firing.jq:145-146 intentionally passes such envelopes through. Why it matters: for every fixture without gameState, NEED is zero but ALLOC_NEED becomes one and ARM3 fails, so this new general-purpose script refuses an unchanged valid fixture. Suggested fix: make allocator need and ARM3 conditional on a present gameState (as stamp_delayed_allocators already is), and add a non-gameState regression case that reaches the script's skip path.

The current parse-diff artifact is bound to 94bb153b0a03fa08f0b0af2d5f8153d03ebde629 and reports no parser changes. I also confirmed the populated per_cycle JSON issue raised earlier is fixed at this head by the map_key_pairs serde adapters and its persistence-boundary test.

lgray added a commit to lgray/phase that referenced this pull request Aug 2, 2026
PRODUCTION STAYED AT 22. That is the half this pin exists to protect: the
assert's own text says a new production site in a certification-path file halts
the U-series, while a new READ site is the benign case to adjudicate.

Both new sites are in `phase-ai/src/policies/loop_shortcut.rs`'s `#[cfg(test)]`
module and belong to the proposer-elimination rows: `bounded_offer_with_period`,
a builder minting an offer whose certificate carries a real `per_cycle` so the
new arm can be driven at all, and `certificate_of`, a read accessor for those
rows. The policy arm itself READS the certificate and writes no offer, which is
why the production count is unmoved.

Named rather than renumbered, per the assert's instruction to name the new site
instead of only moving the number.

Assisted-by: ClaudeCode:claude-opus-5
@lgray

lgray commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Two fixes, one of them a real defect this PR introduced, plus the recorded current-head review your
hold asked for. The review found more than the reorder, so this is longer than a "done" — the
findings that did not survive verification are listed with the ones that did.

1. The AI could declare a loop that decks itself (introduced by this PR)

The bigger of the two, and it is ours.

elimination_bounds derives the offered bound from every living seat and deliberately lets the
proposer be the binding one
— CR 732.2a's proposer "need not be the player proposing the shortcut"
who benefits, so the producer is right not to gate on proposer benefit. That makes it the deciding
side's job, and nothing was doing it. Three facts compose:

  • a bounded offer always carries predicted_winner: None, so the policy's "hands somebody else the
    win" arm is structurally unreachable on every bounded offer;
  • the AI's only bounded candidate is Fixed(max_iterations) — the maximum, never a smaller n;
  • the in-bound arm scores the 8.0 field, auto-banded critical.

Net: a self-mill period whose binding seat is the proposer scored critical for running the
proposer's own library to exactly 0. That is the shape the policy file's own header says the policy
exists to prevent, re-opened on the Fixed axis this PR added — and strictly worse than the
Fixed(0) no-op you blocked on.

Fix, at the policy (cycles_to_proposer_elimination): the inverse of elimination_bounds,
mirrored axis for axis but asked of one seat, returning the minimum across axes so the arm
compares a single number.

axis threshold rule
library reaching 0 CR 104.3c + CR 121.4 — you lose on the draw from an empty library, not on emptying it, so an emptied library is an already-committed self-loss, and elimination_bounds deliberately permits that terminal value
life <= 0 CR 704.5a
poison >= 10 CR 704.5c

Each rate is guarded > 0 before it divides, so a library or life gain yields no bound on that
axis. The new arm sits immediately before the scoring arm, so the only verdicts that change are
ones that previously scored
— deliberate, to keep the AI-gate baseline blast radius minimal.

The Fixed(9) row is the point of the test. Library 30, 3 milled per cycle, fatal at exactly
10: Fixed(10) rejects and Fixed(9) still scores. A "fix" that refused any period charging
the proposer's library would satisfy the reject assertion and be wrong — it would refuse profitable
self-mill loops the proposer survives. Two more rows: the same schema with the drain renamed to an
opponent still scores (proving it reads the seat), and a life-axis row (proving min-across-axes is
not a library special case). Reach-guards assert the schema is bounded and the certificate carries a
period, since the predicate returns None without one and every assertion would otherwise be
vacuous.

2. frames_per_period == 0, and the host I first missed

Sibling of the zero-bound guard, by mechanism rather than symmetry: drive_one_shortcut_cycle closes
a committed cycle on frames_per_period.is_some_and(|k| frames_this_cycle >= k), and
frames_this_cycle is a u32 — so >= 0 is a tautology. The disjunct fires at the first
active-player priority beat, collapsing the cycle boundary to one beat instead of the certified span,
and every per-cycle conformance check then measures a truncated cycle against a whole-period delta.

Worth stating plainly because the first version of this fix was wrong: the field has two wire
hosts and I guarded the one the drive does not read.
frames_per_period rides PeriodicDelta,
which hangs off both LoopCertificate (the LoopShortcut offer) and ShortcutProposal (the
RespondToShortcut proposal) — and materialize_fixed_shortcut feeds the drive from
proposal.per_cycle, never from the offer's certificate. A restored RespondToShortcut is reached
without re-entering LoopShortcut, so the first guard was complete-looking and empty on the consumed
path. reject_zero_frames_per_period is now the single authority called from both hosts, with the
host named in the error so a reader can tell which fired. The test drives both, builds its
PeriodicDelta/WaitingFor in Rust and serializes them rather than hand-authoring JSON
(ResourceVector's maps are PlayerId-keyed and need the wire adapter), and each host carries a
legal-span anti-vacuity arm.

3. The CR 603.5 census, and an offer

Re-pinned twice this round: #6955 (+21) and then #6961/v0.44.0 (uniform +78, all three
effects/mod.rs entries). Every producer re-read and sha256-identical at its new coordinate; the
other two pins unmoved; count unchanged at 5.

The red you saw on shard 2/2 reported left: [5996, 6073, 9048] — byte-for-byte what this branch now
contains after merging your update. That red was the row correctly predicting the merge, not a defect.

The per-drift comments are now a single drift log (five entries) plus a diagnostic instruction:
rebuild the merge tree and compare coordinates there, never edit pins to match a local tree, because
this row fails in CI before it fails locally by design — CI tests refs/pull/<n>/merge.

The offer, your call: five drifts, all pure upstream line movement, zero true positives. A
function + content-hash anchor would end that class while keeping the property that actually matters
(a NEW mint is still a counted event). Happy to do it as a follow-up — not taken unannounced
mid-review.

4. Recorded current-head review

An independent adversarial review ran against this content across four axes, every finding
adversarially verified before being reported: 2 confirmed, 6 downgraded to hardening/disclosure,
1 refuted
, plus 34 areas recorded clean — including a full truth-table verification of the arm
reorder you requested and a census confirming every ShortcutDecisionSchema construction refuses
zero.

The confirmed pair are the two fixes above. Of the rest:

  • Restored ShortcutProposal.count vs MAX_SHORTCUT_CYCLES — the code gap is real; both named
    reproductions were wrong, so the severity does not stand as filed.
  • Empty-schema offers skip predictability_gate/validate_pins — mechanism confirmed
    (engine.rs:5088), but two of the three evidentiary pillars were factually wrong; overstated as
    filed.
  • Probe board vs resolve_top — confirmed structurally: both probe sites do
    clone → stack.retainbind_resolution_scope, while resolve_top performs four further
    preparations after its own bind. The divergence is in the conservative direction. Disclosed, not
    fixed here.
  • trigger_event_batch: None at the probe sites — root fact confirmed, the claimed failure
    mechanism refuted; a real residual remains via a different, amount-independent mechanism the
    finding did not name and did not demonstrate end-to-end.
  • R10′ witness zone-axis blindness — confirmed minor: event_is_accounted marks every
    ZoneChange accounted while BoardAxes measures no hidden-zone cardinality, so hand/library/
    graveyard/exile sizes are unmeasured. That is a guard-strength gap, not a live rules bug — a
    recorded ZoneChange still has its prompt cause checked. Ledgered.

One correction worth showing rather than hiding, since it is the kind of thing that decides how
much weight this review deserves. The self-elimination finding's evidence claimed
net_progress_for is called "exactly once, at engine.rs:2066". Verifying it, I first reported
that engine.rs contained no production call at all — which was wrong, caused by a grep piped
into a bare head whose 10-line default cut the list one line above the real call site. The
settled measurement: :2066 is real and inside the mint path, :4665 is a second production site,
so the finding was right on the site and wrong on the count. The substance is unchanged either way —
net_progress_for's axes are blind to proposer library and poison, and basis B never calls it, so
the policy-side check is the backstop regardless.

5. Measured, then declined

The review also found that the zero-bound guard sits on the two decode wrappers rather than
materialize_prepared, the chokepoint they share. That is correct and I initially mismeasured it
— I looked for calls inside impl Deserialize for ResolutionStateWire through a bounded window and
concluded the routing was unproven. Counting with no window: crates/engine/src/types/resolution.rs
has exactly two materialize_prepared calls, :2574 and :2688, both inside
ResolutionStateWire::from_value (:2465) — a different impl block, which is why the window missed
them — and Deserialize delegates to it. So materialize_prepared is a proven superset host.

Re-hosting is nonetheless declined this round, on one narrower ground: the :2574 legacy arm
mutates the state after materialization (push_ability_continuation, push_repeat_for), so a
guard hosted in the materializer would evaluate a state mid-assembly. For this guard that is likely
harmless, but "likely harmless on a partially-assembled state" is thin ground for a fail-closed
integrity check. Ledgered with a design note for whoever takes it: the right shape is probably not a
guard inside materialize_prepared but a post-assembly seam — at the end of from_value after the
frame pushes — which gets the superset coverage without ever evaluating mid-construction. Meanwhile
the shipped guard covers both live ingresses and has a discriminating dual-ingress test behind it.

6. cargo ai-gate — ran, rc=0, and one row needs explaining rather than waving through

Item 1 is an AI behaviour change, so the gate is required. A local run completed against this
content — verified by artifact rather than assumed: the built ai-gate binary contains the new
loop_shortcut_declare_eliminates_proposer reason string.

Scope correction, added after this comment was posted. This rc=0 is not evidence for
the policy fix, and I should not have presented it next to the item-1 claim. MatchConfig.loop_detection
defaults Off and phase-ai never sets it, so no loop-shortcut offer is minted in any duel-suite
game and LoopShortcutPolicy returns before reaching any arm. The gate is structurally
incapable of exercising the change — its rc=0 is evidence about everything except this policy
change, and the binary containing the reason string proves the build, not the coverage. Proof in a
later comment on this thread. The behavioural evidence for item 1 is the discriminating test set
and only that: the Fixed(10)-rejects / Fixed(9)-scores threshold pair, the opponent-seat
contrast, and the life-axis row.

rc=0, compare 0 FAIL, 0 WARN, 3 PASS. But the suite's own table reports a FAIL on one
matchup, and the exit code does not reflect it:

matchup baseline ms/game current ms/game p0/p1 wins draws avg turns
red-mirror 6,829 297,297 4/6 → 4/6 0 → 0 13.9 → 13.9
affinity-mirror 12,748 610,734 4/4 → 4/3 2 → 3 11.4 → 10.2
enchantress-mirror 25,299 520,256 4/6 → 1/1 0 → 8 14.6 → 11.8

The enchantress "10% mirror imbalance" is 1 win / 1 win / 8 draws.

The mechanism looks like truncation, not decision change. A veto lengthens games — a refused
loop shortcut means play continues — but these games got shorter (14.6 → 11.8 average turns)
and ended winnerless. Shorter, winnerless, alongside a large wall-clock gap on all three
matchups, with red-mirror unchanged to the decimal (4/6, 0 draws, 13.9 turns) as the control where
games still complete.

On the wall-clock numbers, stated with their provenance rather than as a headline: the 20–48× above
is the gap against the checked-in suite-baseline.json, whose recording conditions are not
resolvable (its git_sha references an unpushed commit). Separate instrumentation measures a
smaller engine-cost regression for the recent window, so the honest reading is "a real cost
regression compounded by unknown baseline-recording conditions." Decomposing it is owned by a
separate lane, not asserted here.

The residual, stated plainly: this run cannot positively exclude that the new arm fires during
enchantress games
— the gate's report records no policy-reason data, so reachability is not
answerable from its artifacts. An A/B against the pre-fix binary on the same seeds settles it by
measurement (the pre-fix binary shares the same slowdown, so identical 1/1/8 exonerates the
change). That is running and the result gets posted here either way, including if it goes against
us.

Two instrument defects found along the way, routed to the gate-reliability lane rather than fixed in
this PR: the exit code is driven solely by the compare section and is blind to a suite-level FAIL;
and drawn games carry winner: null, so the paired-seed flip counter has nothing to compare and
prints 0 flips while the matchup aggregate moves — a draw-rate delta should be a first-class
compare signal.

Also carried forward, pre-existing and not from this PR: one seed aborted on
AI fallback reached during pending cast (variant ManaPayment, spell Kappa Cannoneer) — can_cast_object_now has a gap, at search.rs:921. Tracked in the same lane.

For context on why this is reported locally at all — and this PR's own CI is now the cleanest
evidence for it. On run 30816542439 at head 349891f2c:

job started ended duration
Paired-seed AI gate 13:09:54Z 14:10:18Z 60m 24s
Decision-cost perf gate 13:10:01Z 14:10:27Z 60m 26s

Both cancelled at the 60-minute timeout-minutes ceiling, neither having produced a verdict. That
matches the repo-wide pattern: ai-gate's pull_request runs hit the ceiling on 22 of 22 runs on
2026-08-02 and 12 of 14 on 08-03 (median cancelled duration 60.4m — the cap itself). So the
absent AI-gate check on this head is the ceiling, not this change, and the local run above is the
only way we can currently give you a completed result at all.

Verification

Run directly in the branch worktree. fmt and clippy were re-run at the exact tip after the
census adjudication commit, rather than carried over from the commit before it — that commit touches
an integration test file, which --all-targets lints, so the earlier numbers would not have been
for this tree.

stage result
cargo fmt --all --check rc=0
scripts/check-parser-combinators.sh 349891f2c rc=0 — Gate A PASS, Gate G PASS
cargo clippy --workspace --all-targets -- -D warnings rc=0
cargo test -p phase-ai rc=0 — 2072 passed, 0 failed
cargo test -p phase-engine --lib rc=0 — 18462 passed, 0 failed
cargo test -p phase-engine --test integration rc=0 — 4468 passed, 0 failed, 2 ignored
cargo ai-gate rc=0 — see section 6, not reported as clean

Gate A runs against the true merge base explicitly; the hook's fork-relative default resolves to the
wrong base on a stale fork and yields a vacuous PASS.

Each new row is reported present in the test output, not inferred from a total:
loop_shortcut_declare_that_decks_the_proposer_is_refused,
loop_shortcut_declare_that_decks_only_an_opponent_still_scores,
loop_shortcut_declare_that_kills_the_proposer_on_life_is_refused,
loop_shortcut_unbounded_declare_rejects_zero_count,
a_wire_zero_frames_per_period_fails_the_load_and_a_wire_two_does_not — all ... ok.

One integration row went red on the first pass and was adjudicated, not relaxed.
the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_validates_pins reported
left: (22, 16) against right: (22, 14). The production half was unchanged at 22 — which is
the half that pin exists to protect, since its own text says a new production site in a
certification-path file halts the U-series while a new read site is the benign case. Both new hits
are in the policy's #[cfg(test)] module: bounded_offer_with_period, the builder that mints an
offer carrying a real per_cycle so the new arm can be driven at all, and certificate_of, a read
accessor. The new arm reads the certificate and writes no offer. Adjudicated 14 ⇒ 16 with both
sites named in the assert text
, per its own instruction to name the new site rather than only move
the number — matching the two adjudications already recorded there.

…itted

The CR 603.5 producer census pins `game/effects/mod.rs` line-exact. The phase-rs#6961 +
v0.44.0 uniform +78 shift moved the three producers to :5996/:6073/:9048, and
that re-pin was edited during the 851180c fold but never committed — it stayed
in the working tree, so the pushed tip 0b5a2bf still asserted :5918/:5995/:8970
and CI went red on it. This commit is that edit and nothing else.

Why the local gate was green while CI was red: the census test walks `src/` from
disk at runtime (`env!("CARGO_MANIFEST_DIR")` + `read_to_string`), so it measures
the WORKING TREE, never the commit. A dirty tree can never certify a commit —
"green at the exact tip" requires HEAD == sha AND a clean `git status` on the
gated paths, and that precondition is now part of the drift-log instructions.

Re-verified in the `refs/pull/<n>/merge` layout rather than locally, per the
drift log's own rule: `git merge-tree --write-tree HEAD upstream/main` (upstream
1738d5c) puts the producers at exactly :5996/:6073/:9048, with
`scoped_library_search.rs:452` and `engine.rs:11427` unmoved — all five
production producers accounted for, still no sixth. Upstream phase-rs#6959 is innocent.

Assisted-by: ClaudeCode:claude-opus-5
@lgray

lgray commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Correction: my last evidence table certified a tree that was never pushed

CI went red on 0b5a2bfa1. The cause is mine, and the correction matters more than the fix.

What happened. The CR 603.5 producer census pins game/effects/mod.rs line-exact. Upstream #6961 + the v0.44.0 bump shifted the three producers by a uniform +78. I made that re-pin during the fold that produced 851180cf7 — and never committed it. It sat in my working tree. The pushed tip still asserted the old :5918/:5995/:8970.

Why my table said green. The census test walks src/ from disk at runtime (env!("CARGO_MANIFEST_DIR") + read_to_string). It therefore measures the working tree, never the commit. My engine-lib row in the previous comment was a real, honest test run — of a tree that did not exist in git. A dirty tree cannot certify a commit, and my "green at the exact tip" claim did not check that. It checked HEAD == sha and stopped there.

The precondition I was missing, now written into the drift log in-tree:

green at the exact tip requires HEAD == sha and git status --short clean on the gated paths.

Any test that reads source from disk has this property. The pin is one; it will not be the only one.

Fix: 79ea95e1a, additive, plain fast-forward 0b5a2bfa1..79ea95e1a. Nothing rewritten.

Evidence, on the committed tree this time

check result
HEAD at run time 79ea95e1a
git status --short -uno before run clean
git status --short -uno after run clean
stage2_injector_tests (incl. the census) 17 passed, 0 failed
producers in effects/mod.rs, classified independently of the test exactly 3: :5996, :6073, :9048
other two production producers scoped_library_search.rs:452, engine.rs:11427 — unmoved

Adjudicated in the CI layout, not locally, per the drift log's own rule: git merge-tree --write-tree HEAD upstream/main against upstream 1738d5c91 puts the producers at exactly :5996/:6073/:9048. Upstream #6959 is innocent — it moves none of the census files. Five drifts now, all upstream line movement, still zero true positives; the production count is unchanged at five.

Non-vacuity is not an argument here — CI supplied it. This pin is exactly the kind of test that usually needs a revert-probe to prove it discriminates. It did not need one: the old pins put it at 1 failed / 12900 in shard 2/2 with the producer set printed in the assertion. A pin that reds on an 78-line shift is doing its job.

The Rust (fmt, clippy, test, coverage-gate) job that failed in 3s is the rollup, not a second defect: RUST_LINT_RESULT: success, CARD_DATA_RESULT: success, WASM_RESULT: success, TAURI_RESULT: success, RUST_TEST_RESULT: failure. Single root cause.

Still open, unchanged

The line-exact pin is what makes a new sixth producer a counted event, which is why I have not swapped it for something drift-immune mid-review. A function + content-hash anchor would end the drift class while keeping that property; it remains offered as a follow-up, not taken unannounced.

The enchantress win-rate question from the previous comment is still open and deliberately unrun — the box is currently occupied by another measurement whose design needs it quiet, and the repo's own duel_suite/perf.rs:1-30 documents that a bare PRE/POST pair cannot separate a real verdict move from suite jitter (nothing is byte-stable across runs, nor even in-process — RandomState rekeys per allocation, #4878). Results to follow with a variance-floor leg included; I would rather hand you a powered answer late than an under-powered one now.

@lgray

lgray commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

The enchantress ai-gate question, closed — the policy is unreachable in that suite

I owed you a powered A/B on the enchantress-mirror swing (baseline 40% → 10%). I did not run it, because the question was malformed: before attributing a result to a change, establish that the change's code is reachable in the run. It is not. No measurement was needed.

Reachability proof

# fact evidence
1 MatchConfig.loop_detection defaults Off — the #4603 opt-in invariant types/match_config.rs:24-37, #[serde(default, skip_serializing_if = "LoopDetectionMode::is_off")]
2 Off is the enum's #[default]; GameState initializes to it types/game_state.rs:11917-11918, :18684
3 GameState::loop_detection is projected from MatchConfig and nowhere else game_state.rs:18701 (set_match_config, single authority)
4 phase-ai never names loop_detection or LoopDetectionMode grep -rn over crates/phase-ai/**/*.rs (incl. the ai-gate bin): 0 and 0
5 phase-ai depends only on phase-engine — not on the crates that do set it crates/phase-ai/Cargo.toml, [dependencies] has exactly engine = { path = "../engine" }
6 The duel-deck snapshots carry no match config grep -rl loop_detection crates/phase-ai/duel_decks/ = 0; snapshot keys are format, frozen_date, main, name, source
7 Every WaitingFor::LoopShortcut mint is gated on state.loop_detection.samples() game/engine.rs:1426, :1502, :15834, ai_support/candidates.rs:5270
8 samples() is false for Off game_state.rs:11948-11950matches!(self, On | Interactive)
9 LoopShortcutPolicy::score let-else returns unless WaitingFor::LoopShortcut policies/loop_shortcut.rs:136

⇒ In every ai-gate duel-suite game loop_detection == Off, so no loop-shortcut offer is ever minted, so LoopShortcutPolicy returns before reaching any scoring arm — including the Fixed(n) proposer-elimination reject this PR adds. The policy cannot move an ai-gate number in either direction.

The zero in row 4 is instrumented, not assumed: the same grep finds 23 loop_detection assignments in crates/engine/src and 21 non-test On/Interactive sites across engine, manabrew-compat, and server-core. Every one of the engine hits is in a test, the analysis corpus, or mod tests (match_config.rs:109 is inside the mod tests that opens at :81) — none in a constructor the duel suite reaches.

Empirical corroboration

A binary built without this fix, run on the same host and the same suite, reproduces the anomaly digit for digit:

leg no-fix binary with-fix binary
red-mirror PASS 40% / 13.9 turns PASS 40% / 13.9 turns
enchantress-mirror FAIL 10% / 11.8 turns FAIL 10% / 11.8 turns
Wilson 95% CI [0.02, 0.40] [0.02, 0.40]

Caveat, stated rather than buried: that no-fix binary is built from a different tree base (another in-flight branch), so it is corroboration, not a controlled arm. The reachability proof above is the load-bearing argument; this is the independent second instrument agreeing with it.

The swing is therefore a property of the suite and the enchantress list, not of this PR. Consistent with that, duel_suite/perf.rs:1-30 documents that nothing is byte-stable across runs — nor even in-process, since RandomState rekeys per allocation (#4878) — and states that the macro trajectory (winner, turn) "must not be relied upon."

I withdraw the implication in my earlier comment that this PR was a candidate cause. It never was, and I should have run reachability before proposing a measurement.

Three gate-instrument defects found on the way — offered, not filed here

None of these are this PR's to fix; they are why the enchantress signal was hard to read at all. Routed to the gate-reliability lane:

  • P1ai-gate's exit code is driven solely by the COMPARE section, so a suite-level FAIL (as enchantress showed) can still exit 0.
  • P2 — drawn games carry winner: null, so the paired-seed flip counter has nothing to compare and prints 0 flips while the aggregate moves 40% → 10%. Both tables above show exactly this.
  • P3projection.rs's 15 ms TIME_CAP is not gated on measurement mode (unlike search.rs:2012), and the projection-budget floor is bypassed under Deadline::none() because remaining().is_none_or(|r| r >= floor) short-circuits None → true. A bailed projection scores 0.0 against up to +3.0, which makes AI decisions host-speed-dependent — and makes any cross-binary win-rate comparison on a loaded box unsound.

Happy to open these as separate issues if you'd rather have them tracked than mentioned.

@matthewevans matthewevans self-assigned this Aug 3, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] LoopShortcutPolicy treats reaching an empty library as immediate proposer elimination without proving a later mandatory draw. Evidence: crates/phase-ai/src/policies/loop_shortcut.rs:288-300 rejects a fixed declaration when cycles_to_proposer_elimination reaches its calculated threshold; :318-360 includes net negative library_delta in that calculation. CR 121.4 (local docs/MagicCompRules.txt:1158) instead makes a player lose when they attempt to draw from an empty library. The certificate carries the net library delta, not a required subsequent draw, while crates/engine/src/analysis/loop_check.rs:917-935 classifies self-mill as Advantage, not Decking, and the resource bound intentionally permits zero. Why it matters: a bounded, legal self-mill shortcut ending at zero with no forced draw is rejected even though it has not eliminated its proposer. Suggested fix: remove library from the immediate-elimination veto unless the certificate represents and proves a forced draw after the library reaches zero; add a discriminating exact-to-zero/no-draw positive regression that currently fails and scores bounded progress after the fix.

Required current-head evidence before re-review: regenerate the <!-- coverage-parse-diff --> sticky for 79ea95e1a15876655481636764be4eb451508f2d. The current artifact is bound to an earlier head; the Paired-seed AI and Decision-cost perf gates are also still pending. This is an evidence/verification requirement, not the substantive blocker above.

@matthewevans

Copy link
Copy Markdown
Member

Correction to my prior review: its parse-diff freshness sentence was incorrect. The existing <!-- coverage-parse-diff --> artifact at #6886 (comment) explicitly binds to current head 79ea95e1a15876655481636764be4eb451508f2d and reports no card-parse changes.

The substantive self-mill-to-zero blocker and its requested exact-to-zero/no-draw regression remain unchanged. The pending Paired-seed AI and Decision-cost perf gates remain verification conditions.

@matthewevans matthewevans removed their assignment Aug 3, 2026
…21.4)

`cycles_to_proposer_elimination` counted the proposer's library reaching 0 as
an elimination axis, so a bounded self-mill shortcut that ends at exactly zero
was rejected at the CRITICAL band. That is a rules error.

CR 121.4 (docs/MagicCompRules.txt:1158): "A player who attempts to draw a card
from a library with no cards in it loses the game the next time a player would
receive priority." The loss attaches to the DRAW ATTEMPT, not to the library
reaching zero — a player with an empty library and no draw ahead of them has
not lost and may still win. Self-mill to zero is a real strategy class:
`loop_check` classifies such a period as `Advantage`, not `Decking`, and
`ResourceVector::elimination_bounds` intentionally permits the exactly-zero
terminal value.

ACCUMULATION IS NOT REALIZATION, and the engine already tests that doctrine on
The One Ring. Under the Kilo/Freed/Relic proliferate engine it certifies an
infinite burden-GROWTH loop as `WinKind::Advantage` — not a win — naming the
unbounded burden counter axis (`analysis/corpus_tests.rs`,
`one_ring_burden_growth_certificate`, with the 0-burden dead-loop control
beside it). The burden's lethality realizes only downstream at the upkeep
trigger, which is where CR 704.5a finally applies:
`tests/integration/one_ring_burden_upkeep_lethal.rs` pairs
`one_ring_burden_upkeep_kills_owner_p0_wins` with the sub-lethal control
`one_ring_sublethal_burden_owner_survives_no_gameover`. An emptying library is
that same shape with mill for burden and the draw attempt for the upkeep
trigger, so removing the axis makes this policy CONSISTENT with the engine's
own tested doctrine rather than stricter than it.

That is also why life stays, and why the line is principled rather than an
ad-hoc keep/drop: `per_cycle.delta.life` is IN-CYCLE realization — the drain
happens inside the certified period and is state-based-checkable at cycle
boundaries, so the certificate proves the death it implies. The One Ring's
life loss would only enter a certificate the same way if the upkeep trigger
were inside the loop span. Both surviving axes are immediate state-based
losses on state alone: life 0 or less (CR 704.5a), ten or more poison
counters (CR 704.5c).

A certified period records per-cycle resource deltas; it cannot express "and
then the proposer is forced to draw", so no library-based veto is sound today.
The axis is removed rather than weakened, and the EXCLUSION is what the doc
comment annotates — why library is absent is the non-obvious fact a future
reader needs. If a certificate ever proves a forced post-zero draw, that is
the documented extension point.

Tests, written before the fix and observed failing first:
- `..._mills_the_proposer_to_exactly_zero_still_scores` is the regression. It
  FAILED pre-fix reading `loop_shortcut_declare_eliminates_proposer` with
  facts `declared=10, eliminates_at=10`, and scores bounded progress post-fix.
  Re-adding a library term flips it back, so it is its own revert-probe.
- The threshold-discrimination pair (fatal-at-N rejects / N-1 scores) moves to
  the life axis, which remains a true elimination axis, and keeps its
  independent-failure revert-probe.
- The seat contrast is re-based onto life: a library-drain contrast would now
  pass for BOTH seats and discriminate nothing.
- A poison row is added so the min-across-axes claim is covered on both
  surviving axes, with life held constant so poison is the only binding one.

Sibling sweep across phase-ai for library-to-zero-as-death reasoning: none.
`mill_targeting.rs:89` penalizes milling an ALREADY-empty library, which is a
wasted-value heuristic and not an elimination claim; the `self_cost_value.rs`
rows concern DRAWING from an empty library, which is CR 121.4-correct.

The engine-side bound derivation is deliberately untouched: this finding is
about the AI-side veto, and `elimination_bounds` already permits exactly-zero.

Assisted-by: ClaudeCode:claude-opus-5
@lgray

lgray commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

[HIGH] library-as-elimination — you're right, and it's your own doctrine I was violating

Fixed in 2cda9473d. I verified CR 121.4 at docs/MagicCompRules.txt:1158 myself before touching anything: "A player who attempts to draw a card from a library with no cards in it loses the game the next time a player would receive priority." The loss attaches to the draw attempt, not to the library reaching zero. A bounded self-mill that ends at exactly zero with no forced draw has eliminated nobody, and vetoing it refused a real strategy class.

cycles_to_proposer_elimination now measures life (CR 704.5a) and poison (CR 704.5c) only. The axis is removed rather than weakened, because a certified period records per-cycle resource deltas and cannot express "and then the proposer is forced to draw" — so no library-based veto is sound on today's evidence.

The precedent this should have been built from

Your engine already tests exactly this distinction, and my veto contradicted it:

The One Ring (already tested) an emptying library
burden accumulates in-loop mill accumulates in-loop
lethality realizes downstream at the upkeep trigger (CR 704.5a) loss realizes at the draw attempt (CR 121.4)
one_ring_burden_growth_certificate certifies it WinKind::Advantage — not a win loop_check classifies self-mill Advantage, not Decking
sub-lethal control one_ring_sublethal_burden_owner_survives_no_gameover — survives, no GameOver exact-to-zero control — scores bounded progress, no veto

tests/integration/one_ring_burden_upkeep_lethal.rs pairs one_ring_burden_upkeep_kills_owner_p0_wins with that sub-lethal control, and analysis/corpus_tests.rs carries the burden-growth certificate with its 0-burden dead-loop control. Accumulation is not realization — the engine refuses to conflate them, and this change makes the policy consistent with that rather than stricter than it.

That is also why life stays, and why the line is principled rather than an ad-hoc keep/drop. per_cycle.delta.life is in-cycle realization: the drain happens inside the certified period and is state-based-checkable at cycle boundaries, so the certificate proves the death it implies. The One Ring's life loss would only enter a certificate the same way if the upkeep trigger were inside the loop span. Both surviving axes are immediate state-based losses on state alone; that is precisely the property an empty library lacks. The exclusion — not the inclusion — is what the doc comment now annotates, since why library is absent is the non-obvious fact, and it names the extension point should a certificate ever prove a forced post-zero draw.

Your regression, written first and observed failing

stage result
..._mills_the_proposer_to_exactly_zero_still_scores, pre-fix FAILEDReject { kind: "loop_shortcut_declare_eliminates_proposer", facts: [("declared", 10), ("eliminates_at", 10)] }
same row, post-fix scores loop_shortcut_bounded_declare_progress, delta > STRONG_MAX

That failure is the built-in revert-probe: re-adding a library term flips it straight back. Neighbour-non-masking check: the life and poison rows passed in both runs, so the flip belongs to the library row alone and is not an artefact of a neighbouring assertion — pre-fix was 1 failed / 19 passed, post-fix 20 / 0.

The other rows moved with it. The threshold-discrimination pair (fatal-at-N rejects / N−1 scores) is now on the life axis, which is still a true elimination axis, keeping the property that the two rows fail independently. The old library threshold row is deleted, not adapted — its Fixed(10) assertion is now wrong and its Fixed(9) half was subsumed. The seat contrast is re-based onto life: a library-drain contrast would now pass for both seats and discriminate nothing, so keeping it would have been a test that tests nothing. A poison row is added so the min-across-axes claim is covered on both surviving axes, with life held constant so poison is the only binding one.

Sibling sweep across phase-ai for library-to-zero-as-death reasoning: none. Classified by role rather than by keyword — mill_targeting.rs:89 penalizes milling an already-empty library, which is a wasted-value heuristic and carries no elimination claim; the self_cost_value.rs rows concern drawing from an empty library, which is CR 121.4-correct as written.

The engine-side bound derivation is deliberately untouched: your finding is about the AI-side veto, and elimination_bounds already permits the exactly-zero terminal value.

Verification at this exact head

Measured at 2cda9473d, with git status --short verified clean before and after each run — a dirty tree cannot certify a commit, which is a precondition I added after it bit this PR earlier today.

check result
cargo clippy -p phase-ai --all-targets -- -D warnings rc=0
cargo test -p phase-ai (whole crate) 2073 passed, 0 failed
loop_shortcut policy module 20 passed, 0 failed
pre-commit Gate A / Gate G / Gate P all PASS

cargo ai-gate is not cited as coverage here. It cannot exercise this arm at all — loop_detection defaults Off and phase-ai never sets it, so no shortcut offer is minted in any duel-suite game. The proof is in my earlier comment on this thread; the behavioural evidence for this change is the discriminating test set above and only that.

Thanks for the self-correction on the parse-diff sticky — no action was needed there, though this push moves the head again, so it will rebind to 2cda9473d. The two 60-minute AI gates remain the documented repo-wide ceiling rather than PR signal, per the first-party table earlier in this thread; the gate-instrument defects found alongside it stand as offers to the reliability lane whenever you want them filed as issues.

Requesting a fresh review.

…ensus pinned

The CR 732.2a offer-writer census (`engine/tests/integration/
loop_shortcut_offer_writer_census.rs`) walks `phase-ai/src` as well as
`engine/src`, and pins `(production, in_test)` at `(22, 16)` as an INVARIANCE
pin over the whole 5d U-series. The poison row added in the previous commit
patched the certificate after construction:

    match &mut state.waiting_for {
        WaitingFor::LoopShortcut { certificate, .. } => { ...poison... }

which the anchor counts, taking the in-test half to 17 and reddening the
census. Measured `left: (22, 17)` / `right: (22, 16)`.

PRODUCTION STAYED AT 22 — the benign signature. The previous commit removes an
elimination axis and touches only `#[cfg(test)]` rows and doc comments, so a
production move would have meant something else was wrong and would have been
escalated rather than re-pinned.

The fix is to remove the hit, not to adjudicate it. The row never needed to
reach into a minted offer; it needed a poison delta AT CONSTRUCTION. A
`periodic_poison` helper beside `periodic` supplies that, so the offer is built
in one step and the census is untouched. Re-pinning would have spent an
invariance guarantee that spans the U-series to buy a test-local convenience.

Verified: census 2 passed / 0 failed with the pin unchanged; policy module
20 passed / 0 failed.

Process note for whoever reads this next: the previous commit's gate was scoped
by crate (`-p phase-ai`) and therefore could not see this failure, because the
test that broke lives in `crates/engine/tests/` and reads phase-ai source from
disk. Gate scope must be derived from what READS the files a change touches,
not from which crate those files live in.

Assisted-by: ClaudeCode:claude-opus-5
@matthewevans matthewevans self-assigned this Aug 3, 2026

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[HIGH] The offer-writer census has not been re-pinned after adding a WaitingFor::LoopShortcut test-pattern hit. Evidence: crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs:231-259 still expects (production, in_test) == (22, 16), while the current required shard-2 run reports left: (22, 17) and right: (22, 16) for that exact assertion (https://github.com/phase-rs/phase/actions/runs/30828354811/job/91735687842). Why it matters: this deterministic failure blocks the required Rust test gate and leaves the census contract out of sync with the deliberately added test-half surface. Suggested fix: adjudicate the new test-only WaitingFor::LoopShortcut site, update the expected test-half count to 17, and extend the explanatory census contract to name why that site is a benign test read rather than silently relaxing the pin.

The prior self-mill-to-zero logic blocker is resolved at this head: the library axis is now deliberately excluded from cycles_to_proposer_elimination, with an exact-to-zero/no-draw positive regression. This review requests only the census correction above.

@lgray

lgray commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

CI red on my last push was mine — a gate-scope miss, fixed in 1b2bf6d63

The CR 121.4 fix itself was sound; the commit that carried it reddened a different pin, and the reason is worth stating rather than quietly shipping a follow-up.

What broke. The CR 732.2a offer-writer census (engine/tests/integration/loop_shortcut_offer_writer_census.rs) pins (production, in_test) at (22, 16). My new poison row patched the certificate after construction —

match &mut state.waiting_for {
    WaitingFor::LoopShortcut { certificate, .. } => { /* poison */ }

— which the anchor counts. Measured left: (22, 17) / right: (22, 16).

Production stayed at 22. That is the benign signature, and I confirmed it by measurement rather than inferring it: the CR 121.4 commit removes an elimination axis and touches only #[cfg(test)] rows and doc comments, so a production move would have meant something else was wrong, and I would have stopped rather than touched the pin.

I did not re-pin it. The row never needed to reach into a minted offer — it needed a poison delta at construction. A periodic_poison helper beside the existing periodic supplies exactly that, so the offer is built in one step and the census hit ceases to exist. Your invariance pin stays (22, 16), as strong as when you reviewed it, and the row still tests what it exists to test: poison as the sole binding axis. Re-pinning would have spent an adjudication that spans the whole 5d U-series to buy a test-local convenience.

Why my gate didn't catch it. I scoped verification by crate — clippy -p phase-ai plus cargo test -p phase-ai — but that census lives in the engine's integration suite and reads phase-ai/src from disk (anchor() joins phase-ai at :164-167). A phase-ai-only gate structurally cannot observe it. Gate scope has to be derived from what reads the touched files, not from which crate they live in; this repo has two source-scanning censuses with different scan roots, and only one of them reaches phase-ai.

That is the second scope correction I have made on this PR today. The first was that a test reading source from disk measures the working tree, not the commit, so "green at the exact tip" requires HEAD == sha and a clean git status. Both are now preconditions I run rather than facts I remember, and I would rather you see both than have them buried.

Verification at 1b2bf6d63

Clean git status --short confirmed before and after each run, at this exact head.

check result
cargo clippy -p phase-ai --all-targets -- -D warnings rc=0
cargo test -p phase-ai (whole crate) 2073 passed, 0 failed
cargo test -p phase-engine --test integration (full suite, reverse-dependency scope) 4468 passed, 0 failed
CR 732.2a offer-writer census, pin unchanged at (22, 16) passing
loop_shortcut policy module 20 passed, 0 failed
pre-commit Gate A / Gate G / Gate P all PASS

The substantive CR 121.4 change in 2cda9473d is untouched by this commit — the regression row still fails-first against a restored library term, and the life/poison threshold pairs are unchanged. This commit only changes how the poison row constructs its offer.

@matthewevans

Copy link
Copy Markdown
Member

Correction to my immediately preceding census review: the branch advanced during submission to 1b2bf6d63fa70a71c95acc75bc59edd706ef82fe. That new head replaces the direct test WaitingFor::LoopShortcut mutation with the periodic_poison fixture helper in crates/phase-ai/src/policies/loop_shortcut.rs, so the added census hit is no longer present and the existing (22, 16) contract need not be re-pinned.

The prior request to change the count to 17 therefore does not apply to this new head. Fresh CI should verify that the census is again green. The resolved self-mill-to-zero behavior remains unchanged.

@matthewevans matthewevans removed their assignment Aug 3, 2026
@matthewevans matthewevans self-assigned this Aug 3, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer hold — the branch advanced to 9d00d2d5c8f277454535ffe6f2d22f78e54785a6 by merging current main after the independently reviewed 1b2bf6d63fa70a71c95acc75bc59edd706ef82fe head. Required CI is still running for this head, and there is not yet a current-head parse-diff artifact or external review. The earlier clean review and parse evidence therefore cannot authorize approval or merge-queue enrollment yet.

No contributor action is requested. Once required CI and the current-head artifact settle, we will perform the current-head recheck. The paired-seed and decision-cost AI checks are advisory and are not, by themselves, the blocker.

@matthewevans matthewevans left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved: current head was independently re-reviewed; required CI is green and the current-head parse artifact reports no changes. The bounded-offer fix is at the established seam with discriminating runtime coverage.

@matthewevans matthewevans added the quality For high-quality minimal to no-churn PRs label Aug 3, 2026
@matthewevans
matthewevans added this pull request to the merge queue Aug 3, 2026
@matthewevans matthewevans removed their assignment Aug 3, 2026
Merged via the queue into phase-rs:main with commit 4b34e54 Aug 3, 2026
15 of 17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request needs-maintainer AI-contribution PR requires human triage (Non-dev track or unresolved gaps) quality For high-quality minimal to no-churn PRs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants