Repository navigation
pins: icechunk snapshot ids and content fingerprints for directory stores - #48
Merged
Merged
Conversation
`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
5 of 6 tasks
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The finding
ddrs planfingerprinted a directory data source by hashing the sorted names ofits top-level entries. Every icechunk repository has the same top-level
entries (
chunks,manifests,overwritten,repo,snapshots,transactions), so the two committed Juniata storesboth hashed to
blake3:b30974f8d1140506e419095f758f2e9ac0a22a6a4535e39d69d01eea6383c5b7. Twodifferent 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
Icechunk repositories fingerprint as their
mainbranch snapshot id.src/cli/fingerprint.rs::compute_fpdetects an icechunk repo and returnsfp = "icechunk:<snapshot id>"via the newsrc/data/store/icechunk.rs::main_branch_snapshot(alookup_branch("main"),no
Session, no zarrStore). The two Juniata stores now readicechunk:E0M3W6W1881H868V2KRGandicechunk:BPY4SW34FJGXWC3T806G. Becausethe fingerprint is the snapshot id, appending to a store shows up as drift
on the next
plan, which now prints onename: locked <fp> -> current <fp>line per source in both the warn path and the
--strictabort path.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 alwaysrecompute rather than short-circuiting on
(mtime, size): a directory's ownmtime 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
planwas the alternative, and the largest sources are icechunk, hence exact.data_sources.pins(optional map, source name → snapshot id) opensstreamflowandobservationsatVersionInfo::SnapshotIdinstead of thebranch 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-timeerror 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.
The resolved snapshot is recorded whether or not the source is pinned.
Fingerprintgainedsnapshot: Option<String>, written to.ddrs/sources.lockand to the run manifest assources.<name>.snapshot;dataset open logs
streamflow snapshot: <id> (pinned|main tip)next to theexisting
streamflow resolution:line. Every run from now on names the exactdata version it read. The baseline reads the pinned version too
(
src/baseline/summed_q_prime.rs), so a pinned run is scored against areference built from the same data, and
src/baseline/cache.rs::cache_keynow hashes the pins: re-pinning recomputesthe summed-Q' baseline instead of reusing the previous snapshot's.
Backward compatibility
snapshotis#[serde(default, skip_serializing_if = "Option::is_none")], soevery existing
sources.lockand runmanifest.jsonstill parses, and asnapshot-less fingerprint round-trips without emitting the key. Pinned by
tests/cli_fingerprint.rs::fingerprint_json_without_snapshot_still_parses.pins:changes nothing: every source opens at themaintip exactlyas 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_VERSIONis deliberately not bumped, because the baselinecomputation did not change). No cached baseline under
.ddrs/baselines/isinvalidated. Pinned by
cache_key's three new unit tests, including a literalmeasured before the change.
sources.lockfiles will report drift onceafter this lands, because the fingerprint format for directory stores changed.
That is expected. A
ddrs planrelocks them; until it has,ddrs run --strictaborts 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.cargo test --features fixtures --no-fail-fast(debug)layer_b_step3_mc_forward_matches_ddrandlayer_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_bundleddr_sandbox_match1 passed,gridded_bundle5 passedcargo run --release --example compare_ddr_sandboxcargo test --release --test juniata_acceptancepython3 scripts/verify_doc_paths.pyNothing under
src/routing/,src/sparse/,src/geometry.rsorsrc/nn/istouched; the change is confined to CLI fingerprinting, config validation, the
icechunk reader, and the baseline cache key.
Docs
CLAUDE.md§Data sources, and theddrs-devskill'sreferences/config.md(the
pinsblock, its validation, and the baseline-cache consequence) andreferences/testing.md(change-to-gate rows forsrc/cli/fingerprint.rsand theicechunk reader / pins).
🤖 Generated with Claude Code
https://claude.ai/code/session_01GbK4a8dyT5eNDU97CUWHYZ