Skip to main content

polydat_core/dsl/
compile.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! DSL-to-assembly bridge: compile a parsed Polydat AST into a runtime kernel.
5//!
6//! Walks the AST, resolves function names to node constructors, wires
7//! the `PolydatAssembler`, and produces the interpreter's
8//! `PolydatKernel` or any engine's boxed `Kernel`.
9
10use std::path::{Path, PathBuf};
11
12use crate::compile::assembly::{PolydatAssembler, WireRef};
13use crate::dsl::ast::*;
14use crate::dsl::lexer;
15use crate::dsl::parser;
16use crate::kernel::PolydatKernel;
17
18use crate::dsl::error::DiagnosticReport;
19use crate::dsl::validate::{collect_references, validate_ast};
20
21use std::collections::HashSet;
22
23use super::modules::ResolvedModule;
24
25/// Typed error ontology for the embedded-evaluation surface.
26///
27/// Per `expression_engine.md` §6 Error Ontology (under
28/// `crates/polydat/docs/design/`), every failure mode the embedding
29/// surface can produce maps to one of these variants. Hosts
30/// pattern-match on the variant to drive UX, recovery, or logging
31/// without parsing message strings.
32///
33/// The embedding surfaces (`eval_const_expr*`, the typed surfaces,
34/// `interpolate_via_kernel`) return this type; the `compile_polydat*`
35/// entry points return `String` errors, and `From<EmbeddingError>
36/// for String` bridges the two.
37#[derive(Debug, Clone)]
38pub enum EmbeddingError {
39    /// Text could not be parsed as polydat expression source.
40    /// The lexer or parser rejected the input before any
41    /// semantic analysis.
42    Parse {
43        /// The source text.
44        source: String,
45        /// The lexer's or parser's message.
46        message: String,
47        /// The byte offset of the error, when known.
48        position: Option<usize>,
49    },
50
51    /// A `{name}` placeholder in the text had no matching
52    /// binding in the kernel chain. Produced by
53    /// `interpolate_via_kernel` only.
54    UnresolvedPlaceholder {
55        /// The placeholder's name.
56        name: String,
57        /// The source text.
58        source: String,
59    },
60
61    /// The expression's upstream cone reaches a dynamic input,
62    /// but the requested evaluation surface requires
63    /// effectively-const lifecycle. Produced by
64    /// `eval_const_expr` (directly or via the two-step
65    /// composition).
66    LifecycleMismatch {
67        /// The source text.
68        source: String,
69        /// The dynamic inputs the cone reaches.
70        dynamic_inputs: Vec<String>,
71    },
72
73    /// A node mentioned in the expression is not registered
74    /// in the runtime. Includes a suggested alternative when
75    /// the name is close to a known node.
76    UnknownNode {
77        /// The unknown node's name.
78        name: String,
79        /// The source text.
80        source: String,
81        /// A registered name close to it, if any.
82        suggestion: Option<String>,
83    },
84
85    /// The expression's wire chain has a type mismatch that
86    /// auto-adapters cannot heal. Produced by the assembly
87    /// pass during compilation.
88    TypeMismatch {
89        /// The producing node.
90        from_node: String,
91        /// Its output type.
92        from_type: crate::ast::PortType,
93        /// The consuming node.
94        to_node: String,
95        /// The type its port requires.
96        to_type: crate::ast::PortType,
97        /// The source text.
98        source: String,
99    },
100
101    /// A node's `eval` panicked during scope-init evaluation.
102    /// The kernel's `catch_unwind` boundary captured the
103    /// panic; the message is the panic payload's
104    /// human-readable form.
105    NodeEvalPanic {
106        /// The node that panicked.
107        node_name: String,
108        /// The panic's message.
109        message: String,
110        /// The source text.
111        source: String,
112    },
113
114    /// Compilation succeeded but the requested output name
115    /// could not be resolved in the resulting kernel.
116    /// Indicates an internal compiler issue or a mismatch
117    /// between the wrapper template and the compiler's output
118    /// naming.
119    ResultMissing {
120        /// The output the caller asked for.
121        output_name: String,
122        /// The source text.
123        source: String,
124    },
125
126    /// A `Value::None` propagated to the expression's output
127    /// where a concrete value was required. Produced by a
128    /// `HostType::from_value` conversion that meets `Value::None`,
129    /// or by a host's own strict accessor (`as_bool` on
130    /// `Value::None`, etc.). See SRD-74.
131    NonePropagated {
132        /// The accessor the host called.
133        accessor: &'static str,
134        /// The source text.
135        source: String,
136    },
137
138    /// Evaluation exceeded a host-specified time budget.
139    /// Currently produced only by deadline-accepting
140    /// surfaces (reserved for the bulk-evaluation surface
141    /// γ-9 and adapter-specific embedding paths).
142    Timeout {
143        /// The source text.
144        source: String,
145        /// Milliseconds spent.
146        elapsed_ms: u64,
147        /// The budget, in milliseconds.
148        deadline_ms: u64,
149    },
150
151    /// A node the expression references is absent from the
152    /// link-time node registry. Carries the node names that
153    /// could not be resolved. Not produced by any current
154    /// surface.
155    RegistryNotInitialised {
156        /// The node names that could not be resolved.
157        missing: Vec<String>,
158        /// The source text.
159        source: String,
160    },
161}
162
163impl std::fmt::Display for EmbeddingError {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        match self {
166            EmbeddingError::Parse {
167                source,
168                message,
169                position,
170            } => match position {
171                Some(p) => write!(f, "parse error at position {p} in '{source}': {message}"),
172                None => write!(f, "parse error in '{source}': {message}"),
173            },
174            EmbeddingError::UnresolvedPlaceholder { name, source } => write!(
175                f,
176                "unresolved placeholder '{{{name}}}' in '{source}' — \
177                 no matching binding in the kernel chain"
178            ),
179            EmbeddingError::LifecycleMismatch {
180                source,
181                dynamic_inputs,
182            } => write!(
183                f,
184                "not a const expression: '{source}' depends on runtime inputs ({})",
185                dynamic_inputs.join(", ")
186            ),
187            EmbeddingError::UnknownNode {
188                name,
189                source,
190                suggestion,
191            } => match suggestion {
192                Some(sug) => write!(
193                    f,
194                    "unknown function: '{name}' in '{source}'\n\n  Did you mean '{sug}'?"
195                ),
196                None => write!(
197                    f,
198                    "unknown function: '{name}' in '{source}'\n\n  \
199                     This function is not registered in the Polydat function library."
200                ),
201            },
202            EmbeddingError::TypeMismatch {
203                from_node,
204                from_type,
205                to_node,
206                to_type,
207                source,
208            } => {
209                write!(
210                    f,
211                    "type mismatch in '{source}': cannot connect \
212                     {from_type:?} output of '{from_node}' to {to_type:?} \
213                     input of '{to_node}'"
214                )
215            }
216            EmbeddingError::NodeEvalPanic {
217                node_name,
218                message,
219                source,
220            } => write!(
221                f,
222                "node-eval panic in '{source}' (node '{node_name}'): {message}"
223            ),
224            EmbeddingError::ResultMissing {
225                output_name,
226                source,
227            } => write!(
228                f,
229                "compilation completed for '{source}' but output '{output_name}' \
230                 is not reachable — internal compiler issue"
231            ),
232            EmbeddingError::NonePropagated { accessor, source } => write!(
233                f,
234                "Value::None propagated to '{source}'; \
235                 host called strict accessor `{accessor}`. \
236                 Use a non-strict accessor (`try_as_*`) or surface the None to the user."
237            ),
238            EmbeddingError::Timeout {
239                source,
240                elapsed_ms,
241                deadline_ms,
242            } => write!(
243                f,
244                "evaluation of '{source}' exceeded deadline: \
245                 {elapsed_ms}ms elapsed, {deadline_ms}ms budget"
246            ),
247            EmbeddingError::RegistryNotInitialised { missing, source } => write!(
248                f,
249                "runtime registry missing node(s) referenced by '{source}': {}",
250                missing.join(", ")
251            ),
252        }
253    }
254}
255
256impl std::error::Error for EmbeddingError {}
257
258/// Renders the error as its message for callers on the
259/// `Result<_, String>` entry points.
260impl From<EmbeddingError> for String {
261    fn from(e: EmbeddingError) -> String {
262        e.to_string()
263    }
264}
265
266/// Embedded standard library modules, compiled into the binary.
267///
268/// Each entry is (filename, source). Multiple modules per file —
269/// each top-level binding is a separate module, resolved by name.
270/// Searched as the final fallback after the source directory and
271/// `CompileOptions::lib_paths` (the binary's `--lib`).
272pub(super) static STDLIB_MODULES: &[(&str, &str)] = &[
273    (
274        "hashing.polydat",
275        include_str!("../../stdlib/hashing.polydat"),
276    ),
277    (
278        "strings.polydat",
279        include_str!("../../stdlib/strings.polydat"),
280    ),
281    (
282        "identity.polydat",
283        include_str!("../../stdlib/identity.polydat"),
284    ),
285    (
286        "distributions.polydat",
287        include_str!("../../stdlib/distributions.polydat"),
288    ),
289    (
290        "latency.polydat",
291        include_str!("../../stdlib/latency.polydat"),
292    ),
293    (
294        "timeseries.polydat",
295        include_str!("../../stdlib/timeseries.polydat"),
296    ),
297    ("waves.polydat", include_str!("../../stdlib/waves.polydat")),
298    (
299        "fourier.polydat",
300        include_str!("../../stdlib/fourier.polydat"),
301    ),
302    (
303        "modeling.polydat",
304        include_str!("../../stdlib/modeling.polydat"),
305    ),
306];
307
308/// Return the embedded standard library module sources.
309pub fn stdlib_sources() -> &'static [(&'static str, &'static str)] {
310    STDLIB_MODULES
311}
312
313/// Compile a `.polydat` source string into the interpreter's kernel,
314/// under the default options: [`compile_polydat_with_options`] with
315/// [`CompileOptions::default`]. The interpreter is the semantic oracle;
316/// [`compile_polydat_kernel`] is the same program on the default engine.
317pub fn compile_polydat(source: &str) -> Result<PolydatKernel, String> {
318    compile_polydat_with_options(source, &CompileOptions::default(), None)
319}
320
321/// Compile source together with tiles a host built from what it holds
322/// (SRD 114 §5.6): template text, JSON text, or a parsed JSON value,
323/// via [`crate::tile`]. The tiles are appended as `tile` statements, so
324/// they see every wire the source defines and are wires themselves.
325pub fn compile_polydat_with_tiles(
326    source: &str,
327    tiles: Vec<super::ast::TileDef>,
328) -> Result<PolydatKernel, String> {
329    let (ast, options) = ast_with_tiles(source, tiles)?;
330    compile_ast_with_options(&ast, source, &options, None)
331}
332
333/// [`compile_polydat_with_tiles`] on [`Engine::default`](crate::Engine::default):
334/// the same program with the same tiles appended, as a compiled kernel.
335pub fn compile_polydat_kernel_with_tiles(
336    source: &str,
337    tiles: Vec<super::ast::TileDef>,
338) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
339    let (ast, options) = ast_with_tiles(source, tiles).map_err(crate::KernelError::Source)?;
340    compile_ast_with_engine(&ast, source, &options, None, crate::Engine::default())
341}
342
343/// The parsed source with `tiles` appended as `tile` statements, and
344/// the options every tile entry point compiles under.
345fn ast_with_tiles(
346    source: &str,
347    tiles: Vec<super::ast::TileDef>,
348) -> Result<(PolydatFile, CompileOptions), String> {
349    let tokens = super::lexer::lex(source)?;
350    let mut ast = super::parser::parse(tokens)?;
351    ast.statements
352        .extend(tiles.into_iter().map(Statement::Tile));
353    let options = CompileOptions {
354        context: "polydat source with host tiles".to_string(),
355        ..CompileOptions::default()
356    };
357    Ok((ast, options))
358}
359
360/// Compile Polydat source to an assembler (not yet compiled to a kernel).
361///
362/// Returns the `PolydatAssembler` with every node and wire in place,
363/// the graph a host may extend by hand before building it on any
364/// engine: [`PolydatAssembler::compile_kernel`] for the default engine,
365/// [`PolydatAssembler::compile_with`] for a named one,
366/// [`PolydatAssembler::compile`] for the interpreter's concrete kernel.
367/// An assembler carries no traversal, so a program with a `for`
368/// statement is refused here; the kernel entry points compile it.
369pub fn compile_polydat_to_assembler(source: &str) -> Result<PolydatAssembler, String> {
370    compile_polydat_to_assembler_with(source, &CompileOptions::default())
371}
372
373/// [`compile_polydat_to_assembler`] with the options the kernel entry
374/// points take: a source directory for relative imports, library
375/// directories, required outputs, strict typing, a diagnostic context,
376/// and a cursor limit. The assembler it returns is the graph
377/// [`compile_polydat_with_options`] would compile from the same source
378/// and options, ready for any engine.
379pub fn compile_polydat_to_assembler_with(
380    source: &str,
381    options: &CompileOptions,
382) -> Result<PolydatAssembler, String> {
383    let tokens = super::lexer::lex(source)?;
384    let ast = super::parser::parse(tokens)?;
385    let mut prepared = Prepared::new(source, &ast, options, None);
386    let (compiler, filter) = prepared.parts();
387    compiler.assemble_parent(&ast, filter)
388}
389
390/// Compile one selected scalar output into the conservative perfect-ordinal
391/// Tier-1 SIMD executor.
392///
393/// This is an explicit execution surface: ordinary [`compile_polydat`] and
394/// `PolydatKernel::pull` remain scalar-cycle APIs. `driving_input` is normally
395/// a cursor projection such as `base__ordinal`; `output` names the only result
396/// drained by the batch executor.
397#[cfg(feature = "jit")]
398pub fn compile_polydat_tier1_simd_ordinal(
399    source: &str,
400    driving_input: &str,
401    output: &str,
402) -> Result<crate::compile::simd_tier1::Tier1SimdExecutor, String> {
403    compile_polydat_to_assembler(source)?
404        .try_compile_tier1_simd_ordinal(driving_input, output)
405        .map_err(|error| error.to_string())
406}
407
408/// [`compile_polydat_with_options`] with a source directory alone.
409#[deprecated(note = "use compile_polydat_with_options with CompileOptions { source_dir, .. }")]
410pub fn compile_polydat_with_path(
411    source: &str,
412    source_dir: Option<&Path>,
413) -> Result<PolydatKernel, String> {
414    let options = CompileOptions {
415        source_dir: source_dir.map(Path::to_path_buf),
416        ..CompileOptions::default()
417    };
418    compile_polydat_with_options(source, &options, None)
419}
420
421/// [`compile_polydat_with_options`] with a source directory, the
422/// outputs to keep, and strictness as separate parameters.
423#[deprecated(
424    note = "use compile_polydat_with_options with CompileOptions { required_outputs, .. }"
425)]
426pub fn compile_polydat_with_outputs(
427    source: &str,
428    source_dir: Option<&Path>,
429    required_outputs: &[String],
430    strict: bool,
431) -> Result<PolydatKernel, String> {
432    let options = CompileOptions {
433        source_dir: source_dir.map(Path::to_path_buf),
434        required_outputs: required_outputs.to_vec(),
435        strict,
436        ..CompileOptions::default()
437    };
438    compile_polydat_with_options(source, &options, None)
439}
440
441/// `const name := expr` declares a side-effect-carrying compile-time
442/// computation: download a dataset, prebuffer a facet, register a
443/// resource, etc. The user's signal that they want it evaluated is
444/// the `const` keyword itself, not a downstream wire reference. Yet
445/// the assembler's DCE pass walks back from the requested-outputs
446/// set and prunes anything not in that ancestry, which silently
447/// removes const bindings whose result nothing reads.
448///
449/// This helper extends a caller-supplied `required_outputs` list
450/// with every `const` binding target in the source. Two effects:
451/// the assembler keeps those nodes during DCE, and constant
452/// folding then evaluates them once at compile time — running the
453/// side effect exactly once, before any dispatch.
454///
455/// Plain bindings (`name := ...`) are *not* added; they only run
456/// when consumed. Modules and other statements are likewise not
457/// auto-promoted.
458fn extend_required_with_const_bindings(
459    required_outputs: &[String],
460    ast: &crate::dsl::ast::PolydatFile,
461) -> Vec<String> {
462    let mut out: Vec<String> = required_outputs.to_vec();
463    for stmt in &ast.statements {
464        if let crate::dsl::ast::Statement::Binding(b) = stmt
465            && b.modifier.is_const()
466        {
467            for name in &b.targets {
468                if !out.iter().any(|n| n == name) {
469                    out.push(name.clone());
470                }
471            }
472        }
473    }
474    out
475}
476
477/// [`compile_polydat_with_options`] with the source directory, library
478/// directories, outputs to keep, strictness, and context label as
479/// separate parameters.
480#[deprecated(note = "use compile_polydat_with_options with CompileOptions { lib_paths, .. }")]
481pub fn compile_polydat_with_libs(
482    source: &str,
483    source_dir: Option<&Path>,
484    polydat_lib_paths: Vec<PathBuf>,
485    required_outputs: &[String],
486    strict: bool,
487    context: &str,
488) -> Result<PolydatKernel, String> {
489    let options = CompileOptions {
490        source_dir: source_dir.map(Path::to_path_buf),
491        lib_paths: polydat_lib_paths,
492        required_outputs: required_outputs.to_vec(),
493        strict,
494        context: context.to_string(),
495        cursor_limit: None,
496        ledger: None,
497    };
498    compile_polydat_with_options(source, &options, None)
499}
500
501/// RAII guard that sets the data-file base directory (see
502/// [`crate::library::datafile::set_data_base_dir`]) for the duration of
503/// a synchronous compile and restores the previous value on drop, so
504/// nested compiles unwind cleanly.
505struct DataBaseDirGuard(Option<PathBuf>);
506
507impl DataBaseDirGuard {
508    fn set(dir: &Path) -> Self {
509        DataBaseDirGuard(crate::library::datafile::set_data_base_dir(Some(
510            dir.to_path_buf(),
511        )))
512    }
513}
514
515impl Drop for DataBaseDirGuard {
516    fn drop(&mut self) {
517        crate::library::datafile::set_data_base_dir(self.0.take());
518    }
519}
520
521/// [`compile_polydat_with_options`] with every option as a separate
522/// parameter.
523#[deprecated(note = "use compile_polydat_with_options")]
524pub fn compile_polydat_with_libs_and_limit(
525    source: &str,
526    source_dir: Option<&Path>,
527    polydat_lib_paths: Vec<PathBuf>,
528    required_outputs: &[String],
529    strict: bool,
530    context: &str,
531    cursor_limit: Option<u64>,
532) -> Result<PolydatKernel, String> {
533    let options = CompileOptions {
534        source_dir: source_dir.map(Path::to_path_buf),
535        lib_paths: polydat_lib_paths,
536        required_outputs: required_outputs.to_vec(),
537        strict,
538        context: context.to_string(),
539        cursor_limit,
540        ledger: None,
541    };
542    compile_polydat_with_options(source, &options, None)
543}
544
545/// [`compile_polydat_with_options`] with a source directory and
546/// strictness alone.
547#[deprecated(note = "use compile_polydat_with_options with CompileOptions { strict, .. }")]
548pub fn compile_polydat_strict(
549    source: &str,
550    source_dir: Option<&Path>,
551    strict: bool,
552) -> Result<PolydatKernel, String> {
553    let options = CompileOptions {
554        source_dir: source_dir.map(Path::to_path_buf),
555        strict,
556        ..CompileOptions::default()
557    };
558    compile_polydat_with_options(source, &options, None)
559}
560
561/// The options every entry point compiles under. A host that names
562/// none gets the defaults: no source directory, no library paths,
563/// every binding an output, lax typing, the default context label,
564/// and no cursor limit.
565///
566/// `strict` refuses what lax compilation warns about, on every engine:
567/// an implicit type coercion, a config wire fed from a cycle-time
568/// source, a nondeterministic node no `volatile` output acknowledges, a
569/// binding nothing reads, an undeclared coordinate, and a positional
570/// module argument.
571#[derive(Debug, Default, Clone)]
572pub struct CompileOptions {
573    /// The directory relative data-file paths resolve against.
574    pub source_dir: Option<PathBuf>,
575    /// Library search paths, tried after the source directory and before the embedded standard library.
576    pub lib_paths: Vec<PathBuf>,
577    /// The outputs to keep; every output when empty.
578    pub required_outputs: Vec<String>,
579    /// Whether to enforce strict validation.
580    pub strict: bool,
581    /// The diagnostic context label, such as a file name.
582    pub context: String,
583    /// A limit on every cursor's extent, if any.
584    pub cursor_limit: Option<u64>,
585    /// The compile ledger to record this program tree in: a host that
586    /// holds one charges the compile to it; `None` mints a fresh one,
587    /// read back through the kernel's `ledger()`.
588    pub ledger: Option<std::sync::Arc<crate::kernel::CompileLedger>>,
589}
590
591/// Compile Polydat source into the interpreter's kernel under
592/// `options`, recording pragma and assembly events in `log` when one is
593/// given: the interpreter-typed entry point every other interpreter form
594/// reduces to. [`compile_polydat_with_engine`] is the same compile on
595/// any engine.
596pub fn compile_polydat_with_options(
597    source: &str,
598    options: &CompileOptions,
599    log: Option<&mut super::events::CompileEventLog>,
600) -> Result<PolydatKernel, String> {
601    let tokens = lexer::lex(source)?;
602    let ast = parser::parse(tokens)?;
603    compile_ast_with_options(&ast, source, options, log)
604}
605
606/// [`compile_polydat_with_options`] for an already parsed, possibly
607/// transformed, program. `source` is the text the program was parsed
608/// from and is used for diagnostics only.
609pub fn compile_ast_with_options(
610    ast: &PolydatFile,
611    source: &str,
612    options: &CompileOptions,
613    mut log: Option<&mut super::events::CompileEventLog>,
614) -> Result<PolydatKernel, String> {
615    let mut prepared = Prepared::new(source, ast, options, log.as_deref_mut());
616    let (compiler, filter) = prepared.parts();
617    compiler
618        .compile_interpreter(ast, filter, log, crate::JitMode::Auto)
619        .map_err(|e| e.to_string())
620}
621
622/// [`compile_polydat_with_options`] under the default options, with the
623/// compile event log: the same kernel [`compile_polydat`] builds, with
624/// every pragma, assembly, fold, and tile event recorded.
625pub fn compile_polydat_with_log(
626    source: &str,
627    log: &mut super::events::CompileEventLog,
628) -> Result<PolydatKernel, String> {
629    compile_polydat_with_options(source, &CompileOptions::default(), Some(log))
630}
631
632/// Record one event per pragma in `set`: `PragmaAcknowledged`
633/// (advisory) for `strict_types`/`strict_values`/`strict`,
634/// `UnknownPragma` (warning) for the rest. Forward-compatible: an
635/// unknown pragma never blocks compilation.
636///
637/// Called from `Prepared::new` for every entry point given a log;
638/// the set comes from `pragmas::collect_from_ast`.
639pub(crate) fn record_pragma_events(
640    set: &super::pragmas::PragmaSet,
641    log: &mut super::events::CompileEventLog,
642) {
643    use super::events::CompileEvent;
644    for entry in &set.entries {
645        let known = matches!(
646            entry.name.as_str(),
647            "strict_types" | "strict_values" | "strict"
648        );
649        if known {
650            log.push(CompileEvent::PragmaAcknowledged {
651                name: entry.name.clone(),
652                line: entry.line,
653            });
654        } else {
655            log.push(CompileEvent::UnknownPragma {
656                name: entry.name.clone(),
657                line: entry.line,
658            });
659        }
660    }
661}
662
663/// Compile with full diagnostics: errors, warnings, suggestions, on the
664/// default engine.
665///
666/// Returns `(Ok(kernel), report)` on success with possible warnings,
667/// or `(Err(()), report)` on failure with errors. The report always
668/// contains all diagnostics. The program the report describes is the
669/// program the kernel runs: the same compile every entry point makes.
670pub fn compile_polydat_checked(
671    source: &str,
672) -> (Result<Box<dyn crate::Kernel>, ()>, DiagnosticReport) {
673    let mut report = DiagnosticReport::new(source);
674
675    let tokens = match lexer::lex(source) {
676        Ok(t) => t,
677        Err(e) => {
678            report.error(crate::dsl::lexer::Span { line: 1, col: 1 }, e);
679            return (Err(()), report);
680        }
681    };
682
683    let ast = match parser::parse(tokens) {
684        Ok(a) => a,
685        Err(e) => {
686            report.error(crate::dsl::lexer::Span { line: 1, col: 1 }, e);
687            return (Err(()), report);
688        }
689    };
690
691    // Validate the AST before compiling
692    validate_ast(&ast, &mut report);
693
694    if report.has_errors() {
695        return (Err(()), report);
696    }
697
698    match compile_ast_with_engine(
699        &ast,
700        source,
701        &CompileOptions::default(),
702        None,
703        crate::Engine::default(),
704    ) {
705        Ok(kernel) => (Ok(kernel), report),
706        Err(e) => {
707            report.error(crate::dsl::lexer::Span { line: 1, col: 1 }, e.to_string());
708            (Err(()), report)
709        }
710    }
711}
712
713/// Cache of constant-expression results keyed by source text. A const
714/// expression compiles with no inputs, so its value is a pure function
715/// of its text; caching is exact. Bounded so a pathological caller
716/// cannot grow it without limit. This is what keeps repeated evaluation
717/// of the same range, list, or predicate text compile-free (SRD 113
718/// §5.2).
719static CONST_EXPR_CACHE: std::sync::OnceLock<
720    std::sync::Mutex<std::collections::HashMap<String, crate::ast::Value>>,
721> = std::sync::OnceLock::new();
722const CONST_EXPR_CACHE_CAP: usize = 8192;
723
724/// Evaluate a constant expression by compiling it as a one-binding
725/// program: what a comprehension source such as `partitions("*\/4", 1000)`
726/// goes through. Cached by source text, so the same text compiles once
727/// per process (SRD 113 §5.2). An expression that reaches a dynamic
728/// input is a lifecycle error. The compile, when there is one, is
729/// recorded in a ledger of its own; [`eval_const_expr_for`] charges
730/// it to a tree's.
731///
732/// # Examples
733///
734/// ```
735/// use polydat::dsl::compile::eval_const_expr;
736/// let v = eval_const_expr("4 * 4").unwrap();
737/// assert_eq!(v.as_u64(), 16);  // both int literals → u64_mul
738/// let v = eval_const_expr("4.0 * 4.0").unwrap();
739/// assert_eq!(v.as_f64(), 16.0);  // both float literals → f64_mul
740/// ```
741pub fn eval_const_expr(source: &str) -> Result<crate::ast::Value, EmbeddingError> {
742    eval_const_expr_for(source, &crate::kernel::CompileLedger::new())
743}
744
745/// [`eval_const_expr`] with its compile, when the text is not cached,
746/// recorded in `ledger`: what a traversal source or predicate that has
747/// to compile charges to the tree that opened it.
748pub fn eval_const_expr_for(
749    source: &str,
750    ledger: &std::sync::Arc<crate::kernel::CompileLedger>,
751) -> Result<crate::ast::Value, EmbeddingError> {
752    let cache =
753        CONST_EXPR_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()));
754    if let Ok(map) = cache.lock()
755        && let Some(v) = map.get(source)
756    {
757        return Ok(v.clone());
758    }
759    let result = eval_const_expr_uncached(source, ledger);
760    if let Ok(v) = &result
761        && let Ok(mut map) = cache.lock()
762    {
763        if map.len() >= CONST_EXPR_CACHE_CAP {
764            map.clear();
765        }
766        map.insert(source.to_string(), v.clone());
767    }
768    result
769}
770
771fn eval_const_expr_uncached(
772    source: &str,
773    ledger: &std::sync::Arc<crate::kernel::CompileLedger>,
774) -> Result<crate::ast::Value, EmbeddingError> {
775    let wrapped = format!("\nout := {source}");
776    let source_owned = source.to_string();
777    let options = CompileOptions {
778        ledger: Some(ledger.clone()),
779        ..CompileOptions::default()
780    };
781    // Constant-folding inside `compile_polydat` invokes node `eval`
782    // for inputs-free DAGs, so any node that panics on bad data
783    // (e.g. `handle_of(&Value::None)` after a failed
784    // `dataset_open`) would unwind out past this function and
785    // crash any caller that doesn't itself catch panics. The
786    // kernel's `engines::eval_node` enriches node-eval panics
787    // with their provenance string; that string is what we
788    // extract.
789    let source_for_panic = source_owned.clone();
790    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(
791        move || -> Result<crate::ast::Value, EmbeddingError> {
792            let kernel = compile_polydat_with_options(&wrapped, &options, None)
793                .map_err(|msg| classify_compile_error(&source_owned, msg))?;
794            kernel
795                .get_constant("out")
796                .cloned()
797                .ok_or_else(|| EmbeddingError::LifecycleMismatch {
798                    source: source_owned.clone(),
799                    dynamic_inputs: Vec::new(),
800                })
801        },
802    ));
803    match result {
804        Ok(r) => r,
805        Err(payload) => Err(EmbeddingError::NodeEvalPanic {
806            node_name: "(unknown)".to_string(),
807            message: panic_payload_message(&payload),
808            source: source_for_panic,
809        }),
810    }
811}
812
813/// Classify a raw compile-error string into a typed
814/// `EmbeddingError` variant. Best-effort string pattern
815/// matching against the compiler's error message shapes;
816/// when nothing matches, falls through to `Parse` (the most
817/// common case for stringly-typed compile errors).
818fn classify_compile_error(source: &str, msg: String) -> EmbeddingError {
819    // "not a const expression: '...' depends on runtime inputs"
820    if msg.starts_with("not a const expression") {
821        return EmbeddingError::LifecycleMismatch {
822            source: source.to_string(),
823            dynamic_inputs: Vec::new(),
824        };
825    }
826    // "unknown function: 'foo'" patterns
827    if let Some(stripped) = msg.strip_prefix("unknown function: '")
828        && let Some(end) = stripped.find('\'')
829    {
830        let name = stripped[..end].to_string();
831        return EmbeddingError::UnknownNode {
832            name,
833            source: source.to_string(),
834            suggestion: None,
835        };
836    }
837    // "type mismatch" patterns from the assembler
838    if msg.contains("type mismatch") {
839        return EmbeddingError::TypeMismatch {
840            from_node: "(unknown)".to_string(),
841            from_type: crate::ast::PortType::U64,
842            to_node: "(unknown)".to_string(),
843            to_type: crate::ast::PortType::U64,
844            source: source.to_string(),
845        };
846    }
847    // Fall-through: treat as parse error since most
848    // compiler-side failures originate at parse time.
849    EmbeddingError::Parse {
850        source: source.to_string(),
851        message: msg,
852        position: None,
853    }
854}
855
856// ───── Typed embedding surface (γ-4) ─────
857
858/// Host-facing type that polydat can return from the typed
859/// embedding surfaces. The trait declares the polydat
860/// `PortType` the Rust type corresponds to and the conversion
861/// from the returned [`crate::ast::Value`] back to the host
862/// type.
863///
864/// Hosts that want compile-time type alignment use the typed
865/// surfaces ([`eval_const_expr_typed`] /
866/// [`eval_kernel_bound_typed`]) and let the type parameter
867/// drive the contract. The fall-back is the untyped surface
868/// (`eval_const_expr`) which returns a raw [`crate::ast::Value`]
869/// for hosts to coerce themselves.
870///
871/// See expression_engine.md §5.3.
872pub trait HostType: Sized {
873    /// The `PortType` that polydat compares the expression's
874    /// output type against. Used for compile-time / construction-
875    /// time type-mismatch detection.
876    fn target_port_type() -> crate::ast::PortType;
877
878    /// Convert a polydat [`crate::ast::Value`] into the host Rust
879    /// type. Returns a typed [`EmbeddingError::TypeMismatch`] when
880    /// the value cannot be represented as the host type; the impls
881    /// accept the lossless widenings (`U64` → `bool`/`f64`, scalars
882    /// → `String`).
883    fn from_value(v: crate::ast::Value) -> Result<Self, EmbeddingError>;
884}
885
886impl HostType for bool {
887    fn target_port_type() -> crate::ast::PortType {
888        crate::ast::PortType::Bool
889    }
890    fn from_value(v: crate::ast::Value) -> Result<Self, EmbeddingError> {
891        match v {
892            crate::ast::Value::Bool(b) => Ok(b),
893            crate::ast::Value::U64(n) => Ok(n != 0),
894            crate::ast::Value::None => Err(EmbeddingError::NonePropagated {
895                accessor: "HostType::<bool>::from_value",
896                source: "<typed-embedding result>".to_string(),
897            }),
898            other => Err(EmbeddingError::TypeMismatch {
899                from_node: "<expression-output>".to_string(),
900                from_type: other.port_type(),
901                to_node: "<host-target>".to_string(),
902                to_type: crate::ast::PortType::Bool,
903                source: "<typed-embedding result>".to_string(),
904            }),
905        }
906    }
907}
908
909impl HostType for u64 {
910    fn target_port_type() -> crate::ast::PortType {
911        crate::ast::PortType::U64
912    }
913    fn from_value(v: crate::ast::Value) -> Result<Self, EmbeddingError> {
914        match v {
915            crate::ast::Value::U64(n) => Ok(n),
916            crate::ast::Value::None => Err(EmbeddingError::NonePropagated {
917                accessor: "HostType::<u64>::from_value",
918                source: "<typed-embedding result>".to_string(),
919            }),
920            other => Err(EmbeddingError::TypeMismatch {
921                from_node: "<expression-output>".to_string(),
922                from_type: other.port_type(),
923                to_node: "<host-target>".to_string(),
924                to_type: crate::ast::PortType::U64,
925                source: "<typed-embedding result>".to_string(),
926            }),
927        }
928    }
929}
930
931impl HostType for f64 {
932    fn target_port_type() -> crate::ast::PortType {
933        crate::ast::PortType::F64
934    }
935    fn from_value(v: crate::ast::Value) -> Result<Self, EmbeddingError> {
936        match v {
937            crate::ast::Value::F64(n) => Ok(n),
938            crate::ast::Value::U64(n) => Ok(n as f64),
939            crate::ast::Value::None => Err(EmbeddingError::NonePropagated {
940                accessor: "HostType::<f64>::from_value",
941                source: "<typed-embedding result>".to_string(),
942            }),
943            other => Err(EmbeddingError::TypeMismatch {
944                from_node: "<expression-output>".to_string(),
945                from_type: other.port_type(),
946                to_node: "<host-target>".to_string(),
947                to_type: crate::ast::PortType::F64,
948                source: "<typed-embedding result>".to_string(),
949            }),
950        }
951    }
952}
953
954impl HostType for String {
955    fn target_port_type() -> crate::ast::PortType {
956        crate::ast::PortType::Str
957    }
958    fn from_value(v: crate::ast::Value) -> Result<Self, EmbeddingError> {
959        match v {
960            crate::ast::Value::Str(s) => Ok(s.to_string()),
961            crate::ast::Value::U64(n) => Ok(n.to_string()),
962            crate::ast::Value::F64(n) => Ok(n.to_string()),
963            crate::ast::Value::Bool(b) => Ok(b.to_string()),
964            crate::ast::Value::None => Err(EmbeddingError::NonePropagated {
965                accessor: "HostType::<String>::from_value",
966                source: "<typed-embedding result>".to_string(),
967            }),
968            other => Err(EmbeddingError::TypeMismatch {
969                from_node: "<expression-output>".to_string(),
970                from_type: other.port_type(),
971                to_node: "<host-target>".to_string(),
972                to_type: crate::ast::PortType::Str,
973                source: "<typed-embedding result>".to_string(),
974            }),
975        }
976    }
977}
978
979/// Const-fold the expression and convert the typed `Value`
980/// into the host's requested Rust type. Compile-time type
981/// alignment per expression_engine.md §5.3 + E5 + E7.
982///
983/// `T` must implement [`HostType`]. The expression's output
984/// `PortType` is compared against `T::target_port_type()`;
985/// matching types pass through directly to
986/// [`HostType::from_value`]. Mismatched types invoke the γ-6
987/// **return-path boundary adapter**: the catalog
988/// (`crate::compile::assembly::auto_adapter`) is consulted
989/// to heal the mismatch when possible. Only when no
990/// catalog entry exists for the (output_type, target_type)
991/// pair does this surface return
992/// `EmbeddingError::TypeMismatch`.
993///
994/// Pairs with [`eval_kernel_bound_typed`] for the
995/// kernel-bound (post-interpolation) case.
996pub fn eval_const_expr_typed<T: HostType>(source: &str) -> Result<T, EmbeddingError> {
997    let value = eval_const_expr(source)?;
998    let value_type = value.port_type();
999    let target_type = T::target_port_type();
1000    if value_type == target_type {
1001        return T::from_value(value);
1002    }
1003    // γ-6 return-path adapter: try the catalog before
1004    // surfacing TypeMismatch.
1005    if let Some(adapter) = crate::compile::assembly::auto_adapter(value_type, target_type) {
1006        let inputs = vec![value];
1007        let mut outputs = vec![crate::ast::Value::None];
1008        adapter.eval(&inputs, &mut outputs);
1009        return T::from_value(outputs.remove(0));
1010    }
1011    // No catalog entry — surface as typed error.
1012    Err(EmbeddingError::TypeMismatch {
1013        from_node: "<expression-output>".to_string(),
1014        from_type: value_type,
1015        to_node: "<host-target>".to_string(),
1016        to_type: target_type,
1017        source: source.to_string(),
1018    })
1019}
1020
1021/// Two-step: interpolate placeholders against `kernel`, then
1022/// const-fold + type-convert. The canonical pattern for
1023/// kernel-bound typed embedding per expression_engine.md
1024/// §3.2 + §5.3.
1025pub fn eval_kernel_bound_typed<T: HostType>(
1026    text: &str,
1027    kernel: &crate::kernel::PolydatKernel,
1028) -> Result<T, EmbeddingError> {
1029    let interpolated = crate::kernel::interp::interpolate_via_kernel(text, kernel)?;
1030    eval_const_expr_typed::<T>(&interpolated)
1031}
1032
1033/// Strict-mode variant of [`eval_const_expr_typed`].
1034///
1035/// Rejects type mismatches whose only catalog adapter is
1036/// **lossy** (e.g., `F64 → U64` truncation, `U64 → Bool`
1037/// boolean coercion). Hosts that want guaranteed-lossless
1038/// value passage opt into this surface per
1039/// `expression_engine.md` §5.1.3 (opt-in strict contract).
1040///
1041/// The "lossy" classification is per
1042/// [`is_lossless_adapter`] below; the function returns
1043/// `false` for catalog entries that change the value's
1044/// information content (truncation, narrowing, boolean
1045/// projection).
1046pub fn eval_const_expr_typed_strict<T: HostType>(source: &str) -> Result<T, EmbeddingError> {
1047    let value = eval_const_expr(source)?;
1048    let value_type = value.port_type();
1049    let target_type = T::target_port_type();
1050    if value_type == target_type {
1051        return T::from_value(value);
1052    }
1053    if !is_lossless_adapter(value_type, target_type) {
1054        return Err(EmbeddingError::TypeMismatch {
1055            from_node: "<expression-output>".to_string(),
1056            from_type: value_type,
1057            to_node: "<host-target>".to_string(),
1058            to_type: target_type,
1059            source: source.to_string(),
1060        });
1061    }
1062    if let Some(adapter) = crate::compile::assembly::auto_adapter(value_type, target_type) {
1063        let inputs = vec![value];
1064        let mut outputs = vec![crate::ast::Value::None];
1065        adapter.eval(&inputs, &mut outputs);
1066        return T::from_value(outputs.remove(0));
1067    }
1068    Err(EmbeddingError::TypeMismatch {
1069        from_node: "<expression-output>".to_string(),
1070        from_type: value_type,
1071        to_node: "<host-target>".to_string(),
1072        to_type: target_type,
1073        source: source.to_string(),
1074    })
1075}
1076
1077/// Strict-mode kernel-bound variant. Composes
1078/// [`crate::kernel::interp::interpolate_via_kernel`] with
1079/// [`eval_const_expr_typed_strict`].
1080pub fn eval_kernel_bound_typed_strict<T: HostType>(
1081    text: &str,
1082    kernel: &crate::kernel::PolydatKernel,
1083) -> Result<T, EmbeddingError> {
1084    let interpolated = crate::kernel::interp::interpolate_via_kernel(text, kernel)?;
1085    eval_const_expr_typed_strict::<T>(&interpolated)
1086}
1087
1088/// Classify a catalog adapter as lossless or lossy per
1089/// `expression_engine.md` §5.4.3. Lossless conversions
1090/// preserve value identity (widening numeric types,
1091/// to-string display roundtrips); lossy conversions
1092/// change information content (truncation, boolean
1093/// projection).
1094///
1095/// Strict-mode embedding surfaces use this to gate which
1096/// catalog adapters they'll invoke.
1097pub fn is_lossless_adapter(from: crate::ast::PortType, to: crate::ast::PortType) -> bool {
1098    use crate::ast::PortType;
1099    match (from, to) {
1100        // Numeric widening — lossless.
1101        (PortType::U32, PortType::U64) => true,
1102        (PortType::U32, PortType::F64) => true,
1103        (PortType::I32, PortType::I64) => true,
1104        (PortType::I32, PortType::F64) => true,
1105        (PortType::I64, PortType::F64) => true,
1106        (PortType::F32, PortType::F64) => true,
1107        // To-string conversions — lossless (string is a
1108        // representation of the value).
1109        (_, PortType::Str) => true,
1110        // Bool → U64 is lossless (true→1, false→0; round-trip
1111        // exact).
1112        (PortType::Bool, PortType::U64) => true,
1113        // U64 → Bool is lossy (nonzero → true throws away
1114        // the magnitude).
1115        (PortType::U64, PortType::Bool) => false,
1116        // F64 → U64 is lossy (truncation).
1117        (PortType::F64, PortType::U64) => false,
1118        // U64 → F64 widening is lossless (u64 fits in f64
1119        // mantissa for values < 2^53; values above lose
1120        // precision but f64 is the canonical wider type).
1121        (PortType::U64, PortType::F64) => true,
1122        // Default: unknown → assume lossy (conservative).
1123        _ => false,
1124    }
1125}
1126
1127// ───── End typed embedding surface ─────
1128
1129/// Best-effort extraction of a human message from a
1130/// `catch_unwind` payload. The kernel's `enrich_eval_panic`
1131/// re-raises with a `String` payload, so the common case is one
1132/// line of context-bearing text; fall through to a sentinel for
1133/// non-string payloads (rare — third-party panic with a custom
1134/// payload type).
1135fn panic_payload_message(payload: &Box<dyn std::any::Any + Send>) -> String {
1136    if let Some(s) = payload.downcast_ref::<&str>() {
1137        (*s).to_string()
1138    } else if let Some(s) = payload.downcast_ref::<String>() {
1139        s.clone()
1140    } else {
1141        "<non-string panic payload>".to_string()
1142    }
1143}
1144
1145/// Run one of the assembler's str-to-typed coercion nodes over a string
1146/// literal at compile time, turning the node's panic diagnostic into a
1147/// compile error.
1148fn coerce_string_literal(
1149    node: Box<dyn crate::ast::PolydatNode>,
1150    s: &str,
1151) -> Result<crate::ast::Value, String> {
1152    use crate::ast::Value;
1153    // The coercion node reports a bad value by panicking with its
1154    // diagnostic. Silence the default hook so the diagnostic surfaces
1155    // once, as the compile error, rather than also on stderr.
1156    let hook = std::panic::take_hook();
1157    std::panic::set_hook(Box::new(|_| {}));
1158    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1159        let mut out = [Value::None];
1160        node.eval(&[Value::Str(s.into())], &mut out);
1161        out[0].clone()
1162    }));
1163    std::panic::set_hook(hook);
1164    result.map_err(|e| coercion_panic_message(&e))
1165}
1166
1167fn coercion_panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
1168    if let Some(s) = payload.downcast_ref::<&str>() {
1169        (*s).to_string()
1170    } else if let Some(s) = payload.downcast_ref::<String>() {
1171        s.clone()
1172    } else {
1173        "string value could not be coerced to the declared type".to_string()
1174    }
1175}
1176
1177/// Evaluate an `extern name: type = default` default expression
1178/// to a typed `Value`. Accepts literal forms only (`IntLit`,
1179/// `FloatLit`, `StringLit`, plus identifiers `true`/`false` for
1180/// `bool` ports). A string literal fuses to the declared type
1181/// through the same `StrToU64`/`StrToF64`/`StrToBool` coercions
1182/// the assembler inserts. Non-literal expressions are rejected
1183/// with a clear error; complex defaults belong in a binding, not
1184/// on the extern declaration.
1185fn evaluate_default_expr(
1186    expr: &crate::dsl::ast::Expr,
1187    port_type: crate::ast::PortType,
1188) -> Result<crate::ast::Value, String> {
1189    use crate::ast::{PortType, Value};
1190    use crate::dsl::ast::Expr;
1191    match (expr, port_type) {
1192        (Expr::IntLit(v, _), PortType::U64) => Ok(Value::U64(*v)),
1193        (Expr::IntLit(v, _), PortType::F64) => Ok(Value::F64(*v as f64)),
1194        (Expr::FloatLit(v, _), PortType::F64) => Ok(Value::F64(*v)),
1195        (Expr::StringLit(s, _), PortType::Str) => Ok(Value::Str(s.as_str().into())),
1196        (Expr::Ident(name, _), PortType::Bool) if name == "true" => Ok(Value::Bool(true)),
1197        (Expr::Ident(name, _), PortType::Bool) if name == "false" => Ok(Value::Bool(false)),
1198        // A string literal default fuses to the declared type through the
1199        // same coercions the assembler inserts when a str wire feeds a
1200        // typed port. This is what lets a host inject `name=value` text
1201        // as a program transform and leave typing to the program.
1202        (Expr::StringLit(s, _), PortType::U64) => {
1203            coerce_string_literal(Box::new(crate::library::convert::StrToU64::new()), s)
1204        }
1205        (Expr::StringLit(s, _), PortType::F64) => {
1206            coerce_string_literal(Box::new(crate::library::convert::StrToF64::new()), s)
1207        }
1208        (Expr::StringLit(s, _), PortType::Bool) => {
1209            coerce_string_literal(Box::new(crate::library::convert::StrToBool::new()), s)
1210        }
1211        _ => Err(format!(
1212            "default expression must be a literal of type {port_type:?}; got {expr:?}"
1213        )),
1214    }
1215}
1216
1217/// Infer the surface-level `PortType` of an auto-extern binding's
1218/// RHS for the `const NAME := <expr>` shape. Returns `None` when
1219/// the type can't be determined cheaply from the AST alone —
1220/// the caller falls back to `PortType::Ext` in that case
1221/// (preserving today's behavior at the type-system edge).
1222///
1223/// ## Why this exists
1224///
1225/// Auto-extern slots — the `const NAME := <expr>` form where
1226/// `<expr>` references at least one name — are the
1227/// conditional-shadow fallback path that two-tier lookup uses
1228/// when the const-fold yields None at scope-init.
1229///
1230/// Before this inference: every auto-extern landed at the slot
1231/// boundary as `PortType::Ext`. When an outer scope provided
1232/// a concrete primitive (a U64 iter-var, a Str literal), the
1233/// boundary adapter had to bridge `U64 → Ext` / `Str → Ext` /
1234/// etc — and the type-adapter catalog had no entries for those
1235/// directions, so the runtime warned and passed the value
1236/// through unchanged.
1237///
1238/// `PortType::Ext` is meant for adapter-contributed reflected
1239/// types (CQL UUIDs, timestamps) — `Box<dyn ReflectedValue>`
1240/// — not as a "generic unknown" placeholder. Conflating the
1241/// **scope** axis (`InputKind::IterationExtern` — "this is
1242/// populated by the outer chain") with the **type** axis
1243/// (`PortType` — "what is this value's concrete shape") is the
1244/// design bug this function targets.
1245///
1246/// ## Rules
1247///
1248/// - String literal RHS (including `"{interp}"` templates) →
1249///   `Str`. The DSL parser produces `Expr::StringLit` for both
1250///   plain strings and interpolation patterns; the produced
1251///   value is Str in either case.
1252/// - Integer literal → `U64`.
1253/// - Float literal → `F64`.
1254/// - `true`/`false` → `Bool`.
1255/// - Bare identifier referencing an already-declared input →
1256///   the referenced input's `PortType`. Threading reference
1257///   types lets `const X := other_extern` propagate types
1258///   along the cascade rather than collapsing to Ext.
1259/// - Binary op → the operand types (preferring LHS when both
1260///   resolve and match; both `Add`/`Sub`/`Mul`/`Div`/`Mod`
1261///   preserve operand type). `Pow` always returns F64.
1262/// - Unary negation and bitwise not → operand type.
1263/// - Cast → its target type.
1264/// - `for` producer → `Ext`.
1265/// - Calls to `printf`/`concat`/`format`/`str` → `Str`; to
1266///   `dataset_prebuffer`/`const_handle` → `Handle`.
1267/// - Other function calls, array literals, field access →
1268///   `None` (Ext fallback). These produce types the assembler
1269///   knows only after node attachment; inferring here would
1270///   need a full second pass.
1271///
1272/// ## Tradeoffs not covered
1273///
1274/// String-literal RHS without interpolation is already foldable
1275/// to a concrete value at compile time — the auto-extern slot
1276/// only exists because the binding's RHS has refs. So the
1277/// "Str → ?" path is real and covered.
1278///
1279/// For Ident → declared-input, we look up the input that's
1280/// ALREADY in the assembler. Forward references (an Ident that
1281/// will be declared later in the same pass) return `None`.
1282/// Production sites declare in dependency order, so this
1283/// covers ~all real-world cases; the Ext fallback is correct
1284/// when it doesn't.
1285fn infer_auto_extern_type(
1286    expr: &crate::dsl::ast::Expr,
1287    asm: &crate::compile::assembly::PolydatAssembler,
1288) -> Option<crate::ast::PortType> {
1289    use crate::ast::PortType;
1290    use crate::dsl::ast::{BinOpKind, Expr};
1291    match expr {
1292        Expr::StringLit(_, _) => Some(PortType::Str),
1293        Expr::IntLit(_, _) => Some(PortType::U64),
1294        Expr::FloatLit(_, _) => Some(PortType::F64),
1295        Expr::Ident(name, _) => {
1296            if name == "true" || name == "false" {
1297                Some(PortType::Bool)
1298            } else {
1299                asm.input_type(name)
1300            }
1301        }
1302        Expr::BinOp(lhs, op, rhs) => {
1303            let lhs_t = infer_auto_extern_type(lhs, asm);
1304            let rhs_t = infer_auto_extern_type(rhs, asm);
1305            match op {
1306                BinOpKind::Pow => Some(PortType::F64),
1307                _ => lhs_t.or(rhs_t),
1308            }
1309        }
1310        Expr::UnaryNeg(inner, _) | Expr::UnaryBitNot(inner, _) => {
1311            infer_auto_extern_type(inner, asm)
1312        }
1313        // SRD-84 Part 1b — a cast's type is its target.
1314        Expr::Cast(_, ty, _) => Some(*ty),
1315        // A producer is an Ext-carried comprehension until the Streamer
1316        // port type lands (SRD 113 step 3).
1317        Expr::For(_) => Some(PortType::Ext),
1318        Expr::Call(call) => {
1319            // Each call we recognize here is one fewer
1320            // boundary-adapter `… → Ext` warning at runtime.
1321            // Consult `registry::lookup(f).and_then(|s| s.output_port)`
1322            // first; the function name → output `PortType` table
1323            // below remains only for hand registrations without
1324            // one.
1325            //
1326            // Categories:
1327            //
1328            // - String-producing builtins. The DSL parser also
1329            //   desugars `"hello {x}"` to `printf("hello {}", x)`,
1330            //   so `printf` covers every interpolation-literal
1331            //   workload sugar (e.g. `set: { foo: "{outer}" }`).
1332            // - Handle-producing builtins. `dataset_prebuffer`
1333            //   returns an opaque `Value::Handle` so downstream
1334            //   binds can declare a `Handle`-typed input slot
1335            //   without per-source plumbing.
1336            match call.func.as_str() {
1337                "printf" | "concat" | "format" | "str" => Some(crate::ast::PortType::Str),
1338                "dataset_prebuffer" | "const_handle" => Some(crate::ast::PortType::Handle),
1339                _ => None,
1340            }
1341        }
1342        Expr::ArrayLit(_, _) | Expr::FieldAccess { .. } => None,
1343    }
1344}
1345
1346/// Try to fold a `shared X := <expr>` initializer to a typed
1347/// `(Value, PortType)`. Returns `Some` for literal forms (the
1348/// shareable-cell case); returns `None` for non-literal
1349/// expressions (which keep the ordinary binding shape — the
1350/// `shared` keyword carries metadata only and the binding has
1351/// no cross-scope mutability today).
1352///
1353/// Literal-init shared bindings compile to an input slot +
1354/// passthrough output, so `materialize_wiring_from_outer` can wire a
1355/// `SharedCell` between this slot and inner kernels' matching
1356/// inputs. Non-literal shared bindings retain the
1357/// computation-node shape; full cross-scope mutability for
1358/// those is future work (see scope_model.md §6.2 "Concurrent
1359/// semantics").
1360fn try_fold_shared_init(
1361    expr: &crate::dsl::ast::Expr,
1362) -> Option<(crate::ast::Value, crate::ast::PortType)> {
1363    use crate::ast::{PortType, Value};
1364    use crate::dsl::ast::Expr;
1365    match expr {
1366        Expr::IntLit(v, _) => Some((Value::U64(*v), PortType::U64)),
1367        Expr::FloatLit(v, _) => Some((Value::F64(*v), PortType::F64)),
1368        Expr::StringLit(s, _) => Some((Value::Str(s.as_str().into()), PortType::Str)),
1369        Expr::Ident(name, _) if name == "true" => Some((Value::Bool(true), PortType::Bool)),
1370        Expr::Ident(name, _) if name == "false" => Some((Value::Bool(false), PortType::Bool)),
1371        _ => None,
1372    }
1373}
1374
1375/// Apply the optional `shared name: type := …` annotation
1376/// (scope_model.md §"Type stability") to the folded `(value, type)`:
1377/// the annotation PINS the cell's type for life, winning over literal
1378/// inference. An integer literal widens to an f64-annotated cell (the
1379/// natural authoring, `shared m: f64 := 1`); any other mismatch is a
1380/// compile error at the declaration — not a runtime surprise.
1381fn apply_shared_type_annotation(
1382    name: &str,
1383    annotation: Option<&String>,
1384    init_value: crate::ast::Value,
1385    port_type: crate::ast::PortType,
1386) -> Result<(crate::ast::Value, crate::ast::PortType), String> {
1387    let Some(t) = annotation else {
1388        return Ok((init_value, port_type));
1389    };
1390    let annotated = crate::ast::PortType::from_keyword(t).ok_or_else(|| {
1391        format!(
1392            "shared binding '{name}': unknown type `{t}` in annotation. \
1393             Recognised types: u64, f64, str, bool."
1394        )
1395    })?;
1396    if annotated == port_type {
1397        Ok((init_value, annotated))
1398    } else if port_type == crate::ast::PortType::U64 && annotated == crate::ast::PortType::F64 {
1399        let widened = match init_value {
1400            crate::ast::Value::U64(v) => crate::ast::Value::F64(v as f64),
1401            other => other,
1402        };
1403        Ok((widened, annotated))
1404    } else {
1405        Err(format!(
1406            "shared binding '{name}: {t}': the initializer is {port_type:?}, \
1407             which doesn't match the annotated type. A cell keeps ONE type \
1408             for life — make the initializer match the annotation."
1409        ))
1410    }
1411}
1412
1413/// Extract an integer literal from a positional argument. Returns None
1414/// for named args, non-int-literal positional args, or any other form.
1415fn positional_int_lit(arg: &crate::dsl::ast::Arg) -> Option<u64> {
1416    match arg {
1417        crate::dsl::ast::Arg::Positional(crate::dsl::ast::Expr::IntLit(v, _)) => Some(*v),
1418        _ => None,
1419    }
1420}
1421
1422/// Collect the declared port type of every `input <name>: <type>`
1423/// declaration in the file (bare and tuple forms both lower to one
1424/// `InputDecl` per name). An unrecognised or absent type keyword is
1425/// omitted, leaving the assembler's `U64` default in force.
1426fn declared_input_types(
1427    file: &PolydatFile,
1428) -> std::collections::HashMap<String, crate::ast::PortType> {
1429    let mut types = std::collections::HashMap::new();
1430    for stmt in &file.statements {
1431        if let Statement::InputDecl(d) = stmt
1432            && let Some(ty) = &d.ty
1433            && let Some(pt) = crate::ast::PortType::from_keyword(ty)
1434        {
1435            types.insert(d.name.clone(), pt);
1436        }
1437    }
1438    types
1439}
1440
1441/// Extract a string literal from an optional positional argument.
1442/// Re-exported for cursor-sugar handlers in node modules that
1443/// validate string-literal-only constructor args.
1444pub fn positional_str_lit(arg: Option<&crate::dsl::ast::Arg>) -> Option<String> {
1445    match arg? {
1446        crate::dsl::ast::Arg::Positional(crate::dsl::ast::Expr::StringLit(s, _)) => Some(s.clone()),
1447        _ => None,
1448    }
1449}
1450
1451/// [`compile_ast_with_options`] under the default options.
1452#[deprecated(note = "use compile_ast_with_options")]
1453pub fn compile_ast(file: &PolydatFile) -> Result<PolydatKernel, String> {
1454    compile_ast_with_options(file, "", &CompileOptions::default(), None)
1455}
1456
1457/// [`compile_ast_with_options`] with a source directory alone.
1458#[deprecated(note = "use compile_ast_with_options with CompileOptions { source_dir, .. }")]
1459pub fn compile_ast_with_path(
1460    file: &PolydatFile,
1461    source_dir: Option<&Path>,
1462) -> Result<PolydatKernel, String> {
1463    let options = CompileOptions {
1464        source_dir: source_dir.map(Path::to_path_buf),
1465        ..CompileOptions::default()
1466    };
1467    compile_ast_with_options(file, "", &options, None)
1468}
1469
1470/// [`compile_ast_with_options`] with a source directory and strictness
1471/// alone.
1472#[deprecated(note = "use compile_ast_with_options with CompileOptions { strict, .. }")]
1473pub fn compile_ast_strict(
1474    file: &PolydatFile,
1475    source_dir: Option<&Path>,
1476    strict: bool,
1477) -> Result<PolydatKernel, String> {
1478    let options = CompileOptions {
1479        source_dir: source_dir.map(Path::to_path_buf),
1480        strict,
1481        ..CompileOptions::default()
1482    };
1483    compile_ast_with_options(file, "", &options, None)
1484}
1485
1486/// [`compile_ast_with_options`] with the source directory, library
1487/// directories, outputs to keep, strictness, and context label as
1488/// separate parameters.
1489#[deprecated(note = "use compile_ast_with_options")]
1490pub fn compile_ast_with_libs(
1491    file: &PolydatFile,
1492    source_dir: Option<&Path>,
1493    polydat_lib_paths: Vec<PathBuf>,
1494    required_outputs: &[String],
1495    strict: bool,
1496    context: &str,
1497) -> Result<PolydatKernel, String> {
1498    let options = CompileOptions {
1499        source_dir: source_dir.map(Path::to_path_buf),
1500        lib_paths: polydat_lib_paths,
1501        required_outputs: required_outputs.to_vec(),
1502        strict,
1503        context: context.to_string(),
1504        cursor_limit: None,
1505        ledger: None,
1506    };
1507    compile_ast_with_options(file, "", &options, None)
1508}
1509
1510pub(super) struct Compiler {
1511    pub(super) input_names: Vec<String>,
1512    /// Track all named outputs so we can expose them.
1513    pub(super) all_names: Vec<String>,
1514    /// Auto-generated node counter for desugared intermediates.
1515    pub(super) anon_counter: usize,
1516    /// Directory for module resolution (search for .polydat files).
1517    pub(super) source_dir: Option<PathBuf>,
1518    /// Additional library directories for module resolution.
1519    ///
1520    /// Searched after `source_dir` but before the embedded stdlib.
1521    /// Populated from `CompileOptions::lib_paths` (the binary's
1522    /// `--lib`).
1523    pub(super) polydat_lib_paths: Vec<PathBuf>,
1524    /// Cache of already-resolved module ASTs: module_name → (inputs, statements).
1525    pub(super) module_cache: std::collections::HashMap<String, ResolvedModule>,
1526    /// When true, enforce strict validation.
1527    pub(super) strict: bool,
1528    /// Original source text, attached to compiled programs for diagnostics.
1529    source_text: String,
1530    /// Source schemas collected during compilation.
1531    pub(super) cursor_schemas: Vec<crate::iteration::source::SourceSchema>,
1532    /// Deferred cursor extent resolutions: each entry maps a cursor
1533    /// schema index to the aux output names that, once folded, give
1534    /// the range's start and end values. These are resolved after the
1535    /// kernel compiles by reading `get_constant()` for each name.
1536    pub(super) deferred_extents: Vec<DeferredExtent>,
1537    /// Optional limit applied to all cursors (from `limit` activity param).
1538    pub(super) cursor_limit: Option<u64>,
1539    /// Diagnostic context label.
1540    context_label: String,
1541    /// Module-level pragmas extracted from the source. Drive the
1542    /// assembler's `strict_types` / `strict_values` flags
1543    /// (SRD 15 §"Module-Level Pragmas" + §"Strict Wire Mode").
1544    pub(super) pragmas: super::pragmas::PragmaSet,
1545    /// LHS binding name currently being compiled, if any. Used as a
1546    /// prefix for auto-generated anonymous node names so type-mismatch
1547    /// errors point at the user-level binding (`overscan__anon_3`)
1548    /// instead of an opaque counter (`__anon_14`).
1549    pub(super) current_binding: Option<String>,
1550    /// Tiles lowered so far in this compile, in order, so later tiles
1551    /// can splice earlier ones (SRD 114 §5.5).
1552    pub(super) tiles: Vec<super::ast::TileDef>,
1553    /// Producer bindings seen so far, so tile projections over a
1554    /// producer can type their elements.
1555    pub(super) producers_seen: Vec<super::traversal::Producer>,
1556    /// SRD 114 §4.4: one `TileHoleTyped` event per hole, handed to the
1557    /// compile event log so `explain tiles` can show how each hole was
1558    /// typed and encoded.
1559    pub(super) tile_events: Vec<super::events::CompileEvent>,
1560    /// The compile ledger of the tree being compiled: the root's, handed
1561    /// to every body compiler and to the assembler of every program.
1562    pub(super) ledger: std::sync::Arc<crate::kernel::CompileLedger>,
1563}
1564
1565/// Records a cursor whose `range(...)` bounds reference const
1566/// expressions (e.g., `vector_count("example:default")`) rather than
1567/// integer literals. The expressions are compiled as auxiliary outputs
1568/// and the extent is resolved after kernel compilation by querying the
1569/// constant values.
1570pub(super) struct DeferredExtent {
1571    /// Index into `cursor_schemas` whose extent needs resolution.
1572    pub schema_idx: usize,
1573    /// Name of the aux output that, when folded, gives the start value.
1574    pub start_output: String,
1575    /// Name of the aux output that, when folded, gives the end value.
1576    pub end_output: String,
1577}
1578
1579impl Compiler {
1580    pub(super) fn with_lib_paths(
1581        source_dir: Option<PathBuf>,
1582        polydat_lib_paths: Vec<PathBuf>,
1583        strict: bool,
1584    ) -> Self {
1585        Self {
1586            input_names: Vec::new(),
1587            all_names: Vec::new(),
1588            anon_counter: 0,
1589            source_dir,
1590            polydat_lib_paths,
1591            module_cache: std::collections::HashMap::new(),
1592            strict,
1593            source_text: String::new(),
1594            context_label: "(polydat)".into(),
1595            cursor_schemas: Vec::new(),
1596            deferred_extents: Vec::new(),
1597            cursor_limit: None,
1598            pragmas: super::pragmas::PragmaSet::default(),
1599            current_binding: None,
1600            tiles: Vec::new(),
1601            producers_seen: Vec::new(),
1602            tile_events: Vec::new(),
1603            ledger: crate::kernel::CompileLedger::new(),
1604        }
1605    }
1606
1607    /// Process a source declaration: create input ports for projections,
1608    /// passthrough nodes, and record the schema.
1609    fn process_cursor(
1610        &mut self,
1611        asm: &mut PolydatAssembler,
1612        decl: &crate::dsl::ast::CursorDecl,
1613    ) -> Result<(), String> {
1614        let source_name = &decl.name;
1615
1616        // Cursor-sugar dispatch: any node module can register a
1617        // handler that recognizes a non-`range` constructor (e.g.
1618        // `vectordata_base("ds", "label_00")`) and rewrites it into
1619        // a synthetic `range(...)` plus a list of aux bindings to
1620        // emit after input ports are wired. The core stays
1621        // generic — nothing here knows that vectordata exists.
1622        // See `dsl::cursor_sugar` for the registry mechanism.
1623        let sugar = crate::dsl::cursor_sugar::dispatch(source_name, &decl.constructor)?;
1624        let effective_constructor = match &sugar {
1625            Some(s) => s.effective_constructor.clone(),
1626            None => decl.constructor.clone(),
1627        };
1628
1629        // All sources get an "ordinal" projection.
1630        let mut projections = vec![("ordinal".to_string(), crate::ast::PortType::U64)];
1631
1632        // Determine extent from constructor args. Three cases per arg:
1633        //   1. Integer literal → use directly
1634        //   2. Other const-foldable expression (e.g. `vector_count("...")`)
1635        //      → compile as an aux output and resolve after kernel compiles
1636        //   3. Arg references runtime state → no extent available
1637        //
1638        // Immediate-literal cases produce a concrete extent here.
1639        // Deferred cases push a DeferredExtent record; the outer compile
1640        // routine reads the folded values after compilation and updates
1641        // the schema's extent in place.
1642        let mut deferred: Option<(Option<u64>, String, Option<u64>, String)> = None;
1643        let mut cursor_kind_for_decl: crate::iteration::source::CursorKind =
1644            crate::iteration::source::CursorKind::Range;
1645        let extent = match &effective_constructor {
1646            // ── until_*(...) — extending cursors ────────────────
1647            // Recognise every cursor function whose constructor
1648            // declares an extending policy. The shape of each is:
1649            //   until_FAMILY(base, ...policy_args[, delta])
1650            // where `base` is the initial extent / pass size and
1651            // policy_args carry the family's stop-condition
1652            // parameters. An optional final `delta` overrides the
1653            // extension step size (defaults to `base`).
1654            //
1655            // Recognised families:
1656            //   until_elapsed(base, min_ms[, delta])
1657            //   until_passes(base, min_passes[, delta])
1658            //   until_count(base, min_count[, delta])
1659            //   until_elapsed_and_passes(base, min_ms, min_passes[, delta])
1660            //   until_elapsed_or_passes(base, min_ms, min_passes[, delta])
1661            //
1662            // Common shape: emit `base` as the cursor's `end` aux
1663            // output, `start` as a literal 0, and each policy arg
1664            // as a named aux output the runtime pulls at phase
1665            // setup. The CursorKind variant carries the output
1666            // names so the executor knows how to build the policy.
1667            crate::dsl::ast::Expr::Call(call)
1668                if matches!(
1669                    call.func.as_str(),
1670                    "until_elapsed"
1671                        | "until_passes"
1672                        | "until_count"
1673                        | "until_elapsed_and_passes"
1674                        | "until_elapsed_or_passes"
1675                ) =>
1676            {
1677                let family = call.func.as_str();
1678                let expected = match family {
1679                    "until_elapsed" | "until_passes" | "until_count" => (2usize, 3usize),
1680                    "until_elapsed_and_passes" | "until_elapsed_or_passes" => (3, 4),
1681                    _ => unreachable!(),
1682                };
1683                let n = call.args.len();
1684                if n < expected.0 || n > expected.1 {
1685                    return Err(format!(
1686                        "cursor '{source_name}': `{family}` takes {}-{} args, got {n}",
1687                        expected.0, expected.1,
1688                    ));
1689                }
1690                // Common: base, start, end aux outputs.
1691                let base_literal = positional_int_lit(&call.args[0]);
1692                let base_name = format!("__cursor_extent_{source_name}_end");
1693                let start_name = format!("__cursor_extent_{source_name}_start");
1694                let _ = self.compile_binding(
1695                    asm,
1696                    std::slice::from_ref(&start_name),
1697                    &crate::dsl::ast::Expr::IntLit(0, decl.span),
1698                );
1699                if let crate::dsl::ast::Arg::Positional(expr) = &call.args[0] {
1700                    self.compile_binding(asm, std::slice::from_ref(&base_name), expr)
1701                        .map_err(|e| {
1702                            format!("cursor '{source_name}': failed to compile {family} base: {e}")
1703                        })?;
1704                }
1705                // Helper closure: compile a positional arg as a
1706                // named aux output. Returns the name on success.
1707                let mut compile_aux = |idx: usize, suffix: &str| -> Result<String, String> {
1708                    let out_name = format!("__cursor_{suffix}_{source_name}");
1709                    if let crate::dsl::ast::Arg::Positional(expr) = &call.args[idx] {
1710                        self.compile_binding(asm, std::slice::from_ref(&out_name), expr)
1711                            .map_err(|e| {
1712                                format!(
1713                                    "cursor '{source_name}': failed to compile \
1714                                 {family} arg {idx}: {e}"
1715                                )
1716                            })?;
1717                    }
1718                    Ok(out_name)
1719                };
1720                // Family-specific arg layout.
1721                cursor_kind_for_decl = match family {
1722                    "until_elapsed" => {
1723                        let min_ms_name = compile_aux(1, "min_ms")?;
1724                        let delta_output = if n == 3 {
1725                            Some(compile_aux(2, "delta")?)
1726                        } else {
1727                            None
1728                        };
1729                        crate::iteration::source::CursorKind::ExtendingTimed {
1730                            min_ms_output: min_ms_name,
1731                            delta_output,
1732                        }
1733                    }
1734                    "until_passes" => {
1735                        let min_passes_name = compile_aux(1, "min_passes")?;
1736                        let delta_output = if n == 3 {
1737                            Some(compile_aux(2, "delta")?)
1738                        } else {
1739                            None
1740                        };
1741                        crate::iteration::source::CursorKind::ExtendingPasses {
1742                            min_passes_output: min_passes_name,
1743                            delta_output,
1744                        }
1745                    }
1746                    "until_count" => {
1747                        let min_count_name = compile_aux(1, "min_count")?;
1748                        let delta_output = if n == 3 {
1749                            Some(compile_aux(2, "delta")?)
1750                        } else {
1751                            None
1752                        };
1753                        crate::iteration::source::CursorKind::ExtendingCount {
1754                            min_count_output: min_count_name,
1755                            delta_output,
1756                        }
1757                    }
1758                    "until_elapsed_and_passes" => {
1759                        let min_ms_name = compile_aux(1, "min_ms")?;
1760                        let min_passes_name = compile_aux(2, "min_passes")?;
1761                        let delta_output = if n == 4 {
1762                            Some(compile_aux(3, "delta")?)
1763                        } else {
1764                            None
1765                        };
1766                        crate::iteration::source::CursorKind::ExtendingElapsedAndPasses {
1767                            min_ms_output: min_ms_name,
1768                            min_passes_output: min_passes_name,
1769                            delta_output,
1770                        }
1771                    }
1772                    "until_elapsed_or_passes" => {
1773                        let min_ms_name = compile_aux(1, "min_ms")?;
1774                        let min_passes_name = compile_aux(2, "min_passes")?;
1775                        let delta_output = if n == 4 {
1776                            Some(compile_aux(3, "delta")?)
1777                        } else {
1778                            None
1779                        };
1780                        crate::iteration::source::CursorKind::ExtendingElapsedOrPasses {
1781                            min_ms_output: min_ms_name,
1782                            min_passes_output: min_passes_name,
1783                            delta_output,
1784                        }
1785                    }
1786                    _ => unreachable!(),
1787                };
1788                deferred = Some((Some(0), start_name, base_literal, base_name));
1789                base_literal
1790            }
1791            crate::dsl::ast::Expr::Call(call) if call.func == "range" && call.args.len() >= 2 => {
1792                let start_literal = positional_int_lit(&call.args[0]);
1793                let end_literal = positional_int_lit(&call.args[1]);
1794
1795                match (start_literal, end_literal) {
1796                    // Both literal — compute directly. We also emit
1797                    // the start/end as named final bindings so the
1798                    // comprehension `all(<cursor>)` form (SRD-18c)
1799                    // can resolve them uniformly with the deferred
1800                    // (non-literal) case below.
1801                    (Some(s), Some(e)) => {
1802                        let start_name = format!("__cursor_extent_{source_name}_start");
1803                        let end_name = format!("__cursor_extent_{source_name}_end");
1804                        let s_lit = crate::dsl::ast::Expr::IntLit(s, decl.span);
1805                        let e_lit = crate::dsl::ast::Expr::IntLit(e, decl.span);
1806                        let _ = self.compile_binding(asm, &[start_name], &s_lit);
1807                        let _ = self.compile_binding(asm, &[end_name], &e_lit);
1808                        Some(e.saturating_sub(s))
1809                    }
1810                    // At least one non-literal — compile as aux outputs.
1811                    _ => {
1812                        let start_name = format!("__cursor_extent_{source_name}_start");
1813                        let end_name = format!("__cursor_extent_{source_name}_end");
1814                        // Compile each arg as a named auxiliary output. Errors
1815                        // are returned so the user sees them — silently
1816                        // dropping them would leave extent=None and produce
1817                        // a phase that runs zero cycles with no explanation.
1818                        if let crate::dsl::ast::Arg::Positional(expr) = &call.args[0] {
1819                            self.compile_binding(asm, std::slice::from_ref(&start_name), expr)
1820                                .map_err(|e| {
1821                                    format!(
1822                                        "cursor '{source_name}': failed to compile range start: {e}"
1823                                    )
1824                                })?;
1825                        }
1826                        if let crate::dsl::ast::Arg::Positional(expr) = &call.args[1] {
1827                            self.compile_binding(asm, std::slice::from_ref(&end_name), expr)
1828                                .map_err(|e| {
1829                                    format!(
1830                                        "cursor '{source_name}': failed to compile range end: {e}"
1831                                    )
1832                                })?;
1833                        }
1834                        deferred = Some((start_literal, start_name, end_literal, end_name));
1835                        None
1836                    }
1837                }
1838            }
1839            _ => None,
1840        };
1841
1842        // Create input ports and passthrough nodes for each projection.
1843        for (field_name, port_type) in &projections {
1844            let input_name = format!("{source_name}__{field_name}");
1845            let default_value = match port_type {
1846                crate::ast::PortType::U64 => crate::ast::Value::U64(0),
1847                crate::ast::PortType::F64 => crate::ast::Value::F64(0.0),
1848                _ => crate::ast::Value::None,
1849            };
1850
1851            // Cursor projection slots are written by cursor advance
1852            // every cycle — dynamic for init-contract purposes.
1853            asm.add_input(
1854                &input_name,
1855                default_value,
1856                *port_type,
1857                crate::kernel::InputKind::ExternalWrite,
1858            );
1859            self.input_names.push(input_name.clone());
1860
1861            let passthrough = Box::new(crate::library::identity::PortPassthrough::new(
1862                &input_name,
1863                *port_type,
1864            ));
1865            let node_name = format!("{source_name}__{field_name}");
1866            asm.add_node(&node_name, passthrough, vec![WireRef::input(&input_name)]);
1867            asm.add_output(&node_name, WireRef::node(&node_name));
1868        }
1869
1870        // Apply any aux bindings the sugar handler asked for.
1871        // Bindings whose `projection` is `Some` are also published
1872        // as cursor projections — both pinned on the schema and
1873        // exposed as kernel outputs the runtime can read.
1874        if let Some(sugar) = sugar {
1875            for aux in sugar.aux_bindings {
1876                self.compile_binding(asm, std::slice::from_ref(&aux.name), &aux.value)
1877                    .map_err(|e| {
1878                        format!(
1879                            "cursor '{source_name}': failed to compile aux binding '{}': {e}",
1880                            aux.name,
1881                        )
1882                    })?;
1883                if let Some((field, port_type)) = aux.projection {
1884                    projections.push((field, port_type));
1885                    asm.add_output(&aux.name, WireRef::node(&aux.name));
1886                }
1887            }
1888        }
1889
1890        // If a limit is set, insert a limit() node that shadows the cursor wire.
1891        // The limit node is a visible, documented passthrough that clamps extent.
1892        let effective_extent = if let Some(limit_val) = self.cursor_limit {
1893            let limit_node_name = format!("{source_name}__limit");
1894            let ordinal_wire = format!("{source_name}__ordinal");
1895            asm.add_node(
1896                &limit_node_name,
1897                Box::new(crate::library::context::CursorLimit::new(limit_val)),
1898                vec![WireRef::node(&ordinal_wire)],
1899            );
1900            // Shadow the ordinal output with the limited version
1901            asm.add_output(&ordinal_wire, WireRef::node(&limit_node_name));
1902
1903            // Clamp extent
1904            extent.map(|e| e.min(limit_val)).or(Some(limit_val))
1905        } else {
1906            extent
1907        };
1908
1909        let schema_idx = self.cursor_schemas.len();
1910        let extent_outputs = deferred
1911            .as_ref()
1912            .map(|(_, start, _, end)| (start.clone(), end.clone()));
1913
1914        // SRD 71: if the cursor decl carries an `over <expr>`
1915        // clause, set up two pieces of plumbing:
1916        //
1917        // 1. An auxiliary output `<source>__over_raw` carrying
1918        //    the raw expression value (typically a string spec
1919        //    or a workload-param-typed value). The executor
1920        //    pulls this at phase setup to determine the
1921        //    narrowing range.
1922        //
1923        // 2. An input slot + passthrough output `<source>__cursor`
1924        //    of type `Ext` — this is the field-access wire that
1925        //    workload authors reference as `<source>.cursor`. At
1926        //    phase setup the executor resolves the raw value to
1927        //    a concrete `Partition` and writes it into this slot,
1928        //    so downstream nodes (`mod_in`, `cardinality`, etc.)
1929        //    can consume it as a `Partition`-typed wire.
1930        let mut partitions: Option<Vec<crate::iteration::cursor_partition::Partition>> = None;
1931        let partition_output = if let Some(over_expr) = decl.over.as_ref() {
1932            let raw_name = format!("__cursor_{source_name}_over_raw");
1933            self.compile_binding(asm, std::slice::from_ref(&raw_name), over_expr)
1934                .map_err(|e| {
1935                    format!("cursor '{source_name}': failed to compile `over` expression: {e}")
1936                })?;
1937            // A literal spec over a known extent resolves now
1938            // (engine_parity.md, step 3): the schema carries the
1939            // partitions for the host, and a clause that denotes
1940            // exactly one partition seeds the cursor's slots, so the
1941            // program runs on every engine with no host call. A clause
1942            // that denotes several leaves the choice to the host or
1943            // the traversal runtime, as before.
1944            if let (crate::dsl::ast::Expr::StringLit(spec, _), Some(extent)) =
1945                (over_expr, effective_extent)
1946            {
1947                let open = !matches!(
1948                    cursor_kind_for_decl,
1949                    crate::iteration::source::CursorKind::Range
1950                );
1951                let parts = crate::iteration::cursor_partition::resolve_over(
1952                    &crate::ast::Value::Str(spec.as_str().into()),
1953                    extent,
1954                    open,
1955                )
1956                .map_err(|e| format!("cursor '{source_name}': `over \"{spec}\"`: {e}"))?;
1957                partitions = Some(parts);
1958            }
1959            let seeded: Option<crate::iteration::cursor_partition::Partition> =
1960                partitions.as_ref().filter(|p| p.len() == 1).map(|p| p[0]);
1961            // Allocate the resolved-Partition input slot. Its default
1962            // is the one partition the clause denotes, or `Value::None`
1963            // until the host or the traversal runtime narrows it.
1964            let cursor_input_name = format!("{source_name}__cursor");
1965            asm.add_input(
1966                &cursor_input_name,
1967                seeded.map_or(crate::ast::Value::None, crate::ast::Value::from_partition),
1968                crate::ast::PortType::Ext,
1969                crate::kernel::InputKind::ExternalWrite,
1970            );
1971            self.input_names.push(cursor_input_name.clone());
1972            let passthrough = Box::new(crate::library::identity::PortPassthrough::new(
1973                &cursor_input_name,
1974                crate::ast::PortType::Ext,
1975            ));
1976            asm.add_node(
1977                &cursor_input_name,
1978                passthrough,
1979                vec![WireRef::input(&cursor_input_name)],
1980            );
1981            asm.add_output(&cursor_input_name, WireRef::node(&cursor_input_name));
1982            // SRD 71 §"Cursor metadata wires": scalar projections
1983            // of the resolved partition, as plain typed slots —
1984            // `<source>.cursor.idx` and friends parse as chained
1985            // field access and flatten onto these wires. The
1986            // executor writes them alongside the Ext slot at
1987            // phase setup; defaults here cover the no-narrowing
1988            // case (idx 0, count 1, full-extent pcts; the
1989            // ordinal pair is patched by the executor once the
1990            // cursor's extent is known).
1991            use crate::ast::{PortType, Value};
1992            let scalar_slots: [(&str, Value, PortType); 6] = match seeded {
1993                Some(p) => [
1994                    ("idx", Value::U64(p.idx), PortType::U64),
1995                    ("partition_count", Value::U64(p.count.max(1)), PortType::U64),
1996                    ("start_pct", Value::F64(p.start_pct), PortType::F64),
1997                    ("end_pct", Value::F64(p.end_pct), PortType::F64),
1998                    ("start_ordinal", Value::U64(p.start_ord), PortType::U64),
1999                    ("end_ordinal", Value::U64(p.end_ord), PortType::U64),
2000                ],
2001                None => [
2002                    ("idx", Value::U64(0), PortType::U64),
2003                    ("partition_count", Value::U64(1), PortType::U64),
2004                    ("start_pct", Value::F64(0.0), PortType::F64),
2005                    ("end_pct", Value::F64(100.0), PortType::F64),
2006                    ("start_ordinal", Value::U64(0), PortType::U64),
2007                    ("end_ordinal", Value::U64(0), PortType::U64),
2008                ],
2009            };
2010            for (field, default, port_type) in scalar_slots {
2011                let slot = format!("{cursor_input_name}__{field}");
2012                asm.add_input(
2013                    &slot,
2014                    default,
2015                    port_type,
2016                    crate::kernel::InputKind::ExternalWrite,
2017                );
2018                self.input_names.push(slot.clone());
2019                let pass = Box::new(crate::library::identity::PortPassthrough::new(
2020                    &slot, port_type,
2021                ));
2022                asm.add_node(&slot, pass, vec![WireRef::input(&slot)]);
2023                asm.add_output(&slot, WireRef::node(&slot));
2024            }
2025            Some(raw_name)
2026        } else {
2027            None
2028        };
2029
2030        self.cursor_schemas
2031            .push(crate::iteration::source::SourceSchema {
2032                name: source_name.clone(),
2033                projections,
2034                extent: effective_extent,
2035                extent_outputs,
2036                extent_limit: self.cursor_limit,
2037                cursor_kind: cursor_kind_for_decl.clone(),
2038                partition_output,
2039                partitions,
2040            });
2041
2042        // Record deferred extent resolution if the range bounds are not
2043        // both literals. Post-compile, the outer compile routine will
2044        // query the aux outputs' folded constants and update this
2045        // schema's extent in place.
2046        if let Some((_start_lit, start_output, _end_lit, end_output)) = deferred {
2047            self.deferred_extents.push(DeferredExtent {
2048                schema_idx,
2049                start_output,
2050                end_output,
2051            });
2052        }
2053        Ok(())
2054    }
2055
2056    /// The interpreter's kernel of `file` as its concrete type: the one
2057    /// compile path with the interpreter's build, keeping the outputs in
2058    /// `filter` (every output when `None`) and recording events in `log`.
2059    /// The parent's AST is retained as program metadata for the subscope
2060    /// synthesizer.
2061    pub(super) fn compile_interpreter(
2062        &mut self,
2063        file: &PolydatFile,
2064        filter: Option<&[String]>,
2065        log: Option<&mut super::events::CompileEventLog>,
2066        cones: crate::JitMode,
2067    ) -> Result<PolydatKernel, crate::KernelError> {
2068        let (mut kernel, parent) = compile_file_with(self, file, filter, log, |mut asm, log| {
2069            asm.set_jit_mode(cones);
2070            asm.compile_with_log(log)
2071                .map_err(crate::KernelError::Assembly)
2072        })?;
2073        kernel.set_ast(std::sync::Arc::new(parent));
2074        Ok(kernel)
2075    }
2076
2077    /// The output type of a generator expression used as a comprehension
2078    /// source (SRD 113 §3.3): compile `__probe := <expr>` on its own and
2079    /// read the port type. Shared by `for` bodies and tile projections.
2080    pub(super) fn probe_element_type(&self, expr: &str) -> Result<crate::ast::PortType, String> {
2081        let src = format!("input cycle: u64\n__probe := {expr}\n");
2082        let tokens = lexer::lex(&src)?;
2083        let ast = parser::parse(tokens)?;
2084        let mut probe_compiler = Compiler::with_lib_paths(
2085            self.source_dir.clone(),
2086            self.polydat_lib_paths.clone(),
2087            false,
2088        );
2089        probe_compiler.source_text = src.clone();
2090        probe_compiler.context_label = format!("{} (element probe)", self.context_label);
2091        probe_compiler.module_cache = self.module_cache.clone();
2092        let k = probe_compiler
2093            .compile_interpreter(&ast, None, None, crate::JitMode::Auto)
2094            .map_err(|e| e.to_string())?;
2095        k.program()
2096            .output_port_type("__probe")
2097            .ok_or_else(|| "probe produced no output".to_string())
2098    }
2099
2100    /// Lower each `for` statement's body to a child program, typed from
2101    /// its comprehension and the parent's manifest (SRD 113 §3.3, §4).
2102    fn compile_traversals(
2103        &mut self,
2104        for_stmts: &[super::ast::ForStmt],
2105        producers: &[super::traversal::Producer],
2106        type_of: &dyn Fn(&str) -> Option<crate::ast::PortType>,
2107    ) -> Result<Vec<super::traversal::Traversal>, String> {
2108        use super::traversal::{Traversal, child_file, element_types, resolve_source};
2109        let mut out = Vec::with_capacity(for_stmts.len());
2110        for f in for_stmts {
2111            let comprehension = resolve_source(&f.source, producers)?.clone();
2112            let mut probe = |expr: &str| self.probe_element_type(expr);
2113            let elements = element_types(&comprehension, &mut probe).map_err(|e| {
2114                format!(
2115                    "`for {}` at line {}, col {}: {e}",
2116                    f.source.text, f.span.line, f.span.col
2117                )
2118            })?;
2119            let (child, cascade) = child_file(f, &comprehension, &elements, type_of)?;
2120            let mut child_compiler = Compiler::with_lib_paths(
2121                self.source_dir.clone(),
2122                self.polydat_lib_paths.clone(),
2123                self.strict,
2124            );
2125            // The body sees every module the parent resolved, its own
2126            // definitions included, wherever it compiles.
2127            child_compiler.module_cache = self.module_cache.clone();
2128            // The body's program is one of the tree's.
2129            child_compiler.ledger = self.ledger.clone();
2130            child_compiler.source_text = super::pprint::pp_file(&child);
2131            child_compiler.context_label = format!(
2132                "{} :: for {} (line {}, col {})",
2133                self.context_label, f.source.text, f.span.line, f.span.col
2134            );
2135            child_compiler.cursor_limit = self.cursor_limit;
2136            child_compiler.pragmas = self.pragmas.clone();
2137            let child_kernel = child_compiler
2138                .compile_interpreter(&child, None, None, crate::JitMode::Auto)
2139                .map_err(|e| {
2140                    format!(
2141                        "`for {}` at line {}, col {}: body failed to compile: {e}",
2142                        f.source.text, f.span.line, f.span.col
2143                    )
2144                })?;
2145            self.tile_events.append(&mut child_compiler.tile_events);
2146            let body = super::traversal::BodySource {
2147                file: child,
2148                source_text: child_compiler.source_text.clone(),
2149                source_dir: self.source_dir.clone(),
2150                lib_paths: self.polydat_lib_paths.clone(),
2151                strict: self.strict,
2152                context_label: child_compiler.context_label.clone(),
2153                cursor_limit: self.cursor_limit,
2154                pragmas: self.pragmas.clone(),
2155                modules: self.module_cache.clone(),
2156                programs: std::sync::Mutex::new(std::collections::HashMap::new()),
2157                ledger: self.ledger.clone(),
2158            };
2159            out.push(Traversal {
2160                span: f.span,
2161                source_text: f.source.text.clone(),
2162                comprehension,
2163                elements,
2164                cascade,
2165                program: child_kernel.into_program(),
2166                body: std::sync::Arc::new(body),
2167            });
2168        }
2169        Ok(out)
2170    }
2171
2172    /// Compile a traversal body on `engine` (engine parity, step 8): the
2173    /// same child file and compiler settings the parent used for the
2174    /// interpreter's program, through the assembler, its own `for`
2175    /// statements and producers included.
2176    pub(super) fn compile_body_on(
2177        body: &super::traversal::BodySource,
2178        engine: crate::Engine,
2179    ) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2180        let _data_base = body.source_dir.as_deref().map(DataBaseDirGuard::set);
2181        let mut compiler =
2182            Compiler::with_lib_paths(body.source_dir.clone(), body.lib_paths.clone(), body.strict);
2183        compiler.source_text = body.source_text.clone();
2184        compiler.context_label = body.context_label.clone();
2185        compiler.cursor_limit = body.cursor_limit;
2186        compiler.pragmas = body.pragmas.clone();
2187        compiler.module_cache = body.modules.clone();
2188        compiler.ledger = body.ledger.clone();
2189        compile_file_on_engine(&mut compiler, &body.file, None, engine, None)
2190    }
2191
2192    /// Assemble the parent program: inputs and their passthroughs,
2193    /// externs, bindings, cursors, tiles, and the output set. Every
2194    /// entry point builds its assembler here, so a kernel and an
2195    /// assembler from the same source are the same graph.
2196    fn assemble_parent(
2197        &mut self,
2198        file: &PolydatFile,
2199        required_outputs: Option<&[String]>,
2200    ) -> Result<PolydatAssembler, String> {
2201        self.register_local_modules(file);
2202        // First pass: collect explicit `input` declarations, dedup by name.
2203        for stmt in &file.statements {
2204            if let Statement::InputDecl(d) = stmt
2205                && !self.input_names.iter().any(|n| n == &d.name)
2206            {
2207                self.input_names.push(d.name.clone());
2208            }
2209        }
2210
2211        // Input declaration check: error in strict mode (modules, .polydat files)
2212        if self.input_names.is_empty() && self.strict {
2213            return Err(
2214                "strict mode: no `input` declaration — add `input <name>: <type>` \
2215                 (or the tuple form `input (a: u64, b: f64)`) to declare graph \
2216                 inputs explicitly"
2217                    .into(),
2218            );
2219        }
2220
2221        // If no explicit inputs, infer from unbound references
2222        if self.input_names.is_empty() {
2223            let defined: HashSet<String> = file
2224                .statements
2225                .iter()
2226                .flat_map(|stmt| match stmt {
2227                    Statement::Binding(b) => b.targets.clone(),
2228                    Statement::ModuleDef(m) => vec![m.name.clone()],
2229                    Statement::ExternPort(p) => vec![p.name.clone()],
2230                    Statement::InputDecl(_) => vec![],
2231                    Statement::Cursor(_) => vec![],
2232                    Statement::Pragma { .. } => vec![],
2233                    Statement::For(_) => vec![],
2234                    Statement::Tile(t) => vec![t.name.clone()],
2235                })
2236                .collect();
2237
2238            let mut referenced: HashSet<String> = HashSet::new();
2239            for stmt in &file.statements {
2240                let expr = match stmt {
2241                    Statement::InputDecl(_)
2242                    | Statement::ModuleDef(_)
2243                    | Statement::ExternPort(_)
2244                    | Statement::Cursor(_)
2245                    | Statement::Pragma { .. }
2246                    | Statement::For(_)
2247                    | Statement::Tile(_) => continue,
2248                    Statement::Binding(b) => &b.value,
2249                };
2250                collect_references(expr, &mut referenced);
2251            }
2252
2253            let mut inferred: Vec<String> = referenced
2254                .into_iter()
2255                .filter(|name| !defined.contains(name))
2256                .collect();
2257            inferred.sort();
2258            self.input_names = inferred;
2259        }
2260
2261        // Zero inferred inputs means all bindings are constants — valid.
2262
2263        let mut asm = PolydatAssembler::new(self.input_names.clone());
2264        asm.ledger = self.ledger.clone();
2265        for (name, ty) in declared_input_types(file) {
2266            asm.set_input_type(&name, ty);
2267        }
2268
2269        // Auto-expose every declared input as a passthrough output
2270        // (parity with `extern`). See `compile()` for the same wiring.
2271        for input_name in self.input_names.clone() {
2272            // Mirror the input's (now correctly-typed) slot so the
2273            // auto-exposed output carries the declared type, not U64.
2274            let port_type = asm
2275                .input_type(&input_name)
2276                .unwrap_or(crate::ast::PortType::U64);
2277            let passthrough = Box::new(crate::library::identity::PortPassthrough::new(
2278                &input_name,
2279                port_type,
2280            ));
2281            let passthrough_name = format!("__port_{input_name}");
2282            asm.add_node(
2283                &passthrough_name,
2284                passthrough,
2285                vec![WireRef::input(&input_name)],
2286            );
2287            asm.add_output(&input_name, WireRef::node(&passthrough_name));
2288        }
2289
2290        // Second pass: process all bindings into the assembler
2291        for stmt in &file.statements {
2292            match stmt {
2293                Statement::InputDecl(_) => {}
2294                Statement::Binding(b) => {
2295                    // `shared X := <literal>` compiles to an input
2296                    // slot + passthrough output, so
2297                    // `materialize_wiring_from_outer` can wire a
2298                    // `SharedCell` for cross-scope mutability (SRD-16
2299                    // §"Mutability Rules: Shared Mutable"). Non-literal
2300                    // inits and tuple-target shared bindings are
2301                    // rejected on every entry point: the cell needs a
2302                    // single, well-defined initial value, and a
2303                    // computation-shaped RHS doesn't have one. See
2304                    // SRD-16 §"Non-literal `shared` initializers".
2305                    if b.modifier == BindingModifier::SHARED {
2306                        if b.targets.len() != 1 {
2307                            return Err(format!(
2308                                "shared binding must be single-target, not tuple unpack \
2309                                 ({}). Declare each target separately if a shared cell \
2310                                 is intended.",
2311                                b.targets.join(", "),
2312                            ));
2313                        }
2314                        let name = &b.targets[0];
2315                        let (init_value, port_type) =
2316                            try_fold_shared_init(&b.value).ok_or_else(|| {
2317                                format!(
2318                                    "shared binding '{name}' requires a literal initial value \
2319                                 (number, string, true/false). Computed and cycle-dependent \
2320                                 expressions don't have a well-defined single init for the \
2321                                 shared cell. See SRD-16 §\"Non-literal `shared` initializers\"."
2322                                )
2323                            })?;
2324                        let (init_value, port_type) = apply_shared_type_annotation(
2325                            name,
2326                            b.type_annotation.as_ref(),
2327                            init_value,
2328                            port_type,
2329                        )?;
2330                        asm.add_input(
2331                            name,
2332                            init_value,
2333                            port_type,
2334                            crate::kernel::InputKind::ExternalWrite,
2335                        );
2336                        self.input_names.push(name.clone());
2337                        let passthrough = Box::new(crate::library::identity::PortPassthrough::new(
2338                            name, port_type,
2339                        ));
2340                        let passthrough_name = format!("__port_{name}");
2341                        asm.add_node(&passthrough_name, passthrough, vec![WireRef::input(name)]);
2342                        asm.add_output(name, WireRef::node(&passthrough_name));
2343                        asm.set_output_modifier(name, BindingModifier::SHARED);
2344                        continue;
2345                    }
2346                    self.compile_binding(&mut asm, &b.targets, &b.value)?;
2347                    if b.modifier != BindingModifier::NONE {
2348                        for target in &b.targets {
2349                            asm.set_output_modifier(target, b.modifier);
2350                        }
2351                    }
2352                    // SRD-74 P2: auto-extern const targets whose RHS
2353                    // references at least one name. See the parallel
2354                    // block in `compile()` for rationale — makes
2355                    // `const NAME := <expr>` a conditional shadow when
2356                    // its RHS could fold to None, while leaving
2357                    // pure-literal consts (SRD-13f Gate 2 iter-vars)
2358                    // alone.
2359                    if b.modifier.is_const() {
2360                        let rhs_has_refs = {
2361                            let mut refs = std::collections::HashSet::new();
2362                            crate::dsl::validate::collect_references(&b.value, &mut refs);
2363                            !refs.is_empty()
2364                        };
2365                        for target in &b.targets {
2366                            asm.mark_const_output(target);
2367                            if rhs_has_refs && !asm.input_names().contains(&target.as_str()) {
2368                                // Infer the slot's `PortType` from the
2369                                // RHS surface shape so the auto-extern
2370                                // lands at the boundary with its
2371                                // actual type (Str for string-template
2372                                // / interpolation forms, U64 / F64 /
2373                                // Bool for literals + literal-bearing
2374                                // arithmetic) rather than the legacy
2375                                // `Ext` catchall — the conflation the
2376                                // type-axis-vs-scope-axis design fix
2377                                // removes. `Ext` survives as the
2378                                // fallback for shapes we can't cheaply
2379                                // resolve (function calls, array
2380                                // literals, field access), so the
2381                                // boundary adapter's catalog miss is
2382                                // narrower and the typed paths bypass
2383                                // the warning entirely.
2384                                // Two-step type discovery for the
2385                                // auto-extern slot:
2386                                //
2387                                // 1. The binding's RHS was just
2388                                //    compiled (`compile_binding`
2389                                //    above) — its output is now a
2390                                //    node in the assembler. Query
2391                                //    that node's declared output
2392                                //    `PortType` directly. This
2393                                //    covers every shape the
2394                                //    inferrer's surface-AST pass
2395                                //    can't see through: `select_str`,
2396                                //    `str_concat`, `format_u64`,
2397                                //    `query_count`, arbitrary nested
2398                                //    function calls — all already
2399                                //    have nodes in the assembler with
2400                                //    fully-resolved `NodeMeta` ports.
2401                                // 2. If the assembler doesn't have an
2402                                //    answer (rare — should only
2403                                //    happen for shapes where
2404                                //    `compile_binding` didn't
2405                                //    register a node under the
2406                                //    target name), fall back to the
2407                                //    surface-AST inferrer.
2408                                // 3. If both fail, `PortType::Ext`
2409                                //    remains as the last-resort
2410                                //    fallback — every catalog miss
2411                                //    at runtime points back to a
2412                                //    real registry gap.
2413                                let inferred = asm
2414                                    .output_type(target.as_str())
2415                                    .or_else(|| infer_auto_extern_type(&b.value, &asm))
2416                                    .unwrap_or(crate::ast::PortType::Ext);
2417                                asm.add_input(
2418                                    target.as_str(),
2419                                    crate::ast::Value::None,
2420                                    inferred,
2421                                    crate::kernel::InputKind::IterationExtern,
2422                                );
2423                            }
2424                        }
2425                    }
2426                }
2427                Statement::ModuleDef(_) => {}
2428                Statement::ExternPort(port) => {
2429                    // Mirror `compile()`: same kind classification —
2430                    // a default expression marks this as a capture
2431                    // port (dynamic); no default marks it as an
2432                    // iteration extern (effectively-const at
2433                    // scope-init time).
2434                    let port_type = crate::ast::PortType::from_keyword(port.typ.as_str())
2435                        .ok_or_else(|| {
2436                            format!(
2437                                "extern '{}': unknown polydat type keyword '{}'. \
2438                             Canonical keywords are emitted by PortType::to_keyword \
2439                             (one per PortType variant).",
2440                                port.name, port.typ,
2441                            )
2442                        })?;
2443                    let (default_value, kind) = match &port.default {
2444                        Some(expr) => {
2445                            let v = evaluate_default_expr(expr, port_type)
2446                                .map_err(|e| format!("extern '{}' default: {e}", port.name,))?;
2447                            (v, crate::kernel::InputKind::ExternalWrite)
2448                        }
2449                        None => (
2450                            crate::ast::Value::None,
2451                            crate::kernel::InputKind::IterationExtern,
2452                        ),
2453                    };
2454                    asm.add_input(&port.name, default_value, port_type, kind);
2455                    self.input_names.push(port.name.clone());
2456                    let passthrough = Box::new(crate::library::identity::PortPassthrough::new(
2457                        &port.name, port_type,
2458                    ));
2459                    let passthrough_name = format!("__port_{}", port.name);
2460                    asm.add_node(
2461                        &passthrough_name,
2462                        passthrough,
2463                        vec![crate::compile::assembly::WireRef::input(&port.name)],
2464                    );
2465                    asm.add_output(
2466                        &port.name,
2467                        crate::compile::assembly::WireRef::node(&passthrough_name),
2468                    );
2469                }
2470                Statement::Cursor(decl) => {
2471                    self.process_cursor(&mut asm, decl)?;
2472                }
2473                Statement::Pragma { .. } => {}
2474                Statement::For(f) => {
2475                    return Err(format!(
2476                        "`for {}` at line {}, col {}: {}",
2477                        f.source.text,
2478                        f.span.line,
2479                        f.span.col,
2480                        "a `for` traversal compiles through `compile_polydat` and runs through `PolydatKernel::traverse`; the assembler entry point builds one program and cannot carry a traversal (docs/design/engine_parity.md, A5)"
2481                    ));
2482                }
2483                Statement::Tile(t) => {
2484                    self.compile_tile(&mut asm, t)?;
2485                }
2486            }
2487        }
2488
2489        // Unused binding check: defer to kernel-level check in fold_init_constants_impl.
2490        // The kernel has the full wiring graph and can accurately determine which
2491        // nodes have no downstream consumers. The compiler can't do this reliably
2492        // because it doesn't track inter-binding wire dependencies.
2493
2494        // Expose outputs: only the required set, or all if no filter.
2495        // Cursor extent aux outputs (`__cursor_extent_*`) must always be
2496        // exposed regardless of the filter — they are queried by the
2497        // post-compile deferred extent resolution and would otherwise be
2498        // pruned by DCE, leaving the cursor extent unresolved.
2499        match required_outputs {
2500            Some(required) => {
2501                // SRD-13f Push D / SRD-44: `volatile` bindings stay
2502                // exposed as outputs even when the caller's
2503                // required list doesn't mention them. The author
2504                // declared the wire as volatile to mark it as
2505                // non-deterministic across invocations — losing
2506                // it from the output set (DCE) would also lose
2507                // the "exclude from program identity" guarantee,
2508                // because the lifecycle classifier would no
2509                // longer find a volatile output pointing at the
2510                // producing node.
2511                let mut required_owned: Vec<String> = required.to_vec();
2512                for stmt in &file.statements {
2513                    if let crate::dsl::ast::Statement::Binding(b) = stmt
2514                        && b.modifier.is_volatile()
2515                    {
2516                        for t in &b.targets {
2517                            if !required_owned.iter().any(|n| n == t) {
2518                                required_owned.push(t.clone());
2519                            }
2520                        }
2521                    }
2522                }
2523                for name in &required_owned {
2524                    if self.all_names.contains(name) {
2525                        asm.add_output(name, WireRef::node(name));
2526                    }
2527                }
2528                for deferred in &self.deferred_extents {
2529                    if self.all_names.contains(&deferred.start_output) {
2530                        asm.add_output(
2531                            &deferred.start_output,
2532                            WireRef::node(&deferred.start_output),
2533                        );
2534                    }
2535                    if self.all_names.contains(&deferred.end_output) {
2536                        asm.add_output(&deferred.end_output, WireRef::node(&deferred.end_output));
2537                    }
2538                }
2539                // Always preserve `__cursor_extent_*` auxiliary
2540                // outputs — they're consumed by the comprehension
2541                // `all(<cursor>)` form (SRD-18c §"Layer 3") and
2542                // also by the post-compile deferred-extent
2543                // resolution above. DCE-ing them would leave the
2544                // cursor's extent unresolvable to descendant scopes.
2545                let pruned_aux: Vec<String> = self
2546                    .all_names
2547                    .iter()
2548                    .filter(|n| n.starts_with("__cursor_extent_"))
2549                    .cloned()
2550                    .collect();
2551                for name in pruned_aux {
2552                    asm.add_output(&name, WireRef::node(&name));
2553                }
2554            }
2555            None => {
2556                for name in &self.all_names {
2557                    asm.add_output(name, WireRef::node(name));
2558                }
2559            }
2560        }
2561
2562        asm.set_context(&self.source_text, &self.context_label);
2563        // The strictness pragmas reach every kernel built from this
2564        // assembler, on every engine and on every entry point.
2565        asm.set_strict_wires(self.pragmas.strict_types(), self.pragmas.strict_values());
2566        asm.set_strict(self.strict);
2567        // The cursors, with their partitions resolved at build, reach
2568        // every kernel built from this assembler (engine_parity.md,
2569        // step 3).
2570        asm.set_cursor_schemas(self.cursor_schemas.clone());
2571        Ok(asm)
2572    }
2573}
2574
2575// ── The one entry point (engine_parity.md, step 4) ──────────────────
2576
2577/// Compile `source` for `engine`: the interpreter, the closure tier,
2578/// the hybrid kernel, or pure native code. Every engine accepts every
2579/// program the interpreter accepts, or refuses it with a reason
2580/// ([`crate::KernelError::Refused`]); a host drives the result through
2581/// [`crate::Kernel`] without knowing which engine it holds. The
2582/// `compile_polydat_kernel*` and `compile_polydat_checked` entry
2583/// points are this on `Engine::default()`; `compile_polydat`,
2584/// `compile_polydat_with_options`, and the deprecated forms build
2585/// the interpreter's kernel.
2586pub fn compile_polydat_with(
2587    source: &str,
2588    engine: crate::Engine,
2589) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2590    compile_polydat_with_engine(source, engine, &CompileOptions::default(), None)
2591}
2592
2593/// [`compile_polydat_with`] on [`Engine::default`](crate::Engine::default):
2594/// compiled code, with the JIT where the build has it.
2595pub fn compile_polydat_kernel(source: &str) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2596    compile_polydat_with(source, crate::Engine::default())
2597}
2598
2599/// [`compile_polydat_kernel`] with the kernel path's options (source
2600/// directory, library paths, required outputs, strict typing, the
2601/// error context label, the cursor limit) and the compile event log:
2602/// [`compile_polydat_with_engine`] on [`Engine::default`](crate::Engine::default).
2603pub fn compile_polydat_kernel_with_options(
2604    source: &str,
2605    options: &CompileOptions,
2606    log: Option<&mut super::events::CompileEventLog>,
2607) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2608    compile_polydat_with_engine(source, crate::Engine::default(), options, log)
2609}
2610
2611/// [`compile_polydat_with`] with the kernel path's options (source
2612/// directory, library paths, required outputs, strict typing, the
2613/// error context label, the cursor limit) and the compile event log.
2614/// On the interpreter this is the whole kernel path, traversals
2615/// included; on a compiled engine the assembler entry point followed
2616/// by [`PolydatAssembler::compile_engine_with_log`].
2617pub fn compile_polydat_with_engine(
2618    source: &str,
2619    engine: crate::Engine,
2620    options: &CompileOptions,
2621    log: Option<&mut super::events::CompileEventLog>,
2622) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2623    use crate::KernelError;
2624    let tokens = super::lexer::lex(source).map_err(KernelError::Source)?;
2625    let ast = super::parser::parse(tokens).map_err(KernelError::Source)?;
2626    compile_ast_with_engine(&ast, source, options, log, engine)
2627}
2628
2629/// [`compile_polydat_with_engine`] from a parsed file: the parent
2630/// compiles on `engine` through the assembler, and each `for` body
2631/// compiles once for the interpreter as the traversal's record and on
2632/// any engine at activation (engine parity, step 8).
2633pub fn compile_ast_with_engine(
2634    ast: &PolydatFile,
2635    source: &str,
2636    options: &CompileOptions,
2637    mut log: Option<&mut super::events::CompileEventLog>,
2638    engine: crate::Engine,
2639) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2640    let mut prepared = Prepared::new(source, ast, options, log.as_deref_mut());
2641    let (compiler, filter) = prepared.parts();
2642    compile_file_on_engine(compiler, ast, filter, engine, log)
2643}
2644
2645/// Everything an entry point sets up before a program assembles: the
2646/// compiler under its options, the outputs to keep, and the data-file
2647/// base directory for the compile's duration. One prologue for every
2648/// entry point, so the options mean the same thing whichever one
2649/// carries them.
2650struct Prepared {
2651    compiler: Compiler,
2652    required: Vec<String>,
2653    _data_base: Option<DataBaseDirGuard>,
2654}
2655
2656impl Prepared {
2657    fn new(
2658        source: &str,
2659        ast: &PolydatFile,
2660        options: &CompileOptions,
2661        log: Option<&mut super::events::CompileEventLog>,
2662    ) -> Self {
2663        // Relative data-file paths (csv/jsonl nodes) resolve against the
2664        // program's own directory for the duration of this synchronous
2665        // compile; see `library::datafile::set_data_base_dir`.
2666        let _data_base = options.source_dir.as_deref().map(DataBaseDirGuard::set);
2667        let pragmas = super::pragmas::collect_from_ast(ast);
2668        if let Some(log) = log {
2669            record_pragma_events(&pragmas, log);
2670        }
2671        // The required-outputs list is extended with the const bindings
2672        // only when the caller passed one: an empty list keeps every
2673        // binding, and extending it would flip its meaning.
2674        let required = if options.required_outputs.is_empty() {
2675            Vec::new()
2676        } else {
2677            extend_required_with_const_bindings(&options.required_outputs, ast)
2678        };
2679        let mut compiler = Compiler::with_lib_paths(
2680            options.source_dir.clone(),
2681            options.lib_paths.clone(),
2682            options.strict,
2683        );
2684        compiler.source_text = source.to_string();
2685        // An empty context keeps the compiler's default label, so a
2686        // failure reads the same whichever entry point built the kernel.
2687        if !options.context.is_empty() {
2688            compiler.context_label = options.context.clone();
2689        }
2690        compiler.cursor_limit = options.cursor_limit;
2691        compiler.pragmas = pragmas;
2692        if let Some(ledger) = &options.ledger {
2693            compiler.ledger = ledger.clone();
2694        }
2695        Prepared {
2696            compiler,
2697            required,
2698            _data_base,
2699        }
2700    }
2701
2702    /// The compiler and the output filter, `None` for every output.
2703    fn parts(&mut self) -> (&mut Compiler, Option<&[String]>) {
2704        let filter = if self.required.is_empty() {
2705            None
2706        } else {
2707            Some(self.required.as_slice())
2708        };
2709        (&mut self.compiler, filter)
2710    }
2711}
2712
2713/// The one path from a parsed file to a kernel, on every engine: the
2714/// `for` statements and producer bindings are lifted out, the parent
2715/// assembles and `build` makes its kernel, each body compiles once
2716/// against the parent's types and is attached, the tile events reach
2717/// the log, and every cursor extent the program computes from constants
2718/// is resolved on the kernel. Returns the kernel with the parent file
2719/// the traversals were lifted from.
2720fn compile_file_with<K: Built>(
2721    compiler: &mut Compiler,
2722    file: &PolydatFile,
2723    filter: Option<&[String]>,
2724    mut log: Option<&mut super::events::CompileEventLog>,
2725    build: impl FnOnce(
2726        PolydatAssembler,
2727        Option<&mut super::events::CompileEventLog>,
2728    ) -> Result<K, crate::KernelError>,
2729) -> Result<(K, PolydatFile), crate::KernelError> {
2730    use crate::KernelError;
2731    let (parent_file, for_stmts, producers) =
2732        super::traversal::strip_for_forms(file).map_err(KernelError::Source)?;
2733    compiler.producers_seen = producers.clone();
2734    let asm = compiler
2735        .assemble_parent(&parent_file, filter)
2736        .map_err(KernelError::Source)?;
2737    // The tiles typed while assembling belong to this program's log.
2738    if let Some(log) = log.as_deref_mut() {
2739        for e in compiler.tile_events.drain(..) {
2740            log.push(e);
2741        }
2742    }
2743    let mut built = build(asm, log.as_deref_mut())?;
2744    let kernel: &mut dyn crate::Kernel = built.kernel();
2745    if !for_stmts.is_empty() || !producers.is_empty() {
2746        let externs = kernel.externs();
2747        let inputs = kernel.input_names();
2748        let type_of = |name: &str| {
2749            kernel.output_type(name).or_else(|| {
2750                externs
2751                    .iter()
2752                    .find(|(n, _)| n == name)
2753                    .map(|(_, t)| *t)
2754                    .or_else(|| {
2755                        // A coordinate: the one input kind that is not an extern.
2756                        inputs
2757                            .iter()
2758                            .any(|n| n == name)
2759                            .then_some(crate::ast::PortType::U64)
2760                    })
2761            })
2762        };
2763        let traversals = compiler
2764            .compile_traversals(&for_stmts, &producers, &type_of)
2765            .map_err(KernelError::Source)?;
2766        crate::kernel::KernelInternals::set_traversals(kernel, traversals, producers);
2767        // Tiles inside the bodies, typed in the child compilers.
2768        if let Some(log) = log {
2769            for e in compiler.tile_events.drain(..) {
2770                log.push(e);
2771            }
2772        }
2773    }
2774    // A cursor whose range is computed from constants gets its extent
2775    // from the values the build folded, on every engine.
2776    for deferred in &compiler.deferred_extents {
2777        let start = kernel
2778            .folded_value(&deferred.start_output)
2779            .map(|v| v.as_u64());
2780        let end = kernel
2781            .folded_value(&deferred.end_output)
2782            .map(|v| v.as_u64());
2783        if let (Some(s), Some(e)) = (start, end) {
2784            let resolved = e.saturating_sub(s);
2785            let extent = compiler
2786                .cursor_limit
2787                .map(|limit| resolved.min(limit))
2788                .unwrap_or(resolved);
2789            if let Some(schema) = compiler.cursor_schemas.get_mut(deferred.schema_idx) {
2790                schema.extent = Some(extent);
2791            }
2792            kernel.set_cursor_extent(deferred.schema_idx, extent);
2793        }
2794    }
2795    Ok((built, parent_file))
2796}
2797
2798/// What a build hands back to the compile path: the interpreter's
2799/// concrete kernel or any engine's boxed one, each reachable as the one
2800/// trait the lowering drives.
2801trait Built {
2802    fn kernel(&mut self) -> &mut dyn crate::Kernel;
2803}
2804
2805impl Built for PolydatKernel {
2806    fn kernel(&mut self) -> &mut dyn crate::Kernel {
2807        self
2808    }
2809}
2810
2811impl Built for Box<dyn crate::Kernel> {
2812    fn kernel(&mut self) -> &mut dyn crate::Kernel {
2813        self.as_mut()
2814    }
2815}
2816
2817/// The kernel of a parsed file on `engine`: [`compile_file_with`] with
2818/// the engine's build, and the interpreter's concrete kernel boxed when
2819/// the engine is the interpreter.
2820pub(super) fn compile_file_on_engine(
2821    compiler: &mut Compiler,
2822    file: &PolydatFile,
2823    filter: Option<&[String]>,
2824    engine: crate::Engine,
2825    log: Option<&mut super::events::CompileEventLog>,
2826) -> Result<Box<dyn crate::Kernel>, crate::KernelError> {
2827    if let crate::Engine::Interpreter(cones) = engine {
2828        return compiler
2829            .compile_interpreter(file, filter, log, cones)
2830            .map(|k| Box::new(k) as Box<dyn crate::Kernel>);
2831    }
2832    let (kernel, _) = compile_file_with(compiler, file, filter, log, |asm, log| {
2833        asm.compile_engine_with_log(engine, log)
2834    })?;
2835    Ok(kernel)
2836}
2837
2838#[cfg(test)]
2839mod tests {
2840    use super::*;
2841
2842    /// The interpreter kernel under `strict` alone.
2843    fn strict(src: &str, strict: bool) -> Result<PolydatKernel, String> {
2844        let options = CompileOptions {
2845            strict,
2846            ..CompileOptions::default()
2847        };
2848        compile_polydat_with_options(src, &options, None)
2849    }
2850
2851    #[test]
2852    fn array_literal_binding_compiles_as_string() {
2853        // A list-valued binding (`const xs := [1, 2, 3]`) is a sweep
2854        // axis / interpolation value, not a scalar wire. polydat has no
2855        // const-vector node, so it binds to a `ConstStr` holding the
2856        // list's literal text rather than failing the compile — which
2857        // is what lets list-valued workload params (`limit_values:
2858        // [25]`) load.
2859        let result =
2860            compile_polydat("input cycle: u64\nconst eh_values := [1, 2, 3]\nout := cycle");
2861        assert!(
2862            result.is_ok(),
2863            "array-literal binding should compile (binds as a string const), got: {:?}",
2864            result.err(),
2865        );
2866        // The resolved value is the comma-joined, bracket-free form a
2867        // sweep-axis param carries (so a `WorkloadParamList` source
2868        // splits it on `, ` exactly like a string-valued sweep param).
2869        let kernel = result.unwrap();
2870        match kernel.get_constant("eh_values") {
2871            Some(crate::ast::Value::Str(s)) => assert_eq!(s.as_ref(), "1, 2, 3"),
2872            other => panic!("expected eh_values = Str(\"1, 2, 3\"), got {other:?}"),
2873        }
2874    }
2875
2876    #[test]
2877    fn embedding_error_display_includes_source_text() {
2878        let e = EmbeddingError::LifecycleMismatch {
2879            source: "hash(cycle)".to_string(),
2880            dynamic_inputs: vec!["cycle".to_string()],
2881        };
2882        let s = format!("{e}");
2883        assert!(
2884            s.contains("hash(cycle)"),
2885            "display should include source: {s}"
2886        );
2887        assert!(
2888            s.contains("cycle"),
2889            "display should mention dynamic input: {s}"
2890        );
2891    }
2892
2893    #[test]
2894    fn embedding_error_from_string_shim() {
2895        let e = EmbeddingError::UnresolvedPlaceholder {
2896            name: "k".to_string(),
2897            source: "{k} > 5".to_string(),
2898        };
2899        let s: String = e.clone().into();
2900        assert_eq!(s, format!("{e}"));
2901    }
2902
2903    #[test]
2904    fn embedding_error_all_variants_display() {
2905        // Smoke test: every variant constructs and displays without panicking.
2906        let variants: Vec<EmbeddingError> = vec![
2907            EmbeddingError::Parse {
2908                source: "x +".into(),
2909                message: "unexpected EOF".into(),
2910                position: Some(3),
2911            },
2912            EmbeddingError::UnresolvedPlaceholder {
2913                name: "k".into(),
2914                source: "{k}".into(),
2915            },
2916            EmbeddingError::LifecycleMismatch {
2917                source: "hash(cycle)".into(),
2918                dynamic_inputs: vec!["cycle".into()],
2919            },
2920            EmbeddingError::UnknownNode {
2921                name: "frobnicate".into(),
2922                source: "frobnicate(x)".into(),
2923                suggestion: Some("fabricate".into()),
2924            },
2925            EmbeddingError::TypeMismatch {
2926                from_node: "n1".into(),
2927                from_type: crate::ast::PortType::U64,
2928                to_node: "n2".into(),
2929                to_type: crate::ast::PortType::Str,
2930                source: "n1 -> n2".into(),
2931            },
2932            EmbeddingError::NodeEvalPanic {
2933                node_name: "div".into(),
2934                message: "div by zero".into(),
2935                source: "div(a, b)".into(),
2936            },
2937            EmbeddingError::ResultMissing {
2938                output_name: "out".into(),
2939                source: "x := 1".into(),
2940            },
2941            EmbeddingError::NonePropagated {
2942                accessor: "as_bool",
2943                source: "{missing}".into(),
2944            },
2945            EmbeddingError::Timeout {
2946                source: "expensive()".into(),
2947                elapsed_ms: 5000,
2948                deadline_ms: 1000,
2949            },
2950            EmbeddingError::RegistryNotInitialised {
2951                missing: vec!["custom_node".into()],
2952                source: "custom_node()".into(),
2953            },
2954        ];
2955        for v in variants {
2956            let _ = format!("{v}");
2957        }
2958    }
2959
2960    #[test]
2961    fn typed_surface_string() {
2962        let v: String = eval_const_expr_typed("\"hello\"").unwrap();
2963        assert_eq!(v, "hello");
2964    }
2965
2966    #[test]
2967    fn typed_surface_type_mismatch() {
2968        // expression yields U64; host requests f64 — widening allowed
2969        let v: f64 = eval_const_expr_typed("42").unwrap();
2970        assert_eq!(v, 42.0);
2971        // expression yields U64; host requests bool — interpreted as bool (nonzero)
2972        let v: bool = eval_const_expr_typed("1").unwrap();
2973        assert!(v);
2974        let v: bool = eval_const_expr_typed("0").unwrap();
2975        assert!(!v);
2976    }
2977
2978    #[test]
2979    fn typed_surface_return_path_adapter() {
2980        // γ-6: expression produces U64; host requests String.
2981        // The catalog's U64ToString adapter heals the return-path.
2982        let v: String = eval_const_expr_typed("42").unwrap();
2983        assert_eq!(v, "42");
2984
2985        // Expression produces F64; host requests String via catalog
2986        // F64ToString. (Note: f64's Display is locale-independent
2987        // but format may add trailing zeros.)
2988        let v: String = eval_const_expr_typed("3.14").unwrap();
2989        assert!(v.starts_with("3.14"), "got {v}");
2990    }
2991
2992    #[test]
2993    fn typed_surface_return_path_no_adapter_errors() {
2994        // Bytes → Bool isn't in the catalog. Confirm the typed
2995        // error fires when the catalog can't heal.
2996        // (Need an expression producing Bytes; use a string-
2997        // literal-to-bytes conversion via bytes_of or similar
2998        // if available; otherwise use a roundtrip that fails.)
2999        //
3000        // Skipping concrete bytes producer for this test —
3001        // the contract is exercised by the negative path in
3002        // typed_surface_type_mismatch already.
3003    }
3004
3005    #[test]
3006    fn typed_strict_rejects_lossy_conversion() {
3007        // U64 → Bool is in the catalog (γ-6 added it) but
3008        // lossy. Strict mode must reject.
3009        let result: Result<bool, _> = eval_const_expr_typed_strict("42");
3010        match result {
3011            Err(EmbeddingError::TypeMismatch {
3012                from_type, to_type, ..
3013            }) => {
3014                assert!(matches!(from_type, crate::ast::PortType::U64));
3015                assert!(matches!(to_type, crate::ast::PortType::Bool));
3016            }
3017            other => panic!("expected TypeMismatch, got {other:?}"),
3018        }
3019    }
3020
3021    #[test]
3022    fn typed_strict_accepts_lossless_conversion() {
3023        // U64 → F64 widening is lossless (for values that
3024        // fit in f64 mantissa, i.e. < 2^53).
3025        let v: f64 = eval_const_expr_typed_strict("42").unwrap();
3026        assert_eq!(v, 42.0);
3027
3028        // U64 → String via display — lossless.
3029        let v: String = eval_const_expr_typed_strict("42").unwrap();
3030        assert_eq!(v, "42");
3031    }
3032
3033    #[test]
3034    fn shared_non_literal_init_rejected() {
3035        // Non-literal `shared` initializers no longer fall
3036        // through to the cycle-binding shape. Compile error
3037        // surfaces with a clear message naming the binding and
3038        // pointing at the SRD-16 §"Non-literal `shared`
3039        // initializers" section.
3040        let src = r#"
3041            input cycle: u64
3042            shared rolling := hash(cycle)
3043        "#;
3044        let err = compile_polydat(src).expect_err("non-literal shared const must error");
3045        assert!(err.contains("shared binding 'rolling'"), "error: {err}");
3046        assert!(err.contains("literal initial value"), "error: {err}");
3047    }
3048
3049    #[test]
3050    fn final_modifier_tracked() {
3051        let src = r#"
3052            input cycle: u64
3053            const dim := 128
3054        "#;
3055        let kernel = compile_polydat(src).unwrap();
3056        assert_eq!(
3057            kernel.program().output_modifier("dim"),
3058            crate::dsl::ast::BindingModifier::CONST
3059        );
3060    }
3061
3062    #[test]
3063    fn shared_literal_modifier_tracked() {
3064        let src = r#"
3065            input cycle: u64
3066            shared budget := 100
3067        "#;
3068        let kernel = compile_polydat(src).unwrap();
3069        assert_eq!(
3070            kernel.program().output_modifier("budget"),
3071            crate::dsl::ast::BindingModifier::SHARED
3072        );
3073        // Shared cells back the output via a port-passthrough node
3074        // reading the input slot; `lookup` is the cell-aware read.
3075        assert_eq!(kernel.lookup("budget").unwrap().as_u64(), 100);
3076    }
3077
3078    #[test]
3079    fn const_literal_modifier_tracked() {
3080        let src = r#"
3081            input cycle: u64
3082            const max_dim := 256
3083        "#;
3084        let kernel = compile_polydat(src).unwrap();
3085        assert_eq!(
3086            kernel.program().output_modifier("max_dim"),
3087            crate::dsl::ast::BindingModifier::CONST
3088        );
3089        assert_eq!(kernel.get_constant("max_dim").unwrap().as_u64(), 256);
3090    }
3091
3092    #[test]
3093    fn compile_string_constant() {
3094        let src = r#"
3095            input cycle: u64
3096            label := "hello world"
3097        "#;
3098        let mut kernel = compile_polydat(src).unwrap();
3099        kernel.set_inputs(&[0]);
3100        assert_eq!(kernel.pull("label").as_str(), "hello world");
3101    }
3102
3103    #[test]
3104    fn compile_int_constant() {
3105        let src = r#"
3106            input cycle: u64
3107            base := 1710000000000
3108        "#;
3109        let mut kernel = compile_polydat(src).unwrap();
3110        kernel.set_inputs(&[0]);
3111        assert_eq!(kernel.pull("base").as_u64(), 1_710_000_000_000);
3112    }
3113
3114    // --- Diagnostic tests ---
3115
3116    #[test]
3117    fn error_unknown_function() {
3118        let src = "input cycle: u64\nresult := foobar(cycle)";
3119        let (_result, report) = compile_polydat_checked(src);
3120        assert!(report.has_errors());
3121        let errors = report.errors();
3122        assert!(
3123            errors
3124                .iter()
3125                .any(|e| e.message.contains("unknown function"))
3126        );
3127        assert!(errors.iter().any(|e| e.message.contains("foobar")));
3128    }
3129
3130    #[test]
3131    fn explicit_coordinates_rejects_unbound() {
3132        // With explicit coordinates, unbound references are errors
3133        let src = "input cycle: u64\nh := hash(unknown)";
3134        let (_, report) = compile_polydat_checked(src);
3135        assert!(report.has_errors());
3136        assert!(
3137            report
3138                .errors()
3139                .iter()
3140                .any(|e| e.message.contains("undefined") && e.message.contains("unknown"))
3141        );
3142    }
3143
3144    #[test]
3145    fn warning_forward_reference() {
3146        let src = r#"
3147            input cycle: u64
3148            result := mod(h, 100)
3149            h := hash(cycle)
3150        "#;
3151        let (_, report) = compile_polydat_checked(src);
3152        let warnings = report.warnings();
3153        assert!(
3154            warnings
3155                .iter()
3156                .any(|w| w.message.contains("forward reference")),
3157            "should warn about forward ref, got: {:?}",
3158            warnings
3159        );
3160    }
3161
3162    #[test]
3163    fn error_undefined_wire() {
3164        let src = r#"
3165            input cycle: u64
3166            result := hash(nonexistent)
3167        "#;
3168        let (_, report) = compile_polydat_checked(src);
3169        assert!(report.has_errors());
3170        assert!(
3171            report
3172                .errors()
3173                .iter()
3174                .any(|e| e.message.contains("undefined") && e.message.contains("nonexistent"))
3175        );
3176    }
3177
3178    #[test]
3179    fn error_report_includes_source_line() {
3180        let src = "input cycle: u64\nresult := unknown_func(cycle)";
3181        let (_, report) = compile_polydat_checked(src);
3182        let s = report.to_string();
3183        assert!(
3184            s.contains("unknown_func"),
3185            "report should include source context"
3186        );
3187    }
3188
3189    // --- Strict mode tests ---
3190
3191    #[test]
3192    fn strict_requires_explicit_inputs() {
3193        // Without inputs declaration, strict mode should error
3194        let src = "h := hash(cycle)";
3195        let result = strict(src, true);
3196        assert!(result.is_err());
3197        let err = result.unwrap_err();
3198        assert!(
3199            err.contains("strict mode"),
3200            "expected strict error, got: {err}"
3201        );
3202        assert!(
3203            err.contains("inputs"),
3204            "expected inputs mention, got: {err}"
3205        );
3206    }
3207
3208    // --- Dead code elimination tests ---
3209
3210    // --- Strict mode comprehensive tests ---
3211
3212    // --- eval_const_expr tests ---
3213
3214    #[test]
3215    fn eval_const_expr_fails_on_inputs() {
3216        // 'cycle' is a runtime input — should fail as const expr
3217        let r = eval_const_expr("hash(cycle)");
3218        assert!(r.is_err(), "hash(cycle) should fail as a const expression");
3219    }
3220
3221    // ─────────────────────────────────────────────────────────────
3222    // Init-Binding Contract (SRD 11 §"Init Binding Contract")
3223    //
3224    // Plan A — compile-time check: every binding declared `init`
3225    // must classify as compile-const or scope-init. A wire chain
3226    // reaching a coordinate input, a external-write port, or a
3227    // non-deterministic source disqualifies the binding.
3228    // ─────────────────────────────────────────────────────────────
3229
3230    #[test]
3231    fn init_binding_compile_const_folded() {
3232        // Pure init: literal arg, no externs. Folds at compile
3233        // time; the compiled program's output_map points at a
3234        // ConstU64 leaf.
3235        let src = "const dim := 128\n";
3236        let kernel = compile_polydat(src).expect("init compile-const");
3237        let prog = kernel.program();
3238        assert!(prog.const_outputs().contains(&"dim"));
3239        let &(node_idx, _) = prog.output_map_lookup("dim").expect("dim in output map");
3240        // After fold, the node has empty wiring (leaf const).
3241        assert!(
3242            prog.wiring[node_idx].is_empty(),
3243            "compile-const init binding 'dim' must fold to a leaf const node"
3244        );
3245    }
3246
3247    #[test]
3248    fn init_binding_with_iteration_extern_passes_plan_a() {
3249        // Init binding wired through an iteration extern: this is
3250        // legal under Plan A — the wire chain reaches an
3251        // IterationExtern input slot, which is effectively-const at
3252        // scope-init time. Plan B (executor-side) is what actually
3253        // evaluates it; the compile step just must not reject.
3254        let src = "extern profile: String\n\
3255                   const label := format_str(\"label_%s\", profile)\n";
3256        let result = compile_polydat(src);
3257        // We don't care if format_str exists in the stdlib — what
3258        // we're testing is that the contract check itself doesn't
3259        // fail (any error must be about an unknown function, not
3260        // about the init contract).
3261        match result {
3262            Ok(_) => {} // ideal: kernel built
3263            Err(e) => assert!(
3264                !e.contains("violates the init contract"),
3265                "Plan A must accept iteration-extern wires in init bindings; got: {e}"
3266            ),
3267        }
3268    }
3269
3270    #[test]
3271    fn init_binding_wired_to_nondeterministic_rejected() {
3272        // `counter()` is non-deterministic; init bindings must not
3273        // depend on it.
3274        let src = "const bad := counter()\n";
3275        let err = compile_polydat(src)
3276            .expect_err("Plan A must reject init binding wired to a non-deterministic source");
3277        assert!(
3278            err.contains("init binding 'bad'") && err.contains("init contract"),
3279            "diagnostic must name the binding and the contract; got: {err}"
3280        );
3281    }
3282
3283    #[test]
3284    fn init_outputs_threaded_into_program() {
3285        // Sanity: the compiler records every `init`-declared name
3286        // on GkProgram.const_outputs so Plan B (executor side) can
3287        // walk them at scope activation.
3288        let src = "const a := 1\n\
3289                   const b := 2\n\
3290                   c := 3\n";
3291        let kernel = compile_polydat(src).unwrap();
3292        let init_set = kernel.program().const_outputs();
3293        assert!(init_set.contains(&"a"), "const 'a' should be tracked");
3294        assert!(init_set.contains(&"b"), "const 'b' should be tracked");
3295        assert!(
3296            !init_set.contains(&"c"),
3297            "non-const 'c' must not be tracked"
3298        );
3299    }
3300
3301    /// Auto-extern slots inferred from RHS shape land at the
3302    /// boundary with their actual type (Str / U64 / F64 / Bool)
3303    /// rather than the legacy `PortType::Ext` catchall. This
3304    /// removes the `U64 → Ext` boundary-adapter miss the audit
3305    /// log used to warn about for workloads that use `set:`
3306    /// blocks with iter-var interpolation.
3307    ///
3308    /// Test path: declare an iteration extern explicitly with
3309    /// `extern N: str` (no default → `IterationExtern` kind,
3310    /// effectively-const at scope-init); reference it from a
3311    /// const RHS. The const target then needs an auto-extern
3312    /// slot (RHS has a ref), and the inferrer picks the
3313    /// referenced input's type.
3314    #[test]
3315    fn auto_extern_slot_inherits_string_template_type() {
3316        let src = r#"
3317            extern some_outer_var: str
3318            const x := "{some_outer_var}"
3319        "#;
3320        let kernel = compile_polydat(src).expect("compile");
3321        assert_eq!(
3322            kernel.program().input_port_type("x"),
3323            Some(crate::ast::PortType::Str),
3324            "string-template auto-extern MUST be Str, not Ext",
3325        );
3326    }
3327
3328    /// Identifier reference auto-extern inherits the referenced
3329    /// input's type. `const y := other_str_input` → y is Str.
3330    #[test]
3331    fn auto_extern_slot_inherits_ident_reference_type() {
3332        let src = r#"
3333            extern other: str
3334            const y := other
3335        "#;
3336        let kernel = compile_polydat(src).expect("compile");
3337        assert_eq!(
3338            kernel.program().input_port_type("y"),
3339            Some(crate::ast::PortType::Str),
3340            "ident-RHS auto-extern MUST inherit referenced input's type",
3341        );
3342    }
3343
3344    /// `dataset_prebuffer(...)` returns `Value::Handle` — the
3345    /// auto-extern slot for `const prebuffered := dataset_prebuffer(...)`
3346    /// MUST be `PortType::Handle`, not the legacy `Ext` catchall.
3347    /// This is the second specific call site we patched in the
3348    /// inferrer after the `printf` string-template case.
3349    /// (`dataset_prebuffer` is a vectordata node, so the test only
3350    /// exists when that feature registers it.)
3351    #[cfg(feature = "vectordata")]
3352    #[test]
3353    fn auto_extern_slot_for_dataset_prebuffer_is_handle() {
3354        let src = r#"
3355            extern source_uri: str
3356            const prebuffered := dataset_prebuffer(source_uri)
3357        "#;
3358        let kernel = compile_polydat(src).expect("compile");
3359        assert_eq!(
3360            kernel.program().input_port_type("prebuffered"),
3361            Some(crate::ast::PortType::Handle),
3362            "dataset_prebuffer auto-extern MUST be Handle, not Ext",
3363        );
3364    }
3365}