Skip to content

fix(ai): gate projection wall-clock cap on measurement mode + unify AI gate build profiles - #6998

Merged
matthewevans merged 3 commits into
mainfrom
ship/timecap-measurement-gate-and-gate-profiles
Aug 4, 2026
Merged

fix(ai): gate projection wall-clock cap on measurement mode + unify AI gate build profiles#6998
matthewevans merged 3 commits into
mainfrom
ship/timecap-measurement-gate-and-gate-profiles

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 4, 2026

Copy link
Copy Markdown
Member

Why

projection::project_to enforced a 15 ms wall-clock TIME_CAP that was not gated on ExecutionMode::is_measurement(), unlike the two existing search budgets (search.rs, planner::PlannerServices::with_deadline), which both null their deadline under measurement. Projection was the lone holdout, so cargo ai-gate verdicts were host-speed dependent — contradicting ExecutionMode::Measurement's own documented contract that measurement is a pure function of (binary, config, seed).

Reachability is direct: EvasionRemovalPriorityPolicy is registered unconditionally, a wall-clock bail scores 0.0 where a completed projection scores up to +3.0, and that term selects the removal target — changing (winner, turns).

What

  1. Gate the cap. TIME_CAP: Duration becomes a private TIME_CAP_MS: u32; the new projection_deadline(ExecutionMode) -> Deadline is the single producer, so no call site can write the magnitude or construct a projection budget itself. The deadline is threaded as an explicit parameter, which is what makes the determinism claim checkable rather than ambient. Reuses the existing engine::util::Deadline instead of the hand-rolled Instant/elapsed pair. combat_lookahead: bool becomes a payload-carrying CombatLookahead enum, making "lookahead enabled with no budget" unrepresentable.

  2. Unify the build profile. Authority was split four ways, and two were actively wrong for a native wall-clock gate — --release in this workspace is the WASM-size profile (opt-level='z', lto=true, codegen-units=1, panic='abort'). All four now name server-release. They must move as a chain: ai-perf-gate re-spawns itself via current_exe(), so profile skew between the alias and a script yields a silently stale binary rather than an error.

Gate impact — please read before signing a baseline

This changes gate outcomes. Measured at K=5 with card_data_hash held identical between arms (the only comparison that can attribute anything to code):

counter ungated gated delta ratio
attackable_player_sweeps 1,558 2,649 +1,091 1.70x
legend_rule_mode_gate_scans 16,743 20,932 +4,189 1.25x
sba_battlefield_snapshot_builds 16,670 20,859 +4,189 1.25x
state_clone_for_legality 16,286 17,770 +1,484 1.09x
layers_full_eval 14,404 15,883 +1,479 1.10x

24 of 29 counters are byte-identical, including every large one (restriction_static_mode_gate_scans 88k, mana_aura_trigger_scans 26k, crew_eligibility_scans 12k). All movement is upward and expected: projections that used to bail at 15 ms now complete.

sba_battlefield_snapshot_builds and legend_rule_mode_gate_scans moved by the same integer (+4,189), indicating a 1:1 coupling — one phenomenon, counted twice.

Pre-existing, NOT caused by this PR

crates/phase-ai/baselines/perf-baseline.json was generated 2026-07-29 and predates the 2026-08-02 MTGJSON snapshot, so its card_data_hash already mismatches current card data on any branch. card_data_changed() is diagnostic-only and is not in the exit path — report.any_fail() alone drives exit(1) — so the perf job can print an authoritative-looking FAIL table caused purely by data drift. If ai-perf-gate reports failures beyond the five counters above, that is the drift, not this change.

No baseline was refreshed. That is the maintainer's call.

Verification

  • 2,102 tests pass, 0 fail; clippy clean; fmt clean.
  • Revert probe: stubbing projection_deadline to always return after(15) turns two tests red at different layers — projection_deadline_nulls_wall_clock_only_in_measurement (unit pin) and velocity_score_projection_deadline_is_live_on_a_traversing_fixture (reach-through wiring, on a fixture that actually traverses). The other 23 stay green, so the probe discriminates rather than breaking everything.
  • No precise wall-clock speedup is quoted: every timing run available was taken on a machine under concurrent build load, and wall clock under load is not a measurement. The counter data above is load-independent and is what this rests on.

Context: #6967

Summary by CodeRabbit

  • AI Behavior

    • Improved combat lookahead configuration across execution modes.
    • Added deadline-aware projections, with interactive projections capped at 15 ms and measurement projections allowed to complete.
    • Preserved cached results even when deadlines expire.
    • Improved deterministic targeting and projection consistency.
  • Bug Fixes

    • Prevented timed-out projections from being cached.
    • Added safeguards and regression coverage for cache handling and execution-mode behavior.
  • Documentation

    • Clarified AI performance profiles, timing behavior, and reproducibility expectations.

`projection::project_to` bailed with `TimeCapExceeded` after a 15ms
wall-clock cap that was not gated on `ExecutionMode::is_measurement()`,
unlike the two existing search budgets (`planner::PlannerServices::with_deadline`
and `search.rs`), which both null their deadline under measurement.

That made `cargo ai-gate` verdicts host-speed dependent. The reachability is
direct: `EvasionRemovalPriorityPolicy` is registered unconditionally, its
`velocity_score` projects the opponent, a wall-clock bail scores 0.0 where a
completed projection scores up to +3.0, and that term selects which creature
the AI targets for removal — changing (winner, turns).

Changes:
- `TIME_CAP: Duration` becomes private `TIME_CAP_MS: u32`; the new
  `projection_deadline(ExecutionMode) -> Deadline` is the single producer, so
  no call site writes the magnitude or builds a projection deadline itself.
- `project_to` and `AiSession::get_or_project` take the deadline explicitly.
  The deadline gates computation only — a cache hit is served regardless of
  expiry (covered by a dedicated multi-authority test).
- `combat_ai` replaces its `combat_lookahead: bool` parameter with a
  payload-carrying `CombatLookahead` enum, making "lookahead enabled with no
  budget" unrepresentable. The deadline is constructed inside the crackback
  block, not at the call site, so the attacker heuristic's prologue does not
  consume the projection budget.
- Adds `projection_fixtures`, the crate's first full-loop (non
  already-at-horizon) projection fixture class, with reach guards.

Measurement mode is now bounded by `STEP_CAP` alone. Live-play behavior is
unchanged in kind: the predicate is identical and only CEDH enables combat
lookahead.

Comment-only: root `Cargo.toml` and `duel_suite/run.rs` both documented this
defect as live and are now false; `policies/context.rs` records that
`can_afford_projection`'s `is_none_or` is load-bearing and must not be
"fixed" into `is_some_and`.

No baseline refreshed. Context: #6967
The `/review-impl` mutation pass found T7
(`velocity_score_takes_projection_under_measurement_config`) had zero
sensitivity to the deadline argument it was meant to guard at
`evasion_removal_priority.rs:174`. All three wrong forms —
`ctx.context.deadline`, `Deadline::after(15)`, and `Deadline::after(0)` —
left the whole suite green.

Cause: T7 uses an already-at-`OpponentBeginCombat` fixture, so `project_to`
returns from the `Confidence::Exact` short-circuit before the loop's only
`deadline.expired()` read. The deadline was structurally inert there for
every possible value — the fixture is reachable but not discriminating.

