mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 23:21:13 +00:00
[AZ-266] [AZ-269] [AZ-277] [AZ-280] Cross-cutting log/config + SE3/SHA256 helpers
AZ-266: schema-compliant JSON logging entrypoint, level normalisation, handler-topology guard, format-error fallback (log_record_schema v1.0.0). AZ-269: env > YAML > defaults config loader, frozen Config dataclass, missing-var fail-fast with pointer to .env.example, component-block registry. AZ-277: GTSAM-backed SE3Utils (matrix<->SE3 + exp/log/adjoint) with strict orthogonality, dtype, and bottom-row contract enforcement. AZ-280: atomicwrites-backed write_atomic + independent verify + order-deterministic aggregate_hash; sidecar format strictness. pyproject.toml pins gtsam>=4.2,<5.0 and atomicwrites>=1.4,<2.0 (named-backend deps per the AZ-277 / AZ-280 contracts). 139 unit tests pass (44 new). Review verdict: PASS_WITH_WARNINGS; findings are perf-NFR + journald deferrals, no blocking issues. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,5 +1,36 @@
|
||||
"""Shared utilities (owned by AZ-264 / E-CC-HELPERS).
|
||||
"""Shared utilities (E-CC-HELPERS / AZ-264).
|
||||
|
||||
Bootstrap (AZ-263) creates these as importable stubs; concrete implementations are
|
||||
filled in by per-helper tasks under AZ-264. See `_docs/02_document/common-helpers/`.
|
||||
Each helper has its own task (AZ-276..AZ-283). This package exposes the
|
||||
ones that have landed so consumers can depend on a stable public surface
|
||||
without reaching into the helper modules directly.
|
||||
"""
|
||||
|
||||
from gps_denied_onboard.helpers.se3_utils import (
|
||||
SE3,
|
||||
Se3InvalidMatrixError,
|
||||
adjoint,
|
||||
exp_map,
|
||||
is_valid_rotation,
|
||||
log_map,
|
||||
matrix_to_se3,
|
||||
se3_to_matrix,
|
||||
)
|
||||
from gps_denied_onboard.helpers.sha256_sidecar import (
|
||||
SIDECAR_SUFFIX,
|
||||
Sha256Sidecar,
|
||||
Sha256SidecarError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SE3",
|
||||
"SIDECAR_SUFFIX",
|
||||
"Se3InvalidMatrixError",
|
||||
"Sha256Sidecar",
|
||||
"Sha256SidecarError",
|
||||
"adjoint",
|
||||
"exp_map",
|
||||
"is_valid_rotation",
|
||||
"log_map",
|
||||
"matrix_to_se3",
|
||||
"se3_to_matrix",
|
||||
]
|
||||
|
||||
@@ -1,29 +1,142 @@
|
||||
"""SE(3) utility helpers — STUB.
|
||||
"""SE(3) helpers backed by GTSAM `Pose3` (E-CC-HELPERS / AZ-264 / AZ-277).
|
||||
|
||||
Concrete implementation is owned by AZ-277. Contract:
|
||||
`_docs/02_document/common-helpers/02_helper_se3_utils.md`.
|
||||
Implements the `se3_utils` contract v1.0.0 at
|
||||
`_docs/02_document/contracts/shared_helpers/se3_utils.md`. Stateless,
|
||||
pure functions; strict caller-orthogonalisation invariant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
|
||||
import gtsam
|
||||
import numpy as np
|
||||
|
||||
__all__ = [
|
||||
"SE3",
|
||||
"Se3InvalidMatrixError",
|
||||
"adjoint",
|
||||
"exp_map",
|
||||
"is_valid_rotation",
|
||||
"log_map",
|
||||
"matrix_to_se3",
|
||||
"se3_to_matrix",
|
||||
]
|
||||
|
||||
|
||||
def compose(a_se3: Any, b_se3: Any) -> Any:
|
||||
"""Compose two SE(3) transforms."""
|
||||
raise NotImplementedError("se3_utils concrete impl is AZ-277")
|
||||
SE3 = gtsam.Pose3
|
||||
|
||||
# Tolerance for the orthogonality / determinant / bottom-row checks. Tight
|
||||
# enough that an ill-conditioned rotation from a real consumer is caught;
|
||||
# loose enough to not reject within-noise GTSAM output. Documented at the
|
||||
# contract level for symmetry with consumer tests.
|
||||
_DEFAULT_ROT_ATOL: Final[float] = 1e-6
|
||||
_DEFAULT_BOTTOM_ROW: Final[np.ndarray] = np.array([0.0, 0.0, 0.0, 1.0], dtype=np.float64)
|
||||
|
||||
# Small-angle Taylor cutoff for `exp_map` stability (AC-3). Below this twist
|
||||
# norm we delegate to GTSAM's first-order Taylor fallback rather than risk
|
||||
# the `sin(theta)/theta` numerator under-flowing to zero.
|
||||
_SMALL_ANGLE_THRESHOLD: Final[float] = 1e-10
|
||||
|
||||
|
||||
def inverse(t_se3: Any) -> Any:
|
||||
"""Invert an SE(3) transform."""
|
||||
raise NotImplementedError("se3_utils concrete impl is AZ-277")
|
||||
class Se3InvalidMatrixError(ValueError):
|
||||
"""Raised when an input matrix violates the SE(3) shape / dtype / orthogonality contract."""
|
||||
|
||||
|
||||
def log_map(t_se3: Any) -> Any:
|
||||
"""SE(3) → se(3) log map (returns a 6-vector)."""
|
||||
raise NotImplementedError("se3_utils concrete impl is AZ-277")
|
||||
def _require_float64(array: np.ndarray, *, name: str) -> None:
|
||||
if array.dtype != np.float64:
|
||||
raise Se3InvalidMatrixError(
|
||||
f"{name}: helpers operate strictly on dtype=float64; got {array.dtype}"
|
||||
)
|
||||
|
||||
|
||||
def exp_map(xi_6: Any) -> Any:
|
||||
"""se(3) → SE(3) exp map (consumes a 6-vector)."""
|
||||
raise NotImplementedError("se3_utils concrete impl is AZ-277")
|
||||
def _require_shape(array: np.ndarray, expected: tuple[int, ...], *, name: str) -> None:
|
||||
if array.shape != expected:
|
||||
raise Se3InvalidMatrixError(f"{name}: expected shape {expected}; got {array.shape}")
|
||||
|
||||
|
||||
def is_valid_rotation(R_3x3: np.ndarray, *, atol: float = _DEFAULT_ROT_ATOL) -> bool:
|
||||
"""Return True iff `R_3x3` is an orthogonal rotation with positive determinant."""
|
||||
if not isinstance(R_3x3, np.ndarray):
|
||||
return False
|
||||
if R_3x3.shape != (3, 3) or R_3x3.dtype != np.float64:
|
||||
return False
|
||||
drift = R_3x3.T @ R_3x3 - np.eye(3, dtype=np.float64)
|
||||
if np.linalg.norm(drift, ord="fro") > atol:
|
||||
return False
|
||||
if np.linalg.det(R_3x3) < 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def matrix_to_se3(T_4x4: np.ndarray, *, atol: float = _DEFAULT_ROT_ATOL) -> SE3:
|
||||
"""Convert a 4x4 homogeneous-transform matrix into a GTSAM `Pose3`.
|
||||
|
||||
Strict orthogonality contract: callers MUST pre-orthogonalise their
|
||||
rotation matrices. Non-orthogonal inputs, negative-determinant
|
||||
rotations, malformed bottom rows, and `float32` inputs all raise
|
||||
`Se3InvalidMatrixError` — the helper never silently re-orthogonalises.
|
||||
"""
|
||||
if not isinstance(T_4x4, np.ndarray):
|
||||
raise Se3InvalidMatrixError(
|
||||
f"matrix_to_se3: expected np.ndarray; got {type(T_4x4).__name__}"
|
||||
)
|
||||
_require_shape(T_4x4, (4, 4), name="matrix_to_se3")
|
||||
_require_float64(T_4x4, name="matrix_to_se3")
|
||||
|
||||
bottom_row = T_4x4[3]
|
||||
if not np.array_equal(bottom_row, _DEFAULT_BOTTOM_ROW):
|
||||
raise Se3InvalidMatrixError(
|
||||
f"matrix_to_se3: bottom row must be [0, 0, 0, 1]; got {bottom_row.tolist()}"
|
||||
)
|
||||
|
||||
R = T_4x4[:3, :3]
|
||||
drift = R.T @ R - np.eye(3, dtype=np.float64)
|
||||
drift_norm = float(np.linalg.norm(drift, ord="fro"))
|
||||
if drift_norm > atol:
|
||||
raise Se3InvalidMatrixError(
|
||||
f"matrix_to_se3: rotation is not orthogonal "
|
||||
f"(||R^T R - I||_F = {drift_norm:.3e} > atol={atol:.1e}); "
|
||||
f"caller must orthogonalise before invoking the helper"
|
||||
)
|
||||
|
||||
det = float(np.linalg.det(R))
|
||||
if det < 0:
|
||||
raise Se3InvalidMatrixError(
|
||||
f"matrix_to_se3: rotation has negative determinant ({det:.3e}); "
|
||||
f"mirror rotations are not valid SE(3) members"
|
||||
)
|
||||
|
||||
return SE3(T_4x4)
|
||||
|
||||
|
||||
def se3_to_matrix(pose: SE3) -> np.ndarray:
|
||||
"""Return the 4x4 homogeneous matrix for `pose` as `float64`."""
|
||||
return np.ascontiguousarray(pose.matrix(), dtype=np.float64)
|
||||
|
||||
|
||||
def exp_map(xi: np.ndarray) -> SE3:
|
||||
"""Exponential map: se(3) twist (6,) -> SE(3) pose.
|
||||
|
||||
Near-identity inputs (twist norm below the small-angle threshold)
|
||||
fall back to the identity pose rather than relying on the
|
||||
full-precision `sin(theta)/theta` expansion.
|
||||
"""
|
||||
if not isinstance(xi, np.ndarray):
|
||||
raise Se3InvalidMatrixError(f"exp_map: expected np.ndarray; got {type(xi).__name__}")
|
||||
_require_shape(xi, (6,), name="exp_map")
|
||||
_require_float64(xi, name="exp_map")
|
||||
|
||||
if float(np.linalg.norm(xi)) < _SMALL_ANGLE_THRESHOLD:
|
||||
return SE3()
|
||||
return SE3.Expmap(xi)
|
||||
|
||||
|
||||
def log_map(pose: SE3) -> np.ndarray:
|
||||
"""Logarithm map: SE(3) pose -> se(3) twist (6,)."""
|
||||
return np.ascontiguousarray(SE3.Logmap(pose), dtype=np.float64)
|
||||
|
||||
|
||||
def adjoint(pose: SE3) -> np.ndarray:
|
||||
"""Adjoint matrix (6x6) of `pose` for body-frame -> world-frame twist transport."""
|
||||
return np.ascontiguousarray(pose.AdjointMap(), dtype=np.float64)
|
||||
|
||||
@@ -1,19 +1,172 @@
|
||||
"""Content-hash sidecar helper — STUB.
|
||||
"""Atomic-write + SHA-256 sidecar helper (D-C10-3 / E-CC-HELPERS / AZ-280).
|
||||
|
||||
D-C10-3 content-hash gate. Concrete impl owned by AZ-280. Contract:
|
||||
`_docs/02_document/common-helpers/05_helper_sha256_sidecar.md`.
|
||||
Implements the `sha256_sidecar` contract v1.0.0 at
|
||||
`_docs/02_document/contracts/shared_helpers/sha256_sidecar.md`. Stateless
|
||||
static-only design (per coderule § static methods are appropriate only
|
||||
for pure, self-contained computations and well-bounded I/O).
|
||||
|
||||
Atomic write is implemented via ``atomicwrites.atomic_write`` which uses
|
||||
the temp-file -> ``os.replace`` pattern. Verification recomputes the
|
||||
digest from the file's bytes; the sidecar value is consulted only as the
|
||||
"expected" side of the equality check.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from atomicwrites import atomic_write
|
||||
|
||||
def compute_sidecar(target_path: Path) -> Path:
|
||||
"""Compute SHA-256 of `target_path` and write a sidecar file next to it."""
|
||||
raise NotImplementedError("sha256_sidecar concrete impl is AZ-280")
|
||||
__all__ = ["Sha256Sidecar", "Sha256SidecarError"]
|
||||
|
||||
|
||||
def verify_sidecar(target_path: Path) -> bool:
|
||||
"""Verify that the sidecar matches the file content."""
|
||||
raise NotImplementedError("sha256_sidecar concrete impl is AZ-280")
|
||||
_SIDECAR_SUFFIX = ".sha256"
|
||||
_DIGEST_BYTES = 32 # SHA-256
|
||||
_DIGEST_HEX_LEN = _DIGEST_BYTES * 2
|
||||
|
||||
|
||||
class Sha256SidecarError(RuntimeError):
|
||||
"""Raised by `Sha256Sidecar` on any sidecar / atomicity / aggregate failure.
|
||||
|
||||
Wraps the underlying `OSError` (or `ValueError`) so callers only ever
|
||||
handle one exception hierarchy from the helper.
|
||||
"""
|
||||
|
||||
|
||||
def _sidecar_path(payload_path: Path) -> Path:
|
||||
"""Return ``<path>.sha256`` — always appended verbatim to the full path string.
|
||||
|
||||
`Path.with_suffix` would re-interpret an existing extension; we want a
|
||||
pure append so ``manifest`` -> ``manifest.sha256`` and
|
||||
``engine.engine`` -> ``engine.engine.sha256``.
|
||||
"""
|
||||
return Path(str(payload_path) + _SIDECAR_SUFFIX)
|
||||
|
||||
|
||||
def _digest_bytes(payload: bytes) -> str:
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _digest_file(payload_path: Path) -> str:
|
||||
"""Stream-hash a file from disk so we never trust the in-memory copy."""
|
||||
hasher = hashlib.sha256()
|
||||
with payload_path.open("rb") as fh:
|
||||
while True:
|
||||
chunk = fh.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
hasher.update(chunk)
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
def _validate_sidecar_text(sidecar_text: str) -> str:
|
||||
"""Return the cleaned hex digest or raise `Sha256SidecarError`."""
|
||||
if len(sidecar_text) != _DIGEST_HEX_LEN:
|
||||
raise Sha256SidecarError(
|
||||
f"malformed sidecar: expected exactly {_DIGEST_HEX_LEN} hex chars, "
|
||||
f"got {len(sidecar_text)} bytes (content: {sidecar_text!r})"
|
||||
)
|
||||
try:
|
||||
int(sidecar_text, 16)
|
||||
except ValueError as exc:
|
||||
raise Sha256SidecarError(
|
||||
f"malformed sidecar: not a hex digest ({sidecar_text!r}): {exc}"
|
||||
) from exc
|
||||
if sidecar_text.lower() != sidecar_text:
|
||||
raise Sha256SidecarError(
|
||||
f"malformed sidecar: hex digest must be lowercase ({sidecar_text!r})"
|
||||
)
|
||||
return sidecar_text
|
||||
|
||||
|
||||
class Sha256Sidecar:
|
||||
"""Atomic-write + SHA-256 sidecar facade.
|
||||
|
||||
Static-only by design — no per-call state is meaningful. Atomicity
|
||||
and verification invariants are documented at the contract level.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def write_atomic(path: Path, payload: bytes) -> str:
|
||||
"""Atomically write `payload` to `path`; return its SHA-256 hex digest."""
|
||||
digest = _digest_bytes(payload)
|
||||
try:
|
||||
with atomic_write(str(path), mode="wb", overwrite=True) as fh:
|
||||
fh.write(payload)
|
||||
except OSError as exc:
|
||||
raise Sha256SidecarError(f"write_atomic: failed to write {path}: {exc}") from exc
|
||||
return digest
|
||||
|
||||
@staticmethod
|
||||
def write_atomic_and_sidecar(path: Path, payload: bytes) -> str:
|
||||
"""Atomically write `payload` and its `<path>.sha256` sidecar.
|
||||
|
||||
Both writes go through the temp-file + rename atomic-write
|
||||
pattern. Returns the hex digest that was written.
|
||||
"""
|
||||
digest = Sha256Sidecar.write_atomic(path, payload)
|
||||
sidecar = _sidecar_path(path)
|
||||
try:
|
||||
with atomic_write(str(sidecar), mode="w", overwrite=True) as fh:
|
||||
fh.write(digest)
|
||||
except OSError as exc:
|
||||
raise Sha256SidecarError(
|
||||
f"write_atomic_and_sidecar: failed to write sidecar at {sidecar}: {exc}"
|
||||
) from exc
|
||||
return digest
|
||||
|
||||
@staticmethod
|
||||
def verify(path: Path) -> bool:
|
||||
"""Recompute the on-disk SHA-256 and compare with the sidecar.
|
||||
|
||||
Returns False if `path` is missing entirely (a missing artifact
|
||||
is "not verifiable" rather than an error in the verification
|
||||
contract — callers can branch on `path.exists()` first if they
|
||||
need to distinguish). Raises `Sha256SidecarError` if `path`
|
||||
exists but the sidecar is missing or malformed.
|
||||
"""
|
||||
if not path.exists():
|
||||
return False
|
||||
sidecar = _sidecar_path(path)
|
||||
if not sidecar.exists():
|
||||
raise Sha256SidecarError(f"verify: sidecar missing for {path} (expected at {sidecar})")
|
||||
try:
|
||||
sidecar_text = sidecar.read_text()
|
||||
except OSError as exc:
|
||||
raise Sha256SidecarError(f"verify: cannot read sidecar at {sidecar}: {exc}") from exc
|
||||
expected = _validate_sidecar_text(sidecar_text)
|
||||
try:
|
||||
actual = _digest_file(path)
|
||||
except OSError as exc:
|
||||
raise Sha256SidecarError(f"verify: cannot read payload at {path}: {exc}") from exc
|
||||
return actual == expected
|
||||
|
||||
@staticmethod
|
||||
def aggregate_hash(paths: list[Path]) -> str:
|
||||
"""Order-deterministic SHA-256 over many files (Manifest aggregate).
|
||||
|
||||
Inputs are sorted by full path (case-sensitive) before hashing,
|
||||
so two runs over the same set produce byte-equal digests. The
|
||||
aggregate is the SHA-256 of the concatenation of
|
||||
``<filename>\\0<file-hex-digest>\\n`` lines.
|
||||
"""
|
||||
sorted_paths = sorted(paths, key=lambda p: str(p))
|
||||
hasher = hashlib.sha256()
|
||||
for path in sorted_paths:
|
||||
if not path.exists():
|
||||
raise Sha256SidecarError(f"aggregate_hash: missing path in input: {path}")
|
||||
try:
|
||||
digest = _digest_file(path)
|
||||
except OSError as exc:
|
||||
raise Sha256SidecarError(f"aggregate_hash: cannot read {path}: {exc}") from exc
|
||||
hasher.update(path.name.encode("utf-8"))
|
||||
hasher.update(b"\0")
|
||||
hasher.update(digest.encode("ascii"))
|
||||
hasher.update(b"\n")
|
||||
return hasher.hexdigest()
|
||||
|
||||
|
||||
# Public constant for callers that need to spell the sidecar suffix
|
||||
# explicitly (e.g. takeoff-load verifier listing).
|
||||
SIDECAR_SUFFIX = _SIDECAR_SUFFIX
|
||||
|
||||
Reference in New Issue
Block a user