behavior-contracts 0.8.2 → 0.8.4

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 (36) hide show
  1. package/dist/authoring.d.ts +1 -1
  2. package/dist/authoring.d.ts.map +1 -1
  3. package/dist/authoring.js +41 -2
  4. package/dist/authoring.js.map +1 -1
  5. package/dist/behavior.d.ts +0 -24
  6. package/dist/behavior.d.ts.map +1 -1
  7. package/dist/behavior.js +24 -2
  8. package/dist/behavior.js.map +1 -1
  9. package/dist/generator/emit-shared-go-typed.d.ts.map +1 -1
  10. package/dist/generator/emit-shared-go-typed.js +7 -7
  11. package/dist/generator/emit-shared-go-typed.js.map +1 -1
  12. package/dist/generator/emit-shared-rust-typed.d.ts +8 -0
  13. package/dist/generator/emit-shared-rust-typed.d.ts.map +1 -1
  14. package/dist/generator/emit-shared-rust-typed.js +20 -6
  15. package/dist/generator/emit-shared-rust-typed.js.map +1 -1
  16. package/dist/generator/emit-shared-typescript.d.ts.map +1 -1
  17. package/dist/generator/emit-shared-typescript.js +18 -2
  18. package/dist/generator/emit-shared-typescript.js.map +1 -1
  19. package/dist/generator/emit-typed-native-go.d.ts.map +1 -1
  20. package/dist/generator/emit-typed-native-go.js +185 -85
  21. package/dist/generator/emit-typed-native-go.js.map +1 -1
  22. package/dist/generator/emit-typed-native-rust.d.ts.map +1 -1
  23. package/dist/generator/emit-typed-native-rust.js +231 -101
  24. package/dist/generator/emit-typed-native-rust.js.map +1 -1
  25. package/dist/generator/native-expr.d.ts +68 -5
  26. package/dist/generator/native-expr.d.ts.map +1 -1
  27. package/dist/generator/native-expr.js +94 -18
  28. package/dist/generator/native-expr.js.map +1 -1
  29. package/dist/generator/typed.d.ts +26 -0
  30. package/dist/generator/typed.d.ts.map +1 -1
  31. package/dist/generator/typed.js +52 -0
  32. package/dist/generator/typed.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.js +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/package.json +2 -2
@@ -2,12 +2,12 @@ import { GeneratorFailure } from "./core.js";
2
2
  import { isControlGate } from "../behavior.js";
3
3
  import { rustStraightlineInternals } from "./emit-shared-rust.js";
4
4
  import { rustTypedInternals } from "./emit-shared-rust-typed.js";
5
- import { buildTypePlan, deriveTypeRef, findDecl } from "./typed.js";
5
+ import { buildTypePlan, deriveTypeRef, findDecl, inputPortIsOptional, inputPortTypeRef } from "./typed.js";
6
6
  import { concurrencyPlan, execPlan, analyzeSequentialOps, classifyExpr } from "./straightline.js";
7
7
  import { NativeExprCompiler } from "./native-expr.js";
8
8
  import { buildAsyncPlans } from "./async-plan.js";
9
9
  const { sanitize, rustStrLit } = rustStraightlineInternals;
10
- const { rustFieldName, renderTypeRef, typedCell, typedFieldAccess, serializeTyped, emitTypeDecls, emitDefaults, emitSerializers, emitTypedValue, emitMarshallers, emitMarshalHelpers, materializeExpr, } = rustTypedInternals;
10
+ const { typeRefIsCopy, rustFieldName, renderTypeRef, typedCell, typedFieldAccess, serializeTyped, emitTypeDecls, emitDefaults, emitSerializers, emitTypedValue, emitMarshallers, emitMarshalHelpers, materializeExpr, } = rustTypedInternals;
11
11
  /** rustErr — build the LOCAL concrete error value for a behavior/plan failure on the covered read
12
12
  * plane (runtime-free). The covered module declares a local `BehaviorError` (see emitBehaviorErrorType);
13
13
  * the runner returns `Result<_, BehaviorError>` over THAT local type. This REPLACES the old
@@ -135,18 +135,13 @@ function inferPortType(node, inputPorts, asBinding) {
135
135
  }
136
136
  return "Value";
137
137
  }
138
+ // a ref to a REQUIRED scalar input port → that scalar. An OPTIONAL port has no required-scalar
139
+ // kind (its native type is Option<T>) and is lowered by the opt lane ahead of this — reaching here
140
+ // with one means the shape is uncovered, and "Value" routes it to the caller's loud fail-closed.
138
141
  if (e.path.length === 1) {
139
- const s = inputPorts[e.path[0]];
140
- if (s) {
141
- if (s.type === "string" || s.type === "literal")
142
- return "String"; // literal = constrained String.
143
- if (s.type === "int")
144
- return "i64";
145
- if (s.type === "float" || s.type === "number")
146
- return "f64"; // `"number"` = the float channel.
147
- if (s.type === "bool")
148
- return "bool";
149
- }
142
+ const sc = inputScalarKind(inputPorts[e.path[0]]);
143
+ if (sc !== undefined)
144
+ return sc;
150
145
  }
151
146
  return "Value";
152
147
  }
@@ -180,6 +175,12 @@ function staticStringArrElems(node) {
180
175
  * fallback on the native module). `where` names the emission site for the error.
181
176
  */
