// In-memory reader for the dafsa DAWG binary format, ported byte-for-byte from // github.com/iliadenisov/dafsa (bits.go / disk.go / dawg.go). It answers the one // query the local move validator needs: given a word as alphabet-index bytes, is // it stored in the dictionary and at what insertion index. // // The Go reader treats the file as a big-endian, MSB-first bit stream. Every // field this reader touches on the lookup path is at most ~31 bits wide, so the // bit reader accumulates into a plain JavaScript number (no BigInt) and stays // exact. Faithfulness to the Go reader is enforced by dawg.parity.test.ts, which // checks indexOf against the authoritative Go output across the whole dictionary. // bitsLen returns the number of bits needed to represent x (Go math/bits.Len). function bitsLen(x: number): number { return x === 0 ? 0 : 32 - Math.clz32(x); } /** * Dawg is a read-only view over a serialized dafsa dictionary held in memory. * Construct it from the raw bytes of a `.dawg` file, then call {@link indexOf}. */ export class Dawg { private readonly bytes: Uint8Array; private p = 0; // current position, in bits private readonly cbits: number; private readonly abits: number; private readonly numEdges: number; private readonly wbits: number; private readonly firstNodeOffset: number; private readonly hasEmptyWord: boolean; /** Number of words stored (dafsa NumAdded). */ readonly numAdded: number; /** Number of graph nodes (dafsa NumNodes). */ readonly numNodes: number; // Scratch for the last edge resolved by getEdge, mirroring dafsa's edgeEnd + // final flag. Reused to keep the lookup path allocation-free. private eNode = 0; private eCount = 0; private eFinal = false; constructor(bytes: Uint8Array) { this.bytes = bytes; // Reject anything that is not a serialized dawg — e.g. an HTML error page or a // truncated download routed here by mistake. The 32-bit big-endian header size is // the total byte length; if it does not match, the blob is not a dawg. Without // this guard a non-dawg blob would parse into a bogus reader that silently reports // every word as missing (so the caller must throw here to fall back to the network). const declaredSize = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0; if (declaredSize !== bytes.length) { throw new Error(`dawg: not a dawg blob (size header ${declaredSize} != ${bytes.length} bytes)`); } // Header: 32-bit size (skipped — the whole file is already in memory), then // cbits, abits, the language code string, and the word/node/edge counts. this.p = 32; this.cbits = this.readBits(8); this.abits = this.readBits(8); this.skipString(); // language code — not needed to walk index bytes const numAdded = this.readUnsigned(); const numNodes = this.readUnsigned(); this.numEdges = this.readUnsigned(); this.firstNodeOffset = this.p; this.hasEmptyWord = this.readBits(1) === 1; this.numAdded = numAdded; this.numNodes = numNodes; this.wbits = bitsLen(numAdded); } /** * indexOf returns the insertion index of the given word (as alphabet-index * bytes), or -1 if the word was never added. Mirrors dafsa IndexOfB. */ indexOf(word: ArrayLike): number { let skipped = 0; let node = 0; // rootNode let final = this.hasEmptyWord; for (let i = 0; i < word.length; i++) { if (!this.getEdge(node, word[i])) { return -1; } node = this.eNode; final = this.eFinal; skipped += this.eCount; } return final ? skipped : -1; } /** has reports whether the word (as alphabet-index bytes) is in the dictionary. */ has(word: ArrayLike): boolean { return this.indexOf(word) >= 0; } // getEdge resolves the outgoing edge for ch from the node at the given bit // offset. On success it fills eNode/eCount/eFinal and returns true. Mirrors // dafsa (*dawg).getEdge. private getEdge(node: number, ch: number): boolean { if (this.numEdges <= 0) { return false; } const pos = node === 0 ? this.firstNodeOffset : node; this.p = pos; const nodeFinal = this.readBits(1); const fallthrough = this.readBits(1); if (fallthrough === 1) { const edgeCh = this.readBits(this.cbits); if (edgeCh === ch) { this.eCount = nodeFinal; this.eNode = this.p; // the fallthrough target is the physically next node this.eFinal = this.readBits(1) === 1; return true; } return false; } const singleEdge = this.readBits(1); let numEdges = 1; const nskiplen = bitsLen(this.wbits); let nskip = 0; if (singleEdge !== 1) { numEdges = this.readUnsigned(); nskip = this.readBits(nskiplen); } const base = this.p; // bit offset of the first edge record const recordBits = this.cbits + nskip + this.abits; // Binary search over the edges, which are sorted ascending by character. let high = numEdges; let low = -1; while (high - low > 1) { const probe = (high + low) >> 1; // The first edge omits its (zero) skip field, so every later record is // shifted back by nskip bits. let seekTo = base + probe * recordBits; if (probe > 0) { seekTo -= nskip; } this.p = seekTo; const edgeCh = this.readBits(this.cbits); const cmp = edgeCh - ch; if (cmp === 0) { this.eCount = probe > 0 ? this.readBits(nskip) : nodeFinal; this.eNode = this.readBits(this.abits); this.p = this.eNode; this.eFinal = this.readBits(1) === 1; return true; } else if (cmp < 0) { low = probe; } else { high = probe; } } return false; } // readBits reads n (<= 32) bits MSB-first from the current position and // advances it. Mirrors dafsa (*bitSeeker).ReadBits for the widths on this path. private readBits(n: number): number { let result = 0; let p = this.p; const bytes = this.bytes; while (n > 0) { const byteIndex = p >>> 3; const bitInByte = p & 7; const avail = 8 - bitInByte; const take = avail < n ? avail : n; const shift = avail - take; const chunk = (bytes[byteIndex] >>> shift) & ((1 << take) - 1); result = result * (1 << take) + chunk; p += take; n -= take; } this.p = p; return result; } // readUnsigned reads a dafsa "7code" varint (7 payload bits per byte, high bit // continues). Mirrors dafsa readUnsigned. private readUnsigned(): number { let result = 0; for (;;) { const d = this.readBits(8); result = result * 128 + (d & 0x7f); if ((d & 0x80) === 0) { break; } } return result; } // skipString advances past a 7code-length-prefixed byte string. private skipString(): void { const n = this.readUnsigned(); this.p += n * 8; } }