package matrix // Playbook memory — SPEC §3.4 component 5 (learning-loop integration). // // Concept: every time an external system confirms "(query → answer_id) // was a successful match," record it. Future similar queries get that // answer's score boosted, so the matrix indexer learns from outcomes // rather than relying solely on the base embedder's geometry. // // Per feedback_meta_index_vision.md: this is the north star — a // meta-index that LEARNS from playbooks over time, not a static // hybrid search engine. // // Storage shape: a vectord index named DefaultPlaybookCorpus where: // - The vector is embed(query_text) // - The metadata is a serialized PlaybookEntry // Retrieval shape: at /matrix/search time, when use_playbook=true, // matrixd searches the playbook corpus with the same query vector, // looks up each hit's answer_id, and if that answer is in the current // matrix-search results, applies a boost to its distance. // // Composition: this layer is additive on top of the existing // retrieve+merge — when use_playbook=false, behavior is unchanged. // The boost only re-ranks results that ALREADY surfaced from the // regular retrieval. A v1 enhancement would inject playbook hits // directly even when they weren't in the top-K (Shape B from the // design conversation), but v0 keeps the safer "boost-only" stance. import ( "encoding/json" "errors" "sort" "time" ) // DefaultPlaybookCorpus is the vectord index name where playbook // entries land by default. Callers can override per-request, but // having one default makes the system observable from the outside // (operator hits /vectors/index and sees this corpus in the list). const DefaultPlaybookCorpus = "playbook_memory" // DefaultPlaybookTopK is how many similar past queries to consider // when applying boost. 3 keeps the influence focused — we want the // boost to reward consistent matches, not let one stale playbook // dominate. Caller can override. const DefaultPlaybookTopK = 3 // DefaultPlaybookMaxDistance is the cosine ceiling for "this past // query is similar enough to count." 0.5 lets in genuinely related // queries while excluding pure-coincidence neighbors. Caller can // override per-request as we learn what works for staffing data. const DefaultPlaybookMaxDistance = 0.5 // PlaybookEntry is what gets stored as metadata on each playbook // vector. RecordedAt is captured at write time; callers should not // set it (the recorder fills it in). type PlaybookEntry struct { QueryText string `json:"query_text"` AnswerID string `json:"answer_id"` AnswerCorpus string `json:"answer_corpus"` Score float64 `json:"score"` // 0..1; higher = better outcome RecordedAtNs int64 `json:"recorded_at_ns"` Tags []string `json:"tags,omitempty"` } // Validate returns an error if the entry is missing required fields. // Callers should validate before storage so bad data doesn't pollute // the corpus. func (p PlaybookEntry) Validate() error { if p.QueryText == "" { return errors.New("playbook: query_text required") } if p.AnswerID == "" { return errors.New("playbook: answer_id required") } if p.AnswerCorpus == "" { return errors.New("playbook: answer_corpus required") } if p.Score < 0 || p.Score > 1 { return errors.New("playbook: score must be in [0, 1]") } return nil } // BoostFactor returns the multiplier applied to a result's distance // when this playbook entry matches it. Lower is better: // // score = 0 → 1.0 (no boost) // score = 0.5 → 0.75 (mild boost) // score = 1.0 → 0.5 (halve the distance — strong boost) // // Math: 1 - 0.5*score. Capped to [0.5, 1.0] for safety. // // Why halving as the maximum boost: a perfect-confidence playbook // entry shouldn't completely override the base embedding (that // invites runaway feedback loops where one early playbook // dominates forever). Halving is enough to move a mid-rank result // to the top in most cases without erasing the base ranking // signal. func (p PlaybookEntry) BoostFactor() float64 { score := p.Score if score < 0 { score = 0 } if score > 1 { score = 1 } return 1.0 - 0.5*score } // MarshalMetadata serializes the entry as the JSON RawMessage that // vectord stores per item. Convenience for the recorder. func (p PlaybookEntry) MarshalMetadata() (json.RawMessage, error) { return json.Marshal(p) } // UnmarshalPlaybookMetadata is the inverse — used when fetching // playbook hits to decode their metadata back into entries. func UnmarshalPlaybookMetadata(raw json.RawMessage) (PlaybookEntry, error) { var e PlaybookEntry if len(raw) == 0 { return e, errors.New("playbook: empty metadata") } if err := json.Unmarshal(raw, &e); err != nil { return e, err } return e, nil } // NewPlaybookEntry stamps RecordedAtNs to now and returns the entry. // Validation happens at storage; this is just construction. func NewPlaybookEntry(query, answerID, answerCorpus string, score float64, tags []string) PlaybookEntry { return PlaybookEntry{ QueryText: query, AnswerID: answerID, AnswerCorpus: answerCorpus, Score: score, RecordedAtNs: time.Now().UnixNano(), Tags: tags, } } // PlaybookHit is one similarity-search result from the playbook // corpus, paired with its decoded entry. Distance is the cosine // distance between the current query and this past playbook's // query vector — used by the caller to filter out "too far" // matches via PlaybookMaxDistance. type PlaybookHit struct { PlaybookID string `json:"playbook_id"` Distance float32 `json:"distance"` Entry PlaybookEntry `json:"entry"` } // ApplyPlaybookBoost re-ranks results in place using matched // playbook hits. For each hit whose (AnswerID, AnswerCorpus) // matches a result, multiply that result's distance by the hit's // BoostFactor. If multiple hits match the same result, the highest- // score one wins (greatest reduction in distance). // // After applying boosts, results are re-sorted ascending by // distance. // // Returns the number of distinct results that received a boost. // Callers can log this as a signal of "how much the playbook // influenced this query." func ApplyPlaybookBoost(results []Result, hits []PlaybookHit) int { if len(hits) == 0 || len(results) == 0 { return 0 } // For each result, find the hit with the lowest BoostFactor // (= largest boost = highest score, since BoostFactor is // 1-0.5*score and we minimize). bestBoost := make(map[int]float64, len(results)) for i, r := range results { for _, h := range hits { if h.Entry.AnswerID != r.ID || h.Entry.AnswerCorpus != r.Corpus { continue } bf := h.Entry.BoostFactor() if cur, ok := bestBoost[i]; !ok || bf < cur { bestBoost[i] = bf } } } for i, bf := range bestBoost { results[i].Distance = float32(float64(results[i].Distance) * bf) } sort.SliceStable(results, func(i, j int) bool { return results[i].Distance < results[j].Distance }) return len(bestBoost) }