182
177
  function portFieldRustType(node, inputPorts, where = "port", asBinding, plan, typedNodes) {
178
+ // A port reading an OPTIONAL input port lowers through the shared native-expr compiler (the
179
+ // Option<T> pass-through / the coalesce default). It runs FIRST so an optional input can never fall
180
+ // into the required-scalar inference below (which has no way to say "absent").
181
+ const optPort = optPortCompileR(node, inputPorts, where, plan);
182
+ if (optPort !== null)
183
+ return renderTypeRef(optPort.ref);
183
184
  if (staticStringArrElems(node) !== null)
184
185
  return "Vec<&'static str>";
185
186
  // #110: a componentRef port bound to a runtime array input port (with a resolvable elemType) → Vec<ElemT>.
@@ -202,10 +203,61 @@ function portFieldRustType(node, inputPorts, where = "port", asBinding, plan, ty
202
203
  return renderTypeRef(composite);
203
204
  const ft = inferPortType(node, inputPorts, asBinding);
204
205
  if (ft === "Value") {
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.`);
206
+ 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. ${RUST_STATIC_PORT_FORMS} Regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
206
207
  }
207
208
  return ft;
208
209
  }
210
+ /**
211
+ * optPortCompileR — the OPTIONAL-input port lane (rust). A component may declare an input port
212
+ * `{opt:T}` (PortSchema `required:false`); the only native representation of "the value is absent" is
213
+ * `Option<T>`, so such a port never lowers through the required-scalar inference. This lane compiles the
214
+ * port expression with the SHARED runtime-free native-expr compiler (native-expr.ts — the same traversal,
215
+ * type-inference and fail-closed discipline the go twin uses, so the two agree by construction):
216
+ *
217
+ * - `opt($.x)` / `$.x` at port position → `Option<T>` (the leaf receives the absent value as null,
218
+ * exactly as run_behavior's `evalPorts` passes it).
219
+ * - `coalesce(opt($.x), <literal>)` → `in_.x.unwrap_or(<literal>)` — a native `T`, no boxed Value.
220
+ * - `ne(opt($.x), null)` / `eq(…, null)` → the native presence test (`is_some()` / `is_none()`).
221
+ *
222
+ * Returns null when the port expression reads NO optional input (the caller keeps its existing lowering).
223
+ * A port that DOES read one but does not compile — or that compiles to a fallible expression, which this
224
+ * position cannot hoist — FAILS CLOSED loudly rather than degrade to a required type or a zero value.
225
+ */
226
+ function optPortCompileR(node, inputPorts, where, plan) {
227
+ if (plan === undefined)
228
+ return null;
229
+ if (!exprReadsOptionalInput(node, inputPorts))
230
+ return null;
231
+ const resolveHead = (head) => rustInputHeadRef(head, { inputPorts }, plan);
232
+ let c;
233
+ try {
234
+ c = new NativeExprCompiler(makeRustExprBackend(resolveHead, plan)).compilePort(node);
235
+ }
236
+ catch (e) {
237
+ if (!(e instanceof GeneratorFailure))
238
+ throw e;
239
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native (runtime-free): ${where} ${JSON.stringify(node)} reads an OPTIONAL input port but does not lower to a native Rust value: ${e.message}`);
240
+ }
241
+ if (c.stmts.length > 0)
242
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native (runtime-free): ${where} ${JSON.stringify(node)} reads an OPTIONAL input port through a FALLIBLE expression (one that can raise INT_OVERFLOW / NAN_OR_INF / MOD_ZERO / PRECISION_LOSS). Such an expression lowers to STATEMENTS carrying an early error-return, and a port is built inline in the ports-struct literal — a position that cannot host them. (A cond/guard position CAN host them, so the same expression is covered there — the boundary is the POSITION, not the operator.) Use a non-fallible port expression.`);
243
+ return c;
244
+ }
245
+ /** exprReadsOptionalInput — does this port expression reference an OPTIONAL (`required:false`) input
246
+ * port? Language-neutral scan for `ref`/`refOpt` heads bound to an optional port. Decides ONLY which
247
+ * lane a port takes; the lane itself then proves the lowering (or fails closed). */
248
+ function exprReadsOptionalInput(node, inputPorts) {
249
+ if (node === null || typeof node !== "object")
250
+ return false;
251
+ if (Array.isArray(node))
252
+ return node.some((el) => exprReadsOptionalInput(el, inputPorts));
253
+ const rec = node;
254
+ const keys = Object.keys(rec);
255
+ if (keys.length === 1 && (keys[0] === "ref" || keys[0] === "refOpt")) {
256
+ const path = rec[keys[0]];
257
+ return Array.isArray(path) && typeof path[0] === "string" && inputPortIsOptional(inputPorts?.[path[0]]);
258
+ }
259
+ return keys.some((k) => exprReadsOptionalInput(rec[k], inputPorts));
260
+ }
209
261
  /** portCompositeRef — the full TypeRef of a port that is a `{ref:[...]}` into a `$as` element field OR
210
262
  * an input port whose type is a MAP or NAMED struct (a non-scalar covered field). Returns null when the
211
263
  * node is not such a ref (so the caller falls through to the scalar/array inference). #108-map: enables a
@@ -393,21 +445,40 @@ function makeRustExprBackend(resolveHead, plan) {
393
445
  // position (a bare `None` can be ambiguous; the explicit turbofish keeps it a fully-typed value).
394
446
  optNone: (innerTy) => `Option::<${innerTy}>::None`,
395
447
  // 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()`,
448
+ // A presence test only READS the discriminant, so an owning clone of the operand (which a head
449
+ // resolver adds so the value can be moved into a port) is dropped here — testing `Option<String>`
450
+ // must not heap-allocate. Mirrors the go twin's zero-cost `!= nil`.
451
+ optIsSome: (expr) => `${rustStripTrailingClone(expr)}.is_some()`,
452
+ optIsNone: (expr) => `${rustStripTrailingClone(expr)}.is_none()`,
453
+ // opt (`Option<T>`) defaulted to a PURE default — it cannot fail and has no effect, so its
454
+ // evaluation order is UNOBSERVABLE and both forms below are correct (a FALLIBLE default, where
455
+ // laziness IS observable, goes to optCoalesceGuard instead). `unwrap_or` is EAGER — fine for a plain
456
+ // literal/copy. A default that allocates (an owned `String`, a struct/vec literal) uses
457
+ // `unwrap_or_else`, whose closure defers the allocation to the absent branch: not a semantic
458
+ // requirement here, but pointless work otherwise — and what clippy `or_fun_call` asks for.
459
+ optUnwrapOr: (optExpr, defaultExpr, _innerTy) => /^[A-Za-z0-9_.:]+$/.test(defaultExpr) ? `${optExpr}.unwrap_or(${defaultExpr})` : `${optExpr}.unwrap_or_else(|| ${defaultExpr})`,
460
+ // opt defaulted to a FALLIBLE default — a `match`, NOT a closure: the default's hoisted `?` early
461
+ // returns must propagate out of the ENCLOSING fn, which `unwrap_or_else`'s closure would swallow
462
+ // (it would try to return from the closure). Only the None arm evaluates the default.
463
+ optCoalesceGuard: (temp, ty, optExpr, bindTemp, dStmts, dExpr) => [
464
+ `let ${temp}: ${ty} = match ${optExpr} {`,
465
+ ` Some(${bindTemp}) => ${bindTemp},`,
466
+ ` None => { ${rustBlockBody(dStmts, dExpr)} }`,
467
+ `};`,
468
+ ],
398
469
  ternary: (cond, t, e, _ty) => `(if ${rustStripOuterParens(cond)} { ${rustStripOuterParens(t)} } else { ${rustStripOuterParens(e)} })`,
399
470
  shortCircuitBool: (op, temp, left, rStmts, rExpr) => {
400
471
  // and: let temp = if left { <rStmts> rExpr } else { false }; or: if left { true } else { <rStmts> rExpr }.
401
472
  // build a block that runs rStmts then yields rExpr (parens stripped — block-tail is not a sub-expr).
402
- const rblock = `{ ${rStmts.join(" ")} ${rustStripOuterParens(rExpr)} }`;
473
+ const rblock = `{ ${rustBlockBody(rStmts, rExpr)} }`;
403
474
  if (op === "and") {
404
475
  return [`let ${temp}: bool = if ${rustStripOuterParens(left)} ${rblock} else { false };`];
405
476
  }
406
477
  return [`let ${temp}: bool = if ${rustStripOuterParens(left)} { true } else ${rblock};`];
407
478
  },
408
479
  condGuard: (temp, ty, cond, tStmts, tExpr, eStmts, eExpr) => {
409
- const tblock = `{ ${tStmts.join(" ")} ${rustStripOuterParens(tExpr)} }`;
410
- const eblock = `{ ${eStmts.join(" ")} ${rustStripOuterParens(eExpr)} }`;
480
+ const tblock = `{ ${rustBlockBody(tStmts, tExpr)} }`;
481
+ const eblock = `{ ${rustBlockBody(eStmts, eExpr)} }`;
411
482
  return [`let ${temp}: ${ty} = if ${rustStripOuterParens(cond)} ${tblock} else ${eblock};`];
412
483
  },
413
484
  };
