Files
turboquant_plus/refract/score.py
T
TheTomandClaude Opus 4.7 a4c59a0b05 REFRACT v0.3.2: alpha release — 4 axes, 4 subcommands, 3 backends, 9 docs
Compressed history of v0.1.4 → v0.3.2 in one alpha-ship commit so the
framework is testable end-to-end by friends. Detailed per-version
discussion lives in refract/CHANGELOG.md.

What ships
----------

Four scoring axes vs the model's own fp16-KV reference:
  - Axis A (Trajectory, v0.1.4) — decode-time token IDs via patched
    llama-completion, no detokenize round-trip. Replaces the buggy
    v0.1.x GTM as the default.
  - Axis B (KLD@D) — corpus KL divergence, bit-exact zero on Metal
    when ref==cand. Native MLX implementation in addition to the
    llama-perplexity subprocess path.
  - Axis C (R-NIAH, v0.2.0) — long-context retrieval at multiple
    (length, position) cells. Confidence guard for low base_acc.
  - Axis D (PLAD, v0.2.0) — perturbation-locality drift across
    typo/case/punct/paraphrase. Skipped-perturbation handling.

Composite = harmonic mean. Bands EXCELLENT/PASS/DEGRADED/FAIL.

Four CLI subcommands:
  - score          — run REFRACT on a candidate KV config
  - selftest       — 30s preflight: binaries, flags, model probe
  - compare        — multi-report side-by-side
  - repeatability  — run N times, report stdev/range health verdict

Three backends:
  - llamacpp (production)        subprocess wrappers around llama.cpp
  - mlx (production)             native via mlx-lm; verified end-to-end
                                 on Qwen2.5-3B-Instruct-4bit
  - vllm (skeleton)              interface defined; plug-in pointers
                                 in module docstring

Reporting
---------

  - Text (ANSI colour, terminal)
  - JSON schema refract.report.v0.3.1 with framework_version +
    environment metadata (backend, llama_cpp_commit, mlx_lm_version)
  - HTML (self-contained single file) with hardware info, model
    params, repro command, R-NIAH heatmap, PLAD per-perturbation bars

Discoveries from the matrix runs (all logged in CHANGELOG):
  - "secret password" needle wording triggers safety refusals on
    instruct models. v0.2.1 switched to neutral "rare paint color"
  - Thinking-mode models burn n_predict before answering. v0.3.0 added
    -rea off + auto-detect at startup
  - Symmetric turbo4/turbo4 is the canonical FAIL config; framework
    correctly identifies (composite ~19) on gemma-4-26B-A4B-Q8

Documentation
-------------

  - README.md         what + alpha banner + "where do I go" navigator
  - QUICKSTART.md     0 to first PASS in 30 minutes
  - INTERPRETATION.md per-axis "what to do if low" guide
  - LEADERBOARD.md    cross-model rankings + "ranks the quant, not the
                      model" disclaimer
  - PITFALLS.md       known traps from today's matrix runs
  - LIMITATIONS.md    what v0.3 explicitly does not do
  - CHANGELOG.md      version history with discovery stories
  - MATRIX-RESULTS.md the 7-model matrix log
  - examples/        4 sample JSONs + HTML reports + README

Tests
-----

80 → 82 unit tests, all green:
  - 17 trajectory tests (incl. R-NIAH/PLAD impls + 4-axis composite)
  - 5 strip-noise pinning tests
  - 14 unit tests (math + KVConfig)
  - 8 JSON schema tests (incl. v0.3.1 framework_version field)
  - 6 KLD regex tests
  - 5 corpus-identity tests
  - 5 tokenize-to-ids tests
  - 4 command-construction tests

Verified
--------

  - 7-model v0.3.0 matrix end-to-end (~2h on M5 Max 128GB)
  - Llama-4 Scout extras + symmetric-turbo negative control
  - MLX backend on Qwen2.5-3B-Instruct-4bit
  - selftest on llamacpp (no model) and mlx (with model)
  - 2-run repeatability on qwen2.5-1.5b: composite stdev = 0.00 on
    Trajectory + KLD (bit-exact reproducibility on cheap axes)

Open follow-ups (v0.4)
----------------------

  - vLLM backend implementation (skeleton today)
  - T-Call axis (tool-call fidelity) building on v0.3 chat-template
  - Bundled corpus + prompts distribution
  - `refract leaderboard --in dir/ --out LEADERBOARD.md` auto-gen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:13:04 -05:00

