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

Open
lgray wants to merge 56 commits into
phase-rs:mainfrom
lgray:feat/combo-detector-player-feedback
Open

feat(engine): loop-shortcut bounded-offer core, pin injection, owner firewall, and measured 4p rows (combo-fb phases 5a-5d, chain 3)#6886
lgray wants to merge 56 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 independent review-impl gate ran at dbc81821df74a5ad63b6a928ca80a5a720603522, the pre-rebase tree, and returned zero correctness findings. Upstream then advanced and this branch was rebased onto e12447f4f. Measured with git range-diff 73fd7f6de..dbc81821d e12447f4f..dd986e404: of the 25 reviewed commits carried across, 20 are content-identical (=) and 5 changed (!)ec3f88c2d, ed4c158d0, 89eacc318, 254e11183, 0512fccf6 — which are exactly the four conflict resolutions plus the three commits the drift fixes were autosquashed into. Two test/fixture-only commits were added after that (21477adc6, dd986e404). Those 5 changed commits and the 2 new ones have not been through an independent review pass; they have been through the full gate below. Eight further pre-boundary commits and one empty rustfmt commit dropped out of the range as already-merged upstream (#6838/#6839).

Everything below was run directly in the branch worktree at head dd986e404 (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; 4416 passed, 0 failed, 2 ignored.
  • cargo test -p phase-engine --lib — exit 0; 18344 passed, 0 failed, 6 ignored.
  • cargo test -p phase-engine --doc — exit 0; 7 doc tests, all ignore-annotated, so 0 run and 0 failed.
  • cargo test -p phase-ai — exit 0; 2067 passed, 0 failed summed across the crate's test binaries.
  • scripts/check-parser-combinators.sh e12447f4fbdc64d04ed41c1e77193185085a6a0c — exit 0 (Gate A, run against the true merge base rather than the hook's fork-relative default). 0 files under crates/engine/src/parser/ are touched by this PR.
  • 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=dd986e404aa005233a39eaed35868438a5a1c9ab base=e12447f4fbdc64d04ed41c1e77193185085a6a0c

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; the delta between it and the current head is enumerated in the Verification section above. It is stated as the true head of the review rather than restated as the current head.

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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main bounded loop-shortcut, pin validation, ownership, and four-player measurement changes.
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.
✨ 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 on lines +183 to +187
/// CR 732.2a: copied verbatim off the confirmed certificate so the drive reads ONE
/// authority for what a conformant cycle looks like. `None` for every offer whose
/// producer states no per-period signature (see [`LoopCertificate::per_cycle`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub per_cycle: Option<crate::analysis::resource::PeriodicDelta>,

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.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate ShortcutProposal, its derives, and every serialization site that carries it.
ast-grep run --pattern 'pub struct ShortcutProposal { $$$ }' --lang rust crates/engine/src

# Any serde round-trip test naming ShortcutProposal?
rg -nP --type=rust -C4 '\bShortcutProposal\b' crates/engine crates/engine-wasm | rg -n -C4 'serde_json|to_string|from_str|from_value|Serialize|Deserialize'

# DecisionTemplate is the other payload on ShortcutProposal — check its key types for map keys.
ast-grep run --pattern 'pub struct DecisionTemplate { $$$ }' --lang rust crates/engine/src

Repository: phase-rs/phase

Length of output: 5730


🏁 Script executed:

#!/bin/bash
set -e

printf '%s\n' '--- ShortcutProposal and surrounding declarations ---'
sed -n '145,195p' crates/engine/src/analysis/loop_check.rs

printf '%s\n' '--- DecisionTemplate and nested types ---'
sed -n '1,180p' crates/engine/src/analysis/decision_template.rs
rg -n -C3 'enum DecisionGroupKey|struct PinnedDecision|enum ReplayMode|type DecisionGroupKey' crates/engine/src/analysis

printf '%s\n' '--- Serialization and deserialization call sites ---'
rg -n -C4 --type rust 'ShortcutProposal|LoopCertificate|LoopShortcut|serde_json|to_string|from_str|from_value|Serialize|Deserialize' crates/engine crates/engine-wasm

printf '%s\n' '--- Explicit round-trip tests and panic/error handling ---'
rg -n -C5 --type rust 'round.?trip|periodic_delta_survives|serde_json|expect\(|unwrap\(|panic!' crates/engine crates/engine-wasm

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -e

printf '%s\n' '--- loop_check.rs ---'
sed -n '60,195p' crates/engine/src/analysis/loop_check.rs

printf '%s\n' '--- decision_template.rs declarations ---'
rg -n -C5 '^(pub )?(struct|enum|type) (DecisionTemplate|PinnedDecision|DecisionGroupKey|ReplayMode)\b' crates/engine/src/analysis/decision_template.rs

printf '%s\n' '--- exact ShortcutProposal references ---'
rg -n -C3 --glob '*.rs' '\bShortcutProposal\b' crates/engine crates/engine-wasm

printf '%s\n' '--- exact serialization references near loop shortcut types ---'
rg -n -C3 --glob '*.rs' '(serde_json|to_string|from_str|from_value|serialize|deserialize)' crates/engine/src/analysis crates/engine-wasm/src

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -e

printf '%s\n' '--- persisted RespondToShortcut ingress test ---'
sed -n '10770,10855p' crates/engine/tests/integration/loop_shortcut.rs

printf '%s\n' '--- PeriodicDelta and ResourceVector serialization shape ---'
rg -n -C8 'struct PeriodicDelta|struct ResourceVector|counter_key_pairs|PlayerId' crates/engine/src/analysis/resource.rs crates/engine/src/types/player.rs

printf '%s\n' '--- GameState serde boundary and WASM restore path ---'
rg -n -C5 'serde_json|from_slice|from_str|from_value|to_vec|to_string|GameState|waiting_for' crates/engine-wasm/src crates/engine/src/types/game_state.rs | head -n 500

printf '%s\n' '--- round-trip tests for ShortcutProposal or RespondToShortcut ---'
rg -n -C6 'per_cycle|RespondToShortcut|ShortcutProposal.*serialize|serialize.*ShortcutProposal|invalid type: string' crates/engine/tests/integration/loop_shortcut.rs crates/engine/src

Repository: phase-rs/phase

Length of output: 50371


🏁 Script executed:

#!/bin/bash
set -e

printf '%s\n' '--- exact persisted-restore test block ---'
sed -n '10778,10848p' crates/engine/tests/integration/loop_shortcut.rs

printf '%s\n' '--- PeriodicDelta declarations and serde attributes ---'
sed -n '1,220p' crates/engine/src/analysis/resource.rs | rg -n -C10 'PeriodicDelta|ResourceVector|BTreeMap|HashMap'
rg -n -C8 'pub struct PeriodicDelta|pub struct ResourceVector|counter_key_pairs|life:' crates/engine/src/analysis/resource.rs

printf '%s\n' '--- bounded proposal construction ---'
sed -n '4335,4385p' crates/engine/src/game/engine.rs
sed -n '2128,2225p' crates/engine/src/game/engine.rs

printf '%s\n' '--- WASM JSON boundary ---'
rg -n -C8 --glob '*.rs' 'serde_json::(to|from)|from_slice|to_vec|expect\(' crates/engine-wasm/src

Repository: phase-rs/phase

Length of output: 50370


🏁 Script executed:

#!/bin/bash
set -e

printf '%s\n' '--- PlayerId serde implementation ---'
rg -n -C12 'struct PlayerId|impl Serialize for PlayerId|impl.*Deserialize.*PlayerId' crates/engine/src

printf '%s\n' '--- PeriodicDelta complete field list ---'
sed -n '586,665p' crates/engine/src/analysis/resource.rs

printf '%s\n' '--- existing wire test body ---'
sed -n '12335,12435p' crates/engine/src/analysis/resource.rs

printf '%s\n' '--- WASM state projection callers of to_js ---'
rg -n -C5 --glob '*.rs' '\bto_js\(' crates/engine-wasm/src/lib.rs

Repository: phase-rs/phase

Length of output: 34236


🏁 Script executed:

#!/bin/bash
set -e

python3 - <<'PY'
from pathlib import Path

loop_check = Path("crates/engine/src/analysis/loop_check.rs").read_text()
resource = Path("crates/engine/src/analysis/resource.rs").read_text()
engine = Path("crates/engine/src/game/engine.rs").read_text()
player = Path("crates/engine/src/types/player.rs").read_text()
wasm = Path("crates/engine-wasm/src/lib.rs").read_text()
integration = Path("crates/engine/tests/integration/loop_shortcut.rs").read_text()

checks = {
    "ShortcutProposal serializes per_cycle": (
        "pub per_cycle: Option<crate::analysis::resource::PeriodicDelta>" in loop_check
    ),
    "PeriodicDelta contains ResourceVector": (
        "pub delta: ResourceVector" in resource
    ),
    "ResourceVector has PlayerId-keyed life map": (
        "pub life: BTreeMap<PlayerId, i64>" in resource
    ),
    "PlayerId is transparent u8": (
        "#[serde(transparent)]\npub struct PlayerId(pub u8);" in player
    ),
    "bounded proposal copies certificate per_cycle": (
        "per_cycle: offer.certificate.per_cycle.clone()" in engine
    ),
    "WASM generic serializer panics on serialization errors": (
        'unwrap_or_else(|e| panic!("serde_json serialization failed: {e}"))' in wasm
    ),
    "integration source records the non-round-tripping restore case": (
        'invalid type: string "0", expected' in integration
        and "proposal.per_cycle = None;" in integration
    ),
}

for name, ok in checks.items():
    print(f"{'PASS' if ok else 'FAIL'}: {name}")

if not all(checks.values()):
    raise SystemExit(1)
PY

Repository: phase-rs/phase

Length of output: 504


Make ShortcutProposal.per_cycle JSON-round-trippable.

A populated PeriodicDelta contains PlayerId-keyed maps. Persisted WaitingFor::RespondToShortcut then fails to deserialize, and the WASM to_js path panics when it serializes the proposal. Add a wire-safe map representation or serde adaptor.

🤖 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/loop_check.rs` around lines 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.

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 on lines +1281 to +1286
let per_period = ResourceVector::delta(&snaps[frames - 1 - k], &snaps[frames - 1]);
// Smallest repeating period, so every longer one is a whole number of copies of
// this one — a zero here cannot become non-zero at a larger `k`.
if per_period == ResourceVector::default() {
return None;
}

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 | 🟡 Minor | ⚡ Quick win

The zero-delta early return is fail-closed, but its stated justification is wrong.

The comment claims "Smallest repeating period, so every longer one is a whole number of copies of this one — a zero here cannot become non-zero at a larger k." The repetition test only inspects the most recent 2k deltas, so it does not establish that the whole ring is periodic with period k. A larger candidate k' is therefore not necessarily a multiple of k, and its per-period delta can be non-zero.

Counter-example over 8 frames (7 deltas d1..d7, oldest first). At k = 1 the last two deltas are equal and zero, so this returns None. At k' = 3 the test compares [d1, d2, d3] against [d4, d5, d6]; d5 = d6 = 0 forces d2 = d3 = 0, but d4 is unconstrained and equals d1. The k' = 3 period is then d4 + d5 + d6 = d4, which can be non-zero.

The behaviour is the safe direction — a missed offer, never a wrong one — so this is a comment defect rather than a soundness defect. Correct it, because a future reader can use the false claim to justify keeping the early return while widening the search, or to replace the search with a single-k probe.

If you want the search to continue past a zero-delta candidate instead, continue is sound here for the same reason the current return is safe: each candidate k is validated independently.

🐛 Proposed correction
-        // Smallest repeating period, so every longer one is a whole number of copies of
-        // this one — a zero here cannot become non-zero at a larger `k`.
+        // A cycle that moves no resource states no CR 704 threshold to bound, so it
+        // cannot supply a per-period magnitude and is refused. This is deliberately a
+        // WHOLE-SEARCH refusal, not a per-candidate one: the repetition test above
+        // inspects only the most recent `2k` deltas, so a larger `k` is NOT necessarily
+        // a multiple of this one and could carry a non-zero period. Refusing outright
+        // is the fail-closed direction (a missed offer, never a wrong one). Switch this
+        // to `continue` if that missed class ever needs to certify.
         if per_period == ResourceVector::default() {
             return None;
         }
📝 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
let per_period = ResourceVector::delta(&snaps[frames - 1 - k], &snaps[frames - 1]);
// Smallest repeating period, so every longer one is a whole number of copies of
// this one — a zero here cannot become non-zero at a larger `k`.
if per_period == ResourceVector::default() {
return None;
}
let per_period = ResourceVector::delta(&snaps[frames - 1 - k], &snaps[frames - 1]);
// A cycle that moves no resource states no CR 704 threshold to bound, so it
// cannot supply a per-period magnitude and is refused. This is deliberately a
// WHOLE-SEARCH refusal, not a per-candidate one: the repetition test above
// inspects only the most recent `2k` deltas, so a larger `k` is NOT necessarily
// a multiple of this one and could carry a non-zero period. Refusing outright
// is the fail-closed direction (a missed offer, never a wrong one). Switch this
// to `continue` if that missed class ever needs to certify.
if per_period == ResourceVector::default() {
return None;
}
🤖 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 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.

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 a62782802870cc33b03db11ffa8b7e548078b921.

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
// The engine-side regression row that keeps this honest is
// `bounded_fixed_count_commits_exactly_n_periods`, which asserts zero eliminations
// and `committed == n × published δ` on all three fixtures.
(_, IterationCount::Fixed(_)) if !schema.is_bounded() => na(),

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 | 🟡 Minor | ⚡ Quick win

The zero-count guard does not cover an unbounded offer.

Arm ordering places !schema.is_bounded() => na() before the Fixed(0) reject. A Fixed(0) declaration against an unbounded offer therefore returns neutral instead of reject.

The stated justification for the zero arm — "it commits NO cycles while still spending the CR 732.2b response window" — does not depend on the bound. It holds for the unbounded offer as well. Neutral is not harmless at this seam: in the heuristic branch the class-bonus table ranks DeclareShortcut (0.5) above DeclineShortcut (0.4), as this file documents at line 594, so a neutral verdict lets the AI pick the no-op declaration.

The branch is unreachable from today's engine generator, which emits Fixed only for bounded schemas. Rate it by what happens when it is reached.

Move the zero arm above the unbounded arm so the precondition is stated once for the whole Fixed family.

🛡️ Proposed reorder of the `Fixed` arms
+            // CR 732.2a: a ZERO-repetition declaration commits no cycles while still spending
+            // the CR 732.2b response window, on a bounded offer and on an unbounded one alike.
+            // Stated BEFORE the unbounded branch so the precondition covers the whole `Fixed`
+            // family rather than one half of it.
+            (_, IterationCount::Fixed(0)) => PolicyVerdict::reject(PolicyReason::new(
+                "loop_shortcut_bounded_declare_zero_count",
+            )),
+
             (_, IterationCount::Fixed(_)) if !schema.is_bounded() => na(),

Then delete the later Fixed(0) arm.

Also applies to: 233-248

🤖 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` at 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.

Source: Path instructions

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

@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: 1

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

246-248: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

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

The test module covers Fixed(4) and Fixed(11) on a bounded offer, and Fixed(4) on an unbounded one. No row exercises Fixed(0). Delete the arm at Lines 246-248 and every test still passes, so the arm carries no regression protection.

♻️ Proposed test row
+    /// CR 732.2a — the zero-count arm asserted directly. A `Fixed(0)` declaration commits no
+    /// cycles while spending the CR 732.2b response window, so it is weakly dominated by
+    /// declining. REVERT-PROBE: delete the `Fixed(0)` arm ⇒ this row reads
+    /// `"loop_shortcut_bounded_declare_progress"` at the critical band ⇒ FAILS.
+    #[test]
+    fn loop_shortcut_bounded_declare_rejects_zero_count() {
+        let state = bounded_offer_state(10);
+        assert!(schema_of(&state).is_bounded(), "REACH-GUARD: the zero arm sits below the \
+            unbounded branch, so this row is vacuous unless the schema reads bounded");
+        let v = verdict_for(&state, &declare(IterationCount::Fixed(0)));
+        assert!(
+            matches!(v, PolicyVerdict::Reject { .. }),
+            "a zero-cycle declare commits nothing 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 246 - 248, Add a
test case in the loop-shortcut policy test module covering a bounded offer with
IterationCount::Fixed(0), and assert it returns the rejection verdict with
reason "loop_shortcut_bounded_declare_zero_count". Keep the existing Fixed(4),
Fixed(11), and unbounded Fixed(4) cases 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 `@scripts/stamp-fixture-firing.sh`:
- Line 234: Update the staging flow around TMP and the final move so the
temporary compressed fixture is created in dirname "$FIX", ensuring the
subsequent mv remains atomic on the destination filesystem. Register this stage
path in the existing cleanup trap so interruptions during the gzip/jq pipeline
remove it, while preserving the current rm -f "$TMP" cleanup calls for other
paths and the final replacement behavior.

---

Nitpick comments:
In `@crates/phase-ai/src/policies/loop_shortcut.rs`:
- Around line 246-248: Add a test case in the loop-shortcut policy test module
covering a bounded offer with IterationCount::Fixed(0), and assert it returns
the rejection verdict with reason "loop_shortcut_bounded_declare_zero_count".
Keep the existing Fixed(4), Fixed(11), and unbounded Fixed(4) cases unchanged.
🪄 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: 8a8b6da3-ab1e-4993-8816-f57edac9f08b

📥 Commits

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

⛔ 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 (55)
  • client/src/i18n/locales/de/common.json
  • client/src/i18n/locales/en/common.json
  • client/src/i18n/locales/es/common.json
  • client/src/i18n/locales/fr/common.json
  • client/src/i18n/locales/it/common.json
  • client/src/i18n/locales/pl/common.json
  • client/src/i18n/locales/pt/common.json
  • 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/engine/src/game/phasing.rs
  • crates/phase-ai/src/search.rs
  • crates/engine/src/game/effects/token.rs
  • crates/engine/src/game/sba.rs
  • crates/engine/src/game/mod.rs
  • crates/engine/src/game/ability_utils.rs
  • crates/engine/tests/integration/loop_shortcut_mana_engine.rs
  • crates/engine/src/game/casting.rs
  • crates/engine/src/game/effects/separate_piles.rs
  • crates/engine/src/game/players.rs
  • crates/engine/tests/integration/kilo_live_offer_from_real_dump.rs
  • crates/engine/src/game/zone_pipeline.rs
  • crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs
  • crates/engine/tests/integration/sprout_inalla_realistic_offer.rs
  • crates/engine/src/game/effects/clash.rs
  • crates/engine/tests/integration/main.rs
  • crates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rs
  • crates/engine/tests/integration/interaction_contract.rs
  • crates/engine/src/game/casting_costs.rs
  • crates/engine/src/ai_support/candidates.rs
  • crates/phase-ai/src/projection.rs
  • crates/engine/src/game/effects/mod.rs
  • crates/engine/src/game/interaction.rs
  • crates/engine/src/game/effects/choose_from_zone.rs
  • crates/engine/src/game/effects/choose.rs
  • crates/engine/tests/integration/gift_recipient_phased_out_opponent.rs
  • crates/engine/src/game/casting_tests.rs
  • crates/engine/src/types/mod.rs
  • crates/engine/src/game/filter.rs
  • crates/engine/src/types/game_state.rs
  • crates/engine/src/game/targeting.rs
  • crates/engine/src/analysis/loop_check.rs
  • crates/engine/src/analysis/corpus_tests.rs
  • crates/engine/tests/integration/fantastic_four_bounded_loop.rs
  • crates/engine/src/game/effects/proliferate.rs
  • crates/engine/src/analysis/decision_template.rs
  • crates/engine/src/game/stack.rs
  • crates/engine/src/game/ability_scan.rs
  • crates/engine/src/game/replacement.rs
  • crates/engine/src/game/resolution_prompt.rs
  • crates/engine/src/game/engine.rs
  • scripts/lib/trigger-firing.jq
  • crates/engine/tests/integration/loop_shortcut.rs
  • crates/engine/src/analysis/resource.rs

Comment thread scripts/stamp-fixture-firing.sh Outdated
@matthewevans matthewevans self-assigned this Aug 3, 2026
Resolve maintainer-caused integration with main while preserving the loop-shortcut resolution binding and persisted offer validation.

Co-authored-by: Lindsey Gray <lindsey.gray@gmail.com>
Restore contributor changes lost during the delayed-trigger lifecycle merge.

Co-authored-by: Lindsey Gray <lindsey.gray@gmail.com>
@matthewevans

Copy link
Copy Markdown
Member

Maintainer hold — current head d1a5270a47d1331dfa24db5233b543501bf22403 is awaiting fresh CI and its current-head parse-diff artifact.

This follows a maintainer-caused integration correction: I restored loop-shortcut content that was inadvertently dropped while porting the branch across main's delayed-trigger lifecycle migration. The previous completed failures belong to superseded head f598dab9; the fresh CI run is now queued for this head.

No contributor action is requested. Next step: once the current card-data job publishes the parse-diff artifact and the new checks settle, perform a current-head maintainer review before considering approval or enqueue.

@matthewevans matthewevans removed their assignment Aug 3, 2026
lgray added 5 commits August 2, 2026 20:17
…he zero-count arm

Two CodeRabbit findings, plus the class sweep the first one implies.

The stamper's staging was not atomic. `mktemp -t` resolves to /tmp (device 50 here)
while the fixture directory is on /home (device 47), so `mv "$TMP" "$FIX"` degraded
to copy-then-unlink. This script rewrites its fixtures IN PLACE and those fixtures
are TRACKED, so an interrupted run truncates a committed fixture — a worse failure
mode than the migrate script's stray temp file. Staging now happens beside the
destination, and stage paths are registered with a trap on EXIT/INT/TERM.

This is the same defect, and the same fix, as `migrate-dump-fixture.sh` already
carries. That one was fixed and its sibling was not: one recipe, two call sites, one
of them missed. The new `stage_locality_control` pre-flight arm compares stage and
destination DIRECTORIES rather than devices, because device equality does not
discriminate — under a `-t` revert the stage lands in /tmp and a test destination
under /tmp shares its device. Revert-probed: reverting `stage_beside` to `mktemp -t`
reports STAGE_BESIDE_DEST=false ("stage /tmp vs dest /tmp/tmp.KQJVvxNaF5") and the
script exits 1.

CLASS SWEEP over scripts/ for the recipe "mktemp into system tmp, then mv to a
destination outside it":
  grep -rn "mktemp" scripts/ --include=*.sh
  grep -rn "\bmv " scripts/ --include=*.sh
Every other mv-into-place stages in the destination's own directory
(deploy-cf.sh:67, fetch-comp-rules.sh:47, gen-card-data.sh:248/304,
lib/mtgjson-fetch.sh:70, lib/scryfall-fetch.sh:77/131) or never moves at all
(coverage-history.sh:110, tilt-wait.sh:104). One further instance exists —
suno-generate/generate.sh:82 — and is deliberately NOT touched here: it is an
unrelated media-tooling subsystem and its destination (.state.json) is gitignored,
so the failure mode is losing local generator state, not corrupting a tracked file.

The Fixed(0) reject arm had no test. The suite covered Fixed(4)/Fixed(11) bounded
and Fixed(4) unbounded, so deleting the arm left every test green. Added a row with
a reach-guard asserting the schema reads bounded, since the arm sits below the
unbounded `na()` branch and would otherwise measure that instead. Revert-probed:
deleting the arm makes the row FAIL.

The AI change is a test row only — no scoring arm moved, no arm order changed, so
`cargo ai-gate` carries no new obligation.

Assisted-by: ClaudeCode:claude-opus-5
… helper

Review found the locality control in both fixture scripts tests the HELPER and
not the CALL SITE, so it cannot see the defect it exists to catch. Measured:
revert ONLY `TMP="$(stage_beside "$FIX")"` to `mktemp -t`, leaving `stage_beside`
and `stage_locality_control` untouched, and the control still prints
STAGE_BESIDE_DEST=true while the run writes with its stage in /tmp — the original
non-atomic write, fully restored, past a green control. The sibling has the same
shape: `migrate-dump-fixture.sh`'s a0 self-test proves `stage_path` RETURNS a
beside-destination path and says nothing about whether `regenerate` still calls it.

Both call sites now compare `dirname` of stage and destination before writing and
fail closed. Fixed at every site rather than the one that was reported: one recipe
with two call sites was how the first defect survived, and repeating that would
leave the maintainer asking why only half the class moved.

REVERT-PROBES, each reverting ONLY the call-site binding with the helper intact:

  stamp-fixture-firing.sh, on a real tracked fixture (dina_conqueror_4p):
    guards present  rc=0 ctrl_true=1 guard=0 debris=0, fixture written
    call reverted   rc=1 ctrl_true=1 guard=1 debris=0, fixture UNCHANGED
      "stage not beside destination: /tmp/stamp-firing-vwHw0q.json.gz
       vs /tmp/tmp.VtIWnOZvTQ/dina_conqueror_4p.json.gz"

  migrate-dump-fixture.sh:
    guards present  rc=0 guard=0, SELFTEST ATOMIC_ON_FAILURE=true ENVELOPE_PRESERVED=true
    call reverted   rc=1 guard=1, SELFTEST still ATOMIC_ON_FAILURE=true

`ctrl_true=1` and the unchanged SELFTEST line in the reverted arms are the finding:
the helper-level checks stay green across the defect. The guards are what move.

Comparison is by directory, not device, for the reason the helper control already
documents — under a `-t` revert both paths sit under /tmp and share a device.

Assisted-by: ClaudeCode:claude-opus-5
…lper

Two seams the phase-rs#6933 fold analysis flagged that the merge resolution left open.
Both are additive here; nothing in the merge is rewritten.

1. CR 732.2a bound guard, second ingress. phase-rs#6933 split the decode surface: the
   free `decode_persisted_resolution_state` became a delegator, and the two
   authorities are now `GameStateDecode::decode_persisted_resolution_state` and
   `GameStateDecode::decode`. They are genuinely separate — the former
   deserializes `ResolutionStateWire` itself and never calls the latter — and
   `impl Deserialize for GameState` routes through `decode` with
   `GameStateDecodeMode::DirectCurrentRaw`. With the guard on one site only, a
   persisted `LoopShortcut` offer whose wire bound is 0 was refused through the
   persisted path and revived through the bare-`GameState` path.

   A discriminating row lands with it (see loop_shortcut.rs): bare-`GameState`
   decode of `with_bound(0)` must `Err`, with a `with_bound(5)` control and a
   reach-guard that the UNMUTATED fixture decodes bare at all — `DirectCurrentRaw`
   skips the legacy migrations, so without that guard an `Err` would not be
   attributable to the bound. REVERT-PROBE: delete only the new call in `decode`
   and every pre-existing arm still passes while that row flips to `Ok`.

2. `delayed_trigger_payload_matches` is dead. It is base code from the v1
   provenance backfill that phase-rs#6933 replaced; upstream deleted the definition AND
   its only caller together, and this branch adds no caller of its own
   (`git diff 9b97019..ea1b0ac -- game_state.rs` contains no added reference).
   Kept, it is a private fn with zero callers under `-D warnings`. Post-removal
   grep for the name returns 0 hits repo-wide.

Also documents, at the `bind_resolution_scope` caller in stack.rs, why the
CR 603.4 settlement must stay at the caller and never move into the helper:
`analysis/resource.rs` calls that helper on CLONED PROBE BOARDS at five sites,
where running terminal delayed-trigger disposition would mutate lifecycle state
for a board that is only being measured. Comment only; the code is unchanged.

ON THE CR ANNOTATION AT THAT BLOCK, since it is easy to read as drift: the
trigger-event-context block keeps `CR 608.2k`, not the `CR 603.7c` inherited from
before this branch. CR 608.2k — "If an ability's effect refers to a specific
untargeted object that has been previously referred to by that ability's cost or
trigger condition, it still affects that object even if the object has changed
characteristics" — is what the block implements: it stores `current_trigger_event`
so `TriggeringSpellController`/`TriggeringSource` resolve at resolution time.
CR 603.7c is scoped to delayed triggered abilities and zone changes and does not
describe this code. `resolution_prompt.rs` already cross-references CR 608.2k for
this helper.

Assisted-by: ClaudeCode:claude-opus-5
…ted payload

Two independent changes to the same file, both consequences of phase-rs#6933.

1. The second-ingress row for the CR 732.2a bound guard added in the previous
   commit. Arms: bare-`GameState` decode of `with_bound(0)` must `Err` naming
   `max_iterations 0`; `with_bound(5)` must load; and a REACH-GUARD that the
   unmutated fixture decodes bare at all, because `DirectCurrentRaw` skips the
   legacy migrations and an `Err` from a fixture that cannot decode at all would
   say nothing about the bound. The pre-existing arms above it cannot stand in
   for this one: they reach `decode_persisted_resolution_state`, and a revert of
   the guard call in `decode` ALONE leaves every one of them green.

   The orphaned `PersistedGameState` import goes with it — the branch's loader
   uses the fully-qualified path, so the merge left the short import unused
   (`-D warnings`).

2. r28_c's TRUSTED arm hand-built `{"state": <bare GameState>}`. phase-rs#6933 made
   `resolution_state_version` a required discriminator and gave only the
   PersistedRaw ingress permission to stamp v1 onto a legacy payload; the
   TrustedEnvelope ingress deliberately stamps nothing, because a trusted
   snapshot is WRITTEN as a versioned envelope and must retain its declared
   compatibility mode. `GameState`'s derived `Serialize` emits no such field, so
   the hand-built value was a shape production never writes and the trusted path
   was right to refuse it. Measured before the fix:
   `Error("resolution state wire is missing a numeric resolution_state_version")`.

   Fixed at the BUILDER, not the assertion: the payload is now constructed
   through `ResolutionStateWire::from_game_state`, which is exactly what
   `TrustedGameStateEnvelope`'s own `Serialize` does, so the arm round-trips the
   shape production writes. No assertion was weakened.

   The decode `.expect` also becomes a labelled panic. The row drives four arms
   (hostile x trusted) and the original message carried no `{label}`, so the
   failure above could not say which arm broke — it had to be re-derived by hand.

Assisted-by: ClaudeCode:claude-opus-5
…er controls

Arms 5 and 6 asserted against `{"Delayed":null}`, a wire shape phase-rs#6933 removed.
The live `TriggerFiring` variants (`types/identifiers.rs`) are `"Ordinary"`,
`"LegacyDelayed"`, `{"ReceiptEligible":{token,instance,source_id}}` and
`"UnknownLegacy"`.

Those arms stayed GREEN across the removal, which is the point worth recording:
jq is untyped, so the controls fed themselves a shape the engine can no longer
emit and got it back unchanged. A control that passes on input its subject cannot
produce is not evidence about the subject. The arms were not wrong — they had
stopped being about the engine.

Adds a `ReceiptEligible` sub-arm alongside the unit-variant one. That variant
carries a payload, and a preservation rule written against the unit variants
alone could drop its `DelayedTriggerOrigin` and still satisfy every other arm —
so this asserts the VALUE survives, not just the discriminant.

Measured after the change: DEFINITION_SHAPES, CARRIER_PRESERVED (with
`receipt={"ReceiptEligible":{"token":3,"instance":4,"source_id":7}}`),
STALE_CARRIER_PRUNED, NON_GAMESTATE_SKIPPED and STAGE_BESIDE_DEST all true;
12 fixtures, 0 arm failures.

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

lgray commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Acknowledged — holding, no push. Thank you for the merge and the restore; for what it is worth, d1a5270a4's restored files are byte-identical to the fold we had computed independently on this side for 4 of the 5, which is a strong cross-check of both resolutions.

Two heads-ups on d1a5270a4 so the queued CI cycle is not a surprise, both measured at that head:

  1. Rust lint will fail: dead private fn. delayed_trigger_payload_matches (types/game_state.rs:8693) now has exactly one occurrence repo-wide — its definition, no #[allow(dead_code)]. Its only caller was base code that ship/canonicalize delayed trigger lifecycle #6933 removed (correctly); the restore rightly did not touch game_state.rs, so the orphan persists. git grep -c delayed_trigger_payload_matches d1a5270a4 -- crates/engine/ → 1.

  2. CR 732.2a guard is one-sided after the port. reject_zero_bound_shortcut_offer is called at :14963 inside GameStateDecode::decode_persisted_resolution_state, but not in GameStateDecode::decode — and impl Deserialize for GameState routes through the latter, so a zero-bound LoopShortcut offer revives unguarded on the bare-GameState path. One added call plus a discriminating test row closes it.

We hold five small additive commits ready on top of d1a5270a4, verification gate in flight: the two items above; a replay of the two CodeRabbit fixes from the superseded head (stamper cross-filesystem atomicity + call-site stage-locality guards, and the Fixed(0) policy test row); the stamper self-test vocabulary updated from the retired {"Delayed":null} wire shape to "LegacyDelayed"/"ReceiptEligible"; and a one-line fix to r28_c, whose hand-built trusted payload predates #6933's resolution_state_version discriminator (now built via ResolutionStateWire::from_game_state).

Your call on timing: pushing supersedes this head's CI cycle and parse-diff artifact, so we will not push until you say — either now, or after your current-head review. Defaulting to holding.

@lgray

lgray commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Noting your hold, and that these five commits went up on top of it at the repository author's
direction: the queued cycle now runs against a head with the two flagged defects closed rather than
one carrying them, and the parse-diff artifact regenerates for this head.

Thank you for landing the #6933 fold and the follow-up restore. That was the blocking piece and it
would have been a long round-trip from this side.

One thing worth recording because it is a genuine cross-check rather than a courtesy: an independent
rebase-fold of the same 49 commits onto 409956671, done here before your merge landed, produced
byte-identical results to your d1a5270a4 for analysis/resource.rs, game/engine.rs,
kilo_live_offer_from_real_dump.rs and sprout_inalla_realistic_offer.rs. loop_shortcut.rs
differs only by the two additions described below, and stack.rs only by an explanatory comment.
Two resolutions of the same conflicts converging on the same bytes is stronger evidence than either
one alone.

The five commits, in order

commit what why
01ed26cbb stamper staging atomicity + a Fixed(0) reject-arm test row the two CodeRabbit items from the 00:28Z review; #6933 touches neither path
1a3b472ad stage-locality guards at the call sites, both fixture scripts the existing control tested the helper, not the call site
5c1106939 guard GameStateDecode::decode; drop delayed_trigger_payload_matches two seams on the merged head — detail below
79d4f0a55 bare-ingress test row; r28_c wire-version builder repair the discriminator for the guard above, plus one #6933 consequence
596bdfaa9 repin the stamper's control vocabulary to the live wire shapes {"Delayed":null} no longer exists — detail below

Two seams on d1a5270a4

Both additive; neither rewrites your resolution of stack.rs or types/game_state.rs.

The CR 732.2a bound guard is one-sided. reject_zero_bound_shortcut_offer appears once, at
game_state.rs:14963, inside GameStateDecode::decode_persisted_resolution_state.
GameStateDecode::decode has normalize_delayed_trigger_allocators and
validate_trigger_firing_coherence but not the bound check, and impl Deserialize for GameState
routes through decode with GameStateDecodeMode::DirectCurrentRaw. #6933 split what used to be
one function into two authorities that do not call each other, so a guard that was single-sited
before now covers only half the surface: a persisted LoopShortcut offer whose wire bound is 0 is
refused through the persisted path and revived through the bare-GameState path.

The row that pins it is new, because no existing row could see this: every pre-existing arm reaches
decode_persisted_resolution_state, so deleting the decode call alone leaves all of them green.
The new arm decodes bare, expects Err, and carries a reach-guard that the unmutated fixture
decodes bare at all — DirectCurrentRaw skips the legacy migrations, so without that guard an Err
would not be attributable to the bound.

delayed_trigger_payload_matches is dead. It is base code from the v1 provenance backfill
#6933 replaced. Upstream deleted the definition and its only caller together; this branch never
added a caller (git diff 9b970193b..ea1b0ac19 -- crates/engine/src/types/game_state.rs contains no
added reference). On the merged head it is a private fn with zero callers and no allow attribute,
which -D warnings will reject. Removed; the name returns 0 hits repo-wide afterwards.

r28_c — fixed at the builder

r28_c_a_restored_proposal_with_a_foreign_template_owner_is_refused_at_consumption failed with
Error("resolution state wire is missing a numeric resolution_state_version"). Its TRUSTED arm
hand-built {"state": <bare GameState>}. #6933 made the discriminator required and gave only the
PersistedRaw ingress permission to stamp v1; TrustedEnvelope deliberately stamps nothing, since
a trusted snapshot is written as a versioned envelope and must keep its declared mode.
GameState's derived Serialize emits no such field, so that payload was a shape production never
writes and the trusted path was right to refuse it.

Repaired at the builder, not the assertion: the arm now constructs through
ResolutionStateWire::from_game_state, which is what TrustedGameStateEnvelope's own Serialize
does. No assertion weakened. The decode expect also gained the arm label — the row drives four
arms and the message named none of them.

The #6933 fold: two disclosures

Five fixtures normalize on restore, and no test row moved.
normalize_legacy_completed_resolution_carrier nulls resolving_stack_entry /
resolving_trigger_firing / resolution_source_relatch when waiting_for is Priority, the
resolution stack is empty, and there is no pending completion. Measured across the corpus:

fixture waiting_for entry firing normalized
dina_conqueror_4p Priority set Ordinary yes — entry + stamped firing
witherbloom_sprout_lumaret_4p Priority set Ordinary yes — entry + stamped firing
witherbloom_sprout_lumaret_simple_4p Priority set Ordinary yes — entry + stamped firing
kilo_freed_relic_pentad_4p Priority set null yes — entry only
sprout_witherbloom_realistic_lands_4p Priority set null yes — entry only
dellian_emblem_conqueror_4p TriggerTargetSelection set Ordinary no
fantastic_four_bounded_loop_4p TriggerTargetSelection set Ordinary no
tenacity_exquisite_blood_4p LoopShortcut set Ordinary no

No fixture was re-migrated and no expectation was rewritten, because the normalization is correct
and nothing depended on the old state: CR 117.3b — "The active player receives priority after a
spell or ability (other than a mana ability) resolves" — means a dump AT Priority with an empty
resolution stack has no resolution in progress, so the carrier there is leftover rather than live.
A v2 envelope would have preserved a state the rules say cannot be current. The full integration
suite confirms zero rows moved on account of this.

The stamping scripts are now redundant for v1 loadability, and that is your call to make.
#6933's auto-stamp assigns a blanket LegacyDelayed to unlabeled v1 carriers at load, which is
strictly sufficient to make the corpus load. The scripts derive per-record classification from
trigger_definitions instead, so they emit Ordinary where the engine-side default emits
LegacyDelayed. Both are defensible: a measured classification versus an honest fail-safe. The
committed stamps are kept because they still do work the engine-side migration does not — the
bare-GameState (DirectCurrentRaw) decode arms skip all migrations and read what is on disk,
and future captures still need stamping — but if you would rather drop the script apparatus and
rely on your engine-side migration, say so and it comes out. Your subsystem, your call; we are not
attached to the scripts.

Related: the stamper's own controls had been asserting against {"Delayed":null}, a wire shape
#6933 removed. They stayed green through the removal because jq is untyped and they were feeding
themselves the dead shape. Repinned to "Ordinary" / "LegacyDelayed" /
{"ReceiptEligible":{…}}, with a new sub-arm proving the payload-carrying variant keeps its
payload rather than only its discriminant.

Verification

A local gate at this tip is mid-run. Stated as it actually stands rather than as a finished table:
cargo fmt --all --check rc=0 and the parser combinator gate rc=0; clippy --workspace --all-targets -D warnings, phase-ai, engine --lib and --test integration, and the 12-fixture
stamper corpus are still running and are reported here when they land, green or not.

What is already measured at this content: cargo check --workspace --all-targets clean;
r28_c passing, having failed before the builder repair; the stamper's five control arms green
across 12 fixtures with zero stage debris and a clean tracked tree. Your CI on this head is the
authority regardless, and it now runs against the two closed seams.

…fold

The census pins each producer by `file:line`, and folding upstream phase-rs#6933 grew
engine.rs around one of them: `game/engine.rs:10640 => :11427`, +787, while the
file's whole-file delta over the same range is +1134 — so 787 lands above this
producer and 347 below, which is what a file growing AROUND a mint looks like
rather than one gaining a mint.

Identity re-established at the new coordinate, not assumed. The line at :11427 is
byte-identical by sha256 to `ea1b0ac19:game/engine.rs:10640`, and it is still
inside `begin_pending_trigger_target_selection` (fn opens at :11278) — the
producer this row names. The old coordinate now holds copy-target-slot code that
mints nothing. The other four entries did not move at all, which is the same
set-preservation evidence the earlier rebases in this comment relied on: a census
that had gained or lost a producer could not leave four entries byte-identical
AND in place.

Nothing is weakened. The total stays 37 and the partition stays 5/7/25; only the
coordinate of an unchanged producer is updated. This is the failure mode the row
is DESIGNED to have — it is line-pinned precisely so that a moved or added mint
cannot pass silently — so the red was the instrument working, not a defect in it.

Surfaced by `cargo test -p phase-engine --lib` at 596bdfa; every other gate
stage at that tip was green (fmt, parser gate, clippy -D warnings, phase-ai,
integration, fixture corpus).

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

lgray commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

🤖 AI text below 🤖

Completing the verification table the previous comment left open, including the one row that came
back red.

stage result
cargo fmt --all --check rc=0
parser combinator gate rc=0
clippy --workspace --all-targets -D warnings rc=0
cargo test -p phase-ai rc=0
cargo test -p phase-engine --lib rc=101 — 1 failed → fixed, now rc=0, 18398 passed
cargo test -p phase-engine --test integration rc=0
stamper corpus, 12 fixtures rc=0 — 5/5 control arms, 0 arm failures, 0 stage debris

The red, and it was our own instrument rather than anything about the fold.
the_cr_603_5_prompt_census_is_pinned_so_a_sixth_producer_is_a_counted_event pins each CR 603.5
prompt producer by file:line. Folding #6933 grew game/engine.rs around one of them:
:10640 ⇒ :11427, while the file's whole-file delta over the same range is +1134 — so 787 lines
land above that producer and 347 below, which is what a file growing around a mint looks like
rather than one gaining a mint.

Identity was re-established at the new coordinate rather than assumed: the line there is
byte-identical by sha256 to the same producer before the fold, it is still inside
begin_pending_trigger_target_selection, and the old coordinate now holds copy-target-slot code
that mints nothing. The other four entries did not move at all. Total stays 37 and the partition
stays 5/7/25 — nothing weakened, only a coordinate updated.

That is the failure mode this row is designed to have: it is line-pinned precisely so a moved or
added mint cannot pass silently. The re-pin follows the same pattern as the existing #6842 and
#6851 entries in that comment block, each of which records the shift, its cause, and the
byte-identity re-check.

Fix: 6face1fdd, on top of the five commits already pushed. Worth stating plainly that the earlier
push carried this failing row — the fix landed within minutes of the gate surfacing it, but the
head was briefly red on that one test and your CI may have caught it first.

@matthewevans matthewevans self-assigned this Aug 3, 2026
@matthewevans

Copy link
Copy Markdown
Member

Maintainer hold — current head 6face1fddf66c3ce8af1c02fa29d54e87222b0b8 is awaiting current-head evidence before the implementation review resumes.

The current CI rollup still has Rust test shards 1/2 and 2/2 plus the paired-seed and decision-cost AI gates in progress. The sole <!-- coverage-parse-diff --> artifact is for 596bdfaa91eac0bad55751ce8520e7eb05abd5d6, not this head. This is a broad engine/AI change, so neither the prior hold nor an earlier artifact can substitute for the current ref.

Next step: let those checks settle and regenerate/confirm the parse-diff for this exact head; then a fresh implementation and external-review finding pass can determine whether any changes are still required. No closure or enqueue is implied by this hold.

@matthewevans

Copy link
Copy Markdown
Member

Maintainer hold — current head 6face1fddf66c3ce8af1c02fa29d54e87222b0b8 has its required CI and current-head no-change parse artifact, but is not yet ready to approve or enqueue.

The full implementation spans hot engine/AI paths and a broad lifecycle port. Its PR evidence still records Gate A and final review-impl against earlier heads (dd986e4… / dbc8182…), and CodeRabbit has no review bound to this head. The prior inline findings I rechecked are addressed here: the PeriodicDelta maps now use the map_key_pairs wire adapter and the production persistence test is populated; the bounded zero-count policy arm is present with a direct test; and the probe call sites bind the cloned resolution board before probing. Those facts do not substitute for the required fresh adversarial review of the final combined head.

Next step: complete and record a clean current-head Gate A/final implementation review (including the broad loop-shortcut owner, serialization, prompt, and bounded-offer paths). This is a hold only; it neither closes nor rejects the PR.

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

Copy link
Copy Markdown
Member

Maintainer hold — current head a62782802870cc33b03db11ffa8b7e548078b921 is awaiting current-head evidence before final disposition.

Required Rust tests shard 2/2 is still in progress. The sole <!-- coverage-parse-diff --> artifact remains bound to 6face1fddf66c3ce8af1c02fa29d54e87222b0b8, not this merged head. The broad engine/AI change also still needs its recorded final current-head Gate A/adversarial review-impl pass after those prerequisites settle. I rechecked the earlier inline concerns: populated PeriodicDelta persistence uses map_key_pairs; the zero-count policy guard has a direct test; and probe callers bind the cloned resolution board before probing.

@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 loop-shortcut policy still classifies an unbounded Fixed(0) declaration as neutral instead of rejecting the no-op.

🟡 Must address

crates/phase-ai/src/policies/loop_shortcut.rs:218 matches every Fixed(_) declaration on an unbounded schema as na() before the zero-count rejection at :233-248. Thus Fixed(0) on an unbounded offer never reaches the rejection, despite the rationale at :233-245 applying equally: it commits no cycles while spending the response window. The existing regression at :574-585 deliberately asserts a bounded schema, so it cannot detect this ordering error.

Move the Fixed(0) arm before the unbounded arm and add a companion unbounded-schema assertion that expects the same reject verdict. This is the current form of CodeRabbit's still-open finding at discussion_r3699911189; the rest of that review's concrete concerns are addressed on this head.

✅ Rechecked

The current a62782802870cc33b03db11ffa8b7e548078b921 parse artifact reports no parser changes, and current required CI is green. PeriodicDelta now uses the wire-safe map adapter with a populated persistence-boundary test (crates/engine/src/analysis/resource.rs:12649-12710), while production probe callers bind their cloned resolution boards before querying (resource.rs:1711-1718, 4342-4350).

Recommendation: reorder the policy arms, add the unbounded zero-count regression, then request a fresh review.

@matthewevans matthewevans removed their assignment Aug 3, 2026
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)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants