# SPDX-License-Identifier: Apache-2.0
# Copyright 2026 Andrew Hendel. Part of mojolearn, https://doi.org/10.5281/zenodo.22068632
# References: `NearestNeighbors` follows cuVS's brute force;
# `KNeighborsClassifier` and `KNeighborsRegressor` follow cuML's
# `kneighbors_classifier.pyx` / `kneighbors_regressor.pyx` over
# `ML::knn_classify` / `ML::knn_regress` (`neighbors/impl/knn/knn.mojo`,
# `neighbors/impl/selection/knn.mojo`).
"""Exact nearest-neighbor search on the GPU, and the k-NN classifier,
k-NN regressor and radius search built on it.
`NearestNeighbors` follows cuVS's brute force; `KNeighborsClassifier` and
`KNeighborsRegressor` follow cuML's estimators of the same names;
`RadiusNeighbors` returns every neighbor within a radius over a random ball
cover. Every search is exact, never approximate.
"""
from . import _portable_math as math
from . import _mojolearn, _serialize
from ._array import Array
from ._buffer import addr, addr_ro, as_f32_c, as_f32_dense_c, as_i64_c, empty
from ._labels import encode_labels, sorted_classes
from ._mode import NumericModeMixin
from .linear_model import (
_check_saved_by, _dtype_name, _is_integer_labels, _restore_mode,
_saved_mode, _shape_of,
)
#: the x_neighbors binding, which holds the p2m_* ops the data loops below
#: run through (lane apple-fast-py2mojo-neighbors)
_XN = "_mojolearn_x_neighbors"
_DEFAULT_QUERY_TILE = 0 # Ask the compiled planner for its measured default.
#: The model file format (the knn host inference lane, 2026-09-14). `save`
#: writes exactly what `kneighbors` / `predict` read, raw bytes and exact
#: dtypes, through `_serialize.write_npz`, so equal models give equal file
#: hashes on every machine; `load` refuses a cast (`_serialize.exact`). The
#: index is the fitted `<f4` `(n_samples_fit_, n_features_in_)` block, the
#: classifier's labels the `<i4` `(n_outputs, n_samples_fit_)` column block
#: the binding takes (policy 6), the regressor's the same in `<f4`; `p`
#: travels as `<f8` and the three names (`metric`, `algorithm`, `weights`)
#: as text. `mojolearn.host_model(path)` predicts from the file on a CPU
#: with no GPU through `bindings/_mojolearn_core_host.mojo`.
_KNN_FORMAT = "mojolearn-knn-1"
_KNN_META_FIELDS = 6 # n_features_in_, n_samples_fit_, n_neighbors, query_tile, outputs_2d, n_outputs
#: RadiusNeighbors' model file (the neighbors and density inference lane,
#: 2026-09-15): the `<f4` index as fitted, `radius`, `p` as `<f8`, `metric`
#: and `algorithm` as text, `meta` `<i8` [n_features_in_, n_samples_fit_].
#: What `radius_neighbors` reads and nothing else; `mojolearn.host_model`
#: queries it on a CPU through the core host binding's
#: radius_neighbors_count and radius_neighbors_fill.
_RADIUS_FORMAT = "mojolearn-radius-1"
def _knn_arrays(est):
"""The members every k-NN `save` writes; the subclasses add theirs."""
if est._index is None:
raise RuntimeError("this estimator is not fitted yet")
weights = getattr(est, "weights", "uniform")
weights = "uniform" if weights is None else weights
if not isinstance(weights, str):
# `fit` refuses a callable by name before the index is held, so
# this is unreachable on a fitted estimator; a file never carries
# a function.
raise ValueError(
f"mojolearn {type(est).__name__}: weights={weights!r} cannot be saved"
)
y_cols = getattr(est, "_y_cols", None)
n_out = 1 if y_cols is None else int(y_cols.shape[0])
return {
"format": _KNN_FORMAT,
"estimator": type(est).__name__,
"numeric_mode": _saved_mode(est),
"metric": str(est.metric),
"algorithm": str(est.algorithm),
"weights": weights,
"p": Array.from_list([float(est.p)], "<f8"),
"index": est._index,
"meta": Array.from_list(
[int(est.n_features_in_), int(est.n_samples_fit_),
int(est.n_neighbors), int(est.query_tile),
1 if getattr(est, "outputs_2d_", False) else 0, n_out],
"<i8",
),
}
def _load_knn(cls, path):
"""The index and the parameters every k-NN `load` reads. Returns the
estimator (fitted as far as `NearestNeighbors.fit` fits it), the
decoded members, and `(outputs_2d, n_outputs)` for the subclasses."""
arrays = _serialize.read_npz(path, _KNN_FORMAT)
_check_saved_by(arrays, path, cls)
meta = _serialize.exact(arrays, "meta", "<i8")
if meta.size != _KNN_META_FIELDS:
raise ValueError(
f"mojolearn: {path!r} meta holds {meta.size} fields, "
f"{_KNN_META_FIELDS} are needed"
)
nf, ns, k, qt, o2d, n_out = (int(meta[i]) for i in range(_KNN_META_FIELDS)) # glue: six saved metadata fields
p = _serialize.exact(arrays, "p", "<f8")
if p.size != 1:
raise ValueError(f"mojolearn: {path!r} p holds {p.size} values, 1 is needed")
kwargs = dict(
n_neighbors=k, query_tile=qt,
metric=_serialize.scalar_str(arrays, "metric"),
algorithm=_serialize.scalar_str(arrays, "algorithm"),
p=float(p[0]),
)
if cls._HAS_WEIGHTS:
kwargs["weights"] = _serialize.scalar_str(arrays, "weights")
obj = cls(**kwargs)
_restore_mode(obj, arrays)
# The refusals `fit` raises, raised here too, so a file naming a
# metric or an algorithm this class refuses is refused by name at load.
obj._check_refusals()
index = _serialize.exact(arrays, "index", "<f4")
if index.ndim != 2 or index.shape[0] != ns or index.shape[1] != nf:
raise ValueError(
f"mojolearn: {path!r} index has shape {tuple(index.shape)}, meta says ({ns}, {nf})"
)
if ns < 1 or nf < 1:
raise ValueError(f"mojolearn: {path!r} holds an empty index")
obj._index = index
obj.n_samples_fit_ = ns
obj.n_features_in_ = nf
obj.used_query_tile_ = None
return obj, arrays, (bool(o2d), n_out)
def _load_y_cols(arrays, path, dtype, ns, n_out):
y_cols = _serialize.exact(arrays, "y_cols", dtype)
if y_cols.ndim != 2 or y_cols.shape[0] != n_out or y_cols.shape[1] != ns:
raise ValueError(
f"mojolearn: {path!r} y_cols has shape {tuple(y_cols.shape)}, "
f"meta says ({n_out}, {ns})"
)
return y_cols
#: cuVS `DistanceType` values (`cuvs/distance/distance.h:22-69`), mirrored
#: from `neighbors/impl/distance/detail/distance_ops.mojo`. The value gaps
#: are theirs.
_DIST_L2_EXPANDED = 0
_DIST_L2_SQRT_EXPANDED = 1
_DIST_COSINE_EXPANDED = 2
_DIST_L1 = 3
_DIST_L2_SQRT_UNEXPANDED = 5
_DIST_INNER_PRODUCT = 6
_DIST_LINF = 7
_DIST_CANBERRA = 8
_DIST_LP_UNEXPANDED = 9
_DIST_CORRELATION_EXPANDED = 10
_DIST_BRAY_CURTIS = 14
_DIST_JENSEN_SHANNON = 15
#: THE BALL COVER'S OWN TABLE, and it is NOT `_METRIC_TABLE`.
#:
#: Two rows differ, both for reasons that are about the INDEX and not about
#: the metric's name (`neighbors/impl/ball_cover/common.mojo`,
#: DEVIATION 564 and DEVIATION 565).
#:
#: 'euclidean' / 'l2' goes to L2SqrtUNexpanded here where the brute-force
#: table sends it to L2SqrtEXPanded. The cover's index
#: stores `R_1nn_dists` and `R_radius` computed by
#: summing differences directly, and the query compares
#: against them; computing one side by the expanded
#: identity `||a||^2 + ||b||^2 - 2ab` and the other
#: directly permits a boundary case where a point is
#: inside by one formula and outside by the other
#: (DEVIATION 2 in `ball_cover.mojo`).
#: 'sqeuclidean' is ABSENT. Squared Euclidean distance is not a
#: metric even though Euclidean distance is: on three
#: collinear points at unit spacing it gives 4 against
#: 1 + 1. The pruning here IS the triangle inequality,
#: so admitting it would prune true neighbours
#: silently. 'cosine' is absent for the same class of
#: reason.
_RBC_METRIC_TABLE = {
"euclidean": _DIST_L2_SQRT_UNEXPANDED,
"l2": _DIST_L2_SQRT_UNEXPANDED,
"cityblock": _DIST_L1,
"l1": _DIST_L1,
"manhattan": _DIST_L1,
"taxicab": _DIST_L1,
"chebyshev": _DIST_LINF,
"linf": _DIST_LINF,
"minkowski": _DIST_LP_UNEXPANDED,
"lp": _DIST_LP_UNEXPANDED,
}
#: cuML's `NearestNeighbors._build_metric_type` (`nearest_neighbors.pyx:
#: 520-553`), the rows this tree computes. NOT the `pairwise_distances`
#: table -- cuML has two and they DISAGREE about "euclidean" (that one
#: sends it to L2SqrtUnexpanded, this one to L2SqrtExpanded). KDE goes
#: through the other; `python/mojolearn/density.py` has its own.
_METRIC_TABLE = {
"euclidean": _DIST_L2_SQRT_EXPANDED,
"l2": _DIST_L2_SQRT_EXPANDED,
"sqeuclidean": _DIST_L2_EXPANDED,
"cityblock": _DIST_L1,
"l1": _DIST_L1,
"manhattan": _DIST_L1,
"taxicab": _DIST_L1,
"chebyshev": _DIST_LINF,
"linf": _DIST_LINF,
"cosine": _DIST_COSINE_EXPANDED,
"minkowski": _DIST_LP_UNEXPANDED,
"lp": _DIST_LP_UNEXPANDED,
# 2026-09-27 (lane x-neighbors-metrics): distance_ops.mojo::
# extra_metric_cell, one thread per cell on the device and the same
# function on the host.
"canberra": _DIST_CANBERRA,
"braycurtis": _DIST_BRAY_CURTIS,
"correlation": _DIST_CORRELATION_EXPANDED,
"jensenshannon": _DIST_JENSEN_SHANNON,
"inner_product": _DIST_INNER_PRODUCT,
}
#: Names in cuML's `VALID_METRICS["brute"]` (`neighbors/__init__.py:27-48`)
#: that this tree does not compute. Refused BY NAME so a caller learns the
#: metric is UNIMPLEMENTED rather than unknown. haversine needs arcsin, and
#: `checks/numerics.mojo` carries no pinned arcsin: a vendor asin is a
#: last-bit vendor choice (IDENTITY_PATHS row 12), so it cannot be one
#: arithmetic on every column.
_UNSUPPORTED_METRICS = (
"haversine",
)
#: The metrics that need X >= 0 (their logarithms), refused on negative
#: input as scipy's jensenshannon is undefined there.
_NONNEGATIVE_METRICS = frozenset({_DIST_JENSEN_SHANNON})
def _check_metric_input(cls_name, value, x):
"""Refuse input a metric is undefined on, before any device work."""
if value in _NONNEGATIVE_METRICS and x.size and float(x.min()) < 0.0:
raise ValueError(
f"mojolearn {cls_name}: metric='jensenshannon' needs X >= 0 "
"(it takes logarithms of the coordinates)"
)
_WEIGHTS_UNIFORM = 0
_WEIGHTS_DISTANCE = 1
_WEIGHTS_TABLE = {"uniform": _WEIGHTS_UNIFORM, "distance": _WEIGHTS_DISTANCE}
def _resolve_weights(cls_name, weights):
"""`weights` -> its value for the Mojo boundary.
scikit-learn also accepts `None` (treated as uniform, `_base.py:92`)
and a CALLABLE (`:116`). `None` is honored; the callable is refused by
name, and that refusal is one of the two kinds that are still
legitimate -- it is genuinely impossible here, because a Python
function cannot be called from inside a GPU kernel and there is no
portable way to lift one there.
"""
if weights is None:
return _WEIGHTS_UNIFORM
if callable(weights):
raise ValueError(
f"mojolearn {cls_name}: weights=<callable> is NOT IMPLEMENTED. "
"scikit-learn calls it on the distance matrix in Python; the "
"vote here runs in a GPU kernel and there is no portable way "
"to lift a Python function into one. Use 'uniform' or "
"'distance'."
)
if not isinstance(weights, str) or weights not in _WEIGHTS_TABLE:
raise ValueError(
f"mojolearn {cls_name}: weights={weights!r} is not a "
"weighting; use 'uniform' or 'distance'"
)
return _WEIGHTS_TABLE[weights]
def _refuse_inert_p(cls_name, metric, p):
"""A `p` the resolved op never reads is refused by name.
Only metric='minkowski'/'lp' reads `p`; cuML passes `self.p` for every
metric (`nearest_neighbors.pyx:852-854`) and every other op discards it.
Accepting `metric='euclidean', p=3` and running Euclidean is a knob that
changes nothing, so any value but the constructor default (2) under
another metric raises (the claim-surface census, 2026-09-14; the same
rule as GradientBoosting's `bagging_temperature` and `subsample`).
"""
try:
is_default = not isinstance(p, bool) and float(p) == 2.0
except (TypeError, ValueError):
is_default = False
if not is_default:
raise ValueError(
f"mojolearn {cls_name}: p is read only by metric='minkowski' "
f"(or 'lp'); metric={metric!r} never reads it, so p={p!r} would "
"change nothing. Leave p at its default (2) or ask for "
"metric='minkowski'."
)
def _resolve_metric(cls_name, metric, p):
"""`(metric_value, metric_arg)` for the Mojo boundary.
scikit-learn's `metric='minkowski', p=2` IS Euclidean and sklearn
collapses it (`effective_metric_`); cuML does NOT (`:1016-1017` just
echoes `self.metric`), so `metric='minkowski', p=2` goes through the
Lp op there and here. That is deliberate: running a DIFFERENT op for
p=2 would make two spellings share one code path and hide a bug in
whichever of them is unexercised. The Lp op at p=2 agrees with
Euclidean to a few ulp, which is what `neighbors/checks/
metric_check.mojo::check_metric_arg_is_reached` clause 3 measures.
"""
if not isinstance(metric, str):
raise ValueError(
f"mojolearn {cls_name}: metric must be a name, got {metric!r}. "
"A callable metric is not implemented: it would have to run inside a "
"GPU kernel."
)
key = metric.lower()
if key in _METRIC_TABLE:
value = _METRIC_TABLE[key]
elif key in _UNSUPPORTED_METRICS:
raise ValueError(
f"mojolearn {cls_name}: metric={metric!r} is in cuML's "
"VALID_METRICS['brute'] but is NOT IMPLEMENTED "
"(neighbors/NOT_IMPLEMENTED.tsv). Implemented: "
+ ", ".join(sorted(_METRIC_TABLE)) # glue: metric names for the error message
)
else:
raise ValueError(
f"mojolearn {cls_name}: unknown metric {metric!r}. Implemented: "
+ ", ".join(sorted(_METRIC_TABLE)) # glue: metric names for the error message
)
if value != _DIST_LP_UNEXPANDED:
_refuse_inert_p(cls_name, metric, p)
arg = 2.0
if value == _DIST_LP_UNEXPANDED:
arg = float(p)
# DEVIATION 552, mirrored here so the message names the Python
# parameter the caller actually typed. The Mojo side refuses the
# same set by value before any launch.
if not (arg > 0.0) or arg != arg or arg == float("inf"):
raise ValueError(
f"mojolearn {cls_name}: metric='minkowski' needs a finite "
f"p > 0, got {p!r}. p = 0 makes 1/p infinite, p < 0 is not "
"a metric, and p = infinity is metric='chebyshev'."
)
if arg < 1.1754943508222875e-38:
raise ValueError(
f"mojolearn {cls_name}: metric='minkowski' p={p!r} is "
"subnormal in float32; the flush policy differs by vendor "
"so this cannot be one arithmetic (DEVIATION 552)"
)
return value, arg
def _resolve_rbc_metric(cls_name, metric, p):
"""`(metric_value, metric_arg)` for anything running on the BALL COVER.
THE REFUSAL HERE IS NARROWER THAN IT WAS, AND THE ARGUMENT DECIDES THE
WIDTH. Until 2026-09-01 this index admitted Euclidean and nothing else,
with the correct reason attached: the random ball cover's pruning IS the
triangle inequality on the landmark radii, so admitting a non-metric
would silently prune away true neighbours rather than return them
slowly. That argument is sound and it is kept. What it does NOT cover is
every metric it was refusing.
Minkowski at p >= 1 IS a true metric -- that is Minkowski's inequality,
which is what the name refers to -- so an Lp ball cover at p >= 1 is
sound, and p = 1 is Manhattan. Chebyshev is the p -> infinity limit and
is a metric too. So those are admitted, exactly and exhaustively gated
by `neighbors/checks/ball_cover_knn_check.mojo` against a host brute
force with no tolerance.
What stays refused is what genuinely fails the inequality:
cosine `1 - cos` is not a metric. Three unit vectors at 0, 60
and 120 degrees give 1.5 against 0.5 + 0.5. It also
fails identity of indiscernibles: d(x, 2x) = 0, so two
distinct points sit at distance zero and a landmark
radius stops bounding its ball.
Lp at p < 1 |x|^p is not subadditive below p = 1. At p = 1/2 the
points (0,0), (1,0), (1,1) give a direct distance of 4
against a two-leg path of 1 + 1.
sqeuclidean d^2 is not a metric even though d is: three collinear
points at unit spacing give 4 against 1 + 1.
cuML draws its line in the same place from the same side and lands
somewhere narrower still (`VALID_METRICS["rbc"]` is
`{euclidean, haversine, l2}`), which is their scope choice and not an
argument about the inequality.
"""
if not isinstance(metric, str):
raise ValueError(
f"mojolearn {cls_name}: metric must be a name, got {metric!r}"
)
key = metric.lower()
if key in ("sqeuclidean",):
raise ValueError(
f"mojolearn {cls_name}: metric='sqeuclidean' is REFUSED on the "
"random ball cover. The pruning here IS THE TRIANGLE "
"INEQUALITY on the landmark radii "
"(neighbors/impl/ball_cover/common.mojo, DEVIATION "
"565), and SQUARED Euclidean distance does not satisfy it even "
"though Euclidean distance does: on three collinear points at "
"unit spacing it gives 4 against 1 + 1. A cover built on it "
"would prune away true neighbours silently. Ask for "
"metric='euclidean' and square the returned distances, which "
"is the same answer and is exact."
)
if key == "cosine":
raise ValueError(
f"mojolearn {cls_name}: metric='cosine' is REFUSED on the "
"random ball cover. The pruning here IS THE TRIANGLE "
"INEQUALITY on the landmark radii, and `1 - cos` does not "
"satisfy it: three unit vectors at 0, 60 and 120 degrees give "
"1.5 against 0.5 + 0.5. It also fails identity of "
"indiscernibles, d(x, 2x) = 0, so a landmark radius stops "
"bounding its ball. A cover built on it would prune away true "
"neighbours silently rather than return them slowly. Use "
"NearestNeighbors with algorithm='brute', which needs no "
"inequality and honors every implemented metric."
)
if key in _UNSUPPORTED_METRICS:
raise ValueError(
f"mojolearn {cls_name}: metric={metric!r} is in cuML's "
"VALID_METRICS['brute'] but is NOT IMPLEMENTED "
"(neighbors/NOT_IMPLEMENTED.tsv)"
)
if key in _METRIC_TABLE and key not in _RBC_METRIC_TABLE:
raise ValueError(
f"mojolearn {cls_name}: metric={metric!r} is computed by brute "
"force only (NearestNeighbors(algorithm='brute')). The random "
"ball cover's pruning is the triangle inequality on its "
"landmark radii and its kernels carry the Minkowski family; "
"cuML's own VALID_METRICS['rbc'] is {euclidean, haversine, l2}"
)
if key not in _RBC_METRIC_TABLE:
raise ValueError(
f"mojolearn {cls_name}: unknown metric {metric!r}. The random "
"ball cover admits: " + ", ".join(sorted(_RBC_METRIC_TABLE)) # glue: metric names for the error message
)
value = _RBC_METRIC_TABLE[key]
if value != _DIST_LP_UNEXPANDED:
_refuse_inert_p(cls_name, metric, p)
arg = 2.0
if value == _DIST_LP_UNEXPANDED:
arg = float(p)
if arg != arg or arg == float("inf"):
raise ValueError(
f"mojolearn {cls_name}: metric='minkowski' needs a finite "
f"p >= 1, got {p!r}. p = infinity is metric='chebyshev', "
"which IS admitted here."
)
if arg < 1.0:
raise ValueError(
f"mojolearn {cls_name}: metric='minkowski' with p={p!r} is "
"REFUSED on the random ball cover. The pruning here IS THE "
"TRIANGLE INEQUALITY on the landmark radii "
"(neighbors/impl/ball_cover/common.mojo, "
"DEVIATION 564), and |x|^p is not subadditive below p = 1, "
"so Lp at p < 1 is not a metric at all: at p = 1/2 the "
"points (0,0), (1,0), (1,1) give a direct distance of 4 "
"against a two-leg path of 1 + 1. p >= 1 is admitted and "
"p = 1 is Manhattan. Use NearestNeighbors with "
"algorithm='brute' for p < 1, which is exact brute force "
"and needs no inequality."
)
return value, arg
# Maintainer notes (moved from the docstring):
# - Reference: cuVS's fused L2 kernel. Exact by deliberate scope: an
# approximate index is a different algorithm with a different contract.
# - Honored/refused rows were measured row by row by `tools/e2u_matrix_fit.py`.
# - n_neighbors above n_samples_fit is refused: the reference's short-index
# fill is not implemented (knn_brute_force.mojo). Under
# IDENTICAL/DETERMINISTIC a k above 1024 selects in rounds of 1024 and
# merges index tiles by binary search (DEVIATION 6300,
# neighbors/checks/select_radix_identical.mojo); the answer is the same
# ascending (distance, index) list. FAST runs k > 256 through the radix
# select (reference: RAFT).
# - query_tile is a memory number only (`check_knn_tiled_is_query_tile_
# invariant`). The planner: NVIDIA IDENTICAL starts at 512, other modes and
# columns at 256.
# - metric: the rows of cuML's `_build_metric_type`
# (nearest_neighbors.pyx:520-553) this tree computes, plus the extra
# metrics (distance_ops.mojo::extra_metric_cell); haversine is refused by
# name (no pinned arcsin).
# - p: refused at p <= 0, inf, NaN and subnormal (DEVIATION 552 -- 1/p and
# the vendor flush policy). cuML passes self.p for every metric
# (nearest_neighbors.pyx:852-854) and every non-Lp op discards it; here a
# non-default p under another metric is refused rather than ignored.
# - algorithm 'rbc': exact k-NN over a random ball cover (DEVIATIONS 558 to
# 567). 'kd_tree' / 'ball_tree' are an engineering refusal (see the
# message in `_check_refusals`).
# - The arm (fused vs tiled, `KNN_METHOD_AUTO`) is not a parameter by design
# (POLICY CHOICE 4); under NUMERIC_IDENTICAL AUTO is pinned to the tiled
# arm on every column (DEVIATION 509).
[docs]
class NearestNeighbors(NumericModeMixin):
"""Exact k-nearest-neighbor search.
Every query is compared against every fitted point (``algorithm='brute'``,
the default), or searched over an exact random ball cover index
(``algorithm='rbc'``). Both return the exact neighbors; there is no
approximate index and no recall to trade away. Input is converted to
float32. Neighbors come back nearest first, with ties in distance broken
by the lower index.
Parameters
----------
n_neighbors : int, default 5
Number of neighbors returned by `kneighbors`, scikit-learn's default.
Must be between 1 and the number of fitted samples; a larger value
raises ``ValueError``.
query_tile : int, default 0
Queries processed per pass. It changes memory use only, never the
answer. ``0`` lets the library choose, within its workspace limit and
clamped to the query count; a positive value requests a fixed
starting size. ``used_query_tile_`` reports the size that ran.
metric : str, default 'euclidean'
Distance, by name. With ``algorithm='brute'`` or ``'auto'``:
'euclidean'/'l2', 'sqeuclidean', 'manhattan'/'l1'/'cityblock'/
'taxicab', 'chebyshev'/'linf', 'cosine', 'minkowski'/'lp',
'canberra', 'braycurtis', 'correlation', 'jensenshannon' (needs
``X >= 0``) and 'inner_product' (a similarity: larger is nearer, and
`kneighbors` returns the products themselves). With
``algorithm='rbc'``, only true metrics: 'euclidean'/'l2',
'manhattan'/'l1'/'cityblock'/'taxicab', 'chebyshev'/'linf' and
'minkowski'/'lp' with ``p >= 1``. 'haversine', a callable and
unknown names raise ``ValueError``.
algorithm : {'brute', 'auto', 'rbc'}, default 'brute'
'brute' and 'auto' (which means 'brute') compare every pair. 'rbc'
searches an exact random ball cover, whose pruning rests on the
triangle inequality, so it accepts only true metrics. 'kd_tree' and
'ball_tree' raise ``ValueError``.
p : float, default 2
Minkowski exponent, read only by ``metric='minkowski'`` (or 'lp').
It must be finite and positive (and at least 1 with
``algorithm='rbc'``). Under any other metric, a value other than 2
raises ``ValueError`` instead of being ignored. Unlike scikit-learn,
``metric='minkowski', p=2`` runs the Minkowski computation rather
than switching to Euclidean; the two agree to a few units in the last
place.
numeric_mode : {'identical', 'deterministic', 'fast'}, optional
Numeric mode for this estimator; ``None`` uses the process default
(`mojolearn.set_numeric_mode`). 'fast' is available on Apple GPUs
only.
Attributes
----------
n_samples_fit_ : int
Number of fitted samples.
n_features_in_ : int
Number of features seen at `fit`.
used_query_tile_ : int or None
Query batch size the last `kneighbors` call ran with; ``None`` after
an ``algorithm='rbc'`` search.
n_candidate_distances_ : int
Set by an ``algorithm='rbc'`` search: the number of distances the
ball cover computed.
Examples
--------
>>> import numpy as np, mojolearn
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> nn = mojolearn.NearestNeighbors(n_neighbors=5).fit(X)
>>> distances, indices = nn.kneighbors(X[:10])
"""
#: This family's binding, for `NumericModeMixin._bind`.
_BINDING = "_mojolearn"
#: Whether `load` hands the file's `weights` to the constructor.
_HAS_WEIGHTS = False
[docs]
def __init__(
self,
n_neighbors=5,
query_tile=_DEFAULT_QUERY_TILE,
*,
metric="euclidean",
algorithm="brute",
p=2,
):
self.n_neighbors = n_neighbors
self.query_tile = query_tile
self.metric = metric
self.algorithm = algorithm
self.p = p
self._index = None
self.used_query_tile_ = None
# The knn host inference lane, 2026-09-14: the file holds what
# `kneighbors` reads and nothing else.
[docs]
def save(self, path):
"""Save the fitted model to an ``.npz`` file.
The file holds the float32 index as fitted and the parameters
(`n_neighbors`, `query_tile`, `metric`, `p`, `algorithm`, numeric
mode). Equal models write identical files.
`mojolearn.host_model(path)` can search the saved model on a CPU with
no GPU (brute force, 'euclidean' and 'sqeuclidean').
Parameters
----------
path : str or path-like
Destination file.
Raises
------
RuntimeError
If the model is not fitted.
"""
return _serialize.write_npz(path, _knn_arrays(self))
[docs]
@classmethod
def load(cls, path):
"""Load a model written by `save`.
Arrays are read at their saved dtypes and never cast. A file naming
a metric or algorithm this class refuses raises ``ValueError``, as
`fit` would.
Parameters
----------
path : str or path-like
File written by `save`.
Returns
-------
NearestNeighbors
A fitted model, ready for `kneighbors`.
"""
obj, _, _ = _load_knn(cls, path)
return obj
#: Subclasses override; the base class never weights.
_weights_value = _WEIGHTS_UNIFORM
def _dist_params(self):
"""`[metric, metric_arg, weights]`, the list
`bindings/_mojolearn.mojo::_dist_triple` reads. ORDER MATTERS and
is documented there."""
value, arg = _resolve_metric(type(self).__name__, self.metric, self.p)
return [value, arg, self._weights_value]
def _check_refusals(self):
"""Every refusal is BY NAME with the reason, raised at fit so a
caller learns before the index is held."""
# Resolving the metric IS the metric check: it raises by name for
# an unimplemented row of cuML's table, for an unknown name, and for a
# p that cannot be one arithmetic. One table, one place.
# By class, not by `type(self) is NearestNeighbors`: a subclass of
# NearestNeighbors alone (the host class `mojolearn.host_model`
# returns, 2026-09-15) still runs `kneighbors` and keeps the cover.
if self.algorithm == "rbc" and isinstance(self, (KNeighborsClassifier, KNeighborsRegressor)):
# REFUSED RATHER THAN ACCEPTED AND IGNORED, which is the whole
# of `reached-but-inert`. The classifier and the regressor do
# not call `kneighbors`; they call cuML's `knn_classify` /
# `knn_regress`, which do their own brute-force search inside
# one Mojo entry point (`neighbors/impl/knn/knn.mojo`). Taking
# 'rbc' here would change nothing about what runs, and a
# parameter that is honored in the signature and inert in the
# kernel is worse than one that is refused.
raise ValueError(
f"mojolearn {type(self).__name__}: algorithm='rbc' is "
"refused. The ball cover's k-NN query is wired into "
"NearestNeighbors.kneighbors only; this class runs cuML's "
"knn_classify / knn_regress, which perform their own "
"brute-force search inside one call and have no seam to "
"hand an index to. Accepting 'rbc' here would be accepting "
"a parameter that changes nothing. Use "
"NearestNeighbors(algorithm='rbc').kneighbors and vote on "
"the result, or leave this at 'brute'."
)
if (getattr(self, "weights", None) == "distance"
and isinstance(self.metric, str) and self.metric.lower() == "inner_product"):
raise ValueError(
f"mojolearn {type(self).__name__}: weights='distance' is refused "
"with metric='inner_product': it weighs a neighbour by 1 / d, "
"and an inner product is a similarity (larger is nearer, and "
"it can be zero or negative), not a distance"
)
if self.algorithm == "rbc":
# The INDEXED arm resolves against the cover's own table, which
# refuses two rows the brute-force table accepts. Resolving here
# means a caller who asks for an index under cosine learns at
# fit, not at the first query.
_resolve_rbc_metric(type(self).__name__, self.metric, self.p)
elif self.algorithm in ("brute", "auto"):
# Resolving the metric IS the metric check: it raises by name
# for an unimplemented row of cuML's table, for an unknown name, and
# for a p that cannot be one arithmetic. One table, one place.
_resolve_metric(type(self).__name__, self.metric, self.p)
else:
raise ValueError(
f"mojolearn NearestNeighbors: algorithm={self.algorithm!r} is "
"refused. Three are accepted: 'brute' (exact brute force), "
"'auto' (which means 'brute' here), and 'rbc' (exact k-NN "
"over a RANDOM BALL COVER, an index). "
"'kd_tree' and 'ball_tree' are refused, and it is an "
"ENGINEERING refusal rather than an attribution one: a "
"kd-tree query is a per-query stack walk with "
"data-dependent branching and divergent memory access, "
"which is the shape a GPU is worst at, and above roughly "
"15 dimensions the pruning bound stops firing and it "
"degenerates to a full scan, so it would lose to 'brute' "
"on exactly the workloads it claims to help. If what you "
"want is an index instead of a full scan, that is 'rbc': "
"it is EXACT, not approximate, it has no per-query stack, "
"and it is the same structure RadiusNeighbors uses "
"(neighbors/impl/ball_cover/knn.mojo)."
)
def _resident_index_handle(self, binding, idx):
"""The handle of the device-resident copy of `idx` (DEVIATION 2921),
prepared on the first call and reused while the index array keeps
its address and shape; None where the loaded binding has no
`knn_index_prepare` (the CPU host binding, a CPU-only install, a
host subclass), in which case `kneighbors` takes the per-call
upload. Never raises for a missing door: `_HostBinding` refuses an
absent name with ImportError and a plain module with
AttributeError, and both mean "no residency here"."""
try:
prepare = binding.knn_index_prepare
except (ImportError, AttributeError):
return None
key = (addr_ro(idx, name="idx"), tuple(idx.shape))
cached = getattr(self, "_resident", None)
if cached is not None and cached[0] == key:
return cached[1]
if cached is not None:
self._release_resident_index()
handle = int(prepare(key[0], [int(idx.shape[0]), int(idx.shape[1])]))
self._resident = (key, handle)
return handle
@staticmethod
def _resident_door(binding, name):
"""The binding's entry `name`, or None where the loaded binding
has no such door (`_resident_index_handle`'s rule: an absent name
is ImportError on a host binding, AttributeError on a module)."""
try:
return getattr(binding, name)
except (ImportError, AttributeError):
return None
def _release_resident_index(self):
"""Drop the device copy, if one is held. Quiet on a binding that
cannot be reached any more (interpreter shutdown) and on a handle
already released (a copied instance)."""
cached = getattr(self, "_resident", None)
if cached is None:
return
self._resident = None
try:
self._bind("_mojolearn").knn_index_release(cached[1])
except Exception: # noqa: BLE001
pass
def __del__(self):
try:
self._release_resident_index()
except Exception: # noqa: BLE001
pass
def __getstate__(self):
"""A pickle or a deepcopy carries no device handle: the integer is
meaningful only in the process and registry that minted it, and an
unpickled instance holding it could release ANOTHER model's live
index. The copy uploads its own index at its first call."""
state = self.__dict__.copy()
state.pop("_resident", None)
return state
[docs]
def fit(self, X, y=None):
"""Store the points to search.
Parameter checks run here, so an unsupported metric, algorithm or
`p` raises ``ValueError`` before any search.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Points to search; converted to float32.
y : ignored
Accepted for scikit-learn compatibility.
Returns
-------
self
"""
self._check_refusals()
# A refit drops the device copy of the previous index HERE, not at
# the next call: the copy is keyed on the array's address and shape,
# and a new array of the same shape can land at a freed address, so
# a key match after a refit would serve the old bytes.
self._release_resident_index()
idx, _ = as_f32_dense_c(X, ndim=2, name="X")
if self.algorithm != "rbc":
_check_metric_input(type(self).__name__, _resolve_metric(type(self).__name__, self.metric, self.p)[0], idx)
# Held on the instance so the memory outlives this call: the Mojo side
# borrows the address at `kneighbors` time and owns nothing.
self._index = idx
self.n_samples_fit_ = idx.shape[0]
self.n_features_in_ = idx.shape[1]
return self
# Maintainer notes (moved from the docstring): the kernel computes squared
# Euclidean distances and the square root is taken on the way out, over
# n_queries * k values. The index stays on the device (DEVIATION 2921,
# `neighbors/resident_index.mojo`): the resident search is
# `knn_search`'s body after its upload, so the bits are `knn_search`'s.
# The copy is keyed on the index array's address and shape, released at
# refit and when the instance is collected. The CPU host binding has no
# resident entry and reads the caller's memory on every call.
[docs]
def kneighbors(self, X, n_neighbors=None, return_distance=True):
"""Find the nearest fitted points to each query, nearest first.
Distances are the chosen metric's ('euclidean' returns true, not
squared, distances). Ties in distance are broken by the lower index,
matching scikit-learn's layout and ordering.
On a GPU the fitted points are copied to the device at the first call
after `fit` and reused by later calls. Changing the array passed to
`fit` in place after that is not seen; call `fit` again instead (as
with cuML, whose `fit` copies the data to the device).
Parameters
----------
X : array-like of shape (n_queries, n_features)
Query points; converted to float32.
n_neighbors : int, optional
Neighbors per query; defaults to the constructor's
`n_neighbors`. Must be between 1 and `n_samples_fit_`.
return_distance : bool, default True
Return the distances as well as the indices.
Returns
-------
distances : Array of shape (n_queries, n_neighbors), float32
Returned only when `return_distance` is True.
indices : Array of shape (n_queries, n_neighbors), int64
Row indices into the fitted data.
Raises
------
ValueError
If the model is not fitted, `X` has the wrong number of features,
or `n_neighbors` is out of range.
"""
if self._index is None:
raise ValueError("mojolearn: call fit before kneighbors")
k = self.n_neighbors if n_neighbors is None else n_neighbors
q, _ = as_f32_dense_c(X, ndim=2, name="X")
if self.algorithm != "rbc":
_check_metric_input(type(self).__name__, self._dist_params()[0], q)
if q.shape[1] != self.n_features_in_:
raise ValueError(
f"mojolearn: X has {q.shape[1]} features, index has "
f"{self.n_features_in_}"
)
if k < 1 or k > self.n_samples_fit_:
raise ValueError(
f"mojolearn: n_neighbors must be in [1, {self.n_samples_fit_}]"
f", got {k}"
)
nq = q.shape[0]
if self.algorithm == "rbc":
# THE INDEXED ARM. Same answer, computed by pruning instead of
# by comparing every pair: `neighbors/impl/ball_cover/
# knn.mojo` proves each bound exact and
# `neighbors/checks/ball_cover_knn_check.mojo` gates it against
# a host brute force with no tolerance.
mvalue, marg = _resolve_rbc_metric(
type(self).__name__, self.metric, self.p
)
idx = self._index
rind = empty((nq, k), "<i4")
rdist = empty((nq, k), "<f4")
self.n_candidate_distances_ = self._bind(
"_mojolearn"
).rbc_knn_search(
addr_ro(idx, name="idx"), addr_ro(q, name="q"), addr(rind, name="rind"), addr(rdist, name="rdist"),
# ORDER MATCHES bindings/_mojolearn.mojo::
# rbc_knn_search_binding. n_index, n_queries, n_features, k,
# metric, metric_arg
[idx.shape[0], nq, idx.shape[1], k, mvalue, marg],
)
# The index is built inside the call, so `used_query_tile_` has
# no meaning on this arm and is set to None rather than left
# holding a stale value from a previous brute-force call.
self.used_query_tile_ = None
if rind.min() < 0:
raise RuntimeError(
"mojolearn NearestNeighbors(algorithm='rbc'): the query "
"returned an unfilled neighbour slot, which can only "
"happen when k exceeds the index size. The Mojo side "
"refuses that before launching, so this means the "
"arrays changed under the call."
)
if return_distance:
return rdist, rind.astype("<i8")
return rind.astype("<i8")
dist = empty((nq, k), "<f4")
ind = empty((nq, k), "<u4")
# Every array named here stays in a local for the whole call. That is
# the contract `_buffer` documents and the reason it is spelled out.
idx = self._index
binding = self._bind("_mojolearn")
handle = self._resident_index_handle(binding, idx)
# ORDER MATCHES bindings/_mojolearn.mojo::knn_search_binding and
# ::knn_search_resident_binding.
# n_index, n_queries, n_features, k, return_sqrt, query_tile
params = [idx.shape[0], nq, idx.shape[1], k, 1, self.query_tile]
if handle is not None:
self.used_query_tile_ = binding.knn_search_resident(
handle,
addr_ro(idx, name="idx"),
addr_ro(q, name="q"),
addr(dist, name="dist"),
addr(ind, name="ind"),
params,
# metric, metric_arg, weights -- see _dist_triple there.
self._dist_params(),
)
else:
self.used_query_tile_ = binding.knn_search(
addr_ro(idx, name="idx"),
addr_ro(q, name="q"),
addr(dist, name="dist"),
addr(ind, name="ind"),
params,
self._dist_params(),
)
if return_distance:
# an inner-product search hands back the products themselves:
# the binding negates its selection keys on the device before
# the readback (the host binding in its copy loop; lane
# pyglue-numeric, the negation was a second call here)
return dist, ind.astype("<i8")
return ind.astype("<i8")
# Maintainer notes (moved from the docstring):
# - Reference: cuML's `KNeighborsClassifier`; `predict` is `knn_classify`,
# the tie rule cuML's `class_vote_kernel`
# (`src_prims/selection/knn.cuh:74-109`), scikit-learn's `mode`. The vote
# is a serial fold per query in neighbor order, so it is bit-identical in
# both numeric modes by construction (`neighbors/impl/selection/knn.mojo`,
# DEVIATION 542). Rows measured by `tools/e2u_matrix_fit.py`.
# - weights='distance' is DEVIATION 556 (scikit-learn `_base.py:108-113`).
# The weighted arm asks the search for the rooted distance where the
# uniform arm asks for the squared one, because a weight reads the value
# and a vote only the order (estimator.mojo policy 8).
# - algorithm='rbc' is refused: knn_classify searches inside its own entry
# point, so 'rbc' would be accepted and inert.
# - y: any int values, multi-output as cuML's `vector<int*> y`.
# - classes_: the pyx's `cp.unique`; `_labels.sorted_classes`, DEVIATION
# 2340 (a list rather than an ndarray). The Mojo side recomputes the set
# with `getUniquelabels` and `predict` asserts the two agree.
[docs]
class KNeighborsClassifier(NearestNeighbors):
"""Classifier that votes among the k nearest training points.
For each query, the classes of its `n_neighbors` nearest training points
are counted and the most frequent wins; a tie goes to the lowest class in
sorted order, as in cuML and scikit-learn. With ``weights='distance'``
each neighbor's vote is weighted by ``1 / distance``. Neighbor search is
exact brute force, as in `NearestNeighbors`.
Parameters
----------
n_neighbors : int, default 5
Number of neighbors that vote. Must be between 1 and the number of
training samples.
query_tile : int, default 0
As in `NearestNeighbors`: memory use only, never the answer.
weights : {'uniform', 'distance'} or None, default 'uniform'
'uniform' (or ``None``) gives every neighbor one vote. 'distance'
follows scikit-learn: weight ``1 / d``, and when a query has
neighbors at distance exactly zero, those neighbors get weight 1 and
the others in that row get 0. A callable raises ``ValueError``.
'distance' with ``metric='inner_product'`` raises ``ValueError``
(an inner product is a similarity, not a distance).
metric : str, default 'euclidean'
As in `NearestNeighbors` with ``algorithm='brute'``.
algorithm : {'brute', 'auto'}, default 'brute'
Exact brute force. 'rbc' raises ``ValueError`` here; use
``NearestNeighbors(algorithm='rbc').kneighbors`` and vote on the
result instead.
p : float, default 2
As in `NearestNeighbors`.
numeric_mode : {'identical', 'deterministic', 'fast'}, optional
As in `NearestNeighbors`. 'fast' is available on Apple GPUs only.
Attributes
----------
classes_ : list of int, or list of lists for a 2-D `y`
Sorted unique labels per output, as Python ints (a list, not an
ndarray).
outputs_2d_ : bool
Whether `y` had more than one output column.
n_samples_fit_ : int
Number of training samples.
n_features_in_ : int
Number of features seen at `fit`.
used_query_tile_ : int
Query batch size the last prediction ran with.
Examples
--------
>>> import numpy as np, mojolearn
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = (X[:, 0] > 0.5).astype(np.int32)
>>> clf = mojolearn.KNeighborsClassifier(n_neighbors=5).fit(X, y)
>>> labels = clf.predict(X[:10])
>>> proba = clf.predict_proba(X[:10])
"""
#: scikit-learn's estimator kind: `cross_val_score` stratifies a
#: classifier's default folds, as scikit-learn's does.
_estimator_type = "classifier"
[docs]
def __init__(
self,
n_neighbors=5,
query_tile=_DEFAULT_QUERY_TILE,
*,
weights="uniform",
metric="euclidean",
algorithm="brute",
p=2,
):
super().__init__(
n_neighbors, query_tile, metric=metric, algorithm=algorithm, p=p
)
self.weights = weights
self._y_cols = None
_HAS_WEIGHTS = True
@property
def _weights_value(self):
"""`weights` as the value `bindings/_mojolearn.mojo::_dist_triple`
reads. `_check_refusals` is what raises on a bad one; this
property is only reached after it has run."""
return _resolve_weights(type(self).__name__, self.weights)
def _check_refusals(self):
super()._check_refusals()
_resolve_weights(type(self).__name__, self.weights)
# File members: the index and parameters `NearestNeighbors.save` writes,
# `weights`, the `<i4` label columns the binding takes (`_y_cols`,
# policy 6), `classes` (`<i8`, every output's sorted classes
# concatenated) and `class_counts` (`<i8`, one per output). The knn host
# inference lane, 2026-09-14.
[docs]
def save(self, path):
"""Save the fitted model to an ``.npz`` file.
The file holds the training points, labels, classes and parameters.
`mojolearn.host_model(path)` can predict from it on a CPU with no
GPU.
Parameters
----------
path : str or path-like
Destination file.
Raises
------
RuntimeError
If the model is not fitted.
"""
if self._y_cols is None:
raise RuntimeError("this estimator is not fitted yet")
arrays = _knn_arrays(self)
arrays["y_cols"] = self._y_cols
arrays["classes"] = Array.from_list(
[int(c) for cl in self._classes_list for c in cl], "<i8" # glue: saved classes per output column
)
arrays["class_counts"] = Array.from_list(
[len(cl) for cl in self._classes_list], "<i8" # glue: saved classes per output column
)
return _serialize.write_npz(path, arrays)
[docs]
@classmethod
def load(cls, path):
"""Load a model written by `save`.
`classes_` is rebuilt from the saved labels as `fit` builds it; a
file whose stored classes disagree with its labels raises
``ValueError``.
Parameters
----------
path : str or path-like
File written by `save`.
Returns
-------
KNeighborsClassifier
A fitted model, ready for `predict`.
"""
obj, arrays, (o2d, n_out) = _load_knn(cls, path)
y_cols = _load_y_cols(arrays, path, "<i4", obj.n_samples_fit_, n_out)
classes = _serialize.exact(arrays, "classes", "<i8").tolist()
counts = _serialize.exact(arrays, "class_counts", "<i8").tolist()
if len(counts) != n_out or sum(counts) != len(classes): # glue: class count check over the outputs
raise ValueError(f"mojolearn: {path!r} classes and class_counts disagree with n_outputs")
rebuilt = [encode_labels(y_cols[i])[0] for i in range(n_out)] # glue: one native encoding per output column
off = 0
for i, count in enumerate(counts): # glue: saved class check per output
saved = [int(c) for c in classes[off:off + count]] # glue: saved class check per output
off += count
if saved != rebuilt[i]:
raise ValueError(
f"mojolearn: {path!r} classes for output {i} are not the sorted "
"unique labels of its y_cols column"
)
obj._y_cols = y_cols
obj._classes_list = rebuilt
obj.outputs_2d_ = o2d
return obj
[docs]
def fit(self, X, y):
"""Store the training points and their labels.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Training points; converted to float32.
y : array-like of int, shape (n_samples,) or (n_samples, n_outputs)
Integer class labels of any values (negative values and gaps are
fine) within the int32 range. A 2-D `y` votes each column
separately. Float labels raise ``ValueError``; cast them to int
first (cuML requires int32 labels too).
Returns
-------
self
"""
super().fit(X)
shape = _shape_of(y)
if len(shape) not in (1, 2):
raise ValueError(
f"mojolearn: y must be 1-D or 2-D, got {len(shape)}-D"
)
if shape[0] != self.n_samples_fit_:
raise ValueError(
f"mojolearn: y has {shape[0]} rows, X has "
f"{self.n_samples_fit_}"
)
if not _is_integer_labels(y):
raise ValueError(
f"mojolearn: y must be integer class labels, got "
f"{_dtype_name(y)}; cuML converts to int32 "
"(check_dtype=np.int32) and so does this surface -- cast "
"your labels"
)
ya, _ = as_i64_c(y, ndim=len(shape), name="y")
if ya.min() < -(1 << 31) or ya.max() > (1 << 31) - 1:
raise ValueError("mojolearn: y does not fit int32")
n = shape[0]
n_out = 1 if len(shape) == 1 else shape[1]
# POLICY 6 (neighbors/estimator.mojo): the binding takes `n_outputs`
# CONTIGUOUS columns -- cuML's order='F' `y`. One transpose at fit,
# through the O(rows * n_outputs) label loop the contract permits
# (DEVIATION 2374), which also narrows int64 -> int32 exactly after
# the range check above.
self.outputs_2d_ = len(shape) == 2 and shape[1] != 1
if n_out == 1:
# lane/gap-linear-nv: one output is already its own contiguous
# column. The int64 -> int32 narrowing is a C-level copy (exact
# after the range check above) and the classes come from the
# native encoder on that buffer: the same ints, in the same
# sorted order, the list path below produced through a 2 x n
# Python-object round trip (tolist, from_list) that cost more
# than the GPU search on the board's 200,000-row index.
y32 = ya.reshape((n,)).astype("<i4")
self._y_cols = y32.reshape((1, n))
self._classes_list = [encode_labels(y32)[0]]
return self
# the narrowing is a C-level copy (exact after the range check) and
# the transpose a device op (`xn_p2m_transpose_i`; the host binding's
# same op on a CPU-only install); the classes per output column from
# the native encoder, as the one-output path above
y32 = ya.astype("<i4")
yc = empty((n_out, n), "<i4")
if n:
self._bind(_XN).xn_p2m_transpose_i(
[addr_ro(y32, name="y"), addr(yc, name="y_cols")], [n, n_out], [])
self._y_cols = yc
self._classes_list = [encode_labels(yc[j])[0] for j in range(n_out)] # glue: one native encoding per output column
return self
@property
def classes_(self):
"""Sorted unique labels seen at `fit`, as a list of ints; a list of
such lists for a 2-D `y`."""
if self._y_cols is None:
raise AttributeError("classes_ is set by fit")
if self.outputs_2d_:
return self._classes_list
return self._classes_list[0]
def _predict(self, X, want_proba):
if self._index is None or self._y_cols is None:
raise ValueError("mojolearn: call fit before predict")
q, _ = as_f32_dense_c(X, ndim=2, name="X")
_check_metric_input(type(self).__name__, self._dist_params()[0], q)
if q.shape[1] != self.n_features_in_:
raise ValueError(
f"mojolearn: X has {q.shape[1]} features, index has "
f"{self.n_features_in_}"
)
k = self.n_neighbors
if k < 1 or k > self.n_samples_fit_:
raise ValueError(
f"mojolearn: n_neighbors must be in [1, {self.n_samples_fit_}]"
f", got {k}"
)
nq = q.shape[0]
n_out = self._y_cols.shape[0]
n_classes = [len(c) for c in self._classes_list] # glue: class counts per output column
# The native contract writes exactly one of these two outputs. Keep
# the unselected address valid without materializing its full public
# shape: at million-row scale the unused probability matrix can be
# tens or hundreds of MiB, and predict_proba likewise has no use for
# a labels matrix. Both GPU and host bindings promise not to read or
# write the unselected pointer (their one-element sentinel contract).
labels = (empty((1,), "<i4") if want_proba else
empty((nq, n_out), "<i4"))
proba = (empty((nq * sum(n_classes),), "<f4") if want_proba else # glue: class counts per output column
empty((1,), "<f4"))
uniq = empty((sum(n_classes),), "<i4") # glue: class counts per output column
idx = self._index
y_cols = self._y_cols
binding = self._bind("_mojolearn")
# THE INDEX STAYS ON THE DEVICE (DEVIATION 3002, the classifier's
# door of DEVIATION 2921): the first call uploads it, every later
# call classifies against the device copy through
# `knn_classify_resident`; the search and the vote are the same
# statements over the same bytes. A binding without that entry
# takes the per-call path.
resident = self._resident_door(binding, "knn_classify_resident")
handle = self._resident_index_handle(binding, idx) if resident is not None else None
# ORDER MATCHES bindings/_mojolearn.mojo::knn_classify_binding and
# ::knn_classify_resident_binding.
# n_index, n_queries, n_features, k, query_tile, n_outputs,
# want_proba, then n_classes per output
params = [idx.shape[0], nq, idx.shape[1], k, self.query_tile, n_out,
1 if want_proba else 0] + n_classes
if handle is not None:
# The handle rides at the front of `params`: a binding takes at
# most eight arguments and the classifier uses all eight.
self.used_query_tile_ = resident(
addr_ro(idx, name="idx"),
addr_ro(q, name="q"),
addr_ro(y_cols, name="y_cols"),
addr(labels, name="labels"),
addr(proba, name="proba"),
addr(uniq, name="uniq"),
[handle] + params,
# metric, metric_arg, weights -- see _dist_triple there.
self._dist_params(),
)
else:
self.used_query_tile_ = binding.knn_classify(
addr_ro(idx, name="idx"),
addr_ro(q, name="q"),
addr_ro(y_cols, name="y_cols"),
addr(labels, name="labels"),
addr(proba, name="proba"),
addr(uniq, name="uniq"),
params,
self._dist_params(),
)
# POLICY 7: the implementation's class set against ours, made visible. `uniq`
# is split at the running class counts (the old np.cumsum /
# np.split), in Python over O(classes) ints.
flat = uniq.tolist()
off = 0
for i, (count, b) in enumerate(zip(n_classes, self._classes_list)): # glue: class set check per output column
a = flat[off:off + count]
off += count
if a != b:
raise RuntimeError(
f"mojolearn: output {i}: the implemented getUniquelabels found "
f"classes {a[:8]}..., the host sort found "
f"{b[:8]}...; the two class sets disagree"
)
return labels, proba, n_classes
[docs]
def predict(self, X):
"""Predict the class of each query by neighbor vote.
Parameters
----------
X : array-like of shape (n_queries, n_features)
Query points; converted to float32.
Returns
-------
Array of int64, shape (n_queries,) or (n_queries, n_outputs)
The voted labels, in the original label values; 2-D when `y`
was 2-D.
Raises
------
ValueError
If the model is not fitted, `X` has the wrong number of features,
or `n_neighbors` exceeds the number of training samples.
"""
labels, _, _ = self._predict(X, want_proba=False)
if self.outputs_2d_:
return labels.astype("<i8")
# (nq, 1) -> (nq,) is a C-order reshape; int32 -> int64 is exact.
return labels.reshape((labels.shape[0],)).astype("<i8")
[docs]
def predict_proba(self, X):
"""Return each query's vote fractions per class.
Parameters
----------
X : array-like of shape (n_queries, n_features)
Query points; converted to float32.
Returns
-------
Array of float32, shape (n_queries, n_classes), or a list of them
Columns follow `classes_` order. For a 2-D `y`, one array per
output, as cuML and scikit-learn return it.
"""
labels, proba, n_classes = self._predict(X, want_proba=True)
# `labels` is the one-element sentinel in this arm. The selected
# flat probability buffer records the query count without retaining
# an otherwise-unused labels matrix.
nq = proba.shape[0] // sum(n_classes) # glue: class counts per output column
out = []
off = 0
for n in n_classes: # glue: probability block slices per output
# A slice of an Array COPIES (the _array contract).
out.append(proba[off:off + nq * n].reshape((nq, n)))
off += nq * n
if self.outputs_2d_:
return out
return out[0]
# Maintainer notes (moved from the docstring): reference cuML's
# `KNeighborsRegressor` (`ML::knn_regress`, `regress_avg_kernel`,
# `src_prims/selection/knn.cuh:112-131`). The mean is a serial float32 fold
# per query in neighbor order, then one division by k; the same arithmetic
# in both numeric modes, with the seams flushed under NUMERIC_IDENTICAL
# (DEVIATION 542). weights='distance' is DEVIATION 556. float64 targets are
# cast to float32 as cuML does (convert_to_dtype=np.float32).
[docs]
class KNeighborsRegressor(NearestNeighbors):
"""Regressor that averages the targets of the k nearest training points.
Each prediction is the mean of the `n_neighbors` nearest targets, or with
``weights='distance'`` the weighted mean ``sum(w * y) / sum(w)`` with
``w = 1 / d``. Neighbor search is exact brute force, as in
`NearestNeighbors`. Targets are converted to float32.
Parameters
----------
n_neighbors : int, default 5
Number of neighbors averaged. Must be between 1 and the number of
training samples.
query_tile : int, default 0
As in `NearestNeighbors`: memory use only, never the answer.
weights : {'uniform', 'distance'} or None, default 'uniform'
'uniform' (or ``None``) averages equally. 'distance' follows
scikit-learn, including its rule for neighbors at distance exactly
zero (they get weight 1, the rest of that row 0). A callable raises
``ValueError``, as does 'distance' with ``metric='inner_product'``.
metric : str, default 'euclidean'
As in `NearestNeighbors` with ``algorithm='brute'``.
algorithm : {'brute', 'auto'}, default 'brute'
Exact brute force. 'rbc' raises ``ValueError`` here; use
``NearestNeighbors(algorithm='rbc').kneighbors`` instead.
p : float, default 2
As in `NearestNeighbors`.
numeric_mode : {'identical', 'deterministic', 'fast'}, optional
As in `NearestNeighbors`. 'fast' is available on Apple GPUs only.
Attributes
----------
outputs_2d_ : bool
Whether `y` had more than one output column.
n_samples_fit_ : int
Number of training samples.
n_features_in_ : int
Number of features seen at `fit`.
used_query_tile_ : int
Query batch size the last prediction ran with.
Examples
--------
>>> import numpy as np, mojolearn
>>> rng = np.random.default_rng(0)
>>> X = rng.random((1000, 8), dtype=np.float32)
>>> y = X.sum(axis=1)
>>> reg = mojolearn.KNeighborsRegressor(n_neighbors=5).fit(X, y)
>>> pred = reg.predict(X[:10])
"""
#: scikit-learn's estimator kind: `cross_val_score` stratifies a
#: classifier's default folds, as scikit-learn's does.
_estimator_type = "regressor"
[docs]
def __init__(
self,
n_neighbors=5,
query_tile=_DEFAULT_QUERY_TILE,
*,
weights="uniform",
metric="euclidean",
algorithm="brute",
p=2,
):
super().__init__(
n_neighbors, query_tile, metric=metric, algorithm=algorithm, p=p
)
self.weights = weights
self._y_cols = None
_HAS_WEIGHTS = True
@property
def _weights_value(self):
"""`weights` as the value `bindings/_mojolearn.mojo::_dist_triple`
reads. `_check_refusals` is what raises on a bad one; this
property is only reached after it has run."""
return _resolve_weights(type(self).__name__, self.weights)
def _check_refusals(self):
super()._check_refusals()
_resolve_weights(type(self).__name__, self.weights)
# File members: the index and parameters `NearestNeighbors.save` writes,
# `weights` and the `<f4` target columns the binding takes (`_y_cols`,
# policy 6). The knn host inference lane, 2026-09-14.
[docs]
def save(self, path):
"""Save the fitted model to an ``.npz`` file.
The file holds the training points, targets and parameters.
`mojolearn.host_model(path)` can predict from it on a CPU with no
GPU.
Parameters
----------
path : str or path-like
Destination file.
Raises
------
RuntimeError
If the model is not fitted.
"""
if self._y_cols is None:
raise RuntimeError("this estimator is not fitted yet")
arrays = _knn_arrays(self)
arrays["y_cols"] = self._y_cols
return _serialize.write_npz(path, arrays)
[docs]
@classmethod
def load(cls, path):
"""Load a model written by `save`.
Parameters
----------
path : str or path-like
File written by `save`.
Returns
-------
KNeighborsRegressor
A fitted model, ready for `predict`.
"""
obj, arrays, (o2d, n_out) = _load_knn(cls, path)
obj._y_cols = _load_y_cols(arrays, path, "<f4", obj.n_samples_fit_, n_out)
obj.outputs_2d_ = o2d
return obj
[docs]
def fit(self, X, y):
"""Store the training points and their targets.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Training points; converted to float32.
y : array-like, shape (n_samples,) or (n_samples, n_outputs)
Numeric targets; converted to float32. A 2-D `y` is averaged
column by column.
Returns
-------
self
"""
super().fit(X)
shape = _shape_of(y)
if len(shape) not in (1, 2):
raise ValueError(
f"mojolearn: y must be 1-D or 2-D, got {len(shape)}-D"
)
if shape[0] != self.n_samples_fit_:
raise ValueError(
f"mojolearn: y has {shape[0]} rows, X has "
f"{self.n_samples_fit_}"
)
ya, _ = as_f32_c(y, ndim=len(shape), name="y")
n = shape[0]
n_out = 1 if len(shape) == 1 else shape[1]
if n_out == 1:
# One column IS one contiguous row of the transposed layout: a
# reshape, no copy (as `ascontiguousarray(y2.T)` was for 1-D y).
self._y_cols = ya.reshape((1, n))
else:
# The transpose on the device (`xn_p2m_transpose`; the host
# binding's same op on a CPU-only install)
yc = empty((n_out, n), "<f4")
if n:
self._bind(_XN).xn_p2m_transpose(
[addr_ro(ya, name="y"), addr(yc, name="y_cols")], [n, n_out], [])
self._y_cols = yc
self.outputs_2d_ = len(shape) == 2 and shape[1] != 1
return self
[docs]
def predict(self, X):
"""Predict each query's target from its neighbors.
Parameters
----------
X : array-like of shape (n_queries, n_features)
Query points; converted to float32.
Returns
-------
Array of float32, shape (n_queries,) or (n_queries, n_outputs)
The (weighted) mean target; 2-D when `y` was 2-D.
Raises
------
ValueError
If the model is not fitted, `X` has the wrong number of features,
or `n_neighbors` exceeds the number of training samples.
"""
if self._index is None or self._y_cols is None:
raise ValueError("mojolearn: call fit before predict")
q, _ = as_f32_dense_c(X, ndim=2, name="X")
_check_metric_input(type(self).__name__, self._dist_params()[0], q)
if q.shape[1] != self.n_features_in_:
raise ValueError(
f"mojolearn: X has {q.shape[1]} features, index has "
f"{self.n_features_in_}"
)
k = self.n_neighbors
if k < 1 or k > self.n_samples_fit_:
raise ValueError(
f"mojolearn: n_neighbors must be in [1, {self.n_samples_fit_}]"
f", got {k}"
)
nq = q.shape[0]
n_out = self._y_cols.shape[0]
out = empty((nq, n_out), "<f4")
idx = self._index
y_cols = self._y_cols
binding = self._bind("_mojolearn")
# DEVIATION 3002: the regressor's resident door; see the classifier.
resident = self._resident_door(binding, "knn_regress_resident")
handle = self._resident_index_handle(binding, idx) if resident is not None else None
# ORDER MATCHES bindings/_mojolearn.mojo::knn_regress_binding and
# ::knn_regress_resident_binding.
# n_index, n_queries, n_features, k, query_tile, n_outputs
params = [idx.shape[0], nq, idx.shape[1], k, self.query_tile, n_out]
if handle is not None:
self.used_query_tile_ = resident(
handle,
addr_ro(idx, name="idx"),
addr_ro(q, name="q"),
addr_ro(y_cols, name="y_cols"),
addr(out, name="out"),
params,
# metric, metric_arg, weights -- see _dist_triple there.
self._dist_params(),
)
else:
self.used_query_tile_ = binding.knn_regress(
addr_ro(idx, name="idx"),
addr_ro(q, name="q"),
addr_ro(y_cols, name="y_cols"),
addr(out, name="out"),
params,
self._dist_params(),
)
if self.outputs_2d_:
return out
return out.reshape((nq,))
# Maintainer notes (moved from the docstring):
# - The index is cuVS's random ball cover (`neighbors/impl/ball_cover/`),
# which DBSCAN uses for its eps neighborhood. Exactness is asserted by
# `neighbors/checks/radius_check.mojo` against a host brute-force oracle
# per cell; the per-metric gate is `pixi run
# check-ball-cover-knn-identical`. Metric refusals and their reasons live
# in `_resolve_rbc_metric`.
# - The distances are recomputed, not stored by the search: a separate pass
# walks the finished neighbor list (`neighbors/checks/
# radius_distances.mojo` carries the argument that the value is the same;
# under `identical` the check asserts it bit for bit).
# - sort_results: the device returns each row in ascending index order under
# `identical` (DEVIATION 551), so a stable sort by distance gives
# (distance, index) order with the tie-break free. The sort is the
# segmented device sort `xn_p2m_row_sort`.
# - Two calls cross the boundary per query (count, then fill), because the
# output size is not a function of the inputs; the ball cover is built in
# each (`neighbors/estimator.mojo`).
[docs]
class RadiusNeighbors(NumericModeMixin):
"""Find every fitted point within a radius of each query.
Returns scikit-learn's `radius_neighbors` shape: a ragged
``(distances, indices)`` pair with one variable-length array per query.
The search runs over a random ball cover index (neither brute force nor
one of scikit-learn's trees) and returns the exact set: its pruning is a
triangle-inequality bound, not an approximation. Input is converted to
float32.
Parameters
----------
radius : float, default 1.0
Default search radius; must be positive and finite.
metric : str, default 'euclidean'
Any distance that satisfies the triangle inequality:
'euclidean'/'l2', 'manhattan'/'l1'/'cityblock'/'taxicab',
'chebyshev'/'linf' and 'minkowski'/'lp' with ``p >= 1``. 'cosine',
'sqeuclidean', Minkowski with ``p < 1`` and other names raise
``ValueError``, because the index would silently miss neighbors
under them. For squared Euclidean, use 'euclidean' and square the
distances.
algorithm : {'auto', 'rbc'}, default 'auto'
Both mean the random ball cover. 'brute', 'kd_tree' and 'ball_tree'
raise ``ValueError``, since they would name a different algorithm.
p : float, default 2
Minkowski exponent, read only by ``metric='minkowski'`` (or 'lp');
must be finite and at least 1. Under any other metric, a value other
than 2 raises ``ValueError``.
numeric_mode : {'identical', 'deterministic', 'fast'}, optional
Numeric mode for this estimator; ``None`` uses the process default
(`mojolearn.set_numeric_mode`). 'fast' is available on Apple GPUs
only.
Attributes
----------
n_samples_fit_ : int
Number of fitted samples.
n_features_in_ : int
Number of features seen at `fit`.
Examples
--------
>>> import numpy as np, mojolearn
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> rn = mojolearn.RadiusNeighbors(radius=0.5).fit(X)
>>> distances, indices = rn.radius_neighbors(X[:10], sort_results=True)
>>> len(indices)
10
"""
_BINDING = "_mojolearn"
[docs]
def __init__(
self,
radius=1.0,
*,
metric="euclidean",
algorithm="auto",
p=2,
):
self.radius = radius
self.metric = metric
self.algorithm = algorithm
self.p = p
self._index = None
self._metric_value = None
self._metric_arg = None
def _check_refusals(self):
"""Every refusal is BY NAME with the reason, raised at fit.
NOT `_METRIC_TABLE`, AND NOT THE OLD EUCLIDEAN-ONLY LIST EITHER.
Until 2026-09-01 this admitted `euclidean`, `l2` and
`minkowski` at p = 2 and refused everything else, with a correct
reason attached: the pruning here IS the triangle inequality on the
landmark radii, so admitting a non-metric returns a plausible,
WRONG, silently pruned answer rather than a slow one. That argument
is kept and it decides the width: it covers cosine and Lp at p < 1
and `sqeuclidean`, and it does NOT cover Minkowski at p >= 1, which
is a true metric (that is Minkowski's inequality). So Manhattan,
Chebyshev and every Lp at p >= 1 are admitted now, and
`neighbors/checks/ball_cover_knn_check.mojo` gates each of them
against a host brute force per row with no tolerance.
`_resolve_rbc_metric` holds the table and every refusal message.
"""
self._metric_value, self._metric_arg = _resolve_rbc_metric(
type(self).__name__, self.metric, self.p
)
if self.algorithm not in ("auto", "rbc"):
raise ValueError(
f"mojolearn RadiusNeighbors: algorithm={self.algorithm!r} is "
"refused. The index here is cuVS's RANDOM BALL COVER, which "
"is none of scikit-learn's three: it is not 'brute' (there "
"is an index and it prunes), and it is neither 'ball_tree' "
"nor 'kd_tree' (it is a one-level cover over sqrt(n) "
"landmarks, not a tree). Naming any of them would describe "
"the wrong algorithm, so 'auto' and 'rbc' -- the "
"algorithm's own name -- are the two accepted. The "
"results are exact either way"
)
if not math.isfinite(self.radius) or self.radius <= 0:
raise ValueError(
f"mojolearn RadiusNeighbors: radius={self.radius!r} is "
"refused; it must be positive and finite"
)
# The ball cover is not built at fit, a known cost rather than an
# oversight: `neighbors/estimator.mojo`'s RADIUS NEIGHBOURS banner
# records it.
[docs]
def fit(self, X, y=None):
"""Store the points to search.
Parameter checks run here, so an unsupported metric, algorithm, `p`
or radius raises ``ValueError`` before any search. The ball cover
index is built at each `radius_neighbors` call, not here.
Parameters
----------
X : array-like of shape (n_samples, n_features)
Points to search; converted to float32.
y : ignored
Accepted for scikit-learn compatibility.
Returns
-------
self
"""
self._check_refusals()
idx, _ = as_f32_dense_c(X, ndim=2, name="X")
self._index = idx
self.n_samples_fit_ = idx.shape[0]
self.n_features_in_ = idx.shape[1]
return self
[docs]
def save(self, path):
"""Save the fitted model to an ``.npz`` file.
The file holds the float32 points as fitted and the parameters
(`radius`, `metric`, `p`, `algorithm`, numeric mode).
`mojolearn.host_model(path)` can answer `radius_neighbors` from it on
a CPU with no GPU.
Parameters
----------
path : str or path-like
Destination file.
Raises
------
RuntimeError
If the model is not fitted.
"""
if self._index is None:
raise RuntimeError("this estimator is not fitted yet")
return _serialize.write_npz(path, {
"format": _RADIUS_FORMAT,
"estimator": type(self).__name__,
"numeric_mode": _saved_mode(self),
"metric": str(self.metric),
"algorithm": str(self.algorithm),
"radius": Array.from_list([float(self.radius)], "<f8"),
"p": Array.from_list([float(self.p)], "<f8"),
"index": self._index,
"meta": Array.from_list([int(self.n_features_in_), int(self.n_samples_fit_)], "<i8"),
})
[docs]
@classmethod
def load(cls, path):
"""Load a model written by `save`.
Arrays are read at their saved dtypes and never cast. Parameters
`fit` would refuse raise ``ValueError`` here too.
Parameters
----------
path : str or path-like
File written by `save`.
Returns
-------
RadiusNeighbors
A fitted model, ready for `radius_neighbors`.
"""
arrays = _serialize.read_npz(path, _RADIUS_FORMAT)
_check_saved_by(arrays, path, cls)
meta = _serialize.exact(arrays, "meta", "<i8")
if meta.size != 2:
raise ValueError(f"mojolearn: {path!r} meta holds {meta.size} fields, 2 are needed")
radius = _serialize.exact(arrays, "radius", "<f8")
p = _serialize.exact(arrays, "p", "<f8")
if radius.size != 1 or p.size != 1:
raise ValueError(f"mojolearn: {path!r} radius and p must hold one value each")
obj = cls(
radius=float(radius[0]),
metric=_serialize.scalar_str(arrays, "metric"),
algorithm=_serialize.scalar_str(arrays, "algorithm"),
p=float(p[0]),
)
_restore_mode(obj, arrays)
obj._check_refusals()
nf, ns = int(meta[0]), int(meta[1])
index = _serialize.exact(arrays, "index", "<f4")
if index.ndim != 2 or tuple(index.shape) != (ns, nf) or ns < 1 or nf < 1:
raise ValueError(
f"mojolearn: {path!r} index has shape {tuple(index.shape)}, meta says ({ns}, {nf})"
)
obj._index = index
obj.n_samples_fit_ = ns
obj.n_features_in_ = nf
return obj
# DEVIATION 2375: the containers are Python lists of Arrays, where they
# were object-dtype ndarrays of ndarrays. The self-edge under X=None is
# kept because the ball cover returns it, DBSCAN counts on it, and
# dropping it would make this surface disagree with the CSR every other
# consumer sees.
[docs]
def radius_neighbors(
self, X=None, radius=None, return_distance=True, sort_results=False
):
"""Find the fitted points within `radius` of each query.
`X=None` queries the fitted points against themselves, as in
scikit-learn. Unlike scikit-learn, each point is then included in
its own neighbor list.
Parameters
----------
X : array-like of shape (n_queries, n_features), optional
Query points; converted to float32. ``None`` uses the fitted
points.
radius : float, optional
Search radius for this call; defaults to the constructor's
`radius`. Must be positive and finite.
return_distance : bool, default True
Return the distances as well as the indices.
sort_results : bool, default False
Sort each row by increasing distance. Requires
``return_distance=True``. Under the 'identical' mode, rows come
in ascending index order, so sorted rows break distance ties by
the lower index.
Returns
-------
distances : list of Array, float32
One 1-D array per query. Returned only when `return_distance` is
True.
indices : list of Array, int64
One 1-D array per query of row indices into the fitted data.
Rows have different lengths, so these are Python lists rather
than a rectangular array: indexing with ``[i]`` and ``len()``
work, ``np.asarray`` on the list does not give a 2-D block.
Raises
------
ValueError
If the model is not fitted, the radius is invalid, `X` has the
wrong number of features, or ``sort_results=True`` with
``return_distance=False``.
"""
if self._index is None:
raise ValueError(
"mojolearn RadiusNeighbors: call fit() before "
"radius_neighbors()"
)
r = float(self.radius if radius is None else radius)
if not math.isfinite(r) or r <= 0:
raise ValueError(
f"mojolearn RadiusNeighbors: radius={radius!r} is refused; "
"it must be positive and finite"
)
if sort_results and not return_distance:
raise ValueError(
"mojolearn RadiusNeighbors: sort_results=True requires "
"return_distance=True; there is nothing to sort by otherwise"
)
idx = self._index
if X is None:
q = idx
else:
q, _ = as_f32_dense_c(X, ndim=2, name="X")
if q.shape[1] != idx.shape[1]:
raise ValueError(
f"mojolearn RadiusNeighbors: X has {q.shape[1]} features "
f"but the index was fit on {idx.shape[1]}"
)
nq = q.shape[0]
indptr = empty((nq + 1,), "<i4")
nnz = self._bind("_mojolearn").radius_neighbors_count(
addr_ro(idx, name="idx"), addr_ro(q, name="q"), addr(indptr, name="indptr"),
# ORDER MATCHES bindings/_mojolearn.mojo::
# radius_neighbors_count_binding. n_index, n_queries, n_features,
# radius, metric, metric_arg
[idx.shape[0], nq, idx.shape[1], r,
self._metric_value, self._metric_arg],
)
cols = empty((nnz,), "<i4")
dists = empty((nnz,), "<f4")
# NO NEIGHBOUR IN THE WHOLE CALL (2026-09-14 night, found by the
# batch part of tools/identity_break.py): a zero-length `cols` has no
# address, and the fill binding refused it with "null int32 buffer
# address", so a query row with no neighbour raised when asked ALONE
# and answered an empty row inside a larger batch (base fixture,
# held-out rows 1 and 7 of the radius lane). The counting pass has
# already written every indptr entry (all zero), so there is nothing
# to fill.
got = 0 if nnz == 0 else self._bind("_mojolearn").radius_neighbors_fill(
addr_ro(idx, name="idx"), addr_ro(q, name="q"), addr(indptr, name="indptr"), addr(cols, name="cols"),
addr(dists, name="dists"),
# n_index, n_queries, n_features, radius, nnz_capacity,
# return_sqrt, metric, metric_arg. The metric MUST be the same
# value both passes saw: the index is built inside each call, so
# a metric that differed between them would count under one
# cover and fill under another.
[idx.shape[0], nq, idx.shape[1], r, nnz, 1,
self._metric_value, self._metric_arg],
)
# The Mojo side already refuses `got > nnz`. This asserts the other
# direction too, because a SHORT fill would leave the tail of `cols`
# uninitialised and every row after it wrong, and nothing downstream
# would notice.
if got != nnz:
raise RuntimeError(
f"mojolearn RadiusNeighbors: the counting pass found {nnz} "
f"neighbours and the filling pass found {got}, on the same "
"arrays. The two passes rebuild the index independently, so "
"this means the input arrays changed between them"
)
# DEVIATION 2375 -- API CHANGE: the ragged containers are Python
# LISTS of Arrays (int64 indices, float32 distances), one per query
# row, where they were object-dtype ndarrays. Same per-row contents,
# indexed with `[i]` as before; `len()` is the query count.
if sort_results and nnz:
# every row's stable sort by distance as one segmented sort on
# the device (`xn_p2m_row_sort`, key (row, distance, position);
# the rows arrive in ascending index order under `identical`, so
# the result is (distance, index) lexicographic)
from ._expansion_neighbors import _p2m_sort_rows
cols, dists = _p2m_sort_rows(self, indptr, cols, dists, _XN)
ptr = indptr.tolist()
cols64 = cols.astype("<i8")
# glue: one view per query row, the ragged API's list of Arrays
ind = [cols64[ptr[i]:ptr[i + 1]] for i in range(nq)] # glue: ragged API: one index view per query
dst = [dists[ptr[i]:ptr[i + 1]] for i in range(nq)] # glue: ragged API: one index view per query
if return_distance:
return dst, ind
return ind