diff --git a/crates/aura-cli/assets/graph-viewer.js b/crates/aura-cli/assets/graph-viewer.js
index 39185fe..f5f93fe 100644
--- a/crates/aura-cli/assets/graph-viewer.js
+++ b/crates/aura-cli/assets/graph-viewer.js
@@ -29,7 +29,7 @@ function adaptNodes(nodes) {
const n = nodes[key];
if (n.prim) {
const p = n.prim;
- out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, ins: adaptIns(p.ins), outs: p.outs } };
+ out[key] = { prim: { name: p.name, type: p.type, role: p.role, params: p.params, bound: p.bound || [], ins: adaptIns(p.ins), outs: p.outs } };
} else {
out[key] = n; // composite reference { comp }
}
@@ -69,8 +69,23 @@ function cellLabel(id, o, showTypes) {
const cell = (io, arr) => ([n, k], i) =>
`
${txt(n, k)} | `;
- const sp = o.params.map(([n, k]) => `${showTypes && k ? `${n}:${k}` : n}`);
- const sig = o.params.length ? `[${sp.join(', ')}]` : "";
+ // merge free params and bound params back into slot order by original position;
+ // a bound slot renders as a dimmed name=value (reuses the 0035 separator grey).
+ const bnd = o.bound || [];
+ const total = o.params.length + bnd.length;
+ const byPos = new Map(bnd.map(([pos, n, k, v]) => [pos, [n, k, v]]));
+ const slots = [];
+ let fi = 0;
+ for (let pos = 0; pos < total; pos++) {
+ if (byPos.has(pos)) {
+ const [n, , v] = byPos.get(pos);
+ slots.push(`${n}=${v}`);
+ } else {
+ const [n, k] = o.params[fi++];
+ slots.push(`${showTypes && k ? `${n}:${k}` : n}`);
+ }
+ }
+ const sig = total ? `[${slots.join(', ')}]` : "";
const prefix = o.name
? `${o.name}: `
: "";
@@ -108,7 +123,7 @@ function genDot(def, role, expandedSet, showTypes) {
const inst = def.nodes[key], cpath = [...path, key], cid = "n" + cpath.join("__");
if (inst.prim) {
const s = inst.prim;
- block += `${cid} [label=${cellLabel(cid, { name: s.name, type: s.type, params: s.params, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`;
+ block += `${cid} [label=${cellLabel(cid, { name: s.name, type: s.type, params: s.params, bound: s.bound, ins: s.ins, outs: s.outs, bodyBg: bodyBgFor(s.role), composite: false }, showTypes)}];\n`;
addInfo(INFO, cid, s);
if (s.role === "source") topRank.push(cid); // sinks are placed freely (not rank-pinned)
childRes[key] = { type: "leaf", id: cid, spec: s };
diff --git a/crates/aura-cli/tests/fixtures/sample-model.json b/crates/aura-cli/tests/fixtures/sample-model.json
index 6eb7a19..f2bde42 100644
--- a/crates/aura-cli/tests/fixtures/sample-model.json
+++ b/crates/aura-cli/tests/fixtures/sample-model.json
@@ -1 +1 @@
-{"root":{"nodes":{"0":{"comp":"signals"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"SimBroker","role":"node","params":[],"ins":[["f64","any","exposure"],["f64","any","price"]],"outs":[["equity","f64"]]}},"3":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"4":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["2.o0","3.i0"],["1.o0","4.i0"],["src_price.o0","0.i0"],["src_price.o0","2.i1"]]},"composites":{"signals":{"inputs":[["f64","any","price"]],"outputs":[["signal","f64"]],"nodes":{"0":{"comp":"trend"},"1":{"comp":"momentum"},"2":{"prim":{"name":"blend","type":"LinComb","role":"node","params":[["weights[0]","f64"],["weights[1]","f64"]],"ins":[["f64","any","term[0]"],["f64","any","term[1]"],["f64","any","term[2]"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o2","2.i1"],["1.o1","2.i2"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]},"trend":{"inputs":[["f64","any","price"]],"outputs":[["cross","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]},"momentum":{"inputs":[["f64","any","price"]],"outputs":[["macd","f64"],["signal","f64"],["histogram","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}},"3":{"prim":{"name":"signal","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"4":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["2.o0","3.i0"],["2.o0","4.i0"],["3.o0","4.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"],["3.o0","#1"],["4.o0","#2"]]}}}
+{"root":{"nodes":{"0":{"comp":"signals"},"1":{"prim":{"type":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"SimBroker","role":"node","params":[],"ins":[["f64","any","exposure"],["f64","any","price"]],"outs":[["equity","f64"]]}},"3":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"4":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["2.o0","3.i0"],["1.o0","4.i0"],["src_price.o0","0.i0"],["src_price.o0","2.i1"]]},"composites":{"signals":{"inputs":[["f64","any","price"]],"outputs":[["signal","f64"]],"nodes":{"0":{"comp":"trend"},"1":{"comp":"momentum"},"2":{"prim":{"name":"blend","type":"LinComb","role":"node","params":[["weights[0]","f64"],["weights[1]","f64"]],"bound":[[2,"weights[2]","f64","0.5"]],"ins":[["f64","any","term[0]"],["f64","any","term[1]"],["f64","any","term[2]"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o2","2.i1"],["1.o1","2.i2"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]},"trend":{"inputs":[["f64","any","price"]],"outputs":[["cross","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]},"momentum":{"inputs":[["f64","any","price"]],"outputs":[["macd","f64"],["signal","f64"],["histogram","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}},"3":{"prim":{"name":"signal","type":"EMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"4":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["2.o0","3.i0"],["2.o0","4.i0"],["3.o0","4.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"],["3.o0","#1"],["4.o0","#2"]]}}}
diff --git a/crates/aura-cli/tests/viewer_bound_param.mjs b/crates/aura-cli/tests/viewer_bound_param.mjs
new file mode 100644
index 0000000..f5a90a8
--- /dev/null
+++ b/crates/aura-cli/tests/viewer_bound_param.mjs
@@ -0,0 +1,72 @@
+// Headless render guard for the `aura graph` viewer (graph-viewer.js).
+//
+// Property protected: a bind-bound param slot renders in the node signature as a
+// DIMMED `name=value`, in TRUE slot order (by original position), with the free
+// slots in their type colour — never dropped. Covers a trailing bind (the
+// cycle-0036 showcase shape) AND a non-trailing (middle) bind, which an
+// append-after-free design would render in the wrong order.
+//
+// It loads the real viewer module and drives the exported pure `genDot`. `node`
+// is REQUIRED: a skipped guard is not a guard.
+
+import { createRequire } from "node:module";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+const require = createRequire(import.meta.url);
+const here = dirname(fileURLToPath(import.meta.url));
+
+// Load the viewer once with an empty model; normalizeModel/genDot are pure and
+// take the model/def explicitly, so each case is rendered by passing its own.
+global.window = { AURA_MODEL: { root: { nodes: {}, edges: [] }, composites: {} } };
+const { normalizeModel, genDot } = require(join(here, "..", "assets", "graph-viewer.js"));
+const dotFor = (model) => genDot(normalizeModel(model).root, "root", new Set(), false).dot;
+
+const fail = (msg, dot) => {
+ console.error(msg + "\n--- dot ---\n" + dot);
+ process.exit(1);
+};
+
+// --- Model A: trailing bind (the cycle-0036 showcase shape) ---
+const trailing = {
+ root: { nodes: { "0": { prim: {
+ name: "blend", type: "LinComb", role: "node",
+ params: [["weights[0]", "f64"], ["weights[1]", "f64"]],
+ bound: [[2, "weights[2]", "f64", "0.5"]],
+ ins: [["f64", "any", "term[0]"], ["f64", "any", "term[1]"], ["f64", "any", "term[2]"]],
+ outs: [["value", "f64"]],
+ } } }, edges: [] },
+ composites: {},
+};
+const dotA = dotFor(trailing);
+
+const BOUND = 'weights[2]=0.5';
+const FREE0 = 'weights[0]';
+const FREE1 = 'weights[1]';
+if (!dotA.includes(BOUND)) fail("trailing: bound slot weights[2]=0.5 not rendered dimmed", dotA);
+if (!dotA.includes(FREE0) || !dotA.includes(FREE1)) fail("trailing: a free weight slot is missing or not type-coloured", dotA);
+const a0 = dotA.indexOf(FREE0), a1 = dotA.indexOf(FREE1), ab = dotA.indexOf(BOUND);
+if (!(a0 < a1 && a1 < ab)) fail("trailing: slot order is not weights[0] < weights[1] < weights[2]=0.5", dotA);
+
+// --- Model B: NON-TRAILING (middle) bind — pins slot-order faithfulness ---
+const middle = {
+ root: { nodes: { "0": { prim: {
+ name: "mid", type: "Probe", role: "node",
+ params: [["a", "f64"], ["c", "f64"]],
+ bound: [[1, "b", "f64", "9.0"]],
+ ins: [["f64", "any", "t0"], ["f64", "any", "t1"], ["f64", "any", "t2"]],
+ outs: [["value", "f64"]],
+ } } }, edges: [] },
+ composites: {},
+};
+const dotB = dotFor(middle);
+
+const MA = 'a';
+const MB = 'b=9.0';
+const MC = 'c';
+if (!dotB.includes(MA) || !dotB.includes(MB) || !dotB.includes(MC)) fail("middle: a free or bound slot is missing", dotB);
+const ba = dotB.indexOf(MA), bb = dotB.indexOf(MB), bc = dotB.indexOf(MC);
+if (!(ba < bb && bb < bc)) fail("middle: bound slot b=9.0 not placed between free a and c (append-only bug)", dotB);
+
+console.log("OK — bound slot renders dimmed name=value in true slot order (trailing + middle bind).");
+process.exit(0);
diff --git a/crates/aura-cli/tests/viewer_bound_param.rs b/crates/aura-cli/tests/viewer_bound_param.rs
new file mode 100644
index 0000000..a73c487
--- /dev/null
+++ b/crates/aura-cli/tests/viewer_bound_param.rs
@@ -0,0 +1,37 @@
+//! Integration guard: the `aura graph` viewer renders a bind-bound param slot as
+//! a dimmed `name=value` in true slot order (trailing + non-trailing bind).
+//!
+//! The property lives in JavaScript (`cellLabel` in assets/graph-viewer.js), so
+//! this shells out to `node` running the headless guard
+//! `tests/viewer_bound_param.mjs`, which loads the real viewer module and drives
+//! the exported `genDot` over two minimal models.
+//!
+//! `node` is REQUIRED: if it is not on PATH this test FAILS (a skipped guard is
+//! not a guard).
+
+use std::path::PathBuf;
+use std::process::Command;
+
+#[test]
+fn viewer_renders_bound_param_in_slot_order() {
+ let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("tests")
+ .join("viewer_bound_param.mjs");
+ assert!(script.exists(), "guard script missing at {}", script.display());
+
+ let out = match Command::new("node").arg(&script).output() {
+ Ok(out) => out,
+ Err(e) => panic!(
+ "node is required for the viewer bound-param guard but could not be run \
+ ({e}); install Node.js or ensure `node` is on PATH"
+ ),
+ };
+
+ let stdout = String::from_utf8_lossy(&out.stdout);
+ let stderr = String::from_utf8_lossy(&out.stderr);
+ assert!(
+ out.status.success(),
+ "viewer bound-param guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
+ out.status.code()
+ );
+}
diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs
index 3ba5792..5da83c8 100644
--- a/crates/aura-core/src/node.rs
+++ b/crates/aura-core/src/node.rs
@@ -66,6 +66,18 @@ pub struct ParamSpec {
pub kind: ScalarKind,
}
+/// One param bound to a structural constant by [`PrimitiveBuilder::bind`] — a
+/// render/debug surface only (C23), dropped at lowering. `pos` is the slot's
+/// position in the node's ORIGINAL (pre-bind) param list, so the model serializer
+/// and viewer can place it back into slot order regardless of bind sequence.
+#[derive(Clone, Debug, PartialEq)]
+pub struct BoundParam {
+ pub pos: usize,
+ pub name: String,
+ pub kind: ScalarKind,
+ pub value: Scalar,
+}
+
/// A param-generic blueprint **primitive** recipe (C19): a node's full declared
/// signature (`NodeSchema` — inputs/output/params) plus a closure that builds a
/// sized instance through the node's own constructor (the single sizing/validation
@@ -78,6 +90,7 @@ pub struct PrimitiveBuilder {
name: &'static str,
instance_name: Option,
schema: NodeSchema,
+ bound: Vec,
// The build closure's type is exactly the recipe contract (a param slice in, a
// boxed node out); a type alias would not clarify it.
#[allow(clippy::type_complexity)]
@@ -93,7 +106,7 @@ impl PrimitiveBuilder {
schema: NodeSchema,
build: impl Fn(&[Scalar]) -> Box + 'static,
) -> Self {
- Self { name, instance_name: None, schema, build: Box::new(build) }
+ Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) }
}
/// Set this node instance's explicit name. Must be non-empty: the name forms
/// a knob-address segment (`..`, via `node_name()` in
@@ -120,6 +133,14 @@ impl PrimitiveBuilder {
pub fn instance_name(&self) -> Option<&str> {
self.instance_name.as_deref()
}
+ /// The params bound to structural constants by [`bind`](Self::bind), in
+ /// bind-call order, each carrying its ORIGINAL slot position. The render-
+ /// surface twin of [`instance_name`](Self::instance_name): the model
+ /// serializer reads it to show a bound slot's fixed value (the value captured
+ /// in the build closure is otherwise unreachable to it). Empty when no binds.
+ pub fn bound_params(&self) -> &[BoundParam] {
+ &self.bound
+ }
/// The full declared signature (read pre-build by `Composite::param_space`,
/// `BlueprintNode::signature`, and the renderer).
pub fn schema(&self) -> &NodeSchema {
@@ -170,6 +191,22 @@ impl PrimitiveBuilder {
self.schema.params[pos].kind,
"bind: kind mismatch for param `{slot}`",
);
+ // [render side] record the bound slot for the model/viewer at its ORIGINAL
+ // pre-bind position. `pos` indexes the already-shrunk array (earlier binds
+ // removed), so map it back to the full-arity index by adding one for each
+ // earlier-bound slot at an original position <= it. Render/debug only (C23);
+ // the closure capture below is what bootstrap actually reads.
+ let name = self.schema.params[pos].name.clone();
+ let kind = self.schema.params[pos].kind;
+ let mut orig = pos;
+ let mut prior: Vec = self.bound.iter().map(|b| b.pos).collect();
+ prior.sort_unstable();
+ for p in prior {
+ if p <= orig {
+ orig += 1;
+ }
+ }
+ self.bound.push(BoundParam { pos: orig, name, kind, value });
self.schema.params.remove(pos); // [param_space side] shrink the declared surface
let inner = self.build; // [value side] wrap the build closure
self.build = Box::new(move |open: &[Scalar]| {
@@ -407,6 +444,32 @@ mod tests {
);
}
+ #[test]
+ fn bind_records_bound_param_at_original_position() {
+ // middle-of-three: binding `b` records its ORIGINAL slot position (1).
+ let mid = probe3().bind("b", Scalar::I64(8));
+ assert_eq!(
+ mid.bound_params(),
+ [BoundParam { pos: 1, name: "b".into(), kind: ScalarKind::I64, value: Scalar::I64(8) }]
+ .as_slice(),
+ );
+
+ // chained reverse-order bind (b then a): each entry carries its ORIGINAL
+ // position regardless of the shrinking array — positions {1, 0} in call order.
+ let pair = probe3().bind("b", Scalar::I64(8)).bind("a", Scalar::I64(7));
+ assert_eq!(
+ pair.bound_params(),
+ [
+ BoundParam { pos: 1, name: "b".into(), kind: ScalarKind::I64, value: Scalar::I64(8) },
+ BoundParam { pos: 0, name: "a".into(), kind: ScalarKind::I64, value: Scalar::I64(7) },
+ ]
+ .as_slice(),
+ );
+
+ // an unbound builder records nothing.
+ assert!(probe3().bound_params().is_empty());
+ }
+
#[test]
#[should_panic(expected = "no open param named")]
fn bind_unknown_slot_panics() {
diff --git a/crates/aura-engine/src/graph_model.rs b/crates/aura-engine/src/graph_model.rs
index 8ba8460..f31fd46 100644
--- a/crates/aura-engine/src/graph_model.rs
+++ b/crates/aura-engine/src/graph_model.rs
@@ -20,6 +20,20 @@ fn kind_str(k: ScalarKind) -> &'static str {
}
}
+/// A scalar VALUE as a canonical, deterministic string for the model (C14). `f64`
+/// uses the shortest round-trippable debug form, which keeps a decimal point on
+/// whole values (`1.0`, not `1`) so a bound f64 is never mistaken for an i64 in
+/// the render. The value side of `kind_str`.
+fn scalar_str(s: aura_core::Scalar) -> String {
+ use aura_core::Scalar;
+ match s {
+ Scalar::I64(n) => n.to_string(),
+ Scalar::F64(f) => format!("{f:?}"),
+ Scalar::Bool(b) => b.to_string(),
+ Scalar::Ts(t) => t.0.to_string(),
+ }
+}
+
/// One input port's firing policy as a model string: `"any"` or `"barrier "`.
fn firing_str(f: Firing) -> String {
match f {
@@ -81,8 +95,24 @@ fn prim_record(b: &aura_core::PrimitiveBuilder) -> String {
Some(n) => format!(r#""name":{},"#, json_str(n)),
None => String::new(),
};
+ // bound params: present only when the node has binds (mirrors the conditional
+ // "name" field). each entry: [pos,"name","kind","value"].
+ let bound = if b.bound_params().is_empty() {
+ String::new()
+ } else {
+ let items = join(b.bound_params().iter().map(|bp| {
+ format!(
+ "[{},{},{},{}]",
+ bp.pos,
+ json_str(&bp.name),
+ json_str(kind_str(bp.kind)),
+ json_str(&scalar_str(bp.value)),
+ )
+ }));
+ format!(r#","bound":[{items}]"#)
+ };
format!(
- r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#,
+ r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}]{bound},"ins":[{ins}],"outs":[{outs}]}}}}"#,
json_str(&b.label()),
json_str(role),
)
@@ -393,6 +423,23 @@ mod tests {
assert!(!unnamed.contains(r#""name""#), "{unnamed}");
}
+ #[test]
+ fn prim_record_emits_bound_only_when_bound() {
+ // a bound i64 param → a "bound" field carrying [pos,"name","kind","value"]
+ let bound = prim_record(&Sma::builder().bind("length", Scalar::I64(5)));
+ assert!(bound.contains(r#""bound":[[0,"length","i64","5"]]"#), "{bound}");
+ // the bound slot also leaves the tunable "params" surface (bind's tuning side)
+ assert!(bound.contains(r#""params":[]"#), "{bound}");
+
+ // a bound f64 value renders canonically (shortest round-trip), e.g. 0.5
+ let f = prim_record(&Exposure::builder().bind("scale", Scalar::F64(0.5)));
+ assert!(f.contains(r#""bound":[[0,"scale","f64","0.5"]]"#), "{f}");
+
+ // an unbound builder → no "bound" field at all
+ let unbound = prim_record(&Sma::builder());
+ assert!(!unbound.contains(r#""bound""#), "{unbound}");
+ }
+
// -- Task 3: scope (index keys + synthetic sources + edges) ------------------
#[test]