Skip to main content

behavior_contracts/
behavior.rs

1//! behavior — component-graph IR + `run_behavior` unified execution IF.
2//!
3//! Ports the TS reference `ts/src/behavior.ts` (scp-ir-architecture.md §5-§7).
4//!
5//! A portable component-graph IR (`components[]{name, inputPorts, body[], output, plan}`)
6//! is executed on top of the existing COMMON primitives: `run_plan` (stage exec /
7//! Skip propagation / Policy Kind) + `evaluate` (Expression IR). The concrete
8//! per-component (leaf) implementation is resolved by name through a [`ComponentExec`]
9//! seam — the boundary-injected handler registry (IR + {effects,config,hooks}).
10//!
11//! Body node kinds:
12//!   - componentRef: `{id, component, ports, parent?, bindField?, relationKind?, policy?}`
13//!   - map:          `{id, map:{over, as, component, ports, when?, into?, batched?, parent?,
14//!                    relationKind?, policy?}}` (`when`/`into`/`batched` are behaviorVersion 2)
15//!   - cond:         `{id, cond:{if, then, else, parent?}}` (pure Expression; no handler)
16//!
17//! behaviorVersion 2 (bc#22):
18//!   - `map.when`    — per-element guard, lowered to `{cond:[when,true,false]}` (strict bool;
19//!     non-bool fails closed with TYPE_MISMATCH). Falsy elements are skipped (handler not
20//!     called, excluded from the result list; order preserved).
21//!   - `map.into`    — zip-attach: the node result becomes the `over` list with each
22//!     guard-passing element shallow-copied and augmented with `into: <handler result>`
23//!     (same length/order as `over`; skipped elements pass through unchanged). Parent
24//!     results are never mutated. Non-object kept elements fail closed
25//!     (MAP_INTO_ELEMENT_NOT_OBJECT).
26//!   - `map.batched` — evaluates the ports of all guard-passing elements first, then calls
27//!     the handler ONCE with `{items:[<ports>...]}`; the handler must return a list aligned
28//!     to items (violations fail closed with MAP_BATCH_RESULT_MISMATCH). Zero kept elements
29//!     means the handler is not called and the result is the empty list.
30//!   - handler ctx   — every handler call carries the node identity (nodeId + component)
31//!     via the [`ComponentExec::exec_ctx`] seam (additive; default forwards to `exec`).
32
33#[cfg(feature = "ir")]
34use crate::expr::evaluate as evaluate_expression;
35use crate::expr::ExprFailure;
36#[cfg(feature = "ir")]
37use crate::plan::{run_plan, ExecutionPlanSpec, OpSpec, RelationKind};
38use crate::plan::{ExecOutcome, PlanFailure};
39use crate::value::Value;
40#[cfg(feature = "ir")]
41use serde_json::Value as J;
42
43/// Stable behavior-execution failure codes (in addition to plan/expr codes).
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum BehaviorFailureCode {
46    UnknownComponent,
47    UnknownNodeKind,
48    MapOverNotArray,
49    MapIntoElementNotObject,
50    MapBatchResultMismatch,
51    FanoutOverNotArray,
52    FanoutBatchResultMismatch,
53    UnknownEntry,
54}
55
56impl BehaviorFailureCode {
57    pub fn as_str(self) -> &'static str {
58        match self {
59            BehaviorFailureCode::UnknownComponent => "UNKNOWN_COMPONENT",
60            BehaviorFailureCode::UnknownNodeKind => "UNKNOWN_NODE_KIND",
61            BehaviorFailureCode::MapOverNotArray => "MAP_OVER_NOT_ARRAY",
62            BehaviorFailureCode::MapIntoElementNotObject => "MAP_INTO_ELEMENT_NOT_OBJECT",
63            BehaviorFailureCode::MapBatchResultMismatch => "MAP_BATCH_RESULT_MISMATCH",
64            BehaviorFailureCode::FanoutOverNotArray => "FANOUT_OVER_NOT_ARRAY",
65            BehaviorFailureCode::FanoutBatchResultMismatch => "FANOUT_BATCH_RESULT_MISMATCH",
66            BehaviorFailureCode::UnknownEntry => "UNKNOWN_ENTRY",
67        }
68    }
69}
70
71/// A unified failure for `run_behavior`, carrying a stable string code so the
72/// conformance runner can compare against expression / plan / behavior codes alike.
73#[derive(Debug, Clone)]
74pub struct BehaviorError {
75    code: String,
76    pub message: String,
77    /// The structured, recoverable payload a failure carries (scp-error.md "The Error Value").
78    /// Transported verbatim from the leaf / the outType conformance check; `None` when the failure
79    /// is not about a datum. Boxed to keep the `Err` variant small.
80    pub detail: Option<Box<crate::plan::ErrorDetail>>,
81}
82
83impl BehaviorError {
84    /// Construct a coded behavior error. Public so the straight-line codegen surface
85    /// (bc#39) can raise the same fail-closed codes/messages as `run_behavior`
86    /// (`UNKNOWN_COMPONENT` / `UNKNOWN_ENTRY` / `UNKNOWN_NODE_KIND`) without depending
87    /// on the private `BehaviorFailureCode` enum.
88    pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
89        BehaviorError {
90            code: code.into(),
91            message: message.into(),
92            detail: None,
93        }
94    }
95
96    /// The stable string code used for conformance matching.
97    pub fn code(&self) -> &str {
98        &self.code
99    }
100
101    /// The structured payload, if this failure is about a datum.
102    pub fn detail(&self) -> Option<&crate::plan::ErrorDetail> {
103        self.detail.as_deref()
104    }
105}
106
107impl std::fmt::Display for BehaviorError {
108    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109        write!(f, "{}: {}", self.code, self.message)
110    }
111}
112impl std::error::Error for BehaviorError {}
113
114impl From<ExprFailure> for BehaviorError {
115    fn from(e: ExprFailure) -> Self {
116        BehaviorError {
117            code: e.code.as_str().to_string(),
118            message: e.message,
119            detail: e.detail,
120        }
121    }
122}
123impl From<PlanFailure> for BehaviorError {
124    fn from(e: PlanFailure) -> Self {
125        BehaviorError {
126            code: e.code.as_str().to_string(),
127            message: e.message,
128            detail: e.detail,
129        }
130    }
131}
132
133#[cfg(feature = "ir")]
134fn bfail<T>(code: BehaviorFailureCode, message: impl Into<String>) -> Result<T, BehaviorError> {
135    Err(BehaviorError {
136        code: code.as_str().to_string(),
137        message: message.into(),
138        detail: None,
139    })
140}
141
142// ── outType conformance (scp-error.md "The outType conformance check") ──────────────────────
143/// The wire type name of an observed value (ErrorDetail.actual_wire_type vocabulary).
144#[cfg(feature = "ir")]
145fn wire_type_name(v: &Value) -> &'static str {
146    v.type_name()
147}
148
149/// The offending value, stringified (never re-parsed to recover a type).
150#[cfg(feature = "ir")]
151fn raw_value_of(v: &Value) -> String {
152    match v {
153        Value::Str(s) => s.clone(),
154        Value::Int(i) => i.to_string(),
155        Value::Float(f) => crate::canonical::py_float_repr(*f).unwrap_or_else(|_| f.to_string()),
156        Value::Bool(b) => b.to_string(),
157        Value::Null => "null".to_string(),
158        _ => crate::canonical::canonical_json(v).unwrap_or_else(|_| v.type_name().to_string()),
159    }
160}
161
162/// Portable Type Notation of a declared type node (scp-error.md). The `outType` arrives as IR JSON.
163#[cfg(feature = "ir")]
164fn portable_type_notation(t: &J) -> String {
165    if let Some(s) = t.as_str() {
166        return s.to_string();
167    }
168    for key in ["opt", "arr", "map"] {
169        if let Some(inner) = t.get(key) {
170            return format!("{key}({})", portable_type_notation(inner));
171        }
172    }
173    if let Some(J::Object(o)) = t.get("obj") {
174        let parts: Vec<String> = o
175            .iter()
176            .map(|(k, v)| format!("{k}:{}", portable_type_notation(v)))
177            .collect();
178        return format!("obj{{{}}}", parts.join(","));
179    }
180    "?".to_string()
181}
182
183/// The node's declared result type: a map node's `outType` is its ELEMENT type, so its result is an
184/// array of that element (the same reading the compiler applies). Other node kinds: the type itself.
185#[cfg(feature = "ir")]
186fn result_type_of(node: &J, out_type: &J) -> J {
187    if node.get("map").is_some() {
188        serde_json::json!({ "arr": out_type })
189    } else {
190        out_type.clone()
191    }
192}
193
194#[cfg(feature = "ir")]
195fn conform_fail(
196    code: crate::expr::ExprFailureCode,
197    message: String,
198    kind: crate::plan::ErrorKind,
199    node_id: &str,
200    field: &str,
201    expected: &J,
202    actual: Option<&Value>,
203) -> ExprFailure {
204    let mut detail = crate::plan::ErrorDetail {
205        kind: Some(kind),
206        model: Some(node_id.to_string()),
207        field: Some(field.to_string()),
208        expected_type: Some(portable_type_notation(expected)),
209        ..Default::default()
210    };
211    if let Some(a) = actual {
212        detail.actual_wire_type = Some(wire_type_name(a).to_string());
213        detail.raw_value = Some(raw_value_of(a));
214    }
215    ExprFailure {
216        code,
217        message,
218        detail: Some(Box::new(detail)),
219    }
220}
221
222/// Set a key on an insertion-ordered object pair list: replace in place if present, else append.
223#[cfg(feature = "ir")]
224fn obj_set(pairs: &mut Vec<(String, Value)>, key: &str, val: Value) {
225    if let Some(slot) = pairs.iter_mut().find(|(k, _)| k == key) {
226        slot.1 = val;
227    } else {
228        pairs.push((key.to_string(), val));
229    }
230}
231
232/// Check a value against a declared `outType` (Portable Type Notation as IR JSON), failing closed with
233/// TYPE_MISMATCH / MISSING_PROP carrying the structured Error Value, and return the value NORMALIZED to
234/// the declared type. Semantics match the emitters' de-box so generated code and the runtime agree: a
235/// `float` slot given an int WIDENS the value to `Value::Float`; an absent declared `opt` field
236/// materializes as `Value::Null` (de-box's absent→None); a required field's absence is MISSING_PROP;
237/// undeclared extra keys pass through.
238#[cfg(feature = "ir")]
239fn assert_conforms_to_out_type(
240    node_id: &str,
241    v: &Value,
242    t: &J,
243    field: &str,
244) -> Result<Value, ExprFailure> {
245    use crate::expr::ExprFailureCode::{MissingProp, TypeMismatch};
246    use crate::plan::ErrorKind;
247    if let Some(scalar) = t.as_str() {
248        if scalar == "float" {
249            if let Value::Int(i) = v {
250                return Ok(Value::Float(*i as f64)); // int → float widen (de-box: ParseFloat と同一)
251            }
252        }
253        let ok = match scalar {
254            "string" => matches!(v, Value::Str(_)),
255            "int" => matches!(v, Value::Int(_)),
256            "float" => matches!(v, Value::Float(_)),
257            "bool" => matches!(v, Value::Bool(_)),
258            "null" => matches!(v, Value::Null),
259            _ => true,
260        };
261        if !ok {
262            return Err(conform_fail(
263                TypeMismatch,
264                format!(
265                    "node '{node_id}': {field}: expected {scalar}, got {}",
266                    wire_type_name(v)
267                ),
268                ErrorKind::TypeMismatch,
269                node_id,
270                field,
271                t,
272                Some(v),
273            ));
274        }
275        return Ok(v.clone());
276    }
277    if let Some(inner) = t.get("opt") {
278        if matches!(v, Value::Null) {
279            return Ok(Value::Null);
280        }
281        return assert_conforms_to_out_type(node_id, v, inner, field);
282    }
283    if let Some(inner) = t.get("arr") {
284        match v {
285            Value::Arr(items) => {
286                let mut out = Vec::with_capacity(items.len());
287                for (i, el) in items.iter().enumerate() {
288                    out.push(assert_conforms_to_out_type(
289                        node_id,
290                        el,
291                        inner,
292                        &format!("{field}[{i}]"),
293                    )?);
294                }
295                return Ok(Value::Arr(out));
296            }
297            _ => {
298                return Err(conform_fail(
299                    TypeMismatch,
300                    format!(
301                        "node '{node_id}': {field}: expected arr, got {}",
302                        wire_type_name(v)
303                    ),
304                    ErrorKind::TypeMismatch,
305                    node_id,
306                    field,
307                    t,
308                    Some(v),
309                ))
310            }
311        }
312    }
313    if let Some(inner) = t.get("map") {
314        match v {
315            Value::Obj(pairs) => {
316                let mut out = Vec::with_capacity(pairs.len());
317                for (k, mv) in pairs {
318                    out.push((
319                        k.clone(),
320                        assert_conforms_to_out_type(node_id, mv, inner, &format!("{field}.{k}"))?,
321                    ));
322                }
323                return Ok(Value::Obj(out));
324            }
325            _ => {
326                return Err(conform_fail(
327                    TypeMismatch,
328                    format!(
329                        "node '{node_id}': {field}: expected map, got {}",
330                        wire_type_name(v)
331                    ),
332                    ErrorKind::TypeMismatch,
333                    node_id,
334                    field,
335                    t,
336                    Some(v),
337                ))
338            }
339        }
340    }
341    // obj: check each declared field (absent opt → Null, absent required → MISSING_PROP); keep extras.
342    if let Some(J::Object(fields)) = t.get("obj") {
343        let mut out: Vec<(String, Value)> = match v {
344            Value::Obj(pairs) => pairs.clone(),
345            _ => {
346                return Err(conform_fail(
347                    TypeMismatch,
348                    format!(
349                        "node '{node_id}': {field}: expected obj, got {}",
350                        wire_type_name(v)
351                    ),
352                    ErrorKind::TypeMismatch,
353                    node_id,
354                    field,
355                    t,
356                    Some(v),
357                ));
358            }
359        };
360        for (k, ft) in fields {
361            match v.obj_get(k) {
362                None => {
363                    if ft.get("opt").is_some() {
364                        obj_set(&mut out, k, Value::Null); // absent opt → null (de-box: absent→None)
365                    } else {
366                        return Err(conform_fail(
367                            MissingProp,
368                            format!("node '{node_id}': {field}: missing property .{k}"),
369                            ErrorKind::MissingField,
370                            node_id,
371                            &format!("{field}.{k}"),
372                            ft,
373                            None,
374                        ));
375                    }
376                }
377                Some(fv) => {
378                    let nv = assert_conforms_to_out_type(node_id, fv, ft, &format!("{field}.{k}"))?;
379                    obj_set(&mut out, k, nv);
380                }
381            }
382        }
383        return Ok(Value::Obj(out));
384    }
385    Ok(v.clone())
386}
387
388/// The boundary-injected handler registry seam (§7.1). Resolves a catalog name to
389/// a leaf implementation and executes it with evaluated ports (+ the map element
390/// `bound` value, if any). Returns `None` when the name is unknown so `run_behavior`
391/// can fail closed with `UNKNOWN_COMPONENT`.
392pub trait ComponentExec {
393    fn exec(
394        &mut self,
395        component: &str,
396        ports: &[(String, Value)],
397        bound: Option<&Value>,
398    ) -> Option<ExecOutcome>;
399
400    /// behaviorVersion 2: like [`ComponentExec::exec`] but carrying the handler ctx
401    /// (`node_id` = body node id, `component` = catalog name). `run_behavior` calls
402    /// this seam for every handler invocation. The default forwards to `exec`, so
403    /// existing implementations keep working unchanged; override it to observe the
404    /// node identity (error context / tracing).
405    fn exec_ctx(
406        &mut self,
407        node_id: &str,
408        component: &str,
409        ports: &[(String, Value)],
410        bound: Option<&Value>,
411    ) -> Option<ExecOutcome> {
412        let _ = node_id;
413        self.exec(component, ports, bound)
414    }
415}
416
417/// Blanket forwarding impl so a **trait object** handler registry
418/// (`&mut dyn ComponentExec`) itself satisfies [`ComponentExec`] (bc#68).
419///
420/// The generic straight-line / typed codegen surface exposes generic-by-value
421/// entries (`bind<H: ComponentExec>(handlers: H)` / `run_*<H: ComponentExec>(
422/// handlers: &mut H, …)`). A consumer that holds its handler registry behind a
423/// trait object (`handlers: &mut dyn ComponentExec` — exactly how [`run_behavior`]
424/// takes it) could not name a concrete `H` to hand those entries. This impl makes
425/// `H = &mut dyn ComponentExec` a valid instantiation, so the trait-object handler
426/// drives the generated straight-line/typed module through the *unchanged* generic
427/// path (no `bind_dyn` variant needed, no emitter change). It is purely additive:
428/// every existing concrete `H` keeps working exactly as before.
429///
430/// Both methods forward to the underlying `dyn` (a plain reborrow), so calling a
431/// generated module through `&mut dyn` runs the SAME straight-line/typed code as a
432/// concrete handler — it never falls back to `run_behavior`.
433impl ComponentExec for &mut dyn ComponentExec {
434    fn exec(
435        &mut self,
436        component: &str,
437        ports: &[(String, Value)],
438        bound: Option<&Value>,
439    ) -> Option<ExecOutcome> {
440        (**self).exec(component, ports, bound)
441    }
442
443    fn exec_ctx(
444        &mut self,
445        node_id: &str,
446        component: &str,
447        ports: &[(String, Value)],
448        bound: Option<&Value>,
449    ) -> Option<ExecOutcome> {
450        (**self).exec_ctx(node_id, component, ports, bound)
451    }
452}
453
454#[cfg(feature = "ir")]
455fn scope_get<'a>(scope: &'a [(String, Value)], key: &str) -> Option<&'a Value> {
456    scope.iter().find(|(k, _)| k == key).map(|(_, v)| v)
457}
458
459/// fanout_dedup_drop — THE ONE dedup/drop definition (behaviorVersion 3) — Rust twin of behavior.ts
460/// fanoutDedupDrop. Applies first-seen dedupe (by the body's `dedupe_key` field) + dangling drop
461/// (Null / non-object / absent-key body when `drop=="dangling"`) + implicitSource strip to the aligned
462/// raw list; returns the connection items (the caller wraps into {items,cursor:Null}). Byte-equal to
463/// the TS/Python/PHP/Go definitions. Bodies are `Value::Obj`; a Null / non-Obj body is dangling.
464#[cfg(feature = "ir")]
465fn fanout_dedup_drop(
466    aligned_bodies: Vec<Value>,
467    dedupe_key: &str,
468    drop: &str,
469    implicit_source: Option<&str>,
470) -> Vec<Value> {
471    use std::collections::HashSet;
472    let mut items: Vec<Value> = Vec::with_capacity(aligned_bodies.len());
473    let mut seen: HashSet<String> = HashSet::new();
474    for body in aligned_bodies.into_iter() {
475        let fields = match &body {
476            Value::Obj(f) => Some(f),
477            _ => None,
478        };
479        let key_val = fields.and_then(|f| f.iter().find(|(k, _)| k == dedupe_key).map(|(_, v)| v));
480        let has_key = matches!(key_val, Some(v) if !matches!(v, Value::Null));
481        if !has_key {
482            if drop == "dangling" {
483                continue;
484            }
485            items.push(body); // drop:none keeps a dangling body
486            continue;
487        }
488        let seen_key = match key_val.unwrap() {
489            Value::Str(s) => format!("s:{s}"),
490            other => format!("j:{other:?}"),
491        };
492        if seen.contains(&seen_key) {
493            continue;
494        }
495        seen.insert(seen_key);
496        match (implicit_source, fields) {
497            (Some(src), Some(f)) if f.iter().any(|(k, _)| k == src) => {
498                let stripped: Vec<(String, Value)> =
499                    f.iter().filter(|(k, _)| k != src).cloned().collect();
500                items.push(Value::Obj(stripped));
501            }
502            _ => items.push(body),
503        }
504    }
505    items
506}
507
508#[cfg(feature = "ir")]
509fn node_kind(n: &J) -> Result<&'static str, BehaviorError> {
510    if n.get("fanout").is_some() {
511        Ok("fanout")
512    } else if n.get("map").is_some() {
513        Ok("map")
514    } else if n.get("cond").is_some() {
515        Ok("cond")
516    } else if n.get("component").is_some() {
517        Ok("componentRef")
518    } else {
519        bfail(
520            BehaviorFailureCode::UnknownNodeKind,
521            format!(
522                "body node '{}' is not componentRef/map/cond/fanout",
523                n.get("id").and_then(|v| v.as_str()).unwrap_or("?")
524            ),
525        )
526    }
527}
528
529#[cfg(feature = "ir")]
530fn node_sub(n: &J) -> &J {
531    if let Some(f) = n.get("fanout") {
532        f
533    } else if let Some(m) = n.get("map") {
534        m
535    } else if let Some(c) = n.get("cond") {
536        c
537    } else {
538        n
539    }
540}
541
542#[cfg(feature = "ir")]
543fn node_parent(n: &J) -> Option<&str> {
544    node_sub(n).get("parent").and_then(|v| v.as_str())
545}
546
547#[cfg(feature = "ir")]
548fn node_bind_field(n: &J) -> Option<String> {
549    if n.get("map").is_some() || n.get("cond").is_some() || n.get("fanout").is_some() {
550        return None;
551    }
552    n.get("bindField")
553        .and_then(|v| v.as_str())
554        .map(str::to_string)
555}
556
557#[cfg(feature = "ir")]
558fn node_relation_kind(n: &J) -> Option<RelationKind> {
559    if n.get("cond").is_some() {
560        return None;
561    }
562    let sub = if n.get("map").is_some() || n.get("fanout").is_some() {
563        node_sub(n)
564    } else {
565        n
566    };
567    match sub.get("relationKind").and_then(|v| v.as_str()) {
568        Some("connection") => Some(RelationKind::Connection),
569        Some(_) => Some(RelationKind::Single),
570        None => None,
571    }
572}
573
574#[cfg(feature = "ir")]
575fn node_relation_kind_str(n: &J) -> Option<&str> {
576    if n.get("cond").is_some() {
577        return None;
578    }
579    let sub = if n.get("map").is_some() || n.get("fanout").is_some() {
580        node_sub(n)
581    } else {
582        n
583    };
584    sub.get("relationKind").and_then(|v| v.as_str())
585}
586
587#[cfg(feature = "ir")]
588fn node_policy(n: &J) -> Option<String> {
589    if n.get("cond").is_some() {
590        return None;
591    }
592    let sub = if n.get("map").is_some() || n.get("fanout").is_some() {
593        node_sub(n)
594    } else {
595        n
596    };
597    sub.get("policy")
598        .and_then(|v| v.as_str())
599        .map(str::to_string)
600}
601
602#[cfg(feature = "ir")]
603fn eval_ports(ports: &J, scope: &[(String, Value)]) -> Result<Vec<(String, Value)>, BehaviorError> {
604    let obj = ports.as_object().ok_or_else(|| BehaviorError {
605        code: BehaviorFailureCode::UnknownNodeKind.as_str().to_string(),
606        message: "ports must be an object".into(),
607        detail: None,
608    })?;
609    let mut out = Vec::with_capacity(obj.len());
610    for (k, v) in obj {
611        out.push((k.clone(), evaluate_expression(v, scope)?));
612    }
613    Ok(out)
614}
615
616/// Parse an `ExecutionPlanSpec` from a component's `plan` field (`null`/absent → None).
617#[cfg(feature = "ir")]
618fn parse_plan(plan: Option<&J>) -> Option<ExecutionPlanSpec> {
619    let p = plan?;
620    if p.is_null() {
621        return None;
622    }
623    let groups = p
624        .get("groups")?
625        .as_array()?
626        .iter()
627        .map(|g| {
628            g.as_array()
629                .map(|row| {
630                    row.iter()
631                        .filter_map(|i| i.as_u64().map(|x| x as usize))
632                        .collect()
633                })
634                .unwrap_or_default()
635        })
636        .collect();
637    let concurrency = p.get("concurrency").and_then(|c| c.as_i64()).unwrap_or(1);
638    Some(ExecutionPlanSpec {
639        groups,
640        concurrency,
641    })
642}
643
644/// Run a component-graph IR (scp-ir-architecture.md §7).
645///
646/// * `ir`      — the portable component-graph IR (`{components:[...]}`).
647/// * `handlers`— the boundary-injected [`ComponentExec`] seam (catalog name → leaf impl).
648/// * `input`   — the entry component's inputPorts bindings (param values).
649/// * `entry`   — the component name to run (`None` = first component).
650///
651/// Returns the evaluated `output` (Φ merge) or a coded [`BehaviorError`].
652#[cfg(feature = "ir")]
653pub fn run_behavior(
654    ir: &J,
655    handlers: &mut dyn ComponentExec,
656    input: &[(String, Value)],
657    entry: Option<&str>,
658) -> Result<Value, BehaviorError> {
659    let components = ir
660        .get("components")
661        .and_then(|c| c.as_array())
662        .ok_or_else(|| BehaviorError {
663            code: BehaviorFailureCode::UnknownEntry.as_str().to_string(),
664            message: "IR.components must be an array".into(),
665            detail: None,
666        })?;
667    let comp = match entry {
668        Some(name) => components
669            .iter()
670            .find(|c| c.get("name").and_then(|n| n.as_str()) == Some(name)),
671        None => components.first(),
672    };
673    let comp = match comp {
674        Some(c) => c,
675        None => {
676            return bfail(
677                BehaviorFailureCode::UnknownEntry,
678                format!("component '{}' not found in IR", entry.unwrap_or("<first>")),
679            )
680        }
681    };
682
683    let body: Vec<J> = comp
684        .get("body")
685        .and_then(|b| b.as_array())
686        .cloned()
687        .unwrap_or_default();
688
689    let index_of = |id: &str| {
690        body.iter()
691            .position(|n| n.get("id").and_then(|v| v.as_str()) == Some(id))
692    };
693
694    // Build OpSpecs for run_plan staging (parent id → index via Wire).
695    let mut ops: Vec<OpSpec> = Vec::with_capacity(body.len());
696    for n in &body {
697        let parent = node_parent(n).and_then(index_of);
698        ops.push(OpSpec {
699            id: n
700                .get("id")
701                .and_then(|v| v.as_str())
702                .unwrap_or_default()
703                .to_string(),
704            parent,
705            bind_field: node_bind_field(n),
706            relation_kind: node_relation_kind(n),
707            policy: node_policy(n),
708        });
709    }
710
711    run_behavior_inner(
712        &body,
713        &ops,
714        parse_plan(comp.get("plan")),
715        comp.get("output").cloned().unwrap_or(J::Null),
716        &bind_input_scope(comp, input),
717        handlers,
718    )
719}
720
721/// bind_input_scope — the entry component's input bindings, read against its declaration
722/// (`inputPorts`).
723///
724/// An input port declared `{opt:T}` (PortSchema `required:false`) is OMITTABLE and its value domain is
725/// `T | null`. "Key omitted" and "key present as null" are two spellings of the same "no value", so an
726/// omitted key binds to null. This only reads a declaration the portable IR already carries — no new
727/// information, no new operator.
728///
729/// Required ports and undeclared names are NOT bound: referencing one unbound is still
730/// `UNKNOWN_BINDING` (the fail-closed for a typo / an unwired param stands — only a port DECLARED
731/// omittable is relaxed).
732#[cfg(feature = "ir")]
733fn bind_input_scope(comp: &J, input: &[(String, Value)]) -> Vec<(String, Value)> {
734    let mut scope: Vec<(String, Value)> = input.to_vec();
735    if let Some(ports) = comp.get("inputPorts").and_then(|p| p.as_object()) {
736        for (name, schema) in ports {
737            let optional = schema.get("required").and_then(|r| r.as_bool()) == Some(false);
738            if optional && !scope.iter().any(|(k, _)| k == name) {
739                scope.push((name.clone(), Value::Null));
740            }
741        }
742    }
743    scope
744}
745
746/// Single-closure implementation: builds the exec closure that owns `handlers`,
747/// `results`, and `pending_err`, delegates staging to `run_plan`, then evaluates
748/// the component `output`. Kept separate to sidestep split-borrow layering.
749#[cfg(feature = "ir")]
750fn run_behavior_inner(
751    body: &[J],
752    ops: &[OpSpec],
753    plan: Option<ExecutionPlanSpec>,
754    output: J,
755    input: &[(String, Value)],
756    handlers: &mut dyn ComponentExec,
757) -> Result<Value, BehaviorError> {
758    let index_of = |id: &str| {
759        body.iter()
760            .position(|n| n.get("id").and_then(|v| v.as_str()) == Some(id))
761    };
762
763    let mut results: Vec<(String, Value)> = Vec::new();
764    let mut pending_err: Option<BehaviorError> = None;
765
766    let run = {
767        let results_cell = &mut results;
768        let err_cell = &mut pending_err;
769        let exec = |op: &OpSpec, _bound: Option<&Value>| -> ExecOutcome {
770            if err_cell.is_some() {
771                return ExecOutcome::error("aborted");
772            }
773            let idx = match index_of(&op.id) {
774                Some(i) => i,
775                None => {
776                    *err_cell = Some(BehaviorError {
777                        code: BehaviorFailureCode::UnknownNodeKind.as_str().to_string(),
778                        message: format!("no body node for op '{}'", op.id),
779                        detail: None,
780                    });
781                    return ExecOutcome::error("aborted");
782                }
783            };
784            let node = &body[idx];
785
786            let base_scope = |extra: Option<(&str, &Value)>| -> Vec<(String, Value)> {
787                let mut s: Vec<(String, Value)> = input.to_vec();
788                for (k, v) in results_cell.iter() {
789                    s.push((k.clone(), v.clone()));
790                }
791                if let Some((k, v)) = extra {
792                    s.push((k.to_string(), v.clone()));
793                }
794                s
795            };
796
797            let outcome = match node_kind(node) {
798                Err(e) => {
799                    *err_cell = Some(e);
800                    return ExecOutcome::error("aborted");
801                }
802                Ok("cond") => {
803                    let c = node.get("cond").unwrap();
804                    let cond_expr = serde_json::json!({
805                        "cond": [c.get("if"), c.get("then"), c.get("else")]
806                    });
807                    match evaluate_expression(&cond_expr, &base_scope(None)) {
808                        Ok(v) => ExecOutcome::Ok(v),
809                        Err(e) => {
810                            *err_cell = Some(e.into());
811                            return ExecOutcome::error("aborted");
812                        }
813                    }
814                }
815                Ok("map") => {
816                    let m = node.get("map").unwrap();
817                    let over = match evaluate_expression(
818                        m.get("over").unwrap_or(&J::Null),
819                        &base_scope(None),
820                    ) {
821                        Ok(v) => v,
822                        Err(e) => {
823                            *err_cell = Some(e.into());
824                            return ExecOutcome::error("aborted");
825                        }
826                    };
827                    let arr = match &over {
828                        Value::Arr(a) => a.clone(),
829                        _ => {
830                            *err_cell = Some(BehaviorError {
831                                code: BehaviorFailureCode::MapOverNotArray.as_str().to_string(),
832                                message: format!("map '{}': 'over' is not an array", op.id),
833                                detail: None,
834                            });
835                            return ExecOutcome::error("aborted");
836                        }
837                    };
838                    let component = m.get("component").and_then(|v| v.as_str()).unwrap_or("");
839                    let as_name = m.get("as").and_then(|v| v.as_str()).unwrap_or("$");
840                    let ports_j = m
841                        .get("ports")
842                        .cloned()
843                        .unwrap_or(J::Object(Default::default()));
844                    let when = m.get("when");
845                    let batched = m.get("batched").and_then(|v| v.as_bool()).unwrap_or(false);
846                    // Element Error Policy Kind (scp-error.md). Default `error`; an unknown kind
847                    // fails closed. `skip` needs a per-element Failure, so a batched map (one outcome
848                    // for the whole batch) rejects it.
849                    let element_policy = {
850                        let raw = m
851                            .get("elementPolicy")
852                            .and_then(|v| v.as_str())
853                            .unwrap_or("error");
854                        match crate::plan::ElementPolicyKind::parse(raw) {
855                            None => {
856                                *err_cell = Some(BehaviorError {
857                                    code: "UNKNOWN_ELEMENT_POLICY".to_string(),
858                                    message: format!(
859                                        "map '{}': unknown element policy '{raw}' (fail-closed)",
860                                        op.id
861                                    ),
862                                    detail: None,
863                                });
864                                return ExecOutcome::error("aborted");
865                            }
866                            Some(crate::plan::ElementPolicyKind::Skip) if batched => {
867                                *err_cell = Some(BehaviorError {
868                                    code: "ELEMENT_POLICY_NOT_APPLICABLE".to_string(),
869                                    message: format!("map '{}': elementPolicy 'skip' needs a per-element Failure, but a batched map takes ONE outcome for the whole batch (fail-closed)", op.id),
870                                    detail: None,
871                                });
872                                return ExecOutcome::error("aborted");
873                            }
874                            Some(p) => p,
875                        }
876                    };
877                    let into = m.get("into").and_then(|v| v.as_str());
878
879                    // per-element guard (v2): lowered to `{cond:[when,true,false]}`
880                    // (strict bool — a non-bool guard fails closed via the cond operator).
881                    let keep = |scope: &[(String, Value)]| -> Result<bool, BehaviorError> {
882                        match when {
883                            None => Ok(true),
884                            Some(w) => {
885                                let cond_expr = serde_json::json!({ "cond": [w, true, false] });
886                                Ok(matches!(
887                                    evaluate_expression(&cond_expr, scope)?,
888                                    Value::Bool(true)
889                                ))
890                            }
891                        }
892                    };
893
894                    let mut kept_idx: Vec<usize> = Vec::new(); // guard-passing over indices (for into)
895                    let collected: Vec<Value>;
896                    if batched {
897                        // batched (v2): evaluate all guard-passing ports first, call handler ONCE.
898                        let mut items: Vec<Value> = Vec::new();
899                        for (i, el) in arr.iter().enumerate() {
900                            let scope = base_scope(Some((as_name, el)));
901                            match keep(&scope) {
902                                Ok(false) => continue,
903                                Ok(true) => {}
904                                Err(e) => {
905                                    *err_cell = Some(e);
906                                    return ExecOutcome::error("aborted");
907                                }
908                            }
909                            match eval_ports(&ports_j, &scope) {
910                                Ok(p) => items.push(Value::Obj(p)),
911                                Err(e) => {
912                                    *err_cell = Some(e);
913                                    return ExecOutcome::error("aborted");
914                                }
915                            }
916                            kept_idx.push(i);
917                        }
918                        if items.is_empty() {
919                            collected = Vec::new(); // all filtered: handler is not called
920                        } else {
921                            let want = items.len();
922                            let batch_ports = vec![("items".to_string(), Value::Arr(items))];
923                            match handlers.exec_ctx(&op.id, component, &batch_ports, None) {
924                                None => {
925                                    *err_cell = Some(BehaviorError {
926                                        code: BehaviorFailureCode::UnknownComponent
927                                            .as_str()
928                                            .to_string(),
929                                        message: format!(
930                                            "component '{component}' has no handler (fail-closed)"
931                                        ),
932                                        detail: None,
933                                    });
934                                    return ExecOutcome::error("aborted");
935                                }
936                                Some(ExecOutcome::Error(e, d)) => return ExecOutcome::Error(e, d),
937                                Some(ExecOutcome::Ok(Value::Arr(r))) if r.len() == want => {
938                                    collected = r;
939                                }
940                                Some(ExecOutcome::Ok(_)) => {
941                                    *err_cell = Some(BehaviorError {
942                                        code: BehaviorFailureCode::MapBatchResultMismatch
943                                            .as_str()
944                                            .to_string(),
945                                        message: format!(
946                                            "map '{}': batched handler must return a list aligned to items (want {want})",
947                                            op.id
948                                        ),
949                        detail: None,
950                    });
951                                    return ExecOutcome::error("aborted");
952                                }
953                            }
954                        }
955                    } else {
956                        let mut out: Vec<Value> = Vec::with_capacity(arr.len());
957                        for (i, el) in arr.iter().enumerate() {
958                            let scope = base_scope(Some((as_name, el)));
959                            match keep(&scope) {
960                                Ok(false) => continue,
961                                Ok(true) => {}
962                                Err(e) => {
963                                    *err_cell = Some(e);
964                                    return ExecOutcome::error("aborted");
965                                }
966                            }
967                            let ports = match eval_ports(&ports_j, &scope) {
968                                Ok(p) => p,
969                                Err(e) => {
970                                    *err_cell = Some(e);
971                                    return ExecOutcome::error("aborted");
972                                }
973                            };
974                            match handlers.exec_ctx(&op.id, component, &ports, Some(el)) {
975                                None => {
976                                    *err_cell = Some(BehaviorError {
977                                        code: BehaviorFailureCode::UnknownComponent
978                                            .as_str()
979                                            .to_string(),
980                                        message: format!(
981                                            "component '{component}' has no handler (fail-closed)"
982                                        ),
983                                        detail: None,
984                                    });
985                                    return ExecOutcome::error("aborted");
986                                }
987                                // Element Error Policy (scp-error.md): `skip` drops the failing
988                                // element and proceeds (order preserved; the leaf keeps its Error
989                                // Value); `error` promotes it to the map's Component Failure.
990                                Some(ExecOutcome::Error(e, d)) => {
991                                    if element_policy == crate::plan::ElementPolicyKind::Skip {
992                                        continue;
993                                    }
994                                    return ExecOutcome::Error(e, d);
995                                }
996                                Some(ExecOutcome::Ok(v)) => out.push(v),
997                            }
998                            kept_idx.push(i);
999                        }
1000                        collected = out;
1001                    }
1002
1003                    match into {
1004                        None => ExecOutcome::Ok(Value::Arr(collected)),
1005                        Some(key) => {
1006                            // into (v2): result = over list with kept elements shallow-copied
1007                            // + `into` key attached; skipped elements pass through unchanged.
1008                            let mut augmented: Vec<Value> = Vec::with_capacity(arr.len());
1009                            let mut k = 0usize;
1010                            for (i, el) in arr.iter().enumerate() {
1011                                if k < kept_idx.len() && kept_idx[k] == i {
1012                                    let fields = match el {
1013                                        Value::Obj(f) => f.clone(),
1014                                        _ => {
1015                                            *err_cell = Some(BehaviorError {
1016                                                code: BehaviorFailureCode::MapIntoElementNotObject
1017                                                    .as_str()
1018                                                    .to_string(),
1019                                                message: format!(
1020                                                    "map '{}': 'into' requires object elements (element {i} is not an object)",
1021                                                    op.id
1022                                                ),
1023                        detail: None,
1024                    });
1025                                            return ExecOutcome::error("aborted");
1026                                        }
1027                                    };
1028                                    let mut fields = fields;
1029                                    let val = collected[k].clone();
1030                                    match fields.iter_mut().find(|(fk, _)| fk == key) {
1031                                        Some(slot) => slot.1 = val,
1032                                        None => fields.push((key.to_string(), val)),
1033                                    }
1034                                    augmented.push(Value::Obj(fields));
1035                                    k += 1;
1036                                } else {
1037                                    augmented.push(el.clone());
1038                                }
1039                            }
1040                            ExecOutcome::Ok(Value::Arr(augmented))
1041                        }
1042                    }
1043                }
1044                Ok("fanout") => {
1045                    // fanout (v3): over (id-list) → deduped ONE batched handler → dedupe/drop/strip →
1046                    // connection {items, cursor:null}. The MAP_BATCH_RESULT_MISMATCH alignment does NOT
1047                    // apply (the result is a connection, not an aligned list).
1048                    let f = node.get("fanout").unwrap();
1049                    let over = match evaluate_expression(
1050                        f.get("over").unwrap_or(&J::Null),
1051                        &base_scope(None),
1052                    ) {
1053                        Ok(v) => v,
1054                        Err(e) => {
1055                            *err_cell = Some(e.into());
1056                            return ExecOutcome::error("aborted");
1057                        }
1058                    };
1059                    let arr = match &over {
1060                        Value::Arr(a) => a.clone(),
1061                        _ => {
1062                            *err_cell = Some(BehaviorError {
1063                                code: BehaviorFailureCode::FanoutOverNotArray.as_str().to_string(),
1064                                message: format!("fanout '{}': 'over' is not an array", op.id),
1065                                detail: None,
1066                            });
1067                            return ExecOutcome::error("aborted");
1068                        }
1069                    };
1070                    let component = f.get("component").and_then(|v| v.as_str()).unwrap_or("");
1071                    let as_name = f.get("as").and_then(|v| v.as_str()).unwrap_or("$");
1072                    let ports_j = f
1073                        .get("ports")
1074                        .cloned()
1075                        .unwrap_or(J::Object(Default::default()));
1076                    let dedupe_key = f.get("dedupeKey").and_then(|v| v.as_str()).unwrap_or("");
1077                    let drop = f.get("drop").and_then(|v| v.as_str()).unwrap_or("dangling");
1078                    let implicit_source = f.get("implicitSource").and_then(|v| v.as_str());
1079                    let mut items: Vec<Value> = Vec::with_capacity(arr.len());
1080                    for el in arr.iter() {
1081                        let scope = base_scope(Some((as_name, el)));
1082                        match eval_ports(&ports_j, &scope) {
1083                            Ok(p) => items.push(Value::Obj(p)),
1084                            Err(e) => {
1085                                *err_cell = Some(e);
1086                                return ExecOutcome::error("aborted");
1087                            }
1088                        }
1089                    }
1090                    if items.is_empty() {
1091                        ExecOutcome::Ok(Value::Obj(vec![
1092                            ("items".into(), Value::Arr(vec![])),
1093                            ("cursor".into(), Value::Null),
1094                        ]))
1095                    } else {
1096                        let want = items.len();
1097                        let batch_ports = vec![("items".to_string(), Value::Arr(items))];
1098                        match handlers.exec_ctx(&op.id, component, &batch_ports, None) {
1099                            None => {
1100                                *err_cell = Some(BehaviorError {
1101                                    code: BehaviorFailureCode::UnknownComponent
1102                                        .as_str()
1103                                        .to_string(),
1104                                    message: format!(
1105                                        "component '{component}' has no handler (fail-closed)"
1106                                    ),
1107                                    detail: None,
1108                                });
1109                                ExecOutcome::error("aborted")
1110                            }
1111                            Some(ExecOutcome::Error(e, d)) => ExecOutcome::Error(e, d),
1112                            Some(ExecOutcome::Ok(Value::Arr(r))) if r.len() == want => {
1113                                let deduped =
1114                                    fanout_dedup_drop(r, dedupe_key, drop, implicit_source);
1115                                ExecOutcome::Ok(Value::Obj(vec![
1116                                    ("items".into(), Value::Arr(deduped)),
1117                                    ("cursor".into(), Value::Null),
1118                                ]))
1119                            }
1120                            Some(ExecOutcome::Ok(_)) => {
1121                                *err_cell = Some(BehaviorError {
1122                                    code: BehaviorFailureCode::FanoutBatchResultMismatch
1123                                        .as_str()
1124                                        .to_string(),
1125                                    message: format!(
1126                                        "fanout '{}': batched handler must return a list aligned to the deduped id list (want {want})",
1127                                        op.id
1128                                    ),
1129                        detail: None,
1130                    });
1131                                ExecOutcome::error("aborted")
1132                            }
1133                        }
1134                    }
1135                }
1136                Ok(_) => {
1137                    let component = node.get("component").and_then(|v| v.as_str()).unwrap_or("");
1138                    let ports_j = node
1139                        .get("ports")
1140                        .cloned()
1141                        .unwrap_or(J::Object(Default::default()));
1142                    let ports = match eval_ports(&ports_j, &base_scope(None)) {
1143                        Ok(p) => p,
1144                        Err(e) => {
1145                            *err_cell = Some(e);
1146                            return ExecOutcome::error("aborted");
1147                        }
1148                    };
1149                    match handlers.exec_ctx(&op.id, component, &ports, None) {
1150                        None => {
1151                            *err_cell = Some(BehaviorError {
1152                                code: BehaviorFailureCode::UnknownComponent.as_str().to_string(),
1153                                message: format!(
1154                                    "component '{component}' has no handler (fail-closed)"
1155                                ),
1156                                detail: None,
1157                            });
1158                            return ExecOutcome::error("aborted");
1159                        }
1160                        Some(o) => o,
1161                    }
1162                }
1163            };
1164
1165            if let ExecOutcome::Ok(v) = &outcome {
1166                // outType conformance (scp-error.md): a declared node result type is a claim; check
1167                // it here (the runtime holds both the declared type and the returned value), fail
1168                // closed with the structured Error Value on a mismatch, and NORMALIZE the value to
1169                // the declared type (int→float widen / absent opt→null) so results match the de-box.
1170                let mut value = v.clone();
1171                if let Some(ot) = node.get("outType") {
1172                    let want = result_type_of(node, ot);
1173                    match assert_conforms_to_out_type(&op.id, &value, &want, "result") {
1174                        Ok(normalized) => value = normalized,
1175                        Err(e) => {
1176                            *err_cell = Some(e.into());
1177                            return ExecOutcome::error("aborted");
1178                        }
1179                    }
1180                }
1181                results_cell.push((op.id.clone(), value));
1182            }
1183            outcome
1184        };
1185
1186        run_plan(plan.as_ref(), ops, exec)
1187    };
1188
1189    // A BehaviorError raised inside exec takes precedence over the synthetic plan
1190    // OP_FAILED it produced (we injected an "aborted" error to unwind run_plan).
1191    if let Some(e) = pending_err {
1192        return Err(e);
1193    }
1194    let run = run?;
1195
1196    // Skipped nodes contribute their unproduced representation to the scope so the
1197    // component `output` can `ref`/`coalesce` over them.
1198    for (i, id_val) in run.skipped.iter().enumerate() {
1199        let _ = i;
1200        if let Some(idx) = index_of(id_val) {
1201            let rk = node_relation_kind_str(&body[idx]);
1202            let unproduced = if rk == Some("connection") {
1203                Value::Obj(vec![
1204                    ("items".into(), Value::Arr(vec![])),
1205                    ("cursor".into(), Value::Null),
1206                ])
1207            } else {
1208                Value::Null
1209            };
1210            if scope_get(&results, id_val).is_none() {
1211                results.push((id_val.clone(), unproduced));
1212            }
1213        }
1214    }
1215
1216    let scope: Vec<(String, Value)> = {
1217        let mut s: Vec<(String, Value)> = input.to_vec();
1218        s.extend(results.iter().cloned());
1219        s
1220    };
1221    Ok(evaluate_expression(&output, &scope)?)
1222}