Skip to main content

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}