# Prediction Engine The "should I fill up now or wait?" recommendation that drives the headline, notifications, and the entire product. Lives in `app/Services/NationalFuelPredictionService.php` and is called from `Api\PredictionController`. > The prediction is the product's selling point. Confidence calibration matters > as much as direction — a "Wait — prices falling" headline at 30% confidence is > worse than no recommendation at all. ## Output `predict(?float $lat, ?float $lng): array` returns: | Key | Type | Notes | |---|---|---| | `fuel_type` | string | currently always `'e10'` | | `current_avg` | float | current price avg in pence (regional 50km if coords given, else national) | | `predicted_direction` | `'up' | 'down' | 'stable'` | aggregated vote | | `predicted_change_pence` | float | `slope × 7` — pence change projected over the prediction horizon | | `confidence_score` | float (0–100) | see "Confidence" below | | `confidence_label` | `'low' | 'medium' | 'high'` | bucketing of `confidence_score` | | `action` | `'fill_now' | 'wait' | 'no_signal'` | UI action mapped from direction | | `reasoning` | string | concatenation of enabled signal `detail` fields, or action-aware fallback | | `prediction_horizon_days` | int | `7` | | `region_key` | `'national' | 'regional'` | depends on whether coords were passed | | `methodology` | string | identifier for backtesting/auditing | | `weekly_summary` | object | yesterday/today/tomorrow + 7-day series (see below) | | `signals` | object | per-signal breakdown (see below) | ## Signals Each signal returns `{score, confidence, direction, detail, data_points, enabled}`. | Signal | Source | Enabled when | Score formula | |---|---|---|---| | `trend` | regression on daily national avg, 5-day adaptive → 14-day | ≥2 daily averages and R² ≥ 0.5 | `min(1, |slope| / SLOPE_SATURATION_PENCE) × sign(slope)` (saturates at `0.5p/day`) | | `day_of_week` | weekday averages over last 90 days | `unique_days ≥ DAY_OF_WEEK_MIN_DAYS` (21) | `±1` if today ≥1.5p above/below week avg, else `0`; confidence scales with `unique_days/90` | | `brand_behaviour` | supermarket vs major regression slopes over 7 days | both groups have ≥2 data points and divergence ≥1.0p | `±1` if leader is up/down | | `regional_momentum` | regression on stations within 50km, 14 days | coords provided + ≥3 daily averages within radius | `±0.7` | | `price_stickiness` | mean station hold duration over 30 days | ≥10 stations with ≥2 changes | `±0.1` confidence modifier | | `oil` | latest `price_predictions` row covering today or later | a row exists | `±1` if rising/falling, `0` if flat; confidence = stored `confidence/100` | | `national_momentum` | reserved | always disabled today | n/a | ### Oil signal — source preference `computeOilSignal()` picks the freshest row in this order: 1. `source = 'llm_with_context'` 2. `source = 'llm'` 3. `source = 'ewma'` `OilPriceService` (in `app/Services/OilPriceService.php` and friends) populates this table daily at 7am via the scheduler. Cap: LLM confidence is capped at 85, EWMA at 65 (see `.claude/rules/api-data.md`). The Brent oil signal is the **single biggest unlock** for confidence — it captures world-news context (OPEC, geopolitical) that pure local price history can't see. ### Day-of-week threshold The original spec said 56 days. Lowered to 21 because: - The signal's `confidence` is already `min(1, unique_days / 90)` — a 21-day signal naturally contributes only `~0.23` confidence and lifts as more data accumulates. - 56 days delays the signal so long it might as well not exist for new users. ## Aggregator `aggregateSignals(signals, hasCoordinates)` returns `[direction, confidence_score]`. ### Weights ``` National (no coords): trend 0.30 oil 0.25 dayOfWeek 0.20 brandBehaviour 0.15 stickiness 0.10 ---- 1.00 Regional (with coords): regionalMomentum 0.35 oil 0.20 trend 0.15 dayOfWeek 0.15 brandBehaviour 0.10 stickiness 0.05 ---- 1.00 ``` ### Direction ``` directional_score = Σ(score × signal_confidence × weight) // only signals with direction ≠ stable directional_weight = Σ(weight) // only signals with direction ≠ stable normalised = directional_score / directional_weight (0 if directional_weight ≈ 0) direction = 'up' if normalised >= 0.1 'down' if normalised <= -0.1 'stable' otherwise ``` **Stable signals do not dilute the direction vote.** They are excluded from both the numerator and denominator. This is a key fix — previously a single weak trend signal could be cancelled out by three "stable" signals adding weight without contributing direction. ### Confidence ``` avg_signal_confidence = Σ(signal_confidence × weight) / Σ(weight) // all enabled signals agreement = computeAgreement(signals, weights, final_direction) // 0..1 confidence_score = avg_signal_confidence × agreement × 100 (capped at 100) ``` **`avg_signal_confidence`** is how confident the individual signals are in their own readings (R², sample size, model confidence). Stable signals DO contribute here — knowing prices are stable is itself a confident answer. **`agreement`** measures how the signals line up with the chosen direction: - aligned signal: full credit (`signal_confidence × weight`) - one side stable, other directional: half credit - opposing signals: no credit - final score: `Σ credit / Σ max_credit` This separation is the second key fix. Previously `confidence = |normalised| × 100` conflated "the signals point strongly somewhere" with "we're sure". Now: - Strong signals all agreeing → high `confidence_score` - Strong signals disagreeing → low `confidence_score` - Weak signals → low `confidence_score` (via low individual confidences) ### Confidence labels | `confidence_score` | `confidence_label` | UI behaviour | |---|---|---| | ≥ 70 | `high` | fire notification when allowed | | 40–69 | `medium` | dashboard only | | < 40 | `low` | dashboard only | ## Reasoning `buildReasoning()` joins `detail` strings from enabled signals. If none have material content, it falls back to an **action-aware** sentence: | `direction` / `action` | Fallback | |---|---| | `up` / `fill_now` | "Mild upward signals — top up soon if you're nearby." | | `down` / `wait` | "Mild downward signals — wait a day or two if your tank can hold." | | `stable` / `no_signal` | "No clear pattern — fill up at the cheapest station near you now." | The earlier hard-coded "fill up" fallback contradicted "Wait — prices falling" headlines and is no longer used. ## Weekly summary `computeWeeklySummary()` returns the Y/T/T strip + last-7-days numbers: | Field | Meaning | |---|---| | `yesterday_avg` / `today_avg` | regional (50km) → national fallback | | `tomorrow_estimated_avg` | `today_avg + trend.slope` (slope is 0 if trend disabled) | | `yesterday_today_delta_pence` | `today − yesterday`; sign tells you which was cheaper | | `last_7_days_series` | array of `{date, avg}`, one entry per day with data | | `last_7_days_change_pence` | `series[last].avg − series[0].avg` | | `cheapest_day` / `priciest_day` | min/max of the series | | `is_regional` | `true` only if regional data was actually used; `false` after national fallback | ## API gate The prediction is **embedded in the `/api/stations` response** under the `prediction` key — there is no standalone prediction endpoint. The same payload shape ships back regardless of route, but the gate runs server-side: `PlanFeatures::for($user)->can('ai_predictions')`. - ai_predictions allowed (plus, pro): full multi-signal payload (`fuel_type`, `current_avg`, `predicted_direction`, `confidence_score`, `reasoning`, `weekly_summary`, `signals`, …) - otherwise (free, basic, guest): stripped teaser `{fuel_type, predicted_direction, tier_locked: true}` for the upsell card Bundling into `/api/stations` ties prediction availability to a real station search — there is no way to scrape the prediction independently. Don't add a separate prediction route or accept a request body without coords; the prediction is always computed alongside a search. ## What never to do - Don't introduce a new signal without giving it `enabled`, `confidence`, and a weight in both national + regional weight maps. - Don't read `brent_prices` directly from the prediction service — go through `price_predictions`. The prediction table is the source of truth for oil-direction-as-a-signal. - Don't reintroduce a confidence formula that uses `|directional_score|` — that conflates magnitude with sureness. - Don't add a stable-direction signal to `directional_weight` — stable signals must not dilute direction. --- paths: - "app/Services/NationalFuelPredictionService.php" - "app/Http/Controllers/Api/StationController.php" - "tests/Unit/Services/NationalFuelPredictionServiceTest.php" - "tests/Feature/Api/StationControllerTest.php" ---