Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion src/ezmsg/sigproc/slicer.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,29 @@


class SlicerTransformer(BaseStatefulTransformer[SlicerSettings, AxisArray, AxisArray, SlicerState]):
def _resolves_by_position(self) -> bool:
"""True iff the selection can only ever mean array positions.

Every token is a slice expression, so no part of it is matched against
the axis's coordinate values and the resolved indices cannot depend on
them.

Check failure on line 199 in src/ezmsg/sigproc/slicer.py

View workflow job for this annotation

GitHub Actions / build (3.13, ubuntu-latest)

ruff (W291)

src/ezmsg/sigproc/slicer.py:199:14: W291 Trailing whitespace help: Remove trailing whitespace

Check failure on line 199 in src/ezmsg/sigproc/slicer.py

View workflow job for this annotation

GitHub Actions / build (3.12, macos-latest)

ruff (W291)

src/ezmsg/sigproc/slicer.py:199:14: W291 Trailing whitespace help: Remove trailing whitespace

Check failure on line 199 in src/ezmsg/sigproc/slicer.py

View workflow job for this annotation

GitHub Actions / build (3.10.15, macos-latest)

ruff (W291)

src/ezmsg/sigproc/slicer.py:199:14: W291 Trailing whitespace help: Remove trailing whitespace

Check failure on line 199 in src/ezmsg/sigproc/slicer.py

View workflow job for this annotation

GitHub Actions / build (3.12, ubuntu-latest)

ruff (W291)

src/ezmsg/sigproc/slicer.py:199:14: W291 Trailing whitespace help: Remove trailing whitespace

Check failure on line 199 in src/ezmsg/sigproc/slicer.py

View workflow job for this annotation

GitHub Actions / build (3.13, macos-latest)

ruff (W291)

src/ezmsg/sigproc/slicer.py:199:14: W291 Trailing whitespace help: Remove trailing whitespace

It is only a small case, because otherwise we will just use a more complicated hash.
"""
if self.settings.field is not None:
return False
return all(":" in token for token in self.settings.selection.split(",") if token.strip())
Comment on lines +203 to +205

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This re-splits a string on every message to answer a question fixed at construction — settings is frozen, so _resolves_by_position() can never change for a given transformer.

It's only 0.16 µs, but group_spec_fingerprint's docstring frets about the function call itself being a measurable share of cost at this rate, so a split() plus a generator per message is out of keeping. functools.cached_property, or resolve it once in _reset_state.

(Also, narrowly: field is not None returning False is stricter than needed — with field="bank" and selection "3:4", parse_slice takes the two-part slice path and never consults the labels. Moot if the shortcut goes away.)


Check failure on line 206 in src/ezmsg/sigproc/slicer.py

View workflow job for this annotation

GitHub Actions / build (3.13, ubuntu-latest)

ruff (W293)

src/ezmsg/sigproc/slicer.py:206:1: W293 Blank line contains whitespace help: Remove whitespace from blank line

Check failure on line 206 in src/ezmsg/sigproc/slicer.py

View workflow job for this annotation

GitHub Actions / build (3.12, macos-latest)

ruff (W293)

src/ezmsg/sigproc/slicer.py:206:1: W293 Blank line contains whitespace help: Remove whitespace from blank line

Check failure on line 206 in src/ezmsg/sigproc/slicer.py

View workflow job for this annotation

GitHub Actions / build (3.10.15, macos-latest)

ruff (W293)

src/ezmsg/sigproc/slicer.py:206:1: W293 Blank line contains whitespace help: Remove whitespace from blank line

Check failure on line 206 in src/ezmsg/sigproc/slicer.py

View workflow job for this annotation

GitHub Actions / build (3.12, ubuntu-latest)

ruff (W293)

src/ezmsg/sigproc/slicer.py:206:1: W293 Blank line contains whitespace help: Remove whitespace from blank line

Check failure on line 206 in src/ezmsg/sigproc/slicer.py

View workflow job for this annotation

GitHub Actions / build (3.13, macos-latest)

ruff (W293)

src/ezmsg/sigproc/slicer.py:206:1: W293 Blank line contains whitespace help: Remove whitespace from blank line
def _hash_message(self, message: AxisArray) -> int:
axis = self.settings.axis or message.dims[-1]
axis_idx = message.get_axis_idx(axis)
return hash((message.key, message.data.shape[axis_idx]))
key = (message.key, message.data.shape[axis_idx])
if self._resolves_by_position():
return hash(key)
Comment on lines +211 to +212

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Blocking: this shortcut preserves the exact bug the PR fixes.

_state.new_axis is built in _reset_state from message.axes[axis].data[self._state.slice_] — the axis values — and it is cached alongside the indices. So even for a purely positional selection, the cached output axis goes stale when the labels change. Skipping the value hash here means nothing catches it.

