nmbrs_runtime/wires.rs
1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! `WireSource` — narrow read trait for op-template name resolution.
5//!
6//! SRD-68 §"The narrow trait" specifies the wall between adapter
7//! code and `crate::scope_kernel::ScopeKernel` internals: a dispenser
8//! at cycle time accesses its bound Polydat context only through this
9//! trait's `get` (value lookup by name) and `names` (declared-name
10//! iteration for diagnostics). No `program()`, no `state()`, no
11//! `scope_coordinates()` — adapter code is sealed off from kernel
12//! mechanics.
13//!
14//! Two implementations ship here:
15//! - `ScopeKernel` itself, via the kernel's existing `lookup` chain
16//! (input slots, outputs, inherited scope state). Single
17//! resolution surface — name resolves where SRD-67 places it,
18//! no fallback (SRD-68 invariant I-1).
19//! - `NullWireSource`, a unit type that returns `None` for every
20//! name. Used as the default value in `ExecCtx::new` during the
21//! SRD-68 migration so call sites that don't yet have a kernel
22//! handle don't break — adapters opt in via
23//! `ExecCtx::with_wires` once they own a kernel reference.
24//!
25//! See `docs/SRD/68_dispenser_owned_polydat_context.md`.
26
27use std::sync::OnceLock;
28
29use crate::scope_kernel::ScopeKernel;
30use polydat::ast::{PortType, Value};
31use polydat::kernel::WriteError;
32
33/// Cached `NMBRS_DIRTY_DEBUG` flag. Per-cycle env reads cost ~30%
34/// of CPU on single-fiber benches; the OnceLock makes the gate
35/// a single atomic load. See the matching helper in
36/// `polydat::kernel::engines` for the same rationale.
37fn nmbrs_dirty_debug_enabled() -> bool {
38 static FLAG: OnceLock<bool> = OnceLock::new();
39 *FLAG.get_or_init(|| std::env::var("NMBRS_DIRTY_DEBUG").is_ok())
40}
41
42/// Result of a [`WireSource::write`] call.
43///
44/// `Stored` means the value landed on a real input slot in the
45/// underlying kernel and is visible to subsequent pulls.
46///
47/// `NoSlot` means the kernel's program has no input slot named
48/// `name`. Under [`KernelOptLevel::Release`] this is the
49/// closure-binding economy's DCE signal — nothing in the body
50/// referenced the name, so the value is silently dropped. Under
51/// [`KernelOptLevel::Diagnostic`] every magic extern and
52/// result-binding LHS gets a slot, so `NoSlot` indicates a
53/// genuinely-unknown name (caller error or a name outside the
54/// kernel's scope).
55///
56/// Callers don't need to branch on the outcome to maintain
57/// correctness — the kernel either has a slot for the name or it
58/// doesn't, and `NoSlot` is the same as "the workload doesn't
59/// reference this value." Diagnostics (`debug_nodes_enabled()`,
60/// audit log) can log the outcome to make DCE visible.
61///
62/// [`KernelOptLevel::Release`]: polydat::kernel::KernelOptLevel::Release
63/// [`KernelOptLevel::Diagnostic`]: polydat::kernel::KernelOptLevel::Diagnostic
64#[derive(Clone, Debug, PartialEq, Eq)]
65pub enum WriteOutcome {
66 /// Value written to a real input slot.
67 Stored,
68 /// No input slot named `name` in this kernel's program.
69 NoSlot,
70 /// The slot exists but the value is not of the slot's declared
71 /// `PortType` and polydat's conversion catalog has no conversion
72 /// to it (or the conversion failed). The typed-write contract
73 /// rejects this rather than silently corrupting downstream reads;
74 /// the `reason` field carries the polydat-side diagnostic for
75 /// surfacing to the operator.
76 TypeMismatch { reason: String },
77 /// The slot is a coordinate. Coordinates advance with the cycle
78 /// (`set_inputs`), never by a named write, so the kernel refuses
79 /// the write to keep the coordinate prefix in step with the cycle
80 /// a pull is about to read. `reason` is polydat's diagnostic.
81 Coordinate { reason: String },
82 /// The slot holds a `const`'s captured value, which only kernel
83 /// initialization writes: a const changes when the inputs it reads
84 /// change and the kernel is re-initialized, never by a write to its
85 /// own slot. `reason` is polydat's diagnostic.
86 Const { reason: String },
87}
88
89/// Why a [`write_input`] did not land.
90#[derive(Debug)]
91pub enum HostWriteError {
92 /// The kernel refused the typed write.
93 Write(WriteError),
94 /// The value could not be converted to the slot's declared type.
95 Convert {
96 /// The input written.
97 slot: String,
98 /// polydat's reason.
99 error: polydat::convert::ConvertError,
100 },
101}
102
103impl std::fmt::Display for HostWriteError {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 match self {
106 HostWriteError::Write(e) => write!(f, "{e}"),
107 HostWriteError::Convert { slot, error } => write!(f, "'{slot}': {error}"),
108 }
109 }
110}
111
112impl From<HostWriteError> for WriteOutcome {
113 fn from(e: HostWriteError) -> Self {
114 let reason = e.to_string();
115 match e {
116 HostWriteError::Write(WriteError::UnknownWire { .. }) => WriteOutcome::NoSlot,
117 HostWriteError::Write(WriteError::CoordinateSlot { .. }) => {
118 WriteOutcome::Coordinate { reason }
119 }
120 HostWriteError::Write(WriteError::ConstSlot { .. }) => WriteOutcome::Const { reason },
121 HostWriteError::Write(WriteError::TypeMismatch { .. })
122 | HostWriteError::Write(WriteError::FromParent { .. })
123 | HostWriteError::Convert { .. } => WriteOutcome::TypeMismatch { reason },
124 }
125 }
126}
127
128/// nmbrs's one rule for writing a host value into a kernel input.
129///
130/// polydat 0.5 never converts at the write, so a value that may not be
131/// of the slot's declared type is converted first through
132/// `polydat::convert::to_port`, the catalog a converter node uses, and
133/// then written with the typed `set_input_at`. A value already of the
134/// slot's type passes through unconverted. `index` is `name`'s position
135/// in the kernel's inputs; the name is taken too so the per-cycle
136/// callers, which already hold it, pay no index-to-name lookup.
137pub fn write_input(
138 kernel: &mut dyn polydat::Kernel,
139 index: usize,
140 name: &str,
141 value: Value,
142) -> Result<(), HostWriteError> {
143 let value = match kernel.input_port_type(name) {
144 Some(port) => {
145 polydat::convert::to_port(value, port).map_err(|error| HostWriteError::Convert {
146 slot: name.to_string(),
147 error,
148 })?
149 }
150 None => value,
151 };
152 kernel
153 .set_input_at(index, value)
154 .map_err(HostWriteError::Write)
155}
156
157/// Cycle-time read surface a dispenser uses to resolve names from
158/// its bound Polydat context.
159///
160/// `get(name)` returns the current value of the named wire in the
161/// dispenser's kernel. A `None` return indicates the name isn't
162/// declared in this scope — callers MUST treat that as a
163/// resolution error, not a fallback opportunity (SRD-68 I-1).
164///
165/// `names` enumerates declared names for validators and
166/// `describe_resolved`-style introspection. Not for hot-path cycle
167/// reads; cycle reads use `get`.
168pub trait WireSource: Send + Sync {
169 /// Look up `name` in this kernel's scope. Returns the current
170 /// value (cloned, owned) or `None` when the name is not
171 /// declared here. Callers do not retry against another kernel
172 /// — a `None` is the resolution result, full stop.
173 fn get(&self, name: &str) -> Option<Value>;
174
175 /// Iterate declared names. Order is implementation-defined.
176 /// Used by validators and diagnostic renderers.
177 fn names(&self) -> Box<dyn Iterator<Item = String> + '_>;
178
179 /// Write `value` into the named input slot of this kernel.
180 /// Returns [`WriteOutcome::Stored`] when the slot exists and
181 /// the value was written, [`WriteOutcome::NoSlot`] when the
182 /// kernel's program has no input slot named `name`.
183 ///
184 /// This is the canonical cycle-time capture path used by
185 /// `ResultDispenser` and `TraversingDispenser` after their
186 /// inner stack returns. Writes land on the kernel directly —
187 /// no HashMap intermediary, no post-stack pump in the
188 /// activity loop. Subsequent `wires.get` calls (e.g. from a
189 /// later wrapper like `MetricsDispenser`) see fresh values.
190 ///
191 /// Default impl returns `NoSlot` — appropriate for read-only
192 /// implementations like `NullWireSource` and the bare
193 /// `&ScopeKernel` baseline (which has no `&mut` handle to mutate
194 /// state). `CycleWires` overrides with the real write path
195 /// through the wrapped kernel's `set_input`.
196 fn write(&self, _name: &str, _value: Value) -> WriteOutcome {
197 WriteOutcome::NoSlot
198 }
199
200 /// Reset the named input slot to its DECLARED initial value —
201 /// the author's identity element for that wire. The capture
202 /// layer uses this for min/max-of-nothing (SRD-93-era fold
203 /// totality): an empty measurement restores the wire to its
204 /// declared unit rather than parking `Value::None` on a typed
205 /// slot, where every downstream consumer would have to be
206 /// None-aware. Default impl returns `NoSlot` (read-only
207 /// sources have nothing to reset).
208 fn reset(&self, _name: &str) -> WriteOutcome {
209 WriteOutcome::NoSlot
210 }
211
212 /// Advance the underlying kernel state to coordinate `coord`
213 /// and invalidate any memoized pulls so subsequent `get`
214 /// calls produce values for this coord.
215 ///
216 /// Used by batch dispensers per the SRD-68 invariant:
217 /// > "Within the batch view, each iteration of the batch is
218 /// > considered another pull, just as if the operation
219 /// > inside the batch were separate. It is simply an
220 /// > iteration container."
221 ///
222 /// The default impl is a no-op — `NullWireSource` and
223 /// adapters that don't drive batch iteration leave it alone.
224 /// `CycleWires` overrides to mutate the wrapped kernel's
225 /// coord input. Calling `advance` on a `WireSource` that
226 /// has no kernel handle (e.g. `NullWireSource`) is a no-op,
227 /// not an error — the dispenser's own `wires.get` calls
228 /// will resolve correctly against whatever read surface the
229 /// adapter has bound.
230 fn advance(&self, _coord: u64) {}
231}
232
233/// Read-only `WireSource` over a kernel of any engine: the names a scope
234/// lookup answers (`KernelLookup` — a const's value, an input, a folded
235/// value), as the `ScopeKernel` impl below does for the interpreter.
236/// Computed outputs that need a pull are not covered; `CycleWires` is
237/// the surface that pulls. What an adapter's canonical kernel offers a
238/// wrap-time reader.
239pub struct KernelWires<'a>(pub &'a dyn polydat::Kernel);
240
241impl WireSource for KernelWires<'_> {
242 fn get(&self, name: &str) -> Option<Value> {
243 use polydat::kernel::interp::Lookup as _;
244 polydat::kernel::interp::KernelLookup::new(self.0).lookup(name)
245 }
246
247 fn names(&self) -> Box<dyn Iterator<Item = String> + '_> {
248 let outputs = self.0.output_names();
249 let inputs_only: Vec<String> = self
250 .0
251 .input_names()
252 .into_iter()
253 .filter(|n| !outputs.contains(n))
254 .collect();
255 Box::new(outputs.into_iter().chain(inputs_only))
256 }
257}
258
259/// `WireSource` over `&ScopeKernel` — covers names that the kernel's
260/// `lookup` API already exposes (inputs, scope-init constants,
261/// shared-cell-backed values). Computed outputs that require a
262/// memoizing `pull(&mut state, …)` evaluation are NOT covered here
263/// and return `None` from `get`; the SRD-68 Push 2 work introduces a
264/// richer `WireSource` impl that owns the per-fiber kernel handle
265/// with interior mutability and can pull outputs at cycle time.
266///
267/// For Push 1 this `&ScopeKernel` impl is the additive baseline: every
268/// existing call site that gets handed a `NullWireSource` continues
269/// working unchanged, and code that wants kernel-side reads via the
270/// trait can use it for the names `lookup` already answers.
271impl WireSource for ScopeKernel {
272 fn get(&self, name: &str) -> Option<Value> {
273 self.lookup(name)
274 }
275
276 fn names(&self) -> Box<dyn Iterator<Item = String> + '_> {
277 // Outputs come first (the canonical declared-name surface),
278 // followed by input names not also published as outputs.
279 // Ordering is stable for a given program; consumers should
280 // not assume a particular order between the two groups.
281 let program = self.program();
282 let outputs: Vec<String> = program
283 .output_names()
284 .iter()
285 .map(|s| s.to_string())
286 .collect();
287 let inputs_only: Vec<String> = program
288 .input_names()
289 .iter()
290 .filter(|n| !outputs.contains(n))
291 .cloned()
292 .collect();
293 Box::new(outputs.into_iter().chain(inputs_only))
294 }
295}
296
297/// A kernel that carries the interpreter program its names resolve on:
298/// a scope kernel, or an interpreter kernel.
299pub trait ProgramKernel {
300 /// The running kernel and its program.
301 fn split_program(
302 &mut self,
303 ) -> (
304 &mut dyn polydat::Kernel,
305 std::sync::Arc<polydat::kernel::PolydatProgram>,
306 );
307}
308
309impl ProgramKernel for ScopeKernel {
310 fn split_program(
311 &mut self,
312 ) -> (
313 &mut dyn polydat::Kernel,
314 std::sync::Arc<polydat::kernel::PolydatProgram>,
315 ) {
316 let program = self.program().clone();
317 (self.kernel_mut(), program)
318 }
319}
320
321impl ProgramKernel for polydat::kernel::PolydatKernel {
322 fn split_program(
323 &mut self,
324 ) -> (
325 &mut dyn polydat::Kernel,
326 std::sync::Arc<polydat::kernel::PolydatProgram>,
327 ) {
328 let program = self.program().clone();
329 (self, program)
330 }
331}
332
333/// `WireSource` over a per-fiber kernel handle that supports the
334/// full read surface — inputs, scope-init constants, AND computed
335/// outputs (which need a memoizing `pull(&mut state, …)` to fire
336/// the eval cone). Wraps a `&mut ScopeKernel` in a `Mutex` so the
337/// trait stays `&self`-callable (and `Sync`) while still permitting
338/// pull's `&mut` requirement.
339///
340/// The `Mutex` is uncontended in practice: per-fiber-per-cycle
341/// dispatch is single-threaded by construction (the fiber owns
342/// the kernel slot exclusively for the duration of the cycle),
343/// so `lock()` always succeeds without spinning. Using `Mutex`
344/// rather than `RefCell` is a `Sync` requirement — async futures
345/// returned by `OpDispenser::execute` are Send, which means
346/// `&ExecCtx` (and through it `&dyn WireSource`) must be Send,
347/// which means `dyn WireSource` must be Sync.
348///
349/// Cells held here have the lifetime of one cycle dispatch —
350/// constructed at cycle entry from the firing dispenser's
351/// per-fiber kernel slot, dropped after the dispenser returns.
352///
353/// Resolution order on `get(name)`:
354/// 1. Output (memoizing pull through the eval cone).
355/// 2. Input slot (cell-aware read).
356/// 3. Scope-init constant.
357///
358/// `None` when the name doesn't appear on this kernel — callers
359/// surface as an unresolved-bindpoint error per SRD-68 I-1.
360///
361/// The kernel may be on any engine. Names resolve on `program`, the
362/// interpreter program the kernel runs or was imaged from, whose
363/// indices it shares (`fiber_engine::agrees`); the kernel is then
364/// driven by index, so a compiled kernel pays no name lookup.
365///
366/// One value per name per cycle: an output read is kept for the life of
367/// these wires — one cycle's dispatch — so an op that references a
368/// `volatile` binding twice renders one reading, although polydat
369/// re-evaluates a volatile step on every pull. A write, a reset, or an
370/// advance forgets every kept reading, so a read after one sees the
371/// kernel as it is now.
372pub struct CycleWires<'a> {
373 kernel: std::sync::Mutex<&'a mut dyn polydat::Kernel>,
374 /// Where names resolve: the interpreter program the kernel shares
375 /// indices with, or `None` to ask the kernel itself.
376 program: Option<std::sync::Arc<polydat::kernel::PolydatProgram>>,
377 readings: std::sync::Mutex<std::collections::HashMap<usize, Value>>,
378}
379
380impl<'a> CycleWires<'a> {
381 /// Wrap a scope kernel (or an interpreter kernel) for cycle-time
382 /// reads; names resolve on its program. The caller holds the only
383 /// outstanding borrow on the kernel for the duration of this cycle.
384 pub fn new<K: ProgramKernel + ?Sized>(kernel: &'a mut K) -> Self {
385 let (kernel, program) = kernel.split_program();
386 Self::over(kernel, program)
387 }
388
389 /// Wrap a kernel of any engine whose inputs and outputs are
390 /// `program`'s, in the same order.
391 pub fn over(
392 kernel: &'a mut dyn polydat::Kernel,
393 program: std::sync::Arc<polydat::kernel::PolydatProgram>,
394 ) -> Self {
395 Self {
396 kernel: std::sync::Mutex::new(kernel),
397 program: Some(program),
398 readings: std::sync::Mutex::new(std::collections::HashMap::new()),
399 }
400 }
401
402 /// Wrap a kernel of any engine, resolving names on the kernel
403 /// itself. For a holder with no interpreter program in hand — an
404 /// adapter probing a fork of its `Arc<dyn Kernel>` parent; a
405 /// compiled kernel's name lookup is a scan, so a per-cycle path
406 /// uses [`Self::over`].
407 pub fn of(kernel: &'a mut dyn polydat::Kernel) -> Self {
408 Self {
409 kernel: std::sync::Mutex::new(kernel),
410 program: None,
411 readings: std::sync::Mutex::new(std::collections::HashMap::new()),
412 }
413 }
414
415 fn output_index(&self, k: &dyn polydat::Kernel, name: &str) -> Option<usize> {
416 match &self.program {
417 Some(p) => p.output_index(name),
418 None => k.output_index(name),
419 }
420 }
421
422 fn input_index(&self, k: &dyn polydat::Kernel, name: &str) -> Option<usize> {
423 match &self.program {
424 Some(p) => p.find_input(name),
425 None => k.input_index(name),
426 }
427 }
428
429 /// Forget every reading kept this cycle: the kernel's inputs moved.
430 fn forget_readings(&self) {
431 self.readings
432 .lock()
433 .expect("CycleWires readings poisoned")
434 .clear();
435 }
436}
437
438impl<'a> WireSource for CycleWires<'a> {
439 fn get(&self, name: &str) -> Option<Value> {
440 let mut k = self.kernel.lock().expect("CycleWires mutex poisoned");
441 // Output pull first: a name declared by the program's
442 // bindings is an output; the pull memoizes through the
443 // eval cone. Fall through to the scope lookup for inputs
444 // and scope-init constants. No external chain composition —
445 // construction-time wiring set up every visible wire
446 // (SRD-13f).
447 if let Some(output_idx) = self.output_index(&**k, name) {
448 let mut readings = self.readings.lock().expect("CycleWires readings poisoned");
449 let v = readings
450 .entry(output_idx)
451 .or_insert_with(|| k.pull_at(output_idx))
452 .clone();
453 if nmbrs_dirty_debug_enabled() && name == "query" {
454 let s = v.to_display_string();
455 let head: String = s.chars().take(64).collect();
456 eprintln!("DIRTY: wires.get(query) OUTPUT head=\"{head}\"");
457 }
458 return Some(v);
459 }
460 let v = {
461 use polydat::kernel::interp::Lookup as _;
462 polydat::kernel::interp::KernelLookup::new(&**k).lookup(name)
463 };
464 if nmbrs_dirty_debug_enabled() && name == "query" {
465 let head = v
466 .as_ref()
467 .map(|x| x.to_display_string().chars().take(64).collect::<String>())
468 .unwrap_or_else(|| "<none>".into());
469 eprintln!("DIRTY: wires.get(query) INPUT/CONST head=\"{head}\"");
470 }
471 v
472 }
473
474 fn names(&self) -> Box<dyn Iterator<Item = String> + '_> {
475 let (outputs, inputs): (Vec<String>, Vec<String>) = match &self.program {
476 Some(p) => (
477 p.output_names().iter().map(|s| s.to_string()).collect(),
478 p.input_names(),
479 ),
480 None => {
481 let k = self.kernel.lock().expect("CycleWires mutex poisoned");
482 (k.output_names(), k.input_names())
483 }
484 };
485 let inputs_only: Vec<String> = inputs
486 .into_iter()
487 .filter(|n| !outputs.contains(n))
488 .collect();
489 Box::new(outputs.into_iter().chain(inputs_only))
490 }
491
492 fn reset(&self, name: &str) -> WriteOutcome {
493 let mut k = self.kernel.lock().expect("CycleWires mutex poisoned");
494 self.forget_readings();
495 let Some(idx) = self.input_index(&**k, name) else {
496 return WriteOutcome::NoSlot;
497 };
498 // The declared default is by construction the slot's own
499 // type, so this write cannot mismatch; the typed boundary
500 // is still used rather than poking state directly.
501 let Some(default) = k.input_default_at(idx) else {
502 return WriteOutcome::NoSlot;
503 };
504 match write_input(&mut **k, idx, name, default) {
505 Ok(()) => WriteOutcome::Stored,
506 Err(e) => e.into(),
507 }
508 }
509
510 fn write(&self, name: &str, value: Value) -> WriteOutcome {
511 let mut k = self.kernel.lock().expect("CycleWires mutex poisoned");
512 self.forget_readings();
513 let found = self.input_index(&**k, name);
514 if std::env::var("NMBRS_DEBUG_WIRES")
515 .map(|v| v == "1")
516 .unwrap_or(false)
517 {
518 let cells: Vec<String> = k.cells_in_scope().iter().map(|c| c.name.clone()).collect();
519 let slot_cell = found.map(|idx| k.input_is_cell_bound(idx));
520 eprintln!(
521 "WIRES.write name={name} value={value:?} slot_cell_bound={slot_cell:?} cells_in_scope={cells:?}"
522 );
523 }
524 // Result and capture values arrive typed by the adapter, not
525 // by the slot, so they go through the converting write.
526 let Some(idx) = found else {
527 return WriteOutcome::NoSlot;
528 };
529 match write_input(&mut **k, idx, name, value) {
530 Ok(()) => WriteOutcome::Stored,
531 Err(e) => e.into(),
532 }
533 }
534
535 fn advance(&self, coord: u64) {
536 let mut k = self.kernel.lock().expect("CycleWires mutex poisoned");
537 self.forget_readings();
538 if k.coord_count() > 0 {
539 k.set_inputs(&[coord]);
540 }
541 }
542}
543
544/// Empty `WireSource` — every `get` returns `None`, `names` is
545/// empty. Used as the default for `ExecCtx::new` so callers that
546/// don't yet have a kernel handle don't need to construct a
547/// real implementation. Migration call sites switch to
548/// `ExecCtx::with_wires` when they own a kernel reference (see
549/// SRD-68 Push 2 and beyond).
550pub struct NullWireSource;
551
552impl WireSource for NullWireSource {
553 fn get(&self, _name: &str) -> Option<Value> {
554 None
555 }
556 fn names(&self) -> Box<dyn Iterator<Item = String> + '_> {
557 Box::new(std::iter::empty())
558 }
559}
560
561/// Static `&'static dyn WireSource` for use as the default in
562/// `ExecCtx::new`. Avoids per-call allocation; the unit struct has
563/// no state to differ.
564pub static NULL_WIRES: NullWireSource = NullWireSource;
565
566/// Render `template` by substituting each `{name}` placeholder
567/// with `wires.get(name)`'s display-string form. The single
568/// resolution-via-wires entry point adapters use at cycle time
569/// per SRD-68 Push 5 — replaces the synthesis-layer
570/// `substitute_bind_points*` text-mutation pass.
571///
572/// Honors the standard placeholder escape rules established by
573/// `nmbrs_workload::bindpoints`:
574/// - `\{` / `\}` — literal brace, passes through unchanged.
575/// - `{{...}}` — inline-expression form, reserved for the
576/// `{{<polydat-expr>}}` desugar surface; passes through unchanged
577/// (compiled into bindings before reaching cycle time).
578/// - Qualifier-prefixed forms (`{bind:name}` / `{capture:name}`
579/// / `{input:name}`) — the current Push 5 contract is bare
580/// `{name}` only; qualifier-prefixed references error.
581/// - `{` followed by non-identifier text — passes through (CQL
582/// map literals like `{'class': 'SimpleStrategy'}`, JSON object
583/// literals like `{"a": 1}`, format specs like `{:5.2}`).
584///
585/// Returns `Err` with a descriptive message when a bare `{name}`
586/// reference doesn't resolve through `wires` — SRD-68 invariant
587/// I-1 forbids silent fallback. Callers (typically the cycle
588/// dispatch error path) surface this as a phase-stopping
589/// diagnostic.
590pub fn substitute_via_wires(template: &str, wires: &dyn WireSource) -> Result<String, String> {
591 // Mirror the brace-handling algorithm of
592 // `nmbrs_workload::bindpoints::extract_bind_points`: when `{` is
593 // followed by a literal-start character (`'` or `"`), emit the
594 // brace and continue scanning so nested `{name}` placeholders
595 // INSIDE CQL map / JSON object literals still get resolved.
596 // Track brace depth so a top-level `{name}` whose body itself
597 // contains balanced braces (rare) isn't truncated at the first
598 // closing brace.
599 let chars: Vec<char> = template.chars().collect();
600 let n = chars.len();
601 let mut out = String::with_capacity(template.len());
602 let mut i = 0;
603 while i < n {
604 // `\{` / `\}` — pass through, two chars.
605 if chars[i] == '\\' && i + 1 < n && (chars[i + 1] == '{' || chars[i + 1] == '}') {
606 out.push(chars[i]);
607 out.push(chars[i + 1]);
608 i += 2;
609 continue;
610 }
611 // `{{ ... }}` — inline-expression form. Reserved for the
612 // Polydat desugar surface; passes through unchanged at cycle
613 // time (compiled into bindings before reaching this
614 // path).
615 if i + 1 < n && chars[i] == '{' && chars[i + 1] == '{' {
616 let start = i;
617 let mut j = i + 2;
618 while j + 1 < n && !(chars[j] == '}' && chars[j + 1] == '}') {
619 j += 1;
620 }
621 let end = (j + 2).min(n);
622 out.extend(&chars[start..end]);
623 i = end;
624 continue;
625 }
626 if chars[i] != '{' {
627 out.push(chars[i]);
628 i += 1;
629 continue;
630 }
631 // CQL map / JSON object literal: `{` followed by
632 // (whitespace?) `'`/`"`. Emit just the `{` and
633 // continue scanning so any nested `{name}` placeholders
634 // inside still resolve. Whitespace tolerance covers
635 // multi-line CQL maps written with the opening brace
636 // on its own line (e.g. `compaction = {\n 'class':
637 // …\n}`).
638 let next_nonspace = chars[i + 1..].iter().find(|c| !c.is_whitespace()).copied();
639 if matches!(next_nonspace, Some('\'') | Some('"')) {
640 out.push('{');
641 i += 1;
642 continue;
643 }
644 // Single `{...}` form: depth-track to find the matching
645 // `}` so balanced inner braces don't truncate the body.
646 let body_start = i + 1;
647 let mut j = body_start;
648 let mut depth: u32 = 1;
649 while j < n {
650 if chars[j] == '{' {
651 depth += 1;
652 }
653 if chars[j] == '}' {
654 depth -= 1;
655 if depth == 0 {
656 break;
657 }
658 }
659 j += 1;
660 }
661 if j >= n {
662 // Unterminated — treat as literal char.
663 out.push('{');
664 i += 1;
665 continue;
666 }
667 let body: String = chars[body_start..j].iter().collect();
668 let body = body.trim();
669 let after = j + 1;
670 // Empty body — pass through.
671 if body.is_empty() {
672 out.push('{');
673 out.push('}');
674 i = after;
675 continue;
676 }
677 // Qualifier-prefixed form (`{bind:name}`, `{capture:name}`,
678 // `{input:name}`) — SRD-68 Push 5 contract is bare names
679 // only at cycle time.
680 if body.contains(':') {
681 return Err(format!(
682 "qualifier-prefixed bind point `{{{body}}}` is not supported \
683 at cycle time; only bare `{{name}}` references are answered \
684 by the dispenser's WireSource"
685 ));
686 }
687 // Bare identifiers resolve as-is. Dotted identifiers
688 // (`q.cursor.idx`) follow the field-access wire
689 // convention and resolve through their flattened
690 // spelling (`q__cursor__idx`) — same rule the DSL
691 // compiler and kernel lookup apply. Everything else
692 // (format specs `{:5.2}`, expressions `{a+b}`, etc.)
693 // passes through unchanged.
694 let wire_name: std::borrow::Cow<'_, str> = if is_bare_ident(body) {
695 std::borrow::Cow::Borrowed(body)
696 } else if is_dotted_ident(body) {
697 std::borrow::Cow::Owned(body.replace('.', "__"))
698 } else {
699 out.push('{');
700 out.push_str(body);
701 out.push('}');
702 i = after;
703 continue;
704 };
705 let body = wire_name.as_ref();
706 // Bare identifier — wires.get resolves or errors.
707 //
708 // SRD-74 Rule 3 (strict render): `Value::None` reaching the
709 // wire-protocol substitution surface is an error, NOT a
710 // silent empty string. The display-strict primitive returns
711 // `None` for `Value::None` so we can surface a clear
712 // diagnostic. The catch-all `to_display_string` legacy
713 // (which maps None to "") stays in place for log /
714 // diagnostic contexts where empty is acceptable; render
715 // sites use the strict form.
716 match wires.get(body) {
717 Some(v) => {
718 // Binary-natural type guard. A wire holding VecF32 /
719 // VecI32 / Handle / Bytes has no meaningful
720 // text-spliced form for a wire-protocol field —
721 // decimal-stringifying a 128-element f32 vector to
722 // embed in a CQL INSERT is always the wrong path
723 // (it forces format-then-reparse on every cycle and
724 // bypasses the adapter's typed-binding API). Detect
725 // here so the cost — and the design violation — are
726 // surfaced as a clear error instead of silently
727 // burned on every cycle.
728 //
729 // The escape hatch for typed values that genuinely
730 // belong in a field is the "pure-token" form (case 2
731 // in `resolve_op_fields_via_wires`): the entire
732 // field value is exactly `{wire_name}` with no
733 // surrounding text, and the typed `Value` is handed
734 // to the adapter intact.
735 if is_binary_natural(v.port_type()) {
736 return Err(format!(
737 "wire `{body}` holds {} which has no text-spliced \
738 representation suitable for a wire-protocol field. \
739 Either use the pure-token form (the entire field \
740 value is exactly `{{{body}}}` with no surrounding \
741 text) so the adapter binds the typed value via \
742 its parameter API, or split the field so the \
743 wire is bound as a typed parameter alongside a \
744 text template containing placeholders (e.g. CQL \
745 `?`).",
746 v.port_type()
747 ));
748 }
749 match v.to_display_strict() {
750 Some(s) => out.push_str(&s),
751 None => {
752 return Err(format!(
753 "unresolved bind point `{{{body}}}`: wire \
754 `{body}` resolved to `Value::None` (no value \
755 bound in the dispenser's Polydat context chain). \
756 Set a workload-param default for `{body}`, \
757 bind it via `bindings:` / `set:`, or mark \
758 the bind-point as optional once SRD-74 \
759 Rule 2 syntax lands."
760 ));
761 }
762 }
763 }
764 None => {
765 return Err(format!(
766 "unresolved bind point `{{{body}}}`: no wire named \
767 `{body}` in the dispenser's Polydat context"
768 ));
769 }
770 }
771 i = after;
772 }
773 Ok(out)
774}
775
776/// Resolve a list of op-template field entries through the generic
777/// wires API. Each entry is `(field_name, json_value_from_template)`;
778/// the helper returns name+value pairs an adapter can hand to its
779/// renderer, with no synthesis-layer involvement.
780///
781/// The four-case rule, in order of dispatch:
782///
783/// 1. **Non-string YAML scalar / list / object**: passes through as
784/// `Value::Str(json.to_string())`. Adapters that need richer
785/// typing for a JSON-shaped field can downcast through
786/// `ResolvedFields` and parse, but the default projection keeps
787/// the legacy "everything renders to a string" contract.
788///
789/// 2. **Pure-token typed-reference**: the entire string (trimmed)
790/// is exactly `{name}` where `name` is a bare identifier. The
791/// field's value becomes `wires.get(name).clone()` — typed
792/// `Value` preserved. Used by CQL prepared-param bindings,
793/// vector args, and anywhere an adapter needs the native typed
794/// value rather than its display string. An unresolved name
795/// here is a hard error (no silent fallback).
796///
797/// 3. **Text template with placeholders**: any string containing
798/// `{name}` placeholders embedded in surrounding text, or
799/// `{{ expr }}` inline-GK escapes. Renders through
800/// `substitute_via_wires` to produce a `Value::Str` with each
801/// placeholder replaced by its wire's display-string form.
802/// Inline-expression `{{ … }}` placeholders are evaluated then
803/// stringified.
804///
805/// 4. **Bare-string literal**: a string with no `{…}` markers
806/// passes through unchanged as `Value::Str(s.clone())`. This is
807/// the most common case for SQL stmts, URIs, and headers.
808///
809/// **Why not "bare-name as wire reference"?** A workload author
810/// writing `stmt: "SELECT * FROM users"` doesn't expect `users` to
811/// resolve as a Polydat wire. The pure-token form (case 2) is the
812/// explicit opt-in for typed references; bare strings stay
813/// literal. Per-adapter typed-field metadata (a future enhancement)
814/// would let specific fields opt into bare-name-as-reference; the
815/// current contract doesn't require it.
816///
817/// Returns the SRD-68 standard error message on the first
818/// unresolved bind point (single resolution surface, no fallback).
819pub fn resolve_op_fields_via_wires(
820 op_fields: &[(String, serde_json::Value)],
821 wires: &dyn WireSource,
822) -> Result<crate::adapter::ResolvedFields, String> {
823 use polydat::ast::Value;
824 let mut names = Vec::with_capacity(op_fields.len());
825 let mut values = Vec::with_capacity(op_fields.len());
826 for (key, json_value) in op_fields {
827 names.push(key.clone());
828 let serde_json::Value::String(s) = json_value else {
829 values.push(Value::Str(json_value.to_string().into()));
830 continue;
831 };
832 let trimmed = s.trim();
833 let pure_token = trimmed.starts_with('{')
834 && trimmed.ends_with('}')
835 && !trimmed.starts_with("{{")
836 && trimmed.len() >= 2
837 && trimmed[1..trimmed.len() - 1]
838 .chars()
839 .all(|c| c != '{' && c != '}');
840 if pure_token {
841 let body = trimmed[1..trimmed.len() - 1].trim();
842 let bare = match body.split_once(':') {
843 Some((_, n)) => n,
844 None => body,
845 };
846 if is_bare_ident(bare) {
847 match wires.get(bare) {
848 Some(v) => {
849 values.push(v);
850 continue;
851 }
852 None => {
853 return Err(format!(
854 "unresolved bind point `{{{bare}}}` in field '{key}': \
855 no wire named `{bare}` in the dispenser's Polydat context"
856 ));
857 }
858 }
859 }
860 }
861 let rendered = substitute_via_wires(s, wires).map_err(|e| format!("field '{key}': {e}"))?;
862 values.push(Value::Str(rendered.into()));
863 }
864 Ok(crate::adapter::ResolvedFields::new(names, values))
865}
866
867/// Wire types that have no meaningful text-spliced representation
868/// in a wire-protocol field. Embedding `{wire}` for one of these
869/// inside a larger string template (CQL prepared text, HTTP body,
870/// etc.) is always a workload-shape bug: the typed value should
871/// flow through the adapter's typed-binding API, not be
872/// decimal-stringified into the SQL/JSON/body text and reparsed
873/// downstream.
874///
875/// Used by [`substitute_via_wires`] to refuse the splice and by
876/// the future op-template construction-time guard (init-time
877/// shape check) to reject equivalent misuse before any cycle runs.
878fn is_binary_natural(t: PortType) -> bool {
879 matches!(
880 t,
881 PortType::VecF32
882 | PortType::VecI32
883 | PortType::VecF64
884 | PortType::VecI64
885 | PortType::VecF16
886 | PortType::VecI16
887 | PortType::Handle
888 | PortType::Bytes
889 )
890}
891
892/// Same identifier discipline as the core `resolve_placeholders_in_string`
893/// validator in `crate::scope`: ASCII-alpha-or-underscore start,
894/// remainder ASCII-alphanumeric-or-underscore. Anything else is
895/// not a bare identifier and passes through.
896fn is_bare_ident(s: &str) -> bool {
897 let mut chars = s.chars();
898 match chars.next() {
899 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
900 _ => return false,
901 }
902 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
903}
904
905/// A dotted identifier chain (`q.cursor.idx`): two or more
906/// bare-identifier segments joined by single dots. These follow
907/// the field-access wire convention and resolve through the
908/// `__`-flattened spelling.
909fn is_dotted_ident(s: &str) -> bool {
910 let segments: Vec<&str> = s.split('.').collect();
911 segments.len() >= 2 && segments.iter().all(|seg| is_bare_ident(seg))
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917 use polydat::dsl::compile::compile_polydat_interpreter;
918
919 #[test]
920 fn scope_kernel_get_resolves_inputs_and_constants() {
921 // `lookup` (and therefore Push 1's WireSource) covers
922 // input slots and scope-init constants — the names available
923 // without a memoizing pull. `folded := 42` lands as a
924 // compile-folded constant; `cycle` is a coordinate input.
925 let mut k = crate::scope_kernel::ScopeKernel::compile(
926 "input cycle: u64\n\
927 folded := 42\n",
928 )
929 .unwrap();
930 k.set_inputs(&[7]);
931 let wires: &dyn WireSource = &k;
932 assert_eq!(wires.get("folded").map(|v| v.as_u64()), Some(42));
933 assert_eq!(wires.get("cycle").map(|v| v.as_u64()), Some(7));
934 }
935
936 #[test]
937 fn scope_kernel_get_returns_none_for_pull_only_outputs_in_push_1() {
938 // Push 1 baseline: outputs that require a memoizing
939 // `pull(&mut state, …)` evaluation are NOT served by the
940 // `&ScopeKernel` impl. Push 2 introduces the kernel-owning
941 // wires impl that can pull outputs. This test pins the
942 // current contract so the Push 2 change is visible as a
943 // diff.
944 let mut k = crate::scope_kernel::ScopeKernel::compile(
945 "input cycle: u64\n\
946 cyc_dep := hash(cycle)\n",
947 )
948 .unwrap();
949 k.set_inputs(&[7]);
950 let wires: &dyn WireSource = &k;
951 assert!(wires.get("cyc_dep").is_none());
952 }
953
954 #[test]
955 fn scope_kernel_get_returns_none_for_unknown_name() {
956 let k = crate::scope_kernel::ScopeKernel::compile("input cycle: u64\nx := 1\n").unwrap();
957 let wires: &dyn WireSource = &k;
958 assert!(wires.get("not_a_real_name").is_none());
959 }
960
961 #[test]
962 fn scope_kernel_names_lists_declared_outputs_and_inputs() {
963 let k =
964 crate::scope_kernel::ScopeKernel::compile("input cycle: u64\nfolded := 42\n").unwrap();
965 let wires: &dyn WireSource = &k;
966 let names: Vec<String> = wires.names().collect();
967 assert!(
968 names.iter().any(|n| n == "folded"),
969 "folded should appear: {names:?}"
970 );
971 assert!(
972 names.iter().any(|n| n == "cycle"),
973 "cycle should appear: {names:?}"
974 );
975 }
976
977 #[test]
978 fn null_wires_returns_none_and_empty() {
979 let wires: &dyn WireSource = &NULL_WIRES;
980 assert!(wires.get("anything").is_none());
981 assert_eq!(wires.names().count(), 0);
982 }
983
984 #[test]
985 fn cycle_wires_pulls_outputs() {
986 // CycleWires (Push 4) covers the memoizing-pull path that
987 // the bare `&ScopeKernel` impl can't reach. `cyc_dep` is a
988 // computed output — pulling it requires `&mut state` to
989 // fire the eval cone and cache the result.
990 let mut k = crate::scope_kernel::ScopeKernel::compile(
991 "input cycle: u64\n\
992 folded := 42\n\
993 cyc_dep := hash(cycle)\n",
994 )
995 .unwrap();
996 k.set_inputs(&[7]);
997 let cw = CycleWires::new(&mut k);
998 let wires: &dyn WireSource = &cw;
999 // Output pull works through CycleWires.
1000 let v = wires.get("cyc_dep").expect("cyc_dep should resolve");
1001 assert!(v.as_u64() != 0, "hash result should be non-zero");
1002 // Folded constant still resolves.
1003 assert_eq!(wires.get("folded").map(|v| v.as_u64()), Some(42));
1004 // Coordinate input still resolves.
1005 assert_eq!(wires.get("cycle").map(|v| v.as_u64()), Some(7));
1006 }
1007
1008 /// SRD-93-era fold totality: `reset` restores a wire to its
1009 /// DECLARED initial value — the author's identity element — so an
1010 /// empty min/max fold never parks `Value::None` on a typed slot
1011 /// and never leaves a stale prior reading.
1012 #[test]
1013 fn cycle_wires_reset_restores_declared_default() {
1014 let mut k = compile_polydat_interpreter(
1015 "input cycle: u64\nextern pressure: u64 = 7\nx := pressure + 1\n",
1016 )
1017 .unwrap();
1018 let cw = CycleWires::new(&mut k);
1019 let wires: &dyn WireSource = &cw;
1020
1021 assert_eq!(
1022 wires.write("pressure", Value::U64(42)),
1023 WriteOutcome::Stored
1024 );
1025 assert_eq!(wires.get("pressure").map(|v| v.as_u64()), Some(42));
1026
1027 assert_eq!(
1028 wires.reset("pressure"),
1029 WriteOutcome::Stored,
1030 "reset writes the declared default through the typed boundary"
1031 );
1032 assert_eq!(
1033 wires.get("pressure").map(|v| v.as_u64()),
1034 Some(7),
1035 "the wire returns to its declared initial value, not to None/0"
1036 );
1037
1038 assert_eq!(wires.reset("nonesuch"), WriteOutcome::NoSlot);
1039 }
1040
1041 #[test]
1042 fn cycle_wires_returns_none_for_unknown_name() {
1043 let mut k = compile_polydat_interpreter("input cycle: u64\nx := 1\n").unwrap();
1044 let cw = CycleWires::new(&mut k);
1045 let wires: &dyn WireSource = &cw;
1046 assert!(wires.get("not_a_real_name").is_none());
1047 }
1048
1049 #[test]
1050 fn cycle_wires_resolves_phase_binding_lhs_names() {
1051 // SRD-68 Push 4b sanity check: a workload's phase
1052 // binding (e.g. `target_index_table := pick(...)`) becomes
1053 // an output on the per-op canonical kernel after
1054 // `OpBuilder::canonical_kernel_for_op`. Per-fiber
1055 // instances built from it via `build_subscope` carry that
1056 // output. Wrapping such a per-fiber kernel in `CycleWires`
1057 // and calling `wires.get(phase_binding_name)` returns the
1058 // computed value — no text substitution required at
1059 // adapter cycle time.
1060 //
1061 // This unit test simulates the shape directly via
1062 // `compile_polydat_interpreter` rather than spinning up the full activity
1063 // pipeline; it pins the contract that `wires.get` answers
1064 // for any name the program declares as an output.
1065 let mut k = compile_polydat_interpreter(
1066 "input cycle: u64\n\
1067 keyspace := \"baselines\"\n\
1068 table := \"vec_label_00\"\n\
1069 # `pick`-equivalent with constant booleans for testability:\n\
1070 # take the first value when its selector is true.\n\
1071 target_index_table := \"system_views.sai_column_indexes\"\n",
1072 )
1073 .unwrap();
1074 k.set_inputs(&[0]);
1075 let cw = CycleWires::new(&mut k);
1076 let wires: &dyn WireSource = &cw;
1077 assert_eq!(
1078 wires
1079 .get("target_index_table")
1080 .map(|v| v.as_str().to_string()),
1081 Some("system_views.sai_column_indexes".to_string()),
1082 "phase-binding LHS should resolve through CycleWires",
1083 );
1084 assert_eq!(
1085 wires.get("keyspace").map(|v| v.as_str().to_string()),
1086 Some("baselines".to_string()),
1087 );
1088 }
1089
1090 #[test]
1091 fn substitute_via_wires_resolves_bare_names() {
1092 let mut k = compile_polydat_interpreter(
1093 "input cycle: u64\n\
1094 keyspace := \"baselines\"\n\
1095 table := \"vec_label_00\"\n",
1096 )
1097 .unwrap();
1098 let cw = CycleWires::new(&mut k);
1099 let resolved =
1100 substitute_via_wires("SELECT * FROM {keyspace}.{table} WHERE x = 1", &cw).unwrap();
1101 assert_eq!(resolved, "SELECT * FROM baselines.vec_label_00 WHERE x = 1");
1102 }
1103
1104 #[test]
1105 fn substitute_via_wires_passes_through_literal_braces() {
1106 let mut k = compile_polydat_interpreter(
1107 "input cycle: u64\n\
1108 ks := \"baselines\"\n",
1109 )
1110 .unwrap();
1111 let cw = CycleWires::new(&mut k);
1112 // CQL map literal: brace bodies starting with quotes are
1113 // not bare identifiers, pass through verbatim.
1114 let resolved = substitute_via_wires(
1115 "CREATE KEYSPACE {ks} WITH replication = {'class': 'SimpleStrategy'}",
1116 &cw,
1117 )
1118 .unwrap();
1119 assert_eq!(
1120 resolved,
1121 "CREATE KEYSPACE baselines WITH replication = {'class': 'SimpleStrategy'}",
1122 );
1123 }
1124
1125 /// Binary-natural type guard. A wire holding `VecF32` (or any
1126 /// other type with no meaningful text-spliced form) must not be
1127 /// silently decimal-stringified into the field text — that's a
1128 /// workload-shape bug (the value belongs in a typed-binding
1129 /// path, not the text template). The substitution layer
1130 /// rejects with a clear diagnostic pointing at the wire and
1131 /// suggesting both fixes (pure-token form / bindvars split).
1132 ///
1133 /// Regression guard: dropping this check would silently burn
1134 /// per-element Grisu float formatting (≈55% of cycle CPU on
1135 /// the fknn_rampup_data shape) on every cycle.
1136 #[test]
1137 fn substitute_via_wires_rejects_vec_f32_in_text_template() {
1138 use polydat::ast::SliceArc;
1139 struct VecWire;
1140 impl WireSource for VecWire {
1141 fn get(&self, name: &str) -> Option<Value> {
1142 match name {
1143 "id" => Some(Value::U64(7)),
1144 "vec" => Some(Value::VecF32(SliceArc::from_vec(vec![0.1_f32, 0.2, 0.3]))),
1145 _ => None,
1146 }
1147 }
1148 fn names(&self) -> Box<dyn Iterator<Item = String> + '_> {
1149 Box::new(["id", "vec"].iter().map(|s| s.to_string()))
1150 }
1151 }
1152 let err = substitute_via_wires("INSERT ... VALUES ('{id}', {vec})", &VecWire)
1153 .expect_err("VecF32 splice into text template must error");
1154 assert!(
1155 err.contains("vec"),
1156 "diagnostic should name the offending wire: {err}"
1157 );
1158 assert!(
1159 err.contains("vec_f32") || err.contains("VecF32"),
1160 "diagnostic should name the offending type: {err}"
1161 );
1162 assert!(
1163 err.contains("pure-token") || err.contains("typed"),
1164 "diagnostic should point at the fix: {err}"
1165 );
1166 }
1167
1168 /// The escape hatch: a field whose entire value is a pure
1169 /// token `{wire}` (no surrounding text) routes through case 2
1170 /// in `resolve_op_fields_via_wires` — typed Value preserved,
1171 /// no substitution. `substitute_via_wires` itself is only
1172 /// invoked on text-template fields, so the rejection above
1173 /// fires only when the binary-natural wire is genuinely being
1174 /// spliced into surrounding text. Pin that: a VecI32 wire
1175 /// triggers the same rejection (catches the broader contract,
1176 /// not a VecF32-only special case).
1177 #[test]
1178 fn substitute_via_wires_rejects_vec_i32_in_text_template() {
1179 use polydat::ast::SliceArc;
1180 struct VecI32Wire;
1181 impl WireSource for VecI32Wire {
1182 fn get(&self, name: &str) -> Option<Value> {
1183 if name == "vec" {
1184 Some(Value::VecI32(SliceArc::from_vec(vec![1_i32, 2, 3])))
1185 } else {
1186 None
1187 }
1188 }
1189 fn names(&self) -> Box<dyn Iterator<Item = String> + '_> {
1190 Box::new(std::iter::once("vec".to_string()))
1191 }
1192 }
1193 let err = substitute_via_wires("x = {vec}", &VecI32Wire).unwrap_err();
1194 assert!(
1195 err.contains("vec_i32") || err.contains("VecI32"),
1196 "VecI32 should also be rejected: {err}"
1197 );
1198 }
1199
1200 #[test]
1201 fn substitute_via_wires_errors_on_unresolved_name() {
1202 let mut k = compile_polydat_interpreter("input cycle: u64\nx := \"a\"\n").unwrap();
1203 let cw = CycleWires::new(&mut k);
1204 let err = substitute_via_wires("hi {nonexistent}", &cw).unwrap_err();
1205 assert!(
1206 err.contains("nonexistent"),
1207 "diagnostic should name the wire: {err}"
1208 );
1209 assert!(
1210 err.contains("unresolved"),
1211 "diagnostic should call out unresolved: {err}"
1212 );
1213 }
1214
1215 #[test]
1216 fn substitute_via_wires_errors_when_wire_resolves_to_none() {
1217 // SRD-74 Rule 3 strict render: a wire that EXISTS but
1218 // resolves to `Value::None` (e.g. a `const X := "{undef}"`
1219 // whose RHS interpolation propagated None per Rule 1, then
1220 // the conditional-shadow chain found no upstream binding
1221 // either) must error at substitution time — NOT silently
1222 // render as `""`. Empty string is a real value; absent is
1223 // not the same thing, and conflating them was the
1224 // wire-protocol corruption class this SRD closes.
1225 let mut k = compile_polydat_interpreter(
1226 "input cycle: u64\n\
1227 extern undef: str\n\
1228 const x := \"{undef}\"\n",
1229 )
1230 .unwrap();
1231 let cw = CycleWires::new(&mut k);
1232 let err = substitute_via_wires("source_model='{x}'", &cw).unwrap_err();
1233 assert!(
1234 err.contains("`{x}`"),
1235 "diagnostic should name the bind-point: {err}"
1236 );
1237 assert!(
1238 err.contains("Value::None") || err.contains("no value bound"),
1239 "diagnostic should explain the None resolution: {err}"
1240 );
1241 }
1242
1243 #[test]
1244 fn to_display_strict_returns_none_for_value_none() {
1245 // The strict primitive itself; render sites consume it.
1246 use polydat::ast::Value;
1247 assert_eq!(Value::None.to_display_strict(), None);
1248 assert_eq!(
1249 Value::Str("hello".into()).to_display_strict(),
1250 Some("hello".to_string())
1251 );
1252 assert_eq!(Value::U64(42).to_display_strict(), Some("42".to_string()));
1253 }
1254
1255 #[test]
1256 fn substitute_via_wires_resolves_inside_cql_options_map() {
1257 // Pin the failure shape that surfaced in
1258 // `full_cql_vector.yaml`: a CQL `WITH OPTIONS = {'k':
1259 // '{value}'}` map. The earlier substitution algorithm
1260 // truncated the body at the first `}` (the inner
1261 // placeholder's `}`), then treated the outer `{...}`
1262 // as one literal-content body — so inner `{value}`
1263 // placeholders inside the map were never resolved.
1264 // The fix mirrors `nmbrs_workload::bindpoints::extract_bind_points`:
1265 // when `{` is followed by `'` or `"`, treat it as a CQL
1266 // map opener (emit the brace, continue scanning).
1267 let mut k = compile_polydat_interpreter(
1268 "input cycle: u64\n\
1269 optimize_for := \"RECALL\"\n\
1270 similarity_function := \"EUCLIDEAN\"\n",
1271 )
1272 .unwrap();
1273 let cw = CycleWires::new(&mut k);
1274 let resolved = substitute_via_wires(
1275 "WITH OPTIONS = {'optimize_for': '{optimize_for}', 'similarity_function': '{similarity_function}'}",
1276 &cw,
1277 ).unwrap();
1278 assert_eq!(
1279 resolved,
1280 "WITH OPTIONS = {'optimize_for': 'RECALL', 'similarity_function': 'EUCLIDEAN'}",
1281 "nested `{{name}}` placeholders inside CQL map literals must resolve"
1282 );
1283 }
1284
1285 #[test]
1286 fn substitute_via_wires_errors_on_qualifier_prefix() {
1287 let mut k = compile_polydat_interpreter("input cycle: u64\nx := \"a\"\n").unwrap();
1288 let cw = CycleWires::new(&mut k);
1289 let err = substitute_via_wires("hi {bind:x}", &cw).unwrap_err();
1290 assert!(
1291 err.contains("bind:x"),
1292 "diagnostic should name the qualifier form: {err}"
1293 );
1294 }
1295
1296 #[test]
1297 fn substitute_via_wires_passes_through_inline_expr() {
1298 let mut k = compile_polydat_interpreter("input cycle: u64\nx := \"a\"\n").unwrap();
1299 let cw = CycleWires::new(&mut k);
1300 let resolved = substitute_via_wires("v = {{x + 1}}", &cw).unwrap();
1301 // `{{...}}` is reserved for the inline-expression desugar
1302 // surface (compiled into bindings before reaching cycle
1303 // time); cycle-time substitute_via_wires passes it
1304 // through unchanged.
1305 assert_eq!(resolved, "v = {{x + 1}}");
1306 }
1307
1308 #[test]
1309 fn cycle_wires_resolves_iter_var_through_subscope_chain() {
1310 // SRD-68 invariant from user: "the Polydat context visible after
1311 // initialization in each scope is designed to and required
1312 // to provide all of the values which should be visible
1313 // including those which are populated at logical closure
1314 // boundaries based on comprehensions."
1315 //
1316 // Concretely: a for_each scope kernel populates iter vars
1317 // on its INPUT slots (per `for_iteration`). The phase
1318 // scope kernel then inherits them via `build_subscope`.
1319 // The op-template canonical (built via `build_subscope`
1320 // again) MUST also carry the iter var values so cycle-time
1321 // `wires.get(iter_var_name)` answers correctly.
1322 //
1323 // This test mirrors that chain: parent kernel has
1324 // `optimize_for` as an extern input declaration with a
1325 // populated value; child kernel declares the same name;
1326 // verify the value propagates when the child is bound under
1327 // the parent, as every scope is, and is visible via
1328 // `CycleWires::get`.
1329 use polydat::ast::Value;
1330
1331 // Parent: declares `optimize_for` as extern + auto-passthrough
1332 // output via `final` — same pattern the phase synthesizer
1333 // uses for iter-var cascade.
1334 let mut parent = crate::scope_kernel::ScopeKernel::compile(
1335 "input cycle: u64\n\
1336 extern optimize_for: String\n",
1337 )
1338 .unwrap();
1339 // Populate the input slot the way the phase kernel does
1340 // after binding under the for_each bound_kernel.
1341 let opt_idx = parent
1342 .program()
1343 .find_input("optimize_for")
1344 .expect("optimize_for input slot");
1345 parent
1346 .set_input_at(opt_idx, Value::Str("RECALL".into()))
1347 .expect("write optimize_for");
1348
1349 // Child: program declares the same name as an extern.
1350 // Mimics the per-op canonical the dispenser owns.
1351 let child_scope = crate::scope_kernel::ScopeKernel::compile(
1352 "input cycle: u64\n\
1353 extern optimize_for: String\n",
1354 )
1355 .unwrap();
1356 let mut child = child_scope
1357 .bind_under(parent.kernel(), &[])
1358 .expect("the child binds under the parent");
1359
1360 let cw = CycleWires::new(&mut child);
1361 let wires: &dyn WireSource = &cw;
1362 // The architectural contract: child's CycleWires resolves
1363 // `optimize_for` through the inheritance chain. If this
1364 // assertion fails, the chain isn't propagating iter vars
1365 // and we need the gap fix at the Polydat Kernel layer.
1366 assert_eq!(
1367 wires.get("optimize_for").map(|v| v.as_str().to_string()),
1368 Some("RECALL".to_string()),
1369 "iter-var input populated on parent should propagate \
1370 through binding to child's WireSource",
1371 );
1372 }
1373
1374 #[test]
1375 fn extern_decl_only_produces_input_no_output() {
1376 // Diagnostic: an `extern <name>: <type>` declaration by
1377 // itself creates an INPUT but does NOT publish a matching
1378 // output. So `materialize_wiring_from_outer` (which walks outer's
1379 // outputs) wouldn't find this name and wouldn't propagate
1380 // it to descendants. The synthesis pipeline avoids this by
1381 // also calling `mark_inherited_outputs` on the kernel
1382 // post-build, but a plain `extern` line doesn't.
1383 //
1384 // Pinning this so the next person reading the source isn't
1385 // surprised — `cycle_wires_resolves_iter_var_through_subscope_chain`
1386 // passes only because the `String` extern's auto-passthrough
1387 // output (added by the compiler when the name appears in the
1388 // body) gives materialize_wiring_from_outer something to walk. With ONLY
1389 // an extern decl, there's no body reference, no auto-passthrough,
1390 // and the chain breaks.
1391 use polydat::dsl::compile::compile_polydat_interpreter;
1392 let k = compile_polydat_interpreter(
1393 "input cycle: u64\n\
1394 extern optimize_for: String\n",
1395 )
1396 .unwrap();
1397 let outputs: Vec<&str> = k.program().output_names().to_vec();
1398 // If this assertion fails (i.e. `optimize_for` IS in
1399 // outputs), then the chain test above was a no-op and we
1400 // need to revisit the architectural question.
1401 // Document whichever is true.
1402 eprintln!("DBG outputs from `extern optimize_for: String`: {outputs:?}");
1403 }
1404
1405 #[test]
1406 fn cycle_wires_advance_drives_per_row_pulls() {
1407 // SRD-68 batch contract: `advance(coord)` mutates the
1408 // underlying kernel's coord input so subsequent `get`
1409 // calls produce values for that coord. Verify by pulling
1410 // a coord-dependent output before and after advance.
1411 let mut k = compile_polydat_interpreter(
1412 "input cycle: u64\n\
1413 id := format_u64(cycle, 10)\n",
1414 )
1415 .unwrap();
1416 k.set_inputs(&[0]);
1417 let cw = CycleWires::new(&mut k);
1418 let wires: &dyn WireSource = &cw;
1419 // First read at cycle=0.
1420 assert_eq!(
1421 wires.get("id").map(|v| v.as_str().to_string()),
1422 Some("0".to_string())
1423 );
1424 // Advance and re-read.
1425 wires.advance(42);
1426 assert_eq!(
1427 wires.get("id").map(|v| v.as_str().to_string()),
1428 Some("42".to_string())
1429 );
1430 wires.advance(7);
1431 assert_eq!(
1432 wires.get("id").map(|v| v.as_str().to_string()),
1433 Some("7".to_string())
1434 );
1435 }
1436
1437 #[test]
1438 fn null_wires_advance_is_noop() {
1439 // Default `advance` impl on `NullWireSource` does nothing;
1440 // useful as a sanity check that advance on a non-batch-
1441 // capable wires doesn't panic.
1442 let wires: &dyn WireSource = &NULL_WIRES;
1443 wires.advance(0);
1444 wires.advance(123);
1445 assert!(wires.get("anything").is_none());
1446 }
1447
1448 #[test]
1449 fn cycle_wires_write_lands_on_input_slot_visible_to_get() {
1450 // SRD-66 capture flow: ResultDispenser writes a magic-extern
1451 // input (e.g. `count`), then a later wrapper or the eval
1452 // cone reads it. `wires.write` lands the value; `wires.get`
1453 // returns it on the next read.
1454 let mut k = compile_polydat_interpreter(
1455 "input cycle: u64\n\
1456 extern count: u64\n\
1457 extern body: Json\n",
1458 )
1459 .unwrap();
1460 k.set_inputs(&[0]);
1461 let cw = CycleWires::new(&mut k);
1462 let wires: &dyn WireSource = &cw;
1463
1464 assert_eq!(wires.write("count", Value::U64(42)), WriteOutcome::Stored);
1465 assert_eq!(wires.get("count").map(|v| v.as_u64()), Some(42));
1466
1467 // Overwrite — the new value supersedes the old.
1468 assert_eq!(wires.write("count", Value::U64(7)), WriteOutcome::Stored);
1469 assert_eq!(wires.get("count").map(|v| v.as_u64()), Some(7));
1470 }
1471
1472 #[test]
1473 fn cycle_wires_write_returns_no_slot_for_unknown_name() {
1474 // The closure-binding economy's DCE signal: no slot, value
1475 // silently dropped. Caller is unaffected.
1476 let mut k = compile_polydat_interpreter("input cycle: u64\nx := 1\n").unwrap();
1477 let cw = CycleWires::new(&mut k);
1478 let wires: &dyn WireSource = &cw;
1479 assert_eq!(wires.write("nope", Value::U64(99)), WriteOutcome::NoSlot);
1480 }
1481
1482 #[test]
1483 fn cycle_wires_write_feeds_eval_cone_through_get() {
1484 // The full capture-then-pull flow: write a magic-extern
1485 // input, read an output that depends on it through the
1486 // eval cone. This is the metrics-wrapper-reading-row_count
1487 // scenario from the design memo.
1488 let mut k = compile_polydat_interpreter(
1489 "input cycle: u64\n\
1490 extern count: u64\n\
1491 row_count := count\n",
1492 )
1493 .unwrap();
1494 k.set_inputs(&[0]);
1495 let cw = CycleWires::new(&mut k);
1496 let wires: &dyn WireSource = &cw;
1497
1498 // Before the write, `count` is at its default and
1499 // `row_count` reflects that.
1500 assert_eq!(wires.write("count", Value::U64(123)), WriteOutcome::Stored);
1501 // The pull through `row_count` (an output) reads the
1502 // freshly-written `count` (an input).
1503 assert_eq!(wires.get("row_count").map(|v| v.as_u64()), Some(123));
1504 }
1505
1506 #[test]
1507 fn null_wires_write_returns_no_slot() {
1508 let wires: &dyn WireSource = &NULL_WIRES;
1509 assert_eq!(wires.write("anything", Value::U64(1)), WriteOutcome::NoSlot);
1510 }
1511
1512 #[test]
1513 fn resolve_op_fields_four_case_dispatch() {
1514 // Pin the four-case dispatch contract for op-field resolution.
1515 // See `resolve_op_fields_via_wires` doc for the cases.
1516 let mut k = compile_polydat_interpreter(
1517 "input cycle: u64\n\
1518 table := \"users\"\n\
1519 count := 42\n",
1520 )
1521 .unwrap();
1522 let cw = CycleWires::new(&mut k);
1523
1524 let fields: Vec<(String, serde_json::Value)> = vec![
1525 // Case 1: non-string JSON scalar — stringified.
1526 ("limit_num".into(), serde_json::json!(100)),
1527 // Case 2: pure-token — typed wire reference, count is U64(42).
1528 (
1529 "typed_ref".into(),
1530 serde_json::Value::String("{count}".into()),
1531 ),
1532 // Case 3: text template — wires.get(table).to_display_string()
1533 // interpolated into the surrounding text.
1534 (
1535 "templated".into(),
1536 serde_json::Value::String("SELECT * FROM {table} WHERE x = 1".into()),
1537 ),
1538 // Case 4: bare-string literal — no placeholders, passes through.
1539 (
1540 "literal_stmt".into(),
1541 serde_json::Value::String("SELECT 1".into()),
1542 ),
1543 ];
1544
1545 let resolved = resolve_op_fields_via_wires(&fields, &cw).expect("four-case dispatch");
1546
1547 // Case 1: stringified — exact form is implementation-defined,
1548 // verify the value contains the digits.
1549 assert!(
1550 matches!(resolved.get_value("limit_num"),
1551 Some(polydat::ast::Value::Str(s)) if s.contains("100")),
1552 "case 1: got {:?}",
1553 resolved.get_value("limit_num")
1554 );
1555 // Case 2: typed U64.
1556 assert_eq!(
1557 resolved.get_value("typed_ref").map(|v| v.as_u64()),
1558 Some(42),
1559 "case 2: typed wire ref preserves U64"
1560 );
1561 // Case 3: interpolated string.
1562 assert_eq!(
1563 resolved.get_str("templated"),
1564 Some("SELECT * FROM users WHERE x = 1"),
1565 "case 3: text template"
1566 );
1567 // Case 4: literal pass-through.
1568 assert_eq!(
1569 resolved.get_str("literal_stmt"),
1570 Some("SELECT 1"),
1571 "case 4: bare string literal"
1572 );
1573 }
1574
1575 #[test]
1576 fn cycle_wires_caches_across_repeated_gets() {
1577 // Pull memoizes; a second get of the same name reads the
1578 // cached value off state, not re-runs the eval cone. We
1579 // can't directly observe memoization but we CAN observe
1580 // that two reads of the same cycle produce the same value
1581 // (hash is deterministic per coordinate, but the kernel's
1582 // dirty-tracking would require a state mutation to
1583 // re-fire — the property still holds).
1584 let mut k = compile_polydat_interpreter("input cycle: u64\nh := hash(cycle)\n").unwrap();
1585 k.set_inputs(&[42]);
1586 let cw = CycleWires::new(&mut k);
1587 let wires: &dyn WireSource = &cw;
1588 let v1 = wires.get("h").unwrap().as_u64();
1589 let v2 = wires.get("h").unwrap().as_u64();
1590 assert_eq!(v1, v2);
1591 }
1592}