ratel_ai_core/usage_learner.rs
1//! The online learner: turns the trace stream into an [`IntentGraph`]
2//! (ADR-0014).
3//!
4//! # Why this is a sink
5//!
6//! The two halves of a relevance judgment arrive through *different* API calls —
7//! a search, then an invoke — and the registries have no session concept to join
8//! them with. Trace sinks do: each is constructed per session. ADR-0007 already
9//! frames the sink as the subscription seam ("rerankers, suggestion analysis,
10//! and inspection subscribe to different cuts of the same producer"), so the
11//! learner needs no new plumbing — it decorates whatever sink is already
12//! installed and forwards every event untouched.
13//!
14//! **One learner per session.** Two sessions sharing one learner would cross-pair
15//! their searches and invokes and record edges nobody produced.
16//!
17//! # What counts as evidence
18//!
19//! ```text
20//! Search{query} → remembered as this session's pending query
21//! InvokeStart{tool} → paired with it → one confirmed observation
22//! ```
23//!
24//! Only **invocations** become edges. What retrieval *returned* is the ranker's
25//! own guess; recording it would teach the graph what it already believes and
26//! reinforce its mistakes. A search nobody acts on teaches nothing and is
27//! dropped.
28//!
29//! A pending query survives until the next search replaces it, so an agent that
30//! searches once and invokes three tools records three observations — that is
31//! genuinely what happened.
32//!
33//! # How far a cluster reaches
34//!
35//! A [`TraceEvent::Search`] carries the query *text*, not its embedding, so the
36//! sink alone could only cluster on words. A semantic/hybrid registry closes
37//! that gap: it has already embedded the query for its own ranking and stashes
38//! that vector on the graph, so the learner grows a real centroid and clusters
39//! phrasings that share **no vocabulary** — "delete a path" with "remove
40//! something". A `Bm25` registry loads no model (ADR-0011), so its clusters
41//! carry no centroid and reach repeats and near-repeats only.
42//! [`IntentGraph::arm`] picks the tier from what the graph carries, so either
43//! kind works on every [`crate::SearchMethod`].
44
45use std::collections::HashMap;
46use std::sync::{Arc, Mutex, RwLock};
47use std::time::{SystemTime, UNIX_EPOCH};
48
49use crate::trace::{Origin, TraceEnvelope, TraceEvent, TraceEventContext, TraceSink};
50use crate::usage::{Capability, IntentGraph, Observation};
51
52/// The learner's most recent search — the query an invoke attributes to. Kept
53/// per-learner (not read from the shared graph) so a concurrent search from
54/// another session cannot misattribute this learner's invoke. Whether the
55/// question has already been credited a support bump lives on the shared graph
56/// ([`IntentGraph::claim_credit`]), so per-catalog tool and skill learners count
57/// one fanned-out question once between them, not once each.
58struct Pending {
59 query: String,
60}
61
62/// Which searches may open an observation window.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64#[non_exhaustive]
65pub enum OriginFilter {
66 /// Pair an invoke with the most recent search of **any** origin.
67 #[default]
68 Any,
69 /// Pair only with searches carrying exactly this origin — the setting a
70 /// baseline capture uses, with [`crate::Origin::Baseline`].
71 ///
72 /// A search of any other origin is **ignored entirely**: it does not become
73 /// the pending query and it does not arm a support credit. Ignoring rather
74 /// than clearing is deliberate — one of Ratel's own internal searches
75 /// landing between a baseline query and its invokes must not discard the
76 /// turn's evidence.
77 Exactly(Origin),
78}
79
80/// Whether observations are recorded as seeded evidence.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
82#[non_exhaustive]
83pub enum Provenance {
84 /// Live serving traffic — raises `support` only.
85 #[default]
86 Live,
87 /// A seeding pass (a baseline capture, or a replay of one) — additionally
88 /// raises [`crate::Intent::seeded_support`].
89 Seeded,
90}
91
92/// How a [`UsageLearner`] turns a trace stream into observations.
93///
94/// [`Default`] reproduces today's behavior exactly, field for field — pinned by
95/// `the_default_policy_reproduces_todays_pairing_exactly`. Build from it with
96/// the `with_*` setters:
97///
98/// ```
99/// use ratel_ai_core::{ObservationPolicy, Origin, OriginFilter, Provenance};
100///
101/// // A baseline capture: only observed queries teach, and everything learned
102/// // is marked as seeded.
103/// let seeding = ObservationPolicy::default()
104/// .with_origins(OriginFilter::Exactly(Origin::Baseline))
105/// .with_provenance(Provenance::Seeded);
106///
107/// assert_eq!(seeding.provenance, Provenance::Seeded);
108/// assert_eq!(ObservationPolicy::default().origins, OriginFilter::Any);
109/// ```
110///
111/// The struct is `#[non_exhaustive]`, which is what makes a future field
112/// additive rather than breaking — and is also why the setters exist rather
113/// than struct-literal syntax: a `#[non_exhaustive]` struct cannot be built
114/// with a struct expression outside its defining crate at all, `..Default`
115/// included. Fields stay public for reading.
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
117#[non_exhaustive]
118pub struct ObservationPolicy {
119 /// Which searches open an observation window.
120 pub origins: OriginFilter,
121 /// Whether what is learned is marked as seeded.
122 pub provenance: Provenance,
123}
124
125impl ObservationPolicy {
126 /// Set which searches open an observation window.
127 pub fn with_origins(mut self, origins: OriginFilter) -> Self {
128 self.origins = origins;
129 self
130 }
131
132 /// Set whether what is learned is marked as seeded.
133 pub fn with_provenance(mut self, provenance: Provenance) -> Self {
134 self.provenance = provenance;
135 self
136 }
137}
138
139/// What one trace event means for learning, under a policy.
140///
141/// **The pairing rule, in one place.** The live path and the replay path differ
142/// in where they keep pending state — a per-session learner holds a `Mutex`
143/// slot, a replay holds a map keyed by `session_id` — but they must agree
144/// exactly on *which event does what*, or a graph built from a log stops
145/// matching the one live learning would have grown from the same events. Having
146/// written that match twice, a later change (a new confirming event, a pairing
147/// strategy) would have to land in both with nothing forcing the second.
148#[derive(Debug, PartialEq)]
149pub(crate) enum Step<'a> {
150 /// This search opens an observation window for `query`.
151 Remember(&'a str),
152 /// This invocation closes one, confirming `capability_id` of `kind`.
153 Confirm(Capability, &'a str),
154 /// Not evidence — including a search the policy rejects, which is **ignored
155 /// rather than treated as a boundary**, so one of Ratel's own internal
156 /// searches landing mid-turn cannot discard the turn's evidence.
157 Ignore,
158}
159
160pub(crate) fn classify(event: &TraceEvent, policy: ObservationPolicy) -> Step<'_> {
161 match event {
162 // Both search kinds open a window: a capability search hits the tool and
163 // skill registries in turn with the same text.
164 TraceEvent::Search { query, origin, .. }
165 | TraceEvent::SkillSearch { query, origin, .. }
166 if accepts(policy, *origin) =>
167 {
168 Step::Remember(query)
169 }
170 // The invocation the agent CHOSE to make. A trace records which tool was
171 // called, never whether calling it was right, so completion is not a
172 // second signal: filtering on `invoke_end` would drop good choices that
173 // failed on their arguments while keeping wrong ones that ran fine.
174 TraceEvent::InvokeStart { tool_id, .. } => Step::Confirm(Capability::Tool, tool_id),
175 TraceEvent::SkillInvoke { skill_id, .. } => Step::Confirm(Capability::Skill, skill_id),
176 _ => Step::Ignore,
177 }
178}
179
180/// Replay a whole trace log into `graph`, pairing searches with invokes
181/// **per session** while walking the log in its own order.
182///
183/// Both halves of that are load-bearing:
184///
185/// - **Per-session pending state.** Sessions interleave in one log and share one
186/// graph; feeding them through a single pending slot would cross-pair one
187/// session's search with another's invoke and record edges nobody produced
188/// (the rule the module doc states for [`UsageLearner`] itself).
189/// - **Log order, never re-sorted.** `JsonlSink` appends, so file order *is*
190/// arrival order — what the live path saw. Sorting by `ts` would produce a
191/// graph the live path could not have grown, since cluster membership depends
192/// on which clusters existed when each query arrived. `ts` is used to *stamp*
193/// observations (so recency reflects when the work happened), never to order
194/// them.
195///
196/// `embeddings` maps query text to its vector. When present, an entry is stashed
197/// on the graph immediately before the observation that consumes it, so
198/// clustering happens at the **dense** tier — the same tier the live path uses,
199/// rather than the lexical fallback a model-free replay would fall back to. An
200/// absent entry simply clusters lexically.
201pub(crate) fn replay_log_into(
202 graph: &mut IntentGraph,
203 envelopes: &[TraceEnvelope],
204 policy: ObservationPolicy,
205 embeddings: &HashMap<String, Vec<f32>>,
206 fingerprint: Option<&str>,
207) {
208 // session id -> (the query its next invoke attributes to, already credited).
209 //
210 // The credit is tracked HERE rather than through [`IntentGraph::arm_credit`]
211 // / [`claim_credit`]. That slot is global and keyed by query text, which is
212 // enough live — two learners sharing one graph need somewhere common to
213 // agree, and identical text from two concurrent sessions is rare. In a
214 // replay it is not rare: sessions interleave by construction and popular
215 // questions repeat verbatim, so a shared slot loses the second session's
216 // observation every time. Replay knows the session, so it can be exact.
217 let mut pending: HashMap<&str, (&str, bool)> = HashMap::new();
218
219 for env in envelopes {
220 let session = env.session_id.as_str();
221 let (kind, capability_id) = match classify(&env.event, policy) {
222 Step::Remember(query) => {
223 // Re-arming with the same text is idempotent: a capability
224 // search fans one question to both catalogs, and both of those
225 // land before any invoke, so the turn still credits once.
226 pending.insert(session, (query, false));
227 continue;
228 }
229 Step::Confirm(kind, id) => (kind, id),
230 Step::Ignore => continue,
231 };
232
233 let Some(entry) = pending.get_mut(session) else {
234 continue; // an invoke with no accepted search before it proves nothing
235 };
236 let query = entry.0;
237 // The first confirming invoke of THIS session's question is what makes
238 // it an observation; later ones add edges for the same question.
239 let first_confirmation = !entry.1;
240 entry.1 = true;
241 // Stash this query's vector right before the observation reads it. The
242 // slot holds one entry, so with sessions interleaved anything set
243 // earlier may belong to another session's question.
244 if let (Some(vector), Some(fp)) = (embeddings.get(query), fingerprint) {
245 graph.note_query_vector(query, vector, fp);
246 }
247 graph.observe(Observation {
248 query,
249 kind,
250 capability_id,
251 ts_ms: env.ts,
252 first_confirmation,
253 seeded: policy.provenance == Provenance::Seeded,
254 });
255 }
256}
257
258/// Every distinct query a `policy`-accepted search carries, in first-appearance
259/// order — the texts a caller must embed for [`replay_log_into`] to cluster
260/// densely.
261pub(crate) fn queries_to_embed(
262 envelopes: &[TraceEnvelope],
263 policy: ObservationPolicy,
264) -> Vec<String> {
265 let mut seen = std::collections::HashSet::new();
266 let mut out = Vec::new();
267 for env in envelopes {
268 if let TraceEvent::Search { query, origin, .. }
269 | TraceEvent::SkillSearch { query, origin, .. } = &env.event
270 && accepts(policy, *origin)
271 && seen.insert(query.as_str())
272 {
273 out.push(query.clone());
274 }
275 }
276 out
277}
278
279/// Whether a search of `origin` may open an observation window under `policy`.
280fn accepts(policy: ObservationPolicy, origin: Origin) -> bool {
281 match policy.origins {
282 OriginFilter::Any => true,
283 OriginFilter::Exactly(wanted) => origin == wanted,
284 }
285}
286
287/// A [`TraceSink`] decorator that grows an [`IntentGraph`] from the events
288/// passing through it, then forwards them unchanged.
289///
290/// Install it in place of the sink you would otherwise use, and hand the same
291/// graph handle to the registries so searches read what invocations write:
292///
293/// ```
294/// use std::sync::{Arc, RwLock};
295/// use ratel_ai_core::{IntentGraph, NoopSink, Tool, ToolRegistry, UsageLearner};
296///
297/// let graph = Arc::new(RwLock::new(IntentGraph::empty()));
298/// let learner = Arc::new(UsageLearner::new(graph.clone(), Arc::new(NoopSink)));
299///
300/// let mut registry = ToolRegistry::new();
301/// registry.set_trace_sink(learner); // writes the graph
302/// registry.set_intent_graph(Some(graph.clone())); // reads it
303/// registry.register(Tool {
304/// id: "gh_run_list".into(),
305/// name: "gh_run_list".into(),
306/// description: "List CI runs".into(),
307/// experimental_searchable_description: None,
308/// input_schema: serde_json::json!({}),
309/// output_schema: serde_json::json!({}),
310/// });
311///
312/// registry.search("why is the build broken", 5);
313/// registry.record_event(ratel_ai_core::TraceEvent::InvokeStart {
314/// tool_id: "gh_run_list".into(),
315/// args_size_bytes: 0,
316/// });
317/// assert_eq!(graph.read().unwrap().len(), 1); // learned
318/// ```
319pub struct UsageLearner {
320 inner: Arc<dyn TraceSink>,
321 graph: Arc<RwLock<IntentGraph>>,
322 /// The session's most recent search, awaiting an invoke to confirm it.
323 pending: Mutex<Option<Pending>>,
324 policy: ObservationPolicy,
325}
326
327impl UsageLearner {
328 /// Wrap `inner`, learning into `graph` under [`ObservationPolicy::default`].
329 /// Pass [`crate::NoopSink`] for `inner` when the only thing you want is the
330 /// learning.
331 pub fn new(graph: Arc<RwLock<IntentGraph>>, inner: Arc<dyn TraceSink>) -> Self {
332 Self::with_policy(graph, inner, ObservationPolicy::default())
333 }
334
335 /// Wrap `inner`, learning into `graph` under `policy` — the entry point a
336 /// baseline capture or a replay uses. [`Self::new`] is this at the default
337 /// policy.
338 pub fn with_policy(
339 graph: Arc<RwLock<IntentGraph>>,
340 inner: Arc<dyn TraceSink>,
341 policy: ObservationPolicy,
342 ) -> Self {
343 Self {
344 inner,
345 graph,
346 pending: Mutex::new(None),
347 policy,
348 }
349 }
350
351 /// The policy in force.
352 pub fn policy(&self) -> ObservationPolicy {
353 self.policy
354 }
355
356 /// The graph this learner writes — hand it to a registry to read.
357 pub fn graph(&self) -> Arc<RwLock<IntentGraph>> {
358 self.graph.clone()
359 }
360
361 /// Record the search awaiting confirmation, and arm the shared credit.
362 ///
363 /// Arming on the graph re-arms unconditionally: `search_capabilities` emits
364 /// a `Search` and a `SkillSearch` for one question, but both arrive *before*
365 /// any invoke, so one credit follows either way. Because the credit lives on
366 /// the graph the two catalogs share, this holds even though each catalog has
367 /// its own learner — the previous per-learner flag credited once *each*.
368 /// Over-counting still needs a credit, then another search of the same text,
369 /// then another credit — two real searches, which should count twice.
370 fn remember_query(&self, query: &str) {
371 if let Ok(mut pending) = self.pending.lock() {
372 *pending = Some(Pending {
373 query: query.to_string(),
374 });
375 }
376 if let Ok(graph) = self.graph.read() {
377 graph.arm_credit(query);
378 }
379 }
380
381 /// Pair `capability_id` with the pending query, if there is one.
382 ///
383 /// Best-effort throughout: trace events are observations, so a poisoned lock
384 /// or a missing pending query drops the evidence rather than disturbing the
385 /// agent loop (ADR-0007's query-log semantics).
386 fn confirm(&self, kind: Capability, capability_id: &str, ts_ms: u64) {
387 let Ok(pending) = self.pending.lock() else {
388 return;
389 };
390 let Some(query) = pending.as_ref().map(|p| p.query.clone()) else {
391 return; // an invoke with no search before it proves nothing
392 };
393 drop(pending);
394 if let Ok(mut graph) = self.graph.write() {
395 // The first invoke of this question, across every learner sharing the
396 // graph, is what makes it an observation; the rest add edges for the
397 // same question without re-bumping support.
398 let first_confirmation = graph.claim_credit(&query);
399 graph.observe(Observation {
400 query: &query,
401 kind,
402 capability_id,
403 ts_ms,
404 first_confirmation,
405 seeded: self.policy.provenance == Provenance::Seeded,
406 });
407 }
408 }
409
410 /// Learn from a **historical** envelope instead of a live event.
411 ///
412 /// Identical to [`TraceSink::record`] except that the observation is stamped
413 /// with the envelope's own `ts` rather than the wall clock, so decay
414 /// reflects when the work actually happened. Replaying a trace log through
415 /// this therefore reproduces the graph the live path would have grown —
416 /// which is what makes a JSONL replay a faithful reconstruction rather than
417 /// an approximation.
418 ///
419 /// Does **not** forward to the inner sink: replaying an old log must not
420 /// re-emit its events into a live stream.
421 ///
422 /// One learner covers one session. Feed envelopes from different
423 /// `session_id`s through separate learners, or their searches and invokes
424 /// cross-pair into edges nobody produced.
425 pub fn replay(&self, envelope: &TraceEnvelope) {
426 self.learn_from(&envelope.event, envelope.ts);
427 }
428
429 /// The shared pairing step behind [`Self::replay`] and [`TraceSink::record`].
430 ///
431 /// A search the policy rejects falls through to `_ => {}` — **ignored, not
432 /// cleared**. Clearing would let one of Ratel's own internal searches,
433 /// landing between a captured query and its invokes, silently discard the
434 /// turn's evidence.
435 fn learn_from(&self, event: &TraceEvent, ts_ms: u64) {
436 match classify(event, self.policy) {
437 Step::Remember(query) => self.remember_query(query),
438 Step::Confirm(kind, capability_id) => self.confirm(kind, capability_id, ts_ms),
439 Step::Ignore => {}
440 }
441 }
442}
443
444impl TraceSink for UsageLearner {
445 fn record(&self, event: TraceEvent) {
446 self.learn_from(&event, now_ms());
447 self.inner.record(event);
448 }
449
450 fn record_with_context(&self, event: TraceEvent, context: TraceEventContext) {
451 self.learn_from(&event, now_ms());
452 self.inner.record_with_context(event, context);
453 }
454
455 fn record_envelope(&self, envelope: TraceEnvelope) {
456 self.learn_from(&envelope.event, envelope.ts);
457 self.inner.record_envelope(envelope);
458 }
459
460 fn sample_rate(&self) -> f64 {
461 self.inner.sample_rate()
462 }
463}
464
465fn now_ms() -> u64 {
466 SystemTime::now()
467 .duration_since(UNIX_EPOCH)
468 .map(|d| d.as_millis() as u64)
469 .unwrap_or(0)
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::trace::{MemorySink, NoopSink, Origin};
476
477 fn learner() -> (Arc<UsageLearner>, Arc<RwLock<IntentGraph>>) {
478 let graph = Arc::new(RwLock::new(IntentGraph::empty()));
479 let l = Arc::new(UsageLearner::new(graph.clone(), Arc::new(NoopSink)));
480 (l, graph)
481 }
482
483 fn search(query: &str) -> TraceEvent {
484 TraceEvent::Search {
485 query: query.into(),
486 origin: Origin::Agent,
487 top_k: 5,
488 hits: Vec::new(),
489 stages: Vec::new(),
490 took_ms: 0,
491 }
492 }
493
494 fn invoke(tool_id: &str) -> TraceEvent {
495 TraceEvent::InvokeStart {
496 tool_id: tool_id.into(),
497 args_size_bytes: 0,
498 }
499 }
500
501 #[test]
502 fn a_search_then_invoke_becomes_one_observation() {
503 let (l, graph) = learner();
504 l.record(search("why is the build broken"));
505 l.record(invoke("gh_run_list"));
506
507 let g = graph.read().unwrap();
508 assert_eq!(g.len(), 1);
509 assert_eq!(g.intents[0].support, 1);
510 assert_eq!(g.intents[0].tools.get("gh_run_list"), Some(&1.0));
511 }
512
513 #[test]
514 fn a_search_nobody_acts_on_teaches_nothing() {
515 let (l, graph) = learner();
516 l.record(search("why is the build broken"));
517 assert!(graph.read().unwrap().is_empty());
518 }
519
520 #[test]
521 fn an_invoke_with_no_preceding_search_teaches_nothing() {
522 // Nothing ties the tool to an intent, so there is no judgment to record.
523 let (l, graph) = learner();
524 l.record(invoke("gh_run_list"));
525 assert!(graph.read().unwrap().is_empty());
526 }
527
528 #[test]
529 fn what_retrieval_returned_never_becomes_an_edge() {
530 // The central rule (ADR-0014): only invocations are evidence. This search
531 // reports `docker_build` as its top hit and the user invokes something
532 // else — the graph must learn the invoke, not the hit.
533 let (l, graph) = learner();
534 l.record(TraceEvent::Search {
535 query: "why is the build broken".into(),
536 origin: Origin::Agent,
537 top_k: 5,
538 hits: vec![crate::trace::SearchHitTrace {
539 tool_id: "docker_build".into(),
540 score: 9.9,
541 }],
542 stages: Vec::new(),
543 took_ms: 0,
544 });
545 l.record(invoke("gh_run_list"));
546
547 let g = graph.read().unwrap();
548 assert_eq!(
549 g.intents[0].tools.keys().collect::<Vec<_>>(),
550 vec!["gh_run_list"]
551 );
552 }
553
554 #[test]
555 fn several_invokes_after_one_search_all_count_as_capabilities() {
556 // An agent that searches once and uses three tools genuinely confirmed
557 // three capabilities — so three EDGES. But it asked one question, so it
558 // is one observation. This assertion on `support` is what was missing:
559 // the edge count alone passed while support inflated to 3.
560 let (l, graph) = learner();
561 l.record(search("why is the build broken"));
562 l.record(invoke("gh_run_list"));
563 l.record(invoke("gh_run_view"));
564 l.record(invoke("read_file"));
565
566 let g = graph.read().unwrap();
567 assert_eq!(g.len(), 1);
568 assert_eq!(g.intents[0].tools.len(), 3, "three capabilities were used");
569 assert_eq!(g.intents[0].support, 1, "but only one question was asked");
570 for (id, w) in &g.intents[0].tools {
571 assert_eq!(*w, 1.0, "{id} was used once");
572 }
573 }
574
575 #[test]
576 fn the_same_question_asked_twice_counts_twice() {
577 // Two real searches, even with identical text, are two observations.
578 // An earlier attempt to dedupe the capability-search double-emit by
579 // comparing query text broke exactly this — and protected nothing,
580 // since both of those searches arrive before any invoke.
581 let (l, graph) = learner();
582 l.record(search("why is the build broken"));
583 l.record(invoke("gh_run_list"));
584 l.record(search("why is the build broken"));
585 l.record(invoke("gh_run_list"));
586
587 let g = graph.read().unwrap();
588 assert_eq!(g.intents[0].support, 2);
589 assert_eq!(g.intents[0].tools["gh_run_list"], 2.0);
590 }
591
592 #[test]
593 fn separate_searches_each_count() {
594 let (l, graph) = learner();
595 l.record(search("why is the build broken"));
596 l.record(invoke("gh_run_list"));
597 l.record(search("is the build broken again"));
598 l.record(invoke("gh_run_list"));
599
600 let g = graph.read().unwrap();
601 assert_eq!(g.intents[0].support, 2, "two questions, two observations");
602 }
603
604 #[test]
605 fn a_capability_search_across_both_registries_counts_once() {
606 // `search_capabilities` searches the tool and skill catalogs with the
607 // same text, so ONE logical search emits both a Search and a SkillSearch
608 // (src/sdk/ts/src/capabilities.ts). Crediting each would reintroduce the
609 // very inflation this guards against.
610 let (l, graph) = learner();
611 l.record(search("why is the build broken"));
612 l.record(TraceEvent::SkillSearch {
613 query: "why is the build broken".into(),
614 origin: Origin::Agent,
615 top_k: 5,
616 hits: Vec::new(),
617 stages: Vec::new(),
618 took_ms: 0,
619 });
620 l.record(invoke("gh_run_list"));
621 l.record(TraceEvent::SkillInvoke {
622 skill_id: "ci-triage".into(),
623 took_ms: 1,
624 });
625
626 let g = graph.read().unwrap();
627 assert_eq!(g.len(), 1);
628 assert_eq!(
629 g.intents[0].support, 1,
630 "one question, however many catalogs it hit"
631 );
632 assert_eq!(g.intents[0].tools.len(), 1);
633 assert_eq!(g.intents[0].skills.len(), 1);
634 }
635
636 #[test]
637 fn two_learners_sharing_a_graph_count_a_capability_search_once() {
638 // The real SDK topology: `search_capabilities` fans one query to a tool
639 // catalog and a skill catalog, each with its OWN learner but ONE shared
640 // graph. Per-learner crediting double-counts support; the shared credit
641 // slot on the graph collapses them into a single observation.
642 let graph = Arc::new(RwLock::new(IntentGraph::empty()));
643 let tools = Arc::new(UsageLearner::new(graph.clone(), Arc::new(NoopSink)));
644 let skills = Arc::new(UsageLearner::new(graph.clone(), Arc::new(NoopSink)));
645
646 // Fan-out: both searches (same query) arrive before any invoke.
647 tools.record(search("why is the build broken"));
648 skills.record(TraceEvent::SkillSearch {
649 query: "why is the build broken".into(),
650 origin: Origin::Agent,
651 top_k: 5,
652 hits: Vec::new(),
653 stages: Vec::new(),
654 took_ms: 0,
655 });
656 // The agent uses a tool AND a skill for the one question.
657 tools.record(invoke("gh_run_list"));
658 skills.record(TraceEvent::SkillInvoke {
659 skill_id: "ci-triage".into(),
660 took_ms: 1,
661 });
662
663 let g = graph.read().unwrap();
664 assert_eq!(g.len(), 1);
665 assert_eq!(
666 g.intents[0].support, 1,
667 "one question, even across two per-catalog learners"
668 );
669 assert_eq!(g.intents[0].tools.get("gh_run_list"), Some(&1.0));
670 assert_eq!(g.intents[0].skills.get("ci-triage"), Some(&1.0));
671 }
672
673 #[test]
674 fn a_new_search_replaces_the_pending_query() {
675 let (l, graph) = learner();
676 l.record(search("why is the build broken"));
677 l.record(search("rotate the signing key"));
678 l.record(invoke("vault_rotate"));
679
680 let g = graph.read().unwrap();
681 assert_eq!(g.len(), 1, "only the later query should have been credited");
682 assert!(
683 g.intents[0]
684 .members
685 .contains(&"rotate the signing key".to_string())
686 );
687 }
688
689 #[test]
690 fn skill_searches_and_skill_invokes_pair_on_the_skill_edges() {
691 let (l, graph) = learner();
692 l.record(TraceEvent::SkillSearch {
693 query: "why is the build broken".into(),
694 origin: Origin::Agent,
695 top_k: 5,
696 hits: Vec::new(),
697 stages: Vec::new(),
698 took_ms: 0,
699 });
700 l.record(TraceEvent::SkillInvoke {
701 skill_id: "ci-triage".into(),
702 took_ms: 1,
703 });
704
705 let g = graph.read().unwrap();
706 assert_eq!(g.intents[0].skills.get("ci-triage"), Some(&1.0));
707 assert!(g.intents[0].tools.is_empty());
708 }
709
710 // ---- the shared pairing rule -------------------------------------------
711
712 #[test]
713 fn a_rejected_search_is_ignored_not_a_boundary() {
714 // The distinction the live and replay paths must agree on: `Ignore`
715 // leaves whatever window is open alone, so a stray internal search
716 // between a captured query and its invokes cannot discard the turn.
717 let policy =
718 ObservationPolicy::default().with_origins(OriginFilter::Exactly(Origin::Baseline));
719 assert_eq!(
720 classify(&search_from("q", Origin::Direct), policy),
721 Step::Ignore
722 );
723 assert_eq!(
724 classify(&search_from("q", Origin::Baseline), policy),
725 Step::Remember("q")
726 );
727 }
728
729 #[test]
730 fn only_the_attempt_confirms_an_observation() {
731 // A trace records which tool was called, never whether calling it was
732 // right. `invoke_end` is not a second, better signal — it filters on
733 // execution outcome, which is leaky in both directions: a wrong tool
734 // that ran fine is kept, a right one that failed on arguments is
735 // dropped. So the choice is the only signal, and there is nothing to
736 // configure.
737 let policy = ObservationPolicy::default();
738 assert_eq!(
739 classify(&invoke("t"), policy),
740 Step::Confirm(Capability::Tool, "t")
741 );
742 assert_eq!(classify(&invoke_end("t"), policy), Step::Ignore);
743 assert_eq!(classify(&invoke_error("t"), policy), Step::Ignore);
744 }
745
746 #[test]
747 fn an_unrelated_event_is_never_evidence() {
748 assert_eq!(
749 classify(
750 &TraceEvent::AuthNeeds {
751 upstream: "gh".into()
752 },
753 ObservationPolicy::default()
754 ),
755 Step::Ignore
756 );
757 }
758
759 // ---- ObservationPolicy -------------------------------------------------
760
761 fn search_from(query: &str, origin: Origin) -> TraceEvent {
762 TraceEvent::Search {
763 query: query.into(),
764 origin,
765 top_k: 5,
766 hits: Vec::new(),
767 stages: Vec::new(),
768 took_ms: 0,
769 }
770 }
771
772 fn invoke_end(tool_id: &str) -> TraceEvent {
773 TraceEvent::InvokeEnd {
774 tool_id: tool_id.into(),
775 took_ms: 1,
776 }
777 }
778
779 fn invoke_error(tool_id: &str) -> TraceEvent {
780 TraceEvent::InvokeError {
781 tool_id: tool_id.into(),
782 took_ms: 1,
783 error: "bad args".into(),
784 }
785 }
786
787 fn policy_learner(policy: ObservationPolicy) -> (Arc<UsageLearner>, Arc<RwLock<IntentGraph>>) {
788 let graph = Arc::new(RwLock::new(IntentGraph::empty()));
789 let l = Arc::new(UsageLearner::with_policy(
790 graph.clone(),
791 Arc::new(NoopSink),
792 policy,
793 ));
794 (l, graph)
795 }
796
797 #[test]
798 fn the_default_policy_reproduces_todays_pairing_exactly() {
799 // The additive-evolution guarantee: `new` and `with_policy(default())`
800 // must be the same learner. Without this, every later policy field is a
801 // chance to silently move the default path.
802 let events = || {
803 vec![
804 search("why is the build broken"),
805 invoke("gh_run_list"),
806 invoke("gh_run_view"),
807 search("rotate the signing key"),
808 invoke("vault_rotate"),
809 ]
810 };
811
812 let (old, old_graph) = learner();
813 for e in events() {
814 old.record(e);
815 }
816
817 let (new, new_graph) = policy_learner(ObservationPolicy::default());
818 for e in events() {
819 new.record(e);
820 }
821
822 let old_g = old_graph.read().unwrap();
823 let new_g = new_graph.read().unwrap();
824 // Compare the LEARNING, not the graph wholesale: `built_from_ts` and
825 // `last_ts` are stamped from the wall clock, so two runs a millisecond
826 // apart differ there and nowhere else. `Intent`'s equality already
827 // excludes those, which is exactly the cut this assertion wants.
828 assert_eq!(old_g.intents, new_g.intents);
829 assert_eq!(old_g.rev(), new_g.rev());
830 for it in &new_g.intents {
831 assert_eq!(it.seeded_support, 0, "the default policy is live");
832 }
833 }
834
835 #[test]
836 fn only_the_required_origin_opens_an_observation_window() {
837 let (l, graph) = policy_learner(
838 ObservationPolicy::default().with_origins(OriginFilter::Exactly(Origin::Baseline)),
839 );
840
841 l.record(search_from("why is the build broken", Origin::Agent));
842 l.record(invoke("gh_run_list"));
843 assert!(
844 graph.read().unwrap().is_empty(),
845 "an agent search must not teach a baseline-only learner"
846 );
847
848 l.record(search_from("why is the build broken", Origin::Baseline));
849 l.record(invoke("gh_run_list"));
850 assert_eq!(graph.read().unwrap().len(), 1);
851 }
852
853 #[test]
854 fn a_filtered_out_search_leaves_the_pending_query_intact() {
855 // The subtle one. A baseline capture runs Ratel's own searches too (a
856 // pre-fetch helper, a health check). If a filtered search CLEARED the
857 // pending query instead of being ignored, one stray internal search
858 // between the turn's query and its invokes would silently discard the
859 // turn's evidence.
860 let (l, graph) = policy_learner(
861 ObservationPolicy::default().with_origins(OriginFilter::Exactly(Origin::Baseline)),
862 );
863
864 l.record(search_from("why is the build broken", Origin::Baseline));
865 l.record(search_from("some internal probe", Origin::Direct));
866 l.record(invoke("gh_run_list"));
867
868 let g = graph.read().unwrap();
869 assert_eq!(g.len(), 1);
870 assert!(
871 g.intents[0]
872 .members
873 .contains(&"why is the build broken".to_string()),
874 "the baseline query still owns the invoke, got {:?}",
875 g.intents[0].members
876 );
877 }
878
879 #[test]
880 fn the_default_policy_still_pairs_on_the_attempt() {
881 // Choice is the relevance signal: which tool the agent reached for says
882 // what it thought fit, and a later argument error does not retract that.
883 let (l, graph) = learner();
884 l.record(search("why is the build broken"));
885 l.record(invoke("gh_run_list"));
886 assert_eq!(graph.read().unwrap().len(), 1);
887 }
888
889 #[test]
890 fn a_seeded_policy_stamps_provenance_on_what_it_credits() {
891 let (l, graph) = policy_learner(
892 ObservationPolicy::default()
893 .with_origins(OriginFilter::Exactly(Origin::Baseline))
894 .with_provenance(Provenance::Seeded),
895 );
896
897 l.record(search_from("why is the build broken", Origin::Baseline));
898 l.record(invoke("gh_run_list"));
899 l.record(invoke("gh_run_view"));
900
901 let g = graph.read().unwrap();
902 assert_eq!(g.intents[0].support, 1, "one question");
903 assert_eq!(g.intents[0].seeded_support, 1, "and it was seeded");
904 assert_eq!(g.intents[0].tools.len(), 2, "two capabilities");
905 }
906
907 #[test]
908 fn a_skill_invoke_confirms_like_a_tool_invoke() {
909 let (l, graph) = policy_learner(ObservationPolicy::default());
910 l.record(TraceEvent::SkillSearch {
911 query: "why is the build broken".into(),
912 origin: Origin::Agent,
913 top_k: 5,
914 hits: Vec::new(),
915 stages: Vec::new(),
916 took_ms: 0,
917 });
918 l.record(TraceEvent::SkillInvoke {
919 skill_id: "ci-triage".into(),
920 took_ms: 1,
921 });
922 assert_eq!(
923 graph.read().unwrap().intents[0].skills.get("ci-triage"),
924 Some(&1.0)
925 );
926 }
927
928 #[test]
929 fn every_event_is_forwarded_to_the_inner_sink() {
930 // Decorating must be transparent: installing a learner cannot cost the
931 // caller their JSONL/inspector stream.
932 let inner = Arc::new(MemorySink::new("s"));
933 let graph = Arc::new(RwLock::new(IntentGraph::empty()));
934 let l = UsageLearner::new(graph, inner.clone());
935
936 l.record(search("why is the build broken"));
937 l.record(invoke("gh_run_list"));
938 l.record(TraceEvent::AuthNeeds {
939 upstream: "gh".into(),
940 });
941
942 assert_eq!(inner.snapshot().len(), 3);
943 }
944
945 #[test]
946 fn unrelated_events_are_forwarded_without_learning() {
947 let (l, graph) = learner();
948 l.record(TraceEvent::AuthNeeds {
949 upstream: "gh".into(),
950 });
951 assert!(graph.read().unwrap().is_empty());
952 }
953}