Skip to content

Ignore the seed hash of an empty B in theta/tuple a-not-b - #517

Merged
leerho merged 1 commit into
apache:masterfrom
jaideeppyne:theta-anotb-empty-seed-hash
Aug 31, 2026
Merged

Ignore the seed hash of an empty B in theta/tuple a-not-b#517
leerho merged 1 commit into
apache:masterfrom
jaideeppyne:theta-anotb-empty-seed-hash

Conversation

@jaideeppyne

Copy link
Copy Markdown
Contributor

While investigating #460 (Java/C++ theta byte differences) I found a related bug that is not about bytes.

An empty sketch retains no hashes, so its seed hash carries no information. Three of the four C++ paths that validate a seed hash already know this:

  • compact_theta_sketch::deserialize_v3 / deserialize_v4: if (!is_empty) checker<true>::check_seed_hash(...)
  • theta_union_base::update: if (sketch.is_empty()) return; before the check
  • theta_intersection_base::update: if (!sketch.is_empty() && sketch.get_seed_hash() != ...) throw

and all three existing seed mismatch tests record the intent in a comment: sketch.update(1); // non-empty should not be ignored.

theta_set_difference_base::compute is the exception. Its early return only fires when a.get_num_retained() > 0, so when A is non-empty with zero retained entries the check is reached and an empty B with a different seed hash throws.

Reproducible in C++ alone, no Java involved:

// B: empty, built with a different seed
auto b = update_theta_sketch::builder().set_seed(12345).build().compact();
// A: non-empty, zero retained (every hash exceeded theta)
auto a = update_theta_sketch::builder().set_p(1e-6f).build();
a.update(1); a.update(2); a.update(3);

compact_theta_sketch::deserialize(...);              // ACCEPTED
theta_union::builder().build().update(b);            // ACCEPTED
theta_intersection().update(b);                      // ACCEPTED
theta_a_not_b().compute(a.compact(), b);             // THREW -> B seed hash mismatch

It also breaks interop with datasketches-java, which serializes every empty compact sketch as the constant {1, 3, 3, 0, 0, 0x1E, 0, 0}. EmptyCompactSketch documents that seed hash of 0 as ignored, so every empty sketch arriving from Java trips this path. In a 20 case Java/C++ differential I ran, all 6 cases that produce an empty sketch failed here and now pass.

The fix guards B's check with !b.is_empty(), matching what theta_intersection_base::update already does. A's check is left alone because A is guaranteed non-empty by the early return above it.

Verification: the added test fails on master with B seed hash mismatch and passes with the fix. Full suite green afterwards, 17/17 ctest suites, 20,245,913 assertions in the theta suite. This code is shared with the tuple sketches, and tuple_test passes too.

I used Claude Code to help run the differential harness and prepare this change. I verified the behavior, the fix and the test results myself against actual build and test output.

An empty sketch retains no hashes, so its seed hash carries no
information and must not be validated. Deserialization
(deserialize_v3/deserialize_v4), theta_union_base::update() and
theta_intersection_base::update() all already skip the seed hash check
for empty inputs, and the existing seed mismatch tests record the intent
with the comment "non-empty should not be ignored".

theta_set_difference_base::compute() was the one path that still checked
it. When A is non-empty with zero retained entries, the early return does
not fire, and an empty B whose seed hash differs makes a-not-b throw
"B seed hash mismatch" where union and intersection accept the same pair.

This also breaks Java/C++ interop: datasketches-java serializes every
empty compact sketch as the constant {1,3,3,0,0,0x1E,0,0}, with a seed
hash of 0 that is documented as ignored, so any empty sketch coming from
Java hits this path.

The behavior is reproducible in C++ alone using two different seeds.

Generated-by: Claude Code (Claude Opus 4.8)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@leerho

leerho commented Aug 31, 2026

Copy link
Copy Markdown
Member

C++ should follow the example of Java, which preceded C++ by quite a while. You are correct that the Empty Compact Sketch layout should set the seed hash to all zeros, to be ignored. Look carefully at the Java EmptyCompactSketch as it was designed for backward compatibility as well. If you vouch for that here, then I will approve.

@jaideeppyne

Copy link
Copy Markdown
Contributor Author

Vouched, and I went and read it rather than taking my own word for it.

EmptyCompactSketch says it in the source:

//For backward compatibility, a candidate long must have Flags= compact, read-only,
//  COMPACT-Family=3, SerVer=3, PreLongs=1, and be exactly 8 bytes long. The seedHash is ignored.
// NOTE: The empty and ordered flags may or may not be set
private static final long EMPTY_SKETCH_MASK = 0X00_00_EB_00_00_FF_FF_FFL;
private static final long EMPTY_SKETCH_TEST = 0X00_00_0A_00_00_03_03_01L;
static final byte[] EMPTY_COMPACT_SKETCH_ARR = { 1, 3, 3, 0, 0, 0x1E, 0, 0 };

Two things line up with what you said. The serialized array carries 0, 0 in the seed hash position, so Java writes zeros rather than a computed hash. And the mask has 00_00 in its top two bytes, so those same two bytes are excluded from the recognition test, which is what makes "the seedHash is ignored" structural rather than just a convention. The 0xEB in the flags byte is the other half of the backward compatibility you mentioned, since it lets the empty and ordered bits be either way.

On scope, this PR only does the read half. It stops theta_a_not_b throwing on an empty B, which brings it in line with theta_union_base::update, theta_intersection_base::update and deserialize_v3/v4, all three of which already exempt empty sketches. It does not change what C++ writes: compact_theta_sketch_alloc takes seed_hash_(other.get_seed_hash()), so an empty compact sketch still serializes compute_seed_hash(seed) and that is the remaining difference from Java, 6 of the 20 cases I diffed on #460.

Happy to do the write half too, emitting a zero seed hash for empty compact sketches to match EMPTY_COMPACT_SKETCH_ARR. I did not put it here because it changes bytes on the wire and felt like it deserved its own PR and your call on release timing, rather than being folded into a one line read fix. Say which you prefer and I will do it that way.

@jaideeppyne

Copy link
Copy Markdown
Contributor Author

Write half is up as #518, stacked on this branch.

One thing came out of doing it that is worth flagging here, because it affects the order. On its own the write change is a regression, and this PR is what makes it safe. theta_set_difference_base::compute checks B's seed hash, and its early return only covers an empty B when A has retained entries, so an A that is non-empty with zero retained still reaches the check. Today both sides carry the same computed hash so it passes; once an empty B reports 0 it would throw. On a real sketch:

A: is_empty=0 retained=0 seed_hash=37836
B: is_empty=1            seed_hash=37836
early return taken? no
a_not_b: OK

So #518 wants this one in first.

For the record on the rest of the surface: theta_union_base::update returns early on empty, theta_intersection_base::update already has !sketch.is_empty() &&, and deserialize_v3 has if (!is_empty) check_seed_hash(...), so a zero hash on an empty image reads back cleanly. deserialize_v1/v2 check unconditionally, but nothing writes an empty sketch in those formats.

After #518 an empty compact sketch serializes to 1 3 3 0 0 30 0 0, which is EMPTY_COMPACT_SKETCH_ARR byte for byte.

@leerho leerho left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good Job! And thank you for catching this and this contribution! I really appreciate your thoroughness!

@leerho
leerho merged commit 5e18631 into apache:master Aug 31, 2026
17 checks passed
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.

2 participants