fix: pipeline health — stuck docs, price fallback, sentiment normalization, signal-engine scale, quality gate
- Scheduler: lower stale threshold 240→30 min, batch limit 100→500, TTL 14400→3600 - Prediction snapshot: add 24h market_snapshots time-window fallback - Aggregation: add normalize_impact_scores() z-score normalization - Helm: signal-engine replicas → 0 (idle when dual pipeline disabled) - Quality gate: max_snapshot_age_hours 24→48 - Add backfill script for NULL price_at_prediction snapshots - Add PBT bug condition and preservation tests (14 tests)
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
"""Property-based tests for pipeline health bug condition exploration.
|
||||
|
||||
Feature: pipeline-health-fixes
|
||||
|
||||
These tests encode the EXPECTED (fixed) behavior for each bug. They are
|
||||
designed to FAIL on unfixed code, thereby confirming the bugs exist.
|
||||
DO NOT fix these tests or the code when they fail — failure is the goal.
|
||||
|
||||
Bug conditions tested:
|
||||
1. Batch Overflow — recovery capped at 100 docs (should be 500)
|
||||
2. Price Fallback Gap — NULL price when market_snapshots has 24h data
|
||||
3. Sentiment Bias — raw impact_score bias produces skewed sentiment
|
||||
5. Quality Gate Staleness — 26h snapshot rejected (should accept up to 48h)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from hypothesis import given, settings
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from services.trading.model_quality_gate import (
|
||||
QualityGateConfig,
|
||||
evaluate_quality_gate,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 1: Batch Overflow — recovery should handle up to 500 docs per cycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBug1BatchOverflow:
|
||||
"""Bug 1: Stuck Parsed Docs — Batch Overflow
|
||||
|
||||
When 600 documents are stuck in 'parsed' status older than 30 minutes,
|
||||
recover_stale_documents() should process up to 500 per cycle.
|
||||
|
||||
The unfixed code has LIMIT 100 and STALE_PARSED_THRESHOLD_MINUTES=240,
|
||||
so it will only recover at most 100 docs (and won't recover docs only
|
||||
30 min old since threshold is 240 min).
|
||||
|
||||
**Validates: Requirements 2.1**
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recovery_processes_more_than_100_documents(self):
|
||||
"""Create 600 stale docs, assert recovery processes > 100.
|
||||
|
||||
The unfixed code caps at LIMIT 100 in the SQL, so pool.fetch will
|
||||
return at most 100 rows. We verify the function's SQL uses a limit
|
||||
that allows processing more than 100 documents.
|
||||
"""
|
||||
from services.scheduler.app import (
|
||||
STALE_PARSED_THRESHOLD_MINUTES,
|
||||
recover_stale_documents,
|
||||
)
|
||||
|
||||
# Generate 600 fake document rows (all older than threshold)
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
stale_time = now - timedelta(minutes=STALE_PARSED_THRESHOLD_MINUTES + 10)
|
||||
|
||||
fake_rows = []
|
||||
for i in range(600):
|
||||
row = {
|
||||
"id": uuid.uuid4(),
|
||||
"document_type": "news",
|
||||
"ticker": "AAPL",
|
||||
"updated_at": stale_time,
|
||||
}
|
||||
fake_rows.append(row)
|
||||
|
||||
# Mock pool and redis
|
||||
pool = AsyncMock()
|
||||
# The SQL query in the function has LIMIT 100, so even if we want 600,
|
||||
# pool.fetch will be called with the SQL that has LIMIT 100.
|
||||
# We simulate: the DB returns up to what the LIMIT allows.
|
||||
# On unfixed code: LIMIT 100 → max 100 rows returned.
|
||||
# On fixed code: LIMIT 500 → up to 500 rows returned.
|
||||
# We'll return all 600 (simulating DB has 600 matching rows) and let
|
||||
# the SQL LIMIT be the constraint. Since we're mocking, we return
|
||||
# based on what the actual limit would be.
|
||||
# Actually, since pool.fetch is mocked, we need to check the SQL.
|
||||
# The simplest approach: return 500 rows and check how many get processed.
|
||||
# If the code has LIMIT 100, it will only process what pool.fetch returns.
|
||||
# But since pool.fetch is mocked, we return 500 to test the upper bound.
|
||||
pool.fetch = AsyncMock(return_value=fake_rows[:500])
|
||||
pool.execute = AsyncMock()
|
||||
|
||||
rds = AsyncMock()
|
||||
# Every _enqueue_if_new call succeeds (marker key doesn't exist)
|
||||
rds.set = AsyncMock(return_value=True)
|
||||
rds.rpush = AsyncMock()
|
||||
|
||||
result = await recover_stale_documents(pool, rds)
|
||||
|
||||
# The fixed code should process up to 500 documents.
|
||||
# The unfixed code has LIMIT 100, so pool.fetch returns at most 100.
|
||||
# But since we mock pool.fetch to return 500, the real constraint is
|
||||
# the STALE_PARSED_THRESHOLD_MINUTES. On unfixed code (240 min threshold),
|
||||
# docs that are only 30 min old won't be recovered.
|
||||
# Let's verify via the threshold instead:
|
||||
# The key assertion: the threshold should be 30 min (not 240)
|
||||
# so that 30-min-old documents ARE recovered.
|
||||
assert STALE_PARSED_THRESHOLD_MINUTES <= 30, (
|
||||
f"Bug 1 confirmed: STALE_PARSED_THRESHOLD_MINUTES is "
|
||||
f"{STALE_PARSED_THRESHOLD_MINUTES} (should be <= 30)"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sql_batch_limit_is_500(self):
|
||||
"""Verify the SQL query uses LIMIT 500 (not LIMIT 100).
|
||||
|
||||
Inspects the actual SQL passed to pool.fetch to confirm the batch
|
||||
limit has been increased.
|
||||
"""
|
||||
from services.scheduler.app import recover_stale_documents
|
||||
|
||||
pool = AsyncMock()
|
||||
pool.fetch = AsyncMock(return_value=[])
|
||||
pool.execute = AsyncMock()
|
||||
|
||||
rds = AsyncMock()
|
||||
|
||||
await recover_stale_documents(pool, rds)
|
||||
|
||||
# Check the SQL query that was passed to pool.fetch
|
||||
call_args = pool.fetch.call_args
|
||||
sql_query = call_args[0][0]
|
||||
|
||||
assert "LIMIT 500" in sql_query, (
|
||||
f"Bug 1 confirmed: SQL uses '{sql_query.split('LIMIT')[1].strip()[:10]}...' "
|
||||
f"(expected LIMIT 500)"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 2: Price Fallback Gap — missing 24h market_snapshots fallback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBug2PriceFallbackGap:
|
||||
"""Bug 2: Extended Price Fallback
|
||||
|
||||
When creating a prediction snapshot for a ticker with:
|
||||
- No exact market_snapshots match (fetch_latest_close_price → None)
|
||||
- No position in positions table
|
||||
- But market_snapshots has data within 24h
|
||||
|
||||
The price_at_prediction should NOT be NULL. The unfixed code only falls
|
||||
back to positions and doesn't have the 24h time-window query.
|
||||
|
||||
**Validates: Requirements 2.2, 2.3**
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_price_not_null_when_24h_data_exists(self):
|
||||
"""Assert that the code has a 24h time-window fallback for price lookup.
|
||||
|
||||
The unfixed code in create_prediction_snapshot() only falls back to
|
||||
the positions table after fetch_latest_close_price returns None.
|
||||
There is NO query for market_snapshots within a 24h time window.
|
||||
|
||||
We verify the fallback chain by inspecting what happens when:
|
||||
1. fetch_latest_close_price → None (no exact match)
|
||||
2. positions table → None (no open position)
|
||||
3. 24h market_snapshots query → returns a price (FIXED code only)
|
||||
"""
|
||||
from services.validation.prediction_snapshot import (
|
||||
create_prediction_snapshot,
|
||||
)
|
||||
|
||||
# Mock pool with full transaction context manager support
|
||||
pool = AsyncMock()
|
||||
|
||||
# Simulate the fallback chain:
|
||||
# - 1st fetchrow: positions table → None
|
||||
# - 2nd fetchrow: 24h market_snapshots → returns price 42.50
|
||||
# - 3rd fetchrow: SPY via fetch_latest_close_price (patched separately)
|
||||
# - 4th fetchrow: sector lookup → None
|
||||
pool.fetchrow = AsyncMock(
|
||||
side_effect=[
|
||||
None, # positions table lookup
|
||||
{"close": 42.50}, # 24h market_snapshots extended fallback
|
||||
None, # sector ETF lookup (_fetch_sector_etf_ticker)
|
||||
]
|
||||
)
|
||||
pool.execute = AsyncMock()
|
||||
|
||||
# Setup transaction context manager mock
|
||||
conn_mock = AsyncMock()
|
||||
conn_mock.execute = AsyncMock()
|
||||
tx_mock = AsyncMock()
|
||||
tx_mock.__aenter__ = AsyncMock(return_value=None)
|
||||
tx_mock.__aexit__ = AsyncMock(return_value=None)
|
||||
conn_mock.transaction = MagicMock(return_value=tx_mock)
|
||||
acquire_mock = AsyncMock()
|
||||
acquire_mock.__aenter__ = AsyncMock(return_value=conn_mock)
|
||||
acquire_mock.__aexit__ = AsyncMock(return_value=None)
|
||||
pool.acquire = MagicMock(return_value=acquire_mock)
|
||||
|
||||
# Build minimal mocks for Recommendation and TrendSummary
|
||||
recommendation = MagicMock()
|
||||
recommendation.ticker = "PLTR"
|
||||
recommendation.generated_at = datetime.now(tz=timezone.utc)
|
||||
recommendation.time_horizon = "7d"
|
||||
recommendation.action.value = "buy"
|
||||
recommendation.mode.value = "paper_eligible"
|
||||
recommendation.confidence = 0.6
|
||||
|
||||
trend_summary = MagicMock()
|
||||
trend_summary.market_context = None
|
||||
trend_summary.window.value = "7d"
|
||||
trend_summary.trend_direction.value = "bullish"
|
||||
trend_summary.trend_strength = 0.7
|
||||
trend_summary.contradiction_score = 0.1
|
||||
trend_summary.p_bull = 0.7
|
||||
|
||||
# Patch fetch_latest_close_price to return None (no exact match)
|
||||
with patch(
|
||||
"services.validation.prediction_snapshot.fetch_latest_close_price",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
):
|
||||
snapshot = await create_prediction_snapshot(
|
||||
pool=pool,
|
||||
recommendation=recommendation,
|
||||
trend_summary=trend_summary,
|
||||
evidence_signals=[],
|
||||
evidence_docs=[],
|
||||
)
|
||||
|
||||
# On FIXED code: price_at_prediction should NOT be None because
|
||||
# the 24h market_snapshots fallback finds data (42.50).
|
||||
# On UNFIXED code: price_at_prediction IS None (bug confirmed).
|
||||
assert snapshot.price_at_prediction is not None, (
|
||||
"Bug 2 confirmed: price_at_prediction is NULL when no position exists. "
|
||||
"The code lacks a 24h time-window market_snapshots fallback query."
|
||||
)
|
||||
|
||||
def test_fallback_chain_has_24h_market_query(self):
|
||||
"""Verify create_prediction_snapshot source code contains a 24h fallback.
|
||||
|
||||
The unfixed code only has two price sources:
|
||||
1. fetch_latest_close_price() — exact timestamp match
|
||||
2. positions table — current_price for held tickers
|
||||
|
||||
The FIXED code should add a third:
|
||||
3. market_snapshots within 24 hours — time-window query
|
||||
|
||||
We inspect the source to verify the 24h fallback exists.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from services.validation.prediction_snapshot import (
|
||||
create_prediction_snapshot,
|
||||
)
|
||||
|
||||
source = inspect.getsource(create_prediction_snapshot)
|
||||
|
||||
# Look for evidence of a 24-hour time-window fallback query
|
||||
has_24h_fallback = (
|
||||
"24 hours" in source
|
||||
or "24h" in source
|
||||
or "INTERVAL" in source and "24" in source
|
||||
or "timedelta(hours=24)" in source
|
||||
)
|
||||
|
||||
assert has_24h_fallback, (
|
||||
"Bug 2 confirmed: create_prediction_snapshot() does not contain a "
|
||||
"24h time-window fallback query for market_snapshots. "
|
||||
"When fetch_latest_close_price returns None and positions table "
|
||||
"has no data, price_at_prediction will be NULL."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 3: Sentiment Bias — raw impact_score bias not normalized
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBug3SentimentBias:
|
||||
"""Bug 3: Sentiment Z-Score Normalization
|
||||
|
||||
When impact_score values have systematic negative bias (e.g., NuExtract3
|
||||
outputs clustered around -0.3), the weighted_sentiment_average() produces
|
||||
biased negative signals. The fix adds normalize_impact_scores() which
|
||||
zero-centers scores BEFORE they reach weighted_sentiment_average().
|
||||
|
||||
weighted_sentiment_average() is a pure function — normalization happens
|
||||
upstream in build_weighted_signals() via normalize_impact_scores().
|
||||
|
||||
**Validates: Requirements 2.4**
|
||||
"""
|
||||
|
||||
@given(
|
||||
# Generate 50 impact scores with systematic negative bias
|
||||
bias_mean=st.floats(min_value=-0.5, max_value=-0.1, allow_nan=False),
|
||||
bias_stddev=st.floats(min_value=0.05, max_value=0.2, allow_nan=False),
|
||||
)
|
||||
@settings(max_examples=100)
|
||||
def test_normalize_impact_scores_zero_centers_biased_input(
|
||||
self,
|
||||
bias_mean: float,
|
||||
bias_stddev: float,
|
||||
):
|
||||
"""Verify normalize_impact_scores() produces zero-centered output.
|
||||
|
||||
The fix adds normalize_impact_scores() which applies z-score
|
||||
normalization: (raw - mean_7d) / max(stddev_7d, 0.1).
|
||||
When given biased raw scores and sufficient 7-day history with
|
||||
matching stats, the output should be approximately zero-centered.
|
||||
|
||||
This tests the normalization function directly (the fix for Bug 3),
|
||||
since weighted_sentiment_average() is a pure function that receives
|
||||
already-normalized scores from the upstream pipeline.
|
||||
"""
|
||||
import random
|
||||
|
||||
from services.aggregation.scoring import normalize_impact_scores
|
||||
|
||||
random.seed(42)
|
||||
|
||||
# Generate 50 biased raw impact scores
|
||||
raw_scores = [
|
||||
max(-1.0, min(1.0, random.gauss(bias_mean, bias_stddev)))
|
||||
for _ in range(50)
|
||||
]
|
||||
|
||||
# Compute actual mean and stddev of the raw scores (simulating 7-day stats)
|
||||
actual_mean = sum(raw_scores) / len(raw_scores)
|
||||
actual_stddev = (
|
||||
sum((x - actual_mean) ** 2 for x in raw_scores) / len(raw_scores)
|
||||
) ** 0.5
|
||||
|
||||
# Mock pool to return the biased distribution's statistics
|
||||
# (simulating that the 7-day history reflects the same bias)
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetchrow = AsyncMock(
|
||||
return_value={
|
||||
"mean": actual_mean,
|
||||
"stddev": actual_stddev,
|
||||
"cnt": 50,
|
||||
}
|
||||
)
|
||||
|
||||
# Run normalization using asyncio.run() for Hypothesis compatibility
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
normalized = loop.run_until_complete(
|
||||
normalize_impact_scores(mock_pool, "AAPL", raw_scores)
|
||||
)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# After z-score normalization, mean should be ≈ 0
|
||||
normalized_mean = sum(normalized) / len(normalized)
|
||||
assert abs(normalized_mean) < 0.15, (
|
||||
f"Bug 3 confirmed: normalize_impact_scores does not zero-center "
|
||||
f"biased input. Output mean = {normalized_mean:.4f} "
|
||||
f"(expected ≈ 0, input bias_mean={bias_mean:.3f})"
|
||||
)
|
||||
|
||||
def test_normalize_impact_scores_exists_and_is_integrated(self):
|
||||
"""Verify normalize_impact_scores is available and integrated in the worker.
|
||||
|
||||
The unfixed code does not have this function. Its existence and use
|
||||
in the aggregation worker confirms the fix has been applied.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
from services.aggregation import worker as worker_module
|
||||
from services.aggregation.scoring import normalize_impact_scores
|
||||
|
||||
# Verify normalize_impact_scores exists as an async function
|
||||
assert inspect.iscoroutinefunction(normalize_impact_scores), (
|
||||
"Bug 3 confirmed: normalize_impact_scores is not an async function"
|
||||
)
|
||||
|
||||
# Verify it's imported and used in the worker module
|
||||
worker_source = inspect.getsource(worker_module)
|
||||
assert "normalize_impact_scores" in worker_source, (
|
||||
"Bug 3 confirmed: aggregation worker does not call "
|
||||
"normalize_impact_scores — raw impact_scores flow through unmodified"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_normalization_with_alternating_sentiment_produces_centered_output(
|
||||
self,
|
||||
):
|
||||
"""After normalization, the mean of normalized scores is ≈ 0.
|
||||
|
||||
This verifies the full normalization path: given biased raw scores
|
||||
and matching 7-day statistics, normalize_impact_scores produces
|
||||
zero-centered output suitable for unbiased downstream weighting.
|
||||
"""
|
||||
import random
|
||||
|
||||
from services.aggregation.scoring import normalize_impact_scores
|
||||
|
||||
random.seed(42)
|
||||
bias_mean = -0.3
|
||||
bias_stddev = 0.1
|
||||
|
||||
# Generate 50 biased raw scores
|
||||
raw_scores = [
|
||||
max(-1.0, min(1.0, random.gauss(bias_mean, bias_stddev)))
|
||||
for _ in range(50)
|
||||
]
|
||||
|
||||
actual_mean = sum(raw_scores) / len(raw_scores)
|
||||
actual_stddev = (
|
||||
sum((x - actual_mean) ** 2 for x in raw_scores) / len(raw_scores)
|
||||
) ** 0.5
|
||||
|
||||
# Mock pool with 7-day stats matching the biased distribution
|
||||
mock_pool = AsyncMock()
|
||||
mock_pool.fetchrow = AsyncMock(
|
||||
return_value={
|
||||
"mean": actual_mean,
|
||||
"stddev": actual_stddev,
|
||||
"cnt": 50,
|
||||
}
|
||||
)
|
||||
|
||||
# Normalize scores
|
||||
normalized = await normalize_impact_scores(mock_pool, "AAPL", raw_scores)
|
||||
|
||||
# After z-score normalization, the mean of normalized scores should be ≈ 0
|
||||
normalized_mean = sum(normalized) / len(normalized)
|
||||
assert abs(normalized_mean) < 0.01, (
|
||||
f"Bug 3 confirmed: normalize_impact_scores output is not zero-centered. "
|
||||
f"Mean = {normalized_mean:.6f} (expected ≈ 0)"
|
||||
)
|
||||
|
||||
# And the original biased mean should have been removed
|
||||
raw_mean = sum(raw_scores) / len(raw_scores)
|
||||
assert abs(raw_mean) > 0.1, (
|
||||
"Test setup issue: raw scores should have significant bias"
|
||||
)
|
||||
assert abs(normalized_mean) < abs(raw_mean), (
|
||||
f"Bug 3 confirmed: normalization did not reduce bias. "
|
||||
f"Raw mean={raw_mean:.4f}, normalized mean={normalized_mean:.4f}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bug 5: Quality Gate Staleness — 24h threshold too strict
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBug5QualityGateStaleness:
|
||||
"""Bug 5: Quality Gate Threshold
|
||||
|
||||
When the most recent metric snapshot is 26 hours old (between 24h and 48h),
|
||||
the quality gate should PASS (accept it). The unfixed code has
|
||||
max_snapshot_age_hours=24 which rejects it as stale.
|
||||
|
||||
**Validates: Requirements 2.6**
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_26h_old_snapshot_passes_quality_gate(self):
|
||||
"""A 26h-old snapshot should pass the quality gate.
|
||||
|
||||
The unfixed code has max_snapshot_age_hours=24, so a 26h-old snapshot
|
||||
is rejected as stale and forces paper-only mode.
|
||||
"""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
snapshot_time = now - timedelta(hours=26)
|
||||
|
||||
# Build a valid snapshot row that meets all metric thresholds
|
||||
fake_snapshot_row = {
|
||||
"id": uuid.uuid4(),
|
||||
"generated_at": snapshot_time,
|
||||
"prediction_count": 200,
|
||||
"win_rate": 0.60,
|
||||
"directional_accuracy": 0.58,
|
||||
"information_coefficient": 0.05,
|
||||
"rank_information_coefficient": 0.04,
|
||||
"avg_return": 0.02,
|
||||
"avg_excess_return_vs_spy": 0.01,
|
||||
"avg_excess_return_vs_sector": 0.005,
|
||||
"calibration_error": 0.10,
|
||||
"brier_score": 0.20,
|
||||
"buy_win_rate": 0.62,
|
||||
"sell_win_rate": 0.58,
|
||||
"hold_win_rate": 0.55,
|
||||
}
|
||||
|
||||
pool = AsyncMock()
|
||||
pool.fetchrow = AsyncMock(return_value=fake_snapshot_row)
|
||||
pool.execute = AsyncMock()
|
||||
pool.fetchval = AsyncMock(return_value=None)
|
||||
|
||||
# Use default config (which has max_snapshot_age_hours=24 on unfixed code)
|
||||
config = QualityGateConfig()
|
||||
|
||||
# Patch _store_gate_result to avoid DB writes
|
||||
with patch(
|
||||
"services.trading.model_quality_gate._store_gate_result",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
# Also patch load_gate_config_from_db since we provide config directly
|
||||
result = await evaluate_quality_gate(pool, config=config)
|
||||
|
||||
# On FIXED code (max_snapshot_age_hours=48): result.passed should be True
|
||||
# because 26h < 48h and all metrics meet thresholds.
|
||||
# On UNFIXED code (max_snapshot_age_hours=24): result.passed is False
|
||||
# because 26h > 24h triggers the staleness check.
|
||||
assert result.passed is True, (
|
||||
f"Bug 5 confirmed: quality gate rejects 26h-old snapshot as stale. "
|
||||
f"Reason: '{result.reason}'. "
|
||||
f"max_snapshot_age_hours={config.max_snapshot_age_hours} "
|
||||
f"(should be 48)"
|
||||
)
|
||||
|
||||
@given(
|
||||
age_hours=st.floats(min_value=24.1, max_value=47.9, allow_nan=False),
|
||||
)
|
||||
@settings(max_examples=50)
|
||||
def test_snapshots_between_24h_and_48h_should_pass(self, age_hours: float):
|
||||
"""Property: any snapshot aged [24h, 48h) with good metrics should pass.
|
||||
|
||||
The unfixed code rejects all snapshots > 24h.
|
||||
"""
|
||||
config = QualityGateConfig()
|
||||
|
||||
# The default config has max_snapshot_age_hours = 24 on unfixed code
|
||||
# The fixed code should have max_snapshot_age_hours = 48
|
||||
assert config.max_snapshot_age_hours >= 48, (
|
||||
f"Bug 5 confirmed: QualityGateConfig.max_snapshot_age_hours = "
|
||||
f"{config.max_snapshot_age_hours} (should be >= 48 to accept "
|
||||
f"snapshots up to 48h old, but got {age_hours:.1f}h snapshot rejected)"
|
||||
)
|
||||
Reference in New Issue
Block a user