1use crate::config::*;
8use std::collections::{BTreeMap, BTreeSet};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
11pub enum Severity {
12 Warning,
13 Error,
14}
15
16#[derive(Debug, Clone)]
17pub struct Issue {
18 pub severity: Severity,
19 pub field: String,
21 pub message: String,
22}
23
24impl Issue {
25 fn err(field: impl Into<String>, message: impl Into<String>) -> Self {
26 Issue {
27 severity: Severity::Error,
28 field: field.into(),
29 message: message.into(),
30 }
31 }
32 fn warn(field: impl Into<String>, message: impl Into<String>) -> Self {
33 Issue {
34 severity: Severity::Warning,
35 field: field.into(),
36 message: message.into(),
37 }
38 }
39}
40
41#[derive(Debug, Clone, Default)]
42pub struct ValidationReport {
43 pub issues: Vec<Issue>,
44}
45
46impl ValidationReport {
47 pub fn has_errors(&self) -> bool {
48 self.issues.iter().any(|i| i.severity == Severity::Error)
49 }
50 pub fn errors(&self) -> impl Iterator<Item = &Issue> {
51 self.issues.iter().filter(|i| i.severity == Severity::Error)
52 }
53 pub fn warnings(&self) -> impl Iterator<Item = &Issue> {
54 self.issues
55 .iter()
56 .filter(|i| i.severity == Severity::Warning)
57 }
58 pub fn render(&self) -> String {
59 let mut out = String::new();
60 for i in &self.issues {
61 let tag = match i.severity {
62 Severity::Error => "error",
63 Severity::Warning => "warn ",
64 };
65 out.push_str(&format!(" {tag} {}: {}\n", i.field, i.message));
66 }
67 out
68 }
69}
70
71pub fn validate(cfg: &LoopConfig) -> ValidationReport {
72 let mut r = ValidationReport::default();
73
74 if cfg.name.trim().is_empty() {
75 r.issues.push(Issue::err("name", "must not be empty"));
76 }
77 if cfg.goals.is_empty() {
78 r.issues
79 .push(Issue::err("goals", "a loop needs at least one goal"));
80 }
81
82 let goal_names: BTreeSet<&str> = cfg.goals.iter().map(|g| g.name.as_str()).collect();
83 check_goals(cfg, &goal_names, &mut r);
84 check_pre_execution(cfg, &mut r);
85 check_validations(cfg, &goal_names, &mut r);
86 check_success(cfg, &goal_names, &mut r);
87 check_stop_gates(cfg, &mut r);
88 check_execution_guidelines(cfg, &mut r);
89 check_graph(cfg, &goal_names, &mut r);
90 check_providers(cfg, &mut r);
91 r
92}
93
94fn check_execution_guidelines(cfg: &LoopConfig, r: &mut ValidationReport) {
98 let g = &cfg.execution_guidelines;
99
100 let mut seen = BTreeSet::new();
101 for (i, item) in g.items.iter().enumerate() {
102 if item.name.trim().is_empty() {
103 r.issues
104 .push(Issue::err(format!("execution_guidelines.items[{i}].name"), "must not be empty"));
105 }
106 if !seen.insert(item.name.as_str()) {
107 r.issues.push(Issue::err(
108 format!("execution_guidelines.items[{i}].name"),
109 format!("duplicate guideline name `{}`", item.name),
110 ));
111 }
112 if item.guideline.trim().len() < 12 {
113 r.issues.push(Issue::warn(
114 format!("execution_guidelines.items[{i}].guideline"),
115 "too short to steer a node; say what this phase is for and what it must not do",
116 ));
117 }
118 }
119
120 if !g.dependency.is_empty() && g.items.is_empty() {
121 r.issues.push(Issue::err(
122 "execution_guidelines.dependency",
123 "orders guidelines that do not exist; `items` is empty",
124 ));
125 return;
126 }
127
128 let edges = match g.edges() {
129 Ok(e) => e,
130 Err(msg) => {
131 r.issues.push(Issue::err("execution_guidelines.dependency", msg));
132 return;
133 }
134 };
135 for (from, to) in &edges {
136 for name in [from, to] {
137 if !seen.contains(name.as_str()) {
138 r.issues.push(Issue::err(
139 "execution_guidelines.dependency",
140 format!("`{name}` is ordered but is not one of: {}", g.names().join(", ")),
141 ));
142 }
143 }
144 if from == to {
145 r.issues.push(Issue::err(
146 "execution_guidelines.dependency",
147 format!("`{from}` cannot come before itself"),
148 ));
149 }
150 }
151
152 if let Ok(phases) = g.phases() {
155 if let Err(e) = topo_order(&phases) {
156 r.issues.push(Issue::err("execution_guidelines.dependency", e));
157 }
158 }
159
160 for (i, n) in cfg.graph.nodes.iter().enumerate() {
162 if let Some(stage) = &n.stage {
163 if !seen.contains(stage.as_str()) {
164 r.issues.push(Issue::err(
165 format!("graph.nodes[{i}].stage"),
166 format!(
167 "`{stage}` is not a guideline in `execution_guidelines.items`; \
168 this node would never be dispatched"
169 ),
170 ));
171 }
172 }
173 }
174}
175
176fn topo_order(phases: &[crate::Phase]) -> Result<(), String> {
180 use std::collections::BTreeMap;
181 let names: BTreeSet<&str> = phases.iter().map(|p| p.name.as_str()).collect();
182 let mut indegree: BTreeMap<&str, usize> = names.iter().map(|n| (*n, 0)).collect();
183 let mut dependents: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
184 for p in phases {
185 for d in &p.depends_on {
186 if !names.contains(d.as_str()) {
187 continue; }
189 *indegree.get_mut(p.name.as_str()).unwrap() += 1;
190 dependents.entry(d.as_str()).or_default().push(p.name.as_str());
191 }
192 }
193 let mut ready: Vec<&str> = indegree
194 .iter()
195 .filter(|(_, &d)| d == 0)
196 .map(|(n, _)| *n)
197 .collect();
198 let mut placed = 0usize;
199 while let Some(n) = ready.pop() {
200 placed += 1;
201 for d in dependents.get(n).into_iter().flatten() {
202 let e = indegree.get_mut(*d).unwrap();
203 *e -= 1;
204 if *e == 0 {
205 ready.push(d);
206 }
207 }
208 }
209 if placed != phases.len() {
210 let stuck: Vec<&str> = indegree
211 .iter()
212 .filter(|(_, &d)| d > 0)
213 .map(|(n, _)| *n)
214 .collect();
215 return Err(format!(
216 "these guidelines depend on each other in a cycle: {}",
217 stuck.join(", ")
218 ));
219 }
220 Ok(())
221}
222
223fn check_goals(cfg: &LoopConfig, names: &BTreeSet<&str>, r: &mut ValidationReport) {
224 let mut seen = BTreeSet::new();
225 for (i, g) in cfg.goals.iter().enumerate() {
226 let f = format!("goals[{i}]");
227 if g.name.trim().is_empty() {
228 r.issues.push(Issue::err(format!("{f}.name"), "must not be empty"));
229 }
230 if g.name == OVERALL {
231 r.issues.push(Issue::err(
232 format!("{f}.name"),
233 format!("`{OVERALL}` is reserved for whole-loop targets"),
234 ));
235 }
236 if !seen.insert(g.name.as_str()) {
237 r.issues.push(Issue::err(
238 format!("{f}.name"),
239 format!("duplicate goal name `{}`", g.name),
240 ));
241 }
242 if g.description.trim().len() < 12 {
243 r.issues.push(Issue::warn(
244 format!("{f}.description"),
245 "very short; a vague goal produces a vague verdict",
246 ));
247 }
248 for d in &g.depends_on {
249 if !names.contains(d.as_str()) {
250 r.issues.push(Issue::err(
251 format!("{f}.depends_on"),
252 format!("unknown goal `{d}`"),
253 ));
254 }
255 }
256 if g.depends_on.iter().any(|d| d == &g.name) {
257 r.issues
258 .push(Issue::err(format!("{f}.depends_on"), "goal depends on itself"));
259 }
260 }
261}
262
263fn check_pre_execution(cfg: &LoopConfig, r: &mut ValidationReport) {
264 if cfg.pre_execution.is_empty() {
265 r.issues.push(Issue::warn(
266 "pre_execution",
267 "empty. The corpus rule is to do the task manually first — the manual runs are the spec",
268 ));
269 return;
270 }
271 let undone: Vec<&str> = cfg
272 .pre_execution
273 .iter()
274 .filter(|w| !w.done)
275 .map(|w| w.step.as_str())
276 .collect();
277 if !undone.is_empty() {
278 r.issues.push(Issue::err(
279 "pre_execution",
280 format!(
281 "{} step(s) not marked done: {}. Automating before understanding produces fast, confident garbage",
282 undone.len(),
283 undone.join("; ")
284 ),
285 ));
286 }
287}
288
289fn available_artifacts(cfg: &LoopConfig) -> BTreeSet<String> {
297 let mut out = BTreeSet::new();
298 for v in &cfg.validations {
299 if let Detector::FileExists { path, .. } = &v.detector {
300 if let Some(stem) = std::path::Path::new(path)
301 .file_stem()
302 .and_then(|s| s.to_str())
303 {
304 out.insert(stem.to_string());
305 }
306 out.insert(path.clone());
307 }
308 }
309 out
310}
311
312fn check_validations(cfg: &LoopConfig, names: &BTreeSet<&str>, r: &mut ValidationReport) {
313 let artifacts = available_artifacts(cfg);
314 let mut covered: BTreeMap<&str, usize> = BTreeMap::new();
315 for (i, v) in cfg.validations.iter().enumerate() {
316 let f = format!("validations[{i}]");
317 if v.target != OVERALL && !names.contains(v.target.as_str()) {
318 r.issues.push(Issue::err(
319 format!("{f}.target"),
320 format!("unknown target `{}` (expected a goal name or `{OVERALL}`)", v.target),
321 ));
322 }
323 if v.statement.trim().is_empty() {
324 r.issues
325 .push(Issue::err(format!("{f}.statement"), "must not be empty"));
326 }
327 match &v.detector {
328 Detector::Script { command, .. } if command.trim().is_empty() => {
329 r.issues
330 .push(Issue::err(format!("{f}.detector.command"), "must not be empty"));
331 }
332 Detector::Threshold { metric, .. } if metric.trim().is_empty() => {
333 r.issues
334 .push(Issue::err(format!("{f}.detector.metric"), "must not be empty"));
335 }
336 Detector::RegexMatch { artifact, .. } if !artifacts.contains(artifact) => {
337 r.issues.push(Issue::err(
338 format!("{f}.detector.artifact"),
339 format!(
340 "no `file_exists` detector produces `{artifact}`, so this check can \
341 never match. Artifacts are named by the file's stem or its full path; \
342 available here: {}",
343 if artifacts.is_empty() {
344 "none".to_string()
345 } else {
346 artifacts
347 .iter()
348 .cloned()
349 .collect::<Vec<_>>()
350 .join(", ")
351 }
352 ),
353 ));
354 }
355 Detector::Judge { standard, .. } => {
356 if standard.trim().is_empty() {
357 r.issues.push(Issue::err(
358 format!("{f}.detector.standard"),
359 "name the external standard the judge checks against; an unnamed standard is an opinion",
360 ));
361 }
362 if v.blocking && v.mode == Mode::Objective {
363 r.issues.push(Issue::warn(
364 f.clone(),
365 "objective mode with a model judge — prefer a script detector so the verdict is not a model's opinion",
366 ));
367 }
368 }
369 _ => {}
370 }
371 if v.blocking {
372 *covered.entry(v.target.as_str()).or_insert(0) += 1;
373 }
374 }
375
376 for g in &cfg.goals {
377 if covered.get(g.name.as_str()).copied().unwrap_or(0) == 0 {
378 r.issues.push(Issue::err(
379 format!("validations[target={}]", g.name),
380 "goal has no blocking validation; it could never be honestly satisfied",
381 ));
382 }
383 }
384 if covered.get(OVERALL).copied().unwrap_or(0) == 0 {
385 r.issues.push(Issue::warn(
386 format!("validations[target={OVERALL}]"),
387 "no overall validation; the loop can only finish per-goal",
388 ));
389 }
390}
391
392fn check_success(cfg: &LoopConfig, names: &BTreeSet<&str>, r: &mut ValidationReport) {
393 for (i, s) in cfg.success.iter().enumerate() {
394 let f = format!("success[{i}]");
395 if s.target != OVERALL && !names.contains(s.target.as_str()) {
396 r.issues.push(Issue::err(
397 format!("{f}.target"),
398 format!("unknown target `{}`", s.target),
399 ));
400 }
401 match (s.mode, s.threshold) {
402 (Mode::Percentage, None) => r.issues.push(Issue::err(
403 format!("{f}.threshold"),
404 "percentage mode requires a threshold between 0.0 and 1.0",
405 )),
406 (Mode::Percentage, Some(t)) if !(0.0..=1.0).contains(&t) => r.issues.push(Issue::err(
407 format!("{f}.threshold"),
408 format!("{t} is outside 0.0..=1.0"),
409 )),
410 _ => {}
411 }
412 }
413}
414
415fn check_stop_gates(cfg: &LoopConfig, r: &mut ValidationReport) {
416 let g = &cfg.stop_gates;
417 if g.max_iterations == 0 {
418 r.issues
419 .push(Issue::err("stop_gates.max_iterations", "must be at least 1"));
420 }
421 if g.max_iterations > 100 {
422 r.issues.push(Issue::warn(
423 "stop_gates.max_iterations",
424 "very high; a loop that cannot converge in 100 iterations usually has a miscalibrated verifier",
425 ));
426 }
427 if g.no_progress_iterations == 0 {
428 r.issues.push(Issue::warn(
429 "stop_gates.no_progress_iterations",
430 "disabled; the loop can spin without changing anything",
431 ));
432 }
433 if let Some(rand_at) = g.no_progress_iterations_randomness {
434 let field = "stop_gates.no_progress_iterations_randomness";
435 if rand_at == 0 {
436 r.issues.push(Issue::err(
437 field,
438 "must be at least 1; remove it entirely to disable perturbation",
439 ));
440 } else if g.no_progress_iterations == 0 {
441 r.issues.push(Issue::err(
442 field,
443 "no_progress_iterations is 0, so staleness is never counted and this can never fire",
444 ));
445 } else if rand_at >= g.no_progress_iterations {
446 r.issues.push(Issue::err(
447 field,
448 format!(
449 "must be less than no_progress_iterations ({}); at {rand_at} the loop halts \
450 before it ever tries something different",
451 g.no_progress_iterations
452 ),
453 ));
454 }
455 }
456 if g.max_tokens.is_none() && g.max_cost_usd.is_none() && g.max_wall_clock_seconds.is_none() {
457 r.issues.push(Issue::warn(
458 "stop_gates",
459 "no budget ceiling of any kind; an unsolvable task will bill until someone notices",
460 ));
461 }
462}
463
464fn check_graph(cfg: &LoopConfig, goal_names: &BTreeSet<&str>, r: &mut ValidationReport) {
465 let ids: BTreeSet<&str> = cfg.graph.nodes.iter().map(|n| n.id.as_str()).collect();
466 if cfg.graph.nodes.is_empty() {
467 r.issues.push(Issue::warn(
468 "graph.nodes",
469 "no nodes; the loop will run a single implicit builder per goal",
470 ));
471 return;
472 }
473 let mut seen = BTreeSet::new();
474 let mut has_judge = false;
475 for (i, n) in cfg.graph.nodes.iter().enumerate() {
476 let f = format!("graph.nodes[{i}]");
477 if !seen.insert(n.id.as_str()) {
478 r.issues
479 .push(Issue::err(format!("{f}.id"), format!("duplicate node id `{}`", n.id)));
480 }
481 if n.instruction.trim().len() < 16 {
482 r.issues.push(Issue::warn(
483 format!("{f}.instruction"),
484 "thin instruction; vague roles produce whatever the model felt like",
485 ));
486 }
487 if n.weight <= 0.0 {
488 r.issues
489 .push(Issue::err(format!("{f}.weight"), "must be greater than zero"));
490 }
491 for d in &n.depends_on {
492 if !ids.contains(d.as_str()) {
493 r.issues
494 .push(Issue::err(format!("{f}.depends_on"), format!("unknown node `{d}`")));
495 }
496 if d == &n.id {
497 r.issues
498 .push(Issue::err(format!("{f}.depends_on"), "node depends on itself"));
499 }
500 }
501 for g in &n.goals {
502 if !goal_names.contains(g.as_str()) {
503 r.issues
504 .push(Issue::err(format!("{f}.goals"), format!("unknown goal `{g}`")));
505 }
506 }
507 if let Some(p) = &n.provider {
508 if cfg.provider(p).is_none() {
509 r.issues.push(Issue::err(
510 format!("{f}.provider"),
511 format!("unknown provider `{p}`"),
512 ));
513 }
514 }
515 if n.role == Role::Judge {
516 has_judge = true;
517 }
518 }
519 if !has_judge {
520 r.issues.push(Issue::warn(
521 "graph.nodes",
522 "no judge node; verification will fall back to detectors only",
523 ));
524 }
525 if let Concurrency::Fixed { max_parallel } = cfg.graph.concurrency {
526 if max_parallel == 0 {
527 r.issues.push(Issue::err(
528 "graph.concurrency.max_parallel",
529 "must be at least 1",
530 ));
531 }
532 }
533 let parallel_possible = !matches!(cfg.graph.concurrency, Concurrency::Sequential);
538 if parallel_possible {
539 let levels = wave_levels(&cfg.graph.nodes);
540 let mut by_wave: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
541 for n in cfg
542 .graph
543 .nodes
544 .iter()
545 .filter(|n| !n.isolated && matches!(n.role, Role::Builder))
546 {
547 let wave = levels.get(n.id.as_str()).copied().unwrap_or(0);
548 by_wave.entry(wave).or_default().push(n.id.as_str());
549 }
550 for (wave, ids) in by_wave.iter().filter(|(_, ids)| ids.len() > 1) {
551 r.issues.push(Issue::warn(
552 "graph.nodes[].isolated",
553 format!(
554 "{} builder nodes run together in wave {} without worktree isolation: {}",
555 ids.len(),
556 wave + 1,
557 ids.join(", ")
558 ),
559 ));
560 }
561 }
562}
563
564fn wave_levels(nodes: &[NodeSpec]) -> BTreeMap<&str, usize> {
571 let ids: BTreeSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
572 let mut level: BTreeMap<&str, usize> = nodes.iter().map(|n| (n.id.as_str(), 0)).collect();
573
574 for _ in 0..nodes.len() {
577 let mut changed = false;
578 for n in nodes {
579 let want = n
580 .depends_on
581 .iter()
582 .filter(|d| ids.contains(d.as_str()))
583 .map(|d| level.get(d.as_str()).copied().unwrap_or(0) + 1)
584 .max()
585 .unwrap_or(0);
586 if want > level.get(n.id.as_str()).copied().unwrap_or(0) {
587 level.insert(n.id.as_str(), want);
588 changed = true;
589 }
590 }
591 if !changed {
592 break;
593 }
594 }
595 level
596}
597
598fn check_providers(cfg: &LoopConfig, r: &mut ValidationReport) {
599 if cfg.providers.providers.is_empty() {
600 r.issues.push(Issue::warn(
601 "providers.providers",
602 "none declared; nodes cannot be dispatched until at least one exists",
603 ));
604 return;
605 }
606 let mut seen = BTreeSet::new();
607 for (i, p) in cfg.providers.providers.iter().enumerate() {
608 let f = format!("providers.providers[{i}]");
609 if !seen.insert(p.id.as_str()) {
610 r.issues
611 .push(Issue::err(format!("{f}.id"), format!("duplicate provider id `{}`", p.id)));
612 }
613 if p.command.trim().is_empty() {
614 r.issues
615 .push(Issue::err(format!("{f}.command"), "must not be empty"));
616 }
617 }
618 for (tier, ids) in &cfg.providers.cascade {
619 if !matches!(tier.as_str(), "cheap" | "standard" | "strong") {
620 r.issues.push(Issue::err(
621 format!("providers.cascade.{tier}"),
622 "tier must be one of cheap, standard, strong",
623 ));
624 }
625 for id in ids {
626 if cfg.provider(id).is_none() {
627 r.issues.push(Issue::err(
628 format!("providers.cascade.{tier}"),
629 format!("unknown provider `{id}`"),
630 ));
631 }
632 }
633 }
634 if cfg.providers.enforce_judge_independence {
635 let distinct: BTreeSet<&str> = cfg
636 .providers
637 .providers
638 .iter()
639 .map(|p| p.id.as_str())
640 .collect();
641 if distinct.len() < 2 {
642 r.issues.push(Issue::warn(
643 "providers",
644 "judge independence is enforced but only one provider exists; judges will fall back to detector-only verdicts",
645 ));
646 }
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653
654 fn minimal() -> LoopConfig {
655 crate::parse_str(
656 r#"
657name: t
658goals:
659 - name: g1
660 description: a sufficiently long goal description
661validations:
662 - target: g1
663 name: v1
664 mode: objective
665 statement: tests pass
666 detector: { type: script, command: "true" }
667pre_execution:
668 - step: ran it by hand
669 done: true
670"#,
671 "test",
672 )
673 .expect("parses")
674 }
675
676 #[test]
677 fn minimal_config_is_valid() {
678 let r = validate(&minimal());
679 assert!(!r.has_errors(), "unexpected errors:\n{}", r.render());
680 }
681
682 #[test]
683 fn a_regex_naming_an_artifact_nobody_produces_is_an_error() {
684 let mut c = minimal();
689 c.validations.push(crate::Validation {
690 target: "g1".into(),
691 name: "cited".into(),
692 mode: Mode::Objective,
693 statement: "the notes carry source URLs".into(),
694 detector: Detector::RegexMatch {
695 artifact: "notes".into(),
696 pattern: "https?://".into(),
697 },
698 blocking: true,
699 });
700 let r = validate(&c);
701 assert!(r.has_errors(), "{}", r.render());
702 assert!(
703 r.render().contains("can never match"),
704 "the error must say why: {}",
705 r.render()
706 );
707 }
708
709 #[test]
710 fn a_regex_over_a_file_the_config_declares_is_accepted() {
711 let mut c = minimal();
712 c.validations.push(crate::Validation {
713 target: "g1".into(),
714 name: "notes-exist".into(),
715 mode: Mode::Objective,
716 statement: "the notes exist".into(),
717 detector: Detector::FileExists {
718 path: "out/notes.md".into(),
719 non_empty: true,
720 },
721 blocking: true,
722 });
723 for artifact in ["notes", "out/notes.md"] {
725 let mut c = c.clone();
726 c.validations.push(crate::Validation {
727 target: "g1".into(),
728 name: "cited".into(),
729 mode: Mode::Objective,
730 statement: "the notes carry source URLs".into(),
731 detector: Detector::RegexMatch {
732 artifact: artifact.into(),
733 pattern: "https?://".into(),
734 },
735 blocking: true,
736 });
737 let r = validate(&c);
738 assert!(!r.has_errors(), "`{artifact}` should resolve:\n{}", r.render());
739 }
740 }
741
742 #[test]
743 fn goal_without_blocking_validation_is_an_error() {
744 let mut c = minimal();
745 c.validations[0].blocking = false;
746 let r = validate(&c);
747 assert!(r.has_errors());
748 assert!(r.render().contains("no blocking validation"));
749 }
750
751 fn builder(id: &str, deps: &[&str]) -> NodeSpec {
752 NodeSpec {
753 id: id.into(),
754 role: Role::Builder,
755 instruction: "produce the thing described in the goal".into(),
756 depends_on: deps.iter().map(|s| s.to_string()).collect(),
757 goals: vec![],
758 tier: Tier::Standard,
759 provider: None,
760 stage: None,
761 skills: vec![],
762 weight: 1.0,
763 isolated: false,
764 }
765 }
766
767 #[test]
768 fn chained_builders_are_not_reported_as_parallel_writers() {
769 let mut c = minimal();
772 c.graph.nodes = vec![
773 builder("draft", &[]),
774 builder("make-media", &["draft"]),
775 builder("publish", &["make-media"]),
776 ];
777 c.graph.concurrency = Concurrency::Auto {
778 cap: 4,
779 min_marginal_gain: 0.05,
780 };
781 assert!(
782 !validate(&c).render().contains("without worktree isolation"),
783 "a straight chain has no parallel writers:\n{}",
784 validate(&c).render()
785 );
786 }
787
788 #[test]
789 fn builders_that_really_can_overlap_are_still_reported() {
790 let mut c = minimal();
791 c.graph.nodes = vec![
792 builder("survey", &[]),
793 builder("refactor-a", &["survey"]),
794 builder("refactor-b", &["survey"]),
795 ];
796 c.graph.concurrency = Concurrency::Auto {
797 cap: 4,
798 min_marginal_gain: 0.05,
799 };
800 let report = validate(&c).render();
801 assert!(report.contains("run together in wave 2"), "got:\n{report}");
802 assert!(report.contains("refactor-a, refactor-b"), "got:\n{report}");
803 assert!(
804 !report.contains("survey,"),
805 "the node they both depend on is not one of them:\n{report}"
806 );
807 }
808
809 #[test]
810 fn sequential_concurrency_silences_the_warning_entirely() {
811 let mut c = minimal();
812 c.graph.nodes = vec![builder("a", &[]), builder("b", &[])];
813 c.graph.concurrency = Concurrency::Sequential;
814 assert!(!validate(&c).render().contains("without worktree isolation"));
815 }
816
817 #[test]
818 fn wave_levels_follow_the_longest_chain() {
819 let nodes = vec![
822 builder("a", &[]),
823 builder("b", &["a"]),
824 builder("c", &["b"]),
825 builder("d", &["a", "c"]),
826 ];
827 let levels = wave_levels(&nodes);
828 assert_eq!(levels["a"], 0);
829 assert_eq!(levels["b"], 1);
830 assert_eq!(levels["c"], 2);
831 assert_eq!(levels["d"], 3);
832 }
833
834 #[test]
835 fn a_cycle_does_not_hang_the_wave_computation() {
836 let nodes = vec![builder("a", &["b"]), builder("b", &["a"])];
838 let levels = wave_levels(&nodes);
839 assert_eq!(levels.len(), 2);
840 }
841
842 #[test]
843 fn a_randomness_threshold_at_or_past_the_halt_point_is_refused() {
844 let mut c = minimal();
847 c.stop_gates.no_progress_iterations = 3;
848 for at in [3u32, 4] {
849 c.stop_gates.no_progress_iterations_randomness = Some(at);
850 let r = validate(&c);
851 assert!(r.has_errors(), "{at} should be refused against a halt of 3");
852 assert!(r.render().contains("must be less than no_progress_iterations"));
853 }
854
855 c.stop_gates.no_progress_iterations_randomness = Some(2);
856 assert!(
857 !validate(&c)
858 .render()
859 .contains("no_progress_iterations_randomness"),
860 "2 is below the halt point and should be accepted"
861 );
862 }
863
864 #[test]
865 fn randomness_is_refused_when_staleness_is_never_counted() {
866 let mut c = minimal();
867 c.stop_gates.no_progress_iterations = 0;
868 c.stop_gates.no_progress_iterations_randomness = Some(1);
869 let r = validate(&c);
870 assert!(r.has_errors());
871 assert!(r.render().contains("staleness is never counted"));
872 }
873
874 #[test]
875 fn an_execution_guideline_cycle_is_refused() {
876 let mut c = minimal();
877 c.execution_guidelines = ExecutionGuidelines {
878 items: vec![
879 Guideline {
880 name: "a".into(),
881 guideline: "the first phase of a cycle".into(),
882 note: None,
883 },
884 Guideline {
885 name: "b".into(),
886 guideline: "the second phase of a cycle".into(),
887 note: None,
888 },
889 ],
890 dependency: vec!["a -> b".into(), "b -> a".into()],
891 };
892 let r = validate(&c);
893 assert!(r.has_errors());
894 assert!(r.render().contains("cycle"));
895 }
896
897 #[test]
898 fn an_unknown_guideline_name_in_an_arrow_is_refused() {
899 let mut c = minimal();
900 c.execution_guidelines = ExecutionGuidelines {
901 items: vec![Guideline {
902 name: "gather".into(),
903 guideline: "collect the sources first".into(),
904 note: None,
905 }],
906 dependency: vec!["gather -> drfat".into()],
907 };
908 let r = validate(&c);
909 assert!(r.has_errors());
910 assert!(r.render().contains("drfat"));
911 }
912
913 #[test]
914 fn undone_pre_execution_blocks_the_run() {
915 let mut c = minimal();
916 c.pre_execution[0].done = false;
917 let r = validate(&c);
918 assert!(r.has_errors());
919 assert!(r.render().contains("not marked done"));
920 }
921
922 #[test]
923 fn overall_is_reserved_as_a_goal_name() {
924 let mut c = minimal();
925 c.goals[0].name = OVERALL.into();
926 let r = validate(&c);
927 assert!(r.has_errors());
928 assert!(r.render().contains("reserved"));
929 }
930
931 #[test]
932 fn unknown_validation_target_is_an_error() {
933 let mut c = minimal();
934 c.validations[0].target = "nope".into();
935 let r = validate(&c);
936 assert!(r.has_errors());
937 assert!(r.render().contains("unknown target"));
938 }
939
940 #[test]
941 fn judge_detector_requires_a_named_standard() {
942 let mut c = minimal();
943 c.validations[0].detector = Detector::Judge {
944 standard: " ".into(),
945 min_score: None,
946 };
947 let r = validate(&c);
948 assert!(r.has_errors());
949 assert!(r.render().contains("name the external standard"));
950 }
951
952 #[test]
953 fn constraint_merge_appends_rules_and_overrides_limits() {
954 let g = ConstraintSet {
955 rules: vec!["a".into()],
956 max_tokens: Some(10),
957 ..Default::default()
958 };
959 let n = ConstraintSet {
960 rules: vec!["b".into()],
961 max_tokens: Some(20),
962 ..Default::default()
963 };
964 let m = ConstraintSet::merged(&g, Some(&n));
965 assert_eq!(m.rules, vec!["a".to_string(), "b".to_string()]);
966 assert_eq!(m.max_tokens, Some(20));
967 }
968}