# SPDX-License-Identifier: Apache-2.0
"""CPU inference for saved RandomForest and ExtraTrees models (the forest host
lane, 2026-09-13).
`HostForest.from_file(path)` loads a model written by `RandomForest*.save` or
`ExtraTrees*.save` and predicts on the CPU through `_mojolearn_forest_host`,
compiled with no accelerator target, running whichever of the two inference
engines the archive names (docs/FOREST_INFERENCE_ENGINES.md): the sequential
walk for a `mojolearn-randomforest-1` or `mojolearn-extratrees-1` archive,
and, since lane/forest-groves-cpu-and-speed (2026-09-17), the
`parallel_groves` engine for a `-parallel-groves-1` archive (32 fixed tree
groups, the 16/8/4/2/1 fold, `core/forest_host_groves.mojo`), so a groves
model trained on a GPU infers the GPU groves engine's bits on a CPU. A groves
archive saved under a numeric mode other than `identical` is refused by name,
because this binding computes IDENTICAL bits only. `predict` and
`predict_proba` return exactly what the GPU classes return for the same file.
RF probabilities float32, RF regression float32, ET probabilities and
regression float64, class labels through `_labels.decode_labels`.
This module holds no arithmetic. What it promises is what the gate measured:
tools/forest_host_gate.py compares the host predictions of a recorded model
and fixture against the SHA-256 a GPU run recorded. A CPU on which that gate
has not passed is not certified, whatever this code returns on it.
The binary is loaded by path and not through `_backend.load_set`, because
that selector refuses a binary whose vendor read-back is not a GPU API, and
this one reads back `cpu` by design. WHICH DIRECTORY is still
`_backend.host_module_path`'s answer, so `MOJOLEARN_HOST_DIR` moves this
door along with every other host binding. It did not until
lane/host-path-resolution (2026-09-16), and a process told to use another
set loaded the package's own forest binding here instead, which is why
tools/identity_break.py guards every host prediction against the binary it
asked for. `MOJOLEARN_FOREST_HOST_BINARY` names a different file and still
wins over the directory, which is how the gate loads its sabotage build; a
sabotage build is refused unless `MOJOLEARN_FOREST_HOST_ALLOW_SABOTAGE=1`.
"""
import hashlib
import importlib.machinery
import importlib.util
import os
import sys
import weakref
from . import _backend
from . import _serialize
from ._array import Array
from ._buffer import addr, addr_ro, as_f32_c, empty
from ._labels import argmax_rows, classes_from_member, decode_labels
_EXTENSION = '_mojolearn_forest_host'
_MODULE_NAME = 'mojolearn._host.' + _EXTENSION
_IDENTICAL_CODE = 1
# THE LOADER LIVES IN `_backend` (lane/lazy-imports, 2026-10-10):
# `_buffer._host_native` needs only this binary, and importing this whole
# door from the buffer helpers put its imports in every lane's closure.
def binary_path():
"""The binary this process loads, or would load: the file
`MOJOLEARN_FOREST_HOST_BINARY` names, else this install's host binding
of that name (`_backend.door_host_binary_path`)."""
return _backend.door_host_binary_path(_EXTENSION)
def _load():
"""`_mojolearn_forest_host`, loaded once and read back (`_backend.load_door_host`)."""
return _backend.load_door_host(_EXTENSION)
#: The sequential archive formats and the family each belongs to; a
#: `-parallel-groves-1` archive of either is the same family under the
#: other engine.
_FORMATS = {
'mojolearn-randomforest-1': 'rf',
'mojolearn-extratrees-1': 'et',
}
_GROVES_SUFFIX = '-parallel-groves-1'
_ENGINES = ('sequential', 'parallel_groves')
#: The binding's family codes for the grove entries.
_GROVES_FAMILY = {'rf': 0, 'et': 1}
_ESTIMATORS = {
'RandomForestClassifier': ('rf', True),
'RandomForestRegressor': ('rf', False),
'ExtraTreesClassifier': ('et', True),
'ExtraTreesRegressor': ('et', False),
}
_MODEL_ARRAYS = (('offsets', '<i4'), ('colid', '<i4'), ('quesval', '<f4'),
('left_child', '<i4'), ('leaves', '<f4'))
[docs]
class HostForest:
"""A saved random forest or extra-trees model that predicts on the CPU.
Build one with `HostForest.from_file(path)` from a file written by
`RandomForestClassifier.save`, `RandomForestRegressor.save`,
`ExtraTreesClassifier.save` or `ExtraTreesRegressor.save`. Prediction
runs the inference engine the file names ('sequential' or
'parallel_groves') and returns what the GPU estimator returns for the
same file: random forest probabilities and regression values as float32,
extra-trees probabilities and regression values as float64. The model
arrays are used exactly as saved, never cast; a file whose tree arrays
are inconsistent raises `ValueError`.
Attributes
----------
estimator : str
Name of the estimator class that saved the model.
classes_ : list
Class labels (classifiers only).
n_classes_ : int
Number of classes (classifiers only).
n_features_in_ : int
Number of features the model expects.
device : str or None
The device recorded in the file.
Examples
--------
>>> import numpy as np, mojolearn
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> y = X[:, 0] * 2.0
>>> mojolearn.RandomForestRegressor(n_estimators=20).fit(X, y).save("rf.npz")
>>> pred = mojolearn.HostForest.from_file("rf.npz").predict(X)
"""
[docs]
def __init__(self, *, estimator, offsets, colid, quesval, left_child, leaves,
n_features, n_trees, num_outputs, classes=None, device=None,
inference_engine='sequential'):
if estimator not in _ESTIMATORS:
raise ValueError(f"mojolearn: {estimator!r} is not a forest estimator this loader reads")
if inference_engine not in _ENGINES:
raise ValueError(f"mojolearn: inference_engine must be one of {_ENGINES}, got {inference_engine!r}")
self.estimator = estimator
self.family, self.is_classifier = _ESTIMATORS[estimator]
self.device = device
self._inference_engine = inference_engine
self._groves = None
arrays = dict(offsets=offsets, colid=colid, quesval=quesval,
left_child=left_child, leaves=leaves)
for name, dtype in _MODEL_ARRAYS:
a = arrays[name]
if not isinstance(a, Array) or a.dtype != dtype or a.ndim != 1 or a.order != 'C':
raise ValueError(f"mojolearn: model array {name!r} must be a 1-D C-order {dtype} Array")
for name, value in (('n_features', n_features), ('n_trees', n_trees), ('num_outputs', num_outputs)):
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
raise ValueError(f"mojolearn: {name} must be a positive int")
nodes = colid.size
if (offsets.size != n_trees + 1 or nodes < n_trees or quesval.size != nodes
or left_child.size != nodes or leaves.size != nodes * num_outputs
or int(offsets[0]) != 0 or int(offsets[-1]) != nodes):
raise ValueError("mojolearn: forest model array shapes do not match metadata")
if self.is_classifier:
if classes is None or len(classes) != num_outputs:
raise ValueError("mojolearn: a classifier archive must carry num_outputs classes")
self.classes_ = list(classes)
self.n_classes_ = len(self.classes_)
elif num_outputs != 1:
raise ValueError("mojolearn: a regressor archive must carry one output")
self._offsets = offsets
self._colid = colid
self._quesval = quesval
self._left_child = left_child
self._leaves = leaves
self.n_features_in_ = int(n_features)
self._n_trees = int(n_trees)
self._num_outputs = int(num_outputs)
self._binding = _load()
if inference_engine == 'parallel_groves' and not callable(
getattr(self._binding, 'forest_host_groves_prepare', None)):
raise RuntimeError(
f"mojolearn: {binary_path()} predates the parallel_groves host engine; "
"rebuild it with bindings/build_forest_host.sh")
[docs]
@classmethod
def from_file(cls, path):
"""Load a forest from a file written by a random forest or
extra-trees `save`.
The model predicts through the inference engine the file names. A
model saved with `inference_engine='parallel_groves'` must have been
saved under `numeric_mode='identical'`; otherwise this raises
`ValueError`, because the CPU engine computes `identical` results
only.
Parameters
----------
path : str or path-like
The saved model file.
Returns
-------
HostForest
"""
accepted = tuple(_FORMATS) + tuple(f + _GROVES_SUFFIX for f in _FORMATS)
arrays = _serialize.read_npz(path, accepted)
fmt = _serialize.scalar_str(arrays, 'format')
engine = 'sequential'
if fmt.endswith(_GROVES_SUFFIX):
engine = 'parallel_groves'
fmt = fmt[:-len(_GROVES_SUFFIX)]
mode = _serialize.scalar_str(arrays, 'numeric_mode').strip().lower() if 'numeric_mode' in arrays else ''
if mode != 'identical':
raise ValueError(
f"mojolearn: {path!r} is a parallel_groves archive saved under "
f"numeric_mode={mode or 'unrecorded'!r}; the host engine computes IDENTICAL "
"bits only. Save the model under numeric_mode='identical'")
estimator = _serialize.scalar_str(arrays, 'estimator')
if estimator not in _ESTIMATORS or _ESTIMATORS[estimator][0] != _FORMATS[fmt]:
raise ValueError(f"mojolearn: {path!r} was saved by {estimator}, which {fmt} does not hold")
fields = {name: _serialize.exact(arrays, name, dtype) for name, dtype in _MODEL_ARRAYS}
meta = _serialize.exact(arrays, 'meta', '<i8')
if meta.size < 3:
raise ValueError(f"mojolearn: {path!r} meta holds {meta.size} fields, at least 3 are needed")
classes = classes_from_member(arrays['classes']) if 'classes' in arrays else None
return cls(estimator=estimator, n_features=int(meta[0]), n_trees=int(meta[1]),
num_outputs=int(meta[2]), classes=classes,
device=_serialize.scalar_str(arrays, 'device') if 'device' in arrays else None,
inference_engine=engine, **fields)
@property
def n_trees(self):
"""Number of trees in the model."""
return self._n_trees
@property
def inference_engine(self):
"""The inference engine the file named: 'sequential' or
'parallel_groves'. Read-only."""
return self._inference_engine
def _groves_handle(self):
"""The host grove snapshot of this model (validated once, released
with the model), prepared on the first groves prediction."""
if self._groves is None:
family = _GROVES_FAMILY[self.family]
binding = self._binding
handle = int(binding.forest_host_groves_prepare(
[addr_ro(self._offsets, name='offsets'), addr_ro(self._colid, name='colid'),
addr_ro(self._quesval, name='quesval'), addr_ro(self._left_child, name='left_child'),
addr_ro(self._leaves, name='leaves')],
[self._n_trees, self.n_features_in_, self._num_outputs, int(self._colid.size)],
family))
finalizer = weakref.finalize(self, binding.forest_host_groves_release, handle, family)
self._groves = (handle, finalizer)
return self._groves[0]
def __getstate__(self):
state = self.__dict__.copy()
state['_groves'] = None
state.pop('_binding', None)
return state
def __setstate__(self, state):
self.__dict__.update(state)
self._binding = _load()
[docs]
def model_sha256(self):
"""SHA-256 hex digest of the model arrays' bytes, in save order.
Identifies the exact model used for a prediction.
"""
h = hashlib.sha256()
for a in (self._offsets, self._colid, self._quesval, self._left_child, self._leaves):
h.update(a.tobytes())
return h.hexdigest()
def _vote(self, X):
"""The divided vote, `(n_rows, num_outputs)` float32, what the GPU
bindings' sequential entries return before the Python layer's argmax
or widening."""
Xa, _ = as_f32_c(X, ndim=2, name='X')
n_rows, n_features = Xa.shape
if n_features != self.n_features_in_:
raise ValueError(f"X has {n_features} features, fit saw {self.n_features_in_}")
out = empty((n_rows * self._num_outputs,), '<f4')
if self._inference_engine == 'parallel_groves':
entry = 'forest_host_groves_predict'
wrote = self._binding.forest_host_groves_predict(
self._groves_handle(), addr_ro(Xa, name='X'), addr(out, name='out'),
[int(n_rows), int(n_features), self._num_outputs], _GROVES_FAMILY[self.family])
if int(wrote) != n_rows:
raise RuntimeError(f"{entry} wrote {wrote} of {n_rows} rows")
return out.reshape((n_rows, self._num_outputs))
if self.family == 'rf':
entry = 'forest_host_rf_predict_proba' if self.is_classifier else 'forest_host_rf_predict_reg'
else:
entry = 'forest_host_et_predict'
wrote = getattr(self._binding, entry)(
[addr_ro(self._offsets, name='offsets'), addr_ro(self._colid, name='colid'),
addr_ro(self._quesval, name='quesval'), addr_ro(self._left_child, name='left_child'),
addr_ro(self._leaves, name='leaves'), addr_ro(Xa, name='X'), addr(out, name='out')],
[int(n_rows), int(n_features), self._n_trees, self._num_outputs, int(self._colid.size)])
if int(wrote) != n_rows:
raise RuntimeError(f"{entry} wrote {wrote} of {n_rows} rows")
return out.reshape((n_rows, self._num_outputs))
[docs]
def predict_proba(self, X):
"""Class probabilities, for classifier models only.
Returns the same values as the estimator's own `predict_proba`:
float32 for a random forest, float64 for extra trees, shape
(n_samples, n_classes). A regressor model raises `AttributeError`.
"""
if not self.is_classifier:
raise AttributeError(f"{self.estimator} has no predict_proba")
vote = self._vote(X)
return vote if self.family == 'rf' else vote.astype('<f8')
def _predict_with_proba(self, X):
"""The two classifier views from one host traversal.
Kept in lockstep with the RF/ET estimator helper used by the identity
harness, including Extra Trees' exact float32-to-float64 widening.
"""
if not self.is_classifier:
raise AttributeError(f"{self.estimator} has no predict_proba")
vote = self._vote(X)
proba = vote if self.family == 'rf' else vote.astype('<f8')
return decode_labels(self.classes_, argmax_rows(vote)), proba
[docs]
def predict(self, X):
"""Predict with the saved model.
For a classifier, the class with the highest probability (first
maximum winning) mapped through `classes_`. For a regressor, the mean
of the trees' predictions: float32 for a random forest, float64 for
extra trees.
"""
vote = self._vote(X)
if self.is_classifier:
return decode_labels(self.classes_, argmax_rows(vote))
flat = vote.reshape((vote.shape[0],))
return flat if self.family == 'rf' else flat.astype('<f8')
# Maintainer note (moved from the docstring): HostGBDT lives in
# `_gbdt_host.py`; the classical and knn host models in `_classical_host.py`.
[docs]
def host_model(path):
"""Load a saved model for prediction on the CPU.
Chooses the CPU model from the file's format: a `HostForest` for a random
forest or extra-trees file, a `HostGBDT` for a `GradientBoosting.save`
file, or the CPU model for a saved LinearRegression, Ridge, TruncatedSVD,
LogisticRegression, PCA, NearestNeighbors, KNeighborsClassifier,
KNeighborsRegressor, StandardScaler, MinMaxScaler, ElasticNet, Lasso,
KernelRidge, Nystroem or RBFSampler. Any other format raises an error
naming the format.
Parameters
----------
path : str or path-like
The saved model file.
Returns
-------
model
An object with `predict` (and `predict_proba` for classifiers).
Examples
--------
>>> import numpy as np, mojolearn
>>> X = np.random.default_rng(0).random((1000, 8), dtype=np.float32)
>>> mojolearn.RandomForestRegressor(n_estimators=20).fit(X, X[:, 0]).save("rf.npz")
>>> pred = mojolearn.host_model("rf.npz").predict(X)
"""
from ._classical_host import CLASSICAL_FORMATS
from ._classical_host import host_model as classical_host_model
from ._gbdt_host import GBDT_FORMAT, HostGBDT
accepted = (tuple(_FORMATS) + tuple(f + _GROVES_SUFFIX for f in _FORMATS)
+ (GBDT_FORMAT,) + CLASSICAL_FORMATS)
routing = _serialize.peek_npz(path, ('format',))
fmt = _serialize.scalar_str(routing, 'format') if 'format' in routing else ''
if fmt not in accepted:
# Preserve the old malformed-archive error precedence: before the
# routing peek, every member was decoded before the format refusal.
# The full reader raises either that earlier member error or the
# same format mismatch; this branch cannot return successfully.
_serialize.read_npz(path, accepted)
raise AssertionError("read_npz accepted a format rejected by the router")
if fmt == GBDT_FORMAT:
return HostGBDT.from_file(path)
if fmt in CLASSICAL_FORMATS:
return classical_host_model(path)
return HostForest.from_file(path)
[docs]
def host_predict(path, X):
"""Predict on the CPU with a saved model: `host_model(path).predict(X)`.
Parameters
----------
path : str or path-like
The saved model file.
X : array-like of shape (n_samples, n_features)
Input data.
Returns
-------
Array or list of shape (n_samples,)
Predictions, as the saved model's `predict` returns them.
"""
return host_model(path).predict(X)
[docs]
def host_predict_proba(path, X):
"""Class probabilities on the CPU from a saved classifier:
`host_model(path).predict_proba(X)`.
Parameters
----------
path : str or path-like
The saved model file.
X : array-like of shape (n_samples, n_features)
Input data.
Returns
-------
Array of shape (n_samples, n_classes)
Class probabilities.
"""
return host_model(path).predict_proba(X)