Skip to content

Commit e0d3c65

Browse files
Execution Coordinatorclaude
andcommitted
feat: Add HyperLogLog (HLL) support for NIP-45 to NDK core
Implement comprehensive HLL support for cardinality estimation across multiple relays: New Features: - NDKCountHll class with 256 uint8 registers for HLL data - Hex encoding/decoding (512 character strings per NIP-45) - Merge algorithm combining HLLs from multiple relays (max per register) - Cardinality estimation using standard HLL algorithm with small-range correction - NDKCountResult, NDKAggregatedCountResult, and NDKCountOptions interfaces Enhancements: - Updated CountResolver to support HLL field in responses - Added count() method to NDKRelaySet with timeout support - Added count() method to main NDK class for convenient access - Proper aggregation with fallback to max count for relays without HLL support Testing: - Comprehensive test suite (22 tests) covering hex encoding/decoding, merge operations, cardinality estimation, and NIP-45 specification compliance - All tests pass, build passes Code Quality: - Follows NDK naming conventions and patterns - Comprehensive JSDoc documentation with examples - Robust error handling for edge cases - Immutable design for merge operations Co-Authored-By: Claude Code <noreply@anthropic.com> Co-Authored-By: Explore Agent <noreply@anthropic.com>
1 parent f211217 commit e0d3c65

File tree

7 files changed

+618
-7
lines changed

7 files changed

+618
-7
lines changed

