Skip to content

Select a spool on the facts an inventory states - #877

Merged
d-chambers merged 14 commits into
devfrom
inventory-select-phase3a
Aug 12, 2026
Merged

Select a spool on the facts an inventory states#877
d-chambers merged 14 commits into
devfrom
inventory-select-phase3a

Conversation

@d-chambers

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

Copy link
Copy Markdown
Contributor

Description

The first of three PRs implementing inventory-backed spool selection (#857). This one covers the acquisition-level half — whole-patch metadata — and the pieces the other two need. Selecting on the coordinates an inventory defines along the fiber (coupling, geometry, annotation groups) is PR (c), and conform_to_inventory, which owns row subdivision, is PR (b).

Selecting on what the inventory states. spool.select(gauge_length=10.0) and spool.select(**{"interrogator.model": "FI-1"}) now filter an archive whose files never recorded either. Precedence is per row: a patch which states the name is judged by the index exactly as it would be without an inventory, and only the rows leaving it unstated are resolved — once per (acquisition_key, epoch) rather than once per patch. A spool whose headers are complete never consults the inventory at all, and which rows state a name is asked of the index rather than read off a realized relation, so that spool is never realized either.

A patch the inventory does not describe, or describes twice because it straddles an epoch boundary, is simply not selected. Select is a filter, and a patch with no single answer is no more selected than one lacking the attr entirely — the one deliberate exception to loud-by-default, because warning here would make composed selects noisy on intentionally partial inventories. Subdividing the straddlers is conform_to_inventory's job.

The inventory never writes into the SQLite index. The filter rewrites the contents of a new spool, so len and get_contents stay exact, no data is read, and swapping inventories cannot leave a stale derived value behind. The one route by which inventory facts become index facts is still the ordinary round trip: enrich, write, rescan.

One selector, one meaning. evaluate_attr_predicate is the in-memory twin of the SQL build_attr_clause and is held to it: it types its values the way the index types a stored one (so a numeric range against a string attr matches nothing rather than raising, and 1 does not match a stored True), a regex searches as the index's residual does, and a glob is translated from SQLite's own semantics rather than handed to fnmatch[!x] and [^x] are each other's complement in the two, which would have made one pattern select opposite halves of a spool depending on which side answered it.

Inventory.get_names returns the names an inventory could contribute to a patch, split into attrs and coords the way Patch.enrich and select's _attrs/_coords already split them. The attrs side is read off the pydantic models, so a field added to an acquisition or interrogator is selectable without a second list to maintain, and a test pins it to INVENTORY_ATTRS — the two are one vocabulary, and a new field has to reach the readers as well. The coords side comes from the inventory itself. This is what lets a spool tell an inventory field from a misspelled attr, replacing a hedge that could not.

Spool.unselect is the complement of select, taken against select itself rather than by negating each predicate, so one keyword cannot come to mean different things in the two, and an attached inventory's names work here because they work there. Coordinates are refused: a range decides how much of each patch to keep rather than which patches, so its complement is a hole in the middle rather than a filter.

Two fixes found on the way. PatchCatalog._ordered_ids returned a fixed membership unfiltered whenever one was set, ignoring every predicate composed after it — so div[2:8].select(tag="big_gaps")[0:1] came back empty on dev. It now filters the membership while keeping its order, since an integer array may have arranged it. And Patch.enrich accepted a bare track name (coords=("coupling",)) without implementing what it means, silently filling NaN instead of the coupling type.

Performance

Attaching stays free, which is the point of it being a separate step. On one 10k-patch in-memory spool, best of 9:

no inventory inventory attached
select(tag=...) 3.0 ms 4.2 ms (10 optical paths) / 5.8 ms (250)
select(gauge_length=...), every patch states it 2.9 ms 35 ms
select(gauge_length=...), no patch states it n/a 264 ms

The last row is the feature's real cost: resolving rows the index cannot answer for realizes the spool's contents, where an index-only select stays lazy. It is linear in the spool (~25 µs/row) and pays for one resolution per epoch, not per patch. The first two rows are what an adversarial review turned up and this branch then fixed — before it, the same three cases cost 3.7×/29× and 225× their no-inventory counterparts, because every select walked the inventory twice, re-read the index's name lists three times, and realized the relation to discover the index had already answered.

Also here

  • Patch.enrich's on_missing="skip" is renamed "ignore", so every policy knob draws from one word set (raise/warn/ignore, plus "null", which is an action rather than a volume). Nothing is released, so no compatibility spelling is kept.
  • Patch.enrich and Spool.enrich document the same arguments from one source instead of describing them twice.
  • Indexing warns once when hive-style key=value path segments override attrs the files themselves state. The path winning is deliberate — renaming a directory is how a layout corrects metadata — but it is also what a mis-sorted archive looks like from the inside. The comparison is on meaning rather than spelling, so gauge_length=10 over a file stating 10.0 stays silent.

Changelog

  • added: Spool.select filters on the observing-system facts an attached inventory states (spool.select(gauge_length=10.0), spool.select(**{"interrogator.model": "FI-1"})), even when no file recorded them. A patch which states the name is judged by the index as before and only the rest are resolved, once per epoch; a patch the inventory does not describe, or describes twice, is not selected. The inventory never writes into the index, so len and get_contents stay exact and no data is read. Unlike an index-only select, this realizes the spool's contents.
  • added: Spool.unselect, the complement of selectspool.unselect(tag="bad") returns everything the matching selection would have removed. Every keyword means what it means in select. Coordinates are refused, since a range trims each patch rather than filtering the spool.
  • added: Inventory.get_names returns the names an inventory could contribute to a patch, split into attrs and coords. The attrs side is read off the models, so a field added to an acquisition or interrogator becomes selectable without a second list to maintain.
  • added: indexing warns once when hive-style key=value path segments override attrs the files themselves state, naming each attr and one path carrying it. A segment restating what a file already says is silent.
  • fixed: a spool whose membership was already fixed — by a slice, an integer array, or a previous selection — ignored predicates composed afterwards, so spool[2:8].select(tag="x")[0:1] came back empty.
  • fixed: Patch.enrich(coords=("coupling",)) and the other bare track names now give the track's identity field (the coupling type, the geometry or component name) instead of silently filling missing values.
  • changed: Patch.enrich's on_missing="skip" is renamed "ignore", so every policy argument draws from one word set. Breaking only against unreleased work on dev.

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.

Summary by CodeRabbit

  • New Features

    • Added inventory-aware patch selection and unselect for removing matching patches.
    • Expanded inventory introspection with available attribute and coordinate names.
    • Added glob, regex, range, collection, and unit-aware attribute filtering.
    • Added backend reporting for attribute units and stated values.
    • Improved inventory enrichment with track-based lookups and batched context handling.
    • Added support for on_missing="ignore" during enrichment.
  • Bug Fixes

    • Improved attribute conflict detection and ingestion warnings.
    • Preserved selection ordering and membership across composed queries.
  • Documentation

    • Documented patch unselection and enrichment parameters.

Every policy knob now draws from one word set: raise/warn/ignore, plus
'null', which is genuinely an action rather than a volume setting.
Nothing is released, so no compatibility spelling is kept.
Patch.enrich and Spool.enrich take the same arguments, and describing
them twice is how the two drift. Their parameter text moves into shared
constants which both compose, with Spool.enrich listing the forwarded
ones under Other Parameters instead of pointing at Patch.enrich.
Inventory.get_names returns them split by destination: attrs for the
observing-system facts and coords for the names taking a value per
channel. The attrs side is read off the models, so a field added to an
acquisition or interrogator is selectable without a second list to
update, and a test pins it to the reader vocabulary. The coords side
comes from the inventory itself -- its CRS axes, the tracks its paths
describe, and their annotation groups.

The one hand-written piece is the track-name to identity-field map,
which is what a bare track name means; it now also states the track
vocabulary enrichment projects and annotation groups may not shadow.
Naming an acquisition or interrogator fact now filters the spool on what
the inventory says, so an archive whose files never recorded their gauge
length can still be selected by it. Precedence is per row: a patch which
states the name is judged by the index exactly as before, and only the
rows leaving it unstated are resolved -- once per epoch rather than once
per patch. The inventory writes nothing into the index; the filter
rewrites the contents of a new spool, so len and get_contents stay exact
and no data is read.

A selector means the same thing on either side of that split: the
in-memory predicate is the twin of the SQL one, taking the same shapes
and giving unit-bearing values the same error against these unitless
columns.

Naming a coordinate the inventory defines along the fiber now says so,
replacing the hedge which could not tell an inventory field from a
misspelled attr.
Saying what a spool does not want -- one bad tag, an instrument being
serviced -- took spelling the rest of the archive as a selection. The
complement is taken against select itself rather than by negating each
predicate, so a keyword cannot come to mean one thing in one and
something else in the other, and an attached inventory's names work here
because they work there.

Coordinates are refused: a range decides how much of each patch to keep
rather than which patches, so its complement is a hole in the middle
rather than a filter.
Hive-style directories are how a layout corrects metadata, so the path
winning is deliberate. It is also what a mis-sorted archive looks like
from the inside, and the two are indistinguishable once indexed. One
warning per update names each overridden attr and a path to go look at.
Restating what a file already says overrides nothing and stays silent.
A selector now means one thing on either side of the index/inventory
split. The in-memory predicate types its values the way the index types
a stored one, so a numeric range against a string attr matches nothing
instead of raising and 1 no longer matches a stored True; a regex
searches, as the index's residual does; and a glob is translated from
SQLite's own semantics rather than handed to fnmatch, which read a
negated class as its complement.

A fixed membership no longer swallows the predicates composed on top of
it. PatchCatalog._ordered_ids returned the membership unfiltered
whenever one was set, so unselect emptied every windowed, sliced or
chained spool, and inventory-backed select kept rows whose own headers
contradicted the selector. The membership now filters, keeping its order
-- an integer array may have arranged it.

Rows with nothing to resolve with are answered rather than asserted
about: a spool whose patches carry no acquisition_key, and one whose
time axis holds lag rather than instants, describe no row and select
none, where the latter previously picked an epoch from an offset.

Also: names the accessor lists are names enrichment understands, so a
bare track name now means the field the blessed map points at, and a
field a component states as several values is not listed; an optional
collection and an Annotated union are read for what they hold; the
path-override warning compares meaning rather than spelling; and the
forwarded-argument docs render as a table.
Attaching stays free, which is the whole point of it being a separate
step. Every select on a spool carrying an inventory was walking the
inventory twice and reading the index's name lists three times, and a
name the index stated for every row was still realizing the whole
relation to discover that. The names are now read once per call, the
model introspection behind them is cached on the class rather than
redone per track item, and which rows state a name is asked of the
index, so a spool whose headers are complete never realizes anything.

Measured on a 10k-patch spool: selecting on an ordinary index name with
an inventory attached went from 3.7x the plain cost to 1.4x (29x to
1.4x against an inventory with 250 optical paths), and selecting on a
name every patch states went from 225x to 11x.

A bare None or ... selector also stays the no-op it is everywhere else
rather than failing to coerce.
A row with no instant of its own no longer inherits a context: resolving
at NaT holds every epoch effective, which is the whole inventory
answering rather than the one entry describing the row.

The in-memory attr predicate is held closer to the index's. A range now
goes through the same reading, so one with no usable bounds is the error
it is there rather than a selector which quietly keeps every row; a
unit-bearing selector converts to whatever units the index records for
the attr, so attaching an inventory can no longer turn a working
selector into a UnitError; the inventory's units are rendered the way a
patch states them, since the same fact stored as a quantity and as a
string would never match; a wildcard crosses a newline; and a reversed
character range matches its low endpoint, which is what SQLite does with
it -- verified against SQLite over 27000 fuzzed patterns.

The relation and the id list are aligned by patch id rather than by
position. They are realized by different routes and can present
different rows, which an assert turned into a crash and -O turned into
a silent misalignment.

unselect refuses a selection made only of no-ops, for the reason it
refuses an empty one: an empty spool and an unchanged one are both
readings, and it should not guess.
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds inventory-derived names, batch enrichment, inventory-aware selection and unselection, typed predicate parity, membership-preserving catalog filtering, semantic conflict warnings, shared enrichment documentation, and related tests and tutorial coverage.

Changes

Inventory selection and enrichment

Layer / File(s) Summary
Inventory names and enrichment contracts
dascore/constants.py, dascore/core/inventory.py, tests/test_core/test_inventory.py
Adds shared enrichment descriptions and Inventory.get_names() for inventory attributes and coordinates.
Index predicates and membership filtering
dascore/io/index/backend.py, dascore/io/index/catalog.py, dascore/io/index/query.py, dascore/io/index/ingest.py, tests/test_io/test_index/*
Adds typed in-memory and SQLite predicate evaluation, glob translation, stated-ID and unit APIs, membership-order preservation, and semantic hive-attribute conflict warnings.
Batch inventory enrichment
dascore/proc/inventory.py, tests/test_proc/test_proc_inventory.py
Adds batched context and attribute resolution, quantity conversion, bare track projection, the ignore missing-value policy, shared documentation, and enrichment validation.
Inventory-backed selection and unselection
dascore/core/spool.py, tests/test_core/test_spool.py, tests/test_proc/test_proc_inventory.py
Adds inventory-aware Spool.select, attribute-only unselect, selector validation, row alignment, precedence handling, and complement-selection coverage.
Documentation and API rendering
docs/tutorial/spool.qmd, scripts/_render_api.py
Documents unselect and formats Other Parameters with the parameter-table style.

Possibly related PRs

  • DASDAE/dascore#759 — Changes dascore/core/spool.py APIs and directory construction.
  • DASDAE/dascore#855 — Provides the inventory enrichment and inventory-aware spool selection infrastructure extended by this PR.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: inventory-backed facts can filter a spool.
Description check ✅ Passed The description explains the changes, links the issue, documents the feature, includes tests, and records the checklist status.
Docstring Coverage ✅ Passed Docstring coverage is 97.62% which is sufficient. The required threshold is 80.00%.
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.
✨ 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-select-phase3a

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.

@coderabbitai coderabbitai Bot added documentation Improvements or additions to documentation IO Work for reading/writing different formats 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.

Actionable comments posted: 2

🧹 Nitpick comments (2)
tests/test_io/test_index/test_hive_attrs.py (1)

185-200: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Narrow the silence assertions to the override warning.

warnings.simplefilter("error", UserWarning) turns every UserWarning raised during indexing into an error, not only the override warning. An unrelated warning from the index (for example the attr-clobber warning in SQLIndexBackend._apply_attr_columns) would fail these tests with a message that points at the wrong cause.

The _index helper added at line 411 already uses the precise pattern: record all warnings, then filter for "override attrs". Reusing that pattern here keeps the assertion about the behavior under test.

♻️ Proposed change for both silence tests
-        with warnings.catch_warnings():
-            warnings.simplefilter("error", UserWarning)
-            spool = Spool.from_directory(tmp_path).update(progress=None)
+        with warnings.catch_warnings(record=True) as caught:
+            warnings.simplefilter("always")
+            spool = Spool.from_directory(tmp_path).update(progress=None)
+        assert not [x for x in caught if "override attrs" in str(x.message)]
         assert spool.get_contents()["station"].iloc[0] == "A"
         spool.indexer.close()
🤖 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_io/test_index/test_hive_attrs.py` around lines 185 - 200, Update
both test_unstated_attr_is_silent and the preceding override-silence test to
capture warnings and assert only that no warning matching “override attrs” is
emitted, rather than converting every UserWarning into an exception. Reuse the
warning-recording and message-filtering pattern already used by the _index
helper, while preserving the existing spool content assertions and cleanup.
dascore/io/index/backend.py (1)

1103-1122: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Handle the json_each dependency consistently.

build_sql also uses json_each when patch_ids is provided. Replace both predicates with batching, or declare and validate JSON1 as a backend requirement.

🤖 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/io/index/backend.py` around lines 1103 - 1122, Update attr_stated_ids
and the corresponding build_sql patch_ids predicate to handle json_each
consistently: either replace both JSON-based filters with the established
batching approach, or declare and validate SQLite JSON1 as a required backend
capability before these queries run. Keep patch ID filtering behavior unchanged.

Source: Linters/SAST tools

🤖 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.

Inline comments:
In `@dascore/core/spool.py`:
- Around line 857-862: Update the unselect flow around _ordered_ids and
_catalog.restrict so the positional keep mask is applied to the exact ids array
it was built from by passing ids=ids. Keep the existing mask computation and
_new_from_catalog behavior unchanged, and align this call with
_select_from_inventory.
- Around line 999-1007: Update _index_matches to distinguish an unknown
attribute name from an invalid selector: check whether name exists in the
catalog/index before calling PatchCatalog.select, return an empty int64 array
only for an unknown name, and allow InvalidSpoolQueryError from malformed
selectors such as reversed bounds to propagate. Add coverage for an attached
inventory where every patch states the name and selector (20.0, 5.0) still
raises.

---

Nitpick comments:
In `@dascore/io/index/backend.py`:
- Around line 1103-1122: Update attr_stated_ids and the corresponding build_sql
patch_ids predicate to handle json_each consistently: either replace both
JSON-based filters with the established batching approach, or declare and
validate SQLite JSON1 as a required backend capability before these queries run.
Keep patch ID filtering behavior unchanged.

In `@tests/test_io/test_index/test_hive_attrs.py`:
- Around line 185-200: Update both test_unstated_attr_is_silent and the
preceding override-silence test to capture warnings and assert only that no
warning matching “override attrs” is emitted, rather than converting every
UserWarning into an exception. Reuse the warning-recording and message-filtering
pattern already used by the _index helper, while preserving the existing spool
content assertions and cleanup.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: fe22dba5-2514-48cb-b7cc-04b31bf90d77

📥 Commits

Reviewing files that changed from the base of the PR and between 9e1f0b3 and 430b264.

📒 Files selected for processing (15)
  • dascore/constants.py
  • dascore/core/inventory.py
  • dascore/core/spool.py
  • dascore/io/index/backend.py
  • dascore/io/index/catalog.py
  • dascore/io/index/ingest.py
  • dascore/io/index/query.py
  • dascore/proc/inventory.py
  • docs/tutorial/spool.qmd
  • scripts/_render_api.py
  • tests/test_core/test_inventory.py
  • tests/test_core/test_spool.py
  • tests/test_io/test_index/test_catalog.py
  • tests/test_io/test_index/test_hive_attrs.py
  • tests/test_proc/test_proc_inventory.py

Comment thread dascore/core/spool.py Outdated
Comment thread dascore/core/spool.py Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 430b264684

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread dascore/io/index/query.py
Comment on lines +383 to +387
if index + 2 < size and body[index + 1] == "-":
high = body[index + 2]
out.append(re.escape(low))
if low <= high:
out.append(f"{re.escape(low)}-{re.escape(high)}")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle leading ] as a literal in glob classes

For valid SQLite classes that include ] as their first member and then a range-like sequence, _class_body incorrectly treats that literal as the lower endpoint of a range. For example, SQLite evaluates '-' GLOB '[]-a]' as true, while the generated regex does not match '-' and instead admits characters in the unintended ]a range. Consequently, the same selector can reject an inventory-supplied value while accepting an identical index-stored value; preserve the leading literal ] before parsing the remainder of the class.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 1d82adb.

Reproduced exactly as described: SELECT '-' GLOB '[]-a]' is true in SQLite and the generated regex rejected it, while ^ and _ matched when they should not. The leading ] is now peeled off as a literal before the rest of the class is parsed, so a dash following it is a member rather than a range operator. Re-fuzzed against SQLite with an alphabet biased toward class punctuation (40,000 patterns, zero mismatches) and added []-a], [^]-a], []a] to the parity cases. My original fuzz used a uniform alphabet, so this shape essentially never came up.

Comment thread dascore/core/spool.py Outdated
Comment on lines +869 to +877
def _check_channel_level(self, requested, coords, known) -> None:
"""Raise for a name the inventory defines along the fiber."""
if channel_level := sorted(requested & coords - known):
msg = (
f"{channel_level} name coordinates the attached inventory "
"defines along the fiber, which selection cannot trim to "
"yet. Enrich the patches and select on each one instead."
)
raise InvalidSpoolQueryError(msg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve attribute precedence for colliding inventory names

When an optical-path annotation group has the same name as an acquisition attribute, such as gauge_length, get_names() legitimately reports the name in both attrs and coords, but this precheck raises before the documented attribute-first namespace resolution runs. Thus even bare select(gauge_length=10) or an explicit _attrs query is rejected as an unsupported along-fiber selection, despite the acquisition supplying the scalar value. Exclude inventory attribute names from this channel-level check unless the caller explicitly selected the coordinate namespace.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 1d82adb.

Reproduced with an annotation group named gauge_length: get_names() reported it in both namespaces and both select(gauge_length=10.0) and select(_attrs={"gauge_length": 10.0}) were rejected as along-fiber, though the acquisition supplies the scalar. The precheck now counts inventory attr names as known, so a colliding name resolves to the attr as bare names always do, and only a name the caller put in _coords is read as the coordinate. Covered for the mapping form and both tag forms.

Comment on lines +343 to +345
if existing.kind == "bool":
return path_value.strip().lower() != str(existing.value).lower()
return str(existing.value) != path_value

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Compare hive timestamps by value before warning

For a file carrying a datetime-valued extra attribute, a hive segment that restates the same instant in normal text is always reported as a conflict because indexed time values are nanosecond integers and this fallback compares that integer's string to the path text. For example, an attr equal to np.datetime64('2020-01-01') under observed=2020-01-01 emits the new override warning even though the values have the same meaning, contrary to the helper's stated comparison contract. Parse time path values to nanoseconds (and similarly handle stored duration kinds) before deciding they differ.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 1d82adb.

An attr equal to np.datetime64("2020-01-01") under observed=2020-01-01/ warned, because the stored value is integer nanoseconds and the fallback compared that integer as text against the path text, so a restated instant could never compare equal. _states_something_else now parses the segment with to_datetime64 / to_timedelta64 for the time and dur kinds, and treats an unparseable segment as a change. Test covers restated, different, and unparseable.

Comment thread dascore/core/inventory.py Outdated
Comment on lines +1999 to +2001
multi.update(
f"{track}.{x}" for x in names if _is_multi_valued(item, x)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retain fields that are scalar on another optical path

The multi set is accumulated globally across every optical path, so one tuple-valued occurrence suppresses that qualified field for the entire inventory. If one acquisition's component records multi-wavelength loss_db while another path records a scalar loss_db, get_names().coords omits optical_components.loss_db even though the latter path can project it, causing callers to treat a valid name as unknown. Track whether a field has any scalar occurrence and exclude it only when every stated occurrence is multi-valued.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 1d82adb.

The multi set was accumulated globally, so one component recording a multi-wavelength loss_db suppressed the qualified name for the entire inventory. It now records which shapes a field is stated in and drops it only where every stated occurrence is multi-valued, so a path recording a scalar keeps the name. Test asserts all three: multi-only omits it, scalar-only lists it, mixed lists it.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

✅ Documentation built:
👉 Download
Note: You must be logged in to github and a DASDAE member to access the link.

Python 3.13 dedents a docstring as it compiles it, so the depth a
parameter name sits at is not the same on every interpreter; the test
now looks for the name on a line of its own however far it is indented.
Also spells the sibling test's name the way the typo checker reads it.

@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.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@tests/test_proc/test_proc_inventory.py`:
- Around line 825-827: Update the forwarded-parameter name assertion in the
relevant test to search both spool_doc and patch_doc, preserving the existing
regex and name validation so removals from either Patch.enrich.__doc__ or the
spool doc fail the test.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 87c870bc-2c11-4b6c-ab4c-5a0f196f3a4a

📥 Commits

Reviewing files that changed from the base of the PR and between 430b264 and d77eab6.

📒 Files selected for processing (1)
  • tests/test_proc/test_proc_inventory.py

Comment thread tests/test_proc/test_proc_inventory.py Outdated
@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 (9e1f0b3) to head (c5c5b44).
⚠️ Report is 1 commits behind head on dev.

Additional details and impacted files
@@            Coverage Diff             @@
##               dev      #877    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          177       177            
  Lines        20153     20515   +362     
==========================================
+ Hits         20153     20515   +362     
Flag Coverage Δ
network 46.33% <25.97%> (-0.41%) ⬇️
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.

unselect restricts against the ids its mask was built from, as
inventory-backed select already does; reading them twice is two chances
to get a different list on a catalog whose syncer can invalidate between
the calls.

_index_matches asks whether the index knows the name rather than trying
it and catching the refusal. The catch was narrow only by accident of
timing -- a malformed selector is rejected when the rows are realized,
outside the try -- and a name check says what is meant.

Test hygiene: the silence assertions look for the override warning
rather than turning every UserWarning into an error.
The refusal was justified as though a coordinate complement were
ill-defined. It is not: selecting on a coordinate trims each patch as
well as dropping the ones which miss the range, so the complement is
every patch cut into the pieces outside it -- one row becoming two.
That is subdivision, which unselect cannot do yet and conform_to_inventory
will bring; the old wording argued against a feature that is scheduled.
A ']' opening a glob class is a plain member, so the dash which may
follow it is one too: '[]-a]' matched the wrong set of characters, and
the same selector could reject an inventory value while accepting an
identical stored one. Verified against SQLite over 40000 more patterns.

An annotation group may be named after an acquisition field, and the
channel-level precheck rejected the field along with the group. Bare
names resolve to attrs first, so only a name the caller put in _coords
is read as the coordinate.

A track field is dropped only where nothing states it as one value: one
path recording a multi-wavelength loss was silencing the name for every
other path, including those recording a scalar.

A time is stored as integer nanoseconds, which no path segment spells
that way, so the override warning called every restated instant a
disagreement; the segment is now read as an instant before comparing.

The forwarded-argument test checks both docstrings rather than only the
spool's, so a name cannot go missing from Patch.enrich unnoticed.
Comment thread dascore/core/inventory.py Outdated
Comment on lines +983 to +988
# The typed tracks of an optical path, each mapped to the field a bare use
# of its name means: `coupling="trench"` asks about coupling_type, and a
# geometry or component is asked about by name. Every other field of a
# track is reached by its qualified name (`coupling.medium`). This is the
# one place the mapping is written down; a test pins each entry to the
# model it names.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Too verbose, and the test sounds like a bad idea.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed on both counts — reworked in c5c5b44.

The comment is down to what a bare track name means. The map itself is gone as a hand-written thing: each track model now declares its own identity field beside the field it names,

class CouplingCondition(_IntervalModel):
    _identity_field: ClassVar[str] = "coupling_type"
    coupling_type: CouplingType = Field(...)

and TRACK_IDENTITY_FIELDS is read off those declarations by walking OpticalPaths track fields. ClassVar keeps it out of model_fields, the dump, and the round trip.

Comment thread dascore/core/inventory.py

# The observing-system facts, read off the models rather than listed, so a
# field added to either automatically becomes something an inventory can
# contribute. Pinned to INVENTORY_ATTRS by a test: the two are one

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Better way to pin together than just a test? A test that enforces structure has a smell.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You were right that it smelled. The old test only asserted that a field of that name existed on the model, so renaming coupling_type while any field called name survived elsewhere would still have passed — it enforced structure and caught very little.

Reworked in c5c5b44: with the map derived from the models there is no second copy to pin. What remains is a check inside the derivation itself rather than a test —

assert field in model.model_fields, (model, field)

so a declaration naming a field the model does not have fails where the map is built, on import, in every environment. That is stronger than the test was, and it is the defensive-assert form rather than a structural test.

Behaviour is covered behaviourally instead: a bare track name must give what its qualified identity field gives (coupling and coupling.coupling_type agree), and one test asserts the guard fires.

Net 29 lines removed, 42 added, and the constant is no longer something to keep in step with anything.

The track-name to identity-field map was written down by hand and held
to the models by a test, which is a test enforcing structure rather than
behaviour: it only checked that a field of that name existed somewhere
on the model, so a rename which left any 'name' field behind would pass.

Each track model now declares its identity beside the field it names,
and the map is read off those declarations. There is nothing to keep in
step, the comment shrinks to what a bare track name means, and the
derivation asserts that a declaration names a real field -- so a bad one
fails where it is built rather than resolving to NaN somewhere later.

The behaviour is already covered: a bare track name has to give what its
qualified identity field gives, which the round-trip test asserts.
@coderabbitai coderabbitai Bot removed documentation Improvements or additions to documentation IO Work for reading/writing different formats proc Related to processing module labels Aug 12, 2026
@coderabbitai coderabbitai Bot removed the spool related to Spool class label 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.

Actionable comments posted: 1

🤖 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.

Inline comments:
In `@dascore/core/inventory.py`:
- Line 1009: Replace the assertion validating field membership in
model.model_fields with an explicit exception raise, ensuring invalid
_identity_field values cannot be registered in TRACK_IDENTITY_FIELDS even when
Python runs with optimization enabled. Preserve the existing validation context
and include the model and field details in the raised error.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1f6d70c7-49a0-4748-9a94-48593b7a362f

📥 Commits

Reviewing files that changed from the base of the PR and between 1d82adb and c5c5b44.

📒 Files selected for processing (2)
  • dascore/core/inventory.py
  • tests/test_core/test_inventory.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_core/test_inventory.py

Comment thread dascore/core/inventory.py
continue
# The model names one of its own fields, or the map it builds
# would point at nothing.
assert field in model.model_fields, (model, field)

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python -O - <<'PY'
assert False, "This assertion should be removed in optimized mode."
print("Assertions are disabled under -O.")
PY

Repository: DASDAE/dascore

Length of output: 186


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- target function and validation ---'
sed -n '960,1030p' dascore/core/inventory.py

printf '%s\n' '--- related identity-field declarations and mapping uses ---'
rg -n -C 3 '_identity_field|TRACK_IDENTITY_FIELDS|_track_identity_fields' dascore tests

Repository: DASDAE/dascore

Length of output: 9370


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- track enrichment and selection behavior ---'
sed -n '600,650p' dascore/proc/inventory.py
sed -n '1985,2055p' dascore/core/inventory.py

printf '%s\n' '--- existing validation and behavioral tests ---'
sed -n '1785,1820p' tests/test_core/test_inventory.py
sed -n '1875,1905p' tests/test_core/test_inventory.py

printf '%s\n' '--- optimized assertion behavior ---'
python3 - <<'PY'
source = """
def validate(field, fields):
    assert field in fields, ("model", field)
    return {"track": field}
"""
normal = compile(source, "<normal>", "exec", optimize=0)
optimized = compile(source, "<optimized>", "exec", optimize=1)

for label, code in (("normal", normal), ("optimized", optimized)):
    namespace = {}
    exec(code, namespace)
    try:
        result = namespace["validate"]("not_a_field", {"name"})
    except AssertionError as exc:
        print(f"{label}: raises AssertionError: {exc}")
    else:
        print(f"{label}: returns {result!r}")
PY

Repository: DASDAE/dascore

Length of output: 9034


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- track coordinate lookup implementation ---'
rg -n -C 8 'def _get_track_coord|def _value_field_names|def _value_shape' dascore

printf '%s\n' '--- optimized import-time validation context ---'
sed -n '1745,1775p' dascore/core/inventory.py

Repository: DASDAE/dascore

Length of output: 4593


🏁 Script executed:

#!/bin/bash
set -euo pipefail
sed -n '566,600p' dascore/proc/inventory.py

Repository: DASDAE/dascore

Length of output: 1782


Make identity-field validation explicit.

At dascore/core/inventory.py:1009, assert is removed under Python -O. An invalid _identity_field can then enter TRACK_IDENTITY_FIELDS, and track lookup treats the missing field as an absent coordinate. Replace the assertion with an explicit raise.

Proposed fix
-            assert field in model.model_fields, (model, field)
+            if field not in model.model_fields:
+                raise AssertionError((model, field))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assert field in model.model_fields, (model, field)
if field not in model.model_fields:
raise AssertionError((model, field))
🤖 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/core/inventory.py` at line 1009, Replace the assertion validating
field membership in model.model_fields with an explicit exception raise,
ensuring invalid _identity_field values cannot be registered in
TRACK_IDENTITY_FIELDS even when Python runs with optimization enabled. Preserve
the existing validation context and include the model and field details in the
raised error.

@d-chambers
d-chambers merged commit bac0778 into dev Aug 12, 2026
33 checks passed
@d-chambers
d-chambers deleted the inventory-select-phase3a branch August 12, 2026 13:24
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