@@ -416,6 +487,17 @@ function makeRustExprBackend(resolveHead, plan) {
416
487
  function rustSnake(name) {
417
488
  return name.replace(/([a-z])([A-Z0-9])/g, "$1_$2").replace(/([0-9])([a-z])/g, "$1_$2").toLowerCase();
418
489
  }
490
+ /** rustBlockBody — the body of a block-expression that runs `stmts` then yields `expr`. A trailing
491
+ * `let t = E; t` is collapsed to `E`: binding a temp only to return it on the next line is clippy's
492
+ * `let_and_return` (an error under `-D warnings`), and the hoisted-fallible shape produces exactly that
493
+ * whenever the block's tail IS the hoisted temp. */
494
+ function rustBlockBody(stmts, expr) {
495
+ const last = stmts[stmts.length - 1];
496
+ const m = last !== undefined ? /^let\s+([A-Za-z0-9_]+)(?::\s*[^=]+)?\s*=\s*(.+);$/.exec(last) : null;
497
+ if (m !== null && m[1] === expr)
498
+ return [...stmts.slice(0, -1), m[2]].join(" ");
499
+ return [...stmts, rustStripOuterParens(expr)].join(" ");
500
+ }
419
501
  /** strip ONE redundant outer paren pair from an expr (clippy `if (cond)` unused_parens). Only when the
420
502
  * whole string is a single balanced `( … )` — a `(a) && (b)` stays untouched. */
421
503
  function rustStripOuterParens(expr) {
@@ -433,6 +515,12 @@ function rustStripOuterParens(expr) {
433
515
  }
434
516
  return expr.slice(1, -1);
435
517
  }
518
+ /** Drop a TRAILING `.clone()` from an expression — for a position that only reads the value (no move),
519
+ * so an owning clone added by a head resolver is pure waste. Only a clone at the very END is dropped: a
520
+ * `.clone()` mid-path (`t_x.borrow().clone().field`) still owns the field access that follows it. */
521
+ function rustStripTrailingClone(expr) {
522
+ return expr.endsWith(".clone()") ? expr.slice(0, -".clone()".length) : expr;
523
+ }
436
524
  /** Rust float literal (finite; integral gets `.0`). */
437
525
  function rustFloatLitExpr(n) {
438
526
  if (Number.isInteger(n))
@@ -667,9 +755,13 @@ function emitHandlerTrait(comp, typedNodes, plan, ap) {
667
755
  lines.push(`}`);
668
756
  return lines.join("\n");
669
757
  }
670
- /** mapElemMaterializerName — the augmented-element copier for a covered map...into node. */
671
- function mapElemMaterializerName(nodeId) {
672
- return `materialize_map_elem_${typedCell(nodeId)}`;
758
+ /** mapElemMaterializerName — the augmented-element copier for a covered map...into node.
759
+ * QUALIFIED by component name: two components may each carry a map node at the SAME
760
+ * recorder-scan index (e.g. both `…authorFollows` and `…groupsNested` have a map at `n4`),
761
+ * and a node-id-only name would collide at module scope (E0428). Mirrors the Go emitter
762
+ * (`materializeMapElemNR_<comp>_<node>`) and the sibling `decode_row_<comp>_<node>` helper. */
763
+ function mapElemMaterializerName(compName, nodeId) {
764
+ return `materialize_map_elem_${sanitize(compName)}_${typedCell(nodeId)}`;
673
765
  }
674
766
  /** mapNodeArrRef — the map node's PRODUCED-ARRAY TypeRef from its `outType`, normalizing the two IR
675
767
  * conventions to the one array shape every downstream site assumes (`typedNodes.get(mapId)` is an arr):
@@ -725,7 +817,7 @@ function emitMapElemMaterializers(comp, typedNodes, plan) {
725
817
  const intoField = decl.fields.find((f) => f.name === intoKey);
726
818
  const overFields = decl.fields.filter((f) => f.name !== intoKey);
727
819
  const overElemTy = mapOverElemType(comp, n, typedNodes, plan);
728
- const fn = mapElemMaterializerName(n.id);
820
+ const fn = mapElemMaterializerName(comp.name, n.id);
729
821
  const elemRowGo = rawElemStructName(comp.name, n.id);
730
822
  const intoRowRef = mapElemRowRef(n, arrRef, plan);
731
823
  const inits = [];
@@ -855,14 +947,20 @@ function numLiteralRustExpr(node) {
855
947
  // fractional / exponent literal → checked f64 (finite, guaranteed above).
856
948
  return `Value::Float(${node}f64)`;
857
949
  }
858
- /** A port node is static (literal / ref / concat of those / static arr / bare number literal)
859
- * resolvable to a native Value with no Obj / no evaluate interpreter. */
860
- function portIsStatic(node) {
950
+ /** A port node is static (literal / ref / concat of those / static arr / bare number literal / an
951
+ * optional-input read) — resolvable to a native value with no Obj / no evaluate interpreter. This is the
952
+ * SHAPE gate that decides native ELIGIBILITY; the lowering itself (portFieldRustType / emitPortInit) is
953
+ * what proves each shape, and fails closed loudly on one it cannot lower. */
954
+ function portIsStatic(node, inputPorts) {
861
955
  const arr = staticArrElems(node);
862
956
  if (arr !== null)
863
- return arr.every(portIsStatic);
957
+ return arr.every((el) => portIsStatic(el, inputPorts));
864
958
  if (numLiteralRustExpr(node) !== null)
865
959
  return true; // bare number literal (e.g. Query `limit`)
960
+ // An optional-input read (`opt($.x)` / `coalesce(opt($.x), 20)` / `ne(opt($.x), null)`) is a
961
+ // statically-resolvable port — the opt lane lowers it (or fails closed with the specific reason).
962
+ if (inputPorts !== undefined && exprReadsOptionalInput(node, inputPorts))
963
+ return true;
866
964
  const e = classifyExpr(node);
867
965
  switch (e.kind) {
868
966
  case "str":
@@ -871,7 +969,7 @@ function portIsStatic(node) {
871
969
  case "ref":
872
970
  return true;
873
971
  case "concat":
874
- return e.parts.every((p) => portIsStatic(reconstructR(p)));
972
+ return e.parts.every((p) => portIsStatic(reconstructR(p), inputPorts));
875
973
  default:
876
974
  return false;
877
975
  }
@@ -940,7 +1038,7 @@ function coveredMapNode(n, bodyIds, comp) {
940
1038
  }
941
1039
  const ports = m.ports;
942
1040
  for (const p of Object.keys(ports))
943
- if (!portIsStatic(ports[p]))
1041
+ if (!portIsStatic(ports[p], comp.inputPorts))
944
1042
  return false;
945
1043
  return true;
946
1044
  }
@@ -967,7 +1065,7 @@ function coveredFanoutNode(n, bodyIds, comp) {
967
1065
  }
968
1066
  const ports = f.ports;
969
1067
  for (const p of Object.keys(ports))
970
- if (!portIsStatic(ports[p]))
1068
+ if (!portIsStatic(ports[p], comp.inputPorts))
971
1069
  return false;
972
1070
  return true;
973
1071
  }
@@ -1078,7 +1176,7 @@ function coverageRejectReason(comp) {
1078
1176
  if (cr.relationKind !== undefined && cr.relationKind !== "single" && cr.relationKind !== "connection")
1079
1177
  return `relationKind '${cr.relationKind}' not native-covered (node '${n.id}')`;
1080
1178
  for (const p of Object.keys(cr.ports))
1081
- if (portIsStatic(cr.ports[p]) === false)
1179
+ if (portIsStatic(cr.ports[p], comp.inputPorts) === false)
1082
1180
  return `node '${n.id}' port '${p}' is not statically resolvable`;
1083
1181
  }
1084
1182
  if (!outputIsNativeLowerable(comp))
@@ -1106,44 +1204,31 @@ function assertTypedCoverage(comp) {
1106
1204
  throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#77): component '${comp.name}' is a sequential componentRef read but lacks outType/outputType on every node — the combined emitter de-boxes the RESULT into the node's outType struct, so a covered read MUST be fully typed (bc#45). Add outType annotations, or regenerate on rust-straightline-native (boxed result).`);
1107
1205
  }
1108
1206
  // ── typed input struct (InNR<Comp>) + NATIVE scalar port lowering ────────────────────────────
1109
- /** Rust type for an input port's declared type (scalar → concrete; array+elemType → Vec<ElemT>; else Value). */
1110
- function inputPortRustType(schema, plan) {
1111
- switch (schema?.type) {
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":
1116
- return "String";
1117
- case "int":
1118
- return "i64";
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":
1123
- return "f64";
1124
- case "bool":
1125
- return "bool";
1126
- case "array":
1127
- case "arr": {
1128
- // #108: an input ARRAY port with a declared elemType lowers to a native Vec<ElemT>.
1129
- const et = schema.elemType;
1130
- if (et !== undefined && plan !== undefined)
1131
- return `Vec<${renderTypeRef(deriveTypeRef(et, plan))}>`;
1132
- return "Value";
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
- }
1141
- default:
1142
- return "Value";
1143
- }
1144
- }
1145
- /** The scalar kind an input port declares, or undefined if it is not a native scalar. */
1207
+ /**
1208
+ * inputPortRustType the native Rust type for an input port's declared type. Scalars lower to the
1209
+ * concrete scalar; `array`/`map` with a declared elemType to `Vec<ElemT>` / `BTreeMap<String, ElemT>`;
1210
+ * an OPTIONAL port (`{opt:T}` → `required:false`) to `Option<T>` — the native representation of "the
1211
+ * value is absent". A declared type with no native lowering stays the boxed `Value`.
1212
+ *
1213
+ * An OPTIONAL port whose inner type has no native lowering FAILS CLOSED: `Option<Value>` would be a
1214
+ * boxed escape, and emitting the bare inner type would silently turn "absent" into a zero value. A port
1215
+ * whose optionality cannot be represented is never silently coerced.
1216
+ */
1217
+ function inputPortRustType(schema, plan, where = "input port") {
1218
+ const ref = inputPortTypeRef(schema, plan);
1219
+ if (ref !== undefined)
1220
+ return renderTypeRef(ref);
1221
+ if (inputPortIsOptional(schema))
1222
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native (runtime-free): ${where} is OPTIONAL (declared {opt:…}) but its inner type ${JSON.stringify(schema?.type)} has no native Rust lowering, so "absent" has no native representation (an Option<Value> would be a boxed escape, and the bare inner type would silently read absent as a zero value). Declare an elemType for an array/map, or use a natively-lowerable scalar.`);
1223
+ return "Value";
1224
+ }
1225
+ /** The scalar kind a REQUIRED input port declares, or undefined when it is not a required native scalar.
1226
+ * An OPTIONAL port is deliberately NOT a scalar kind here: its native type is `Option<T>`, and the
1227
+ * callers of this function build REQUIRED scalar slots (`in_.<field>` read straight into a non-opt port
1228
+ * field). Answering "i64" for an `{opt:"int"}` port is exactly how "absent" would silently become `0`. */
1146
1229
  function inputScalarKind(schema) {
1230
+ if (inputPortIsOptional(schema))
1231
+ return undefined;
1147
1232
  switch (schema?.type) {
1148
1233
  case "string":
1149
1234
  case "literal": // a `"literal"` union is a constrained String (see inputPortRustType).
@@ -1168,7 +1253,7 @@ function emitInStruct(comp, plan) {
1168
1253
  return `// ${name} — the CONCRETE input for '${comp.name}' (no input ports).\n#[derive(Default)]\npub struct ${name};`;
1169
1254
  }
1170
1255
  const fields = ports
1171
- .map((p) => ` pub ${rustFieldName(p)}: ${inputPortRustType(comp.inputPorts[p], plan)}, // ${JSON.stringify(p)}`)
1256
+ .map((p) => ` pub ${rustFieldName(p)}: ${inputPortRustType(comp.inputPorts[p], plan, `input port '${p}' of component '${comp.name}'`)}, // ${JSON.stringify(p)}`)
1172
1257
  .join("\n");
1173
1258
  return `// ${name} — the CONCRETE input for '${comp.name}' (fields = inputPorts; typed, consumer-built —
1174
1259
  // NO generic Value slice, NO per-field boxing crosses the covered read boundary).
@@ -1177,6 +1262,9 @@ pub struct ${name} {
1177
1262
  ${fields}
1178
1263
  }`;
1179
1264
  }
1265
+ /** The port forms the STATIC (non-optional) lane lowers — named in its reject message so the message
1266
+ * describes what is actually covered rather than a stale corpus list. */
1267
+ const RUST_STATIC_PORT_FORMS = "This port does not read an optional input, so it lowers through the STATIC port forms: a string / bool / number literal, a static string-array (projection), a ref to a REQUIRED scalar input port, a ref to an input array/map port or a prior node's array (declared elemType), a ref to a `$as` element field, or a concat of native strings. A dynamic operator at port position is not covered on this lane. (A port that DOES read an optional input lowers through the full native expression compiler, which admits the whole closed operator set — so the covered operator set at port position currently depends on the lane, not on the operator.)";
1180
1268
  /**
1181
1269
  * emitNativeScalar — lower a static port expr to a NATIVE Rust expression of the concrete `want` scalar
1182
1270
  * type, or null if it cannot be statically proven that type. A string literal is native for
@@ -1250,6 +1338,11 @@ function emitNativeScalar(node, want, resolveRef) {
1250
1338
  */
1251
1339
  function emitPortInit(pn, expr, fieldRustType, resolveRef, inputPorts, plan, typedNodes, asBinding, elemBaseExpr) {
1252
1340
  const field = portFieldName(pn);
1341
+ // The OPTIONAL-input lane (Option<T> pass-through / coalesce default) — same shared compile as
1342
+ // portFieldRustType, so the field type and its initializer are derived from ONE lowering.
1343
+ const optPort = optPortCompileR(expr, inputPorts ?? {}, `port '${pn}'`, plan);
1344
+ if (optPort !== null)
1345
+ return `${field}: ${optPort.expr}`;
1253
1346
  // #108-map: a port that is a ref to a `$as` element MAP/NAMED field (or an input map port) → CLONE the
1254
1347
  // typed composite value directly off the element/input (native BTreeMap / struct — ZERO boxed Value).
1255
1348
  // Reuses portCompositeRef for the type; the value is the typed field access `.clone()`.
@@ -1299,7 +1392,7 @@ function emitPortInit(pn, expr, fieldRustType, resolveRef, inputPorts, plan, typ
1299
1392
  // FAIL CLOSED: the covered read plane is runtime-free — no boxed Value fallback (that would re-couple
1300
1393
  // the covered module to bc-runtime). If a genuinely-dynamic port reaches here, the component is not
1301
1394
  // native-eligible and must regenerate on the boxed straight-line path.
1302
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#90 / runtime-free): port '${pn}' (${JSON.stringify(expr)}) does not lower to a native Rust value of type '${fieldRustType}'. The covered read plane admits NO boxed Value escape regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
1395
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined read (bc#90 / runtime-free): port '${pn}' (${JSON.stringify(expr)}) does not lower to a native Rust value of type '${fieldRustType}'. The covered read plane admits NO boxed Value escape. ${RUST_STATIC_PORT_FORMS} Regenerate on the boxed straight-line path if this port is genuinely dynamic.`);
1303
1396
  }
1304
1397
  // ── the combined runner (STRUCT-returning; INLINE sequential; native ports in; concrete row out) ──
1305
1398
  /** resolveRefAt for the top-level runner: a ref head is a PRIOR NODE (its typed scalar field) or an
@@ -1771,7 +1864,7 @@ isAsync = false) {
1771
1864
  const batch = `${structName}Batch`;
1772
1865
  // #108: over element type + the OWNED over-Vec expression (prior-node arr OR input array w/ elemType).
1773
1866
  const { elemRef: overElemRef, overExpr } = mapOverElemInfo(comp, node, typedNodes, plan);
1774
- const elemMat = mapElemMaterializerName(node.id);
1867
+ const elemMat = mapElemMaterializerName(comp.name, node.id);
1775
1868
  const priorHere = (head) => priorNodeAt(head, atPos);
1776
1869
  const asName = m.as;
1777
1870
  const portNames = Object.keys(m.ports);
@@ -2073,24 +2166,29 @@ function emitFanoutArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync
2073
2166
  lines.push(c);
2074
2167
  return lines.join("\n");
2075
2168
  }
2076
- /** #108: resolve an INPUT port head for a native-expr ref (rust). Scalar → `in_.<field>` (owned: String
2077
- * cloned, Copy as-is); array WITH elemType `in_.<field>.clone()` of Vec<ElemT> (for `len`); else null. */
2169
+ /**
2170
+ * rustInputHeadRef (#108) resolve an INPUT port head for a native-expr ref (rust). The port's declared
2171
+ * type comes from the shared `inputPortTypeRef` SSoT, so an OPTIONAL port resolves as `{opt:…}` (native
2172
+ * `Option<T>`) and the compiler can see its optionality: a null-compare becomes a real presence test and
2173
+ * a coalesce a real `unwrap_or`, while a REQUIRED-scalar slot fed by an opt fails closed. Exprs are OWNED
2174
+ * (String / Option<String> cloned; Copy scalars as-is), except an array — the only array use inside a
2175
+ * cond/guard expr is `len`, which BORROWS (`&in_.xs`).
2176
+ */
2078
2177
  function rustInputHeadRef(head, comp, plan) {
2079
2178
  const schema = comp.inputPorts?.[head];
2080
- if (schema === undefined)
2179
+ const ref = inputPortTypeRef(schema, plan);
2180
+ if (ref === undefined)
2081
2181
  return null;
2082
- const sc = inputScalarKind(schema);
2083
- if (sc !== undefined) {
2084
- const scalarRef = { kind: "scalar", scalar: sc === "i64" ? "int" : sc === "f64" ? "float" : sc === "String" ? "string" : "bool" };
2085
- const field = `in_.${rustFieldName(head)}`;
2086
- return { expr: sc === "String" ? `${field}.clone()` : field, ref: scalarRef };
2087
- }
2088
- const et = schema.elemType;
2089
- if ((schema.type === "array" || schema.type === "arr") && et !== undefined) {
2090
- // no clone: the only array use inside a cond/guard expr is `len`, which BORROWS (`&in_.xs`).
2091
- return { expr: `in_.${rustFieldName(head)}`, ref: { kind: "arr", elem: deriveTypeRef(et, plan) } };
2092
- }
2093
- return null;
2182
+ const field = `in_.${rustFieldName(head)}`;
2183
+ return { expr: rustOwnedHeadExpr(field, ref), ref };
2184
+ }
2185
+ /** The OWNED native expression for a head of type `ref` read off a by-value struct field. A String (or an
2186
+ * Option/collection containing one) is cloned; a Copy scalar (and an Option/array of Copy scalars) is
2187
+ * read directly — cloning a Copy value trips clippy's `clone_on_copy`. An array stays borrowed (`len`). */
2188
+ function rustOwnedHeadExpr(field, ref) {
2189
+ if (ref.kind === "arr")
2190
+ return field; // borrowed by `len`
2191
+ return typeRefIsCopy(ref) ? field : `${field}.clone()`;
2094
2192
  }
2095
2193
  /**
2096
2194
  * emitCondArm — the struct-native exec of a covered cond node (#108, rust). Mirrors run_behavior's cond
@@ -2465,17 +2563,22 @@ export function rustTypedNativeObserve(ir, ioModel = "sync") {
2465
2563
  const node = n;
2466
2564
  const rowT = rawRowStructName(c.name, node.id);
2467
2565
  const decode = emitDecodeRow(c.name, node.id, rowT, ref, plan, emittedDecode, decodeFns, "");
2468
- // #129 (TEST glue): if this node has an `items` port and its outType is a bare arr, support the
2469
- // reference scriptedHandlers' `echo === "items"` the scripted handler ECHOES the NATIVE `f_items`
2470
- // port back as its result. This makes the node→leaf array dataflow test non-vacuous: the leaf's
2471
- // observed result is exactly the native array that flowed into its ports struct, so a dropped or
2472
- // boxed native array would diverge from run_behavior. `ports` is bound only on the echo path.
2473
- const canEcho = ref.kind === "arr" && Object.prototype.hasOwnProperty.call(node.ports, "items");
2474
- const portsParam = canEcho ? "ports" : "_ports";
2475
- const echoBranch = canEcho
2476
- ? `\n if echo == "items" {\n return Some(${rowT} { val: ${portsParam}.${portFieldName("items")}.clone(), ..Default::default() });\n }`
2477
- : "";
2478
- const okBind = canEcho ? "(is_err, err, ok, echo)" : "(is_err, err, ok, _echo)";
2566
+ // #129 (TEST glue): support the reference scriptedHandlers' echo forms (PROTOCOL.md §3.5) the
2567
+ // adapter normalizes `{"echo":"port","port":"X"}` / `{"echo":"items"}` to the port NAME, and the
2568
+ // scripted handler
2569
+ // ECHOES a NATIVE port back as its result. This makes a port-plane pin non-vacuous: the node's
2570
+ // observed result IS the native value that flowed into its ports struct, so a dropped / re-boxed /
2571
+ // zero-defaulted port diverges from run_behavior instead of passing. A port is echoable when its
2572
+ // native type IS the node's outType (that is exactly when the row's `val` can hold it); `ports` is
2573
+ // bound only when at least one port qualifies.
2574
+ const echoable = echoablePorts(c, node, ref, plan, typedNodes);
2575
+ const portsParam = echoable.length > 0 ? "ports" : "_ports";
2576
+ // a Copy port (i64/f64/bool/Option<scalar>) is read directly cloning it trips clippy clone_on_copy.
2577
+ const echoRead = (p) => `${portsParam}.${portFieldName(p)}${typeRefIsCopy(ref) ? "" : ".clone()"}`;
2578
+ const echoBranch = echoable
2579
+ .map((p) => `\n if echo == ${rustStrLit(p)} {\n return Some(${rowT} { val: ${echoRead(p)}, ..Default::default() });\n }`)
2580
+ .join("");
2581
+ const okBind = echoable.length > 0 ? "(is_err, err, ok, echo)" : "(is_err, err, ok, _echo)";
2479
2582
  methods.push(` ${fnKw} ${nodeMethodName(node.id)}(&self, ${portsParam}: &${portsStructName(c.name, node.id)}, _bound: ${bt}) -> Option<${rowT}> {
2480
2583
  let ${okBind} = self.next(${rustStrLit(node.component)})?;
2481
2584
  if is_err {
@@ -2544,7 +2647,14 @@ impl ScriptedNativeRaw {
2544
2647
  let mut queues = self.queues.lock().unwrap();
2545
2648
  let (queue, single) = queues.get_mut(component)?;
2546
2649
  let raw = if *single || queue.len() <= 1 { queue[0].clone() } else { queue.remove(0) };
2547
- let echo = raw.get("echo").and_then(|e| e.as_str()).unwrap_or("").to_string();
2650
+ // PROTOCOL.md §3.5 echo forms, normalized to the port NAME: {"echo":"port","port":"X"} -> "X"
2651
+ // ({"echo":"items"} is already that shape). The generated node method just compares this
2652
+ // against its echoable port names.
2653
+ let echo = match raw.get("echo").and_then(|e| e.as_str()) {
2654
+ Some("port") => raw.get("port").and_then(|p| p.as_str()).unwrap_or("").to_string(),
2655
+ Some(other) => other.to_string(),
2656
+ None => String::new(),
2657
+ };
2548
2658
  if let Some(okv) = raw.get("ok") {
2549
2659
  let v = behavior_contracts::decode_value(okv).expect("decode ok");
2550
2660
  Some((false, String::new(), Some(v), echo))
@@ -2635,8 +2745,10 @@ ${arms}
2635
2745
  }
2636
2746
  /** emitInDecoder — a TEST-glue decoder `decode_InNR_<comp>(input) -> Result<InNR_<comp>, BehaviorError>`.
2637
2747
  * Reads each declared input port from the generic &[(String,Value)] into the concrete field (scalar →
2638
- * typed read; Value-typed → the raw Value clone). A missing key leaves Default (every covered input is
2639
- * required + supplied by the corpus). Lives ONLY in the observe companion. */
2748
+ * typed read; opt → Some/None; Value-typed → the raw Value clone). A key that is absent, or present as
2749
+ * `Value::Null`, leaves the field's Default for an OPTIONAL port that is `None` (absent), which is the
2750
+ * same value `refCore` observes for the `null` binding run_behavior is given. Lives ONLY in the observe
2751
+ * companion. */
2640
2752
  function emitInDecoder(comp, plan) {
2641
2753
  const name = inStructName(comp.name);
2642
2754
  const ports = Object.keys(comp.inputPorts ?? {});
@@ -2658,7 +2770,15 @@ function emitInDecoder(comp, plan) {
2658
2770
  const et = schema.elemType;
2659
2771
  const head = i === 0 ? " if" : " } else if";
2660
2772
  lines.push(`${head} k == ${rustStrLit(p)} {`);
2661
- if ((schema?.type === "array" || schema?.type === "arr") && et !== undefined) {
2773
+ if (inputPortIsOptional(schema)) {
2774
+ // An OPTIONAL port decodes into its native Option<T>. materializeExpr's opt de-box IS the wire
2775
+ // rule: `Value::Null` → None (absent), any other value → Some(<inner>). A key that never appears
2776
+ // keeps the struct Default (None) — the same absent value. Checked FIRST: optionality wraps every
2777
+ // inner type (an optional array is Option<Vec<T>>, not Vec<T>).
2778
+ const optRef = inputPortTypeRef(schema, plan);
2779
+ lines.push(` in_.${field} = ${materializeExpr("v", optRef, plan)};`);
2780
+ }
2781
+ else if ((schema?.type === "array" || schema?.type === "arr") && et !== undefined) {
2662
2782
  // #108: decode the boxed Value::Arr into the native Vec<ElemT> (test glue — materialize each elem).
2663
2783
  const elemRef = deriveTypeRef(et, plan);
2664
2784
  const elemTy = renderTypeRef(elemRef);
@@ -2696,6 +2816,16 @@ function emitInDecoder(comp, plan) {
2696
2816
  lines.push(`}`);
2697
2817
  return lines.join("\n");
2698
2818
  }
2819
+ /** echoablePorts (TEST glue) — the node's ports whose NATIVE type is exactly the node's outType, in
2820
+ * declaration order. Those are the ports a scripted `echo: "<port>"` can return as the row's `val`. A
2821
+ * port with a different type is not echoable (the row could not hold it), and a NAMED outType has no
2822
+ * `val` at all (its row carries the struct's fields flattened — see emitDecodeRow). */
2823
+ function echoablePorts(comp, node, ref, plan, typedNodes) {
2824
+ if (ref.kind === "named")
2825
+ return [];
2826
+ const want = renderTypeRef(ref);
2827
+ return Object.keys(node.ports).filter((p) => portFieldRustType(node.ports[p], comp.inputPorts, `port '${p}'`, undefined, plan, typedNodes) === want);
2828
+ }
2699
2829
  /** emitDecodeRow — emit (once, deduped) a decoder `decode_row_<comp>_<node><suffix>(v: &Value) -> <rowT>`
2700
2830
  * that materializes the scripted ok Value into the concrete row struct. Named: reuse the Value->struct
2701
2831
  * marshaller + move fields; scalar/arr/opt: materialize into .val. TEST glue only. */