From a5a328fea5c15b3c2379e942bd971d618dba3295 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 22 Aug 2026 16:14:17 +0200 Subject: [PATCH 1/2] feat(pipeline): skip extraction when the source has not changed (#105) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ingest now stamps source_hash — sha256 of the ingested body, frontmatter excluded — onto the source page, and compares it before doing any work. An unchanged source returns Skipped with no LLM call at all. This is cost and signal, not correctness: #103 already made a re-ingest byte-identical, so a repeated run was harmless but not free — a full extraction over every chunk to produce a result that merges to nothing. Building this first would have put idempotency in the guard rather than in the write, which is the wrong dependency direction. Body-only hashing on purpose. A re-crawl that stamps a new fetch date has not changed the source, and treating that as drift would re-extract the whole corpus every time a crawler ran. A missing or unreadable hash always means "extract": a missed skip costs one extraction, a wrong skip loses an update silently, and silence is the failure mode this repo keeps paying for. The skip is reported (Result.Skipped plus a warning naming the hash), not silent — a run that does nothing must say so, or "the pipeline is fine" and "the pipeline stopped working" look identical (#107). Storing the hash required settling who owns which frontmatter field. #103 made the on-disk block win wholesale, which protects human-set metadata from a regenerating writer but froze fields the generator legitimately owns. wiki.Merge now refreshes an explicit generatorOwned allowlist — last_updated and source_hash — and keeps everything else on disk. An allowlist rather than a carve-out, so a field nobody has thought about stays human-owned by default. That also retires the last_updated limitation #103 shipped with. Refs #105, #103, #107, brain#21. --- README.md | 17 +++++- ingestion/internal/pipeline/build.go | 3 + ingestion/internal/pipeline/parse.go | 16 ++++- ingestion/internal/pipeline/pipeline.go | 62 +++++++++++++++++++- ingestion/internal/pipeline/pipeline_test.go | 60 +++++++++++++++++++ ingestion/internal/wiki/merge.go | 53 ++++++++++++++++- ingestion/internal/wiki/merge_test.go | 40 +++++++++++++ 7 files changed, 243 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 609410d..1c1d545 100644 --- a/README.md +++ b/README.md @@ -100,8 +100,21 @@ review gate. Re-ingesting unchanged content is **byte-identical** — no diff, so brain-sync does not commit noise that hides real edits. -Known limitation: because the on-disk frontmatter wins, `last_updated` keeps its -first-ingest date on a revised page. +Two fields are **generator-owned** and do get refreshed on a revise: +`last_updated` and `source_hash`. Everything else on disk wins. The boundary is +an explicit allowlist, so a field nobody has thought about stays human-owned. + +## Re-ingesting an unchanged source is skipped + +Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter +excluded — on the source page. A later ingest of the same source compares it +first and skips extraction entirely when it matches, reporting the skip rather +than doing it silently. + +Body-only is deliberate: a re-crawl that stamps a new fetch date has not changed +the source, and treating that as drift would re-extract the corpus every time a +crawler ran. A missing or unreadable hash always means "extract" — a missed skip +costs one extraction, a wrong skip loses an update. ## Phase 1 tools (available now) diff --git a/ingestion/internal/pipeline/build.go b/ingestion/internal/pipeline/build.go index c59ba8e..4ff005a 100644 --- a/ingestion/internal/pipeline/build.go +++ b/ingestion/internal/pipeline/build.go @@ -76,6 +76,9 @@ func buildFrontmatter(rp RawPage, date string) string { } fmt.Fprintf(&sb, "date_ingested: %s\n", date) fmt.Fprintf(&sb, "last_updated: %s\n", date) + if rp.SourceHash != "" { + fmt.Fprintf(&sb, "source_hash: %s\n", rp.SourceHash) + } if rp.Source != "" { fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source)) } diff --git a/ingestion/internal/pipeline/parse.go b/ingestion/internal/pipeline/parse.go index 6b2afc5..43615e1 100644 --- a/ingestion/internal/pipeline/parse.go +++ b/ingestion/internal/pipeline/parse.go @@ -20,9 +20,13 @@ type RawPage struct { // ingested content's own frontmatter (see parseContentFrontmatter) — never // set by the LLM. json:"-" keeps them immune to same-named keys the LLM // might emit. Only meaningful for Type == "source". - Source string `json:"-"` - Author string `json:"-"` - Published string `json:"-"` + // SourceHash is sha256 of the ingested BODY (frontmatter excluded), stamped + // so the next ingest of the same source can tell "unchanged" from "edited" + // without re-running extraction (#105). + SourceHash string `json:"-"` + Source string `json:"-"` + Author string `json:"-"` + Published string `json:"-"` } // ParseRawPages parses LLM output as a JSON array of RawPage objects. @@ -112,6 +116,11 @@ type sourceMeta struct { Source string Author string Published string + // BodyHash is sha256 of the content with any leading frontmatter removed. + // Body-only on purpose: a re-crawl that stamps a new fetch date has not + // changed the source, and treating that as drift would re-extract the + // whole corpus every time a crawler ran (#105). + BodyHash string } // parseContentFrontmatter extracts source/author/published from a leading @@ -154,6 +163,7 @@ func applySourceMeta(pages []RawPage, meta sourceMeta) { if pages[i].Type != "source" { continue } + pages[i].SourceHash = meta.BodyHash pages[i].Source = meta.Source pages[i].Author = meta.Author pages[i].Published = meta.Published diff --git a/ingestion/internal/pipeline/pipeline.go b/ingestion/internal/pipeline/pipeline.go index 48b3aea..f7c3487 100644 --- a/ingestion/internal/pipeline/pipeline.go +++ b/ingestion/internal/pipeline/pipeline.go @@ -3,6 +3,8 @@ package pipeline import ( "context" + "crypto/sha256" + "encoding/hex" "fmt" "os" "path/filepath" @@ -26,6 +28,11 @@ type Config struct { type Result struct { Pages []string // relative paths written (or would-be written in dry-run) Warnings []string + // Skipped reports that the source was byte-identical to the last ingest, + // so no extraction ran and nothing was written. Reported rather than + // silent: a run that does nothing must say so, or "the pipeline is fine" + // and "the pipeline stopped working" look the same (#105, #107). + Skipped bool } // Run ingests content and writes structured wiki pages to brainDir/wiki/. @@ -43,6 +50,18 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR sourceSlug := wiki.Slug(source) date := time.Now().UTC().Format("2006-01-02") + + // Drift check BEFORE any extraction: an unchanged source costs a full LLM + // pass over every chunk to produce a result that merges to nothing (#105). + // The merge is already idempotent (#103), so this is cost and signal, not + // correctness — which is why it is safe for it to be an early return. + bodyHash := hashBody(content) + if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, sourceSlug) { + return Result{Skipped: true, Warnings: []string{ + fmt.Sprintf("source %q unchanged since last ingest (hash %s) — extraction skipped", source, bodyHash[:12]), + }}, nil + } + chunks := Chunk(content, cfg.ChunkSize) var allRaw []RawPage @@ -59,7 +78,9 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR allWarnings = append(allWarnings, warnings...) } - applySourceMeta(allRaw, parseContentFrontmatter(content)) + meta := parseContentFrontmatter(content) + meta.BodyHash = bodyHash + applySourceMeta(allRaw, meta) return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun) } @@ -119,6 +140,45 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string return Result{Pages: written, Warnings: warnings}, nil } +// hashBody returns sha256 of content with any leading frontmatter block +// removed, or "" for empty content. +// +// Body-only on purpose: a re-crawl that stamps a new fetch date has not changed +// the source, and treating that as drift would re-extract the corpus every time +// a crawler ran (#105). +func hashBody(content string) string { + body := content + if strings.HasPrefix(body, "---\n") { + if _, rest, found := strings.Cut(body[4:], "\n---"); found { + body = strings.TrimPrefix(rest, "\n") + } + } + if strings.TrimSpace(body) == "" { + return "" + } + sum := sha256.Sum256([]byte(body)) + return hex.EncodeToString(sum[:]) +} + +// storedSourceHash reads source_hash from the source page a previous ingest +// wrote, or "" when there is no such page or no such field. +// +// Absence always means "extract". That is the fail-safe direction: a missed +// skip costs one extraction, while a wrong skip loses an update silently — and +// silence is the failure mode this repo keeps paying for. +func storedSourceHash(brainDir, sourceSlug string) string { + raw, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", sourceSlug+".md")) + if err != nil { + return "" + } + for _, line := range strings.Split(string(raw), "\n") { + if key, value, found := strings.Cut(line, ":"); found && strings.TrimSpace(key) == "source_hash" { + return strings.TrimSpace(value) + } + } + return "" +} + // reviseOrCreate returns the bytes to write for one extracted page: merged into // whatever is already on disk, or the page as-is when the path is new. // diff --git a/ingestion/internal/pipeline/pipeline_test.go b/ingestion/internal/pipeline/pipeline_test.go index b62301c..d208fe3 100644 --- a/ingestion/internal/pipeline/pipeline_test.go +++ b/ingestion/internal/pipeline/pipeline_test.go @@ -233,3 +233,63 @@ func TestRunRaw_ReIngestingUnchangedContentIsByteIdentical(t *testing.T) { assert.Equal(t, string(first), string(second), "re-ingesting unchanged content must leave the page byte-identical") } + +// --- #105: source drift — an unchanged re-ingest must not re-extract -------- + +func TestRun_SkipsExtractionWhenTheSourceIsUnchanged(t *testing.T) { + // #103 made a re-ingest harmless (byte-identical merge). It is still not + // free: the full LLM extraction runs over every chunk to produce a result + // that merges to nothing. This is the cost half. + dir := t.TempDir() + var calls int + cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) { + calls++ + return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil + }} + + content := "---\nsource: https://example.com/shape-up\n---\n\nThe body of the article.\n" + + first, err := Run(context.Background(), cfg, dir, content, "shape-up", false) + require.NoError(t, err) + require.Equal(t, 1, calls, "first ingest must extract") + assert.False(t, first.Skipped) + + second, err := Run(context.Background(), cfg, dir, content, "shape-up", false) + require.NoError(t, err) + assert.Equal(t, 1, calls, "unchanged content must not be extracted again") + assert.True(t, second.Skipped, "the skip must be reported, not silent") +} + +func TestRun_ReExtractsWhenTheSourceBodyChanged(t *testing.T) { + dir := t.TempDir() + var calls int + cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) { + calls++ + return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil + }} + + _, err := Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion one\n", "shape-up", false) + require.NoError(t, err) + _, err = Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion two — the source was edited\n", "shape-up", false) + require.NoError(t, err) + + assert.Equal(t, 2, calls, "changed content must be re-extracted") +} + +func TestRun_FrontmatterOnlyChangeIsNotDrift(t *testing.T) { + // The hash covers the body, so re-fetching a page whose metadata moved + // (a re-crawl stamping a new date) is not treated as the source changing. + dir := t.TempDir() + var calls int + cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) { + calls++ + return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil + }} + + _, err := Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: monday\n---\n\nsame body\n", "shape-up", false) + require.NoError(t, err) + _, err = Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: tuesday\n---\n\nsame body\n", "shape-up", false) + require.NoError(t, err) + + assert.Equal(t, 1, calls, "only the body counts as the source") +} diff --git a/ingestion/internal/wiki/merge.go b/ingestion/internal/wiki/merge.go index 8c161ea..31e9835 100644 --- a/ingestion/internal/wiki/merge.go +++ b/ingestion/internal/wiki/merge.go @@ -38,7 +38,7 @@ type section struct { // not present in a are appended. func Merge(a, b Page) Page { fmA, preambleA, secsA := parseSections(a.Content) - _, _, secsB := parseSections(b.Content) + fmB, _, secsB := parseSections(b.Content) idx := make(map[string]int, len(secsA)) for i, s := range secsA { @@ -61,7 +61,56 @@ func Merge(a, b Page) Page { } } - return Page{Path: a.Path, Content: rebuildContent(fmA, preambleA, secsA)} + return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), preambleA, secsA)} +} + +// generatorOwned names the frontmatter fields the ingest pipeline may refresh +// on a page it is revising. Everything else on disk wins. +// +// #103 made the on-disk frontmatter win wholesale, which is what stops a +// regenerating writer from overwriting human-set metadata — a title someone +// fixed, or the claim-property envelope (brain#21). The cost was that fields +// the generator legitimately owns froze at their first-ingest values: when it +// last ran, and what it last saw. +// +// An explicit allowlist rather than a carve-out: the ownership boundary is +// nameable, and a field nobody has thought about stays human-owned by default, +// which is the safe direction. +var generatorOwned = map[string]bool{ + "last_updated": true, + "source_hash": true, +} + +// refreshOwned returns a's frontmatter with the generator-owned fields taken +// from b where b supplies them. Fields absent from b keep a's value, so a +// generator that stops emitting a field cannot blank it. +func refreshOwned(a, b string) string { + bVals := make(map[string]string) + for _, line := range strings.Split(b, "\n") { + key, value, found := strings.Cut(line, ":") + if !found || !generatorOwned[strings.TrimSpace(key)] { + continue + } + bVals[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + if len(bVals) == 0 { + return a + } + + out := make([]string, 0, len(strings.Split(a, "\n"))) + for _, line := range strings.Split(a, "\n") { + key, _, found := strings.Cut(line, ":") + k := strings.TrimSpace(key) + if found && generatorOwned[k] { + if v, ok := bVals[k]; ok { + out = append(out, k+": "+v) + delete(bVals, k) + continue + } + } + out = append(out, line) + } + return strings.Join(out, "\n") } // parseSections splits markdown into its frontmatter, its preamble (content diff --git a/ingestion/internal/wiki/merge_test.go b/ingestion/internal/wiki/merge_test.go index e1bdcde..1f91d62 100644 --- a/ingestion/internal/wiki/merge_test.go +++ b/ingestion/internal/wiki/merge_test.go @@ -174,3 +174,43 @@ title: X 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") +} -- 2.54.0 From 9345a0a867d71beca3e951641156ed388f925ebc Mon Sep 17 00:00:00 2001 From: Mathias Date: Sat, 22 Aug 2026 22:52:27 +0200 Subject: [PATCH 2/2] feat(pipeline): report when a re-ingest disagrees with the page on disk (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 18 +++++++ ingestion/internal/pipeline/pipeline.go | 19 +++++-- ingestion/internal/pipeline/pipeline_test.go | 32 ++++++++++++ ingestion/internal/wiki/merge.go | 30 ++++++++++- ingestion/internal/wiki/merge_test.go | 53 ++++++++++++++++++++ 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1c1d545..a99a60e 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,24 @@ Two fields are **generator-owned** and do get refreshed on a revise: `last_updated` and `source_hash`. Everything else on disk wins. The boundary is an explicit allowlist, so a field nobody has thought about stays human-owned. +### Disagreement is reported, not resolved + +When the fresh extraction produces a *different version* of a section that +already exists, the on-disk version still wins — but the run now says so: + +``` +wiki/sources/shape-up.md: source now disagrees on section "Summary" — on-disk version kept +``` + +That line lands in `log.md` with the rest of the run's warnings. Without it, "the +source is unchanged" and "the source changed its mind and we ignored it" look +identical from the outside. + +This is deliberately **textual** disagreement, not semantic contradiction: cheap, +deterministic, no model on the write path. Deciding whether a disagreement is a +genuine contradiction stays with the Judge-gated `brain-enrich` pass, which is +approval-gated by design. Detection here, adjudication there. + ## Re-ingesting an unchanged source is skipped Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter diff --git a/ingestion/internal/pipeline/pipeline.go b/ingestion/internal/pipeline/pipeline.go index f7c3487..ac63c52 100644 --- a/ingestion/internal/pipeline/pipeline.go +++ b/ingestion/internal/pipeline/pipeline.go @@ -117,10 +117,18 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err) } - content, err := reviseOrCreate(dest, page) + content, conflicts, err := reviseOrCreate(dest, page) if err != nil { return Result{}, err } + for _, heading := range conflicts { + // Reported, not resolved. The on-disk version stands; whether + // the disagreement is a genuine contradiction is the + // Judge-gated enrich pass's call, not the write path's. + warnings = append(warnings, fmt.Sprintf( + "%s: source now disagrees on section %q — on-disk version kept", + page.Path, heading)) + } if err := os.WriteFile(dest, []byte(content), 0o644); err != nil { return Result{}, fmt.Errorf("write %s: %w", page.Path, err) } @@ -198,15 +206,16 @@ func storedSourceHash(brainDir, sourceSlug string) string { // its first-ingest date on a revised page. Left deliberately — the alternative // is this function editing frontmatter it does not own, and no lint reads that // field today. -func reviseOrCreate(dest string, page wiki.Page) (string, error) { +func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) { existing, err := os.ReadFile(dest) switch { case os.IsNotExist(err): - return page.Content, nil + return page.Content, nil, nil case err != nil: - return "", fmt.Errorf("read existing %s: %w", page.Path, err) + return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err) } - return wiki.Merge(wiki.Page{Path: page.Path, Content: string(existing)}, page).Content, nil + merged, conflicts := wiki.MergeReport(wiki.Page{Path: page.Path, Content: string(existing)}, page) + return merged.Content, conflicts, nil } // mergeAll deduplicates pages by path, merging content from later occurrences. diff --git a/ingestion/internal/pipeline/pipeline_test.go b/ingestion/internal/pipeline/pipeline_test.go index d208fe3..208f913 100644 --- a/ingestion/internal/pipeline/pipeline_test.go +++ b/ingestion/internal/pipeline/pipeline_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" @@ -293,3 +294,34 @@ func TestRun_FrontmatterOnlyChangeIsNotDrift(t *testing.T) { assert.Equal(t, 1, calls, "only the body counts as the source") } + +func TestRunRaw_ReportsWhenTheSourceDisagreesWithWhatIsOnDisk(t *testing.T) { + // The on-disk version still wins — that is what makes unattended ingest + // safe. But the run must say that the source now claims something else, + // or a changed source is indistinguishable from an unchanged one. + dir := t.TempDir() + page := filepath.Join(dir, "wiki", "sources", "shape-up.md") + require.NoError(t, os.MkdirAll(filepath.Dir(page), 0o755)) + require.NoError(t, os.WriteFile(page, []byte(`--- +title: 'Shape Up' +--- + +## Summary + +Appetite is fixed, scope is variable. +`), 0o644)) + + res, err := RunRaw(dir, "shape-up", []RawPage{{ + Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product", + Content: "## Summary\n\nAppetite is negotiable and scope is fixed.\n", + }}, false) + require.NoError(t, err) + + got, err := os.ReadFile(page) + require.NoError(t, err) + assert.Contains(t, string(got), "Appetite is fixed", "on-disk version wins") + + joined := strings.Join(res.Warnings, " ") + assert.Contains(t, joined, "Summary", "the disagreeing section must be named") + assert.Contains(t, joined, "shape-up", "and the page it happened on") +} diff --git a/ingestion/internal/wiki/merge.go b/ingestion/internal/wiki/merge.go index 31e9835..b59a710 100644 --- a/ingestion/internal/wiki/merge.go +++ b/ingestion/internal/wiki/merge.go @@ -36,10 +36,33 @@ type section struct { // Sections are merged by strategy: bullet sections union unique lines, // append sections concatenate, all others keep a's version. Sections in b // not present in a are appended. +// Merge discards any disagreement silently; prefer MergeReport where the +// caller can act on it. func Merge(a, b Page) Page { + page, _ := MergeReport(a, b) + return page +} + +// MergeReport is Merge, plus the headings of sections where b carried a +// different version of a section a already had — and lost. +// +// That loss is the property which makes ingest safe to run unattended (#103): +// a regenerating writer can add to a note but never rewrite it. But the +// discarded text is also a signal, and until now it went in the bin: the source +// now says something different about a section we already hold. Nobody could +// tell "the source is unchanged" from "the source changed its mind and we +// ignored it". +// +// Deliberately NOT a semantic contradiction check. This reports textual +// disagreement, which is cheap, deterministic, and needs no model on the write +// path. Deciding whether a disagreement is a genuine contradiction stays with +// the Judge-gated enrich pass that already exists (brain-gardener#175), which +// is approval-gated by design — detection here, adjudication there. +func MergeReport(a, b Page) (Page, []string) { fmA, preambleA, secsA := parseSections(a.Content) fmB, _, secsB := parseSections(b.Content) + var conflicts []string idx := make(map[string]int, len(secsA)) for i, s := range secsA { idx[s.heading] = i @@ -58,10 +81,15 @@ func Merge(a, b Page) Page { secsA[i].content = mergeBullets(sA.content, sB.content) case appendSections[sB.heading]: secsA[i].content = appendNew(sA.content, sB.content) + default: + // a wins — but say that b disagreed, rather than dropping it mute. + if strings.TrimSpace(sA.content) != strings.TrimSpace(sB.content) { + conflicts = append(conflicts, sB.heading) + } } } - return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), preambleA, secsA)} + return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), preambleA, secsA)}, conflicts } // generatorOwned names the frontmatter fields the ingest pipeline may refresh diff --git a/ingestion/internal/wiki/merge_test.go b/ingestion/internal/wiki/merge_test.go index 1f91d62..5c9cc93 100644 --- a/ingestion/internal/wiki/merge_test.go +++ b/ingestion/internal/wiki/merge_test.go @@ -214,3 +214,56 @@ Rewritten by the model. 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") +} -- 2.54.0