Skip to content

Commit e4e61b2

Browse files
oierlauziclaude
andcommitted
Describe the image binding as it now stands
AGENTS.md lists what _binding.em.image holds and what the package adds to it, and states three rules the update settled: only what the functions of image_read.hpp and image_write.hpp need is bound, a collaborator the package defaults moves behind the arguments it does not, and the helpers that assemble collaborators are nouns. Every call that reaches a file releases the GIL, not the batch read alone. DECISIONS.md renames the batch source section to the sources and sinks it now covers, and records that centres are an IndexTable in the binding and any sequence in the package. A file's own data type can be asked for after all, through query_descriptor, so the entry saying no such query exists is corrected. BACKLOG.md drops the patch source, which is bound, names the error types rexlib has now in the item on translating them, and says why the image tests never assert a value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cec13df commit e4e61b2

3 files changed

Lines changed: 95 additions & 53 deletions

File tree

‎AGENTS.md‎

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ the same pull request that causes it.
1414

1515
| Path | Holds |
1616
|---|---|
17-
| `src/` | The pybind11 binding, 47 `.cpp` and 47 `.hpp`, compiled into the `rexlib._binding` extension |
17+
| `src/` | The pybind11 binding, 51 `.cpp` and 51 `.hpp`, compiled into the `rexlib._binding` extension |
1818
| `python/rexlib/` | The Python package, 11 `.py`: everything the binding cannot express |
1919
| `tests/` | pytest suites, mirroring the binding's module structure |
2020
| `tests/assets/` | Two dummy plugins, built by CMake, that the plugin tests discover |
@@ -71,7 +71,7 @@ name that names them. See #143.
7171
| `_binding.dispatch` | `src/core/dispatch/` | `ExecutionContext`, `Dispatcher`, `ProgramManager` |
7272
| `_binding.concurrency` | `src/core/concurrency/` | `Executor` and its two kinds, `Completion` |
7373
| `_binding.functional` | `src/functional/` | The operations, each taking an explicit context |
74-
| `_binding.em.image` | `src/em/image/` | `ImageLocation`, the format managers and reader providers, the sources, `read`, `write` and the shape queries |
74+
| `_binding.em.image` | `src/em/image/` | `ImageLocation`, `ImageDescriptor`, `IndexTable`, the format managers, the reader and writer providers, the source and the sink, and the functions of `image_read.hpp` and `image_write.hpp` |
7575

7676
A submodule is created by the `main.cpp` above it, which then hands it to the
7777
`bind_` function of the directory it stands for: `src/main.cpp` creates `em`
@@ -89,7 +89,7 @@ the order the declarations need.
8989
| `_device.py` | `rexlib.device(...)`, the `with` block that activates one |
9090
| `_functional.py` | The operations again, with `context` defaulting to the active one |
9191
| `_ndarray.py` | Installs the Python operators onto `Array` |
92-
| `em/` | The electron microscopy areas, one module each; `em/image/` defaults the format manager and the context |
92+
| `em/` | The electron microscopy areas, one module each; `em/image/` defaults the formats, the reader provider and the context, and assembles sources and sinks |
9393

9494
`_paths` is imported first in `__init__.py`, and the order matters: on Windows
9595
nothing else imports until the bundled library is findable. `_ndarray` is
@@ -99,13 +99,30 @@ imported for its side effect, before anything can hand out an `Array`.
9999
than carrying one of its own, so `read` asks for an active device exactly as
100100
`zeros` does and there is one place to change if that ever stops being the
101101
rule. Two things about those functions are rexlib's and not this package's:
102-
`em::read` allocates on the host whatever device is active, and `em::write`
103-
refuses storage the host cannot reach instead of transferring it. Both are
102+
`em::read` allocates on the host whatever device is active, and the writes
103+
refuse storage the host cannot reach instead of transferring it. Both are
104104
stated in their docstrings, since a caller has no other way to find out.
105105

