Skip to content

feat(coordinator): add the SPEC §3.5 alternative heads (R10 ablation parity) - #416

Merged
James-CUDA merged 1 commit into
James-CUDA:mainfrom
minion1227:minion_head_variants
Jul 23, 2026
Merged

feat(coordinator): add the SPEC §3.5 alternative heads (R10 ablation parity)#416
James-CUDA merged 1 commit into
James-CUDA:mainfrom
minion1227:minion_head_variants

Conversation

@minion1227

@minion1227 minion1227 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

The gap

SPEC §1.3 invariant R10"linear head ≥ all other head variants overall" (Table 3) — is the justification for shipping a plain linear routing head. The verifier for R10 is already merged (analysis/head_variants.py, #352): hand it a {variant: {benchmark: score}} table and it reports the overall ranking and the R10 verdict.

But nothing in src/ could ever produce a non-linear head, so the table it grades had to be typed in by hand from the paper. coordinator/ contains only head.py (LinearHead). This PR is the producer.

Four coordinator.head config knobs — type, hidden_dim, include_stop_action, factorize — also had zero readers anywhere in src/, so editing them silently did nothing.

What's here

coordinator/heads.py adds low-rank, sparse, block-diagonal-2 and block-diagonal-10 per SPEC §3.5. Each maps h → z and splits into the same two logit groups the linear head uses (z[:n_models] agent, z[n_models:] role, one softmax each), so a variant is a drop-in replacement at the decision boundary. param_counts() produces the params mapping the merged R10 verifier already accepts, for Table 3's efficiency column.

head           params        (at our n_a=6, d_h=1024)
low_rank       14,420
sparse          7,170
linear          6,144
block_diag_2    3,072
block_diag_10   1,024

Two SPEC problems this surfaced

Reproducing Table 3 exactly turned up two inconsistencies. Both are documented and pinned by tests rather than quietly worked around — flagging them is the point, since R10 is graded against these numbers.

1. Low-rank is internally inconsistent. A low-rank head is U ∈ R^{r×d_h} plus V ∈ R^{n_a×r} (σ is fixed, not a parameter) = r·(d_h + n_a). At the paper's d_h=1024, n_a=10 and the stated r=14, that is 14,476 — not the tabled 20,680, which requires r=20. The two cannot both hold.

This keeps the stated hyperparameter r=14 as the default, on the grounds that a hyperparameter is the more direct claim and silently switching to r=20 to make a derived total match would hide the conflict. Pass rank=20 to reproduce the table. Pinned by test_lowrank_rank14_contradicts_spec_table3.

2. d_h=1024 is not divisible by 10. SPEC's headline variant — block-diag-10, "one block per logit", "exact 10× reduction" to 1,024 params — is unconstructible with equal input chunks at the paper's own shape (1024/10 = 102.4).

The resolution: B must divide n_a (output logits split cleanly into blocks) but need not divide d_h. Inputs are partitioned into B contiguous near-equal chunks summing to exactly d_h (e.g. 103,103,103,103,102,…). Because the chunk sizes sum to d_h, the count is exactly (n_a/B)·d_h — no rounding, at any d_h — reproducing both 5,120 and 1,024. Requiring B | d_h instead would make the paper's own headline variant impossible to build.

Every other Table 3 count (linear 10,240; sparse 11,266; block-diag-2 5,120; block-diag-10 1,024) is reproduced exactly at the paper's shape.

Sparse's ρ carries no gradient, stated plainly

SPEC defines k = max(1, ⌊d_h·(1−σ(ρ))⌋). A floor to an integer has zero derivative almost everywhere, so ∂L/∂ρ is structurally zero however the mask is relaxed — the Gumbel relaxation makes which edges survive differentiable, not how many. ρ is kept as a parameter because SPEC's +2 counts it and because the gradient-free optimizers this repo actually uses for the head (sep-CMA-ES, Random Search) can search it. test_sparse_rho_has_no_gradient_path asserts this rather than hiding it. Only τ is gradient-trainable of the two.

Why it's low-risk

  • Purely additive. params.py and policy.py are untouched. make_spec() computes n_head = n_a·d_h, which is linear-only and feeds the frozen submission dimension — wiring a variant into that path would change n_total and break submission, so this deliberately does not. Tests pin make_spec().n_total == 13312 and that policy still builds LinearHead.
  • No default changes. from_config on the shipped configs/trinity.yaml reproduces exactly today's linear head (test_from_config_builds_the_shipped_config_unchanged).
  • The registry reuses the shipped head, it does not re-implement it: make_head("linear") returns head.py's LinearHead, pinned identical by test_registry_linear_matches_head_py.
  • Dead knobs are now honoured or rejected, never silently ignored: hidden_dim != 0, include_stop_action, and a non-two_softmax factorize each raise with an explanation, since no SPEC §3.5 head implements them.

Verification

  • pytest tests/ -q1911 passed, 0 failed (81 new)
  • ruff check src/ scripts/repo_governance/ → clean; mypy src/trinity/coordinator/heads.py → clean
  • End-to-end: param_counts() feeding the merged analyze_heads() verifier returns the R10 verdict

Tests import torch lazily inside the test bodies and parametrize over a literal variant list (pinned against the registry by test_variants_list_matches_registry), because pytest imports test modules at collection time — a module-scope torch import would break test_shaped_fitness.py::test_no_torch_imported regardless of filename.

Note on CI: main has unresolved merge-conflict markers in src/trinity/analysis/__init__.py (introduced by dc15f97), so the package does not import and this PR is red on the merge commit. This branch touches no file in analysis/, and the 1911-passed run above was taken with those markers resolved locally.

The fix is not contested: #402, #405, #407, #408 and #403 all rewrite that file to the byte-identical result (every one is index 9916618..6865bc2), so merging any one of them repairs main and the rest become no-ops on that file. Note that #407 / #408 / #403 are already green — they touch that file for their own feature and resolved it in passing. Only PRs that don't touch it (this one, #415, #412) are red.

🤖 Generated with Claude Code

…parity)

