Skip to main content

polydat_core/compile/
roundtrip_lint.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Structural type-round-trip lint (graph_compiler.md §2).
5//!
6//! Detects DAG paths where a value's type is modulated to another type
7//! and then restored — `T → Y → … → T` — purely through conversion /
8//! formatting machinery during synthesis. Native types must stay
9//! native through data passing; a round trip means some hand-off point
10//! was expressed in a foreign type (usually text) and re-parsed, which
11//! loses type fidelity, costs work, and turns downstream type checks
12//! into liars. The canonical instance is a value shadowed through text
13//! (`VecF32 → printf → Str → parse → VecF32`); this lint covers that
14//! string case and every other round trip in one structural rule.
15//!
16//! **Classification is derived from the adapter catalogs themselves**
17//! ([`boundary_adapter`] enumerated over the closed `PortType`
18//! surface), so the conversion-node registry cannot drift from the
19//! catalog. Formatting combinators (`printf`, `str_concat`,
20//! `select_str`) and `identity` are *carriers*: walks pass through
21//! them to find the typed value that entered the text domain.
22//!
23//! **Sanctioned intermediaries are exempt**: a restore FROM `Json` is
24//! by-design (JSON is a declared text hand-off format), so
25//! `T → Json → T` is never reported.
26//!
27//! Severity: one [`RoundTripFinding`] per restoring node; the caller
28//! (assembly `resolve`) reports a finding as a compile warning, or as
29//! a hard error when the scope the restoring node was written in has
30//! `strict_values` on (polydat_grammar.md §14.1).
31
32use std::collections::{HashMap, HashSet};
33
34use crate::ast::{PolydatNode, PortType};
35use crate::compile::assembly::boundary_adapter;
36use crate::kernel::{InputDef, WireSource};
37
38/// One detected round trip: `restored` left the native domain through
39/// `departure_node` (or entered a formatting carrier natively) and was
40/// restored by `restore_node` via the `via` type.
41#[derive(Debug, Clone)]
42pub struct RoundTripFinding {
43    /// The type restored.
44    pub restored: PortType,
45    /// The type it went through.
46    pub via: PortType,
47    /// The node that left the type.
48    pub departure_node: String,
49    /// The node that restored it.
50    pub restore_node: String,
51}
52
53impl RoundTripFinding {
54    /// Operator-facing message: names both ends and the principle.
55    pub fn message(&self) -> String {
56        format!(
57            "type round trip: a {restored:?} value is modulated to {via:?} \
58             (at '{dep}') and restored to {restored:?} (at '{res}') — native \
59             types must stay native through data passing; render to text only \
60             at presentation points, or hand off via a by-design intermediary \
61             (JSON)",
62            restored = self.restored,
63            via = self.via,
64            dep = self.departure_node,
65            res = self.restore_node,
66        )
67    }
68}
69
70/// The closed `PortType` surface the catalogs cover (SIMD register
71/// types carry no adapters and are excluded). Adding a variant here is
72/// only ever a lint-coverage improvement — omission under-lints, never
73/// mis-lints, because classification still comes from the catalog.
74const LINTABLE_TYPES: [PortType; 26] = [
75    PortType::U64,
76    PortType::F64,
77    PortType::U32,
78    PortType::I32,
79    PortType::I64,
80    PortType::F32,
81    PortType::U8,
82    PortType::I8,
83    PortType::U16,
84    PortType::I16,
85    PortType::F16,
86    PortType::U128,
87    PortType::I128,
88    PortType::Bool,
89    PortType::Str,
90    PortType::Bytes,
91    PortType::Json,
92    PortType::Ext,
93    PortType::Handle,
94    PortType::VecF32,
95    PortType::VecI32,
96    PortType::VecF64,
97    PortType::VecI64,
98    PortType::VecF16,
99    PortType::VecI16,
100    PortType::VecI8,
101];
102
103/// Conversion-node registry: node meta-name → (from, to), enumerated
104/// once from the boundary catalog (the superset — every auto adapter
105/// is also a boundary adapter). Cached process-wide; the catalog is
106/// static.
107fn conversion_registry() -> &'static HashMap<String, (PortType, PortType)> {
108    static REG: std::sync::OnceLock<HashMap<String, (PortType, PortType)>> =
109        std::sync::OnceLock::new();
110    REG.get_or_init(|| {
111        let mut m = HashMap::new();
112        for from in LINTABLE_TYPES {
113            for to in LINTABLE_TYPES {
114                if from == to {
115                    continue;
116                }
117                if let Some(node) = boundary_adapter(from, to) {
118                    m.insert(node.meta().name.clone(), (from, to));
119                }
120            }
121        }
122        m
123    })
124}
125
126/// Formatting / passthrough carriers the walk looks through. These
127/// nodes move a value between edges without changing its *information
128/// identity* (identity) or combine typed values into text
129/// (formatters) — the walk continues into their inputs to find the
130/// native value that entered the modulated domain.
131fn is_carrier(name: &str) -> bool {
132    matches!(name, "printf" | "str_concat" | "select_str" | "identity")
133}
134
135/// Run the lint over a resolved DAG (topologically sorted nodes +
136/// wiring, as built at the end of assembly `resolve`). Returns one
137/// finding per restoring conversion node that closes a round trip,
138/// with the restoring node's index, which names the scope whose
139/// pragmas decide the finding's severity.
140/// `pub(crate)`: the only sanctioned caller is assembly `resolve`
141/// (walled-off chokepoint); hosts observe findings as compile
142/// warnings / strict errors, never by re-running the pass.
143pub(crate) fn lint_type_round_trips(
144    nodes: &[Box<dyn PolydatNode>],
145    wiring: &[Vec<WireSource>],
146    input_defs: &[InputDef],
147) -> Vec<(usize, RoundTripFinding)> {
148    let registry = conversion_registry();
149    let mut findings = Vec::new();
150
151    for (i, node) in nodes.iter().enumerate() {
152        let Some(&(via, restored)) = registry.get(&node.meta().name) else {
153            continue;
154        };
155        // A restore FROM Json is a by-design hand-off — sanctioned.
156        if via == PortType::Json {
157            continue;
158        }
159        // Walk upstream from the restorer's wire input, through
160        // conversion chains and carriers, looking for the same type
161        // leaving the native domain.
162        let mut visited: HashSet<usize> = HashSet::new();
163        let mut stack: Vec<&WireSource> = wiring[i].iter().collect();
164        let mut departure: Option<String> = None;
165        while let Some(ws) = stack.pop() {
166            let WireSource::NodeOutput(up, _) = ws else {
167                continue; // a raw input is an origin, not a modulation
168            };
169            if !visited.insert(*up) {
170                continue;
171            }
172            let up_meta = nodes[*up].meta();
173            if let Some(&(dep_from, _dep_to)) = registry.get(&up_meta.name) {
174                if dep_from == restored {
175                    // The same type left the native domain upstream —
176                    // the chain between is pure conversion machinery.
177                    departure = Some(up_meta.name.clone());
178                    break;
179                }
180                // A different conversion in the chain: keep walking
181                // through it (multi-hop modulation, e.g. T→Y→Y'→T).
182                stack.extend(wiring[*up].iter());
183            } else if is_carrier(&up_meta.name) {
184                // A formatter/passthrough: if any of its inputs is
185                // natively the restored type, the carrier is where
186                // the value entered the modulated domain.
187                for cw in &wiring[*up] {
188                    let t = source_type(cw, nodes, input_defs);
189                    if t == Some(restored) {
190                        departure = Some(up_meta.name.clone());
191                        break;
192                    }
193                }
194                if departure.is_some() {
195                    break;
196                }
197                stack.extend(wiring[*up].iter());
198            }
199            // Any other node kind is semantic computation — the walk
200            // stops there; a value produced by real computation in the
201            // via-type domain is not a round trip.
202        }
203        if let Some(dep) = departure {
204            findings.push((
205                i,
206                RoundTripFinding {
207                    restored,
208                    via,
209                    departure_node: dep,
210                    restore_node: node.meta().name.clone(),
211                },
212            ));
213        }
214    }
215    findings
216}
217
218/// The static type of a wire source.
219fn source_type(
220    ws: &WireSource,
221    nodes: &[Box<dyn PolydatNode>],
222    input_defs: &[InputDef],
223) -> Option<PortType> {
224    match ws {
225        WireSource::Input(c) => input_defs.get(*c).map(|d| d.port_type),
226        WireSource::NodeOutput(n, p) => nodes
227            .get(*n)
228            .and_then(|nd| nd.meta().outs.get(*p))
229            .map(|o| o.typ),
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236    use crate::ast::Value;
237    use crate::compile::assembly::{AssemblyError, PolydatAssembler, WireRef};
238    use crate::kernel::InputKind;
239
240    fn conv(from: PortType, to: PortType) -> Box<dyn PolydatNode> {
241        boundary_adapter(from, to).expect("catalog pair")
242    }
243
244    /// U64 → Str → U64 through pure conversions is the canonical
245    /// mechanical round trip: strict-values mode fails the compile
246    /// with a message naming the modulation.
247    #[test]
248    fn strict_mode_rejects_scalar_string_round_trip() {
249        let mut asm = PolydatAssembler::new(vec![]);
250        asm.set_strict_wires(false, true);
251        asm.add_input("x", Value::U64(0), PortType::U64, InputKind::Coordinate);
252        asm.add_node(
253            "to_text",
254            conv(PortType::U64, PortType::Str),
255            vec![WireRef::Input("x".into())],
256        );
257        asm.add_node(
258            "back",
259            conv(PortType::Str, PortType::U64),
260            vec![WireRef::Node("to_text".into(), 0)],
261        );
262        asm.add_output("y", WireRef::node("back"));
263        match asm.compile() {
264            Err(AssemblyError::Other(msg)) => {
265                assert!(msg.contains("type round trip"), "got: {msg}");
266                assert!(msg.contains("U64") && msg.contains("Str"), "got: {msg}");
267            }
268            other => panic!("expected strict round-trip rejection, got {other:?}"),
269        }
270    }
271
272    /// The same graph without strict mode compiles (warning only).
273    #[test]
274    fn default_mode_warns_but_compiles() {
275        let mut asm = PolydatAssembler::new(vec![]);
276        asm.add_input("x", Value::U64(0), PortType::U64, InputKind::Coordinate);
277        asm.add_node(
278            "to_text",
279            conv(PortType::U64, PortType::Str),
280            vec![WireRef::Input("x".into())],
281        );
282        asm.add_node(
283            "back",
284            conv(PortType::Str, PortType::U64),
285            vec![WireRef::Node("to_text".into(), 0)],
286        );
287        asm.add_output("y", WireRef::node("back"));
288        asm.compile().expect("non-strict compile must succeed");
289    }
290
291    /// A round trip closed by a node written in another scope is
292    /// decided by that scope's mark, whatever the program's set says:
293    /// a lax scope in a strict program warns, and a strict scope in a
294    /// lax program fails.
295    #[test]
296    fn the_restoring_nodes_scope_decides_the_severity() {
297        for (program_strict, scope_strict) in [(true, false), (false, true)] {
298            let mut asm = PolydatAssembler::new(vec![]);
299            asm.set_strict_wires(false, program_strict);
300            asm.add_input("x", Value::U64(0), PortType::U64, InputKind::Coordinate);
301            asm.add_node(
302                "to_text",
303                conv(PortType::U64, PortType::Str),
304                vec![WireRef::Input("x".into())],
305            );
306            let before = asm.set_scope_strict_values(Some(scope_strict));
307            asm.add_node(
308                "back",
309                conv(PortType::Str, PortType::U64),
310                vec![WireRef::Node("to_text".into(), 0)],
311            );
312            asm.set_scope_strict_values(before);
313            asm.add_output("y", WireRef::node("back"));
314            let result = asm.compile();
315            assert_eq!(
316                result.is_err(),
317                scope_strict,
318                "program strict {program_strict}, scope strict {scope_strict}: {:?}",
319                result.err()
320            );
321        }
322    }
323
324    /// T → Json → T is a by-design hand-off: clean even under strict.
325    #[test]
326    fn json_intermediary_is_sanctioned() {
327        let mut asm = PolydatAssembler::new(vec![]);
328        asm.set_strict_wires(false, true);
329        asm.add_input("x", Value::U64(0), PortType::U64, InputKind::Coordinate);
330        asm.add_node(
331            "to_json",
332            conv(PortType::U64, PortType::Json),
333            vec![WireRef::Input("x".into())],
334        );
335        asm.add_node(
336            "back",
337            conv(PortType::Json, PortType::U64),
338            vec![WireRef::Node("to_json".into(), 0)],
339        );
340        asm.add_output("y", WireRef::node("back"));
341        asm.compile().expect("Json hand-off must be sanctioned");
342    }
343
344    /// A parser fed from a genuine text ORIGIN (a Str input) is not a
345    /// round trip — nothing left the native domain. Clean under strict.
346    #[test]
347    fn parse_from_text_origin_is_clean() {
348        let mut asm = PolydatAssembler::new(vec![]);
349        asm.set_strict_wires(false, true);
350        asm.add_input(
351            "s",
352            Value::Str("1".into()),
353            PortType::Str,
354            InputKind::Coordinate,
355        );
356        asm.add_node(
357            "parse",
358            conv(PortType::Str, PortType::U64),
359            vec![WireRef::Input("s".into())],
360        );
361        asm.add_output("y", WireRef::node("parse"));
362        asm.compile().expect("parsing a text origin is legitimate");
363    }
364}