# Pipeline Health Fixes — Bugfix Design ## Overview Five operational bugs degrade stonks-beta pipeline health. This design formalizes the bug conditions, expected fixes, and validation strategy for each: 1. **Stuck Parsed Docs** — `recover_stale_documents()` batch limit of 100 is too low for 1,809 stuck documents; increase to 500 and lower the stale threshold to 30 minutes. 2. **Extended Price Fallback** — Prediction snapshots missing prices (26.5%) because the fallback chain stops at `positions`; add a third fallback querying `market_snapshots` within 24h. 3. **Sentiment Z-Score Normalization** — Raw NuExtract3 `impact_score` values produce 64% sell bias; normalize using 7-day rolling z-scores per ticker before signal weighting. 4. **Signal Engine Scale Down** — Idle signal-engine pods consume resources; set replicas to 0 in all Helm values files. 5. **Quality Gate Threshold** — 24h staleness threshold permanently locks quality gate to paper-only; relax to 48h. ## Glossary - **Bug_Condition (C)**: The specific conditions under which each bug manifests - **Property (P)**: The desired correct behavior after the fix is applied - **Preservation**: Existing behavior that must remain unchanged after the fix - **`recover_stale_documents()`**: Function in `services/scheduler/app.py` that re-enqueues documents stuck in `parsed` status - **`STALE_PARSED_THRESHOLD_MINUTES`**: Constant (currently 240) controlling how long a document must be stuck before recovery - **`fetch_latest_close_price()`**: Function in `services/validation/prediction_snapshot.py` that queries `market_snapshots` for the most recent bar - **`compute_signal_weight()`**: Function in `services/aggregation/scoring.py` that computes combined signal weight from recency, credibility, novelty, confidence, and impact - **`QualityGateConfig.max_snapshot_age_hours`**: Threshold in `services/trading/model_quality_gate.py` controlling when the quality gate defaults to paper-only ## Bug Details ### Bug Condition The pipeline health degradation manifests across five independent conditions: **Formal Specification:** ``` FUNCTION isBugCondition(input) INPUT: input of type PipelineState OUTPUT: boolean -- Bug 1: Parsed docs stuck beyond batch capacity RETURN (input.stuckParsedDocCount > 100 AND input.recoveryBatchLimit == 100 AND input.docStaleMinutes >= 240) -- Bug 2: Price fallback chain incomplete OR (input.tickerPrice IS NULL AND input.positionPrice IS NULL AND input.marketSnapshotWithin24h IS NOT NULL) -- Bug 3: Raw impact scores without normalization OR (input.impactScoreUsedRaw == TRUE AND input.ticker7dStddev > 0) -- Bug 4: Signal engine running idle OR (input.signalEngineReplicas > 0 AND input.dualPipelineEnabled == FALSE) -- Bug 5: Quality gate threshold too strict OR (input.snapshotAgeHours > 24 AND input.snapshotAgeHours <= 48 AND input.maxSnapshotAgeConfig == 24) END FUNCTION ``` ### Examples - **Bug 1**: 1,809 documents in `parsed` status older than 4 hours. At 100/cycle every 5 minutes, clearing takes 90+ cycles (~7.5h). With 500/batch, it takes 4 cycles (~20 min). - **Bug 2**: Ticker PLTR has no open position and `fetch_latest_close_price` returns NULL, but `market_snapshots` has a bar from 3 hours ago that could serve as price. - **Bug 3**: NuExtract3 outputs `impact_score` values clustered around -0.3 to -0.1 for a ticker. Without normalization, `weighted_sentiment_average()` systematically produces negative signals → 64% sell recommendations. - **Bug 4**: signal-engine pod starts, detects `dual_pipeline_enabled=False`, enters infinite sleep loop consuming 100m CPU request / 128Mi memory request. - **Bug 5**: Quality gate reads `model_metric_snapshots`, finds the most recent is 26h old (because validation skips NULL-price predictions), fails staleness check, forces paper-only mode permanently. ## Expected Behavior ### Preservation Requirements **Unchanged Behaviors:** - Documents entering `parsed` status and processed within the normal threshold window (< 30 min after fix) are left alone for the extraction queue consumer - Primary price lookup via `fetch_latest_close_price()` (exact time match from `market_snapshots`) continues as the first-choice price source - Tickers with balanced sentiment distributions continue to produce neutral/mixed recommendations without artificial skew - Signal-engine's queue-based architecture and configuration loading remain functional when re-enabled with replicas > 0 - Quality gate threshold logic for snapshots younger than 48h and meeting all criteria continues to promote to `live_eligible` - Outcome evaluator continues to process prediction snapshots with valid (non-NULL) prices normally - `retry_failed_extractions` task continues on its existing cadence without interference **Scope:** All inputs that do NOT match the bug conditions above should be completely unaffected by these fixes. The fixes are additive (new fallback path, wider batch, normalization layer) or config-only (replica count, threshold constant). ## Hypothesized Root Cause ### Bug 1: Stuck Parsed Docs - **Batch limit too small**: The `LIMIT 100` in the SQL query caps recovery throughput at 100 docs per scheduler cycle (~5 min). When a Redis crash orphans thousands of documents, the recovery rate cannot keep up with the backlog. - **Threshold too conservative**: `STALE_PARSED_THRESHOLD_MINUTES = 240` (4 hours) means documents must be stuck for 4 hours before recovery kicks in. A 30-minute threshold would catch orphans much faster. ### Bug 2: Incomplete Fallback Chain - **Missing time-window query**: `fetch_latest_close_price()` only checks `market_snapshots` for an exact timestamp match. When market data ingestion is delayed or the prediction happens outside market hours, no exact match exists. - **Positions-only fallback**: The positions table fallback only works for tickers with an active position. 26.5% of snapshots are for tickers without positions. ### Bug 3: Uncalibrated Impact Scores - **No distribution normalization**: NuExtract3 model outputs are passed raw into `compute_signal_weight()` via `impact_score` parameter. The model has a systematic negative bias in its output distribution that is not corrected. - **Per-ticker variance ignored**: Different tickers receive different volume/types of news, producing different impact_score distributions. A global normalization would be insufficient. ### Bug 4: Idle Signal Engine - **Replicas set to 1 by default**: `values.yaml` defines `signalEngine.replicas: 1` regardless of whether the dual pipeline feature is enabled. The pod starts, detects the feature is off, and sleeps forever. ### Bug 5: Overly Strict Staleness - **24h threshold too tight during bootstrapping**: The `max_snapshot_age_hours = 24` default assumes the validation cycle runs frequently. When Bug 2/3 cause most predictions to be skipped, metric snapshots aren't generated, and the 24h window expires. ## Correctness Properties Property 1: Bug Condition — Stuck Parsed Docs Recovery _For any_ set of documents stuck in `parsed` status longer than 30 minutes, the fixed `recover_stale_documents()` function SHALL process up to 500 documents per cycle, reducing backlog clearance time by 5x compared to the previous 100-document limit. **Validates: Requirements 2.1** Property 2: Bug Condition — Extended Price Fallback _For any_ prediction snapshot where `fetch_latest_close_price()` returns NULL and the `positions` table has no price, but `market_snapshots` contains a bar for the ticker within 24 hours of the prediction time, the fixed code SHALL use that bar's close price as `price_at_prediction`. **Validates: Requirements 2.2, 2.3** Property 3: Bug Condition — Sentiment Z-Score Normalization _For any_ set of `document_impact_records` for a ticker, the fixed aggregation pipeline SHALL normalize `impact_score` values using the 7-day rolling mean and standard deviation for that ticker before passing them into signal weight computation, preventing systematic model bias. **Validates: Requirements 2.4** Property 4: Bug Condition — Signal Engine Scale Down _For any_ Helm deployment where `dual_pipeline_enabled=False`, the fixed Helm values SHALL specify `signalEngine.replicas: 0`, preventing the pod from being scheduled and consuming resources. **Validates: Requirements 2.5** Property 5: Bug Condition — Quality Gate Threshold _For any_ model metric snapshot that is between 24h and 48h old, the fixed quality gate SHALL NOT reject it as stale, allowing the system to remain in non-paper mode during the bootstrapping period. **Validates: Requirements 2.6** Property 6: Preservation — Normal Document Processing _For any_ document that enters `parsed` status and is processed within 30 minutes, the fixed `recover_stale_documents()` function SHALL NOT interfere with normal extraction queue processing, preserving the existing pipeline flow. **Validates: Requirements 3.1, 3.7** Property 7: Preservation — Primary Price Path _For any_ prediction snapshot where `fetch_latest_close_price()` returns a valid price, the fixed code SHALL use that price directly without invoking any fallback, preserving the primary price lookup behavior. **Validates: Requirements 3.2** Property 8: Preservation — Balanced Sentiment _For any_ ticker with a balanced sentiment distribution (equal bullish/bearish evidence), the z-score normalization SHALL produce values centered around 0, preserving neutral/mixed recommendation output without artificial skew. **Validates: Requirements 3.3** Property 9: Preservation — Quality Gate Valid Snapshots _For any_ model metric snapshot younger than 48h that meets all threshold criteria, the fixed quality gate SHALL continue to promote recommendations to `live_eligible` mode per existing logic. **Validates: Requirements 3.5, 3.6** ## Fix Implementation ### Changes Required **Bug 1: Stuck Parsed Docs Recovery** **File**: `services/scheduler/app.py` **Function**: `recover_stale_documents()` **Specific Changes**: 1. **Lower stale threshold**: Change `STALE_PARSED_THRESHOLD_MINUTES` from `240` to `30` — documents stuck longer than 30 minutes are likely orphaned 2. **Increase batch limit**: Change `LIMIT 100` to `LIMIT 500` in the SQL query 3. **Update enqueued TTL**: Change `_ENQUEUED_TTL` from `14400` (4h) to `3600` (1h) to match the new threshold --- **Bug 2: Extended Price Fallback** **File**: `services/validation/prediction_snapshot.py` **Function**: `create_prediction_snapshot()` **Specific Changes**: 1. **Add market_snapshots time-window fallback**: After the positions fallback fails, query `market_snapshots` for the most recent bar within 24h of the current time for the ticker 2. **SQL query**: `SELECT close FROM market_snapshots WHERE ticker = $1 AND timestamp >= NOW() - INTERVAL '24 hours' ORDER BY timestamp DESC LIMIT 1` 3. **Log the fallback**: Add info-level logging when the extended fallback is used **New File**: `scripts/backfill_snapshot_prices.py` **Purpose**: One-time backfill script to populate `price_at_prediction` for existing NULL snapshots using the extended fallback chain. **Approach**: 1. Query all `prediction_snapshots` where `price_at_prediction IS NULL` 2. For each, attempt: `market_snapshots` within 24h of `generated_at`, then `positions` table 3. Update the row with the found price 4. Report statistics (found via market_snapshots, found via positions, still NULL) --- **Bug 3: Sentiment Z-Score Normalization** **File**: `services/aggregation/worker.py` (or new helper in `services/aggregation/scoring.py`) **Function**: New function `normalize_impact_scores()` called before `compute_signal_weight()` **Specific Changes**: 1. **Add normalization function**: Compute 7-day rolling mean and stddev of `impact_score` per ticker from `document_impact_records` 2. **Formula**: `normalized = (raw - mean_7d) / max(stddev_7d, 0.1)` — the 0.1 floor prevents division by near-zero stddev for low-activity tickers 3. **Integration point**: In the aggregation loop (around line 440 of worker.py), normalize `imp.impact_score` before passing to `compute_signal_weight()` and `WeightedSignal` 4. **Fallback**: If fewer than 5 records exist in the 7-day window, use the raw score (insufficient data for meaningful normalization) 5. **Query**: `SELECT AVG(impact_score) as mean, STDDEV(impact_score) as stddev FROM document_impact_records WHERE ticker = $1 AND created_at >= NOW() - INTERVAL '7 days'` --- **Bug 4: Signal Engine Scale Down** **Files**: `infra/helm/stonks-oracle/values.yaml`, `values-beta.yaml`, `values-paper.yaml` **Specific Changes**: 1. **values.yaml**: Change `signalEngine.replicas` from `1` to `0` 2. **values-beta.yaml**: Add `signalEngine.replicas: 0` under `services:` 3. **values-paper.yaml**: Add `signalEngine.replicas: 0` under `services:` --- **Bug 5: Quality Gate Threshold** **File**: `services/trading/model_quality_gate.py` **Class**: `QualityGateConfig` **Specific Changes**: 1. **Change default**: `max_snapshot_age_hours: int = 48` (was 24) ## Testing Strategy ### Validation Approach The testing strategy follows a two-phase approach: first, surface counterexamples that demonstrate the bug on unfixed code, then verify the fix works correctly and preserves existing behavior. ### Exploratory Bug Condition Checking **Goal**: Surface counterexamples that demonstrate the bugs BEFORE implementing the fixes. Confirm or refute the root cause analysis. **Test Plan**: Write tests that exercise each bug condition on the unfixed code to observe failures. **Test Cases**: 1. **Batch Overflow Test**: Create 600 documents in `parsed` status older than threshold, run `recover_stale_documents()`, assert only 100 are processed (will demonstrate Bug 1) 2. **Price Fallback Gap Test**: Call `create_prediction_snapshot()` for a ticker with no position and no exact market_snapshots match, assert `price_at_prediction` is NULL (will demonstrate Bug 2) 3. **Sentiment Bias Test**: Generate 50 impact records with systematic negative bias (mean=-0.3, stddev=0.1), compute weighted signals, assert directional signal is negative (will demonstrate Bug 3) 4. **Quality Gate Staleness Test**: Set most recent metric snapshot to 26h ago, evaluate quality gate, assert it fails (will demonstrate Bug 5) **Expected Counterexamples**: - Bug 1: Only 100 of 600 documents recovered per cycle - Bug 2: `price_at_prediction` stored as NULL despite market data existing within 24h - Bug 3: Weighted sentiment average heavily negative despite mixed underlying events - Bug 5: Quality gate returns `passed=False` with reason containing "stale" ### Fix Checking **Goal**: Verify that for all inputs where the bug condition holds, the fixed function produces the expected behavior. **Pseudocode:** ``` FOR ALL input WHERE isBugCondition(input) DO result := fixedFunction(input) ASSERT expectedBehavior(result) END FOR ``` **Per-bug fix checks:** - Bug 1: `recover_stale_documents()` processes up to 500 docs with 30-min threshold - Bug 2: Extended fallback returns a price when `market_snapshots` has data within 24h - Bug 3: Normalized impact scores have mean ≈ 0 and stddev ≈ 1 for active tickers - Bug 4: `kubectl get pods` shows 0 signal-engine pods - Bug 5: Quality gate passes for snapshots 24–48h old that meet metric thresholds ### Preservation Checking **Goal**: Verify that for all inputs where the bug condition does NOT hold, the fixed function produces the same result as the original function. **Pseudocode:** ``` FOR ALL input WHERE NOT isBugCondition(input) DO ASSERT originalFunction(input) = fixedFunction(input) END FOR ``` **Testing Approach**: Property-based testing is recommended for preservation checking because: - It generates many test cases automatically across the input domain - It catches edge cases that manual unit tests might miss - It provides strong guarantees that behavior is unchanged for all non-buggy inputs **Test Plan**: Observe behavior on UNFIXED code first for normal inputs, then write property-based tests capturing that behavior. **Test Cases**: 1. **Normal Doc Processing Preservation**: Documents < 30 min old are never touched by recovery 2. **Primary Price Preservation**: When `fetch_latest_close_price()` succeeds, no fallback is invoked 3. **Balanced Sentiment Preservation**: Tickers with symmetric impact_score distributions produce neutral signals after normalization 4. **Quality Gate Normal Preservation**: Snapshots < 48h old and meeting thresholds still pass 5. **Failed Extraction Preservation**: `retry_failed_extractions()` behavior unchanged ### Unit Tests - Test `recover_stale_documents()` with various document counts (0, 50, 500, 1000) - Test extended price fallback with market_snapshots at various time offsets (1h, 12h, 23h, 25h) - Test z-score normalization with known distributions (mean=0, mean=-0.5, stddev=0, stddev=0.05) - Test quality gate with snapshot ages at boundary (23h, 24h, 47h, 48h, 49h) - Test backfill script with mixed NULL/non-NULL snapshots ### Property-Based Tests - Generate random document ages and counts, verify recovery processes correct subset (> 30 min old, up to 500) - Generate random ticker price scenarios, verify fallback chain ordering is preserved (primary → positions → market_snapshots_24h → NULL) - Generate random impact_score distributions per ticker, verify normalized output has bounded variance and zero-centered mean - Generate random snapshot ages, verify quality gate accepts [0, 48h) and rejects [48h, ∞) ### Integration Tests - End-to-end: create documents in `parsed` status, run scheduler cycle, verify extraction queue populated - End-to-end: create prediction snapshot for ticker without position, verify price populated from market_snapshots - End-to-end: run full aggregation cycle with biased NuExtract3 outputs, verify recommendation direction is not systematically biased - Helm template render: verify signal-engine deployment has 0 replicas in all value files