GUI Best-Practices Redesign — 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: Turn the raw touchstone gui control-center into a self-explaining, feature-complete UI — without switching frameworks — by adding a component/glossary spine, rendering already-captured data, fixing one real bug, and adding two small instrumentation features.
Architecture: Additive on the existing out-of-process FastAPI + Jinja2 + Alpine.js stack. Two new substrates (a Jinja macro library and a pure glossary.py) make the UI self-explaining; every other change renders data that already lives in responses.jsonl/scores.csv, adds a read-only viewer route, or adds one cheap capture (RAM baseline, reasoning timing). runs/ stays SSOT; pure logic is unit-tested, I/O is dependency-injected.
Tech Stack: Python 3.12 · uv · FastAPI/Jinja2/Alpine (GUI [gui] extra) · pydantic · pytest · ruff · mypy strict.
Reference: spec docs/superpowers/specs/2026-06-23-gui-best-practices-redesign-design.md.
STATUS (2026-06-24): Plan vollständig abgeschlossen. P1–P7 implementiert + gemergt (
07ade7d), Markdown-Report-Export gemergt, Final-E2E-Smoke gegen echte Hardware bestanden, und P0 Tool-Renameramcheck→touchstonegemergt (2555ca4). 429 Tests grün, ruff/mypy sauber. Einzige offene manuelle Aufgabe: Repo auf Codeberg/GitHub im Web-UI umbenennen (siehe P0).
Conventions (apply to every task):
- Run tests with uv run pytest <path> -q; lint uv run ruff check . && uv run ruff format .; types uv run mypy touchstone/.
- GUI tests use _client(tmp_path) → TestClient(gui_app.create_app(runs_dir=tmp_path, registry=RunRegistry(runs_dir=tmp_path, launcher=_FakeLauncher()))); bundle fixtures via _write_bundle / _write_compare_bundle / _mk_judged (copy the local helper from the sibling test file).
- After any template/route/static change: restart the server and verify the touched route headless (uv run touchstone gui & then curl -s localhost:PORT/route | head); a stale process serves dead routes.
- Commit after every green task. Branch feat/gui-best-practices-redesign.
Phase P1 — The bug: hardware-label contradiction¶
Files:
- Create: touchstone/gui/hwlabel.py (pure mismatch detector)
- Modify: touchstone/gui/compare.py (cross-run rows carry a mismatch flag), touchstone/gui/templates/compare.html:12-50
- Test: tests/test_gui_hwlabel.py
Context: compare.html renders r.chip (live hostinfo), r.ram_gb (live), r.machine (static Config.machine YAML label). On a reused config the label goes stale → "M5 Pro / 64 GB / M1-16GB". AggRow (aggregate.py:40-57) already has chip, ram_gb, machine.
- [x] Step 1: Write the failing test
# tests/test_gui_hwlabel.py
from touchstone.gui.hwlabel import label_mismatch
def test_label_matches_detected_hardware_is_not_flagged():
assert label_mismatch(chip="Apple M5 Pro", ram_gb="64.0", machine="M5-Pro-64GB") is False
def test_stale_label_contradicting_detected_chip_is_flagged():
# label says M1/16GB but the run actually executed on an M5 with 64GB
assert label_mismatch(chip="Apple M5 Pro", ram_gb="64.0", machine="M1-16GB") is True
def test_empty_or_unknown_label_is_not_flagged():
assert label_mismatch(chip="Apple M5 Pro", ram_gb="64.0", machine="") is False
assert label_mismatch(chip="", ram_gb="", machine="whatever") is False
- [x] Step 2: Run it, verify it fails
Run: uv run pytest tests/test_gui_hwlabel.py -q → FAIL (module not found).
- [x] Step 3: Implement
hwlabel.py
# touchstone/gui/hwlabel.py
"""Detect a stale Config.machine label that contradicts the detected hardware.
chip/ram_gb come from live hostinfo at eval time; machine is a hand-typed YAML
label. Reusing a config on new hardware without editing the label produces a row
where the detected truth and the label disagree. We never trust the label over
detected hardware — we flag the disagreement so the UI can demote it.
"""
from __future__ import annotations
import re
_GEN = re.compile(r"\bM(\d+)\b", re.IGNORECASE) # M1, M2, ... chip generation
def label_mismatch(*, chip: str, ram_gb: str, machine: str) -> bool:
"""True when the machine label contradicts detected chip/ram (best-effort).
Conservative: only flags a *positive* contradiction (label names a chip
generation or RAM size that differs from detected). Empty/unknown values
never flag — absence of a label is not a contradiction.
"""
if not machine or not chip:
return False
det_gen = _GEN.search(chip)
lab_gen = _GEN.search(machine)
if det_gen and lab_gen and det_gen.group(1) != lab_gen.group(1):
return True
lab_ram = re.search(r"(\d+)\s*GB", machine, re.IGNORECASE)
if lab_ram and ram_gb:
try:
if abs(float(lab_ram.group(1)) - float(ram_gb)) >= 1.0:
return True
except ValueError:
return False
return False
- [x] Step 4: Run test, verify pass
Run: uv run pytest tests/test_gui_hwlabel.py -q → PASS.
- [x] Step 5: Wire the flag into the cross-run rows
In touchstone/gui/compare.py, after aggregate() produces AggRows the route hands them to the template. The simplest non-invasive wiring: compute the flag in the template via a tiny exposed helper. Add to compare.py (or wherever compare_cross builds context) a post-process that attaches mismatch per row. Concretely, change the /compare route in touchstone/gui/app.py:114-118 to:
@app.get("/compare", response_class=HTMLResponse)
def compare_cross(request: Request) -> HTMLResponse:
from touchstone.gui.hwlabel import label_mismatch
rows = aggregate_mod.load_all_scores(runs_dir)
agg = aggregate_mod.aggregate(rows) if rows else []
flagged = [
(a, label_mismatch(chip=a.chip, ram_gb=str(a.ram_gb), machine=a.machine))
for a in agg
]
return render("compare.html", request, rows=flagged, active="compare")
- [x] Step 6: Update the template to demote a stale label
In touchstone/gui/templates/compare.html, change the loop from {% for r in rows %} over AggRow to unpack the tuple, and render the Maschine cell muted with a warning when flagged. Replace lines 27-50 region's <tr>...<td>{{ r.machine }}</td>... with:
{% for r, mismatch in rows %}
<tr>
<td>{{ r.chip }}</td>
<td>{{ r.ram_gb }}</td>
<td>
{% if mismatch %}
<span class="muted" title="Config-Label »{{ r.machine }}« passt nicht zur erkannten Hardware – erkannte Werte gelten.">{{ r.machine }} ⚠</span>
{% else %}
{{ r.machine }}
{% endif %}
</td>
<td>{{ r.pack }}</td>
<td>{{ r.model }}</td>
<td>{{ r.variant }}</td>
<td>{{ r.quant }}</td>
<td>
{% if r.quality_pct is not none %}<strong>{{ "%.0f" | format(r.quality_pct) }}%</strong>{% else %}<span class="muted">—</span>{% endif %}
</td>
<td>{{ r.ttft_p50 }}</td>
<td>{{ r.decode_med }}</td>
<td>{{ r.peak_ram_gb }}</td>
</tr>
{% endfor %}
- [x] Step 7: Add a route smoke test
# append to tests/test_gui_hwlabel.py — reuse a _write_bundle helper copied from tests/test_gui_bundles.py
# Build a bundle whose scores.csv carries chip "Apple M5 Pro"/ram "64.0" but machine "M1-16GB",
# GET /compare, assert "⚠" in r.text and status 200.
(Copy _write_bundle + _client from tests/test_gui_bundles.py; the scores.csv header is model,variant,metric_type,metric,weight,score plus the hardware columns written by scorecard.scores_csv_rows — assert the warning glyph renders.)
- [x] Step 8: Verify headless + commit
uv run pytest tests/test_gui_hwlabel.py -q && uv run ruff check . && uv run mypy touchstone/gui/hwlabel.py
git add touchstone/gui/hwlabel.py touchstone/gui/app.py touchstone/gui/compare.py touchstone/gui/templates/compare.html tests/test_gui_hwlabel.py
git commit -m "fix(gui): demote stale machine label that contradicts detected hardware (P1)"
Phase P2a — Spine: metric glossary¶
Files:
- Create: touchstone/gui/glossary.py, tests/test_gui_glossary.py
- [x] Step 1: Write the failing test
# tests/test_gui_glossary.py
from touchstone.gui.glossary import GLOSSARY, describe
def test_every_entry_has_term_short_long():
for key, g in GLOSSARY.items():
assert g.term and g.short and g.long, f"incomplete glossary entry: {key}"
def test_describe_known_key_returns_short():
assert "Token" in describe("ttft_p50").short or "TTFT" in describe("ttft_p50").term
def test_describe_unknown_key_returns_safe_placeholder():
d = describe("does_not_exist")
assert d.term == "does_not_exist"
assert d.short == ""
-
[x] Step 2: Run, verify fail.
uv run pytest tests/test_gui_glossary.py -q -
[x] Step 3: Implement
glossary.py
# touchstone/gui/glossary.py
"""Single source of truth for what every metric/label in the GUI means.
Pure data — no GUI imports — so it is unit-testable and so a render-time test can
assert that every metric key a template shows is defined here (no unexplained
number can ship). The `metric()` Jinja macro pulls `short` for tooltips.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Glossary:
term: str # human label
short: str # one-line tooltip
long: str # full explanation (glossary page / explainer)
GLOSSARY: dict[str, Glossary] = {
"quality_pct": Glossary(
"Qualität %",
"Σ(Score × Gewicht) / Max × 100 über die holistisch bewerteten Dimensionen.",
"Der Judge vergibt pro Master-Dimension 1–5; gewichtet, normiert auf 0–100 %. "
"Belegt durch eine Begründung mit zitierten Prompt-IDs.",
),
"ttft_p50": Glossary(
"TTFT P50",
"Time-To-First-Token, Median (s): Wartezeit bis zum ersten sichtbaren Antwort-Token.",
"Reasoning-Tokens zählen NICHT zur TTFT — erst der erste Content-Token stoppt die Uhr.",
),
"decode_median": Glossary(
"Decode Median",
"Median der Decode-Geschwindigkeit (Token/s) im Antwort-Fenster nach der TTFT.",
"completion_tokens / (e2e − ttft). Reine Generier-Rate, ohne Prefill.",
),
"prefill_tps": Glossary(
"Prefill",
"Prompt-Verarbeitung (Token/s): prompt_tokens / TTFT.",
"Wie schnell der Prompt eingelesen wird, bevor das erste Token kommt.",
),
"e2e": Glossary(
"Gesamtzeit",
"End-to-End-Wandzeit einer Antwort (s), von Absenden bis letztem Token.",
"Enthält Prefill + Thinking + Decode.",
),
"total_throughput": Glossary(
"Gesamt-Durchsatz",
"(prompt_tokens + completion_tokens) / Gesamtzeit (Token/s).",
"Effektiver Durchsatz inkl. Prompt-Verarbeitung — näher am gefühlten Tempo als Decode allein.",
),
"system_peak_ram": Glossary(
"System-Peak",
"Höchster System-Speicher während des Laufs (GB) — OS + alle Prozesse, nicht nur das Modell.",
"Auf Apple-Silicon mmap't mlx die Gewichte in den Unified Memory; RSS unterzählt. "
"Diese Zahl ist NICHT maschinen-vergleichbar — siehe Modell-Delta.",
),
"model_delta_ram": Glossary(
"Modell-Delta",
"System-Peak minus Baseline vor dem Lauf (GB) — der maschinen-vergleichbare Speicher-Zuwachs.",
"Baseline = Systemspeicher unmittelbar vor der ersten Anfrage. Der Kontext-/KV-Anteil "
"ist im Unified Memory nicht sauber von den Gewichten trennbar (dokumentierte Unschärfe).",
),
"mem_pressure": Glossary(
"Memory-Pressure",
"macOS-Speicherdruck (normal/warn/critical) im Lauf-Fenster.",
"Aus `memory_pressure`. Steigt, wenn das System komprimiert/auslagert.",
),
"variant_baseline": Glossary(
"baseline",
"Lauf MIT dem im Pack definierten System-Prompt.",
"Die »baseline«-Variante setzt den Einsatzzweck-Prompt; Vergleich gegen »none« misst dessen Effekt.",
),
"variant_none": Glossary(
"none",
"Lauf OHNE System-Prompt (Kontrollgruppe).",
"Misst das nackte Modell; Differenz zu »baseline« = Wirkung des Prompts.",
),
"reasoning_only": Glossary(
"reasoning-only",
"Modell schrieb nur ins Reasoning-Feld, keine sichtbare Antwort → aus dem Mittel ausgenommen.",
"Setup-Hinweis (z. B. Token-Budget zu klein), kein inhaltliches Urteil. Echt leer bleibt 1.",
),
"reasoning_duration": Glossary(
"Thinking-Dauer",
"Zeit im Reasoning-Kanal (s), bevor der erste Content-Token kommt.",
"Getrennt von der Antwortzeit gemessen.",
),
"reasoning_tps": Glossary(
"Thinking-Tempo",
"Reasoning-Token/s (heuristisch gezählt).",
"Die API liefert nur eine aggregierte completion_tokens-Zahl; Reasoning-Tokens werden geschätzt.",
),
"cold_start": Glossary(
"Cold-Start",
"Allererste Anfrage des Laufs — separat ausgewiesen, nie in den Aggregaten.",
"Misst »wird das Modell warm«; aus Mittelwerten ausgeschlossen.",
),
"cpu": Glossary(
"CPU Ø/Max",
"System-CPU-Last (%) im Antwort-Fenster, Durchschnitt/Maximum.",
"»n. v.«, wenn das Bundle vor der CPU-Erfassung lief.",
),
}
def describe(key: str) -> Glossary:
"""Glossary entry for a key, or a safe placeholder for an unknown key."""
return GLOSSARY.get(key, Glossary(term=key, short="", long=""))
-
[x] Step 4: Run, verify pass.
uv run pytest tests/test_gui_glossary.py -q→ PASS. -
[x] Step 5: Commit.
uv run ruff check . && uv run mypy touchstone/gui/glossary.py
git add touchstone/gui/glossary.py tests/test_gui_glossary.py
git commit -m "feat(gui): metric glossary — single source of truth for UI explanations (P2a)"
Phase P2b — Spine: Jinja macro library + glossary injection¶
Files:
- Create: touchstone/gui/templates/macros/ui.html
- Modify: touchstone/gui/app.py:42-68 (inject glossary into the Jinja env globals), touchstone/gui/templates/base.html
- Test: tests/test_gui_macros.py
- [x] Step 1: Make the glossary available to all templates. In
touchstone/gui/app.py, where_templates(Jinja2Templates) is defined, add the glossary as a global so macros can call it. Add near the module-level_templatesdefinition:
from touchstone.gui import glossary as _glossary
_templates.env.globals["g"] = _glossary.describe # g("ttft_p50").short in templates
- [x] Step 2: Write the macro library.
{# touchstone/gui/templates/macros/ui.html — reusable presentation macros #}
{% macro metric(value, key, unit="") -%}
{%- set gd = g(key) -%}
<span class="metric" title="{{ gd.short }}">{{ value }}{% if unit %} {{ unit }}{% endif %}{% if gd.short %}<span class="metric-help" aria-hidden="true">ⓘ</span>{% endif %}</span>
{%- endmacro %}
{% macro mlabel(key) -%}
{%- set gd = g(key) -%}
<span class="metric-label" title="{{ gd.short }}">{{ gd.term }}{% if gd.short %}<span class="metric-help" aria-hidden="true">ⓘ</span>{% endif %}</span>
{%- endmacro %}
{% macro badge(text, kind) -%}
<span class="badge {{ kind }}">{{ text }}</span>
{%- endmacro %}
{% macro alert(kind, body) -%}
<div class="alert alert-{{ kind }}">{{ body }}</div>
{%- endmacro %}
{% macro kv(label, value) -%}
<div class="kv-row"><span class="kv-label">{{ label }}</span><span class="kv-value">{{ value }}</span></div>
{%- endmacro %}
-
[x] Step 2b: Add minimal CSS for
.metric-help,.metric-label,.kv-row,.kv-label,.kv-value,.alertintouchstone/gui/static/app.css(mirror existing token style;.metric-help{font-size:0.7em;opacity:0.5;margin-left:2px};.kv-row{display:flex;gap:1rem;padding:0.15rem 0;border-bottom:1px solid var(--border)}etc.). -
[x] Step 3: Write the test (glossary completeness gate + macro render).
# tests/test_gui_macros.py
from fastapi.testclient import TestClient
from touchstone.gui import app as gui_app
from touchstone.gui.control import RunRegistry
# copy _FakeLauncher from tests/test_gui_app_read.py
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_glossary_global_is_registered():
# the macro layer relies on g(key) being a Jinja global
assert "g" in gui_app._templates.env.globals
- [x] Step 4: Run, verify pass. Then commit.
uv run pytest tests/test_gui_macros.py -q && uv run ruff check .
git add touchstone/gui/templates/macros/ui.html touchstone/gui/app.py touchstone/gui/static/app.css tests/test_gui_macros.py
git commit -m "feat(gui): Jinja macro library + glossary injected as template global (P2b)"
Phase P2c — Explain layer + drop HTMX dead weight¶
Files: Modify touchstone/gui/templates/compare.html, compare_axis.html, config.html, pack.html, _method_explainer.html, base.html; pyproject.toml; delete touchstone/gui/static/htmx.min.js.
-
[x] Step 1: Metric headers via
mlabel. Incompare.html<thead>replace bare<th>Qualität %</th><th>TTFT P50</th><th>Decode Median</th><th>Peak RAM</th>with{% import "macros/ui.html" as ui %}at top and<th>{{ ui.mlabel("quality_pct") }}</th>etc. Do the same for the row labels incompare_axis.html(Qualität,Decode,TTFT P50,e2e Median,Peak-RAM,CPU Ø/Max→ui.mlabel("quality_pct"),"decode_median","ttft_p50","e2e","system_peak_ram","cpu"). -
[x] Step 2: Model-checkbox help + variant legend in
config.html. Add under theModellelabel:<p class="muted text-xs">Nur angehakte Modelle werden evaluiert. Abwählen schließt ein Modell aus diesem Lauf aus (nichts wird in die Config geschrieben).</p>. -
[x] Step 3: Variant legend in
compare_axis.html+pack.html. Where variantsbaseline/noneappear, add a one-line legend pullingg("variant_baseline").short/g("variant_none").short. -
[x] Step 4: Pack-name hyperlink in cross-run compare. In
compare.htmlchange<td>{{ r.pack }}</td>to<td><a href="/packs/packs/{{ r.pack }}.yaml">{{ r.pack }}</a></td>. -
[x] Step 5: Breadcrumbs. Add a
{% block breadcrumb %}{% endblock %}tobase.htmlabove the main content; set it inresult.html/compare_axis.html/pack.html(e.g.Übersicht › Ergebnis › {{ run_dir.name }}). -
[x] Step 6: Reformat
_method_explainer.html. Wrap the weighting formula in<pre class="formula">Σ (Score × Gewicht) / Max × 100 = Qualität %</pre>; render the two K.-o. branches as two<div class="card ko-branch">cards; render the 1–5 scale as a small<table>. -
[x] Step 7: Drop HTMX. Remove the
<script src="/static/htmx.min.js">tag frombase.html; deletetouchstone/gui/static/htmx.min.js. (HTMX is not inpyproject.tomldeps — it is a vendored asset only — so only the file + tag are removed.) -
[x] Step 8: Smoke test the explain layer.
# tests/test_gui_explain.py — copy _client/_FakeLauncher/_write_bundle
def test_compare_headers_have_glossary_tooltips(tmp_path):
_write_bundle(tmp_path / "2026_eval_nd", groups=[("m", "baseline")], scores_by_group={("m", "baseline"): {"Q1": 4}})
r = _client(tmp_path).get("/compare")
assert r.status_code == 200
assert "Time-To-First-Token" in r.text # tooltip text from glossary
def test_base_no_longer_references_htmx(tmp_path):
r = _client(tmp_path).get("/")
assert "htmx.min.js" not in r.text
- [x] Step 9: Restart server, headless-verify
/,/compare,/config,/packs/packs/ndassist.yaml; commit.
git add -A touchstone/gui pyproject.toml tests/test_gui_explain.py
git rm touchstone/gui/static/htmx.min.js
git commit -m "feat(gui): self-explaining layer — metric tooltips, legends, breadcrumbs, reformatted explainer; drop unused HTMX (P2c)"
Phase P3 — Result perf panel + full text + e2e_med¶
Files: Modify touchstone/gui/templates/result.html:318-356, touchstone/gui/templates/pack.html:39-78, touchstone/gui/bundles.py:164-204, touchstone/scorecard.py:101-113 + :296-309, touchstone/aggregate.py; tests tests/test_scorecard.py/test_scorecard_render.py, tests/test_gui_result_perf.py.
Task P3.1 — Backend rider: e2e_med into the perf summary + scores.csv¶
- [x] Step 1: Failing test in
tests/test_scorecard_render.py-style (pure):
# tests/test_scorecard_e2e.py
from touchstone.scorecard import _perf_summary
# copy _resp() builder from tests/test_scorecard_render.py (EvalResponse with e2e_s set)
def test_perf_summary_includes_e2e_med():
resps = [_resp(e2e_s=1.0, ok=True), _resp(e2e_s=3.0, ok=True)]
p = _perf_summary(resps)
assert p["e2e_med"] == 2.0
-
[x] Step 2: Run, verify fail.
-
[x] Step 3: Add
e2e_medto_perf_summary(scorecard.py:101-113). Afterdecodes = [...]adde2es = [r.e2e_s for r in ok if not math.isnan(r.e2e_s)]; in the returned dict add"e2e_med": median(e2es),. -
[x] Step 4: Add
e2e_medto the scores.csv base dict (scorecard.py:296-309): after"decode_med": _num(p["decode_med"]),add"e2e_med": _num(p["e2e_med"]),. -
[x] Step 5: Carry
e2e_medthrough aggregate. Inaggregate.pyadde2e_med: strtoAggRow(afterdecode_med) and read it in the row builder; ensurewrite_scores_all_csvcarries the column. Add totests/test_aggregate.pyHEADERthee2e_medcolumn. -
[x] Step 6: Run scorecard+aggregate tests, verify pass; commit.
uv run pytest tests/test_scorecard_e2e.py tests/test_aggregate.py -q
git commit -am "feat(scorecard): persist e2e_med so cross-run total-throughput survives the aggregate path (P3.1)"
Task P3.2 — Per-answer metric block in result.html¶
- [x] Step 1:
bundle_detailalready passesdetail["responses"](list ofEvalResponse) withttft_s, decode_tps, prefill_tps, e2e_s, prompt_tokens, completion_tokens, reasoning_chars— no backend change. Inresult.html, inside the per-answer loop (318-356), after theModell-Antwortblock insert a metrics row using the macro and a computed total throughput:
{% import "macros/ui.html" as ui %}
{# ... inside the {% for resp in ns2.responses %} block, after the answer div ... #}
<div class="flex gap-2 text-xs muted" style="flex-wrap:wrap; margin-top:0.35rem">
{% if resp.ttft_s is not none and resp.ttft_s == resp.ttft_s %}{{ ui.metric("%.2f"|format(resp.ttft_s), "ttft_p50", "s") }}{% endif %}
{% if resp.decode_tps is not none and resp.decode_tps == resp.decode_tps %}{{ ui.metric("%.0f"|format(resp.decode_tps), "decode_median", "tok/s") }}{% endif %}
{% if resp.prefill_tps is not none and resp.prefill_tps == resp.prefill_tps %}{{ ui.metric("%.0f"|format(resp.prefill_tps), "prefill_tps", "tok/s") }}{% endif %}
{% if resp.e2e_s is not none %}{{ ui.metric("%.2f"|format(resp.e2e_s), "e2e", "s") }}{% endif %}
{% if resp.e2e_s and resp.e2e_s > 0 %}{{ ui.metric("%.0f"|format((resp.prompt_tokens + resp.completion_tokens) / resp.e2e_s), "total_throughput", "tok/s") }}{% endif %}
<span class="metric" title="Prompt-/Antwort-Tokens">{{ resp.prompt_tokens }}→{{ resp.completion_tokens }} tok</span>
{% if resp.reasoning_chars > 0 %}<span class="metric" title="{{ g('reasoning_only').short }}">💭 {{ resp.reasoning_chars }} Z.</span>{% endif %}
</div>
(x == x is the Jinja idiom for "not NaN".)
-
[x] Step 2: Expandable reasoning block. When
resp.reasoning_textis non-empty (persisted only oncontent_empty), add an Alpine collapsible mirroring the answer block, showingresp.reasoning_textwithwhite-space:pre-wrap. -
[x] Step 3: Test.
# tests/test_gui_result_perf.py — copy _client/_FakeLauncher/_write_bundle (with perf overrides)
def test_result_shows_per_answer_perf(tmp_path):
d = tmp_path / "2026_eval_nd"
_write_bundle(d, groups=[("m", "baseline")], scores_by_group={("m", "baseline"): {q: 4 for q in DIMS}},
perf={"ttft_s": 0.2, "decode_tps": 30.0, "e2e_s": 1.5, "prompt_tokens": 100, "completion_tokens": 50})
r = _client(tmp_path).get(f"/result/{d.name}")
assert r.status_code == 200
assert "tok/s" in r.text and "→" in r.text # per-answer metric block rendered
- [x] Step 4: Restart + headless-verify
/result/<bundle>; commit.
Task P3.3 — Full prompt + full system-prompt in pack.html¶
- [x] Step 1: Remove the
[:80]slice (pack.html:43) — render the fullpv.system_promptinside an Alpine collapsible (white-space:pre-wrap), collapsed by default. - [x] Step 2: In the prompts loop (
pack.html:60-73), after the title row add a collapsible showing{{ p.prompt }}(full text,pre-wrap) and, when present,{{ p.tests }}as the rubric. - [x] Step 3: Test
tests/test_gui_pack_view.py: GET/packs/packs/ndassist.yaml; assert a known full prompt substring (longer than 80 chars) appears inr.textand is not truncated with…. - [x] Step 4: Restart + headless-verify; commit.
git commit -am "feat(gui): per-answer perf/token block + full prompt & system-prompt text (P3.2/P3.3)"
Phase P4 — Config/Pack viewer + ephemeral overrides + YAML export¶
Files: Create touchstone/gui/templates/config_view.html; modify touchstone/gui/app.py (new /config-view/{config_path} + /export-yaml routes; extend start_eval with a small override whitelist), touchstone/config.py (an apply_overrides whitelist fn), config.html (a "?" link). Tests tests/test_gui_config_view.py, tests/test_config_overrides.py.
Task P4.1 — Read-only config viewer route¶
- [x] Step 1: Test (mirror
pack_explorer):GET /config-view/config.m5.yaml→ 200, body contains endpoint base_url, machine,runs_per_cell, a model id;GET /config-view/../etc/passwd→ 404. - [x] Step 2: Route in
app.pymirroringpack_explorer(77-87):
@app.get("/config-view/{config_path:path}", response_class=HTMLResponse)
def config_view(request: Request, config_path: str) -> HTMLResponse:
candidate = Path(config_path)
if candidate.is_absolute() or ".." in candidate.parts:
raise HTTPException(status_code=404)
if config_path not in {str(p) for p in Path(".").glob("config*.yaml")}:
raise HTTPException(status_code=404)
try:
cfg = load_config(config_path)
except (FileNotFoundError, OSError):
raise HTTPException(status_code=404) from None
return render("config_view.html", request, cfg=cfg, path=config_path, active="config")
- [x] Step 3: Template
config_view.htmlusingui.kvmacros for endpoint (mask api_key), machine, runs_per_cell, seed, temperature, context_buckets, scenarios, max_tokens, server_process_match, power_check, engine/engine_version, vlm, embed, and a models table (id, quant, max_tokens_default, reasoning_headroom_tokens, extra_body). Add a "Config-Datei herunterladen" link →/export-yaml?kind=config&path=.... - [x] Step 4: "?" link next to the Config picker in
config.htmlopening/config-view/{{ config }}in a new tab (Alpine::href="'/config-view/' + config"). - [x] Step 5: Run tests, restart, headless-verify, commit.
Task P4.2 — YAML export route (effective pack/config)¶
- [x] Step 1: Test:
GET /export-yaml?kind=config&path=config.m5.yaml→ 200,text/yaml, body parses viayaml.safe_loadback into a dict withmachine; bad kind/path → 404. - [x] Step 2: Route: validate
kind in {"config","pack"}and the path against the offeredconfig*.yaml/packs/*.yamlglobs;load_config/load_pack; serializeyaml.safe_dump(model.model_dump(mode="json"), sort_keys=False, allow_unicode=True); return asResponse(media_type="application/x-yaml", headers={"Content-Disposition": f'attachment; filename="{name}"'}). - [x] Step 3: Run, restart, commit.
Task P4.3 — Ephemeral non-model overrides at start¶
- [x] Step 1: Test in
tests/test_config_overrides.py(pure):apply_overrides(cfg, {"runs_per_cell": 4, "seed": 7})returns a copy with those replaced and everything else intact; an unknown key ({"endpoint": ...}) raisesValueError; empty dict is a no-op (returns equal config). - [x] Step 2: Implement
apply_overrides(cfg: Config, overrides: dict[str, object]) -> Configinconfig.py, whitelisting{"runs_per_cell", "seed", "temperature"}only, validating types, usingcfg.model_copy(update=...)then re-validating viaConfig.model_validate(updated.model_dump())so validators run. (Models keep their existingapply_models_overridepath.) - [x] Step 3: DEFERRED — do not wire into the spawn this round. Decision gate resolved:
touchstone eval --helpconfirms the CLI has no--runs-per-cell/--seed/--temperatureflags (only--resume/--run-dir/--models-json). Therefore shipapply_overridesas a tested pure helper (Steps 1–2 only) and DO NOT touchstart_eval/RunRegistry/control.pyfor non-model overrides. The model override path (models_json→apply_models_override) is unchanged and already wired. Note the deferral in the commit message. (Follow-up, out of scope here: add the eval CLI flags, then wireoverrides_json.) - [x] Step 4: Tests green, restart, headless-verify, commit.
git commit -am "feat(gui): read-only config viewer + YAML export + ephemeral override whitelist (P4)"
Phase P7 — Export/import whole bundles¶
Files: Modify touchstone/gui/app.py (/export-bundle/{name}, /import-bundle), result.html (zip button + import card or a small /import page). Tests tests/test_gui_bundle_io.py.
Task P7.1 — Export bundle as zip¶
- [x] Step 1: Test: build a judged bundle;
GET /export-bundle/<name>→ 200,application/zip; open the returned bytes withzipfile.ZipFile(io.BytesIO(r.content)), assert it containsbundle.json,responses.jsonl,scores.csvand does NOT containrun.json/events.jsonl. - [x] Step 2: Route:
@app.get("/export-bundle/{name}")
def export_bundle(name: str) -> Any:
rd = (runs_dir / name).resolve()
if not rd.is_relative_to(runs_dir.resolve()) or not (rd / "bundle.json").exists():
raise HTTPException(status_code=404)
ledger = ["bundle.json", "responses.jsonl", "scores.csv", "reports.jsonl",
"judgements.jsonl", "scorecard.md", "perf.csv", "resources.jsonl"]
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for f in ledger:
p = rd / f
if p.exists():
z.write(p, arcname=f)
buf.seek(0)
return Response(buf.getvalue(), media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="{name}.zip"'})
- [x] Step 3: Zip button in
result.htmlexport card. Run, restart, commit.
Task P7.2 — Import bundle from zip¶
- [x] Step 1: Test: zip a fixture bundle in-memory;
POST /import-bundle(multipart file) → 200/redirect; assert a new dir landed underruns_dirandbundles.classify(new_dir)is judged. Missingbundle.jsonin zip → 400. Name collision → suffixed dir, both present. - [x] Step 2: Route using
UploadFile(python-multipart is already a[gui]dep): extract to a temp dir, validatebundle.jsonparses +responses.jsonlvalid JSONL, choose a collision-safe name (nameelsename__2), move intoruns_dir, return{"run_dir": new_name}. Apply the same Origin/CSRF guard (it's a POST — the middleware already covers it). - [x] Step 3: Import card (file input → POST) on
/configor a small/importpage with a help card explaining portability + identity (g-style note: a bundle isbundle.json+responses.jsonl+scores.csv;pack_pathmust exist locally to view criteria). - [x] Step 4: Tests green, restart, headless-verify, commit.
Phase P5 — RAM methodology: baseline-subtracted model delta¶
Files: Modify touchstone/sampler.py:239-251 (baseline sample), touchstone/merge.py (carry baseline + delta), touchstone/models.py (RAW_CSV_COLUMNS + RunRecord), touchstone/scorecard.py (_perf_summary + scores.csv), touchstone/results.py (EvalResponse sys_used_baseline_mb), compare/result UI labels, AGENTS.md, docs/explanation/design-decisions.md. Tests tests/test_merge.py, tests/test_scorecard_*.
Methodology note for the implementer: the harness drives an already-running endpoint, so the model may be loaded before sampling starts. The baseline = system memory at sampler start (before the first request). On a pre-loaded server this yields the inference-time growth (KV/context/activations); on a lazy-loading server it includes weights. We report
model_delta = peak_sys_used − baselineas the comparable number and label the raw peak "System-Peak". Per-prompt KV isolation is explicitly out of scope (documented).
Task P5.1 — Capture a baseline sample¶
- [x] Step 1: Test (
tests/test_sampler_baseline.py): aHostSamplerwhosesample_onceis stubbed to return increasingsys_used_mb, runrun_to_filefor a couple ticks, assert the FIRST line written carries a"baseline": truemarker (or that a separatebaseline_sys_used_mbis recorded). - [x] Step 2: Implement. In
sampler.py:239-251, afterself.start()and before the loop, take onesample_once()and write it first with abaselineflag (addbaseline: bool = FalsetoResourceSampleinmodels.py:26-37, defaulting False, set True for this first sample). This keepsresources.jsonlthe single carrier. - [x] Step 3: Run, verify pass.
Task P5.2 — Compute + persist the delta¶
- [x] Step 1: Test (
tests/test_merge.pystyle): given samples where the baseline tick is 40000 MB and the peak in-window is 52000 MB,merge/aggregate yieldssys_used_delta_mb == 12000. - [x] Step 2: Implement. Thread the baseline (the
baseline=Truesample'ssys_used_mb, orminof pre-first-request samples) through to per-cell aggregation; addsys_used_delta_mbtoRunRecord(models.py:52-95) andRAW_CSV_COLUMNS(aftersys_used_mb) — keep the import-timeRAW_CSV_COLUMNS ↔ RunRecordassertion green. Addsys_used_baseline_mb/derived delta intoEvalResponse+_perf_summary(model_delta_gb) + scores.csv. - [x] Step 3: UI labels. In
compare.html/compare_axis.html/result.html, relabel the existing peak asui.mlabel("system_peak_ram")and add aui.mlabel("model_delta_ram")value next to it; pressure staysui.mlabel("mem_pressure"). - [x] Step 4: Document. Add an AGENTS.md gotcha + a
docs/explanation/design-decisions.mdsection: unified-memory caveat, baseline definition, what is NOT decomposed (KV), per-prompt isolation deferred. - [x] Step 5: Full
pytest+mypy+ruff; restart; headless-verify compare/result; commit.
git commit -am "feat(ram): baseline-subtracted model-delta as the cross-machine-comparable memory metric (P5)"
Phase P6 — Reasoning-phase timing¶
Files: Modify touchstone/runner.py:67-78 (RequestOutcome) + :88-164 (stream_once) + :167-180/derive, touchstone/results.py (EvalResponse), touchstone/qualrun.py:189-207 (persist), compare/result UI. Tests tests/test_runner.py.
Task P6.1 — Capture reasoning timing in stream_once¶
- [x] Step 1: Test (
tests/test_runner.py, mirror the existingReasoningClient+SeqClock): a client that yields tworeasoning_textchunks then onedelta_text; with an injected clock, assert the returned outcome hasreasoning_duration_s≈ (last reasoning tick − first reasoning tick) and that it is set only when reasoning arrived (NaN/None otherwise). Verify reasoning does NOT move TTFT (existing invariant). - [x] Step 2: Implement. Add
t_reasoning_start: float | None = Noneandt_reasoning_last: float | None = NonetoRequestOutcome. Instream_once's loop, on eachev.reasoning_text: ift_reasoning_start is None: t_reasoning_start = clock() - t0; alwayst_reasoning_last = clock() - t0. After the loop computereasoning_duration_s = (t_reasoning_last - t_reasoning_start)when both set elsemath.nan. Add a heuristic reasoning token count via(counter or HeuristicCounter()).count(reasoning)→reasoning_completion_tokens;reasoning_tps = reasoning_completion_tokens / reasoning_duration_s(nan-safe, mirrorderive_rates). - [x] Step 3: Run, verify pass.
Task P6.2 — Persist + surface¶
- [x] Step 1: Add
reasoning_duration_s: float,reasoning_tps: float,reasoning_completion_tokens: inttoEvalResponse(results.py) with safe defaults; set them inqualrun.py:189-207from the outcome; they flow intoresponses.jsonlviaas_dict(). Keep the slim policy: timing always;reasoning_textonly oncontent_empty(unchanged). - [x] Step 2: Test the
EvalResponseround-trip (mirrortest_results.py) — new fields default safely and serialize. - [x] Step 3: Surface in
result.htmlper-answer block (ui.metric(reasoning_duration_s, "reasoning_duration", "s"),ui.metric(reasoning_tps, "reasoning_tps", "tok/s")) and add aThinking-row tocompare_axis.htmlvia a newCompareCell.reasoning_*median incompare._cell_metrics. - [x] Step 4: Full
pytest/mypy/ruff; restart; headless-verify; commit.
git commit -am "feat(eval): capture reasoning-phase duration + tps; surface thinking vs response split (P6)"
Phase P0 — Tool rename (DONE 2026-06-24 — ramcheck → touchstone)¶
Target name: Johannes chose touchstone. CLI touchstone, package touchstone/, distribution llm-touchstone.
- [x] Swept the standalone
ramchecktoken acrosspyproject.toml([project].name→llm-touchstone,[project.scripts]→touchstone = "touchstone.cli:app",packages), theramcheck/→touchstone/package dir (git mv, history preserved), all imports, tests,AGENTS.md, docs, configs, GUI templates/static. uv.lock regenerated. GUI brand capitalized to "Touchstone" — the sidebar logo was split across tags (ram<span>check</span>) so the blanket replace missed it; fixed by hand. 429 tests green · ruff clean · mypy 0 · GUI routes 200. Merge2555ca4.
Restliche manuelle Aufgabe (Hard-Blocker, Web-UI): Das Repo heißt noch
llm-benchmark-harnessauf Codeberg (origin) + GitHub (mirror). Umbenennen muss Johannes in beiden Web-UIs; die git-Remotes funktionieren danach weiter (Plattformen leiten alte URLs um), optional lokalgit remote set-urlnachziehen.
Final E2E smoke (before declaring done)¶
- [x] Start a real local endpoint; run
uv run touchstone eval --pack packs/ndassist.yaml --config <local>.yamlagainst ≥1 real model; open the GUI and walk every touched station (Konfig+Start, Übersicht/pack, Ergebnis, Vergleich, Export/Import). Confirm: per-answer perf renders with real numbers, RAM shows System-Peak + Modell-Delta, reasoning timing appears for a thinking model, export→import round-trips into/compare. Tests/review check logic; this checks real load (lessonharness-preflight-and-rebuild).
DONE (2026-06-24): Echter Smoke gegen LM Studio :1234 mit
google/gemma-4-12b-qat(echtes Thinking-Modell, liefertreasoning_content), Judgeqwen/qwen3.6-27b. Bundle2026-06-24_133030_eval_ndassist(6 Antworten, 0 Fehler, Qualität 76.2 % baseline / 88.8 % none). Live verifiziert: P3 Per-Answer-Perf (tok/s), P5 System-Peak 19.5/33.7 GB + Modell-Delta 1.2/15.5 GB, P6 Reasoning-Timing (reasoning_duration_s≈36 s,reasoning_tps≈16) + baseline-Sample als ersteresources.jsonl-Zeile, P1 hwlabel-Demotion feuert im/compare(alte M1-Labels auf M5-Hardware ⚠), P4 config-view + YAML-Export, P7 ZIP-Export + Import-Round-Trip, sowiejudge_model/quantim Report-Export. TTFT-Invariante hält (Uhr stoppt erst beim ersten Content-Token, nicht beim ersten Reasoning-Tick).
Self-review — spec coverage¶
- Spec §2.1 macros → P2b. §2.2 glossary → P2a (+ completeness gate). P1 bug → P1. P2 explain → P2c. P3 perf/full-text +
e2e_medrider → P3. P4 viewer/overrides/export → P4. P5 RAM delta → P5. P6 reasoning timing → P6. P7 export/import → P7. P0 rename → P0 (deferred). Out-of-scope items (dark mode, mobile, per-prompt KV, full YAML editor) are not tasked — correct. - Sequence matches spec: P1 → P2 → P3 → (P4 ∥ P7) → P5 → P6 → P0.