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.intent.goals.is_empty() {
78 r.issues
79 .push(Issue::err("intent.goals", "a loop needs at least one goal"));
80 }
81
82 let goal_names: BTreeSet<&str> = cfg.intent.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 check_gate_rules(cfg, &mut r);
92 check_recovery(cfg, &mut r);
93 check_alerts(cfg, &mut r);
94 check_containers(cfg, &mut r);
95 r
96}
97
98fn check_execution_guidelines(cfg: &LoopConfig, r: &mut ValidationReport) {
102 let g = &cfg.execution.phases;
103
104 let mut seen = BTreeSet::new();
105 for (i, item) in g.items.iter().enumerate() {
106 if item.name.trim().is_empty() {
107 r.issues
108 .push(Issue::err(format!("execution.phases.items[{i}].name"), "must not be empty"));
109 }
110 if !seen.insert(item.name.as_str()) {
111 r.issues.push(Issue::err(
112 format!("execution.phases.items[{i}].name"),
113 format!("duplicate guideline name `{}`", item.name),
114 ));
115 }
116 if item.guideline.trim().len() < 12 {
117 r.issues.push(Issue::warn(
118 format!("execution.phases.items[{i}].guideline"),
119 "too short to steer a node; say what this phase is for and what it must not do",
120 ));
121 }
122 }
123
124 if !g.dependency.is_empty() && g.items.is_empty() {
125 r.issues.push(Issue::err(
126 "execution.phases.dependency",
127 "orders guidelines that do not exist; `items` is empty",
128 ));
129 return;
130 }
131
132 let edges = match g.edges() {
133 Ok(e) => e,
134 Err(msg) => {
135 r.issues.push(Issue::err("execution.phases.dependency", msg));
136 return;
137 }
138 };
139 for (from, to) in &edges {
140 for name in [from, to] {
141 if !seen.contains(name.as_str()) {
142 r.issues.push(Issue::err(
143 "execution.phases.dependency",
144 format!("`{name}` is ordered but is not one of: {}", g.names().join(", ")),
145 ));
146 }
147 }
148 if from == to {
149 r.issues.push(Issue::err(
150 "execution.phases.dependency",
151 format!("`{from}` cannot come before itself"),
152 ));
153 }
154 }
155
156 if let Ok(phases) = g.phases() {
159 if let Err(e) = topo_order(&phases) {
160 r.issues.push(Issue::err("execution.phases.dependency", e));
161 }
162 }
163
164 for (i, n) in cfg.execution.graph.nodes.iter().enumerate() {
166 if let Some(stage) = &n.stage {
167 if !seen.contains(stage.as_str()) {
168 r.issues.push(Issue::err(
169 format!("execution.graph.nodes[{i}].stage"),
170 format!(
171 "`{stage}` is not a guideline in `execution_guidelines.items`; \
172 this node would never be dispatched"
173 ),
174 ));
175 }
176 }
177 }
178}
179
180fn topo_order(phases: &[crate::Phase]) -> Result<(), String> {
184 use std::collections::BTreeMap;
185 let names: BTreeSet<&str> = phases.iter().map(|p| p.name.as_str()).collect();
186 let mut indegree: BTreeMap<&str, usize> = names.iter().map(|n| (*n, 0)).collect();
187 let mut dependents: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
188 for p in phases {
189 for d in &p.depends_on {
190 if !names.contains(d.as_str()) {
191 continue; }
193 *indegree.get_mut(p.name.as_str()).unwrap() += 1;
194 dependents.entry(d.as_str()).or_default().push(p.name.as_str());
195 }
196 }
197 let mut ready: Vec<&str> = indegree
198 .iter()
199 .filter(|(_, &d)| d == 0)
200 .map(|(n, _)| *n)
201 .collect();
202 let mut placed = 0usize;
203 while let Some(n) = ready.pop() {
204 placed += 1;
205 for d in dependents.get(n).into_iter().flatten() {
206 let e = indegree.get_mut(*d).unwrap();
207 *e -= 1;
208 if *e == 0 {
209 ready.push(d);
210 }
211 }
212 }
213 if placed != phases.len() {
214 let stuck: Vec<&str> = indegree
215 .iter()
216 .filter(|(_, &d)| d > 0)
217 .map(|(n, _)| *n)
218 .collect();
219 return Err(format!(
220 "these guidelines depend on each other in a cycle: {}",
221 stuck.join(", ")
222 ));
223 }
224 Ok(())
225}
226
227fn check_goals(cfg: &LoopConfig, names: &BTreeSet<&str>, r: &mut ValidationReport) {
228 let mut seen = BTreeSet::new();
229 for (i, g) in cfg.intent.goals.iter().enumerate() {
230 let f = format!("intent.goals[{i}]");
231 if g.name.trim().is_empty() {
232 r.issues.push(Issue::err(format!("{f}.name"), "must not be empty"));
233 }
234 if g.name == OVERALL {
235 r.issues.push(Issue::err(
236 format!("{f}.name"),
237 format!("`{OVERALL}` is reserved for whole-loop targets"),
238 ));
239 }
240 if !seen.insert(g.name.as_str()) {
241 r.issues.push(Issue::err(
242 format!("{f}.name"),
243 format!("duplicate goal name `{}`", g.name),
244 ));
245 }
246 if g.description.trim().len() < 12 {
247 r.issues.push(Issue::warn(
248 format!("{f}.description"),
249 "very short; a vague goal produces a vague verdict",
250 ));
251 }
252 for d in &g.depends_on {
253 if !names.contains(d.as_str()) {
254 r.issues.push(Issue::err(
255 format!("{f}.depends_on"),
256 format!("unknown goal `{d}`"),
257 ));
258 }
259 }
260 if g.depends_on.iter().any(|d| d == &g.name) {
261 r.issues
262 .push(Issue::err(format!("{f}.depends_on"), "goal depends on itself"));
263 }
264 }
265}
266
267fn check_pre_execution(cfg: &LoopConfig, r: &mut ValidationReport) {
268 if cfg.intent.prerequisites.is_empty() {
269 r.issues.push(Issue::warn(
270 "intent.prerequisites",
271 "empty. The corpus rule is to do the task manually first — the manual runs are the spec",
272 ));
273 return;
274 }
275 let undone: Vec<&str> = cfg
276 .intent
277 .prerequisites
278 .iter()
279 .filter(|w| !w.done)
280 .map(|w| w.step.as_str())
281 .collect();
282 if !undone.is_empty() {
283 r.issues.push(Issue::err(
284 "intent.prerequisites",
285 format!(
286 "{} step(s) not marked done: {}. Automating before understanding produces fast, confident garbage",
287 undone.len(),
288 undone.join("; ")
289 ),
290 ));
291 }
292}
293
294fn available_artifacts(cfg: &LoopConfig) -> BTreeSet<String> {
302 let mut out = BTreeSet::new();
303 for v in &cfg.safety.checks {
304 if let Detector::FileExists { path, .. } = &v.detector {
305 if let Some(stem) = std::path::Path::new(path)
306 .file_stem()
307 .and_then(|s| s.to_str())
308 {
309 out.insert(stem.to_string());
310 }
311 out.insert(path.clone());
312 }
313 }
314 out
315}
316
317fn check_validations(cfg: &LoopConfig, names: &BTreeSet<&str>, r: &mut ValidationReport) {
318 let artifacts = available_artifacts(cfg);
319 let mut covered: BTreeMap<&str, usize> = BTreeMap::new();
320 for (i, v) in cfg.safety.checks.iter().enumerate() {
321 let f = format!("safety.checks[{i}]");
322 if v.target != OVERALL && !names.contains(v.target.as_str()) {
323 r.issues.push(Issue::err(
324 format!("{f}.target"),
325 format!("unknown target `{}` (expected a goal name or `{OVERALL}`)", v.target),
326 ));
327 }
328 if v.statement.trim().is_empty() {
329 r.issues
330 .push(Issue::err(format!("{f}.statement"), "must not be empty"));
331 }
332 match &v.detector {
333 Detector::Script { command, .. } if command.trim().is_empty() => {
334 r.issues
335 .push(Issue::err(format!("{f}.detector.command"), "must not be empty"));
336 }
337 Detector::Threshold { metric, .. } if metric.trim().is_empty() => {
338 r.issues
339 .push(Issue::err(format!("{f}.detector.metric"), "must not be empty"));
340 }
341 Detector::RegexMatch { artifact, .. } if !artifacts.contains(artifact) => {
342 r.issues.push(Issue::err(
343 format!("{f}.detector.artifact"),
344 format!(
345 "no `file_exists` detector produces `{artifact}`, so this check can \
346 never match. Artifacts are named by the file's stem or its full path; \
347 available here: {}",
348 if artifacts.is_empty() {
349 "none".to_string()
350 } else {
351 artifacts
352 .iter()
353 .cloned()
354 .collect::<Vec<_>>()
355 .join(", ")
356 }
357 ),
358 ));
359 }
360 Detector::Judge { standard, .. } => {
361 if standard.trim().is_empty() {
362 r.issues.push(Issue::err(
363 format!("{f}.detector.standard"),
364 "name the external standard the judge checks against; an unnamed standard is an opinion",
365 ));
366 }
367 if v.blocking && v.mode == Mode::Objective {
368 r.issues.push(Issue::warn(
369 f.clone(),
370 "objective mode with a model judge — prefer a script detector so the verdict is not a model's opinion",
371 ));
372 }
373 }
374 _ => {}
375 }
376 if v.blocking {
377 *covered.entry(v.target.as_str()).or_insert(0) += 1;
378 }
379 }
380
381 for g in &cfg.intent.goals {
382 if covered.get(g.name.as_str()).copied().unwrap_or(0) == 0 {
383 r.issues.push(Issue::err(
384 format!("safety.checks[target={}]", g.name),
385 "goal has no blocking validation; it could never be honestly satisfied",
386 ));
387 }
388 }
389 if covered.get(OVERALL).copied().unwrap_or(0) == 0 {
390 r.issues.push(Issue::warn(
391 format!("safety.checks[target={OVERALL}]"),
392 "no overall validation; the loop can only finish per-goal",
393 ));
394 }
395}
396
397fn check_success(cfg: &LoopConfig, names: &BTreeSet<&str>, r: &mut ValidationReport) {
398 for (i, s) in cfg.intent.success.iter().enumerate() {
399 let f = format!("intent.success[{i}]");
400 if s.target != OVERALL && !names.contains(s.target.as_str()) {
401 r.issues.push(Issue::err(
402 format!("{f}.target"),
403 format!("unknown target `{}`", s.target),
404 ));
405 }
406 match (s.mode, s.threshold) {
407 (Mode::Percentage, None) => r.issues.push(Issue::err(
408 format!("{f}.threshold"),
409 "percentage mode requires a threshold between 0.0 and 1.0",
410 )),
411 (Mode::Percentage, Some(t)) if !(0.0..=1.0).contains(&t) => r.issues.push(Issue::err(
412 format!("{f}.threshold"),
413 format!("{t} is outside 0.0..=1.0"),
414 )),
415 _ => {}
416 }
417 }
418}
419
420fn check_stop_gates(cfg: &LoopConfig, r: &mut ValidationReport) {
421 let g = &cfg.safety.gates.stop;
422 if g.max_iterations == 0 {
423 r.issues
424 .push(Issue::err("safety.gates.stop.max_iterations", "must be at least 1"));
425 }
426 if g.max_iterations > 100 {
427 r.issues.push(Issue::warn(
428 "safety.gates.stop.max_iterations",
429 "very high; a loop that cannot converge in 100 iterations usually has a miscalibrated verifier",
430 ));
431 }
432 if g.no_progress_iterations == 0 {
433 r.issues.push(Issue::warn(
434 "safety.gates.stop.no_progress_iterations",
435 "disabled; the loop can spin without changing anything",
436 ));
437 }
438 if let Some(rand_at) = g.no_progress_iterations_randomness {
439 let field = "safety.gates.stop.no_progress_iterations_randomness";
440 if rand_at == 0 {
441 r.issues.push(Issue::err(
442 field,
443 "must be at least 1; remove it entirely to disable perturbation",
444 ));
445 } else if g.no_progress_iterations == 0 {
446 r.issues.push(Issue::err(
447 field,
448 "no_progress_iterations is 0, so staleness is never counted and this can never fire",
449 ));
450 } else if rand_at >= g.no_progress_iterations {
451 r.issues.push(Issue::err(
452 field,
453 format!(
454 "must be less than no_progress_iterations ({}); at {rand_at} the loop halts \
455 before it ever tries something different",
456 g.no_progress_iterations
457 ),
458 ));
459 }
460 }
461 if g.max_tokens.is_none() && g.max_cost_usd.is_none() && g.max_wall_clock_seconds.is_none() {
462 r.issues.push(Issue::warn(
463 "safety.gates.stop",
464 "no budget ceiling of any kind; an unsolvable task will bill until someone notices",
465 ));
466 }
467}
468
469fn check_gate_rules(cfg: &LoopConfig, r: &mut ValidationReport) {
471 use crate::{GateKind, GateOutcome};
472 let gates = &cfg.safety.gates;
473 for (kind, list) in [
474 (GateKind::Entry, &gates.entry),
475 (GateKind::Approval, &gates.approval),
476 (GateKind::Rollback, &gates.rollback),
477 ] {
478 let mut seen = BTreeSet::new();
479 for (i, rule) in list.iter().enumerate() {
480 let field = format!("safety.gates.{}[{i}]", kind.as_str());
481 if rule.id.trim().is_empty() {
482 r.issues.push(Issue::err(format!("{field}.id"), "must not be empty"));
483 } else if !seen.insert(rule.id.as_str()) {
484 r.issues.push(Issue::err(
485 format!("{field}.id"),
486 format!("`{}` is used twice; the ledger could not tell them apart", rule.id),
487 ));
488 }
489 if kind != GateKind::Rollback && rule.on_fail == GateOutcome::Rollback {
490 r.issues.push(Issue::warn(
491 format!("{field}.on_fail"),
492 format!(
493 "an {} rule runs before anything has been done, so there is nothing \
494 to roll back; the run fails instead",
495 kind.as_str()
496 ),
497 ));
498 }
499 }
500 }
501
502 if !gates.approval.is_empty() && !cfg.features.human_approval {
506 let msg = "approval rules are declared but `features.human_approval` is off, so none \
507 of them is checked";
508 r.issues.push(if cfg.environment == crate::Environment::Prod {
509 Issue::err("features.human_approval", msg)
510 } else {
511 Issue::warn("features.human_approval", msg)
512 });
513 }
514 if cfg.evolution_enabled() && cfg.evolution.baseline.is_none() {
515 r.issues.push(Issue::warn(
516 "evolution.baseline",
517 "self-evolution is on with no baseline, so no proposal can ever be shown to be an \
518 improvement; they are recorded, never adoptable",
519 ));
520 }
521 if cfg.environment == crate::Environment::Prod && !cfg.evolution.require_approval {
522 r.issues.push(Issue::err(
523 "evolution.require_approval",
524 "must stay on in `prod`: a production loop may propose changes to itself, never \
525 adopt them unreviewed",
526 ));
527 }
528}
529
530fn check_containers(cfg: &LoopConfig, r: &mut ValidationReport) {
532 let graph = &cfg.execution.graph;
533 for (i, n) in graph.nodes.iter().enumerate() {
534 let crate::Isolation::Container { network, .. } = &n.isolation else {
535 continue;
536 };
537 let field = format!("execution.graph.nodes[{i}].isolation");
538 if n.isolation.container_image(graph.container_image.as_deref()).is_none() {
539 r.issues.push(Issue::warn(
540 field.clone(),
541 format!(
542 "`{}` asks for a container but no image is named here or in \
543 `execution.graph.container_image`; it will run in a worktree instead",
544 n.id
545 ),
546 ));
547 }
548 let hosted = cfg
552 .cascade_for(n.tier)
553 .iter()
554 .chain(n.provider.as_deref().and_then(|id| cfg.provider(id)).iter())
555 .any(|p| p.kind != crate::ProviderKind::Ollama);
556 if !network && hosted {
557 r.issues.push(Issue::warn(
558 format!("{field}.network"),
559 format!(
560 "`{}` runs in a container with no network, but a provider it can be \
561 routed to is hosted and needs one to reach its model; set `network: true` \
562 or route it to a local model",
563 n.id
564 ),
565 ));
566 }
567 }
568}
569
570fn check_alerts(cfg: &LoopConfig, r: &mut ValidationReport) {
572 use crate::Metric;
573 let mut seen = BTreeSet::new();
574 for (i, a) in cfg.safety.alerts.iter().enumerate() {
575 let field = format!("safety.alerts[{i}]");
576 if a.id.trim().is_empty() {
577 r.issues.push(Issue::err(format!("{field}.id"), "must not be empty"));
578 } else if !seen.insert(a.id.as_str()) {
579 r.issues.push(Issue::err(
580 format!("{field}.id"),
581 format!("`{}` is used twice", a.id),
582 ));
583 }
584 match (a.above, a.below) {
585 (None, None) => r.issues.push(Issue::err(
586 field.clone(),
587 "needs `above`, `below`, or both; without one it can never fire",
588 )),
589 (Some(hi), Some(lo)) if lo >= hi => r.issues.push(Issue::warn(
590 field.clone(),
591 format!("fires on anything above {hi} or below {lo}, which is every value"),
592 )),
593 _ => {}
594 }
595 if a.metric == Metric::ValidationPassRate {
596 for t in [a.above, a.below].into_iter().flatten() {
597 if !(0.0..=1.0).contains(&t) {
598 r.issues.push(Issue::warn(
599 field.clone(),
600 format!("validation_pass_rate is a fraction from 0 to 1; {t} is outside it"),
601 ));
602 }
603 }
604 }
605 }
606}
607
608fn check_recovery(cfg: &LoopConfig, r: &mut ValidationReport) {
610 use crate::RecoveryAction;
611 let rec = &cfg.safety.recovery;
612 for class in crate::FailureClass::ALL {
613 if let RecoveryAction::Retry { max_attempts, .. } | RecoveryAction::Revise { max_attempts } =
614 rec.action_for(class)
615 {
616 if max_attempts <= 1 {
617 r.issues.push(Issue::warn(
618 format!("safety.recovery.{}", class.key()),
619 "max_attempts counts dispatches, so 1 or 0 never tries a second time",
620 ));
621 }
622 }
623 }
624 if matches!(
625 rec.safety_violation,
626 RecoveryAction::Retry { .. } | RecoveryAction::Revise { .. } | RecoveryAction::Fallback {}
627 ) {
628 r.issues.push(Issue::err(
629 "safety.recovery.safety_violation",
630 "a safety violation cannot be retried, revised, or routed around; use stop, pause, \
631 escalate, or restore_checkpoint",
632 ));
633 }
634}
635
636fn check_graph(cfg: &LoopConfig, goal_names: &BTreeSet<&str>, r: &mut ValidationReport) {
637 let ids: BTreeSet<&str> = cfg.execution.graph.nodes.iter().map(|n| n.id.as_str()).collect();
638 if cfg.execution.graph.nodes.is_empty() {
639 r.issues.push(Issue::warn(
640 "execution.graph.nodes",
641 "no nodes; the loop will run a single implicit builder per goal",
642 ));
643 return;
644 }
645 let mut seen = BTreeSet::new();
646 let mut has_judge = false;
647 for (i, n) in cfg.execution.graph.nodes.iter().enumerate() {
648 let f = format!("execution.graph.nodes[{i}]");
649 if !seen.insert(n.id.as_str()) {
650 r.issues
651 .push(Issue::err(format!("{f}.id"), format!("duplicate node id `{}`", n.id)));
652 }
653 if n.instruction.trim().len() < 16 {
654 r.issues.push(Issue::warn(
655 format!("{f}.instruction"),
656 "thin instruction; vague roles produce whatever the model felt like",
657 ));
658 }
659 if n.weight <= 0.0 {
660 r.issues
661 .push(Issue::err(format!("{f}.weight"), "must be greater than zero"));
662 }
663 for d in &n.depends_on {
664 if !ids.contains(d.as_str()) {
665 r.issues
666 .push(Issue::err(format!("{f}.depends_on"), format!("unknown node `{d}`")));
667 }
668 if d == &n.id {
669 r.issues
670 .push(Issue::err(format!("{f}.depends_on"), "node depends on itself"));
671 }
672 }
673 for g in &n.goals {
674 if !goal_names.contains(g.as_str()) {
675 r.issues
676 .push(Issue::err(format!("{f}.goals"), format!("unknown goal `{g}`")));
677 }
678 }
679 if let Some(p) = &n.provider {
680 if cfg.provider(p).is_none() {
681 r.issues.push(Issue::err(
682 format!("{f}.provider"),
683 format!("unknown provider `{p}`"),
684 ));
685 }
686 }
687 if n.role == Role::Judge {
688 has_judge = true;
689 }
690 }
691 if !has_judge {
692 r.issues.push(Issue::warn(
693 "execution.graph.nodes",
694 "no judge node; verification will fall back to detectors only",
695 ));
696 }
697 if let Concurrency::Fixed { max_parallel } = cfg.execution.graph.concurrency {
698 if max_parallel == 0 {
699 r.issues.push(Issue::err(
700 "execution.graph.concurrency.max_parallel",
701 "must be at least 1",
702 ));
703 }
704 }
705 let parallel_possible = !matches!(cfg.execution.graph.concurrency, Concurrency::Sequential {});
710 if parallel_possible {
711 let levels = wave_levels(&cfg.execution.graph.nodes);
712 let mut by_wave: BTreeMap<usize, Vec<&str>> = BTreeMap::new();
713 for n in cfg
714 .execution
715 .graph
716 .nodes
717 .iter()
718 .filter(|n| !n.isolation.needs_worktree() && matches!(n.role, Role::Builder))
719 {
720 let wave = levels.get(n.id.as_str()).copied().unwrap_or(0);
721 by_wave.entry(wave).or_default().push(n.id.as_str());
722 }
723 for (wave, ids) in by_wave.iter().filter(|(_, ids)| ids.len() > 1) {
724 r.issues.push(Issue::warn(
725 "execution.graph.nodes[].isolation",
726 format!(
727 "{} builder nodes run together in wave {} without worktree isolation: {}",
728 ids.len(),
729 wave + 1,
730 ids.join(", ")
731 ),
732 ));
733 }
734 }
735}
736
737fn wave_levels(nodes: &[NodeSpec]) -> BTreeMap<&str, usize> {
744 let ids: BTreeSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect();
745 let mut level: BTreeMap<&str, usize> = nodes.iter().map(|n| (n.id.as_str(), 0)).collect();
746
747 for _ in 0..nodes.len() {
750 let mut changed = false;
751 for n in nodes {
752 let want = n
753 .depends_on
754 .iter()
755 .filter(|d| ids.contains(d.as_str()))
756 .map(|d| level.get(d.as_str()).copied().unwrap_or(0) + 1)
757 .max()
758 .unwrap_or(0);
759 if want > level.get(n.id.as_str()).copied().unwrap_or(0) {
760 level.insert(n.id.as_str(), want);
761 changed = true;
762 }
763 }
764 if !changed {
765 break;
766 }
767 }
768 level
769}
770
771fn check_providers(cfg: &LoopConfig, r: &mut ValidationReport) {
772 if cfg.execution.providers.providers.is_empty() {
773 r.issues.push(Issue::warn(
774 "execution.providers.providers",
775 "none declared; nodes cannot be dispatched until at least one exists",
776 ));
777 return;
778 }
779 let mut seen = BTreeSet::new();
780 for (i, p) in cfg.execution.providers.providers.iter().enumerate() {
781 let f = format!("execution.providers.providers[{i}]");
782 if !seen.insert(p.id.as_str()) {
783 r.issues
784 .push(Issue::err(format!("{f}.id"), format!("duplicate provider id `{}`", p.id)));
785 }
786 if p.command.trim().is_empty() {
787 r.issues
788 .push(Issue::err(format!("{f}.command"), "must not be empty"));
789 }
790 }
791 for (tier, ids) in &cfg.execution.providers.cascade {
792 if !matches!(tier.as_str(), "cheap" | "standard" | "strong") {
793 r.issues.push(Issue::err(
794 format!("execution.providers.cascade.{tier}"),
795 "tier must be one of cheap, standard, strong",
796 ));
797 }
798 for id in ids {
799 if cfg.provider(id).is_none() {
800 r.issues.push(Issue::err(
801 format!("execution.providers.cascade.{tier}"),
802 format!("unknown provider `{id}`"),
803 ));
804 }
805 }
806 }
807 if cfg.execution.providers.enforce_judge_independence {
808 let distinct: BTreeSet<&str> = cfg
809 .execution
810 .providers
811 .providers
812 .iter()
813 .map(|p| p.id.as_str())
814 .collect();
815 if distinct.len() < 2 {
816 r.issues.push(Issue::warn(
817 "execution.providers",
818 "judge independence is enforced but only one provider exists; judges will fall back to detector-only verdicts",
819 ));
820 }
821 }
822}
823
824#[cfg(test)]
825mod tests {
826 use super::*;
827
828 fn minimal() -> LoopConfig {
829 crate::parse_str(
830 r#"
831name: t
832goals:
833 - name: g1
834 description: a sufficiently long goal description
835validations:
836 - target: g1
837 name: v1
838 mode: objective
839 statement: tests pass
840 detector: { type: script, command: "true" }
841pre_execution:
842 - step: ran it by hand
843 done: true
844"#,
845 "test",
846 )
847 .expect("parses")
848 }
849
850 #[test]
859 fn every_issue_names_a_path_the_config_has() {
860 const ROOTS: [&str; 9] = [
861 "name", "version", "description", "environment", "intent", "execution", "safety",
862 "evolution", "features",
863 ];
864
865 let bad: LoopConfig = crate::parse_str(
870 r#"
871name: t
872intent:
873 goals:
874 - name: g1
875 description: a sufficiently long goal description
876 - name: g2
877 description: another sufficiently long goal description
878safety:
879 checks:
880 - target: g1
881 name: v1
882 mode: objective
883 statement: tests pass
884 detector: { type: regex_match, artifact: nobody-writes-this.txt, pattern: ok }
885 blocking: false
886 gates:
887 entry:
888 - id: dup
889 statement: s
890 detector: { type: file_exists, path: x }
891 - id: dup
892 statement: s
893 detector: { type: file_exists, path: x }
894execution:
895 providers:
896 providers:
897 - id: only
898 kind: custom
899 command: "true"
900 cascade:
901 cheap: [nobody]
902 phases:
903 dependency: ["a -> b"]
904 graph:
905 nodes:
906 - id: n1
907 role: builder
908 instruction: do the thing
909 stage: nowhere
910 - id: n2
911 role: builder
912 instruction: do the other thing
913"#,
914 "test",
915 )
916 .expect("parses");
917
918 let report = validate(&bad);
919 assert!(
920 report.issues.len() >= 6,
921 "this config was meant to trip most of the validator: {:?}",
922 report.issues
923 );
924 for issue in &report.issues {
925 let root = issue
926 .field
927 .split(['.', '['])
928 .next()
929 .unwrap_or(issue.field.as_str());
930 assert!(
931 ROOTS.contains(&root),
932 "`{}` is not a 1.0 config path ({})",
933 issue.field,
934 issue.message
935 );
936 }
937 }
938
939 fn errors_on(cfg: &LoopConfig, field: &str) -> usize {
940 validate(cfg).errors().filter(|i| i.field == field).count()
941 }
942
943 fn warnings_on(cfg: &LoopConfig, field: &str) -> usize {
944 validate(cfg).warnings().filter(|i| i.field == field).count()
945 }
946
947 fn rule(id: &str, on_fail: &str) -> crate::GateRule {
948 serde_yaml::from_str(&format!(
949 "id: {id}\nstatement: s\ndetector: {{ type: file_exists, path: x }}\non_fail: {on_fail}\n"
950 ))
951 .unwrap()
952 }
953
954 #[test]
955 fn a_rule_id_used_twice_is_refused() {
956 let mut c = minimal();
957 c.safety.gates.entry = vec![rule("a", "stop"), rule("a", "pause")];
958 assert_eq!(errors_on(&c, "safety.gates.entry[1].id"), 1);
959 }
960
961 #[test]
962 fn an_entry_rule_cannot_roll_back_what_has_not_run() {
963 let mut c = minimal();
964 c.safety.gates.entry = vec![rule("a", "rollback")];
965 assert_eq!(warnings_on(&c, "safety.gates.entry[0].on_fail"), 1);
966 c.safety.gates.rollback = vec![rule("b", "rollback")];
967 assert_eq!(warnings_on(&c, "safety.gates.rollback[0].on_fail"), 0);
968 }
969
970 #[test]
971 fn unchecked_approval_rules_are_refused_in_prod_and_warned_about_elsewhere() {
972 let mut c = minimal();
973 c.safety.gates.approval = vec![rule("signed-off", "pause")];
974 c.features.human_approval = false;
975 assert_eq!(warnings_on(&c, "features.human_approval"), 1);
976 c.environment = crate::Environment::Prod;
977 assert_eq!(errors_on(&c, "features.human_approval"), 1);
978 }
979
980 #[test]
981 fn evolution_without_a_baseline_is_warned_about() {
982 let mut c = minimal();
983 c.features.self_evolution = true;
984 c.evolution.enabled = true;
985 assert_eq!(warnings_on(&c, "evolution.baseline"), 1);
986 c.evolution.baseline = Some(Default::default());
987 assert_eq!(warnings_on(&c, "evolution.baseline"), 0);
988 }
989
990 #[test]
991 fn an_alert_that_can_never_fire_is_refused() {
992 let mut c = minimal();
993 c.safety.alerts = serde_yaml::from_str("- id: a\n metric: cost_usd\n").unwrap();
994 assert_eq!(errors_on(&c, "safety.alerts[0]"), 1);
995 }
996
997 #[test]
998 fn a_sealed_container_in_front_of_a_hosted_model_is_warned_about() {
999 let mut c = minimal();
1000 c.execution.providers.providers = serde_yaml::from_str(
1001 "- id: hosted\n kind: claude_code\n command: claude\n",
1002 )
1003 .unwrap();
1004 c.execution.graph.container_image = Some("img".into());
1005 c.execution.graph.nodes = serde_yaml::from_str(
1006 "- id: b\n role: builder\n instruction: do the thing well\n \
1007 isolation: { mode: container }\n",
1008 )
1009 .unwrap();
1010 assert_eq!(warnings_on(&c, "execution.graph.nodes[0].isolation.network"), 1);
1011 c.execution.graph.nodes[0].isolation = crate::Isolation::Container {
1012 image: None,
1013 network: true,
1014 };
1015 assert_eq!(warnings_on(&c, "execution.graph.nodes[0].isolation.network"), 0);
1016 }
1017
1018 #[test]
1019 fn a_safety_violation_cannot_be_retried() {
1020 let mut c = minimal();
1021 c.safety.recovery.safety_violation = crate::RecoveryAction::Retry {
1022 max_attempts: 3,
1023 base_delay_seconds: 1,
1024 backoff: crate::Backoff::Fixed,
1025 };
1026 assert_eq!(errors_on(&c, "safety.recovery.safety_violation"), 1);
1027 }
1028
1029 #[test]
1030 fn the_default_policy_raises_nothing() {
1031 let c = minimal();
1034 let report = validate(&c);
1035 assert!(
1036 report
1037 .issues
1038 .iter()
1039 .all(|i| !i.field.starts_with("safety.gates.entry")
1040 && !i.field.starts_with("safety.recovery")
1041 && i.field != "features.human_approval"),
1042 "{:?}",
1043 report.issues
1044 );
1045 }
1046
1047 #[test]
1048 fn minimal_config_is_valid() {
1049 let r = validate(&minimal());
1050 assert!(!r.has_errors(), "unexpected errors:\n{}", r.render());
1051 }
1052
1053 #[test]
1054 fn a_regex_naming_an_artifact_nobody_produces_is_an_error() {
1055 let mut c = minimal();
1060 c.safety.checks.push(crate::Validation {
1061 target: "g1".into(),
1062 name: "cited".into(),
1063 mode: Mode::Objective,
1064 statement: "the notes carry source URLs".into(),
1065 detector: Detector::RegexMatch {
1066 artifact: "notes".into(),
1067 pattern: "https?://".into(),
1068 },
1069 blocking: true,
1070 });
1071 let r = validate(&c);
1072 assert!(r.has_errors(), "{}", r.render());
1073 assert!(
1074 r.render().contains("can never match"),
1075 "the error must say why: {}",
1076 r.render()
1077 );
1078 }
1079
1080 #[test]
1081 fn a_regex_over_a_file_the_config_declares_is_accepted() {
1082 let mut c = minimal();
1083 c.safety.checks.push(crate::Validation {
1084 target: "g1".into(),
1085 name: "notes-exist".into(),
1086 mode: Mode::Objective,
1087 statement: "the notes exist".into(),
1088 detector: Detector::FileExists {
1089 path: "out/notes.md".into(),
1090 non_empty: true,
1091 },
1092 blocking: true,
1093 });
1094 for artifact in ["notes", "out/notes.md"] {
1096 let mut c = c.clone();
1097 c.safety.checks.push(crate::Validation {
1098 target: "g1".into(),
1099 name: "cited".into(),
1100 mode: Mode::Objective,
1101 statement: "the notes carry source URLs".into(),
1102 detector: Detector::RegexMatch {
1103 artifact: artifact.into(),
1104 pattern: "https?://".into(),
1105 },
1106 blocking: true,
1107 });
1108 let r = validate(&c);
1109 assert!(!r.has_errors(), "`{artifact}` should resolve:\n{}", r.render());
1110 }
1111 }
1112
1113 #[test]
1114 fn goal_without_blocking_validation_is_an_error() {
1115 let mut c = minimal();
1116 c.safety.checks[0].blocking = false;
1117 let r = validate(&c);
1118 assert!(r.has_errors());
1119 assert!(r.render().contains("no blocking validation"));
1120 }
1121
1122 fn builder(id: &str, deps: &[&str]) -> NodeSpec {
1123 NodeSpec {
1124 id: id.into(),
1125 role: Role::Builder,
1126 instruction: "produce the thing described in the goal".into(),
1127 depends_on: deps.iter().map(|s| s.to_string()).collect(),
1128 goals: vec![],
1129 tier: Tier::Standard,
1130 provider: None,
1131 stage: None,
1132 skills: vec![],
1133 weight: 1.0,
1134 isolation: Isolation::None {},
1135 }
1136 }
1137
1138 #[test]
1139 fn chained_builders_are_not_reported_as_parallel_writers() {
1140 let mut c = minimal();
1143 c.execution.graph.nodes = vec![
1144 builder("draft", &[]),
1145 builder("make-media", &["draft"]),
1146 builder("publish", &["make-media"]),
1147 ];
1148 c.execution.graph.concurrency = Concurrency::Auto {
1149 cap: 4,
1150 min_marginal_gain: 0.05,
1151 };
1152 assert!(
1153 !validate(&c).render().contains("without worktree isolation"),
1154 "a straight chain has no parallel writers:\n{}",
1155 validate(&c).render()
1156 );
1157 }
1158
1159 #[test]
1160 fn builders_that_really_can_overlap_are_still_reported() {
1161 let mut c = minimal();
1162 c.execution.graph.nodes = vec![
1163 builder("survey", &[]),
1164 builder("refactor-a", &["survey"]),
1165 builder("refactor-b", &["survey"]),
1166 ];
1167 c.execution.graph.concurrency = Concurrency::Auto {
1168 cap: 4,
1169 min_marginal_gain: 0.05,
1170 };
1171 let report = validate(&c).render();
1172 assert!(report.contains("run together in wave 2"), "got:\n{report}");
1173 assert!(report.contains("refactor-a, refactor-b"), "got:\n{report}");
1174 assert!(
1175 !report.contains("survey,"),
1176 "the node they both depend on is not one of them:\n{report}"
1177 );
1178 }
1179
1180 #[test]
1181 fn sequential_concurrency_silences_the_warning_entirely() {
1182 let mut c = minimal();
1183 c.execution.graph.nodes = vec![builder("a", &[]), builder("b", &[])];
1184 c.execution.graph.concurrency = Concurrency::Sequential {};
1185 assert!(!validate(&c).render().contains("without worktree isolation"));
1186 }
1187
1188 #[test]
1189 fn wave_levels_follow_the_longest_chain() {
1190 let nodes = vec![
1193 builder("a", &[]),
1194 builder("b", &["a"]),
1195 builder("c", &["b"]),
1196 builder("d", &["a", "c"]),
1197 ];
1198 let levels = wave_levels(&nodes);
1199 assert_eq!(levels["a"], 0);
1200 assert_eq!(levels["b"], 1);
1201 assert_eq!(levels["c"], 2);
1202 assert_eq!(levels["d"], 3);
1203 }
1204
1205 #[test]
1206 fn a_cycle_does_not_hang_the_wave_computation() {
1207 let nodes = vec![builder("a", &["b"]), builder("b", &["a"])];
1209 let levels = wave_levels(&nodes);
1210 assert_eq!(levels.len(), 2);
1211 }
1212
1213 #[test]
1214 fn a_randomness_threshold_at_or_past_the_halt_point_is_refused() {
1215 let mut c = minimal();
1218 c.safety.gates.stop.no_progress_iterations = 3;
1219 for at in [3u32, 4] {
1220 c.safety.gates.stop.no_progress_iterations_randomness = Some(at);
1221 let r = validate(&c);
1222 assert!(r.has_errors(), "{at} should be refused against a halt of 3");
1223 assert!(r.render().contains("must be less than no_progress_iterations"));
1224 }
1225
1226 c.safety.gates.stop.no_progress_iterations_randomness = Some(2);
1227 assert!(
1228 !validate(&c)
1229 .render()
1230 .contains("no_progress_iterations_randomness"),
1231 "2 is below the halt point and should be accepted"
1232 );
1233 }
1234
1235 #[test]
1236 fn randomness_is_refused_when_staleness_is_never_counted() {
1237 let mut c = minimal();
1238 c.safety.gates.stop.no_progress_iterations = 0;
1239 c.safety.gates.stop.no_progress_iterations_randomness = Some(1);
1240 let r = validate(&c);
1241 assert!(r.has_errors());
1242 assert!(r.render().contains("staleness is never counted"));
1243 }
1244
1245 #[test]
1246 fn an_execution_guideline_cycle_is_refused() {
1247 let mut c = minimal();
1248 c.execution.phases = ExecutionGuidelines {
1249 items: vec![
1250 Guideline {
1251 name: "a".into(),
1252 guideline: "the first phase of a cycle".into(),
1253 note: None,
1254 },
1255 Guideline {
1256 name: "b".into(),
1257 guideline: "the second phase of a cycle".into(),
1258 note: None,
1259 },
1260 ],
1261 dependency: vec!["a -> b".into(), "b -> a".into()],
1262 };
1263 let r = validate(&c);
1264 assert!(r.has_errors());
1265 assert!(r.render().contains("cycle"));
1266 }
1267
1268 #[test]
1269 fn an_unknown_guideline_name_in_an_arrow_is_refused() {
1270 let mut c = minimal();
1271 c.execution.phases = ExecutionGuidelines {
1272 items: vec![Guideline {
1273 name: "gather".into(),
1274 guideline: "collect the sources first".into(),
1275 note: None,
1276 }],
1277 dependency: vec!["gather -> drfat".into()],
1278 };
1279 let r = validate(&c);
1280 assert!(r.has_errors());
1281 assert!(r.render().contains("drfat"));
1282 }
1283
1284 #[test]
1285 fn undone_pre_execution_blocks_the_run() {
1286 let mut c = minimal();
1287 c.intent.prerequisites[0].done = false;
1288 let r = validate(&c);
1289 assert!(r.has_errors());
1290 assert!(r.render().contains("not marked done"));
1291 }
1292
1293 #[test]
1294 fn overall_is_reserved_as_a_goal_name() {
1295 let mut c = minimal();
1296 c.intent.goals[0].name = OVERALL.into();
1297 let r = validate(&c);
1298 assert!(r.has_errors());
1299 assert!(r.render().contains("reserved"));
1300 }
1301
1302 #[test]
1303 fn unknown_validation_target_is_an_error() {
1304 let mut c = minimal();
1305 c.safety.checks[0].target = "nope".into();
1306 let r = validate(&c);
1307 assert!(r.has_errors());
1308 assert!(r.render().contains("unknown target"));
1309 }
1310
1311 #[test]
1312 fn judge_detector_requires_a_named_standard() {
1313 let mut c = minimal();
1314 c.safety.checks[0].detector = Detector::Judge {
1315 standard: " ".into(),
1316 min_score: None,
1317 };
1318 let r = validate(&c);
1319 assert!(r.has_errors());
1320 assert!(r.render().contains("name the external standard"));
1321 }
1322
1323 #[test]
1324 fn constraint_merge_appends_rules_and_overrides_limits() {
1325 let g = ConstraintSet {
1326 rules: vec!["a".into()],
1327 max_tokens: Some(10),
1328 ..Default::default()
1329 };
1330 let n = ConstraintSet {
1331 rules: vec!["b".into()],
1332 max_tokens: Some(20),
1333 ..Default::default()
1334 };
1335 let m = ConstraintSet::merged(&g, Some(&n));
1336 assert_eq!(m.rules, vec!["a".to_string(), "b".to_string()]);
1337 assert_eq!(m.max_tokens, Some(20));
1338 }
1339}