B2 — Cross-Run-Vergleichsseite 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: /compare wird eine Auswahl-+-Vergleichsseite: einzelne Bundle-Zeilen (HW×Modell×Quant×Variante) auswählen/importieren, dann ein Auto-Diff-Vergleich (GEMEINSAM-Block + variierende Spalten + Winner).
Architecture: Zwei neue pure Funktionen in aggregate.py — pool_rows(runs_dir) (pro-Bundle-Zeilen mit run_dir-Herkunft + stabiler ID) und diff_rows(selected) (common/varying-Erkennung + Winner) — speisen die /compare-Route (?rows=<id>,…). compare.html wird neu: Auswahl-Pool oben (Tabelle + Alpine-Filter + Import + „Vergleichen"), Auto-Diff-Partial unten. Die bestehende aggregate() (CLI-Pfad) bleibt unberührt.
Tech Stack: Python 3.12 · FastAPI + Jinja2 ([gui] extra) · Alpine.js (Filter + Auswahl + Import-fetch) · stdlib csv · pytest · ruff · mypy strict.
Global Constraints¶
- Python 3.12,
uvonly. Ruff (line-length 100) + mypy strict müssen nach jedem Task grün sein (uv run ruff check . && uv run ruff format . && uv run mypy touchstone/). - Deutsch in UI-Text/Kommentaren ok; Code/Identifier englisch.
- Die bestehende
aggregate.aggregate()(über GroupKey gemittelt) bleibt unverändert — sie speist den CLI-Pfadtouchstone aggregate. B2 fügt Funktionen hinzu. /compare/{name}(der B1-Redirect) undcompare.html's B1-Nachbarn (result.html,_compare_block.html) nicht anfassen.- Scope-Disziplin: Nur die je Task genannten Dateien. Format nur geänderte Python-Dateien (
uv run ruff format <files>), nieruff format .tree-weit.git addexplizit, niegit add -A.git status --shortvor jedem Commit prüfen — nur beabsichtigte Dateien. - Identifizierende Dimensionen (für common/varying):
chip, ram_gb, pack, pack_version, model, quant, variant— keinmachine(Projekt-A-Provenance-Hygiene). - Robuste Routen: ungültige/unbekannte
rows-IDs werden ignoriert, nie 500.
Task 1: pool_rows — pro-Bundle-Zeilen mit stabiler ID¶
Files:
- Modify: touchstone/aggregate.py (Helper extrahieren + pool_rows + PoolRow hinzufügen)
- Test: tests/test_aggregate.py (oder neue tests/test_pool_rows.py falls test_aggregate.py groß)
Interfaces:
- Consumes: load_scores_csv(path) -> list[dict[str,str]] (existiert).
- Produces:
- @dataclass PoolRow mit Feldern: id: str (f"{run_name}|{model}|{variant}"), run_name: str, chip: str, ram_gb: str, pack: str, pack_version: str, model: str, quant: str, variant: str, quality_pct: float | None, ttft_p50: str, decode_med: str, peak_ram_gb: str, model_delta_gb: str, power: str.
- pool_rows(runs_dir: str | Path) -> list[PoolRow] — eine Zeile pro (run_name, model, variant) über alle runs_dir/*/scores.csv, run_name = scores.csv.parent.name.
- _weighted_quality(rows: list[dict[str,str]]) -> tuple[float | None, dict[str,int]] — extrahiert aus aggregate() (Σ score·weight / Σ 5·weight über metric_type=='dimension'-Zeilen), von aggregate() UND pool_rows genutzt (DRY).
- [ ] Step 1: Write the failing test
# tests/test_aggregate.py
from pathlib import Path
from touchstone import aggregate
def _write_scores(d: Path, rows: list[dict]):
import csv
d.mkdir(parents=True, exist_ok=True)
cols = ["chip","ram_gb","pack","pack_version","model","quant","variant",
"ttft_p50","decode_med","peak_ram_gb","model_delta_gb","power",
"metric_type","metric","weight","score"]
with (d / "scores.csv").open("w", newline="", encoding="utf-8") as fh:
w = csv.DictWriter(fh, fieldnames=cols, extrasaction="ignore"); w.writeheader()
for r in rows: w.writerow(r)
def test_pool_rows_one_row_per_bundle_model_variant(tmp_path):
# two runs, same setup → two distinct pool rows (NOT averaged), unique ids
base = {"chip":"M1","ram_gb":"16","pack":"ndassist","pack_version":"1",
"model":"gemma","quant":"Q4","variant":"baseline",
"ttft_p50":"0.1","decode_med":"18","peak_ram_gb":"14","model_delta_gb":"8","power":"ac"}
dim = {**base, "metric_type":"dimension","metric":"D1","weight":"1","score":"4"}
_write_scores(tmp_path / "2026-01-01_000000_eval_ndassist", [dim])
_write_scores(tmp_path / "2026-01-02_000000_eval_ndassist", [dim])
pool = aggregate.pool_rows(tmp_path)
assert len(pool) == 2
ids = {r.id for r in pool}
assert ids == {"2026-01-01_000000_eval_ndassist|gemma|baseline",
"2026-01-02_000000_eval_ndassist|gemma|baseline"}
r0 = pool[0]
assert r0.chip == "M1" and r0.model == "gemma" and r0.variant == "baseline"
assert r0.quality_pct == 80.0 # score 4 of 5 = 80%
assert r0.decode_med == "18"
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_aggregate.py::test_pool_rows_one_row_per_bundle_model_variant -v
Expected: FAIL — aggregate has no attribute pool_rows.
- [ ] Step 3: Implement
In aggregate.py: extrahiere die Quality-Schleife aus aggregate() in _weighted_quality, lass aggregate() sie aufrufen (gleiches Ergebnis), und füge PoolRow + pool_rows hinzu:
def _weighted_quality(rows: list[dict[str, str]]) -> tuple[float | None, dict[str, int]]:
dim_scores: dict[str, int] = {}
wsum = wmax = 0
for r in rows:
if r.get("metric_type") != "dimension":
continue
score = _as_int(r.get("score", ""))
if score is None:
continue
weight = _as_int(r.get("weight", "")) or 0
dim_scores[r.get("metric", "")] = score
wsum += score * weight
wmax += SCALE_MAX * weight
return ((wsum / wmax * 100.0) if wmax else None), dim_scores
@dataclass
class PoolRow:
id: str
run_name: str
chip: str
ram_gb: str
pack: str
pack_version: str
model: str
quant: str
variant: str
quality_pct: float | None
ttft_p50: str
decode_med: str
peak_ram_gb: str
model_delta_gb: str
power: str
def pool_rows(runs_dir: str | Path) -> list[PoolRow]:
"""One row per (run_name, model, variant) — NOT averaged across bundles (unlike
aggregate()), so two runs of the same setup stay distinct. id = run_name|model|variant."""
base = Path(runs_dir)
if not base.exists():
return []
groups: dict[tuple[str, str, str], list[dict[str, str]]] = {}
order: list[tuple[str, str, str]] = []
for f in sorted(base.rglob("scores.csv")):
run_name = f.parent.name
for r in load_scores_csv(f):
k = (run_name, r.get("model", ""), r.get("variant", ""))
if k not in groups:
groups[k] = []
order.append(k)
groups[k].append(r)
out: list[PoolRow] = []
for (run_name, model, variant) in order:
grp = groups[(run_name, model, variant)]
first = grp[0]
quality, _dims = _weighted_quality(grp)
out.append(
PoolRow(
id=f"{run_name}|{model}|{variant}",
run_name=run_name,
chip=first.get("chip", ""),
ram_gb=first.get("ram_gb", ""),
pack=first.get("pack", ""),
pack_version=first.get("pack_version", ""),
model=model,
quant=first.get("quant", ""),
variant=variant,
quality_pct=quality,
ttft_p50=first.get("ttft_p50", ""),
decode_med=first.get("decode_med", ""),
peak_ram_gb=first.get("peak_ram_gb", ""),
model_delta_gb=first.get("model_delta_gb", ""),
power=first.get("power", ""),
)
)
return out
aggregate()'s inner quality loop to call _weighted_quality(grp) (keep n_dims=len(dim_scores)).
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_aggregate.py -v && uv run mypy touchstone/
Expected: PASS — including the existing aggregate tests (refactor must not change their results).
- [ ] Step 5: Commit
git add touchstone/aggregate.py tests/test_aggregate.py
git commit -m "feat(aggregate): pool_rows — per-bundle rows with stable id (DRY _weighted_quality)"
Task 2: diff_rows — common/varying + winners¶
Files:
- Modify: touchstone/aggregate.py (add CompareColumn, CompareDiff, diff_rows)
- Test: tests/test_aggregate.py
Interfaces:
- Consumes: PoolRow (Task 1).
- Produces:
- @dataclass CompareColumn mit id: str, header: str, row: PoolRow.
- @dataclass CompareDiff mit common: list[tuple[str, str]] ((dim_label, value) für über alle konstante Dims), varying: list[str] (Dim-Namen die differieren; leer → der Lauf ist die Achse), columns: list[CompareColumn], winners: dict[str, str | None] (metric → gewinnende column-id, None bei Gleichstand/fehlend).
- diff_rows(selected: list[PoolRow]) -> CompareDiff.
- Konstanten: DIM_LABELS = [("chip","Chip"),("ram_gb","RAM"),("pack","Pack"),("pack_version","Pack-Version"),("model","Modell"),("quant","Quant"),("variant","Variante")].
- [ ] Step 1: Write the failing test
def test_diff_rows_cross_machine(tmp_path):
from touchstone.aggregate import PoolRow, diff_rows
def pr(rid, chip, ram, decode, ram_gb_peak):
return PoolRow(id=rid, run_name=rid, chip=chip, ram_gb=ram, pack="ndassist",
pack_version="1", model="gemma", quant="Q4", variant="baseline",
quality_pct=76.0, ttft_p50="0.1", decode_med=decode,
peak_ram_gb=ram_gb_peak, model_delta_gb="8", power="ac")
d = diff_rows([pr("a","M1","16","18","14"), pr("b","M5","64","42","18")])
# model/pack/quant/variant constant → common; chip/ram_gb vary → headers
common_dims = {dim for dim, _ in d.common}
assert "model" in common_dims and "pack" in common_dims and "quant" in common_dims
assert set(d.varying) == {"chip", "ram_gb"}
assert d.columns[0].header and d.columns[1].header # non-empty headers from varying values
assert d.winners["decode_med"] == "b" # 42 > 18, higher is better
assert d.winners["quality_pct"] is None # tie (76 == 76)
def test_diff_rows_same_setup_axis_is_run(tmp_path):
from touchstone.aggregate import PoolRow, diff_rows
def pr(rid, decode):
return PoolRow(id=rid, run_name=rid, chip="M1", ram_gb="16", pack="ndassist",
pack_version="1", model="gemma", quant="Q4", variant="baseline",
quality_pct=76.0, ttft_p50="0.1", decode_med=decode,
peak_ram_gb="14", model_delta_gb="8", power="ac")
d = diff_rows([pr("run-A","18"), pr("run-B","42")])
assert d.varying == [] # all dims identical
assert d.columns[0].header == "run-A" # header falls back to run_name
assert d.winners["decode_med"] == "run-B"
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_aggregate.py::test_diff_rows_cross_machine tests/test_aggregate.py::test_diff_rows_same_setup_axis_is_run -v
Expected: FAIL — no diff_rows.
- [ ] Step 3: Implement
DIM_LABELS: list[tuple[str, str]] = [
("chip", "Chip"), ("ram_gb", "RAM"), ("pack", "Pack"),
("pack_version", "Pack-Version"), ("model", "Modell"),
("quant", "Quant"), ("variant", "Variante"),
]
@dataclass
class CompareColumn:
id: str
header: str
row: PoolRow
@dataclass
class CompareDiff:
common: list[tuple[str, str]]
varying: list[str]
columns: list[CompareColumn]
winners: dict[str, str | None]
def _num(s: str) -> float | None:
try:
return float(s)
except (TypeError, ValueError):
return None
def _winner(cols: list[CompareColumn], getter: Any, higher: bool) -> str | None:
scored = [(getter(c.row), c.id) for c in cols if getter(c.row) is not None]
if not scored:
return None
best = (max if higher else min)(v for v, _ in scored)
leaders = [cid for v, cid in scored if v == best]
return leaders[0] if len(leaders) == 1 else None # tie → no trophy (B1 convention)
def diff_rows(selected: list[PoolRow]) -> CompareDiff:
common: list[tuple[str, str]] = []
varying: list[str] = []
for dim, label in DIM_LABELS:
vals = {getattr(r, dim) for r in selected}
if len(vals) <= 1:
common.append((dim, next(iter(vals)) if vals else ""))
else:
varying.append(dim)
columns: list[CompareColumn] = []
for r in selected:
if varying:
header = " · ".join(getattr(r, d) for d in varying)
else:
header = r.run_name # all dims identical → the run is the axis
columns.append(CompareColumn(id=r.id, header=header, row=r))
winners = {
"quality_pct": _winner(columns, lambda row: row.quality_pct, True),
"decode_med": _winner(columns, lambda row: _num(row.decode_med), True),
"ttft_p50": _winner(columns, lambda row: _num(row.ttft_p50), False),
"peak_ram_gb": _winner(columns, lambda row: _num(row.peak_ram_gb), False),
"model_delta_gb": _winner(columns, lambda row: _num(row.model_delta_gb), False),
}
return CompareDiff(common=common, varying=varying, columns=columns, winners=winners)
from typing import Any to the imports if not present.
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_aggregate.py -v && uv run mypy touchstone/
Expected: PASS.
- [ ] Step 5: Commit
git add touchstone/aggregate.py tests/test_aggregate.py
git commit -m "feat(aggregate): diff_rows — auto-diff common/varying + winners"
Task 3: /compare route takes ?rows=¶
Files:
- Modify: touchstone/gui/app.py (compare_cross route, ~line 222-226)
- Test: tests/test_gui_compare_route.py (route-level) — NOTE: this file also holds B1 redirect tests; add to it, don't disturb them.
Interfaces:
- Consumes: aggregate.pool_rows(runs_dir), aggregate.diff_rows(selected) (Tasks 1-2).
- Produces: compare.html context gets pool: list[PoolRow] (all rows) and diff: CompareDiff | None (only when ≥2 valid rows ids selected).
- [ ] Step 1: Write the failing test
def test_compare_pool_and_diff(client_factory, tmp_path):
# build two pool rows via real scores.csv, then select both via ?rows=
# (reuse a scores.csv writer helper; assert pool renders and diff appears)
...
def test_compare_no_rows_shows_pool_only(client_factory):
# GET /compare without ?rows → diff is None (no comparison block)
...
def test_compare_bad_rows_ignored(client_factory):
# GET /compare?rows=does|not|exist → 200, no 500, no diff
...
(Use the existing GUI TestClient construction pattern from this file / test_gui_app_detail.py's _client. For the pool, write real scores.csv files under the client's runs_dir with the Task-1 writer helper. Concrete assertions: resp.status_code == 200; pool body contains the run/model/variant; with two valid ids the body contains "GEMEINSAM" (the diff block marker from Task 5) — if Task 5 isn't done yet, assert the route context via a render spy like B1's Task 3, checking diff is not None and len(diff.columns) == 2.)
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_gui_compare_route.py -k compare_pool -v
Expected: FAIL — route ignores rows, passes no pool/diff.
- [ ] Step 3: Implement
@app.get("/compare", response_class=HTMLResponse)
def compare_cross(request: Request, rows: str | None = None) -> HTMLResponse:
pool = aggregate_mod.pool_rows(runs_dir)
diff = None
if rows:
wanted = [x for x in rows.split(",") if x]
selected = [r for r in pool if r.id in wanted]
# preserve the user's selection order (as given in ?rows=)
selected.sort(key=lambda r: wanted.index(r.id))
if len(selected) >= 2:
diff = aggregate_mod.diff_rows(selected)
return render("compare.html", request, pool=pool, diff=diff, active="compare")
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_gui_compare_route.py -v && uv run mypy touchstone/
Expected: PASS (the rendered-HTML "GEMEINSAM" assertion goes green after Task 5; until then assert the context via spy).
- [ ] Step 5: Commit
git add touchstone/gui/app.py tests/test_gui_compare_route.py
git commit -m "feat(gui): /compare takes ?rows= → pool + auto-diff context"
Task 4: compare.html — selection pool¶
Files:
- Modify: touchstone/gui/templates/compare.html (replace the body)
- Test: tests/test_gui_compare_route.py
Interfaces:
- Consumes: pool (list[PoolRow]) from the route context (Task 3).
- [ ] Step 1: Write the failing test
def test_compare_pool_has_checkboxes_and_compare_button(client_factory, ...):
body = ... # GET /compare with ≥1 pool row
assert 'type="checkbox"' in body
assert "x-data" in body # Alpine selection state
assert "Vergleichen" in body # the compare button
# each pool row exposes its id as the checkbox value
assert 'value="' in body and "|gemma|baseline" in body
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_gui_compare_route.py -k pool_has -v
Expected: FAIL — old compare.html renders the static rows aggregate table, no checkboxes.
- [ ] Step 3: Implement
Replace compare.html's body. Pool table over pool, Alpine state { sel: [], chip:'', pack:'', model:'' }:
- Filter inputs (chip/pack/model) bound with x-model; each row <tr x-show="(!chip || '{{r.chip}}'===chip) && …">.
- Each row: <input type="checkbox" value="{{ r.id }}" x-model="sel"> + cells (run_name/date, chip, ram, pack, model, quant, variant, quality%, decode, peak-ram).
- A „Vergleichen"-Button: <button :disabled="sel.length < 2" @click="window.location = '/compare?rows=' + sel.map(encodeURIComponent).join(',')">Vergleichen (<span x-text="sel.length"></span>)</button>.
- Keep the page title/card chrome. Put the table in a partial if it grows (macros/_compare_pool.html) — optional.
(Keep the existing {% extends "base.html" %} + ui macros import. German UI text.)
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_gui_compare_route.py -v
Expected: PASS.
- [ ] Step 5: Commit
git add touchstone/gui/templates/compare.html tests/test_gui_compare_route.py
git commit -m "feat(gui): compare selection pool (checkboxes + filters + compare button)"
Task 5: Auto-Diff comparison section¶
Files:
- Create: touchstone/gui/templates/macros/_compare_diff.html
- Modify: touchstone/gui/templates/compare.html (include the diff partial when diff)
- Test: tests/test_gui_compare_route.py
Interfaces:
- Consumes: diff (CompareDiff) from the route context — diff.common, diff.varying, diff.columns (.id/.header/.row), diff.winners (Tasks 2-3).
- [ ] Step 1: Write the failing test
def test_compare_diff_renders_common_and_columns(client_factory, ...):
# cross-machine: two rows differing only in chip/ram
body = ... # GET /compare?rows=<a>,<b>
assert "GEMEINSAM" in body # common block
assert "gemma" in body # the constant model shown in GEMEINSAM
assert "M1" in body and "M5" in body # varying chips as column headers
assert "🏆" in body # a winner marker on the differing metric
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_gui_compare_route.py -k diff_renders -v
Expected: FAIL — no diff partial rendered.
- [ ] Step 3: Implement
macros/_compare_diff.html: a GEMEINSAM line ({% for dim, val in diff.common %}{{ label }} {{ val }}{% endfor %} — map dim→label via the ui macros or an inline lookup) and a table:
<div class="card">
<div class="card-title">↔ Cross-Run-Vergleich</div>
<p class="muted text-sm">GEMEINSAM:
{% for dim, val in diff.common %}{% if val %}{{ val }}{% if not loop.last %} · {% endif %}{% endif %}{% endfor %}
</p>
<table class="bundle-table">
<thead><tr><th></th>{% for c in diff.columns %}<th>{{ c.header }}</th>{% endfor %}</tr></thead>
<tbody>
<tr><td>Qualität</td>{% for c in diff.columns %}<td>{% if c.row.quality_pct is not none %}{{ "%.0f"|format(c.row.quality_pct) }} %{% if diff.winners.quality_pct == c.id %} 🏆{% endif %}{% else %}—{% endif %}</td>{% endfor %}</tr>
<tr><td>Decode</td>{% for c in diff.columns %}<td>{% if c.row.decode_med %}{{ c.row.decode_med }} tok/s{% if diff.winners.decode_med == c.id %} 🏆{% endif %}{% else %}—{% endif %}</td>{% endfor %}</tr>
<tr><td>TTFT P50</td>{% for c in diff.columns %}<td>{% if c.row.ttft_p50 %}{{ c.row.ttft_p50 }} s{% if diff.winners.ttft_p50 == c.id %} 🏆{% endif %}{% else %}—{% endif %}</td>{% endfor %}</tr>
<tr><td>System-Peak</td>{% for c in diff.columns %}<td>{% if c.row.peak_ram_gb %}{{ c.row.peak_ram_gb }} GB{% if diff.winners.peak_ram_gb == c.id %} 🏆{% endif %}{% else %}—{% endif %}</td>{% endfor %}</tr>
<tr><td>Modell-Delta</td>{% for c in diff.columns %}<td>{% if c.row.model_delta_gb %}{{ c.row.model_delta_gb }} GB{% if diff.winners.model_delta_gb == c.id %} 🏆{% endif %}{% else %}—{% endif %}</td>{% endfor %}</tr>
</tbody>
</table>
</div>
compare.html, above or below the pool: {% if diff %}{% include "macros/_compare_diff.html" %}{% endif %}.
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_gui_compare_route.py -v
Expected: PASS. Now the Task-3 "GEMEINSAM" HTML assertion (if used) also passes.
- [ ] Step 5: Commit
git add touchstone/gui/templates/macros/_compare_diff.html touchstone/gui/templates/compare.html tests/test_gui_compare_route.py
git commit -m "feat(gui): auto-diff comparison section (GEMEINSAM + varying columns + winners)"
Task 6: Import upload field¶
Files:
- Modify: touchstone/gui/templates/compare.html (add an import control to the pool zone)
- Test: tests/test_gui_compare_route.py
Interfaces:
- Consumes: the existing POST /import-bundle endpoint (returns {"run_dir": name}), unchanged.
- [ ] Step 1: Write the failing test
def test_compare_has_import_control(client_factory, ...):
body = ... # GET /compare
assert 'type="file"' in body
assert "/import-bundle" in body # the endpoint the JS posts to
assert "Importieren" in body # the control label
- [ ] Step 2: Run test to verify it fails
Run: uv run pytest tests/test_gui_compare_route.py -k import_control -v
Expected: FAIL — no import control in compare.html.
- [ ] Step 3: Implement
Add an Alpine import control to compare.html's pool zone (the existing /import-bundle returns JSON, so post via fetch and reload on success):
<div x-data="{ busy:false, err:'' }" style="margin-bottom:0.75rem">
<label class="muted text-sm">Lauf importieren (.zip von anderer Maschine):</label>
<input type="file" accept=".zip" @change="
busy=true; err='';
const fd = new FormData(); fd.append('file', $event.target.files[0]);
fetch('/import-bundle', {method:'POST', body:fd})
.then(r => r.ok ? r.json() : Promise.reject())
.then(() => window.location.reload())
.catch(() => { err='Import fehlgeschlagen'; busy=false; });
">
<span x-show="busy" class="muted text-xs">importiere…</span>
<span x-show="err" class="text-xs" style="color:var(--nein)" x-text="err"></span>
</div>
- [ ] Step 4: Run test to verify it passes
Run: uv run pytest tests/test_gui_compare_route.py -v && uv run pytest -q && uv run ruff check . && uv run mypy touchstone/
Expected: PASS, full suite green, lint+types clean.
- [ ] Step 5: Commit
git add touchstone/gui/templates/compare.html tests/test_gui_compare_route.py
git commit -m "feat(gui): import-bundle upload control on the compare page"
Notes für die Umsetzung¶
aggregate()(CLI) niemals in Verhalten ändern — nur den_weighted_quality-Helper extrahieren; die bestehenden aggregate-Tests sind der Wächter.- Scores.csv-Writer-Helper für Tests: einmal definieren (Task 1) und in den Route-Tests wiederverwenden (ggf. teilen).
- Reihenfolge: Task 3 (Route-Daten) vor Task 4/5 (HTML). Task 3's Step-1 prüft den Kontext per Spy, die HTML-Strings kommen mit Task 4/5.
/compare/{name}(B1-Redirect),result.html,_compare_block.htmlnicht anfassen.- Nach jedem Task:
uv run pytest -q && uv run ruff check . && uv run mypy touchstone/grün.