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 pub higher_is_better: bool,
28}
29
30pub 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 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 pub fn facts(&self) -> Result<Vec<GrainRecord>> {
92 self.grains_of_type(crate::model::grain_type::FACT, ReadOpts::default())
93 }
94
95 pub fn observations(&self) -> Result<Vec<GrainRecord>> {
97 self.grains_of_type(crate::model::grain_type::OBSERVATION, ReadOpts::default())
98 }
99
100 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 pub fn skills(&self) -> Result<Vec<GrainRecord>> {
111 self.grains_of_type(crate::model::grain_type::SKILL, ReadOpts::default())
112 }
113
114 pub fn goals(&self) -> Result<Vec<GrainRecord>> {
116 self.grains_of_type(crate::model::grain_type::GOAL, ReadOpts::default())
117 }
118
119 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 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 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
154pub trait Analyzer: Send + Sync {
156 fn manifest(&self) -> &AnalyzerManifest;
157
158 fn analyze(&self, ctx: &AnalyzeCtx) -> Result<Vec<RecDraft>>;
163}
164
165pub 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}