Skip to main content

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}