106+
Only what the functions of `image_read.hpp` and `image_write.hpp` need is
107+
bound. A reader, a writer, a format, a transfer plan and a sanitizer have no
108+
Python counterpart, so `ImageSource` and `ImageSink` are bound without the
109+
`read` and `write` that take them, and `ManagedImageWriterProvider` without
110+
`acquire`.
111+
112+
A collaborator the package defaults moves behind the arguments it does not:
113+
`em::write(arr, path, manager, descriptor)` is
114+
`write(array, path, descriptor, manager=None)`. A function with nothing to
115+
default is re-exported as the binding has it, which is how
116+
`read_batch_async` and `write_batch_async` arrive.
117+
118+
The helpers that assemble collaborators are nouns: `reader_provider`,
119+
`writer_provider`, `source` and `sink`. `sink` takes its writer provider
120+
rather than assembling one, since whoever writes keeps it to declare the
121+
files and to close them.
122+
106123
`image_metadata` is not bound. rexlib declares it empty, so the parameter is
107-
left off `write` until the type has fields; adding it later is compatible,
108-
and publishing an empty class now would not be.
124+
left off the writes and off `declare` until the type has fields; adding it
125+
later is compatible, and publishing an empty class now would not be.
109126

110127
## Building
111128

@@ -249,16 +266,16 @@ written, which is rexlib's to do rather than this binding's;
249266
250267
A binding that blocks releases the GIL with
251268
`py::call_guard<py::gil_scoped_release>()`. `Completion.wait`, `Completion.get`
252-
and `ImageBatchSource.read` all do: held through a read, the GIL would freeze
253-
every other Python thread for its duration and there would be nothing
254-
asynchronous left about the interface. pybind11 converts the arguments before
269+
and everything in `em.image` that reaches a file all do: held through a
270+
read, the GIL would freeze every other Python thread for its duration and
271+
there would be nothing asynchronous left about the interface. pybind11 converts the arguments before
255272
the guard is constructed and the return value after it is destroyed, so
256273
nothing touches Python without it.
257274
258275
`rexlib::array` is move-only, and anything taking one by value has to be given
259-
`destination.share()` rather than the caller's own. Bound plainly, pybind11
260-
would move the array out of the Python object and hand the caller back an
261-
empty one.
276+
`destination.share()` rather than the caller's own, or `source.share_const()`
277+
where it takes a `const_array`. Bound plainly, pybind11 would move the array
278+
out of the Python object and hand the caller back an empty one.
262279
263280
A type rexlib parses from a string carries a `from_string` static method and
264281
no constructor that parses. `DeviceIndex` and `ImageLocation` both do, and
@@ -350,7 +367,7 @@ Tests for what `_binding` exposes at its top level stay at the root.
350367
| `tests/hardware/` | Devices, sessions, events, memory resources, the session pool |
351368
| `tests/dispatch/` | `ExecutionContext`, the active context, `rexlib.device(...)` |
352369
| `tests/functional/` | The operations |
353-
| `tests/em/image/` | `ImageLocation`, parsing, the format managers, `read` and `write` |
370+
| `tests/em/image/` | `ImageLocation`, `ImageDescriptor`, `IndexTable`, the format managers, the providers, the synchronous reads and writes and the asynchronous ones |
354371
355372
There are no `__init__.py` files and no `conftest.py`. Test module names are
356373
therefore unique across the whole tree, and a fixture belongs to the file that

‎BACKLOG.md‎

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Work whose shape is settled and only waits to be scheduled. Everything here
44
has a known method; nothing here is waiting on a judgement call. Open
55
questions live in [DECISIONS.md](DECISIONS.md).
66

7-
Reviewed 20 Sep 2026, against rexlib-python `abb3000` and rexlib `ce67271d`.
7+
Reviewed 1 Oct 2026, against rexlib-python `cec13df` and rexlib `3d7fb23f`.
88

99
## 1. Bind the rest of `functional`
1010

@@ -56,43 +56,42 @@ One trap before `compare` lands: a type that defines `__eq__` and no
5656
rexlib, so the comparison operators need an answer about identity before they
5757
go in.
5858

59-
## 3. Bind the patch source
60-
61-
`image_patch_source` arrived with the batch source upstream and crops batches
62-
of patches out of an image, clipped at the borders. The batch source is bound;
63-
this one is not, and it stands on the same chain, so it is a smaller piece of
64-
work than the first was.
65-
66-
## 4. Translate the library's exceptions
59+
## 3. Translate the library's exceptions
6760

