behavior-contracts 0.7.2 → 0.8.0

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.
Files changed (68) hide show
  1. package/README.md +0 -1
  2. package/dist/authoring.d.ts +215 -3
  3. package/dist/authoring.d.ts.map +1 -1
  4. package/dist/authoring.js +338 -8
  5. package/dist/authoring.js.map +1 -1
  6. package/dist/behavior.d.ts +101 -3
  7. package/dist/behavior.d.ts.map +1 -1
  8. package/dist/behavior.js +126 -4
  9. package/dist/behavior.js.map +1 -1
  10. package/dist/bind.d.ts +64 -0
  11. package/dist/bind.d.ts.map +1 -0
  12. package/dist/bind.js +131 -0
  13. package/dist/bind.js.map +1 -0
  14. package/dist/builder.d.ts +30 -88
  15. package/dist/builder.d.ts.map +1 -1
  16. package/dist/builder.js +114 -59
  17. package/dist/builder.js.map +1 -1
  18. package/dist/expr-operator-types.d.ts +89 -0
  19. package/dist/expr-operator-types.d.ts.map +1 -0
  20. package/dist/expr-operator-types.js +134 -0
  21. package/dist/expr-operator-types.js.map +1 -0
  22. package/dist/generator/async-plan.d.ts.map +1 -1
  23. package/dist/generator/async-plan.js +4 -0
  24. package/dist/generator/async-plan.js.map +1 -1
  25. package/dist/generator/core.d.ts +15 -0
  26. package/dist/generator/core.d.ts.map +1 -1
  27. package/dist/generator/core.js +2 -0
  28. package/dist/generator/core.js.map +1 -1
  29. package/dist/generator/emit-php.d.ts.map +1 -1
  30. package/dist/generator/emit-php.js +1 -0
  31. package/dist/generator/emit-php.js.map +1 -1
  32. package/dist/generator/emit-python.d.ts.map +1 -1
  33. package/dist/generator/emit-python.js +1 -0
  34. package/dist/generator/emit-python.js.map +1 -1
  35. package/dist/generator/emit-straightline-typed-typescript.d.ts.map +1 -1
  36. package/dist/generator/emit-straightline-typed-typescript.js +1 -0
  37. package/dist/generator/emit-straightline-typed-typescript.js.map +1 -1
  38. package/dist/generator/emit-typed-native-go.d.ts.map +1 -1
  39. package/dist/generator/emit-typed-native-go.js +364 -14
  40. package/dist/generator/emit-typed-native-go.js.map +1 -1
  41. package/dist/generator/emit-typed-native-rust.d.ts.map +1 -1
  42. package/dist/generator/emit-typed-native-rust.js +347 -21
  43. package/dist/generator/emit-typed-native-rust.js.map +1 -1
  44. package/dist/generator/emit-typescript.d.ts.map +1 -1
  45. package/dist/generator/emit-typescript.js +24 -8
  46. package/dist/generator/emit-typescript.js.map +1 -1
  47. package/dist/generator/index.d.ts +1 -1
  48. package/dist/generator/index.d.ts.map +1 -1
  49. package/dist/generator/index.js +7 -5
  50. package/dist/generator/index.js.map +1 -1
  51. package/dist/generator/straightline.d.ts.map +1 -1
  52. package/dist/generator/straightline.js +21 -1
  53. package/dist/generator/straightline.js.map +1 -1
  54. package/dist/guard.js +20 -1
  55. package/dist/guard.js.map +1 -1
  56. package/dist/index.d.ts +42 -9
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +28 -7
  59. package/dist/index.js.map +1 -1
  60. package/dist/provenance.d.ts +102 -0
  61. package/dist/provenance.d.ts.map +1 -0
  62. package/dist/provenance.js +128 -0
  63. package/dist/provenance.js.map +1 -0
  64. package/dist/type-gate.d.ts +63 -0
  65. package/dist/type-gate.d.ts.map +1 -0
  66. package/dist/type-gate.js +295 -0
  67. package/dist/type-gate.js.map +1 -0
  68. package/package.json +4 -3
@@ -178,13 +178,17 @@ function staticStringArrElems(node) {
178
178
  * Value-typed input) FAILS CLOSED — the covered read plane admits NO boxed escape (there is no `Value`
179
179
  * fallback on the native module). `where` names the emission site for the error.
180
180
  */
