Skip to content

Commit 6391bdf

Browse files
committed
feat(observability): commit 3 — gate diagnostics wiring + docs
**FINAL** commit of the Phase 4h.2 Part 1 3-commit cluster (issue #116). Populates the ``osap_gate_diagnostics`` field landed in commit 1's schema delta + docs the full Part-1 surface so reviewers + future maintainers see the schema and observability contract in one place. **`compute/main.py` wiring** (+23 LOC): 1. Import added to ``from compute.output.schemas import (...)``: ``OsapGateDiagnostic`` inserted alphabetically between ``Metadata`` and ``PillarScores`` (schemas import already used at this site, no new module touched). 2. Variable initialized BEFORE the OSAP try block: ``osap_gate_diagnostics: dict[str, OsapGateDiagnostic] = {}``. 3. Populated inside the try after ``gate_results = gate_osap_signals(osap_ls, requested_signals= config.OSAP_SIGNALS_100)`` and BEFORE ``filter_accepted_signals`` — captures EVERY signal that reached the gate (both accepted and rejected). Accepted carry ``rejection_reason=None``; rejected carry one of the canonical taxonomy values (``high_pbo`` / ``low_dsr`` / ``insufficient_data`` / ``gate_failed``) per ``compute/validation/osap_validation.py::GateResult``. 4. Reset to ``{}`` in the OSAP-pipeline-failed ``except`` branch so graceful degradation continues to leave every osap_* field at ``None``. 5. Wired into the ``Metadata(...)`` constructor with the established ``or None`` idiom: ```python osap_gate_diagnostics=osap_gate_diagnostics or None, ``` **Tests** (``tests/test_output/test_schema_phase4h2.py``, +55 LOC, 2 new offline appended to commit 1's suite): 1. ``test_metadata_gate_diagnostics_round_trip_with_production_cohort_shape`` — simulates the production observation from #116 (22 signals reach the gate, all rejected with a mix of rejection_reason values across the canonical 4-value taxonomy); asserts the dict-of-OsapGateDiagnostic structure survives ``model_validate`` → ``model_dump`` → ``model_validate`` round-trip. 2. ``test_metadata_gate_diagnostics_accepted_signal_has_null_rejection_reason`` — locks the ``rejection_reason=None`` semantics for accepted signals (Pydantic preserves None rather than coercing to a sentinel string). **Docs** (atomic with the wiring): - ``CLAUDE.md`` ``## Phase status`` — schema line updated to ``0.9.1-phase4h.2`` with the PATCH-bump framing; preserved the prior MINOR-bump history (`0.8.0-phase4.5f` → `0.9.0-phase4h` via PR #112). - ``PHASE_STATUS.md`` row 4 — Phase 4h.2 Part 1 sub-status added; describes both new fields, the Part-1 / Part-2 split rationale ("Part 2 opens after ≥1 week of production diagnostic data accumulates"), and the "no new veto / no rank change" invariant. - ``SKILL.md`` schema-versions table — new row for ``0.9.1-phase4h.2`` inserted above the ``0.9.0-phase4h`` row; cites the SKILL.md L305 PATCH-bump quote verbatim, locks the ``OsapGateDiagnostic`` "all 4 fields explicit = None" refinement in writing, and documents the set-diff helper placement decision (``compute/features/osap_replicate.py::signals_in_dataframe`` per refinement #4). - ``WORKFLOW.md`` — unchanged; no "Open items" checkbox list for Phase 4h.2 yet (would be created when Part 2 is scoped). **Verification ladder** (steps 1-5 complete): - ``ruff check .`` → clean ✅ - ``pytest tests/ -m "not network"`` → **924 passed** (911 baseline + 13 new across the 3-commit cluster: 7 schema + 4 helper + 2 gate-diagnostic) ✅ - ``python -m compute.output.schema_check`` → in-sync (no new schema delta this commit; the snapshot already captured both fields + ``OsapGateDiagnostic`` from commit 1's regen) ✅ - ``python -c "from compute.main import run_weekly_compute; from compute.output.schemas import OsapGateDiagnostic; ..."`` → OK ✅ Steps 6-8 next: ``git push`` → open Draft PR → ``subscribe_pr_activity`` + STOP for user audit + Mark-Ready authorization. **Defense layer**: unchanged at 17. **Top-5 rotation**: unchanged. **Schema version**: ``0.9.1-phase4h.2`` (locked from commit 1). **Cluster summary**: | # | SHA | LOC | Tests added | |---|---|---|---| | 1 — schema delta | ``428729ad`` | 231 | +7 (round-trip + backward-compat) | | 2 — silent-drop wiring | ``c7949403`` | 116 | +4 (helper unit tests) | | 3 — gate diagnostics + docs (this) | TBD | ~86 | +2 (gate-diag round-trip) | | **Total** | — | ~433 | **+13** | Within the Option-β diagnostic-first scope (~250-350 LOC budget; + docs); under the original plan's ~300 LOC estimate. https://claude.ai/code/session_01T8FE3MAnmk6hcjvH4SgYNU
1 parent c794940 commit 6391bdf

5 files changed

Lines changed: 86 additions & 5 deletions

File tree

CLAUDE.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,12 @@ non-connector-bound work.
153153

154154
## Phase status
155155

156-
Current schema: **`0.9.0-phase4h`** (bumped from `0.8.0-phase4.5f` in
157-
PR #112). Defense layer: **17** (7 active vetoes + 10 annotates + 5
158-
numerical guards + `manipulation_index` rollup) — Phase 4h adds
159-
observability surface, no new veto. Latest release tag:
156+
Current schema: **`0.9.1-phase4h.2`** (PATCH bump from `0.9.0-phase4h`
157+
for the observability-only Phase 4h.2 Part 1 follow-up to issue #116;
158+
prior MINOR bump `0.8.0-phase4.5f``0.9.0-phase4h` shipped in PR
159+
#112). Defense layer: **17** (7 active vetoes + 10 annotates + 5
160+
numerical guards + `manipulation_index` rollup) — Phase 4h.2 Part 1
161+
adds two new optional `Metadata` fields, no new veto, no rank impact. Latest release tag:
160162
[**`v1.2.0-phase4.5`**](https://github.com/dackclup/quantrank/releases/tag/v1.2.0-phase4.5)
161163
shipped 2026-05-17 at commit `6d414a9b`. **Phase 4h in flight in PR
162164
#112** — OSAP signal replication (factor-exposure proxy) + PBO/DSR

PHASE_STATUS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
| 1 | Universe + prices ingestion | ✅ DONE — 2026-05-08 |
77
| 2 | Fundamentals via SEC EDGAR | ✅ DONE — 2026-05-08 |
88
| 3 | Classical features + composite + **defenses****v1.0** |**DONE — 2026-05-14** (v1.0.0 tagged + GitHub release) |
9-
| 4 | Factor consolidation (OSAP + JKP + Qlib + IPCA) → **v1.1** | 🟡 IN PROGRESS — 4a-4g + 4c.1/4c.2/4c.3 + PR 4b §1+§2 all merged; **PR #112 (Phase 4h)** ships OSAP signal replication + PBO/DSR gate + Path-b 50/50 blend (schema bump `0.8.0-phase4.5f``0.9.0-phase4h`, no new veto — annotate-only blend, Top-5 still ranks raw composite per Rule 16, 5-commit cluster on `claude/resume-quantrank-phase-4.5-Zh0pO`); 4i/4j/4k pending; PR 4b §3 IC-decay output deferred to Phase 5 |
9+
| 4 | Factor consolidation (OSAP + JKP + Qlib + IPCA) → **v1.1** | 🟡 IN PROGRESS — 4a-4g + 4c.1/4c.2/4c.3 + PR 4b §1+§2 all merged; **PR #112 (Phase 4h)** shipped OSAP signal replication + PBO/DSR gate + Path-b 50/50 blend (schema bump `0.8.0-phase4.5f``0.9.0-phase4h`, no new veto — annotate-only blend, Top-5 still ranks raw composite per Rule 16); **Phase 4h.2 Part 1** (issue #116, this PR) ships observability follow-up — schema PATCH bump `0.9.0-phase4h``0.9.1-phase4h.2`; 2 new optional `Metadata` fields surface (a) silent-drop list — manifest entries missing from dataset (78/100 in first 0.9.0-phase4h run) + (b) per-signal `OsapGateDiagnostic` (PBO/DSR/Sharpe/rejection_reason) for every signal that reached the gate; no new veto, no rank change. Part 2 (threshold calibration + manifest reconciliation) opens after ≥1 week of production diagnostic data accumulates; 4i/4j/4k pending; PR 4b §3 IC-decay output deferred to Phase 5 |
1010
| **4.5** | **Earnings-manipulation defense cluster****v1.2** |**DONE 2026-05-17****tag [`v1.2.0-phase4.5`](https://github.com/dackclup/quantrank/releases/tag/v1.2.0-phase4.5) cut** at commit `6d414a9b`. 6 sub-PRs (#89/#90/#91 + #93 + #95 + #97 + #100). Active vetoes **5 → 7**; defense layer **9 → 17** (= 7 vetoes + 10 annotates). 4.5f adds `manipulation_index` (0-100 rollup) + `composite_score_adjusted` (soft penalty, max 10 pts, informational only) + `ManipulationRiskCard` UI + schema bump **`0.7.1-phase4g``0.8.0-phase4.5f`**. Production verified run #51 (`b1588b2a`, 5m14s warm-cache): card fires on 158/502 (31.5%); HIGH band 2 (SMCI=84 · WAT=64), MODERATE 60, LOW 96. 4.5e Form-4 insider clustering **deferred to v1.3.0** — reserved-slot weights already declared in `FLAG_WEIGHTS`. |
1111
| 5 | ML meta-learner (Triple-Barrier + Meta-Labeling + Conformal) + SHAP | ⚪ not started |
1212
| 6 | Sentiment v2 (FinBERT + Whisper + 8-K Lazy Prices) | ⚪ not started |

SKILL.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ Schema versions:
304304
| `0.7.0-phase4g` | Phase 4g | **8-K Tier-2 event defenses re-enabled** (PR #79, merged 2026-05-15 on `c35c6d40`, closes [issue #14](https://github.com/dackclup/quantrank/issues/14)). Flipped `compute/scoring/tier2._EIGHT_K_DEFENSES_ENABLED = True` after the PR 3d workflow-timeout deferral (root cause cleared by PR #58 cache layers + PR 3d tenacity tightening). `non_reliance_filing` (Item 4.02 hard veto, 365d lookback, Schroeder 2024 SSRN — ~50% of 4.02 filings precede formal restatement) returns to the active layer as the **5th active veto**. `auditor_change` (Item 4.01 annotate, 730d lookback, Reg S-K Item 304, Cohen-Malloy-Nguyen 2020 type) joins the Tier-2 annotate surface. No data-schema-shape delta — only the feature-flag flip + reason-taxonomy expansion. |
305305
| `0.7.1-phase4g` | Phase 4g | **`price_change_1d_pct` additive field** (squash-merged via PR #80, commit `1509f707`). New optional `float \| None` field on `StockSummary` + `StockDetail` — day-over-day percent change from the prior trading-day close. Computed once in `compute/main.py:_fetch_prices_one` from the last two valid yfinance closes; null for newly-IPO'd tickers (only one close available). Lets the ranking-table mobile cards render a change pill without lazy-fetching 502 per-stock history JSONs. Per `phase-4/schema-versioning/PLAN.md`: "Add a new optional field (default = None) → patch". Production metadata.version stays `0.7.0-phase4g` until next weekly compute. |
306306
| `0.7.1-phase4g` (no schema delta) | Phase 4.5a-4.5d wave | **Earnings-manipulation defense cluster — sub-PRs 4.5a + 4.5b + 4.5c + 4.5d shipped 2026-05-16/17** (PRs #89/#90/#91 + #93 + #95 + #97). **No data-schema-shape delta** — all 9 new flag identifiers are strings appended to existing `risk_flags: list[str]` (active vetoes) + `valuation_warnings: list[str]` (annotates) arrays. Active vetoes **5 → 7**: + `beneish_manipulation_veto` (Beneish 1999, M > −1.78) + `dechow_manipulation_veto` (Dechow 2011, F > 3.0). Annotates added: `manipulation_triple_flag` (4.5a joint gate, 2 fired: SMCI · WAT), `restatement_history` (4.5b, 59 fired / 11.8% — Hennes-Leone-Miller 2008 *TAR*), `late_filing_notification` (4.5b, 2 fired: HAS · Q — Bartov-Lai-Yeung 2002 *JAR*), `rem_suspect` (4.5c, 16 fired / 3.2% — Roychowdhury 2006 *JAE* 3-proxy REM via per-sector OLS), `accruals_momentum_high` (4.5d, 50 fired / 10.0% — Sloan 1996 / Beneish 1999 Δ(TATA) > +0.05 over 3y), `loss_avoidance_pattern` (4.5d, 0 fired — Burgstahler-Dichev 1997 cohort thresholds too tight for S&P 500 large-cap universe, file as follow-up). Also closes [issue #7](https://github.com/dackclup/quantrank/issues/7) (Sloan over-firing on Financials: 21.3% → 11.7%, sector spread 7.7× → 1.4×). 2 new cache dirs (`compute/cache/edgar_amendments/` + `compute/cache/edgar_late_filings/`, 7d TTL each). Test suite **646 → 831 offline**. Reason taxonomy: 24 stable + 2 Tier-3 + 2 new vetoes + 6 new annotates = **34 stable identifiers**. |
307+
| **`0.9.1-phase4h.2`** (in flight in PR #<NEXT>) | Phase 4h.2 Part 1 | **Observability follow-up to Phase 4h** (issue #116). PATCH bump per `phase-4/schema-versioning/PLAN.md` ("Add a new optional field (default = None) → patch"). 2 new optional `Metadata` fields land: `osap_signals_missing_from_dataset: list[str] \| None` (surfaces the silent-drop bug — 78/100 manifest signals missing from dataset surface in the first 0.9.0-phase4h production run) + `osap_gate_diagnostics: dict[str, OsapGateDiagnostic] \| None` where the new `OsapGateDiagnostic` Pydantic model carries `pbo`/`dsr`/`sharpe`/`rejection_reason` (all nullable per the "all 4 fields explicit `= None` defaults" lock). Set-diff helper lives at `compute/features/osap_replicate.py::signals_in_dataframe` (mirrors `coverage_by_signal` shape one function above — pure helper, no I/O). Wired into `compute/main.py` inside the existing OSAP try/except so graceful degradation continues to set every osap_* field to `None` on fetch failure. **No new veto, no rank change** — observability-only; Top-5 still ranks raw `composite_score` per Rule 16. Defense layer unchanged at **17**. Part 2 (threshold calibration + manifest reconciliation) deferred until ≥1 week of production diagnostic data accumulates. Test suite **911 → 924 offline** (Part 1 adds 13 across 3 commits: 7 schema round-trip/backward-compat + 4 helper + 2 gate-diagnostic round-trip). Reason taxonomy unchanged at 34 stable identifiers. |
307308
| **`0.9.0-phase4h`** (in flight in PR #112) | Phase 4h | **OSAP signal replication + PBO/DSR hard gate + Path-b composite × OSAP blend** (5-commit cluster on branch `claude/resume-quantrank-phase-4.5-Zh0pO`: 06bdac76 schema-foundation, b79983f6 osap_replicate proxy + 100-signal manifest, a6760d91 osap_blend Path-b, df4d9bd2 osap_validation PBO/DSR gate + rolling-12m-IC, [TBD] compute/main.py wiring + @network e2e). **Minor bump** — 6 new optional fields land simultaneously: `StockDetail.osap_signals: dict[str, float] \| None` + `StockDetail.osap_blended_score: float \| None`; `Metadata.osap_signals_used: list[str] \| None`, `Metadata.osap_excluded_signals: list[str] \| None`, `Metadata.osap_signals_ic_12m: dict[str, float] \| None`, `Metadata.osap_signals_coverage_pct: dict[str, float] \| None`. **OSAP blend stays OUTSIDE `compute_composite()`** — `PHASE3_WEIGHTS` sum-to-1.0 invariant (`compute/scoring/composite.py:43-45`) intact; Path-b formula `blended = (1 - weight) × composite_score + weight × osap_signal_aggregate`, default `weight=0.5` locked at `osap-integration/PLAN.md:168-170`. **Hard gate** = PBO ≤ 0.5 AND DSR > 0 via PR #60's `factor_passes_gates`; rolling-12m Spearman IC is observability-only (full walk-forward CV deferred to Phase 5 per `defense-infrastructure/PLAN.md:270`). **No new veto** (Top-5 still ranks raw `composite_score` per Rule 16; `osap_blended_score` is informational); defense layer stays at **17**. **Universe-gap policy** — tickers with no OSAP coverage pass `composite_score` through unchanged (no impute, distinct from pillar `neutralize_missing=True`). **NaN policy in PBO cohort** — zero-fill (not mean-fill, not dropna) preserves Bailey 2014 `n_trials = cohort_size` multiple-testing correction; sparse signals naturally lose on DSR (low Sharpe → DSR rejection). **OSAP failure is observability-only** — wrapped in try/except in `compute/main.py` so live-fetch / package failure NEVER blocks weekly production; all 6 new fields degrade to `None`. Test suite **856 → 906 offline + 18 → 19 `@network`** (commits 2-5 added 50 tests; e2e network test added in commit 5). Reason taxonomy unchanged at 34 stable identifiers. Tag `v1.1.0-phase4` (or `v1.3.0` for the 4.5e+4h combined release) deferred until 4i/4j/4k also merge. |
308309
| **`0.8.0-phase4.5f`** | Phase 4.5f | **Manipulation Composite + soft composite penalty + UI** (PR #100 merged 2026-05-17 on commit `b1588b2a`; production verified on commit `e57f09cb`, run #51, warm-cache 5m14s). **Minor bump** because 5 new optional fields land simultaneously + new UI surface ships + tag `v1.2.0-phase4.5` coordinates with the data-version bump (semver coupling). Additive optional fields: `StockSummary.manipulation_index: float \| None`, `StockSummary.composite_score_adjusted: float \| None`, `StockDetail.manipulation_index`, `StockDetail.composite_score_adjusted`, `StockDetail.manipulation_components: dict[str, bool] \| None`. **`manipulation_index`** is a 0-100 rollup over the 4.5a-d flag set via a per-flag additive weight table in `compute/scoring/manipulation_index.py::FLAG_WEIGHTS` (active vetoes 15-20 pts · joint-gate 10 · annotates 5-8 · Tier-3 soft 3); clipped to `[0, 100]`. **`composite_score_adjusted`** applies the soft penalty `composite − 0.5 × (index / 100) × 20` (max 10-pt deduction at index = 100); the original `composite_score` field is preserved untouched per Rule 9 audit trail. **Rank source stays the raw composite per Rule 16** — the adjusted value is informational only, surfaced on the new detail-page `ManipulationRiskCard` (3-band outlined-light: emerald LOW / amber MODERATE / rose HIGH) with the in-line qualifier "Composite penalty: −X.XX pts (informational; rank uses raw composite)". Production: 158/502 (31.5%) fire the card (HIGH 2: SMCI=84 · WAT=64; MODERATE 60; LOW 96). **Phase 4.5e reserved-slot weights declared** (`INSIDER_SELL_CLUSTER_WEIGHT_RESERVED = 10`, `C_SUITE_UNUSUAL_SELL_WEIGHT_RESERVED = 5`) — the 4.5e PR uncomments 2 entries in `FLAG_WEIGHTS`, no calibration cascade. Test suite **831 → 856 offline**. Reason taxonomy: 34 stable identifiers (unchanged — `manipulation_index` is a derivation, not a new flag). Tag **`v1.2.0-phase4.5`** ready to cut. |
309310

compute/main.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
from compute.output.schemas import (
6565
DataQuality,
6666
Metadata,
67+
OsapGateDiagnostic,
6768
PillarScores,
6869
RawMetrics,
6970
StockDetail,
@@ -962,6 +963,10 @@ def run_weekly_compute() -> int:
962963
# empty when the OSAP pipeline fails entirely (graceful-degradation
963964
# path leaves every osap_* metadata field None).
964965
osap_signals_missing_from_dataset: list[str] = []
966+
# Phase 4h.2 Part 1 (issue #116) — per-signal PBO/DSR/Sharpe/
967+
# rejection_reason diagnostics for every signal that reaches the
968+
# gate. Populated inside the try block from ``gate_results``.
969+
osap_gate_diagnostics: dict[str, OsapGateDiagnostic] = {}
965970
try:
966971
logger.info(
967972
"Phase 4h — fetching OSAP returns for %d-signal manifest "
@@ -1001,6 +1006,22 @@ def run_weekly_compute() -> int:
10011006
osap_ls,
10021007
requested_signals=config.OSAP_SIGNALS_100,
10031008
)
1009+
# Phase 4h.2 Part 1 — persist per-signal gate decisions into
1010+
# metadata (issue #116). Captures EVERY signal that reached the
1011+
# gate (both accepted and rejected); accepted signals carry
1012+
# ``rejection_reason=None`` while rejected carry one of the
1013+
# canonical taxonomy values (``high_pbo`` / ``low_dsr`` /
1014+
# ``insufficient_data`` / ``gate_failed``) per
1015+
# ``compute/validation/osap_validation.py::GateResult``.
1016+
osap_gate_diagnostics = {
1017+
sig: OsapGateDiagnostic(
1018+
pbo=result.pbo,
1019+
dsr=result.dsr,
1020+
sharpe=result.sharpe,
1021+
rejection_reason=result.rejection_reason,
1022+
)
1023+
for sig, result in gate_results.items()
1024+
}
10041025
osap_signals_used, osap_excluded_signals = filter_accepted_signals(
10051026
gate_results
10061027
)
@@ -1065,6 +1086,7 @@ def run_weekly_compute() -> int:
10651086
osap_signals_coverage_pct = {}
10661087
composite_osap_adjusted = pd.Series(dtype=float)
10671088
osap_signals_missing_from_dataset = []
1089+
osap_gate_diagnostics = {}
10681090

10691091
# Step 8 — combined per-ticker loop: fair-price ensemble + price history
10701092
# write + StockSummary + StockDetail. Single pass so per-ticker outputs
@@ -1419,6 +1441,7 @@ def run_weekly_compute() -> int:
14191441
osap_signals_missing_from_dataset=(
14201442
osap_signals_missing_from_dataset or None
14211443
),
1444+
osap_gate_diagnostics=osap_gate_diagnostics or None,
14221445
)
14231446

14241447
config.DATA_DIR.mkdir(parents=True, exist_ok=True)

tests/test_output/test_schema_phase4h2.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,61 @@ def test_metadata_new_fields_default_to_none():
126126
assert meta.osap_gate_diagnostics is None
127127

128128

129+
def test_metadata_gate_diagnostics_round_trip_with_production_cohort_shape():
130+
"""Simulate the production cohort shape: 22 signals reach the gate,
131+
all rejected with a mix of rejection_reason values across the
132+
canonical taxonomy (matches issue #116 production observation:
133+
22 excluded, 0 accepted, 78 silently dropped). Verify Metadata
134+
round-trip preserves the ``dict[str, OsapGateDiagnostic]``
135+
structure end-to-end."""
136+
rejection_cycle = ["gate_failed", "high_pbo", "low_dsr", "insufficient_data"]
137+
diagnostics = {
138+
f"Signal_{i:02d}": OsapGateDiagnostic(
139+
pbo=0.6 + i * 0.005,
140+
dsr=-0.4 + i * 0.01,
141+
sharpe=0.05 + i * 0.001,
142+
rejection_reason=rejection_cycle[i % 4],
143+
)
144+
for i in range(22)
145+
}
146+
payload = _legacy_0_9_0_metadata_payload()
147+
payload["osap_gate_diagnostics"] = {
148+
sig: diag.model_dump() for sig, diag in diagnostics.items()
149+
}
150+
151+
meta = Metadata.model_validate(payload)
152+
assert meta.osap_gate_diagnostics is not None
153+
assert len(meta.osap_gate_diagnostics) == 22
154+
155+
# Spot-check one entry from each rejection_reason bucket.
156+
reasons_seen = {
157+
diag.rejection_reason for diag in meta.osap_gate_diagnostics.values()
158+
}
159+
assert reasons_seen == set(rejection_cycle)
160+
161+
# Round-trip preserves the dict-of-OsapGateDiagnostic structure.
162+
serialized = meta.model_dump()
163+
restored = Metadata.model_validate(serialized)
164+
assert restored.osap_gate_diagnostics is not None
165+
assert len(restored.osap_gate_diagnostics) == 22
166+
assert restored.osap_gate_diagnostics["Signal_05"].pbo == diagnostics[
167+
"Signal_05"
168+
].pbo
169+
170+
171+
def test_metadata_gate_diagnostics_accepted_signal_has_null_rejection_reason():
172+
"""Accepted signals carry ``rejection_reason=None`` (per
173+
``GateResult`` contract). Tests that the Pydantic model preserves
174+
``None`` rather than coercing to a sentinel string."""
175+
diag = OsapGateDiagnostic(pbo=0.3, dsr=0.5, sharpe=0.4, rejection_reason=None)
176+
payload = _legacy_0_9_0_metadata_payload()
177+
payload["osap_gate_diagnostics"] = {"BM": diag.model_dump()}
178+
meta = Metadata.model_validate(payload)
179+
assert meta.osap_gate_diagnostics is not None
180+
assert meta.osap_gate_diagnostics["BM"].rejection_reason is None
181+
assert meta.osap_gate_diagnostics["BM"].pbo == 0.3
182+
183+
129184
def test_metadata_extra_forbid_rejects_unknown_fields():
130185
"""``model_config = ConfigDict(extra='forbid')`` catches typo'd
131186
field names. Locks the schema surface so future refactors that

0 commit comments

Comments
 (0)