@@ -27,7 +27,9 @@ alias ExPhil.Agents.Agent
2727alias 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
3234policy_path = opts [ :policy ] || raise "--policy required"
3335fixture = opts [ :fixture ] || "test/fixtures/replays/fox_multishine_closed.slp"
@@ -49,7 +51,18 @@ Output.banner("Policy vs its own fixture (offline)")
4951Output . puts ( "policy: #{ Path . basename ( policy_path ) } " )
5052Output . 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+ )
5366Agent . 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
6881n = 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
79109Output . 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("")
91128Output . puts ( "press rates policy: B=#{ Float . round ( pb_rate * 100 , 1 ) } % X=#{ Float . round ( px_rate * 100 , 1 ) } %" )
92129Output . 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
96138cond 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