1use std::collections::{HashMap, HashSet};
33
34use crate::ast::{PolydatNode, PortType};
35use crate::compile::assembly::boundary_adapter;
36use crate::kernel::{InputDef, WireSource};
37
38#[derive(Debug, Clone)]
42pub struct RoundTripFinding {
43 pub restored: PortType,
45 pub via: PortType,
47 pub departure_node: String,
49 pub restore_node: String,
51}
52
53impl RoundTripFinding {
54 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
70const 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
103fn 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
126fn is_carrier(name: &str) -> bool {
132 matches!(name, "printf" | "str_concat" | "select_str" | "identity")
133}
134
135pub(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 if via == PortType::Json {
157 continue;
158 }
159 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; };
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 departure = Some(up_meta.name.clone());
178 break;
179 }
180 stack.extend(wiring[*up].iter());
183 } else if is_carrier(&up_meta.name) {
184 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 }
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
218fn 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 #[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 #[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 #[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 #[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 #[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}