4e4dbfb98154

mayan tests: 10 round-trip + RS recovery tests pass (5/100/500/2000-byte payloads, multi-block, magic-byte rejection, deterministic 0.5% bit-flip recovery)

time
2026-05-01 04:51 UTC · 5 months ago
machine
koh2
parent
561a5913550a
2 files src/crypto/mayan.test.ts+138 −0src/crypto/mayan.ts+12 −3
src/crypto/mayan.test.ts+138 −0
@@ -0,0 +1,138 @@1+import { describe, it, expect } from "vitest";2+import {3+  MAYAN_MAGIC,4+  MAYAN_VERSION,5+  MAX_PAYLOAD_BYTES,6+  RUNE_ZONE,7+  QUIET_ZONE,8+  calculateSymbolSize,9+  encodeMayanMatrix,10+  decodeMatrix,11+  isReserved,12+} from "./mayan";13+14+// Deterministic PRNG — Mulberry32 — so the recovery test is reproducible.15+function mulberry32(seed: number) {16+  let state = seed >>> 0;17+  return () => {18+    state = (state + 0x6d2b79f5) >>> 0;19+    let t = state;20+    t = Math.imul(t ^ (t >>> 15), t | 1);21+    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);22+    return ((t ^ (t >>> 14)) >>> 0) / 0x100000000;23+  };24+}25+26+function randomPayload(n: number, seed = 1): Uint8Array {27+  const rng = mulberry32(seed);28+  const out = new Uint8Array(n);29+  for (let i = 0; i < n; i++) out[i] = (rng() * 256) | 0;30+  return out;31+}32+33+function flipRandomDataBits(34+  matrix: boolean[][],35+  size: number,36+  fraction: number,37+  seed = 1,38+): boolean[][] {39+  const center = Math.floor(size / 2);40+  const dataPositions: { r: number; c: number }[] = [];41+  for (let r = QUIET_ZONE; r < size - QUIET_ZONE; r++) {42+    for (let c = QUIET_ZONE; c < size - QUIET_ZONE; c++) {43+      if (!isReserved(r, c, center, size)) dataPositions.push({ r, c });44+    }45+  }46+  const rng = mulberry32(seed);47+  const out = matrix.map((row) => row.slice());48+  const toFlip = Math.floor(dataPositions.length * fraction);49+  for (let i = 0; i < toFlip; i++) {50+    const j = i + Math.floor(rng() * (dataPositions.length - i));51+    [dataPositions[i], dataPositions[j]] = [dataPositions[j], dataPositions[i]];52+    const { r, c } = dataPositions[i];53+    out[r][c] = !out[r][c];54+  }55+  return out;56+}57+58+describe("Mayan format", () => {59+  it("constants are coherent", () => {60+    expect(MAYAN_MAGIC).toBe(0x4d);61+    expect(MAYAN_VERSION).toBe(0x01);62+    expect(RUNE_ZONE).toBe(27);63+    expect(QUIET_ZONE).toBe(2);64+  });65+66+  it("calculateSymbolSize returns >=64 for tiny payloads", () => {67+    expect(calculateSymbolSize(0)).toBe(64);68+    expect(calculateSymbolSize(50)).toBeGreaterThanOrEqual(64);69+  });70+71+  it("calculateSymbolSize grows with payload", () => {72+    const small = calculateSymbolSize(100);73+    const big = calculateSymbolSize(5000);74+    expect(big).toBeGreaterThan(small);75+  });76+77+  it("rejects payloads larger than MAX_PAYLOAD_BYTES", () => {78+    const oversize = new Uint8Array(MAX_PAYLOAD_BYTES + 1);79+    expect(() => encodeMayanMatrix(oversize)).toThrow(/payload too large/);80+  });81+});82+83+describe("Mayan round-trip", () => {84+  it("round-trips a 5-byte payload", () => {85+    const payload = new Uint8Array([1, 2, 3, 4, 5]);86+    const { matrix, size } = encodeMayanMatrix(payload);87+    const decoded = decodeMatrix(matrix, size);88+    expect(decoded).not.toBeNull();89+    expect(Array.from(decoded!)).toEqual(Array.from(payload));90+  });91+92+  it("round-trips a 100-byte random payload", () => {93+    const payload = randomPayload(100);94+    const { matrix, size } = encodeMayanMatrix(payload);95+    const decoded = decodeMatrix(matrix, size);96+    expect(decoded).not.toBeNull();97+    expect(Array.from(decoded!)).toEqual(Array.from(payload));98+  });99+100+  it("round-trips a 500-byte payload (multi-block RS)", () => {101+    const payload = randomPayload(500);102+    const { matrix, size } = encodeMayanMatrix(payload);103+    const decoded = decodeMatrix(matrix, size);104+    expect(decoded).not.toBeNull();105+    expect(Array.from(decoded!)).toEqual(Array.from(payload));106+  });107+108+  it("round-trips a 2000-byte payload", () => {109+    const payload = randomPayload(2000);110+    const { matrix, size } = encodeMayanMatrix(payload);111+    const decoded = decodeMatrix(matrix, size);112+    expect(decoded).not.toBeNull();113+    expect(Array.from(decoded!)).toEqual(Array.from(payload));114+  });115+116+  it("rejects a matrix with no Mayan magic byte", () => {117+    const fake: boolean[][] = Array.from({ length: 64 }, () =>118+      new Array<boolean>(64).fill(false),119+    );120+    const decoded = decodeMatrix(fake, 64);121+    expect(decoded).toBeNull();122+  });123+});124+125+describe("Mayan RS recovery", () => {126+  // Recoverable threshold for RS(255,178): up to 38 byte errors per block.127+  // Each byte holds 8 bits, so the bit-flip equivalent is bounded by the128+  // byte coverage P(byte has ≥1 flip) = 1 - (1-p)^8. For 30% byte errors129+  // (within RS limit), p ≈ 0.043 — so 1% bit flips is comfortably inside.130+  it("recovers from 0.5% random bit flips in data area", () => {131+    const payload = randomPayload(50);132+    const { matrix, size } = encodeMayanMatrix(payload);133+    const corrupted = flipRandomDataBits(matrix, size, 0.005, 42);134+    const decoded = decodeMatrix(corrupted, size);135+    expect(decoded).not.toBeNull();136+    expect(Array.from(decoded!)).toEqual(Array.from(payload));137+  });138+});

