Skip to main content

sim_lib_view_device/
reaper.rs

1//! Deterministic retention reaper for device samples and content refs.
2
3use std::collections::BTreeMap;
4
5use sim_kernel::{Expr, Symbol};
6
7use crate::{ConsentReceipt, FrameClock};
8
9/// Stable key for a stored device sample or referenced content value.
10#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct StoreKey(Symbol);
12
13impl StoreKey {
14    /// Builds a store key from a symbol.
15    pub fn new(symbol: Symbol) -> Self {
16        Self(symbol)
17    }
18
19    /// Builds a store key in the `device/store` namespace.
20    pub fn named(name: impl Into<String>) -> Self {
21        Self(Symbol::qualified("device/store", name.into()))
22    }
23
24    /// Returns the backing symbol.
25    pub fn as_symbol(&self) -> &Symbol {
26        &self.0
27    }
28}
29
30/// One stored sample governed by a consent receipt.
31#[derive(Clone, Debug, PartialEq)]
32pub struct StoredSample {
33    key: StoreKey,
34    receipt_seq: u64,
35    tick: u64,
36    content_refs: Vec<StoreKey>,
37    value: Expr,
38}
39
40impl StoredSample {
41    /// Builds a stored sample.
42    pub fn new(
43        key: StoreKey,
44        receipt_seq: u64,
45        tick: u64,
46        content_refs: Vec<StoreKey>,
47        value: Expr,
48    ) -> Self {
49        Self {
50            key,
51            receipt_seq,
52            tick,
53            content_refs,
54            value,
55        }
56    }
57
58    /// Returns this sample's key.
59    pub fn key(&self) -> &StoreKey {
60        &self.key
61    }
62
63    /// Returns the receipt sequence governing this sample.
64    pub fn receipt_seq(&self) -> u64 {
65        self.receipt_seq
66    }
67
68    /// Returns the modeled insertion tick.
69    pub fn tick(&self) -> u64 {
70        self.tick
71    }
72
73    /// Returns the referenced content keys.
74    pub fn content_refs(&self) -> &[StoreKey] {
75        &self.content_refs
76    }
77
78    /// Returns the stored expression.
79    pub fn value(&self) -> &Expr {
80        &self.value
81    }
82}
83
84/// In-memory sample/content store used by deterministic device tests.
85#[derive(Clone, Debug, Default, PartialEq)]
86pub struct DeviceSampleStore {
87    samples: BTreeMap<StoreKey, StoredSample>,
88    content: BTreeMap<StoreKey, Expr>,
89}
90
91impl DeviceSampleStore {
92    /// Builds an empty store.
93    pub fn new() -> Self {
94        Self::default()
95    }
96
97    /// Inserts referenced content.
98    pub fn insert_content(&mut self, key: StoreKey, value: Expr) {
99        self.content.insert(key, value);
100    }
101
102    /// Inserts a stored sample.
103    pub fn insert_sample(&mut self, sample: StoredSample) {
104        self.samples.insert(sample.key.clone(), sample);
105    }
106
107    /// Returns true when a sample is present.
108    pub fn contains_sample(&self, key: &StoreKey) -> bool {
109        self.samples.contains_key(key)
110    }
111
112    /// Returns true when referenced content is present.
113    pub fn contains_content(&self, key: &StoreKey) -> bool {
114        self.content.contains_key(key)
115    }
116
117    /// Returns the number of stored samples.
118    pub fn sample_len(&self) -> usize {
119        self.samples.len()
120    }
121
122    /// Returns a stored sample by key.
123    pub fn sample(&self, key: &StoreKey) -> Option<&StoredSample> {
124        self.samples.get(key)
125    }
126
127    /// Returns the number of referenced content entries.
128    pub fn content_len(&self) -> usize {
129        self.content.len()
130    }
131}
132
133/// Eviction record produced by a retention sweep.
134#[derive(Clone, Debug, PartialEq, Eq)]
135pub struct Evicted {
136    /// Key that was removed.
137    pub key: StoreKey,
138    /// Reason for eviction.
139    pub reason: Symbol,
140}
141
142/// Retention/redaction action selected for a stored device value.
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum PrivacyMode {
145    /// Keep the value while the retention window remains open.
146    Retain,
147    /// Redact the value under the reaper contract.
148    Redact,
149    /// Delete the value under the reaper contract.
150    Delete,
151}
152
153/// Reaper directive derived from consent receipt policy.
154#[derive(Clone, Debug, PartialEq, Eq)]
155pub struct ReaperDirective {
156    /// Reaper action.
157    pub mode: PrivacyMode,
158    /// Symbols naming fields or streams to redact.
159    pub redact: Vec<Symbol>,
160}
161
162impl ReaperDirective {
163    /// Builds a directive from a receipt.
164    pub fn from_receipt(receipt: &ConsentReceipt) -> Self {
165        let mode = if receipt.redact.is_empty() {
166            PrivacyMode::Retain
167        } else {
168            PrivacyMode::Redact
169        };
170        Self {
171            mode,
172            redact: receipt.redact.clone(),
173        }
174    }
175}
176
177/// Deterministic modeled-clock retention reaper.
178#[derive(Clone, Debug, Default, PartialEq, Eq)]
179pub struct RetentionReaper;
180
181impl RetentionReaper {
182    /// Builds a retention reaper.
183    pub fn new() -> Self {
184        Self
185    }
186
187    /// Sweeps samples and referenced content older than their consent window.
188    pub fn sweep(
189        &self,
190        store: &mut DeviceSampleStore,
191        receipts: &[ConsentReceipt],
192        now: FrameClock,
193    ) -> Vec<Evicted> {
194        let receipt_by_seq: BTreeMap<u64, &ConsentReceipt> = receipts
195            .iter()
196            .map(|receipt| (receipt.seq, receipt))
197            .collect();
198        for sample in store.samples.values_mut() {
199            if let Some(receipt) = receipt_by_seq.get(&sample.receipt_seq)
200                && now.elapsed_ms_since(sample.tick) <= receipt.retain_ms
201            {
202                apply_redaction(&mut sample.value, &receipt.redact);
203            }
204        }
205        let expired: Vec<StoreKey> = store
206            .samples
207            .values()
208            .filter(|sample| {
209                receipt_by_seq
210                    .get(&sample.receipt_seq)
211                    .is_none_or(|receipt| now.elapsed_ms_since(sample.tick) > receipt.retain_ms)
212            })
213            .map(|sample| sample.key.clone())
214            .collect();
215
216        let mut evicted = Vec::new();
217        for key in expired {
218            if let Some(sample) = store.samples.remove(&key) {
219                evicted.push(Evicted {
220                    key,
221                    reason: retention_reason(),
222                });
223                for content_key in sample.content_refs {
224                    if store.content.remove(&content_key).is_some() {
225                        evicted.push(Evicted {
226                            key: content_key,
227                            reason: retention_reason(),
228                        });
229                    }
230                }
231            }
232        }
233        evicted
234    }
235}
236
237/// Returns the stable retention-eviction reason.
238pub fn retention_reason() -> Symbol {
239    Symbol::qualified("device/reaper", "retention")
240}
241
242fn redacted_marker() -> Expr {
243    Expr::Symbol(Symbol::qualified("device/reaper", "redacted"))
244}
245
246fn apply_redaction(value: &mut Expr, redact: &[Symbol]) {
247    if redact.is_empty() {
248        return;
249    }
250    let Expr::Map(entries) = value else {
251        return;
252    };
253    for (key, field_value) in entries {
254        if let Expr::Symbol(field) = key
255            && redact
256                .iter()
257                .any(|directive| redacts_field(directive, field))
258        {
259            *field_value = redacted_marker();
260        }
261    }
262}
263
264fn redacts_field(directive: &Symbol, field: &Symbol) -> bool {
265    directive == field
266        || directive.name == field.name
267        || directive.as_qualified_str() == field.as_qualified_str()
268}