Skip to content

staging → master - #208

Open
george-connito wants to merge 42 commits into
masterfrom
staging
Open

staging → master#208
george-connito wants to merge 42 commits into
masterfrom
staging

Conversation

@george-connito

@george-connito george-connito commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Rolls up PRs #204#215.


The headline: validators now score on the full expert set

Today a validator evaluates a miner using only its partial slice of the model. After this release it evaluates using
all 64 experts per layer, the same topology the reference experiment trains and measures with.

evaluation.full_topology_eval ships locked at true, not as an opt-in. Locked fields are reset to their class default on every start (--auto_update_config defaults to True), which is the only channel that
reaches a running validator's settings. Watchtower replaces the image and never touches an operator's config.yaml.

Two fields are locked with it, because the startup guard
(check_full_topology_eval_supported) rejects the other combinations:

Field Now Why
evaluation.full_topology_eval true the change being enforced
model.precision bf16-mixed the base checkpoint's own dtype
moe.partial_moe true a full global model cannot merge (see below)

How it fits in memory

The obvious approach (just set partial_moe: false) was measured off a cliff twice over. The merge step needs params + gradients + SGD momentum all resident: 87.8 GiB for 15.71 B params, against 44.4 GiB of VRAM. And
per-miner eval deepcopies the base, a second 29.3 GiB.

So instead: global_model stays partial (merge, outer optimizer, chain hash, peer sync all unchanged), and a separate frozen full-topology model is used only for scoring. It has no gradients, no optimizer state, and is
parked on CPU outside the eval window so the merge phase never shares the GPU with it. Miner shards are grafted into it in place, with the touched rows backed up and restored afterward.

This is the reference experiment's own design translated over — it keeps trainable local experts apart from a frozen full expert set on CPU that swaps in for merged-topology eval.

What operators need

Host RAM + swap ≥ 80 GB. The full base is ~29 GB parked on host, plus a
~38 GB transient while it builds. Validators short on host memory will
crash-loop, not degrade quietly. VRAM ≥ 48 GB (46 GB is not enough).


Everything else

