Revised checkbox-tree functions and related

This commit is contained in:
2026-07-12 21:46:13 -05:00
parent 5f450de1c7
commit 1d92cf4473
5 changed files with 571 additions and 305 deletions
+206
View File
@@ -0,0 +1,206 @@
// Manifest parsing and binary codecs. This module has no DOM dependencies.
export const SERIALIZATION_VERSION = 1;
export const PATH_DELIMITER = '>';
const STATE_ENCODINGS = new Set(['adaptive', 'dense', 'tiered']);
export function loadManifest(source) {
let records;
let schema = 1;
if (typeof source === 'string') {
records = source.split(/\r?\n/)
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'))
.map(line => line === '!deleted' ? null : line);
} else if (source && typeof source === 'object' && Array.isArray(source.nodes)) {
schema = source.schema;
records = source.nodes;
} else if (source && Array.isArray(source.slots)) {
return normalizeManifest(source.schema, source.slots.map(slot => slot?.deleted ? null : slot?.path));
} else {
throw new TypeError('CheckboxTree manifest must be text or an object with a nodes array.');
}
return normalizeManifest(schema, records);
}
function normalizeManifest(schema, records) {
if (schema !== 1) {
throw new Error(`Unsupported CheckboxTree manifest schema: ${schema}`);
}
const pathToSlot = new Map();
const slots = records.map((record, index) => {
if (record === null || record === '!deleted') {
return { path: null, deleted: true };
}
if (typeof record !== 'string' || !record || record.trim() !== record) {
throw new TypeError(`Invalid CheckboxTree manifest entry at slot ${index}.`);
}
if (pathToSlot.has(record)) {
throw new Error(`Duplicate CheckboxTree manifest path: ${record}`);
}
pathToSlot.set(record, index);
return { path: record, deleted: false };
});
return { schema: 1, slots, pathToSlot };
}
export function setBit(bytes, bitIndex, value) {
const byteIndex = Math.floor(bitIndex / 8);
const mask = 1 << (bitIndex % 8);
if (value) bytes[byteIndex] |= mask;
else bytes[byteIndex] &= ~mask;
}
export function getBit(bytes, bitIndex) {
const byteIndex = Math.floor(bitIndex / 8);
return byteIndex < bytes.length && (bytes[byteIndex] & (1 << (bitIndex % 8))) !== 0;
}
export function bytesToBase64Url(bytes) {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
export function base64UrlToBytes(value, maximumLength = Infinity) {
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
throw new Error('Invalid Base64URL value.');
}
if (Number.isFinite(maximumLength) && value.length > Math.ceil(maximumLength * 4 / 3) + 2) {
throw new Error('Encoded tree state exceeds the manifest size.');
}
const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
const binary = atob(base64 + '='.repeat((4 - base64.length % 4) % 4));
if (binary.length > maximumLength) throw new Error('Encoded tree state exceeds the manifest size.');
return Uint8Array.from(binary, character => character.charCodeAt(0));
}
function trimTrailingZeroBytes(bytes) {
let end = bytes.length;
while (end && bytes[end - 1] === 0) end--;
return bytes.slice(0, end);
}
function encodeUnsignedVarint(value) {
const bytes = [];
do {
let byte = value & 0x7f;
value = Math.floor(value / 128);
if (value) byte |= 0x80;
bytes.push(byte);
} while (value);
return bytes;
}
function decodeUnsignedVarint(bytes, offset) {
let value = 0;
let multiplier = 1;
for (let index = offset; index < bytes.length && index < offset + 8; index++) {
const byte = bytes[index];
value += (byte & 0x7f) * multiplier;
if ((byte & 0x80) === 0) return { value, offset: index + 1 };
multiplier *= 128;
if (!Number.isSafeInteger(value) || !Number.isSafeInteger(multiplier)) break;
}
throw new Error('Invalid adaptive tree-state integer.');
}
function encodeSparseIndexes(indexes) {
const bytes = [];
let previous = -1;
for (const index of indexes) {
bytes.push(...encodeUnsignedVarint(index - previous - 1));
previous = index;
}
return bytes;
}
function decodeSparseIndexes(bytes, offset, limit) {
const indexes = new Set();
let previous = -1;
while (offset < bytes.length) {
const decoded = decodeUnsignedVarint(bytes, offset);
const index = previous + decoded.value + 1;
if (index >= limit) throw new Error('Sparse tree state contains an out-of-range slot.');
indexes.add(index);
previous = index;
offset = decoded.offset;
}
return indexes;
}
export function encodeAdaptiveBitset(values, slotCount) {
const enabled = [];
const disabled = [];
const denseBytes = new Uint8Array(Math.ceil(slotCount / 8));
for (const [slot, value] of values) {
(value ? enabled : disabled).push(slot);
setBit(denseBytes, slot, value);
}
enabled.sort((left, right) => left - right);
disabled.sort((left, right) => left - right);
if (enabled.length === 0) return new Uint8Array();
const candidates = [
Uint8Array.from([0, ...trimTrailingZeroBytes(denseBytes)]),
Uint8Array.from([1, ...encodeSparseIndexes(enabled)]),
Uint8Array.from([2, ...encodeUnsignedVarint(slotCount), ...encodeSparseIndexes(disabled)])
];
return candidates.reduce((shortest, candidate) =>
candidate.length < shortest.length ? candidate : shortest
);
}
export function decodeAdaptiveBitset(bytes, slotCount) {
if (bytes.length === 0) return () => false;
const mode = bytes[0];
if (mode === 0) {
const dense = bytes.slice(1);
if (dense.length > Math.ceil(slotCount / 8)) {
throw new Error('Dense tree state exceeds the manifest size.');
}
return slot => getBit(dense, slot);
}
if (mode === 1) {
const enabled = decodeSparseIndexes(bytes, 1, slotCount);
return slot => enabled.has(slot);
}
if (mode === 2) {
const coverage = decodeUnsignedVarint(bytes, 1);
if (coverage.value > slotCount) throw new Error('Tree-state coverage exceeds the manifest size.');
const disabled = decodeSparseIndexes(bytes, coverage.offset, coverage.value);
return slot => slot < coverage.value && !disabled.has(slot);
}
throw new Error('Unknown adaptive tree-state mode.');
}
export function encodeDenseBitset(values, slotCount) {
const bytes = new Uint8Array(Math.ceil(slotCount / 8));
for (const [slot, value] of values) setBit(bytes, slot, value);
return trimTrailingZeroBytes(bytes);
}
export function decodeDenseBitset(bytes, slotCount) {
if (bytes.length > Math.ceil(slotCount / 8)) {
throw new Error('Dense tree state exceeds the manifest size.');
}
return slot => getBit(bytes, slot);
}
export function maximumEncodedLength(slotCount) {
return Math.max(Math.ceil(slotCount / 8) + 1, slotCount * 2 + 10);
}
export function assertStateEncoding(encoding) {
if (!STATE_ENCODINGS.has(encoding)) {
throw new Error(`Unsupported CheckboxTree state encoding: ${encoding}`);
}
}
export function asManifestSource(manifest) {
return manifest?.pathToSlot instanceof Map ? manifest : loadManifest(manifest);
}