This mattered more than its severity: the evasion seam is the one reachable
at the gate's default Medium difficulty and is the stated reason the change
exists, yet it was the only production call site without regression
protection. The CEDH-only combat seam was already covered by T8.

Adds `seed_opponent_begin_combat_horizon` to `projection_fixtures` — a
fixture that genuinely traverses to the horizon rather than starting at it.
Reaching `OpponentBeginCombat` by traversal requires a begin-combat trigger
on the projected opponent's board, because `auto_advance_once` opens a
priority window in that phase only when a trigger fires; otherwise it
advances past to DeclareAttackers or CR-508.8-skips to PostCombatMain.
`assert_begin_combat_trigger_parsed` guards that dependency so engine drift
surfaces as a named panic rather than a silently vacuous pass.

All three mutations watched RED and reverted byte-exactly. T7 is unchanged
and still guards the already-at-horizon path and the cache interaction.
Both PR-facing AI gates died at their 60-minute timeout on every run.
Optimized, one perf-gate sample completes in <=25s on an M-series Mac and
the parent gate is PERF_SAMPLE_COUNT=5 of those, so ~2 minutes.

No precise speedup ratio is quoted here on purpose. Every timing run
available while preparing this change was taken on a machine running
concurrent builds, and wall clock under load is not a measurement. The 25s
figure is therefore an upper bound, which is the only direction that is safe
to assert. The load-INDEPENDENT evidence is the counter data recorded on the
PR; that is what this change actually rests on.

Profile authority was split four ways, and two of the four were not merely
unoptimized but actively wrong for a native wall-clock gate:

  * .cargo/config.toml aliases (what CI runs)  -> dev, opt-level 0
  * scripts/ai-gate.sh                          -> --release
  * scripts/ai-perf-gate.sh                     -> --release
  * scripts/validate-ai-perf-reproducibility.sh -> dev, deliberately

`--release` in this workspace is the WASM-SIZE profile: opt-level 'z',
lto = true, codegen-units = 1, panic = 'abort'. It optimizes for binary
size, builds slowly, and aborts rather than unwinds.

All four now name `server-release` (opt-level 2, thin LTO, codegen-units 16,
panic = 'unwind'). refresh-ai-baseline.sh and refresh-ai-perf-baseline.sh
delegate to the two wrappers and inherit the profile automatically.

They must move as a chain, not one at a time: ai-perf-gate re-spawns ITSELF
via current_exe() for each cold-process trial, so the child inherits the
parent binary's profile while the scripts hardcode a target/<profile>/ path.
Profile skew between the alias and a script yields a silently stale or
missing binary, not an error. The reproducibility script's comment recorded
this invariant as its reason for pinning debug; the invariant is preserved,
only the profile name changes.

The shared `cache-shared-key: rust-ai-gate` stays coherent because every job
in the workflow moves together -- win-rate jobs populate the cache, perf jobs
reuse it. Expect one cold build on the first run after this lands.

Counter VALUES are logical event counts and profile-independent, so this
does not by itself invalidate a baseline. No baseline refreshed here.

Context: #6967
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Projection timing now follows ExecutionMode, with cache-aware deadline propagation through AI sessions and combat lookahead. Combat configuration preserves lookahead mode. AI gate aliases, scripts, workflows, and reproducibility checks now use the server-release profile.

Changes

Execution-mode projection and caching

