vole_document/store/account.rs
1//! Three-universe accounting for a cohort of store-backed roots (Phase 9).
2//!
3//! The three universes are kept **permanently distinct** (ADR-0020); conflating
4//! them is what makes a content-addressed store look like a compressor.
5//!
6//! Notation: cohort `C = {r_0 .. r_{n-1}}`; `d_i` is the standalone descriptor
7//! for root `i`, `e_i` its store-backed form; `reach(i)` is the set of external
8//! object ids in root `i`'s object table; `O = ⋃_i reach(i)` is the unique
9//! reachable set and `refcount(o) = |{ i : o ∈ reach(i) }|`.
10//!
11//! ```text
12//! S (standalone) = Σ_i |serialize(d_i)|
13//! U (unique reachable) = Σ_i |serialize(e_i)| + Σ_{o ∈ O} len(o)
14//! A (amortized cohort) = Σ_i ( |serialize(e_i)| + Σ_{o ∈ reach(i)} len(o)/refcount(o) )
15//! ```
16//!
17//! [`account`] reports all three plus the per-root amortized split. Since
18//! `Σ_i Σ_{o ∈ reach(i)} len(o)/refcount(o) = Σ_{o ∈ O} len(o)` telescopes,
19//! `A == U` **exactly** by construction: the amortized total is not a fourth
20//! number. See ADR-0020 for why the split rule is *fractional by reference
21//! count*, and why it reduces to standalone with no sharing.
22//!
23//! Note that `S` is a whole-file number and is the only one comparable to a
24//! per-file compressor; `U` and `A` include the shared object once and are **not**
25//! comparable to a whole file (a store root alone is never a whole document).
26
27use std::collections::BTreeMap;
28
29use crate::container::{Descriptor, ObjectSource};
30use crate::error::{Error, Result};
31use crate::store::{Id, ObjectResolver, ObjectStore, hydrate};
32
33/// Per-root accounting.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct RootAccount {
36 /// Serialized store-backed descriptor bytes `|serialize(e_i)|`.
37 pub root_bytes: u64,
38 /// Serialized standalone descriptor bytes `|serialize(d_i)|` (all objects
39 /// inline).
40 pub standalone_bytes: u64,
41 /// Distinct external object ids reachable from this root.
42 pub reachable_objects: u64,
43 /// Integerized amortized bytes `A_i` (see [`account`] for the split rule).
44 pub amortized_bytes: u64,
45}
46
47/// Cohort accounting across the three frozen universes.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct AccountReport {
50 /// Per-root figures, in cohort order.
51 pub roots: Vec<RootAccount>,
52 /// `S`: the standalone universe (whole-file, comparable to per-file LZ).
53 pub standalone_bytes: u64,
54 /// `U`: the unique-reachable universe (roots + each unique object once).
55 pub unique_reachable_bytes: u64,
56 /// `A`: the amortized cohort universe. Equals `unique_reachable_bytes`.
57 pub amortized_bytes: u64,
58 /// `|O|`: number of unique reachable objects.
59 pub unique_objects: u64,
60 /// `Σ_{o ∈ O} len(o)`: raw bytes of the unique reachable objects.
61 pub unique_object_bytes: u64,
62 /// Reachable ids the store does not contain. A valid closure has none; a
63 /// non-empty list is a live [`crate::ErrorClass::MissingExternalObject`] and
64 /// [`account`] fails closed rather than reporting a universe it cannot prove.
65 pub dangling: Vec<Id>,
66}
67
68/// Compute the three accounting universes for `roots`, resolving objects and
69/// closure through `store`.
70///
71/// The amortized distribution rule is **fractional by reference count**: each
72/// object `o` is split equally among the `refcount(o)` roots that reference it,
73/// `q = len(o) / refcount(o)` to each, with the `len(o) % refcount(o)` remainder
74/// bytes assigned one each to the lowest-index referencing roots. Every byte of
75/// `len(o)` is thus distributed, so `Σ_i A_i == U` holds exactly with no rounding
76/// drift. The rule is order-invariant in the sense that it depends only on
77/// `reach(i)` and per-object `refcount`, and it reduces to standalone when every
78/// `refcount(o) == 1`.
79///
80/// Fails closed with [`ErrorClass::MissingExternalObject`][crate::ErrorClass]
81/// if any reachable object is absent.
82pub fn account<R: ObjectResolver + ObjectStore>(
83 roots: &[Descriptor],
84 store: &R,
85) -> Result<AccountReport> {
86 // id -> (declared raw length, referencing root indices in ascending order).
87 let mut objects: BTreeMap<Id, (u64, Vec<usize>)> = BTreeMap::new();
88 for (i, root) in roots.iter().enumerate() {
89 for src in &root.objects {
90 if let ObjectSource::External { id, len } = src {
91 let entry = objects.entry(*id).or_insert((*len, Vec::new()));
92 if !entry.1.contains(&i) {
93 entry.1.push(i);
94 }
95 }
96 }
97 }
98
99 // Closure: every reachable id must be present in the store.
100 let mut dangling: Vec<Id> = Vec::new();
101 for id in objects.keys() {
102 if !store.contains(id)? {
103 dangling.push(*id);
104 }
105 }
106 if !dangling.is_empty() {
107 return Err(Error::missing_external_object(format!(
108 "{} reachable object(s) are not present in the store; the closure is not valid",
109 dangling.len()
110 )));
111 }
112
113 // Root/store-backed bytes and standalone bytes.
114 let mut roots_out: Vec<RootAccount> = Vec::with_capacity(roots.len());
115 let mut standalone_bytes: u64 = 0;
116 let mut root_total: u64 = 0;
117 for root in roots {
118 let root_bytes = root.serialize()?.0.len() as u64;
119 root_total += root_bytes;
120
121 let mut standalone = root.clone();
122 hydrate(&mut standalone, store)?;
123 let standalone_len = standalone.serialize()?.0.len() as u64;
124 standalone_bytes += standalone_len;
125
126 roots_out.push(RootAccount {
127 root_bytes,
128 standalone_bytes: standalone_len,
129 reachable_objects: 0,
130 // The root bytes are always paid by this root; object shares are added
131 // below.
132 amortized_bytes: root_bytes,
133 });
134 }
135
136 // Distribute each object's raw length across its referencing roots.
137 let mut unique_object_bytes: u64 = 0;
138 for (len, refs) in objects.values() {
139 unique_object_bytes += *len;
140 let k = refs.len() as u64;
141 if k == 0 {
142 continue;
143 }
144 let q = len / k;
145 let r = len % k;
146 for (j, &root_index) in refs.iter().enumerate() {
147 let mut add = q;
148 if (j as u64) < r {
149 add += 1;
150 }
151 roots_out[root_index].amortized_bytes += add;
152 roots_out[root_index].reachable_objects += 1;
153 }
154 }
155
156 let unique_reachable_bytes = root_total + unique_object_bytes;
157 let amortized_bytes: u64 = roots_out.iter().map(|r| r.amortized_bytes).sum();
158 debug_assert_eq!(
159 amortized_bytes, unique_reachable_bytes,
160 "Σ amortized must equal unique reachable (the split telescopes)"
161 );
162
163 Ok(AccountReport {
164 roots: roots_out,
165 standalone_bytes,
166 unique_reachable_bytes,
167 amortized_bytes,
168 unique_objects: objects.len() as u64,
169 unique_object_bytes,
170 dangling,
171 })
172}