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    /// The roots and every Artifact sharing a cycle with one of them, lexically ordered.
216    pub fn cycle_members(
217        &self,
218        roots: &[&ArtifactName],
219    ) -> Result<Vec<&'a ArtifactName>, GraphError> {
220        let components = roots
221            .iter()
222            .map(|root| {
223                self.artifacts
224                    .get(*root)
225                    .map(|artifact| artifact.component)
226                    .ok_or_else(|| GraphError(format!("Unknown Artifact: {root}")))
227            })
228            .collect::<Result<BTreeSet<_>, _>>()?;
229        Ok(components
230            .into_iter()
231            .flat_map(|component| self.components[component.index()].artifacts.iter().copied())
232            .collect::<BTreeSet<_>>()
233            .into_iter()
234            .collect())
235    }
236
237    /// Evaluate the whole graph. Evidence freshness is supplied by the caller;
238    /// absent entries are unreviewed, and unrelated entries are ignored.
239    pub fn evaluate(&self, evidence: &BTreeMap<EvalId, Evidence>) -> Evaluation<'_> {
240        self.evaluate_for_run(evidence, false)
241    }
242
243    /// Speculation turns operational failures into immediate blockers, without changing
244    /// the default read/verification policy of waiting for replacement GREEN evidence.
245    pub(crate) fn evaluate_for_run(
246        &self,
247        evidence: &BTreeMap<EvalId, Evidence>,
248        errors_block: bool,
249    ) -> Evaluation<'_> {
250        let mut evals: BTreeMap<EvalId, EvalEvaluation<'_>> = BTreeMap::new();
251        for &id in &self.schedule.order {
252            let gates = &self.schedule.gates[id];
253            let unmet_gates: Vec<_> = gates
254                .iter()
255                .copied()
256                .filter(|id| !evals[id.as_str()].status.passes_gate())
257                .collect();
258            // Only a Human eval's sibling machine evals share its Artifact; their ERROR blocks
259            // it too, since a person signs off only on passing machine evals.
260            let target = self.evals[id.as_str()];
261            // A BLOCKED gate passes on its own causes: the root is always a RED upstream.
262            let causes: Vec<_> = unmet_gates
263                .iter()
264                .flat_map(|&gate| {
265                    let eval = &evals[gate.as_str()];
266                    let sibling = self.evals[gate.as_str()] == target;
267                    match eval.status {
268                        EvalStatus::Red if sibling => vec![Cause::SiblingRed(gate)],
269                        EvalStatus::Red => vec![Cause::Red(gate)],
270                        EvalStatus::Error if sibling => vec![Cause::SiblingError(gate)],
271                        EvalStatus::Error if errors_block => vec![Cause::Error(gate)],
272                        EvalStatus::Blocked => {
273                            eval.causes.iter().map(|cause| cause.upstream()).collect()
274                        }
275                        _ => Vec::new(),
276                    }
277                })
278                .collect::<BTreeSet<_>>()
279                .into_iter()
280                .collect();
281            let readiness = if !causes.is_empty() {
282                Readiness::Blocked
283            } else if unmet_gates.is_empty() {
284                Readiness::Ready
285            } else {
286                Readiness::Wait
287            };
288            let provisional = gates.iter().any(|id| {
289                matches!(
290                    evals[id.as_str()].status,
291                    EvalStatus::Deferred | EvalStatus::Provisional
292                )
293            });
294            let evidence = evidence.get(id.as_str()).copied();
295            let status = match readiness {
296                Readiness::Blocked => EvalStatus::Blocked,
297                Readiness::Wait => match evidence {
298                    Some(Evidence::Current(Verdict::Red)) if errors_block => EvalStatus::Red,
299                    Some(Evidence::OperationalError) if errors_block => EvalStatus::Error,
300                    _ => EvalStatus::Wait,
301                },
302                Readiness::Ready => match evidence {
303                    Some(Evidence::Deferred) => EvalStatus::Deferred,
304                    Some(Evidence::Current(Verdict::Green)) if provisional => {
305                        EvalStatus::Provisional
306                    }
307                    Some(Evidence::Current(Verdict::Green)) => EvalStatus::Green,
308                    Some(Evidence::Current(Verdict::Red)) => EvalStatus::Red,
309                    Some(Evidence::OperationalError) => EvalStatus::Error,
310                    Some(Evidence::Stale) => EvalStatus::Stale,
311                    None => EvalStatus::Unreviewed,
312                },
313            };
314            let blocked_by = if causes.is_empty() {
315                unmet_gates
316                    .iter()
317                    .copied()
318                    .map(BlockedReference::Eval)
319                    .collect()
320            } else {
321                causes
322                    .iter()
323                    .map(|cause| BlockedReference::Eval(cause.eval()))
324                    .collect()
325            };
326            evals.insert(
327                id.clone(),
328                EvalEvaluation {
329                    readiness,
330                    status,
331                    evidence,
332                    unmet_gates,
333                    blocked_by,
334                    causes,
335                },
336            );
337        }
338        let own_satisfied: BTreeMap<_, _> = self
339            .artifacts
340            .iter()
341            .map(|(id, artifact)| {
342                let satisfied = artifact.basis
343                    || (!artifact.evals.is_empty()
344                        && artifact
345                            .evals
346                            .iter()
347                            .all(|id| evals[id.as_str()].status == EvalStatus::Green));
348                (id, satisfied)
349            })
350            .collect();
351        let mut satisfied = vec![false; self.components.len()];
352        for (index, component) in self.components.iter().enumerate() {
353            satisfied[index] = component.artifacts.iter().all(|id| own_satisfied[id])
354                && component
355                    .dependencies
356                    .iter()
357                    .all(|&dependency| satisfied[dependency.index()]);
358        }
359        let artifacts = self
360            .artifacts
361            .iter()
362            .map(|(id, artifact)| {
363                let satisfied = satisfied[artifact.component.index()];
364                let status = if satisfied {
365                    if artifact.basis {
366                        ArtifactStatus::Basis
367                    } else {
368                        ArtifactStatus::Green
369                    }
370                } else {
371                    [
372                        (EvalStatus::Error, ArtifactStatus::Error),
373                        (EvalStatus::Red, ArtifactStatus::Red),
374                        (EvalStatus::Blocked, ArtifactStatus::Blocked),
375                        (EvalStatus::Wait, ArtifactStatus::Wait),
376                        (EvalStatus::Deferred, ArtifactStatus::Deferred),
377                        (EvalStatus::Provisional, ArtifactStatus::Provisional),
378                        (EvalStatus::Stale, ArtifactStatus::Stale),
379                        (EvalStatus::Unreviewed, ArtifactStatus::Unreviewed),
380                    ]
381                    .into_iter()
382                    .find_map(|(eval_status, artifact_status)| {
383                        artifact
384                            .evals
385                            .iter()
386                            .any(|id| evals[id.as_str()].status == eval_status)
387                            .then_some(artifact_status)
388                    })
389                    .unwrap_or(if own_satisfied[id] {
390                        ArtifactStatus::Incomplete
391                    } else {
392                        ArtifactStatus::Unreviewed
393                    })
394                };
395                (
396                    id.clone(),
397                    ArtifactEvaluation {
398                        status,
399                        own_satisfied: own_satisfied[id],
400                        satisfied,
401                        passed: artifact
402                            .evals
403                            .iter()
404                            .filter(|id| evals[id.as_str()].status == EvalStatus::Green)
405                            .count(),
406                        total: artifact.evals.len(),
407                    },
408                )
409            })
410            .collect();
411        let obligations: Vec<_> = own_satisfied
412            .into_iter()
413            .filter_map(|(id, satisfied)| (!satisfied).then_some(id))
414            .collect();
415        let status = if evals.values().any(|c| c.status == EvalStatus::Error) {
416            FinalStatus::Error
417        } else if evals.values().any(|c| c.status == EvalStatus::Red) {
418            FinalStatus::Red
419        } else if obligations.is_empty() {
420            FinalStatus::Green
421        } else {
422            FinalStatus::Incomplete
423        };
424        Evaluation {
425            evals,
426            artifacts,
427            obligations,
428            status,
429        }
430    }
431
432    pub(crate) fn gates(&self, id: &EvalId) -> Vec<&'a EvalId> {
433        self.schedule.gates[id].clone()
434    }
435
436    pub fn eval_target(&self, id: &str) -> Option<&'a ArtifactName> {
437        self.evals.get(id).copied()
438    }
439}
440
441/// Current means usable for this input/run, not merely the latest stored result.
442/// Stale evidence carries no current verdict; operational failure is not RED.
443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
444pub enum Evidence {
445    Current(Verdict),
446    Deferred,
447    OperationalError,
448    Stale,
449}
450
451#[derive(Debug, Clone, Copy, PartialEq, Eq)]
452pub enum Readiness {
453    Ready,
454    Wait,
455    Blocked,
456}
457
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub enum EvalStatus {
460    Deferred,
461    Provisional,
462    Green,
463    Red,
464    Error,
465    Stale,
466    Unreviewed,
467    Wait,
468    Blocked,
469}
470
471#[derive(Debug, Clone, PartialEq, Eq)]
472pub struct EvalEvaluation<'a> {
473    /// Gate readiness only; READY never requests re-execution of existing evidence.
474    pub readiness: Readiness,
475    pub status: EvalStatus,
476    /// Retained for audit even when gates mask its effective status.
477    pub evidence: Option<Evidence>,
478    pub unmet_gates: Vec<&'a EvalId>,
479    /// BLOCKED: the root causes; otherwise the unmet gates it waits for.
480    pub blocked_by: Vec<BlockedReference<'a>>,
481    /// Why the eval is BLOCKED, followed through BLOCKED gates to the failed evals upstream;
482    /// empty unless BLOCKED.
483    pub causes: Vec<Cause<'a>>,
484}
485
486/// The failed eval at the root of a block: a RED verdict, or in a Run that treats
487/// operational failures as blockers (`--speculate`), an ERROR. A Human eval is also blocked
488/// by a RED or ERROR machine eval of its own Artifact, its sibling.
489#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
490pub enum Cause<'a> {
491    Red(&'a EvalId),
492    Error(&'a EvalId),
493    SiblingRed(&'a EvalId),
494    SiblingError(&'a EvalId),
495}
496
497impl<'a> Cause<'a> {
498    pub fn eval(self) -> &'a EvalId {
499        match self {
500            Self::Red(id) | Self::Error(id) | Self::SiblingRed(id) | Self::SiblingError(id) => id,
501        }
502    }
503
504    /// The same failure as seen from downstream, where it is an upstream dependency.
505    fn upstream(self) -> Self {
506        match self {
507            Self::SiblingRed(id) => Self::Red(id),
508            Self::SiblingError(id) => Self::Error(id),
509            cause => cause,
510        }
511    }
512}
513
514/// A blocked dependency names either material or an Eval, never an unclassified string.
515#[derive(Debug, Clone, Copy, PartialEq, Eq)]
516pub enum BlockedReference<'a> {
517    Artifact(&'a ArtifactName),
518    Eval(&'a EvalId),
519}
520impl BlockedReference<'_> {
521    pub fn as_str(&self) -> &str {
522        match self {
523            Self::Artifact(id) => id.as_str(),
524            Self::Eval(id) => id.as_str(),
525        }
526    }
527}
528impl std::fmt::Display for BlockedReference<'_> {
529    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
530        formatter.write_str(self.as_str())
531    }
532}
533
534impl EvalStatus {
535    pub fn passes_gate(self) -> bool {
536        matches!(self, Self::Green | Self::Deferred | Self::Provisional)
537    }
538}
539
540impl EvalEvaluation<'_> {
541    /// Operational errors require an explicit retry, not automatic redispatch.
542    pub fn can_execute(&self) -> bool {
543        self.readiness == Readiness::Ready && matches!(self.evidence, None | Some(Evidence::Stale))
544    }
545
546    /// Names the failed evals that block this one, RED verdicts first; `None` unless BLOCKED.
547    /// Each cause is final within a Run, but more can follow, so settlement saves it again.
548    pub fn blocked_reason(&self) -> Option<String> {
549        let names = |kind: fn(&Cause<'_>) -> bool| {
550            self.causes
551                .iter()
552                .filter(|cause| kind(cause))
553                .map(|cause| cause.eval().as_str())
554                .collect::<Vec<_>>()
555                .join(", ")
556        };
557        let parts: Vec<_> = [
558            (
559                "Sibling machine eval RED",
560                names(|cause| matches!(cause, Cause::SiblingRed(_))),
561            ),
562            (
563                "Sibling machine eval ERROR",
564                names(|cause| matches!(cause, Cause::SiblingError(_))),
565            ),
566            (
567                "Dependency verdict RED",
568                names(|cause| matches!(cause, Cause::Red(_))),
569            ),
570            (
571                "Dependency ERROR",
572                names(|cause| matches!(cause, Cause::Error(_))),
573            ),
574        ]
575        .into_iter()
576        .filter(|(_, names)| !names.is_empty())
577        .map(|(label, names)| format!("{label}: {names}"))
578        .collect();
579        (!parts.is_empty()).then(|| parts.join("; "))
580    }
581}
582
583#[derive(Debug, Clone, Copy, PartialEq, Eq)]
584pub enum ArtifactStatus {
585    Deferred,
586    Provisional,
587    Basis,
588    Green,
589    Incomplete,
590    Error,
591    Red,
592    Blocked,
593    Wait,
594    Stale,
595    Unreviewed,
596}
597
598impl From<ArtifactStatus> for crate::types::ArtifactValidationStatus {
599    fn from(status: ArtifactStatus) -> Self {
600        match status {
601            ArtifactStatus::Deferred => Self::Deferred,
602            ArtifactStatus::Provisional => Self::Provisional,
603            ArtifactStatus::Basis => Self::Basis,
604            ArtifactStatus::Green => Self::Green,
605            ArtifactStatus::Incomplete => Self::Incomplete,
606            ArtifactStatus::Error => Self::Error,
607            ArtifactStatus::Red => Self::Red,
608            ArtifactStatus::Blocked => Self::Blocked,
609            ArtifactStatus::Wait => Self::Wait,
610            ArtifactStatus::Stale => Self::Stale,
611            ArtifactStatus::Unreviewed => Self::Unreviewed,
612        }
613    }
614}
615
616#[derive(Debug, Clone, PartialEq, Eq)]
617pub struct ArtifactEvaluation {
618    pub status: ArtifactStatus,
619    pub own_satisfied: bool,
620    /// Includes all required dependencies and SCC peers, even without Evals.
621    pub satisfied: bool,
622    pub passed: usize,
623    pub total: usize,
624}
625
626/// Aggregate validation status, never a fabricated Eval verdict.
627#[derive(Debug, Clone, Copy, PartialEq, Eq)]
628pub enum FinalStatus {
629    Green,
630    Red,
631    Error,
632    Incomplete,
633}
634
635#[derive(Debug, Clone, PartialEq, Eq)]
636pub struct Evaluation<'a> {
637    pub evals: BTreeMap<EvalId, EvalEvaluation<'a>>,
638    pub artifacts: BTreeMap<ArtifactName, ArtifactEvaluation>,
639    /// Artifacts whose own obligations are unmet, not execution gates.
640    pub obligations: Vec<&'a ArtifactName>,
641    pub status: FinalStatus,
642}
643
644#[cfg(test)]
645mod tests;
646
647impl serde::Serialize for BlockedReference<'_> {
648    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
649        serializer.serialize_str(self.as_str())
650    }
651}
652
653impl From<BlockedReference<'_>> for crate::store::Blocker {
654    fn from(reference: BlockedReference<'_>) -> Self {
655        match reference {
656            BlockedReference::Artifact(id) => Self::Artifact(id.clone()),
657            BlockedReference::Eval(id) => Self::Eval(id.clone()),
658        }
659    }
660}
661
662pub(crate) mod schedule;