Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09aeaa91f5 | ||
|
|
9abe4369f2 |
@@ -100,39 +100,8 @@ review gate.
|
|||||||
Re-ingesting unchanged content is **byte-identical** — no diff, so brain-sync
|
Re-ingesting unchanged content is **byte-identical** — no diff, so brain-sync
|
||||||
does not commit noise that hides real edits.
|
does not commit noise that hides real edits.
|
||||||
|
|
||||||
Two fields are **generator-owned** and do get refreshed on a revise:
|
Known limitation: because the on-disk frontmatter wins, `last_updated` keeps its
|
||||||
`last_updated` and `source_hash`. Everything else on disk wins. The boundary is
|
first-ingest date on a revised page.
|
||||||
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
|
|
||||||
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)
|
## Phase 1 tools (available now)
|
||||||
|
|
||||||
|
|||||||
@@ -76,9 +76,6 @@ func buildFrontmatter(rp RawPage, date string) string {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
||||||
fmt.Fprintf(&sb, "last_updated: %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 != "" {
|
if rp.Source != "" {
|
||||||
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
|
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ type RawPage struct {
|
|||||||
// ingested content's own frontmatter (see parseContentFrontmatter) — never
|
// ingested content's own frontmatter (see parseContentFrontmatter) — never
|
||||||
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM
|
// set by the LLM. json:"-" keeps them immune to same-named keys the LLM
|
||||||
// might emit. Only meaningful for Type == "source".
|
// might emit. Only meaningful for Type == "source".
|
||||||
// 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:"-"`
|
Source string `json:"-"`
|
||||||
Author string `json:"-"`
|
Author string `json:"-"`
|
||||||
Published string `json:"-"`
|
Published string `json:"-"`
|
||||||
@@ -116,11 +112,6 @@ type sourceMeta struct {
|
|||||||
Source string
|
Source string
|
||||||
Author string
|
Author string
|
||||||
Published 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
|
// parseContentFrontmatter extracts source/author/published from a leading
|
||||||
@@ -163,7 +154,6 @@ func applySourceMeta(pages []RawPage, meta sourceMeta) {
|
|||||||
if pages[i].Type != "source" {
|
if pages[i].Type != "source" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
pages[i].SourceHash = meta.BodyHash
|
|
||||||
pages[i].Source = meta.Source
|
pages[i].Source = meta.Source
|
||||||
pages[i].Author = meta.Author
|
pages[i].Author = meta.Author
|
||||||
pages[i].Published = meta.Published
|
pages[i].Published = meta.Published
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ package pipeline
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -28,11 +26,6 @@ type Config struct {
|
|||||||
type Result struct {
|
type Result struct {
|
||||||
Pages []string // relative paths written (or would-be written in dry-run)
|
Pages []string // relative paths written (or would-be written in dry-run)
|
||||||
Warnings []string
|
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/.
|
// Run ingests content and writes structured wiki pages to brainDir/wiki/.
|
||||||
@@ -50,18 +43,6 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
|
|
||||||
sourceSlug := wiki.Slug(source)
|
sourceSlug := wiki.Slug(source)
|
||||||
date := time.Now().UTC().Format("2006-01-02")
|
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)
|
chunks := Chunk(content, cfg.ChunkSize)
|
||||||
|
|
||||||
var allRaw []RawPage
|
var allRaw []RawPage
|
||||||
@@ -78,9 +59,7 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
allWarnings = append(allWarnings, warnings...)
|
allWarnings = append(allWarnings, warnings...)
|
||||||
}
|
}
|
||||||
|
|
||||||
meta := parseContentFrontmatter(content)
|
applySourceMeta(allRaw, parseContentFrontmatter(content))
|
||||||
meta.BodyHash = bodyHash
|
|
||||||
applySourceMeta(allRaw, meta)
|
|
||||||
|
|
||||||
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
return buildAndWrite(allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||||
}
|
}
|
||||||
@@ -117,18 +96,10 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
|
|||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
content, conflicts, err := reviseOrCreate(dest, page)
|
content, err := reviseOrCreate(dest, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
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 {
|
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
|
||||||
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
@@ -148,45 +119,6 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
|
|||||||
return Result{Pages: written, Warnings: warnings}, nil
|
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
|
// 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.
|
// whatever is already on disk, or the page as-is when the path is new.
|
||||||
//
|
//
|
||||||
@@ -206,16 +138,15 @@ func storedSourceHash(brainDir, sourceSlug string) string {
|
|||||||
// its first-ingest date on a revised page. Left deliberately — the alternative
|
// 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
|
// is this function editing frontmatter it does not own, and no lint reads that
|
||||||
// field today.
|
// field today.
|
||||||
func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) {
|
func reviseOrCreate(dest string, page wiki.Page) (string, error) {
|
||||||
existing, err := os.ReadFile(dest)
|
existing, err := os.ReadFile(dest)
|
||||||
switch {
|
switch {
|
||||||
case os.IsNotExist(err):
|
case os.IsNotExist(err):
|
||||||
return page.Content, nil, nil
|
return page.Content, nil
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err)
|
return "", fmt.Errorf("read existing %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
merged, conflicts := wiki.MergeReport(wiki.Page{Path: page.Path, Content: string(existing)}, page)
|
return wiki.Merge(wiki.Page{Path: page.Path, Content: string(existing)}, page).Content, nil
|
||||||
return merged.Content, conflicts, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -234,94 +233,3 @@ func TestRunRaw_ReIngestingUnchangedContentIsByteIdentical(t *testing.T) {
|
|||||||
assert.Equal(t, string(first), string(second),
|
assert.Equal(t, string(first), string(second),
|
||||||
"re-ingesting unchanged content must leave the page byte-identical")
|
"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")
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,36 +29,17 @@ type section struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Merge combines two Page values with the same path.
|
// Merge combines two Page values with the same path.
|
||||||
// Frontmatter is taken from a. Sections are merged by strategy:
|
// Frontmatter is taken from a. The preamble (content before the first ##
|
||||||
// bullet sections union unique lines, append sections concatenate,
|
// heading) is taken from a — same "on-disk wins" default every unlisted
|
||||||
// all others keep a's version. Sections in b not present in a are appended.
|
// named section gets, kept as a separate field rather than a section with
|
||||||
// Merge combines two pages, discarding any disagreement silently.
|
// an empty heading so it can never collide with one (see parseSections).
|
||||||
// Prefer MergeReport where the caller can act on the disagreement.
|
// 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.
|
||||||
func Merge(a, b Page) Page {
|
func Merge(a, b Page) Page {
|
||||||
page, _ := MergeReport(a, b)
|
fmA, preambleA, secsA := parseSections(a.Content)
|
||||||
return page
|
_, _, secsB := parseSections(b.Content)
|
||||||
}
|
|
||||||
|
|
||||||
// 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, secsA := parseSections(a.Content)
|
|
||||||
fmB, secsB := parseSections(b.Content)
|
|
||||||
|
|
||||||
var conflicts []string
|
|
||||||
idx := make(map[string]int, len(secsA))
|
idx := make(map[string]int, len(secsA))
|
||||||
for i, s := range secsA {
|
for i, s := range secsA {
|
||||||
idx[s.heading] = i
|
idx[s.heading] = i
|
||||||
@@ -77,67 +58,23 @@ func MergeReport(a, b Page) (Page, []string) {
|
|||||||
secsA[i].content = mergeBullets(sA.content, sB.content)
|
secsA[i].content = mergeBullets(sA.content, sB.content)
|
||||||
case appendSections[sB.heading]:
|
case appendSections[sB.heading]:
|
||||||
secsA[i].content = appendNew(sA.content, sB.content)
|
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), secsA)}, conflicts
|
return Page{Path: a.Path, Content: rebuildContent(fmA, preambleA, secsA)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// generatorOwned names the frontmatter fields the ingest pipeline may refresh
|
// parseSections splits markdown into its frontmatter, its preamble (content
|
||||||
// on a page it is revising. Everything else on disk wins.
|
// before the first `## ` heading), and its `## `-headed sections.
|
||||||
//
|
//
|
||||||
// #103 made the on-disk frontmatter win wholesale, which is what stops a
|
// The preamble is returned separately rather than as a section with an
|
||||||
// regenerating writer from overwriting human-set metadata — a title someone
|
// empty heading — a section keyed by heading=="" would collide with a
|
||||||
// fixed, or the claim-property envelope (brain#21). The cost was that fields
|
// literal degenerate `## ` (marker, no title text; strings.TrimPrefix also
|
||||||
// the generator legitimately owns froze at their first-ingest values: when it
|
// yields ""), which is a distinct, addressable section a page can genuinely
|
||||||
// last ran, and what it last saw.
|
// have, not the same thing as "no heading yet." Two entries sharing one key
|
||||||
//
|
// in Merge's idx map means one of them silently loses lookup visibility —
|
||||||
// An explicit allowlist rather than a carve-out: the ownership boundary is
|
// found in review as real, reproducible data loss, not a hypothetical.
|
||||||
// nameable, and a field nobody has thought about stays human-owned by default,
|
func parseSections(markdown string) (frontmatter, preamble string, sections []section) {
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseSections(markdown string) (frontmatter string, sections []section) {
|
|
||||||
lines := strings.Split(markdown, "\n")
|
lines := strings.Split(markdown, "\n")
|
||||||
i := 0
|
i := 0
|
||||||
|
|
||||||
@@ -155,6 +92,22 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
|
|||||||
frontmatter = fmt.Sprintf("---\n%s\n---\n", strings.Join(fmLines, "\n"))
|
frontmatter = fmt.Sprintf("---\n%s\n---\n", strings.Join(fmLines, "\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Everything before the first `## ` heading — an intro paragraph, or, if
|
||||||
|
// the page has no headings at all, the entire body — is ordinary content,
|
||||||
|
// not something this parser is entitled to drop (hyperguild#103 review:
|
||||||
|
// dropping it meant Merge, one caller up, would silently replace the
|
||||||
|
// whole on-disk body with the fresh extraction whenever a page didn't
|
||||||
|
// follow the ## convention — exactly the class of bug this file exists
|
||||||
|
// to prevent).
|
||||||
|
var preambleLines []string
|
||||||
|
for ; i < len(lines); i++ {
|
||||||
|
if strings.HasPrefix(lines[i], "## ") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
preambleLines = append(preambleLines, lines[i])
|
||||||
|
}
|
||||||
|
preamble = strings.Join(preambleLines, "\n")
|
||||||
|
|
||||||
var cur *section
|
var cur *section
|
||||||
for ; i < len(lines); i++ {
|
for ; i < len(lines); i++ {
|
||||||
line := lines[i]
|
line := lines[i]
|
||||||
@@ -181,9 +134,12 @@ func parseSections(markdown string) (frontmatter string, sections []section) {
|
|||||||
// by a blank line — invisible in a diff viewer, fatal to byte-stability. Compile-on-ingest
|
// by a blank line — invisible in a diff viewer, fatal to byte-stability. Compile-on-ingest
|
||||||
// merges on every run, so "merging changes nothing when nothing changed" has to
|
// merges on every run, so "merging changes nothing when nothing changed" has to
|
||||||
// hold at the byte level, not just the content level.
|
// hold at the byte level, not just the content level.
|
||||||
func rebuildContent(frontmatter string, sections []section) string {
|
func rebuildContent(frontmatter, preamble string, sections []section) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
sb.WriteString(frontmatter)
|
sb.WriteString(frontmatter)
|
||||||
|
if body := strings.Trim(preamble, "\n"); body != "" {
|
||||||
|
fmt.Fprintf(&sb, "\n%s\n", body)
|
||||||
|
}
|
||||||
for _, sec := range sections {
|
for _, sec := range sections {
|
||||||
body := strings.Trim(sec.content, "\n")
|
body := strings.Trim(sec.content, "\n")
|
||||||
fmt.Fprintf(&sb, "\n## %s\n\n%s\n", sec.heading, body)
|
fmt.Fprintf(&sb, "\n## %s\n\n%s\n", sec.heading, body)
|
||||||
@@ -191,6 +147,15 @@ func rebuildContent(frontmatter string, sections []section) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// canonicalLine collapses a line to its comparison key: internal whitespace
|
||||||
|
// runs (a re-extraction's bullet spacing, tabs vs spaces, trailing spaces)
|
||||||
|
// must not make the same logical line look new. strings.Fields already
|
||||||
|
// trims and splits on any whitespace run, so joining it back with single
|
||||||
|
// spaces is exactly "same words, same order, whitespace-insensitive."
|
||||||
|
func canonicalLine(line string) string {
|
||||||
|
return strings.Join(strings.Fields(line), " ")
|
||||||
|
}
|
||||||
|
|
||||||
// appendNew concatenates the lines of b that a does not already carry.
|
// appendNew concatenates the lines of b that a does not already carry.
|
||||||
//
|
//
|
||||||
// Append sections used to concatenate wholesale, which is correct exactly once.
|
// Append sections used to concatenate wholesale, which is correct exactly once.
|
||||||
@@ -211,18 +176,18 @@ func rebuildContent(frontmatter string, sections []section) string {
|
|||||||
func appendNew(a, b string) string {
|
func appendNew(a, b string) string {
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
for _, line := range strings.Split(a, "\n") {
|
for _, line := range strings.Split(a, "\n") {
|
||||||
if t := strings.TrimSpace(line); t != "" {
|
if c := canonicalLine(line); c != "" {
|
||||||
seen[t] = true
|
seen[c] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var add []string
|
var add []string
|
||||||
for _, line := range strings.Split(b, "\n") {
|
for _, line := range strings.Split(b, "\n") {
|
||||||
t := strings.TrimSpace(line)
|
c := canonicalLine(line)
|
||||||
if t == "" || seen[t] {
|
if c == "" || seen[c] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[t] = true
|
seen[c] = true
|
||||||
add = append(add, line)
|
add = append(add, line)
|
||||||
}
|
}
|
||||||
if len(add) == 0 {
|
if len(add) == 0 {
|
||||||
@@ -235,11 +200,11 @@ func mergeBullets(a, b string) string {
|
|||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
var lines []string
|
var lines []string
|
||||||
for _, line := range strings.Split(a+b, "\n") {
|
for _, line := range strings.Split(a+b, "\n") {
|
||||||
trimmed := strings.TrimSpace(line)
|
c := canonicalLine(line)
|
||||||
if trimmed == "" || seen[trimmed] {
|
if c == "" || seen[c] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[trimmed] = true
|
seen[c] = true
|
||||||
lines = append(lines, line)
|
lines = append(lines, line)
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n") + "\n"
|
return strings.Join(lines, "\n") + "\n"
|
||||||
|
|||||||
@@ -54,6 +54,77 @@ func TestMerge_KeepsFrontmatterFromA(t *testing.T) {
|
|||||||
assert.NotContains(t, got.Content, "title: B")
|
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 -------------------------
|
// --- brain#21/B1: merging must be safe to run again -------------------------
|
||||||
|
|
||||||
func TestMerge_AppendSectionDoesNotDuplicateContentAlreadyPresent(t *testing.T) {
|
func TestMerge_AppendSectionDoesNotDuplicateContentAlreadyPresent(t *testing.T) {
|
||||||
@@ -103,96 +174,3 @@ title: X
|
|||||||
assert.Contains(t, got, "Does this converge?", "existing question must survive")
|
assert.Contains(t, got, "Does this converge?", "existing question must survive")
|
||||||
assert.Contains(t, got, "What happens under load?", "new question must be added")
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user