# Luxar Viewer Cache Package

Multi-tier caching system with intelligent prefetching for zarr chunks enabling offline viewing, instant reloads, and reduced bandwidth.

## Overview

This package implements a transparent caching and prefetching layer for zarr datasets:

- **S-cache (SliceCache, `slice-cache.ts`)**: LRU cache of fully DECODED
  per-slice geometry ladders, keyed per node + view signature (displayDims,
  slicePosition, tolerance, dimensions). Its byte budget is **heap-aware and
  two-sided** (`heap-budget.ts::computeCacheBudgets`): it scales UP on a large
  device heap so a fits-in-RAM timelapse stays fully resident (decode-free
  revisit) and DOWN on a small heap to avoid OOM; `config.cache.sliceCacheMaxSizeMB`
  (128MB) is the fixed fallback used where `performance.memory` is unavailable
  (Firefox/Safari). A slice revisit — e.g. scrubbing back
  to a timepoint — skips the whole query + fetch + decode pipeline; the
  nD→3D projection re-runs unless a **stage output** matches (below). Sits
  ABOVE L0; in-memory, per-session, cleared on content-hash invalidation.
  Disable with `?noSliceCache`.
- **S-cache stage outputs (#2944 B2, `slice-cache.ts` + `slice-cache-origin.ts`)**:
  a Lines/GSplats entry may also carry the PROJECTED buffers of its slice,
  tagged by a signature of every projection input other than the slice data
  (`data/scene-loader/process/projection-stage-cache.ts`). The data processors
  find the entry through an origin stamp the loaders put on data restored from
  it (payload object identity — never a content compare), so a revisit with
  unchanged parameters skips the worker projection and a changed parameter
  (e.g. `uTruncate`) misses and replaces it. Stage bytes count inside the
  S-cache budget and leave with their slice, but they are admitted only into
  SPARE budget and are shed first when a slice needs room — a saturated cache
  (every timelapse that fills the budget) behaves exactly as without them.
- **Eager line working set (`heap-budget.ts::computeWorkingSetBudgetBytes`)**:
  resolves from the explicit cache-pool override, measured heap, device class,
  or fixed fallback in that order. Pool- and heap-derived budgets reserve half
  of the corresponding non-cache remainder (capped at 512 MiB) for overlapping
  line decode/projection/staging work.
- **L2 write-queue retain (`heap-budget.ts::computeOpfsWriteQueueBudgetBytes`)**:
  resolves the same way, taking a QUARTER of that non-cache remainder (capped
  at 512 MiB, 256 MiB with no memory signal) as the bytes pending background
  OPFS writes may hold — 328 MiB on a 4 GiB heap. That clears the 214 MB the
  largest audited scene streams before its cap binds (peak pending ≈ cumulative
  streamed there; inferred from the drain rate, not measured) and clears that
  scene's 277 MB whole-store ceiling by 1.18×. It binds again below roughly a
  2.6 GiB heap (heap-relative by design). The
  eager working set takes half of the same remainder and the last quarter is
  claimed by neither; that bounds these two shares, not total commitment —
  the eager half is handed in full to two independent consumers, while GPU
  geometry takes the full remainder under its own ceiling (see the share
  constants in `heap-budget.ts`).
  Deliberately NOT derived from the L1 budget: a pending write's buffer is the
  same one L1 already holds and bounds, so an L1-resident pending write costs a
  reference rather than a second copy (#2561). An overflowing arrival is the
  entry dropped, so the oldest end drains in order and what lands on disk is
  contiguous runs of the load.
- **L0 (Decompressed)**: LRU cache for decoded TypedArrays (eliminates Blosc decompression); heap-aware budget, config `l0MaxSizeMB` (200) is the ceiling — see `heap-budget.ts`
- **L1 (Memory)**: segmented LRU cache with metadata protection; heap-aware budget, config `l1MaxSizeMB` (100) is the ceiling
- **L2 (OPFS)**: 2GB persistent storage surviving browser restarts (disk — fixed, not heap-sized)
- **Intelligent Prefetching**: Proactive loading of adjacent chunks to hide network latency
- **Content-hash validation**: Automatic cache invalidation when data changes
- **Zero overhead**: Stores raw compressed chunks (no re-compression)

## Cache Hierarchy

```
Slice revisit → S-cache (decoded slice ladder) ────────────────┐ hit: skip all of ↓
Chunk request → L0 (Decompressed) → L1 (Memory) → L2 (OPFS) → Remote HTTP
                    ↓                   ↓             ↓            ↓
                 ~1μs               ~1μs+2ms       ~1ms+2ms     ~100ms+2ms
             (no decompress)    (decompress)   (decompress)   (decompress)
```

| Level   | Storage | Speed         | Size        | Persistence  | Content               |
| ------- | ------- | ------------- | ----------- | ------------ | --------------------- |
| S-cache | Memory  | ~1μs          | heap-aware† | Session only | Decoded slice ladders |
| L0      | Memory  | ~1μs          | heap-aware† | Session only | Decompressed data     |
| L1      | Memory  | ~1μs + ~2ms\* | heap-aware† | Session only | Compressed chunks     |
| L2      | OPFS    | ~1ms + ~2ms\* | 2GB         | Permanent    | Compressed chunks     |
| L3      | Remote  | ~100ms        | ∞           | N/A          | Compressed chunks     |

\*~2ms is Blosc decompression time per chunk (skipped on L0 hit)

† The three in-memory tiers are sized two-sidedly from the device heap by
`heap-budget.ts` — `l0MaxSizeMB` (200) and `l1MaxSizeMB` (100) are L0/L1
ceilings; `sliceCacheMaxSizeMB` (128) is the S-cache fallback and the cap on
its pool-relative floor, not its ceiling. On a large heap the S-cache scales up
(residual headroom, capped at `SLICE_CAP_BYTES`, 2 GiB today); on a small heap
all three scale down to stay within `dataLoading.memory.targetHeapUsage`.
L2 (OPFS/disk) is a fixed 2GB and unaffected.

Heap detection uses `performance.memory`, which is **Chrome/Blink-only**. In
**WebKit — WKWebView (the native `luxar export --native` app) and Safari** — it
is absent, so the heap can't be measured. There, pass an explicit pool with
**`?cacheBudgetMB=<N>`** (the native launcher injects it automatically, default
2048, env `LUXAR_CACHE_BUDGET_MB`); it takes precedence over heap detection and
is split across the tiers the same way. The implied non-cache remainder also
becomes the viewer's auto GPU-geometry/LOD residency signal, capped separately
at 2 GB, so the launcher default raises that budget from 512 to 1432 MB; lower
values reduce both cache and retained geometry pressure. The GPU and refinement
ceilings are independent but not additive because they count overlapping
renderer rows. Mobile devices retain a separate 128 MiB GPU safety cap, so a
mobile override can only lower that value. Without an override, WebKit falls back to
an inferred **device-class** pool (the `deviceClass` of the shared input profile, `utils/input-capabilities`): mobile ≈ 384 MB, laptop
≈ 1 GB, desktop ≈ 2 GB. `mobile` is detected reliably (mobile UA, or touch +
coarse pointer — which also catches iPadOS); laptop vs desktop is a deliberately
weak `hardwareConcurrency ≥ 12` proxy (there is no in-browser RAM signal on
WebKit — `navigator.deviceMemory` is Chromium-only — and laptop/desktop aren't
reliably distinguishable, so this is an educated guess, safe on 8 GB+ machines).
The fixed config sizes are the last resort (non-browser / no device signals).

## Quick Start

The cache is **enabled by default** and works transparently through the Luxar Zarr facade:

```typescript
import { MultiLevelCachingStore } from '../cache/multi-level-caching-store';
import * as zarr from '../data/zarr';

// Create caching store
const store = new MultiLevelCachingStore('https://example.com/dataset.luxar.zarr', {
  l1MaxSize: 100 * 1024 * 1024, // 100MB (optional)
  l2MaxSize: 2 * 1024 * 1024 * 1024, // 2GB (optional)
  debug: false, // Set true for verbose logging (optional)
});

await store.init();

// Open through the Luxar Zarr facade - completely transparent!
const zarrStore = await zarr.openStore(store);
const root = await zarr.openGroup(zarrStore);

// First load: HTTP → L2 → L1 → decompress → L0 → render
// Second load: L0 → render (~1μs, no decompression!)
// After reload: L2 → L1 → decompress → L0 → render (~1ms)

// Clean up when done
await store.dispose();
```

## URL Parameters

Override cache behavior via URL parameters:

- `?noCache` - Disable all caching (S-cache + L0 + L1 + L2) for this session
- `?noSliceCache` - Disable only the SliceCache (S-cache); L0/L1/L2 stay on
- `?noOpfs` - Disable only the L2 OPFS tier; L0/L1/S-cache stay on. The deterministic sibling of the OPFS circuit breaker, for environments whose OPFS is known to stall (automated Chromium). A deliberate disable does NOT raise the `opfs-unavailable` badge
- No OPFS at all (plain http from another host is not a secure context, so `navigator.storage` is absent; or `getDirectory()` throws a `SecurityError`): the L2 tier is skipped with ONE `console.debug` line (`isOpfsSupported()` in `opfs-store/opfs-root.ts`) rather than a warning with a stack, and the `opfs-unavailable` badge shows as for any unavailable L2
- `?opfsReadConcurrency=<N>` - Override the page-wide concurrent OPFS read cap (default 64) for diagnosis
- `?cacheDebug` - Enable verbose cache logging for all layers
- `?clearCache` - Clear all persistent/persisted caches (L0 + L1 + L2) before loading dataset (the in-memory S-cache is created fresh per load)
- `?noPrefetch` - Disable prefetching (caches still active)
- `?prefetchDebug` - Enable verbose prefetch logging
- `?cacheStats` - Auto-open the data-loading monitor expanded on the Cache tab

Example:

```
http://localhost:5173/?src=http://example.com/data.luxar.zarr&cacheDebug&prefetchDebug
```

## Cache invariants and lifecycle

These contracts span every tier and are enforced by the unit tests in
`tests/unit/cache/invalidation-chain.test.ts` and friends:

### Lifecycle

- **`MultiLevelCachingStore.dispose()`** is async. It sets a `disposed`
  flag (synchronously short-circuiting `getResult`), aborts the
  store-level `dataAbort` controller (cancelling in-flight prefetch /
  demand fetches), tears down the prefetcher, and awaits L2 dispose.
- **`SceneLoader.dispose()`** is async and awaited by `loadScene` before
  the next caching store is constructed. Sync callers (the
  `beforeunload` path, `SceneLoaderManager.destroyLoader`) keep
  working — the returned promise just unwinds in the background.
- **`SceneLoaderManager.destroyLoaderAsync` / `destroyAllAsync`** are
  awaitable variants for callers that need deterministic teardown
  (dataset switches, tests).

### Invalidation

- `clearAll()` invokes every registered `onInvalidate` callback so L0
  (wired through `cache-setup.ts`) is cleared alongside L1 and L2.
- `clearL1()` and `clearL2()` are single-tier ops — they do NOT fire
  invalidation callbacks. L0 stays populated.
- Content-hash mismatch in `doValidateCache` defensively clears L1
  alongside L2, then fans out via `onInvalidate` to L0.
- Coalesced `pendingGets` clean up by entry identity, not key alone. An older
  aborted chain settling after invalidation therefore cannot evict a newer
  same-key request from the map or make that replacement unabortable.
- Signal-bearing demand reads still coalesce by key. Cancelling one waiter leaves
  the shared chain running for the others; invalidation, or cancellation of the
  last waiter, aborts the source request.
- A live demand read aborted by its caller or invalidation rejects with
  `AbortError`; it never becomes `undefined`, because zarrita interprets that
  value as a missing chunk and would commit fill-value geometry. Reads unwinding
  after store disposal remain quiet because the owning scene is already being
  discarded.

### OPFS mutation ordering

- **Generation token**: every `clear()` bumps a counter. A `set()` in
  flight when `clear()` runs detects the mismatch on completion and
  skips its index update (best-effort delete the just-written file).
- **Pending writes drain**: `clear()` awaits in-flight `pendingWrites`
  via `Promise.allSettled` before resetting state.
- **Disposed flag**: post-`dispose()` `set/get/touch` are no-ops.
- **In-flight metadata save tracking**: `dispose()` awaits the
  metadata manager's in-flight save (`awaitInFlight()`) before the final
  flush.

### L0 read-only chunk contract

Cached zarr chunks are returned by reference on the L0 hit path.
Loaders MUST treat chunk data as immutable input and copy into
accumulator/output buffers before mutating. See the dedicated section
under "L0 Decompressed Chunk Cache" below.

## Cache health and validation modes

`MultiLevelCachingStore.getStats()` returns a `health` field with:

- `validationMode: 'content-hash' | 'zattrs-hash' | 'archive-etag' | 'ttl' | 'none'`
  - `content-hash`: Luxar dataset with `content_hash` attr — strongest
    invalidation guarantee.
  - `archive-etag`: a zipped store, identified by a probe on the archive itself
    (its root attrs live inside it, so no document can be re-fetched alone).
    Covers the whole store at once rather than one document.
  - `zattrs-hash`: dataset without `content_hash`; the SHA-256 of the raw
    root `.zattrs` bytes serves as an implicit validation token. Luxar
    writers re-stamp a per-save `timestamp` attr, so a dataset regenerated
    in place at the same URL still invalidates.
  - `ttl`: root `.zattrs` unreachable AND no cached content hash to fall
    back on; cache is invalidated after `cache.externalDatasetTtlMs`
    elapses. (A dataset that was hash-validated online keeps its hash
    mode offline and is never TTL-expired.)
  - `none`: `.zattrs` unreachable, no cached hash, no TTL configured —
    cache may be stale indefinitely until manually cleared. The cache tab
    surfaces this as an `unvalidated-external-dataset` badge.
- `lastValidatedAt: number | null`: wall-clock millis at last successful source
  validation, or the fixed known-good baseline under `ttl`/`none` — seeded once
  and NOT advanced by repeat no-token checks, so the TTL age grows monotonically.
- `unvalidatedExternalDataset: boolean`: convenience flag.

`OPFSStore.getStats()` exposes health counters:

- `oversizedWriteSkipped`: entry larger than `maxSize` was rejected.
- `quotaWriteSkipped`: browser reported insufficient quota.
- `evictions`: own-LRU evictions to make room for incoming writes.
- `writeFailures`: I/O exceptions during `set()`.
- `corruptedEntries`: get() detected a size mismatch and removed the
  bad entry.
- `metadataParseFailures`: `_cache_meta.json` could not be parsed.
- `orphanedFilesRemoved`: unindexed files deleted by the open-time orphan
  reconcile (unrecoverable, or no recovery hash; see "L2 index persistence").
- `orphansReindexed`: unindexed files recovered into the index, by the
  reconcile or by a first read of the key.

## Cache Status Badges

The data monitor's cache tab renders a row of small pill badges
summarising the cache's operational state. Each badge is also exposed
on `CacheMetrics.status: CacheStatusBadge[]` so programmatic consumers
(debug snapshots, E2E tests) can assert on the same set.

| Badge                          | Meaning                                                       | Source                                                                                                                                                                                                                                                              |
| ------------------------------ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `cache-enabled`                | Caching is wired and operational.                             | `telemetryState.kind === 'enabled'`                                                                                                                                                                                                                                 |
| `no-cache`                     | The `?noCache` URL flag is set; all tiers disabled.           | `telemetryState.kind === 'disabled-no-cache'`                                                                                                                                                                                                                       |
| `disabled-config`              | App config disabled caching (e.g. `cache.enabled: false`).    | `telemetryState.kind === 'disabled-config'`                                                                                                                                                                                                                         |
| `opfs-unavailable`             | OPFS is absent OR mounts read-only; L2 is disabled.           | OPFS provider absent, the init write probe failed (WebKit/WKWebView has no main-thread `createWritable`), or the circuit breaker tripped after `opfsTimeoutTripThreshold` consecutive OPFS timeouts (#1645). A DELIBERATE `?noOpfs` / `?noCache` does NOT raise it. |
| `quota-constrained`            | L2 has skipped at least one write because of browser quota.   | `l2.quotaWriteSkipped > 0`                                                                                                                                                                                                                                          |
| `cache-errors-detected`        | L2 has accumulated I/O / corruption failures.                 | `l2.writeFailures + corruptedEntries + metadataParseFailures > 0`                                                                                                                                                                                                   |
| `unvalidated-external-dataset` | External dataset, no TTL configured — entries may stay stale. | `health.unvalidatedExternalDataset === true`                                                                                                                                                                                                                        |
| `provider-missing`             | Telemetry says cache is enabled but no provider is attached.  | classifier/provider contradiction                                                                                                                                                                                                                                   |

Several badges may coexist — for example, a Luxar dataset on a near-full
browser disk can show `cache-enabled` + `quota-constrained` simultaneously.
The cache tab also shows a dedicated **Cache Health** section with the
current validation mode and last-validated timestamp, and an inline
**Errors** card on the L2 section when any of the four OPFS error
counters is non-zero.

## Privacy / local persistence

The L2 OPFS layer **persists dataset bytes locally in the browser** for
this origin. Cleared by:

- `__luxarDebug.cache.clearL2()` from the JS console
- The cache tab's **Clear L2** button (with confirmation)
- The user's browser data-clearing UI (origin-wide)
- `?clearCache` URL param at the next page load
- A content-hash mismatch (Luxar datasets) or TTL expiry (external
  datasets)

OPFS storage is sandboxed per origin. Cross-origin pages cannot read
this cache. Private/incognito browser windows typically expose a
reduced-quota OPFS that wipes on tab close — the cache degrades to L1

- network with no persistence.

## OPFS availability and quota

OPFS is available in modern Chrome/Edge/Safari/Firefox, but WebKit has no
main-thread `FileSystemFileHandle.createWritable()`: Safari and the native
WKWebView launcher therefore fail the init write probe and run L1-only. See the
`opfs-unavailable` row under [Cache Status Badges](#cache-status-badges). Older
browsers also fall back to L1-only (no L2 persistence). The cache tab surfaces:

- `cache-enabled`: caching is operational.
- `no-cache`: `?noCache` URL flag set.
- `disabled-config`: app config disabled the cache.
- `quota-constrained`: at least one write was skipped due to quota.
- `cache-errors-detected`: writeFailures / corruptedEntries /
  metadataParseFailures > 0.
- `unvalidated-external-dataset`: external dataset, no TTL configured.
- `provider-missing`: telemetry says enabled but no provider wired
  (scene transition mid-flight or wiring bug).

## L0 Decompressed Chunk Cache

The L0 cache is the **fastest cache layer**, storing already-decoded TypedArrays (Float32Array, Uint8Array, etc.) to eliminate Blosc decompression overhead.

### Why L0 Matters

Without L0, even an L1 cache hit requires ~2ms for Blosc decompression. For a typical view update accessing 4 attributes (positions, colors, radii, sharpness):

- **Without L0**: ~8ms decompression overhead per view update
- **With L0**: ~0.004ms (essentially zero)

This makes the difference between 120 FPS and 60 FPS during navigation.

### Usage

L0 caching is **enabled by default** and integrated into the scene loading pipeline:

```typescript
import { DecompressedChunkCache } from '../cache/decompressed-chunk-cache';
import { wrapWithCache } from '../cache/decompressed-chunk-cache/cached-zarr-array';

// L0 is automatically enabled when loading scenes via SceneLoader
// Manual usage (advanced):
const l0Cache = new DecompressedChunkCache({
  maxSize: 200 * 1024 * 1024, // 200MB (default)
  debug: false,
});

// Wrap zarr arrays to enable L0 caching
const cachedArray = wrapWithCache(zarrArray, l0Cache, '/points/positions');

// First getChunk(): decompress + cache in L0
const chunk1 = await cachedArray.getChunk([0, 1, 2]);

// Second getChunk(): instant from L0 (~1μs, no decompression)
const chunk2 = await cachedArray.getChunk([0, 1, 2]);
```

### API

**DecompressedChunkCache**

```typescript
const cache = new DecompressedChunkCache({
  maxSize?: number,  // Max size in bytes (default: 200MB)
  debug?: boolean,   // Enable debug logging (default: false)
});

// Get cache statistics
const stats = cache.getStats();
// { size, count, hits, misses, evictions, hitRate }

// Clear the cache
cache.clear();
```

**wrapWithCache**

```typescript
// Wrap a zarr.Array with L0 caching
const cached = wrapWithCache(array, cache, arrayPath);

// Check if array is wrapped
isCachedArray(cached); // true

// Get original unwrapped array
unwrapCachedArray(cached);
```

### Read-only chunk contract

Cached zarr chunks are **read-only** to every loader downstream of `wrapWithCache`.

The L0 cache stores the same `ArrayBufferView` it returns to the caller
whose miss decoded it, to waiters that joined that decode, and to every
later hit — no defensive clone on any path. Mutating that view in place
would corrupt the cached entry for the next caller. Loaders MUST
treat chunk data as immutable input and copy into accumulator buffers,
output `BufferAttribute` allocations, or fresh `TypedArray`s before
mutating.

In practice, viewer loaders never call `getChunk()` directly; they go
through `zarr.get()` which combines multiple chunks into a fresh result
buffer. Mutation concerns therefore only apply to lower-level code that
might wire `getChunk()` results into a render path. If you find a
loader that does mutate `chunk.data`, either copy first or — if the
mutation is unavoidable — switch the wrapped array to skip caching for
that path.

There is no miss-path clone either: the first caller gets the very
buffer L0 stores (`cloneArrayBufferView` runs only for a view into a
larger buffer, so L0 never retains bytes it does not account for). The
contract is "read-only on every path", and `l0-immutability.test.ts`
pins that a miss, a coalesced waiter and a hit share one buffer.

## Intelligent Prefetching

The cache includes an **intelligent prefetching system** that proactively loads adjacent chunks to hide network latency.

### How It Works

When a chunk is accessed from L2 (OPFS) or L3 (HTTP):

1. Parse chunk indices from the key (e.g., `positions/1.2.3` → `[1, 2, 3]`)
2. Calculate adjacent chunks (±1 in each dimension)
3. Queue them for background prefetching
4. Limit to 4 concurrent prefetches (leaves bandwidth for normal requests)

### Example

```
User requests: positions/1.2.3
             ↓
Cache prefetches (in background):
  positions/0.2.3  (dim 0: -1)
  positions/2.2.3  (dim 0: +1)
  positions/1.1.3  (dim 1: -1)
  positions/1.3.3  (dim 1: +1)
  positions/1.2.2  (dim 2: -1)
  positions/1.2.4  (dim 2: +1)
```

### Benefits

- **Reduced Latency**: Adjacent chunks pre-loaded before explicit request
- **Transparent**: No API changes required, works automatically
- **Bandwidth-Friendly**: Limited concurrency prevents congestion
- **Non-Blocking**: Fire-and-forget pattern, never blocks normal requests

### Configuration

Prefetching is **enabled by default** and configurable via ChunkPrefetcher options:

```typescript
import { ChunkPrefetcher } from '../cache/chunk-prefetcher';

const prefetcher = new ChunkPrefetcher(store, {
  maxConcurrent: 4, // Max concurrent prefetch requests
  enabled: true, // Enable/disable prefetching
  debug: false, // Enable debug logging
});

store.setPrefetcher(prefetcher);
```

For full details, see [`docs/guides/specs/CACHE_PREFETCHING_SPEC.md`](../../../../docs/guides/specs/CACHE_PREFETCHING_SPEC.md).

## API Reference

### MultiLevelCachingStore

Main class implementing zarrita's `AsyncReadable` interface.

#### Constructor

```typescript
new MultiLevelCachingStore(source: string | ChunkSource, options?: {
  l1MaxSize?: number;             // L1 size in bytes (default: 100MB)
  l2MaxSize?: number;             // L2 size in bytes (default: 2GB)
  debug?: boolean;                // Enable debug logging (default: false)
  noCache?: boolean;              // Disable both tiers, e.g. `?noCache` (default: false)
  clearCache?: boolean;           // Clear caches on init, e.g. `?clearCache` (default: false)
  opfsWriteConcurrency?: number;  // L2 write-queue concurrency (default: config.cache.opfsWriteConcurrency)
  opfsWriteQueueMax?: number;     // L2 write-queue max depth (default: config.cache.opfsWriteQueueMax)
  opfsWriteQueueMaxBytes?: number; // Max bytes retained by pending L2 writes
                                   // (default: computeOpfsWriteQueueBudgetBytes() —
                                   // a quarter of the non-cache heap remainder,
                                   // capped at 512MB. NOT derived from the L1
                                   // budget: a pending write's buffer IS the L1
                                   // entry's.)
})
```

#### Methods

**`async init(): Promise<void>`**

Initialize OPFS storage and validate cache. Must be called before first use.

**`async get(key: string, options?: { signal?: AbortSignal }): Promise<Uint8Array | undefined>`**

Get a zarr chunk with L1 → L2 → HTTP cascade. Implements zarrita's AsyncReadable
interface. Missing keys return `undefined`; exhausted network and container
failures reject so the loader records them instead of accepting fill values.
Caller cancellation and mid-session invalidation reject with `AbortError` so
zarrita cannot decode an interrupted demand read as a fill-value chunk. Disposal
aborts remain quiet.

**`getStats(): MultiLevelCacheStats`**

Get the aggregated multi-tier statistics snapshot consumed by the
data-loading monitor, debug overlay, and cache E2E suite:

```typescript
{
  l1: {
    metadataSize: number,    // Bytes in metadata segment
    chunksSize: number,      // Bytes in chunks segment
    metadataCount: number,   // Files in metadata segment
    chunksCount: number,     // Files in chunks segment
    hits: number,            // Total cache hits
    misses: number,          // Total cache misses
    evictions: number        // Total evictions
  },
  l2: {
    size: number,            // Total bytes in OPFS
    count: number,           // Total files in OPFS
    reads: number,           // Total reads from L2
    writes: number,          // Total writes to L2
    misses: number,          // L2 lookup misses
    canceledReads: number,   // Reads canceled before filesystem I/O
    activeReads: number,     // Reads holding a page-wide OPFS gate slot
    queuedReads: number      // Reads waiting for a page-wide OPFS gate slot
    // plus health counters: available, oversizedWriteSkipped,
    // quotaWriteSkipped, evictions, writeFailures,
    // corruptedEntries, metadataParseFailures, orphanedFilesRemoved
  },
  network: {
    bytesTransferred: number,   // Total bytes fetched from network (L3 only)
    requestCount: number,       // Total HTTP requests (incl. prefetch)
    bandwidth: number,          // Current bandwidth (bytes/sec, sliding window)
    totalBytesServed: number,   // Cumulative bytes delivered to demand callers
                                // across ALL tiers (L1 + L2 + network). Stays
                                // non-zero on a warm/cache-served reload where
                                // bytesTransferred is 0 — drives the monitor's
                                // "DATA LOADED" card.
    totalRequestsServed: number // Demand reads served across all tiers
  },
  demand: {
    l1Hits: number,           // User-demand L1 hits (excludes prefetch)
    l2Hits: number,           // User-demand L2 hits (excludes prefetch)
    networkRequests: number   // User-demand network requests
  },
  health: {
    validationMode: 'content-hash' | 'zattrs-hash' | 'archive-etag' | 'ttl' | 'none',
    lastValidatedAt: number | null,
    unvalidatedExternalDataset: boolean,
    opfsAvailable: boolean
  },
  clearOnInitCount: number    // Times ?clearCache fired on init
}
```

See `types.ts` (`MultiLevelCacheStats`) for the authoritative shape.

**`clearL1(): void`**

Clear L1 memory cache only (L2 persists).

**`async clearL2(): Promise<void>`**

Clear L2 OPFS cache only (L1 untouched).

**`async clearAll(): Promise<void>`**

Clear both L1 and L2 caches.

**`isEnabled(): boolean`**

Check if caching is enabled. Returns `false` when `?noCache` URL parameter is present.

**`onInvalidate(callback: () => void): void`**

Register a callback invoked when caches are invalidated (e.g., `clearAll()`, content hash mismatch). Used by the L0 DecompressedChunkCache to clear itself when L1/L2 are invalidated.

**`setPrefetcher(prefetcher: ChunkPrefetcher | null): void`**

Attach a prefetcher to enable intelligent adjacent chunk prefetching. Pass `null` to disable.

```typescript
const prefetcher = new ChunkPrefetcher(store, { maxConcurrent: 4 });
store.setPrefetcher(prefetcher); // Enable prefetching
```

**`getPrefetcher(): ChunkPrefetcher | null`**

Get the attached prefetcher instance (if any).

**`async dispose(): Promise<void>`**

Mark the store disposed (synchronously short-circuiting `getResult`),
abort the store-level `dataAbort` controller (cancelling in-flight
prefetch / demand fetches), tear down the prefetcher, cancel any
in-flight or queued validation, await L2 dispose (which flushes pending
metadata writes), and clear L1. Call when navigating away or switching
datasets.

**`async listDatasets(): Promise<Array<{...}>>`**

List all cached datasets under the `luxar/` OPFS namespace dir (an origin
that has never run the viewer resolves to `[]`):

```typescript
[
  {
    url: "https://example.com/dataset.luxar.zarr",
    hash: "8649296f56b76790",
    size: 52428800,  // bytes
    count: 1234      // number of cached chunks
  },
  ...
]
```

### ChunkPrefetcher

Intelligent prefetcher for proactive adjacent chunk loading.

#### Constructor

```typescript
new ChunkPrefetcher(store: MultiLevelCachingStore, options?: {
  maxConcurrent?: number;       // Max concurrent prefetches (default: 4)
  enabled?: boolean;            // Enable/disable (default: true)
  debug?: boolean;              // Enable debug logging (default: false)
})
```

#### Methods

**`onAccess(key: string): void`**

Called by store when a chunk is accessed from L2 or L3. Enqueues
adjacent chunks for prefetching. Called automatically - not for direct
use.

**`registerArrayBounds(arrayPath: string, shape: number[], chunks: number[]): void`**

Register array shape and chunk sizes to enable adjacency prefetch. Unregistered arrays get no adjacency prefetch. For registered arrays, negative and out-of-range neighbors and dimensions with only one chunk are skipped.

```typescript
prefetcher.registerArrayBounds('gsplats_t0023/centers', [2096, 4], [1024, 4]);
```

**`getStats(): { queued, inFlight, enabled }`**

Get prefetch queue statistics:

```typescript
{
  queued: number,        // Chunks waiting in the queue
  inFlight: number,      // Chunks currently being prefetched
  enabled: boolean       // Whether prefetching is enabled
}
```

**`dispose(): void`**

Dispose the prefetcher, clearing all internal state (seen set, parsed cache, bounds, queue) and stopping processing.

### Modules

**DecompressedChunkCache** - L0 cache for decoded zarr chunks (eliminates Blosc decompression)

**wrapWithCache** - ES6 Proxy wrapper to add L0 caching to zarr.Array

**MultiLevelCachingStore** - Main orchestrator for L1/L2 caching, implements AsyncReadable interface

**SliceCache** - "S-cache" above L0/L1/L2: per-(node, view) LRU of fully decoded per-slice geometry (skips the query + fetch + dequant + gather pipeline on slice revisits)

**computeCacheBudgets** (`heap-budget.ts`) - Heap-aware per-tier byte budgets for L0/L1/S-cache (scales up on big heaps, down on small ones)

**ChunkPrefetcher** - Intelligent adjacent chunk prefetcher (enabled by default)

**LRUCache** - Generic LRU cache with O(1) operations

**SegmentedLRUCache** - 20/80 metadata/chunks split with name-based routing

**OPFSStore** - OPFS persistence layer with LRU eviction and shallow bucketing

## L2 Storage Structure

The L2 cache uses **shallow bucketing** to distribute files across 256 directories, avoiding filesystem limits with large datasets.

### Why Bucketing?

With a 2GB cache limit and typical chunk sizes:

| Avg Chunk Size | Max Files |
| -------------- | --------- |
| 32 KB          | ~65,000   |
| 64 KB          | ~32,000   |

Storing 65,000 files in a single directory can cause performance issues. Bucketing distributes them into ~250 files per bucket.

### Structure

Everything the viewer persists in OPFS lives under ONE namespace directory,
`luxar/`, never at the origin's OPFS root (`opfs-store/opfs-root.ts`:
`OPFS_NAMESPACE_DIR` + `getLuxarOpfsRoot({ create })`). An embedder's own OPFS
usage on the same origin cannot collide with the dataset directories, and a
host wipes the viewer's whole footprint with one
`root.removeEntry('luxar', { recursive: true })`. `OPFSStore.listAll()` opens
the namespace dir with `create: false` and treats a cold origin's
`NotFoundError` as "nothing cached".

```
luxar/                       # Viewer namespace dir (OPFS_NAMESPACE_DIR)
└── zarr-cache-{url-hash}/   # One dataset dir per base URL (SHA-256)
    ├── 00/                  # Bucket directories (256 total)
    │   ├── 3f1c….cG9pbnRz…  # {hash tag}.{base64url zarr key}
    │   └── ...
    ├── 01/
    ├── ...
    ├── ff/
    ├── _cache_identity.json # Content hash the chunk files were written under
    └── _cache_meta.json     # Index + LRU metadata
```

The move under `luxar/` needed no `OPFS_ENCODING_VERSION` bump: a
pre-namespace `zarr-cache-*` directory at the OPFS root is simply never read
again.

### How It Works

1. **Hash the key**: `"points/positions/0.0.0"` → bucket `23`
2. **Base64 encode**: `"points/positions/0.0.0"` → `cG9pbnRzL3Bvc2l0aW9ucy8wLjAuMA`
3. **Tag the content hash** it is written under (`hashTag`, 16 hex chars;
   none when the dataset has no hash): `{tag}.cG9pbnRzL3Bvc2l0aW9ucy8wLjAuMA`
4. **Store**: `23/{tag}.cG9pbnRzL3Bvc2l0aW9ucy8wLjAuMA`

The tag is what keeps two tabs on one directory apart. A tab still at the old
hash of a republished dataset keeps writing after the new hash's tab cleared
the directory; its files carry the old tag, so the new-hash store never reads
them, and its reconcile deletes them rather than recovering them.

### Benefits

- **Max ~250 files per directory** instead of 65,000
- **Only 256 bucket handles** to cache (trivial memory overhead)
- **2 async calls** per file access (bucket + file) vs 1 - negligible overhead
- **Efficient clear()**: Iterates 256 directories, not 65,000 files

### L2 index persistence

The contract: **a chunk file whose write completed is served from L2 by the
next session of the same dataset, whenever the page was reloaded or killed**,
and never under a different content hash.

The index (`_cache_meta.json`) cannot carry that alone. It is saved on a
debounce (150 ms leading edge, 1 s trailing, 2 s ceiling), and a save started
during unload does not survive a navigation (measured on Chromium: the old page
stops after `getFileHandle(create)`, leaving a zero-byte index). So a reload
can always land after some chunk writes the index never recorded. What makes
those files usable anyway:

1. **The directory's identity is durable before its chunks.** The validated
   content hash goes to `_cache_identity.json`, written only when the hash
   changes, and every chunk write waits for that write. Any chunk file on disk
   therefore has a known provenance, even when no index save ever landed. With
   no index, the identity is the cached hash validation compares against, so a
   changed dataset still clears every tier.
2. **A key's file path is a function of the key and the hash**
   (`{bucket(key)}/{tag(hash)}.{base64url(key)}`). While the open-time
   reconcile (below) runs, an L2 lookup of a key the index does not list reads
   that path, and a hit is indexed (size and LRU accounted) and served. This is
   what serves a reload's first-frame reads; it stops when the reconcile ends
   (a budget-capped crawl leaves the rest for the next session rather than
   paying a lookup per L2 miss all session).
3. **The open-time reconcile** (`opfs-store/orphan-reconcile.ts`, per-session
   budgeted, in the background) re-indexes every unindexed file of the
   recovery hash's tag that still fits under `maxSize` when it merges, and
   deletes the rest (other tags included). A key written meanwhile keeps its
   live entry; one written and then evicted during the crawl is not resurrected.

Recovery is gated on the same hash that makes indexed entries trustworthy: the
identity file's hash, unless an index that did land names a different one. A
directory with neither (written before the identity file existed, or for a
dataset that never yielded a validation token) is index-only, and its
unindexed files are deleted. A zero-byte chunk file (a write cut off between
create and close) is never recovered.

## Cache Behavior

### First Visit

```
User loads dataset
└─→ HTTP fetches all chunks (~100ms each)
    └─→ Stores in L2 (OPFS, persists across sessions)
    └─→ Stores in L1 (memory, session only)
    └─→ Blosc decompresses (~2ms)
        └─→ Stores in L0 (decompressed TypedArray)
        └─→ Renders
```

### Same Session (L0 Hit)

```
User navigates/zooms to previously viewed area
└─→ L0 hit (~1μs) - instant, no decompression!
    └─→ Renders
```

### Same Session (L0 Miss, L1 Hit)

```
User navigates to new area
└─→ L0 miss
    └─→ L1 hit (~1μs)
        └─→ Blosc decompresses (~2ms)
            └─→ Stores in L0
            └─→ Renders
```

### Browser Restart

```
User returns later
└─→ L0 miss (cleared on page load)
    └─→ L1 miss (cleared on page load)
        └─→ L2 hit (~1ms) - fast!
            └─→ Promotes to L1
            └─→ Blosc decompresses (~2ms)
                └─→ Stores in L0
                └─→ Renders
```

### Dataset Updated

```
Dataset content changed (new content_hash)
└─→ Cache validation detects mismatch
    └─→ Clears L0, L1, L2 automatically
    └─→ Fetches fresh data
```

## Cache Invalidation Chain

L1/L2 invalidation is the root signal for the whole cache stack. Components that
cache derived data must subscribe with `MultiLevelCachingStore.onInvalidate()`:

```typescript
const store = new MultiLevelCachingStore(datasetUrl);
const decompressedCache = new DecompressedChunkCache();
store.onInvalidate(() => decompressedCache.clear());
```

This is required because L0 stores decoded typed arrays derived from compressed
L1/L2 bytes. When `clearAll()`, `?clearCache`, or content-hash mismatch clears
L1/L2, every L0 wrapper for that dataset must clear too; otherwise stale decoded
arrays can survive even though the compressed source cache was invalidated. New
cache layers should either register their own invalidation callback or be owned
by an object that does.

## Content Hash System

All Luxar-generated datasets include hierarchical content hashes:

**Python side** (automatic):

```python
# In LuxarZarrCompiler.finalize():
# - Computes xxhash64 for each node
# - Children hashed before parents (post-order)
# - Stored in .zattrs['content_hash']
```

**TypeScript side** (automatic):

```typescript
// On MultiLevelCachingStore.init():
// - Reads root .zattrs['content_hash']
// - Compares with cached hash
// - Clears cache if mismatch
```

**CRITICAL: Cache Validation Bypass**

The validation process MUST bypass the cache when checking for dataset changes:

```typescript
// CORRECT: Bypass cache to get true server state
// (free function in validation-queue.ts)
async function getRemoteContentHash(
  baseUrl: string,
  options: { signal?: AbortSignal; timeoutMsOverride?: number }
): Promise<RemoteValidationToken | null> {
  // Direct HTTP fetch - NO cache lookup
  const bytes = await fetchWithRetry(
    buildUrl(baseUrl, '.zattrs'),
    options,
    async ({ response, readBody }) => (response.ok ? readBody() : null)
  );
  if (!bytes) return null;
  // ... return the stamped content_hash ('content-hash' mode), or the
  // SHA-256 of the raw .zattrs bytes ('zattrs-hash' mode) when absent
}

// WRONG: Would compare cached hash against itself (always matches!)
// const attrs = await this.get('.zattrs');  // DON'T DO THIS
```

**Why Bypass Matters**: If validation used the cache, it would read the cached `.zattrs` with the OLD hash, compare it to itself, and always validate successfully (false positive). This bug was discovered when switching datasets on the same port showed stale cached data.

**External datasets** (non-Luxar):

- A directory store without `content_hash` is validated in `zattrs-hash` mode:
  the SHA-256 of its raw root metadata document (`.zattrs` or `zarr.json`)
  becomes an implicit token, prefixed `zattrs:`. The cache auto-invalidates
  when that document changes. Zip archives use `archive-etag` instead.
- If the root document is unreachable and no hash was cached, validation falls
  back to `ttl` or `none` mode. See [Cache health and validation modes](#cache-health-and-validation-modes).

## Performance

### Expected Improvements

| Metric                   | HTTP Only        | With Cache                  |
| ------------------------ | ---------------- | --------------------------- |
| First load               | 100ms/chunk      | 100ms/chunk (same)          |
| Reload (same session)    | 100ms/chunk      | 1μs/chunk (100,000x faster) |
| Reload (new session)     | 100ms/chunk      | 1ms/chunk (100x faster)     |
| Offline viewing          | Impossible       | Fully functional            |
| Bandwidth (10MB dataset) | 10MB every visit | 10MB once                   |

### Memory Budget

The three in-memory tiers (L0 + L1 + S-cache) are sized **two-sidedly from the
device heap** by `heap-budget.ts::computeCacheBudgets` — up on a large heap so a
fits-in-RAM timelapse stays fully resident, down on a small heap to stay within
`dataLoading.memory.targetHeapUsage`. The config `l0MaxSizeMB` (200) and
`l1MaxSizeMB` (100) are L0/L1 ceilings; `sliceCacheMaxSizeMB` (128) is the
S-cache fallback and the cap on its pool-relative floor, not its ceiling. The
fixed fallback applies when no heap, explicit pool, or device-class pool is
available. L2 (OPFS/disk, 2GB) is fixed and not heap-sized.

**Typical desktop session** (4 GB heap): L0 up to ~200MB + L1 up to ~100MB +
S-cache the residual (capped at `SLICE_CAP_BYTES`, 2 GiB today), all
demand-filled — so actual footprint tracks the working set, not the ceilings.
On a small mobile heap the same tiers shrink proportionally to avoid OOM.

## Browser Support

**OPFS Available**:

- ✅ Chrome 86+ (October 2020)
- ✅ Firefox 111+ (March 2023)
- ⚠️ Safari 15.2+ exposes OPFS, but WebKit lacks main-thread
  `FileSystemFileHandle.createWritable()`; the init write probe disables L2 and
  raises the [`opfs-unavailable` badge](#cache-status-badges)

**Graceful Degradation**:

- Older browsers → L1-only mode (still faster than no cache)
- OPFS quota exceeded → Skip L2, use L1 + HTTP
- Network offline → Use cached data if available

## Debug API

When `?debug` URL parameter is present, cache management available at:

```typescript
await window.__luxarDebug.cache.getStats(); // Get L0 + L1 + L2 statistics
await window.__luxarDebug.cache.listDatasets(); // List all cached datasets
window.__luxarDebug.cache.clearL0(); // Clear L0 decompressed cache only
window.__luxarDebug.cache.clearL1(); // Clear L1 memory cache only
await window.__luxarDebug.cache.clearL2(); // Clear L2 OPFS cache only
await window.__luxarDebug.cache.clearAll(); // Clear L0 + L1 + L2
```

L0 cache statistics are also surfaced by the Data Monitor's Cache tab
(press `M` to cycle the data monitor; `?cacheStats` auto-opens it
expanded on the Cache tab).

Example usage:

```typescript
// Check cache usage (L1/L2)
const stats = await window.__luxarDebug.cache.getStats();
console.log('L1 Memory:', stats.l1.metadataCount, 'metadata,', stats.l1.chunksCount, 'chunks');
console.log('L2 OPFS:', stats.l2.size, 'bytes,', stats.l2.count, 'files');

// List all cached datasets
const datasets = await window.__luxarDebug.cache.listDatasets();
console.log('Cached datasets:', datasets);

// Clear all caches during development
await window.__luxarDebug.cache.clearAll();
```

## File Layout

- `chunk-source.ts` — the byte-source port the caching store reads through
  (`ChunkSource`). Declared in this layer, not in `src/data`, so the cache never
  has to import a concrete store to read through it.
- `chunk-source/http-chunk-source.ts` — the directory-store source: one
  retrying HTTP request per chunk, owning the response lifetime.
- `chunk-source/packed-chunk-source.ts` — wraps the store's source and serves a
  packed store's small nodes (`luxar optimize --pack`) from ONE request each:
  every member key gets its own bytes sliced from the pack, so L0/L1/L2 cache
  exactly what plain reads would have. Adopts the `chunk_packs` index only for
  the root `content_hash` it was built for (`adoptChunkPacks`, data layer), and
  reads plainly when a pack is missing, fails its SHA-256, or lacks the key. Unadopted,
  it is the inner source. Each member is handed out once; unserved members (a
  rung the view never loads) are held least-recently-used within
  `maxHeldBytes` (4 MB), and a member read again after the caches evicted it,
  or one of a pack let go, refetches the pack (one request, as a plain read).
  Each caller races its own abort signal against the shared pack fetch, a
  joining caller raises that fetch's class, and the pack's bytes over the wire
  are reported once.
- `chunk-source/zip-chunk-source.ts` — the zipped-store source: members read out
  of one archive, identity from a probe on the archive, and archive-level faults
  reported as `fatal` so the store rethrows instead of rendering an empty scene.
- `root-document-prefetch.ts` — ONE load-time fetch of the dataset's root
  document (`zarr.json`, then `.zattrs`), started by bootstrap before `app.init`
  so it overlaps renderer init, claimed by `setupCaches`, and shared by cache
  validation (`probeIdentityToken`, still fail-fast on `validationTimeoutMs`)
  and the store open (the root read) through `SharedRootDocumentSource` — one
  root request per cold load instead of two. Its `ETag` seeds the identity
  watchdog's first `If-None-Match`. Plain http(s) directory stores only
  (zipped / presigned URLs read as before).
- `multi-level-caching-store.ts` — L1+L2 facade implementing zarrita's
  `AsyncReadable` with validation, prefetcher hookup, and disposal.
- `decompressed-chunk-cache.ts` — L0 LRU of decoded TypedArrays.
- `chunk-prefetcher.ts` — Background prefetcher for adjacent chunks
  with per-array bounds registration and one bounded (`MAX_QUEUED`, 64),
  newest-first queue: the latest access's neighbours are served first and
  the oldest entries dropped first.
- `slice-cache.ts` — `SliceCache` (S-cache): per-(node, view) byte-budget
  LRU of decoded per-slice geometry, keyed by node path +
  slice/tolerance/displayDims signature; sits above the chunk caches so
  slice revisits skip everything but the nD→3D projection — and that too
  when the entry carries a matching stage output (`getStage` / `setStage`).
- `slice-cache-origin.ts` — WeakMap stamps tying a loaded-data object to the
  S-cache entry it was restored from, plus the stage-output lookup/store
  helpers the data processors call.
- `heap-budget.ts` — `computeCacheBudgets`: derives L0/L1/S-cache byte
  ceilings from the device heap (`performance.memory`, Chrome-only;
  fixed config sizes elsewhere).
- `lru-cache.ts` — Generic LRU with O(1) get/set/delete.
- `residency-probe.ts` — `ResidencyProbe` interface + `ResidencyAccumulator`:
  a per-load sink the L0 proxy reports chunk hit/miss outcomes to, so a
  loader can tell whether a load was served entirely from cache (used by
  the progressive loaders to decide whether to refine the next LOD in the
  same frame).
- `types.ts` — Shared cache types (`CacheStats`, `MultiLevelCacheStats`,
  `CacheValidationMode`, `OPFSMetadata`, `OPFS_ENCODING_VERSION`, etc.).
- `decompressed-chunk-cache/cached-zarr-array.ts` — ES6 Proxy that
  wraps a `zarr.Array` with the L0 cache (`wrapWithCache`,
  `isCachedArray`, `unwrapCachedArray`).
- `multi-level-caching-store/` — internals split into
  `segmented-lru-cache.ts` (L1), `opfs-store.ts` + `opfs-store/`
  (L2 persistence, bucketing, metadata), `fetch-retry.ts` (HTTP
  retry/abort helpers, URL hashing), `bandwidth-window.ts` (sliding
  window), and `validation-queue.ts` (per-dataset validation
  serialization).

## Related Packages

- **Scene Loader** (`../data/scene-loader.ts`) - Uses MultiLevelCachingStore transparently
- **Configuration** (`../config/index.ts`) - Cache configuration options
