Skip to content

Commit 5cc5658

Browse files
harness: offline-vs-live agreement gate + fix the discriminator itself
(GOTCHA #85) Wires eval_policy_on_fixture.exs into eval_live_protocol.sh (10s, no Dolphin, advisory, recorded in protocol.txt). Fixing it to be meaningful required two changes: an offset SWEEP (0..8) replacing the delay-0-era two-offset compare, and a --delay-id flag. ms_g6_sp1 goes from a misleading 0.33 to 0.999 at offset 5 — its exact trained convention, re-derived from the weights. Caveat recorded: teacher-forced, so it cannot see closed-loop collapse (an untrained id still scores 0.93); CycleSim is the complementary instrument. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SuqdnRgbvmFFduhyw3PUpy
1 parent 316e85d commit 5cc5658

3 files changed

Lines changed: 94 additions & 16 deletions

File tree

docs/reference/GOTCHAS.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3300,3 +3300,23 @@ embeddable game state). Rule: EVERY human/pressure session that might
33003300
feed training passes --replay-dir eval_runs/<name>/ at launch. The
33013301
eval protocol does this implicitly (its r*.slp ARE the collected pool);
33023302
ad-hoc play launches must do it explicitly.
3303+
3304+
## 85. The offline fixture discriminator was delay-0-era: two offsets, no delay-id
3305+
3306+
`scripts/eval_policy_on_fixture.exs` compared the policy's emission only
3307+
against controller_N and controller_N+1, and constructed its Agent without
3308+
a `delay_id`. Both assumptions predate the delay campaign. Consequence
3309+
(measured 2026-08-03 while wiring it into the eval protocol): the two
3310+
best policies in the repo — 380-434 shines/min live — scored ~0.33-0.40
3311+
"agreement", which reads as "never learned the mapping" and would send a
3312+
reader hunting a nonexistent training bug. With an offset SWEEP (0..8)
3313+
and the trained id, ms_g6_sp1 scores **0.999 at offset 5** — exactly its
3314+
trained `frame_delay 3 + pipeline_offset 2`. The argmax offset is now the
3315+
useful output: it MEASURES the policy's effective label shift from the
3316+
weights alone.
3317+
3318+
Second lesson, recorded at the same time: this eval is TEACHER-FORCED on
3319+
fixture states, so it cannot see closed-loop mode collapse. An UNTRAINED
3320+
delay-id (catastrophic live: 71/min vs 434) still scores 0.93 offline.
3321+
Offline agreement answers "did it learn the mapping"; CycleSim answers
3322+
"does the closed loop hold". Use both.

scripts/eval_live_protocol.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,18 @@ for i in $(seq 1 "$RUNS"); do
113113
grep -a "Final stats\|Staleness\|skipped\|SD FAILED\|replay finalized" "$OUTDIR/r$i.log" | sed "s/^/ r$i /"
114114
done
115115

116+
# Offline-vs-live discriminator (task #21, 2026-08-03). ~10s, no Dolphin.
117+
# Recorded BEFORE scoring so every block carries the number: high offline
118+
# agreement + poor live numbers => STATE-STREAM / delay problem, not a
119+
# learning failure (the distinction GOTCHA #81 took four escalations to
120+
# reach). Advisory, not fatal — we have no calibrated threshold yet, and a
121+
# hard gate on an uncalibrated number would block good runs.
122+
echo "=== offline fixture agreement"
123+
FIXTURE_AGREE=$(XLA_TARGET="${XLA_TARGET_EVAL:-cpu}" mix run scripts/eval_policy_on_fixture.exs \
124+
--policy "$POLICY" 2>&1 | grep -a "FIXTURE_AGREEMENT" | tail -1)
125+
echo " ${FIXTURE_AGREE:-(unavailable)}"
126+
echo "fixture_agreement: ${FIXTURE_AGREE:-unavailable}" >> "$OUTDIR/protocol.txt"
127+
116128
echo "=== scoring"
117129
SLPS=$(ls "$OUTDIR"/r*.slp 2>/dev/null)
118130
if [ -z "$SLPS" ]; then echo "no replays captured" >&2; exit 4; fi

scripts/eval_policy_on_fixture.exs

Lines changed: 62 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ alias ExPhil.Agents.Agent
2727
alias ExPhil.Training.Output
2828

2929
{opts, _, _} =
30-
OptionParser.parse(System.argv(), strict: [policy: :string, fixture: :string, port: :integer, limit: :integer])
30+
OptionParser.parse(System.argv(),
31+
strict: [policy: :string, fixture: :string, port: :integer, limit: :integer, delay_id: :integer]
32+
)
3133

3234
policy_path = opts[:policy] || raise "--policy required"
3335
fixture = opts[:fixture] || "test/fixtures/replays/fox_multishine_closed.slp"
@@ -49,7 +51,18 @@ Output.banner("Policy vs its own fixture (offline)")
4951
Output.puts("policy: #{Path.basename(policy_path)}")
5052
Output.puts("fixture: #{Path.basename(fixture)} (#{length(frames)} frames)")
5153

