Skip to main content

areev_loop/
manifest.rs

1//! Analyzer manifests and the flat `ParamSpec` list. One manifest is the
2//! single source feeding the CLI listing, the HTTP `/analyzers` route, MCP
3//! listing, param validation, docs, and the console's analyzer cards
4//! (proposal §11). Hand-rolled with serde_json only — no JSON-Schema dep.
5
6use crate::error::{Error, Result};
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10/// Analysis tier: T0 pure statistics, T1 embedding-assisted, T2 LLM.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
12pub enum Tier {
13    T0,
14    T1,
15    T2,
16}
17
18/// How often an analyzer is worth running; rides per-analyzer time budgets.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum CadenceClass {
22    /// Cheap; safe to run every pass.
23    Fast,
24    /// Moderate; the default batch cadence.
25    Batch,
26    /// Expensive; run sparingly.
27    Slow,
28}
29
30/// Optional substrate capability an analyzer may require.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum Capability {
34    Forks,
35    Telemetry,
36    Embeddings,
37}
38
39/// Target classes an analyzer may propose against.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum TargetClass {
43    Memory,
44    Query,
45    Prompt,
46    Host,
47}
48
49/// Whether an analyzer's structural proposals are *eligible* for auto-apply.
50/// Eligibility is necessary, never sufficient — the engine still requires host
51/// opt-in, policy allowlisting, and per-draft shape verification (§6.3).
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum AutoApplyClass {
55    /// Never auto-applies (the default and the only safe class for anything
56    /// carrying evidence-derived text).
57    Never,
58    /// Structural curation with zero attacker-influenced free text; still
59    /// re-verified per draft by the engine.
60    StructuralCuration,
61}
62
63/// Trust class of the analyzer's code path (drives the console badge).
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "lowercase")]
66pub enum TrustClass {
67    /// Built-in or statically linked — compiling it in *is* the trust decision.
68    Builtin,
69    /// External command analyzer; never auto-applies.
70    Command,
71    /// LLM-drafted; never auto-applies, never prompt/host targets.
72    Llm,
73}
74
75/// One declared parameter. Six kinds, hand-rolled (proposal §11).
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(tag = "kind", rename_all = "snake_case")]
78pub enum ParamSpec {
79    Bool {
80        name: String,
81        default: bool,
82        description: String,
83    },
84    Int {
85        name: String,
86        default: i64,
87        min: i64,
88        max: i64,
89        description: String,
90    },
91    Float {
92        name: String,
93        default: f64,
94        min: f64,
95        max: f64,
96        description: String,
97    },
98    Str {
99        name: String,
100        default: String,
101        max_len: usize,
102        description: String,
103    },
104    Enum {
105        name: String,
106        default: String,
107        choices: Vec<String>,
108        description: String,
109    },
110    /// A duration in whole seconds.
111    Duration {
112        name: String,
113        default_secs: i64,
114        description: String,
115    },
116}
117
118impl ParamSpec {
119    pub fn name(&self) -> &str {
120        match self {
121            ParamSpec::Bool { name, .. }
122            | ParamSpec::Int { name, .. }
123            | ParamSpec::Float { name, .. }
124            | ParamSpec::Str { name, .. }
125            | ParamSpec::Enum { name, .. }
126            | ParamSpec::Duration { name, .. } => name,
127        }
128    }
129
130    fn default_value(&self) -> Value {
131        match self {
132            ParamSpec::Bool { default, .. } => Value::Bool(*default),
133            ParamSpec::Int { default, .. } => Value::from(*default),
134            ParamSpec::Float { default, .. } => Value::from(*default),
135            ParamSpec::Str { default, .. } => Value::from(default.clone()),
136            ParamSpec::Enum { default, .. } => Value::from(default.clone()),
137            ParamSpec::Duration { default_secs, .. } => Value::from(*default_secs),
138        }
139    }
140
141    /// Validate a supplied override value against this spec.
142    fn validate(&self, v: &Value) -> Result<()> {
143        let bad = |m: String| Err(Error::ParamInvalid(format!("{}: {m}", self.name())));
144        match self {
145            ParamSpec::Bool { .. } => {
146                if !v.is_boolean() {
147                    return bad(format!("expected bool, got {v}"));
148                }
149            }
150            ParamSpec::Int { min, max, .. } => {
151                let n = v.as_i64().ok_or_else(|| {
152                    Error::ParamInvalid(format!("{}: expected integer, got {v}", self.name()))
153                })?;
154                if n < *min || n > *max {
155                    return bad(format!("{n} out of range [{min}, {max}]"));
156                }
157            }
158            ParamSpec::Float { min, max, .. } => {
159                let n = v.as_f64().ok_or_else(|| {
160                    Error::ParamInvalid(format!("{}: expected number, got {v}", self.name()))
161                })?;
162                if n < *min || n > *max {
163                    return bad(format!("{n} out of range [{min}, {max}]"));
164                }
165            }
166            ParamSpec::Str { max_len, .. } => {
167                let s = v.as_str().ok_or_else(|| {
168                    Error::ParamInvalid(format!("{}: expected string, got {v}", self.name()))
169                })?;
170                if s.chars().count() > *max_len {
171                    return bad(format!(
172                        "length {} exceeds max {max_len}",
173                        s.chars().count()
174                    ));
175                }
176            }
177            ParamSpec::Enum { choices, .. } => {
178                let s = v.as_str().ok_or_else(|| {
179                    Error::ParamInvalid(format!("{}: expected string, got {v}", self.name()))
180                })?;
181                if !choices.iter().any(|c| c == s) {
182                    return bad(format!("{s:?} not in {choices:?}"));
183                }
184            }
185            ParamSpec::Duration { .. } => {
186                let n = v.as_i64().ok_or_else(|| {
187                    Error::ParamInvalid(format!(
188                        "{}: expected integer seconds, got {v}",
189                        self.name()
190                    ))
191                })?;
192                if n < 0 {
193                    return bad(format!("duration {n} must be non-negative"));
194                }
195            }
196        }
197        Ok(())
198    }
199}
200
201/// A fully resolved parameter set: manifest defaults with validated overrides
202/// applied. Typed getters never panic — an absent key means the manifest is
203/// out of sync, which is an engine bug, so they fall back to the type zero.
204#[derive(Debug, Clone, Default, PartialEq)]
205pub struct Params(Map<String, Value>);
206
207impl Params {
208    pub fn get_bool(&self, name: &str) -> bool {
209        self.0.get(name).and_then(Value::as_bool).unwrap_or(false)
210    }
211    pub fn get_int(&self, name: &str) -> i64 {
212        self.0.get(name).and_then(Value::as_i64).unwrap_or(0)
213    }
214    pub fn get_float(&self, name: &str) -> f64 {
215        self.0.get(name).and_then(Value::as_f64).unwrap_or(0.0)
216    }
217    pub fn get_str(&self, name: &str) -> &str {
218        self.0.get(name).and_then(Value::as_str).unwrap_or("")
219    }
220    pub fn get_duration_secs(&self, name: &str) -> i64 {
221        self.get_int(name)
222    }
223    /// The raw snapshot, stored on each recommendation for full "why"
224    /// provenance.
225    pub fn snapshot(&self) -> Map<String, Value> {
226        self.0.clone()
227    }
228}
229
230/// An analyzer's self-description.
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
232pub struct AnalyzerManifest {
233    /// Versioned logic id `publisher.name/major`, e.g. `loop.duplicate_sweep/1`.
234    pub id: String,
235    pub title: String,
236    pub description: String,
237    pub tier: Tier,
238    pub cadence: CadenceClass,
239    #[serde(default)]
240    pub requires: Vec<Capability>,
241    pub target_classes: Vec<TargetClass>,
242    pub auto_apply: AutoApplyClass,
243    pub trust_class: TrustClass,
244    #[serde(default)]
245    pub params: Vec<ParamSpec>,
246    /// Whether this analyzer is on by default (decided by measured precision,
247    /// never by assertion — proposal §8).
248    pub default_on: bool,
249}
250
251impl AnalyzerManifest {
252    /// The dedup family: `publisher.name` with the `/major` suffix stripped.
253    /// Keeping major out of the family is why an analyzer upgrade does not
254    /// re-propose its whole queue as novel (proposal §7.1).
255    pub fn family(&self) -> &str {
256        analyzer_family(&self.id)
257    }
258
259    /// Resolve defaults + validated overrides into a `Params`. Unknown
260    /// override keys are rejected (fail-closed, like the trust-floor schema).
261    pub fn resolve_params(&self, overrides: &Map<String, Value>) -> Result<Params> {
262        for key in overrides.keys() {
263            if !self.params.iter().any(|p| p.name() == key) {
264                return Err(Error::ParamInvalid(format!("unknown parameter {key:?}")));
265            }
266        }
267        let mut resolved = Map::new();
268        for spec in &self.params {
269            let value = match overrides.get(spec.name()) {
270                Some(v) => {
271                    spec.validate(v)?;
272                    v.clone()
273                }
274                None => spec.default_value(),
275            };
276            resolved.insert(spec.name().to_string(), value);
277        }
278        Ok(Params(resolved))
279    }
280}
281
282/// `publisher.name/major` → `publisher.name`.
283pub fn analyzer_family(id: &str) -> &str {
284    id.split('/').next().unwrap_or(id)
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use serde_json::json;
291
292    fn spec_manifest() -> AnalyzerManifest {
293        AnalyzerManifest {
294            id: "loop.duplicate_sweep/1".into(),
295            title: "Duplicate sweep".into(),
296            description: "d".into(),
297            tier: Tier::T0,
298            cadence: CadenceClass::Batch,
299            requires: vec![],
300            target_classes: vec![TargetClass::Memory],
301            auto_apply: AutoApplyClass::StructuralCuration,
302            trust_class: TrustClass::Builtin,
303            params: vec![
304                ParamSpec::Float {
305                    name: "jaccard".into(),
306                    default: 0.9,
307                    min: 0.0,
308                    max: 1.0,
309                    description: "near-dup threshold".into(),
310                },
311                ParamSpec::Int {
312                    name: "window_days".into(),
313                    default: 30,
314                    min: 1,
315                    max: 365,
316                    description: "lookback".into(),
317                },
318            ],
319            default_on: true,
320        }
321    }
322
323    #[test]
324    fn family_strips_major() {
325        assert_eq!(
326            analyzer_family("loop.duplicate_sweep/1"),
327            "loop.duplicate_sweep"
328        );
329        assert_eq!(
330            analyzer_family("loop.duplicate_sweep/2"),
331            "loop.duplicate_sweep"
332        );
333        assert_eq!(spec_manifest().family(), "loop.duplicate_sweep");
334    }
335
336    #[test]
337    fn resolve_fills_defaults() {
338        let p = spec_manifest().resolve_params(&Map::new()).unwrap();
339        assert_eq!(p.get_float("jaccard"), 0.9);
340        assert_eq!(p.get_int("window_days"), 30);
341    }
342
343    #[test]
344    fn resolve_validates_overrides() {
345        let m = spec_manifest();
346        let mut ov = Map::new();
347        ov.insert("jaccard".into(), json!(1.5));
348        assert!(
349            m.resolve_params(&ov).is_err(),
350            "out-of-range float rejected"
351        );
352
353        let mut ok = Map::new();
354        ok.insert("jaccard".into(), json!(0.95));
355        assert_eq!(m.resolve_params(&ok).unwrap().get_float("jaccard"), 0.95);
356    }
357
358    #[test]
359    fn resolve_rejects_unknown_keys() {
360        let mut ov = Map::new();
361        ov.insert("bogus".into(), json!(1));
362        assert!(spec_manifest().resolve_params(&ov).is_err());
363    }
364}