# Contract: descriptor_normaliser

**Component**: shared_helpers / `helpers.descriptor_normaliser` (cross-cutting concern owned by E-CC-HELPERS / AZ-264)
**Producer task**: AZ-283 — `_docs/02_tasks/todo/AZ-283_descriptor_normaliser.md`
**Consumer tasks**: every C2 task that produces a query embedding before FAISS lookup; every C2.5 task that pre-processes descriptors for re-rank; every C3 task that pre-processes descriptors for cross-domain matching; every C10 task that builds the corpus side of the FAISS index during pre-flight provisioning
**Version**: 1.1.0
**Status**: draft
**Last Updated**: 2026-05-13

## Purpose

L2-normalise descriptors so cosine similarity aligns with FAISS's Euclidean / inner-product metric. Required because FAISS HNSW operates on Euclidean / inner-product spaces but the upstream backbones (UltraVPR, MegaLoc, MixVPR, etc.) emit raw cosine-similar embeddings. The same normalisation MUST be applied at both the **corpus** side (C10 during F1 provisioning) and the **query** side (C2 at runtime) — otherwise the index returns garbage. Centralising the helper guarantees they don't drift apart. Per `_docs/02_document/common-helpers/08_helper_descriptor_normaliser.md`.

## Shape

### For function / method APIs

```python
class DescriptorNormaliser:
    @staticmethod
    def l2_normalise(descriptor: np.ndarray) -> np.ndarray: ...                                   # shape (D,)
    @staticmethod
    def l2_normalise_batch(descriptors: np.ndarray) -> np.ndarray: ...                            # shape (N, D)
    @staticmethod
    def intra_cluster_normalise(descriptor: np.ndarray, num_clusters: int) -> np.ndarray: ...     # shape (K*D,) — added in v1.1.0
    @staticmethod
    def descriptor_metric() -> str: ...                                                            # always "inner_product"
```

| Name | Signature | Throws / Errors | Blocking? |
|------|-----------|-----------------|-----------|
| `l2_normalise` | `(descriptor: (D,)) -> (D,)` | `DescriptorNormaliserError` if shape is not 1-D, `D < 1`, or dtype is not `float16` / `float32` | sync, hot-path |
| `l2_normalise_batch` | `(descriptors: (N, D)) -> (N, D)` | `DescriptorNormaliserError` if shape is not 2-D, `N < 1`, `D < 1`, or dtype is not `float16` / `float32` | sync, hot-path |
| `intra_cluster_normalise` | `(descriptor: (K*D,), num_clusters: int) -> (K*D,)` | `DescriptorNormaliserError` if shape is not 1-D, `num_clusters < 1`, length not divisible by `num_clusters`, or dtype is not `float16` / `float32` | sync, hot-path |
| `descriptor_metric` | `() -> str` | none | sync, in-memory, returns `"inner_product"` |

Numpy arrays. dtype contract: `float16` in → `float16` out; `float32` in → `float32` out (no silent up-cast). The helper does NOT mutate inputs in place — it returns a new array.

## Invariants

- **Stateless**: no module-level state; static methods only. Stateless static-only design satisfies `coderule.mdc`.
- **dtype-preserving**: `float16` in → `float16` out; `float32` in → `float32` out. The helper does NOT silently up-cast or down-cast. Other dtypes (e.g., `float64`, `int8`) are rejected.
- **Zero-norm vector handling**: a zero-norm input vector is returned as the zero vector (no division-by-zero, no exception). Callers must filter or accept that such descriptors will match nothing on FAISS lookup. Documented invariant.
- **No in-place mutation**: every call returns a new numpy array; the input is never modified.
- **Single source of truth for metric**: `descriptor_metric()` always returns `"inner_product"`. C6's `DescriptorIndex.search_topk` and C10's index-build code MUST call this helper for the FAISS index distance metric — never hard-code `"l2"` or `"cosine"`.
- **L2 idempotence**: `l2_normalise(l2_normalise(x)) == l2_normalise(x)` byte-equal for non-zero `x`. Re-normalising an already-normalised vector is a no-op (within `atol=0` for `float32`; within `atol=1e-3` for `float16` due to half-precision rounding).
- **No upward imports** (Layer 1): the module imports ONLY from `_types`, numpy, and stdlib. No `gps_denied_onboard.components.*` imports.