Verified by grafting this branch's _hash_message and _resolves_by_position onto the transformer:

selection "0:2"
axis ['Ch5','Ch7','Ch10']  -> out.axes['ch'].data ['Ch5' 'Ch7']   correct
axis ['Ch9','Ch8','Ch1']   -> out.axes['ch'].data ['Ch5' 'Ch7']   should be ['Ch9' 'Ch8']

Same class of error as the one in your description — right-looking label, wrong channel's samples — just reached through the positional path.

Two ways out. Either drop the shortcut entirely (any message carrying a coordinate axis is value-sensitive, because the output axis is), or keep it and stop caching new_axis, recomputing it per message outside _reset_state. I'd drop it: per the benchmark it saves 0.16 µs of split(",") and costs a live bug. If you keep it, it needs to be conditional on the axis having no coordinate data at all, not on the selection being positional.

# new cache includes the coordinate-axis bytes when the selection can resolve
# against them.
data = getattr(message.axes.get(axis), "data", None)
return hash((*key, None if data is None else np.asarray(data).tobytes()))
Comment on lines +213 to +216

@cboulay cboulay Sep 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few sharp edges here, all of which coord_value_fingerprint (a util/channels.py helper I've written but not yet pushed) handles — I'd suggest return hash(key + coord_value_fingerprint(message, axis, fields)) and deleting this branch:

Object-dtype axes silently reset every message. An object array's buffer is pointers, so two equal label arrays built from distinct string objects checksum differently:

a = np.array([f"ch{i}" for i in range(8)], dtype=object)
b = np.array(["".join(("ch", str(i))) for i in range(8)], dtype=object)
hash(a.tobytes()) == hash(b.tobytes())   # False

That's a silent perf cliff rather than a wrong answer, but it inverts test_slicer_unchanged_axis_does_not_reset_state for anyone whose source hands over an object array. The helper widens with .astype("U") first.

It hashes fields the selection never reads. With field="bank" on a ChannelMap axis, jitter in x/y — a source recomputing float electrode positions — forces a pointless re-resolve. This is the real argument for narrowing to the consulted field, more than speed: coord_value_fingerprint(message, axis, ("bank",)) tracks bank and ignores the rest.

Note the fields Slicer consults are exactly what _axis_labels decides: (field,) when settings.field is set, ("label",) for a structured axis carrying one, and None (whole array) for a plain axis. That logic wants to be shared with _axis_labels rather than re-derived, so the hash and the resolution can't drift apart.

np.asarray(data) assumes numpy. Fine today, but it raises on a cupy-backed coordinate axis rather than degrading. The helper goes through np.ascontiguousarray, which also covers the strided case (a sliced coordinate array has no C-contiguous buffer).

Minor: tobytes() alone carries neither dtype nor shape. n_ch is already in the key so it barely matters, but they're nearly free to include — with one catch worth knowing, since it bit me: str(dtype) on an 8-field structured dtype costs 10 µs, ten times the checksum it annotates. Store the np.dtype object instead (0.03 µs, hashable, compares by value).


def _selects_positional_int(self, axinfo: AxisArray.CoordinateAxis | None) -> bool:
"""True iff the selection is a single bare-integer token that parse_slice
Expand Down
119 changes: 119 additions & 0 deletions tests/unit/test_slicer.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,3 +447,122 @@ def test_slicer_order_single_slice_untouched():
def test_slicer_order_invalid():
with pytest.raises(ValueError, match="order"):
SlicerTransformer(SlicerSettings(selection="0:2", axis="ch", order="token"))(_make_order_msg())


def _labelled_msg(labels: list[str], key: str = "test_relabel") -> AxisArray:
"""One message whose every column carries its own index as its value.

That is what lets these tests say *which* channel came out, rather than
only what the output axis claims came out -- the distinction the bug turns
on, since the axis is cached alongside the indices.

``_labelled_msg(["Ch5", "Ch7", "Ch10"])`` gives dims ``["time", "ch"]``,
a 100 Hz time axis and::

.data [[0., 1., 2.], column i carries i, so a slice
[0., 1., 2.], of the data reports the position
[0., 1., 2.], it was taken from
[0., 1., 2.]]
.axes["ch"].data ["Ch5", "Ch7", "Ch10"]
.key "test_relabel" constant, so the base hash sees
the same stream every time

Calling it again with ``["Ch7", "Ch5", "Ch10"]`` returns identical data
under a reordered axis: same key, same channel count, different labels --
the one thing a hash keyed on ``(key, n_ch)`` cannot see.
"""
n_times = 4
data = np.tile(np.arange(len(labels), dtype=float), (n_times, 1))
return AxisArray(
data,
dims=["time", "ch"],
axes={
"time": AxisArray.TimeAxis(fs=100.0, offset=0.0),
"ch": AxisArray.CoordinateAxis(data=np.array(labels), dims=["ch"]),
},
key=key,
)


def test_slicer_relabelled_axis_reresolves_selection():
"""A label selection must follow its label when the axis is reordered.

The channel count and the message key are unchanged, so state keyed on
those alone would keep the indices resolved against the previous axis --
and, because the output axis is cached with them, keep labelling the wrong
channel's samples "Ch7" without raising anything.
"""
xformer = SlicerTransformer(SlicerSettings(selection="Ch7", axis="ch"))

out = xformer(_labelled_msg(["Ch5", "Ch7", "Ch10"]))
assert out.data[0, 0] == 1 # Ch7 is at position 1
assert np.array_equal(out.axes["ch"].data, np.array(["Ch7"]))

out = xformer(_labelled_msg(["Ch7", "Ch5", "Ch10"]))
assert out.data[0, 0] == 0 # ... and now at position 0
assert np.array_equal(out.axes["ch"].data, np.array(["Ch7"]))


def test_slicer_label_gone_from_axis_is_no_longer_selected():
"""Test relabeling.
A relabel that removes the selected channel must stop returning one.
"""
xformer = SlicerTransformer(SlicerSettings(selection="Ch7", axis="ch", on_empty="warn"))
xformer(_labelled_msg(["Ch5", "Ch7", "Ch10"]))

out = xformer(_labelled_msg(["Ch1", "Ch2", "Ch3"]))
assert out.data.shape[1] == 0
assert out.axes["ch"].data.size == 0


def test_slicer_regex_selection_follows_a_relabel():
xformer = SlicerTransformer(SlicerSettings(selection="C.*", axis="ch"))
assert xformer(_labelled_msg(["Fp1", "C3", "C4"])).data[0].tolist() == [1.0, 2.0]
assert xformer(_labelled_msg(["C3", "C4", "Fp1"])).data[0].tolist() == [0.0, 1.0]


def test_slicer_field_selection_follows_a_relabel():
"""Structured axes too: the field values are what the tokens matched."""
dt = np.dtype([("bank", "U2"), ("elec", "<i4")])

def msg(banks: list[str]) -> AxisArray:
data = np.zeros(len(banks), dtype=dt)
for i, bank in enumerate(banks):
data[i] = (bank, i + 1)
return AxisArray(
np.tile(np.arange(len(banks), dtype=float), (4, 1)),
dims=["time", "ch"],
axes={
"time": AxisArray.TimeAxis(fs=100.0, offset=0.0),
"ch": AxisArray.CoordinateAxis(data=data, dims=["ch"]),
},
key="test_field_relabel",
)

xformer = SlicerTransformer(SlicerSettings(selection="B", axis="ch", field="bank"))
assert xformer(msg(["A", "B", "B"])).data[0].tolist() == [1.0, 2.0]
assert xformer(msg(["B", "A", "A"])).data[0].tolist() == [0.0]


def test_slicer_positional_selection_ignores_the_axis_values():
"""A slice selection cannot depend on the labels, so it must not re-resolve.

Also the reason the hash may skip the axis entirely for such selections:
hashing coordinate data no positional selection can consult is pure cost.
"""
xformer = SlicerTransformer(SlicerSettings(selection="0:2", axis="ch"))
assert xformer(_labelled_msg(["Ch5", "Ch7", "Ch10"])).data[0].tolist() == [0.0, 1.0]
assert xformer(_labelled_msg(["Ch9", "Ch8", "Ch1"])).data[0].tolist() == [0.0, 1.0]

Comment on lines +547 to +556

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This test passes on a broken implementation. It asserts on .data but never on .axes["ch"].data, which is where the positional path goes stale (see the comment on _hash_message). Adding

assert np.array_equal(out.axes["ch"].data, np.array(["Ch9", "Ch8"]))

turns it into a failing test for the bug.

The docstring's second claim — "hashing coordinate data no positional selection can consult is pure cost" — is the part that doesn't hold: _reset_state does consult it, to build new_axis.

Two more cases I'd want covered once the mechanism settles: an object-dtype label axis (should behave like test_slicer_unchanged_axis_does_not_reset_state, and currently doesn't), and a structured axis where an unread field changes while the selected field doesn't (should not reset).


def test_slicer_unchanged_axis_does_not_reset_state():
"""An equal axis rebuilt per message must not look like a change.

Sources commonly rebuild an identical ch axis for every message; if that
counted as a change, the selection would be re-parsed at the sample rate.
"""
xformer = SlicerTransformer(SlicerSettings(selection="Ch7", axis="ch"))
xformer(_labelled_msg(["Ch5", "Ch7", "Ch10"]))
first = xformer._hash
xformer(_labelled_msg(["Ch5", "Ch7", "Ch10"]))
assert xformer._hash == first
Loading