1use std::fmt;
13
14use rustev_contract::canonical::tagged_digest;
15use rustev_contract::ids::{ContentDigest, DatasetId, PlanId};
16use rustev_contract::limits::REPLAY_V1;
17use rustev_contract::scope::Handle;
18use serde::{Deserialize, Serialize};
19
20pub const HOLDOUT_LEDGER: &str = "rustev.holdout-ledger/1";
21pub const ENTRY_TAG: &str = "rustev.holdout-ledger/1#entry";
23pub const MAX_ENTRIES: usize = 10_000;
25pub const MAX_ENTRY_RUNS: usize = 64;
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct HoldoutLedger {
31 pub schema: String,
32 pub dataset: DatasetId,
33 pub entries: Vec<LedgerEntry>,
34}
35
36crate::doc::eval_document!(HoldoutLedger, HOLDOUT_LEDGER, REPLAY_V1, ContentDigest);
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct LedgerEntry {
41 pub lineage: Handle,
43 pub plan: PlanId,
44 pub repeat_config: ContentDigest,
45 pub runs: Vec<ContentDigest>,
47 pub recorded_at_ms: i64,
49 pub prev: Option<ContentDigest>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(deny_unknown_fields)]
59pub struct HeldOutRecord {
60 pub lineage: Handle,
61 pub count: u64,
62 pub plan_count: u64,
63 pub dataset_count: u64,
64 pub ledger_head_before: Option<ContentDigest>,
65 pub ledger_head_after: ContentDigest,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq)]
70pub enum LedgerError {
71 Schema,
72 OtherDataset,
73 TooManyEntries(usize),
74 MalformedEntry(usize),
76 BrokenChain(usize),
78 NotCanonical,
79}
80
81impl fmt::Display for LedgerError {
82 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83 match self {
84 LedgerError::Schema => write!(f, "not a rustev.holdout-ledger/1 document"),
85 LedgerError::OtherDataset => write!(f, "the ledger is for another dataset"),
86 LedgerError::TooManyEntries(n) => write!(f, "{n} entries exceed {MAX_ENTRIES}"),
87 LedgerError::MalformedEntry(i) => write!(f, "entry {i} binds no run or more than 64"),
88 LedgerError::BrokenChain(i) => write!(f, "entry {i} does not extend the chain"),
89 LedgerError::NotCanonical => write!(f, "an entry has no canonical form"),
90 }
91 }
92}
93
94impl std::error::Error for LedgerError {}
95
96pub fn entry_digest(entry: &LedgerEntry) -> Result<ContentDigest, LedgerError> {
98 let bytes = rustev_contract::canonical::canonical_bytes(entry)
99 .map_err(|_| LedgerError::NotCanonical)?;
100 ContentDigest::parse(&tagged_digest(ENTRY_TAG, &bytes)).map_err(|_| LedgerError::NotCanonical)
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct Append {
106 pub lineage: Handle,
107 pub plan: PlanId,
108 pub repeat_config: ContentDigest,
109 pub runs: Vec<ContentDigest>,
110 pub recorded_at_ms: i64,
111}
112
113impl HoldoutLedger {
114 pub fn new(dataset: DatasetId) -> HoldoutLedger {
116 HoldoutLedger {
117 schema: HOLDOUT_LEDGER.into(),
118 dataset,
119 entries: vec![],
120 }
121 }
122
123 pub fn verify(&self, dataset: &DatasetId) -> Result<(), LedgerError> {
126 if self.schema != HOLDOUT_LEDGER {
127 return Err(LedgerError::Schema);
128 }
129 if &self.dataset != dataset {
130 return Err(LedgerError::OtherDataset);
131 }
132 if self.entries.len() > MAX_ENTRIES {
133 return Err(LedgerError::TooManyEntries(self.entries.len()));
134 }
135 let mut prev = None;
136 for (i, e) in self.entries.iter().enumerate() {
137 if e.runs.is_empty() || e.runs.len() > MAX_ENTRY_RUNS {
138 return Err(LedgerError::MalformedEntry(i));
139 }
140 if e.prev != prev {
141 return Err(LedgerError::BrokenChain(i));
142 }
143 prev = Some(entry_digest(e)?);
144 }
145 Ok(())
146 }
147
148 pub fn head(&self) -> Result<Option<ContentDigest>, LedgerError> {
150 self.entries.last().map(entry_digest).transpose()
151 }
152
153 pub fn append(
157 &self,
158 dataset: &DatasetId,
159 a: Append,
160 ) -> Result<(HoldoutLedger, HeldOutRecord), LedgerError> {
161 self.verify(dataset)?;
162 if self.entries.len() >= MAX_ENTRIES {
163 return Err(LedgerError::TooManyEntries(self.entries.len() + 1));
164 }
165 if a.runs.is_empty() || a.runs.len() > MAX_ENTRY_RUNS {
166 return Err(LedgerError::MalformedEntry(self.entries.len()));
167 }
168 let before = self.head()?;
169 let entry = LedgerEntry {
170 lineage: a.lineage,
171 plan: a.plan,
172 repeat_config: a.repeat_config,
173 runs: a.runs,
174 recorded_at_ms: a.recorded_at_ms,
175 prev: before.clone(),
176 };
177 let after = entry_digest(&entry)?;
178 let mut next = self.clone();
179 next.entries.push(entry);
180 let new = next.entries.last().expect("just appended");
181 let count =
182 |f: &dyn Fn(&LedgerEntry) -> bool| next.entries.iter().filter(|e| f(e)).count() as u64;
183 let record = HeldOutRecord {
184 lineage: new.lineage.clone(),
185 count: count(&|e| e.lineage == new.lineage),
186 plan_count: count(&|e| e.plan == new.plan),
187 dataset_count: next.entries.len() as u64,
188 ledger_head_before: before,
189 ledger_head_after: after,
190 };
191 Ok((next, record))
192 }
193}