Skip to main content

flutmax_codegen/
builder.rs

1/// AST -> PatchGraph conversion
2///
3/// Converts `Program` (AST) to `PatchGraph`.
4/// Each InDecl/OutDecl/Wire/OutAssignment is converted to corresponding nodes and edges,
5/// then `insert_triggers()` auto-inserts triggers for fanouts.
6use std::collections::{HashMap, HashSet};
7
8#[allow(unused_imports)] // CallArg used in tests
9use flutmax_ast::{
10    CallArg, DestructuringWire, DirectConnection, Expr, FeedbackAssignment, FeedbackDecl, InDecl,
11    LitValue, MsgDecl, OutAssignment, OutDecl, PortType, Program, StateAssignment, StateDecl, Wire,
12};
13use flutmax_objdb::{InletSpec, ObjectDb, OutletSpec};
14use flutmax_sema::graph::{NodePurity, PatchEdge, PatchGraph, PatchNode};
15use flutmax_sema::registry::AbstractionRegistry;
16use flutmax_sema::trigger::insert_triggers;
17
18/// Code file mapping. Filename -> code content.
19/// Used when referencing external code files in `v8.codebox` and `codebox` (gen~).
20pub type CodeFiles = HashMap<String, String>;
21
22/// Build error
23#[derive(Debug)]
24pub enum BuildError {
25    /// Referenced an undefined variable
26    UndefinedRef(String),
27    /// Output port index out of range
28    OutletIndexOutOfRange(u32),
29    /// E004: no out declaration corresponding to out[N]
30    NoOutDeclaration(u32),
31    /// E006: destructuring LHS count does not match RHS outlet count
32    DestructuringCountMismatch { expected: usize, got: usize },
33    /// E009: Abstraction argument count does not match in_ports count
34    AbstractionArgCountMismatch {
35        name: String,
36        expected: usize,
37        got: usize,
38    },
39    /// E013: multiple assignments to the same feedback variable
40    DuplicateFeedbackAssignment(String),
41    /// E007: port index out of range
42    InvalidPortIndex {
43        node: String,
44        port: String,
45        index: u32,
46        max: u32,
47    },
48    /// E020: bare reference to multi-outlet node (.out[N] required)
49    BareMultiOutletRef { name: String, num_outlets: u32 },
50    /// E019: multiple assignments to the same state
51    DuplicateStateAssignment(String),
52}
53
54impl std::fmt::Display for BuildError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            BuildError::UndefinedRef(name) => write!(f, "undefined reference: {}", name),
58            BuildError::OutletIndexOutOfRange(idx) => {
59                write!(f, "outlet index out of range: {}", idx)
60            }
61            BuildError::NoOutDeclaration(idx) => {
62                write!(f, "E004: out[{}] has no corresponding out declaration", idx)
63            }
64            BuildError::DestructuringCountMismatch { expected, got } => {
65                write!(
66                    f,
67                    "E006: destructuring count mismatch: expected {} names, got {}",
68                    expected, got
69                )
70            }
71            BuildError::AbstractionArgCountMismatch {
72                name,
73                expected,
74                got,
75            } => {
76                write!(
77                    f,
78                    "E009: abstraction '{}' expects {} arguments, got {}",
79                    name, expected, got
80                )
81            }
82            BuildError::DuplicateFeedbackAssignment(name) => {
83                write!(f, "E013: duplicate feedback assignment to '{}'", name)
84            }
85            BuildError::InvalidPortIndex {
86                node,
87                port,
88                index,
89                max,
90            } => {
91                write!(
92                    f,
93                    "E007: port index out of range: {}.{}[{}] (max: {})",
94                    node, port, index, max
95                )
96            }
97            BuildError::BareMultiOutletRef { name, num_outlets } => {
98                write!(
99                    f,
100                    "E020: bare reference to multi-outlet node '{}' ({} outlets); use .out[N] to specify which outlet",
101                    name, num_outlets
102                )
103            }
104            BuildError::DuplicateStateAssignment(name) => {
105                write!(f, "E019: duplicate state assignment to '{}'", name)
106            }
107        }
108    }
109}
110
111impl std::error::Error for BuildError {}
112
113/// Build warning
114#[derive(Debug, Clone)]
115pub enum BuildWarning {
116    /// W001: duplicate connection to the same inlet
117    DuplicateInletConnection {
118        node_id: String,
119        inlet: u32,
120        count: usize,
121    },
122}
123
124impl std::fmt::Display for BuildWarning {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        match self {
127            BuildWarning::DuplicateInletConnection {
128                node_id,
129                inlet,
130                count,
131            } => {
132                write!(
133                    f,
134                    "W001: {} connections to {}.in[{}]",
135                    count, node_id, inlet
136                )
137            }
138        }
139    }
140}
141
142/// Build result (graph + warnings)
143pub struct BuildResult {
144    pub graph: PatchGraph,
145    pub warnings: Vec<BuildWarning>,
146}
147
148/// Builder that constructs a PatchGraph from AST.
149struct GraphBuilder<'a> {
150    graph: PatchGraph,
151    /// Sequential node ID counter
152    next_id: u32,
153    /// Name -> (node_id, outlet_index) mapping
154    /// Used to look up nodes from inlet names and wire names
155    name_map: HashMap<String, (String, u32)>,
156    /// out_decl index -> node_id
157    outlet_nodes: HashMap<u32, String>,
158    /// Abstraction registry (used during multi-file compilation)
159    registry: Option<&'a AbstractionRegistry>,
160    /// feedback name -> tapin~ node ID mapping
161    feedback_map: HashMap<String, String>,
162    /// Set of already-assigned feedback names (E013: duplicate detection)
163    assigned_feedbacks: HashSet<String>,
164    /// Set of names generated by destructuring assignments (excluded from E020)
165    destructured_names: HashSet<String>,
166    /// Set of already-assigned state names (E019: duplicate detection)
167    assigned_states: HashSet<String>,
168    /// Tuple wire name -> pack type arguments for each element ("i", "f", "s")
169    /// Used for typed unpack generation during destructuring
170    tuple_type_args: HashMap<String, Vec<String>>,
171    /// Code file mapping (for codebox)
172    code_files: Option<&'a CodeFiles>,
173    /// Object definition database (used for inlet/outlet count inference)
174    objdb: Option<&'a ObjectDb>,
175}
176
177impl<'a> GraphBuilder<'a> {
178    fn new(
179        registry: Option<&'a AbstractionRegistry>,
180        code_files: Option<&'a CodeFiles>,
181        objdb: Option<&'a ObjectDb>,
182    ) -> Self {
183        Self {
184            graph: PatchGraph::new(),
185            next_id: 1,
186            name_map: HashMap::new(),
187            outlet_nodes: HashMap::new(),
188            registry,
189            feedback_map: HashMap::new(),
190            assigned_feedbacks: HashSet::new(),
191            destructured_names: HashSet::new(),
192            assigned_states: HashSet::new(),
193            tuple_type_args: HashMap::new(),
194            code_files,
195            objdb,
196        }
197    }
198
199    /// Generate a new node ID.
200    fn gen_id(&mut self) -> String {
201        let id = format!("obj-{}", self.next_id);
202        self.next_id += 1;
203        id
204    }
205
206    /// Convert an InDecl to a node.
207    fn add_inlet(&mut self, decl: &InDecl) {
208        let id = self.gen_id();
209        let is_signal = decl.port_type.is_signal();
210        let object_name = if is_signal { "inlet~" } else { "inlet" };
211        let num_inlets = if is_signal { 1 } else { 0 };
212        let node = PatchNode {
213            id: id.clone(),
214            object_name: object_name.to_string(),
215            args: vec![],
216            num_inlets,
217            num_outlets: 1,
218            is_signal,
219            varname: None,
220            hot_inlets: default_hot_inlets(object_name, num_inlets),
221            purity: classify_purity(object_name),
222            attrs: vec![],
223            code: None,
224        };
225        self.graph.add_node(node);
226        // Register inlet name for lookup
227        self.name_map.insert(decl.name.clone(), (id, 0));
228    }
229
230    /// Convert an OutDecl to a node.
231    fn add_outlet(&mut self, decl: &OutDecl) {
232        let id = self.gen_id();
233        let is_signal = decl.port_type.is_signal();
234        let object_name = if is_signal { "outlet~" } else { "outlet" };
235        let node = PatchNode {
236            id: id.clone(),
237            object_name: object_name.to_string(),
238            args: vec![],
239            num_inlets: 1,
240            num_outlets: 0,
241            is_signal,
242            varname: None,
243            hot_inlets: default_hot_inlets(object_name, 1),
244            purity: classify_purity(object_name),
245            attrs: vec![],
246            code: None,
247        };
248        self.graph.add_node(node);
249        self.outlet_nodes.insert(decl.index, id);
250    }
251
252    /// Process a MsgDecl. Generate a Max message box node.
253    fn add_msg(&mut self, decl: &MsgDecl) {
254        let id = self.gen_id();
255        let attrs = decl
256            .attrs
257            .iter()
258            .map(|a| (a.key.clone(), format_attr_value(&a.value)))
259            .collect();
260        let node = PatchNode {
261            id: id.clone(),
262            object_name: "message".to_string(),
263            args: vec![decl.content.clone()],
264            num_inlets: 2, // inlet 0 = hot (bang/message), inlet 1 = cold (set)
265            num_outlets: 1,
266            is_signal: false,
267            varname: Some(decl.name.clone()),
268            hot_inlets: vec![true, false],
269            purity: classify_purity("message"),
270            attrs,
271            code: None,
272        };
273        self.graph.add_node(node);
274        self.name_map.insert(decl.name.clone(), (id, 0));
275    }
276
277    /// Process a Wire. Evaluate the expression and generate nodes/edges.
278    fn add_wire(&mut self, wire: &Wire) -> Result<(), BuildError> {
279        // For Tuples, record each element's type argument (for propagation during destructuring)
280        if let Expr::Tuple(elements) = &wire.value {
281            let type_args: Vec<String> = elements.iter().map(infer_pack_type_arg).collect();
282            self.tuple_type_args.insert(wire.name.clone(), type_args);
283        }
284
285        let (node_id, outlet) = self.resolve_expr(&wire.value)?;
286        // Set wire name as varname (for debugging in Max)
287        if let Some(node) = self.graph.nodes.iter_mut().find(|n| n.id == node_id) {
288            node.varname = Some(wire.name.clone());
289        }
290        // Transfer .attr() chain attributes to the node
291        if !wire.attrs.is_empty() {
292            if let Some(node) = self.graph.nodes.iter_mut().find(|n| n.id == node_id) {
293                node.attrs = wire
294                    .attrs
295                    .iter()
296                    .map(|a| (a.key.clone(), format_attr_value(&a.value)))
297                    .collect();
298            }
299        }
300        self.name_map.insert(wire.name.clone(), (node_id, outlet));
301        Ok(())
302    }
303
304    /// Process an OutAssignment. Connect the wire's output to the outlet node.
305    fn add_out_assignment(&mut self, assign: &OutAssignment) -> Result<(), BuildError> {
306        let (source_id, source_outlet) = self.resolve_expr(&assign.value)?;
307        let dest_id = self
308            .outlet_nodes
309            .get(&assign.index)
310            .ok_or(BuildError::NoOutDeclaration(assign.index))?
311            .clone();
312
313        self.graph.add_edge(PatchEdge {
314            source_id,
315            source_outlet,
316            dest_id,
317            dest_inlet: 0,
318            is_feedback: false,
319            order: None,
320        });
321        Ok(())
322    }
323
324    /// Resolve an expression. Generate nodes if needed, and return (node_id, outlet_index).
325    fn resolve_expr(&mut self, expr: &Expr) -> Result<(String, u32), BuildError> {
326        match expr {
327            Expr::Ref(name) => {
328                let (node_id, outlet_index) = self
329                    .name_map
330                    .get(name)
331                    .ok_or_else(|| BuildError::UndefinedRef(name.clone()))?
332                    .clone();
333
334                // Bare references are always interpreted as outlet 0 (E020 removed).
335                // Bare references are OK even for multi-outlet nodes.
336                // Use .out[N] to access outlet 1+.
337
338                Ok((node_id, outlet_index))
339            }
340            Expr::Call { object, args } => {
341                let id = self.gen_id();
342                let max_name = resolve_max_object_name(object);
343                let is_signal = max_name.ends_with('~');
344
345                // Collect literal arguments as part of object text
346                let mut lit_args: Vec<String> = Vec::new();
347                // Collect Ref arguments to create edges later
348                let mut ref_connections: Vec<(String, u32, u32)> = Vec::new(); // (source_node, source_outlet, dest_inlet)
349
350                for (i, arg) in args.iter().enumerate() {
351                    // Named argument → resolve inlet index from objdb or AbstractionRegistry;
352                    // positional argument → use index directly.
353                    let inlet_idx = if let Some(ref name) = arg.name {
354                        resolve_inlet_name(max_name, name, self.objdb)
355                            .or_else(|| resolve_abstraction_inlet_name(object, name, self.registry))
356                            .unwrap_or(i as u32)
357                    } else {
358                        i as u32
359                    };
360
361                    match &arg.value {
362                        Expr::Lit(lit) => {
363                            lit_args.push(format_lit(lit));
364                        }
365                        Expr::Ref(name) => {
366                            let (ref_node_id, ref_outlet) = self
367                                .name_map
368                                .get(name)
369                                .ok_or_else(|| BuildError::UndefinedRef(name.clone()))?
370                                .clone();
371                            ref_connections.push((ref_node_id, ref_outlet, inlet_idx));
372                        }
373                        Expr::Call { .. } => {
374                            // Recursively resolve nested calls
375                            let (nested_id, nested_outlet) = self.resolve_expr(&arg.value)?;
376                            ref_connections.push((nested_id, nested_outlet, inlet_idx));
377                        }
378                        Expr::OutputPortAccess(opa) => {
379                            // output_port_access: resolve name.out[N]
380                            let (ref_node_id, _) = self
381                                .name_map
382                                .get(&opa.object)
383                                .ok_or_else(|| BuildError::UndefinedRef(opa.object.clone()))?
384                                .clone();
385                            ref_connections.push((ref_node_id, opa.index, inlet_idx));
386                        }
387                        Expr::Tuple(_) => {
388                            // Recursively resolve nested tuple expressions
389                            let (nested_id, nested_outlet) = self.resolve_expr(&arg.value)?;
390                            ref_connections.push((nested_id, nested_outlet, inlet_idx));
391                        }
392                    }
393                }
394
395                // Check the Abstraction registry.
396                // If object (name before alias resolution) is registered in the registry,
397                // determine numinlets/numoutlets from its interface.
398                // However, if the name changed through alias resolution (sub->-, add->+, etc.),
399                // it is a built-in object and not treated as an Abstraction.
400                let abstraction_info = if max_name == object {
401                    self.registry.and_then(|reg| reg.lookup(object))
402                } else {
403                    // Alias-resolved name — this is a built-in Max object, not an abstraction
404                    None
405                };
406
407                // E009: Abstraction argument count check
408                if let Some(iface) = abstraction_info {
409                    let expected = iface.in_ports.len();
410                    let got = args.len();
411                    if expected != got {
412                        return Err(BuildError::AbstractionArgCountMismatch {
413                            name: object.clone(),
414                            expected,
415                            got,
416                        });
417                    }
418                }
419
420                // Estimate inlet/outlet count
421                let (max_inlet, num_outlets, is_signal) = if let Some(iface) = abstraction_info {
422                    // Abstraction: determined from interface
423                    let num_in = iface.in_ports.len() as u32;
424                    let num_out = iface.out_ports.len() as u32;
425                    let sig = iface
426                        .out_ports
427                        .first()
428                        .map(|p| p.port_type.is_signal())
429                        .unwrap_or(false);
430                    // Inlet count: use at least the number of interface in_ports
431                    let max_from_refs = ref_connections
432                        .iter()
433                        .map(|(_, _, inlet)| *inlet + 1)
434                        .max()
435                        .unwrap_or(0);
436                    let from_args = args.len() as u32;
437                    let inlets = std::cmp::max(std::cmp::max(max_from_refs, from_args), num_in);
438                    (inlets, num_out, sig)
439                } else {
440                    // Normal Max object
441                    let inlet_count = if ref_connections.is_empty() && lit_args.is_empty() {
442                        infer_num_inlets(max_name, &lit_args, self.objdb)
443                    } else {
444                        let max_from_refs = ref_connections
445                            .iter()
446                            .map(|(_, _, inlet)| *inlet + 1)
447                            .max()
448                            .unwrap_or(0);
449                        let from_args = args.len() as u32;
450                        std::cmp::max(
451                            std::cmp::max(max_from_refs, from_args),
452                            infer_num_inlets(max_name, &lit_args, self.objdb),
453                        )
454                    };
455                    let outlet_count = infer_num_outlets(max_name, &lit_args, self.objdb);
456                    (inlet_count, outlet_count, is_signal)
457                };
458
459                // For Abstractions, use the name before alias resolution.
460                // Max references the filename as the object name, like `[oscillator 440]`.
461                let object_name = if abstraction_info.is_some() {
462                    object.to_string()
463                } else {
464                    max_name.to_string()
465                };
466
467                let mut node = PatchNode {
468                    id: id.clone(),
469                    object_name: object_name.clone(),
470                    args: lit_args.clone(),
471                    num_inlets: max_inlet,
472                    num_outlets,
473                    is_signal,
474                    varname: None,
475                    hot_inlets: default_hot_inlets(&object_name, max_inlet),
476                    purity: classify_purity(&object_name),
477                    attrs: vec![],
478                    code: None,
479                };
480
481                // Codebox: resolve code file reference and infer port counts
482                if matches!(max_name, "v8.codebox" | "codebox") {
483                    if let Some(code_files) = self.code_files {
484                        if let Some(filename) = lit_args.first() {
485                            if let Some(code_content) = code_files.get(filename.as_str()) {
486                                node.code = Some(code_content.clone());
487                                node.args.clear();
488                                // gen~ codebox: infer inlet/outlet counts from in1..inN / out1..outN
489                                if max_name == "codebox" {
490                                    let (inlets, outlets) = infer_codebox_ports(code_content);
491                                    node.num_inlets = inlets;
492                                    node.num_outlets = outlets;
493                                }
494                            }
495                        }
496                    }
497                }
498
499                self.graph.add_node(node);
500
501                // Create edges from Ref arguments
502                for (source_id, source_outlet, dest_inlet) in ref_connections {
503                    self.graph.add_edge(PatchEdge {
504                        source_id,
505                        source_outlet,
506                        dest_id: id.clone(),
507                        dest_inlet,
508                        is_feedback: false,
509                        order: None,
510                    });
511                }
512
513                Ok((id, 0))
514            }
515            Expr::Lit(lit) => {
516                // Generate literal expression as message/number box node
517                let id = self.gen_id();
518                let (object_name, arg_str, is_signal) = match lit {
519                    LitValue::Int(v) => ("message".to_string(), v.to_string(), false),
520                    LitValue::Float(_) => ("message".to_string(), format_lit(lit), false),
521                    LitValue::Str(s) => ("message".to_string(), s.clone(), false),
522                };
523                let node = PatchNode {
524                    id: id.clone(),
525                    object_name,
526                    args: vec![arg_str],
527                    num_inlets: 1,
528                    num_outlets: 1,
529                    is_signal,
530                    varname: None,
531                    hot_inlets: default_hot_inlets("message", 1),
532                    purity: classify_purity("message"),
533                    attrs: vec![],
534                    code: None,
535                };
536                self.graph.add_node(node);
537                Ok((id, 0))
538            }
539            Expr::OutputPortAccess(opa) => {
540                // output_port_access: resolve name.out[N]
541                let (node_id, _) = self
542                    .name_map
543                    .get(&opa.object)
544                    .ok_or_else(|| BuildError::UndefinedRef(opa.object.clone()))?
545                    .clone();
546                Ok((node_id, opa.index))
547            }
548            Expr::Tuple(elements) => {
549                let id = self.gen_id();
550                let num_elements = elements.len() as u32;
551
552                // Resolve each element and create edges
553                let mut ref_connections: Vec<(String, u32, u32)> = Vec::new();
554                let mut type_args: Vec<String> = Vec::new();
555                for (i, elem) in elements.iter().enumerate() {
556                    let (elem_id, elem_outlet) = self.resolve_expr(elem)?;
557                    ref_connections.push((elem_id, elem_outlet, i as u32));
558                    // Infer type from expression to determine type argument
559                    type_args.push(infer_pack_type_arg(elem));
560                }
561
562                let node = PatchNode {
563                    id: id.clone(),
564                    object_name: "pack".to_string(),
565                    args: type_args,
566                    num_inlets: num_elements,
567                    num_outlets: 1,
568                    is_signal: false,
569                    varname: None,
570                    hot_inlets: default_hot_inlets("pack", num_elements),
571                    purity: classify_purity("pack"),
572                    attrs: vec![],
573                    code: None,
574                };
575                self.graph.add_node(node);
576
577                for (source_id, source_outlet, dest_inlet) in ref_connections {
578                    self.graph.add_edge(PatchEdge {
579                        source_id,
580                        source_outlet,
581                        dest_id: id.clone(),
582                        dest_inlet,
583                        is_feedback: false,
584                        order: None,
585                    });
586                }
587
588                Ok((id, 0))
589            }
590        }
591    }
592
593    /// Process a DestructuringWire.
594    /// Resolve the value expression and map each name to a different outlet of that node.
595    ///
596    /// For `wire (a, b) = unpack(coords);`:
597    ///   - `unpack(coords)` generates an unpack node
598    ///   - a → (unpack_id, 0), b → (unpack_id, 1)
599    ///
600    /// For `wire (a, b) = some_ref;`:
601    ///   - After resolving some_ref, automatically insert an unpack node
602    ///   - a → (unpack_id, 0), b → (unpack_id, 1)
603    fn add_destructuring_wire(&mut self, dw: &DestructuringWire) -> Result<(), BuildError> {
604        let (source_id, _source_outlet) = self.resolve_expr(&dw.value)?;
605        let num_names = dw.names.len() as u32;
606
607        // E006: Destructuring count check
608        // If the RHS node num_outlets is known (not the default 1),
609        // check if it matches the LHS name count.
610        let resolved_node = self.graph.nodes.iter().find(|n| n.id == source_id);
611        if let Some(node) = resolved_node {
612            let outlet_count = node.num_outlets;
613            // If default value is 1, treat as unknown and skip
614            let is_known = outlet_count != 1
615                || node.object_name == "unpack"
616                || node.object_name == "inlet"
617                || node.object_name == "inlet~";
618            if is_known && outlet_count != num_names {
619                return Err(BuildError::DestructuringCountMismatch {
620                    expected: outlet_count as usize,
621                    got: num_names as usize,
622                });
623            }
624        }
625
626        // Check if the resolved expression is already unpack (or has enough outlets)
627        let source_has_enough_outlets = resolved_node
628            .map(|n| n.num_outlets >= num_names)
629            .unwrap_or(false);
630
631        let target_id = if source_has_enough_outlets {
632            // If the resolved node has enough outlets, map directly
633            source_id.clone()
634        } else {
635            // Auto-insert unpack node
636            let id = self.gen_id();
637            // If source is from a tuple, use its type arguments
638            let type_args = self.lookup_tuple_type_args(&dw.value, num_names);
639
640            let node = PatchNode {
641                id: id.clone(),
642                object_name: "unpack".to_string(),
643                args: type_args,
644                num_inlets: 1,
645                num_outlets: num_names,
646                is_signal: false,
647                varname: None,
648                hot_inlets: default_hot_inlets("unpack", 1),
649                purity: classify_purity("unpack"),
650                attrs: vec![],
651                code: None,
652            };
653            self.graph.add_node(node);
654
655            self.graph.add_edge(PatchEdge {
656                source_id,
657                source_outlet: _source_outlet,
658                dest_id: id.clone(),
659                dest_inlet: 0,
660                is_feedback: false,
661                order: None,
662            });
663
664            id
665        };
666
667        // Map each name to its corresponding outlet
668        for (i, name) in dw.names.iter().enumerate() {
669            self.name_map
670                .insert(name.clone(), (target_id.clone(), i as u32));
671            self.destructured_names.insert(name.clone());
672        }
673
674        Ok(())
675    }
676
677    /// Look up tuple type arguments from the destructuring source.
678    ///
679    /// If source is `Expr::Ref(name)` and `name` is from a tuple,
680    /// return the recorded type arguments.
681    /// If source is `Expr::Call { object: "unpack", args: [Expr::Ref(name)] }` and
682    /// `name` is from a tuple, return similarly.
683    /// Falls back to all elements "f" if not found.
684    fn lookup_tuple_type_args(&self, value: &Expr, num_names: u32) -> Vec<String> {
685        let source_name = match value {
686            Expr::Ref(name) => Some(name.as_str()),
687            Expr::Call { object, args } if object == "unpack" => args.first().and_then(|arg| {
688                if let Expr::Ref(name) = &arg.value {
689                    Some(name.as_str())
690                } else {
691                    None
692                }
693            }),
694            _ => None,
695        };
696        if let Some(name) = source_name {
697            if let Some(type_args) = self.tuple_type_args.get(name) {
698                return type_args.clone();
699            }
700        }
701        (0..num_names).map(|_| "f".to_string()).collect()
702    }
703
704    /// Process a FeedbackDecl.
705    ///
706    /// Forward-declare the feedback name and register it in name_map.
707    /// The actual tapin~ node is generated during feedback_assignment.
708    /// Here we tentatively register the feedback name in name_map (so tapout~ can reference it later).
709    fn add_feedback_decl(&mut self, decl: &FeedbackDecl) {
710        // Register feedback name in feedback_map (tapin~ node ID is determined at assignment)
711        // Insert dummy entry in name_map to make it referenceable
712        // When fb is referenced in tapout~(fb, delay), a connection to the tapin~ node is needed
713        // Generate the tapin~ node first and register it in name_map
714        let tapin_id = self.gen_id();
715        let node = PatchNode {
716            id: tapin_id.clone(),
717            object_name: "tapin~".to_string(),
718            args: vec![],
719            num_inlets: 1,
720            num_outlets: 1,
721            is_signal: true,
722            varname: None,
723            hot_inlets: default_hot_inlets("tapin~", 1),
724            purity: classify_purity("tapin~"),
725            attrs: vec![],
726            code: None,
727        };
728        self.graph.add_node(node);
729        self.feedback_map
730            .insert(decl.name.clone(), tapin_id.clone());
731        // Map tapin~ outlet 0 to the feedback name
732        // When fb is referenced in tapout~(fb, 500), an edge from tapin~ outlet 0 -> tapout~ inlet 0 is created
733        self.name_map.insert(decl.name.clone(), (tapin_id, 0));
734    }
735
736    /// Process a FeedbackAssignment.
737    ///
738    /// Evaluate the assignment value as in `feedback fb = tapin~(mixed, 1000);`
739    /// and connect it to tapin~ node inlet 0.
740    fn add_feedback_assignment(&mut self, assign: &FeedbackAssignment) -> Result<(), BuildError> {
741        // E013: Duplicate assignment check
742        if !self.assigned_feedbacks.insert(assign.target.clone()) {
743            return Err(BuildError::DuplicateFeedbackAssignment(
744                assign.target.clone(),
745            ));
746        }
747
748        let (source_id, source_outlet) = self.resolve_expr(&assign.value)?;
749
750        // Get tapin~ node ID
751        if let Some(tapin_id) = self.feedback_map.get(&assign.target).cloned() {
752            // Connection source -> tapin~ (feedback edge)
753            self.graph.add_edge(PatchEdge {
754                source_id,
755                source_outlet,
756                dest_id: tapin_id,
757                dest_inlet: 0,
758                is_feedback: true,
759                order: None,
760            });
761        }
762
763        Ok(())
764    }
765
766    /// Process a StateDecl.
767    ///
768    /// `state counter: int = 0;` -> Max `[int 0]` node
769    /// `state volume: float = 0.5;` -> Max `[float 0.5]` node
770    fn add_state_decl(&mut self, decl: &StateDecl) -> Result<(), BuildError> {
771        let id = self.gen_id();
772
773        let (object_name, init_arg) = match decl.port_type {
774            PortType::Int => (
775                "int".to_string(),
776                match &decl.init_value {
777                    Expr::Lit(LitValue::Int(v)) => v.to_string(),
778                    Expr::Lit(LitValue::Float(v)) => format!("{}", *v as i64),
779                    _ => "0".to_string(),
780                },
781            ),
782            PortType::Float => (
783                "float".to_string(),
784                match &decl.init_value {
785                    Expr::Lit(LitValue::Float(v)) => format_lit(&LitValue::Float(*v)),
786                    Expr::Lit(LitValue::Int(v)) => format!("{}.", v),
787                    _ => "0.".to_string(),
788                },
789            ),
790            // Use int as fallback for Bang, List, Symbol
791            _ => ("int".to_string(), "0".to_string()),
792        };
793
794        let node = PatchNode {
795            id: id.clone(),
796            object_name: object_name.clone(),
797            args: vec![init_arg],
798            num_inlets: 2, // inlet 0 = hot (bang/output), inlet 1 = cold (set value)
799            num_outlets: 1,
800            is_signal: false,
801            varname: Some(decl.name.clone()),
802            hot_inlets: vec![true, false], // inlet 0 hot, inlet 1 cold
803            purity: classify_purity(&object_name),
804            attrs: vec![],
805            code: None,
806        };
807        self.graph.add_node(node);
808
809        // Register state name in name_map
810        self.name_map.insert(decl.name.clone(), (id, 0));
811
812        Ok(())
813    }
814
815    /// Process a StateAssignment.
816    ///
817    /// `state counter = next;` -> connect `next` output to state node inlet 1 (cold)
818    fn add_state_assignment(&mut self, assign: &StateAssignment) -> Result<(), BuildError> {
819        // E019: Duplicate assignment check
820        if !self.assigned_states.insert(assign.name.clone()) {
821            return Err(BuildError::DuplicateStateAssignment(assign.name.clone()));
822        }
823
824        // Get node from state name
825        let (state_node_id, _) = self
826            .name_map
827            .get(&assign.name)
828            .ok_or_else(|| BuildError::UndefinedRef(assign.name.clone()))?
829            .clone();
830
831        // Resolve the expression
832        let (source_id, source_outlet) = self.resolve_expr(&assign.value)?;
833
834        // Connect source -> state node inlet 1 (cold inlet)
835        self.graph.add_edge(PatchEdge {
836            source_id,
837            source_outlet,
838            dest_id: state_node_id,
839            dest_inlet: 1, // cold inlet for value update
840            is_feedback: false,
841            order: None,
842        });
843
844        Ok(())
845    }
846
847    /// Process a DirectConnection.
848    ///
849    /// Process direct connections like `node.in[N] = expr;`,
850    /// connecting the expression output to the specified inlet of the target node.
851    fn add_direct_connection(&mut self, conn: &DirectConnection) -> Result<(), BuildError> {
852        let target_name = &conn.target.object;
853        let index = conn.target.index;
854
855        // Look up node from wire name
856        let (node_id, _) = self
857            .name_map
858            .get(target_name)
859            .ok_or_else(|| BuildError::UndefinedRef(target_name.clone()))?
860            .clone();
861
862        // Get node numinlets and validate port index
863        // If index is out of range, expand the node numinlets
864        // (can occur with back-edge direct_connections generated by the decompiler)
865        if let Some(node) = self.graph.find_node_mut(&node_id) {
866            if index >= node.num_inlets {
867                node.num_inlets = index + 1;
868            }
869        }
870
871        // Resolve the expression and create the edge
872        let (source_id, source_outlet) = self.resolve_expr(&conn.value)?;
873
874        self.graph.add_edge(PatchEdge {
875            source_id,
876            source_outlet,
877            dest_id: node_id,
878            dest_inlet: index,
879            is_feedback: false,
880            order: None,
881        });
882
883        Ok(())
884    }
885}
886
887/// Infer pack type argument for a tuple element from an expression.
888///
889/// - `Expr::Lit(Int(_))` → `"i"`
890/// - `Expr::Lit(Float(_))` → `"f"`
891/// - `Expr::Lit(Str(_))` → `"s"`
892/// - Others -> `"f"` fallback (cannot determine without type context)
893fn infer_pack_type_arg(expr: &Expr) -> String {
894    match expr {
895        Expr::Lit(LitValue::Int(_)) => "i".to_string(),
896        Expr::Lit(LitValue::Float(_)) => "f".to_string(),
897        Expr::Lit(LitValue::Str(_)) => "s".to_string(),
898        _ => "f".to_string(), // Ref, Call, OutputPortAccess, Tuple -> fallback
899    }
900}
901
902/// Classify purity from object name.
903fn classify_purity(object_name: &str) -> NodePurity {
904    match object_name {
905        // Signal objects are generally Pure (with exceptions)
906        name if name.ends_with('~') => match name {
907            "tapin~" | "tapout~" | "line~" | "delay~" | "phasor~" | "count~" | "index~"
908            | "buffer~" | "groove~" | "play~" | "record~" | "sfplay~" | "sfrecord~" | "sig~" => {
909                NodePurity::Stateful
910            }
911            _ => NodePurity::Pure,
912        },
913        // Known stateful Control objects
914        "pack" | "unpack" | "int" | "float" | "toggle" | "gate" | "counter" | "message" | "zl"
915        | "coll" | "dict" | "regexp" | "value" | "table" | "funbuff" | "bag" | "borax"
916        | "bucket" | "histo" | "mousestate" | "spray" | "switch" | "if" | "expr" | "vexpr"
917        | "button" | "number" | "flonum" | "slider" | "dial" | "umenu" | "preset" | "pattr"
918        | "autopattr" | "pattrstorage" => NodePurity::Stateful,
919        // Known pure Control objects
920        "+" | "-" | "*" | "/" | "%" | "trigger" | "t" | "route" | "select" | "prepend"
921        | "append" | "stripnote" | "makenote" | "scale" | "split" | "swap" | "clip" | "minimum"
922        | "maximum" | "inlet" | "inlet~" | "outlet" | "outlet~" | "loadbang" | "print" | "send"
923        | "receive" | "forward" | "ezdac~" | "dac~" | "adc~" => NodePurity::Pure,
924        _ => NodePurity::Unknown,
925    }
926}
927
928/// Generate default hot/cold inlets from object name and inlet count.
929/// Max default rule: inlet 0 is hot, others are cold.
930fn default_hot_inlets(_object_name: &str, num_inlets: u32) -> Vec<bool> {
931    if num_inlets == 0 {
932        return vec![];
933    }
934    // trigger has only inlet 0 as hot (no other inlets)
935    // Most objects have inlet 0 as hot, the rest as cold
936    (0..num_inlets).map(|i| i == 0).collect()
937}
938
939/// Assign order to fanout edges.
940/// Assign 0, 1, 2... when multiple edges share the same (source_id, source_outlet).
941/// Remain None for single edges.
942fn assign_edge_orders(graph: &mut PatchGraph) {
943    use std::collections::HashMap;
944
945    // Group edge indices by (source_id, source_outlet)
946    let mut groups: HashMap<(String, u32), Vec<usize>> = HashMap::new();
947    for (i, edge) in graph.edges.iter().enumerate() {
948        let key = (edge.source_id.clone(), edge.source_outlet);
949        groups.entry(key).or_default().push(i);
950    }
951
952    // Only assign order to groups with 2+ edges
953    for indices in groups.values() {
954        if indices.len() >= 2 {
955            for (order, &edge_idx) in indices.iter().enumerate() {
956                graph.edges[edge_idx].order = Some(order as u32);
957            }
958        }
959    }
960}
961
962/// Convert LitValue to string.
963fn format_lit(lit: &LitValue) -> String {
964    match lit {
965        LitValue::Int(v) => v.to_string(),
966        LitValue::Float(v) => {
967            // Float always preserves the decimal point. In Max, 1. (float) and 1 (int) have different meanings.
968            // e.g., [* 1.] is float multiplication, [* 1] is int multiplication.
969            if v.fract() == 0.0 {
970                format!("{}.", *v as i64)
971            } else {
972                format!("{}", v)
973            }
974        }
975        LitValue::Str(s) => s.clone(),
976    }
977}
978
979/// Convert AttrValue to string.
980/// Used for Max `@key value` format and box JSON fields.
981fn format_attr_value(val: &flutmax_ast::AttrValue) -> String {
982    match val {
983        flutmax_ast::AttrValue::Int(v) => v.to_string(),
984        flutmax_ast::AttrValue::Float(v) => {
985            // Max accepts trailing dot (e.g., "100.").
986            // Format integer-like floats with trailing dot.
987            if v.fract() == 0.0 {
988                format!("{}.", *v as i64)
989            } else {
990                format!("{}", v)
991            }
992        }
993        flutmax_ast::AttrValue::Str(s) => s.clone(),
994        flutmax_ast::AttrValue::Ident(s) => s.clone(),
995    }
996}
997
998/// Convert flutmax aliases to Max object names.
999/// Only converts arithmetic operators. Returns others as-is.
1000fn resolve_max_object_name(flutmax_name: &str) -> &str {
1001    match flutmax_name {
1002        "add" => "+",
1003        "sub" => "-",
1004        "mul" => "*",
1005        "dvd" => "/",
1006        "mod" => "%",
1007        "add~" => "+~",
1008        "sub~" => "-~",
1009        "mul~" => "*~",
1010        "dvd~" => "/~",
1011        "mod~" => "%~",
1012        // Reversed arithmetic
1013        "rsub" => "!-",
1014        "rdvd" => "!/",
1015        "rmod" => "!%",
1016        "rsub~" => "!-~",
1017        "rdvd~" => "!/~",
1018        "rmod~" => "!%~",
1019        // Comparison
1020        "gt" => ">",
1021        "lt" => "<",
1022        "gte" => ">=",
1023        "lte" => "<=",
1024        "eq" => "==",
1025        "neq" => "!=",
1026        "gt~" => ">~",
1027        "lt~" => "<~",
1028        "gte~" => ">=~",
1029        "lte~" => "<=~",
1030        "eq~" => "==~",
1031        "neq~" => "!=~",
1032        // Logical/bitwise
1033        "and" => "&&",
1034        "or" => "||",
1035        "lshift" => "<<",
1036        "rshift" => ">>",
1037        other => other,
1038    }
1039}
1040
1041/// Match named argument parameter name against objdb inlet definitions and return inlet index.
1042///
1043/// Returns `None` if not registered in objdb or if the name does not match.
1044/// Name matching is case-insensitive with spaces normalized to underscores.
1045fn resolve_inlet_name(object_name: &str, arg_name: &str, objdb: Option<&ObjectDb>) -> Option<u32> {
1046    let db = objdb?;
1047    let def = db.lookup(object_name)?;
1048    let inlets = match &def.inlets {
1049        InletSpec::Fixed(ports) => ports.as_slice(),
1050        InletSpec::Variable { defaults, .. } => defaults.as_slice(),
1051    };
1052    let arg_lower = arg_name.to_lowercase();
1053    for port in inlets {
1054        let normalized = normalize_port_description(&port.description);
1055        if let Some(ref n) = normalized {
1056            if *n == arg_lower {
1057                return Some(port.id);
1058            }
1059        }
1060    }
1061    None
1062}
1063
1064/// Normalize an objdb port description to a valid flutmax identifier.
1065///
1066/// This must match the normalization in `flutmax-decompile`'s `normalize_inlet_name`
1067/// to ensure roundtrip consistency (decompile → named args → compile → resolve).
1068fn normalize_port_description(description: &str) -> Option<String> {
1069    let trimmed = description.trim();
1070    // Strip leading type prefix like "(signal)", "(signal/float)", "(float)"
1071    let stripped = if trimmed.starts_with('(') {
1072        if let Some(end) = trimmed.find(')') {
1073            trimmed[end + 1..].trim()
1074        } else {
1075            trimmed
1076        }
1077    } else {
1078        trimmed
1079    };
1080    let s: String = stripped
1081        .to_lowercase()
1082        .chars()
1083        .map(|c| if c == ' ' { '_' } else { c })
1084        .filter(|c| c.is_ascii_alphanumeric() || *c == '_')
1085        .collect();
1086    let parts: Vec<&str> = s.split('_').filter(|p| !p.is_empty()).collect();
1087    let result = parts.join("_");
1088    let result = result
1089        .trim_start_matches(|c: char| c.is_ascii_digit())
1090        .to_string();
1091    if result.is_empty() || result.len() > 20 {
1092        None
1093    } else {
1094        Some(result)
1095    }
1096}
1097
1098/// Resolve a named argument against the AbstractionRegistry.
1099///
1100/// Abstractions define their inlets via `in freq: float;` declarations.
1101/// This function matches the argument name against those declarations.
1102fn resolve_abstraction_inlet_name(
1103    object_name: &str,
1104    arg_name: &str,
1105    registry: Option<&AbstractionRegistry>,
1106) -> Option<u32> {
1107    let reg = registry?;
1108    let iface = reg.lookup(object_name)?;
1109    let arg_lower = arg_name.to_lowercase();
1110    for port in &iface.in_ports {
1111        if port.name.to_lowercase() == arg_lower {
1112            return Some(port.index);
1113        }
1114    }
1115    None
1116}
1117
1118/// Estimate inlet count from object name and arguments.
1119/// Prioritizes objdb when provided; falls back to hardcoded table for unregistered objects.
1120/// Variable-inlet objects are inferred from argument count.
1121fn infer_num_inlets(object_name: &str, args: &[String], objdb: Option<&ObjectDb>) -> u32 {
1122    // Prioritize objdb lookup
1123    if let Some(db) = objdb {
1124        if let Some(def) = db.lookup(object_name) {
1125            return match &def.inlets {
1126                InletSpec::Fixed(ports) => ports.len() as u32,
1127                InletSpec::Variable {
1128                    defaults,
1129                    min_inlets,
1130                } => {
1131                    if args.is_empty() {
1132                        defaults.len().max(*min_inlets as usize) as u32
1133                    } else {
1134                        args.len() as u32
1135                    }
1136                }
1137            };
1138        }
1139    }
1140    // Hardcoded fallback
1141    match object_name {
1142        // Signal arithmetic
1143        "cycle~" => 2,
1144        "*~" | "+~" | "-~" | "/~" | "%~" | "!-~" | "!/~" | "!%~" => 2,
1145        ">~" | "<~" | ">=~" | "<=~" | "==~" | "!=~" => 2,
1146        // Control arithmetic
1147        "*" | "+" | "-" | "/" | "%" | "!-" | "!/" | "!%" => 2,
1148        ">" | "<" | ">=" | "<=" | "==" | "!=" => 2,
1149        "&&" | "||" | "<<" | ">>" => 2,
1150        // Audio I/O
1151        "ezdac~" => 2,
1152        "dac~" => 2,
1153        "adc~" => 0,
1154        // Triggers / UI
1155        "loadbang" => 1,
1156        "button" => 1,
1157        "print" => 1,
1158        // Signal processing
1159        "biquad~" => 6,
1160        "line~" => 2,
1161        "tapin~" => 1,
1162        "tapout~" => 2,
1163        "noise~" | "phasor~" => 1,
1164        "snapshot~" | "peakamp~" | "meter~" => 1,
1165        "edge~" => 1,
1166        "dspstate~" => 1,
1167        "fftinfo~" => 1,
1168        "fftin~" => 1,
1169        "fftout~" => 1,
1170        "cartopol~" | "poltocar~" => 2,
1171        "freqshift~" => 2,
1172        "curve~" => 2,
1173        "adsr~" => 5,
1174        "filtercoeff~" => 4,
1175        "filtergraph~" => 8,
1176        // Data
1177        "int" | "float" => 2,
1178        "inlet" | "inlet~" => 0,
1179        "outlet" | "outlet~" => 1,
1180        // Variable inlets (arg-dependent)
1181        "trigger" | "t" => 1,
1182        "select" | "sel" => {
1183            if args.is_empty() {
1184                2
1185            } else {
1186                1
1187            }
1188        }
1189        "route" => 1,
1190        "gate" => 2,
1191        "pack" | "pak" => {
1192            if args.is_empty() {
1193                2
1194            } else {
1195                args.len() as u32
1196            }
1197        }
1198        "unpack" => 1,
1199        "buddy" => {
1200            if args.is_empty() {
1201                2
1202            } else {
1203                args.first()
1204                    .and_then(|a| a.parse::<u32>().ok())
1205                    .unwrap_or(2)
1206            }
1207        }
1208        // MIDI
1209        "makenote" => 3,
1210        "notein" => 1,
1211        "noteout" => 3,
1212        "ctlin" => 1,
1213        "ctlout" => 3,
1214        "midiin" => 1,
1215        "midiout" => 1,
1216        "borax" => 1,
1217        // Timing / control
1218        "line" => 2,
1219        "function" => 2,
1220        "counter" => 3,
1221        "metro" => 2,
1222        "delay" => 2,
1223        "pipe" => {
1224            if args.is_empty() {
1225                2
1226            } else {
1227                args.len() as u32 + 1
1228            }
1229        }
1230        "speedlim" => 2,
1231        "thresh" => 2,
1232        // Data structures
1233        "coll" => 1,
1234        "urn" => 2,
1235        "drunk" => 2,
1236        "random" => 2,
1237        // List / string
1238        "match" => 1,
1239        "zl" => 2,
1240        "regexp" => 1,
1241        "sprintf" => {
1242            if args.is_empty() {
1243                1
1244            } else {
1245                args.len() as u32
1246            }
1247        }
1248        "fromsymbol" => 1,
1249        "tosymbol" => 1,
1250        "iter" => 1,
1251        // Codebox
1252        "v8.codebox" => 1,
1253        "codebox" => 1,
1254        // gen~ ternary conditional operator
1255        "?" => 3,
1256        _ => 1,
1257    }
1258}
1259
1260/// Estimate outlet count from object name and arguments.
1261/// Prioritizes objdb when provided; falls back to hardcoded table for unregistered objects.
1262/// Variable-outlet objects are dynamically inferred from argument count.
1263fn infer_num_outlets(object_name: &str, args: &[String], objdb: Option<&ObjectDb>) -> u32 {
1264    // Prioritize objdb lookup
1265    if let Some(db) = objdb {
1266        if let Some(def) = db.lookup(object_name) {
1267            return match &def.outlets {
1268                OutletSpec::Fixed(ports) => ports.len() as u32,
1269                OutletSpec::Variable {
1270                    defaults,
1271                    min_outlets,
1272                } => {
1273                    if args.is_empty() {
1274                        defaults.len().max(*min_outlets as usize) as u32
1275                    } else {
1276                        args.len() as u32
1277                    }
1278                }
1279            };
1280        }
1281    }
1282    // Hardcoded fallback
1283    match object_name {
1284        // Signal processing
1285        "cycle~" => 1,
1286        "*~" | "+~" | "-~" | "/~" => 1,
1287        "biquad~" => 1,
1288        "line~" => 2,
1289        "tapin~" => 1,
1290        "tapout~" => 1,
1291        "noise~" | "phasor~" => 1,
1292        "snapshot~" | "peakamp~" | "meter~" => 1,
1293        "edge~" => 2,
1294        "dspstate~" => 4,
1295        "fftinfo~" => 4,
1296        "fftin~" => 3,
1297        "fftout~" => 1,
1298        "cartopol~" | "poltocar~" => 2,
1299        "freqshift~" => 2,
1300        "curve~" => 2,
1301        "adsr~" => 4,
1302        "filtercoeff~" => 5,
1303        "filtergraph~" => 7,
1304        // Control arithmetic
1305        "*" | "+" | "-" | "/" | "%" => 1,
1306        // Audio I/O
1307        "ezdac~" | "dac~" => 0,
1308        "adc~" => 1,
1309        // Triggers / UI
1310        "loadbang" => 1,
1311        "button" => 1,
1312        "print" => 0,
1313        // Data
1314        "int" | "float" => 1,
1315        "inlet" | "inlet~" => 1,
1316        "outlet" | "outlet~" => 0,
1317        // Variable outlets (arg-dependent)
1318        "select" | "sel" => {
1319            if args.is_empty() {
1320                2
1321            } else {
1322                args.len() as u32 + 1
1323            }
1324        }
1325        "route" => {
1326            if args.is_empty() {
1327                2
1328            } else {
1329                args.len() as u32 + 1
1330            }
1331        }
1332        "gate" => args
1333            .first()
1334            .and_then(|a| a.parse::<u32>().ok())
1335            .unwrap_or(2),
1336        "trigger" | "t" => {
1337            if args.is_empty() {
1338                1
1339            } else {
1340                args.len() as u32
1341            }
1342        }
1343        "unpack" => {
1344            if args.is_empty() {
1345                2
1346            } else {
1347                args.len() as u32
1348            }
1349        }
1350        "pack" | "pak" => 1,
1351        "buddy" => {
1352            if args.is_empty() {
1353                2
1354            } else {
1355                args.first()
1356                    .and_then(|a| a.parse::<u32>().ok())
1357                    .unwrap_or(2)
1358            }
1359        }
1360        // Timing / control
1361        "function" => 2,
1362        "line" => 2,
1363        "counter" => 4,
1364        "metro" => 1,
1365        "delay" => 1,
1366        "pipe" => {
1367            if args.is_empty() {
1368                1
1369            } else {
1370                args.len() as u32
1371            }
1372        }
1373        "speedlim" => 1,
1374        "thresh" => 2,
1375        // MIDI
1376        "makenote" => 2,
1377        "borax" => 8,
1378        "notein" => 3,
1379        "noteout" => 0,
1380        "ctlin" => 3,
1381        "ctlout" => 0,
1382        "midiin" => 1,
1383        "midiout" => 0,
1384        // Data structures
1385        "coll" => 4,
1386        "urn" => 2,
1387        "drunk" => 1,
1388        "random" => 1,
1389        // List / string / pattern
1390        "match" => 2,
1391        "zl" => 2,
1392        "regexp" => 5,
1393        "sprintf" => 1,
1394        "fromsymbol" => 1,
1395        "tosymbol" => 1,
1396        "iter" => 1,
1397        // UI objects
1398        "textbutton" => 3,
1399        "live.text" => 2,
1400        "live.dial" => 2,
1401        "live.toggle" => 1,
1402        "live.menu" => 3,
1403        "live.numbox" => 2,
1404        "live.tab" => 3,
1405        "live.comment" => 0,
1406        "umenu" => 3,
1407        "flonum" => 2,
1408        "number" => 2,
1409        "slider" | "dial" | "rslider" => 1,
1410        "multislider" | "kslider" => 2,
1411        "tab" => 3,
1412        "toggle" => 1,
1413        // Codebox
1414        "v8.codebox" => 1,
1415        "codebox" => 1,
1416        _ => 1,
1417    }
1418}
1419
1420/// Infer inlet/outlet count from gen~ codebox code.
1421///
1422/// GenExpr code references inputs with `in1`, `in2`, ..., `inN`,
1423/// and defines outputs with `out1`, `out2`, ..., `outN`.
1424/// Detects the maximum N and returns inlet/outlet counts.
1425fn infer_codebox_ports(code: &str) -> (u32, u32) {
1426    let mut max_in: u32 = 0;
1427    let mut max_out: u32 = 0;
1428
1429    // Scan for in1..inN and out1..outN patterns
1430    // Use simple byte scanning to avoid regex dependency
1431    let bytes = code.as_bytes();
1432    let len = bytes.len();
1433    let mut i = 0;
1434    while i < len {
1435        // Check for "in" or "out" at word boundary
1436        let at_word_start = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
1437        if at_word_start {
1438            if i + 2 < len && bytes[i] == b'o' && bytes[i + 1] == b'u' && bytes[i + 2] == b't' {
1439                // Parse "outN"
1440                let mut j = i + 3;
1441                let mut num: u32 = 0;
1442                let mut has_digit = false;
1443                while j < len && bytes[j].is_ascii_digit() {
1444                    num = num * 10 + (bytes[j] - b'0') as u32;
1445                    has_digit = true;
1446                    j += 1;
1447                }
1448                // Must have digits and NOT be followed by alphanumeric (word boundary)
1449                if has_digit && (j >= len || !bytes[j].is_ascii_alphanumeric()) && num > max_out {
1450                    max_out = num;
1451                }
1452                i = j;
1453                continue;
1454            } else if i + 1 < len && bytes[i] == b'i' && bytes[i + 1] == b'n' {
1455                // Parse "inN" — but not "int", "into", etc.
1456                let mut j = i + 2;
1457                let mut num: u32 = 0;
1458                let mut has_digit = false;
1459                while j < len && bytes[j].is_ascii_digit() {
1460                    num = num * 10 + (bytes[j] - b'0') as u32;
1461                    has_digit = true;
1462                    j += 1;
1463                }
1464                if has_digit && (j >= len || !bytes[j].is_ascii_alphanumeric()) && num > max_in {
1465                    max_in = num;
1466                }
1467                if has_digit {
1468                    i = j;
1469                    continue;
1470                }
1471            }
1472        }
1473        i += 1;
1474    }
1475
1476    // gen~ uses 1-based indexing: in1..inN means N inlets
1477    (max_in.max(1), max_out.max(1))
1478}
1479
1480/// Convert Program (AST) to PatchGraph.
1481///
1482/// After conversion, calls `insert_triggers()` to auto-insert triggers for fanouts.
1483pub fn build_graph(program: &Program) -> Result<PatchGraph, BuildError> {
1484    build_graph_with_registry(program, None)
1485}
1486
1487/// Convert Program (AST) to PatchGraph (with Abstraction registry).
1488///
1489/// When `registry` is `Some` and the `Expr::Call` object name is
1490/// registered in the registry, `numinlets`/`numoutlets` are determined from its interface.
1491pub fn build_graph_with_registry(
1492    program: &Program,
1493    registry: Option<&AbstractionRegistry>,
1494) -> Result<PatchGraph, BuildError> {
1495    build_graph_with_code_files(program, registry, None)
1496}
1497
1498/// Convert Program (AST) to PatchGraph (with Abstraction registry + code files).
1499///
1500/// When `code_files` is `Some`, resolves `v8.codebox` and `codebox` filename arguments
1501/// to code content and stores it in `PatchNode.code`.
1502pub fn build_graph_with_code_files(
1503    program: &Program,
1504    registry: Option<&AbstractionRegistry>,
1505    code_files: Option<&CodeFiles>,
1506) -> Result<PatchGraph, BuildError> {
1507    build_graph_with_objdb(program, registry, code_files, None)
1508}
1509
1510/// Convert Program (AST) to PatchGraph (with all parameters).
1511///
1512/// When `objdb` is `Some`, `infer_num_inlets`/`infer_num_outlets`
1513/// prioritize the object definition database, falling back to hardcoded tables for unregistered objects.
1514pub fn build_graph_with_objdb(
1515    program: &Program,
1516    registry: Option<&AbstractionRegistry>,
1517    code_files: Option<&CodeFiles>,
1518    objdb: Option<&ObjectDb>,
1519) -> Result<PatchGraph, BuildError> {
1520    let mut builder = GraphBuilder::new(registry, code_files, objdb);
1521
1522    // 1. InDecl -> inlet nodes
1523    for decl in &program.in_decls {
1524        builder.add_inlet(decl);
1525    }
1526
1527    // 2. OutDecl -> outlet nodes
1528    for decl in &program.out_decls {
1529        builder.add_outlet(decl);
1530    }
1531
1532    // 2b. FeedbackDecl -> tapin~ nodes (forward declaration)
1533    for decl in &program.feedback_decls {
1534        builder.add_feedback_decl(decl);
1535    }
1536
1537    // 2c. StateDecl -> int/float nodes (forward declaration)
1538    for decl in &program.state_decls {
1539        builder.add_state_decl(decl)?;
1540    }
1541
1542    // 2d. MsgDecl -> message box nodes
1543    for decl in &program.msg_decls {
1544        builder.add_msg(decl);
1545    }
1546
1547    // 3. Wire -> object nodes + edges
1548    for wire in &program.wires {
1549        builder.add_wire(wire)?;
1550    }
1551
1552    // 3b. DestructuringWire -> unpack nodes + edges
1553    for dw in &program.destructuring_wires {
1554        builder.add_destructuring_wire(dw)?;
1555    }
1556
1557    // 3c. FeedbackAssignment -> connections to tapin~
1558    for assign in &program.feedback_assignments {
1559        builder.add_feedback_assignment(assign)?;
1560    }
1561
1562    // 3d. StateAssignment -> connections to state node cold inlets
1563    for assign in &program.state_assignments {
1564        builder.add_state_assignment(assign)?;
1565    }
1566
1567    // 4. OutAssignment -> edges
1568    for assign in &program.out_assignments {
1569        builder.add_out_assignment(assign)?;
1570    }
1571
1572    // 4a. OutDecl with inline value → implicit OutAssignment
1573    for decl in &program.out_decls {
1574        if let Some(ref value) = decl.value {
1575            let implicit_assign = OutAssignment {
1576                index: decl.index,
1577                value: value.clone(),
1578                span: None,
1579            };
1580            builder.add_out_assignment(&implicit_assign)?;
1581        }
1582    }
1583
1584    // 4b. DirectConnection -> edges
1585    for conn in &program.direct_connections {
1586        builder.add_direct_connection(conn)?;
1587    }
1588
1589    // 5. Auto-insert triggers
1590    insert_triggers(&mut builder.graph);
1591
1592    // 6. Assign order to fanout edges
1593    assign_edge_orders(&mut builder.graph);
1594
1595    Ok(builder.graph)
1596}
1597
1598/// Convert Program (AST) to PatchGraph + warnings.
1599pub fn build_graph_with_warnings(program: &Program) -> Result<BuildResult, BuildError> {
1600    build_graph_with_registry_and_warnings(program, None)
1601}
1602
1603/// Convert Program (AST) to PatchGraph + warnings (with Abstraction registry).
1604pub fn build_graph_with_registry_and_warnings(
1605    program: &Program,
1606    registry: Option<&AbstractionRegistry>,
1607) -> Result<BuildResult, BuildError> {
1608    let graph = build_graph_with_registry(program, registry)?;
1609    let warnings = detect_duplicate_inlets(&graph);
1610    Ok(BuildResult { graph, warnings })
1611}
1612
1613/// Detect duplicate connections to the same inlet.
1614fn detect_duplicate_inlets(graph: &PatchGraph) -> Vec<BuildWarning> {
1615    let mut inlet_counts: HashMap<(String, u32), usize> = HashMap::new();
1616    for edge in &graph.edges {
1617        if !edge.is_feedback {
1618            *inlet_counts
1619                .entry((edge.dest_id.clone(), edge.dest_inlet))
1620                .or_insert(0) += 1;
1621        }
1622    }
1623    let mut warnings: Vec<BuildWarning> = inlet_counts
1624        .into_iter()
1625        .filter(|(_, count)| *count > 1)
1626        .map(
1627            |((node_id, inlet), count)| BuildWarning::DuplicateInletConnection {
1628                node_id,
1629                inlet,
1630                count,
1631            },
1632        )
1633        .collect();
1634    // Sort to make output order deterministic
1635    warnings.sort_by(|a, b| {
1636        let (a_id, a_inlet) = match a {
1637            BuildWarning::DuplicateInletConnection { node_id, inlet, .. } => (node_id, inlet),
1638        };
1639        let (b_id, b_inlet) = match b {
1640            BuildWarning::DuplicateInletConnection { node_id, inlet, .. } => (node_id, inlet),
1641        };
1642        a_id.cmp(b_id).then(a_inlet.cmp(b_inlet))
1643    });
1644    warnings
1645}
1646
1647#[cfg(test)]
1648mod tests {
1649    use super::*;
1650    use flutmax_ast::*;
1651
1652    /// L1: `cycle~ 440` -> `ezdac~` (minimal patch)
1653    fn make_l1_program() -> Program {
1654        Program {
1655            in_decls: vec![],
1656            out_decls: vec![],
1657            wires: vec![Wire {
1658                name: "osc".to_string(),
1659                value: Expr::Call {
1660                    object: "cycle~".to_string(),
1661                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
1662                },
1663                span: None,
1664                attrs: vec![],
1665            }],
1666            destructuring_wires: vec![],
1667            msg_decls: vec![],
1668            out_assignments: vec![],
1669            direct_connections: vec![],
1670            feedback_decls: vec![],
1671            feedback_assignments: vec![],
1672            state_decls: vec![],
1673            state_assignments: vec![],
1674        }
1675    }
1676
1677    /// L2: `in freq: float → cycle~(freq) → *~(osc, 0.5) → out audio: signal`
1678    fn make_l2_program() -> Program {
1679        Program {
1680            in_decls: vec![InDecl {
1681                index: 0,
1682                name: "freq".to_string(),
1683                port_type: PortType::Float,
1684            }],
1685            out_decls: vec![OutDecl {
1686                index: 0,
1687                name: "audio".to_string(),
1688                port_type: PortType::Signal,
1689                value: None,
1690            }],
1691            wires: vec![
1692                Wire {
1693                    name: "osc".to_string(),
1694                    value: Expr::Call {
1695                        object: "cycle~".to_string(),
1696                        args: vec![CallArg::positional(Expr::Ref("freq".to_string()))],
1697                    },
1698                    span: None,
1699                    attrs: vec![],
1700                },
1701                Wire {
1702                    name: "amp".to_string(),
1703                    value: Expr::Call {
1704                        object: "mul~".to_string(),
1705                        args: vec![
1706                            CallArg::positional(Expr::Ref("osc".to_string())),
1707                            CallArg::positional(Expr::Lit(LitValue::Float(0.5))),
1708                        ],
1709                    },
1710                    span: None,
1711                    attrs: vec![],
1712                },
1713            ],
1714            destructuring_wires: vec![],
1715            msg_decls: vec![],
1716            out_assignments: vec![OutAssignment {
1717                index: 0,
1718                value: Expr::Ref("amp".to_string()),
1719                span: None,
1720            }],
1721            direct_connections: vec![],
1722            feedback_decls: vec![],
1723            feedback_assignments: vec![],
1724            state_decls: vec![],
1725            state_assignments: vec![],
1726        }
1727    }
1728
1729    #[test]
1730    fn test_build_l1_nodes() {
1731        let prog = make_l1_program();
1732        let graph = build_graph(&prog).unwrap();
1733
1734        // One cycle~ node
1735        assert_eq!(graph.nodes.len(), 1);
1736        let node = &graph.nodes[0];
1737        assert_eq!(node.object_name, "cycle~");
1738        assert_eq!(node.args, vec!["440"]);
1739        assert!(node.is_signal);
1740        assert_eq!(node.num_inlets, 2);
1741        assert_eq!(node.num_outlets, 1);
1742    }
1743
1744    #[test]
1745    fn test_build_l1_no_edges() {
1746        let prog = make_l1_program();
1747        let graph = build_graph(&prog).unwrap();
1748
1749        // No edges since cycle~ is standalone
1750        assert_eq!(graph.edges.len(), 0);
1751    }
1752
1753    #[test]
1754    fn test_build_l2_nodes() {
1755        let prog = make_l2_program();
1756        let graph = build_graph(&prog).unwrap();
1757
1758        // 4 nodes: inlet, outlet~, cycle~, *~
1759        assert_eq!(graph.nodes.len(), 4);
1760
1761        let names: Vec<&str> = graph.nodes.iter().map(|n| n.object_name.as_str()).collect();
1762        assert!(names.contains(&"inlet"));
1763        assert!(names.contains(&"outlet~"));
1764        assert!(names.contains(&"cycle~"));
1765        assert!(names.contains(&"*~"));
1766    }
1767
1768    #[test]
1769    fn test_build_l2_edges() {
1770        let prog = make_l2_program();
1771        let graph = build_graph(&prog).unwrap();
1772
1773        // Edges: inlet->cycle~, cycle~->*~, *~->outlet~
1774        assert_eq!(graph.edges.len(), 3);
1775
1776        // inlet → cycle~ (inlet 0)
1777        let inlet_node = graph
1778            .nodes
1779            .iter()
1780            .find(|n| n.object_name == "inlet")
1781            .unwrap();
1782        let cycle_node = graph
1783            .nodes
1784            .iter()
1785            .find(|n| n.object_name == "cycle~")
1786            .unwrap();
1787        let inlet_to_cycle = graph
1788            .edges
1789            .iter()
1790            .find(|e| e.source_id == inlet_node.id && e.dest_id == cycle_node.id)
1791            .expect("edge from inlet to cycle~ should exist");
1792        assert_eq!(inlet_to_cycle.source_outlet, 0);
1793        assert_eq!(inlet_to_cycle.dest_inlet, 0);
1794
1795        // cycle~ → *~ (inlet 0)
1796        let mul_node = graph.nodes.iter().find(|n| n.object_name == "*~").unwrap();
1797        let cycle_to_mul = graph
1798            .edges
1799            .iter()
1800            .find(|e| e.source_id == cycle_node.id && e.dest_id == mul_node.id)
1801            .expect("edge from cycle~ to *~ should exist");
1802        assert_eq!(cycle_to_mul.dest_inlet, 0);
1803
1804        // *~ → outlet~
1805        let outlet_node = graph
1806            .nodes
1807            .iter()
1808            .find(|n| n.object_name == "outlet~")
1809            .unwrap();
1810        let mul_to_outlet = graph
1811            .edges
1812            .iter()
1813            .find(|e| e.source_id == mul_node.id && e.dest_id == outlet_node.id)
1814            .expect("edge from *~ to outlet~ should exist");
1815        assert_eq!(mul_to_outlet.dest_inlet, 0);
1816    }
1817
1818    #[test]
1819    fn test_build_l2_mul_args() {
1820        let prog = make_l2_program();
1821        let graph = build_graph(&prog).unwrap();
1822
1823        let mul_node = graph.nodes.iter().find(|n| n.object_name == "*~").unwrap();
1824        // *~(osc, 0.5) -> args contains "0.5"
1825        assert_eq!(mul_node.args, vec!["0.5"]);
1826    }
1827
1828    #[test]
1829    fn test_undefined_ref_error() {
1830        let prog = Program {
1831            in_decls: vec![],
1832            out_decls: vec![],
1833            wires: vec![Wire {
1834                name: "x".to_string(),
1835                value: Expr::Call {
1836                    object: "cycle~".to_string(),
1837                    args: vec![CallArg::positional(Expr::Ref("nonexistent".to_string()))],
1838                },
1839                span: None,
1840                attrs: vec![],
1841            }],
1842            destructuring_wires: vec![],
1843            msg_decls: vec![],
1844            out_assignments: vec![],
1845            direct_connections: vec![],
1846            feedback_decls: vec![],
1847            feedback_assignments: vec![],
1848            state_decls: vec![],
1849            state_assignments: vec![],
1850        };
1851
1852        let result = build_graph(&prog);
1853        assert!(result.is_err());
1854        match result.unwrap_err() {
1855            BuildError::UndefinedRef(name) => assert_eq!(name, "nonexistent"),
1856            _ => panic!("expected UndefinedRef error"),
1857        }
1858    }
1859
1860    #[test]
1861    fn test_outlet_index_out_of_range() {
1862        let prog = Program {
1863            in_decls: vec![],
1864            out_decls: vec![OutDecl {
1865                index: 0,
1866                name: "out".to_string(),
1867                port_type: PortType::Float,
1868                value: None,
1869            }],
1870            wires: vec![Wire {
1871                name: "x".to_string(),
1872                value: Expr::Call {
1873                    object: "button".to_string(),
1874                    args: vec![],
1875                },
1876                span: None,
1877                attrs: vec![],
1878            }],
1879            destructuring_wires: vec![],
1880            msg_decls: vec![],
1881            out_assignments: vec![OutAssignment {
1882                index: 5, // out_decls only has index 0
1883                value: Expr::Ref("x".to_string()),
1884                span: None,
1885            }],
1886            direct_connections: vec![],
1887            feedback_decls: vec![],
1888            feedback_assignments: vec![],
1889            state_decls: vec![],
1890            state_assignments: vec![],
1891        };
1892
1893        let result = build_graph(&prog);
1894        assert!(result.is_err());
1895        match result.unwrap_err() {
1896            BuildError::NoOutDeclaration(idx) => assert_eq!(idx, 5),
1897            _ => panic!("expected NoOutDeclaration error"),
1898        }
1899    }
1900
1901    #[test]
1902    fn test_format_lit_int() {
1903        assert_eq!(format_lit(&LitValue::Int(440)), "440");
1904        assert_eq!(format_lit(&LitValue::Int(-1)), "-1");
1905        assert_eq!(format_lit(&LitValue::Int(0)), "0");
1906    }
1907
1908    #[test]
1909    fn test_format_lit_float() {
1910        assert_eq!(format_lit(&LitValue::Float(0.5)), "0.5");
1911        assert_eq!(format_lit(&LitValue::Float(440.0)), "440.");
1912        assert_eq!(format_lit(&LitValue::Float(3.14)), "3.14");
1913    }
1914
1915    #[test]
1916    fn test_format_lit_str() {
1917        assert_eq!(format_lit(&LitValue::Str("hello".to_string())), "hello");
1918    }
1919
1920    #[test]
1921    fn test_signal_inlet_is_signal() {
1922        let prog = Program {
1923            in_decls: vec![InDecl {
1924                index: 0,
1925                name: "sig_in".to_string(),
1926                port_type: PortType::Signal,
1927            }],
1928            out_decls: vec![],
1929            wires: vec![],
1930            destructuring_wires: vec![],
1931            msg_decls: vec![],
1932            out_assignments: vec![],
1933            direct_connections: vec![],
1934            feedback_decls: vec![],
1935            feedback_assignments: vec![],
1936            state_decls: vec![],
1937            state_assignments: vec![],
1938        };
1939
1940        let graph = build_graph(&prog).unwrap();
1941        let inlet_node = &graph.nodes[0];
1942        assert_eq!(inlet_node.object_name, "inlet~");
1943        assert!(inlet_node.is_signal);
1944        assert_eq!(inlet_node.num_inlets, 1);
1945        assert_eq!(inlet_node.num_outlets, 1);
1946    }
1947
1948    #[test]
1949    fn test_control_inlet_not_signal() {
1950        let prog = Program {
1951            in_decls: vec![InDecl {
1952                index: 0,
1953                name: "ctrl_in".to_string(),
1954                port_type: PortType::Float,
1955            }],
1956            out_decls: vec![],
1957            wires: vec![],
1958            destructuring_wires: vec![],
1959            msg_decls: vec![],
1960            out_assignments: vec![],
1961            direct_connections: vec![],
1962            feedback_decls: vec![],
1963            feedback_assignments: vec![],
1964            state_decls: vec![],
1965            state_assignments: vec![],
1966        };
1967
1968        let graph = build_graph(&prog).unwrap();
1969        let inlet_node = &graph.nodes[0];
1970        assert_eq!(inlet_node.object_name, "inlet");
1971        assert!(!inlet_node.is_signal);
1972        assert_eq!(inlet_node.num_inlets, 0);
1973        assert_eq!(inlet_node.num_outlets, 1);
1974    }
1975
1976    #[test]
1977    fn test_signal_outlet() {
1978        let prog = Program {
1979            in_decls: vec![],
1980            out_decls: vec![OutDecl {
1981                index: 0,
1982                name: "audio".to_string(),
1983                port_type: PortType::Signal,
1984                value: None,
1985            }],
1986            wires: vec![],
1987            destructuring_wires: vec![],
1988            msg_decls: vec![],
1989            out_assignments: vec![],
1990            direct_connections: vec![],
1991            feedback_decls: vec![],
1992            feedback_assignments: vec![],
1993            state_decls: vec![],
1994            state_assignments: vec![],
1995        };
1996
1997        let graph = build_graph(&prog).unwrap();
1998        let outlet_node = &graph.nodes[0];
1999        assert_eq!(outlet_node.object_name, "outlet~");
2000        assert!(outlet_node.is_signal);
2001    }
2002
2003    #[test]
2004    fn test_control_outlet() {
2005        let prog = Program {
2006            in_decls: vec![],
2007            out_decls: vec![OutDecl {
2008                index: 0,
2009                name: "ctrl_out".to_string(),
2010                port_type: PortType::Float,
2011                value: None,
2012            }],
2013            wires: vec![],
2014            destructuring_wires: vec![],
2015            msg_decls: vec![],
2016            out_assignments: vec![],
2017            direct_connections: vec![],
2018            feedback_decls: vec![],
2019            feedback_assignments: vec![],
2020            state_decls: vec![],
2021            state_assignments: vec![],
2022        };
2023
2024        let graph = build_graph(&prog).unwrap();
2025        let outlet_node = &graph.nodes[0];
2026        assert_eq!(outlet_node.object_name, "outlet");
2027        assert!(!outlet_node.is_signal);
2028    }
2029
2030    #[test]
2031    fn test_nested_call() {
2032        // wire x = *~(cycle~(440), 0.5);
2033        let prog = Program {
2034            in_decls: vec![],
2035            out_decls: vec![],
2036            wires: vec![Wire {
2037                name: "x".to_string(),
2038                value: Expr::Call {
2039                    object: "*~".to_string(),
2040                    args: vec![
2041                        CallArg::positional(Expr::Call {
2042                            object: "cycle~".to_string(),
2043                            args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
2044                        }),
2045                        CallArg::positional(Expr::Lit(LitValue::Float(0.5))),
2046                    ],
2047                },
2048                span: None,
2049                attrs: vec![],
2050            }],
2051            destructuring_wires: vec![],
2052            msg_decls: vec![],
2053            out_assignments: vec![],
2054            direct_connections: vec![],
2055            feedback_decls: vec![],
2056            feedback_assignments: vec![],
2057            state_decls: vec![],
2058            state_assignments: vec![],
2059        };
2060
2061        let graph = build_graph(&prog).unwrap();
2062        // 2 nodes: cycle~ and *~
2063        assert_eq!(graph.nodes.len(), 2);
2064
2065        let cycle_node = graph
2066            .nodes
2067            .iter()
2068            .find(|n| n.object_name == "cycle~")
2069            .unwrap();
2070        let mul_node = graph.nodes.iter().find(|n| n.object_name == "*~").unwrap();
2071
2072        // Edge: cycle~ -> *~
2073        let edge = graph
2074            .edges
2075            .iter()
2076            .find(|e| e.source_id == cycle_node.id && e.dest_id == mul_node.id)
2077            .expect("edge from cycle~ to *~ should exist");
2078        assert_eq!(edge.dest_inlet, 0);
2079    }
2080
2081    #[test]
2082    fn test_multiple_outlets() {
2083        // Patch with 2 output ports
2084        let prog = Program {
2085            in_decls: vec![],
2086            out_decls: vec![
2087                OutDecl {
2088                    index: 0,
2089                    name: "left".to_string(),
2090                    port_type: PortType::Signal,
2091                    value: None,
2092                },
2093                OutDecl {
2094                    index: 1,
2095                    name: "right".to_string(),
2096                    port_type: PortType::Signal,
2097                    value: None,
2098                },
2099            ],
2100            wires: vec![Wire {
2101                name: "osc".to_string(),
2102                value: Expr::Call {
2103                    object: "cycle~".to_string(),
2104                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
2105                },
2106                span: None,
2107                attrs: vec![],
2108            }],
2109            destructuring_wires: vec![],
2110            msg_decls: vec![],
2111            out_assignments: vec![
2112                OutAssignment {
2113                    index: 0,
2114                    value: Expr::Ref("osc".to_string()),
2115                    span: None,
2116                },
2117                OutAssignment {
2118                    index: 1,
2119                    value: Expr::Ref("osc".to_string()),
2120                    span: None,
2121                },
2122            ],
2123            direct_connections: vec![],
2124            feedback_decls: vec![],
2125            feedback_assignments: vec![],
2126            state_decls: vec![],
2127            state_assignments: vec![],
2128        };
2129
2130        let graph = build_graph(&prog).unwrap();
2131
2132        // 2 outlet~ nodes, 1 cycle~ node
2133        let outlet_nodes: Vec<&PatchNode> = graph
2134            .nodes
2135            .iter()
2136            .filter(|n| n.object_name == "outlet~")
2137            .collect();
2138        assert_eq!(outlet_nodes.len(), 2);
2139
2140        // 2 edges from cycle~ -> outlet~ (Signal, so no trigger needed)
2141        let cycle_node = graph
2142            .nodes
2143            .iter()
2144            .find(|n| n.object_name == "cycle~")
2145            .unwrap();
2146        let edges_from_cycle: Vec<&PatchEdge> = graph
2147            .edges
2148            .iter()
2149            .filter(|e| e.source_id == cycle_node.id)
2150            .collect();
2151        assert_eq!(edges_from_cycle.len(), 2);
2152    }
2153
2154    // ─── Abstraction registry tests ───
2155
2156    /// Build AST for oscillator
2157    fn make_oscillator_program() -> Program {
2158        Program {
2159            in_decls: vec![InDecl {
2160                index: 0,
2161                name: "freq".to_string(),
2162                port_type: PortType::Float,
2163            }],
2164            out_decls: vec![OutDecl {
2165                index: 0,
2166                name: "audio".to_string(),
2167                port_type: PortType::Signal,
2168                value: None,
2169            }],
2170            wires: vec![Wire {
2171                name: "osc".to_string(),
2172                value: Expr::Call {
2173                    object: "cycle~".to_string(),
2174                    args: vec![CallArg::positional(Expr::Ref("freq".to_string()))],
2175                },
2176                span: None,
2177                attrs: vec![],
2178            }],
2179            destructuring_wires: vec![],
2180            msg_decls: vec![],
2181            out_assignments: vec![OutAssignment {
2182                index: 0,
2183                value: Expr::Ref("osc".to_string()),
2184                span: None,
2185            }],
2186            direct_connections: vec![],
2187            feedback_decls: vec![],
2188            feedback_assignments: vec![],
2189            state_decls: vec![],
2190            state_assignments: vec![],
2191        }
2192    }
2193
2194    /// fm_synth AST: oscillator(base_freq) -> *~(carrier, 0.5) -> out[0]
2195    fn make_fm_synth_program() -> Program {
2196        Program {
2197            in_decls: vec![InDecl {
2198                index: 0,
2199                name: "base_freq".to_string(),
2200                port_type: PortType::Float,
2201            }],
2202            out_decls: vec![OutDecl {
2203                index: 0,
2204                name: "audio".to_string(),
2205                port_type: PortType::Signal,
2206                value: None,
2207            }],
2208            wires: vec![
2209                Wire {
2210                    name: "carrier".to_string(),
2211                    value: Expr::Call {
2212                        object: "oscillator".to_string(),
2213                        args: vec![CallArg::positional(Expr::Ref("base_freq".to_string()))],
2214                    },
2215                    span: None,
2216                    attrs: vec![],
2217                },
2218                Wire {
2219                    name: "amp".to_string(),
2220                    value: Expr::Call {
2221                        object: "mul~".to_string(),
2222                        args: vec![
2223                            CallArg::positional(Expr::Ref("carrier".to_string())),
2224                            CallArg::positional(Expr::Lit(LitValue::Float(0.5))),
2225                        ],
2226                    },
2227                    span: None,
2228                    attrs: vec![],
2229                },
2230            ],
2231            destructuring_wires: vec![],
2232            msg_decls: vec![],
2233            out_assignments: vec![OutAssignment {
2234                index: 0,
2235                value: Expr::Ref("amp".to_string()),
2236                span: None,
2237            }],
2238            direct_connections: vec![],
2239            feedback_decls: vec![],
2240            feedback_assignments: vec![],
2241            state_decls: vec![],
2242            state_assignments: vec![],
2243        }
2244    }
2245
2246    #[test]
2247    fn test_build_graph_with_registry_abstraction_inlets_outlets() {
2248        let mut registry = AbstractionRegistry::new();
2249        registry.register("oscillator", &make_oscillator_program());
2250
2251        let prog = make_fm_synth_program();
2252        let graph = build_graph_with_registry(&prog, Some(&registry)).unwrap();
2253
2254        // Find the oscillator node
2255        let osc_node = graph
2256            .nodes
2257            .iter()
2258            .find(|n| n.object_name == "oscillator")
2259            .expect("oscillator node should exist");
2260
2261        // oscillator has in_ports=1 (freq), out_ports=1 (audio)
2262        assert_eq!(osc_node.num_inlets, 1);
2263        assert_eq!(osc_node.num_outlets, 1);
2264        // First out_port is Signal, so is_signal = true
2265        assert!(osc_node.is_signal);
2266    }
2267
2268    #[test]
2269    fn test_build_graph_with_registry_abstraction_name_preserved() {
2270        let mut registry = AbstractionRegistry::new();
2271        registry.register("oscillator", &make_oscillator_program());
2272
2273        let prog = make_fm_synth_program();
2274        let graph = build_graph_with_registry(&prog, Some(&registry)).unwrap();
2275
2276        // object_name remains "oscillator" without alias conversion
2277        let osc_node = graph
2278            .nodes
2279            .iter()
2280            .find(|n| n.object_name == "oscillator")
2281            .expect("oscillator node should exist with original name");
2282        assert_eq!(osc_node.object_name, "oscillator");
2283    }
2284
2285    #[test]
2286    fn test_build_graph_with_registry_full_graph() {
2287        let mut registry = AbstractionRegistry::new();
2288        registry.register("oscillator", &make_oscillator_program());
2289
2290        let prog = make_fm_synth_program();
2291        let graph = build_graph_with_registry(&prog, Some(&registry)).unwrap();
2292
2293        // Nodes: inlet, outlet~, oscillator, *~
2294        assert_eq!(graph.nodes.len(), 4);
2295
2296        let names: Vec<&str> = graph.nodes.iter().map(|n| n.object_name.as_str()).collect();
2297        assert!(names.contains(&"inlet"));
2298        assert!(names.contains(&"outlet~"));
2299        assert!(names.contains(&"oscillator"));
2300        assert!(names.contains(&"*~"));
2301
2302        // Edges: inlet->oscillator, oscillator->*~, *~->outlet~
2303        assert_eq!(graph.edges.len(), 3);
2304    }
2305
2306    #[test]
2307    fn test_build_graph_without_registry_unknown_object() {
2308        // Calling oscillator without registry,
2309        // falls back to infer_num_inlets/outlets (no error)
2310        let prog = make_fm_synth_program();
2311        let graph = build_graph(&prog).unwrap();
2312
2313        let osc_node = graph
2314            .nodes
2315            .iter()
2316            .find(|n| n.object_name == "oscillator")
2317            .expect("oscillator node should exist");
2318
2319        // Without registry: infer_num_inlets = 1, infer_num_outlets = 1
2320        // But with 1 argument, num_inlets = max(1, 1) = 1
2321        assert_eq!(osc_node.num_inlets, 1);
2322        assert_eq!(osc_node.num_outlets, 1);
2323    }
2324
2325    #[test]
2326    fn test_build_graph_with_registry_multi_port_abstraction() {
2327        // filter abstraction with 3 inlets, 2 outlets
2328        let filter_prog = Program {
2329            in_decls: vec![
2330                InDecl {
2331                    index: 0,
2332                    name: "input_sig".to_string(),
2333                    port_type: PortType::Signal,
2334                },
2335                InDecl {
2336                    index: 1,
2337                    name: "cutoff".to_string(),
2338                    port_type: PortType::Float,
2339                },
2340                InDecl {
2341                    index: 2,
2342                    name: "q_factor".to_string(),
2343                    port_type: PortType::Float,
2344                },
2345            ],
2346            out_decls: vec![
2347                OutDecl {
2348                    index: 0,
2349                    name: "lowpass".to_string(),
2350                    port_type: PortType::Signal,
2351                    value: None,
2352                },
2353                OutDecl {
2354                    index: 1,
2355                    name: "highpass".to_string(),
2356                    port_type: PortType::Signal,
2357                    value: None,
2358                },
2359            ],
2360            wires: vec![],
2361            destructuring_wires: vec![],
2362            msg_decls: vec![],
2363            out_assignments: vec![],
2364            direct_connections: vec![],
2365            feedback_decls: vec![],
2366            feedback_assignments: vec![],
2367            state_decls: vec![],
2368            state_assignments: vec![],
2369        };
2370
2371        let mut registry = AbstractionRegistry::new();
2372        registry.register("filter", &filter_prog);
2373
2374        // Program that calls filter(osc, 1000, 0.7)
2375        let caller = Program {
2376            in_decls: vec![],
2377            out_decls: vec![],
2378            wires: vec![Wire {
2379                name: "result".to_string(),
2380                value: Expr::Call {
2381                    object: "filter".to_string(),
2382                    args: vec![
2383                        CallArg::positional(Expr::Call {
2384                            object: "cycle~".to_string(),
2385                            args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
2386                        }),
2387                        CallArg::positional(Expr::Lit(LitValue::Int(1000))),
2388                        CallArg::positional(Expr::Lit(LitValue::Float(0.7))),
2389                    ],
2390                },
2391                span: None,
2392                attrs: vec![],
2393            }],
2394            destructuring_wires: vec![],
2395            msg_decls: vec![],
2396            out_assignments: vec![],
2397            direct_connections: vec![],
2398            feedback_decls: vec![],
2399            feedback_assignments: vec![],
2400            state_decls: vec![],
2401            state_assignments: vec![],
2402        };
2403
2404        let graph = build_graph_with_registry(&caller, Some(&registry)).unwrap();
2405
2406        let filter_node = graph
2407            .nodes
2408            .iter()
2409            .find(|n| n.object_name == "filter")
2410            .expect("filter node should exist");
2411
2412        assert_eq!(filter_node.num_inlets, 3);
2413        assert_eq!(filter_node.num_outlets, 2);
2414        assert!(filter_node.is_signal);
2415    }
2416
2417    #[test]
2418    fn test_build_graph_with_none_registry() {
2419        // registry=None behaves the same as build_graph
2420        let prog = make_l2_program();
2421        let graph = build_graph_with_registry(&prog, None).unwrap();
2422
2423        assert_eq!(graph.nodes.len(), 4);
2424    }
2425
2426    // ─── Tuple / Destructuring tests ───
2427
2428    #[test]
2429    fn test_tuple_generates_pack_node() {
2430        // wire t = (x, y, z); -> pack f f f node
2431        let prog = Program {
2432            in_decls: vec![
2433                InDecl {
2434                    index: 0,
2435                    name: "x".to_string(),
2436                    port_type: PortType::Float,
2437                },
2438                InDecl {
2439                    index: 1,
2440                    name: "y".to_string(),
2441                    port_type: PortType::Float,
2442                },
2443                InDecl {
2444                    index: 2,
2445                    name: "z".to_string(),
2446                    port_type: PortType::Float,
2447                },
2448            ],
2449            out_decls: vec![OutDecl {
2450                index: 0,
2451                name: "coords".to_string(),
2452                port_type: PortType::List,
2453                value: None,
2454            }],
2455            wires: vec![Wire {
2456                name: "packed".to_string(),
2457                value: Expr::Tuple(vec![
2458                    Expr::Ref("x".to_string()),
2459                    Expr::Ref("y".to_string()),
2460                    Expr::Ref("z".to_string()),
2461                ]),
2462                span: None,
2463                attrs: vec![],
2464            }],
2465            destructuring_wires: vec![],
2466            msg_decls: vec![],
2467            out_assignments: vec![OutAssignment {
2468                index: 0,
2469                value: Expr::Ref("packed".to_string()),
2470                span: None,
2471            }],
2472            direct_connections: vec![],
2473            feedback_decls: vec![],
2474            feedback_assignments: vec![],
2475            state_decls: vec![],
2476            state_assignments: vec![],
2477        };
2478
2479        let graph = build_graph(&prog).unwrap();
2480
2481        // pack node exists
2482        let pack_node = graph
2483            .nodes
2484            .iter()
2485            .find(|n| n.object_name == "pack")
2486            .expect("pack node should exist");
2487        assert_eq!(pack_node.num_inlets, 3);
2488        assert_eq!(pack_node.num_outlets, 1);
2489        assert_eq!(pack_node.args, vec!["f", "f", "f"]);
2490        assert!(!pack_node.is_signal);
2491
2492        // 3 edges from inlet -> pack
2493        let edges_to_pack: Vec<_> = graph
2494            .edges
2495            .iter()
2496            .filter(|e| e.dest_id == pack_node.id)
2497            .collect();
2498        assert_eq!(edges_to_pack.len(), 3);
2499
2500        // Each inlet connects to a different dest_inlet
2501        let mut dest_inlets: Vec<u32> = edges_to_pack.iter().map(|e| e.dest_inlet).collect();
2502        dest_inlets.sort();
2503        assert_eq!(dest_inlets, vec![0, 1, 2]);
2504    }
2505
2506    #[test]
2507    fn test_destructuring_with_unpack_call() {
2508        // wire (a, b) = unpack(data); -> Expr::Call generates an unpack node,
2509        // DestructuringWire maps a, b to its outlets
2510        use flutmax_ast::DestructuringWire;
2511
2512        let prog = Program {
2513            in_decls: vec![InDecl {
2514                index: 0,
2515                name: "data".to_string(),
2516                port_type: PortType::Float,
2517            }],
2518            out_decls: vec![
2519                OutDecl {
2520                    index: 0,
2521                    name: "x".to_string(),
2522                    port_type: PortType::Float,
2523                    value: None,
2524                },
2525                OutDecl {
2526                    index: 1,
2527                    name: "y".to_string(),
2528                    port_type: PortType::Float,
2529                    value: None,
2530                },
2531            ],
2532            wires: vec![],
2533            destructuring_wires: vec![DestructuringWire {
2534                names: vec!["a".to_string(), "b".to_string()],
2535                value: Expr::Call {
2536                    object: "unpack".to_string(),
2537                    args: vec![CallArg::positional(Expr::Ref("data".to_string()))],
2538                },
2539                span: None,
2540            }],
2541            msg_decls: vec![],
2542            out_assignments: vec![
2543                OutAssignment {
2544                    index: 0,
2545                    value: Expr::Ref("a".to_string()),
2546                    span: None,
2547                },
2548                OutAssignment {
2549                    index: 1,
2550                    value: Expr::Ref("b".to_string()),
2551                    span: None,
2552                },
2553            ],
2554            direct_connections: vec![],
2555            feedback_decls: vec![],
2556            feedback_assignments: vec![],
2557            state_decls: vec![],
2558            state_assignments: vec![],
2559        };
2560
2561        let graph = build_graph(&prog).unwrap();
2562
2563        // Expr::Call("unpack") generates one unpack node
2564        // DestructuringWire reuses existing unpack node outlets
2565        let unpack_nodes: Vec<_> = graph
2566            .nodes
2567            .iter()
2568            .filter(|n| n.object_name == "unpack")
2569            .collect();
2570        assert_eq!(unpack_nodes.len(), 1);
2571
2572        let unpack_node = unpack_nodes[0];
2573        assert_eq!(unpack_node.num_outlets, 2);
2574        assert!(!unpack_node.is_signal);
2575
2576        // Edge: inlet -> unpack
2577        let edges_to_unpack: Vec<_> = graph
2578            .edges
2579            .iter()
2580            .filter(|e| e.dest_id == unpack_node.id)
2581            .collect();
2582        assert_eq!(edges_to_unpack.len(), 1);
2583
2584        // a and b are each connected to outlet nodes
2585        let outlet_nodes: Vec<_> = graph
2586            .nodes
2587            .iter()
2588            .filter(|n| n.object_name == "outlet")
2589            .collect();
2590        assert_eq!(outlet_nodes.len(), 2);
2591
2592        // 2 edges from unpack -> outlet
2593        let edges_from_unpack: Vec<_> = graph
2594            .edges
2595            .iter()
2596            .filter(|e| e.source_id == unpack_node.id)
2597            .collect();
2598        assert_eq!(edges_from_unpack.len(), 2);
2599
2600        // From outlet 0 and outlet 1 respectively
2601        let mut source_outlets: Vec<u32> =
2602            edges_from_unpack.iter().map(|e| e.source_outlet).collect();
2603        source_outlets.sort();
2604        assert_eq!(source_outlets, vec![0, 1]);
2605    }
2606
2607    #[test]
2608    fn test_destructuring_with_ref_auto_unpack() {
2609        // wire (a, b) = packed; -> packed node has insufficient outlets, so auto-insert unpack
2610        use flutmax_ast::DestructuringWire;
2611
2612        let prog = Program {
2613            in_decls: vec![
2614                InDecl {
2615                    index: 0,
2616                    name: "x".to_string(),
2617                    port_type: PortType::Float,
2618                },
2619                InDecl {
2620                    index: 1,
2621                    name: "y".to_string(),
2622                    port_type: PortType::Float,
2623                },
2624            ],
2625            out_decls: vec![],
2626            wires: vec![Wire {
2627                name: "packed".to_string(),
2628                value: Expr::Tuple(vec![Expr::Ref("x".to_string()), Expr::Ref("y".to_string())]),
2629                span: None,
2630                attrs: vec![],
2631            }],
2632            destructuring_wires: vec![DestructuringWire {
2633                names: vec!["a".to_string(), "b".to_string()],
2634                value: Expr::Ref("packed".to_string()),
2635                span: None,
2636            }],
2637            msg_decls: vec![],
2638            out_assignments: vec![],
2639            direct_connections: vec![],
2640            feedback_decls: vec![],
2641            feedback_assignments: vec![],
2642            state_decls: vec![],
2643            state_assignments: vec![],
2644        };
2645
2646        let graph = build_graph(&prog).unwrap();
2647
2648        // One pack node (tuple)
2649        let pack_node = graph
2650            .nodes
2651            .iter()
2652            .find(|n| n.object_name == "pack")
2653            .expect("pack node should exist");
2654        assert_eq!(pack_node.num_outlets, 1);
2655
2656        // pack has 1 outlet, so DestructuringWire auto-inserts unpack
2657        let unpack_node = graph
2658            .nodes
2659            .iter()
2660            .find(|n| n.object_name == "unpack")
2661            .expect("unpack node should be auto-inserted");
2662        assert_eq!(unpack_node.num_outlets, 2);
2663        assert_eq!(unpack_node.args, vec!["f", "f"]);
2664
2665        // Edge: pack -> unpack
2666        let pack_to_unpack = graph
2667            .edges
2668            .iter()
2669            .find(|e| e.source_id == pack_node.id && e.dest_id == unpack_node.id)
2670            .expect("edge from pack to unpack should exist");
2671        assert_eq!(pack_to_unpack.dest_inlet, 0);
2672    }
2673
2674    #[test]
2675    fn test_tuple_two_elements_pack() {
2676        // wire t = (a, b); → pack f f
2677        let prog = Program {
2678            in_decls: vec![
2679                InDecl {
2680                    index: 0,
2681                    name: "a".to_string(),
2682                    port_type: PortType::Float,
2683                },
2684                InDecl {
2685                    index: 1,
2686                    name: "b".to_string(),
2687                    port_type: PortType::Float,
2688                },
2689            ],
2690            out_decls: vec![],
2691            wires: vec![Wire {
2692                name: "t".to_string(),
2693                value: Expr::Tuple(vec![Expr::Ref("a".to_string()), Expr::Ref("b".to_string())]),
2694                span: None,
2695                attrs: vec![],
2696            }],
2697            destructuring_wires: vec![],
2698            msg_decls: vec![],
2699            out_assignments: vec![],
2700            direct_connections: vec![],
2701            feedback_decls: vec![],
2702            feedback_assignments: vec![],
2703            state_decls: vec![],
2704            state_assignments: vec![],
2705        };
2706
2707        let graph = build_graph(&prog).unwrap();
2708
2709        let pack_node = graph
2710            .nodes
2711            .iter()
2712            .find(|n| n.object_name == "pack")
2713            .expect("pack node should exist");
2714        assert_eq!(pack_node.num_inlets, 2);
2715        assert_eq!(pack_node.args, vec!["f", "f"]);
2716    }
2717
2718    // ─── Feedback tests ───
2719
2720    #[test]
2721    fn test_feedback_generates_tapin_node() {
2722        // feedback fb: signal; -> tapin~ node is generated
2723        use flutmax_ast::FeedbackDecl;
2724
2725        let prog = Program {
2726            in_decls: vec![InDecl {
2727                index: 0,
2728                name: "input".to_string(),
2729                port_type: PortType::Signal,
2730            }],
2731            out_decls: vec![OutDecl {
2732                index: 0,
2733                name: "output".to_string(),
2734                port_type: PortType::Signal,
2735                value: None,
2736            }],
2737            wires: vec![
2738                Wire {
2739                    name: "delayed".to_string(),
2740                    value: Expr::Call {
2741                        object: "tapout~".to_string(),
2742                        args: vec![
2743                            CallArg::positional(Expr::Ref("fb".to_string())),
2744                            CallArg::positional(Expr::Lit(LitValue::Int(500))),
2745                        ],
2746                    },
2747                    span: None,
2748                    attrs: vec![],
2749                },
2750                Wire {
2751                    name: "mixed".to_string(),
2752                    value: Expr::Call {
2753                        object: "add~".to_string(),
2754                        args: vec![
2755                            CallArg::positional(Expr::Ref("input".to_string())),
2756                            CallArg::positional(Expr::Call {
2757                                object: "mul~".to_string(),
2758                                args: vec![
2759                                    CallArg::positional(Expr::Ref("delayed".to_string())),
2760                                    CallArg::positional(Expr::Lit(LitValue::Float(0.3))),
2761                                ],
2762                            }),
2763                        ],
2764                    },
2765                    span: None,
2766                    attrs: vec![],
2767                },
2768            ],
2769            destructuring_wires: vec![],
2770            msg_decls: vec![],
2771            out_assignments: vec![OutAssignment {
2772                index: 0,
2773                value: Expr::Ref("mixed".to_string()),
2774                span: None,
2775            }],
2776            direct_connections: vec![],
2777            feedback_decls: vec![FeedbackDecl {
2778                name: "fb".to_string(),
2779                port_type: PortType::Signal,
2780                span: None,
2781            }],
2782            feedback_assignments: vec![FeedbackAssignment {
2783                target: "fb".to_string(),
2784                value: Expr::Call {
2785                    object: "tapin~".to_string(),
2786                    args: vec![
2787                        CallArg::positional(Expr::Ref("mixed".to_string())),
2788                        CallArg::positional(Expr::Lit(LitValue::Int(1000))),
2789                    ],
2790                },
2791                span: None,
2792            }],
2793            state_decls: vec![],
2794            state_assignments: vec![],
2795        };
2796
2797        let graph = build_graph(&prog).unwrap();
2798
2799        // tapin~ node exists
2800        let tapin_node = graph
2801            .nodes
2802            .iter()
2803            .find(|n| n.object_name == "tapin~")
2804            .expect("tapin~ node should exist");
2805        assert!(tapin_node.is_signal);
2806        assert_eq!(tapin_node.num_inlets, 1);
2807        assert_eq!(tapin_node.num_outlets, 1);
2808
2809        // tapout~ node exists
2810        let tapout_node = graph
2811            .nodes
2812            .iter()
2813            .find(|n| n.object_name == "tapout~")
2814            .expect("tapout~ node should exist");
2815        assert!(tapout_node.is_signal);
2816
2817        // Edge tapin~ -> tapout~ exists
2818        let tapin_to_tapout = graph
2819            .edges
2820            .iter()
2821            .find(|e| e.source_id == tapin_node.id && e.dest_id == tapout_node.id)
2822            .expect("edge from tapin~ to tapout~ should exist");
2823        assert_eq!(tapin_to_tapout.source_outlet, 0);
2824        assert_eq!(tapin_to_tapout.dest_inlet, 0);
2825        // tapin~ -> tapout~ is a normal edge (is_feedback is on the assignment edge)
2826        assert!(!tapin_to_tapout.is_feedback);
2827
2828        // feedback assignment edge has is_feedback=true
2829        let feedback_edges: Vec<_> = graph.edges.iter().filter(|e| e.is_feedback).collect();
2830        assert_eq!(
2831            feedback_edges.len(),
2832            1,
2833            "should have exactly one feedback edge"
2834        );
2835    }
2836
2837    #[test]
2838    fn test_feedback_no_trigger_on_feedback_edge() {
2839        // Verify trigger is not inserted for feedback edges
2840        use flutmax_ast::FeedbackDecl;
2841
2842        let prog = Program {
2843            in_decls: vec![InDecl {
2844                index: 0,
2845                name: "input".to_string(),
2846                port_type: PortType::Signal,
2847            }],
2848            out_decls: vec![OutDecl {
2849                index: 0,
2850                name: "output".to_string(),
2851                port_type: PortType::Signal,
2852                value: None,
2853            }],
2854            wires: vec![
2855                Wire {
2856                    name: "delayed".to_string(),
2857                    value: Expr::Call {
2858                        object: "tapout~".to_string(),
2859                        args: vec![
2860                            CallArg::positional(Expr::Ref("fb".to_string())),
2861                            CallArg::positional(Expr::Lit(LitValue::Int(500))),
2862                        ],
2863                    },
2864                    span: None,
2865                    attrs: vec![],
2866                },
2867                Wire {
2868                    name: "mixed".to_string(),
2869                    value: Expr::Call {
2870                        object: "add~".to_string(),
2871                        args: vec![
2872                            CallArg::positional(Expr::Ref("input".to_string())),
2873                            CallArg::positional(Expr::Ref("delayed".to_string())),
2874                        ],
2875                    },
2876                    span: None,
2877                    attrs: vec![],
2878                },
2879            ],
2880            destructuring_wires: vec![],
2881            msg_decls: vec![],
2882            out_assignments: vec![OutAssignment {
2883                index: 0,
2884                value: Expr::Ref("mixed".to_string()),
2885                span: None,
2886            }],
2887            direct_connections: vec![],
2888            feedback_decls: vec![FeedbackDecl {
2889                name: "fb".to_string(),
2890                port_type: PortType::Signal,
2891                span: None,
2892            }],
2893            feedback_assignments: vec![FeedbackAssignment {
2894                target: "fb".to_string(),
2895                value: Expr::Call {
2896                    object: "tapin~".to_string(),
2897                    args: vec![
2898                        CallArg::positional(Expr::Ref("mixed".to_string())),
2899                        CallArg::positional(Expr::Lit(LitValue::Int(1000))),
2900                    ],
2901                },
2902                span: None,
2903            }],
2904            state_decls: vec![],
2905            state_assignments: vec![],
2906        };
2907
2908        let graph = build_graph(&prog).unwrap();
2909
2910        // Confirm no trigger node was inserted
2911        // (all Signal, so no trigger needed)
2912        let trigger_nodes: Vec<_> = graph
2913            .nodes
2914            .iter()
2915            .filter(|n| n.object_name == "trigger")
2916            .collect();
2917        assert_eq!(
2918            trigger_nodes.len(),
2919            0,
2920            "no trigger nodes should be inserted for signal-only feedback"
2921        );
2922    }
2923
2924    // ─── E004: NoOutDeclaration tests ───
2925
2926    #[test]
2927    fn test_e004_no_out_declaration_detected() {
2928        // Assign to out[0] without out declaration -> E004
2929        let prog = Program {
2930            in_decls: vec![],
2931            out_decls: vec![],
2932            wires: vec![Wire {
2933                name: "x".to_string(),
2934                value: Expr::Call {
2935                    object: "button".to_string(),
2936                    args: vec![],
2937                },
2938                span: None,
2939                attrs: vec![],
2940            }],
2941            destructuring_wires: vec![],
2942            msg_decls: vec![],
2943            out_assignments: vec![OutAssignment {
2944                index: 0,
2945                value: Expr::Ref("x".to_string()),
2946                span: None,
2947            }],
2948            direct_connections: vec![],
2949            feedback_decls: vec![],
2950            feedback_assignments: vec![],
2951            state_decls: vec![],
2952            state_assignments: vec![],
2953        };
2954
2955        let result = build_graph(&prog);
2956        assert!(result.is_err());
2957        match result.unwrap_err() {
2958            BuildError::NoOutDeclaration(idx) => assert_eq!(idx, 0),
2959            other => panic!("expected NoOutDeclaration, got {:?}", other),
2960        }
2961    }
2962
2963    #[test]
2964    fn test_e004_valid_out_declaration_no_error() {
2965        // Assign to out[0] with out declaration -> no error
2966        let prog = Program {
2967            in_decls: vec![],
2968            out_decls: vec![OutDecl {
2969                index: 0,
2970                name: "audio".to_string(),
2971                port_type: PortType::Signal,
2972                value: None,
2973            }],
2974            wires: vec![Wire {
2975                name: "osc".to_string(),
2976                value: Expr::Call {
2977                    object: "cycle~".to_string(),
2978                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
2979                },
2980                span: None,
2981                attrs: vec![],
2982            }],
2983            destructuring_wires: vec![],
2984            msg_decls: vec![],
2985            out_assignments: vec![OutAssignment {
2986                index: 0,
2987                value: Expr::Ref("osc".to_string()),
2988                span: None,
2989            }],
2990            direct_connections: vec![],
2991            feedback_decls: vec![],
2992            feedback_assignments: vec![],
2993            state_decls: vec![],
2994            state_assignments: vec![],
2995        };
2996
2997        let result = build_graph(&prog);
2998        assert!(result.is_ok());
2999    }
3000
3001    // ─── E006: DestructuringCountMismatch tests ───
3002
3003    #[test]
3004    fn test_e006_destructuring_count_mismatch_detected() {
3005        // unpack has 2 outlets but 3 names -> E006
3006        use flutmax_ast::DestructuringWire;
3007
3008        let prog = Program {
3009            in_decls: vec![InDecl {
3010                index: 0,
3011                name: "data".to_string(),
3012                port_type: PortType::Float,
3013            }],
3014            out_decls: vec![],
3015            wires: vec![],
3016            destructuring_wires: vec![DestructuringWire {
3017                names: vec!["a".to_string(), "b".to_string(), "c".to_string()],
3018                value: Expr::Call {
3019                    object: "unpack".to_string(),
3020                    args: vec![CallArg::positional(Expr::Ref("data".to_string()))],
3021                },
3022                span: None,
3023            }],
3024            msg_decls: vec![],
3025            out_assignments: vec![],
3026            direct_connections: vec![],
3027            feedback_decls: vec![],
3028            feedback_assignments: vec![],
3029            state_decls: vec![],
3030            state_assignments: vec![],
3031        };
3032
3033        let result = build_graph(&prog);
3034        assert!(result.is_err());
3035        match result.unwrap_err() {
3036            BuildError::DestructuringCountMismatch { expected, got } => {
3037                assert_eq!(expected, 2);
3038                assert_eq!(got, 3);
3039            }
3040            other => panic!("expected DestructuringCountMismatch, got {:?}", other),
3041        }
3042    }
3043
3044    #[test]
3045    fn test_e006_destructuring_count_match_no_error() {
3046        // unpack has 2 outlets and 2 names -> no error
3047        use flutmax_ast::DestructuringWire;
3048
3049        let prog = Program {
3050            in_decls: vec![InDecl {
3051                index: 0,
3052                name: "data".to_string(),
3053                port_type: PortType::Float,
3054            }],
3055            out_decls: vec![],
3056            wires: vec![],
3057            destructuring_wires: vec![DestructuringWire {
3058                names: vec!["a".to_string(), "b".to_string()],
3059                value: Expr::Call {
3060                    object: "unpack".to_string(),
3061                    args: vec![CallArg::positional(Expr::Ref("data".to_string()))],
3062                },
3063                span: None,
3064            }],
3065            msg_decls: vec![],
3066            out_assignments: vec![],
3067            direct_connections: vec![],
3068            feedback_decls: vec![],
3069            feedback_assignments: vec![],
3070            state_decls: vec![],
3071            state_assignments: vec![],
3072        };
3073
3074        let result = build_graph(&prog);
3075        assert!(result.is_ok());
3076    }
3077
3078    // ─── E009: AbstractionArgCountMismatch tests ───
3079
3080    #[test]
3081    fn test_e009_abstraction_arg_count_mismatch_detected() {
3082        // oscillator has 1 in_port but called with 2 args -> E009
3083        let mut registry = AbstractionRegistry::new();
3084        registry.register("oscillator", &make_oscillator_program());
3085
3086        let prog = Program {
3087            in_decls: vec![],
3088            out_decls: vec![],
3089            wires: vec![Wire {
3090                name: "osc".to_string(),
3091                value: Expr::Call {
3092                    object: "oscillator".to_string(),
3093                    args: vec![
3094                        CallArg::positional(Expr::Lit(LitValue::Int(440))),
3095                        CallArg::positional(Expr::Lit(LitValue::Float(0.5))),
3096                    ],
3097                },
3098                span: None,
3099                attrs: vec![],
3100            }],
3101            destructuring_wires: vec![],
3102            msg_decls: vec![],
3103            out_assignments: vec![],
3104            direct_connections: vec![],
3105            feedback_decls: vec![],
3106            feedback_assignments: vec![],
3107            state_decls: vec![],
3108            state_assignments: vec![],
3109        };
3110
3111        let result = build_graph_with_registry(&prog, Some(&registry));
3112        assert!(result.is_err());
3113        match result.unwrap_err() {
3114            BuildError::AbstractionArgCountMismatch {
3115                name,
3116                expected,
3117                got,
3118            } => {
3119                assert_eq!(name, "oscillator");
3120                assert_eq!(expected, 1);
3121                assert_eq!(got, 2);
3122            }
3123            other => panic!("expected AbstractionArgCountMismatch, got {:?}", other),
3124        }
3125    }
3126
3127    #[test]
3128    fn test_e009_abstraction_arg_count_match_no_error() {
3129        // oscillator has 1 in_port with 1 arg -> no error
3130        let mut registry = AbstractionRegistry::new();
3131        registry.register("oscillator", &make_oscillator_program());
3132
3133        let prog = Program {
3134            in_decls: vec![],
3135            out_decls: vec![],
3136            wires: vec![Wire {
3137                name: "osc".to_string(),
3138                value: Expr::Call {
3139                    object: "oscillator".to_string(),
3140                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
3141                },
3142                span: None,
3143                attrs: vec![],
3144            }],
3145            destructuring_wires: vec![],
3146            msg_decls: vec![],
3147            out_assignments: vec![],
3148            direct_connections: vec![],
3149            feedback_decls: vec![],
3150            feedback_assignments: vec![],
3151            state_decls: vec![],
3152            state_assignments: vec![],
3153        };
3154
3155        let result = build_graph_with_registry(&prog, Some(&registry));
3156        assert!(result.is_ok());
3157    }
3158
3159    // ─── E013: DuplicateFeedbackAssignment tests ───
3160
3161    #[test]
3162    fn test_e013_duplicate_feedback_assignment_detected() {
3163        // 2 assignments to same feedback variable -> E013
3164        use flutmax_ast::{FeedbackAssignment, FeedbackDecl};
3165
3166        let prog = Program {
3167            in_decls: vec![InDecl {
3168                index: 0,
3169                name: "input".to_string(),
3170                port_type: PortType::Signal,
3171            }],
3172            out_decls: vec![],
3173            wires: vec![Wire {
3174                name: "sig".to_string(),
3175                value: Expr::Call {
3176                    object: "cycle~".to_string(),
3177                    args: vec![CallArg::positional(Expr::Ref("input".to_string()))],
3178                },
3179                span: None,
3180                attrs: vec![],
3181            }],
3182            destructuring_wires: vec![],
3183            msg_decls: vec![],
3184            out_assignments: vec![],
3185            direct_connections: vec![],
3186            feedback_decls: vec![FeedbackDecl {
3187                name: "fb".to_string(),
3188                port_type: PortType::Signal,
3189                span: None,
3190            }],
3191            feedback_assignments: vec![
3192                FeedbackAssignment {
3193                    target: "fb".to_string(),
3194                    value: Expr::Ref("sig".to_string()),
3195                    span: None,
3196                },
3197                FeedbackAssignment {
3198                    target: "fb".to_string(),
3199                    value: Expr::Ref("sig".to_string()),
3200                    span: None,
3201                },
3202            ],
3203            state_decls: vec![],
3204            state_assignments: vec![],
3205        };
3206
3207        let result = build_graph(&prog);
3208        assert!(result.is_err());
3209        match result.unwrap_err() {
3210            BuildError::DuplicateFeedbackAssignment(name) => assert_eq!(name, "fb"),
3211            other => panic!("expected DuplicateFeedbackAssignment, got {:?}", other),
3212        }
3213    }
3214
3215    #[test]
3216    fn test_e013_single_feedback_assignment_no_error() {
3217        // 1 feedback assignment -> no error
3218        use flutmax_ast::{FeedbackAssignment, FeedbackDecl};
3219
3220        let prog = Program {
3221            in_decls: vec![InDecl {
3222                index: 0,
3223                name: "input".to_string(),
3224                port_type: PortType::Signal,
3225            }],
3226            out_decls: vec![],
3227            wires: vec![Wire {
3228                name: "sig".to_string(),
3229                value: Expr::Call {
3230                    object: "cycle~".to_string(),
3231                    args: vec![CallArg::positional(Expr::Ref("input".to_string()))],
3232                },
3233                span: None,
3234                attrs: vec![],
3235            }],
3236            destructuring_wires: vec![],
3237            msg_decls: vec![],
3238            out_assignments: vec![],
3239            direct_connections: vec![],
3240            feedback_decls: vec![FeedbackDecl {
3241                name: "fb".to_string(),
3242                port_type: PortType::Signal,
3243                span: None,
3244            }],
3245            feedback_assignments: vec![FeedbackAssignment {
3246                target: "fb".to_string(),
3247                value: Expr::Ref("sig".to_string()),
3248                span: None,
3249            }],
3250            state_decls: vec![],
3251            state_assignments: vec![],
3252        };
3253
3254        let result = build_graph(&prog);
3255        assert!(result.is_ok());
3256    }
3257
3258    // ─── E17: Edge Order tests ───
3259
3260    #[test]
3261    fn test_fanout_edges_get_order() {
3262        // cycle~ -> outlet~ x2 (Signal fanout) -> order is assigned
3263        let prog = Program {
3264            in_decls: vec![],
3265            out_decls: vec![
3266                OutDecl {
3267                    index: 0,
3268                    name: "left".to_string(),
3269                    port_type: PortType::Signal,
3270                    value: None,
3271                },
3272                OutDecl {
3273                    index: 1,
3274                    name: "right".to_string(),
3275                    port_type: PortType::Signal,
3276                    value: None,
3277                },
3278            ],
3279            wires: vec![Wire {
3280                name: "osc".to_string(),
3281                value: Expr::Call {
3282                    object: "cycle~".to_string(),
3283                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
3284                },
3285                span: None,
3286                attrs: vec![],
3287            }],
3288            destructuring_wires: vec![],
3289            msg_decls: vec![],
3290            out_assignments: vec![
3291                OutAssignment {
3292                    index: 0,
3293                    value: Expr::Ref("osc".to_string()),
3294                    span: None,
3295                },
3296                OutAssignment {
3297                    index: 1,
3298                    value: Expr::Ref("osc".to_string()),
3299                    span: None,
3300                },
3301            ],
3302            direct_connections: vec![],
3303            feedback_decls: vec![],
3304            feedback_assignments: vec![],
3305            state_decls: vec![],
3306            state_assignments: vec![],
3307        };
3308
3309        let graph = build_graph(&prog).unwrap();
3310
3311        // 2 edges from cycle~, with order assigned
3312        let cycle_node = graph
3313            .nodes
3314            .iter()
3315            .find(|n| n.object_name == "cycle~")
3316            .unwrap();
3317        let edges_from_cycle: Vec<_> = graph
3318            .edges
3319            .iter()
3320            .filter(|e| e.source_id == cycle_node.id && e.source_outlet == 0)
3321            .collect();
3322        assert_eq!(edges_from_cycle.len(), 2);
3323
3324        // Both have Some order
3325        assert!(edges_from_cycle[0].order.is_some());
3326        assert!(edges_from_cycle[1].order.is_some());
3327
3328        // order is 0 and 1
3329        let mut orders: Vec<u32> = edges_from_cycle.iter().map(|e| e.order.unwrap()).collect();
3330        orders.sort();
3331        assert_eq!(orders, vec![0, 1]);
3332    }
3333
3334    #[test]
3335    fn test_single_edge_no_order() {
3336        // Single-connection edges are not assigned order
3337        let prog = make_l2_program();
3338        let graph = build_graph(&prog).unwrap();
3339
3340        // All edges have order: None
3341        for edge in &graph.edges {
3342            assert_eq!(
3343                edge.order, None,
3344                "single edge from {} outlet {} should have no order",
3345                edge.source_id, edge.source_outlet
3346            );
3347        }
3348    }
3349
3350    // ─── E17: Purity Classification tests ───
3351
3352    #[test]
3353    fn test_classify_purity_signal_pure() {
3354        assert_eq!(classify_purity("cycle~"), NodePurity::Pure);
3355        assert_eq!(classify_purity("*~"), NodePurity::Pure);
3356        assert_eq!(classify_purity("+~"), NodePurity::Pure);
3357        assert_eq!(classify_purity("biquad~"), NodePurity::Pure);
3358    }
3359
3360    #[test]
3361    fn test_classify_purity_signal_stateful() {
3362        assert_eq!(classify_purity("tapin~"), NodePurity::Stateful);
3363        assert_eq!(classify_purity("tapout~"), NodePurity::Stateful);
3364        assert_eq!(classify_purity("line~"), NodePurity::Stateful);
3365        assert_eq!(classify_purity("delay~"), NodePurity::Stateful);
3366    }
3367
3368    #[test]
3369    fn test_classify_purity_control_stateful() {
3370        assert_eq!(classify_purity("pack"), NodePurity::Stateful);
3371        assert_eq!(classify_purity("unpack"), NodePurity::Stateful);
3372        assert_eq!(classify_purity("int"), NodePurity::Stateful);
3373        assert_eq!(classify_purity("float"), NodePurity::Stateful);
3374        assert_eq!(classify_purity("toggle"), NodePurity::Stateful);
3375        assert_eq!(classify_purity("gate"), NodePurity::Stateful);
3376        assert_eq!(classify_purity("counter"), NodePurity::Stateful);
3377        assert_eq!(classify_purity("coll"), NodePurity::Stateful);
3378        assert_eq!(classify_purity("dict"), NodePurity::Stateful);
3379    }
3380
3381    #[test]
3382    fn test_classify_purity_control_pure() {
3383        assert_eq!(classify_purity("+"), NodePurity::Pure);
3384        assert_eq!(classify_purity("-"), NodePurity::Pure);
3385        assert_eq!(classify_purity("*"), NodePurity::Pure);
3386        assert_eq!(classify_purity("/"), NodePurity::Pure);
3387        assert_eq!(classify_purity("trigger"), NodePurity::Pure);
3388        assert_eq!(classify_purity("t"), NodePurity::Pure);
3389        assert_eq!(classify_purity("route"), NodePurity::Pure);
3390        assert_eq!(classify_purity("select"), NodePurity::Pure);
3391        assert_eq!(classify_purity("prepend"), NodePurity::Pure);
3392    }
3393
3394    #[test]
3395    fn test_classify_purity_unknown() {
3396        assert_eq!(classify_purity("my_custom_object"), NodePurity::Unknown);
3397        assert_eq!(classify_purity("some_abstraction"), NodePurity::Unknown);
3398    }
3399
3400    // ─── E17: Hot/Cold Inlets tests ───
3401
3402    #[test]
3403    fn test_default_hot_inlets_standard() {
3404        // inlet 0 = hot, others = cold
3405        let hot = default_hot_inlets("cycle~", 2);
3406        assert_eq!(hot, vec![true, false]);
3407    }
3408
3409    #[test]
3410    fn test_default_hot_inlets_single() {
3411        let hot = default_hot_inlets("print", 1);
3412        assert_eq!(hot, vec![true]);
3413    }
3414
3415    #[test]
3416    fn test_default_hot_inlets_none() {
3417        let hot = default_hot_inlets("inlet", 0);
3418        assert!(hot.is_empty());
3419    }
3420
3421    #[test]
3422    fn test_default_hot_inlets_many() {
3423        let hot = default_hot_inlets("biquad~", 6);
3424        assert_eq!(hot, vec![true, false, false, false, false, false]);
3425    }
3426
3427    // ─── E17: Graph Node Attributes tests ───
3428
3429    #[test]
3430    fn test_built_node_has_purity() {
3431        let prog = make_l2_program();
3432        let graph = build_graph(&prog).unwrap();
3433
3434        let cycle_node = graph
3435            .nodes
3436            .iter()
3437            .find(|n| n.object_name == "cycle~")
3438            .unwrap();
3439        assert_eq!(cycle_node.purity, NodePurity::Pure);
3440
3441        let mul_node = graph.nodes.iter().find(|n| n.object_name == "*~").unwrap();
3442        assert_eq!(mul_node.purity, NodePurity::Pure);
3443    }
3444
3445    #[test]
3446    fn test_built_node_has_hot_inlets() {
3447        let prog = make_l2_program();
3448        let graph = build_graph(&prog).unwrap();
3449
3450        let cycle_node = graph
3451            .nodes
3452            .iter()
3453            .find(|n| n.object_name == "cycle~")
3454            .unwrap();
3455        assert_eq!(cycle_node.hot_inlets, vec![true, false]);
3456
3457        let mul_node = graph.nodes.iter().find(|n| n.object_name == "*~").unwrap();
3458        assert_eq!(mul_node.hot_inlets, vec![true, false]);
3459    }
3460
3461    // ─── E007: InvalidPortIndex tests ───
3462
3463    #[test]
3464    fn test_direct_connection_valid_port() {
3465        // node.in[0] = expr; — valid port index
3466        let prog = Program {
3467            in_decls: vec![],
3468            out_decls: vec![],
3469            wires: vec![
3470                Wire {
3471                    name: "src".to_string(),
3472                    value: Expr::Call {
3473                        object: "button".to_string(),
3474                        args: vec![],
3475                    },
3476                    span: None,
3477                    attrs: vec![],
3478                },
3479                Wire {
3480                    name: "target".to_string(),
3481                    value: Expr::Call {
3482                        object: "+".to_string(),
3483                        args: vec![
3484                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
3485                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
3486                        ],
3487                    },
3488                    span: None,
3489                    attrs: vec![],
3490                },
3491            ],
3492            destructuring_wires: vec![],
3493            msg_decls: vec![],
3494            out_assignments: vec![],
3495            direct_connections: vec![DirectConnection {
3496                target: flutmax_ast::InputPortAccess {
3497                    object: "target".to_string(),
3498                    index: 0,
3499                },
3500                value: Expr::Ref("src".to_string()),
3501            }],
3502            feedback_decls: vec![],
3503            feedback_assignments: vec![],
3504            state_decls: vec![],
3505            state_assignments: vec![],
3506        };
3507
3508        let result = build_graph(&prog);
3509        assert!(result.is_ok(), "valid port index should succeed");
3510    }
3511
3512    #[test]
3513    fn test_direct_connection_invalid_port_index() {
3514        // node.in[99] = expr; — out-of-range port index -> E007
3515        let prog = Program {
3516            in_decls: vec![],
3517            out_decls: vec![],
3518            wires: vec![
3519                Wire {
3520                    name: "src".to_string(),
3521                    value: Expr::Call {
3522                        object: "button".to_string(),
3523                        args: vec![],
3524                    },
3525                    span: None,
3526                    attrs: vec![],
3527                },
3528                Wire {
3529                    name: "target".to_string(),
3530                    value: Expr::Call {
3531                        object: "+".to_string(),
3532                        args: vec![
3533                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
3534                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
3535                        ],
3536                    },
3537                    span: None,
3538                    attrs: vec![],
3539                },
3540            ],
3541            destructuring_wires: vec![],
3542            msg_decls: vec![],
3543            out_assignments: vec![],
3544            direct_connections: vec![DirectConnection {
3545                target: flutmax_ast::InputPortAccess {
3546                    object: "target".to_string(),
3547                    index: 99,
3548                },
3549                value: Expr::Ref("src".to_string()),
3550            }],
3551            feedback_decls: vec![],
3552            feedback_assignments: vec![],
3553            state_decls: vec![],
3554            state_assignments: vec![],
3555        };
3556
3557        // Port index exceeding initial num_inlets auto-extends the node
3558        // (needed for decompiler back-edge direct_connections).
3559        let result = build_graph(&prog);
3560        assert!(result.is_ok());
3561        let graph = result.unwrap();
3562        let target_node = graph
3563            .find_node("target_id_0")
3564            .or_else(|| graph.nodes.iter().find(|n| n.object_name == "+"));
3565        assert!(target_node.is_some());
3566        // The node should now have at least 100 inlets (index 99 + 1)
3567        assert!(target_node.unwrap().num_inlets >= 100);
3568    }
3569
3570    #[test]
3571    fn test_direct_connection_undefined_node() {
3572        // nonexistent.in[0] = expr; — undefined node
3573        let prog = Program {
3574            in_decls: vec![],
3575            out_decls: vec![],
3576            wires: vec![Wire {
3577                name: "src".to_string(),
3578                value: Expr::Call {
3579                    object: "button".to_string(),
3580                    args: vec![],
3581                },
3582                span: None,
3583                attrs: vec![],
3584            }],
3585            destructuring_wires: vec![],
3586            msg_decls: vec![],
3587            out_assignments: vec![],
3588            direct_connections: vec![DirectConnection {
3589                target: flutmax_ast::InputPortAccess {
3590                    object: "nonexistent".to_string(),
3591                    index: 0,
3592                },
3593                value: Expr::Ref("src".to_string()),
3594            }],
3595            feedback_decls: vec![],
3596            feedback_assignments: vec![],
3597            state_decls: vec![],
3598            state_assignments: vec![],
3599        };
3600
3601        let result = build_graph(&prog);
3602        assert!(result.is_err());
3603        match result.unwrap_err() {
3604            BuildError::UndefinedRef(name) => assert_eq!(name, "nonexistent"),
3605            other => panic!("expected UndefinedRef, got: {:?}", other),
3606        }
3607    }
3608
3609    // ─── Typed Pack tests ───
3610
3611    #[test]
3612    fn test_typed_pack_int_literals() {
3613        // wire t = (1, 2, 3); → pack i i i
3614        let prog = Program {
3615            in_decls: vec![],
3616            out_decls: vec![],
3617            wires: vec![Wire {
3618                name: "t".to_string(),
3619                value: Expr::Tuple(vec![
3620                    Expr::Lit(LitValue::Int(1)),
3621                    Expr::Lit(LitValue::Int(2)),
3622                    Expr::Lit(LitValue::Int(3)),
3623                ]),
3624                span: None,
3625                attrs: vec![],
3626            }],
3627            destructuring_wires: vec![],
3628            msg_decls: vec![],
3629            out_assignments: vec![],
3630            direct_connections: vec![],
3631            feedback_decls: vec![],
3632            feedback_assignments: vec![],
3633            state_decls: vec![],
3634            state_assignments: vec![],
3635        };
3636
3637        let graph = build_graph(&prog).unwrap();
3638        let pack_node = graph
3639            .nodes
3640            .iter()
3641            .find(|n| n.object_name == "pack")
3642            .expect("pack node should exist");
3643        assert_eq!(pack_node.args, vec!["i", "i", "i"]);
3644    }
3645
3646    #[test]
3647    fn test_typed_pack_mixed_literals() {
3648        // wire t = (1, 0.5, "x"); → pack i f s
3649        let prog = Program {
3650            in_decls: vec![],
3651            out_decls: vec![],
3652            wires: vec![Wire {
3653                name: "t".to_string(),
3654                value: Expr::Tuple(vec![
3655                    Expr::Lit(LitValue::Int(1)),
3656                    Expr::Lit(LitValue::Float(0.5)),
3657                    Expr::Lit(LitValue::Str("x".to_string())),
3658                ]),
3659                span: None,
3660                attrs: vec![],
3661            }],
3662            destructuring_wires: vec![],
3663            msg_decls: vec![],
3664            out_assignments: vec![],
3665            direct_connections: vec![],
3666            feedback_decls: vec![],
3667            feedback_assignments: vec![],
3668            state_decls: vec![],
3669            state_assignments: vec![],
3670        };
3671
3672        let graph = build_graph(&prog).unwrap();
3673        let pack_node = graph
3674            .nodes
3675            .iter()
3676            .find(|n| n.object_name == "pack")
3677            .expect("pack node should exist");
3678        assert_eq!(pack_node.args, vec!["i", "f", "s"]);
3679    }
3680
3681    #[test]
3682    fn test_typed_pack_ref_fallback() {
3683        // wire t = (x, y); -> pack f f (Ref falls back)
3684        let prog = Program {
3685            in_decls: vec![
3686                InDecl {
3687                    index: 0,
3688                    name: "x".to_string(),
3689                    port_type: PortType::Float,
3690                },
3691                InDecl {
3692                    index: 1,
3693                    name: "y".to_string(),
3694                    port_type: PortType::Float,
3695                },
3696            ],
3697            out_decls: vec![],
3698            wires: vec![Wire {
3699                name: "t".to_string(),
3700                value: Expr::Tuple(vec![Expr::Ref("x".to_string()), Expr::Ref("y".to_string())]),
3701                span: None,
3702                attrs: vec![],
3703            }],
3704            destructuring_wires: vec![],
3705            msg_decls: vec![],
3706            out_assignments: vec![],
3707            direct_connections: vec![],
3708            feedback_decls: vec![],
3709            feedback_assignments: vec![],
3710            state_decls: vec![],
3711            state_assignments: vec![],
3712        };
3713
3714        let graph = build_graph(&prog).unwrap();
3715        let pack_node = graph
3716            .nodes
3717            .iter()
3718            .find(|n| n.object_name == "pack")
3719            .expect("pack node should exist");
3720        assert_eq!(pack_node.args, vec!["f", "f"]);
3721    }
3722
3723    // ─── E020: bare multi-outlet ref tests ───
3724
3725    #[test]
3726    fn test_bare_multi_outlet_ref_ok() {
3727        // wire x = line~(arg0); out[0] = x; -> OK (bare = outlet 0, E020 removed)
3728        let prog = Program {
3729            in_decls: vec![InDecl {
3730                index: 0,
3731                name: "arg0".to_string(),
3732                port_type: PortType::Signal,
3733            }],
3734            out_decls: vec![OutDecl {
3735                index: 0,
3736                name: "out".to_string(),
3737                port_type: PortType::Signal,
3738                value: None,
3739            }],
3740            wires: vec![Wire {
3741                name: "result".to_string(),
3742                value: Expr::Call {
3743                    object: "line~".to_string(),
3744                    args: vec![CallArg::positional(Expr::Ref("arg0".to_string()))],
3745                },
3746                span: None,
3747                attrs: vec![],
3748            }],
3749            destructuring_wires: vec![],
3750            msg_decls: vec![],
3751            out_assignments: vec![OutAssignment {
3752                index: 0,
3753                value: Expr::Ref("result".to_string()),
3754                span: None,
3755            }],
3756            direct_connections: vec![],
3757            feedback_decls: vec![],
3758            feedback_assignments: vec![],
3759            state_decls: vec![],
3760            state_assignments: vec![],
3761        };
3762
3763        let result = build_graph(&prog);
3764        assert!(
3765            result.is_ok(),
3766            "bare reference to multi-outlet node should be OK"
3767        );
3768    }
3769
3770    #[test]
3771    fn test_e020_output_port_access_ok() {
3772        // wire x = line~(arg0); out[0] = x.out[0]; → OK
3773        use flutmax_ast::OutputPortAccess;
3774
3775        let prog = Program {
3776            in_decls: vec![InDecl {
3777                index: 0,
3778                name: "arg0".to_string(),
3779                port_type: PortType::Signal,
3780            }],
3781            out_decls: vec![OutDecl {
3782                index: 0,
3783                name: "out".to_string(),
3784                port_type: PortType::Signal,
3785                value: None,
3786            }],
3787            wires: vec![Wire {
3788                name: "result".to_string(),
3789                value: Expr::Call {
3790                    object: "line~".to_string(),
3791                    args: vec![CallArg::positional(Expr::Ref("arg0".to_string()))],
3792                },
3793                span: None,
3794                attrs: vec![],
3795            }],
3796            destructuring_wires: vec![],
3797            msg_decls: vec![],
3798            out_assignments: vec![OutAssignment {
3799                index: 0,
3800                value: Expr::OutputPortAccess(OutputPortAccess {
3801                    object: "result".to_string(),
3802                    index: 0,
3803                }),
3804                span: None,
3805            }],
3806            direct_connections: vec![],
3807            feedback_decls: vec![],
3808            feedback_assignments: vec![],
3809            state_decls: vec![],
3810            state_assignments: vec![],
3811        };
3812
3813        let result = build_graph(&prog);
3814        assert!(
3815            result.is_ok(),
3816            "OutputPortAccess should bypass E020: {:?}",
3817            result.err()
3818        );
3819    }
3820
3821    #[test]
3822    fn test_e020_destructured_names_exempt() {
3823        // wire (a, b) = unpack(data); out[0] = a; → OK (destructured names exempt from E020)
3824        use flutmax_ast::DestructuringWire;
3825
3826        let prog = Program {
3827            in_decls: vec![InDecl {
3828                index: 0,
3829                name: "data".to_string(),
3830                port_type: PortType::Float,
3831            }],
3832            out_decls: vec![OutDecl {
3833                index: 0,
3834                name: "x".to_string(),
3835                port_type: PortType::Float,
3836                value: None,
3837            }],
3838            wires: vec![],
3839            destructuring_wires: vec![DestructuringWire {
3840                names: vec!["a".to_string(), "b".to_string()],
3841                value: Expr::Call {
3842                    object: "unpack".to_string(),
3843                    args: vec![CallArg::positional(Expr::Ref("data".to_string()))],
3844                },
3845                span: None,
3846            }],
3847            msg_decls: vec![],
3848            out_assignments: vec![OutAssignment {
3849                index: 0,
3850                value: Expr::Ref("a".to_string()),
3851                span: None,
3852            }],
3853            direct_connections: vec![],
3854            feedback_decls: vec![],
3855            feedback_assignments: vec![],
3856            state_decls: vec![],
3857            state_assignments: vec![],
3858        };
3859
3860        let result = build_graph(&prog);
3861        assert!(
3862            result.is_ok(),
3863            "destructured name should not trigger E020: {:?}",
3864            result.err()
3865        );
3866    }
3867
3868    #[test]
3869    fn test_single_outlet_bare_ref_ok() {
3870        // wire x = cycle~(440); out[0] = x; → OK (single outlet)
3871        let prog = Program {
3872            in_decls: vec![],
3873            out_decls: vec![OutDecl {
3874                index: 0,
3875                name: "out".to_string(),
3876                port_type: PortType::Signal,
3877                value: None,
3878            }],
3879            wires: vec![Wire {
3880                name: "osc".to_string(),
3881                value: Expr::Call {
3882                    object: "cycle~".to_string(),
3883                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
3884                },
3885                span: None,
3886                attrs: vec![],
3887            }],
3888            destructuring_wires: vec![],
3889            msg_decls: vec![],
3890            out_assignments: vec![OutAssignment {
3891                index: 0,
3892                value: Expr::Ref("osc".to_string()),
3893                span: None,
3894            }],
3895            direct_connections: vec![],
3896            feedback_decls: vec![],
3897            feedback_assignments: vec![],
3898            state_decls: vec![],
3899            state_assignments: vec![],
3900        };
3901
3902        let result = build_graph(&prog);
3903        assert!(
3904            result.is_ok(),
3905            "single outlet bare ref should be OK: {:?}",
3906            result.err()
3907        );
3908    }
3909
3910    // ─── State tests ───
3911
3912    #[test]
3913    fn test_state_decl_creates_int_node() {
3914        // state counter: int = 0; -> [int 0] node is generated
3915        let prog = Program {
3916            in_decls: vec![],
3917            out_decls: vec![],
3918            wires: vec![],
3919            destructuring_wires: vec![],
3920            msg_decls: vec![],
3921            out_assignments: vec![],
3922            direct_connections: vec![],
3923            feedback_decls: vec![],
3924            feedback_assignments: vec![],
3925            state_decls: vec![StateDecl {
3926                name: "counter".to_string(),
3927                port_type: PortType::Int,
3928                init_value: Expr::Lit(LitValue::Int(0)),
3929                span: None,
3930            }],
3931            state_assignments: vec![],
3932        };
3933
3934        let graph = build_graph(&prog).unwrap();
3935
3936        assert_eq!(graph.nodes.len(), 1);
3937        let node = &graph.nodes[0];
3938        assert_eq!(node.object_name, "int");
3939        assert_eq!(node.args, vec!["0"]);
3940        assert_eq!(node.num_inlets, 2);
3941        assert_eq!(node.num_outlets, 1);
3942        assert!(!node.is_signal);
3943        assert_eq!(node.varname, Some("counter".to_string()));
3944        // inlet 0 hot, inlet 1 cold
3945        assert_eq!(node.hot_inlets, vec![true, false]);
3946    }
3947
3948    #[test]
3949    fn test_state_decl_creates_float_node() {
3950        // state volume: float = 0.5; -> [float 0.5] node is generated
3951        let prog = Program {
3952            in_decls: vec![],
3953            out_decls: vec![],
3954            wires: vec![],
3955            destructuring_wires: vec![],
3956            msg_decls: vec![],
3957            out_assignments: vec![],
3958            direct_connections: vec![],
3959            feedback_decls: vec![],
3960            feedback_assignments: vec![],
3961            state_decls: vec![StateDecl {
3962                name: "volume".to_string(),
3963                port_type: PortType::Float,
3964                init_value: Expr::Lit(LitValue::Float(0.5)),
3965                span: None,
3966            }],
3967            state_assignments: vec![],
3968        };
3969
3970        let graph = build_graph(&prog).unwrap();
3971
3972        assert_eq!(graph.nodes.len(), 1);
3973        let node = &graph.nodes[0];
3974        assert_eq!(node.object_name, "float");
3975        assert_eq!(node.args, vec!["0.5"]);
3976        assert_eq!(node.varname, Some("volume".to_string()));
3977    }
3978
3979    #[test]
3980    fn test_state_assignment_connects_to_cold_inlet() {
3981        // state counter: int = 0;
3982        // wire next = add(counter, 1);
3983        // state counter = next;
3984        // -> next -> int inlet 1 (cold)
3985        let prog = Program {
3986            in_decls: vec![],
3987            out_decls: vec![],
3988            wires: vec![Wire {
3989                name: "next".to_string(),
3990                value: Expr::Call {
3991                    object: "add".to_string(),
3992                    args: vec![
3993                        CallArg::positional(Expr::Ref("counter".to_string())),
3994                        CallArg::positional(Expr::Lit(LitValue::Int(1))),
3995                    ],
3996                },
3997                span: None,
3998                attrs: vec![],
3999            }],
4000            destructuring_wires: vec![],
4001            msg_decls: vec![],
4002            out_assignments: vec![],
4003            direct_connections: vec![],
4004            feedback_decls: vec![],
4005            feedback_assignments: vec![],
4006            state_decls: vec![StateDecl {
4007                name: "counter".to_string(),
4008                port_type: PortType::Int,
4009                init_value: Expr::Lit(LitValue::Int(0)),
4010                span: None,
4011            }],
4012            state_assignments: vec![StateAssignment {
4013                name: "counter".to_string(),
4014                value: Expr::Ref("next".to_string()),
4015                span: None,
4016            }],
4017        };
4018
4019        let graph = build_graph(&prog).unwrap();
4020
4021        // int node (state)
4022        let int_node = graph
4023            .nodes
4024            .iter()
4025            .find(|n| n.object_name == "int")
4026            .expect("int node should exist");
4027
4028        // add node (next)
4029        let add_node = graph
4030            .nodes
4031            .iter()
4032            .find(|n| n.object_name == "+")
4033            .expect("add node should exist");
4034
4035        // Edge add -> int inlet 1 exists
4036        let edge = graph
4037            .edges
4038            .iter()
4039            .find(|e| e.source_id == add_node.id && e.dest_id == int_node.id)
4040            .expect("edge from add to int should exist");
4041        assert_eq!(
4042            edge.dest_inlet, 1,
4043            "state assignment should connect to cold inlet (1)"
4044        );
4045    }
4046
4047    #[test]
4048    fn test_state_ref_in_wire_expression() {
4049        // state counter: int = 0;
4050        // wire next = add(counter, 1);
4051        // -> counter reference creates edge from int node outlet 0 -> add inlet 0
4052        let prog = Program {
4053            in_decls: vec![],
4054            out_decls: vec![],
4055            wires: vec![Wire {
4056                name: "next".to_string(),
4057                value: Expr::Call {
4058                    object: "add".to_string(),
4059                    args: vec![
4060                        CallArg::positional(Expr::Ref("counter".to_string())),
4061                        CallArg::positional(Expr::Lit(LitValue::Int(1))),
4062                    ],
4063                },
4064                span: None,
4065                attrs: vec![],
4066            }],
4067            destructuring_wires: vec![],
4068            msg_decls: vec![],
4069            out_assignments: vec![],
4070            direct_connections: vec![],
4071            feedback_decls: vec![],
4072            feedback_assignments: vec![],
4073            state_decls: vec![StateDecl {
4074                name: "counter".to_string(),
4075                port_type: PortType::Int,
4076                init_value: Expr::Lit(LitValue::Int(0)),
4077                span: None,
4078            }],
4079            state_assignments: vec![],
4080        };
4081
4082        let graph = build_graph(&prog).unwrap();
4083
4084        let int_node = graph
4085            .nodes
4086            .iter()
4087            .find(|n| n.object_name == "int")
4088            .expect("int node should exist");
4089        let add_node = graph
4090            .nodes
4091            .iter()
4092            .find(|n| n.object_name == "+")
4093            .expect("add node should exist");
4094
4095        // Edge int -> add inlet 0 exists
4096        let edge = graph
4097            .edges
4098            .iter()
4099            .find(|e| e.source_id == int_node.id && e.dest_id == add_node.id)
4100            .expect("edge from int to add should exist");
4101        assert_eq!(edge.source_outlet, 0);
4102        assert_eq!(edge.dest_inlet, 0);
4103    }
4104
4105    #[test]
4106    fn test_e019_duplicate_state_assignment() {
4107        // state counter: int = 0;
4108        // state counter = a;
4109        // state counter = b;  → E019
4110        let prog = Program {
4111            in_decls: vec![],
4112            out_decls: vec![],
4113            wires: vec![
4114                Wire {
4115                    name: "a".to_string(),
4116                    value: Expr::Call {
4117                        object: "button".to_string(),
4118                        args: vec![],
4119                    },
4120                    span: None,
4121                    attrs: vec![],
4122                },
4123                Wire {
4124                    name: "b".to_string(),
4125                    value: Expr::Call {
4126                        object: "button".to_string(),
4127                        args: vec![],
4128                    },
4129                    span: None,
4130                    attrs: vec![],
4131                },
4132            ],
4133            destructuring_wires: vec![],
4134            msg_decls: vec![],
4135            out_assignments: vec![],
4136            direct_connections: vec![],
4137            feedback_decls: vec![],
4138            feedback_assignments: vec![],
4139            state_decls: vec![StateDecl {
4140                name: "counter".to_string(),
4141                port_type: PortType::Int,
4142                init_value: Expr::Lit(LitValue::Int(0)),
4143                span: None,
4144            }],
4145            state_assignments: vec![
4146                StateAssignment {
4147                    name: "counter".to_string(),
4148                    value: Expr::Ref("a".to_string()),
4149                    span: None,
4150                },
4151                StateAssignment {
4152                    name: "counter".to_string(),
4153                    value: Expr::Ref("b".to_string()),
4154                    span: None,
4155                },
4156            ],
4157        };
4158
4159        let result = build_graph(&prog);
4160        assert!(result.is_err());
4161        match result.unwrap_err() {
4162            BuildError::DuplicateStateAssignment(name) => assert_eq!(name, "counter"),
4163            other => panic!("expected DuplicateStateAssignment, got {:?}", other),
4164        }
4165    }
4166
4167    #[test]
4168    fn test_state_single_assignment_no_error() {
4169        // Single state assignment does not error
4170        let prog = Program {
4171            in_decls: vec![],
4172            out_decls: vec![],
4173            wires: vec![Wire {
4174                name: "val".to_string(),
4175                value: Expr::Call {
4176                    object: "button".to_string(),
4177                    args: vec![],
4178                },
4179                span: None,
4180                attrs: vec![],
4181            }],
4182            destructuring_wires: vec![],
4183            msg_decls: vec![],
4184            out_assignments: vec![],
4185            direct_connections: vec![],
4186            feedback_decls: vec![],
4187            feedback_assignments: vec![],
4188            state_decls: vec![StateDecl {
4189                name: "counter".to_string(),
4190                port_type: PortType::Int,
4191                init_value: Expr::Lit(LitValue::Int(0)),
4192                span: None,
4193            }],
4194            state_assignments: vec![StateAssignment {
4195                name: "counter".to_string(),
4196                value: Expr::Ref("val".to_string()),
4197                span: None,
4198            }],
4199        };
4200
4201        let result = build_graph(&prog);
4202        assert!(result.is_ok());
4203    }
4204
4205    // ─── E20: Typed Destructuring (unpack subtype propagation) tests ───
4206
4207    #[test]
4208    fn test_typed_unpack_from_int_tuple() {
4209        // wire t = (1, 2, 3); wire (a, b, c) = t;
4210        // → auto-inserted unpack should have args ["i", "i", "i"]
4211        use flutmax_ast::DestructuringWire;
4212
4213        let prog = Program {
4214            in_decls: vec![],
4215            out_decls: vec![],
4216            wires: vec![Wire {
4217                name: "t".to_string(),
4218                value: Expr::Tuple(vec![
4219                    Expr::Lit(LitValue::Int(1)),
4220                    Expr::Lit(LitValue::Int(2)),
4221                    Expr::Lit(LitValue::Int(3)),
4222                ]),
4223                span: None,
4224                attrs: vec![],
4225            }],
4226            destructuring_wires: vec![DestructuringWire {
4227                names: vec!["a".to_string(), "b".to_string(), "c".to_string()],
4228                value: Expr::Ref("t".to_string()),
4229                span: None,
4230            }],
4231            msg_decls: vec![],
4232            out_assignments: vec![],
4233            direct_connections: vec![],
4234            feedback_decls: vec![],
4235            feedback_assignments: vec![],
4236            state_decls: vec![],
4237            state_assignments: vec![],
4238        };
4239
4240        let graph = build_graph(&prog).unwrap();
4241        let unpack_node = graph
4242            .nodes
4243            .iter()
4244            .find(|n| n.object_name == "unpack")
4245            .expect("unpack node should be auto-inserted");
4246        assert_eq!(unpack_node.args, vec!["i", "i", "i"]);
4247    }
4248
4249    #[test]
4250    fn test_typed_unpack_from_mixed_tuple() {
4251        // wire t = (1, 0.5, "x"); wire (a, b, c) = t;
4252        // → auto-inserted unpack should have args ["i", "f", "s"]
4253        use flutmax_ast::DestructuringWire;
4254
4255        let prog = Program {
4256            in_decls: vec![],
4257            out_decls: vec![],
4258            wires: vec![Wire {
4259                name: "t".to_string(),
4260                value: Expr::Tuple(vec![
4261                    Expr::Lit(LitValue::Int(1)),
4262                    Expr::Lit(LitValue::Float(0.5)),
4263                    Expr::Lit(LitValue::Str("x".to_string())),
4264                ]),
4265                span: None,
4266                attrs: vec![],
4267            }],
4268            destructuring_wires: vec![DestructuringWire {
4269                names: vec!["a".to_string(), "b".to_string(), "c".to_string()],
4270                value: Expr::Ref("t".to_string()),
4271                span: None,
4272            }],
4273            msg_decls: vec![],
4274            out_assignments: vec![],
4275            direct_connections: vec![],
4276            feedback_decls: vec![],
4277            feedback_assignments: vec![],
4278            state_decls: vec![],
4279            state_assignments: vec![],
4280        };
4281
4282        let graph = build_graph(&prog).unwrap();
4283        let unpack_node = graph
4284            .nodes
4285            .iter()
4286            .find(|n| n.object_name == "unpack")
4287            .expect("unpack node should be auto-inserted");
4288        assert_eq!(unpack_node.args, vec!["i", "f", "s"]);
4289    }
4290
4291    #[test]
4292    fn test_typed_unpack_unknown_source_fallback() {
4293        // wire (a, b) = unpack(data); — data is an inlet (not tuple)
4294        // → unpack should have default args ["f", "f"]
4295        use flutmax_ast::DestructuringWire;
4296
4297        let prog = Program {
4298            in_decls: vec![InDecl {
4299                index: 0,
4300                name: "data".to_string(),
4301                port_type: PortType::Float,
4302            }],
4303            out_decls: vec![],
4304            wires: vec![],
4305            destructuring_wires: vec![DestructuringWire {
4306                names: vec!["a".to_string(), "b".to_string()],
4307                value: Expr::Call {
4308                    object: "unpack".to_string(),
4309                    args: vec![CallArg::positional(Expr::Ref("data".to_string()))],
4310                },
4311                span: None,
4312            }],
4313            msg_decls: vec![],
4314            out_assignments: vec![],
4315            direct_connections: vec![],
4316            feedback_decls: vec![],
4317            feedback_assignments: vec![],
4318            state_decls: vec![],
4319            state_assignments: vec![],
4320        };
4321
4322        let graph = build_graph(&prog).unwrap();
4323        let unpack_nodes: Vec<_> = graph
4324            .nodes
4325            .iter()
4326            .filter(|n| n.object_name == "unpack")
4327            .collect();
4328        assert_eq!(unpack_nodes.len(), 1);
4329        // Explicit unpack call: the resolve_expr creates it with default num_outlets=2
4330        // which matches names count, so no auto-inserted unpack needed.
4331        // The Call-generated unpack has its own arg handling.
4332    }
4333
4334    #[test]
4335    fn test_typed_unpack_ref_to_tuple_with_refs() {
4336        // wire t = (x, y); wire (a, b) = t;
4337        // → Ref elements fall back to "f", so unpack args = ["f", "f"]
4338        use flutmax_ast::DestructuringWire;
4339
4340        let prog = Program {
4341            in_decls: vec![
4342                InDecl {
4343                    index: 0,
4344                    name: "x".to_string(),
4345                    port_type: PortType::Float,
4346                },
4347                InDecl {
4348                    index: 1,
4349                    name: "y".to_string(),
4350                    port_type: PortType::Float,
4351                },
4352            ],
4353            out_decls: vec![],
4354            wires: vec![Wire {
4355                name: "t".to_string(),
4356                value: Expr::Tuple(vec![Expr::Ref("x".to_string()), Expr::Ref("y".to_string())]),
4357                span: None,
4358                attrs: vec![],
4359            }],
4360            destructuring_wires: vec![DestructuringWire {
4361                names: vec!["a".to_string(), "b".to_string()],
4362                value: Expr::Ref("t".to_string()),
4363                span: None,
4364            }],
4365            msg_decls: vec![],
4366            out_assignments: vec![],
4367            direct_connections: vec![],
4368            feedback_decls: vec![],
4369            feedback_assignments: vec![],
4370            state_decls: vec![],
4371            state_assignments: vec![],
4372        };
4373
4374        let graph = build_graph(&prog).unwrap();
4375        let unpack_node = graph
4376            .nodes
4377            .iter()
4378            .find(|n| n.object_name == "unpack")
4379            .expect("unpack node should be auto-inserted");
4380        assert_eq!(unpack_node.args, vec!["f", "f"]);
4381    }
4382
4383    // ========================================
4384    // W001: Duplicate connection to same inlet warning
4385    // ========================================
4386
4387    #[test]
4388    fn test_w001_duplicate_inlet_detected() {
4389        // 2 connections to target.in[0] -> W001 warning
4390        let prog = Program {
4391            in_decls: vec![],
4392            out_decls: vec![],
4393            wires: vec![
4394                Wire {
4395                    name: "a".to_string(),
4396                    value: Expr::Call {
4397                        object: "button".to_string(),
4398                        args: vec![],
4399                    },
4400                    span: None,
4401                    attrs: vec![],
4402                },
4403                Wire {
4404                    name: "b".to_string(),
4405                    value: Expr::Call {
4406                        object: "button".to_string(),
4407                        args: vec![],
4408                    },
4409                    span: None,
4410                    attrs: vec![],
4411                },
4412                Wire {
4413                    name: "target".to_string(),
4414                    value: Expr::Call {
4415                        object: "+".to_string(),
4416                        args: vec![
4417                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
4418                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
4419                        ],
4420                    },
4421                    span: None,
4422                    attrs: vec![],
4423                },
4424            ],
4425            destructuring_wires: vec![],
4426            msg_decls: vec![],
4427            out_assignments: vec![],
4428            direct_connections: vec![
4429                DirectConnection {
4430                    target: flutmax_ast::InputPortAccess {
4431                        object: "target".to_string(),
4432                        index: 0,
4433                    },
4434                    value: Expr::Ref("a".to_string()),
4435                },
4436                DirectConnection {
4437                    target: flutmax_ast::InputPortAccess {
4438                        object: "target".to_string(),
4439                        index: 0,
4440                    },
4441                    value: Expr::Ref("b".to_string()),
4442                },
4443            ],
4444            feedback_decls: vec![],
4445            feedback_assignments: vec![],
4446            state_decls: vec![],
4447            state_assignments: vec![],
4448        };
4449
4450        let result = build_graph_with_warnings(&prog).unwrap();
4451        assert_eq!(result.warnings.len(), 1);
4452        match &result.warnings[0] {
4453            BuildWarning::DuplicateInletConnection {
4454                node_id: _,
4455                inlet,
4456                count,
4457            } => {
4458                assert_eq!(*inlet, 0);
4459                assert_eq!(*count, 2);
4460            }
4461        }
4462    }
4463
4464    #[test]
4465    fn test_w001_no_warning_single_connection() {
4466        // 1 connection per inlet -> no warning
4467        let prog = Program {
4468            in_decls: vec![],
4469            out_decls: vec![],
4470            wires: vec![
4471                Wire {
4472                    name: "a".to_string(),
4473                    value: Expr::Call {
4474                        object: "button".to_string(),
4475                        args: vec![],
4476                    },
4477                    span: None,
4478                    attrs: vec![],
4479                },
4480                Wire {
4481                    name: "target".to_string(),
4482                    value: Expr::Call {
4483                        object: "+".to_string(),
4484                        args: vec![
4485                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
4486                            CallArg::positional(Expr::Lit(LitValue::Int(0))),
4487                        ],
4488                    },
4489                    span: None,
4490                    attrs: vec![],
4491                },
4492            ],
4493            destructuring_wires: vec![],
4494            msg_decls: vec![],
4495            out_assignments: vec![],
4496            direct_connections: vec![DirectConnection {
4497                target: flutmax_ast::InputPortAccess {
4498                    object: "target".to_string(),
4499                    index: 1,
4500                },
4501                value: Expr::Ref("a".to_string()),
4502            }],
4503            feedback_decls: vec![],
4504            feedback_assignments: vec![],
4505            state_decls: vec![],
4506            state_assignments: vec![],
4507        };
4508
4509        let result = build_graph_with_warnings(&prog).unwrap();
4510        assert!(
4511            result.warnings.is_empty(),
4512            "single connections should not trigger W001"
4513        );
4514    }
4515
4516    #[test]
4517    fn test_w001_display_format() {
4518        let warning = BuildWarning::DuplicateInletConnection {
4519            node_id: "obj-3".to_string(),
4520            inlet: 0,
4521            count: 2,
4522        };
4523        assert_eq!(format!("{}", warning), "W001: 2 connections to obj-3.in[0]");
4524    }
4525
4526    // ─── msg declaration tests ───
4527
4528    #[test]
4529    fn test_msg_creates_message_node() {
4530        let prog = Program {
4531            in_decls: vec![],
4532            out_decls: vec![OutDecl {
4533                index: 0,
4534                name: "output".to_string(),
4535                port_type: PortType::Bang,
4536                value: None,
4537            }],
4538            wires: vec![],
4539            destructuring_wires: vec![],
4540            msg_decls: vec![MsgDecl {
4541                name: "click".to_string(),
4542                content: "bang".to_string(),
4543                span: None,
4544                attrs: vec![],
4545            }],
4546            out_assignments: vec![OutAssignment {
4547                index: 0,
4548                value: Expr::Ref("click".to_string()),
4549                span: None,
4550            }],
4551            direct_connections: vec![],
4552            feedback_decls: vec![],
4553            feedback_assignments: vec![],
4554            state_decls: vec![],
4555            state_assignments: vec![],
4556        };
4557
4558        let graph = build_graph(&prog).unwrap();
4559
4560        // Find the message node
4561        let msg_node = graph
4562            .nodes
4563            .iter()
4564            .find(|n| n.object_name == "message")
4565            .expect("should have a message node");
4566
4567        assert_eq!(msg_node.args, vec!["bang"]);
4568        assert_eq!(msg_node.num_inlets, 2);
4569        assert_eq!(msg_node.num_outlets, 1);
4570        assert!(!msg_node.is_signal);
4571        assert_eq!(msg_node.varname, Some("click".to_string()));
4572    }
4573
4574    #[test]
4575    fn test_msg_connectable_as_source() {
4576        let prog = Program {
4577            in_decls: vec![],
4578            out_decls: vec![],
4579            wires: vec![Wire {
4580                name: "printer".to_string(),
4581                value: Expr::Call {
4582                    object: "print".to_string(),
4583                    args: vec![CallArg::positional(Expr::Ref("click".to_string()))],
4584                },
4585                span: None,
4586                attrs: vec![],
4587            }],
4588            destructuring_wires: vec![],
4589            msg_decls: vec![MsgDecl {
4590                name: "click".to_string(),
4591                content: "bang".to_string(),
4592                span: None,
4593                attrs: vec![],
4594            }],
4595            out_assignments: vec![],
4596            direct_connections: vec![],
4597            feedback_decls: vec![],
4598            feedback_assignments: vec![],
4599            state_decls: vec![],
4600            state_assignments: vec![],
4601        };
4602
4603        let graph = build_graph(&prog).unwrap();
4604
4605        // Should have edge from message node to print node
4606        assert!(!graph.edges.is_empty(), "should have at least one edge");
4607        let msg_node = graph
4608            .nodes
4609            .iter()
4610            .find(|n| n.object_name == "message")
4611            .expect("message node");
4612        let print_node = graph
4613            .nodes
4614            .iter()
4615            .find(|n| n.object_name == "print")
4616            .expect("print node");
4617
4618        let edge = graph
4619            .edges
4620            .iter()
4621            .find(|e| e.source_id == msg_node.id && e.dest_id == print_node.id)
4622            .expect("edge from message to print");
4623        assert_eq!(edge.source_outlet, 0);
4624        assert_eq!(edge.dest_inlet, 0);
4625    }
4626
4627    // ─── Dotted identifier tests ───
4628
4629    #[test]
4630    fn test_dotted_object_name_in_call() {
4631        let prog = Program {
4632            in_decls: vec![],
4633            out_decls: vec![OutDecl {
4634                index: 0,
4635                name: "output".to_string(),
4636                port_type: PortType::Float,
4637                value: None,
4638            }],
4639            wires: vec![Wire {
4640                name: "dial".to_string(),
4641                value: Expr::Call {
4642                    object: "live.dial".to_string(),
4643                    args: vec![CallArg::positional(Expr::Lit(LitValue::Float(0.5)))],
4644                },
4645                span: None,
4646                attrs: vec![],
4647            }],
4648            destructuring_wires: vec![],
4649            msg_decls: vec![],
4650            out_assignments: vec![OutAssignment {
4651                index: 0,
4652                value: Expr::OutputPortAccess(OutputPortAccess {
4653                    object: "dial".to_string(),
4654                    index: 0,
4655                }),
4656                span: None,
4657            }],
4658            direct_connections: vec![],
4659            feedback_decls: vec![],
4660            feedback_assignments: vec![],
4661            state_decls: vec![],
4662            state_assignments: vec![],
4663        };
4664
4665        let graph = build_graph(&prog).unwrap();
4666
4667        // Should have a live.dial node
4668        let dial_node = graph
4669            .nodes
4670            .iter()
4671            .find(|n| n.object_name == "live.dial")
4672            .expect("should have a live.dial node");
4673        assert_eq!(dial_node.args, vec!["0.5"]);
4674    }
4675
4676    // ================================================
4677    // .attr() chain builder tests
4678    // ================================================
4679
4680    #[test]
4681    fn test_wire_attrs_propagated_to_node() {
4682        use flutmax_ast::AttrPair;
4683
4684        let prog = Program {
4685            in_decls: vec![],
4686            out_decls: vec![],
4687            wires: vec![Wire {
4688                name: "w".to_string(),
4689                value: Expr::Call {
4690                    object: "flonum".to_string(),
4691                    args: vec![],
4692                },
4693                span: None,
4694                attrs: vec![
4695                    AttrPair {
4696                        key: "minimum".to_string(),
4697                        value: flutmax_ast::AttrValue::Float(0.0),
4698                    },
4699                    AttrPair {
4700                        key: "maximum".to_string(),
4701                        value: flutmax_ast::AttrValue::Float(100.0),
4702                    },
4703                ],
4704            }],
4705            destructuring_wires: vec![],
4706            msg_decls: vec![],
4707            out_assignments: vec![],
4708            direct_connections: vec![],
4709            feedback_decls: vec![],
4710            feedback_assignments: vec![],
4711            state_decls: vec![],
4712            state_assignments: vec![],
4713        };
4714
4715        let graph = build_graph(&prog).unwrap();
4716
4717        let fnum = graph
4718            .nodes
4719            .iter()
4720            .find(|n| n.object_name == "flonum")
4721            .expect("should have a flonum node");
4722
4723        assert_eq!(fnum.attrs.len(), 2);
4724        assert_eq!(fnum.attrs[0], ("minimum".to_string(), "0.".to_string()));
4725        assert_eq!(fnum.attrs[1], ("maximum".to_string(), "100.".to_string()));
4726    }
4727
4728    #[test]
4729    fn test_msg_attrs_propagated_to_node() {
4730        use flutmax_ast::AttrPair;
4731
4732        let prog = Program {
4733            in_decls: vec![],
4734            out_decls: vec![],
4735            wires: vec![],
4736            destructuring_wires: vec![],
4737            msg_decls: vec![MsgDecl {
4738                name: "click".to_string(),
4739                content: "bang".to_string(),
4740                span: None,
4741                attrs: vec![AttrPair {
4742                    key: "patching_rect".to_string(),
4743                    value: flutmax_ast::AttrValue::Float(100.0),
4744                }],
4745            }],
4746            out_assignments: vec![],
4747            direct_connections: vec![],
4748            feedback_decls: vec![],
4749            feedback_assignments: vec![],
4750            state_decls: vec![],
4751            state_assignments: vec![],
4752        };
4753
4754        let graph = build_graph(&prog).unwrap();
4755
4756        let msg = graph
4757            .nodes
4758            .iter()
4759            .find(|n| n.object_name == "message")
4760            .expect("should have a message node");
4761
4762        assert_eq!(msg.attrs.len(), 1);
4763        assert_eq!(
4764            msg.attrs[0],
4765            ("patching_rect".to_string(), "100.".to_string())
4766        );
4767    }
4768
4769    #[test]
4770    fn test_wire_no_attrs_empty() {
4771        let prog = Program {
4772            in_decls: vec![],
4773            out_decls: vec![],
4774            wires: vec![Wire {
4775                name: "osc".to_string(),
4776                value: Expr::Call {
4777                    object: "cycle~".to_string(),
4778                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
4779                },
4780                span: None,
4781                attrs: vec![],
4782            }],
4783            destructuring_wires: vec![],
4784            msg_decls: vec![],
4785            out_assignments: vec![],
4786            direct_connections: vec![],
4787            feedback_decls: vec![],
4788            feedback_assignments: vec![],
4789            state_decls: vec![],
4790            state_assignments: vec![],
4791        };
4792
4793        let graph = build_graph(&prog).unwrap();
4794
4795        let osc = graph
4796            .nodes
4797            .iter()
4798            .find(|n| n.object_name == "cycle~")
4799            .expect("should have a cycle~ node");
4800
4801        assert!(osc.attrs.is_empty());
4802    }
4803
4804    // ================================================
4805    // infer_num_outlets / infer_num_inlets unit tests
4806    // ================================================
4807
4808    #[test]
4809    fn test_infer_outlets_select_single_arg() {
4810        // select 0 → 2 outlets (1 match + 1 unmatched)
4811        assert_eq!(infer_num_outlets("select", &["0".to_string()], None), 2);
4812    }
4813
4814    #[test]
4815    fn test_infer_outlets_select_multiple_args() {
4816        // select 1 2 3 → 4 outlets (3 matches + 1 unmatched)
4817        assert_eq!(
4818            infer_num_outlets(
4819                "select",
4820                &["1".to_string(), "2".to_string(), "3".to_string()],
4821                None
4822            ),
4823            4
4824        );
4825    }
4826
4827    #[test]
4828    fn test_infer_outlets_sel_alias() {
4829        // sel 0 → 2 outlets (same as select)
4830        assert_eq!(infer_num_outlets("sel", &["0".to_string()], None), 2);
4831    }
4832
4833    #[test]
4834    fn test_infer_outlets_select_no_args() {
4835        // select with no args → default 2
4836        assert_eq!(infer_num_outlets("select", &[], None), 2);
4837    }
4838
4839    #[test]
4840    fn test_infer_outlets_trigger_two_args() {
4841        // trigger b f → 2 outlets
4842        assert_eq!(
4843            infer_num_outlets("trigger", &["b".to_string(), "f".to_string()], None),
4844            2
4845        );
4846    }
4847
4848    #[test]
4849    fn test_infer_outlets_trigger_alias() {
4850        // t b i f → 3 outlets
4851        assert_eq!(
4852            infer_num_outlets(
4853                "t",
4854                &["b".to_string(), "i".to_string(), "f".to_string()],
4855                None
4856            ),
4857            3
4858        );
4859    }
4860
4861    #[test]
4862    fn test_infer_outlets_function() {
4863        // function → 2 outlets (list output + bang)
4864        assert_eq!(infer_num_outlets("function", &[], None), 2);
4865    }
4866
4867    #[test]
4868    fn test_infer_outlets_route() {
4869        // route a b c → 4 outlets (3 matches + 1 unmatched)
4870        assert_eq!(
4871            infer_num_outlets(
4872                "route",
4873                &["a".to_string(), "b".to_string(), "c".to_string()],
4874                None
4875            ),
4876            4
4877        );
4878    }
4879
4880    #[test]
4881    fn test_infer_outlets_gate() {
4882        // gate 3 → 3 outlets
4883        assert_eq!(infer_num_outlets("gate", &["3".to_string()], None), 3);
4884    }
4885
4886    #[test]
4887    fn test_infer_outlets_gate_default() {
4888        // gate with no args → 2
4889        assert_eq!(infer_num_outlets("gate", &[], None), 2);
4890    }
4891
4892    #[test]
4893    fn test_infer_outlets_unpack_with_args() {
4894        // unpack f f f → 3 outlets
4895        assert_eq!(
4896            infer_num_outlets(
4897                "unpack",
4898                &["f".to_string(), "f".to_string(), "f".to_string()],
4899                None
4900            ),
4901            3
4902        );
4903    }
4904
4905    #[test]
4906    fn test_infer_outlets_unpack_no_args() {
4907        // unpack with no args → default 2
4908        assert_eq!(infer_num_outlets("unpack", &[], None), 2);
4909    }
4910
4911    #[test]
4912    fn test_infer_outlets_pack() {
4913        // pack always → 1 outlet
4914        assert_eq!(
4915            infer_num_outlets("pack", &["0".to_string(), "0".to_string()], None),
4916            1
4917        );
4918    }
4919
4920    #[test]
4921    fn test_infer_outlets_fixed_objects() {
4922        // Verify expanded fixed-outlet table
4923        assert_eq!(infer_num_outlets("line", &[], None), 2);
4924        assert_eq!(infer_num_outlets("makenote", &[], None), 2);
4925        assert_eq!(infer_num_outlets("borax", &[], None), 8);
4926        assert_eq!(infer_num_outlets("counter", &[], None), 4);
4927        assert_eq!(infer_num_outlets("notein", &[], None), 3);
4928        assert_eq!(infer_num_outlets("noteout", &[], None), 0);
4929        assert_eq!(infer_num_outlets("ctlin", &[], None), 3);
4930        assert_eq!(infer_num_outlets("ctlout", &[], None), 0);
4931        assert_eq!(infer_num_outlets("midiin", &[], None), 1);
4932        assert_eq!(infer_num_outlets("midiout", &[], None), 0);
4933        assert_eq!(infer_num_outlets("coll", &[], None), 4);
4934        assert_eq!(infer_num_outlets("urn", &[], None), 2);
4935        assert_eq!(infer_num_outlets("drunk", &[], None), 1);
4936        assert_eq!(infer_num_outlets("random", &[], None), 1);
4937        assert_eq!(infer_num_outlets("match", &[], None), 2);
4938        assert_eq!(infer_num_outlets("zl", &[], None), 2);
4939        assert_eq!(infer_num_outlets("regexp", &[], None), 5);
4940        assert_eq!(infer_num_outlets("sprintf", &[], None), 1);
4941        assert_eq!(infer_num_outlets("thresh", &[], None), 2);
4942        assert_eq!(infer_num_outlets("metro", &[], None), 1);
4943        assert_eq!(infer_num_outlets("delay", &[], None), 1);
4944        assert_eq!(infer_num_outlets("speedlim", &[], None), 1);
4945    }
4946
4947    #[test]
4948    fn test_infer_outlets_signal_objects() {
4949        assert_eq!(infer_num_outlets("dspstate~", &[], None), 4);
4950        assert_eq!(infer_num_outlets("edge~", &[], None), 2);
4951        assert_eq!(infer_num_outlets("fftinfo~", &[], None), 4);
4952        assert_eq!(infer_num_outlets("fftin~", &[], None), 3);
4953        assert_eq!(infer_num_outlets("fftout~", &[], None), 1);
4954        assert_eq!(infer_num_outlets("cartopol~", &[], None), 2);
4955        assert_eq!(infer_num_outlets("poltocar~", &[], None), 2);
4956        assert_eq!(infer_num_outlets("freqshift~", &[], None), 2);
4957        assert_eq!(infer_num_outlets("curve~", &[], None), 2);
4958        assert_eq!(infer_num_outlets("adsr~", &[], None), 4);
4959        assert_eq!(infer_num_outlets("filtercoeff~", &[], None), 5);
4960        assert_eq!(infer_num_outlets("filtergraph~", &[], None), 7);
4961        assert_eq!(infer_num_outlets("noise~", &[], None), 1);
4962        assert_eq!(infer_num_outlets("phasor~", &[], None), 1);
4963        assert_eq!(infer_num_outlets("snapshot~", &[], None), 1);
4964        assert_eq!(infer_num_outlets("peakamp~", &[], None), 1);
4965        assert_eq!(infer_num_outlets("meter~", &[], None), 1);
4966    }
4967
4968    #[test]
4969    fn test_infer_inlets_expanded() {
4970        // Verify expanded inlet table
4971        assert_eq!(infer_num_inlets("function", &[], None), 2);
4972        assert_eq!(infer_num_inlets("counter", &[], None), 3);
4973        assert_eq!(infer_num_inlets("makenote", &[], None), 3);
4974        assert_eq!(infer_num_inlets("line", &[], None), 2);
4975        assert_eq!(infer_num_inlets("metro", &[], None), 2);
4976        assert_eq!(infer_num_inlets("delay", &[], None), 2);
4977        assert_eq!(infer_num_inlets("coll", &[], None), 1);
4978        assert_eq!(infer_num_inlets("urn", &[], None), 2);
4979        assert_eq!(infer_num_inlets("drunk", &[], None), 2);
4980        assert_eq!(infer_num_inlets("random", &[], None), 2);
4981    }
4982
4983    /// Integration test: select with literal arg produces correct outlet count in graph
4984    #[test]
4985    fn test_graph_select_outlet_count() {
4986        let prog = Program {
4987            in_decls: vec![],
4988            out_decls: vec![],
4989            wires: vec![Wire {
4990                name: "s".to_string(),
4991                value: Expr::Call {
4992                    object: "select".to_string(),
4993                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(0)))],
4994                },
4995                span: None,
4996                attrs: vec![],
4997            }],
4998            destructuring_wires: vec![],
4999            msg_decls: vec![],
5000            out_assignments: vec![],
5001            direct_connections: vec![],
5002            feedback_decls: vec![],
5003            feedback_assignments: vec![],
5004            state_decls: vec![],
5005            state_assignments: vec![],
5006        };
5007
5008        let graph = build_graph(&prog).unwrap();
5009        let sel = graph
5010            .nodes
5011            .iter()
5012            .find(|n| n.object_name == "select")
5013            .expect("should have a select node");
5014
5015        // select 0 → 2 outlets
5016        assert_eq!(sel.num_outlets, 2);
5017    }
5018
5019    /// Integration test: function produces correct outlet count in graph
5020    #[test]
5021    fn test_graph_function_outlet_count() {
5022        let prog = Program {
5023            in_decls: vec![],
5024            out_decls: vec![],
5025            wires: vec![Wire {
5026                name: "f".to_string(),
5027                value: Expr::Call {
5028                    object: "function".to_string(),
5029                    args: vec![],
5030                },
5031                span: None,
5032                attrs: vec![],
5033            }],
5034            destructuring_wires: vec![],
5035            msg_decls: vec![],
5036            out_assignments: vec![],
5037            direct_connections: vec![],
5038            feedback_decls: vec![],
5039            feedback_assignments: vec![],
5040            state_decls: vec![],
5041            state_assignments: vec![],
5042        };
5043
5044        let graph = build_graph(&prog).unwrap();
5045        let func = graph
5046            .nodes
5047            .iter()
5048            .find(|n| n.object_name == "function")
5049            .expect("should have a function node");
5050
5051        assert_eq!(func.num_outlets, 2);
5052    }
5053
5054    /// Integration test: trigger with multiple args
5055    #[test]
5056    fn test_graph_trigger_outlet_count() {
5057        let prog = Program {
5058            in_decls: vec![],
5059            out_decls: vec![],
5060            wires: vec![Wire {
5061                name: "tr".to_string(),
5062                value: Expr::Call {
5063                    object: "trigger".to_string(),
5064                    args: vec![
5065                        CallArg::positional(Expr::Lit(LitValue::Str("b".to_string()))),
5066                        CallArg::positional(Expr::Lit(LitValue::Str("f".to_string()))),
5067                    ],
5068                },
5069                span: None,
5070                attrs: vec![],
5071            }],
5072            destructuring_wires: vec![],
5073            msg_decls: vec![],
5074            out_assignments: vec![],
5075            direct_connections: vec![],
5076            feedback_decls: vec![],
5077            feedback_assignments: vec![],
5078            state_decls: vec![],
5079            state_assignments: vec![],
5080        };
5081
5082        let graph = build_graph(&prog).unwrap();
5083        let t = graph
5084            .nodes
5085            .iter()
5086            .find(|n| n.object_name == "trigger")
5087            .expect("should have a trigger node");
5088
5089        // trigger b f → 2 outlets
5090        assert_eq!(t.num_outlets, 2);
5091    }
5092
5093    /// Integration test: route with multiple args
5094    #[test]
5095    fn test_graph_route_outlet_count() {
5096        let prog = Program {
5097            in_decls: vec![],
5098            out_decls: vec![],
5099            wires: vec![Wire {
5100                name: "r".to_string(),
5101                value: Expr::Call {
5102                    object: "route".to_string(),
5103                    args: vec![
5104                        CallArg::positional(Expr::Lit(LitValue::Str("a".to_string()))),
5105                        CallArg::positional(Expr::Lit(LitValue::Str("b".to_string()))),
5106                        CallArg::positional(Expr::Lit(LitValue::Str("c".to_string()))),
5107                    ],
5108                },
5109                span: None,
5110                attrs: vec![],
5111            }],
5112            destructuring_wires: vec![],
5113            msg_decls: vec![],
5114            out_assignments: vec![],
5115            direct_connections: vec![],
5116            feedback_decls: vec![],
5117            feedback_assignments: vec![],
5118            state_decls: vec![],
5119            state_assignments: vec![],
5120        };
5121
5122        let graph = build_graph(&prog).unwrap();
5123        let r = graph
5124            .nodes
5125            .iter()
5126            .find(|n| n.object_name == "route")
5127            .expect("should have a route node");
5128
5129        // route a b c → 4 outlets
5130        assert_eq!(r.num_outlets, 4);
5131    }
5132
5133    #[test]
5134    fn test_codebox_with_code_files() {
5135        let mut code_files = CodeFiles::new();
5136        code_files.insert(
5137            "processor.js".to_string(),
5138            "function bang() { outlet(0, 42); }".to_string(),
5139        );
5140
5141        let prog = Program {
5142            in_decls: vec![],
5143            out_decls: vec![],
5144            wires: vec![Wire {
5145                name: "cb".to_string(),
5146                value: Expr::Call {
5147                    object: "v8.codebox".to_string(),
5148                    args: vec![CallArg::positional(Expr::Lit(LitValue::Str(
5149                        "processor.js".to_string(),
5150                    )))],
5151                },
5152                span: None,
5153                attrs: vec![],
5154            }],
5155            destructuring_wires: vec![],
5156            msg_decls: vec![],
5157            out_assignments: vec![],
5158            direct_connections: vec![],
5159            feedback_decls: vec![],
5160            feedback_assignments: vec![],
5161            state_decls: vec![],
5162            state_assignments: vec![],
5163        };
5164
5165        let graph = build_graph_with_code_files(&prog, None, Some(&code_files)).unwrap();
5166
5167        let cb_node = graph
5168            .nodes
5169            .iter()
5170            .find(|n| n.object_name == "v8.codebox")
5171            .expect("should have a v8.codebox node");
5172
5173        assert_eq!(
5174            cb_node.code,
5175            Some("function bang() { outlet(0, 42); }".to_string())
5176        );
5177        assert!(
5178            cb_node.args.is_empty(),
5179            "args should be cleared when code file is resolved"
5180        );
5181    }
5182
5183    #[test]
5184    fn test_codebox_without_code_files() {
5185        // When no code_files provided, codebox still works but code is None
5186        let prog = Program {
5187            in_decls: vec![],
5188            out_decls: vec![],
5189            wires: vec![Wire {
5190                name: "cb".to_string(),
5191                value: Expr::Call {
5192                    object: "v8.codebox".to_string(),
5193                    args: vec![CallArg::positional(Expr::Lit(LitValue::Str(
5194                        "processor.js".to_string(),
5195                    )))],
5196                },
5197                span: None,
5198                attrs: vec![],
5199            }],
5200            destructuring_wires: vec![],
5201            msg_decls: vec![],
5202            out_assignments: vec![],
5203            direct_connections: vec![],
5204            feedback_decls: vec![],
5205            feedback_assignments: vec![],
5206            state_decls: vec![],
5207            state_assignments: vec![],
5208        };
5209
5210        let graph = build_graph(&prog).unwrap();
5211
5212        let cb_node = graph
5213            .nodes
5214            .iter()
5215            .find(|n| n.object_name == "v8.codebox")
5216            .expect("should have a v8.codebox node");
5217
5218        assert_eq!(cb_node.code, None);
5219        assert_eq!(cb_node.args, vec!["processor.js"]);
5220    }
5221
5222    #[test]
5223    fn test_codebox_infer_inlets_outlets() {
5224        // v8.codebox and codebox should have default 1 inlet and 1 outlet
5225        assert_eq!(infer_num_inlets("v8.codebox", &[], None), 1);
5226        assert_eq!(infer_num_inlets("codebox", &[], None), 1);
5227        assert_eq!(infer_num_outlets("v8.codebox", &[], None), 1);
5228        assert_eq!(infer_num_outlets("codebox", &[], None), 1);
5229    }
5230
5231    #[test]
5232    fn test_infer_codebox_ports_basic() {
5233        // Simple: out1 = in1 * in2
5234        assert_eq!(infer_codebox_ports("out1 = in1 * in2;"), (2, 1));
5235    }
5236
5237    #[test]
5238    fn test_infer_codebox_ports_multiple_outputs() {
5239        let code = "out1 = in1 * in2;\nout2 = in1 + in2;\nout3 = in1 - in2;";
5240        assert_eq!(infer_codebox_ports(code), (2, 3));
5241    }
5242
5243    #[test]
5244    fn test_infer_codebox_ports_history() {
5245        // Real gen~ code with History, multiple ins/outs
5246        let code = "History hold(0), gate(0);\nout1 = in1 * in2 * in3;\nout2 = in4;";
5247        assert_eq!(infer_codebox_ports(code), (4, 2));
5248    }
5249
5250    #[test]
5251    fn test_infer_codebox_ports_no_refs() {
5252        // No in/out references → defaults to (1, 1)
5253        assert_eq!(infer_codebox_ports("x = 42;"), (1, 1));
5254    }
5255
5256    #[test]
5257    fn test_infer_codebox_ports_word_boundary() {
5258        // "into" and "output" should NOT match
5259        let code = "into = 5;\noutput = into + 1;\nout1 = in1;";
5260        assert_eq!(infer_codebox_ports(code), (1, 1));
5261    }
5262
5263    // ================================================
5264    // ObjectDb integration tests
5265    // ================================================
5266
5267    /// Registered objects return inlet/outlet counts from objdb
5268    #[test]
5269    fn test_infer_with_objdb() {
5270        use flutmax_objdb::{
5271            InletSpec, Module, ObjectDb, ObjectDef, OutletSpec, PortDef, PortType as ObjPortType,
5272        };
5273
5274        let mut db = ObjectDb::new();
5275        db.insert(ObjectDef {
5276            name: "myobj~".to_string(),
5277            module: Module::Msp,
5278            category: "test".to_string(),
5279            digest: "test object".to_string(),
5280            inlets: InletSpec::Fixed(vec![
5281                PortDef {
5282                    id: 0,
5283                    port_type: ObjPortType::Signal,
5284                    is_hot: true,
5285                    description: "in 0".to_string(),
5286                },
5287                PortDef {
5288                    id: 1,
5289                    port_type: ObjPortType::Signal,
5290                    is_hot: false,
5291                    description: "in 1".to_string(),
5292                },
5293                PortDef {
5294                    id: 2,
5295                    port_type: ObjPortType::Float,
5296                    is_hot: false,
5297                    description: "in 2".to_string(),
5298                },
5299            ]),
5300            outlets: OutletSpec::Fixed(vec![
5301                PortDef {
5302                    id: 0,
5303                    port_type: ObjPortType::Signal,
5304                    is_hot: false,
5305                    description: "out 0".to_string(),
5306                },
5307                PortDef {
5308                    id: 1,
5309                    port_type: ObjPortType::Signal,
5310                    is_hot: false,
5311                    description: "out 1".to_string(),
5312                },
5313            ]),
5314            args: vec![],
5315        });
5316
5317        // 3 inlets, 2 outlets returned from objdb
5318        assert_eq!(infer_num_inlets("myobj~", &[], Some(&db)), 3);
5319        assert_eq!(infer_num_outlets("myobj~", &[], Some(&db)), 2);
5320    }
5321
5322    /// Unregistered objects fall back to hardcoded table
5323    #[test]
5324    fn test_infer_objdb_fallback() {
5325        use flutmax_objdb::ObjectDb;
5326
5327        let db = ObjectDb::new(); // empty db
5328
5329        // "cycle~" is not in objdb -> 2 inlets, 1 outlet from hardcoded table
5330        assert_eq!(infer_num_inlets("cycle~", &[], Some(&db)), 2);
5331        assert_eq!(infer_num_outlets("cycle~", &[], Some(&db)), 1);
5332
5333        // "counter" also uses hardcoded fallback
5334        assert_eq!(infer_num_inlets("counter", &[], Some(&db)), 3);
5335        assert_eq!(infer_num_outlets("counter", &[], Some(&db)), 4);
5336    }
5337
5338    /// objdb Variable inlet/outlet works correctly with default arguments
5339    #[test]
5340    fn test_infer_objdb_variable_ports() {
5341        use flutmax_objdb::{
5342            InletSpec, Module, ObjectDb, ObjectDef, OutletSpec, PortDef, PortType as ObjPortType,
5343        };
5344
5345        let mut db = ObjectDb::new();
5346        db.insert(ObjectDef {
5347            name: "varobj".to_string(),
5348            module: Module::Max,
5349            category: "test".to_string(),
5350            digest: "variable port object".to_string(),
5351            inlets: InletSpec::Variable {
5352                defaults: vec![
5353                    PortDef {
5354                        id: 0,
5355                        port_type: ObjPortType::Any,
5356                        is_hot: true,
5357                        description: "in 0".to_string(),
5358                    },
5359                    PortDef {
5360                        id: 1,
5361                        port_type: ObjPortType::Any,
5362                        is_hot: false,
5363                        description: "in 1".to_string(),
5364                    },
5365                ],
5366                min_inlets: 1,
5367            },
5368            outlets: OutletSpec::Variable {
5369                defaults: vec![
5370                    PortDef {
5371                        id: 0,
5372                        port_type: ObjPortType::Any,
5373                        is_hot: false,
5374                        description: "out 0".to_string(),
5375                    },
5376                    PortDef {
5377                        id: 1,
5378                        port_type: ObjPortType::Any,
5379                        is_hot: false,
5380                        description: "out 1".to_string(),
5381                    },
5382                    PortDef {
5383                        id: 2,
5384                        port_type: ObjPortType::Any,
5385                        is_hot: false,
5386                        description: "out 2".to_string(),
5387                    },
5388                ],
5389                min_outlets: 1,
5390            },
5391            args: vec![],
5392        });
5393
5394        // No arguments -> returns defaults.len()
5395        assert_eq!(infer_num_inlets("varobj", &[], Some(&db)), 2);
5396        assert_eq!(infer_num_outlets("varobj", &[], Some(&db)), 3);
5397
5398        // With arguments -> returns args.len()
5399        assert_eq!(
5400            infer_num_inlets(
5401                "varobj",
5402                &["a".to_string(), "b".to_string(), "c".to_string()],
5403                Some(&db)
5404            ),
5405            3
5406        );
5407        assert_eq!(
5408            infer_num_outlets("varobj", &["x".to_string(), "y".to_string()], Some(&db)),
5409            2
5410        );
5411    }
5412
5413    // ── E52: OutDecl with inline value ──────────────────────
5414
5415    #[test]
5416    fn test_out_decl_inline_value_produces_edge() {
5417        // out audio: signal = osc; should produce the same graph as
5418        // out audio: signal; + out[0] = osc;
5419        let inline_program = Program {
5420            in_decls: vec![],
5421            out_decls: vec![OutDecl {
5422                index: 0,
5423                name: "audio".to_string(),
5424                port_type: PortType::Signal,
5425                value: Some(Expr::Ref("osc".to_string())),
5426            }],
5427            wires: vec![Wire {
5428                name: "osc".to_string(),
5429                value: Expr::Call {
5430                    object: "cycle~".to_string(),
5431                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
5432                },
5433                span: None,
5434                attrs: vec![],
5435            }],
5436            destructuring_wires: vec![],
5437            msg_decls: vec![],
5438            out_assignments: vec![],
5439            direct_connections: vec![],
5440            feedback_decls: vec![],
5441            feedback_assignments: vec![],
5442            state_decls: vec![],
5443            state_assignments: vec![],
5444        };
5445
5446        let separate_program = Program {
5447            in_decls: vec![],
5448            out_decls: vec![OutDecl {
5449                index: 0,
5450                name: "audio".to_string(),
5451                port_type: PortType::Signal,
5452                value: None,
5453            }],
5454            wires: vec![Wire {
5455                name: "osc".to_string(),
5456                value: Expr::Call {
5457                    object: "cycle~".to_string(),
5458                    args: vec![CallArg::positional(Expr::Lit(LitValue::Int(440)))],
5459                },
5460                span: None,
5461                attrs: vec![],
5462            }],
5463            destructuring_wires: vec![],
5464            msg_decls: vec![],
5465            out_assignments: vec![OutAssignment {
5466                index: 0,
5467                value: Expr::Ref("osc".to_string()),
5468                span: None,
5469            }],
5470            direct_connections: vec![],
5471            feedback_decls: vec![],
5472            feedback_assignments: vec![],
5473            state_decls: vec![],
5474            state_assignments: vec![],
5475        };
5476
5477        let inline_graph = build_graph(&inline_program).expect("inline build failed");
5478        let separate_graph = build_graph(&separate_program).expect("separate build failed");
5479
5480        // Both should have same number of nodes and edges
5481        assert_eq!(
5482            inline_graph.nodes.len(),
5483            separate_graph.nodes.len(),
5484            "node count mismatch: inline={} vs separate={}",
5485            inline_graph.nodes.len(),
5486            separate_graph.nodes.len()
5487        );
5488        assert_eq!(
5489            inline_graph.edges.len(),
5490            separate_graph.edges.len(),
5491            "edge count mismatch: inline={} vs separate={}",
5492            inline_graph.edges.len(),
5493            separate_graph.edges.len()
5494        );
5495    }
5496
5497    // ── Named argument resolution tests ─────────────────────────
5498
5499    #[test]
5500    fn test_resolve_inlet_name_found() {
5501        use flutmax_objdb::{
5502            InletSpec, Module, ObjectDb, ObjectDef, OutletSpec, PortDef, PortType as ObjPortType,
5503        };
5504
5505        let mut db = ObjectDb::new();
5506        db.insert(ObjectDef {
5507            name: "cycle~".to_string(),
5508            module: Module::Msp,
5509            category: String::new(),
5510            digest: String::new(),
5511            inlets: InletSpec::Fixed(vec![
5512                PortDef {
5513                    id: 0,
5514                    port_type: ObjPortType::SignalFloat,
5515                    is_hot: true,
5516                    description: "Frequency".to_string(),
5517                },
5518                PortDef {
5519                    id: 1,
5520                    port_type: ObjPortType::SignalFloat,
5521                    is_hot: false,
5522                    description: "Phase offset".to_string(),
5523                },
5524            ]),
5525            outlets: OutletSpec::Fixed(vec![]),
5526            args: vec![],
5527        });
5528
5529        assert_eq!(
5530            resolve_inlet_name("cycle~", "frequency", Some(&db)),
5531            Some(0)
5532        );
5533        assert_eq!(
5534            resolve_inlet_name("cycle~", "phase_offset", Some(&db)),
5535            Some(1)
5536        );
5537    }
5538
5539    #[test]
5540    fn test_resolve_inlet_name_not_found() {
5541        use flutmax_objdb::{
5542            InletSpec, Module, ObjectDb, ObjectDef, OutletSpec, PortDef, PortType as ObjPortType,
5543        };
5544
5545        let mut db = ObjectDb::new();
5546        db.insert(ObjectDef {
5547            name: "cycle~".to_string(),
5548            module: Module::Msp,
5549            category: String::new(),
5550            digest: String::new(),
5551            inlets: InletSpec::Fixed(vec![PortDef {
5552                id: 0,
5553                port_type: ObjPortType::SignalFloat,
5554                is_hot: true,
5555                description: "Frequency".to_string(),
5556            }]),
5557            outlets: OutletSpec::Fixed(vec![]),
5558            args: vec![],
5559        });
5560
5561        assert_eq!(resolve_inlet_name("cycle~", "nonexistent", Some(&db)), None);
5562    }
5563
5564    #[test]
5565    fn test_resolve_inlet_name_no_objdb() {
5566        assert_eq!(resolve_inlet_name("cycle~", "frequency", None), None);
5567    }
5568
5569    #[test]
5570    fn test_resolve_abstraction_inlet_name() {
5571        use flutmax_ast::PortType;
5572        use flutmax_sema::registry::{AbstractionInterface, AbstractionRegistry, PortInfo};
5573
5574        let mut reg = AbstractionRegistry::new();
5575        reg.register_interface(AbstractionInterface {
5576            name: "simpleFM".to_string(),
5577            in_ports: vec![
5578                PortInfo {
5579                    index: 0,
5580                    name: "carrier_freq".to_string(),
5581                    port_type: PortType::Float,
5582                },
5583                PortInfo {
5584                    index: 1,
5585                    name: "harmonicity".to_string(),
5586                    port_type: PortType::Float,
5587                },
5588                PortInfo {
5589                    index: 2,
5590                    name: "mod_index".to_string(),
5591                    port_type: PortType::Float,
5592                },
5593            ],
5594            out_ports: vec![PortInfo {
5595                index: 0,
5596                name: "output".to_string(),
5597                port_type: PortType::Signal,
5598            }],
5599        });
5600
5601        assert_eq!(
5602            resolve_abstraction_inlet_name("simpleFM", "carrier_freq", Some(&reg)),
5603            Some(0)
5604        );
5605        assert_eq!(
5606            resolve_abstraction_inlet_name("simpleFM", "harmonicity", Some(&reg)),
5607            Some(1)
5608        );
5609        assert_eq!(
5610            resolve_abstraction_inlet_name("simpleFM", "mod_index", Some(&reg)),
5611            Some(2)
5612        );
5613        assert_eq!(
5614            resolve_abstraction_inlet_name("simpleFM", "nonexistent", Some(&reg)),
5615            None
5616        );
5617        assert_eq!(
5618            resolve_abstraction_inlet_name("unknown", "carrier_freq", Some(&reg)),
5619            None
5620        );
5621        assert_eq!(
5622            resolve_abstraction_inlet_name("simpleFM", "carrier_freq", None),
5623            None
5624        );
5625    }
5626
5627    #[test]
5628    fn test_named_arg_codegen() {
5629        // Named args should resolve to correct inlet indices
5630        use flutmax_objdb::{
5631            InletSpec, Module, ObjectDb, ObjectDef, OutletSpec, PortDef, PortType as ObjPortType,
5632        };
5633
5634        let mut db = ObjectDb::new();
5635        db.insert(ObjectDef {
5636            name: "biquad~".to_string(),
5637            module: Module::Msp,
5638            category: String::new(),
5639            digest: String::new(),
5640            inlets: InletSpec::Fixed(vec![
5641                PortDef {
5642                    id: 0,
5643                    port_type: ObjPortType::Signal,
5644                    is_hot: true,
5645                    description: "Input".to_string(),
5646                },
5647                PortDef {
5648                    id: 1,
5649                    port_type: ObjPortType::SignalFloat,
5650                    is_hot: false,
5651                    description: "Frequency".to_string(),
5652                },
5653                PortDef {
5654                    id: 2,
5655                    port_type: ObjPortType::SignalFloat,
5656                    is_hot: false,
5657                    description: "Q factor".to_string(),
5658                },
5659            ]),
5660            outlets: OutletSpec::Fixed(vec![PortDef {
5661                id: 0,
5662                port_type: ObjPortType::Signal,
5663                is_hot: false,
5664                description: "Output".to_string(),
5665            }]),
5666            args: vec![],
5667        });
5668
5669        // Build a program with named args
5670        let program = Program {
5671            in_decls: vec![
5672                InDecl {
5673                    index: 0,
5674                    name: "sig".to_string(),
5675                    port_type: PortType::Signal,
5676                },
5677                InDecl {
5678                    index: 1,
5679                    name: "freq".to_string(),
5680                    port_type: PortType::Float,
5681                },
5682            ],
5683            out_decls: vec![OutDecl {
5684                index: 0,
5685                name: "out".to_string(),
5686                port_type: PortType::Signal,
5687                value: None,
5688            }],
5689            wires: vec![Wire {
5690                name: "filtered".to_string(),
5691                value: Expr::Call {
5692                    object: "biquad~".to_string(),
5693                    args: vec![
5694                        // Use named args: "frequency" maps to inlet 1
5695                        CallArg::named("frequency", Expr::Ref("freq".to_string())),
5696                        // "input" maps to inlet 0
5697                        CallArg::named("input", Expr::Ref("sig".to_string())),
5698                    ],
5699                },
5700                span: None,
5701                attrs: vec![],
5702            }],
5703            out_assignments: vec![OutAssignment {
5704                index: 0,
5705                value: Expr::Ref("filtered".to_string()),
5706                span: None,
5707            }],
5708            destructuring_wires: vec![],
5709            msg_decls: vec![],
5710            direct_connections: vec![],
5711            feedback_decls: vec![],
5712            feedback_assignments: vec![],
5713            state_decls: vec![],
5714            state_assignments: vec![],
5715        };
5716
5717        let graph =
5718            build_graph_with_objdb(&program, None, None, Some(&db)).expect("should build graph");
5719
5720        // Verify that the named args resolved to the correct inlet indices
5721        // "frequency" → inlet 1, "input" → inlet 0
5722        // Find the biquad~ node ID
5723        let biquad_node = graph
5724            .nodes
5725            .iter()
5726            .find(|n| n.object_name == "biquad~")
5727            .expect("should have biquad~ node");
5728        let biquad_id = &biquad_node.id;
5729
5730        let biquad_edges: Vec<_> = graph
5731            .edges
5732            .iter()
5733            .filter(|e| &e.dest_id == biquad_id)
5734            .collect();
5735
5736        // Should have 2 edges going into biquad~
5737        assert_eq!(
5738            biquad_edges.len(),
5739            2,
5740            "expected 2 edges to biquad~, got {}: {:?}",
5741            biquad_edges.len(),
5742            biquad_edges
5743        );
5744
5745        // Check inlet assignments: freq→inlet 1, sig→inlet 0
5746        let freq_edge = biquad_edges.iter().find(|e| e.dest_inlet == 1);
5747        let sig_edge = biquad_edges.iter().find(|e| e.dest_inlet == 0);
5748        assert!(
5749            freq_edge.is_some(),
5750            "should have edge to inlet 1 (frequency)"
5751        );
5752        assert!(sig_edge.is_some(), "should have edge to inlet 0 (input)");
5753    }
5754}