use std::collections::{HashMap, HashSet};
use crate::ast::{PolydatNode, PortType};
use crate::compile::assembly::boundary_adapter;
use crate::kernel::{InputDef, WireSource};
#[derive(Debug, Clone)]
pub struct RoundTripFinding {
pub restored: PortType,
pub via: PortType,
pub departure_node: String,
pub restore_node: String,
}
impl RoundTripFinding {
pub fn message(&self) -> String {
format!(
"type round trip: a {restored:?} value is modulated to {via:?} \
(at '{dep}') and restored to {restored:?} (at '{res}') — native \
types must stay native through data passing; render to text only \
at presentation points, or hand off via a by-design intermediary \
(JSON)",
restored = self.restored,
via = self.via,
dep = self.departure_node,
res = self.restore_node,
)
}
}
const LINTABLE_TYPES: [PortType; 26] = [
PortType::U64, PortType::F64, PortType::U32, PortType::I32,
PortType::I64, PortType::F32, PortType::U8, PortType::I8,
PortType::U16, PortType::I16, PortType::F16, PortType::U128,
PortType::I128, PortType::Bool, PortType::Str, PortType::Bytes,
PortType::Json, PortType::Ext, PortType::Handle,
PortType::VecF32, PortType::VecI32, PortType::VecF64,
PortType::VecI64, PortType::VecF16, PortType::VecI16, PortType::VecI8,
];
fn conversion_registry() -> &'static HashMap<String, (PortType, PortType)> {
static REG: std::sync::OnceLock<HashMap<String, (PortType, PortType)>> =
std::sync::OnceLock::new();
REG.get_or_init(|| {
let mut m = HashMap::new();
for from in LINTABLE_TYPES {
for to in LINTABLE_TYPES {
if from == to {
continue;
}
if let Some(node) = boundary_adapter(from, to) {
m.insert(node.meta().name.clone(), (from, to));
}
}
}
m
})
}
fn is_carrier(name: &str) -> bool {
matches!(name, "printf" | "str_concat" | "select_str" | "identity")
}
pub(crate) fn lint_type_round_trips(
nodes: &[Box<dyn PolydatNode>],
wiring: &[Vec<WireSource>],
input_defs: &[InputDef],
) -> Vec<RoundTripFinding> {
let registry = conversion_registry();
let mut findings = Vec::new();
for (i, node) in nodes.iter().enumerate() {
let Some(&(via, restored)) = registry.get(&node.meta().name) else {
continue;
};
if via == PortType::Json {
continue;
}
let mut visited: HashSet<usize> = HashSet::new();
let mut stack: Vec<&WireSource> = wiring[i].iter().collect();
let mut departure: Option<String> = None;
while let Some(ws) = stack.pop() {
let WireSource::NodeOutput(up, _) = ws else {
continue; };
if !visited.insert(*up) {
continue;
}
let up_meta = nodes[*up].meta();
if let Some(&(dep_from, _dep_to)) = registry.get(&up_meta.name) {
if dep_from == restored {
departure = Some(up_meta.name.clone());
break;
}
stack.extend(wiring[*up].iter());
} else if is_carrier(&up_meta.name) {
for cw in &wiring[*up] {
let t = source_type(cw, nodes, input_defs);
if t == Some(restored) {
departure = Some(up_meta.name.clone());
break;
}
}
if departure.is_some() {
break;
}
stack.extend(wiring[*up].iter());
}
}
if let Some(dep) = departure {
findings.push(RoundTripFinding {
restored,
via,
departure_node: dep,
restore_node: node.meta().name.clone(),
});
}
}
findings
}
fn source_type(
ws: &WireSource,
nodes: &[Box<dyn PolydatNode>],
input_defs: &[InputDef],
) -> Option<PortType> {
match ws {
WireSource::Input(c) => input_defs.get(*c).map(|d| d.port_type),
WireSource::NodeOutput(n, p) => {
nodes.get(*n).and_then(|nd| nd.meta().outs.get(*p)).map(|o| o.typ)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::Value;
use crate::compile::assembly::{AssemblyError, PolydatAssembler, WireRef};
use crate::kernel::InputKind;
fn conv(from: PortType, to: PortType) -> Box<dyn PolydatNode> {
boundary_adapter(from, to).expect("catalog pair")
}
#[test]
fn strict_mode_rejects_scalar_string_round_trip() {
let mut asm = PolydatAssembler::new(vec![]);
asm.set_strict_wires(false, true);
asm.add_input("x", Value::U64(0), PortType::U64, InputKind::Coordinate);
asm.add_node("to_text", conv(PortType::U64, PortType::Str),
vec![WireRef::Input("x".into())]);
asm.add_node("back", conv(PortType::Str, PortType::U64),
vec![WireRef::Node("to_text".into(), 0)]);
asm.add_output("y", WireRef::node("back"));
match asm.compile() {
Err(AssemblyError::Other(msg)) => {
assert!(msg.contains("type round trip"), "got: {msg}");
assert!(msg.contains("U64") && msg.contains("Str"), "got: {msg}");
}
other => panic!("expected strict round-trip rejection, got {other:?}"),
}
}
#[test]
fn default_mode_warns_but_compiles() {
let mut asm = PolydatAssembler::new(vec![]);
asm.add_input("x", Value::U64(0), PortType::U64, InputKind::Coordinate);
asm.add_node("to_text", conv(PortType::U64, PortType::Str),
vec![WireRef::Input("x".into())]);
asm.add_node("back", conv(PortType::Str, PortType::U64),
vec![WireRef::Node("to_text".into(), 0)]);
asm.add_output("y", WireRef::node("back"));
asm.compile().expect("non-strict compile must succeed");
}
#[test]
fn json_intermediary_is_sanctioned() {
let mut asm = PolydatAssembler::new(vec![]);
asm.set_strict_wires(false, true);
asm.add_input("x", Value::U64(0), PortType::U64, InputKind::Coordinate);
asm.add_node("to_json", conv(PortType::U64, PortType::Json),
vec![WireRef::Input("x".into())]);
asm.add_node("back", conv(PortType::Json, PortType::U64),
vec![WireRef::Node("to_json".into(), 0)]);
asm.add_output("y", WireRef::node("back"));
asm.compile().expect("Json hand-off must be sanctioned");
}
#[test]
fn parse_from_text_origin_is_clean() {
let mut asm = PolydatAssembler::new(vec![]);
asm.set_strict_wires(false, true);
asm.add_input("s", Value::Str("1".into()), PortType::Str, InputKind::Coordinate);
asm.add_node("parse", conv(PortType::Str, PortType::U64),
vec![WireRef::Input("s".into())]);
asm.add_output("y", WireRef::node("parse"));
asm.compile().expect("parsing a text origin is legitimate");
}
}