6861
There is no `py::register_exception` anywhere in `src/`, so everything rexlib
69-
throws arrives through pybind11's catch-all. `invalid_operation_error` derives
70-
from `std::logic_error` and `image_format_error` from `std::runtime_error`,
71-
neither of which pybind11 special-cases, so both land as a bare
72-
`RuntimeError`.
73-
74-
This cost nothing while the surface a caller could fail against was small. It
75-
no longer is: a missing file, a format nothing recognises, a truncated file
76-
and an array the host cannot reach are four different mistakes that a caller
77-
meets routinely through `em.image` and cannot tell apart.
62+
throws arrives through pybind11's catch-all. `image_file_error`,
63+
`image_format_error`, `unsupported_operation_error` and
64+
`unsupported_capability_error` all derive from `std::runtime_error`, which
65+
pybind11 does not special-case, so all four land as a bare `RuntimeError`.
66+
67+
rexlib now tells them apart, and the binding throws that away: a file that
68+
cannot be reached, a file whose contents contradict its format, a format
69+
nothing recognises and an array the host cannot reach are four different
70+
mistakes that a caller meets routinely through `em.image` and cannot tell
71+
apart. What already arrives well is what rexlib throws as a standard
72+
exception: an index past the end of a stack is an `IndexError`, and a
73+
destination of the wrong shape a `ValueError`.
7874

7975
The cheapest visible win is a missing file arriving as `FileNotFoundError`. It
80-
reaches `image_read_format_manager::open` as "no registered format recognizes
81-
the file", which is true but is not what went wrong and is not what anyone
82-
will search for.
76+
is an `image_file_error` in rexlib, raised with the path at the front of its
77+
message, and a `RuntimeError` here.
8378

8479
Register the types once where the module is built so every submodule inherits
8580
them; translate only what genuinely corresponds to a Python builtin and give
8681
the rest types of their own; the tests under `tests/em/image/` currently
8782
assert `RuntimeError` and follow.
8883

89-
## 5. Zero-copy exchange with the array ecosystem
84+
## 4. Zero-copy exchange with the array ecosystem
9085

9186
`Array` can now be measured and printed, but nothing can be handed to anything
9287
else: there is no buffer protocol, no `__array__`, no DLPack. Loading batches
9388
at speed and receiving objects that cannot leave the package is a pipeline
9489
without an exit.
9590

91+
It is also why the tests under `tests/em/image/` assert shapes, types and
92+
failures and never a value: a patch clipped at a border, or a batch read back
93+
from a stack, cannot be looked into from Python.
94+
9695
Worth separating: the buffer protocol or `__array__` for host-resident arrays,
9796
where device-resident ones refuse rather than transfer, matching the explicit
9897
transfer rule the library holds everywhere else; and DLPack afterwards, if

‎DECISIONS.md‎

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ them by accident. Each names the condition that would reopen it, so a future
55
reader can tell a considered choice from an oversight. Implementation work
66
lives in [BACKLOG.md](BACKLOG.md).
77

8-
Reviewed 20 Sep 2026. Nothing is currently waiting on an answer.
8+
Reviewed 1 Oct 2026. Nothing is currently waiting on an answer.
99

10-
## The batch source
10+
## Sources and sinks
1111

1212
### A destination's data type is chosen, not discovered
1313

@@ -17,8 +17,9 @@ the pipeline computes in and the read converts on the way. The type is fixed
1717
by what the work needs rather than by what the file happens to hold, and
1818
reading straight into it saves a separate conversion pass later.
1919

20-
So no query for a file's native type is needed, and none exists. The
21-
conversion is the design rather than an oversight.
20+
The conversion is the design rather than an oversight. A file's own type is
21+
still there for whoever wants it: `query_descriptor` reports it, and the
22+
synchronous `read` keeps it unless it is asked for another.
2223

