From 94c1b76086f2aca93261d4d104eb2417060c06fa Mon Sep 17 00:00:00 2001 From: Yuzviak Date: Mon, 11 May 2026 18:49:55 +0300 Subject: [PATCH] feat(02-07): add Pydantic v2 boundary-log schemas (OBS-01) - Create src/gps_denied/obs/log_schemas.py with 3 models: MavlinkGpsInputEmitted (AC-4.3 + AC-1.4), ApiRequestCompleted, AnchorDecision (VERIFY-02) - All models use ConfigDict(extra='forbid', frozen=True) - SourceLabel Literal encodes AC-1.4 vocab; AnchorRejectReason encodes VERIFY-02 - Update gps_denied.obs barrel __init__.py to re-export all schemas + type aliases --- src/gps_denied/obs/__init__.py | 30 +++++++++ src/gps_denied/obs/log_schemas.py | 100 ++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) create mode 100644 src/gps_denied/obs/__init__.py create mode 100644 src/gps_denied/obs/log_schemas.py diff --git a/src/gps_denied/obs/__init__.py b/src/gps_denied/obs/__init__.py new file mode 100644 index 0000000..347b381 --- /dev/null +++ b/src/gps_denied/obs/__init__.py @@ -0,0 +1,30 @@ +"""Observability package — structlog spine + boundary log schemas. + +Hot-path logging spine wired by ``configure_logging``; per-frame ``correlation_id`` +(= frame_id) bound at ``pipeline/orchestrator.py:process_frame``. + +Boundary log schemas (REST, FDR, anchor decisions) defined in ``log_schemas``. +""" +import structlog + +from gps_denied.obs.log_schemas import ( + AnchorDecision, + AnchorRejectReason, + ApiRequestCompleted, + MavlinkGpsInputEmitted, + SourceLabel, +) +from gps_denied.obs.logging_config import configure_logging + +get_logger = structlog.get_logger # convenience re-export + +__all__ = [ + "configure_logging", + "get_logger", + # Boundary schemas + "MavlinkGpsInputEmitted", + "ApiRequestCompleted", + "AnchorDecision", + "SourceLabel", + "AnchorRejectReason", +] diff --git a/src/gps_denied/obs/log_schemas.py b/src/gps_denied/obs/log_schemas.py new file mode 100644 index 0000000..15e1e4c --- /dev/null +++ b/src/gps_denied/obs/log_schemas.py @@ -0,0 +1,100 @@ +"""Pydantic v2 schemas for boundary log records. + +Boundary = a point where the hot path hands off to (or receives from) an external +system: MAVLink wire (AC-4.3), REST API, AnchorVerifier accept/reject (VERIFY-02), +FDR JSONL append (AC-NEW-3). Inside the hot path we use +``@dataclass(slots=True, frozen=True)`` per ARCH-02 — these Pydantic schemas are +NEVER used per-frame inside the pipeline body. + +Usage pattern (Phase 5 / Phase 6 / Phase 3 wiring): + + from gps_denied.obs import log_schemas, get_logger + log = get_logger(__name__) + + rec = log_schemas.MavlinkGpsInputEmitted( + lat_deg=est.lat_deg, lon_deg=est.lon_deg, alt_m=est.alt_m, + fix_type=3, horiz_accuracy_m=est.cov_semi_major_m, + source_label=est.source_label, anchor_age_ms=est.anchor_age_ms, + cov_semi_major_m=est.cov_semi_major_m, + ) + log.info("mavlink_emit_gps_input", **rec.model_dump(mode="json")) + # frame_id is auto-included via structlog merge_contextvars (Plan 02-06). + +``mode="json"`` ensures datetimes / enums / numpy scalars are JSON-safe before the +structlog renderer fires. Validation cost on Pydantic v2 / pydantic-core is +~10-60 µs per record (RESEARCH.md §5.4 performance note); boundary call rates +(≤10 Hz MAVLink, ≤1 Hz API, ≤0.1 Hz failed-tile thumbnails) make this trivial +against the 400 ms / frame budget. + +Field validation is intentionally tight (``extra='forbid'``, frozen=True) so adding +a producer-side field without updating the schema fails fast. +""" +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +# Source-label vocabulary — AC-1.4. Matches the Phase-1 enum +# gps_denied.schemas.eskf.ConfidenceTier (kept in stdlib boundary schemas for +# legacy DB rows); new code uses this Literal. +SourceLabel = Literal["satellite_anchored", "vo_extrapolated", "dead_reckoned"] + +# Reject-reason vocabulary — REQUIREMENTS.md VERIFY-02. Stable contract; Phase 3 +# AnchorVerifier emits exactly these strings. +AnchorRejectReason = Literal[ + "ok", + "too_few_inliers", + "mre_above_threshold", + "degenerate_homography", + "freshness_expired", +] + + +class _BoundaryLogRecord(BaseModel): + """Common base. Tighten the contract: + - ``extra='forbid'`` — producer-side typo or schema drift fails validation. + - ``frozen=True`` — records are facts, not state. No mutation after build. + """ + model_config = ConfigDict(extra="forbid", frozen=True) + + +class MavlinkGpsInputEmitted(_BoundaryLogRecord): + """Logged once per GPS_INPUT MAVLink message emitted on the wire (AC-4.3). + + AC-1.4 categorical source_label + AC-1.3 anchor_age_ms + AC-NEW-8 cov_semi_major_m + are all required fields — the producer must compute them. Phase 5 (MAVOUT-01) wires. + """ + lat_deg: float + lon_deg: float + alt_m: float + fix_type: int = Field(ge=0, le=6) # MAVLink GPS fix_type: 0..6 + horiz_accuracy_m: float = Field(ge=0) + source_label: SourceLabel # AC-1.4 + anchor_age_ms: int = Field(ge=0) # AC-1.3 + cov_semi_major_m: float = Field(ge=0) # AC-1.4 (95% covariance) + + +class ApiRequestCompleted(_BoundaryLogRecord): + """Logged at the end of every FastAPI request. Phase 6 middleware consumer.""" + path: str + method: Literal["GET", "POST", "PUT", "DELETE", "PATCH"] + status_code: int = Field(ge=100, lt=600) + duration_ms: float = Field(ge=0) + + +class AnchorDecision(_BoundaryLogRecord): + """Logged on every AnchorVerifier accept/reject (VERIFY-02). Phase 3 consumer.""" + decision: Literal["accept", "reject"] + reason: AnchorRejectReason + n_inliers: int = Field(ge=0) + mre_px: float = Field(ge=0) + + +__all__ = [ + "SourceLabel", + "AnchorRejectReason", + "MavlinkGpsInputEmitted", + "ApiRequestCompleted", + "AnchorDecision", +]