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_with_policy(evidence, false)
257    }
258
259    /// Gate bypass changes readiness, never the required final obligations.
260    pub fn evaluate_with_policy(
261        &self,
262        evidence: &BTreeMap<EvalId, Evidence>,
263        ignore_gates: bool,
264    ) -> Evaluation<'_> {
265        let mut evals: BTreeMap<EvalId, EvalEvaluation<'_>> = BTreeMap::new();
266        for component in &self.components {
267            let unmet_gates: Vec<_> = component
268                .gates
269                .iter()
270                .copied()
271                .filter(|id| !ignore_gates && evals[id.as_str()].status != EvalStatus::Green)
272                .collect();
273            let readiness = if unmet_gates.iter().any(|id| {
274                matches!(
275                    evals[id.as_str()].status,
276                    EvalStatus::Red | EvalStatus::Blocked
277                )
278            }) {
279                Readiness::Blocked
280            } else if unmet_gates.is_empty() {
281                Readiness::Ready
282            } else {
283                Readiness::Wait
284            };
285            let ordinary = component
286                .artifacts
287                .iter()
288                .flat_map(|id| &self.artifacts[id.as_str()].evals)
289                .copied()
290                .filter(|id| !self.derived.contains_key(id.as_str()));
291            let derived = self
292                .derived_order
293                .iter()
294                .copied()
295                .filter(|id| component.artifacts.contains(&self.evals[id.as_str()]));
296            for id in ordinary.chain(derived) {
297                if let Some(targets) = self.derived.get(id.as_str()) {
298                    if evidence.get(id.as_str()) == Some(&Evidence::OperationalError) {
299                        evals.insert(
300                            id.clone(),
301                            EvalEvaluation {
302                                status: EvalStatus::Error,
303                                readiness: Readiness::Ready,
304                                evidence: Some(Evidence::OperationalError),
305                                unmet_gates: Vec::new(),
306                                blocked_by: Vec::new(),
307                                derived: true,
308                            },
309                        );
310                        continue;
311                    }
312                    let unmet: Vec<_> = targets
313                        .iter()
314                        .flat_map(|target| &self.artifacts[target.as_str()].evals)
315                        .copied()
316                        .filter(|id| evals[id.as_str()].status != EvalStatus::Green)
317                        .collect();
318                    let mut blocked_by = Vec::new();
319                    for target in targets {
320                        let pending: Vec<_> = self.artifacts[target.as_str()]
321                            .evals
322                            .iter()
323                            .copied()
324                            .filter(|id| evals[id.as_str()].status != EvalStatus::Green)
325                            .collect();
326                        if !pending.is_empty() {
327                            blocked_by.push(BlockedReference::Artifact(target));
328                            blocked_by.extend(pending.into_iter().map(BlockedReference::Eval));
329                        }
330                    }
331                    let readiness = if unmet.iter().any(|id| {
332                        matches!(
333                            evals[id.as_str()].status,
334                            EvalStatus::Red | EvalStatus::Blocked
335                        )
336                    }) {
337                        Readiness::Blocked
338                    } else if unmet.is_empty() {
339                        Readiness::Ready
340                    } else {
341                        Readiness::Wait
342                    };
343                    evals.insert(
344                        id.clone(),
345                        EvalEvaluation {
346                            status: match readiness {
347                                Readiness::Ready => EvalStatus::Green,
348                                Readiness::Blocked => EvalStatus::Blocked,
349                                Readiness::Wait => EvalStatus::Wait,
350                            },
351                            readiness,
352                            evidence: None,
353                            unmet_gates: unmet,
354                            blocked_by,
355                            derived: true,
356                        },
357                    );
358                    continue;
359                }
360                let evidence = evidence.get(id.as_str()).copied();
361                let status = match readiness {
362                    Readiness::Blocked => EvalStatus::Blocked,
363                    Readiness::Wait => EvalStatus::Wait,
364                    Readiness::Ready => match evidence {
365                        Some(Evidence::Current(Verdict::Green)) => EvalStatus::Green,
366                        Some(Evidence::Current(Verdict::Red)) => EvalStatus::Red,
367                        Some(Evidence::OperationalError) => EvalStatus::Error,
368                        Some(Evidence::Stale) => EvalStatus::Stale,
369                        None => EvalStatus::Unreviewed,
370                    },
371                };
372                evals.insert(
373                    id.clone(),
374                    EvalEvaluation {
375                        readiness,
376                        status,
377                        evidence,
378                        unmet_gates: unmet_gates.clone(),
379                        blocked_by: unmet_gates
380                            .iter()
381                            .copied()
382                            .map(BlockedReference::Eval)
383                            .collect(),
384                        derived: false,
385                    },
386                );
387            }
388        }
389        let own_satisfied: BTreeMap<_, _> = self
390            .artifacts
391            .iter()
392            .map(|(id, artifact)| {
393                let satisfied = artifact.basis
394                    || (!artifact.evals.is_empty()
395                        && artifact
396                            .evals
397                            .iter()
398                            .all(|id| evals[id.as_str()].status == EvalStatus::Green));
399                (id, satisfied)
400            })
401            .collect();
402        let mut satisfied = vec![false; self.components.len()];
403        for (index, component) in self.components.iter().enumerate() {
404            satisfied[index] = component.artifacts.iter().all(|id| own_satisfied[id])
405                && component
406                    .dependencies
407                    .iter()
408                    .all(|&dependency| satisfied[dependency.index()]);
409        }
410        let artifacts = self
411            .artifacts
412            .iter()
413            .map(|(id, artifact)| {
414                let satisfied = satisfied[artifact.component.index()];
415                let status = if satisfied {
416                    if artifact.basis {
417                        ArtifactStatus::Basis
418                    } else {
419                        ArtifactStatus::Green
420                    }
421                } else {
422                    [
423                        (EvalStatus::Error, ArtifactStatus::Error),
424                        (EvalStatus::Red, ArtifactStatus::Red),
425                        (EvalStatus::Blocked, ArtifactStatus::Blocked),
426                        (EvalStatus::Wait, ArtifactStatus::Wait),
427                        (EvalStatus::Stale, ArtifactStatus::Stale),
428                        (EvalStatus::Unreviewed, ArtifactStatus::Unreviewed),
429                    ]
430                    .into_iter()
431                    .find_map(|(eval_status, artifact_status)| {
432                        artifact
433                            .evals
434                            .iter()
435                            .any(|id| evals[id.as_str()].status == eval_status)
436                            .then_some(artifact_status)
437                    })
438                    .unwrap_or(if own_satisfied[id] {
439                        ArtifactStatus::Incomplete
440                    } else {
441                        ArtifactStatus::Unreviewed
442                    })
443                };
444                (
445                    id.clone(),
446                    ArtifactEvaluation {
447                        status,
448                        own_satisfied: own_satisfied[id],
449                        satisfied,
450                        passed: artifact
451                            .evals
452                            .iter()
453                            .filter(|id| evals[id.as_str()].status == EvalStatus::Green)
454                            .count(),
455                        total: artifact.evals.len(),
456                    },
457                )
458            })
459            .collect();
460        let obligations: Vec<_> = own_satisfied
461            .into_iter()
462            .filter_map(|(id, satisfied)| (!satisfied).then_some(id))
463            .collect();
464        let status = if evals.values().any(|c| c.status == EvalStatus::Error) {
465            FinalStatus::Error
466        } else if evals.values().any(|c| c.status == EvalStatus::Red) {
467            FinalStatus::Red
468        } else if obligations.is_empty() {
469            FinalStatus::Green
470        } else {
471            FinalStatus::Incomplete
472        };
473        Evaluation {
474            evals,
475            artifacts,
476            obligations,
477            status,
478        }
479    }
480
481    pub fn eval_target(&self, id: &str) -> Option<&'a ArtifactName> {
482        self.evals.get(id).copied()
483    }
484}
485
486/// Current means usable for this input/run, not merely the latest stored result.
487/// Stale evidence carries no current verdict; operational failure is not RED.
488#[derive(Debug, Clone, Copy, PartialEq, Eq)]
489pub enum Evidence {
490    Current(Verdict),
491    OperationalError,
492    Stale,
493}
494
495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
496pub enum Readiness {
497    Ready,
498    Wait,
499    Blocked,
500}
501
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503pub enum EvalStatus {
504    Green,
505    Red,
506    Error,
507    Stale,
508    Unreviewed,
509    Wait,
510    Blocked,
511}
512
513#[derive(Debug, Clone, PartialEq, Eq)]
514pub struct EvalEvaluation<'a> {
515    /// Gate readiness only; READY never requests re-execution of existing evidence.
516    pub readiness: Readiness,
517    pub status: EvalStatus,
518    /// Retained for audit even when gates mask its effective status.
519    pub evidence: Option<Evidence>,
520    pub unmet_gates: Vec<&'a EvalId>,
521    pub blocked_by: Vec<BlockedReference<'a>>,
522    pub derived: bool,
523}
524
525/// A blocked dependency names either material or an Eval, never an unclassified string.
526#[derive(Debug, Clone, Copy, PartialEq, Eq)]
527pub enum BlockedReference<'a> {
528    Artifact(&'a ArtifactName),
529    Eval(&'a EvalId),
530}
531impl BlockedReference<'_> {
532    pub fn as_str(&self) -> &str {
533        match self {
534            Self::Artifact(id) => id.as_str(),
535            Self::Eval(id) => id.as_str(),
536        }
537    }
538}
539impl std::fmt::Display for BlockedReference<'_> {
540    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
541        formatter.write_str(self.as_str())
542    }
543}
544
545impl EvalEvaluation<'_> {
546    /// Operational errors require an explicit retry, not automatic redispatch.
547    pub fn can_execute(&self) -> bool {
548        !self.derived
549            && self.readiness == Readiness::Ready
550            && matches!(self.evidence, None | Some(Evidence::Stale))
551    }
552}
553
554#[derive(Debug, Clone, Copy, PartialEq, Eq)]
555pub enum ArtifactStatus {
556    Basis,
557    Green,
558    Incomplete,
559    Error,
560    Red,
561    Blocked,
562    Wait,
563    Stale,
564    Unreviewed,
565}
566
567#[derive(Debug, Clone, PartialEq, Eq)]
568pub struct ArtifactEvaluation {
569    pub status: ArtifactStatus,
570    pub own_satisfied: bool,
571    /// Includes all required dependencies and SCC peers, even without Evals.
572    pub satisfied: bool,
573    pub passed: usize,
574    pub total: usize,
575}
576
577/// Aggregate validation status, never a fabricated Eval verdict.
578#[derive(Debug, Clone, Copy, PartialEq, Eq)]
579pub enum FinalStatus {
580    Green,
581    Red,
582    Error,
583    Incomplete,
584}
585
586#[derive(Debug, Clone, PartialEq, Eq)]
587pub struct Evaluation<'a> {
588    pub evals: BTreeMap<EvalId, EvalEvaluation<'a>>,
589    pub artifacts: BTreeMap<ArtifactName, ArtifactEvaluation>,
590    /// Artifacts whose own obligations are unmet, not execution gates.
591    pub obligations: Vec<&'a ArtifactName>,
592    pub status: FinalStatus,
593}
594
595#[cfg(test)]
596mod tests;
597
598impl serde::Serialize for BlockedReference<'_> {
599    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
600        serializer.serialize_str(self.as_str())
601    }
602}
603
604impl From<BlockedReference<'_>> for crate::store::Blocker {
605    fn from(reference: BlockedReference<'_>) -> Self {
606        match reference {
607            BlockedReference::Artifact(id) => Self::Artifact(id.clone()),
608            BlockedReference::Eval(id) => Self::Eval(id.clone()),
609        }
610    }
611}