diff --git a/generative/calibration/collect.py b/generative/calibration/collect.py
index 4d1a248..52a92b9 100644
--- a/generative/calibration/collect.py
+++ b/generative/calibration/collect.py
@@ -205,13 +205,18 @@ def main() -> None:
print(f" [warn] Pipeline-Labels für Agreement nicht ladbar: {_ke}", file=_sys.stderr)
# LLM-Halluzinationsraten aus note_evals holen (kanonische DB: db.DB_PATH —
- # nicht generative/.cache, dort liegt keine DB)
+ # nicht generative/.cache, dort liegt keine DB). note_evals kann mehrere
+ # Zeilen derselben Note tragen (Re-Evals, s. Statistik-Review 2026-07-15/
+ # eval_dashboard._dedup_latest_per_note) — ohne ORDER BY war die Zeilen-
+ # reihenfolge unspezifiziert und "letzte gewinnt" damit nicht-deterministisch;
+ # `ORDER BY timestamp` macht "neueste Eval-Zeile gewinnt" explizit, dieselbe
+ # Dedup-Basis wie KPI-Kachel/per-PDF-Tabelle.
_db.init_db(_db.DB_PATH)
conn_plain = _sq.connect(str(_db.DB_PATH))
llm_rates = {
r[0]: r[1]
for r in conn_plain.execute(
- "SELECT note_path, hallucination_rate FROM note_evals WHERE eval_version='4.1'"
+ "SELECT note_path, hallucination_rate FROM note_evals WHERE eval_version='4.1' ORDER BY timestamp"
).fetchall()
}
conn_plain.close()
diff --git a/generative/eval_dashboard.py b/generative/eval_dashboard.py
index 59090b5..490394f 100644
--- a/generative/eval_dashboard.py
+++ b/generative/eval_dashboard.py
@@ -51,6 +51,7 @@
_WORDS_RE = re.compile(r"(\d[\d.]*)\s+W")
_PAGES_RE = re.compile(r"(\d+)\s+S\.")
_CHUNKS_RE = re.compile(r"(\d+)\s+Chunks")
+_NOTE_NS_PREFIX_RE = re.compile(r"^(?:vault|inbox|merge)__")
_PDF_LABELS: dict[str, str] = {
"bates": "Bates 2017",
@@ -571,6 +572,13 @@ def _calc_kpis(
if latest_pver
else quality_rows
)
+ # Re-Eval-Dedup (Statistik-Review 2026-07-15): pro Note nur die neueste
+ # Eval-Zeile — sonst poolen hall/cov unten Anker mehrfach-evaluierter
+ # Notes mehrfach (Produktionsbeleg: 52 Zeilen / 40 distinct Notes bei
+ # v0.3.140). Ab hier ist `latest_qrows` die EINE Basis für hall, cov UND
+ # n_notes — die Kachel-n passt dadurch automatisch zur Pooling-Basis
+ # (vorher: Kachel n=40, Pooling-Basis 52 — inkonsistent).
+ latest_qrows = _dedup_latest_per_note(latest_qrows)
all_versions = sorted({r["ver"] for r in all_log_runs if r.get("ver")}, key=_ver_sort_key)
@@ -751,6 +759,58 @@ def _distinct_notes(rows: list[dict]) -> int:
return len({r.get("note_path") or r.get("note") or i for i, r in enumerate(rows)})
+def _note_key(r: dict, idx: int) -> str:
+ """Normalisierter Note-Schlüssel für Dedup/Identitätsvergleiche.
+
+ `note_path`/`note` mit gestripptem Namespace-Prefix (`vault__`/`inbox__`/
+ `merge__`) — eine Note kann zwischen zwei Re-Evals den Namespace wechseln
+ (Routing-Änderung/Merge), ein reiner Feldvergleich würde dieselbe Note
+ sonst als zwei Identitäten zählen (Statistik-Review 2026-07-15). Fehlt ein
+ Identifier (synthetische Rows), ist der Schlüssel der Zeilenindex —
+ konsistent mit dem Fallback in `_distinct_notes`."""
+ raw = r.get("note_path") or r.get("note")
+ if not raw:
+ return f"__row{idx}"
+ return _NOTE_NS_PREFIX_RE.sub("", str(raw))
+
+
+def _dedup_latest_per_note(rows: list[dict]) -> list[dict]:
+ """Pro (normalisierter) Note NUR die neueste Eval-Zeile.
+
+ Statistik-Review 2026-07-15 (3 unabhängige Opus-Statistiker, konvergent +
+ adversarial bestätigt): note_evals enthält mehrere Zeilen derselben Note
+ innerhalb einer pipeline_version (Re-Evals + identische Duplikat-Inserts;
+ Produktionsbeleg v0.3.140 = 52 Zeilen / 40 distinct Notes, 12 Duplikate).
+ Ungefiltert poolt jede KPI-Aggregation (`_pooled_hall_stats`, Coverage-
+ Median, `kpi_trend` im Server) Anker mehrfach-evaluierter Notes mehrfach —
+ Pseudoreplikation, ~2pp Bias auf der gepoolten Fehlerquote (oft
+ re-evaluierte Notes haben tendenziell gute Raten, die die Poolung sonst
+ nach unten ziehen).
+
+ Aufrufer MÜSSEN `rows` vorher auf eine einzelne pipeline_version
+ einschränken — der Dedup hier ist versionsblind (reine Note-Identität);
+ das Vermischen mehrerer Versionen ist Aufgabe des Callers, nicht dieser
+ Funktion.
+
+ „Neueste" = größter `timestamp`-String (ISO 8601, lexikographisch
+ sortierbar); Tie-Break `eval_id` (ebenfalls Timestamp-präfixiert), dann
+ Listenposition — rein für Determinismus bei exaktem Timestamp+eval_id-
+ Gleichstand (Rows ohne beides: die letzte in der Liste gewinnt, was zur
+ `ORDER BY timestamp`-Reihenfolge von `db.query_note_evals` passt). Rows
+ ohne Note-Identifier zählen einzeln (s. `_note_key`)."""
+ best: dict[str, tuple] = {}
+ order: list[str] = []
+ for i, r in enumerate(rows):
+ key = _note_key(r, i)
+ sort_key = (str(r.get("timestamp") or ""), str(r.get("eval_id") or ""), i)
+ prev = best.get(key)
+ if prev is None or sort_key > prev[0]:
+ if prev is None:
+ order.append(key)
+ best[key] = (sort_key, r)
+ return [best[k][1] for k in order]
+
+
def _newest_capped_version(versions: list[str], current: str | None) -> str | None:
"""Neueste Version, die nicht NEUER als die Config-Version ist (#191);
fällt auf die neueste vorhandene zurück, wenn ALLE Versionen Orphans sind
@@ -864,6 +924,10 @@ def _words_for(runs: list[dict]) -> int | None:
# „neueste Eval-Version" zeigen (#194 P5).
orphan = bool(vers) and _capped_latest_version(vers, current_version) is None
at = [r for r in qrows if _row_version(r) == ver]
+ # Re-Eval-Dedup (Statistik-Review 2026-07-15): dieselbe Basis wie die
+ # KPI-Kachel (`_calc_kpis`) — sonst zeigen Kachel und per-PDF-Tabelle
+ # unterschiedliche gepoolte Raten für dieselbe Version.
+ at = _dedup_latest_per_note(at)
n_notes = _distinct_notes(at)
hall = _pooled_hall_pct(at)
cov_vals = [v for r in at if (v := r.get("coverage_factual") or r.get("coverage_rate")) is not None and v >= 0]
@@ -985,6 +1049,19 @@ def _chart_longitudinal(log_data: dict) -> dict:
_DELTA_MIN_N = 20 # unter N=20 kein Besser/Schlechter-Urteil (Apophenie-Schutz)
+# Corpus-Overlap-Guard (Statistik-Review 2026-07-15, 3 unabhängige Opus-
+# Statistiker, konvergent + adversarial bestätigt): n>=20 in beiden Versionen
+# allein härtet ein Delta NICHT gegen PDF-Mix-Artefakte. Produktionsbeleg
+# v0.3.140 -> v0.3.143: beide n>=20 (40/22 distinct Notes), aber nur 3 von 9/5
+# PDFs geteilt — von den 22 Notes der neueren Version stammen nur 8 (36 %) aus
+# einer PDF, die auch in v0.3.140 vorkommt. Das dort gemessene +2,7pp-Hall-
+# Delta ist damit größtenteils ein ausgetauschter Corpus, kein echter
+# Versions-Effekt, wurde vor diesem Fix aber als "reliable" (grün/rot) gezeigt.
+# Schwelle 50 %: unter der Hälfte notengewichteter Quellen-Überlappung ist der
+# Corpus faktisch ausgetauscht. Als Konstante konfigurierbar statt hart
+# inline verdrahtet, falls sich das in der Praxis als zu streng/lax zeigt.
+_DELTA_MIN_PDF_OVERLAP = 0.5
+
def version_delta(kpi_trend: dict, metric: str) -> dict:
"""Delta der neuesten Version gegen die letzte belastbare Vorversion.
@@ -998,12 +1075,24 @@ def version_delta(kpi_trend: dict, metric: str) -> dict:
(n=1–2), gegen die jedes Delta reliable:false wäre. Existiert KEINE frühere
Version mit n>=_DELTA_MIN_N, greift der bisherige Fallback (direkte
Vorversion, Chip bleibt grau/reliable:false). `prev_version`/`prev_n` machen
- im Client-Tooltip transparent, WOGEGEN verglichen wird. `reliable` bleibt an
- den n>=_DELTA_MIN_N-Guard in BEIDEN Versionen gebunden.
+ im Client-Tooltip transparent, WOGEGEN verglichen wird.
+
+ Statistik-Review 2026-07-15: `reliable` ist zusätzlich an den Corpus-
+ Overlap gekoppelt (`_DELTA_MIN_PDF_OVERLAP`) — der Notes-Anteil der
+ neuesten Version, dessen PDF-Quelle auch in der Vergleichsversion
+ vorkommt. `kpi_trend["pdf_notes"]` (optional, vom Server befüllt, s.
+ eval_dashboard_server.py) trägt dafür je Version ein
+ `{pdf_group_key: n_notes}`-Dict. Fehlt der Key (ältere Aufrufer/Tests ohne
+ `pdf_notes`), greift der Guard nicht — reines n>=20-Verhalten bleibt
+ rückwärtskompatibel. `reason` unterscheidet im Rückgabewert, WARUM
+ `reliable` False ist (`"n_lt_20"` vs. `"pdf_mix"`), damit der Client die
+ beiden Fälle unterschiedlich betexten kann ("n<20" vs. "nicht vergleichbar
+ (PDF-Mix)").
"""
values = kpi_trend.get(metric) or []
ns = kpi_trend.get("n") or []
versions = kpi_trend.get("versions") or []
+ pdf_notes = kpi_trend.get("pdf_notes") or []
latest = values[-1] if values else None
n_latest = ns[-1] if ns else None
@@ -1013,11 +1102,14 @@ def _n_at(i: int) -> int:
def _ver_at(i: int):
return versions[i] if i < len(versions) else None
+ def _pdf_notes_at(i: int) -> dict:
+ return pdf_notes[i] if i < len(pdf_notes) and pdf_notes[i] else {}
+
# Jüngste frühere Version mit Wert und n>=_DELTA_MIN_N suchen (rückwärts).
- prev = prev_version = n_prev = None
+ prev = prev_version = n_prev = prev_idx = None
for i in range(len(values) - 2, -1, -1):
if values[i] is not None and _n_at(i) >= _DELTA_MIN_N:
- prev, n_prev, prev_version = values[i], _n_at(i), _ver_at(i)
+ prev, n_prev, prev_version, prev_idx = values[i], _n_at(i), _ver_at(i), i
break
else:
# Fallback: direkte Vorversion (bisheriges Verhalten; reliable bleibt False).
@@ -1025,9 +1117,31 @@ def _ver_at(i: int):
prev = values[-2]
n_prev = ns[-2] if len(ns) >= 2 else None
prev_version = _ver_at(len(values) - 2)
+ prev_idx = len(values) - 2
delta = None if (latest is None or prev is None) else round(latest - prev, 4)
- reliable = delta is not None and (n_latest or 0) >= _DELTA_MIN_N and (n_prev or 0) >= _DELTA_MIN_N
+ n_reliable = delta is not None and (n_latest or 0) >= _DELTA_MIN_N and (n_prev or 0) >= _DELTA_MIN_N
+
+ # Notengewichteter Corpus-Overlap: Anteil der Notes der NEUESTEN Version,
+ # deren PDF-Quelle auch in der Vergleichsversion (prev_idx) vorkommt.
+ pdf_overlap = None
+ if prev_idx is not None:
+ latest_pdf_notes = _pdf_notes_at(len(values) - 1)
+ prev_pdf_notes = _pdf_notes_at(prev_idx)
+ total = sum(latest_pdf_notes.values())
+ if total:
+ shared = sum(n for pdf, n in latest_pdf_notes.items() if pdf in prev_pdf_notes)
+ pdf_overlap = round(shared / total, 3)
+ pdf_ok = pdf_overlap is None or pdf_overlap >= _DELTA_MIN_PDF_OVERLAP
+
+ reliable = n_reliable and pdf_ok
+ reason = None
+ if delta is not None:
+ if not n_reliable:
+ reason = "n_lt_20"
+ elif not pdf_ok:
+ reason = "pdf_mix"
+
return {
"latest": latest,
"prev": prev,
@@ -1035,6 +1149,8 @@ def _ver_at(i: int):
"reliable": reliable,
"prev_version": prev_version,
"prev_n": n_prev,
+ "pdf_overlap": pdf_overlap,
+ "reason": reason,
}
diff --git a/generative/eval_dashboard_server.py b/generative/eval_dashboard_server.py
index 1efdd3a..437b073 100644
--- a/generative/eval_dashboard_server.py
+++ b/generative/eval_dashboard_server.py
@@ -713,12 +713,6 @@ def _safe_median(lst):
if ver not in quality_by_version:
quality_by_version[ver] = {"hall": [], "cov": [], "accept": [], "n": 0, "rows": []}
quality_by_version[ver]["rows"].append(r)
- hall_val = r.get("hallucination_rate")
- if hall_val is not None and float(hall_val) >= 0:
- quality_by_version[ver]["hall"].append(float(hall_val) * 100)
- cov = r.get("coverage_factual") or r.get("coverage_rate")
- if cov is not None and float(cov) >= 0:
- quality_by_version[ver]["cov"].append(float(cov) * 100)
# Statistiken berechnen (Median ist primär, Mean sekundär)
def _vkey(v):
@@ -730,6 +724,19 @@ def _vkey(v):
sorted_pipeline_versions = sorted(quality_by_version.keys(), key=_vkey)
for ver, d2 in quality_by_version.items():
+ # Re-Eval-Dedup (Statistik-Review 2026-07-15): pro Note nur die
+ # neueste Eval-Zeile — VOR der hall/cov-Extraktion, damit Kachel
+ # (_calc_kpis), per-PDF-Tabelle und diese Sparkline dieselbe Basis
+ # teilen (Produktionsbeleg: 52 Zeilen / 40 distinct Notes bei
+ # v0.3.140, gepoolte Rate sonst um ~2pp nach unten verzerrt).
+ d2["rows"] = D._dedup_latest_per_note(d2["rows"])
+ for r in d2["rows"]:
+ hall_val = r.get("hallucination_rate")
+ if hall_val is not None and float(hall_val) >= 0:
+ d2["hall"].append(float(hall_val) * 100)
+ cov = r.get("coverage_factual") or r.get("coverage_rate")
+ if cov is not None and float(cov) >= 0:
+ d2["cov"].append(float(cov) * 100)
# n = distinct Notes (nicht Eval-Instanzen), identisch zur „Evaluierte
# Notes"-KPI-Kachel (_calc_kpis.n_notes) — sonst zählt die Kachel distinct
# (40) und die kpi_trend.n-Sparkline Instanzen (52) (#194 #4).
@@ -741,6 +748,17 @@ def _vkey(v):
d2["avg_cov"] = round(sum(d2["cov"]) / len(d2["cov"]), 1) if d2["cov"] else None
d2["median_hall"] = _safe_median(d2["hall"])
d2["median_cov"] = _safe_median(d2["cov"])
+ # Notes je PDF-Quelle (Statistik-Review 2026-07-15, Fix 2): Basis für
+ # den Corpus-Overlap-Guard in D.version_delta — kanonischer
+ # PDF-Gruppen-Schlüssel (SSoT mit PDF-Filter/-Tabelle, #194/#202)
+ # statt Rohtitel, sonst zählen Volltitel- und Kebab-Varianten
+ # derselben Quelle als "verschiedene" PDFs.
+ _pdf_notes: dict[str, int] = {}
+ for r in d2["rows"]:
+ gk = D._pdf_group_key(r.get("pdf")) if r.get("pdf") else ""
+ if gk:
+ _pdf_notes[gk] = _pdf_notes.get(gk, 0) + 1
+ d2["pdf_notes"] = _pdf_notes
# Trend-Daten fuer KPI-Drill-Down (sortierte Listen parallel zu sorted_pipeline_versions)
# Akzeptanzrate je Pipeline-Version: gepoolt (sum vault / sum total) —
@@ -795,6 +813,9 @@ def _pooled_accept(ver: str) -> float | None:
"cost": [
round(sum(cost_by_ver.get(v, [])), 4) if cost_by_ver.get(v) else None for v in sorted_pipeline_versions
],
+ # pdf_notes (Statistik-Review 2026-07-15, Fix 2): {pdf_group_key: n_notes}
+ # je Version — Basis für den Corpus-Overlap-Guard in D.version_delta().
+ "pdf_notes": [quality_by_version[v].get("pdf_notes", {}) for v in sorted_pipeline_versions],
}
# Kosten pro akzeptierter Note je Version (#196 P2): nur für API-Runs mit
# Pricing aussagekräftig — subscription-Läufe kosten 0 (compute_cost_per_call)
@@ -891,14 +912,29 @@ def _vault_name() -> str:
def _chart_scatter_versioned(quality_rows: list[dict]) -> dict:
- """Scatter-Daten mit Version-Info fuer den Version-Filter."""
+ """Scatter-Daten mit Version-Info fuer den Version-Filter.
+
+ Re-Eval-Dedup (Nachbesserung adversariale Kontrolle #293): pro
+ pipeline_version nur die neueste Eval-Zeile je Note — dieselbe Basis wie
+ KPI-Kachel/per-PDF-Tabelle/kpi_trend (`_dedup_latest_per_note`). Vorher
+ zeigte der Scatter Eval-Instanzen (52 Punkte bei v0.3.140), während die
+ Kachel korrekt 40 distinct Notes auswies; re-evaluierte Notes erschienen
+ doppelt (z. B. "Asynchronous E-Learning" bei x=0,0 UND x=29,4) — die
+ "Instanzen vs. distinct"-Bugklasse (#194). Gruppierung JE Version, nicht
+ global: dieselbe Note in zwei Versionen bleibt zwei Punkte (der
+ Versions-Filter des Scatters vergleicht Versionen)."""
+ by_ver: dict[str, list[dict]] = {}
+ for r in quality_rows:
+ by_ver.setdefault(r.get("version") or r.get("pipeline_version") or "unbekannt", []).append(r)
+ deduped_rows = [r for rows in by_ver.values() for r in D._dedup_latest_per_note(rows)]
+
points: list[dict] = []
pdf_map: dict[str, str] = {}
versions: list[str] = []
import re as _re
- for r in quality_rows:
+ for r in deduped_rows:
hall = r.get("hallucination_rate")
cov = r.get("coverage_factual") or r.get("coverage_rate")
# Sentinel-Werte (-1.0 = ungültig) überspringen
diff --git a/generative/tests/test_dashboard_delta_pdf_overlap.py b/generative/tests/test_dashboard_delta_pdf_overlap.py
new file mode 100644
index 0000000..425c411
--- /dev/null
+++ b/generative/tests/test_dashboard_delta_pdf_overlap.py
@@ -0,0 +1,188 @@
+"""Tests für den Corpus-Overlap-Guard bei Versions-Deltas (Statistik-Review 2026-07-15).
+
+Befund (3 unabhängige Opus-Statistiker, konvergent + adversarial bestätigt):
+`version_delta()` markierte ein Delta als "reliable" allein ab n>=20 in beiden
+Versionen (`_DELTA_MIN_N`) — unabhängig davon, ob die beiden Versionen
+überhaupt denselben Corpus (PDF-Quellen) evaluieren. Produktionsbeleg
+v0.3.140 -> v0.3.143: beide n>=20 (40/22 distinct Notes), aber nur 3 von 9/5
+PDFs geteilt — von den 22 Notes der neueren Version stammen nur 8 (36 %) aus
+einer PDF, die auch in v0.3.140 vorkommt. Das +2,7pp-Hall-Delta ist damit
+größtenteils ein PDF-Mix-Artefakt, kein echter Versions-Effekt, wurde aber
+grün/rot als belastbar angezeigt.
+
+Fix: `reliable` zusätzlich an den Notes-Anteil der neueren Version gekoppelt,
+dessen PDF auch in der Vergleichsversion vorkommt (>= `_DELTA_MIN_PDF_OVERLAP`
+= 50 %). Unter der Schwelle bleibt das Delta sichtbar, aber `reliable=False`
+mit `reason="pdf_mix"` statt `"n_lt_20"` — der Client kann die beiden Fälle
+im Tooltip unterscheiden ("nicht vergleichbar (PDF-Mix)" statt "n<20").
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from generative.eval_dashboard import version_delta
+
+# Produktionsbeleg v0.3.140 -> v0.3.143 (dedupliziert, s. test_dashboard_reeval_dedup.py):
+# pdf_notes = {pdf_group_key: distinct-Notes-Zahl} je Version.
+_PROD_PDF_NOTES = [
+ {
+ "assfalg-2013": 2,
+ "ebner-und-gegenfurtner-2019": 7,
+ "hrastinski-2008": 6,
+ "knowles-from-pedagogy-to-andragogy": 9,
+ "mahmood-und-university-of-the-punjab-2016": 4,
+ "merrill-first-principles-of-instruction": 5,
+ "reimer-2013": 1,
+ "schlebbe-und-greifeneder-2022": 1,
+ "zettelkasten-primer": 5,
+ },
+ {
+ "bates-information-behavior": 4,
+ "hrastinski-2008": 2,
+ "s-hl-strohmenger-2008": 10,
+ "schlebbe-und-greifeneder-2022": 2,
+ "zettelkasten-primer": 4,
+ },
+]
+
+
+def _kpi_trend(**over):
+ base = {
+ "versions": ["v0.3.140", "v0.3.143"],
+ "hall": [9.46, 12.01],
+ "n": [40, 22],
+ "pdf_notes": [dict(d) for d in _PROD_PDF_NOTES],
+ }
+ base.update(over)
+ return base
+
+
+def test_production_delta_140_143_not_reliable_due_to_pdf_mix():
+ # n>=20 in beiden Versionen (40/22), aber nur 8/22 = 36 % Notes-Overlap
+ # -> unter der 50%-Schwelle, nicht belastbar trotz ausreichendem n.
+ d = version_delta(_kpi_trend(), "hall")
+ assert d["reliable"] is False
+ assert d["reason"] == "pdf_mix"
+ assert d["pdf_overlap"] == pytest.approx(8 / 22, abs=0.001)
+
+
+def test_full_overlap_stays_reliable():
+ trend = _kpi_trend(pdf_notes=[{"a": 20}, {"a": 20}]) # 100 % Overlap
+ d = version_delta(trend, "hall")
+ assert d["reliable"] is True
+ assert d["reason"] is None
+ assert d["pdf_overlap"] == 1.0
+
+
+def test_overlap_exactly_at_threshold_is_reliable():
+ trend = _kpi_trend(
+ pdf_notes=[
+ {"a": 10, "b": 10}, # prev
+ {"a": 10, "c": 10}, # latest: a geteilt (10), c nicht (10) -> 50 %
+ ]
+ )
+ d = version_delta(trend, "hall")
+ assert d["pdf_overlap"] == 0.5
+ assert d["reliable"] is True # genau an der Schwelle gilt als belastbar
+
+
+def test_disjoint_corpus_zero_overlap():
+ trend = _kpi_trend(pdf_notes=[{"a": 30}, {"b": 30}])
+ d = version_delta(trend, "hall")
+ assert d["pdf_overlap"] == 0.0
+ assert d["reliable"] is False
+ assert d["reason"] == "pdf_mix"
+
+
+def test_n_guard_reason_takes_priority_over_pdf_mix():
+ # latest zu klein (n<20) UND PDF-Mix -> reason meldet den n-Guard, nicht pdf_mix
+ trend = _kpi_trend(n=[40, 5], pdf_notes=[{"a": 30}, {"b": 5}])
+ d = version_delta(trend, "hall")
+ assert d["reliable"] is False
+ assert d["reason"] == "n_lt_20"
+
+
+def test_missing_pdf_notes_key_skips_overlap_guard_backward_compat():
+ # Ohne "pdf_notes" (ältere Aufrufer/Tests) bleibt das reine n>=20-Verhalten
+ # unverändert -- der Guard greift nur, wenn der Server die Daten liefert.
+ trend = {
+ "versions": ["v1", "v2"],
+ "hall": [12.0, 9.7],
+ "n": [25, 22],
+ }
+ d = version_delta(trend, "hall")
+ assert d["reliable"] is True
+ assert d["pdf_overlap"] is None
+ assert d["reason"] is None
+
+
+def test_overlap_guard_applies_to_fallback_prev_too():
+ # Kein früherer n>=20-Vergleichspunkt -> Fallback auf direkte Vorversion
+ # (bisheriges Verhalten, reliable bleibt False) -- pdf_overlap wird
+ # trotzdem gegen den Fallback-prev berechnet, nicht gecrasht.
+ trend = {
+ "versions": ["v1", "v2"],
+ "hall": [5.0, 9.7],
+ "n": [3, 25],
+ "pdf_notes": [{"a": 3}, {"a": 25}],
+ }
+ d = version_delta(trend, "hall")
+ assert d["reliable"] is False
+ assert d["reason"] == "n_lt_20"
+
+
+# ── Server-Integration: build_data() verdrahtet pdf_notes bis in kpi_trend ──
+
+
+def _eval(note, ver, pdf, hall, ts, total=10, hallucinated=0):
+ return {
+ "run_id": f"r-{note}",
+ "note_path": note,
+ "pipeline_version": ver,
+ "version": ver,
+ "hallucination_rate": hall,
+ "anchors_total": total,
+ "anchors_hallucinated": hallucinated,
+ "coverage_factual": 0.5,
+ "pdf": pdf,
+ "eval_version": "4.1",
+ "timestamp": ts,
+ }
+
+
+def test_build_data_flags_pdf_mix_delta_via_kpi_trend(monkeypatch):
+ """End-to-End: build_data() -> quality_by_version -> kpi_trend["pdf_notes"]
+ -> version_delta() erkennt einen weitgehend ausgetauschten Corpus, obwohl
+ n>=20 in beiden Versionen (Produktionsmuster v0.3.140 -> v0.3.143)."""
+ from generative import config as _cfg
+ from generative import db as _gdb
+ from generative import eval_dashboard as D
+ from generative import eval_dashboard_server as S
+
+ evals = []
+ # v0.3.140: 25 Notes, davon 20 auf "Shared.pdf", 5 auf "Only140.pdf".
+ for i in range(20):
+ evals.append(_eval(f"shared-{i}.md", "v0.3.140", "Shared.pdf", 0.0, f"2026-06-01T00:00:{i:02d}"))
+ for i in range(5):
+ evals.append(_eval(f"only140-{i}.md", "v0.3.140", "Only140.pdf", 0.0, f"2026-06-02T00:00:{i:02d}"))
+ # v0.3.143: 22 Notes, nur 3 teilen "Shared.pdf" mit v0.3.140 -> 3/22 = 13.6 % Overlap.
+ for i in range(3):
+ evals.append(_eval(f"shared2-{i}.md", "v0.3.143", "Shared.pdf", 0.5, f"2026-06-10T00:00:{i:02d}", 10, 5))
+ for i in range(19):
+ evals.append(_eval(f"only143-{i}.md", "v0.3.143", "Only143.pdf", 0.5, f"2026-06-11T00:00:{i:02d}", 10, 5))
+
+ monkeypatch.setattr(_cfg, "AGENT_VERSION", "v0.3.143")
+ monkeypatch.setattr(_gdb, "query_pipeline_runs", lambda *a, **k: [])
+ monkeypatch.setattr(_gdb, "query_note_evals", lambda *a, **k: evals)
+ monkeypatch.setattr(D, "_read_all_log_runs", lambda: [])
+ monkeypatch.setattr(D, "_read_token_runs", lambda: [])
+
+ data = S.build_data()
+ kt = data["kpi_trend"]
+ assert kt["versions"] == ["v0.3.140", "v0.3.143"]
+ assert kt["n"] == [25, 22] # n>=20 in beiden -> der alte Guard allein hätte reliable=True ergeben
+ vd = kt["deltas"]["hall"]
+ assert vd["reliable"] is False
+ assert vd["reason"] == "pdf_mix"
+ assert vd["pdf_overlap"] == pytest.approx(3 / 22, abs=0.001)
diff --git a/generative/tests/test_dashboard_reeval_dedup.py b/generative/tests/test_dashboard_reeval_dedup.py
new file mode 100644
index 0000000..80af791
--- /dev/null
+++ b/generative/tests/test_dashboard_reeval_dedup.py
@@ -0,0 +1,255 @@
+"""Tests für Re-Eval-Dedup in den KPI-/Trend-Aggregationen (Statistik-Review 2026-07-15).
+
+Befund (3 unabhängige Opus-Statistiker, konvergent + adversarial bestätigt):
+note_evals enthält mehrere Eval-Zeilen derselben Note innerhalb einer
+pipeline_version (Re-Evals + identische Duplikat-Inserts; Produktionsbeleg
+v0.3.140 = 52 Zeilen / 40 distinct Notes, 12 Duplikate; v0.3.143 = 8 Duplikate).
+Ungefiltert poolt jede KPI-/Trend-Aggregation (_calc_kpis, _calc_pdf_table,
+kpi_trend im Server) Anker mehrfach-evaluierter Notes mehrfach — Pseudo-
+replikation, ~2pp Bias nach unten auf der Fehlerquote (Produktionskopie:
+gepoolte Hall-Rate v0.3.140 7,53 % -> 9,46 % nach Dedup, weil oft
+re-evaluierte Notes tendenziell gute Raten haben). Zusätzlich: die Kachel
+zeigte n=40 (distinct Notes), die Pooling-Basis war aber 52 Zeilen —
+inkonsistent.
+
+Fix: pro (pipeline_version, note) NUR die neueste Eval-Zeile (max timestamp,
+Tie-Break eval_id) in jede Aggregation. Note-Identität normalisiert über
+`_note_key` (Namespace-Prefix `vault__`/`inbox__`/`merge__` gestrippt) — Notes
+können zwischen zwei Re-Evals den Namespace wechseln (Routing-Änderung).
+"""
+
+from __future__ import annotations
+
+from generative.eval_dashboard import (
+ _build_log_data,
+ _calc_kpis,
+ _calc_pdf_table,
+ _dedup_latest_per_note,
+ _note_key,
+ _pooled_hall_pct,
+)
+
+
+def _qrow(note, ver, hall, total, hallucinated, ts, eval_id=None, cov=0.5):
+ return {
+ "note_path": note,
+ "version": ver,
+ "hallucination_rate": hall,
+ "anchors_total": total,
+ "anchors_hallucinated": hallucinated,
+ "coverage_factual": cov,
+ "timestamp": ts,
+ "eval_id": eval_id or f"{ts}__{note}",
+ }
+
+
+# ── _note_key: Namespace-Prefix-Drift ───────────────────────────────────────
+
+
+def test_note_key_strips_vault_prefix():
+ assert _note_key({"note_path": "vault__Zettelkasten.md"}, 0) == "Zettelkasten.md"
+
+
+def test_note_key_strips_inbox_prefix():
+ assert _note_key({"note_path": "inbox__Foo.md"}, 0) == "Foo.md"
+
+
+def test_note_key_unifies_prefix_drift_between_reevals():
+ # Dieselbe Note kann zwischen zwei Re-Evals den Namespace wechseln
+ # (Routing-Änderung) — ohne Normalisierung zählten vault__X und X als
+ # zwei Identitäten.
+ a = _note_key({"note_path": "vault__Zettelkasten.md"}, 0)
+ b = _note_key({"note_path": "Zettelkasten.md"}, 1)
+ assert a == b
+
+
+def test_note_key_falls_back_to_row_index_without_identifier():
+ assert _note_key({}, 3) == "__row3"
+ assert _note_key({}, 3) != _note_key({}, 4)
+
+
+# ── _dedup_latest_per_note ───────────────────────────────────────────────────
+
+
+def test_dedup_keeps_only_latest_row_per_note():
+ rows = [
+ _qrow("a.md", "v1", 0.0, 17, 0, "2026-06-21T19:50:12"),
+ _qrow("a.md", "v1", 0.0, 17, 0, "2026-06-21T21:20:53"),
+ _qrow("a.md", "v1", 0.294, 17, 5, "2026-07-05T19:59:18"), # neueste
+ ]
+ out = _dedup_latest_per_note(rows)
+ assert len(out) == 1
+ assert out[0]["hallucination_rate"] == 0.294
+
+
+def test_dedup_normalizes_namespace_prefix_drift():
+ rows = [
+ _qrow("vault__X.md", "v1", 0.0, 10, 0, "2026-06-01T00:00:00"),
+ _qrow("X.md", "v1", 0.5, 10, 5, "2026-06-02T00:00:00"), # gleiche Note, Prefix weg
+ ]
+ out = _dedup_latest_per_note(rows)
+ assert len(out) == 1
+ assert out[0]["hallucination_rate"] == 0.5
+
+
+def test_dedup_tie_breaks_deterministically_on_eval_id():
+ rows = [
+ _qrow("a.md", "v1", 0.0, 10, 0, "2026-06-01T00:00:00", eval_id="20260601-000000__a.md"),
+ _qrow("a.md", "v1", 0.5, 10, 5, "2026-06-01T00:00:00", eval_id="20260601-000001__a.md"),
+ ]
+ out = _dedup_latest_per_note(rows)
+ assert len(out) == 1
+ assert out[0]["hallucination_rate"] == 0.5 # größerer eval_id gewinnt bei Timestamp-Gleichstand
+
+
+def test_dedup_rows_without_identifier_all_kept():
+ rows = [{"hallucination_rate": 0.0}, {"hallucination_rate": 0.5}]
+ out = _dedup_latest_per_note(rows)
+ assert len(out) == 2 # synthetische Rows zählen einzeln (Fallback Zeilenindex)
+
+
+def test_dedup_leaves_distinct_notes_untouched():
+ rows = [_qrow("a.md", "v1", 0.0, 10, 0, "t1"), _qrow("b.md", "v1", 0.5, 10, 5, "t2")]
+ out = _dedup_latest_per_note(rows)
+ assert len(out) == 2
+
+
+def test_dedup_empty_is_empty():
+ assert _dedup_latest_per_note([]) == []
+
+
+# ── Integration: gepoolte Rate steigt nach Dedup (Produktionsmuster) ────────
+
+
+def test_pooled_hall_increases_after_dedup_when_duplicates_are_clean():
+ # 3 identische "gute" Duplikat-Zeilen + 1 "schlechte" neueste Zeile für
+ # dieselbe Note (Produktionsmuster "Asynchronous E-Learning.md",
+ # v0.3.140): ungefiltert drückt die 3x wiederholte 0%-Rate die Poolung
+ # nach unten.
+ rows = [
+ _qrow("dup.md", "v1", 0.0, 17, 0, "2026-06-21T19:50:12"),
+ _qrow("dup.md", "v1", 0.0, 17, 0, "2026-06-21T21:20:53"),
+ _qrow("dup.md", "v1", 0.0, 17, 0, "2026-06-21T21:30:36"),
+ _qrow("dup.md", "v1", 0.294, 17, 5, "2026-07-05T19:59:18"),
+ _qrow("clean.md", "v1", 0.0, 20, 0, "2026-06-25T00:00:00"),
+ ]
+ raw_pct = _pooled_hall_pct(rows) # ungefiltert: 5 / (17*4 + 20) = 5,6 %
+ deduped_pct = _pooled_hall_pct(_dedup_latest_per_note(rows)) # dedup: 5 / (17 + 20) = 13,5 %
+ assert raw_pct < deduped_pct
+ assert deduped_pct == 13.5
+
+
+# ── _calc_kpis nutzt die deduplizierte Basis ────────────────────────────────
+
+
+def test_calc_kpis_hall_uses_deduped_basis():
+ rows = [
+ _qrow("dup.md", "v1", 0.0, 10, 0, "2026-06-21T00:00:00"),
+ _qrow("dup.md", "v1", 1.0, 10, 10, "2026-06-22T00:00:00"), # neueste gewinnt
+ _qrow("solo.md", "v1", 0.0, 10, 0, "2026-06-21T00:00:00"),
+ ]
+ kpis = _calc_kpis({}, [], rows, [], current_version="v1")
+ # dedupliziert: dup.md zählt nur mit der neuesten Zeile (10 Anker, 10
+ # halluziniert) + solo.md (10 Anker, 0 halluziniert) -> 10/20 = 50 %
+ assert kpis["avg_hall"] == 50.0
+ assert kpis["n_notes"] == 2
+ # Kachel-n muss zur Pooling-Basis passen (Produktionsbefund: Kachel n=40,
+ # Pooling-Basis 52 Zeilen — inkonsistent vor dem Fix).
+ assert kpis["hall_rows_n"] == 2
+ assert kpis["hall_anchors_total"] == 20
+
+
+def test_calc_kpis_coverage_uses_deduped_basis():
+ rows = [
+ _qrow("dup.md", "v1", 0.0, 10, 0, "2026-06-21T00:00:00", cov=0.2),
+ _qrow("dup.md", "v1", 0.0, 10, 0, "2026-06-22T00:00:00", cov=0.9), # neueste gewinnt
+ ]
+ kpis = _calc_kpis({}, [], rows, [], current_version="v1")
+ assert kpis["avg_cov"] == 90.0 # nicht Median(20, 90) = 55.0 — nur die neueste Zeile zählt
+
+
+# ── _calc_pdf_table nutzt dieselbe Basis (SSoT mit der KPI-Kachel) ─────────
+
+
+def test_calc_pdf_table_hall_uses_deduped_basis():
+ quality_rows = [
+ {
+ "pdf": "Quelle - 2020 - X.pdf",
+ "note_path": "dup.md",
+ "pipeline_version": "v1",
+ "hallucination_rate": 0.0,
+ "anchors_total": 10,
+ "anchors_hallucinated": 0,
+ "coverage_factual": 0.5,
+ "timestamp": "2026-06-21T00:00:00",
+ },
+ {
+ "pdf": "Quelle - 2020 - X.pdf",
+ "note_path": "dup.md",
+ "pipeline_version": "v1",
+ "hallucination_rate": 1.0,
+ "anchors_total": 10,
+ "anchors_hallucinated": 10,
+ "coverage_factual": 0.5,
+ "timestamp": "2026-06-22T00:00:00",
+ },
+ ]
+ all_log_runs = [
+ {
+ "key": "quelle-2020",
+ "label": "Quelle",
+ "ver": "v1",
+ "n_total": 1,
+ "n_vault": 1,
+ "accept_pct": 100.0,
+ "words": None,
+ }
+ ]
+ log_data = _build_log_data(all_log_runs)
+ rows = _calc_pdf_table(log_data, all_log_runs, quality_rows, current_version="v1")
+ row = next(r for r in rows if r["key"].startswith("quelle"))
+ assert row["hall"] == 100.0 # nur neueste Zeile (10/10), nicht gepoolt über beide (5/20=25%)
+ assert row["n_notes"] == 1
+
+
+# ── Scatter nutzt dieselbe Basis (Nachbesserung adversariale Kontrolle #293) ─
+# Befund: _chart_scatter_versioned deduplizierte NICHT — der Scatter zeigte 52
+# Punkte (v0.3.140), während die KPI-Kachel nach Fix 1 korrekt "40 evaluierte
+# Notes" sagte; re-evaluierte Notes erschienen doppelt (z. B. "Asynchronous
+# E-Learning" bei x=0,0 UND x=29,4). Dieselbe "Instanzen vs. distinct"-
+# Bugklasse (#194), die dieser PR schließt.
+
+
+def test_scatter_shows_distinct_notes_per_version():
+ from generative.eval_dashboard_server import _chart_scatter_versioned
+
+ rows = [
+ # dup.md: 3 Re-Evals in v1 -> nur der neueste Punkt (x=29.4) darf erscheinen
+ _qrow("dup.md", "v1", 0.0, 17, 0, "2026-06-21T19:50:12"),
+ _qrow("dup.md", "v1", 0.0, 17, 0, "2026-06-21T21:20:53"),
+ _qrow("dup.md", "v1", 0.294, 17, 5, "2026-07-05T19:59:18"),
+ _qrow("solo.md", "v1", 0.1, 10, 1, "2026-06-25T00:00:00"),
+ ]
+ for r in rows:
+ r["pdf"] = "Quelle - 2020 - X.pdf"
+ out = _chart_scatter_versioned(rows)
+ assert len(out["points"]) == 2, f"Scatter zeigt Eval-Instanzen statt distinct Notes: {len(out['points'])} Punkte"
+ dup_points = [p for p in out["points"] if p["label"] == "dup"]
+ assert len(dup_points) == 1
+ assert dup_points[0]["x"] == 29.4 # neueste Eval-Zeile gewinnt, nicht die alte 0,0
+
+
+def test_scatter_dedup_is_per_version_not_global():
+ from generative.eval_dashboard_server import _chart_scatter_versioned
+
+ # Dieselbe Note in ZWEI Versionen bleibt zwei Punkte (der Versions-Filter
+ # des Scatters vergleicht Versionen) — Dedup nur INNERHALB einer Version.
+ rows = [
+ _qrow("a.md", "v1", 0.1, 10, 1, "2026-06-01T00:00:00"),
+ _qrow("a.md", "v2", 0.2, 10, 2, "2026-06-02T00:00:00"),
+ ]
+ for r in rows:
+ r["pdf"] = "Quelle - 2020 - X.pdf"
+ out = _chart_scatter_versioned(rows)
+ assert len(out["points"]) == 2
+ assert sorted(p["version"] for p in out["points"]) == ["v1", "v2"]
diff --git a/internal/dashboard/eval_dashboard.html b/internal/dashboard/eval_dashboard.html
index 43cbdab..5c08a58 100644
--- a/internal/dashboard/eval_dashboard.html
+++ b/internal/dashboard/eval_dashboard.html
@@ -1417,7 +1417,13 @@
// starr die direkte Vorversion) — im Tooltip transparent benennen, WOGEGEN
// verglichen wird, inkl. deren n.
const prevRef = vd.prev_version ? `Version ${vd.prev_version}${vd.prev_n != null ? ` (n=${vd.prev_n})` : ''}` : 'der vorherigen Pipeline-Version';
- const chipHint = `Veränderung gegenüber ${prevRef}, serverseitig berechnet.${vd.reliable ? '' : ' Erst ab n≥20 in beiden Versionen gilt das Delta als belastbar — hier nicht der Fall.'}`;
+ // Statistik-Review 2026-07-15: n≥20 allein härtet nicht gegen PDF-Mix
+ // (Corpus faktisch ausgetauscht) — zwei unterschiedliche Gründe für
+ // "nicht belastbar" brauchen zwei unterschiedliche Erklärungen.
+ const unreliableMsg = vd.reason === 'pdf_mix'
+ ? ` Nicht vergleichbar: die Quell-PDFs beider Versionen überschneiden sich kaum${vd.pdf_overlap != null ? ` (nur ${_fmtDE(vd.pdf_overlap * 100, 0)} % der Notes teilen eine PDF-Quelle mit ${prevRef})` : ''} — das Delta spiegelt eher einen Corpus-Wechsel als einen echten Versions-Effekt.`
+ : ' Erst ab n≥20 in beiden Versionen gilt das Delta als belastbar — hier nicht der Fall.';
+ const chipHint = `Veränderung gegenüber ${prevRef}, serverseitig berechnet.${vd.reliable ? '' : unreliableMsg}`;
chip = `${arrow} ${_fmtDE(Math.abs(dnum))}${chipHint}`;
}
// #219-Nachbesserung Punkt 1: kein tabindex auf den 4 Hint-Triggern hier —
@@ -1475,8 +1481,13 @@
const lowerIsBetter = ['hall','dur','tokens','cost'].includes(kd.key);
const dClass = (dnum!=null && vd.reliable && dnum!==0) ? ((dnum>0) === lowerIsBetter ? 'dneg' : 'dpos') : ''; // 0.0 = neutral, keine Farbe
const sign = dnum!=null && dnum>0 ? '+' : '';
+ // Statistik-Review 2026-07-15: "(n<20)" war die einzige Nicht-belastbar-
+ // Erklärung — ein PDF-Mix-Delta (n≥20, aber Corpus ausgetauscht) sah
+ // damit faelschlich wie ein Stichprobenproblem statt ein Vergleichbarkeits-
+ // problem aus.
+ const caveatSuffix = (vd && !vd.reliable) ? (vd.reason === 'pdf_mix' ? ' (PDF-Mix)' : ' (n<20)') : '';
const deltaHtml = dnum!=null
- ? `Δ vs. ${prevVer}: ${sign}${_fmtDE(dnum)} ${kd.u}${vd.reliable ? '' : ' (n<20)'}`
+ ? `Δ vs. ${prevVer}: ${sign}${_fmtDE(dnum)} ${kd.u}${caveatSuffix}`
: `${vers.length} Pipeline-Versionen`;
const inner = document.getElementById('spark-inner');
if (inner) {
@@ -2245,8 +2256,13 @@
if (vd && vd.prev != null) {
const prevVer = esc(vd.prev_version ?? vers[vers.length - 2] ?? '–');
const nPrev = vd.prev_n ?? (kt.n || [])[(kt.n || []).length - 2];
+ // Statistik-Review 2026-07-15: "n<20" war die einzige Caveat-Erklärung —
+ // ein PDF-Mix-Delta (n≥20, aber Corpus ausgetauscht) braucht eine eigene.
+ const caveatTxt = vd.reason === 'pdf_mix'
+ ? ` (PDF-Mix — nicht vergleichbar)`
+ : ` (n<20 — nicht belastbar)`;
deltaTxt = ` vs. ${prevVer}: ${_fmtDE(vd.prev)} % (n=${nPrev})` +
- (vd.reliable ? '' : ` (n<20 — nicht belastbar)`);
+ (vd.reliable ? '' : caveatTxt);
}
_setInsight('ins-overview',
`Pipeline ${esc(k.kpi_version||'–')}: Fehlerquote ${_fmtDE(k.avg_hall)} %, ` +