1use 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#[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
27pub 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 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 pub fn facts(&self) -> Result<Vec<GrainRecord>> {
89 self.grains_of_type(crate::model::grain_type::FACT, ReadOpts::default())
90 }
91
92 pub fn observations(&self) -> Result<Vec<GrainRecord>> {
94 self.grains_of_type(crate::model::grain_type::OBSERVATION, ReadOpts::default())
95 }
96
97 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 pub fn skills(&self) -> Result<Vec<GrainRecord>> {
108 self.grains_of_type(crate::model::grain_type::SKILL, ReadOpts::default())
109 }
110
111 pub fn goals(&self) -> Result<Vec<GrainRecord>> {
113 self.grains_of_type(crate::model::grain_type::GOAL, ReadOpts::default())
114 }
115
116 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 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 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
151pub trait Analyzer: Send + Sync {
153 fn manifest(&self) -> &AnalyzerManifest;
154
155 fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>>;
160}
161
162pub 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}