# 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"
---
