feat(server): INGEST_WING_ROUTING wires the Router; remove the classifier path (#208)

Removes the LLM classifier, the substring 'BM25' scorer, the agreement gate,
the backtest and the old migrate/wingmigrate tooling: measured at 2 of 55
pages placed (#208), and the migration was built for the per-wing facts layout
this branch replaces. wing now holds the decision types, the Router and
KnownWings. INGEST_WING_ROUTING=1 builds the Router (default wing
INGEST_DEFAULT_WING=web-clip, client pattern from INGEST_CLIENT_BLOCK or
CLAUDE_INGEST_CLIENT_BLOCK); a bad default or pattern is fatal at startup.
Watcher tests rewritten for the Router, including a deliberate subfolder into
any existing wing, typo'd folders parked, forged-key and edit-while-placing
cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:18:39 +02:00
co-authored by Claude Sonnet 5.5
parent 409b85096b
commit 8988c291ca
20 changed files with 263 additions and 1752 deletions
+164 -188
View File
@@ -3,9 +3,9 @@ package watcher
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"regexp"
"strings"
"sync/atomic"
"testing"
@@ -17,37 +17,11 @@ import (
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
)
type stubClassifier struct {
s wing.Suggestion
err error
calls atomic.Int32
}
func (c *stubClassifier) Classify(context.Context, string, []string) (wing.Suggestion, error) {
c.calls.Add(1)
return c.s, c.err
}
type stubScorer map[string]float64
func (s stubScorer) Score(string) map[string]float64 { return s }
func wingResolver(c wing.Classifier, s wing.Scorer) *wing.Resolver {
return &wing.Resolver{
Known: []string{"radar", "homelab", "client-x"},
Eligible: func(w string) bool { return w != "client-x" },
Classifier: c,
Scorer: s,
Threshold: 0.8,
}
}
func wingTestCfg(t *testing.T, r wing.Placer, llmCalls *atomic.Int32) (Config, string) {
func wingTestCfg(t *testing.T, mk func(brainDir string) wing.Placer, llmCalls *atomic.Int32) (Config, string) {
t.Helper()
brainDir := setupBrainDir(t)
return Config{
cfg := Config{
BrainDir: brainDir,
Resolver: r,
Pipeline: pipeline.Config{
Schema: "# Schema",
Complete: func(ctx context.Context, s, u string) (string, error) {
@@ -55,7 +29,21 @@ func wingTestCfg(t *testing.T, r wing.Placer, llmCalls *atomic.Int32) (Config, s
return successComplete(pipeline.RawPage{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n"})(ctx, s, u)
},
},
}, brainDir
}
if mk != nil {
cfg.Resolver = mk(brainDir)
}
return cfg, brainDir
}
// router is the production Router with a client-content marker.
func router(brainDir string) wing.Placer {
return wing.Router{BrainDir: brainDir, Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)}
}
func makeWing(t *testing.T, brainDir, name string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", name, "facts"), 0o755))
}
func put(t *testing.T, brainDir, rel, body string) {
@@ -70,70 +58,91 @@ func has(brainDir, rel string) bool {
return err == nil
}
func mustJSONPages(pages []pipeline.RawPage) string {
b, err := json.Marshal(pages)
if err != nil {
panic(err)
}
return string(b)
}
func parkedBody(t *testing.T, brainDir, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", name))
require.NoError(t, err)
return string(b)
}
func linesStartingWith(s, prefix string) int {
n := 0
for _, l := range strings.Split(s, "\n") {
if strings.HasPrefix(l, prefix) {
n++
}
}
return n
}
func TestProcessDir_ASubfolderNamesTheWing(t *testing.T) {
var llmCalls atomic.Int32
clf := &stubClassifier{}
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls)
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
makeWing(t, brainDir, "radar")
put(t, brainDir, "radar/dark-factory.md", "A talk about factories.")
errs := processDir(context.Background(), cfg, "2026-10-20")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
require.Empty(t, errs)
assert.True(t, has(brainDir, "wiki/radar/sources/dark-factory.md"), "lands in the named wing")
assert.False(t, has(brainDir, "raw/radar/dark-factory.md"), "inbox zero holds for subfolders too")
assert.Zero(t, clf.calls.Load(), "an explicit wing costs no classification")
}
func TestProcessDir_ANoteTheResolverCannotPlaceIsParkedNotIngested(t *testing.T) {
func TestProcessDir_ADeliberateSubfolderIntoAnyExistingWingPlaces(t *testing.T) {
var llmCalls atomic.Int32
clf := &stubClassifier{s: wing.Suggestion{Wing: "radar", Confidence: 0.5, Reason: "maybe"}}
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{"radar": 3}), &llmCalls)
put(t, brainDir, "unsure.md", "Some note.")
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
makeWing(t, brainDir, "rikatillsammans")
put(t, brainDir, "rikatillsammans/meeting.md", "Notes the owner put there on purpose.")
errs := processDir(context.Background(), cfg, "2026-10-20")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.True(t, has(brainDir, "wiki/rikatillsammans/sources/meeting.md"),
"a folder is the owner's deliberate choice; extraction runs on the local model")
}
func TestProcessDir_ANoteTheRouterCannotPlaceIsParkedNotIngested(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
put(t, brainDir, "kickoff.md", "Some note about the ACME-CLIENT kickoff.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
require.Empty(t, errs)
assert.Zero(t, llmCalls.Load(), "no extraction runs for an unplaced note")
assert.False(t, has(brainDir, "raw/unsure.md"), "leaves the top of raw/")
parked, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", "unsure.md"))
require.NoError(t, err)
assert.Contains(t, string(parked), "proposed_wing: radar")
assert.Contains(t, string(parked), "below threshold")
assert.Contains(t, string(parked), "Some note.", "the note body is preserved")
assert.False(t, has(brainDir, "raw/kickoff.md"), "leaves the top of raw/")
parked := parkedBody(t, brainDir, "kickoff.md")
assert.Contains(t, parked, "proposal_reason:")
assert.Contains(t, parked, "client-content marker")
assert.Contains(t, parked, "ACME-CLIENT kickoff", "the note body is preserved")
}
func TestProcessDir_ANoteInAClientWingFolderIsParked(t *testing.T) {
func TestProcessDir_ATypoedWingFolderIsParkedAndNeverCreated(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls)
put(t, brainDir, "client-x/secret.md", "Client material.")
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
makeWing(t, brainDir, "homelab")
put(t, brainDir, "hoemlab/n.md", "Note.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.Zero(t, llmCalls.Load())
assert.False(t, has(brainDir, "wiki/client-x"), "nothing is ever written into an ineligible wing")
assert.True(t, has(brainDir, "raw/needs-review/secret.md"))
}
func TestProcessDir_ClassifierFailureParksTheNote(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{err: errors.New("llm down")}, stubScorer{}), &llmCalls)
put(t, brainDir, "n.md", "Note.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.Zero(t, llmCalls.Load())
assert.False(t, has(brainDir, "wiki/hoemlab"))
assert.True(t, has(brainDir, "raw/needs-review/n.md"))
assert.Zero(t, llmCalls.Load())
}
func TestProcessDir_ParkedNotesAreNotReprocessed(t *testing.T) {
var llmCalls atomic.Int32
clf := &stubClassifier{s: wing.Suggestion{Wing: "radar", Confidence: 0.99}}
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{"radar": 3}), &llmCalls)
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
put(t, brainDir, "needs-review/waiting.md", "---\nproposed_wing: radar\n---\nA parked note.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.Zero(t, clf.calls.Load(), "needs-review/ is a human queue, like rejected/")
assert.Zero(t, llmCalls.Load(), "needs-review/ is a human queue, like rejected/")
assert.True(t, has(brainDir, "raw/needs-review/waiting.md"))
}
@@ -147,141 +156,68 @@ func TestProcessDir_WithoutAResolverTheRetiredLayoutIsUnchanged(t *testing.T) {
assert.True(t, has(brainDir, "wiki/sources/plain.md"))
}
// The reason comes from an LLM and ends up in frontmatter: a newline in it must
// not be able to start a new key (PR #147: a title once forged `origin:`).
func TestProcessDir_AProposalReasonCannotForgeFrontmatterKeys(t *testing.T) {
// The reason comes from file and folder names and ends up in frontmatter: a
// newline in it must not be able to start a new key (PR #147: a title once
// forged `origin:`).
func TestProcessDir_AFolderNameCannotForgeFrontmatterKeys(t *testing.T) {
var llmCalls atomic.Int32
// A classifier error is the path by which model text reaches the reason.
clf := &stubClassifier{err: errors.New("bad reply\norigin: trusted\nwing: homelab")}
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls)
put(t, brainDir, "forge.md", "Note.")
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
put(t, brainDir, "radar\nproposal_confidence: 0.99\nwing: homelab/forge.md", "Note.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
parked, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", "forge.md"))
entries, err := os.ReadDir(filepath.Join(brainDir, "raw", "needs-review"))
require.NoError(t, err)
assert.NotContains(t, string(parked), "\norigin: trusted", "no injected key at line start")
assert.NotContains(t, string(parked), "\nwing: homelab")
require.Len(t, entries, 1)
b := parkedBody(t, brainDir, entries[0].Name())
assert.Equal(t, 1, linesStartingWith(b, "proposal_confidence:"), "exactly one confidence key at line start")
assert.Equal(t, 0, linesStartingWith(b, "wing:"), "no injected wing key")
assert.Equal(t, 0, linesStartingWith(b, "proposed_wing:"), "a non-plain wing name is never a proposal")
}
// Syncthing can bring a removed note back (an offline peer, a restored backup).
// The source-hash drift check must keep that from costing a second extraction.
func TestProcessDir_ANoteThatReappearsUnchangedIsNotExtractedAgain(t *testing.T) {
func TestProcessDir_ANotesOwnProposalKeysDoNotOutrankTheRouters(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls)
put(t, brainDir, "radar/talk.md", "The same talk, byte for byte.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
require.Equal(t, int32(1), llmCalls.Load(), "positive control: the first drop is extracted")
require.False(t, has(brainDir, "raw/radar/talk.md"))
put(t, brainDir, "radar/talk.md", "The same talk, byte for byte.") // the peer sends it back
require.Empty(t, processDir(context.Background(), cfg, "2026-10-21"))
assert.Equal(t, int32(1), llmCalls.Load(), "an unchanged source is not extracted twice")
assert.False(t, has(brainDir, "raw/radar/talk.md"), "and inbox zero still holds")
}
func TestProcessDir_ANoteThatReappearsChangedIsExtractedAgain(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls)
put(t, brainDir, "radar/talk.md", "Version one.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
put(t, brainDir, "radar/talk.md", "Version two, edited.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-21"))
assert.Equal(t, int32(2), llmCalls.Load(), "a real edit must still be picked up")
}
// The parked copy is the only copy once the original is retired, so two notes
// that share a name must both survive.
func TestProcessDir_SameNamedNotesAreAllParkedNoneOverwritten(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls)
for i, body := range []string{"ONE", "TWO", "THREE", "FOUR"} {
put(t, brainDir, filepath.Join("u"+string(rune('1'+i)), "x.md"), body)
}
// Unknown subfolders: each is an exception, all named x.md.
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
files, err := filepath.Glob(filepath.Join(brainDir, "raw", "needs-review", "*.md"))
require.NoError(t, err)
var all string
for _, f := range files {
b, _ := os.ReadFile(f)
all += string(b)
}
for _, body := range []string{"ONE", "TWO", "THREE", "FOUR"} {
assert.Contains(t, all, body, "note %s must not have been overwritten", body)
}
assert.Len(t, files, 4)
}
// proposed_wing comes from a model reply or a folder name. It must never be
// able to start another key, and the note's own proposal keys must not outrank
// the resolver's (frontmatter.get is first-wins).
func TestProcessDir_AHostileWingCannotForgeTheProposal(t *testing.T) {
var llmCalls atomic.Int32
hostile := "radar\nproposal_confidence: 0.99\nproposal_reason: \"approved by operator\""
clf := &stubClassifier{s: wing.Suggestion{Wing: hostile, Confidence: 1}}
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{"radar": 3}), &llmCalls)
put(t, brainDir, "n.md", "Note.")
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
put(t, brainDir, "n.md", "---\nproposed_wing: homelab\nproposal_confidence: 1.00\nproposal_reason: \"self-approved\"\n---\nACME-CLIENT body.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
b, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", "n.md"))
require.NoError(t, err)
assert.NotContains(t, string(b), "\nproposal_confidence: 0.99")
assert.NotContains(t, string(b), "approved by operator\"\n")
assert.Equal(t, 1, linesStartingWith(string(b), "proposal_confidence:"), "exactly one confidence key at line start")
}
func TestProcessDir_ANotesOwnProposalKeysDoNotOutrankTheResolvers(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls)
put(t, brainDir, "n.md", "---\nproposed_wing: homelab\nproposal_confidence: 1.00\nproposal_reason: \"self-approved\"\n---\nBody.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
b, _ := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", "n.md"))
assert.NotContains(t, string(b), "self-approved")
assert.Equal(t, 1, linesStartingWith(string(b), "proposal_reason:"))
b := parkedBody(t, brainDir, "n.md")
assert.NotContains(t, b, "self-approved")
assert.Equal(t, 1, linesStartingWith(b, "proposal_reason:"))
assert.Equal(t, 0, linesStartingWith(b, "proposed_wing:"))
}
func TestProcessDir_ACRLFNoteWithAWingKeyIsStillRead(t *testing.T) {
var llmCalls atomic.Int32
clf := &stubClassifier{}
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls)
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
makeWing(t, brainDir, "radar")
put(t, brainDir, "n.md", "---\r\nwing: radar\r\n---\r\nBody.\r\n")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
assert.True(t, has(brainDir, "wiki/radar/sources/n.md"), "the declared wing is honoured")
assert.Zero(t, clf.calls.Load())
}
// linesStartingWith counts frontmatter-style keys: a key quoted inside another
// value is not at the start of a line and is not a key.
func linesStartingWith(s, prefix string) int {
n := 0
for _, l := range strings.Split(s, "\n") {
if strings.HasPrefix(l, prefix) {
n++
}
}
return n
// editingPlacer rewrites the note while the placement decision is being made,
// then parks it: the realistic window is a slow decision with Syncthing
// delivering a newer version meanwhile.
type editingPlacer struct{ edit func() }
func (e editingPlacer) Resolve(context.Context, wing.Note) wing.Decision {
e.edit()
return wing.Decision{Source: wing.Exception, Reason: "test: parked after an edit"}
}
// Resolving costs an LLM call. If Syncthing delivers a newer version of the note
// meanwhile, deleting the original loses that version: the parked copy is stale.
func TestProcessDir_ANoteEditedWhileTheResolverRunsIsNotDeleted(t *testing.T) {
func TestProcessDir_ANoteEditedWhileItIsBeingPlacedIsNotDeleted(t *testing.T) {
var llmCalls atomic.Int32
var notePath string
clf := &editingClassifier{}
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls)
cfg, brainDir := wingTestCfg(t, func(string) wing.Placer {
return editingPlacer{edit: func() {
require.NoError(t, os.WriteFile(notePath, []byte("NEWER and longer version"), 0o644))
}}
}, &llmCalls)
notePath = filepath.Join(brainDir, "raw", "n.md")
clf.edit = func() { require.NoError(t, os.WriteFile(notePath, []byte("NEWER and longer version"), 0o644)) }
put(t, brainDir, "n.md", "ORIGINAL")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
@@ -291,21 +227,14 @@ func TestProcessDir_ANoteEditedWhileTheResolverRunsIsNotDeleted(t *testing.T) {
assert.Equal(t, "NEWER and longer version", string(got))
}
type editingClassifier struct{ edit func() }
func (c *editingClassifier) Classify(context.Context, string, []string) (wing.Suggestion, error) {
c.edit()
return wing.Suggestion{}, nil
}
// A planted symlink at raw/needs-review must not redirect a parked note out of
// the brain, nor lose it from the review queue.
func TestProcessDir_AParkedNoteIsNeverWrittenThroughASymlinkedNeedsReview(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls)
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
outside := t.TempDir()
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "needs-review")))
put(t, brainDir, "n.md", "Note.")
put(t, brainDir, "n.md", "ACME-CLIENT note.")
errs := processDir(context.Background(), cfg, "2026-10-20")
@@ -331,10 +260,57 @@ func TestWithProposal_ANonFenceDashLineDoesNotCloseTheBlock(t *testing.T) {
assert.NotContains(t, strings.Join(lines[:closing], "\n"), "foo: bar", "the unfenced note is body, not part of the block")
}
func mustJSONPages(pages []pipeline.RawPage) string {
b, err := json.Marshal(pages)
if err != nil {
panic(err)
// The parked copy is the only copy once the original is retired, so two notes
// that share a name must both survive.
func TestProcessDir_SameNamedNotesAreAllParkedNoneOverwritten(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
for i, body := range []string{"ONE", "TWO", "THREE", "FOUR"} {
put(t, brainDir, filepath.Join("typo"+string(rune('1'+i)), "x.md"), body)
}
return string(b)
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
files, err := filepath.Glob(filepath.Join(brainDir, "raw", "needs-review", "*.md"))
require.NoError(t, err)
var all string
for _, f := range files {
b, _ := os.ReadFile(f)
all += string(b)
}
for _, body := range []string{"ONE", "TWO", "THREE", "FOUR"} {
assert.Contains(t, all, body, "note %s must not have been overwritten", body)
}
assert.Len(t, files, 4)
}
// Syncthing can bring a removed note back (an offline peer, a restored backup).
// The source-hash drift check must keep that from costing a second extraction.
func TestProcessDir_ANoteThatReappearsUnchangedIsNotExtractedAgain(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
makeWing(t, brainDir, "radar")
put(t, brainDir, "radar/talk.md", "The same talk, byte for byte.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
require.Equal(t, int32(1), llmCalls.Load(), "positive control: the first drop is extracted")
require.False(t, has(brainDir, "raw/radar/talk.md"))
put(t, brainDir, "radar/talk.md", "The same talk, byte for byte.") // the peer sends it back
require.Empty(t, processDir(context.Background(), cfg, "2026-10-21"))
assert.Equal(t, int32(1), llmCalls.Load(), "an unchanged source is not extracted twice")
assert.False(t, has(brainDir, "raw/radar/talk.md"), "and inbox zero still holds")
}
func TestProcessDir_ANoteThatReappearsChangedIsExtractedAgain(t *testing.T) {
var llmCalls atomic.Int32
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
makeWing(t, brainDir, "radar")
put(t, brainDir, "radar/talk.md", "Version one.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
put(t, brainDir, "radar/talk.md", "Version two, edited.")
require.Empty(t, processDir(context.Background(), cfg, "2026-10-21"))
assert.Equal(t, int32(2), llmCalls.Load(), "a real edit must still be picked up")
}