use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::ids;
use crate::types::ProofDebt;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProofDebtBudgetConfig {
pub weight_missing_source_basis: u64,
pub weight_missing_repro: u64,
pub weight_missing_benchmark: u64,
pub weight_missing_external_validation: u64,
pub warn_threshold_pct: u64,
pub degrade_threshold_pct: u64,
pub retract_threshold_pct: u64,
}
impl Default for ProofDebtBudgetConfig {
fn default() -> Self {
Self {
weight_missing_source_basis: 250_000,
weight_missing_repro: 150_000,
weight_missing_benchmark: 100_000,
weight_missing_external_validation: 75_000,
warn_threshold_pct: 80,
degrade_threshold_pct: 100,
retract_threshold_pct: 120,
}
}
}
impl ProofDebtBudgetConfig {
pub fn weight(&self, debt: &ProofDebt) -> u64 {
match debt {
ProofDebt::MissingSourceBasis => self.weight_missing_source_basis,
ProofDebt::MissingRepro => self.weight_missing_repro,
ProofDebt::MissingBenchmark => self.weight_missing_benchmark,
ProofDebt::MissingExternalValidation => self.weight_missing_external_validation,
ProofDebt::None => 0,
}
}
}
pub fn proof_debt_weight(debt: &ProofDebt) -> u64 {
ProofDebtBudgetConfig::default().weight(debt)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProofDebtBudgetV1 {
pub schema_version: String,
pub budget_id: String,
pub scope: String,
pub budget_micros: u64,
pub consumed_micros: u64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl ProofDebtBudgetV1 {
pub fn new(scope: &str, budget_micros: u64) -> Self {
let now = Utc::now();
Self {
schema_version: "ProofDebtBudgetV1".to_string(),
budget_id: ids::proof_debt_budget_id(scope),
scope: scope.to_string(),
budget_micros,
consumed_micros: 0,
created_at: now,
updated_at: now,
}
}
pub fn available_micros(&self) -> u64 {
self.budget_micros.saturating_sub(self.consumed_micros)
}
pub fn is_exhausted(&self) -> bool {
self.consumed_micros >= self.budget_micros
}
pub fn consumed_pct(&self) -> u64 {
if self.budget_micros == 0 {
return 100;
}
(self.consumed_micros * 100) / self.budget_micros
}
pub fn consume(
&mut self,
amount_micros: u64,
source: &str,
rationale: &str,
strict: bool,
) -> Option<ProofDebtDebitV1> {
let new_consumed = self.consumed_micros.saturating_add(amount_micros);
let overdrawn = new_consumed > self.budget_micros;
if overdrawn && strict {
return None;
}
let remaining = self.budget_micros.saturating_sub(new_consumed);
self.consumed_micros = new_consumed;
self.updated_at = Utc::now();
Some(ProofDebtDebitV1::new(
&self.budget_id,
amount_micros,
remaining,
overdrawn,
source,
rationale,
))
}
pub fn replenish(&mut self, amount_micros: u64, source: &str, rationale: &str) -> ProofDebtCreditV1 {
self.consumed_micros = self.consumed_micros.saturating_sub(amount_micros);
self.updated_at = Utc::now();
ProofDebtCreditV1::new(&self.budget_id, amount_micros, source, rationale)
}
pub fn consume_debt(
&mut self,
debt: &ProofDebt,
source: &str,
rationale: &str,
strict: bool,
) -> Option<ProofDebtDebitV1> {
let weight = proof_debt_weight(debt);
if weight == 0 {
return None;
}
self.consume(weight, source, rationale, strict)
}
pub fn consume_debt_with_config(
&mut self,
debt: &ProofDebt,
config: &ProofDebtBudgetConfig,
source: &str,
rationale: &str,
strict: bool,
) -> Option<ProofDebtDebitV1> {
let weight = config.weight(debt);
if weight == 0 {
return None;
}
self.consume(weight, source, rationale, strict)
}
pub fn resolve_debt(
&mut self,
debt: &ProofDebt,
source: &str,
rationale: &str,
) -> Option<ProofDebtCreditV1> {
let weight = proof_debt_weight(debt);
if weight == 0 {
return None;
}
Some(self.replenish(weight, source, rationale))
}
pub fn resolve_debt_with_config(
&mut self,
debt: &ProofDebt,
config: &ProofDebtBudgetConfig,
source: &str,
rationale: &str,
) -> Option<ProofDebtCreditV1> {
let weight = config.weight(debt);
if weight == 0 {
return None;
}
Some(self.replenish(weight, source, rationale))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProofDebtDebitV1 {
pub schema_version: String,
pub debit_id: String,
pub budget_id: String,
pub amount_micros: u64,
pub remaining_micros: u64,
pub overdrawn: bool,
pub source: String,
pub rationale: String,
pub recorded_time: DateTime<Utc>,
}
impl ProofDebtDebitV1 {
pub fn new(
budget_id: &str,
amount_micros: u64,
remaining_micros: u64,
overdrawn: bool,
source: &str,
rationale: &str,
) -> Self {
Self {
schema_version: "ProofDebtDebitV1".to_string(),
debit_id: ids::proof_debt_debit_id(budget_id, source, amount_micros),
budget_id: budget_id.to_string(),
amount_micros,
remaining_micros,
overdrawn,
source: source.to_string(),
rationale: rationale.to_string(),
recorded_time: Utc::now(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProofDebtCreditV1 {
pub schema_version: String,
pub credit_id: String,
pub budget_id: String,
pub amount_micros: u64,
pub source: String,
pub rationale: String,
pub recorded_time: DateTime<Utc>,
}
impl ProofDebtCreditV1 {
pub fn new(budget_id: &str, amount_micros: u64, source: &str, rationale: &str) -> Self {
Self {
schema_version: "ProofDebtCreditV1".to_string(),
credit_id: ids::proof_debt_credit_id(budget_id, source, amount_micros),
budget_id: budget_id.to_string(),
amount_micros,
source: source.to_string(),
rationale: rationale.to_string(),
recorded_time: Utc::now(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProofDebtGateDecision {
Proceed,
Warn,
Degrade,
Retract,
Waived,
}
impl ProofDebtGateDecision {
pub fn allows_proceed(&self) -> bool {
matches!(self, Self::Proceed | Self::Warn | Self::Waived)
}
pub fn blocks(&self) -> bool {
matches!(self, Self::Degrade | Self::Retract)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProofDebtGateResult {
pub budget_id: String,
pub decision: ProofDebtGateDecision,
pub consumed_pct: u64,
pub exhausted: bool,
pub summary: String,
}
pub fn evaluate_proof_debt_gate(budget: &ProofDebtBudgetV1) -> ProofDebtGateResult {
evaluate_proof_debt_gate_with_config(budget, &ProofDebtBudgetConfig::default())
}
pub fn evaluate_proof_debt_gate_with_config(
budget: &ProofDebtBudgetV1,
config: &ProofDebtBudgetConfig,
) -> ProofDebtGateResult {
let pct = budget.consumed_pct();
let exhausted = budget.is_exhausted();
let _overdrawn = budget.consumed_micros > budget.budget_micros;
let (decision, summary) = if pct >= config.retract_threshold_pct {
(
ProofDebtGateDecision::Retract,
format!(
"proof debt severely overdrawn: {}% consumed ({} / {} micros)",
pct, budget.consumed_micros, budget.budget_micros
),
)
} else if pct >= config.degrade_threshold_pct {
(
ProofDebtGateDecision::Degrade,
format!(
"proof debt exhausted: {}% consumed ({} / {} micros)",
pct, budget.consumed_micros, budget.budget_micros
),
)
} else if pct >= config.warn_threshold_pct {
(
ProofDebtGateDecision::Warn,
format!(
"proof debt warning: {}% consumed ({} / {} micros)",
pct, budget.consumed_micros, budget.budget_micros
),
)
} else {
(
ProofDebtGateDecision::Proceed,
format!(
"proof debt ok: {}% consumed ({} / {} micros)",
pct, budget.consumed_micros, budget.budget_micros
),
)
};
ProofDebtGateResult {
budget_id: budget.budget_id.clone(),
decision,
consumed_pct: pct,
exhausted,
summary,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProofDebtWaiverReceipt {
pub schema_version: String,
pub waiver_id: String,
pub budget_id: String,
pub waived_amount_micros: u64,
pub operator_ref: String,
pub rationale: String,
pub recorded_time: DateTime<Utc>,
}
impl ProofDebtWaiverReceipt {
pub fn new(
budget_id: &str,
waived_amount_micros: u64,
operator_ref: &str,
rationale: &str,
) -> Self {
Self {
schema_version: "ProofDebtWaiverReceiptV1".to_string(),
waiver_id: ids::proof_debt_waiver_id(budget_id, operator_ref, waived_amount_micros),
budget_id: budget_id.to_string(),
waived_amount_micros,
operator_ref: operator_ref.to_string(),
rationale: rationale.to_string(),
recorded_time: Utc::now(),
}
}
pub fn apply(&self, budget: &mut ProofDebtBudgetV1) -> ProofDebtCreditV1 {
budget.replenish(
self.waived_amount_micros,
&format!("waiver:{}", self.waiver_id),
&self.rationale,
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ProofDebt;
#[test]
fn budget_new_has_id() {
let budget = ProofDebtBudgetV1::new("claim:clm_abc", 500_000);
assert!(budget.budget_id.starts_with("pdb_"));
assert_eq!(budget.budget_micros, 500_000);
assert_eq!(budget.consumed_micros, 0);
assert_eq!(budget.available_micros(), 500_000);
assert!(!budget.is_exhausted());
}
#[test]
fn consume_debt_reduces_available() {
let mut budget = ProofDebtBudgetV1::new("claim:clm_abc", 500_000);
let debit = budget.consume_debt(
&ProofDebt::MissingSourceBasis,
"claim:clm_abc",
"claim lacks source basis",
false,
);
assert!(debit.is_some());
let debit = debit.unwrap();
assert_eq!(debit.amount_micros, 250_000); assert_eq!(budget.consumed_micros, 250_000);
assert_eq!(budget.available_micros(), 250_000);
assert!(!budget.is_exhausted());
}
#[test]
fn consume_strict_blocks_overdraw() {
let mut budget = ProofDebtBudgetV1::new("claim:clm_abc", 100_000);
let result = budget.consume(150_000, "test", "overdraw attempt", true);
assert!(result.is_none());
assert_eq!(budget.consumed_micros, 0);
}
#[test]
fn consume_non_strict_allows_overdraw() {
let mut budget = ProofDebtBudgetV1::new("claim:clm_abc", 100_000);
let debit = budget.consume(150_000, "test", "overdraw", false);
assert!(debit.is_some());
let debit = debit.unwrap();
assert!(debit.overdrawn);
assert!(budget.is_exhausted());
}
#[test]
fn resolve_debt_replenishes() {
let mut budget = ProofDebtBudgetV1::new("claim:clm_abc", 500_000);
budget.consume_debt(&ProofDebt::MissingSourceBasis, "claim:clm_abc", "initial", false);
assert_eq!(budget.consumed_micros, 250_000);
let credit = budget.resolve_debt(&ProofDebt::MissingSourceBasis, "evidence:evb_xyz", "source basis found");
assert!(credit.is_some());
assert_eq!(budget.consumed_micros, 0);
}
#[test]
fn gate_proceed_when_under_80() {
let mut budget = ProofDebtBudgetV1::new("claim:clm_abc", 500_000);
budget.consume(100_000, "test", "20%", false); let result = evaluate_proof_debt_gate(&budget);
assert_eq!(result.decision, ProofDebtGateDecision::Proceed);
assert!(!result.exhausted);
}
#[test]
fn gate_warn_at_80() {
let mut budget = ProofDebtBudgetV1::new("claim:clm_abc", 500_000);
budget.consume(400_000, "test", "80%", false); let result = evaluate_proof_debt_gate(&budget);
assert_eq!(result.decision, ProofDebtGateDecision::Warn);
}
#[test]
fn gate_degrade_when_exhausted() {
let mut budget = ProofDebtBudgetV1::new("claim:clm_abc", 500_000);
budget.consume(500_000, "test", "100%", false); let result = evaluate_proof_debt_gate(&budget);
assert_eq!(result.decision, ProofDebtGateDecision::Degrade);
assert!(result.exhausted);
}
#[test]
fn gate_retract_when_severely_overdrawn() {
let mut budget = ProofDebtBudgetV1::new("claim:clm_abc", 500_000);
budget.consume(600_000, "test", "120%", false); let result = evaluate_proof_debt_gate(&budget);
assert_eq!(result.decision, ProofDebtGateDecision::Retract);
assert!(result.decision.blocks());
}
#[test]
fn waiver_replenishes_budget() {
let mut budget = ProofDebtBudgetV1::new("claim:clm_abc", 500_000);
budget.consume(400_000, "test", "debt", false);
assert_eq!(budget.available_micros(), 100_000);
let waiver = ProofDebtWaiverReceipt::new(
&budget.budget_id,
200_000,
"operator:josh",
"accepting risk on missing benchmark",
);
let _credit = waiver.apply(&mut budget);
assert_eq!(budget.consumed_micros, 200_000);
assert_eq!(budget.available_micros(), 300_000);
}
#[test]
fn proof_debt_weights_are_nonzero_except_none() {
assert_eq!(proof_debt_weight(&ProofDebt::None), 0);
assert!(proof_debt_weight(&ProofDebt::MissingSourceBasis) > 0);
assert!(proof_debt_weight(&ProofDebt::MissingBenchmark) > 0);
assert!(proof_debt_weight(&ProofDebt::MissingRepro) > 0);
assert!(proof_debt_weight(&ProofDebt::MissingExternalValidation) > 0);
}
#[test]
fn source_basis_is_heaviest_debt() {
let source_weight = proof_debt_weight(&ProofDebt::MissingSourceBasis);
assert!(source_weight > proof_debt_weight(&ProofDebt::MissingRepro));
assert!(source_weight > proof_debt_weight(&ProofDebt::MissingBenchmark));
assert!(source_weight > proof_debt_weight(&ProofDebt::MissingExternalValidation));
}
#[test]
fn gate_decision_allows_proceed() {
assert!(ProofDebtGateDecision::Proceed.allows_proceed());
assert!(ProofDebtGateDecision::Warn.allows_proceed());
assert!(ProofDebtGateDecision::Waived.allows_proceed());
assert!(!ProofDebtGateDecision::Degrade.allows_proceed());
assert!(!ProofDebtGateDecision::Retract.allows_proceed());
}
#[test]
fn custom_config_changes_thresholds() {
let mut budget = ProofDebtBudgetV1::new("claim:clm_custom", 1_000_000);
budget.consume(500_000, "test", "50%", false);
let result = evaluate_proof_debt_gate(&budget);
assert_eq!(result.decision, ProofDebtGateDecision::Proceed);
let config = ProofDebtBudgetConfig {
warn_threshold_pct: 40,
..ProofDebtBudgetConfig::default()
};
let result = evaluate_proof_debt_gate_with_config(&budget, &config);
assert_eq!(result.decision, ProofDebtGateDecision::Warn);
}
#[test]
fn custom_config_weights_change_consumption() {
let config = ProofDebtBudgetConfig {
weight_missing_source_basis: 500_000,
..ProofDebtBudgetConfig::default()
};
let mut budget = ProofDebtBudgetV1::new("claim:clm_weight", 1_000_000);
let debit = budget.consume_debt_with_config(
&ProofDebt::MissingSourceBasis,
&config,
"test",
"custom weight",
false,
);
assert!(debit.is_some());
assert_eq!(budget.consumed_micros, 500_000);
assert_eq!(budget.consumed_pct(), 50);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProofDebtSummaryV1 {
pub schema_version: String,
pub budget_id: String,
pub scope: String,
pub budget_micros: u64,
pub consumed_micros: u64,
pub available_micros: u64,
pub consumed_pct: u64,
pub exhausted: bool,
pub gate_decision: ProofDebtGateDecision,
pub gate_summary: String,
}
impl ProofDebtSummaryV1 {
pub fn from_budget(budget: &ProofDebtBudgetV1) -> Self {
let gate = evaluate_proof_debt_gate(budget);
Self {
schema_version: "ProofDebtSummaryV1".to_string(),
budget_id: budget.budget_id.clone(),
scope: budget.scope.clone(),
budget_micros: budget.budget_micros,
consumed_micros: budget.consumed_micros,
available_micros: budget.available_micros(),
consumed_pct: budget.consumed_pct(),
exhausted: budget.is_exhausted(),
gate_decision: gate.decision,
gate_summary: gate.summary,
}
}
}
pub fn total_proof_debt_weight(debts: &[ProofDebt]) -> u64 {
debts.iter().map(proof_debt_weight).sum()
}
pub fn total_proof_debt_weight_with_config(
debts: &[ProofDebt],
config: &ProofDebtBudgetConfig,
) -> u64 {
debts.iter().map(|d| config.weight(d)).sum()
}
pub fn budget_for_claim(
claim_id: &str,
debts: &[ProofDebt],
budget_micros: u64,
) -> (ProofDebtBudgetV1, Vec<ProofDebtDebitV1>) {
let mut budget = ProofDebtBudgetV1::new(claim_id, budget_micros);
let mut debits = Vec::new();
for debt in debts {
if let Some(debit) = budget.consume_debt(debt, claim_id, &format!("{:?}", debt), false) {
debits.push(debit);
}
}
(budget, debits)
}
#[cfg(test)]
mod summary_tests {
use super::*;
#[test]
fn summary_reflects_budget_state() {
let mut budget = ProofDebtBudgetV1::new("claim:clm_summary", 500_000);
budget.consume(400_000, "test", "80%", false);
let summary = ProofDebtSummaryV1::from_budget(&budget);
assert_eq!(summary.scope, "claim:clm_summary");
assert_eq!(summary.consumed_micros, 400_000);
assert_eq!(summary.available_micros, 100_000);
assert_eq!(summary.consumed_pct, 80);
assert!(!summary.exhausted);
assert_eq!(summary.gate_decision, ProofDebtGateDecision::Warn);
assert!(!summary.gate_summary.is_empty());
}
#[test]
fn total_weight_sums_all_debts() {
let debts = vec![
ProofDebt::MissingSourceBasis,
ProofDebt::MissingRepro,
ProofDebt::MissingBenchmark,
];
let total = total_proof_debt_weight(&debts);
assert_eq!(total, 500_000);
}
#[test]
fn budget_for_claim_consumes_all_debts() {
let debts = vec![
ProofDebt::MissingSourceBasis,
ProofDebt::MissingBenchmark,
];
let (budget, debits) = budget_for_claim("claim:clm_auto", &debts, 1_000_000);
assert_eq!(budget.consumed_micros, 350_000);
assert_eq!(debits.len(), 2);
assert_eq!(budget.available_micros(), 650_000);
}
#[test]
fn budget_for_claim_with_no_debts() {
let debts = vec![ProofDebt::None];
let (budget, debits) = budget_for_claim("claim:clm_clean", &debts, 500_000);
assert_eq!(budget.consumed_micros, 0);
assert!(debits.is_empty());
}
}