From bed5cd240bdcaa89786321dc346e9d37f0f8e2a0 Mon Sep 17 00:00:00 2001 From: jaideeppyne Date: Sun, 30 Aug 2026 10:35:11 +0530 Subject: [PATCH] Ignore the seed hash of an empty B in theta/tuple a-not-b 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 --- .../theta_set_difference_base_impl.hpp | 4 +++- theta/test/theta_a_not_b_test.cpp | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/theta/include/theta_set_difference_base_impl.hpp b/theta/include/theta_set_difference_base_impl.hpp index 40f94a2f..5ba3e15b 100644 --- a/theta/include/theta_set_difference_base_impl.hpp +++ b/theta/include/theta_set_difference_base_impl.hpp @@ -39,7 +39,9 @@ template CS theta_set_difference_base::compute(FwdSketch&& a, const Sketch& b, bool ordered) const { if (a.is_empty() || (a.get_num_retained() > 0 && b.is_empty())) return CS(a, ordered); if (a.get_seed_hash() != seed_hash_) throw std::invalid_argument("A seed hash mismatch"); - if (b.get_seed_hash() != seed_hash_) throw std::invalid_argument("B seed hash mismatch"); + // an empty sketch has no hashes, so its seed hash is meaningless and must be ignored, + // consistent with deserialization, theta_union::update() and theta_intersection::update() + if (!b.is_empty() && b.get_seed_hash() != seed_hash_) throw std::invalid_argument("B seed hash mismatch"); const uint64_t theta = std::min(a.get_theta64(), b.get_theta64()); std::vector entries(allocator_); diff --git a/theta/test/theta_a_not_b_test.cpp b/theta/test/theta_a_not_b_test.cpp index 75a1f79c..3fe27058 100644 --- a/theta/test/theta_a_not_b_test.cpp +++ b/theta/test/theta_a_not_b_test.cpp @@ -241,6 +241,28 @@ TEST_CASE("theta a-not-b: seed mismatch", "[theta_a_not_b]") { REQUIRE_THROWS_AS(a_not_b.compute(sketch, sketch), std::invalid_argument); } +TEST_CASE("theta a-not-b: empty B with different seed must be ignored", "[theta_a_not_b]") { + // An empty sketch carries no hashes, so its seed hash is meaningless. Deserialization, + // theta_union::update() and theta_intersection::update() all skip the seed hash check + // for empty inputs; a-not-b must do the same. This matters across languages because + // datasketches-java serializes every empty compact sketch with a seed hash of 0. + + // A: non-empty, but with zero retained entries (all hashes exceeded theta) + update_theta_sketch a = update_theta_sketch::builder().set_p(1e-6f).build(); + a.update(1); + REQUIRE_FALSE(a.is_empty()); + REQUIRE(a.get_num_retained() == 0); + + // B: empty, built with a different seed + compact_theta_sketch b = update_theta_sketch::builder().set_seed(123).build().compact(); + REQUIRE(b.is_empty()); + + theta_a_not_b a_not_b; // default seed + compact_theta_sketch result = a_not_b.compute(a.compact(), b); + REQUIRE_FALSE(result.is_empty()); + REQUIRE(result.get_num_retained() == 0); +} + TEST_CASE("theta a-not-b: issue #152", "[theta_a_not_b]") { update_theta_sketch a = update_theta_sketch::builder().build(); int value = 0;