refactor(aura-engine): fold resolve/resolve_axes into one generic resolve_into

resolve (single Scalar per slot) and resolve_axes (a Vec<Scalar> axis per
slot) were the same two-phase named-binding resolution against param_space(),
duplicated almost verbatim — the live instance of the "two raise-sites for
one invariant" hazard (cf. #45/#53). A change to binding precedence or error
ordering had to touch both in lockstep.

Collapse the shared skeleton into resolve_into<T>, parameterized by two
closures: claim_ok (phase-1 claim-time gate, axis-only EmptyAxis, before the
duplicate check) and kind_ok (phase-2 per-slot kind gate). The error total
order now lives in exactly one place. Behaviour-preserving: same error for
same input, all 231 tests green (the resolve/bind precedence tests pin it).
This commit is contained in:
2026-06-14 22:30:17 +02:00
parent a8bd52eaff
commit 941503ed3d
+61 -59
View File
@@ -359,12 +359,29 @@ impl SweepBinder {
}
}
/// Resolve named sweep axes to a positional `Vec<Vec<Scalar>>` in `param_space()`
/// slot order. (Body filled in Step 3.)
fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec<Scalar>)]) -> Result<Vec<Vec<Scalar>>, BindError> {
// Phase 1 — per axis in input order: claim slots by exact name (a, b, c, d).
let mut claimed: Vec<Option<Vec<Scalar>>> = 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<Scalar>`
/// 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<T: Clone>(
space: &[ParamSpec],
bindings: &[(String, T)],
claim_ok: impl Fn(&str, &T) -> Option<BindError>,
kind_ok: impl Fn(&ParamSpec, &T) -> Option<BindError>,
) -> Result<Vec<T>, BindError> {
// Phase 1 — per binding in input order: claim slots by exact name.
let mut claimed: Vec<Option<T>> = vec![None; space.len()];
for (name, value) in bindings {
let matches: Vec<usize> = space
.iter()
.enumerate()
@@ -374,39 +391,55 @@ fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec<Scalar>)]) -> Result<V
match matches.as_slice() {
[] => 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<BindError> {
(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<Vec<Scalar>>` 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<Scalar>)]) -> Result<Vec<Vec<Scalar>>, 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<Scalar>` 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<Vec<Scalar>, BindError> {
// Phase 1 — per binding in input order: claim slots by exact name (a, b, d).
let mut claimed: Vec<Option<Scalar>> = vec![None; space.len()];
for (name, value) in bound {
let matches: Vec<usize> = 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