use std::fmt;
use rustev_contract::canonical::tagged_digest;
use rustev_contract::ids::{ContentDigest, DatasetId, PlanId};
use rustev_contract::limits::REPLAY_V1;
use rustev_contract::scope::Handle;
use serde::{Deserialize, Serialize};
pub const HOLDOUT_LEDGER: &str = "rustev.holdout-ledger/1";
pub const ENTRY_TAG: &str = "rustev.holdout-ledger/1#entry";
pub const MAX_ENTRIES: usize = 10_000;
pub const MAX_ENTRY_RUNS: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HoldoutLedger {
pub schema: String,
pub dataset: DatasetId,
pub entries: Vec<LedgerEntry>,
}
crate::doc::eval_document!(HoldoutLedger, HOLDOUT_LEDGER, REPLAY_V1, ContentDigest);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LedgerEntry {
pub lineage: Handle,
pub plan: PlanId,
pub repeat_config: ContentDigest,
pub runs: Vec<ContentDigest>,
pub recorded_at_ms: i64,
pub prev: Option<ContentDigest>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HeldOutRecord {
pub lineage: Handle,
pub count: u64,
pub plan_count: u64,
pub dataset_count: u64,
pub ledger_head_before: Option<ContentDigest>,
pub ledger_head_after: ContentDigest,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LedgerError {
Schema,
OtherDataset,
TooManyEntries(usize),
MalformedEntry(usize),
BrokenChain(usize),
NotCanonical,
}
impl fmt::Display for LedgerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LedgerError::Schema => write!(f, "not a rustev.holdout-ledger/1 document"),
LedgerError::OtherDataset => write!(f, "the ledger is for another dataset"),
LedgerError::TooManyEntries(n) => write!(f, "{n} entries exceed {MAX_ENTRIES}"),
LedgerError::MalformedEntry(i) => write!(f, "entry {i} binds no run or more than 64"),
LedgerError::BrokenChain(i) => write!(f, "entry {i} does not extend the chain"),
LedgerError::NotCanonical => write!(f, "an entry has no canonical form"),
}
}
}
impl std::error::Error for LedgerError {}
pub fn entry_digest(entry: &LedgerEntry) -> Result<ContentDigest, LedgerError> {
let bytes = rustev_contract::canonical::canonical_bytes(entry)
.map_err(|_| LedgerError::NotCanonical)?;
ContentDigest::parse(&tagged_digest(ENTRY_TAG, &bytes)).map_err(|_| LedgerError::NotCanonical)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Append {
pub lineage: Handle,
pub plan: PlanId,
pub repeat_config: ContentDigest,
pub runs: Vec<ContentDigest>,
pub recorded_at_ms: i64,
}
impl HoldoutLedger {
pub fn new(dataset: DatasetId) -> HoldoutLedger {
HoldoutLedger {
schema: HOLDOUT_LEDGER.into(),
dataset,
entries: vec![],
}
}
pub fn verify(&self, dataset: &DatasetId) -> Result<(), LedgerError> {
if self.schema != HOLDOUT_LEDGER {
return Err(LedgerError::Schema);
}
if &self.dataset != dataset {
return Err(LedgerError::OtherDataset);
}
if self.entries.len() > MAX_ENTRIES {
return Err(LedgerError::TooManyEntries(self.entries.len()));
}
let mut prev = None;
for (i, e) in self.entries.iter().enumerate() {
if e.runs.is_empty() || e.runs.len() > MAX_ENTRY_RUNS {
return Err(LedgerError::MalformedEntry(i));
}
if e.prev != prev {
return Err(LedgerError::BrokenChain(i));
}
prev = Some(entry_digest(e)?);
}
Ok(())
}
pub fn head(&self) -> Result<Option<ContentDigest>, LedgerError> {
self.entries.last().map(entry_digest).transpose()
}
pub fn append(
&self,
dataset: &DatasetId,
a: Append,
) -> Result<(HoldoutLedger, HeldOutRecord), LedgerError> {
self.verify(dataset)?;
if self.entries.len() >= MAX_ENTRIES {
return Err(LedgerError::TooManyEntries(self.entries.len() + 1));
}
if a.runs.is_empty() || a.runs.len() > MAX_ENTRY_RUNS {
return Err(LedgerError::MalformedEntry(self.entries.len()));
}
let before = self.head()?;
let entry = LedgerEntry {
lineage: a.lineage,
plan: a.plan,
repeat_config: a.repeat_config,
runs: a.runs,
recorded_at_ms: a.recorded_at_ms,
prev: before.clone(),
};
let after = entry_digest(&entry)?;
let mut next = self.clone();
next.entries.push(entry);
let new = next.entries.last().expect("just appended");
let count =
|f: &dyn Fn(&LedgerEntry) -> bool| next.entries.iter().filter(|e| f(e)).count() as u64;
let record = HeldOutRecord {
lineage: new.lineage.clone(),
count: count(&|e| e.lineage == new.lineage),
plan_count: count(&|e| e.plan == new.plan),
dataset_count: next.entries.len() as u64,
ledger_head_before: before,
ledger_head_after: after,
};
Ok((next, record))
}
}