diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index afa734d..6ef4906 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -359,12 +359,29 @@ impl SweepBinder { } } -/// Resolve named sweep axes to a positional `Vec>` in `param_space()` -/// slot order. (Body filled in Step 3.) -fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec)]) -> Result>, BindError> { - // Phase 1 — per axis in input order: claim slots by exact name (a, b, c, d). - let mut claimed: Vec>> = vec![None; space.len()]; - for (name, values) in axes { +/// The shared two-phase named-binding resolution against `param_space()`, generic +/// over the per-slot payload `T` (one `Scalar` for [`resolve`], a `Vec` +/// axis for [`resolve_axes`]). The two callers differ only in the per-element work +/// at two pinned points; the error TOTAL ORDER lives here, once, so it cannot drift +/// between the scalar and axis paths (the #45/#53 "two raise-sites for one +/// invariant" hazard). +/// +/// `claim_ok` runs inside the phase-1 `[idx]` arm BEFORE the duplicate check (an +/// axis rejects an empty value list there); `kind_ok` is the phase-2 per-slot kind +/// gate (an axis loops every element, first offender in axis order winning). Both +/// return `Some(err)` to abort with that error, `None` to continue. The order of +/// arms — phase 1 per input `UnknownKnob → claim_ok → DuplicateBinding`, then phase +/// 2 per slot `MissingKnob → kind_ok` — is the contract pinned by the resolve/bind +/// tests; do not reorder. +fn resolve_into( + space: &[ParamSpec], + bindings: &[(String, T)], + claim_ok: impl Fn(&str, &T) -> Option, + kind_ok: impl Fn(&ParamSpec, &T) -> Option, +) -> Result, BindError> { + // Phase 1 — per binding in input order: claim slots by exact name. + let mut claimed: Vec> = vec![None; space.len()]; + for (name, value) in bindings { let matches: Vec = space .iter() .enumerate() @@ -374,39 +391,55 @@ fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec)]) -> Result return Err(BindError::UnknownKnob(name.clone())), // a [idx] => { - if values.is_empty() { - return Err(BindError::EmptyAxis(name.clone())); // c + if let Some(err) = claim_ok(name, value) { + return Err(err); // c (axis-only: EmptyAxis), before duplicate } if claimed[*idx].is_some() { return Err(BindError::DuplicateBinding(name.clone())); // d } - claimed[*idx] = Some(values.clone()); + claimed[*idx] = Some(value.clone()); } _ => unreachable!("param_space() is injective — checked before resolve"), } } - // Phase 2 — slot walk in param_space() order: completeness (e) + per-element kind (f). + // Phase 2 — slot walk in param_space() order: completeness (e) + kind (f). let mut ordered = Vec::with_capacity(space.len()); for (i, p) in space.iter().enumerate() { match &claimed[i] { None => return Err(BindError::MissingKnob(p.name.clone())), // e - Some(values) => { - for v in values { - if v.kind() != p.kind { - return Err(BindError::KindMismatch { - knob: p.name.clone(), - expected: p.kind, - got: v.kind(), - }); // f — first offending element in axis order - } + Some(value) => { + if let Some(err) = kind_ok(p, value) { + return Err(err); // f } - ordered.push(values.clone()); + ordered.push(value.clone()); } } } Ok(ordered) } +/// The phase-2 kind gate for a single scalar: mismatch against the slot kind. +fn scalar_kind_err(p: &ParamSpec, v: Scalar) -> Option { + (v.kind() != p.kind).then(|| BindError::KindMismatch { + knob: p.name.clone(), + expected: p.kind, + got: v.kind(), + }) +} + +/// Resolve named sweep axes to a positional `Vec>` in `param_space()` +/// slot order. Thin caller over [`resolve_into`]: an axis rejects an empty value +/// list at claim time (`EmptyAxis`, before the duplicate check) and kind-checks +/// every element (first offender in axis order). +fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec)]) -> Result>, BindError> { + resolve_into( + space, + axes, + |name, values| values.is_empty().then(|| BindError::EmptyAxis(name.to_string())), + |p, values| values.iter().find_map(|v| scalar_kind_err(p, *v)), + ) +} + /// Structural validation (param-value-independent): the `param_space()` name /// projection is the by-name knob address space (C12/C19) and must be injective — /// a duplicated path is a knob no binding can select alone. The first duplicate in @@ -424,46 +457,15 @@ fn check_param_namespace_injective(space: &[ParamSpec]) -> Result<(), CompileErr } /// Resolve named bindings to a positional `Vec` in `param_space()` slot -/// order. (Body filled in Step 3.) +/// order. Thin caller over [`resolve_into`]: a single scalar has no claim-time +/// rejection and kind-checks the one value. fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result, BindError> { - // Phase 1 — per binding in input order: claim slots by exact name (a, b, d). - let mut claimed: Vec> = vec![None; space.len()]; - for (name, value) in bound { - let matches: Vec = space - .iter() - .enumerate() - .filter(|(_, p)| p.name == *name) - .map(|(i, _)| i) - .collect(); - match matches.as_slice() { - [] => return Err(BindError::UnknownKnob(name.clone())), // a - [idx] => { - if claimed[*idx].is_some() { - return Err(BindError::DuplicateBinding(name.clone())); // d - } - claimed[*idx] = Some(*value); - } - _ => unreachable!("param_space() is injective — checked before resolve"), - } - } - // Phase 2 — slot walk in param_space() order (e, f). - let mut point = Vec::with_capacity(space.len()); - for (i, p) in space.iter().enumerate() { - match claimed[i] { - None => return Err(BindError::MissingKnob(p.name.clone())), // e - Some(v) => { - if v.kind() != p.kind { - return Err(BindError::KindMismatch { - knob: p.name.clone(), - expected: p.kind, - got: v.kind(), - }); // f - } - point.push(v); - } - } - } - Ok(point) + resolve_into( + space, + bound, + |_name, _value| None, + |p, value| scalar_kind_err(p, *value), + ) } /// A construction-phase fault, caught before the flat graph reaches