/ proof-of-concept / toy-instruments
Sign in
proof-of-concept / toy-instruments
toy-instruments / src / sim / cache.ts
185 lines · 7.3 KBBlameHistoryRaw
1import type { BeadSystem, Excitation, SimOptions, SimResult } from './types';
3// Shared cache of simulation results on artifact-cache (https://artifact-cache.tempory.net,
4// app "toy-instruments"). Reads are public; writes need the app's passcode, kept in
5// localStorage. Keys: v<CACHE_VERSION>/<instrument>/<cpu|gpu>/<SHA-256 of sys, exc, opt>.
6// The hash covers everything the solver sees, so model changes miss old entries by
7// themselves; bump CACHE_VERSION when the solver's output changes for the same inputs.
9export const CACHE_VERSION = 1;
10const BASE = 'https://artifact-cache.tempory.net/api/apps/toy-instruments';
11const PASS_KEY = 'toy-instruments.cache-passcode';
12export const MAX_ENTRY_BYTES = 100 * 1024 * 1024;
14export type Backend = 'cpu' | 'gpu';
16export async function resultKey(instrument: string, sys: BeadSystem, exc: Excitation, opt: SimOptions, backend: Backend): Promise<string> {
17 return `v${CACHE_VERSION}/${instrument}/${backend}/${await digest({ sys, exc, opt })}`;
20// SHA-256 of a canonical encoding: JSON with sorted keys, typed arrays replaced by
21// their type and byte length, followed by the arrays' bytes in order.
22export async function digest(value: unknown): Promise<string> {
23 const parts: Uint8Array[] = [];
24 const head = new TextEncoder().encode(canon(value, parts));
25 const all = new Uint8Array(head.length + parts.reduce((s, p) => s + p.length, 0));
26 all.set(head);
27 let o = head.length;
28 for (const p of parts) {
29 all.set(p, o);
30 o += p.length;
31 }
32 const h = new Uint8Array(await crypto.subtle.digest('SHA-256', all));
33 return [...h].map((b) => b.toString(16).padStart(2, '0')).join('');
36function canon(v: unknown, parts: Uint8Array[]): string {
37 if (ArrayBuffer.isView(v)) {
38 const b = new Uint8Array(v.buffer, v.byteOffset, v.byteLength);
39 parts.push(b);
40 return `{"$":"${v.constructor.name}","n":${b.length}}`;
41 }
42 if (v === null || typeof v !== 'object') return JSON.stringify(v) ?? 'null';
43 if (Array.isArray(v)) return `[${v.map((x) => canon(x, parts)).join(',')}]`;
44 const o = v as Record<string, unknown>;
45 const keys = Object.keys(o).filter((k) => o[k] !== undefined).sort();
46 return `{${keys.map((k) => `${JSON.stringify(k)}:${canon(o[k], parts)}`).join(',')}}`;
49// Binary layout: "TIR1", uint32 header length, header JSON, then each array's bytes,
50// every section starting on an 8-byte boundary.
51const MAGIC = 'TIR1';
52const ARRAYS = { Float32Array, Float64Array };
53type ArrayType = keyof typeof ARRAYS;
55const pad8 = (n: number) => (n + 7) & ~7;
57export function encodeResult(r: SimResult): Uint8Array<ArrayBuffer> {
58 const scalars: Record<string, unknown> = {};
59 const arrays: { name: string; type: ArrayType; length: number }[] = [];
60 const data: Uint8Array[] = [];
61 for (const [name, v] of Object.entries(r)) {
62 if (v instanceof Float32Array || v instanceof Float64Array) {
63 arrays.push({ name, type: v.constructor.name as ArrayType, length: v.length });
64 data.push(new Uint8Array(v.buffer, v.byteOffset, v.byteLength));
65 } else if (v !== undefined) scalars[name] = v;
66 }
67 const head = new TextEncoder().encode(JSON.stringify({ scalars, arrays }));
68 let size = pad8(8 + head.length);
69 for (const d of data) size += pad8(d.length);
70 const out = new Uint8Array(size);
71 out.set(new TextEncoder().encode(MAGIC));
72 new DataView(out.buffer).setUint32(4, head.length, true);
73 out.set(head, 8);
74 let o = pad8(8 + head.length);
75 for (const d of data) {
76 out.set(d, o);
77 o += pad8(d.length);
78 }
79 return out;
82export function decodeResult(buf: ArrayBuffer): SimResult {
83 if (new TextDecoder().decode(new Uint8Array(buf, 0, 4)) !== MAGIC) throw new Error('not a cached result');
84 const hl = new DataView(buf).getUint32(4, true);
85 const { scalars, arrays } = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, 8, hl)));
86 const r: Record<string, unknown> = { ...scalars };
87 let o = pad8(8 + hl);
88 for (const a of arrays as { name: string; type: ArrayType; length: number }[]) {
89 const A = ARRAYS[a.type];
90 r[a.name] = new A(buf, o, a.length);
91 o += pad8(a.length * A.BYTES_PER_ELEMENT);
92 }
93 return r as unknown as SimResult;
96export interface CacheHit {
97 result: SimResult;
98 stored?: Date;
101// Whether a result is cached (HEAD, no download): its size and when it was stored, or null.
102export async function probeResult(key: string, signal?: AbortSignal): Promise<{ bytes: number; stored?: Date } | null> {
103 const res = await fetch(`${BASE}/entries/${key}`, { method: 'HEAD', signal });
104 if (res.status === 404) return null;
105 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
106 const lm = res.headers.get('last-modified');
107 return { bytes: Number(res.headers.get('content-length')) || 0, stored: lm ? new Date(lm) : undefined };
110// Fetch a cached result, or null if there is none. onProgress gets the fraction downloaded.
111export async function loadResult(key: string, onProgress: (f: number) => void, signal?: AbortSignal): Promise<CacheHit | null> {
112 const res = await fetch(`${BASE}/entries/${key}`, { signal });
113 if (res.status === 404) return null;
114 if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
115 // Content-Length is used only for progress (it is the compressed size if the response is encoded)
116 const total = Number(res.headers.get('content-length')) || 0;
117 let buf: ArrayBuffer;
118 if (res.body) {
119 const chunks: Uint8Array[] = [];
120 const reader = res.body.getReader();
121 let got = 0;
122 for (;;) {
123 const { done, value } = await reader.read();
124 if (done) break;
125 chunks.push(value);
126 got += value.length;
127 if (total) onProgress(Math.min(got / total, 1));
128 }
129 const bytes = new Uint8Array(got);
130 let o = 0;
131 for (const c of chunks) {
132 bytes.set(c, o);
133 o += c.length;
134 }
135 buf = bytes.buffer;
136 } else buf = await res.arrayBuffer();
137 const lm = res.headers.get('last-modified');
138 return { result: decodeResult(buf), stored: lm ? new Date(lm) : undefined };
141export async function storeResult(key: string, r: SimResult, meta: Record<string, unknown>): Promise<void> {
142 const pass = getPasscode();
143 if (!pass) throw new Error('no passcode');
144 const body = encodeResult(r);
145 if (body.length > MAX_ENTRY_BYTES) throw new Error(`result is ${Math.round(body.length / 1e6)} MB, over the 100 MB limit`);
146 const res = await fetch(`${BASE}/entries/${key}`, {
147 method: 'PUT',
148 headers: { 'X-Passcode': pass, 'Content-Type': 'application/octet-stream', 'X-Artifact-Meta': encodeURIComponent(JSON.stringify(meta)) },
149 body,
150 });
151 if (!res.ok) throw new Error(await errorText(res));
154export async function checkPasscode(pass: string): Promise<{ valid: boolean; reason?: string }> {
155 const res = await fetch(`${BASE}/check-passcode`, { method: 'POST', headers: { 'X-Passcode': pass } });
156 if (!res.ok) throw new Error(await errorText(res));
157 return res.json();
160export function getPasscode(): string | null {
161 try {
162 return localStorage.getItem(PASS_KEY);
163 } catch {
164 return null;
165 }
168export function setPasscode(pass: string | null) {
169 try {
170 if (pass) localStorage.setItem(PASS_KEY, pass);
171 else localStorage.removeItem(PASS_KEY);
172 } catch {
173 /* storage unavailable */
174 }
177async function errorText(res: Response): Promise<string> {
178 try {
179 const b = await res.json();
180 if (b?.error) return `${res.status}: ${b.error}`;
181 } catch {
182 /* not JSON */
183 }
184 return `${res.status} ${res.statusText}`;
↑↓ move↵openescclose