Files
scrabble-game/ui/src/lib/dict/dawg.ts
T
Ilia Denisov 2776ef83c2
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
fix(deploy): route /dict through caddy to the gateway (local eval)
The edge caddy @gateway matcher was missing /dict/*, so the client's dictionary
fetch fell to the static landing catch-all: it received a non-dawg blob (200 OK),
which the reader parsed into a bogus finder that reports every word missing — a
valid first move showed as illegal with no network fallback. Add /dict/* to the
gateway route.

Hardening + the cross-test that would have caught it:
- reader: reject a blob whose 32-bit size header != its byte length, so a
  non-dawg page or a truncated download now throws and the loader falls back to
  the network preview instead of silently validating against garbage.
- eval.parity: an adapter conformance suite that drives evaluateLocal through the
  real letter-space path (the server alphabet table, a letter board and letter
  placements) against the engine — the algorithm-level validate.parity did not
  exercise the adapter. validategen now emits the alphabet table for it.
- a CI deploy probe asserts {edge}/dict reaches the gateway (401), not the landing.
- the DebugPanel reports the feature flag, the bad-connection breaker and the
  cached dictionaries with their sizes; its reset also clears the dict cache.
2026-07-01 20:55:20 +02:00

203 lines
6.8 KiB
TypeScript

// 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>): 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<number>): 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;
}
}