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
9pub fn parse_graph(json: &str) -> Result<CompositionGraph, serde_json::Error> {
11 serde_json::from_str(json)
12}
13
14#[derive(Debug, Clone)]
17pub enum PrefixResolutionError {
18 NotFound { prefix: String },
20 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
53struct ResolverIndex {
56 all_ids: Vec<String>,
58 by_name: std::collections::HashMap<String, (Vec<String>, Vec<String>)>,
63}
64
65pub 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 if index.all_ids.iter().any(|i| i == &id.0) {
111 return Ok(());
112 }
113 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 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
204pub fn serialize_graph(graph: &CompositionGraph) -> Result<String, serde_json::Error> {
206 serde_json::to_string_pretty(graph)
207}
208
209pub 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}