1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
10pub enum SubtractionOperation {
11 Dedupe,
13 Summarize,
15 Compact,
17 Retire,
19 Quarantine,
21 Minimize,
23 SupportCoreExtraction,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct SubtractionPlanV1 {
31 pub plan_id: String,
33 pub operation: SubtractionOperation,
35 pub target_ids: Vec<String>,
37 pub preserved_invariants: Vec<InvariantBudgetV1>,
39 pub dry_run: bool,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct InvariantBudgetV1 {
46 pub budget_type: BudgetType,
48 pub max_loss: usize,
50 pub current_at_risk: usize,
52 pub exceeded: bool,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58pub enum BudgetType {
59 Replay,
61 AsOfQuery,
63 ClaimSupport,
65 ReceiptLineage,
67 LegalRetention,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct SupportCoreV1 {
74 pub invariant: String,
76 pub retained_ids: Vec<String>,
78 pub removable_ids: Vec<String>,
80 pub verified: bool,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct RemovalFrontierV1 {
87 pub invariant: String,
89 pub removal_set: Vec<String>,
91 pub found: bool,
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct CompactionReceiptV1 {
98 pub receipt_id: String,
100 pub before_digest: String,
102 pub after_digest: String,
104 pub what_lost: Vec<String>,
106 pub what_preserved: Vec<String>,
108 pub as_of_queries_correct: bool,
110 pub timestamp: String,
112}
113
114impl SubtractionPlanV1 {
115 pub fn new(operation: SubtractionOperation, target_ids: Vec<String>) -> Self {
117 Self {
118 plan_id: format!(
119 "sp:{}:{}",
120 match operation {
121 SubtractionOperation::Dedupe => "dedupe",
122 SubtractionOperation::Summarize => "summarize",
123 SubtractionOperation::Compact => "compact",
124 SubtractionOperation::Retire => "retire",
125 SubtractionOperation::Quarantine => "quarantine",
126 SubtractionOperation::Minimize => "minimize",
127 SubtractionOperation::SupportCoreExtraction => "support-core",
128 },
129 chrono::Utc::now().timestamp()
130 ),
131 operation,
132 target_ids,
133 preserved_invariants: vec![],
134 dry_run: true,
135 }
136 }
137
138 pub fn with_invariant(mut self, budget: InvariantBudgetV1) -> Self {
140 self.preserved_invariants.push(budget);
141 self
142 }
143
144 pub fn can_proceed(&self) -> bool {
146 !self.preserved_invariants.iter().any(|b| b.exceeded)
147 }
148}
149
150impl SupportCoreV1 {
151 pub fn would_break(&self, artifact_id: &str) -> bool {
153 self.retained_ids.contains(&artifact_id.to_string())
154 }
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160
161 #[test]
162 fn subtraction_plan_starts_as_dry_run() {
163 let plan = SubtractionPlanV1::new(SubtractionOperation::Compact, vec!["a1".into()]);
164 assert!(plan.dry_run);
165 assert!(plan.can_proceed());
166 }
167
168 #[test]
169 fn plan_blocked_when_budget_exceeded() {
170 let plan = SubtractionPlanV1::new(SubtractionOperation::Compact, vec!["a1".into()])
171 .with_invariant(InvariantBudgetV1 {
172 budget_type: BudgetType::Replay,
173 max_loss: 10,
174 current_at_risk: 15,
175 exceeded: true,
176 });
177 assert!(!plan.can_proceed());
178 }
179
180 #[test]
181 fn support_core_would_break_on_retained() {
182 let core = SupportCoreV1 {
183 invariant: "replay".into(),
184 retained_ids: vec!["a1".into(), "a2".into()],
185 removable_ids: vec!["a3".into()],
186 verified: true,
187 };
188 assert!(core.would_break("a1"));
189 assert!(!core.would_break("a3"));
190 }
191
192 #[test]
193 fn removal_frontier_found() {
194 let frontier = RemovalFrontierV1 {
195 invariant: "claim_support".into(),
196 removal_set: vec!["a1".into()],
197 found: true,
198 };
199 assert!(frontier.found);
200 }
201
202 #[test]
203 fn compaction_receipt_records_before_after() {
204 let receipt = CompactionReceiptV1 {
205 receipt_id: "cr:1".into(),
206 before_digest: "abc".into(),
207 after_digest: "def".into(),
208 what_lost: vec!["old_receipts".into()],
209 what_preserved: vec!["current_claims".into()],
210 as_of_queries_correct: true,
211 timestamp: chrono::Utc::now().to_rfc3339(),
212 };
213 assert_ne!(receipt.before_digest, receipt.after_digest);
214 assert!(receipt.as_of_queries_correct);
215 }
216
217 #[test]
218 fn subtraction_preserves_invariant_or_fails() {
219 let plan = SubtractionPlanV1::new(SubtractionOperation::Retire, vec!["a1".into()])
221 .with_invariant(InvariantBudgetV1 {
222 budget_type: BudgetType::ClaimSupport,
223 max_loss: 0,
224 current_at_risk: 1,
225 exceeded: true,
226 });
227 assert!(
228 !plan.can_proceed(),
229 "subtraction must not proceed when invariant budget is exceeded"
230 );
231 }
232}