behavior-contracts 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -242,6 +242,22 @@ pub struct ${batch} {
242
242
  pub items: Vec<${name}>,
243
243
  }`;
244
244
  }
245
+ /** emitFanoutPortsStructs (v3, rust twin) — per-id element ports struct + batch struct for a fanout node. */
246
+ function emitFanoutPortsStructs(comp, node, typedNodes, plan) {
247
+ const f = node.fanout;
248
+ const name = portsStructName(comp.name, node.id);
249
+ const batch = `${name}Batch`;
250
+ const { elemRef } = fanoutOverElemInfo(comp, node, typedNodes, plan);
251
+ const asBinding = { name: f.as, ref: elemRef, plan };
252
+ const elemStruct = emitPortsStruct(comp, { id: node.id, component: f.component, ports: f.ports }, asBinding, plan);
253
+ return `${elemStruct}
254
+
255
+ // ${batch} — CONCRETE batched ports for fanout '${node.id}': the Vec of per-id CONCRETE ports structs.
256
+ #[derive(Clone)]
257
+ pub struct ${batch} {
258
+ pub items: Vec<${name}>,
259
+ }`;
260
+ }
245
261
  // ── native expression compiler backend (#108) — cond `if` / map `when` / cond branches (Rust) ────────
246
262
  //
247
263
  // The runtime-free native expression compiler (native-expr.ts) mirrors run_behavior's cond/guard with
@@ -440,6 +456,13 @@ function emitRawRowStructs(comp, typedNodes, plan) {
440
456
  if ("cond" in n)
441
457
  continue; // #108: a cond node has no handler-result row (pure Expression).
442
458
  const ref = typedNodes.get(n.id);
459
+ if ("fanout" in n) {
460
+ const elemRowRef = fanoutItemsElemRef(n, ref, plan);
461
+ parts.push(emitOneRowStruct(rawElemStructName(comp.name, n.id), elemRowRef, plan));
462
+ parts.push(`// ${rawRowStructName(comp.name, n.id)} — the BATCH row for fanout '${n.id}': the aligned per-body rows.\n` +
463
+ `#[derive(Default)]\npub struct ${rawRowStructName(comp.name, n.id)} {\n pub is_error: bool,\n pub err: String,\n pub rows: Vec<${rawElemStructName(comp.name, n.id)}>,\n}`);
464
+ continue;
465
+ }
443
466
  if ("map" in n) {
444
467
  const arrRef = ref;
445
468
  const elemRowRef = mapElemRowRef(n, arrRef, plan);
@@ -541,6 +564,13 @@ function emitHandlerTrait(comp, typedNodes, plan, ap) {
541
564
  const method = nodeMethodName(n.id);
542
565
  const fnKw = fnKwFor(n.id);
543
566
  const bt = boundRustType(n, typedNodes, plan);
567
+ if ("fanout" in n) {
568
+ // fanout (v3): one batched dispatch of the deduped id list — batch ports IN, aligned batch row OUT.
569
+ const portsT = `${portsStructName(comp.name, n.id)}Batch`;
570
+ const retT = rawRowStructName(comp.name, n.id);
571
+ lines.push(` ${fnKw} ${method}(&self, ports: &${portsT}, bound: ${bt}) -> Option<${retT}>;`);
572
+ continue;
573
+ }
544
574
  if ("map" in n) {
545
575
  const m = n.map;
546
576
  const batched = m.batched === true;
@@ -773,6 +803,63 @@ function coveredMapNode(n, bodyIds, comp) {
773
803
  return false;
774
804
  return true;
775
805
  }
806
+ /** coveredFanoutNode (v3, rust twin) — a first-class fanout node is covered when its over is a covered
807
+ * ref (prior-node arr / input array with elemType) and its element ports are static. See the go twin. */
808
+ function coveredFanoutNode(n, bodyIds, comp) {
809
+ if (n === null || typeof n !== "object" || !("fanout" in n))
810
+ return false;
811
+ const f = n.fanout;
812
+ if (f.policy !== undefined && f.policy !== "fail")
813
+ return false;
814
+ if (f.relationKind !== "connection")
815
+ return false;
816
+ const overE = classifyExpr(f.over);
817
+ if (overE.kind !== "ref")
818
+ return false;
819
+ const head = overE.path[0];
820
+ if (!bodyIds.has(head)) {
821
+ if (overE.path.length !== 1)
822
+ return false;
823
+ const schema = comp.inputPorts?.[head];
824
+ if (schema === undefined || schema.elemType === undefined)
825
+ return false;
826
+ }
827
+ const ports = f.ports;
828
+ for (const p of Object.keys(ports))
829
+ if (!portIsStatic(ports[p]))
830
+ return false;
831
+ return true;
832
+ }
833
+ /** fanoutItemsElemRef (v3, rust twin) — the connection items element TypeRef for a covered fanout node. */
834
+ function fanoutItemsElemRef(node, connRef, plan) {
835
+ if (connRef.kind !== "named")
836
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust fanout (v3): node '${node.id}' outType must be a connection struct {items,cursor} (got ${JSON.stringify(connRef.kind)}).`);
837
+ const decl = findDecl(plan, connRef.name);
838
+ const itemsField = decl.fields.find((fd) => fd.name === "items");
839
+ if (!itemsField || itemsField.type.kind !== "arr")
840
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust fanout (v3): node '${node.id}' connection struct '${connRef.name}' has no 'items' array field.`);
841
+ return itemsField.type.elem;
842
+ }
843
+ /** fanoutOverElemInfo (v3, rust twin) — the over id-list element TypeRef + the OWNED over-Vec expr. */
844
+ function fanoutOverElemInfo(comp, node, typedNodes, plan) {
845
+ const f = node.fanout;
846
+ const e = classifyExpr(f.over);
847
+ if (e.kind === "ref" && e.path.length >= 1 && typedNodes.has(e.path[0])) {
848
+ const baseRef = typedNodes.get(e.path[0]);
849
+ const { ref } = typedFieldAccess(`${typedCell(e.path[0])}.borrow()`, baseRef, e.path.slice(1), plan);
850
+ if (ref.kind === "arr") {
851
+ const fieldPath = e.path.slice(1).map((p) => `.${rustFieldName(p)}`).join("");
852
+ return { elemRef: ref.elem, overExpr: `${typedCell(e.path[0])}.borrow()${fieldPath}.clone()` };
853
+ }
854
+ }
855
+ if (e.kind === "ref" && e.path.length === 1) {
856
+ const schema = comp.inputPorts?.[e.path[0]];
857
+ const et = schema?.elemType;
858
+ if (et !== undefined)
859
+ return { elemRef: deriveTypeRef(et, plan), overExpr: `in_.${rustFieldName(e.path[0])}.clone()` };
860
+ }
861
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust fanout (v3): fanout '${node.id}' 'over' must be a ref to a prior node's typed arr field OR an input array port with a declared elemType.`);
862
+ }
776
863
  /** A covered read SHAPE with static ports + native-lowerable output (+ covered map...into), OR a
777
864
  * real-concurrency staged plan (bc#87) whose parallel-stage members are plain componentRef point reads. */
778
865
  function isSequentialComponentRefRead(comp) {
@@ -809,6 +896,11 @@ function coverageRejectReason(comp) {
809
896
  return `node '${n.id}' cond has no outType (required to de-box the branch join)`;
810
897
  continue;
811
898
  }
899
+ if ("fanout" in n) {
900
+ if (!coveredFanoutNode(n, bodyIds, comp))
901
+ return `node '${n.id}' is a non-covered fanout shape (over an input array without elemType, or a dynamic element port)`;
902
+ continue;
903
+ }
812
904
  if ("map" in n) {
813
905
  if (!coveredMapNode(n, bodyIds, comp))
814
906
  return `node '${n.id}' is a non-covered map shape (guard+into heterogeneous, or over an input array without elemType)`;
@@ -1112,6 +1204,14 @@ function emitNativeRawRunner(comp, plan, ap) {
1112
1204
  };
1113
1205
  return [...order.keys()].some((k) => {
1114
1206
  const nd = comp.body[order[k]];
1207
+ if ("fanout" in nd) {
1208
+ const fo = nd.fanout;
1209
+ const bound = new Set([fo.as]);
1210
+ // the fanout's OVER is evaluated in the outer scope (may read input); ports in element scope.
1211
+ if (walk(fo.over, k, new Set()))
1212
+ return true;
1213
+ return Object.values(fo.ports).some((pn) => walk(pn, k, bound));
1214
+ }
1115
1215
  if ("map" in nd) {
1116
1216
  const m = nd.map;
1117
1217
  const bound = new Set([m.as]);
@@ -1152,7 +1252,7 @@ function emitNativeRawRunner(comp, plan, ap) {
1152
1252
  // default emits `pub fn` exactly as before (byte-identical).
1153
1253
  const fnKw = isAsync ? "pub async fn" : "pub fn";
1154
1254
  // #108: a component whose body is ALL cond nodes dispatches NO handler — bind `_handlers` (clippy-clean).
1155
- const dispatchesHandler = comp.body.some((n) => "map" in n || "component" in n);
1255
+ const dispatchesHandler = comp.body.some((n) => "map" in n || "fanout" in n || "component" in n);
1156
1256
  lines.push(`${fnKw} run_native_raw_struct_${fn}${bound}(`);
1157
1257
  lines.push(` ${dispatchesHandler ? "handlers" : "_handlers"}: &H,`);
1158
1258
  lines.push(` ${inParam}: ${inStructName(comp.name)},`);
@@ -1171,6 +1271,10 @@ function emitNativeRawRunner(comp, plan, ap) {
1171
1271
  k += stage.length - 1;
1172
1272
  continue;
1173
1273
  }
1274
+ if ("fanout" in comp.body[i]) {
1275
+ lines.push(emitFanoutArm(comp, comp.body[i], k, typedNodes, plan, priorNodeAt, ap.nodeIsAsync(comp.body[i].id)));
1276
+ continue;
1277
+ }
1174
1278
  if ("map" in comp.body[i]) {
1175
1279
  lines.push(emitMapArm(comp, comp.body[i], k, typedNodes, plan, priorNodeAt, ap.nodeIsAsync(comp.body[i].id)));
1176
1280
  continue;
@@ -1601,6 +1705,135 @@ isAsync = false) {
1601
1705
  lines.push(c);
1602
1706
  return lines.join("\n");
1603
1707
  }
1708
+ /**
1709
+ * emitFanoutArm (v3, rust twin) — the struct-native exec of a covered FANOUT node: build the per-id
1710
+ * ports (all over ids), dispatch the batched handler ONCE (aligned per-body rows), then apply THE SAME
1711
+ * dedupe/drop as run_behavior's fanoutDedupDrop — first-seen dedupe by the elem's dedupeKey field, drop
1712
+ * dangling (empty dedupeKey ≡ the interpreter's null/absent-key body), implicitSource strip (STRUCTURAL:
1713
+ * the elem type already omits it), wrap into the connection struct { items, cursor: None }.
1714
+ */
1715
+ function emitFanoutArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync = false) {
1716
+ const f = node.fanout;
1717
+ const awaitSfx = isAsync ? ".await" : "";
1718
+ const s = sanitize(node.id);
1719
+ const structName = portsStructName(comp.name, node.id);
1720
+ const batch = `${structName}Batch`;
1721
+ const connRef = typedNodes.get(node.id);
1722
+ const elemRef = fanoutItemsElemRef(node, connRef, plan);
1723
+ const elemName = renderTypeRef(elemRef);
1724
+ const { elemRef: overElemRef, overExpr } = fanoutOverElemInfo(comp, node, typedNodes, plan);
1725
+ const asName = f.as;
1726
+ const priorHere = (head) => priorNodeAt(head, atPos);
1727
+ const portNames = Object.keys(f.ports);
1728
+ if (elemRef.kind !== "named")
1729
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust fanout (v3): node '${node.id}' connection items element must be a named struct (got ${JSON.stringify(elemRef.kind)}).`);
1730
+ const elemDecl = findDecl(plan, elemRef.name);
1731
+ const dkField = elemDecl.fields.find((fd) => fd.name === f.dedupeKey);
1732
+ if (!dkField || dkField.type.kind !== "scalar" || dkField.type.scalar !== "string")
1733
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust fanout (v3): node '${node.id}' dedupeKey '${f.dedupeKey}' must be a string field of the connection items element type '${elemRef.name}'.`);
1734
+ const dkRust = rustFieldName(f.dedupeKey);
1735
+ const resolveRef = (head, restPath, opt) => {
1736
+ if (opt)
1737
+ return null;
1738
+ let baseExpr;
1739
+ let baseRef;
1740
+ if (head === asName) {
1741
+ baseExpr = `oel_${s}`;
1742
+ baseRef = overElemRef;
1743
+ }
1744
+ else if (priorHere(head)) {
1745
+ baseExpr = `${typedCell(head)}.borrow()`;
1746
+ baseRef = typedNodes.get(head);
1747
+ }
1748
+ else {
1749
+ if (restPath.length !== 0)
1750
+ return null;
1751
+ const scalar = inputScalarKind(comp.inputPorts?.[head]);
1752
+ if (scalar === undefined)
1753
+ return null;
1754
+ const field = `in_.${rustFieldName(head)}`;
1755
+ return { expr: scalar === "String" ? `${field}.clone()` : field, scalar };
1756
+ }
1757
+ let acc;
1758
+ let expr;
1759
+ try {
1760
+ const r = typedFieldAccess(baseExpr, baseRef, restPath, plan);
1761
+ expr = r.expr;
1762
+ acc = r.ref;
1763
+ }
1764
+ catch {
1765
+ return null;
1766
+ }
1767
+ if (acc.kind !== "scalar" || acc.scalar === "null")
1768
+ return null;
1769
+ const isBareRef = restPath.length === 0;
1770
+ let owned;
1771
+ if (acc.scalar === "string")
1772
+ owned = isBareRef ? `(*${expr}).clone()` : `${expr}.clone()`;
1773
+ else
1774
+ owned = isBareRef ? `*${expr}` : expr;
1775
+ return { expr: owned, scalar: rustScalar(acc.scalar) };
1776
+ };
1777
+ const buildPorts = (il) => {
1778
+ const inits = [];
1779
+ const asBinding = { name: asName, ref: overElemRef, plan };
1780
+ for (const pn of portNames) {
1781
+ const ty = portFieldRustType(f.ports[pn], comp.inputPorts, `fanout port '${pn}' of node '${node.id}'`, asBinding, plan);
1782
+ inits.push(emitPortInit(pn, f.ports[pn], ty, resolveRef, comp.inputPorts, plan));
1783
+ }
1784
+ return [`${il}let ep_${s} = ${structName} { ${inits.join(", ")} };`];
1785
+ };
1786
+ const lines = [];
1787
+ const closers = [];
1788
+ lines.push(` // ── fanout '${node.id}' (${f.component}, dedupeKey:${JSON.stringify(f.dedupeKey)}, drop:${JSON.stringify(f.drop)}${f.implicitSource ? ", implicitSource:" + JSON.stringify(f.implicitSource) : ""}${f.parent ? ", parent:" + f.parent : ""}) ──`);
1789
+ let indent = " ";
1790
+ if (f.parent !== undefined && priorNodeAt(f.parent, atPos)) {
1791
+ lines.push(`${indent}if produced_${sanitize(f.parent)}.get() {`);
1792
+ closers.unshift(`${indent}}`);
1793
+ indent += " ";
1794
+ }
1795
+ lines.push(`${indent}let over_${s} = ${overExpr};`);
1796
+ lines.push(`${indent}let mut built_${s}: Vec<${elemName}> = Vec::new();`);
1797
+ lines.push(`${indent}let mut pi_${s}: Vec<${structName}> = Vec::with_capacity(over_${s}.len());`);
1798
+ lines.push(`${indent}for oel_${s} in over_${s}.iter() {`);
1799
+ for (const l of buildPorts(indent + " "))
1800
+ lines.push(l);
1801
+ lines.push(`${indent} pi_${s}.push(ep_${s});`);
1802
+ lines.push(`${indent}}`);
1803
+ lines.push(`${indent}if !pi_${s}.is_empty() {`);
1804
+ lines.push(`${indent} let want_${s} = pi_${s}.len();`);
1805
+ lines.push(`${indent} let bp_${s} = ${batch} { items: pi_${s} };`);
1806
+ lines.push(`${indent} let row_${s} = match handlers.${nodeMethodName(node.id)}(&bp_${s}, None)${awaitSfx} {`);
1807
+ lines.push(`${indent} Some(r) => r,`);
1808
+ lines.push(`${indent} None => return Err(unknown_component(${rustStrLit(f.component)})),`);
1809
+ lines.push(`${indent} };`);
1810
+ lines.push(`${indent} if row_${s}.is_error {`);
1811
+ lines.push(`${indent} return Err(BehaviorError::new("OP_FAILED", format!("operation '{}' failed under 'fail' policy: {}", ${rustStrLit(node.id)}, row_${s}.err)));`);
1812
+ lines.push(`${indent} }`);
1813
+ lines.push(`${indent} if row_${s}.rows.len() != want_${s} {`);
1814
+ lines.push(`${indent} return Err(BehaviorError::new("FANOUT_BATCH_RESULT_MISMATCH", format!("fanout '{}': batched handler must return a list aligned to the deduped id list (want {})", ${rustStrLit(node.id)}, want_${s})));`);
1815
+ lines.push(`${indent} }`);
1816
+ // ── THE ONE dedupe/drop (mirrors fanoutDedupDrop verbatim): first-seen dedupe by dedupeKey field;
1817
+ // drop dangling (empty dedupeKey ≡ interpreter's null/absent-key body); implicitSource strip is
1818
+ // structural (the elem type already omits it). cursor is always None (connection wrap).
1819
+ lines.push(`${indent} let mut seen_${s}: std::collections::HashSet<String> = std::collections::HashSet::new();`);
1820
+ lines.push(`${indent} for er_${s} in row_${s}.rows.into_iter() {`);
1821
+ lines.push(`${indent} ${emitElemMove(`let el_${s}`, `er_${s}`, elemRef, plan)}`);
1822
+ lines.push(`${indent} let key_${s} = el_${s}.${dkRust}.clone();`);
1823
+ if (f.drop === "dangling") {
1824
+ lines.push(`${indent} if key_${s}.is_empty() { continue; }`);
1825
+ }
1826
+ lines.push(`${indent} if seen_${s}.contains(&key_${s}) { continue; }`);
1827
+ lines.push(`${indent} seen_${s}.insert(key_${s});`);
1828
+ lines.push(`${indent} built_${s}.push(el_${s});`);
1829
+ lines.push(`${indent} }`);
1830
+ lines.push(`${indent}}`);
1831
+ lines.push(`${indent}*${typedCell(node.id)}.borrow_mut() = ${connRef.name} { ${rustFieldName("items")}: built_${s}, ..Default::default() };`);
1832
+ lines.push(`${indent}produced_${sanitize(node.id)}.set(true);`);
1833
+ for (const c of closers)
1834
+ lines.push(c);
1835
+ return lines.join("\n");
1836
+ }
1604
1837
  /** #108: resolve an INPUT port head for a native-expr ref (rust). Scalar → `in_.<field>` (owned: String
1605
1838
  * cloned, Copy as-is); array WITH elemType → `in_.<field>.clone()` of Vec<ElemT> (for `len`); else null. */
1606
1839
  function rustInputHeadRef(head, comp, plan) {
@@ -1800,7 +2033,9 @@ ${emitStructSurface([])}
1800
2033
  for (const n of c.body)
1801
2034
  typedNodes.set(n.id, deriveTypeRef(n.outType, plan));
1802
2035
  for (const n of c.body) {
1803
- if ("map" in n)
2036
+ if ("fanout" in n)
2037
+ structs.push(emitFanoutPortsStructs(c, n, typedNodes, plan));
2038
+ else if ("map" in n)
1804
2039
  structs.push(emitMapPortsStructs(c, n, typedNodes, plan));
1805
2040
  else if ("cond" in n) {
1806
2041
  // #108: a cond node has NO ports struct (pure Expression — no handler dispatch).
@@ -1904,6 +2139,34 @@ export function rustTypedNativeObserve(ir, ioModel = "sync") {
1904
2139
  // bc#90 / runtime-free: the scripted impl's `_bound` param mirrors the trait's typed produced-aware
1905
2140
  // Option<K> (NOT a boxed Value) — computed per node so the impl signature matches the trait.
1906
2141
  const bt = boundRustType(n, typedNodes, plan);
2142
+ if ("fanout" in n) {
2143
+ // v3: scripted fanout impl — drain one batched outcome, decode the aligned array into per-body
2144
+ // elem rows (a null array element decodes to the zero elem row = dangling → dropped by the arm).
2145
+ const f = n.fanout;
2146
+ const elemRowRef = fanoutItemsElemRef(n, ref, plan);
2147
+ const elemT = rawElemStructName(c.name, n.id);
2148
+ const batchT = rawRowStructName(c.name, n.id);
2149
+ const elemDecode = emitDecodeRow(c.name, n.id, elemT, elemRowRef, plan, emittedDecode, decodeFns, "Elem");
2150
+ const portsT = `${portsStructName(c.name, n.id)}Batch`;
2151
+ methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${batchT}> {
2152
+ let (is_err, err, ok) = self.next(${rustStrLit(f.component)})?;
2153
+ if is_err {
2154
+ return Some(${batchT} { is_error: true, err, ..Default::default() });
2155
+ }
2156
+ let arr = match ok { Some(Value::Arr(a)) => a, _ => Vec::new() };
2157
+ // v3: a null aligned body is a DANGLING id — decode it to the zero elem row (whose dedupeKey
2158
+ // field is empty), so the runner's dangling-drop excludes it (byte-equal to fanoutDedupDrop).
2159
+ let rows = arr
2160
+ .iter()
2161
+ .map(|ev| match ev {
2162
+ Value::Null => ${elemT}::default(),
2163
+ _ => ${elemDecode}(ev),
2164
+ })
2165
+ .collect();
2166
+ Some(${batchT} { rows, ..Default::default() })
2167
+ }`);
2168
+ continue;
2169
+ }
1907
2170
  if ("map" in n) {
1908
2171
  const m = n.map;
1909
2172
  const batched = m.batched === true;