Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion generative/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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", ""),
Expand Down
5 changes: 5 additions & 0 deletions generative/pipeline/vault_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}]"
Expand Down
6 changes: 6 additions & 0 deletions generative/schemas/atomic_note.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions generative/tests/test_n_merge_actual_outcome.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions generative/tests/test_typeaware_dedup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Loading