Skip to content

Conform a spool to the inventory which describes it - #882

Merged
d-chambers merged 3 commits into
devfrom
inventory-conform-phase3b
Aug 12, 2026
Merged

Conform a spool to the inventory which describes it#882
d-chambers merged 3 commits into
devfrom
inventory-conform-phase3b

Conversation

@d-chambers

@d-chambers d-chambers commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Description

Phase 3 (b) of the inventory work (#857), on top of #877: Spool.conform_to_inventory, the one eager step of the inventory workflow.

Attaching an inventory promises nothing about the spool matching it. Conforming makes it so: every row is resolved now, patches the inventory does not describe are dropped, and a patch whose span crosses a change of optical path is subdivided into one patch per epoch — so the spool can grow as well as shrink, which is why the method proposed as prune_to_inventory is called conform_to_inventory. It is metadata work; no patch data is read.

spool = dc.spool("/data/archive").attach_inventory(inventory)
raw = spool.conform_to_inventory().enrich()

Subdivision is a plan, not a dataframe rewrite. The derived-catalog machinery already models one source row feeding several outputs, so conform builds an outputs/members pair and hands it to derived_catalog(..., mode="chunk"). _patch_id stays a source identity on the members, read hints ride along through PlanResolver._load_member, and len/get_contents stay exact because the outputs are the contents rows.

Splitting is exact along the sample grid. An epoch boundary owes the sample grid nothing, so each piece opens at the first sample at or after its boundary. The obvious split — [t_min, b - step] and [b, t_max] — loses any sample falling between those two bounds, which for an arbitrary boundary is the common case rather than the edge one.

A change of acquisition raises instead of subdividing. Its two halves were recorded under different configurations, so no subdivision makes it one honest patch; on_unresolved has no say, since the inventory describes such a patch twice rather than not at all.

resolve_contexts now sits on the same epoch walk, which fixes it refusing a row that crosses an epoch bound nothing actually changes across — something Patch.enrich has always allowed, so the two no longer disagree about the same patch.

Not in scope, and unchanged: channel-level select/unselect and split_by are phase 3 (c), so conform subdivides along time only.

Review notes

The full review pipeline ran before this PR (Codex plus five Claude reviewers). Two defects it found are worth calling out, since both would have been easy to ship:

  • Three reviewers independently found that snapping a cut onto the sample grid could round an exactly-on-grid boundary a hair above its index, putting the boundary sample in the epoch before the boundary. The cause was converting both operands to float seconds with to_float before dividing — three roundings where one would do. (CoordRange._get_index divides the native types and is unaffected: measured over 200k random step/index pairs it is wrong 0 times, against ~21.5k for the to_float form. An earlier draft of this description blamed its fudge factor; that was wrong.) The ratio now only starts the search, and an exact comparison against the grid settles it either way it errs.
  • Comparing two resolutions by object identity made conform refuse patches Patch.enrich accepts: a fiber array re-registered with a new description resolves to a fresh object whose acquisition and optical path say exactly what they said before. Resolutions are now compared by what they say, over the two fields that say anything about the patch.

While verifying composition I found that chunk(time=2).chunk(time=3) duplicates samples on dev with no inventory involved — the minimal form of #871, recorded there rather than fixed here. chunk(...).conform_to_inventory() is unaffected and tested; the reverse order walks into that pre-existing defect.

Closes nothing on its own; #857 stays open for phase 3 (c).

Changelog

  • added: Spool.conform_to_inventory, which resolves a spool against an attached DASDAE inventory: patches the inventory does not describe are dropped under an on_unresolved policy, and a patch spanning a change of optical path is subdivided into one patch per epoch.
  • fixed: inventory-backed Spool.select refusing a patch whose span crosses an epoch bound across which nothing the inventory says about it changes; Patch.enrich already accepted such a patch.

Checklist

I have:

  • filled in the Changelog section above (see docs/contributing/general_guidelines.qmd).

I have (if applicable):

  • referenced the GitHub issue this PR closes.
  • documented the new feature with docstrings and/or appropriate doc page.
  • included tests. See testing guidelines.
  • added the "ready_for_review" tag once the PR is ready to be reviewed.

Add Spool.conform_to_inventory, the one eager step of the inventory
workflow: resolve every row now, drop the patches the inventory does
not describe, and subdivide a patch whose span crosses a change of
optical path into one patch per epoch.

Subdivision is a derived-catalog plan, not a dataframe rewrite, so the
existing chunk machinery does the loading and len/get_contents stay
exact. Each piece opens at the first sample at or after its boundary,
which keeps the split faithful to half-open epochs and loses no sample
to a boundary the sample grid does not share.

A patch spanning a change of acquisition raises instead: its halves
were recorded under two configurations, so no subdivision makes it one
honest patch.

resolve_contexts now sits on the same epoch walk, which fixes it
refusing a row that crosses a bound nothing changes across -- something
Patch.enrich has always allowed.
Snapping a cut onto the sample grid divided in float seconds, so an
exactly-on-grid boundary could round a hair above its index and take the
boundary sample with it into the epoch before the boundary -- the one
place it must not go. Three reviewers found it independently, and the
fudge factor CoordRange._get_index uses is not enough here: past a
million samples the error outgrows any fixed tolerance. The ratio now
only starts the search and the grid itself settles it, which is exact
either way it errs.

Comparing resolutions by identity made conform refuse patches enrich
accepts: a fiber array re-registered with a new description resolves to
a fresh object whose acquisition and optical path say exactly what they
said before. Compare what the entries say, as _resolve_context does, and
compare only the two which say anything about the patch.

Also: a row whose end precedes its start no longer raises IndexError out
of Spool.select; enrich and conform share their argument checking; and
the two tests named for a boundary that changes nothing now put one
inside the patch, where the comparison they pin actually runs.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6f13418b-9561-4140-9c53-8028aa9070a9

📥 Commits

Reviewing files that changed from the base of the PR and between 919eb03 and 6ca77f5.

📒 Files selected for processing (3)
  • dascore/proc/inventory.py
  • dascore/utils/chunk_plan.py
  • tests/test_proc/test_proc_inventory.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • dascore/utils/chunk_plan.py
  • tests/test_proc/test_proc_inventory.py
  • dascore/proc/inventory.py

📝 Walkthrough

Walkthrough

Changes

Inventory conformance

Layer / File(s) Summary
Epoch-aware inventory resolution
dascore/proc/inventory.py, tests/test_proc/test_proc_inventory.py
Inventory rows are evaluated across all covered epochs. Resolution reports optical-path cuts, acquisition conflicts, and unresolved rows.
Sample-aligned subdivision planning
dascore/utils/chunk_plan.py, tests/test_utils/test_chunk.py, tests/test_proc/test_proc_inventory.py
Cuts are snapped to sample boundaries and converted into contiguous subdivision plans.
Spool conformance and enrichment integration
dascore/core/spool.py, tests/test_proc/test_proc_inventory.py
Spool.conform_to_inventory applies unresolved-row policies, validates acquisition changes and time steps, subdivides patches, and updates enrichment state.

Possibly related PRs

Suggested labels: proc, spool

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the primary change: adding inventory conformance for a spool.
Description check ✅ Passed The description explains the feature, scope, implementation, testing, review findings, changelog, and checklist status.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch inventory-conform-phase3b

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (018a570) to head (6ca77f5).

Additional details and impacted files
@@            Coverage Diff             @@
##               dev      #882    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          177       177            
  Lines        20665     20797   +132     
==========================================
+ Hits         20665     20797   +132     
Flag Coverage Δ
network 46.33% <15.48%> (-0.19%) ⬇️
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@d-chambers

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot added proc Related to processing module spool related to Spool class labels Aug 12, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
dascore/utils/chunk_plan.py (1)

1088-1093: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

State that each row's cuts must be ascending.

_snapped_cuts removes duplicates but keeps the input order. Line 1120 computes each piece's high from the next entry in bounds, so an unsorted cuts sequence produces inverted envelopes with no error. The current caller, Spool.conform_to_inventory, passes cuts derived from np.unique-sorted epoch bounds, so the order holds today. Document the requirement, or sort inside _snapped_cuts so a future caller cannot break it silently.

♻️ Proposed contract change
     cuts
         One sequence of cut values per row, in the row's own units, each
         above that row's minimum and no greater than its maximum — a cut
-        on the maximum yields a one-sample final piece. A cut opens a new
+        on the maximum yields a one-sample final piece. Each sequence must
+        be in ascending order. A cut opens a new
         piece at the first sample at or after it, so a row with `n`
         distinct cuts becomes at most `n + 1` outputs.

Also applies to: 1117-1120

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/utils/chunk_plan.py` around lines 1088 - 1093, Document in the cuts
parameter description that each row’s cut sequence must be in ascending order,
or update _snapped_cuts to sort cuts after removing duplicates. Ensure the
bounds consumed by the piece high calculation remain ordered so unsorted input
cannot produce inverted envelopes.
dascore/proc/inventory.py (1)

328-328: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add strict= to the new zip() calls. Ruff reports B905 at three new zip() calls in this PR. Each pairs sequences whose lengths are equal by construction, so strict=True is safe and matches the neighbouring code, which already passes it (for example dascore/proc/inventory.py Line 335).

  • dascore/proc/inventory.py#L328-L328: pass strict=True to zip(starts_at, ends_at); both arrays come from np.searchsorted over the same rows.
  • dascore/proc/inventory.py#L380-L380: pass strict=True to zip(resolved, resolved[1:], boundaries); boundaries is bounds[lo:hi], which has exactly len(resolved) - 1 entries.
  • tests/test_proc/test_proc_inventory.py#L1943-L1943: pass strict=True to zip(contents["time_min"], contents["time_max"]); both columns come from the same dataframe.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/proc/inventory.py` at line 328, Update the three identified zip calls
to pass strict=True: zip(starts_at, ends_at) in dascore/proc/inventory.py lines
328-328, zip(resolved, resolved[1:], boundaries) in dascore/proc/inventory.py
lines 380-380, and zip(contents["time_min"], contents["time_max"]) in
tests/test_proc/test_proc_inventory.py lines 1943-1943.

Source: Linters/SAST tools

tests/test_proc/test_proc_inventory.py (1)

2036-2118: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider moving these tests out of TestConformBoundaryPolicy.

The class docstring states the class covers where a subdivided patch is cut and which piece each sample joins. Tests from Line 2036 to Line 2118 cover undescribed rows: NaT instants, an empty key, a relative time axis, and merged rows. TestConformMembership already covers which patches a conformed spool holds. Move these tests there, or add a separate class for unresolved inputs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_proc/test_proc_inventory.py` around lines 2036 - 2118, Move the
four undescribed-input tests—test_a_row_with_no_instants_is_undescribed,
test_an_empty_key_is_undescribed, test_a_relative_time_axis_is_undescribed, and
test_a_merged_patch_carries_one_key/test_a_merge_which_drops_the_key_is_undescribed—out
of TestConformBoundaryPolicy into TestConformMembership or a dedicated
unresolved-input test class, preserving their assertions and behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@dascore/proc/inventory.py`:
- Line 328: Update the three identified zip calls to pass strict=True:
zip(starts_at, ends_at) in dascore/proc/inventory.py lines 328-328,
zip(resolved, resolved[1:], boundaries) in dascore/proc/inventory.py lines
380-380, and zip(contents["time_min"], contents["time_max"]) in
tests/test_proc/test_proc_inventory.py lines 1943-1943.

In `@dascore/utils/chunk_plan.py`:
- Around line 1088-1093: Document in the cuts parameter description that each
row’s cut sequence must be in ascending order, or update _snapped_cuts to sort
cuts after removing duplicates. Ensure the bounds consumed by the piece high
calculation remain ordered so unsorted input cannot produce inverted envelopes.

In `@tests/test_proc/test_proc_inventory.py`:
- Around line 2036-2118: Move the four undescribed-input
tests—test_a_row_with_no_instants_is_undescribed,
test_an_empty_key_is_undescribed, test_a_relative_time_axis_is_undescribed, and
test_a_merged_patch_carries_one_key/test_a_merge_which_drops_the_key_is_undescribed—out
of TestConformBoundaryPolicy into TestConformMembership or a dedicated
unresolved-input test class, preserving their assertions and behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ed8da495-1673-4796-871b-35879566edf1

📥 Commits

Reviewing files that changed from the base of the PR and between 018a570 and 919eb03.

📒 Files selected for processing (5)
  • dascore/core/spool.py
  • dascore/proc/inventory.py
  • dascore/utils/chunk_plan.py
  • tests/test_proc/test_proc_inventory.py
  • tests/test_utils/test_chunk.py

Which cuts a row has is a set, not a sequence, but the pieces are read
off consecutive pairs -- so an unordered one would describe envelopes
running backwards rather than raise. Sort them where they are computed,
so no caller can get it wrong.

The epoch walk's zips now pair sequences of equal length explicitly. The
three-way one needed `resolved[:-1]`, not just `strict=True`: there is
one boundary between each consecutive pair, so the untrimmed sequence
was always one longer.
@d-chambers

Copy link
Copy Markdown
Contributor Author

Thanks — addressed in 6ca77f5.

1. Cut order (chunk_plan.py) — fixed, by sorting rather than documenting. Which cuts a row has is a set; the order carries no information, so a caller cannot meaningfully get it wrong and the helper should not be able to be broken by one. _snapped_cuts now returns them sorted, which is also where the deduplication already happens.

2. strict= on the new zip() calls — fixed, but the suggestion for _epoch_changes would have broken it. zip(resolved, resolved[1:], boundaries, strict=True) raises: there is one boundary between each consecutive pair, so resolved is always one longer than the other two. It now reads zip(resolved[:-1], resolved[1:], boundaries, strict=True), which is what the equal-length claim actually requires. The other two sites took strict=True as-is.

(For the record, ruff as configured here does not report B905 — uvx pre-commit run --all-files is clean before and after. The change is worth making on its own merits, since the neighbouring code already passes strict=True.)

3. Test placement — the mismatch was real, but it was the docstring, so that is what changed. Those four cases are boundary policy: they are the rows that offer no usable boundary — no instants to place one against, or no identity to look one up with — and they were written from the same checklist as the rest of the class, before the implementation. TestConformMembership is about which patches survive a conform, which is a different question from where one can be cut. The class docstring now says what the class covers.

@d-chambers
d-chambers merged commit ae85d31 into dev Aug 12, 2026
32 checks passed
@d-chambers
d-chambers deleted the inventory-conform-phase3b branch August 12, 2026 18:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

proc Related to processing module spool related to Spool class

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant