Files
fuel-price/.claude/rules/scoring.md
Ovidiu U 19d5c6eb0b
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
feat: add Laravel Fortify skill, condense API data rules, add homepage mockup
2026-04-09 14:19:04 +01:00

3.7 KiB
Raw Blame History

Scoring Engine (AlertScoringService)

Purpose

Produces a "fill up now or wait?" recommendation per user based on their local station history. Output is one of: fill_up, wait, no_signal. Never guess — stay silent (no_signal) when signals conflict or data is insufficient.

The 5 signals (in priority order)

Signal 1 — Local price trend (40 pts max)

  • Nearest 5 stations within 5km; user's preferred fuel type
  • Least-squares regression on (recorded_at, price_pence); adaptive lookback: 5 days first, fall back to 14 if R² < 0.5
  • Slope ≤ -0.3p/day AND R² ≥ 0.5 → wait; slope ≥ +0.3p/day AND R² ≥ 0.5 → fill_up; otherwise no signal
  • Store: slope, R², lookback_days, data_points

Signal 2 — Supermarket anchor effect (35 pts max)

  • Nearest supermarket (is_supermarket = 1) within 10km
  • Supermarket cut > 1p in last 48h AND independents haven't followed → wait
  • Inverse (supermarket raised, independents haven't) → mild fill_up

Signal 3 — Day-of-week pattern (15 pts max)

  • Requires 56+ days of station history; average price by day-of-week over last 90 days
  • Today 1.5p+ below weekly average → mild fill_up; 1.5p+ above → mild wait

Signal 4 — Brent crude direction (10 pts max)

  • Read from price_predictions table only (never query brent_prices in scoring)
  • LLM (source='llm') preferred; EWMA fallback. Points = (confidence / 100) * 10
  • rising → fill_up; falling → wait; flat → no signal

Signal 5 — Price stickiness (confidence modifier, ±5 pts)

  • Requires 30+ days history. Applied after all signals are summed.
  • avg hold < 2 days → -5 pts; 24 days → 0; > 5 days → +5 pts
  • Store: avg_hold_days, data_points

Confidence thresholds

  • Score 70100: strong signal → fire recommendation + notification
  • Score 4069: weak signal → show in dashboard only, no push/SMS/WhatsApp
  • Score 039: no_signal → stay silent entirely

Only send notifications when confidence ≥ 70. Never spam.

Output (stored in scoring_results)

[
    'recommendation'  => 'wait',
    'confidence'      => 78,
    'signals'         => [
        'trend'       => [
            'direction'     => 'falling',
            'slope'         => -1.07,     // pence per day
            'r_squared'     => 0.96,
            'lookback_days' => 5,
            'data_points'   => 5,
            'points'        => 32,
        ],
        'supermarket' => ['triggered' => true, 'points' => 35],
        'day_pattern' => ['triggered' => false, 'points' => 0],
        'brent'       => ['direction' => 'flat', 'points' => 0],
        'stickiness'  => ['avg_hold_days' => 2.8, 'modifier' => 0],
    ],
    'local_avg_pence' => 14380,           // 143.80p
    'trend_delta'     => -2.3,            // pence change over lookback period
]

Human-readable reason strings

Always generate a plain-English reason for the recommendation:

  • "Prices near you have been falling at 1.1p/day for 5 days. Tesco {station} cut 3p yesterday — independents usually follow within 48 hours."
  • "Prices are rising sharply in your area (+7.5p expected this week) — filling up today avoids paying more later."
  • "No clear pattern this week — fill up at the cheapest station near you now."

Reason strings are stored in scoring_results.signals JSON and shown in the UI and notifications.

Data quality — anomaly rejection

Reject before storing or scoring: price_pence > 25000 or < 10000, or single-update change > 20p (flag for review). Log to anomalous_prices table. Never let dirty data skew regression slope or collapse R².

Accuracy self-tracking

After 3 days, check if wait was correct (prices fell further). Store outcome in scoring_results for display.