Skip to content

Zarr coverage, verification and unit signals - #278

Merged
neuromechanist merged 21 commits into
stagingfrom
feature/zarr-coverage-v3
Sep 2, 2026
Merged

Zarr coverage, verification and unit signals#278
neuromechanist merged 21 commits into
stagingfrom
feature/zarr-coverage-v3

Conversation

@neuromechanist

@neuromechanist neuromechanist commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Website half of nemarOrg/nemar-cli#1181 (zarr serving next phase) and nemarOrg/nemar-cli#1197 (per-dataset coverage). Folds in #276.

  • Parsing: parseZarrIndex now returns a discriminated { format_version: 1 | 3 } union. v3 adds discovered_count, pending[], failure detail, per-store units_report/channels_tsv_read_error, and top-level provenance (doi/license/citation/hed_version). Every existing consumer keeps working unchanged against both a v1 and a v3 index; unknown fields are ignored; malformed documents still return null. A pure zarrCoverage(index) computes { viewable, failed, pending, discovered, byFailureCode, byPendingReason, unknownPending }.
  • Dataset page coverage panel: one compact block just above the file tree — "N of M recordings viewable" (M = discovered_count on v3; on v1, M = stores + failures with a note that pending isn't reported by that index version), then failures grouped by code with the viewer-safe reason and a detail disclosure, then pending grouped by reason with attempt counts and a last_error disclosure. Populated client-side from the tree's existing index.json fetch — no new API call. Each recording links to its row in the file tree via a new bidsRowId anchor; the link click auto-expands collapsed ancestor directories (including revealing further "Show next N" chunks, now yielding a frame between each) before scrolling the row into view.
  • Search + cards: has_zarr / has_zarr_verified checkboxes in the FilterSidebar, round-tripped through the URL and sent server-side like has_hed (production ignores the unrecognized params harmlessly until the backend release catches up — documented at the call site); disabled while a search query is active, since the hybrid search endpoint can't enforce them yet. A zarr_verify_status badge (verified = positive/green, failed = "Zarr fidelity issue" in warning/amber, unverifiable = neutral) on DatasetCard.astro and the dataset page header; null renders nothing.
  • Viewer unit notice: when the open recording's store has units_report.kept_importer_unit > 0, or channels_tsv_read_error is set, one line renders under the viewer explaining the sidecar unit couldn't be adopted, with the affected channel count. Absent for v1 stores (no units_report at all).
  • Reader cleanup (Zarr follow-ups from nemar-cli epic #1181: stale probe comment in store.ts, staging notes for zarr-test re-conversions #276): fixed the stale "no level count attribute, so we probe" comment in store.ts (probing has been the legacy fallback since declaredViewLevels started preferring the producer-declared shape); an empty declared view_levels array is now treated as "declared, zero levels" instead of "not declared," so a very short recording no longer triggers a full probe batch. Verified against a reverted copy of the fix that the new regression test actually fails on the old behavior.
  • AGENTS.md (Zarr follow-ups from nemar-cli epic #1181: stale probe comment in store.ts, staging notes for zarr-test re-conversions #276): staging section now states zarr-test.nemar.org carries real nightly re-conversions of the exemplar fleet (not copies of prod's stores) and can exercise a producer change before it reaches production.

Review response

Two review passes landed after the initial PR (code/tests review, then a silent-failure-focused delta). All 16 items addressed; decisions below.

Must fix

  1. Declared-levels fast path silently dropped siblings on one bad level. discoverViewLevels's declared branch used Promise.all, so one rejected level (403, transient 5xx) discarded every level that fetched. Fixed to mirror the probing branch: Promise.allSettled, keep fulfilled levels, throw ViewLevelDiscoveryError with the partial list on a non-404 failure. Also corrected openViewLevel's return type (never actually resolved null) so both call sites lost their vestigial null-check. New test verified to fail against the pre-fix code first.
  2. Pending 4D/BTi directory recording had no bidsRowId. annotateZarrRows only recognized state.paths/state.failureReasons; added zarrPendingPaths(index) and a pendingPaths set to the per-section state so the coverage panel's jump link resolves for a still-converting directory recording too.
  3. Badge semantics. Relabeled failed as "Zarr fidelity issue" (the sweep ran and found a mismatch) and added two new Tag kinds, positive/warning, reusing the existing --color-success/--color-warning palette tokens (documented in the Tag color bible, .rules/design-language.md) rather than inventing new tag colors. verified → positive, failed → warning, unverifiable → neutral (no check ran at all, nothing to flag).
  4. Pending last_error was parsed but never rendered. Now gets the same escaped <details>/<pre> disclosure treatment as a failure's detail.
  5. Escaping test coverage. Added a dedicated suite running a string with all five HTML-significant characters through every interpolated field (failure code/reason/detail/path, pending reason/path/last_error), asserting the escaped form appears, the raw form never does, and no bare < survives outside the renderer's own tags.
  6. Numeric declared-levels branch untested. Added tests for n_view_levels/view_level_count at a positive count and at zero (zero probes either way), mirroring the array-branch coverage.
  7. parsePending malformed-entry coverage. Added a test: a null entry is skipped, a non-string reason defaults to "unknown".
  8. unitsNoticeText overlap pinned. When kept_importer_unit > 0 and channels_tsv_read_error are both true, kept_importer_unit's exact count wins over the coarser channel-count fallback — now asserted explicitly.
  9. discovered_count trusted verbatim. A too-small value could under-report the panel total, a raw 0 alongside nonempty arrays hid the whole panel (?? doesn't fall through on 0), and a negative value rendered as-is. discovered_count is now always stores.length + failures.length + pending.length; a disagreeing raw value logs one warning naming both numbers, a matching or absent one stays silent. Tests: too-small, zero-with-nonempty, negative, matching-stays-silent.
  10. Search mode silently dropped zarr filters. The hybrid search endpoint's reduced SearchResult projection carries no zarr fields and applyClientFilters doesn't read hasZarr/hasZarrVerified, so the checkboxes did nothing while searching with no indication. Added disabled={searchActive} and the same filters__note idiom the license chips use (a dedicated .flag--disabled class, distinct from the already-existing .flag--soon "not shipped yet" state). Search-side zarr filtering (extending SearchResult) is an explicit follow-up, not in this PR.
  11. bidsRowId collision. _ doubled as the escape marker for other characters but was left unescaped itself, so "a_2e" and "a." both produced "rec-a_2e". Now _ is escaped too (_5f); the one fixture-pinned expected id in bids-tree.test.ts is updated, plus a dedicated non-collision test.
  12. zarrCoverage __proto__ crash. byFailureCode/byPendingReason were plain {} literals; a failure code or pending reason literally equal to "__proto__" hits the special own-prototype accessor an ordinary object has for that key, so the init check sees a truthy Object.prototype and the next .push throws. Switched both to Object.create(null). Verified against a reverted copy that both new tests fail with that exact TypeError first.
  13. Ambiguous error label. The single .catch after fetchZarrIndex().then(...) labeled both an actual fetch/parse failure AND any exception from applying an already-successfully-fetched index (a rendering bug against known-good data) as "index fetch threw". Split into a try/catch around the apply step with its own "applying the fetched index threw" label.
  14. fetchZarrIndex silently swallowed failures. Non-2xx, a network failure, and bad JSON all collapsed into an identical silent null. Now logs before returning null — mirrors api.ts's resolveCanonical convention: a 404 stays quiet (the common "not converted yet" case), anything else non-ok logs the status, a thrown error logs itself. Tested with a stubbed globalThis.fetch (same transport-boundary pattern the existing prefetch tests already use).

Should fix

  1. declaredViewLevels conflated malformed with declared-empty. A non-empty view_levels array that filters down to zero valid entries (every element garbage) is producer data corruption, not a deliberate "zero levels" declaration — now warns and falls back to probing; a genuine [] is unaffected. Tested both.
  2. revealRecordingRow could block the main thread. Up to REVEAL_MAX_CHUNK_CLICKS (500) synthesized "Show next" clicks ran back-to-back with no yield. Added one requestAnimationFrame yield per click; the bound is unchanged. Inline page script, so no unit test is possible for this one.

Delta re-review (after the above 16, all 12 commits verified)

  1. New positive/warning Tag kinds failed dark-mode contrast. --color-success/--color-warning (src/styles/tokens.css) were defined only in the light :root block; Tag.astro uses them as text color for the badge, so "Zarr verified"/"Zarr fidelity issue" rendered at roughly 3.7:1 on the dark background. Added dark-lift overrides in both dark blocks (prefers-color-scheme and [data-theme="dark"]), following the --color-maintenance pattern in the same file, reusing the exact hexes already dark-lifted on --license-public/--license-sharealike (#4ade80/#fbbf24) since the light values are identical. This also fixes the same pre-existing gap in DoiBadge.astro, BidsTree.astro, and UploadProgress.astro, which already consumed these tokens as text before this PR touched tokens.css.

Screenshots / manual verification

Verified live against bun run dev with PUBLIC_ZARR_BASE_URL pointed at a same-origin static fixture (a schema-valid v3 index.json, served for the real on008083 dataset so the rest of the page — landing/metadata/tree — came from production):

  • Coverage panel: "2 of 7 recordings viewable", "2 recordings failed" (grouped by not_continuous / retry_exhausted, with a working Detail disclosure showing the sanitized exception text), "3 recordings pending" (grouped by infra_failure / memory_budget / not_attempted, each with its attempt count).
  • Jump-to-recording: clicking a failed recording's path auto-expanded three ancestor directory levels (sub-011/ses-01/eeg/) it had never rendered before, then scrolled the target row into view and updated the URL hash — confirmed via the real on008083 file tree.
  • FilterSidebar: has_zarr / has_zarr_verified checkboxes present, submit via Apply round-trips through ?has_zarr=1&has_zarr_verified=1, confirmed via the rendered checked attribute on reload; confirmed disabled + the new note appear only when ?q= is set.
  • No console errors on either page.

Not independently verified live (both from before the review passes, still true): the units notice under a mounted viewer (needs a real dataset with a real v3-format S3 store — production still serves v1 everywhere today) and the zarr_verify_status badge (no real dataset has a non-null verdict yet, since the fidelity sweep hasn't shipped to production). Both paths are covered by real unit tests instead (unitsNoticeText, isZarrVerifyStatus, and the conditional-render guards in DatasetCard.astro / the dataset page). Also not independently verified live: item 16's requestAnimationFrame yield (inline script, no unit test possible, and exercising 500 synthesized clicks against a real huge dataset wasn't practical here) — verified only by reading the change against the reveal loop's structure.

Test plan

  • bun run lint — clean
  • bun run typecheck — 0 errors
  • bun run test — 1486 tests / 61 files passing, including:
    • src/lib/zarr-index.test.ts (51 tests) — v1/v3 discrimination, zarrCoverage on both the real on008083 v1 fixture and a schema-valid v3 fixture with pending, unitsNoticeText, discovered_count validation, __proto__ safety, fetchZarrIndex logging
    • src/lib/render-zarr-coverage.test.ts (22 tests) — the coverage panel's HTML end-to-end against both fixtures, plus the escaping suite
    • src/lib/eeg-viewer/store.test.ts (56 tests) — declared-levels partial-failure degradation, numeric branch, malformed-vs-empty array, all verified to fail against pre-fix code first
    • src/lib/bids-tree.test.ts (19 tests) — bidsRowId, including the injectivity fix
    • src/lib/render-dir-listing.test.ts (30 tests) — recording-row anchor ids
    • src/lib/filters.test.ts (56 tests) / src/lib/tags.test.ts (26 tests) — has_zarr/has_zarr_verified round trip + API query, badge label/kind mapping
  • bun run build — Cloudflare adapter build succeeds
  • Manual browser verification against bun run dev with a local same-origin v3 fixture (see above)
  • Verify on test.nemar.org once staging serves format_version 3 (the exemplar fleet re-converts through engine 3 nightly, per the AGENTS.md update in this PR)

Closes #277
Closes #276
Part of nemarOrg/nemar-cli#1181

parseZarrIndex now returns a {format_version: 1 | 3} discriminated union;
v3 adds discovered_count, pending[], failure detail, and per-store
units_report/channels_tsv_read_error while every existing consumer keeps
working unchanged. Adds the pure zarrCoverage() and unitsNoticeText()
helpers for the dataset-page coverage panel and viewer notice (website#277).

Fixtures: a real production v1 index (on008083, 2 stores/36 failures) and
a schema-valid v3 sample with pending, captured/built against
epic-zarr-serving's zarr-index.schema.json.
The comment above discoverViewLevels described the probe as the only
path; declaredViewLevels has preferred the producer-declared shape for
a while, and biosigio 1.2.6+ writes it on every group, so probing is now
the legacy fallback.

declaredViewLevels also returned null for an explicit but EMPTY
view_levels array, which reads as "not declared" and sends a very short
recording through a full probe batch to discover the same zero the
attrs already stated. An empty (or zero-count) declaration is now kept
as a real answer: zero levels, no probe. Verified against a reverted
copy of the fix that the new tests fail on the old behavior (website#276).
Biome flags the ??= grouping idiom; rewrite as explicit if-checks.
Also re-formats the test file additions to match biome's style.
FilterSidebar gains has_zarr and has_zarr_verified checkboxes,
round-tripped through the URL and sent server-side like has_hed. Sent
even though production's /datasets doesn't understand them yet (an
unrecognized query param is ignored, not an error) -- documented at
the call site in filters.ts.

Dataset.zarr_verify_status drives a small badge (verified/failed/
unverifiable, one-sentence tooltip) on DatasetCard.astro and the
dataset page header; null renders nothing.
Adds bidsRowId(path), a deterministic DOM-safe id keyed by BIDS path.
render-dir-listing.ts stamps it on file and directory-recording rows;
the client-side upgradeDirRecordingRow path (BTi dirs recognized only
after the Zarr index resolves) stamps the same id for parity.

Groundwork for the coverage panel's "link each recording to its row"
(website#277 decision 2) landing next.
Pure renderer over zarrCoverage(): "N of M recordings viewable" (M =
discovered_count on v3, stores+failures on v1 with a note that pending
isn't reported by that index version), failures grouped by code with
the viewer-safe reason and a detail disclosure, pending grouped by
reason with attempt counts. Each recording links to its BIDS row via
bidsRowId. Returns "" when the dataset has no Zarr data at all.

Tested against both real fixtures end-to-end (not just zarrCoverage in
isolation) plus edge cases (empty index, full coverage, an unrecognized
pending reason).
Coverage panel: an SSR placeholder slot sits just above the BIDS tree,
populated by renderZarrCoveragePanel() inside the tree's existing
index.json fetch callback -- no new request. Each failed/pending
recording's link resolves via bidsRowId; a click delegate walks the
path's ancestor directories (opening each <details>, revealing further
"Show next" chunks as needed) before scrolling the row into view and
flashing it, since a plain #anchor only works when the row already
happens to be rendered.

Units notice: applyUnitsNotice appends unitsNoticeText()'s one-line
notice under a just-mounted viewer, called from all three
mountEegViewer success paths (inline open, dialog open, in-dialog
navigate) with the current recording's ZarrIndexStore.

Styling for .zcov, .tree__row--flash, and .eegv-units-notice added to
BidsTree.astro's existing is:global block (dynamically-rendered
tree/viewer markup lives there, not in scoped component styles).

Verified: bun run lint, bun run typecheck, bun run test (1456 tests),
and bun run build all pass.
Documents the --test Hallu instance (nemar-cli#1180) so contributors
know zarr-test can exercise a producer change (chunk geometry, index
v3 fields) against a real re-conversion of the exemplar fleet before
it reaches production, not just serve bytes copied from prod.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 2, 2026

Copy link
Copy Markdown

Deploying nemar-website with  Cloudflare Pages  Cloudflare Pages

Latest commit: b2f1676
Status: ✅  Deploy successful!
Preview URL: https://79b837bc.nemar-website.pages.dev
Branch Preview URL: https://feature-zarr-coverage-v3.nemar-website.pages.dev

View logs

The declared branch used Promise.all, so one rejected level (403,
transient 5xx, a declared-but-missing level) discarded every level
that fetched successfully -- unlike the sibling probing branch, which
already tolerated this. Mirrors it: Promise.allSettled, keep fulfilled
levels, throw ViewLevelDiscoveryError with the partial list on a
non-404 failure so the caller flags degradation the same way. A clean
404 on a declared level is dropped silently, matching the probing
branch's "ended" case.

openViewLevel's return type never actually included null (it either
resolves a ViewLevel or its openNode() call rejects), so the type is
now Promise<ViewLevel> and the vestigial null-check filter is gone
from both call sites, not just the declared one.

declaredViewLevels also now distinguishes a genuinely empty
`view_levels: []` (declared zero levels) from a non-empty array that
filters down to zero valid entries (malformed producer data): the
latter warns and falls back to probing instead of being read as a
deliberate zero.

Adds regression tests for all three: the declared-branch partial
failure (verified to fail against the pre-fix code before restoring
the fix), the numeric n_view_levels/view_level_count branch at a
positive count and at zero, and the malformed-vs-genuinely-empty
array distinction.
annotateZarrRows only checked state.paths (viewable) and
state.failureReasons (failed) when deciding whether an ordinary-looking
<details> directory is actually a recording to upgrade into a
signal-viewer row. A pending 4D/BTi directory recording (no
name-derived extension, recognized only via the zarr index) fell
through: it never got upgraded, so it never got a bidsRowId, and the
coverage panel's jump link for it resolved to nothing.

Adds zarrPendingPaths(index) (mirrors zarrAvailablePaths /
zarrFailureReasonByPath's shape) and threads a pendingPaths set through
the per-section zarr state so the same recognition check covers all
three buckets.
"failed" means the sweep RAN and found a real channel-count mismatch;
"unverifiable" means no check could run at all (private dataset, infra
error). Both rendered as identical neutral "Zarr unverified"/"Zarr
unverifiable" tags, which read as the same thing.

Relabels failed as "Zarr fidelity issue" and adds two new Tag kinds,
positive and warning, reusing the existing --color-success /
--color-warning palette tokens the StatusBadge-family components
already use for pass/fail states (not new tag colors). verified now
renders positive (green), failed renders warning (amber), unverifiable
stays neutral -- it isn't a flagged issue, there is nothing to warn
about. Documented in the Tag color bible (.rules/design-language.md).
ZarrIndexPending.last_error was parsed but never rendered -- a pending
entry gave no way to see WHY it was stuck, unlike a failure's detail.
Adds the same escaped <details>/<pre> disclosure treatment, labeled
"Last error", shown only when the entry actually carries one.

Adds a dedicated escaping test suite: a single string carrying all
five HTML-significant characters run through every field the renderer
interpolates raw text into (failure code/reason/detail/path, pending
reason/path/last_error), asserting the escaped form appears and the
raw form never does -- including inside the data-jump-path attribute,
and a structural check that no bare '<' survives outside the
renderer's own tags.
…rlap

parsePending: a null entry is skipped and a non-string reason defaults
to "unknown" -- was implemented but untested.

unitsNoticeText: pins which branch wins when kept_importer_unit > 0
and channels_tsv_read_error are both true (a real case -- the sidecar
was read, some channels kept the importer's unit, but it also could
not be read for a different reason). kept_importer_unit's exact count
wins over the coarser channels_tsv_read_error fallback.
A present discovered_count was taken as-is: a too-small value could
render "5 of 3 recordings viewable", a raw 0 alongside nonempty
stores/failures/pending hid the whole panel (?? does not fall through
on 0), and a negative value rendered as-is.

discovered_count is now always stores.length + failures.length +
pending.length -- the producer's own documented invariant, enforced
rather than trusted. A present value that disagrees logs one warning
naming both numbers; a matching or absent value stays silent.
The hybrid search endpoint's reduced SearchResult projection carries
no zarr fields, and applyClientFilters never reads hasZarr/
hasZarrVerified, so checking either box while a search query is
active silently did nothing -- the checkboxes stayed enabled with no
indication they weren't working, unlike license/density/electrode-
system, which already disable themselves in search mode.

Adds disabled={searchActive} to both checkboxes and the same
filters__note idiom the license chips use. A dedicated .flag--disabled
class (not the existing .flag--soon, which means "not shipped yet")
keeps the "temporarily unavailable while searching" reading distinct
from "phase 3, not built". Search-side zarr filtering (extending
SearchResult) is a follow-up issue, not in scope here.
…core

'_' doubled as the escape marker for every other disallowed character
('.' -> '_2e', '/' -> '_2f', ...) but was itself left unescaped, so
"a_2e" (literal underscore + "2e") and "a." (escaped) both produced
"rec-a_2e". Escaping '_' too (-> '_5f') means '_' never appears in the
output except as part of an escape sequence, which makes the mapping
injective again.
byFailureCode/byPendingReason were plain {} literals indexed by
producer-supplied strings (a failure code, a pending reason). A key
literally equal to "__proto__" hits the special accessor an ordinary
object has for that name: the init check reads a truthy
Object.prototype instead of undefined, skips allocating an array, and
the next .push throws. Verified against a reverted copy that both new
tests actually fail with that TypeError before restoring the fix.

Switches both accumulators to Object.create(null), which has no such
accessor, so "__proto__" behaves like any other string key. Object.
entries/keys/values (what render-zarr-coverage.ts already calls) work
unchanged on a null-prototype object.
The single .catch after fetchZarrIndex().then(...) caught both an
actual fetch/parse failure AND any exception thrown while applying an
already-successfully-fetched index (annotateZarrRows,
renderZarrCoveragePanel, ...) under the same "index fetch threw"
console message -- a rendering bug against known-good data pointed a
developer at the wrong stage.

Wraps the apply step in its own try/catch with a distinct
"applying the fetched index threw" label (console.error, since it is
a bug against data that already parsed fine, not an expected network
hiccup); the outer .catch keeps its original label and warn level for
the fetch/parse stage.
Non-2xx, a network failure, and a malformed JSON body all collapsed
into an identical silent null, indistinguishable from the (also-null)
happy path a caller can't tell apart from an outage. Mirrors api.ts's
resolveCanonical convention: a 404 stays quiet (the common "not
converted yet" case), anything else non-ok logs the status, and a
thrown error (network failure or bad JSON) logs the caught error --
all before returning null, never after.

Tested with a stubbed globalThis.fetch (same transport-boundary
pattern the existing prefetchZarrStoreMetadata tests already use):
404 silent, 503 warns with status, a rejected fetch warns with the
error, invalid JSON warns, and a successful response stays silent.
revealRecordingRow's top-level chunk-reveal loop could synthesize up
to REVEAL_MAX_CHUNK_CLICKS (500) .click() calls back-to-back with no
yield, each triggering a DOM insert + re-annotate pass -- on a
pathological dataset that blocks the main thread solid instead of
letting the browser paint between chunks. Adds one requestAnimationFrame
yield per click; REVEAL_MAX_CHUNK_CLICKS is unchanged.

Inline page script, so no unit test is possible for this one -- noted
in the PR body.
Both were defined only in the light :root block; Tag.astro's new
positive/warning kinds (PR #278) use them as text color, so "Zarr
verified" and "Zarr fidelity issue" rendered at roughly 3.7:1 on the
dark background. Adds dark overrides in both dark blocks
(prefers-color-scheme and [data-theme="dark"]), following the
--color-maintenance pattern in the same file.

Reuses the exact hexes already dark-lifted on --license-public /
--license-sharealike (#4ade80 / #fbbf24) rather than inventing new
lift pairs -- the light values are identical (#15803d / #b45309), so
these are the same color under two names, not two different colors
that happen to coincide.

This also fixes the same pre-existing contrast gap in DoiBadge.astro,
BidsTree.astro, and UploadProgress.astro, which already consumed
--color-success/--color-warning as text before this PR touched
tokens.css at all.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant