use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DroppedRecord {
pub subject: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Allowance {
max_dropped: usize,
empty_pass_permitted: bool,
justification: String,
}
impl Allowance {
pub fn at_most(max_dropped: usize, justification: impl Into<String>) -> Self {
let justification = justification.into();
assert!(
!justification.trim().is_empty(),
"an Allowance needs a justification: an empty one is indistinguishable \
from having never considered the question",
);
Self {
max_dropped,
empty_pass_permitted: false,
justification,
}
}
#[must_use]
pub fn permitting_an_empty_pass(mut self) -> Self {
self.empty_pass_permitted = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AllowanceNote {
pub max_dropped: usize,
pub empty_pass_permitted: bool,
pub justification: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CaptureCounts {
pub attempted: usize,
pub succeeded: usize,
pub dropped: usize,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub dropped_by_reason: BTreeMap<String, usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowance: Option<AllowanceNote>,
}
impl CaptureCounts {
#[must_use]
pub fn is_complete(&self) -> bool {
self.dropped == 0 && self.attempted > 0
}
#[must_use]
pub fn is_self_consistent(&self) -> bool {
let total = self.succeeded.checked_add(self.dropped);
let by_reason = self
.dropped_by_reason
.values()
.try_fold(0usize, |sum, count| sum.checked_add(*count));
total == Some(self.attempted) && by_reason == Some(self.dropped)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DroppedShortfall {
pub subject: String,
pub counts: CaptureCounts,
pub records: Vec<DroppedRecord>,
pub allowed: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Shortfall {
NothingAttempted {
subject: String,
},
Dropped(Box<DroppedShortfall>),
}
impl Shortfall {
#[must_use]
pub fn subject(&self) -> &str {
match self {
Self::NothingAttempted { subject } => subject,
Self::Dropped(detail) => &detail.subject,
}
}
#[must_use]
pub fn dropped_records(&self) -> &[DroppedRecord] {
match self {
Self::NothingAttempted { .. } => &[],
Self::Dropped(detail) => &detail.records,
}
}
}
const MAX_NAMED_SUBJECTS: usize = 10;
const MAX_NAMED_REASONS: usize = 10;
impl fmt::Display for Shortfall {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NothingAttempted { subject } => write!(
f,
"{subject}: the pass attempted 0 records, so an artifact written from it \
would claim an empty population rather than report that it found none. \
Refusing to write a fixture built from a partial pass. If an empty pass \
is legitimate here, declare it with \
`Allowance::at_most(0, ..).permitting_an_empty_pass()`."
),
Self::Dropped(detail) => {
let DroppedShortfall {
subject,
counts,
records,
allowed,
} = detail.as_ref();
write!(
f,
"{subject}: {} of {} records were dropped",
counts.dropped, counts.attempted
)?;
match allowed {
Some(max) => write!(f, ", exceeding the declared allowance of {max}")?,
None => write!(f, " and no allowance was declared")?,
}
write!(
f,
". Refusing to write a fixture built from a partial pass, because a \
partial pass and a clean one produce indistinguishable output.\n \
by reason ({} distinct):",
counts.dropped_by_reason.len()
)?;
for (reason, count) in counts.dropped_by_reason.iter().take(MAX_NAMED_REASONS) {
write!(f, "\n {reason}: {count}")?;
}
if counts.dropped_by_reason.len() > MAX_NAMED_REASONS {
let hidden: usize = counts
.dropped_by_reason
.values()
.skip(MAX_NAMED_REASONS)
.sum();
write!(
f,
"\n … and {} more reasons covering {hidden} drops",
counts.dropped_by_reason.len() - MAX_NAMED_REASONS,
)?;
}
write!(f, "\n dropped:")?;
for record in records.iter().take(MAX_NAMED_SUBJECTS) {
write!(f, "\n {} ({})", record.subject, record.reason)?;
}
if records.len() > MAX_NAMED_SUBJECTS {
write!(f, "\n … and {} more", records.len() - MAX_NAMED_SUBJECTS)?;
}
Ok(())
}
}
}
}
impl std::error::Error for Shortfall {}
#[derive(Debug, Clone)]
pub struct CaptureLedger {
subject: String,
succeeded: usize,
dropped: Vec<DroppedRecord>,
}
impl CaptureLedger {
pub fn new(subject: impl Into<String>) -> Self {
Self {
subject: subject.into(),
succeeded: 0,
dropped: Vec::new(),
}
}
pub fn record_success(&mut self) {
self.succeeded += 1;
}
pub fn record_drop(&mut self, subject: impl Into<String>, reason: impl Into<String>) {
self.dropped.push(DroppedRecord {
subject: subject.into(),
reason: reason.into(),
});
}
pub fn record<T, E: fmt::Display>(
&mut self,
subject: impl Into<String>,
outcome: Result<T, E>,
) -> Option<T> {
match outcome {
Ok(value) => {
self.record_success();
Some(value)
}
Err(error) => {
self.record_drop(subject, error.to_string());
None
}
}
}
#[must_use]
pub fn attempted(&self) -> usize {
self.succeeded + self.dropped.len()
}
#[must_use]
pub fn succeeded(&self) -> usize {
self.succeeded
}
#[must_use]
pub fn dropped(&self) -> usize {
self.dropped.len()
}
#[must_use]
pub fn counts(&self) -> CaptureCounts {
let mut dropped_by_reason: BTreeMap<String, usize> = BTreeMap::new();
for record in &self.dropped {
*dropped_by_reason.entry(record.reason.clone()).or_insert(0) += 1;
}
CaptureCounts {
attempted: self.attempted(),
succeeded: self.succeeded,
dropped: self.dropped.len(),
dropped_by_reason,
allowance: None,
}
}
pub fn finish(self) -> Result<CaptureCounts, Shortfall> {
self.close(None)
}
pub fn finish_with(self, allowance: Allowance) -> Result<CaptureCounts, Shortfall> {
self.close(Some(allowance))
}
fn close(self, allowance: Option<Allowance>) -> Result<CaptureCounts, Shortfall> {
let mut counts = self.counts();
let empty_permitted = allowance.as_ref().is_some_and(|a| a.empty_pass_permitted);
if counts.attempted == 0 && !empty_permitted {
return Err(Shortfall::NothingAttempted {
subject: self.subject,
});
}
let ceiling = allowance.as_ref().map_or(0, |a| a.max_dropped);
counts.allowance = allowance.map(|a| AllowanceNote {
max_dropped: a.max_dropped,
empty_pass_permitted: a.empty_pass_permitted,
justification: a.justification,
});
if counts.dropped > ceiling {
let allowed = counts.allowance.as_ref().map(|a| a.max_dropped);
return Err(Shortfall::Dropped(Box::new(DroppedShortfall {
subject: self.subject,
counts,
records: self.dropped,
allowed,
})));
}
Ok(counts)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_complete_pass_certifies() {
let mut ledger = CaptureLedger::new("transcript windows");
ledger.record_success();
ledger.record_success();
let counts = ledger.finish().expect("a complete pass certifies");
assert_eq!(counts.attempted, 2);
assert_eq!(counts.succeeded, 2);
assert_eq!(counts.dropped, 0);
assert!(counts.dropped_by_reason.is_empty());
assert!(counts.allowance.is_none());
assert!(counts.is_complete());
}
#[test]
fn a_single_drop_refuses() {
let mut ledger = CaptureLedger::new("transcript windows");
for _ in 0..999 {
ledger.record_success();
}
ledger.record_drop("NM_003002.2", "no stored sequence");
let error = ledger.finish().expect_err("one drop must refuse");
match &error {
Shortfall::Dropped(detail) => {
assert_eq!(detail.subject, "transcript windows");
assert_eq!(detail.counts.attempted, 1000);
assert_eq!(detail.counts.succeeded, 999);
assert_eq!(detail.counts.dropped, 1);
assert_eq!(detail.records.len(), 1);
assert_eq!(detail.records[0].subject, "NM_003002.2");
assert_eq!(detail.allowed, None);
}
other => panic!("expected a Dropped shortfall, got {other:?}"),
}
}
#[test]
fn the_refusal_names_what_was_lost_and_that_it_refused() {
let mut ledger = CaptureLedger::new("spec rows");
ledger.record_success();
ledger.record_drop("LRG_199t1:c.850del", "unresolvable accession");
ledger.record_drop("NM_007294.3:c.2077delins", "unresolvable accession");
let message = ledger.finish().expect_err("must refuse").to_string();
assert!(message.contains("spec rows"), "{message}");
assert!(message.contains("2 of 3 records were dropped"), "{message}");
assert!(message.contains("no allowance was declared"), "{message}");
assert!(
message.contains("Refusing to write a fixture built from a partial pass"),
"{message}"
);
assert!(message.contains("unresolvable accession: 2"), "{message}");
assert!(message.contains("LRG_199t1:c.850del"), "{message}");
}
#[test]
fn a_long_refusal_is_truncated_but_still_states_the_total() {
let mut ledger = CaptureLedger::new("corpus rows");
for i in 0..(MAX_NAMED_SUBJECTS + 5) {
ledger.record_drop(format!("row-{i}"), "parse failed");
}
let message = ledger.finish().expect_err("must refuse").to_string();
assert!(
message.contains("15 of 15 records were dropped"),
"{message}"
);
assert!(message.contains("row-0"), "{message}");
assert!(!message.contains("row-14"), "{message}");
assert!(message.contains("and 5 more"), "{message}");
}
#[test]
fn a_declared_allowance_waives_up_to_its_ceiling() {
let mut ledger = CaptureLedger::new("reference accessions");
ledger.record_success();
ledger.record_drop("LRG_199", "unversioned reference");
let counts = ledger
.finish_with(Allowance::at_most(
1,
"bare LRG_ ids have no versioned index entry",
))
.expect("within the declared allowance");
assert_eq!(counts.dropped, 1);
let note = counts
.allowance
.as_ref()
.expect("the allowance is recorded");
assert_eq!(note.max_dropped, 1);
assert_eq!(
note.justification,
"bare LRG_ ids have no versioned index entry"
);
assert!(!counts.is_complete());
}
#[test]
fn an_allowance_is_a_ceiling_not_a_licence() {
let mut ledger = CaptureLedger::new("reference accessions");
ledger.record_drop("LRG_199", "unversioned reference");
ledger.record_drop("LRG_200", "unversioned reference");
let error = ledger
.finish_with(Allowance::at_most(1, "at most one bare LRG_ id"))
.expect_err("two drops exceed a ceiling of one");
assert!(matches!(&error, Shortfall::Dropped(detail) if detail.allowed == Some(1)));
assert!(
error
.to_string()
.contains("exceeding the declared allowance of 1"),
"{error}"
);
}
#[test]
fn a_zero_allowance_records_the_question_and_still_refuses() {
let mut clean = CaptureLedger::new("rows");
clean.record_success();
let counts = clean
.finish_with(Allowance::at_most(0, "no drop is acceptable here"))
.expect("a clean pass passes under a zero allowance");
assert_eq!(
counts
.allowance
.expect("the allowance is recorded even at zero")
.max_dropped,
0
);
let mut lossy = CaptureLedger::new("rows");
lossy.record_drop("row-0", "parse failed");
assert!(lossy
.finish_with(Allowance::at_most(0, "no drop is acceptable here"))
.is_err());
}
#[test]
fn an_empty_pass_refuses_unless_it_is_permitted() {
let error = CaptureLedger::new("corpus rows")
.finish()
.expect_err("an empty pass must refuse");
assert!(matches!(error, Shortfall::NothingAttempted { .. }));
assert!(error.to_string().contains("attempted 0 records"), "{error}");
assert!(error.dropped_records().is_empty());
assert!(CaptureLedger::new("corpus rows")
.finish_with(Allowance::at_most(5, "some rows may fail"))
.is_err());
let counts = CaptureLedger::new("corpus rows")
.finish_with(
Allowance::at_most(0, "the corpus is optional here").permitting_an_empty_pass(),
)
.expect("an explicitly permitted empty pass");
assert_eq!(counts.attempted, 0);
assert!(!counts.is_complete());
}
#[test]
fn record_accounts_for_a_fallible_step_and_yields_its_value() {
let mut ledger = CaptureLedger::new("transcripts");
let ok: Result<u32, String> = Ok(7);
let err: Result<u32, String> = Err("no stored sequence".to_string());
assert_eq!(ledger.record("NM_000088.3", ok), Some(7));
assert_eq!(ledger.record("NM_003002.2", err), None);
assert_eq!(ledger.attempted(), 2);
assert_eq!(ledger.succeeded(), 1);
assert_eq!(ledger.dropped(), 1);
let error = ledger.finish().expect_err("the drop must refuse");
assert_eq!(error.dropped_records()[0].reason, "no stored sequence");
}
#[test]
fn counts_round_trip_through_json_so_an_artifact_can_carry_them() {
let mut ledger = CaptureLedger::new("windows");
ledger.record_success();
ledger.record_drop("NM_003002.2", "no stored sequence");
let counts = ledger
.finish_with(Allowance::at_most(1, "one transcript is not provisioned"))
.expect("within the allowance");
let json = serde_json::to_string(&counts).expect("serialize counts");
let parsed: CaptureCounts = serde_json::from_str(&json).expect("deserialize counts");
assert_eq!(parsed, counts);
assert_eq!(parsed.dropped_by_reason["no stored sequence"], 1);
assert_eq!(
parsed.allowance.expect("allowance survives").justification,
"one transcript is not provisioned"
);
}
#[test]
fn a_wide_reason_vocabulary_cannot_produce_an_unbounded_refusal() {
let mut ledger = CaptureLedger::new("corpus rows");
let drops = 2000;
for i in 0..drops {
let outcome: Result<(), String> = Err(format!("row {i} failed to resolve"));
ledger.record(format!("row-{i}"), outcome);
}
let message = ledger.finish().expect_err("must refuse").to_string();
let lines = message.lines().count();
assert!(
lines <= MAX_NAMED_SUBJECTS + MAX_NAMED_REASONS + 8,
"refusal must stay readable, got {lines} lines:\n{message}"
);
assert!(
message.contains(&format!("{drops} of {drops} records were dropped")),
"{message}"
);
assert!(
message.contains(&format!("by reason ({drops} distinct)")),
"{message}"
);
assert!(
message.contains(&format!(
"and {} more reasons covering {} drops",
drops - MAX_NAMED_REASONS,
drops - MAX_NAMED_REASONS
)),
"{message}"
);
}
#[test]
#[should_panic(expected = "an Allowance needs a justification")]
fn an_allowance_with_a_blank_justification_is_refused() {
let _ = Allowance::at_most(0, " ");
}
#[test]
fn self_consistency_is_checkable_on_counts_read_back_from_an_artifact() {
let mut ledger = CaptureLedger::new("windows");
ledger.record_success();
ledger.record_drop("NM_003002.2", "no stored sequence");
let counts = ledger
.finish_with(Allowance::at_most(1, "one transcript is not provisioned"))
.expect("within the allowance");
assert!(counts.is_self_consistent());
let internally_consistent_but_unverifiable: CaptureCounts = serde_json::from_str(
r#"{"attempted":100,"succeeded":100,"dropped":0,"dropped_by_reason":{}}"#,
)
.expect("deserialize");
assert!(internally_consistent_but_unverifiable.is_self_consistent());
let inconsistent: CaptureCounts =
serde_json::from_str(r#"{"attempted":100,"succeeded":10,"dropped":0}"#)
.expect("deserialize");
assert!(!inconsistent.is_self_consistent());
}
#[test]
fn self_consistency_treats_overflowing_counts_as_inconsistent() {
let overflowing: CaptureCounts = serde_json::from_str(&format!(
r#"{{"attempted":1,"succeeded":{max},"dropped":{max}}}"#,
max = usize::MAX
))
.expect("deserialize");
assert!(!overflowing.is_self_consistent());
let overflowing_reasons: CaptureCounts = serde_json::from_str(&format!(
r#"{{"attempted":{max},"succeeded":0,"dropped":{max},
"dropped_by_reason":{{"a":{max},"b":{max}}}}}"#,
max = usize::MAX
))
.expect("deserialize");
assert!(!overflowing_reasons.is_self_consistent());
let wraps_onto_attempted: CaptureCounts = serde_json::from_str(&format!(
r#"{{"attempted":1,"succeeded":{max},"dropped":2,
"dropped_by_reason":{{"unversioned reference":2}}}}"#,
max = usize::MAX
))
.expect("deserialize");
assert!(!wraps_onto_attempted.is_self_consistent());
}
#[test]
fn a_clean_claim_serializes_without_empty_fields() {
let mut ledger = CaptureLedger::new("windows");
ledger.record_success();
let json = serde_json::to_string(&ledger.finish().expect("clean")).expect("serialize");
assert_eq!(json, r#"{"attempted":1,"succeeded":1,"dropped":0}"#);
}
}