mirror of
https://github.com/TheTom/turboquant_plus.git
synced 2026-10-22 02:53:13 -05:00
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>
504 lines
18 KiB
Python
504 lines
18 KiB
Python
"""Tests for axes.trajectory (v0.1.4) and v0.2 skeletons.
|
|
|
|
Pure logic + skeleton smoke tests. The real subprocess-driven behaviour
|
|
of ``run_trajectory`` is exercised by the integration-tier tests under
|
|
``test_validation.py`` (skipped unless ``REFRACT_RUN_INTEGRATION=1``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from refract.axes import plad as plad_mod
|
|
from refract.axes import rniah as rniah_mod
|
|
from refract.axes import trajectory as traj_mod
|
|
from refract.axes.trajectory import (
|
|
TrajectoryResult,
|
|
_diff,
|
|
_load_prompts,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _diff: same shape as gtm._diff but operates on int token-ID lists
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_diff_identical():
|
|
assert _diff([1, 2, 3], [1, 2, 3]) == (None, 3)
|
|
|
|
|
|
def test_diff_first_position():
|
|
# Diverge at step 0
|
|
assert _diff([1, 2, 3], [9, 2, 3]) == (0, 0)
|
|
|
|
|
|
def test_diff_middle():
|
|
assert _diff([1, 2, 3, 4], [1, 2, 9, 4]) == (2, 2)
|
|
|
|
|
|
def test_diff_cand_is_prefix_of_ref():
|
|
# cand stops short — divergence is at the boundary
|
|
assert _diff([1, 2, 3, 4], [1, 2, 3]) == (3, 3)
|
|
|
|
|
|
def test_diff_ref_is_prefix_of_cand():
|
|
# ref stops short (e.g. EOS)
|
|
assert _diff([1, 2, 3], [1, 2, 3, 4]) == (3, 3)
|
|
|
|
|
|
def test_diff_both_empty():
|
|
# No tokens decoded by either side. Treated as full match (zero-length).
|
|
assert _diff([], []) == (None, 0)
|
|
|
|
|
|
def test_diff_one_empty():
|
|
# Boundary divergence at position 0 when one side decoded nothing.
|
|
assert _diff([1], []) == (0, 0)
|
|
assert _diff([], [1]) == (0, 0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _load_prompts
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_load_prompts(tmp_path):
|
|
p = tmp_path / "prompts.jsonl"
|
|
p.write_text(
|
|
'{"id": "p1", "prompt": "hello"}\n'
|
|
"# comment line, ignored\n"
|
|
"\n"
|
|
'{"id": "p2", "category": "smoke", "prompt": "world"}\n'
|
|
)
|
|
out = _load_prompts(p)
|
|
assert len(out) == 2
|
|
assert out[0]["id"] == "p1"
|
|
assert out[1]["category"] == "smoke"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TrajectoryResult shape
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_trajectory_result_has_gtm_compatible_shape():
|
|
"""Drop-in compat with GTMResult: same fields, same types.
|
|
|
|
The composite scorer (score.composite_score) only consumes ``score`` so
|
|
technically only that field matters, but reporting code accesses the
|
|
diagnostics; pin the shape so a swap-in doesn't surprise downstream
|
|
consumers.
|
|
"""
|
|
r = TrajectoryResult(
|
|
score=87.5,
|
|
full_match_rate=0.6,
|
|
median_first_divergence=12,
|
|
mean_prefix_agreement_length=42.0,
|
|
mean_cand_length=48.0,
|
|
mean_ref_length=48.0,
|
|
n_prompts=10,
|
|
n_tokens_each=50,
|
|
per_prompt=[],
|
|
)
|
|
# Same field names as GTMResult so the report layer is indifferent.
|
|
from refract.axes.gtm import GTMResult
|
|
|
|
gtm_fields = {f for f in GTMResult.__dataclass_fields__}
|
|
traj_fields = {f for f in TrajectoryResult.__dataclass_fields__}
|
|
# Trajectory must include every diagnostic field GTM exposed.
|
|
assert gtm_fields <= traj_fields, (
|
|
f"Missing fields vs GTMResult: {gtm_fields - traj_fields}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# run_trajectory: subprocess path is mocked. We verify the empty-trajectory
|
|
# protective error fires when the patched binary isn't present.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_run_trajectory_raises_on_empty_ref_and_cand(monkeypatch, tmp_path):
|
|
"""Both sides empty → patched binary is missing; raise loudly so the
|
|
user knows to rebuild llama-completion. Silent fallback would produce
|
|
a 0-tokens score that looks like data."""
|
|
p = tmp_path / "prompts.jsonl"
|
|
p.write_text('{"id": "p1", "prompt": "hello"}\n')
|
|
|
|
def fake_run(*args, **kwargs):
|
|
return ([], {"n_tokens": 0})
|
|
|
|
monkeypatch.setattr(traj_mod, "run_completion_trajectory", fake_run)
|
|
|
|
from refract.runner import KVConfig
|
|
|
|
with pytest.raises(RuntimeError, match="patched llama-completion"):
|
|
traj_mod.run_trajectory(
|
|
model=tmp_path / "fake.gguf",
|
|
reference_kv=KVConfig.parse("ctk=f16,ctv=f16"),
|
|
candidate_kv=KVConfig.parse("ctk=q8_0,ctv=q8_0"),
|
|
prompts_path=p,
|
|
n_predict=8,
|
|
progress=False,
|
|
)
|
|
|
|
|
|
def test_run_trajectory_happy_path(monkeypatch, tmp_path):
|
|
"""Two sequences differ at step 5; verify score, divergence, and
|
|
per-prompt diagnostics."""
|
|
p = tmp_path / "prompts.jsonl"
|
|
p.write_text(
|
|
'{"id": "p1", "prompt": "hello"}\n'
|
|
'{"id": "p2", "category": "smoke", "prompt": "world"}\n'
|
|
)
|
|
|
|
# ref: 10 tokens; cand: matches first 5 then diverges at 5; 8 tokens total
|
|
ref_tokens = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
|
|
cand_tokens = [1, 2, 3, 4, 5, 99, 7, 8]
|
|
|
|
call_order = []
|
|
|
|
def fake_run(model, prompt, kv, **_kw):
|
|
call_order.append((prompt, kv.label()))
|
|
# Reference call first per prompt, then candidate. Trajectory v0.1.4
|
|
# alternates ref/cand in run_trajectory().
|
|
is_ref = "f16" in kv.label()
|
|
return (ref_tokens if is_ref else cand_tokens, {"n_tokens": 0})
|
|
|
|
monkeypatch.setattr(traj_mod, "run_completion_trajectory", fake_run)
|
|
|
|
from refract.runner import KVConfig
|
|
|
|
res = traj_mod.run_trajectory(
|
|
model=tmp_path / "fake.gguf",
|
|
reference_kv=KVConfig.parse("ctk=f16,ctv=f16"),
|
|
candidate_kv=KVConfig.parse("ctk=q8_0,ctv=q8_0"),
|
|
prompts_path=p,
|
|
n_predict=10,
|
|
progress=False,
|
|
)
|
|
|
|
# Both prompts diverge at step 5 ⇒ prefix_agreement_length = 5
|
|
# mean_cand_length = 8, score = 100 * 5 / 8 = 62.5
|
|
assert res.n_prompts == 2
|
|
assert res.full_match_rate == 0.0
|
|
assert res.mean_prefix_agreement_length == 5.0
|
|
assert res.mean_cand_length == 8.0
|
|
assert res.median_first_divergence == 5
|
|
assert res.score == pytest.approx(62.5)
|
|
# Per-prompt diagnostics carry token-ID lists, not text — the v0.1.4 fix.
|
|
assert res.per_prompt[0]["ref_token_ids"] == ref_tokens
|
|
assert res.per_prompt[0]["cand_token_ids"] == cand_tokens
|
|
assert res.per_prompt[0]["matched"] is False
|
|
assert res.per_prompt[0]["first_divergence"] == 5
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# v0.2 skeletons: importable, fields stable, run_* raises NotImplementedError
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_rniah_module_constants():
|
|
"""v0.2 implementer must keep DEFAULT_* stable so reporting code can rely
|
|
on them without hardcoding strings."""
|
|
assert rniah_mod.DEFAULT_LENGTHS == (4096, 8192, 16384, 32768, 65536)
|
|
assert rniah_mod.DEFAULT_POSITIONS == (0.10, 0.50, 0.90)
|
|
assert "APRICOT" in rniah_mod.DEFAULT_NEEDLE
|
|
|
|
|
|
def test_rniah_dataclass_shapes_stable():
|
|
cell_fields = {f for f in rniah_mod.RNIAHCell.__dataclass_fields__}
|
|
assert {"length", "position", "n_trials", "base_acc",
|
|
"cand_acc", "degradation"} <= cell_fields
|
|
|
|
res_fields = {f for f in rniah_mod.RNIAHResult.__dataclass_fields__}
|
|
assert {"score", "n_cells", "cells", "skipped_cells",
|
|
"needle", "notes", "password_keyword"} <= res_fields
|
|
|
|
|
|
def test_rniah_extract_password_keyword_default():
|
|
assert rniah_mod._extract_password_keyword(
|
|
"The secret password is APRICOT-7-BLUE."
|
|
) == "APRICOT-7-BLUE"
|
|
|
|
|
|
def test_rniah_extract_password_keyword_fallback():
|
|
# No all-caps token → fall back to the last whitespace-token.
|
|
assert rniah_mod._extract_password_keyword(
|
|
"the password is hunter2."
|
|
) == "hunter2"
|
|
|
|
|
|
def test_rniah_nearest_sentence_boundary_finds_period():
|
|
text = "Foo. Bar baz qux. Quux."
|
|
# target=10 → there's a period at 16; nearest going forward is 16+2=18,
|
|
# nearest going backward is 4 (first period+space). Outward scan gives 4
|
|
# before 18 because abs(10-4)=6 < abs(18-10)=8.
|
|
out = rniah_mod._nearest_sentence_boundary(text, 10)
|
|
assert out in (4, 18)
|
|
|
|
|
|
def test_rniah_run_skips_when_all_lengths_exceed_ctx_max(tmp_path, monkeypatch):
|
|
"""If every cell is skipped because length > ctx_max, the run returns
|
|
score=0.0 with all cells in skipped_cells and a clear note."""
|
|
haystack = tmp_path / "haystack.txt"
|
|
haystack.write_text("Hello world. " * 200)
|
|
|
|
# Stub out the costly subprocess paths that we don't reach in this case.
|
|
monkeypatch.setattr(rniah_mod, "tokenize_to_ids",
|
|
lambda model, text: [1] * (len(text) // 4))
|
|
|
|
from refract.runner import KVConfig
|
|
|
|
res = rniah_mod.run_rniah(
|
|
model=tmp_path / "fake.gguf",
|
|
haystack_corpus=haystack,
|
|
reference_kv=KVConfig.parse("ctk=f16,ctv=f16"),
|
|
candidate_kv=KVConfig.parse("ctk=q8_0,ctv=q8_0"),
|
|
ctx_max=128, # smaller than every default length
|
|
n_trials=1,
|
|
progress=False,
|
|
)
|
|
assert res.score == 0.0
|
|
assert res.n_cells == 0
|
|
assert len(res.skipped_cells) == len(rniah_mod.DEFAULT_LENGTHS) * len(
|
|
rniah_mod.DEFAULT_POSITIONS
|
|
)
|
|
|
|
|
|
def test_rniah_run_full_match(tmp_path, monkeypatch):
|
|
"""Both base and candidate find the password in every cell → score 100."""
|
|
haystack = tmp_path / "haystack.txt"
|
|
haystack.write_text("Lorem ipsum dolor sit amet. " * 1000)
|
|
|
|
monkeypatch.setattr(rniah_mod, "tokenize_to_ids",
|
|
lambda model, text: [1] * max(1, len(text) // 4))
|
|
|
|
def fake_run_completion(model, prompt, kv, **_kw):
|
|
# Both KV configs respond with the password every time.
|
|
return ("APRICOT-7-BLUE is the password.", {})
|
|
|
|
monkeypatch.setattr(rniah_mod, "run_completion", fake_run_completion)
|
|
|
|
from refract.runner import KVConfig
|
|
|
|
res = rniah_mod.run_rniah(
|
|
model=tmp_path / "fake.gguf",
|
|
haystack_corpus=haystack,
|
|
reference_kv=KVConfig.parse("ctk=f16,ctv=f16"),
|
|
candidate_kv=KVConfig.parse("ctk=q8_0,ctv=q8_0"),
|
|
ctx_max=8192,
|
|
lengths=(1024, 2048),
|
|
positions=(0.5,),
|
|
n_trials=1,
|
|
progress=False,
|
|
)
|
|
assert res.n_cells == 2
|
|
assert res.score == pytest.approx(100.0)
|
|
for cell in res.cells:
|
|
assert cell.base_acc == 1.0
|
|
assert cell.cand_acc == 1.0
|
|
assert cell.degradation == 0.0
|
|
|
|
|
|
def test_rniah_run_candidate_loses_at_long_ctx(tmp_path, monkeypatch):
|
|
"""Realistic shape: cand finds password at short context but loses at
|
|
long context. R-NIAH score reflects the per-cell degradation."""
|
|
haystack = tmp_path / "haystack.txt"
|
|
haystack.write_text("Lorem ipsum dolor sit amet. " * 4000)
|
|
|
|
monkeypatch.setattr(rniah_mod, "tokenize_to_ids",
|
|
lambda model, text: [1] * max(1, len(text) // 4))
|
|
|
|
def fake_run_completion(model, prompt, kv, **kw):
|
|
# ctx tells us the cell's length here (we set ctx = length + n_predict + 32).
|
|
# f16 always finds it. q8_0 fails when ctx > ~2200.
|
|
is_ref = "f16" in kv.label()
|
|
ctx = kw.get("ctx", 0)
|
|
if is_ref or ctx <= 2200:
|
|
return ("Found it: APRICOT-7-BLUE.", {})
|
|
return ("Sorry, I do not recall.", {})
|
|
|
|
monkeypatch.setattr(rniah_mod, "run_completion", fake_run_completion)
|
|
|
|
from refract.runner import KVConfig
|
|
|
|
res = rniah_mod.run_rniah(
|
|
model=tmp_path / "fake.gguf",
|
|
haystack_corpus=haystack,
|
|
reference_kv=KVConfig.parse("ctk=f16,ctv=f16"),
|
|
candidate_kv=KVConfig.parse("ctk=q8_0,ctv=q8_0"),
|
|
ctx_max=8192,
|
|
lengths=(1024, 4096), # 1024 → short (ok), 4096 → long (cand fails)
|
|
positions=(0.5,),
|
|
n_trials=1,
|
|
progress=False,
|
|
)
|
|
# Two cells: short cell base=1, cand=1, deg=0; long cell base=1, cand=0, deg=1.
|
|
# mean_deg = 0.5, score = 50.
|
|
assert res.n_cells == 2
|
|
short = next(c for c in res.cells if c.length == 1024)
|
|
long_ = next(c for c in res.cells if c.length == 4096)
|
|
assert short.degradation == 0.0
|
|
assert long_.degradation == 1.0
|
|
assert res.score == pytest.approx(50.0)
|
|
|
|
|
|
# ---- PLAD ------------------------------------------------------------------
|
|
|
|
|
|
def test_plad_module_constants():
|
|
assert plad_mod.DEFAULT_PERTURBATIONS == ("typo", "case", "punct", "paraphrase")
|
|
assert plad_mod.DEFAULT_ALPHA == 5.0
|
|
|
|
|
|
def test_plad_dataclass_shapes_stable():
|
|
pp_fields = {f for f in plad_mod.PLADPerPrompt.__dataclass_fields__}
|
|
assert {"prompt_id", "perturbation", "perturbed_prompt",
|
|
"ref_drift", "cand_drift", "excess_drift", "plad_pp"} <= pp_fields
|
|
|
|
res_fields = {f for f in plad_mod.PLADResult.__dataclass_fields__}
|
|
assert {"score", "per_perturbation_score", "per_prompt",
|
|
"n_prompts", "n_perturbations"} <= res_fields
|
|
|
|
|
|
def test_plad_levenshtein_sanity():
|
|
assert plad_mod._levenshtein([1, 2, 3], [1, 2, 3]) == 0
|
|
assert plad_mod._levenshtein([1, 2, 3], [1, 9, 3]) == 1
|
|
assert plad_mod._levenshtein([1, 2, 3], []) == 3
|
|
assert plad_mod._levenshtein([], [1, 2, 3]) == 3
|
|
|
|
|
|
def test_plad_apply_typo_swaps_two_chars():
|
|
import random as _r
|
|
out = plad_mod._apply_typo("Please describe Paris", _r.Random(0))
|
|
assert out is not None
|
|
assert out != "Please describe Paris"
|
|
# Should still be roughly the same length (single swap)
|
|
assert abs(len(out) - len("Please describe Paris")) <= 1
|
|
|
|
|
|
def test_plad_apply_typo_no_eligible_word():
|
|
import random as _r
|
|
# All words ≤3 chars or stopwords; should return None.
|
|
assert plad_mod._apply_typo("a b cd ef", _r.Random(0)) is None
|
|
|
|
|
|
def test_plad_apply_case_lowers_first_letters():
|
|
out = plad_mod._apply_case("Hello World")
|
|
assert out == "hello world"
|
|
|
|
|
|
def test_plad_apply_case_no_change_returns_none():
|
|
assert plad_mod._apply_case("hello world") is None
|
|
|
|
|
|
def test_plad_apply_punct_adds_question_mark():
|
|
assert plad_mod._apply_punct("hello") == "hello?"
|
|
|
|
|
|
def test_plad_apply_punct_removes_question_mark():
|
|
assert plad_mod._apply_punct("hello?") == "hello"
|
|
|
|
|
|
def test_plad_apply_paraphrase_uses_synonym():
|
|
import random as _r
|
|
out = plad_mod._apply_paraphrase("Make a big house", _r.Random(0))
|
|
assert out is not None
|
|
# 'make' → 'create' or 'big' → 'large'; either way the prompt changed.
|
|
assert out != "Make a big house"
|
|
assert any(w in out.lower() for w in ("create", "large"))
|
|
|
|
|
|
def test_plad_run_happy_path(tmp_path, monkeypatch):
|
|
"""Realistic shape: cand drifts a lot more than ref under perturbation."""
|
|
p = tmp_path / "prompts.jsonl"
|
|
p.write_text(
|
|
'{"id": "p1", "prompt": "Please describe Paris briefly"}\n'
|
|
)
|
|
|
|
# Stub tokenize_to_ids to return character codes — gives a deterministic
|
|
# token sequence with a clear edit-distance signal.
|
|
monkeypatch.setattr(plad_mod, "tokenize_to_ids",
|
|
lambda model, text: [ord(c) for c in text or ""])
|
|
|
|
# Reference: stable answer regardless of perturbation.
|
|
# Candidate: gets perturbed prompts wrong (different surface form).
|
|
def fake_run_completion(model, prompt, kv, **_kw):
|
|
is_ref = "f16" in kv.label()
|
|
if is_ref:
|
|
return ("Paris is in France.", {})
|
|
# Candidate drifts on perturbed prompts but stays put on the anchor.
|
|
if prompt == "Please describe Paris briefly":
|
|
return ("Paris is in France.", {})
|
|
return ("xx" * 30, {}) # very different from anchor
|
|
|
|
monkeypatch.setattr(plad_mod, "run_completion", fake_run_completion)
|
|
|
|
from refract.runner import KVConfig
|
|
|
|
res = plad_mod.run_plad(
|
|
model=tmp_path / "fake.gguf",
|
|
prompts_path=p,
|
|
reference_kv=KVConfig.parse("ctk=f16,ctv=f16"),
|
|
candidate_kv=KVConfig.parse("ctk=q8_0,ctv=q8_0"),
|
|
progress=False,
|
|
)
|
|
|
|
# At least some perturbations applied (typo, case, punct guaranteed for
|
|
# this prompt; paraphrase may or may not).
|
|
assert len(res.per_prompt) >= 3
|
|
# Reference drift is ~0; candidate drift is >0; excess > 0; plad_pp < 100.
|
|
for r in res.per_prompt:
|
|
assert r.ref_drift == pytest.approx(0.0)
|
|
assert r.cand_drift > 0.0
|
|
assert r.excess_drift > 0.0
|
|
assert r.plad_pp < 100.0
|
|
# Overall score is below 100.
|
|
assert 0.0 < res.score < 100.0
|
|
|
|
|
|
def test_plad_run_unknown_perturbation_raises(tmp_path):
|
|
p = tmp_path / "prompts.jsonl"
|
|
p.write_text('{"id": "p1", "prompt": "x"}\n')
|
|
from refract.runner import KVConfig
|
|
with pytest.raises(ValueError, match="Unknown perturbations"):
|
|
plad_mod.run_plad(
|
|
model=tmp_path / "fake.gguf",
|
|
prompts_path=p,
|
|
reference_kv=KVConfig.parse("ctk=f16,ctv=f16"),
|
|
candidate_kv=KVConfig.parse("ctk=q8_0,ctv=q8_0"),
|
|
perturbations=("nonsense",),
|
|
)
|
|
|
|
|
|
# ---- 4-axis composite ------------------------------------------------------
|
|
|
|
|
|
def test_composite_score_4_axes():
|
|
from refract.score import composite_score
|
|
|
|
c = composite_score(
|
|
gtm_score=80.0, kld_score=99.0,
|
|
rniah_score=70.0, plad_score=90.0,
|
|
)
|
|
# Harmonic mean of [80, 99, 70, 90] = 4 / (1/80 + 1/99 + 1/70 + 1/90)
|
|
expected = 4 / (1 / 80 + 1 / 99 + 1 / 70 + 1 / 90)
|
|
assert c.composite == pytest.approx(expected, rel=1e-6)
|
|
assert c.rniah_score == 70.0
|
|
assert c.plad_score == 90.0
|
|
|
|
|
|
def test_composite_score_2_axes_unchanged():
|
|
"""v0.1 callers passing only gtm + kld must get the same number they
|
|
used to (no rniah / no plad in the harmonic mean)."""
|
|
from refract.score import composite_score
|
|
|
|
c = composite_score(gtm_score=92.0, kld_score=99.5)
|
|
expected = 2 / (1 / 92.0 + 1 / 99.5)
|
|
assert c.composite == pytest.approx(expected, rel=1e-6)
|
|
assert c.rniah_score is None
|
|
assert c.plad_score is None
|