Files
TheTomandClaude Opus 4.7 87b95b8e73 release(refract): v0.3.2.2 — --prompts defaults to bundled
Mac mini install walkthrough (2026-05-03) found that `refract score`
required explicit --prompts even though the wheel ships the v0.1
prompts file. After `pip install refract-llm` a user shouldn't need
to know where the bundled file lives.

Now: when --prompts is omitted, _run_score resolves the bundled
refract/prompts/v0.1.jsonl via importlib.resources. Minimum viable
score invocation drops to:

    refract score --backend mlx --model /path/to/model --candidate ctk=q8_0,ctv=q8_0

README install section flipped to lead with `pip install refract-llm`
(PyPI path) and demote the source-clone path to "for hacking".

Walkthrough also surfaced two non-blocking findings (kept as separate
follow-ups):
- macOS system python3 is 3.9, mlx-lm needs 3.10+; users hit this
- KLD axis returned NaN on Qwen2.5-1.5B-Instruct-8bit MLX — separate
  numerical-stability bug, not a packaging issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: tturney1@gmail.com <tturney1@gmail.com>
2026-05-03 20:10:25 -05:00

1045 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""REFRACT v0.1 CLI.
Usage:
python3 -m refract.cli score \\
--model MODEL.gguf \\
--reference "ctk=f16,ctv=f16" \\
--candidate "ctk=q8_0,ctv=turbo4,attn_rot_v=0" \\
--prompts refract/prompts/v0.1.jsonl \\
--corpus ~/local_llms/llama.cpp/wikitext-2-raw/wiki.test.raw \\
--chunks 32 -c 512 -ngl 99
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from .axes.gtm import run_gtm
from .axes.kld import run_kld
from .axes.plad import run_plad
from .axes.rniah import run_rniah
from .axes.trajectory import run_trajectory
from .report import json_report, text_report, to_json_string
from .runner import KVConfig
from .score import MIN_FLOOR, composite_score
_SCORE_DESCRIPTION = """\
REFRACT v0.2 — score a KV-cache quantization config against a fp16-KV reference
on the same model. Returns a 0–100 composite + EXCELLENT/PASS/DEGRADED/FAIL
band, with per-axis bands and one-line plain-English interpretation.
Four axes (A and B are cheap and run by default; C and D are opt-in):
A — GTM / Trajectory Greedy Trajectory Match.
Greedy-decode N tokens from each prompt under both
the reference and candidate KV configs; compare the
emitted token IDs. Score = fraction of the candidate's
decoded tokens that match the reference. Catches "the
candidate generates different text" failures.
B — KLD@D KL Divergence at the Decoder.
Per-token KL between the candidate's next-token
distribution and the reference's, averaged across a
natural-text corpus. Bit-exact zero on Metal when
candidate == reference, so any non-zero is real
signal. Catches "distributions silently shift even
though argmax stayed the same" failures.
C — R-NIAH (--full) Retrieval Needle-In-A-Haystack.
Insert a sentinel fact ("the password is X") into a
long context at fractional positions; ask the model
to retrieve it. Score = 100 * (1 - mean candidate
degradation vs reference per (length, position) cell).
Catches "scores 99 on KLD@D but fails at 32K context"
failures that short-window axes miss.
D — PLAD (--full) Perturbation-Locality Aware Drift.
Generate anchor completions; perturb each prompt
minimally (typo, casing, punctuation, paraphrase);
compare candidate's drift vs reference's drift via
token edit distance. Catches "works on the demo,
breaks on real users with typos" brittleness.
Cost on a 7B Q8 model (Apple Silicon ballpark):
default Trajectory + KLD ~5-7 min
--full adds R-NIAH + PLAD ~25-30 min
Composite = harmonic mean of all axes scored. Any single axis being broken
drops the composite hard — the framework is intentionally fail-loud.
"""
_SCORE_EPILOG = """\
Examples:
Quick go/no-go (default; just trajectory + KLD):
python3 -m refract.cli score \\
--model model.gguf \\
--candidate "ctk=q8_0,ctv=q8_0" \\
--prompts refract/prompts/v0.1.jsonl \\
--corpus ~/local_llms/llama.cpp/wikitext-2-raw/wiki.test.raw \\
--axis-a trajectory
Full audit (all four axes):
python3 -m refract.cli score \\
--model model.gguf \\
--candidate "ctk=q8_0,ctv=turbo4" \\
--prompts refract/prompts/v0.1.jsonl \\
--corpus ~/local_llms/llama.cpp/wikitext-2-raw/wiki.test.raw \\
--axis-a trajectory \\
--full \\
--rniah-haystack ~/local_llms/llama.cpp/wikitext-2-raw/wiki.train.raw \\
--rniah-ctx-max 16384 \\
--json-out report.json
Borderline result triage (start cheap, add R-NIAH only if you need it):
# First pass: composite=72 DEGRADED → see which axis dragged it down
python3 -m refract.cli score ... (default)
# If KLD is fine but trajectory is bad → don't run R-NIAH; the issue is
# short-context distribution drift, not long-context retrieval.
# If KLD is fine AND trajectory is fine → run --axis-rniah; long-context
# retrieval is the surface most likely to be the hidden problem.
KV config syntax: comma-separated key=value pairs, e.g. "ctk=q8_0,ctv=turbo4".
Recognised keys: ctk, ctv, attn_rot_k, attn_rot_v, attn_rot_disable.
"""
_REFRACT_CACHE = Path.home() / ".cache" / "refract"
_WIKITEXT_2_URL = "https://huggingface.co/datasets/ggml-org/ci/resolve/main/wikitext-2-raw-v1.zip"
def _ensure_wikitext_2(cache_dir: Path = _REFRACT_CACHE,
silent: bool = False) -> Path:
"""Make sure wikitext-2-raw is downloaded + extracted under
``cache_dir/wikitext-2-raw/``. Returns that directory.
Idempotent: re-running is a no-op when files already exist.
Network: ~10 MB single zip, ~30 s on a typical home connection.
No third-party deps; uses stdlib urllib + zipfile.
"""
import urllib.request
import zipfile
target = cache_dir / "wikitext-2-raw"
test_p = target / "wiki.test.raw"
train_p = target / "wiki.train.raw"
if test_p.exists() and train_p.exists():
return target
cache_dir.mkdir(parents=True, exist_ok=True)
zip_path = cache_dir / "wikitext-2-raw-v1.zip"
if not silent:
print(f"Downloading wikitext-2-raw (~10MB) → {target} ...")
urllib.request.urlretrieve(_WIKITEXT_2_URL, zip_path)
if not silent:
print("Unzipping ...")
with zipfile.ZipFile(zip_path) as zf:
zf.extractall(cache_dir)
try:
zip_path.unlink()
except OSError:
pass
if not test_p.exists() or not train_p.exists():
raise RuntimeError(
f"Wikitext-2 unzip didn't produce expected files at {target}. "
f"Inspect {cache_dir} or fetch manually."
)
if not silent:
print(f"✓ Cached at {target}")
return target
def _resolve_default_paths(args, *, need_corpus: bool, need_haystack: bool):
"""Auto-resolve --corpus and --rniah-haystack from the wikitext-2 cache.
If the cache is missing AND ``--no-auto-fetch`` is NOT set, this
triggers a one-time wikitext-2 download. With ``--no-auto-fetch``,
raises with a clear remediation message.
"""
if not (need_corpus or need_haystack):
return
have_corpus = bool(getattr(args, "corpus", None))
have_haystack = bool(getattr(args, "rniah_haystack", None))
if (need_corpus and have_corpus) and (not need_haystack or have_haystack):
return
no_fetch = bool(getattr(args, "no_auto_fetch", False))
target = _REFRACT_CACHE / "wikitext-2-raw"
have_cache = (target / "wiki.test.raw").exists() and (target / "wiki.train.raw").exists()
if not have_cache:
if no_fetch:
raise SystemExit(
"Missing --corpus (and/or --rniah-haystack) and "
"--no-auto-fetch was passed. Either pass paths explicitly "
"or drop --no-auto-fetch to let REFRACT download "
"wikitext-2-raw to ~/.cache/refract/ (~10MB)."
)
_ensure_wikitext_2()
if need_corpus and not have_corpus:
args.corpus = target / "wiki.test.raw"
print(f" using cached corpus : {args.corpus}")
if need_haystack and not have_haystack:
args.rniah_haystack = target / "wiki.train.raw"
print(f" using cached haystack: {args.rniah_haystack}")
def _add_score_parser(sub):
import argparse as _ap
p = sub.add_parser(
"score",
help="Score a candidate KV config vs the fp16-KV reference (4 axes).",
description=_SCORE_DESCRIPTION,
epilog=_SCORE_EPILOG,
formatter_class=_ap.RawDescriptionHelpFormatter,
)
p.add_argument("--model", required=True, type=Path,
help="Path to the GGUF model.")
p.add_argument("--reference", default="ctk=f16,ctv=f16",
help="Reference KV config (default: ctk=f16,ctv=f16).")
p.add_argument("--candidate", required=True,
help="Candidate KV config to score.")
p.add_argument("--prompts", type=Path, default=None,
help="Path to JSONL prompts file. If omitted, REFRACT uses "
"the bundled refract/prompts/v0.1.jsonl shipped in the "
"wheel (30 CC0 prompts; see prompts/README.md).")
p.add_argument("--corpus", type=Path, default=None,
help="Path to plain-text corpus for KLD axis. "
"If omitted, REFRACT auto-downloads wikitext-2-raw "
"(~10MB) to ~/.cache/refract/ and uses wiki.test.raw. "
"Pass --no-auto-fetch to require an explicit path.")
p.add_argument("--no-auto-fetch", action="store_true",
help="Disable auto-download of wikitext-2-raw. Requires "
"--corpus and --rniah-haystack to be passed "
"explicitly.")
p.add_argument("--chunks", type=int, default=32,
help="--chunks for llama-perplexity (default: 32).")
p.add_argument("-c", "--ctx", type=int, default=512,
help="Context size (default: 512).")
p.add_argument("-ngl", "--n-gpu-layers", type=int, default=99,
help="-ngl flag (default: 99).")
p.add_argument("--n-predict", type=int, default=128,
help="Tokens to greedy-decode per GTM prompt (default: 128).")
p.add_argument("--seed", type=int, default=42,
help="Greedy seed (default: 42).")
p.add_argument("--measure-floor", action="store_true",
help="Measure REFRACT(ref, ref) and abort if < %.1f."
% MIN_FLOOR)
p.add_argument("--skip-gtm", action="store_true",
help="Skip Axis A. Composite uses KLD only (debug).")
p.add_argument("--skip-kld", action="store_true",
help="Skip Axis B. Composite uses GTM only (debug).")
p.add_argument("--axis-a", choices=["gtm", "trajectory"], default="trajectory",
help="Axis A implementation. 'trajectory' (default, v0.1.4+) "
"captures token IDs at decode time via the patched "
"llama-completion binary — no detokenize round-trip. "
"'gtm' (deprecated, v0.1.x) compares retokenized text "
"and has known unit-mismatch artifacts.")
p.add_argument("--backend", choices=["auto", "llamacpp", "mlx", "vllm"],
default="auto",
help="Inference backend. 'auto' (default) infers from "
"model path: .gguf → llamacpp; directory → mlx. "
"Override via REFRACT_BACKEND env var.")
p.add_argument("--full", action="store_true",
help="Enable all v0.2 axes (R-NIAH + PLAD). Equivalent to "
"passing --axis-rniah --axis-plad. R-NIAH still "
"requires --rniah-haystack and --rniah-ctx-max.")
p.add_argument("--axis-rniah", action="store_true",
help="Enable Axis C (R-NIAH, v0.2). Probes long-context "
"retrieval degradation. Requires --rniah-haystack "
"(auto-resolved from cache when omitted). Cost: "
"~10–15 min on a 7B Q8 at 16K, scaling roughly "
"linearly with max length.")
p.add_argument("--rniah-haystack", type=Path, default=None,
help="Path to long-text haystack corpus for R-NIAH. "
"Auto-resolved from ~/.cache/refract/ when omitted "
"(wiki.train.raw, fits cells up to ~16K cleanly).")
p.add_argument("--rniah-up-to", type=int, default=16384,
help="R-NIAH max context length to test. Lengths are "
"auto-generated as a doubling step-up starting at "
"4096 (4K, 8K, 16K, 32K, ... up to this value). "
"Default: 16384 (4K, 8K, 16K). Pass 32768 / 65536 / "
"131072 for deeper long-context audits. Cells are "
"always run at positions 0.10/0.50/0.90 unless "
"overridden via --rniah-positions.")
p.add_argument("--rniah-ctx-max", type=int, default=None,
help="(Power-user) hard ceiling for R-NIAH cells. Cells "
"longer than this are skipped. Defaults to "
"--rniah-up-to. Useful when the model cannot "
"actually do its nominal max context.")
p.add_argument("--rniah-lengths", type=str, default=None,
help="(Power-user) comma-separated R-NIAH lengths, "
"overrides --rniah-up-to. Default: synthesised "
"from --rniah-up-to.")
p.add_argument("--rniah-positions", type=str, default=None,
help="Comma-separated R-NIAH needle positions as "
"fractions of length. Default: 0.10,0.50,0.90.")
p.add_argument("--rniah-trials", type=int, default=1,
help="Trials per cell. Default: 1.")
p.add_argument("--axis-plad", action="store_true",
help="Enable Axis D (PLAD, v0.2). Probes brittleness "
"to small prompt perturbations. Reuses --prompts. "
"Cost: ~5–7 min on a 7B Q8 "
"(prompts × (1 + n_perturbations) × 2 generations).")
p.add_argument("--json-out", type=Path, default=None,
help="Path to write the JSON report to.")
p.add_argument("--html-out", type=Path, default=None,
help="Path to write a self-contained HTML report (single "
"file with inline CSS, no JS deps). Includes hardware "
"info, model params, and the exact repro command.")
p.add_argument("--no-progress", action="store_true",
help="Suppress per-prompt progress output.")
return p
def _run_score(args) -> int:
from . import __version__
from .backends import auto_backend, get_backend
from .runner import set_active_backend
# If --prompts wasn't passed, fall back to the bundled prompts file
# shipped inside the wheel via package_data.
if args.prompts is None:
try:
import importlib.resources
args.prompts = Path(
str(importlib.resources.files("refract").joinpath(
"prompts/v0.1.jsonl"))
)
except Exception as e:
print(
f"ERROR: --prompts not given and bundled prompts not found "
f"({e}). Pass --prompts /path/to/prompts.jsonl explicitly."
)
return 2
ref_kv = KVConfig.parse(args.reference)
cand_kv = KVConfig.parse(args.candidate)
# v0.3.1: select backend per --backend flag (or auto-detect from model path).
if args.backend == "auto":
backend = auto_backend(args.model)
else:
backend = get_backend(args.backend)
set_active_backend(backend)
# --full is sugar for the two opt-in v0.2 axes.
if args.full:
args.axis_rniah = True
args.axis_plad = True
# v0.3.2: auto-resolve corpus + haystack from ~/.cache/refract/ when
# the user didn't pass them. Triggers a one-time wikitext-2-raw fetch
# if the cache is empty and --no-auto-fetch isn't set.
_resolve_default_paths(
args, need_corpus=not args.skip_kld,
need_haystack=args.axis_rniah,
)
# Cost hint up front so the user knows what they're committing to.
cost_axes = ["A (~2 min)", "B (~5 min)"]
if args.axis_rniah:
cost_axes.append("C R-NIAH (~10-15 min)")
if args.axis_plad:
cost_axes.append("D PLAD (~5-7 min)")
cost_hint = " + ".join(cost_axes)
print(f"REFRACT v{__version__}")
print(f" model : {args.model}")
print(f" reference : {ref_kv.label()}")
print(f" candidate : {cand_kv.label()}")
print(f" backend : {backend.name}")
print(f" axes : {cost_hint} (estimates for a 7B Q8 model on Apple Silicon)")
# v0.3.1: thinking-mode auto-detect at startup. Reasoning is auto-disabled
# via -rea off in run_completion when backend supports it; we surface the
# detection so the user knows what we did and why.
thinking_detected, thinking_markers = False, []
try:
thinking_detected, thinking_markers = backend.detect_thinking_mode(
model=args.model, timeout=30,
)
except Exception as e:
print(f" thinking : probe failed ({e}); assuming non-thinking")
if thinking_detected:
print(f" thinking : DETECTED (markers: {thinking_markers}) — "
f"reasoning disabled, n_predict-aware fallback active")
else:
print(f" thinking : not detected")
print()
# ---- Floor check ------------------------------------------------------
floor_score = None
if args.measure_floor:
print("Measuring noise floor: REFRACT(ref, ref) ...")
floor_gtm = run_gtm(
model=args.model, reference_kv=ref_kv, candidate_kv=ref_kv,
prompts_path=args.prompts, n_predict=args.n_predict,
ctx=args.ctx, n_gpu_layers=args.n_gpu_layers, seed=args.seed,
progress=not args.no_progress,
)
floor_kld = run_kld(
model=args.model, corpus=args.corpus,
reference_kv=ref_kv, candidate_kv=ref_kv,
chunks=args.chunks, ctx=args.ctx,
n_gpu_layers=args.n_gpu_layers,
progress=not args.no_progress,
)
floor_composite = composite_score(floor_gtm.score, floor_kld.score)
floor_score = floor_composite.composite
print(f" floor: {floor_score:.2f} (gtm={floor_gtm.score:.2f}, "
f"kld={floor_kld.score:.2f}, kld nats={floor_kld.mean_kld:.6f})")
if floor_score < MIN_FLOOR:
print()
print(f"ERROR: noise floor {floor_score:.2f} < {MIN_FLOOR}.")
print("The reference itself is non-deterministic on this build.")
print("KLD deltas vs this reference cannot be trusted. Aborting.")
return 2
# v0.1.3: composite-level floor passes if KLD is ~100 (bit-exact zero
# on Metal) even when GTM is in a "high-score broken" state. Add a
# GTM-level byte-identity check: ref-vs-ref greedy must match for
# the FULL retokenized candidate length on every prompt. If
# mean_prefix != mean_cand, the tokenizer or runner is broken.
if floor_gtm.mean_cand_length > 0:
ratio = (
floor_gtm.mean_prefix_agreement_length
/ floor_gtm.mean_cand_length
)
else:
ratio = 0.0
if abs(ratio - 1.0) > 1e-9:
print()
print(
f"ERROR: GTM ref-vs-ref ratio = {ratio:.6f} (expected 1.0). "
f"mean_prefix={floor_gtm.mean_prefix_agreement_length:.2f}, "
f"mean_cand_length={floor_gtm.mean_cand_length:.2f}."
)
print(
"GTM ref-vs-ref isn't byte-identical, your tokenizer or "
"runner is broken. Aborting."
)
return 2
print()
# ---- Axis A (GTM or Trajectory) ---------------------------------------
if args.skip_gtm:
gtm = _stub_gtm()
else:
if args.axis_a == "trajectory":
print("Running Axis A (Trajectory, v0.1.4)...")
gtm = run_trajectory(
model=args.model, reference_kv=ref_kv, candidate_kv=cand_kv,
prompts_path=args.prompts, n_predict=args.n_predict,
ctx=args.ctx, n_gpu_layers=args.n_gpu_layers, seed=args.seed,
progress=not args.no_progress,
)
print(f" Trajectory score: {gtm.score:.2f}")
else:
print("Running Axis A (GTM, v0.1.x)...")
gtm = run_gtm(
model=args.model, reference_kv=ref_kv, candidate_kv=cand_kv,
prompts_path=args.prompts, n_predict=args.n_predict,
ctx=args.ctx, n_gpu_layers=args.n_gpu_layers, seed=args.seed,
progress=not args.no_progress,
)
print(f" GTM score: {gtm.score:.2f}")
# ---- KLD --------------------------------------------------------------
if args.skip_kld:
kld = _stub_kld(args.chunks, args.ctx)
else:
print("Running Axis B (KLD@D, corpus proxy)...")
kld = run_kld(
model=args.model, corpus=args.corpus,
reference_kv=ref_kv, candidate_kv=cand_kv,
chunks=args.chunks, ctx=args.ctx,
n_gpu_layers=args.n_gpu_layers,
progress=not args.no_progress,
)
print(f" KLD score: {kld.score:.2f} (mean KLD = {kld.mean_kld:.6f} nats)")
# ---- R-NIAH (v0.2 opt-in) ---------------------------------------------
rniah = None
if args.axis_rniah:
if args.rniah_haystack is None:
print("ERROR: --axis-rniah requires --rniah-haystack "
"(or run `refract fetch` first to populate the cache).")
return 2
# v0.3.2: synthesize lengths from --rniah-up-to (doubling step-up
# from 4K) when --rniah-lengths isn't set. Default --rniah-ctx-max
# to --rniah-up-to so the user only has one knob to think about.
if args.rniah_lengths:
lengths = tuple(int(x) for x in args.rniah_lengths.split(","))
else:
up_to = args.rniah_up_to
n = 4096
synth: list[int] = []
while n <= up_to:
synth.append(n)
n *= 2
if not synth:
synth = [up_to]
lengths = tuple(synth)
if args.rniah_ctx_max is None:
args.rniah_ctx_max = max(lengths)
positions = (
tuple(float(x) for x in args.rniah_positions.split(","))
if args.rniah_positions else None
)
kwargs: dict = {
"model": args.model,
"haystack_corpus": args.rniah_haystack,
"reference_kv": ref_kv,
"candidate_kv": cand_kv,
"ctx_max": args.rniah_ctx_max,
"n_trials": args.rniah_trials,
"n_gpu_layers": args.n_gpu_layers,
"seed": args.seed,
"progress": not args.no_progress,
}
if lengths is not None:
kwargs["lengths"] = lengths
if positions is not None:
kwargs["positions"] = positions
print("Running Axis C (R-NIAH, v0.2)...")
rniah = run_rniah(**kwargs)
print(f" R-NIAH score: {rniah.score:.2f} ({rniah.n_cells} cells)")
# ---- PLAD (v0.2 opt-in) -----------------------------------------------
plad = None
if args.axis_plad:
print("Running Axis D (PLAD, v0.2)...")
plad = run_plad(
model=args.model, prompts_path=args.prompts,
reference_kv=ref_kv, candidate_kv=cand_kv,
n_predict=args.n_predict, ctx=args.ctx,
n_gpu_layers=args.n_gpu_layers, seed=args.seed,
progress=not args.no_progress,
)
print(f" PLAD score: {plad.score:.2f}")
# ---- Composite --------------------------------------------------------
# Skipped axes contribute None to the composite (dropped before the
# harmonic mean) rather than a stub 100, which would inflate the
# composite and read as EXCELLENT in the report.
gtm_for_composite = None if args.skip_gtm else gtm.score
kld_for_composite = None if args.skip_kld else kld.score
composite = composite_score(
gtm_for_composite, kld_for_composite,
rniah_score=rniah.score if rniah else None,
plad_score=plad.score if plad else None,
floor_score=floor_score,
)
print()
print(text_report(
model=str(args.model),
reference_label=ref_kv.label(),
candidate_label=cand_kv.label(),
composite=composite,
gtm=gtm, kld=kld, rniah=rniah, plad=plad,
))
rep = None
if args.json_out or args.html_out:
rep = json_report(
model=str(args.model),
reference_label=ref_kv.label(),
candidate_label=cand_kv.label(),
composite=composite,
gtm=gtm, kld=kld, rniah=rniah, plad=plad,
)
if args.json_out:
args.json_out.write_text(to_json_string(rep))
print(f"\nJSON report written to {args.json_out}")
if args.html_out:
from .report_html import html_report
html = html_report(
model=str(args.model),
reference_label=ref_kv.label(),
candidate_label=cand_kv.label(),
composite=composite,
gtm=gtm, kld=kld, rniah=rniah, plad=plad,
raw_json=rep,
)
args.html_out.write_text(html)
print(f"HTML report written to {args.html_out}")
return 0
# Stubs for --skip-gtm / --skip-kld dev modes (composite still computes).
def _stub_gtm():
from .axes.gtm import GTMResult
return GTMResult(
score=100.0, full_match_rate=1.0,
median_first_divergence=None, mean_prefix_agreement_length=0.0,
mean_cand_length=0.0, mean_ref_length=0.0,
n_prompts=0, n_tokens_each=0, per_prompt=[],
)
def _stub_kld(chunks: int, ctx: int):
from .axes.kld import KLDResult
return KLDResult(
score=100.0, mean_kld=0.0, ppl=None,
rms_dp_pct=None, same_topp_pct=None,
base_path="", chunks=chunks, ctx=ctx, is_self_reference=False,
)
_TOP_DESCRIPTION = """\
REFRACT — REFerence-anchored Robust Acid-test for Compressed Transformers.
A benchmaxx-resistant alternative to corpus PPL for evaluating KV-cache
quantization quality on a model. Replaces "lower PPL = better" — a metric
that can invert sign on instruct-tuned models — with a 4-axis composite
anchored to the fp16-KV reference.
Subcommands:
score run REFRACT on a candidate KV config vs the reference
Run `refract score --help` for full options + usage examples.
"""
def _add_selftest_parser(sub):
p = sub.add_parser(
"selftest",
help="Preflight check before running score/repeatability.",
description=(
"Verifies the environment is ready for REFRACT: binaries / "
"imports, required CLI flags (--jinja, REFRACT_TRAJECTORY env), "
"KV cache types compiled in, model loadable, fp16-vs-fp16 "
"sanity generation. Bails out with a useful message on any "
"failure so you don't burn a long run finding out your setup "
"is broken.\n\n"
"Tip: pass --model for a real generation probe (~10-30s "
"depending on model size). Without --model, only static "
"binary/import checks run (~1s)."
),
)
p.add_argument("--backend", choices=["auto", "llamacpp", "mlx", "vllm"],
default="auto",
help="Backend to test. 'auto' picks llamacpp for .gguf "
"models and mlx for directories. Override via "
"REFRACT_BACKEND env.")
p.add_argument("--model", type=Path, default=None,
help="Optional model to use for a real generation probe. "
"Accepts both .gguf files (llamacpp) and MLX model "
"directories. If omitted, only static checks run.")
return p
def _run_selftest(args) -> int:
from . import __version__
from .backends import get_backend, auto_backend, BackendCapabilityError
print(f"REFRACT v{__version__} selftest")
print()
failures: list[str] = []
warnings: list[str] = []
# --- Backend selection ---
if args.backend == "auto":
if args.model:
backend = auto_backend(args.model)
else:
from .backends.llamacpp import LlamaCppBackend
backend = LlamaCppBackend()
else:
backend = get_backend(args.backend)
print(f"Backend: {backend.name}")
# --- Backend-specific binaries ---
if backend.name == "llamacpp":
from .runner import DEFAULT_BIN_DIR
print(f" bin dir : {DEFAULT_BIN_DIR}")
for tool in ("llama-cli", "llama-completion", "llama-tokenize",
"llama-perplexity"):
p = DEFAULT_BIN_DIR / tool
if p.exists():
print(f" ✓ {tool}")
else:
print(f" ✗ {tool} (set LLAMA_CPP_BIN_DIR or rebuild)")
failures.append(f"missing binary: {tool}")
# Check for --jinja flag support. Capture stderr too — when the
# binary can't load (e.g. Linux missing libllama.so on PATH, Windows
# missing the DLL next to the exe), --help fails *before* it can
# print its help text. Without checking the launch result, an empty
# stdout would cause a misleading "--jinja missing" diagnosis.
try:
import subprocess as _sp
proc = _sp.run(
[str(DEFAULT_BIN_DIR / "llama-completion"), "--help"],
capture_output=True, text=True, timeout=10,
)
help_text = proc.stdout
launch_failed = (
proc.returncode != 0 and not help_text.strip()
)
stderr_tail = (proc.stderr or "")[-400:]
if launch_failed:
print(f" ✗ llama-completion can't launch (rc={proc.returncode})")
if stderr_tail:
print(f" stderr: {stderr_tail.strip()}")
# Linux + Windows shared-library hints
hint = ""
if "cannot open shared object" in stderr_tail or \
"libllama" in stderr_tail.lower():
hint = (" — Linux: libllama.so/libggml*.so not on the "
"loader path. Add the bin dir to LD_LIBRARY_PATH "
"or register it via ldconfig.")
elif "dll" in stderr_tail.lower() or \
"0xc0000135" in stderr_tail.lower():
hint = (" — Windows: required DLLs (llama.dll, ggml-*.dll) "
"not next to the .exe. Copy them from the build "
"dir or add the bin dir to PATH.")
failures.append(
f"llama-completion failed to launch (rc={proc.returncode}){hint}"
)
elif "--jinja" in help_text:
print(" ✓ --jinja chat template flag supported")
else:
print(" ✗ --jinja not in llama-completion help")
failures.append("--jinja missing — chat templates won't apply")
if "REFRACT_TRAJECTORY" in help_text or "trajectory" in help_text.lower():
print(" ✓ REFRACT_TRAJECTORY likely supported")
elif not launch_failed:
# The patch is env-var triggered, no help-string evidence.
# Run a probe instead.
warnings.append(
"Could not verify REFRACT_TRAJECTORY support from --help. "
"Run a quick trajectory probe to confirm."
)
except Exception as e:
warnings.append(f"Could not probe llama-completion --help: {e}")
elif backend.name == "mlx":
try:
from .backends.mlx import _require_mlx
_require_mlx()
print(" ✓ mlx + mlx_lm importable")
except BackendCapabilityError as e:
print(f" ✗ mlx not importable: {e}")
failures.append(str(e))
elif backend.name == "vllm":
try:
import vllm # noqa
print(f" ✓ vllm importable (version: {getattr(vllm, '__version__', 'unknown')})")
except ImportError:
print(" ✗ vllm not importable; pip install vllm")
failures.append("vllm not importable")
# --- Model probe (optional) ---
if args.model:
if not args.model.exists():
print(f" ✗ model not found: {args.model}")
failures.append(f"model missing: {args.model}")
else:
print(f"\nProbing model: {args.model.name}")
gen_ok = False
try:
result = backend.run_completion(
model=args.model,
prompt="What is 2+2?",
kv_config_str="ctk=f16,ctv=f16",
n_predict=16, ctx=128, seed=42, temperature=0.0,
timeout=120,
)
preview = (result.text or "").replace("\n", " ")[:80]
print(f" ✓ generation works → {preview!r}")
gen_ok = True
except Exception as e:
print(f" ✗ generation failed: {e}")
failures.append(f"model generation: {e}")
# Thinking probe — only run if generation succeeded. If gen
# failed, the probe uses the same broken path and "no markers
# detected" would be misleading.
if gen_ok:
try:
detected, markers = backend.detect_thinking_mode(model=args.model)
if detected:
print(f" ℹ thinking-mode detected (markers: {markers}). "
"REFRACT will handle this automatically.")
else:
print(" ✓ no thinking-mode markers (faster runs)")
except Exception as e:
warnings.append(f"thinking-mode probe failed: {e}")
# --- Summary ---
print()
if failures:
print(f"FAILED {len(failures)} issue(s):")
for f in failures:
print(f" - {f}")
return 2
if warnings:
print(f"OK with {len(warnings)} warning(s):")
for w in warnings:
print(f" - {w}")
else:
print("OK — selftest passed.")
print()
print("You're ready to run `refract score --model ... --candidate ... ...`.")
return 0
def _add_compare_parser(sub):
p = sub.add_parser(
"compare",
help="Side-by-side comparison of multiple report JSONs.",
)
p.add_argument("reports", type=Path, nargs="+",
help="Two or more report.json files to compare.")
return p
def _run_compare(args) -> int:
import json as _json
rows = []
for path in args.reports:
try:
d = _json.loads(path.read_text())
except Exception as e:
print(f"skip {path}: {e}")
continue
rows.append({
"label": path.stem,
"composite": d.get("composite"),
"band": d.get("band"),
"summary": d.get("summary"),
"axes": d.get("axes", {}),
"version": d.get("framework_version"),
"backend": d.get("environment", {}).get("backend"),
})
if not rows:
print("no reports parsed")
return 1
# Print a markdown-style comparison table
print()
print(f"{'Report':<32} {'Comp':>7} {'Band':<10} {'Traj':>7} {'KLD':>7} {'R-NIAH':>7} {'PLAD':>7}")
print("-" * 80)
for r in rows:
a = r["axes"]
def fmt(d, k):
try:
ax = a[k]
if ax.get("skipped") or ax.get("score") is None:
return "skip"
return f"{ax['score']:.2f}"
except Exception:
return "—"
comp_val = r["composite"]
comp_str = f"{comp_val:>7.2f}" if isinstance(comp_val, (int, float)) else f"{'—':>7}"
band_str = r["band"] if isinstance(r["band"], str) else "—"
print(f"{r['label'][:32]:<32} {comp_str} {band_str:<10} "
f"{fmt(a, 'gtm'):>7} {fmt(a, 'kld'):>7} "
f"{fmt(a, 'rniah'):>7} {fmt(a, 'plad'):>7}")
print()
return 0
def _add_fetch_parser(sub):
p = sub.add_parser(
"fetch",
help="Download wikitext-2-raw corpus + haystack to ~/.cache/refract/.",
description=(
"Pre-fetch the wikitext-2-raw corpus (10MB zip → wiki.test.raw "
"+ wiki.train.raw) into ~/.cache/refract/. Subsequent score / "
"repeatability invocations will auto-find these files when "
"--corpus / --rniah-haystack are omitted.\n\n"
"Idempotent: re-running with the cache already populated is a "
"no-op. Skips any download if files already exist."
),
)
p.add_argument("--cache-dir", type=Path, default=_REFRACT_CACHE,
help=f"Override cache location (default: {_REFRACT_CACHE}).")
return p
def _run_fetch(args) -> int:
target = _ensure_wikitext_2(cache_dir=args.cache_dir)
print()
print(f" test : {target / 'wiki.test.raw'}")
print(f" train : {target / 'wiki.train.raw'}")
print()
print("`refract score` will auto-find these when --corpus / --rniah-haystack are omitted.")
return 0
def _add_repeatability_parser(sub):
p = sub.add_parser(
"repeatability",
help="Run the same scoring config N times and report the spread.",
description=(
"Verifies REFRACT scores are reproducible on the same model + "
"candidate. Runs ``score`` N times back-to-back, then prints "
"min/median/max + stdev for the composite and each axis. "
"Healthy framework: composite stdev ≤ 1.0, per-axis stdev ≤ 2.0 "
"(R-NIAH may be noisier on n_trials=1)."
),
)
p.add_argument("--model", required=True, type=Path)
p.add_argument("--candidate", required=True)
p.add_argument("--reference", default="ctk=f16,ctv=f16")
p.add_argument("--prompts", required=True, type=Path)
p.add_argument("--corpus", required=True, type=Path)
p.add_argument("--runs", type=int, default=4,
help="Number of repeat runs (default: 4).")
p.add_argument("--n-predict", type=int, default=50)
p.add_argument("--ctx", "-c", type=int, default=512)
p.add_argument("--chunks", type=int, default=32)
p.add_argument("--n-gpu-layers", "-ngl", type=int, default=99)
p.add_argument("--seed", type=int, default=42,
help="Seed used for ALL runs. Identical seed gives upper "
"bound on reproducibility — variance comes purely "
"from non-determinism in the engine, not RNG.")
p.add_argument("--axis-a", choices=["gtm", "trajectory"], default="trajectory")
p.add_argument("--full", action="store_true",
help="Include R-NIAH + PLAD in each repeat run.")
p.add_argument("--rniah-haystack", type=Path, default=None)
p.add_argument("--rniah-ctx-max", type=int, default=None)
p.add_argument("--backend", choices=["auto", "llamacpp", "mlx", "vllm"],
default="auto")
p.add_argument("--out-dir", type=Path, default=None,
help="Directory for per-run JSON outputs. Default: tmp.")
return p
def _run_repeatability(args) -> int:
"""Run ``score`` N times and report the spread per-axis."""
import json as _json
import statistics as _stats
import tempfile as _tf
from . import __version__
from .backends import auto_backend, get_backend
from .runner import set_active_backend
# Set backend up front so all runs share it
backend = (auto_backend(args.model) if args.backend == "auto"
else get_backend(args.backend))
set_active_backend(backend)
out_dir = args.out_dir or Path(_tf.mkdtemp(prefix="refract-repeatability-"))
out_dir.mkdir(parents=True, exist_ok=True)
print(f"REFRACT v{__version__} repeatability — {args.runs} runs")
print(f" model : {args.model}")
print(f" candidate : {args.candidate}")
print(f" output : {out_dir}")
print()
composite_scores: list[float] = []
axis_scores: dict[str, list[float]] = {
"trajectory": [], "kld": [], "rniah": [], "plad": [],
}
# Build a one-shot args namespace for each run
import argparse as _ap
base_args = _ap.Namespace(
model=args.model, reference=args.reference, candidate=args.candidate,
prompts=args.prompts, corpus=args.corpus,
chunks=args.chunks, ctx=args.ctx, n_gpu_layers=args.n_gpu_layers,
n_predict=args.n_predict, seed=args.seed,
measure_floor=False, skip_gtm=False, skip_kld=False,
axis_a=args.axis_a, full=args.full,
axis_rniah=args.full,
rniah_haystack=args.rniah_haystack,
rniah_ctx_max=args.rniah_ctx_max,
rniah_lengths=None, rniah_positions=None, rniah_trials=1,
axis_plad=args.full,
json_out=None, html_out=None, no_progress=True,
backend=args.backend,
)
for i in range(1, args.runs + 1):
json_path = out_dir / f"run-{i:02d}.json"
run_args = _ap.Namespace(**vars(base_args))
run_args.json_out = json_path
print(f"=== run {i}/{args.runs} → {json_path.name} ===")
rc = _run_score(run_args)
if rc != 0:
print(f" run {i} failed (rc={rc}); aborting")
return rc
try:
d = _json.loads(json_path.read_text())
composite_scores.append(float(d.get("composite", 0)))
for axis, key in (("trajectory", "gtm"), ("kld", "kld"),
("rniah", "rniah"), ("plad", "plad")):
ax = d.get("axes", {}).get(key, {})
if "score" in ax and isinstance(ax["score"], (int, float)):
axis_scores[axis].append(float(ax["score"]))
except Exception as e:
print(f" warning: could not parse {json_path}: {e}")
# Spread report
print()
print("=" * 72)
print(f"REPEATABILITY ({args.runs} runs)")
print("=" * 72)
def _spread(values: list[float]) -> str:
if not values:
return "no data"
if len(values) == 1:
return f"single run: {values[0]:.2f}"
return (
f"min={min(values):.2f} med={_stats.median(values):.2f} "
f"max={max(values):.2f} stdev={_stats.stdev(values):.2f} "
f"range={max(values) - min(values):.2f}"
)
print(f"composite : {_spread(composite_scores)}")
for axis, vals in axis_scores.items():
print(f" {axis:<10}: {_spread(vals)}")
# Health check
print()
if composite_scores:
if len(composite_scores) > 1:
cs_stdev = _stats.stdev(composite_scores)
if cs_stdev <= 1.0:
print(f"✓ HEALTHY composite stdev {cs_stdev:.2f} ≤ 1.0")
elif cs_stdev <= 3.0:
print(f"⚠ NOISY composite stdev {cs_stdev:.2f} (1.0-3.0): "
"results stable but with some run-to-run jitter")
else:
print(f"✗ UNSTABLE composite stdev {cs_stdev:.2f} > 3.0: "
"framework or engine is non-deterministic. Investigate.")
return 0
def main(argv=None) -> int:
import argparse as _ap
parser = argparse.ArgumentParser(
prog="refract",
description=_TOP_DESCRIPTION,
formatter_class=_ap.RawDescriptionHelpFormatter,
)
sub = parser.add_subparsers(dest="cmd", required=True)
_add_score_parser(sub)
_add_selftest_parser(sub)
_add_compare_parser(sub)
_add_repeatability_parser(sub)
_add_fetch_parser(sub)
args = parser.parse_args(argv)
if args.cmd == "score":
return _run_score(args)
if args.cmd == "selftest":
return _run_selftest(args)
if args.cmd == "compare":
return _run_compare(args)
if args.cmd == "repeatability":
return _run_repeatability(args)
if args.cmd == "fetch":
return _run_fetch(args)
parser.error(f"unknown subcommand: {args.cmd}")
return 1
if __name__ == "__main__":
sys.exit(main())