2324
**Revisit when** a pipeline is measured to be spending materially in that cast
2425
and reading into the file's own type would avoid it.
@@ -28,7 +29,7 @@ and reading into the file's own type would avoid it.
2829
Sharing an executor between sources is the caller's responsibility rather than
2930
the binding's; most clients hold a single source and a single sink. A default
3031
is still provided, and per instance is the one chosen, with an explicit
31-
executor accepted as an override.
32+
executor accepted as an override. `source()` and `sink()` both follow it.
3233

3334
Per instance rather than process-wide because the choice is meant to be
3435
revisited once there is evidence of how clients use it, and this is the
@@ -57,10 +58,13 @@ with code already built on `asyncio` or `concurrent.futures`.
5758

5859
### Blocking calls release the GIL
5960

60-
`wait` and `get` block, and `read` opens files through the provider before
61-
returning. Bound plainly they would hold the GIL and freeze every Python
62-
thread for the duration, which makes the asynchronous interface pointless.
63-
All three take `py::call_guard<py::gil_scoped_release>()`.
61+
`wait` and `get` block, and the asynchronous reads and writes open files
62+
through their provider before returning. Bound plainly they would hold the
63+
GIL and freeze every Python thread for the duration, which makes the
64+
asynchronous interface pointless. All of them take
65+
`py::call_guard<py::gil_scoped_release>()`, and so does everything else in
66+
`em.image` that reaches a file: the synchronous reads and writes,
67+
`query_descriptor`, `flush` and `close`.
6468

6569
Safe because pybind11 constructs the guard after the arguments are converted
6670
and destroys it before the return value is cast, so nothing touches Python
@@ -79,21 +83,43 @@ loads a `std::vector` from anything passing `isinstance<sequence>`, so a list,
7983
a tuple and a `range` all reach `make_contiguous_array_descriptor`. Tests pin
8084
both halves, including the round trip: a shape this reports is one it accepts.
8185

86+
### Centres are an `IndexTable` in the binding and any sequence in the package
87+
88+
`read_patches_async` takes an `index_table` in rexlib, so the binding takes an
89+
`IndexTable` and nothing else. The package accepts any sequence of sequences
90+
beside it and builds the table, with the rank the destination implies, which
91+
is what keeps an empty list of centres meaningful.
92+
93+
The table is bound as a class rather than hidden behind a caster because a
94+
caller cropping many batches can fill one and reuse it, which a conversion on
95+
every call would not allow.
96+
97+
### Only what the high-level functions need is bound
98+
99+
`rexlib.em.image` exposes the functions of `image_read.hpp` and
100+
`image_write.hpp` and the classes a caller has to construct to call them.
101+
Readers, writers, formats, transfer plans and sanitizers stay in C++: nothing
102+
Python does with them could not be done through those functions, and each
103+
would be another surface to keep matching rexlib.
104+
105+
**Revisit when** a format or a border rule has to be written in Python.
106+
82107
### `__eq__` without `__hash__` leaves a type unhashable
83108

84-
Where rexlib gives a type a `hash()` of its own, forward it; `array_descriptor`
85-
and `image_location` both do. Where it does not — `device_index` is the one
86-
left — the hash would have to be written rather than forwarded, and that
87-
belongs in rexlib beside the ones that already exist. `_session_pool.py` keys
88-
on a `(backend, id)` tuple in the meantime.
109+
Where rexlib gives a type a `hash()` of its own, forward it; `array_descriptor`,
110+
`image_descriptor` and `image_location` all do. Where it does not —
111+
`device_index` is the one left — the hash would have to be written rather
112+
than forwarded, and that belongs in rexlib beside the ones that already exist.
113+
`_session_pool.py` keys on a `(backend, id)` tuple in the meantime.
89114

90115
**Revisit when** rexlib gives `device_index` a `hash()`.
91116

92117
### `image_metadata` is not bound
93118

94-
rexlib declares the class empty and says so. Leaving the parameter off `write`
95-
until the type has fields is backward compatible; publishing an empty class
96-
whose shape is going to change is not.
119+
rexlib declares the class empty and says so. Leaving the parameter off the
120+
writes and off `ManagedImageWriterProvider.declare` until the type has fields
121+
is backward compatible; publishing an empty class whose shape is going to
122+
change is not.
97123

98124
**Revisit when** rexlib gives `image_metadata` contents.
99125

0 commit comments

Comments
 (0)