SPEC §1.3 invariant R10 — "linear head >= all other head variants overall"
(Table 3) — is the justification for shipping a plain linear routing head. The
*verifier* for R10 is already merged (`analysis/head_variants.py`, James-CUDA#352): hand it
a {variant: {benchmark: score}} table and it reports the ranking and the R10
verdict. But nothing in `src/` could ever *produce* a non-linear head, so the
table it grades had to be typed in by hand from the paper. This is the producer.

Adds low-rank, sparse, block-diagonal-2 and block-diagonal-10 (SPEC §3.5), each
mapping h -> z and splitting into the same two logit groups the linear head uses,
so a variant is a drop-in replacement at the decision boundary. `param_counts()`
feeds the verifier's `params` argument for Table 3's efficiency column.

Two SPEC problems surfaced while reproducing Table 3 exactly; both are documented
and pinned by tests rather than quietly worked around:

- **low-rank is internally inconsistent.** A low-rank head is r*(d_h + n_a)
  parameters, which is 14,476 at the stated r=14 — not the tabled 20,680, which
  needs r=20. The two cannot both hold. The stated *hyperparameter* is kept as the
  default (it is the more direct claim); `rank=20` reproduces the table.
- **d_h=1024 is not divisible by 10.** SPEC's headline block-diag-10 ("one block
  per logit", "exact 10x reduction" to 1,024 params) is unconstructible with equal
  input chunks, at the paper's own shape. Blocks therefore partition the inputs
  *near-equally*; since the chunk sizes sum to d_h, the count is exactly
  (n_a/B)*d_h at any d_h, reproducing both 5,120 and 1,024. Requiring B | d_h
  would make the paper's own variant impossible to build.

Sparse's rho carries no gradient by construction (it sets an integer k through a
floor, so dL/drho is structurally zero however the mask is relaxed). It stays a
parameter because SPEC's "+2" counts it and the gradient-free optimizers this repo
uses for the head can search it; `test_sparse_rho_has_no_gradient_path` asserts
this rather than hiding it.

`from_config` is the first reader of the `coordinator.head` block: `type`,
`hidden_dim`, `include_stop_action` and `factorize` had no consumer anywhere in
`src/`, so editing them silently did nothing. They are now honoured or rejected
with an explicit error. Feeding it the shipped configs/trinity.yaml reproduces
exactly today's linear head.

Purely additive: `params.py` and `policy.py` are untouched, with tests pinning
`make_spec().n_total == 13312` (the frozen submission dimension, which is
linear-only) and that policy still builds LinearHead. No default changes.

Tests import torch lazily inside the test bodies and parametrize over a literal
variant list, because pytest imports test modules at *collection* time — a
module-scope torch import would break
`test_shaped_fitness.py::test_no_torch_imported` regardless of filename.

Full suite: 1911 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@James-CUDA
James-CUDA force-pushed the minion_head_variants branch from e3453a1 to 2882cdd Compare July 23, 2026 11:38
@James-CUDA
James-CUDA merged commit ecf7150 into James-CUDA:main Jul 23, 2026
1 of 2 checks passed
@minion1227
minion1227 deleted the minion_head_variants branch July 23, 2026 11:45
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.

2 participants