Skip to main content

sim_lib_intent/
model.rs

1//! Intent value model: origin, builders, accessors, and fail-closed validation.
2//!
3//! An Intent is a SIM value: a `kind`-tagged `Expr::Map` that also carries an
4//! `origin` (operator plus logical tick) and the fields its kind requires. This
5//! module builds and inspects Intents over `Expr` (no parallel data model),
6//! validates them structurally into a [`IntentError`], and resolves the targets
7//! an Intent references against a caller-supplied predicate so an Intent naming
8//! an unknown target produces a diagnostic rather than a partial mutation.
9
10use std::sync::Arc;
11
12use sim_kernel::{Cx, DefaultFactory, Expr, NoopEvalPolicy, ShapeMatch, Symbol};
13use sim_value::access;
14
15use crate::kinds::{
16    AT_TICK_KEY, KIND_KEY, OPERATOR_KEY, ORIGIN_KEY, is_known_kind, required_fields,
17};
18
19pub use sim_value::access::field;
20
21/// Who issued an Intent. Recorded on every Intent for audit; both a human
22/// (through the browser) and an agent (through the runner) are peers on the bus.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum Operator {
25    /// A human operator gesturing through the browser shell.
26    Human,
27    /// An agent operator acting through the agent runner.
28    Agent,
29}
30
31impl Operator {
32    /// The operator symbol used inside an Intent origin.
33    pub fn symbol(self) -> Symbol {
34        match self {
35            Operator::Human => Symbol::new("human"),
36            Operator::Agent => Symbol::new("agent"),
37        }
38    }
39
40    /// Parse an operator symbol, or `None` if it names neither operator.
41    pub fn from_name(name: &str) -> Option<Self> {
42        match name {
43            "human" => Some(Operator::Human),
44            "agent" => Some(Operator::Agent),
45            _ => None,
46        }
47    }
48}
49
50/// The origin of an Intent: which operator issued it and at what logical tick.
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
52pub struct Origin {
53    /// The issuing operator.
54    pub operator: Operator,
55    /// A monotonically increasing logical tick.
56    pub at_tick: u64,
57}
58
59impl Origin {
60    /// Build an origin for a human operator at `tick`.
61    pub fn human(tick: u64) -> Self {
62        Self {
63            operator: Operator::Human,
64            at_tick: tick,
65        }
66    }
67
68    /// Build an origin for an agent operator at `tick`.
69    pub fn agent(tick: u64) -> Self {
70        Self {
71            operator: Operator::Agent,
72            at_tick: tick,
73        }
74    }
75
76    fn to_expr(self) -> Expr {
77        sim_value::build::map(vec![
78            (OPERATOR_KEY, Expr::Symbol(self.operator.symbol())),
79            (AT_TICK_KEY, sim_value::build::uint(self.at_tick)),
80        ])
81    }
82}
83
84/// A structured Intent validation diagnostic: where the problem is and what it
85/// is.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct IntentError {
88    /// Address into the Intent (for example `path` or `targets[1]`).
89    pub path: Vec<String>,
90    /// Human-readable description of the violation.
91    pub message: String,
92}
93
94impl IntentError {
95    fn at(path: &[&str], message: impl Into<String>) -> Self {
96        Self {
97            path: path.iter().map(|segment| (*segment).to_owned()).collect(),
98            message: message.into(),
99        }
100    }
101
102    /// Render the path as a dotted address, or `<root>` when empty.
103    pub fn path_string(&self) -> String {
104        if self.path.is_empty() {
105            "<root>".to_owned()
106        } else {
107            self.path.join(".")
108        }
109    }
110}
111
112impl core::fmt::Display for IntentError {
113    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
114        write!(f, "{}: {}", self.path_string(), self.message)
115    }
116}
117
118/// Build an Intent value: a `kind`-tagged map carrying `origin` then `fields`.
119pub fn intent(kind_name: &str, origin: Origin, fields: Vec<(&str, Expr)>) -> Expr {
120    let mut pairs = Vec::with_capacity(fields.len() + 2);
121    pairs.push((
122        sim_value::build::sym(KIND_KEY),
123        Expr::Symbol(crate::kinds::intent_kind(kind_name)),
124    ));
125    pairs.push((sim_value::build::sym(ORIGIN_KEY), origin.to_expr()));
126    for (key, value) in fields {
127        pairs.push((sim_value::build::sym(key), value));
128    }
129    Expr::Map(pairs)
130}
131
132/// If `expr` is a `kind`-tagged map, return the kind symbol.
133pub fn intent_kind_of(expr: &Expr) -> Option<Symbol> {
134    let Expr::Map(entries) = expr else {
135        return None;
136    };
137    match access::entry_field(entries, KIND_KEY) {
138        Some(Expr::Symbol(kind)) => Some(kind.clone()),
139        _ => None,
140    }
141}
142
143/// Parse the origin of an Intent, if present and well-formed.
144pub fn origin(expr: &Expr) -> Option<Origin> {
145    let origin = field(expr, ORIGIN_KEY)?;
146    let Expr::Map(entries) = origin else {
147        return None;
148    };
149    let operator = match access::entry_field(entries, OPERATOR_KEY) {
150        Some(Expr::Symbol(symbol)) => Operator::from_name(&symbol.name)?,
151        _ => return None,
152    };
153    let at_tick = match access::entry_field(entries, AT_TICK_KEY) {
154        Some(Expr::Number(number)) => number.canonical.parse::<u64>().ok()?,
155        _ => return None,
156    };
157    Some(Origin { operator, at_tick })
158}
159
160/// Validate that `expr` is a structurally well-formed Intent, failing closed
161/// with an [`IntentError`] otherwise.
162pub fn validate_intent(expr: &Expr) -> Result<(), IntentError> {
163    let shape_error = check_intent_shape(expr)?;
164    let Expr::Map(entries) = expr else {
165        return Err(IntentError::at(&[], "an Intent must be a map"));
166    };
167    let kind = match access::entry_field(entries, KIND_KEY) {
168        Some(Expr::Symbol(kind)) if is_known_kind(kind) => kind.clone(),
169        Some(Expr::Symbol(kind)) => {
170            return Err(IntentError::at(
171                &[KIND_KEY],
172                format!("unrecognized Intent kind '{kind}'"),
173            ));
174        }
175        Some(_) => {
176            return Err(IntentError::at(
177                &[KIND_KEY],
178                "Intent 'kind' must be a symbol",
179            ));
180        }
181        None => return Err(IntentError::at(&[], "Intent is missing a 'kind' tag")),
182    };
183    if let Some(message) = shape_error {
184        return Err(IntentError::at(&[], message));
185    }
186    validate_origin(entries)?;
187    for required in required_fields(&kind.name) {
188        let Some(value) = access::entry_field(entries, required) else {
189            return Err(IntentError::at(
190                &[required],
191                format!("Intent '{kind}' is missing required field '{required}'"),
192            ));
193        };
194        if *required == "path" && !matches!(value, Expr::List(_)) {
195            return Err(IntentError::at(
196                &["path"],
197                format!("{kind} 'path' must be a list of segments"),
198            ));
199        }
200    }
201    Ok(())
202}
203
204fn check_intent_shape(expr: &Expr) -> Result<Option<String>, IntentError> {
205    let mut cx = Cx::new(Arc::new(NoopEvalPolicy), Arc::new(DefaultFactory));
206    let matched = crate::shapes::intent_shape()
207        .check_expr(&mut cx, expr)
208        .map_err(|error| IntentError::at(&[], format!("Intent shape check failed: {error}")))?;
209    Ok(
210        (!matched.accepted)
211            .then(|| rejection_message(&matched, "value is not a recognized Intent")),
212    )
213}
214
215fn rejection_message(matched: &ShapeMatch, fallback: &str) -> String {
216    matched
217        .diagnostics
218        .first()
219        .map(|diagnostic| diagnostic.message.clone())
220        .unwrap_or_else(|| fallback.to_owned())
221}
222
223fn validate_origin(entries: &[(Expr, Expr)]) -> Result<(), IntentError> {
224    let Some(origin) = access::entry_field(entries, ORIGIN_KEY) else {
225        return Err(IntentError::at(
226            &[ORIGIN_KEY],
227            "Intent is missing an 'origin'",
228        ));
229    };
230    let Expr::Map(origin_entries) = origin else {
231        return Err(IntentError::at(
232            &[ORIGIN_KEY],
233            "Intent 'origin' must be a map",
234        ));
235    };
236    match access::entry_field(origin_entries, OPERATOR_KEY) {
237        Some(Expr::Symbol(symbol)) if Operator::from_name(&symbol.name).is_some() => {}
238        _ => {
239            return Err(IntentError::at(
240                &[ORIGIN_KEY, OPERATOR_KEY],
241                "origin 'operator' must be 'human' or 'agent'",
242            ));
243        }
244    }
245    match access::entry_field(origin_entries, AT_TICK_KEY) {
246        Some(Expr::Number(_)) => Ok(()),
247        _ => Err(IntentError::at(
248            &[ORIGIN_KEY, AT_TICK_KEY],
249            "origin 'at-tick' must be a number",
250        )),
251    }
252}
253
254/// Resolve every target an Intent references against `is_known`, returning a
255/// diagnostic for the first unknown target. A failed resolution means the
256/// editor must not produce an operation: nothing mutates.
257pub fn resolve_targets(expr: &Expr, is_known: impl Fn(&Expr) -> bool) -> Result<(), IntentError> {
258    for (label, target) in referenced_targets(expr) {
259        if !is_known(&target) {
260            return Err(IntentError {
261                path: vec![label],
262                message: "Intent references an unknown target".to_owned(),
263            });
264        }
265    }
266    Ok(())
267}
268
269/// The list of `(field-label, target-expr)` references an Intent carries, by
270/// kind. Port references (`from`/`to`) contribute their inner `node`.
271pub fn referenced_targets(expr: &Expr) -> Vec<(String, Expr)> {
272    let Some(kind) = intent_kind_of(expr) else {
273        return Vec::new();
274    };
275    let mut refs = Vec::new();
276    let mut single = |name: &str| {
277        if let Some(value) = field(expr, name) {
278            refs.push((name.to_owned(), value.clone()));
279        }
280    };
281    match &*kind.name {
282        "tap" | "edit" | "edit-field" | "invoke" | "scrub" | "set-param" | "performance-event"
283        | "piano-roll-edit" | "player-rack-edit" | "arranger-edit" => single("target"),
284        "move" => single("node"),
285        "unwire" => single("edge"),
286        "create" => single("class"),
287        "open" => single("value"),
288        "approve" | "reject" | "ask" | "split-mission" | "pause-agent" | "rerun-validation"
289        | "replay-cassette" => single("mission"),
290        "open-source" => single("location"),
291        "select" | "delete" => {
292            if let Some(Expr::List(items)) = field(expr, "targets") {
293                for (index, item) in items.iter().enumerate() {
294                    refs.push((format!("targets[{index}]"), item.clone()));
295                }
296            }
297        }
298        "wire" => {
299            for end in ["from", "to"] {
300                if let Some(Expr::Map(port)) = field(expr, end)
301                    && let Some(node) = access::entry_field(port, "node")
302                {
303                    refs.push((format!("{end}.node"), node.clone()));
304                }
305            }
306        }
307        _ => {}
308    }
309    refs
310}