Metadata-Version: 2.4
Name: biomimetic-framework
Version: 0.2.1
Summary: Biomimetic Lookup Cell — parametric geodesic lookup framework for machine learning
License: MIT
Project-URL: Homepage, https://github.com/SariItani/bmc
Project-URL: Documentation, https://SariItani.github.io/bmc
Project-URL: Repository, https://github.com/SariItani/bmc
Project-URL: Issues, https://github.com/SariItani/bmc/issues
Keywords: machine-learning,lookup-table,voronoi,geodesic,gradient-free
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: numpy>=1.24
Requires-Dist: scipy>=1.10
Provides-Extra: image
Requires-Dist: Pillow>=9.0; extra == "image"
Requires-Dist: torch>=2.0; extra == "image"
Requires-Dist: opencv-python>=4.7; extra == "image"
Provides-Extra: gpu
Requires-Dist: torch>=2.0; extra == "gpu"
Provides-Extra: viz
Requires-Dist: rich>=12.0; extra == "viz"
Requires-Dist: graphviz>=0.20; extra == "viz"
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"

# BMC API Reference — 0.2.1

A gradient-free machine learning framework. Instead of learned weights, BMC
stores empirical patterns in Voronoi cells ordered along a geodesic path
through the data manifold. No backpropagation, no loss functions, no optimizer.

Based on papers by Dr. Ziad Kobti:

