Skip to content

Join and PartialJoin Traits - #16

Open
yuval-block wants to merge 3 commits into
mainfrom
lattice-partial
Open

Join and PartialJoin Traits#16
yuval-block wants to merge 3 commits into
mainfrom
lattice-partial

Conversation

@yuval-block

Copy link
Copy Markdown
Collaborator

Introduces Join and PartialJoin, traits for combining values that respect commutativity, associativity, and idempotence.

This allows collections of values to be merged, tracking conflicts between them.

Join::join is total (infallible), PartialJoin::try_join returns a JoinResult. JoinResult implements Join. PartialJoin::wrap wraps self in Ok injecting or "lifting" it into the JoinResult space, which is essentially a completion of join by way of a formal product: the Conflict struct just accumulates arguments to join when one does not exist in the partial definition.

Lifting to the result type is more convenient since a total join can be used to reduce items in any order and obtain the least upper bound of all of the inputs, which in this case may be a conflict.

Join is the crate-internal infallible merge operation used by the PSBT
constructor and sorter implementation. Implementors must satisfy
idempotent, commutative, and associative laws.

JoinMut is the in-place variant; a blanket impl provides Join
automatically.

assert_join_laws! is also provided as a crate-internal (gated behind
prop-tests feature and scoped via #[macro_use]). Given an arbitrary
strategy, it generates three proptests verifying the semilattice laws
for any Join + Clone + PartialEq + Debug type.
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 crate-internal macro (gated behind
prop-tests feature, scoped via #[macro_use]). Given clean-value and
result strategies, it generates try_join law tests, JoinResult law tests
(via assert_join_laws!), and a wrap roundtrip test.

## Rationale for Conflict as flat, order preserving & order insensitive

Conflicts represent a formal completion of the partial semilattice `V :
PartialJoin`. If `a` and `b` are conflicts, commutativity requires that
`a.join(b) == b.join(a)`, and idempotence requires that `a.join(a) ==
a`. This is somewhat at odds with keeping track of where each
conflicting value originated from.

The main purpose of preserving the order is to allow provenance to be
tracked.

If `a` and `b` are both conflict free, and `let c = a.join(b)`, then
`c.conflicted_field.len() == 2` and the first value is from `a` whereas
the second is from `b`, which makes reporting this as an error with
clear diagnostics easier, without requiring the provenance be tracked by
some kind of surrogate ID.

This does not generalize to n > 2, because if
`a.join(b).join(c).some_conflicted_field.len() == 2`, the values could
originate from `(a, b)`, `(b, c)`, or `(a, c)`.

This compromise keeps the interface and implementation simple, allows
provenance to be tracked as long as it's done one pair of values at a
time in a straightforward way, but imposes no additional burdens on
users that do not care about provenance (for instance if computing
something like `vs.reduce(|a, b| a.join(b))`)

### Alternatives considered

Several alternative approaches were tried, of which the compromise of
making `Conflict` just a thin wrapper around Vec seemed the best.

#### HashSet or BTreeSet based

This alternative is very close to what is implemented. The differences
are that with a Vec, the order is preserved, the implementation of
equality and `join` has quadratic complexity. We expect `n` to be very
small so this shouldn't make a difference in practice.

Using a lookup based set requires `V : Hash` or `V : Ord` which the
current `Conflict` does not require (unfortunately adding it later would
be semver breaking, as would be changing the return value from `iter()`
or the associated `IntoIter` type of the `IntoIterator` impl).

#### Recursive data type

The following definition could in principle shadow the `join` structure:

```rs
enum Conflict<V> {
    Value(V),
    Pair([Box<Conflict>; 2])
}
```

In this case, if `a.join(b).join(c.join(d))` has 4 conflicting values,
they would take the form `Pair([ Pair([ Value(x), Value(y) ]), Pair([
Value(z), Value(w) ]) ])`, which is arguably more informative.
Unfortunately this is still imprecise because if `a.join(b).join(c)` has
a binary conflict `Pair([ Value(x), Value(y) ])`, it's still ambiguous
in the same way.

In order for this approach to be workable it has to shadow the syntax
tree of the join operation for the Ok branch too, in which case this
entire abstraction kind of only computing the transpose, going from e.g.
a list of structs with values, to a struct of lists of values, but not
reducing any of the complexity unless there are no conflicts anywhere.

The purpose of these abstractions is to take the problem of merging two
or more compound values into the a series of simpler problems, merging
two or more elements of a simpler type. Tracking provenance with perfect
fidelity means that if there is any conflict the structure is not
simplified at all.

#### n-ary join

The final option considered was defining join not as a binary operation
but n-ary. This is no different than the other options in terms of
expressive power, it could be just a simple transpose step followed by
some kind of collapse-compatible-values-to-LUB step. Presumably this
could be impl on `SomeNewType(Vec<Psbt>)`, with a transpose operation to
a `VecPsbt` (analog of `ResultPsbt`) which internally contains `Vec<>`
wrappers (instead of `JoinResult` wrappers) for each field. Then this
transposed structure would be joined field-wise, attempting to collapse
the n items to their LUB.

The current implementation can kinda represent this operation by mapping
*everything* to a conflict, joining and then reducing the conflicts, at
the cost of no longer having the special meaning of a unary conflict
representing a global invariant violation.

### Associated error type or generics

The above alternatives imply a "one size fits all" approach. However,
PartialJoin could have an Error type, where `JoinResult<V> = Result<V,
<V as PartialJoin>::Error>`.

Ostensibly this would allow some choice, but with associated types the
choice is fixed per implementation of the trait and so would not afford
users the choice of whether to opt out of provenance tracking for
simpler errors or opt in and deal with the added complexity.

Making the error type fully generic would make that possible with even
more complexity and syntactic overhead. However, no generality would be
gained for this additional complexity.

Thinking of Conflict<V> as just "deferred arguments for a join" (i.e. a
formal product), any arbitrary merge operation can be expressed by just
taking those arguments.

More formally, Conflict<V> is the free semilattice (sets under union)
over V. Since every semilattice is a quotient of the free semilattice,
so there is no operation that can be expressed by setting Error to some
type that merges V's according to some rules (e.g. taking the max of
integers) that can't be expressed by simply processing the conflict
after the fact.

### Conclusion

For these reasons, making Conflict a thin wrapper around `Vec` seems
like the best compromise: has the same expressive power but results in a
simpler interface than all the alternatives, and makes provenance
tracking possible and even relatively straightforward without forcing it
on all users.
@0xZaddyy

0xZaddyy commented Jul 23, 2026

Copy link
Copy Markdown

cACK, PartialJoin::try_join is documented as returning Ok(lub) whenever a least upper bound exists. Under that contract, JoinResult<V>::join is not necessarily associative.

For example, consider a partial semilattice with values A, B, C, and D, where:
A ⋁ B = C
joining either A, B, or C with D produces a conflict
Then:
(A ⋁ B) ⋁ D = Err({C, D})
A ⋁ (B ⋁ D) = Err({A, B, D})
I reproduced this locally with an integration test; the equality assertion fails with those exact results.
The existing Foo properties do not expose this because Foo only supports equality-or-conflict merging. For that restricted model, simply accumulating conflict members works.
Could we clarify which contract is intended?
If PartialJoin is restricted to equality-or-conflict semantics, document that Ok may only represent the unchanged equal value and adjust the general Ok(lub) wording.
If nontrivial LUBs are intended, JoinResult::join needs a canonicalization strategy, so conflicting expressions remain associative.
In either case, adding a test type with a nontrivial successful join would make the intended behavior explicit. The join_result property strategy is useful here, although it currently constructs singleton and otherwise potentially unreachable conflicts, so the intended valid state space should also be documented.

@yuval-block

yuval-block commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

That edge case is acceptable because in the power set lattice the LUB of {A, B, C} is C, so {C, D} and {A, B, D} have equivalent information (and by Birkhoff’s representation theorem this applies to any lattice).

Because of this correspondence, and our decision to favor a formal join (Conflict is just completing any partial lattice via the power set lattice), the diagram does commute for any non-trivial completion definition of join that resolves conflicts, since to be consistent it would need to join {C, D} and {A, B, D} into exactly the same thing.

@bc1cindy

Copy link
Copy Markdown

That edge case is acceptable because in the power set lattice the LUB of {A, B, C} is C, so {C, D} and {A, B, D} have equivalent information (and by Birkhoff’s representation theorem this applies to any lattice).

Because of this correspondence, and our decision to favor a formal join (Conflict is just completing any partial lattice via the power set lattice), the diagram does commute for any non-trivial completion definition of join that resolves conflicts, since to be consistent it would need to join {C, D} and {A, B, D} into exactly the same thing.

i think the equivalence argument holds semantically, but Conflict's PartialEq is structural set equality, not equality up to join, so assert_join_laws! still fails once a type has a non-trivial LUB: Err({C, D}) != Err({A, B, D})

would it make sense to restrict the docs to equality-or-conflict (Ok only when a == b), or canonicalize Conflict?

@bc1cindy

Copy link
Copy Markdown

it currently constructs singleton and otherwise potentially unreachable conflicts, so the intended valid state space should also be documented.

from_values being pub is what makes empty/singleton conflicts constructible from outside. worth making it pub(crate)?

@bc1cindy bc1cindy left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall looks great!

Comment on lines +80 to +83
#[test]
fn unit_type_join() {
assert_eq!(Join::join((), ()), ());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just clippy fails here (clippy::unit_cmp, deny-by-default)

the assert can't fail since () has only one value. I guess CI doesn't catch it because the nightly clippy job is warn-only. is it intentional?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think comparing to unit is intentional and can be ignored

@xstoicunicornx

Copy link
Copy Markdown

A lot of these concepts are brand new to me so I want to start with a general questions regarding Conflict that will hopefully help my understanding.

How is Conflict intended to be used? If this library is intended to be used for joining PSBTs, when would one want to continue joining with a Conflict rather than just bailing out? Are callers expected to recover from the conflict? If so do they implement that themselves or is that something this library will provide?

@Mshehu5 Mshehu5 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implementation of Join and Partial Join looks okay and tests looks good too
Other Reviewers can check payjoin/concurrent-psbt#20 to see past discussions

@nothingmuch

Copy link
Copy Markdown
Contributor

How is Conflict intended to be used?

In the special case of x.wrap().join(y.wrap()), which is a bit like Ok(x) + Ok(y), a conflict can be interpreted as an ordered list if len == 2, preserving which value came from which side.

In all other cases, Conflict is only to be interpreted as a set, with no meaning to the order. This is in some sense a universal representation: https://en.wikipedia.org/wiki/Birkhoff%27s_representation_theorem)

To define a join operation for the particular field, for example tx version is a JoinResult<u8> that can make sense to resolve via max(). The particular merge semantics might depend on the application.

mut borrowing will suffice to map the join results with that lattice's join, so for example for integers just call max on all the values in a JoinResult to always produce an Ok with the least upper bound.

However, the main use is likely to be reporting:

For genuinely conflicting values, in the CLI the main thing to do is report the error to the console. This means traversing the Result${Foo} struct, extracting the conflicted data into a representation that lists the conflicts with provenance (i.e. a description of which field it's from), where the conflicts are lifted to Box<dyn ...> with comparison and display trait bounds that are suitable for this

In a GUI this could be a modal dialog that forces you to select one of the conflicting values for each field, and then creates a new session joining those values with everything that didn't conflict, or conflicted fields could just be permitted, but serialization would require making such a selection. Note that representing a conflict is easy, it just requires a relaxation of PSBT rules to allow duplicate field values. The only thing that can't be represented is input/output count conflicts, but those are programmer errors anyway and shouldn't be possible via the typesafe API (Constructor etc).

The joinable struct macro doesn't do very much at the moment, but it's planned to be extended to help support this kind of use, so that the fields are generically introspectable (so e.g. when constructing the error as discussed in the previous paragraph you can loop through the fields, instead of having one line per field). There are some compile time introspection crates we might want to take inspiration from but i would prefer not to introduce such a dependency.

If this library is intended to be used for joining PSBTs, when would one want to continue joining with a Conflict rather than just bailing out?

Whenever there's a deterministic rule to select a value consistently across replicas, given the set of values in the conflict.

Are callers expected to recover from the conflict? If so do they implement that themselves or is that something this library will provide?

users may be required to, if they are not prevented from doing that locally (e.g. lock certain global fields' values ahead of time).

with the keydata G-Set, we never expect any conflicts, and most of the CRDTs will be encoded this way (fee contributions, payment confirmations, txin/out removal, ...).

BIP 370 already defines nLocktime via max of fallback and per input required nlocktimes.

the BIP 174 combiner rule could be seen as a join in a quotient lattice.

@bc1cindy bc1cindy left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

utACK 3448b07

Comment on lines +19 to +20
/// Returns `Ok(lub)` where `lub` is the least upper bound if one exists, or `Err(Conflict)`
/// containing both values.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

non-blocking: i think this restriction is already implied by the design: any non-trivial LUB (a ∨ b = c, a ≠ b) breaks associativity of JoinResult's Join under Conflict's structural
PartialEq. Err({c, d}) ≠ Err({a, b, d}), and per the rationale itself, non-trivial merges are expressed by processing the conflict after the fact.

Suggested change
/// Returns `Ok(lub)` where `lub` is the least upper bound if one exists, or `Err(Conflict)`
/// containing both values.
/// Returns `Ok(self)` when the two values are equal, or `Err(Conflict)` containing both values otherwise.
///
/// Implementations must only return `Ok` for equal values: a non-trivial least upper bound breaks associativity
/// of [`JoinResult`]'s [`Join`] under `Conflict`'s structural set equality. Non-trivial merges are expressed
/// by processing the conflict after the fact, or by implementing [`Join`] directly when the merge is total.

the contract could then be enforced in assert_partial_join_laws! so violations fail with a minimal
counterexample instead of an opaque associativity failure:

#[test]
fn ok_implies_equal(a in $arb_clean, b in $arb_clean) {
    if let Ok(v) = a.clone().try_join(b.clone()) {
        prop_assert_eq!(a.clone(), b);
        prop_assert_eq!(v, a);
    }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not true, specific types may implement PartialJoin in terms of a different subtrait than IdempotentValue. If all fields are IdempotentValue this is implied, but there may be good reasons to let these diverge later.

It's perfectly valid for a PartialJoin implementation to be total and already short circuit to a value that is the LUB.

the associativity break is syntactic/formal, not semantic, because even when it doesn't strictly hold, all of the information to obtain the join defined under completion is there, so technically this has the structure of a quotient lattice where "different" conflicts are equivalent in the sense that if they can be merged into a non-result-wrapped value coherently, then that operation will return the LUB of the different representations.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also to be clear, i don't think introducing "product lattice of partial lattice and quotient lattice of formal products" as a concept here is going to be helpful here, while it does accurately describe the construction if i haven't confused myself, i think it's going to confuse everyone else much more than it's going to help

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, thank you

@arminsabouri arminsabouri left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The meat of Join and Partial made sense. Only a couple questions. Did not look into the tests. I have reviewed them in the original PR. Can take another look if they have changed

Comment on lines +80 to +83
#[test]
fn unit_type_join() {
assert_eq!(Join::join((), ()), ());
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think comparing to unit is intentional and can be ignored

Comment on lines +19 to +24
impl<T: JoinMut> Join for T {
fn join(mut self, other: Self) -> Self {
self.join_mut(other);
self
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you have an example of where this is used?
Also would it make more sense for the impls to be reversed?
i.e implementing Join provides JoinMut automatically?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JoinMut is usually a little bit simpler to implement, especially for container types it's easier to copy from other into self rather than to copy from both self and other into a new container, that removes a tad of boilerplate.

Join is more symmetric, and assumes less of the implementation so based on the experience of implementing the rest of the crate, no this is the simpler direction and JoinMut has no consumers, only implementors

}

/// Check for duplicates in a small slice. $O(n^2)$.
#[cfg_attr(coverage_nightly, coverage(off))]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like an easy function to cover?

@nothingmuch nothingmuch Aug 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in some sense it's a test utility, it's only used in debug_assert (edit: and tests) so can't be covered in release builds

}

/// Build from an iterator, deduplicating values.
pub fn from_values(iter: impl IntoIterator<Item = V>) -> Self {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems a bit odd to pub this? Would a caller ever construct a conflict? My understanding is that its always created from try_join. Perhaps this is just for tests?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah i went back and forth a few times, and i think it can go back to being pub(crate) at the very least, i can't remember the reason i went back the last time but it should come up if it's a good one ;-)

@xstoicunicornx

Copy link
Copy Markdown

For genuinely conflicting values, in the CLI the main thing to do is report the error to the console.

If the values are genuinely conflicting wouldn't they no longer be good candidates for concurrent psbt join at all? And wouldn't you want to fail fast/early with the first conflict?

If this library is intended to be used for joining PSBTs, when would one want to continue joining with a Conflict rather than just bailing out?

Whenever there's a deterministic rule to select a value consistently across replicas, given the set of values in the conflict.

This seems to indicate two types of conflict then:

  1. Recoverable
  2. Non-recoverable

For the recoverable conflicts, why would these not be classified differently from non-recoverable conflict? Additionally, why wouldn't we just handle recoverable conflicts in a normal join flow if it is recoverable?

For the non-recoverable conflicts, why do we want continue joining after the first failure when continuing the join would require some sort of human intervention which ruins the deterministic nature of a join?

To me it seems like conflict is the most complex piece here and largely seems like something that should be guarded against prior to attempting a join rather than handling as a failure after a join attempt. It also seems simpler to explicitly define what qualifies "joinable" psbt rather than trying to handle all the conflict permutations.

I guess my question harkens back to this comment which I am still unclear about in terms of practical usage.

@nothingmuch

Copy link
Copy Markdown
Contributor

For genuinely conflicting values, in the CLI the main thing to do is report the error to the console.

If the values are genuinely conflicting wouldn't they no longer be good candidates for concurrent psbt join at all? And wouldn't you want to fail fast/early with the first conflict?

Yes. In the BFT setting, coalition formation is intended to guarantee by prior agreement that we are always on a join semi lattice, not merely a lattice.

In more informal settings, e.g. sneakernet, it is not possible in general to establish such parameters up front and ensure they are synchronized so as to preclude inputs that would conflict, so we need to detect and report them.

However, where application specific rules make it possible to merge things in a deterministic manner it's always possible to do that cleanly by just detecting that specific error condition and recovering the input values from the conflict (unless it's easier to just prevent that ahead of time, which for the set sizes for example made more sense than generating a conflict and then resolving it).

This seems to indicate two types of conflict then:

  1. Recoverable
  2. Non-recoverable

I would say:

  1. preventable
  2. non-recoverable

and then "recoverable" is defined in terms of a list of exceptions, i.e. seemingly non-recoverable but

For the recoverable conflicts, why would these not be classified differently from non-recoverable conflict?

Yes. What this crate does is provide generic stuff for the container types, and all conflicts are non-recoverable.

Additionally, why wouldn't we just handle recoverable conflicts in a normal join flow if it is recoverable?

The argument is that by the equivalence of lattices, any kind of definition of recoverable errors, which I have not found except, for application specific ones that are pretty narrow. these can always be implemented by processing Conflict values and so does not need to be supported through generics for example, it can just be out of scope.

For the non-recoverable conflicts, why do we want continue joining after the first failure when continuing the join would require some sort of human intervention which ruins the deterministic nature of a join?

Weak practical argument: Because in the sneakernet setting refusing to make progress when technically progress is possible kinda sucks (note that to actually address this we do need to allow conflicted values to be serialized, that's a small relaxation of BIP 174 parsing rules that allows for duplicates...)

Mathy argument: defining it that way makes it easier to reason about the behavior, since it adopts the structure of a well understood structure. It also simplifies the implementation, having join always succeed.

Application specific practical argument: this could be useful as is in a well understood process if conflict resolution can be defined, users of the library can just reduce conflict result variants to specific values, which makes it possible to dispatch conflict resolution in a type directed way, which makes it easier to support more than one conflict resolution strategy without needing something that is generic.

The alternative approach, which was considered, is something like the pergola crate where you generally wrap a value type in a newtype that carries the join or meet definition as a generic. That felt a lot more cumbersome in practice, and also kinda forces people to contend with the full complexity of recursively merging PSBTs instead of just merging specific fields.

The approach that was taken currently lacks a convenient way of enumerating conflicts (e.g. via some kind of introspection API) so you do still need to traverse the PSBT structure, I'm still working out the ergonomics of that.

To me it seems like conflict is the most complex piece here and largely seems like something that should be guarded against prior to attempting a join rather than handling as a failure after a join attempt. It also seems simpler to explicitly define what qualifies "joinable" psbt rather than trying to handle all the conflict permutations.

Yes, but it's only possible with another protocol, i.e. coalition formation. In the honest and semi-honest settings we can't guarantee that without some kind of setup that is done synchronously before anything else.

I guess my question harkens back to this comment which I am still unclear about in terms of practical usage.

So yeah, only application specific definitions. The main purpose of conflict objects is to allow binary or n-ary conflicts to be reported to whatever UI layer is there.

The only real example I can think of where such a rule makes sense is BIP 372 (MuSig2) because some of the field values are a list of 33 byte pubkeys, and this list may be sorted. String equality from the IdempotentValue case would reject that but this conflict is resolvable (and can be prevented by canonicalizing to sorted list before joining).

For most protocols I think the obvious choice is to use idempotence IDs (like random UUIDs or hashes) in the keydata of a field, which is automatically a grow-only set or map depending on whether or not the keydata is meaningful. This is a universal CRDT, because any operational representation can be included as elements in a set like that. But it also requires O(n) state growth.

As far as I know all existing usage is basically of this form (BIP 373, 375) already, and therefore needs no special handling other than the restricted form of the combiner role (to ensure no information is lost).

Often times the merging operation will be more like a sum than a join, i.e. it lacks idempotence, in those cases it is important to use this O(n) representation, accumulate a set and then combine the values in just one reduce pass at the end.

However, in some cases it may be more efficient or practical to define a specific field with a state based CRDT, where the field value is computed that way. So it is a legitimate thing to want to do. I expect if we ever get any kind of introspection for covenants the fields specifying inputs to those would often have definitions that would make more sense to do this way (join of the encoded form rather than the set-of-unjoined-values representation).

Because I can't think of a strong motivation right now, I didn't want to make things generic in the Join operation like the pergola crate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants