Skip to main content

areev_loop/
model.rs

1//! Shared value types: grain records the engine reads, target references it
2//! proposes against, severity, action kinds, and provenance origin.
3//!
4//! Text normalization note: grains arriving from an OMS substrate are already
5//! NFC-normalized by canonical serialization (a frozen OMS invariant), so the
6//! engine only case-folds and trims for identity comparisons — it deliberately
7//! carries no Unicode-normalization dependency (dependency-light policy).
8
9use serde::{Deserialize, Serialize};
10use serde_json::{Map, Value};
11
12/// Canonical grain-type names as the substrate reports them.
13pub mod grain_type {
14    pub const FACT: &str = "fact";
15    pub const EVENT: &str = "event";
16    /// OMS Tool grain (0x05) — how a captured tool call is stored, carrying
17    /// `tool_name`/`is_error`/`content` natively (the flagship analyzer's food).
18    pub const TOOL: &str = "tool";
19    pub const OBSERVATION: &str = "observation";
20    /// OMS Skill grain (0x0B) — `proficiency` (aliases `confidence`) +
21    /// `practice_count`; the chain is the learning history.
22    pub const SKILL: &str = "skill";
23    /// OMS Goal grain (0x07) — `goal_state` + `progress`.
24    pub const GOAL: &str = "goal";
25    pub const RECOMMENDATION: &str = "recommendation";
26}
27
28/// A grain as read from the substrate: the content address, the OMS type name,
29/// index-layer facts (`superseded_by`), and the decoded field map. The engine
30/// treats fields as JSON and pulls typed values through the accessors below.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
32pub struct GrainRecord {
33    pub hash: String,
34    pub grain_type: String,
35    #[serde(default)]
36    pub namespace: String,
37    #[serde(default)]
38    pub created_at_ms: i64,
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub valid_to_ms: Option<i64>,
41    /// Index-layer supersession pointer; `Some` means this grain is not a head.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub superseded_by: Option<String>,
44    #[serde(default)]
45    pub fields: Map<String, Value>,
46}
47
48impl GrainRecord {
49    /// A grain is *live* when no supersession has retired it.
50    pub fn is_live(&self) -> bool {
51        self.superseded_by.is_none()
52    }
53
54    pub fn str_field(&self, key: &str) -> Option<&str> {
55        self.fields.get(key).and_then(Value::as_str)
56    }
57
58    pub fn bool_field(&self, key: &str) -> Option<bool> {
59        self.fields.get(key).and_then(Value::as_bool)
60    }
61
62    // --- Fact accessors (subject/relation/object) ---
63    pub fn fact_subject(&self) -> Option<&str> {
64        self.str_field("subject")
65    }
66    pub fn fact_relation(&self) -> Option<&str> {
67        self.str_field("relation")
68    }
69    pub fn fact_object(&self) -> Option<&str> {
70        self.str_field("object")
71    }
72
73    // --- Tool-grain accessors (captured tool calls / results) ---
74    pub fn tool_name(&self) -> Option<&str> {
75        self.str_field("tool_name")
76            .or_else(|| self.str_field("name"))
77    }
78    pub fn is_error(&self) -> bool {
79        self.bool_field("is_error").unwrap_or(false)
80    }
81    /// The tool result text used for error-signature extraction. Tool grains
82    /// carry it as `tool_content` (compact `cnt`, distinct from Event's
83    /// uncompacted `content`); other shapes fall back.
84    pub fn tool_content(&self) -> Option<&str> {
85        self.str_field("tool_content")
86            .or_else(|| self.str_field("content"))
87            .or_else(|| self.str_field("result"))
88            .or_else(|| self.str_field("error"))
89            .or_else(|| self.str_field("body"))
90    }
91
92    fn f64_field(&self, key: &str) -> Option<f64> {
93        self.fields.get(key).and_then(Value::as_f64)
94    }
95    fn i64_field(&self, key: &str) -> Option<i64> {
96        self.fields.get(key).and_then(Value::as_i64)
97    }
98
99    // --- Skill accessors ---
100    pub fn skill_name(&self) -> Option<&str> {
101        self.str_field("name").or_else(|| self.str_field("skill_name"))
102    }
103    /// Skill proficiency (dedicated `proficiency` key; aliases `confidence`).
104    pub fn skill_proficiency(&self) -> Option<f64> {
105        self.f64_field("proficiency").or_else(|| self.f64_field("confidence"))
106    }
107    pub fn skill_practice_count(&self) -> i64 {
108        self.i64_field("practice_count").unwrap_or(0)
109    }
110
111    // --- Goal accessors ---
112    pub fn goal_state(&self) -> Option<&str> {
113        self.str_field("goal_state").or_else(|| self.str_field("state"))
114    }
115    pub fn goal_progress(&self) -> f64 {
116        self.f64_field("progress").unwrap_or(0.0)
117    }
118}
119
120/// Severity ranks proposals for review triage. Ordering is
121/// `Info < Low < Medium < High`.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
123#[serde(rename_all = "lowercase")]
124pub enum Severity {
125    Info,
126    Low,
127    Medium,
128    High,
129}
130
131impl Severity {
132    pub fn as_str(&self) -> &'static str {
133        match self {
134            Severity::Info => "info",
135            Severity::Low => "low",
136            Severity::Medium => "medium",
137            Severity::High => "high",
138        }
139    }
140}
141
142/// Provenance of a recommendation, engine-stamped (never settable by an
143/// analyzer draft). Drives the trust class and the auto-apply gate.
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145#[serde(tag = "kind", rename_all = "lowercase")]
146pub enum Origin {
147    /// A built-in or statically-linked Rust analyzer (same trust class).
148    Builtin,
149    /// An external command analyzer, by id. Never auto-applies.
150    Command { id: String },
151    /// An LLM DISCOVER/ENRICH draft, by model. Never auto-applies; never
152    /// touches prompt/host targets.
153    Llm { model: String },
154}
155
156impl Origin {
157    /// Only builtin (incl. statically-linked) origins are eligible for
158    /// auto-apply; command and llm origins never are (trust floor, §6.3).
159    pub fn auto_apply_eligible(&self) -> bool {
160        matches!(self, Origin::Builtin)
161    }
162}
163
164/// The kind of change a proposal makes. Combined with analyzer-family and
165/// target_ref it forms the dedup identity — so it must be a stable, small
166/// vocabulary, not free text.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum ActionKind {
170    /// Supersede duplicate members with one consolidated grain.
171    Consolidate,
172    /// Flag a subject holding contradictory values under a functional relation.
173    FlagContradiction,
174    /// Record a recurring tool-failure cluster as a lesson.
175    ClusterFailure,
176    /// Tombstone a grain whose declared validity has elapsed.
177    Expire,
178    /// Merge multiple heads of one entity.
179    MergeHeads,
180    /// Revert an applied recommendation whose outcome regressed.
181    Revert,
182    /// Surface an advisory finding for human attention (no automatic fix) —
183    /// e.g. a stalled skill or goal.
184    Flag,
185    /// Propose a new revision of an executable tool's code (§7.4). NEVER
186    /// auto-applied — categorically, by name — and the recommendation must
187    /// pin the evalset hash it was gated against (Rule E1): apply is refused
188    /// without a recorded gating run, and a superseded evalset invalidates
189    /// the pin (the recommendation re-gates).
190    CodeRevision,
191    /// Propose promoting a host-trained adapter into service for a `model:`
192    /// target (the tuning seam). Same governance shape as `CodeRevision`:
193    /// NEVER auto-applied, must pin the evalset it was gated against
194    /// (Rule E1), and apply is refused without a recorded gating run.
195    AdapterRevision,
196}
197
198impl ActionKind {
199    pub fn as_str(&self) -> &'static str {
200        match self {
201            ActionKind::Consolidate => "consolidate",
202            ActionKind::FlagContradiction => "flag_contradiction",
203            ActionKind::ClusterFailure => "cluster_failure",
204            ActionKind::Expire => "expire",
205            ActionKind::MergeHeads => "merge_heads",
206            ActionKind::Revert => "revert",
207            ActionKind::Flag => "flag",
208            ActionKind::CodeRevision => "code_revision",
209            ActionKind::AdapterRevision => "adapter_revision",
210        }
211    }
212}
213
214/// A parsed `target_ref`: `<scheme>:<opaque>`. The scheme is the target-kind
215/// discriminator (proposal §7.1).
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct TargetRef {
218    scheme: String,
219    opaque: String,
220}
221
222impl TargetRef {
223    /// Parse `<scheme>:<opaque>`. Fails if there is no scheme or empty opaque.
224    pub fn parse(s: &str) -> crate::error::Result<Self> {
225        let (scheme, opaque) = s.split_once(':').ok_or_else(|| {
226            crate::error::Error::InvalidTargetRef(format!("missing scheme in {s:?}"))
227        })?;
228        if scheme.is_empty() || opaque.is_empty() {
229            return Err(crate::error::Error::InvalidTargetRef(format!(
230                "empty scheme or opaque in {s:?}"
231            )));
232        }
233        if !KNOWN_SCHEMES.contains(&scheme) {
234            return Err(crate::error::Error::InvalidTargetRef(format!(
235                "unknown scheme {scheme:?} in {s:?}"
236            )));
237        }
238        Ok(TargetRef {
239            scheme: scheme.to_string(),
240            opaque: opaque.to_string(),
241        })
242    }
243
244    pub fn scheme(&self) -> &str {
245        &self.scheme
246    }
247    pub fn opaque(&self) -> &str {
248        &self.opaque
249    }
250
251    /// Memory/query targets are the only classes eligible for auto-apply
252    /// (§6.3); prompt (`doc:`), `host:`, the §7.4 `tool:`/`evalset:` classes,
253    /// and the tuning seam's `model:` class are never auto-applied — code,
254    /// adapters, and their gate are excluded BY NAME, not by accident of the
255    /// grant vocabulary.
256    pub fn auto_apply_eligible_class(&self) -> bool {
257        matches!(
258            self.scheme.as_str(),
259            "grain" | "entity" | "query" | "template"
260        )
261    }
262
263    /// The policy target class: `memory` (grain/entity), `query`
264    /// (query/template), `prompt` (doc), `code` (tool), `evalset`, or `host`.
265    pub fn target_class(&self) -> &'static str {
266        match self.scheme.as_str() {
267            "grain" | "entity" => "memory",
268            "query" | "template" => "query",
269            "doc" => "prompt",
270            "tool" => "code",
271            "evalset" => "evalset",
272            "model" => "model",
273            _ => "host",
274        }
275    }
276
277    pub fn as_string(&self) -> String {
278        format!("{}:{}", self.scheme, self.opaque)
279    }
280}
281
282const KNOWN_SCHEMES: &[&str] = &[
283    "grain", "entity", "query", "template", "doc", "host", "tool", "evalset", "model",
284];
285
286/// Case-fold + trim for identity comparison. Upstream NFC is assumed.
287pub(crate) fn normalize_ident(s: &str) -> String {
288    s.trim().to_lowercase()
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn severity_orders() {
297        assert!(Severity::High > Severity::Medium);
298        assert!(Severity::Low > Severity::Info);
299    }
300
301    #[test]
302    fn target_ref_parses_known_schemes() {
303        let t = TargetRef::parse("entity:caller/john").unwrap();
304        assert_eq!(t.scheme(), "entity");
305        assert_eq!(t.opaque(), "caller/john");
306        assert!(t.auto_apply_eligible_class());
307
308        let doc = TargetRef::parse("doc:claude.md").unwrap();
309        assert!(
310            !doc.auto_apply_eligible_class(),
311            "prompt targets never auto-apply"
312        );
313    }
314
315    #[test]
316    fn target_ref_rejects_junk() {
317        assert!(TargetRef::parse("no-scheme").is_err());
318        assert!(TargetRef::parse("bogus:x").is_err());
319        assert!(TargetRef::parse("grain:").is_err());
320    }
321
322    #[test]
323    fn origin_auto_apply_gate() {
324        assert!(Origin::Builtin.auto_apply_eligible());
325        assert!(!Origin::Command { id: "x".into() }.auto_apply_eligible());
326        assert!(!Origin::Llm { model: "m".into() }.auto_apply_eligible());
327    }
328}