181
- function portFieldRustType(node, inputPorts, where = "port", asBinding, plan) {
181
+ function portFieldRustType(node, inputPorts, where = "port", asBinding, plan, typedNodes) {
182
182
  if (staticStringArrElems(node) !== null)
183
183
  return "Vec<&'static str>";
184
184
  // #110: a componentRef port bound to a runtime array input port (with a resolvable elemType) → Vec<ElemT>.
185
185
  const arrElem = portArrayElemRef(node, inputPorts, plan);
186
186
  if (arrElem !== null)
187
187
  return `Vec<${renderTypeRef(arrElem)}>`;
188
+ // #129: a leaf port that is a ref into a PRIOR NODE's typed arr outType (node→leaf array dataflow) → Vec<ElemT>.
189
+ const priorArr = priorNodeArrElemInfo(node, typedNodes, plan);
190
+ if (priorArr !== null)
191
+ return `Vec<${renderTypeRef(priorArr.elemRef)}>`;
188
192
  const num = numLiteralRustExpr(node);
189
193
  if (num !== null)
190
194
  return typeof node === "number" && Number.isInteger(node) ? "i64" : "f64";
@@ -201,13 +205,13 @@ function portFieldRustType(node, inputPorts, where = "port", asBinding, plan) {
201
205
  * reads `ports.f_<field>` DIRECTLY. The struct derives Clone (a bc#87 parallel stage clones it into the
202
206
  * per-worker dispatch). Every field is a NATIVE Rust type (bc#90 / runtime-free) — NO boxed `Value`.
203
207
  */
204
- function emitPortsStruct(comp, node, asBinding, plan) {
208
+ function emitPortsStruct(comp, node, asBinding, plan, typedNodes) {
205
209
  const name = portsStructName(comp.name, node.id);
206
210
  const portNames = Object.keys(node.ports);
207
211
  if (portNames.length === 0) {
208
212
  return `// ${name} — native ports for node '${node.id}' (${node.component}); no ports.\n#[derive(Clone)]\npub struct ${name};`;
209
213
  }
210
- const types = portNames.map((p) => portFieldRustType(node.ports[p], comp.inputPorts, `port '${p}' of node '${node.id}'`, asBinding, plan));
214
+ const types = portNames.map((p) => portFieldRustType(node.ports[p], comp.inputPorts, `port '${p}' of node '${node.id}'`, asBinding, plan, typedNodes));
211
215
  const fields = portNames
212
216
  .map((p, i) => ` pub ${portFieldName(p)}: ${types[i]}, // ${JSON.stringify(p)}`)
213
217
  .join("\n");
@@ -242,6 +246,22 @@ pub struct ${batch} {
242
246
  pub items: Vec<${name}>,
243
247
  }`;
244
248
  }
249
+ /** emitFanoutPortsStructs (v3, rust twin) — per-id element ports struct + batch struct for a fanout node. */
250
+ function emitFanoutPortsStructs(comp, node, typedNodes, plan) {
251
+ const f = node.fanout;
252
+ const name = portsStructName(comp.name, node.id);
253
+ const batch = `${name}Batch`;
254
+ const { elemRef } = fanoutOverElemInfo(comp, node, typedNodes, plan);
255
+ const asBinding = { name: f.as, ref: elemRef, plan };
256
+ const elemStruct = emitPortsStruct(comp, { id: node.id, component: f.component, ports: f.ports }, asBinding, plan);
257
+ return `${elemStruct}
258
+
259
+ // ${batch} — CONCRETE batched ports for fanout '${node.id}': the Vec of per-id CONCRETE ports structs.
260
+ #[derive(Clone)]
261
+ pub struct ${batch} {
262
+ pub items: Vec<${name}>,
263
+ }`;
264
+ }
245
265
  // ── native expression compiler backend (#108) — cond `if` / map `when` / cond branches (Rust) ────────
246
266
  //
247
267
  // The runtime-free native expression compiler (native-expr.ts) mirrors run_behavior's cond/guard with
@@ -440,6 +460,13 @@ function emitRawRowStructs(comp, typedNodes, plan) {
440
460
  if ("cond" in n)
441
461
  continue; // #108: a cond node has no handler-result row (pure Expression).
442
462
  const ref = typedNodes.get(n.id);
463
+ if ("fanout" in n) {
464
+ const elemRowRef = fanoutItemsElemRef(n, ref, plan);
465
+ parts.push(emitOneRowStruct(rawElemStructName(comp.name, n.id), elemRowRef, plan));
466
+ parts.push(`// ${rawRowStructName(comp.name, n.id)} — the BATCH row for fanout '${n.id}': the aligned per-body rows.\n` +
467
+ `#[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}`);
468
+ continue;
469
+ }
443
470
  if ("map" in n) {
444
471
  const arrRef = ref;
445
472
  const elemRowRef = mapElemRowRef(n, arrRef, plan);
@@ -541,6 +568,13 @@ function emitHandlerTrait(comp, typedNodes, plan, ap) {
541
568
  const method = nodeMethodName(n.id);
542
569
  const fnKw = fnKwFor(n.id);
543
570
  const bt = boundRustType(n, typedNodes, plan);
571
+ if ("fanout" in n) {
572
+ // fanout (v3): one batched dispatch of the deduped id list — batch ports IN, aligned batch row OUT.
573
+ const portsT = `${portsStructName(comp.name, n.id)}Batch`;
574
+ const retT = rawRowStructName(comp.name, n.id);
575
+ lines.push(` ${fnKw} ${method}(&self, ports: &${portsT}, bound: ${bt}) -> Option<${retT}>;`);
576
+ continue;
577
+ }
544
578
  if ("map" in n) {
545
579
  const m = n.map;
546
580
  const batched = m.batched === true;
@@ -651,6 +685,44 @@ function mapOverElemInfo(comp, node, typedNodes, plan) {
651
685
  function mapOverElemType(comp, node, typedNodes, plan) {
652
686
  return renderTypeRef(mapOverElemInfo(comp, node, typedNodes, plan).elemRef);
653
687
  }
688
+ /**
689
+ * priorNodeArrElemInfo (#129, rust twin) — resolve a leaf port that is a `ref` into a PRIOR BODY NODE
690
+ * whose annotated `outType` is (or reaches, through a field path) a native array, to its element TypeRef
691
+ * + the OWNED Rust Vec expression that yields it. GENERALIZES the `map…over` prior-node-arr dataflow
692
+ * (mapOverElemInfo's first branch) to ANY leaf input port: a fold/dedup leaf whose `items` port is
693
+ * `{ref:["<priorNode>"]}` (or `.items` of a prior connection node) now lowers to `Vec<ElemT>` cloned
694
+ * out of the prior node's typed cell — ZERO boxed Value on the read hot path.
695
+ *
696
+ * The element type comes STRICTLY from the prior node's EXPLICIT `outType` annotation walked through
697
+ * typedFieldAccess (BC does NOT infer — consumer-interface C3 / [[graphddb-typed-native-static-link]]):
698
+ * if the head is not a prior body node, or its outType (through the field path) is not an `arr`, this
699
+ * returns null and the caller keeps its existing fail-closed behaviour (never a silent box). A
700
+ * single-segment INPUT-array port (elemType) is a DIFFERENT covered shape (portArrayElemRef) — handled
701
+ * ahead of this. Requires the typedNodes map (prior-node outType TypeRefs) and a TypePlan.
702
+ */
703
+ function priorNodeArrElemInfo(node, typedNodes, plan) {
704
+ if (typedNodes === undefined || plan === undefined)
705
+ return null;
706
+ if (staticArrElems(node) !== null)
707
+ return null; // a static array literal — not a prior-node ref.
708
+ const e = classifyExpr(node);
709
+ if (e.kind !== "ref" || e.opt || e.path.length < 1)
710
+ return null;
711
+ if (!typedNodes.has(e.path[0]))
712
+ return null; // head is not a prior body node → not this shape.
713
+ const baseRef = typedNodes.get(e.path[0]);
714
+ let acc;
715
+ try {
716
+ acc = typedFieldAccess(`${typedCell(e.path[0])}.borrow()`, baseRef, e.path.slice(1), plan);
717
+ }
718
+ catch {
719
+ return null; // the field path does not resolve on the prior node's outType → fail-closed at caller.
720
+ }
721
+ if (acc.ref.kind !== "arr")
722
+ return null; // the prior-node result (through the path) is not an array.
723
+ // OWNED: clone the borrowed slice out of the RefCell so the ports struct owns its Vec<ElemT>.
724
+ return { elemRef: acc.ref.elem, overExpr: `${acc.expr}.clone()` };
725
+ }
654
726
  // ── eligibility ─────────────────────────────────────────────────────────────────────
655
727
  function reconstructR(e) {
656
728
  switch (e.kind) {
@@ -773,6 +845,63 @@ function coveredMapNode(n, bodyIds, comp) {
773
845
  return false;
774
846
  return true;
775
847
  }
848
+ /** coveredFanoutNode (v3, rust twin) — a first-class fanout node is covered when its over is a covered
849
+ * ref (prior-node arr / input array with elemType) and its element ports are static. See the go twin. */
850
+ function coveredFanoutNode(n, bodyIds, comp) {
851
+ if (n === null || typeof n !== "object" || !("fanout" in n))
852
+ return false;
853
+ const f = n.fanout;
854
+ if (f.policy !== undefined && f.policy !== "fail")
855
+ return false;
856
+ if (f.relationKind !== "connection")
857
+ return false;
858
+ const overE = classifyExpr(f.over);
859
+ if (overE.kind !== "ref")
860
+ return false;
861
+ const head = overE.path[0];
862
+ if (!bodyIds.has(head)) {
863
+ if (overE.path.length !== 1)
864
+ return false;
865
+ const schema = comp.inputPorts?.[head];
866
+ if (schema === undefined || schema.elemType === undefined)
867
+ return false;
868
+ }
869
+ const ports = f.ports;
870
+ for (const p of Object.keys(ports))
871
+ if (!portIsStatic(ports[p]))
872
+ return false;
873
+ return true;
874
+ }
875
+ /** fanoutItemsElemRef (v3, rust twin) — the connection items element TypeRef for a covered fanout node. */
876
+ function fanoutItemsElemRef(node, connRef, plan) {
877
+ if (connRef.kind !== "named")
878
+ 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)}).`);
879
+ const decl = findDecl(plan, connRef.name);
880
+ const itemsField = decl.fields.find((fd) => fd.name === "items");
881
+ if (!itemsField || itemsField.type.kind !== "arr")
882
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust fanout (v3): node '${node.id}' connection struct '${connRef.name}' has no 'items' array field.`);
883
+ return itemsField.type.elem;
884
+ }
885
+ /** fanoutOverElemInfo (v3, rust twin) — the over id-list element TypeRef + the OWNED over-Vec expr. */
886
+ function fanoutOverElemInfo(comp, node, typedNodes, plan) {
887
+ const f = node.fanout;
888
+ const e = classifyExpr(f.over);
889
+ if (e.kind === "ref" && e.path.length >= 1 && typedNodes.has(e.path[0])) {
890
+ const baseRef = typedNodes.get(e.path[0]);
891
+ const { ref } = typedFieldAccess(`${typedCell(e.path[0])}.borrow()`, baseRef, e.path.slice(1), plan);
892
+ if (ref.kind === "arr") {
893
+ const fieldPath = e.path.slice(1).map((p) => `.${rustFieldName(p)}`).join("");
894
+ return { elemRef: ref.elem, overExpr: `${typedCell(e.path[0])}.borrow()${fieldPath}.clone()` };
895
+ }
896
+ }
897
+ if (e.kind === "ref" && e.path.length === 1) {
898
+ const schema = comp.inputPorts?.[e.path[0]];
899
+ const et = schema?.elemType;
900
+ if (et !== undefined)
901
+ return { elemRef: deriveTypeRef(et, plan), overExpr: `in_.${rustFieldName(e.path[0])}.clone()` };
902
+ }
903
+ 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.`);
904
+ }
776
905
  /** A covered read SHAPE with static ports + native-lowerable output (+ covered map...into), OR a
777
906
  * real-concurrency staged plan (bc#87) whose parallel-stage members are plain componentRef point reads. */
778
907
  function isSequentialComponentRefRead(comp) {
@@ -809,6 +938,11 @@ function coverageRejectReason(comp) {
809
938
  return `node '${n.id}' cond has no outType (required to de-box the branch join)`;
810
939
  continue;
811
940
  }
941
+ if ("fanout" in n) {
942
+ if (!coveredFanoutNode(n, bodyIds, comp))
943
+ return `node '${n.id}' is a non-covered fanout shape (over an input array without elemType, or a dynamic element port)`;
944
+ continue;
945
+ }
812
946
  if ("map" in n) {
813
947
  if (!coveredMapNode(n, bodyIds, comp))
814
948
  return `node '${n.id}' is a non-covered map shape (guard+into heterogeneous, or over an input array without elemType)`;
@@ -977,7 +1111,7 @@ function emitNativeScalar(node, want, resolveRef) {
977
1111
  * a boxed escape would re-introduce the bc-runtime coupling this module removes). `fieldRustType` is the
978
1112
  * ports-struct field type (from portFieldRustType); `resolveRef` resolves a ref head for the scalar path.
979
1113
  */
980
- function emitPortInit(pn, expr, fieldRustType, resolveRef, inputPorts, plan) {
1114
+ function emitPortInit(pn, expr, fieldRustType, resolveRef, inputPorts, plan, typedNodes) {
981
1115
  const field = portFieldName(pn);
982
1116
  // #110: a componentRef port bound to a runtime array input port (with elemType) → the native Vec<ElemT>,
983
1117
  // cloned out of the concrete input struct (`in_.<field>.clone()`) — ZERO boxed Value on the read hot path.
@@ -987,6 +1121,12 @@ function emitPortInit(pn, expr, fieldRustType, resolveRef, inputPorts, plan) {
987
1121
  return `${field}: in_.${rustFieldName(e.path[0])}.clone()`;
988
1122
  }
989
1123
  }
1124
+ // #129: a leaf port that is a ref into a PRIOR NODE's typed arr outType (node→leaf array dataflow) →
1125
+ // the native Vec<ElemT> cloned off the prior node's typed cell (`t_<node>.borrow().field….clone()`) —
1126
+ // ZERO boxed Value on the read hot path (a fold/dedup leaf whose `items` = a prior node's array result).
1127
+ const priorArr = priorNodeArrElemInfo(expr, typedNodes, plan);
1128
+ if (priorArr !== null)
1129
+ return `${field}: ${priorArr.overExpr}`;
990
1130
  // change #2: a static string array (projection) → a native `vec![...]` of &'static str.
991
1131
  const strArr = staticStringArrElems(expr);
992
1132
  if (strArr !== null) {
@@ -1112,6 +1252,14 @@ function emitNativeRawRunner(comp, plan, ap) {
1112
1252
  };
1113
1253
  return [...order.keys()].some((k) => {
1114
1254
  const nd = comp.body[order[k]];
1255
+ if ("fanout" in nd) {
1256
+ const fo = nd.fanout;
1257
+ const bound = new Set([fo.as]);
1258
+ // the fanout's OVER is evaluated in the outer scope (may read input); ports in element scope.
1259
+ if (walk(fo.over, k, new Set()))
1260
+ return true;
1261
+ return Object.values(fo.ports).some((pn) => walk(pn, k, bound));
1262
+ }
1115
1263
  if ("map" in nd) {
1116
1264
  const m = nd.map;
1117
1265
  const bound = new Set([m.as]);
@@ -1152,7 +1300,7 @@ function emitNativeRawRunner(comp, plan, ap) {
1152
1300
  // default emits `pub fn` exactly as before (byte-identical).
1153
1301
  const fnKw = isAsync ? "pub async fn" : "pub fn";
1154
1302
  // #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);
1303
+ const dispatchesHandler = comp.body.some((n) => "map" in n || "fanout" in n || "component" in n);
1156
1304
  lines.push(`${fnKw} run_native_raw_struct_${fn}${bound}(`);
1157
1305
  lines.push(` ${dispatchesHandler ? "handlers" : "_handlers"}: &H,`);
1158
1306
  lines.push(` ${inParam}: ${inStructName(comp.name)},`);
@@ -1171,6 +1319,10 @@ function emitNativeRawRunner(comp, plan, ap) {
1171
1319
  k += stage.length - 1;
1172
1320
  continue;
1173
1321
  }
1322
+ if ("fanout" in comp.body[i]) {
1323
+ lines.push(emitFanoutArm(comp, comp.body[i], k, typedNodes, plan, priorNodeAt, ap.nodeIsAsync(comp.body[i].id)));
1324
+ continue;
1325
+ }
1174
1326
  if ("map" in comp.body[i]) {
1175
1327
  lines.push(emitMapArm(comp, comp.body[i], k, typedNodes, plan, priorNodeAt, ap.nodeIsAsync(comp.body[i].id)));
1176
1328
  continue;
@@ -1219,8 +1371,8 @@ function emitNativeRawRunner(comp, plan, ap) {
1219
1371
  const inits = [];
1220
1372
  for (const pn of portNames) {
1221
1373
  const expr = node.ports[pn];
1222
- const ty = portFieldRustType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`, undefined, plan);
1223
- inits.push(emitPortInit(pn, expr, ty, resolveRef, comp.inputPorts, plan));
1374
+ const ty = portFieldRustType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`, undefined, plan, typedNodes);
1375
+ inits.push(emitPortInit(pn, expr, ty, resolveRef, comp.inputPorts, plan, typedNodes));
1224
1376
  }
1225
1377
  const boundArg = node.bindField !== undefined ? `bound_${s}` : "None";
1226
1378
  // bc#97: derived per-node await — this point-read's future is awaited HERE (its result-consumption
@@ -1325,8 +1477,8 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
1325
1477
  const inits = [];
1326
1478
  for (const pn of Object.keys(node.ports)) {
1327
1479
  const expr = node.ports[pn];
1328
- const ty = portFieldRustType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`, undefined, plan);
1329
- inits.push(emitPortInit(pn, expr, ty, resolveRef, comp.inputPorts, plan));
1480
+ const ty = portFieldRustType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`, undefined, plan, typedNodes);
1481
+ inits.push(emitPortInit(pn, expr, ty, resolveRef, comp.inputPorts, plan, typedNodes));
1330
1482
  }
1331
1483
  if (gated) {
1332
1484
  lines.push(`${ind}ports_${s} = Some(${structName} { ${inits.join(", ")} });`);
@@ -1601,6 +1753,135 @@ isAsync = false) {
1601
1753
  lines.push(c);
1602
1754
  return lines.join("\n");
1603
1755
  }
1756
+ /**
1757
+ * emitFanoutArm (v3, rust twin) — the struct-native exec of a covered FANOUT node: build the per-id
1758
+ * ports (all over ids), dispatch the batched handler ONCE (aligned per-body rows), then apply THE SAME
1759
+ * dedupe/drop as run_behavior's fanoutDedupDrop — first-seen dedupe by the elem's dedupeKey field, drop
1760
+ * dangling (empty dedupeKey ≡ the interpreter's null/absent-key body), implicitSource strip (STRUCTURAL:
1761
+ * the elem type already omits it), wrap into the connection struct { items, cursor: None }.
1762
+ */
1763
+ function emitFanoutArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync = false) {
1764
+ const f = node.fanout;
1765
+ const awaitSfx = isAsync ? ".await" : "";
1766
+ const s = sanitize(node.id);
1767
+ const structName = portsStructName(comp.name, node.id);
1768
+ const batch = `${structName}Batch`;
1769
+ const connRef = typedNodes.get(node.id);
1770
+ const elemRef = fanoutItemsElemRef(node, connRef, plan);
1771
+ const elemName = renderTypeRef(elemRef);
1772
+ const { elemRef: overElemRef, overExpr } = fanoutOverElemInfo(comp, node, typedNodes, plan);
1773
+ const asName = f.as;
1774
+ const priorHere = (head) => priorNodeAt(head, atPos);
1775
+ const portNames = Object.keys(f.ports);
1776
+ if (elemRef.kind !== "named")
1777
+ 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)}).`);
1778
+ const elemDecl = findDecl(plan, elemRef.name);
1779
+ const dkField = elemDecl.fields.find((fd) => fd.name === f.dedupeKey);
1780
+ if (!dkField || dkField.type.kind !== "scalar" || dkField.type.scalar !== "string")
1781
+ 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}'.`);
1782
+ const dkRust = rustFieldName(f.dedupeKey);
1783
+ const resolveRef = (head, restPath, opt) => {
1784
+ if (opt)
1785
+ return null;
1786
+ let baseExpr;
1787
+ let baseRef;
1788
+ if (head === asName) {
1789
+ baseExpr = `oel_${s}`;
1790
+ baseRef = overElemRef;
1791
+ }
1792
+ else if (priorHere(head)) {
1793
+ baseExpr = `${typedCell(head)}.borrow()`;
1794
+ baseRef = typedNodes.get(head);
1795
+ }
1796
+ else {
1797
+ if (restPath.length !== 0)
1798
+ return null;
1799
+ const scalar = inputScalarKind(comp.inputPorts?.[head]);
1800
+ if (scalar === undefined)
1801
+ return null;
1802
+ const field = `in_.${rustFieldName(head)}`;
1803
+ return { expr: scalar === "String" ? `${field}.clone()` : field, scalar };
1804
+ }
1805
+ let acc;
1806
+ let expr;
1807
+ try {
1808
+ const r = typedFieldAccess(baseExpr, baseRef, restPath, plan);
1809
+ expr = r.expr;
1810
+ acc = r.ref;
1811
+ }
1812
+ catch {
1813
+ return null;
1814
+ }
1815
+ if (acc.kind !== "scalar" || acc.scalar === "null")
1816
+ return null;
1817
+ const isBareRef = restPath.length === 0;
1818
+ let owned;
1819
+ if (acc.scalar === "string")
1820
+ owned = isBareRef ? `(*${expr}).clone()` : `${expr}.clone()`;
1821
+ else
1822
+ owned = isBareRef ? `*${expr}` : expr;
1823
+ return { expr: owned, scalar: rustScalar(acc.scalar) };
1824
+ };
1825
+ const buildPorts = (il) => {
1826
+ const inits = [];
1827
+ const asBinding = { name: asName, ref: overElemRef, plan };
1828
+ for (const pn of portNames) {
1829
+ const ty = portFieldRustType(f.ports[pn], comp.inputPorts, `fanout port '${pn}' of node '${node.id}'`, asBinding, plan);
1830
+ inits.push(emitPortInit(pn, f.ports[pn], ty, resolveRef, comp.inputPorts, plan));
1831
+ }
1832
+ return [`${il}let ep_${s} = ${structName} { ${inits.join(", ")} };`];
1833
+ };
1834
+ const lines = [];
1835
+ const closers = [];
1836
+ 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 : ""}) ──`);
1837
+ let indent = " ";
1838
+ if (f.parent !== undefined && priorNodeAt(f.parent, atPos)) {
1839
+ lines.push(`${indent}if produced_${sanitize(f.parent)}.get() {`);
1840
+ closers.unshift(`${indent}}`);
1841
+ indent += " ";
1842
+ }
1843
+ lines.push(`${indent}let over_${s} = ${overExpr};`);
1844
+ lines.push(`${indent}let mut built_${s}: Vec<${elemName}> = Vec::new();`);
1845
+ lines.push(`${indent}let mut pi_${s}: Vec<${structName}> = Vec::with_capacity(over_${s}.len());`);
1846
+ lines.push(`${indent}for oel_${s} in over_${s}.iter() {`);
1847
+ for (const l of buildPorts(indent + " "))
1848
+ lines.push(l);
1849
+ lines.push(`${indent} pi_${s}.push(ep_${s});`);
1850
+ lines.push(`${indent}}`);
1851
+ lines.push(`${indent}if !pi_${s}.is_empty() {`);
1852
+ lines.push(`${indent} let want_${s} = pi_${s}.len();`);
1853
+ lines.push(`${indent} let bp_${s} = ${batch} { items: pi_${s} };`);
1854
+ lines.push(`${indent} let row_${s} = match handlers.${nodeMethodName(node.id)}(&bp_${s}, None)${awaitSfx} {`);
1855
+ lines.push(`${indent} Some(r) => r,`);
1856
+ lines.push(`${indent} None => return Err(unknown_component(${rustStrLit(f.component)})),`);
1857
+ lines.push(`${indent} };`);
1858
+ lines.push(`${indent} if row_${s}.is_error {`);
1859
+ lines.push(`${indent} return Err(BehaviorError::new("OP_FAILED", format!("operation '{}' failed under 'fail' policy: {}", ${rustStrLit(node.id)}, row_${s}.err)));`);
1860
+ lines.push(`${indent} }`);
1861
+ lines.push(`${indent} if row_${s}.rows.len() != want_${s} {`);
1862
+ 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})));`);
1863
+ lines.push(`${indent} }`);
1864
+ // ── THE ONE dedupe/drop (mirrors fanoutDedupDrop verbatim): first-seen dedupe by dedupeKey field;
1865
+ // drop dangling (empty dedupeKey ≡ interpreter's null/absent-key body); implicitSource strip is
1866
+ // structural (the elem type already omits it). cursor is always None (connection wrap).
1867
+ lines.push(`${indent} let mut seen_${s}: std::collections::HashSet<String> = std::collections::HashSet::new();`);
1868
+ lines.push(`${indent} for er_${s} in row_${s}.rows.into_iter() {`);
1869
+ lines.push(`${indent} ${emitElemMove(`let el_${s}`, `er_${s}`, elemRef, plan)}`);
1870
+ lines.push(`${indent} let key_${s} = el_${s}.${dkRust}.clone();`);
1871
+ if (f.drop === "dangling") {
1872
+ lines.push(`${indent} if key_${s}.is_empty() { continue; }`);
1873
+ }
1874
+ lines.push(`${indent} if seen_${s}.contains(&key_${s}) { continue; }`);
1875
+ lines.push(`${indent} seen_${s}.insert(key_${s});`);
1876
+ lines.push(`${indent} built_${s}.push(el_${s});`);
1877
+ lines.push(`${indent} }`);
1878
+ lines.push(`${indent}}`);
1879
+ lines.push(`${indent}*${typedCell(node.id)}.borrow_mut() = ${connRef.name} { ${rustFieldName("items")}: built_${s}, ..Default::default() };`);
1880
+ lines.push(`${indent}produced_${sanitize(node.id)}.set(true);`);
1881
+ for (const c of closers)
1882
+ lines.push(c);
1883
+ return lines.join("\n");
1884
+ }
1604
1885
  /** #108: resolve an INPUT port head for a native-expr ref (rust). Scalar → `in_.<field>` (owned: String
1605
1886
  * cloned, Copy as-is); array WITH elemType → `in_.<field>.clone()` of Vec<ElemT> (for `len`); else null. */
1606
1887
  function rustInputHeadRef(head, comp, plan) {
@@ -1800,13 +2081,15 @@ ${emitStructSurface([])}
1800
2081
  for (const n of c.body)
1801
2082
  typedNodes.set(n.id, deriveTypeRef(n.outType, plan));
1802
2083
  for (const n of c.body) {
1803
- if ("map" in n)
2084
+ if ("fanout" in n)
2085
+ structs.push(emitFanoutPortsStructs(c, n, typedNodes, plan));
2086
+ else if ("map" in n)
1804
2087
  structs.push(emitMapPortsStructs(c, n, typedNodes, plan));
1805
2088
  else if ("cond" in n) {
1806
2089
  // #108: a cond node has NO ports struct (pure Expression — no handler dispatch).
1807
2090
  }
1808
2091
  else
1809
- structs.push(emitPortsStruct(c, n, undefined, plan));
2092
+ structs.push(emitPortsStruct(c, n, undefined, plan, typedNodes)); // #129: typedNodes for prior-node arr leaf ports.
1810
2093
  }
1811
2094
  rowStructs.push(emitRawRowStructs(c, typedNodes, plan));
1812
2095
  handlerTraits.push(emitHandlerTrait(c, typedNodes, plan, asyncPlans.get(c.name)));
@@ -1904,6 +2187,34 @@ export function rustTypedNativeObserve(ir, ioModel = "sync") {
1904
2187
  // bc#90 / runtime-free: the scripted impl's `_bound` param mirrors the trait's typed produced-aware
1905
2188
  // Option<K> (NOT a boxed Value) — computed per node so the impl signature matches the trait.
1906
2189
  const bt = boundRustType(n, typedNodes, plan);
2190
+ if ("fanout" in n) {
2191
+ // v3: scripted fanout impl — drain one batched outcome, decode the aligned array into per-body
2192
+ // elem rows (a null array element decodes to the zero elem row = dangling → dropped by the arm).
2193
+ const f = n.fanout;
2194
+ const elemRowRef = fanoutItemsElemRef(n, ref, plan);
2195
+ const elemT = rawElemStructName(c.name, n.id);
2196
+ const batchT = rawRowStructName(c.name, n.id);
2197
+ const elemDecode = emitDecodeRow(c.name, n.id, elemT, elemRowRef, plan, emittedDecode, decodeFns, "Elem");
2198
+ const portsT = `${portsStructName(c.name, n.id)}Batch`;
2199
+ methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${batchT}> {
2200
+ let (is_err, err, ok, _echo) = self.next(${rustStrLit(f.component)})?;
2201
+ if is_err {
2202
+ return Some(${batchT} { is_error: true, err, ..Default::default() });
2203
+ }
2204
+ let arr = match ok { Some(Value::Arr(a)) => a, _ => Vec::new() };
2205
+ // v3: a null aligned body is a DANGLING id — decode it to the zero elem row (whose dedupeKey
2206
+ // field is empty), so the runner's dangling-drop excludes it (byte-equal to fanoutDedupDrop).
2207
+ let rows = arr
2208
+ .iter()
2209
+ .map(|ev| match ev {
2210
+ Value::Null => ${elemT}::default(),
2211
+ _ => ${elemDecode}(ev),
2212
+ })
2213
+ .collect();
2214
+ Some(${batchT} { rows, ..Default::default() })
2215
+ }`);
2216
+ continue;
2217
+ }
1907
2218
  if ("map" in n) {
1908
2219
  const m = n.map;
1909
2220
  const batched = m.batched === true;
@@ -1916,7 +2227,7 @@ export function rustTypedNativeObserve(ir, ioModel = "sync") {
1916
2227
  const retT = batched ? batchT : elemT;
1917
2228
  if (batched) {
1918
2229
  methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${batchT}> {
1919
- let (is_err, err, ok) = self.next(${rustStrLit(m.component)})?;
2230
+ let (is_err, err, ok, _echo) = self.next(${rustStrLit(m.component)})?;
1920
2231
  if is_err {
1921
2232
  return Some(${batchT} { is_error: true, err, ..Default::default() });
1922
2233
  }
@@ -1927,7 +2238,7 @@ export function rustTypedNativeObserve(ir, ioModel = "sync") {
1927
2238
  }
1928
2239
  else {
1929
2240
  methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${elemT}> {
1930
- let (is_err, err, ok) = self.next(${rustStrLit(m.component)})?;
2241
+ let (is_err, err, ok, _echo) = self.next(${rustStrLit(m.component)})?;
1931
2242
  if is_err {
1932
2243
  return Some(${elemT} { is_error: true, err, ..Default::default() });
1933
2244
  }
@@ -1941,11 +2252,22 @@ export function rustTypedNativeObserve(ir, ioModel = "sync") {
1941
2252
  const node = n;
1942
2253
  const rowT = rawRowStructName(c.name, node.id);
1943
2254
  const decode = emitDecodeRow(c.name, node.id, rowT, ref, plan, emittedDecode, decodeFns, "");
1944
- methods.push(` ${fnKw} ${nodeMethodName(node.id)}(&self, _ports: &${portsStructName(c.name, node.id)}, _bound: ${bt}) -> Option<${rowT}> {
1945
- let (is_err, err, ok) = self.next(${rustStrLit(node.component)})?;
2255
+ // #129 (TEST glue): if this node has an `items` port and its outType is a bare arr, support the
2256
+ // reference scriptedHandlers' `echo === "items"` — the scripted handler ECHOES the NATIVE `f_items`
2257
+ // port back as its result. This makes the node→leaf array dataflow test non-vacuous: the leaf's
2258
+ // observed result is exactly the native array that flowed into its ports struct, so a dropped or
2259
+ // boxed native array would diverge from run_behavior. `ports` is bound only on the echo path.
2260
+ const canEcho = ref.kind === "arr" && Object.prototype.hasOwnProperty.call(node.ports, "items");
2261
+ const portsParam = canEcho ? "ports" : "_ports";
2262
+ const echoBranch = canEcho
2263
+ ? `\n if echo == "items" {\n return Some(${rowT} { val: ${portsParam}.${portFieldName("items")}.clone(), ..Default::default() });\n }`
2264
+ : "";
2265
+ const okBind = canEcho ? "(is_err, err, ok, echo)" : "(is_err, err, ok, _echo)";
2266
+ methods.push(` ${fnKw} ${nodeMethodName(node.id)}(&self, ${portsParam}: &${portsStructName(c.name, node.id)}, _bound: ${bt}) -> Option<${rowT}> {
2267
+ let ${okBind} = self.next(${rustStrLit(node.component)})?;
1946
2268
  if is_err {
1947
2269
  return Some(${rowT} { is_error: true, err, ..Default::default() });
1948
- }
2270
+ }${echoBranch}
1949
2271
  let v = ok.unwrap_or(Value::Null);
1950
2272
  Some(${decode}(&v))
1951
2273
  }`);
@@ -2000,19 +2322,22 @@ impl ScriptedNativeRaw {
2000
2322
 
2001
2323
  // drain one scripted outcome for a component (last entry repeats — mirrors the reference scripted
2002
2324
  // handlers); None when the component has no scripted queue (fail-closed). Returns (is_error, err,
2003
- // ok_value). The bc#87 parallel-stage runner calls node_* from multiple threads, so the drain is
2004
- // Mutex-guarded.
2005
- fn next(&self, component: &str) -> Option<(bool, String, Option<Value>)> {
2325
+ // ok_value, echo). The bc#87 parallel-stage runner calls node_* from multiple threads, so the drain
2326
+ // is Mutex-guarded. #129: \`echo\` carries the reference scriptedHandlers' \`echo\` directive (e.g.
2327
+ // "items") so a scripted node can ECHO a named input PORT back — used to make the node→leaf array
2328
+ // dataflow test non-vacuous (the leaf returns its \`items\` port; a dropped/boxed array diverges).
2329
+ fn next(&self, component: &str) -> Option<(bool, String, Option<Value>, String)> {
2006
2330
  self.seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2007
2331
  let mut queues = self.queues.lock().unwrap();
2008
2332
  let (queue, single) = queues.get_mut(component)?;
2009
2333
  let raw = if *single || queue.len() <= 1 { queue[0].clone() } else { queue.remove(0) };
2334
+ let echo = raw.get("echo").and_then(|e| e.as_str()).unwrap_or("").to_string();
2010
2335
  if let Some(okv) = raw.get("ok") {
2011
2336
  let v = behavior_contracts::decode_value(okv).expect("decode ok");
2012
- Some((false, String::new(), Some(v)))
2337
+ Some((false, String::new(), Some(v), echo))
2013
2338
  } else {
2014
2339
  let e = raw.get("error").and_then(|e| e.as_str()).unwrap_or("").to_string();
2015
- Some((true, e, None))
2340
+ Some((true, e, None, echo))
2016
2341
  }
2017
2342
  }
2018
2343
  }`;
@@ -2179,6 +2504,7 @@ function emitDecodeRow(compName, nodeId, rowT, ref, plan, emitted, out, suffix)
2179
2504
  }
2180
2505
  export const rustTypedNativeEmitter = {
2181
2506
  language: "rust-typed-native",
2507
+ classification: "codegen", // native: 型付きフラットコード生成・IR/dict 非在(#128/A6)
2182
2508
  fileExtension: ".rs",
2183
2509
  defaultRuntimeImport: "behavior_contracts",
2184
2510
  filenameHint: "behaviors_typed_native_generated.rs",