# Common Helper — `DescriptorNormaliser`

## Purpose

L2-normalise descriptors so cosine similarity aligns with Euclidean distance — 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); centralising the helper guarantees they don't drift apart.

## Used By

- C2 — VPR (query side; per-frame embedding before FAISS lookup).
- C10 — Pre-flight Cache Provisioning (corpus side; per-tile embedding before FAISS index population).

## Interface (sketch)

```
class DescriptorNormaliser:
    @staticmethod
    def l2_normalise(descriptor: ndarray[D]) -> ndarray[D]
    @staticmethod
    def l2_normalise_batch(descriptors: ndarray[N, D]) -> ndarray[N, D]
    @staticmethod
    def descriptor_metric() -> str                        # always "inner_product" — for FAISS index config
```

## Implementation Notes

- Stateless; pure function.
- Backed by numpy / numpy-CUDA depending on the runtime; preserves dtype (fp16 in/out → fp16 out, fp32 in/out → fp32 out).
- The `descriptor_metric()` static return is the source of truth for the FAISS HNSW index distance metric — both C6's `DescriptorIndex.search_topk` and C10's index-build code consult it.

## Caveats

- Zero-norm vectors are returned as the zero vector (no division-by-zero); callers must filter or accept that such descriptors will match nothing.
- The choice of "inner product on L2-normalised" rather than "cosine" is FAISS-idiomatic — FAISS does not have a built-in cosine metric; cosine is achieved by L2-normalising and using inner product.

## Cycle-1 operational reality

The shipped surface in `src/gps_denied_onboard/helpers/descriptor_normaliser.py` (AZ-283, extended by AZ-338 NetVLAD per-cluster method) is static-only, stateless, and dtype-preserving. Norms are computed in `float32` to stabilise `float16` inputs against under/overflow, then cast back to the input dtype — the helper NEVER silently up-casts the returned descriptor.

- **`DescriptorNormaliserError`** — single public exception type (subclasses `ValueError`). Raised on: non-`ndarray` input; wrong dimensionality (`l2_normalise` requires 1-D, `l2_normalise_batch` requires 2-D, `intra_cluster_normalise` requires 1-D); zero-length axis; dtype not in `ALLOWED_DTYPES`; `num_clusters` not a non-bool positive int that divides `descriptor.shape[0]`.
- **`ALLOWED_DTYPES = (np.float16, np.float32)`** — public module-level constant. Anything else is rejected hard; this keeps the FAISS index and the runtime query path on the same precision (catches "C10 built the index in float32 but C2 fed a float64 query" regressions).
- **`intra_cluster_normalise(descriptor, num_clusters)` — NEW METHOD (AZ-338)**. Per-cluster L2 normalisation for VLAD-aggregated descriptors. NetVLAD's published preprocessing chain L2-normalises each per-cluster sub-vector BEFORE the global L2 step (`l2_normalise`). The input is a flat 1-D VLAD descriptor of shape `(num_clusters * cluster_dim,)`; the method reshapes to `(num_clusters, cluster_dim)`, normalises row-wise (zero-norm rows stay zero), and flattens back. `num_clusters` MUST divide `descriptor.shape[0]` — otherwise `DescriptorNormaliserError`.
- **`descriptor_metric()` returns the literal string `"inner_product"`** — the source of truth for FAISS HNSW index construction. C6's `DescriptorIndex.search_topk` and C10's index-build code both consult this; do NOT hard-code the metric string anywhere else.
- **Zero-norm vectors return zeros** — `l2_normalise`, `l2_normalise_batch`, and `intra_cluster_normalise` all guard the divisor. Callers that want to reject zero-norm descriptors must do so explicitly; the helper never raises on zero norm (it would be the wrong layer to decide the policy).
- **`l2_normalise_batch` vectorised** — uses `np.where(norms == 0.0, ...)` to apply the zero-guard row-wise so a batch of N descriptors with K zeros costs the same as a batch of N non-zero descriptors plus K boolean comparisons (no per-row branch).

### Cycle-1 task lineage

- AZ-283 — initial helper, contract producer (`l2_normalise`, `l2_normalise_batch`, `descriptor_metric`).
- AZ-338 — `intra_cluster_normalise` addition for the C2 VPR NetVLAD preprocessing path (`ultra_vpr` AZ-337 consumer). Contract minor revision (v1.0.0 → v1.1.0) is queued for the next contracts-folder sweep.
