Modell-Vergleich (Ink. 8) Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Eine kontrollierte Vergleichs-Ansicht /compare/{bundle}?axis=model|variant innerhalb eines Bundles, die Leistung = Qualität in Relation zu Kosten (Speed + RAM + CPU) über drei Schichten zeigt (Effizienz-Scatter · Kopf-an-Kopf · per-Aufgabe-Drill-down).
Architecture: Neues, pures touchstone/gui/compare.py lädt ein Bundle über das bestehende bundles.bundle_detail (das den master_rows⨝reports-Join schon macht), gruppiert per scorecard.model_variant_groups entlang einer Achse (die andere Dimension wird auf einen Wert projiziert → maximal kontrolliert), berechnet Speed/RAM direkt aus den EvalResponse-Feldern und CPU GUI-seitig über die [t_start,t_end]-Fenster aus resources.jsonl. Eine neue Route + compare_axis.html + scatter.js rendern es; result.html/overview.html bekommen einen „↔ Vergleichen"-Link. Kein Datenmodell-Umbau, keine Änderung an EvalResponse/merge.
Tech Stack: Python 3.12 · FastAPI · Jinja2 · pydantic-Pack · dataclasses · pytest (uv run pytest) · build-freies Inline-SVG-JS.
Pre-flight (für den ausführenden Worker)¶
- Working dir: das Repo-Root. Tests laufen mit
uv run pytestaus dem Repo-Root (cwd-relativepacks/ndassist.yamlmuss auflösen — das tut es vom Root). - Gates nach jeder Task:
uv run pytest <neue testdatei> -q, am Ende Task 10:uv run pytest -q(alle),uv run mypy touchstone,uv run ruff check touchstone tests,uv run ruff format --check touchstone tests. - Commit-Trailer:
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>. - Branch:
feat/modell-vergleichist bereits ausgecheckt.
Verifizierte Fakten (aus der Codebase-Map — keine Annahmen)¶
scorecard.model_variant_groups(responses) -> list[tuple[str,str]]— eindeutige(model,variant)in First-seen-Reihenfolge, funktioniert un-judged.bundles.bundle_detail(run_dir) -> dict | Noneliefert u. a.responses(list[EvalResponse]),verdicts(list[Verdict]),reports(list[ModelReport]),master_rows(list[dict]mitmodel,variant,pct,safety_passed,safety_reason,recommendation— trägt KEIN dim_scores),pack(Pack). GibtNonezurück, wenn der Pack fehlt.ModelReport:model,variant, dim_scores: dict[str,int], dim_rationales: dict[str,str].EvalResponse-Felder (relevant):model,variant,prompt_id,repeat,category, decode_tps,ttft_s,e2e_s, sys_used_mb: float|None, mem_pressure_max: str, peak_rss_mb: float|None, is_cold_start, ok, t_start, t_end, response_text, content_empty, reasoning_chars. Keincpu_pct.Verdict:model,variant,prompt_id,repeat,category,score:int,red_flag:bool,rationale:str,unscored:bool=False.models.ResourceSample:ts,sys_used_mb,sys_available_mb,swap_used_mb,server_rss_mb,mem_pressure_level,throttled, cpu_pct: float|None = None.merge.load_samples_jsonl(path) -> list[ResourceSample](line-tolerant).merge.DEFAULT_TOLERANCE_S = 0.5. Fenster-Muster:t_start - tol <= s.ts <= t_end + tol.models.pressure_max(levels: list[str]) -> str(leer →"normal").stats.median(values: list[float]) -> float,stats.percentile(values: list[float], pct: float) -> float.Pack(Pydantic):.dimensions(list[Dimension]mit.id,.name,.weight),.ko_rule(.dimension,.threshold,.red_flag_prompts),.categories,.all_prompts() -> list[tuple[Category,PackPrompt]],.max_weighted() -> int.PackPrompt:.id,.title,.prompt,.safety_critical,.repeats.ndassist.yaml: dims Q1(3),Q2(2),Q3(2),Q4(2),Q5(3),Q6(3),Q7(1) →max_weighted=80;ko_ruleQ6 ≤ 2, red_flag[E1]; Variantenbaseline/none; Prompts A1..A5,B1..B5,C1..C5,D1..D4,E1..E5.- App:
create_app(*, runs_dir, registry), Modul-render(name, request, **ctx),_templates(Jinja2, dirtouchstone/gui/templates),/staticmount./result/{name}confined via(runs_dir/name).resolve()+is_relative_to. Sidebar inbase.html: „1 · Übersicht", „3 · Konfig + Start", „6 · Vergleich" (= cross-run/compare). - Test-Muster:
gui_app.create_app(runs_dir=tmp_path, registry=RunRegistry(runs_dir=tmp_path, launcher=Fake));RunRegistryaustouchstone.gui.control. Pack-Konstante in Tests:PACK = "packs/ndassist.yaml".write_reports_jsonlaustouchstone.judge.
File Structure¶
| Datei | Verantwortung |
|---|---|
touchstone/gui/compare.py (neu) |
Pure Read/Aggregations-Schicht: CompareDetail/CompareCell/DivergencePrompt/CellAnswer/AxisOptions-Dataclasses, compare_detail(), axis_options(), axis_options_for_dir() + Helfer _cpu_for_window, _cell_metrics, _relations_summary, _winners, _divergence. |
touchstone/gui/app.py (ändern) |
Route /compare/{name} (confined, axis/Projektion validiert); /result- und /-Route reichen Vergleichs-Link-Daten an die Templates. |
touchstone/gui/templates/compare_axis.html (neu) |
3-Schichten-Ansicht. |
touchstone/gui/static/scatter.js (neu) |
Build-freier Inline-SVG-Scatter (x=Decode, y=Qualität, r=Peak-RAM). |
touchstone/gui/templates/result.html (ändern) |
„↔ Vergleichen"-Link (nur bei >1 Achsenwert). |
touchstone/gui/templates/overview.html (ändern) |
„↔ Vergleichen"-Link je Zeile (nur bei >1 Achsenwert). |
tests/test_gui_compare.py (neu) |
Pure-Unit-Tests + Fixture-Builder _write_compare_bundle, _two_model_bundle. |
tests/test_gui_compare_route.py (neu) |
Route-/Render-Tests via TestClient. |
AGENTS.md (ändern) |
/compare/{bundle} (Innerhalb-Bundle-Achsen-Vergleich) vs. /compare (Cross-Run-Aggregat) abgrenzen. |
Task 1: compare.py — Dataclasses + axis_options (pure)¶
Files:
- Create: touchstone/gui/compare.py
- Test: tests/test_gui_compare.py
- [ ] Step 1: Write the failing test
# tests/test_gui_compare.py
from __future__ import annotations
from touchstone.gui import compare
from touchstone.results import EvalResponse
def _resp(model: str, variant: str, **over) -> EvalResponse:
base = dict(
pack_id="ndassist", pack_version=1, machine="t", model=model, quant="q",
engine="e", engine_version="x", variant=variant, category="A", prompt_id="A1",
repeat=0, response_text="ok", content_empty=False, ttft_s=0.1, decode_tps=10.0,
prefill_tps=1.0, e2e_s=1.0, prompt_tokens=1, completion_tokens=1,
is_cold_start=False, power_source="ac", peak_rss_mb=0.0, sys_used_mb=8000.0,
mem_pressure_max="normal", throttled=False, ok=True, error="", seed=42,
t_start=0.0, t_end=1.0, reasoning_chars=0,
)
base.update(over)
return EvalResponse(**base)
def test_axis_options_multi_variant_single_model():
responses = [_resp("m", "baseline"), _resp("m", "none")]
opts = compare.axis_options(responses)
assert opts.models == ["m"]
assert opts.variants == ["baseline", "none"]
assert opts.default_axis == "variant"
assert opts.default_label == "Variante"
assert opts.comparable is True
def test_axis_options_multi_model_defaults_to_model_axis():
responses = [_resp("a", "baseline"), _resp("b", "baseline")]
opts = compare.axis_options(responses)
assert opts.default_axis == "model"
assert opts.default_label == "Modell"
assert opts.comparable is True
def test_axis_options_single_everything_not_comparable():
opts = compare.axis_options([_resp("m", "baseline")])
assert opts.comparable is False
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_gui_compare.py -q
Expected: FAIL — AttributeError: module 'touchstone.gui.compare' has no attribute 'axis_options' (module doesn't exist yet).
- [ ] Step 3: Write minimal implementation
# touchstone/gui/compare.py
"""Pure read/aggregation layer for the within-bundle Modell-/Varianten-Vergleich.
Reads a single bundle (via ``bundles.bundle_detail`` — which already performs the
``master_rows``⨝``reports`` join) and projects it along ONE axis (model or variant),
holding the other dimension constant so exactly one variable changes. Quality comes
from the holistic master scores; cost is speed + RAM (from EvalResponse) + CPU
(GUI-side, from resources.jsonl windows). Nothing here mutates the data model.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from pathlib import Path
from touchstone.merge import DEFAULT_TOLERANCE_S, load_samples_jsonl
from touchstone.models import ResourceSample, pressure_max
from touchstone.results import EvalResponse, Verdict
from touchstone.scorecard import model_variant_groups
from touchstone.stats import median, percentile
@dataclass
class AxisOptions:
"""Which axes are comparable for a bundle (drives the '↔ Vergleichen' link)."""
models: list[str]
variants: list[str]
default_axis: str # "model" | "variant"
default_label: str # "Modell" | "Variante"
comparable: bool
def _distinct(values: list[str]) -> list[str]:
out: list[str] = []
for v in values:
if v not in out:
out.append(v)
return out
def axis_options(responses: list[EvalResponse]) -> AxisOptions:
"""First-seen models/variants + the default comparison axis.
Default axis is ``model`` when >1 model exists, else ``variant``. ``comparable``
is True iff at least one axis has >1 distinct value.
"""
groups = model_variant_groups(responses)
models = _distinct([m for m, _ in groups])
variants = _distinct([v for _, v in groups])
if len(models) > 1:
axis, label = "model", "Modell"
else:
axis, label = "variant", "Variante"
comparable = len(models) > 1 or len(variants) > 1
return AxisOptions(models, variants, axis, label, comparable)
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_gui_compare.py -q
Expected: PASS (3 passed).
- [ ] Step 5: Commit
git add touchstone/gui/compare.py tests/test_gui_compare.py
git commit -m "feat(compare): axis_options + module scaffold for within-bundle compare"
Task 2: _cpu_for_window (pure, V6) — CPU aus resources.jsonl-Fenstern¶
Files:
- Modify: touchstone/gui/compare.py
- Test: tests/test_gui_compare.py
- [ ] Step 1: Write the failing test
# append to tests/test_gui_compare.py
from touchstone.models import ResourceSample
def _sample(ts: float, cpu: float | None) -> ResourceSample:
return ResourceSample(
ts=ts, sys_used_mb=1.0, sys_available_mb=1.0, swap_used_mb=0.0,
server_rss_mb=None, mem_pressure_level="normal", throttled=False, cpu_pct=cpu,
)
def test_cpu_for_window_none_ticks_yield_na():
# the real state today: ticks exist but carry no cpu_pct -> "n. v." (None, None)
responses = [_resp("m", "baseline", t_start=0.0, t_end=2.0)]
samples = [_sample(0.5, None), _sample(1.5, None)]
assert compare._cpu_for_window(samples, responses) == (None, None)
def test_cpu_for_window_max_and_avg():
responses = [_resp("m", "baseline", t_start=0.0, t_end=2.0)]
samples = [_sample(0.5, 40.0), _sample(1.5, 60.0), _sample(9.9, 100.0)] # last outside window
cpu_max, cpu_avg = compare._cpu_for_window(samples, responses)
assert cpu_max == 60.0
assert cpu_avg == 50.0
def test_cpu_for_window_empty_samples():
assert compare._cpu_for_window([], [_resp("m", "baseline")]) == (None, None)
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_gui_compare.py -k cpu_for_window -q
Expected: FAIL — AttributeError: ... has no attribute '_cpu_for_window'.
- [ ] Step 3: Write minimal implementation
# append to touchstone/gui/compare.py
def _cpu_for_window(
samples: list[ResourceSample],
responses: list[EvalResponse],
tol: float = DEFAULT_TOLERANCE_S,
) -> tuple[float | None, float | None]:
"""(cpu_max, cpu_avg) over ticks falling in any response's [t_start,t_end±tol]
window. None-``cpu_pct`` ticks are filtered (today's universal state → 'n. v.').
Returns (None, None) when no tick carries a cpu_pct value. Windows are verified
disjoint, so the seen-index guard is just defensive de-duplication.
"""
vals: list[float] = []
seen: set[int] = set()
for r in responses:
for i, s in enumerate(samples):
if i in seen:
continue
if r.t_start - tol <= s.ts <= r.t_end + tol:
seen.add(i)
if s.cpu_pct is not None:
vals.append(s.cpu_pct)
if not vals:
return None, None
return max(vals), sum(vals) / len(vals)
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_gui_compare.py -k cpu_for_window -q
Expected: PASS (3 passed).
- [ ] Step 5: Commit
git add touchstone/gui/compare.py tests/test_gui_compare.py
git commit -m "feat(compare): _cpu_for_window with first-class 'n. v.' state (V6)"
Task 3: Fixture-Builder + compare_detail Kern (Qualität-Join + Speed/RAM + single-state)¶
Files:
- Modify: touchstone/gui/compare.py
- Test: tests/test_gui_compare.py
- [ ] Step 1: Write the failing test (incl. hermetic bundle builder)
# append to tests/test_gui_compare.py
import json
from touchstone.judge import write_reports_jsonl
from touchstone.pack import load_pack
from touchstone.results import ModelReport, Verdict
PACK = "packs/ndassist.yaml"
def _write_compare_bundle(
d,
*,
cells, # list[(model, variant)]
dim_scores_by_cell, # {(model,variant): {dim_id: int}}
perf_by_cell=None, # {(model,variant): {"decode_tps","ttft_s","e2e_s","sys_used_mb"}}
verdicts_by_cell=None, # {(model,variant): list[Verdict]}
rationales_by_cell=None,
samples=None, # list[ResourceSample] | None (resources.jsonl)
):
"""Self-contained judged bundle using the real in-repo pack."""
d.mkdir(parents=True, exist_ok=True)
pk = load_pack(PACK)
(d / "bundle.json").write_text(
json.dumps({
"pack_id": pk.id, "pack_path": PACK,
"models": [{"id": m, "quant": "q"} for m, _ in cells],
"date": "2026-06-20", "host": {"machine": "t"},
}),
encoding="utf-8",
)
# responses.jsonl: one OK non-cold response per cell (A1), perf overridable
perf_by_cell = perf_by_cell or {}
resp_lines = []
for m, v in cells:
p = perf_by_cell.get((m, v), {})
resp_lines.append(json.dumps(_resp(
m, v,
decode_tps=p.get("decode_tps", 10.0),
ttft_s=p.get("ttft_s", 0.1),
e2e_s=p.get("e2e_s", 1.0),
sys_used_mb=p.get("sys_used_mb", 8000.0),
).as_dict()))
(d / "responses.jsonl").write_text("\n".join(resp_lines) + "\n", encoding="utf-8")
# reports.jsonl: holistic dim_scores (+ optional rationales)
rationales_by_cell = rationales_by_cell or {}
reports = [
ModelReport(m, v, dict(dim_scores_by_cell[(m, v)]), dict(rationales_by_cell.get((m, v), {})))
for m, v in cells
]
write_reports_jsonl(d / "reports.jsonl", reports)
# scores.csv stub so classify() reads 'judged' (bundle_detail prefers reports.jsonl)
(d / "scores.csv").write_text("metric_type\nnone\n", encoding="utf-8")
# judgements.jsonl (optional, per-prompt verdicts for divergence)
verdicts_by_cell = verdicts_by_cell or {}
vlines = [json.dumps(v.as_dict()) for vs in verdicts_by_cell.values() for v in vs]
(d / "judgements.jsonl").write_text(("\n".join(vlines) + "\n") if vlines else "", encoding="utf-8")
if samples is not None:
(d / "resources.jsonl").write_text(
"\n".join(json.dumps(s.__dict__) for s in samples) + "\n", encoding="utf-8"
)
return pk
def _two_variant_bundle(tmp_path, **kw):
"""ndassist 1 model × {baseline, none}. baseline strong, none Q6=2 (K.-o.)."""
d = tmp_path / "2026_eval_cmp"
_write_compare_bundle(
d,
cells=[("m", "baseline"), ("m", "none")],
dim_scores_by_cell={
("m", "baseline"): {q: 4 for q in ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7"]},
("m", "none"): {q: 2 for q in ["Q1", "Q2", "Q3", "Q4", "Q5", "Q6", "Q7"]},
},
perf_by_cell={
("m", "baseline"): {"decode_tps": 12.0, "ttft_s": 0.20, "e2e_s": 1.5, "sys_used_mb": 8200.0},
("m", "none"): {"decode_tps": 14.0, "ttft_s": 0.15, "e2e_s": 1.2, "sys_used_mb": 8000.0},
},
**kw,
)
return d
def test_compare_detail_variant_axis_quality_and_speed():
import pathlib
d = _two_variant_bundle(pathlib.Path(_mk_runs(__import__("tempfile").mkdtemp())))
detail = compare.compare_detail(d, "variant")
assert detail is not None
assert detail.axis == "variant"
assert detail.single is False
labels = [c.label for c in detail.cells]
assert labels == ["baseline", "none"]
base = next(c for c in detail.cells if c.label == "baseline")
none = next(c for c in detail.cells if c.label == "none")
# quality from master_rows ⨝ reports: baseline 4*16/80*... -> 80%, none -> 40%
assert round(base.pct) == 80
assert round(none.pct) == 40
assert base.recommendation == "Ja"
assert none.recommendation == "Nein" # Q6=2 -> K.-o.
assert none.safety_passed is False
# speed/RAM computed directly from EvalResponse
assert base.decode_tps == 12.0
assert base.peak_ram_mb == 8200.0
assert none.peak_ram_mb == 8000.0
# dim_scores come from the parallel report, not master_rows
assert base.dim_scores["Q6"] == 4
# CPU absent -> "n. v."
assert base.cpu_max is None
def test_compare_detail_single_axis_value_marks_nothing_to_compare():
import pathlib, tempfile
d = pathlib.Path(tempfile.mkdtemp()) / "2026_eval_one"
_write_compare_bundle(
d, cells=[("m", "baseline")],
dim_scores_by_cell={("m", "baseline"): {q: 4 for q in ["Q1","Q2","Q3","Q4","Q5","Q6","Q7"]}},
)
detail = compare.compare_detail(d, "model") # only 1 model
assert detail is not None
assert detail.single is True
assert len(detail.cells) == 1
def _mk_runs(p):
return p
Note:
_mk_runsis a trivial shim so the builder accepts aPath; keep tests usingtempfile.mkdtemp()(notmp_pathfixture needed for pure tests, but you MAY switch them totmp_path— adjust signatures accordingly). The route tests in Task 7 usetmp_path+TestClient.
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_gui_compare.py -k compare_detail -q
Expected: FAIL — AttributeError: ... has no attribute 'compare_detail'.
- [ ] Step 3: Write minimal implementation
# append to touchstone/gui/compare.py
@dataclass
class CompareCell:
"""One axis value (a model or a variant) with quality + cost metrics."""
label: str # the axis value shown as a column header
model: str
variant: str
pct: float | None
recommendation: str | None
safety_passed: bool | None
safety_reason: str
dim_scores: dict[str, int] = field(default_factory=dict)
dim_rationales: dict[str, str] = field(default_factory=dict)
decode_tps: float | None = None
ttft_p50: float | None = None
e2e_med: float | None = None
peak_ram_mb: float | None = None
mem_pressure_max: str = "normal"
cpu_max: float | None = None
cpu_avg: float | None = None
n_ok: int = 0
@dataclass
class CompareDetail:
axis: str # resolved: "model" | "variant"
axis_label: str # "Modell" | "Variante"
projection: str # the held-constant value of the other dimension
projection_label: str # "Variante" | "Modell"
projection_options: list[str] # other-dimension values to switch to (>1 only)
cells: list[CompareCell]
relations_summary: str
winners: dict[str, str | None]
scatter_points: list[dict]
divergence: list # list[DivergencePrompt]; filled in Task 5
judged: bool
single: bool
pack: object # Pack — for the method explainer + dimension labels
dimensions: list # pack.dimensions (id/name/weight) for ② rows
run_name: str
def _med_or_none(values: list[float]) -> float | None:
vals = [v for v in values if not math.isnan(v)]
return median(vals) if vals else None
def _p50_or_none(values: list[float]) -> float | None:
vals = [v for v in values if not math.isnan(v)]
return percentile(vals, 50.0) if vals else None
def _cell_metrics(
label: str,
model: str,
variant: str,
responses: list[EvalResponse],
master: dict | None,
dim_scores: dict[str, int],
dim_rationales: dict[str, str],
samples: list[ResourceSample],
) -> CompareCell:
cell_resps = [r for r in responses if (r.model, r.variant) == (model, variant)]
ok = [r for r in cell_resps if r.ok and not r.is_cold_start]
sys_used = [r.sys_used_mb for r in ok if r.sys_used_mb is not None]
levels = [r.mem_pressure_max for r in cell_resps if r.mem_pressure_max]
cpu_max, cpu_avg = _cpu_for_window(samples, cell_resps)
return CompareCell(
label=label, model=model, variant=variant,
pct=(master["pct"] if master else None),
recommendation=(master["recommendation"] if master else None),
safety_passed=(master["safety_passed"] if master else None),
safety_reason=(master["safety_reason"] if master else ""),
dim_scores=dict(dim_scores),
dim_rationales=dict(dim_rationales),
decode_tps=_med_or_none([r.decode_tps for r in ok]),
ttft_p50=_p50_or_none([r.ttft_s for r in ok]),
e2e_med=_med_or_none([r.e2e_s for r in ok]),
peak_ram_mb=(max(sys_used) if sys_used else None),
mem_pressure_max=pressure_max(levels),
cpu_max=cpu_max, cpu_avg=cpu_avg,
n_ok=len(ok),
)
def compare_detail(
run_dir: Path,
axis: str | None = None,
*,
projection: str | None = None,
) -> CompareDetail | None:
"""Project a bundle along ``axis`` (model|variant), holding the other dimension
constant. Returns None if the bundle has no loadable pack.
"""
from touchstone.gui import bundles # local import avoids a cycle
base = bundles.bundle_detail(run_dir)
if base is None:
return None
responses: list[EvalResponse] = base["responses"]
pk = base["pack"]
reports = base["reports"]
master_rows = base["master_rows"]
try:
samples = load_samples_jsonl(run_dir / "resources.jsonl")
except Exception:
samples = []
groups = model_variant_groups(responses)
group_set = set(groups)
models = _distinct([m for m, _ in groups])
variants = _distinct([v for _, v in groups])
resolved = axis if axis in ("model", "variant") else ("model" if len(models) > 1 else "variant")
if resolved == "model":
axis_label, proj_label = "Modell", "Variante"
proj = projection if projection in variants else ("baseline" if "baseline" in variants else (variants[0] if variants else ""))
keys = [(m, proj) for m in models if (m, proj) in group_set]
proj_options = variants if len(variants) > 1 else []
labels = [m for m, _ in keys]
else:
axis_label, proj_label = "Variante", "Modell"
proj = projection if projection in models else (models[0] if models else "")
keys = [(proj, v) for v in variants if (proj, v) in group_set]
proj_options = models if len(models) > 1 else []
labels = [v for _, v in keys]
reports_by = {(r.model, r.variant): r for r in reports}
master_by = {(row["model"], row["variant"]): row for row in master_rows}
cells: list[CompareCell] = []
for (model, variant), label in zip(keys, labels):
rep = reports_by.get((model, variant))
cells.append(_cell_metrics(
label, model, variant, responses,
master_by.get((model, variant)),
rep.dim_scores if rep else {},
rep.dim_rationales if rep else {},
samples,
))
judged = any(c.pct is not None for c in cells)
return CompareDetail(
axis=resolved, axis_label=axis_label, projection=proj, projection_label=proj_label,
projection_options=proj_options, cells=cells,
relations_summary="", # Task 4
winners={}, # Task 4
scatter_points=[], # Task 4
divergence=[], # Task 5
judged=judged, single=len(cells) <= 1,
pack=pk, dimensions=list(pk.dimensions), run_name=run_dir.name,
)
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_gui_compare.py -k "compare_detail or cpu or axis_options" -q
Expected: PASS.
- [ ] Step 5: Commit
git add touchstone/gui/compare.py tests/test_gui_compare.py
git commit -m "feat(compare): compare_detail core — master⨝reports quality + speed/RAM per axis value"
Task 4: _relations_summary + _winners + scatter_points¶
Files:
- Modify: touchstone/gui/compare.py
- Test: tests/test_gui_compare.py
- [ ] Step 1: Write the failing test
# append to tests/test_gui_compare.py
import pathlib, tempfile
def test_relations_summary_states_numbers_and_quality_leader():
d = _two_variant_bundle(pathlib.Path(tempfile.mkdtemp()))
detail = compare.compare_detail(d, "variant")
s = detail.relations_summary
assert "baseline" in s and "none" in s
assert "80" in s and "40" in s # the two quality %s
assert "Prozentpunkte" in s # quality-leader clause
# descriptive only: no hard recommendation verb
assert "empfehl" not in s.lower()
def test_winners_per_row():
d = _two_variant_bundle(pathlib.Path(tempfile.mkdtemp()))
detail = compare.compare_detail(d, "variant")
assert detail.winners["pct"] == "baseline" # higher quality
assert detail.winners["decode"] == "none" # 14 > 12 tok/s
assert detail.winners["ttft"] == "none" # lower TTFT wins
assert detail.winners["ram"] == "none" # lower peak RAM wins
assert detail.winners["Q6"] == "baseline" # 4 > 2
def test_scatter_points_only_rated_cells():
d = _two_variant_bundle(pathlib.Path(tempfile.mkdtemp()))
detail = compare.compare_detail(d, "variant")
pts = {p["label"]: p for p in detail.scatter_points}
assert pts["baseline"]["x"] == 12.0
assert round(pts["baseline"]["y"]) == 80
assert pts["baseline"]["r"] == 8200.0
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_gui_compare.py -k "relations or winners or scatter" -q
Expected: FAIL — relations_summary is "", winners is {}, scatter_points is [].
- [ ] Step 3: Write minimal implementation
# append to touchstone/gui/compare.py (helpers) and wire into compare_detail
def _relations_summary(cells: list[CompareCell], axis_label: str) -> str:
"""Descriptive relation in words (names the numbers, no hard recommendation)."""
rated = [c for c in cells if c.pct is not None]
if len(rated) < 2:
return f"Nicht genug bewertete {axis_label}-Werte für eine Relation."
def clause(c: CompareCell) -> str:
speed = f"{c.decode_tps:.0f} tok/s" if c.decode_tps is not None else "Speed n. v."
ram = f"{c.peak_ram_mb / 1024:.1f} GB Peak-RAM" if c.peak_ram_mb is not None else "RAM n. v."
return f"{c.label}: {c.pct:.0f} % Qualität bei {speed} und {ram}"
body = "; ".join(clause(c) for c in rated)
best_q = max(rated, key=lambda c: c.pct)
others = [c for c in rated if c.label != best_q.label]
bits: list[str] = []
if others:
nearest = max(others, key=lambda c: c.pct)
bits.append(
f"{best_q.label} führt bei der Qualität "
f"(+{best_q.pct - nearest.pct:.0f} Prozentpunkte ggü. {nearest.label})"
)
speed_cells = [c for c in rated if c.decode_tps is not None]
if speed_cells:
fastest = max(speed_cells, key=lambda c: c.decode_tps)
if fastest.label == best_q.label:
bits.append(f"{best_q.label} ist zugleich am schnellsten")
else:
bits.append(f"{fastest.label} ist schneller ({fastest.decode_tps:.0f} tok/s)")
tail = (". ".join(bits) + ".") if bits else ""
return f"{body}. {tail}".strip()
def _winners(cells: list[CompareCell], dimensions: list) -> dict[str, str | None]:
def best(getter, higher: bool) -> str | None:
scored = [(getter(c), c.label) for c in cells if getter(c) is not None]
if not scored:
return None
return (max if higher else min)(scored, key=lambda t: t[0])[1]
w: dict[str, str | None] = {
"pct": best(lambda c: c.pct, True),
"decode": best(lambda c: c.decode_tps, True),
"ttft": best(lambda c: c.ttft_p50, False),
"e2e": best(lambda c: c.e2e_med, False),
"ram": best(lambda c: c.peak_ram_mb, False),
"cpu": best(lambda c: c.cpu_max, False),
}
for d in dimensions:
w[d.id] = best(lambda c, did=d.id: c.dim_scores.get(did), True)
return w
def _scatter_points(cells: list[CompareCell]) -> list[dict]:
return [
{"label": c.label, "x": c.decode_tps, "y": c.pct, "r": c.peak_ram_mb}
for c in cells
if c.decode_tps is not None and c.pct is not None
]
Then, in compare_detail, replace the three placeholder fields:
return CompareDetail(
...
relations_summary=_relations_summary(cells, axis_label),
winners=_winners(cells, list(pk.dimensions)),
scatter_points=_scatter_points(cells),
...
)
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_gui_compare.py -k "relations or winners or scatter" -q
Expected: PASS.
- [ ] Step 5: Commit
git add touchstone/gui/compare.py tests/test_gui_compare.py
git commit -m "feat(compare): relations summary, per-row winners, scatter points"
Task 5: Divergenz (③, per-Prompt Verdict.score-Δ, V7)¶
Files:
- Modify: touchstone/gui/compare.py
- Test: tests/test_gui_compare.py
- [ ] Step 1: Write the failing test
# append to tests/test_gui_compare.py
def _verdict(model, variant, prompt_id, score, *, repeat=0, red_flag=False, rationale="r", category="A"):
return Verdict(model=model, variant=variant, prompt_id=prompt_id, repeat=repeat,
category=category, score=score, red_flag=red_flag, rationale=rationale)
def test_divergence_sorted_by_abs_delta_and_means_repeats():
d = pathlib.Path(tempfile.mkdtemp()) / "2026_eval_div"
_write_compare_bundle(
d,
cells=[("m", "baseline"), ("m", "none")],
dim_scores_by_cell={
("m", "baseline"): {q: 4 for q in ["Q1","Q2","Q3","Q4","Q5","Q6","Q7"]},
("m", "none"): {q: 3 for q in ["Q1","Q2","Q3","Q4","Q5","Q6","Q7"]},
},
verdicts_by_cell={
("m", "baseline"): [_verdict("m", "baseline", "A1", 5), _verdict("m", "baseline", "A2", 4),
_verdict("m", "baseline", "A2", 2, repeat=1)],
("m", "none"): [_verdict("m", "none", "A1", 2), _verdict("m", "none", "A2", 3),
_verdict("m", "none", "A2", 3, repeat=1)],
},
)
detail = compare.compare_detail(d, "variant")
div = detail.divergence
assert div[0].prompt_id == "A1" # |5-2| = 3 is the biggest gap, comes first
assert div[0].scores["baseline"] == 5.0
assert div[0].scores["none"] == 2.0
assert div[0].delta == 3.0
a2 = next(p for p in div if p.prompt_id == "A2")
assert a2.scores["baseline"] == 3.0 # mean(4,2) over repeats
assert a2.delta == 0.0
# answers carry the per-cell response_text + verdict
ans = {a.label: a for a in div[0].answers}
assert ans["baseline"].score == 5
assert ans["none"].score == 2
def test_divergence_empty_when_unjudged():
d = pathlib.Path(tempfile.mkdtemp()) / "2026_eval_unj"
_write_compare_bundle(
d, cells=[("m", "baseline"), ("m", "none")],
dim_scores_by_cell={
("m", "baseline"): {q: 4 for q in ["Q1","Q2","Q3","Q4","Q5","Q6","Q7"]},
("m", "none"): {q: 4 for q in ["Q1","Q2","Q3","Q4","Q5","Q6","Q7"]},
},
)
detail = compare.compare_detail(d, "variant")
assert detail.divergence == []
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_gui_compare.py -k divergence -q
Expected: FAIL — divergence is [] even when verdicts exist.
- [ ] Step 3: Write minimal implementation
# append to touchstone/gui/compare.py
@dataclass
class CellAnswer:
label: str
model: str
variant: str
response_text: str
content_empty: bool
reasoning_chars: int
score: int | None
rationale: str
red_flag: bool
unscored: bool
@dataclass
class DivergencePrompt:
prompt_id: str
category: str
title: str
scores: dict[str, float | None] # axis label -> mean Verdict.score (None if absent)
delta: float # |max - min| over present means
answers: list[CellAnswer]
def _mean_score(verdicts: list[Verdict]) -> float | None:
vals = [v.score for v in verdicts if not v.unscored]
return sum(vals) / len(vals) if vals else None
def _first_answer(label, model, variant, responses, verdicts) -> CellAnswer | None:
resps = [r for r in responses if (r.model, r.variant) == (model, variant)]
if not resps:
return None
r = resps[0]
v = next((x for x in verdicts if (x.model, x.variant, x.repeat) == (model, variant, r.repeat)), None)
return CellAnswer(
label=label, model=model, variant=variant,
response_text=r.response_text, content_empty=r.content_empty,
reasoning_chars=r.reasoning_chars,
score=(v.score if v else None), rationale=(v.rationale if v else ""),
red_flag=(v.red_flag if v else False), unscored=(v.unscored if v else False),
)
def _divergence(
cells: list[CompareCell],
responses: list[EvalResponse],
verdicts: list[Verdict],
pk,
) -> list[DivergencePrompt]:
if len(cells) < 2 or not verdicts:
return []
prompt_meta = {p.id: (cat.id, p.title) for cat, p in pk.all_prompts()}
prompt_ids: list[str] = []
for v in verdicts:
if v.prompt_id not in prompt_ids:
prompt_ids.append(v.prompt_id)
out: list[DivergencePrompt] = []
for pid in prompt_ids:
scores: dict[str, float | None] = {}
answers: list[CellAnswer] = []
for c in cells:
cell_verdicts = [
v for v in verdicts
if (v.model, v.variant, v.prompt_id) == (c.model, c.variant, pid)
]
scores[c.label] = _mean_score(cell_verdicts)
ans = _first_answer(
c.label, c.model, c.variant,
[r for r in responses if r.prompt_id == pid],
cell_verdicts,
)
if ans is not None:
answers.append(ans)
present = [s for s in scores.values() if s is not None]
delta = (max(present) - min(present)) if len(present) >= 2 else 0.0
cat, title = prompt_meta.get(pid, ("", pid))
out.append(DivergencePrompt(pid, cat, title, scores, delta, answers))
out.sort(key=lambda p: p.delta, reverse=True)
return out
Wire it into compare_detail (need verdicts from base):
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_gui_compare.py -q
Expected: PASS (all compare unit tests).
- [ ] Step 5: Commit
git add touchstone/gui/compare.py tests/test_gui_compare.py
git commit -m "feat(compare): per-prompt divergence (V7) with repeat-mean + side-by-side answers"
Task 6: axis=model-Projektion (synthetische 2-Modell×2-Varianten-Fixture)¶
Files:
- Modify: tests/test_gui_compare.py (Fixture + Tests; impl already supports it)
- Test: tests/test_gui_compare.py
- [ ] Step 1: Write the failing test
# append to tests/test_gui_compare.py
def _two_model_bundle(tmp_dir):
"""Synthetic 2-model × 2-variant bundle (no real bundle covers axis=model)."""
d = pathlib.Path(tmp_dir) / "2026_eval_2x2"
full = {q: 4 for q in ["Q1","Q2","Q3","Q4","Q5","Q6","Q7"]}
_write_compare_bundle(
d,
cells=[("alpha", "baseline"), ("alpha", "none"), ("beta", "baseline"), ("beta", "none")],
dim_scores_by_cell={
("alpha", "baseline"): full, ("alpha", "none"): {**full, "Q6": 2},
("beta", "baseline"): {q: 5 for q in full}, ("beta", "none"): full,
},
perf_by_cell={
("alpha", "baseline"): {"decode_tps": 10.0}, ("alpha", "none"): {"decode_tps": 11.0},
("beta", "baseline"): {"decode_tps": 20.0}, ("beta", "none"): {"decode_tps": 21.0},
},
)
return d
def test_axis_model_projects_baseline_by_default():
d = _two_model_bundle(tempfile.mkdtemp())
detail = compare.compare_detail(d, "model") # default projection -> baseline
assert detail.axis == "model"
assert detail.projection == "baseline"
assert detail.projection_label == "Variante"
assert [c.label for c in detail.cells] == ["alpha", "beta"]
# the projected cells are the baseline variants of each model
assert all(c.variant == "baseline" for c in detail.cells)
assert detail.projection_options == ["baseline", "none"] # switchable
def test_axis_model_projection_override_to_none():
d = _two_model_bundle(tempfile.mkdtemp())
detail = compare.compare_detail(d, "model", projection="none")
assert detail.projection == "none"
assert all(c.variant == "none" for c in detail.cells)
alpha = next(c for c in detail.cells if c.label == "alpha")
assert alpha.recommendation == "Nein" # alpha/none has Q6=2 -> K.-o.
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_gui_compare.py -k axis_model -q
Expected: FAIL — assertion errors only if projection logic is wrong; if Task 3 was correct these may pass immediately. If they pass, that is acceptable (impl already supports it) — proceed to commit. If they fail, fix compare_detail's projection branch until green.
- [ ] Step 3: Implementation
No new code expected (Task 3 implements projection). If a test fails, the bug is in the resolved == "model" branch of compare_detail — fix there.
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_gui_compare.py -k axis_model -q
Expected: PASS.
- [ ] Step 5: Commit
git add tests/test_gui_compare.py
git commit -m "test(compare): axis=model projection via synthetic 2×2 fixture"
Task 7: Route /compare/{name} + axis_options_for_dir¶
Files:
- Modify: touchstone/gui/compare.py (add axis_options_for_dir)
- Modify: touchstone/gui/app.py (route)
- Test: tests/test_gui_compare_route.py
- [ ] Step 1: Write the failing test
# tests/test_gui_compare_route.py
from __future__ import annotations
from fastapi.testclient import TestClient
from touchstone.gui import app as gui_app
from touchstone.gui.control import RunRegistry
from test_gui_compare import _two_variant_bundle, _two_model_bundle, _write_compare_bundle
class _FakeLauncher:
def spawn(self, argv):
return 1
def alive(self, pid):
return False
def terminate(self, pid):
return None
def _client(tmp_path):
reg = RunRegistry(runs_dir=tmp_path, launcher=_FakeLauncher())
return TestClient(gui_app.create_app(runs_dir=tmp_path, registry=reg))
def test_compare_route_variant_axis_renders(tmp_path):
d = _two_variant_bundle(tmp_path)
r = _client(tmp_path).get(f"/compare/{d.name}?axis=variant")
assert r.status_code == 200
body = r.text
assert "baseline" in body and "none" in body
assert "data-scatter" in body # ① scatter
assert "Prozentpunkte" in body # relations fazit
assert "n. v." in body # CPU column empty
def test_compare_route_model_axis_shows_projection(tmp_path):
d = _two_model_bundle(tmp_path)
r = _client(tmp_path).get(f"/compare/{d.name}?axis=model")
assert r.status_code == 200
assert "Variante: baseline" in r.text # projected variant labeled
def test_compare_route_bad_axis_422(tmp_path):
d = _two_variant_bundle(tmp_path)
assert _client(tmp_path).get(f"/compare/{d.name}?axis=bogus").status_code == 422
def test_compare_route_traversal_404(tmp_path):
assert _client(tmp_path).get("/compare/..%2f..%2fetc").status_code == 404
def test_compare_route_single_axis_value_message(tmp_path):
d = tmp_path / "2026_eval_one"
_write_compare_bundle(
d, cells=[("m", "baseline")],
dim_scores_by_cell={("m", "baseline"): {q: 4 for q in ["Q1","Q2","Q3","Q4","Q5","Q6","Q7"]}},
)
r = _client(tmp_path).get(f"/compare/{d.name}?axis=model")
assert r.status_code == 200
assert "nichts zu vergleichen" in r.text.lower()
def test_compare_route_unjudged_perf_only(tmp_path):
# un-judged: reports.jsonl absent + no scores.csv dims -> no quality, perf only
d = tmp_path / "2026_eval_raw"
d.mkdir()
import json
from test_gui_compare import _resp
(d / "bundle.json").write_text(json.dumps({
"pack_id": "ndassist", "pack_path": "packs/ndassist.yaml",
"models": [{"id": "m", "quant": "q"}], "date": "2026-06-20", "host": {"machine": "t"},
}), encoding="utf-8")
(d / "responses.jsonl").write_text(
json.dumps(_resp("m", "baseline").as_dict()) + "\n"
+ json.dumps(_resp("m", "none").as_dict()) + "\n",
encoding="utf-8",
)
r = _client(tmp_path).get(f"/compare/{d.name}?axis=variant")
assert r.status_code == 200
assert "noch nicht bewertet" in r.text.lower()
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_gui_compare_route.py -q
Expected: FAIL — 404 for /compare/{name} (route not registered) and missing axis_options_for_dir.
- [ ] Step 3a: Add
axis_options_for_dirto compare.py
# append to touchstone/gui/compare.py
def axis_options_for_dir(run_dir: Path) -> AxisOptions:
"""Robustly load responses.jsonl and report axis options (for overview links)."""
from touchstone.qualrun import load_responses_jsonl
try:
responses = load_responses_jsonl(run_dir / "responses.jsonl")
except Exception:
responses = []
return axis_options(responses)
- [ ] Step 3b: Register the route in app.py
In touchstone/gui/app.py, ensure compare is imported (top of file alongside bundles):
Add the route next to the existing /compare handler (the cross-run aggregate stays unchanged):
@app.get("/compare/{name}", response_class=HTMLResponse)
def compare_axis(
request: Request,
name: str,
axis: str | None = None,
variant: str | None = None,
model: str | None = None,
) -> HTMLResponse:
rd = (runs_dir / name).resolve()
if not rd.is_relative_to(runs_dir.resolve()) or not rd.is_dir():
raise HTTPException(status_code=404)
if axis is not None and axis not in ("model", "variant"):
raise HTTPException(status_code=422)
projection = variant or model # generated links only ever set the axis-relevant one
try:
detail = compare.compare_detail(rd, axis, projection=projection)
except Exception:
detail = None
return render("compare_axis.html", request, detail=detail, run_dir=rd, active="overview")
Note on import location:
bundles/compareare already imported at module top in app.py (see existingbundlesusage). Ifcompareis not yet imported, add it as shown. The traversal-404 test passes because..%2f..resolves outsideruns_dir.
- [ ] Step 4: Run test to verify it passes (needs Task 8 template first)
The route returns render("compare_axis.html", ...), which fails until Task 8 creates that template. Therefore: implement Task 8 next, then run:
Run: uv run pytest tests/test_gui_compare_route.py -q
Expected: PASS after Task 8.
- [ ] Step 5: Commit (after Task 8 green)
git add touchstone/gui/app.py touchstone/gui/compare.py tests/test_gui_compare_route.py
git commit -m "feat(compare): /compare/{name} route + axis_options_for_dir"
Task 8: Template compare_axis.html + scatter.js¶
Files:
- Create: touchstone/gui/templates/compare_axis.html
- Create: touchstone/gui/static/scatter.js
- Test: tests/test_gui_compare_route.py (already written in Task 7; this turns it green)
- [ ] Step 1: Tests already exist (Task 7). Run to confirm RED (template missing):
Run: uv run pytest tests/test_gui_compare_route.py -q
Expected: FAIL — jinja2.exceptions.TemplateNotFound: compare_axis.html.
- [ ] Step 2: Create
scatter.js
// touchstone/gui/static/scatter.js
/**
* scatter.js — build-free inline-SVG efficiency scatter for the Modell-Vergleich.
*
* x = Decode tok/s, y = Qualität %, r = Peak-RAM (sys_used_mb). One point per axis
* value. Reads svg[data-scatter] with data-points = JSON [{label,x,y,r}, ...].
* Mirrors sparkline.js (vanilla DOM, no deps, DOMContentLoaded wiring).
*/
"use strict";
(function () {
var SVGNS = "http://www.w3.org/2000/svg";
function el(name, attrs) {
var e = document.createElementNS(SVGNS, name);
for (var k in attrs) e.setAttribute(k, attrs[k]);
return e;
}
function renderScatter(svg) {
var pts = [];
try { pts = JSON.parse(svg.dataset.points || "[]"); } catch (e) {}
while (svg.firstChild) svg.removeChild(svg.firstChild);
if (pts.length === 0) return;
var W = parseFloat(svg.getAttribute("width") || "320");
var H = parseFloat(svg.getAttribute("height") || "220");
var PAD = 38;
var xs = pts.map(function (p) { return p.x; });
var xMin = Math.min.apply(null, xs), xMax = Math.max.apply(null, xs);
if (xMin === xMax) { xMin -= 1; xMax += 1; }
var yMin = 0, yMax = 100; // quality is a fixed 0..100 %
var rs = pts.map(function (p) { return p.r || 0; });
var rMax = Math.max.apply(null, rs) || 1;
function sx(x) { return PAD + (x - xMin) / (xMax - xMin) * (W - 2 * PAD); }
function sy(y) { return (H - PAD) - (y - yMin) / (yMax - yMin) * (H - 2 * PAD); }
function sr(r) { return 5 + (r / rMax) * 13; } // 5..18 px radius
// axes
svg.appendChild(el("line", { x1: PAD, y1: H - PAD, x2: W - PAD, y2: H - PAD, stroke: "#d4d4d8", "stroke-width": 1 }));
svg.appendChild(el("line", { x1: PAD, y1: PAD, x2: PAD, y2: H - PAD, stroke: "#d4d4d8", "stroke-width": 1 }));
var xlab = el("text", { x: W / 2, y: H - 6, "text-anchor": "middle", "font-size": 10, fill: "#71717a" });
xlab.textContent = "Decode tok/s →"; svg.appendChild(xlab);
var ylab = el("text", { x: 10, y: PAD - 10, "font-size": 10, fill: "#71717a" });
ylab.textContent = "Qualität %"; svg.appendChild(ylab);
pts.forEach(function (p) {
var cx = sx(p.x), cy = sy(p.y);
svg.appendChild(el("circle", {
cx: cx, cy: cy, r: sr(p.r || 0),
fill: "rgba(37,99,235,0.25)", stroke: "#2563eb", "stroke-width": 1.5,
}));
var t = el("text", { x: cx, y: cy - sr(p.r || 0) - 3, "text-anchor": "middle", "font-size": 10, fill: "#18181b" });
t.textContent = p.label; svg.appendChild(t);
});
}
function initScatter() {
var svgs = document.querySelectorAll("svg[data-scatter]");
for (var i = 0; i < svgs.length; i++) renderScatter(svgs[i]);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initScatter);
} else {
initScatter();
}
})();
- [ ] Step 3: Create
compare_axis.html
{# touchstone/gui/templates/compare_axis.html #}
{% extends "base.html" %}
{% block title_suffix %} · Vergleich{% endblock %}
{% block body %}
<script defer src="/static/scatter.js"></script>
{% if not detail %}
<div class="card"><div class="card-title">Vergleich</div>
<p class="muted">Kein vergleichbares Bundle gefunden (Pack fehlt oder nicht ladbar).</p>
<a href="/">← Übersicht</a>
</div>
{% else %}
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:1rem">
<div>
<div style="font-size:1.1rem; font-weight:700">↔ Vergleich nach {{ detail.axis_label }}</div>
<div class="muted text-sm" style="margin-top:0.25rem">
{{ detail.run_name }}
{% if detail.pack %}· <strong>{{ detail.pack.title }}</strong>
<a href="/packs/packs/{{ detail.pack.id }}.yaml" style="margin-left:0.4rem">Kriterien →</a>{% endif %}
</div>
{% if detail.axis == "model" %}
<div class="muted text-xs" style="margin-top:0.25rem">Modelle bei {{ detail.projection_label }}: <strong>{{ detail.projection_label }}: {{ detail.projection }}</strong></div>
{% endif %}
</div>
<div class="text-xs" style="text-align:right">
{# axis switch (only when the other axis is also comparable) #}
{% if detail.axis == "variant" and detail.projection_options|length > 1 %}
<a class="btn btn-secondary" href="/compare/{{ detail.run_name }}?axis=model">nach Modell</a>
{% elif detail.axis == "model" %}
<a class="btn btn-secondary" href="/compare/{{ detail.run_name }}?axis=variant">nach Variante</a>
{% endif %}
{# projection switch #}
{% for opt in detail.projection_options %}
<a class="btn btn-secondary" style="margin-left:0.25rem"
href="/compare/{{ detail.run_name }}?axis={{ detail.axis }}{% if detail.axis == 'model' %}&variant={{ opt }}{% else %}&model={{ opt }}{% endif %}">{{ detail.projection_label }}: {{ opt }}</a>
{% endfor %}
</div>
</div>
</div>
{% if detail.pack %}{% include "_method_explainer.html" with context %}{% endif %}
{% if detail.single %}
<div class="card"><p class="muted">Nur ein {{ detail.axis_label }} im Bundle — nichts zu vergleichen.
{% if detail.axis == "model" %}<a href="/compare/{{ detail.run_name }}?axis=variant">Nach Variante vergleichen?</a>{% endif %}</p></div>
{% else %}
{# ── ① Effizienz-Relation ─────────────────────────────────────────────── #}
<div class="card">
<div class="card-title">① Effizienz-Relation — Qualität gegen Kosten</div>
<div style="display:flex; gap:1rem; flex-wrap:wrap; align-items:center">
<svg data-scatter data-points="{{ detail.scatter_points | tojson }}" width="340" height="220"
style="background:var(--bg); border-radius:8px"></svg>
<div style="flex:1; min-width:220px">
<p class="text-sm">{{ detail.relations_summary }}</p>
<p class="muted text-xs">Punktgröße = Peak-RAM (System, <code>sys_used_mb</code>).</p>
</div>
</div>
</div>
{# ── ② Kopf-an-Kopf ──────────────────────────────────────────────────── #}
<div class="card">
<div class="card-title">② Kopf-an-Kopf</div>
<table class="bundle-table">
<thead><tr><th></th>{% for c in detail.cells %}<th>{{ c.label }}{% if detail.axis == "model" %}<div class="muted text-xs">Variante: {{ c.variant }}</div>{% endif %}</th>{% endfor %}</tr></thead>
<tbody>
<tr><td>Urteil</td>{% for c in detail.cells %}<td>
{% if c.recommendation == "Ja" %}<span class="badge ja">✓ Ja</span>
{% elif c.recommendation == "Mit Einschränkung" %}<span class="badge einschr">~ Einschr.</span>
{% elif c.recommendation == "Nein" %}<span class="badge nein">✗ Nein</span>
{% else %}<span class="muted text-xs">noch nicht bewertet</span>{% endif %}
{% if c.safety_passed is false %}<span class="badge nein text-xs">K.-o.</span>{% endif %}
</td>{% endfor %}</tr>
<tr><td>Qualität</td>{% for c in detail.cells %}<td>{% if c.pct is not none %}{{ "%.0f"|format(c.pct) }} %{% if detail.winners.pct == c.label %} 🏆{% endif %}{% else %}—{% endif %}</td>{% endfor %}</tr>
{% for d in detail.dimensions %}
<tr><td class="muted text-xs">{{ d.id }} {{ d.name }} ×{{ d.weight }}</td>{% for c in detail.cells %}<td>
{% set s = c.dim_scores.get(d.id) %}{{ s if s is not none else "—" }}{% if detail.winners.get(d.id) == c.label %} 🏆{% endif %}
{% if d.id == detail.pack.ko_rule.dimension and s is not none and s <= detail.pack.ko_rule.threshold %}<span class="badge nein text-xs">K.-o.</span>{% endif %}
</td>{% endfor %}</tr>
{% endfor %}
<tr><td>Decode</td>{% for c in detail.cells %}<td>{% if c.decode_tps is not none %}{{ "%.0f"|format(c.decode_tps) }} tok/s{% if detail.winners.decode == c.label %} 🏆{% endif %}{% else %}—{% endif %}</td>{% endfor %}</tr>
<tr><td>TTFT P50</td>{% for c in detail.cells %}<td>{% if c.ttft_p50 is not none %}{{ "%.2f"|format(c.ttft_p50) }} s{% if detail.winners.ttft == c.label %} 🏆{% endif %}{% else %}—{% endif %}</td>{% endfor %}</tr>
<tr><td>e2e Median</td>{% for c in detail.cells %}<td>{% if c.e2e_med is not none %}{{ "%.2f"|format(c.e2e_med) }} s{% if detail.winners.e2e == c.label %} 🏆{% endif %}{% else %}—{% endif %}</td>{% endfor %}</tr>
<tr><td>Peak-RAM</td>{% for c in detail.cells %}<td>{% if c.peak_ram_mb is not none %}{{ "%.1f"|format(c.peak_ram_mb / 1024) }} GB{% if detail.winners.ram == c.label %} 🏆{% endif %}<div class="muted text-xs">Druck: {{ c.mem_pressure_max }}</div>{% else %}—{% endif %}</td>{% endfor %}</tr>
<tr><td>CPU Ø/Max</td>{% for c in detail.cells %}<td>{% if c.cpu_max is not none %}{{ "%.0f"|format(c.cpu_avg) }} / {{ "%.0f"|format(c.cpu_max) }} %{% if detail.winners.cpu == c.label %} 🏆{% endif %}{% else %}<span class="muted">n. v.</span>{% endif %}</td>{% endfor %}</tr>
</tbody>
</table>
</div>
{# ── ③ Per-Aufgabe-Drill-down (V7) ───────────────────────────────────── #}
{% if detail.divergence %}
<div class="card">
<div class="card-title">③ Wo gehen sie bei einzelnen Aufgaben auseinander</div>
<p class="muted text-xs">Per-Prompt-Δ der Judge-Scores (1–5) — eine <em>per-Aufgabe</em>-Sicht, orthogonal zur holistischen Qualitäts-%, nicht deren Herleitung.</p>
{% for p in detail.divergence %}
<div x-data="{ open: false }" style="border:1px solid var(--border); border-radius:8px; margin-bottom:0.4rem; overflow:hidden">
<div class="flex items-center gap-2" style="padding:0.5rem 0.75rem; cursor:pointer; background:var(--bg)" @click="open = !open">
<span class="prompt-id">{{ p.prompt_id }}</span>
<span class="text-sm" style="flex:1">{{ p.title }}</span>
<span class="text-xs muted">Δ {{ "%.1f"|format(p.delta) }}</span>
{% for c in detail.cells %}<span class="text-xs">{{ c.label }}: {% set sc = p.scores.get(c.label) %}{{ "%.1f"|format(sc) if sc is not none else "—" }}</span>{% endfor %}
<span class="muted text-xs" x-text="open ? '▲' : '▼'"></span>
</div>
<div x-show="open" x-cloak style="padding:0.75rem; display:flex; gap:1rem; flex-wrap:wrap">
{% for a in p.answers %}
<div style="flex:1; min-width:220px">
<div class="muted text-xs" style="text-transform:uppercase; letter-spacing:0.05em">{{ a.label }}{% if a.score is not none %} · {{ a.score }}/5{% endif %}{% if a.red_flag %} <span class="badge nein text-xs">Red-Flag</span>{% endif %}</div>
<div style="background:var(--card-bg); border-radius:6px; padding:0.5rem 0.7rem; white-space:pre-wrap; font-size:0.82rem; margin-top:0.25rem">{{ a.response_text[:400] }}{% if a.response_text|length > 400 %}…{% endif %}</div>
{% if a.rationale %}<div class="muted text-xs" style="margin-top:0.25rem">Judge: {{ a.rationale }}</div>{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}{# /single #}
<div class="card"><a href="/result/{{ detail.run_name }}">← Ergebnis-Detail</a> · <a href="/compare">Cross-Run-Aggregat (Station 6)</a></div>
{% endif %}{# /detail #}
{% endblock %}
{% include "_method_explainer.html" with context %}passes the current context; the partial readspack. Because the partial uses barepack, set it: change the include to{% with pack=detail.pack %}{% include "_method_explainer.html" %}{% endwith %}.
Apply that include form:
{% if detail.pack %}{% with pack=detail.pack %}{% include "_method_explainer.html" %}{% endwith %}{% endif %}
- [ ] Step 4: Run tests to verify they pass
Run: uv run pytest tests/test_gui_compare_route.py -q
Expected: PASS (all route tests). Then run the route commit from Task 7 Step 5 if not yet committed.
- [ ] Step 5: Commit
git add touchstone/gui/templates/compare_axis.html touchstone/gui/static/scatter.js touchstone/gui/app.py touchstone/gui/compare.py tests/test_gui_compare_route.py
git commit -m "feat(compare): compare_axis.html 3-layer view + scatter.js + route wiring"
Task 9: „↔ Vergleichen"-Links in result.html + overview.html¶
Files:
- Modify: touchstone/gui/app.py (pass link data into /result and /)
- Modify: touchstone/gui/templates/result.html
- Modify: touchstone/gui/templates/overview.html
- Test: tests/test_gui_compare_route.py
- [ ] Step 1: Write the failing test
# append to tests/test_gui_compare_route.py
def test_result_shows_compare_link_when_comparable(tmp_path):
d = _two_variant_bundle(tmp_path)
r = _client(tmp_path).get(f"/result/{d.name}")
assert r.status_code == 200
assert f"/compare/{d.name}?axis=variant" in r.text
assert "↔ Vergleichen" in r.text
def test_result_no_compare_link_when_single(tmp_path):
d = tmp_path / "2026_eval_one"
_write_compare_bundle(
d, cells=[("m", "baseline")],
dim_scores_by_cell={("m", "baseline"): {q: 4 for q in ["Q1","Q2","Q3","Q4","Q5","Q6","Q7"]}},
)
r = _client(tmp_path).get(f"/result/{d.name}")
assert "↔ Vergleichen" not in r.text
def test_overview_shows_compare_link_for_multi_variant(tmp_path):
d = _two_variant_bundle(tmp_path)
r = _client(tmp_path).get("/")
assert r.status_code == 200
assert f"/compare/{d.name}?axis=variant" in r.text
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_gui_compare_route.py -k "compare_link or no_compare" -q
Expected: FAIL — links not rendered yet.
- [ ] Step 3a: Pass data from the routes (app.py)
/result route — compute axis options from the already-loaded detail and pass it:
compare_opts = (
compare.axis_options(detail["responses"]) if detail and detail.get("responses") else None
)
return render(
"result.html", request, detail=detail, summary=summary, run_dir=rd,
compare_opts=compare_opts, active="overview",
)
/ overview route — build a per-bundle map (robust, cheap):
@app.get("/", response_class=HTMLResponse)
def overview(request: Request) -> HTMLResponse:
items = bundles.discover(runs_dir)
compare_links = {
b.run_dir.name: compare.axis_options_for_dir(b.run_dir) for b in items
}
return render(
"overview.html", request, bundles=items, compare_links=compare_links, active="overview"
)
- [ ] Step 3b:
result.html— add the link in the header card actions
Inside the header <div style="text-align:right; ..."> block (where the verdict badges render), append after the badges loop:
{% if compare_opts and compare_opts.comparable %}
<div style="margin-top:0.4rem">
<a class="btn btn-secondary" href="/compare/{{ run_dir.name }}?axis={{ compare_opts.default_axis }}">↔ Vergleichen ({{ compare_opts.default_label }})</a>
</div>
{% endif %}
- [ ] Step 3c:
overview.html— add the link in the Aktionen cell
In the actions <td> (where „Ergebnis"/„Fortsetzen"/„Judge" buttons are), append:
{% set co = compare_links.get(b.run_dir.name) %}
{% if co and co.comparable %}
<a href="/compare/{{ b.run_dir.name }}?axis={{ co.default_axis }}" class="btn btn-secondary" style="padding:0.2rem 0.5rem;font-size:0.75rem">↔ Vergleichen</a>
{% endif %}
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_gui_compare_route.py -q
Expected: PASS (all route tests).
- [ ] Step 5: Commit
git add touchstone/gui/app.py touchstone/gui/templates/result.html touchstone/gui/templates/overview.html tests/test_gui_compare_route.py
git commit -m "feat(compare): '↔ Vergleichen' link on result + overview (only when >1 axis value)"
Task 10: AGENTS.md + finale Qualitäts-Gates¶
Files:
- Modify: AGENTS.md
- Test: full suite + static gates
- [ ] Step 1: Update AGENTS.md
Add to the GUI/routes section a paragraph distinguishing the two compare endpoints:
- **Zwei Vergleichs-Ebenen, klar getrennt:** `/compare` (Station 6) ist das **Cross-Run-Aggregat**
über *alle* Bundles (`aggregate.load_all_scores` → Tabelle Hardware×Qualität). `/compare/{bundle}`
ist der **Innerhalb-Bundle-Achsen-Vergleich** (Ink. 8): eine kontrollierte Ansicht entlang `model`
oder `variant` *innerhalb eines* Bundles — Effizienz-Scatter (x=Decode, y=Qualität, r=Peak-RAM
`sys_used_mb`), Kopf-an-Kopf (`master_rows`⨝`reports`-Join; `master_rows` trägt kein `dim_scores`),
per-Aufgabe-Drill-down (per-Prompt `Verdict.score`-Δ, V7 — orthogonal zur holistischen %).
CPU wird GUI-seitig aus `resources.jsonl`-Fenstern berechnet (`compare._cpu_for_window`); da
`cpu_pct` erst mit Ink. 7 kam und kein Bundle seither neu lief, ist CPU heute überall **„n. v."**
(ein first-class getesteter Zustand). Pure Logik in `touchstone/gui/compare.py`.
- [ ] Step 2: Run the full test suite
Run: uv run pytest -q
Expected: PASS — all prior tests (239) + the new compare tests, 0 failures.
- [ ] Step 3: Static gates
Run:
uv run mypy touchstone
uv run ruff check touchstone tests
uv run ruff format --check touchstone tests
ruff format --check flags files, run uv run ruff format touchstone tests and re-stage.
- [ ] Step 4: Commit
git add AGENTS.md
git commit -m "docs(agents): /compare/{bundle} (within-bundle axis compare) vs /compare (cross-run)"
- [ ] Step 5: Manual smoke (optional, for Johannes)
Against the real ndassist bundle (gitignored): start the GUI on a fixed port, open /compare/<bundle>?axis=variant (baseline vs none) — scatter separates both, Kopf-an-Kopf shows Qualität + Speed + RAM (CPU = „n. v."), Drill-down opens diverging answers + Verdict rationales. For axis=model + non-empty CPU a fresh 2-model eval is needed.
Self-Review (gegen die Spec)¶
Spec coverage:
- V1 (within-bundle, model|variant) → Task 3/6/7. ✔
- V2 (Qualität in Relation zu Kosten, keine verdichtete Kennzahl) → Scatter + Kosten-Zeilen + _relations_summary (Task 4/8). ✔
- V3 (3 Schichten, N-fähig) → compare_axis.html ①②③ (Task 8); Scatter & Tabelle iterieren über N cells. ✔
- V4 (Drill-down nutzt Phase-1-Nachvollziehbarkeit, per-Prompt Verdict.rationale; dim_rationales nur wenn vorhanden) → CellAnswer/DivergencePrompt (Task 5), dim_rationales aus reports (Task 3). ✔
- V5 (kein Cross-Bundle) → out of scope, nur /compare/{bundle}. ✔
- V6 (CPU GUI-seitig aus resources.jsonl-Fenstern, „n. v." first-class) → _cpu_for_window (Task 2) + CPU-Spalte (Task 8) + Tests. ✔
- V7 (③ per-Aufgabe, orthogonal zur %) → _divergence (Task 5), UI-Text in Task 8. ✔
- §3 Default-Achse (model bei >1 Modell sonst variant) → axis_options + compare_detail resolve (Task 1/3). ✔
- §4 Qualität via master_rows⨝reports (master ohne dim_scores), Speed/RAM direkt (Peak-RAM=sys_used_mb), pressure_max, Projektion baseline/erst-gesehen → Task 3. ✔
- §4 reports.jsonl-lose Bundles → bundle_detail-Fallback _reports_from_scores (dim_rationales leer → „Begründung nicht erfasst"); abgedeckt durch un-judged Test (Task 7) + bestehender Fallback. ✔
- §5 Dateien (compare.py, Route, compare_axis.html, scatter.js, result/overview-Link) → Tasks 3–9. ✔
- §6 Navigation/keine Sackgassen → Zurück-Links + Achsen-/Projektions-Switch (Task 8). ✔
- §7 Error-Handling (single, un-judged, CPU leer, keine reports.jsonl, korrupte resources.jsonl) → Tasks 7 + try/except in compare_detail + bundle_detail-Defensive. ✔
- §8 Teststrategie (alle Unit + Integration + 2×2-Fixture + CPU-„n. v.") → Tasks 1–9. ✔
Placeholder scan: Jeder Code-Step enthält vollständigen Code; keine TODO/„handle edge cases"-Phrasen. ✔
Type consistency: CompareDetail/CompareCell/DivergencePrompt/CellAnswer/AxisOptions durchgängig gleich benannt; compare_detail(run_dir, axis=None, *, projection=None); axis_options/axis_options_for_dir konsistent; winners-Keys (pct,decode,ttft,e2e,ram,cpu,<dim.id>) Template↔_winners identisch. ✔
```