Skip to main content

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}