From 47eab6edf6aafd3522c7aba3561705d4ef2a91da Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Tue, 8 Sep 2026 02:36:27 +0800 Subject: [PATCH] fix(lifecycle): park drift scores without baseline; optional GCP auth Treat baseline_available=False as unavailable for NEW_RISK inject so no-baseline 0.0 HEALTHY cannot pass as clean. Gate reusable drift-check cloud writes behind optional WIF inputs; without them keep local-only. Co-Authored-By: Claude Co-authored-by: Cursor --- .github/workflows/reusable-drift-check.yml | 40 +++++++++++++++++++ .../risk/production_drift_new_risk.py | 8 +++- .../production_drift_evaluator.py | 8 +++- tests/test_production_drift_new_risk.py | 12 ++++++ tests/test_production_drift_score_resolve.py | 20 ++++++++++ 5 files changed, 86 insertions(+), 2 deletions(-) diff --git a/.github/workflows/reusable-drift-check.yml b/.github/workflows/reusable-drift-check.yml index be16ed9..237a802 100644 --- a/.github/workflows/reusable-drift-check.yml +++ b/.github/workflows/reusable-drift-check.yml @@ -47,6 +47,21 @@ on: required: false type: string default: "" + gcp_workload_identity_provider: + description: "Optional WIF provider for writing LIFECYCLE_PERFORMANCE_BUCKET." + required: false + type: string + default: "" + gcp_service_account: + description: "Optional GCP service account email for lifecycle bucket writes." + required: false + type: string + default: "" + gcp_project_id: + description: "Optional GCP project id for gcloud setup." + required: false + type: string + default: "" secrets: codex_audit_service_url: required: false @@ -160,6 +175,31 @@ jobs: python -m pip install -e . pandas python -m pip install --no-deps -e external/QuantPlatformKit + - name: Gate cloud lifecycle writes without GCP auth + env: + WIF_PROVIDER: ${{ inputs.gcp_workload_identity_provider }} + WIF_SA: ${{ inputs.gcp_service_account }} + shell: bash + run: | + set -euo pipefail + if [ -n "${LIFECYCLE_PERFORMANCE_BUCKET:-}" ] && { [ -z "${WIF_PROVIDER}" ] || [ -z "${WIF_SA}" ]; }; then + echo "::warning::LIFECYCLE_PERFORMANCE_BUCKET is set without GCP WIF inputs; keeping local-only lifecycle store for this run" + echo "LIFECYCLE_PERFORMANCE_BUCKET=" >> "$GITHUB_ENV" + fi + + - name: Authenticate to Google Cloud + if: ${{ inputs.gcp_workload_identity_provider != '' && inputs.gcp_service_account != '' }} + uses: google-github-actions/auth@v3 + with: + workload_identity_provider: ${{ inputs.gcp_workload_identity_provider }} + service_account: ${{ inputs.gcp_service_account }} + + - name: Set up gcloud + if: ${{ inputs.gcp_workload_identity_provider != '' && inputs.gcp_service_account != '' && inputs.gcp_project_id != '' }} + uses: google-github-actions/setup-gcloud@v3 + with: + project_id: ${{ inputs.gcp_project_id }} + - name: Download lifecycle preflight artifact if: inputs.lifecycle_preflight_artifact != '' uses: actions/download-artifact@v7 diff --git a/src/quant_platform_kit/risk/production_drift_new_risk.py b/src/quant_platform_kit/risk/production_drift_new_risk.py index 1ef341d..01a1973 100644 --- a/src/quant_platform_kit/risk/production_drift_new_risk.py +++ b/src/quant_platform_kit/risk/production_drift_new_risk.py @@ -57,9 +57,15 @@ def production_drift_new_risk_reasons(status: object) -> tuple[str, ...]: def production_drift_status_from_result(drift: Any) -> str | None: - """Extract status string from a ``DriftResult``-like object (inject helper).""" + """Extract status string from a ``DriftResult``-like object (inject helper). + + Missing baseline (``baseline_available=False``) omits status so NEW_RISK + does not treat no-baseline 0.0 scores as healthy. + """ if drift is None: return None + if getattr(drift, "baseline_available", True) is False: + return None status = getattr(drift, "status", drift) return normalize_production_drift_status(status) diff --git a/src/quant_platform_kit/strategy_lifecycle/production_drift_evaluator.py b/src/quant_platform_kit/strategy_lifecycle/production_drift_evaluator.py index 8b96ebc..05906c5 100644 --- a/src/quant_platform_kit/strategy_lifecycle/production_drift_evaluator.py +++ b/src/quant_platform_kit/strategy_lifecycle/production_drift_evaluator.py @@ -48,9 +48,15 @@ def resolve_injected_drift_score( drift: DriftResult | None = None, snapshot: StrategyPerformanceSnapshot | None = None, ) -> float | None: - """Prefer latest DriftResult score, else snapshot.drift_score; never invent 0.0.""" + """Prefer latest DriftResult score, else snapshot.drift_score; never invent 0.0. + + DriftResult with ``baseline_available=False`` is treated as unavailable even + when ``drift_score`` is 0.0 (no-baseline detect_drift must not look healthy). + """ if drift is not None: + if getattr(drift, "baseline_available", True) is False: + return None try: return sanitize_unit_drift_score(drift.drift_score) except ValueError: diff --git a/tests/test_production_drift_new_risk.py b/tests/test_production_drift_new_risk.py index 1d8037d..1db7ff9 100644 --- a/tests/test_production_drift_new_risk.py +++ b/tests/test_production_drift_new_risk.py @@ -56,6 +56,18 @@ def test_status_from_drift_result() -> None: assert production_drift_status_from_result(None) is None +def test_status_from_result_omits_when_baseline_unavailable() -> None: + drift = DriftResult( + strategy_profile="demo", + domain="us_equity", + as_of=date(2026, 9, 7), + drift_score=0.0, + status=DriftStatus.HEALTHY, + baseline_available=False, + ) + assert production_drift_status_from_result(drift) is None + + def test_probe_summary_parked_omits_status() -> None: assert production_drift_status_from_probe_summary(None) is None diff --git a/tests/test_production_drift_score_resolve.py b/tests/test_production_drift_score_resolve.py index 92ef984..86fa920 100644 --- a/tests/test_production_drift_score_resolve.py +++ b/tests/test_production_drift_score_resolve.py @@ -76,3 +76,23 @@ def test_resolve_returns_none_when_missing_or_invalid() -> None: drift_score=1.5, ) assert resolve_injected_drift_score(snapshot=bad) is None + + +def test_resolve_omits_score_when_baseline_unavailable() -> None: + drift = DriftResult( + strategy_profile="demo", + domain="us_equity", + as_of=date(2026, 9, 7), + drift_score=0.0, + status=DriftStatus.HEALTHY, + baseline_available=False, + ) + snapshot = StrategyPerformanceSnapshot( + strategy_profile="demo", + domain="us_equity", + platform="test", + as_of=date(2026, 9, 6), + drift_score=0.2, + ) + # Must not fall through to snapshot when drift exists but baseline is missing. + assert resolve_injected_drift_score(drift=drift, snapshot=snapshot) is None