use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::aggregate::ic_matcher::{IcMatchResult, UnmatchedReason, UnmatchedSide};
use crate::errors::{GroupError, GroupResult};
pub const UNMATCHED_SAMPLE_CAP: usize = 100;
pub const COVERAGE_REPORT_SUBDIR: &str = "ic_eliminations";
pub const COVERAGE_REPORT_FILENAME: &str = "ic_matching_coverage.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoverageReport {
pub total_pairs_planned: usize,
pub matched: usize,
pub coverage: f64,
pub unmatched_by_reason: BTreeMap<UnmatchedReason, usize>,
pub unmatched_sample: Vec<UnmatchedSide>,
}
pub fn build_coverage_report(result: &IcMatchResult) -> CoverageReport {
let mut unmatched_by_reason: BTreeMap<UnmatchedReason, usize> = BTreeMap::new();
unmatched_by_reason.insert(UnmatchedReason::MissingBuyerSide, 0);
unmatched_by_reason.insert(UnmatchedReason::MissingSellerSide, 0);
unmatched_by_reason.insert(UnmatchedReason::AmountDriftAboveTolerance, 0);
for side in &result.unmatched {
*unmatched_by_reason.entry(side.reason).or_insert(0) += 1;
}
let unmatched_sample: Vec<UnmatchedSide> = result
.unmatched
.iter()
.take(UNMATCHED_SAMPLE_CAP)
.cloned()
.collect();
CoverageReport {
total_pairs_planned: result.total_planned,
matched: result.matched.len(),
coverage: result.coverage,
unmatched_by_reason,
unmatched_sample,
}
}
pub fn write_coverage_report(report: &CoverageReport, out_dir: &Path) -> GroupResult<PathBuf> {
let dir = out_dir.join(COVERAGE_REPORT_SUBDIR);
fs::create_dir_all(&dir).map_err(GroupError::Io)?;
let path = dir.join(COVERAGE_REPORT_FILENAME);
let mut json = serde_json::to_string_pretty(report)?;
json.push('\n');
fs::write(&path, json).map_err(GroupError::Io)?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aggregate::ic_matcher::{IcMatchResult, IcMatchedPair, UnmatchedSide};
use crate::shard::ic_plan::IcRole;
use chrono::NaiveDate;
use datasynth_core::models::journal_entry::JournalEntryHeader;
use datasynth_core::models::{IcPairId, JournalEntry};
fn empty_match_result() -> IcMatchResult {
IcMatchResult {
matched: Vec::new(),
unmatched: Vec::new(),
total_planned: 0,
coverage: 0.0,
}
}
fn dummy_je() -> JournalEntry {
let header = JournalEntryHeader::new(
"ENT".to_string(),
NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
);
JournalEntry::new(header)
}
#[test]
fn build_from_empty_matches_zero_coverage() {
let r = build_coverage_report(&empty_match_result());
assert_eq!(r.total_pairs_planned, 0);
assert_eq!(r.matched, 0);
assert_eq!(r.coverage, 0.0);
assert!(r.unmatched_sample.is_empty());
assert_eq!(r.unmatched_by_reason.len(), 3);
assert_eq!(
r.unmatched_by_reason
.get(&UnmatchedReason::MissingBuyerSide),
Some(&0)
);
assert_eq!(
r.unmatched_by_reason
.get(&UnmatchedReason::MissingSellerSide),
Some(&0)
);
assert_eq!(
r.unmatched_by_reason
.get(&UnmatchedReason::AmountDriftAboveTolerance),
Some(&0)
);
}
#[test]
fn histogram_keys_always_present_even_when_zero() {
let mr = IcMatchResult {
matched: Vec::new(),
unmatched: vec![UnmatchedSide {
pair_id: IcPairId::from_bytes([1; 32]),
present_role: IcRole::Seller,
present_entity: "ENT".to_string(),
present_je: dummy_je(),
reason: UnmatchedReason::MissingBuyerSide,
}],
total_planned: 1,
coverage: 0.0,
};
let r = build_coverage_report(&mr);
assert_eq!(
r.unmatched_by_reason
.get(&UnmatchedReason::MissingBuyerSide),
Some(&1)
);
assert_eq!(
r.unmatched_by_reason
.get(&UnmatchedReason::MissingSellerSide),
Some(&0),
"MissingSellerSide must be present at 0 for schema stability"
);
assert_eq!(
r.unmatched_by_reason
.get(&UnmatchedReason::AmountDriftAboveTolerance),
Some(&0),
"AmountDriftAboveTolerance must be present at 0 for schema stability"
);
}
#[test]
fn build_passes_through_matched_count_and_coverage() {
let je = dummy_je();
let pair = IcMatchedPair {
pair_id: IcPairId::from_bytes([2; 32]),
seller_entity: "S".to_string(),
buyer_entity: "B".to_string(),
seller_je: je.clone(),
buyer_je: je,
};
let mr = IcMatchResult {
matched: vec![pair],
unmatched: Vec::new(),
total_planned: 2,
coverage: 0.5,
};
let r = build_coverage_report(&mr);
assert_eq!(r.total_pairs_planned, 2);
assert_eq!(r.matched, 1);
assert_eq!(r.coverage, 0.5);
}
}