From 74bcbbb252971d8611204b3ae10921d974681988 Mon Sep 17 00:00:00 2001 From: Matthew Lorimor Date: Wed, 19 Aug 2026 22:47:07 -0400 Subject: [PATCH] Sweep every span query, mutating ones included TestSpanAndArrayQueriesAgree reached 5 of the 20 structures offering a span query. It now reaches all 19 with a pure one, and a second sweep covers the 13 whose span query also changes the structure. The mutating half needed a different shape. Agreeing on every answer is only half of it: two paths can return the same value at every step and leave the structure holding different things, and a filter that answers correctly while holding the wrong thing does not fail at the seam -- it fails later, somewhere else, for no visible reason. Each pair is driven step for step through its own instance and then compared through its payload, the same oracle the equivalence sweep uses. Two mutators answer with the structure itself, so there is no value to compare and the state is the whole check. InvertibleBloomLookupTable needs the fixed-width helper, since it refuses a key of any other length. Both sweeps are checked against rosters derived from the span surface, split by whether the method mutates. A structure that gains a span query fails these until it is covered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JH2NRDbhF5bwAsTAP7znb9 --- .../StructureRoster.cs | 33 ++ .../TestSpanOverloads.cs | 287 ++++++++++++++++-- 2 files changed, 292 insertions(+), 28 deletions(-) diff --git a/TestProbabilisticDataStructures/StructureRoster.cs b/TestProbabilisticDataStructures/StructureRoster.cs index 4298c71..f7a8a38 100644 --- a/TestProbabilisticDataStructures/StructureRoster.cs +++ b/TestProbabilisticDataStructures/StructureRoster.cs @@ -64,6 +64,39 @@ internal static class StructureRoster .OrderBy(t => t.Name, StringComparer.Ordinal) .ToArray(); + /// + /// Every public structure offering a span query that answers without changing + /// anything: Test, Count, TryGetValue. + /// + internal static IReadOnlyList WithPureSpanQueries { get; } = + SpanMethodsWhere(name => name != "Add" && !Mutates(name)); + + /// + /// Every public structure offering a span query that answers *and* changes the + /// structure: TestAndAdd, TestAndRemove, Remove. + /// + /// + /// These need a different check from the pure ones. Two overloads returning the + /// same answer is only half of it -- an array path and a span path can agree on + /// every answer and still leave the structure in different states, and a filter + /// that answers correctly while holding the wrong thing fails later, somewhere + /// else, for no visible reason. + /// + internal static IReadOnlyList WithMutatingSpanQueries { get; } = + SpanMethodsWhere(Mutates); + + private static bool Mutates(string method) => + method is "TestAndAdd" or "TestAndRemove" or "Remove"; + + private static IReadOnlyList SpanMethodsWhere(Func wanted) => + Library.GetTypes() + .Where(t => t.IsPublic && !t.IsAbstract && IsAStructure(t)) + .Where(t => t.GetMethods(BindingFlags.Public | BindingFlags.Instance) + .Any(m => wanted(m.Name) && m.GetParameters() + .Any(param => param.ParameterType == typeof(ReadOnlySpan)))) + .OrderBy(t => t.Name, StringComparer.Ordinal) + .ToArray(); + /// /// Every public structure that can be handed a hash as it is built, whether /// through a constructor or a static factory. This is a wider set than diff --git a/TestProbabilisticDataStructures/TestSpanOverloads.cs b/TestProbabilisticDataStructures/TestSpanOverloads.cs index 58196bf..ea9b1cc 100644 --- a/TestProbabilisticDataStructures/TestSpanOverloads.cs +++ b/TestProbabilisticDataStructures/TestSpanOverloads.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Text; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -231,55 +232,285 @@ public void TestSpanAndArrayPathsLeaveIdenticalState() } /// - /// Queries must answer identically through either overload, sliced. + /// A span query, as a delegate. A span cannot travel through , so the sweeps below need their own delegate types to hold one. + /// + private delegate object SpanQuery(T structure, ReadOnlySpan data); + + private delegate void SpanMutation(T structure, ReadOnlySpan data); + + /// + /// Queries that answer without changing anything must answer identically + /// through either overload, sliced. /// [TestMethod] public void TestSpanAndArrayQueriesAgree() { - var (buffer, slices) = PackedKeys(); + var bloom = Filled(new BloomFilter(1000, 0.01), (f, k) => f.Add(k)); + var bloom64 = Filled(new BloomFilter64(1000, 0.01), (f, k) => f.Add(k)); + var counting = Filled(new CountingBloomFilter(1000, 4, 0.01), (f, k) => f.Add(k)); + var deletable = Filled(new DeletableBloomFilter(1000, 100, 0.01), (f, k) => f.Add(k)); + var partitioned = Filled(new PartitionedBloomFilter(1000, 0.01), (f, k) => f.Add(k)); + var scalable = Filled(new ScalableBloomFilter(100, 0.01, 0.8), (f, k) => f.Add(k)); + var stable = Filled(new StableBloomFilter(1000, 4, 0.01, seed: 5), (f, k) => f.Add(k)); + var inverse = Filled(new InverseBloomFilter(500), (f, k) => f.Add(k)); + var cuckoo = Filled(new CuckooBloomFilter(1000, 0.01, seed: 9), (f, k) => f.Add(k)); + var quotient = Filled(new QuotientFilter(1000, 0.01), (f, k) => f.Add(k)); + var infini = Filled(new InfiniFilter(64, 8), (f, k) => f.Add(k)); + var cms = Filled(new CountMinSketch(0.001, 0.01), (s, k) => s.Add(k)); + var countSketch = Filled(new CountSketch(0.01, 0.01), (s, k) => s.Add(k, 3)); + var keeper = Filled(new HeavyKeeper(20, 512, seed: 3), (s, k) => s.Add(k)); + var sublime = Filled(new SublimeCountMinSketch(0.01, 0.5, 1.0), (s, k) => s.Add(k)); + var priv = Filled(new PrivateCountMinSketch(64, 4, 1.0, seed: 3), (s, k) => s.Add(k)); + var dpsw = Filled( + new DpswSketch(window: 128, rho: 4.0, alpha: 0.6, width: 8, depth: 2, seed: 3), + (s, k) => s.Add(k)); + + var half = Enumerable.Range(0, Items).Where(i => i % 2 == 0).ToArray(); + var fuse = BinaryFuseFilter.Build(half.Select(KeyAt)); + var bloomier = BloomierFilter.Build( + // Eight value bits, so the values have to stay under 256. + half.Select(i => new KeyValuePair(KeyAt(i), (ulong)(i % 200))), 8); - var bloom = new BloomFilter(1000, 0.01); - var cms = new CountMinSketch(0.001, 0.01); - var cs = new CountSketch(0.01, 0.01); - var qf = new QuotientFilter(1000, 0.01); - var inverse = new InverseBloomFilter(500); + var covered = new[] + { + AssertQueryAgrees("BloomFilter.Test", bloom, + (f, k) => f.Test(k), (f, s) => f.Test(s)), + AssertQueryAgrees("BloomFilter64.Test", bloom64, + (f, k) => f.Test(k), (f, s) => f.Test(s)), + AssertQueryAgrees("CountingBloomFilter.Test", counting, + (f, k) => f.Test(k), (f, s) => f.Test(s)), + AssertQueryAgrees("DeletableBloomFilter.Test", deletable, + (f, k) => f.Test(k), (f, s) => f.Test(s)), + AssertQueryAgrees("PartitionedBloomFilter.Test", partitioned, + (f, k) => f.Test(k), (f, s) => f.Test(s)), + AssertQueryAgrees("ScalableBloomFilter.Test", scalable, + (f, k) => f.Test(k), (f, s) => f.Test(s)), + AssertQueryAgrees("StableBloomFilter.Test", stable, + (f, k) => f.Test(k), (f, s) => f.Test(s)), + AssertQueryAgrees("InverseBloomFilter.Test", inverse, + (f, k) => f.Test(k), (f, s) => f.Test(s)), + AssertQueryAgrees("CuckooBloomFilter.Test", cuckoo, + (f, k) => f.Test(k), (f, s) => f.Test(s)), + AssertQueryAgrees("QuotientFilter.Test", quotient, + (f, k) => f.Test(k), (f, s) => f.Test(s)), + AssertQueryAgrees("InfiniFilter.Test", infini, + (f, k) => f.Test(k), (f, s) => f.Test(s)), + AssertQueryAgrees("BinaryFuseFilter.Test", fuse, + (f, k) => f.Test(k), (f, s) => f.Test(s)), + + AssertQueryAgrees("CountMinSketch.Count", cms, + (s, k) => s.Count(k), (s, p) => s.Count(p)), + AssertQueryAgrees("CountSketch.Count", countSketch, + (s, k) => s.Count(k), (s, p) => s.Count(p)), + AssertQueryAgrees("HeavyKeeper.Count", keeper, + (s, k) => s.Count(k), (s, p) => s.Count(p)), + AssertQueryAgrees("SublimeCountMinSketch.Count", sublime, + (s, k) => s.Count(k), (s, p) => s.Count(p)), + + // The noisy pair need no special treatment, which is worth saying + // because it looks as though they should. Their noise is drawn once at + // construction and lives in the counters -- that is what stops repeated + // queries from averaging it away -- so a query is an ordinary read and + // two calls must return the identical double, not merely a close one. + AssertQueryAgrees("PrivateCountMinSketch.Count", priv, + (s, k) => s.Count(k), (s, p) => s.Count(p)), + AssertQueryAgrees("DpswSketch.Count", dpsw, + (s, k) => s.Count(k), (s, p) => s.Count(p)), + + AssertQueryAgrees("BloomierFilter.TryGetValue", bloomier, + (f, k) => { var ok = f.TryGetValue(k, out var v); return (ok, v); }, + (f, s) => { var ok = f.TryGetValue(s, out var v); return (ok, v); }), + }; + + StructureRoster.AssertCoversEveryType( + "pure span queries", StructureRoster.WithPureSpanQueries, covered); + } + + /// + /// Queries that answer *and* change the structure must do both identically. + /// + /// + /// Agreeing on every answer is only half of this. Two paths can return the same + /// value at every step and leave the structure holding different things, and a + /// filter that answers correctly while holding the wrong thing does not fail + /// here -- it fails later, somewhere else, for no visible reason. So each pair + /// is driven step for step and then compared through its payload, the same + /// oracle the equivalence sweep uses. + /// + [TestMethod] + public void TestSpanAndArrayMutatingQueriesAgree() + { + var covered = new[] + { + AssertMutatingQueryAgrees("BloomFilter.TestAndAdd", + () => new BloomFilter(1000, 0.01), + (f, k) => f.TestAndAdd(k), (f, s) => f.TestAndAdd(s)), + AssertMutatingQueryAgrees("BloomFilter64.TestAndAdd", + () => new BloomFilter64(1000, 0.01), + (f, k) => f.TestAndAdd(k), (f, s) => f.TestAndAdd(s)), + AssertMutatingQueryAgrees("PartitionedBloomFilter.TestAndAdd", + () => new PartitionedBloomFilter(1000, 0.01), + (f, k) => f.TestAndAdd(k), (f, s) => f.TestAndAdd(s)), + AssertMutatingQueryAgrees("ScalableBloomFilter.TestAndAdd", + () => new ScalableBloomFilter(100, 0.01, 0.8), + (f, k) => f.TestAndAdd(k), (f, s) => f.TestAndAdd(s)), + AssertMutatingQueryAgrees("InverseBloomFilter.TestAndAdd", + () => new InverseBloomFilter(500), + (f, k) => f.TestAndAdd(k), (f, s) => f.TestAndAdd(s)), + AssertMutatingQueryAgrees("StableBloomFilter.TestAndAdd", + () => new StableBloomFilter(1000, 4, 0.01, seed: 5), + (f, k) => f.TestAndAdd(k), (f, s) => f.TestAndAdd(s)), + + AssertMutatingQueryAgrees("CountingBloomFilter.TestAndAdd", + () => new CountingBloomFilter(1000, 4, 0.01), + (f, k) => f.TestAndAdd(k), (f, s) => f.TestAndAdd(s)), + AssertMutatingQueryAgrees("CountingBloomFilter.TestAndRemove", + () => Filled(new CountingBloomFilter(1000, 4, 0.01), (f, k) => f.Add(k)), + (f, k) => f.TestAndRemove(k), (f, s) => f.TestAndRemove(s)), + + AssertMutatingQueryAgrees("DeletableBloomFilter.TestAndAdd", + () => new DeletableBloomFilter(1000, 100, 0.01), + (f, k) => f.TestAndAdd(k), (f, s) => f.TestAndAdd(s)), + AssertMutatingQueryAgrees("DeletableBloomFilter.TestAndRemove", + () => Filled(new DeletableBloomFilter(1000, 100, 0.01), (f, k) => f.Add(k)), + (f, k) => f.TestAndRemove(k), (f, s) => f.TestAndRemove(s)), + + // Its TestAndAdd answers with a pair -- whether the key was there, and + // whether room was found for it -- so both halves are compared. + AssertMutatingQueryAgrees("CuckooBloomFilter.TestAndAdd", + () => new CuckooBloomFilter(1000, 0.01, seed: 9), + (f, k) => f.TestAndAdd(k), (f, s) => f.TestAndAdd(s)), + AssertMutatingQueryAgrees("CuckooBloomFilter.TestAndRemove", + () => Filled(new CuckooBloomFilter(1000, 0.01, seed: 9), (f, k) => f.Add(k)), + (f, k) => f.TestAndRemove(k), (f, s) => f.TestAndRemove(s)), + + AssertMutatingQueryAgrees("QuotientFilter.TestAndRemove", + () => Filled(new QuotientFilter(1000, 0.01), (f, k) => f.Add(k)), + (f, k) => f.TestAndRemove(k), (f, s) => f.TestAndRemove(s)), + AssertMutatingQueryAgrees("InfiniFilter.TestAndRemove", + () => Filled(new InfiniFilter(64, 8), (f, k) => f.Add(k)), + (f, k) => f.TestAndRemove(k), (f, s) => f.TestAndRemove(s)), + + // These two answer with themselves, so there is no value to compare and + // the state is the whole of it. + AssertMutationAgrees("SublimeCountMinSketch.Remove", + () => Filled(new SublimeCountMinSketch(0.01, 0.5, 1.0), (s, k) => s.Add(k)), + (s, k) => s.Remove(k), (s, p) => s.Remove(p)), + AssertFixedWidthMutationAgrees("InvertibleBloomLookupTable.Remove", 8, + () => new InvertibleBloomLookupTable(64, 8), + (t, k) => t.Remove(k), (t, p) => t.Remove(p)), + }; + + StructureRoster.AssertCoversEveryType( + "mutating span queries", StructureRoster.WithMutatingSpanQueries, covered); + } + + /// Adds every key to a structure and hands it back. + private static T Filled(T structure, Action add) + { for (int i = 0; i < Items; i += 2) { - var k = KeyAt(i); - bloom.Add(k); cms.Add(k); cs.Add(k); qf.Add(k); inverse.Add(k); + add(structure, KeyAt(i)); } + return structure; + } + + /// + /// Asks one structure the same question through both overloads and requires the + /// same answer. Safe on one instance because the query changes nothing. + /// + private static Type AssertQueryAgrees( + string name, T structure, Func viaArray, SpanQuery viaSpan) + { + var (buffer, slices) = PackedKeys(); for (int i = 0; i < Items; i++) { - var k = KeyAt(i); - var span = buffer.AsSpan(slices[i].Offset, slices[i].Length); + Assert.AreEqual( + viaArray(structure, KeyAt(i)), + viaSpan(structure, buffer.AsSpan(slices[i].Offset, slices[i].Length)), + $"{name} answered differently for key {i} as a span than as an array"); + } - Assert.AreEqual(bloom.Test(k), bloom.Test(span), $"BloomFilter.Test at {i}"); - Assert.AreEqual(cms.Count(k), cms.Count(span), $"CountMinSketch.Count at {i}"); - Assert.AreEqual(cs.Count(k), cs.Count(span), $"CountSketch.Count at {i}"); - Assert.AreEqual(qf.Test(k), qf.Test(span), $"QuotientFilter.Test at {i}"); - Assert.AreEqual(inverse.Test(k), inverse.Test(span), $"InverseBloomFilter.Test at {i}"); + return typeof(T); + } + + /// + /// Drives two structures the same way, one through each overload, and requires + /// both the answers and the states they end in to match. + /// + private static Type AssertMutatingQueryAgrees( + string name, Func create, Func viaArray, SpanQuery viaSpan) + where T : IBinaryPersistable + { + var (buffer, slices) = PackedKeys(); + var arrayDriven = create(); + var spanDriven = create(); + + for (int i = 0; i < Items; i++) + { + Assert.AreEqual( + viaArray(arrayDriven, KeyAt(i)), + viaSpan(spanDriven, buffer.AsSpan(slices[i].Offset, slices[i].Length)), + $"{name} answered differently for key {i} as a span than as an array"); } - var fuse = BinaryFuseFilter.Build( - Enumerable.Range(0, Items).Where(i => i % 2 == 0).Select(KeyAt).ToArray()); + CollectionAssert.AreEqual(arrayDriven.ToByteArray(), spanDriven.ToByteArray(), + $"{name}: the two paths agreed on every answer and still left the " + + "structure holding different things."); + + return typeof(T); + } + + /// + /// The same, for a mutator that answers with the structure itself. There is no + /// value to compare, so the state is the whole of the check. + /// + private static Type AssertMutationAgrees( + string name, Func create, Action viaArray, SpanMutation viaSpan) + where T : IBinaryPersistable + { + var (buffer, slices) = PackedKeys(); + var arrayDriven = create(); + var spanDriven = create(); + for (int i = 0; i < Items; i++) { - Assert.AreEqual(fuse.Test(KeyAt(i)), - fuse.Test(buffer.AsSpan(slices[i].Offset, slices[i].Length)), - $"BinaryFuseFilter.Test at {i}"); + viaArray(arrayDriven, KeyAt(i)); + viaSpan(spanDriven, buffer.AsSpan(slices[i].Offset, slices[i].Length)); } - var bloomier = BloomierFilter.Build( - Enumerable.Range(0, Items).ToDictionary(KeyAt, i => (ulong)(i % 200)), 8); + CollectionAssert.AreEqual(arrayDriven.ToByteArray(), spanDriven.ToByteArray(), + $"{name}: driving the two overloads the same way left different states."); + + return typeof(T); + } + + /// The same again, for a structure that will only take a fixed width. + private static Type AssertFixedWidthMutationAgrees( + string name, int keySize, Func create, + Action viaArray, SpanMutation viaSpan) + where T : IBinaryPersistable + { + var buffer = new byte[Items * keySize]; for (int i = 0; i < Items; i++) { - var found = bloomier.TryGetValue(KeyAt(i), out var fromArray); - var foundSpan = bloomier.TryGetValue( - buffer.AsSpan(slices[i].Offset, slices[i].Length), out var fromSpan); - Assert.AreEqual(found, foundSpan, $"BloomierFilter.TryGetValue at {i}"); - Assert.AreEqual(fromArray, fromSpan, $"BloomierFilter value at {i}"); + BitConverter.TryWriteBytes(buffer.AsSpan(i * keySize, keySize), (long)i * 2654435761L); } + + var arrayDriven = create(); + var spanDriven = create(); + for (int i = 0; i < Items; i++) + { + viaArray(arrayDriven, buffer.AsSpan(i * keySize, keySize).ToArray()); + viaSpan(spanDriven, buffer.AsSpan(i * keySize, keySize)); + } + + CollectionAssert.AreEqual(arrayDriven.ToByteArray(), spanDriven.ToByteArray(), + $"{name}: driving the two overloads the same way left different states."); + + return typeof(T); } ///