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 {
Self::new_at(scope, budget_micros, Utc::now())
}
pub fn new_at(scope: &str, budget_micros: u64, recorded_time: DateTime<Utc>) -> Self {
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: recorded_time,
updated_at: recorded_time,
}
}
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;
}
let percentage = (u128::from(self.consumed_micros) * 100) / u128::from(self.budget_micros);
percentage.min(u128::from(u64::MAX)) as u64
}
pub fn consume(
&mut self,
amount_micros: u64,
source: &str,
rationale: &str,
strict: bool,
) -> Option<ProofDebtDebitV1> {
self.consume_at(amount_micros, source, rationale, strict, Utc::now())
}
pub fn consume_at(
&mut self,
amount_micros: u64,
source: &str,
rationale: &str,
strict: bool,
recorded_time: DateTime<Utc>,
) -> 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 = recorded_time;
Some(ProofDebtDebitV1::new_at(
&self.budget_id,
amount_micros,
remaining,
overdrawn,
source,
rationale,
recorded_time,
))
}
pub fn replenish(
&mut self,
amount_micros: u64,
source: &str,
rationale: &str,
) -> ProofDebtCreditV1 {
self.replenish_at(amount_micros, source, rationale, Utc::now())
}
pub fn replenish_at(
&mut self,
amount_micros: u64,
source: &str,
rationale: &str,
recorded_time: DateTime<Utc>,
) -> ProofDebtCreditV1 {
self.consumed_micros = self.consumed_micros.saturating_sub(amount_micros);
self.updated_at = recorded_time;
ProofDebtCreditV1::new_at(
&self.budget_id,
amount_micros,
source,
rationale,
recorded_time,
)
}
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::new_at(
budget_id,
amount_micros,
remaining_micros,
overdrawn,
source,
rationale,
Utc::now(),
)
}
pub fn new_at(
budget_id: &str,
amount_micros: u64,
remaining_micros: u64,
overdrawn: bool,
source: &str,
rationale: &str,
recorded_time: DateTime<Utc>,
) -> 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,
}
}
}
#[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::new_at(budget_id, amount_micros, source, rationale, Utc::now())
}
pub fn new_at(
budget_id: &str,
amount_micros: u64,
source: &str,
rationale: &str,
recorded_time: DateTime<Utc>,
) -> 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,
}
}
}
#[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 waiver_id: Option<String>,
pub waived_amount_micros: Option<u64>,
pub summary: String,
}
pub fn evaluate_proof_debt_gate(budget: &ProofDebtBudgetV1) -> ProofDebtGateResult {
evaluate_proof_debt_gate_with_waiver_and_config(budget, None, &ProofDebtBudgetConfig::default())
}
pub fn evaluate_proof_debt_gate_with_waiver(
budget: &ProofDebtBudgetV1,
waiver: Option<&VerifiedProofDebtWaiver>,
) -> ProofDebtGateResult {
evaluate_proof_debt_gate_with_waiver_and_config(
budget,
waiver,
&ProofDebtBudgetConfig::default(),
)
}
pub fn evaluate_proof_debt_gate_with_config(
budget: &ProofDebtBudgetV1,
config: &ProofDebtBudgetConfig,
) -> ProofDebtGateResult {
evaluate_proof_debt_gate_with_waiver_and_config(budget, None, config)
}
pub fn evaluate_proof_debt_gate_with_waiver_and_config(
budget: &ProofDebtBudgetV1,
waiver: Option<&VerifiedProofDebtWaiver>,
config: &ProofDebtBudgetConfig,
) -> ProofDebtGateResult {
let pct = budget.consumed_pct();
let exhausted = budget.is_exhausted();
let _overdrawn = budget.consumed_micros > budget.budget_micros;
let (decision, summary, waiver_id, waived_amount_micros) = if let Some(verified) =
waiver.filter(|verified| verified.applies_to(budget))
{
(
ProofDebtGateDecision::Waived,
format!(
"proof debt waived for bounded proceeding: {}% consumed ({} / {} micros), waiver {} authorizes {} micros",
pct, budget.consumed_micros, budget.budget_micros, verified.waiver_id(), verified.waived_amount_micros()
),
Some(verified.waiver_id().to_string()),
Some(verified.waived_amount_micros()),
)
} else if pct >= config.retract_threshold_pct {
(
ProofDebtGateDecision::Retract,
format!(
"proof debt severely overdrawn: {}% consumed ({} / {} micros)",
pct, budget.consumed_micros, budget.budget_micros
),
None,
None,
)
} else if pct >= config.degrade_threshold_pct {
(
ProofDebtGateDecision::Degrade,
format!(
"proof debt exhausted: {}% consumed ({} / {} micros)",
pct, budget.consumed_micros, budget.budget_micros
),
None,
None,
)
} else if pct >= config.warn_threshold_pct {
(
ProofDebtGateDecision::Warn,
format!(
"proof debt warning: {}% consumed ({} / {} micros)",
pct, budget.consumed_micros, budget.budget_micros
),
None,
None,
)
} else {
(
ProofDebtGateDecision::Proceed,
format!(
"proof debt ok: {}% consumed ({} / {} micros)",
pct, budget.consumed_micros, budget.budget_micros
),
None,
None,
)
};
ProofDebtGateResult {
budget_id: budget.budget_id.clone(),
decision,
consumed_pct: pct,
exhausted,
waiver_id,
waived_amount_micros,
summary,
}
}
pub const PROOF_DEBT_WAIVER_SCHEMA_VERSION: &str = "ProofDebtWaiverReceiptV1";
pub const PROOF_DEBT_WAIVER_AUTHORIZATION_DOMAIN: &str = "claim-ledger.proof-debt-waiver.v1";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProofDebtWaiverReceipt {
pub schema_version: String,
pub authorization_domain: String,
pub waiver_id: String,
pub budget_id: String,
pub scope: String,
pub budget_micros: u64,
pub outstanding_debt_micros: u64,
pub waived_amount_micros: u64,
pub operator_ref: String,
pub rationale: String,
pub recorded_time: DateTime<Utc>,
}
impl ProofDebtWaiverReceipt {
pub fn new(
budget: &ProofDebtBudgetV1,
waived_amount_micros: u64,
operator_ref: &str,
rationale: &str,
) -> Self {
Self::new_at(
budget,
waived_amount_micros,
operator_ref,
rationale,
Utc::now(),
)
}
pub fn new_at(
budget: &ProofDebtBudgetV1,
waived_amount_micros: u64,
operator_ref: &str,
rationale: &str,
recorded_time: DateTime<Utc>,
) -> Self {
let schema_version = PROOF_DEBT_WAIVER_SCHEMA_VERSION.to_string();
let authorization_domain = PROOF_DEBT_WAIVER_AUTHORIZATION_DOMAIN.to_string();
let outstanding_debt_micros = budget.consumed_micros;
let canonical_recorded_time = Self::canonical_recorded_time(recorded_time);
Self {
waiver_id: ids::proof_debt_waiver_id(ids::ProofDebtWaiverIdParts {
schema_version: &schema_version,
authorization_domain: &authorization_domain,
budget_id: &budget.budget_id,
scope: &budget.scope,
budget_micros: budget.budget_micros,
outstanding_debt_micros,
waived_amount_micros,
operator_ref,
rationale,
recorded_time: &canonical_recorded_time,
}),
schema_version,
authorization_domain,
budget_id: budget.budget_id.clone(),
scope: budget.scope.clone(),
budget_micros: budget.budget_micros,
outstanding_debt_micros,
waived_amount_micros,
operator_ref: operator_ref.to_string(),
rationale: rationale.to_string(),
recorded_time,
}
}
pub fn canonical_recorded_time(recorded_time: DateTime<Utc>) -> String {
recorded_time.to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
}
pub fn expected_waiver_id(&self) -> String {
let canonical_recorded_time = Self::canonical_recorded_time(self.recorded_time);
ids::proof_debt_waiver_id(ids::ProofDebtWaiverIdParts {
schema_version: &self.schema_version,
authorization_domain: &self.authorization_domain,
budget_id: &self.budget_id,
scope: &self.scope,
budget_micros: self.budget_micros,
outstanding_debt_micros: self.outstanding_debt_micros,
waived_amount_micros: self.waived_amount_micros,
operator_ref: &self.operator_ref,
rationale: &self.rationale,
recorded_time: &canonical_recorded_time,
})
}
pub fn has_valid_integrity(&self) -> bool {
self.schema_version == PROOF_DEBT_WAIVER_SCHEMA_VERSION
&& self.authorization_domain == PROOF_DEBT_WAIVER_AUTHORIZATION_DOMAIN
&& self.waiver_id == self.expected_waiver_id()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProofDebtWaiverValidationError {
UnsupportedSchema,
WrongAuthorizationDomain,
IntegrityMismatch,
BudgetMismatch,
DebtSnapshotMismatch,
InsufficientWaivedAmount,
UnauthorizedOperator,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedProofDebtWaiver {
receipt: ProofDebtWaiverReceipt,
}
impl VerifiedProofDebtWaiver {
fn applies_to(&self, budget: &ProofDebtBudgetV1) -> bool {
self.receipt.has_valid_integrity()
&& self.receipt.budget_id == budget.budget_id
&& self.receipt.scope == budget.scope
&& self.receipt.budget_micros == budget.budget_micros
&& self.receipt.outstanding_debt_micros == budget.consumed_micros
&& self.receipt.waived_amount_micros >= self.receipt.outstanding_debt_micros
}
fn waiver_id(&self) -> &str {
&self.receipt.waiver_id
}
fn waived_amount_micros(&self) -> u64 {
self.receipt.waived_amount_micros
}
}
pub fn verify_proof_debt_waiver<F>(
budget: &ProofDebtBudgetV1,
receipt: ProofDebtWaiverReceipt,
authorize_operator: F,
) -> Result<VerifiedProofDebtWaiver, ProofDebtWaiverValidationError>
where
F: FnOnce(&str) -> bool,
{
if receipt.schema_version != PROOF_DEBT_WAIVER_SCHEMA_VERSION {
return Err(ProofDebtWaiverValidationError::UnsupportedSchema);
}
if receipt.authorization_domain != PROOF_DEBT_WAIVER_AUTHORIZATION_DOMAIN {
return Err(ProofDebtWaiverValidationError::WrongAuthorizationDomain);
}
if !receipt.has_valid_integrity() {
return Err(ProofDebtWaiverValidationError::IntegrityMismatch);
}
if receipt.budget_id != budget.budget_id
|| receipt.scope != budget.scope
|| receipt.budget_micros != budget.budget_micros
{
return Err(ProofDebtWaiverValidationError::BudgetMismatch);
}
if receipt.outstanding_debt_micros != budget.consumed_micros {
return Err(ProofDebtWaiverValidationError::DebtSnapshotMismatch);
}
if receipt.waived_amount_micros < receipt.outstanding_debt_micros {
return Err(ProofDebtWaiverValidationError::InsufficientWaivedAmount);
}
if !authorize_operator(&receipt.operator_ref) {
return Err(ProofDebtWaiverValidationError::UnauthorizedOperator);
}
Ok(VerifiedProofDebtWaiver { receipt })
}
#[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_preserves_debt_and_authorizes_bounded_proceeding() {
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,
400_000,
"operator:josh",
"accepting risk on missing benchmark",
);
assert_eq!(budget.consumed_micros, 400_000);
assert_eq!(budget.available_micros(), 100_000);
let waiver =
verify_proof_debt_waiver(&budget, waiver, |operator| operator == "operator:josh")
.unwrap();
let gate = evaluate_proof_debt_gate_with_waiver(&budget, Some(&waiver));
assert_eq!(gate.decision, ProofDebtGateDecision::Waived);
}
#[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,
pub waiver_id: Option<String>,
pub waived_amount_micros: Option<u64>,
}
impl ProofDebtSummaryV1 {
pub fn from_budget(budget: &ProofDebtBudgetV1) -> Self {
Self::from_budget_with_waiver(budget, None)
}
pub fn from_budget_with_waiver(
budget: &ProofDebtBudgetV1,
waiver: Option<&VerifiedProofDebtWaiver>,
) -> Self {
let gate = evaluate_proof_debt_gate_with_waiver(budget, waiver);
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,
waiver_id: gate.waiver_id,
waived_amount_micros: gate.waived_amount_micros,
}
}
}
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());
}
}