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
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.
203 lines
6.8 KiB
TypeScript
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;
|
|
}
|
|
}
|