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
↓ peach-browser-30054dc5.tar.gzatlas at this save
files changed9 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 −4src/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 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);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 /**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 });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;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 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" };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{" "}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;