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    /// Record a new durable fact derived from evidence — the LLM `fact`
186    /// proposal, where a lesson's fixed `relation = "lesson"` is too narrow
187    /// (a vendor alias, a settled default a person keeps re-supplying).
188    /// Applies as an `ADD`, so it is never auto-applied.
189    Record,
190    /// Revise something that governs FUTURE behaviour rather than recording a
191    /// past one: a saved CAL query/template (`DEFINE`), or field-level edits
192    /// to a Workflow plan (`SUPERSEDE`). Never auto-applied — a grain edit
193    /// changes one remembered value, a revision changes every future run.
194    Revise,
195    /// Propose a new revision of an executable tool's code (§7.4). NEVER
196    /// auto-applied — categorically, by name — and the recommendation must
197    /// pin the evalset hash it was gated against (Rule E1): apply is refused
198    /// without a recorded gating run, and a superseded evalset invalidates
199    /// the pin (the recommendation re-gates).
200    CodeRevision,
201    /// Propose promoting a host-trained adapter into service for a `model:`
202    /// target (the tuning seam). Same governance shape as `CodeRevision`:
203    /// NEVER auto-applied, must pin the evalset it was gated against
204    /// (Rule E1), and apply is refused without a recorded gating run.
205    AdapterRevision,
206}
207
208impl ActionKind {
209    pub fn as_str(&self) -> &'static str {
210        match self {
211            ActionKind::Consolidate => "consolidate",
212            ActionKind::FlagContradiction => "flag_contradiction",
213            ActionKind::ClusterFailure => "cluster_failure",
214            ActionKind::Expire => "expire",
215            ActionKind::MergeHeads => "merge_heads",
216            ActionKind::Revert => "revert",
217            ActionKind::Flag => "flag",
218            ActionKind::Record => "record",
219            ActionKind::Revise => "revise",
220            ActionKind::CodeRevision => "code_revision",
221            ActionKind::AdapterRevision => "adapter_revision",
222        }
223    }
224}
225
226/// A parsed `target_ref`: `<scheme>:<opaque>`. The scheme is the target-kind
227/// discriminator (proposal §7.1).
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct TargetRef {
230    scheme: String,
231    opaque: String,
232}
233
234impl TargetRef {
235    /// Parse `<scheme>:<opaque>`. Fails if there is no scheme or empty opaque.
236    pub fn parse(s: &str) -> crate::error::Result<Self> {
237        let (scheme, opaque) = s.split_once(':').ok_or_else(|| {
238            crate::error::Error::InvalidTargetRef(format!("missing scheme in {s:?}"))
239        })?;
240        if scheme.is_empty() || opaque.is_empty() {
241            return Err(crate::error::Error::InvalidTargetRef(format!(
242                "empty scheme or opaque in {s:?}"
243            )));
244        }
245        if !KNOWN_SCHEMES.contains(&scheme) {
246            return Err(crate::error::Error::InvalidTargetRef(format!(
247                "unknown scheme {scheme:?} in {s:?}"
248            )));
249        }
250        Ok(TargetRef {
251            scheme: scheme.to_string(),
252            opaque: opaque.to_string(),
253        })
254    }
255
256    pub fn scheme(&self) -> &str {
257        &self.scheme
258    }
259    pub fn opaque(&self) -> &str {
260        &self.opaque
261    }
262
263    /// Memory/query targets are the only classes eligible for auto-apply
264    /// (§6.3); prompt (`doc:`), `host:`, the §7.4 `tool:`/`evalset:` classes,
265    /// and the tuning seam's `model:` class are never auto-applied — code,
266    /// adapters, and their gate are excluded BY NAME, not by accident of the
267    /// grant vocabulary.
268    pub fn auto_apply_eligible_class(&self) -> bool {
269        matches!(
270            self.scheme.as_str(),
271            "grain" | "entity" | "query" | "template"
272        )
273    }
274
275    /// The policy target class: `memory` (grain/entity), `query`
276    /// (query/template), `prompt` (doc), `code` (tool), `evalset`, or `host`.
277    pub fn target_class(&self) -> &'static str {
278        match self.scheme.as_str() {
279            "grain" | "entity" => "memory",
280            "query" | "template" => "query",
281            "doc" => "prompt",
282            "tool" => "code",
283            "evalset" => "evalset",
284            "model" => "model",
285            _ => "host",
286        }
287    }
288
289    pub fn as_string(&self) -> String {
290        format!("{}:{}", self.scheme, self.opaque)
291    }
292}
293
294const KNOWN_SCHEMES: &[&str] = &[
295    "grain", "entity", "query", "template", "doc", "host", "tool", "evalset", "model",
296];
297
298/// Case-fold + trim for identity comparison. Upstream NFC is assumed.
299pub(crate) fn normalize_ident(s: &str) -> String {
300    s.trim().to_lowercase()
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn severity_orders() {
309        assert!(Severity::High > Severity::Medium);
310        assert!(Severity::Low > Severity::Info);
311    }
312
313    #[test]
314    fn target_ref_parses_known_schemes() {
315        let t = TargetRef::parse("entity:caller/john").unwrap();
316        assert_eq!(t.scheme(), "entity");
317        assert_eq!(t.opaque(), "caller/john");
318        assert!(t.auto_apply_eligible_class());
319
320        let doc = TargetRef::parse("doc:claude.md").unwrap();
321        assert!(
322            !doc.auto_apply_eligible_class(),
323            "prompt targets never auto-apply"
324        );
325    }
326
327    #[test]
328    fn target_ref_rejects_junk() {
329        assert!(TargetRef::parse("no-scheme").is_err());
330        assert!(TargetRef::parse("bogus:x").is_err());
331        assert!(TargetRef::parse("grain:").is_err());
332    }
333
334    #[test]
335    fn origin_auto_apply_gate() {
336        assert!(Origin::Builtin.auto_apply_eligible());
337        assert!(!Origin::Command { id: "x".into() }.auto_apply_eligible());
338        assert!(!Origin::Llm { model: "m".into() }.auto_apply_eligible());
339    }
340}