use serde::{Deserialize, Serialize};
use std::time::Instant;
pub const BUDGET_ENVELOPE_SCHEMA_VERSION: u32 = 1;
pub const NEAR_LIMIT_FRACTION: f64 = 0.8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BudgetPhase {
Healthy,
NearLimit,
Exhausted,
}
pub fn budget_status(elapsed_ms: u64, budget_ms: u64) -> BudgetPhase {
if budget_ms == 0 || elapsed_ms >= budget_ms {
return BudgetPhase::Exhausted;
}
let near_threshold = (budget_ms as u128 * (NEAR_LIMIT_FRACTION * 100.0) as u128) / 100;
if (elapsed_ms as u128) >= near_threshold {
BudgetPhase::NearLimit
} else {
BudgetPhase::Healthy
}
}
#[derive(Debug, Clone, Copy)]
pub struct RobotBudget {
total_ms: u64,
start: Instant,
}
impl RobotBudget {
pub fn new(total_ms: u64) -> Self {
Self {
total_ms,
start: Instant::now(),
}
}
pub fn with_start(total_ms: u64, start: Instant) -> Self {
Self { total_ms, start }
}
pub fn total_ms(&self) -> u64 {
self.total_ms
}
pub fn elapsed_ms(&self) -> u64 {
u64::try_from(self.start.elapsed().as_millis()).unwrap_or(u64::MAX)
}
pub fn remaining_ms(&self) -> u64 {
self.total_ms.saturating_sub(self.elapsed_ms())
}
pub fn phase(&self) -> BudgetPhase {
budget_status(self.elapsed_ms(), self.total_ms)
}
pub fn is_exhausted(&self) -> bool {
self.phase() == BudgetPhase::Exhausted
}
pub fn is_healthy(&self) -> bool {
self.phase() == BudgetPhase::Healthy
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BudgetOutcome {
Complete,
Partial,
TimedOut,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BudgetBlock {
pub elapsed_ms: u64,
pub budget_ms: u64,
pub timed_out: bool,
#[serde(default)]
pub skipped_sections: Vec<String>,
#[serde(default)]
pub recommended_next_probe: Option<String>,
}
impl BudgetBlock {
pub fn from_budget(
budget: &RobotBudget,
skipped_sections: Vec<String>,
recommended_next_probe: Option<String>,
) -> Self {
let elapsed_ms = budget.elapsed_ms();
let budget_ms = budget.total_ms();
Self {
elapsed_ms,
budget_ms,
timed_out: budget_status(elapsed_ms, budget_ms) == BudgetPhase::Exhausted,
skipped_sections,
recommended_next_probe,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BudgetEnvelope<T> {
pub schema_version: u32,
pub outcome: BudgetOutcome,
pub timed_out: bool,
pub elapsed_ms: u64,
pub budget_ms: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skipped_sections: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub recommended_next_probe: Option<String>,
pub data: T,
}
impl<T> BudgetEnvelope<T> {
pub fn complete(data: T, elapsed_ms: u64, budget_ms: u64) -> Self {
Self {
schema_version: BUDGET_ENVELOPE_SCHEMA_VERSION,
outcome: BudgetOutcome::Complete,
timed_out: false,
elapsed_ms,
budget_ms,
skipped_sections: Vec::new(),
recommended_next_probe: None,
data,
}
}
pub fn partial(
data: T,
elapsed_ms: u64,
budget_ms: u64,
skipped_sections: Vec<String>,
) -> Self {
Self {
schema_version: BUDGET_ENVELOPE_SCHEMA_VERSION,
outcome: BudgetOutcome::Partial,
timed_out: false,
elapsed_ms,
budget_ms,
skipped_sections,
recommended_next_probe: None,
data,
}
}
pub fn timed_out(
data: T,
elapsed_ms: u64,
budget_ms: u64,
skipped_sections: Vec<String>,
) -> Self {
Self {
schema_version: BUDGET_ENVELOPE_SCHEMA_VERSION,
outcome: BudgetOutcome::TimedOut,
timed_out: true,
elapsed_ms,
budget_ms,
skipped_sections,
recommended_next_probe: None,
data,
}
}
pub fn from_budget(data: T, budget: &RobotBudget, skipped_sections: Vec<String>) -> Self {
let elapsed_ms = budget.elapsed_ms();
let budget_ms = budget.total_ms();
let exhausted = budget.is_exhausted();
match (skipped_sections.is_empty(), exhausted) {
(true, false) => Self::complete(data, elapsed_ms, budget_ms),
(_, true) => Self::timed_out(data, elapsed_ms, budget_ms, skipped_sections),
(false, false) => Self::partial(data, elapsed_ms, budget_ms, skipped_sections),
}
}
pub fn skip_section(mut self, name: impl Into<String>) -> Self {
self.skipped_sections.push(name.into());
self
}
pub fn with_next_probe(mut self, probe: impl Into<String>) -> Self {
self.recommended_next_probe = Some(probe.into());
self
}
pub fn is_degraded(&self) -> bool {
self.outcome != BudgetOutcome::Complete
}
pub fn map<U, F: FnOnce(T) -> U>(self, f: F) -> BudgetEnvelope<U> {
BudgetEnvelope {
schema_version: self.schema_version,
outcome: self.outcome,
timed_out: self.timed_out,
elapsed_ms: self.elapsed_ms,
budget_ms: self.budget_ms,
skipped_sections: self.skipped_sections,
recommended_next_probe: self.recommended_next_probe,
data: f(self.data),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn budget_status_is_pure_and_covers_phases() {
assert_eq!(budget_status(0, 1000), BudgetPhase::Healthy);
assert_eq!(budget_status(799, 1000), BudgetPhase::Healthy);
assert_eq!(budget_status(800, 1000), BudgetPhase::NearLimit);
assert_eq!(budget_status(999, 1000), BudgetPhase::NearLimit);
assert_eq!(budget_status(1000, 1000), BudgetPhase::Exhausted);
assert_eq!(budget_status(5000, 1000), BudgetPhase::Exhausted);
assert_eq!(budget_status(0, 0), BudgetPhase::Exhausted);
}
#[test]
fn zero_budget_is_immediately_exhausted() {
let b = RobotBudget::new(0);
assert!(b.is_exhausted());
assert!(!b.is_healthy());
assert_eq!(b.remaining_ms(), 0);
}
#[test]
fn large_budget_starts_healthy_with_headroom() {
let b = RobotBudget::new(60_000);
assert!(!b.is_exhausted());
assert!(b.remaining_ms() <= 60_000);
assert!(
b.remaining_ms() > 50_000,
"should have most of the budget left"
);
assert_eq!(b.total_ms(), 60_000);
}
#[test]
fn budget_block_zero_budget_is_an_exhausted_boundary() {
let budget = RobotBudget::new(0);
let block = BudgetBlock::from_budget(&budget, Vec::new(), None);
assert_eq!(block.budget_ms, 0);
assert!(block.timed_out);
assert!(block.skipped_sections.is_empty());
assert_eq!(block.recommended_next_probe, None);
}
#[test]
fn budget_block_complete_snapshot_has_stable_wire_shape() {
let budget = RobotBudget::new(60_000);
let block = BudgetBlock::from_budget(&budget, Vec::new(), None);
let expected_elapsed_ms = block.elapsed_ms;
assert!(!block.timed_out);
assert_eq!(
serde_json::to_value(&block).unwrap(),
json!({
"elapsed_ms": expected_elapsed_ms,
"budget_ms": 60_000,
"timed_out": false,
"skipped_sections": [],
"recommended_next_probe": null,
})
);
}
#[test]
fn budget_block_partial_snapshot_names_skips_and_next_probe() {
let budget = RobotBudget::new(60_000);
let block = BudgetBlock::from_budget(
&budget,
vec!["semantic".to_string(), "remote".to_string()],
Some("cass status --json".to_string()),
);
assert!(!block.timed_out);
assert_eq!(block.skipped_sections, ["semantic", "remote"]);
assert_eq!(
block.recommended_next_probe.as_deref(),
Some("cass status --json")
);
}
#[test]
fn budget_block_timeout_snapshot_preserves_partial_guidance() {
let start = Instant::now()
.checked_sub(std::time::Duration::from_millis(25))
.expect("25 milliseconds before now is representable");
let budget = RobotBudget::with_start(10, start);
let block = BudgetBlock::from_budget(
&budget,
vec!["deep-probe".to_string()],
Some("cass health --json".to_string()),
);
assert!(block.timed_out);
assert!(block.elapsed_ms >= block.budget_ms);
assert_eq!(block.skipped_sections, ["deep-probe"]);
assert_eq!(
block.recommended_next_probe.as_deref(),
Some("cass health --json")
);
let value = serde_json::to_value(&block).unwrap();
let round_trip: BudgetBlock = serde_json::from_value(value).unwrap();
assert_eq!(round_trip, block);
}
#[test]
fn complete_envelope_has_no_skips_and_is_not_degraded() {
let env = BudgetEnvelope::complete(json!({"ready": true}), 12, 8000);
assert_eq!(env.outcome, BudgetOutcome::Complete);
assert!(!env.timed_out);
assert!(!env.is_degraded());
assert!(env.skipped_sections.is_empty());
}
#[test]
fn timed_out_envelope_keeps_partial_data_and_flags() {
let env = BudgetEnvelope::timed_out(
json!({"index": "ok"}),
8001,
8000,
vec!["semantic".to_string(), "remote".to_string()],
)
.with_next_probe("cass health --json");
assert_eq!(env.outcome, BudgetOutcome::TimedOut);
assert!(env.timed_out);
assert!(env.is_degraded());
let value = serde_json::to_value(&env).unwrap();
assert_eq!(value["outcome"], "timed-out");
assert_eq!(value["timed_out"], true);
assert_eq!(value["elapsed_ms"], 8001);
assert_eq!(value["budget_ms"], 8000);
assert_eq!(value["skipped_sections"][0], "semantic");
assert_eq!(value["recommended_next_probe"], "cass health --json");
assert_eq!(value["data"]["index"], "ok");
}
#[test]
fn partial_envelope_is_proactive_not_timed_out() {
let env = BudgetEnvelope::partial(json!({}), 6500, 8000, vec!["pack".to_string()]);
assert_eq!(env.outcome, BudgetOutcome::Partial);
assert!(!env.timed_out);
assert!(env.is_degraded());
}
#[test]
fn from_budget_chooses_outcome() {
let healthy = RobotBudget::new(60_000);
let c = BudgetEnvelope::from_budget(json!({}), &healthy, vec![]);
assert_eq!(c.outcome, BudgetOutcome::Complete);
let p = BudgetEnvelope::from_budget(json!({}), &healthy, vec!["x".to_string()]);
assert_eq!(p.outcome, BudgetOutcome::Partial);
let dead = RobotBudget::new(0);
let t = BudgetEnvelope::from_budget(json!({}), &dead, vec!["x".to_string()]);
assert_eq!(t.outcome, BudgetOutcome::TimedOut);
assert!(t.timed_out);
}
#[test]
fn skip_section_chains_and_accumulates() {
let env = BudgetEnvelope::partial(json!({}), 100, 8000, vec![])
.skip_section("a")
.skip_section("b");
assert_eq!(env.skipped_sections, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn map_preserves_budget_metadata() {
let env = BudgetEnvelope::timed_out(5u32, 9000, 8000, vec!["s".to_string()]);
let mapped = env.map(|n| n.to_string());
assert_eq!(mapped.data, "5");
assert_eq!(mapped.outcome, BudgetOutcome::TimedOut);
assert!(mapped.timed_out);
assert_eq!(mapped.elapsed_ms, 9000);
assert_eq!(mapped.skipped_sections, vec!["s".to_string()]);
}
#[test]
fn envelope_round_trips_through_json() {
let env =
BudgetEnvelope::partial(json!({"k": 1}), 7000, 8000, vec!["semantic".to_string()])
.with_next_probe("cass triage --json");
let value = serde_json::to_value(&env).unwrap();
let back: BudgetEnvelope<serde_json::Value> = serde_json::from_value(value).unwrap();
assert_eq!(back, env);
}
#[test]
fn outcome_and_phase_wire_values_are_kebab() {
assert_eq!(
serde_json::to_string(&BudgetOutcome::TimedOut).unwrap(),
"\"timed-out\""
);
assert_eq!(
serde_json::to_string(&BudgetOutcome::Partial).unwrap(),
"\"partial\""
);
assert_eq!(
serde_json::to_string(&BudgetOutcome::Complete).unwrap(),
"\"complete\""
);
assert_eq!(
serde_json::to_string(&BudgetPhase::NearLimit).unwrap(),
"\"near-limit\""
);
assert_eq!(
serde_json::to_string(&BudgetPhase::Exhausted).unwrap(),
"\"exhausted\""
);
}
}