behavior-contracts 0.5.0 → 0.7.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 (112) hide show
  1. package/dist/behavior.d.ts +7 -0
  2. package/dist/behavior.d.ts.map +1 -1
  3. package/dist/behavior.js.map +1 -1
  4. package/dist/builder.d.ts.map +1 -1
  5. package/dist/builder.js +10 -3
  6. package/dist/builder.js.map +1 -1
  7. package/dist/generator/async-plan.d.ts +78 -0
  8. package/dist/generator/async-plan.d.ts.map +1 -0
  9. package/dist/generator/async-plan.js +116 -0
  10. package/dist/generator/async-plan.js.map +1 -0
  11. package/dist/generator/core.d.ts +30 -9
  12. package/dist/generator/core.d.ts.map +1 -1
  13. package/dist/generator/core.js.map +1 -1
  14. package/dist/generator/{emit-straightline-typed-go.d.ts → emit-shared-go-typed.d.ts} +11 -17
  15. package/dist/generator/emit-shared-go-typed.d.ts.map +1 -0
  16. package/dist/generator/emit-shared-go-typed.js +572 -0
  17. package/dist/generator/emit-shared-go-typed.js.map +1 -0
  18. package/dist/generator/emit-shared-go.d.ts +29 -0
  19. package/dist/generator/emit-shared-go.d.ts.map +1 -0
  20. package/dist/generator/{emit-straightline-go.js → emit-shared-go.js} +2 -34
  21. package/dist/generator/emit-shared-go.js.map +1 -0
  22. package/dist/generator/{emit-straightline-typed-rust.d.ts → emit-shared-rust-typed.d.ts} +11 -17
  23. package/dist/generator/emit-shared-rust-typed.d.ts.map +1 -0
  24. package/dist/generator/emit-shared-rust-typed.js +576 -0
  25. package/dist/generator/emit-shared-rust-typed.js.map +1 -0
  26. package/dist/generator/{emit-straightline-rust.d.ts → emit-shared-rust.d.ts} +1 -36
  27. package/dist/generator/emit-shared-rust.d.ts.map +1 -0
  28. package/dist/generator/{emit-straightline-rust.js → emit-shared-rust.js} +2 -16
  29. package/dist/generator/emit-shared-rust.js.map +1 -0
  30. package/dist/generator/emit-shared-typescript.d.ts +12 -0
  31. package/dist/generator/emit-shared-typescript.d.ts.map +1 -0
  32. package/dist/generator/{emit-straightline-typescript.js → emit-shared-typescript.js} +5 -20
  33. package/dist/generator/emit-shared-typescript.js.map +1 -0
  34. package/dist/generator/emit-straightline-typed-typescript.d.ts +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 -1
  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 +546 -82
  40. package/dist/generator/emit-typed-native-go.js.map +1 -1
  41. package/dist/generator/emit-typed-native-rust.d.ts +2 -1
  42. package/dist/generator/emit-typed-native-rust.d.ts.map +1 -1
  43. package/dist/generator/emit-typed-native-rust.js +597 -110
  44. package/dist/generator/emit-typed-native-rust.js.map +1 -1
  45. package/dist/generator/index.d.ts +2 -12
  46. package/dist/generator/index.d.ts.map +1 -1
  47. package/dist/generator/index.js +10 -62
  48. package/dist/generator/index.js.map +1 -1
  49. package/dist/generator/native-expr.d.ts +120 -0
  50. package/dist/generator/native-expr.d.ts.map +1 -0
  51. package/dist/generator/native-expr.js +456 -0
  52. package/dist/generator/native-expr.js.map +1 -0
  53. package/dist/generator/typed.d.ts.map +1 -1
  54. package/dist/generator/typed.js +8 -0
  55. package/dist/generator/typed.js.map +1 -1
  56. package/dist/guard.js +14 -0
  57. package/dist/guard.js.map +1 -1
  58. package/dist/index.d.ts +2 -2
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +1 -1
  61. package/dist/index.js.map +1 -1
  62. package/dist/plan.d.ts +6 -2
  63. package/dist/plan.d.ts.map +1 -1
  64. package/dist/plan.js +4 -0
  65. package/dist/plan.js.map +1 -1
  66. package/package.json +1 -1
  67. package/dist/generator/emit-go.d.ts +0 -19
  68. package/dist/generator/emit-go.d.ts.map +0 -1
  69. package/dist/generator/emit-go.js +0 -79
  70. package/dist/generator/emit-go.js.map +0 -1
  71. package/dist/generator/emit-raw-abi-go.d.ts +0 -45
  72. package/dist/generator/emit-raw-abi-go.d.ts.map +0 -1
  73. package/dist/generator/emit-raw-abi-go.js +0 -346
  74. package/dist/generator/emit-raw-abi-go.js.map +0 -1
  75. package/dist/generator/emit-raw-abi-rust.d.ts +0 -45
  76. package/dist/generator/emit-raw-abi-rust.d.ts.map +0 -1
  77. package/dist/generator/emit-raw-abi-rust.js +0 -380
  78. package/dist/generator/emit-raw-abi-rust.js.map +0 -1
  79. package/dist/generator/emit-rust.d.ts +0 -25
  80. package/dist/generator/emit-rust.d.ts.map +0 -1
  81. package/dist/generator/emit-rust.js +0 -125
  82. package/dist/generator/emit-rust.js.map +0 -1
  83. package/dist/generator/emit-straightline-go.d.ts +0 -60
  84. package/dist/generator/emit-straightline-go.d.ts.map +0 -1
  85. package/dist/generator/emit-straightline-go.js.map +0 -1
  86. package/dist/generator/emit-straightline-native-go.d.ts +0 -54
  87. package/dist/generator/emit-straightline-native-go.d.ts.map +0 -1
  88. package/dist/generator/emit-straightline-native-go.js +0 -835
  89. package/dist/generator/emit-straightline-native-go.js.map +0 -1
  90. package/dist/generator/emit-straightline-native-rust.d.ts +0 -27
  91. package/dist/generator/emit-straightline-native-rust.d.ts.map +0 -1
  92. package/dist/generator/emit-straightline-native-rust.js +0 -624
  93. package/dist/generator/emit-straightline-native-rust.js.map +0 -1
  94. package/dist/generator/emit-straightline-php.d.ts +0 -46
  95. package/dist/generator/emit-straightline-php.d.ts.map +0 -1
  96. package/dist/generator/emit-straightline-php.js +0 -941
  97. package/dist/generator/emit-straightline-php.js.map +0 -1
  98. package/dist/generator/emit-straightline-python.d.ts +0 -36
  99. package/dist/generator/emit-straightline-python.d.ts.map +0 -1
  100. package/dist/generator/emit-straightline-python.js +0 -855
  101. package/dist/generator/emit-straightline-python.js.map +0 -1
  102. package/dist/generator/emit-straightline-rust.d.ts.map +0 -1
  103. package/dist/generator/emit-straightline-rust.js.map +0 -1
  104. package/dist/generator/emit-straightline-typed-go.d.ts.map +0 -1
  105. package/dist/generator/emit-straightline-typed-go.js +0 -1114
  106. package/dist/generator/emit-straightline-typed-go.js.map +0 -1
  107. package/dist/generator/emit-straightline-typed-rust.d.ts.map +0 -1
  108. package/dist/generator/emit-straightline-typed-rust.js +0 -1155
  109. package/dist/generator/emit-straightline-typed-rust.js.map +0 -1
  110. package/dist/generator/emit-straightline-typescript.d.ts +0 -47
  111. package/dist/generator/emit-straightline-typescript.d.ts.map +0 -1
  112. package/dist/generator/emit-straightline-typescript.js.map +0 -1
@@ -1,8 +1,10 @@
1
1
  import { GeneratorFailure } from "./core.js";
2
- import { rustStraightlineInternals } from "./emit-straightline-rust.js";
3
- import { rustTypedInternals } from "./emit-straightline-typed-rust.js";
2
+ import { rustStraightlineInternals } from "./emit-shared-rust.js";
3
+ import { rustTypedInternals } from "./emit-shared-rust-typed.js";
4
4
  import { buildTypePlan, deriveTypeRef, findDecl } from "./typed.js";
5
5
  import { concurrencyPlan, execPlan, analyzeSequentialOps, classifyExpr } from "./straightline.js";
6
+ import { NativeExprCompiler } from "./native-expr.js";
7
+ import { buildAsyncPlans } from "./async-plan.js";
6
8
  const { sanitize, rustStrLit } = rustStraightlineInternals;
7
9
  const { rustFieldName, renderTypeRef, typedCell, typedFieldAccess, serializeTyped, emitTypeDecls, emitDefaults, emitSerializers, emitTypedValue, emitMarshallers, emitMarshalHelpers, materializeExpr, } = rustTypedInternals;
