Skip to main content

loopsmith_core/
validate.rs

1//! A–J validation.
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.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
94/// Section I. A phase graph that cannot be scheduled is a config bug, and it is
95/// far cheaper to say so now than to discover it when the first arrow turns out
96/// to point at a typo three hours into an unattended run.
97fn 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    // Cycles are found by trying to schedule. Reusing the scheduler rather than
153    // writing a second traversal is the point of the `DagNode` trait.
154    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    // A node pointing at a phase that does not exist would simply never run.
161    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
176/// Kahn's algorithm over phase names. `loopsmith-graph` owns the real
177/// scheduler, but `loopsmith-core` cannot depend on it (the dependency runs the
178/// other way), so cycle detection for validation lives here.
179fn 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; // already reported as an unknown name
188            }
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
289/// Artifact names a `regex_match` detector can refer to.
290///
291/// Evidence is collected from the files this config's own `file_exists`
292/// detectors name, registered under both the full path and the stem. A regex
293/// naming anything else has nothing to match and fails closed for the whole
294/// life of the loop, which reads as "the work is not done" rather than as "this
295/// check was never wired up".
296fn 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    // Parallel writers without isolation clobber each other — but only the ones
534    // that can actually overlap. Two unisolated builders in a dependency chain
535    // never run at the same time, and warning about them trains the reader to
536    // ignore the warning that matters.
537    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
564/// Which wave each node lands in: the longest dependency chain ending at it.
565///
566/// Nodes sharing a wave have no path between them, so they are exactly the ones
567/// that can be dispatched at the same time. `loopsmith-graph` computes the same
568/// thing with Kahn's algorithm, but the dependency runs the other way — core
569/// cannot reach it — so this is a small relaxation pass instead.
570fn 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    // The node count bounds the longest possible chain. A cyclic graph stops
575    // improving rather than looping forever; cycles are reported at plan time.
576    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        // Evidence is collected from the files `file_exists` detectors name.
685        // A regex naming anything else has nothing to match and fails closed
686        // for the life of the loop, which reads as "the work is not done"
687        // rather than as "this check was never wired up".
688        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        // Both spellings resolve: the file's stem and its full path.
724        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        // `make-media -> publish` cannot overlap, so warning about it trains the
770        // reader to ignore the warning that matters.
771        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        // `d` depends on both a one-hop and a two-hop path; the longest wins,
820        // which is what decides whether two nodes can share a wave.
821        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        // Cycles are reported at plan time; this must terminate, not loop.
837        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        // At or past `no_progress_iterations` the loop halts first, so the
845        // perturbation would never fire and the author would never find out.
846        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}