Skip to main content

binoc_sdk/
partition.rs

1//! JIT, format-owned partition identities for N↔M correspondence (CFM-72).
2//!
3//! Some changes turn one artifact into several of the same shape (a table split
4//! by year) or several into one (merged). Representing that needs a way to ask
5//! whether one artifact's atomic sub-units are exactly the disjoint union of
6//! several others'. This module supplies the seam:
7//!
8//! - [`IdentityToken`] — an opaque, globally-comparable identity for one atomic
9//!   sub-unit (e.g. a table row).
10//! - [`IdentityExtractor`] — a format-keyed capability that yields an artifact's
11//!   ordered token sequence. The engine dispatches it like writers/compaction;
12//!   the *format* owns what a sub-unit is and how its identity is derived.
13//! - [`disjoint_cover`] — the generic, opaque-token coverage query that answers
14//!   "is `whole` the clean disjoint union of a subset of `pool`?".
15//!
16//! The query is deliberately conservative (see the partition-identities ADR): it
17//! reports [`Coverage::Clean`] only when the relationship is complete (residual
18//! 0), disjoint, unambiguous, and not a whole-artifact 1:1; any messiness is a
19//! [`Coverage::NearMiss`] that a consumer declines on, leaving honest add/remove.
20
21use std::collections::HashMap;
22
23use crate::{tabular_v1, ArtifactFormat, BinocError, BinocResult, TabularData};
24
25use serde::{Deserialize, Serialize};
26
27/// An opaque, globally-comparable identity for one atomic sub-unit of an
28/// artifact (e.g. a table row).
29///
30/// The engine only ever compares tokens for equality / membership /
31/// disjointness — it never interprets their contents. The producing artifact
32/// *format* owns the meaning (content hash, stable key, …) via its
33/// [`IdentityExtractor`]. Tokens must be globally comparable: the same sub-unit
34/// in two different artifacts must yield the same token (content- or
35/// key-derived, never a positional index).
36#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
37pub struct IdentityToken(pub String);
38
39impl IdentityToken {
40    pub fn new(value: impl Into<String>) -> Self {
41        Self(value.into())
42    }
43}
44
45/// Metadata for a registered [`IdentityExtractor`].
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
48pub struct IdentityExtractorDescriptor {
49    pub name: String,
50    /// The artifact format whose atomic sub-units this extractor identifies.
51    pub format: ArtifactFormat,
52}
53
54/// Derives an ordered sequence of opaque [`IdentityToken`]s for an artifact —
55/// the atomic sub-units (e.g. table rows) used by partition (split/merge)
56/// detection.
57///
58/// Keyed by [`ArtifactFormat`] and dispatched like writers/compaction/annotators;
59/// a format with no registered extractor is simply not partition-capable. The
60/// extractor rides the *format*, so every producer of that format gains
61/// partition capability for free — the parsers do nothing.
62pub trait IdentityExtractor: Send + Sync {
63    fn descriptor(&self) -> IdentityExtractorDescriptor;
64    /// Yield the ordered identity tokens for one artifact's serialized bytes.
65    fn extract(&self, artifact_bytes: &[u8]) -> BinocResult<Vec<IdentityToken>>;
66}
67
68/// The SDK's `tabular_v1` identity extractor: one token per row, derived from the
69/// row's cell values (order-stable canonical JSON of the `Vec<Value>`). All six
70/// `tabular_v1` producers (CSV, SQLite, Excel, Parquet, Avro, DBF) gain partition
71/// capability through this single extractor.
72pub struct TabularIdentityExtractor;
73
74impl IdentityExtractor for TabularIdentityExtractor {
75    fn descriptor(&self) -> IdentityExtractorDescriptor {
76        IdentityExtractorDescriptor {
77            name: "binoc.identity.tabular".into(),
78            format: tabular_v1(),
79        }
80    }
81
82    fn extract(&self, artifact_bytes: &[u8]) -> BinocResult<Vec<IdentityToken>> {
83        let table: TabularData = serde_json::from_slice(artifact_bytes).map_err(|err| {
84            BinocError::Other(format!("decode tabular artifact for identity: {err}"))
85        })?;
86        // The token is the row's cell values only — not the header — so the same
87        // row recognizes across a reformat that reorders/renames columns is left
88        // to the fuzzy tier; here equality is exact cell content.
89        Ok(table
90            .rows
91            .iter()
92            .map(|row| {
93                let canonical = serde_json::to_string(row).unwrap_or_default();
94                IdentityToken::new(canonical)
95            })
96            .collect())
97    }
98}
99
100/// One participant in a coverage query: an opaque node handle plus its ordered
101/// identity tokens.
102pub struct Candidate<T> {
103    pub node: T,
104    pub tokens: Vec<IdentityToken>,
105}
106
107/// A clean, complete, disjoint, unambiguous partition: `whole`'s token multiset
108/// is exactly the disjoint union of `parts`, each a strict subset.
109pub struct PartitionMatch<T> {
110    pub whole: T,
111    pub parts: Vec<T>,
112    /// Number of atoms (tokens) the partition covers — `whole`'s total.
113    pub covered: usize,
114}
115
116/// Outcome of [`disjoint_cover`].
117pub enum Coverage<T> {
118    /// A clean partition: complete (residual 0), disjoint, unambiguous, ≥2 parts,
119    /// and no single part equals the whole.
120    Clean(PartitionMatch<T>),
121    /// `whole` shares atoms with `pool` members but the relationship is not clean
122    /// — a residual, a shared (ambiguous) token, or a foreign atom. A
123    /// conservative consumer declines and reports `binoc.possible_split`.
124    NearMiss,
125    /// `whole` shares no atoms with any `pool` member — unrelated.
126    None,
127}
128
129/// Is `whole`'s token multiset the **clean disjoint union** of a subset of
130/// `pool`? Generic over opaque tokens and location-agnostic — `pool` may live
131/// anywhere in either tree, because the tokens carry the correspondence, not the
132/// structure. Call it with `whole` = an input and `pool` = the output residue to
133/// detect a split; swap the sides to detect a merge.
134///
135/// Conservative by construction: any pool member that shares at least one token
136/// with `whole` is treated as a participant, and the cover is [`Coverage::Clean`]
137/// only when **every** participant is fully inside `whole` (no foreign atoms),
138/// participants are pairwise disjoint (no token owned by two — the ambiguity
139/// flag), their union reconstructs `whole` exactly (residual 0), there are at
140/// least two of them, and none equals the whole. Anything else is a
141/// [`Coverage::NearMiss`].
142pub fn disjoint_cover<T: Clone>(whole: &Candidate<T>, pool: &[Candidate<T>]) -> Coverage<T> {
143    if whole.tokens.is_empty() {
144        return Coverage::None;
145    }
146    let mut whole_counts: HashMap<&IdentityToken, usize> = HashMap::new();
147    for token in &whole.tokens {
148        *whole_counts.entry(token).or_default() += 1;
149    }
150
151    // A pool member participates if it shares any atom with the whole.
152    let participants: Vec<usize> = pool
153        .iter()
154        .enumerate()
155        .filter(|(_, cand)| {
156            !cand.tokens.is_empty() && cand.tokens.iter().any(|t| whole_counts.contains_key(t))
157        })
158        .map(|(index, _)| index)
159        .collect();
160    if participants.is_empty() {
161        return Coverage::None;
162    }
163
164    // Walk every participant atom once, checking three cleanliness properties:
165    // foreign atoms (a participant token not in the whole), ambiguity (a token
166    // owned by two participants), and coverage (participant union == whole).
167    let mut owner: HashMap<&IdentityToken, usize> = HashMap::new();
168    let mut covered: HashMap<&IdentityToken, usize> = HashMap::new();
169    let mut clean = true;
170    for &index in &participants {
171        for token in &pool[index].tokens {
172            if !whole_counts.contains_key(token) {
173                clean = false; // foreign atom
174            }
175            match owner.get(token) {
176                Some(&existing) if existing != index => clean = false, // ambiguous
177                _ => {
178                    owner.insert(token, index);
179                }
180            }
181            *covered.entry(token).or_default() += 1;
182        }
183    }
184    if clean {
185        for (token, &want) in &whole_counts {
186            if covered.get(token).copied().unwrap_or(0) != want {
187                clean = false; // residual / over-cover
188                break;
189            }
190        }
191    }
192    if participants.len() < 2 {
193        // A single participant is a 1:1 relationship — a clean whole-artifact
194        // move/copy, or a modify with shared-and-changed rows. Either way the
195        // exact/fuzzy rules own it; it is neither a split nor a near miss worth a
196        // diagnostic. Only ≥2 other-side tables that *together* almost reconstruct
197        // the whole are a genuine partition (or near miss).
198        return Coverage::None;
199    }
200    if !clean {
201        return Coverage::NearMiss;
202    }
203
204    Coverage::Clean(PartitionMatch {
205        whole: whole.node.clone(),
206        parts: participants
207            .iter()
208            .map(|&index| pool[index].node.clone())
209            .collect(),
210        covered: whole.tokens.len(),
211    })
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn cand(node: &str, rows: &[&str]) -> Candidate<String> {
219        Candidate {
220            node: node.to_string(),
221            tokens: rows.iter().map(|r| IdentityToken::new(*r)).collect(),
222        }
223    }
224
225    #[test]
226    fn clean_split_is_detected() {
227        let whole = cand("all", &["a", "b", "c", "d"]);
228        let pool = vec![cand("x", &["a", "b"]), cand("y", &["c", "d"])];
229        match disjoint_cover(&whole, &pool) {
230            Coverage::Clean(m) => {
231                assert_eq!(m.covered, 4);
232                assert_eq!(m.parts.len(), 2);
233            }
234            _ => panic!("expected clean split"),
235        }
236    }
237
238    #[test]
239    fn residual_is_near_miss() {
240        // `d` is unaccounted for by the parts.
241        let whole = cand("all", &["a", "b", "c", "d"]);
242        let pool = vec![cand("x", &["a", "b"]), cand("y", &["c"])];
243        assert!(matches!(disjoint_cover(&whole, &pool), Coverage::NearMiss));
244    }
245
246    #[test]
247    fn shared_token_is_ambiguous_near_miss() {
248        // `b` appears in both parts.
249        let whole = cand("all", &["a", "b", "c"]);
250        let pool = vec![cand("x", &["a", "b"]), cand("y", &["b", "c"])];
251        assert!(matches!(disjoint_cover(&whole, &pool), Coverage::NearMiss));
252    }
253
254    #[test]
255    fn foreign_atom_is_near_miss() {
256        // `y` carries `z`, which is not in the whole — split-plus-edit, deferred.
257        let whole = cand("all", &["a", "b", "c"]);
258        let pool = vec![cand("x", &["a"]), cand("y", &["b", "c", "z"])];
259        assert!(matches!(disjoint_cover(&whole, &pool), Coverage::NearMiss));
260    }
261
262    #[test]
263    fn single_participant_partial_is_not_a_split() {
264        // A 1:1 reformat/modify: one other-side table shares some rows and changes
265        // others. That is not a partition near miss — only the fuzzy rules' job.
266        let whole = cand("data.csv", &["a", "b", "c"]);
267        let pool = vec![cand("data.tsv", &["a", "c", "d"])];
268        assert!(matches!(disjoint_cover(&whole, &pool), Coverage::None));
269    }
270
271    #[test]
272    fn unrelated_pool_is_none() {
273        let whole = cand("all", &["a", "b"]);
274        let pool = vec![cand("x", &["m", "n"])];
275        assert!(matches!(disjoint_cover(&whole, &pool), Coverage::None));
276    }
277
278    #[test]
279    fn single_whole_cover_is_not_a_split() {
280        // One part reconstructs the whole — that's a 1:1 move, not a split, and
281        // not a near miss (no diagnostic): the exact rules own it.
282        let whole = cand("all", &["a", "b"]);
283        let pool = vec![cand("x", &["a", "b"])];
284        assert!(matches!(disjoint_cover(&whole, &pool), Coverage::None));
285    }
286}