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}