c334a9d7b7
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m38s
First engine-first step of PWA offline mode (Phase A): the client-side move generator — the "robot brain" a local vs_ai game will run on-device — with no runtime wiring yet (Phase B). - dawg.ts: add the step-by-step cursor (root/final/next/arcs), a faithful port of dafsa traverse.go over the reader's existing bitstream. - generate.ts: the Appel-Jacobson generator (leftPart/extendRight + cross-sets + counts-rack + board transpose + moveKey ranking), reusing the cursor and validate.ts evaluate/connected. A cross-set LetterSet is a Uint8Array, so the 33-letter Russian alphabet (index 32) is exact under JS bit ops. - validate.ts: export connected for the generator's connectivity filter. - backend/cmd/movegen: dev tool building small sample dictionaries and emitting golden move-generation fixtures from the real Go solver (EN + RU). - tests: dawg.cursor.test.ts (enumeration bijection vs indexOf) and generate.parity.test.ts (7/7 vs the Go solver: empty board, mid-game, blank, single-word rule, Russian index-32 cross-set). The committed EN sample also unblocks the existing skipped dawg.parity.test.ts once wired with DICT_* in CI. Pure additive library code; no runtime behavior change.
282 lines
9.5 KiB
TypeScript
282 lines
9.5 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;
|
|
}
|
|
|
|
// --- Step-by-step traversal (the move generator's primitive) ---------------
|
|
//
|
|
// A `Node` is a bit offset into the graph; 0 denotes the root (which resolves
|
|
// to firstNodeOffset). These mirror dafsa's traverse.go Cursor (Root/Final/
|
|
// Next/Arcs) over the same bitstream this reader already decodes, so the ported
|
|
// generator can drive the automaton one transition at a time. Single-threaded
|
|
// JS shares this reader's position across calls; every method re-seeks to its
|
|
// node on entry, and arcs brackets the callback with a save/restore, so nested
|
|
// use during a walk is safe. Mirrors dafsa (*Cursor).
|
|
|
|
/** root returns the start state of the automaton. */
|
|
root(): number {
|
|
return 0;
|
|
}
|
|
|
|
/** final reports whether node is an accepting state (a stored word ends there). */
|
|
final(node: number): boolean {
|
|
if (this.numEdges <= 0) {
|
|
return this.hasEmptyWord && node === 0;
|
|
}
|
|
this.p = node === 0 ? this.firstNodeOffset : node;
|
|
return this.readBits(1) === 1;
|
|
}
|
|
|
|
/**
|
|
* next follows the edge labelled ch (an alphabet index) from node, returning the
|
|
* destination node, or -1 when no such edge exists.
|
|
*/
|
|
next(node: number, ch: number): number {
|
|
return this.getEdge(node, ch) ? this.eNode : -1;
|
|
}
|
|
|
|
/**
|
|
* arcs calls fn for each out-edge of node in ascending label order, passing the
|
|
* edge's label, its destination node and whether that destination is accepting.
|
|
* It stops early if fn returns false. Mirrors dafsa (*Cursor).Arcs.
|
|
*/
|
|
arcs(node: number, fn: (label: number, dest: number, final: boolean) => boolean): void {
|
|
if (this.numEdges <= 0) {
|
|
return;
|
|
}
|
|
this.p = node === 0 ? this.firstNodeOffset : node;
|
|
this.readBits(1); // node final flag — not needed here
|
|
const fallthrough = this.readBits(1);
|
|
|
|
if (fallthrough === 1) {
|
|
const label = this.readBits(this.cbits);
|
|
// The reader now sits at the destination node, whose first bit is its final flag.
|
|
const dest = this.p;
|
|
const final = this.readBits(1) === 1;
|
|
fn(label, dest, final);
|
|
return;
|
|
}
|
|
|
|
const nskiplen = bitsLen(this.wbits);
|
|
let nskip = 0;
|
|
let numEdges = 1;
|
|
if (this.readBits(1) !== 1) {
|
|
// not a single edge
|
|
numEdges = this.readUnsigned();
|
|
nskip = this.readBits(nskiplen);
|
|
}
|
|
|
|
for (let i = 0; i < numEdges; i++) {
|
|
const label = this.readBits(this.cbits);
|
|
if (i > 0) {
|
|
this.readBits(nskip); // per-edge skip count, unused for traversal
|
|
}
|
|
const dest = this.readBits(this.abits);
|
|
const resume = this.p;
|
|
this.p = dest;
|
|
const final = this.readBits(1) === 1;
|
|
if (!fn(label, dest, final)) {
|
|
return;
|
|
}
|
|
this.p = resume;
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|