bun.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/src/count/index.test.ts

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import { describe, it, expect } from "vitest";
2+
import { NDKCountHll, HLL_REGISTER_COUNT } from "./index.js";
3+
4+
describe("NDKCountHll", () => {
5+
describe("constructor", () => {
6+
it("creates an empty HLL with 256 zero registers", () => {
7+
const hll = new NDKCountHll();
8+
expect(hll.registers.length).toBe(HLL_REGISTER_COUNT);
9+
expect(hll.registers.every((v) => v === 0)).toBe(true);
10+
});
11+
12+
it("creates HLL from provided registers", () => {
13+
const registers = new Uint8Array(HLL_REGISTER_COUNT);
14+
registers[0] = 5;
15+
registers[100] = 10;
16+
registers[255] = 3;
17+
18+
const hll = new NDKCountHll(registers);
19+
expect(hll.registers[0]).toBe(5);
20+
expect(hll.registers[100]).toBe(10);
21+
expect(hll.registers[255]).toBe(3);
22+
});
23+
24+
it("throws error for wrong register count", () => {
25+
const registers = new Uint8Array(100);
26+
expect(() => new NDKCountHll(registers)).toThrow(
27+
`HLL must have exactly ${HLL_REGISTER_COUNT} registers, got 100`,
28+
);
29+
});
30+
});
31+
32+
describe("fromHex", () => {
33+
it("parses valid hex string", () => {
34+
// Create a hex string with known values
35+
// First byte = 0x05, second = 0x0a, rest are zeros
36+
const hex = "050a" + "00".repeat(254);
37+
const hll = NDKCountHll.fromHex(hex);
38+
39+
expect(hll.registers[0]).toBe(5);
40+
expect(hll.registers[1]).toBe(10);
41+
expect(hll.registers[2]).toBe(0);
42+
});
43+
44+
it("throws error for wrong hex length", () => {
45+
expect(() => NDKCountHll.fromHex("0a0b0c")).toThrow(
46+
`HLL hex string must be ${HLL_REGISTER_COUNT * 2} characters`,
47+
);
48+
});
49+
50+
it("parses max value correctly", () => {
51+
const hex = "ff" + "00".repeat(255);
52+
const hll = NDKCountHll.fromHex(hex);
53+
expect(hll.registers[0]).toBe(255);
54+
});
55+
});
56+
57+
describe("toHex", () => {
58+
it("converts to hex string correctly", () => {
59+
const hll = new NDKCountHll();
60+
hll.registers[0] = 5;
61+
hll.registers[1] = 10;
62+
hll.registers[255] = 15;
63+
64+
const hex = hll.toHex();
65+
expect(hex.length).toBe(HLL_REGISTER_COUNT * 2);
66+
expect(hex.substring(0, 4)).toBe("050a");
67+
expect(hex.substring(510, 512)).toBe("0f");
68+
});
69+
70+
it("roundtrips correctly", () => {
71+
const original = new NDKCountHll();
72+
original.registers[0] = 1;
73+
original.registers[50] = 15;
74+
original.registers[100] = 255;
75+
original.registers[200] = 128;
76+
77+
const hex = original.toHex();
78+
const parsed = NDKCountHll.fromHex(hex);
79+
80+
expect(parsed.registers[0]).toBe(1);
81+
expect(parsed.registers[50]).toBe(15);
82+
expect(parsed.registers[100]).toBe(255);
83+
expect(parsed.registers[200]).toBe(128);
84+
});
85+
});
86+
87+
describe("merge", () => {
88+
it("takes maximum value for each register", () => {
89+
const hll1 = new NDKCountHll();
90+
hll1.registers[0] = 5;
91+
hll1.registers[1] = 10;
92+
hll1.registers[2] = 3;
93+
94+
const hll2 = new NDKCountHll();
95+
hll2.registers[0] = 3;
96+
hll2.registers[1] = 15;
97+
hll2.registers[2] = 1;
98+
99+
const merged = hll1.merge(hll2);
100+
101+
expect(merged.registers[0]).toBe(5); // max(5, 3)
102+
expect(merged.registers[1]).toBe(15); // max(10, 15)
103+
expect(merged.registers[2]).toBe(3); // max(3, 1)
104+
});
105+
106+
it("does not modify original HLLs", () => {
107+
const hll1 = new NDKCountHll();
108+
hll1.registers[0] = 5;
109+
110+
const hll2 = new NDKCountHll();
111+
hll2.registers[0] = 10;
112+
113+
hll1.merge(hll2);
114+
115+
expect(hll1.registers[0]).toBe(5);
116+
expect(hll2.registers[0]).toBe(10);
117+
});
118+
});
119+
120+
describe("static merge", () => {
121+
it("returns empty HLL for empty array", () => {
122+
const merged = NDKCountHll.merge([]);
123+
expect(merged.isEmpty()).toBe(true);
124+
});
125+
126+
it("merges multiple HLLs correctly", () => {
127+
const hll1 = new NDKCountHll();
128+
hll1.registers[0] = 5;
129+
130+
const hll2 = new NDKCountHll();
131+
hll2.registers[0] = 10;
132+
hll2.registers[1] = 7;
133+
134+
const hll3 = new NDKCountHll();
135+
hll3.registers[0] = 3;
136+
hll3.registers[1] = 12;
137+
hll3.registers[2] = 4;
138+
139+
const merged = NDKCountHll.merge([hll1, hll2, hll3]);
140+
141+
expect(merged.registers[0]).toBe(10); // max(5, 10, 3)
142+
expect(merged.registers[1]).toBe(12); // max(0, 7, 12)
143+
expect(merged.registers[2]).toBe(4); // max(0, 0, 4)
144+
});
145+
});
146+
147+
describe("estimate", () => {
148+
it("returns 0 for empty HLL", () => {
149+
const hll = new NDKCountHll();
150+
// Empty HLL should return 0 (uses linear counting for small cardinalities)
151+
expect(hll.estimate()).toBe(0);
152+
});
153+
154+
it("estimates small cardinality", () => {
155+
// Simulate a small number of items by setting some registers
156+
const hll = new NDKCountHll();
157+
// Set a few registers to simulate seeing a few unique items
158+
for (let i = 0; i < 10; i++) {
159+
hll.registers[i] = 1;
160+
}
161+
162+
const estimate = hll.estimate();
163+
// Estimate should be in a reasonable range for small cardinality
164+
expect(estimate).toBeGreaterThan(0);
165+
expect(estimate).toBeLessThan(50);
166+
});
167+
168+
it("estimates larger cardinality", () => {
169+
const hll = new NDKCountHll();
170+
// Simulate more items by setting more registers with higher values
171+
for (let i = 0; i < 256; i++) {
172+
hll.registers[i] = Math.floor(Math.random() * 5) + 1;
173+
}
174+
175+
const estimate = hll.estimate();
176+
// For HLL with 256 registers and values 1-5, estimate should be reasonable
177+
expect(estimate).toBeGreaterThan(100);
178+
});
179+
180+
it("merged HLLs give reasonable estimate", () => {
181+
// Create two HLLs representing overlapping sets
182+
const hll1 = new NDKCountHll();
183+
const hll2 = new NDKCountHll();
184+
185+
// Simulate some overlap by setting similar patterns
186+
for (let i = 0; i < 128; i++) {
187+
hll1.registers[i] = 2;
188+
}
189+
for (let i = 64; i < 192; i++) {
190+
hll2.registers[i] = 3;
191+
}
192+
193+
const merged = hll1.merge(hll2);
194+
const estimate = merged.estimate();
195+
196+
// The merged estimate should be at least as large as the individual estimates
197+
expect(estimate).toBeGreaterThan(0);
198+
});
199+
});
200+
201+
describe("isEmpty", () => {
202+
it("returns true for empty HLL", () => {
203+
const hll = new NDKCountHll();
204+
expect(hll.isEmpty()).toBe(true);
205+
});
206+
207+
it("returns false for non-empty HLL", () => {
208+
const hll = new NDKCountHll();
209+
hll.registers[100] = 1;
210+
expect(hll.isEmpty()).toBe(false);
211+
});
212+
});
213+
214+
describe("clone", () => {
215+
it("creates independent copy", () => {
216+
const original = new NDKCountHll();
217+
original.registers[0] = 5;
218+
219+
const cloned = original.clone();
220+
221+
// Modify the clone
222+
cloned.registers[0] = 10;
223+
224+
// Original should be unchanged
225+
expect(original.registers[0]).toBe(5);
226+
expect(cloned.registers[0]).toBe(10);
227+
});
228+
});
229+
});
230+
231+
describe("NIP-45 HLL specification compliance", () => {
232+
it("uses 256 registers as specified", () => {
233+
expect(HLL_REGISTER_COUNT).toBe(256);
234+
});
235+
236+
it("hex string is 512 characters (256 bytes)", () => {
237+
const hll = new NDKCountHll();
238+
const hex = hll.toHex();
239+
expect(hex.length).toBe(512);
240+
});
241+
242+
it("registers are uint8 values (0-255)", () => {
243+
const hex = "ff".repeat(256);
244+
const hll = NDKCountHll.fromHex(hex);
245+
expect(hll.registers.every((v) => v === 255)).toBe(true);
246+
});
247+
});

0 commit comments

Comments
 (0)