Skip to main content

loopsmith_core/
validate.rs

1//! Validation of the four-bundle model.
2//!
3//! The rules here are the corpus rules made mechanical. The most important one
4//! is that every goal carries at least one blocking validation: a goal you
5//! cannot check is a goal the loop can never honestly finish.
6
7use 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    /// Dotted path into the config, e.g. `goals[2].name`.
20    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
98/// Section I. A phase graph that cannot be scheduled is a config bug, and it is
99/// far cheaper to say so now than to discover it when the first arrow turns out
100/// to point at a typo three hours into an unattended run.
101fn 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    // Cycles are found by trying to schedule. Reusing the scheduler rather than
157    // writing a second traversal is the point of the `DagNode` trait.
158    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    // A node pointing at a phase that does not exist would simply never run.
165    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
180/// Kahn's algorithm over phase names. `loopsmith-graph` owns the real
181/// scheduler, but `loopsmith-core` cannot depend on it (the dependency runs the
182/// other way), so cycle detection for validation lives here.
183fn 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; // already reported as an unknown name
192            }
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
294/// Artifact names a `regex_match` detector can refer to.
295///
296/// Evidence is collected from the files this config's own `file_exists`
297/// detectors name, registered under both the full path and the stem. A regex
298/// naming anything else has nothing to match and fails closed for the whole
299/// life of the loop, which reads as "the work is not done" rather than as "this
300/// check was never wired up".
301fn 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
469/// Entry, approval, and rollback rules.
470fn 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    // An approval rule nobody checks is a sign-off that never happens. That is
503    // a choice a developer may make on their own machine, and one a production
504    // loop must not be able to make quietly.
505    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
530/// Container isolation that has nothing to run in.
531fn 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        // A container has no network unless asked for one, and every hosted
549        // agent CLI needs one to reach its model. Only a local model can
550        // answer from inside a sealed container.
551        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
570/// Alert thresholds.
571fn 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
608/// The failure-class to action map.
609fn 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    // Parallel writers without isolation clobber each other — but only the ones
706    // that can actually overlap. Two unisolated builders in a dependency chain
707    // never run at the same time, and warning about them trains the reader to
708    // ignore the warning that matters.
709    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
737/// Which wave each node lands in: the longest dependency chain ending at it.
738///
739/// Nodes sharing a wave have no path between them, so they are exactly the ones
740/// that can be dispatched at the same time. `loopsmith-graph` computes the same
741/// thing with Kahn's algorithm, but the dependency runs the other way — core
742/// cannot reach it — so this is a small relaxation pass instead.
743fn 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    // The node count bounds the longest possible chain. A cyclic graph stops
748    // improving rather than looping forever; cycles are reported at plan time.
749    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    /// Every issue names a path the 1.0 config actually has.
851    ///
852    /// An issue's `field` is not decoration: the web UI routes it to the step
853    /// that owns it and scrolls to the card that edits it, and `help.rs` keys
854    /// its notes by the same string. A leftover 0.3 spelling — `stop_gates`
855    /// rather than `safety.gates.stop` — still reads fine in the terminal and
856    /// silently breaks both, which is why this walks the whole surface rather
857    /// than trusting the rename.
858    #[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        // Trip as many checks as one config can: a goal nothing blocks, a
866        // dangling cascade entry, one provider with independence on, a graph
867        // whose parallel builders share a tree, a stage nothing declares, and
868        // no budget ceiling anywhere.
869        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        // The shipped examples use every default, and CI asserts they raise
1032        // exactly one issue. None of these checks may be it.
1033        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        // Evidence is collected from the files `file_exists` detectors name.
1056        // A regex naming anything else has nothing to match and fails closed
1057        // for the life of the loop, which reads as "the work is not done"
1058        // rather than as "this check was never wired up".
1059        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        // Both spellings resolve: the file's stem and its full path.
1095        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        // `make-media -> publish` cannot overlap, so warning about it trains the
1141        // reader to ignore the warning that matters.
1142        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        // `d` depends on both a one-hop and a two-hop path; the longest wins,
1191        // which is what decides whether two nodes can share a wave.
1192        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        // Cycles are reported at plan time; this must terminate, not loop.
1208        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        // At or past `no_progress_iterations` the loop halts first, so the
1216        // perturbation would never fire and the author would never find out.
1217        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}