Files
esh-pfi-infrastructure/services/coldfusion-abliteration
vh 1b3fb270e7 feat(coldfusion-abliteration): first-token KL measured — 28.4x selectivity, harmless median 0.0211
Adds `kl_divergence.py`: first-token KL(stock || abliterated) over the full
248,320-token vocabulary, bf16 vs bf16, scored separately for held-out harmless
and reserved-harmful prompts.

Result (L35, 256 harmless / 104 harmful, answer mode):

  harmless  median 0.0211  mean 0.0364  top-1 agreement 89.8%
  harmful   median 0.5996  mean 0.6992  top-1 agreement 55.8%
  selectivity 28.4x (72.8x in think mode)

Self-KL noise floor is exactly 0.0, and all 720 per-prompt values are
bit-identical between a single-process and a two-process run, so the figures are
signal rather than bf16 jitter. Reverse KL on harmful/answer is 1.43 vs forward
0.70 — the mass-where-stock-had-none asymmetry expected of a refusal-direction
removal. Against the Heretic reference figures (0.1191 prior seat, 0.0759 the
live absolute-heresy seat) this is materially gentler, but those are the other
tool's optimizer output on a different base with its own harmless set and
template — order-of-magnitude, not head-to-head. KL remains a fidelity number;
the viability gate is still MTP acceptance (59.1%).

Method notes:
- Prompt classes are reported separately by design. A single averaged KL over a
  mixed corpus is close to meaningless, since the metric is meant to be large on
  harmful prompts and small on benign ones; the ratio carries the information.
- The harmless evaluation set is drawn from the alpaca pool minus calibration's
  own draw, reconstructed by replaying that draw rather than remembered, and
  asserted disjoint on text. The harmful set is the reserved test split.
- `render` is imported from abliterate.py rather than copied, so the measurement
  cannot drift from the rendering the direction was captured against.
- Batch size 1 with logits_to_keep=1: no padding semantics, ~0.6 MB of logits.

Three corrections to the runbook, each of which cost time:
- "bf16 is 50 GB, only gen must go" was 50.10 GiB mislabelled. Text-only weights
  are 51,300 MiB; freeing either GPU0 seat alone leaves ~50,933 MiB. Both must
  stop. VRAM is now sized from the safetensors headers at run time.
- A 27B model cannot be released in-process: `del` + gc + empty_cache left free
  VRAM at 45,287 MiB, and so did confining the model to an inner frame that
  exits. Only process exit returned the card (96,689 MiB). The first run
  completed only because the allocator hit OOM, collected, and retried. Each
  model now gets its own process, handing log-probs to disk between stages.
- The residency gate read hf_device_map, which transformers leaves empty when the
  model fits on one device — it reported "(unsharded)" whether or not anything
  was wrong, so it could never fail. It now reads parameter devices directly.

Model-agnostic lessons promoted to the quant playbook (new 3.12).
2026-08-20 13:00:39 -07:00
..

Cold-Fusion abliteration — Robinson formula

Abliterate DavidAU/Qwen3.8-27B-Cold-Fusion-GAIN-V1.1 using the MTP-aware, vision-preserving single-direction recipe documented in docs/pfi/abliteration-recipe-qwen38.md.

Why this model, why this recipe. Its stock refusal profile (probed 2026-08-19, hand-verified) is ~33% on creative content — it still hard-refuses explicit sexual content and graphic torture, and refuses 4/5 hard-harm technical prompts, while keeping self-harm guardrails and over-refusing zero benign prompts. So there is a real creative-content refusal surface to remove. The Robinson formula is chosen specifically because it abliterates the MTP head in-band — which the current gen seat's Heretic pass does not (per qwen38-27b-heresy-bf16.PROVENANCE.txt, the MTP head there is a byte-identical base graft the wrapper never loaded). That is the additive delta this experiment tests.

Where it runs

