Skip to main content

mlua_swarm/core/
step_naming.rs

1//! `StepNaming` — GH #23: the Blueprint-declared step-projection naming
2//! table.
3//!
4//! Before this module, a dispatched Step was addressable under two
5//! independent, occasionally-colliding names: the flow.ir data-plane
6//! producer name (`Step.ref` / `AgentDef.name`) and the `result_ref`
7//! ctx-path key (`Step.out`'s top-level path segment). Consumers
8//! (`ContextPolicy.steps` filter / `StepPointer.name` / the REST
9//! `:step` resolver / `FileProjectionAdapter`'s file stem) resolved the
10//! union of both, data-plane winning on collision — see
11//! `crates/mlua-swarm-server/src/projection.rs`'s `enumerate_steps` for
12//! the pre-GH-#23 runtime union rule this table statically replaces.
13//!
14//! [`StepNaming`] collapses that union into a single addressing space,
15//! built ONCE per Blueprint at
16//! [`blueprint::compiler::Compiler::compile`](crate::blueprint::compiler::Compiler::compile)
17//! time (the sole construction site — see [`StepNaming::from_blueprint`]),
18//! then threaded read-only from there: `EngineDispatcher` stashes an
19//! `Arc<StepNaming>` per dispatched task
20//! (`EngineState.step_namings`, keyed by `StepId`), and
21//! `Engine::step_naming_for` is the accessor later consumers pull from.
22//!
23//! GH #23 subtask-2/3 completed the 5-consumer switch-over this module's
24//! table backs — `Engine::submit_output`/`materialize_final_submission`
25//! (data-plane write + file stem), `ContextPolicy.allows_step`
26//! (`crates/mlua-swarm-server/src/worker.rs`'s `allows_step_canonical`
27//! seam), `StepPointer`/`StepSummary` assembly, and the REST `:step`
28//! resolver all resolve through [`StepNaming::canonical_of_producer`] /
29//! [`StepNaming::resolve`] instead of re-deriving the pre-GH-#23 union
30//! rule at read time. `crate::store::output::OutputStore::get_latest_by_name_in_run`
31//! (Layer 2) closed the cross-Run same-name race this table's
32//! canonicalization alone could not: a declared or undeclared name is
33//! now resolved Run-scoped regardless. An undeclared step's `canonical`
34//! stays its raw `Step.ref` and its `aliases` still include the
35//! `result_ref` top-level segment, so the pre-GH-#23 union's observable
36//! behavior is unchanged for any Blueprint that never declares
37//! `AgentMeta.projection_name`.
38
39use std::collections::{BTreeMap, BTreeSet};
40
41use mlua_flow_ir::{Expr, Node};
42
43use crate::blueprint::Blueprint;
44
45/// One step's resolved canonical projection name plus every alias name
46/// consumers may still address it by.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct StepNameEntry {
49    /// The name every consumer converges on: the `AgentMeta.projection_name`
50    /// declared for the step's Blueprint agent, or (when undeclared) the
51    /// flow.ir `Step.ref` (the data-plane producer name) unchanged.
52    pub canonical: String,
53    /// Every name this step should ALSO resolve under: always includes the
54    /// `Step.ref`, plus — when the Step's `out` is a `Path` expr — that
55    /// path's top-level segment (the pre-GH-#23 `result_ref`-derived
56    /// name), subject to the strong/weak claim ladder in
57    /// [`StepNaming::from_blueprint`]'s doc: a top segment claimed from a
58    /// NESTED `out` (`$.r.a`) is a WEAK claim and is absent here whenever
59    /// another step claims the same name. A bare `Step.ref` that happens
60    /// to equal its own `out` top segment collapses to a single-element
61    /// set; this is not a collision.
62    pub aliases: BTreeSet<String>,
63}
64
65/// Non-fatal collision detected while building a [`StepNaming`] table:
66/// two UNDECLARED steps' canonical/alias name sets intersect.
67/// Registration still proceeds — the pre-GH-#23 union rule's
68/// "data-plane wins" tie-break applies (see
69/// [`StepNaming::from_blueprint`]) — but the caller is expected to
70/// surface this via `tracing::warn!`. This type carries no logging side
71/// effect itself, matching the crate's existing convention
72/// (`blueprint::compiler`'s static-walk helpers) of returning data and
73/// letting the caller decide how to report it.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct StepNamingWarning {
76    /// The contested name.
77    pub name: String,
78    /// The step (`Step.ref`) that claimed `name` first.
79    pub first_step_ref: String,
80    /// The step (`Step.ref`) whose claim collided with the first.
81    pub second_step_ref: String,
82}
83
84/// Fatal collision: at least one side of the clash declared `name` via
85/// `AgentMeta.projection_name`. Rejected at registration time — the same
86/// "Blueprint validation error" family as
87/// `blueprint::compiler::CompileError`'s existing fail-fast checks
88/// (`DuplicateAgent` / `UnresolvedMetaRef` / …).
89#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
90#[error(
91    "StepNaming collision: name '{name}' is claimed by both step '{first_step_ref}' and step \
92     '{second_step_ref}' ({reason})"
93)]
94pub struct StepNamingError {
95    /// The contested name.
96    pub name: String,
97    /// The step (`Step.ref`) that claimed `name` first.
98    pub first_step_ref: String,
99    /// The step (`Step.ref`) whose claim collided with the first.
100    pub second_step_ref: String,
101    /// Human-readable reason (which side(s) declared `projection_name`).
102    pub reason: String,
103}
104
105/// GH #23 — the single addressing-space table for one Blueprint's
106/// dispatched steps. See the module doc for the construction site and
107/// storage/accessor threading; this doc covers the resolution rules.
108///
109/// # Canonical / alias resolution
110///
111/// For every distinct `Step.ref` appearing anywhere in the flow (`Seq` /
112/// `Branch` / `Fanout` / `Loop` / `Try` nesting all walked — see
113/// [`Self::from_blueprint`]):
114///
115/// - `canonical` = the dispatching agent's `AgentMeta.projection_name`
116///   when declared, else the `Step.ref` itself (byte-identical to
117///   pre-GH-#23 behavior for undeclared Blueprints).
118/// - `aliases` = `{Step.ref}` ∪ every `out` Path expr's top-level segment
119///   seen across every occurrence of that `ref` in the flow (`"$.plan"`
120///   → `"plan"`, `"$.a.b"` → `"a"`; a non-`Path` `out` contributes
121///   nothing — best-effort, mirroring `blueprint::compiler`'s existing
122///   static-walk convention of skipping what can't be inspected
123///   structurally), minus any WEAK claim another step contests (below).
124///
125/// # Strong and weak claims
126///
127/// Not every name a step could claim is claimed with the same strength:
128///
129/// - **strong claim** — the `Step.ref`, the declared `projection_name`,
130///   and an `out` that is exactly `$.T` (depth 1, so the step OWNS the
131///   whole `T` subtree).
132/// - **weak claim** — the top segment `T` of an `out` writing UNDER it
133///   (`$.T.x…`, depth ≥ 2). The step owns a lane inside `T`, not `T`.
134///
135/// A weak claim registers only when no other step claims that name at
136/// all. The moment a second step claims it — strongly or weakly — every
137/// weak claim on the name is dropped (silently, at `tracing::debug!`
138/// level: a shared nesting root is ordinary Blueprint shape, not a
139/// defect). The name then resolves to whichever step claims it strongly,
140/// or to nothing when the contest was weak-vs-weak. That deliberate miss
141/// replaces the pre-existing behavior of handing out one arbitrary lane's
142/// output under the shared root's name.
143///
144/// Every strong claim (`canonical` + every strongly-claimed alias) is
145/// checked for cross-step collisions. A clash where either side declared
146/// `projection_name` is a hard [`StepNamingError`] (registration is
147/// rejected outright). A clash between two undeclared steps is a soft
148/// [`StepNamingWarning`]: the pre-GH-#23 union rule's "data-plane wins"
149/// precedence is preserved by letting the step whose OWN `ref` equals the
150/// contested name own it in [`Self::resolve`] — an alias derived merely
151/// from another step's `out` segment never displaces it.
152///
153/// The resulting boundaries:
154///
155/// | Blueprint shape | result |
156/// |---|---|
157/// | `$.r.a` / `$.r.b` / … written by different steps (a shared nesting root) | `"r"` is nobody's alias; no warning. Each lane is addressed by its own ref / `projection_name` |
158/// | two steps whose `out` is the identical `$.r` | soft warning + data-plane priority (a genuine ambiguity) |
159/// | `ref: "r"` on one step, `$.r.x` on another | the weak claim yields: no warning, `resolve("r")` is the `"r"` step |
160/// | `projection_name: "r"` on one step, `$.r.x` on another | the weak claim yields: compiles (no hard error) |
161/// | `$.r.a` written by exactly one step | `"r"` stays that step's alias (unchanged) |
162#[derive(Debug, Clone, Default)]
163pub struct StepNaming {
164    by_ref: BTreeMap<String, String>,
165    by_name: BTreeMap<String, String>,
166    entries: BTreeMap<String, StepNameEntry>,
167}
168
169impl StepNaming {
170    /// Resolve `name` (canonical or alias) to its canonical name.
171    pub fn resolve(&self, name: &str) -> Option<&str> {
172        self.by_name.get(name).map(String::as_str)
173    }
174
175    /// Resolve a Step's data-plane producer name (`Step.ref` /
176    /// `AgentDef.name`) to its canonical name.
177    pub fn canonical_of_producer(&self, ref_name: &str) -> Option<&str> {
178        self.by_ref.get(ref_name).map(String::as_str)
179    }
180
181    /// Every canonical name this table declares (subtask-2/3 enumeration
182    /// consumers, e.g. `McpQueryAdapter::enumerate_steps`).
183    pub fn names(&self) -> impl Iterator<Item = &str> {
184        self.entries.keys().map(String::as_str)
185    }
186
187    /// Every full [`StepNameEntry`] (canonical + aliases) this table
188    /// holds.
189    pub fn entries(&self) -> impl Iterator<Item = &StepNameEntry> {
190        self.entries.values()
191    }
192
193    /// Build the table from a Blueprint's `flow` + `agents` — the sole
194    /// construction site (see the module + struct docs). Returns the
195    /// table plus any soft [`StepNamingWarning`]s (the caller decides
196    /// how to log them, typically via `tracing::warn!`); a hard
197    /// collision returns [`StepNamingError`] instead.
198    pub fn from_blueprint(
199        bp: &Blueprint,
200    ) -> Result<(StepNaming, Vec<StepNamingWarning>), StepNamingError> {
201        // 1. Static walk: collect every Step occurrence's
202        //    (ref, out-top-segment + whether that `out` was NESTED under
203        //    the segment).
204        let mut occurrences: Vec<(String, Option<(String, bool)>)> = Vec::new();
205        collect_steps(&bp.flow, &mut occurrences);
206
207        // 2. Group by ref — a `Step.ref` may recur (e.g. inside a Loop
208        //    body, or a flow author simply dispatching the same agent
209        //    twice); the same agent always resolves to the same
210        //    canonical name, so all of its occurrences fold into one
211        //    entry, and every `out`-top segment seen across occurrences
212        //    is unioned into its claim set. Contests are therefore only
213        //    ever evaluated BETWEEN distinct refs.
214        let mut order: Vec<String> = Vec::new();
215        let mut out_tops: BTreeMap<String, BTreeSet<(String, bool)>> = BTreeMap::new();
216        for (ref_, top) in occurrences {
217            let tops = out_tops.entry(ref_.clone()).or_default();
218            if let Some(top) = top {
219                tops.insert(top);
220            }
221            if !order.contains(&ref_) {
222                order.push(ref_);
223            }
224        }
225
226        // 3. `AgentDef.name -> AgentMeta.projection_name` (declared-only).
227        let declared: BTreeMap<&str, &str> = bp
228            .agents
229            .iter()
230            .filter_map(|ad| {
231                let name = ad.meta.as_ref()?.projection_name.as_deref()?;
232                Some((ad.name.as_str(), name))
233            })
234            .collect();
235
236        // 4. Split each ref's claims into the strong set (its `ref`, its
237        //    canonical name, and every `out` that is exactly `$.T`) and
238        //    the weak set (top segments of NESTED `out`s, `$.T.x…` — the
239        //    step owns a lane inside `T`, not `T` itself). See the struct
240        //    doc's "Strong and weak claims".
241        let mut plans: Vec<StepClaims> = Vec::with_capacity(order.len());
242        for ref_ in &order {
243            let is_declared = declared.contains_key(ref_.as_str());
244            let canonical = declared
245                .get(ref_.as_str())
246                .map(|s| s.to_string())
247                .unwrap_or_else(|| ref_.clone());
248            let mut strong_aliases: BTreeSet<String> = BTreeSet::new();
249            strong_aliases.insert(ref_.clone());
250            let mut weak_aliases: BTreeSet<String> = BTreeSet::new();
251            for (top, nested) in out_tops.remove(ref_).unwrap_or_default() {
252                if nested {
253                    weak_aliases.insert(top);
254                } else {
255                    strong_aliases.insert(top);
256                }
257            }
258            // A name this ref ALSO claims strongly is not weak for it.
259            weak_aliases.retain(|n| n != &canonical && !strong_aliases.contains(n));
260            plans.push(StepClaims {
261                ref_: ref_.clone(),
262                canonical,
263                is_declared,
264                strong_aliases,
265                weak_aliases,
266            });
267        }
268
269        // 5. Count claimants per name (each ref counted at most once per
270        //    name) so a weak claim can tell "nobody else wants this" from
271        //    "contested".
272        let mut claimants: BTreeMap<&str, usize> = BTreeMap::new();
273        for plan in &plans {
274            for name in plan.claimed_strong().chain(plan.weak_aliases.iter()) {
275                *claimants.entry(name.as_str()).or_default() += 1;
276            }
277        }
278
279        let mut naming = StepNaming::default();
280        let mut warnings = Vec::new();
281        // name -> (owning ref, declared?) — tracks current ownership so a
282        // later occurrence can detect + (for soft clashes) re-arbitrate.
283        let mut claims: BTreeMap<String, (String, bool)> = BTreeMap::new();
284
285        for plan in &plans {
286            let StepClaims {
287                ref_,
288                canonical,
289                is_declared,
290                strong_aliases,
291                weak_aliases,
292            } = plan;
293            let (is_declared, canonical) = (*is_declared, canonical.clone());
294
295            // Weak claims survive only uncontested; a contested one is
296            // dropped without a warning (a shared nesting root is
297            // ordinary Blueprint shape). `debug!` keeps it observable.
298            let mut aliases: BTreeSet<String> = strong_aliases.clone();
299            for name in weak_aliases {
300                if claimants.get(name.as_str()).copied().unwrap_or(0) > 1 {
301                    tracing::debug!(
302                        name = %name,
303                        step_ref = %ref_,
304                        "StepNaming: dropping a contested weak (nesting-root) alias claim; \
305                         address this step by its ref or projection_name instead"
306                    );
307                    continue;
308                }
309                aliases.insert(name.clone());
310            }
311
312            let mut claimed: BTreeSet<String> = aliases.clone();
313            claimed.insert(canonical.clone());
314
315            for name in &claimed {
316                match claims.get(name).cloned() {
317                    None => {
318                        claims.insert(name.clone(), (ref_.clone(), is_declared));
319                        naming.by_name.insert(name.clone(), canonical.clone());
320                    }
321                    Some((other_ref, other_declared)) => {
322                        if is_declared || other_declared {
323                            return Err(StepNamingError {
324                                name: name.clone(),
325                                first_step_ref: other_ref,
326                                second_step_ref: ref_.clone(),
327                                reason: collision_reason(other_declared, is_declared),
328                            });
329                        }
330                        warnings.push(StepNamingWarning {
331                            name: name.clone(),
332                            first_step_ref: other_ref.clone(),
333                            second_step_ref: ref_.clone(),
334                        });
335                        // Soft clash between two undeclared steps: the
336                        // pre-GH-#23 union rule's data-plane-first
337                        // precedence — whichever step's OWN `ref` equals
338                        // the contested name owns it. If neither (or
339                        // both, which cannot happen since refs are
340                        // unique) side's ref matches, the first-seen
341                        // owner is kept (deterministic tie-break).
342                        if ref_ == name && &other_ref != name {
343                            claims.insert(name.clone(), (ref_.clone(), false));
344                            naming.by_name.insert(name.clone(), canonical.clone());
345                        }
346                    }
347                }
348            }
349
350            naming.by_ref.insert(ref_.clone(), canonical.clone());
351            naming
352                .entries
353                .insert(canonical.clone(), StepNameEntry { canonical, aliases });
354        }
355
356        Ok((naming, warnings))
357    }
358}
359
360/// One distinct `Step.ref`'s claim ladder, as folded from every
361/// occurrence of that ref in the flow — the intermediate
362/// [`StepNaming::from_blueprint`] builds before arbitrating contests (see
363/// the [`StepNaming`] struct doc's "Strong and weak claims").
364struct StepClaims {
365    ref_: String,
366    canonical: String,
367    is_declared: bool,
368    /// `{Step.ref}` ∪ every depth-1 `out` top segment (`$.T`).
369    strong_aliases: BTreeSet<String>,
370    /// Top segments of nested `out`s (`$.T.x…`), minus anything this ref
371    /// already claims strongly.
372    weak_aliases: BTreeSet<String>,
373}
374
375impl StepClaims {
376    /// Every name this ref claims strongly, each yielded once
377    /// (`canonical` frequently IS the ref, i.e. already in
378    /// `strong_aliases`).
379    fn claimed_strong(&self) -> impl Iterator<Item = &String> {
380        self.strong_aliases.iter().chain(
381            std::iter::once(&self.canonical).filter(|c| !self.strong_aliases.contains(c.as_str())),
382        )
383    }
384}
385
386fn collision_reason(other_declared: bool, is_declared: bool) -> String {
387    match (other_declared, is_declared) {
388        (true, true) => "both sides declare projection_name".to_string(),
389        (true, false) => "the first step declares projection_name".to_string(),
390        (false, true) => "the second step declares projection_name".to_string(),
391        (false, false) => {
392            unreachable!("hard StepNamingError requires at least one declared side")
393        }
394    }
395}
396
397/// Walk the flow `Node` (same recursion shape as
398/// `blueprint::compiler::collect_refs` / `collect_step_meta_refs`) and
399/// collect every `Step`'s `(ref, out-alias)`.
400fn collect_steps(node: &Node, out: &mut Vec<(String, Option<(String, bool)>)>) {
401    match node {
402        Node::Step {
403            ref_,
404            out: out_expr,
405            ..
406        } => {
407            out.push((ref_.clone(), out_alias(out_expr)));
408        }
409        Node::Seq { children } => {
410            for child in children {
411                collect_steps(child, out);
412            }
413        }
414        Node::Branch { then_, else_, .. } => {
415            collect_steps(then_, out);
416            collect_steps(else_, out);
417        }
418        Node::Fanout { body, .. } => collect_steps(body, out),
419        Node::Loop { body, .. } => collect_steps(body, out),
420        Node::Try { body, catch, .. } => {
421            collect_steps(body, out);
422            collect_steps(catch, out);
423        }
424        Node::Assign { .. } => {} // The Assign node carries no ref.
425    }
426}
427
428/// Extract the top-level segment of a `Step.out` `Path` expr together
429/// with whether the path writes UNDER that segment rather than to it:
430/// `"$.plan"` → `("plan", false)` (a strong claim on `plan`), `"$.a.b"` →
431/// `("a", true)` (a weak claim on `a` — the step owns the `b` lane, not
432/// `a`). Any other `Expr` shape (or an empty path) contributes no alias —
433/// best-effort, mirroring `blueprint::compiler`'s existing static-walk
434/// convention of skipping what can't be inspected structurally (flow.ir's
435/// own `write_path` requires `Step.out` to be a `Path` expr at eval time
436/// regardless, so a non-`Path` `out` is already a runtime error there —
437/// this walk just never invents an alias for it statically).
438fn out_alias(expr: &Expr) -> Option<(String, bool)> {
439    let Expr::Path { at } = expr else {
440        return None;
441    };
442    let rendered = at.to_string();
443    let trimmed = rendered
444        .strip_prefix("$.")
445        .or_else(|| rendered.strip_prefix('$'))?;
446    let mut segments = trimmed.split('.').filter(|s| !s.is_empty());
447    let top = segments.next()?.to_string();
448    Some((top, segments.next().is_some()))
449}
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use crate::blueprint::{
455        current_schema_version, AgentDef, AgentKind, AgentMeta, BlueprintMetadata, CompilerHints,
456        CompilerStrategy,
457    };
458    use mlua_flow_ir::JoinMode;
459    use serde_json::json;
460
461    fn path(s: &str) -> Expr {
462        Expr::Path {
463            at: s.parse().expect("literal test path"),
464        }
465    }
466
467    fn step(ref_: &str, out: &str) -> Node {
468        Node::Step {
469            ref_: ref_.to_string(),
470            in_: path("$.in"),
471            out: path(out),
472        }
473    }
474
475    fn agent(name: &str, projection_name: Option<&str>) -> AgentDef {
476        AgentDef {
477            name: name.to_string(),
478            kind: AgentKind::RustFn,
479            spec: json!({ "fn_id": name }),
480            profile: None,
481            meta: Some(AgentMeta {
482                projection_name: projection_name.map(str::to_string),
483                ..Default::default()
484            }),
485            runner: None,
486            runner_ref: None,
487            verdict: None,
488        }
489    }
490
491    fn bp(flow: Node, agents: Vec<AgentDef>) -> Blueprint {
492        Blueprint {
493            schema_version: current_schema_version(),
494            id: "step-naming-ut".into(),
495            flow,
496            agents,
497            operators: vec![],
498            metas: vec![],
499            hints: CompilerHints::default(),
500            strategy: CompilerStrategy::default(),
501            metadata: BlueprintMetadata::default(),
502            spawner_hints: Default::default(),
503            default_agent_kind: AgentKind::Operator,
504            default_operator_kind: None,
505            default_init_ctx: None,
506            default_agent_ctx: None,
507            default_context_policy: None,
508            projection_placement: None,
509            audits: vec![],
510            degradation_policy: None,
511            runners: vec![],
512            default_runner: None,
513            subprocesses: vec![],
514            check_policy: None,
515            blueprint_ref_includes: Vec::new(),
516        }
517    }
518
519    #[test]
520    fn declared_step_canonical_is_projection_name_aliases_are_ref_and_out_top() {
521        let flow = step("planner", "$.plan");
522        let bp = bp(flow, vec![agent("planner", Some("plan-out"))]);
523        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
524        assert!(warnings.is_empty());
525        assert_eq!(naming.canonical_of_producer("planner"), Some("plan-out"));
526        let entry = naming
527            .entries()
528            .find(|e| e.canonical == "plan-out")
529            .expect("entry present");
530        assert_eq!(
531            entry.aliases,
532            BTreeSet::from(["planner".to_string(), "plan".to_string()])
533        );
534    }
535
536    #[test]
537    fn undeclared_step_canonical_is_ref_aliases_are_ref_and_out_top() {
538        let flow = step("worker", "$.result");
539        let bp = bp(flow, vec![agent("worker", None)]);
540        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
541        assert!(warnings.is_empty());
542        assert_eq!(naming.canonical_of_producer("worker"), Some("worker"));
543        let entry = naming
544            .entries()
545            .find(|e| e.canonical == "worker")
546            .expect("entry present");
547        assert_eq!(
548            entry.aliases,
549            BTreeSet::from(["worker".to_string(), "result".to_string()])
550        );
551    }
552
553    #[test]
554    fn ref_equal_to_out_top_collapses_to_single_alias_and_is_not_a_collision() {
555        let flow = step("scout", "$.scout");
556        let bp = bp(flow, vec![agent("scout", None)]);
557        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
558        assert!(warnings.is_empty());
559        let entry = naming
560            .entries()
561            .find(|e| e.canonical == "scout")
562            .expect("entry present");
563        assert_eq!(entry.aliases, BTreeSet::from(["scout".to_string()]));
564    }
565
566    #[test]
567    fn declared_name_colliding_with_another_steps_ref_is_a_hard_error() {
568        // Step "a" declares projection_name "b"; step "b" is undeclared
569        // (its own ref IS "b") — the two claim the same canonical name.
570        let flow = Node::Seq {
571            children: vec![step("a", "$.a_out"), step("b", "$.b_out")],
572        };
573        let bp = bp(flow, vec![agent("a", Some("b")), agent("b", None)]);
574        let err = StepNaming::from_blueprint(&bp).expect_err("declared collision must reject");
575        assert_eq!(err.name, "b");
576        assert!(
577            err.reason.contains("declare"),
578            "reason should explain which side declared: {}",
579            err.reason
580        );
581    }
582
583    #[test]
584    fn undeclared_collision_is_ok_with_a_warning_and_data_plane_priority() {
585        // Step "foo" (undeclared) has out "$.bar" — alias "bar".
586        // Step "bar" (undeclared) has its own ref "bar" — canonical "bar".
587        // Both claim the name "bar"; neither declares projection_name, so
588        // this is a soft warning, and the data-plane owner ("bar"'s own
589        // ref) must win `resolve("bar")`.
590        let flow = Node::Seq {
591            children: vec![step("foo", "$.bar"), step("bar", "$.baz")],
592        };
593        let bp = bp(flow, vec![agent("foo", None), agent("bar", None)]);
594        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("soft collision is Ok");
595        assert_eq!(warnings.len(), 1);
596        assert_eq!(warnings[0].name, "bar");
597        assert_eq!(naming.resolve("bar"), Some("bar"));
598    }
599
600    #[test]
601    fn walk_covers_seq_branch_fanout_loop_and_try_nesting() {
602        let flow = Node::Seq {
603            children: vec![
604                step("in-seq", "$.a"),
605                Node::Branch {
606                    cond: Expr::Lit { value: json!(true) },
607                    then_: Box::new(step("in-then", "$.b")),
608                    else_: Box::new(step("in-else", "$.c")),
609                },
610                Node::Fanout {
611                    items: path("$.items"),
612                    bind: path("$.item"),
613                    body: Box::new(step("in-fanout", "$.d")),
614                    join: JoinMode::All,
615                    out: path("$.results"),
616                },
617                Node::Loop {
618                    counter: path("$.n"),
619                    cond: Expr::Lit { value: json!(true) },
620                    body: Box::new(step("in-loop", "$.e")),
621                    max: 3,
622                },
623                Node::Try {
624                    body: Box::new(step("in-try", "$.f")),
625                    catch: Box::new(step("in-catch", "$.g")),
626                    err_at: None,
627                },
628                Node::Assign {
629                    at: path("$.h"),
630                    value: Expr::Lit { value: json!(1) },
631                },
632            ],
633        };
634        let agents = vec![
635            "in-seq",
636            "in-then",
637            "in-else",
638            "in-fanout",
639            "in-loop",
640            "in-try",
641            "in-catch",
642        ]
643        .into_iter()
644        .map(|n| agent(n, None))
645        .collect();
646        let bp = bp(flow, agents);
647        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
648        assert!(warnings.is_empty());
649        let mut names: Vec<&str> = naming.names().collect();
650        names.sort_unstable();
651        assert_eq!(
652            names,
653            vec![
654                "in-catch",
655                "in-else",
656                "in-fanout",
657                "in-loop",
658                "in-seq",
659                "in-then",
660                "in-try",
661            ]
662        );
663    }
664
665    #[test]
666    fn resolve_returns_canonical_for_alias_lookup() {
667        let flow = step("planner", "$.plan");
668        let bp = bp(flow, vec![agent("planner", Some("plan-out"))]);
669        let (naming, _) = StepNaming::from_blueprint(&bp).expect("no collision");
670        assert_eq!(naming.resolve("plan-out"), Some("plan-out"));
671        assert_eq!(naming.resolve("planner"), Some("plan-out"));
672        assert_eq!(naming.resolve("plan"), Some("plan-out"));
673        assert_eq!(naming.resolve("does-not-exist"), None);
674    }
675
676    /// The shape this claim ladder exists for: several lanes writing
677    /// under one shared nesting root (`$.r.a` / `$.r.b` / `$.r.c`). Every
678    /// claim on `"r"` is weak and contested, so `"r"` becomes nobody's
679    /// alias — no warning is emitted (this is ordinary Blueprint shape),
680    /// `resolve("r")` misses explicitly instead of handing out one
681    /// arbitrary lane, and each lane stays addressable by its own ref.
682    #[test]
683    fn shared_nesting_root_is_claimed_by_nobody_and_warns_nowhere() {
684        let flow = Node::Seq {
685            children: vec![
686                step("lane-a", "$.r.a"),
687                step("lane-b", "$.r.b"),
688                step("lane-c", "$.r.c"),
689            ],
690        };
691        let bp = bp(
692            flow,
693            vec![
694                agent("lane-a", None),
695                agent("lane-b", None),
696                agent("lane-c", None),
697            ],
698        );
699        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
700        assert!(
701            warnings.is_empty(),
702            "a shared nesting root is not an ambiguity: {warnings:?}"
703        );
704        assert_eq!(naming.resolve("r"), None);
705        for ref_ in ["lane-a", "lane-b", "lane-c"] {
706            let entry = naming
707                .entries()
708                .find(|e| e.canonical == ref_)
709                .expect("entry present");
710            assert_eq!(
711                entry.aliases,
712                BTreeSet::from([ref_.to_string()]),
713                "{ref_} must keep its own ref and drop the contested root"
714            );
715            assert_eq!(naming.resolve(ref_), Some(ref_));
716        }
717    }
718
719    /// The genuine ambiguity the weak-claim rule must NOT swallow: two
720    /// steps writing the identical depth-1 path both claim `"r"` strongly,
721    /// so the pre-existing soft warning + data-plane priority stands.
722    #[test]
723    fn two_steps_writing_the_identical_root_still_warn() {
724        let flow = Node::Seq {
725            children: vec![step("first", "$.r"), step("second", "$.r")],
726        };
727        let bp = bp(flow, vec![agent("first", None), agent("second", None)]);
728        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("soft collision is Ok");
729        assert_eq!(warnings.len(), 1, "warnings: {warnings:?}");
730        assert_eq!(warnings[0].name, "r");
731        // Neither step's own ref is "r", so the first-seen owner is kept.
732        assert_eq!(naming.resolve("r"), Some("first"));
733    }
734
735    /// A step whose ref IS the nesting root claims it strongly; the
736    /// nesting sibling's weak claim yields, so no warning fires and
737    /// `resolve` keeps pointing at the `ref: "r"` step.
738    #[test]
739    fn strong_ref_claim_beats_a_nesting_sibling_without_warning() {
740        let flow = Node::Seq {
741            children: vec![step("r", "$.r_out"), step("lane", "$.r.x")],
742        };
743        let bp = bp(flow, vec![agent("r", None), agent("lane", None)]);
744        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
745        assert!(warnings.is_empty(), "warnings: {warnings:?}");
746        assert_eq!(naming.resolve("r"), Some("r"));
747        let lane = naming
748            .entries()
749            .find(|e| e.canonical == "lane")
750            .expect("entry present");
751        assert_eq!(lane.aliases, BTreeSet::from(["lane".to_string()]));
752    }
753
754    /// The hard-error case the ladder dissolves: a DECLARED
755    /// `projection_name` against a nesting sibling's weak claim used to
756    /// reject the compile outright. The weak claim now yields instead.
757    #[test]
758    fn declared_name_against_a_nesting_sibling_compiles() {
759        let flow = Node::Seq {
760            children: vec![step("declarer", "$.declarer_out"), step("lane", "$.r.x")],
761        };
762        let bp = bp(
763            flow,
764            vec![agent("declarer", Some("r")), agent("lane", None)],
765        );
766        let (naming, warnings) =
767            StepNaming::from_blueprint(&bp).expect("a yielding weak claim must not hard-error");
768        assert!(warnings.is_empty(), "warnings: {warnings:?}");
769        assert_eq!(naming.resolve("r"), Some("r"));
770        assert_eq!(naming.canonical_of_producer("declarer"), Some("r"));
771    }
772
773    /// Backward compat: an uncontested nesting root is still that step's
774    /// alias — dropping weak claims is a contest rule, not a blanket
775    /// demotion of nested `out` paths.
776    #[test]
777    fn uncontested_nesting_root_stays_an_alias() {
778        let flow = step("only", "$.r.a");
779        let bp = bp(flow, vec![agent("only", None)]);
780        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
781        assert!(warnings.is_empty());
782        let entry = naming
783            .entries()
784            .find(|e| e.canonical == "only")
785            .expect("entry present");
786        assert_eq!(
787            entry.aliases,
788            BTreeSet::from(["only".to_string(), "r".to_string()])
789        );
790        assert_eq!(naming.resolve("r"), Some("only"));
791    }
792
793    #[test]
794    fn same_ref_dispatched_twice_unions_out_top_aliases_without_self_collision() {
795        let flow = Node::Seq {
796            children: vec![step("worker", "$.first"), step("worker", "$.second")],
797        };
798        let bp = bp(flow, vec![agent("worker", None)]);
799        let (naming, warnings) = StepNaming::from_blueprint(&bp).expect("no collision");
800        assert!(warnings.is_empty());
801        let entry = naming
802            .entries()
803            .find(|e| e.canonical == "worker")
804            .expect("entry present");
805        assert_eq!(
806            entry.aliases,
807            BTreeSet::from([
808                "worker".to_string(),
809                "first".to_string(),
810                "second".to_string()
811            ])
812        );
813    }
814}