Making the full expert path actually work (#210, #211, #212)

The full-model code path existed but had never been exercised. Three real bugs fell out:

PR Bug Effect before the fix
#210 Full path never loaded pretrained experts 14.39 B of 15.71 B params were random, silently — strict=False swallowed 4992 unmatched keys
#210 Expert shards fetched as .pt, not .safetensors Validators 404'd on every checkpoint published in the last 3 months; a validator losing local state could never recover the model
#211 moe.partial_moe was declared but read by nothing; full_topk was 2 Full branch unreachable from any config; and it would have routed at the wrong width (base checkpoint's native is 6)
#212 Model built in fp32, then cast Peaked at 59.6 GB on a 62 GB host for a ~31 GB model. Now 38.0 GB. Loaded weights bit-identical

Quantization (#205, #209)

Storage format switched int8 → fp8 (e4m3) to match experiment repo exactly, so loss measurements transfer between the two codebases. Scope narrowed to expert projections only (the reference quantizes nothing else; we were also converting 162 backbone Linears, which broke comparability).

fp8 is weight storage only — weights are dequantized to the activation dtype for compute. Both formats are one byte per weight, so this buys no memory (measured identical at 8.428 GB) and costs 3.19× fidelity.

Validators refuse fp8 at startup unless CONNITO_ALLOW_VALIDATOR_FP8 is set, and it is now additionally incompatible with full-topology scoring — so on this release, validator quantization is off, period.

Dedup filter (#204)

Flags near-duplicate submissions (copy + noise, or a brief finetune) by averaging two miners' models and checking whether the merged loss got worse. Catches what the existing exact-score-tie rule misses: a 1-ULP perturbation on
2 scalars out of 1.77 B is enough to slip past it
(docs/diagnosis-2026-08-10-duplicate-submissions.md).

Three modes: off (fully inert), shadow (measure and log only),enforce (zeroes both sides of a flagged pair).

Should run shadow for a cycle or two and set the threshold from that data first.

Enabling shadow costs ~7 GB more disk (shard retention widens from top_k_miners_to_reward todedup_top_k) and reserves the eval window's tail (20 min by default).

Observer mode (#207)

Lets a second validator run on a live production hotkey for testing while submitting no chain extrinsic, skipping the DHT, and declining HF upload. Requires CONNITO_VALIDATOR_OBSERVER=1 exactly.

Notably, an observer now only needs the hotkey's public address, never the private key — so a live validator's private key doesn't have to be copied onto a shared test box. A public-only keyfile can't act under the shared identity
even if the flag is wrong; it fails loudly instead.

Stability fixes found by running this live (#213, #214, #215)

Each of these was a crash observed on the tester validator, not a theoretical concern:

  • Graft backups were cloning onto the GPU. First live full-topology round scored zero miners — baseline fit at 39.5 GiB, then all three miners OOM'd at 42.9 GiB with 213 MB free. Backups are now pooled on the host.
  • Outer-optimizer momentum leaked across rounds. Crash-looped on a ~2-cycle rhythm overnight (23:16, 02:47, 06:18, 09:45); allocated VRAM went 9,513 MB → 19,028 MB. SGD momentum is now parked on the host between rounds.
  • Grad buffers were allocated and deleted a line later; downloads were being killed mid-flight; park() was itself allocating.

Validated live

Observer-mode validator on an L40S sharing production's hotkey:

  • Full-topology rounds scored end-to-end (baseline_loss=1.8578, miners merged, outer optimizer step completed)
  • Graft backup / restore verified bit-identical, including orphan restore after a mid-eval failure
  • Isolation held on all three observer channels — zero chain extrinsics, DHT port unbound, HF upload declined
  • fp8 confirmed a scoring no-op when off: per-miner val_loss within 0.05% of production

present42 and others added 29 commits August 10, 2026 12:54
…ssions

Measurement-only pass against near-duplicate submissions (copy + noise /
brief finetune), which evade the existing exact-score-tie penalty in
finalize_round_scores. During idle ticks of the bg-eval worker, average
pairs of the round's top-5 positive-scoring submissions, evaluate the
merged model on the round's cached eval batches, and LOG loss(avg) vs
each side plus delta-cosine similarity — raw merge_penalty and
would-flag verdicts at multiple thresholds, under both candidate
predicates. No enforcement: the pass never touches scores, weights,
journal, or round lifecycle.

- connito/validator/dedup.py: pure helpers (average_state_dicts,
  delta_cosine, select_pairs, shadow_report, recover_val_loss,
  find_submission_path)
- bg-eval worker: incremental one-pair-per-idle-tick pass (real miner
  evals always win the GPU), per-round state keyed by round_id (survives
  the stuck-lock recycler), deepcopy-per-pair (never mutates
  _eval_base_model), gpu_eval_lock acquired in-thread with in-thread
  deadline (the _run_eval precedent)
- retention_top_k(): both prune sites keep top-dedup_top_k files while
  the filter is active so pair files survive until measured
- EvalCfg: dedup_filter_mode ("off"|"shadow", default off, deliberately
  UNLOCKED so shadow can be enabled per-host), dedup_top_k=5,
  dedup_max_pairs=10
- MinerEvalJob.val_loss + Round.scores_snapshot() accessors
- telemetry: pairs-evaluated / would-flag counters (no uid labels)
- tests: connito/test/test_dedup_filter.py (20 cases)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t branch

`get_base_model` carried a 4-bit BitsAndBytes/Unsloth branch gated on
`config.model.use_quantization` / `model.use_unsloth`. Neither field was ever
declared on `ModelCfg`, and `BaseConfig` sets `extra="ignore"`, so the gate
was permanently False and the branch unreachable. It was also wrong in three
further ways had it ever fired: it returned a stock `AutoModelForCausalLM`
instead of the custom MoE class, it sat above the `partial` split so it would
have handed back a full 64-expert model where a partial one was expected, and
`bitsandbytes` is not pinned so `from_pretrained` would have raised ImportError.

Delete it, along with the now-unused `unsloth` pin and its doc references.

Also collapse the four copy-pasted `precision -> model_dtype` blocks into
`helper.resolve_precision` / `helper.resolve_model_dtype`. These have to agree
across sites — a model built at one dtype and autocast at another produces
silently different losses — and they were drifting.

Finally, point pytest at `connito/test` and add a CI test job. `testpaths` had
pointed at a `tests/` directory that has never existed, so a bare `pytest`
collected nothing and reported success, and there was no test job in CI at all.
Four tests already fail on master and are stale rather than broken code; they
are deselected explicitly in the workflow, with the reason for each, so the
list stays visible and shrinks as they are triaged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…default off)

Adds `model.quantization: off | int8`, a runtime weight-only int8
representation. Weights are still loaded and saved at `model.precision`;
int8 only changes how they are held in memory in between.

Hand-rolled rather than bitsandbytes/torchao, for two reasons that are
properties of this codebase rather than preferences:

  * Production never loads weights through `from_pretrained` — both roles take
    the partial path in `get_base_model`, which constructs a bare module and
    streams safetensors into it, so a `quantization_config` has no effect.
  * Both libraries only rewrite `nn.Linear`. The routed experts are stacked raw
    3D `nn.Parameter`s consumed by `F.linear` on a slice, so either library
    would silently skip the tensors that dominate memory.

The load-bearing property is that `state_dict()` is dequantization
transparent: a quantized module serialises exactly the keys, shapes and dtypes
it would unquantized (int8 values and scales are persistent=False buffers) and
re-quantizes plain fp16/bf16 on the way back in. That is what lets the
checkpoint hash contract, the validator's per-miner graft and the save path
keep working untouched, and it is covered by tests.

Scope differs by role, and not for want of trying. On a validator eval model
the whole module is frozen, so the routed experts can be quantized wholesale.
On a miner they cannot: each layer's stacked tensor interleaves the trainable
group with the frozen helper group, and `freeze_parameters` necessarily marks
the whole thing trainable — freezing helper slices would change what the
trainable experts co-adapt against, and therefore change what miners submit.
Miner scope is the frozen non-expert Linears only.

Quantizing the validator's `global_model` is forbidden outright, and asserted:
int8 weights are buffers, so merge and the outer optimizer would silently skip
every converted tensor — no exception, no warning, just decaying vtrust.
`require_not_quantized` guards merge, the round snapshot, save, and the three
streaming loaders that mutate `state_dict()` tensors in place.

Consensus: this changes `val_loss`. A validator on int8 is not comparable with
an fp16 one for the same `combined_seed`. Default off, deliberately unlocked so
per-host staging validation is possible, and reported as a `connito_validator`
telemetry label so divergence is diagnosable. Lock it if it ever becomes the
fleet default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Foreground eval passed `base_model=global_model` directly while the background
worker got its own `deepcopy(global_model)`. That asymmetry is harmless today
but blocks int8: `global_model` cannot be quantized (merge and the outer
optimizer walk `named_parameters()`, and int8 weights are buffers), so
quantizing only the background copy would score some of a round's miners in
int8 and others in fp16, against mismatched baselines, and then rank them
against each other into the `_RANK_TO_SCORE` cliff.

Give foreground its own persistent eval model, re-seeded from the round
snapshot each round — the load routes through the re-quantizing
`_load_from_state_dict`, so the snapshot stays fp16 and nothing here has to
know about int8.

Gated on `model.quantization != "off"`. With the toggle off,
`resolve_foreground_eval_model` returns `global_model` itself and no third
model is ever built, so this is a no-op by construction rather than by
measurement. That matters: an unconditional persistent copy would cost every
fp16 validator ~8-10 GB of resident VRAM for a dormant feature, and off is the
fleet-wide state for the whole shadow period — and permanently if the
rank-preservation gate fails.

The per-miner `copy.deepcopy` in `load_model_from_path` is deliberately left
alone. It copies whatever the base model holds, so an int8 base already makes
every per-miner copy ~2x smaller. Replacing it with in-place grafting would
trade deepcopy's pristine-base guarantee for bookkeeping, in a system where a
1e-6 perturbation is documented to move a miner 54 places; it saves memory in
fp16 too, so it stands alone as its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(dtype=)

`Int8Linear.compute_dtype` and `CustomDeepseekV2Experts._compute_dtype` are
what `_save_to_state_dict` dequantizes into, so a stale value makes
`state_dict()` report a dtype the module no longer uses — which shows up as a
dtype mismatch in the validator's graft path rather than as an obvious error.
`Module.to(dtype=...)` never updated them.

Nothing in production casts a quantized model today: quantization is applied
last, and `require_not_quantized` guards the one `.to(dtype=...)` that could
reach one. But that is an ordering property, and ordering properties rot. Probe
what `_apply`'s fn did to a throwaway float and adopt it, rather than relying on
the guard staying in place. Same treatment the fp32 scales already had.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rank-preservation gate ran, and validator int8 failed it. Measured on an
L40S against 7 real miner shards on the production eval mix (C4 +
Nemotron-CC-Math), 21 batches @ seq 1024:

  - The top-3 ordering changes. `_RANK_TO_SCORE` pays by position
    (2.25/1.5/1.0), so this silently redistributes rewards: brother-winter/bro03
    goes 1st -> 3rd, maximso/con1 goes 2nd -> 1st.
  - int8 manufactures exact val_loss ties out of a tie-free population — 0 pairs
    under fp16, 2 pairs under int8 (con1==con5, con9==con10). The exact-tie
    branch in `finalize_round_scores` then zeroes all four, including the two
    miners int8 itself ranked first and second. int8 creates the collision and
    the scoring rule punishes miners for it.
  - Per-miner perturbation is 0.36x the best-to-worst spread, against the <0.1x
    bar this was gated on.

(An earlier C4-only run put the noise ratio at 1.65x. Nemotron-Math
discriminates miners much better — spread 0.005062 vs 0.001243 — so the
production number is far kinder. It still fails, on ordering and ties rather
than on raw noise.)

Given that, a startup warning is not enough: both failure modes are silent in
production and one of them costs real miners a round's rewards. So refuse to
start, with the numbers in the error, unless CONNITO_ALLOW_VALIDATOR_INT8=1 is
set — an escape hatch for re-running the gate on a staging hotkey, not a tuning
knob. The miner-side toggle is unaffected and needs no override.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`quantize_eval_model_` raises the first time a round needs an eval model, which
is after `Round.freeze` has already locked the roster. An operator who set
`model.quantization: int8` should learn about it while reading the startup log,
not from a traceback part-way through their first cycle.

Check it in `run()` before the telemetry server comes up, routing through the
same function so the explanation and the override stay in one place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Matches the reference implementation in ~/experiment (partial_moe.py:FP8Linear,
17c878d) so loss measurements transfer between the two codebases. Storage
format and forward math only — scope, attachment points and the
dequantization-transparent state_dict() contract are unchanged.

  float8_e4m3fn / QMAX 448, cast without an explicit round or clamp.
  Forward dequantizes in the activation dtype, matching the reference exactly
  (including the stacked-experts path); serialization keeps fp32 arithmetic.
  model.quantization: "int8" -> "fp8"; CONNITO_ALLOW_VALIDATOR_INT8 ->
  CONNITO_ALLOW_VALIDATOR_FP8.

Both formats are one byte per weight, so this buys no memory (measured
identical at 8.428 GB) and costs 3.19x fidelity: 2.645% relative weight error
against int8's 0.829%. Comparability is the whole return.

Three traps fp8 has that int8 did not, each found by probing on an L40S and
each now pinned by a test:

  * float8_e4m3fn IS a floating dtype, so Module.to(dtype=) upcasts the weight
    buffer and the memory saving evaporates with nothing raised. Both _apply
    overrides re-assert the storage dtype. Casting back is exactly lossless.
  * torch.isfinite raises NotImplementedError for fp8 on CPU and CUDA alike.
    The miner's post-setup guard filters buffers on is_floating_point(), which
    int8 failed and fp8 passes, so this would have crashed every fp8 miner at
    startup. quantization.all_finite uses torch.isnan, which is exhaustive
    here: e4m3fn has no infinity encoding.
  * e4m3fn overflow produces NaN, not saturation. Safe only because the per-row
    scale caps |w/scale| at 448.000031 and everything up to 464 rounds back to
    448 — verified over a 200k-row adversarial sweep.

Two dequantization functions, deliberately: in fp16 a row whose scale rounds to
zero (amax < 1.3351e-5) would be silently deleted from a checkpoint by the
activation-dtype multiply, so state_dict() keeps fp32 arithmetic.

The validator refusal now separates int8-measured evidence from fp8 inference:
the rank gate was only ever run under int8 and int8 failed it; fp8 is coarser
and has not been gated in its own right. A test pins that wording.

Also: FP8Linear._save_to_state_dict moves buffers to CPU before dequantizing
rather than after, which is what its comment already claimed (save-path VRAM
overhead 0.168 GB -> 0.000 GB); and _load_from_state_dict no longer allocates a
second full stacked clone per graft just to populate a debug log field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chore: bring staging up to date with master (PRs #196#203)
Running a second validator on a hotkey that a production validator already
uses lets a test instance be evaluated by the real fleet without registering
(and paying for) a separate hotkey. It is only safe if the second process
acts under that shared identity nowhere. `CONNITO_VALIDATOR_OBSERVER=1`
closes the three channels where it otherwise would.

**Chain extrinsics.** All six writes — the startup commit, MinerCommit1,
both ValidatorCommits, and both `set_weights` paths — funnel through
`ChainSubmitter`, so gating its three public methods covers them with no
change at any call site. Suppressing `set_weights` alone would not be
enough: `ValidatorChainCommit` carries a per-cycle `miner_seed` that every
validator hashes into the seed driving fleet-wide miner assignment, and
`build_chain_checkpoints_from_previous_phase` pairs the commit-1 signature
with the commit-2 hash by hotkey — so interleaved commits make the live
validator's own checkpoint fail signature verification.

**Hivemind all-reduce.** `HotkeyAuthorizer` admits any whitelisted hotkey,
so an observer on a shared hotkey is accepted into the same
`expert_averaging-group{id}` prefix and its gradients average into the
fleet's global model. It has to skip the DHT outright; handing hivemind a
throwaway wallet instead just crashes the process, since peers reject the
unwhitelisted signer and the bootstrap raises outside the run loop's `try`.
`sync_grad_across_validators` iterates `group_averagers`, so an empty dict
leaves the merge phase a clean no-op.

**HF upload** needs no code — pointing `hf.token_env_var` at an unset name
makes `get_hf_upload_readiness` decline. Keep the real `HF_TOKEN` in the
environment: `load_dataset` relies on ambient auth and the eval set is gated.

Env var rather than a config field, because `check_and_prompt_locked`
rewrites the YAML on startup and a deployment that does not set it cannot
inherit it. Off by default, and only the exact value `1` enables it, so a
typo cannot silently stop a live validator from submitting.

Also stamps `observer` onto `connito_validator_info` — hotkey and uid are
identical between the two processes by design, so that label is the only
thing distinguishing the two scrapes.
`sign_hash` was the last thing in the validator that needed the hotkey's
private key: no chain extrinsic is submitted in observer mode, and the DHT —
whose `HotkeyAuthorizer` signs every request — is skipped. Its only consumer
is the `SignedModelHashChainCommit` on the next line, which is suppressed
too, so producing the signature was pure cost.

Gating it changes what has to be placed on the test host. An observer now
needs the hotkey's *address*, which is public and already visible on the
metagraph, but never the key that signs with it — so a live validator's
private key does not have to be copied onto a shared box to run one. That is
a stronger guarantee than the env var alone gives: a public-only keyfile
cannot act under the shared identity even if the flag is wrong, and would
fail loudly rather than quietly submit.

The hash itself is still computed — `model_hash` is read on the next line and
drives eval, and `sign_hash` was what lazily triggered it.
feat(validator): observer mode for a validator sharing a live hotkey
…ion-toggle

# Conflicts:
#	connito/shared/telemetry.py
#	connito/validator/run.py
Review question on #205: after `delattr(self, name)` drops the stacked
parameter from `_parameters`, is `self._compute_dtype` still readable?

It is, and the code was already correct — but nothing said so. The
assignment must stay above the `delattr` because it is the last read of
`param`, and it survives because a `torch.dtype` is not a tensor, so
`nn.Module.__setattr__` routes it to `__dict__` instead of `_parameters`.
Both facts are invisible at the call site and a reordering would break
`_stacked_dtype`, `_expert_slice` and `state_dict` at once.
Adds `dedup_filter_mode: "enforce"`, which zeroes BOTH sides of every
pair the merge-loss filter confirms redundant. Generalizes the existing
exact-score-tie rule from bit-identical to near-identical submissions —
a 1-ULP perturbation on 2 scalars out of 1.77B is otherwise enough to
slip past it (docs/diagnosis-2026-08-10-duplicate-submissions.md).

Threshold is pinned at 0, making the decision a pure SIGN test. Measured
on 7 live submissions from round 8814586: pairs containing a miner with a
genuine per-cycle training history came out negative (-4.8e-4, -4.9e-4)
and are not flagged; near-copy and noise-injected pairs came out >= 0
(+1.2e-5 .. +5.8e-4) and are. Every threshold >= 0.01 in SHADOW_THRESHOLDS
flags 100% of live pairs, honest miners included, and is unusable here.

- dedup.py: `compute_merge_penalty` + `is_redundant(penalty, threshold)`.
  Enforcement acts on the UNROUNDED penalty — `shadow_report` rounds to
  6 dp, where a tiny negative becomes `-0.0` and `-0.0 >= 0` is True,
  which would flag an honest pair.
- round.py: `dedup_flagged_uids` + lock-guarded `mark_dedup_flagged`.
- evaluator.py: flagged UIDs are excluded from the rank ladder and get an
  explicit 0, alongside `tied_uids`. Read via `getattr` because
  `finalize_round_scores` also runs against the journal-recovery path's
  duck-typed `_RecoveryRound`, which has no dedup state.
- config.py: `dedup_threshold: float = 0.0`, still unlocked so a single
  host can enforce ahead of the fleet.
- tests: 6 new dedup cases (sign test, threshold-widening, the -0.0
  rounding trap, enforce flags/skips, shadow never enforces) + 2
  finalize_round_scores cases.

KNOWN GAPS, both open before this should be enabled in production:
1. The run-to-run noise floor of `merge_penalty` is unmeasured. Signal is
   ~5e-4; if repeat evals of one pair vary by that much, the sign is not
   stable and this zeroes honest miners at random.
2. Zeroing both sides is safe only if in-round copying is impossible. The
   commit-reveal should guarantee it (MinerCommit1 signs the hash before
   MinerCommit2 reveals), but it is UNVERIFIED that the signature must
   land in the MinerCommit1 window. If it need not, copying a top miner
   becomes a way to zero them.

Separately: the pass is still triggered only on bg-eval idle ticks, and on
production those never occur — `no pending targets` logged 0 times in 100
minutes against a 7m50s eval window. The trigger needs to become
"one pair per K evals" or enforcement will never fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The shadow pass hung entirely off bg-eval idle ticks, and on a full
roster those never arrive. Measured on production: `no pending targets
— going idle` was logged ZERO times in 100 minutes, against a 7m50s eval
window in which the worker completed 44 back-to-back evals before Merge
cut it off with work still queued. The pass never executed once — not
even to skip a pair — so `validator_dedup_pairs_evaluated_total` sat at
0.0 for the whole deploy. There is no spare GPU time to donate.

Adds `dedup_eval_interval` (default 8): run one merged pair after every
N completed evals, so measurement is proportional to work actually done.
At the observed ~44 evals/window that spends ~5 of the 10-pair budget
and adds ~11% to the window. The idle-tick trigger is kept — it is still
the cheapest opportunity when one exists. Set the interval to 0 to
restore idle-only behaviour.

The cadence lives in `_tick_interleaved_dedup()` rather than inline in
the loop so it is testable without driving the whole worker.

- background_eval_worker.py: `_dedup_evals_since_pair` counter, reset per
  round alongside the rest of the dedup state; `_tick_interleaved_dedup`.
- config.py: `dedup_eval_interval: int = 8`.
- tests: cadence fires every N, 0 disables it, counter resets on a new
  round.

Unchanged: every gate inside `_maybe_run_dedup_shadow` still applies, so
an interleaved call during Merge or outside the eval window is a no-op,
and the whole thing is inert while `dedup_filter_mode` is "off".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…placing evals

Reverses the interleave default and replaces it with the window it was
always meant to use.

WHY INTERLEAVING WAS WRONG
The bg-eval window is bounded by phase transitions, not by work remaining
(`eval_window_active.clear()` at MinerCommit1 - 5). It cannot absorb extra
work. A merged pair run mid-scoring therefore DISPLACES a miner eval, and
an unevaluated miner scores 0 for the round — the filter's cost lands on
honest miners. `dedup_eval_interval` now defaults to 0.

WHY IT IS NOT NEEDED
The window spans ValidatorCommit1 → Train, and measured on production the
roster was graded in ~27 min of a ~68 min Train (40 miners, ~41 s each),
leaving ~41 min idle with the eval base model still resident (10,467 MiB,
0% util). Ten pairs need ~7 min of that. The idle-tick trigger already
covers it; `_wait_clear` never tears the model down, so nothing has to be
re-materialised.

Two earlier diagnoses in this branch were wrong and are corrected here:
"the worker never idles" (the `no pending targets` line is suppressed
unless idle_ticks == 0, and idle_ticks is also bumped in the gated branch)
and "the round is empty during Train" (validator_miner_last_scored_round_id
only updates at finalize, so it cannot show the in-flight round).

WHAT THIS ADDS
- Deadline guard. `eval_window_close_block()` computes MinerCommit1 - 5
  from the cycle periods; run.py publishes it as a monotonic deadline when
  it opens the window. The pass refuses to START a pair with less than
  `dedup_pair_budget_sec` (120 s) left, because finalize applies verdicts
  and submits weights in the same step — a later result misses the round
  entirely. Inactive when unpublished, so behaviour is unchanged without it.
- Skip-reason logging. Every early return was silent, which is why a pass
  that never produced a pair was indistinguishable from one never called.
  Each gate now names itself, and `no_pairs` carries scored/positive counts
  — the counts that would have settled this immediately. Logged on CHANGE
  only; the pass is polled every ~2 s across a 68 min phase.
- `dedup_pair_budget_sec` config; `dedup_eval_interval` kept as an escape
  hatch for a roster whose grading already overruns Train.

Guarded: `eval_worker` is None when background_worker_enabled is false, so
the deadline publish is skipped rather than warning every cycle.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_emit_dedup_summary` returned early when `budget_used == 0 and
pairs_skipped == 0`, which silenced exactly the case worth reporting: a
round where the pass WAS reached but every attempt was gated. At round
granularity "produced nothing" and "never called" were indistinguishable
— the ambiguity that made this take three attempts to diagnose.

Now gates on the filter being enabled rather than on whether work
happened, and carries `last_skip_reason` + `mode`. A disabled filter is
still silent. A blocked round now emits, e.g.:

  dedup-shadow: round summary round_id=… mode=shadow budget_used=0
    pairs_evaluated=0 last_skip_reason=no_pairs reason=round_transition

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Flagged miners were being removed from the ranking list, so the
survivors re-ranked from position 0 and the 2.25 / 1.5 / 1.0 slots
refilled from below. Two things go wrong with that cascade:

  * It promotes miners nobody examined. The pairwise filter only ever
    compares the top `dedup_top_k` (5), so zeroing the top three hands a
    paid slot to #6 — a submission that was never compared against
    anything. In round 8814586, 70 of 96 miners sat inside duplicate
    clusters, so the promoted miner is more likely than not to come from
    the same cluster we just penalised.

  * It pays an attacker for taking out a rival. Copy the leader from a
    throwaway UID, the pair is flagged, both are zeroed — and every UID
    behind the leader gains exactly one rank, including the attacker's
    other UIDs. Symmetric zeroing plus a cascade turns the filter into a
    weapon aimed at whoever is winning.

Flagged miners now keep their rank position and are paid 0, so the slot
is burned rather than reassigned. No unflagged miner's score changes.

The exact-tie rule keeps its existing cascade on purpose: it applies
even with `dedup_filter_mode: off`, so changing it would alter
production scoring well beyond this feature.

This does NOT withhold emission, and it is not meant to. Weights are
normalized before submission and scores are a rolling ~8-round window,
so the round still pays out in full — burning redirects the share from
"this round's #4-#6" to the recent historical field, which is a much
harder target to game than being ranked sixth once.

Telemetry: `top3` now logs the awarded score rather than the rank's
nominal value (a burned slot must read 0 or the log contradicts the
aggregator), plus a `dedup_flagged_count`.

Tests: the old expectation is replaced by the burn semantics, and an
anti-griefing invariant is added — flagging the leader must leave every
unflagged miner's score byte-for-byte unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`select_pairs` re-reads `scores_snapshot()` on every trigger, so "top K"
meant "top K of whoever happens to be graded right now". Miners are
graded stalest-first — an order unrelated to quality — so an early
sample is a RANDOM subset of the field, not the leaders.

That interacts badly with the budgets, which are asymmetric:
`dedup_top_k` bounds a single call, `dedup_max_pairs` bounds the whole
ROUND. With `dedup_eval_interval: 2` the first pair fires after two
evals and all ten are gone inside the first twenty — drawn from a
half-filled scoreboard. By the time the real top-5 exists there is no
budget left to compare it, so the miners actually about to be paid are
the ones never examined.

In `enforce` mode this is not merely wasted measurement.
`dedup_flagged_uids` is only ever added to and finalize zeroes anything
in it, so a miner flagged against a neighbour who later fell down the
board is still zeroed — on a comparison the final ranking would never
have chosen to make.

Reserve the tail of the eval window instead. Once it opens the worker
stops claiming miner evals, so the ranking cannot move between one pair
and the next, and `select_pairs` reads a settled board. The span is
`dedup_max_pairs * dedup_pair_budget_sec`, which is the pass's own worst
case for its own budget — the last pair therefore starts exactly where
`_dedup_window_allows_pair` still permits one. New `dedup_freeze_field`
(default on) turns it off and restores the idle/interval triggers.

The freeze only arms when `dedup_filter_mode` is shadow/enforce. Without
that check the default `off` would stop claiming evals for the whole
reserved span and spend it doing nothing, since the pass's mode gate
returns immediately — a 20-minute stall on every validator.

Cost, stated plainly: miners still ungraded when the tail opens score 0
for the round. On a validator with a long idle tail (measured: roster
graded in ~27 min of a ~68 min Train) that is free. On one whose grading
already overruns the window it is real, so the freeze logs `scored` /
`positive` / round stats at the transition rather than going quiet.

Tests: tail derivation, freeze disabled, filter-off never freezing, and
that the pass refuses to spend a single pair before the board settles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
shadow-mode merge-loss dedup filter for top submissions
Adds `include_linears` to `quantize_model_` and passes False from
`quantize_eval_model_`, so the validator's fp8 scope is expert projections
only — the MLA projections, first_k_dense_replace dense MLPs and
shared_experts stay at full precision.

Why: the reference implementation this format was matched to
(`~/experiment/partial_moe.py:quantize_expert_fp8`, 17c878d) quantizes expert
projections and nothing else. We already matched its representation (e4m3,
per-output-row, activation-dtype dequant in the forward) so that loss
measurements transfer between the two codebases — but a delta only transfers
if both sides are quantizing the same set of tensors, and ours was also
converting 162 backbone Linears. Same scope now, so the numbers are
comparable.

Little is given up: on a partial model the experts are ~1.8 B params against
a ~0.4 B backbone, so experts-only keeps most of the memory saving.

The miner path is untouched — `include_linears` defaults to True, and the
miner's stacked expert tensors can't be quantized at all (trainable and
helper groups share one tensor per layer), so backbone Linears are the only
thing its toggle has to work with.

No production behaviour change: validator fp8 still refuses to start without
CONNITO_ALLOW_VALIDATOR_FP8=1, and `model.quantization` still defaults to off.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…real hardware

Records the evidence behind the experts-only scope this branch introduces, and
corrects a refusal message that has since become false.

`tools/quantization/` holds three harnesses that need an L40S, the 30 GB
DeepSeek-V2-Lite checkpoint and the production eval datasets, so they cannot
live in `connito/test/`. The CPU-only invariants stay there; these answer the
questions that only real hardware can.

The parity result, measured on the stock HuggingFace model — the experiment
repo's own base (`model.py:72`) — with our `FP8Linear` applied exactly where its
`quantize_expert_fp8` applies its own:

  - relative weight error 2.654% mean across all 4992 expert projections
    (min 2.645%, max 2.660%), reproducing the reference's ~2.7%
  - val_loss 1.449180 -> 1.448177 on identical seeded batches
  - 29.36 -> 16.98 GiB resident

That error figure is what makes loss deltas transfer between the two codebases,
which was the point of matching the scope in the first place.

Two findings that fell out of running the full-experts configuration, both
recorded in the README rather than fixed here since neither is this branch's
subject:

  - `partial=False` leaves every routed expert at `_init_weights` random. The
    checkpoint names them `...experts.{N}.gate_proj.weight`; we serialise the
    fused `...experts.{N}.gate_up_proj`. The partial path translates between the
    two, the full path is a bare `load_state_dict(strict=False)` that swallows
    4992 unexpected and 52 missing keys. Same weights score 1.449 through the
    stock loader and 9.956 through ours.
  - `resolve_foreground_eval_model` copies before it quantizes, so a
    full-experts fp8 validator needs two full fp16 models at once and OOMs at
    43.69 of 44.39 GiB.

The refusal in `quantize_eval_model_` claimed fp8 "has NOT been gated in its own
right". It has been since, and it failed the same way int8 did — 0.21x
perturbation against a <0.1x bar, top-3 reordering, one manufactured exact tie.
Saying so is a stronger argument for the block than the old appeal to fp8 being
coarser, and it stops anyone reading the message from concluding the gate is
still outstanding. Also adds the trap that message was missing: a ~0.001
absolute loss shift is negligible for training and comparable to the entire
inter-miner spread the ranking has to resolve, which is exactly how a harmless
loss delta becomes a reordered podium.

The param counts in the scope comment were wrong (~1.8 B experts against a
~0.4 B backbone); measured, it is 14.39 B routed experts against 1.32 B.

Verified on the L40S: all three harnesses load and run from the repo path, the
refusal raises with the new text, the override path still starts, and
test_validator_observer_mode passes (14 tests).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`get_base_model(partial=False)` returned a model whose every routed expert was
still at its `_init_weights` random value — 14.39 B of 15.71 B parameters — with
no exception and no warning.

The full branch of `load_pretrained_model_low_mem` was a bare
`load_state_dict(strict=False)` against the HuggingFace checkpoint. The
checkpoint names routed experts `...experts.{N}.{gate,up,down}_proj.weight`, one
tensor per expert per projection; `CustomDeepseekV2Experts` stores each layer's
experts stacked, gate and up fused, under `...experts.{N}.gate_up_proj`. Nothing
matched in either direction, and `strict=False` swallowed both halves: 4992
unexpected keys in, 52 missing keys unfilled.

The partial path has translated between the two all along, in
`_apply_pretrained_tensor_to_partial` — it buffers `gate_proj` until `up_proj`
arrives for the same expert, concatenates them, and writes the pair into the
right slice. The full path simply never called it. This routes the full path
through the same translator and the same streaming loaders, so both paths now
share one implementation rather than one path having a silent gap.

`assignments_from_expert_modules` supplies the local->global mapping those
loaders need. A partial build derives it from its group assignment; a full build
has no group, so it reads `expert_indices` off the modules themselves. It does
not assume the identity — a full build happens to be 0..N-1, but the helper
reports whatever layout is there, and a test pins that.

Streaming is a second, free win: the old path materialised a whole CPU model and
a whole pretrained state_dict before copying between them, peaking at 59.6 GB of
host RAM on a 62 GB box. Streaming holds the model plus one mmap'd shard,
~37 GB. For reference, HuggingFace's own loader peaks at 18.9 GB for the same
checkpoint, so there is more to take here later.

Measured on an L40S before this change: the full model scored 9.956 on seeded
eval batches where the stock HuggingFace model scored 1.449 on the same batches.
Expert tensors mismatched the checkpoint while the backbone matched it exactly,
and the loaded expert values had mean magnitude 0.01595 against the initialiser's
0.02*sqrt(2/pi) = 0.01596 — untouched, not partially loaded.

Verified: the 6 new tests pass. The end-to-end check (a corrected full model
should score ~1.45, and `tools/quantization/gpu_full_load_check.py` should report
`experts_loaded: true`) has NOT been run — it needs ~37 GB of host RAM and the
box is currently running the tester validator. Four unrelated tests fail on this
branch; all four fail identically on pristine staging with these changes
reverted, so they are pre-existing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#118 (2a51426, 2026-05-07) made `.safetensors` the expert-shard save format.
Two fetch sites were not migrated with it and have asked for `.pt` alone ever
since, so both 404 against every checkpoint published in the last three months:

  - `_build_download_targets` — the validator's global-model fetch from chain.
    A validator that loses local state can never recover the subnet's model,
    and no validator ever picks up a peer's newer checkpoint. Observed live:
    404 on `g-connito/co/resolve/a26aba5/model_expgroup_4.pt`, followed by a
    silent cold start from pretrained.
  - `hydrate_miner_submissions_from_hf` — recovering miner submissions from HF.
    Dead by the same mechanism.

Confirmed against that checkpoint: it carries `model_expgroup_3.safetensors`
and `model_expgroup_4.safetensors`, and zero `.pt`. Everywhere else already
handles both suffixes — `background_download_worker`, `dedup`, `evaluator` and
`checkpoint_helper` all go through `MINER_CHECKPOINT_SUFFIXES`, and the bg
worker already names its output after whatever it actually downloaded. These
two were the outliers.

`.pt` is kept as a fallback rather than deleted. It is tried only when the
preferred suffix is missing, and only on `HFFileMissingError` — a dead repo, an
auth failure or a timeout means the candidate is unusable, so retrying it would
double the latency of every real failure for nothing. The two suffixes go out
as separate requests because `download_checkpoint_from_hf` raises on the first
absent entry: one call listing both would fail against every repo, since none
carries both.

`hydrate_miner_submissions_from_hf` had a third defect in the same function: it
scanned for already-present submissions with `glob("*.pt")`, which matches none
of the `.safetensors` files the background worker writes, so every poll
re-downloaded every miner it had already fetched. Now uses the same
`MINER_CHECKPOINT_SUFFIXES` sweep as its siblings.

The destination filename now carries the suffix that was actually downloaded.
`load_state_dict_from_path` dispatches on it, so writing safetensors bytes into
a hardcoded `.pt` name would have routed them to `torch.load` and failed — a
trap that only appears once the fetch is fixed.

`test_hydrate_miner_submissions_from_hf_writes_assigned_miners_only` asserted
`model_expgroup_0.pt`, i.e. it pinned the bug rather than catching it; updated,
and a companion added for the legacy-fallback path. New
`test_expert_shard_suffix.py` covers the target builder and the fallback ladder,
including that a non-file error is not retried.

Four tests fail on this branch; all four fail identically on pristine staging
with these changes reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🩹 fix(model): load pretrained routed experts in the full-model path
@george-connito george-connito changed the title PR 204 - 206 -> Master PR 204 - 210 -> Master Aug 17, 2026
george-connito and others added 11 commits August 17, 2026 16:34
Makes the full expert topology reachable and routes it the way the base
checkpoint does. Three findings, one of which would have silently
invalidated the comparison it exists to enable.

**1. The full branch was unreachable.** `moe.partial_moe` has been
declared in `MoECfg` since the initial commit and read by nothing;
`validator/run.py` hard-coded `partial=True`. So the full branch of
`mycelia.get_base_model` — including the loader fixed in #210 — could
not be entered from any config. Wired the existing field to the call
site rather than adding a new one.

**2. `full_topk` was wrong: 2, not 6.** It has sat at its initial-commit
value for four months because nothing read it. DeepSeek-V2-Lite ships
`num_experts_per_tok: 6`, and the reference experiment inherits that —
it loads through `from_pretrained` and never overrides the gate
(`partial_moe.py` reads `top_k` off the stock gate). Turning full
experts on at 2 would have scored top-2-of-64 against the experiment's
top-6-of-64: a different forward graph, presented as the same one.
`partial_topk` got a deliberate 1 -> 6 bump in 917bb16; this was missed
in the same pass.

**3. Two differences I expected were not real** — verified rather than
assumed, and left alone: `n_group=2` never applies (the group-limited
branch needs `topk_method == "group_limited_greedy"`, and V2-Lite ships
`greedy`), and `norm_topk_prob` is set on the config but read nowhere in
our routing.

The remaining genuine gap to the experiment is dtype: it runs bf16, we
run fp16. `bf16-mixed` is already supported, so this is a per-host
config choice; documented at the field. Not changed as a default —
that would move scoring on the partial topology the fleet runs today.

**Behaviour is unchanged by default.** `partial_moe` defaults True, so
the validator builds exactly the model it does now, and `full_topk` is
still read by nothing until an operator opts in.

**`partial_moe: false` does not fit on a 46 GB card yet**, and the new
startup warning says so with the numbers: ~29 GB of fp16 expert weights
against partial's ~2.6 GB, and `load_model_from_path` deepcopies the
model *before* `quantize_eval_model_` can shrink it, so the peak is two
unquantized models. That OOM was measured, not predicted. fp8 shrinks
the copy, not the thing that produces it. Deliberately a warning and not
a refusal — the option is for reproducing the experiment on a box that
can hold it.

Left `partial_moe` out of `_LOCKED_FIELDS` on purpose: locking it would
have the reset undo a staging host's edit every restart. It should be
locked before it ever becomes a fleet default, since two validators on
different topologies score on different graphs.

Tests: 7 new in `test_full_expert_topology.py`, including the switch
asserted against the real `setup_training` call rather than a
recomputation of it — the bug was a literal at that call site. Full
suite run in the `:staging` image; the 5 failures are identical with and
without this change (4 pre-existing, 1 telemetry port artefact of the
container).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pology

🧭 feat(validator): selectable expert topology, and fix full_topk
The full-model build peaked at **59.6 GB resident on a 62 GB host** for a
model whose final size is ~31 GB. Now 38.0 GB. Loaded weights and eval
loss are bit-identical either way — only the peak moves.

`nn.Parameter(torch.empty(...))` and `nn.Linear` take torch's *default*
dtype, which is fp32. Constructing a model and casting afterwards keeps
the fp32 storage for every parameter live until that parameter is
individually replaced, so the peak is ~2x the final size. The comment
above the call site already said not to do this:

    # Build at `model_dtype` from the start: a fp32 default + later
    # cast would peak at ~2x the final size for a 15B-param model.
    model = model_class(moe_config).to(dtype=model_dtype)

The comment was right. The line under it constructed fp32 and cast after.

`mycelia.build_at_dtype` wraps construction in `set_default_dtype`.
Applied at all three construction sites — the full path, its
meta-tensor fallback, and the partial path. Partial was the same bug an
order of magnitude smaller (~8 GB peak for a ~4 GB model); fixed because
it is the same one-line remedy, not because it was hurting anything.

Call sites keep their trailing `.to(dtype=...)`. It is free once
construction is already correct (`Tensor.to` returns self, verified by
`data_ptr` in the tests) and it keeps the end-state invariant pinned if a
submodule ever hard-codes fp32.

`set_default_dtype` is process-global, so the restore is load-bearing:
a leaked bf16 default would silently downgrade every tensor built later
in the process. Restored in a `finally`, tested including the exception
and nesting paths.

**Measured, not argued.** Same script, same box, before and after:

| | before | after |
|---|---|---|
| host peak | 59.6 GB | **38.0 GB** |
| expert gate/up/down vs checkpoint | MATCH, max delta 0 | MATCH, max delta 0 |
| backbone (control) | MATCH | MATCH |
| forward loss | 0.30255791544914246 | 0.30255791544914246 |

38.0 and not ~31 because streaming adds a shard mmap plus the tensor
being materialised; the 2x construction overhead is what went away.

**Repo cleanup carried with it.** `gpu_full_load_check.py` is removed: it
reproduced the pre-#210 bug by calling `load_state_dict(strict=False)`
directly, which is a code path the loader no longer takes, so running it
today reports a mismatch that no longer exists. Replaced by
`gpu_full_fix_verify.py`, which exercises the real entry point
(`get_base_model(partial=False)`) and reads ground truth with `safe_open`
one tensor at a time rather than materialising a second 30 GB state_dict
to compare four tensors. The README keeps the old diagnosis as history —
the failure was silent, so the shape of it is worth not losing.

Tests: 5 new. Full suite run in the `:staging` image; failures identical
with and without this change (4 pre-existing, 1 telemetry port artefact
of the container).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`build_grad_buff_from_model` built a gradient buffer for every expert
group; `validator.run` then deleted all but its own on the next line:

    group_grad_buff_meta = build_grad_buff_from_model(...)   # builds ALL
    excluded = [gid for gid in group_grad_buff_meta if gid != active_group_id ...]
    for gid in excluded:
        del group_grad_buff_meta[gid]                        # keeps ONE

Each buffer is a real `torch.zeros(total, device="cpu")`, so this
allocated and binned a full second copy on every validator start:

  - **7.25 GB** on the partial topology the fleet runs today
  - **27.4 GB** on the full topology, where it was a material part of
    running out of host RAM

`group_ids` now scopes the allocation. The delete loop stays as the
invariant check — `group_ids` guards the memory, the loop guards what
reaches the averager.

Name bucketing still runs over every group, deliberately. It costs
nothing (it builds lists of strings) and `include_shared` needs the
complete ownership picture: scoping the *assignment* instead of the
*allocation* would make other groups' experts look unowned and drop them
into the shared buffer, to be averaged with every peer rather than
within their group. There is a test pinning that specifically, because
it is the one way this change could have been quietly wrong.

Observed on the tester at full topology, both buffers reporting the same
size:

    Built expert group grad buffer - 4   27456 MB  total_numel=14394851328
    Built expert group grad buffer - 2   27456 MB  total_numel=14394851328

That they are *equal* is a second, separate bug: the stacked-mode
bucketing matches by layer rather than by expert, so on a full model
every group claims all 64 experts of every layer it touches. Not fixed
here — it needs the buffer to hold slices of the stacked tensor, which
changes how gradients are packed and unpacked, and that deserves its own
PR and its own tests.

Tests: 5 new. Full suite run in the `:staging` image; failures identical
with and without this change (4 pre-existing, 1 telemetry port artefact
of the container).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eval base

Scores miners on the full expert topology (all 64 routed experts,
`full_topk=6`) without making `global_model` full — the direct route was
measured off a cliff twice over. Opt-in via
`evaluation.full_topology_eval`; default-off changes nothing.

**Why not `partial_moe: false`.** A full global model cannot merge: the
outer step needs params + `.grad` + SGD momentum resident, measured at
87.8 GiB for 15.71 B params (`gpu_option_b_budget.py`, included) against
44.4 GiB VRAM or 62 GB host. Freezing unmerged experts can't rescue it —
`requires_grad` is per-tensor and a layer's 64 experts share one stacked
tensor. And the merge only ever moves own-group rows anyway: a miner
model is a deepcopy of global + shard overlay, so `g.data - p.data` is
exactly zero everywhere the shard didn't reach. Carrying optimizer state
for 1664 experts to merge ~215 was the whole wall.

**The design** keeps `global_model` partial — merge, outer optimizer,
chain hash, peer resync byte-identical to production — and adds a frozen
full-topology base used only for scoring:

  - `requires_grad=False` throughout: no grads, no momentum, no
    optimizer state, ever;
  - one `load_state_dict(global.state_dict())` refreshes it per round —
    the expert serializer emits per-expert keys under *global* ids, so
    backbone + merged rows carry across with no new translation code;
  - miner shards graft in place through the same overlay loader, with
    the touched rows (~3.4 GB, not 29) backed up first and restored
    bit-identically after;
  - parked on CPU for the merge window, so the merge never shares the
    GPU with it.

This is the reference experiment's own architecture
(`~/experiment/partial_moe.py`): trainable local experts apart from a
frozen full set that lives on CPU and swaps in for merged-topology eval.
Its paradigm D — full model, assigned experts trainable at original
positions — is exactly partial-global + frozen-full-base.

**Grafting in place is safe here because the base is disposable** — it
is never hashed, submitted, merged or synced. The failure that rules out
grafting into `global_model` (a died restore corrupting what the
validator commits) costs at worst one round of scores, and the next
round's refresh rebuilds the base from global regardless.

**What in-place grafting newly has to defend, and does:**

  - *Hostile shards.* Deepcopy isolation made them free — they could
    only poison their own copy. In place, an unvalidated write (another
    group's experts, lm_head, the router) would survive the restore and
    contaminate every later miner of the round. Shards are validated
    all-or-nothing before any tensor is read; `ShardRejected` subclasses
    ValueError so the existing statedict_parse_failed handler attributes
    the failure to the miner.
  - *Orphaned evals.* A foreground per-miner timeout detaches the
    awaiter but the thread runs on (long-documented in
    `evaluate_one_miner_sync`). With a deepcopy that burned VRAM; with a
    shared base an orphan's late restore would interleave with the next
    miner's graft and silently corrupt that miner's loss. The base
    carries a lock acquired inside the eval thread (bg-eval's
    gpu_eval_lock pattern), and prepare/park bump a generation so a
    *queued* orphan fails its generation check without touching the
    base. A graft that dies mid-overlay self-heals before re-raising.

Baseline and miners share the base, so both sides of every delta are the
same topology. v1 constraints, enforced at startup: bg worker off (its
own template would be a second 29.3 GiB copy), quantization off, and
`partial_moe` true.

Budget on the L40S: eval window = partial global 9.2 (idle) + base 29.3
+ backup ~3.4 + activations ≈ 42-43 of 44.4 — tight, and the number the
live run must confirm; merge window = partial peak 27.6 with the base
parked (29.3 GB of 62 host). `full_topology_eval` is deliberately NOT
locked while opt-in (same reasoning as `model.quantization`): full and
partial rank miners differently (Spearman rho -0.45 across 22 real
submissions, per the reference experiment), so it must be lockable
before ever becoming a fleet default, but a staging host has to hold it
across restarts today.

Tests: 12 new pinning bit-identical restore, all-or-nothing rejection,
stale-generation no-touch, refresh-carries-merged-rows, stale-backup
discard, and the frozen contract. Full suite in the `:staging` image:
failures identical with and without this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three fixes from the first live full-topology round. The mechanism
worked — first full-topology baseline 1.9768 against partial's 3.7839,
51 batches, zero NaN — and the eval-window budget came in at 39.5 GiB of
44.4, better than the ~42 projected. Both problems were around it.

**1. The bg-worker constraint starved the round.** v1 required
`background_worker_enabled: false`, reasoning that bg-eval would need a
second full template (true, 29.3 GiB, does not fit). But that flag also
gates bg-*download*, and foreground reads exclusively from the directory
bg-download fills — the compose file says so in as many words. Observed:
a full window with `discovered_total=0` and `scored=0` against four
claimed foreground UIDs. bg-eval is now suppressed on its own and
downloads keep flowing; the startup check no longer demands the flag.

**2. `park()` allocated 29.3 GB at the worst possible moment.**
`.to("cpu")` reuses nothing — the host tensors were released when the
model moved to the GPU — so parking allocated a second full model just
as the merge phase began wanting memory of its own. The validator died
there: last line `Aggregating miner gradient change locally`, next line a
fresh `Validator starting`, no traceback.

The base now holds references to its original CPU tensors for the
process lifetime and `park()` copies back into them. Host cost is a flat
29.3 GB from build to shutdown, peak host never exceeds steady-state
host, and there is no allocation left to mistime. Pinned by `data_ptr`
in the tests — wrapper identity would prove nothing, the storage address
is the claim.

**3. Neither transition logged anything.** The post-mortem above had a
seventeen-second window and no numbers in it. `prepare_for_round`,
`park` and `build` now emit VRAM used/free and host RSS.

Tests: 3 new (park writes back into pre-allocated storage, park is
idempotent, park preserves values), 15 in the file. Full suite in the
`:staging` image: failures identical with and without this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Watchtower replaces the image and never touches an operator's compose file,
.env or config.yaml. The only channel that reaches a running validator's
settings is check_and_prompt_locked, which resets locked fields to their class
defaults on every start (--auto_update_config defaults to True). "Enforce this
fleet-wide" therefore means exactly: ship the default, and lock the field.

Four fields have to move together, because check_full_topology_eval_supported
rejects the mixtures at startup:

  evaluation.full_topology_eval  false -> true    the change being enforced
  model.precision           fp16-mixed -> bf16    the base checkpoint's dtype
  moe.partial_moe                 true, locked    a full global model cannot merge
  model.quantization             "off", locked    fp8 + graft is rejected

Leaving any one unlocked hands the operator who had edited it a crash-loop on
the release that turns full-topology eval on, rather than a config they can
argue with.

full_topology_eval is the only new field of the four, so it is the only one
with a free ride: pydantic fills the default and config.write() persists it.
The other three are existing fields whose operator values survive every pull
forever — the lock is the whole mechanism there, not a belt on a brace.

precision is safe to lock because resolve_precision downgrades bf16 -> fp16 on
a device without BF16 compute, so a card that cannot run the default lands on
fp16 through the resolver rather than on a broken config.

Staging hosts that need to diverge use --no-auto_update_config, which
suppresses the reset. That is also why the run.py guard survives, with its
fallback pinned false: a config predating the field must not build a 29 GB
base before the locked-field reset has had its say.

Tests: 6 new pinning the enforced set and the reset mechanism; the
partial_moe test inverted to match, keeping its old rationale as history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first live round with a full eval window scored zero miners. Baseline
fitted — 51 batches, baseline_loss=1.7303 on the full topology — and then all
three foreground miners OOM'd:

  foreground eval: starting  baseline_loss=1.7303  foreground_uids=[165, 36, 23]
  evaluate_one_miner: OOM  uid=165
  evaluate_one_miner: OOM  uid=36
  evaluate_one_miner: OOM  uid=23
  No foreground miners evaluated  round_id=8872226
  [VRAM before GPU cleanup] allocated=42939.7MB free=213.4MB alloc_pct=94.5%

Baseline ran at 39.5 GiB of 44.4; the miners hit 42.9. The ~3.4 GiB delta is
the graft backup, because `rows[local].clone()` clones onto the *source*
device and the base is on the GPU for the eval window. Nothing reads those
rows on the GPU — they are written once at graft and read once at restore —
so the eval window was carrying 3.4 GiB it never used and the activations
were what did not fit.

Each OOM landed ~2.5 minutes into its eval, i.e. in the forward pass, not the
overlay. The graft mechanism itself worked; it had nowhere to compute.

Staged on the host now, and pooled rather than allocated per miner: every
miner of a round touches the same expert set (this validator's group), so
storage is allocated once and written over. Same reasoning as `_shadow_*` in
park() — flat host cost, no per-miner churn on a box with no swap. Pinned
memory deliberately not used: 3.4 GB of page-locked storage on a host already
running at 47 GB of 62 trades one pressure for another.

Also logs VRAM and host RSS at graft and restore. The same round grew host
RSS 31.5 -> 47.3 GB across four eval loader builds and then died entering the
merge with no traceback; ~15.8 GB of that is unaccounted for, and these two
lines are what will localise it to the eval window or rule it out.

Tests: 3 new — backups land on CPU, the pool is reused across miners
(data_ptr, not values), and restore stays bit-identical through host staging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In-round evidence costs one attempt per ~105-minute cycle and does not
distinguish causes. Two rounds failed to score a miner: the first OOM'd all
three (42.9 GiB allocated, 213 MB free), the second never reached a graft
because the submission window had closed before the roster froze. Neither told
us whether the memory fixes work.

This reproduces the eval window on its own, with the two things that make the
numbers real: a ballast allocation standing in for the resident partial
global model (~9.3 GiB, without which a 45.5 GiB card looks roomy and any
budget claim is meaningless), and a real miner shard off disk rather than a
synthesised one, since the overlay's cost depends on how many rows the shard
actually touches.

Forward passes use synthetic (1, 1024) batches — the shape `get_dataloader`
builds for eval, per `per_device_train_batch_size`. Activation memory is a
function of shape, not content, and pulling real eval batches would add a
dataset download to a test about allocation. Overlay correctness is checked by
comparing tensors directly, which is stricter than inferring it from a loss.

Measured on the L40S, all seven checks passing:

  build host RSS                30.13 GB          (build_at_dtype)
  base on card                  29.33 GiB
  graft VRAM delta              +0.00 GiB         (was ~3.4)
  backup                        410 rows, 3.30 GB staged, on host
  grafted forward               peak 39.67 of 44.39 GiB
  restore                       52/52 sampled rows bit-identical
  pool reuse (2nd miner)        410/410 rows, host flat 34.47 -> 34.47
  park                          host +0.00 GB, card released

`vram_free_gb=4.19` here against the live round's 4.17 — the ballast
reproduces the real budget rather than approximating it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix(validator): two host-RAM fixes — build dtype, and duplicate grad buffers
@george-connito george-connito changed the title PR 204 - 210 -> Master PR 204 - 213 -> Master Aug 18, 2026
george-connito and others added 2 commits August 19, 2026 09:50
…ounds

Full-topology validators crash-looped on a ~2-cycle rhythm overnight —
23:16, 02:47, 06:18, 09:45 — with a CUDA OOM inside
FullTopologyEvalBase.prepare_for_round:

  File "connito/validator/full_topology_eval.py", line 213, in prepare_for_round
    self.model.to(device)
  torch.OutOfMemoryError: Tried to allocate 704.00 MiB. GPU 0 has a total
  capacity of 44.39 GiB of which 639.38 MiB is free.

43.75 GiB was already in use before the base moved. The VRAM log says where
it went:

  round start, pre-merge   allocated =  9,513.3 MB   params
  steady, post-merge       allocated = 19,027.8 MB   params + momentum

19,027.8 is almost exactly 2 x 9,513.3. The outer optimizer is DiLoCo-style
SGD with momentum, so step() allocates a momentum_buffer the size of the
parameter set on its first call and holds it for the process lifetime.

That was harmless while the eval model was a per-miner deepcopy of the
partial topology. It stops being harmless with evaluation.full_topology_eval,
which wants the 29.3 GiB frozen base resident in the same window:
19.0 + 29.3 = 48.3 GiB against a 44.39 GiB card. The first round of every
process fitted because momentum did not exist yet; the second always OOM'd.

The eval-window budget in full_topology_eval's module docstring counted the
partial global model at 9.2 GiB idle and omitted the momentum entirely —
even though tools/quantization/gpu_option_b_budget.py documents that the
buffer "is allocated on the first step() and persists for the process
lifetime". gpu_graft_verify.py's ballast reproduced the pre-merge budget
faithfully and the post-merge one not at all, which is why every measurement
taken so far came from a first round and looked fine.

Nothing reads the buffer between steps, so it has no reason to hold the card
while miners are scored. It is now onloaded immediately before step() and
parked back on the host immediately after, with empty_cache() so the freed
blocks go back to the driver rather than sitting in the caching allocator —
the next caller wants large contiguous blocks for the base's .to(device).
Cost is ~9.5 GiB over PCIe each way once per round, well under a second
against a multi-minute round.

Applied unconditionally rather than gated on full_topology_eval: partial
validators get the same 9.5 GiB back, and a fix that only runs in one
configuration is a fix that only gets tested in one configuration.

Tests: 5, the load-bearing one being that momentum is *relocated* and not
reset. Two optimizers stepped over identical gradients, one parked between
every step, required to end bit-identical in both weights and buffers — a
buggy offload that dropped or zeroed the buffer would pass every
device-placement assertion and silently change how every validator merges.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…offload

fix(validator): park outer-optimizer momentum on the host between rounds
@george-connito george-connito changed the title PR 204 - 213 -> Master staging → master Aug 19, 2026
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