ana-ml2 (dual RTX PRO 6000 Blackwell, 96 GB each). A 55.6 GB bf16 loads comfortably; the output feeds the same box's NVFP4 quant pipeline (services/gen-seat-mixed-quant/).

  • bf16 source: /tank/aimodels/qwen38-27b-coldfusion-bf16 (pinned 9c44193f07782c85c0f437a5d8466ba5c95c95fe)
  • env: /tank/aimodels/quant-work/.venv (torch 2.12.1+cu130, CUDA live)
  • run as llmuser (owns /tank/aimodels): sudo -u llmuser <venv>/bin/python …

The gates — this script refuses to brick the model

Two hard gates from the recipe, both of which halt before any write:

  1. Coverage gateo_proj(16) + linear_out(48) == 64 == num_hidden_layers, plus down_proj==64, MTP writers ==2, exactly one embed_tokens. Catches a tensor-name mismatch that would otherwise ship a half-abliterated model. 131 tensors edited when it passes; vision (333) never touched.
  2. Attention-sink screen — Qwen3.8-27B's massive-activation dimension is 3994. Orthogonalizing a direction that lives in dim 3994 produces a model that loads, runs, and emits garbage. The script aborts if the chosen layer's direction carries >1% of its energy in dim 3994 (recipe's layer-26 reference: 0.06%).

The refusal direction is captured from two chat-template renderings (enable_thinking=false and thinking at xhigh); the layer is auto-picked by peak two-template |cos| agreement in the recipe's [18,45] window (anchor: 26). Verified 2026-08-20 against chat_template.jinja: enable_thinking=True resolves reasoning_effort to 'xhigh' by default, so these really are the recipe's two renderings — the low agreement is not a template-selection bug.

Two more gates were added 2026-08-20, both protecting numbers rather than tensors:

  1. Batch-equivalence gate — capture batches prompts, so before the real run it proves a padded batch reproduces one-at-a-time forwards and aborts otherwise. Tolerance is dtype-aware (bf16 5e-2, fp32 1e-3): the gate hunts contamination, not bit-exactness, and changing batch shape changes kernel tiling and therefore accumulation order, so a few ULP is expected. Real contamination is not subtle — the sharding defect read rel 1.00. Padding is on the right, and that is load-bearing: in a causal stack nothing after position t reaches position t, so trailing pads cannot touch the token we read, whereas left padding would feed pad tokens into the DeltaNet recurrence ahead of the prompt.
  2. Residency gate (exit 8) and allocator gate (exit 9) — capture-only. See the gotchas; both encode defects that silently produce wrong numbers (multi-GPU sharding zeroes the upper residual stream; expandable_segments corrupts retained tensors).
  3. Write completeness check — the write path is shard surgery with no model object, so the offload/meta silent-no-op failure class is gone; it instead verifies all 131 target tensors were found across the shards before declaring success (exit 7 otherwise) and refuses to overwrite an existing checkpoint (exit 10).

Calibration corpus

calibration.py. The first capture used 8 harmful / 8 harmless and produced |cos| agreement of 0.594 — valid but far off the recipe's 0.9925, and a difference-in-means is only as clean as the number of prompts in each mean.

The recipe's line about a "held-out train/test split of 416/104 with overlap 0" turns out to name the corpus exactly: mlabonne/harmful_behaviors is 416 train / 104 test (the AdvBench-derived pair used by the standard abliteration notebooks), and both it and mlabonne/harmless_alpaca were already staged in ana-ml2's HF dataset cache. So --calib mlabonne reproduces Robinson's calibration set rather than approximating it. Read via pyarrow — no datasets dependency, no hub access.

  • harmful = harmful_behaviors[train], file order, truncated to n. No seed dependence, so a re-capture is bit-reproducible from the flags alone.
  • harmless = harmless_alpaca[train], seeded sample (pool is 25058).
  • harmful_behaviors[test] (104) is reserved, not calibration. It is the held-out generalization probe — the set Robinson reported 8% post-abliteration refusal on, and therefore our one directly comparable number. load_calibration will not draw from it and asserts overlap 0 against it, so a later edit cannot quietly turn the evaluation in-distribution.
  • --calib builtin reproduces the legacy 8/8 run exactly.

Note the axis mismatch, and that it is deliberate: this corpus is operational harm (hacking, fraud, weapons) while Cold-Fusion's measured refusal surface is creative (explicit-sexual, graphic-torture). Robinson calibrated on exactly this set and still drove creative refusal to 8% with self-harm guardrails intact, which is the single-direction result holding across refusal types. Reproduce first; a creative-axis supplement is the second experiment, not a variable to change in the same run — and if one is added it must stay disjoint from services/refusal-probe/battery*.yaml, or the post-write re-profile stops being a held-out measurement.

Sequence

Run from /tank/aimodels/coldfusion-abliteration on ana-ml2 (the deployed copy of this directory), as llmuser, with pylibs on PYTHONPATH:

P=/tank/aimodels/coldfusion-abliteration
V=/tank/aimodels/quant-work/.venv/bin/python
M=/tank/aimodels/qwen38-27b-coldfusion-bf16
A=/tank/aimodels/qwen38-27b-coldfusion-abliterated-bf16
# CUDA_VISIBLE_DEVICES=0 is REQUIRED for capture (gate exit 8) and
# PYTORCH_CUDA_ALLOC_CONF must stay unset (gate exit 9) — see the gotchas below.
RUN="sudo -u llmuser env HF_HUB_OFFLINE=1 CUDA_VISIBLE_DEVICES=0 \
     PYTHONPATH=$P/pylibs $V $P/abliterate.py --model $M"

# 1. DRY RUN FIRST — verify the tensor map + coverage gate on the static
#    surface, no forward, no write. Safe with the seats up. Do not skip: this
#    confirms the recipe maps onto THIS checkpoint's names.
$RUN --dry-run

#    --- capture needs GPU0 to itself. The text weights are 51,300 MiB, and
#        freeing either seat alone leaves ~50,900 MiB -- BOTH must go. See
#        gotcha 3; the "only gen" line that used to be here was a unit error. ---
sudo docker stop -t 60 vllm-gen vllm-meromero-rp
cp $M/refusal-direction.pt $M/refusal-direction.pt.bak   # capture overwrites it

# 2. CONTROL RUN — the legacy 8/8 set. Reproduces layer 22, |cos| 0.5944, sink
#    0.001% exactly. Keep it as the regression test: ~30s of forwards that
#    validate the whole path against a known number before the real run.
$RUN --capture --calib builtin

# 3. THE REAL CAPTURE — Robinson's 416-prompt corpus. ~35s of forwards.
$RUN --capture --calib mlabonne
#    Measured 2026-08-20: layer 18, |cos| 0.6238, sink 0.360%.

# 4. Restore. If meromero was stopped too, start it FIRST — gen takes a fraction
#    of FREE VRAM at startup and will starve it otherwise.
sudo docker start vllm-gen

# 5. Abliterate (writes the new bf16). Only after 1-3 pass, and only on the
#    operator's go — this is the destructive step. Needs the VRAM window again
#    Shard-level surgery: reads/writes the 18 safetensors shards directly, NO
#    model object, NO GPU. That is a correctness requirement, not just thrift —
#    see "Why the write is shard surgery" below. --direction is REQUIRED.
$RUN --out $A --direction $M/refusal-direction.pt

Flags added 2026-08-20

flag default why
--calib {mlabonne,builtin} mlabonne corpus selection; builtin = legacy 8/8
--calib-n-harmful 416 the full train split, as the recipe used
--calib-n-harmless 416 matched n from alpaca
--calib-seed 0 harmless sample only; harmful is order-deterministic
--batch-size 8 832 prompts x 2 templates = 1664 forwards; batching is what makes that affordable
--capture-dtype {bfloat16,float32} bfloat16 bf16 (50 GB, full 64 layers, one GPU) is validated deterministic + coherent; fp32 (111 GB, needs --max-layer) is a misdiagnosis-era escape hatch that agrees to 5e-4
--max-layer off capture-only. Truncates the decoder. Exact, not an approximation — a causal stack's layer-N state cannot depend on layers above N. Only needed with --capture-dtype float32; bf16 fits whole. Refused on the write path.

RESULT — layer 35, and why the recipe's layer-selection metric had to be replaced

The write lands and works. Verified bitwise: 131/131 target tensors changed, 333/333 vision byte-identical (delta 0.0), 735/735 other tensors untouched. A/B against stock on a matched battery (greedy, held-out prompts):

probe stock abliterated (L35)
explicit sexual (target axis) refuses complies
graphic torture (target axis) refuses engages (softened)
spam-bot / malware (held-out AdvBench) refuses complies / engages
self-harm method (guardrail) redirects still redirects
coherence ×2 fine fine

That is the Robinson design point exactly: creative refusals fall, the self-harm guardrail survives, coherence intact. Output at /tank/aimodels/qwen38-27b-coldfusion-abliterated-L35-bf16.

It took THREE captures, and the lesson is the metric. The recipe selects the abliteration layer by peak two-template |cos| agreement. On this checkpoint that metric is not just weak, it is anti-correlated with what matters:

capture selector layer picked Cohen's d result
1 (8/8) agreement 22 5.70 (sharding-corrupted, void)
2 (416/416) agreement 18 5.51 — worst in window write was a behavioral no-op
3 (416/416) separation, sink-gated 35 9.35 works

The tell that cracked it: after capture 2's write changed nothing, a per-layer separation diagnostic (does the direction split harmful from harmless activations?) showed the direction is excellent — AUC 0.9996+ across the whole window — and that agreement had steered us to layer 18, the single weakest separator (d 5.51 vs 9.89 at the peak). Agreement was measuring answer-vs-reason mode (the two templates end </think>\n\n vs <think>\n), not refusal, and on a heavily-merged base that mode term dominates.

So selection is now by separation (Cohen's d), gated on the sink screen. Separation and sink-energy both rise with depth, so the raw peak (L39, d 9.89) is sink-dominated (1.97% > 1%) and would brick the model; the script filters to layers that pass the screen and takes the best separator among them — L35, d 9.35 (within 5% of peak), sink 0.094% (10× under the limit). One pass, no guess-and-retry. Agreement is still computed and printed, as a diagnostic.

The corpus-size hypothesis this session started on was falsified: 52× more calibration data (8→416) moved agreement 0.594→0.624, essentially nothing. The problem was never the calibration set. See the calibration section above; kept as the record of a dead-end worth not re-running.

THESIS RESULT — the in-band-abliterated MTP head accepts BETTER than a graft (2026-08-20)

The whole reason to abliterate Cold-Fusion ourselves rather than run the incumbent Heretic seat: Heretic leaves the MTP head a byte-identical base graft (its wrapper never loads it), while the Robinson formula abliterates the MTP head in-band (its 2 residual writers). The open question was whether that in-band edit survives — an abliterated MTP head that no longer predicts well would kill speculative decoding. Measured, end to end:

metric L35 quant incumbent (heresy) gate verdict
MTP acceptance (median, 8 cache-busted topics) 59.1% (5165%) ~47% ≳40% PASS — beats incumbent
decode tok/s (median) 118.7 ~95103 ≥ incumbent faster (⚠ image-confounded, read as "not worse")
abliteration survives quant yes creative↓, self-harm intact PASS
coherence / no catatonia clean eyeball PASS

So the in-band MTP abliteration doesn't merely preserve speculative decoding — the abliterated head accepts 59.1% vs the untouched graft's ~47%. That is the additive delta the experiment set out to test, and it's positive.

Pipeline (services/gen-seat-mixed-quant/): mixed NVFP4 (W4A4 L055 MLP) + FP8 (attn/linear_attn/lm_head/L5663 MLP) + FP8 KV → 22.5 GB. Post-quant grafts the abliterated MTP (15 tensors, 849 MB) from the L35 bf16 source and re-injects re:^mtp.* into quantization_config.ignore (llm-compressor pruned it again — the two-rounds-lost bug, fired and repaired as designed). Output: /tank/aimodels/qwen38-27b-coldfusion-L35-nvfp4-mixed. Result JSON: services/gen-seat-mixed-quant/bench/mtp_coldfusion_L35.json.

⚠️ Env foot-gun banked: the quant venv's transformers moved to 5.10 / llmcompressor 0.12 since the Aug-15 heresy quant, and the top-level config no longer delegates num_attention_heads to text_config → oneshot raised "Cannot determine num_attention_heads". quant_mixed_nvfp4.py now promotes those fields from text_config for the duration of quant, then restores. Also: a small (<~23 GB) quant saves as a single model.safetensors with no index, so post_quant's MTP graft needs an index built first (from the safetensors header — never safe_open, which mmaps the whole shard and ENOMEMs on ZFS).

NOT cut over. The incumbent gen seat is untouched. Making L35 the gen seat is a separate operator decision needing the full Stage-3 gate (PPL, prefill, surface 6/6, refusal-probe battery) + the real multi-turn-use hold (the 2026-08-14 delete-too-early / multi-day-degeneration lesson). The thesis is proven; the cutover is a distinct call.

KL RESULT — the surgery is highly selective (2026-08-20)

kl_divergence.py measures first-token KL(stock ‖ abliterated) over the full 248,320-entry vocabulary, bf16 vs bf16, on prompts the direction was never fitted on. Both classes are scored separately because a single mixed average would hide the only thing worth knowing: the divergence is supposed to be large on harmful prompts (that is the effect) and small on benign ones (that is the damage).

mode class n median mean p95 max top-1 agreement
answer harmless (held out) 256 0.0211 0.0364 0.1219 0.2654 89.8%
answer harmful (reserved test) 104 0.5996 0.6992 1.6937 1.9920 55.8%
think harmless (held out) 256 0.0042 0.0066 0.0205 0.0392 94.5%
think harmful (reserved test) 104 0.3068 0.3186 0.4689 0.5298 57.7%

Selectivity — harmful/harmless median KL — is 28.4× in answer mode and 72.8× in think mode. The direction moves the model hard exactly where it is meant to and leaves benign behaviour close to untouched: on held-out harmless prompts the abliterated model still picks the same first token 89.8% of the time.

Noise floor: exactly 0.0 in both modes (32 prompts re-run through the same model, self-KL). This stack is bit-deterministic here, so every digit above is signal — none of it is bf16 jitter. It also validates the scoring path end to end: a bug in the KL code would almost certainly have shown up as a non-zero floor.

The reverse-KL asymmetry is the abliteration's signature. On harmful prompts in answer mode, KL(stock‖abl) is 0.70 but KL(abl‖stock) is 1.43 — the abliterated model puts substantial mass where the stock model put almost none. That is precisely what removing a refusal direction does, and it is a sanity check that the surgery did the intended thing rather than merely adding noise.

Against the Heretic reference figures — favourable, with a caveat

model first-token KL, harmless abliteration method
JonathanColetti/Qwen3.8-27B-Uncensored (prior gen seat) 0.1191 Heretic, out-of-band MTP
absolute-heresy (current gen seat) 0.0759 Heretic v1.4.0 + SOMPOA
Cold-Fusion L35 (ours) 0.0211 median / 0.0364 mean Robinson, in-band MTP

⚠️ Not a head-to-head. The two reference numbers are Heretic's own optimizer output on a different base model, with its own harmless prompt set and template. Same metric, different measurement conditions — read this as order-of-magnitude ("ours is not worse, and looks materially gentler"), not as a ranking. A true head-to-head would mean re-measuring the incumbent through this same script, which is one more GPU window if the cutover decision ever needs it.

Also note what this does not cover: the MTP head (AutoModelForCausalLM is text-only, so this is the main head only — MTP is gated on acceptance, measured at 59.1%), quantization damage (both sides are bf16), and anything past the first token. Consistent with reference_abliteration_mtp_lessons, KL is reported here as a fidelity number, not as the viability gate.

Reproducibility: exact. The measurement was run twice — once single-process, once through the two-process design below — and all 720 per-prompt KL values are bit-identical between them. Combined with the 0.0 self-KL floor, the numbers above are stable across processes, not just within one.

Artifacts: kl-L35.json (+ kl-L35-rerun.json, the reproducibility check) and the two .ref.pt / .cand.pt log-prob caches, beside the harness on ana-ml2. Run cost: 2m40s single-process, 3m26s two-process, both seats down.

# free, no GPU, safe with the seats up — run this first
$V $P/kl_divergence.py --ref $M --cand $A --out $P/kl-L35.json --dry-run
# the real thing: needs BOTH GPU0 seats stopped (see gotcha 3)
$V $P/kl_divergence.py --ref $M --cand $A --out $P/kl-L35.json

Why it runs one process per model. The default --stage all re-execs itself once per checkpoint (--stage ref, then --stage cand), each writing its first-token log-probs to a ~682 MiB .pt cache, then scores from the caches. This is not tidiness — it is the only teardown that works. Measured, free VRAM after the reference model:

teardown free VRAM
del model + gc.collect() + empty_cache() 45,287 MiB
the same, model confined to an inner frame that exits 45,287 MiB
the process exits 96,689 MiB

The weights survive both in-process teardowns. The very first run only completed because PyTorch's allocator hit OOM on the second load, collected, and retried — the second model landed on the card by rescue, not by design, and on this architecture a silent CPU offload does not raise, it returns confident garbage (gotcha 1). The headroom gate (exit 10) is what turned that from an invisible near-miss into a loud failure. Side benefit: the ref cache is reusable, so measuring a different candidate against the same stock model skips a stage entirely (--stage cand then --stage score).

⚠️ The old residency gate could not fail. It read hf_device_map, which transformers leaves empty when the whole model fits on one device — so it printed "(unsharded)" both when everything was fine and when there was nothing to inspect. It now reads {p.device for p in model.parameters()} and prints the real placement (all parameters on cuda:0).

Why the write is shard surgery, not model.save_pretrained

The --out path edits the 18 safetensors shards directly and never instantiates a model for the write. This is correctness, not thrift. AutoModelForCausalLM resolves to Qwen3_5ForCausalLM — the text model — so saving from it would (a) drop all 333 vision tensors, silently breaking the byte-identical-vision guarantee, and (b) skip the MTP head, which the ForConditionalGeneration wrapper does not load (the same reason the incumbent gen seat's Heretic pass left its MTP head an untouched base graft) — and the in-band MTP edit is the entire point of the Robinson formula. Neither failure raises. Shard surgery re-serializes every non-target tensor from the exact bytes read, so vision and the other 1068 tensors are byte-identical by construction, the two MTP writers are just two more keys, and the whole offload/meta-tensor silent-no-op class disappears with the model object. Math is done in fp32, stored back at the original bf16.

Verify after (do not trust the write blind)

  1. Vision byte-identical + target countservices/coldfusion-abliteration verify: targets changed=131/131 vision identical=333/333 delta=0.0 other differ=0/735. Done 2026-08-20, clean.
  2. Refusal re-profile — the ad-hoc battery above is a smoke test. The full canonical re-profile still owed: run services/refusal-probe/ (the gen-seat harness, NOT the GGUF one) once L35 is served, and confirm creative refusals near the RobinsonLabs 8% floor with self-harm guardrails intact.
  3. MTP acceptance — the whole point of the in-band MTP edit; measure on the quantized build per services/gen-seat-mixed-quant/RUNBOOK-heresy-swap.md. Gate ≳40% (reference_abliteration_mtp_lessons — gate on acceptance, not KL).
  4. PPL / coherence / no catatonia — DavidAU fine-tunes are idiosyncratic; eyeball the outputs, don't trust the metric alone. (Smoke: coherent, no catatonia observed.)

Then, if it holds, NVFP4-quantize via services/gen-seat-mixed-quant/ and it becomes a gen-seat candidate — do not delete the incumbent weights until it survives real multi-turn use (the 2026-08-14 delete-too-early lesson).

⚠️ Environment gotchas (2026-08-20 — cost real time, read before re-running)

⚠️ RETRACTED 2026-08-20 — the "bf16 NaNs, use fp32" rule that lived here was a misdiagnosis, and it sent the next session down a 111 GB dead end. The NaN was never precision. It was the two defects below. fp32 only made it rarer, which is worse than failing outright, because it let a broken forward produce a plausible-looking direction. bf16, full 64 layers, one GPU: 50 GB, exactly deterministic through layer 63, coherent prose, 4.3× the throughput.

1. Never let the capture shard across both GPUs. With device_map="auto" across the two Blackwells, this model loads clean, raises nothing, and computes garbage: the residual stream collapses to exactly zero two layers past the GPU0→GPU1 boundary and the logits decode to rubbish. Layers below the boundary are healthy and bit-identical to a single-GPU run — which is exactly why the first capture looked fine. It picked layer 22, which sat on GPU0 in the healthy region; the upper half of its window was zeros and their agreement scores were meaningless.

Run CUDA_VISIBLE_DEVICES=0. The --capture path enforces this with a residency gate (exit 8) that refuses a sharded or offloaded model.

2. Never set PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True. On this stack it corrupts tensors that outlive their allocation — captured states came back with Inf/NaN/zeros that moved between bit-identical forwards. Unset, the same forwards are exactly reproducible. The old runbook recommended this flag for headroom; it buys corruption. Gated (exit 9).

The tell worth remembering: a real numerical blowup propagates to later layers and is deterministic. This did neither. If a NaN doesn't propagate, debug memory, not math.

3. bf16 fits on one GPU — but it needs BOTH GPU0 seats stopped, not one.

⚠️ CORRECTED 2026-08-20. This section used to read "50.1 GB … a capture needs only vllm-gen stopped." The unit was wrong and the conclusion that rode on it was wrong. The real figure is 50.10 GiB = 51,300 MiB = 53.8 GB of text-only weights, measured from the safetensors headers rather than read off a /1e9 print:

GB GiB MiB
checkpoint total 55.56 51.75 52,989
vision (not loaded by AutoModelForCausalLM) 0.92 0.86 879
MTP (not loaded either) 0.85 0.79 810
text-only — what actually lands on the card 53.79 50.10 51,300

GPU0's two tenants are meromero (50,072 MiB) and gen (46,304 MiB), and freeing either one alone leaves at most 50,933 MiB — about 400 MiB short. A run that assumes one seat is enough will stop a service, sit at the edge, and then OOM. Stop both. Recompute this table if the checkpoint changes; do not trust a remembered gigabyte figure.

Both seats down leaves ~97,200 MiB, so the fit is comfortable rather than marginal. Gate the run on observing the free VRAM (nvidia-smi --query-gpu=memory.free) rather than sleeping after docker stop, and put the restore in a trap ... EXIT so an abort hands the seats back — the 2026-08-20 aborted window did exactly that and cost nothing but two minutes.

(--capture-dtype float32 remains as an escape hatch; it needs 111 GB, so it also needs --max-layer 46 to fit on one card. The two agree to 0.0005, so there is no reason to reach for it.) Restore after: start vllm-meromero-rp first, then vllm-gen. (The stated reason — "gen grabs a fraction of free VRAM" — is not what the configs do: both seats pass --gpu-memory-utilization as a fraction of total (MEROMERO_GPU_MEM_UTIL=0.52, GEN_GPU_MEM_UTIL=0.43), so restore order is not actually load-bearing. Kept as the runbook order anyway; it costs nothing.)

4. fla is irrelevant here — but harmless. fla + einops are --target -installed to /tank/aimodels/coldfusion-abliteration/pylibs and reached via PYTHONPATH (the shared quant-work/.venv is not llmuser-writable). Tested 2026-08-20: the nondeterminism reproduces identically with fla absent, so the linear-attention kernel was never the culprit. Keep passing PYTHONPATH; just don't blame it.

Status

Harness written 2026-08-19; bf16 fully staged. Dry-run PASSED (recipe maps 1:1, 131 tensors). --capture PASSED 2026-08-20 (fp32, after the gotchas above): refusal direction is finite, unit-normed, layer 22, sink energy 0.0008% in dim 3994 (recipe L26 ref 0.06%, threshold 1%) — clean, not sink-dominated. Saved to qwen38-27b-coldfusion-bf16/refusal-direction.pt.

2026-08-20, second session — the corpus hypothesis is FALSIFIED

Measured, on a forward that is trustworthy for the first time:

calibration layer |cos| agreement sink energy
8 / 8 (legacy) 22 0.5944 0.001%
416 / 416 (Robinson's corpus) 18 0.6238 0.360%

52× more calibration data bought +0.03. The small calibration set was not why agreement sat at 0.59, and Robinson's 0.9925 is not reachable on this checkpoint by adding prompts. Agreement is uniformly ~0.540.62 across the whole healthy window (L18 0.6238, L22 0.6158, L21 0.6101, L19 0.5944, L28 0.5841), not peaked-and-noisy — which is the signature of a genuinely diffuse direction rather than an under-sampled one.

Cross-validated two ways: the 8/8 run reproduces the previous session's 0.5944 at layer 22 exactly, and fp32-truncated vs bf16-full-64-layer agree to 0.0005. So the number is real and the pipeline is sound.

⚠️ The first capture's log reported 0.594 as |cos|=0.8538. Reporting bug, fixed: the line printed the global agree.max() next to the window's argmax layer. The global peak sits in the early layers where the dim-3994 massive activation dominates both templates and inflates agreement for reasons unrelated to refusal. 0.5944 was always the real number.

The leading explanation is the metric, not the model. The two renderings do not just differ in formatting — they leave the model in different generative modes at the token we read:

  • enable_thinking=false ends …<think>\n\n</think>\n\n → about to write the answer
  • xhigh ends …<think>\n → about to write chain-of-thought

So |cos| here measures refusal semantics plus answer-vs-reason mode. Robinson's stock Qwen3.8-27B scored 0.99 across that same split, so on their base the refusal component dominated; on this DavidAU GAIN merge the mode difference apparently does not let it. Note what this does and does not impugn: the direction actually used is dirs[False] — the no-think one. Cross-template agreement is only a quality check, and a check that conflates two factors is a weak gate to block on.

The check that would actually settle it is a split-half. Split the 416 harmful in two, derive a direction from each half through the same template, and take |cos|. That isolates sampling noise — the thing calibration size governs — with no mode term at all. If split-half is ~0.99, the direction is well-estimated, the 0.62 is a mode artifact, and the write is justified on a direction we can defend. If split-half is also ~0.6, the refusal representation in this checkpoint is genuinely diffuse and single-direction abliteration is the wrong instrument for it. Cheap: no extra forwards, just two accumulators.

Status: the destructive --out write has NOT been executed. It gates on the operator's go. The saved direction (refusal-direction.pt, layer 18, 416/416, sink 0.360%) is usable but its quality is unresolved pending the split-half. The legacy 8/8 direction is preserved at refusal-direction.pt.bak-8x8.