Files
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

178 lines
5.1 KiB
Python

"""Abstract Backend interface for REFRACT.
A backend wraps an inference engine (llama.cpp, MLX, vLLM, …) and exposes
the four primitives REFRACT axes need:
- ``run_completion`` text-in/text-out with chat template + KV config
- ``run_completion_trajectory`` decode-time token-ID capture
- ``run_kld`` per-token KL divergence vs a reference, on a corpus
- ``tokenize_to_ids`` tokenization for edit-distance and unit-matching
- ``detect_thinking_mode`` runtime probe so axes can adapt n_predict / pre-fill
- ``model_metadata`` framework version stamp + any backend-specific notes
"""
from __future__ import annotations
import abc
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
class BackendCapabilityError(RuntimeError):
"""Raised when a backend doesn't support a feature that an axis requires.
Backends should raise this with a clear remediation hint so the user
knows whether to switch backends or skip the axis.
"""
@dataclass
class CompletionResult:
"""Result of a backend.run_completion call."""
text: str # post-noise-strip completion text
n_tokens: int # tokens actually decoded
metadata: dict = field(default_factory=dict) # backend-specific extras
@dataclass
class TrajectoryResult:
"""Result of a backend.run_completion_trajectory call."""
token_ids: list[int] # actual sampled IDs at decode time
metadata: dict = field(default_factory=dict)
@dataclass
class KLDResult:
"""Result of a backend.run_kld call (Axis B)."""
mean_kld: float # nats
ppl: Optional[float] = None
rms_dp_pct: Optional[float] = None
same_topp_pct: Optional[float] = None
chunks: int = 0
ctx: int = 0
metadata: dict = field(default_factory=dict)
class Backend(abc.ABC):
"""Abstract REFRACT backend.
Implementations must be importable without their underlying inference
engine being installed (use lazy imports inside methods). This lets a
user with only llama.cpp run REFRACT without paying the cost of
importing mlx or vllm at startup.
"""
name: str = "abstract"
@abc.abstractmethod
def run_completion(
self,
*,
model: Path,
prompt: str,
kv_config_str: str,
n_predict: int = 128,
ctx: int = 512,
n_gpu_layers: int = 99,
seed: int = 42,
temperature: float = 0.0,
timeout: float = 300.0,
apply_chat_template: bool = True,
system: Optional[str] = None,
reasoning: str = "off",
) -> CompletionResult:
...
@abc.abstractmethod
def run_completion_trajectory(
self,
*,
model: Path,
prompt: str,
kv_config_str: str,
n_predict: int = 128,
ctx: int = 512,
n_gpu_layers: int = 99,
seed: int = 42,
temperature: float = 0.0,
timeout: float = 300.0,
apply_chat_template: bool = True,
system: Optional[str] = None,
) -> TrajectoryResult:
...
@abc.abstractmethod
def run_kld(
self,
*,
model: Path,
corpus: Path,
ref_kv_str: str,
cand_kv_str: str,
chunks: int = 32,
ctx: int = 512,
n_gpu_layers: int = 99,
) -> KLDResult:
...
@abc.abstractmethod
def tokenize_to_ids(
self,
*,
model: Path,
text: str,
timeout: float = 120.0,
) -> list[int]:
...
def detect_thinking_mode(
self,
*,
model: Path,
timeout: float = 30.0,
) -> tuple[bool, list[str]]:
"""Run a tiny probe and return ``(detected, markers_found)``.
Default implementation issues a "What is 2+2?" generation and
scans the response for canonical thinking markers. Subclasses can
override with a cheaper signal (read GGUF chat_template, etc.).
"""
markers = (
"<think>", "</think>",
"<|thinking|>", "<|end_thinking|>",
"<|channel|>analysis", "<|channel|>commentary",
"[Start thinking]", "[End thinking]",
"<thinking>", "</thinking>",
)
try:
result = self.run_completion(
model=model,
prompt="What is 2+2? Answer briefly.",
kv_config_str="ctk=f16,ctv=f16",
n_predict=64,
ctx=128,
temperature=0.0,
seed=42,
timeout=timeout,
)
except Exception:
return False, []
text = result.text or ""
hit = [m for m in markers if m in text]
return bool(hit), hit
def model_metadata(self, *, model: Path) -> dict:
"""Return backend-specific metadata to embed in the JSON report.
Default: backend name + model path basename. Overridable to capture
commit hashes, library versions, GGUF metadata, etc.
"""
return {
"backend": self.name,
"model": str(model),
}