Skip to content

pins: icechunk snapshot ids and content fingerprints for directory stores - #48

Merged
taddyb merged 4 commits into
masterfrom
pins-snapshot-fingerprints
Sep 17, 2026
Merged

taddyb merged 4 commits into
masterfrom
pins-snapshot-fingerprints

Conversation

@taddyb

@taddyb taddyb commented Sep 16, 2026

Copy link
Copy Markdown
Owner

The finding

ddrs plan fingerprinted a directory data source by hashing the sorted names of
its top-level entries. Every icechunk repository has the same top-level
entries (chunks, manifests, overwritten, repo, snapshots,
transactions), so the two committed Juniata stores

examples/juniata/data/juniata_qprime.ic
examples/juniata/data/juniata_obs.ic

both hashed to
blake3:b30974f8d1140506e419095f758f2e9ac0a22a6a4535e39d69d01eea6383c5b7. Two
different stores shared one fingerprint, and no content change inside any
directory source could ever be detected as drift: the lock recorded a name list,
not a version.

What this branch does

  1. Icechunk repositories fingerprint as their main branch snapshot id.
    src/cli/fingerprint.rs::compute_fp detects an icechunk repo and returns
    fp = "icechunk:<snapshot id>" via the new
    src/data/store/icechunk.rs::main_branch_snapshot (a lookup_branch("main"),
    no Session, no zarr Store). The two Juniata stores now read
    icechunk:E0M3W6W1881H868V2KRG and icechunk:BPY4SW34FJGXWC3T806G. Because
    the fingerprint is the snapshot id, appending to a store shows up as drift
    on the next plan, which now prints one name: locked <fp> -> current <fp>
    line per source in both the warn path and the --strict abort path.

  2. Any other directory store fingerprints by content shape. A recursive walk
    of relative paths and sizes, sorted, plus the bytes of the root metadata file
    (zarr.json / .zarray / .zgroup). Directory sources therefore always
    recompute rather than short-circuiting on (mtime, size): a directory's own
    mtime does not move when a chunk deep inside it does. Known limit, commented
    in the code and in the test: an in-place rewrite that preserves every file's
    length is invisible. Byte-hashing the multi-TB CONUS/global stores on every
    plan was the alternative, and the largest sources are icechunk, hence exact.

  3. data_sources.pins (optional map, source name → snapshot id) opens
    streamflow and observations at VersionInfo::SnapshotId instead of the
    branch tip, so a run is reproducible after the store is appended to. Only
    those two names are pinnable (src/config.rs::validate_pins, a load-time
    error otherwise), and a pin on a path that sniffs as the zarr-v2 global
    product is refused at open (src/data/store/mod.rs::reject_pin_on_non_icechunk)
    rather than silently ignored. A malformed or absent id errors naming both the
    store path and the id.

  4. The resolved snapshot is recorded whether or not the source is pinned.
    Fingerprint gained snapshot: Option<String>, written to
    .ddrs/sources.lock and to the run manifest as sources.<name>.snapshot;
    dataset open logs streamflow snapshot: <id> (pinned|main tip) next to the
    existing streamflow resolution: line. Every run from now on names the exact
    data version it read. The baseline reads the pinned version too
    (src/baseline/summed_q_prime.rs), so a pinned run is scored against a
    reference built from the same data, and
    src/baseline/cache.rs::cache_key now hashes the pins: re-pinning recomputes
    the summed-Q' baseline instead of reusing the previous snapshot's.

Backward compatibility

  • snapshot is #[serde(default, skip_serializing_if = "Option::is_none")], so
    every existing sources.lock and run manifest.json still parses, and a
    snapshot-less fingerprint round-trips without emitting the key. Pinned by
    tests/cli_fingerprint.rs::fingerprint_json_without_snapshot_still_parses.
  • Omitting pins: changes nothing: every source opens at the main tip exactly
    as before, and the unpinned baseline cache key is byte-identical to the one it
    has always had (the pins block is hashed only when present and non-empty, and
    BASELINE_ALGO_VERSION is deliberately not bumped, because the baseline
    computation did not change). No cached baseline under .ddrs/baselines/ is
    invalidated. Pinned by cache_key's three new unit tests, including a literal
    measured before the change.
  • One-time consequence: existing sources.lock files will report drift once
    after this lands, because the fingerprint format for directory stores changed.
    That is expected. A ddrs plan relocks them; until it has, ddrs run --strict
    aborts with exit 4, and the new per-source drift lines make that abort
    self-explanatory.

Gates

