Files
fuel-price/.claude/rules/prediction.md
Ovidiu U 775e076bb7
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
Add current_period tracking to subscriptions, document prediction engine, and refactor station list UI
Add current_period_start, current_period_end, and stripe_data columns to subscriptions table via migration. Extend Subscription model with datetime casts for new fields. Create comprehensive prediction engine documentation covering signals, aggregation, confidence calibration, and weekly summary logic. Add PredictionFull Vue component displaying action label, reasoning, and 7-day context. Refactor StationList to collapse outdated stations behind expandable section. Add UpsellBanner component with station count formatting. Create .claude/settings.json denying .env file access. Add todo.md tracking Stripe dashboard setup, production deployment steps, and E2E QA checklist. Update .env.example with fuel-finder credentials, Anthropic config, and complete Stripe price IDs.
2026-04-29 18:14:03 +01:00

9.0 KiB
Raw Blame History

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'
predicted_change_pence float slope × 7 — pence change projected over the prediction horizon
confidence_score float (0100) see "Confidence" below
confidence_label `'low' 'medium'
action `'fill_now' 'wait'
reasoning string concatenation of enabled signal detail fields, or action-aware fallback
prediction_horizon_days int 7
region_key `'national' 'regional'`
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,
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
4069 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"