Closes SPEC §3.4. The matrix indexer is now a learning meta-index per
feedback_meta_index_vision.md — every successful (query → answer)
pair recorded via /matrix/playbooks/record boosts that answer for
future similar queries.
This is the architectural piece that lifts vectord from "static
hybrid search" to the meta-index J originally framed in Phase 19 of
the Rust system.
What's new:
- internal/matrix/playbook.go — PlaybookEntry, PlaybookHit,
ApplyPlaybookBoost. Pure-function boost math:
distance' = distance * (1 - 0.5 * score)
Score 0 = no boost (factor 1.0); score 1 = halve distance
(factor 0.5). Capped at 0.5 deliberately so a single high-
confidence playbook can't dominate the base ranking forever
(runaway-feedback-loop guard).
- Retriever.Record(entry, corpus) — embeds query_text, ensures
playbook corpus exists (idempotent), upserts via deterministic
sha256-derived ID (last score wins on re-record of same triple).
- Retriever.Search extended with UsePlaybook + PlaybookCorpus +
PlaybookTopK + PlaybookMaxDistance. Reuses the query vector —
no extra embed call. Missing-corpus 404 = no-op (cold-start
state before any Record call), not an error.
- POST /v1/matrix/playbooks/record (matrixd) — caller submits
{query_text, answer_id, answer_corpus, score, tags?}; gets
{playbook_id} back.
Storage: a vectord index named "playbook_memory" (configurable per
request) with embed(query_text) as the vector and the
PlaybookEntry JSON as metadata. Just another corpus — observable
from /vectors/index, persistable through G1P, etc.
Match key for boost: (AnswerID, AnswerCorpus). Cross-corpus ID
collisions don't false-match — verified by
TestApplyPlaybookBoost_CorpusAttributionRespected.
End-to-end smoke (scripts/playbook_smoke.sh, all assertions PASS):
- Baseline search: widget-c at distance 0.6566 (rank 3)
- Record playbook: query → widget-c, score=1.0
- Re-search with use_playbook=true:
widget-c distance: 0.3283 (rank 2)
ratio: 0.5 EXACTLY (matches boost math precisely)
playbook_boosted: 1
- widget-c jumped from #3 to #2 — learning loop visible
Tests:
- 8 unit tests in internal/matrix/playbook_test.go covering
Validate, BoostFactor (5 cases), the no-boost identity, the
boost-moves-result-up scenario, highest-score wins on duplicate
matches, cross-corpus attribution, JSON round-trip, and
rejection of empty metadata
- scripts/playbook_smoke.sh integration test (3 assertions PASS)
15-smoke regression sweep all green (D1-D6, G1, G1P, G2,
storaged_cap, pathway, matrix, relevance, downgrade, playbook).
SPEC §3.4 NOW COMPLETE: 5 of 5 components shipped. The matrix
indexer's port is done as a substrate; remaining work is operational
(rating signal sources, telemetry, eventual structured filtering for
staffing data — none in §3.4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
197 lines
6.9 KiB
Go
197 lines
6.9 KiB
Go
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)
|
|
}
|