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(
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 if via == PortType::Json {
155 continue;
156 }
157 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; };
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 departure = Some(up_meta.name.clone());
176 break;
177 }
178 stack.extend(wiring[*up].iter());
181 } else if is_carrier(&up_meta.name) {
182 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 }
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
213fn 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 #[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 #[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 #[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 #[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}