Files
Aura/fieldtests/milestone-construction-layer/mc_2_miswire_render.rs
T
Brummel 784e6c917a feat(aura-engine): composite output is a named multi-field record
Replace a composite's single output port (OutPort { node, field }) with
an ordered, named record (Vec<OutField { node, field, name }>). A
composite can now re-export K interior fields as one output; a consumer
selects one via the existing Edge::from_field — the same field-wise
selector that already works for multi-output leaf producers (OHLCV).

Why: multi-line indicators (MACD, Bollinger, Stochastic, Ichimoku) could
not be authored as a composition and re-exported as a unit — only one
line escaped the boundary. The MACD PoC was forced to expose only the
histogram. This lifts the three `field == 0` / `from_field == 0` caps at
the composite boundary (inline_composite nested arm, rewrite_edge
composite arm), range-checking the index instead.

Boundary completion, not an invariant change:
- C8/C7/C4 untouched — one port, one row per eval, K base columns (a
  3-line MACD is identical in kind to OHLCV).
- C23 honoured — re-export names live at the blueprint boundary only and
  are dropped in the compilat (raw index wiring). compiled_view_golden
  stayed byte-identical (the regression guard): no name leaked into the
  flat graph.

The MACD PoC now re-exports macd / signal / histogram as a record; the
strategy still trades the histogram, selected via from_field: 2. The
render shows K [out:<name>] markers per multi-output composite (input
roles stay [in:<index>] — naming them is the dependent #41).

Output naming ships with the capability (OutField is born name-ready) so
#41 — composite param aliasing + input-role naming — does not reopen the
type.

Verification: cargo build/test/clippy --workspace -- -D warnings all
green; aura-engine 62 tests (+3 capability, +1 through-run E2E), aura-cli
unchanged-count green; compiled_view_golden byte-identical.

Known debt (out of scope, pre-existing): the non-workspace construction-
layer fieldtests (fieldtests/milestone-construction-layer/mc_1..mc_4)
carry their OutField sweep but still do not compile standalone — they use
the Sma::new / BlueprintNode::from(Sma) API retired in the cycle-0016
value-empty migration, never propagated to these fixtures. Tracked as a
follow-up.

closes #40
2026-06-08 01:00:04 +02:00

91 lines
3.7 KiB
Rust

// Milestone fieldtest — axis (b): deliberately mis-wire a graph (swap the
// fast/slow SMA legs of the cross) and confirm the wiring reads back
// differently — the milestone's headline promise that a render visibly
// surfaces a mis-wiring.
//
// The headline render is the `aura graph` CLI (exercised separately, in the
// accompanying .sh-free flow: see the spec). HERE we test the property the
// render RELIES ON from the consumer side: that the labels + graph-as-data of a
// correctly-wired composite differ observably from a swapped one. If a swap
// produced an identical graph-as-data, no renderer could ever surface it.
//
// Public interface only: Composite / Blueprint accessors + Node::label() via
// the boxed node; ledger C8-refinement (label is the disambiguating symbol).
use aura_engine::{BlueprintNode, Composite, Edge, OutField, Target};
// NB: `node.label()` on a `&Box<dyn Node>` leaf dispatches via the trait object
// without an explicit `use aura_core::Node` (the method is in scope through the
// boxed trait object). A consumer does not need to import the Node trait to read
// labels off a built graph.
use aura_std::{Sma, Sub};
/// Build an SMA-cross composite. `swap` flips which SMA feeds Sub's slot 0.
/// Correct: fast(SMA2) -> minuend(slot0), slow(SMA4) -> subtrahend(slot1).
/// Swapped: slow -> slot0, fast -> slot1 (the classic fast/slow mis-wire).
fn cross(swap: bool) -> Composite {
let (e_fast, e_slow) = if swap {
// slow(node 1) into slot0, fast(node 0) into slot1
(
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
Edge { from: 0, to: 2, slot: 1, from_field: 0 },
)
} else {
(
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
)
};
Composite::new(
"sma_cross",
vec![
BlueprintNode::from(Sma::new(2)),
BlueprintNode::from(Sma::new(4)),
BlueprintNode::from(Sub::new()),
],
vec![e_fast, e_slow],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
}
fn node_labels(c: &Composite) -> Vec<String> {
c.nodes()
.iter()
.map(|n| match n {
BlueprintNode::Leaf(node) => node.label(),
BlueprintNode::Composite(inner) => format!("composite:{}", inner.name()),
})
.collect()
}
fn main() {
let correct = cross(false);
let swapped = cross(true);
// The per-node labels carry identifying params (SMA(2) vs SMA(4)) so two
// SMAs are distinguishable in a render.
println!("correct labels: {:?}", node_labels(&correct));
println!("swapped labels: {:?}", node_labels(&swapped));
// The mis-wire lives in the EDGE table; the graph-as-data must reflect it.
let ce: Vec<Edge> = correct.edges().to_vec();
let se: Vec<Edge> = swapped.edges().to_vec();
println!("correct interior edges: {ce:?}");
println!("swapped interior edges: {se:?}");
// Headline property: the two graphs-as-data differ. A renderer reading the
// edge table + labels can therefore make the swap visible.
assert_ne!(
ce, se,
"a fast/slow swap MUST change the graph-as-data, else no render could surface it"
);
// And the disambiguating labels are present (SMA(2)/SMA(4) distinguishable).
let labels = node_labels(&correct);
assert!(
labels.iter().any(|l| l.contains("SMA(2)")) && labels.iter().any(|l| l.contains("SMA(4)")),
"labels must carry SMA params so a swapped leg reads differently: {labels:?}"
);
println!("OK: a fast/slow swap is observable in graph-as-data + labels.");
}