Skip to main content

wyrd/authoring/
validate.rs

1//! Structural validation and soft/hard resource budgets for weaves.
2//!
3//! Definition validation (`validate_def`) enforces ids, ports, fan-in, required
4//! connections, numeric path match, and acyclicity. Post-validation budget
5//! checks (`validate` / `validate_report`) enforce hard limits and collect soft
6//! warnings for tooling. Runtime bind re-runs budget validation with bind opts.
7
8use core::fmt;
9
10use std::collections::{BTreeMap, BTreeSet};
11use std::prelude::v1::vec;
12use std::string::String;
13use std::vec::Vec;
14
15use crate::foundation::{
16    port_domain, port_slot, ports_of, KnotKind, NumericPath, PortDir, PortDomain, PortSlot, Signal,
17    SignalDomain, ONE, ZERO,
18};
19
20use crate::{ValidationError, Weave, WeaveDef};
21
22/// Hard and soft resource limits applied after structural validation.
23#[derive(Clone, Debug)]
24pub struct Budget {
25    /// Hard cap on knot count (bind rejects above this).
26    pub max_knots: u16,
27    /// Hard cap on thread count.
28    pub max_threads: u16,
29    /// Soft knot count; exceeded yields [`BudgetWarning::SoftKnots`].
30    pub soft_knots: u16,
31    /// Soft thread count; exceeded yields [`BudgetWarning::SoftThreads`].
32    pub soft_threads: u16,
33    /// Hard cap on longest topo chain depth.
34    pub max_chain_depth: u16,
35    /// Soft chain depth; exceeded yields [`BudgetWarning::SoftChainDepth`].
36    pub soft_chain_depth: u16,
37    /// Hard cap on outbound threads from any single knot.
38    pub max_fan_out: u16,
39    /// Soft fan-out; exceeded yields [`BudgetWarning::SoftFanOut`].
40    pub soft_fan_out: u16,
41    /// Hard cap on summed `Delay` ticks along any topo path.
42    pub max_delay_path_sum: u16,
43}
44
45impl Default for Budget {
46    fn default() -> Self {
47        Self {
48            max_knots: 256,
49            max_threads: 512,
50            soft_knots: 64,
51            soft_threads: 128,
52            max_chain_depth: 16,
53            soft_chain_depth: 8,
54            max_fan_out: 8,
55            soft_fan_out: 4,
56            max_delay_path_sum: 32,
57        }
58    }
59}
60
61/// Soft-limit warning; graph remains valid for bind.
62#[derive(Clone, Debug, PartialEq, Eq)]
63#[non_exhaustive]
64pub enum BudgetWarning {
65    /// Knot count exceeded the soft budget but remains under the hard cap.
66    SoftKnots {
67        /// Observed knot count.
68        count: u16,
69        /// Configured soft limit from [`Budget::soft_knots`].
70        soft: u16,
71    },
72    /// Thread count exceeded the soft budget but remains under the hard cap.
73    SoftThreads {
74        /// Observed thread count.
75        count: u16,
76        /// Configured soft limit from [`Budget::soft_threads`].
77        soft: u16,
78    },
79    /// Longest topo chain depth exceeded the soft budget.
80    SoftChainDepth {
81        /// Observed chain depth at [`Self::SoftChainDepth::at_knot`].
82        depth: u16,
83        /// Configured soft limit from [`Budget::soft_chain_depth`].
84        soft: u16,
85        /// Knot id where the soft depth threshold was crossed.
86        at_knot: String,
87    },
88    /// Outbound thread count from one knot exceeded the soft fan-out budget.
89    SoftFanOut {
90        /// Observed fan-out from [`Self::SoftFanOut::at_knot`].
91        fan_out: u16,
92        /// Configured soft limit from [`Budget::soft_fan_out`].
93        soft: u16,
94        /// Knot id whose outbound threads triggered the warning.
95        at_knot: String,
96    },
97}
98
99impl fmt::Display for BudgetWarning {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        match self {
102            Self::SoftKnots { count, soft } => {
103                write!(f, "soft knot budget: {count} knots (soft {soft})")
104            }
105            Self::SoftThreads { count, soft } => {
106                write!(f, "soft thread budget: {count} threads (soft {soft})")
107            }
108            Self::SoftChainDepth {
109                depth,
110                soft,
111                at_knot,
112            } => write!(
113                f,
114                "soft chain depth: {depth} edges at knot '{at_knot}' (soft {soft})"
115            ),
116            Self::SoftFanOut {
117                fan_out,
118                soft,
119                at_knot,
120            } => write!(
121                f,
122                "soft fan-out: {fan_out} from knot '{at_knot}' (soft {soft})"
123            ),
124        }
125    }
126}
127
128/// Soft budget warnings from a successful hard-limit pass.
129#[derive(Clone, Debug, Default, PartialEq, Eq)]
130pub struct ValidateReport {
131    /// Soft-limit warnings collected during a successful hard-limit pass.
132    pub warnings: Vec<BudgetWarning>,
133}
134impl ValidateReport {
135    /// True when no soft warnings were recorded.
136    pub fn ok(&self) -> bool {
137        self.warnings.is_empty()
138    }
139}
140
141impl fmt::Display for ValidateReport {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        if self.warnings.is_empty() {
144            return f.write_str("validate ok");
145        }
146        for (i, warning) in self.warnings.iter().enumerate() {
147            if i != 0 {
148                f.write_str("; ")?;
149            }
150            warning.fmt(f)?;
151        }
152        Ok(())
153    }
154}
155
156/// Hard budget check only; discards soft warnings.
157pub fn validate(weave: &Weave, budget: &Budget) -> Result<(), ValidationError> {
158    validate_report(weave, budget).map(|_| ())
159}
160
161/// Hard budget check plus soft-limit warnings for tooling.
162pub fn validate_report(weave: &Weave, budget: &Budget) -> Result<ValidateReport, ValidationError> {
163    let knots = weave.knots();
164    let threads = weave.threads();
165    budget_limit("knots", knots.len(), budget.max_knots as usize, None)?;
166    budget_limit("threads", threads.len(), budget.max_threads as usize, None)?;
167
168    let index: BTreeMap<&str, usize> = knots
169        .iter()
170        .enumerate()
171        .map(|(i, knot)| (knot.id.as_str(), i))
172        .collect();
173    let mut adj = vec![Vec::new(); knots.len()];
174    let mut indeg = vec![0u16; knots.len()];
175    let mut fan_out = vec![0u16; knots.len()];
176    for thread in threads {
177        let from = index[thread.from.knot.as_str()];
178        let to = index[thread.to.knot.as_str()];
179        adj[from].push(to);
180        indeg[to] = indeg[to].saturating_add(1);
181        fan_out[from] = fan_out[from].saturating_add(1);
182        budget_limit(
183            "fan-out",
184            fan_out[from] as usize,
185            budget.max_fan_out as usize,
186            Some(&knots[from].id),
187        )?;
188    }
189
190    let mut queue: Vec<usize> = indeg
191        .iter()
192        .enumerate()
193        .filter_map(|(i, d)| (*d == 0).then_some(i))
194        .collect();
195    let mut depth = vec![0u16; knots.len()];
196    let mut delay_sum: Vec<u32> = knots
197        .iter()
198        .map(|k| u32::from(delay_ticks(&k.kind)))
199        .collect();
200    while let Some(node) = queue.pop() {
201        budget_limit(
202            "chain depth",
203            depth[node] as usize,
204            budget.max_chain_depth as usize,
205            Some(&knots[node].id),
206        )?;
207        budget_limit(
208            "delay path sum",
209            delay_sum[node] as usize,
210            budget.max_delay_path_sum as usize,
211            Some(&knots[node].id),
212        )?;
213        for &next in &adj[node] {
214            depth[next] = depth[next].max(depth[node].saturating_add(1));
215            delay_sum[next] = delay_sum[next]
216                .max(delay_sum[node].saturating_add(u32::from(delay_ticks(&knots[next].kind))));
217            indeg[next] -= 1;
218            if indeg[next] == 0 {
219                queue.push(next);
220            }
221        }
222    }
223
224    let mut warnings = Vec::new();
225    if knots.len() > budget.soft_knots as usize {
226        warnings.push(BudgetWarning::SoftKnots {
227            count: knots.len() as u16,
228            soft: budget.soft_knots,
229        });
230    }
231    if threads.len() > budget.soft_threads as usize {
232        warnings.push(BudgetWarning::SoftThreads {
233            count: threads.len() as u16,
234            soft: budget.soft_threads,
235        });
236    }
237    for (i, &count) in fan_out.iter().enumerate() {
238        if count > budget.soft_fan_out {
239            warnings.push(BudgetWarning::SoftFanOut {
240                fan_out: count,
241                soft: budget.soft_fan_out,
242                at_knot: knots[i].id.clone(),
243            });
244        }
245    }
246    for (i, &count) in depth.iter().enumerate() {
247        if count > budget.soft_chain_depth {
248            warnings.push(BudgetWarning::SoftChainDepth {
249                depth: count,
250                soft: budget.soft_chain_depth,
251                at_knot: knots[i].id.clone(),
252            });
253        }
254    }
255    Ok(ValidateReport { warnings })
256}
257
258pub(crate) fn validate_def(def: &WeaveDef) -> Result<(), ValidationError> {
259    validate_def_with_external_inputs(def, &BTreeSet::new())
260}
261
262pub(crate) fn validate_def_with_external_inputs(
263    def: &WeaveDef,
264    external: &BTreeSet<(String, String)>,
265) -> Result<(), ValidationError> {
266    if def.id.is_empty() {
267        return Err(ValidationError::InvalidWeaveId {
268            weave_id: def.id.clone(),
269            reason: "must be non-empty",
270        });
271    }
272    if def.knots.is_empty() {
273        return Err(ValidationError::EmptyWeave {
274            weave_id: def.id.clone(),
275        });
276    }
277    if def.knots.len() > u16::MAX as usize {
278        return Err(ValidationError::RepresentationOverflow {
279            what: "knot",
280            actual: def.knots.len(),
281            limit: u16::MAX as usize,
282        });
283    }
284    if def.threads.len() > u16::MAX as usize {
285        return Err(ValidationError::RepresentationOverflow {
286            what: "thread",
287            actual: def.threads.len(),
288            limit: u16::MAX as usize,
289        });
290    }
291    if def.numeric != NumericPath::compiled() {
292        return Err(ValidationError::NumericMismatch {
293            expected: NumericPath::compiled(),
294            actual: def.numeric,
295        });
296    }
297
298    let mut index = BTreeMap::new();
299    for (i, knot) in def.knots.iter().enumerate() {
300        if knot.id.is_empty() {
301            return Err(ValidationError::InvalidKnotId {
302                knot_id: knot.id.clone(),
303                reason: "must be non-empty",
304            });
305        }
306        if index.insert(knot.id.as_str(), i).is_some() {
307            return Err(ValidationError::DuplicateKnotId {
308                knot_id: knot.id.clone(),
309            });
310        }
311        validate_kind(knot)?;
312    }
313    let mut fan = BTreeSet::new();
314    let mut adj = vec![Vec::new(); def.knots.len()];
315    let mut indeg = vec![0u32; def.knots.len()];
316    for thread in &def.threads {
317        let &from =
318            index
319                .get(thread.from.knot.as_str())
320                .ok_or_else(|| ValidationError::UnknownKnot {
321                    knot_id: thread.from.knot.clone(),
322                })?;
323        let &to =
324            index
325                .get(thread.to.knot.as_str())
326                .ok_or_else(|| ValidationError::UnknownKnot {
327                    knot_id: thread.to.knot.clone(),
328                })?;
329        check_port(&def.knots[from], &thread.from.port, PortDir::Out)?;
330        check_port(&def.knots[to], &thread.to.port, PortDir::In)?;
331        if !fan.insert((to, thread.to.port.as_str())) {
332            return Err(ValidationError::FanIn {
333                knot_id: thread.to.knot.clone(),
334                port: thread.to.port.clone(),
335            });
336        }
337        adj[from].push(to);
338        indeg[to] += 1;
339    }
340    for (i, knot) in def.knots.iter().enumerate() {
341        for port in ports_of(&knot.kind) {
342            if port.dir != PortDir::In || !required(&knot.kind, port.name, port.required) {
343                continue;
344            }
345            let connected = fan.contains(&(i, port.name));
346            let exported = external.contains(&(knot.id.clone(), String::from(port.name)));
347            if !connected && !exported {
348                return Err(ValidationError::UnconnectedRequired {
349                    knot_id: knot.id.clone(),
350                    port: String::from(port.name),
351                });
352            }
353        }
354    }
355    let mut queue: Vec<usize> = indeg
356        .iter()
357        .enumerate()
358        .filter_map(|(i, d)| (*d == 0).then_some(i))
359        .collect();
360    let mut seen = 0usize;
361    while let Some(node) = queue.pop() {
362        seen += 1;
363        for &next in &adj[node] {
364            indeg[next] -= 1;
365            if indeg[next] == 0 {
366                queue.push(next);
367            }
368        }
369    }
370    if seen != def.knots.len() {
371        let at_knot = indeg
372            .iter()
373            .position(|d| *d != 0)
374            .map(|i| def.knots[i].id.clone());
375        return Err(ValidationError::Cycle { at_knot });
376    }
377    validate_domains(def, &index, external)?;
378    Ok(())
379}
380
381fn validate_kind(knot: &crate::KnotDef) -> Result<(), ValidationError> {
382    if ports_of(&knot.kind).is_empty() {
383        return Err(ValidationError::InvalidParameter {
384            knot_id: knot.id.clone(),
385            parameter: "arity",
386            reason: "unsupported port arity",
387        });
388    }
389    validate_kind_domains(knot)?;
390    match &knot.kind {
391        KnotKind::Constant { domain, value } => {
392            validate_signal_parameter(knot, "value", *domain, *value)?;
393        }
394        KnotKind::Compare {
395            domain,
396            rhs_const: Some(value),
397            ..
398        } => validate_signal_parameter(knot, "rhs_const", *domain, *value)?,
399        KnotKind::Map {
400            domain,
401            in_min,
402            in_max,
403            out_min,
404            out_max,
405        }
406        | KnotKind::Digitize {
407            domain,
408            in_min,
409            in_max,
410            out_min,
411            out_max,
412            ..
413        } => {
414            validate_signal_parameter(knot, "in_min", *domain, *in_min)?;
415            validate_signal_parameter(knot, "in_max", *domain, *in_max)?;
416            validate_signal_parameter(knot, "out_min", *domain, *out_min)?;
417            validate_signal_parameter(knot, "out_max", *domain, *out_max)?;
418        }
419        KnotKind::Threshold {
420            domain, high, low, ..
421        } => {
422            validate_signal_parameter(knot, "high", *domain, *high)?;
423            validate_signal_parameter(knot, "low", *domain, *low)?;
424        }
425        KnotKind::Clamp { domain, min, max } => {
426            validate_signal_parameter(knot, "min", *domain, *min)?;
427            validate_signal_parameter(knot, "max", *domain, *max)?;
428        }
429        _ => {}
430    }
431
432    let invalid = match &knot.kind {
433        KnotKind::Digitize { steps: 0, .. } => Some(("steps", "must be greater than zero")),
434        KnotKind::Digitize { in_min, in_max, .. } | KnotKind::Map { in_min, in_max, .. }
435            if in_min > in_max =>
436        {
437            Some(("in_min", "must not exceed in_max"))
438        }
439        KnotKind::Threshold {
440            high,
441            low,
442            use_hysteresis: true,
443            ..
444        } if low > high => Some(("low", "must not exceed high when hysteresis is enabled")),
445        KnotKind::Clamp { min, max, .. } if min > max => Some(("min", "must not exceed max")),
446        _ => None,
447    };
448    if let Some((parameter, reason)) = invalid {
449        return Err(ValidationError::InvalidParameter {
450            knot_id: knot.id.clone(),
451            parameter,
452            reason,
453        });
454    }
455    Ok(())
456}
457
458fn validate_kind_domains(knot: &crate::KnotDef) -> Result<(), ValidationError> {
459    let invalid = match &knot.kind {
460        KnotKind::Compare { domain, op, .. } if !op.supports_domain(*domain) => Some((
461            "domain",
462            "comparison operator does not support the selected domain",
463        )),
464        KnotKind::Calc { domain, .. }
465        | KnotKind::Map { domain, .. }
466        | KnotKind::Abs { domain }
467        | KnotKind::Neg { domain }
468        | KnotKind::Digitize { domain, .. }
469        | KnotKind::Threshold { domain, .. }
470        | KnotKind::Random { domain, .. }
471        | KnotKind::Sqrt { domain }
472        | KnotKind::Clamp { domain, .. }
473            if !domain.is_numeric() =>
474        {
475            Some(("domain", "must be Level or Count"))
476        }
477        KnotKind::Convert { from, to } if from == to => {
478            Some(("domain", "conversion domains must differ"))
479        }
480        _ => None,
481    };
482    if let Some((parameter, reason)) = invalid {
483        return Err(ValidationError::InvalidParameter {
484            knot_id: knot.id.clone(),
485            parameter,
486            reason,
487        });
488    }
489    Ok(())
490}
491
492fn validate_signal_parameter(
493    knot: &crate::KnotDef,
494    parameter: &'static str,
495    domain: SignalDomain,
496    value: Signal,
497) -> Result<(), ValidationError> {
498    #[cfg(feature = "signal-f32")]
499    if !value.is_finite() {
500        return Err(invalid_signal_parameter(knot, parameter, "must be finite"));
501    }
502
503    if domain == SignalDomain::Bool && value != ZERO && value != ONE {
504        return Err(invalid_signal_parameter(
505            knot,
506            parameter,
507            "must be ZERO or ONE for Bool domain",
508        ));
509    }
510
511    #[cfg(feature = "signal-f32")]
512    if domain == SignalDomain::Count {
513        let wide = f64::from(value);
514        if wide < f64::from(i32::MIN) || wide > f64::from(i32::MAX) {
515            return Err(invalid_signal_parameter(
516                knot,
517                parameter,
518                "must fit in i32 for Count domain",
519            ));
520        }
521        if (value as i32) as f32 != value {
522            return Err(invalid_signal_parameter(
523                knot,
524                parameter,
525                "must be a whole number for Count domain",
526            ));
527        }
528    }
529
530    Ok(())
531}
532
533fn invalid_signal_parameter(
534    knot: &crate::KnotDef,
535    parameter: &'static str,
536    reason: &'static str,
537) -> ValidationError {
538    ValidationError::InvalidParameter {
539        knot_id: knot.id.clone(),
540        parameter,
541        reason,
542    }
543}
544
545// Union-find domain inference: Variable ports on one knot must unify, and each
546// thread unions its source and sink. Compare knots with rhs_const relax the
547// required rhs port before this pass runs.
548fn validate_domains(
549    def: &WeaveDef,
550    index: &BTreeMap<&str, usize>,
551    external: &BTreeSet<(String, String)>,
552) -> Result<(), ValidationError> {
553    let mut inference = DomainInference::default();
554    let mut nodes: BTreeMap<(usize, PortSlot), usize> = BTreeMap::new();
555    let mut variables: BTreeMap<(usize, u8), (usize, &'static str)> = BTreeMap::new();
556    let mut active = BTreeSet::new();
557
558    for (knot_index, knot) in def.knots.iter().enumerate() {
559        for port in ports_of(&knot.kind) {
560            let constraint = require_port_domain(knot, port.slot)?;
561            let domain = match constraint {
562                PortDomain::Fixed(domain) => Some(domain),
563                PortDomain::Variable(_) | PortDomain::Any => None,
564            };
565            let node = inference.make_set(domain);
566            nodes.insert((knot_index, port.slot), node);
567
568            if let PortDomain::Variable(variable) = constraint {
569                active.insert(node);
570                if let Some(&(first, first_port)) = variables.get(&(knot_index, variable)) {
571                    let ports = (first_port, port.name);
572                    validate_variable_domains(&mut inference, first, node, knot, ports)?;
573                } else {
574                    variables.insert((knot_index, variable), (node, port.name));
575                }
576            }
577        }
578    }
579
580    for thread in &def.threads {
581        let from_index = index[thread.from.knot.as_str()];
582        let to_index = index[thread.to.knot.as_str()];
583        let from_slot = port_slot(&def.knots[from_index].kind, &thread.from.port)
584            .expect("ports were checked before domain validation");
585        let to_slot = port_slot(&def.knots[to_index].kind, &thread.to.port)
586            .expect("ports were checked before domain validation");
587        let from_node = nodes[&(from_index, from_slot)];
588        let to_node = nodes[&(to_index, to_slot)];
589        active.insert(from_node);
590        active.insert(to_node);
591        if let Some((from_domain, to_domain)) = inference.union(from_node, to_node) {
592            return Err(ValidationError::SignalDomainMismatch {
593                from_knot: thread.from.knot.clone(),
594                from_port: thread.from.port.clone(),
595                from_domain,
596                to_knot: thread.to.knot.clone(),
597                to_port: thread.to.port.clone(),
598                to_domain,
599            });
600        }
601    }
602
603    let mut external_nodes = Vec::new();
604    for (knot_id, port_name) in external {
605        let knot_index = index[knot_id.as_str()];
606        let slot = port_slot(&def.knots[knot_index].kind, port_name)
607            .expect("pattern exports were checked before domain validation");
608        let node = nodes[&(knot_index, slot)];
609        active.insert(node);
610        external_nodes.push(node);
611    }
612    let deferred: BTreeSet<usize> = external_nodes
613        .into_iter()
614        .map(|node| inference.find(node))
615        .collect();
616
617    for (knot_index, knot) in def.knots.iter().enumerate() {
618        for port in ports_of(&knot.kind) {
619            let node = nodes[&(knot_index, port.slot)];
620            if !active.contains(&node) {
621                continue;
622            }
623            validate_resolved_domain(&mut inference, node, &deferred, &knot.id, port.name)?;
624        }
625    }
626
627    Ok(())
628}
629
630fn require_port_domain(
631    knot: &crate::KnotDef,
632    slot: PortSlot,
633) -> Result<PortDomain, ValidationError> {
634    port_domain(&knot.kind, slot).ok_or_else(|| ValidationError::InvalidParameter {
635        knot_id: knot.id.clone(),
636        parameter: "domain",
637        reason: "port has no domain constraint",
638    })
639}
640
641fn validate_variable_domains(
642    inference: &mut DomainInference,
643    first: usize,
644    node: usize,
645    knot: &crate::KnotDef,
646    ports: (&str, &str),
647) -> Result<(), ValidationError> {
648    if let Some((from_domain, to_domain)) = inference.union(first, node) {
649        return Err(ValidationError::SignalDomainMismatch {
650            from_knot: knot.id.clone(),
651            from_port: String::from(ports.0),
652            from_domain,
653            to_knot: knot.id.clone(),
654            to_port: String::from(ports.1),
655            to_domain,
656        });
657    }
658    Ok(())
659}
660
661fn validate_resolved_domain(
662    inference: &mut DomainInference,
663    node: usize,
664    deferred: &BTreeSet<usize>,
665    knot_id: &str,
666    port: &str,
667) -> Result<(), ValidationError> {
668    let root = inference.find(node);
669    if inference.nodes[root].domain.is_none() && !deferred.contains(&root) {
670        return Err(ValidationError::UnresolvedSignalDomain {
671            knot_id: String::from(knot_id),
672            port: String::from(port),
673        });
674    }
675    Ok(())
676}
677
678#[derive(Copy, Clone)]
679struct DomainNode {
680    parent: usize,
681    rank: u8,
682    domain: Option<SignalDomain>,
683}
684
685#[derive(Default)]
686struct DomainInference {
687    nodes: Vec<DomainNode>,
688}
689
690impl DomainInference {
691    fn make_set(&mut self, domain: Option<SignalDomain>) -> usize {
692        let index = self.nodes.len();
693        self.nodes.push(DomainNode {
694            parent: index,
695            rank: 0,
696            domain,
697        });
698        index
699    }
700
701    fn find(&mut self, node: usize) -> usize {
702        let parent = self.nodes[node].parent;
703        if parent != node {
704            let root = self.find(parent);
705            self.nodes[node].parent = root;
706        }
707        self.nodes[node].parent
708    }
709
710    fn union(&mut self, left: usize, right: usize) -> Option<(SignalDomain, SignalDomain)> {
711        let mut left_root = self.find(left);
712        let mut right_root = self.find(right);
713        if left_root == right_root {
714            return None;
715        }
716
717        let left_domain = self.nodes[left_root].domain;
718        let right_domain = self.nodes[right_root].domain;
719        if let (Some(left_domain), Some(right_domain)) = (left_domain, right_domain) {
720            if left_domain != right_domain {
721                return Some((left_domain, right_domain));
722            }
723        }
724
725        if self.nodes[left_root].rank < self.nodes[right_root].rank {
726            core::mem::swap(&mut left_root, &mut right_root);
727        }
728        self.nodes[right_root].parent = left_root;
729        self.nodes[left_root].domain = left_domain.or(right_domain);
730        if self.nodes[left_root].rank == self.nodes[right_root].rank {
731            self.nodes[left_root].rank = self.nodes[left_root].rank.saturating_add(1);
732        }
733        None
734    }
735}
736
737fn check_port(knot: &crate::KnotDef, name: &str, expected: PortDir) -> Result<(), ValidationError> {
738    let ports = ports_of(&knot.kind);
739    let info =
740        ports
741            .iter()
742            .find(|p| p.name == name)
743            .ok_or_else(|| ValidationError::UnknownPort {
744                knot_id: knot.id.clone(),
745                port: String::from(name),
746                expected: ports.iter().map(|p| String::from(p.name)).collect(),
747            })?;
748    if info.dir != expected {
749        return Err(ValidationError::WrongPortDirection {
750            knot_id: knot.id.clone(),
751            port: String::from(name),
752            expected,
753            actual: info.dir,
754        });
755    }
756    Ok(())
757}
758
759fn required(kind: &KnotKind, port: &str, catalog: bool) -> bool {
760    match kind {
761        KnotKind::Compare { rhs_const, .. } if port == "rhs" => rhs_const.is_none(),
762        KnotKind::Random {
763            require_gate: true, ..
764        } if port == "gate" => true,
765        _ => catalog,
766    }
767}
768
769fn budget_limit(
770    metric: &'static str,
771    actual: usize,
772    limit: usize,
773    knot: Option<&str>,
774) -> Result<(), ValidationError> {
775    if actual > limit {
776        return Err(ValidationError::BudgetExceeded {
777            metric,
778            actual: actual as u32,
779            limit: limit as u32,
780            at_knot: knot.map(String::from),
781        });
782    }
783    Ok(())
784}
785
786fn delay_ticks(kind: &KnotKind) -> u16 {
787    match kind {
788        KnotKind::Delay { ticks } => *ticks,
789        _ => 0,
790    }
791}
792
793#[cfg(test)]
794mod tests {
795    use std::collections::BTreeSet;
796
797    use super::{
798        require_port_domain, validate_resolved_domain, validate_variable_domains, DomainInference,
799    };
800    use crate::{KnotDef, KnotKind, PortSlot, SignalDomain, ValidationError};
801
802    fn select_knot() -> KnotDef {
803        KnotDef {
804            id: "select".into(),
805            kind: KnotKind::select(),
806        }
807    }
808
809    #[test]
810    fn domain_inference_reports_internal_conflicts_and_unresolved_components() {
811        let mut inference = DomainInference::default();
812        let level = inference.make_set(Some(SignalDomain::Level));
813        let boolean = inference.make_set(Some(SignalDomain::Bool));
814        let select = select_knot();
815        assert!(matches!(
816            validate_variable_domains(
817                &mut inference,
818                level,
819                boolean,
820                &select,
821                ("a", "b"),
822            ),
823            Err(ValidationError::SignalDomainMismatch {
824                from_knot,
825                from_port,
826                to_knot,
827                to_port,
828                ..
829            }) if from_knot == "select" && from_port == "a" && to_knot == "select" && to_port == "b"
830        ));
831
832        let unknown = inference.make_set(None);
833        assert!(matches!(
834            validate_resolved_domain(&mut inference, unknown, &BTreeSet::new(), "delay", "out"),
835            Err(ValidationError::UnresolvedSignalDomain { knot_id, port })
836                if knot_id == "delay" && port == "out"
837        ));
838        let deferred = BTreeSet::from([unknown]);
839        assert!(
840            validate_resolved_domain(&mut inference, unknown, &deferred, "delay", "out").is_ok()
841        );
842    }
843
844    #[test]
845    fn domain_inference_accepts_unified_nodes() {
846        let mut inference = DomainInference::default();
847        let first = inference.make_set(None);
848        let second = inference.make_set(None);
849        let select = select_knot();
850        assert!(
851            validate_variable_domains(&mut inference, first, second, &select, ("a", "b")).is_ok()
852        );
853        assert!(inference.union(first, second).is_none());
854    }
855
856    #[test]
857    fn missing_catalog_domain_is_a_structured_validation_error() {
858        assert!(matches!(
859            require_port_domain(&select_knot(), PortSlot::new(u8::MAX)),
860            Err(ValidationError::InvalidParameter {
861                knot_id,
862                parameter: "domain",
863                reason: "port has no domain constraint",
864            }) if knot_id == "select"
865        ));
866    }
867}