#![forbid(unsafe_code)]
use crate::benchmark_gate::GateResult;
use crate::shadow_run::{ShadowRunResult, ShadowVerdict};
use ftui_runtime::effect_system::QueueTelemetry;
#[derive(Debug, Clone)]
pub struct RolloutScorecardConfig {
pub min_shadow_scenarios: usize,
pub min_match_ratio: f64,
pub require_benchmark_pass: bool,
}
impl Default for RolloutScorecardConfig {
fn default() -> Self {
Self {
min_shadow_scenarios: 1,
min_match_ratio: 1.0,
require_benchmark_pass: false,
}
}
}
impl RolloutScorecardConfig {
#[must_use]
pub fn min_shadow_scenarios(mut self, n: usize) -> Self {
self.min_shadow_scenarios = n;
self
}
#[must_use]
pub fn min_match_ratio(mut self, ratio: f64) -> Self {
self.min_match_ratio = ratio.clamp(0.0, 1.0);
self
}
#[must_use]
pub fn require_benchmark_pass(mut self, required: bool) -> Self {
self.require_benchmark_pass = required;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RolloutVerdict {
Go,
NoGo,
Inconclusive,
}
impl RolloutVerdict {
#[must_use]
pub fn is_go(self) -> bool {
matches!(self, Self::Go)
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::Go => "GO",
Self::NoGo => "NO-GO",
Self::Inconclusive => "INCONCLUSIVE",
}
}
}
impl std::fmt::Display for RolloutVerdict {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug)]
pub struct RolloutScorecard {
config: RolloutScorecardConfig,
shadow_results: Vec<ShadowRunResult>,
benchmark_gate: Option<GateResult>,
}
impl RolloutScorecard {
pub fn new(config: RolloutScorecardConfig) -> Self {
Self {
config,
shadow_results: Vec::new(),
benchmark_gate: None,
}
}
pub fn add_shadow_result(&mut self, result: ShadowRunResult) {
self.shadow_results.push(result);
}
pub fn set_benchmark_gate(&mut self, result: GateResult) {
self.benchmark_gate = Some(result);
}
#[must_use]
pub fn shadow_scenario_count(&self) -> usize {
self.shadow_results.len()
}
#[must_use]
pub fn shadow_match_count(&self) -> usize {
self.shadow_results
.iter()
.filter(|r| r.verdict == ShadowVerdict::Match)
.count()
}
#[must_use]
pub fn aggregate_match_ratio(&self) -> f64 {
if self.shadow_results.is_empty() {
return 0.0;
}
let total_frames: usize = self.shadow_results.iter().map(|r| r.frames_compared).sum();
if total_frames == 0 {
return 1.0;
}
let matched_frames: usize = self
.shadow_results
.iter()
.flat_map(|r| r.frame_comparisons.iter())
.filter(|c| c.matched)
.count();
matched_frames as f64 / total_frames as f64
}
#[must_use]
pub fn evaluate(&self) -> RolloutVerdict {
if self.shadow_results.len() < self.config.min_shadow_scenarios {
return RolloutVerdict::Inconclusive;
}
let match_ratio = self.aggregate_match_ratio();
if match_ratio < self.config.min_match_ratio {
return RolloutVerdict::NoGo;
}
if self
.shadow_results
.iter()
.any(|r| r.verdict == ShadowVerdict::Diverged)
{
return RolloutVerdict::NoGo;
}
if self.config.require_benchmark_pass {
match &self.benchmark_gate {
None => return RolloutVerdict::Inconclusive,
Some(gate) if !gate.passed() => return RolloutVerdict::NoGo,
_ => {}
}
}
RolloutVerdict::Go
}
#[must_use]
pub fn summary(&self) -> RolloutSummary {
let verdict = self.evaluate();
RolloutSummary {
verdict,
shadow_scenarios: self.shadow_results.len(),
shadow_matches: self.shadow_match_count(),
aggregate_match_ratio: self.aggregate_match_ratio(),
total_frames_compared: self.shadow_results.iter().map(|r| r.frames_compared).sum(),
benchmark_passed: self.benchmark_gate.as_ref().map(|g| g.passed()),
min_shadow_scenarios_required: self.config.min_shadow_scenarios,
min_match_ratio_required: self.config.min_match_ratio,
benchmark_required: self.config.require_benchmark_pass,
}
}
}
#[derive(Debug, Clone)]
pub struct RolloutSummary {
pub verdict: RolloutVerdict,
pub shadow_scenarios: usize,
pub shadow_matches: usize,
pub aggregate_match_ratio: f64,
pub total_frames_compared: usize,
pub benchmark_passed: Option<bool>,
pub min_shadow_scenarios_required: usize,
pub min_match_ratio_required: f64,
pub benchmark_required: bool,
}
impl RolloutSummary {
#[must_use]
pub fn to_json(&self) -> String {
let benchmark_str = match self.benchmark_passed {
Some(true) => "\"pass\"",
Some(false) => "\"fail\"",
None => "null",
};
format!(
concat!(
"{{",
"\"verdict\":\"{verdict}\",",
"\"shadow_scenarios\":{scenarios},",
"\"shadow_matches\":{matches},",
"\"aggregate_match_ratio\":{ratio},",
"\"total_frames_compared\":{frames},",
"\"benchmark_passed\":{bench},",
"\"config\":{{",
"\"min_shadow_scenarios\":{min_scenarios},",
"\"min_match_ratio\":{min_ratio},",
"\"benchmark_required\":{bench_required}",
"}}",
"}}"
),
verdict = self.verdict.label(),
scenarios = self.shadow_scenarios,
matches = self.shadow_matches,
ratio = self.aggregate_match_ratio,
frames = self.total_frames_compared,
bench = benchmark_str,
min_scenarios = self.min_shadow_scenarios_required,
min_ratio = self.min_match_ratio_required,
bench_required = self.benchmark_required,
)
}
}
impl std::fmt::Display for RolloutSummary {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "=== Rollout Scorecard ===")?;
writeln!(f, "Verdict: {}", self.verdict)?;
writeln!(
f,
"Shadow: {}/{} scenarios matched ({} required)",
self.shadow_matches, self.shadow_scenarios, self.min_shadow_scenarios_required,
)?;
writeln!(
f,
"Match ratio: {:.1}% (>= {:.1}% required)",
self.aggregate_match_ratio * 100.0,
self.min_match_ratio_required * 100.0,
)?;
writeln!(f, "Frames compared: {}", self.total_frames_compared)?;
match self.benchmark_passed {
Some(true) => writeln!(f, "Benchmark: PASS")?,
Some(false) => writeln!(f, "Benchmark: FAIL")?,
None if self.benchmark_required => writeln!(f, "Benchmark: MISSING (required)")?,
None => writeln!(f, "Benchmark: not provided")?,
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct RolloutEvidenceBundle {
pub scorecard: RolloutSummary,
pub queue_telemetry: Option<QueueTelemetry>,
pub requested_lane: String,
pub resolved_lane: String,
pub rollout_policy: String,
}
impl RolloutEvidenceBundle {
#[must_use]
pub fn to_json(&self) -> String {
let qt_json = match &self.queue_telemetry {
Some(qt) => format!(
concat!(
"{{",
"\"enqueued\":{e},",
"\"processed\":{p},",
"\"dropped\":{d},",
"\"high_water\":{hw},",
"\"in_flight\":{inf}",
"}}"
),
e = qt.enqueued,
p = qt.processed,
d = qt.dropped,
hw = qt.high_water,
inf = qt.in_flight,
),
None => "null".to_string(),
};
format!(
concat!(
"{{",
"\"schema_version\":\"1.0.0\",",
"\"scorecard\":{sc},",
"\"queue_telemetry\":{qt},",
"\"runtime\":{{",
"\"requested_lane\":\"{rl}\",",
"\"resolved_lane\":\"{rsl}\",",
"\"rollout_policy\":\"{rp}\"",
"}}",
"}}"
),
sc = self.scorecard.to_json(),
qt = qt_json,
rl = self.requested_lane,
rsl = self.resolved_lane,
rp = self.rollout_policy,
)
}
}
impl std::fmt::Display for RolloutEvidenceBundle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "=== Rollout Evidence Bundle ===")?;
writeln!(
f,
"Lane: {} (resolved: {})",
self.requested_lane, self.resolved_lane
)?;
writeln!(f, "Policy: {}", self.rollout_policy)?;
write!(f, "{}", self.scorecard)?;
if let Some(qt) = &self.queue_telemetry {
writeln!(
f,
"Queue: enqueued={}, processed={}, dropped={}, high_water={}, in_flight={}",
qt.enqueued, qt.processed, qt.dropped, qt.high_water, qt.in_flight
)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shadow_run::{FrameComparison, ShadowRunResult, ShadowVerdict};
use crate::lab_integration::LabOutput;
fn empty_lab_output() -> LabOutput {
LabOutput {
frame_count: 0,
frame_records: vec![],
event_count: 0,
event_log: vec![],
tick_count: 0,
anomaly_count: 0,
}
}
fn make_shadow_result(verdict: ShadowVerdict, frames: usize) -> ShadowRunResult {
let frame_comparisons: Vec<FrameComparison> = (0..frames)
.map(|i| FrameComparison {
index: i,
baseline_checksum: 0xDEAD_BEEF,
candidate_checksum: if verdict == ShadowVerdict::Match {
0xDEAD_BEEF
} else {
0xCAFE_BABE
},
matched: verdict == ShadowVerdict::Match,
})
.collect();
ShadowRunResult {
verdict,
scenario_name: "test".to_string(),
seed: 42,
frame_comparisons,
first_divergence: if verdict == ShadowVerdict::Diverged {
Some(0)
} else {
None
},
frames_compared: frames,
baseline: empty_lab_output(),
candidate: empty_lab_output(),
baseline_label: "baseline".to_string(),
candidate_label: "candidate".to_string(),
run_total: 1,
}
}
#[test]
fn scorecard_go_with_matching_shadows() {
let config = RolloutScorecardConfig::default().min_shadow_scenarios(2);
let mut sc = RolloutScorecard::new(config);
sc.add_shadow_result(make_shadow_result(ShadowVerdict::Match, 10));
sc.add_shadow_result(make_shadow_result(ShadowVerdict::Match, 15));
let verdict = sc.evaluate();
assert_eq!(verdict, RolloutVerdict::Go);
assert!(verdict.is_go());
assert_eq!(sc.aggregate_match_ratio(), 1.0);
}
#[test]
fn scorecard_nogo_with_diverged_shadow() {
let config = RolloutScorecardConfig::default();
let mut sc = RolloutScorecard::new(config);
sc.add_shadow_result(make_shadow_result(ShadowVerdict::Diverged, 10));
assert_eq!(sc.evaluate(), RolloutVerdict::NoGo);
}
#[test]
fn scorecard_inconclusive_without_enough_scenarios() {
let config = RolloutScorecardConfig::default().min_shadow_scenarios(3);
let mut sc = RolloutScorecard::new(config);
sc.add_shadow_result(make_shadow_result(ShadowVerdict::Match, 10));
sc.add_shadow_result(make_shadow_result(ShadowVerdict::Match, 10));
assert_eq!(sc.evaluate(), RolloutVerdict::Inconclusive);
}
#[test]
fn scorecard_inconclusive_when_benchmark_required_but_missing() {
let config = RolloutScorecardConfig::default().require_benchmark_pass(true);
let mut sc = RolloutScorecard::new(config);
sc.add_shadow_result(make_shadow_result(ShadowVerdict::Match, 10));
assert_eq!(sc.evaluate(), RolloutVerdict::Inconclusive);
}
#[test]
fn scorecard_summary_display() {
let config = RolloutScorecardConfig::default().min_shadow_scenarios(1);
let mut sc = RolloutScorecard::new(config);
sc.add_shadow_result(make_shadow_result(ShadowVerdict::Match, 10));
let summary = sc.summary();
let text = summary.to_string();
assert!(text.contains("GO"));
assert!(text.contains("100.0%"));
assert!(text.contains("10"));
}
#[test]
fn verdict_labels() {
assert_eq!(RolloutVerdict::Go.label(), "GO");
assert_eq!(RolloutVerdict::NoGo.label(), "NO-GO");
assert_eq!(RolloutVerdict::Inconclusive.label(), "INCONCLUSIVE");
assert_eq!(format!("{}", RolloutVerdict::Go), "GO");
}
#[test]
fn scorecard_summary_json_go() {
let config = RolloutScorecardConfig::default().min_shadow_scenarios(1);
let mut sc = RolloutScorecard::new(config);
sc.add_shadow_result(make_shadow_result(ShadowVerdict::Match, 10));
let json = sc.summary().to_json();
assert!(json.contains("\"verdict\":\"GO\""));
assert!(json.contains("\"shadow_scenarios\":1"));
assert!(json.contains("\"shadow_matches\":1"));
assert!(json.contains("\"total_frames_compared\":10"));
assert!(json.contains("\"aggregate_match_ratio\":1"));
assert!(json.contains("\"benchmark_passed\":null"));
}
#[test]
fn scorecard_summary_json_nogo() {
let config = RolloutScorecardConfig::default();
let mut sc = RolloutScorecard::new(config);
sc.add_shadow_result(make_shadow_result(ShadowVerdict::Diverged, 5));
let json = sc.summary().to_json();
assert!(json.contains("\"verdict\":\"NO-GO\""));
assert!(json.contains("\"shadow_matches\":0"));
}
#[test]
fn scorecard_e2e_with_real_shadow_run() {
use crate::shadow_run::{ShadowRun, ShadowRunConfig};
use ftui_core::event::Event;
use ftui_core::geometry::Rect;
use ftui_render::frame::Frame;
use ftui_runtime::program::{Cmd, Model};
use ftui_widgets::Widget;
use ftui_widgets::paragraph::Paragraph;
struct RolloutModel {
ticks: u64,
}
#[derive(Debug, Clone)]
enum RolloutMsg {
Tick,
Quit,
}
impl From<Event> for RolloutMsg {
fn from(e: Event) -> Self {
match e {
Event::Tick => RolloutMsg::Tick,
_ => RolloutMsg::Quit,
}
}
}
impl Model for RolloutModel {
type Message = RolloutMsg;
fn update(&mut self, msg: RolloutMsg) -> Cmd<RolloutMsg> {
match msg {
RolloutMsg::Tick => {
self.ticks += 1;
Cmd::none()
}
RolloutMsg::Quit => Cmd::quit(),
}
}
fn view(&self, frame: &mut Frame) {
let text = format!("Ticks: {}", self.ticks);
let area = Rect::new(0, 0, frame.width(), 1);
Paragraph::new(text).render(area, frame);
}
}
let mut scorecard =
RolloutScorecard::new(RolloutScorecardConfig::default().min_shadow_scenarios(3));
for seed in [42, 99, 7] {
let config = ShadowRunConfig::new("rollout_e2e", "tick_counter", seed).viewport(40, 10);
let result = ShadowRun::compare(
config,
|| RolloutModel { ticks: 0 },
|session| {
session.init();
for _ in 0..5 {
session.tick();
session.capture_frame();
}
},
);
scorecard.add_shadow_result(result);
}
let verdict = scorecard.evaluate();
assert_eq!(verdict, RolloutVerdict::Go);
let summary = scorecard.summary();
assert_eq!(summary.shadow_scenarios, 3);
assert_eq!(summary.shadow_matches, 3);
assert_eq!(summary.total_frames_compared, 15); assert!((summary.aggregate_match_ratio - 1.0).abs() < f64::EPSILON);
assert!(summary.to_string().contains("GO"));
}
#[test]
fn evidence_bundle_json_contains_all_sections() {
let config = RolloutScorecardConfig::default().min_shadow_scenarios(1);
let mut sc = RolloutScorecard::new(config);
sc.add_shadow_result(make_shadow_result(ShadowVerdict::Match, 5));
let bundle = RolloutEvidenceBundle {
scorecard: sc.summary(),
queue_telemetry: Some(QueueTelemetry {
enqueued: 10,
processed: 8,
dropped: 1,
high_water: 4,
in_flight: 1,
}),
requested_lane: "structured".to_string(),
resolved_lane: "structured".to_string(),
rollout_policy: "shadow".to_string(),
};
let json = bundle.to_json();
assert!(json.contains("\"schema_version\":\"1.0.0\""));
assert!(json.contains("\"scorecard\":{"));
assert!(json.contains("\"verdict\":\"GO\""));
assert!(json.contains("\"queue_telemetry\":{"));
assert!(json.contains("\"enqueued\":10"));
assert!(json.contains("\"dropped\":1"));
assert!(json.contains("\"runtime\":{"));
assert!(json.contains("\"requested_lane\":\"structured\""));
assert!(json.contains("\"rollout_policy\":\"shadow\""));
}
#[test]
fn evidence_bundle_display_readable() {
let config = RolloutScorecardConfig::default().min_shadow_scenarios(1);
let mut sc = RolloutScorecard::new(config);
sc.add_shadow_result(make_shadow_result(ShadowVerdict::Match, 5));
let bundle = RolloutEvidenceBundle {
scorecard: sc.summary(),
queue_telemetry: None,
requested_lane: "asupersync".to_string(),
resolved_lane: "structured".to_string(),
rollout_policy: "off".to_string(),
};
let text = bundle.to_string();
assert!(text.contains("Rollout Evidence Bundle"));
assert!(text.contains("asupersync"));
assert!(text.contains("structured"));
assert!(text.contains("GO"));
}
}