Skip to content
Open
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
191 changes: 191 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,194 @@
## 2026-07-02 - Version 0.14.0
A `Matrix` gather/reduce release. The dense matrix type gains top-k selection,
along-axis gather/scatter, interleaved repetition, a lazy element iterator,
masked reductions, and trigonometric/sign ufuncs, while the random-number
factories move to an independent per-interpreter generator. A crash in the
noticeboard mutator thread when reconstructing Matrix-valued entries is fixed,
a family of allocation-overflow and cross-interpreter acquisition guards
harden the C core, and passing an empty cown-list group to ``@when`` no longer
crashes or drops a behavior parameter. It also adds a zero-copy `MatrixView` —
read/write row and column windows that alias a matrix's storage, with
``.x``/``.y``/``.z``/``.w`` accessors and per-row/column iterators — plus value
binning via `Matrix.bin` and `Matrix.digitize`. One breaking change: the
``in_place`` flag on the unary element-wise methods is now keyword-only.

**New Features**

- **`Matrix.topk(k, axis=None, largest=True, where=None, as_matrix=False)`** —
the *k* extreme elements per reduction group, in sorted order, returning a
``(values, indices)`` tuple. NumPy tie-break (first occurrence wins, NaN
sorts last); a masked group shorter than *k* pads with ``NaN`` values and
``-1`` indices. Indices come back as Python lists by default (a flat
``list[int]`` for ``axis=None``, a list of per-group lists for an axis) so
they feed straight into fancy indexing; ``as_matrix=True`` returns a
same-shape index :class:`Matrix` instead.
- **`Matrix.take_along_axis` / `Matrix.put_along_axis`** — the
``np.take_along_axis`` / ``np.put_along_axis`` gather and scatter, one index
per row (``axis=1``) or per column (``axis=0``). ``take_along_axis`` accepts
a keyword-only ``out=`` target; ``put_along_axis`` has an ``accumulate=True``
mode. Pairs directly with :meth:`Matrix.argmin` / :meth:`Matrix.argmax`.
- **`Matrix.repeat_interleave(repeats, axis=None)`** — the
``np.repeat`` / ``torch.repeat_interleave`` interleaved (not tiled) copy of
each element, row, or column.
- **`Matrix.values()`** — a lazy row-major iterator over every element,
holding a strong reference to its source and re-checking cown acquisition on
each step.
- **`Matrix.full(size, value)`** — a constant-filled constructor alongside
:meth:`Matrix.zeros` / :meth:`Matrix.ones`.
- **`Matrix.sign` / `Matrix.cos` / `Matrix.sin`** — element-wise ufuncs
(``sign`` maps ±0.0 and NaN to ``0``), each with keyword-only
``in_place=`` / ``out=``.
- **`Matrix.reciprocal`** — the ``np.reciprocal`` element-wise ``1 / x`` ufunc
(zeros yield ±``inf``), with keyword-only ``in_place=`` / ``out=``.
- **Masked reductions** — ``sum`` / ``mean`` / ``min`` / ``max`` /
``magnitude`` / ``magnitude_squared`` and ``argmin`` / ``argmax`` accept a
``where=`` same-shape mask matrix; only cells whose mask is non-zero (NaN
counts as included) are considered. An all-excluded group publishes an
op-specific sentinel: ``0`` for the additive ops, ``NaN`` for ``mean`` /
``min`` / ``max`` (matching NumPy's empty-slice semantics), and ``-1`` for
``argmin`` / ``argmax``.
- **Axis-wise `argmin` / `argmax`** — ``axis=0`` / ``axis=1`` return the
per-column / per-row extreme positions as a Python ``list[int]`` (directly
usable in fancy indexing), or a :class:`Matrix` vector with
``as_matrix=True``. ``axis=None`` still returns a single ``int``.
- **`MatrixView` — zero-copy row/column windows** — :meth:`Matrix.row_view`
and :meth:`Matrix.column_view` (and the ``m.view[...]`` slice-grammar
accessor) return a read/write 1-D view that *aliases* the matrix's storage:
reading or writing an element (``rv[i]``, ``rv[i] = x``, ``rv[:] = ...``, or
the ``.x`` / ``.y`` / ``.z`` / ``.w`` accessors) touches the underlying
buffer directly. A view pins its whole backing buffer, materialises to a
fresh contiguous :class:`Matrix` for arithmetic, and is deliberately not
shippable across interpreters (call :meth:`MatrixView.copy` to detach an
owned copy). A view is directly iterable (``for x in rv``) and
:meth:`MatrixView.values` lazily iterates its (possibly strided) elements.
Pass ``read_only=True`` to :meth:`Matrix.row_view` / :meth:`Matrix.column_view`
for an immutable view whose writes (item / slice assignment and the
``.x``/``.y``/``.z``/``.w`` setters) raise ``TypeError``; ``.T`` and
sub-slices inherit the flag, and the ``.read_only`` property reports it.
- **`Matrix.row_views()` / `Matrix.column_views()`** — iterators that yield a
fresh :class:`MatrixView` over each row / column in turn, a cleaner spelling
of ``for i in range(m.rows): m.row_view(i)``. Pass ``read_only=True`` for
immutable per-row / per-column iteration.
- **`MatrixView.x` / `.y` / `.z` / `.w`** — get / set the first four elements
of a view, honouring its offset and stride so a column or reversed view
reads and writes the correct cells (mirroring the :class:`Matrix`
accessors).
- **`Matrix.bin(bins, *, bounds=None, right=False, in_place=False, out=None)`**
— map every element to its equal-width bin index over a range. ``bounds`` is
an optional ``(min, max)`` tuple of finite numbers that fixes the range and
skips the ``O(size)`` min/max scan; when omitted the range is the matrix's
own min/max (ignoring NaN). Indices clamp to ``[0, bins-1]`` (so a value at
the max lands in the last bin) and a ``NaN`` element maps to ``-1``.
Clip-family output routing: ``in_place`` / keyword-only ``out`` are mutually
exclusive.
- **`Matrix.digitize(edges, *, right=False, in_place=False, out=None)`** — the
``numpy.digitize`` map: each element to the index of the interval bounded by
a strictly increasing *edges* vector (a :class:`Matrix` vector or any
coercible sequence). ``right`` selects the boundary side; a ``NaN`` element
maps to ``-1``. Same output routing as ``bin``.

**Improvements**

- **Allocation-free `out=` on gathers** — :meth:`Matrix.take` and
:meth:`Matrix.take_along_axis` accept a keyword-only ``out=`` :class:`Matrix`
to write into, rejected if it aliases the source (a reordering gather would
read cells it had already overwritten).
- **`Matrix.clip` gains `in_place=` / `out=`** — mutate in place or write into
a caller-supplied matrix, mutually exclusive.
- **Slice subscripts always return a `Matrix`** — a slice anywhere in the key
(even a length-1 one, e.g. ``m[0:1, 0:1]``) keeps the result a
:class:`Matrix`; only an all-integer key collapses to a ``float`` scalar.
- **Reproducible, race-free Matrix RNG** — :meth:`Matrix.normal` /
:meth:`Matrix.uniform` / :meth:`Matrix.seed` now draw from a per-interpreter
splitmix64 generator held in the module state instead of the process-global
C ``rand()`` / ``srand()``. Each worker sub-interpreter gets an independent
stream, so ``seed()`` is reproducible within an interpreter and concurrent
draws are no longer a data race on non-glibc or free-threaded builds.

**Bug Fixes**

- **Noticeboard Matrix-entry segfault** — the noticeboard mutator thread (a
second OS thread in the primary interpreter that never ran the ``_math``
module init) crashed dereferencing a NULL cached module-state pointer while
reconstructing a Matrix-valued noticeboard entry during a ``notice_update``
snapshot. The main interpreter's module state is now published at module
exec (``MAIN_MATH_STATE``) and resolved on that thread; a regression test
exercises both the XIData-reconstruction and pickle paths on the mutator
thread.
- **`repeat_interleave` integer-overflow → out-of-bounds heap write** — the
overflow guard bounded only the repeated dimension, so a large ``repeats``
with a small repeated axis could wrap the ``rows * columns * repeats``
product and under-allocate. The guard now bounds the total element count,
and ``impl_new`` re-checks the ``rows * columns`` product as a backstop for
every constructor; both raise ``OverflowError`` instead of corrupting the
heap.
- **`Matrix.allclose` missing acquisition guard** — the only data-touching
method that dereferenced both operands' buffers without an
``impl_check_acquired`` check now raises ``RuntimeError`` on a released or
cross-interpreter operand instead of reading memory it does not own.
- **NULL-deref under memory pressure** — the ``Matrix`` subscript / item paths
now check the ``impl_new`` result before use.
- **Empty cown-list `@when` group crashed or dropped a parameter** — passing an
empty list to :func:`when` (e.g. ``@when(a, [])`` or ``@when([], b)``) dropped
the corresponding behavior parameter: a trailing empty group raised
``TypeError`` (argument tuple too small) and a leading or middle empty group
left a ``NULL`` argument slot that segfaulted the call. Empty groups now
reconstruct as ``[]`` at their correct parameter slot, and the low-level
``BehaviorCapsule`` constructor validates the ``group_id`` / slot-count
relationship so a malformed capsule can no longer drive an out-of-bounds
argument-tuple write.

**Breaking Changes**

- **`in_place` is keyword-only on the unary element-wise methods** —
``ceil`` / ``floor`` / ``round`` / ``negate`` / ``abs`` / ``sqrt`` /
``sign`` / ``cos`` / ``sin`` no longer accept ``in_place`` positionally,
matching :meth:`Matrix.clip`. Replace ``m.negate(True)`` with
``m.negate(in_place=True)``.

**Documentation**

- Expanded the :doc:`api` matrix surface via the ``__init__.pyi`` stub
docstrings for the new methods and parameters, including ``@overload``
signatures for the polymorphic ``topk`` / ``argmin`` / ``argmax`` return
types. The :class:`Matrix` class docstring now states the output-routing
convention (which methods offer ``out=`` vs ``in_place=``) and the
masked-reduction empty-group sentinel table in one place.
- Documented the :class:`MatrixView` surface (construction, aliasing,
not-shippable semantics, ``.x``/``.y``/``.z``/``.w`` accessors), the
``row_views`` / ``column_views`` iterators, and ``Matrix.bin`` /
``Matrix.digitize`` via the ``__init__.pyi`` stubs and the :doc:`api` page,
and switched the boids example to zero-copy ``row_view`` / ``row_views`` for
its per-boid coordinate reads.

**Tests**

- Added golden and fuzzed coverage for ``topk`` (list and matrix index forms),
``take_along_axis`` / ``put_along_axis``, ``repeat_interleave`` (including
the overflow regression), masked reductions and arg-reductions, the
``values()`` iterator, ``sign`` / ``cos`` / ``sin``, slice-forces-matrix,
and the ``allclose`` acquisition guard. Added ``TestFactoriesOnWorker`` to
exercise the per-interpreter RNG from inside ``@when`` behaviors, and a
``TestNoticeboardMatrixEntry`` regression class for the mutator-thread fix.
- Added construction, read/write, aliasing, arithmetic-materialisation,
ownership-guard, not-shippable, strided-accessor, and fuzzed coverage for
:class:`MatrixView` and the ``row_views`` / ``column_views`` iterators, and
golden + fuzzed (vs a pure-Python oracle) coverage for ``bin`` (data and
fixed ``bounds``, non-finite-bounds rejection, ``NaN`` → ``-1``) and
``digitize`` (``right`` sides, monotonic-edge validation, in-place edge
aliasing).

**Internal**

- Reworked the RNG onto a per-interpreter splitmix64 state seeded at module
exec, and stopped caching the published main-interpreter state into a
cold thread's thread-local so a torn-down module surfaces a clean
``RuntimeError`` rather than a dangling pointer.
- De-duplicated the three ``topk`` axis branches into a single group loop
behind ``topk_gather_group`` / ``topk_write_group`` / ``topk_pack_result``,
and extracted the shared gather output-allocation + alias-check block
(``gather_output``) used by the whole-axis and along-axis gathers.

## 2026-06-24 - Version 0.13.0
A `Matrix` ergonomics release. The dense matrix type gains named method forms
of the arithmetic operators, a numpy-style ``out=`` target on every
Expand Down
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ authors:
given-names: "Matthew Alastair"
orcid: "https://orcid.org/0000-0002-1019-8036"
title: "bocpy"
version: 0.13.0
date-released: 2026-06-24
version: 0.14.0
date-released: 2026-07-02
url: "https://github.com/microsoft/bocpy"
9 changes: 4 additions & 5 deletions examples/boids.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def avoid_others(neighbors: Matrix, pos: Matrix,
bounds = BoundingBox(left, top, right, bottom)

move = Matrix.vector([0, 0])
for npos in neighbors:
for npos in neighbors.row_views():
if bounds.is_outside(npos.x, npos.y):
continue

Expand Down Expand Up @@ -263,7 +263,7 @@ def spatial_hashing(self, positions: Matrix):

self.grid_cells.clear()

for i, pos in enumerate(positions):
for i, pos in enumerate(positions.row_views()):
r = int_coord(pos.y, self.spacing)
c = int_coord(pos.x, self.spacing)
self.grid_cells.add(Cell(r, c))
Expand Down Expand Up @@ -303,9 +303,8 @@ def build_cell_data(self, positions: Matrix, velocities: Matrix, row: int, colum
start = self.cell_start[h]
end = self.cell_start[h + 1]
boids = []
for i in range(start, end):
b = self.cell_entries[i]
pos = positions[b]
for b in self.cell_entries[start:end]:
pos = positions.row_view(b)
if box.is_outside(pos.x, pos.y):
continue

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "bocpy"
version = "0.13.0"
version = "0.14.0"
authors = [
{name = "bocpy Team", email="bocpy@microsoft.com"}
]
Expand Down
52 changes: 52 additions & 0 deletions sphinx/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,58 @@ Math
:special-members: __init__, __eq__, __ne__, __lt__, __le__, __gt__, __ge__, __getitem__, __setitem__


Matrix views
^^^^^^^^^^^^

:meth:`Matrix.row_view`, :meth:`Matrix.column_view`, and the ``m.view[...]``
accessor return a :class:`MatrixView` — a read/write, 1-D window that
**shares** the matrix's storage instead of copying it. This is the
borrow-by-view counterpart to plain subscripting (``m[i]`` / ``m[i, j]`` /
``m[rows, cols]``), which always **copies**::

m = Matrix(3, 4, [float(i) for i in range(12)])

row = m.row_view(1) # borrow: aliases m's storage
row[0] = 99.0 # writes THROUGH to m[1, 0]
col = m.view[:, 2] # slice-notation borrow of column 2
snap = m[1] # copy: an independent 1 x 4 Matrix

Key semantics:

* **Aliasing / write-through.** Reading or writing a view element touches the
source matrix directly (``view[i] = x``, ``view[:] = scalar``,
``view[:] = sequence``). Sub-views (``view[1:3]``) and the free transpose
(``view.T``, an O(1) orientation flip) alias the same storage.
* **Copy vs borrow.** ``m[...]`` copies; ``m.row_view`` / ``m.column_view`` /
``m.view[...]`` borrow. Use :meth:`MatrixView.copy` (or
:meth:`Matrix.from_view`) to materialise an independent owned
:class:`Matrix`.
* **Iterating views.** :meth:`Matrix.row_views` / :meth:`Matrix.column_views`
yield a fresh borrow over each row / column in turn — a cleaner spelling of
``for i in range(m.rows): m.row_view(i)``. Each yielded view is a distinct
object (never a reused cursor), so ``list(m.row_views())`` behaves like any
other iterator. Plain ``for row in m`` still **copies** each row.
* **Whole-buffer lifetime pin.** A view keeps the *entire* backing matrix
alive for as long as the view exists, even a one-element span.
* **Not shippable.** A view cannot be pickled, sent, or placed in a
:class:`Cown`; doing so raises :class:`TypeError` naming the ``.copy()``
remedy. ``.copy()`` yields an owned, shippable matrix.
* **Arithmetic materialises.** ``+``, ``-``, ``*``, ``/``, ``@``, unary ``-``,
and ``abs()`` on a view return a fresh owned :class:`Matrix`; augmented
``view += x`` therefore rebinds the name to a new matrix rather than writing
through.
* **Column views are strided.** A column view materialises (for arithmetic or
``.copy()``) by gathering strided elements, so it costs a little more than a
contiguous row view.
* **Ownership.** Every value access requires the current interpreter to own
the source matrix; a released base raises :class:`RuntimeError`.

.. autoclass:: MatrixView
:members:
:undoc-members:
:special-members: __len__, __getitem__, __setitem__


Messaging
---------

Expand Down
2 changes: 1 addition & 1 deletion sphinx/source/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
project = 'bocpy'
copyright = '2026, Microsoft'
author = 'Microsoft'
release = '0.13.0'
release = '0.14.0'

# -- General configuration ---------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
Expand Down
5 changes: 3 additions & 2 deletions src/bocpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import sys

from ._core import drain, receive, send, set_tags, TIMEOUT
from ._math import Matrix
from ._math import Matrix, MatrixView
from .behaviors import (Behaviors, Cown, notice_delete, notice_read,
notice_seed, notice_update, notice_write, noticeboard,
PinnedCown, pump, PumpResult, quiesce,
Expand Down Expand Up @@ -67,7 +67,8 @@ def get_sources() -> list[str]:
return []


__all__ = ["Behaviors", "Cown", "Matrix", "PinnedCown", "PumpResult",
__all__ = ["Behaviors", "Cown", "Matrix", "MatrixView", "PinnedCown",
"PumpResult",
"REMOVED", "TIMEOUT",
"WORKER_COUNT", "__version__", "drain",
"get_include", "get_sources",
Expand Down
Loading
Loading