behavior-contracts 0.8.1 → 0.8.2

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 (60) hide show
  1. package/dist/authoring.d.ts +3 -0
  2. package/dist/authoring.d.ts.map +1 -1
  3. package/dist/authoring.js +7 -0
  4. package/dist/authoring.js.map +1 -1
  5. package/dist/behavior.d.ts +4 -0
  6. package/dist/behavior.d.ts.map +1 -1
  7. package/dist/behavior.js.map +1 -1
  8. package/dist/expr-operator-types.d.ts.map +1 -1
  9. package/dist/expr-operator-types.js +2 -0
  10. package/dist/expr-operator-types.js.map +1 -1
  11. package/dist/generator/emit-shared-go-typed.d.ts.map +1 -1
  12. package/dist/generator/emit-shared-go-typed.js +130 -4
  13. package/dist/generator/emit-shared-go-typed.js.map +1 -1
  14. package/dist/generator/emit-shared-rust-typed.d.ts.map +1 -1
  15. package/dist/generator/emit-shared-rust-typed.js +49 -1
  16. package/dist/generator/emit-shared-rust-typed.js.map +1 -1
  17. package/dist/generator/emit-shared-typescript-typed.d.ts +83 -0
  18. package/dist/generator/emit-shared-typescript-typed.d.ts.map +1 -0
  19. package/dist/generator/emit-shared-typescript-typed.js +259 -0
  20. package/dist/generator/emit-shared-typescript-typed.js.map +1 -0
  21. package/dist/generator/emit-shared-typescript.d.ts.map +1 -1
  22. package/dist/generator/emit-shared-typescript.js +88 -0
  23. package/dist/generator/emit-shared-typescript.js.map +1 -1
  24. package/dist/generator/emit-straightline-typed-typescript.d.ts.map +1 -1
  25. package/dist/generator/emit-straightline-typed-typescript.js +2 -0
  26. package/dist/generator/emit-straightline-typed-typescript.js.map +1 -1
  27. package/dist/generator/emit-typed-native-go.d.ts.map +1 -1
  28. package/dist/generator/emit-typed-native-go.js +179 -21
  29. package/dist/generator/emit-typed-native-go.js.map +1 -1
  30. package/dist/generator/emit-typed-native-rust.d.ts.map +1 -1
  31. package/dist/generator/emit-typed-native-rust.js +183 -21
  32. package/dist/generator/emit-typed-native-rust.js.map +1 -1
  33. package/dist/generator/emit-typescript-native.d.ts +56 -0
  34. package/dist/generator/emit-typescript-native.d.ts.map +1 -0
  35. package/dist/generator/emit-typescript-native.js +44 -0
  36. package/dist/generator/emit-typescript-native.js.map +1 -0
  37. package/dist/generator/index.d.ts +2 -0
  38. package/dist/generator/index.d.ts.map +1 -1
  39. package/dist/generator/index.js +6 -0
  40. package/dist/generator/index.js.map +1 -1
  41. package/dist/generator/native-expr.d.ts +17 -0
  42. package/dist/generator/native-expr.d.ts.map +1 -1
  43. package/dist/generator/native-expr.js +82 -2
  44. package/dist/generator/native-expr.js.map +1 -1
  45. package/dist/generator/straightline.d.ts +22 -1
  46. package/dist/generator/straightline.d.ts.map +1 -1
  47. package/dist/generator/straightline.js +25 -7
  48. package/dist/generator/straightline.js.map +1 -1
  49. package/dist/generator/typed.d.ts +5 -0
  50. package/dist/generator/typed.d.ts.map +1 -1
  51. package/dist/generator/typed.js +8 -0
  52. package/dist/generator/typed.js.map +1 -1
  53. package/dist/guard.d.ts +2 -1
  54. package/dist/guard.d.ts.map +1 -1
  55. package/dist/guard.js +6 -5
  56. package/dist/guard.js.map +1 -1
  57. package/dist/type-gate.d.ts.map +1 -1
  58. package/dist/type-gate.js +5 -2
  59. package/dist/type-gate.js.map +1 -1
  60. package/package.json +1 -1
