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
13pub fn parse_graph(json: &str) -> Result<CompositionGraph, serde_json::Error> {
15 serde_json::from_str(json)
16}
17
18#[derive(Debug, Clone)]
21pub enum PrefixResolutionError {
22 NotFound { prefix: String },
24 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
57struct ResolverIndex {
60 all_ids: Vec<String>,
62 by_name: std::collections::HashMap<String, (Vec<String>, Vec<String>)>,
67}
68
69pub 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 if index.all_ids.iter().any(|i| i == &id.0) {
115 return Ok(());
116 }
117 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 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
208pub fn serialize_graph(graph: &CompositionGraph) -> Result<String, serde_json::Error> {
210 serde_json::to_string_pretty(graph)
211}
212
213pub 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}