The asymmetry that makes unattended ingest safe (#103) also discarded a real signal. When a fresh extraction produced a different version of a section already on disk, the on-disk version won and the model's version went in the bin without a word — so "the source is unchanged" and "the source changed its mind and we ignored it" were indistinguishable. wiki.MergeReport now returns the headings where the incoming version disagreed and lost, and the ingest run reports them as warnings, which AppendLog already writes into log.md. This is the simplified answer to contradiction flagging at ingest. The issue proposed calling brain-gardener's Judge from the ingest path, which needs either a shared Go module across two repos or an HTTP service that does not exist — and puts an LLM call on the write path, where a timeout must neither fail the ingest nor silently mark nothing as contested. None of that is necessary to get the signal. The merge already computes the disagreement; it was throwing it away. Surfacing it costs nothing, needs no model, no cue-word matcher, no cross-repo dependency, and is deterministic. Deliberately textual disagreement, not semantic contradiction. Deciding whether a disagreement is a genuine contradiction stays with the Judge-gated enrich pass that already runs hourly and is approval-gated by design (brain-gardener#175). Detection here, adjudication there. Merge is kept as a thin wrapper over MergeReport so existing callers are untouched. Refs #104, #103, brain-gardener#175, brain#21.
270 lines
10 KiB
Go
270 lines
10 KiB
Go
// ingestion/internal/wiki/merge_test.go
|
|
package wiki
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestMerge_BulletSectionsUnion(t *testing.T) {
|
|
a := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
|
|
b := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n- [[baz|Baz]]\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "[[bar|Bar]]")
|
|
assert.Contains(t, got.Content, "[[baz|Baz]]")
|
|
assert.Equal(t, 1, strings.Count(got.Content, "[[bar|Bar]]"))
|
|
}
|
|
|
|
func TestMerge_AppendSections(t *testing.T) {
|
|
a := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Evolving Notes\n\nFirst note.\n"}
|
|
b := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Evolving Notes\n\nSecond note.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "First note.")
|
|
assert.Contains(t, got.Content, "Second note.")
|
|
}
|
|
|
|
func TestMerge_KeepFirstForOtherSections(t *testing.T) {
|
|
a := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Definition\n\nFirst definition.\n"}
|
|
b := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Definition\n\nSecond definition.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "First definition.")
|
|
assert.NotContains(t, got.Content, "Second definition.")
|
|
}
|
|
|
|
func TestMerge_NewSectionFromB(t *testing.T) {
|
|
a := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Definition\n\nA thing.\n"}
|
|
b := Page{Path: "wiki/concepts/foo.md", Content: "---\ntitle: Foo\n---\n\n## Why It Matters\n\nBecause reasons.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "A thing.")
|
|
assert.Contains(t, got.Content, "Because reasons.")
|
|
}
|
|
|
|
func TestMerge_KeepsFrontmatterFromA(t *testing.T) {
|
|
a := Page{Path: "p.md", Content: "---\ntitle: A\nlast_updated: 2026-01-01\n---\n\n## Definition\n\nA.\n"}
|
|
b := Page{Path: "p.md", Content: "---\ntitle: B\nlast_updated: 2026-06-01\n---\n\n## Definition\n\nB.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "title: A")
|
|
assert.NotContains(t, got.Content, "title: B")
|
|
}
|
|
|
|
// --- hyperguild#103 review: a page with content outside any `## ` section ---
|
|
// must never lose that content on merge. parseSections used to only capture
|
|
// text between `## ` headings — anything before the first heading (a common,
|
|
// ordinary shape for a hand-written note: an intro paragraph before the first
|
|
// section) or, worse, an entire page with no `## ` headings at all (a legacy
|
|
// note, a stub, any hand-edit that doesn't follow the section convention)
|
|
// vanished silently. Merge would then fill the page entirely from the fresh
|
|
// extraction — the opposite of "never silently rewrite what a human put
|
|
// there," the guarantee this whole file exists to provide.
|
|
|
|
func TestMerge_PreservesOnDiskContentWithNoSectionHeadings(t *testing.T) {
|
|
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nJust some free text a human wrote. No section headings at all.\n"}
|
|
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nFresh extraction's take.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "Just some free text a human wrote",
|
|
"on-disk content with no ## headings must survive the merge, not be replaced wholesale by the fresh extraction")
|
|
}
|
|
|
|
func TestMerge_DegenerateEmptyHeadingDoesNotCollideWithPreamble(t *testing.T) {
|
|
// Second review pass on #103: representing the preamble as a section with
|
|
// heading=="" collides with a literal `## ` (marker, no title text) —
|
|
// strings.TrimPrefix("## ", "## ") is also "". `a` has only a preamble
|
|
// (no degenerate heading of its own); `b` introduces a genuinely new
|
|
// degenerate `## ` section. Because both key to "", Merge's idx thinks
|
|
// b's section "already exists" in a (it's really just a's unrelated
|
|
// preamble) and never adds it — b's section vanishes from the output
|
|
// entirely, not merged, not kept, not appended.
|
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph.\n\n## Definition\n\nA thing.\n"}
|
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## \n\nA genuinely new degenerate section's body.\n\n## Definition\n\nA thing.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "An intro paragraph.", "a's own preamble must survive")
|
|
assert.Contains(t, got.Content, "A genuinely new degenerate section's body.",
|
|
"b's degenerate section is new content, not a collision with a's unrelated preamble — it must not be silently dropped")
|
|
}
|
|
|
|
func TestMerge_PreservesPreambleBeforeFirstHeading(t *testing.T) {
|
|
a := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\nAn intro paragraph before the first heading.\n\n## Definition\n\nA thing.\n"}
|
|
b := Page{Path: "wiki/x.md", Content: "---\ntitle: X\n---\n\n## Definition\n\nA thing.\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Contains(t, got.Content, "An intro paragraph before the first heading",
|
|
"text before the first ## heading is ordinary page content, not something the parser is entitled to drop")
|
|
}
|
|
|
|
// --- hyperguild#103 review: dedup must be whitespace-insensitive -----------
|
|
// Page content is produced by an LLM per run, so byte-identical re-extraction
|
|
// of "the same" logical line is not guaranteed — different bullet spacing or
|
|
// trailing whitespace on an otherwise-identical line must still dedup, or
|
|
// idempotency only holds when the extractor happens to reproduce whitespace
|
|
// exactly, which is not a real guarantee.
|
|
|
|
func TestMerge_AppendSectionDedupsAcrossWhitespaceVariants(t *testing.T) {
|
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- Does this converge?\n"}
|
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Open Questions\n\n- Does this converge? \n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Equal(t, 1, strings.Count(got.Content, "Does this converge?"),
|
|
"same logical line with different whitespace must dedup, not duplicate")
|
|
}
|
|
|
|
func TestMerge_BulletSectionDedupsAcrossWhitespaceVariants(t *testing.T) {
|
|
a := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
|
|
b := Page{Path: "p.md", Content: "---\ntitle: X\n---\n\n## Related Concepts\n\n- [[bar|Bar]]\n"}
|
|
|
|
got := Merge(a, b)
|
|
assert.Equal(t, 1, strings.Count(got.Content, "[[bar|Bar]]"),
|
|
"same logical bullet with different whitespace must dedup, not duplicate")
|
|
}
|
|
|
|
// --- brain#21/B1: merging must be safe to run again -------------------------
|
|
|
|
func TestMerge_AppendSectionDoesNotDuplicateContentAlreadyPresent(t *testing.T) {
|
|
// Compile-on-ingest merges a freshly extracted page into the one already
|
|
// on disk. Bullet sections union, so they are naturally idempotent — but
|
|
// append sections concatenate, so re-ingesting an unchanged source would
|
|
// grow "Open Questions" on every run until the page is unreadable.
|
|
page := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: X
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
- Does this converge?
|
|
`}
|
|
|
|
once := Merge(page, page)
|
|
twice := Merge(once, page)
|
|
|
|
assert.Equal(t, 1, strings.Count(once.Content, "Does this converge?"),
|
|
"merging a page with itself must not duplicate an append section")
|
|
assert.Equal(t, once.Content, twice.Content,
|
|
"merge must be idempotent — a third pass may not keep growing the page")
|
|
}
|
|
|
|
func TestMerge_AppendSectionStillAccumulatesGenuinelyNewContent(t *testing.T) {
|
|
// The dedup must not turn append sections into keep-a-only sections:
|
|
// new material is exactly what compile-on-ingest exists to add.
|
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: X
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
- Does this converge?
|
|
`}
|
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: X
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
- What happens under load?
|
|
`}
|
|
|
|
got := Merge(existing, fresh).Content
|
|
assert.Contains(t, got, "Does this converge?", "existing question must survive")
|
|
assert.Contains(t, got, "What happens under load?", "new question must be added")
|
|
}
|
|
|
|
func TestMerge_GeneratorOwnedFrontmatterFieldsAreRefreshed(t *testing.T) {
|
|
// #103 made on-disk frontmatter win wholesale, which is what protects
|
|
// human-set fields from a regenerating writer. The cost was that fields the
|
|
// generator legitimately owns — when it last ran, what it last saw — froze
|
|
// at their first-ingest values. An explicit owned-set is the boundary:
|
|
// named fields the generator may refresh, everything else human-owned.
|
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'X'
|
|
last_updated: 2026-01-01
|
|
source_hash: aaaa
|
|
half_life: durable
|
|
claim_provenance: asserted
|
|
---
|
|
|
|
## Summary
|
|
|
|
Original.
|
|
`}
|
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'Renamed by the model'
|
|
last_updated: 2026-08-22
|
|
source_hash: bbbb
|
|
---
|
|
|
|
## Summary
|
|
|
|
Rewritten by the model.
|
|
`}
|
|
|
|
got := Merge(existing, fresh).Content
|
|
|
|
assert.Contains(t, got, "last_updated: 2026-08-22", "generator owns last_updated")
|
|
assert.Contains(t, got, "source_hash: bbbb", "generator owns source_hash")
|
|
assert.Contains(t, got, "title: 'X'", "a human-set title must survive regeneration")
|
|
assert.Contains(t, got, "half_life: durable", "the claim envelope must survive regeneration")
|
|
assert.Contains(t, got, "claim_provenance: asserted")
|
|
assert.Contains(t, got, "Original.", "an existing section still wins over the model's rewrite")
|
|
assert.NotContains(t, got, "Renamed by the model")
|
|
}
|
|
|
|
func TestMerge_ReportsSectionsWhereTheFreshVersionWasDiscarded(t *testing.T) {
|
|
// The asymmetry that makes ingest safe (on-disk wins) also throws away a
|
|
// real signal: the source now says something different about a section we
|
|
// already have. Today that disagreement is silently discarded, so nobody
|
|
// can tell "the source is unchanged" from "the source changed its mind and
|
|
// we ignored it".
|
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'X'
|
|
---
|
|
|
|
## Summary
|
|
|
|
Appetite is fixed, scope is variable.
|
|
|
|
## Method
|
|
|
|
Six-week cycles.
|
|
`}
|
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'X'
|
|
---
|
|
|
|
## Summary
|
|
|
|
Appetite is negotiable and scope is fixed.
|
|
|
|
## Method
|
|
|
|
Six-week cycles.
|
|
`}
|
|
|
|
got, conflicts := MergeReport(existing, fresh)
|
|
|
|
assert.Contains(t, got.Content, "Appetite is fixed", "on-disk version still wins")
|
|
assert.NotContains(t, got.Content, "negotiable")
|
|
assert.Equal(t, []string{"Summary"}, conflicts,
|
|
"only the section that actually disagreed is reported")
|
|
}
|
|
|
|
func TestMerge_ReportsNothingWhenTheFreshVersionAgrees(t *testing.T) {
|
|
page := Page{Path: "wiki/sources/x.md", Content: `---
|
|
title: 'X'
|
|
---
|
|
|
|
## Summary
|
|
|
|
Appetite is fixed, scope is variable.
|
|
`}
|
|
|
|
_, conflicts := MergeReport(page, page)
|
|
assert.Empty(t, conflicts, "identical content is not a disagreement")
|
|
}
|