Files
gps-denied-onboard/tests/unit/test_az283_descriptor_normaliser.py
T
Oleksandr Bezdieniezhnykh 3acc7f33dd [AZ-270] [AZ-272] [AZ-279] [AZ-281] [AZ-283] Compose root + FDR schema + 3 Layer-1 helpers
AZ-270: composition root with strategy registry, tier-gated lookup,
topo-order construction, all-or-nothing teardown, StrategyNotLinkedError
payload.
AZ-272: orjson-backed FdrRecord serialise/parse with forward-compat for
unknown payload + top-level fields and canonical overrun-record shape.
AZ-279: pyproj-backed WGS84/ECEF/ENU + OSM slippy-map tile math with
WgsConversionError for shape/range/zoom guards.
AZ-281: strict EngineFilenameSchema build/parse/matches_host with
anchored regex + enum validation; round-trip identity by construction.
AZ-283: dtype-preserving (fp16/fp32) single + batch L2 normaliser with
zero-norm safety and descriptor_metric() source-of-truth.
pyproject.toml pins pyproj>=3.6 and orjson>=3.9 (named-backend deps per
the AZ-272 / AZ-279 contracts). New DTOs LatLonAlt + BoundingBox and
EngineCacheKey + HostCapabilities land in _types/ to back the helper
contracts.
203 unit tests pass (64 new). Review verdict: PASS_WITH_WARNINGS;
findings are perf-NFR deferrals + dep amendment + minor docstring polish.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 02:03:36 +03:00

123 lines
4.6 KiB
Python

"""AZ-283 — DescriptorNormaliser helper AC tests.
Verifies the contract at ``_docs/02_document/contracts/shared_helpers/descriptor_normaliser.md`` v1.0.0.
"""
from __future__ import annotations
import ast
from pathlib import Path
import numpy as np
import pytest
from gps_denied_onboard.helpers import DescriptorNormaliser, DescriptorNormaliserError
def test_ac1_unit_vector_example() -> None:
out = DescriptorNormaliser.l2_normalise(np.array([3.0, 4.0], dtype=np.float32))
np.testing.assert_allclose(out, np.array([0.6, 0.8], dtype=np.float32), atol=1e-6)
assert float(np.linalg.norm(out)) == pytest.approx(1.0, abs=1e-6)
def test_ac2_batch_normalisation() -> None:
batch = np.array([[3.0, 4.0], [1.0, 0.0]], dtype=np.float32)
out = DescriptorNormaliser.l2_normalise_batch(batch)
np.testing.assert_allclose(out[0], np.array([0.6, 0.8], dtype=np.float32), atol=1e-6)
np.testing.assert_allclose(out[1], np.array([1.0, 0.0], dtype=np.float32), atol=1e-6)
for row in out:
assert float(np.linalg.norm(row)) == pytest.approx(1.0, abs=1e-6)
def test_ac3_fp16_dtype_preservation() -> None:
rng = np.random.default_rng(2026)
x = rng.standard_normal(512).astype(np.float16)
out = DescriptorNormaliser.l2_normalise(x)
assert out.dtype == np.float16
assert float(np.linalg.norm(out.astype(np.float32))) == pytest.approx(1.0, abs=1e-3)
def test_ac4_fp32_dtype_preservation() -> None:
rng = np.random.default_rng(2026)
x = rng.standard_normal(512).astype(np.float32)
out = DescriptorNormaliser.l2_normalise(x)
assert out.dtype == np.float32
assert float(np.linalg.norm(out)) == pytest.approx(1.0, abs=1e-6)
def test_ac5_zero_vector_handling() -> None:
zeros = np.zeros(128, dtype=np.float32)
out = DescriptorNormaliser.l2_normalise(zeros)
np.testing.assert_array_equal(out, zeros)
assert not np.any(np.isnan(out))
def test_ac5b_zero_row_in_batch_remains_zero() -> None:
batch = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], dtype=np.float32)
out = DescriptorNormaliser.l2_normalise_batch(batch)
np.testing.assert_array_equal(out[0], np.zeros(3, dtype=np.float32))
np.testing.assert_allclose(out[1], np.array([1.0, 0.0, 0.0], dtype=np.float32))
def test_ac6_idempotence_fp32() -> None:
rng = np.random.default_rng(2026)
x = rng.standard_normal(64).astype(np.float32)
once = DescriptorNormaliser.l2_normalise(x)
twice = DescriptorNormaliser.l2_normalise(once)
assert once.tobytes() == twice.tobytes()
def test_ac7_idempotence_fp16_within_half_precision_tol() -> None:
rng = np.random.default_rng(2026)
x = rng.standard_normal(64).astype(np.float16)
once = DescriptorNormaliser.l2_normalise(x)
twice = DescriptorNormaliser.l2_normalise(once)
np.testing.assert_allclose(twice.astype(np.float32), once.astype(np.float32), atol=1e-3)
def test_ac8_no_in_place_mutation() -> None:
x = np.array([3.0, 4.0, 0.0], dtype=np.float32)
snapshot = x.copy()
_ = DescriptorNormaliser.l2_normalise(x)
np.testing.assert_array_equal(x, snapshot)
def test_ac9_metric_is_inner_product_exact_string() -> None:
assert DescriptorNormaliser.descriptor_metric() == "inner_product"
def test_ac10_float64_dtype_rejected() -> None:
with pytest.raises(DescriptorNormaliserError, match=r"float16.*float32|float32.*float16"):
DescriptorNormaliser.l2_normalise(np.array([1.0, 2.0], dtype=np.float64))
def test_ac11_shape_contract_single_rejects_2d() -> None:
with pytest.raises(DescriptorNormaliserError, match=r"1-D"):
DescriptorNormaliser.l2_normalise(np.zeros((2, 3), dtype=np.float32))
def test_ac11_shape_contract_batch_rejects_1d() -> None:
with pytest.raises(DescriptorNormaliserError, match=r"2-D"):
DescriptorNormaliser.l2_normalise_batch(np.zeros(128, dtype=np.float32))
def test_ac12_no_upward_imports_to_components() -> None:
module_path = (
Path(__file__).resolve().parents[2]
/ "src"
/ "gps_denied_onboard"
/ "helpers"
/ "descriptor_normaliser.py"
)
tree = ast.parse(module_path.read_text(encoding="utf-8"))
bad: list[str] = []
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module:
if node.module.startswith("gps_denied_onboard.components"):
bad.append(node.module)
elif isinstance(node, ast.Import):
for alias in node.names:
if alias.name.startswith("gps_denied_onboard.components"):
bad.append(alias.name)
assert not bad, f"descriptor_normaliser must not import components.*; found: {bad}"