Rebased onto master 419f307 (post #46/#47) before running any of these.

Gate Result
cargo test --features fixtures --no-fail-fast (debug) 789 passed, 2 failed. The two are layer_b_step3_mc_forward_matches_ddr and layer_c_step2_gradients_match_ddr, both pre-existing on this workstation and green in CI. Nothing else failed.
cargo test --test ddr_sandbox_match --test gridded_bundle ddr_sandbox_match 1 passed, gridded_bundle 5 passed
cargo run --release --example compare_ddr_sandbox ABSOLUTE MATCH, max abs diff 1.526e-4 m³/s, max rel diff 2.41e-6
cargo test --release --test juniata_acceptance routed NSE 0.7903 / KGE 0.8810, baseline NSE 0.6947 (the seed-42 reference values exactly)
python3 scripts/verify_doc_paths.py 0 unresolved in agent context

Nothing under src/routing/, src/sparse/, src/geometry.rs or src/nn/ is
touched; the change is confined to CLI fingerprinting, config validation, the
icechunk reader, and the baseline cache key.

Docs

CLAUDE.md §Data sources, and the ddrs-dev skill's references/config.md
(the pins block, its validation, and the baseline-cache consequence) and
references/testing.md (change-to-gate rows for src/cli/fingerprint.rs and the
icechunk reader / pins).

🤖 Generated with Claude Code

https://claude.ai/code/session_01GbK4a8dyT5eNDU97CUWHYZ

taddyb and others added 4 commits September 16, 2026 09:19
`compute_fp` fingerprinted a directory store by hashing the sorted names of
its top-level entries, so any two icechunk repositories were identical to
`sources.lock`: the committed Juniata Q' and observations stores both hashed
to blake3:b30974f8... and drift between them was invisible.

Two directory cases now:

* icechunk repository (`snapshots/` + `manifests/`, or `refs/`) —
  `icechunk:<main branch snapshot id>`, via the new
  `src/data/store/icechunk.rs::main_branch_snapshot`, which opens the repo
  read-only the way `open_session` does and stops at `lookup_branch`. The
  snapshot id is a content hash by construction, so there is no walk.
* any other directory — blake3 over the recursive, sorted
  `<relative path>\n<size>\n` listing plus the bytes of the root metadata
  file (`zarr.json` / `.zarray` / `.zgroup`).

`Fingerprint` gains `snapshot: Option<String>`, serde-default and skipped
when absent, so existing locks and manifests still parse.

`reuse_if_unchanged` now short-circuits on stat for regular files only: a
directory's own mtime does not move when a chunk deep inside it does, so
directories always recompute. Cost is noted on the function.

Known limit, pinned by a test comment: the walk hashes sizes, not bytes, so
an in-place rewrite that preserves a file's length is not detected. Hashing
every byte of the CONUS and global stores on each `ddrs plan` was judged too
expensive; icechunk sources escape the tradeoff entirely.

`plan.rs` and `tests/cli_lockfile.rs` each gain one line for the new field.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbK4a8dyT5eNDU97CUWHYZ
`data_sources.pins` (optional map, source name -> icechunk snapshot id)
opens `streamflow` / `observations` at `VersionInfo::SnapshotId` instead of
the `main` branch tip, so a run is reproducible after the store is appended
to. A pin on any other source name is rejected at load
(`config.rs::validate_pins`), and a pin on a path that turns out to be the
zarr-v2 global product is refused at open
(`store/mod.rs::reject_pin_on_non_icechunk`).

Both icechunk stores gain `open_at(path, Option<&str>)` (`open` delegates
with `None`) and a `snapshot` field carrying the id they actually read —
the pin, or the `main` tip resolved via `Session::snapshot_id()`. A
malformed or absent id is a `DataError::Malformed` naming the store path
and the id. Dataset open logs `streamflow snapshot: <id> (pinned|main tip)`
beside the existing `streamflow resolution:` line, same for observations.

The fingerprint path takes the pin too (`fingerprint_path_at` /
`reuse_if_unchanged_at`), so `sources.lock` and the run manifest record the
version the run will read rather than a tip it never touches. `ddrs plan`'s
drift report now prints one `source: locked <fp> -> current <fp>` line per
drifted source, in both the warn and the `--strict` abort path.

Omitting `pins:` changes nothing except that icechunk sources now carry
their resolved snapshot id in the lock and manifest.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbK4a8dyT5eNDU97CUWHYZ
`summed_q_prime` now reads `data_sources.pins`' snapshot, so changing a pin
changes the baseline's inputs. `cache_key` did not hash the pins, so the
second run silently reused the first snapshot's cached predictions and
observations — and the baseline is what a trained model is judged against,
so a stale one invalidates the comparison the run exists to make.

The pins block is hashed only when at least one pin is present, so an
unpinned config yields the byte-identical key it did before pins existed and
none of the existing cached baselines is invalidated. `BASELINE_ALGO_VERSION`
is deliberately NOT bumped: the algorithm is unchanged (that constant means
"recompute stale caches from older binaries"), this is a new input.

Three tests in `cache.rs`'s module: the unpinned key is pinned to its
literal `43843300d30ff837` (measured before the change) so any future change
to the key's inputs fails loudly; two pin values give different keys; a
pinned and an unpinned config give different keys, while an EMPTY pins map
hashes as unpinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbK4a8dyT5eNDU97CUWHYZ
CLAUDE.md §Data sources gains a paragraph on the new fingerprint scheme
(icechunk repo -> main-branch snapshot id, other directories -> recursive
path+size walk), the `sources.<name>.snapshot` field now written to both
`sources.lock` and the run manifest, the optional `data_sources.pins` map,
and the one-off drift every existing lock will report.

The ddrs-dev skill's config.md records that the summed-Q' cache key hashes
the pins, so re-pinning recomputes the baseline instead of reusing the
previous snapshot's; testing.md gains change-to-gate rows for
`src/cli/fingerprint.rs` and the icechunk reader / pins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GbK4a8dyT5eNDU97CUWHYZ
@taddyb
taddyb merged commit 6863fe2 into master Sep 17, 2026
2 checks passed
taddyb added a commit that referenced this pull request Sep 30, 2026
Conflicts and resolutions (both features kept, neither changed):
- src/config.rs, DataSources: this branch's `reservoirs` table path and
  master's `pins` map sit side by side (reservoirs first, then pins,
  ICECHUNK_PINNABLE and DataSources::pin_for). validate_pins (master) and
  validate_reservoirs (branch) both auto-merged into their call sites.
- .claude/skills/ddrs-dev/references/config.md, data_sources: kept the
  `reservoirs` paragraph and master's `pins:` section with its example.

Auto-merged, checked by hand: the baseline cache-key test fixtures carry both
`reservoirs: None` and `pins: None`; `reservoirs` is locked through master's
fingerprint_path_at (a CSV, so a content hash; pin_for returns None);
CLAUDE.md and testing.md carry both sections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015TepsZAS2LkFC5EmvuZrz9
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