diff --git a/generative/orchestrator.py b/generative/orchestrator.py index b5a174c..2b2dc12 100644 --- a/generative/orchestrator.py +++ b/generative/orchestrator.py @@ -1350,6 +1350,33 @@ def _absorb_alias(name: str) -> None: return kept, len(drop_idx) +def count_actual_merges(drafts: list[AtomicNoteDraft], n_sibling_dedup: int) -> int: + """#283: n_merge aus tatsächlichen Merge-AUSGÄNGEN statt Planner-Intent. + + Bug: `pipeline_runs.n_merge` zählte bisher `sum(1 for d in drafts if + d.action == "extend")` — die Planner-VORAB-Klassifikation, nicht das + tatsächliche Verify-/Dedup-Ergebnis. Ein als "extend" geplantes Konzept + kann am Ende trotzdem als eigene Note landen (dangling Intra-Run-extend, + resolve_sibling_dups setzt es auf "create" zurück, oder das extend_path-Ziel + löst beim Schreiben gar nicht auf), während ein als "create" geplantes + Konzept durch cross_reference/den Vault-Title-Match erst beim Schreiben zum + Merge wird — Planung und Ausgang laufen zustandsabhängig auseinander + (Belege: Lauf 2 hatte 1 Merge-Stub im Log, DB n_merge=0; Lauf 5 hatte 2 + Sibling-Dedup-Merges bei 0 geplanten extends, DB n_merge=0; Lauf 3s + scheinbar korrektes n_merge=2 war Zufall). + + Reale Merges kommen aus zwei disjunkten Quellen (Fix-Richtung #283): + - Sibling-Dedup-Absorption (resolve_sibling_dups, VOR dem Schreiben) — + absorbierte Drafts sind zum Zeitpunkt dieses Aufrufs schon aus `drafts` + entfernt, deshalb als Zähler übergeben statt erneut zählbar. + - Merge-Stub-Erzeugung beim Schreiben (vault_writer.write_note setzt + draft.is_merge_stub, wenn der Draft als Diff-Stub zu einer bestehenden + Vault-Note statt als eigene Note geschrieben wurde) — ausgewertet auf + den POST-Write-Drafts, kein doppeltes Zählen derselben Note möglich. + """ + return n_sibling_dedup + sum(1 for d in drafts if d.is_merge_stub) + + def flag_redundant_siblings( drafts: list[AtomicNoteDraft], threshold: float | None = None, @@ -2902,7 +2929,9 @@ def main(argv: list[str] | None = None): "n_extracted": n_extracted, "n_vault": vault_count, "n_inbox": inbox_count, - "n_merge": sum(1 for d in drafts if getattr(d, "action", "") == "extend"), + # #283: realer Merge-Ausgang (Sibling-Dedup-Absorption + Merge-Stub- + # Erzeugung beim Schreiben) statt Planner-Intent (action == "extend"). + "n_merge": count_actual_merges(drafts, n_sib), "n_dropped": ctx.dropped_total, "n_words": ctx.word_count, "model": getattr(_db_cfg, "MODEL_PLANNER", ""), diff --git a/generative/pipeline/vault_writer.py b/generative/pipeline/vault_writer.py index 378e43e..40740d3 100644 --- a/generative/pipeline/vault_writer.py +++ b/generative/pipeline/vault_writer.py @@ -1065,6 +1065,11 @@ def write_note( note.tag_review_status = kept_status or "needs-review" content = render_note(note, source_file, citation=citation) + # #283: realer Schreib-Ausgang für n_merge (statt Planner-Intent) — Draft-Snapshot, + # analog zu auto_vault_recommended oben. Gilt in dry_run wie live identisch, weil + # is_merge_stub schon vor der dry_run-Verzweigung feststeht (kein Schreibpfad-Unterschied). + note.is_merge_stub = is_merge_stub + if dry_run: if is_merge_stub: marker = f"[Merge-Stub -> {existing_vault.relative_to(VAULT)}]" diff --git a/generative/schemas/atomic_note.py b/generative/schemas/atomic_note.py index cbf7364..ecd1526 100644 --- a/generative/schemas/atomic_note.py +++ b/generative/schemas/atomic_note.py @@ -100,6 +100,12 @@ class AtomicNoteDraft: # nicht zuverlässig aufgelöst werden konnte (Enrichment # leer ODER CrossRef-Override fail-closed verworfen). # Schmales Frontmatter-Flag, kein Erklär-Absatz. + # ownership: writer=vault_writer reader=orchestrator + is_merge_stub: bool = False # #283: True wenn write_note() diesen Draft als Diff-Stub zu + # einer bestehenden Vault-Note geschrieben hat (statt als eigene Note) — realer + # Schreib-Ausgang. SSoT für orchestrator.count_actual_merges (n_merge), statt der + # vorherigen Planner-Intent-Zählung (action == "extend"), die zustandsabhängig + # falsch lag (Konzept war anders geplant als es tatsächlich endete). # ownership (Klassen-Block, #99): alle 8 Felder werden ausschließlich von diff --git a/generative/tests/test_n_merge_actual_outcome.py b/generative/tests/test_n_merge_actual_outcome.py new file mode 100644 index 0000000..940cbc1 --- /dev/null +++ b/generative/tests/test_n_merge_actual_outcome.py @@ -0,0 +1,75 @@ +"""Tests für #283: n_merge muss den tatsächlichen Merge-AUSGANG zählen +(Merge-Stub-Erzeugung + Sibling-Dedup-Absorption), nicht die Planner-VORAB- +Klassifikation (`action == "extend"`). + +Belegter Bug (Testlauf-Serie 2026-07-14, Issue #283): Lauf 2 hatte 1 echten +Merge-Stub im Log, DB `n_merge=0` (Draft war als "create" geplant, wurde aber +erst beim Schreiben zum Merge-Stub). Lauf 5 hatte 2 echte Sibling-Dedup-Merges +bei 0 geplanten "extend"-Konzepten, DB `n_merge=0`. Lauf 4s `n_merge=2` war +zufällig korrekt (geplante extends == spätere Merge-Ziele). + +`count_actual_merges(drafts, n_sibling_dedup)` ersetzt die alte Intent-Zählung: +- Sibling-Dedup-Absorption kommt als Parameter rein (die absorbierten Drafts + sind zum Zeitpunkt des Aufrufs schon aus `drafts` entfernt — resolve_sibling_dups + gibt ihre Anzahl bereits zurück, siehe test_sibling_dedup.py). +- Merge-Stub-Erzeugung wird auf den POST-Write-Drafts über `is_merge_stub` + ausgewertet (vault_writer.write_note setzt dieses Feld beim Schreiben, + siehe test_typeaware_dedup.py). +""" + +from __future__ import annotations + +from generative.orchestrator import count_actual_merges +from generative.schemas.atomic_note import AtomicNoteDraft + + +def _draft(title, action="create", is_merge_stub=False) -> AtomicNoteDraft: + d = AtomicNoteDraft( + title=title, + body="Body.", + source_anchors=[], + related=[], + tags=[], + synthesis_confidence="high", + action=action, + ) + d.is_merge_stub = is_merge_stub + return d + + +def test_no_merges_is_zero(): + drafts = [_draft("A"), _draft("B")] + assert count_actual_merges(drafts, n_sibling_dedup=0) == 0 + + +def test_counts_write_time_merge_stub(): + # Draft war als "create" geplant, wurde aber beim Schreiben zum Merge-Stub + # (Title-/Alias-Match im Vault entdeckt) — der alte Bug hätte das NICHT gezählt. + drafts = [_draft("A", action="create", is_merge_stub=True), _draft("B", action="create")] + assert count_actual_merges(drafts, n_sibling_dedup=0) == 1 + + +def test_counts_sibling_dedup_absorption_via_parameter(): + # 2 echte Sibling-Dedup-Merges, 0 geplante extends (Lauf-5-Repro) — die + # absorbierten Drafts sind hier schon aus `drafts` raus, n_sibling_dedup trägt sie. + drafts = [_draft("Survivor", action="create")] + assert count_actual_merges(drafts, n_sibling_dedup=2) == 2 + + +def test_planned_extend_without_actual_merge_not_counted(): + # Planner-Intent "extend", aber resolve_sibling_dups hat es auf "create" + # zurückgesetzt (dangling Intra-Run-extend) UND write_note fand keinen + # Vault-Treffer -> kein echter Merge. Alte Zählung (action=="extend") hätte + # hier faelschlich 1 gezaehlt. + drafts = [_draft("A", action="extend", is_merge_stub=False)] + assert count_actual_merges(drafts, n_sibling_dedup=0) == 0 + + +def test_sums_both_sources_without_double_counting(): + drafts = [ + _draft("A", action="create", is_merge_stub=True), + _draft("B", action="extend", is_merge_stub=True), + _draft("C", action="extend", is_merge_stub=False), + _draft("D", action="create", is_merge_stub=False), + ] + assert count_actual_merges(drafts, n_sibling_dedup=3) == 2 + 3 diff --git a/generative/tests/test_typeaware_dedup.py b/generative/tests/test_typeaware_dedup.py index 51ceaa4..ffa6e0f 100644 --- a/generative/tests/test_typeaware_dedup.py +++ b/generative/tests/test_typeaware_dedup.py @@ -133,6 +133,7 @@ def test_write_note_honors_extend_path_to_concept(tmp_path, monkeypatch): target = vw.write_note(d, source_file="X.pdf", dry_run=False, existing_concepts=ec, inbox_dir=inbox) assert "MERGE" in target.name # Merge-Stub statt create-Dublette assert target.exists() + assert d.is_merge_stub is True # #283: realer Schreib-Ausgang, SSoT für n_merge def test_write_note_extend_path_to_literature_not_merged(tmp_path, monkeypatch): @@ -148,3 +149,4 @@ def test_write_note_extend_path_to_literature_not_merged(tmp_path, monkeypatch): target = vw.write_note(d, source_file="X.pdf", dry_run=False, existing_concepts=ec, inbox_dir=inbox) assert "MERGE" not in target.name # keine Lit-Note als Merge-Ziel → normale create-Note assert target.exists() + assert d.is_merge_stub is False # #283: planned "extend", aber kein realer Merge