Skip to content

Commit 577f816

Browse files
authored
Validate serialized sketches and preserve capacity in a versioned format (#3)
Stacked on #2 (`modernization/test-runtime`). This completes construction/deserialization validation and capacity preservation from that PR's release checklist. CountMinSketch now writes CMS2 with original capacity and 32-bit UTF-8 key lengths. It still reads legacy bytes and supports explicit legacy export. Legacy imports accept the original capacity when known; the documented fallback uses the stored entry count (at least one). Readers validate exact byte windows, dimensions, allocation budgets, hash metadata, keys, counts and HLL registers before using them. Counter overflow and invalid merges fail before mutation. Validation: 35 tests pass locally, retaining million-item estimator checks and adding legacy byte fixtures, every truncated prefix, malformed dimensions/keys, Unicode, empty/partially full capacity roundtrips and atomic failure cases. Default CMS output and stricter input rules are breaking changes; SERIALIZATION.md documents the migration. The next stacked PR supplies declarations and reproducible package/type validation. This PR is ready for review, not merged or published.
1 parent fb5be62 commit 577f816

10 files changed

Lines changed: 416 additions & 438 deletions

‎CHANGELOG.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,12 @@
1111

1212
### Release compatibility
1313

14-
Runtime minimum becomes Node 6 because Buffer.alloc is now used; test development requires modern Node (CI: 22/24/26). Dropping previously advertised Node 0.6 support requires a major release. No version has been bumped yet. Top-k tie ordering is unspecified and may change. The existing binary format is retained; it does not record the original top-k capacity when a sketch is serialized before filling, so that capacity cannot be fully recovered. Malformed-input/deserialization validation remains a follow-up before a release is considered complete.
14+
Runtime minimum becomes Node 6 because Buffer.alloc is now used; test development requires modern Node (CI: 22/24/26). Dropping previously advertised Node 0.6 support requires a major release. No version has been bumped yet. Top-k tie ordering is unspecified and may change. The follow-up below adds versioned serialization and validates malformed inputs. See SERIALIZATION.md for legacy import/export and the major-release migration.
15+
16+
## Release validation follow-up
17+
18+
- Validate construction probabilities, allocation bounds, keys and exact serialized byte windows; reject malformed data before unbounded allocation.
19+
- Preserve CountMinSketch capacity in CMS2 output, read legacy bytes, and offer explicit legacy capacity/import and legacy export options. See SERIALIZATION.md.
20+
- Reject overflowing counters atomically and validate HLL merges before mutation.
21+
- Fix the signed-minimum hash bucket edge case and retain the existing mapping for all other hashes.
22+
- Add legacy golden fixtures and tests for truncation, malformed metadata, invalid Unicode, capacity preservation and overflow.

‎SERIALIZATION.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Serialization and migration
2+
3+
CountMinSketch writes the versioned **CMS2** format by default. Readers automatically accept the old untagged layout. Integers are unsigned 32-bit little-endian, except legacy key lengths (one byte).
4+
5+
CMS2: magic bytes `CMS2`, original `maxEntries`, log2(width), depth, width, `depth * width` row-major counters, hash count (equal to depth), that many odd hash multipliers, entry count, then each entry's count, UTF-8 byte length and key bytes. Each key length is uint32. Legacy omits the first eight bytes and uses uint8 key lengths.
6+
7+
To write bytes for an old reader, use `sketch.serialize({ legacy: true })`. This rejects keys longer than 255 UTF-8 bytes and cannot preserve capacity. New files cannot be read by old package versions.
8+
9+
Old files do not contain capacity. Import them with `CountMinSketch.deserialize(buffer, start, length, { maxEntries: originalCapacity })` if known. If omitted, legacy capacity defaults to the stored entry count, or one for an empty sketch. A supplied capacity must be at least the stored entry count. Versioned files preserve capacity automatically and reject conflicting overrides.
10+
11+
HyperLogLog retains its layout: uint32 `32 - log2(registerCount)`, float64 scale factor, then uint32 registers. Legacy files with 2/4/8 registers remain readable, although new counters allocate at least 16. Saturation at the limit of the 32-bit hash universe reports Infinity rather than NaN.
12+
13+
Readers honor the exact `(start, length)` byte window; omitted length means the rest of the buffer after start. Trailing or truncated bytes, invalid dimensions, invalid UTF-8, duplicate keys, inconsistent entry counts and impossible registers reject synchronously. Buffers from concatenated streams must be passed with their exact length.
14+
15+
## Bounds
16+
17+
- Probabilities/error rates must be finite numbers strictly between zero and one. Factory defaults apply only when an argument is omitted.
18+
- CMS: 1–1,048,576 capacity; 1–64 rows; power-of-two width from 4–1,048,576; no more than 4,194,304 cells in total.
19+
- HLL: newly constructed register arrays contain 16–1,048,576 registers.
20+
- Keys must be valid Unicode strings, at most 1,048,576 UTF-8 bytes. Empty strings are allowed.
21+
- Serialized buffers are limited to 64 MiB. Limits are checked before data-dependent allocation.
22+
- Counts cannot exceed uint32. An increment that would overflow rejects before changing state.
23+
24+
These validation rules and the default CMS2 output are breaking changes and belong in the next major release. The legacy hash mapping is retained, with the signed-minimum bucket calculation corrected so it cannot produce a negative array index.

‎index.js‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
var HyperLogLog = require('./lib/hyperLogLog');
2+
var valid = require('./lib/validation');
23
var CountMinSketch = require('./lib/countMinSketch');
34

45
exports.createUniquesCounter = createUniquesCounter;
@@ -20,7 +21,7 @@ exports.PRNG = require('./lib/prng');
2021
* tradeoff. 0.01 is the default.
2122
*/
2223
function createUniquesCounter(stdError) {
23-
return new HyperLogLog(stdError || 0.01);
24+
return new HyperLogLog(stdError === undefined ? 0.01 : stdError);
2425
}
2526

2627
/**
@@ -40,7 +41,7 @@ function createUniquesCounter(stdError) {
4041
* tradeoff. 0.0001 is the default.
4142
*/
4243
function createViewsCounter(topEntryCount, errFactor, failRate) {
43-
return new CountMinSketch(topEntryCount, errFactor || 0.002, failRate || 0.0001);
44+
return new CountMinSketch(topEntryCount, errFactor === undefined ? 0.002 : errFactor, failRate === undefined ? 0.0001 : failRate);
4445
}
4546

4647
/**
@@ -50,21 +51,19 @@ function createViewsCounter(topEntryCount, errFactor, failRate) {
5051
* numbers.
5152
*/
5253
function getUniquesObjSize(stdError) {
53-
var acc = 1.04 / stdError;
54-
var k = Math.ceil(Math.log(acc * acc) / Math.LN2);
55-
return 12 + Math.pow(2, k) * 4;
54+
return 12 + valid.hllLayout(stdError === undefined ? 0.01 : stdError) * 4;
5655
}
5756

5857
/**
5958
* Returns the serialized size of a views counter (CountMinSketch) object in
6059
* bytes given an errFactor and failRate. NOTE: This does not include the size
6160
* of the serialized MinHeap which includes the size of each unique ID (up to a
62-
* max of topEntryCount) plus 5 bytes overhead per entry. NOTE2: The memory
61+
* max of topEntryCount) plus 8 bytes overhead per entry. NOTE2: The memory
6362
* usage will be higher than this number since we serialize 32-bit integers but
6463
* JavaScript uses 64-bit numbers.
6564
*/
6665
function getViewsObjSize(errFactor, failRate) {
67-
var depth = Math.max(Math.ceil(Math.log(1.0 / failRate)), 1);
68-
var width = Math.pow(2, Math.ceil(Math.log(Math.ceil(Math.E / errFactor)) / Math.LN2));
69-
return 4 + 8 + depth * width * 4 + 4 + depth * 4 + 4;
66+
var layout = valid.cmsLayout(errFactor === undefined ? 0.002 : errFactor,
67+
failRate === undefined ? 0.0001 : failRate);
68+
return 28 + layout.depth * layout.width * 4 + layout.depth * 4;
7069
}

0 commit comments

Comments
 (0)