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}