feat(engine): loop-shortcut bounded-offer core, pin injection, owner firewall, and measured 4p rows (combo-fb phases 5a-5d, chain 3) - #6886
Conversation
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThe 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. ChangesBounded loop shortcuts and probing
Choice eligibility
Fixture tooling and client labels
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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 winUse
choosable_opponentsin the fallback.When fewer than two candidates exist,
resolve_chooserusesplayers::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 winGuard the install-command probe against non-object
commandvalues.
select(.command.DelayedTriggerInstall)indexes.commandwith a key. Serde serializes an externally tagged unit variant as a bare JSON string. If any journal entry carries such a command, jq aborts withCannot 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 winArm 2 compares aggregate carrier sums, so two opposite errors can cancel.
NEEDcollapses the pending, stack, and resolving carrier counts into one integer.GOTcollapses 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.jqcannot produce that combination, because_firingaborts 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 stalestack_trigger_firingsentry.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_countinscripts/lib/trigger-firing.jqcan expose the per-class object and keep the scalar astrigger_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_beatduplicatesdump_drive_one_beatfrom the same module.
dump_drive_one_beatis defined at the top of thismod testsand is in scope here. The nesteddrive_one_beatis the same policy with the same body: pass atPriority, otherwise take the first legal non-terminal action, excludingConcedeandDebug.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 | 🔵 TrivialConsider emitting a metric when the probe budget denies a charge.
PROBE_BUDGETis 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 —MintMetercarriesdenied, and nothing surfaces it outside tests.A counter or structured log on
denied() == truewould 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 winState and enforce the
frames_per_period >= 1invariant.
frames_per_perioddelimits a committed cycle ingame::engine::drive_one_shortcut_cycleand, per the doc above, supplies the per-period magnitude the CR 704 count bound divides by. A value of0is meaningless for both roles: as a delimiter it commits a cycle after no frames, as a divisor it is a panic.
PeriodicDeltaderivesDefault, soframes_per_period: 0is constructible in-crate today. Both current producers yield at least 1 (ring_delta_signaturesearcheskfrom1, 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
0as 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_samplenow 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(vianormalize_for_loop, which itself starts withself.clone()) andlive(via the newloop_detect_live_sample, which also doesself.clone()). Both clones are rooted in the same unmodifiedself, so this is correct, but it doubles the clone cost ofGameStateon 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 32GameState-equivalent payloads instead of 16.Many
GameStatefields are plainVec/HashMap/HashSet(notim-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 tradeoffKey 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, andthe_period_touch_window_is_carried_by_the_live_half.The first pins exact
file:linecoordinates 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_extentto 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 winAdd the
n == max_iterationsboundary case.The row tests
Fixed(4)andFixed(11)againstmax_iterations == 10. It never testsFixed(10)— the exact countai_support::candidatesemits 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 rowai_bounded_declare_candidate_is_generated_legal_and_drivescovers 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
⛔ Files ignored due to path filters (6)
crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/dina_conqueror_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/tenacity_exquisite_blood_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gzis excluded by!**/*.gz
📒 Files selected for processing (48)
crates/engine/src/ai_support/candidates.rscrates/engine/src/analysis/corpus_tests.rscrates/engine/src/analysis/decision_template.rscrates/engine/src/analysis/loop_check.rscrates/engine/src/analysis/resource.rscrates/engine/src/game/ability_scan.rscrates/engine/src/game/ability_utils.rscrates/engine/src/game/casting.rscrates/engine/src/game/casting_costs.rscrates/engine/src/game/casting_tests.rscrates/engine/src/game/effects/choose.rscrates/engine/src/game/effects/choose_from_zone.rscrates/engine/src/game/effects/clash.rscrates/engine/src/game/effects/mod.rscrates/engine/src/game/effects/proliferate.rscrates/engine/src/game/effects/separate_piles.rscrates/engine/src/game/effects/token.rscrates/engine/src/game/engine.rscrates/engine/src/game/filter.rscrates/engine/src/game/interaction.rscrates/engine/src/game/mod.rscrates/engine/src/game/phasing.rscrates/engine/src/game/players.rscrates/engine/src/game/replacement.rscrates/engine/src/game/resolution_prompt.rscrates/engine/src/game/sba.rscrates/engine/src/game/stack.rscrates/engine/src/game/targeting.rscrates/engine/src/game/zone_pipeline.rscrates/engine/src/types/game_state.rscrates/engine/src/types/mod.rscrates/engine/tests/integration/fantastic_four_bounded_loop.rscrates/engine/tests/integration/gift_recipient_phased_out_opponent.rscrates/engine/tests/integration/interaction_contract.rscrates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rscrates/engine/tests/integration/kilo_live_offer_from_real_dump.rscrates/engine/tests/integration/loop_shortcut.rscrates/engine/tests/integration/loop_shortcut_mana_engine.rscrates/engine/tests/integration/loop_shortcut_offer_writer_census.rscrates/engine/tests/integration/main.rscrates/engine/tests/integration/rules/battle.rscrates/engine/tests/integration/sprout_inalla_realistic_offer.rscrates/phase-ai/src/policies/loop_shortcut.rscrates/phase-ai/src/projection.rscrates/phase-ai/src/search.rsscripts/lib/trigger-firing.jqscripts/migrate-dump-fixture.shscripts/stamp-fixture-firing.sh
| 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] | ||
| } |
There was a problem hiding this comment.
🩺 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
framesis shorter,self.frames[f.0]panics on an index out of bounds. - If B's
framesis 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.
| 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() | ||
| }) | ||
| } |
There was a problem hiding this comment.
🎯 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.
| 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.
|
Generated for head Parse changes introduced by this PR✓ No card-parse changes detected. |
matthewevans
left a comment
There was a problem hiding this comment.
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.
…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
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
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
…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
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
dd986e4 to
34f5823
Compare
|
🤖 AI text below 🤖 Thanks — all six findings are addressed, each with a discriminating test whose revert-probe was The failing Rust verificationThe census row Findings → commits
Plus
Trigger-firing evidence. Confirmed on the committed fixtures before changing anything: of the On the fixture control you asked for: I did not add one, and I want to be straight about why rather Bounded Fixture migration. Writes now stage to a temp file and Empty events. Made a first-class refusal in every build rather than a louder assert — a Two CI jobs I could not fix, with the measurementThe Paired-seed AI gate and Decision-cost perf gate were cancelled, and this is CodeRabbitThirteen of the seventeen inline comments are fixed in the commits above (several coincide with Four are declined as follow-ups rather than landed in this round, each with its measurement in the One note on the base: this is rebased onto Local gate, run in full at
The tip Every commit in the range also compiles on its own ( Re-requesting review. |
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (2)
crates/engine/src/game/resolution_prompt.rs (1)
53-64: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueMake the
joinmatch wildcard-free, like the rest of this module.Line 62 uses
_. The rest of this file enforces classification at compile time (the wildcard-freeEffectmatch, the..-freeResolvedAbilitydestructure). A future thirdResolutionChoiceFreedomvariant would silently collapse intoMayPrompthere 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 winAdd 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_boundasserts onlyFixed(4)andFixed(11), andloop_shortcut_unbounded_offer_keeps_fixed_neutralasserts onlyFixed(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
⛔ Files ignored due to path filters (6)
crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/dina_conqueror_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/tenacity_exquisite_blood_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gzis excluded by!**/*.gz
📒 Files selected for processing (48)
crates/engine/src/ai_support/candidates.rscrates/engine/src/analysis/corpus_tests.rscrates/engine/src/analysis/decision_template.rscrates/engine/src/analysis/loop_check.rscrates/engine/src/analysis/resource.rscrates/engine/src/game/ability_scan.rscrates/engine/src/game/ability_utils.rscrates/engine/src/game/casting.rscrates/engine/src/game/casting_costs.rscrates/engine/src/game/casting_tests.rscrates/engine/src/game/effects/choose.rscrates/engine/src/game/effects/choose_from_zone.rscrates/engine/src/game/effects/clash.rscrates/engine/src/game/effects/mod.rscrates/engine/src/game/effects/proliferate.rscrates/engine/src/game/effects/separate_piles.rscrates/engine/src/game/effects/token.rscrates/engine/src/game/engine.rscrates/engine/src/game/filter.rscrates/engine/src/game/interaction.rscrates/engine/src/game/mod.rscrates/engine/src/game/phasing.rscrates/engine/src/game/players.rscrates/engine/src/game/replacement.rscrates/engine/src/game/resolution_prompt.rscrates/engine/src/game/sba.rscrates/engine/src/game/stack.rscrates/engine/src/game/targeting.rscrates/engine/src/game/zone_pipeline.rscrates/engine/src/types/game_state.rscrates/engine/src/types/mod.rscrates/engine/tests/integration/fantastic_four_bounded_loop.rscrates/engine/tests/integration/gift_recipient_phased_out_opponent.rscrates/engine/tests/integration/interaction_contract.rscrates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rscrates/engine/tests/integration/kilo_live_offer_from_real_dump.rscrates/engine/tests/integration/loop_shortcut.rscrates/engine/tests/integration/loop_shortcut_mana_engine.rscrates/engine/tests/integration/loop_shortcut_offer_writer_census.rscrates/engine/tests/integration/main.rscrates/engine/tests/integration/rules/battle.rscrates/engine/tests/integration/sprout_inalla_realistic_offer.rscrates/phase-ai/src/policies/loop_shortcut.rscrates/phase-ai/src/projection.rscrates/phase-ai/src/search.rsscripts/lib/trigger-firing.jqscripts/migrate-dump-fixture.shscripts/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
matthewevans
left a comment
There was a problem hiding this comment.
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.
…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
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
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
…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
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
34f5823 to
94bb153
Compare
|
🤖 AI text below 🤖 All three findings are addressed, and probing one of them turned up a fourth defect you did not Findings → commits
HIGH —
|
| 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 # -> 30That 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.
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (6)
crates/engine/tests/fixtures/dellian_emblem_conqueror_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/dina_conqueror_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/tenacity_exquisite_blood_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gzis excluded by!**/*.gzcrates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gzis excluded by!**/*.gz
📒 Files selected for processing (48)
crates/engine/src/ai_support/candidates.rscrates/engine/src/analysis/corpus_tests.rscrates/engine/src/analysis/decision_template.rscrates/engine/src/analysis/loop_check.rscrates/engine/src/analysis/resource.rscrates/engine/src/game/ability_scan.rscrates/engine/src/game/ability_utils.rscrates/engine/src/game/casting.rscrates/engine/src/game/casting_costs.rscrates/engine/src/game/casting_tests.rscrates/engine/src/game/effects/choose.rscrates/engine/src/game/effects/choose_from_zone.rscrates/engine/src/game/effects/clash.rscrates/engine/src/game/effects/mod.rscrates/engine/src/game/effects/proliferate.rscrates/engine/src/game/effects/separate_piles.rscrates/engine/src/game/effects/token.rscrates/engine/src/game/engine.rscrates/engine/src/game/filter.rscrates/engine/src/game/interaction.rscrates/engine/src/game/mod.rscrates/engine/src/game/phasing.rscrates/engine/src/game/players.rscrates/engine/src/game/replacement.rscrates/engine/src/game/resolution_prompt.rscrates/engine/src/game/sba.rscrates/engine/src/game/stack.rscrates/engine/src/game/targeting.rscrates/engine/src/game/zone_pipeline.rscrates/engine/src/types/game_state.rscrates/engine/src/types/mod.rscrates/engine/tests/integration/fantastic_four_bounded_loop.rscrates/engine/tests/integration/gift_recipient_phased_out_opponent.rscrates/engine/tests/integration/interaction_contract.rscrates/engine/tests/integration/issue_3867_volcanic_offering_opponent_choice.rscrates/engine/tests/integration/kilo_live_offer_from_real_dump.rscrates/engine/tests/integration/loop_shortcut.rscrates/engine/tests/integration/loop_shortcut_mana_engine.rscrates/engine/tests/integration/loop_shortcut_offer_writer_census.rscrates/engine/tests/integration/main.rscrates/engine/tests/integration/rules/battle.rscrates/engine/tests/integration/sprout_inalla_realistic_offer.rscrates/phase-ai/src/policies/loop_shortcut.rscrates/phase-ai/src/projection.rscrates/phase-ai/src/search.rsscripts/lib/trigger-firing.jqscripts/migrate-dump-fixture.shscripts/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
matthewevans
left a comment
There was a problem hiding this comment.
[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.
PRODUCTION STAYED AT 22. That is the half this pin exists to protect: the assert's own text says a new production site in a certification-path file halts the U-series, while a new READ site is the benign case to adjudicate. Both new sites are in `phase-ai/src/policies/loop_shortcut.rs`'s `#[cfg(test)]` module and belong to the proposer-elimination rows: `bounded_offer_with_period`, a builder minting an offer whose certificate carries a real `per_cycle` so the new arm can be driven at all, and `certificate_of`, a read accessor for those rows. The policy arm itself READS the certificate and writes no offer, which is why the production count is unmoved. Named rather than renumbered, per the assert's instruction to name the new site instead of only moving the number. Assisted-by: ClaudeCode:claude-opus-5
|
🤖 AI text below 🤖 Two fixes, one of them a real defect this PR introduced, plus the recorded current-head review your 1. The AI could declare a loop that decks itself (introduced by this PR)The bigger of the two, and it is ours.
Net: a self-mill period whose binding seat is the proposer scored critical for running the Fix, at the policy (
Each rate is guarded The 2.
|
| matchup | baseline ms/game | current ms/game | p0/p1 wins | draws | avg turns |
|---|---|---|---|---|---|
| red-mirror | 6,829 | 297,297 | 4/6 → 4/6 | 0 → 0 | 13.9 → 13.9 |
| affinity-mirror | 12,748 | 610,734 | 4/4 → 4/3 | 2 → 3 | 11.4 → 10.2 |
| enchantress-mirror | 25,299 | 520,256 | 4/6 → 1/1 | 0 → 8 | 14.6 → 11.8 |
The enchantress "10% mirror imbalance" is 1 win / 1 win / 8 draws.
The mechanism looks like truncation, not decision change. A veto lengthens games — a refused
loop shortcut means play continues — but these games got shorter (14.6 → 11.8 average turns)
and ended winnerless. Shorter, winnerless, alongside a large wall-clock gap on all three
matchups, with red-mirror unchanged to the decimal (4/6, 0 draws, 13.9 turns) as the control where
games still complete.
On the wall-clock numbers, stated with their provenance rather than as a headline: the 20–48× above
is the gap against the checked-in suite-baseline.json, whose recording conditions are not
resolvable (its git_sha references an unpushed commit). Separate instrumentation measures a
smaller engine-cost regression for the recent window, so the honest reading is "a real cost
regression compounded by unknown baseline-recording conditions." Decomposing it is owned by a
separate lane, not asserted here.
The residual, stated plainly: this run cannot positively exclude that the new arm fires during
enchantress games — the gate's report records no policy-reason data, so reachability is not
answerable from its artifacts. An A/B against the pre-fix binary on the same seeds settles it by
measurement (the pre-fix binary shares the same slowdown, so identical 1/1/8 exonerates the
change). That is running and the result gets posted here either way, including if it goes against
us.
Two instrument defects found along the way, routed to the gate-reliability lane rather than fixed in
this PR: the exit code is driven solely by the compare section and is blind to a suite-level FAIL;
and drawn games carry winner: null, so the paired-seed flip counter has nothing to compare and
prints 0 flips while the matchup aggregate moves — a draw-rate delta should be a first-class
compare signal.
Also carried forward, pre-existing and not from this PR: one seed aborted on
AI fallback reached during pending cast (variant ManaPayment, spell Kappa Cannoneer) — can_cast_object_now has a gap, at search.rs:921. Tracked in the same lane.
For context on why this is reported locally at all — and this PR's own CI is now the cleanest
evidence for it. On run 30816542439 at head 349891f2c:
| job | started | ended | duration |
|---|---|---|---|
| Paired-seed AI gate | 13:09:54Z | 14:10:18Z | 60m 24s |
| Decision-cost perf gate | 13:10:01Z | 14:10:27Z | 60m 26s |
Both cancelled at the 60-minute timeout-minutes ceiling, neither having produced a verdict. That
matches the repo-wide pattern: ai-gate's pull_request runs hit the ceiling on 22 of 22 runs on
2026-08-02 and 12 of 14 on 08-03 (median cancelled duration 60.4m — the cap itself). So the
absent AI-gate check on this head is the ceiling, not this change, and the local run above is the
only way we can currently give you a completed result at all.
Verification
Run directly in the branch worktree. fmt and clippy were re-run at the exact tip after the
census adjudication commit, rather than carried over from the commit before it — that commit touches
an integration test file, which --all-targets lints, so the earlier numbers would not have been
for this tree.
| stage | result |
|---|---|
cargo fmt --all --check |
rc=0 |
scripts/check-parser-combinators.sh 349891f2c |
rc=0 — Gate A PASS, Gate G PASS |
cargo clippy --workspace --all-targets -- -D warnings |
rc=0 |
cargo test -p phase-ai |
rc=0 — 2072 passed, 0 failed |
cargo test -p phase-engine --lib |
rc=0 — 18462 passed, 0 failed |
cargo test -p phase-engine --test integration |
rc=0 — 4468 passed, 0 failed, 2 ignored |
cargo ai-gate |
rc=0 — see section 6, not reported as clean |
Gate A runs against the true merge base explicitly; the hook's fork-relative default resolves to the
wrong base on a stale fork and yields a vacuous PASS.
Each new row is reported present in the test output, not inferred from a total:
loop_shortcut_declare_that_decks_the_proposer_is_refused,
loop_shortcut_declare_that_decks_only_an_opponent_still_scores,
loop_shortcut_declare_that_kills_the_proposer_on_life_is_refused,
loop_shortcut_unbounded_declare_rejects_zero_count,
a_wire_zero_frames_per_period_fails_the_load_and_a_wire_two_does_not — all ... ok.
One integration row went red on the first pass and was adjudicated, not relaxed.
the_loop_shortcut_offer_writer_surface_is_pinned_and_every_declare_site_validates_pins reported
left: (22, 16) against right: (22, 14). The production half was unchanged at 22 — which is
the half that pin exists to protect, since its own text says a new production site in a
certification-path file halts the U-series while a new read site is the benign case. Both new hits
are in the policy's #[cfg(test)] module: bounded_offer_with_period, the builder that mints an
offer carrying a real per_cycle so the new arm can be driven at all, and certificate_of, a read
accessor. The new arm reads the certificate and writes no offer. Adjudicated 14 ⇒ 16 with both
sites named in the assert text, per its own instruction to name the new site rather than only move
the number — matching the two adjudications already recorded there.
…itted The CR 603.5 producer census pins `game/effects/mod.rs` line-exact. The phase-rs#6961 + v0.44.0 uniform +78 shift moved the three producers to :5996/:6073/:9048, and that re-pin was edited during the 851180c fold but never committed — it stayed in the working tree, so the pushed tip 0b5a2bf still asserted :5918/:5995/:8970 and CI went red on it. This commit is that edit and nothing else. Why the local gate was green while CI was red: the census test walks `src/` from disk at runtime (`env!("CARGO_MANIFEST_DIR")` + `read_to_string`), so it measures the WORKING TREE, never the commit. A dirty tree can never certify a commit — "green at the exact tip" requires HEAD == sha AND a clean `git status` on the gated paths, and that precondition is now part of the drift-log instructions. Re-verified in the `refs/pull/<n>/merge` layout rather than locally, per the drift log's own rule: `git merge-tree --write-tree HEAD upstream/main` (upstream 1738d5c) puts the producers at exactly :5996/:6073/:9048, with `scoped_library_search.rs:452` and `engine.rs:11427` unmoved — all five production producers accounted for, still no sixth. Upstream phase-rs#6959 is innocent. Assisted-by: ClaudeCode:claude-opus-5
|
🤖 AI text below 🤖 Correction: my last evidence table certified a tree that was never pushedCI went red on What happened. The CR 603.5 producer census pins Why my table said green. The census test walks The precondition I was missing, now written into the drift log in-tree:
Any test that reads source from disk has this property. The pin is one; it will not be the only one. Fix: Evidence, on the committed tree this time
Adjudicated in the CI layout, not locally, per the drift log's own rule: Non-vacuity is not an argument here — CI supplied it. This pin is exactly the kind of test that usually needs a revert-probe to prove it discriminates. It did not need one: the old pins put it at The Still open, unchangedThe line-exact pin is what makes a new sixth producer a counted event, which is why I have not swapped it for something drift-immune mid-review. A function + content-hash anchor would end the drift class while keeping that property; it remains offered as a follow-up, not taken unannounced. The enchantress win-rate question from the previous comment is still open and deliberately unrun — the box is currently occupied by another measurement whose design needs it quiet, and the repo's own |
|
🤖 AI text below 🤖 The enchantress ai-gate question, closed — the policy is unreachable in that suiteI owed you a powered A/B on the enchantress-mirror swing (baseline 40% → 10%). I did not run it, because the question was malformed: before attributing a result to a change, establish that the change's code is reachable in the run. It is not. No measurement was needed. Reachability proof
⇒ In every ai-gate duel-suite game The zero in row 4 is instrumented, not assumed: the same grep finds 23 Empirical corroborationA binary built without this fix, run on the same host and the same suite, reproduces the anomaly digit for digit:
Caveat, stated rather than buried: that no-fix binary is built from a different tree base (another in-flight branch), so it is corroboration, not a controlled arm. The reachability proof above is the load-bearing argument; this is the independent second instrument agreeing with it. The swing is therefore a property of the suite and the enchantress list, not of this PR. Consistent with that, I withdraw the implication in my earlier comment that this PR was a candidate cause. It never was, and I should have run reachability before proposing a measurement. Three gate-instrument defects found on the way — offered, not filed hereNone of these are this PR's to fix; they are why the enchantress signal was hard to read at all. Routed to the gate-reliability lane:
Happy to open these as separate issues if you'd rather have them tracked than mentioned. |
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] LoopShortcutPolicy treats reaching an empty library as immediate proposer elimination without proving a later mandatory draw. Evidence: crates/phase-ai/src/policies/loop_shortcut.rs:288-300 rejects a fixed declaration when cycles_to_proposer_elimination reaches its calculated threshold; :318-360 includes net negative library_delta in that calculation. CR 121.4 (local docs/MagicCompRules.txt:1158) instead makes a player lose when they attempt to draw from an empty library. The certificate carries the net library delta, not a required subsequent draw, while crates/engine/src/analysis/loop_check.rs:917-935 classifies self-mill as Advantage, not Decking, and the resource bound intentionally permits zero. Why it matters: a bounded, legal self-mill shortcut ending at zero with no forced draw is rejected even though it has not eliminated its proposer. Suggested fix: remove library from the immediate-elimination veto unless the certificate represents and proves a forced draw after the library reaches zero; add a discriminating exact-to-zero/no-draw positive regression that currently fails and scores bounded progress after the fix.
Required current-head evidence before re-review: regenerate the <!-- coverage-parse-diff --> sticky for 79ea95e1a15876655481636764be4eb451508f2d. The current artifact is bound to an earlier head; the Paired-seed AI and Decision-cost perf gates are also still pending. This is an evidence/verification requirement, not the substantive blocker above.
|
Correction to my prior review: its parse-diff freshness sentence was incorrect. The existing The substantive self-mill-to-zero blocker and its requested exact-to-zero/no-draw regression remain unchanged. The pending Paired-seed AI and Decision-cost perf gates remain verification conditions. |
…21.4) `cycles_to_proposer_elimination` counted the proposer's library reaching 0 as an elimination axis, so a bounded self-mill shortcut that ends at exactly zero was rejected at the CRITICAL band. That is a rules error. CR 121.4 (docs/MagicCompRules.txt:1158): "A player who attempts to draw a card from a library with no cards in it loses the game the next time a player would receive priority." The loss attaches to the DRAW ATTEMPT, not to the library reaching zero — a player with an empty library and no draw ahead of them has not lost and may still win. Self-mill to zero is a real strategy class: `loop_check` classifies such a period as `Advantage`, not `Decking`, and `ResourceVector::elimination_bounds` intentionally permits the exactly-zero terminal value. ACCUMULATION IS NOT REALIZATION, and the engine already tests that doctrine on The One Ring. Under the Kilo/Freed/Relic proliferate engine it certifies an infinite burden-GROWTH loop as `WinKind::Advantage` — not a win — naming the unbounded burden counter axis (`analysis/corpus_tests.rs`, `one_ring_burden_growth_certificate`, with the 0-burden dead-loop control beside it). The burden's lethality realizes only downstream at the upkeep trigger, which is where CR 704.5a finally applies: `tests/integration/one_ring_burden_upkeep_lethal.rs` pairs `one_ring_burden_upkeep_kills_owner_p0_wins` with the sub-lethal control `one_ring_sublethal_burden_owner_survives_no_gameover`. An emptying library is that same shape with mill for burden and the draw attempt for the upkeep trigger, so removing the axis makes this policy CONSISTENT with the engine's own tested doctrine rather than stricter than it. That is also why life stays, and why the line is principled rather than an ad-hoc keep/drop: `per_cycle.delta.life` is IN-CYCLE realization — the drain happens inside the certified period and is state-based-checkable at cycle boundaries, so the certificate proves the death it implies. The One Ring's life loss would only enter a certificate the same way if the upkeep trigger were inside the loop span. Both surviving axes are immediate state-based losses on state alone: life 0 or less (CR 704.5a), ten or more poison counters (CR 704.5c). A certified period records per-cycle resource deltas; it cannot express "and then the proposer is forced to draw", so no library-based veto is sound today. The axis is removed rather than weakened, and the EXCLUSION is what the doc comment annotates — why library is absent is the non-obvious fact a future reader needs. If a certificate ever proves a forced post-zero draw, that is the documented extension point. Tests, written before the fix and observed failing first: - `..._mills_the_proposer_to_exactly_zero_still_scores` is the regression. It FAILED pre-fix reading `loop_shortcut_declare_eliminates_proposer` with facts `declared=10, eliminates_at=10`, and scores bounded progress post-fix. Re-adding a library term flips it back, so it is its own revert-probe. - The threshold-discrimination pair (fatal-at-N rejects / N-1 scores) moves to the life axis, which remains a true elimination axis, and keeps its independent-failure revert-probe. - The seat contrast is re-based onto life: a library-drain contrast would now pass for BOTH seats and discriminate nothing. - A poison row is added so the min-across-axes claim is covered on both surviving axes, with life held constant so poison is the only binding one. Sibling sweep across phase-ai for library-to-zero-as-death reasoning: none. `mill_targeting.rs:89` penalizes milling an ALREADY-empty library, which is a wasted-value heuristic and not an elimination claim; the `self_cost_value.rs` rows concern DRAWING from an empty library, which is CR 121.4-correct. The engine-side bound derivation is deliberately untouched: this finding is about the AI-side veto, and `elimination_bounds` already permits exactly-zero. Assisted-by: ClaudeCode:claude-opus-5
|
🤖 AI text below 🤖 [HIGH] library-as-elimination — you're right, and it's your own doctrine I was violatingFixed in
The precedent this should have been built fromYour engine already tests exactly this distinction, and my veto contradicted it:
That is also why life stays, and why the line is principled rather than an ad-hoc keep/drop. Your regression, written first and observed failing
That failure is the built-in revert-probe: re-adding a library term flips it straight back. Neighbour-non-masking check: the life and poison rows passed in both runs, so the flip belongs to the library row alone and is not an artefact of a neighbouring assertion — pre-fix was The other rows moved with it. The threshold-discrimination pair (fatal-at-N rejects / N−1 scores) is now on the life axis, which is still a true elimination axis, keeping the property that the two rows fail independently. The old library threshold row is deleted, not adapted — its Sibling sweep across The engine-side bound derivation is deliberately untouched: your finding is about the AI-side veto, and Verification at this exact headMeasured at
Thanks for the self-correction on the parse-diff sticky — no action was needed there, though this push moves the head again, so it will rebind to Requesting a fresh review. |
…ensus pinned
The CR 732.2a offer-writer census (`engine/tests/integration/
loop_shortcut_offer_writer_census.rs`) walks `phase-ai/src` as well as
`engine/src`, and pins `(production, in_test)` at `(22, 16)` as an INVARIANCE
pin over the whole 5d U-series. The poison row added in the previous commit
patched the certificate after construction:
match &mut state.waiting_for {
WaitingFor::LoopShortcut { certificate, .. } => { ...poison... }
which the anchor counts, taking the in-test half to 17 and reddening the
census. Measured `left: (22, 17)` / `right: (22, 16)`.
PRODUCTION STAYED AT 22 — the benign signature. The previous commit removes an
elimination axis and touches only `#[cfg(test)]` rows and doc comments, so a
production move would have meant something else was wrong and would have been
escalated rather than re-pinned.
The fix is to remove the hit, not to adjudicate it. The row never needed to
reach into a minted offer; it needed a poison delta AT CONSTRUCTION. A
`periodic_poison` helper beside `periodic` supplies that, so the offer is built
in one step and the census is untouched. Re-pinning would have spent an
invariance guarantee that spans the U-series to buy a test-local convenience.
Verified: census 2 passed / 0 failed with the pin unchanged; policy module
20 passed / 0 failed.
Process note for whoever reads this next: the previous commit's gate was scoped
by crate (`-p phase-ai`) and therefore could not see this failure, because the
test that broke lives in `crates/engine/tests/` and reads phase-ai source from
disk. Gate scope must be derived from what READS the files a change touches,
not from which crate those files live in.
Assisted-by: ClaudeCode:claude-opus-5
matthewevans
left a comment
There was a problem hiding this comment.
[HIGH] The offer-writer census has not been re-pinned after adding a WaitingFor::LoopShortcut test-pattern hit. Evidence: crates/engine/tests/integration/loop_shortcut_offer_writer_census.rs:231-259 still expects (production, in_test) == (22, 16), while the current required shard-2 run reports left: (22, 17) and right: (22, 16) for that exact assertion (https://github.com/phase-rs/phase/actions/runs/30828354811/job/91735687842). Why it matters: this deterministic failure blocks the required Rust test gate and leaves the census contract out of sync with the deliberately added test-half surface. Suggested fix: adjudicate the new test-only WaitingFor::LoopShortcut site, update the expected test-half count to 17, and extend the explanatory census contract to name why that site is a benign test read rather than silently relaxing the pin.
The prior self-mill-to-zero logic blocker is resolved at this head: the library axis is now deliberately excluded from cycles_to_proposer_elimination, with an exact-to-zero/no-draw positive regression. This review requests only the census correction above.
|
🤖 AI text below 🤖 CI red on my last push was mine — a gate-scope miss, fixed in
|
| check | result |
|---|---|
cargo clippy -p phase-ai --all-targets -- -D warnings |
rc=0 |
cargo test -p phase-ai (whole crate) |
2073 passed, 0 failed |
cargo test -p phase-engine --test integration (full suite, reverse-dependency scope) |
4468 passed, 0 failed |
CR 732.2a offer-writer census, pin unchanged at (22, 16) |
passing |
loop_shortcut policy module |
20 passed, 0 failed |
| pre-commit Gate A / Gate G / Gate P | all PASS |
The substantive CR 121.4 change in 2cda9473d is untouched by this commit — the regression row still fails-first against a restored library term, and the life/poison threshold pairs are unchanged. This commit only changes how the poison row constructs its offer.
|
Correction to my immediately preceding census review: the branch advanced during submission to The prior request to change the count to 17 therefore does not apply to this new head. Fresh CI should verify that the census is again green. The resolved self-mill-to-zero behavior remains unchanged. |
|
Maintainer hold — the branch advanced to No contributor action is requested. Once required CI and the current-head artifact settle, we will perform the current-head recheck. The paired-seed and decision-cost AI checks are advisory and are not, by themselves, the blocker. |
matthewevans
left a comment
There was a problem hiding this comment.
Approved: current head was independently re-reviewed; required CI is green and the current-head parse artifact reports no changes. The bounded-offer fix is at the established seam with discriminating runtime coverage.
🤖 AI text below 🤖
Summary
Chain 3 of 3 of the combo-feedback series (phase 5, sub-phases 5a–5d). Splits
LoopDetectSampleinto 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-timetemplate.ownerfirewall (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 publicapply()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.gzcrates/engine/tests/fixtures/dina_conqueror_4p.json.gzcrates/engine/tests/fixtures/fantastic_four_bounded_loop_4p.json.gz(new)crates/engine/tests/fixtures/tenacity_exquisite_blood_4p.json.gzcrates/engine/tests/fixtures/witherbloom_sprout_lumaret_4p.json.gzcrates/engine/tests/fixtures/witherbloom_sprout_lumaret_simple_4p.json.gzFixture-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 stringCR 999.99zwas correctly not found.Verification
This box is deliberately unchecked and the reason is stated rather than papered over. The last independent review-impl gate to run to completion ran at
dbc81821df74a5ad63b6a928ca80a5a720603522, a pre-rebase tree, and returned zero correctness findings. That SHA is not in this branch's current history.Since then the head has moved for reasons largely outside this PR's own changes, and each move is named rather than averaged away:
e12447f4f, whose carried-commit delta was measured withgit range-diff 73fd7f6de..dbc81821d e12447f4f..dd986e404— of 25 reviewed commits, 20 content-identical (=) and 5 changed (!) (ec3f88c2d,ed4c158d0,89eacc318,254e11183,0512fccf6), exactly the four conflict resolutions plus the three commits the drift fixes were autosquashed into; eight pre-boundary commits and one empty rustfmt commit dropped out as already-merged upstream (fix(engine): loop-ring retention across forced windows + observer-negative rulings (combo-fb phases 0-2, chain 1) #6838/fix(engine): infinity-badge/offer correctness + per-entry token occurrence index (combo-fb phases 3-4, chain 2) #6839);mainfolding ship/canonicalize delayed trigger lifecycle #6933 (d1a5270a4), plus five additive commits closing two seams that fold exposed and one census re-pin (6face1fdd);a62782802);84992d9e5).None of those has been through a completed independent review pass. All of them have been through the current-head gate below. A fresh adversarial review at
84992d9e5is running now and its result is posted to the thread when it lands — including if it is not clean.Run at the CURRENT head
84992d9e52cd664cfc7d117194e57d3d21c84304, directly in the branch worktree (Tilt watches the main checkout, not this worktree):cargo fmt --all --check— exit 0.cargo clippy --workspace --all-targets -- -D warnings— exit 0, 0 warnings.cargo test -p phase-engine --test integration— exit 0; 4442 passed, 0 failed, 2 ignored.cargo test -p phase-engine --lib— exit 0; 18426 passed, 0 failed, 6 ignored.cargo test -p phase-ai— exit 0; 2069 passed, 0 failed summed across the crate's test binaries.scripts/check-parser-combinators.sh 7fa1fc4a1— exit 0 (Gate A PASS + Gate G PASS, run against the true merge base rather than the hook's fork-relative default, which on a stale fork resolves to the wrong base and yields a vacuous PASS). 0 files undercrates/engine/src/parser/are touched by this PR.cargo ai-gatehas not returned at this head, and the last commit was pushed ahead of it at the repository author's direction. Stated rather than omitted, because the repo's own rule is that AI behaviour changes run that gate. Repo-wide, ai-gate'spull_requestruns hit the 60-minutetimeout-minutesceiling on 22 of 22 runs on 2026-08-02 and 12 of 14 on 08-03 (median cancelled duration 60.4m — the cap itself), so a red or absent ai-gate result on this head reflects that ceiling rather than this change. A local run is in progress and its outcome is posted to the thread either way.Scope of that gate, measured after the above was written:
cargo ai-gatecannot exercise this PR's loop-shortcut policy arm at all.MatchConfig.loop_detectiondefaultsOff(the #4603 opt-in invariant) andphase-ainever sets it — 0 mentions ofloop_detection/LoopDetectionModecrate-wide including theai-gatebinary, against a positive control of 23 assignments incrates/engine/src. Every loop-shortcut offer is gated onloop_detection.samples(), false forOff, soLoopShortcutPolicyreturns before reaching any arm in every duel-suite game. A green or red ai-gate on this head is therefore evidence about the suite, not about the policy change; the behavioural evidence for that change is its discriminating test set (Fixed(10)-rejects /Fixed(9)-scores threshold pair, opponent-seat contrast, life-axis row). Full proof in the thread.Measured at earlier heads and NOT re-run at the current head — listed separately rather than restated as current:
cargo test -p phase-engine --doc— exit 0; 7 doc tests, allignore-annotated, so 0 run and 0 failed.cargo check -p phase-engine --all-targetsat each of the 27 commits inupstream/main..HEAD— 27 OK / 0 FAIL. This is the check that catches the exhaustiveness-arm class, where an early commit'smatchgains arms only a later commit defines, so the branch is bisectable rather than only green at the tip.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.trigger_carrier_countdefinition: needed == stamped on all six fixtures (154 / 7 / 3 / 6 / 1 / 1 = 172), every valueOrdinary,delayed_triggers: 0everywhere, 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 diffempty, andsha256sumof the restored file equal togit show HEAD:of it):if template.as_ref().is_some_and(|t| t.owner != offer.proposer) { .. }removed fromhandle_declare_shortcut):r28_a_declared_template_owning_another_seat_is_refused_at_declareFAILED,r28_a_the_owner_firewall_is_reached_on_an_empty_schema_offer_tooFAILED, andr28_b_the_drive_seat_guard_compares_a_client_supplied_owner_against_itselfFAILED 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_consumptionstayed ok, so the consumption ingress is a genuinely different seam and not the same guard asserted twice.!offer.schema.points.is_empty()block (existence unchanged, placement changed):r28_a_the_owner_firewall_is_reached_on_an_empty_schema_offer_tooFAILED whiler28_a_declared_template_owning_another_seat_is_refused_at_declareandr28_c_...both stayed ok. The firewall's placement outside that block is therefore independently load-bearing and independently measured — an empty-schema offer skipspredictability_gateandvalidate_pinsentirely, so a guard inside the block would let an unvalidatedownerreach the proposal.Gate A
Gate A PASS head=84992d9e52cd664cfc7d117194e57d3d21c84304 base=7fa1fc4a106f8356b7b5c8cf4c7f97df3cb1de56
Anchored on
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 newtemplate.ownerfirewall 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.migrate_legacy_trigger_firing_carriers, which derivesOrdinaryfor DEFERRED contexts from the historically-omitteddispatch_origin: Normaldefault. The fixture stamp derives the same CR 603.1-vs-CR 603.7a discriminant for the ACTIVE-pending and stack cases, which that function's(None, None)arm deliberately refuses to infer.Final review-impl
Final review-impl PASS head=dbc81821df74a5ad63b6a928ca80a5a720603522
That SHA is the pre-rebase reviewed tree and is not in this branch's history. It is stated as the true head of the review rather than restated as the current head, which would be the easy and wrong thing to write here. The four head-moves between it and
84992d9e5are enumerated in the Verification section above; a fresh adversarial review at the current head is in flight and its outcome is posted to the thread either way.Claimed parse impact
None. 0 files under
crates/engine/src/parser/are touched.Scope Expansion
The fixture migration (
scripts/lib/trigger-firing.jq,scripts/stamp-fixture-firing.sh,scripts/migrate-dump-fixture.sh, and the six re-stamped fixtures) is outside the phase-5 (5a–5d) implementation scope. It was forced by the base advance, not chosen: upstream8121fd1c6(#6842) made a CR 603.7TriggerFiringcarrier 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
ed0a8e55c)9169d8f44)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) through62718fe01— 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 renamedphase-enginepackage 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), andPeriodicDelta— introduced byed4c158d0— does not exist onmainat 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: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.
r1bis the armed tripwire: it fails loudly the moment any remedy widens the announced set. Remedy sizing was measured, not guessed — wideningannouncedalone 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: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::LoopShortcutarm ofcrates/engine/src/ai_support/candidates.rs, whoseFixed(max_iterations)candidate — the one that exists precisely for bounded offers — is gated onschema.points.is_empty(). F4 publishes one point, so that candidate is never generated and the legal set collapses toDeclareShortcut { count: UntilLethal, template: None }(refused outright byhandle_declare_shortcut) plusDeclineShortcut. The phase-ai policy independently reaches decline: the offer latchespredicted_winner: None, routingLoopShortcutPolicyto its(None, UntilLethal) => rejectarm. Note for whoever closes it: that file holds R8'sai_support/candidates.rs 1production-multiset entry, so a fix that ADDS aWaitingFor::LoopShortcut {construction there moves the multiset and must update R8's expected counts in the SAME commit.5.
ShortcutProposal.per_cyclecannot round-trip, and this PR introduces the defect.ShortcutProposalis plain serde insideGameState.waiting_for, butper_cyclecarries aPlayerId-keyed resource map andPlayerIdcannot 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 forper_cycle: Noneproposals.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 onupstream/main: thePeriodicDeltatype is absent entirely, andShortcutProposal(crates/engine/src/analysis/loop_check.rs:154) has exactly six fields, none of themper_cycle. (Greppingper_cycleonmaindoes hit — those areper_cycle_delta: u32, an unrelated field with no map key to decode.)git log -S'pub per_cycle' upstream/main..HEADbisects the field — on bothLoopCertificateandShortcutProposal— 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 nullper_cycle, so they stay byte-identical exceptowner. 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:
template.owner; it does not and cannot, becauseinject_pinned_answerholds 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.: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 dropped85cadabd3was whitespace-only rustfmt); all six fixtures are additions-only, a subset of the 5 stamped keys, with zero removals and canonical identity after stripping (thedina/witherbloom_..._simplebyte difference is a trailing jq newline, and the.gzgrowth 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:14says arm 1 strips "the three new keys", but the executabledel()list at:60names 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
e12447f4fhit four conflicts, each resolved preserving both intents rather than by taking a side:analysis/resource.rsin two commits — neither side compiled alone; took upstream's class-quantifiedarg2plus the widenedwindow_scope_from_cover_framesargument list.game/engine.rs+game/interaction.rs+types/game_state.rsin the 5c commit — 5c moves pin-validation after the count cap; upstream factored the handback intoreject_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.rskept BOTH independent validators.game/engine.rsin the 5d U2 commit — the owner firewall routed throughreject_shortcut_declaration.Separately, three upstream-blamed test-literal sites broke only under composition and were fixed by content-neutral
--autosquashinto their owning commits (diff against the known-good pre-fold tree empty, 0fixup!left). One local commit (85cadabd3, a rustfmt oftriggers.rs) dropped as empty, verified redundant with upstream683a6e66bby 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:
trigger_definitions/base_trigger_definitions, matched by exactdescription(CR 603.1: a printed or granted triggered ability of a permanent is an ordinary triggered ability). Delayed ⟸ an install receipt indelayed_triggers(CR 603.7a). Anything else aborts by name; there is deliberately no fallback stamp.(source, description)classes, zero defaulted, allOrdinary— 154 dellian / 7 dina / 3 F4 / 6 tenacity / 1 + 1 witherbloom. All six fixtures recorddelayed_triggers: 0, soDelayed(Some(..))could not have validated regardless.UnknownLegacywas REFUTED as an option, not skipped.validate_firing(crates/engine/src/types/game_state.rs, 7 call sites) returnsErr("{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.dina_conqueror_4pandwitherbloom_sprout_lumaret_simple_4pdiffer from their pristine regeneration in exactly one object each (Priest of Forgotten Gods'abilities/base_abilitiesAST), because the committed fixture carries a LATER parser state than the capture. Regenerating would have silently reverted that.fantastic_four_bounded_loop_4pis byte-identical across both paths.next_delayed_trigger_tokencarries#[serde(default)], so a bareGameStatedecode restores 0 while the productionPersistedGameStatepath runs a load-time repair to 1 — and 0 is invalid on its face, sincevalidate_trigger_firing_coherencerejectsnext_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 mistakemigrate-dump-fixture.shrefuses to make forEffectKind. 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 toexit 1, nothing written.descriptioncollided exactly with a printed trigger on the same source object, would be stampedOrdinary. That conjunction is measured unreachable on this corpus — all six fixtures recorddelayed_triggers: 0and zeroDelayedTriggerInstallcommands 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.jqis 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.shhas 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 agameStatekey on the dumps stored in theturn_numberenvelope — caught by a control before any write, and guarded.migrate-dump-fixture.shalso fixes a pre-existing bug where an unguarded|=aborted on the 4 dumps lackingtarget_slots, so it had only ever been usable on 2 of 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:452UNMOVED. 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 returnsErr("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