206 lines
7.5 KiB
Python
Raw 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 composite scoring.
Combines per-axis scores into a single 0–100 number using the harmonic mean,
which penalises a single bad axis more aggressively than the arithmetic mean
(matches the "fail-loud" intent of the design).
Bands (tunable; align these with the paper's findings as we collect more data):
[90, 100] EXCELLENT — within reference noise / true equivalence
[80, 90) PASS — minor drift, safe to deploy
[60, 80) DEGRADED — visible drift, audit before use
[ 0, 60) FAIL — flag and treat as broken
Floor verification:
REFRACT(fp16-KV, fp16-KV) must be >= MIN_FLOOR (default 99.5).
If it is not, the reference itself is non-deterministic on this build
and KLD deltas cannot be trusted.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from typing import Optional
# Minimum REFRACT score the reference vs reference must hit. The paper §4.5
# shows bit-exact zero KLD on Metal, so 99.5 leaves headroom for non-Metal
# float-jitter without admitting a broken reference.
MIN_FLOOR = 99.5
def harmonic_mean(values: list[float]) -> float:
"""Harmonic mean clipped to [0, 100]. Returns 0 if any value is 0."""
clean = [max(v, 0.0) for v in values]
if not clean:
return 0.0
if any(v <= 0.0 for v in clean):
return 0.0
n = len(clean)
h = n / sum(1.0 / v for v in clean)
return min(max(h, 0.0), 100.0)
def band(score: float) -> str:
if score >= 90.0:
return "EXCELLENT"
if score >= 80.0:
return "PASS"
if score >= 60.0:
return "DEGRADED"
return "FAIL"
def interpret_pattern(
*,
gtm_score: float,
kld_score: float,
rniah_score: Optional[float] = None,
plad_score: Optional[float] = None,
) -> list[str]:
"""v0.2.0: turn the per-axis band pattern into plain-English diagnosis.
Returns 0-3 short sentences a non-techie reader can act on. Each sentence
names what's broken (in user terms, not "axis A score") and suggests a
direction. Does not duplicate the band labels — rendering callers should
print bands AND interpretation; the bands say "what" and the
interpretation says "what it means".
Pattern recognised:
- all axes ≥ 80 (PASS or above) → "all clear"
- short-context drift (A or B < 80) → "decode distribution shift"
- long-context drift (C < 80) → "retrieval at long context"
- brittleness (D < 80) → "perturbation brittleness"
- all axes < 60 (FAIL) → "catastrophic"
Multiple patterns can fire; sentences are emitted in order of severity.
"""
notes: list[str] = []
def low(s: Optional[float]) -> bool:
return s is not None and s < 80.0
def fail(s: Optional[float]) -> bool:
return s is not None and s < 60.0
short_drift = low(gtm_score) or low(kld_score)
long_drift = low(rniah_score)
brittle = low(plad_score)
# Catastrophic case first: every measured axis below FAIL.
measured = [s for s in (gtm_score, kld_score, rniah_score, plad_score)
if s is not None]
if measured and all(s < 60.0 for s in measured):
notes.append(
"Catastrophic: every measured surface is broken. Treat as a "
"non-functional quantization; revert to a higher-bit config."
)
return notes
if not (short_drift or long_drift or brittle):
notes.append(
"All axes intact. Quantization is faithful to the fp16 reference "
"across the surfaces tested."
)
return notes
if short_drift and not long_drift and not brittle:
if fail(gtm_score) and fail(kld_score):
notes.append(
"Per-token distribution is broken but high-level surfaces "
"(retrieval, robustness) are intact. The model decodes "
"materially different tokens than fp16; consider a higher-bit "
"V-cache or a non-rotating V scheme."
)
else:
notes.append(
"Mild short-context drift; long-context retrieval and "
"perturbation robustness are intact. Likely safe for typical "
"use; audit on your specific decoding workload before shipping."
)
return notes
# Mixed cases: name each axis that's affected.
if short_drift:
notes.append(
"Decode distribution drift detected: the candidate generates "
"different tokens than fp16 on short-context prompts."
)
if long_drift:
notes.append(
"Long-context retrieval degraded: candidate fails on prompts "
"where the fp16 reference still retrieves correctly. Inspect the "
"per-(length, position) cell breakdown to see where it breaks."
)
if brittle:
notes.append(
"Brittleness to small input changes: candidate's output drifts "
"more than fp16's under typo / casing / punctuation perturbations."
)
return notes
@dataclass
class CompositeScore:
"""REFRACT composite output.
v0.1 shipped two axes (gtm + kld); v0.2 adds rniah + plad as
optional axes. The composite is the harmonic mean of *all axes
actually scored* — None values are dropped before aggregation, so
a v0.1-style two-axis run still produces the same number it did
before. Per-axis scores are kept as separate fields so the report
layer can render bands per axis even when not all axes are run.
"""
composite: float # 0–100 (harmonic_mean of scored axes)
band: str # EXCELLENT / PASS / DEGRADED / FAIL
gtm_score: float # 0–100 (axis A: gtm or trajectory)
kld_score: float # 0–100 (axis B)
rniah_score: Optional[float] = None # 0–100 (axis C; v0.2)
plad_score: Optional[float] = None # 0–100 (axis D; v0.2)
floor_score: Optional[float] = None # measured floor (ref vs ref)
floor_ok: Optional[bool] = None
floor_min: float = MIN_FLOOR
notes: list[str] = field(default_factory=list)
def composite_score(
gtm_score: float,
kld_score: float,
rniah_score: Optional[float] = None,
plad_score: Optional[float] = None,
floor_score: Optional[float] = None,
) -> CompositeScore:
"""Combine the per-axis scores into a REFRACT composite.
Axes that weren't run (passed as ``None``) are dropped before
aggregation. v0.1 callers passing only gtm + kld get a 2-axis
harmonic mean (unchanged behaviour); v0.2 callers passing all four
get a 4-axis harmonic mean.
"""
axes = [gtm_score, kld_score]
if rniah_score is not None:
axes.append(rniah_score)
if plad_score is not None:
axes.append(plad_score)
composite = harmonic_mean(axes)
floor_ok: Optional[bool] = None
notes: list[str] = []
if floor_score is not None:
floor_ok = floor_score >= MIN_FLOOR
if not floor_ok:
notes.append(
f"Floor failed: REFRACT(ref, ref) = {floor_score:.2f} < {MIN_FLOOR}. "
"Reference is non-deterministic on this build; KLD deltas are unreliable."
)
return CompositeScore(
composite=composite,
band=band(composite),
gtm_score=gtm_score,
kld_score=kld_score,
rniah_score=rniah_score,
plad_score=plad_score,
floor_score=floor_score,
floor_ok=floor_ok,
notes=notes,
)