8
10
  /** rustErr — build the LOCAL concrete error value for a behavior/plan failure on the covered read
@@ -84,7 +86,32 @@ function staticArrElems(node) {
84
86
  const arg = node.arr;
85
87
  return Array.isArray(arg) ? arg : null;
86
88
  }
87
- function inferPortType(node, inputPorts) {
89
+ /**
90
+ * portArrayElemRef (#110) — resolve a componentRef input port that binds a RUNTIME ARRAY (an IN-list /
91
+ * array-bound WHERE head) to its native element TypeRef, or null when not a covered array. The covered
92
+ * shape is a SINGLE-SEGMENT, non-opt `ref` into an INPUT ARRAY port carrying a resolvable `elemType`
93
+ * (PortSchema.elemType — BC does NOT infer, consumer-interface C3). The port lowers to a native
94
+ * `Vec<ElemT>` fed from `in_.<field>.clone()`. Reuses the bc#108 elemType lowering, extended from the
95
+ * `map…over` element-source path to a plain componentRef port. A static string array is a different
96
+ * covered shape (Vec<&'static str>) — handled ahead of this. No resolvable elemType / opt / multi-segment
97
+ * ref / non-array schema / no plan → null (caller keeps its existing fail-closed behaviour). */
98
+ function portArrayElemRef(node, inputPorts, plan) {
99
+ if (plan === undefined)
100
+ return null;
101
+ if (staticStringArrElems(node) !== null)
102
+ return null;
103
+ const e = classifyExpr(node);
104
+ if (e.kind !== "ref" || e.opt || e.path.length !== 1)
105
+ return null;
106
+ const schema = inputPorts?.[e.path[0]];
107
+ if (schema === undefined || (schema.type !== "array" && schema.type !== "arr"))
108
+ return null;
109
+ const et = schema.elemType;
110
+ if (et === undefined)
111
+ return null;
112
+ return deriveTypeRef(et, plan);
113
+ }
114
+ function inferPortType(node, inputPorts, asBinding) {
88
115
  if (staticArrElems(node) !== null)
89
116
  return "Value";
90
117
  const e = classifyExpr(node);
@@ -95,6 +122,18 @@ function inferPortType(node, inputPorts) {
95
122
  case "bool":
96
123
  return "bool";
97
124
  case "ref": {
125
+ // #108: a `$as`-headed ref (map element binding) — resolve the element field's scalar type.
126
+ if (asBinding !== undefined && e.path[0] === asBinding.name) {
127
+ try {
128
+ const { ref } = typedFieldAccess(asBinding.name, asBinding.ref, e.path.slice(1), asBinding.plan);
129
+ if (ref.kind === "scalar" && ref.scalar !== "null")
130
+ return rustScalar(ref.scalar);
131
+ }
132
+ catch {
133
+ return "Value";
134
+ }
135
+ return "Value";
136
+ }
98
137
  if (e.path.length === 1) {
99
138
  const s = inputPorts[e.path[0]];
100
139
  if (s) {
@@ -139,13 +178,17 @@ function staticStringArrElems(node) {
139
178
  * Value-typed input) FAILS CLOSED — the covered read plane admits NO boxed escape (there is no `Value`
140
179
  * fallback on the native module). `where` names the emission site for the error.
141
180
  */
142
- function portFieldRustType(node, inputPorts, where = "port") {
181
+ function portFieldRustType(node, inputPorts, where = "port", asBinding, plan) {
143
182
  if (staticStringArrElems(node) !== null)
144
183
  return "Vec<&'static str>";
184
+ // #110: a componentRef port bound to a runtime array input port (with a resolvable elemType) → Vec<ElemT>.
185
+ const arrElem = portArrayElemRef(node, inputPorts, plan);
186
+ if (arrElem !== null)
187
+ return `Vec<${renderTypeRef(arrElem)}>`;
145
188
  const num = numLiteralRustExpr(node);
146
189
  if (num !== null)
147
190
  return typeof node === "number" && Number.isInteger(node) ? "i64" : "f64";
148
- const ft = inferPortType(node, inputPorts);
191
+ const ft = inferPortType(node, inputPorts, asBinding);
149
192
  if (ft === "Value") {
150
193
  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.`);
151
194
  }
@@ -158,13 +201,13 @@ function portFieldRustType(node, inputPorts, where = "port") {
158
201
  * reads `ports.f_<field>` DIRECTLY. The struct derives Clone (a bc#87 parallel stage clones it into the
159
202
  * per-worker dispatch). Every field is a NATIVE Rust type (bc#90 / runtime-free) — NO boxed `Value`.
160
203
  */
161
- function emitPortsStruct(comp, node) {
204
+ function emitPortsStruct(comp, node, asBinding, plan) {
162
205
  const name = portsStructName(comp.name, node.id);
163
206
  const portNames = Object.keys(node.ports);
164
207
  if (portNames.length === 0) {
165
208
  return `// ${name} — native ports for node '${node.id}' (${node.component}); no ports.\n#[derive(Clone)]\npub struct ${name};`;
166
209
  }
167
- const types = portNames.map((p) => portFieldRustType(node.ports[p], comp.inputPorts, `port '${p}' of node '${node.id}'`));
210
+ const types = portNames.map((p) => portFieldRustType(node.ports[p], comp.inputPorts, `port '${p}' of node '${node.id}'`, asBinding, plan));
168
211
  const fields = portNames
169
212
  .map((p, i) => ` pub ${portFieldName(p)}: ${types[i]}, // ${JSON.stringify(p)}`)
170
213
  .join("\n");
@@ -181,11 +224,14 @@ ${fields}
181
224
  * struct carries the Vec of per-element CONCRETE ports structs (the handler reads them off the concrete
182
225
  * struct — no by-name accessor, no key-value object).
183
226
  */
184
- function emitMapPortsStructs(comp, node) {
227
+ function emitMapPortsStructs(comp, node, typedNodes, plan) {
185
228
  const m = node.map;
186
229
  const name = portsStructName(comp.name, node.id);
187
230
  const batch = `${name}Batch`;
188
- const elemStruct = emitPortsStruct(comp, { id: node.id, component: m.component, ports: m.ports });
231
+ // #108: element ports may read the `$as` binding resolve the over element type for native typing.
232
+ const { elemRef } = mapOverElemInfo(comp, node, typedNodes, plan);
233
+ const asBinding = { name: m.as, ref: elemRef, plan };
234
+ const elemStruct = emitPortsStruct(comp, { id: node.id, component: m.component, ports: m.ports }, asBinding, plan);
189
235
  return `${elemStruct}
190
236
 
191
237
  // ${batch} — CONCRETE batched ports for map '${node.id}': the Vec of per-element CONCRETE ports structs
@@ -196,6 +242,188 @@ pub struct ${batch} {
196
242
  pub items: Vec<${name}>,
197
243
  }`;
198
244
  }
245
+ // ── native expression compiler backend (#108) — cond `if` / map `when` / cond branches (Rust) ────────
246
+ //
247
+ // The runtime-free native expression compiler (native-expr.ts) mirrors run_behavior's cond/guard with
248
+ // BYTE-EQUAL semantics. The value-dependent runtime failures (INT_OVERFLOW / NAN_OR_INF / MOD_ZERO /
249
+ // PRECISION_LOSS) are emitted as calls to a FIXED set of RUNTIME-FREE helper fns baked into the module
250
+ // (bc_expr_* below) returning Result<T, BehaviorError> — the covered module still imports ZERO bc runtime.
251
+ // The runner is Result<_, BehaviorError>, so a fallible helper propagates with `?`.
252
+ let rustExprTempSeq = 0;
253
+ /** module-level accumulator: set when the native-expr compiler emits a fallible helper call (so emit()
254
+ * bakes the helper library). Reset at the start of each emit(). */
255
+ const rustExprUsed = { helpers: false };
256
+ /** makeRustExprBackend — renders native-expr for Rust. `resolveHead` maps a ref head to an OWNED typed
257
+ * base expr (a prior-node cell clone / an input field / a $as element). Sets rustExprUsed.helpers when
258
+ * a fallible helper is emitted. The runner is Result<_, BehaviorError>, so hoistFallible uses `?`. */
259
+ function makeRustExprBackend(resolveHead, plan) {
260
+ const used = rustExprUsed;
261
+ return {
262
+ lang: "rust",
263
+ freshTemp: () => `ex_${rustExprTempSeq++}`,
264
+ renderType: (ref) => renderTypeRef(ref),
265
+ scalarType: (s) => rustScalar(s),
266
+ strLit: (s) => `${rustStrLit(s)}.to_string()`,
267
+ boolLit: (b) => (b ? "true" : "false"),
268
+ intLit: (dec) => `${dec}i64`,
269
+ floatLit: (n) => `${rustFloatLitExpr(n)}f64`,
270
+ fieldAccess: (baseExpr, baseRef, path) => typedFieldAccess(baseExpr, baseRef, path, plan),
271
+ resolveHead,
272
+ hoistFallible: (temp, _ty, call) => {
273
+ used.helpers = true;
274
+ return [`let ${temp} = ${call}?;`];
275
+ },
276
+ helperCall: (helper, args) => {
277
+ used.helpers = true;
278
+ // the string code-point compare helpers take &str — borrow the owned String args.
279
+ const isStrCmp = helper === "strLt" || helper === "strLe" || helper === "strGt" || helper === "strGe";
280
+ const passed = isStrCmp ? args.map((a) => `&${a}`) : args;
281
+ // lenArr takes &[T] — borrow the owned Vec.
282
+ const finalArgs = helper === "lenArr" ? args.map((a) => `&${a}`) : passed;
283
+ return `bc_expr_${rustSnake(helper)}(${finalArgs.join(", ")})`;
284
+ },
285
+ binOp: (op, a, b) => `(${a} ${op} ${b})`,
286
+ concat: (parts) => {
287
+ // native String concat via a `[&str; N].concat()` — a string-literal part (`"…".to_string()`) is
288
+ // used as the bare `"…"` &str; any other owned String is borrowed with `.as_str()`. This shape is
289
+ // clippy-clean (no to_string_in_format_args / single_char_push_str / unnecessary_to_string).
290
+ const refs = parts.map((p) => {
291
+ const m = /^(".*")\.to_string\(\)$/.exec(p);
292
+ return m ? m[1] : `(${p}).as_str()`;
293
+ });
294
+ return `[${refs.join(", ")}].concat()`;
295
+ },
296
+ notOp: (a) => `(!${a})`,
297
+ structLit: (name, inits) => `${name} { ${inits.map((i) => `${rustFieldName(i.field)}: ${i.expr}`).join(", ")} }`,
298
+ arrLit: (elemTy, items) => `{ let __v: Vec<${elemTy}> = vec![${items.join(", ")}]; __v }`,
299
+ ternary: (cond, t, e, _ty) => `(if ${rustStripOuterParens(cond)} { ${rustStripOuterParens(t)} } else { ${rustStripOuterParens(e)} })`,
300
+ shortCircuitBool: (op, temp, left, rStmts, rExpr) => {
301
+ // and: let temp = if left { <rStmts> rExpr } else { false }; or: if left { true } else { <rStmts> rExpr }.
302
+ // build a block that runs rStmts then yields rExpr (parens stripped — block-tail is not a sub-expr).
303
+ const rblock = `{ ${rStmts.join(" ")} ${rustStripOuterParens(rExpr)} }`;
304
+ if (op === "and") {
305
+ return [`let ${temp}: bool = if ${rustStripOuterParens(left)} ${rblock} else { false };`];
306
+ }
307
+ return [`let ${temp}: bool = if ${rustStripOuterParens(left)} { true } else ${rblock};`];
308
+ },
309
+ condGuard: (temp, ty, cond, tStmts, tExpr, eStmts, eExpr) => {
310
+ const tblock = `{ ${tStmts.join(" ")} ${rustStripOuterParens(tExpr)} }`;
311
+ const eblock = `{ ${eStmts.join(" ")} ${rustStripOuterParens(eExpr)} }`;
312
+ return [`let ${temp}: ${ty} = if ${rustStripOuterParens(cond)} ${tblock} else ${eblock};`];
313
+ },
314
+ };
315
+ }
316
+ /** camelCase helper name → snake_case for the Rust helper fn (addI64 → add_i64, strLt → str_lt). */
317
+ function rustSnake(name) {
318
+ return name.replace(/([a-z])([A-Z0-9])/g, "$1_$2").replace(/([0-9])([a-z])/g, "$1_$2").toLowerCase();
319
+ }
320
+ /** strip ONE redundant outer paren pair from an expr (clippy `if (cond)` unused_parens). Only when the
321
+ * whole string is a single balanced `( … )` — a `(a) && (b)` stays untouched. */
322
+ function rustStripOuterParens(expr) {
323
+ if (!expr.startsWith("(") || !expr.endsWith(")"))
324
+ return expr;
325
+ let depth = 0;
326
+ for (let i = 0; i < expr.length; i++) {
327
+ if (expr[i] === "(")
328
+ depth++;
329
+ else if (expr[i] === ")") {
330
+ depth--;
331
+ if (depth === 0 && i !== expr.length - 1)
332
+ return expr; // closed before the end → not a single wrap
333
+ }
334
+ }
335
+ return expr.slice(1, -1);
336
+ }
337
+ /** Rust float literal (finite; integral gets `.0`). */
338
+ function rustFloatLitExpr(n) {
339
+ if (Number.isInteger(n))
340
+ return `${n}.0`;
341
+ return String(n);
342
+ }
343
+ /**
344
+ * emitExprHelpersRust — the FIXED runtime-free native-expr helper library (Rust). Emitted once per
345
+ * covered module that uses cond/guard/arith. Each helper is BYTE-EQUAL to expr-eval.ts core. Failures
346
+ * are the LOCAL BehaviorError with the SAME code strings as run_behavior.
347
+ */
348
+ function emitExprHelpersRust() {
349
+ return `// ── native-expr runtime-free helper library (#108) — byte-equal to expr-eval.ts core ──
350
+ #[allow(dead_code)]
351
+ fn bc_expr_add_i64(a: i64, b: i64) -> Result<i64, BehaviorError> {
352
+ a.checked_add(b).ok_or_else(|| BehaviorError::new("INT_OVERFLOW", "i64 overflow"))
353
+ }
354
+ #[allow(dead_code)]
355
+ fn bc_expr_sub_i64(a: i64, b: i64) -> Result<i64, BehaviorError> {
356
+ a.checked_sub(b).ok_or_else(|| BehaviorError::new("INT_OVERFLOW", "i64 overflow"))
357
+ }
358
+ #[allow(dead_code)]
359
+ fn bc_expr_mul_i64(a: i64, b: i64) -> Result<i64, BehaviorError> {
360
+ a.checked_mul(b).ok_or_else(|| BehaviorError::new("INT_OVERFLOW", "i64 overflow"))
361
+ }
362
+ #[allow(dead_code)]
363
+ fn bc_expr_neg_i64(a: i64) -> Result<i64, BehaviorError> {
364
+ a.checked_neg().ok_or_else(|| BehaviorError::new("INT_OVERFLOW", "i64 overflow"))
365
+ }
366
+ #[allow(dead_code)]
367
+ fn bc_expr_check_finite(v: f64) -> Result<f64, BehaviorError> {
368
+ if v.is_finite() { Ok(v) } else { Err(BehaviorError::new("NAN_OR_INF", "non-finite float")) }
369
+ }
370
+ #[allow(dead_code)]
371
+ fn bc_expr_add_f(a: f64, b: f64) -> Result<f64, BehaviorError> { bc_expr_check_finite(a + b) }
372
+ #[allow(dead_code)]
373
+ fn bc_expr_sub_f(a: f64, b: f64) -> Result<f64, BehaviorError> { bc_expr_check_finite(a - b) }
374
+ #[allow(dead_code)]
375
+ fn bc_expr_mul_f(a: f64, b: f64) -> Result<f64, BehaviorError> { bc_expr_check_finite(a * b) }
376
+ #[allow(dead_code)]
377
+ fn bc_expr_neg_f(a: f64) -> Result<f64, BehaviorError> { bc_expr_check_finite(-a) }
378
+ #[allow(dead_code)]
379
+ fn bc_expr_mod_f(a: f64, b: f64) -> Result<f64, BehaviorError> { bc_expr_check_finite(a % b) }
380
+ #[allow(dead_code)]
381
+ fn bc_expr_mod_i64(a: i64, b: i64) -> Result<i64, BehaviorError> {
382
+ if b == 0 { return Err(BehaviorError::new("MOD_ZERO", "int mod by zero")); }
383
+ // Rust % is truncated (sign of dividend) — matches modCore. MIN % -1 would panic; guard it.
384
+ if a == i64::MIN && b == -1 { return Ok(0); }
385
+ Ok(a % b)
386
+ }
387
+ #[allow(dead_code)]
388
+ fn bc_expr_widen_i64f(a: i64) -> Result<f64, BehaviorError> {
389
+ if !(-9007199254740992..=9007199254740992).contains(&a) {
390
+ return Err(BehaviorError::new("PRECISION_LOSS", "int exceeds exact float range (±2^53)"));
391
+ }
392
+ Ok(a as f64)
393
+ }
394
+ #[allow(dead_code)]
395
+ fn bc_expr_div_f(a: f64, b: f64) -> Result<f64, BehaviorError> { bc_expr_check_finite(a / b) }
396
+ #[allow(dead_code)]
397
+ fn bc_expr_len_arr<T>(a: &[T]) -> Result<i64, BehaviorError> {
398
+ i64::try_from(a.len()).map_err(|_| BehaviorError::new("INT_OVERFLOW", "i64 overflow"))
399
+ }
400
+ // code-point (char) order comparison — matches cmpCodePoints (char-by-char, NOT byte order).
401
+ #[allow(dead_code)]
402
+ fn bc_expr_cmp_cp(a: &str, b: &str) -> std::cmp::Ordering {
403
+ let mut ia = a.chars();
404
+ let mut ib = b.chars();
405
+ loop {
406
+ match (ia.next(), ib.next()) {
407
+ (None, None) => return std::cmp::Ordering::Equal,
408
+ (None, Some(_)) => return std::cmp::Ordering::Less,
409
+ (Some(_), None) => return std::cmp::Ordering::Greater,
410
+ (Some(ca), Some(cb)) => {
411
+ if ca != cb {
412
+ return if (ca as u32) < (cb as u32) { std::cmp::Ordering::Less } else { std::cmp::Ordering::Greater };
413
+ }
414
+ }
415
+ }
416
+ }
417
+ }
418
+ #[allow(dead_code)]
419
+ fn bc_expr_str_lt(a: &str, b: &str) -> bool { bc_expr_cmp_cp(a, b) == std::cmp::Ordering::Less }
420
+ #[allow(dead_code)]
421
+ fn bc_expr_str_le(a: &str, b: &str) -> bool { bc_expr_cmp_cp(a, b) != std::cmp::Ordering::Greater }
422
+ #[allow(dead_code)]
423
+ fn bc_expr_str_gt(a: &str, b: &str) -> bool { bc_expr_cmp_cp(a, b) == std::cmp::Ordering::Greater }
424
+ #[allow(dead_code)]
425
+ fn bc_expr_str_ge(a: &str, b: &str) -> bool { bc_expr_cmp_cp(a, b) != std::cmp::Ordering::Less }`;
426
+ }
199
427
  // ── CONCRETE per-node row structs + row→outType copiers (mirror the Go concrete contract) ──────────
200
428
  //
201
429
  // Each covered node has a CONCRETE row struct `RawRowNR<Comp><Node>` whose fields are the node's projected
@@ -209,6 +437,8 @@ pub struct ${batch} {
209
437
  function emitRawRowStructs(comp, typedNodes, plan) {
210
438
  const parts = [];
211
439
  for (const n of comp.body) {
440
+ if ("cond" in n)
441
+ continue; // #108: a cond node has no handler-result row (pure Expression).
212
442
  const ref = typedNodes.get(n.id);
213
443
  if ("map" in n) {
214
444
  const arrRef = ref;
@@ -291,12 +521,14 @@ function boundRustType(node, typedNodes, plan) {
291
521
  * struct (per-element). This is the fully-concrete, runtime-free replacement for the NativeRawComponentExec
292
522
  * seam.
293
523
  */
294
- function emitHandlerTrait(comp, typedNodes, plan, isAsync = false) {
295
- // bc#97: async I/O model (uniform) every terminal handler method is an `async fn` so a consumer's
296
- // async node_* returns a future that the (async) runner awaits at the call site. Default = plain `fn`
297
- // (byte-identical to the pre-#97 sync output). `async fn` needs NO external runtime to compile the
298
- // future is `async fn` sugar over std, so the covered module stays runtime-free either way.
299
- const fnKw = isAsync ? "async fn" : "fn";
524
+ function emitHandlerTrait(comp, typedNodes, plan, ap) {
525
+ // bc#97 (increment 2): async I/O model is PER-TERMINAL-NODE. A node whose terminal handler is declared
526
+ // async gets an `async fn node_*` (the consumer's impl returns a future the runner awaits at the call
527
+ // site); a sync terminal stays plain `fn node_*` (called directly, no await). Mixed sync+async in one
528
+ // trait is fine. `async fn` needs NO external runtime to compile — the future is `async fn` sugar over
529
+ // std, so the covered module stays runtime-free either way. Uniform ioModel:'async' ⇒ every method
530
+ // async (byte-identical to increment-1); ioModel unset/'sync' ⇒ every method plain `fn` (pre-#97).
531
+ const fnKwFor = (nodeId) => (ap.nodeIsAsync(nodeId) ? "async fn" : "fn");
300
532
  const lines = [];
301
533
  lines.push(`// ${handlerTraitName(comp.name)} — the CONCRETE per-component handler seam: one typed method`);
302
534
  lines.push(`// per covered node (native ports struct IN, concrete row struct OUT). No generic boxed-ports`);
@@ -304,7 +536,10 @@ function emitHandlerTrait(comp, typedNodes, plan, isAsync = false) {
304
536
  lines.push(`// node_* with a decode of its own wire payload into the concrete row (see INTEGRATION.md §6).`);
305
537
  lines.push(`pub trait ${handlerTraitName(comp.name)} {`);
306
538
  for (const n of comp.body) {
539
+ if ("cond" in n)
540
+ continue; // #108: a cond node has no handler method (pure Expression).
307
541
  const method = nodeMethodName(n.id);
542
+ const fnKw = fnKwFor(n.id);
308
543
  const bt = boundRustType(n, typedNodes, plan);
309
544
  if ("map" in n) {
310
545
  const m = n.map;
@@ -368,8 +603,11 @@ function emitMapElemMaterializers(comp, typedNodes, plan) {
368
603
  const elemRowGo = rawElemStructName(comp.name, n.id);
369
604
  const intoRowRef = mapElemRowRef(n, arrRef, plan);
370
605
  const inits = [];
371
- for (const f of overFields)
372
- inits.push(` ${rustFieldName(f.name)}: over.${rustFieldName(f.name)}.clone(),`);
606
+ // Copy scalars (i64/f64/bool) copy directly; String/named/arr/opt clone (clippy clone_on_copy clean).
607
+ for (const f of overFields) {
608
+ const isCopy = f.type.kind === "scalar" && (f.type.scalar === "int" || f.type.scalar === "float" || f.type.scalar === "bool");
609
+ inits.push(` ${rustFieldName(f.name)}: over.${rustFieldName(f.name)}${isCopy ? "" : ".clone()"},`);
610
+ }
373
611
  // move the into field off the concrete element row (named → struct literal; scalar/arr/opt → .val).
374
612
  const intoValue = intoRowRef.kind === "named"
375
613
  ? `${intoRowRef.name} { ${findDecl(plan, intoRowRef.name).fields.map((f) => `${rustFieldName(f.name)}: into.${rustFieldName(f.name)}`).join(", ")} }`
@@ -385,17 +623,33 @@ ${inits.join("\n")}
385
623
  }
386
624
  return parts.join("\n\n");
387
625
  }
388
- /** The Rust type of the over-element for a covered map node (parent collection element type). */
389
- function mapOverElemType(comp, node, typedNodes, plan) {
626
+ /**
627
+ * mapOverElemInfo resolve a covered map's `over` collection element TypeRef + the OWNED Rust
628
+ * expression that yields the over Vec (#108, rust twin): prior-node arr field (cell clone) or an input
629
+ * array port with elemType (`in_.<field>.clone()`). Fail-closed for an input array without elemType.
630
+ */
631
+ function mapOverElemInfo(comp, node, typedNodes, plan) {
390
632
  const m = node.map;
391
633
  const e = classifyExpr(m.over);
392
634
  if (e.kind === "ref" && e.path.length >= 1 && typedNodes.has(e.path[0])) {
393
635
  const baseRef = typedNodes.get(e.path[0]);
394
636
  const { ref } = typedFieldAccess(`${typedCell(e.path[0])}.borrow()`, baseRef, e.path.slice(1), plan);
395
- if (ref.kind === "arr")
396
- return renderTypeRef(ref.elem);
637
+ if (ref.kind === "arr") {
638
+ const fieldPath = e.path.slice(1).map((p) => `.${rustFieldName(p)}`).join("");
639
+ return { elemRef: ref.elem, overExpr: `${typedCell(e.path[0])}.borrow()${fieldPath}.clone()` };
640
+ }
641
+ }
642
+ if (e.kind === "ref" && e.path.length === 1) {
643
+ const schema = comp.inputPorts?.[e.path[0]];
644
+ const et = schema?.elemType;
645
+ if (et !== undefined)
646
+ return { elemRef: deriveTypeRef(et, plan), overExpr: `in_.${rustFieldName(e.path[0])}.clone()` };
397
647
  }
398
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined map (#86 pt2): map '${node.id}' 'over' must be a ref to a prior node's typed arr field.`);
648
+ throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust combined map (#108): map '${node.id}' 'over' must be a ref to a prior node's typed arr field OR an input array port with a declared elemType. BC does not infer input-array element types (consumer-interface C3).`);
649
+ }
650
+ /** The Rust type of the over-element (back-compat shim). */
651
+ function mapOverElemType(comp, node, typedNodes, plan) {
652
+ return renderTypeRef(mapOverElemInfo(comp, node, typedNodes, plan).elemRef);
399
653
  }
400
654
  // ── eligibility ─────────────────────────────────────────────────────────────────────
401
655
  function reconstructR(e) {
@@ -489,19 +743,30 @@ function outputIsNativeLowerable(comp) {
489
743
  /**
490
744
  * coveredMapNode — the #86 part 2 covered map shape (nestedBatchGet), rust twin of the go predicate.
491
745
  */
492
- function coveredMapNode(n, bodyIds) {
746
+ function coveredMapNode(n, bodyIds, comp) {
493
747
  if (n === null || typeof n !== "object" || !("map" in n))
494
748
  return false;
495
749
  const m = n.map;
496
- if (m.when !== undefined)
750
+ // #108 (rust twin of the go predicate): a guarded map (`when`) IS covered with NO `into` (guard+into
751
+ // is heterogeneous — fail-closed). over element type must be statically resolvable: prior-node arr
752
+ // field, or an input array port carrying `elemType` (BC does not infer — consumer-interface C3).
753
+ if (m.when !== undefined && m.into !== undefined)
497
754
  return false;
498
755
  if (m.policy !== undefined && m.policy !== "fail")
499
756
  return false;
500
757
  if (m.relationKind !== undefined && m.relationKind !== "single" && m.relationKind !== "connection")
501
758
  return false;
502
759
  const overE = classifyExpr(m.over);
503
- if (overE.kind !== "ref" || !bodyIds.has(overE.path[0]))
760
+ if (overE.kind !== "ref")
504
761
  return false;
762
+ const head = overE.path[0];
763
+ if (!bodyIds.has(head)) {
764
+ if (overE.path.length !== 1)
765
+ return false;
766
+ const schema = comp.inputPorts?.[head];
767
+ if (schema === undefined || schema.elemType === undefined)
768
+ return false;
769
+ }
505
770
  const ports = m.ports;
506
771
  for (const p of Object.keys(ports))
507
772
  if (!portIsStatic(ports[p]))
@@ -533,11 +798,16 @@ function coverageRejectReason(comp) {
533
798
  }
534
799
  const bodyIds = new Set(comp.body.map((b) => b.id));
535
800
  for (const n of comp.body) {
536
- if ("cond" in n)
537
- return `node '${n.id}' is a cond branch (not yet native-covered)`;
801
+ if ("cond" in n) {
802
+ // #108: a cond node is native-covered (its if/then/else compile via the runtime-free
803
+ // NativeExprCompiler; an uncovered expr fails closed at emit). cond requires outType.
804
+ if (n.outType === undefined)
805
+ return `node '${n.id}' cond has no outType (required to de-box the branch join)`;
806
+ continue;
807
+ }
538
808
  if ("map" in n) {
539
- if (!coveredMapNode(n, bodyIds))
540
- return `node '${n.id}' is a non-covered map shape (guarded/over-input)`;
809
+ if (!coveredMapNode(n, bodyIds, comp))
810
+ return `node '${n.id}' is a non-covered map shape (guard+into heterogeneous, or over an input array without elemType)`;
541
811
  continue;
542
812
  }
543
813
  if (!("component" in n))
@@ -576,8 +846,8 @@ function assertTypedCoverage(comp) {
576
846
  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).`);
577
847
  }
578
848
  // ── typed input struct (InNR<Comp>) + NATIVE scalar port lowering ────────────────────────────
579
- /** Rust type for an input port's declared type (scalar → concrete; anything else Value). */
580
- function inputPortRustType(schema) {
849
+ /** Rust type for an input port's declared type (scalar → concrete; array+elemType → Vec<ElemT>; else Value). */
850
+ function inputPortRustType(schema, plan) {
581
851
  switch (schema?.type) {
582
852
  case "string":
583
853
  return "String";
@@ -587,6 +857,14 @@ function inputPortRustType(schema) {
587
857
  return "f64";
588
858
  case "bool":
589
859
  return "bool";
860
+ case "array":
861
+ case "arr": {
862
+ // #108: an input ARRAY port with a declared elemType lowers to a native Vec<ElemT>.
863
+ const et = schema.elemType;
864
+ if (et !== undefined && plan !== undefined)
865
+ return `Vec<${renderTypeRef(deriveTypeRef(et, plan))}>`;
866
+ return "Value";
867
+ }
590
868
  default:
591
869
  return "Value";
592
870
  }
@@ -608,14 +886,14 @@ function inputScalarKind(schema) {
608
886
  }
609
887
  /** emitInStruct — the concrete input struct declaration for a covered component (fields = inputPorts).
610
888
  * Empty inputPorts => empty struct. Derives Default so a consumer/test can build it field-by-field. */
611
- function emitInStruct(comp) {
889
+ function emitInStruct(comp, plan) {
612
890
  const name = inStructName(comp.name);
613
891
  const ports = Object.keys(comp.inputPorts ?? {});
614
892
  if (ports.length === 0) {
615
893
  return `// ${name} — the CONCRETE input for '${comp.name}' (no input ports).\n#[derive(Default)]\npub struct ${name};`;
616
894
  }
617
895
  const fields = ports
618
- .map((p) => ` pub ${rustFieldName(p)}: ${inputPortRustType(comp.inputPorts[p])}, // ${JSON.stringify(p)}`)
896
+ .map((p) => ` pub ${rustFieldName(p)}: ${inputPortRustType(comp.inputPorts[p], plan)}, // ${JSON.stringify(p)}`)
619
897
  .join("\n");
620
898
  return `// ${name} — the CONCRETE input for '${comp.name}' (fields = inputPorts; typed, consumer-built —
621
899
  // NO generic Value slice, NO per-field boxing crosses the covered read boundary).
@@ -695,8 +973,16 @@ function emitNativeScalar(node, want, resolveRef) {
695
973
  * a boxed escape would re-introduce the bc-runtime coupling this module removes). `fieldRustType` is the
696
974
  * ports-struct field type (from portFieldRustType); `resolveRef` resolves a ref head for the scalar path.
697
975
  */
698
- function emitPortInit(pn, expr, fieldRustType, resolveRef) {
976
+ function emitPortInit(pn, expr, fieldRustType, resolveRef, inputPorts, plan) {
699
977
  const field = portFieldName(pn);
978
+ // #110: a componentRef port bound to a runtime array input port (with elemType) → the native Vec<ElemT>,
979
+ // cloned out of the concrete input struct (`in_.<field>.clone()`) — ZERO boxed Value on the read hot path.
980
+ if (inputPorts !== undefined && portArrayElemRef(expr, inputPorts, plan) !== null) {
981
+ const e = classifyExpr(expr);
982
+ if (e.kind === "ref" && e.path.length === 1) {
983
+ return `${field}: in_.${rustFieldName(e.path[0])}.clone()`;
984
+ }
985
+ }
700
986
  // change #2: a static string array (projection) → a native `vec![...]` of &'static str.
701
987
  const strArr = staticStringArrElems(expr);
702
988
  if (strArr !== null) {
@@ -765,18 +1051,21 @@ function runnerResolveRef(priorNodeAt, atPos, typedNodes, plan, inputPorts) {
765
1051
  function rustScalar(s) {
766
1052
  return s === "string" ? "String" : s === "int" ? "i64" : s === "float" ? "f64" : "bool";
767
1053
  }
768
- function emitNativeRawRunner(comp, plan, isAsync = false) {
1054
+ function emitNativeRawRunner(comp, plan, ap) {
769
1055
  const ep = execPlan(comp);
770
1056
  const order = ep.order;
771
1057
  const hasConcurrency = ep.parallelStageOf.size > 0;
772
1058
  const fn = sanitize(comp.name);
773
- // bc#97 (uniform-rust-async slice): the async runner awaits each async handler at its call site. A
774
- // real-concurrency stage (bc#87) dispatches handlers from scoped std threads that mechanism cannot
775
- // host `.await` (a std thread is not an async task), and mixing it with an async terminal handler is
776
- // a per-node-propagation problem outside THIS slice. Fail closed LOUDLY rather than emit code that
777
- // won't compile.
1059
+ const isAsync = ap.runnerIsAsync;
1060
+ // bc#97: the async runner awaits each async handler at its call site (derived per-node). A real-
1061
+ // concurrency stage (bc#87) dispatches handlers from scoped std threads that mechanism cannot host
1062
+ // `.await` (a std thread is not an async task). Fail closed LOUDLY (rather than emit code that won't
1063
+ // compile) iff the runner is async AND has a concurrency stage. NOTE: with per-node granularity a
1064
+ // consumer CAN keep a concurrency-stage component sync (declare only its OTHER terminals async) — only
1065
+ // a component that both concurrency-stages AND awaits is rejected. Async + concurrent-stage (issuing
1066
+ // the stage's futures with join!) is a documented follow-up.
778
1067
  if (isAsync && hasConcurrency) {
779
- throw new GeneratorFailure("UNSUPPORTED_NODE_STRAIGHTLINE", `rust typed-native async (bc#97): component '${comp.name}' has a bc#87 real-concurrency stage, which dispatches handlers on scoped std threads and cannot await async handlers. Async + concurrent-stage is a per-node-propagation follow-up (out of this uniform-async slice) generate this component with io-model=sync, or split the concurrency stage.`);
1068
+ 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-updeclare this component's terminals sync, or split the concurrency stage.`);
780
1069
  }
781
1070
  const typedNodes = new Map();
782
1071
  for (const n of comp.body)
@@ -797,39 +1086,44 @@ function emitNativeRawRunner(comp, plan, isAsync = false) {
797
1086
  };
798
1087
  const outRef = comp.outputType !== undefined ? deriveTypeRef(comp.outputType, plan) : undefined;
799
1088
  const outTy = outRef ? renderTypeRef(outRef) : "Value";
800
- // Does any port read an input param? If not, `in_` is unused — bind it as `_in_` to stay clippy-clean.
1089
+ // Does any expression read an input param? If not, `in_` is unused — bind `_in_` to stay clippy-clean.
1090
+ // #108: this must recurse ALL expression operators (cond if/then/else, map when + ports, arith/compare/…),
1091
+ // not just ref/concat — a cond `if:{gt:[{add:[{ref:["x"]}…]}]}` reads `in_.x` through nested operators.
801
1092
  const readsInput = (() => {
802
- const walk = (node, atPos) => {
803
- const arr = staticArrElems(node);
804
- if (arr !== null)
805
- return arr.some((el) => walk(el, atPos));
806
- const e = classifyExpr(node);
807
- if (e.kind === "ref")
808
- return !priorNodeAt(e.path[0], atPos);
809
- if (e.kind === "concat")
810
- return e.parts.some((p) => walk(reconstructR(p), atPos));
811
- return false;
1093
+ // walk any Expression IR node; `bound` = names bound in this scope (a map $as) that are NOT input.
1094
+ const walk = (node, atPos, bound) => {
1095
+ if (node === null || typeof node === "boolean" || typeof node === "string" || typeof node === "number")
1096
+ return false;
1097
+ if (Array.isArray(node))
1098
+ return node.some((el) => walk(el, atPos, bound));
1099
+ const rec = node;
1100
+ const keys = Object.keys(rec);
1101
+ if (keys.length === 1 && (keys[0] === "ref" || keys[0] === "refOpt")) {
1102
+ const path = rec[keys[0]];
1103
+ const head = path[0];
1104
+ return !bound.has(head) && !priorNodeAt(head, atPos);
1105
+ }
1106
+ // any other single-key operator (obj/arr/concat/add/gt/cond/…): recurse into every child value.
1107
+ return keys.some((k) => walk(rec[k], atPos, bound));
812
1108
  };
813
1109
  return [...order.keys()].some((k) => {
814
1110
  const nd = comp.body[order[k]];
815
1111
  if ("map" in nd) {
816
1112
  const m = nd.map;
817
- const asName = m.as;
818
- const walkMap = (node) => {
819
- const arr = staticArrElems(node);
820
- if (arr !== null)
821
- return arr.some(walkMap);
822
- const e = classifyExpr(node);
823
- if (e.kind === "ref")
824
- return e.path[0] !== asName && !priorNodeAt(e.path[0], k);
825
- if (e.kind === "concat")
826
- return e.parts.some((p) => walkMap(reconstructR(p)));
827
- return false;
828
- };
829
- return Object.values(m.ports).some((pn) => walkMap(pn));
1113
+ const bound = new Set([m.as]);
1114
+ // the map's OVER is evaluated in the outer scope (may read input); ports + when in element scope.
1115
+ if (walk(m.over, k, new Set()))
1116
+ return true;
1117
+ if (m.when !== undefined && walk(m.when, k, bound))
1118
+ return true;
1119
+ return Object.values(m.ports).some((pn) => walk(pn, k, bound));
1120
+ }
1121
+ if ("cond" in nd) {
1122
+ const c = nd.cond;
1123
+ return walk(c.if, k, new Set()) || walk(c.then, k, new Set()) || walk(c.else, k, new Set());
830
1124
  }
831
1125
  const cr = nd;
832
- return Object.values(cr.ports).some((pn) => walk(pn, k));
1126
+ return Object.values(cr.ports).some((pn) => walk(pn, k, new Set()));
833
1127
  });
834
1128
  })();
835
1129
  const inParam = readsInput ? "in_" : "_in_";
@@ -853,8 +1147,10 @@ function emitNativeRawRunner(comp, plan, isAsync = false) {
853
1147
  // so the runner is itself `async fn` and returns a future the (already-async) consumer awaits. Sync
854
1148
  // default emits `pub fn` exactly as before (byte-identical).
855
1149
  const fnKw = isAsync ? "pub async fn" : "pub fn";
1150
+ // #108: a component whose body is ALL cond nodes dispatches NO handler — bind `_handlers` (clippy-clean).
1151
+ const dispatchesHandler = comp.body.some((n) => "map" in n || "component" in n);
856
1152
  lines.push(`${fnKw} run_native_raw_struct_${fn}${bound}(`);
857
- lines.push(` handlers: &H,`);
1153
+ lines.push(` ${dispatchesHandler ? "handlers" : "_handlers"}: &H,`);
858
1154
  lines.push(` ${inParam}: ${inStructName(comp.name)},`);
859
1155
  lines.push(`) -> Result<${outTy}, BehaviorError> {`);
860
1156
  for (const k of order.keys()) {
@@ -872,7 +1168,12 @@ function emitNativeRawRunner(comp, plan, isAsync = false) {
872
1168
  continue;
873
1169
  }
874
1170
  if ("map" in comp.body[i]) {
875
- lines.push(emitMapArm(comp, comp.body[i], k, typedNodes, plan, priorNodeAt, isAsync));
1171
+ lines.push(emitMapArm(comp, comp.body[i], k, typedNodes, plan, priorNodeAt, ap.nodeIsAsync(comp.body[i].id)));
1172
+ continue;
1173
+ }
1174
+ // #108: a cond node — pure Expression (no handler). Compile via the runtime-free NativeExprCompiler.
1175
+ if ("cond" in comp.body[i]) {
1176
+ lines.push(emitCondArm(comp, comp.body[i], k, typedNodes, plan, priorNodeAt));
876
1177
  continue;
877
1178
  }
878
1179
  const node = comp.body[i];
@@ -914,11 +1215,14 @@ function emitNativeRawRunner(comp, plan, isAsync = false) {
914
1215
  const inits = [];
915
1216
  for (const pn of portNames) {
916
1217
  const expr = node.ports[pn];
917
- const ty = portFieldRustType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`);
918
- inits.push(emitPortInit(pn, expr, ty, resolveRef));
1218
+ const ty = portFieldRustType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`, undefined, plan);
1219
+ inits.push(emitPortInit(pn, expr, ty, resolveRef, comp.inputPorts, plan));
919
1220
  }
920
1221
  const boundArg = node.bindField !== undefined ? `bound_${s}` : "None";
921
- const awaitSfx = isAsync ? ".await" : "";
1222
+ // bc#97: derived per-node await this point-read's future is awaited HERE (its result-consumption
1223
+ // point) iff THIS node's terminal handler is async. A sync handler in the same async runner is called
1224
+ // directly (no `.await`). The runner being `async fn` does not force every call site to await.
1225
+ const awaitSfx = ap.nodeIsAsync(node.id) ? ".await" : "";
922
1226
  lines.push(`${indent}let ports_${s} = ${structName} { ${inits.join(", ")} };`);
923
1227
  lines.push(`${indent}let row_${s} = match handlers.${nodeMethodName(node.id)}(&ports_${s}, ${boundArg})${awaitSfx} {`);
924
1228
  lines.push(`${indent} Some(r) => r,`);
@@ -995,8 +1299,8 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
995
1299
  const inits = [];
996
1300
  for (const pn of Object.keys(node.ports)) {
997
1301
  const expr = node.ports[pn];
998
- const ty = portFieldRustType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`);
999
- inits.push(emitPortInit(pn, expr, ty, resolveRef));
1302
+ const ty = portFieldRustType(expr, comp.inputPorts, `port '${pn}' of node '${node.id}'`, undefined, plan);
1303
+ inits.push(emitPortInit(pn, expr, ty, resolveRef, comp.inputPorts, plan));
1000
1304
  }
1001
1305
  if (gated) {
1002
1306
  lines.push(`${ind}ports_${s} = Some(${structName} { ${inits.join(", ")} });`);
@@ -1084,26 +1388,26 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
1084
1388
  * - #93 shape 2: NON-batched map...into — dispatch ONCE PER ELEMENT (N physical requests), into per element.
1085
1389
  * - #93 shape 3: BATCHED map NO into — dispatch once, collect each element from the concrete element row.
1086
1390
  */
1087
- function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync = false) {
1391
+ function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt,
1392
+ // bc#97: whether THIS map node's terminal handler is async (derived per-node) — awaits the batched /
1393
+ // per-element handler call at its consumption point iff true. Sync map handlers are called directly.
1394
+ isAsync = false) {
1088
1395
  const m = node.map;
1089
1396
  const awaitSfx = isAsync ? ".await" : "";
1090
1397
  const batched = m.batched === true;
1091
1398
  const hasInto = m.into !== undefined;
1399
+ const hasGuard = m.when !== undefined;
1092
1400
  const s = sanitize(node.id);
1093
1401
  const structName = portsStructName(comp.name, node.id);
1094
1402
  const batch = `${structName}Batch`;
1095
- const overE = classifyExpr(m.over);
1096
- const overParent = overE.kind === "ref" ? overE.path[0] : "";
1097
- const overBaseRef = typedNodes.get(overParent);
1098
- const { ref: overRef } = typedFieldAccess(`${typedCell(overParent)}.borrow()`, overBaseRef, overE.kind === "ref" ? overE.path.slice(1) : [], plan);
1099
- const overElemRef = overRef.elem;
1100
- const overFieldPath = overE.kind === "ref" ? overE.path.slice(1).map((p) => `.${rustFieldName(p)}`).join("") : "";
1403
+ // #108: over element type + the OWNED over-Vec expression (prior-node arr OR input array w/ elemType).
1404
+ const { elemRef: overElemRef, overExpr } = mapOverElemInfo(comp, node, typedNodes, plan);
1101
1405
  const elemMat = mapElemMaterializerName(node.id);
1102
1406
  const priorHere = (head) => priorNodeAt(head, atPos);
1103
1407
  const asName = m.as;
1104
1408
  const portNames = Object.keys(m.ports);
1105
1409
  const elemArrRef = typedNodes.get(node.id);
1106
- const elemName = elemArrRef.elem.name;
1410
+ const elemName = renderTypeRef(elemArrRef.elem);
1107
1411
  const resolveRef = (head, restPath, opt) => {
1108
1412
  if (opt)
1109
1413
  return null;
@@ -1138,37 +1442,77 @@ function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync =
1138
1442
  }
1139
1443
  if (acc.kind !== "scalar" || acc.scalar === "null")
1140
1444
  return null;
1141
- const owned = acc.scalar === "string" ? `(${expr}).clone()` : `*(${expr})`;
1445
+ // OWNED value. When restPath is EMPTY, `expr` is a bare REFERENCE (`oel_${s}` = &Elem-scalar, or
1446
+ // `cell.borrow()` = Ref<scalar>) — deref with `*` (Copy) / `.clone()` via deref (String). When a
1447
+ // `.field` was appended, `expr` is a value PLACE (auto-deref) — read it directly (`.clone()` String).
1448
+ const isBareRef = restPath.length === 0;
1449
+ let owned;
1450
+ if (acc.scalar === "string") {
1451
+ owned = isBareRef ? `(*${expr}).clone()` : `${expr}.clone()`;
1452
+ }
1453
+ else {
1454
+ owned = isBareRef ? `*${expr}` : expr;
1455
+ }
1142
1456
  return { expr: owned, scalar: rustScalar(acc.scalar) };
1143
1457
  };
1144
1458
  // build the per-element CONCRETE native ports struct. `il` = indent inside the element loop.
1145
1459
  const buildPorts = (il) => {
1146
1460
  const out = [];
1147
1461
  const inits = [];
1462
+ const asBinding = { name: asName, ref: overElemRef, plan };
1148
1463
  for (const pn of portNames) {
1149
- const ty = portFieldRustType(m.ports[pn], comp.inputPorts, `map port '${pn}' of node '${node.id}'`);
1150
- inits.push(emitPortInit(pn, m.ports[pn], ty, resolveRef));
1464
+ const ty = portFieldRustType(m.ports[pn], comp.inputPorts, `map port '${pn}' of node '${node.id}'`, asBinding, plan);
1465
+ inits.push(emitPortInit(pn, m.ports[pn], ty, resolveRef, comp.inputPorts, plan));
1151
1466
  }
1152
1467
  out.push(`${il}let ep_${s} = ${structName} { ${inits.join(", ")} };`);
1153
1468
  return out;
1154
1469
  };
1470
+ // #108 guard: compile `when` to a native bool over an element-scoped resolveHead ($as element value,
1471
+ // prior node, input port). On skip: `continue` (element excluded — matches run_behavior's keep-gate).
1472
+ const emitGuard = (il) => {
1473
+ if (!hasGuard)
1474
+ return [];
1475
+ const resolveHead = (head) => {
1476
+ if (head === asName)
1477
+ return { expr: `oel_${s}.clone()`, ref: overElemRef };
1478
+ if (priorHere(head))
1479
+ return { expr: `${typedCell(head)}.borrow().clone()`, ref: typedNodes.get(head) };
1480
+ return rustInputHeadRef(head, comp, plan);
1481
+ };
1482
+ const g = new NativeExprCompiler(makeRustExprBackend(resolveHead, plan)).compileBool(m.when);
1483
+ const out = [];
1484
+ for (const st of g.stmts)
1485
+ out.push(`${il}${st}`);
1486
+ // bind the keep flag to a var so clippy's nonminimal_bool doesn't rewrite `!(a > b)` (the `when`
1487
+ // expr must stay verbatim = run_behavior's `{cond:[when,true,false]}` strict-bool keep-gate).
1488
+ out.push(`${il}let __keep = ${rustStripOuterParens(g.expr)};`);
1489
+ out.push(`${il}if !__keep { continue; }`);
1490
+ return out;
1491
+ };
1155
1492
  const lines = [];
1156
1493
  const closers = [];
1157
- lines.push(` // ── map '${node.id}' (${m.component}, ${batched ? "batched" : "per-element"}, into:${JSON.stringify(m.into ?? null)}${m.relationKind ? ", relationKind:" + m.relationKind : ""}${m.parent ? ", parent:" + m.parent : ""}) ──`);
1494
+ lines.push(` // ── map '${node.id}' (${m.component}, ${batched ? "batched" : "per-element"}, into:${JSON.stringify(m.into ?? null)}${hasGuard ? ", guarded" : ""}${m.relationKind ? ", relationKind:" + m.relationKind : ""}${m.parent ? ", parent:" + m.parent : ""}) ──`);
1158
1495
  let indent = " ";
1159
1496
  if (m.parent !== undefined && priorNodeAt(m.parent, atPos)) {
1160
1497
  lines.push(`${indent}if produced_${sanitize(m.parent)}.get() {`);
1161
1498
  closers.unshift(`${indent}}`);
1162
1499
  indent += " ";
1163
1500
  }
1164
- lines.push(`${indent}let over_${s} = ${typedCell(overParent)}.borrow()${overFieldPath}.clone();`);
1501
+ lines.push(`${indent}let over_${s} = ${overExpr};`);
1165
1502
  lines.push(`${indent}let mut built_${s}: Vec<${elemName}> = Vec::with_capacity(over_${s}.len());`);
1166
1503
  if (batched) {
1504
+ // build kept-element ports (+ kept over elements for into). Batched into is never guarded (rejected).
1167
1505
  lines.push(`${indent}let mut items_${s}: Vec<${structName}> = Vec::with_capacity(over_${s}.len());`);
1506
+ if (hasInto)
1507
+ lines.push(`${indent}let mut kept_${s}: Vec<${renderTypeRef(overElemRef)}> = Vec::with_capacity(over_${s}.len());`);
1168
1508
  lines.push(`${indent}for oel_${s} in over_${s}.iter() {`);
1509
+ for (const l of emitGuard(indent + " "))
1510
+ lines.push(l);
1169
1511
  for (const l of buildPorts(indent + " "))
1170
1512
  lines.push(l);
1171
1513
  lines.push(`${indent} items_${s}.push(ep_${s});`);
1514
+ if (hasInto)
1515
+ lines.push(`${indent} kept_${s}.push(oel_${s}.clone());`);
1172
1516
  lines.push(`${indent}}`);
1173
1517
  lines.push(`${indent}if !items_${s}.is_empty() {`);
1174
1518
  lines.push(`${indent} let want_${s} = items_${s}.len();`);
@@ -1183,23 +1527,28 @@ function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync =
1183
1527
  lines.push(`${indent} if row_${s}.rows.len() != want_${s} {`);
1184
1528
  lines.push(`${indent} return Err(BehaviorError::new("MAP_BATCH_RESULT_MISMATCH", format!("map '{}': batched handler must return a list aligned to items (want {})", ${rustStrLit(node.id)}, want_${s})));`);
1185
1529
  lines.push(`${indent} }`);
1186
- lines.push(`${indent} let mut rows_iter_${s} = row_${s}.rows.into_iter();`);
1187
- lines.push(`${indent} for over_el_${s} in over_${s}.iter() {`);
1188
- lines.push(`${indent} let er_${s} = rows_iter_${s}.next().expect("aligned batch row");`);
1189
1530
  if (hasInto) {
1531
+ // zip the aligned batch rows onto each KEPT over element (into materializer).
1532
+ lines.push(`${indent} let mut rows_iter_${s} = row_${s}.rows.into_iter();`);
1533
+ lines.push(`${indent} for over_el_${s} in kept_${s}.iter() {`);
1534
+ lines.push(`${indent} let er_${s} = rows_iter_${s}.next().expect("aligned batch row");`);
1190
1535
  lines.push(`${indent} built_${s}.push(${elemMat}(over_el_${s}, er_${s}));`);
1536
+ lines.push(`${indent} }`);
1191
1537
  }
1192
1538
  else {
1193
- lines.push(`${indent} let _ = &over_el_${s};`);
1539
+ // no-into: collect each element DIRECTLY from the aligned per-element row (result = kept rows).
1540
+ lines.push(`${indent} for er_${s} in row_${s}.rows.into_iter() {`);
1194
1541
  lines.push(`${indent} ${emitElemMove(`let el_${s}`, `er_${s}`, elemArrRef.elem, plan)}`);
1195
1542
  lines.push(`${indent} built_${s}.push(el_${s});`);
1543
+ lines.push(`${indent} }`);
1196
1544
  }
1197
- lines.push(`${indent} }`);
1198
1545
  lines.push(`${indent}}`);
1199
1546
  }
1200
1547
  else {
1201
- // #93 shape 2: NON-batched map...into — dispatch ONCE PER ELEMENT.
1548
+ // #93 shape 2 / #108: NON-batched map — dispatch ONCE PER KEPT ELEMENT.
1202
1549
  lines.push(`${indent}for oel_${s} in over_${s}.iter() {`);
1550
+ for (const l of emitGuard(indent + " "))
1551
+ lines.push(l);
1203
1552
  for (const l of buildPorts(indent + " "))
1204
1553
  lines.push(l);
1205
1554
  lines.push(`${indent} let er_${s} = match handlers.${nodeMethodName(node.id)}(&ep_${s}, None)${awaitSfx} {`);
@@ -1213,6 +1562,7 @@ function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync =
1213
1562
  lines.push(`${indent} built_${s}.push(${elemMat}(oel_${s}, er_${s}));`);
1214
1563
  }
1215
1564
  else {
1565
+ lines.push(`${indent} let _ = &oel_${s};`);
1216
1566
  lines.push(`${indent} ${emitElemMove(`let el_${s}`, `er_${s}`, elemArrRef.elem, plan)}`);
1217
1567
  lines.push(`${indent} built_${s}.push(el_${s});`);
1218
1568
  }
@@ -1224,6 +1574,71 @@ function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync =
1224
1574
  lines.push(c);
1225
1575
  return lines.join("\n");
1226
1576
  }
1577
+ /** #108: resolve an INPUT port head for a native-expr ref (rust). Scalar → `in_.<field>` (owned: String
1578
+ * cloned, Copy as-is); array WITH elemType → `in_.<field>.clone()` of Vec<ElemT> (for `len`); else null. */
1579
+ function rustInputHeadRef(head, comp, plan) {
1580
+ const schema = comp.inputPorts?.[head];
1581
+ if (schema === undefined)
1582
+ return null;
1583
+ const sc = inputScalarKind(schema);
1584
+ if (sc !== undefined) {
1585
+ const scalarRef = { kind: "scalar", scalar: sc === "i64" ? "int" : sc === "f64" ? "float" : sc === "String" ? "string" : "bool" };
1586
+ const field = `in_.${rustFieldName(head)}`;
1587
+ return { expr: sc === "String" ? `${field}.clone()` : field, ref: scalarRef };
1588
+ }
1589
+ const et = schema.elemType;
1590
+ if ((schema.type === "array" || schema.type === "arr") && et !== undefined) {
1591
+ // no clone: the only array use inside a cond/guard expr is `len`, which BORROWS (`&in_.xs`).
1592
+ return { expr: `in_.${rustFieldName(head)}`, ref: { kind: "arr", elem: deriveTypeRef(et, plan) } };
1593
+ }
1594
+ return null;
1595
+ }
1596
+ /**
1597
+ * emitCondArm — the struct-native exec of a covered cond node (#108, rust). Mirrors run_behavior's cond
1598
+ * lower: compile `if` to a native bool, materialize the TAKEN branch to the node's outType (only the
1599
+ * taken branch evaluated), assign the node's typed cell + set produced. NO handler dispatch. Compiles
1600
+ * via the runtime-free NativeExprCompiler (byte-equal to `evaluate`); an uncovered expr fails closed.
1601
+ */
1602
+ function emitCondArm(comp, node, atPos, typedNodes, plan, priorNodeAt) {
1603
+ const c = node.cond;
1604
+ const s = sanitize(node.id);
1605
+ const nodeRef = typedNodes.get(node.id);
1606
+ const priorHere = (head) => priorNodeAt(head, atPos);
1607
+ const resolveHead = (head) => {
1608
+ if (priorHere(head))
1609
+ return { expr: `${typedCell(head)}.borrow().clone()`, ref: typedNodes.get(head) };
1610
+ return rustInputHeadRef(head, comp, plan);
1611
+ };
1612
+ const compiler = new NativeExprCompiler(makeRustExprBackend(resolveHead, plan));
1613
+ const resolveDecl = (name) => findDecl(plan, name).fields;
1614
+ const cond = compiler.compileBool(c.if);
1615
+ const thenC = compiler.compileTo(c.then, nodeRef, resolveDecl);
1616
+ const elseC = compiler.compileTo(c.else, nodeRef, resolveDecl);
1617
+ const lines = [];
1618
+ lines.push(` // ── cond '${node.id}'${c.parent ? " (parent:" + c.parent + ")" : ""} — pure Expression (no handler) ──`);
1619
+ let indent = " ";
1620
+ const closers = [];
1621
+ if (c.parent !== undefined && priorNodeAt(c.parent, atPos)) {
1622
+ lines.push(`${indent}if produced_${sanitize(c.parent)}.get() {`);
1623
+ closers.unshift(`${indent}}`);
1624
+ indent += " ";
1625
+ }
1626
+ for (const st of cond.stmts)
1627
+ lines.push(`${indent}${st}`);
1628
+ lines.push(`${indent}if ${rustStripOuterParens(cond.expr)} {`);
1629
+ for (const st of thenC.stmts)
1630
+ lines.push(`${indent} ${st}`);
1631
+ lines.push(`${indent} *${typedCell(node.id)}.borrow_mut() = ${thenC.expr};`);
1632
+ lines.push(`${indent}} else {`);
1633
+ for (const st of elseC.stmts)
1634
+ lines.push(`${indent} ${st}`);
1635
+ lines.push(`${indent} *${typedCell(node.id)}.borrow_mut() = ${elseC.expr};`);
1636
+ lines.push(`${indent}}`);
1637
+ lines.push(`${indent}produced_${s}.set(true);`);
1638
+ for (const cl of closers)
1639
+ lines.push(cl);
1640
+ return lines.join("\n");
1641
+ }
1227
1642
  /** emitElemMove — move a concrete element row (RawElemNR, error already handled) into a fresh `let` local
1228
1643
  * of the element outType. Named → struct literal from moved fields; scalar/arr/opt → .val. */
1229
1644
  function emitElemMove(letBind, rowExpr, ref, plan) {
@@ -1306,8 +1721,11 @@ fn unknown_component(component: &str) -> BehaviorError {
1306
1721
  }`;
1307
1722
  // ── module assembly ──────────────────────────────────────────────────────────────────
1308
1723
  function emit(ctx) {
1309
- // bc#97: uniform I/O model for THIS generation async terminal handlers async fn + .await.
1310
- const isAsync = ctx.ioModel === "async";
1724
+ // bc#97 (increment 2): the SSoT language-agnostic propagation pass derives, PER COMPONENT + PER NODE,
1725
+ // which handler-call boundaries must be awaited (bottom-up over the declared async terminal handlers).
1726
+ // rust CONSUMES this annotation (async fn + .await where derived); go ignores it. per-node granularity
1727
+ // is a strict generalization of increment-1's uniform ioModel:'async' (= every terminal async).
1728
+ const asyncPlans = buildAsyncPlans(ctx.ir, ctx.ioModel);
1311
1729
  for (const c of ctx.ir.components)
1312
1730
  assertTypedCoverage(c);
1313
1731
  const native = ctx.ir.components.filter(isNativeRaw);
@@ -1342,6 +1760,8 @@ ${emitStructSurface([])}
1342
1760
  .replace(/^( {4})([a-z_][A-Za-z0-9_]*: )/gm, "$1pub $2")
1343
1761
  .replace(/^( {4})(r#[a-z_][A-Za-z0-9_]*: )/gm, "$1pub $2");
1344
1762
  const defaults = emitDefaults(plan);
1763
+ // #108: reset the native-expr helper-usage accumulator (set by the cond/guard compiler).
1764
+ rustExprUsed.helpers = false;
1345
1765
  const structs = [];
1346
1766
  const rowStructs = [];
1347
1767
  const handlerTraits = [];
@@ -1349,21 +1769,26 @@ ${emitStructSurface([])}
1349
1769
  const elemMaterializers = [];
1350
1770
  for (const c of native) {
1351
1771
  const typedNodes = new Map();
1772
+ // build the full typed-node map FIRST so a map's over-element resolution can see every node.
1773
+ for (const n of c.body)
1774
+ typedNodes.set(n.id, deriveTypeRef(n.outType, plan));
1352
1775
  for (const n of c.body) {
1353
1776
  if ("map" in n)
1354
- structs.push(emitMapPortsStructs(c, n));
1777
+ structs.push(emitMapPortsStructs(c, n, typedNodes, plan));
1778
+ else if ("cond" in n) {
1779
+ // #108: a cond node has NO ports struct (pure Expression — no handler dispatch).
1780
+ }
1355
1781
  else
1356
- structs.push(emitPortsStruct(c, n));
1357
- typedNodes.set(n.id, deriveTypeRef(n.outType, plan));
1782
+ structs.push(emitPortsStruct(c, n, undefined, plan));
1358
1783
  }
1359
1784
  rowStructs.push(emitRawRowStructs(c, typedNodes, plan));
1360
- handlerTraits.push(emitHandlerTrait(c, typedNodes, plan, isAsync));
1361
- inStructs.push(emitInStruct(c));
1785
+ handlerTraits.push(emitHandlerTrait(c, typedNodes, plan, asyncPlans.get(c.name)));
1786
+ inStructs.push(emitInStruct(c, plan));
1362
1787
  const mm = emitMapElemMaterializers(c, typedNodes, plan);
1363
1788
  if (mm)
1364
1789
  elemMaterializers.push(mm);
1365
1790
  }
1366
- const runners = native.map((c) => emitNativeRawRunner(c, plan, isAsync)).join("\n\n");
1791
+ const runners = native.map((c) => emitNativeRawRunner(c, plan, asyncPlans.get(c.name))).join("\n\n");
1367
1792
  const structSurface = emitStructSurface(native);
1368
1793
  // (nonNative.length === 0 is guaranteed here — the fail-closed guard above threw otherwise.)
1369
1794
  const banner = `#![allow(dead_code, unused_imports, non_snake_case)]
@@ -1389,8 +1814,15 @@ use std::cell::RefCell;`;
1389
1814
  decls || undefined,
1390
1815
  defaults || undefined,
1391
1816
  UNKNOWN_COMPONENT_HELPER,
1392
- `// Native ports structs (one per componentRef node; typed per the static port type — CONCRETE).\n${structs.join("\n\n")}`,
1393
- `// CONCRETE per-node row structs (typed fields = the node's outType; + error signal).\n${rowStructs.join("\n\n")}`,
1817
+ rustExprUsed.helpers
1818
+ ? `// #108 native-expr helper library (runtime-free; byte-equal to expr-eval.ts core) used by\n// cond / map-guard checked arithmetic. NO bc-runtime reference (failures use the LOCAL BehaviorError).\n${emitExprHelpersRust()}`
1819
+ : undefined,
1820
+ structs.some((r) => r.trim().length > 0)
1821
+ ? `// Native ports structs (one per componentRef node; typed per the static port type — CONCRETE).\n${structs.filter((r) => r.trim().length > 0).join("\n\n")}`
1822
+ : undefined,
1823
+ rowStructs.some((r) => r.trim().length > 0)
1824
+ ? `// CONCRETE per-node row structs (typed fields = the node's outType; + error signal).\n${rowStructs.filter((r) => r.trim().length > 0).join("\n\n")}`
1825
+ : undefined,
1394
1826
  `// CONCRETE per-component input structs (fields = inputPorts).\n${inStructs.join("\n\n")}`,
1395
1827
  `// CONCRETE per-component handler traits (one node_* method per node — native ports IN, row OUT).\n${handlerTraits.join("\n\n")}`,
1396
1828
  elemMaterializers.length
@@ -1410,13 +1842,18 @@ use std::cell::RefCell;`;
1410
1842
  * Value for the equivalence pin. Lives OUTSIDE the covered module so the module stays literal whole-file
1411
1843
  * zero (the ser_* / decode helpers are test glue, never on the consumer's read hot path).
1412
1844
  */
1413
- export function rustTypedNativeObserve(ir) {
1845
+ export function rustTypedNativeObserve(ir, ioModel = "sync") {
1414
1846
  const components = ir.components;
1415
1847
  const native = components.filter(isNativeRaw);
1416
1848
  const plan = buildTypePlan(ir);
1417
1849
  const needSync = native.some((c) => concurrencyPlan(c) !== null);
1418
1850
  const serializers = emitSerializers(plan);
1419
1851
  const marshallers = emitMarshallers(plan);
1852
+ // bc#97: the observe glue must MATCH the covered module's per-node async-ness — the scripted handler's
1853
+ // node_* methods are `async fn` exactly where the trait's are, and observe_native_raw awaits an async
1854
+ // runner. Built from the SAME SSoT propagation pass the covered emitter used, so glue never drifts.
1855
+ const asyncPlans = buildAsyncPlans(ir, ioModel);
1856
+ const anyAsyncRunner = native.some((c) => asyncPlans.get(c.name).runnerIsAsync);
1420
1857
  // ── per-component input decoders: generic &[(String,Value)] -> concrete InNR_<comp> (test glue). ──
1421
1858
  const inDecoders = native.map((c) => emitInDecoder(c, plan)).join("\n\n");
1422
1859
  // ── concrete scripted handler: implements every HandlerNR<comp> trait by decoding a scripted Value
@@ -1425,12 +1862,18 @@ export function rustTypedNativeObserve(ir) {
1425
1862
  const emittedDecode = new Set();
1426
1863
  const traitImpls = [];
1427
1864
  for (const c of native) {
1865
+ const ap = asyncPlans.get(c.name);
1866
+ // the scripted impl's node_* is `async fn` iff the trait's is (same per-node derivation).
1867
+ const fnKwFor = (nodeId) => (ap.nodeIsAsync(nodeId) ? "async fn" : "fn");
1428
1868
  const typedNodes = new Map();
1429
1869
  for (const n of c.body)
1430
1870
  typedNodes.set(n.id, deriveTypeRef(n.outType, plan));
1431
1871
  const methods = [];
1432
1872
  for (const n of c.body) {
1873
+ if ("cond" in n)
1874
+ continue; // #108: a cond node has no handler method (pure Expression) — no scripted impl.
1433
1875
  const ref = typedNodes.get(n.id);
1876
+ const fnKw = fnKwFor(n.id);
1434
1877
  // bc#90 / runtime-free: the scripted impl's `_bound` param mirrors the trait's typed produced-aware
1435
1878
  // Option<K> (NOT a boxed Value) — computed per node so the impl signature matches the trait.
1436
1879
  const bt = boundRustType(n, typedNodes, plan);
@@ -1445,7 +1888,7 @@ export function rustTypedNativeObserve(ir) {
1445
1888
  const portsT = batched ? `${portsStructName(c.name, n.id)}Batch` : portsStructName(c.name, n.id);
1446
1889
  const retT = batched ? batchT : elemT;
1447
1890
  if (batched) {
1448
- methods.push(` fn ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${batchT}> {
1891
+ methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${batchT}> {
1449
1892
  let (is_err, err, ok) = self.next(${rustStrLit(m.component)})?;
1450
1893
  if is_err {
1451
1894
  return Some(${batchT} { is_error: true, err, ..Default::default() });
@@ -1456,7 +1899,7 @@ export function rustTypedNativeObserve(ir) {
1456
1899
  }`);
1457
1900
  }
1458
1901
  else {
1459
- methods.push(` fn ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${elemT}> {
1902
+ methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${elemT}> {
1460
1903
  let (is_err, err, ok) = self.next(${rustStrLit(m.component)})?;
1461
1904
  if is_err {
1462
1905
  return Some(${elemT} { is_error: true, err, ..Default::default() });
@@ -1471,7 +1914,7 @@ export function rustTypedNativeObserve(ir) {
1471
1914
  const node = n;
1472
1915
  const rowT = rawRowStructName(c.name, node.id);
1473
1916
  const decode = emitDecodeRow(c.name, node.id, rowT, ref, plan, emittedDecode, decodeFns, "");
1474
- methods.push(` fn ${nodeMethodName(node.id)}(&self, _ports: &${portsStructName(c.name, node.id)}, _bound: ${bt}) -> Option<${rowT}> {
1917
+ methods.push(` ${fnKw} ${nodeMethodName(node.id)}(&self, _ports: &${portsStructName(c.name, node.id)}, _bound: ${bt}) -> Option<${rowT}> {
1475
1918
  let (is_err, err, ok) = self.next(${rustStrLit(node.component)})?;
1476
1919
  if is_err {
1477
1920
  return Some(${rowT} { is_error: true, err, ..Default::default() });
@@ -1490,7 +1933,12 @@ export function rustTypedNativeObserve(ir) {
1490
1933
  // bridge it to the bc-runtime `behavior_contracts::BehaviorError` at the observe boundary (same
1491
1934
  // code/message) so the conformance harness (main.rs) reads the stable code without the covered
1492
1935
  // module ever touching bc runtime. decode_* is test glue and returns the local error too.
1493
- return ` ${rustStrLit(c.name)} => {\n let in_ = decode_${inStructName(c.name)}(input).map_err(to_rt_err)?;\n let tv = super::run_native_raw_struct_${sanitize(c.name)}(handlers, in_).map_err(to_rt_err)?;\n Ok(${ser})\n }`;
1936
+ // bc#97: an async runner returns a Future drive it to completion with the std-only block-on
1937
+ // (bc_block_on) so observe_native_raw keeps its SYNC external signature (the harness is unchanged).
1938
+ // The scripted async node_* return ready values, so the future needs no real executor / no runtime.
1939
+ const runnerCall = `super::run_native_raw_struct_${sanitize(c.name)}(handlers, in_)`;
1940
+ const driven = asyncPlans.get(c.name).runnerIsAsync ? `bc_block_on(${runnerCall})` : runnerCall;
1941
+ return ` ${rustStrLit(c.name)} => {\n let in_ = decode_${inStructName(c.name)}(input).map_err(to_rt_err)?;\n let tv = ${driven}.map_err(to_rt_err)?;\n Ok(${ser})\n }`;
1494
1942
  })
1495
1943
  .join("\n");
1496
1944
  // the scripted handler is passed as `&ScriptedNativeRaw`; the observe entry takes `&ScriptedNativeRaw`
@@ -1562,6 +2010,30 @@ use super::*;
1562
2010
  fn to_rt_err(e: BehaviorError) -> behavior_contracts::BehaviorError {
1563
2011
  behavior_contracts::BehaviorError::new(e.code, e.message)
1564
2012
  }
2013
+ ${anyAsyncRunner
2014
+ ? `// bc#97 (TEST glue) — a minimal std-only block-on that drives an async runner Future to completion so
2015
+ // observe_native_raw stays sync-callable (the harness never changes). The covered async fn's futures are
2016
+ // ready (the scripted handler does no real I/O), so a no-op waker + spin poll resolves in one step. This
2017
+ // lives ONLY in the observe companion — the covered module itself imports zero runtime.
2018
+ fn bc_block_on<F: std::future::Future>(mut fut: F) -> F::Output {
2019
+ use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
2020
+ fn noop(_: *const ()) {}
2021
+ fn clone(_: *const ()) -> RawWaker {
2022
+ RawWaker::new(std::ptr::null(), &VTABLE)
2023
+ }
2024
+ static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
2025
+ let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
2026
+ let mut cx = Context::from_waker(&waker);
2027
+ // SAFETY: fut is owned on the stack and never moved after pinning (it is dropped at scope end).
2028
+ let mut fut = unsafe { std::pin::Pin::new_unchecked(&mut fut) };
2029
+ loop {
2030
+ match fut.as_mut().poll(&mut cx) {
2031
+ Poll::Ready(v) => return v,
2032
+ Poll::Pending => std::thread::yield_now(),
2033
+ }
2034
+ }
2035
+ }`
2036
+ : ""}
1565
2037
 
1566
2038
  ${serializers}
1567
2039
 
@@ -1601,7 +2073,6 @@ ${arms}
1601
2073
  * typed read; Value-typed → the raw Value clone). A missing key leaves Default (every covered input is
1602
2074
  * required + supplied by the corpus). Lives ONLY in the observe companion. */
1603
2075
  function emitInDecoder(comp, plan) {
1604
- void plan;
1605
2076
  const name = inStructName(comp.name);
1606
2077
  const ports = Object.keys(comp.inputPorts ?? {});
1607
2078
  const lines = [];
@@ -1617,10 +2088,26 @@ function emitInDecoder(comp, plan) {
1617
2088
  lines.push(` for (k, v) in input {`);
1618
2089
  ports.forEach((p, i) => {
1619
2090
  const field = rustFieldName(p);
1620
- const scalar = inputScalarKind(comp.inputPorts[p]);
2091
+ const schema = comp.inputPorts[p];
2092
+ const scalar = inputScalarKind(schema);
2093
+ const et = schema.elemType;
1621
2094
  const head = i === 0 ? " if" : " } else if";
1622
2095
  lines.push(`${head} k == ${rustStrLit(p)} {`);
1623
- if (scalar === undefined) {
2096
+ if ((schema?.type === "array" || schema?.type === "arr") && et !== undefined) {
2097
+ // #108: decode the boxed Value::Arr into the native Vec<ElemT> (test glue — materialize each elem).
2098
+ const elemRef = deriveTypeRef(et, plan);
2099
+ const elemTy = renderTypeRef(elemRef);
2100
+ const mat = materializeExpr("ev", elemRef, plan);
2101
+ lines.push(` match v {`);
2102
+ lines.push(` Value::Arr(a) => {`);
2103
+ lines.push(` let mut dec: Vec<${elemTy}> = Vec::with_capacity(a.len());`);
2104
+ lines.push(` for ev in a.iter() { dec.push(${mat}); }`);
2105
+ lines.push(` in_.${field} = dec;`);
2106
+ lines.push(` }`);
2107
+ lines.push(` other => return Err(marshal_type_error(${rustStrLit(`decode input ${p}: expected array, got `)}, other.type_name())),`);
2108
+ lines.push(` }`);
2109
+ }
2110
+ else if (scalar === undefined) {
1624
2111
  lines.push(` in_.${field} = v.clone();`);
1625
2112
  }
1626
2113
  else {