Initial Commit
This commit is contained in:
61
packages/foundation/bitpacker/bitpacker-delta/README.md
Normal file
61
packages/foundation/bitpacker/bitpacker-delta/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# @suaveplan/bitpacker-delta - Bitpacker delta plugin
|
||||
|
||||
## Summary
|
||||
|
||||
Delta encoding utilities for efficient repeated state transmission. Detects changes between successive values and encodes differences rather than full values.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
||||
- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
|
||||
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
|
||||
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
|
||||
<!-- end:auto-docs --> - [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
|
||||
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
|
||||
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
|
||||
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
|
||||
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
|
||||
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
|
||||
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
|
||||
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
|
||||
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
|
||||
- [delta-manager](./src/delta-manager/delta-manager.md) — <!-- begin:auto-docs --> _(no submodules)_- [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_ - [delta-state](./src/delta-state/delta-state.md) — <!-- begin:auto-docs --> _(no submodules)_- [index](./src/index.md) — **Path:** `bitpacker-delta/src/index.ts` **Export:** See source for details.
|
||||
- [string-diff](./src/string-diff.md) — **Path:** `src/string-diff.ts` **Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUse…
|
||||
- [xor-encoder](./src/xor-encoder.md) — **Path:** `src/xor-encoder.ts` **Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficie…
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
bun add @suaveplan/bitpacker-delta
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { DeltaManager, calculateStringDiff, applyStringDiff, numberXor } from "@suaveplan/bitpacker-delta";
|
||||
|
||||
// Change detection
|
||||
const dm = new DeltaManager();
|
||||
dm.detectChanges({ x: 1, y: 2 }); // returns { x: 1, y: 2 }
|
||||
dm.detectChanges({ x: 1, y: 2 }); // returns undefined (no change)
|
||||
dm.detectChanges({ x: 1, y: 3 }); // returns { x: 1, y: 3 }
|
||||
|
||||
// String diff
|
||||
const diff = calculateStringDiff("hello world", "hello there");
|
||||
// { prefixLen: 6, suffixLen: 0, middleLen: 5, middle: "there" }
|
||||
const reconstructed = applyStringDiff("hello world", diff);
|
||||
// "hello there"
|
||||
|
||||
// Numeric XOR delta
|
||||
const delta = numberXor(3.14159, 2.71828);
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
- `src/index.ts` — `DeltaManager`, change detection API
|
||||
- `src/xor-encoder.ts` — XOR-based numeric delta encoding
|
||||
- `src/string-diff.ts` — Common prefix/suffix string diff encoding
|
||||
48
packages/foundation/bitpacker/bitpacker-delta/package.json
Normal file
48
packages/foundation/bitpacker/bitpacker-delta/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "@suaveplan/bitpacker-delta",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Bitpacker delta plugin",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@suaveplan/bitpacker": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/types": "workspace:^",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
# DeltaManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DeltaManager` is the field-level delta detector for bit-packed objects. Given a current state and a `BitPackerSchema` describing it, it returns the ordered list of fields whose value has changed since the previous snapshot stored under the same state key. Encoders use the returned `FieldChange[]` to build a fixed-width bitmask and send only the changed fields, dramatically reducing bandwidth for high-frequency state-sync workloads (game replication, multi-peer document sync, telemetry deltas).
|
||||
|
||||
This is the port of suaveplan's upstream `core/delta/delta-manager.ts`. hardening replaces upstream's `Date.now()` with injectable `Clock` (CLAUDE.md determinism rule), upstream's `JSON.stringify`-based equality with a structural walker that respects `BitPackerSchema` field order (avoiding subtle false-positive diffs from key-order changes in nested objects), upstream's plain `Error` throws with `BitpackerError` codes, and validates `DeltaConfig` via Zod with strict-mode key rejection.
|
||||
|
||||
## Features
|
||||
|
||||
- Field-indexed `FieldChange[]` output matching `Object.keys(schema)` order for stable bit offsets
|
||||
- Per-state-key snapshot store with LRU eviction (delegates to `DeltaStateManager`)
|
||||
- Configurable `maxFieldCount` cap with `BITPACKER.DELTA_FIELD_LIMIT_EXCEEDED` enforcement
|
||||
- Structural equality default (matches schema semantics) with opt-in `equalityMode: "json"` for upstream byte-compat
|
||||
- Zod-validated `DeltaConfig`; unknown keys rejected with `BITPACKER.DELTA_INVALID_CONFIG`
|
||||
- Threshold-driven `shouldUseDelta(originalBits, deltaBits)` for bandwidth-savings decisions
|
||||
- Zero `Math.random` / `Date.now` in the implementation — fully deterministic given identical inputs
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { DeltaManager } from "@suaveplan/bitpacker-delta";
|
||||
|
||||
const schema = { x: "u32", y: "u32" } as const;
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
|
||||
// First call: no previous state, so no changes.
|
||||
dm.detectChanges({ x: 0, y: 0 }, schema); // → []
|
||||
dm.storePreviousState({ x: 0, y: 0 });
|
||||
|
||||
// Second call: x changed.
|
||||
dm.detectChanges({ x: 5, y: 0 }, schema);
|
||||
// → [{ index: 0, key: "x", previousValue: 0, currentValue: 5 }]
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { DeltaManager } from "@suaveplan/bitpacker-delta";
|
||||
|
||||
const schema = {
|
||||
pos: { x: "f32", y: "f32" },
|
||||
vel: { x: "f32", y: "f32" },
|
||||
health: "u8",
|
||||
} as const;
|
||||
|
||||
// Per-peer state streams: one DeltaManager, many state keys.
|
||||
const dm = new DeltaManager(
|
||||
{ mode: "field-level", threshold: 25, maxFieldCount: 16 },
|
||||
{ maxStates: 256 } // 256 simultaneous peers
|
||||
);
|
||||
|
||||
for (const peer of peers) {
|
||||
// Each peer is its own state stream.
|
||||
const previous = dm.getPreviousState(peer.id);
|
||||
const changes = dm.detectChanges(peer.state, schema, peer.id);
|
||||
|
||||
// Only send a delta if it saves at least 25% of bits.
|
||||
const fullSize = bitWidthOf(schema);
|
||||
const deltaSize = changes.length * 32 + Math.ceil(3 / 8) * 8;
|
||||
if (dm.shouldUseDelta(fullSize, deltaSize)) {
|
||||
sendDelta(peer.id, changes);
|
||||
} else {
|
||||
sendFull(peer.id, peer.state);
|
||||
}
|
||||
|
||||
dm.storePreviousState(peer.state, peer.id);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new DeltaManager(config?: DeltaConfig, options?: DeltaManagerOptions)`
|
||||
|
||||
`config.mode` ∈ `"none" | "xor" | "string-diff" | "field-level"`. Default `"none"`.
|
||||
`config.threshold` — bandwidth-savings %, 0–100. Default `0`.
|
||||
`config.maxFieldCount` — max fields the bitmask can address. Default `64`.
|
||||
`config.stateKey` — default key for `detectChanges` calls without one. Default `"default"`.
|
||||
`config.equalityMode` — `"structural"` (default) or `"json"` (upstream-compat).
|
||||
`options.maxStates` — capacity of the underlying state store. Default `100`.
|
||||
|
||||
Throws `BitpackerError(BITPACKER.DELTA_INVALID_CONFIG)` on invalid config.
|
||||
|
||||
### `detectChanges(current, schema, stateKey?) → FieldChange[]`
|
||||
|
||||
Returns the ordered list of fields whose `current` value differs from the stored snapshot under `stateKey`. First call (no snapshot) returns `[]`. Schema with more keys than `maxFieldCount` throws `BITPACKER.DELTA_FIELD_LIMIT_EXCEEDED`.
|
||||
|
||||
### `storePreviousState(state, stateKey?) → void`
|
||||
|
||||
Stores a deep-cloned snapshot of `state` for the next diff.
|
||||
|
||||
### `getPreviousState(stateKey?) → unknown | undefined`
|
||||
|
||||
### `hasPreviousState(stateKey?) → boolean`
|
||||
|
||||
Snapshot inspection helpers.
|
||||
|
||||
### `calculateSavings(originalBits, deltaBits) → number`
|
||||
|
||||
### `shouldUseDelta(originalBits, deltaBits) → boolean`
|
||||
|
||||
Bandwidth-savings calculation against the configured `threshold`.
|
||||
|
||||
### `clear() → void`
|
||||
|
||||
### `clearState(stateKey) → void`
|
||||
|
||||
### `getConfig() → Readonly<Required<DeltaConfig>>`
|
||||
|
||||
Lifecycle and inspection helpers.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The structural-equality walker treats every `ArrayBuffer.isView` value (every typed-array variant) as a byte-by-byte comparison. `Date` instances compare via `getTime()`. Plain objects compare key-sets first (size + key membership), then recurse on values. Arrays compare length-then-elements. This matches `BitPackerSchema` semantics: two values that pack to identical bytes are equal.
|
||||
|
||||
The `"json"` equality mode is provided for upstream byte-compat but has known limitations: `JSON.stringify({ a: 1, b: 2 })` and `JSON.stringify({ b: 2, a: 1 })` produce different strings even though the values are semantically equal in a `BitPackerSchema` context. Consumers porting from upstream should switch to `"structural"` once they've verified no callers depend on key-order-sensitive equality.
|
||||
|
||||
The `maxFieldCount` cap is a defence: a hostile peer that sends a schema with thousands of fields could exhaust memory if the bitmask were unbounded. The default (64) accommodates typical state-sync workloads; consumers with larger schemas raise it explicitly.
|
||||
|
||||
Threshold semantics: `shouldUseDelta` returns `true` when `(originalBits - deltaBits) / originalBits * 100 >= threshold`. A `threshold` of `0` means "always prefer delta when smaller"; a `threshold` of `25` means "only prefer delta if it saves at least a quarter of the bits."
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Spec-conformance tests for DeltaManager. These tests pin the behaviours
|
||||
* documented in `openspec/changes/bitpacker-stub-completion/specs/bitpacker-delta/spec.md`
|
||||
* and the audit-driven additions: invalid-config rejection via Zod, no
|
||||
* module-scope determinism violations.
|
||||
*/
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { BitPackerSchema } from "@suaveplan/types";
|
||||
import { type DeltaConfig, DeltaManager } from "./delta-manager.js";
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
const SRC_ROOT = join(__dirname, "..");
|
||||
|
||||
describe("DeltaManager spec conformance", () => {
|
||||
it("rejects invalid DeltaConfig with DELTA_INVALID_CONFIG (unknown mode)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad = { mode: "magic" } as unknown as DeltaConfig;
|
||||
expect(() => new DeltaManager(bad)).toThrow(BitpackerError);
|
||||
try {
|
||||
new DeltaManager(bad);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.DELTA_INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid DeltaConfig with DELTA_INVALID_CONFIG (unknown key)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad = {
|
||||
mode: "field-level",
|
||||
wrongKey: 123,
|
||||
} as unknown as DeltaConfig;
|
||||
expect(() => new DeltaManager(bad)).toThrow(BitpackerError);
|
||||
try {
|
||||
new DeltaManager(bad);
|
||||
} catch (e) {
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.DELTA_INVALID_CONFIG
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects invalid DeltaConfig with DELTA_INVALID_CONFIG (out-of-range threshold)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const bad = {
|
||||
mode: "field-level",
|
||||
threshold: 150,
|
||||
} as unknown as DeltaConfig;
|
||||
expect(() => new DeltaManager(bad)).toThrow(BitpackerError);
|
||||
});
|
||||
|
||||
it("uses structural equality by default (key-order-insensitive on nested objects)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ o: { a: 1, b: 2 } });
|
||||
// Different declaration order, same data:
|
||||
const changes = dm.detectChanges({ o: { b: 2, a: 1 } }, schema);
|
||||
expect(changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("opts into JSON equality when equalityMode is 'json'", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({
|
||||
mode: "field-level",
|
||||
equalityMode: "json",
|
||||
});
|
||||
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ o: { a: 1, b: 2 } });
|
||||
const changes = dm.detectChanges({ o: { b: 2, a: 1 } }, schema);
|
||||
// JSON.stringify is sensitive to key order — flagged as change.
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("no module-scope Date.now in implementation files", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const matches = grepImpl(/Date\.now\(\)/g);
|
||||
expect(matches, JSON.stringify(matches, null, 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it("no module-scope Math.random in implementation files", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const matches = grepImpl(/Math\.random\(\)/g);
|
||||
expect(matches, JSON.stringify(matches, null, 2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Recursively walk `src/`, collect every `.ts` that's NOT a test file, and
|
||||
* return all matches of `pattern` in non-comment lines. Used to verify the
|
||||
* determinism rule. Comment lines are skipped because docblocks legitimately
|
||||
* mention the forbidden tokens to explain the rule.
|
||||
*/
|
||||
function grepImpl(
|
||||
pattern: RegExp
|
||||
): readonly { file: string; line: number; text: string }[] {
|
||||
const matches: { file: string; line: number; text: string }[] = [];
|
||||
const visit = (dir: string): void => {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) {
|
||||
visit(full);
|
||||
continue;
|
||||
}
|
||||
if (!entry.endsWith(".ts")) continue;
|
||||
if (entry.endsWith(".test.ts")) continue;
|
||||
if (entry.endsWith(".spec-conformance.test.ts")) continue;
|
||||
const content = readFileSync(full, "utf8");
|
||||
const lines = content.split("\n");
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const raw = lines[i] ?? "";
|
||||
if (isCommentLine(raw)) continue;
|
||||
if (pattern.test(raw)) {
|
||||
matches.push({
|
||||
file: full,
|
||||
line: i + 1,
|
||||
text: raw.trim(),
|
||||
});
|
||||
}
|
||||
pattern.lastIndex = 0; // reset for /g
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(SRC_ROOT);
|
||||
return matches;
|
||||
}
|
||||
|
||||
function isCommentLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") return true;
|
||||
if (trimmed.startsWith("//")) return true;
|
||||
if (trimmed.startsWith("*")) return true; // inside /** ... */
|
||||
if (trimmed.startsWith("/*")) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { BitPackerSchema } from "@suaveplan/types";
|
||||
import { DeltaManager } from "./delta-manager.js";
|
||||
|
||||
describe("DeltaManager", () => {
|
||||
it("returns empty array on first call for a state key", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { a: "u8" } as Record<string, BitPackerSchema>;
|
||||
const result = dm.detectChanges({ a: 1 }, schema);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("detects changed fields in schema order", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = {
|
||||
a: "u8",
|
||||
b: "-32",
|
||||
c: "u16",
|
||||
} as Record<string, BitPackerSchema>;
|
||||
|
||||
dm.storePreviousState({ a: 1, b: "x", c: 100 });
|
||||
const changes = dm.detectChanges({ a: 1, b: "y", c: 200 }, schema);
|
||||
|
||||
expect(changes).toHaveLength(2);
|
||||
expect(changes[0]).toEqual({
|
||||
index: 1,
|
||||
key: "b",
|
||||
previousValue: "x",
|
||||
currentValue: "y",
|
||||
});
|
||||
expect(changes[1]).toEqual({
|
||||
index: 2,
|
||||
key: "c",
|
||||
previousValue: 100,
|
||||
currentValue: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it("detects no changes when values are identical (primitives)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { a: "u8" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ a: 1 });
|
||||
expect(dm.detectChanges({ a: 1 }, schema)).toEqual([]);
|
||||
});
|
||||
|
||||
it("structural equality treats different key orders as equal in nested objects", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { pos: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ pos: { x: 1, y: 2 } });
|
||||
const changes = dm.detectChanges({ pos: { y: 2, x: 1 } }, schema);
|
||||
expect(changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("json equality mode flags key-order-different nested objects as changes", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({
|
||||
mode: "field-level",
|
||||
equalityMode: "json",
|
||||
});
|
||||
const schema = { pos: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ pos: { x: 1, y: 2 } });
|
||||
const changes = dm.detectChanges({ pos: { y: 2, x: 1 } }, schema);
|
||||
// JSON stringify is sensitive to key order — upstream-compat mode.
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("throws DELTA_FIELD_LIMIT_EXCEEDED when schema exceeds maxFieldCount", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({
|
||||
mode: "field-level",
|
||||
maxFieldCount: 4,
|
||||
});
|
||||
const schema = {
|
||||
a: "u8",
|
||||
b: "u8",
|
||||
c: "u8",
|
||||
d: "u8",
|
||||
e: "u8",
|
||||
} as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ a: 1, b: 2, c: 3, d: 4, e: 5 });
|
||||
expect(() =>
|
||||
dm.detectChanges({ a: 1, b: 2, c: 3, d: 4, e: 5 }, schema)
|
||||
).toThrow(BitpackerError);
|
||||
try {
|
||||
dm.detectChanges({ a: 1, b: 2, c: 3, d: 4, e: 5 }, schema);
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(BitpackerError);
|
||||
expect((e as BitpackerError).code).toBe(
|
||||
BITPACKER_CODES.DELTA_FIELD_LIMIT_EXCEEDED
|
||||
);
|
||||
expect((e as BitpackerError).context).toMatchObject({
|
||||
fieldCount: 5,
|
||||
max: 4,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("scopes diffs by stateKey — multiple peers don't cross-contaminate", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { v: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ v: 100 }, "peer-A");
|
||||
dm.storePreviousState({ v: 200 }, "peer-B");
|
||||
|
||||
// Diffing peer-A's current against peer-A's snapshot:
|
||||
expect(dm.detectChanges({ v: 105 }, schema, "peer-A")).toHaveLength(1);
|
||||
// peer-B's current value happens to equal its snapshot:
|
||||
expect(dm.detectChanges({ v: 200 }, schema, "peer-B")).toEqual([]);
|
||||
});
|
||||
|
||||
it("storePreviousState deep-clones the snapshot", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = {
|
||||
obj: "u32",
|
||||
} as Record<string, BitPackerSchema>;
|
||||
const original = { obj: { x: 1 } };
|
||||
dm.storePreviousState(original);
|
||||
(original.obj as { x: number }).x = 999;
|
||||
|
||||
// Diff against current (matches the original pre-mutation state):
|
||||
const changes = dm.detectChanges({ obj: { x: 1 } }, schema);
|
||||
expect(changes).toEqual([]);
|
||||
});
|
||||
|
||||
it("calculateSavings is correct", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
expect(dm.calculateSavings(100, 25)).toBe(75);
|
||||
expect(dm.calculateSavings(100, 100)).toBe(0);
|
||||
expect(dm.calculateSavings(0, 0)).toBe(0); // /0 guard
|
||||
});
|
||||
|
||||
it("shouldUseDelta respects threshold", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({
|
||||
mode: "field-level",
|
||||
threshold: 25,
|
||||
});
|
||||
expect(dm.shouldUseDelta(100, 80)).toBe(false); // 20% savings, < 25%
|
||||
expect(dm.shouldUseDelta(100, 75)).toBe(true); // 25% savings, exactly threshold
|
||||
expect(dm.shouldUseDelta(100, 50)).toBe(true); // 50% savings
|
||||
});
|
||||
|
||||
it("getPreviousState / hasPreviousState reflect storePreviousState", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
expect(dm.hasPreviousState()).toBe(false);
|
||||
expect(dm.getPreviousState()).toBeUndefined();
|
||||
|
||||
dm.storePreviousState({ x: 1 });
|
||||
expect(dm.hasPreviousState()).toBe(true);
|
||||
expect(dm.getPreviousState()).toEqual({ x: 1 });
|
||||
|
||||
dm.storePreviousState({ x: 2 }, "peer-A");
|
||||
expect(dm.hasPreviousState("peer-A")).toBe(true);
|
||||
expect(dm.getPreviousState("peer-A")).toEqual({ x: 2 });
|
||||
expect(dm.hasPreviousState("missing")).toBe(false);
|
||||
expect(dm.getPreviousState("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clear drops all snapshots", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
dm.storePreviousState({ a: 1 }, "k1");
|
||||
dm.storePreviousState({ a: 2 }, "k2");
|
||||
dm.clear();
|
||||
expect(dm.hasPreviousState("k1")).toBe(false);
|
||||
expect(dm.hasPreviousState("k2")).toBe(false);
|
||||
});
|
||||
|
||||
it("clearState drops one snapshot", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
dm.storePreviousState({ a: 1 }, "k1");
|
||||
dm.storePreviousState({ a: 2 }, "k2");
|
||||
dm.clearState("k1");
|
||||
expect(dm.hasPreviousState("k1")).toBe(false);
|
||||
expect(dm.hasPreviousState("k2")).toBe(true);
|
||||
});
|
||||
|
||||
it("constructor with no args defaults to { mode: 'none' }", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager();
|
||||
expect(dm.getConfig().mode).toBe("none");
|
||||
});
|
||||
|
||||
it("structural equality treats null vs object as different", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { v: "u32" } as Record<string, BitPackerSchema>;
|
||||
// First store: previous = { v: { x: 1 } }
|
||||
dm.storePreviousState({ v: { x: 1 } });
|
||||
// Current has v: null — null vs object asymmetric branches
|
||||
const changes = dm.detectChanges(
|
||||
{ v: null as unknown as object },
|
||||
schema
|
||||
);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("structural equality treats different typeof as different", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { v: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ v: 1 });
|
||||
const changes = dm.detectChanges(
|
||||
{ v: "1" as unknown as number },
|
||||
schema
|
||||
);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("structural equality detects typed-array length mismatch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { buf: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ buf: new Uint8Array([1, 2, 3]) });
|
||||
const changes = dm.detectChanges(
|
||||
{ buf: new Uint8Array([1, 2]) },
|
||||
schema
|
||||
);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("structural equality detects object key-count mismatch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ o: { a: 1, b: 2 } });
|
||||
const changes = dm.detectChanges(
|
||||
{ o: { a: 1 } as unknown as { a: number; b: number } },
|
||||
schema
|
||||
);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("structural equality detects object disjoint-keys mismatch", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ o: { a: 1, b: 2 } });
|
||||
// Same key count but different keys
|
||||
const changes = dm.detectChanges(
|
||||
{
|
||||
o: { a: 1, c: 2 } as unknown as { a: number; b: number },
|
||||
},
|
||||
schema
|
||||
);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("structural equality detects object same-keys-different-values", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { o: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ o: { a: 1, b: 2 } });
|
||||
// Same keys, different value for `b`
|
||||
const changes = dm.detectChanges({ o: { a: 1, b: 999 } }, schema);
|
||||
expect(changes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("getConfig returns the resolved config", { timeout: 5000 }, () => {
|
||||
const dm = new DeltaManager({ mode: "xor", threshold: 50 });
|
||||
const cfg = dm.getConfig();
|
||||
expect(cfg.mode).toBe("xor");
|
||||
expect(cfg.threshold).toBe(50);
|
||||
expect(cfg.xorNumbers).toBe(true); // default
|
||||
expect(cfg.diffStrings).toBe(false); // default
|
||||
expect(cfg.maxFieldCount).toBe(64); // default
|
||||
expect(cfg.stateKey).toBe("default"); // default
|
||||
expect(cfg.equalityMode).toBe("structural"); // default
|
||||
});
|
||||
|
||||
it("structural equality compares typed arrays element-wise", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { buf: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ buf: new Uint8Array([1, 2, 3]) });
|
||||
|
||||
// Same content → no change
|
||||
expect(
|
||||
dm.detectChanges({ buf: new Uint8Array([1, 2, 3]) }, schema)
|
||||
).toEqual([]);
|
||||
|
||||
// Different content → one change
|
||||
expect(
|
||||
dm.detectChanges({ buf: new Uint8Array([1, 2, 4]) }, schema)
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("structural equality compares arrays length-then-elements", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const dm = new DeltaManager({ mode: "field-level" });
|
||||
const schema = { arr: "u32" } as Record<string, BitPackerSchema>;
|
||||
dm.storePreviousState({ arr: [1, 2, 3] });
|
||||
expect(dm.detectChanges({ arr: [1, 2, 3] }, schema)).toEqual([]);
|
||||
expect(dm.detectChanges({ arr: [1, 2] }, schema)).toHaveLength(1); // length differs
|
||||
expect(dm.detectChanges({ arr: [1, 2, 4] }, schema)).toHaveLength(1); // element differs
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Field-level delta detector for bit-packed objects.
|
||||
*
|
||||
* Ported from suaveplan upstream `core/delta/delta-manager.ts`. Genesis-side
|
||||
* hardening:
|
||||
* - Replaces upstream's `Date.now()` with an injectable `Clock` (CLAUDE.md
|
||||
* determinism rule).
|
||||
* - Replaces upstream's `JSON.stringify`-based equality (which is sensitive
|
||||
* to key order) with a structural walker that respects `BitPackerSchema`
|
||||
* field order. The legacy stringify behaviour is available behind opt-in
|
||||
* `equalityMode: "json"` for byte-compat with upstream payloads.
|
||||
* - Replaces upstream's plain-`Error` throws with `BitpackerError` codes.
|
||||
* - Validates `DeltaConfig` via Zod at construction; unknown fields and
|
||||
* invalid `mode` values are rejected with `BITPACKER.DELTA_INVALID_CONFIG`.
|
||||
*/
|
||||
|
||||
import { BITPACKER_CODES, BitpackerError } from "@suaveplan/bitpacker";
|
||||
import type { BitPackerSchema } from "@suaveplan/types";
|
||||
import z from "zod";
|
||||
import { DeltaStateManager } from "../delta-state/delta-state.js";
|
||||
|
||||
const DEFAULT_STATE_KEY = "default";
|
||||
const DEFAULT_MAX_FIELD_COUNT = 64;
|
||||
const DEFAULT_THRESHOLD = 0;
|
||||
|
||||
/**
|
||||
* One field that changed between the previous and current state. Indexed by
|
||||
* `Object.keys(schema)` order so encoders can rely on stable bit offsets.
|
||||
*/
|
||||
export interface FieldChange {
|
||||
readonly index: number;
|
||||
readonly key: string;
|
||||
readonly previousValue: unknown;
|
||||
readonly currentValue: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration accepted by `new DeltaManager`. Validated via Zod at
|
||||
* construction; unknown keys are rejected.
|
||||
*/
|
||||
export interface DeltaConfig {
|
||||
/** Strategy mode. `"none"` returns the value unchanged on every detect. */
|
||||
readonly mode: "none" | "xor" | "string-diff" | "field-level";
|
||||
/** XOR numeric fields when packing the delta. Default `true`. */
|
||||
readonly xorNumbers?: boolean;
|
||||
/** Diff string fields when packing. Default `false`. */
|
||||
readonly diffStrings?: boolean;
|
||||
/** Bandwidth-saving threshold (% bits saved) before delta is preferred. Default `0`. */
|
||||
readonly threshold?: number;
|
||||
/** Maximum fields per object the field-level mask can address. Default `64`. */
|
||||
readonly maxFieldCount?: number;
|
||||
/** Default state key for `detectChanges` calls without one. */
|
||||
readonly stateKey?: string;
|
||||
/**
|
||||
* `"structural"` (default) walks the value respecting schema field order.
|
||||
* `"json"` falls back to `JSON.stringify` equality (upstream-compat mode).
|
||||
*/
|
||||
readonly equalityMode?: "structural" | "json";
|
||||
}
|
||||
|
||||
const DeltaConfigSchema = z
|
||||
.object({
|
||||
mode: z.enum(["none", "xor", "string-diff", "field-level"]),
|
||||
xorNumbers: z.boolean().optional(),
|
||||
diffStrings: z.boolean().optional(),
|
||||
threshold: z.number().min(0).max(100).optional(),
|
||||
maxFieldCount: z.number().int().min(1).optional(),
|
||||
stateKey: z.string().min(1).optional(),
|
||||
equalityMode: z.enum(["structural", "json"]).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type ResolvedConfig = Required<DeltaConfig>;
|
||||
|
||||
/**
|
||||
* Detects field-level changes between successive states of an object.
|
||||
*
|
||||
* Stateful: each `DeltaManager` owns a `DeltaStateManager` keyed by string.
|
||||
* Consumers store one snapshot per logical state stream (per peer, per
|
||||
* session, etc.) and call `detectChanges(current, schema)` on each tick to
|
||||
* get an ordered `FieldChange[]`. The returned indices match
|
||||
* `Object.keys(schema)` order so encoders can pack a fixed-width bitmask.
|
||||
*/
|
||||
export class DeltaManager {
|
||||
private readonly stateManager: DeltaStateManager;
|
||||
private readonly config: ResolvedConfig;
|
||||
|
||||
public constructor(
|
||||
config: DeltaConfig = { mode: "none" },
|
||||
options: DeltaManagerOptions = {}
|
||||
) {
|
||||
const parsed = DeltaConfigSchema.safeParse(config);
|
||||
if (!parsed.success) {
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.DELTA_INVALID_CONFIG,
|
||||
`Invalid DeltaConfig: ${parsed.error.message}`,
|
||||
{ context: { issues: parsed.error.issues } }
|
||||
);
|
||||
}
|
||||
this.stateManager = new DeltaStateManager(options.maxStates);
|
||||
this.config = {
|
||||
mode: parsed.data.mode,
|
||||
xorNumbers: parsed.data.xorNumbers ?? true,
|
||||
diffStrings: parsed.data.diffStrings ?? false,
|
||||
threshold: parsed.data.threshold ?? DEFAULT_THRESHOLD,
|
||||
maxFieldCount: parsed.data.maxFieldCount ?? DEFAULT_MAX_FIELD_COUNT,
|
||||
stateKey: parsed.data.stateKey ?? DEFAULT_STATE_KEY,
|
||||
equalityMode: parsed.data.equalityMode ?? "structural",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the ordered list of fields whose value changed between the
|
||||
* stored previous state (under `stateKey ?? config.stateKey`) and
|
||||
* `current`. On the first call for a state key (no previous stored), the
|
||||
* result is an empty array. Field indices match `Object.keys(schema)`.
|
||||
*/
|
||||
public detectChanges(
|
||||
current: Record<string, unknown>,
|
||||
schema: Record<string, BitPackerSchema>,
|
||||
stateKey?: string
|
||||
): FieldChange[] {
|
||||
const fieldKeys = Object.keys(schema);
|
||||
if (fieldKeys.length > this.config.maxFieldCount) {
|
||||
throw new BitpackerError(
|
||||
BITPACKER_CODES.DELTA_FIELD_LIMIT_EXCEEDED,
|
||||
`Schema has ${fieldKeys.length} fields; maxFieldCount is ${this.config.maxFieldCount}`,
|
||||
{
|
||||
context: {
|
||||
fieldCount: fieldKeys.length,
|
||||
max: this.config.maxFieldCount,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const key = stateKey ?? this.config.stateKey;
|
||||
const previous = this.stateManager.getPreviousState(key) as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
if (previous === undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const changes: FieldChange[] = [];
|
||||
for (let index = 0; index < fieldKeys.length; index++) {
|
||||
// `Object.keys` always returns defined strings, so the
|
||||
// index-access is sound; cast tames `noUncheckedIndexedAccess`.
|
||||
const fieldKey = fieldKeys[index] as string;
|
||||
const currentValue = current[fieldKey];
|
||||
const previousValue = previous[fieldKey];
|
||||
|
||||
if (!this.valuesEqual(currentValue, previousValue)) {
|
||||
changes.push({
|
||||
index,
|
||||
key: fieldKey,
|
||||
previousValue,
|
||||
currentValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store `state` as the new "previous" snapshot under `key`. The next
|
||||
* `detectChanges` call will diff against this. Snapshot is deep-cloned by
|
||||
* the underlying `DeltaStateManager`.
|
||||
*/
|
||||
public storePreviousState(state: unknown, stateKey?: string): void {
|
||||
this.stateManager.storePreviousState(
|
||||
stateKey ?? this.config.stateKey,
|
||||
state
|
||||
);
|
||||
}
|
||||
|
||||
public getPreviousState(stateKey?: string): unknown | undefined {
|
||||
return this.stateManager.getPreviousState(
|
||||
stateKey ?? this.config.stateKey
|
||||
);
|
||||
}
|
||||
|
||||
public hasPreviousState(stateKey?: string): boolean {
|
||||
return this.stateManager.hasPreviousState(
|
||||
stateKey ?? this.config.stateKey
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a savings percentage. `0%` means delta is the same size as
|
||||
* full; `100%` means delta is empty. Used by `shouldUseDelta` to decide
|
||||
* whether the delta is worth sending.
|
||||
*/
|
||||
public calculateSavings(originalBits: number, deltaBits: number): number {
|
||||
if (originalBits === 0) return 0;
|
||||
return ((originalBits - deltaBits) / originalBits) * 100;
|
||||
}
|
||||
|
||||
public shouldUseDelta(originalBits: number, deltaBits: number): boolean {
|
||||
return (
|
||||
this.calculateSavings(originalBits, deltaBits) >=
|
||||
this.config.threshold
|
||||
);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.stateManager.clear();
|
||||
}
|
||||
|
||||
public clearState(stateKey: string): void {
|
||||
this.stateManager.clearState(stateKey);
|
||||
}
|
||||
|
||||
public getConfig(): Readonly<ResolvedConfig> {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural deep equality respecting bit-packer field semantics.
|
||||
*
|
||||
* - In `"json"` mode, falls back to `JSON.stringify` equality
|
||||
* (upstream-compat — sensitive to key order and not safe for typed
|
||||
* arrays without custom replacer).
|
||||
* - In `"structural"` (default), walks values: primitives compare via
|
||||
* `===`, typed arrays compare element-wise, arrays compare deeply,
|
||||
* plain objects compare key-set-then-values.
|
||||
*
|
||||
* Note: Maps and Sets are NOT supported in structural mode (they would
|
||||
* never be packed via a `BitPackerSchema` anyway). If supplied they
|
||||
* compare by reference (identity).
|
||||
*/
|
||||
private valuesEqual(a: unknown, b: unknown): boolean {
|
||||
if (this.config.equalityMode === "json") {
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (typeof a !== typeof b) return false;
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!this.valuesEqual(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(a) && ArrayBuffer.isView(b)) {
|
||||
const aArr = new Uint8Array(a.buffer, a.byteOffset, a.byteLength);
|
||||
const bArr = new Uint8Array(b.buffer, b.byteOffset, b.byteLength);
|
||||
if (aArr.length !== bArr.length) return false;
|
||||
for (let i = 0; i < aArr.length; i++) {
|
||||
if (aArr[i] !== bArr[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof a === "object" && typeof b === "object") {
|
||||
const ao = a as Record<string, unknown>;
|
||||
const bo = b as Record<string, unknown>;
|
||||
const aKeys = Object.keys(ao);
|
||||
const bKeys = Object.keys(bo);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
for (const key of aKeys) {
|
||||
if (!Object.hasOwn(bo, key)) return false;
|
||||
if (!this.valuesEqual(ao[key], bo[key])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Construction-time options for `DeltaManager`. Distinct from runtime
|
||||
* `DeltaConfig` (which controls behaviour); this controls the underlying
|
||||
* state-store capacity.
|
||||
*/
|
||||
export interface DeltaManagerOptions {
|
||||
/** Capacity of the underlying `DeltaStateManager`. Default 100. */
|
||||
readonly maxStates?: number;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# DeltaStateManager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`DeltaStateManager` keeps deep-cloned snapshots of previous values per state key, so that a delta encoder can diff the current value against its predecessor without worrying about caller-side mutations. It is the storage layer for `DeltaManager`; consumers usually go through `DeltaManager` rather than calling this class directly.
|
||||
|
||||
The store is bounded: an LRU eviction policy drops the oldest key once the configured capacity is reached. Default capacity is 100 keys, which is plenty for most workloads (one key per per-peer state stream). Re-storing under an existing key updates the value in place without advancing its LRU position — this matches upstream semantics so that frequently-updated keys do not unfairly evict rarely-updated ones.
|
||||
|
||||
## Features
|
||||
|
||||
- Deep-cloning on store: `Date`, all typed-array variants (`Uint8Array`, `Int8Array`, `Uint16Array`, `Int16Array`, `Uint32Array`, `Int32Array`, `Float32Array`, `Float64Array`), arrays, and plain objects are all isolated from caller mutations.
|
||||
- Insertion-order LRU eviction; `maxStates` configurable (default 100).
|
||||
- `clear()`, `clearState(key)`, `getStateCount()`, `getMaxStates()` accessors for inspection and reset.
|
||||
- Zero `Math.random` / `Date.now` calls — fully deterministic given identical inputs.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```typescript
|
||||
import { DeltaStateManager } from "@suaveplan/bitpacker-delta";
|
||||
|
||||
const states = new DeltaStateManager(100);
|
||||
|
||||
states.storePreviousState("player-1", { x: 0, y: 0 });
|
||||
const previous = states.getPreviousState("player-1");
|
||||
// → { x: 0, y: 0 }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```typescript
|
||||
import { DeltaStateManager } from "@suaveplan/bitpacker-delta";
|
||||
|
||||
const states = new DeltaStateManager(3);
|
||||
states.storePreviousState("a", { v: 1 });
|
||||
states.storePreviousState("b", { v: 2 });
|
||||
states.storePreviousState("c", { v: 3 });
|
||||
states.storePreviousState("d", { v: 4 }); // evicts "a"
|
||||
|
||||
states.hasPreviousState("a"); // → false (evicted)
|
||||
states.hasPreviousState("d"); // → true
|
||||
|
||||
// Mutating the original after storing does NOT touch the snapshot:
|
||||
const original = { x: 0 };
|
||||
states.storePreviousState("e", original);
|
||||
original.x = 999;
|
||||
states.getPreviousState("e"); // → { x: 0 }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `new DeltaStateManager(maxStates?: number)`
|
||||
|
||||
- `maxStates` — positive integer; default `100`. Throws `RangeError` if non-integer or `< 1`.
|
||||
|
||||
### `storePreviousState(key: string, state: unknown): void`
|
||||
|
||||
Stores a deep-cloned copy of `state` under `key`. If `key` already exists, the value is updated in place; LRU position is unchanged. If the store is at capacity and this is a new key, the oldest key is evicted.
|
||||
|
||||
### `getPreviousState(key: string): unknown | undefined`
|
||||
|
||||
Returns the stored snapshot, or `undefined` if the key has never been stored or has been evicted.
|
||||
|
||||
### `hasPreviousState(key: string): boolean`
|
||||
|
||||
True if a snapshot exists for `key`.
|
||||
|
||||
### `clear(): void`
|
||||
|
||||
Drops every snapshot.
|
||||
|
||||
### `clearState(key: string): void`
|
||||
|
||||
Drops the snapshot for one key. No-op if absent.
|
||||
|
||||
### `getStateCount(): number`
|
||||
|
||||
Number of stored snapshots.
|
||||
|
||||
### `getMaxStates(): number`
|
||||
|
||||
Configured maximum capacity.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The deep-clone walker handles the eight typed-array variants explicitly because the generic object-clone path (`Object.hasOwn` over keys) does not preserve `Uint8Array`-vs-plain-array distinction. Maps and Sets are intentionally NOT cloned: delta-state semantics assume keys are strings and values are plain data; if a consumer needs `Map`/`Set`, they should canonicalise to a plain object first.
|
||||
|
||||
Performance: insertion is O(1) amortised; eviction is O(1) (Array `.shift()` on a small bounded array). Lookup is O(1). The `stateKeys` array tracks insertion order without using a `Map`'s native iteration (some legacy environments don't preserve insertion order — though every modern runtime does — so the explicit array is defensive).
|
||||
@@ -0,0 +1,160 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { DeltaStateManager } from "./delta-state.js";
|
||||
|
||||
describe("DeltaStateManager", () => {
|
||||
it("deep-clones stored states to isolate from caller mutation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const states = new DeltaStateManager();
|
||||
const original = { x: 1, nested: { y: 2 } };
|
||||
states.storePreviousState("k", original);
|
||||
|
||||
(original as { x: number }).x = 999;
|
||||
(original as { nested: { y: number } }).nested.y = 999;
|
||||
|
||||
const snapshot = states.getPreviousState("k") as {
|
||||
x: number;
|
||||
nested: { y: number };
|
||||
};
|
||||
expect(snapshot.x).toBe(1);
|
||||
expect(snapshot.nested.y).toBe(2);
|
||||
});
|
||||
|
||||
it("evicts oldest state when maxStates exceeded", { timeout: 5000 }, () => {
|
||||
const states = new DeltaStateManager(3);
|
||||
states.storePreviousState("a", 1);
|
||||
states.storePreviousState("b", 2);
|
||||
states.storePreviousState("c", 3);
|
||||
states.storePreviousState("d", 4);
|
||||
|
||||
expect(states.hasPreviousState("a")).toBe(false);
|
||||
expect(states.hasPreviousState("b")).toBe(true);
|
||||
expect(states.hasPreviousState("c")).toBe(true);
|
||||
expect(states.hasPreviousState("d")).toBe(true);
|
||||
expect(states.getStateCount()).toBe(3);
|
||||
});
|
||||
|
||||
it("re-store under existing key preserves LRU position", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const states = new DeltaStateManager(3);
|
||||
states.storePreviousState("a", 1);
|
||||
states.storePreviousState("b", 2);
|
||||
states.storePreviousState("c", 3);
|
||||
// re-store "a" — should NOT advance LRU
|
||||
states.storePreviousState("a", 100);
|
||||
// adding a new key should still evict "a" (oldest)
|
||||
states.storePreviousState("d", 4);
|
||||
|
||||
expect(states.hasPreviousState("a")).toBe(false);
|
||||
expect(states.hasPreviousState("b")).toBe(true);
|
||||
expect(states.hasPreviousState("c")).toBe(true);
|
||||
expect(states.hasPreviousState("d")).toBe(true);
|
||||
});
|
||||
|
||||
it("re-store updates value in place", { timeout: 5000 }, () => {
|
||||
const states = new DeltaStateManager();
|
||||
states.storePreviousState("k", 1);
|
||||
states.storePreviousState("k", 2);
|
||||
expect(states.getPreviousState("k")).toBe(2);
|
||||
expect(states.getStateCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("clear drops all states", { timeout: 5000 }, () => {
|
||||
const states = new DeltaStateManager();
|
||||
states.storePreviousState("a", 1);
|
||||
states.storePreviousState("b", 2);
|
||||
states.clear();
|
||||
expect(states.getStateCount()).toBe(0);
|
||||
expect(states.hasPreviousState("a")).toBe(false);
|
||||
expect(states.hasPreviousState("b")).toBe(false);
|
||||
});
|
||||
|
||||
it("clearState drops only the named state", { timeout: 5000 }, () => {
|
||||
const states = new DeltaStateManager();
|
||||
states.storePreviousState("a", 1);
|
||||
states.storePreviousState("b", 2);
|
||||
states.clearState("a");
|
||||
expect(states.hasPreviousState("a")).toBe(false);
|
||||
expect(states.hasPreviousState("b")).toBe(true);
|
||||
});
|
||||
|
||||
it("clearState on absent key is a no-op", { timeout: 5000 }, () => {
|
||||
const states = new DeltaStateManager();
|
||||
states.storePreviousState("a", 1);
|
||||
states.clearState("missing");
|
||||
expect(states.hasPreviousState("a")).toBe(true);
|
||||
expect(states.getStateCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("default maxStates is 100", { timeout: 5000 }, () => {
|
||||
const states = new DeltaStateManager();
|
||||
expect(states.getMaxStates()).toBe(100);
|
||||
});
|
||||
|
||||
it("deep-clones Date instances to isolate from caller mutation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const states = new DeltaStateManager();
|
||||
const original = new Date("2026-01-01T00:00:00Z");
|
||||
states.storePreviousState("k", original);
|
||||
const snapshot = states.getPreviousState("k") as Date;
|
||||
expect(snapshot).toBeInstanceOf(Date);
|
||||
expect(snapshot.getTime()).toBe(original.getTime());
|
||||
// Snapshot is a copy:
|
||||
expect(snapshot).not.toBe(original);
|
||||
});
|
||||
|
||||
it("deep-clones Uint8Array buffers to isolate from caller mutation", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const states = new DeltaStateManager();
|
||||
const original = new Uint8Array([1, 2, 3]);
|
||||
states.storePreviousState("k", original);
|
||||
original[0] = 99;
|
||||
const snapshot = states.getPreviousState("k") as Uint8Array;
|
||||
expect(snapshot).toBeInstanceOf(Uint8Array);
|
||||
expect(Array.from(snapshot)).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
it("deep-clones every typed-array variant", { timeout: 5000 }, () => {
|
||||
const states = new DeltaStateManager();
|
||||
const variants: ReadonlyArray<readonly [string, unknown]> = [
|
||||
["i8", new Int8Array([1, 2])],
|
||||
["u16", new Uint16Array([300])],
|
||||
["i16", new Int16Array([-300])],
|
||||
["u32", new Uint32Array([70000])],
|
||||
["i32", new Int32Array([-70000])],
|
||||
["f32", new Float32Array([1.5])],
|
||||
["f64", new Float64Array([1.5])],
|
||||
];
|
||||
for (const [k, v] of variants) {
|
||||
states.storePreviousState(k, v);
|
||||
const snap = states.getPreviousState(k);
|
||||
expect(snap).toBeInstanceOf(
|
||||
(v as ArrayBufferView).constructor as new () => unknown
|
||||
);
|
||||
expect(snap).not.toBe(v);
|
||||
}
|
||||
});
|
||||
|
||||
it("deep-clones nested arrays and objects", { timeout: 5000 }, () => {
|
||||
const states = new DeltaStateManager();
|
||||
const original = {
|
||||
arr: [1, 2, { nested: true }],
|
||||
obj: { a: { b: { c: 1 } } },
|
||||
};
|
||||
states.storePreviousState("k", original);
|
||||
(original.arr[2] as { nested: boolean }).nested = false;
|
||||
(original.obj.a.b as { c: number }).c = 999;
|
||||
const snap = states.getPreviousState("k") as typeof original;
|
||||
expect((snap.arr[2] as { nested: boolean }).nested).toBe(true);
|
||||
expect(snap.obj.a.b.c).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects non-positive maxStates", { timeout: 5000 }, () => {
|
||||
expect(() => new DeltaStateManager(0)).toThrow(RangeError);
|
||||
expect(() => new DeltaStateManager(-1)).toThrow(RangeError);
|
||||
expect(() => new DeltaStateManager(1.5)).toThrow(RangeError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Manages previous state snapshots for delta compression.
|
||||
*
|
||||
* Ported from suaveplan upstream `core/delta/delta-state.ts`. Genesis-side
|
||||
* hardening: no module-scope `Math.random` or `Date.now`; deep-cloning
|
||||
* preserved verbatim (caller mutations of the original do not affect the
|
||||
* stored snapshot); LRU eviction by insertion order with a configurable
|
||||
* `maxStates` capacity (default 100).
|
||||
*/
|
||||
|
||||
const DEFAULT_MAX_STATES = 100;
|
||||
|
||||
/**
|
||||
* Stores per-key snapshots of previous values for delta diffing. Insertion
|
||||
* order determines eviction order: the oldest key is evicted when the store
|
||||
* grows past `maxStates`. Re-storing under an existing key updates the value
|
||||
* in place and does NOT advance the LRU position (matches upstream behaviour).
|
||||
*/
|
||||
export class DeltaStateManager {
|
||||
private readonly previousStates = new Map<string, unknown>();
|
||||
private readonly stateKeys: string[] = [];
|
||||
private readonly maxStates: number;
|
||||
|
||||
public constructor(maxStates: number = DEFAULT_MAX_STATES) {
|
||||
if (!Number.isInteger(maxStates) || maxStates < 1) {
|
||||
throw new RangeError(
|
||||
`DeltaStateManager maxStates must be a positive integer; got ${String(maxStates)}`
|
||||
);
|
||||
}
|
||||
this.maxStates = maxStates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a deep-cloned snapshot of `state` under `key`. If `key` already
|
||||
* exists, its value is updated in place; LRU position is preserved.
|
||||
*/
|
||||
public storePreviousState(key: string, state: unknown): void {
|
||||
const existed = this.previousStates.has(key);
|
||||
this.previousStates.set(key, deepClone(state));
|
||||
|
||||
if (!existed) {
|
||||
this.stateKeys.push(key);
|
||||
}
|
||||
|
||||
// Evict oldest if we've grown past capacity. The `> maxStates` check
|
||||
// above guarantees `stateKeys` is non-empty here, so `shift()` never
|
||||
// returns undefined — the cast is sound.
|
||||
if (this.stateKeys.length > this.maxStates) {
|
||||
const oldestKey = this.stateKeys.shift() as string;
|
||||
this.previousStates.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the snapshot stored under `key`, or `undefined` if no snapshot
|
||||
* has been stored yet (or the key has been evicted).
|
||||
*/
|
||||
public getPreviousState(key: string): unknown | undefined {
|
||||
return this.previousStates.get(key);
|
||||
}
|
||||
|
||||
public hasPreviousState(key: string): boolean {
|
||||
return this.previousStates.has(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop every stored snapshot. The instance can be reused.
|
||||
*/
|
||||
public clear(): void {
|
||||
this.previousStates.clear();
|
||||
this.stateKeys.length = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the snapshot for one specific key. No-op if the key is absent.
|
||||
*/
|
||||
public clearState(key: string): void {
|
||||
this.previousStates.delete(key);
|
||||
const index = this.stateKeys.indexOf(key);
|
||||
if (index !== -1) {
|
||||
this.stateKeys.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
public getStateCount(): number {
|
||||
return this.previousStates.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only view of the configured maximum capacity. Useful for tests.
|
||||
*/
|
||||
public getMaxStates(): number {
|
||||
return this.maxStates;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone a value to isolate stored snapshots from caller mutations.
|
||||
* Handles primitives, Date, every typed-array view, plain objects, and
|
||||
* arrays. Maps and Sets are NOT cloned (out of scope for delta state — if a
|
||||
* consumer needs them, they should canonicalise to a plain object first).
|
||||
*/
|
||||
function deepClone(value: unknown): unknown {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value instanceof Date) {
|
||||
return new Date(value.getTime());
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(deepClone);
|
||||
}
|
||||
|
||||
if (value instanceof Uint8Array) {
|
||||
return new Uint8Array(value);
|
||||
}
|
||||
if (value instanceof Int8Array) {
|
||||
return new Int8Array(value);
|
||||
}
|
||||
if (value instanceof Uint16Array) {
|
||||
return new Uint16Array(value);
|
||||
}
|
||||
if (value instanceof Int16Array) {
|
||||
return new Int16Array(value);
|
||||
}
|
||||
if (value instanceof Uint32Array) {
|
||||
return new Uint32Array(value);
|
||||
}
|
||||
if (value instanceof Int32Array) {
|
||||
return new Int32Array(value);
|
||||
}
|
||||
if (value instanceof Float32Array) {
|
||||
return new Float32Array(value);
|
||||
}
|
||||
if (value instanceof Float64Array) {
|
||||
return new Float64Array(value);
|
||||
}
|
||||
|
||||
const cloned: Record<string, unknown> = {};
|
||||
const source = value as Record<string, unknown>;
|
||||
// `Object.keys` returns own enumerable keys only — no need for an
|
||||
// inner `Object.hasOwn` guard.
|
||||
for (const key of Object.keys(source)) {
|
||||
cloned[key] = deepClone(source[key]);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Tests for delta encoders (string-diff, xor-encoder, numberXor).
|
||||
*
|
||||
* Note: `DeltaManager` and `DeltaStateManager` tests have been moved to their
|
||||
* own module directories under `delta-manager/` and `delta-state/`. This file
|
||||
* covers only the standalone codecs.
|
||||
*/
|
||||
import { describe, expect, test } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
applyStringDiff,
|
||||
applyXorDelta,
|
||||
calculateDeltaBitSize,
|
||||
calculateDiffSavings,
|
||||
calculateStringDiff,
|
||||
calculateXorDelta,
|
||||
isXorEfficient,
|
||||
numberXor,
|
||||
shouldUseDiff,
|
||||
} from "./index.js";
|
||||
|
||||
describe("numberXor", () => {
|
||||
test("XOR of identical numbers is zero-ish", () => {
|
||||
const result = numberXor(42, 42);
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
test("XOR roundtrips", () => {
|
||||
const a = Math.PI;
|
||||
const b = Math.E;
|
||||
const xor = numberXor(a, b);
|
||||
expect(numberXor(xor, b)).toBeCloseTo(a);
|
||||
});
|
||||
});
|
||||
|
||||
describe("xor encoder", () => {
|
||||
test("calculateXorDelta", () => {
|
||||
expect(calculateXorDelta(0b1010, 0b1100)).toBe(0b0110);
|
||||
});
|
||||
test("applyXorDelta roundtrips", () => {
|
||||
const prev = 12345;
|
||||
const curr = 54321;
|
||||
const delta = calculateXorDelta(curr, prev);
|
||||
expect(applyXorDelta(prev, delta)).toBe(curr);
|
||||
});
|
||||
test("calculateDeltaBitSize", () => {
|
||||
expect(calculateDeltaBitSize(0)).toBe(1);
|
||||
expect(calculateDeltaBitSize(1)).toBe(1);
|
||||
expect(calculateDeltaBitSize(2)).toBe(2);
|
||||
expect(calculateDeltaBitSize(255)).toBe(8);
|
||||
});
|
||||
test("isXorEfficient", () => {
|
||||
expect(isXorEfficient(10, 10, 8)).toBe(true);
|
||||
expect(isXorEfficient(0, 255, 4)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("string diff", () => {
|
||||
test("identical strings have full prefix", () => {
|
||||
const diff = calculateStringDiff("hello", "hello");
|
||||
expect(diff.prefixLen).toBe(5);
|
||||
expect(diff.middleLen).toBe(0);
|
||||
});
|
||||
test("empty previous returns full current", () => {
|
||||
const diff = calculateStringDiff("", "hello");
|
||||
expect(diff.middle).toBe("hello");
|
||||
});
|
||||
test("empty current returns zero diff", () => {
|
||||
const diff = calculateStringDiff("hello", "");
|
||||
expect(diff.middle).toBe("");
|
||||
expect(diff.prefixLen).toBe(0);
|
||||
});
|
||||
test("suffix-only change", () => {
|
||||
const diff = calculateStringDiff("hello world", "hello moon");
|
||||
expect(applyStringDiff("hello world", diff)).toBe("hello moon");
|
||||
});
|
||||
test("common suffix is captured", () => {
|
||||
const diff = calculateStringDiff("testing", "besting");
|
||||
expect(diff.suffixLen).toBeGreaterThan(0);
|
||||
expect(applyStringDiff("testing", diff)).toBe("besting");
|
||||
});
|
||||
test("diff applies correctly", () => {
|
||||
const diff = calculateStringDiff("hello world", "hello there");
|
||||
expect(applyStringDiff("hello world", diff)).toBe("hello there");
|
||||
});
|
||||
test("shouldUseDiff returns true for similar strings", () => {
|
||||
expect(shouldUseDiff("hello world", "hello there")).toBe(true);
|
||||
});
|
||||
test("calculateDiffSavings returns percentage", () => {
|
||||
const diff = calculateStringDiff("hello world", "hello there");
|
||||
const savings = calculateDiffSavings(
|
||||
"hello world",
|
||||
"hello there",
|
||||
diff
|
||||
);
|
||||
expect(savings).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
## index
|
||||
|
||||
**Path:** `bitpacker-delta/src/index.ts`
|
||||
**Export:** See source for details.
|
||||
|
||||
### What it does
|
||||
|
||||
Part of the @suaveplan/ package.
|
||||
26
packages/foundation/bitpacker/bitpacker-delta/src/index.ts
Normal file
26
packages/foundation/bitpacker/bitpacker-delta/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Delta encoding for efficient repeated state transmission.
|
||||
*/
|
||||
|
||||
export {
|
||||
type DeltaConfig,
|
||||
DeltaManager,
|
||||
type DeltaManagerOptions,
|
||||
type FieldChange,
|
||||
} from "./delta-manager/delta-manager.js";
|
||||
export { DeltaStateManager } from "./delta-state/delta-state.js";
|
||||
|
||||
export {
|
||||
applyStringDiff,
|
||||
calculateDiffSavings,
|
||||
calculateStringDiff,
|
||||
type StringDiff,
|
||||
shouldUseDiff,
|
||||
} from "./string-diff.js";
|
||||
export {
|
||||
applyXorDelta,
|
||||
calculateDeltaBitSize,
|
||||
calculateXorDelta,
|
||||
isXorEfficient,
|
||||
numberXor,
|
||||
} from "./xor-encoder.js";
|
||||
@@ -0,0 +1,26 @@
|
||||
## String Diff
|
||||
|
||||
**Path:** `src/string-diff.ts`
|
||||
**Export:** `calculateStringDiff`, `applyStringDiff`, `calculateDiffSavings`, `shouldUseDiff`, `StringDiff`
|
||||
|
||||
### What it does
|
||||
|
||||
Efficiently encodes string changes by finding common prefix and suffix, only encoding the differing middle section.
|
||||
|
||||
### Key API
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `calculateStringDiff(previous, current)` | Compute `{ prefixLen, suffixLen, middleLen, middle }` |
|
||||
| `applyStringDiff(previous, diff)` | Reconstruct current string from diff |
|
||||
| `calculateDiffSavings(previous, current, diff)` | Percentage savings from diff encoding |
|
||||
| `shouldUseDiff(previous, current, threshold?)` | Decide whether diff encoding is worthwhile |
|
||||
|
||||
### Format
|
||||
|
||||
`[prefixLen][suffixLen][middleLen][middleChars...]` — each length field is 1 byte.
|
||||
|
||||
### Used by
|
||||
|
||||
- Delta compression for string fields in game state
|
||||
- Network protocols with repetitive string data
|
||||
113
packages/foundation/bitpacker/bitpacker-delta/src/string-diff.ts
Normal file
113
packages/foundation/bitpacker/bitpacker-delta/src/string-diff.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* String Diff Encoding for Delta Compression
|
||||
*
|
||||
* Efficiently encodes string changes by:
|
||||
* - Common prefix elimination
|
||||
* - Common suffix elimination
|
||||
* - Only encoding the differing middle section
|
||||
*
|
||||
* Format: [prefixLen][suffixLen][middleLength][middleChars...]
|
||||
*/
|
||||
|
||||
export interface StringDiff {
|
||||
/** Length of common prefix */
|
||||
prefixLen: number;
|
||||
/** Length of common suffix */
|
||||
suffixLen: number;
|
||||
/** Length of middle (differing) section */
|
||||
middleLen: number;
|
||||
/** Middle (differing) string section */
|
||||
middle: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate diff between two strings.
|
||||
*/
|
||||
export function calculateStringDiff(
|
||||
previous: string,
|
||||
current: string
|
||||
): StringDiff {
|
||||
if (previous === current) {
|
||||
return {
|
||||
prefixLen: previous.length,
|
||||
suffixLen: 0,
|
||||
middleLen: 0,
|
||||
middle: "",
|
||||
};
|
||||
}
|
||||
|
||||
if (previous.length === 0) {
|
||||
return {
|
||||
prefixLen: 0,
|
||||
suffixLen: 0,
|
||||
middleLen: current.length,
|
||||
middle: current,
|
||||
};
|
||||
}
|
||||
|
||||
if (current.length === 0) {
|
||||
return { prefixLen: 0, suffixLen: 0, middleLen: 0, middle: "" };
|
||||
}
|
||||
|
||||
let prefixLen = 0;
|
||||
const minLen = Math.min(previous.length, current.length);
|
||||
|
||||
while (prefixLen < minLen && previous[prefixLen] === current[prefixLen]) {
|
||||
prefixLen++;
|
||||
}
|
||||
|
||||
let suffixLen = 0;
|
||||
let prevEnd = previous.length - 1;
|
||||
let currEnd = current.length - 1;
|
||||
|
||||
while (
|
||||
suffixLen < minLen - prefixLen &&
|
||||
prevEnd >= prefixLen &&
|
||||
currEnd >= prefixLen &&
|
||||
previous[prevEnd] === current[currEnd]
|
||||
) {
|
||||
suffixLen++;
|
||||
prevEnd--;
|
||||
currEnd--;
|
||||
}
|
||||
|
||||
const middleStart = prefixLen;
|
||||
const middleEnd = current.length - suffixLen;
|
||||
const middle = current.substring(middleStart, middleEnd);
|
||||
|
||||
return { prefixLen, suffixLen, middleLen: middle.length, middle };
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply diff to reconstruct current string from previous.
|
||||
*/
|
||||
export function applyStringDiff(previous: string, diff: StringDiff): string {
|
||||
const prefix = previous.substring(0, diff.prefixLen);
|
||||
const suffix = previous.substring(previous.length - diff.suffixLen);
|
||||
return prefix + diff.middle + suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate savings percentage from using diff encoding vs full string.
|
||||
*/
|
||||
export function calculateDiffSavings(
|
||||
_previous: string,
|
||||
current: string,
|
||||
diff: StringDiff
|
||||
): number {
|
||||
const fullSize = 1 + current.length;
|
||||
const diffSize = 3 + diff.middleLen;
|
||||
return ((fullSize - diffSize) / fullSize) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if diff encoding should be used based on savings threshold.
|
||||
*/
|
||||
export function shouldUseDiff(
|
||||
previous: string,
|
||||
current: string,
|
||||
threshold = 0
|
||||
): boolean {
|
||||
const diff = calculateStringDiff(previous, current);
|
||||
return calculateDiffSavings(previous, current, diff) >= threshold;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
## XOR Encoder
|
||||
|
||||
**Path:** `src/xor-encoder.ts`
|
||||
**Export:** `calculateXorDelta`, `applyXorDelta`, `calculateDeltaBitSize`, `isXorEfficient`, `numberXor`
|
||||
|
||||
### What it does
|
||||
|
||||
XOR-based delta encoding for numeric values. Encodes the difference between values rather than absolute values, which can save space when consecutive values are similar.
|
||||
|
||||
### Key API
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `numberXor(a, b)` | XOR two float64 values byte-by-byte |
|
||||
| `calculateXorDelta(current, previous)` | Compute `current ^ previous` |
|
||||
| `applyXorDelta(previous, delta)` | Reconstruct `current` from `previous ^ delta` |
|
||||
| `calculateDeltaBitSize(delta)` | Count significant bits in delta |
|
||||
| `isXorEfficient(current, previous, maxBits)` | Determine if XOR saves space |
|
||||
|
||||
### Used by
|
||||
|
||||
- `@suaveplan/bitpacker-delta` `DeltaManager` for change detection
|
||||
- Downstream compression pipelines
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* XOR-based delta encoding for numeric values.
|
||||
* Encodes the difference between values rather than absolute values.
|
||||
*/
|
||||
|
||||
/**
|
||||
* XOR two float64 values byte-by-byte.
|
||||
*/
|
||||
export function numberXor(a: number, b: number): number {
|
||||
const aBuf = new ArrayBuffer(8);
|
||||
const bBuf = new ArrayBuffer(8);
|
||||
new DataView(aBuf).setFloat64(0, a, false);
|
||||
new DataView(bBuf).setFloat64(0, b, false);
|
||||
|
||||
const aBytes = new Uint8Array(aBuf);
|
||||
const bBytes = new Uint8Array(bBuf);
|
||||
const result = new Uint8Array(8);
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result[i] = aBytes[i]! ^ bBytes[i]!;
|
||||
}
|
||||
|
||||
return new DataView(result.buffer).getFloat64(0, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate XOR delta between two integers.
|
||||
*/
|
||||
export function calculateXorDelta(current: number, previous: number): number {
|
||||
return (current ^ previous) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct current value from previous and XOR delta.
|
||||
*/
|
||||
export function applyXorDelta(previous: number, delta: number): number {
|
||||
return (previous ^ delta) >>> 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the number of significant bits in a delta value.
|
||||
*/
|
||||
export function calculateDeltaBitSize(delta: number): number {
|
||||
if (delta === 0) return 1;
|
||||
return Math.floor(Math.log2(delta)) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if XOR delta encoding would save space.
|
||||
*/
|
||||
export function isXorEfficient(
|
||||
current: number,
|
||||
previous: number,
|
||||
maxBits: number
|
||||
): boolean {
|
||||
const deltaBits = calculateDeltaBitSize(
|
||||
calculateXorDelta(current, previous)
|
||||
);
|
||||
return deltaBits < maxBits;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
37
packages/foundation/bitpacker/bitpacker-delta/vite.config.ts
Normal file
37
packages/foundation/bitpacker/bitpacker-delta/vite.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/bitpacker",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
"tslib",
|
||||
"zod",
|
||||
"@opentelemetry/api",
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/types",
|
||||
/^node:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user