## Non-Goals

- Whitening / mean-subtraction — out of scope; consumers that need it apply it before / after this helper.
- PCA / dimensionality reduction — owned elsewhere (or out of scope entirely).
- GPU-accelerated normalisation — out of scope for v1.0.0; numpy / numpy-CUDA is fine for descriptor vector sizes (≤ 8192 dims) at the per-frame rate.
- Quantisation (PQ, IVF) — owned by C6 / C10 around the FAISS index, not by this helper.
- Auto-detection of descriptor dim — the helper is shape-agnostic for any `D >= 1`; consumers ensure the corpus and query side use the same `D`.

## Versioning Rules

- **Breaking changes** (function renamed/removed, signature changed, dtype contract relaxed, return value of `descriptor_metric()` changed) require a new major version + a re-build of every FAISS index built with the previous version (since the index metric is baked into the corpus-side normalisation).
- **Non-breaking additions** (new helper function, new optional kwarg with safe default) require a minor version bump.
- Changing `descriptor_metric()` return value is ALWAYS a major version because it forces every downstream FAISS index to be rebuilt.

## Test Cases

| Case | Input | Expected | Notes |
|------|-------|----------|-------|
| valid-unit-vector | `np.array([3.0, 4.0], dtype=float32)` | `np.array([0.6, 0.8], dtype=float32)`; norm ≈ 1.0 within `atol=1e-6` | Round-trip happy path |
| valid-batch | `np.array([[3.0, 4.0], [1.0, 0.0]], dtype=float32)` | rows `[0.6, 0.8]` and `[1.0, 0.0]`; each row's norm ≈ 1.0 | Batch path |
| valid-fp16-roundtrip | random `float16` descriptor of dim 512 | `result.dtype == float16`; norm ≈ 1.0 within `atol=1e-3` | dtype preservation |
| valid-fp32-roundtrip | random `float32` descriptor of dim 512 | `result.dtype == float32`; norm ≈ 1.0 within `atol=1e-6` | dtype preservation |
| valid-zero-vector | `np.zeros(128, dtype=float32)` | returned as `np.zeros(128, dtype=float32)`; no exception, no NaN | Zero-norm invariant |
| valid-idempotent-fp32 | `l2_normalise(l2_normalise(x))` for `float32` `x` | byte-equal to `l2_normalise(x)` | Idempotence (fp32) |
| valid-idempotent-fp16 | `l2_normalise(l2_normalise(x))` for `float16` `x` | matches within `atol=1e-3` | Idempotence (fp16, looser due to half-precision) |
| valid-no-mutation | call `l2_normalise(x)`; check `x` afterward | `x` is bit-identical to its original value | No in-place mutation |
| valid-metric | `descriptor_metric()` | returns the string `"inner_product"` | Single source of truth |
| invalid-dtype-float64 | `np.array([1.0, 2.0], dtype=float64)` | `DescriptorNormaliserError` mentions `float16` / `float32` only | dtype contract |
| invalid-shape-2d-on-single | `np.zeros((2, 3), dtype=float32)` passed to `l2_normalise` (single) | `DescriptorNormaliserError` mentions 1-D shape required | Shape contract (single) |
| invalid-shape-1d-on-batch | `np.zeros(128, dtype=float32)` passed to `l2_normalise_batch` | `DescriptorNormaliserError` mentions 2-D shape required | Shape contract (batch) |
| no-upward-imports | static import scan | only `_types`, numpy, stdlib | Layer 1 invariant |

## Change Log

| Version | Date | Change | Author |
|---------|------|--------|--------|
| 1.0.0 | 2026-05-10 | Initial contract derived from `_docs/02_document/common-helpers/08_helper_descriptor_normaliser.md` | autodev decompose Step 2 |
| 1.1.0 | 2026-05-13 | Added `intra_cluster_normalise(descriptor, num_clusters)` for NetVLAD's dual-stage normalisation chain. Backward-compatible function addition required by AZ-338. | autodev implement Batch 46 |
