Files
hyperguild/ingestion/internal/wing/router.go
T
mathiasandClaude Sonnet 5.5 6f5fb481ad fix(wing): client filter handles multi-word/\s/\b patterns, homoglyphs, marks, fillers; checked in bounded chunks (review of #211, pass 2)
Views of each chunk: as is; NFKD + marks/format/fillers dropped + Cyrillic/Greek
lookalikes mapped + dashes normalised, with whitespace collapsed (acme corp,
\s) or removed (acme-\nclient); and a soft hyphen read as a dash. Chunks of 1 MiB
with a 4 KiB overlap bound the folding's allocation (it was ~14x the input: 919
MiB for a 64 MiB note). Documented limits: leetspeak, punctuation splits,
encodings and homoglyphs outside a small table. Mutation-checked.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:45:00 +02:00

219 lines
8.0 KiB
Go

package wing
import (
"context"
"fmt"
"regexp"
"slices"
"strings"
"unicode"
xnorm "golang.org/x/text/unicode/norm"
)
// Router places a note with rules only, no model (hyperguild#208):
//
// 1. A subfolder or frontmatter `wing:` is the owner's deliberate choice. It
// must name a plain, EXISTING wing (a typo never creates one) and then it
// places, whatever the wing is: extraction runs on the local model, and it
// is the owner who dropped the note there. Two signals that disagree park.
// 2. With no signal, a note whose text matches ClientBlock is parked; any
// other note goes to Default, the one wing that is created on first use.
//
// Wings are read from disk on every decision, so a wing created while the
// server runs is usable at once.
type Router struct {
BrainDir string
Default string
ClientBlock *regexp.Regexp // nil = no content check
// FilterChunk is how many bytes of a note the content filter folds and
// matches at a time (0 = 1 MiB). Folding allocates several times its input,
// so a 64 MiB note is checked in pieces (with an overlap, so a marker on a
// boundary is still found) instead of all at once.
FilterChunk int
}
// Resolve implements Placer. It never returns an error: anything it cannot
// decide safely is an Exception with the reason spelled out.
func (r Router) Resolve(_ context.Context, n Note) Decision {
for _, raw := range []string{n.Dir, n.FrontmatterWing} {
if !isASCII(raw) {
// strings.ToLower folds some non-ASCII runes to ASCII (the Kelvin
// sign becomes "k"), which would let a lookalike name a real wing.
return exception("", 0, fmt.Sprintf("wing name %q contains non-ASCII characters", strings.TrimSpace(raw)))
}
}
dir, fm := norm(n.Dir), norm(n.FrontmatterWing)
switch {
case dir != "" && fm != "" && dir != fm:
return exception("", 0, fmt.Sprintf("subfolder %q and frontmatter wing %q disagree", dir, fm))
case dir != "":
return r.explicit(dir, "subfolder")
case fm != "":
return r.explicit(fm, "frontmatter")
}
if r.ClientBlock != nil && matchesClient(r.ClientBlock, n.Text, r.FilterChunk) {
return exception("", 0, "text matches the client-content marker; not defaulted into a shared wing")
}
if r.Default == "" || !usableWingName(r.Default) {
return exception("", 0, "no valid default wing configured")
}
return Decision{Wing: r.Default, Source: Default, Confidence: 1, Reason: "no wing named; default wing"}
}
func (r Router) explicit(w, via string) Decision {
if !usableWingName(w) {
return exception("", 0, fmt.Sprintf("%s names %q, which is not a usable wing name", via, w))
}
if w == r.Default {
return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via}
}
known, err := KnownWings(r.BrainDir)
if err != nil {
return exception(w, 0, fmt.Sprintf("cannot list wings: %v", err))
}
if !slices.Contains(known, w) {
return exception(w, 0, fmt.Sprintf("%s names %q: not an existing wing (new wings are never created from a folder name)", via, strings.TrimSpace(w)))
}
return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via}
}
// NewRouter builds the production Router. defaultWing must be a plain wing
// name; clientPattern, when set, is a case-insensitive regexp over note text
// whose match parks an unsignalled note instead of filing it. Both fail at
// startup rather than later: a client filter that silently does nothing is
// worse than none.
func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) {
if defaultWing == "" || !usableWingName(defaultWing) {
return Router{}, fmt.Errorf("default wing %q is not a usable wing name", defaultWing)
}
r := Router{BrainDir: brainDir, Default: defaultWing}
if clientPattern != "" {
re, err := regexp.Compile("(?i)" + clientPattern)
if err != nil {
return Router{}, fmt.Errorf("client-content pattern: %w", err)
}
r.ClientBlock = re
}
return r, nil
}
// reservedWingNames are directory names under wiki/ that are not wings: the
// retired layout and the root. A note must never be proposed into one.
var reservedWingNames = map[string]bool{"sources": true, "concepts": true, "entities": true, "wiki": true}
// usableWingName: a plain name that is not reserved.
func usableWingName(w string) bool { return plainWingName(w) && !reservedWingNames[w] }
func isASCII(s string) bool {
for _, r := range s {
if r > unicode.MaxASCII {
return false
}
}
return true
}
const (
defaultFilterChunk = 1 << 20
filterOverlap = 4096
)
// matchesClient applies the client-content pattern to the text in bounded
// chunks, each overlapping the next, so memory stays proportional to the chunk
// and a marker on a chunk edge is still found.
func matchesClient(re *regexp.Regexp, text string, chunk int) bool {
if chunk <= 0 {
chunk = defaultFilterChunk
}
overlap := filterOverlap
if overlap >= chunk {
overlap = chunk / 2
}
for start := 0; start < len(text); {
end := start + chunk
if end >= len(text) {
end = len(text)
}
if matchesFolded(re, text[start:end]) {
return true
}
if end == len(text) {
return false
}
// A cut inside a rune is harmless: the overlap carries every marker
// shorter than it whole into the next chunk.
start = end - overlap
}
return false
}
// matchesFolded matches the pattern against the chunk as it is and against
// folded forms, so the cheap evasions do not defeat it. Folding: NFKD (fullwidth
// letters, accents become base letter + mark), then marks, format characters and
// invisible fillers dropped, Cyrillic/Greek lookalikes mapped to Latin, every
// dash made "-". Views: whitespace collapsed to one space (so "acme corp" and
// \s work), whitespace removed (so "acme-\nclient" works), and the same with a
// soft hyphen read as a dash. It catches what it names and nothing else:
// leetspeak, punctuation splits (a.c.m.e), encodings and the many homoglyphs
// not in the small table below are not caught. It is a filter, not a classifier.
func matchesFolded(re *regexp.Regexp, chunk string) bool {
if re.MatchString(chunk) {
return true
}
decomposed := xnorm.NFKD.String(chunk)
return re.MatchString(fold(decomposed, false, true)) ||
re.MatchString(fold(decomposed, false, false)) ||
re.MatchString(fold(decomposed, true, false))
}
// fold builds one view. softAsDash reads U+00AD as "-" instead of dropping it;
// keepSpace collapses whitespace to a single space instead of removing it.
func fold(s string, softAsDash, keepSpace bool) string {
var b strings.Builder
b.Grow(len(s))
space := false
for _, r := range s {
switch {
case r == '\u00ad' && softAsDash:
b.WriteRune('-')
space = false
continue
case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Mc, r) ||
unicode.Is(unicode.Cf, r) || invisibleFiller[r]:
continue
case unicode.IsSpace(r) || unicode.Is(unicode.Zs, r):
if keepSpace && !space {
b.WriteByte(' ')
space = true
}
continue
}
space = false
switch {
case unicode.Is(unicode.Pd, r) || r == '\u2212':
b.WriteRune('-')
default:
if l, ok := latinLookalike[r]; ok {
r = l
}
b.WriteRune(r)
}
}
return b.String()
}
// invisibleFiller are characters that render as nothing but are not format
// characters: Hangul fillers and the blank Braille pattern.
var invisibleFiller = map[rune]bool{0x115F: true, 0x1160: true, 0x3164: true, 0xFFA0: true, 0x2800: true}
// latinLookalike maps the commonest Cyrillic and Greek letters that read as
// Latin ones. Deliberately small; see matchesFolded.
var latinLookalike = map[rune]rune{
'\u0430': 'a', '\u0435': 'e', '\u043e': 'o', '\u0440': 'p', '\u0441': 'c', '\u0445': 'x', '\u0443': 'y',
'\u0456': 'i', '\u0458': 'j', '\u0455': 's', '\u04bb': 'h', '\u0501': 'd', '\u043a': 'k', '\u043d': 'h', '\u0442': 't',
'\u0410': 'A', '\u0412': 'B', '\u0415': 'E', '\u041a': 'K', '\u041c': 'M', '\u041d': 'H', '\u041e': 'O', '\u0420': 'P', '\u0421': 'C', '\u0422': 'T', '\u0425': 'X',
'\u03bf': 'o', '\u03bd': 'v', '\u03c1': 'p', '\u0391': 'A', '\u0392': 'B', '\u0395': 'E', '\u039f': 'O', '\u03a1': 'P',
}