Skip to main content

artifactize_core/
graph.rs

1//! Dependency edges, strongly connected components, gates, and obligations.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use petgraph::{algo::kosaraju_scc, graph::DiGraph};
6use thiserror::Error;
7
8use crate::{
9    config::{ArtifactName, EvalId, RepoConfig},
10    runtime::Verdict,
11};
12
13#[derive(Debug, Error)]
14#[error("{0}")]
15pub struct GraphError(pub String);
16
17/// A strongly-connected component's position in `Graph::components`, guarding it from
18/// arbitrary `usize` arithmetic: dependency-first, so a lower id is never a later
19/// component's dependency.
20#[derive(
21    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize,
22)]
23#[serde(transparent)]
24pub struct ComponentId(usize);
25
26impl ComponentId {
27    /// Live graph and legacy saved components derive their identity from a list position.
28    /// Saved explicit IDs deserialize as the same unsigned integer, never arithmetic indices.
29    pub(crate) fn from_position(index: usize) -> Self {
30        Self(index)
31    }
32
33    pub(crate) fn index(self) -> usize {
34        self.0
35    }
36}
37
38impl std::fmt::Display for ComponentId {
39    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        self.0.fmt(f)
41    }
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct ArtifactNode<'a> {
46    pub basis: bool,
47    pub evals: Vec<&'a EvalId>,
48    pub component: ComponentId,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Component<'a> {
53    pub artifacts: Vec<&'a ArtifactName>,
54    /// Direct condensation dependencies, always earlier in the component list.
55    pub dependencies: Vec<ComponentId>,
56    /// Aggregate external obligations for the material projection, not execution gates.
57    /// Each Eval’s actual gates come from the Eval-level schedule.
58    pub gates: Vec<&'a EvalId>,
59}
60
61#[derive(Debug)]
62pub struct Graph<'a> {
63    artifacts: BTreeMap<ArtifactName, ArtifactNode<'a>>,
64    evals: BTreeMap<EvalId, &'a ArtifactName>,
65    components: Vec<Component<'a>>,
66    schedule: schedule::Schedule<'a>,
67}
68
69impl<'a> Graph<'a> {
70    /// Build from scope's resolved relations without reading files or running code.
71    pub fn new(config: &'a RepoConfig) -> Result<Self, GraphError> {
72        let mut graph = DiGraph::<&ArtifactName, ()>::new();
73        let nodes: BTreeMap<_, _> = config
74            .artifacts
75            .keys()
76            .map(|id| (id, graph.add_node(id)))
77            .collect();
78        let mut artifacts: BTreeMap<_, _> = config
79            .artifacts
80            .iter()
81            .map(|(id, artifact)| {
82                (
83                    id.clone(),
84                    ArtifactNode {
85                        basis: artifact.basis == Some(true),
86                        evals: Vec::new(),
87                        component: ComponentId(0),
88                    },
89                )
90            })
91            .collect();
92        let mut evals = BTreeMap::new();
93        for eval in &config.evals {
94            let artifact = artifacts
95                .get_mut(&eval.target)
96                .ok_or_else(|| GraphError(format!("Unknown Eval target: {}", eval.target)))?;
97            if artifact.basis {
98                return Err(GraphError(format!(
99                    "Basis Artifact {} cannot own Evals.",
100                    eval.target
101                )));
102            }
103            if evals.insert(eval.id.clone(), &eval.target).is_some() {
104                return Err(GraphError(format!("Duplicate Eval: {}", eval.id)));
105            }
106            artifact.evals.push(&eval.id);
107        }
108        for artifact in artifacts.values_mut() {
109            artifact.evals.sort_unstable();
110        }
111        let mut edges = BTreeSet::new();
112        for relation in &config.relations {
113            for id in [&relation.source, &relation.target] {
114                if !nodes.contains_key(id) {
115                    return Err(GraphError(format!("Unknown relation Artifact: {id}")));
116                }
117            }
118            edges.insert((&relation.target, &relation.source));
119        }
120        // Consumer -> input makes iterative SCC traversal dependency-first.
121        for &(consumer, dependency) in &edges {
122            graph.add_edge(nodes[consumer], nodes[dependency], ());
123        }
124        let mut components: Vec<_> = kosaraju_scc(&graph)
125            .into_iter()
126            .map(|members| {
127                let mut artifacts: Vec<_> = members.into_iter().map(|node| graph[node]).collect();
128                artifacts.sort_unstable();
129                Component {
130                    artifacts,
131                    dependencies: Vec::new(),
132                    gates: Vec::new(),
133                }
134            })
135            .collect();
136        for (index, component) in components.iter().enumerate() {
137            for id in &component.artifacts {
138                artifacts.get_mut(id.as_str()).unwrap().component = ComponentId(index);
139            }
140        }
141        let mut dependencies = vec![BTreeSet::new(); components.len()];
142        for (consumer, dependency) in edges {
143            let consumer = artifacts[consumer.as_str()].component;
144            let dependency = artifacts[dependency.as_str()].component;
145            if consumer != dependency {
146                debug_assert!(dependency < consumer);
147                dependencies[consumer.index()].insert(dependency);
148            }
149        }
150        for (index, dependencies) in dependencies.into_iter().enumerate() {
151            let mut gates: Vec<_> = dependencies
152                .iter()
153                .flat_map(|&dependency| &components[dependency.index()].artifacts)
154                .flat_map(|id| &artifacts[id.as_str()].evals)
155                .copied()
156                .collect();
157            gates.sort_unstable();
158            components[index].dependencies = dependencies.into_iter().collect();
159            components[index].gates = gates;
160        }
161        let schedule = schedule::Schedule::new(
162            config.evals.iter().map(|eval| schedule::Input {
163                id: &eval.id,
164                target: &eval.target,
165                deps: eval.deps.iter().collect(),
166                human: eval.declaration.profile().kind() == crate::config::ProfileKind::Human,
167            }),
168            config.artifacts.iter().flat_map(|(parent, artifact)| {
169                artifact.children.values().map(move |child| (parent, child))
170            }),
171        );
172        Ok(Self {
173            artifacts,
174            evals,
175            components,
176            schedule,
177        })
178    }
179
180    pub fn artifacts(&self) -> &BTreeMap<ArtifactName, ArtifactNode<'a>> {
181        &self.artifacts
182    }
183
184    /// Components are dependency-first; members and gates are lexically ordered.
185    pub fn components(&self) -> &[Component<'a>] {
186        &self.components
187    }
188
189    /// Required material and verification, including roots and all cycle peers.
190    pub fn dependency_closure(
191        &self,
192        roots: &[&ArtifactName],
193    ) -> Result<Vec<&'a ArtifactName>, GraphError> {
194        let mut pending = Vec::new();
195        for root in roots {
196            let artifact = self
197                .artifacts
198                .get(*root)
199                .ok_or_else(|| GraphError(format!("Unknown Artifact: {root}")))?;
200            pending.push(artifact.component);
201        }
202        let mut visited = BTreeSet::new();
203        let mut required = BTreeSet::new();
204        while let Some(index) = pending.pop() {
205            if !visited.insert(index) {
206                continue;
207            }
208            let component = &self.components[index.index()];
209            required.extend(component.artifacts.iter().copied());
210            pending.extend(&component.dependencies);
211        }
212        Ok(required.into_iter().collect())
213    }
214
215    /// Evaluate the whole graph. Evidence freshness is supplied by the caller;
216    /// absent entries are unreviewed, and unrelated entries are ignored.
217    pub fn evaluate(&self, evidence: &BTreeMap<EvalId, Evidence>) -> Evaluation<'_> {
218        self.evaluate_for_run(evidence, false)
219    }
220
221    /// Speculation turns operational failures into immediate blockers, without changing
222    /// the default read/verification policy of waiting for replacement GREEN evidence.
223    pub(crate) fn evaluate_for_run(
224        &self,
225        evidence: &BTreeMap<EvalId, Evidence>,
226        errors_block: bool,
227    ) -> Evaluation<'_> {
228        let mut evals: BTreeMap<EvalId, EvalEvaluation<'_>> = BTreeMap::new();
229        for &id in &self.schedule.order {
230            let gates = &self.schedule.gates[id];
231            let unmet_gates: Vec<_> = gates
232                .iter()
233                .copied()
234                .filter(|id| !evals[id.as_str()].status.passes_gate())
235                .collect();
236            // Only a Human eval's sibling machine evals share its Artifact; their ERROR blocks
237            // it too, since a person signs off only on passing machine evals.
238            let target = self.evals[id.as_str()];
239            // A BLOCKED gate passes on its own causes: the root is always a RED upstream.
240            let causes: Vec<_> = unmet_gates
241                .iter()
242                .flat_map(|&gate| {
243                    let eval = &evals[gate.as_str()];
244                    let sibling = self.evals[gate.as_str()] == target;
245                    match eval.status {
246                        EvalStatus::Red if sibling => vec![Cause::SiblingRed(gate)],
247                        EvalStatus::Red => vec![Cause::Red(gate)],
248                        EvalStatus::Error if sibling => vec![Cause::SiblingError(gate)],
249                        EvalStatus::Error if errors_block => vec![Cause::Error(gate)],
250                        EvalStatus::Blocked => {
251                            eval.causes.iter().map(|cause| cause.upstream()).collect()
252                        }
253                        _ => Vec::new(),
254                    }
255                })
256                .collect::<BTreeSet<_>>()
257                .into_iter()
258                .collect();
259            let readiness = if !causes.is_empty() {
260                Readiness::Blocked
261            } else if unmet_gates.is_empty() {
262                Readiness::Ready
263            } else {
264                Readiness::Wait
265            };
266            let provisional = gates.iter().any(|id| {
267                matches!(
268                    evals[id.as_str()].status,
269                    EvalStatus::Deferred | EvalStatus::Provisional
270                )
271            });
272            let evidence = evidence.get(id.as_str()).copied();
273            let status = match readiness {
274                Readiness::Blocked => EvalStatus::Blocked,
275                Readiness::Wait => match evidence {
276                    Some(Evidence::Current(Verdict::Red)) if errors_block => EvalStatus::Red,
277                    Some(Evidence::OperationalError) if errors_block => EvalStatus::Error,
278                    _ => EvalStatus::Wait,
279                },
280                Readiness::Ready => match evidence {
281                    Some(Evidence::Deferred) => EvalStatus::Deferred,
282                    Some(Evidence::Current(Verdict::Green)) if provisional => {
283                        EvalStatus::Provisional
284                    }
285                    Some(Evidence::Current(Verdict::Green)) => EvalStatus::Green,
286                    Some(Evidence::Current(Verdict::Red)) => EvalStatus::Red,
287                    Some(Evidence::OperationalError) => EvalStatus::Error,
288                    Some(Evidence::Stale) => EvalStatus::Stale,
289                    None => EvalStatus::Unreviewed,
290                },
291            };
292            let blocked_by = if causes.is_empty() {
293                unmet_gates
294                    .iter()
295                    .copied()
296                    .map(BlockedReference::Eval)
297                    .collect()
298            } else {
299                causes
300                    .iter()
301                    .map(|cause| BlockedReference::Eval(cause.eval()))
302                    .collect()
303            };
304            evals.insert(
305                id.clone(),
306                EvalEvaluation {
307                    readiness,
308                    status,
309                    evidence,
310                    unmet_gates,
311                    blocked_by,
312                    causes,
313                },
314            );
315        }
316        let own_satisfied: BTreeMap<_, _> = self
317            .artifacts
318            .iter()
319            .map(|(id, artifact)| {
320                let satisfied = artifact.basis
321                    || (!artifact.evals.is_empty()
322                        && artifact
323                            .evals
324                            .iter()
325                            .all(|id| evals[id.as_str()].status == EvalStatus::Green));
326                (id, satisfied)
327            })
328            .collect();
329        let mut satisfied = vec![false; self.components.len()];
330        for (index, component) in self.components.iter().enumerate() {
331            satisfied[index] = component.artifacts.iter().all(|id| own_satisfied[id])
332                && component
333                    .dependencies
334                    .iter()
335                    .all(|&dependency| satisfied[dependency.index()]);
336        }
337        let artifacts = self
338            .artifacts
339            .iter()
340            .map(|(id, artifact)| {
341                let satisfied = satisfied[artifact.component.index()];
342                let status = if satisfied {
343                    if artifact.basis {
344                        ArtifactStatus::Basis
345                    } else {
346                        ArtifactStatus::Green
347                    }
348                } else {
349                    [
350                        (EvalStatus::Error, ArtifactStatus::Error),
351                        (EvalStatus::Red, ArtifactStatus::Red),
352                        (EvalStatus::Blocked, ArtifactStatus::Blocked),
353                        (EvalStatus::Wait, ArtifactStatus::Wait),
354                        (EvalStatus::Deferred, ArtifactStatus::Deferred),
355                        (EvalStatus::Provisional, ArtifactStatus::Provisional),
356                        (EvalStatus::Stale, ArtifactStatus::Stale),
357                        (EvalStatus::Unreviewed, ArtifactStatus::Unreviewed),
358                    ]
359                    .into_iter()
360                    .find_map(|(eval_status, artifact_status)| {
361                        artifact
362                            .evals
363                            .iter()
364                            .any(|id| evals[id.as_str()].status == eval_status)
365                            .then_some(artifact_status)
366                    })
367                    .unwrap_or(if own_satisfied[id] {
368                        ArtifactStatus::Incomplete
369                    } else {
370                        ArtifactStatus::Unreviewed
371                    })
372                };
373                (
374                    id.clone(),
375                    ArtifactEvaluation {
376                        status,
377                        own_satisfied: own_satisfied[id],
378                        satisfied,
379                        passed: artifact
380                            .evals
381                            .iter()
382                            .filter(|id| evals[id.as_str()].status == EvalStatus::Green)
383                            .count(),
384                        total: artifact.evals.len(),
385                    },
386                )
387            })
388            .collect();
389        let obligations: Vec<_> = own_satisfied
390            .into_iter()
391            .filter_map(|(id, satisfied)| (!satisfied).then_some(id))
392            .collect();
393        let status = if evals.values().any(|c| c.status == EvalStatus::Error) {
394            FinalStatus::Error
395        } else if evals.values().any(|c| c.status == EvalStatus::Red) {
396            FinalStatus::Red
397        } else if obligations.is_empty() {
398            FinalStatus::Green
399        } else {
400            FinalStatus::Incomplete
401        };
402        Evaluation {
403            evals,
404            artifacts,
405            obligations,
406            status,
407        }
408    }
409
410    pub(crate) fn gates(&self, id: &EvalId) -> Vec<&'a EvalId> {
411        self.schedule.gates[id].clone()
412    }
413
414    pub fn eval_target(&self, id: &str) -> Option<&'a ArtifactName> {
415        self.evals.get(id).copied()
416    }
417}
418
419/// Current means usable for this input/run, not merely the latest stored result.
420/// Stale evidence carries no current verdict; operational failure is not RED.
421#[derive(Debug, Clone, Copy, PartialEq, Eq)]
422pub enum Evidence {
423    Current(Verdict),
424    Deferred,
425    OperationalError,
426    Stale,
427}
428
429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
430pub enum Readiness {
431    Ready,
432    Wait,
433    Blocked,
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437pub enum EvalStatus {
438    Deferred,
439    Provisional,
440    Green,
441    Red,
442    Error,
443    Stale,
444    Unreviewed,
445    Wait,
446    Blocked,
447}
448
449#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct EvalEvaluation<'a> {
451    /// Gate readiness only; READY never requests re-execution of existing evidence.
452    pub readiness: Readiness,
453    pub status: EvalStatus,
454    /// Retained for audit even when gates mask its effective status.
455    pub evidence: Option<Evidence>,
456    pub unmet_gates: Vec<&'a EvalId>,
457    /// BLOCKED: the root causes; otherwise the unmet gates it waits for.
458    pub blocked_by: Vec<BlockedReference<'a>>,
459    /// Why the eval is BLOCKED, followed through BLOCKED gates to the failed evals upstream;
460    /// empty unless BLOCKED.
461    pub causes: Vec<Cause<'a>>,
462}
463
464/// The failed eval at the root of a block: a RED verdict, or in a Run that treats
465/// operational failures as blockers (`--speculate`), an ERROR. A Human eval is also blocked
466/// by a RED or ERROR machine eval of its own Artifact, its sibling.
467#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
468pub enum Cause<'a> {
469    Red(&'a EvalId),
470    Error(&'a EvalId),
471    SiblingRed(&'a EvalId),
472    SiblingError(&'a EvalId),
473}
474
475impl<'a> Cause<'a> {
476    pub fn eval(self) -> &'a EvalId {
477        match self {
478            Self::Red(id) | Self::Error(id) | Self::SiblingRed(id) | Self::SiblingError(id) => id,
479        }
480    }
481
482    /// The same failure as seen from downstream, where it is an upstream dependency.
483    fn upstream(self) -> Self {
484        match self {
485            Self::SiblingRed(id) => Self::Red(id),
486            Self::SiblingError(id) => Self::Error(id),
487            cause => cause,
488        }
489    }
490}
491
492/// A blocked dependency names either material or an Eval, never an unclassified string.
493#[derive(Debug, Clone, Copy, PartialEq, Eq)]
494pub enum BlockedReference<'a> {
495    Artifact(&'a ArtifactName),
496    Eval(&'a EvalId),
497}
498impl BlockedReference<'_> {
499    pub fn as_str(&self) -> &str {
500        match self {
501            Self::Artifact(id) => id.as_str(),
502            Self::Eval(id) => id.as_str(),
503        }
504    }
505}
506impl std::fmt::Display for BlockedReference<'_> {
507    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
508        formatter.write_str(self.as_str())
509    }
510}
511
512impl EvalStatus {
513    pub fn passes_gate(self) -> bool {
514        matches!(self, Self::Green | Self::Deferred | Self::Provisional)
515    }
516}
517
518impl EvalEvaluation<'_> {
519    /// Operational errors require an explicit retry, not automatic redispatch.
520    pub fn can_execute(&self) -> bool {
521        self.readiness == Readiness::Ready && matches!(self.evidence, None | Some(Evidence::Stale))
522    }
523
524    /// Names the failed evals that block this one, RED verdicts first; `None` unless BLOCKED.
525    /// Each cause is final within a Run, but more can follow, so settlement saves it again.
526    pub fn blocked_reason(&self) -> Option<String> {
527        let names = |kind: fn(&Cause<'_>) -> bool| {
528            self.causes
529                .iter()
530                .filter(|cause| kind(cause))
531                .map(|cause| cause.eval().as_str())
532                .collect::<Vec<_>>()
533                .join(", ")
534        };
535        let parts: Vec<_> = [
536            (
537                "Sibling machine eval RED",
538                names(|cause| matches!(cause, Cause::SiblingRed(_))),
539            ),
540            (
541                "Sibling machine eval ERROR",
542                names(|cause| matches!(cause, Cause::SiblingError(_))),
543            ),
544            (
545                "Dependency verdict RED",
546                names(|cause| matches!(cause, Cause::Red(_))),
547            ),
548            (
549                "Dependency ERROR",
550                names(|cause| matches!(cause, Cause::Error(_))),
551            ),
552        ]
553        .into_iter()
554        .filter(|(_, names)| !names.is_empty())
555        .map(|(label, names)| format!("{label}: {names}"))
556        .collect();
557        (!parts.is_empty()).then(|| parts.join("; "))
558    }
559}
560
561#[derive(Debug, Clone, Copy, PartialEq, Eq)]
562pub enum ArtifactStatus {
563    Deferred,
564    Provisional,
565    Basis,
566    Green,
567    Incomplete,
568    Error,
569    Red,
570    Blocked,
571    Wait,
572    Stale,
573    Unreviewed,
574}
575
576impl From<ArtifactStatus> for crate::types::ArtifactValidationStatus {
577    fn from(status: ArtifactStatus) -> Self {
578        match status {
579            ArtifactStatus::Deferred => Self::Deferred,
580            ArtifactStatus::Provisional => Self::Provisional,
581            ArtifactStatus::Basis => Self::Basis,
582            ArtifactStatus::Green => Self::Green,
583            ArtifactStatus::Incomplete => Self::Incomplete,
584            ArtifactStatus::Error => Self::Error,
585            ArtifactStatus::Red => Self::Red,
586            ArtifactStatus::Blocked => Self::Blocked,
587            ArtifactStatus::Wait => Self::Wait,
588            ArtifactStatus::Stale => Self::Stale,
589            ArtifactStatus::Unreviewed => Self::Unreviewed,
590        }
591    }
592}
593
594#[derive(Debug, Clone, PartialEq, Eq)]
595pub struct ArtifactEvaluation {
596    pub status: ArtifactStatus,
597    pub own_satisfied: bool,
598    /// Includes all required dependencies and SCC peers, even without Evals.
599    pub satisfied: bool,
600    pub passed: usize,
601    pub total: usize,
602}
603
604/// Aggregate validation status, never a fabricated Eval verdict.
605#[derive(Debug, Clone, Copy, PartialEq, Eq)]
606pub enum FinalStatus {
607    Green,
608    Red,
609    Error,
610    Incomplete,
611}
612
613#[derive(Debug, Clone, PartialEq, Eq)]
614pub struct Evaluation<'a> {
615    pub evals: BTreeMap<EvalId, EvalEvaluation<'a>>,
616    pub artifacts: BTreeMap<ArtifactName, ArtifactEvaluation>,
617    /// Artifacts whose own obligations are unmet, not execution gates.
618    pub obligations: Vec<&'a ArtifactName>,
619    pub status: FinalStatus,
620}
621
622#[cfg(test)]
623mod tests;
624
625impl serde::Serialize for BlockedReference<'_> {
626    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
627        serializer.serialize_str(self.as_str())
628    }
629}
630
631impl From<BlockedReference<'_>> for crate::store::Blocker {
632    fn from(reference: BlockedReference<'_>) -> Self {
633        match reference {
634            BlockedReference::Artifact(id) => Self::Artifact(id.clone()),
635            BlockedReference::Eval(id) => Self::Eval(id.clone()),
636        }
637    }
638}
639
640pub(crate) mod schedule;