fix(pipeline): slug clashes inside one batch; the source page is never the one skipped (review of #211, pass 2)

dropSlugClashes only compared against what was on disk, so a clip named like an
entity the model extracted wrote both pages and neither could be updated again.
In-batch winners: source over entity over concept. Against the brain, only
entities/concepts are skipped; the source's slug was already chosen free or its
own by resolveSourceSlug, so an existing bad pair no longer locks the source out.
Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:43:39 +02:00
co-authored by Claude Sonnet 5.5
parent 87c40fe364
commit 74d6a08c7b
2 changed files with 102 additions and 5 deletions
+43 -5
View File
@@ -372,15 +372,53 @@ func resolveSourceSlug(brainDir, wing, base string) (string, error) {
}
// dropSlugClashes removes, from the pages about to be written in a wing, any
// page whose slug already belongs to a different page elsewhere in the brain.
// The page's own path is not a clash (a shared entity merging into itself).
// A skipped page is a warning, not a failure: the ingest as a whole is fine,
// and one extracted page must not be able to lock a source out of re-ingest.
// page whose slug belongs to a different page. A slug is the graph identity, so
// two pages must not share one; and a page that shares one cannot be updated
// again, because every later ingest sees the other as its clash.
//
// Two kinds of clash, both by slug, neither failing the ingest (one extracted
// page must not be able to lock a source out of re-ingest):
// - inside the batch (a clip named notes.md whose model output also has an
// entity "Notes"): the source wins over an entity, an entity over a concept;
// - against the brain: an entity or concept whose slug is another page's. The
// SOURCE page is exempt: it is the note's own output, and its slug was
// already chosen free (or its own) by resolveSourceSlug.
//
// A page's own path is never a clash (a shared entity merging into itself).
func dropSlugClashes(brainDir string, pages []wiki.Page) ([]wiki.Page, []string, error) {
rank := func(p wiki.Page) int {
switch {
case isSourcePath(p.Path):
return 0
case strings.HasPrefix(p.Path, "wiki/entities/"):
return 1
case strings.HasPrefix(p.Path, "wiki/concepts/"):
return 2
}
return 3
}
slugOf := func(p wiki.Page) string { return strings.TrimSuffix(filepath.Base(p.Path), ".md") }
winner := map[string]wiki.Page{}
for _, p := range pages {
w, seen := winner[slugOf(p)]
if !seen || (w.Path != p.Path && rank(p) < rank(w)) {
winner[slugOf(p)] = p
}
}
var kept []wiki.Page
var warnings []string
for _, p := range pages {
slug := strings.TrimSuffix(filepath.Base(p.Path), ".md")
slug := slugOf(p)
if w := winner[slug]; w.Path != p.Path {
warnings = append(warnings, fmt.Sprintf("skipped %s: slug %q is also used by %s in this batch", p.Path, slug, w.Path))
continue
}
if rank(p) == 0 {
kept = append(kept, p)
continue
}
existing, err := wiki.FindSlugs(brainDir, slug)
if err != nil {
return nil, nil, fmt.Errorf("check slug %q: %w", slug, err)