# DescriptorNormaliser Helper Module

**Task**: AZ-283_descriptor_normaliser
**Name**: DescriptorNormaliser Helper
**Description**: Implement the shared `DescriptorNormaliser` helper that L2-normalises descriptors so cosine similarity aligns with FAISS HNSW's inner-product metric. Used by C2 (query-side per-frame embedding before FAISS lookup), C2.5 (descriptor pre-processing for re-rank), C3 (descriptor pre-processing for cross-domain matching), and C10 (corpus-side per-tile embedding before FAISS index population). The same helper on both sides is what guarantees the index returns useful neighbours rather than garbage. Stateless static-only; dtype-preserving (`float16`/`float32` in → same out).
**Complexity**: 2 points
**Dependencies**: AZ-263_initial_structure
**Component**: shared.helpers.descriptor_normaliser (cross-cutting; epic AZ-264 / E-CC-HELPERS)
**Tracker**: AZ-283
**Epic**: AZ-264 (E-CC-HELPERS)

### Document Dependencies

- `_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md` — frozen public interface this task produces.
- `_docs/02_document/common-helpers/08_helper_descriptor_normaliser.md` — design rationale (FAISS metric alignment, corpus-vs-query coupling).

## Problem

FAISS HNSW operates on Euclidean / inner-product spaces, but the upstream backbones (UltraVPR, MegaLoc, MixVPR, SelaVPR, EigenPlaces, NetVLAD, SALAD) emit raw cosine-similar embeddings. The standard FAISS-idiomatic recipe is "L2-normalise both sides + use inner-product metric" — but this is fragile:
- If C10 (corpus side, pre-flight) and C2 (query side, runtime) drift on whether to normalise, or how to handle zero-norm vectors, the FAISS index returns garbage.
- If one side silently up-casts `float16` to `float32`, the index gets built with a different precision than the queries, producing wrong neighbours.
- A future contributor "improves" one side's normalisation (e.g., adds whitening) without the other; recall drops silently.

## Outcome

- A single `helpers.descriptor_normaliser` module is the only path through which any onboard process L2-normalises descriptors.
- The metric ("inner_product") is exposed via `descriptor_metric()` so C6's `DescriptorIndex.search_topk` and C10's index-build code consult the same source — no hard-coded `"l2"` or `"cosine"` strings anywhere.
- dtype is preserved: `float16` in → `float16` out (preserves the precision the backbone chose); `float32` in → `float32` out. No silent up-cast.
- Zero-norm input vectors are returned as the zero vector (no division-by-zero); callers filter or accept that such descriptors will match nothing.
- L2 normalisation is idempotent (byte-equal for `float32`, near-byte-equal for `float16` due to half-precision rounding) so accidentally normalising twice is harmless.

## Scope

### Included

- `DescriptorNormaliser` static methods: `l2_normalise(descriptor) -> ndarray`, `l2_normalise_batch(descriptors) -> ndarray`, `descriptor_metric() -> str`.
- `DescriptorNormaliserError` exception type.
- Public interface contract published at `_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md`.

### Excluded

- Whitening / mean-subtraction — out of scope; consumers that need it apply it before / after this helper.
- PCA / dimensionality reduction — out of scope.
- GPU-accelerated normalisation — out of scope for v1.0.0.
- Quantisation (PQ, IVF) — owned by C6 / C10 around the FAISS index.
- Auto-detection of descriptor dim — helper is shape-agnostic for any `D >= 1`.

## Acceptance Criteria

**AC-1: Unit-vector example**
Given `np.array([3.0, 4.0], dtype=float32)`
When `l2_normalise` runs
Then the result equals `np.array([0.6, 0.8], dtype=float32)` within `atol=1e-6`; norm ≈ 1.0

**AC-2: Batch normalisation**
Given `np.array([[3.0, 4.0], [1.0, 0.0]], dtype=float32)`
When `l2_normalise_batch` runs
Then the rows are `[0.6, 0.8]` and `[1.0, 0.0]`; each row's norm is ≈ 1.0

**AC-3: dtype preservation — float16**
Given a random `float16` descriptor of dim 512
When `l2_normalise` runs
Then `result.dtype == float16` AND `np.linalg.norm(result.astype(float32))` ≈ 1.0 within `atol=1e-3`

**AC-4: dtype preservation — float32**
Given a random `float32` descriptor of dim 512
When `l2_normalise` runs
Then `result.dtype == float32` AND `np.linalg.norm(result)` ≈ 1.0 within `atol=1e-6`

**AC-5: Zero-vector handling**
Given `np.zeros(128, dtype=float32)`
When `l2_normalise` runs
Then the result is `np.zeros(128, dtype=float32)` (no exception, no NaN)

**AC-6: Idempotence — float32**
Given a random `float32` descriptor `x`
When `l2_normalise(l2_normalise(x))` runs
Then it is byte-equal to `l2_normalise(x)`

**AC-7: Idempotence — float16**
Given a random `float16` descriptor `x`
When `l2_normalise(l2_normalise(x))` runs
Then it matches `l2_normalise(x)` within `atol=1e-3` (half-precision rounding)

**AC-8: No in-place mutation**
Given `x` is a `float32` descriptor
When `l2_normalise(x)` runs
Then `x` is bit-identical to its original value

