Skip to main content

ratel_ai_core/
usage.rs

1//! The usage-ranking read model: clusters of past queries, each carrying
2//! weighted edges to the capabilities users actually invoked after them
3//! (ADR-0014).
4//!
5//! A query is matched to at most one cluster, and that cluster's capabilities
6//! become an extra ranked arm for [`crate::fusion::rrf_fuse_weighted`] beside
7//! BM25 and dense retrieval. Two things follow from that and are easy to lose:
8//!
9//! - **Only the arm's *order* is used.** Edge weights choose the order and are
10//!   then discarded; RRF fuses on rank position, so a weight never has to be
11//!   reconciled with a BM25 or cosine score.
12//! - **A miss produces no arm at all**, not a zero-weighted one. A query that
13//!   matches nothing ranks bit-identically to a registry with no graph.
14//!
15//! Matching has two tiers, because the graph must work on a `Bm25` catalog that
16//! has no embedder ([`crate::SearchMethod`], ADR-0011):
17//!
18//! - [`IntentGraph::arm_dense`] — cosine against a cluster's stored centroid.
19//!   Groups phrasings that share no words. Used by semantic/hybrid, where the
20//!   query embedding was already computed for the dense arm, so it costs nothing.
21//! - [`IntentGraph::arm_lexical`] — token overlap against a cluster's member
22//!   bag. No model is ever loaded. Reaches repeats and near-repeats only; it
23//!   cannot connect "why is the build broken" to "did CI pass".
24//!
25//! The wire shape is `protocol/v1/schema/intent-graph.schema.json`; this is its
26//! consumer. An edge weight is a plain count of confirmed invocations: it orders
27//! the arm and nothing more, since RRF then fuses on rank position.
28
29use std::collections::BTreeMap;
30use std::sync::Mutex;
31
32use serde::{Deserialize, Serialize};
33
34use crate::fusion::sort_and_truncate;
35
36/// Fraction of the usage arm's full weight granted per unit of support, capped
37/// at 1.0 once `SUPPORT_FULL` observations agree. One confirmed observation
38/// nudges the ranking; it must never dictate it, or a single misclick becomes
39/// policy (ADR-0014).
40pub(crate) const SUPPORT_FULL: u32 = 3;
41
42/// The usage arm's full weight, relative to the BM25/dense arms at 1.0.
43///
44/// **Deliberately below 1.0**: at the same rank, a capability the query
45/// lexically matched outranks one only usage history supports. The arm still
46/// promotes a deeply-ranked capability past another arm's top hit, because that
47/// id accumulates from both arms — sub-unit damps the arm without disabling it.
48/// Like `BM25_K1` / `RRF_K`, this is fixed tuning, not a public knob (ADR-0004).
49pub(crate) const USAGE_WEIGHT: f32 = 0.5;
50
51/// Minimum cosine between a query and a cluster centroid to count as a match.
52pub(crate) const TAU_COSINE: f32 = 0.70;
53
54/// Minimum Jaccard overlap between a query and a cluster's closest single
55/// member for a lexical match — `|q ∩ m| / |q ∪ m|`.
56///
57/// Scored per member rather than against the members' union: a union only
58/// grows, so union scoring let a mature cluster absorb unrelated asks and grow
59/// further still. Per-member scoring reaches repeats and near-repeats, which is
60/// this tier's documented ceiling (ADR-0014) — distant wording is the dense
61/// tier's job.
62pub(crate) const TAU_LEXICAL: f32 = 0.5;
63
64/// How many c-TF-IDF terms a cluster's display label carries.
65const MAX_TERMS: usize = 5;
66
67const MS_PER_DAY: f64 = 86_400_000.0;
68
69/// `skip_serializing_if` predicate for count fields that default to zero.
70fn is_zero(n: &u32) -> bool {
71    *n == 0
72}
73
74/// A cluster keeps full weight for this long after its last use, then decays.
75/// Recent work should not be discounted at all; only topics that have genuinely
76/// gone quiet fade (ADR-0014, blocker #3).
77const RECENCY_GRACE_DAYS: f64 = 90.0;
78
79/// After the grace period, the recency factor halves every this many days —
80/// gentle: a topic idle for a year still weighs ~0.12, only near-zero by ~2y.
81const RECENCY_HALF_LIFE_DAYS: f64 = 90.0;
82
83/// A cluster whose recency factor falls below this is evicted on the next
84/// observation — it no longer boosts, and dropping it bounds cluster count (the
85/// search cost) and memory. `0.01` ≈ idle ~2 years at the defaults above.
86const EVICTION_FLOOR: f32 = 0.01;
87
88/// Cap on members kept per cluster. Bounds the lexical token bags and per-cluster
89/// memory; the centroid is a running mean and is unaffected by dropping members.
90const MEMBER_CAP: usize = 50;
91
92/// Recency weight for a cluster last touched at `last_ts`, evaluated against the
93/// graph's newest observed event `now_ts`. `1.0` within the grace period, then
94/// `2^(−(Δdays − grace)/half_life)`.
95///
96/// Measured against the newest **observed** event, not the wall clock, so the
97/// graph stays a pure function of its trace log — a topic fades relative to how
98/// much other activity has happened since, and an idle graph does not decay.
99fn recency_factor(now_ts: u64, last_ts: u64) -> f32 {
100    let dt_days = now_ts.saturating_sub(last_ts) as f64 / MS_PER_DAY;
101    if dt_days <= RECENCY_GRACE_DAYS {
102        return 1.0;
103    }
104    2f64.powf(-(dt_days - RECENCY_GRACE_DAYS) / RECENCY_HALF_LIFE_DAYS) as f32
105}
106
107/// The effective weight of the usage arm for a cluster with `support`
108/// observations: `USAGE_WEIGHT · min(1, support / SUPPORT_FULL)`.
109pub(crate) fn usage_weight(support: u32) -> f32 {
110    let ramp = (support as f32 / SUPPORT_FULL as f32).min(1.0);
111    USAGE_WEIGHT * ramp
112}
113
114/// One confirmed observation to fold into a graph — a query, and the capability
115/// invoked after it.
116///
117/// A struct rather than positional arguments because the two booleans read
118/// identically at a call site and mean very different things: one is "this
119/// search was acted on", the other is "this evidence came from a seeding pass".
120#[derive(Debug, Clone, Copy)]
121pub(crate) struct Observation<'a> {
122    /// The query text the invocation is attributed to — the cluster match key.
123    pub query: &'a str,
124    /// Which edge map the invoked capability belongs to.
125    pub kind: Capability,
126    /// The capability that was invoked.
127    pub capability_id: &'a str,
128    /// When it happened, epoch-millis. Records how current the graph is and
129    /// drives recency; never affects ranking order directly.
130    pub ts_ms: u64,
131    /// Whether this is the search's **first** confirming invoke — the only kind
132    /// that raises `support`. Later invokes of the same question add edges only.
133    pub first_confirmation: bool,
134    /// Whether this came from a seeding pass (a baseline capture or a replay)
135    /// rather than live serving traffic. Recorded on
136    /// [`Intent::seeded_support`]; never reaches ranking.
137    pub seeded: bool,
138}
139
140/// Which edge map of a cluster to rank.
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub(crate) enum Capability {
143    /// Rank the cluster's `tools` edges.
144    Tool,
145    /// Rank the cluster's `skills` edges.
146    Skill,
147}
148
149/// A matched cluster's contribution to one search: the capabilities it
150/// remembers, best-first, plus what is needed to weight and trace the arm.
151#[derive(Debug, Clone, PartialEq)]
152pub(crate) struct UsageArm {
153    /// Id of the cluster that matched — carried into `TraceEvent::UsageBoost`.
154    pub intent_id: String,
155    /// How well the query matched: cosine against the centroid on the dense
156    /// tier, the best per-member Jaccard overlap on the lexical one. Both are in
157    /// `[0, 1]`, but they are **different scales** — compare within a tier, not
158    /// across. Reported so near-misses are visible, not just hits.
159    pub similarity: f32,
160    /// The cluster's observation count. Sets the confidence ramp of the weight
161    /// and is reported on the trace event; the final weight also folds in
162    /// recency (see [`Self::weight`]).
163    pub support: u32,
164    /// The arm's full fusion weight — the support ramp times the cluster's
165    /// recency factor, precomputed at match time because recency needs the
166    /// graph's newest-event anchor.
167    pub weight: f32,
168    /// Capability ids, best-first. Already filtered to ids the registry knows.
169    pub ids: Vec<String>,
170    /// How many of the cluster's ids were dropped because the registry no
171    /// longer defines them. Never reaches the fusion — it is carried so
172    /// `TraceEvent::UsageBoost` can report catalog drift.
173    pub dropped: u32,
174}
175
176/// What a query's usage lookup produced.
177///
178/// Distinguishes the two ways a search ends up with no arm, which a bare
179/// `Option<UsageArm>` collapsed into one. Both leave ranking untouched, but they
180/// are different problems: [`Self::NoMatch`] means the graph does not cover the
181/// question, while [`Self::AllFiltered`] means it does and the *catalog* has
182/// moved out from under it.
183#[derive(Debug, Clone, PartialEq)]
184pub(crate) enum ArmOutcome {
185    /// No cluster cleared the match threshold.
186    NoMatch,
187    /// A cluster matched, but every capability it remembers of this kind names
188    /// an id the registry does not define.
189    AllFiltered {
190        /// The cluster that matched.
191        intent_id: String,
192        /// How well it matched.
193        similarity: f32,
194        /// Its observation count.
195        support: u32,
196        /// How many ids were dropped — all of them, by definition.
197        dropped: u32,
198    },
199    /// A cluster matched and contributed ids to the fusion.
200    Armed(UsageArm),
201}
202
203impl ArmOutcome {
204    /// The arm, if one was produced — the view the fusion path takes, and the
205    /// reason adding the outcome distinction leaves ranking bit-identical.
206    pub(crate) fn into_arm(self) -> Option<UsageArm> {
207        match self {
208            ArmOutcome::Armed(arm) => Some(arm),
209            _ => None,
210        }
211    }
212
213    /// `(intent, similarity, support, promoted, dropped)` for
214    /// [`crate::TraceEvent::UsageBoost`].
215    pub(crate) fn describe(&self) -> (Option<String>, f64, u32, u32, u32) {
216        match self {
217            ArmOutcome::NoMatch => (None, 0.0, 0, 0, 0),
218            ArmOutcome::AllFiltered {
219                intent_id,
220                similarity,
221                support,
222                dropped,
223            } => (
224                Some(intent_id.clone()),
225                *similarity as f64,
226                *support,
227                0,
228                *dropped,
229            ),
230            ArmOutcome::Armed(a) => (
231                Some(a.intent_id.clone()),
232                a.similarity as f64,
233                a.support,
234                a.ids.len() as u32,
235                a.dropped,
236            ),
237        }
238    }
239
240    /// Prefer an armed outcome, then a drift report, then a plain miss — used
241    /// where a dense attempt falls through to a lexical one and only the more
242    /// informative of the two failures is worth reporting.
243    fn or_else(self, next: impl FnOnce() -> ArmOutcome) -> ArmOutcome {
244        match self {
245            ArmOutcome::Armed(_) => self,
246            _ => match next() {
247                ArmOutcome::NoMatch => self,
248                other => other,
249            },
250        }
251    }
252}
253
254/// Sugar for the many tests that predate [`Observation`] and mean "a live
255/// observation" — the default provenance. Keeps those call sites reading as the
256/// behaviour they assert rather than as struct literals.
257#[cfg(test)]
258impl IntentGraph {
259    fn observe_live(
260        &mut self,
261        query: &str,
262        kind: Capability,
263        capability_id: &str,
264        ts_ms: u64,
265        first_confirmation: bool,
266    ) {
267        self.observe(Observation {
268            query,
269            kind,
270            capability_id,
271            ts_ms,
272            first_confirmation,
273            seeded: false,
274        });
275    }
276}
277
278/// `Option`-shaped sugar for the tests that predate the outcome distinction.
279/// They assert on *whether an arm reached the fusion*, which is exactly what
280/// these expose; the `NoMatch`/`AllFiltered` split is asserted separately.
281#[cfg(test)]
282impl ArmOutcome {
283    fn expect(self, msg: &str) -> UsageArm {
284        self.into_arm().expect(msg)
285    }
286
287    fn unwrap(self) -> UsageArm {
288        self.into_arm().expect("expected an arm")
289    }
290
291    fn is_none(&self) -> bool {
292        !matches!(self, ArmOutcome::Armed(_))
293    }
294
295    fn is_some(&self) -> bool {
296        matches!(self, ArmOutcome::Armed(_))
297    }
298}
299
300impl UsageArm {
301    /// This arm's fusion weight — the support ramp times recency, precomputed
302    /// when the arm was built.
303    pub(crate) fn weight(&self) -> f32 {
304        self.weight
305    }
306}
307
308/// A graph that could not be adopted.
309#[derive(Debug, Clone, PartialEq, Eq)]
310pub enum IntentGraphError {
311    /// The bytes were not the expected JSON shape, or a value broke a semantic
312    /// rule of the wire contract (e.g. a zero-support cluster, a duplicate intent
313    /// id) that the shape alone cannot enforce.
314    Malformed(String),
315    /// The graph declares a schema version this build does not know. A consumer
316    /// rejects rather than degrading, since an unknown version may have changed
317    /// what the fields mean.
318    UnsupportedVersion(u32),
319}
320
321impl std::fmt::Display for IntentGraphError {
322    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
323        match self {
324            IntentGraphError::Malformed(e) => write!(f, "malformed intent graph: {e}"),
325            IntentGraphError::UnsupportedVersion(v) => {
326                write!(
327                    f,
328                    "unsupported intent graph version {v} (this build reads 1)"
329                )
330            }
331        }
332    }
333}
334
335impl std::error::Error for IntentGraphError {}
336
337/// The schema version this build reads.
338const GRAPH_VERSION: u32 = 1;
339
340/// The most recent query and its embedding, stashed by the search path so the
341/// learner can grow a real centroid.
342///
343/// Transient scratch, **not part of the graph's value**: skipped on the wire,
344/// empty after a clone, and ignored by equality — two graphs that differ only
345/// here are the same graph. It lives on [`IntentGraph`] because the search path
346/// and the learner share nothing else, and it is a `Mutex` so the search path
347/// can write it while holding only a read lock.
348#[derive(Debug, Default)]
349struct PendingQuery(Mutex<Option<(String, Vec<f32>, String)>>);
350
351impl Clone for PendingQuery {
352    /// A clone starts empty: a half-finished search is not worth copying.
353    fn clone(&self) -> Self {
354        Self::default()
355    }
356}
357
358impl PartialEq for PendingQuery {
359    fn eq(&self, _: &Self) -> bool {
360        true
361    }
362}
363
364impl PendingQuery {
365    fn set(&self, query: &str, vector: &[f32], fingerprint: &str) {
366        if let Ok(mut slot) = self.0.lock() {
367            *slot = Some((query.to_string(), vector.to_vec(), fingerprint.to_string()));
368        }
369    }
370
371    /// The stashed vector and the fingerprint of the model that produced it, but
372    /// **only if it belongs to `query`**. Reads without clearing: several invokes
373    /// may follow one search, and each needs to see it.
374    ///
375    /// Sessions share a graph, so a concurrent search can overwrite the slot
376    /// between one session's search and its invoke. Keying by the query text
377    /// means a clobbered slot degrades to lexical clustering rather than
378    /// attaching one session's embedding to another's question.
379    fn vector_for(&self, query: &str) -> Option<(Vec<f32>, String)> {
380        let slot = self.0.lock().ok()?;
381        match slot.as_ref() {
382            Some((q, v, fp)) if q == query => Some((v.clone(), fp.clone())),
383            _ => None,
384        }
385    }
386}
387
388/// Which query is currently owed a support credit, and whether an invoke has
389/// already claimed it. Lives on the shared graph — not the learner — so the
390/// per-registry tool and skill learners that a `search_capabilities` fan-out
391/// drives (same query, two learners) credit **one** observation between them,
392/// not one each. A `Mutex` so a search can arm it while holding only a read
393/// lock, mirroring [`PendingQuery`].
394///
395/// Identity is the **query text**, and there is one slot per graph — the same
396/// single-slot, best-effort posture as [`PendingQuery`]. This is exact for the
397/// fan-out it targets (one question, two catalogs, searches before invokes),
398/// but it cannot distinguish that from two *concurrent* sessions that ask the
399/// same text and each resolve a different catalog into the same cluster: those
400/// share the one slot and credit once, an under-count. The trade is deliberate
401/// — it removes the systematic over-count on every fanned-out question at the
402/// cost of a rare, order-of-magnitude-smaller concurrent edge, and it errs
403/// conservative (under-, not over-count, and support caps regardless). Making
404/// concurrent same-text sessions exact needs a per-turn correlation id threaded
405/// through the trace events, deferred as not worth the plumbing.
406#[derive(Debug, Default)]
407struct CreditSlot(Mutex<Option<(String, bool)>>);
408
409impl Clone for CreditSlot {
410    fn clone(&self) -> Self {
411        Self::default()
412    }
413}
414
415impl PartialEq for CreditSlot {
416    fn eq(&self, _: &Self) -> bool {
417        true
418    }
419}
420
421impl CreditSlot {
422    /// Arm `query` for a support credit. Called on every search; re-arming with
423    /// the same text before any invoke is idempotent, so a fanned-out capability
424    /// search still yields a single credit.
425    fn arm(&self, query: &str) {
426        if let Ok(mut slot) = self.0.lock() {
427            *slot = Some((query.to_string(), false));
428        }
429    }
430
431    /// `true` for the first invoke of an armed `query` — and marks it claimed so
432    /// later invokes of the same question (a tool *and* a skill) do not re-credit.
433    /// Keyed by query text: a slot clobbered by another session's search reads as
434    /// "not first" rather than crediting the wrong question.
435    fn claim(&self, query: &str) -> bool {
436        let Ok(mut slot) = self.0.lock() else {
437            return false;
438        };
439        match slot.as_mut() {
440            Some((q, credited)) if q == query && !*credited => {
441                *credited = true;
442                true
443            }
444            _ => false,
445        }
446    }
447}
448
449/// One cluster: the queries it covers and the capabilities invoked after them.
450///
451/// `label` and `terms` are **derived**, not stored: they are computed from the
452/// members at read time and deliberately excluded from equality. c-TF-IDF scores
453/// a term against *the other clusters*, so a value frozen when this cluster was
454/// last written is wrong the moment another cluster appears.
455#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct Intent {
457    /// Cluster id, unique within the graph. Opaque — it names a row.
458    pub id: String,
459    /// Display name (the medoid member). Never affects ranking.
460    pub label: String,
461    /// Distinguishing keywords. Never affects ranking.
462    #[serde(default)]
463    pub terms: Vec<String>,
464    /// The texts this cluster covers — **the match key**.
465    pub members: Vec<String>,
466    /// Optional precomputed L2-normalized mean of the members' embeddings.
467    /// Absent when the producer clustered lexically.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub centroid: Option<Vec<f32>>,
470    /// Confirmed search-then-invoke observations behind this cluster.
471    pub support: u32,
472    /// How many of [`Self::support`] came from a **seeding** pass — a baseline
473    /// capture or a trace replay — rather than from live serving traffic.
474    ///
475    /// Provenance only: nothing reads it during ranking, and two graphs
476    /// differing only here rank identically and compare equal. It exists so a
477    /// caller can see how much of a cluster's confidence rests on seeded
478    /// evidence, and discount it if the seed turns out to have taught something
479    /// wrong.
480    ///
481    /// Invariant: `seeded_support <= support`, enforced on load. Omitted from
482    /// the wire form when zero, so a live-only graph serializes byte-identically
483    /// to one produced before this field existed.
484    #[serde(default, skip_serializing_if = "is_zero")]
485    pub seeded_support: u32,
486    /// Epoch-millis of this cluster's most recent observation. Drives the
487    /// recency factor and eviction; `0` (default) means "as old as the graph".
488    #[serde(default)]
489    pub last_ts: u64,
490    /// Tool id → count of confirmed invocations. Orders the arm; the
491    /// magnitude is discarded by the fusion.
492    #[serde(default)]
493    pub tools: BTreeMap<String, f32>,
494    /// Skill id → count of confirmed invocations. Orders the arm; the
495    /// magnitude is discarded by the fusion.
496    #[serde(default)]
497    pub skills: BTreeMap<String, f32>,
498    /// Tokens of each member, positionally parallel to `members`.
499    ///
500    /// Matching scores a query against **individual members**, not their union:
501    /// the union only grows, so scoring against it made a mature cluster
502    /// recognize most of the vocabulary and absorb unrelated asks, which grew it
503    /// further (ADR-0014). Derived from `members`, so never serialized and never
504    /// part of identity.
505    #[serde(skip)]
506    member_bags: Vec<std::collections::HashSet<String>>,
507    /// Every distinct content token across `members`, cached — retained as a
508    /// cheap prefilter for [`Self::lexical_score`], not as the score itself.
509    ///
510    /// Derived from `members` and kept in step with them, so it is never
511    /// serialized and never part of identity. It exists because lexical
512    /// matching needs this set on **every search**, and rebuilding it from the
513    /// member strings each time cost ~99% of that search — the set does not
514    /// change between searches, so it is built once and extended in place.
515    /// Rebuilt after deserialization by [`IntentGraph::rebuild_caches`].
516    #[serde(skip)]
517    bag: std::collections::HashSet<String>,
518    /// How many query vectors have been folded into `centroid` — the weight of
519    /// the running mean in [`Self::absorb_vector`]. Distinct from `members.len()`
520    /// because a cluster can gain members lexically (no vector), which must not
521    /// inflate the weight. Live-learning scratch: not serialized (a reloaded
522    /// centroid is treated as a single prior sample), so never part of identity.
523    #[serde(skip)]
524    vector_n: u32,
525}
526
527/// Identity is the evidence — members, centroid, support, edges. The derived
528/// display fields are ignored, so a graph compares equal to its own round-trip
529/// whether or not labels have been materialized.
530impl PartialEq for Intent {
531    fn eq(&self, other: &Self) -> bool {
532        self.id == other.id
533            && self.members == other.members
534            && self.centroid == other.centroid
535            && self.support == other.support
536            && self.tools == other.tools
537            && self.skills == other.skills
538    }
539}
540
541impl Intent {
542    fn edges(&self, kind: Capability) -> &BTreeMap<String, f32> {
543        match kind {
544            Capability::Tool => &self.tools,
545            Capability::Skill => &self.skills,
546        }
547    }
548
549    /// The cluster's capabilities of `kind`, best-first, dropping any id the
550    /// registry does not currently define, plus **how many were dropped**.
551    /// Ordered `(weight desc, id asc)` — the same total order the rankers use,
552    /// so the arm is deterministic.
553    ///
554    /// The drop count is returned rather than discarded because an arm that
555    /// loses every id is indistinguishable, from the outside, from a cluster
556    /// that never matched — see [`ArmOutcome`].
557    fn ranked(&self, kind: Capability, known: &dyn Fn(&str) -> bool) -> (Vec<String>, u32) {
558        let edges = self.edges(kind);
559        let mut ranked: Vec<(String, f32)> = edges
560            .iter()
561            .filter(|(id, _)| known(id.as_str()))
562            .map(|(id, w)| (id.clone(), *w))
563            .collect();
564        let dropped = (edges.len() - ranked.len()) as u32;
565        let len = ranked.len();
566        sort_and_truncate(&mut ranked, len);
567        (ranked.into_iter().map(|(id, _)| id).collect(), dropped)
568    }
569
570    /// Fold `vector` into this cluster's centroid as a running mean over its
571    /// members, renormalized so cosine stays a plain dot product.
572    ///
573    /// The mean of unit vectors falls inside the sphere, so skipping the
574    /// renormalize would depress every later similarity by the cluster's own
575    /// spread. A first vector — or one of a different width, meaning the
576    /// embedding model changed — replaces the centroid rather than being
577    /// averaged into a space it does not share.
578    fn absorb_vector(&mut self, vector: &[f32]) {
579        let merged: Vec<f32> = match self.centroid.as_deref() {
580            // Weight the running mean by vectors ALREADY folded (`vector_n`), not
581            // by `members.len()`: a cluster can gain members lexically with no
582            // vector, and counting those would pin the centroid to the first
583            // vector after such growth (it would arrive with weight ~n).
584            Some(c) if c.len() == vector.len() => {
585                let k = self.vector_n.max(1) as f32;
586                c.iter().zip(vector).map(|(c, v)| c * k + v).collect()
587            }
588            // A first vector — or one of a different width (the embedding model
589            // changed) — starts the mean fresh rather than blending across spaces.
590            _ => {
591                self.vector_n = 0;
592                vector.to_vec()
593            }
594        };
595        self.vector_n = self.vector_n.saturating_add(1);
596        self.centroid = Some(normalize(merged));
597    }
598
599    /// Drop the oldest members past [`MEMBER_CAP`], keeping the token caches in
600    /// step. Bounds per-cluster memory and lexical-match cost; the centroid is a
601    /// cumulative mean, so trimming members does not disturb it.
602    fn cap_members(&mut self) {
603        while self.members.len() > MEMBER_CAP {
604            self.members.remove(0);
605            self.member_bags.remove(0);
606        }
607        // The union bag is derived from the surviving members.
608        self.bag = self.member_bags.iter().flatten().cloned().collect();
609    }
610
611    /// Fold a newly added member's tokens into the cache. O(tokens in that one
612    /// member) — the other members are already accounted for.
613    fn absorb_tokens(&mut self, member: &str) {
614        let tokens: std::collections::HashSet<String> = tokenize(member).into_iter().collect();
615        self.bag.extend(tokens.iter().cloned());
616        self.member_bags.push(tokens);
617    }
618
619    /// Rebuild the cache from `members` — after deserialization, where the
620    /// cache is skipped on the wire.
621    fn rebuild_bag(&mut self) {
622        self.member_bags = self
623            .members
624            .iter()
625            .map(|m| tokenize(m).into_iter().collect())
626            .collect();
627        self.bag = self.members.iter().flat_map(|m| tokenize(m)).collect();
628    }
629
630    /// How well `q` matches this cluster: the **best Jaccard overlap with any
631    /// single member**, `|q ∩ m| / |q ∪ m|`.
632    ///
633    /// Per-member rather than against the union, because the union only grows —
634    /// so a union score rises with cluster size regardless of whether any actual
635    /// past question resembles the query. Per-member, a cluster is exactly as
636    /// discriminating on its 200th member as on its first.
637    ///
638    /// The union is still useful as a cheap **necessary condition**: from
639    /// `J = i/(|q|+|m|-i) ≥ τ` and `|m| ≥ 1`, any matching member needs
640    /// `i ≥ τ(|q|+1)/(1+τ)` shared tokens, and `|q ∩ union| ≥ |q ∩ m|` for every
641    /// member. Clusters that cannot clear that are skipped without touching
642    /// their members.
643    fn lexical_score(&self, q: &std::collections::HashSet<String>) -> f32 {
644        let needed = (TAU_LEXICAL * (q.len() as f32 + 1.0) / (1.0 + TAU_LEXICAL)).ceil() as usize;
645        if q.iter().filter(|t| self.bag.contains(*t)).count() < needed {
646            return 0.0;
647        }
648        self.member_bags
649            .iter()
650            .map(|m| {
651                // Length alone can rule a member out: the intersection is at most
652                // `min(|q|,|m|)` and the union at least `max(|q|,|m|)`, so a
653                // 2-token query can never reach 0.5 against a 5-token member
654                // (best case 2/5). Checking that first skips the hashing entirely,
655                // and it is exact rather than heuristic.
656                let (lo, hi) = if q.len() < m.len() {
657                    (q.len(), m.len())
658                } else {
659                    (m.len(), q.len())
660                };
661                if hi == 0 || (lo as f32 / hi as f32) < TAU_LEXICAL {
662                    return 0.0;
663                }
664                let inter = q.intersection(m).count() as f32;
665                let union = (q.len() + m.len()) as f32 - inter;
666                if union == 0.0 { 0.0 } else { inter / union }
667            })
668            .fold(0.0f32, f32::max)
669    }
670}
671
672/// The usage-ranking read model — a set of query clusters with capability edges.
673///
674/// Built either in-process by the local learner or offline by Ratel Cloud; both
675/// emit the shape in `protocol/v1`. Attach one to a registry to add the usage
676/// arm to its ranking.
677#[derive(Debug, Clone, PartialEq, Deserialize)]
678pub struct IntentGraph {
679    /// Schema version. Always [`GRAPH_VERSION`] for a graph this build accepts.
680    pub v: u32,
681    /// Epoch-millis of the newest event folded in. Provenance only — it says how
682    /// current the graph is, and nothing reads it during ranking.
683    pub built_from_ts: u64,
684    /// Monotonic write counter, bumped once on every mutation ([`Self::observe`],
685    /// a centroid rebuild). Nothing reads it during ranking; it exists for the
686    /// caller's storage layer, which owns persistence (the graph is in-process
687    /// only). Two uses: **save-when-changed** — persist only when `rev` differs
688    /// from the last saved value; and **stale-base detection** — before
689    /// overwriting a stored graph, compare its `rev` to the one you loaded, and
690    /// if it advanced another writer got there first (single-writer is the
691    /// supported model; this makes a clobber *detectable*, not merged). Carried
692    /// in the wire form; an older graph without it loads as 0 and continues up.
693    #[serde(default)]
694    pub rev: u64,
695    /// The clusters. Order is not significant.
696    pub intents: Vec<Intent>,
697    /// Fingerprint of the embedding model the centroids were built with, or
698    /// `None` for a lexically-grown graph that has none.
699    ///
700    /// Centroids are only comparable to a query embedded by the **same** model.
701    /// This lets a consumer detect a model swap (`GraphModelStatus`) instead of
702    /// cosine-ing across incompatible vector spaces. Stamped when the first
703    /// centroid is grown, or by a producer (e.g. Ratel Cloud) that builds
704    /// centroids offline.
705    #[serde(default, skip_serializing_if = "Option::is_none")]
706    pub model: Option<String>,
707    /// Scratch for the search path → learner handoff; never serialized.
708    #[serde(skip)]
709    pending: PendingQuery,
710    /// Which query is owed a support credit, shared across the tool and skill
711    /// learners so one fanned-out question counts once. Never serialized.
712    #[serde(skip)]
713    credit: CreditSlot,
714}
715
716/// Whether an [`IntentGraph`]'s centroids can be trusted against the currently
717/// active embedding model.
718#[derive(Debug, Clone, PartialEq, Eq)]
719pub(crate) enum GraphModelStatus {
720    /// Usable: no centroids (lexical graph), or the model matches.
721    Ok,
722    /// Centroid width differs from the active model's output — a different model
723    /// family. Dense matching is meaningless; the arm must pause.
724    DimMismatch { built: usize, active: usize },
725    /// Same width but a different model fingerprint (a fine-tune, or another
726    /// model of the same dimension). Cosine across the two spaces is garbage; the
727    /// arm must pause. A length check alone cannot catch this.
728    ModelMismatch { built: String, active: String },
729}
730
731impl GraphModelStatus {
732    /// `(built, active, dim_mismatch)` for [`crate::TraceEvent::UsageModelMismatch`],
733    /// or `None` when there is no mismatch. Dimensions are stringified so both
734    /// cases share one event shape.
735    pub(crate) fn describe(&self) -> Option<(String, String, bool)> {
736        match self {
737            GraphModelStatus::Ok => None,
738            GraphModelStatus::DimMismatch { built, active } => {
739                Some((built.to_string(), active.to_string(), true))
740            }
741            GraphModelStatus::ModelMismatch { built, active } => {
742                Some((built.clone(), active.clone(), false))
743            }
744        }
745    }
746}
747
748/// Serializing materializes the derived display fields, so the wire form always
749/// carries labels computed against the graph being written — never a stale
750/// snapshot from whenever a cluster last happened to change.
751impl Serialize for IntentGraph {
752    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
753        use serde::ser::SerializeStruct;
754        let len = 4 + usize::from(self.model.is_some());
755        let mut out = serializer.serialize_struct("IntentGraph", len)?;
756        out.serialize_field("v", &self.v)?;
757        out.serialize_field("built_from_ts", &self.built_from_ts)?;
758        out.serialize_field("rev", &self.rev)?;
759        if let Some(model) = &self.model {
760            out.serialize_field("model", model)?;
761        }
762        out.serialize_field("intents", &self.labeled())?;
763        out.end()
764    }
765}
766
767impl Default for IntentGraph {
768    fn default() -> Self {
769        Self::empty()
770    }
771}
772
773impl IntentGraph {
774    /// Parse a graph from its JSON wire form.
775    ///
776    /// # Errors
777    ///
778    /// [`IntentGraphError::Malformed`] if the bytes are not the expected shape,
779    /// or [`IntentGraphError::UnsupportedVersion`] if `v` is not 1.
780    pub fn from_json(json: &str) -> Result<Self, IntentGraphError> {
781        let mut graph: IntentGraph =
782            serde_json::from_str(json).map_err(|e| IntentGraphError::Malformed(e.to_string()))?;
783        if graph.v != GRAPH_VERSION {
784            return Err(IntentGraphError::UnsupportedVersion(graph.v));
785        }
786        graph.validate()?;
787        // A cluster with no recorded `last_ts` (an older or cloud-built graph
788        // that didn't track it) is treated as current at load — decay begins
789        // from the graph's own timestamp, not epoch 0, so a freshly loaded graph
790        // is not instantly stale.
791        let anchor = graph.built_from_ts;
792        for it in &mut graph.intents {
793            if it.last_ts == 0 {
794                it.last_ts = anchor;
795            }
796        }
797        graph.rebuild_caches();
798        Ok(graph)
799    }
800
801    /// Reject a structurally-parseable graph that breaks a semantic rule the wire
802    /// contract requires (`protocol/v1/conformance/vectors.json`, the `invalid`
803    /// set). Serde already catches shape errors (a missing `members`, a negative
804    /// `rev`); these are the value-level rules it cannot express.
805    fn validate(&self) -> Result<(), IntentGraphError> {
806        let mut seen = std::collections::HashSet::with_capacity(self.intents.len());
807        for it in &self.intents {
808            if !seen.insert(it.id.as_str()) {
809                return Err(IntentGraphError::Malformed(format!(
810                    "duplicate intent id {:?}",
811                    it.id
812                )));
813            }
814            if it.members.is_empty() {
815                return Err(IntentGraphError::Malformed(format!(
816                    "intent {:?} has no members",
817                    it.id
818                )));
819            }
820            if it.support < 1 {
821                return Err(IntentGraphError::Malformed(format!(
822                    "intent {:?} has support 0 (a confirmed cluster is at least 1)",
823                    it.id
824                )));
825            }
826            if it.centroid.as_ref().is_some_and(|c| c.is_empty()) {
827                return Err(IntentGraphError::Malformed(format!(
828                    "intent {:?} has an empty centroid",
829                    it.id
830                )));
831            }
832            // `seeded_support` counts a subset of `support`, so no producer can
833            // emit a larger value. Equality is legal and is the normal state
834            // right after a seeding pass.
835            if it.seeded_support > it.support {
836                return Err(IntentGraphError::Malformed(format!(
837                    "intent {:?} has seeded_support {} exceeding support {}",
838                    it.id, it.seeded_support, it.support
839                )));
840            }
841            if it
842                .tools
843                .values()
844                .chain(it.skills.values())
845                .any(|w| *w <= 0.0)
846            {
847                return Err(IntentGraphError::Malformed(format!(
848                    "intent {:?} has a non-positive edge weight",
849                    it.id
850                )));
851            }
852        }
853        Ok(())
854    }
855
856    /// Rebuild every cluster's derived token cache. The cache is skipped on the
857    /// wire, so a deserialized graph must restore it before it can match
858    /// lexically.
859    fn rebuild_caches(&mut self) {
860        for it in &mut self.intents {
861            it.rebuild_bag();
862        }
863    }
864
865    /// An empty graph at the current version — the starting state of a learner.
866    pub fn empty() -> Self {
867        Self {
868            v: GRAPH_VERSION,
869            built_from_ts: 0,
870            rev: 0,
871            intents: Vec::new(),
872            model: None,
873            pending: PendingQuery::default(),
874            credit: CreditSlot::default(),
875        }
876    }
877
878    /// Number of clusters.
879    pub fn len(&self) -> usize {
880        self.intents.len()
881    }
882
883    /// Whether the graph holds no clusters — the cold-start state, in which it
884    /// contributes no arm to any query.
885    pub fn is_empty(&self) -> bool {
886        self.intents.is_empty()
887    }
888
889    /// Stash the embedded query so a later [`Self::observe`] can grow a real
890    /// centroid from it.
891    ///
892    /// Called on the search path of a semantic/hybrid registry, which has
893    /// already embedded the query for its own ranking — so this costs nothing
894    /// beyond a copy. Takes `&self`: the slot is a `Mutex`, so the search path
895    /// never needs the write lock.
896    pub(crate) fn note_query_vector(&self, query: &str, vector: &[f32], fingerprint: &str) {
897        self.pending.set(query, vector, fingerprint);
898    }
899
900    /// Arm `query` for a support credit on the shared credit slot — called by the
901    /// learner on every search. See [`CreditSlot`] for why this lives on the
902    /// graph rather than the learner.
903    pub(crate) fn arm_credit(&self, query: &str) {
904        self.credit.arm(query);
905    }
906
907    /// Whether this invoke is the first confirmation of `query` across every
908    /// learner sharing the graph. Marks the credit claimed, so a tool invoke and
909    /// a skill invoke for one fanned-out question yield a single support bump.
910    pub(crate) fn claim_credit(&self, query: &str) -> bool {
911        self.credit.claim(query)
912    }
913
914    /// Fold one confirmed observation — a query, and the capability invoked
915    /// after it — into the graph.
916    ///
917    /// This is the whole learning step (ADR-0014). It:
918    ///
919    /// 1. finds the cluster this query belongs to — by centroid when the search
920    ///    path stashed an embedding, else by token overlap — or **seeds a new
921    ///    one**;
922    /// 2. adds the query as a member and adds `1.0` to the invoked capability's
923    ///    edge, bumping `support` only when this is the search's **first**
924    ///    confirming invoke;
925    /// 3. recomputes the cluster's display label and terms.
926    ///
927    /// `ts_ms` records how current the graph is; it never affects ranking.
928    /// Traces are loosely ordered (ADR-0007), so a late-arriving older event
929    /// leaves the recorded high-water mark alone.
930    /// [`Observation::first_confirmation`] distinguishes *this search was acted
931    /// on* from *another capability was used for the same search*. Both add an
932    /// edge; only the former is an observation, so only the former raises
933    /// `support`. The caller owns that distinction because it is the one holding
934    /// the pending search — see [`crate::UsageLearner`].
935    pub(crate) fn observe(&mut self, obs: Observation<'_>) {
936        let Observation {
937            query,
938            kind,
939            capability_id,
940            ts_ms,
941            first_confirmation,
942            seeded,
943        } = obs;
944        // A query vector is available only when the search path was
945        // semantic/hybrid AND the slot still belongs to this query.
946        let stashed = self.pending.vector_for(query);
947        if stashed.is_none() && tokenize(query).is_empty() {
948            return; // no words to cluster on and no embedding either
949        }
950        self.built_from_ts = self.built_from_ts.max(ts_ms);
951
952        // Only fold the vector if it was produced by the graph's model. On a
953        // model swap (fingerprint differs from `self.model`) we FREEZE: the
954        // member, support, and edge still update — they are model-independent —
955        // but the centroid is left untouched rather than blended across two
956        // vector spaces. `None` model means no centroids yet; the first fold
957        // stamps it.
958        let usable = match (&self.model, &stashed) {
959            (Some(m), Some((_, fp))) => m == fp,
960            _ => true,
961        };
962        let vector: Option<Vec<f32>> = if usable {
963            stashed.as_ref().map(|(v, _)| v.clone())
964        } else {
965            None
966        };
967        let fingerprint: Option<String> = stashed.as_ref().map(|(_, fp)| fp.clone());
968
969        let idx = match self.best_match(query, vector.as_deref()) {
970            Some(i) => i,
971            None => {
972                let id = format!("intent_{}", self.next_intent_seq());
973                self.intents.push(Intent {
974                    id,
975                    // Derived on read — see `labeled`. Never written while learning.
976                    label: String::new(),
977                    terms: Vec::new(),
978                    members: Vec::new(),
979                    centroid: None,
980                    support: 0,
981                    seeded_support: 0,
982                    last_ts: 0,
983                    tools: BTreeMap::new(),
984                    skills: BTreeMap::new(),
985                    bag: std::collections::HashSet::new(),
986                    member_bags: Vec::new(),
987                    vector_n: 0,
988                });
989                self.intents.len() - 1
990            }
991        };
992
993        {
994            let it = &mut self.intents[idx];
995            // Members are the match key, so a repeated phrasing must not inflate
996            // the token bag — dedupe. The centroid is the mean of the DISTINCT
997            // member texts, so it moves exactly when a new member arrives: the
998            // same condition, and what stops a second invoke from folding the
999            // same query vector in twice.
1000            if !it.members.iter().any(|m| m == query) {
1001                it.members.push(query.to_string());
1002                it.absorb_tokens(query);
1003                if let Some(v) = vector.as_deref() {
1004                    it.absorb_vector(v);
1005                }
1006                it.cap_members();
1007            }
1008            // `|| support == 0` is load-bearing: a cluster moves as it learns, so
1009            // a later invoke from the same search can match a cluster the first
1010            // one did not — and a freshly seeded cluster must still start at 1.
1011            // `protocol/v1` requires support >= 1, and a zero-support cluster
1012            // would contribute a weightless arm.
1013            if first_confirmation || it.support == 0 {
1014                it.support = it.support.saturating_add(1);
1015                // In lockstep with `support`, never per edge — one fanned-out
1016                // question adds two edges but is one observation, and bumping
1017                // per edge would break `seeded_support <= support` on the very
1018                // first fanned-out baseline turn.
1019                if seeded {
1020                    it.seeded_support = it.seeded_support.saturating_add(1);
1021                }
1022            }
1023            it.last_ts = it.last_ts.max(ts_ms);
1024            let edges = match kind {
1025                Capability::Tool => &mut it.tools,
1026                Capability::Skill => &mut it.skills,
1027            };
1028            *edges.entry(capability_id.to_string()).or_insert(0.0) += 1.0;
1029        }
1030
1031        // Stamp the model the first time a centroid actually exists, so later
1032        // observations under a different model can be detected and frozen. Done
1033        // before eviction, while `idx` is still valid.
1034        if self.model.is_none() && self.intents[idx].centroid.is_some() {
1035            self.model = fingerprint;
1036        }
1037
1038        // Evict clusters decayed past the floor — last, since it renumbers
1039        // `intents`. The just-touched cluster has `last_ts == built_from_ts`, so
1040        // it is never evicted here.
1041        let now = self.built_from_ts;
1042        self.intents
1043            .retain(|it| recency_factor(now, it.last_ts) >= EVICTION_FLOOR);
1044
1045        // Every path that reaches here changed a member, an edge, or support, so
1046        // count exactly one write. The early returns above (no words and no
1047        // vector) leave `rev` alone — nothing was persisted-worthy.
1048        self.rev += 1;
1049    }
1050
1051    /// The write counter — see [`Self::rev`]. Snapshot it after each save; a
1052    /// later value means unsaved learning, or another writer moved ahead of you.
1053    pub fn rev(&self) -> u64 {
1054        self.rev
1055    }
1056
1057    /// Whether this graph's centroids can be trusted against the currently active
1058    /// embedding model, whose vectors are `query_dim`-wide with identity
1059    /// `active_fingerprint`.
1060    ///
1061    /// A lexical graph (no centroids) is always [`GraphModelStatus::Ok`] — it has
1062    /// nothing model-specific. A dense graph must agree on both width and model
1063    /// identity; the width check alone cannot catch a same-dimension model swap.
1064    pub(crate) fn model_status(
1065        &self,
1066        active_fingerprint: &str,
1067        query_dim: usize,
1068    ) -> GraphModelStatus {
1069        let Some(built_dim) = self
1070            .intents
1071            .iter()
1072            .find_map(|i| i.centroid.as_ref().map(Vec::len))
1073        else {
1074            return GraphModelStatus::Ok; // no centroids — lexical, model-agnostic
1075        };
1076        if built_dim != query_dim {
1077            return GraphModelStatus::DimMismatch {
1078                built: built_dim,
1079                active: query_dim,
1080            };
1081        }
1082        match &self.model {
1083            Some(built) if built != active_fingerprint => GraphModelStatus::ModelMismatch {
1084                built: built.clone(),
1085                active: active_fingerprint.to_string(),
1086            },
1087            _ => GraphModelStatus::Ok,
1088        }
1089    }
1090
1091    /// Re-embed every cluster's members under a new model and replace the
1092    /// centroids, restamping [`Self::model`]. Each entry is a cluster **id** and
1093    /// the embeddings of its `members` (in member order).
1094    ///
1095    /// Assignment is **by id, not position**. `rebuild_intent_graph` snapshots
1096    /// members and embeds them without the graph lock (so searches are not
1097    /// blocked), then re-locks to apply here — and a concurrent `observe()` in
1098    /// that window can evict or seed a cluster, shifting positions since the
1099    /// snapshot. Zipping by position would stamp a centroid onto the wrong
1100    /// cluster, silently, because the fresh model fingerprint hides the swap. An
1101    /// id absent now (evicted since the snapshot) is skipped; a cluster seeded
1102    /// since is simply left for the next rebuild.
1103    ///
1104    /// Members, support, and edges are model-independent and untouched, so all
1105    /// learning survives a model change — only the centroids move to the new
1106    /// space. A cluster with no members (or none embedded) keeps whatever
1107    /// centroid it had.
1108    pub(crate) fn rebuild_centroids(
1109        &mut self,
1110        per_cluster: Vec<(String, Vec<Vec<f32>>)>,
1111        fingerprint: String,
1112    ) {
1113        let index: std::collections::HashMap<String, usize> = self
1114            .intents
1115            .iter()
1116            .enumerate()
1117            .map(|(i, it)| (it.id.clone(), i))
1118            .collect();
1119        for (id, vectors) in per_cluster {
1120            if vectors.is_empty() {
1121                continue;
1122            }
1123            let Some(&i) = index.get(&id) else {
1124                continue; // evicted since the snapshot — nothing to attach to
1125            };
1126            let dim = vectors[0].len();
1127            let mut sum = vec![0.0f32; dim];
1128            for v in &vectors {
1129                for (s, x) in sum.iter_mut().zip(v) {
1130                    *s += x;
1131                }
1132            }
1133            self.intents[i].centroid = Some(normalize(sum));
1134        }
1135        self.model = Some(fingerprint);
1136        // A rebuild rewrites every centroid and restamps the model — a change the
1137        // caller will want to persist.
1138        self.rev += 1;
1139    }
1140
1141    /// The cluster this query belongs to: by cosine when an embedding is
1142    /// available and some cluster carries a centroid, otherwise by token
1143    /// overlap.
1144    ///
1145    /// Dense first, lexical as a fallback — a graph can hold both kinds while
1146    /// centroids are still being filled in, and a query that no centroid
1147    /// recognizes may still share words with a cluster.
1148    fn best_match(&self, query: &str, vector: Option<&[f32]>) -> Option<usize> {
1149        if let Some(v) = vector
1150            && let Some(i) = self.best_dense_match(v)
1151        {
1152            return Some(i);
1153        }
1154        self.best_lexical_match(query)
1155    }
1156
1157    /// Index of the nearest cluster centroid clearing [`TAU_COSINE`]. Ties break
1158    /// by cluster id so growth does not depend on `Vec` order.
1159    fn best_dense_match(&self, vector: &[f32]) -> Option<usize> {
1160        self.intents
1161            .iter()
1162            .enumerate()
1163            .filter_map(|(i, it)| {
1164                let c = it.centroid.as_deref()?;
1165                if c.len() != vector.len() {
1166                    return None; // a different embedding model — not comparable
1167                }
1168                Some((i, cosine(vector, c)))
1169            })
1170            .filter(|(_, sim)| *sim >= TAU_COSINE)
1171            .max_by(|a, b| {
1172                a.1.partial_cmp(&b.1)
1173                    .unwrap_or(std::cmp::Ordering::Equal)
1174                    .then_with(|| self.intents[b.0].id.cmp(&self.intents[a.0].id))
1175            })
1176            .map(|(i, _)| i)
1177    }
1178
1179    /// Index of the cluster whose member-token bag best covers `query`, if any
1180    /// clears [`TAU_LEXICAL`]. Ties break by cluster id so growth does not
1181    /// depend on `Vec` order.
1182    fn best_lexical_match(&self, query: &str) -> Option<usize> {
1183        let q: std::collections::HashSet<String> = tokenize(query).into_iter().collect();
1184        if q.is_empty() {
1185            return None;
1186        }
1187        self.intents
1188            .iter()
1189            .enumerate()
1190            .map(|(i, it)| (i, it.lexical_score(&q)))
1191            .filter(|(_, score)| *score >= TAU_LEXICAL)
1192            .max_by(|a, b| {
1193                a.1.partial_cmp(&b.1)
1194                    .unwrap_or(std::cmp::Ordering::Equal)
1195                    .then_with(|| self.intents[b.0].id.cmp(&self.intents[a.0].id))
1196            })
1197            .map(|(i, _)| i)
1198    }
1199
1200    /// The next free `intent_N` sequence number, so ids stay unique even after
1201    /// clusters are merged away by a future compaction.
1202    fn next_intent_seq(&self) -> usize {
1203        self.intents
1204            .iter()
1205            .filter_map(|i| i.id.strip_prefix("intent_")?.parse::<usize>().ok())
1206            .max()
1207            .map_or(0, |m| m + 1)
1208    }
1209
1210    /// The most central member — the one whose tokens the *rest* of the cluster
1211    /// shares most — as a real past query rather than a generated summary, so it
1212    /// can never misdescribe the cluster. Ties break by the member text.
1213    ///
1214    /// Scored against the *other* members, not the cluster's union bag: the union
1215    /// contains every member's tokens by construction, so coverage-of-the-union
1216    /// is a constant `1.0` and would leave the label to the tie-break alone.
1217    fn medoid(&self, idx: usize) -> String {
1218        let it = &self.intents[idx];
1219        let tokenized: Vec<(&String, Vec<String>)> =
1220            it.members.iter().map(|m| (m, tokenize(m))).collect();
1221        tokenized
1222            .iter()
1223            .enumerate()
1224            .map(|(i, (m, t))| {
1225                let shared = t
1226                    .iter()
1227                    .filter(|tok| {
1228                        tokenized
1229                            .iter()
1230                            .enumerate()
1231                            .any(|(j, (_, other))| j != i && other.contains(*tok))
1232                    })
1233                    .count();
1234                let score = if t.is_empty() {
1235                    0.0
1236                } else {
1237                    shared as f32 / t.len() as f32
1238                };
1239                (*m, score)
1240            })
1241            .max_by(|a, b| {
1242                a.1.partial_cmp(&b.1)
1243                    .unwrap_or(std::cmp::Ordering::Equal)
1244                    .then_with(|| b.0.cmp(a.0))
1245            })
1246            .map(|(m, _)| m.clone())
1247            .unwrap_or_default()
1248    }
1249
1250    /// The distinguishing terms for one cluster: class-based TF-IDF (BERTopic's
1251    /// method — each cluster is one document, so a term ranks by how much it sets
1252    /// this cluster apart, not how common it is within it).
1253    ///
1254    /// Takes the corpus-wide stats — `total` tokens across the graph, `avg`
1255    /// tokens per cluster, and `global` per-token occurrence counts — as
1256    /// arguments because they are identical for every cluster. [`Self::labeled`]
1257    /// builds them once and hands them to each call rather than rebuilding the
1258    /// whole-corpus index per cluster (which made labeling O(N²) in cluster
1259    /// count on a path `toJson` may run often).
1260    fn c_tf_idf_terms(
1261        cluster_tokens: &[String],
1262        total: usize,
1263        avg: f32,
1264        global: &std::collections::HashMap<&str, usize>,
1265    ) -> Vec<String> {
1266        use std::collections::HashMap;
1267        if total == 0 || cluster_tokens.is_empty() {
1268            return Vec::new();
1269        }
1270        let mut local: HashMap<&str, usize> = HashMap::new();
1271        for t in cluster_tokens {
1272            *local.entry(t.as_str()).or_insert(0) += 1;
1273        }
1274        let len = cluster_tokens.len() as f32;
1275
1276        let mut scored: Vec<(String, f32)> = local
1277            .into_iter()
1278            .map(|(t, count)| {
1279                let f = global[t] as f32;
1280                (t.to_string(), (count as f32 / len) * (1.0 + avg / f).ln())
1281            })
1282            .collect();
1283        sort_and_truncate(&mut scored, MAX_TERMS);
1284        scored.into_iter().map(|(t, _)| t).collect()
1285    }
1286
1287    /// The clusters with their display fields materialized against the graph as
1288    /// it is **now**.
1289    ///
1290    /// Labels are derived rather than stored for two reasons. c-TF-IDF ranks a
1291    /// term by how rare it is across the *other* clusters, so a value computed
1292    /// when a cluster was last written goes stale as soon as the graph grows.
1293    /// And computing them on write meant re-tokenizing every member of every
1294    /// cluster on every invocation — for strings ranking never reads.
1295    ///
1296    /// The whole-corpus token index (`per_cluster`, `global`, `avg`) is built
1297    /// **once** here and shared by every cluster's c-TF-IDF; building it inside
1298    /// each call made this quadratic in cluster count.
1299    pub fn labeled(&self) -> Vec<Intent> {
1300        use std::collections::HashMap;
1301        // Tokenize every member of every cluster once, in `intents` order.
1302        let per_cluster: Vec<Vec<String>> = self
1303            .intents
1304            .iter()
1305            .map(|it| it.members.iter().flat_map(|m| tokenize(m)).collect())
1306            .collect();
1307        let total: usize = per_cluster.iter().map(|c| c.len()).sum();
1308        let avg = if per_cluster.is_empty() {
1309            0.0
1310        } else {
1311            total as f32 / per_cluster.len() as f32
1312        };
1313        // Corpus-wide occurrence count per token, shared across all clusters.
1314        let mut global: HashMap<&str, usize> = HashMap::new();
1315        for c in &per_cluster {
1316            for t in c {
1317                *global.entry(t.as_str()).or_insert(0) += 1;
1318            }
1319        }
1320
1321        self.intents
1322            .iter()
1323            .enumerate()
1324            .map(|(i, it)| Intent {
1325                label: self.medoid(i),
1326                terms: Self::c_tf_idf_terms(&per_cluster[i], total, avg, &global),
1327                ..it.clone()
1328            })
1329            .collect()
1330    }
1331
1332    /// Resolve the usage arm, choosing the match tier from **what this graph
1333    /// carries** rather than from the caller's search method.
1334    ///
1335    /// Dense matching needs both a query vector *and* stored centroids. A
1336    /// producer that clustered lexically — the in-process learner, or Ratel
1337    /// Cloud's Jaccard clusterer — emits no centroids, so a semantic catalog
1338    /// handed such a graph must still match it lexically rather than see nothing
1339    /// at all. Falling back here is what makes the format portable across
1340    /// producers in practice, not just on paper.
1341    ///
1342    /// On a **mixed** graph (some clusters carry centroids, some don't), a vector
1343    /// query matches the centroid-bearing clusters densely and, on a miss, the
1344    /// centroid-less clusters lexically. The lexical fallback is restricted to
1345    /// centroid-less clusters on purpose: a fingerprinted cluster dense matching
1346    /// already rejected must never be rescued by token overlap (that would let
1347    /// words override meaning), but a centroid-less cluster has no other way to
1348    /// match at all, so it gets the lexical shot it is due (#5).
1349    pub(crate) fn arm(
1350        &self,
1351        query: &str,
1352        query_vec: Option<&[f32]>,
1353        kind: Capability,
1354        known: &dyn Fn(&str) -> bool,
1355    ) -> ArmOutcome {
1356        match query_vec {
1357            Some(v) if self.has_centroids() => self
1358                .arm_dense(v, kind, known)
1359                .or_else(|| self.arm_lexical_matching(query, kind, known, true)),
1360            _ => self.arm_lexical(query, kind, known),
1361        }
1362    }
1363
1364    /// Whether any cluster carries a centroid, i.e. whether dense matching is
1365    /// possible at all against this graph.
1366    fn has_centroids(&self) -> bool {
1367        self.intents.iter().any(|i| i.centroid.is_some())
1368    }
1369
1370    /// Match `query_vec` to the nearest cluster centroid and return its arm.
1371    ///
1372    /// `None` when nothing clears [`TAU_COSINE`], when the matched cluster has
1373    /// no surviving edges of `kind`, or when no cluster carries a centroid of
1374    /// the query's dimension (a changed embedding model — mismatched vector
1375    /// spaces are skipped, never compared).
1376    pub(crate) fn arm_dense(
1377        &self,
1378        query_vec: &[f32],
1379        kind: Capability,
1380        known: &dyn Fn(&str) -> bool,
1381    ) -> ArmOutcome {
1382        let Some(best) = self
1383            .intents
1384            .iter()
1385            .filter_map(|it| {
1386                let c = it.centroid.as_deref()?;
1387                if c.len() != query_vec.len() {
1388                    return None; // different embedding model — not comparable
1389                }
1390                Some((it, cosine(query_vec, c)))
1391            })
1392            .filter(|(_, sim)| *sim >= TAU_COSINE)
1393            .max_by(pick_best)
1394        else {
1395            return ArmOutcome::NoMatch;
1396        };
1397        arm_from(best.0, best.1, self.built_from_ts, kind, known)
1398    }
1399
1400    /// Match `query` lexically against each cluster's members and return the best
1401    /// cluster's arm.
1402    ///
1403    /// The score is the best **per-member Jaccard overlap** — `|q ∩ m| / |q ∪ m|`
1404    /// against the cluster's closest single member, not against the members'
1405    /// union (which only grows, letting a mature cluster absorb unrelated asks;
1406    /// see [`Intent::lexical_score`]). Bounded in `[0, 1]`, so it thresholds
1407    /// meaningfully — unlike a raw BM25 score, which is unbounded and
1408    /// corpus-relative. `None` when nothing clears [`TAU_LEXICAL`] or the match
1409    /// has no surviving edges.
1410    pub(crate) fn arm_lexical(
1411        &self,
1412        query: &str,
1413        kind: Capability,
1414        known: &dyn Fn(&str) -> bool,
1415    ) -> ArmOutcome {
1416        self.arm_lexical_matching(query, kind, known, false)
1417    }
1418
1419    /// Lexical match, optionally limited to centroid-less clusters. The dense
1420    /// serving fallback sets `centroidless_only` so a fingerprinted cluster that
1421    /// dense matching already rejected is never rescued by token overlap; only
1422    /// clusters dense cannot see (no centroid) get a lexical match (see [`arm`]).
1423    ///
1424    /// [`arm`]: Self::arm
1425    fn arm_lexical_matching(
1426        &self,
1427        query: &str,
1428        kind: Capability,
1429        known: &dyn Fn(&str) -> bool,
1430        centroidless_only: bool,
1431    ) -> ArmOutcome {
1432        let q: std::collections::HashSet<String> = tokenize(query).into_iter().collect();
1433        if q.is_empty() {
1434            return ArmOutcome::NoMatch;
1435        }
1436        let Some(best) = self
1437            .intents
1438            .iter()
1439            .filter(|it| !centroidless_only || it.centroid.is_none())
1440            .map(|it| (it, it.lexical_score(&q)))
1441            .filter(|(_, score)| *score >= TAU_LEXICAL)
1442            .max_by(pick_best)
1443        else {
1444            return ArmOutcome::NoMatch;
1445        };
1446        arm_from(best.0, best.1, self.built_from_ts, kind, known)
1447    }
1448}
1449
1450/// Break a score tie by id ascending, so the chosen cluster does not depend on
1451/// iteration order. (`max_by` keeps the last maximum, so the comparison is
1452/// reversed on id to leave the alphabetically-first winner in place.)
1453fn pick_best(a: &(&Intent, f32), b: &(&Intent, f32)) -> std::cmp::Ordering {
1454    a.1.partial_cmp(&b.1)
1455        .unwrap_or(std::cmp::Ordering::Equal)
1456        .then_with(|| b.0.id.cmp(&a.0.id))
1457}
1458
1459fn arm_from(
1460    intent: &Intent,
1461    similarity: f32,
1462    now_ts: u64,
1463    kind: Capability,
1464    known: &dyn Fn(&str) -> bool,
1465) -> ArmOutcome {
1466    let (ids, dropped) = intent.ranked(kind, known);
1467    if ids.is_empty() {
1468        // Matched, but nothing it remembers of this kind still exists. Two very
1469        // different reasons: the catalog dropped every id it knew (`dropped >
1470        // 0` — drift, worth reporting), or the cluster simply holds no edges of
1471        // this kind at all, which a tools-only cluster asked for skills does
1472        // legitimately and is not drift.
1473        return if dropped > 0 {
1474            ArmOutcome::AllFiltered {
1475                intent_id: intent.id.clone(),
1476                similarity,
1477                support: intent.support,
1478                dropped,
1479            }
1480        } else {
1481            ArmOutcome::NoMatch
1482        };
1483    }
1484    let weight = usage_weight(intent.support) * recency_factor(now_ts, intent.last_ts);
1485    ArmOutcome::Armed(UsageArm {
1486        intent_id: intent.id.clone(),
1487        similarity,
1488        support: intent.support,
1489        weight,
1490        ids,
1491        dropped,
1492    })
1493}
1494
1495/// Scale to unit length. A zero vector is returned unchanged — there is no
1496/// direction to preserve, and dividing would produce NaNs that would poison
1497/// every later comparison.
1498fn normalize(mut v: Vec<f32>) -> Vec<f32> {
1499    let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
1500    if norm > 0.0 {
1501        for x in &mut v {
1502            *x /= norm;
1503        }
1504    }
1505    v
1506}
1507
1508/// Cosine similarity. Computed in full rather than as a bare dot product: the
1509/// contract says centroids are L2-normalized, but a producer that rounds or
1510/// truncates would otherwise silently depress every score.
1511fn cosine(a: &[f32], b: &[f32]) -> f32 {
1512    let mut dot = 0.0;
1513    let mut na = 0.0;
1514    let mut nb = 0.0;
1515    for (x, y) in a.iter().zip(b.iter()) {
1516        dot += x * y;
1517        na += x * x;
1518        nb += y * y;
1519    }
1520    if na == 0.0 || nb == 0.0 {
1521        return 0.0;
1522    }
1523    dot / (na.sqrt() * nb.sqrt())
1524}
1525
1526/// Content tokens of a text: lowercased alphanumeric runs, minus a small
1527/// closed-class stopword list. Deliberately tiny — the lexical tier is a
1528/// fallback for catalogs with no embedder, not a search engine.
1529fn tokenize(text: &str) -> Vec<String> {
1530    text.split(|c: char| !c.is_alphanumeric())
1531        .filter(|t| !t.is_empty())
1532        .map(|t| t.to_lowercase())
1533        .filter(|t| !STOPWORDS.contains(&t.as_str()))
1534        .collect()
1535}
1536
1537const STOPWORDS: &[&str] = &[
1538    "a", "an", "and", "are", "as", "at", "be", "but", "by", "did", "do", "does", "for", "from",
1539    "how", "i", "if", "in", "is", "it", "my", "of", "on", "or", "that", "the", "this", "to", "was",
1540    "what", "when", "where", "which", "why", "with", "you", "your",
1541];
1542
1543#[cfg(test)]
1544mod tests {
1545    use super::*;
1546
1547    fn intent(id: &str, members: &[&str], tools: &[(&str, f32)]) -> Intent {
1548        let mut it = Intent {
1549            id: id.into(),
1550            label: members.first().copied().unwrap_or_default().into(),
1551            terms: Vec::new(),
1552            members: members.iter().map(|m| m.to_string()).collect(),
1553            centroid: None,
1554            support: 5,
1555            seeded_support: 0,
1556            last_ts: 0,
1557            tools: tools.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
1558            skills: BTreeMap::new(),
1559            bag: std::collections::HashSet::new(),
1560            member_bags: Vec::new(),
1561            vector_n: 0,
1562        };
1563        it.rebuild_bag(); // the cache is derived from members — keep them in step
1564        it
1565    }
1566
1567    fn graph(intents: Vec<Intent>) -> IntentGraph {
1568        IntentGraph {
1569            v: 1,
1570            built_from_ts: 1_753_000_000_000,
1571            rev: 0,
1572            intents,
1573            model: None,
1574            pending: PendingQuery::default(),
1575            credit: CreditSlot::default(),
1576        }
1577    }
1578
1579    fn all_known(_: &str) -> bool {
1580        true
1581    }
1582
1583    // ---- mixed-graph serving fallback (#5) ---------------------------------
1584
1585    #[test]
1586    fn a_vector_query_boosts_a_centroidless_cluster_when_dense_misses() {
1587        // Mixed graph: one dense cluster (fingerprinted) and one word-only
1588        // cluster carrying its own tool edge. A vector query orthogonal to the
1589        // dense centroid must still reach the word-only cluster lexically — its
1590        // learned evidence is otherwise permanently invisible to dense queries.
1591        let mut dense = intent(
1592            "dense",
1593            &["why is the build broken"],
1594            &[("gh_run_list", 1.0)],
1595        );
1596        dense.centroid = Some(normalize(vec![1.0, 0.0, 0.0]));
1597        let lexical = intent(
1598            "lexical",
1599            &["deploy the app to prod"],
1600            &[("deploy_tool", 1.0)],
1601        );
1602        let g = graph(vec![dense, lexical]);
1603
1604        // Orthogonal to the dense centroid → dense miss; shares every word with
1605        // the word-only cluster → lexical hit.
1606        let arm = g.arm(
1607            "deploy the app to prod",
1608            Some(&[0.0, 1.0, 0.0]),
1609            Capability::Tool,
1610            &all_known,
1611        );
1612
1613        let arm = arm.expect("word-only cluster must still boost on a dense miss");
1614        assert_eq!(arm.intent_id, "lexical");
1615        assert_eq!(arm.ids, vec!["deploy_tool".to_string()]);
1616    }
1617
1618    #[test]
1619    fn a_dense_rejected_cluster_is_not_rescued_by_word_overlap() {
1620        // The guard on the fallback: a fingerprinted cluster that dense matching
1621        // rejected must NOT be pulled back by token overlap — that would let a
1622        // shallow word match override the embedder's "not similar" verdict.
1623        let mut dense = intent(
1624            "dense",
1625            &["deploy the app to prod"],
1626            &[("gh_run_list", 1.0)],
1627        );
1628        dense.centroid = Some(normalize(vec![1.0, 0.0, 0.0]));
1629        let g = graph(vec![dense]);
1630
1631        // Orthogonal → dense miss; but the query shares every word with the
1632        // cluster's member, so an unrestricted lexical fallback would match it.
1633        let arm = g.arm(
1634            "deploy the app to prod",
1635            Some(&[0.0, 1.0, 0.0]),
1636            Capability::Tool,
1637            &all_known,
1638        );
1639
1640        assert!(
1641            arm.is_none(),
1642            "a fingerprinted cluster dense rejected must not match lexically, got {arm:?}"
1643        );
1644    }
1645
1646    // ---- centroid running mean ---------------------------------------------
1647
1648    #[test]
1649    fn absorb_vector_weights_by_vectors_folded_not_member_count() {
1650        // A cluster that grew lexically (members added with no vector) must not
1651        // let those members inflate the running-mean weight — otherwise the first
1652        // vector after lexical growth dominates the centroid.
1653        let mut it = intent("i0", &["a", "b", "c", "d"], &[("t", 1.0)]);
1654        assert!(
1655            it.centroid.is_none(),
1656            "four lexical members, no centroid yet"
1657        );
1658
1659        it.absorb_vector(&[1.0, 0.0, 0.0]); // first vector → centroid is e_x
1660        it.absorb_vector(&[0.0, 1.0, 0.0]); // second → equal-weight mean of the two
1661
1662        // Two equal-weight orthogonal unit vectors → normalize(e_x + e_y).
1663        let c = it.centroid.as_ref().unwrap();
1664        assert!(
1665            (c[0] - c[1]).abs() < 1e-6,
1666            "equal-weight vectors → symmetric centroid, got {c:?}"
1667        );
1668        assert!(
1669            (c[0] - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-6,
1670            "expected ~0.707 per axis, got {c:?}"
1671        );
1672    }
1673
1674    #[test]
1675    fn medoid_labels_the_most_central_member_not_the_alphabetical_first() {
1676        // The old scoring was a constant 1.0 (every member's tokens are in the
1677        // union bag), so the label was just the tie-break — the alphabetically
1678        // first member. It should be the member most shared with the rest.
1679        let g = graph(vec![intent(
1680            "i0",
1681            &[
1682                "a lonely unique phrase",
1683                "build broken ci",
1684                "build broken pipeline",
1685            ],
1686            &[("t", 1.0)],
1687        )]);
1688        // "a lonely…" sorts first but shares no tokens; the build-broken members
1689        // are central. Most-covered wins, tie broken alphabetically among equals.
1690        assert_eq!(g.medoid(0), "build broken ci");
1691    }
1692
1693    // ---- support ramp ------------------------------------------------------
1694
1695    #[test]
1696    fn support_ramps_the_arm_weight_then_caps() {
1697        assert!((usage_weight(1) - USAGE_WEIGHT / 3.0).abs() < 1e-6);
1698        assert!((usage_weight(2) - USAGE_WEIGHT * 2.0 / 3.0).abs() < 1e-6);
1699        assert!((usage_weight(3) - USAGE_WEIGHT).abs() < 1e-6);
1700        assert!((usage_weight(900) - USAGE_WEIGHT).abs() < 1e-6);
1701    }
1702
1703    #[test]
1704    fn a_single_observation_is_weaker_than_a_confirmed_cluster() {
1705        // The whole point of the ramp: one misclick must not rank like a pattern.
1706        assert!(usage_weight(1) < usage_weight(3));
1707    }
1708
1709    // ---- parsing -----------------------------------------------------------
1710
1711    #[test]
1712    fn parses_a_graph_without_a_centroid() {
1713        // The Bm25 / Jaccard-producer case: `centroid` is optional by contract.
1714        let json = r#"{"v":1,"built_from_ts":1,
1715            "intents":[{"id":"i0","label":"l","members":["q"],"support":2,
1716            "tools":{"t":1.0},"skills":{}}]}"#;
1717        let g = IntentGraph::from_json(json).expect("valid graph");
1718        assert_eq!(g.len(), 1);
1719        assert!(g.intents[0].centroid.is_none());
1720    }
1721
1722    #[test]
1723    fn rejects_an_unknown_version_instead_of_degrading() {
1724        let json = r#"{"v":2,"built_from_ts":1,"intents":[]}"#;
1725        assert_eq!(
1726            IntentGraph::from_json(json),
1727            Err(IntentGraphError::UnsupportedVersion(2))
1728        );
1729    }
1730
1731    #[test]
1732    fn rejects_malformed_bytes() {
1733        assert!(matches!(
1734            IntentGraph::from_json("not json"),
1735            Err(IntentGraphError::Malformed(_))
1736        ));
1737    }
1738
1739    // ---- seeded_support: baseline provenance -------------------------------
1740
1741    #[test]
1742    fn a_seeded_observation_records_provenance_beside_support() {
1743        let mut g = IntentGraph::empty();
1744        g.observe(Observation {
1745            query: "why is the build broken",
1746            kind: Capability::Tool,
1747            capability_id: "gh_run_list",
1748            ts_ms: T0,
1749            first_confirmation: true,
1750            seeded: true,
1751        });
1752        assert_eq!(g.intents[0].support, 1);
1753        assert_eq!(g.intents[0].seeded_support, 1);
1754    }
1755
1756    #[test]
1757    fn a_live_observation_leaves_seeded_support_alone() {
1758        let mut g = IntentGraph::empty();
1759        g.observe_live("why is the build broken", Capability::Tool, "t", T0, true);
1760        assert_eq!(g.intents[0].support, 1);
1761        assert_eq!(g.intents[0].seeded_support, 0);
1762    }
1763
1764    #[test]
1765    fn a_seeded_observation_that_adds_only_an_edge_does_not_bump_seeded_support() {
1766        // The `search_capabilities` fan-out shape: one question, two edges, one
1767        // observation. Bumping seeded_support per EDGE rather than per
1768        // observation would break `seeded_support <= support` on the very first
1769        // fanned-out baseline turn.
1770        let mut g = IntentGraph::empty();
1771        let obs = |id, first| Observation {
1772            query: "why is the build broken",
1773            kind: Capability::Tool,
1774            capability_id: id,
1775            ts_ms: T0,
1776            first_confirmation: first,
1777            seeded: true,
1778        };
1779        g.observe(obs("gh_run_list", true));
1780        g.observe(obs("gh_run_view", false));
1781
1782        assert_eq!(g.intents[0].tools.len(), 2, "two capabilities were used");
1783        assert_eq!(g.intents[0].support, 1, "but one question was asked");
1784        assert_eq!(g.intents[0].seeded_support, 1, "and it was one seeded turn");
1785    }
1786
1787    #[test]
1788    fn seeded_and_live_observations_accumulate_in_the_same_cluster() {
1789        // The post-flip state: a seeded base that live traffic builds on. The
1790        // gap between the two counts is how much of the cluster's confidence
1791        // came from the baseline.
1792        let mut g = IntentGraph::empty();
1793        g.observe(Observation {
1794            query: "why is the build broken",
1795            kind: Capability::Tool,
1796            capability_id: "gh_run_list",
1797            ts_ms: T0,
1798            first_confirmation: true,
1799            seeded: true,
1800        });
1801        g.observe_live(
1802            "why is the build broken",
1803            Capability::Tool,
1804            "gh_run_list",
1805            T0,
1806            true,
1807        );
1808
1809        assert_eq!(g.intents[0].support, 2);
1810        assert_eq!(g.intents[0].seeded_support, 1);
1811    }
1812
1813    #[test]
1814    fn a_graph_with_no_seeded_observations_serializes_without_the_field() {
1815        // Zero-skip keeps a live-only graph byte-identical to one produced
1816        // before the field existed, so existing wire fixtures do not move.
1817        let mut g = IntentGraph::empty();
1818        g.observe_live("why is the build broken", Capability::Tool, "t", T0, true);
1819        let json = serde_json::to_string(&g).unwrap();
1820        assert!(
1821            !json.contains("seeded_support"),
1822            "absent means zero; got {json}"
1823        );
1824    }
1825
1826    #[test]
1827    fn seeded_support_round_trips_through_the_wire_form() {
1828        let mut g = IntentGraph::empty();
1829        g.observe(Observation {
1830            query: "why is the build broken",
1831            kind: Capability::Tool,
1832            capability_id: "t",
1833            ts_ms: T0,
1834            first_confirmation: true,
1835            seeded: true,
1836        });
1837        let json = serde_json::to_string(&g).unwrap();
1838        assert!(json.contains(r#""seeded_support":1"#), "got {json}");
1839        let back = IntentGraph::from_json(&json).unwrap();
1840        assert_eq!(back.intents[0].seeded_support, 1);
1841    }
1842
1843    #[test]
1844    fn a_graph_without_seeded_support_loads_as_zero() {
1845        let json = r#"{"v":1,"built_from_ts":1,
1846            "intents":[{"id":"i0","label":"l","members":["q"],"support":2,
1847            "tools":{"t":1.0},"skills":{}}]}"#;
1848        let g = IntentGraph::from_json(json).expect("valid graph");
1849        assert_eq!(g.intents[0].seeded_support, 0);
1850    }
1851
1852    #[test]
1853    fn a_graph_whose_seeded_support_exceeds_its_support_is_rejected() {
1854        // seeded_support counts a SUBSET of support; no producer can emit a
1855        // larger value, so accepting one would mean trusting a broken producer.
1856        let json = r#"{"v":1,"built_from_ts":1,
1857            "intents":[{"id":"i0","label":"l","members":["q"],"support":2,
1858            "seeded_support":3,"tools":{"t":1.0},"skills":{}}]}"#;
1859        assert!(matches!(
1860            IntentGraph::from_json(json),
1861            Err(IntentGraphError::Malformed(_))
1862        ));
1863    }
1864
1865    #[test]
1866    fn seeded_support_equal_to_support_is_the_normal_post_seeding_state() {
1867        let json = r#"{"v":1,"built_from_ts":1,
1868            "intents":[{"id":"i0","label":"l","members":["q"],"support":4,
1869            "seeded_support":4,"tools":{"t":1.0},"skills":{}}]}"#;
1870        assert!(IntentGraph::from_json(json).is_ok());
1871    }
1872
1873    #[test]
1874    fn two_graphs_differing_only_in_seeded_support_compare_equal() {
1875        // Identity is the evidence — members, centroid, support, edges.
1876        // Provenance is not evidence: a re-seeded graph must compare equal to an
1877        // equivalent live one, or round-trip and rebuild assertions start
1878        // failing for a field that cannot affect retrieval.
1879        let live = r#"{"v":1,"built_from_ts":1,
1880            "intents":[{"id":"i0","label":"l","members":["q"],"support":2,
1881            "tools":{"t":1.0},"skills":{}}]}"#;
1882        let seeded = r#"{"v":1,"built_from_ts":1,
1883            "intents":[{"id":"i0","label":"l","members":["q"],"support":2,
1884            "seeded_support":2,"tools":{"t":1.0},"skills":{}}]}"#;
1885        assert_eq!(
1886            IntentGraph::from_json(live).unwrap(),
1887            IntentGraph::from_json(seeded).unwrap()
1888        );
1889    }
1890
1891    // ---- rev: the persistence write-counter --------------------------------
1892
1893    #[test]
1894    fn observe_bumps_rev_once_per_mutation() {
1895        let mut g = IntentGraph::empty();
1896        assert_eq!(g.rev(), 0, "an empty graph has written nothing");
1897        g.observe_live("build broken", Capability::Tool, "a", T0, true);
1898        assert_eq!(g.rev(), 1);
1899        // A second observe on the same search adds an edge — a real change even
1900        // though it seeds no new member — so it must still count as one write.
1901        g.observe_live("build broken", Capability::Tool, "b", T0, false);
1902        assert_eq!(g.rev(), 2);
1903    }
1904
1905    #[test]
1906    fn a_no_op_observe_does_not_bump_rev() {
1907        // No words to cluster on and no stashed vector: `observe` returns before
1908        // changing anything, so the write-counter must not move. Guards against a
1909        // "bump unconditionally" regression.
1910        let mut g = IntentGraph::empty();
1911        g.observe_live("   ", Capability::Tool, "a", T0, true);
1912        assert_eq!(g.len(), 0);
1913        assert_eq!(g.rev(), 0);
1914    }
1915
1916    #[test]
1917    fn rev_survives_a_round_trip() {
1918        let mut g = IntentGraph::empty();
1919        g.observe_live("build broken", Capability::Tool, "a", T0, true);
1920        g.observe_live("rotate the signing key", Capability::Tool, "b", T0, true);
1921        let before = g.rev();
1922        assert_eq!(before, 2);
1923        let back = IntentGraph::from_json(&serde_json::to_string(&g).unwrap()).unwrap();
1924        assert_eq!(back.rev(), before, "rev must persist across the wire form");
1925    }
1926
1927    #[test]
1928    fn a_graph_without_rev_loads_as_zero_then_continues() {
1929        // An older or cloud-built graph carries no `rev`; it loads as 0 and the
1930        // counter continues up from there — monotonic across the gap.
1931        let json = r#"{"v":1,"built_from_ts":1,
1932            "intents":[{"id":"i0","label":"l","members":["q"],"support":2,
1933            "tools":{"t":1.0},"skills":{}}]}"#;
1934        let mut g = IntentGraph::from_json(json).expect("valid graph");
1935        assert_eq!(g.rev(), 0);
1936        g.observe_live("something new", Capability::Tool, "t", T0, true);
1937        assert_eq!(g.rev(), 1);
1938    }
1939
1940    #[test]
1941    fn an_unknown_field_is_ignored_on_load() {
1942        // Forward compatibility: a field a future build adds must be dropped, not
1943        // rejected — both at the graph and the intent level. Locks the current
1944        // (no `deny_unknown_fields`) behavior against regression.
1945        let json = r#"{"v":1,"built_from_ts":1,"future_top_level":42,
1946            "intents":[{"id":"i0","label":"l","members":["q"],"support":2,
1947            "tools":{"t":1.0},"skills":{},"future_intent_field":"x"}]}"#;
1948        let g = IntentGraph::from_json(json).expect("unknown fields must be ignored");
1949        assert_eq!(g.len(), 1);
1950        assert_eq!(g.intents[0].support, 2);
1951    }
1952
1953    #[test]
1954    fn an_empty_graph_contributes_no_arm() {
1955        let g = IntentGraph::empty();
1956        assert!(g.is_empty());
1957        assert!(
1958            g.arm_lexical("anything", Capability::Tool, &all_known)
1959                .is_none()
1960        );
1961        assert!(g.arm_dense(&[1.0], Capability::Tool, &all_known).is_none());
1962    }
1963
1964    // ---- dense matching ----------------------------------------------------
1965
1966    #[test]
1967    fn dense_match_returns_edges_best_first() {
1968        let mut it = intent("i0", &["why is the build broken"], &[]);
1969        it.centroid = Some(vec![1.0, 0.0, 0.0]);
1970        it.tools = [
1971            ("gh_run_view".to_string(), 0.2),
1972            ("gh_run_list".to_string(), 0.8),
1973        ]
1974        .into_iter()
1975        .collect();
1976        let g = graph(vec![it]);
1977        let arm = g
1978            .arm_dense(&[1.0, 0.0, 0.0], Capability::Tool, &all_known)
1979            .expect("exact match");
1980        assert_eq!(arm.ids, vec!["gh_run_list", "gh_run_view"]);
1981        assert_eq!(arm.intent_id, "i0");
1982    }
1983
1984    #[test]
1985    fn dense_match_below_tau_yields_no_arm() {
1986        let mut it = intent("i0", &["q"], &[("t", 1.0)]);
1987        it.centroid = Some(vec![1.0, 0.0]);
1988        let g = graph(vec![it]);
1989        // Orthogonal query: cosine 0, far below TAU_COSINE.
1990        assert!(
1991            g.arm_dense(&[0.0, 1.0], Capability::Tool, &all_known)
1992                .is_none()
1993        );
1994    }
1995
1996    #[test]
1997    fn dense_match_skips_centroids_of_a_different_dimension() {
1998        // A changed embedding model must never be compared across vector spaces.
1999        let mut it = intent("i0", &["q"], &[("t", 1.0)]);
2000        it.centroid = Some(vec![1.0, 0.0, 0.0]);
2001        let g = graph(vec![it]);
2002        assert!(
2003            g.arm_dense(&[1.0, 0.0], Capability::Tool, &all_known)
2004                .is_none()
2005        );
2006    }
2007
2008    #[test]
2009    fn dense_match_picks_the_closest_of_several_clusters() {
2010        let mut a = intent("a", &["q"], &[("ta", 1.0)]);
2011        a.centroid = Some(vec![1.0, 0.0]);
2012        let mut b = intent("b", &["q"], &[("tb", 1.0)]);
2013        b.centroid = Some(vec![0.8, 0.6]);
2014        let g = graph(vec![a, b]);
2015        let arm = g
2016            .arm_dense(&[0.8, 0.6], Capability::Tool, &all_known)
2017            .expect("match");
2018        assert_eq!(arm.intent_id, "b");
2019    }
2020
2021    // ---- lexical matching --------------------------------------------------
2022
2023    #[test]
2024    fn lexical_match_finds_a_repeat_phrasing() {
2025        let g = graph(vec![intent(
2026            "i0",
2027            &["why is the build broken", "is the build green"],
2028            &[("gh_run_list", 1.0)],
2029        )]);
2030        let arm = g
2031            .arm_lexical("is the build broken", Capability::Tool, &all_known)
2032            .expect("shares 'build' and 'broken'");
2033        assert_eq!(arm.ids, vec!["gh_run_list"]);
2034    }
2035
2036    #[test]
2037    fn lexical_match_cannot_bridge_disjoint_vocabulary() {
2038        // The documented ceiling of the Bm25 tier (ADR-0014): no shared content
2039        // tokens means no match, however semantically close the two queries are.
2040        // This is what the dense tier exists to fix — pinned so the boundary is a
2041        // test, not a claim in prose.
2042        let g = graph(vec![intent(
2043            "i0",
2044            &["why is the build broken"],
2045            &[("gh_run_list", 1.0)],
2046        )]);
2047        assert!(
2048            g.arm_lexical("did CI pass", Capability::Tool, &all_known)
2049                .is_none()
2050        );
2051    }
2052
2053    #[test]
2054    fn lexical_match_ignores_stopwords_only_queries() {
2055        let g = graph(vec![intent("i0", &["build"], &[("t", 1.0)])]);
2056        assert!(
2057            g.arm_lexical("is the", Capability::Tool, &all_known)
2058                .is_none()
2059        );
2060    }
2061
2062    // ---- edge filtering ----------------------------------------------------
2063
2064    #[test]
2065    fn edges_naming_capabilities_the_registry_lacks_are_dropped() {
2066        // A graph outlives a catalog change; ranking a ghost id would surface a
2067        // capability that cannot be invoked.
2068        let g = graph(vec![intent(
2069            "i0",
2070            &["build broken"],
2071            &[("gh_run_list", 0.8), ("since_deleted", 0.9)],
2072        )]);
2073        let arm = g
2074            .arm_lexical("build broken", Capability::Tool, &|id| {
2075                id != "since_deleted"
2076            })
2077            .expect("match");
2078        assert_eq!(arm.ids, vec!["gh_run_list"]);
2079    }
2080
2081    #[test]
2082    fn a_match_whose_every_edge_is_gone_yields_no_arm() {
2083        let g = graph(vec![intent("i0", &["build broken"], &[("gone", 1.0)])]);
2084        let outcome = g.arm_lexical("build broken", Capability::Tool, &|_| false);
2085        assert!(outcome.is_none(), "nothing reaches the fusion");
2086        // ...but the cluster DID match, and saying so is the whole point: a
2087        // caller that only saw "no arm" would read catalog drift as a coverage
2088        // gap and re-derive a graph that was never the problem.
2089        assert_eq!(
2090            outcome,
2091            ArmOutcome::AllFiltered {
2092                intent_id: "i0".into(),
2093                similarity: 1.0,
2094                support: 5,
2095                dropped: 1,
2096            }
2097        );
2098    }
2099
2100    #[test]
2101    fn a_cluster_with_no_edges_of_the_asked_kind_is_a_plain_miss() {
2102        // A tools-only cluster asked for skills has nothing to drop — that is
2103        // not drift, and reporting it as such would cry wolf on every mixed
2104        // graph.
2105        let g = graph(vec![intent("i0", &["build broken"], &[("a_tool", 1.0)])]);
2106        assert_eq!(
2107            g.arm_lexical("build broken", Capability::Skill, &all_known),
2108            ArmOutcome::NoMatch
2109        );
2110    }
2111
2112    #[test]
2113    fn tool_and_skill_edges_are_ranked_independently() {
2114        let mut it = intent("i0", &["build broken"], &[("a_tool", 1.0)]);
2115        it.skills = [("a_skill".to_string(), 1.0)].into_iter().collect();
2116        let g = graph(vec![it]);
2117        assert_eq!(
2118            g.arm_lexical("build broken", Capability::Tool, &all_known)
2119                .unwrap()
2120                .ids,
2121            vec!["a_tool"]
2122        );
2123        assert_eq!(
2124            g.arm_lexical("build broken", Capability::Skill, &all_known)
2125                .unwrap()
2126                .ids,
2127            vec!["a_skill"]
2128        );
2129    }
2130
2131    #[test]
2132    fn edges_rank_by_weight_not_by_id() {
2133        // The edges live in a BTreeMap, which already iterates id-ascending — so a
2134        // fixture whose weight order happens to agree with alphabetical order proves
2135        // nothing about the sort. Here they DISAGREE: `zulu` is the strongest edge
2136        // and must lead despite sorting last by id.
2137        let g = graph(vec![intent(
2138            "i0",
2139            &["build broken"],
2140            &[("alpha", 0.1), ("mike", 0.5), ("zulu", 0.9)],
2141        )]);
2142        let arm = g
2143            .arm_lexical("build broken", Capability::Tool, &all_known)
2144            .unwrap();
2145        assert_eq!(arm.ids, vec!["zulu", "mike", "alpha"]);
2146    }
2147
2148    #[test]
2149    fn tied_edge_weights_break_by_id_ascending() {
2150        let g = graph(vec![intent(
2151            "i0",
2152            &["build broken"],
2153            &[("zeta", 1.0), ("alpha", 1.0), ("mid", 1.0)],
2154        )]);
2155        let arm = g
2156            .arm_lexical("build broken", Capability::Tool, &all_known)
2157            .unwrap();
2158        assert_eq!(arm.ids, vec!["alpha", "mid", "zeta"]);
2159    }
2160
2161    #[test]
2162    fn round_trips_through_json() {
2163        let mut it = intent("i0", &["q"], &[("t", 1.0)]);
2164        it.centroid = Some(vec![0.8, 0.6]);
2165        let g = graph(vec![it]);
2166        let back = IntentGraph::from_json(&serde_json::to_string(&g).unwrap()).unwrap();
2167        assert_eq!(g, back);
2168    }
2169
2170    // ---- observe: the online learning step ---------------------------------
2171
2172    const T0: u64 = 1_753_000_000_000;
2173
2174    #[test]
2175    fn the_first_observation_seeds_a_cluster() {
2176        let mut g = IntentGraph::empty();
2177        g.observe_live(
2178            "why is the build broken",
2179            Capability::Tool,
2180            "gh_run_list",
2181            T0,
2182            true,
2183        );
2184
2185        assert_eq!(g.len(), 1);
2186        assert_eq!(g.intents[0].support, 1);
2187        assert_eq!(g.intents[0].members, vec!["why is the build broken"]);
2188        assert_eq!(g.intents[0].tools.get("gh_run_list"), Some(&1.0));
2189        // Grown lexically, so no centroid — `arm` must still match it.
2190        assert!(g.intents[0].centroid.is_none());
2191    }
2192
2193    #[test]
2194    fn a_similar_query_joins_the_existing_cluster() {
2195        let mut g = IntentGraph::empty();
2196        g.observe_live(
2197            "why is the build broken",
2198            Capability::Tool,
2199            "gh_run_list",
2200            T0,
2201            true,
2202        );
2203        g.observe_live(
2204            "is the build broken now",
2205            Capability::Tool,
2206            "gh_run_list",
2207            T0,
2208            true,
2209        );
2210
2211        assert_eq!(g.len(), 1, "should not have seeded a second cluster");
2212        assert_eq!(g.intents[0].support, 2);
2213        assert_eq!(g.intents[0].tools.get("gh_run_list"), Some(&2.0));
2214    }
2215
2216    #[test]
2217    fn a_dissimilar_query_seeds_its_own_cluster() {
2218        let mut g = IntentGraph::empty();
2219        g.observe_live(
2220            "why is the build broken",
2221            Capability::Tool,
2222            "gh_run_list",
2223            T0,
2224            true,
2225        );
2226        g.observe_live(
2227            "rotate the signing key",
2228            Capability::Tool,
2229            "vault_rotate",
2230            T0,
2231            true,
2232        );
2233
2234        assert_eq!(g.len(), 2);
2235        let ids: Vec<&str> = g.intents.iter().map(|i| i.id.as_str()).collect();
2236        assert_eq!(ids, vec!["intent_0", "intent_1"]);
2237    }
2238
2239    #[test]
2240    fn a_repeated_phrasing_is_not_duplicated_in_members() {
2241        // Members are the match key; repeating one must not inflate the token
2242        // bag and make the cluster match ever more loosely.
2243        let mut g = IntentGraph::empty();
2244        for _ in 0..3 {
2245            g.observe_live(
2246                "why is the build broken",
2247                Capability::Tool,
2248                "gh_run_list",
2249                T0,
2250                true,
2251            );
2252        }
2253        assert_eq!(g.intents[0].members.len(), 1);
2254        assert_eq!(
2255            g.intents[0].support, 3,
2256            "support still counts every observation"
2257        );
2258    }
2259
2260    #[test]
2261    fn learning_then_searching_closes_the_loop() {
2262        // The whole feature in one assertion: observe, then match a query that
2263        // was never observed verbatim.
2264        let mut g = IntentGraph::empty();
2265        g.observe_live(
2266            "why is the build broken",
2267            Capability::Tool,
2268            "gh_run_list",
2269            T0,
2270            true,
2271        );
2272        g.observe_live(
2273            "is the build broken again",
2274            Capability::Tool,
2275            "gh_run_list",
2276            T0,
2277            true,
2278        );
2279
2280        let arm = g
2281            .arm(
2282                "the build broken on main",
2283                None,
2284                Capability::Tool,
2285                &all_known,
2286            )
2287            .expect("a near-repeat of a member");
2288        assert_eq!(arm.ids, vec!["gh_run_list"]);
2289        assert_eq!(arm.support, 2);
2290    }
2291
2292    #[test]
2293    fn the_lexical_tier_does_not_reach_distant_wording() {
2294        // "is the build ok" and "why is the build broken" are the same question,
2295        // and this tier will not connect them — they share one word out of two,
2296        // which is indistinguishable from two unrelated asks that happen to
2297        // share a word (`one_shared_word_does_not_merge_distinct_intents`).
2298        //
2299        // No word-overlap rule can accept one and reject the other, so this tier
2300        // rejects both: a false merge degrades ranking, a false split only misses
2301        // a boost. Bridging distant wording is the dense tier's job.
2302        let mut g = IntentGraph::empty();
2303        g.observe_live(
2304            "why is the build broken",
2305            Capability::Tool,
2306            "gh_run_list",
2307            T0,
2308            true,
2309        );
2310        assert!(
2311            g.arm("is the build ok", None, Capability::Tool, &all_known)
2312                .is_none()
2313        );
2314    }
2315
2316    #[test]
2317    fn a_lexically_grown_graph_is_matchable_even_when_a_query_vector_is_offered() {
2318        // A semantic catalog hands `arm` a query vector, but a locally-learned
2319        // graph has no centroids to compare it against. It must fall back to
2320        // lexical matching rather than silently returning nothing.
2321        let mut g = IntentGraph::empty();
2322        g.observe_live(
2323            "why is the build broken",
2324            Capability::Tool,
2325            "gh_run_list",
2326            T0,
2327            true,
2328        );
2329
2330        let arm = g.arm(
2331            "why is the build broken",
2332            Some(&[0.1, 0.2, 0.3]),
2333            Capability::Tool,
2334            &all_known,
2335        );
2336        assert!(arm.is_some(), "must not be invisible to a semantic catalog");
2337    }
2338
2339    #[test]
2340    fn edges_rank_by_how_often_a_capability_was_chosen() {
2341        let mut g = IntentGraph::empty();
2342        for _ in 0..3 {
2343            g.observe_live(
2344                "why is the build broken",
2345                Capability::Tool,
2346                "chosen_often",
2347                T0,
2348                true,
2349            );
2350        }
2351        g.observe_live(
2352            "why is the build broken",
2353            Capability::Tool,
2354            "chosen_once",
2355            T0,
2356            true,
2357        );
2358
2359        let arm = g
2360            .arm(
2361                "why is the build broken",
2362                None,
2363                Capability::Tool,
2364                &all_known,
2365            )
2366            .unwrap();
2367        assert_eq!(arm.ids, vec!["chosen_often", "chosen_once"]);
2368    }
2369
2370    #[test]
2371    fn built_from_ts_tracks_the_newest_event_and_never_rewinds() {
2372        // Provenance only — it says how current the graph is. Traces are loosely
2373        // ordered (ADR-0007), so a late-arriving older event must not drag it back.
2374        let mut g = IntentGraph::empty();
2375        g.observe_live("build broken", Capability::Tool, "a", T0 + 10, true);
2376        g.observe_live("build broken", Capability::Tool, "b", T0, true);
2377        assert_eq!(g.built_from_ts, T0 + 10);
2378    }
2379
2380    #[test]
2381    fn the_token_cache_stays_in_step_with_members() {
2382        // The cache is derived from `members`; if the two drift, a query stops
2383        // matching a cluster that plainly covers it. Silent, and invisible to
2384        // every other test — so pin it directly.
2385        let mut g = IntentGraph::empty();
2386        g.observe_live("why is the build broken", Capability::Tool, "t", T0, true);
2387        g.observe_live("the pipeline is broken", Capability::Tool, "t", T0, true);
2388
2389        let it = &g.intents[0];
2390        let fresh: std::collections::HashSet<String> =
2391            it.members.iter().flat_map(|m| tokenize(m)).collect();
2392        assert_eq!(&it.bag, &fresh, "union cache drifted from members");
2393
2394        // The per-member sets are what scoring actually reads, and they are
2395        // positional — a drift here silently stops a cluster matching queries it
2396        // plainly covers.
2397        assert_eq!(it.member_bags.len(), it.members.len(), "one set per member");
2398        for (m, bag) in it.members.iter().zip(&it.member_bags) {
2399            let fresh: std::collections::HashSet<String> = tokenize(m).into_iter().collect();
2400            assert_eq!(bag, &fresh, "member set drifted for {m:?}");
2401        }
2402    }
2403
2404    #[test]
2405    fn a_deserialized_graph_can_still_match_lexically() {
2406        // The cache is skipped on the wire, so `from_json` must rebuild it —
2407        // otherwise a reloaded graph silently matches nothing.
2408        let mut g = IntentGraph::empty();
2409        g.observe_live(
2410            "why is the build broken",
2411            Capability::Tool,
2412            "gh_run_list",
2413            T0,
2414            true,
2415        );
2416        let back = IntentGraph::from_json(&serde_json::to_string(&g).unwrap()).unwrap();
2417
2418        assert!(
2419            back.arm(
2420                "why is the build broken",
2421                None,
2422                Capability::Tool,
2423                &all_known
2424            )
2425            .is_some(),
2426            "a reloaded graph must still match"
2427        );
2428    }
2429
2430    #[test]
2431    fn the_centroid_is_folded_once_per_distinct_member() {
2432        // The centroid is the mean of the cluster's DISTINCT member texts, so
2433        // extra invokes from one search must not fold that query's vector again.
2434        //
2435        // Two members are essential here: with a single member the running mean
2436        // is `c*(n-1) + v = v`, so re-folding is idempotent and a one-member
2437        // fixture passes even when the guard is removed.
2438        let v1 = [1.0f32, 0.0, 0.0];
2439        let v2 = [0.0f32, 1.0, 0.0];
2440        let build = |extra: usize| {
2441            let mut g = IntentGraph::empty();
2442            g.note_query_vector("build broken", &v1, "m");
2443            g.observe_live("build broken", Capability::Tool, "a", T0, true);
2444            g.note_query_vector("build broken again", &v2, "m");
2445            g.observe_live("build broken again", Capability::Tool, "b", T0, true);
2446            for i in 0..extra {
2447                g.observe_live(
2448                    "build broken again",
2449                    Capability::Tool,
2450                    &format!("x{i}"),
2451                    T0,
2452                    false,
2453                );
2454            }
2455            g.intents[0].centroid.clone().unwrap()
2456        };
2457
2458        let once = build(0);
2459        let with_extra_invokes = build(3);
2460        for (a, b) in once.iter().zip(&with_extra_invokes) {
2461            assert!(
2462                (a - b).abs() < 1e-6,
2463                "extra invokes moved the centroid: {once:?} vs {with_extra_invokes:?}"
2464            );
2465        }
2466    }
2467
2468    #[test]
2469    fn a_later_invoke_landing_elsewhere_still_has_support() {
2470        // A cluster moves as it learns, so a second invoke from the same search
2471        // can match a DIFFERENT cluster than the first did. That cluster is new,
2472        // so it must still start at 1 — `protocol/v1` requires support >= 1, and
2473        // a zero-support cluster would contribute a weightless arm.
2474        let mut g = IntentGraph::empty();
2475        g.observe_live("why is the build broken", Capability::Tool, "a", T0, false);
2476        assert_eq!(g.intents[0].support, 1);
2477    }
2478
2479    // ---- lexical clustering must not over-merge -----------------------------
2480
2481    #[test]
2482    fn one_shared_word_does_not_merge_distinct_intents() {
2483        // The bug, minimally. Two unrelated asks sharing a single word were
2484        // exactly 50% "covered" by each other and merged.
2485        let mut g = IntentGraph::empty();
2486        g.observe_live("deploy0 rollback3", Capability::Tool, "a", T0, true);
2487        g.observe_live("deploy0 migrate5", Capability::Tool, "b", T0, true);
2488        assert_eq!(g.len(), 2, "one shared word is not the same question");
2489    }
2490
2491    #[test]
2492    fn a_large_cluster_does_not_absorb_an_unrelated_query() {
2493        // The runaway: the old score was measured against the UNION of every
2494        // member, which only grows — so a mature cluster recognized most of the
2495        // vocabulary and swallowed anything, which grew it further.
2496        let mut g = IntentGraph::empty();
2497        for i in 0..30 {
2498            g.observe_live(
2499                &format!("build broken variant{i}"),
2500                Capability::Tool,
2501                "gh_run_list",
2502                T0,
2503                true,
2504            );
2505        }
2506        assert_eq!(g.len(), 1, "those really are one ask");
2507
2508        // Every word of this query appears somewhere in that cluster's 32-word
2509        // union — but no single member shares more than one of them. Scoring
2510        // against the union called it a perfect match; scoring against members
2511        // calls it 0.25.
2512        g.observe_live("variant7 variant12", Capability::Tool, "vault", T0, true);
2513        assert_eq!(
2514            g.len(),
2515            2,
2516            "a big cluster must not absorb by sheer vocabulary"
2517        );
2518    }
2519
2520    #[test]
2521    fn distinct_topics_do_not_collapse_at_scale() {
2522        // Collapse only shows once unions have grown, which is why small
2523        // fixtures never caught it: 40 separable topics used to end up as 11
2524        // clusters. These phrasings are deliberately adversarial — two words
2525        // each, low overlap — so a HIGH cluster count is the right outcome here.
2526        // This asserts the absence of collapse; `near_repeats_still_merge`
2527        // covers the other direction.
2528        const WORDS: [&str; 20] = [
2529            "deploy", "rollback", "migrate", "schema", "invoice", "refund", "tenant", "webhook",
2530            "cursor", "throttle", "quota", "shard", "replica", "index", "vault", "rotate", "lease",
2531            "beacon", "harvest", "prune",
2532        ];
2533        let mut g = IntentGraph::empty();
2534        for topic in 0..40 {
2535            for phrasing in 0..10 {
2536                let q = format!(
2537                    "{}{topic} {}{phrasing}",
2538                    WORDS[topic % 20],
2539                    WORDS[(topic + phrasing) % 20]
2540                );
2541                g.observe_live(&q, Capability::Tool, &format!("t{topic}"), T0, true);
2542            }
2543        }
2544        assert!(
2545            g.len() >= 35,
2546            "40 distinct topics collapsed into {} clusters",
2547            g.len()
2548        );
2549    }
2550
2551    #[test]
2552    fn near_repeats_still_merge() {
2553        // The fix must not over-split: rephrasings of one ask stay together.
2554        let mut g = IntentGraph::empty();
2555        for q in [
2556            "why is the build broken",
2557            "is the build broken again",
2558            "the build broken on main",
2559        ] {
2560            g.observe_live(q, Capability::Tool, "gh_run_list", T0, true);
2561        }
2562        for q in ["rotate the signing key", "rotate the signing key now"] {
2563            g.observe_live(q, Capability::Tool, "vault_rotate", T0, true);
2564        }
2565        assert_eq!(g.len(), 2, "two asks, however phrased");
2566        assert_eq!(g.intents[0].members.len(), 3);
2567        assert_eq!(g.intents[1].members.len(), 2);
2568    }
2569
2570    // ---- labels ------------------------------------------------------------
2571
2572    #[test]
2573    fn the_label_is_always_one_of_the_members() {
2574        // Counted from the data, so it cannot describe the cluster wrongly.
2575        let mut g = IntentGraph::empty();
2576        g.observe_live("why is the build broken", Capability::Tool, "t", T0, true);
2577        g.observe_live("is the build broken now", Capability::Tool, "t", T0, true);
2578
2579        let it = &g.labeled()[0];
2580        assert!(
2581            it.members.contains(&it.label),
2582            "label {:?} not a member",
2583            it.label
2584        );
2585    }
2586
2587    #[test]
2588    fn terms_distinguish_a_cluster_from_its_neighbours() {
2589        let mut g = IntentGraph::empty();
2590        g.observe_live("why is the build broken", Capability::Tool, "t", T0, true);
2591        g.observe_live("the build is broken again", Capability::Tool, "t", T0, true);
2592        g.observe_live("rotate the signing key", Capability::Tool, "v", T0, true);
2593
2594        let build = &g.labeled()[0];
2595        assert!(
2596            build.terms.contains(&"build".to_string()),
2597            "got {:?}",
2598            build.terms
2599        );
2600        assert!(!build.terms.contains(&"rotate".to_string()));
2601    }
2602
2603    #[test]
2604    fn terms_are_scored_against_the_graph_as_it_is_now() {
2605        // c-TF-IDF ranks a term by how rare it is across the OTHER clusters, so a
2606        // value frozen when a cluster was last written goes stale the moment the
2607        // graph grows. This used to be computed inside `observe`, which made every
2608        // label describe a graph that no longer existed.
2609        let mut g = IntentGraph::empty();
2610        g.observe_live("why is the build broken", Capability::Tool, "t", T0, true);
2611        g.observe_live("the build broken again", Capability::Tool, "t", T0, true);
2612        let alone = g.labeled()[0].terms.clone();
2613
2614        // A second cluster that also uses "again" makes that term less
2615        // distinguishing for the first — which must be reflected even though the
2616        // first cluster was never touched again.
2617        g.observe_live(
2618            "tail the service log again",
2619            Capability::Tool,
2620            "u",
2621            T0,
2622            true,
2623        );
2624        let with_neighbour = g.labeled()[0].terms.clone();
2625
2626        let rank = |terms: &[String], t: &str| terms.iter().position(|x| x == t);
2627        assert!(
2628            rank(&with_neighbour, "again") >= rank(&alone, "again"),
2629            "\"again\" should not gain rank once a neighbour shares it: {alone:?} -> {with_neighbour:?}"
2630        );
2631    }
2632
2633    #[test]
2634    fn the_label_is_derived_not_stored() {
2635        // Nothing writes `label` during learning; it is materialized on read, so
2636        // two graphs holding the same evidence are equal regardless.
2637        let mut g = IntentGraph::empty();
2638        g.observe_live("why is the build broken", Capability::Tool, "t", T0, true);
2639        assert!(
2640            g.intents[0].label.is_empty(),
2641            "not stored on the write path"
2642        );
2643        assert!(!g.labeled()[0].label.is_empty(), "materialized on read");
2644    }
2645
2646    #[test]
2647    fn a_stopword_only_query_teaches_nothing() {
2648        let mut g = IntentGraph::empty();
2649        g.observe_live("is the", Capability::Tool, "t", T0, true);
2650        assert!(g.is_empty());
2651    }
2652
2653    #[test]
2654    fn tool_and_skill_observations_land_on_separate_edge_maps() {
2655        let mut g = IntentGraph::empty();
2656        g.observe_live(
2657            "why is the build broken",
2658            Capability::Tool,
2659            "gh_run_list",
2660            T0,
2661            true,
2662        );
2663        g.observe_live(
2664            "why is the build broken",
2665            Capability::Skill,
2666            "ci-triage",
2667            T0,
2668            true,
2669        );
2670
2671        assert_eq!(g.len(), 1);
2672        assert_eq!(g.intents[0].tools.len(), 1);
2673        assert_eq!(g.intents[0].skills.len(), 1);
2674    }
2675
2676    #[test]
2677    fn a_learned_graph_round_trips_through_the_wire_form() {
2678        let mut g = IntentGraph::empty();
2679        g.observe_live(
2680            "why is the build broken",
2681            Capability::Tool,
2682            "gh_run_list",
2683            T0,
2684            true,
2685        );
2686        let back = IntentGraph::from_json(&serde_json::to_string(&g).unwrap()).unwrap();
2687        assert_eq!(g, back);
2688    }
2689    // ---- embedding-model change detection (centroids are model-specific) -----
2690
2691    fn dense_intent(id: &str, centroid: Vec<f32>) -> Intent {
2692        let mut it = intent(id, &["why is the build broken"], &[("gh_run_list", 1.0)]);
2693        it.centroid = Some(normalize(centroid));
2694        it
2695    }
2696
2697    #[test]
2698    fn model_status_ok_for_a_lexical_graph() {
2699        // No centroids → nothing model-specific → always usable.
2700        let g = graph(vec![intent("i0", &["build broken"], &[("t", 1.0)])]);
2701        assert_eq!(g.model_status("any-model", 384), GraphModelStatus::Ok);
2702    }
2703
2704    #[test]
2705    fn model_status_flags_a_dimension_change() {
2706        let mut g = graph(vec![dense_intent("i0", vec![1.0, 0.0, 0.0])]);
2707        g.model = Some("bge-small".into());
2708        assert_eq!(
2709            g.model_status("bge-base", 768),
2710            GraphModelStatus::DimMismatch {
2711                built: 3,
2712                active: 768
2713            }
2714        );
2715    }
2716
2717    #[test]
2718    fn model_status_flags_a_same_dim_model_change() {
2719        // The case a length check cannot catch: same width, different model.
2720        let mut g = graph(vec![dense_intent("i0", vec![1.0, 0.0, 0.0])]);
2721        g.model = Some("model-a".into());
2722        assert_eq!(
2723            g.model_status("model-b", 3),
2724            GraphModelStatus::ModelMismatch {
2725                built: "model-a".into(),
2726                active: "model-b".into()
2727            }
2728        );
2729    }
2730
2731    #[test]
2732    fn model_status_ok_when_the_model_matches() {
2733        let mut g = graph(vec![dense_intent("i0", vec![1.0, 0.0, 0.0])]);
2734        g.model = Some("model-a".into());
2735        assert_eq!(g.model_status("model-a", 3), GraphModelStatus::Ok);
2736    }
2737
2738    #[test]
2739    fn intent_graph_accepts_pre_pr_local_path_model_fingerprint() {
2740        let mut g = graph(vec![dense_intent("i0", vec![1.0, 0.0, 0.0])]);
2741        let pre_pr = "local|path=11:/models/foo";
2742        g.model = Some(pre_pr.into());
2743        assert_eq!(
2744            g.model_status(pre_pr, 3),
2745            GraphModelStatus::Ok,
2746            "pre-PR Local path fingerprints must remain IntentGraph-compatible"
2747        );
2748        assert!(matches!(
2749            g.model_status(
2750                "local|content=64:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
2751                3
2752            ),
2753            GraphModelStatus::ModelMismatch { .. }
2754        ));
2755    }
2756
2757    #[test]
2758    fn observe_stamps_the_model_on_the_first_centroid() {
2759        let mut g = IntentGraph::empty();
2760        g.note_query_vector("build broken", &[1.0, 0.0, 0.0], "model-a");
2761        g.observe_live("build broken", Capability::Tool, "t", T0, true);
2762        assert_eq!(g.model.as_deref(), Some("model-a"));
2763    }
2764
2765    #[test]
2766    fn observe_freezes_the_centroid_on_a_model_change() {
2767        // Grow under model-a, then an observation arrives embedded by model-b.
2768        // Member/support must still update, but the centroid must NOT blend the
2769        // two vector spaces.
2770        let mut g = IntentGraph::empty();
2771        g.note_query_vector("build broken", &[1.0, 0.0, 0.0], "model-a");
2772        g.observe_live("build broken", Capability::Tool, "t", T0, true);
2773        let frozen = g.intents[0].centroid.clone();
2774
2775        g.note_query_vector("build broken again", &[0.0, 1.0, 0.0], "model-b");
2776        g.observe_live("build broken again", Capability::Tool, "t", T0, true);
2777
2778        assert_eq!(
2779            g.intents[0].centroid, frozen,
2780            "centroid must not blend models"
2781        );
2782        assert_eq!(g.intents[0].members.len(), 2, "member still recorded");
2783        assert_eq!(g.intents[0].support, 2, "support still counts");
2784        assert_eq!(g.model.as_deref(), Some("model-a"), "model unchanged");
2785    }
2786
2787    #[test]
2788    fn rebuild_centroids_re_embeds_members_and_restamps() {
2789        let mut g = IntentGraph::empty();
2790        g.note_query_vector("build broken", &[1.0, 0.0, 0.0], "model-a");
2791        g.observe_live("build broken", Capability::Tool, "gh_run_list", T0, true);
2792        let rev_before = g.rev();
2793
2794        // Members re-embedded under model-b (here, just different vectors).
2795        let id = g.intents[0].id.clone();
2796        g.rebuild_centroids(vec![(id, vec![vec![0.0, 1.0, 0.0]])], "model-b".into());
2797
2798        // A rebuild is a persistable change — it must advance the write counter.
2799        assert_eq!(g.rev(), rev_before + 1);
2800        assert_eq!(g.model.as_deref(), Some("model-b"));
2801        assert_eq!(g.model_status("model-b", 3), GraphModelStatus::Ok);
2802        // Learning preserved.
2803        assert_eq!(g.intents[0].support, 1);
2804        assert_eq!(g.intents[0].tools.get("gh_run_list"), Some(&1.0));
2805        // Centroid moved to the new (normalized) vector.
2806        let c = g.intents[0].centroid.as_ref().unwrap();
2807        assert!((c[1] - 1.0).abs() < 1e-6);
2808    }
2809
2810    #[test]
2811    fn rebuild_centroids_follows_ids_when_a_cluster_is_evicted_mid_rebuild() {
2812        // `rebuild_intent_graph` snapshots members, embeds WITHOUT the lock, then
2813        // re-locks to apply — a concurrent `observe()` can evict a cluster in
2814        // that window and shift positions. Centroids must reattach by id, not by
2815        // position, or a survivor silently inherits the evicted cluster's vector
2816        // (and the fresh model stamp hides it).
2817        let mut g = IntentGraph::empty();
2818        g.observe_live("alpha query", Capability::Tool, "a", T0, true);
2819        g.observe_live("bravo query", Capability::Tool, "b", T0, true);
2820        g.observe_live("charlie query", Capability::Tool, "c", T0, true);
2821        assert_eq!(g.len(), 3, "three disjoint queries → three clusters");
2822        let id_a = g.intents[0].id.clone();
2823        let id_b = g.intents[1].id.clone();
2824        let id_c = g.intents[2].id.clone();
2825
2826        // Embeddings computed from the snapshot order [a, b, c], each a distinct
2827        // axis so a misassignment is unambiguous.
2828        let per_cluster = vec![
2829            (id_a.clone(), vec![vec![1.0, 0.0, 0.0]]),
2830            (id_b.clone(), vec![vec![0.0, 1.0, 0.0]]),
2831            (id_c.clone(), vec![vec![0.0, 0.0, 1.0]]),
2832        ];
2833
2834        // ...but by apply time an observe() evicted cluster A, shifting b and c
2835        // down one slot. Position-zip would give b the [1,0,0] meant for a.
2836        g.intents.retain(|it| it.id != id_a);
2837        assert_eq!(g.len(), 2);
2838
2839        g.rebuild_centroids(per_cluster, "model-b".into());
2840
2841        let b = g.intents.iter().find(|it| it.id == id_b).unwrap();
2842        assert!(
2843            (b.centroid.as_ref().unwrap()[1] - 1.0).abs() < 1e-6,
2844            "cluster b must keep its own centroid, got {:?}",
2845            b.centroid
2846        );
2847        let c = g.intents.iter().find(|it| it.id == id_c).unwrap();
2848        assert!(
2849            (c.centroid.as_ref().unwrap()[2] - 1.0).abs() < 1e-6,
2850            "cluster c must keep its own centroid, got {:?}",
2851            c.centroid
2852        );
2853    }
2854
2855    #[test]
2856    fn the_model_field_round_trips_through_json() {
2857        let mut g = graph(vec![dense_intent("i0", vec![0.6, 0.8, 0.0])]);
2858        g.model = Some("bge-small".into());
2859        let back = IntentGraph::from_json(&serde_json::to_string(&g).unwrap()).unwrap();
2860        assert_eq!(back.model.as_deref(), Some("bge-small"));
2861    }
2862
2863    #[test]
2864    fn from_json_matches_the_conformance_valid_and_invalid_sets() {
2865        // The protocol mandates every consumer reject the `invalid` set and
2866        // accept the `valid` set (protocol/v1/conformance/vectors.json). Drive
2867        // both directly off the fixtures so this consumer stays conformant.
2868        let vectors: serde_json::Value = serde_json::from_str(include_str!(
2869            "../../../protocol/v1/conformance/vectors.json"
2870        ))
2871        .expect("conformance vectors parse");
2872        let graph = &vectors["graph"];
2873
2874        for case in graph["invalid"].as_array().unwrap() {
2875            let name = case["name"].as_str().unwrap();
2876            let doc = serde_json::to_string(&case["doc"]).unwrap();
2877            assert!(
2878                IntentGraph::from_json(&doc).is_err(),
2879                "invalid vector `{name}` must be rejected"
2880            );
2881        }
2882        for case in graph["valid"].as_array().unwrap() {
2883            let name = case["name"].as_str().unwrap();
2884            let doc = serde_json::to_string(&case["doc"]).unwrap();
2885            assert!(
2886                IntentGraph::from_json(&doc).is_ok(),
2887                "valid vector `{name}` must be accepted"
2888            );
2889        }
2890    }
2891
2892    #[test]
2893    fn a_lexical_graph_omits_the_model_on_the_wire() {
2894        let g = graph(vec![intent("i0", &["build broken"], &[("t", 1.0)])]);
2895        let json = serde_json::to_string(&g).unwrap();
2896        assert!(
2897            !json.contains("model"),
2898            "no model field for a centroid-less graph"
2899        );
2900    }
2901    // ---- recency: decay, eviction, member cap (blocker #3) -----------------
2902
2903    const DAY: u64 = 86_400_000;
2904
2905    #[test]
2906    fn a_recent_cluster_keeps_full_weight_within_the_grace() {
2907        let mut g = IntentGraph::empty();
2908        for _ in 0..3 {
2909            g.observe_live("why is the build broken", Capability::Tool, "t", T0, true);
2910        }
2911        // now == last_ts → Δt 0 → recency 1; support 3 → ramp 1.
2912        let arm = g
2913            .arm(
2914                "why is the build broken",
2915                None,
2916                Capability::Tool,
2917                &all_known,
2918            )
2919            .unwrap();
2920        assert!((arm.weight() - USAGE_WEIGHT).abs() < 1e-6);
2921    }
2922
2923    #[test]
2924    fn a_stale_cluster_decays_after_the_grace() {
2925        let mut g = IntentGraph::empty();
2926        for _ in 0..3 {
2927            g.observe_live("why is the build broken", Capability::Tool, "t", T0, true);
2928        }
2929        // Advance the graph's clock 200 days via a different topic.
2930        g.observe_live(
2931            "rotate the signing key",
2932            Capability::Tool,
2933            "v",
2934            T0 + 200 * DAY,
2935            true,
2936        );
2937
2938        let arm = g
2939            .arm(
2940                "why is the build broken",
2941                None,
2942                Capability::Tool,
2943                &all_known,
2944            )
2945            .unwrap();
2946        let expected = USAGE_WEIGHT * 2f32.powf(-((200.0 - 90.0) / 90.0));
2947        assert!(
2948            (arm.weight() - expected).abs() < 1e-3,
2949            "got {} expected {expected}",
2950            arm.weight()
2951        );
2952        assert!(
2953            arm.weight() < USAGE_WEIGHT,
2954            "a cold cluster must weigh less"
2955        );
2956    }
2957
2958    #[test]
2959    fn a_long_idle_cluster_is_evicted() {
2960        let mut g = IntentGraph::empty();
2961        for _ in 0..3 {
2962            g.observe_live("why is the build broken", Capability::Tool, "t", T0, true);
2963        }
2964        assert_eq!(g.len(), 1);
2965        // ~2 years of other activity later: the build cluster is past the floor.
2966        g.observe_live(
2967            "rotate the signing key",
2968            Capability::Tool,
2969            "v",
2970            T0 + 700 * DAY,
2971            true,
2972        );
2973        assert_eq!(
2974            g.len(),
2975            1,
2976            "the stale cluster was evicted, the fresh one stays"
2977        );
2978        assert!(
2979            g.arm(
2980                "why is the build broken",
2981                None,
2982                Capability::Tool,
2983                &all_known
2984            )
2985            .is_none(),
2986            "an evicted cluster contributes no arm"
2987        );
2988    }
2989
2990    #[test]
2991    fn members_are_capped_per_cluster() {
2992        let mut g = IntentGraph::empty();
2993        for i in 0..MEMBER_CAP + 20 {
2994            g.observe_live(
2995                &format!("build broken variant{i}"),
2996                Capability::Tool,
2997                "t",
2998                T0,
2999                true,
3000            );
3001        }
3002        assert_eq!(g.len(), 1, "near-repeats form one cluster");
3003        assert!(
3004            g.intents[0].members.len() <= MEMBER_CAP,
3005            "members capped, got {}",
3006            g.intents[0].members.len()
3007        );
3008        // The token cache stays in step with the trimmed members.
3009        let fresh: std::collections::HashSet<String> = g.intents[0]
3010            .members
3011            .iter()
3012            .flat_map(|m| tokenize(m))
3013            .collect();
3014        assert_eq!(&g.intents[0].bag, &fresh);
3015    }
3016}