@@ -138,12 +138,12 @@ function inferPortType(node, inputPorts, asBinding) {
138
138
  if (e.path.length === 1) {
139
139
  const s = inputPorts[e.path[0]];
140
140
  if (s) {
141
- if (s.type === "string")
142
- return "String";
141
+ if (s.type === "string" || s.type === "literal")
142
+ return "String"; // literal = constrained String.
143
143
  if (s.type === "int")
144
144
  return "i64";
145
- if (s.type === "float")
146
- return "f64";
145
+ if (s.type === "float" || s.type === "number")
146
+ return "f64"; // `"number"` = the float channel.
147
147
  if (s.type === "bool")
148
148
  return "bool";
149
149
  }
@@ -193,12 +193,84 @@ function portFieldRustType(node, inputPorts, where = "port", asBinding, plan, ty
193
193
  const num = numLiteralRustExpr(node);
194
194
  if (num !== null)
195
195
  return typeof node === "number" && Number.isInteger(node) ? "i64" : "f64";
196
+ // a ref to a `$as` element field (or an input port) whose type is a MAP or NAMED struct (NOT a bare
197
+ // scalar) → the native map/struct type. #108-map: a `{map:V}`-typed field (e.g. a nested
198
+ // `map<map<obj>>` write port) lowers to BTreeMap; a named field lowers to its struct. Reuses the same
199
+ // typedFieldAccess resolution as the scalar path; a genuinely-untyped ref still falls through.
200
+ const composite = portCompositeRef(node, inputPorts, asBinding, plan);
201
+ if (composite !== null)
202
+ return renderTypeRef(composite);
196
203
  const ft = inferPortType(node, inputPorts, asBinding);
197
204
  if (ft === "Value") {
198
205
  throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#90 / runtime-free): ${where} '${JSON.stringify(node)}' does not lower to a native Rust type (would need a boxed Value). The covered read plane is runtime-free and admits NO boxed escape — the covered corpus ports are string / bool / static-string-array (projection) / bare-number (limit) only. Regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
199
206
  }
200
207
  return ft;
201
208
  }
209
+ /** portCompositeRef — the full TypeRef of a port that is a `{ref:[...]}` into a `$as` element field OR
210
+ * an input port whose type is a MAP or NAMED struct (a non-scalar covered field). Returns null when the
211
+ * node is not such a ref (so the caller falls through to the scalar/array inference). #108-map: enables a
212
+ * `{map:V}`-typed field port (e.g. a nested map write port) to lower to the native BTreeMap. */
213
+ function portCompositeRef(node, inputPorts, asBinding, plan) {
214
+ const e = classifyExpr(node);
215
+ if (e.kind !== "ref" || plan === undefined)
216
+ return null;
217
+ let ref = null;
218
+ if (asBinding !== undefined && e.path[0] === asBinding.name) {
219
+ try {
220
+ ref = typedFieldAccess(asBinding.name, asBinding.ref, e.path.slice(1), asBinding.plan).ref;
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ }
226
+ else if (e.path.length >= 1) {
227
+ const s = inputPorts[e.path[0]];
228
+ // a whole-port ref to an input MAP port carrying an elemType → its BTreeMap type (mirrors
229
+ // inputPortRustType); a deeper path into an input port is not resolved here (no field type source).
230
+ if (s !== undefined && s.type === "map" && e.path.length === 1) {
231
+ const et = s.elemType;
232
+ if (et !== undefined)
233
+ return { kind: "map", value: deriveTypeRef(et, plan) };
234
+ }
235
+ return null;
236
+ }
237
+ if (ref === null)
238
+ return null;
239
+ // only MAP / NAMED composite fields here — scalars/arrays keep their existing dedicated paths.
240
+ if (ref.kind === "map" || ref.kind === "named")
241
+ return ref;
242
+ return null;
243
+ }
244
+ /** emitCompositePortValue — the OWNED native value expr for a port that is a ref to a `$as` element
245
+ * MAP/NAMED field (or an input map port): a typed field access `.clone()` (the concrete BTreeMap/struct,
246
+ * ZERO boxed Value). `elemBaseExpr` is the map arm's element base (`oel_<mapId>`) for a `$as`-headed ref;
247
+ * omitted at the top level (only input-port composite refs resolve there). Returns null otherwise. */
248
+ function emitCompositePortValue(node, inputPorts, asBinding, plan, elemBaseExpr) {
249
+ const e = classifyExpr(node);
250
+ if (e.kind !== "ref")
251
+ return null;
252
+ if (asBinding !== undefined && elemBaseExpr !== undefined && e.path[0] === asBinding.name) {
253
+ let acc;
254
+ try {
255
+ acc = typedFieldAccess(elemBaseExpr, asBinding.ref, e.path.slice(1), plan);
256
+ }
257
+ catch {
258
+ return null;
259
+ }
260
+ if (acc.ref.kind !== "map" && acc.ref.kind !== "named")
261
+ return null;
262
+ return `${acc.expr}.clone()`;
263
+ }
264
+ // a WHOLE-port ref to an input MAP port (with elemType) — valid at top level OR inside a map node (a
265
+ // ref head that is NOT the `$as` element binding). Clones the native BTreeMap off the input struct.
266
+ if ((asBinding === undefined || e.path[0] !== asBinding.name) && e.path.length === 1) {
267
+ const s = inputPorts[e.path[0]];
268
+ if (s !== undefined && s.type === "map" && s.elemType !== undefined) {
269
+ return `in_.${rustFieldName(e.path[0])}.clone()`;
270
+ }
271
+ }
272
+ return null;
273
+ }
202
274
  /**
203
275
  * emitPortsStruct — the CONCRETE native ports struct for a componentRef node. Typed fields per the
204
276
  * static port type, constructed directly. NO `impl PortReaderT` / by-name `port()` — the covered runner
@@ -317,6 +389,12 @@ function makeRustExprBackend(resolveHead, plan) {
317
389
  notOp: (a) => `(!${a})`,
318
390
  structLit: (name, inits) => `${name} { ${inits.map((i) => `${rustFieldName(i.field)}: ${i.expr}`).join(", ")} }`,
319
391
  arrLit: (elemTy, items) => `{ let __v: Vec<${elemTy}> = vec![${items.join(", ")}]; __v }`,
392
+ // an opt (`Option<T>`) NONE literal. Annotate as `Option::<T>::None` so it type-infers in ANY
393
+ // position (a bare `None` can be ambiguous; the explicit turbofish keeps it a fully-typed value).
394
+ optNone: (innerTy) => `Option::<${innerTy}>::None`,
395
+ // opt (`Option<T>`) null-presence test: present = `expr.is_some()`, absent = `expr.is_none()`.
396
+ optIsSome: (expr) => `${expr}.is_some()`,
397
+ optIsNone: (expr) => `${expr}.is_none()`,
320
398
  ternary: (cond, t, e, _ty) => `(if ${rustStripOuterParens(cond)} { ${rustStripOuterParens(t)} } else { ${rustStripOuterParens(e)} })`,
321
399
  shortCircuitBool: (op, temp, left, rStmts, rExpr) => {
322
400
  // and: let temp = if left { <rStmts> rExpr } else { false }; or: if left { true } else { <rStmts> rExpr }.
@@ -593,6 +671,19 @@ function emitHandlerTrait(comp, typedNodes, plan, ap) {
593
671
  function mapElemMaterializerName(nodeId) {
594
672
  return `materialize_map_elem_${typedCell(nodeId)}`;
595
673
  }
674
+ /** mapNodeArrRef — the map node's PRODUCED-ARRAY TypeRef from its `outType`, normalizing the two IR
675
+ * conventions to the one array shape every downstream site assumes (`typedNodes.get(mapId)` is an arr):
676
+ * - the RECORDER SoT (authoring.ts recordMap): `node.outType` is the ELEMENT (pre-array-wrap) — wrap
677
+ * it to `{arr:element}` here (the map produces `[]element`), matching type-gate's outputType wrap
678
+ * (`"map" in n ? {arr:ot} : ot`). This is the declarative-authoring form (graphddb batchWrite/forEach).
679
+ * - legacy hand-built IR: `node.outType` is already the `{arr:element}` produced form — take it as-is.
680
+ * A map ELEMENT is never itself an `{arr:…}` in the covered corpus, so the `arr`-keyed discriminator is
681
+ * unambiguous. Both converge to the SAME produced-array ref, so existing goldens stay byte-identical. */
682
+ function mapNodeArrRef(node, plan) {
683
+ const outT = node.outType;
684
+ const isArr = outT !== null && typeof outT === "object" && "arr" in outT;
685
+ return deriveTypeRef((isArr ? outT : { arr: outT }), plan);
686
+ }
596
687
  /** mapElemRowRef — the handler's per-element RETURN shape for a covered map node: the `into` field's
597
688
  * type (into) or the element outType directly (no-into). This is the type of RawElemNR<Comp><Node>. */
598
689
  function mapElemRowRef(node, arrRef, plan) {
@@ -829,6 +920,13 @@ function coveredMapNode(n, bodyIds, comp) {
829
920
  return false;
830
921
  if (m.relationKind !== undefined && m.relationKind !== "single" && m.relationKind !== "connection")
831
922
  return false;
923
+ // A map node must carry an outType (its ELEMENT type per the recorder SoT — authoring.ts recordMap
924
+ // holds `node.outType = mapElem`, the PRE-array-wrap element; the emitter synthesizes the produced
925
+ // `[]element`). A map WITHOUT outType is uncovered. (Legacy hand-built IR that already carries the
926
+ // `{arr:element}` produced form is ALSO accepted — mapNodeElemRef normalizes both to the element.)
927
+ const outT = n.outType;
928
+ if (outT === undefined || typeof outT !== "object")
929
+ return false;
832
930
  const overE = classifyExpr(m.over);
833
931
  if (overE.kind !== "ref")
834
932
  return false;
@@ -927,11 +1025,14 @@ function coverageRejectReason(comp) {
927
1025
  // shape). Kept fail-closed: a non-componentRef member is rejected.
928
1026
  for (const [i] of ep.parallelStageOf) {
929
1027
  const n = comp.body[i];
930
- // #125: a when(...) control-gate cond IS allowed as a parallel-stage member — it is a pure
931
- // Expression (no dispatch), settled in ascending preflight order like the bindField key gate, so its
932
- // produced_<gate> flag is决定済み before the stage's gated children dispatch. No handler, no boxed
933
- // result it does not disturb the bounded-concurrency error-precedence protocol.
934
- if (isControlGate(n))
1028
+ // #125/#108: a cond node IS allowed as a parallel-stage member — it is a pure Expression (no
1029
+ // dispatch), settled in ascending preflight order, so its produced flag / typed result cell is
1030
+ // settled before any dispatch. No handler, no boxed result — it does not disturb the bounded-
1031
+ // concurrency error-precedence protocol. Covers BOTH the when(...) control-gate cond (#136,
1032
+ // {ok}/null skip marker) AND the Φ-merge VALUE cond (#108, has outType — the sibling Φ-join). A
1033
+ // value cond's outType requirement is enforced by the per-node cond check below, so a parallel
1034
+ // value cond without outType still loud-rejects there.
1035
+ if ("cond" in n)
935
1036
  continue;
936
1037
  if (!("component" in n))
937
1038
  return `node '${n.id}' is a non-componentRef member of a parallel stage (not native-covered)`;
@@ -1009,10 +1110,16 @@ function assertTypedCoverage(comp) {
1009
1110
  function inputPortRustType(schema, plan) {
1010
1111
  switch (schema?.type) {
1011
1112
  case "string":
1113
+ // a `"literal"` port is a constrained STRING union (`param.literal('a','b',…)`, stored 'S') — its
1114
+ // native representation is a plain String (the union constraint is a validation concern, not a type).
1115
+ case "literal":
1012
1116
  return "String";
1013
1117
  case "int":
1014
1118
  return "i64";
1015
1119
  case "float":
1120
+ // a `"number"` port is a FLOAT (run_behavior: `typeName(number) === "float"`, expr-eval.ts §value):
1121
+ // a JS number is IEEE754 → f64. (An i64 value is declared `"int"`; `"number"` is the float channel.)
1122
+ case "number":
1016
1123
  return "f64";
1017
1124
  case "bool":
1018
1125
  return "bool";
@@ -1024,6 +1131,13 @@ function inputPortRustType(schema, plan) {
1024
1131
  return `Vec<${renderTypeRef(deriveTypeRef(et, plan))}>`;
1025
1132
  return "Value";
1026
1133
  }
1134
+ case "map": {
1135
+ // an input MAP port (dynamic string keys, declared value elemType) lowers to a native BTreeMap.
1136
+ const et = schema.elemType;
1137
+ if (et !== undefined && plan !== undefined)
1138
+ return `std::collections::BTreeMap<String, ${renderTypeRef(deriveTypeRef(et, plan))}>`;
1139
+ return "Value";
1140
+ }
1027
1141
  default:
1028
1142
  return "Value";
1029
1143
  }
@@ -1032,10 +1146,12 @@ function inputPortRustType(schema, plan) {
1032
1146
  function inputScalarKind(schema) {
1033
1147
  switch (schema?.type) {
1034
1148
  case "string":
1149
+ case "literal": // a `"literal"` union is a constrained String (see inputPortRustType).
1035
1150
  return "String";
1036
1151
  case "int":
1037
1152
  return "i64";
1038
1153
  case "float":
1154
+ case "number": // a `"number"` port is the FLOAT channel (run_behavior: number → float / f64).
1039
1155
  return "f64";
1040
1156
  case "bool":
1041
1157
  return "bool";
@@ -1132,8 +1248,16 @@ function emitNativeScalar(node, want, resolveRef) {
1132
1248
  * a boxed escape would re-introduce the bc-runtime coupling this module removes). `fieldRustType` is the
1133
1249
  * ports-struct field type (from portFieldRustType); `resolveRef` resolves a ref head for the scalar path.
1134
1250
  */
1135
- function emitPortInit(pn, expr, fieldRustType, resolveRef, inputPorts, plan, typedNodes) {
1251
+ function emitPortInit(pn, expr, fieldRustType, resolveRef, inputPorts, plan, typedNodes, asBinding, elemBaseExpr) {
1136
1252
  const field = portFieldName(pn);
1253
+ // #108-map: a port that is a ref to a `$as` element MAP/NAMED field (or an input map port) → CLONE the
1254
+ // typed composite value directly off the element/input (native BTreeMap / struct — ZERO boxed Value).
1255
+ // Reuses portCompositeRef for the type; the value is the typed field access `.clone()`.
1256
+ if (plan !== undefined) {
1257
+ const compExpr = emitCompositePortValue(expr, inputPorts ?? {}, asBinding, plan, elemBaseExpr);
1258
+ if (compExpr !== null)
1259
+ return `${field}: ${compExpr}`;
1260
+ }
1137
1261
  // #110: a componentRef port bound to a runtime array input port (with elemType) → the native Vec<ElemT>,
1138
1262
  // cloned out of the concrete input struct (`in_.<field>.clone()`) — ZERO boxed Value on the read hot path.
1139
1263
  if (inputPorts !== undefined && portArrayElemRef(expr, inputPorts, plan) !== null) {
@@ -1233,9 +1357,11 @@ function emitNativeRawRunner(comp, plan, ap) {
1233
1357
  throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native async (bc#97): component '${comp.name}' has a bc#87 real-concurrency stage AND an async terminal handler, but a scoped-std-thread stage cannot host \`.await\`. Async + concurrent-stage (join! over the stage's futures) is a follow-up — declare this component's terminals sync, or split the concurrency stage.`);
1234
1358
  }
1235
1359
  const typedNodes = new Map();
1360
+ // #125: control-gate は typeless(typedNodes に載せない)。map ノードは produced-array ref に正規化
1361
+ // (recorder SoT の element outType を {arr:element} へラップ / legacy {arr} はそのまま)。
1236
1362
  for (const n of comp.body)
1237
1363
  if (!isControlGate(n))
1238
- typedNodes.set(n.id, deriveTypeRef(n.outType, plan)); // #125: control-gate は typeless(typedNodes に載せない)
1364
+ typedNodes.set(n.id, "map" in n ? mapNodeArrRef(n, plan) : deriveTypeRef(n.outType, plan));
1239
1365
  const idToIndex = new Map();
1240
1366
  comp.body.forEach((n, i) => idToIndex.set(n.id, i));
1241
1367
  const opsLiteral = comp.body.map((n) => {
@@ -1471,17 +1597,41 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
1471
1597
  const ids = stage.map((i) => comp.body[i].id);
1472
1598
  lines.push(` // ── bc#87 real-concurrency stage {${ids.map((x) => rustStrLit(x)).join(", ")}} — static parallel`);
1473
1599
  lines.push(` // orchestration (scoped threads, bound=${concurrency}); preflight+commit in ascending index order.`);
1600
+ // #125/#108: a stage member may be a cond node — a pure Expression (NO handler/dispatch). The scoped-
1601
+ // thread orchestration governs only the componentRef fan-out members; a cond is settled in ascending
1602
+ // preflight order — BEFORE any dispatch — via the SAME emitCondArm the sequential path uses (no forked
1603
+ // lowering). emitCondArm routes internally: a when(...) control-gate cond (#136, isControlGate, no
1604
+ // outType) commits produced_<gate> ({ok}/null skip marker); a Φ-merge VALUE cond (#108, has outType,
1605
+ // then/else are data branches) materializes its typed result cell (the sibling Φ-join — e.g. two
1606
+ // when-gated connection relations each folding to `ref | emptyConn`). Both are mutually independent
1607
+ // (§1), so ascending-at-head is byte-equal to run_behavior. A cond never shares a stage with a node
1608
+ // that depends on it (an intra-stage parent/child dep is rejected by concurrencyPlan).
1609
+ const condMembers = stage.filter((i) => "cond" in comp.body[i]);
1610
+ const refMembers = stage.filter((i) => !("cond" in comp.body[i]));
1611
+ for (const i of condMembers) {
1612
+ lines.push(emitCondArm(comp, comp.body[i], atPos + stage.indexOf(i), typedNodes, plan, priorNodeAt));
1613
+ }
1614
+ // A cond-ONLY stage carries no handler fan-out — the conds above ARE the whole stage. Skip the scoped-
1615
+ // thread orchestration block entirely (an empty one is dead scaffolding clippy rejects: never_loop /
1616
+ // match_single_binding on a zero-job scope).
1617
+ if (refMembers.length === 0)
1618
+ return lines.join("\n");
1474
1619
  lines.push(` {`);
1475
1620
  // 1) PREFLIGHT (ascending): build each member's CONCRETE ports struct into a stage-scoped Option
1476
1621
  // local. #114: a bindField relation child ALSO reads its produced-aware bound key from the SETTLED
1477
1622
  // parent struct field (typed access — no serialize) into a stage-scoped Option; a None bound =>
1478
1623
  // UNPRODUCED/skip (ports_<s> stays None), exactly run_behavior's preflightOp bindField skip, before
1479
1624
  // any dispatch. The bound is captured (cloned) by the member's scoped-thread worker.
1480
- for (const i of stage) {
1625
+ for (const i of refMembers) {
1481
1626
  const node = comp.body[i];
1482
1627
  const s = sanitize(node.id);
1483
1628
  const structName = portsStructName(comp.name, node.id);
1484
1629
  const gated = node.parent !== undefined && priorNodeAt(node.parent, atPos);
1630
+ // #125: a member gated by a PRIOR-stage control gate (`parent=gate, bindField:"ok"`). Its bindField is
1631
+ // the gate's skip sentinel, NOT a relation data key — the gate is typeless (no struct field to read).
1632
+ // Mirror the sequential arm: past the produced_<gate> gate we are past the skip, so the bound is the
1633
+ // non-None "ok" sentinel (the child's ports read the REAL data parent, not the gate).
1634
+ const parentIsGate = node.parent !== undefined && isControlGate(comp.body.find((b) => b.id === node.parent));
1485
1635
  let ind = " ";
1486
1636
  const closers = [];
1487
1637
  if (node.bindField !== undefined) {
@@ -1493,7 +1643,10 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
1493
1643
  lines.push(`${ind}if produced_${sanitize(node.parent)}.get() {`);
1494
1644
  closers.unshift(`${ind}}`);
1495
1645
  ind += " ";
1496
- if (node.bindField !== undefined) {
1646
+ if (node.bindField !== undefined && parentIsGate) {
1647
+ lines.push(`${ind}bound_${s} = Some("ok".to_string());`);
1648
+ }
1649
+ else if (node.bindField !== undefined) {
1497
1650
  const parentRef = typedNodes.get(node.parent);
1498
1651
  const { expr: bfExpr, ref: bfRef } = typedFieldAccess(`${typedCell(node.parent)}.borrow()`, parentRef, [node.bindField], plan);
1499
1652
  if (bfRef.kind === "opt") {
@@ -1526,16 +1679,18 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
1526
1679
  }
1527
1680
  // 2) DISPATCH (bounded scoped threads). Share `&H` (concrete trait; H: Sync). Each worker calls the
1528
1681
  // concrete node_* method for its member's CONCRETE ports and stores the concrete row in its slot.
1529
- const nMembers = stage.length;
1682
+ // #125: the fan-out (slots / jobs / workers) spans the COMPONENTREF members only — a control gate
1683
+ // dispatches no handler (committed in preflight above), so it is not a scoped-thread job.
1684
+ const nMembers = refMembers.length;
1530
1685
  const boundN = Math.min(concurrency, nMembers);
1531
1686
  const jobsVar = `jobs_${sanitize(comp.name)}`;
1532
- for (const i of stage) {
1687
+ for (const i of refMembers) {
1533
1688
  const s = sanitize(comp.body[i].id);
1534
1689
  const rowT = rawRowStructName(comp.name, comp.body[i].id);
1535
1690
  lines.push(` let slot_${s}: std::sync::Mutex<Option<Option<${rowT}>>> = std::sync::Mutex::new(None);`);
1536
1691
  }
1537
1692
  lines.push(` let mut ${jobsVar}: Vec<usize> = Vec::new();`);
1538
- stage.forEach((i, slot) => {
1693
+ refMembers.forEach((i, slot) => {
1539
1694
  const s = sanitize(comp.body[i].id);
1540
1695
  lines.push(` if ports_${s}.is_some() { ${jobsVar}.push(${slot}); }`);
1541
1696
  });
@@ -1548,7 +1703,7 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
1548
1703
  lines.push(` if j >= ${jobsVar}.len() { break; }`);
1549
1704
  lines.push(` let slot = ${jobsVar}[j];`);
1550
1705
  lines.push(` match slot {`);
1551
- stage.forEach((i, slot) => {
1706
+ refMembers.forEach((i, slot) => {
1552
1707
  const node = comp.body[i];
1553
1708
  const s = sanitize(node.id);
1554
1709
  const boundArg = node.bindField !== undefined ? `bound_${s}.clone()` : "None";
@@ -1564,7 +1719,7 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
1564
1719
  lines.push(` }`);
1565
1720
  lines.push(` });`);
1566
1721
  // 3) COMMIT (ascending index): interpret + move the concrete row into the outType cell.
1567
- stage.forEach((i) => {
1722
+ refMembers.forEach((i) => {
1568
1723
  const node = comp.body[i];
1569
1724
  const meta = metas[i];
1570
1725
  const s = sanitize(node.id);
@@ -1676,7 +1831,8 @@ isAsync = false) {
1676
1831
  const asBinding = { name: asName, ref: overElemRef, plan };
1677
1832
  for (const pn of portNames) {
1678
1833
  const ty = portFieldRustType(m.ports[pn], comp.inputPorts, `map port '${pn}' of node '${node.id}'`, asBinding, plan);
1679
- inits.push(emitPortInit(pn, m.ports[pn], ty, resolveRef, comp.inputPorts, plan));
1834
+ // #108-map: pass the element binding + base expr so a `$as` MAP/NAMED field port clones the typed composite.
1835
+ inits.push(emitPortInit(pn, m.ports[pn], ty, resolveRef, comp.inputPorts, plan, undefined, asBinding, `oel_${s}`));
1680
1836
  }
1681
1837
  out.push(`${il}let ep_${s} = ${structName} { ${inits.join(", ")} };`);
1682
1838
  return out;
@@ -2135,7 +2291,7 @@ ${emitStructSurface([])}
2135
2291
  // build the full typed-node map FIRST so a map's over-element resolution can see every node.
2136
2292
  for (const n of c.body)
2137
2293
  if (!isControlGate(n))
2138
- typedNodes.set(n.id, deriveTypeRef(n.outType, plan)); // #125: control-gate は typeless(typedNodes に載せない)
2294
+ typedNodes.set(n.id, "map" in n ? mapNodeArrRef(n, plan) : deriveTypeRef(n.outType, plan)); // #125: control-gate typeless; map produced-array ref に正規化
2139
2295
  for (const n of c.body) {
2140
2296
  if ("fanout" in n)
2141
2297
  structs.push(emitFanoutPortsStructs(c, n, typedNodes, plan));
@@ -2234,7 +2390,7 @@ export function rustTypedNativeObserve(ir, ioModel = "sync") {
2234
2390
  const typedNodes = new Map();
2235
2391
  for (const n of c.body)
2236
2392
  if (!isControlGate(n))
2237
- typedNodes.set(n.id, deriveTypeRef(n.outType, plan)); // #125: control-gate は typeless(typedNodes に載せない)
2393
+ typedNodes.set(n.id, "map" in n ? mapNodeArrRef(n, plan) : deriveTypeRef(n.outType, plan)); // #125: control-gate typeless; map produced-array ref に正規化
2238
2394
  const methods = [];
2239
2395
  for (const n of c.body) {
2240
2396
  if ("cond" in n)
@@ -2516,6 +2672,12 @@ function emitInDecoder(comp, plan) {
2516
2672
  lines.push(` other => return Err(marshal_type_error(${rustStrLit(`decode input ${p}: expected array, got `)}, other.type_name())),`);
2517
2673
  lines.push(` }`);
2518
2674
  }
2675
+ else if (schema?.type === "map" && et !== undefined) {
2676
+ // #108-map: decode the boxed Value::Obj into the native BTreeMap<String, V> (test glue — the input
2677
+ // map port carries a declared elemType). materializeExpr covers the map de-box (obj → BTreeMap).
2678
+ const mapRef = { kind: "map", value: deriveTypeRef(et, plan) };
2679
+ lines.push(` in_.${field} = ${materializeExpr("v", mapRef, plan)};`);
2680
+ }
2519
2681
  else if (scalar === undefined) {
2520
2682
  lines.push(` in_.${field} = v.clone();`);
2521
2683
  }