Skip to main content

noether_engine/lagrange/
mod.rs

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