use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FaultEventId(pub u64);
impl FaultEventId {
#[must_use]
pub const fn new(id: u64) -> Self {
Self(id)
}
#[must_use]
pub const fn get(self) -> u64 {
self.0
}
}
impl std::fmt::Display for FaultEventId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "e{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HittingSetBudget {
pub max_depth: usize,
pub max_hypotheses: usize,
}
impl Default for HittingSetBudget {
fn default() -> Self {
Self {
max_depth: 3,
max_hypotheses: 64,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HittingSetResult {
pub hypotheses: Vec<BTreeSet<FaultEventId>>,
pub exhausted: bool,
pub unbreakable: bool,
pub max_depth: usize,
}
impl HittingSetResult {
#[must_use]
pub fn is_empty(&self) -> bool {
self.hypotheses.is_empty()
}
#[must_use]
pub fn len(&self) -> usize {
self.hypotheses.len()
}
#[must_use]
pub fn coverage_certificate(&self) -> Option<usize> {
if self.hypotheses.is_empty() && (self.exhausted || self.unbreakable) {
Some(self.max_depth)
} else {
None
}
}
pub fn run_experiments<F>(
&self,
budget: LdfiExperimentBudget,
mut experiment: F,
) -> LdfiExperimentReport
where
F: FnMut(&BTreeSet<FaultEventId>) -> LdfiExperimentObservation,
{
let mut refuted = Vec::new();
if self.hypotheses.is_empty() {
let status = if self.exhausted || self.unbreakable {
LdfiExperimentStatus::RefutedUpToDepth {
max_depth: self.max_depth,
}
} else {
LdfiExperimentStatus::HypothesisSearchTruncated
};
return LdfiExperimentReport {
status,
experiments_run: 0,
refuted,
};
}
let max_experiments = budget.max_experiments.min(self.hypotheses.len());
for hypothesis in self.hypotheses.iter().take(max_experiments) {
match experiment(hypothesis) {
LdfiExperimentObservation::InvariantViolated => {
return LdfiExperimentReport {
status: LdfiExperimentStatus::FoundViolation {
hypothesis: hypothesis.clone(),
},
experiments_run: refuted.len() + 1,
refuted,
};
}
LdfiExperimentObservation::InvariantHeld => {
refuted.push(hypothesis.clone());
}
}
}
let status = if refuted.len() < self.hypotheses.len() {
LdfiExperimentStatus::ExperimentBudgetExhausted {
remaining_hypotheses: self.hypotheses.len() - refuted.len(),
}
} else if self.exhausted {
LdfiExperimentStatus::RefutedUpToDepth {
max_depth: self.max_depth,
}
} else {
LdfiExperimentStatus::HypothesisSearchTruncated
};
LdfiExperimentReport {
experiments_run: refuted.len(),
status,
refuted,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LdfiExperimentBudget {
pub max_experiments: usize,
}
impl Default for LdfiExperimentBudget {
fn default() -> Self {
Self {
max_experiments: usize::MAX,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LdfiExperimentObservation {
InvariantHeld,
InvariantViolated,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LdfiExperimentStatus {
FoundViolation {
hypothesis: BTreeSet<FaultEventId>,
},
RefutedUpToDepth {
max_depth: usize,
},
ExperimentBudgetExhausted {
remaining_hypotheses: usize,
},
HypothesisSearchTruncated,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LdfiExperimentReport {
pub status: LdfiExperimentStatus,
pub experiments_run: usize,
pub refuted: Vec<BTreeSet<FaultEventId>>,
}
impl LdfiExperimentReport {
#[must_use]
pub fn coverage_certificate(&self) -> Option<usize> {
match self.status {
LdfiExperimentStatus::RefutedUpToDepth { max_depth } => Some(max_depth),
LdfiExperimentStatus::FoundViolation { .. }
| LdfiExperimentStatus::ExperimentBudgetExhausted { .. }
| LdfiExperimentStatus::HypothesisSearchTruncated => None,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SupportGraph {
derivations: Vec<BTreeSet<FaultEventId>>,
}
impl SupportGraph {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_derivation(&mut self, events: impl IntoIterator<Item = FaultEventId>) {
self.derivations.push(events.into_iter().collect());
}
#[must_use]
pub fn derivations(&self) -> &[BTreeSet<FaultEventId>] {
&self.derivations
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.derivations.is_empty()
}
#[must_use]
pub fn minimal_hitting_sets(&self, budget: HittingSetBudget) -> HittingSetResult {
if self.derivations.is_empty() {
return HittingSetResult {
hypotheses: Vec::new(),
exhausted: true,
unbreakable: false,
max_depth: budget.max_depth,
};
}
if self.derivations.iter().any(|d| d.is_empty()) {
return HittingSetResult {
hypotheses: Vec::new(),
exhausted: true,
unbreakable: true,
max_depth: budget.max_depth,
};
}
let raw_cap = budget
.max_hypotheses
.saturating_mul(4)
.max(budget.max_hypotheses)
.max(1);
let mut found: Vec<BTreeSet<FaultEventId>> = Vec::new();
let mut exhausted = true;
let mut partial = BTreeSet::new();
self.search(
&mut partial,
budget.max_depth,
raw_cap,
&mut found,
&mut exhausted,
);
let mut hypotheses = filter_minimal(found);
if hypotheses.len() > budget.max_hypotheses {
hypotheses.truncate(budget.max_hypotheses);
exhausted = false;
}
HittingSetResult {
hypotheses,
exhausted,
unbreakable: false,
max_depth: budget.max_depth,
}
}
fn search(
&self,
partial: &mut BTreeSet<FaultEventId>,
remaining_depth: usize,
cap: usize,
found: &mut Vec<BTreeSet<FaultEventId>>,
exhausted: &mut bool,
) {
if found.len() >= cap {
*exhausted = false;
return;
}
match self.derivations.iter().find(|d| d.is_disjoint(partial)) {
None => {
found.push(partial.clone());
}
Some(clause) => {
if remaining_depth == 0 {
*exhausted = false;
return;
}
for &event in clause {
if partial.contains(&event) {
continue;
}
partial.insert(event);
self.search(partial, remaining_depth - 1, cap, found, exhausted);
partial.remove(&event);
if found.len() >= cap {
*exhausted = false;
return;
}
}
}
}
}
}
fn filter_minimal(mut sets: Vec<BTreeSet<FaultEventId>>) -> Vec<BTreeSet<FaultEventId>> {
sets.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.iter().cmp(b.iter())));
let mut result: Vec<BTreeSet<FaultEventId>> = Vec::new();
for candidate in sets {
if !result.iter().any(|kept| kept.is_subset(&candidate)) {
result.push(candidate);
}
}
result
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CausalLineage {
predecessors: BTreeMap<FaultEventId, BTreeSet<FaultEventId>>,
faultable: BTreeSet<FaultEventId>,
}
impl CausalLineage {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_event(&mut self, event: FaultEventId, faultable: bool) {
self.predecessors.entry(event).or_default();
if faultable {
self.faultable.insert(event);
} else {
self.faultable.remove(&event);
}
}
pub fn mark_faultable(&mut self, event: FaultEventId) {
self.predecessors.entry(event).or_default();
self.faultable.insert(event);
}
pub fn add_happens_before(&mut self, before: FaultEventId, after: FaultEventId) {
self.predecessors.entry(before).or_default();
let preds = self.predecessors.entry(after).or_default();
if before != after {
preds.insert(before);
}
}
#[must_use]
pub fn is_faultable(&self, event: FaultEventId) -> bool {
self.faultable.contains(&event)
}
#[must_use]
pub fn causal_cone(&self, target: FaultEventId) -> BTreeSet<FaultEventId> {
let mut seen = BTreeSet::new();
let mut stack = vec![target];
while let Some(event) = stack.pop() {
if !seen.insert(event) {
continue;
}
if let Some(preds) = self.predecessors.get(&event) {
for &pred in preds {
if !seen.contains(&pred) {
stack.push(pred);
}
}
}
}
seen
}
#[must_use]
pub fn support_of(&self, target: FaultEventId) -> BTreeSet<FaultEventId> {
self.causal_cone(target)
.into_iter()
.filter(|event| self.faultable.contains(event))
.collect()
}
}
impl SupportGraph {
#[must_use]
pub fn from_causal_cone(lineage: &CausalLineage, target: FaultEventId) -> Self {
Self::from_causal_cones(lineage, std::iter::once(target))
}
#[must_use]
pub fn from_causal_cones(
lineage: &CausalLineage,
targets: impl IntoIterator<Item = FaultEventId>,
) -> Self {
let mut graph = Self::new();
for target in targets {
graph.add_derivation(lineage.support_of(target));
}
graph
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ev(id: u64) -> FaultEventId {
FaultEventId::new(id)
}
fn set(ids: &[u64]) -> BTreeSet<FaultEventId> {
ids.iter().copied().map(FaultEventId::new).collect()
}
#[test]
fn single_shared_event_is_a_depth_one_hypothesis() {
let mut g = SupportGraph::new();
g.add_derivation([ev(1), ev(2)]);
g.add_derivation([ev(1), ev(3)]);
g.add_derivation([ev(1), ev(4)]);
let result = g.minimal_hitting_sets(HittingSetBudget::default());
assert!(result.hypotheses.contains(&set(&[1])));
assert!(
result
.hypotheses
.iter()
.all(|h| !h.is_superset(&set(&[1])) || *h == set(&[1]))
);
assert!(result.exhausted);
assert!(result.coverage_certificate().is_none());
}
#[test]
fn disjoint_derivations_require_a_depth_two_hypothesis() {
let mut g = SupportGraph::new();
g.add_derivation([ev(1), ev(2)]);
g.add_derivation([ev(3), ev(4)]);
let result = g.minimal_hitting_sets(HittingSetBudget::default());
assert!(result.hypotheses.iter().all(|h| h.len() == 2));
for h in &result.hypotheses {
assert!(!h.is_disjoint(&set(&[1, 2])));
assert!(!h.is_disjoint(&set(&[3, 4])));
}
assert!(result.exhausted);
}
#[test]
fn unbreakable_outcome_yields_coverage_certificate() {
let mut g = SupportGraph::new();
g.add_derivation([ev(1)]);
g.add_derivation(std::iter::empty());
let result = g.minimal_hitting_sets(HittingSetBudget::default());
assert!(result.is_empty());
assert!(result.unbreakable);
assert_eq!(result.coverage_certificate(), Some(3));
}
#[test]
fn depth_budget_limits_hypothesis_size() {
let mut g = SupportGraph::new();
g.add_derivation([ev(1), ev(2)]);
g.add_derivation([ev(3), ev(4)]);
g.add_derivation([ev(5), ev(6)]);
let result = g.minimal_hitting_sets(HittingSetBudget {
max_depth: 2,
max_hypotheses: 64,
});
assert!(result.is_empty());
assert!(!result.exhausted);
assert!(result.coverage_certificate().is_none());
}
#[test]
fn determinism() {
let mut g = SupportGraph::new();
g.add_derivation([ev(1), ev(2), ev(3)]);
g.add_derivation([ev(2), ev(4)]);
let a = g.minimal_hitting_sets(HittingSetBudget::default());
let b = g.minimal_hitting_sets(HittingSetBudget::default());
assert_eq!(a, b);
}
#[test]
fn empty_support_graph_is_trivially_exhausted() {
let g = SupportGraph::new();
let result = g.minimal_hitting_sets(HittingSetBudget::default());
assert!(result.is_empty());
assert!(result.exhausted);
assert!(!result.unbreakable);
}
#[test]
fn causal_cone_collects_transitive_faultable_ancestry_only() {
let mut lineage = CausalLineage::new();
for id in [1, 2, 3] {
lineage.mark_faultable(ev(id));
}
lineage.add_happens_before(ev(1), ev(2));
lineage.add_happens_before(ev(2), ev(3));
lineage.add_happens_before(ev(3), ev(4));
assert_eq!(lineage.causal_cone(ev(4)), set(&[1, 2, 3, 4]));
assert_eq!(lineage.support_of(ev(4)), set(&[1, 2, 3]));
assert!(!lineage.is_faultable(ev(4)));
}
#[test]
fn shared_root_cone_yields_depth_one_hypothesis_end_to_end() {
let mut lineage = CausalLineage::new();
for id in [1, 2, 3] {
lineage.mark_faultable(ev(id));
}
lineage.add_happens_before(ev(1), ev(2));
lineage.add_happens_before(ev(1), ev(3));
lineage.add_happens_before(ev(2), ev(10));
lineage.add_happens_before(ev(3), ev(11));
let graph = SupportGraph::from_causal_cones(&lineage, [ev(10), ev(11)]);
assert_eq!(graph.derivations().len(), 2);
let result = graph.minimal_hitting_sets(HittingSetBudget::default());
assert!(result.hypotheses.contains(&set(&[1])));
assert!(result.coverage_certificate().is_none());
}
#[test]
fn outcome_with_no_faultable_support_is_unbreakable() {
let mut lineage = CausalLineage::new();
lineage.add_event(ev(7), false); let graph = SupportGraph::from_causal_cone(&lineage, ev(7));
let result = graph.minimal_hitting_sets(HittingSetBudget::default());
assert!(result.unbreakable);
assert_eq!(result.coverage_certificate(), Some(3));
}
#[test]
fn add_event_can_demote_a_previously_faultable_event() {
let mut lineage = CausalLineage::new();
lineage.mark_faultable(ev(1));
assert!(lineage.is_faultable(ev(1)));
lineage.add_event(ev(1), false);
assert!(!lineage.is_faultable(ev(1)));
assert!(lineage.support_of(ev(1)).is_empty());
}
}