@mochi.js/consistency
Advanced tools
| /** | ||
| * DAG validation unit tests — acyclicity, duplicate-output detection, and | ||
| * topo-ordering correctness against synthetic rule lists. | ||
| */ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { validateAndOrder } from "../dag"; | ||
| import { DuplicateOutputError, RuleDagCycleError } from "../errors"; | ||
| import type { Rule } from "../rule"; | ||
| const passthrough = (id: string, inputs: readonly string[], output: string): Rule => ({ | ||
| id, | ||
| description: `synthetic rule ${id}`, | ||
| inputs, | ||
| output, | ||
| derive: () => null, | ||
| }); | ||
| describe("validateAndOrder", () => { | ||
| it("returns rules in topological order", () => { | ||
| const r1 = passthrough("r1", [], "a"); | ||
| const r2 = passthrough("r2", ["a"], "b"); | ||
| const r3 = passthrough("r3", ["b"], "c"); | ||
| const plan = validateAndOrder([r3, r1, r2]); | ||
| expect(plan.order.map((r) => r.id)).toEqual(["r1", "r2", "r3"]); | ||
| }); | ||
| it("preserves declaration order for tied rules (stable tie-break)", () => { | ||
| const r1 = passthrough("r1", [], "a"); | ||
| const r2 = passthrough("r2", [], "b"); | ||
| const r3 = passthrough("r3", [], "c"); | ||
| const plan = validateAndOrder([r1, r2, r3]); | ||
| expect(plan.order.map((r) => r.id)).toEqual(["r1", "r2", "r3"]); | ||
| }); | ||
| it("raises RuleDagCycleError on a 2-cycle", () => { | ||
| const r1 = passthrough("r1", ["b"], "a"); | ||
| const r2 = passthrough("r2", ["a"], "b"); | ||
| expect(() => validateAndOrder([r1, r2])).toThrow(RuleDagCycleError); | ||
| }); | ||
| it("raises RuleDagCycleError on a longer cycle and reports the path", () => { | ||
| const r1 = passthrough("r1", ["c"], "a"); | ||
| const r2 = passthrough("r2", ["a"], "b"); | ||
| const r3 = passthrough("r3", ["b"], "c"); | ||
| let caught: unknown; | ||
| try { | ||
| validateAndOrder([r1, r2, r3]); | ||
| } catch (err) { | ||
| caught = err; | ||
| } | ||
| expect(caught).toBeInstanceOf(RuleDagCycleError); | ||
| expect((caught as RuleDagCycleError).cycle.length).toBeGreaterThanOrEqual(2); | ||
| }); | ||
| it("raises DuplicateOutputError when two rules write the same path", () => { | ||
| const r1 = passthrough("r1", [], "shared"); | ||
| const r2 = passthrough("r2", [], "shared"); | ||
| expect(() => validateAndOrder([r1, r2])).toThrow(DuplicateOutputError); | ||
| }); | ||
| it("inputs that don't match any rule output don't create edges (treated as profile-leaves)", () => { | ||
| const r1 = passthrough("r1", ["external.input"], "a"); | ||
| const plan = validateAndOrder([r1]); | ||
| expect(plan.order.map((r) => r.id)).toEqual(["r1"]); | ||
| }); | ||
| }); |
| /** | ||
| * Determinism contract — the load-bearing guarantee of the consistency | ||
| * engine (PLAN.md I-5). Two derivations of the same `(profile, seed)` | ||
| * must produce byte-identical Matrices, modulo `derivedAt`. | ||
| */ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { deriveMatrix } from "../derive"; | ||
| import { MAC_M2_CHROME, WIN11_EDGE } from "./fixture"; | ||
| /** JSON-serialize a matrix with `derivedAt` stripped. */ | ||
| function matrixSignature(m: ReturnType<typeof deriveMatrix>): string { | ||
| const { derivedAt: _drop, ...rest } = m; | ||
| return JSON.stringify(rest); | ||
| } | ||
| describe("deriveMatrix — determinism contract", () => { | ||
| it("(profile, seed) yields byte-identical output 100x (excluding derivedAt)", () => { | ||
| const reference = matrixSignature(deriveMatrix(MAC_M2_CHROME, "deterministic-seed")); | ||
| for (let i = 0; i < 100; i++) { | ||
| const sig = matrixSignature(deriveMatrix(MAC_M2_CHROME, "deterministic-seed")); | ||
| expect(sig).toBe(reference); | ||
| } | ||
| }); | ||
| it("different seeds → different matrices", () => { | ||
| const a = matrixSignature(deriveMatrix(MAC_M2_CHROME, "alpha")); | ||
| const b = matrixSignature(deriveMatrix(MAC_M2_CHROME, "beta")); | ||
| expect(a).not.toBe(b); | ||
| }); | ||
| it("different profiles → different matrices (cross-profile isolation)", () => { | ||
| const a = matrixSignature(deriveMatrix(MAC_M2_CHROME, "shared-seed")); | ||
| const b = matrixSignature(deriveMatrix(WIN11_EDGE, "shared-seed")); | ||
| expect(a).not.toBe(b); | ||
| }); | ||
| it("matrix round-trips through JSON losslessly (excluding derivedAt)", () => { | ||
| const m = deriveMatrix(MAC_M2_CHROME, "round-trip"); | ||
| const roundTripped = JSON.parse(JSON.stringify(m)); | ||
| expect(roundTripped).toEqual(m); | ||
| }); | ||
| it("derivedAt is the only field that varies between runs", () => { | ||
| const a = deriveMatrix(MAC_M2_CHROME, "stable-seed"); | ||
| const b = deriveMatrix(MAC_M2_CHROME, "stable-seed"); | ||
| expect(a.derivedAt).toBeDefined(); | ||
| expect(b.derivedAt).toBeDefined(); | ||
| // Even if they happen to land in the same ms (ISO timestamps have | ||
| // ms precision), the signature comparison above is the main contract. | ||
| expect(matrixSignature(a)).toBe(matrixSignature(b)); | ||
| }); | ||
| it("seed-driven fields actually differ between seeds (R-019, R-023)", () => { | ||
| const a = deriveMatrix(MAC_M2_CHROME, "seed-A"); | ||
| const b = deriveMatrix(MAC_M2_CHROME, "seed-B"); | ||
| expect(a.uaCh["seed-derived-noise"]).not.toBe(b.uaCh["seed-derived-noise"]); | ||
| expect(a.uaCh["ua-build-hash"]).not.toBe(b.uaCh["ua-build-hash"]); | ||
| }); | ||
| }); |
| /** | ||
| * Shared test fixture — a Mac M2 Chrome profile used by the unit tests. | ||
| * Kept here so the per-rule + determinism tests share one canonical input. | ||
| */ | ||
| import type { ProfileV1 } from "../generated/profile"; | ||
| export const MAC_M2_CHROME: ProfileV1 = { | ||
| id: "mac-m2-chrome-stable", | ||
| version: "1.0.0", | ||
| engine: "chromium", | ||
| browser: { name: "chrome", channel: "stable", minVersion: "131", maxVersion: "133" }, | ||
| os: { name: "macos", version: "14", arch: "arm64" }, | ||
| device: { | ||
| vendor: "apple", | ||
| model: "mac14,2", | ||
| cpuFamily: "apple-silicon-m2", | ||
| cores: 8, | ||
| memoryGB: 16, | ||
| }, | ||
| display: { width: 2560, height: 1664, dpr: 2, colorDepth: 30, pixelDepth: 30 }, | ||
| gpu: { | ||
| vendor: "Apple", | ||
| renderer: "Apple M2", | ||
| webglUnmaskedVendor: "Google Inc. (Apple)", | ||
| webglUnmaskedRenderer: "ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)", | ||
| webglMaxTextureSize: 16384, | ||
| webglMaxColorAttachments: 8, | ||
| webglExtensions: [], | ||
| }, | ||
| audio: { contextSampleRate: 44100, audioWorkletLatency: 0.0058, destinationMaxChannelCount: 2 }, | ||
| fonts: { family: "macos-system-arial-pack", list: ["Arial"] }, | ||
| timezone: "America/Los_Angeles", | ||
| locale: "en-US", | ||
| languages: ["en-US", "en"], | ||
| behavior: { hand: "right", tremor: 0.18, wpm: 65, scrollStyle: "smooth" }, | ||
| wreqPreset: "chrome_131_macos", | ||
| userAgent: | ||
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", | ||
| uaCh: { "sec-ch-ua-platform": '"macOS"' }, | ||
| entropyBudget: { fixed: ["gpu.vendor"], perSeed: ["display.width"] }, | ||
| }; | ||
| export const WIN11_EDGE: ProfileV1 = { | ||
| id: "win11-edge-stable", | ||
| version: "1.0.0", | ||
| engine: "chromium", | ||
| browser: { name: "edge", channel: "stable", minVersion: "131", maxVersion: "133" }, | ||
| os: { name: "windows", version: "11", arch: "x64" }, | ||
| device: { | ||
| vendor: "dell", | ||
| model: "xps-15-9530", | ||
| cpuFamily: "intel-core-i7", | ||
| cores: 16, | ||
| memoryGB: 32, | ||
| }, | ||
| display: { width: 1920, height: 1200, dpr: 1, colorDepth: 24, pixelDepth: 24 }, | ||
| gpu: { | ||
| vendor: "Intel Inc.", | ||
| renderer: "Intel Iris Xe Graphics", | ||
| webglUnmaskedVendor: "Google Inc. (Intel Inc.)", | ||
| webglUnmaskedRenderer: "ANGLE (Intel Inc., Intel Iris Xe Graphics, OpenGL 4.1)", | ||
| webglMaxTextureSize: 16384, | ||
| webglMaxColorAttachments: 8, | ||
| webglExtensions: [], | ||
| }, | ||
| audio: { contextSampleRate: 48000, audioWorkletLatency: 0.0102, destinationMaxChannelCount: 2 }, | ||
| fonts: { family: "win11-baseline", list: ["Segoe UI"] }, | ||
| timezone: "America/New_York", | ||
| locale: "en-US", | ||
| languages: ["en-US", "en"], | ||
| behavior: { hand: "right", tremor: 0.2, wpm: 60, scrollStyle: "smooth" }, | ||
| wreqPreset: "edge_131_windows", | ||
| userAgent: | ||
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", | ||
| uaCh: {}, | ||
| entropyBudget: { fixed: [], perSeed: [] }, | ||
| }; |
| /** | ||
| * Unit tests for the dotted-path helpers used by the rule runner. | ||
| */ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { type DeepRecord, getByPath, isValidPath, setByPath } from "../path"; | ||
| describe("dotted-path helpers", () => { | ||
| it("getByPath retrieves nested values", () => { | ||
| const obj: DeepRecord = { a: { b: { c: 42 } } }; | ||
| expect(getByPath(obj, "a.b.c")).toBe(42); | ||
| expect(getByPath(obj, "a.b")).toEqual({ c: 42 }); | ||
| expect(getByPath(obj, "a.x")).toBeUndefined(); | ||
| }); | ||
| it("getByPath returns undefined for empty path", () => { | ||
| const obj: DeepRecord = { a: 1 }; | ||
| expect(getByPath(obj, "")).toBeUndefined(); | ||
| }); | ||
| it("setByPath writes nested values, creating intermediates", () => { | ||
| const obj: DeepRecord = {}; | ||
| setByPath(obj, "a.b.c", 7); | ||
| expect(obj).toEqual({ a: { b: { c: 7 } } }); | ||
| }); | ||
| it("setByPath overwrites an existing leaf", () => { | ||
| const obj: DeepRecord = { a: { b: 1 } }; | ||
| setByPath(obj, "a.b", 99); | ||
| expect(obj.a).toEqual({ b: 99 }); | ||
| }); | ||
| it("setByPath throws when attempting to descend through a primitive", () => { | ||
| const obj: DeepRecord = { a: "leaf" }; | ||
| expect(() => setByPath(obj, "a.b", 1)).toThrow(); | ||
| }); | ||
| it("setByPath throws on empty path", () => { | ||
| const obj: DeepRecord = {}; | ||
| expect(() => setByPath(obj, "", 1)).toThrow(); | ||
| }); | ||
| it("isValidPath accepts well-formed paths and rejects malformed ones", () => { | ||
| expect(isValidPath("a")).toBe(true); | ||
| expect(isValidPath("a.b.c")).toBe(true); | ||
| expect(isValidPath("a.b-c")).toBe(true); | ||
| expect(isValidPath("a.b_c")).toBe(true); | ||
| expect(isValidPath("")).toBe(false); | ||
| expect(isValidPath("a..b")).toBe(false); | ||
| expect(isValidPath("a.b.")).toBe(false); | ||
| expect(isValidPath("a.b!c")).toBe(false); | ||
| }); | ||
| }); |
| /** | ||
| * PRNG unit tests — determinism, isolation, and basic distribution sanity. | ||
| * | ||
| * These are the floor of the consistency engine's correctness story: if the | ||
| * PRNG isn't deterministic, nothing downstream is. | ||
| */ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { deriveSeedState, seedToPrng } from "../prng/seed"; | ||
| import { makeXoshiro256ss } from "../prng/xoshiro256ss"; | ||
| describe("xoshiro256** PRNG", () => { | ||
| it("produces the same sequence for the same state", () => { | ||
| const stateA = [1n, 2n, 3n, 4n] as const; | ||
| const stateB = [1n, 2n, 3n, 4n] as const; | ||
| const a = makeXoshiro256ss(stateA); | ||
| const b = makeXoshiro256ss(stateB); | ||
| for (let i = 0; i < 16; i++) { | ||
| expect(a.nextU64()).toBe(b.nextU64()); | ||
| } | ||
| }); | ||
| it("produces different sequences for differing states", () => { | ||
| const a = makeXoshiro256ss([1n, 2n, 3n, 4n]); | ||
| const b = makeXoshiro256ss([1n, 2n, 3n, 5n]); | ||
| let divergedAt = -1; | ||
| for (let i = 0; i < 8; i++) { | ||
| if (a.nextU64() !== b.nextU64()) { | ||
| divergedAt = i; | ||
| break; | ||
| } | ||
| } | ||
| expect(divergedAt).toBeGreaterThanOrEqual(0); | ||
| }); | ||
| it("rejects the all-zero state", () => { | ||
| expect(() => makeXoshiro256ss([0n, 0n, 0n, 0n])).toThrow(/all zero/); | ||
| }); | ||
| it("nextFloat01 falls strictly in [0, 1)", () => { | ||
| const prng = makeXoshiro256ss([42n, 43n, 44n, 45n]); | ||
| for (let i = 0; i < 256; i++) { | ||
| const v = prng.nextFloat01(); | ||
| expect(v).toBeGreaterThanOrEqual(0); | ||
| expect(v).toBeLessThan(1); | ||
| } | ||
| }); | ||
| it("nextIntInclusive respects bounds", () => { | ||
| const prng = makeXoshiro256ss([10n, 20n, 30n, 40n]); | ||
| for (let i = 0; i < 256; i++) { | ||
| const v = prng.nextIntInclusive(5, 9); | ||
| expect(v).toBeGreaterThanOrEqual(5); | ||
| expect(v).toBeLessThanOrEqual(9); | ||
| } | ||
| }); | ||
| it("nextIntInclusive throws on inverted bounds", () => { | ||
| const prng = makeXoshiro256ss([10n, 20n, 30n, 40n]); | ||
| expect(() => prng.nextIntInclusive(10, 5)).toThrow(); | ||
| }); | ||
| it("pick selects a member; throws on empty array", () => { | ||
| const prng = makeXoshiro256ss([10n, 20n, 30n, 40n]); | ||
| const choices = [1, 2, 3, 4, 5] as const; | ||
| for (let i = 0; i < 32; i++) { | ||
| const v = prng.pick(choices); | ||
| expect(choices).toContain(v); | ||
| } | ||
| expect(() => prng.pick([])).toThrow(); | ||
| }); | ||
| it("nextHex produces hex string of the requested byte length", () => { | ||
| const prng = makeXoshiro256ss([1n, 2n, 3n, 4n]); | ||
| expect(prng.nextHex(16)).toMatch(/^[0-9a-f]{32}$/); | ||
| expect(prng.nextHex(4).length).toBe(8); | ||
| expect(prng.nextHex(12).length).toBe(24); | ||
| }); | ||
| it("distribution sanity: 1k draws don't all collide in any 8-bit bin", () => { | ||
| // Weak test, but catches obvious bugs (e.g. PRNG returning a constant). | ||
| const prng = makeXoshiro256ss([0xfeedfacecafebeefn, 0xdeadbeefn, 0xbabec0den, 0x1n]); | ||
| const buckets = new Array<number>(256).fill(0); | ||
| for (let i = 0; i < 1000; i++) { | ||
| const lowByte = Number(prng.nextU64() & 0xffn); | ||
| buckets[lowByte] = (buckets[lowByte] ?? 0) + 1; | ||
| } | ||
| let nonzero = 0; | ||
| for (const b of buckets) if (b > 0) nonzero++; | ||
| // 1000 uniform draws over 256 buckets should fill > 200 of them. | ||
| expect(nonzero).toBeGreaterThan(200); | ||
| }); | ||
| }); | ||
| describe("seed derivation", () => { | ||
| it("same (profileId, seed) → same xoshiro state", () => { | ||
| const a = deriveSeedState("profile-a", "seed-1"); | ||
| const b = deriveSeedState("profile-a", "seed-1"); | ||
| expect(a).toEqual(b); | ||
| }); | ||
| it("different profile.id → different state", () => { | ||
| const a = deriveSeedState("profile-a", "seed-1"); | ||
| const b = deriveSeedState("profile-b", "seed-1"); | ||
| expect(a).not.toEqual(b); | ||
| }); | ||
| it("different seed → different state", () => { | ||
| const a = deriveSeedState("profile-a", "seed-1"); | ||
| const b = deriveSeedState("profile-a", "seed-2"); | ||
| expect(a).not.toEqual(b); | ||
| }); | ||
| it("same (profile, seed) PRNG produces identical first sequence", () => { | ||
| const a = seedToPrng("profile-x", "abc"); | ||
| const b = seedToPrng("profile-x", "abc"); | ||
| for (let i = 0; i < 8; i++) { | ||
| expect(a.nextU64()).toBe(b.nextU64()); | ||
| } | ||
| }); | ||
| }); |
| /** | ||
| * Per-rule unit tests — each v0.2 rule gets a golden assertion driving the | ||
| * relational lock for the canonical Mac M2 + Win11 profiles. The tests | ||
| * exercise the rules through the full `deriveMatrix` pipeline rather than | ||
| * by calling `rule.derive` directly, so the integration is what's locked. | ||
| */ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { deriveMatrix } from "../derive"; | ||
| import { MAC_M2_CHROME, WIN11_EDGE } from "./fixture"; | ||
| const SEED = "rule-test-seed"; | ||
| describe("rules — v0.2 ruleset (golden lock)", () => { | ||
| const macMatrix = deriveMatrix(MAC_M2_CHROME, SEED); | ||
| const winMatrix = deriveMatrix(WIN11_EDGE, SEED); | ||
| it("R-001: webgl unmasked vendor wraps device vendor in 'Google Inc. (...)'", () => { | ||
| expect(macMatrix.gpu.webglUnmaskedVendor).toBe("Google Inc. (Apple)"); | ||
| expect(winMatrix.gpu.webglUnmaskedVendor).toBe("Google Inc. (Intel Inc.)"); | ||
| }); | ||
| it("R-002: webgl unmasked renderer wraps in ANGLE prefix", () => { | ||
| expect(macMatrix.gpu.webglUnmaskedRenderer).toBe( | ||
| "ANGLE (Apple, ANGLE Metal Renderer: Apple M2, Unspecified Version)", | ||
| ); | ||
| expect(winMatrix.gpu.webglUnmaskedRenderer).toBe( | ||
| "ANGLE (Intel, Intel Iris Xe Graphics, OpenGL 4.1)", | ||
| ); | ||
| }); | ||
| it("R-003: max texture size lookup", () => { | ||
| expect(macMatrix.gpu.webglMaxTextureSize).toBe(16384); | ||
| expect(winMatrix.gpu.webglMaxTextureSize).toBe(16384); | ||
| }); | ||
| it("R-004: userAgent contains the seeded build version", () => { | ||
| expect(macMatrix.userAgent).toContain("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"); | ||
| expect(macMatrix.userAgent).toMatch(/Chrome\/131\.0\.\d+\.\d+/); | ||
| expect(winMatrix.userAgent).toContain("Windows NT 10.0; Win64; x64"); | ||
| expect(winMatrix.userAgent).toMatch(/Edg\/131\.0\.\d+\.\d+/); | ||
| }); | ||
| it("R-005: sec-ch-ua brand list — branded, GREASE (pinned v=8), Chromium", () => { | ||
| expect(macMatrix.uaCh["sec-ch-ua"]).toBe( | ||
| '"Google Chrome";v="131", "Not.A/Brand";v="8", "Chromium";v="131"', | ||
| ); | ||
| expect(winMatrix.uaCh["sec-ch-ua"]).toBe( | ||
| '"Microsoft Edge";v="131", "Not.A/Brand";v="8", "Chromium";v="131"', | ||
| ); | ||
| }); | ||
| it("R-006: sec-ch-ua-platform", () => { | ||
| expect(macMatrix.uaCh["sec-ch-ua-platform"]).toBe('"macOS"'); | ||
| expect(winMatrix.uaCh["sec-ch-ua-platform"]).toBe('"Windows"'); | ||
| }); | ||
| it("R-007: sec-ch-ua-platform-version is the quoted OS version", () => { | ||
| expect(macMatrix.uaCh["sec-ch-ua-platform-version"]).toBe('"14"'); | ||
| expect(winMatrix.uaCh["sec-ch-ua-platform-version"]).toBe('"11"'); | ||
| }); | ||
| it("R-008: device.cores passthrough — mirrors profile.device.cores exactly", () => { | ||
| expect(macMatrix.device.cores).toBe(8); | ||
| expect(winMatrix.device.cores).toBe(16); | ||
| }); | ||
| it("R-009: device.memoryGB caps at 8 (Chrome quantization)", () => { | ||
| expect(macMatrix.device.memoryGB).toBe(8); | ||
| expect(winMatrix.device.memoryGB).toBe(8); | ||
| }); | ||
| it("R-010: screen-dimensions tuple includes width, height, availWidth, availHeight", () => { | ||
| const dims = JSON.parse(macMatrix.uaCh["screen-dimensions"] ?? "null") as { | ||
| width: number; | ||
| height: number; | ||
| availWidth: number; | ||
| availHeight: number; | ||
| }; | ||
| expect(dims.width).toBe(2560); | ||
| expect(dims.height).toBe(1664); | ||
| expect(dims.availWidth).toBe(2560); | ||
| expect(dims.availHeight).toBe(1664 - 25); // mac menubar | ||
| }); | ||
| it("R-011: colorDepth passthrough", () => { | ||
| expect(macMatrix.display.colorDepth).toBe(30); | ||
| expect(winMatrix.display.colorDepth).toBe(24); | ||
| }); | ||
| it("R-012: dpr passthrough", () => { | ||
| expect(macMatrix.display.dpr).toBe(2); | ||
| expect(winMatrix.display.dpr).toBe(1); | ||
| }); | ||
| it("R-013: fonts.list is the OS baseline", () => { | ||
| expect(macMatrix.fonts.list).toContain("Arial"); | ||
| expect(macMatrix.fonts.list).toContain("Helvetica"); | ||
| expect(winMatrix.fonts.list).toContain("Segoe UI"); | ||
| expect(winMatrix.fonts.list).toContain("Calibri"); | ||
| }); | ||
| it("R-014: timezone passthrough", () => { | ||
| expect(macMatrix.timezone).toBe("America/Los_Angeles"); | ||
| expect(winMatrix.timezone).toBe("America/New_York"); | ||
| }); | ||
| it("R-015: locale passthrough", () => { | ||
| expect(macMatrix.locale).toBe("en-US"); | ||
| }); | ||
| it("R-016: languages passthrough", () => { | ||
| expect(macMatrix.languages).toEqual(["en-US", "en"]); | ||
| }); | ||
| it("R-017: navigator.platform per OS", () => { | ||
| expect(macMatrix.uaCh["navigator-platform"]).toBe("MacIntel"); | ||
| expect(winMatrix.uaCh["navigator-platform"]).toBe("Win32"); | ||
| }); | ||
| it("R-018: navigator.vendor — 'Google Inc.' on chromium-family", () => { | ||
| expect(macMatrix.uaCh["navigator-vendor"]).toBe("Google Inc."); | ||
| expect(winMatrix.uaCh["navigator-vendor"]).toBe("Google Inc."); | ||
| }); | ||
| it("R-019: seed-derived noise is 32-hex-char (16-byte) string", () => { | ||
| expect(macMatrix.uaCh["seed-derived-noise"]).toMatch(/^[0-9a-f]{32}$/); | ||
| }); | ||
| it("R-020: maxTouchPoints = 0 on desktop", () => { | ||
| expect(macMatrix.uaCh["navigator-maxTouchPoints"]).toBe("0"); | ||
| }); | ||
| it("R-021: avail-screen subtracts OS chrome", () => { | ||
| const avail = JSON.parse(macMatrix.uaCh["screen-availSize"] ?? "null") as { | ||
| availWidth: number; | ||
| availHeight: number; | ||
| }; | ||
| expect(avail.availHeight).toBe(1664 - 25); | ||
| }); | ||
| it("R-022: navigator.webdriver = false", () => { | ||
| expect(macMatrix.uaCh["navigator-webdriver"]).toBe("false"); | ||
| }); | ||
| it("R-023: ua-build-hash is 8-hex-char (4-byte) string", () => { | ||
| expect(macMatrix.uaCh["ua-build-hash"]).toMatch(/^[0-9a-f]{8}$/); | ||
| }); | ||
| it("R-024: gpu.webglExtensions is the curated vendor list, non-empty", () => { | ||
| expect(macMatrix.gpu.webglExtensions.length).toBeGreaterThan(0); | ||
| expect(macMatrix.gpu.webglExtensions).toContain("WEBGL_debug_renderer_info"); | ||
| // Apple-class includes ASTC; Intel-class doesn't. | ||
| expect(macMatrix.gpu.webglExtensions).toContain("WEBGL_compressed_texture_astc"); | ||
| expect(winMatrix.gpu.webglExtensions).not.toContain("WEBGL_compressed_texture_astc"); | ||
| }); | ||
| it("R-025: max color attachments = 8 on desktop GPUs", () => { | ||
| expect(macMatrix.gpu.webglMaxColorAttachments).toBe(8); | ||
| expect(winMatrix.gpu.webglMaxColorAttachments).toBe(8); | ||
| }); | ||
| it("R-026: navigator.appVersion is userAgent without 'Mozilla/' prefix", () => { | ||
| expect(macMatrix.uaCh["navigator-appVersion"]).toBe( | ||
| macMatrix.userAgent.replace(/^Mozilla\//, ""), | ||
| ); | ||
| }); | ||
| it("R-027: navigator.appCodeName = 'Mozilla'", () => { | ||
| expect(macMatrix.uaCh["navigator-appCodeName"]).toBe("Mozilla"); | ||
| }); | ||
| it("R-028: navigator.product = 'Gecko'", () => { | ||
| expect(macMatrix.uaCh["navigator-product"]).toBe("Gecko"); | ||
| }); | ||
| it("R-029: window-viewport carries inner/outer dimensions", () => { | ||
| const vp = JSON.parse(macMatrix.uaCh["window-viewport"] ?? "null") as { | ||
| innerWidth: number; | ||
| innerHeight: number; | ||
| outerWidth: number; | ||
| outerHeight: number; | ||
| }; | ||
| expect(vp.outerWidth).toBe(2560); | ||
| expect(vp.outerHeight).toBe(1664 - 25); | ||
| expect(vp.innerWidth).toBe(2560); | ||
| expect(vp.innerHeight).toBe(1664 - 25 - 87); // mac browser-chrome | ||
| }); | ||
| it("R-030: navigator.cookieEnabled = true", () => { | ||
| expect(macMatrix.uaCh["navigator-cookieEnabled"]).toBe("true"); | ||
| }); | ||
| // ---- phase-0.7 rules (R-031..R-040) ------------------------------------ | ||
| it("R-031: ua-full-version-list is JSON of {brand,version} with tip-locked Chrome 131", () => { | ||
| const raw = macMatrix.uaCh["ua-full-version-list"]; | ||
| expect(typeof raw).toBe("string"); | ||
| const parsed = JSON.parse(raw ?? "[]") as { brand: string; version: string }[]; | ||
| expect(parsed).toEqual([ | ||
| { brand: "Google Chrome", version: "131.0.6778.110" }, | ||
| { brand: "Not.A/Brand", version: "8.0.0.0" }, | ||
| { brand: "Chromium", version: "131.0.6778.110" }, | ||
| ]); | ||
| }); | ||
| it("R-032: webgpu-features carries the Apple-class catalog", () => { | ||
| const raw = macMatrix.uaCh["webgpu-features"]; | ||
| const features = JSON.parse(raw ?? "[]") as string[]; | ||
| expect(features).toContain("shader-f16"); | ||
| expect(features).toContain("texture-compression-astc"); | ||
| expect(features).toContain("subgroups"); | ||
| expect(features.length).toBeGreaterThanOrEqual(20); | ||
| }); | ||
| it("R-033: webgpu-info has architecture metal-3 for Apple GPUs", () => { | ||
| const raw = macMatrix.uaCh["webgpu-info"]; | ||
| const info = JSON.parse(raw ?? "{}") as { architecture: string; vendor: string }; | ||
| expect(info.vendor).toBe("apple"); | ||
| expect(info.architecture).toBe("metal-3"); | ||
| }); | ||
| it("R-034: media-devices shape declares audioinput / videoinput / audiooutput", () => { | ||
| const raw = macMatrix.uaCh["media-devices"]; | ||
| const devices = JSON.parse(raw ?? "[]") as { kind: string }[]; | ||
| const kinds = devices.map((d) => d.kind); | ||
| expect(kinds).toContain("audioinput"); | ||
| expect(kinds).toContain("videoinput"); | ||
| expect(kinds).toContain("audiooutput"); | ||
| }); | ||
| it("R-035: media-supported-constraints map includes deviceId + groupId", () => { | ||
| const raw = macMatrix.uaCh["media-supported-constraints"]; | ||
| const map = JSON.parse(raw ?? "{}") as Record<string, true>; | ||
| expect(map.deviceId).toBe(true); | ||
| expect(map.groupId).toBe(true); | ||
| expect(map.echoCancellation).toBe(true); | ||
| }); | ||
| it("R-036: permissions defaults map sensors to granted, prompts to prompt", () => { | ||
| const raw = macMatrix.uaCh["permissions-defaults"]; | ||
| const map = JSON.parse(raw ?? "{}") as Record<string, string>; | ||
| expect(map.geolocation).toBe("prompt"); | ||
| expect(map.accelerometer).toBe("granted"); | ||
| expect(map["clipboard-write"]).toBe("granted"); | ||
| }); | ||
| it("R-037: connection defaults to 4g effective type", () => { | ||
| const raw = macMatrix.uaCh.connection; | ||
| const conn = JSON.parse(raw ?? "{}") as { effectiveType: string; saveData: boolean }; | ||
| expect(conn.effectiveType).toBe("4g"); | ||
| expect(conn.saveData).toBe(false); | ||
| }); | ||
| it("R-038: screen-orientation is landscape-primary on desktop", () => { | ||
| const raw = macMatrix.uaCh["screen-orientation"]; | ||
| const o = JSON.parse(raw ?? "{}") as { type: string; angle: number }; | ||
| expect(o.type).toBe("landscape-primary"); | ||
| expect(o.angle).toBe(0); | ||
| }); | ||
| it("R-039: media-queries map carries prefers-color-scheme + color-gamut", () => { | ||
| const raw = macMatrix.uaCh["media-queries"]; | ||
| const map = JSON.parse(raw ?? "{}") as Record<string, string | boolean>; | ||
| expect(map["prefers-color-scheme"]).toBe("light"); | ||
| expect(map["color-gamut"]).toBe("srgb"); | ||
| expect(map.monochrome).toBe(false); | ||
| }); | ||
| it("R-040: storage-estimate quota scales with cores; usage is 0", () => { | ||
| const raw = macMatrix.uaCh["storage-estimate"]; | ||
| const e = JSON.parse(raw ?? "{}") as { quota: number; usage: number }; | ||
| expect(e.usage).toBe(0); | ||
| expect(e.quota).toBeGreaterThan(0); | ||
| }); | ||
| }); |
+148
| /** | ||
| * DAG validation and topological ordering for the rule list. | ||
| * | ||
| * - Acyclicity: DFS three-coloring (`white` → `gray` → `black`). When DFS | ||
| * re-enters a gray node we have a cycle; the path-stack at that moment | ||
| * gives us the cycle for the error message. | ||
| * - Topological sort: Kahn's algorithm seeded by all nodes with in-degree | ||
| * zero (typically the rules whose inputs are profile fields). | ||
| * | ||
| * Both passes are O(V + E) and run on the in-process rule list once, the | ||
| * first time `deriveMatrix` executes (the orchestrator caches the order). | ||
| * | ||
| * @see PLAN.md §5.2 | ||
| */ | ||
| import { DuplicateOutputError, RuleDagCycleError } from "./errors"; | ||
| import type { Rule } from "./rule"; | ||
| /** | ||
| * The pre-computed rule plan: rules in execution order. Returned by | ||
| * `validateAndOrder` and consumed by `runRules`. | ||
| */ | ||
| export interface RulePlan { | ||
| /** Rules in topo-sorted execution order. */ | ||
| readonly order: readonly Rule[]; | ||
| /** Output path → rule id (the unique producer). */ | ||
| readonly producers: ReadonlyMap<string, string>; | ||
| } | ||
| /** | ||
| * Build the producer index, detect duplicates, detect cycles, and topo-sort. | ||
| * | ||
| * @throws RuleDagCycleError when the DAG is cyclic | ||
| * @throws DuplicateOutputError when two rules write the same output path | ||
| */ | ||
| export function validateAndOrder(rules: readonly Rule[]): RulePlan { | ||
| // 1. Build the producer index (output path → rule id). | ||
| const producers = new Map<string, string>(); | ||
| for (const rule of rules) { | ||
| const existing = producers.get(rule.output); | ||
| if (existing !== undefined) { | ||
| throw new DuplicateOutputError(rule.output, [existing, rule.id]); | ||
| } | ||
| producers.set(rule.output, rule.id); | ||
| } | ||
| // 2. Build per-rule predecessor list. A rule R depends on rule P iff one | ||
| // of R's inputs equals P's output. Inputs that don't match any output | ||
| // are "external" (profile fields) — they impose no DAG edge. | ||
| const ruleById = new Map<string, Rule>(); | ||
| for (const rule of rules) ruleById.set(rule.id, rule); | ||
| const adj = new Map<string, string[]>(); // ruleId -> downstream rule ids | ||
| const inDegree = new Map<string, number>(); | ||
| for (const rule of rules) { | ||
| adj.set(rule.id, []); | ||
| inDegree.set(rule.id, 0); | ||
| } | ||
| for (const rule of rules) { | ||
| for (const input of rule.inputs) { | ||
| const producerId = producers.get(input); | ||
| if (producerId === undefined || producerId === rule.id) continue; | ||
| const list = adj.get(producerId); | ||
| if (list !== undefined) list.push(rule.id); | ||
| inDegree.set(rule.id, (inDegree.get(rule.id) ?? 0) + 1); | ||
| } | ||
| } | ||
| // 3. Cycle detection via DFS three-coloring. | ||
| detectCycle(rules, adj); | ||
| // 4. Topo sort (Kahn). The cycle check above guarantees we'll drain. | ||
| const order: Rule[] = []; | ||
| const queue: string[] = []; | ||
| for (const rule of rules) { | ||
| if ((inDegree.get(rule.id) ?? 0) === 0) queue.push(rule.id); | ||
| } | ||
| // Stable ordering: preserve declaration order for ties. | ||
| queue.sort((a, b) => declarationIndex(rules, a) - declarationIndex(rules, b)); | ||
| while (queue.length > 0) { | ||
| const id = queue.shift(); | ||
| if (id === undefined) break; | ||
| const rule = ruleById.get(id); | ||
| if (rule === undefined) continue; | ||
| order.push(rule); | ||
| const downstream = adj.get(id) ?? []; | ||
| const newlyReady: string[] = []; | ||
| for (const next of downstream) { | ||
| const remaining = (inDegree.get(next) ?? 0) - 1; | ||
| inDegree.set(next, remaining); | ||
| if (remaining === 0) newlyReady.push(next); | ||
| } | ||
| newlyReady.sort((a, b) => declarationIndex(rules, a) - declarationIndex(rules, b)); | ||
| queue.unshift(...newlyReady); | ||
| queue.sort((a, b) => declarationIndex(rules, a) - declarationIndex(rules, b)); | ||
| } | ||
| if (order.length !== rules.length) { | ||
| // Defensive — `detectCycle` should have raised already. | ||
| throw new RuleDagCycleError(["<unresolved>"]); | ||
| } | ||
| return { order, producers }; | ||
| } | ||
| function declarationIndex(rules: readonly Rule[], id: string): number { | ||
| for (let i = 0; i < rules.length; i++) { | ||
| if (rules[i]?.id === id) return i; | ||
| } | ||
| return Number.MAX_SAFE_INTEGER; | ||
| } | ||
| /** | ||
| * DFS three-coloring cycle detector. Throws `RuleDagCycleError` if a cycle | ||
| * exists; returns silently otherwise. | ||
| */ | ||
| function detectCycle(rules: readonly Rule[], adj: ReadonlyMap<string, readonly string[]>): void { | ||
| const WHITE = 0; | ||
| const GRAY = 1; | ||
| const BLACK = 2; | ||
| const color = new Map<string, number>(); | ||
| for (const rule of rules) color.set(rule.id, WHITE); | ||
| const path: string[] = []; | ||
| function visit(id: string): void { | ||
| color.set(id, GRAY); | ||
| path.push(id); | ||
| const downstream = adj.get(id) ?? []; | ||
| for (const next of downstream) { | ||
| const c = color.get(next) ?? WHITE; | ||
| if (c === GRAY) { | ||
| // Found a back-edge. Slice path from where `next` first appears. | ||
| const idx = path.indexOf(next); | ||
| const cycle = idx >= 0 ? [...path.slice(idx), next] : [next, ...path, next]; | ||
| throw new RuleDagCycleError(cycle); | ||
| } | ||
| if (c === WHITE) visit(next); | ||
| } | ||
| color.set(id, BLACK); | ||
| path.pop(); | ||
| } | ||
| for (const rule of rules) { | ||
| if (color.get(rule.id) === WHITE) visit(rule.id); | ||
| } | ||
| } |
+100
| /** | ||
| * `deriveMatrix` — the public Matrix engine entrypoint. | ||
| * | ||
| * Pipeline: | ||
| * 1. Build a fresh PRNG seeded from `(profile.id, seed)`. | ||
| * 2. Deep-clone the profile into a `MatrixV1`-shaped working object, | ||
| * stamping `seed`, `derivedAt`, `consistencyEngineVersion`. | ||
| * 3. Resolve and cache the topo-sorted rule plan (validated for cycles | ||
| * and duplicate outputs once per process). | ||
| * 4. Run rules in topological order, reading inputs from the in-progress | ||
| * matrix and writing outputs back. | ||
| * 5. Return the matrix as a frozen, JSON-round-trippable object. | ||
| * | ||
| * Determinism guarantees: | ||
| * - Same `(profile, seed)` → same Matrix (excluding `derivedAt`). | ||
| * - The PRNG sequence is shared across all rules in topo order; rules | ||
| * that consume the PRNG advance the cursor for downstream rules. | ||
| * - No `Math.random`, no `Date.now` (other than `derivedAt`), no | ||
| * environment reads anywhere in the rule path. | ||
| * | ||
| * @see PLAN.md §5.2 / §9 | ||
| */ | ||
| import { type RulePlan, validateAndOrder } from "./dag"; | ||
| import { CONSISTENCY_ENGINE_VERSION } from "./engine-version"; | ||
| import { MissingInputError } from "./errors"; | ||
| import type { MatrixV1 } from "./generated/matrix"; | ||
| import type { ProfileV1 } from "./generated/profile"; | ||
| import { type DeepRecord, getByPath, setByPath } from "./path"; | ||
| import { seedToPrng } from "./prng/seed"; | ||
| import { RULES } from "./rules"; | ||
| /** | ||
| * Cached, validated rule plan. Lazily computed on first call so the cycle | ||
| * check runs once per process. | ||
| */ | ||
| let cachedPlan: RulePlan | null = null; | ||
| function getPlan(): RulePlan { | ||
| if (cachedPlan === null) cachedPlan = validateAndOrder(RULES); | ||
| return cachedPlan; | ||
| } | ||
| /** | ||
| * @internal Reset the cached plan. Used by tests that mutate the global | ||
| * rule list. Not part of the public API. | ||
| */ | ||
| export function _resetPlanCache(): void { | ||
| cachedPlan = null; | ||
| } | ||
| /** | ||
| * Derive a `MatrixV1` from `(profile, seed)`. Pure and deterministic per | ||
| * the contract above — except for `derivedAt`, which carries the wall-clock | ||
| * timestamp at derivation. Strip `derivedAt` to compare two matrices for | ||
| * byte-for-byte identity. | ||
| * | ||
| * @param profile The device-class profile to instantiate. | ||
| * @param seed Per-session deterministic entropy seed. | ||
| * @returns A relationally-locked MatrixV1 ready for `@mochi.js/inject`. | ||
| * | ||
| * @throws RuleDagCycleError if the rule list is cyclic (init-time check) | ||
| * @throws DuplicateOutputError if two rules write the same output path | ||
| * @throws MissingInputError if a rule's declared input isn't on the matrix | ||
| * | ||
| * @example | ||
| * const matrix = deriveMatrix(profile, "session-1"); | ||
| * // matrix.userAgent, matrix.gpu.webglUnmaskedRenderer, ... are derived. | ||
| * // Two distinct seeds produce two distinct matrices; one seed produces | ||
| * // one matrix exactly. | ||
| */ | ||
| export function deriveMatrix(profile: ProfileV1, seed: string): MatrixV1 { | ||
| if (typeof seed !== "string" || seed.length === 0) { | ||
| throw new Error("[mochi/consistency] deriveMatrix: seed must be a non-empty string"); | ||
| } | ||
| const plan = getPlan(); | ||
| const prng = seedToPrng(profile.id, seed); | ||
| // Deep-clone the profile via JSON round-trip. The schema forbids functions | ||
| // / symbols / cycles already, so the round-trip is lossless. | ||
| const matrix = JSON.parse(JSON.stringify(profile)) as MatrixV1; | ||
| matrix.seed = seed; | ||
| matrix.derivedAt = new Date().toISOString(); | ||
| matrix.consistencyEngineVersion = CONSISTENCY_ENGINE_VERSION; | ||
| const view = matrix as unknown as DeepRecord; | ||
| for (const rule of plan.order) { | ||
| const resolved: unknown[] = []; | ||
| for (const path of rule.inputs) { | ||
| const v = getByPath(view, path); | ||
| if (v === undefined) throw new MissingInputError(rule.id, path); | ||
| resolved.push(v); | ||
| } | ||
| const output = rule.derive(resolved as readonly unknown[], prng); | ||
| setByPath(view, rule.output, output); | ||
| } | ||
| return matrix; | ||
| } |
| /** | ||
| * Engine version stamped on every derived MatrixV1. | ||
| * | ||
| * Bumped whenever the rule semantics change in a way that produces a | ||
| * different output for the same `(profile, seed)` pair. v0.2.0 is the | ||
| * first real (non-stub) cut; v0.7.0 will land the full ruleset. | ||
| * | ||
| * Kept distinct from the package's `VERSION` constant so the engine can | ||
| * version its output independently of the package's npm lifecycle. | ||
| * | ||
| * @see PLAN.md §5.2 / §6.2 | ||
| */ | ||
| export const CONSISTENCY_ENGINE_VERSION = "0.2.0" as const; |
| /** | ||
| * Errors raised by the consistency engine. | ||
| * | ||
| * @see PLAN.md §5.2 | ||
| */ | ||
| /** | ||
| * Thrown when the rule DAG is found to contain a cycle at engine init time. | ||
| * The cycle path is included for triage. Detection runs once per process via | ||
| * a cached check inside `deriveMatrix`. | ||
| */ | ||
| export class RuleDagCycleError extends Error { | ||
| override readonly name = "RuleDagCycleError"; | ||
| /** Ordered list of rule ids forming the detected cycle (head equals tail). */ | ||
| readonly cycle: readonly string[]; | ||
| constructor(cycle: readonly string[]) { | ||
| super(`[mochi/consistency] rule DAG contains a cycle: ${cycle.join(" -> ")}`); | ||
| this.cycle = cycle; | ||
| } | ||
| } | ||
| /** | ||
| * Thrown when a rule declares an input path that is missing from the matrix | ||
| * being built. This is a programmer error — either the input path is wrong, | ||
| * or the rule that produces it was filtered out before this rule executed. | ||
| */ | ||
| export class MissingInputError extends Error { | ||
| override readonly name = "MissingInputError"; | ||
| readonly ruleId: string; | ||
| readonly path: string; | ||
| constructor(ruleId: string, path: string) { | ||
| super( | ||
| `[mochi/consistency] rule ${ruleId} requires input "${path}" but it is missing from the matrix-under-construction`, | ||
| ); | ||
| this.ruleId = ruleId; | ||
| this.path = path; | ||
| } | ||
| } | ||
| /** | ||
| * Thrown when two rules declare the same output path. The DAG must have a | ||
| * single producer per slot — otherwise execution order silently changes | ||
| * results and the deterministic guarantee breaks. | ||
| */ | ||
| export class DuplicateOutputError extends Error { | ||
| override readonly name = "DuplicateOutputError"; | ||
| readonly path: string; | ||
| readonly ruleIds: readonly string[]; | ||
| constructor(path: string, ruleIds: readonly string[]) { | ||
| super( | ||
| `[mochi/consistency] output path "${path}" is produced by multiple rules: ${ruleIds.join(", ")}`, | ||
| ); | ||
| this.path = path; | ||
| this.ruleIds = ruleIds; | ||
| } | ||
| } |
Sorry, the diff of this file is not supported yet
| // AUTO-GENERATED — do not edit. Run `bun run codegen` to regenerate. | ||
| // Source schema lives in schemas/. See scripts/codegen.ts and tasks/0003-schemas-and-codegen.md. | ||
| /** | ||
| * A ProfileV1 instantiated for a specific seed. Same shape as ProfileV1 (every property re-declared via $ref to profile.schema.json), with three additional required fields: seed, derivedAt, consistencyEngineVersion. Consumed by @mochi.js/inject. JSON-serializable; round-trips losslessly. See PLAN.md §6.2. | ||
| */ | ||
| export type MatrixV1 = ProfileV1 & { | ||
| /** | ||
| * The deterministic per-session entropy seed passed to mochi.launch(). | ||
| */ | ||
| seed: string; | ||
| /** | ||
| * ISO-8601 timestamp at which deriveMatrix() produced this snapshot. | ||
| */ | ||
| derivedAt: string; | ||
| /** | ||
| * Version of @mochi.js/consistency that produced this matrix. | ||
| */ | ||
| consistencyEngineVersion: string; | ||
| }; | ||
| /** | ||
| * A device-class profile consumed by @mochi.js/consistency. Declares the deterministic capabilities of a single (hardware, OS, browser) class. Instantiated for a specific seed by deriveMatrix() to produce a MatrixV1. See PLAN.md §6.1. | ||
| */ | ||
| export interface ProfileV1 { | ||
| /** | ||
| * Stable profile identifier, e.g. 'mac-m2-chrome-stable'. | ||
| */ | ||
| id: string; | ||
| /** | ||
| * Semver version of this profile document. | ||
| */ | ||
| version: string; | ||
| /** | ||
| * JS engine family. v1 invariant: chromium-only (PLAN.md I-4, decision #4). | ||
| */ | ||
| engine: "chromium"; | ||
| /** | ||
| * Browser identity this profile spoofs. | ||
| */ | ||
| browser: { | ||
| /** | ||
| * Branded browser name. v1: chromium-family only. | ||
| */ | ||
| name: "chrome" | "edge" | "brave" | "arc" | "opera"; | ||
| /** | ||
| * Release channel. | ||
| */ | ||
| channel: "stable" | "beta" | "dev" | "canary"; | ||
| /** | ||
| * Inclusive lower bound on the Chromium-for-Testing major version this profile is verified against. | ||
| */ | ||
| minVersion: string; | ||
| /** | ||
| * Inclusive upper bound on the Chromium-for-Testing major version this profile is verified against. | ||
| */ | ||
| maxVersion: string; | ||
| }; | ||
| /** | ||
| * Host OS identity. | ||
| */ | ||
| os: { | ||
| name: "macos" | "windows" | "linux"; | ||
| /** | ||
| * OS marketing version, e.g. '14' for macOS Sonoma. | ||
| */ | ||
| version: string; | ||
| arch: "arm64" | "x64" | "x86"; | ||
| }; | ||
| /** | ||
| * Hardware identity. | ||
| */ | ||
| device: { | ||
| vendor: string; | ||
| model: string; | ||
| cpuFamily: string; | ||
| /** | ||
| * Logical core count exposed to navigator.hardwareConcurrency. | ||
| */ | ||
| cores: number; | ||
| /** | ||
| * Physical RAM in GiB. navigator.deviceMemory caps at 8. | ||
| */ | ||
| memoryGB: number; | ||
| }; | ||
| /** | ||
| * Display geometry surfaced via window.screen / matchMedia / visualViewport. | ||
| */ | ||
| display: { | ||
| width: number; | ||
| height: number; | ||
| dpr: number; | ||
| colorDepth: number; | ||
| pixelDepth: number; | ||
| }; | ||
| /** | ||
| * GPU identity surfaced via WebGL/WebGPU. Locks the canvas/webgl/webgpu render-hash chain. | ||
| */ | ||
| gpu: { | ||
| vendor: string; | ||
| renderer: string; | ||
| webglUnmaskedVendor: string; | ||
| webglUnmaskedRenderer: string; | ||
| webglMaxTextureSize: number; | ||
| webglMaxColorAttachments: number; | ||
| webglExtensions: string[]; | ||
| }; | ||
| /** | ||
| * AudioContext capabilities surfaced via OfflineAudioContext / AudioContext. | ||
| */ | ||
| audio: { | ||
| contextSampleRate: number; | ||
| audioWorkletLatency: number; | ||
| destinationMaxChannelCount: number; | ||
| }; | ||
| /** | ||
| * Installed-font inventory used for font enumeration probes. | ||
| */ | ||
| fonts: { | ||
| /** | ||
| * Curated pack identifier, e.g. 'macos-system-arial-pack'. | ||
| */ | ||
| family: string; | ||
| /** | ||
| * @minItems 1 | ||
| */ | ||
| list: [string, ...string[]]; | ||
| }; | ||
| /** | ||
| * IANA timezone identifier, e.g. 'America/Los_Angeles'. | ||
| */ | ||
| timezone: string; | ||
| /** | ||
| * Primary BCP 47 locale, e.g. 'en-US'. | ||
| */ | ||
| locale: string; | ||
| /** | ||
| * navigator.languages ordered list. First entry must match `locale`. | ||
| * | ||
| * @minItems 1 | ||
| */ | ||
| languages: [string, ...string[]]; | ||
| /** | ||
| * Per-profile behavioral parameters consumed by @mochi.js/behavioral. | ||
| */ | ||
| behavior: { | ||
| hand: "left" | "right"; | ||
| /** | ||
| * Per-axis Gaussian jitter amplitude, in pixel-equivalents. | ||
| */ | ||
| tremor: number; | ||
| /** | ||
| * Mean typing speed in words per minute. | ||
| */ | ||
| wpm: number; | ||
| scrollStyle: "smooth" | "stepped" | "inertial"; | ||
| }; | ||
| /** | ||
| * Preset name accepted by the wreq Rust crate, e.g. 'chrome_131_macos'. Maps profile -> TLS/H2 fingerprint. | ||
| */ | ||
| wreqPreset: string; | ||
| /** | ||
| * Full navigator.userAgent string this profile spoofs. | ||
| */ | ||
| userAgent: string; | ||
| /** | ||
| * User-Agent Client Hints (sec-ch-ua, sec-ch-ua-platform, etc.). Headers as observed on the wire. | ||
| */ | ||
| uaCh: { | ||
| [k: string]: string | undefined; | ||
| }; | ||
| /** | ||
| * Declares which fields are device-fixed vs. seed-varying. PLAN.md §6.1. | ||
| */ | ||
| entropyBudget: { | ||
| /** | ||
| * Dotted paths into this profile that are constants across all seeds. | ||
| */ | ||
| fixed: string[]; | ||
| /** | ||
| * Dotted paths that resolve deterministically per (profile, seed) within the profile's declared bounds. | ||
| */ | ||
| perSeed: string[]; | ||
| }; | ||
| } |
| // AUTO-GENERATED — do not edit. Run `bun run codegen` to regenerate. | ||
| // Source schema lives in schemas/. See scripts/codegen.ts and tasks/0003-schemas-and-codegen.md. | ||
| /** | ||
| * A device-class profile consumed by @mochi.js/consistency. Declares the deterministic capabilities of a single (hardware, OS, browser) class. Instantiated for a specific seed by deriveMatrix() to produce a MatrixV1. See PLAN.md §6.1. | ||
| */ | ||
| export interface ProfileV1 { | ||
| /** | ||
| * Stable profile identifier, e.g. 'mac-m2-chrome-stable'. | ||
| */ | ||
| id: string; | ||
| /** | ||
| * Semver version of this profile document. | ||
| */ | ||
| version: string; | ||
| /** | ||
| * JS engine family. v1 invariant: chromium-only (PLAN.md I-4, decision #4). | ||
| */ | ||
| engine: "chromium"; | ||
| /** | ||
| * Browser identity this profile spoofs. | ||
| */ | ||
| browser: { | ||
| /** | ||
| * Branded browser name. v1: chromium-family only. | ||
| */ | ||
| name: "chrome" | "edge" | "brave" | "arc" | "opera"; | ||
| /** | ||
| * Release channel. | ||
| */ | ||
| channel: "stable" | "beta" | "dev" | "canary"; | ||
| /** | ||
| * Inclusive lower bound on the Chromium-for-Testing major version this profile is verified against. | ||
| */ | ||
| minVersion: string; | ||
| /** | ||
| * Inclusive upper bound on the Chromium-for-Testing major version this profile is verified against. | ||
| */ | ||
| maxVersion: string; | ||
| }; | ||
| /** | ||
| * Host OS identity. | ||
| */ | ||
| os: { | ||
| name: "macos" | "windows" | "linux"; | ||
| /** | ||
| * OS marketing version, e.g. '14' for macOS Sonoma. | ||
| */ | ||
| version: string; | ||
| arch: "arm64" | "x64" | "x86"; | ||
| }; | ||
| /** | ||
| * Hardware identity. | ||
| */ | ||
| device: { | ||
| vendor: string; | ||
| model: string; | ||
| cpuFamily: string; | ||
| /** | ||
| * Logical core count exposed to navigator.hardwareConcurrency. | ||
| */ | ||
| cores: number; | ||
| /** | ||
| * Physical RAM in GiB. navigator.deviceMemory caps at 8. | ||
| */ | ||
| memoryGB: number; | ||
| }; | ||
| /** | ||
| * Display geometry surfaced via window.screen / matchMedia / visualViewport. | ||
| */ | ||
| display: { | ||
| width: number; | ||
| height: number; | ||
| dpr: number; | ||
| colorDepth: number; | ||
| pixelDepth: number; | ||
| }; | ||
| /** | ||
| * GPU identity surfaced via WebGL/WebGPU. Locks the canvas/webgl/webgpu render-hash chain. | ||
| */ | ||
| gpu: { | ||
| vendor: string; | ||
| renderer: string; | ||
| webglUnmaskedVendor: string; | ||
| webglUnmaskedRenderer: string; | ||
| webglMaxTextureSize: number; | ||
| webglMaxColorAttachments: number; | ||
| webglExtensions: string[]; | ||
| }; | ||
| /** | ||
| * AudioContext capabilities surfaced via OfflineAudioContext / AudioContext. | ||
| */ | ||
| audio: { | ||
| contextSampleRate: number; | ||
| audioWorkletLatency: number; | ||
| destinationMaxChannelCount: number; | ||
| }; | ||
| /** | ||
| * Installed-font inventory used for font enumeration probes. | ||
| */ | ||
| fonts: { | ||
| /** | ||
| * Curated pack identifier, e.g. 'macos-system-arial-pack'. | ||
| */ | ||
| family: string; | ||
| /** | ||
| * @minItems 1 | ||
| */ | ||
| list: [string, ...string[]]; | ||
| }; | ||
| /** | ||
| * IANA timezone identifier, e.g. 'America/Los_Angeles'. | ||
| */ | ||
| timezone: string; | ||
| /** | ||
| * Primary BCP 47 locale, e.g. 'en-US'. | ||
| */ | ||
| locale: string; | ||
| /** | ||
| * navigator.languages ordered list. First entry must match `locale`. | ||
| * | ||
| * @minItems 1 | ||
| */ | ||
| languages: [string, ...string[]]; | ||
| /** | ||
| * Per-profile behavioral parameters consumed by @mochi.js/behavioral. | ||
| */ | ||
| behavior: { | ||
| hand: "left" | "right"; | ||
| /** | ||
| * Per-axis Gaussian jitter amplitude, in pixel-equivalents. | ||
| */ | ||
| tremor: number; | ||
| /** | ||
| * Mean typing speed in words per minute. | ||
| */ | ||
| wpm: number; | ||
| scrollStyle: "smooth" | "stepped" | "inertial"; | ||
| }; | ||
| /** | ||
| * Preset name accepted by the wreq Rust crate, e.g. 'chrome_131_macos'. Maps profile -> TLS/H2 fingerprint. | ||
| */ | ||
| wreqPreset: string; | ||
| /** | ||
| * Full navigator.userAgent string this profile spoofs. | ||
| */ | ||
| userAgent: string; | ||
| /** | ||
| * User-Agent Client Hints (sec-ch-ua, sec-ch-ua-platform, etc.). Headers as observed on the wire. | ||
| */ | ||
| uaCh: { | ||
| [k: string]: string | undefined; | ||
| }; | ||
| /** | ||
| * Declares which fields are device-fixed vs. seed-varying. PLAN.md §6.1. | ||
| */ | ||
| entropyBudget: { | ||
| /** | ||
| * Dotted paths into this profile that are constants across all seeds. | ||
| */ | ||
| fixed: string[]; | ||
| /** | ||
| * Dotted paths that resolve deterministically per (profile, seed) within the profile's declared bounds. | ||
| */ | ||
| perSeed: string[]; | ||
| }; | ||
| } |
+69
| /** | ||
| * Tiny dotted-path getter/setter for navigating the matrix-under-construction. | ||
| * | ||
| * Supports `a.b.c`-style paths only; no array indices, no escaping, no | ||
| * wildcards. Sufficient for the consistency-engine surface — every input/ | ||
| * output path used by rules is a static string declared in the rule's | ||
| * `inputs` / `output` fields. | ||
| * | ||
| * @internal | ||
| */ | ||
| /** Mutable, deeply-nested record. Used as the in-progress matrix shape. */ | ||
| export type DeepRecord = { [k: string]: unknown }; | ||
| /** Resolve a dotted path. Returns `undefined` if any segment is missing. */ | ||
| export function getByPath(obj: DeepRecord, path: string): unknown { | ||
| if (path.length === 0) return undefined; | ||
| const parts = path.split("."); | ||
| let cur: unknown = obj; | ||
| for (const part of parts) { | ||
| if (cur === null || typeof cur !== "object") return undefined; | ||
| cur = (cur as DeepRecord)[part]; | ||
| } | ||
| return cur; | ||
| } | ||
| /** | ||
| * Write `value` at the dotted path, creating intermediate objects as needed. | ||
| * The caller owns `obj`. Throws if a non-final segment exists but is not a | ||
| * plain object (i.e. we'd otherwise overwrite a primitive). | ||
| */ | ||
| export function setByPath(obj: DeepRecord, path: string, value: unknown): void { | ||
| if (path.length === 0) { | ||
| throw new Error("[mochi/consistency] setByPath: empty path"); | ||
| } | ||
| const parts = path.split("."); | ||
| const last = parts.pop(); | ||
| if (last === undefined) { | ||
| throw new Error("[mochi/consistency] setByPath: empty path"); | ||
| } | ||
| let cur: DeepRecord = obj; | ||
| for (const part of parts) { | ||
| const next = cur[part]; | ||
| if (next === undefined || next === null) { | ||
| const fresh: DeepRecord = {}; | ||
| cur[part] = fresh; | ||
| cur = fresh; | ||
| continue; | ||
| } | ||
| if (typeof next !== "object" || Array.isArray(next)) { | ||
| throw new Error( | ||
| `[mochi/consistency] setByPath: cannot descend through non-object at "${part}" of "${path}"`, | ||
| ); | ||
| } | ||
| cur = next as DeepRecord; | ||
| } | ||
| cur[last] = value; | ||
| } | ||
| /** True iff every dotted-path segment is non-empty and matches `[A-Za-z0-9_-]+`. */ | ||
| export function isValidPath(path: string): boolean { | ||
| if (path.length === 0) return false; | ||
| const parts = path.split("."); | ||
| for (const part of parts) { | ||
| if (part.length === 0) return false; | ||
| if (!/^[A-Za-z0-9_-]+$/.test(part)) return false; | ||
| } | ||
| return true; | ||
| } |
| /** | ||
| * `@mochi.js/consistency/prng` — public PRNG sub-export. | ||
| * | ||
| * Lifted from internal-only at v0.2.1 so downstream packages (in particular | ||
| * `@mochi.js/behavioral`, phase 0.8) can share the seeded xoshiro256** without | ||
| * re-implementing the algorithm or the seed-derivation contract. | ||
| * | ||
| * Determinism contract carried forward from `xoshiro256ss.ts`: | ||
| * - `seedToPrng(profileId, seed)` is a pure function of its arguments. | ||
| * - Two calls with the same `(profileId, seed)` produce identical PRNG | ||
| * sequences, byte-for-byte. | ||
| * - No globals, no `Math.random`. SHA-256 of `${profileId}:${seed}` keys | ||
| * the four-word xoshiro state. | ||
| * | ||
| * @see PLAN.md §5.2, §5.5 | ||
| */ | ||
| export { deriveSeedState, seedToPrng } from "./seed"; | ||
| export { makeXoshiro256ss, type SeededPrng } from "./xoshiro256ss"; |
| /** | ||
| * Seed derivation for the consistency engine PRNG. | ||
| * | ||
| * Given a `(profile.id, seed)` pair we hash `${profile.id}:${seed}` with | ||
| * SHA-256 (Bun.CryptoHasher) and slice the 32-byte digest into four | ||
| * little-endian u64 words. Those four words form the xoshiro256** state. | ||
| * | ||
| * This guarantees: | ||
| * - Same input → same digest → same sequence (determinism). | ||
| * - Different `profile.id` produce isolated sequences even with the same | ||
| * `seed` (cross-profile isolation, PLAN.md I-5). | ||
| * - Different `seed` on the same profile produce divergent sequences | ||
| * (per-session entropy). | ||
| * - The vanishingly unlikely all-zero digest (probability ~ 2^-256) is | ||
| * handled by xoshiro construction, which throws. | ||
| * | ||
| * @see PLAN.md §5.2 | ||
| */ | ||
| import { makeXoshiro256ss, type SeededPrng } from "./xoshiro256ss"; | ||
| /** Read 8 little-endian bytes at `offset` as a `bigint` in `[0, 2^64)`. */ | ||
| function readU64LE(buf: Uint8Array, offset: number): bigint { | ||
| let value = 0n; | ||
| for (let i = 7; i >= 0; i--) { | ||
| const byte = buf[offset + i]; | ||
| if (byte === undefined) { | ||
| throw new Error("[mochi/consistency] seed digest truncated"); | ||
| } | ||
| value = (value << 8n) | BigInt(byte); | ||
| } | ||
| return value; | ||
| } | ||
| /** | ||
| * Derive a 4×u64 xoshiro256** state from the SHA-256 digest of | ||
| * `${profileId}:${seed}`. Exposed for tests; rules call `seedToPrng`. | ||
| */ | ||
| export function deriveSeedState( | ||
| profileId: string, | ||
| seed: string, | ||
| ): readonly [bigint, bigint, bigint, bigint] { | ||
| const hasher = new Bun.CryptoHasher("sha256"); | ||
| hasher.update(`${profileId}:${seed}`); | ||
| const digest = new Uint8Array(hasher.digest().buffer); | ||
| if (digest.length !== 32) { | ||
| throw new Error( | ||
| `[mochi/consistency] expected 32-byte SHA-256 digest, got ${digest.length} bytes`, | ||
| ); | ||
| } | ||
| return [ | ||
| readU64LE(digest, 0), | ||
| readU64LE(digest, 8), | ||
| readU64LE(digest, 16), | ||
| readU64LE(digest, 24), | ||
| ] as const; | ||
| } | ||
| /** Build a fresh `SeededPrng` for `(profileId, seed)`. */ | ||
| export function seedToPrng(profileId: string, seed: string): SeededPrng { | ||
| return makeXoshiro256ss(deriveSeedState(profileId, seed)); | ||
| } |
| /** | ||
| * xoshiro256** — a fast, high-quality 64-bit PRNG by Blackman & Vigna. | ||
| * | ||
| * Reference: https://prng.di.unimi.it/xoshiro256starstar.c (public domain). | ||
| * | ||
| * The implementation uses BigInt to retain 64-bit semantics on Bun (where | ||
| * Number precision tops out at 53 bits). Every step masks the working | ||
| * registers back into the 64-bit envelope. The returned value of `nextU64` | ||
| * is a positive `bigint` in `[0, 2^64)`. | ||
| * | ||
| * Determinism contract: | ||
| * - Construction with the same 4×u64 state ALWAYS yields the same sequence. | ||
| * - Two PRNGs whose states differ in any bit produce divergent sequences. | ||
| * - No globals are touched. | ||
| * - `Math.random` is NEVER called. | ||
| * | ||
| * @see PLAN.md §5.2 — "Seeded PRNG (deterministic; xoshiro256** with seed = sha256(profile.id + seed))" | ||
| */ | ||
| const U64_MASK = 0xffff_ffff_ffff_ffffn; | ||
| /** Rotate-left for u64. `bits` is assumed in [1, 63]. */ | ||
| function rotl(x: bigint, bits: number): bigint { | ||
| const b = BigInt(bits); | ||
| return ((x << b) | (x >> (64n - b))) & U64_MASK; | ||
| } | ||
| /** | ||
| * Public PRNG surface consumed by rules. Methods return primitive JS numbers | ||
| * (or bigints where the full 64-bit range matters) and never expose state. | ||
| */ | ||
| export interface SeededPrng { | ||
| /** Next raw u64 as a non-negative bigint. */ | ||
| nextU64(): bigint; | ||
| /** Next u32 in `[0, 2^32)` as a number. */ | ||
| nextU32(): number; | ||
| /** Next IEEE-754 double in `[0, 1)`, evenly distributed. */ | ||
| nextFloat01(): number; | ||
| /** Inclusive integer in `[lo, hi]`. Throws if `lo > hi`. */ | ||
| nextIntInclusive(lo: number, hi: number): number; | ||
| /** Pick one element of `arr`. Throws if `arr` is empty. */ | ||
| pick<T>(arr: readonly T[]): T; | ||
| /** Hex string of `byteLength` bytes (so `byteLength*2` hex chars). */ | ||
| nextHex(byteLength: number): string; | ||
| } | ||
| /** | ||
| * Construct a xoshiro256** PRNG from a 4×u64 state. At least one of the | ||
| * four words must be non-zero (the all-zero seed is a fixed point of the | ||
| * algorithm and produces a degenerate sequence). | ||
| */ | ||
| export function makeXoshiro256ss(state: readonly [bigint, bigint, bigint, bigint]): SeededPrng { | ||
| let s0 = state[0] & U64_MASK; | ||
| let s1 = state[1] & U64_MASK; | ||
| let s2 = state[2] & U64_MASK; | ||
| let s3 = state[3] & U64_MASK; | ||
| if (s0 === 0n && s1 === 0n && s2 === 0n && s3 === 0n) { | ||
| throw new Error("[mochi/consistency] xoshiro256** seed must not be all zero"); | ||
| } | ||
| function nextU64(): bigint { | ||
| // result = rotl(s1 * 5, 7) * 9 | ||
| const result = (rotl((s1 * 5n) & U64_MASK, 7) * 9n) & U64_MASK; | ||
| const t = (s1 << 17n) & U64_MASK; | ||
| s2 ^= s0; | ||
| s3 ^= s1; | ||
| s1 ^= s2; | ||
| s0 ^= s3; | ||
| s2 ^= t; | ||
| s3 = rotl(s3, 45); | ||
| return result; | ||
| } | ||
| function nextU32(): number { | ||
| return Number(nextU64() >> 32n); | ||
| } | ||
| function nextFloat01(): number { | ||
| // Standard xoshiro recipe: take the high 53 bits and divide by 2^53. | ||
| const hi53 = nextU64() >> 11n; | ||
| return Number(hi53) / 2 ** 53; | ||
| } | ||
| function nextIntInclusive(lo: number, hi: number): number { | ||
| if (!Number.isInteger(lo) || !Number.isInteger(hi)) { | ||
| throw new Error("[mochi/consistency] nextIntInclusive requires integer bounds"); | ||
| } | ||
| if (lo > hi) { | ||
| throw new Error(`[mochi/consistency] nextIntInclusive lo (${lo}) > hi (${hi})`); | ||
| } | ||
| const span = BigInt(hi - lo + 1); | ||
| // Modulo bias on a 64-bit draw with span <= 2^53 is below the IEEE-754 | ||
| // resolution we ever observe; rules use this for small ranges. | ||
| return lo + Number(nextU64() % span); | ||
| } | ||
| function pick<T>(arr: readonly T[]): T { | ||
| if (arr.length === 0) { | ||
| throw new Error("[mochi/consistency] pick: array is empty"); | ||
| } | ||
| const idx = nextIntInclusive(0, arr.length - 1); | ||
| // We just bounds-checked; non-null assert is safe. | ||
| return arr[idx] as T; | ||
| } | ||
| function nextHex(byteLength: number): string { | ||
| if (!Number.isInteger(byteLength) || byteLength <= 0) { | ||
| throw new Error("[mochi/consistency] nextHex: byteLength must be a positive integer"); | ||
| } | ||
| let out = ""; | ||
| let remaining = byteLength; | ||
| while (remaining > 0) { | ||
| const word = nextU64(); | ||
| // Each u64 yields 16 hex chars; we may trim the last word. | ||
| const hex = word.toString(16).padStart(16, "0"); | ||
| const take = Math.min(remaining, 8) * 2; | ||
| out += hex.slice(0, take); | ||
| remaining -= 8; | ||
| } | ||
| return out; | ||
| } | ||
| return { nextU64, nextU32, nextFloat01, nextIntInclusive, pick, nextHex }; | ||
| } |
+61
| /** | ||
| * The Rule contract — the unit of relational locking inside the consistency | ||
| * engine. PLAN.md §5.2 / §9.2. | ||
| * | ||
| * Each rule reads a tuple of dotted-path inputs from the matrix-under- | ||
| * construction, runs its `derive` function (pure + deterministic given the | ||
| * inputs and PRNG), and the engine writes the returned value to the rule's | ||
| * `output` path. Rules are executed in topological order; the engine | ||
| * verifies the DAG is acyclic before any rule runs. | ||
| * | ||
| * The PRNG is the only side-channel a rule may read beyond its declared | ||
| * inputs. PRNG state is forked per (profile.id, seed); rules that consume | ||
| * it produce different outputs per seed but the same output per (profile, | ||
| * seed) pair. | ||
| */ | ||
| import type { SeededPrng } from "./prng/xoshiro256ss"; | ||
| /** | ||
| * The engine's view of a rule. The input tuple type is erased to | ||
| * `readonly unknown[]` so heterogeneous rules collect into a single | ||
| * `readonly Rule[]` array. Individual rule definitions narrow the tuple | ||
| * via the `defineRule` helper for ergonomic, type-safe authoring. | ||
| */ | ||
| export interface Rule { | ||
| /** Stable rule id, e.g. `"R-001"`. */ | ||
| readonly id: string; | ||
| /** Short human description of the lock the rule encodes. */ | ||
| readonly description: string; | ||
| /** Dotted paths into the matrix-under-construction. Empty for source rules. */ | ||
| readonly inputs: readonly string[]; | ||
| /** | ||
| * Dotted path the rule writes. Must be unique across the rule list; the | ||
| * engine raises `DuplicateOutputError` otherwise. | ||
| */ | ||
| readonly output: string; | ||
| /** Compute the output. Must be pure given (inputs, prng). */ | ||
| readonly derive: (inputs: readonly unknown[], prng: SeededPrng) => unknown; | ||
| } | ||
| /** | ||
| * Define a rule with a typed input tuple `I` and output `O`. The helper | ||
| * casts the typed `derive` into the engine's erased shape, letting rule | ||
| * authors keep their narrowed types inside the body without polluting the | ||
| * collection-level rule list with variant generics. | ||
| */ | ||
| export function defineRule<I extends readonly unknown[], O>(rule: { | ||
| readonly id: string; | ||
| readonly description: string; | ||
| readonly inputs: readonly string[]; | ||
| readonly output: string; | ||
| readonly derive: (inputs: I, prng: SeededPrng) => O; | ||
| }): Rule { | ||
| return { | ||
| id: rule.id, | ||
| description: rule.description, | ||
| inputs: rule.inputs, | ||
| output: rule.output, | ||
| derive: rule.derive as (inputs: readonly unknown[], prng: SeededPrng) => unknown, | ||
| }; | ||
| } |
| /** | ||
| * Extras — phase-0.7 polish rules that round out the JS-derivable harness | ||
| * surface. Cover R-034..R-040. | ||
| * | ||
| * These rules are pure passthroughs over static lookups (per PLAN.md §6.1 | ||
| * "profile is the source of truth, lookups are for genuinely-derived-from- | ||
| * primitives" and the I-5 lesson from 0051). Every output lands as a | ||
| * JSON-encoded string under `uaCh.*` since the schema's `uaCh` is the | ||
| * single open-keyed expansion slot at v0.7. | ||
| * | ||
| * @see PLAN.md §9.2, §13.6 | ||
| * @see tasks/0070-consistency-rules-full.md | ||
| */ | ||
| import type { ProfileV1 } from "../generated/profile"; | ||
| import { defineRule, type Rule } from "../rule"; | ||
| import { DEVICES_BY_OS, SUPPORTED_CONSTRAINTS } from "./lookups/media-devices"; | ||
| import { PERMISSIONS_DEFAULT_STATE } from "./lookups/permissions"; | ||
| import { DESKTOP_ORIENTATION, MEDIA_QUERY_DEFAULTS } from "./lookups/screen-extras"; | ||
| type OsName = ProfileV1["os"]["name"]; | ||
| /** | ||
| * R-034 — `os.name` → `uaCh.media-devices` JSON shape. | ||
| * | ||
| * The output bundles the typical (kind, label) device shape from the | ||
| * lookup; deviceId/groupId stay empty here and are filled in by the inject- | ||
| * side seeded xoshiro derivation. Keeping the seeded IDs out of the matrix | ||
| * keeps the matrix byte-stable per (profile, seed) — same matrix, two | ||
| * inject runs, identical IDs. | ||
| */ | ||
| export const R034: Rule = defineRule<readonly [OsName], string>({ | ||
| id: "R-034", | ||
| description: "MediaDevices.enumerateDevices shape (without seeded IDs) per OS", | ||
| inputs: ["os.name"], | ||
| output: "uaCh.media-devices", | ||
| derive([osName]) { | ||
| return JSON.stringify(DEVICES_BY_OS[osName]); | ||
| }, | ||
| }); | ||
| /** | ||
| * R-035 — `os.name` → `uaCh.media-supported-constraints` JSON map. | ||
| * | ||
| * Static across desktop OS at this Chrome major; we still gate the rule on | ||
| * `os.name` so the DAG records the relationship (PLAN.md §9.2 "rules | ||
| * declare their semantic dependency, not just the data dependency"). | ||
| */ | ||
| export const R035: Rule = defineRule<readonly [OsName], string>({ | ||
| id: "R-035", | ||
| description: "MediaDevices.getSupportedConstraints map (Chrome ≥ 130 default)", | ||
| inputs: ["os.name"], | ||
| output: "uaCh.media-supported-constraints", | ||
| derive() { | ||
| return JSON.stringify(SUPPORTED_CONSTRAINTS); | ||
| }, | ||
| }); | ||
| /** | ||
| * R-036 — `os.name` → `uaCh.permissions-defaults` JSON map. | ||
| * | ||
| * Stable across desktop Chrome: most permissions default to `"prompt"`, | ||
| * the sensor cluster + clipboard-write to `"granted"`. Inject overrides | ||
| * `Permissions.prototype.query` to consult this map. | ||
| */ | ||
| export const R036: Rule = defineRule<readonly [OsName], string>({ | ||
| id: "R-036", | ||
| description: "Permissions.query default-state map per fresh-profile Chrome", | ||
| inputs: ["os.name"], | ||
| output: "uaCh.permissions-defaults", | ||
| derive() { | ||
| return JSON.stringify(PERMISSIONS_DEFAULT_STATE); | ||
| }, | ||
| }); | ||
| /** | ||
| * R-037 — `os.name` → `uaCh.connection` (Network Information API JSON). | ||
| * | ||
| * Chrome desktop reports `effectiveType: "4g"`, plausible downlink ≈ | ||
| * 10 mbps, RTT ≈ 50ms, saveData false. Captured baselines vary on | ||
| * downlink/rtt by physical link; harness normalize sentinelizes those | ||
| * leaves so we ship plausible defaults here. | ||
| */ | ||
| export const R037: Rule = defineRule<readonly [OsName], string>({ | ||
| id: "R-037", | ||
| description: "navigator.connection (NetworkInformation) defaults", | ||
| inputs: ["os.name"], | ||
| output: "uaCh.connection", | ||
| derive() { | ||
| return JSON.stringify({ | ||
| effectiveType: "4g", | ||
| downlink: 10, | ||
| rtt: 50, | ||
| saveData: false, | ||
| }); | ||
| }, | ||
| }); | ||
| /** R-038 — `os.name` → `uaCh.screen-orientation` JSON `{type, angle}`. */ | ||
| export const R038: Rule = defineRule<readonly [OsName], string>({ | ||
| id: "R-038", | ||
| description: "screen.orientation — landscape-primary on desktop", | ||
| inputs: ["os.name"], | ||
| output: "uaCh.screen-orientation", | ||
| derive() { | ||
| return JSON.stringify(DESKTOP_ORIENTATION); | ||
| }, | ||
| }); | ||
| /** R-039 — `os.name` → `uaCh.media-queries` JSON map of feature → answer. */ | ||
| export const R039: Rule = defineRule<readonly [OsName], string>({ | ||
| id: "R-039", | ||
| description: "matchMedia default answers for desktop Chrome captures", | ||
| inputs: ["os.name"], | ||
| output: "uaCh.media-queries", | ||
| derive() { | ||
| return JSON.stringify(MEDIA_QUERY_DEFAULTS); | ||
| }, | ||
| }); | ||
| /** | ||
| * R-040 — `[device.cores]` → `uaCh.storage-estimate` JSON `{quota, usage}`. | ||
| * | ||
| * Chrome's `navigator.storage.estimate()` returns a quota that scales with | ||
| * available disk; harness normalize sentinelizes the `quota` leaf, so the | ||
| * exact value does not need to match. We seed a plausible value derived | ||
| * from the device cores (a stable proxy that varies per profile). | ||
| */ | ||
| export const R040: Rule = defineRule<readonly [number], string>({ | ||
| id: "R-040", | ||
| description: "navigator.storage.estimate() — quota proxy + zero usage", | ||
| inputs: ["device.cores"], | ||
| output: "uaCh.storage-estimate", | ||
| derive([cores]) { | ||
| // ~74 GB per core baseline — gives 64 GB on 1-core through 1 TB on | ||
| // 14-core. Real Chrome reports per-disk-free-space; the exact number | ||
| // is sentinelized by harness normalize so any plausible int works. | ||
| const quota = cores * 74_000_000_000; | ||
| return JSON.stringify({ quota, usage: 0 }); | ||
| }, | ||
| }); | ||
| export const EXTRAS_RULES: readonly Rule[] = [R034, R035, R036, R037, R038, R039, R040]; |
| /** | ||
| * GPU + WebGL rules. Cover R-001, R-002, R-003, R-024, R-025. | ||
| * | ||
| * @see tasks/0020-consistency-engine-v0.md | ||
| * @see PLAN.md §9.2 | ||
| */ | ||
| import { defineRule, type Rule } from "../rule"; | ||
| import { | ||
| classifyGpuVendor, | ||
| deriveWebglUnmaskedRenderer, | ||
| deriveWebglUnmaskedVendor, | ||
| lookupMaxColorAttachments, | ||
| lookupMaxTextureSize, | ||
| WEBGL_EXTENSIONS_BY_VENDOR, | ||
| } from "./lookups/gpu"; | ||
| /** R-001 — `gpu.{vendor,renderer}` → `gpu.webglUnmaskedVendor`. */ | ||
| export const R001: Rule = defineRule<readonly [string, string], string>({ | ||
| id: "R-001", | ||
| description: "WebGL unmasked vendor — Chrome wraps device vendor in 'Google Inc. (...)'", | ||
| inputs: ["gpu.vendor", "gpu.renderer"], | ||
| output: "gpu.webglUnmaskedVendor", | ||
| derive([vendor, renderer]) { | ||
| return deriveWebglUnmaskedVendor(vendor, renderer); | ||
| }, | ||
| }); | ||
| /** R-002 — `gpu.{vendor,renderer}` → `gpu.webglUnmaskedRenderer`. */ | ||
| export const R002: Rule = defineRule<readonly [string, string], string>({ | ||
| id: "R-002", | ||
| description: "WebGL unmasked renderer — Chrome wraps device renderer with ANGLE prefix", | ||
| inputs: ["gpu.vendor", "gpu.renderer"], | ||
| output: "gpu.webglUnmaskedRenderer", | ||
| derive([vendor, renderer]) { | ||
| return deriveWebglUnmaskedRenderer(vendor, renderer); | ||
| }, | ||
| }); | ||
| /** R-003 — `gpu.renderer` → `gpu.webglMaxTextureSize` (lookup). */ | ||
| export const R003: Rule = defineRule<readonly [string], number>({ | ||
| id: "R-003", | ||
| description: "MAX_TEXTURE_SIZE lookup keyed off renderer family", | ||
| inputs: ["gpu.renderer"], | ||
| output: "gpu.webglMaxTextureSize", | ||
| derive([renderer]) { | ||
| return lookupMaxTextureSize(renderer); | ||
| }, | ||
| }); | ||
| /** R-024 — `gpu.vendor` → `gpu.webglExtensions` (curated per vendor). */ | ||
| export const R024: Rule = defineRule<readonly [string], readonly string[]>({ | ||
| id: "R-024", | ||
| description: "Curated WebGL extension list per GPU vendor class", | ||
| inputs: ["gpu.vendor"], | ||
| output: "gpu.webglExtensions", | ||
| derive([vendor]) { | ||
| return [...WEBGL_EXTENSIONS_BY_VENDOR[classifyGpuVendor(vendor)]]; | ||
| }, | ||
| }); | ||
| /** R-025 — `gpu.renderer` → `gpu.webglMaxColorAttachments` (lookup). */ | ||
| export const R025: Rule = defineRule<readonly [string], number>({ | ||
| id: "R-025", | ||
| description: "MAX_COLOR_ATTACHMENTS lookup — desktop ⇒ 8, mobile ⇒ 4", | ||
| inputs: ["gpu.renderer"], | ||
| output: "gpu.webglMaxColorAttachments", | ||
| derive([renderer]) { | ||
| return lookupMaxColorAttachments(renderer); | ||
| }, | ||
| }); | ||
| export const GPU_RULES: readonly Rule[] = [R001, R002, R003, R024, R025]; |
| /** | ||
| * Rule registry — the single source of truth for the v0.2 ruleset. | ||
| * | ||
| * The order in `RULES` is the **declaration order**. The engine topo-sorts | ||
| * via `validateAndOrder` (see `dag.ts`); declaration order is used as the | ||
| * tie-breaker for nodes with equal topological depth. Adding a rule means: | ||
| * 1. write the rule in its category file (gpu / userAgent / navigator / …), | ||
| * 2. import it here, | ||
| * 3. append it to `RULES`. | ||
| * | ||
| * Acyclicity is verified the first time `deriveMatrix` runs; the rule list | ||
| * is also exercised by `tests/contract/consistency-rules.contract.test.ts`. | ||
| * | ||
| * @see PLAN.md §5.2 / §9.2 | ||
| * @see tasks/0020-consistency-engine-v0.md | ||
| */ | ||
| import type { Rule } from "../rule"; | ||
| import { EXTRAS_RULES } from "./extras"; | ||
| import { GPU_RULES } from "./gpu"; | ||
| import { LOCALE_RULES } from "./locale"; | ||
| import { NAVIGATOR_RULES } from "./navigator"; | ||
| import { SCREEN_RULES } from "./screen"; | ||
| import { USER_AGENT_RULES } from "./userAgent"; | ||
| import { WEBGPU_RULES } from "./webgpu"; | ||
| /** | ||
| * The full rule list. Don't reorder casually — the topo sort uses | ||
| * declaration order as the tie-breaker, so reorderings can change the | ||
| * output of seed-driven rules that share a PRNG cursor. | ||
| * | ||
| * Rule families: | ||
| * - GPU_RULES R-001..R-003, R-024, R-025 | ||
| * - USER_AGENT_RULES R-004..R-007, R-023, R-026, R-031 | ||
| * - NAVIGATOR_RULES R-008..R-009, R-015..R-018, R-020, R-022, R-027, | ||
| * R-028, R-030 | ||
| * - SCREEN_RULES R-010..R-012, R-021, R-029 | ||
| * - LOCALE_RULES R-013, R-014, R-019 | ||
| * - WEBGPU_RULES R-032, R-033 | ||
| * - EXTRAS_RULES R-034..R-040 (mediaDevices / permissions / network / | ||
| * screen.orientation / matchMedia / storage) | ||
| */ | ||
| export const RULES: readonly Rule[] = [ | ||
| ...GPU_RULES, | ||
| ...USER_AGENT_RULES, | ||
| ...NAVIGATOR_RULES, | ||
| ...SCREEN_RULES, | ||
| ...LOCALE_RULES, | ||
| ...WEBGPU_RULES, | ||
| ...EXTRAS_RULES, | ||
| ]; |
| /** | ||
| * Locale + timezone + fonts rules. Cover R-013, R-014, R-019. | ||
| * | ||
| * R-015 (locale → navigator.language) and R-016 (languages → | ||
| * navigator.languages) live in `navigator.ts` since they belong to the | ||
| * navigator surface even though their inputs are top-level locale fields. | ||
| * | ||
| * @see PLAN.md §9.2 | ||
| */ | ||
| import type { ProfileV1 } from "../generated/profile"; | ||
| import { defineRule, type Rule } from "../rule"; | ||
| import { FONTS_BY_OS } from "./lookups/os"; | ||
| type OsName = ProfileV1["os"]["name"]; | ||
| /** | ||
| * R-013 — `os.name` → `fonts.list`. Curated baseline list per OS. The full | ||
| * device-specific list (e.g. mac-m2 vs mac-intel) lands in phase 0.7. | ||
| */ | ||
| export const R013: Rule = defineRule<readonly [OsName], readonly string[]>({ | ||
| id: "R-013", | ||
| description: "fonts.list — curated OS baseline; phase 0.7 expands per device", | ||
| inputs: ["os.name"], | ||
| output: "fonts.list", | ||
| derive([osName]) { | ||
| return [...FONTS_BY_OS[osName]]; | ||
| }, | ||
| }); | ||
| /** | ||
| * R-014 — `timezone` → `timezone`. Passthrough; the inject layer surfaces | ||
| * this via `Intl.DateTimeFormat().resolvedOptions().timeZone` overrides. | ||
| */ | ||
| export const R014: Rule = defineRule<readonly [string], string>({ | ||
| id: "R-014", | ||
| description: "Intl.DateTimeFormat() resolvedOptions().timeZone — passthrough", | ||
| inputs: ["timezone"], | ||
| output: "timezone", | ||
| derive([tz]) { | ||
| return tz; | ||
| }, | ||
| }); | ||
| /** | ||
| * R-019 — `[seed]` → `uaCh.seed-derived-noise`. | ||
| * | ||
| * A seed-derived placeholder for any future per-seed fingerprint slot | ||
| * (visitor-id-style values exist in phase 0.7 — for now we record the | ||
| * deterministic noise the engine *would* use for them). | ||
| * | ||
| * The PRNG state is forked per (profile.id, seed) by `seedToPrng`, so this | ||
| * rule produces different bytes per seed but the same bytes per (profile, | ||
| * seed) pair. The output is a 16-byte hex string. | ||
| */ | ||
| export const R019: Rule = defineRule<readonly [string], string>({ | ||
| id: "R-019", | ||
| description: "Seed-derived noise placeholder — visitorId precursor", | ||
| inputs: ["seed"], | ||
| output: "uaCh.seed-derived-noise", | ||
| derive(_inputs, prng) { | ||
| return prng.nextHex(16); | ||
| }, | ||
| }); | ||
| export const LOCALE_RULES: readonly Rule[] = [R013, R014, R019]; |
| /** | ||
| * Browser lookup tables — UA templates, vendor strings, brand-list | ||
| * compositions for Sec-CH-UA. v0.2 covers the chromium-family browsers | ||
| * the v1 catalog will ship. | ||
| * | ||
| * @see PLAN.md §9.5 — userAgent + uaCh derivation chain | ||
| */ | ||
| import type { ProfileV1 } from "../../generated/profile"; | ||
| /** Browser key matching `ProfileV1["browser"]["name"]`. */ | ||
| export type BrowserKey = ProfileV1["browser"]["name"]; | ||
| /** OS key matching `ProfileV1["os"]["name"]`. */ | ||
| export type OsKey = ProfileV1["os"]["name"]; | ||
| /** | ||
| * `navigator.vendor` — universally `"Google Inc."` for chromium-family | ||
| * browsers. Brave and Arc leave this untouched even though the brand | ||
| * differs in the UA-CH brand list. | ||
| */ | ||
| export const VENDOR_BY_BROWSER: Readonly<Record<BrowserKey, string>> = { | ||
| chrome: "Google Inc.", | ||
| edge: "Google Inc.", | ||
| brave: "Google Inc.", | ||
| arc: "Google Inc.", | ||
| opera: "Google Inc.", | ||
| }; | ||
| /** | ||
| * The brand list each browser inserts into Sec-CH-UA. Order is significant — | ||
| * fingerprint surfaces compare the full ordered list verbatim. | ||
| * | ||
| * Real Chrome 110+ emits brands in `[Branded, GREASE, Chromium]` order with | ||
| * a pinned GREASE label (`Not.A/Brand`) and a pinned GREASE major (`8`). | ||
| * The previous ordering (`[Chromium, Branded, GREASE]`) and GREASE label | ||
| * (`Not_A Brand`) were the harness-surfaced bug captured in | ||
| * tasks/0051-consistency-stack-fixes.md (Group B). | ||
| */ | ||
| export const SEC_CH_UA_BRANDS_BY_BROWSER: Readonly<Record<BrowserKey, readonly string[]>> = { | ||
| chrome: ["Google Chrome", "Not.A/Brand", "Chromium"], | ||
| edge: ["Microsoft Edge", "Not.A/Brand", "Chromium"], | ||
| brave: ["Brave", "Not.A/Brand", "Chromium"], | ||
| arc: ["Arc", "Not.A/Brand", "Chromium"], | ||
| opera: ["Opera", "Not.A/Brand", "Chromium"], | ||
| }; | ||
| /** | ||
| * The pinned GREASE label Chrome 110+ emits. Real Chrome shuffles GREASE | ||
| * per boot for spec compliance; v0.2 keeps a fixed value for determinism. | ||
| * Phase 0.7 may revisit per-boot shuffle. | ||
| */ | ||
| const GREASE_BRAND = "Not.A/Brand"; | ||
| /** | ||
| * The pinned GREASE major. Chrome 110+ emits `"Not.A/Brand";v="8"` regardless | ||
| * of the real browser major, by design — the GREASE entry's purpose is to | ||
| * exercise downstream parsers with an unfamiliar value, not to track Chrome. | ||
| */ | ||
| const GREASE_VERSION = "8"; | ||
| /** Format a single brand entry as it appears in Sec-CH-UA: `"<brand>";v="<major>"`. */ | ||
| function formatBrand(brand: string, major: string): string { | ||
| // The spec uses curly-quoted JSON-style escaping — biome won't complain | ||
| // because we treat the literal as data, not source code. | ||
| return `"${brand}";v="${major}"`; | ||
| } | ||
| /** | ||
| * Compose the full Sec-CH-UA header value. Stable, deterministic, depends | ||
| * only on browser identity + major version. The GREASE entry uses its own | ||
| * pinned version (`8`), not the browser major. | ||
| */ | ||
| export function deriveSecChUa(browser: BrowserKey, major: string): string { | ||
| const brands = SEC_CH_UA_BRANDS_BY_BROWSER[browser]; | ||
| return brands.map((b) => formatBrand(b, b === GREASE_BRAND ? GREASE_VERSION : major)).join(", "); | ||
| } | ||
| /** | ||
| * UA template substituted with the browser version + a seeded build number | ||
| * variance. The v0.2 templates cover the matrix of (os, browser) declared | ||
| * in the v1 catalog. | ||
| * | ||
| * Template tokens: | ||
| * `{MAJOR}` — browser major version (e.g. "131") | ||
| * `{BUILD}` — full version string (e.g. "131.0.6778.110") | ||
| */ | ||
| const UA_TEMPLATES: Readonly<Record<OsKey, Readonly<Record<BrowserKey, string>>>> = { | ||
| macos: { | ||
| chrome: | ||
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36", | ||
| edge: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36 Edg/{BUILD}", | ||
| brave: | ||
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36", | ||
| arc: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36", | ||
| opera: | ||
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36 OPR/{MAJOR}.0.0.0", | ||
| }, | ||
| windows: { | ||
| chrome: | ||
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36", | ||
| edge: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36 Edg/{BUILD}", | ||
| brave: | ||
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36", | ||
| arc: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36", | ||
| opera: | ||
| "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36 OPR/{MAJOR}.0.0.0", | ||
| }, | ||
| linux: { | ||
| chrome: | ||
| "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36", | ||
| edge: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36 Edg/{BUILD}", | ||
| brave: | ||
| "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36", | ||
| arc: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36", | ||
| opera: | ||
| "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{BUILD} Safari/537.36 OPR/{MAJOR}.0.0.0", | ||
| }, | ||
| }; | ||
| /** | ||
| * Build a User-Agent string given the OS, browser, and a full version. | ||
| * `version` should look like `"131.0.6778.110"`; the major is extracted as | ||
| * the first dot-separated segment. | ||
| */ | ||
| export function deriveUserAgent(os: OsKey, browser: BrowserKey, version: string): string { | ||
| const major = version.split(".")[0] ?? "0"; | ||
| const tmpl = UA_TEMPLATES[os][browser]; | ||
| return tmpl.replace(/\{BUILD\}/g, version).replace(/\{MAJOR\}/g, major); | ||
| } | ||
| /** | ||
| * Build a deterministic full-build version string from a major version and | ||
| * a 32-bit seed-derived integer. The two middle digits ("0.X") are 0 to | ||
| * match Chrome stable; the patch (Y) and build (Z) are seed-derived. | ||
| * | ||
| * Chrome stable build numbers are typically of the form | ||
| * `<major>.0.<build>.<patch>` with `build` ~ 4-digit and `patch` ~ 2-3 digit. | ||
| * v0.2 uses a stable, plausible distribution: build ∈ [6000, 6999], | ||
| * patch ∈ [1, 199]. | ||
| */ | ||
| export function deriveBuildVersion(major: string, seedDerivedU32: number): string { | ||
| const build = 6000 + (seedDerivedU32 % 1000); | ||
| const patch = 1 + (Math.floor(seedDerivedU32 / 1000) % 199); | ||
| return `${major}.0.${build}.${patch}`; | ||
| } | ||
| /** | ||
| * Tip-of-stable patch versions, keyed by `(browser, major)`. Chrome's | ||
| * `userAgentData.getHighEntropyValues({hints:["fullVersionList"]})` returns | ||
| * the EXACT stable patch (e.g. `"147.0.7727.138"`); the marketing major and | ||
| * the seed-derived build above are both fine for the legacy `userAgent` | ||
| * string, but the high-entropy fullVersionList probe expects byte-stable | ||
| * tip values. Capturing devices observe the published tip; we mirror it. | ||
| * | ||
| * Maintenance: refresh this table each Chrome major. Missing keys fall | ||
| * through to the synthesized `<major>.0.<build>.<patch>` (R-004 chain). | ||
| * | ||
| * @see PLAN.md §9.2 R-031, §13.6 | ||
| */ | ||
| export const BROWSER_TIP_FULL_VERSION: Readonly< | ||
| Record<BrowserKey, Readonly<Record<string, string>>> | ||
| > = { | ||
| chrome: { | ||
| "147": "147.0.7727.138", | ||
| "146": "146.0.7390.122", | ||
| "145": "145.0.7242.79", | ||
| "144": "144.0.7180.65", | ||
| "143": "143.0.7106.65", | ||
| "142": "142.0.6993.119", | ||
| "141": "141.0.6938.122", | ||
| "140": "140.0.6810.143", | ||
| "133": "133.0.6943.142", | ||
| "132": "132.0.6834.111", | ||
| "131": "131.0.6778.110", | ||
| }, | ||
| edge: { | ||
| "147": "147.0.3477.94", | ||
| "133": "133.0.3065.92", | ||
| "131": "131.0.2903.99", | ||
| }, | ||
| brave: { | ||
| "147": "147.0.7727.138", | ||
| "131": "131.0.6778.110", | ||
| }, | ||
| arc: { | ||
| "131": "131.0.6778.110", | ||
| }, | ||
| opera: { | ||
| "131": "131.0.0.0", | ||
| }, | ||
| }; | ||
| /** | ||
| * Resolve a browser's tip full-version. Falls back to `<major>.0.0.0` when | ||
| * the table doesn't carry the key, which matches Chrome's no-data shape. | ||
| */ | ||
| export function lookupTipFullVersion(browser: BrowserKey, major: string): string | null { | ||
| const family = BROWSER_TIP_FULL_VERSION[browser]; | ||
| const tip = family[major]; | ||
| if (tip !== undefined) return tip; | ||
| return null; | ||
| } |
| /** | ||
| * GPU lookup tables — small static maps that translate primitive GPU | ||
| * identifiers (vendor + renderer) into derived WebGL parameters. | ||
| * | ||
| * v0.2 covers the v1 catalog profiles (Apple M-series, Intel Iris Xe, | ||
| * AMD Radeon, NVIDIA RTX, Adreno mobile placeholder). Real device-specific | ||
| * data for the full catalog lands in phase 0.7. | ||
| * | ||
| * @see PLAN.md §9.5 / tasks/0020 R-001..R-003, R-024, R-025 | ||
| */ | ||
| /** | ||
| * Coarse vendor classification used for table keys. Anything we don't | ||
| * recognize maps to `"other"`, which the rule layer surfaces as the | ||
| * conservative "Google Inc." fallback Chrome historically emits. | ||
| */ | ||
| export type GpuVendorKey = "apple" | "intel" | "amd" | "nvidia" | "qualcomm" | "other"; | ||
| /** | ||
| * Classify a `gpu.vendor` string. We're tolerant of casing and the | ||
| * "Google Inc. (X)" disclosure-extension wrapping that Chrome uses. | ||
| */ | ||
| export function classifyGpuVendor(vendor: string): GpuVendorKey { | ||
| const v = vendor.toLowerCase(); | ||
| if (v.includes("apple")) return "apple"; | ||
| if (v.includes("intel")) return "intel"; | ||
| if (v.includes("amd") || v.includes("ati") || v.includes("radeon")) return "amd"; | ||
| if (v.includes("nvidia") || v.includes("geforce")) return "nvidia"; | ||
| if (v.includes("qualcomm") || v.includes("adreno")) return "qualcomm"; | ||
| return "other"; | ||
| } | ||
| /** | ||
| * Compose the WEBGL_debug_renderer_info `UNMASKED_VENDOR_WEBGL` string the | ||
| * way Chrome reports it. Chrome wraps the underlying vendor in | ||
| * `"Google Inc. (<vendor>)"` on most platforms. | ||
| */ | ||
| export function deriveWebglUnmaskedVendor(vendor: string, _renderer: string): string { | ||
| // Direct passthrough when the input already includes "Google Inc.". | ||
| if (vendor.toLowerCase().startsWith("google inc.")) return vendor; | ||
| return `Google Inc. (${vendor})`; | ||
| } | ||
| /** | ||
| * Compose the WEBGL_debug_renderer_info `UNMASKED_RENDERER_WEBGL` string. | ||
| * Chrome wraps the renderer with the ANGLE backend name on macOS/Windows. | ||
| * | ||
| * The profile may supply the renderer in either of three shapes: | ||
| * 1. fully wrapped — `"ANGLE (Apple, ANGLE Metal Renderer: Apple M4 Max, Unspecified Version)"` | ||
| * 2. half-wrapped — `"ANGLE Metal Renderer: Apple M4 Max"` (Chromium-internal form) | ||
| * 3. raw — `"Apple M2"` / `"Intel Iris Xe Graphics"` (vendor doc form) | ||
| * | ||
| * Chrome's WebGL `getParameter(UNMASKED_RENDERER_WEBGL)` always emits form 1. | ||
| * v0.7: detect form 1 via the literal `"ANGLE ("` prefix (with paren) and | ||
| * pass through; otherwise wrap. Form 2 ("ANGLE Metal Renderer: ...") is | ||
| * pulled into the Apple wrapper since the inner string IS the renderer | ||
| * Chrome reports inside the wrap. | ||
| */ | ||
| export function deriveWebglUnmaskedRenderer(vendor: string, renderer: string): string { | ||
| // Already wrapped form (`"ANGLE ("`) — pass through verbatim. Note we | ||
| // require the open paren so half-wrapped `"ANGLE Metal Renderer: …"` | ||
| // strings still hit the wrap branch below. v0.5 used a substring check | ||
| // that conflated the two forms; the harness gate flipped it. | ||
| if (renderer.startsWith("ANGLE (")) return renderer; | ||
| const vendorKey = classifyGpuVendor(vendor); | ||
| switch (vendorKey) { | ||
| case "apple": { | ||
| // Strip a leading `"ANGLE Metal Renderer: "` (form 2) so we don't | ||
| // double-prefix the wrapped output. | ||
| const inner = renderer.replace(/^ANGLE Metal Renderer: */i, ""); | ||
| return `ANGLE (Apple, ANGLE Metal Renderer: ${inner}, Unspecified Version)`; | ||
| } | ||
| case "intel": | ||
| return `ANGLE (Intel, ${renderer}, OpenGL 4.1)`; | ||
| case "amd": | ||
| return `ANGLE (AMD, ${renderer}, OpenGL 4.1)`; | ||
| case "nvidia": | ||
| return `ANGLE (NVIDIA, ${renderer}, OpenGL 4.1)`; | ||
| case "qualcomm": | ||
| return `ANGLE (Qualcomm, Adreno (TM) ${renderer}, OpenGL 3.2)`; | ||
| default: | ||
| return `ANGLE (${renderer})`; | ||
| } | ||
| } | ||
| /** | ||
| * `MAX_TEXTURE_SIZE` lookup. Modern desktop GPUs report 16384 almost | ||
| * universally; older / mobile parts vary. Conservative fallback: 16384. | ||
| */ | ||
| export function lookupMaxTextureSize(renderer: string): number { | ||
| const r = renderer.toLowerCase(); | ||
| if (r.includes("apple m") || r.includes("apple a")) return 16384; | ||
| if (r.includes("iris xe") || r.includes("uhd graphics")) return 16384; | ||
| if (r.includes("intel hd graphics 4")) return 8192; | ||
| if (r.includes("radeon rx 5") || r.includes("radeon rx 6") || r.includes("radeon rx 7")) | ||
| return 16384; | ||
| if (r.includes("geforce rtx") || r.includes("geforce gtx 1") || r.includes("geforce gtx 16")) | ||
| return 16384; | ||
| if (r.includes("adreno 6") || r.includes("adreno 7")) return 16384; | ||
| if (r.includes("adreno 5")) return 8192; | ||
| return 16384; | ||
| } | ||
| /** | ||
| * `MAX_COLOR_ATTACHMENTS`. WebGL 2 minimum is 4; modern desktop reports 8. | ||
| * Mobile typically reports 4. | ||
| */ | ||
| export function lookupMaxColorAttachments(renderer: string): number { | ||
| const r = renderer.toLowerCase(); | ||
| if (r.includes("adreno") || r.includes("mali")) return 4; | ||
| return 8; | ||
| } | ||
| /** | ||
| * Curated WebGL extension list per vendor class. v0.2 ships a baseline | ||
| * subset — the full per-device list is captured at probe time in phase 0.7. | ||
| * The order matches what Chrome reports in `getSupportedExtensions()` (the | ||
| * order itself is a fingerprint surface). | ||
| */ | ||
| export const WEBGL_EXTENSIONS_BY_VENDOR: Readonly<Record<GpuVendorKey, readonly string[]>> = { | ||
| apple: [ | ||
| "ANGLE_instanced_arrays", | ||
| "EXT_blend_minmax", | ||
| "EXT_color_buffer_half_float", | ||
| "EXT_float_blend", | ||
| "EXT_frag_depth", | ||
| "EXT_shader_texture_lod", | ||
| "EXT_texture_compression_bptc", | ||
| "EXT_texture_compression_rgtc", | ||
| "EXT_texture_filter_anisotropic", | ||
| "EXT_sRGB", | ||
| "OES_element_index_uint", | ||
| "OES_fbo_render_mipmap", | ||
| "OES_standard_derivatives", | ||
| "OES_texture_float", | ||
| "OES_texture_float_linear", | ||
| "OES_texture_half_float", | ||
| "OES_texture_half_float_linear", | ||
| "OES_vertex_array_object", | ||
| "WEBGL_color_buffer_float", | ||
| "WEBGL_compressed_texture_astc", | ||
| "WEBGL_compressed_texture_etc", | ||
| "WEBGL_compressed_texture_etc1", | ||
| "WEBGL_compressed_texture_s3tc", | ||
| "WEBGL_debug_renderer_info", | ||
| "WEBGL_debug_shaders", | ||
| "WEBGL_depth_texture", | ||
| "WEBGL_draw_buffers", | ||
| "WEBGL_lose_context", | ||
| "WEBGL_multi_draw", | ||
| ], | ||
| intel: [ | ||
| "ANGLE_instanced_arrays", | ||
| "EXT_blend_minmax", | ||
| "EXT_color_buffer_half_float", | ||
| "EXT_float_blend", | ||
| "EXT_frag_depth", | ||
| "EXT_shader_texture_lod", | ||
| "EXT_texture_compression_bptc", | ||
| "EXT_texture_compression_rgtc", | ||
| "EXT_texture_filter_anisotropic", | ||
| "EXT_sRGB", | ||
| "OES_element_index_uint", | ||
| "OES_fbo_render_mipmap", | ||
| "OES_standard_derivatives", | ||
| "OES_texture_float", | ||
| "OES_texture_float_linear", | ||
| "OES_texture_half_float", | ||
| "OES_texture_half_float_linear", | ||
| "OES_vertex_array_object", | ||
| "WEBGL_color_buffer_float", | ||
| "WEBGL_compressed_texture_s3tc", | ||
| "WEBGL_compressed_texture_s3tc_srgb", | ||
| "WEBGL_debug_renderer_info", | ||
| "WEBGL_debug_shaders", | ||
| "WEBGL_depth_texture", | ||
| "WEBGL_draw_buffers", | ||
| "WEBGL_lose_context", | ||
| "WEBGL_multi_draw", | ||
| ], | ||
| amd: [ | ||
| "ANGLE_instanced_arrays", | ||
| "EXT_blend_minmax", | ||
| "EXT_color_buffer_half_float", | ||
| "EXT_float_blend", | ||
| "EXT_frag_depth", | ||
| "EXT_shader_texture_lod", | ||
| "EXT_texture_compression_bptc", | ||
| "EXT_texture_compression_rgtc", | ||
| "EXT_texture_filter_anisotropic", | ||
| "EXT_sRGB", | ||
| "OES_element_index_uint", | ||
| "OES_fbo_render_mipmap", | ||
| "OES_standard_derivatives", | ||
| "OES_texture_float", | ||
| "OES_texture_float_linear", | ||
| "OES_texture_half_float", | ||
| "OES_texture_half_float_linear", | ||
| "OES_vertex_array_object", | ||
| "WEBGL_color_buffer_float", | ||
| "WEBGL_compressed_texture_s3tc", | ||
| "WEBGL_compressed_texture_s3tc_srgb", | ||
| "WEBGL_debug_renderer_info", | ||
| "WEBGL_debug_shaders", | ||
| "WEBGL_depth_texture", | ||
| "WEBGL_draw_buffers", | ||
| "WEBGL_lose_context", | ||
| "WEBGL_multi_draw", | ||
| ], | ||
| nvidia: [ | ||
| "ANGLE_instanced_arrays", | ||
| "EXT_blend_minmax", | ||
| "EXT_color_buffer_half_float", | ||
| "EXT_float_blend", | ||
| "EXT_frag_depth", | ||
| "EXT_shader_texture_lod", | ||
| "EXT_texture_compression_bptc", | ||
| "EXT_texture_compression_rgtc", | ||
| "EXT_texture_filter_anisotropic", | ||
| "EXT_sRGB", | ||
| "OES_element_index_uint", | ||
| "OES_fbo_render_mipmap", | ||
| "OES_standard_derivatives", | ||
| "OES_texture_float", | ||
| "OES_texture_float_linear", | ||
| "OES_texture_half_float", | ||
| "OES_texture_half_float_linear", | ||
| "OES_vertex_array_object", | ||
| "WEBGL_color_buffer_float", | ||
| "WEBGL_compressed_texture_s3tc", | ||
| "WEBGL_compressed_texture_s3tc_srgb", | ||
| "WEBGL_debug_renderer_info", | ||
| "WEBGL_debug_shaders", | ||
| "WEBGL_depth_texture", | ||
| "WEBGL_draw_buffers", | ||
| "WEBGL_lose_context", | ||
| "WEBGL_multi_draw", | ||
| ], | ||
| qualcomm: [ | ||
| "ANGLE_instanced_arrays", | ||
| "EXT_blend_minmax", | ||
| "EXT_color_buffer_half_float", | ||
| "EXT_frag_depth", | ||
| "EXT_shader_texture_lod", | ||
| "EXT_texture_filter_anisotropic", | ||
| "OES_element_index_uint", | ||
| "OES_standard_derivatives", | ||
| "OES_texture_float", | ||
| "OES_texture_half_float", | ||
| "OES_vertex_array_object", | ||
| "WEBGL_compressed_texture_astc", | ||
| "WEBGL_compressed_texture_etc", | ||
| "WEBGL_debug_renderer_info", | ||
| "WEBGL_lose_context", | ||
| ], | ||
| other: [ | ||
| "ANGLE_instanced_arrays", | ||
| "EXT_blend_minmax", | ||
| "OES_element_index_uint", | ||
| "OES_standard_derivatives", | ||
| "OES_texture_float", | ||
| "OES_vertex_array_object", | ||
| "WEBGL_debug_renderer_info", | ||
| "WEBGL_lose_context", | ||
| ], | ||
| }; |
| /** | ||
| * MediaDevices lookup tables — typical device shape per OS, plus the full | ||
| * `MediaTrackSupportedConstraints` map Chrome exposes via | ||
| * `mediaDevices.getSupportedConstraints()`. | ||
| * | ||
| * Captured Mac M4 baseline reports 3 devices in headless capture (audio | ||
| * input + audio output + video input, all label-blanked / id-blanked | ||
| * because permissions weren't prompted). Real Chrome with mic+cam grants | ||
| * lights up the same shape with non-empty `deviceId` / `groupId` strings. | ||
| * | ||
| * `deviceId` and `groupId` MUST be deterministic per `(profile, seed)` to | ||
| * survive harness normalization (which sentinelizes those leaves). The | ||
| * inject layer feeds the seeded xoshiro to derive 32-byte hex IDs. | ||
| * | ||
| * @see PLAN.md §9.5 / tasks/0070-consistency-rules-full.md (media-devices) | ||
| */ | ||
| import type { ProfileV1 } from "../../generated/profile"; | ||
| /** OS key matching `ProfileV1["os"]["name"]`. */ | ||
| export type OsKey = ProfileV1["os"]["name"]; | ||
| /** A single enumerated device entry (sans deviceId/groupId, those are seeded). */ | ||
| export interface DeviceShape { | ||
| readonly kind: "audioinput" | "audiooutput" | "videoinput"; | ||
| readonly label: string; | ||
| } | ||
| /** | ||
| * The default device shape per OS. Chrome with mic+cam grants typically | ||
| * reports one of each kind — additional devices stack as users plug them | ||
| * in. v0.7 baseline matches the captured-headless shape (3 devices, blank | ||
| * labels) so the harness can structurally diff against the live session. | ||
| */ | ||
| export const DEVICES_BY_OS: Readonly<Record<OsKey, readonly DeviceShape[]>> = { | ||
| macos: [ | ||
| { kind: "audioinput", label: "" }, | ||
| { kind: "videoinput", label: "" }, | ||
| { kind: "audiooutput", label: "" }, | ||
| ], | ||
| windows: [ | ||
| { kind: "audioinput", label: "" }, | ||
| { kind: "videoinput", label: "" }, | ||
| { kind: "audiooutput", label: "" }, | ||
| ], | ||
| linux: [ | ||
| { kind: "audioinput", label: "" }, | ||
| { kind: "videoinput", label: "" }, | ||
| { kind: "audiooutput", label: "" }, | ||
| ], | ||
| }; | ||
| /** | ||
| * The full `MediaTrackSupportedConstraints` map Chrome ≥ 130 reports. | ||
| * Captured verbatim from the Mac M4 baseline — every key present is `true`. | ||
| * Stable across desktop OS at this Chrome major. | ||
| */ | ||
| export const SUPPORTED_CONSTRAINTS: Readonly<Record<string, true>> = { | ||
| aspectRatio: true, | ||
| autoGainControl: true, | ||
| brightness: true, | ||
| channelCount: true, | ||
| colorTemperature: true, | ||
| contrast: true, | ||
| deviceId: true, | ||
| displaySurface: true, | ||
| echoCancellation: true, | ||
| exposureCompensation: true, | ||
| exposureMode: true, | ||
| exposureTime: true, | ||
| facingMode: true, | ||
| focusDistance: true, | ||
| focusMode: true, | ||
| frameRate: true, | ||
| groupId: true, | ||
| height: true, | ||
| iso: true, | ||
| latency: true, | ||
| noiseSuppression: true, | ||
| pan: true, | ||
| pointsOfInterest: true, | ||
| resizeMode: true, | ||
| restrictOwnAudio: true, | ||
| sampleRate: true, | ||
| sampleSize: true, | ||
| saturation: true, | ||
| sharpness: true, | ||
| suppressLocalAudioPlayback: true, | ||
| tilt: true, | ||
| torch: true, | ||
| voiceIsolation: true, | ||
| whiteBalanceMode: true, | ||
| width: true, | ||
| zoom: true, | ||
| }; |
| /** | ||
| * OS lookup tables — platform strings, font baselines, OS-chrome heights, | ||
| * and other OS-derived constants used by the v0.2 ruleset. | ||
| * | ||
| * @see PLAN.md §9.5 — fonts.list and platform string surface | ||
| */ | ||
| import type { ProfileV1 } from "../../generated/profile"; | ||
| /** Compact OS key matching `ProfileV1["os"]["name"]`. */ | ||
| export type OsKey = ProfileV1["os"]["name"]; | ||
| /** | ||
| * `navigator.platform` values. Even on Apple Silicon, Chrome historically | ||
| * reports `"MacIntel"` for compatibility — this is the v0.2 chaser-recon | ||
| * fingerprint baseline. Linux varies with arch. | ||
| */ | ||
| export const PLATFORM_BY_OS: Readonly<Record<OsKey, string>> = { | ||
| macos: "MacIntel", | ||
| windows: "Win32", | ||
| linux: "Linux x86_64", | ||
| }; | ||
| /** | ||
| * Sec-CH-UA-Platform value (note the surrounding quotes — that matches the | ||
| * on-the-wire header form Chrome emits for Sec-CH-UA-* headers). | ||
| */ | ||
| export const SEC_CH_UA_PLATFORM_BY_OS: Readonly<Record<OsKey, string>> = { | ||
| macos: '"macOS"', | ||
| windows: '"Windows"', | ||
| linux: '"Linux"', | ||
| }; | ||
| /** | ||
| * OS-chrome height (menubar, taskbar, dock) deducted from `display.height` | ||
| * to derive `screen.availHeight`. Units: physical pixels at the profile's | ||
| * declared `dpr`. v0.2 numbers from chaser-recon's typical-device baselines. | ||
| */ | ||
| export const OS_CHROME_HEIGHT_BY_OS: Readonly<Record<OsKey, number>> = { | ||
| macos: 25, // mac menu bar at 1x; Dock auto-hides on most captured profiles | ||
| windows: 40, // Windows 11 taskbar | ||
| linux: 27, // top panel on default Ubuntu / GNOME | ||
| }; | ||
| /** | ||
| * OS-chrome width deduction. Almost always 0 on desktop OSes (taskbars are | ||
| * horizontal on the bottom). Reserved for future side-dock profiles. | ||
| */ | ||
| export const OS_CHROME_WIDTH_BY_OS: Readonly<Record<OsKey, number>> = { | ||
| macos: 0, | ||
| windows: 0, | ||
| linux: 0, | ||
| }; | ||
| /** | ||
| * Browser-window chrome (URL bar + tabs + bookmarks) deducted from outer | ||
| * dimensions to compute `window.innerHeight`. Conservative averages across | ||
| * default browser configs. | ||
| */ | ||
| export const BROWSER_CHROME_HEIGHT_BY_OS: Readonly<Record<OsKey, number>> = { | ||
| macos: 87, | ||
| windows: 117, | ||
| linux: 110, | ||
| }; | ||
| /** | ||
| * Curated baseline font lists per OS. v0.2 ships only the universally- | ||
| * present subset that shows up across captured profiles. The full | ||
| * device-specific list (per `mac-m2-chrome-stable`, etc.) lands in phase | ||
| * 0.7 along with the canvas hash maps. | ||
| */ | ||
| export const FONTS_BY_OS: Readonly<Record<OsKey, readonly string[]>> = { | ||
| macos: [ | ||
| "American Typewriter", | ||
| "Andale Mono", | ||
| "Arial", | ||
| "Arial Black", | ||
| "Arial Hebrew", | ||
| "Arial Narrow", | ||
| "Arial Rounded MT Bold", | ||
| "Arial Unicode MS", | ||
| "Avenir", | ||
| "Avenir Next", | ||
| "Baskerville", | ||
| "Big Caslon", | ||
| "Bodoni 72", | ||
| "Bradley Hand", | ||
| "Brush Script MT", | ||
| "Chalkboard", | ||
| "Chalkduster", | ||
| "Charter", | ||
| "Cochin", | ||
| "Comic Sans MS", | ||
| "Copperplate", | ||
| "Courier", | ||
| "Courier New", | ||
| "Didot", | ||
| "Futura", | ||
| "Geneva", | ||
| "Georgia", | ||
| "Gill Sans", | ||
| "Helvetica", | ||
| "Helvetica Neue", | ||
| "Herculanum", | ||
| "Hoefler Text", | ||
| "Impact", | ||
| "Lucida Grande", | ||
| "Marker Felt", | ||
| "Menlo", | ||
| "Microsoft Sans Serif", | ||
| "Monaco", | ||
| "Noteworthy", | ||
| "Optima", | ||
| "Palatino", | ||
| "Papyrus", | ||
| "Phosphate", | ||
| "Rockwell", | ||
| "Savoye LET", | ||
| "SignPainter", | ||
| "Skia", | ||
| "Snell Roundhand", | ||
| "Tahoma", | ||
| "Times", | ||
| "Times New Roman", | ||
| "Trattatello", | ||
| "Trebuchet MS", | ||
| "Verdana", | ||
| "Zapfino", | ||
| ], | ||
| windows: [ | ||
| "Arial", | ||
| "Arial Black", | ||
| "Arial Narrow", | ||
| "Bahnschrift", | ||
| "Calibri", | ||
| "Cambria", | ||
| "Cambria Math", | ||
| "Candara", | ||
| "Comic Sans MS", | ||
| "Consolas", | ||
| "Constantia", | ||
| "Corbel", | ||
| "Courier New", | ||
| "Ebrima", | ||
| "Franklin Gothic Medium", | ||
| "Gabriola", | ||
| "Gadugi", | ||
| "Georgia", | ||
| "Impact", | ||
| "Ink Free", | ||
| "Javanese Text", | ||
| "Leelawadee UI", | ||
| "Lucida Console", | ||
| "Lucida Sans Unicode", | ||
| "MS Gothic", | ||
| "MV Boli", | ||
| "Malgun Gothic", | ||
| "Marlett", | ||
| "Microsoft Himalaya", | ||
| "Microsoft JhengHei", | ||
| "Microsoft New Tai Lue", | ||
| "Microsoft PhagsPa", | ||
| "Microsoft Sans Serif", | ||
| "Microsoft Tai Le", | ||
| "Microsoft YaHei", | ||
| "Microsoft Yi Baiti", | ||
| "MingLiU-ExtB", | ||
| "Mongolian Baiti", | ||
| "Myanmar Text", | ||
| "Nirmala UI", | ||
| "Palatino Linotype", | ||
| "Segoe MDL2 Assets", | ||
| "Segoe Print", | ||
| "Segoe Script", | ||
| "Segoe UI", | ||
| "Segoe UI Emoji", | ||
| "Segoe UI Historic", | ||
| "Segoe UI Symbol", | ||
| "SimSun", | ||
| "Sitka", | ||
| "Sylfaen", | ||
| "Symbol", | ||
| "Tahoma", | ||
| "Times New Roman", | ||
| "Trebuchet MS", | ||
| "Verdana", | ||
| "Webdings", | ||
| "Wingdings", | ||
| ], | ||
| linux: [ | ||
| "DejaVu Sans", | ||
| "DejaVu Sans Mono", | ||
| "DejaVu Serif", | ||
| "FreeMono", | ||
| "FreeSans", | ||
| "FreeSerif", | ||
| "Liberation Mono", | ||
| "Liberation Sans", | ||
| "Liberation Serif", | ||
| "Noto Color Emoji", | ||
| "Noto Mono", | ||
| "Noto Sans", | ||
| "Noto Sans CJK JP", | ||
| "Noto Sans CJK KR", | ||
| "Noto Sans CJK SC", | ||
| "Noto Sans CJK TC", | ||
| "Noto Serif", | ||
| "Ubuntu", | ||
| "Ubuntu Condensed", | ||
| "Ubuntu Mono", | ||
| ], | ||
| }; |
| /** | ||
| * Permissions defaults — `Permissions.query({name})` answers that real | ||
| * Chrome reports for a fresh, never-prompted profile. | ||
| * | ||
| * Chrome's defaults observed in the captured baselines: | ||
| * - geolocation, notifications, camera, microphone → `"prompt"` | ||
| * - clipboard-read → `"prompt"` | ||
| * - clipboard-write, accelerometer, gyroscope, magnetometer → `"granted"` | ||
| * | ||
| * The values are stable across desktop OS so v0.7 doesn't key by OS yet. | ||
| * | ||
| * @see tasks/0070-consistency-rules-full.md (permissions) | ||
| */ | ||
| /** Chrome's default permission states for a fresh profile, by name. */ | ||
| export const PERMISSIONS_DEFAULT_STATE: Readonly<Record<string, "granted" | "prompt" | "denied">> = | ||
| { | ||
| geolocation: "prompt", | ||
| notifications: "prompt", | ||
| camera: "prompt", | ||
| microphone: "prompt", | ||
| "clipboard-read": "prompt", | ||
| "clipboard-write": "granted", | ||
| accelerometer: "granted", | ||
| gyroscope: "granted", | ||
| magnetometer: "granted", | ||
| midi: "prompt", | ||
| push: "prompt", | ||
| "background-sync": "granted", | ||
| "persistent-storage": "prompt", | ||
| "ambient-light-sensor": "granted", | ||
| "screen-wake-lock": "prompt", | ||
| "storage-access": "prompt", | ||
| "window-management": "prompt", | ||
| "system-wake-lock": "prompt", | ||
| }; |
| /** | ||
| * Screen-extra lookups — `screen.orientation` and `matchMedia()` defaults | ||
| * for desktop Chrome. | ||
| * | ||
| * `screen.orientation`: desktop is always landscape-primary, angle 0. | ||
| * Mobile profiles in v2 will key off `device.formFactor`. | ||
| * | ||
| * `matchMedia` defaults match the captured `mac-m4-chrome-stable` baseline: | ||
| * | ||
| * - prefers-color-scheme: "light" | ||
| * - prefers-reduced-motion: "reduce" (system pref on capture machine) | ||
| * - prefers-contrast: "no-preference" | ||
| * - forced-colors: "none" | ||
| * - color-gamut: "srgb" (varies by display) | ||
| * - pointer / hover: "fine" / "hover" (desktop with mouse) | ||
| * - any-pointer / any-hover: "fine" / "hover" | ||
| * - display-mode: "browser" | ||
| * - dynamic-range: "standard" | ||
| * - update: "fast" | ||
| * - min-resolution: "96dpi" | ||
| * - monochrome: false | ||
| * | ||
| * The values are surfaced via the `screen-orientation` inject module by | ||
| * intercepting `Window.matchMedia(spec)` and answering from this table. | ||
| * | ||
| * @see tasks/0070-consistency-rules-full.md (screen-orientation) | ||
| */ | ||
| /** | ||
| * `screen.orientation` shape on desktop Chrome. v2 will diversify by | ||
| * `device.formFactor`. | ||
| */ | ||
| export const DESKTOP_ORIENTATION = { | ||
| type: "landscape-primary", | ||
| angle: 0, | ||
| } as const; | ||
| /** | ||
| * Default `matchMedia` answers for the v0.7 desktop catalog. The keys are | ||
| * the CSS media features the probe-page measures (chaser-recon's `screen- | ||
| * probe.ts`); the values are the answers Chrome is observed to return on | ||
| * the captured device. | ||
| */ | ||
| export const MEDIA_QUERY_DEFAULTS: Readonly<Record<string, string | boolean>> = { | ||
| "prefers-color-scheme": "light", | ||
| "prefers-reduced-motion": "reduce", | ||
| "prefers-contrast": "no-preference", | ||
| "forced-colors": "none", | ||
| "color-gamut": "srgb", | ||
| pointer: "fine", | ||
| hover: "hover", | ||
| "any-pointer": "fine", | ||
| "any-hover": "hover", | ||
| "display-mode": "browser", | ||
| "dynamic-range": "standard", | ||
| update: "fast", | ||
| "min-resolution": "96dpi", | ||
| monochrome: false, | ||
| }; |
| /** | ||
| * WebGPU lookup tables — adapter info + features list keyed by GPU vendor. | ||
| * | ||
| * The WebGPU `requestAdapter()` API exposes: | ||
| * - `adapter.features` — a `GPUSupportedFeatures` set; iterable strings. | ||
| * - `adapter.info` — `{ vendor, architecture, device, description }`. | ||
| * - `adapter.isFallbackAdapter` — `false` on real hardware. | ||
| * | ||
| * Captured baselines (e.g. `mac-m4-chrome-stable/baseline.manifest.json`) | ||
| * carry the exact features list + info shape Chrome reports for that | ||
| * device. v0.7 ships per-vendor curated lists keyed by GPU vendor class. | ||
| * | ||
| * @see PLAN.md §9.5 (WebGPU adapter info — phase 0.7) | ||
| * @see tasks/0070-consistency-rules-full.md (webgpu) | ||
| */ | ||
| import type { GpuVendorKey } from "./gpu"; | ||
| /** | ||
| * `adapter.info` shape per vendor. `device` and `description` are typically | ||
| * empty strings on Chrome's secure default ("masked" adapter info); only | ||
| * `vendor` and `architecture` carry signal. Captured Mac M4 baseline shows | ||
| * `architecture: "metal-3"`, `vendor: "apple"`. | ||
| */ | ||
| export interface WebGpuAdapterInfo { | ||
| readonly vendor: string; | ||
| readonly architecture: string; | ||
| readonly device: string; | ||
| readonly description: string; | ||
| } | ||
| export const WEBGPU_INFO_BY_VENDOR: Readonly<Record<GpuVendorKey, WebGpuAdapterInfo>> = { | ||
| apple: { vendor: "apple", architecture: "metal-3", device: "", description: "" }, | ||
| intel: { vendor: "intel", architecture: "xe", device: "", description: "" }, | ||
| amd: { vendor: "amd", architecture: "rdna-3", device: "", description: "" }, | ||
| nvidia: { vendor: "nvidia", architecture: "ada", device: "", description: "" }, | ||
| qualcomm: { vendor: "qualcomm", architecture: "adreno-7", device: "", description: "" }, | ||
| other: { vendor: "", architecture: "", device: "", description: "" }, | ||
| }; | ||
| /** | ||
| * Curated `adapter.features` lists per vendor. Order matters — iteration | ||
| * order over `GPUSupportedFeatures` is fingerprintable. Lists derived from | ||
| * the captured baselines under `packages/profiles/data/<id>/baseline.manifest.json` | ||
| * (web-gpu probe). Refresh per Chrome major. | ||
| * | ||
| * Apple Silicon list — captured from `mac-m4-chrome-stable` 2026-05-08: | ||
| * 22 features in this exact order. | ||
| */ | ||
| export const WEBGPU_FEATURES_BY_VENDOR: Readonly<Record<GpuVendorKey, readonly string[]>> = { | ||
| apple: [ | ||
| "depth32float-stencil8", | ||
| "rg11b10ufloat-renderable", | ||
| "texture-formats-tier1", | ||
| "bgra8unorm-storage", | ||
| "texture-compression-bc", | ||
| "dual-source-blending", | ||
| "core-features-and-limits", | ||
| "float32-filterable", | ||
| "indirect-first-instance", | ||
| "texture-compression-astc-sliced-3d", | ||
| "float32-blendable", | ||
| "texture-compression-astc", | ||
| "texture-compression-etc2", | ||
| "depth-clip-control", | ||
| "texture-compression-bc-sliced-3d", | ||
| "shader-f16", | ||
| "timestamp-query", | ||
| "clip-distances", | ||
| "texture-formats-tier2", | ||
| "primitive-index", | ||
| "texture-component-swizzle", | ||
| "subgroups", | ||
| ], | ||
| // Conservative cross-vendor lists; expand as captures land per vendor. | ||
| intel: [ | ||
| "depth32float-stencil8", | ||
| "rg11b10ufloat-renderable", | ||
| "texture-formats-tier1", | ||
| "bgra8unorm-storage", | ||
| "texture-compression-bc", | ||
| "dual-source-blending", | ||
| "core-features-and-limits", | ||
| "float32-filterable", | ||
| "indirect-first-instance", | ||
| "depth-clip-control", | ||
| "shader-f16", | ||
| "timestamp-query", | ||
| ], | ||
| amd: [ | ||
| "depth32float-stencil8", | ||
| "rg11b10ufloat-renderable", | ||
| "texture-formats-tier1", | ||
| "bgra8unorm-storage", | ||
| "texture-compression-bc", | ||
| "dual-source-blending", | ||
| "core-features-and-limits", | ||
| "float32-filterable", | ||
| "indirect-first-instance", | ||
| "depth-clip-control", | ||
| "shader-f16", | ||
| "timestamp-query", | ||
| ], | ||
| nvidia: [ | ||
| "depth32float-stencil8", | ||
| "rg11b10ufloat-renderable", | ||
| "texture-formats-tier1", | ||
| "bgra8unorm-storage", | ||
| "texture-compression-bc", | ||
| "dual-source-blending", | ||
| "core-features-and-limits", | ||
| "float32-filterable", | ||
| "indirect-first-instance", | ||
| "depth-clip-control", | ||
| "shader-f16", | ||
| "timestamp-query", | ||
| ], | ||
| qualcomm: ["texture-compression-astc", "texture-compression-etc2", "core-features-and-limits"], | ||
| other: ["core-features-and-limits"], | ||
| }; |
| /** | ||
| * Navigator-surface rules. Cover R-008, R-009, R-015, R-016, R-017, R-018, | ||
| * R-020, R-022, R-027, R-028, R-030. | ||
| * | ||
| * `device.cores` and `device.memoryGB` are written back into the matrix's | ||
| * device subtree — those slots represent both the input device data AND | ||
| * the spoofed `navigator.{hardwareConcurrency,deviceMemory}` outputs since | ||
| * they're the single source of truth. | ||
| * | ||
| * `navigator.platform`, `navigator.vendor`, `navigator.{appCodeName,product, | ||
| * cookieEnabled,maxTouchPoints}`, and `webdriver` have no schema slot — they | ||
| * are encoded as additional `uaCh` keys (uaCh's schema is open-keyed strings). | ||
| * | ||
| * @see PLAN.md §9.2 | ||
| */ | ||
| import type { ProfileV1 } from "../generated/profile"; | ||
| import { defineRule, type Rule } from "../rule"; | ||
| import { PLATFORM_BY_OS } from "./lookups/os"; | ||
| type OsName = ProfileV1["os"]["name"]; | ||
| type BrowserName = ProfileV1["browser"]["name"]; | ||
| /** | ||
| * R-008 — `[device.cores]` → `device.cores` (passthrough). | ||
| * | ||
| * Per PLAN.md §9.2 / §6.1 the profile is the source of truth (I-5). This | ||
| * rule asserts the lock that `navigator.hardwareConcurrency` mirrors | ||
| * `device.cores` exactly — no transformation, no coarse cpu-family lookup | ||
| * that would overwrite the real device's logical-core count. v0.5 surfaced | ||
| * the original derive-from-cpuFamily implementation as a bug for M-series | ||
| * Pro/Max parts (e.g. M4 Max ships 14 cores; the table emitted 10 and | ||
| * clobbered the profile). See tasks/0051-consistency-stack-fixes.md | ||
| * (Group A). | ||
| */ | ||
| export const R008: Rule = defineRule<readonly [number], number>({ | ||
| id: "R-008", | ||
| description: "navigator.hardwareConcurrency — passthrough of profile.device.cores", | ||
| inputs: ["device.cores"], | ||
| output: "device.cores", | ||
| derive([cores]) { | ||
| return cores; | ||
| }, | ||
| }); | ||
| /** R-009 — `device.memoryGB` → `device.memoryGB` (capped at 8 per Chrome). */ | ||
| export const R009: Rule = defineRule<readonly [number], number>({ | ||
| id: "R-009", | ||
| description: "navigator.deviceMemory cap at 8 — Chrome quantizes the value", | ||
| inputs: ["device.memoryGB"], | ||
| output: "device.memoryGB", | ||
| derive([memoryGB]) { | ||
| // Chrome reports a quantized value: 0.25, 0.5, 1, 2, 4, 8. We snap to 8 | ||
| // for any device with ≥ 8 GiB and otherwise round down to the nearest | ||
| // standard step. Real devices < 8 GiB are rare in v1 catalog but | ||
| // handled defensively. | ||
| if (memoryGB >= 8) return 8; | ||
| if (memoryGB >= 4) return 4; | ||
| if (memoryGB >= 2) return 2; | ||
| if (memoryGB >= 1) return 1; | ||
| return 1; | ||
| }, | ||
| }); | ||
| /** R-015 — `locale` → `locale` (passthrough; navigator.language is the same value). */ | ||
| export const R015: Rule = defineRule<readonly [string], string>({ | ||
| id: "R-015", | ||
| description: "navigator.language — passthrough of profile.locale", | ||
| inputs: ["locale"], | ||
| output: "locale", | ||
| derive([locale]) { | ||
| return locale; | ||
| }, | ||
| }); | ||
| /** R-016 — `languages` → `languages` (passthrough; navigator.languages is the array). */ | ||
| export const R016: Rule = defineRule<readonly [readonly string[]], readonly string[]>({ | ||
| id: "R-016", | ||
| description: "navigator.languages — passthrough of profile.languages", | ||
| inputs: ["languages"], | ||
| output: "languages", | ||
| derive([languages]) { | ||
| return [...languages]; | ||
| }, | ||
| }); | ||
| /** R-017 — `os.name` → `uaCh.navigator-platform`. "MacIntel"/"Win32"/"Linux x86_64". */ | ||
| export const R017: Rule = defineRule<readonly [OsName], string>({ | ||
| id: "R-017", | ||
| description: "navigator.platform per OS", | ||
| inputs: ["os.name"], | ||
| output: "uaCh.navigator-platform", | ||
| derive([osName]) { | ||
| return PLATFORM_BY_OS[osName]; | ||
| }, | ||
| }); | ||
| /** R-018 — `browser.name` → `uaCh.navigator-vendor`. "Google Inc." universally. */ | ||
| export const R018: Rule = defineRule<readonly [BrowserName], string>({ | ||
| id: "R-018", | ||
| description: "navigator.vendor — 'Google Inc.' for chromium-family browsers", | ||
| inputs: ["browser.name"], | ||
| output: "uaCh.navigator-vendor", | ||
| derive() { | ||
| // All chromium-family browsers report "Google Inc." here. Brave/Arc/etc. | ||
| // are detected via Sec-CH-UA brands, not the legacy `navigator.vendor`. | ||
| return "Google Inc."; | ||
| }, | ||
| }); | ||
| /** R-020 — `os.name` → `uaCh.navigator-maxTouchPoints`. 0 on desktop. */ | ||
| export const R020: Rule = defineRule<readonly [OsName], string>({ | ||
| id: "R-020", | ||
| description: "navigator.maxTouchPoints — 0 on desktop OSes", | ||
| inputs: ["os.name"], | ||
| output: "uaCh.navigator-maxTouchPoints", | ||
| derive() { | ||
| // v0.2 catalog is desktop-only; mobile profiles ship in v2 per PLAN.md §16. | ||
| return "0"; | ||
| }, | ||
| }); | ||
| /** R-022 — `[os.name, browser.name]` → `uaCh.navigator-webdriver`. "false". */ | ||
| export const R022: Rule = defineRule<readonly [OsName, BrowserName], string>({ | ||
| id: "R-022", | ||
| description: "navigator.webdriver — false on real browsers", | ||
| inputs: ["os.name", "browser.name"], | ||
| output: "uaCh.navigator-webdriver", | ||
| derive() { | ||
| return "false"; | ||
| }, | ||
| }); | ||
| /** R-027 — `[os.name, browser.name]` → `uaCh.navigator-appCodeName`. */ | ||
| export const R027: Rule = defineRule<readonly [OsName, BrowserName], string>({ | ||
| id: "R-027", | ||
| description: "navigator.appCodeName — 'Mozilla' universally", | ||
| inputs: ["os.name", "browser.name"], | ||
| output: "uaCh.navigator-appCodeName", | ||
| derive() { | ||
| return "Mozilla"; | ||
| }, | ||
| }); | ||
| /** R-028 — `os.name` → `uaCh.navigator-product`. */ | ||
| export const R028: Rule = defineRule<readonly [OsName], string>({ | ||
| id: "R-028", | ||
| description: "navigator.product — 'Gecko' universally", | ||
| inputs: ["os.name"], | ||
| output: "uaCh.navigator-product", | ||
| derive() { | ||
| return "Gecko"; | ||
| }, | ||
| }); | ||
| /** R-030 — `[os.name, browser.name]` → `uaCh.navigator-cookieEnabled`. */ | ||
| export const R030: Rule = defineRule<readonly [OsName, BrowserName], string>({ | ||
| id: "R-030", | ||
| description: "navigator.cookieEnabled — true", | ||
| inputs: ["os.name", "browser.name"], | ||
| output: "uaCh.navigator-cookieEnabled", | ||
| derive() { | ||
| return "true"; | ||
| }, | ||
| }); | ||
| export const NAVIGATOR_RULES: readonly Rule[] = [ | ||
| R008, | ||
| R009, | ||
| R015, | ||
| R016, | ||
| R017, | ||
| R018, | ||
| R020, | ||
| R022, | ||
| R027, | ||
| R028, | ||
| R030, | ||
| ]; |
| /** | ||
| * Screen-surface rules. Cover R-010, R-011, R-012, R-021, R-029. | ||
| * | ||
| * The schema has slots for `display.{width,height,dpr,colorDepth,pixelDepth}` | ||
| * but no explicit `availWidth/availHeight/innerWidth/...` slots. v0.2 | ||
| * stashes the derived avail/viewport pair as JSON-encoded `uaCh` entries — | ||
| * phase 0.7 adds proper schema slots when the inject layer wants them. | ||
| * | ||
| * Each rule has exactly one output path (the engine contract). R-010 | ||
| * produces a JSON tuple summarizing the four screen dimensions; the inject | ||
| * layer (phase 0.3+) splits it into `screen.width` / `screen.height` / | ||
| * `screen.availWidth` / `screen.availHeight`. | ||
| * | ||
| * @see PLAN.md §9.2 | ||
| */ | ||
| import type { ProfileV1 } from "../generated/profile"; | ||
| import { defineRule, type Rule } from "../rule"; | ||
| import { | ||
| BROWSER_CHROME_HEIGHT_BY_OS, | ||
| OS_CHROME_HEIGHT_BY_OS, | ||
| OS_CHROME_WIDTH_BY_OS, | ||
| } from "./lookups/os"; | ||
| type OsName = ProfileV1["os"]["name"]; | ||
| /** | ||
| * R-010 — `[display.width, display.height, display.dpr, os.name]` → | ||
| * `uaCh.screen-dimensions` as JSON `{ width, height, availWidth, availHeight }`. | ||
| * The width/height come from the profile; availWidth/availHeight subtract | ||
| * the OS chrome from the lookup table. | ||
| */ | ||
| export const R010: Rule = defineRule<readonly [number, number, number, OsName], string>({ | ||
| id: "R-010", | ||
| description: "screen.{width,height,availWidth,availHeight} JSON tuple", | ||
| inputs: ["display.width", "display.height", "display.dpr", "os.name"], | ||
| output: "uaCh.screen-dimensions", | ||
| derive([width, height, _dpr, osName]) { | ||
| const availWidth = Math.max(0, width - OS_CHROME_WIDTH_BY_OS[osName]); | ||
| const availHeight = Math.max(0, height - OS_CHROME_HEIGHT_BY_OS[osName]); | ||
| return JSON.stringify({ width, height, availWidth, availHeight }); | ||
| }, | ||
| }); | ||
| /** R-011 — `display.colorDepth` → `display.colorDepth` (passthrough). */ | ||
| export const R011: Rule = defineRule<readonly [number], number>({ | ||
| id: "R-011", | ||
| description: "screen.colorDepth — passthrough", | ||
| inputs: ["display.colorDepth"], | ||
| output: "display.colorDepth", | ||
| derive([cd]) { | ||
| return cd; | ||
| }, | ||
| }); | ||
| /** R-012 — `display.dpr` → `display.dpr` (passthrough). */ | ||
| export const R012: Rule = defineRule<readonly [number], number>({ | ||
| id: "R-012", | ||
| description: "window.devicePixelRatio — passthrough", | ||
| inputs: ["display.dpr"], | ||
| output: "display.dpr", | ||
| derive([dpr]) { | ||
| return dpr; | ||
| }, | ||
| }); | ||
| /** | ||
| * R-021 — `[display.width, display.height, os.name]` → `uaCh.screen-availSize` | ||
| * as JSON `{ availWidth, availHeight }`. | ||
| * | ||
| * Functionally overlaps with R-010 (which also computes avail*) but the brief | ||
| * lists them separately to keep the avail-screen lock independently testable. | ||
| * The two rules MUST agree — that's the relational invariant captured here. | ||
| */ | ||
| export const R021: Rule = defineRule<readonly [number, number, OsName], string>({ | ||
| id: "R-021", | ||
| description: "screen.availWidth/availHeight — display dims minus OS chrome", | ||
| inputs: ["display.width", "display.height", "os.name"], | ||
| output: "uaCh.screen-availSize", | ||
| derive([width, height, osName]) { | ||
| const availWidth = Math.max(0, width - OS_CHROME_WIDTH_BY_OS[osName]); | ||
| const availHeight = Math.max(0, height - OS_CHROME_HEIGHT_BY_OS[osName]); | ||
| return JSON.stringify({ availWidth, availHeight }); | ||
| }, | ||
| }); | ||
| /** | ||
| * R-029 — `[display.dpr, display.width, display.height, os.name]` | ||
| * → `uaCh.window-viewport` as JSON `{innerWidth, innerHeight, outerWidth, outerHeight}`. | ||
| * | ||
| * inner = outer minus browser chrome (URL bar + tabs + bookmark bar); | ||
| * outer = display minus OS chrome. Assumes a maximized browser window. | ||
| */ | ||
| export const R029: Rule = defineRule<readonly [number, number, number, OsName], string>({ | ||
| id: "R-029", | ||
| description: "window.{innerWidth,innerHeight,outerWidth,outerHeight} per OS chrome", | ||
| inputs: ["display.dpr", "display.width", "display.height", "os.name"], | ||
| output: "uaCh.window-viewport", | ||
| derive([_dpr, width, height, osName]) { | ||
| const outerWidth = Math.max(0, width - OS_CHROME_WIDTH_BY_OS[osName]); | ||
| const outerHeight = Math.max(0, height - OS_CHROME_HEIGHT_BY_OS[osName]); | ||
| const innerWidth = outerWidth; | ||
| const innerHeight = Math.max(0, outerHeight - BROWSER_CHROME_HEIGHT_BY_OS[osName]); | ||
| return JSON.stringify({ innerWidth, innerHeight, outerWidth, outerHeight }); | ||
| }, | ||
| }); | ||
| export const SCREEN_RULES: readonly Rule[] = [R010, R011, R012, R021, R029]; |
| /** | ||
| * User-Agent + UA-CH rules. Cover R-004, R-005, R-006, R-007, R-023, R-026. | ||
| * | ||
| * R-023 produces a seed-derived build-hash that R-004 consumes — that's the | ||
| * v0.2 chain that gives the rule DAG real (non-trivial) edges. | ||
| * | ||
| * @see PLAN.md §9.2 | ||
| */ | ||
| import type { ProfileV1 } from "../generated/profile"; | ||
| import { defineRule, type Rule } from "../rule"; | ||
| import { | ||
| deriveBuildVersion, | ||
| deriveSecChUa, | ||
| deriveUserAgent, | ||
| lookupTipFullVersion, | ||
| } from "./lookups/browser"; | ||
| import { SEC_CH_UA_PLATFORM_BY_OS } from "./lookups/os"; | ||
| type OsName = ProfileV1["os"]["name"]; | ||
| type BrowserName = ProfileV1["browser"]["name"]; | ||
| /** | ||
| * R-023 — `[seed]` (with the engine PRNG forking off `(profile.id, seed)`) | ||
| * → `uaCh.ua-build-hash`. | ||
| * | ||
| * The PRNG state is itself derived from `(profile.id, seed)` (see prng/seed.ts), | ||
| * so this rule only needs to read the seed value to enforce its declared | ||
| * dependency on the seed in the rule DAG. The actual entropy comes from the | ||
| * PRNG argument injected by the engine. | ||
| */ | ||
| export const R023: Rule = defineRule<readonly [string], string>({ | ||
| id: "R-023", | ||
| description: "Seed-derived UA build-hash — drives the build-number variance in R-004", | ||
| inputs: ["seed"], | ||
| output: "uaCh.ua-build-hash", | ||
| derive(_inputs, prng) { | ||
| // 8 hex chars = 4 bytes — plenty of entropy for a build-number lookup | ||
| // and short enough to inspect in JSON dumps. | ||
| return prng.nextHex(4); | ||
| }, | ||
| }); | ||
| /** | ||
| * R-004 — `[os.name, browser.name, browser.minVersion, uaCh.ua-build-hash]` | ||
| * → `userAgent`. Tip-stable when the lookup table has a published patch for | ||
| * `(browser, major)`; otherwise the build hash from R-023 fans out to a | ||
| * stable `<major>.0.<build>.<patch>` triple. Tip-locking matches what real | ||
| * Chromium reports in `userAgent` + `userAgentDataHighEntropy.fullVersionList`, | ||
| * which the harness compares structurally. | ||
| * | ||
| * v0.7 ranks the tip lookup ahead of seed-derived because real-device | ||
| * captures observe the published tip; the seed-derived path is reserved | ||
| * for ad-hoc majors (canary, beta) that aren't yet in the tip table. | ||
| */ | ||
| export const R004: Rule = defineRule<readonly [OsName, BrowserName, string, string], string>({ | ||
| id: "R-004", | ||
| description: "User-Agent template + tip-locked patch (fallback: seed-driven build variance)", | ||
| inputs: ["os.name", "browser.name", "browser.minVersion", "uaCh.ua-build-hash"], | ||
| output: "userAgent", | ||
| derive([osName, browser, minVersion, buildHash]) { | ||
| // Tip lookup first — closes the harness divergence on | ||
| // navigator.userAgent + userAgentDataHighEntropy.fullVersionList[*].version | ||
| // for the captured Mac M4 baseline. PLAN.md §9.2 R-031. | ||
| const tip = lookupTipFullVersion(browser, minVersion); | ||
| if (tip !== null) { | ||
| return deriveUserAgent(osName, browser, tip); | ||
| } | ||
| // Convert the hex build-hash to a 32-bit number — the lower 31 bits are | ||
| // enough; the build/patch derivation uses small modular bands. | ||
| const u32 = parseInt(buildHash.slice(0, 8), 16) >>> 0; | ||
| const fullVersion = deriveBuildVersion(minVersion, u32); | ||
| return deriveUserAgent(osName, browser, fullVersion); | ||
| }, | ||
| }); | ||
| /** | ||
| * R-005 — `[os.name (declared but unused), browser.name, browser.minVersion]` | ||
| * → `uaCh.sec-ch-ua`. Brand list with deterministic GREASE entry. | ||
| * | ||
| * `os.name` is part of the declared input tuple per the brief's contract | ||
| * even though the brand list is OS-independent — the lock represents the | ||
| * relationship that the Sec-CH-UA header is observed alongside the OS. | ||
| */ | ||
| export const R005: Rule = defineRule<readonly [OsName, BrowserName, string], string>({ | ||
| id: "R-005", | ||
| description: "Sec-CH-UA brand list", | ||
| inputs: ["os.name", "browser.name", "browser.minVersion"], | ||
| output: "uaCh.sec-ch-ua", | ||
| derive([_osName, browser, minVersion]) { | ||
| return deriveSecChUa(browser, minVersion); | ||
| }, | ||
| }); | ||
| /** R-006 — `os.name` → `uaCh.sec-ch-ua-platform` (enum). */ | ||
| export const R006: Rule = defineRule<readonly [OsName], string>({ | ||
| id: "R-006", | ||
| description: "Sec-CH-UA-Platform enum", | ||
| inputs: ["os.name"], | ||
| output: "uaCh.sec-ch-ua-platform", | ||
| derive([osName]) { | ||
| return SEC_CH_UA_PLATFORM_BY_OS[osName]; | ||
| }, | ||
| }); | ||
| /** R-007 — `os.version` → `uaCh.sec-ch-ua-platform-version` (passthrough, quoted). */ | ||
| export const R007: Rule = defineRule<readonly [string], string>({ | ||
| id: "R-007", | ||
| description: "Sec-CH-UA-Platform-Version — quoted OS marketing version", | ||
| inputs: ["os.version"], | ||
| output: "uaCh.sec-ch-ua-platform-version", | ||
| derive([version]) { | ||
| return `"${version}"`; | ||
| }, | ||
| }); | ||
| /** | ||
| * R-026 — `userAgent` → `uaCh.navigator-appVersion`. `navigator.appVersion` | ||
| * historically returns the UA without the leading `"Mozilla/"` prefix. | ||
| */ | ||
| export const R026: Rule = defineRule<readonly [string], string>({ | ||
| id: "R-026", | ||
| description: "navigator.appVersion = userAgent without leading 'Mozilla/'", | ||
| inputs: ["userAgent"], | ||
| output: "uaCh.navigator-appVersion", | ||
| derive([userAgent]) { | ||
| return userAgent.replace(/^Mozilla\//, ""); | ||
| }, | ||
| }); | ||
| /** | ||
| * R-031 — `[browser.name, browser.minVersion]` → `uaCh.ua-full-version-list`. | ||
| * | ||
| * Emits a JSON-encoded brand list with FULL `<major>.0.<build>.<patch>` | ||
| * versions, mirroring what `navigator.userAgentData.getHighEntropyValues({ | ||
| * hints:["fullVersionList"]})` returns on real Chromium. The brand list | ||
| * itself reuses R-005's ordering — Branded → GREASE → Chromium — but the | ||
| * `version` field is the tip-locked patch (or `<major>.0.0.0` for GREASE, | ||
| * which is canonical Chromium behaviour). | ||
| * | ||
| * Inject (`client-hints.ts`) parses this JSON and serves it from | ||
| * `getHighEntropyValues`. Without this rule the inject layer falls back to | ||
| * the brand-list majors (`"147"`), which mismatches the captured baseline. | ||
| * | ||
| * @see PLAN.md §9.2 / §13.6 | ||
| * @see tasks/0070-consistency-rules-full.md (full-version-list) | ||
| */ | ||
| export const R031: Rule = defineRule<readonly [BrowserName, string], string>({ | ||
| id: "R-031", | ||
| description: "uaCh.ua-full-version-list — tip-locked Sec-CH-UA-Full-Version-List", | ||
| inputs: ["browser.name", "browser.minVersion"], | ||
| output: "uaCh.ua-full-version-list", | ||
| derive([browser, minVersion]) { | ||
| const tip = lookupTipFullVersion(browser, minVersion); | ||
| const fullVersion = tip ?? `${minVersion}.0.0.0`; | ||
| // Brand-list shape mirrors R-005. GREASE pinned to `8.0.0.0` (Chromium- | ||
| // spec): the GREASE entry's full version is its own canonical placeholder, | ||
| // not the browser tip. Chromium-canonical brands use the same tip as the | ||
| // branded entry. | ||
| const brands = [ | ||
| { brand: brandLabel(browser), version: fullVersion }, | ||
| { brand: "Not.A/Brand", version: "8.0.0.0" }, | ||
| { brand: "Chromium", version: fullVersion }, | ||
| ]; | ||
| return JSON.stringify(brands); | ||
| }, | ||
| }); | ||
| /** Brand label parallel to SEC_CH_UA_BRANDS_BY_BROWSER. */ | ||
| function brandLabel(browser: BrowserName): string { | ||
| switch (browser) { | ||
| case "chrome": | ||
| return "Google Chrome"; | ||
| case "edge": | ||
| return "Microsoft Edge"; | ||
| case "brave": | ||
| return "Brave"; | ||
| case "arc": | ||
| return "Arc"; | ||
| case "opera": | ||
| return "Opera"; | ||
| } | ||
| } | ||
| export const USER_AGENT_RULES: readonly Rule[] = [R023, R004, R005, R006, R007, R026, R031]; |
| /** | ||
| * WebGPU rules. Cover R-032 (features) and R-033 (adapter info). | ||
| * | ||
| * Both rules emit JSON-encoded values into the open-keyed `uaCh` map: that's | ||
| * the only schema-stable expansion slot at v0.7 (PLAN.md §6.1). The inject- | ||
| * side WebGPU module parses these strings and serves them from | ||
| * `navigator.gpu.requestAdapter().{features,info}`. | ||
| * | ||
| * @see PLAN.md §9.5 (WebGPU adapter info) | ||
| * @see tasks/0070-consistency-rules-full.md | ||
| */ | ||
| import { defineRule, type Rule } from "../rule"; | ||
| import { classifyGpuVendor } from "./lookups/gpu"; | ||
| import { WEBGPU_FEATURES_BY_VENDOR, WEBGPU_INFO_BY_VENDOR } from "./lookups/webgpu"; | ||
| /** | ||
| * R-032 — `gpu.vendor` → `uaCh.webgpu-features` as a JSON-encoded array of | ||
| * feature strings. Inject parses and exposes via `adapter.features` (a | ||
| * `GPUSupportedFeatures` set). | ||
| */ | ||
| export const R032: Rule = defineRule<readonly [string], string>({ | ||
| id: "R-032", | ||
| description: "WebGPU adapter.features list per GPU vendor class", | ||
| inputs: ["gpu.vendor"], | ||
| output: "uaCh.webgpu-features", | ||
| derive([vendor]) { | ||
| const key = classifyGpuVendor(vendor); | ||
| return JSON.stringify(WEBGPU_FEATURES_BY_VENDOR[key]); | ||
| }, | ||
| }); | ||
| /** | ||
| * R-033 — `gpu.vendor` → `uaCh.webgpu-info` as a JSON-encoded `{vendor, | ||
| * architecture, device, description}` shape. Inject parses and exposes via | ||
| * `adapter.info`. Chromium routinely returns empty `device` and | ||
| * `description` for the secure default; only `vendor` and `architecture` | ||
| * carry signal. | ||
| */ | ||
| export const R033: Rule = defineRule<readonly [string], string>({ | ||
| id: "R-033", | ||
| description: "WebGPU adapter.info shape per GPU vendor class", | ||
| inputs: ["gpu.vendor"], | ||
| output: "uaCh.webgpu-info", | ||
| derive([vendor]) { | ||
| const key = classifyGpuVendor(vendor); | ||
| return JSON.stringify(WEBGPU_INFO_BY_VENDOR[key]); | ||
| }, | ||
| }); | ||
| export const WEBGPU_RULES: readonly Rule[] = [R032, R033]; |
+8
-3
| { | ||
| "name": "@mochi.js/consistency", | ||
| "version": "0.0.1", | ||
| "version": "0.1.0", | ||
| "description": "The Matrix engine — relational fingerprint locking for mochi. Imported by @mochi.js/core.", | ||
@@ -14,2 +14,7 @@ "license": "MIT", | ||
| "default": "./src/index.ts" | ||
| }, | ||
| "./prng": { | ||
| "bun": "./src/prng/index.ts", | ||
| "types": "./src/prng/index.ts", | ||
| "default": "./src/prng/index.ts" | ||
| } | ||
@@ -24,6 +29,6 @@ }, | ||
| }, | ||
| "homepage": "https://github.com/0xchasercat/mochi.js", | ||
| "homepage": "https://github.com/0xchasercat/mochi", | ||
| "repository": { | ||
| "type": "git", | ||
| "url": "git+https://github.com/0xchasercat/mochi.js.git", | ||
| "url": "git+https://github.com/0xchasercat/mochi.git", | ||
| "directory": "packages/consistency" | ||
@@ -30,0 +35,0 @@ }, |
+2
-2
| # @mochi.js/consistency | ||
| The Matrix engine for [mochi](https://github.com/0xchasercat/mochi.js). Generates a relationally-locked fingerprint matrix from a `(profile, seed)` pair. | ||
| The Matrix engine for [mochi](https://github.com/0xchasercat/mochi). Generates a relationally-locked fingerprint matrix from a `(profile, seed)` pair. | ||
@@ -13,2 +13,2 @@ This is an **internal** package consumed by `@mochi.js/core`. Most users should `bun add @mochi.js/core` instead. | ||
| See [PLAN.md §9](https://github.com/0xchasercat/mochi.js/blob/main/PLAN.md) for the full ruleset. | ||
| See [PLAN.md §9](https://github.com/0xchasercat/mochi/blob/main/PLAN.md) for the full ruleset. |
@@ -0,12 +1,78 @@ | ||
| /** | ||
| * Smoke test for @mochi.js/consistency v0.2. | ||
| * | ||
| * Verifies the package's public surface — VERSION, deriveMatrix shape, | ||
| * and the engine version stamp. Per-rule unit tests live alongside in | ||
| * `__tests__/rules.test.ts`; determinism + DAG tests live in their own | ||
| * test files. | ||
| */ | ||
| import { describe, expect, it } from "bun:test"; | ||
| import { deriveMatrix, VERSION } from "../index"; | ||
| import { CONSISTENCY_ENGINE_VERSION, deriveMatrix, type ProfileV1, RULES, VERSION } from "../index"; | ||
| describe("@mochi.js/consistency (claim release)", () => { | ||
| it("exports VERSION", () => { | ||
| /** | ||
| * Minimal valid ProfileV1 fixture covering the Mac M2 catalog profile. | ||
| * Real profiles ship with `@mochi.js/profiles` (phase 0.4). | ||
| */ | ||
| const FIXTURE: ProfileV1 = { | ||
| id: "test-profile", | ||
| version: "1.0.0", | ||
| engine: "chromium", | ||
| browser: { name: "chrome", channel: "stable", minVersion: "131", maxVersion: "133" }, | ||
| os: { name: "macos", version: "14", arch: "arm64" }, | ||
| device: { | ||
| vendor: "apple", | ||
| model: "mac14,2", | ||
| cpuFamily: "apple-silicon-m2", | ||
| cores: 8, | ||
| memoryGB: 16, | ||
| }, | ||
| display: { width: 2560, height: 1664, dpr: 2, colorDepth: 30, pixelDepth: 30 }, | ||
| gpu: { | ||
| vendor: "Apple", | ||
| renderer: "Apple M2", | ||
| webglUnmaskedVendor: "Google Inc. (Apple)", | ||
| webglUnmaskedRenderer: "ANGLE (Apple, ANGLE Metal Renderer: Apple M2)", | ||
| webglMaxTextureSize: 16384, | ||
| webglMaxColorAttachments: 8, | ||
| webglExtensions: [], | ||
| }, | ||
| audio: { contextSampleRate: 44100, audioWorkletLatency: 0.0058, destinationMaxChannelCount: 2 }, | ||
| fonts: { family: "macos-system-arial-pack", list: ["Arial"] }, | ||
| timezone: "America/Los_Angeles", | ||
| locale: "en-US", | ||
| languages: ["en-US", "en"], | ||
| behavior: { hand: "right", tremor: 0.18, wpm: 65, scrollStyle: "smooth" }, | ||
| wreqPreset: "chrome_131_macos", | ||
| userAgent: | ||
| "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", | ||
| uaCh: { "sec-ch-ua-platform": '"macOS"' }, | ||
| entropyBudget: { fixed: ["gpu.vendor"], perSeed: ["display.width"] }, | ||
| }; | ||
| describe("@mochi.js/consistency (v0.2 smoke)", () => { | ||
| it("exports a semver-shaped VERSION", () => { | ||
| expect(VERSION).toMatch(/^\d+\.\d+\.\d+/); | ||
| }); | ||
| it("deriveMatrix throws until phase 0.2", () => { | ||
| expect(() => deriveMatrix({ id: "x", version: "1" }, "seed")).toThrow(/not yet implemented/); | ||
| it("exports a semver-shaped CONSISTENCY_ENGINE_VERSION", () => { | ||
| expect(CONSISTENCY_ENGINE_VERSION).toBe("0.2.0"); | ||
| }); | ||
| it("ships at least the 30 v0.2 rules", () => { | ||
| expect(RULES.length).toBeGreaterThanOrEqual(30); | ||
| }); | ||
| it("deriveMatrix returns a MatrixV1 with seed + engine version stamped", () => { | ||
| const matrix = deriveMatrix(FIXTURE, "seed-1"); | ||
| expect(matrix.seed).toBe("seed-1"); | ||
| expect(matrix.consistencyEngineVersion).toBe(CONSISTENCY_ENGINE_VERSION); | ||
| expect(typeof matrix.derivedAt).toBe("string"); | ||
| // Sanity: the matrix carries the profile identity unchanged. | ||
| expect(matrix.id).toBe(FIXTURE.id); | ||
| expect(matrix.engine).toBe("chromium"); | ||
| }); | ||
| it("rejects empty seeds", () => { | ||
| expect(() => deriveMatrix(FIXTURE, "")).toThrow(/non-empty/); | ||
| }); | ||
| }); |
+40
-24
@@ -5,30 +5,46 @@ /** | ||
| * Generates an immutable, relationally-locked fingerprint matrix from a | ||
| * (profile, seed) pair. v0.0.1 claim release; full ruleset lands in phase 0.2 / 0.7. | ||
| * `(profile, seed)` pair. The matrix is a JSON-serializable, schema-shaped | ||
| * snapshot consumed by `@mochi.js/inject` (phase 0.3+). | ||
| * | ||
| * Determinism contract: | ||
| * - `deriveMatrix(profile, seed)` is pure. Same inputs → same output, | ||
| * bit-for-bit, **excluding the `derivedAt` ISO timestamp**. | ||
| * - Different `(profile.id, seed)` pairs produce isolated PRNG sequences; | ||
| * reusing a seed across profiles is safe. | ||
| * - The rule DAG is validated for acyclicity and unique outputs once per | ||
| * process the first time `deriveMatrix` is called. | ||
| * | ||
| * Public API surface: | ||
| * - {@link deriveMatrix} — derive a Matrix from a profile + seed. | ||
| * - {@link CONSISTENCY_ENGINE_VERSION} — the engine version stamp. | ||
| * - {@link VERSION} — the npm package version. | ||
| * - {@link Rule}, {@link SeededPrng} — types for downstream packages | ||
| * building on the engine (e.g. tests asserting rule shape). | ||
| * - Error classes: {@link RuleDagCycleError}, {@link DuplicateOutputError}, | ||
| * {@link MissingInputError}. | ||
| * | ||
| * @see PLAN.md §5.2 and §9 | ||
| */ | ||
| export const VERSION = "0.0.1" as const; | ||
| /** Placeholder; real shape lands with the schema codegen in phase 0.0+. */ | ||
| export interface ProfileV1 { | ||
| readonly id: string; | ||
| readonly version: string; | ||
| } | ||
| export const VERSION = "0.2.1" as const; | ||
| /** Placeholder; real shape mirrors ProfileV1 with seed-resolved values. */ | ||
| export interface MatrixV1 { | ||
| readonly profile: ProfileV1; | ||
| readonly seed: string; | ||
| readonly derivedAt: string; | ||
| readonly consistencyEngineVersion: string; | ||
| } | ||
| /** | ||
| * Derive a Matrix from a profile + seed. Lands in phase 0.2. | ||
| */ | ||
| export function deriveMatrix(_profile: ProfileV1, _seed: string): MatrixV1 { | ||
| throw new Error( | ||
| "@mochi.js/consistency.deriveMatrix is not yet implemented (v0.0.1 claim). " + | ||
| "Lands in phase 0.2; see PLAN.md §9.", | ||
| ); | ||
| } | ||
| export { deriveMatrix } from "./derive"; | ||
| export { CONSISTENCY_ENGINE_VERSION } from "./engine-version"; | ||
| export { DuplicateOutputError, MissingInputError, RuleDagCycleError } from "./errors"; | ||
| export type { MatrixV1 } from "./generated/matrix"; | ||
| // Canonical types are generated from schemas/*.schema.json by `bun run codegen`. | ||
| // @mochi.js/consistency *owns* both ProfileV1 and MatrixV1 — see AGENTS.md §5. | ||
| export type { ProfileV1 } from "./generated/profile"; | ||
| // Public PRNG surface — promoted from internal at v0.2.1 so downstream packages | ||
| // (e.g. `@mochi.js/behavioral`, phase 0.8) can reuse the seeded xoshiro256** | ||
| // without re-implementing it. See PLAN.md §5.5 ("pure-data principle"): the | ||
| // behavioral engine is deterministic, and its determinism MUST share the same | ||
| // PRNG primitive as the consistency engine to avoid divergence. | ||
| // | ||
| // Sub-export `@mochi.js/consistency/prng` is also exposed via package.exports | ||
| // so consumers may import it without pulling the rule DAG. The barrel here | ||
| // is the canonical re-export. | ||
| export { deriveSeedState, seedToPrng } from "./prng/seed"; | ||
| export { makeXoshiro256ss, type SeededPrng } from "./prng/xoshiro256ss"; | ||
| export type { Rule } from "./rule"; | ||
| export { RULES } from "./rules"; |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
No website
QualityPackage does not have a website.
128557
4340.66%37
825%3385
8362.5%1
-50%1
Infinity%1
Infinity%