Skip to content

Commit 43b2c19

Browse files
committed
PartialJoin trait abstracting fallible merge operations
PartialJoin<V>::try_join returns JoinResult<V>, either Ok(v), where v the least upper bound, or a Err(Conflict<V>) where the conflict contains values that could not be joined. Conflict<V> is a multiset with set-equality semantics (order-independent). It implements JoinMut to merge conflict sets (union of distinct values). JoinResult<V> itself implements Join: (Ok(a), Ok(b)) => a.try_join(b) (delegate to PartialJoin on V) (Ok(v), Err(c)) => Err(c ∪ {v}) (absorb into conflict) (Err(c), Ok(v)) => Err(c ∪ {v}) (absorb into conflict) (Err(a), Err(b)) => Err(a ∪ b) (join conflict sets) Containers or product types that wrap their fields in JoinResult<V> can implement Join recursively to compute the field-by-field merge without early exit. assert_partial_join_laws! is a #[macro_export] public API macro (gated behind prop-tests feature). Given clean-value and result strategies, it generates try_join law tests, JoinResult law tests (via assert_join_laws!), and a wrap roundtrip test.
1 parent 53dfcae commit 43b2c19

3 files changed

Lines changed: 353 additions & 0 deletions

File tree

src/lattice/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
#[macro_use]
22
pub mod join;
3+
#[macro_use]
4+
pub mod partial;

src/lattice/partial.rs

