1use 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#[derive(Clone, Debug)]
24pub struct Budget {
25 pub max_knots: u16,
27 pub max_threads: u16,
29 pub soft_knots: u16,
31 pub soft_threads: u16,
33 pub max_chain_depth: u16,
35 pub soft_chain_depth: u16,
37 pub max_fan_out: u16,
39 pub soft_fan_out: u16,
41 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#[derive(Clone, Debug, PartialEq, Eq)]
63#[non_exhaustive]
64pub enum BudgetWarning {
65 SoftKnots {
67 count: u16,
69 soft: u16,
71 },
72 SoftThreads {
74 count: u16,
76 soft: u16,
78 },
79 SoftChainDepth {
81 depth: u16,
83 soft: u16,
85 at_knot: String,
87 },
88 SoftFanOut {
90 fan_out: u16,
92 soft: u16,
94 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
130pub struct ValidateReport {
131 pub warnings: Vec<BudgetWarning>,
133}
134impl ValidateReport {
135 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
156pub fn validate(weave: &Weave, budget: &Budget) -> Result<(), ValidationError> {
158 validate_report(weave, budget).map(|_| ())
159}
160
161pub 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
545fn 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}