Skip to main content

artifactize_core/
graph.rs

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