fieldtest: cycle 0031 node-naming — naming cure under-signposted on the by-name flow

Per-cycle fieldtest of node-instance naming (#56), driven as a downstream
consumer from the public interface only (ledger + spec + rustdoc + CLI; no
crates/*/src read), three fixtures built and run from HEAD.

Verdict: the core 0031 promise holds first-try. A consumer authors
Sma::builder().named("fast"), inspects param_space() (sma_cross.fast.length),
binds by name and runs; the default-name case (sma.length / exposure.scale,
verbatim lowercase) and paramless-interchangeable-stays-legal both hold.

0 bugs, 3 working, 2 friction, 1 spec_gap. The one real gap (verified by the
orchestrator against the fixture output): the spec's headline forcing function
IndistinguishableFanIn does NOT reach an author on the canonical
.with(...).bootstrap() flow. An un-named 2-SMA cross emits a literal DUPLICATE
knob (sma_cross.sma.length x2); the binder resolves names before the compile
fan-in check, so the author hits UnknownKnob / AmbiguousKnob("sma_cross.sma.length")
— which point at the knob, not at the cure "name your nodes". IndistinguishableFanIn
only surfaces via the positional compile_with_params path. Rejection still
happens (no invalid blueprint runs), so it is an ergonomic/signposting gap, not
a correctness bug. Routed to the backlog (relates to #58); 0031 stays
audit-closed. Minor: FlatGraph/Harness lack Debug, so a bootstrap Result can't
be {:?}-printed.
This commit is contained in:
2026-06-11 12:18:29 +02:00
parent 0e411f1796
commit 11dfff860c
9 changed files with 809 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aura-core"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "aura-engine"
version = "0.1.0"
dependencies = [
"aura-core",
"serde",
]
[[package]]
name = "aura-std"
version = "0.1.0"
dependencies = [
"aura-core",
]
[[package]]
name = "c0031-fieldtest"
version = "0.0.0"
dependencies = [
"aura-core",
"aura-engine",
"aura-std",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "syn"
version = "2.0.117"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
@@ -0,0 +1,34 @@
# Standalone downstream-consumer crate for the cycle-0031 fieldtest
# (node-instance naming: `.named()`, uniform `<node>.<param>` param paths,
# IndistinguishableFanIn re-keyed onto the node name, ParamAlias retired).
#
# Like the prior cycle-0006..0011 fixtures, this is NOT a member of the aura
# workspace — it path-depends on the engine crates exactly as a real research
# project (C16) would. Built/run via
# cargo run --manifest-path fieldtests/cycle-0031-node-naming/Cargo.toml --bin <name>
# so HEAD source is always what runs. No crates/*/src was read; the API was
# discovered from the ledger (C9/C23), spec 0031, and `cargo doc`.
[workspace]
[package]
name = "c0031-fieldtest"
version = "0.0.0"
edition = "2024"
publish = false
[dependencies]
aura-core = { path = "../../crates/aura-core" }
aura-engine = { path = "../../crates/aura-engine" }
aura-std = { path = "../../crates/aura-std" }
[[bin]]
name = "c0031_1_named_single_run"
path = "c0031_1_named_single_run.rs"
[[bin]]
name = "c0031_2_forcing_function"
path = "c0031_2_forcing_function.rs"
[[bin]]
name = "c0031_3_default_names"
path = "c0031_3_default_names.rs"
@@ -0,0 +1,147 @@
// Cycle-0031 fieldtest — scenario 1 (axis a): the named single-run authoring path.
//
// Promise probed (spec 0031 acceptance #1, #2, #4): a downstream author writes
// `Sma::builder().named("fast")`, inspects `param_space()` (now uniformly
// `<node>.<param>` at every level, including the root `exposure.scale`), binds
// `sma_cross.fast.length` BY NAME with NO ParamAlias and NO index counting, and
// runs. This is the exact pain the milestone-0030 fixture (mw_1) had to work
// around with two hand-counted ParamAlias overlays.
//
// Public interface only: API discovered from the ledger (C9/C23), spec 0031,
// and `cargo doc`. No crates/*/src was read.
//
// The harness: source -> sma_cross composite { Sma("fast"), Sma("slow"), Sub }
// -> Exposure -> SimBroker -> Recorder.
use std::sync::mpsc;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{BlueprintNode, Composite, Edge, OutField, Role, Target};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
/// The reusable inner 2/4-SMA-cross composite. The two SMA legs are now named
/// inline via `.named()` — the single act that (a) distinguishes the fan-in and
/// (b) qualifies the two param paths. No ParamAlias argument exists anymore
/// (Composite::new is 4-arg).
fn sma_cross() -> Composite {
Composite::new(
"sma_cross",
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
}
/// The root harness over the named cross. The root-level Exposure node now also
/// carries a name (default "exposure"), so its knob surfaces as `exposure.scale`.
fn harness(tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>) -> Composite {
Composite::new(
"harness",
vec![
BlueprintNode::Composite(sma_cross()),
Exposure::builder().into(),
SimBroker::builder(1e-4).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 2, slot: 1 },
],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "exposure".into() }],
)
}
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
let prices = [
1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07,
];
prices
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(60_000_000_000 * i as i64), Scalar::F64(p)))
.collect()
}
fn main() {
// --- 1. Inspect the param-space: are the names what the spec promises? -----
let (tx0, _rx0) = mpsc::channel();
let space = harness(tx0).param_space();
println!("param_space() knobs (name : kind):");
for p in &space {
println!(" {} : {:?}", p.name, p.kind);
}
let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect();
// spec 0031 acceptance #1/#4: uniform <node>.<param>, distinct named legs.
assert!(
names.contains(&"sma_cross.fast.length"),
"expected sma_cross.fast.length, got {names:?}"
);
assert!(
names.contains(&"sma_cross.slow.length"),
"expected sma_cross.slow.length, got {names:?}"
);
assert!(
names.contains(&"exposure.scale"),
"expected root-level exposure.scale, got {names:?}"
);
// --- 2. Bind BY NAME, with no ParamAlias and no index counting. ------------
let (tx, rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
let mut h = harness(tx)
.with("sma_cross.fast.length", 2)
.with("sma_cross.slow.length", 4)
.with("exposure.scale", 0.5)
.bootstrap()
.expect("named harness binds by the uniform <node>.<param> names and bootstraps");
h.run(vec![synthetic_prices()]);
drop(h);
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.iter().collect();
println!("\nnamed-bound run — recorded rows: {}", rows.len());
for (ts, row) in &rows {
if let Scalar::F64(x) = row[0] {
println!(" ts={:>14} exposure={:+.4}", ts.0, x);
}
}
assert!(!rows.is_empty(), "named-bound harness must record a populated trace");
// --- 3. Determinism (C1): a disjoint second run is bit-identical. ----------
let (tx2, rx2) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
let mut h2 = harness(tx2)
.with("sma_cross.fast.length", 2)
.with("sma_cross.slow.length", 4)
.with("exposure.scale", 0.5)
.bootstrap()
.expect("second disjoint bootstrap");
h2.run(vec![synthetic_prices()]);
drop(h2);
let rows2: Vec<(Timestamp, Vec<Scalar>)> = rx2.iter().collect();
assert_eq!(rows, rows2, "two disjoint named runs must be bit-identical (C1)");
println!("\nOK: param-space uniform <node>.<param>, named bind ran, deterministic.");
}
@@ -0,0 +1,160 @@
// Cycle-0031 fieldtest — scenario 2 (axis b): the forcing function.
//
// Promise probed (spec 0031 acceptance #3): author the 2-SMA cross WITHOUT
// names -> both legs default to "sma" -> they collide and the fan-in is
// indistinguishable -> `IndistinguishableFanIn { node }`. The recovery is the
// single act of naming the colliding legs. The question this fixture answers as
// a downstream consumer: is the diagnostic DISCOVERABLE and HELPFUL from the
// public surface alone — does it name the node, and does the error type/text
// point the author at "give the legs distinct names"?
//
// Public interface only (ledger C9/C23 + spec 0031 + cargo doc). No src read.
use std::sync::mpsc;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
BlueprintNode, CompileError, Composite, Edge, OutField, Role, Target,
};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
/// Build the cross with the two SMA legs given (caller decides whether to name
/// them). `name_them = false` reproduces the minimal un-named authoring.
fn sma_cross(name_them: bool) -> Composite {
let (fast, slow) = if name_them {
(Sma::builder().named("fast"), Sma::builder().named("slow"))
} else {
(Sma::builder(), Sma::builder())
};
Composite::new(
"sma_cross",
vec![fast.into(), slow.into(), Sub::builder().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
}
fn harness(
name_them: bool,
tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
) -> Composite {
Composite::new(
"harness",
vec![
BlueprintNode::Composite(sma_cross(name_them)),
Exposure::builder().into(),
SimBroker::builder(1e-4).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 2, slot: 1 },
],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "exposure".into() }],
)
}
fn main() {
// --- 0. What does param_space() emit for the UN-NAMED cross? --------------
// Both legs default to "sma" -> sma_cross.sma.length appears TWICE
// (a collision). This is what makes a by-name bind ambiguous/unknown.
let (txs, _rxs) = mpsc::channel();
let unnamed_space = harness(false, txs).param_space();
println!("[0] un-named cross param_space():");
for p in &unnamed_space {
println!(" {} : {:?}", p.name, p.kind);
}
// --- 1. The minimal un-named authoring is correctly REJECTED. -------------
let (txu, _rxu) = mpsc::channel();
let unnamed = harness(false, txu)
.with("sma_cross.length", 2) // best guess at the colliding bare name
.bootstrap();
// Harness (Ok branch) may not implement Debug; inspect the error only.
println!("[1] un-named cross bootstrap is_err: {}", unnamed.is_err());
match unnamed.err() {
// The binder may reject first (AmbiguousKnob on the colliding
// sma_cross.length), OR the compile may reject first
// (IndistinguishableFanIn). Record whichever wall the author hits.
Some(other) => println!(" -> diagnostic the author sees: {other:?}"),
None => panic!("BUG: un-named param-bearing fan-in should be rejected"),
}
// --- 1a. Bind the name param_space() ACTUALLY emits (the duplicate). ------
// sma_cross.sma.length is emitted TWICE -> binding it is AmbiguousKnob, NOT
// the spec's headline IndistinguishableFanIn. So the by-name author never
// sees "name the colliding legs"; they see a knob-name diagnostic.
let (txa, _rxa) = mpsc::channel();
let ambiguous = harness(false, txa)
.with("sma_cross.sma.length", 2)
.bootstrap();
println!(
"[1a] bind the real emitted (duplicate) name -> {:?}",
ambiguous.err()
);
// --- 1b. The bare bootstrap (no binds at all) isolates the COMPILE wall. ---
// This removes the binder from the picture so we see the fan-in error itself.
let (txc, _rxc) = mpsc::channel();
let compiled = harness(false, txc).compile_with_params(&[
Scalar::I64(2),
Scalar::I64(4),
Scalar::F64(0.5),
]);
// FlatGraph (the Ok branch) does not implement Debug, so we cannot
// `{:?}`-print the whole Result; inspect via .err() instead.
println!("\n[1b] un-named cross compile_with_params err: {:?}", compiled.as_ref().err());
match compiled.err() {
Some(CompileError::IndistinguishableFanIn { node }) => {
println!(
" -> IndistinguishableFanIn at interior node index {node} \
(the Sub that fans in both un-named SMAs)"
);
}
other => println!(
" -> NOTE: expected IndistinguishableFanIn, got {other:?}"
),
}
// --- 2. The recovery: name the legs -> the SAME topology now compiles. -----
let (tx, rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
let named = harness(true, tx)
.with("sma_cross.fast.length", 2)
.with("sma_cross.slow.length", 4)
.with("exposure.scale", 0.5)
.bootstrap();
println!("\n[2] named cross bootstrap ok: {}", named.is_ok());
let mut h = named.expect("naming the colliding legs resolves the fan-in");
let prices: Vec<(Timestamp, Scalar)> = (0..12)
.map(|i| (Timestamp(60_000_000_000 * i), Scalar::F64(1.0 + 0.01 * i as f64)))
.collect();
h.run(vec![prices]);
drop(h);
let rows: Vec<_> = rx.iter().collect();
println!("[2] named run recorded {} rows", rows.len());
assert!(!rows.is_empty(), "named cross must run and record");
println!(
"\nOK: un-named fan-in rejected, naming the legs is the single recovering act."
);
}
@@ -0,0 +1,130 @@
// Cycle-0031 fieldtest — scenario 3 (axis c): default names + the paramless
// "interchangeable stays legal" corner + node-name visibility.
//
// Probes (spec 0031 §"Default name", §"Paramless interchangeable stays legal",
// and the carrier's optional 3rd axis):
// (a) a single un-named primitive emits `<lowercased-type>.<param>` — `sma.length`;
// (b) a root-level Exposure (un-named) emits `exposure.scale`;
// (c) the default lowercasing is verbatim, no snake_case insertion
// ("SimBroker" -> "simbroker", not "sim_broker");
// (d) two un-named PARAMLESS same-type siblings under a fan-in stay legal
// (no IndistinguishableFanIn), as the spec preserves.
//
// Public interface only (ledger C9/C23 + spec 0031 + cargo doc). No src read.
use std::sync::mpsc;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{Composite, Edge, OutField, Role, Target};
use aura_std::{Add, Exposure, Recorder, SimBroker, Sma, Sub};
/// A trivial harness: one un-named SMA feeding Exposure -> SimBroker -> Recorder.
/// No composite nesting, so every param surfaces at the ROOT level — the case
/// that exercises the "root segment is now present too" rule.
fn single_unnamed_harness(tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>) -> Composite {
Composite::new(
"harness",
vec![
Sma::builder().into(), // un-named -> default "sma"
Exposure::builder().into(), // un-named -> default "exposure"
SimBroker::builder(1e-4).into(), // un-named -> default "simbroker"
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 2, slot: 1 },
],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 1, field: 0, name: "exposure".into() }],
)
}
/// Two un-named PARAMLESS same-type siblings (Add has no params) under a fan-in
/// (Sub). Spec: this stays LEGAL — interchangeable paramless legs do not fire
/// IndistinguishableFanIn. Built as a self-contained ROOT (the single price
/// source feeds both Adds' slots) so the role is bound and the only thing under
/// test is the paramless same-name fan-in rule, not role-binding.
fn paramless_fanin() -> Composite {
Composite::new(
"paramless",
vec![
Add::builder().into(), // un-named -> "add"
Add::builder().into(), // un-named -> "add" — same name, but paramless
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "x".into(),
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 0, slot: 1 },
Target { node: 1, slot: 0 },
Target { node: 1, slot: 1 },
],
// bound root source — keeps the role satisfied so the compile only
// exercises the paramless fan-in rule.
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
}
fn main() {
// --- (a)(b)(c) default names appear in param_space at the root level. ------
let (tx0, _rx0) = mpsc::channel();
let space = single_unnamed_harness(tx0).param_space();
println!("default-name param_space():");
for p in &space {
println!(" {} : {:?}", p.name, p.kind);
}
let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"sma.length"), "expected sma.length, got {names:?}");
assert!(names.contains(&"exposure.scale"), "expected exposure.scale, got {names:?}");
// SimBroker's spread arg is a constructor value, not a knob; if SimBroker has
// a param it must surface lowercased verbatim. Record whatever it emits.
let simbroker_knobs: Vec<&&str> =
names.iter().filter(|n| n.starts_with("sim")).collect();
println!("simbroker-prefixed knobs (verbatim-lowercase check): {simbroker_knobs:?}");
assert!(
!names.iter().any(|n| n.starts_with("sim_broker")),
"default name must be verbatim-lowercased 'simbroker', never snake_cased 'sim_broker' ({names:?})"
);
// Bind the default names and run, to prove they are real bindable knobs.
let (tx, rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
let mut h = single_unnamed_harness(tx)
.with("sma.length", 3)
.with("exposure.scale", 1.0)
.bootstrap()
.expect("default-named knobs bind by their lowercased-type paths");
let prices: Vec<(Timestamp, Scalar)> = (0..10)
.map(|i| (Timestamp(60_000_000_000 * i), Scalar::F64(1.0 + 0.02 * i as f64)))
.collect();
h.run(vec![prices]);
drop(h);
let rows: Vec<_> = rx.iter().collect();
println!("\ndefault-named run recorded {} rows", rows.len());
assert!(!rows.is_empty());
// --- (d) paramless interchangeable same-name fan-in stays LEGAL. ----------
let compiled = paramless_fanin().compile_with_params(&[]);
println!("\nparamless same-name fan-in compile ok: {:?}", compiled.is_ok());
assert!(
compiled.is_ok(),
"two un-named PARAMLESS same-type legs must stay legal (no IndistinguishableFanIn): err={:?}",
compiled.err()
);
println!("\nOK: default names = lowercased type label, root-level qualified, paramless legal.");
}
@@ -0,0 +1,20 @@
param_space() knobs (name : kind):
sma_cross.fast.length : I64
sma_cross.slow.length : I64
exposure.scale : F64
named-bound run — recorded rows: 12
ts= 0 exposure=+0.0000
ts= 60000000000 exposure=+0.0000
ts= 120000000000 exposure=+0.0000
ts= 180000000000 exposure=+0.0000
ts= 240000000000 exposure=+4.0000
ts= 300000000000 exposure=+11.5000
ts= 360000000000 exposure=+3.5000
ts= 420000000000 exposure=-2.5000
ts= 480000000000 exposure=+0.5000
ts= 540000000000 exposure=+4.5000
ts= 600000000000 exposure=-2.5000
ts= 660000000000 exposure=-6.5000
OK: param-space uniform <node>.<param>, named bind ran, deterministic.
@@ -0,0 +1,15 @@
[0] un-named cross param_space():
sma_cross.sma.length : I64
sma_cross.sma.length : I64
exposure.scale : F64
[1] un-named cross bootstrap is_err: true
-> diagnostic the author sees: UnknownKnob("sma_cross.length")
[1a] bind the real emitted (duplicate) name -> Some(AmbiguousKnob("sma_cross.sma.length"))
[1b] un-named cross compile_with_params err: Some(IndistinguishableFanIn { node: 2 })
-> IndistinguishableFanIn at interior node index 2 (the Sub that fans in both un-named SMAs)
[2] named cross bootstrap ok: true
[2] named run recorded 12 rows
OK: un-named fan-in rejected, naming the legs is the single recovering act.
@@ -0,0 +1,10 @@
default-name param_space():
sma.length : I64
exposure.scale : F64
simbroker-prefixed knobs (verbatim-lowercase check): []
default-named run recorded 10 rows
paramless same-name fan-in compile ok: true
OK: default names = lowercased type label, root-level qualified, paramless legal.