Skip to main content

areev_loop/
analyzer.rs

1//! The `Analyzer` trait and `AnalyzeCtx` — the SDK seam. `AnalyzeCtx` is a
2//! struct (not a trait) so the engine can add methods without breaking
3//! implementors. It exposes only read-only substrate access plus resolved
4//! params, the watermark, and `now` — analyzers cannot write (trust floor),
5//! enforced by holding `&dyn SubstrateRead`.
6
7use crate::error::Result;
8use crate::manifest::{AnalyzerManifest, Params};
9use crate::model::GrainRecord;
10use crate::recommendation::RecDraft;
11use crate::substrate::{HeadGroup, ReadOpts, SubstrateRead, TelemetryView};
12
13/// One applied recommendation due for outcome review, with its metric already
14/// re-measured by the engine (which owns the `&mut` substrate). The outcome
15/// analyzer makes the deterministic changed/regressed decision over this — I/O
16/// in the engine, judgment in the analyzer.
17#[derive(Debug, Clone, PartialEq)]
18pub struct OutcomeInput {
19    pub rec_hash: String,
20    pub target_ref: String,
21    pub metric: String,
22    pub baseline: f64,
23    pub current: f64,
24    pub unit: String,
25    /// Carried from the metric snapshot so the analyzer applies the SAME
26    /// direction the engine did — see `recommendation::is_regression`.
27    pub higher_is_better: bool,
28}
29
30/// The context handed to `analyze`. Read-only by construction.
31pub struct AnalyzeCtx<'a> {
32    reader: &'a dyn SubstrateRead,
33    params: &'a Params,
34    namespaces: &'a [String],
35    watermark_ms: Option<i64>,
36    now_ms: i64,
37    outcome_inputs: &'a [OutcomeInput],
38}
39
40impl<'a> AnalyzeCtx<'a> {
41    #[allow(clippy::too_many_arguments)]
42    pub fn new(
43        reader: &'a dyn SubstrateRead,
44        params: &'a Params,
45        namespaces: &'a [String],
46        watermark_ms: Option<i64>,
47        now_ms: i64,
48        outcome_inputs: &'a [OutcomeInput],
49    ) -> Self {
50        AnalyzeCtx {
51            reader,
52            params,
53            namespaces,
54            watermark_ms,
55            now_ms,
56            outcome_inputs,
57        }
58    }
59
60    pub fn params(&self) -> &Params {
61        self.params
62    }
63    pub fn now_ms(&self) -> i64 {
64        self.now_ms
65    }
66    pub fn watermark_ms(&self) -> Option<i64> {
67        self.watermark_ms
68    }
69    pub fn capabilities(&self) -> crate::substrate::Capabilities {
70        self.reader.capabilities()
71    }
72    pub fn outcome_inputs(&self) -> &[OutcomeInput] {
73        self.outcome_inputs
74    }
75
76    /// All grains of a type across the configured namespaces (or all namespaces
77    /// when none are configured).
78    pub fn grains_of_type(&self, grain_type: &str, opts: ReadOpts) -> Result<Vec<GrainRecord>> {
79        if self.namespaces.is_empty() {
80            self.reader.grains_of_type(grain_type, None, opts)
81        } else {
82            let mut out = Vec::new();
83            for ns in self.namespaces {
84                out.extend(self.reader.grains_of_type(grain_type, Some(ns), opts)?);
85            }
86            Ok(out)
87        }
88    }
89
90    /// Live facts across configured namespaces.
91    pub fn facts(&self) -> Result<Vec<GrainRecord>> {
92        self.grains_of_type(crate::model::grain_type::FACT, ReadOpts::default())
93    }
94
95    /// Live observations across configured namespaces.
96    pub fn observations(&self) -> Result<Vec<GrainRecord>> {
97        self.grains_of_type(crate::model::grain_type::OBSERVATION, ReadOpts::default())
98    }
99
100    /// Live grains of one type in ONE explicit namespace — for analyzers
101    /// whose datasource lives in a well-known system namespace (the run
102    /// journals in `agent:harness`) regardless of the sweep's configured
103    /// scope.
104    pub fn grains_in(&self, grain_type: &str, namespace: &str) -> Result<Vec<GrainRecord>> {
105        self.reader
106            .grains_of_type(grain_type, Some(namespace), ReadOpts::default())
107    }
108
109    /// Live Skill grains across configured namespaces.
110    pub fn skills(&self) -> Result<Vec<GrainRecord>> {
111        self.grains_of_type(crate::model::grain_type::SKILL, ReadOpts::default())
112    }
113
114    /// Live Goal grains across configured namespaces.
115    pub fn goals(&self) -> Result<Vec<GrainRecord>> {
116        self.grains_of_type(crate::model::grain_type::GOAL, ReadOpts::default())
117    }
118
119    /// Tool grains (captured tool calls), optionally windowed by a `since`
120    /// watermark, live only. The flagship analyzer's input.
121    pub fn tools_since(&self, since_ms: Option<i64>) -> Result<Vec<GrainRecord>> {
122        self.grains_of_type(
123            crate::model::grain_type::TOOL,
124            ReadOpts {
125                live_only: true,
126                since_ms,
127            },
128        )
129    }
130
131    /// Entities with more than one live head (requires the forks capability).
132    pub fn heads(&self) -> Result<Vec<HeadGroup>> {
133        let ns = if self.namespaces.len() == 1 {
134            Some(self.namespaces[0].as_str())
135        } else {
136            None
137        };
138        self.reader.heads(ns)
139    }
140
141    /// A snapshot of the recall-telemetry rollups (requires the `telemetry`
142    /// capability). `None` when the substrate has no sidecar — a telemetry-fed
143    /// analyzer then degrades to an activation-ladder entry.
144    pub fn telemetry(&self) -> Result<Option<TelemetryView>> {
145        let ns = if self.namespaces.len() == 1 {
146            Some(self.namespaces[0].as_str())
147        } else {
148            None
149        };
150        self.reader.telemetry(ns)
151    }
152}
153
154/// An analysis unit. Object-safe so `builtin_analyzers()` yields trait objects.
155pub trait Analyzer: Send + Sync {
156    fn manifest(&self) -> &AnalyzerManifest;
157
158    /// Produce recommendation drafts. `dedup_key`, `origin`, and the params
159    /// snapshot are stamped by the engine afterward, not here. Returning an
160    /// error drops *this* analyzer's findings for the run; other analyzers are
161    /// unaffected.
162    fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>>;
163}
164
165/// The default-registered built-in analyzers. Count is test-pinned (§11).
166pub fn builtin_analyzers() -> Vec<Box<dyn Analyzer>> {
167    vec![
168        Box::new(crate::analyzers::tool_failure::ToolFailureClustering::new()),
169        Box::new(crate::analyzers::duplicate_sweep::DuplicateSweep::new()),
170        Box::new(crate::analyzers::contradiction_sweep::ContradictionSweep::new()),
171        Box::new(crate::analyzers::fork_surfacing::ForkSurfacing::new()),
172        Box::new(crate::analyzers::staleness::Staleness::new()),
173        Box::new(crate::analyzers::skill_stall::SkillStall::new()),
174        Box::new(crate::analyzers::goal_stagnation::GoalStagnation::new()),
175        Box::new(crate::analyzers::cold_grains::ColdGrains::new()),
176        Box::new(crate::analyzers::coverage_gap::CoverageGap::new()),
177        Box::new(crate::analyzers::budget_pressure::BudgetPressure::new()),
178        Box::new(crate::analyzers::outcome_review::OutcomeReview::new()),
179        Box::new(crate::analyzers::retention_sweep::RetentionSweep::new()),
180        Box::new(crate::analyzers::run_outcome::RunOutcome::new()),
181    ]
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn builtins_have_unique_ids() {
190        let a = builtin_analyzers();
191        assert_eq!(
192            a.len(),
193            13,
194            "6 hygiene + skill/goal trajectory + 3 telemetry-fed (cold/coverage/budget) \
195             + retention (default-off) + run_outcome"
196        );
197        let mut ids: Vec<&str> = a.iter().map(|x| x.manifest().id.as_str()).collect();
198        ids.sort_unstable();
199        ids.dedup();
200        assert_eq!(ids.len(), 13, "analyzer ids must be unique");
201    }
202
203    #[test]
204    fn all_builtins_are_trust_class_builtin() {
205        for a in builtin_analyzers() {
206            assert_eq!(
207                a.manifest().trust_class,
208                crate::manifest::TrustClass::Builtin,
209                "{} must be builtin trust class",
210                a.manifest().id
211            );
212        }
213    }
214}