Layer / File(s) Summary
Projection deadline contract and traversal
crates/phase-ai/src/projection.rs
project_to accepts a caller-supplied Deadline. Measurement mode uses an unbounded deadline. Interactive mode uses a 15 ms cap. Tests cover traversal, timeout, and fixture validity.
Session and policy propagation
crates/phase-ai/src/session.rs, crates/phase-ai/src/policies/*
AiSession::get_or_project forwards deadlines on cache misses. Cache hits remain available after deadline expiry. Evasion-removal scoring derives deadlines from execution mode and tests cache and timeout behavior.

Combat lookahead integration

Layer / File(s) Summary
Lookahead policy and combat wiring
crates/phase-ai/src/combat_ai.rs, crates/phase-ai/src/search.rs
CombatLookahead replaces the boolean lookahead parameter and preserves execution mode. Combat paths pass enabled or disabled policies through projection, cache, and direct paths.
Configuration and regression coverage
crates/phase-ai/src/combat_ai.rs, crates/phase-ai/src/search.rs
Tests verify CEDH enables lookahead with the configured execution mode, while Medium and explicit deterministic helpers disable it.

AI gate profile alignment

Layer / File(s) Summary
Shared server-release profile
.cargo/config.toml, .github/workflows/ai-gate.yml, scripts/*ai*, scripts/validate-ai-perf-reproducibility.sh
AI gate aliases, scripts, workflows, and reproducibility checks build and run with server-release.
Determinism documentation
Cargo.toml, crates/phase-ai/src/duel_suite/run.rs, crates/phase-ai/src/policies/context.rs, crates/phase-ai/src/policies/evasion_removal_priority.rs
Documentation states that measurement projections have no wall-clock deadline and remain bounded by STEP_CAP; RandomState iteration order remains variable.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Search
  participant CombatLookahead
  participant AiSession
  participant Projection
  Search->>CombatLookahead: derive policy from AiConfig
  CombatLookahead->>AiSession: request projection with execution-mode deadline
  AiSession->>Projection: compute cache miss with Deadline
  Projection-->>AiSession: projection or timeout
  AiSession-->>Search: cached or computed result
Loading

Possibly related PRs

  • phase-rs/phase#6977: Both changes modify projection-time combat and priority decision handling in crates/phase-ai/src/projection.rs.

Suggested labels: bug, needs-maintainer

Suggested reviewers: lgray

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes both primary changes: measurement-mode projection deadlines and unified AI gate build profiles.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ship/timecap-measurement-gate-and-gate-profiles

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@matthewevans
matthewevans enabled auto-merge August 4, 2026 19:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
.github/workflows/ai-gate.yml (1)

138-143: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Update the stale debug-build timing comments on the sibling win-rate jobs.

This comment states that "every job in this workflow builds the same profile" after the .cargo/config.toml alias change moves cargo ai-gate to --profile server-release. That claim is accurate, but the win-rate jobs' own timeout comments were not updated to match.

Lines 26-32 (ai-gate job) and lines 56-61 (ai-gate-nightly job) still attribute their measured timeout budget to "a cold debug build" and "the debug run itself (30 games at ~1.1m/game on a slow runner)." Those jobs now build and run under server-release, not the debug profile. Update those two comments to describe the server-release build/runtime characteristics, so a future contributor tuning timeout-minutes does not reason from the wrong profile.

As per CLAUDE.md: "Maintain clear documentation for determinism, measurement versus interactive deadlines, cache behavior, and reproducibility."

🤖 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 @.github/workflows/ai-gate.yml around lines 138 - 143, Update the timeout
rationale comments in the ai-gate and ai-gate-nightly jobs to describe
server-release build and runtime characteristics instead of cold debug builds
and debug execution. Preserve the existing timeout values and clarify cache
behavior and measured win-rate workload timing consistently with the
authoritative server-release profile comments.

Source: Path instructions

scripts/validate-ai-perf-reproducibility.sh (1)

57-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove or use W_run.

Line 57 defines W_run = T_run_max / PERF_SAMPLE_COUNT(5). The commit rule on line 58 uses only T_run_max and T_build. An operator reading this echoes a quantity that no threshold consumes. Either drop the W_run definition or state the threshold it feeds.

🤖 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/validate-ai-perf-reproducibility.sh` around lines 57 - 58, Update the
explanatory output near the commit rule to remove the unused W_run definition,
unless the commit threshold is explicitly changed to consume it; keep the
existing T_run_max and T_build threshold semantics 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/phase-ai/src/policies/evasion_removal_priority.rs`:
- Around line 905-916: Make the interactive test independent of traversal speed
by replacing the cache-emptiness timing assertion in the policy-score test with
an observable deadline-propagation check. Set interactive_ctx.deadline to a
live, distinguishable long-budget deadline and capture the deadline received by
get_or_project during policy_score_in_context, then assert it matches the
interactive context deadline rather than relying on the 15 ms cap; preserve the
existing measurement-arm checks.

In `@scripts/validate-ai-perf-reproducibility.sh`:
- Around line 10-18: Update the binary path stated in the comments near the
profile-coupling explanation to include the isolated CARGO_TARGET_DIR prefix,
matching the target/ai/server-release/ai-perf-gate path already used by the
script. Keep the explanation of current_exe() and profile consistency unchanged.

---

Nitpick comments:
In @.github/workflows/ai-gate.yml:
- Around line 138-143: Update the timeout rationale comments in the ai-gate and
ai-gate-nightly jobs to describe server-release build and runtime
characteristics instead of cold debug builds and debug execution. Preserve the
existing timeout values and clarify cache behavior and measured win-rate
workload timing consistently with the authoritative server-release profile
comments.

In `@scripts/validate-ai-perf-reproducibility.sh`:
- Around line 57-58: Update the explanatory output near the commit rule to
remove the unused W_run definition, unless the commit threshold is explicitly
changed to consume it; keep the existing T_run_max and T_build threshold
semantics unchanged.
🪄 Autofix

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: 871e87a1-c46c-474c-9444-ebf83509efa3

📥 Commits

Reviewing files that changed from the base of the PR and between b5ab421 and 7cd269b.

📒 Files selected for processing (13)
  • .cargo/config.toml
  • .github/workflows/ai-gate.yml
  • Cargo.toml
  • crates/phase-ai/src/combat_ai.rs
  • crates/phase-ai/src/duel_suite/run.rs
  • crates/phase-ai/src/policies/context.rs
  • crates/phase-ai/src/policies/evasion_removal_priority.rs
  • crates/phase-ai/src/projection.rs
  • crates/phase-ai/src/search.rs
  • crates/phase-ai/src/session.rs
  • scripts/ai-gate.sh
  • scripts/ai-perf-gate.sh
  • scripts/validate-ai-perf-reproducibility.sh

Comment on lines +905 to +916
let _ = policy_score_in_context(&state, &decision, grower, &interactive, &interactive_ctx);
assert!(
interactive_ctx
.session
.projection_cache
.read()
.unwrap()
.is_empty(),
"under an interactive config the 15 ms projection cap must bail this traversal, so \
nothing is cached (revert-failing: passing ctx.context.deadline here hands the \
projection the caller's whole-turn budget and it completes)"
);

@coderabbitai coderabbitai Bot Aug 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Make the interactive arm independent of host speed.

This assertion passes only while the debug-build traversal costs more than TIME_CAP_MS (15 ms) on every host that runs cargo test. A faster runner, or any later reduction in traversal cost (fewer filler cards in seed_opponent_begin_combat_horizon, a cheaper auto_advance_once, a faster project_to), lets the traversal finish inside the cap. The cache then holds one entry and the test fails for a host-speed reason, not a wiring reason.

The measurement arm above already refutes Deadline::after(0) and Deadline::after(TIME_CAP_MS). The only substitution unique to this arm is ctx.context.deadline. Discriminate that substitution without a wall-clock race: set interactive_ctx.deadline to a deadline that is live but distinguishable (for example a long budget), then assert on which deadline get_or_project received rather than on elapsed time. If you keep the timing form, gate it so a fast host reports a fixture-drift message instead of a bare is_empty failure.

🤖 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/evasion_removal_priority.rs` around lines 905 -
916, Make the interactive test independent of traversal speed by replacing the
cache-emptiness timing assertion in the policy-score test with an observable
deadline-propagation check. Set interactive_ctx.deadline to a live,
distinguishable long-budget deadline and capture the deadline received by
get_or_project during policy_score_in_context, then assert it matches the
interactive context deadline rather than relying on the 15 ms cap; preserve the
existing measurement-arm checks.

Source: Path instructions

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, and fixed in 2ef000fbd8 (held until this PR clears the merge queue — see below).

The arm does race the wall clock: it only tests deadline wiring while the uncapped traversal costs materially more than 15 ms, and a cheaper fixture or a faster host turns it into an is_empty failure that reads as a wiring regression.

I did not take the "observe the deadline directly" form. velocity_score reaches get_or_project through the production path, and making the deadline it passes observable means adding a test-only seam to production code to verify production code — the arm would then assert against the seam rather than against the behaviour. So I kept the timing form and closed the failure mode you identified: the measurement arm is now timed (it runs the same traversal uncapped, so it measures exactly the cost the cap must beat), and a guard requires that cost to clear the cap with margin before the interactive arm is trusted. A host or fixture that can no longer discriminate now fails with that reason and a pointer at seed_opponent_begin_combat_horizon, instead of a bare is_empty.

Residual limitation, stated plainly: this converts a silent wrong-diagnosis failure into a loud correct-diagnosis failure. It does not make the arm host-independent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

commit="2ef000fbd8"
file="crates/phase-ai/src/policies/evasion_removal_priority.rs"

if git rev-parse --verify "${commit}^{commit}" >/dev/null 2>&1; then
  git show --no-ext-diff --format=fuller --find-renames -- "$commit" -- "$file"
else
  echo "Commit ${commit} is not available in this checkout."
fi

Length of output: 525


@mattthewevans, the approach addresses the failure diagnosis I identified.

I could not verify the implementation because commit 2ef000fbd8 is not available in the current checkout. Please request another review after the commit reaches this PR branch. I will then verify the timing guard and resolve the finding if the code matches the described behavior.

🐇🔍

You are interacting with an AI system.

Comment thread scripts/validate-ai-perf-reproducibility.sh
@matthewevans
matthewevans added this pull request to the merge queue Aug 4, 2026
@matthewevans

Copy link
Copy Markdown
Member Author

Decision-cost perf gate: red, and not because of this PR

The gate reports 8 FAIL / 21 PASS on this branch. It fails for card-data drift, not for anything here, and the gate says so itself in its own output:

note: card-data hash changed (e2db8a6d4711…411fda64abaa…) — likely a card-data-driven trajectory shift, not a cost-per-node regression; review and refresh if intended

That note is diagnostic only — card_data_changed() is not in the exit path, report.any_fail() alone drives exit(1) — so the gate prints an authoritative-looking table and exits non-zero on pure data drift.

Control. An unrelated engine PR (fix/6967-passpriority-fast-path, run 30921360928) on a third card-data hash (e2db8a6d4711…8a58e54727ed…) produces the same 8 FAIL / 21 PASS, with three counters at byte-identical values to this branch:

counter control this PR
crew_eligibility_scans 12062 12062
mana_aura_trigger_scans 26648 26648
restriction_static_mode_gate_scans 88371 88371

The same gate has been red on ship/ai-answers-from-issued-domain and on a card-addition branch over the last two days. The committed baseline is simply stale against current MTGJSON.

What this PR does move. Measured as an A/B at fixed card data (K=5 both arms, card_data_hash match confirmed), exactly 5 of 29 counters change, all upward; the other 24 are byte-identical:

counter ungated (main) gated (this PR) delta ratio
attackable_player_sweeps 1558 2649 +1091 1.70×
legend_rule_mode_gate_scans 16743 20932 +4189 1.25×
sba_battlefield_snapshot_builds 16670 20859 +4189 1.25×
state_clone_for_legality 16286 17770 +1484 1.09×
layers_full_eval 14404 15883 +1479 1.10×

This is the expected, intended consequence: with TIME_CAP gated off under ExecutionMode::Measurement, projections that used to bail at 15 ms now run to completion, so the measured decision does more work. That is the point of the change — the gate stops measuring host speed.

The two +4,189 deltas being the same integer indicates those counters are 1:1 coupled, i.e. one phenomenon counted twice, not two independent regressions.

No new failures. Three of the eight FAILs (crew_eligibility_scans, mana_aura_trigger_scans, restriction_static_mode_gate_scans) are byte-identical between the ungated and gated arms — zero contribution from this PR. For the other five, the ungated arm already exceeds its threshold at current card data:

counter ungated threshold already over?
attackable_player_sweeps 1558 935 yes
layers_full_eval 14404 3733 yes
legend_rule_mode_gate_scans 16743 10851 yes
sba_battlefield_snapshot_builds 16670 10779 yes
state_clone_for_legality 16286 6877 yes

So all 8 FAILs occur without this change. This PR increases the magnitude of 5 of them; it flips no counter from PASS to FAIL.

Ask: the baseline needs a refresh for two independent reasons — MTGJSON drift (pre-existing, affects every branch) and the 5 counters attributed above (this PR, intended). The tables separate the two causes so the refresh can be signed off knowingly. I have not refreshed any baseline.

Also worth noting: with the gate profiles unified on server-release, the full K=5 suite ran in wall_clock=33737ms — about 34 seconds.

Merged via the queue into main with commit c75fda9 Aug 4, 2026
19 of 20 checks passed
@matthewevans
matthewevans deleted the ship/timecap-measurement-gate-and-gate-profiles branch August 4, 2026 20:35
lgray pushed a commit to lgray/phase that referenced this pull request Aug 5, 2026
…discriminating (phase-rs#7004)

* test(ai): name fixture drift as the cause when the interactive arm stops discriminating

The interactive arm of the T7b deadline test asserts that the 15 ms cap BAILS
the traversal, so nothing caches. That is a statement about deadline wiring only
while the uncapped traversal costs materially more than the cap. Let the fixture
get cheaper — fewer filler cards, a cheaper auto_advance_once or project_to — or
run it on a fast enough host, and the traversal fits inside 15 ms, the cache
holds one entry, and `is_empty` fails as though the production wiring had broken.

Time the measurement arm, which runs the same traversal uncapped, and require
that cost to clear the cap with margin before trusting the interactive arm. A
host or fixture that can no longer discriminate now says so and points at the
seed helper, instead of reporting a wiring regression that did not happen.

Also correct the binary path stated in validate-ai-perf-reproducibility.sh: the
script isolates CARGO_TARGET_DIR to target/ai, so current_exe() resolves to
target/ai/server-release/ai-perf-gate. The block calls that profile coupling
load-bearing, so a wrong path there is worse than no path.

Both raised by CodeRabbit on phase-rs#6998.

* test(ai): time the projection itself, and record the margin it actually has

The previous guard started its clock around `policy_score_in_context`, which
runs `candidate_for` and the policy's impact/threat/evasion gates before it ever
reaches the projection. Only `project_to` reads the deadline, so a slow wrapper
could satisfy the guard while the projection finished well inside the 15 ms cap
— exactly the case the guard exists to catch.

Time `get_or_project` directly instead, with the coordinates Guard 2 already
pinned and `velocity_score` passes, under `Deadline::none()`. The probe asserts
the projection COMPLETED (else it would be timing a bail, not a traversal) and
uses its own session so it cannot warm either arm's cache.

Measuring the right span immediately falsified a claim this test had been making
in prose: the uncapped projection costs ~29 ms, clearing the cap by about 1.9x,
not the "several times" the measurement arm's message asserted. The wrapper
cleared 45 ms comfortably and hid how thin the real margin is. Threshold is now
20 ms — just above the cap, because the arm's true precondition is `uncapped >
15 ms` and below that it discriminates nothing. A host roughly 2x faster than
this one will trip the guard and name the fixture, rather than letting the arm
report a wiring regression that never happened.

Corrects the measurement arm's message to the measured figure.

Raised by CodeRabbit on phase-rs#7004.

---------

Co-authored-by: matthewevans <matthewevans@users.noreply.github.com>
lgray added a commit to lgray/phase that referenced this pull request Aug 5, 2026
…ated

The rebase onto upstream `b654513cb` (phase-rs#6996, phase-rs#6999, phase-rs#6998, phase-rs#7001, phase-rs#6997,
phase-rs#6946) resolved the census row's conflict to upstream's three
`effects/mod.rs` literals, which are correct for the upstream tree but not
for this branch replayed on top of it. Re-derived from the row's own failure
output — never predicted by arithmetic — and re-pinned:

  `:6065/:6142/:9324 ⇒ :6175/:6252/:9456`

The shift is NOT uniform (`+110/+110/+132`), and the asymmetry is the
measurement: C1's `upfront_optional_gate` authority is one 110-line hunk
above all three producers, and `resolve_chain_body` takes a further `+22`
from two hunks inside itself and above its own gate (the `optional_for`
coupling note, and adoption A replacing the inline conjunct chain with the
authority call plus its `debug_assert!`). The file's whole-file delta is also
`+132`, so nothing lands below the third producer, and `6065+110`,
`6142+110`, `9324+132` equal the observed coordinates exactly.

Identity re-established rather than assumed. Each producer at its new
coordinate is sha256-identical to `b654513cb:effects/mod.rs` at its old one
and to the pre-rebase tip `117baa6a1` at `:6109/:6186/:9183`, and each is
still inside the enclosing function this row NAMES —
`drive_sequential_repeated_optional_payment`,
`resolve_repeated_optional_payment_choice`, `resolve_chain_body` — which is
stronger evidence than the coordinate. The diff instrument discriminates: in
the new tree the three old coordinates hold a `may_trigger_auto_choice`
lookup, a blank line, and a bare `//`.

`engine.rs:11549` was re-derived too, not carried over, and is UNMOVED:
upstream's six commits net ZERO above it (`:11420` in both the old base
`dcb8f3808` and the new base `b654513cb`), so this branch's own `+95`/`+34`
still land it on `:11549`, byte-identical and still inside
`begin_pending_trigger_target_selection`. `scoped_library_search.rs:452` is
unmoved as well. Two entries holding still while three move is the
set-preservation evidence: the row's first two asserts fired GREEN on the
run that caught this, so the total stays 37 and the partition stays 5/7/25 —
no producer was gained or lost.

Assisted-by: ClaudeCode:claude-opus-5
matthewevans pushed a commit to JacobWoodson/phase that referenced this pull request Aug 5, 2026
phase-rs#7005)

* fix(engine): announce the entries a forced-window answer places on the stack

CR 732.2a's ring sampler had exactly one site: `pass_priority_once_with_pipeline`,
which fires only at an active-player `Priority` settle. Any stack entry that resolves
ACROSS a forced pre-priority window — a CR 608.2b `TriggerTargetSelection`, a CR 603.5
`OptionalEffectChoice`, a CR 603.3b `OrderTriggers` — was therefore never present in
two consecutive retained frames, so `certified_period_touch`'s announced set
("entries in a frame's stack absent from the previous frame's") could not see it and
`bounded_cycle_pin_slots_for_window` could not publish its choice. The shortcut then
described a sequence with unpublished per-iteration choices in it.

This adds the SECOND sampling site, in `apply_action`, keyed on the forced-window flag
captured BEFORE the reducer consumed it. Its conjuncts are the settle sampler's, plus
a non-shrinking-stack guard measured against the pre-action depth.

Consequences carried in this commit rather than left to be discovered:

* the structural pin `arc_as_ptr_beat_identity_is_the_sample_not_one_of_its_halves`
  moves 2 -> 3 `as *const` reads (the shared per-beat `before` plus an `after` read in
  each arm that can advance the ring), so a re-basing onto a field address still flips
  it;
* two doc comments claiming `victim_slot` is "empty on every trajectory that offers
  today" are FALSIFIED by the widening and are replaced, not softened — a `Targets`
  declaration is announced now, so `worst_seat_life_loss` reaches `elimination_bounds`
  in production;
* B5f rows that consequence two-sided on the user's own MODE1 capture (tracked here as
  `f4_user_mode1_no_offer_4p.json.gz`, 860,451 B, derived `jq -c '{gameState}' | gzip
  -9 -n` from the 20.5 MB envelope): with P1 seeded at 7 and 6 the offer FIRES with
  `max_iterations == 1`; at 5 and 4 the drive reaches the same beat, raises nothing,
  and the typed verdict is `NoNarrowedLegalCount`. The arms are ONE life point apart,
  which is what makes the row about the divisor rather than about the board.

Landing FIRST of the five commits is load-bearing: relief without the widening turns a
silent no-offer into a treadmill that offers and commits nothing.

Assisted-by: ClaudeCode:claude-opus-5

* refactor(engine): one authority for a loop-shortcut period boundary

`drive_one_shortcut_cycle` delimits a committed repetition two ways: board recurrence,
and — for the certification basis that consults no board predicate at all — the
published `frames_per_period` count. The frame-count arm existed at exactly one beat
kind, the active-player settle, because that was the ring's only sampling site.

With the answer-beat sampler in place that premise is gone: a period whose extra frames
are recorded while a player answers a forced pre-priority window would never reach `k`,
so `frames_per_period` becomes unreachable on precisely the boards the widening was
for, and such a drive can only end at its runaway beat cap having committed nothing.
The injector arm therefore advances the same counter, under the same `Arc`-identity
frame detector the settle arm uses.

Both arms now ask ONE function. `published_period_elapsed(frames_this_cycle,
frames_per_period)` carries the two properties neither call site can state: `None` NEVER
elapses (an offer that published no signature must not have one invented for it, because
ending a cycle early commits a fraction of the published delta — the conditional action
CR 732.2a forbids), and the comparison is `>=` rather than `==` (one beat may retain more
than one frame, and an `==` would drive past its own boundary).

Two regression rows, one on each surface:

* `published_period_elapsed_is_total_over_the_axes_that_delimit_a_cycle` asserts the
  whole truth table, including the `k - 1` and `k + 1` arms that discriminate the
  off-by-one and the `>=`/`==` choice — the anti-vacuity control the structural row
  cannot supply;
* `the_period_delimiter_has_one_authority_and_both_frame_recording_arms_ask_it` censuses
  `drive_one_shortcut_cycle`'s extent with the tree's own comment-excluding extractor:
  exactly two delimiter calls, exactly two counter advances, and ZERO inlined raw
  comparisons, with a proven-live instrument on both sides of the zero census.

Also restores `drive_one_shortcut_cycle`'s doc block, which the delimiter extraction had
silently re-attached to the new function, and corrects its "the single
`record_loop_detect_sample` call site" sentence — there are two sampling sites now.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine): discharge a loop's replacement obligations against the live board

Conjunct (6) classifies each announced stack entry on its CARRYING FRAME — a retained
ring sample, and therefore a board from the past. It then discharged the resulting
`FreeUnlessReplacements` obligation against that same frame, which answers the wrong
question: a shortcut is a claim about the FUTURE, and every remaining repetition
resolves under the board that exists NOW. A replacement definition that entered the
battlefield after the sample was taken is invisible to the frame-side check, so the
described sequence could contain exactly the CR 616.1 resolution-time choice CR 732.2a
forbids.

The gate now discharges a second time against `state`, guarded by `!ptr::eq(*frame,
state)` — a de-duplication, not an exemption: when the pair is carried by `current`
itself the first call already ran on that very board.

Two rows, each with its own paired positive:

* `n3_a_replacement_installed_after_the_frame_was_captured_refuses_certification`
  builds a ring whose frames are all cloned BEFORE the definition is installed, so the
  def exists on the live board and nowhere else, and runs four arms — no def
  (certifies), live-only OPTIONAL (refused, the arm this change exists for), live-only
  MANDATORY (certifies, which keys the previous arm to optionality rather than to "a
  definition exists"), and present-everywhere OPTIONAL (refused, proving the frame-side
  discharge still does its own job so this is an ADDED refusal, not a relocated one).
  `announced_from_retained_sample` runs on every arm as the reach-guard that the pair is
  carried by a frame that is not `current`.
* `n3_b_a_live_carried_pair_is_still_discharged_by_the_first_call` exhibits the
  short-circuited shape and shows the optional definition is still refused there.

CR anchors corrected in the same change, because they are about this seam. CR 614.1a is
"effects that use the word instead" — a sub-rule cited for its parent's job. The
prompt-cause authority in `replacement.rs` classifies EVERY applicable replacement,
including skips, enters-with, turned-face-up and virtual candidates that carry no
`ReplacementDefinition` at all, so its anchor is the definitional head CR 614.1; and
what makes an optional replacement disqualify a shortcut is CR 732.2a's ban on
conditional actions, not CR 614.1a. Both `replacement.rs` sites and four r9 sites now
read `CR 732.2a + CR 614.1`, in that order. CR 616.1 stays on the two-or-more ORDERING
branch, where it belongs.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine): one authority for whether a "may" is already answered, and answer the shortcut's gate with it

Three places answered "does this ability open ONE up-front optional gate, and to whom,
under which key?" — production's own branch in `resolve_chain_body`, the loop-shortcut
mint's guard (b), and `analysis::resource::auto_may_answer_for`. The latter two asked
the same four predicates and OMITTED two conjuncts the first has: `optional_for` and
the CR 608.2d feasibility probe. Measured, that made them return a different answer on
two ability shapes, and each was defended only incidentally — the fan-out case by a
disjunct in `resolution_prompt.rs` answering a different question, and the infeasible
case by membership of a fail-closed list from which three variants have already been
promoted out.

`effects::upfront_optional_gate` is now the assembler, and production's own branch IS
that function rather than a fourth copy of it. `stored_may_answer` is its consumer half.

`OptionalFeasibility::{Known, Probe}` exists because a naive adoption would have made
production probe TWICE: `resolve_chain_body` has already run
`optional_effect_is_infeasible` for the `CastFromZone` decline early-return that
precedes the gate, and that arm clones the whole `GameState` per bound object to run a
dry-run cast. Adoption A hands its answer over as `Known`; every other caller passes
`Probe`, which the authority evaluates LAST so the clone-bearing arm is reached only for
`optional AND NOT optional_for AND NOT repeat` entries. It is deliberately not charged
against `PROBE_BUDGET`: that counter bounds CR 732.2a certification asks at the verdict
door, and mixing wall-clock cost into it would re-base every metered row's pinned spend.

Guard (b) adopting the authority is a BEHAVIOUR CHANGE and it is rules-correct in the
fail-closed direction. It now withholds the `MayChoice` slot for an `optional_for`
ability — CR 608.2d + CR 101.4 make that an APNAP cascade of up to one window per living
player, and one published slot standing for N prompts is the cardinality defect group
(c) already argues against — and for an infeasible optional, which opens no window at
all, so a slot for it is a pin the gate can never spend. Direction: strictly FEWER
offers, never more.

N0 rides on the same authority: gate (6) now takes relief from a stored auto-choice as
well as from a published pin, and the two bases are disjoint by construction because
guard (b) publishes a slot only for a may with NO stored answer. Reading an
auto-answered may's slotless mint as "unspecified" was the defect. Only `Accept` is
relieved — a stored `Decline` is equally prompt-free but produces the OPPOSITE board, so
its optional-cleared residual would describe events the shortcut never proposes.

Rows, each with its own paired positive:

* `a5_a_stored_accept_relieves_gate_six_and_a_stored_decline_does_not` — one board, one
  key, one value different; the arm the user's MODE1 capture rides on.
* `f2a_the_upfront_gate_authority_answers_the_two_shapes_the_third_copy_omitted` — every
  arm seeds a stored `Accept` under exactly the key the old copy built, so an omitted
  conjunct is a WRONG answer rather than an absent one. Includes the feasibility control
  (the same `RemoveCounter` ability on a board ONE counter different) and the pair that
  proves `Known` overrides the probe instead of re-running it.
* `f2b_guard_b_withholds_a_pin_the_cr_603_5_gate_can_never_spend` — deliberately
  UNSEEDED, so guard (b)'s store conjunct is vacuously true on every arm and the only
  thing that can move `may` is the axis under test. A seeded variant is rejected: it
  cannot fail for the reason the row exists.
* `f2c_the_cr_603_5_conjunct_set_has_one_production_assembler` — MEASURED per-predicate
  production call-site counts 2/2/2/1/2, plus the stronger statement that ZERO production
  consumers live outside `game/effects/`, with a proven-live instrument on the zero
  census. The three surviving non-authority sites select a DRIVER rather than opening an
  up-front window, so folding them in would be wrong, not cleaner — and the guarantee is
  stated honestly: an inline re-derivation from `ability.repeat_for` is NOT caught, and
  no census over these five tokens can catch it.

`cargo clippy -p phase-engine --all-targets -- -D warnings` is clean under the shipped
enum, with zero `is never constructed` (both variants are constructed on production
paths, and F2a exercises `Probe` to opposite outcomes).

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): re-derive every row whose premise was the sampler's blind spot, and pin both user captures

The answer-beat sampling site (C4) widened what a CR 732.2a offer can publish, and
several rows had encoded the OLD blind spot as if it were a property of the board.
Each is re-derived from measurement rather than relaxed, and both of the user's own
captures are tracked and driven end to end.

THE FIX BAR. `a1_the_users_accept_committed_nothing_board_now_commits_on_every_axis`
drives the user's MODE2 capture — the board where the offer fired, the declaration was
accepted, and the drive then committed nothing and re-offered. It now publishes all
three per-iteration choices and the accepted `Fixed(n)` grant commits EXACTLY n
repetitions of the offer's own published per-cycle signature on life and library, with
counters and tokens non-zero at n=1 and exactly 3x at n=3. Revert-probe run: with the
answer-beat site ablated every axis collapses to 0, reproducing the captured symptom.
`m1_...` is its one-field-apart sibling on MODE1 (a STORED CR 603.5 answer, so guard (b)
withholds Sue's slot and the auto-answer relief discharges gate (6) instead).

`--lib` rows re-keyed to production's own walk. `newest_item4_window` consumes
`game::engine::candidate_windows`, so R21(b-placement-B)'s window IS production's rather
than a hard-coded `len - 2` that silently asserted `span == 1`; its reach-guard now
states what it needs (no denied answer; a gate that ASKED must have completed) with the
load-bearing exemption equality byte-identical. R16(ii-b) searches its real construction
requirement (`meter.spent > 0`, never `denied`, which would assert itself) and ships its
own revert-probe as a `RaisedTwiceLinks` positive control. The CR 603.5 prompt census is
re-pinned from the failure's own left side, every producer sha256-identical at its new
coordinate and still inside the function the row names, and its authority count is made
comment-insensitive so prose cannot trip a call-site pin.

Attribution repairs: `r2` is renamed `r2a` to state what its body now asserts; r1 keeps
its over-charge follow-up pointer instead of claiming discharge; the `r5_declare_is_accepted`
citation is replaced with the per-caller measurement that actually exists; the r28
empty-schema arm DISCLOSES that its path is now reached by staging rather than naturally;
a pre-existing `CR 614.1a` comment that described no rule is corrected to CR 614.1.

`ai1_the_bounded_declare_candidate_withdraws_when_the_offer_publishes_a_pin` pins the
generator's `Fixed` candidate to the published pin set in both directions on one board,
and is deliberately not `#[ignore]`d.

Assisted-by: ClaudeCode:claude-opus-5

* docs(engine): replace the four notes C4 falsified, and recover the counter assertion a false premise cost

Five ACCEPT-WITH-FIXES findings, plus one sibling swept by the same defect
mechanism. `crates/engine/src/` is COMMENT-ONLY this round — proved by
`git diff -U0 crates/engine/src/` having no non-comment +/- line — so the only
executable change is in the F4 test file.

F1. Four docs still asserted the pre-C4 premise that `record_loop_detect_sample`
has ONE call site, and this branch's own policy is to REPLACE a falsified note
rather than soften it. The measured truth is TWO production sites, both after
`run_post_action_pipeline` (CR 603.3): the settle sampler in
`pass_priority_once_with_pipeline` and the forced-window answer site in
`apply_action`. Rewritten at the fn doc and the `loop_detect_ring` field doc
(`game_state.rs`), at `frames_per_period` (`resource.rs` — the justification C2
had already repaired in code), and at `ring_delta_signature`, whose homogeneity
argument now rests on the `Priority{active_player}` conjunct the two sites
SHARE rather than on there being one site.

F1e (swept sibling). `drive_one_shortcut_cycle`'s "the frame counter is advanced
here and nowhere else" was falsified on this same branch by the forced-window
ANSWER arm's own advance. Both arms key the advance on the ring's back
allocation changing, which is what keeps drive and mint one-to-one.

F2. The second site's comment claimed "the same conjuncts as the settle sampler,
PLUS the window flag". Measured, the sets are the same size one member apart:
`answering_forced_window` REPLACES `resolved_this_beat`, and the settle site's
`else { ring.clear() }` has no counterpart here. The comment now says that,
names the consequence (an answer that resolves nothing but leaves the stack
non-shrinking records a duplicate frame), and says why it is acceptable —
`ring_delta_signature` refuses a zero smallest-period delta, and mint/drive are
symmetric because `inject_pinned_answer`'s arms all dispatch `apply_action`.
It also documents the latent ordering asymmetry: this site records BEFORE
`state.waiting_for = wf` while the settle sampler records after
`sync_waiting_for`, and `GameState::eq` compares both `waiting_for` and
`priority_player` while `normalize_for_loop` neutralizes neither. Latent, not
live: a `debug_assert_eq!` census reported 0 failures across 18,486 lib and
4,487 integration rows, with a `debug_assert!(false)` positive control that
fired on the A1 board. Deliberately NOT reordered — that would be a behavioural
change for a non-live defect.

F3. The fix bar's life and library equalities had no anti-vacuity guard, so an
all-zero certificate would satisfy `moved == rate * n` on a board that never
moved. Added, in the existing `assert_axis_scales` idiom.

F4. The counter assertion had been weakened on a false premise. Measured, the
published vector is `counters {(Plus1Plus1, Creature): 2}` — non-zero and
state-readable; only `tokens_created: 0` is event-fed. The real obstacle was the
accessor: `commit_axes` reads ONE object's counters against an AGGREGATE key.
Re-cut against `ResourceVector::snapshot`/`delta`, the accessor the certificate
is minted from, plus a "nothing unpublished may move" arm. The aggregate moves 2
at n=1 and 6 at n=3, i.e. exactly 2n. The token axis keeps the scaling arm alone,
now for its real measured reason.

F5. `has_frozen_window`'s residual was declared as "four authored-ring rows".
Measured: two call sites, and NEITHER is an authored ring — both drive the real
tracked dumps. Overstated in count, understated in kind. The disclosure now
names both rows and the loud floor that lets them keep a hard-coded `span == 1`.

The CR 603.5 prompt census went red on F2's line drift and was re-pinned by its
own protocol, not by matching the tree: `engine.rs:11515 => :11549`, +34 which is
engine.rs's entire (comment-only) delta above the producer, the line
sha256-identical at the new coordinate and still inside
`begin_pending_trigger_target_selection`; total 37 and partition 5/7/25
unchanged.

Every new assertion and guard is proven to flip. Ablating the answer-beat
sampling site fails the library equality at `left: 0 / right: -1` (the prior
probe's signature) and, with the seat loop bypassed so control reaches it, the
recovered counter equality at `left: 0 / right: 2`. Zeroing each published rate
in turn fires each guard alone. All five are typed assertion failures, not
harness crashes.

lib 18487 passed / 0 failed, integration 4487 passed / 0 failed / 2 ignored,
clippy --workspace --all-targets -D warnings exit 0.

Assisted-by: ClaudeCode:claude-opus-5

* test(engine): re-derive the CR 603.5 producer pins the rebase invalidated

The rebase onto upstream `b654513cb` (phase-rs#6996, phase-rs#6999, phase-rs#6998, phase-rs#7001, phase-rs#6997,
phase-rs#6946) resolved the census row's conflict to upstream's three
`effects/mod.rs` literals, which are correct for the upstream tree but not
for this branch replayed on top of it. Re-derived from the row's own failure
output — never predicted by arithmetic — and re-pinned:

  `:6065/:6142/:9324 ⇒ :6175/:6252/:9456`

The shift is NOT uniform (`+110/+110/+132`), and the asymmetry is the
measurement: C1's `upfront_optional_gate` authority is one 110-line hunk
above all three producers, and `resolve_chain_body` takes a further `+22`
from two hunks inside itself and above its own gate (the `optional_for`
coupling note, and adoption A replacing the inline conjunct chain with the
authority call plus its `debug_assert!`). The file's whole-file delta is also
`+132`, so nothing lands below the third producer, and `6065+110`,
`6142+110`, `9324+132` equal the observed coordinates exactly.

Identity re-established rather than assumed. Each producer at its new
coordinate is sha256-identical to `b654513cb:effects/mod.rs` at its old one
and to the pre-rebase tip `117baa6a1` at `:6109/:6186/:9183`, and each is
still inside the enclosing function this row NAMES —
`drive_sequential_repeated_optional_payment`,
`resolve_repeated_optional_payment_choice`, `resolve_chain_body` — which is
stronger evidence than the coordinate. The diff instrument discriminates: in
the new tree the three old coordinates hold a `may_trigger_auto_choice`
lookup, a blank line, and a bare `//`.

`engine.rs:11549` was re-derived too, not carried over, and is UNMOVED:
upstream's six commits net ZERO above it (`:11420` in both the old base
`dcb8f3808` and the new base `b654513cb`), so this branch's own `+95`/`+34`
still land it on `:11549`, byte-identical and still inside
`begin_pending_trigger_target_selection`. `scoped_library_search.rs:452` is
unmoved as well. Two entries holding still while three move is the
set-preservation evidence: the row's first two asserts fired GREEN on the
run that caught this, so the total stays 37 and the partition stays 5/7/25 —
no producer was gained or lost.

Assisted-by: ClaudeCode:claude-opus-5

* docs(engine): replace every doc claim this branch's own rows falsified, and re-measure the span==1 residual

The final review at 7841e1e found three survivors of the F1 falsified-doc class plus one
unproven mechanism. A mechanical sweep of the same class found two more the review did not
name, both in the test file the previous round never searched. Comment-only; no behaviour.

r1's doc said the offer "publishes ONE point and commits ZERO cycles (see r1b and r2)". All
three clauses are dead: r1b's own assert_eq! pins THREE points [Sue MayChoice, Reed MayChoice,
Torch Targets]; r2a commits exactly n at n=1 and n=3; and `r2` names a row this branch renamed
(fn-name diff b654513..HEAD: exactly one name disappeared,
r2_an_accepted_declaration_commits_zero_cycles_because_reeds_may_is_unannounced, and zero
references to it survive). r1's second paragraph was falsified too and went unreported: the
in-tree form is the ADDITIVE one (resource.rs observed_life_loss.max(0) +
declared_life_magnitude), not the MAX form, and victim_slot is NON-EMPTY on this board, so the
two forms do not coincide.

r1b's OWN doc block was the sharpest instance and neither round had caught it — it said "403
and 401 are never announced ... publishes exactly ONE point" while the same function's body
asserts three and its message says all four sources are announced. The U6 header's "F4
publishes ONE point, not three" and the Fixed-gate bullet's "F4 publishes one point" are
corrected with the reason preserved: the AI still declines, but on the emptiness gate, never
on the count. resource.rs:10713 is brought into line with the two siblings this branch already
replaced at resource.rs:1062-70 and engine.rs:2257-64, reusing their wording.

has_frozen_window's span==1 residual was justified by an unproven mechanism ("both fail LOUD
on a half period"). A half period is non-degenerate, so those guards do not fire, and both
rows' assertions are span-independent — they would PASS. Re-measured here rather than
transcribed: at the beat drive_dump_until(gz, 80, has_frozen_window) selects, dina beat=6
ring=2 and dellian beat=5 ring=2, and candidate_windows yields exactly one candidate
(idx=0, span=1, len=2) on each, so &live[len-2..] is the whole ring and span==1 is exact. The
residual is restated as "exact today, silent if the sampling rate grows this ring past two".

inject_pinned_answer's "arms all dispatch apply_action" (two sites) is corrected for precision:
four arms, three dispatch, the fourth Err()s before any frame advance. The mint/drive symmetry
conclusion survives and is now stated in the stronger form the code supports.

The engine.rs edit is deliberately line-neutral (3 for 3) so the CR 603.5 census pin at
engine.rs:11549 does not move; verified still on the OptionalEffectChoice producer and the
census row green.

--lib: 18501 passed; 0 failed; 6 ignored.
--test integration: 4513 passed; 0 failed; 2 ignored.
clippy --workspace --all-targets -D warnings: exit 0.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine): correct the CR 608.2d announcer citation, derive r2a's rates from the certificate, and make an unresolvable point source fatal

CodeRabbit left four inline findings on phase-rs#7005. Three hold; the fourth's premise
does not, and the difference is a measurement rather than an argument.

F-A - `UpfrontOptionalGate::prompt_player` cited CR 117.3a ("The active player
receives priority at the beginning of most steps and phases..."), which is
priority timing. The field names the player who ANNOUNCES the choice, which is
CR 608.2d ("...the player announces these while applying the effect."). Both
quotes verified against the rules text before the edit. `optional_prompt_player`'s
own doc carried the identical wrong citation and is fixed with it. Both edits are
line-for-line so the CR 603.5 prompt census keeps its exact pins.

F-B - CodeRabbit asked for memoization "if the fixtures produce optional,
non-repeat, non-`optional_for` `CastFromZone` entries". They do not. Instrumenting
the clone-bearing arm and both `state.clone()` sites, with a thread-local marking
the `Probe` path, over a full `--test integration` run (reproduced bit-for-bit
across two runs): 16402 raw mint calls => 4573 `Probe`-mode feasibility calls => 0
arm entries and 0 clones on that path. The 59 arm entries and 50 clones that do
occur are production's own `Known` path, which pays them once per resolution. The
zero's positive control is in-band: `P` and `K` are two labels from the same
statement, and `K` returned 59. The memo itself already exists for the other
caller - `PeriodVerdicts` is a `(FrameIx, ObjectId)`-keyed compute-on-miss memo
whose `published` field IS `entry_publishes_pin_slots`. Recorded the measured
number at the seam; added no memoization for a cost of zero.

F-C - `(libs_before[0] - libs_after[0]) as i64` subtracted two `usize` before the
cast, so the zero-commit regression the row exists to catch aborted on an
arithmetic overflow instead of printing the row's own diagnostic. Demonstrated
both ways: the old form under the underflow condition panics `attempt to subtract
with overflow` with the diagnostic suppressed; the new form fails as `assertion
left == right` and prints it. The row also asserted `(i64::from(n), i64::from(n))`
- two literals - while its message claimed the published per-cycle delta. Both
rates now come from `certificate.per_cycle.delta`, negated because the axes are
measured as losses, with an anti-vacuity guard so the equality cannot degenerate
to `0 == 0 * n`, and seat ids read positionally so a rate belongs to the seat
whose movement is measured.

F-D - `published_point_names` synthesised `obj<id>` when a point's source was
absent from `state.objects`. Every caller compares that string to the
SUE/REED/TORCH constants, so an unresolvable source read as "not that card" and
silently satisfied m1's negative owner-firewall assertion. Now a panic, matching
the treatment the adjacent `other =>` arm already gave the same class of failure.
The new guard is proven able to fire:
`published_point_names_panics_when_a_points_source_is_absent` deletes the first
published point's source and requires the panic, and reports `should panic ...
FAILED` when the synthetic fallback is restored.

Gates at this tip: lib 18501 passed / 0 failed / 6 ignored; integration 4514
passed / 0 failed / 2 ignored (4513 + the new row); clippy --workspace
--all-targets -D warnings exit 0. The CR 603.5 prompt census and
`a1_the_users_accept_committed_nothing_board_now_commits_on_every_axis` are both
green.

Assisted-by: ClaudeCode:claude-opus-5

* fix(engine): synchronize the forced-window state before recording the loop sample

`apply_action`'s answer-beat sampler called `record_loop_detect_sample` BEFORE
installing the pipeline's returned `wf`, while the settle sampler in
`pass_priority_once_with_pipeline` records AFTER its `sync_waiting_for`. A frame
minted at the answer site therefore snapshotted the PRE-pipeline
`waiting_for`/`priority_player` pair and a settle frame the synced one. That is a
detection hazard, not cosmetics: `impl PartialEq for GameState` compares both
fields and `normalize_for_loop` neutralizes neither, so a heterogeneous ring
breaks `ring_delta_signature`'s turn-position conjunct.

Route `wf` through `game::public_state::sync_waiting_for` — the canonical
synchronizer, which also recomputes `priority_player` — before the record, and
drop the raw assignment below it. Blast radius is the ring only:
`apply_action_boundary` already re-syncs the returned `wf` before the result
leaves the engine, so the settled state is unchanged. The edit is line-neutral so
the CR 603.5 prompt census keeps its line-exact `engine.rs:11549` pin (verified
byte-identical by sha256, still inside `begin_pending_trigger_target_selection`).

Mechanism verified at source: `is_forced_cascade_window` is a `matches!` over 13
non-`Priority` variants with a fail-closed fall-through, and the sampler's gate
reads the returned `wf` while the snapshot preserved `state.waiting_for`.

Evidence — instrumented probe on the pre-fix tree, `--test-threads=1`, full lib +
integration; one unit = one emitted probe line = one `record_loop_detect_sample`
invocation at that site. Settle site: 996 samples, 0 that the sync changed on
either field. Answer site: 285 samples, 0 stale on either field. An always-true
comparison of the same shape emitted on the same line is true on 996/996 and
285/285, so the zeros are a verdict rather than a dead instrument. The defect is
consequently structural and latent, and this replaces a coincidence with a
guarantee.

New production-fixture row on the tracked `dina_conqueror_4p` dump, driven
through production `apply()`: the newest answer-beat frame is
`Priority{active_player}`, its `priority_player` is that seat, and the published
`LoopCertificate` is exact under an exhaustive destructure. Revert-probes,
measured: clobbering `priority_player` after the sync fails arm 2
(`PlayerId(3)` vs `PlayerId(0)`); clobbering the window fails arm 1 (`GameOver`
vs `Priority`), with arm 2 passing first, so the arms are separately live. A pure
revert of the reorder PASSES, which is the honest statement that no current
fixture reaches the divergence.

Gate at this tree: fmt 0, clippy --workspace --all-targets -D warnings 0, lib
18501 passed / 0 failed / 6 ignored, integration 4515 passed / 0 failed / 2
ignored (4514 -> 4515 is exactly the one new row).

Assisted-by: ClaudeCode:claude-opus-5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant