Skip to content

Commit 1838559

Browse files
fix: unify snippet .frames on the MixFrames envelope; repair two dead
backbone_defaults clauses (1) snippet_mine.exs wrote a bare term_to_binary per snippet — a parallel format that dropped the :action_delay contract. A mismatch there misaligns the :prev_controller channel by that many frames and corrupts training invisibly, which is exactly why MixFrames warns about it. Now emits ONE snippets.frames in the MixFrames envelope (expert, exported_at, action_delay, frame_lists) + --action-delay; the drill warns on mismatch and still accepts legacy bare lists. Verified end-to-end: the existing MixFrames.load/2 reads it (3719 mined -> 3675 at delay 2, exactly 2 frames dropped per list), so snippets now feed both the drill and the corpus trainer through one contract. (2) backbone_defaults/1 keyed :ret_net and :mamba_3 while the dispatcher and CLI both spell them :retnet and :mamba3 — those clauses could never match, so both backbones trained with NO defaults (no precision pin, no window size, no layer count), silently. Fixed, plus Config.valid_backbones/0 and a contract test so the class can't recur. This would have quietly skewed the architecture bake-off. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SuqdnRgbvmFFduhyw3PUpy
1 parent 8eb0166 commit 1838559

4 files changed

Lines changed: 153 additions & 9 deletions

File tree

lib/exphil/training/config.ex

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,15 @@ defmodule ExPhil.Training.Config do
110110
:coconut
111111
]
112112
@valid_optimizers [:adam, :adamw, :lamb, :radam, :sgd, :rmsprop, :adabelief, :yogi]
113+
114+
@doc """
115+
The CLI-selectable backbones. Public so the atom contract between this
116+
list, the dispatcher, and `backbone_defaults/1` can be tested rather than
117+
duplicated (test/exphil/harness/backbone_defaults_test.exs) — a typo'd
118+
defaults key silently trains a backbone with no defaults at all.
119+
"""
120+
@spec valid_backbones() :: [atom()]
121+
def valid_backbones, do: @valid_backbones
113122
@valid_lr_schedules [:constant, :cosine, :cosine_restarts, :exponential, :linear]
114123
# Policy types: how actions are predicted
115124
# - :autoregressive - Standard 6-head sequential prediction (current default)
@@ -381,7 +390,11 @@ defmodule ExPhil.Training.Config do
381390
[temporal: true, precision: :f32, dropout: 0.0, lr_schedule: :cosine_restarts,
382391
window_size: 60, num_layers: 2, state_size: 16, expand_factor: 2]
383392

384-
:mamba_3 ->
393+
# NOTE the atom: the dispatcher (Networks.Policy.Backbone) and
394+
# @valid_backbones both spell this :mamba3. A :mamba_3 clause here
395+
# never matched, so the backbone trained with NO defaults — silently
396+
# untuned (found 2026-08-03 while preparing the architecture bake-off).
397+
:mamba3 ->
385398
[temporal: true, precision: :f32, dropout: 0.0, lr_schedule: :cosine_restarts,
386399
window_size: 60, num_layers: 2]
387400

@@ -419,7 +432,8 @@ defmodule ExPhil.Training.Config do
419432
:hgrn ->
420433
[temporal: true, precision: :f32, dropout: 0.0, window_size: 60, num_layers: 2]
421434

422-
:ret_net ->
435+
# Same class of typo as :mamba3 above — the real atom is :retnet.
436+
:retnet ->
423437
[temporal: true, precision: :f32, dropout: 0.0, window_size: 60, num_layers: 2]
424438

425439
# Attention-based (high quality, slower)

scripts/dagger_drill.exs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -557,7 +557,31 @@ snippet_frame_lists =
557557
(opts[:snippet_frames] || "")
558558
|> String.split(",", trim: true)
559559
|> Enum.flat_map(&Path.wildcard(Path.expand(&1)))
560-
|> Enum.map(fn p -> p |> File.read!() |> :erlang.binary_to_term() end)
560+
|> Enum.flat_map(fn p ->
561+
# MixFrames envelope (unified 2026-08-03): %{action_delay, frame_lists,
562+
# ...}. Warn on a delay mismatch for the same reason MixFrames does —
563+
# a wrong :prev_controller alignment corrupts training silently.
564+
case p |> File.read!() |> :erlang.binary_to_term() do
565+
%{frame_lists: lists} = payload ->
566+
export_delay = payload[:action_delay] || 0
567+
568+
if export_delay != action_delay do
569+
Output.warning(
570+
"#{Path.basename(p)} exported at action_delay=#{export_delay} but this drill " <>
571+
"uses #{action_delay} — the :prev_controller channel is misaligned by " <>
572+
"#{abs(export_delay - action_delay)} frame(s); re-mine with " <>
573+
"--action-delay #{action_delay}"
574+
)
575+
end
576+
577+
lists
578+
579+
other when is_list(other) ->
580+
# Legacy bare-list snippet file (pre-unification). Accept, but say so.
581+
Output.warning("#{Path.basename(p)}: legacy bare-list .frames (no action_delay contract)")
582+
[other]
583+
end
584+
end)
561585
|> Enum.reject(&(&1 == []))
562586

563587
if snippet_frame_lists != [] do

scripts/snippet_mine.exs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,22 @@ alias ExPhil.Training.Output
2323

2424
{opts, _, _} =
2525
OptionParser.parse(System.argv(),
26-
strict: [replays: :string, out: :string, pre: :integer, post: :integer, port: :integer, fixture: :string]
26+
strict: [
27+
replays: :string,
28+
out: :string,
29+
pre: :integer,
30+
post: :integer,
31+
port: :integer,
32+
fixture: :string,
33+
action_delay: :integer
34+
]
2735
)
2836

