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 (C6b; governing principle:
5//! `feedback_no_auto_string_conversion`).
6//!
7//! Detects DAG paths where a value's type is modulated to another type
8//! and then restored — `T → Y → … → T` — purely through conversion /
9//! formatting machinery during synthesis. Native types must stay
10//! native through data passing; a round trip means some hand-off point
11//! was expressed in a foreign type (usually text) and re-parsed, which
12//! loses type fidelity, costs work, and turns downstream type checks
13//! into liars. The canonical instance is the retired identity-shadow
14//! bug (`VecF32 → printf → Str → parse → VecF32`); this lint subsumes
15//! that string case in one structural rule.
16//!
17//! **Classification is derived from the adapter catalogs themselves**
18//! ([`boundary_adapter`] enumerated over the closed `PortType`
19//! surface), so the conversion-node registry cannot drift from the
20//! catalog. Formatting combinators (`printf`, `str_concat`,
21//! `select_str`) and `identity` are *carriers*: walks pass through
22//! them to find the typed value that entered the text domain.
23//!
24//! **Sanctioned intermediaries are exempt**: a restore FROM `Json` is
25//! by-design (JSON is a declared text hand-off format), so
26//! `T → Json → T` is never reported.
27//!
28//! Severity: one [`RoundTripFinding`] per restoring node; the caller
29//! (assembly `resolve`) reports findings as compile warnings by
30//! default and as a hard error under strict-values mode.
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/// `pub(crate)`: the only sanctioned caller is assembly `resolve`
139/// (walled-off chokepoint); hosts observe findings as compile
140/// warnings / strict errors, never by re-running the pass.
141pub(crate) fn lint_type_round_trips(
142    nodes: &[Box<dyn PolydatNode>],
143    wiring: &[Vec<WireSource>],
144    input_defs: &[InputDef],
145) -> Vec<RoundTripFinding> {
146    let registry = conversion_registry();
147    let mut findings = Vec::new();
148
149    for (i, node) in nodes.iter().enumerate() {
150        let Some(&(via, restored)) = registry.get(&node.meta().name) else {
151            continue;
152        };
153        // A restore FROM Json is a by-design hand-off — sanctioned.
154        if via == PortType::Json {
155            continue;
156        }
157        // Walk upstream from the restorer's wire input, through
158        // conversion chains and carriers, looking for the same type
159        // leaving the native domain.
160        let mut visited: HashSet<usize> = HashSet::new();
161        let mut stack: Vec<&WireSource> = wiring[i].iter().collect();
162        let mut departure: Option<String> = None;
163        while let Some(ws) = stack.pop() {
164            let WireSource::NodeOutput(up, _) = ws else {
165                continue; // a raw input is an origin, not a modulation
166            };
167            if !visited.insert(*up) {
168                continue;
169            }
170            let up_meta = nodes[*up].meta();
171            if let Some(&(dep_from, _dep_to)) = registry.get(&up_meta.name) {
172                if dep_from == restored {
173                    // The same type left the native domain upstream —
174                    // the chain between is pure conversion machinery.
175                    departure = Some(up_meta.name.clone());
176                    break;
177                }
178                // A different conversion in the chain: keep walking
179                // through it (multi-hop modulation, e.g. T→Y→Y'→T).
180                stack.extend(wiring[*up].iter());
181            } else if is_carrier(&up_meta.name) {
182                // A formatter/passthrough: if any of its inputs is
183                // natively the restored type, the carrier is where
184                // the value entered the modulated domain.
185                for cw in &wiring[*up] {
186                    let t = source_type(cw, nodes, input_defs);
187                    if t == Some(restored) {
188                        departure = Some(up_meta.name.clone());
189                        break;
190                    }
191                }
192                if departure.is_some() {
193                    break;
194                }
195                stack.extend(wiring[*up].iter());
196            }
197            // Any other node kind is semantic computation — the walk
198            // stops there; a value produced by real computation in the
199            // via-type domain is not a round trip.
200        }
201        if let Some(dep) = departure {
202            findings.push(RoundTripFinding {
203                restored,
204                via,
205                departure_node: dep,
206                restore_node: node.meta().name.clone(),
207            });
208        }
209    }
210    findings
211}
212
213/// The static type of a wire source.
214fn source_type(
215    ws: &WireSource,
216    nodes: &[Box<dyn PolydatNode>],
217    input_defs: &[InputDef],
218) -> Option<PortType> {
219    match ws {
220        WireSource::Input(c) => input_defs.get(*c).map(|d| d.port_type),
221        WireSource::NodeOutput(n, p) => nodes
222            .get(*n)
223            .and_then(|nd| nd.meta().outs.get(*p))
224            .map(|o| o.typ),
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::ast::Value;
232    use crate::compile::assembly::{AssemblyError, PolydatAssembler, WireRef};
233    use crate::kernel::InputKind;
234
235    fn conv(from: PortType, to: PortType) -> Box<dyn PolydatNode> {
236        boundary_adapter(from, to).expect("catalog pair")
237    }
238
239    /// U64 → Str → U64 through pure conversions is the canonical
240    /// mechanical round trip: strict-values mode fails the compile
241    /// with a message naming the modulation.
242    #[test]
243    fn strict_mode_rejects_scalar_string_round_trip() {
244        let mut asm = PolydatAssembler::new(vec![]);
245        asm.set_strict_wires(false, true);
246        asm.add_input("x", Value::U64(0), PortType::U64, InputKind::Coordinate);
247        asm.add_node(
248            "to_text",
249            conv(PortType::U64, PortType::Str),
250            vec![WireRef::Input("x".into())],
251        );
252        asm.add_node(
253            "back",
254            conv(PortType::Str, PortType::U64),
255            vec![WireRef::Node("to_text".into(), 0)],
256        );
257        asm.add_output("y", WireRef::node("back"));
258        match asm.compile() {
259            Err(AssemblyError::Other(msg)) => {
260                assert!(msg.contains("type round trip"), "got: {msg}");
261                assert!(msg.contains("U64") && msg.contains("Str"), "got: {msg}");
262            }
263            other => panic!("expected strict round-trip rejection, got {other:?}"),
264        }
265    }
266
267    /// The same graph without strict mode compiles (warning only).
268    #[test]
269    fn default_mode_warns_but_compiles() {
270        let mut asm = PolydatAssembler::new(vec![]);
271        asm.add_input("x", Value::U64(0), PortType::U64, InputKind::Coordinate);
272        asm.add_node(
273            "to_text",
274            conv(PortType::U64, PortType::Str),
275            vec![WireRef::Input("x".into())],
276        );
277        asm.add_node(
278            "back",
279            conv(PortType::Str, PortType::U64),
280            vec![WireRef::Node("to_text".into(), 0)],
281        );
282        asm.add_output("y", WireRef::node("back"));
283        asm.compile().expect("non-strict compile must succeed");
284    }
285
286    /// T → Json → T is a by-design hand-off: clean even under strict.
287    #[test]
288    fn json_intermediary_is_sanctioned() {
289        let mut asm = PolydatAssembler::new(vec![]);
290        asm.set_strict_wires(false, true);
291        asm.add_input("x", Value::U64(0), PortType::U64, InputKind::Coordinate);
292        asm.add_node(
293            "to_json",
294            conv(PortType::U64, PortType::Json),
295            vec![WireRef::Input("x".into())],
296        );
297        asm.add_node(
298            "back",
299            conv(PortType::Json, PortType::U64),
300            vec![WireRef::Node("to_json".into(), 0)],
301        );
302        asm.add_output("y", WireRef::node("back"));
303        asm.compile().expect("Json hand-off must be sanctioned");
304    }
305
306    /// A parser fed from a genuine text ORIGIN (a Str input) is not a
307    /// round trip — nothing left the native domain. Clean under strict.
308    #[test]
309    fn parse_from_text_origin_is_clean() {
310        let mut asm = PolydatAssembler::new(vec![]);
311        asm.set_strict_wires(false, true);
312        asm.add_input(
313            "s",
314            Value::Str("1".into()),
315            PortType::Str,
316            InputKind::Coordinate,
317        );
318        asm.add_node(
319            "parse",
320            conv(PortType::Str, PortType::U64),
321            vec![WireRef::Input("s".into())],
322        );
323        asm.add_output("y", WireRef::node("parse"));
324        asm.compile().expect("parsing a text origin is legitimate");
325    }
326}