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