Skip to main content

noether_engine/lagrange/
mod.rs

1mod ast;
2pub mod canonical;
3pub mod resolver;
4
5pub use ast::{collect_stage_ids, resolve_stage_ref, CompositionGraph, CompositionNode, Pinning};
6pub use canonical::canonicalise;
7pub use resolver::{resolve_pinning, ResolutionError, Rewrite};
8
9use noether_core::stage::{Stage, StageId};
10use noether_store::StageStore;
11use sha2::{Digest, Sha256};
12
13/// Parse a Lagrange JSON string into a CompositionGraph.
14pub fn parse_graph(json: &str) -> Result<CompositionGraph, serde_json::Error> {
15    serde_json::from_str(json)
16}
17
18/// Errors raised by `resolve_stage_prefixes` when an ID in the graph cannot
19/// be uniquely resolved against the store.
20#[derive(Debug, Clone)]
21pub enum PrefixResolutionError {
22    /// The prefix did not match any stage in the store.
23    NotFound { prefix: String },
24    /// The prefix matched multiple stages — author must use a longer prefix.
25    Ambiguous {
26        prefix: String,
27        matches: Vec<String>,
28    },
29}
30
31impl std::fmt::Display for PrefixResolutionError {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Self::NotFound { prefix } => {
35                write!(f, "no stage in store matches prefix '{prefix}'")
36            }
37            Self::Ambiguous { prefix, matches } => {
38                write!(
39                    f,
40                    "stage prefix '{prefix}' is ambiguous; matches {} stages — \
41                     use a longer prefix. First few: {}",
42                    matches.len(),
43                    matches
44                        .iter()
45                        .take(3)
46                        .map(|s| &s[..16.min(s.len())])
47                        .collect::<Vec<_>>()
48                        .join(", ")
49                )
50            }
51        }
52    }
53}
54
55impl std::error::Error for PrefixResolutionError {}
56
57/// Snapshot of the store's identity metadata, used to resolve composition
58/// references without holding the store reference across nested walks.
59struct ResolverIndex {
60    /// Every stage ID currently in the store, for prefix matching.
61    all_ids: Vec<String>,
62    /// Name → (active_ids, non_active_ids). A stage-ref string is tried as
63    /// a name lookup when it doesn't match any ID prefix. Active matches
64    /// win unconditionally; non-active are only considered when no Active
65    /// candidate exists.
66    by_name: std::collections::HashMap<String, (Vec<String>, Vec<String>)>,
67}
68
69/// Walk a composition graph and replace any stage IDs that are unique
70/// prefixes — or human-authored names — of a real stage in the store with
71/// their full 64-character IDs.
72///
73/// Resolution order for `{"op": "Stage", "id": "<ref>"}`:
74///
75///   1. `<ref>` is an exact full-length ID → pass through.
76///   2. `<ref>` is a unique hex prefix of one stored ID → use it.
77///   3. `<ref>` matches exactly one stored stage's `name` field — with
78///      Active preferred over Draft/Deprecated — → use that stage's ID.
79///   4. Otherwise error with `NotFound` or `Ambiguous`.
80///
81/// Hand-authored graphs can therefore reference stages by the name from
82/// their spec (`{"id": "volvo_map"}`) without juggling 8-char prefixes.
83pub fn resolve_stage_prefixes(
84    node: &mut CompositionNode,
85    store: &(impl StageStore + ?Sized),
86) -> Result<(), PrefixResolutionError> {
87    let stages: Vec<&Stage> = store.list(None);
88    let mut by_name: std::collections::HashMap<String, (Vec<String>, Vec<String>)> =
89        std::collections::HashMap::new();
90    for s in &stages {
91        if let Some(name) = &s.name {
92            let entry = by_name.entry(name.clone()).or_default();
93            if matches!(s.lifecycle, noether_core::stage::StageLifecycle::Active) {
94                entry.0.push(s.id.0.clone());
95            } else {
96                entry.1.push(s.id.0.clone());
97            }
98        }
99    }
100    let index = ResolverIndex {
101        all_ids: stages.iter().map(|s| s.id.0.clone()).collect(),
102        by_name,
103    };
104    resolve_in_node(node, &index)
105}
106
107fn resolve_in_node(
108    node: &mut CompositionNode,
109    index: &ResolverIndex,
110) -> Result<(), PrefixResolutionError> {
111    match node {
112        CompositionNode::Stage { id, .. } => {
113            // 1. Exact full-length ID.
114            if index.all_ids.iter().any(|i| i == &id.0) {
115                return Ok(());
116            }
117            // 2. Hex prefix match. Guarded by "looks hex-ish" so a name
118            //    that happens to start with hex chars doesn't block name
119            //    lookup (e.g. the name `fade_in` would prefix-match
120            //    "fade…" stage IDs).
121            let looks_like_prefix = !id.0.is_empty() && id.0.chars().all(|c| c.is_ascii_hexdigit());
122            if looks_like_prefix {
123                let matches: Vec<&String> = index
124                    .all_ids
125                    .iter()
126                    .filter(|i| i.starts_with(&id.0))
127                    .collect();
128                match matches.len() {
129                    0 => {}
130                    1 => {
131                        *id = StageId(matches[0].clone());
132                        return Ok(());
133                    }
134                    _ => {
135                        return Err(PrefixResolutionError::Ambiguous {
136                            prefix: id.0.clone(),
137                            matches: matches.into_iter().cloned().collect(),
138                        })
139                    }
140                }
141            }
142            // 3. Name lookup — Active preferred, then fall back.
143            if let Some((active, other)) = index.by_name.get(&id.0) {
144                let candidates = if !active.is_empty() { active } else { other };
145                match candidates.len() {
146                    0 => {}
147                    1 => {
148                        *id = StageId(candidates[0].clone());
149                        return Ok(());
150                    }
151                    _ => {
152                        return Err(PrefixResolutionError::Ambiguous {
153                            prefix: id.0.clone(),
154                            matches: candidates.clone(),
155                        })
156                    }
157                }
158            }
159            Err(PrefixResolutionError::NotFound {
160                prefix: id.0.clone(),
161            })
162        }
163        CompositionNode::RemoteStage { .. } | CompositionNode::Const { .. } => Ok(()),
164        CompositionNode::Sequential { stages } => {
165            for s in stages {
166                resolve_in_node(s, index)?;
167            }
168            Ok(())
169        }
170        CompositionNode::Parallel { branches } => {
171            for b in branches.values_mut() {
172                resolve_in_node(b, index)?;
173            }
174            Ok(())
175        }
176        CompositionNode::Branch {
177            predicate,
178            if_true,
179            if_false,
180        } => {
181            resolve_in_node(predicate, index)?;
182            resolve_in_node(if_true, index)?;
183            resolve_in_node(if_false, index)
184        }
185        CompositionNode::Fanout { source, targets } => {
186            resolve_in_node(source, index)?;
187            for t in targets {
188                resolve_in_node(t, index)?;
189            }
190            Ok(())
191        }
192        CompositionNode::Merge { sources, target } => {
193            for s in sources {
194                resolve_in_node(s, index)?;
195            }
196            resolve_in_node(target, index)
197        }
198        CompositionNode::Retry { stage, .. } => resolve_in_node(stage, index),
199        CompositionNode::Let { bindings, body } => {
200            for b in bindings.values_mut() {
201                resolve_in_node(b, index)?;
202            }
203            resolve_in_node(body, index)
204        }
205    }
206}
207
208/// Serialize a CompositionGraph to pretty-printed JSON.
209pub fn serialize_graph(graph: &CompositionGraph) -> Result<String, serde_json::Error> {
210    serde_json::to_string_pretty(graph)
211}
212
213/// Compute a deterministic composition ID.
214///
215/// The hash is taken over the **canonical form of the graph's root node**,
216/// serialised via JCS (RFC 8785). Metadata fields (`description`,
217/// `version`) do not contribute to the ID: cosmetic edits should not
218/// shift a composition's identity, and equivalent graphs with different
219/// surface syntax (nested Sequentials, permuted Parallel branches,
220/// collapsed Retry layers, etc.) must produce identical IDs.
221///
222/// The canonicalisation rules are documented in
223/// `docs/architecture/semantics.md` and implemented in
224/// `crate::lagrange::canonical`.
225///
226/// **Compatibility note.** This changes composition IDs from the
227/// pre-0.5 byte-of-the-whole-graph hash. Migration guidance lives in
228/// the 0.5.0 release notes.
229pub fn compute_composition_id(graph: &CompositionGraph) -> Result<String, serde_json::Error> {
230    let canonical = canonicalise(&graph.root);
231    let bytes = serde_jcs::to_vec(&canonical)?;
232    let hash = Sha256::digest(&bytes);
233    Ok(hex::encode(hash))
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::lagrange::ast::CompositionNode;
240    use noether_core::stage::StageId;
241
242    #[test]
243    fn parse_and_serialize_round_trip() {
244        let graph = CompositionGraph::new(
245            "test",
246            CompositionNode::Stage {
247                id: StageId("abc".into()),
248                pinning: Pinning::Signature,
249                config: None,
250            },
251        );
252        let json = serialize_graph(&graph).unwrap();
253        let parsed = parse_graph(&json).unwrap();
254        assert_eq!(graph, parsed);
255    }
256
257    #[test]
258    fn resolver_resolves_by_name_when_no_prefix_match() {
259        use noether_core::capability::Capability;
260        use noether_core::effects::EffectSet;
261        use noether_core::stage::{CostEstimate, Stage, StageLifecycle, StageSignature};
262        use noether_core::types::NType;
263        use noether_store::MemoryStore;
264        use noether_store::StageStore as _;
265        use std::collections::BTreeSet;
266
267        let sig = StageSignature {
268            input: NType::Text,
269            output: NType::Number,
270            effects: EffectSet::pure(),
271            implementation_hash: "hash".into(),
272        };
273        let stage = Stage {
274            id: StageId("ffaa1122deadbeef0000000000000000000000000000000000000000000000ff".into()),
275            signature_id: None,
276            signature: sig,
277            capabilities: BTreeSet::<Capability>::new(),
278            cost: CostEstimate {
279                time_ms_p50: None,
280                tokens_est: None,
281                memory_mb: None,
282            },
283            description: "stub".into(),
284            examples: vec![],
285            lifecycle: StageLifecycle::Active,
286            ed25519_signature: None,
287            signer_public_key: None,
288            implementation_code: None,
289            implementation_language: None,
290            ui_style: None,
291            tags: vec![],
292            aliases: vec![],
293            name: Some("volvo_map".into()),
294            properties: Vec::new(),
295        };
296        let mut store = MemoryStore::new();
297        store.put(stage.clone()).unwrap();
298
299        let mut node = CompositionNode::Stage {
300            id: StageId("volvo_map".into()),
301            pinning: Pinning::Signature,
302            config: None,
303        };
304        resolve_stage_prefixes(&mut node, &store).unwrap();
305        match node {
306            CompositionNode::Stage { id, .. } => assert_eq!(id.0, stage.id.0),
307            _ => panic!("expected Stage node"),
308        }
309    }
310
311    #[test]
312    fn resolver_prefers_active_when_duplicate_names() {
313        use noether_core::capability::Capability;
314        use noether_core::effects::EffectSet;
315        use noether_core::stage::{CostEstimate, Stage, StageLifecycle, StageSignature};
316        use noether_core::types::NType;
317        use noether_store::MemoryStore;
318        use noether_store::StageStore as _;
319        use std::collections::BTreeSet;
320
321        fn mk(id_hex: &str, lifecycle: StageLifecycle, hash: &str) -> Stage {
322            Stage {
323                id: StageId(id_hex.into()),
324                signature_id: None,
325                signature: StageSignature {
326                    input: NType::Text,
327                    output: NType::Number,
328                    effects: EffectSet::pure(),
329                    implementation_hash: hash.into(),
330                },
331                capabilities: BTreeSet::<Capability>::new(),
332                cost: CostEstimate {
333                    time_ms_p50: None,
334                    tokens_est: None,
335                    memory_mb: None,
336                },
337                description: "stub".into(),
338                examples: vec![],
339                lifecycle,
340                ed25519_signature: None,
341                signer_public_key: None,
342                implementation_code: None,
343                implementation_language: None,
344                ui_style: None,
345                tags: vec![],
346                aliases: vec![],
347                name: Some("shared".into()),
348                properties: Vec::new(),
349            }
350        }
351
352        let draft = mk(
353            "1111111111111111111111111111111111111111111111111111111111111111",
354            StageLifecycle::Draft,
355            "h1",
356        );
357        let active = mk(
358            "2222222222222222222222222222222222222222222222222222222222222222",
359            StageLifecycle::Active,
360            "h2",
361        );
362        let mut store = MemoryStore::new();
363        store.put(draft).unwrap();
364        store.put(active.clone()).unwrap();
365
366        let mut node = CompositionNode::Stage {
367            id: StageId("shared".into()),
368            pinning: Pinning::Signature,
369            config: None,
370        };
371        resolve_stage_prefixes(&mut node, &store).unwrap();
372        match node {
373            CompositionNode::Stage { id, .. } => assert_eq!(id.0, active.id.0),
374            _ => panic!("expected Stage node"),
375        }
376    }
377
378    #[test]
379    fn composition_id_is_deterministic() {
380        let graph = CompositionGraph::new(
381            "test",
382            CompositionNode::Stage {
383                id: StageId("abc".into()),
384                pinning: Pinning::Signature,
385                config: None,
386            },
387        );
388        let id1 = compute_composition_id(&graph).unwrap();
389        let id2 = compute_composition_id(&graph).unwrap();
390        assert_eq!(id1, id2);
391        assert_eq!(id1.len(), 64);
392    }
393}