Files
esh-pfi-infrastructure/services/coldfusion-abliteration/heretic_export.py
T
vh f90a5025de feat(coldfusion-abliteration): Heretic-300 — 8/100 refusals at KL 0.0136, beats the heresy bar 3.6x
Ran Heretic v1.4.0's 300-trial TPE search on Cold-Fusion. Best trial scores
8/100 refusals at KL 0.0136 against a 98/100 base, versus absolute-heresy at
29/100 and our hand-tuned Robinson L35 at 72/100 / KL 0.0116 — i.e. 64 fewer
refusals for the same damage. Hand-verified coherent: correct arithmetic with
shown working, clean code, 66-167 word prose across nine probes.

Durable findings:

- direction_scope=0 (single shared direction) is decisive on this merged base:
  n=129, best 8/100. Per-layer directions n=131 never beat 52/100 despite a
  better median. Points against the multi-direction intuition for a diffuse
  direction (our two-template |cos| is 0.62 vs Robinson's 0.99 on stock).
- Aggression is not the lever. r(KL, refusals) = -0.561 over 261 trials; the
  KL<0.02 band contains both the worst results (median 87/100) and the single
  best. A KL 0.3554 trial scored worse than one at 0.0193.
- PR #317 confirmed: Heretic silently drops the MTP head on save. Source 1199
  tensors -> export 1184, all 15 mtp.* gone, vision 333/333 intact, exit 0, no
  warning. This is also why absolute-heresy ships a byte-identical MTP head —
  a bug, not a design choice. Always diff tensor keys after a Heretic export.
- Heretic's recovered direction carries 6.18% of its energy in sink dim 3994,
  versus 0.094% for our L35 and 1.97% for the L39 we rejected as brick-inducing.
  It survives that only because of magnitude-preserving ablation
  (row_normalization=FULL); our plain projection has no such protection, so the
  sink screen correctly refused the in-band MTP graft. Same direction, different
  operation. MPOA is the prerequisite for in-band MTP on a Heretic trunk.
- Heretic's edit is recoverable from weights: delta is rank-1 (s2/s1 ~ 0.010),
  SVD gives the direction, norms give per-layer weights (1.08 -> 1.34, i.e.
  over-projection). Cross-layer |cos| agreement 0.9903 independently confirms
  the single-direction result.

New tooling in services/coldfusion-abliteration/:
  kl_divergence.py    first-token KL, class-split, zero noise floor
  catatonia_gate.py   12 probes x 220 tokens, prints every completion
  heretic_export.py   PTY driver; selects by measured value, never by menu
                      position — Heretic's resume prompt puts "delete the
                      checkpoint and all results" one arrow-key from the target
  graft_mtp.py        recovers the trunk direction by SVD; --pristine for the
                      safe path when the sink screen refuses

Also adds quant playbook 3.13: the NVFP4 recipe sets observer="imatrix_mse" but
llm-compressor has always silently fallen back to uniform MSE for want of
importance data — on this build and on the incumbent. Existing A/B comparisons
stay valid since every build shares the fallback. Parked as id 42.

Guardrail note: this build has lost the self-harm guardrail that the Robinson
L35 build retained. Restoration is the operator's own work item.
2026-08-20 22:51:56 -07:00

290 lines
12 KiB
Python

#!/usr/bin/env python3
"""Drive Heretic's interactive Pareto menu non-interactively, and export a chosen trial.
WHY THIS EXISTS
---------------
Heretic finishes a search by printing the Pareto-optimal trials and opening a
`questionary` menu to pick one. `--export-strategy MERGE` chooses HOW to export,
not WHICH trial — the selection is a separate prompt. Run under `nohup`/`</dev/null`
the menu raises `Warning: Input is not a terminal` and the whole run exits 1 with
300 trials of completed search stranded in the study checkpoint.
Nothing is lost when that happens: re-running with the same `--study-checkpoint-dir`
reloads the finished study and goes straight back to the menu. This script gives
that menu a real PTY, reads what it offers, picks by MEASURED VALUE rather than by
position, and answers the follow-up prompts.
WHY NOT --reproduce
-------------------
`reproduce.json` carries only `direction_index` + per-component kernel params. The
published examples are Heretic 1.2.0 and predate `direction_scope`, which 1.4.0
uses to switch between a numeric direction index and per-layer directions. Our
winning trial is scope=0; hand-writing a file whose schema cannot express that
risks silently reproducing a different ablation than the one we measured. Driving
the real menu keeps Heretic's own selection logic authoritative.
SELECTION IS BY VALUE, NOT INDEX
--------------------------------
The menu is ordered by Heretic, not by us, and the Pareto set changes with the
study. Choosing "the Nth entry" would be exactly the positional-inference mistake
that produced four wrong readings during this run. So: parse every menu line for
its refusal count and KL, pick the line whose numbers match the target, and refuse
to guess if no line matches.
"""
from __future__ import annotations
import argparse
import os
import pty
import re
import select
import sys
import time
# Heretic's real row format, confirmed by capturing the live menu:
# » [Trial 260] Refusals: 8/100, KL divergence: 0.0136
# The count comes AFTER the label. An earlier guess had it before, matched
# nothing, and the driver correctly refused to navigate rather than guess.
ROW = re.compile(r"\[Trial\s+(\d+)\]\s*Refusals:\s*(\d+)\s*/\s*\d+.*?"
r"KL\s+divergence:\s*([0-9.]+)", re.I)
ANSI = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[=>]")
DOWN, UP, ENTER = b"\x1b[B", b"\x1b[A", b"\r"
def clean(s: str) -> str:
return ANSI.sub("", s)
class Driver:
def __init__(self, argv, log_path, timeout=1800, quiet_for=2.5):
self.argv, self.timeout, self.quiet_for = argv, timeout, quiet_for
self.log = open(log_path, "w", buffering=1, errors="replace")
self.buf = ""
def start(self):
self.pid, self.fd = pty.fork()
if self.pid == 0: # child
os.execvp(self.argv[0], self.argv)
os.set_blocking(self.fd, False)
def pump(self, until_quiet=None, deadline=None):
"""Read until the child goes quiet for `until_quiet` seconds (or deadline)."""
until_quiet = self.quiet_for if until_quiet is None else until_quiet
deadline = deadline or (time.time() + self.timeout)
last = time.time()
while time.time() < deadline:
r, _, _ = select.select([self.fd], [], [], 0.4)
if r:
try:
chunk = os.read(self.fd, 65536)
except OSError:
break
if not chunk:
break
text = chunk.decode("utf-8", "replace")
self.buf += text
self.log.write(text)
sys.stdout.write(text)
sys.stdout.flush()
last = time.time()
elif time.time() - last > until_quiet:
return True
return False
def pump_until(self, pattern, deadline_s, label=""):
"""Read until `pattern` appears in the buffer. Returns True if seen.
Waiting for a MARKER, not for silence. A quiet-based wait cannot survive
the model-load phase: pulling 52 GB off ZFS and quantizing it pauses for
longer than any sane quiet threshold, so 'it went quiet' means 'the disk
stalled', not 'it is ready for input'.
"""
rx = re.compile(pattern, re.I)
end = time.time() + deadline_s
while time.time() < end:
if rx.search(clean(self.buf)):
return True
r, _, _ = select.select([self.fd], [], [], 1.0)
if r:
try:
chunk = os.read(self.fd, 65536)
except OSError:
break
if not chunk:
break
text = chunk.decode("utf-8", "replace")
self.buf += text
self.log.write(text)
sys.stdout.write(text)
sys.stdout.flush()
print(f"\n[driver] TIMEOUT waiting for {label or pattern!r}", file=sys.stderr)
return False
def send(self, data, times=1, pause=0.12):
for _ in range(times):
os.write(self.fd, data)
time.sleep(pause)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--model", required=True)
ap.add_argument("--study-checkpoint-dir", required=True)
ap.add_argument("--out", required=True, help="directory to save the merged model to")
ap.add_argument("--target-refusals", type=int, required=True)
ap.add_argument("--target-kl", type=float, required=True)
ap.add_argument("--kl-tolerance", type=float, default=0.002)
ap.add_argument("--n-trials", type=int, default=300)
ap.add_argument("--quantization", default="BNB_4BIT")
ap.add_argument("--log", default="/tmp/heretic-export-pty.log")
ap.add_argument("--dry-run", action="store_true",
help="show the menu and the chosen row, then quit without saving")
args = ap.parse_args()
argv = ["/home/infra-ops/.local/bin/heretic",
"--model", args.model,
"--study-checkpoint-dir", args.study_checkpoint_dir,
"--n-trials", str(args.n_trials),
"--quantization", args.quantization,
"--export-strategy", "MERGE"]
d = Driver(argv, args.log)
print(f"[driver] spawning: {' '.join(argv)}\n", flush=True)
d.start()
# Model load, then a RESUME PROMPT before the Pareto menu:
#
# » Show the results from the previous run
# Ignore the previous run and start from scratch <-- DELETES the checkpoint
# Exit program
#
# The destructive option is one arrow-key away from the one we want, and it
# discards every completed trial. So: never send an arrow here. Verify the
# highlighted default IS the show-results row, then send a bare ENTER. If the
# default is anything else, abort and let a human look.
print("\n[driver] waiting for the resume prompt …\n", flush=True)
d.pump(until_quiet=6.0, deadline=time.time() + 1500)
if "How would you like to proceed" in clean(d.buf):
default_row = ""
for line in clean(d.buf).splitlines():
if "»" in line:
default_row = line.strip()
print(f"[driver] resume prompt detected; highlighted default: {default_row!r}")
if "Show the results" not in default_row:
print("[driver] !! the highlighted option is NOT 'Show the results from the "
"previous run'. Refusing to press ENTER — the adjacent option deletes "
"the study checkpoint. Inspect the PTY log.", file=sys.stderr)
os.write(d.fd, b"\x03")
sys.exit(5)
print("[driver] accepting default with a bare ENTER (no arrows near the destructive option)")
d.send(ENTER)
# Model load happens HERE (~1-3 min: 52 GB off ZFS, then 4-bit quantize).
# Wait for the Pareto banner by name; a quiet-based wait mistakes a disk
# stall for readiness and bails mid-load.
print("\n[driver] waiting for the Pareto banner (model load first) …\n", flush=True)
if not d.pump_until(r"Pareto optimal", 2400, "Pareto banner"):
os.write(d.fd, b"\x03")
sys.exit(6)
# Banner seen; let the menu itself finish rendering.
d.pump(until_quiet=4.0, deadline=time.time() + 300)
screen = clean(d.buf)
# Only the CURRENT menu render matters — the buffer holds every repaint, so
# parsing the whole thing would stack duplicate rows and wreck the index
# arithmetic. Take the last block that contains the prompt.
if "Which trial do you want to use" in screen:
screen = screen[screen.rindex("Which trial do you want to use"):]
rows, seen = [], set()
for line in screen.splitlines():
m = ROW.search(line)
if m:
trial, refus, kl = int(m.group(1)), int(m.group(2)), float(m.group(3))
if trial in seen:
continue
seen.add(trial)
rows.append((refus, kl, f"[Trial {trial}] {refus}/100 KL {kl:.4f}"))
print("\n" + "=" * 72)
print(f"[driver] parsed {len(rows)} candidate menu rows:")
for r, k, raw in rows:
print(f" refusals {r:>3} KL {k:.4f} | {raw[:70]}")
if not rows:
# The row regex is a guess at Heretic's wording. If it matched nothing,
# dump the menu verbatim so the format can be read rather than guessed at
# a second time.
print("\n[driver] ROW REGEX MATCHED NOTHING — verbatim tail of the screen:")
tail = [ln for ln in screen.splitlines() if ln.strip()][-45:]
for ln in tail:
print(f" | {ln[:150]}")
match = [i for i, (r, k, _) in enumerate(rows)
if r == args.target_refusals and abs(k - args.target_kl) <= args.kl_tolerance]
if not match:
print(f"\n[driver] !! no menu row matches {args.target_refusals} refusals @ KL "
f"{args.target_kl}±{args.kl_tolerance}. NOT guessing a position — "
f"inspect {args.log} and rerun with corrected targets.", file=sys.stderr)
os.write(d.fd, b"\x03")
sys.exit(4)
idx = match[0]
print(f"\n[driver] selecting row {idx} -> {rows[idx][2][:70]}")
if args.dry_run:
print("[driver] dry-run, sending SIGINT")
os.write(d.fd, b"\x03")
sys.exit(0)
d.send(DOWN, times=idx) # menu starts highlighted on row 0
d.send(ENTER)
# --- post-selection action menu -----------------------------------------
# This is a questionary SELECT, which ignores typed text (typing only filters
# in an autocomplete). So it has to be driven by arrows — and therefore by
# reading the options, not by assuming their order. Heretic re-applies the
# ablation before showing it ("Resetting model... Abliterating..."), so wait
# for the prompt itself rather than for a pause.
print("\n[driver] waiting for the action menu …")
if not d.pump_until(r"\?\s+What (do you want|would you like)", 1800, "action menu"):
os.write(d.fd, b"\x03")
sys.exit(7)
d.pump(until_quiet=3.0, deadline=time.time() + 120)
scr = clean(d.buf)
scr = scr[scr.rindex("?"):] if "?" in scr else scr
opts = []
for line in scr.splitlines():
s = line.strip()
if s.startswith("»"):
opts.append((s.lstrip("» ").strip(), True))
elif s and not s.startswith("?") and len(opts) and len(s) < 90:
opts.append((s, False))
print("[driver] action menu options:")
for i, (t, cur) in enumerate(opts):
print(f" {i}{' *' if cur else ' '} {t[:70]}")
want = [i for i, (t, _) in enumerate(opts) if re.search(r"\bsave\b", t, re.I)]
if not want:
print("[driver] !! no option matching 'save' — NOT guessing. Menu dumped above.",
file=sys.stderr)
os.write(d.fd, b"\x03")
sys.exit(8)
cur = next((i for i, (_, c) in enumerate(opts) if c), 0)
delta = want[0] - cur
print(f"[driver] moving {delta:+d} to '{opts[want[0]][0][:50]}' and selecting")
d.send(DOWN if delta > 0 else UP, times=abs(delta))
d.send(ENTER)
d.pump(until_quiet=3.0, deadline=time.time() + 120)
# Path prompt IS a text input, so typing is correct here.
print(f"\n[driver] answering path prompt with {args.out}")
d.send(args.out.encode())
d.send(ENTER)
print("\n[driver] saving (this writes ~52 GB, be patient) …\n")
ok = d.pump(until_quiet=90.0, deadline=time.time() + 5400)
print(f"\n[driver] {'child quiet' if ok else 'deadline hit'} — see {args.log}")
os.write(d.fd, b"\x03")
if __name__ == "__main__":
main()