**AC-9: Metric source of truth**
Given a call to `descriptor_metric()`
When it runs
Then it returns the string `"inner_product"`

**AC-10: dtype contract — float64 rejected**
Given a `float64` array
When `l2_normalise` runs
Then `DescriptorNormaliserError` is raised mentioning `float16` / `float32` only

**AC-11: Shape contract — 1-D for single, 2-D for batch**
Given a 2-D array passed to `l2_normalise` (single)
When the call runs
Then `DescriptorNormaliserError` is raised mentioning the 1-D shape requirement
And given a 1-D array passed to `l2_normalise_batch`
When the call runs
Then `DescriptorNormaliserError` is raised mentioning the 2-D shape requirement

**AC-12: No upward imports (Layer 1 invariant)**
Given the helper module
When a static-import check runs
Then it imports ONLY from `_types`, numpy, and stdlib — no `gps_denied_onboard.components.*` imports anywhere

## Non-Functional Requirements

**Performance**
- `l2_normalise` p99 ≤ 50 µs on Tier-2 for `D=512` (matches the per-frame VPR query budget).
- `l2_normalise_batch` p99 ≤ 5 ms on Tier-2 for `(N=1000, D=512)` (matches the C10 batch index-build chunk size).
- Helper-level overhead vs. inline `x / np.linalg.norm(x)` ≤ 5 % (per E-CC-HELPERS hot-path NFR).

**Reliability**
- `DescriptorNormaliserError` is the ONLY exception type the public surface raises on shape / dtype violations.
- Pure deterministic; same input → byte-equal output (within `float16` rounding).

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | `[3.0, 4.0]` fp32 | result `[0.6, 0.8]` within `atol=1e-6` |
| AC-2 | batch `[[3, 4], [1, 0]]` | rows `[0.6, 0.8]` and `[1.0, 0.0]` |
| AC-3 | random fp16 dim-512 | result.dtype fp16; norm ≈ 1.0 within `atol=1e-3` |
| AC-4 | random fp32 dim-512 | result.dtype fp32; norm ≈ 1.0 within `atol=1e-6` |
| AC-5 | zero vector | returned as zero vector; no exception |
| AC-6 | double-normalise fp32 | byte-equal to single-normalise |
| AC-7 | double-normalise fp16 | matches single-normalise within `atol=1e-3` |
| AC-8 | mutation check | input unchanged after call |
| AC-9 | `descriptor_metric()` | exact string `"inner_product"` |
| AC-10 | fp64 input | `DescriptorNormaliserError`; mentions fp16/fp32 |
| AC-11 | 2-D into single, 1-D into batch | `DescriptorNormaliserError` for each |
| AC-12 | importlinter / grep gate | no `components.*` imports |
| NFR-perf | microbench `l2_normalise` (D=512, 10k iterations on Tier-2 fixture) | p99 ≤ 50 µs; overhead ≤ 5 % |
| NFR-perf-batch | microbench `l2_normalise_batch` (N=1000, D=512, 10k iterations) | p99 ≤ 5 ms |

## Constraints

- Public surface frozen by `_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md` v1.0.0.
- Layer 1 Foundation only.
- Numpy is the single backend; numpy-CUDA may be used opportunistically but the contract surface is dtype-only (no GPU array types leak through).
- Static-only design satisfies `coderule.mdc`.
- No new dependency beyond what AZ-263 / E-BOOT pinned.

## Risks & Mitigation

**Risk 1: Silent dtype up-cast hides corpus-vs-query precision drift**
- *Risk*: A future change up-casts `float16` → `float32` "for numerical stability"; C10's corpus is built with fp32 normalisations while C2's queries are still fp16 raw embeddings; recall silently drops.
- *Mitigation*: AC-3 / AC-4 pin dtype preservation. The contract test is the canary.

**Risk 2: Whitening creep**
- *Risk*: A contributor adds optional whitening "for `MixVPR` only" inside this helper; one consumer calls it with `whiten=True`, the other doesn't; index becomes inconsistent.
- *Mitigation*: The contract `Non-Goals` explicitly excludes whitening. Whitening lives elsewhere (or not at all in v1.0.0). A whitening-related contract change is a major version with a forced index rebuild.

**Risk 3: Zero-norm vectors crash the FAISS index build**
- *Risk*: Zero-norm input → `nan / 0` propagates into the FAISS index; the index becomes corrupt.
- *Mitigation*: AC-5 pins the zero-vector handling: zero in → zero out, no NaN. C10 / C2 are responsible for filtering zero descriptors before / after this helper if they don't want them in the index.

## Runtime Completeness

- **Named capability**: L2 normalisation aligning cosine-similar embeddings to FAISS inner-product metric (architecture / E-CC-HELPERS / `08_helper_descriptor_normaliser.md`).
- **Production code that must exist**: real numpy-backed L2 normalisation; real dtype-preserving path; real zero-norm-safe handling.
- **Allowed external stubs**: none — numpy is stdlib-tier production dep.
- **Unacceptable substitutes**: silent dtype up-cast; `np.divide(x, np.linalg.norm(x))` without zero-norm guard (NaN propagation); hard-coded metric string in C6 / C10 instead of consulting `descriptor_metric()`.

## Contract

This task produces the contract at `_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md`.
Consumers MUST read that file — not this task spec — to discover the interface.
