#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GateKind {
Deterministic,
ModelJudged,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GateVerdict {
Pass,
Fail,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum GateSurface {
Approval,
FinalGate,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct GateScore {
pub score: f64,
pub threshold: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ArtefactRef {
pub reference: String,
pub detail: Option<String>,
}
impl ArtefactRef {
pub fn new(reference: impl Into<String>) -> Self {
Self {
reference: reference.into(),
detail: None,
}
}
pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
self.detail = Some(detail.into());
self
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct GateOutcome {
pub verdict: GateVerdict,
pub artefact: ArtefactRef,
pub score: Option<GateScore>,
pub rule_ids: Vec<String>,
}
impl GateOutcome {
pub fn pass(artefact: ArtefactRef) -> Self {
Self {
verdict: GateVerdict::Pass,
artefact,
score: None,
rule_ids: Vec::new(),
}
}
pub fn fail(artefact: ArtefactRef) -> Self {
Self {
verdict: GateVerdict::Fail,
artefact,
score: None,
rule_ids: Vec::new(),
}
}
pub fn with_score(mut self, score: f64, threshold: f64) -> Self {
self.score = Some(GateScore { score, threshold });
self
}
pub fn with_rule_ids(mut self, rule_ids: Vec<String>) -> Self {
self.rule_ids = rule_ids;
self
}
pub fn passed(&self) -> bool {
self.verdict == GateVerdict::Pass
}
}
pub trait Gate {
fn name(&self) -> &str;
fn kind(&self) -> GateKind;
fn evaluate(&self) -> GateOutcome;
}
#[derive(Debug, Clone)]
pub struct GateReport {
pub name: String,
pub kind: GateKind,
pub outcome: GateOutcome,
}
#[derive(Default)]
pub struct GatePipeline {
deterministic: Vec<Box<dyn Gate>>,
model_judged: Vec<Box<dyn Gate>>,
}
impl GatePipeline {
pub fn new() -> Self {
Self::default()
}
pub fn register(&mut self, gate: Box<dyn Gate>) -> &mut Self {
match gate.kind() {
GateKind::Deterministic => self.deterministic.push(gate),
GateKind::ModelJudged => self.model_judged.push(gate),
}
self
}
pub fn len(&self) -> usize {
self.deterministic.len() + self.model_judged.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn evaluate(&self) -> Vec<GateReport> {
self.deterministic
.iter()
.chain(self.model_judged.iter())
.map(|gate| GateReport {
name: gate.name().to_string(),
kind: gate.kind(),
outcome: gate.evaluate(),
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::rc::Rc;
struct ScriptedGate {
name: &'static str,
kind: GateKind,
verdict: GateVerdict,
calls: Rc<RefCell<Vec<&'static str>>>,
}
impl Gate for ScriptedGate {
fn name(&self) -> &str {
self.name
}
fn kind(&self) -> GateKind {
self.kind
}
fn evaluate(&self) -> GateOutcome {
self.calls.borrow_mut().push(self.name);
match self.verdict {
GateVerdict::Pass => GateOutcome::pass(ArtefactRef::new(self.name)),
GateVerdict::Fail => GateOutcome::fail(ArtefactRef::new(self.name)),
}
}
}
fn scripted(
name: &'static str,
kind: GateKind,
calls: &Rc<RefCell<Vec<&'static str>>>,
) -> Box<dyn Gate> {
Box::new(ScriptedGate {
name,
kind,
verdict: GateVerdict::Pass,
calls: Rc::clone(calls),
})
}
#[test]
fn gate_plugin_model_gates_cannot_precede_deterministic_gates() {
let calls = Rc::new(RefCell::new(Vec::new()));
let mut pipeline = GatePipeline::new();
pipeline
.register(scripted("model-a", GateKind::ModelJudged, &calls))
.register(scripted("det-a", GateKind::Deterministic, &calls))
.register(scripted("model-b", GateKind::ModelJudged, &calls))
.register(scripted("det-b", GateKind::Deterministic, &calls));
assert_eq!(pipeline.len(), 4);
let reports = pipeline.evaluate();
let expected = ["det-a", "det-b", "model-a", "model-b"];
assert_eq!(
reports
.iter()
.map(|report| report.name.as_str())
.collect::<Vec<_>>(),
expected,
"deterministic section first, registration order within each section"
);
assert_eq!(
reports.iter().map(|report| report.kind).collect::<Vec<_>>(),
[
GateKind::Deterministic,
GateKind::Deterministic,
GateKind::ModelJudged,
GateKind::ModelJudged,
]
);
assert_eq!(
*calls.borrow(),
expected,
"evaluation ran in pipeline order"
);
assert!(reports.iter().all(|report| report.outcome.passed()));
}
struct BooleanGate;
impl Gate for BooleanGate {
fn name(&self) -> &str {
"boolean-gate"
}
fn kind(&self) -> GateKind {
GateKind::Deterministic
}
fn evaluate(&self) -> GateOutcome {
GateOutcome::pass(ArtefactRef::new("lint.log"))
}
}
#[test]
fn gate_plugin_boolean_gate_runs_without_a_score() {
let mut pipeline = GatePipeline::new();
pipeline.register(Box::new(BooleanGate));
let reports = pipeline.evaluate();
assert_eq!(reports.len(), 1);
assert!(reports[0].outcome.passed());
assert_eq!(reports[0].outcome.score, None);
assert_eq!(reports[0].outcome.artefact.reference, "lint.log");
}
#[test]
fn gate_plugin_verdict_is_never_derived_from_the_score() {
let confident_failure = GateOutcome::fail(ArtefactRef::new("review")).with_score(0.99, 0.5);
assert!(
!confident_failure.passed(),
"a high score must not flip a stated Fail"
);
let nervous_pass = GateOutcome::pass(ArtefactRef::new("review")).with_score(0.1, 0.9);
assert!(
nervous_pass.passed(),
"a low score must not flip a stated Pass"
);
}
#[test]
fn gate_plugin_scored_gate_carries_score_and_threshold() {
let outcome = GateOutcome::pass(ArtefactRef::new("scrutiny")).with_score(0.42, 0.75);
let score = outcome.score.expect("score recorded");
assert_eq!(score.score, 0.42);
assert_eq!(score.threshold, 0.75);
assert!(outcome.passed());
}
}