// Package wing decides which brain wing an incoming raw note belongs to // without the human typing it (hyperguild#205). // // The contract is conservative: a note is placed automatically only when the // decision is explicit, or when two independent opinions (an LLM and a // deterministic scorer) agree with enough confidence on a wing that is both // known and eligible. Everything else is an Exception for a human to approve. // A wing is never created here, and a wing the caller does not mark eligible // (client wings, anything not deliberately tagged) is never auto-assigned. package wing import ( "context" "fmt" "strings" ) // Source says how a Decision was reached. type Source string const ( Explicit Source = "explicit" // subfolder or frontmatter named the wing Classified Source = "classified" // classifier + scorer agreed, confident Exception Source = "exception" // needs a human ) // Note is what the resolver sees of an incoming raw note. type Note struct { Dir string // first subfolder under raw/, "" when dropped at the top FrontmatterWing string // `wing:` from the note's frontmatter, "" if absent Text string } // Suggestion is a Classifier's answer. An empty Wing means "none fits". type Suggestion struct { Wing string Confidence float64 // 0..1 Reason string } // Classifier proposes a wing from the candidates. Implementations must run on // a local model; the resolver cannot enforce that. type Classifier interface { Classify(ctx context.Context, text string, candidates []string) (Suggestion, error) } // Scorer is an independent, deterministic opinion (e.g. BM25 against each // wing's index). Higher is better; the key is the wing name. type Scorer interface { Score(text string) map[string]float64 } // Decision is the outcome. For an Exception, Wing holds the proposal when // there was one (so a reviewer can approve it) and is empty otherwise. type Decision struct { Wing string Source Source Confidence float64 Reason string } // Resolver holds the policy. Known is every existing wing; Eligible reports // whether a known wing may be assigned automatically. type Resolver struct { Known []string Eligible func(wing string) bool Classifier Classifier Scorer Scorer Threshold float64 } // Resolve never returns an error: anything it cannot decide safely is an // Exception with the reason spelled out. func (r Resolver) Resolve(ctx context.Context, n Note) Decision { dir := norm(n.Dir) fm := 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") } return r.classify(ctx, n) } func (r Resolver) explicit(w, via string) Decision { if why := r.refuse(w); why != "" { return exception(w, 0, fmt.Sprintf("%s names %q: %s", via, w, why)) } return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via} } func (r Resolver) classify(ctx context.Context, n Note) Decision { if r.Classifier == nil { return exception("", 0, "no classifier configured") } s, err := r.Classifier.Classify(ctx, n.Text, r.Known) if err != nil { return exception("", 0, fmt.Sprintf("classifier failed: %v", err)) } w := norm(s.Wing) if w == "" { return exception("", s.Confidence, "classifier found no fitting wing") } if why := r.refuse(w); why != "" { return exception(w, s.Confidence, fmt.Sprintf("classifier proposed %q: %s", w, why)) } if s.Confidence < r.Threshold { return exception(w, s.Confidence, fmt.Sprintf("confidence %.2f below threshold %.2f", s.Confidence, r.Threshold)) } top, ok := r.scorerTop(n.Text) if !ok { return exception(w, s.Confidence, "scorer has no clear opinion to cross-check against") } if top != w { return exception(w, s.Confidence, fmt.Sprintf("classifier says %q, scorer says %q", w, top)) } return Decision{Wing: w, Source: Classified, Confidence: s.Confidence, Reason: fmt.Sprintf("classifier and scorer agree (%.2f): %s", s.Confidence, s.Reason)} } // refuse returns why a wing may not be auto-assigned, or "" if it may. func (r Resolver) refuse(w string) string { if !r.known(w) { return "not an existing wing (new wings are never created automatically)" } if r.Eligible == nil || !r.Eligible(w) { return "not eligible for automatic assignment" } return "" } func (r Resolver) known(w string) bool { for _, k := range r.Known { if norm(k) == w { return true } } return false } // scorerTop returns the single best-scoring wing; a missing scorer, no scores, // or a tie at the top is "no clear opinion". func (r Resolver) scorerTop(text string) (string, bool) { if r.Scorer == nil { return "", false } return TopWing(r.Scorer.Score(text)) } // TopWing returns the unique highest-scoring wing in scores, or false when // there is none or the top is tied. Shared by the Resolver and the backtest so // both apply the same notion of "the scorer has an opinion". func TopWing(scores map[string]float64) (string, bool) { best, bestScore, tied := "", 0.0, false for k, v := range scores { switch { case v > bestScore: best, bestScore, tied = norm(k), v, false case v == bestScore && v > 0: tied = true } } if best == "" || tied { return "", false } return best, true } func exception(w string, conf float64, reason string) Decision { return Decision{Wing: w, Source: Exception, Confidence: conf, Reason: reason} } func norm(s string) string { return strings.ToLower(strings.TrimSpace(s)) }