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, HandleSeed, 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(
206        Arc::new(NoopEvalPolicy),
207        Arc::new(DefaultFactory),
208        HandleSeed::new(1),
209    );
210    let matched = crate::shapes::intent_shape()
211        .check_expr(&mut cx, expr)
212        .map_err(|error| IntentError::at(&[], format!("Intent shape check failed: {error}")))?;
213    Ok(
214        (!matched.accepted)
215            .then(|| rejection_message(&matched, "value is not a recognized Intent")),
216    )
217}
218
219fn rejection_message(matched: &ShapeMatch, fallback: &str) -> String {
220    matched
221        .diagnostics
222        .first()
223        .map(|diagnostic| diagnostic.message.clone())
224        .unwrap_or_else(|| fallback.to_owned())
225}
226
227fn validate_origin(entries: &[(Expr, Expr)]) -> Result<(), IntentError> {
228    let Some(origin) = access::entry_field(entries, ORIGIN_KEY) else {
229        return Err(IntentError::at(
230            &[ORIGIN_KEY],
231            "Intent is missing an 'origin'",
232        ));
233    };
234    let Expr::Map(origin_entries) = origin else {
235        return Err(IntentError::at(
236            &[ORIGIN_KEY],
237            "Intent 'origin' must be a map",
238        ));
239    };
240    match access::entry_field(origin_entries, OPERATOR_KEY) {
241        Some(Expr::Symbol(symbol)) if Operator::from_name(&symbol.name).is_some() => {}
242        _ => {
243            return Err(IntentError::at(
244                &[ORIGIN_KEY, OPERATOR_KEY],
245                "origin 'operator' must be 'human' or 'agent'",
246            ));
247        }
248    }
249    match access::entry_field(origin_entries, AT_TICK_KEY) {
250        Some(Expr::Number(_)) => Ok(()),
251        _ => Err(IntentError::at(
252            &[ORIGIN_KEY, AT_TICK_KEY],
253            "origin 'at-tick' must be a number",
254        )),
255    }
256}
257
258/// Resolve every target an Intent references against `is_known`, returning a
259/// diagnostic for the first unknown target. A failed resolution means the
260/// editor must not produce an operation: nothing mutates.
261pub fn resolve_targets(expr: &Expr, is_known: impl Fn(&Expr) -> bool) -> Result<(), IntentError> {
262    for (label, target) in referenced_targets(expr) {
263        if !is_known(&target) {
264            return Err(IntentError {
265                path: vec![label],
266                message: "Intent references an unknown target".to_owned(),
267            });
268        }
269    }
270    Ok(())
271}
272
273/// The list of `(field-label, target-expr)` references an Intent carries, by
274/// kind. Port references (`from`/`to`) contribute their inner `node`.
275pub fn referenced_targets(expr: &Expr) -> Vec<(String, Expr)> {
276    let Some(kind) = intent_kind_of(expr) else {
277        return Vec::new();
278    };
279    let mut refs = Vec::new();
280    let mut single = |name: &str| {
281        if let Some(value) = field(expr, name) {
282            refs.push((name.to_owned(), value.clone()));
283        }
284    };
285    match &*kind.name {
286        "tap" | "edit" | "edit-field" | "invoke" | "scrub" | "set-param" | "performance-event"
287        | "piano-roll-edit" | "player-rack-edit" | "arranger-edit" => single("target"),
288        "move" => single("node"),
289        "unwire" => single("edge"),
290        "create" => single("class"),
291        "open" => single("value"),
292        "approve" | "reject" | "ask" | "split-mission" | "pause-agent" | "rerun-validation"
293        | "replay-cassette" => single("mission"),
294        "open-source" => single("location"),
295        "select" | "delete" => {
296            if let Some(Expr::List(items)) = field(expr, "targets") {
297                for (index, item) in items.iter().enumerate() {
298                    refs.push((format!("targets[{index}]"), item.clone()));
299                }
300            }
301        }
302        "wire" => {
303            for end in ["from", "to"] {
304                if let Some(Expr::Map(port)) = field(expr, end)
305                    && let Some(node) = access::entry_field(port, "node")
306                {
307                    refs.push((format!("{end}.node"), node.clone()));
308                }
309            }
310        }
311        _ => {}
312    }
313    refs
314}