Skip to main content

noether_engine/lagrange/
mod.rs

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