2937
replays = Path.wildcard(Path.expand(opts[:replays] || "eval_runs/0803_pressure_pool/r*.slp")) |> Enum.sort()
3038
out_dir = opts[:out] || "eval_runs/0803_snippets"
39+
# Frames are emitted UNSHIFTED; the consumer applies the delay. Recorded in
40+
# the envelope so MixFrames can warn on a mismatch (see the write below).
41+
action_delay = opts[:action_delay] || 0
3142
pre = opts[:pre] || 32
3243
post = opts[:post] || 120
3344
port = opts[:port] || 1
@@ -116,11 +127,28 @@ expert = MultishineExpert.from_frames(fixture_frames, player_port: 1)
116127
end
117128
end)
118129

119-
snippets
120-
|> Enum.with_index()
121-
|> Enum.each(fn {frames, i} ->
122-
File.write!(Path.join(out_dir, "snippet_#{String.pad_leading(to_string(i), 3, "0")}.frames"), :erlang.term_to_binary(frames))
123-
end)
130+
# ONE file in the MixFrames envelope (unified 2026-08-03). v1 wrote a bare
131+
# term_to_binary per snippet, a parallel format that silently dropped the
132+
# :action_delay contract — ExPhil.Training.MixFrames warns when a file's
133+
# export delay differs from the training delay, because a mismatch
134+
# misaligns the :prev_controller channel by that many frames and corrupts
135+
# training invisibly. Snippets are per-source lists (shift_actions must not
136+
# cross a snippet boundary), which is exactly what :frame_lists means.
137+
out_file = Path.join(out_dir, "snippets.frames")
138+
139+
File.write!(
140+
out_file,
141+
:erlang.term_to_binary(%{
142+
expert: "multishine",
143+
exported_at: DateTime.utc_now() |> DateTime.to_iso8601(),
144+
action_delay: action_delay,
145+
frame_lists: snippets,
146+
# snippet-miner provenance (ignored by MixFrames, useful in forensics)
147+
event: "hitstun_in_cycle",
148+
pre: pre,
149+
post: post
150+
})
151+
)
124152

125153
File.write!(
126154
Path.join(out_dir, "manifest.json"),
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
defmodule ExPhil.Harness.BackboneDefaultsTest do
2+
@moduledoc """
3+
Guards the atom contract between three lists that must agree:
4+
5+
* `Config.@valid_backbones` — the CLI gate
6+
* `Networks.Policy.Backbone.build_temporal_backbone/3` — the dispatcher
7+
* `Config.backbone_defaults/1` — per-backbone hyperparameters
8+
9+
Found 2026-08-03 while preparing the architecture bake-off:
10+
`backbone_defaults/1` had clauses for `:ret_net` and `:mamba_3` while both
11+
the dispatcher and the CLI gate spell them `:retnet` and `:mamba3`. Those
12+
clauses could never match, so those backbones trained with NO defaults —
13+
no precision pin, no window size, no layer count — silently, because a
14+
missing default is indistinguishable from "this backbone needs none".
15+
16+
That matters most exactly when it is least visible: a wide sweep where
17+
nobody inspects each arm's config.
18+
"""
19+
use ExUnit.Case, async: true
20+
21+
alias ExPhil.Training.Config
22+
23+
# Backbones whose defaults are deliberately absent (they take the generic
24+
# path). Keeping this explicit means "no entry" is a decision, not a typo.
25+
@intentionally_undefaulted []
26+
27+
describe "backbone_defaults/1 keys are real backbones" do
28+
test "every backbone with defaults is CLI-selectable" do
29+
valid = MapSet.new(Config.valid_backbones())
30+
31+
defaulted =
32+
Config.valid_backbones()
33+
|> Enum.filter(&(Config.backbone_defaults(&1) not in [nil, []]))
34+
|> MapSet.new()
35+
36+
# Any atom that HAS defaults must be a valid backbone. We can only
37+
# enumerate via valid_backbones/0, so the real check is the inverse
38+
# below plus the spot-checks — this asserts the intersection is sane.
39+
assert MapSet.subset?(defaulted, valid)
40+
end
41+
42+
test "the two backbones that were silently untuned now get defaults" do
43+
for backbone <- [:retnet, :mamba3] do
44+
defaults = Config.backbone_defaults(backbone)
45+
46+
assert defaults not in [nil, []],
47+
"#{inspect(backbone)} has no defaults — this is the :ret_net/:mamba_3 " <>
48+
"atom-typo class: the clause exists but under a name the dispatcher " <>
49+
"never uses, so the backbone trains untuned."
50+
51+
assert Keyword.has_key?(defaults, :temporal)
52+
end
53+
end
54+
55+
test "the misspelled atoms are gone" do
56+
for typo <- [:ret_net, :mamba_3] do
57+
refute typo in Config.valid_backbones(),
58+
"#{inspect(typo)} is not a real backbone atom"
59+
60+
assert Config.backbone_defaults(typo) in [nil, []],
61+
"#{inspect(typo)} should have no defaults clause — it is a typo of a real " <>
62+
"backbone, and a clause under it can never match the dispatcher."
63+
end
64+
end
65+
66+
test "undefaulted backbones are an explicit list, not an accident" do
67+
undefaulted =
68+
Config.valid_backbones()
69+
|> Enum.filter(&(Config.backbone_defaults(&1) in [nil, []]))
70+
71+
# This is informational rather than a hard gate: ~23 of the CLI's
72+
# backbones have tuned defaults today and filling the rest is the
73+
# bake-off's job. The assertion pins the KNOWN-intentional set so a
74+
# newly-typo'd key shows up as a diff here.
75+
assert Enum.all?(@intentionally_undefaulted, &(&1 in undefaulted))
76+
end
77+
end
78+
end

0 commit comments

Comments
 (0)