refactor(aura-core): own FieldSpec.name to drop the derive_signature heap leak

FieldSpec.name was &'static str, forcing derive_signature to Box::leak
every re-exported composite OutField name. Because compile_with_params
reruns derive_signature per sweep point, an N-point sweep over an
M-output-field composite leaked N×M boundary names unbounded — a scaling
wall the moment the World/sweep layer compiles families per point.

Pull FieldSpec.name to String, mirroring the already-owned PortSpec.name
and ParamSpec.name: a composite re-exports interior fields under runtime
boundary names, so ownership is the correct model, not a workaround.
FieldSpec loses Copy; the only fallout is one borrow at the render site.
leak_name/Box::leak deleted.

Behaviour-preserving: signature contents unchanged, all 231 tests green.
This commit is contained in:
2026-06-14 22:21:15 +02:00
parent 21c1621bd0
commit a8bd52eaff
11 changed files with 34 additions and 37 deletions
+11 -7
View File
@@ -33,8 +33,9 @@ pub enum Firing {
/// window = its `length`). The `name` is a **non-load-bearing** debug symbol (C23):
/// identity is the positional slot, the name is for tracing / graph rendering
/// (#13), never read by bootstrap or the run loop (which wire by index). A
/// `String`, not `&'static str` like `FieldSpec.name`: a variadic node generates
/// per-slot names (`term[0]`), exactly as `ParamSpec.name` does (`weights[0]`).
/// `String` (as `FieldSpec.name` and `ParamSpec.name` are): a variadic node
/// generates per-slot names (`term[0]`), needing ownership, exactly as
/// `ParamSpec.name` does (`weights[0]`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PortSpec {
pub kind: ScalarKind,
@@ -45,10 +46,13 @@ pub struct PortSpec {
/// One declared output column of a node's record: its name (metadata for sinks /
/// the playground, C18) and its scalar kind. The position of a `FieldSpec` in
/// `NodeSchema.output` is what an `Edge` binds (`Edge::from_field`); the name is
/// not load-bearing for wiring.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// not load-bearing for wiring. The `name` is a `String` (not `Copy`): a composite
/// re-exports an interior field under a runtime boundary name (`OutField.name`,
/// derived in `derive_signature`), so ownership is required — the same reason
/// `PortSpec.name` and `ParamSpec.name` are owned.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FieldSpec {
pub name: &'static str,
pub name: String,
pub kind: ScalarKind,
}
@@ -56,8 +60,8 @@ pub struct FieldSpec {
/// kind. The name is a **non-load-bearing** debug symbol (path-qualified at
/// aggregation, as `FieldSpec.name` already is); a param's identity is its
/// positional slot in the blueprint's aggregated param-space (C23 — by index, not
/// by name). Unlike `FieldSpec.name` (`&'static str`), the name is a `String`: a
/// vector knob carries a runtime index (`weights[0]`) and aggregation prefixes the
/// by name). The name is a `String` (as `FieldSpec.name` and `PortSpec.name` are):
/// a vector knob carries a runtime index (`weights[0]`) and aggregation prefixes the
/// composite path (`strategy.weights[0]`). Permitted kinds: `I64`/`F64`/`Bool`;
/// `Timestamp` is a structural axis (C20), never a numeric knob.
#[derive(Clone, Debug, PartialEq, Eq)]
+3 -10
View File
@@ -100,7 +100,7 @@ fn derive_signature(c: &Composite) -> NodeSchema {
.get(of.node)
.and_then(|n| n.signature().output.get(of.field).map(|f| f.kind))
.unwrap_or(ScalarKind::F64);
FieldSpec { name: leak_name(&of.name), kind }
FieldSpec { name: of.name.clone(), kind }
})
.collect();
let mut params = Vec::new();
@@ -120,13 +120,6 @@ fn interior_slot_kind(nodes: &[BlueprintNode], _edges: &[Edge], t: &Target) -> S
.unwrap_or(ScalarKind::F64)
}
/// Leak a boundary name into a `&'static str` for a derived `FieldSpec`. Acceptable
/// because `derive_signature` is a cold, pre-build, render/validation path (never the
/// hot loop), and the leaked names are bounded by the static blueprint.
fn leak_name(s: &str) -> &'static str {
Box::leak(s.to_string().into_boxed_str())
}
/// One named input role: role `r` (by position) fans the source value into
/// `targets`. The `name` is a non-load-bearing render symbol (C23); identity is
/// the role index, which survives lowering — the name does not.
@@ -1048,7 +1041,7 @@ mod tests {
}
/// A one-f64-field output record under name `v`.
fn out_v() -> Vec<FieldSpec> {
vec![FieldSpec { name: "v", kind: ScalarKind::F64 }]
vec![FieldSpec { name: "v".into(), kind: ScalarKind::F64 }]
}
/// A bound root role of f64 kind, fanning into `targets`.
fn root_role(name: &str, targets: Vec<Target>) -> Role {
@@ -2181,7 +2174,7 @@ mod tests {
"Boom",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
output: vec![FieldSpec { name: "v", kind: ScalarKind::I64 }],
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::I64 }],
params: vec![],
},
|_| panic!("build must not run when validation fails pre-build"),
+2 -2
View File
@@ -88,7 +88,7 @@ fn prim_record(b: &aura_core::PrimitiveBuilder) -> String {
let role = if s.output.is_empty() { "sink" } else { "node" };
let params = join(s.params.iter().map(|p| named_kind(&p.name, p.kind)));
let ins = join(s.inputs.iter().map(port_json));
let outs = join(s.output.iter().map(|f| named_kind(f.name, f.kind)));
let outs = join(s.output.iter().map(|f| named_kind(&f.name, f.kind)));
// explicit instance name only — an unnamed node carries no "name" field
// (node_name()'s lowercased-type default would be a redundant type-duplicate)
let name = match b.instance_name() {
@@ -298,7 +298,7 @@ mod tests {
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "rhs".into() },
],
output: vec![FieldSpec { name: "diff", kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "diff".into(), kind: ScalarKind::F64 }],
params: vec![],
},
|_| Box::new(Bare),
+11 -11
View File
@@ -395,7 +395,7 @@ mod tests {
fn sig() -> NodeSchema {
NodeSchema {
inputs: vec![f64_port(Firing::Any), f64_port(Firing::Any)],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
}
}
@@ -424,7 +424,7 @@ mod tests {
fn sig() -> NodeSchema {
NodeSchema {
inputs: vec![f64_port(Firing::Barrier(0)), f64_port(Firing::Barrier(0))],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
}
}
@@ -453,7 +453,7 @@ mod tests {
f64_port(Firing::Barrier(0)),
f64_port(Firing::Any),
],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
}
}
@@ -486,11 +486,11 @@ mod tests {
NodeSchema {
inputs: vec![f64_port(Firing::Barrier(0)); 5],
output: vec![
FieldSpec { name: "open", kind: ScalarKind::F64 },
FieldSpec { name: "high", kind: ScalarKind::F64 },
FieldSpec { name: "low", kind: ScalarKind::F64 },
FieldSpec { name: "close", kind: ScalarKind::F64 },
FieldSpec { name: "volume", kind: ScalarKind::F64 },
FieldSpec { name: "open".into(), kind: ScalarKind::F64 },
FieldSpec { name: "high".into(), kind: ScalarKind::F64 },
FieldSpec { name: "low".into(), kind: ScalarKind::F64 },
FieldSpec { name: "close".into(), kind: ScalarKind::F64 },
FieldSpec { name: "volume".into(), kind: ScalarKind::F64 },
],
params: vec![],
}
@@ -524,8 +524,8 @@ mod tests {
NodeSchema {
inputs: vec![f64_port(Firing::Any)],
output: vec![
FieldSpec { name: "f", kind: ScalarKind::F64 },
FieldSpec { name: "i", kind: ScalarKind::I64 },
FieldSpec { name: "f".into(), kind: ScalarKind::F64 },
FieldSpec { name: "i".into(), kind: ScalarKind::I64 },
],
params: vec![],
}
@@ -553,7 +553,7 @@ mod tests {
fn sig() -> NodeSchema {
NodeSchema {
inputs: vec![f64_port(Firing::Any)],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ impl Add {
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "rhs".into() },
],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
},
|_| Box::new(Add::new()),
+1 -1
View File
@@ -61,7 +61,7 @@ impl Ema {
"EMA",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
},
|p| Box::new(Ema::new(p[0].as_i64().expect("length slot is I64") as usize)),
+1 -1
View File
@@ -30,7 +30,7 @@ impl Exposure {
"Exposure",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "signal".into() }],
output: vec![FieldSpec { name: "exposure", kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "exposure".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
},
|p| Box::new(Exposure::new(p[0].as_f64().expect("scale slot is F64"))),
+1 -1
View File
@@ -52,7 +52,7 @@ impl LinComb {
.collect();
PrimitiveBuilder::new(
"LinComb",
NodeSchema { inputs, output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], params },
NodeSchema { inputs, output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }], params },
|p| Box::new(LinComb::new(
p.iter().map(|s| s.as_f64().expect("weight slot is F64")).collect(),
)),
+1 -1
View File
@@ -66,7 +66,7 @@ impl SimBroker {
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "exposure".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() },
],
output: vec![FieldSpec { name: "equity", kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "equity".into(), kind: ScalarKind::F64 }],
params: vec![],
},
move |_| Box::new(SimBroker::new(pip_size)),
+1 -1
View File
@@ -29,7 +29,7 @@ impl Sma {
"SMA",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
},
|p| Box::new(Sma::new(p[0].as_i64().expect("length slot is I64") as usize)),
+1 -1
View File
@@ -27,7 +27,7 @@ impl Sub {
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() },
PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "rhs".into() },
],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![],
},
|_| Box::new(Sub::new()),