behavior-contracts 0.5.0 → 0.6.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 +495 -75
  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 +553 -103
  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,7 @@ 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
+ function inferPortType(node, inputPorts, asBinding) {
88
90
  if (staticArrElems(node) !== null)
89
91
  return "Value";
90
92
  const e = classifyExpr(node);
@@ -95,6 +97,18 @@ function inferPortType(node, inputPorts) {
95
97
  case "bool":
96
98
  return "bool";
97
99
  case "ref": {
100
+ // #108: a `$as`-headed ref (map element binding) — resolve the element field's scalar type.
101
+ if (asBinding !== undefined && e.path[0] === asBinding.name) {
102
+ try {
103
+ const { ref } = typedFieldAccess(asBinding.name, asBinding.ref, e.path.slice(1), asBinding.plan);
104
+ if (ref.kind === "scalar" && ref.scalar !== "null")
105
+ return rustScalar(ref.scalar);
106
+ }
107
+ catch {
108
+ return "Value";
109
+ }
110
+ return "Value";
111
+ }
98
112
  if (e.path.length === 1) {
99
113
  const s = inputPorts[e.path[0]];
100
114
  if (s) {
@@ -139,13 +153,13 @@ function staticStringArrElems(node) {
139
153
  * Value-typed input) FAILS CLOSED — the covered read plane admits NO boxed escape (there is no `Value`
140
154
  * fallback on the native module). `where` names the emission site for the error.
141
155
  */
142
- function portFieldRustType(node, inputPorts, where = "port") {
156
+ function portFieldRustType(node, inputPorts, where = "port", asBinding) {
143
157
  if (staticStringArrElems(node) !== null)
144
158
  return "Vec<&'static str>";
145
159
  const num = numLiteralRustExpr(node);
146
160
  if (num !== null)
147
161
  return typeof node === "number" && Number.isInteger(node) ? "i64" : "f64";
148
- const ft = inferPortType(node, inputPorts);
162
+ const ft = inferPortType(node, inputPorts, asBinding);
149
163
  if (ft === "Value") {
150
164
  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
165
  }
@@ -158,13 +172,13 @@ function portFieldRustType(node, inputPorts, where = "port") {
158
172
  * reads `ports.f_<field>` DIRECTLY. The struct derives Clone (a bc#87 parallel stage clones it into the
159
173
  * per-worker dispatch). Every field is a NATIVE Rust type (bc#90 / runtime-free) — NO boxed `Value`.
160
174
  */
161
- function emitPortsStruct(comp, node) {
175
+ function emitPortsStruct(comp, node, asBinding) {
162
176
  const name = portsStructName(comp.name, node.id);
163
177
  const portNames = Object.keys(node.ports);
164
178
  if (portNames.length === 0) {
165
179
  return `// ${name} — native ports for node '${node.id}' (${node.component}); no ports.\n#[derive(Clone)]\npub struct ${name};`;
166
180
  }
167
- const types = portNames.map((p) => portFieldRustType(node.ports[p], comp.inputPorts, `port '${p}' of node '${node.id}'`));
181
+ const types = portNames.map((p) => portFieldRustType(node.ports[p], comp.inputPorts, `port '${p}' of node '${node.id}'`, asBinding));
168
182
  const fields = portNames
169
183
  .map((p, i) => ` pub ${portFieldName(p)}: ${types[i]}, // ${JSON.stringify(p)}`)
170
184
  .join("\n");
@@ -181,11 +195,14 @@ ${fields}
181
195
  * struct carries the Vec of per-element CONCRETE ports structs (the handler reads them off the concrete
182
196
  * struct — no by-name accessor, no key-value object).
183
197
  */
184
- function emitMapPortsStructs(comp, node) {
198
+ function emitMapPortsStructs(comp, node, typedNodes, plan) {
185
199
  const m = node.map;
186
200
  const name = portsStructName(comp.name, node.id);
187
201
  const batch = `${name}Batch`;
188
- const elemStruct = emitPortsStruct(comp, { id: node.id, component: m.component, ports: m.ports });
202
+ // #108: element ports may read the `$as` binding resolve the over element type for native typing.
203
+ const { elemRef } = mapOverElemInfo(comp, node, typedNodes, plan);
204
+ const asBinding = { name: m.as, ref: elemRef, plan };
205
+ const elemStruct = emitPortsStruct(comp, { id: node.id, component: m.component, ports: m.ports }, asBinding);
189
206
  return `${elemStruct}
190
207
 
191
208
  // ${batch} — CONCRETE batched ports for map '${node.id}': the Vec of per-element CONCRETE ports structs
@@ -196,6 +213,188 @@ pub struct ${batch} {
196
213
  pub items: Vec<${name}>,
197
214
  }`;
198
215
  }
216
+ // ── native expression compiler backend (#108) — cond `if` / map `when` / cond branches (Rust) ────────
217
+ //
218
+ // The runtime-free native expression compiler (native-expr.ts) mirrors run_behavior's cond/guard with
219
+ // BYTE-EQUAL semantics. The value-dependent runtime failures (INT_OVERFLOW / NAN_OR_INF / MOD_ZERO /
220
+ // PRECISION_LOSS) are emitted as calls to a FIXED set of RUNTIME-FREE helper fns baked into the module
221
+ // (bc_expr_* below) returning Result<T, BehaviorError> — the covered module still imports ZERO bc runtime.
222
+ // The runner is Result<_, BehaviorError>, so a fallible helper propagates with `?`.
223
+ let rustExprTempSeq = 0;
224
+ /** module-level accumulator: set when the native-expr compiler emits a fallible helper call (so emit()
225
+ * bakes the helper library). Reset at the start of each emit(). */
226
+ const rustExprUsed = { helpers: false };
227
+ /** makeRustExprBackend — renders native-expr for Rust. `resolveHead` maps a ref head to an OWNED typed
228
+ * base expr (a prior-node cell clone / an input field / a $as element). Sets rustExprUsed.helpers when
229
+ * a fallible helper is emitted. The runner is Result<_, BehaviorError>, so hoistFallible uses `?`. */
230
+ function makeRustExprBackend(resolveHead, plan) {
231
+ const used = rustExprUsed;
232
+ return {
233
+ lang: "rust",
234
+ freshTemp: () => `ex_${rustExprTempSeq++}`,
235
+ renderType: (ref) => renderTypeRef(ref),
236
+ scalarType: (s) => rustScalar(s),
237
+ strLit: (s) => `${rustStrLit(s)}.to_string()`,
238
+ boolLit: (b) => (b ? "true" : "false"),
239
+ intLit: (dec) => `${dec}i64`,
240
+ floatLit: (n) => `${rustFloatLitExpr(n)}f64`,
241
+ fieldAccess: (baseExpr, baseRef, path) => typedFieldAccess(baseExpr, baseRef, path, plan),
242
+ resolveHead,
243
+ hoistFallible: (temp, _ty, call) => {
244
+ used.helpers = true;
245
+ return [`let ${temp} = ${call}?;`];
246
+ },
247
+ helperCall: (helper, args) => {
248
+ used.helpers = true;
249
+ // the string code-point compare helpers take &str — borrow the owned String args.
250
+ const isStrCmp = helper === "strLt" || helper === "strLe" || helper === "strGt" || helper === "strGe";
251
+ const passed = isStrCmp ? args.map((a) => `&${a}`) : args;
252
+ // lenArr takes &[T] — borrow the owned Vec.
253
+ const finalArgs = helper === "lenArr" ? args.map((a) => `&${a}`) : passed;
254
+ return `bc_expr_${rustSnake(helper)}(${finalArgs.join(", ")})`;
255
+ },
256
+ binOp: (op, a, b) => `(${a} ${op} ${b})`,
257
+ concat: (parts) => {
258
+ // native String concat via a `[&str; N].concat()` — a string-literal part (`"…".to_string()`) is
259
+ // used as the bare `"…"` &str; any other owned String is borrowed with `.as_str()`. This shape is
260
+ // clippy-clean (no to_string_in_format_args / single_char_push_str / unnecessary_to_string).
261
+ const refs = parts.map((p) => {
262
+ const m = /^(".*")\.to_string\(\)$/.exec(p);
263
+ return m ? m[1] : `(${p}).as_str()`;
264
+ });
265
+ return `[${refs.join(", ")}].concat()`;
266
+ },
267
+ notOp: (a) => `(!${a})`,
268
+ structLit: (name, inits) => `${name} { ${inits.map((i) => `${rustFieldName(i.field)}: ${i.expr}`).join(", ")} }`,
269
+ arrLit: (elemTy, items) => `{ let __v: Vec<${elemTy}> = vec![${items.join(", ")}]; __v }`,
270
+ ternary: (cond, t, e, _ty) => `(if ${rustStripOuterParens(cond)} { ${rustStripOuterParens(t)} } else { ${rustStripOuterParens(e)} })`,
271
+ shortCircuitBool: (op, temp, left, rStmts, rExpr) => {
272
+ // and: let temp = if left { <rStmts> rExpr } else { false }; or: if left { true } else { <rStmts> rExpr }.
273
+ // build a block that runs rStmts then yields rExpr (parens stripped — block-tail is not a sub-expr).
274
+ const rblock = `{ ${rStmts.join(" ")} ${rustStripOuterParens(rExpr)} }`;
275
+ if (op === "and") {
276
+ return [`let ${temp}: bool = if ${rustStripOuterParens(left)} ${rblock} else { false };`];
277
+ }
278
+ return [`let ${temp}: bool = if ${rustStripOuterParens(left)} { true } else ${rblock};`];
279
+ },
280
+ condGuard: (temp, ty, cond, tStmts, tExpr, eStmts, eExpr) => {
281
+ const tblock = `{ ${tStmts.join(" ")} ${rustStripOuterParens(tExpr)} }`;
282
+ const eblock = `{ ${eStmts.join(" ")} ${rustStripOuterParens(eExpr)} }`;
283
+ return [`let ${temp}: ${ty} = if ${rustStripOuterParens(cond)} ${tblock} else ${eblock};`];
284
+ },
285
+ };
286
+ }
287
+ /** camelCase helper name → snake_case for the Rust helper fn (addI64 → add_i64, strLt → str_lt). */
288
+ function rustSnake(name) {
289
+ return name.replace(/([a-z])([A-Z0-9])/g, "$1_$2").replace(/([0-9])([a-z])/g, "$1_$2").toLowerCase();
290
+ }
291
+ /** strip ONE redundant outer paren pair from an expr (clippy `if (cond)` unused_parens). Only when the
292
+ * whole string is a single balanced `( … )` — a `(a) && (b)` stays untouched. */
293
+ function rustStripOuterParens(expr) {
294
+ if (!expr.startsWith("(") || !expr.endsWith(")"))
295
+ return expr;
296
+ let depth = 0;
297
+ for (let i = 0; i < expr.length; i++) {
298
+ if (expr[i] === "(")
299
+ depth++;
300
+ else if (expr[i] === ")") {
301
+ depth--;
302
+ if (depth === 0 && i !== expr.length - 1)
303
+ return expr; // closed before the end → not a single wrap
304
+ }
305
+ }
306
+ return expr.slice(1, -1);
307
+ }
308
+ /** Rust float literal (finite; integral gets `.0`). */
309
+ function rustFloatLitExpr(n) {
310
+ if (Number.isInteger(n))
311
+ return `${n}.0`;
312
+ return String(n);
313
+ }
314
+ /**
315
+ * emitExprHelpersRust — the FIXED runtime-free native-expr helper library (Rust). Emitted once per
316
+ * covered module that uses cond/guard/arith. Each helper is BYTE-EQUAL to expr-eval.ts core. Failures
317
+ * are the LOCAL BehaviorError with the SAME code strings as run_behavior.
318
+ */
319
+ function emitExprHelpersRust() {
320
+ return `// ── native-expr runtime-free helper library (#108) — byte-equal to expr-eval.ts core ──
321
+ #[allow(dead_code)]
322
+ fn bc_expr_add_i64(a: i64, b: i64) -> Result<i64, BehaviorError> {
323
+ a.checked_add(b).ok_or_else(|| BehaviorError::new("INT_OVERFLOW", "i64 overflow"))
324
+ }
325
+ #[allow(dead_code)]
326
+ fn bc_expr_sub_i64(a: i64, b: i64) -> Result<i64, BehaviorError> {
327
+ a.checked_sub(b).ok_or_else(|| BehaviorError::new("INT_OVERFLOW", "i64 overflow"))
328
+ }
329
+ #[allow(dead_code)]
330
+ fn bc_expr_mul_i64(a: i64, b: i64) -> Result<i64, BehaviorError> {
331
+ a.checked_mul(b).ok_or_else(|| BehaviorError::new("INT_OVERFLOW", "i64 overflow"))
332
+ }
333
+ #[allow(dead_code)]
334
+ fn bc_expr_neg_i64(a: i64) -> Result<i64, BehaviorError> {
335
+ a.checked_neg().ok_or_else(|| BehaviorError::new("INT_OVERFLOW", "i64 overflow"))
336
+ }
337
+ #[allow(dead_code)]
338
+ fn bc_expr_check_finite(v: f64) -> Result<f64, BehaviorError> {
339
+ if v.is_finite() { Ok(v) } else { Err(BehaviorError::new("NAN_OR_INF", "non-finite float")) }
340
+ }
341
+ #[allow(dead_code)]
342
+ fn bc_expr_add_f(a: f64, b: f64) -> Result<f64, BehaviorError> { bc_expr_check_finite(a + b) }
343
+ #[allow(dead_code)]
344
+ fn bc_expr_sub_f(a: f64, b: f64) -> Result<f64, BehaviorError> { bc_expr_check_finite(a - b) }
345
+ #[allow(dead_code)]
346
+ fn bc_expr_mul_f(a: f64, b: f64) -> Result<f64, BehaviorError> { bc_expr_check_finite(a * b) }
347
+ #[allow(dead_code)]
348
+ fn bc_expr_neg_f(a: f64) -> Result<f64, BehaviorError> { bc_expr_check_finite(-a) }
349
+ #[allow(dead_code)]
350
+ fn bc_expr_mod_f(a: f64, b: f64) -> Result<f64, BehaviorError> { bc_expr_check_finite(a % b) }
351
+ #[allow(dead_code)]
352
+ fn bc_expr_mod_i64(a: i64, b: i64) -> Result<i64, BehaviorError> {
353
+ if b == 0 { return Err(BehaviorError::new("MOD_ZERO", "int mod by zero")); }
354
+ // Rust % is truncated (sign of dividend) — matches modCore. MIN % -1 would panic; guard it.
355
+ if a == i64::MIN && b == -1 { return Ok(0); }
356
+ Ok(a % b)
357
+ }
358
+ #[allow(dead_code)]
359
+ fn bc_expr_widen_i64f(a: i64) -> Result<f64, BehaviorError> {
360
+ if !(-9007199254740992..=9007199254740992).contains(&a) {
361
+ return Err(BehaviorError::new("PRECISION_LOSS", "int exceeds exact float range (±2^53)"));
362
+ }
363
+ Ok(a as f64)
364
+ }
365
+ #[allow(dead_code)]
366
+ fn bc_expr_div_f(a: f64, b: f64) -> Result<f64, BehaviorError> { bc_expr_check_finite(a / b) }
367
+ #[allow(dead_code)]
368
+ fn bc_expr_len_arr<T>(a: &[T]) -> Result<i64, BehaviorError> {
369
+ i64::try_from(a.len()).map_err(|_| BehaviorError::new("INT_OVERFLOW", "i64 overflow"))
370
+ }
371
+ // code-point (char) order comparison — matches cmpCodePoints (char-by-char, NOT byte order).
372
+ #[allow(dead_code)]
373
+ fn bc_expr_cmp_cp(a: &str, b: &str) -> std::cmp::Ordering {
374
+ let mut ia = a.chars();
375
+ let mut ib = b.chars();
376
+ loop {
377
+ match (ia.next(), ib.next()) {
378
+ (None, None) => return std::cmp::Ordering::Equal,
379
+ (None, Some(_)) => return std::cmp::Ordering::Less,
380
+ (Some(_), None) => return std::cmp::Ordering::Greater,
381
+ (Some(ca), Some(cb)) => {
382
+ if ca != cb {
383
+ return if (ca as u32) < (cb as u32) { std::cmp::Ordering::Less } else { std::cmp::Ordering::Greater };
384
+ }
385
+ }
386
+ }
387
+ }
388
+ }
389
+ #[allow(dead_code)]
390
+ fn bc_expr_str_lt(a: &str, b: &str) -> bool { bc_expr_cmp_cp(a, b) == std::cmp::Ordering::Less }
391
+ #[allow(dead_code)]
392
+ fn bc_expr_str_le(a: &str, b: &str) -> bool { bc_expr_cmp_cp(a, b) != std::cmp::Ordering::Greater }
393
+ #[allow(dead_code)]
394
+ fn bc_expr_str_gt(a: &str, b: &str) -> bool { bc_expr_cmp_cp(a, b) == std::cmp::Ordering::Greater }
395
+ #[allow(dead_code)]
396
+ fn bc_expr_str_ge(a: &str, b: &str) -> bool { bc_expr_cmp_cp(a, b) != std::cmp::Ordering::Less }`;
397
+ }
199
398
  // ── CONCRETE per-node row structs + row→outType copiers (mirror the Go concrete contract) ──────────
200
399
  //
201
400
  // Each covered node has a CONCRETE row struct `RawRowNR<Comp><Node>` whose fields are the node's projected
@@ -209,6 +408,8 @@ pub struct ${batch} {
209
408
  function emitRawRowStructs(comp, typedNodes, plan) {
210
409
  const parts = [];
211
410
  for (const n of comp.body) {
411
+ if ("cond" in n)
412
+ continue; // #108: a cond node has no handler-result row (pure Expression).
212
413
  const ref = typedNodes.get(n.id);
213
414
  if ("map" in n) {
214
415
  const arrRef = ref;
@@ -291,12 +492,14 @@ function boundRustType(node, typedNodes, plan) {
291
492
  * struct (per-element). This is the fully-concrete, runtime-free replacement for the NativeRawComponentExec
292
493
  * seam.
293
494
  */
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";
495
+ function emitHandlerTrait(comp, typedNodes, plan, ap) {
496
+ // bc#97 (increment 2): async I/O model is PER-TERMINAL-NODE. A node whose terminal handler is declared
497
+ // async gets an `async fn node_*` (the consumer's impl returns a future the runner awaits at the call
498
+ // site); a sync terminal stays plain `fn node_*` (called directly, no await). Mixed sync+async in one
499
+ // trait is fine. `async fn` needs NO external runtime to compile — the future is `async fn` sugar over
500
+ // std, so the covered module stays runtime-free either way. Uniform ioModel:'async' ⇒ every method
501
+ // async (byte-identical to increment-1); ioModel unset/'sync' ⇒ every method plain `fn` (pre-#97).
502
+ const fnKwFor = (nodeId) => (ap.nodeIsAsync(nodeId) ? "async fn" : "fn");
300
503
  const lines = [];
301
504
  lines.push(`// ${handlerTraitName(comp.name)} — the CONCRETE per-component handler seam: one typed method`);
302
505
  lines.push(`// per covered node (native ports struct IN, concrete row struct OUT). No generic boxed-ports`);
@@ -304,7 +507,10 @@ function emitHandlerTrait(comp, typedNodes, plan, isAsync = false) {
304
507
  lines.push(`// node_* with a decode of its own wire payload into the concrete row (see INTEGRATION.md §6).`);
305
508
  lines.push(`pub trait ${handlerTraitName(comp.name)} {`);
306
509
  for (const n of comp.body) {
510
+ if ("cond" in n)
511
+ continue; // #108: a cond node has no handler method (pure Expression).
307
512
  const method = nodeMethodName(n.id);
513
+ const fnKw = fnKwFor(n.id);
308
514
  const bt = boundRustType(n, typedNodes, plan);
309
515
  if ("map" in n) {
310
516
  const m = n.map;
@@ -368,8 +574,11 @@ function emitMapElemMaterializers(comp, typedNodes, plan) {
368
574
  const elemRowGo = rawElemStructName(comp.name, n.id);
369
575
  const intoRowRef = mapElemRowRef(n, arrRef, plan);
370
576
  const inits = [];
371
- for (const f of overFields)
372
- inits.push(` ${rustFieldName(f.name)}: over.${rustFieldName(f.name)}.clone(),`);
577
+ // Copy scalars (i64/f64/bool) copy directly; String/named/arr/opt clone (clippy clone_on_copy clean).
578
+ for (const f of overFields) {
579
+ const isCopy = f.type.kind === "scalar" && (f.type.scalar === "int" || f.type.scalar === "float" || f.type.scalar === "bool");
580
+ inits.push(` ${rustFieldName(f.name)}: over.${rustFieldName(f.name)}${isCopy ? "" : ".clone()"},`);
581
+ }
373
582
  // move the into field off the concrete element row (named → struct literal; scalar/arr/opt → .val).
374
583
  const intoValue = intoRowRef.kind === "named"
375
584
  ? `${intoRowRef.name} { ${findDecl(plan, intoRowRef.name).fields.map((f) => `${rustFieldName(f.name)}: into.${rustFieldName(f.name)}`).join(", ")} }`
@@ -385,17 +594,33 @@ ${inits.join("\n")}
385
594
  }
386
595
  return parts.join("\n\n");
387
596
  }
388
- /** The Rust type of the over-element for a covered map node (parent collection element type). */
389
- function mapOverElemType(comp, node, typedNodes, plan) {
597
+ /**
598
+ * mapOverElemInfo resolve a covered map's `over` collection element TypeRef + the OWNED Rust
599
+ * expression that yields the over Vec (#108, rust twin): prior-node arr field (cell clone) or an input
600
+ * array port with elemType (`in_.<field>.clone()`). Fail-closed for an input array without elemType.
601
+ */
602
+ function mapOverElemInfo(comp, node, typedNodes, plan) {
390
603
  const m = node.map;
391
604
  const e = classifyExpr(m.over);
392
605
  if (e.kind === "ref" && e.path.length >= 1 && typedNodes.has(e.path[0])) {
393
606
  const baseRef = typedNodes.get(e.path[0]);
394
607
  const { ref } = typedFieldAccess(`${typedCell(e.path[0])}.borrow()`, baseRef, e.path.slice(1), plan);
395
- if (ref.kind === "arr")
396
- return renderTypeRef(ref.elem);
608
+ if (ref.kind === "arr") {
609
+ const fieldPath = e.path.slice(1).map((p) => `.${rustFieldName(p)}`).join("");
610
+ return { elemRef: ref.elem, overExpr: `${typedCell(e.path[0])}.borrow()${fieldPath}.clone()` };
611
+ }
612
+ }
613
+ if (e.kind === "ref" && e.path.length === 1) {
614
+ const schema = comp.inputPorts?.[e.path[0]];
615
+ const et = schema?.elemType;
616
+ if (et !== undefined)
617
+ return { elemRef: deriveTypeRef(et, plan), overExpr: `in_.${rustFieldName(e.path[0])}.clone()` };
397
618
  }
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.`);
619
+ 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).`);
620
+ }
621
+ /** The Rust type of the over-element (back-compat shim). */
622
+ function mapOverElemType(comp, node, typedNodes, plan) {
623
+ return renderTypeRef(mapOverElemInfo(comp, node, typedNodes, plan).elemRef);
399
624
  }
400
625
  // ── eligibility ─────────────────────────────────────────────────────────────────────
401
626
  function reconstructR(e) {
@@ -489,19 +714,30 @@ function outputIsNativeLowerable(comp) {
489
714
  /**
490
715
  * coveredMapNode — the #86 part 2 covered map shape (nestedBatchGet), rust twin of the go predicate.
491
716
  */
492
- function coveredMapNode(n, bodyIds) {
717
+ function coveredMapNode(n, bodyIds, comp) {
493
718
  if (n === null || typeof n !== "object" || !("map" in n))
494
719
  return false;
495
720
  const m = n.map;
496
- if (m.when !== undefined)
721
+ // #108 (rust twin of the go predicate): a guarded map (`when`) IS covered with NO `into` (guard+into
722
+ // is heterogeneous — fail-closed). over element type must be statically resolvable: prior-node arr
723
+ // field, or an input array port carrying `elemType` (BC does not infer — consumer-interface C3).
724
+ if (m.when !== undefined && m.into !== undefined)
497
725
  return false;
498
726
  if (m.policy !== undefined && m.policy !== "fail")
499
727
  return false;
500
728
  if (m.relationKind !== undefined && m.relationKind !== "single" && m.relationKind !== "connection")
501
729
  return false;
502
730
  const overE = classifyExpr(m.over);
503
- if (overE.kind !== "ref" || !bodyIds.has(overE.path[0]))
731
+ if (overE.kind !== "ref")
504
732
  return false;
733
+ const head = overE.path[0];
734
+ if (!bodyIds.has(head)) {
735
+ if (overE.path.length !== 1)
736
+ return false;
737
+ const schema = comp.inputPorts?.[head];
738
+ if (schema === undefined || schema.elemType === undefined)
739
+ return false;
740
+ }
505
741
  const ports = m.ports;
506
742
  for (const p of Object.keys(ports))
507
743
  if (!portIsStatic(ports[p]))
@@ -533,11 +769,16 @@ function coverageRejectReason(comp) {
533
769
  }
534
770
  const bodyIds = new Set(comp.body.map((b) => b.id));
535
771
  for (const n of comp.body) {
536
- if ("cond" in n)
537
- return `node '${n.id}' is a cond branch (not yet native-covered)`;
772
+ if ("cond" in n) {
773
+ // #108: a cond node is native-covered (its if/then/else compile via the runtime-free
774
+ // NativeExprCompiler; an uncovered expr fails closed at emit). cond requires outType.
775
+ if (n.outType === undefined)
776
+ return `node '${n.id}' cond has no outType (required to de-box the branch join)`;
777
+ continue;
778
+ }
538
779
  if ("map" in n) {
539
- if (!coveredMapNode(n, bodyIds))
540
- return `node '${n.id}' is a non-covered map shape (guarded/over-input)`;
780
+ if (!coveredMapNode(n, bodyIds, comp))
781
+ return `node '${n.id}' is a non-covered map shape (guard+into heterogeneous, or over an input array without elemType)`;
541
782
  continue;
542
783
  }
543
784
  if (!("component" in n))
@@ -576,8 +817,8 @@ function assertTypedCoverage(comp) {
576
817
  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
818
  }
578
819
  // ── 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) {
820
+ /** Rust type for an input port's declared type (scalar → concrete; array+elemType → Vec<ElemT>; else Value). */
821
+ function inputPortRustType(schema, plan) {
581
822
  switch (schema?.type) {
582
823
  case "string":
583
824
  return "String";
@@ -587,6 +828,14 @@ function inputPortRustType(schema) {
587
828
  return "f64";
588
829
  case "bool":
589
830
  return "bool";
831
+ case "array":
832
+ case "arr": {
833
+ // #108: an input ARRAY port with a declared elemType lowers to a native Vec<ElemT>.
834
+ const et = schema.elemType;
835
+ if (et !== undefined && plan !== undefined)
836
+ return `Vec<${renderTypeRef(deriveTypeRef(et, plan))}>`;
837
+ return "Value";
838
+ }
590
839
  default:
591
840
  return "Value";
592
841
  }
@@ -608,14 +857,14 @@ function inputScalarKind(schema) {
608
857
  }
609
858
  /** emitInStruct — the concrete input struct declaration for a covered component (fields = inputPorts).
610
859
  * Empty inputPorts => empty struct. Derives Default so a consumer/test can build it field-by-field. */
611
- function emitInStruct(comp) {
860
+ function emitInStruct(comp, plan) {
612
861
  const name = inStructName(comp.name);
613
862
  const ports = Object.keys(comp.inputPorts ?? {});
614
863
  if (ports.length === 0) {
615
864
  return `// ${name} — the CONCRETE input for '${comp.name}' (no input ports).\n#[derive(Default)]\npub struct ${name};`;
616
865
  }
617
866
  const fields = ports
618
- .map((p) => ` pub ${rustFieldName(p)}: ${inputPortRustType(comp.inputPorts[p])}, // ${JSON.stringify(p)}`)
867
+ .map((p) => ` pub ${rustFieldName(p)}: ${inputPortRustType(comp.inputPorts[p], plan)}, // ${JSON.stringify(p)}`)
619
868
  .join("\n");
620
869
  return `// ${name} — the CONCRETE input for '${comp.name}' (fields = inputPorts; typed, consumer-built —
621
870
  // NO generic Value slice, NO per-field boxing crosses the covered read boundary).
@@ -765,18 +1014,21 @@ function runnerResolveRef(priorNodeAt, atPos, typedNodes, plan, inputPorts) {
765
1014
  function rustScalar(s) {
766
1015
  return s === "string" ? "String" : s === "int" ? "i64" : s === "float" ? "f64" : "bool";
767
1016
  }
768
- function emitNativeRawRunner(comp, plan, isAsync = false) {
1017
+ function emitNativeRawRunner(comp, plan, ap) {
769
1018
  const ep = execPlan(comp);
770
1019
  const order = ep.order;
771
1020
  const hasConcurrency = ep.parallelStageOf.size > 0;
772
1021
  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.
1022
+ const isAsync = ap.runnerIsAsync;
1023
+ // bc#97: the async runner awaits each async handler at its call site (derived per-node). A real-
1024
+ // concurrency stage (bc#87) dispatches handlers from scoped std threads that mechanism cannot host
1025
+ // `.await` (a std thread is not an async task). Fail closed LOUDLY (rather than emit code that won't
1026
+ // compile) iff the runner is async AND has a concurrency stage. NOTE: with per-node granularity a
1027
+ // consumer CAN keep a concurrency-stage component sync (declare only its OTHER terminals async) — only
1028
+ // a component that both concurrency-stages AND awaits is rejected. Async + concurrent-stage (issuing
1029
+ // the stage's futures with join!) is a documented follow-up.
778
1030
  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.`);
1031
+ 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
1032
  }
781
1033
  const typedNodes = new Map();
782
1034
  for (const n of comp.body)
@@ -797,39 +1049,44 @@ function emitNativeRawRunner(comp, plan, isAsync = false) {
797
1049
  };
798
1050
  const outRef = comp.outputType !== undefined ? deriveTypeRef(comp.outputType, plan) : undefined;
799
1051
  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.
1052
+ // Does any expression read an input param? If not, `in_` is unused — bind `_in_` to stay clippy-clean.
1053
+ // #108: this must recurse ALL expression operators (cond if/then/else, map when + ports, arith/compare/…),
1054
+ // not just ref/concat — a cond `if:{gt:[{add:[{ref:["x"]}…]}]}` reads `in_.x` through nested operators.
801
1055
  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;
1056
+ // walk any Expression IR node; `bound` = names bound in this scope (a map $as) that are NOT input.
1057
+ const walk = (node, atPos, bound) => {
1058
+ if (node === null || typeof node === "boolean" || typeof node === "string" || typeof node === "number")
1059
+ return false;
1060
+ if (Array.isArray(node))
1061
+ return node.some((el) => walk(el, atPos, bound));
1062
+ const rec = node;
1063
+ const keys = Object.keys(rec);
1064
+ if (keys.length === 1 && (keys[0] === "ref" || keys[0] === "refOpt")) {
1065
+ const path = rec[keys[0]];
1066
+ const head = path[0];
1067
+ return !bound.has(head) && !priorNodeAt(head, atPos);
1068
+ }
1069
+ // any other single-key operator (obj/arr/concat/add/gt/cond/…): recurse into every child value.
1070
+ return keys.some((k) => walk(rec[k], atPos, bound));
812
1071
  };
813
1072
  return [...order.keys()].some((k) => {
814
1073
  const nd = comp.body[order[k]];
815
1074
  if ("map" in nd) {
816
1075
  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));
1076
+ const bound = new Set([m.as]);
1077
+ // the map's OVER is evaluated in the outer scope (may read input); ports + when in element scope.
1078
+ if (walk(m.over, k, new Set()))
1079
+ return true;
1080
+ if (m.when !== undefined && walk(m.when, k, bound))
1081
+ return true;
1082
+ return Object.values(m.ports).some((pn) => walk(pn, k, bound));
1083
+ }
1084
+ if ("cond" in nd) {
1085
+ const c = nd.cond;
1086
+ return walk(c.if, k, new Set()) || walk(c.then, k, new Set()) || walk(c.else, k, new Set());
830
1087
  }
831
1088
  const cr = nd;
832
- return Object.values(cr.ports).some((pn) => walk(pn, k));
1089
+ return Object.values(cr.ports).some((pn) => walk(pn, k, new Set()));
833
1090
  });
834
1091
  })();
835
1092
  const inParam = readsInput ? "in_" : "_in_";
@@ -853,8 +1110,10 @@ function emitNativeRawRunner(comp, plan, isAsync = false) {
853
1110
  // so the runner is itself `async fn` and returns a future the (already-async) consumer awaits. Sync
854
1111
  // default emits `pub fn` exactly as before (byte-identical).
855
1112
  const fnKw = isAsync ? "pub async fn" : "pub fn";
1113
+ // #108: a component whose body is ALL cond nodes dispatches NO handler — bind `_handlers` (clippy-clean).
1114
+ const dispatchesHandler = comp.body.some((n) => "map" in n || "component" in n);
856
1115
  lines.push(`${fnKw} run_native_raw_struct_${fn}${bound}(`);
857
- lines.push(` handlers: &H,`);
1116
+ lines.push(` ${dispatchesHandler ? "handlers" : "_handlers"}: &H,`);
858
1117
  lines.push(` ${inParam}: ${inStructName(comp.name)},`);
859
1118
  lines.push(`) -> Result<${outTy}, BehaviorError> {`);
860
1119
  for (const k of order.keys()) {
@@ -872,7 +1131,12 @@ function emitNativeRawRunner(comp, plan, isAsync = false) {
872
1131
  continue;
873
1132
  }
874
1133
  if ("map" in comp.body[i]) {
875
- lines.push(emitMapArm(comp, comp.body[i], k, typedNodes, plan, priorNodeAt, isAsync));
1134
+ lines.push(emitMapArm(comp, comp.body[i], k, typedNodes, plan, priorNodeAt, ap.nodeIsAsync(comp.body[i].id)));
1135
+ continue;
1136
+ }
1137
+ // #108: a cond node — pure Expression (no handler). Compile via the runtime-free NativeExprCompiler.
1138
+ if ("cond" in comp.body[i]) {
1139
+ lines.push(emitCondArm(comp, comp.body[i], k, typedNodes, plan, priorNodeAt));
876
1140
  continue;
877
1141
  }
878
1142
  const node = comp.body[i];
@@ -918,7 +1182,10 @@ function emitNativeRawRunner(comp, plan, isAsync = false) {
918
1182
  inits.push(emitPortInit(pn, expr, ty, resolveRef));
919
1183
  }
920
1184
  const boundArg = node.bindField !== undefined ? `bound_${s}` : "None";
921
- const awaitSfx = isAsync ? ".await" : "";
1185
+ // bc#97: derived per-node await this point-read's future is awaited HERE (its result-consumption
1186
+ // point) iff THIS node's terminal handler is async. A sync handler in the same async runner is called
1187
+ // directly (no `.await`). The runner being `async fn` does not force every call site to await.
1188
+ const awaitSfx = ap.nodeIsAsync(node.id) ? ".await" : "";
922
1189
  lines.push(`${indent}let ports_${s} = ${structName} { ${inits.join(", ")} };`);
923
1190
  lines.push(`${indent}let row_${s} = match handlers.${nodeMethodName(node.id)}(&ports_${s}, ${boundArg})${awaitSfx} {`);
924
1191
  lines.push(`${indent} Some(r) => r,`);
@@ -1084,26 +1351,26 @@ function emitParallelStageArm(comp, stage, atPos, concurrency, metas, typedNodes
1084
1351
  * - #93 shape 2: NON-batched map...into — dispatch ONCE PER ELEMENT (N physical requests), into per element.
1085
1352
  * - #93 shape 3: BATCHED map NO into — dispatch once, collect each element from the concrete element row.
1086
1353
  */
1087
- function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync = false) {
1354
+ function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt,
1355
+ // bc#97: whether THIS map node's terminal handler is async (derived per-node) — awaits the batched /
1356
+ // per-element handler call at its consumption point iff true. Sync map handlers are called directly.
1357
+ isAsync = false) {
1088
1358
  const m = node.map;
1089
1359
  const awaitSfx = isAsync ? ".await" : "";
1090
1360
  const batched = m.batched === true;
1091
1361
  const hasInto = m.into !== undefined;
1362
+ const hasGuard = m.when !== undefined;
1092
1363
  const s = sanitize(node.id);
1093
1364
  const structName = portsStructName(comp.name, node.id);
1094
1365
  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("") : "";
1366
+ // #108: over element type + the OWNED over-Vec expression (prior-node arr OR input array w/ elemType).
1367
+ const { elemRef: overElemRef, overExpr } = mapOverElemInfo(comp, node, typedNodes, plan);
1101
1368
  const elemMat = mapElemMaterializerName(node.id);
1102
1369
  const priorHere = (head) => priorNodeAt(head, atPos);
1103
1370
  const asName = m.as;
1104
1371
  const portNames = Object.keys(m.ports);
1105
1372
  const elemArrRef = typedNodes.get(node.id);
1106
- const elemName = elemArrRef.elem.name;
1373
+ const elemName = renderTypeRef(elemArrRef.elem);
1107
1374
  const resolveRef = (head, restPath, opt) => {
1108
1375
  if (opt)
1109
1376
  return null;
@@ -1138,37 +1405,77 @@ function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync =
1138
1405
  }
1139
1406
  if (acc.kind !== "scalar" || acc.scalar === "null")
1140
1407
  return null;
1141
- const owned = acc.scalar === "string" ? `(${expr}).clone()` : `*(${expr})`;
1408
+ // OWNED value. When restPath is EMPTY, `expr` is a bare REFERENCE (`oel_${s}` = &Elem-scalar, or
1409
+ // `cell.borrow()` = Ref<scalar>) — deref with `*` (Copy) / `.clone()` via deref (String). When a
1410
+ // `.field` was appended, `expr` is a value PLACE (auto-deref) — read it directly (`.clone()` String).
1411
+ const isBareRef = restPath.length === 0;
1412
+ let owned;
1413
+ if (acc.scalar === "string") {
1414
+ owned = isBareRef ? `(*${expr}).clone()` : `${expr}.clone()`;
1415
+ }
1416
+ else {
1417
+ owned = isBareRef ? `*${expr}` : expr;
1418
+ }
1142
1419
  return { expr: owned, scalar: rustScalar(acc.scalar) };
1143
1420
  };
1144
1421
  // build the per-element CONCRETE native ports struct. `il` = indent inside the element loop.
1145
1422
  const buildPorts = (il) => {
1146
1423
  const out = [];
1147
1424
  const inits = [];
1425
+ const asBinding = { name: asName, ref: overElemRef, plan };
1148
1426
  for (const pn of portNames) {
1149
- const ty = portFieldRustType(m.ports[pn], comp.inputPorts, `map port '${pn}' of node '${node.id}'`);
1427
+ const ty = portFieldRustType(m.ports[pn], comp.inputPorts, `map port '${pn}' of node '${node.id}'`, asBinding);
1150
1428
  inits.push(emitPortInit(pn, m.ports[pn], ty, resolveRef));
1151
1429
  }
1152
1430
  out.push(`${il}let ep_${s} = ${structName} { ${inits.join(", ")} };`);
1153
1431
  return out;
1154
1432
  };
1433
+ // #108 guard: compile `when` to a native bool over an element-scoped resolveHead ($as element value,
1434
+ // prior node, input port). On skip: `continue` (element excluded — matches run_behavior's keep-gate).
1435
+ const emitGuard = (il) => {
1436
+ if (!hasGuard)
1437
+ return [];
1438
+ const resolveHead = (head) => {
1439
+ if (head === asName)
1440
+ return { expr: `oel_${s}.clone()`, ref: overElemRef };
1441
+ if (priorHere(head))
1442
+ return { expr: `${typedCell(head)}.borrow().clone()`, ref: typedNodes.get(head) };
1443
+ return rustInputHeadRef(head, comp, plan);
1444
+ };
1445
+ const g = new NativeExprCompiler(makeRustExprBackend(resolveHead, plan)).compileBool(m.when);
1446
+ const out = [];
1447
+ for (const st of g.stmts)
1448
+ out.push(`${il}${st}`);
1449
+ // bind the keep flag to a var so clippy's nonminimal_bool doesn't rewrite `!(a > b)` (the `when`
1450
+ // expr must stay verbatim = run_behavior's `{cond:[when,true,false]}` strict-bool keep-gate).
1451
+ out.push(`${il}let __keep = ${rustStripOuterParens(g.expr)};`);
1452
+ out.push(`${il}if !__keep { continue; }`);
1453
+ return out;
1454
+ };
1155
1455
  const lines = [];
1156
1456
  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 : ""}) ──`);
1457
+ 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
1458
  let indent = " ";
1159
1459
  if (m.parent !== undefined && priorNodeAt(m.parent, atPos)) {
1160
1460
  lines.push(`${indent}if produced_${sanitize(m.parent)}.get() {`);
1161
1461
  closers.unshift(`${indent}}`);
1162
1462
  indent += " ";
1163
1463
  }
1164
- lines.push(`${indent}let over_${s} = ${typedCell(overParent)}.borrow()${overFieldPath}.clone();`);
1464
+ lines.push(`${indent}let over_${s} = ${overExpr};`);
1165
1465
  lines.push(`${indent}let mut built_${s}: Vec<${elemName}> = Vec::with_capacity(over_${s}.len());`);
1166
1466
  if (batched) {
1467
+ // build kept-element ports (+ kept over elements for into). Batched into is never guarded (rejected).
1167
1468
  lines.push(`${indent}let mut items_${s}: Vec<${structName}> = Vec::with_capacity(over_${s}.len());`);
1469
+ if (hasInto)
1470
+ lines.push(`${indent}let mut kept_${s}: Vec<${renderTypeRef(overElemRef)}> = Vec::with_capacity(over_${s}.len());`);
1168
1471
  lines.push(`${indent}for oel_${s} in over_${s}.iter() {`);
1472
+ for (const l of emitGuard(indent + " "))
1473
+ lines.push(l);
1169
1474
  for (const l of buildPorts(indent + " "))
1170
1475
  lines.push(l);
1171
1476
  lines.push(`${indent} items_${s}.push(ep_${s});`);
1477
+ if (hasInto)
1478
+ lines.push(`${indent} kept_${s}.push(oel_${s}.clone());`);
1172
1479
  lines.push(`${indent}}`);
1173
1480
  lines.push(`${indent}if !items_${s}.is_empty() {`);
1174
1481
  lines.push(`${indent} let want_${s} = items_${s}.len();`);
@@ -1183,23 +1490,28 @@ function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync =
1183
1490
  lines.push(`${indent} if row_${s}.rows.len() != want_${s} {`);
1184
1491
  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
1492
  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
1493
  if (hasInto) {
1494
+ // zip the aligned batch rows onto each KEPT over element (into materializer).
1495
+ lines.push(`${indent} let mut rows_iter_${s} = row_${s}.rows.into_iter();`);
1496
+ lines.push(`${indent} for over_el_${s} in kept_${s}.iter() {`);
1497
+ lines.push(`${indent} let er_${s} = rows_iter_${s}.next().expect("aligned batch row");`);
1190
1498
  lines.push(`${indent} built_${s}.push(${elemMat}(over_el_${s}, er_${s}));`);
1499
+ lines.push(`${indent} }`);
1191
1500
  }
1192
1501
  else {
1193
- lines.push(`${indent} let _ = &over_el_${s};`);
1502
+ // no-into: collect each element DIRECTLY from the aligned per-element row (result = kept rows).
1503
+ lines.push(`${indent} for er_${s} in row_${s}.rows.into_iter() {`);
1194
1504
  lines.push(`${indent} ${emitElemMove(`let el_${s}`, `er_${s}`, elemArrRef.elem, plan)}`);
1195
1505
  lines.push(`${indent} built_${s}.push(el_${s});`);
1506
+ lines.push(`${indent} }`);
1196
1507
  }
1197
- lines.push(`${indent} }`);
1198
1508
  lines.push(`${indent}}`);
1199
1509
  }
1200
1510
  else {
1201
- // #93 shape 2: NON-batched map...into — dispatch ONCE PER ELEMENT.
1511
+ // #93 shape 2 / #108: NON-batched map — dispatch ONCE PER KEPT ELEMENT.
1202
1512
  lines.push(`${indent}for oel_${s} in over_${s}.iter() {`);
1513
+ for (const l of emitGuard(indent + " "))
1514
+ lines.push(l);
1203
1515
  for (const l of buildPorts(indent + " "))
1204
1516
  lines.push(l);
1205
1517
  lines.push(`${indent} let er_${s} = match handlers.${nodeMethodName(node.id)}(&ep_${s}, None)${awaitSfx} {`);
@@ -1213,6 +1525,7 @@ function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync =
1213
1525
  lines.push(`${indent} built_${s}.push(${elemMat}(oel_${s}, er_${s}));`);
1214
1526
  }
1215
1527
  else {
1528
+ lines.push(`${indent} let _ = &oel_${s};`);
1216
1529
  lines.push(`${indent} ${emitElemMove(`let el_${s}`, `er_${s}`, elemArrRef.elem, plan)}`);
1217
1530
  lines.push(`${indent} built_${s}.push(el_${s});`);
1218
1531
  }
@@ -1224,6 +1537,71 @@ function emitMapArm(comp, node, atPos, typedNodes, plan, priorNodeAt, isAsync =
1224
1537
  lines.push(c);
1225
1538
  return lines.join("\n");
1226
1539
  }
1540
+ /** #108: resolve an INPUT port head for a native-expr ref (rust). Scalar → `in_.<field>` (owned: String
1541
+ * cloned, Copy as-is); array WITH elemType → `in_.<field>.clone()` of Vec<ElemT> (for `len`); else null. */
1542
+ function rustInputHeadRef(head, comp, plan) {
1543
+ const schema = comp.inputPorts?.[head];
1544
+ if (schema === undefined)
1545
+ return null;
1546
+ const sc = inputScalarKind(schema);
1547
+ if (sc !== undefined) {
1548
+ const scalarRef = { kind: "scalar", scalar: sc === "i64" ? "int" : sc === "f64" ? "float" : sc === "String" ? "string" : "bool" };
1549
+ const field = `in_.${rustFieldName(head)}`;
1550
+ return { expr: sc === "String" ? `${field}.clone()` : field, ref: scalarRef };
1551
+ }
1552
+ const et = schema.elemType;
1553
+ if ((schema.type === "array" || schema.type === "arr") && et !== undefined) {
1554
+ // no clone: the only array use inside a cond/guard expr is `len`, which BORROWS (`&in_.xs`).
1555
+ return { expr: `in_.${rustFieldName(head)}`, ref: { kind: "arr", elem: deriveTypeRef(et, plan) } };
1556
+ }
1557
+ return null;
1558
+ }
1559
+ /**
1560
+ * emitCondArm — the struct-native exec of a covered cond node (#108, rust). Mirrors run_behavior's cond
1561
+ * lower: compile `if` to a native bool, materialize the TAKEN branch to the node's outType (only the
1562
+ * taken branch evaluated), assign the node's typed cell + set produced. NO handler dispatch. Compiles
1563
+ * via the runtime-free NativeExprCompiler (byte-equal to `evaluate`); an uncovered expr fails closed.
1564
+ */
1565
+ function emitCondArm(comp, node, atPos, typedNodes, plan, priorNodeAt) {
1566
+ const c = node.cond;
1567
+ const s = sanitize(node.id);
1568
+ const nodeRef = typedNodes.get(node.id);
1569
+ const priorHere = (head) => priorNodeAt(head, atPos);
1570
+ const resolveHead = (head) => {
1571
+ if (priorHere(head))
1572
+ return { expr: `${typedCell(head)}.borrow().clone()`, ref: typedNodes.get(head) };
1573
+ return rustInputHeadRef(head, comp, plan);
1574
+ };
1575
+ const compiler = new NativeExprCompiler(makeRustExprBackend(resolveHead, plan));
1576
+ const resolveDecl = (name) => findDecl(plan, name).fields;
1577
+ const cond = compiler.compileBool(c.if);
1578
+ const thenC = compiler.compileTo(c.then, nodeRef, resolveDecl);
1579
+ const elseC = compiler.compileTo(c.else, nodeRef, resolveDecl);
1580
+ const lines = [];
1581
+ lines.push(` // ── cond '${node.id}'${c.parent ? " (parent:" + c.parent + ")" : ""} — pure Expression (no handler) ──`);
1582
+ let indent = " ";
1583
+ const closers = [];
1584
+ if (c.parent !== undefined && priorNodeAt(c.parent, atPos)) {
1585
+ lines.push(`${indent}if produced_${sanitize(c.parent)}.get() {`);
1586
+ closers.unshift(`${indent}}`);
1587
+ indent += " ";
1588
+ }
1589
+ for (const st of cond.stmts)
1590
+ lines.push(`${indent}${st}`);
1591
+ lines.push(`${indent}if ${rustStripOuterParens(cond.expr)} {`);
1592
+ for (const st of thenC.stmts)
1593
+ lines.push(`${indent} ${st}`);
1594
+ lines.push(`${indent} *${typedCell(node.id)}.borrow_mut() = ${thenC.expr};`);
1595
+ lines.push(`${indent}} else {`);
1596
+ for (const st of elseC.stmts)
1597
+ lines.push(`${indent} ${st}`);
1598
+ lines.push(`${indent} *${typedCell(node.id)}.borrow_mut() = ${elseC.expr};`);
1599
+ lines.push(`${indent}}`);
1600
+ lines.push(`${indent}produced_${s}.set(true);`);
1601
+ for (const cl of closers)
1602
+ lines.push(cl);
1603
+ return lines.join("\n");
1604
+ }
1227
1605
  /** emitElemMove — move a concrete element row (RawElemNR, error already handled) into a fresh `let` local
1228
1606
  * of the element outType. Named → struct literal from moved fields; scalar/arr/opt → .val. */
1229
1607
  function emitElemMove(letBind, rowExpr, ref, plan) {
@@ -1306,8 +1684,11 @@ fn unknown_component(component: &str) -> BehaviorError {
1306
1684
  }`;
1307
1685
  // ── module assembly ──────────────────────────────────────────────────────────────────
1308
1686
  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";
1687
+ // bc#97 (increment 2): the SSoT language-agnostic propagation pass derives, PER COMPONENT + PER NODE,
1688
+ // which handler-call boundaries must be awaited (bottom-up over the declared async terminal handlers).
1689
+ // rust CONSUMES this annotation (async fn + .await where derived); go ignores it. per-node granularity
1690
+ // is a strict generalization of increment-1's uniform ioModel:'async' (= every terminal async).
1691
+ const asyncPlans = buildAsyncPlans(ctx.ir, ctx.ioModel);
1311
1692
  for (const c of ctx.ir.components)
1312
1693
  assertTypedCoverage(c);
1313
1694
  const native = ctx.ir.components.filter(isNativeRaw);
@@ -1342,6 +1723,8 @@ ${emitStructSurface([])}
1342
1723
  .replace(/^( {4})([a-z_][A-Za-z0-9_]*: )/gm, "$1pub $2")
1343
1724
  .replace(/^( {4})(r#[a-z_][A-Za-z0-9_]*: )/gm, "$1pub $2");
1344
1725
  const defaults = emitDefaults(plan);
1726
+ // #108: reset the native-expr helper-usage accumulator (set by the cond/guard compiler).
1727
+ rustExprUsed.helpers = false;
1345
1728
  const structs = [];
1346
1729
  const rowStructs = [];
1347
1730
  const handlerTraits = [];
@@ -1349,21 +1732,26 @@ ${emitStructSurface([])}
1349
1732
  const elemMaterializers = [];
1350
1733
  for (const c of native) {
1351
1734
  const typedNodes = new Map();
1735
+ // build the full typed-node map FIRST so a map's over-element resolution can see every node.
1736
+ for (const n of c.body)
1737
+ typedNodes.set(n.id, deriveTypeRef(n.outType, plan));
1352
1738
  for (const n of c.body) {
1353
1739
  if ("map" in n)
1354
- structs.push(emitMapPortsStructs(c, n));
1740
+ structs.push(emitMapPortsStructs(c, n, typedNodes, plan));
1741
+ else if ("cond" in n) {
1742
+ // #108: a cond node has NO ports struct (pure Expression — no handler dispatch).
1743
+ }
1355
1744
  else
1356
1745
  structs.push(emitPortsStruct(c, n));
1357
- typedNodes.set(n.id, deriveTypeRef(n.outType, plan));
1358
1746
  }
1359
1747
  rowStructs.push(emitRawRowStructs(c, typedNodes, plan));
1360
- handlerTraits.push(emitHandlerTrait(c, typedNodes, plan, isAsync));
1361
- inStructs.push(emitInStruct(c));
1748
+ handlerTraits.push(emitHandlerTrait(c, typedNodes, plan, asyncPlans.get(c.name)));
1749
+ inStructs.push(emitInStruct(c, plan));
1362
1750
  const mm = emitMapElemMaterializers(c, typedNodes, plan);
1363
1751
  if (mm)
1364
1752
  elemMaterializers.push(mm);
1365
1753
  }
1366
- const runners = native.map((c) => emitNativeRawRunner(c, plan, isAsync)).join("\n\n");
1754
+ const runners = native.map((c) => emitNativeRawRunner(c, plan, asyncPlans.get(c.name))).join("\n\n");
1367
1755
  const structSurface = emitStructSurface(native);
1368
1756
  // (nonNative.length === 0 is guaranteed here — the fail-closed guard above threw otherwise.)
1369
1757
  const banner = `#![allow(dead_code, unused_imports, non_snake_case)]
@@ -1389,8 +1777,15 @@ use std::cell::RefCell;`;
1389
1777
  decls || undefined,
1390
1778
  defaults || undefined,
1391
1779
  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")}`,
1780
+ rustExprUsed.helpers
1781
+ ? `// #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()}`
1782
+ : undefined,
1783
+ structs.some((r) => r.trim().length > 0)
1784
+ ? `// 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")}`
1785
+ : undefined,
1786
+ rowStructs.some((r) => r.trim().length > 0)
1787
+ ? `// CONCRETE per-node row structs (typed fields = the node's outType; + error signal).\n${rowStructs.filter((r) => r.trim().length > 0).join("\n\n")}`
1788
+ : undefined,
1394
1789
  `// CONCRETE per-component input structs (fields = inputPorts).\n${inStructs.join("\n\n")}`,
1395
1790
  `// CONCRETE per-component handler traits (one node_* method per node — native ports IN, row OUT).\n${handlerTraits.join("\n\n")}`,
1396
1791
  elemMaterializers.length
@@ -1410,13 +1805,18 @@ use std::cell::RefCell;`;
1410
1805
  * Value for the equivalence pin. Lives OUTSIDE the covered module so the module stays literal whole-file
1411
1806
  * zero (the ser_* / decode helpers are test glue, never on the consumer's read hot path).
1412
1807
  */
1413
- export function rustTypedNativeObserve(ir) {
1808
+ export function rustTypedNativeObserve(ir, ioModel = "sync") {
1414
1809
  const components = ir.components;
1415
1810
  const native = components.filter(isNativeRaw);
1416
1811
  const plan = buildTypePlan(ir);
1417
1812
  const needSync = native.some((c) => concurrencyPlan(c) !== null);
1418
1813
  const serializers = emitSerializers(plan);
1419
1814
  const marshallers = emitMarshallers(plan);
1815
+ // bc#97: the observe glue must MATCH the covered module's per-node async-ness — the scripted handler's
1816
+ // node_* methods are `async fn` exactly where the trait's are, and observe_native_raw awaits an async
1817
+ // runner. Built from the SAME SSoT propagation pass the covered emitter used, so glue never drifts.
1818
+ const asyncPlans = buildAsyncPlans(ir, ioModel);
1819
+ const anyAsyncRunner = native.some((c) => asyncPlans.get(c.name).runnerIsAsync);
1420
1820
  // ── per-component input decoders: generic &[(String,Value)] -> concrete InNR_<comp> (test glue). ──
1421
1821
  const inDecoders = native.map((c) => emitInDecoder(c, plan)).join("\n\n");
1422
1822
  // ── concrete scripted handler: implements every HandlerNR<comp> trait by decoding a scripted Value
@@ -1425,12 +1825,18 @@ export function rustTypedNativeObserve(ir) {
1425
1825
  const emittedDecode = new Set();
1426
1826
  const traitImpls = [];
1427
1827
  for (const c of native) {
1828
+ const ap = asyncPlans.get(c.name);
1829
+ // the scripted impl's node_* is `async fn` iff the trait's is (same per-node derivation).
1830
+ const fnKwFor = (nodeId) => (ap.nodeIsAsync(nodeId) ? "async fn" : "fn");
1428
1831
  const typedNodes = new Map();
1429
1832
  for (const n of c.body)
1430
1833
  typedNodes.set(n.id, deriveTypeRef(n.outType, plan));
1431
1834
  const methods = [];
1432
1835
  for (const n of c.body) {
1836
+ if ("cond" in n)
1837
+ continue; // #108: a cond node has no handler method (pure Expression) — no scripted impl.
1433
1838
  const ref = typedNodes.get(n.id);
1839
+ const fnKw = fnKwFor(n.id);
1434
1840
  // bc#90 / runtime-free: the scripted impl's `_bound` param mirrors the trait's typed produced-aware
1435
1841
  // Option<K> (NOT a boxed Value) — computed per node so the impl signature matches the trait.
1436
1842
  const bt = boundRustType(n, typedNodes, plan);
@@ -1445,7 +1851,7 @@ export function rustTypedNativeObserve(ir) {
1445
1851
  const portsT = batched ? `${portsStructName(c.name, n.id)}Batch` : portsStructName(c.name, n.id);
1446
1852
  const retT = batched ? batchT : elemT;
1447
1853
  if (batched) {
1448
- methods.push(` fn ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${batchT}> {
1854
+ methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${batchT}> {
1449
1855
  let (is_err, err, ok) = self.next(${rustStrLit(m.component)})?;
1450
1856
  if is_err {
1451
1857
  return Some(${batchT} { is_error: true, err, ..Default::default() });
@@ -1456,7 +1862,7 @@ export function rustTypedNativeObserve(ir) {
1456
1862
  }`);
1457
1863
  }
1458
1864
  else {
1459
- methods.push(` fn ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${elemT}> {
1865
+ methods.push(` ${fnKw} ${nodeMethodName(n.id)}(&self, _ports: &${portsT}, _bound: ${bt}) -> Option<${elemT}> {
1460
1866
  let (is_err, err, ok) = self.next(${rustStrLit(m.component)})?;
1461
1867
  if is_err {
1462
1868
  return Some(${elemT} { is_error: true, err, ..Default::default() });
@@ -1471,7 +1877,7 @@ export function rustTypedNativeObserve(ir) {
1471
1877
  const node = n;
1472
1878
  const rowT = rawRowStructName(c.name, node.id);
1473
1879
  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}> {
1880
+ methods.push(` ${fnKw} ${nodeMethodName(node.id)}(&self, _ports: &${portsStructName(c.name, node.id)}, _bound: ${bt}) -> Option<${rowT}> {
1475
1881
  let (is_err, err, ok) = self.next(${rustStrLit(node.component)})?;
1476
1882
  if is_err {
1477
1883
  return Some(${rowT} { is_error: true, err, ..Default::default() });
@@ -1490,7 +1896,12 @@ export function rustTypedNativeObserve(ir) {
1490
1896
  // bridge it to the bc-runtime `behavior_contracts::BehaviorError` at the observe boundary (same
1491
1897
  // code/message) so the conformance harness (main.rs) reads the stable code without the covered
1492
1898
  // 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 }`;
1899
+ // bc#97: an async runner returns a Future drive it to completion with the std-only block-on
1900
+ // (bc_block_on) so observe_native_raw keeps its SYNC external signature (the harness is unchanged).
1901
+ // The scripted async node_* return ready values, so the future needs no real executor / no runtime.
1902
+ const runnerCall = `super::run_native_raw_struct_${sanitize(c.name)}(handlers, in_)`;
1903
+ const driven = asyncPlans.get(c.name).runnerIsAsync ? `bc_block_on(${runnerCall})` : runnerCall;
1904
+ 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
1905
  })
1495
1906
  .join("\n");
1496
1907
  // the scripted handler is passed as `&ScriptedNativeRaw`; the observe entry takes `&ScriptedNativeRaw`
@@ -1562,6 +1973,30 @@ use super::*;
1562
1973
  fn to_rt_err(e: BehaviorError) -> behavior_contracts::BehaviorError {
1563
1974
  behavior_contracts::BehaviorError::new(e.code, e.message)
1564
1975
  }
1976
+ ${anyAsyncRunner
1977
+ ? `// bc#97 (TEST glue) — a minimal std-only block-on that drives an async runner Future to completion so
1978
+ // observe_native_raw stays sync-callable (the harness never changes). The covered async fn's futures are
1979
+ // ready (the scripted handler does no real I/O), so a no-op waker + spin poll resolves in one step. This
1980
+ // lives ONLY in the observe companion — the covered module itself imports zero runtime.
1981
+ fn bc_block_on<F: std::future::Future>(mut fut: F) -> F::Output {
1982
+ use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
1983
+ fn noop(_: *const ()) {}
1984
+ fn clone(_: *const ()) -> RawWaker {
1985
+ RawWaker::new(std::ptr::null(), &VTABLE)
1986
+ }
1987
+ static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
1988
+ let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
1989
+ let mut cx = Context::from_waker(&waker);
1990
+ // SAFETY: fut is owned on the stack and never moved after pinning (it is dropped at scope end).
1991
+ let mut fut = unsafe { std::pin::Pin::new_unchecked(&mut fut) };
1992
+ loop {
1993
+ match fut.as_mut().poll(&mut cx) {
1994
+ Poll::Ready(v) => return v,
1995
+ Poll::Pending => std::thread::yield_now(),
1996
+ }
1997
+ }
1998
+ }`
1999
+ : ""}
1565
2000
 
1566
2001
  ${serializers}
1567
2002
 
@@ -1601,7 +2036,6 @@ ${arms}
1601
2036
  * typed read; Value-typed → the raw Value clone). A missing key leaves Default (every covered input is
1602
2037
  * required + supplied by the corpus). Lives ONLY in the observe companion. */
1603
2038
  function emitInDecoder(comp, plan) {
1604
- void plan;
1605
2039
  const name = inStructName(comp.name);
1606
2040
  const ports = Object.keys(comp.inputPorts ?? {});
1607
2041
  const lines = [];
@@ -1617,10 +2051,26 @@ function emitInDecoder(comp, plan) {
1617
2051
  lines.push(` for (k, v) in input {`);
1618
2052
  ports.forEach((p, i) => {
1619
2053
  const field = rustFieldName(p);
1620
- const scalar = inputScalarKind(comp.inputPorts[p]);
2054
+ const schema = comp.inputPorts[p];
2055
+ const scalar = inputScalarKind(schema);
2056
+ const et = schema.elemType;
1621
2057
  const head = i === 0 ? " if" : " } else if";
1622
2058
  lines.push(`${head} k == ${rustStrLit(p)} {`);
1623
- if (scalar === undefined) {
2059
+ if ((schema?.type === "array" || schema?.type === "arr") && et !== undefined) {
2060
+ // #108: decode the boxed Value::Arr into the native Vec<ElemT> (test glue — materialize each elem).
2061
+ const elemRef = deriveTypeRef(et, plan);
2062
+ const elemTy = renderTypeRef(elemRef);
2063
+ const mat = materializeExpr("ev", elemRef, plan);
2064
+ lines.push(` match v {`);
2065
+ lines.push(` Value::Arr(a) => {`);
2066
+ lines.push(` let mut dec: Vec<${elemTy}> = Vec::with_capacity(a.len());`);
2067
+ lines.push(` for ev in a.iter() { dec.push(${mat}); }`);
2068
+ lines.push(` in_.${field} = dec;`);
2069
+ lines.push(` }`);
2070
+ lines.push(` other => return Err(marshal_type_error(${rustStrLit(`decode input ${p}: expected array, got `)}, other.type_name())),`);
2071
+ lines.push(` }`);
2072
+ }
2073
+ else if (scalar === undefined) {
1624
2074
  lines.push(` in_.${field} = v.clone();`);
1625
2075
  }
1626
2076
  else {