52-
{:ok, agent} = Agent.start_link(policy_path: policy_path, deterministic: true)
54+
# --delay-id N: delay-id policies have per-id behavioral MODES, and an
55+
# UNTRAINED id is catastrophic (2026-08-03: ms_g6_sp1 scores 434/min at a
56+
# trained id, 71 at an untrained one). Defaulting to 0 made this script
57+
# report ~0.40 agreement for two record-setting policies — a phantom
58+
# "training bug" that is really an untrained-mode measurement. Pass the id
59+
# the policy was trained on (LATENCY_ARCHITECTURE: sp1 => 2 or 3).
60+
{:ok, agent} =
61+
Agent.start_link(
62+
policy_path: policy_path,
63+
deterministic: true,
64+
delay_id: opts[:delay_id] || 0
65+
)
5366
Agent.warmup(agent)
5467

5568
# Compare against the input recorded on the SAME frame (delay 0) and on the
@@ -66,19 +79,43 @@ results =
6679
|> Enum.reject(&is_nil/1)
6780

6881
n = length(results)
82+
arr = List.to_tuple(results)
83+
84+
# Offset SWEEP (widened 2026-08-03). The original compared only offsets 0
85+
# and 1 — a delay-0-era assumption. Delay-trained policies emit the action
86+
# for frame N+d+pipeline_offset (measured intrinsic +2), so the champion
87+
# recipes land at offset ~4-5 and scored ~0.33 against the old two-offset
88+
# comparison, which reads as "never learned the mapping" when it is
89+
# actually "learned a different, correct convention". The argmax offset is
90+
# itself the useful output: it MEASURES the policy's effective label shift.
91+
offsets = 0..8
92+
93+
agreements =
94+
Map.new(offsets, fn off ->
95+
pairs = max(n - off, 1)
96+
97+
hits =
98+
Enum.count(0..(n - off - 1)//1, fn i ->
99+
{pb, px, _, _} = elem(arr, i)
100+
{_, _, ab, ax} = elem(arr, i + off)
101+
pb == ab and px == ax
102+
end)
103+
104+
{off, hits / pairs}
105+
end)
69106

70-
same_frame =
71-
Enum.count(results, fn {pb, px, ab, ax} -> pb == ab and px == ax end)
72-
73-
next_frame =
74-
results
75-
|> Enum.zip(tl(results) ++ [nil])
76-
|> Enum.reject(fn {_a, b} -> is_nil(b) end)
77-
|> Enum.count(fn {{pb, px, _, _}, {_, _, ab, ax}} -> pb == ab and px == ax end)
107+
{best_off, best} = Enum.max_by(agreements, fn {_off, a} -> a end)
78108

79109
Output.puts("")
80-
Output.puts("B/X agreement vs controller_N (delay 0): #{Float.round(100.0 * same_frame / max(n, 1), 1)}%")
81-
Output.puts("B/X agreement vs controller_N+1 (delay 1): #{Float.round(100.0 * next_frame / max(n - 1, 1), 1)}%")
110+
111+
Output.puts(
112+
"B/X agreement by offset: " <>
113+
Enum.map_join(offsets, " ", fn o ->
114+
"#{o}=#{Float.round(100.0 * agreements[o], 1)}%"
115+
end)
116+
)
117+
118+
Output.puts("best offset #{best_off} (#{Float.round(100.0 * best, 1)}%)")
82119

83120
# What the policy is actually pressing — a policy stuck on "B always" shows
84121
# up here instantly, and no agreement number explains that as clearly.
@@ -91,14 +128,23 @@ Output.puts("")
91128
Output.puts("press rates policy: B=#{Float.round(pb_rate * 100, 1)}% X=#{Float.round(px_rate * 100, 1)}%")
92129
Output.puts(" fixture: B=#{Float.round(ab_rate * 100, 1)}% X=#{Float.round(ax_rate * 100, 1)}%")
93130

94-
best = max(same_frame / max(n, 1), next_frame / max(n - 1, 1))
131+
# Machine-readable summary for the eval-protocol gate (task #21). Keep this
132+
# line's shape stable — eval_live_protocol.sh greps it.
133+
Output.puts(
134+
"FIXTURE_AGREEMENT best=#{Float.round(best, 4)} offset=#{best_off} " <>
135+
"delay_id=#{opts[:delay_id] || 0} n=#{n}"
136+
)
95137

96138
cond do
97139
best > 0.95 ->
98140
Output.success(
99-
"Policy reproduces the fixture offline. A live failure is then a STATE-STREAM " <>
100-
"problem (parsed vs live action_frame, GOTCHAS #81) or an action_delay mismatch — " <>
101-
"not a learning failure."
141+
"Policy reproduces the fixture offline at offset #{best_off}. A live failure is " <>
142+
"then a STATE-STREAM problem (parsed vs live action_frame, GOTCHAS #81), a " <>
143+
"delay/id mismatch, or a CLOSED-LOOP failure this eval cannot see — it is " <>
144+
"teacher-forced on fixture states, so mode collapse that only appears on " <>
145+
"self-generated histories scores high here (measured: an UNTRAINED delay-id " <>
146+
"still scores 0.93 offline while collapsing live). Use CycleSim for the " <>
147+
"closed-loop question; the two instruments are complementary."
102148
)
103149

104150
abs(pb_rate - ab_rate) > 0.2 or abs(px_rate - ax_rate) > 0.2 ->

0 commit comments

Comments
 (0)