30054dc526c6

scale Codex parity with vault size — 5% of data chunks, min 2, max 20

time
2026-05-17 20:55 UTC · 5 months ago
machine
koh2
parent
334358e5905e
9 files src/background/codex-handlers.ts+8 −6src/popup/codex/codex.adversarial.test.ts+16 −14src/popup/codex/erasure.ts+17 −9src/popup/codex/generate.test.ts+78 −5src/popup/codex/generate.ts+33 −14src/popup/codex/restore.test.ts+7 −6src/popup/codex/restore.ts+12 −2src/popup/views/CodexGenerateView.tsx+16 −1src/shared/codex-types.ts+57 −4
src/background/codex-handlers.ts+8 −6
@@ -23,7 +23,7 @@2323   type CodexVaultSnapshot,2424   CHUNK_SIZE,2525   CODES_PER_PAGE,26-  PARITY_SHARDS,26+  computeParityShards,2727   STORAGE_KEY_LAST_RESTORED,2828   STORAGE_KEY_VERIFY_CODE,2929 } from "../shared/codex-types";@@ -184,6 +184,7 @@184184   ok: boolean;185185   error?: string;186186   pages?: number;187+  parityShards?: number;187188 }> {188189   if (!(await isProUser())) {189190     return { ok: false, error: "pro_required" };@@ -192,7 +193,7 @@192193     const raw = await chrome.storage.local.get(["peach_vault_a", "peach_vault_b"]);193194     const slotA = (raw as Record<string, unknown>)["peach_vault_a"];194195     const slotB = (raw as Record<string, unknown>)["peach_vault_b"];195-    if (!slotA) return { ok: true, pages: 3 };196+    if (!slotA) return { ok: true, pages: 3, parityShards: 2 };196197 197198     // Match the generator's payload shape: { a, b } JSON, deflated. The198199     // popup's real generator does the same compression on the actual@@ -200,12 +201,13 @@200201     // envelope, so a deflate of the slot blobs is a faithful estimate.201202     const blobJson = JSON.stringify({ a: slotA, b: slotB ?? null });202203     const compressed = deflate(new TextEncoder().encode(blobJson));203-    const dataChunks = Math.ceil(compressed.length / CHUNK_SIZE);204-    const totalChunks = dataChunks + PARITY_SHARDS;204+    const dataChunks = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));205+    const parityShards = computeParityShards(dataChunks);206+    const totalChunks = dataChunks + parityShards;205207     const pages = Math.max(1, Math.ceil(totalChunks / CODES_PER_PAGE));206-    return { ok: true, pages };208+    return { ok: true, pages, parityShards };207209   } catch {208-    return { ok: true, pages: 3 };210+    return { ok: true, pages: 3, parityShards: 2 };209211   }210212 }211213 

view file

src/popup/codex/codex.adversarial.test.ts+16 −14
@@ -13,7 +13,8 @@1313 import {1414   CodexError,1515   CHUNK_SIZE,16-  PARITY_SHARDS,16+  computeParityShards,17+  deriveShardCounts,1718   type ChunkEnvelope,1819   type CodexPayload,1920 } from "../../shared/codex-types";@@ -221,11 +222,11 @@221222   }222223 223224   for (const total of [3, 5, 7, 10, 20]) {224-    it(`context dataShards = total - PARITY_SHARDS at total=${total}`, async () => {225+    it(`context dataShards matches deriveShardCounts at total=${total}`, async () => {225226       const buf = new ChunkBuffer();226227       await buf.add(await makeEnv({ index: 0, total }));227228       const ctx = buf.getContext();228-      expect(ctx?.dataShards).toBe(total - PARITY_SHARDS);229+      expect(ctx?.dataShards).toBe(deriveShardCounts(total).dataShards);229230     });230231   }231232 @@ -279,12 +280,13 @@279280 280281 describe("encodeWithErasure / decodeWithErasure adversarial", () => {281282   it("encoding empty input still produces at least 1 data shard", async () => {282-    const { dataChunks, parityChunks, totalChunks, dataShards } =283+    const { dataChunks, parityChunks, totalChunks, dataShards, parityShards } =283284       await encodeWithErasure(new Uint8Array());284285     expect(dataShards).toBeGreaterThanOrEqual(1);285-    expect(totalChunks).toBe(dataShards + PARITY_SHARDS);286+    expect(parityShards).toBe(computeParityShards(dataShards));287+    expect(totalChunks).toBe(dataShards + parityShards);286288     expect(dataChunks.length).toBe(dataShards);287-    expect(parityChunks.length).toBe(PARITY_SHARDS);289+    expect(parityChunks.length).toBe(parityShards);288290   });289291 290292   for (const size of [1, 100, 1023, 1024, 1025, 5000, 10000]) {@@ -368,7 +370,7 @@368370     const { parityChunks, dataShards, totalChunks } = await encodeWithErasure(369371       data370372     );371-    // Only parity, count = PARITY_SHARDS, typically < dataShards.373+    // Only parity present; with parity < dataShards the decoder must refuse.372374     if (parityChunks.length >= dataShards) return;373375     const map = new Map<number, Uint8Array>();374376     parityChunks.forEach((c, j) => map.set(dataShards + j, c));@@ -379,12 +381,12 @@379381 380382   it("dropping too many data chunks (more than parity) fails cleanly", async () => {381383     const data = crypto.getRandomValues(new Uint8Array(20000));382-    const { dataChunks, parityChunks, dataShards, totalChunks } =384+    const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } =383385       await encodeWithErasure(data);384-    if (dataShards <= PARITY_SHARDS + 1) return; // need enough shards to test.386+    if (dataShards <= parityShards + 1) return; // need enough shards to test.385387     const map = new Map<number, Uint8Array>();386-    // Drop PARITY_SHARDS + 1 data chunks.387-    for (let i = PARITY_SHARDS + 1; i < dataShards; i++) {388+    // Drop parityShards + 1 data chunks (one more than the budget allows).389+    for (let i = parityShards + 1; i < dataShards; i++) {388390       map.set(i, dataChunks[i]!);389391     }390392     parityChunks.forEach((c, j) => map.set(dataShards + j, c));@@ -410,7 +412,7 @@410412   it("missing chunks but enough parity still reconstructs", async () => {411413     const payload = await makePayload(5000);412414     const envs = await buildEnvelopesForPayload(payload);413-    if (envs.length <= PARITY_SHARDS + 1) return;415+    if (envs.length <= 3) return; // need room to drop one and still have parity414416     const buf = new ChunkBuffer();415417     // Skip the first data chunk (index 0). Fill the rest.416418     for (let i = 1; i < envs.length; i++) await buf.add(envs[i]!);@@ -523,9 +525,9 @@523525 524526 describe("ChunkBuffer scale", () => {525527   for (const total of [3, 5, 10]) {526-    it(`buffer accepts ${total - PARITY_SHARDS} data chunks and reports ready`, async () => {528+    const dataShards = deriveShardCounts(total).dataShards;529+    it(`buffer accepts ${dataShards} data chunks and reports ready`, async () => {527530       const buf = new ChunkBuffer();528-      const dataShards = total - PARITY_SHARDS;529531       for (let i = 0; i < dataShards; i++) {530532         const res = await buf.add(await makeEnv({ index: i, total }));531533         expect(res.accepted).toBe(true);

view file

src/popup/codex/erasure.ts+17 −9
@@ -17,7 +17,7 @@1717 import {1818   CHUNK_SIZE,1919   CodexError,20-  PARITY_SHARDS,20+  computeParityShards,2121 } from "../../shared/codex-types";2222 import { getReedSolomon } from "./rs-loader";2323 @@ -89,23 +89,25 @@8989   parityChunks: Uint8Array[];9090   totalChunks: number;9191   dataShards: number;92+  parityShards: number;9293 }9394 9495 /**95- * Split `data` into fixed-size shards and compute `PARITY_SHARDS` parity96- * shards. Pads the trailing data shard with zeros — the original byte length97- * is recovered via the `CodexPayload.vaultBlob` (no length prefix needed on98- * the wire because the outer JSON already bounds the data).96+ * Split `data` into fixed-size shards plus `computeParityShards(dataShards)`97+ * parity shards. Pads the trailing data shard with zeros — the original byte98+ * length is recovered via the `CodexPayload.vaultBlob` (no length prefix99+ * needed on the wire because the outer JSON already bounds the data).99100  */100101 export async function encodeWithErasure(data: Uint8Array): Promise<EncodeResult> {101102   const rs = await freshReedSolomon();102103 103104   const dataShards = Math.max(1, Math.ceil(data.length / CHUNK_SIZE));104-  const totalShards = dataShards + PARITY_SHARDS;105+  const parityShards = computeParityShards(dataShards);106+  const totalShards = dataShards + parityShards;105107   const buffer = new Uint8Array(totalShards * CHUNK_SIZE);106108   buffer.set(data, 0); // Trailing bytes after `data.length` stay zero — the pad.107109 108-  const code = rs.encode(buffer, dataShards, PARITY_SHARDS);110+  const code = rs.encode(buffer, dataShards, parityShards);109111   if (code !== ReedSolomonErasure.RESULT_OK) {110112     throw new CodexError(111113       "ERASURE_CODING_VERIFICATION_FAILED",@@ -120,12 +122,18 @@120122     dataChunks.push(buffer.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE));121123   }122124   const parityChunks: Uint8Array[] = [];123-  for (let i = 0; i < PARITY_SHARDS; i++) {125+  for (let i = 0; i < parityShards; i++) {124126     const base = (dataShards + i) * CHUNK_SIZE;125127     parityChunks.push(buffer.slice(base, base + CHUNK_SIZE));126128   }127129 128-  return { dataChunks, parityChunks, totalChunks: totalShards, dataShards };130+  return {131+    dataChunks,132+    parityChunks,133+    totalChunks: totalShards,134+    dataShards,135+    parityShards,136+  };129137 }130138 131139 /**

view file

src/popup/codex/generate.test.ts+78 −5
@@ -2,7 +2,7 @@22 import {33   CHUNK_SIZE,44   CodexError,5-  PARITY_SHARDS,5+  computeParityShards,66   STORAGE_KEY_VERIFY_CODE,77   type CodexDeps,88 } from "../../shared/codex-types";@@ -142,10 +142,12 @@142142     const original = new Uint8Array(CHUNK_SIZE * 3 + 17);143143     for (let i = 0; i < original.length; i++) original[i] = (i * 31) & 0xff;144144 145-    const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);145+    const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } =146+      await encodeWithErasure(original);146147     expect(dataShards).toBe(4);147-    expect(parityChunks.length).toBe(PARITY_SHARDS);148-    expect(totalChunks).toBe(dataShards + PARITY_SHARDS);148+    expect(parityShards).toBe(computeParityShards(dataShards));149+    expect(parityChunks.length).toBe(parityShards);150+    expect(totalChunks).toBe(dataShards + parityShards);149151 150152     const present = new Map<number, Uint8Array>();151153     dataChunks.forEach((c, i) => present.set(i, c));@@ -213,7 +215,7 @@213215 214216     expect(manifest.verifyCode).toBe("PX-TEST-CODE");215217     expect(manifest.entryCount).toBe(3);216-    expect(manifest.parityChunks).toBe(PARITY_SHARDS);218+    expect(manifest.parityChunks).toBe(computeParityShards(manifest.dataChunks));217219     expect(manifest.totalChunks).toBe(manifest.dataChunks + manifest.parityChunks);218220     expect(manifest.pageCount).toBeGreaterThanOrEqual(1);219221     expect(manifest.vaultFingerprint).toMatch(/^[0-9A-F]{32}$/); // AUD-004@@ -242,4 +244,75 @@242244     expect(STORAGE_KEY_VERIFY_CODE).toBe("peach_codex_verify_code");243245     expect(CodexError).toBeTypeOf("function");244246   });247+});248+249+describe("parity scaling", () => {250+  beforeEach(() => {251+    installChromeStorageMock();252+  });253+254+  it("computeParityShards returns minimum 2 for very small vaults", () => {255+    expect(computeParityShards(1)).toBe(2);256+    expect(computeParityShards(10)).toBe(2);257+    expect(computeParityShards(13)).toBe(2);258+    expect(computeParityShards(40)).toBe(2);259+  });260+261+  it("computeParityShards scales at 5% for medium vaults", () => {262+    expect(computeParityShards(56)).toBe(3); // 56 * 0.05 = 2.8 → ceil = 3263+    expect(computeParityShards(100)).toBe(5);264+    expect(computeParityShards(213)).toBe(11);265+  });266+267+  it("computeParityShards caps at 20 for large vaults", () => {268+    expect(computeParityShards(600)).toBe(20);269+    expect(computeParityShards(660)).toBe(20);270+    expect(computeParityShards(1000)).toBe(20);271+  });272+273+  it("encodeWithErasure returns scaled parityShards", async () => {274+    const data = new Uint8Array(50_000);275+    for (let i = 0; i < data.length; i++) data[i] = (i * 37) & 0xff;276+    const { dataShards, parityShards } = await encodeWithErasure(data);277+    expect(parityShards).toBe(computeParityShards(dataShards));278+  });279+280+  it("large vault: survives loss of up to parityShards codes", async () => {281+    const data = new Uint8Array(200_000);282+    for (let i = 0; i < data.length; i++) data[i] = (i * 37) & 0xff;283+284+    const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } =285+      await encodeWithErasure(data);286+287+    const allChunks = new Map<number, Uint8Array>(288+      [...dataChunks, ...parityChunks].map((c, i) => [i, c]),289+    );290+    // Drop exactly parityShards codes — the maximum survivable loss.291+    for (let i = 0; i < parityShards; i++) allChunks.delete(i);292+293+    const reconstructed = await decodeWithErasure(294+      allChunks,295+      dataShards,296+      totalChunks,297+    );298+    for (let i = 0; i < data.length; i++) {299+      expect(reconstructed[i]).toBe(data[i]);300+    }301+  }, 60_000);302+303+  it("large vault: fails gracefully when loss exceeds parityShards", async () => {304+    const data = new Uint8Array(200_000);305+    for (let i = 0; i < data.length; i++) data[i] = (i * 41) & 0xff;306+    const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } =307+      await encodeWithErasure(data);308+309+    const allChunks = new Map<number, Uint8Array>(310+      [...dataChunks, ...parityChunks].map((c, i) => [i, c]),311+    );312+    for (let i = 0; i < parityShards + 1; i++) allChunks.delete(i);313+314+    await expect(315+      decodeWithErasure(allChunks, dataShards, totalChunks),316+    ).rejects.toThrow(CodexError);317+  }, 60_000);245318 });

view file

src/popup/codex/generate.ts+33 −14
@@ -18,7 +18,7 @@1818   CHUNK_SIZE,1919   CODES_PER_PAGE,2020   CodexError,21-  PARITY_SHARDS,21+  computeParityShards,2222   type ChunkEnvelope,2323   type CodexDeps,2424   type CodexManifest,@@ -70,8 +70,16 @@7070 7171   // STEP 4 — erasure encode + simulated-loss verify7272   onProgress?.("erasure");73-  const { dataChunks, parityChunks, totalChunks, dataShards } = await encodeWithErasure(compressed);74-  await verifyErasureCoding(dataChunks, parityChunks, dataShards, totalChunks, compressed);73+  const { dataChunks, parityChunks, totalChunks, dataShards, parityShards } =74+    await encodeWithErasure(compressed);75+  await verifyErasureCoding(76+    dataChunks,77+    parityChunks,78+    dataShards,79+    parityShards,80+    totalChunks,81+    compressed,82+  );7583 7684   // STEP 5 — envelopes (data first, then parity, preserving global index ordering)7785   const envelopes: ChunkEnvelope[] = [];@@ -93,9 +101,16 @@93101   }94102   onProgress?.("aztec", envelopes.length, envelopes.length);95103 96-  // STEP 7 — full reconstruction simulation (lose 2 random envelopes, rebuild)104+  // STEP 7 — full reconstruction simulation (lose the maximum tolerated105+  // number of envelopes for this Codex, then rebuild)97106   onProgress?.("verify");98-  await simulateAndVerifyReconstruction(envelopes, dataShards, totalChunks, payload);107+  await simulateAndVerifyReconstruction(108+    envelopes,109+    dataShards,110+    parityShards,111+    totalChunks,112+    payload,113+  );99114 100115   // STEP 8 — render PDF101116   onProgress?.("pdf");@@ -103,7 +118,7 @@103118     verifyCode,104119     totalChunks,105120     dataChunks: dataShards,106-    parityChunks: PARITY_SHARDS,121+    parityChunks: parityShards,107122     pageCount: Math.ceil(totalChunks / CODES_PER_PAGE),108123     entryCount,109124     vaultFingerprint,@@ -183,7 +198,7 @@183198 export async function estimateCodexPages(184199   slotA: string,185200   slotB: string | null,186-): Promise<number> {201+): Promise<{ pages: number; parityShards: number }> {187202   const combined = JSON.stringify({ a: slotA, b: slotB ?? null });188203   const vaultBlob = bytesToBase64Url(new TextEncoder().encode(combined));189204   // Stand-in payload — the exact verify code / timestamp / fingerprint don't@@ -199,8 +214,10 @@199214   };200215   const compressed = deflate(new TextEncoder().encode(JSON.stringify(stub)), { level: 9 });201216   const dataShards = Math.max(1, Math.ceil(compressed.length / CHUNK_SIZE));202-  const totalChunks = dataShards + PARITY_SHARDS;203-  return Math.ceil(totalChunks / CODES_PER_PAGE);217+  const parityShards = computeParityShards(dataShards);218+  const totalChunks = dataShards + parityShards;219+  const pages = Math.ceil(totalChunks / CODES_PER_PAGE);220+  return { pages, parityShards };204221 }205222 206223 /** Pick 3 distinct positions in [1, 12] for the recovery-words confirmation@@ -261,14 +278,15 @@261278 262279 /**263280  * Confirm the encoded shards can be reconstructed from a partial set. We264- * drop two random shards (the maximum loss our PARITY_SHARDS budget tolerates)265- * and check byte-for-byte that the recovered prefix equals the original266- * compressed blob.281+ * drop `parityShards` random shards (the maximum loss the parity budget282+ * tolerates for this Codex) and check byte-for-byte that the recovered283+ * prefix equals the original compressed blob.267284  */268285 async function verifyErasureCoding(269286   dataChunks: Uint8Array[],270287   parityChunks: Uint8Array[],271288   dataShards: number,289+  parityShards: number,272290   totalChunks: number,273291   originalCompressed: Uint8Array,274292 ): Promise<void> {@@ -276,7 +294,7 @@276294   dataChunks.forEach((chunk, i) => present.set(i, chunk));277295   parityChunks.forEach((chunk, i) => present.set(dataShards + i, chunk));278296 279-  const removed = pickRandomIndices(totalChunks, Math.min(PARITY_SHARDS, totalChunks - 1));297+  const removed = pickRandomIndices(totalChunks, Math.min(parityShards, totalChunks - 1));280298   for (const idx of removed) present.delete(idx);281299 282300   let recovered: Uint8Array;@@ -305,13 +323,14 @@305323 async function simulateAndVerifyReconstruction(306324   envelopes: ChunkEnvelope[],307325   dataShards: number,326+  parityShards: number,308327   totalChunks: number,309328   originalPayload: CodexPayload,310329 ): Promise<void> {311330   const present = new Map<number, Uint8Array>();312331   envelopes.forEach((env, i) => present.set(i, base64UrlToBytes(env.d)));313332 314-  const removed = pickRandomIndices(totalChunks, Math.min(PARITY_SHARDS, totalChunks - 1));333+  const removed = pickRandomIndices(totalChunks, Math.min(parityShards, totalChunks - 1));315334   for (const idx of removed) present.delete(idx);316335 317336   let reconstructed: CodexPayload;

view file

src/popup/codex/restore.test.ts+7 −6
@@ -12,7 +12,7 @@1212 1313 import {1414   CodexError,15-  PARITY_SHARDS,15+  deriveShardCounts,1616   type ChunkEnvelope,1717   type CodexPayload,1818 } from "../../shared/codex-types";@@ -119,7 +119,7 @@119119     expect(buf.getContext()).toEqual({120120       verifyCode: VERIFY_CODE,121121       total: 5,122-      dataShards: 5 - PARITY_SHARDS,122+      dataShards: deriveShardCounts(5).dataShards,123123     });124124   });125125 @@ -171,7 +171,7 @@171171   it("isReady() flips true at dataShards count", async () => {172172     const buf = new ChunkBuffer();173173     const total = 5;174-    const dataShards = total - PARITY_SHARDS; // 3174+    const dataShards = deriveShardCounts(total).dataShards;175175     for (let i = 0; i < dataShards; i++) {176176       expect(buf.isReady()).toBe(false);177177       const env = await makeSingleEnvelope({@@ -253,11 +253,12 @@253253   it("reconstructs after losing 2 chunks", async () => {254254     const payload = await makePayload();255255     const envelopes = await buildEnvelopesForPayload(payload);256-    // Need at least dataShards + PARITY_SHARDS total to lose exactly PARITY_SHARDS.257-    expect(envelopes.length).toBeGreaterThan(PARITY_SHARDS);256+    // Need at least dataShards + 2 chunks total so dropping 2 still leaves257+    // enough for reconstruction (small vaults pin to the 2-shard parity floor).258+    expect(envelopes.length).toBeGreaterThan(2);258259 259260     // Drop two indices that exercise the parity-decode path: one data shard260-    // and one parity shard. PARITY_SHARDS=2 means we can lose any two.261+    // and one parity shard. The parity floor of 2 means we can lose any two.261262     const dropA = 1;262263     const dropB = envelopes.length - 1;263264 

view file

src/popup/codex/restore.ts+12 −2
@@ -23,7 +23,7 @@2323 2424 import {2525   CodexError,26-  PARITY_SHARDS,26+  deriveShardCounts,2727   type ChunkEnvelope,2828   type CodexPayload,2929 } from "../../shared/codex-types";@@ -85,10 +85,20 @@8585       // First valid chunk establishes the Codex context. Later chunks must8686       // agree on verifyCode and total — that is how we keep two overlapping8787       // Codex prints from poisoning each other.88+      //89+      // parityShards is no longer fixed: derive it (and dataShards) from the90+      // printed total via the inverse of `computeParityShards`. Malformed91+      // totals throw — buffer.add returns parse_error.92+      let dataShards: number;93+      try {94+        dataShards = deriveShardCounts(envelope.t).dataShards;95+      } catch {96+        return { accepted: false, reason: "parse_error" };97+      }8898       this.ctx = {8999         verifyCode: envelope.c,90100         total: envelope.t,91-        dataShards: envelope.t - PARITY_SHARDS,101+        dataShards,92102       };93103     } else if (envelope.c !== this.ctx.verifyCode) {94104       return { accepted: false, reason: "foreign_codex" };

view file

src/popup/views/CodexGenerateView.tsx+16 −1
@@ -51,6 +51,7 @@5151 export function CodexGenerateView(props: CodexGenerateViewProps) {5252   const [snapshot, setSnapshot] = createSignal<CodexVaultSnapshot | null>(null);5353   const [estimatedPages, setEstimatedPages] = createSignal<number | null>(null);54+  const [estimatedParity, setEstimatedParity] = createSignal<number | null>(null);5455   const [loading, setLoading] = createSignal(true);5556   const [loadError, setLoadError] = createSignal<string | null>(null);5657   const [missingRecoveryWords, setMissingRecoveryWords] = createSignal(false);@@ -111,11 +112,16 @@111112 112113     if (res.data.slotA) {113114       try {114-        const pages = await estimateCodexPages(res.data.slotA, res.data.slotB);115+        const { pages, parityShards } = await estimateCodexPages(116+          res.data.slotA,117+          res.data.slotB,118+        );115119         setEstimatedPages(pages);120+        setEstimatedParity(parityShards);116121       } catch {117122         // Estimation is informational; failure is non-fatal.118123         setEstimatedPages(null);124+        setEstimatedParity(null);119125       }120126     }121127 @@ -383,7 +389,16 @@383389                 {estimatedPages() === 1 ? "page" : "pages"} ·{" "}384390                 {snapshot()!.entryCount}{" "}385391                 {snapshot()!.entryCount === 1 ? "entry" : "entries"}392+                <Show when={estimatedParity() !== null}>393+                  {" "}· {estimatedParity()} redundancy codes394+                </Show>386395               </p>396+              <Show when={estimatedParity() !== null}>397+                <p class="peach-codex-meta peach-codex-meta-hint">398+                  can recover from loss of up to {estimatedParity()} codes399+                  anywhere in the document.400+                </p>401+              </Show>387402               <Show when={(estimatedPages() ?? 0) >= 2}>388403                 <p class="peach-codex-meta peach-codex-meta-hint">389404                   this is your complete vault. print double-sided to reduce to{" "}

view file

src/shared/codex-types.ts+57 −4
@@ -168,12 +168,65 @@168168  */169169 export const CHUNK_SIZE = 1024;170170 171+/** Floor on parity shards: a Codex must always survive losing at least 2 codes. */172+export const CODEX_MIN_PARITY_SHARDS = 2;173+174+/** Ceiling on parity shards: caps printing overhead for very large vaults. */175+export const CODEX_MAX_PARITY_SHARDS = 20;176+177+/** Target ratio of parity to data shards (5%). */178+export const CODEX_PARITY_RATIO = 0.05;179+171180 /**172- * Number of parity shards. With 2 parity shards, the Codex survives the loss173- * of any 2 chunks (torn corner, coffee stain over one code, etc.) without174- * data loss. Raising this beyond 2 grows the page count meaningfully.181+ * Number of parity shards for a Codex with `dataShards` data shards. Scales182+ * with vault size — small vaults pin to the minimum, huge vaults cap at the183+ * maximum. Survives the loss of up to the returned count of codes anywhere184+ * in the printed document. The function is monotonic non-decreasing in185+ * `dataShards`, which `deriveShardCounts` exploits to invert (total → data, parity).186+ *187+ * Examples:188+ *   13 → 2   (minimum floor)189+ *   56 → 3   (5% bracket)190+ *   213 → 11191+ *   660 → 20 (maximum cap)175192  */176-export const PARITY_SHARDS = 2;193+export function computeParityShards(dataShards: number): number {194+  return Math.min(195+    CODEX_MAX_PARITY_SHARDS,196+    Math.max(197+      CODEX_MIN_PARITY_SHARDS,198+      Math.ceil(dataShards * CODEX_PARITY_RATIO),199+    ),200+  );201+}202+203+/**204+ * Recover (dataShards, parityShards) from a total chunk count printed on a205+ * Codex. Restoration sees only `ChunkEnvelope.t` from scanned codes — there206+ * is no manifest to consult — so we invert `computeParityShards` by trying207+ * each candidate parity count in [MIN, MAX] and picking the one that round208+ * trips. Monotonicity of `computeParityShards` guarantees a unique solution.209+ */210+export function deriveShardCounts(totalChunks: number): {211+  dataShards: number;212+  parityShards: number;213+} {214+  for (215+    let parityShards = CODEX_MIN_PARITY_SHARDS;216+    parityShards <= CODEX_MAX_PARITY_SHARDS;217+    parityShards++218+  ) {219+    const dataShards = totalChunks - parityShards;220+    if (dataShards < 1) continue;221+    if (computeParityShards(dataShards) === parityShards) {222+      return { dataShards, parityShards };223+    }224+  }225+  throw new CodexError(226+    "ERASURE_CODING_VERIFICATION_FAILED",227+    `Cannot derive shard layout for totalChunks=${totalChunks}`,228+  );229+}177230 178231 /** Aztec error-correction percentage. 25% balances barcode density vs. resilience. */179232 export const AZTEC_ECC_PERCENT = 25;

view file