- [Bio-mimetic approach in digital artificial neuro-science](https://ieeexplore.ieee.org/document/7560120)
- [Biomimetic Cells: A New Frontier in Brain Informatics](https://ieeexplore.ieee.org/abstract/document/8603044)
- [Advanced Biomimetic Cells Architecture for Parallel Operations in AI](https://ieeexplore.ieee.org/abstract/document/8851116)

Install: `pip install biomimetic-framework`

---

## Architecture

```
Input → ObservationSpace → BMCBlock (N independent BMCLayers)
                                ↓
                          PredictionHead (cross-zone patterns)
                                ↓
                             Output
```

## Three Orthogonal Axes (0.2.1)

BMC has three independent design axes. Each axis can be configured without
affecting the others:

| Axis | Values | Where |
|---|---|---|
| **Activation** | `logical` (AND/OR exact match) vs `metric` (closest-wins) | `bmc.core.activation` |
| **Readout** | `exact` (fired cell's stored value) vs `interpolated` (geodesic blend) | `BMCLayer.forward` |
| **Topology** (new in 0.2.1) | `unsupervised` (density-based k-means++) vs `stratified` (per-class k-means++) vs user callable | `bmc.core.fitting.cluster` |
| **Tagging** (new in 0.2.1) | `argmax` (Voronoi majority) vs `seed_class` (preserves class each seed was born under) vs `weighted` (inverse-frequency) | `bmc.core.fitting.group_by_output` |

### Quick Topology Decision Guide

- **Balanced classes, well-separated features**: `topology='unsupervised'` (default). No need for the fix.
- **Class boundaries don't align with feature-density peaks** (text embeddings, shared visual territory, etc.): **`topology='stratified'`, `tag_mode='seed_class'`**. On 20 Newsgroups this lifts macro-F1 by +0.74 at K=8.
- **Imbalanced classes where minorities visually overlap majorities** (e.g. handwritten `c`/`C`): same as above.
- **Mild imbalance, balanced features**: `topology='unsupervised'` + `tag_mode='weighted'` as a lightweight alternative.
- **Regression (continuous y)**: topology has no effect — stratified auto-downgrades to unsupervised.

### Supervised Topology Example

```python
from bmc import BMCLayer, ObservationSpace, L2Metric, metric_activation

inp = ObservationSpace(dim=256, dtype=np.float32)
out = ObservationSpace(dim=1, dtype=np.int32)
layer = BMCLayer(inp)

# Default (0.2.0 behavior): unsupervised topology, argmax tagging
layer.fit(X, y, n_clusters=50, output_space=out,
          activation=metric_activation(L2Metric()))

# New (0.2.1): class-stratified topology with seed_class tagging
layer.fit(X, y, n_clusters=50, output_space=out,
          activation=metric_activation(L2Metric()),
          topology='stratified', tag_mode='seed_class')

# Custom per-class budget via dict
layer.fit(X, y, n_clusters=50, output_space=out,
          activation=metric_activation(L2Metric()),
          topology='stratified', tag_mode='seed_class',
          k_per_class={0: 20, 1: 15, 2: 10, 3: 5})

# Square-root (default), proportional, or equal allocator
layer.fit(X, y, n_clusters=50, output_space=out,
          activation=metric_activation(L2Metric()),
          topology='stratified', tag_mode='seed_class',
          k_per_class='equal')
```

---

## Allocators — per-class seed budgets

For `topology='stratified'`, the allocator decides how to distribute K seeds
across classes. Built-in allocators (`bmc.core.allocation`):

| Allocator | Rule | Use case |
|---|---|---|
| `sqrt_allocator` *(default)* | `k_c ∝ √n_c` | Textbook anti-imbalance: minorities get more than proportional but majority still dominates on big differences |
| `proportional_allocator` | `k_c ∝ n_c` | Matches natural class distribution; does NOT help with imbalance |
| `equal_allocator` | `k_c = K / n_classes` | Fully balanced; strongest anti-imbalance setting |

All allocators satisfy: `sum(k_c) == K`, `1 <= k_c <= counts[c]`, deterministic.
User allocators can be any callable with the signature
`(labels, counts, K) -> np.ndarray[int]`, or a dict `{label: k_c}`, or a raw
`np.ndarray` of length `n_classes`.

```python
from bmc.core.allocation import sqrt_allocator, equal_allocator, resolve_allocator

# Resolver accepts str, callable, ndarray, dict, or None
sqrt_allocator == resolve_allocator('sqrt')
sqrt_allocator == resolve_allocator(None)   # default

# On counts=[80, 10, 5, 3, 2] with K=50:
#   sqrt         → [31, 9, 5, 3, 2]    (minorities get more)
#   proportional → [40, 5, 3, 2, 1]   (matches density)
#   equal        → [10, 10, 10, 10, 10]  (fully balanced; capped if n_c < K/n_classes)
```

---


## Table of Contents


- [Core Primitives](#core-primitives)
  - [ObservationSpace](#observationspace)
  - [BMCCell](#bmccell)
  - [Activation Functions](#activation-functions)
- [Fitting Pipeline](#fitting-pipeline)
  - [Fitting Pipeline](#fitting-pipeline)
  - [allocation](#allocation)
  - [Distortion Functions](#distortion-functions)
- [Composition](#composition)
  - [BMCLayer](#bmclayer)
  - [BMCBlock](#bmcblock)
  - [BMCNetwork](#bmcnetwork)
  - [PredictionHead](#predictionhead)
  - [GateBMC](#gatebmc)
  - [MuxBMC](#muxbmc)
  - [SkipBMC](#skipbmc)
- [Metrics](#metrics)
  - [SimilarityMetric](#similaritymetric)
  - [L2Metric](#l2metric)
  - [CosineMetric](#cosinemetric)
  - [FrobeniusMetric](#frobeniusmetric)
  - [SpectralMetric](#spectralmetric)
  - [NuclearMetric](#nuclearmetric)
- [Interpolators](#interpolators)
  - [LinearInterpolator](#linearinterpolator)
  - [SplineInterpolator](#splineinterpolator)
  - [PCHIPInterpolator](#pchipinterpolator)
  - [KrigingInterpolator](#kriginginterpolator)
  - [KernelInterpolator](#kernelinterpolator)
- [Observation Spaces](#observation-spaces)
  - [TabularObservationSpace](#tabularobservationspace)
  - [ImageObservationSpace](#imageobservationspace)
  - [TimeSeriesObservationSpace](#timeseriesobservationspace)
  - [TextObservationSpace](#textobservationspace)
- [Infrastructure](#infrastructure)
  - [Constants](#constants)
  - [Backend](#backend)

---


## Core Primitives

### ObservationSpace

Describes an input or output data space — dimensionality, dtype, and name.

#### Import

```python
from bmc import ObservationSpace
```

#### Constructor

```python
ObservationSpace(dim, dtype=np.float32, name=None)
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `dim` | `int` | required | Dimensionality. Must be ≥ 1. |
| `dtype` | `np.dtype` | `float32` | Data type of values. |
| `name` | `str` | auto | Human-readable label. |

#### Methods

##### `validate(data) → np.ndarray`

Checks last dimension matches `dim`, casts dtype if needed.

**Raises** `ValueError` if dimension mismatch.

#### Properties

`dim`, `dtype`, `name`

#### Example

```python
inp = ObservationSpace(dim=5, dtype=np.float32, name="features")
out = ObservationSpace(dim=1, dtype=np.int32, name="label")

X = np.array([[1.0, 2.0, 3.0, 4.0, 5.0]])
X_valid = inp.validate(X)  # shape (1, 5), dtype float32
```

#### See Also

**BMCCell**, **BMCLayer**

---

### BMCCell

The core primitive — a lookup table with K weights and 1 output. Pluggable activation (logical or metric).

#### Import

```python
from bmc import BMCCell
```

#### Constructor

```python
BMCCell(input_space, output_space, activation=None)
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `input_space` | `ObservationSpace` | required | Input data space. |
| `output_space` | `ObservationSpace` | required | Output data space. |
| `activation` | `callable` | `logical_activation` | Activation function. Use `metric_activation(L2Metric())` for distance-based. |

#### Methods

##### `memorize(weights, output) → self`

Store weight-output pairs. All weights must be unique. NaN rejected.

| Parameter | Type | Description |
|---|---|---|
| `weights` | `(K, D)` array | Input patterns. Each row is one key. |
| `output` | `(O,)` array | The single output produced when any weight matches. |

##### `forward(inputs) → (fired, outputs)`

Activate the cell on input(s).

| Parameter | Type | Description |
|---|---|---|
| `inputs` | `(M, D)` array | Query inputs. |

**Returns:**
- `fired`: `(M,)` float — 1.0 if activated, 0.0 if not.
- `outputs`: `(M, O)` array — cell output for firing inputs, zero otherwise.

#### Properties

| Property | Type | Description |
|---|---|---|
| `K` | `int` | Number of stored weights |
| `D` | `int` | Input dimensionality |
| `O` | `int` | Output dimensionality |
| `is_empty` | `bool` | True if no weights stored |
| `weights_` | `(K, D)` array | Stored weights |
| `output_` | `(O,)` array | Stored output |

#### Example

```python
from bmc import BMCCell, ObservationSpace, L2Metric, metric_activation
import numpy as np

inp = ObservationSpace(dim=2, dtype=np.float32)
out = ObservationSpace(dim=1, dtype=np.int32)

### Logical activation (exact match)
cell = BMCCell(inp, out)
cell.memorize(
    np.array([[0, 0], [0, 1], [1, 0]], dtype=np.float32),
    np.array([1], dtype=np.int32),
)
fired, outputs = cell.forward(np.array([[0, 1], [5, 5]], dtype=np.float32))
### fired  → [1.0, 0.0]  (first matches, second doesn't)
### outputs → [[1], [0]]

### Metric activation (closest wins)
cell_m = BMCCell(inp, out, activation=metric_activation(L2Metric()))
cell_m.memorize(np.array([[0, 0]], dtype=np.float32), np.array([0], dtype=np.int32))
fired, outputs = cell_m.forward(np.array([[99, 99]], dtype=np.float32))
### fired → [1.0]  (metric always fires)
```

#### See Also

**ObservationSpace**, **activation**, **BMCLayer**

---

### Activation Functions

Determine HOW a cell matches inputs to weights. Pluggable callables with signature `(inputs, weights) → (fired, indices)`.

#### Import

```python
from bmc import logical_activation, metric_activation
```

#### logical_activation

```python
logical_activation(inputs, weights) → (fired, indices)
```

Exact-match AND per weight, OR across weights. The base activation from BMC theory.

- `fired`: `(M,)` float — 1.0 if any weight matched, 0.0 otherwise.
- `indices`: `(M,)` int — index of first matching weight, -1 if none.

#### metric_activation

```python
metric_activation(metric_fn) → callable
```

Factory: wraps a `SimilarityMetric` into a closest-wins activation.

| Parameter | Type | Description |
|---|---|---|
| `metric_fn` | `SimilarityMetric` | Must be a `SimilarityMetric` instance. Type-enforced. |

**Returns** a callable with same signature as `logical_activation`. Always fires (there is always a closest weight).

**Raises** `TypeError` if `metric_fn` is not a `SimilarityMetric`.

#### Example

```python
import numpy as np
from bmc import logical_activation, metric_activation, L2Metric

weights = np.array([[0, 0], [1, 1]], dtype=np.float32)
inputs = np.array([[0, 0], [0.6, 0.6]], dtype=np.float32)

### Logical: exact match only
fired, idx = logical_activation(inputs, weights)
### fired → [1.0, 0.0]   (only [0,0] matches exactly)
### idx   → [0, -1]

### Metric: closest wins
act = metric_activation(L2Metric())
fired, idx = act(inputs, weights)
### fired → [1.0, 1.0]   (always fires)
### idx   → [0, 1]       ([0.6,0.6] closer to [1,1])
```

#### See Also

**BMCCell**, **SimilarityMetric**

---


## Fitting Pipeline

### Fitting Pipeline

Four standalone functions for training BMCLayers. Each is independently callable. `layer.fit()` chains them.

#### Import

```python
from bmc import cluster, assign, group_by_output, order_cells
```

#### cluster

```python
cluster(X, n_clusters, metric=None, rng=None, max_iter=50, tol=1e-4,
        *, y=None, topology='unsupervised', k_per_class=None,
        return_labels=False) → seeds  OR  (seeds, seed_labels)
```

K-means++ initialization + Lloyd's refinement, with an optional supervised topology axis.

| Parameter | Type | Description |
|---|---|---|
| `X` | `(M, D)` | Training data. |
| `n_clusters` | `int` | Number of seeds K. |
| `metric` | `SimilarityMetric` | Default: `L2Metric()`. |
| `rng` | `Generator` | For reproducibility. |
| `max_iter` | `int` | Lloyd iterations. |
| `tol` | `float` | Convergence tolerance. |
| `y` | `(M,)` | Class labels. Required when `topology='stratified'`. |
| `topology` | `str` or callable | `'unsupervised'` (default, pre-0.2.1 behavior), `'stratified'` (per-class k-means++), or a user callable `(X, y, K, rng, metric) -> seeds` or `-> (seeds, labels)`. |
| `k_per_class` | `str \| callable \| ndarray \| dict` | Allocator for stratified mode — see [Allocators](#allocators). None → `sqrt_allocator`. |
| `return_labels` | `bool` | If True, return `(seeds, seed_labels)` — needed for `tag_mode='seed_class'` downstream. |

**Returns**: `seeds` `(K, D)` when `return_labels=False`. Otherwise `(seeds, seed_labels)` where `seed_labels` is `(K,)` with the class each seed was born under (or `None` for unsupervised).

##### Unsupervised vs Stratified

**Unsupervised** (default, byte-identical to 0.2.0): k-means++ places seeds at feature-density peaks. Labels enter only at `group_by_output` via `argmax(counts)`. On overlapping or imbalanced classes, minorities can be silenced.

**Stratified** (new in 0.2.1): per-class k-means++. Each class's samples are clustered independently with an allocated seed budget. The seed pool always represents every class.

#### assign

```python
assign(X, seeds, metric=None) → assignments
```

Voronoi tessellation: assign each point to its nearest seed.

**Returns**: `(M,)` int array, index of the nearest seed for each point.

#### group_by_output

```python
group_by_output(seeds, assignments, y, input_space, output_space, activation=None,
                *, tag_mode='argmax', seed_labels=None) → list[BMCCell]
```

Construct BMCCells by grouping seeds that share an output label.

| Parameter | Description |
|---|---|
| `seeds`, `assignments`, `y` | From `cluster()` and `assign()`. |
| `input_space`, `output_space` | Input/output descriptors. |
| `activation` | BMCCell activation. |
| `tag_mode` | How to assign labels to seeds — see below. |
| `seed_labels` | Required when `tag_mode='seed_class'`. Usually produced by `cluster(return_labels=True, topology='stratified')`. |

**Tag modes** (discrete / integer `y` only; continuous `y` ignores this):

- `'argmax'` *(default, pre-0.2.1 behavior)*: each seed's label is the majority class among the training points in its Voronoi region. On overlapping classes, minorities can lose.
- `'seed_class'`: each seed keeps the class it was born under (from stratified clustering). Every class in `seed_labels` gets at least one cell. Pair this with `topology='stratified'` for the full supervised-topology effect.
- `'weighted'`: inverse-frequency-weighted bincount. Minority samples count more per vote. Useful for mild imbalance without stratified seeding. (Note: the current `1/n_c` weighting is aggressive and may flip labels on overwhelmingly-majority cells; a gentler weighting is planned for 0.2.2.)

**Returns**: list of fitted BMCCells, one per unique class in the chosen tag scheme.

#### order_cells

```python
order_cells(cells, metric=None) → list[BMCCell]
```

Reorder cells along the geodesic via TSP (nearest-neighbor + 2-opt). Required for interpolated readout.

**Returns**: same cells, TSP-ordered.

#### Allocators

Used only when `topology='stratified'`. `k_per_class` accepts:

- `None` → default `sqrt_allocator` (textbook anti-imbalance)
- `'sqrt'`, `'proportional'`, `'equal'` → lookup in `bmc.core.allocation.ALLOCATORS`
- A callable with signature `(labels, counts, K) -> (n_classes,) int`
- An `np.ndarray` of per-class budgets (validated at call time)
- A `dict` `{label: k_c}` (validated at call time)

#### Examples

##### Unsupervised (default)

```python
import numpy as np
from bmc import cluster, assign, group_by_output, ObservationSpace

X = np.array([[0,0],[0,1],[1,0],[5,5],[5,6],[6,5]], dtype=np.float32)
y = np.array([0, 0, 0, 1, 1, 1], dtype=np.int32)

seeds = cluster(X, n_clusters=4, rng=np.random.default_rng(42))
assignments = assign(X, seeds)

inp = ObservationSpace(dim=2, dtype=np.float32)
out = ObservationSpace(dim=1, dtype=np.int32)
cells = group_by_output(seeds, assignments, y, inp, out)
### len(cells) → 2  (one per class)
```

##### Stratified + seed_class

```python
seeds, seed_labels = cluster(
    X, n_clusters=4, rng=np.random.default_rng(42),
    y=y, topology='stratified', return_labels=True,
)
assignments = assign(X, seeds)
cells = group_by_output(
    seeds, assignments, y, inp, out,
    tag_mode='seed_class', seed_labels=seed_labels,
)
### Every class in y guaranteed at least one cell.
```

##### Custom allocator

```python
### Give class 0 more seeds than sqrt would allocate
seeds, seed_labels = cluster(
    X, n_clusters=10, rng=np.random.default_rng(0),
    y=y, topology='stratified', k_per_class={0: 7, 1: 3},
    return_labels=True,
)
```

#### See Also

**BMCLayer.fit()**, **distortion**

---

### allocation

Per-class seed allocators for stratified clustering. New in 0.2.1.

#### Import

```python
from bmc.core.allocation import (
    sqrt_allocator,
    proportional_allocator,
    equal_allocator,
    resolve_allocator,
    ALLOCATORS,
)
```

#### Built-in Allocators

All three have the same signature:

```python
allocator(labels: np.ndarray, counts: np.ndarray, K: int) -> np.ndarray[int]
```

| Allocator | Rule | Typical output on `counts=[80, 10, 5, 3, 2]`, `K=50` |
|---|---|---|
| `sqrt_allocator` *(default)* | `k_c ∝ √n_c` | `[31, 9, 5, 3, 2]` — anti-imbalance |
| `proportional_allocator` | `k_c ∝ n_c` | `[40, 5, 3, 2, 1]` — matches density |
| `equal_allocator` | `k_c ≈ K / n_classes` | `[10, 10, 10, 10, 10]` — max balance |

**Invariants** (every allocator satisfies):

- `sum(k_c) == K`
- `1 <= k_c <= counts[c]` (at least one seed per class, capped at per-class sample count)
- Deterministic output for the same inputs
- Raises `ValueError` when `K < n_classes` or `K > sum(counts)`

#### resolve_allocator

```python
resolve_allocator(spec, default=sqrt_allocator) → callable
```

Resolves a user-supplied spec to an allocator callable:

| Spec type | Resolved to |
|---|---|
| `None` | `default` (sqrt) |
| `str` | Registry lookup: `'sqrt'`, `'proportional'`, `'equal'` |
| `callable` | Used directly; must match allocator signature |
| `np.ndarray` | Fixed allocation, validated at call time |
| `dict` `{label: k_c}` | Fixed allocation, keyed by labels, validated at call time |

#### Example

```python
import numpy as np
from bmc.core.allocation import sqrt_allocator, resolve_allocator

labels = np.array([0, 1, 2, 3, 4])
counts = np.array([80, 10, 5, 3, 2])
K = 50

k = sqrt_allocator(labels, counts, K)
### k → array([31, 9, 5, 3, 2])
assert k.sum() == K
assert np.all(k >= 1)
assert np.all(k <= counts)

### User-specified via dict
alloc = resolve_allocator({0: 20, 1: 15, 2: 10, 3: 3, 4: 2})
k_custom = alloc(labels, counts, 50)
### k_custom → array([20, 15, 10, 3, 2])
```

#### See Also

**cluster**, **group_by_output**

---

### Distortion Functions

Measure where a layer fails. Used by GateBMC for routing decisions.

#### Import

```python
from bmc import distortion_residual, distortion_reconstruction
```

#### distortion_residual (default)

```python
distortion_residual(layer, X, y, metric=None) → scores
```

Per-cell mean |target − prediction|. Task-aware.

**Returns**: `(N,)` float — higher = more distortion.

#### distortion_reconstruction

```python
distortion_reconstruction(layer, X, y=None, metric=None) → scores
```

Per-cell mean nearest-weight distance. Task-independent.

**Returns**: `(N,)` float — higher = coarser tessellation.

#### Example

```python
scores_r = distortion_residual(layer, X_train, y_train)
scores_g = distortion_reconstruction(layer, X_train)
### Both shape (N,) — one score per cell
### High scores → cell needs refinement
```

#### See Also

**GateBMC**, **SkipBMC**

---


## Composition

### BMCLayer

Parallel container of BMCCells. Competitive activation — one cell wins per input. Supports fitting, ordering, and interpolated forward.

#### Import

```python
from bmc import BMCLayer
```

#### Constructor

```python
BMCLayer(input_space, cells=None)
```

| Parameter | Type | Description |
|---|---|---|
| `input_space` | `ObservationSpace` | Shared input space for all cells. |
| `cells` | `list[BMCCell]` | Optional initial cells. |

#### Methods

##### `add(cell) → self`
Add a cell. Must have matching input space.

##### `fit(X, y, n_clusters, output_space, ...) → self`
4-step pipeline: cluster → assign → group → order (each bypassable).

| Parameter | Type | Default | Description |
|---|---|---|---|
| `X` | `(M, D)` | required | Training inputs. |
| `y` | `(M,)` | required | Training targets. |
| `n_clusters` | `int` | required | Voronoi seeds K. |
| `output_space` | `ObservationSpace` | required | Cell output space. |
| `metric` | `SimilarityMetric` | L2 | Distance metric. |
| `activation` | `callable` | None | Cell activation function. |
| `seeds` | `(K,D)` | None | Skip clustering if provided. |
| `assignments` | `(M,)` | None | Skip assignment if provided. |
| `order` | `bool` | True | TSP-order cells after grouping. |
| `topology` | `str` or callable *(keyword-only)* | `'unsupervised'` | Clustering topology. See **fitting**. Pre-0.2.1 default. |
| `tag_mode` | `str` *(keyword-only)* | `'argmax'` | Label tagging rule. See **fitting**. Pre-0.2.1 default. |
| `k_per_class` | `str`/callable/ndarray/dict *(keyword-only)* | None | Allocator for stratified mode. |
| `seed_labels` | `(K,)` *(keyword-only)* | None | Required with `tag_mode='seed_class'` if user-supplied `seeds` are also passed. |

##### `order(metric) → self`
Reorder cells along geodesic via TSP.

##### `forward(X, interpolator=None, metric=None) → (fired, outputs)`
- Hard: `fired` is `(M, N)` with one 1.0 per row.
- Interpolated: two neighboring cells share the signal as arc-length ratios.

##### `compute_arc_lengths(metric) → (N,) float`

#### Properties

`N`, `D`, `cells`, `is_empty`, `is_ordered`

#### Example

```python
from bmc import BMCLayer, ObservationSpace, L2Metric, metric_activation
import numpy as np

X = np.array([[0,0],[0,1],[5,5],[5,6]], dtype=np.float32)
y = np.array([0, 0, 1, 1], dtype=np.int32)

inp = ObservationSpace(dim=2, dtype=np.float32)
out = ObservationSpace(dim=1, dtype=np.int32)
act = metric_activation(L2Metric())

layer = BMCLayer(inp)
layer.fit(X, y, n_clusters=4, output_space=out, activation=act)
### layer.N → 2 (one cell per class)

fired, outputs = layer.forward(np.array([[1,1],[4,4]], dtype=np.float32))
### fired[0] → [1.0, 0.0]  (closer to class 0)
### fired[1] → [0.0, 1.0]  (closer to class 1)
```

##### Supervised Topology

```python
### On imbalanced or overlapping classes, use stratified topology with
### seed_class tagging so minority classes always get dedicated seeds.
layer.fit(X, y, n_clusters=50, output_space=out, activation=act,
          topology='stratified', tag_mode='seed_class')

### Custom per-class budget
layer.fit(X, y, n_clusters=50, output_space=out, activation=act,
          topology='stratified', tag_mode='seed_class',
          k_per_class='equal')   # or 'sqrt' (default), 'proportional',
                                 # or a dict / ndarray / callable
```

#### See Also

**BMCCell**, **BMCBlock**, **fitting**

---

### BMCBlock

Parallel container of independent BMCLayers. All layers fire independently — no competition between layers. Used for zone decomposition.

#### Import

```python
from bmc import BMCBlock
```

#### Constructor

```python
BMCBlock(layers=None, input_fns=None)
```

| Parameter | Type | Description |
|---|---|---|
| `layers` | `list[BMCLayer]` | Independent layers. |
| `input_fns` | `list[callable]` | Per-layer input mapping. `fn(X) → X_zone`. None = identity. |

#### Methods

##### `add(layer, input_fn=None) → self`

##### `forward(X, interpolator=None, metric=None) → list[(fired, outputs)]`
Runs ALL layers independently. Returns one `(fired, outputs)` per layer.

##### `fit(X, y, n_clusters_per_layer, output_space, ...) → self`
Fits all layers independently on their respective inputs.

##### `flatten_fired(results) → (M, total_cells)`
Static method. Concatenates fired signals from all layers.

#### Properties

`N` (num layers), `layers`, `total_cells`, `is_empty`

#### Example

```python
from bmc import BMCBlock, BMCLayer, ObservationSpace, L2Metric, metric_activation
import numpy as np

X = np.random.randn(100, 10).astype(np.float32)
y = np.array([0]*50 + [1]*50, dtype=np.int32)
act = metric_activation(L2Metric())
out = ObservationSpace(dim=1, dtype=np.int32)

### 2 zones: features 0-4 and 5-9
l0 = BMCLayer(ObservationSpace(dim=5, dtype=np.float32))
l0.fit(X[:, :5], y, n_clusters=8, output_space=out, activation=act)
l1 = BMCLayer(ObservationSpace(dim=5, dtype=np.float32))
l1.fit(X[:, 5:], y, n_clusters=8, output_space=out, activation=act)

block = BMCBlock(
    layers=[l0, l1],
    input_fns=[lambda X: X[:, :5], lambda X: X[:, 5:]],
)
results = block.forward(X)
### len(results) → 2, one per zone
### results[0] = (fired_zone0, outputs_zone0)

flat = BMCBlock.flatten_fired(results)
### flat.shape → (100, total_cells)
```

#### See Also

**BMCLayer**, **PredictionHead**, **SkipBMC**

---

### BMCNetwork

Sequential container — layers chained via `reduce_output()`.

#### Import

```python
from bmc import BMCNetwork
```

#### Constructor

```python
BMCNetwork(layers=None)
```

#### Methods

##### `add(layer) → self`
Validates dimension matching between consecutive layers.

##### `forward(X, interpolator=None, metric=None) → (fired, outputs)`
Forward through all layers. Each layer's output is reduced to `(M, O)` for the next.

##### `forward_at(X, depth) → (fired, outputs)`
Forward through layers 0..depth (inclusive).

##### `reduce_output(fired, outputs) → (M, O)` (static)
Fired-weighted sum of cell outputs.

#### Properties

`depth`, `layers`, `input_space`, `is_empty`

#### Example

```python
from bmc import BMCNetwork

net = BMCNetwork()
net.add(layer_0)  # input D, output O₀
net.add(layer_1)  # input O₀, output O₁
fired, outputs = net.forward(X)

### reduce_output for manual chaining:
intermediate = BMCNetwork.reduce_output(fired_0, outputs_0)
### intermediate.shape → (M, O₀)
```

#### See Also

**BMCLayer**, **BMCBlock**

---

### PredictionHead

Reads cross-layer activation patterns from a BMCBlock. Finds discriminative patterns like "zone 3 + zone 7 active → class A."

#### Import

```python
from bmc import PredictionHead
```

#### Class Methods

##### `from_block(block, output_space, n_clusters, ...) → PredictionHead`

| Parameter | Type | Description |
|---|---|---|
| `block` | `BMCBlock` | The block whose patterns to read. |
| `output_space` | `ObservationSpace` | Task output space. |
| `n_clusters` | `int` | K for the head's clustering. |

#### Methods

##### `fit_from_block(block, X, y, interpolator=None, metric=None) → self`
Forward block on X, flatten fired signals, fit head on flattened patterns.

##### `predict_from_block(block, X, ...) → (fired, outputs)`
Forward block, flatten, forward head.

##### `flatten_block(block_results) → (M, total_cells)` (static)
Concatenates fired signals from all block layers.

#### Example

```python
from bmc import BMCBlock, PredictionHead, ObservationSpace

### block has 4 zones, each with 3 cells → total_cells = 12
head = PredictionHead.from_block(block, 
    output_space=ObservationSpace(dim=1, dtype=np.int32),
    n_clusters=16)
head.fit_from_block(block, X_train, y_train)

fired, outputs = head.predict_from_block(block, X_test)
preds = np.array([int(outputs[np.argmax(fired[m])][m, 0]) 
                   for m in range(len(X_test))])
```

#### See Also

**BMCBlock**, **GateBMC**

---

### GateBMC

Binary soft router between resolution levels. A BMCLayer with 2 cells: low-distortion (stay) and high-distortion (defer to finer layer).

#### Import

```python
from bmc import GateBMC
```

#### Constructor

```python
GateBMC(input_space, n_clusters=8, metric=None, rng=None)
```

#### Methods

##### `fit_from_distortion(parent, X, y, distortion_fn=distortion_residual, threshold=None) → self`
Trains on binary distortion labels derived from the parent layer.

##### `route(X, interpolator=None, metric=None) → (M,) float`
Returns routing weight per input in [0, 1]. 0 = stay, 1 = defer.

#### Properties

`distortion_scores` — `(N,)` per-cell scores from last fit.

#### Example

```python
from bmc import GateBMC, distortion_residual

gate = GateBMC(parent.input_space, n_clusters=8)
gate.fit_from_distortion(parent, X_train, y_train)

weights = gate.route(X_test)
### weights[m] ≈ 0.0 → trust parent layer
### weights[m] ≈ 1.0 → defer to finer layer
```

#### See Also

**SkipBMC**, **distortion**

---

### MuxBMC

Learned interpolator — compiles a mathematical interpolator into a BMC. The interpolator is the "teacher," the MUX is the "student."

#### Import

```python
from bmc import MuxBMC
```

#### Class Methods

##### `from_layer(parent, n_clusters=16, metric=None, rng=None) → MuxBMC`

#### Methods

##### `compile(parent, X, interpolator=None, metric=None) → self`
Run parent with interpolator, learn the blending pattern.

##### `blend(X, metric=None) → (M, N_parent) float`
Produce learned blending weights. Non-negative, rows sum to 1.

#### Example

```python
from bmc import MuxBMC, LinearInterpolator

mux = MuxBMC.from_layer(parent_layer, n_clusters=16)
mux.compile(parent_layer, X_train, interpolator=LinearInterpolator())

weights = mux.blend(X_test)
### weights.shape → (M, N_parent), rows sum to 1
```

#### See Also

**BMCLayer**, **Interpolators**

---

### SkipBMC

Parallel-layer refinement: BMCBlock(parent, finer) + GateBMC router. Recursive — finer layer can be another SkipBMC.

#### Import

```python
from bmc import SkipBMC
```

#### Constructor

```python
SkipBMC(parent, n_clusters_gate=8, distortion_fn=distortion_residual, metric=None, rng=None)
```

| Parameter | Type | Description |
|---|---|---|
| `parent` | `BMCLayer` or `SkipBMC` | Coarse layer to refine. |
| `n_clusters_gate` | `int` | Gate K. |
| `distortion_fn` | `callable` | Default: `distortion_residual`. |

#### Methods

##### `fit(X, y, n_clusters_fine, output_space=None, activation=None, *, finer_topology='stratified', finer_tag_mode='seed_class', finer_k_per_class=None) → self`
Train gate + finer layer. Finer layer K biased toward distorted regions.

New in 0.2.1: the finer layer uses **stratified clustering with seed_class tagging** by default. This ensures the refinement actually represents minority classes in confused regions — the original promise of skip connections. The gate stays unsupervised (its target is intrinsically binary distortion).

| Parameter | Type | Default | Description |
|---|---|---|---|
| `finer_topology` | `str` or callable *(keyword-only)* | `'stratified'` | Topology for the finer layer. Auto-falls back to `'unsupervised'` for continuous (float) `y` regression. Set explicitly to `'unsupervised'` for pre-0.2.1 behavior. |
| `finer_tag_mode` | `str` *(keyword-only)* | *auto* | Tag mode for the finer layer. Defaults to `'seed_class'` when `finer_topology='stratified'`, else `'argmax'`. |
| `finer_k_per_class` | `str`/callable/ndarray/dict *(keyword-only)* | None | Allocator spec when stratified. |

**Bug fixed in 0.2.1**: the candidate subset is now sliced on `y` alongside `X`, preventing a class-mismatch that was invisible under argmax but wrong under stratified.

##### `forward(X, interpolator=None, metric=None) → (fired, outputs)`
Gated blend: `(1-gate) * parent + gate * finer`.

##### `predict(X) → preds`
Hard prediction from combined fired signal.

#### Properties

`block_`, `gate_`, `finer_`, `distortion_scores`, `is_fitted_`

#### Example

```python
from bmc import SkipBMC

### Default (0.2.1): stratified + seed_class on the finer layer
skip = SkipBMC(parent_layer)
skip.fit(X_train, y_train, n_clusters_fine=24)
preds = skip.predict(X_test)

### Pre-0.2.1 behavior — unsupervised finer layer
skip_legacy = SkipBMC(parent_layer)
skip_legacy.fit(X_train, y_train, n_clusters_fine=24,
                finer_topology='unsupervised')

### Recursive
skip2 = SkipBMC(skip)
skip2.fit(X_train, y_train, n_clusters_fine=32)
```

#### See Also

**GateBMC**, **BMCBlock**, **distortion**

---


## Metrics

### SimilarityMetric

Abstract base class for all distance metrics. Subclasses must implement `pairwise()`.

#### Import

```python
from bmc.metrics.base import SimilarityMetric
```

#### Constructor

```python
SimilarityMetric(device=None)
```

`device`: `"cpu"`, `"cuda"`, or `"auto"`.

#### Methods

##### `pairwise(A, B) → (M, K) float` (abstract)
Pairwise distances. Must be vectorized.

##### `__call__(a, b) → float`
Distance between two vectors. Convenience wrapper.

##### `to(device) → self`
Switch device.

#### Properties

`device` — resolved device string.

#### Guarantees

- Non-negativity: d(a, b) ≥ 0
- Identity: d(a, a) = 0
- Symmetry: d(a, b) = d(b, a)

#### See Also

**L2Metric**, **metric_activation**

---

### L2Metric

Euclidean distance metric.

#### Import

```python
from bmc import L2Metric
```

#### Constructor

```python
L2Metric(device=None)
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `device` | `str` | `None` | `"cpu"`, `"cuda"`, or `"auto"`. |

#### Example

```python
import numpy as np
from bmc import L2Metric

metric = L2Metric()
A = np.array([[1.0, 0.0], [0.0, 1.0]])
B = np.array([[1.0, 0.0]])
dists = metric.pairwise(A, B)
### dists.shape → (2, 1)
### dists[0, 0] → 0.0  (identical)
### dists[1, 0] > 0    (different)
```

#### See Also

**SimilarityMetric**, **metric_activation**

---

### CosineMetric

Cosine distance metric.

#### Import

```python
from bmc import CosineMetric
```

#### Constructor

```python
CosineMetric(device=None)
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `device` | `str` | `None` | `"cpu"`, `"cuda"`, or `"auto"`. |
| `eps` | `float` | `1e-8` | Normalization guard. |

#### Example

```python
import numpy as np
from bmc import CosineMetric

metric = CosineMetric()
A = np.array([[1.0, 0.0], [0.0, 1.0]])
B = np.array([[1.0, 0.0]])
dists = metric.pairwise(A, B)
### dists.shape → (2, 1)
### dists[0, 0] → 0.0  (identical)
### dists[1, 0] > 0    (different)
```

#### See Also

**SimilarityMetric**, **metric_activation**

---

### FrobeniusMetric

Frobenius distance metric.

#### Import

```python
from bmc import FrobeniusMetric
```

#### Constructor

```python
FrobeniusMetric(device=None)
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `device` | `str` | `None` | `"cpu"`, `"cuda"`, or `"auto"`. |

#### Example

```python
import numpy as np
from bmc import FrobeniusMetric

metric = FrobeniusMetric()
A = np.array([[1.0, 0.0], [0.0, 1.0]])
B = np.array([[1.0, 0.0]])
dists = metric.pairwise(A, B)
### dists.shape → (2, 1)
### dists[0, 0] → 0.0  (identical)
### dists[1, 0] > 0    (different)
```

#### See Also

**SimilarityMetric**, **metric_activation**

---

### SpectralMetric

Spectral distance metric.

#### Import

```python
from bmc import SpectralMetric
```

#### Constructor

```python
SpectralMetric(device=None)
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `device` | `str` | `None` | `"cpu"`, `"cuda"`, or `"auto"`. |

> For flat vectors (M, D), delegates to L2. For matrix inputs (M, H, W), uses per-pair SVD — O(D³) per element.

#### Example

```python
import numpy as np
from bmc import SpectralMetric

metric = SpectralMetric()
A = np.array([[1.0, 0.0], [0.0, 1.0]])
B = np.array([[1.0, 0.0]])
dists = metric.pairwise(A, B)
### dists.shape → (2, 1)
### dists[0, 0] → 0.0  (identical)
### dists[1, 0] > 0    (different)
```

#### See Also

**SimilarityMetric**, **metric_activation**

---

### NuclearMetric

Nuclear distance metric.

#### Import

```python
from bmc import NuclearMetric
```

#### Constructor

```python
NuclearMetric(device=None)
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `device` | `str` | `None` | `"cpu"`, `"cuda"`, or `"auto"`. |

> For flat vectors (M, D), delegates to L2. For matrix inputs (M, H, W), uses per-pair SVD — O(D³) per element.

#### Example

```python
import numpy as np
from bmc import NuclearMetric

metric = NuclearMetric()
A = np.array([[1.0, 0.0], [0.0, 1.0]])
B = np.array([[1.0, 0.0]])
dists = metric.pairwise(A, B)
### dists.shape → (2, 1)
### dists[0, 0] → 0.0  (identical)
### dists[1, 0] > 0    (different)
```

#### See Also

**SimilarityMetric**, **metric_activation**

---


## Interpolators

### LinearInterpolator

Piecewise linear between adjacent prototypes. C0 continuous. Fast.

#### Import
```python
from bmc import LinearInterpolator
```

#### Constructor
```python
LinearInterpolator()
```

#### Methods

##### `fit(t_values, y_values) → self`
| Parameter | Type | Description |
|---|---|---|
| `t_values` | `(K,)` | Arc-length positions, normalized [0, 1]. |
| `y_values` | `(K,)` or `(K, D)` | Values at each position. |

##### `evaluate(t) → values`
Evaluate at query positions. Clipped to [t_min, t_max].

##### `get_state() → dict` / `set_state(state) → self`
Serialization roundtrip.

#### Example
```python
t = np.array([0.0, 0.5, 1.0])
y = np.array([0.0, 1.0, 0.0])
interp = LinearInterpolator()
interp.fit(t, y)
interp.evaluate(np.array([0.25, 0.75]))
### → [0.5, 0.5]  (linear blend)
```

#### See Also
**SplineInterpolator**, **BMCLayer.forward()**

---

### SplineInterpolator

Cubic spline through all prototypes. C2 continuous. Uses `scipy.interpolate.CubicSpline`.

#### Import
```python
from bmc import SplineInterpolator
```

#### Constructor
```python
SplineInterpolator(bc_type='not-a-knot')
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `bc_type` | `str` | `'not-a-knot'` | Boundary condition. Options: `'not-a-knot'`, `'clamped'`, `'natural'`. |

Falls back to LinearInterpolator for < 4 prototypes.

#### Methods
`fit(t, y)`, `evaluate(t)`, `get_state()`, `set_state(state)`

#### See Also
**LinearInterpolator**, **PCHIPInterpolator**

---

### PCHIPInterpolator

Monotone piecewise cubic Hermite (PCHIP). C1 continuous. Prevents overshoot — important when outputs must stay bounded (e.g., probabilities shouldn't go negative).

#### Import
```python
from bmc import PCHIPInterpolator
```

#### Constructor
```python
PCHIPInterpolator()
```

Falls back to LinearInterpolator for < 2 prototypes.

#### Methods
`fit(t, y)`, `evaluate(t)`, `get_state()`, `set_state(state)`

#### See Also
**SplineInterpolator**, **KrigingInterpolator**

---

### KrigingInterpolator

Gaussian Process (Kriging) interpolation along the geodesic. Kernel-based.

#### Import
```python
from bmc import KrigingInterpolator
```

#### Constructor
```python
KrigingInterpolator(kernel='gaussian', length_scale=1.0, noise=1e-6)
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `kernel` | `str` | `'gaussian'` | `'gaussian'`, `'matern'`, or `'thin_plate_spline'`. |
| `length_scale` | `float` | `1.0` | Controls influence extent. |
| `noise` | `float` | `1e-6` | Regularization. |

#### Methods
`fit(t, y)`, `evaluate(t)`, `get_state()`, `set_state(state)`

#### See Also
**KernelInterpolator**, **PCHIPInterpolator**

---

### KernelInterpolator

Inverse Distance Weighting (IDW) or Radial Basis Function (RBF). Global method — all prototypes contribute to every query.

#### Import
```python
from bmc import KernelInterpolator
```

#### Constructor
```python
KernelInterpolator(method='idw', power=2.0)
```

| Parameter | Type | Default | Description |
|---|---|---|---|
| `method` | `str` | `'idw'` | `'idw'` or `'rbf'`. |
| `power` | `float` | `2.0` | IDW exponent (ignored for RBF). |

#### Methods
`fit(t, y)`, `evaluate(t)`, `get_state()`, `set_state(state)`

#### See Also
**KrigingInterpolator**, **LinearInterpolator**

---


## Observation Spaces

### TabularObservationSpace

Splits flat feature vectors into zone subsets for zone-parallel processing.

#### Import
```python
from bmc import TabularObservationSpace
```

#### Constructor
```python
TabularObservationSpace(n_features, n_zones=None, zone_indices=None)
```

| Parameter | Type | Description |
|---|---|---|
| `n_features` | `int` | Total number of features. |
| `n_zones` | `int` | Number of zones (auto-splits features evenly). |
| `zone_indices` | `list[list[int]]` | Explicit feature indices per zone. Overrides `n_zones`. |

#### Methods

##### `extract(X) → (N_zones, M, D_zone)`
Decompose input into zone feature vectors.

#### Properties
`n_zones`, `n_features`, `feature_dim`

#### Example
```python
obs = TabularObservationSpace(n_features=30, n_zones=6)
### 6 zones of 5 features each
zones = obs.extract(X)  # shape (6, M, 5)
```

#### See Also
**BMCBlock**, **ImageObservationSpace**

---

### ImageObservationSpace

Overlapping patch grid for grayscale images. Pools patches to fixed size.

#### Import
```python
from bmc import ImageObservationSpace
```

#### Constructor
```python
ImageObservationSpace(image_size, pool_size, grid_n, output_size, method='lanczos')
```

| Parameter | Type | Description |
|---|---|---|
| `image_size` | `int` | Input image size (assumes square). |
| `pool_size` | `int` | Patch size before pooling. |
| `grid_n` | `int` | Grid dimension (grid_n × grid_n zones). |
| `output_size` | `int` | Pooled patch size. |
| `method` | `str` | Resize method. Default `'lanczos'`. |

#### Methods
##### `extract(images) → (N_zones, M, D)`
##### `grid_starts() → list[int]`

#### Properties
`n_zones` (= grid_n²), `feature_dim` (= output_size²)

#### Example
```python
obs = ImageObservationSpace(image_size=8, pool_size=3, grid_n=4, output_size=2)
### 16 zones (4×4), each 4D (2×2 pooled patch)
zones = obs.extract(images)  # shape (16, M, 4)
```

#### See Also
**TabularObservationSpace**, **BMCBlock**

---

### TimeSeriesObservationSpace

Sliding windows for sequential data.

#### Import
```python
from bmc import TimeSeriesObservationSpace
```

#### Constructor
```python
TimeSeriesObservationSpace(series_length, window_size, stride=1)
```

| Parameter | Type | Description |
|---|---|---|
| `series_length` | `int` | Length of input sequences. |
| `window_size` | `int` | Temporal window per zone. |
| `stride` | `int` | Step between windows. stride < window = overlap. |

#### Methods
##### `extract(X) → (N_zones, M, D)`
##### `window_starts() → list[int]`

#### Properties
`n_zones`, `feature_dim` (= window_size × n_features)

#### Example
```python
obs = TimeSeriesObservationSpace(series_length=140, window_size=28, stride=14)
### 9 overlapping windows
zones = obs.extract(X)  # shape (9, M, 28)
```

#### See Also
**TextObservationSpace**, **BMCBlock**

---

### TextObservationSpace

Contiguous chunks of embedding sequences.

#### Import
```python
from bmc import TextObservationSpace
```

#### Constructor
```python
TextObservationSpace(seq_len, embed_dim, chunk_size, stride=None)
```

| Parameter | Type | Description |
|---|---|---|
| `seq_len` | `int` | Number of positions in the sequence. |
| `embed_dim` | `int` | Embedding dimension per position. |
| `chunk_size` | `int` | Positions per chunk. |
| `stride` | `int` | Step between chunks. Default = chunk_size (no overlap). |

#### Methods
##### `extract(X) → (N_zones, M, D)`
Input shape: `(M, seq_len, embed_dim)`.

##### `chunk_starts() → list[int]`

#### Properties
`n_zones`, `feature_dim` (= chunk_size × embed_dim)

#### Example
```python
obs = TextObservationSpace(seq_len=16, embed_dim=16, chunk_size=2, stride=1)
### 15 overlapping chunks, each 32D
zones = obs.extract(X_seq)  # shape (15, M, 32)
```

#### See Also
**TimeSeriesObservationSpace**, **BMCBlock**

---


## Infrastructure

### Constants

Named defaults for the BMC framework. All magic numbers import from here.

#### Import

```python
from bmc.constants import DEFAULT_RNG_SEED, EPSILON_ZERO, ...
```

#### Values

| Constant | Value | Used for |
|---|---|---|
| `DEFAULT_RNG_SEED` | 42 | Default random seed |
| `DEFAULT_MEMORY_FRACTION` | 0.75 | RAM/VRAM budget for batching |
| `DEFAULT_MEMORY_MAX_FRACTION` | 0.99 | Warning threshold |
| `DEFAULT_LLOYD_BATCH_MULTIPLIER` | 10 | Mini-batch = min(M, 10·K) |
| `DEFAULT_MAX_ZONES` | 10 | Auto TabularObservationSpace zones |
| `EPSILON_ZERO` | 1e-12 | Division-by-zero guard |
| `EPSILON_NORM` | 1e-8 | Normalization guard |
| `EPSILON_CONVERGENCE` | 1e-4 | Lloyd's convergence tolerance |
| `EPSILON_TSP` | 1e-10 | Minimum 2-opt swap gain |

---

### Backend

CPU/CUDA abstraction for pairwise distance computation.

#### Import

```python
from bmc.backend import get_backend, resolve_device
```

#### get_backend

```python
get_backend(device=None) → CPUBackend or CUDABackend
```

| Device | Backend | Requirements |
|---|---|---|
| `"cpu"` | NumPy | None |
| `"cuda"` | PyTorch | `torch` + CUDA GPU |
| `None` / `"auto"` | Best available | Auto-detect |

#### Backend Methods

All accept NumPy arrays, return NumPy arrays.

| Method | Signature | Description |
|---|---|---|
| `pairwise_l2(A, B)` | `(M,D)×(K,D) → (M,K)` | L2 distances |
| `pairwise_cosine(A, B)` | `(M,D)×(K,D) → (M,K)` | Cosine distances |
| `pairwise_frobenius(A, B)` | `(M,D)×(K,D) → (M,K)` | Frobenius distances |
| `argmin(dists, axis)` | `(M,K) → (M,)` | Argmin |
| `bincount(x)` | `(M,) → (max+1,)` | Bin count |

#### See Also

**SimilarityMetric**

---
