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
18pub fn parse_graph(json: &str) -> Result<CompositionGraph, serde_json::Error> {
20 serde_json::from_str(json)
21}
22
23#[derive(Debug, Clone)]
26pub enum PrefixResolutionError {
27 NotFound { prefix: String },
29 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
62struct ResolverIndex {
65 all_ids: Vec<String>,
67 by_name: std::collections::HashMap<String, (Vec<String>, Vec<String>)>,
72}
73
74pub 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 if index.all_ids.iter().any(|i| i == &id.0) {
120 return Ok(());
121 }
122 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 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
213pub fn serialize_graph(graph: &CompositionGraph) -> Result<String, serde_json::Error> {
215 serde_json::to_string_pretty(graph)
216}
217
218pub 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}