Skip to content

Commit 428729a

Browse files
committed
feat(observability): commit 1 — schema delta for OSAP gate diagnostics + silent-drop surface
Phase 4h.2 Part 1 (issue #116) — commit 1 of 3. Schema delta only; orchestration wiring lands in commits 2 (silent-drop) + 3 (gate diagnostics). **SCHEMA_VERSION** ``0.9.0-phase4h`` → ``0.9.1-phase4h.2``. PATCH bump per SKILL.md L305 lock: "Add a new optional field (default = None) → patch". Both new fields are ``| None = None`` additive optional; legacy 0.9.0-phase4h JSONs deserialize cleanly (verified by ``tests/test_output/test_schema_phase4h2.py:: test_metadata_backward_compat_with_0_9_0_payload``). **New Pydantic model** (``compute/output/schemas.py``): - ``OsapGateDiagnostic`` — 4 nullable fields per the locked refinement: ``pbo`` · ``dsr`` · ``sharpe`` · ``rejection_reason``. All default to ``None`` (no positional-required fields) so per-signal diagnostics serialize cleanly even for accepted signals (where ``rejection_reason`` is ``None``). - Mirrored in ``frontend/lib/types.ts::OsapGateDiagnostic`` + regenerated ``frontend/lib/schema-snapshot.json``. **Metadata additions** (both ``| None = None`` per the schema- versioning rule): - ``osap_signals_missing_from_dataset: list[str] | None`` — surfaces the silent-drop bug from #116. Production today shows 78/100 manifest signals missing from the dataset surface; commit 2 will populate this field via ``compute_missing_signals`` in ``compute/features/osap_replicate.py``. - ``osap_gate_diagnostics: dict[str, OsapGateDiagnostic] | None`` — surfaces per-signal PBO/DSR/Sharpe/rejection_reason. Production today shows 22 signals reaching the gate with 0% acceptance; commit 3 will populate this dict via the existing ``gate_results`` output of ``compute/validation/osap_validation.py:: gate_osap_signals``. **Registry updates**: - ``compute/output/schema_check.py::TRACKED_MODELS`` — added ``OsapGateDiagnostic`` so the BaseModel-subclass tracking test (``tests/test_output/test_schema_check.py::test_A5_tracked_ models_count_matches_schemas_module``) doesn't flag the new class as untracked. - ``tests/test_config.py`` — SCHEMA_VERSION pin updated to ``0.9.1-phase4h.2``. - ``tests/test_smoke.py`` — unchanged (``startswith("0.9.")`` still passes). **Tests** (``tests/test_output/test_schema_phase4h2.py``, 7 offline): 1. ``test_osap_gate_diagnostic_round_trip_with_all_fields`` — full field round-trip. 2. ``test_osap_gate_diagnostic_all_fields_default_to_none`` — empty construction validates per refinement #3 lock. 3. ``test_osap_gate_diagnostic_rejection_reason_taxonomy`` — canonical 4-value taxonomy round-trips (``high_pbo`` / ``low_dsr`` / ``insufficient_data`` / ``gate_failed``). 4. ``test_metadata_round_trip_with_new_fields_populated`` — end-to- end Metadata with both new fields filled. 5. ``test_metadata_backward_compat_with_0_9_0_payload`` — legacy payload deserializes; new fields default to ``None``. 6. ``test_metadata_new_fields_default_to_none`` — verbose restatement of the additive-optional contract. 7. ``test_metadata_extra_forbid_rejects_unknown_fields`` — locks the schema surface against silent field renames. **Verification**: - ``ruff check .`` → clean - ``python -m compute.output.schema_check`` → in-sync (Python ↔ TypeScript ↔ snapshot) - ``pytest tests/ -m "not network"`` → **918 passed** (911 prior + 7 new) **Out of scope this commit** (commits 2 + 3): - ``compute/features/osap_replicate.py`` set-diff helper for the silent-drop list (commit 2) - ``compute/main.py`` orchestration wiring both fields (commits 2 + 3) - ``compute/main.py`` unit test for silent-drop pass-through (commit 2) - Docs updates (PHASE_STATUS / SKILL / CLAUDE / WORKFLOW) (commit 3) **Defense layer**: unchanged at 17. **Top-5 rotation**: unchanged (Rule 16 lock — observability-only). https://claude.ai/code/session_01T8FE3MAnmk6hcjvH4SgYNU
1 parent 07484c3 commit 428729a

7 files changed

Lines changed: 231 additions & 3 deletions

File tree

compute/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
MODELS_DIR: Path = PROJECT_ROOT / "models"
2828

2929
UNIVERSE: str = "SP500"
30-
SCHEMA_VERSION: str = "0.9.0-phase4h"
30+
SCHEMA_VERSION: str = "0.9.1-phase4h.2"
3131

3232
PRICES_PERIOD: str = "5y"
3333
MAX_PARALLEL_FETCHES: int = 10

compute/output/schema_check.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
from compute.output.schemas import (
4343
DataQuality,
4444
Metadata,
45+
OsapGateDiagnostic,
4546
PillarBaseline,
4647
PillarScores,
4748
RawMetrics,
@@ -54,6 +55,7 @@
5455
TRACKED_MODELS: list[type[BaseModel]] = [
5556
DataQuality,
5657
Metadata,
58+
OsapGateDiagnostic,
5759
PillarBaseline,
5860
PillarScores,
5961
RawMetrics,

compute/output/schemas.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,28 @@ class StockSummary(BaseModel):
7777
exited_top5: bool = False
7878

7979

80+
class OsapGateDiagnostic(BaseModel):
81+
"""Per-signal PBO/DSR gate decision surfaced into
82+
``Metadata.osap_gate_diagnostics``. Phase 4h.2 Part 1 observability
83+
addition (issue #116) — lets future debugging answer "why did this
84+
signal reject?" without a local re-run of the PBO/DSR cohort.
85+
86+
All 4 fields default to ``None`` so legacy 0.9.0 JSONs without this
87+
field deserialize cleanly. ``rejection_reason`` taxonomy mirrors
88+
``compute/validation/osap_validation.py::GateResult.rejection_reason``:
89+
one of ``"high_pbo"`` / ``"low_dsr"`` / ``"insufficient_data"`` /
90+
``"gate_failed"`` for rejected signals; ``None`` for accepted
91+
signals.
92+
"""
93+
94+
model_config = ConfigDict(extra="forbid")
95+
96+
pbo: float | None = None
97+
dsr: float | None = None
98+
sharpe: float | None = None
99+
rejection_reason: str | None = None
100+
101+
80102
class Metadata(BaseModel):
81103
model_config = ConfigDict(extra="forbid")
82104

@@ -96,6 +118,15 @@ class Metadata(BaseModel):
96118
osap_excluded_signals: list[str] | None = None
97119
osap_signals_ic_12m: dict[str, float] | None = None
98120
osap_signals_coverage_pct: dict[str, float] | None = None
121+
# Phase 4h.2 Part 1 — observability for the manifest-vs-dataset gap
122+
# and per-signal gate decisions surfaced by issue #116.
123+
# ``osap_signals_missing_from_dataset`` lists ``OSAP_SIGNALS_100``
124+
# entries that the OSAP fetch returned no rows for (silent drop in
125+
# 0.9.0-phase4h; visible here). ``osap_gate_diagnostics`` carries
126+
# the per-signal PBO/DSR/Sharpe/rejection_reason for every signal
127+
# that reached the gate.
128+
osap_signals_missing_from_dataset: list[str] | None = None
129+
osap_gate_diagnostics: dict[str, OsapGateDiagnostic] | None = None
99130

100131

101132
class RawMetrics(BaseModel):

frontend/lib/schema-snapshot.json

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@
7272
"required": false,
7373
"default": null
7474
},
75+
"osap_gate_diagnostics": {
76+
"type": "dict[str, OsapGateDiagnostic] | None",
77+
"required": false,
78+
"default": null
79+
},
7580
"osap_signals_coverage_pct": {
7681
"type": "dict[str, float] | None",
7782
"required": false,
@@ -82,6 +87,11 @@
8287
"required": false,
8388
"default": null
8489
},
90+
"osap_signals_missing_from_dataset": {
91+
"type": "list[str] | None",
92+
"required": false,
93+
"default": null
94+
},
8595
"osap_signals_used": {
8696
"type": "list[str] | None",
8797
"required": false,
@@ -108,6 +118,28 @@
108118
"default": "<required>"
109119
}
110120
},
121+
"OsapGateDiagnostic": {
122+
"dsr": {
123+
"type": "float | None",
124+
"required": false,
125+
"default": null
126+
},
127+
"pbo": {
128+
"type": "float | None",
129+
"required": false,
130+
"default": null
131+
},
132+
"rejection_reason": {
133+
"type": "str | None",
134+
"required": false,
135+
"default": null
136+
},
137+
"sharpe": {
138+
"type": "float | None",
139+
"required": false,
140+
"default": null
141+
}
142+
},
111143
"PillarBaseline": {
112144
"label": {
113145
"type": "str",

frontend/lib/types.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,28 @@ export type Metadata = {
8282
osap_excluded_signals: string[] | null;
8383
osap_signals_ic_12m: Record<string, number> | null;
8484
osap_signals_coverage_pct: Record<string, number> | null;
85+
// Phase 4h.2 Part 1 — observability for the manifest-vs-dataset gap
86+
// and per-signal gate decisions surfaced by issue #116.
87+
// `osap_signals_missing_from_dataset` lists OSAP_SIGNALS_100 entries
88+
// that the OSAP fetch returned no rows for (silent drops in
89+
// 0.9.0-phase4h; visible here). `osap_gate_diagnostics` carries the
90+
// per-signal PBO/DSR/Sharpe/rejection_reason for every signal that
91+
// reached the gate. Both null on legacy outputs from before
92+
// 0.9.1-phase4h.2.
93+
osap_signals_missing_from_dataset: string[] | null;
94+
osap_gate_diagnostics: Record<string, OsapGateDiagnostic> | null;
95+
};
96+
97+
// Phase 4h.2 Part 1 — per-signal gate decision shape. Mirrors
98+
// `compute/output/schemas.py::OsapGateDiagnostic`. All 4 fields nullable
99+
// so legacy 0.9.0 JSONs deserialize cleanly. `rejection_reason` is one
100+
// of "high_pbo" / "low_dsr" / "insufficient_data" / "gate_failed" for
101+
// rejected signals; null for accepted signals.
102+
export type OsapGateDiagnostic = {
103+
pbo: number | null;
104+
dsr: number | null;
105+
sharpe: number | null;
106+
rejection_reason: string | null;
85107
};
86108

87109
// Phase 3d Tier-2 event defenses. Surfaces in StockDetail.tier2_events.

tests/test_config.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
from compute import config
1111

1212

13-
def test_schema_version_is_phase4h():
14-
assert config.SCHEMA_VERSION == "0.9.0-phase4h"
13+
def test_schema_version_is_phase4h_2():
14+
assert config.SCHEMA_VERSION == "0.9.1-phase4h.2"
1515

1616

1717
def test_eight_k_lookback_veto_is_one_year():
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""Pydantic round-trip + legacy backward-compat tests for the
2+
Phase 4h.2 Part 1 schema additions (issue #116).
3+
4+
Two new optional fields land on ``Metadata``:
5+
6+
- ``osap_signals_missing_from_dataset: list[str] | None = None``
7+
- ``osap_gate_diagnostics: dict[str, OsapGateDiagnostic] | None = None``
8+
9+
Plus a new nested model ``OsapGateDiagnostic`` with 4 nullable fields.
10+
All defaults are ``None`` so a legacy 0.9.0-phase4h ``metadata.json``
11+
deserializes cleanly with the new fields set to ``None`` — the
12+
canonical "additive optional field → PATCH bump" pattern from
13+
SKILL.md L305 ("Add a new optional field (default = None) → patch").
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from compute.output.schemas import Metadata, OsapGateDiagnostic
19+
20+
21+
def _legacy_0_9_0_metadata_payload() -> dict:
22+
"""A canonical 0.9.0-phase4h metadata.json payload — the production
23+
shape *before* this PR's additions. Used to assert backward-compat.
24+
"""
25+
return {
26+
"version": "0.9.0-phase4h",
27+
"last_update_utc": "2026-05-18T22:00:00Z",
28+
"next_update_utc": "2026-05-25T22:00:00Z",
29+
"universe": "sp500",
30+
"universe_size": 502,
31+
"compute_run_id": "local",
32+
"git_commit": "fbd1acf461847d835967bd701a098af93c9d2bd7",
33+
"mos_trailing_ic_smoke": 0.05,
34+
"tier2_coverage_pct": 0.97,
35+
"fundamentals_coverage_pct": 0.99,
36+
"fundamentals_latency_p50_seconds": 1.2,
37+
"fundamentals_latency_p95_seconds": 5.4,
38+
"osap_signals_used": None,
39+
"osap_excluded_signals": [
40+
"AbnormalAccruals", "AssetGrowth", "BM", "BetaFP", "CF",
41+
],
42+
"osap_signals_ic_12m": None,
43+
"osap_signals_coverage_pct": None,
44+
}
45+
46+
47+
def test_osap_gate_diagnostic_round_trip_with_all_fields():
48+
"""Happy path — populate every field, round-trip through JSON."""
49+
diag = OsapGateDiagnostic(
50+
pbo=0.45,
51+
dsr=0.12,
52+
sharpe=0.34,
53+
rejection_reason=None,
54+
)
55+
payload = diag.model_dump()
56+
restored = OsapGateDiagnostic.model_validate(payload)
57+
assert restored == diag
58+
59+
60+
def test_osap_gate_diagnostic_all_fields_default_to_none():
61+
"""Empty construction (zero args) — every field is None.
62+
Per refinement #3: all 4 fields explicit ``= None`` defaults."""
63+
diag = OsapGateDiagnostic()
64+
assert diag.pbo is None
65+
assert diag.dsr is None
66+
assert diag.sharpe is None
67+
assert diag.rejection_reason is None
68+
69+
70+
def test_osap_gate_diagnostic_rejection_reason_taxonomy():
71+
"""Mirrors ``compute/validation/osap_validation.py::GateResult``
72+
rejection_reason values. The model accepts any str (no Literal
73+
constraint), but the canonical taxonomy should round-trip cleanly.
74+
"""
75+
for reason in ("high_pbo", "low_dsr", "insufficient_data", "gate_failed"):
76+
diag = OsapGateDiagnostic(rejection_reason=reason)
77+
restored = OsapGateDiagnostic.model_validate(diag.model_dump())
78+
assert restored.rejection_reason == reason
79+
80+
81+
def test_metadata_round_trip_with_new_fields_populated():
82+
"""End-to-end: Metadata with both new fields populated."""
83+
payload = _legacy_0_9_0_metadata_payload()
84+
payload["osap_signals_missing_from_dataset"] = ["AOP", "AccrualsBM", "ChEQ"]
85+
payload["osap_gate_diagnostics"] = {
86+
"BM": {"pbo": 0.6, "dsr": -0.1, "sharpe": 0.05, "rejection_reason": "high_pbo"},
87+
"Mom12m": {"pbo": 0.4, "dsr": -0.3, "sharpe": 0.2, "rejection_reason": "low_dsr"},
88+
}
89+
meta = Metadata.model_validate(payload)
90+
assert meta.osap_signals_missing_from_dataset == ["AOP", "AccrualsBM", "ChEQ"]
91+
assert isinstance(meta.osap_gate_diagnostics, dict)
92+
assert meta.osap_gate_diagnostics["BM"].pbo == 0.6
93+
assert meta.osap_gate_diagnostics["BM"].rejection_reason == "high_pbo"
94+
assert meta.osap_gate_diagnostics["Mom12m"].dsr == -0.3
95+
96+
restored = Metadata.model_validate(meta.model_dump())
97+
assert restored == meta
98+
99+
100+
def test_metadata_backward_compat_with_0_9_0_payload():
101+
"""Legacy 0.9.0-phase4h JSON (no new fields) deserializes cleanly
102+
— the backward-compat guarantee that justifies a PATCH bump per
103+
SKILL.md L305 ('Add a new optional field (default = None) →
104+
patch').
105+
"""
106+
legacy_payload = _legacy_0_9_0_metadata_payload()
107+
assert "osap_signals_missing_from_dataset" not in legacy_payload
108+
assert "osap_gate_diagnostics" not in legacy_payload
109+
110+
meta = Metadata.model_validate(legacy_payload)
111+
assert meta.version == "0.9.0-phase4h"
112+
assert meta.osap_signals_missing_from_dataset is None
113+
assert meta.osap_gate_diagnostics is None
114+
# Existing Phase 4h fields still populated.
115+
assert meta.osap_excluded_signals == [
116+
"AbnormalAccruals", "AssetGrowth", "BM", "BetaFP", "CF",
117+
]
118+
119+
120+
def test_metadata_new_fields_default_to_none():
121+
"""Constructing a Metadata without supplying the new fields leaves
122+
them at None — same semantics as every other Phase-4h OSAP field."""
123+
payload = _legacy_0_9_0_metadata_payload()
124+
meta = Metadata.model_validate(payload)
125+
assert meta.osap_signals_missing_from_dataset is None
126+
assert meta.osap_gate_diagnostics is None
127+
128+
129+
def test_metadata_extra_forbid_rejects_unknown_fields():
130+
"""``model_config = ConfigDict(extra='forbid')`` catches typo'd
131+
field names. Locks the schema surface so future refactors that
132+
rename ``osap_signals_missing_from_dataset`` (e.g., to
133+
``osap_manifest_missing``) raise instead of silently producing
134+
a no-op field."""
135+
import pytest as _pytest
136+
from pydantic import ValidationError
137+
138+
payload = _legacy_0_9_0_metadata_payload()
139+
payload["osap_signals_missing"] = ["typo_field_name"] # not the real field
140+
with _pytest.raises(ValidationError):
141+
Metadata.model_validate(payload)

0 commit comments

Comments
 (0)