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