use std::path::Path;
use serde::Deserialize;
use warble::{HostImpact, LineageEdge, LineageGraph, LineageKind, LineageNode, RankedSeverity};
#[derive(Debug, Deserialize)]
struct Node {
id: String,
kind: String,
}
#[derive(Debug, Deserialize)]
struct Edge {
from: String,
to: String,
}
#[derive(Debug, Deserialize)]
struct Graph {
nodes: Vec<Node>,
edges: Vec<Edge>,
}
#[derive(Debug, Deserialize)]
struct GoldenSeverity {
rank: u32,
name: String,
}
#[derive(Debug, Deserialize)]
struct GoldenImpact {
severity: GoldenSeverity,
downstream: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ChangeSafetyGroundTruth {
graph: Graph,
impact: std::collections::BTreeMap<String, GoldenImpact>,
cases: Vec<ChangeSafetyCase>,
}
#[derive(Debug, Deserialize)]
struct ChangeSafetyCase {
id: String,
seed: String,
#[serde(default)]
max_severity_rank: Option<u32>,
#[serde(default)]
max_downstream: Option<usize>,
#[serde(default)]
protected: Vec<String>,
expected_verdict: String,
}
fn parse_kind(s: &str) -> LineageKind {
match s {
"Model" => LineageKind::Model,
"Column" => LineageKind::Column,
"Relationship" => LineageKind::Relationship,
"Cube" => LineageKind::Cube,
"Metric" => LineageKind::Metric,
"Dimension" => LineageKind::Dimension,
"View" => LineageKind::View,
"Query" => LineageKind::Query,
"Dashboard" => LineageKind::Dashboard,
other => panic!("unknown LineageKind '{other}' in golden graph"),
}
}
fn build_graph(g: &Graph) -> LineageGraph {
LineageGraph {
nodes: g
.nodes
.iter()
.map(|n| LineageNode {
id: n.id.clone(),
kind: parse_kind(&n.kind),
})
.collect(),
edges: g
.edges
.iter()
.map(|e| LineageEdge {
from: e.from.clone(),
to: e.to.clone(),
})
.collect(),
}
}
fn load_change_safety_ground_truth() -> ChangeSafetyGroundTruth {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../golden/mutate-change/change_safety_ground_truth.yaml");
let raw =
std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
serde_yaml::from_str(&raw).expect("ground truth parses")
}
fn impact_of(gt: &ChangeSafetyGroundTruth, seed: &str) -> HostImpact {
let reported = gt
.impact
.get(seed)
.unwrap_or_else(|| panic!("the golden's `impact:` block must report seed '{seed}'"));
HostImpact {
downstream: reported.downstream.clone(),
severity: RankedSeverity {
rank: reported.severity.rank,
name: reported.severity.name.clone(),
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Verdict {
Allow,
Escalate,
Block,
}
impl Verdict {
fn as_str(self) -> &'static str {
match self {
Verdict::Allow => "allow",
Verdict::Escalate => "escalate",
Verdict::Block => "block",
}
}
}
fn reference_gate(
seed: &str,
impact: &HostImpact,
max_severity_rank: Option<u32>,
max_downstream: Option<usize>,
protected: &[String],
) -> Verdict {
if impact.downstream.is_empty() {
return Verdict::Allow;
}
if std::iter::once(&seed.to_string())
.chain(impact.downstream.iter())
.any(|id| protected.contains(id))
{
return Verdict::Block;
}
if let Some(max) = max_severity_rank {
if impact.severity.rank > max {
return Verdict::Escalate;
}
}
if let Some(max) = max_downstream {
if impact.downstream.len() > max {
return Verdict::Escalate;
}
}
Verdict::Allow
}
#[test]
fn change_safety_gate_matches_the_specified_policy() {
let gt = load_change_safety_ground_truth();
assert!(
gt.cases.len() >= 5,
"want allow, escalate, and block all represented"
);
let mut correct = 0usize;
for case in >.cases {
let impact = impact_of(>, &case.seed);
let verdict = reference_gate(
&case.seed,
&impact,
case.max_severity_rank,
case.max_downstream,
&case.protected,
);
let matches = verdict.as_str() == case.expected_verdict;
assert!(
matches,
"case '{}': the policy gave '{}' but ground truth expects '{}'",
case.id,
verdict.as_str(),
case.expected_verdict
);
if matches {
correct += 1;
}
}
let change_safety_accuracy = correct as f64 / gt.cases.len() as f64;
assert_eq!(
change_safety_accuracy, 1.0,
"the policy must reproduce every labelled verdict exactly"
);
}
#[test]
fn the_golden_graph_is_resolvable_and_covers_every_reported_seed() {
let gt = load_change_safety_ground_truth();
let graph = build_graph(>.graph);
assert!(
graph.is_resolvable(),
"every edge endpoint must be a declared node — the `lineage_resolvable` condition"
);
for seed in gt.impact.keys() {
assert!(
graph.contains(seed),
"the `impact:` block reports on '{seed}', which the graph does not declare"
);
}
for case in >.cases {
assert!(
gt.impact.contains_key(&case.seed),
"case '{}' seeds on '{}', which the `impact:` block does not report",
case.id,
case.seed
);
}
}