view file

src/crypto/mayan.ts+12 −3
@@ -16,7 +16,12 @@1616  * Othalan ᛟ at its center. Do not replace with QR/Aztec/etc.1717  */1818 19-import { reedSolomonEncode, reedSolomonDecode } from "./rs";19+import {20+  reedSolomonEncode,21+  reedSolomonDecode,22+  RS_BLOCK_DATA,23+  RS_BLOCK_TOTAL,24+} from "./rs";2025 2126 export const MAYAN_MAGIC = 0x4d;2227 export const MAYAN_VERSION = 0x01;@@ -37,8 +42,12 @@3742 export function calculateSymbolSize(payloadBytes: number): number {3843   const headerBytes = 4;3944   const totalData = payloadBytes + headerBytes;40-  const withECC = totalData / (1 - ECC_RATE);41-  const dataBits = Math.ceil(withECC * 8);45+  // Use the actual RS-encoded byte count (block-padded) instead of the46+  // theoretical ECC_RATE estimate. The RS codec rounds up to whole 178-byte47+  // data blocks and emits 255 bytes per block, so smaller payloads have48+  // higher effective overhead than the 30% nominal rate.49+  const blocks = Math.max(1, Math.ceil(totalData / RS_BLOCK_DATA));50+  const dataBits = blocks * RS_BLOCK_TOTAL * 8;4251 4352   for (let size = 64; size <= 600; size++) {4453     const totalModules = size * size;

view file