Skip to main content

polydat_core/kernel/
api_impl.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Trait implementations on the interpreter kernel: the
5//! engine-independent [`Kernel`](crate::kernel::Kernel) surface every
6//! engine shares, and the three interpreter-only traits ([`Metadata`],
7//! [`Dataflow`], [`Construction`]).
8
9use crate::ast::{PortType, Value};
10use crate::kernel::{Construction, Dataflow, Metadata, PolydatKernel};
11
12impl Metadata for PolydatKernel {
13    #[inline]
14    fn find_input(&self, name: &str) -> Option<usize> {
15        self.program().find_input(name)
16    }
17
18    #[inline]
19    fn input_names(&self) -> Vec<String> {
20        self.program().input_names()
21    }
22
23    #[inline]
24    fn output_names(&self) -> Vec<String> {
25        self.program()
26            .output_names()
27            .iter()
28            .map(|s| s.to_string())
29            .collect()
30    }
31
32    #[inline]
33    fn coord_count(&self) -> usize {
34        self.program().coord_count()
35    }
36
37    #[inline]
38    fn input_port_type(&self, name: &str) -> Option<PortType> {
39        self.program().input_port_type(name)
40    }
41
42    #[inline]
43    fn input_port_type_by_idx(&self, idx: usize) -> Option<PortType> {
44        self.program().input_port_type_by_idx(idx)
45    }
46
47    #[inline]
48    fn output_port_type(&self, name: &str) -> Option<PortType> {
49        self.program().output_port_type(name)
50    }
51}
52
53impl Dataflow for PolydatKernel {
54    fn set_wire_idx(
55        &mut self,
56        idx: usize,
57        value: Value,
58    ) -> Result<(), crate::kernel::api::WriteError> {
59        use crate::kernel::api::WriteError;
60
61        // Look up the slot's declared port type. An out-of-range
62        // index is an unknown-wire error rather than a panic —
63        // the typed boundary surfaces the diagnostic uniformly
64        // for callers who computed the index from external
65        // metadata.
66        let slot_type = match self.program().input_port_type_by_idx(idx) {
67            Some(t) => t,
68            None => {
69                return Err(WriteError::UnknownWire {
70                    key: format!("wire[{idx}]"),
71                    known: Vec::new(),
72                });
73            }
74        };
75
76        let slot_name = self
77            .program()
78            .input_name_by_idx(idx)
79            .map(|s| s.to_string())
80            .unwrap_or_else(|| format!("wire[{idx}]"));
81
82        // Per S4 / T2: try direct write, fall back to the
83        // boundary auto-adapter, then report a TypeMismatch
84        // diagnostic if no adapter healed the mismatch. The
85        // `adapt_boundary_value` helper currently passes
86        // unhealable mismatches through with a warning; here we
87        // detect that case by checking whether the adapted value
88        // still has the wrong port type, and surface a typed
89        // error instead of letting silent corruption propagate
90        // to downstream readers.
91        let got = value.port_type();
92        let adapted = crate::kernel::state::adapt_boundary_value(&slot_name, slot_type, value);
93        // `Value::None` is the absent sentinel — always permitted
94        // regardless of slot type (per none_semantics.md / SRD-74
95        // Rule 1). For non-None values, the residual check uses
96        // the bit-stuffing equivalence helper
97        // (`Value::satisfies_slot`) so a narrowing adapter that
98        // outputs `Value::U64` for a U32 slot — the runtime
99        // bit-stuffed form per type_system.md §1 — passes
100        // validation. The pre-adapter check in
101        // `adapt_boundary_value` remains strict, so an unadapted
102        // Value::U64 cannot silently truncate into a U32 slot.
103        if !adapted.satisfies_slot(slot_type) {
104            return Err(WriteError::TypeMismatch {
105                slot: slot_name,
106                expected: slot_type,
107                got,
108            });
109        }
110        self.state().set_input(idx, adapted);
111        Ok(())
112    }
113
114    #[inline]
115    fn get_wire_idx(&self, idx: usize) -> Value {
116        self.state_ref().get_input(idx)
117    }
118}
119
120impl Construction for PolydatKernel {
121    type Error = crate::kernel::subcontext::ContractViolation;
122
123    fn root(matter: crate::kernel::subcontext::PolydatMatter<'_>) -> Result<Self, Self::Error> {
124        use crate::kernel::subcontext::PolydatMatterInner;
125        match matter.inner {
126            PolydatMatterInner::Source(s) => {
127                let options = crate::dsl::compile::CompileOptions {
128                    source_dir: s.options.workload_dir.clone(),
129                    lib_paths: s.options.polydat_lib_paths,
130                    required_outputs: s.options.required_outputs.clone(),
131                    strict: s.options.strict,
132                    context: s
133                        .options
134                        .context_label
135                        .clone()
136                        .unwrap_or_else(|| s.label.clone()),
137                    cursor_limit: s.options.cursor_limit,
138                    input_variance: s.options.input_variance,
139                    inferred_externs: Vec::new(),
140                    ledger: None,
141                    engine: crate::Engine::default(),
142                };
143                crate::dsl::compile::compile_polydat_interpreter_with_options(
144                    &s.body, &options, None,
145                )
146                .map_err(|e| crate::kernel::subcontext::ContractViolation::Compile(e.to_string()))
147            }
148            PolydatMatterInner::Statements(s) => {
149                // Pre-parsed AST — go through the compile-from-AST
150                // path. The `PolydatFile` AST root takes the statements
151                // verbatim; the same options surface as the source
152                // path.
153                let file = crate::dsl::ast::PolydatFile {
154                    statements: s.statements,
155                };
156                let options = crate::dsl::compile::CompileOptions {
157                    source_dir: s.options.workload_dir.clone(),
158                    lib_paths: s.options.polydat_lib_paths,
159                    required_outputs: s.options.required_outputs.clone(),
160                    strict: s.options.strict,
161                    context: s
162                        .options
163                        .context_label
164                        .clone()
165                        .unwrap_or_else(|| s.label.clone()),
166                    cursor_limit: None,
167                    input_variance: s.options.input_variance,
168                    inferred_externs: Vec::new(),
169                    ledger: None,
170                    engine: crate::Engine::default(),
171                };
172                crate::dsl::compile::compile_ast_interpreter_with_options(&file, "", &options, None)
173                    .map_err(|e| {
174                        crate::kernel::subcontext::ContractViolation::Compile(e.to_string())
175                    })
176            }
177            PolydatMatterInner::Program(p) => {
178                let mut k = PolydatKernel::from_program(p.program);
179                for (var, value) in p.iter_bindings {
180                    if let Some(idx) = k.program().find_input(var) {
181                        k.state().set_input(idx, value.clone());
182                    }
183                }
184                Ok(k)
185            }
186        }
187    }
188
189    fn subscope(
190        &self,
191        matter: crate::kernel::subcontext::PolydatMatter<'_>,
192    ) -> Result<Self, Self::Error> {
193        // Delegate to PolydatKernel's existing typed subscope path.
194        PolydatKernel::build_subscope(self, matter)
195    }
196}
197
198// ── The interpreter kernel on the engine-independent surface ────────
199
200impl crate::kernel::Kernel for PolydatKernel {
201    fn engine(&self) -> crate::compile::select::Engine {
202        crate::compile::select::Engine::Interpreter(self.program().cone_mode())
203    }
204    fn set_inputs(&mut self, coords: &[u64]) {
205        PolydatKernel::set_inputs(self, coords);
206    }
207    fn set_input(&mut self, name: &str, value: Value) -> Result<(), crate::kernel::WriteError> {
208        PolydatKernel::set_input(self, name, value)
209    }
210    fn set_cursor(
211        &mut self,
212        name: &str,
213        partition: &crate::iteration::cursor_partition::Partition,
214    ) -> Result<(), crate::kernel::WriteError> {
215        PolydatKernel::set_cursor(self, name, partition)
216    }
217    /// Every output is pulled, so what a side channel observes is what
218    /// it observes on a compiled kernel's run.
219    fn eval(&mut self) {
220        PolydatKernel::eval_read(self);
221    }
222    fn pull(&mut self, name: &str) -> Value {
223        PolydatKernel::pull_ref(self, name).clone()
224    }
225    fn input_names(&self) -> Vec<String> {
226        Metadata::input_names(self)
227    }
228    fn output_names(&self) -> Vec<String> {
229        Metadata::output_names(self)
230    }
231    fn output_type(&self, name: &str) -> Option<PortType> {
232        Metadata::output_port_type(self, name)
233    }
234    fn externs(&self) -> Vec<(String, PortType)> {
235        let program = self.program();
236        Metadata::input_names(self)
237            .into_iter()
238            .enumerate()
239            .filter(|(i, _)| program.input_kind(*i) != Some(crate::kernel::InputKind::Coordinate))
240            .filter_map(|(i, name)| Metadata::input_port_type_by_idx(self, i).map(|t| (name, t)))
241            .collect()
242    }
243    fn cursor_schemas(&self) -> &[crate::iteration::source::SourceSchema] {
244        self.program().cursor_schemas()
245    }
246    fn input_value(&self, name: &str) -> Option<Value> {
247        let idx = self.program().find_input(name)?;
248        Some(self.state_ref().get_input(idx))
249    }
250    fn input_index(&self, name: &str) -> Option<usize> {
251        self.program().find_input(name)
252    }
253    fn set_input_at(
254        &mut self,
255        index: usize,
256        value: Value,
257    ) -> Result<(), crate::kernel::WriteError> {
258        PolydatKernel::set_input_at(self, index, value)
259    }
260    fn output_index(&self, name: &str) -> Option<usize> {
261        self.program().output_index(name)
262    }
263    fn const_inits(&self) -> &[crate::kernel::ConstInit] {
264        self.program().const_inits()
265    }
266    fn init_input_at(
267        &mut self,
268        index: usize,
269        value: Value,
270    ) -> Result<(), crate::kernel::WriteError> {
271        PolydatKernel::init_input_at(self, index, value)
272    }
273    fn pull_at(&mut self, index: usize) -> Value {
274        PolydatKernel::pull_ref_at(self, index).clone()
275    }
276    fn traversals(&self) -> &[crate::dsl::traversal::Traversal] {
277        self.program().traversals()
278    }
279    fn plan(&self) -> crate::EnginePlan {
280        self.program().engine_plan()
281    }
282    fn traverse(&mut self, index: usize) -> Result<crate::kernel::TraversalStream, String> {
283        PolydatKernel::traverse(self, index)
284    }
285    fn invalidate_all(&mut self) {
286        self.state().invalidate_all();
287    }
288    fn shared_cells(&self) -> Vec<crate::kernel::SharedCellEntry> {
289        self.shared_cells_in_scope()
290    }
291    fn output_cell(&self, name: &str) -> Option<crate::kernel::SharedCell> {
292        // Seeded for every output at construction, so this is a read.
293        self.state_ref().core.output_cell(self.program(), name)
294    }
295    fn output_modifier(&self, name: &str) -> crate::dsl::ast::BindingModifier {
296        self.program().output_modifier(name)
297    }
298    fn cells_in_scope(&self) -> Vec<crate::kernel::SharedCellEntry> {
299        // The interpreter's own enumeration already walks its slots and
300        // its transit list together.
301        self.shared_cells_in_scope()
302    }
303    fn set_transit_cells(&mut self, cells: Vec<crate::kernel::SharedCellEntry>) {
304        self.replace_transit_cells(cells);
305    }
306    fn scope_coordinates(&self) -> &[crate::kernel::ScopeCoord] {
307        PolydatKernel::scope_coordinates(self)
308    }
309    fn extend_scope_coordinates(&mut self, outer: &[crate::kernel::ScopeCoord]) {
310        PolydatKernel::extend_scope_coordinates(self, outer);
311    }
312    fn input_port_type(&self, name: &str) -> Option<crate::ast::PortType> {
313        self.program().input_port_type(name)
314    }
315    fn bind_input_cell(&mut self, name: &str, cell: crate::kernel::SharedCell) -> bool {
316        // The state's own attach has never required the slot to be
317        // `shared` — the filter is on the host-facing
318        // `attach_shared_cell` above, and the binder has always come
319        // through here.
320        let Some(idx) = self.program().find_input(name) else {
321            return false;
322        };
323        self.state().attach_shared_cell(idx, cell);
324        true
325    }
326    fn attach_shared_cell(
327        &mut self,
328        name: &str,
329        cell: crate::kernel::SharedCell,
330    ) -> Result<(), String> {
331        let program = self.program().clone();
332        let shared = program.shared_outputs();
333        let idx = program.find_input(name).filter(|_| shared.contains(&name));
334        let Some(idx) = idx else {
335            return Err(format!(
336                "no `shared` binding named '{name}'; this kernel's shared bindings are {shared:?}"
337            ));
338        };
339        self.state().attach_shared_cell(idx, cell);
340        Ok(())
341    }
342    fn into_program(self: Box<Self>) -> std::sync::Arc<dyn crate::kernel::KernelProgram> {
343        PolydatKernel::into_program(*self)
344    }
345    fn ledger(&self) -> &std::sync::Arc<crate::kernel::CompileLedger> {
346        self.program().ledger()
347    }
348    fn coord_count(&self) -> usize {
349        self.program().coord_count()
350    }
351    fn input_value_at(&self, index: usize) -> Option<Value> {
352        (index < self.state_ref().core.inputs.len())
353            .then(|| self.state_ref().read_input_value(index))
354    }
355    fn input_default_at(&self, index: usize) -> Option<Value> {
356        self.program().input_default_by_idx(index).cloned()
357    }
358    fn input_is_cell_bound(&self, index: usize) -> bool {
359        self.state_ref().shared_cell(index).is_some()
360    }
361    fn reset_inputs(&mut self) {
362        let from = self.program().coord_count();
363        self.state().reset_inputs_from(from);
364    }
365    fn fork(&self) -> Box<dyn crate::kernel::Kernel> {
366        Box::new(self.fork_kernel())
367    }
368    fn publish_broadcasts(&mut self) {
369        self.advance_broadcasts();
370    }
371    fn commit_write_throughs(&mut self) -> Result<(), String> {
372        PolydatKernel::commit_write_throughs(self)
373    }
374    fn program_id(&self) -> crate::kernel::ProgramId {
375        crate::kernel::ProgramId(std::sync::Arc::as_ptr(self.program()) as *const () as usize)
376    }
377    fn input_type_origin(&self, name: &str) -> Option<crate::kernel::TypeOrigin> {
378        self.program().input_type_origin(name)
379    }
380}
381
382impl crate::kernel::KernelInternals for PolydatKernel {
383    fn set_write_throughs(&mut self, pairs: Vec<(String, String)>) {
384        PolydatKernel::set_write_throughs(
385            self,
386            pairs
387                .into_iter()
388                .map(
389                    |(export_name, source_output)| crate::kernel::KernelWriteThrough {
390                        export_name,
391                        source_output,
392                    },
393                )
394                .collect(),
395        );
396    }
397    fn set_traversals(
398        &mut self,
399        traversals: Vec<crate::dsl::traversal::Traversal>,
400        producers: Vec<crate::dsl::traversal::Producer>,
401    ) {
402        PolydatKernel::set_traversals(self, traversals, producers);
403    }
404    /// Only for an output fixed for the kernel's life: a computed output's
405    /// buffer holds its last pulled value, which is not the scope's.
406    fn folded_value(&self, name: &str) -> Option<Value> {
407        if !self.program().is_fixed_output(name) {
408            return None;
409        }
410        self.get_constant(name).cloned()
411    }
412    fn set_cursor_extent(&mut self, index: usize, extent: u64) {
413        let mut schemas = self.program().cursor_schemas().to_vec();
414        if let Some(schema) = schemas.get_mut(index) {
415            schema.extent = Some(extent);
416            self.set_cursor_schemas(schemas);
417        }
418    }
419}
420
421impl crate::kernel::KernelProgram for crate::kernel::PolydatProgram {
422    fn engine(&self) -> crate::compile::select::Engine {
423        crate::compile::select::Engine::Interpreter(self.cone_mode())
424    }
425    fn as_interpreter(self: std::sync::Arc<Self>) -> Option<std::sync::Arc<Self>> {
426        Some(self)
427    }
428    fn create_uninitialized(self: std::sync::Arc<Self>) -> Box<dyn crate::kernel::Kernel> {
429        Box::new(PolydatKernel::from_program(self))
430    }
431    fn ledger(&self) -> &std::sync::Arc<crate::kernel::CompileLedger> {
432        crate::kernel::PolydatProgram::ledger(self)
433    }
434    fn program_id(&self) -> crate::kernel::ProgramId {
435        crate::kernel::ProgramId(self as *const Self as *const () as usize)
436    }
437}
438
439#[cfg(test)]
440// These tests exercise the deprecated `Dataflow` writes themselves.
441#[allow(deprecated)]
442mod tests {
443    use super::*;
444    use crate::dsl::compile::compile_polydat_interpreter;
445
446    /// Indexed wire access works.
447    #[test]
448    fn dataflow_indexed_set_get() {
449        let mut k = compile_polydat_interpreter("input cycle: u64\nconst x := 7\n").unwrap();
450        // cycle is index 0
451        k.set_wire(0_usize, Value::U64(42)).expect("typed write");
452        assert_eq!(k.get_wire(0_usize), Some(Value::U64(42)));
453    }
454
455    /// Named wire access resolves through metadata.
456    #[test]
457    fn dataflow_named_set_get() {
458        let mut k = compile_polydat_interpreter("input cycle: u64\nextern n: u64\n").unwrap();
459        k.set_wire("n", Value::U64(5)).expect("typed write");
460        match k.get_wire("n") {
461            Some(Value::U64(5)) => {}
462            other => panic!("expected U64(5), got {other:?}"),
463        }
464    }
465
466    /// String key works alongside &str.
467    #[test]
468    fn dataflow_string_key() {
469        let mut k = compile_polydat_interpreter("input cycle: u64\nextern n: u64\n").unwrap();
470        let name = String::from("n");
471        k.set_wire(&name, Value::U64(99)).expect("typed write");
472        assert_eq!(k.get_wire(name.clone()), Some(Value::U64(99)));
473    }
474
475    /// Unknown name returns Err(UnknownWire) / None — no panic.
476    #[test]
477    fn dataflow_unknown_name_safe() {
478        let mut k = compile_polydat_interpreter("input cycle: u64\n").unwrap();
479        let err = k.set_wire("nonexistent", Value::U64(1)).unwrap_err();
480        assert!(matches!(
481            err,
482            crate::kernel::api::WriteError::UnknownWire { .. }
483        ));
484        assert!(k.get_wire("nonexistent").is_none());
485    }
486
487    /// S4 type-check: writing the wrong Value variant to a typed
488    /// slot returns Err(TypeMismatch) when no boundary adapter
489    /// can heal the mismatch.
490    ///
491    /// `VecF32 → U64` is intentionally absent from the polyfill
492    /// matrix (type_system.md §3 — collection → scalar requires
493    /// explicit choice), so it is a stable "no adapter exists"
494    /// pair for testing the diagnostic.
495    #[test]
496    fn dataflow_type_mismatch_rejected() {
497        let mut k = compile_polydat_interpreter("input cycle: u64\nextern n: u64\n").unwrap();
498        let err = k
499            .set_wire(
500                "n",
501                Value::VecF32(crate::ast::SliceArc::from_vec(vec![1.0_f32, 2.0])),
502            )
503            .unwrap_err();
504        match err {
505            crate::kernel::api::WriteError::TypeMismatch {
506                slot,
507                expected,
508                got,
509            } => {
510                assert_eq!(slot, "n");
511                assert_eq!(expected, PortType::U64);
512                assert_eq!(got, PortType::VecF32);
513            }
514            other => panic!("expected TypeMismatch, got {other:?}"),
515        }
516    }
517
518    /// The `WriteError::TypeMismatch` Display impl includes a
519    /// vec → scalar hint naming the reduction the program needs
520    /// when the rejected `got` is a Vec type and the `expected` is
521    /// not a collection-compatible type.
522    #[test]
523    fn vec_to_scalar_diagnostic_mentions_explicit_helpers() {
524        let err = crate::kernel::api::WriteError::TypeMismatch {
525            slot: "score".into(),
526            expected: PortType::F64,
527            got: PortType::VecF32,
528        };
529        let msg = err.to_string();
530        assert!(
531            msg.contains("reduction node"),
532            "missing reduction hint: {msg}"
533        );
534        assert!(msg.contains("vec_dot"), "missing vec_dot hint: {msg}");
535    }
536
537    /// S4 type-adapt: a healable mismatch (u64 → f64) routes
538    /// through the boundary auto-adapter rather than rejecting.
539    #[test]
540    fn dataflow_healable_mismatch_adapts() {
541        let mut k = compile_polydat_interpreter("input cycle: u64\nextern x: f64\n").unwrap();
542        // u64 → f64 has an auto-adapter (lossless widening); the
543        // typed-write API should accept this transparently.
544        k.set_wire("x", Value::U64(42))
545            .expect("u64→f64 boundary adapter");
546        match k.get_wire("x") {
547            Some(Value::F64(42.0)) => {}
548            other => panic!("expected adapted F64(42.0), got {other:?}"),
549        }
550    }
551
552    /// S4 None pass-through: Value::None is the absent sentinel
553    /// and always permitted at the boundary regardless of slot
554    /// type (per none_semantics.md).
555    #[test]
556    fn dataflow_none_passes_through_any_slot() {
557        let mut k = compile_polydat_interpreter("input cycle: u64\nextern n: u64\n").unwrap();
558        k.set_wire("n", Value::None).expect("None always permitted");
559    }
560
561    /// Metadata trait surfaces names + types.
562    #[test]
563    fn metadata_listings() {
564        let k = compile_polydat_interpreter(
565            "input (cycle: u64, thread: u64)\nextern n: u64\nconst x := 7\n",
566        )
567        .unwrap();
568        let inputs: Vec<String> = k.input_names();
569        assert!(inputs.iter().any(|s| s == "cycle"));
570        assert!(inputs.iter().any(|s| s == "n"));
571        assert_eq!(k.coord_count(), 2); // cycle + thread
572        assert!(k.find_input("n").is_some());
573        assert_eq!(k.input_port_type("n"), Some(PortType::U64));
574    }
575
576    /// Construction trait — both paths take the same polydat
577    /// matter type. Verify symmetry: root from source, then
578    /// subscope from source against the root.
579    #[test]
580    fn construction_symmetric_paths() {
581        let root_opts = crate::kernel::subcontext::CompileOptions {
582            workload_dir: None,
583            polydat_lib_paths: Vec::new(),
584            strict: false,
585            required_outputs: Vec::new(),
586            context_label: Some("root".to_string()),
587            cursor_limit: None,
588            ..Default::default()
589        };
590        let root_matter = crate::kernel::subcontext::PolydatMatter::builder()
591            .label("root")
592            .source("input cycle: u64\nshared flag := 0\n")
593            .options(root_opts)
594            .build()
595            .expect("matter build");
596        let root =
597            <PolydatKernel as Construction>::root(root_matter).expect("root from source matter");
598
599        let sub_opts = crate::kernel::subcontext::CompileOptions {
600            workload_dir: None,
601            polydat_lib_paths: Vec::new(),
602            strict: false,
603            required_outputs: Vec::new(),
604            context_label: Some("sub".to_string()),
605            cursor_limit: None,
606            ..Default::default()
607        };
608        let sub_matter = crate::kernel::subcontext::PolydatMatter::builder()
609            .label("sub")
610            .source("input cycle: u64\n")
611            .options(sub_opts)
612            .build()
613            .expect("matter build");
614        let _sub = root
615            .subscope(sub_matter)
616            .expect("subscope from source matter");
617    }
618
619    /// Root construction also accepts pre-compiled program
620    /// matter (re-instance with fresh state). Verifies via
621    /// the input slot — `n` is an extern input.
622    #[test]
623    fn construction_root_from_program() {
624        let template = compile_polydat_interpreter("input cycle: u64\nextern n: u64\n").unwrap();
625        let program = template.program().clone();
626        let matter = crate::kernel::subcontext::PolydatMatter::builder()
627            .program(program)
628            .build()
629            .expect("matter build");
630        let mut root =
631            <PolydatKernel as Construction>::root(matter).expect("root from program matter");
632        root.set_wire("n", Value::U64(13)).expect("set_wire");
633        assert_eq!(root.get_wire("n"), Some(Value::U64(13)));
634    }
635
636    /// Builder rejects ambiguous matter (multiple input forms).
637    #[test]
638    fn builder_rejects_multiple_forms() {
639        let template = compile_polydat_interpreter("input cycle: u64\n").unwrap();
640        match crate::kernel::subcontext::PolydatMatter::builder()
641            .source("input cycle: u64\n")
642            .program(template.program().clone())
643            .build()
644        {
645            Err(msg) => assert!(
646                msg.contains("multiple"),
647                "expected multiple-forms error, got: {msg}"
648            ),
649            Ok(_) => panic!("multiple forms must error"),
650        }
651    }
652
653    /// Builder rejects empty matter.
654    #[test]
655    fn builder_rejects_empty() {
656        match crate::kernel::subcontext::PolydatMatter::builder().build() {
657            Err(msg) => assert!(
658                msg.contains("no input form"),
659                "expected no-form error, got: {msg}"
660            ),
661            Ok(_) => panic!("empty matter must error"),
662        }
663    }
664}