Lines changed: 350 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,350 @@
1+
//! Some types have a natural merge operation but that isn't defined for all pairs of values.
2+
//! [`PartialJoin::try_join`] captures this.
3+
//!
4+
//! It returns [`JoinResult<V>`], an alias for `Result<V, Conflict<V>>`, where [`Conflict<V>`],
5+
//! collects all the distinct values that couldn't be merged.
6+
//!
7+
//! [`JoinResult<V>`] implements [`Join`], which is an infallible join operation (the free
8+
//! semilattice over `V`, more or less), in terms of [`Conflict<V>`]'s [`Join`] implementation.
9+
10+
use crate::lattice::join::{Join, JoinMut};
11+
12+
/// Fallible join for scalar value types where merge may produce a conflict.
13+
///
14+
/// Containers should not implement this, instead they should implement [`Join`] by wrapping
15+
/// contained values in [`JoinResult`].
16+
pub trait PartialJoin: Sized + PartialEq {
17+
/// Attempt to join two values.
18+
///
19+
/// Returns `Ok(lub)` where `lub` is the least upper bound if one exists, or `Err(Conflict)`
20+
/// containing both values.
21+
fn try_join(self, other: Self) -> JoinResult<Self>;
22+
23+
/// Lift a value into the result domain as `Ok(self)`.
24+
fn wrap(self) -> JoinResult<Self> {
25+
Ok(self)
26+
}
27+
}
28+
29+
/// Result of a fallible join: `Ok(v)` where `v` is the least upper bound if one exists,
30+
/// `Err(Conflict)` otherwise.
31+
///
32+
/// `JoinResult<V>` implements [`Join`].
33+
pub type JoinResult<V> = Result<V, Conflict<V>>;
34+
35+
impl<V> Join for JoinResult<V>
36+
where
37+
V: PartialJoin,
38+
{
39+
fn join(self, other: Self) -> Self {
40+
match (self, other) {
41+
(Ok(a), Ok(b)) => a.try_join(b),
42+
(Err(a), Err(b)) => Err(a.join(b)),
43+
(Err(a), Ok(b)) => Err(a.join(Conflict::singleton(b))),
44+
(Ok(a), Err(b)) => Err(Conflict::singleton(a).join(b)),
45+
}
46+
}
47+
}
48+
49+
/// A set of conflicting values, produced when a join for those values does not exist.
50+
///
51+
/// When two participants set the same field to different values, neither can be chosen without
52+
/// losing information. `Conflict` preserves the set of distinct conflicting values allowing the
53+
/// caller to inspect or resolve the disagreement.
54+
///
55+
/// The inner values are [`PartialJoin`] with equality based on [`PartialEq`]. Duplicate values
56+
/// are omitted.
57+
///
58+
/// # Order preservation (provenance)
59+
///
60+
/// Although `Conflict<V>` has set semantics, insertion order is preserved during iteration: when
61+
/// two `Ok` values are joined and produce a binary conflict, the first value is the left operand
62+
/// and the second is the right.
63+
///
64+
/// Provenance is only reliable when both operands were `Ok` before the join. When an existing
65+
/// `Conflict` is joined with another value, new entries are appended, so a multi-way fold does not
66+
/// preserve per-operand attribution.
67+
#[derive(Debug, Clone)]
68+
pub struct Conflict<V>(Vec<V>);
69+
70+
/// Set equality: same elements, regardless of order.
71+
///
72+
/// $O(n^2)$ but $n$ is tiny in practice (typically 2).
73+
impl<V: PartialJoin> PartialEq for Conflict<V> {
74+
fn eq(&self, other: &Self) -> bool {
75+
self.0.len() == other.0.len() && self.0.iter().all(|v| other.0.contains(v))
76+
}
77+
}
78+
79+
impl<V: PartialJoin + Eq> Eq for Conflict<V> {}
80+
81+
impl<V> Conflict<V> {
82+
/// Wrap a single value into a conflict.
83+
fn singleton(v: V) -> Self {
84+
Self(vec![v])
85+
}
86+
87+
/// Number of distinct conflicting values.
88+
pub fn len(&self) -> usize {
89+
self.0.len()
90+
}
91+
92+
pub fn is_empty(&self) -> bool {
93+
self.0.is_empty()
94+
}
95+
}
96+
97+
impl<V> IntoIterator for Conflict<V> {
98+
type Item = V;
99+
type IntoIter = std::vec::IntoIter<V>;
100+
101+
fn into_iter(self) -> Self::IntoIter {
102+
self.0.into_iter()
103+
}
104+
}
105+
106+
impl<'a, V> IntoIterator for &'a Conflict<V> {
107+
type Item = &'a V;
108+
type IntoIter = std::slice::Iter<'a, V>;
109+
110+
fn into_iter(self) -> Self::IntoIter {
111+
self.0.iter()
112+
}
113+
}
114+
115+
impl<V: PartialJoin> Conflict<V> {
116+
/// Build from an iterator, deduplicating values.
117+
#[allow(dead_code)] // production caller: tspwkqxz (values.rs IdempotentValue::try_join)
118+
pub(crate) fn from_values(iter: impl IntoIterator<Item = V>) -> Self {
119+
let mut vals = Vec::new();
120+
for v in iter {
121+
if !vals.contains(&v) {
122+
vals.push(v);
123+
}
124+
}
125+
Self(vals)
126+
}
127+
}
128+
129+
impl<V: PartialJoin> JoinMut for Conflict<V> {
130+
fn join_mut(&mut self, other: Self) {
131+
for value in other.0 {
132+
if !self.0.contains(&value) {
133+
self.0.push(value)
134+
}
135+
}
136+
}
137+
}
138+
139+
/// Proptest macros for verifying `PartialJoin` laws and the `JoinResult` completion. Available
140+
/// when `prop-tests` feature is enabled.
141+
///
142+
/// # `assert_partial_join_laws!($arb_clean, $arb_result)`
143+
///
144+
/// Generates:
145+
/// - `try_join` laws on clean values: idempotent, commutative, associative (via wrap/join)
146+
/// - `JoinResult` laws on pre-built Ok/Err values: idempotent, commutative, associative
147+
/// - `wrap_roundtrip`: wrapping a clean value always produces `Ok`
148+
///
149+
/// The `$arb_clean` strategy produces clean (pre-join) values.
150+
/// The `$arb_result` strategy produces `JoinResult` values (both Ok and Err).
151+
#[cfg(feature = "prop-tests")]
152+
#[allow(unused_macros)] // used by downstream commits (collection modules, domain types)
153+
macro_rules! assert_partial_join_laws {
154+
($arb_clean:expr, $arb_result:expr) => {
155+
proptest! {
156+
#[test]
157+
fn try_join_idempotent(a in $arb_clean) {
158+
prop_assert_eq!(a.clone().try_join(a.clone()), Ok(a));
159+
}
160+
161+
#[test]
162+
fn try_join_commutative(a in $arb_clean, b in $arb_clean) {
163+
prop_assert_eq!(a.clone().try_join(b.clone()), b.try_join(a));
164+
}
165+
166+
#[test]
167+
fn try_join_associative(a in $arb_clean, b in $arb_clean, c in $arb_clean) {
168+
prop_assert_eq!(
169+
a.clone().try_join(b.clone()).join(c.clone().wrap()),
170+
a.wrap().join(b.try_join(c)),
171+
);
172+
}
173+
174+
#[test]
175+
fn wrap_roundtrip(a in $arb_clean) {
176+
let wrapped = a.clone().wrap();
177+
prop_assert!(wrapped.is_ok());
178+
prop_assert_eq!(wrapped.expect("should be Ok"), a);
179+
}
180+
}
181+
182+
mod join_result {
183+
use super::*;
184+
assert_join_laws!($arb_result);
185+
}
186+
};
187+
}
188+
189+
#[cfg(test)]
190+
#[cfg_attr(coverage_nightly, coverage(off))]
191+
mod tests {
192+
use super::*;
193+
194+
#[derive(Debug, Clone, Copy, PartialEq)]
195+
struct Foo(u8);
196+
197+
impl PartialJoin for Foo {
198+
fn try_join(self, other: Self) -> JoinResult<Self> {
199+
if self == other {
200+
Ok(self)
201+
} else {
202+
Err(Conflict::from_values([self, other]))
203+
}
204+
}
205+
}
206+
207+
#[cfg(feature = "unit-tests")]
208+
mod unit {
209+
use super::*;
210+
211+
#[test]
212+
fn result_ok_ok_different_produces_conflict() {
213+
assert_eq!(
214+
Foo(0).wrap().join(Foo(1).wrap()),
215+
Err(Conflict(vec![Foo(0), Foo(1)])),
216+
);
217+
}
218+
219+
#[test]
220+
fn result_err_ok_absorbs() {
221+
let err: JoinResult<Foo> = Err(Conflict(vec![Foo(0), Foo(1)]));
222+
assert_eq!(
223+
err.join(Ok(Foo(2))),
224+
Err(Conflict(vec![Foo(0), Foo(1), Foo(2)]))
225+
);
226+
}
227+
228+
#[test]
229+
fn result_ok_err_absorbs() {
230+
let err: JoinResult<Foo> = Err(Conflict(vec![Foo(0), Foo(1)]));
231+
assert_eq!(
232+
Ok(Foo(2)).join(err),
233+
Err(Conflict(vec![Foo(2), Foo(0), Foo(1)]))
234+
);
235+
}
236+
237+
#[test]
238+
fn result_err_err_merges_conflicts() {
239+
let a: JoinResult<Foo> = Err(Conflict(vec![Foo(0), Foo(1)]));
240+
let b: JoinResult<Foo> = Err(Conflict(vec![Foo(1), Foo(2)]));
241+
assert_eq!(a.join(b), Err(Conflict(vec![Foo(0), Foo(1), Foo(2)])));
242+
}
243+
244+
#[test]
245+
fn different_conflicts_are_not_equal() {
246+
let a = Conflict::from_values([Foo(0), Foo(1)]);
247+
let b = Conflict::from_values([Foo(0), Foo(2)]);
248+
assert_ne!(a, b);
249+
}
250+
251+
#[test]
252+
fn different_length_conflicts_are_not_equal() {
253+
let a = Conflict::from_values([Foo(0), Foo(1)]);
254+
let b = Conflict::from_values([Foo(0), Foo(1), Foo(2)]);
255+
assert_ne!(a, b);
256+
}
257+
258+
#[test]
259+
fn conflict_from_equal_pair_deduplicates() {
260+
let c = Conflict::from_values([Foo(0), Foo(0)]);
261+
assert_eq!(c.len(), 1);
262+
}
263+
264+
#[test]
265+
fn is_empty() {
266+
assert!(!Conflict::from_values([Foo(0)]).is_empty());
267+
}
268+
269+
#[test]
270+
fn into_iter_yields_elements() {
271+
let c = Conflict::from_values([Foo(0), Foo(1)]);
272+
let v: Vec<_> = c.into_iter().collect();
273+
assert_eq!(v, vec![Foo(0), Foo(1)]);
274+
}
275+
276+
#[test]
277+
fn borrowed_iteration() {
278+
let c = Conflict::from_values([Foo(0), Foo(1)]);
279+
let v: Vec<_> = (&c).into_iter().collect();
280+
assert_eq!(v, vec![&Foo(0), &Foo(1)]);
281+
}
282+
}
283+
284+
// Small domain (4 values): high collision rate exercises the
285+
// equal-value (Ok) path while still covering the conflict (Err) path.
286+
#[cfg(feature = "prop-tests")]
287+
mod prop {
288+
use super::*;
289+
use proptest::prelude::*;
290+
291+
pub fn arb_foo() -> impl Strategy<Value = Foo> {
292+
(0u8..4).prop_map(Foo)
293+
}
294+
295+
/// Generate a JoinResult<Foo> which is either Ok(v) or Err(Conflict) with 1–5 values.
296+
/// Exercises all arms of JoinResult::join.
297+
pub fn arb_join_result() -> impl Strategy<Value = JoinResult<Foo>> {
298+
prop_oneof![
299+
arb_foo().prop_map(Ok),
300+
proptest::collection::vec(arb_foo(), 1..=5)
301+
.prop_map(|v| Err(Conflict::from_values(v))),
302+
]
303+
}
304+
305+
assert_partial_join_laws!(arb_foo(), arb_join_result());
306+
307+
proptest! {
308+
#[test]
309+
fn conflict_into_iter_roundtrips(a in arb_foo(), b in arb_foo()) {
310+
let c = Conflict::from_values([a, b]);
311+
let vals = c.clone().into_iter();
312+
let rebuilt = Conflict::from_values(vals);
313+
prop_assert_eq!(c, rebuilt);
314+
}
315+
316+
#[test]
317+
fn borrowed_iter_matches_owned(a in arb_foo(), b in arb_foo()) {
318+
let c = Conflict::from_values([a, b]);
319+
let owned: Vec<_> = c.clone().into_iter().collect();
320+
let borrowed: Vec<_> = (&c).into_iter().copied().collect();
321+
prop_assert_eq!(owned, borrowed);
322+
}
323+
324+
#[test]
325+
fn len_matches_iter_count(a in arb_foo(), b in arb_foo(), c in arb_foo()) {
326+
let conflict = Conflict::from_values([a, b, c]);
327+
prop_assert_eq!(conflict.len(), conflict.into_iter().count());
328+
}
329+
330+
#[test]
331+
fn non_empty_after_construction(a in arb_foo()) {
332+
let c = Conflict::from_values([a]);
333+
prop_assert!(!c.is_empty());
334+
}
335+
336+
#[test]
337+
fn conflict_different_lengths_not_equal(
338+
a in arb_foo(),
339+
b in arb_foo(),
340+
c in arb_foo(),
341+
) {
342+
let short = Conflict::from_values([a, b]);
343+
let long = Conflict::from_values([a, b, c]);
344+
if short.len() != long.len() {
345+
prop_assert_ne!(short, long);
346+
}
347+
}
348+
}
349+
}
350+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
mod lattice;
55

66
pub use lattice::join::{Join, JoinMut};
7+
pub use lattice::partial::{Conflict, JoinResult, PartialJoin};

0 commit comments

Comments
 (0)