From d6ab09c19b40382ac3c7fc375fae35ccbec3dc9f Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Mon, 6 Jul 2026 18:23:46 +0300 Subject: [PATCH] Update: 2026-07-06 18:23:46 --- backend/pricing-engine/src/analysis/engine.ts | 145 ++++++-- .../pricing-engine/src/analysis/regression.ts | 125 +++++-- backend/pricing-engine/src/analysis/types.ts | 2 + backend/pricing-engine/src/index.ts | 19 +- backend/pricing-engine/src/utils/math.ts | 342 ++++++++++++------ backend/ride/rides/finish_ride_updates.php | 20 +- backend/ride/rides/start_ride.php | 54 ++- 7 files changed, 505 insertions(+), 202 deletions(-) diff --git a/backend/pricing-engine/src/analysis/engine.ts b/backend/pricing-engine/src/analysis/engine.ts index 65b90273..40015777 100644 --- a/backend/pricing-engine/src/analysis/engine.ts +++ b/backend/pricing-engine/src/analysis/engine.ts @@ -12,26 +12,35 @@ export interface EngineOptions { cleanOutliers?: boolean; /** * Surge threshold as a fraction of the median price (e.g. 0.05 = 5%). - * Default: 0.05 (5% of median ride price). - * The absolute threshold is computed dynamically per dataset so it scales - * correctly across currencies (JOD, SYP, IQD, etc.). + * Computed dynamically per dataset so it scales across currencies. */ surgeThresholdFraction?: number; tierCount?: number; + /** + * Known receipts to validate against — used to sanity-check the formula. + * Each entry is a real fare that the engine's formula should be able to predict. + */ + knownReceipts?: Array<{ + label: string; + distanceKm: number; + durationMin: number; + actualPrice: number; + }>; } /** * Main pricing analysis engine. - * Orchestrates the full pipeline: fetch → clean → cluster → regress → surge → zone. + * Pipeline: fetch → clean → cluster → regress → surge → zone → validate. */ export async function runAnalysis( samples: RideSample[], options: EngineOptions = {} ): Promise { const { - cleanOutliers = true, + cleanOutliers = true, surgeThresholdFraction = 0.05, - tierCount = 3, + tierCount = 3, + knownReceipts = [], } = options; if (samples.length < 5) { @@ -43,10 +52,9 @@ export async function runAnalysis( // Step 1: Remove statistical outliers (MAD on PPK) const cleanSamples = cleanOutliers ? removeOutliers(samples) : samples; - // Step 2: Compute dynamic surge threshold — 5% of median price, clamped to [0.05, 2.0] - // This ensures the threshold scales correctly for high-denomination currencies. - const allPrices = cleanSamples.map(s => s.price); - const medianPrice = median(allPrices); + // Step 2: Compute dynamic surge threshold (5% of median price) + const allPrices = cleanSamples.map(s => s.price); + const medianPrice = median(allPrices); const surgeThreshold = Math.min(Math.max(medianPrice * surgeThresholdFraction, 0.05), 2.0); // Step 3: Group by route and extract base (non-surge) prices @@ -56,42 +64,54 @@ export async function runAnalysis( // Step 4: Cluster into pricing tiers by PPK const rawTiers = clusterTiers(cleanSamples, tierCount); - // Step 5: Run regression on each tier + // Step 5: Run regression on each tier (two-stage + robust-MLR, best wins) const analyzedTiers = analyzeAllTiers(rawTiers); // Step 6: Detect surge patterns const surgePatterns = detectSurge(cleanSamples, surgeThreshold); - const surgeHours = aggregateSurgeHours(surgePatterns); + const surgeHours = aggregateSurgeHours(surgePatterns); // Step 7: Zone analysis - const zones = analyzeByZone(cleanSamples); + const zones = analyzeByZone(cleanSamples); const zoneTypes = analyzeByZoneType(cleanSamples); // Build report const report: AnalysisReport = { competitorName: firstSample.competitorName, - countryCode: firstSample.countryCode, - tiers: analyzedTiers, + countryCode: firstSample.countryCode, + tiers: analyzedTiers, surgePatterns, zones, - totalSamples: samples.length, - analyzedAt: new Date().toISOString(), + totalSamples: samples.length, + analyzedAt: new Date().toISOString(), }; - // Print summary - printSummary(report, baseSamples, surgeHours, zoneTypes, surgeThreshold); + // Print full summary including receipt validation + printSummary(report, baseSamples, surgeHours, zoneTypes, surgeThreshold, knownReceipts); return report; } +// ───────────────────────────────────────────────────────────── +// Summary printing +// ───────────────────────────────────────────────────────────── + +function currencyCode(countryCode: string): string { + const map: Record = { JO: 'JOD', SY: 'SYP', IQ: 'IQD', SA: 'SAR', AE: 'AED', EG: 'EGP' }; + return map[countryCode] ?? 'CUR'; +} + function printSummary( - report: AnalysisReport, - baseSamples: RideSample[], - surgeHours: ReturnType, - zoneTypes: ReturnType, - surgeThreshold: number + report: AnalysisReport, + baseSamples: RideSample[], + surgeHours: ReturnType, + zoneTypes: ReturnType, + surgeThreshold: number, + knownReceipts: NonNullable ): void { const sep = '═══════════════════════════════════════════════════════'; + const cur = currencyCode(report.countryCode); + console.log(`\n${sep}`); console.log(` 📊 Pricing Analysis Report — ${report.competitorName} (${report.countryCode})`); console.log(` ${report.totalSamples} total samples, ${baseSamples.length} base-price samples`); @@ -99,28 +119,32 @@ function printSummary( console.log(` Analyzed at: ${report.analyzedAt}`); console.log(sep); - // Tiers + // ── Tiers ─────────────────────────────────── console.log(`\n📦 PRICING TIERS:`); for (const tier of report.tiers) { const reg = tier.regression; if (reg) { - const tierIcon = tier.label === 'economy' ? '💰' : tier.label === 'standard' ? '🚗' : '💎'; - console.log(` ${tierIcon} ${tier.label.toUpperCase()}:`); - console.log(` Base Fare: ${reg.baseFare.toFixed(3)} ${report.countryCode === 'JO' ? 'JOD' : report.countryCode === 'SY' ? 'SYP' : report.countryCode === 'IQ' ? 'IQD' : 'CUR'}`); + const icon = tier.label === 'economy' ? '💰' : tier.label === 'standard' ? '🚗' : '💎'; + console.log(` ${icon} ${tier.label.toUpperCase()}: [model: ${reg.modelName}]`); + console.log(` Flag Fall: ${reg.baseFare.toFixed(3)} ${cur}`); console.log(` Per KM: ${reg.kmRate.toFixed(3)}`); - console.log(` Per Min: ${reg.minRate.toFixed(3)}`); + console.log(` Per Min: ${reg.minRate.toFixed(3)}${reg.minRate < 0.005 ? ' ⚠️ (near-zero — may need more data)' : ''}`); console.log(` Min Fare: ${reg.minFare.toFixed(3)} ${reg.hasMinFare ? '✅ active' : ''}`); console.log(` RMSE: ${reg.rmse.toFixed(4)}`); console.log(` R²: ${reg.rSquared.toFixed(4)}`); console.log(` Samples: ${reg.sampleCount}`); console.log(` PPK range: ${tier.ppkRange[0].toFixed(3)} – ${tier.ppkRange[1].toFixed(3)}`); + + // Formula preview + const formula = buildFormulaString(reg.baseFare, reg.kmRate, reg.minRate, reg.minFare, cur); + console.log(` Formula: ${formula}`); } else { console.log(` 📄 ${tier.label.toUpperCase()}: ${tier.samples.length} samples (insufficient for regression)`); } console.log(''); } - // Surge + // ── Surge ─────────────────────────────────── if (surgeHours.length > 0) { console.log(`⚡ SURGE PATTERNS (by hour-of-day):`); for (const sh of surgeHours) { @@ -130,7 +154,7 @@ function printSummary( console.log(`\nℹ️ No significant surge patterns detected.`); } - // Zones + // ── Zones ─────────────────────────────────── if (zoneTypes.length > 0) { console.log(`\n📍 ZONE TYPE ANALYSIS:`); for (const zt of zoneTypes) { @@ -138,14 +162,69 @@ function printSummary( } } - // Surge route details + // ── Top Surge Routes ──────────────────────── if (report.surgePatterns.length > 0) { console.log(`\n🔍 TOP SURGE ROUTES:`); for (const sr of report.surgePatterns.slice(0, 5)) { - console.log(` ${sr.distanceKm.toFixed(1)}km → base ${sr.basePrice.toFixed(2)}, peak ${(sr.basePrice * sr.maxMultiplier).toFixed(2)} JOD (${sr.maxMultiplier.toFixed(3)}x)`); + console.log(` ${sr.distanceKm.toFixed(1)}km → base ${sr.basePrice.toFixed(2)}, peak ${(sr.basePrice * sr.maxMultiplier).toFixed(2)} ${cur} (${sr.maxMultiplier.toFixed(3)}x)`); } } + // ── Receipt Validation ────────────────────── + if (knownReceipts.length > 0) { + console.log(`\n🧾 RECEIPT VALIDATION:`); + + for (const receipt of knownReceipts) { + console.log(`\n 📄 ${receipt.label}`); + console.log(` Route: ${receipt.distanceKm} km / ${receipt.durationMin.toFixed(2)} min`); + console.log(` Actual price: ${receipt.actualPrice.toFixed(3)} ${cur}`); + + for (const tier of report.tiers) { + const reg = tier.regression; + if (!reg) continue; + + const predicted = + reg.baseFare + + reg.kmRate * receipt.distanceKm + + reg.minRate * receipt.durationMin; + + const effective = reg.hasMinFare && reg.minFare > 0 + ? Math.max(predicted, reg.minFare) + : predicted; + + const error = effective - receipt.actualPrice; + const errorPct = (error / receipt.actualPrice) * 100; + const sign = error >= 0 ? '+' : ''; + const flag = Math.abs(errorPct) <= 5 ? '✅' : Math.abs(errorPct) <= 15 ? '⚠️' : '❌'; + + console.log(` [${tier.label.padEnd(8)}] predicted: ${effective.toFixed(3)} ${cur} error: ${sign}${errorPct.toFixed(1)}% ${flag}`); + } + } + console.log(''); + } + console.log(sep); console.log(''); } + +/** + * Build a human-readable formula string for display. + * e.g. "price = 0.440 + 0.220 × km + 0.040 × min (min fare: 0.800)" + */ +function buildFormulaString( + baseFare: number, + kmRate: number, + minRate: number, + minFare: number, + cur: string +): string { + const parts: string[] = []; + + if (baseFare > 0.001) parts.push(`${baseFare.toFixed(3)}`); + parts.push(`${kmRate.toFixed(3)} × km`); + if (minRate > 0.001) parts.push(`${minRate.toFixed(3)} × min`); + + let formula = `price = ${parts.join(' + ')}`; + if (minFare > 0.001) formula += ` (min fare: ${minFare.toFixed(3)} ${cur})`; + return formula; +} diff --git a/backend/pricing-engine/src/analysis/regression.ts b/backend/pricing-engine/src/analysis/regression.ts index ce756a16..b23629f9 100644 --- a/backend/pricing-engine/src/analysis/regression.ts +++ b/backend/pricing-engine/src/analysis/regression.ts @@ -1,15 +1,68 @@ -import { PricingTier, RegressionResult, RideSample } from './types'; +import { PricingTier, RideSample, RegressionResult } from './types'; import { + twoStageRegression, robustMultipleLinearRegression, calcRMSE, calcRSquared, detectMinimumFare, } from '../utils/math'; -import { mean } from 'simple-statistics'; + +type RawModel = { baseFare: number; kmRate: number; minRate: number }; + +/** Evaluate RMSE of a model against the actual samples. */ +function evalRMSE(model: RawModel, samples: RideSample[]): number { + const actual = samples.map(s => s.price); + const predicted = samples.map(s => + model.baseFare + model.kmRate * s.distance_km + model.minRate * s.duration_min + ); + return calcRMSE(actual, predicted); +} /** - * Run multiple linear regression on each pricing tier. - * Detects minimum fare and computes RMSE/R². + * Select the best model from two candidates. + * + * Strategy: + * 1. Always run both two-stage and robust-MLR. + * 2. Primary criterion: lower RMSE wins. + * 3. Tie-break: if both models have similar RMSE (within 5%), prefer the one + * with a positive minRate — this respects the domain knowledge that time + * is always part of the taxi pricing formula. + */ +function selectBestModel( + modelA: RawModel | null, // two-stage + modelB: RawModel | null, // robust-MLR + samples: RideSample[] +): { model: RawModel; name: string } | null { + if (!modelA && !modelB) return null; + if (!modelA) return { model: modelB!, name: 'robust-MLR' }; + if (!modelB) return { model: modelA, name: 'two-stage' }; + + const rmseA = evalRMSE(modelA, samples); + const rmseB = evalRMSE(modelB, samples); + + // If RMSE difference is within 5%, prefer the model with a time component + const tolerance = Math.min(rmseA, rmseB) * 0.05; + if (Math.abs(rmseA - rmseB) <= tolerance) { + const aHasTime = modelA.minRate > 0.005; + const bHasTime = modelB.minRate > 0.005; + if (aHasTime && !bHasTime) return { model: modelA, name: 'two-stage' }; + if (bHasTime && !aHasTime) return { model: modelB, name: 'robust-MLR' }; + } + + return rmseA <= rmseB + ? { model: modelA, name: 'two-stage' } + : { model: modelB, name: 'robust-MLR' }; +} + +/** + * Run regression on a single pricing tier. + * + * Tries two approaches and picks the best: + * A) Two-stage regression — estimates flag fall first, then km + min rates + * B) Robust MLR — iterative outlier removal on full 3-parameter model + * + * The winner is chosen by RMSE, with a 5% tie-break that prefers models + * with a positive per-minute rate (time is always a component in real meters). */ export function analyzeTier(tier: PricingTier): PricingTier { const samples = tier.samples; @@ -18,78 +71,78 @@ export function analyzeTier(tier: PricingTier): PricingTier { return tier; } - // Primary model: price = baseFare + kmRate * dist + minRate * dur - // We use robust regression to strip out surge outliers and find the floor price - const mlrResult = robustMultipleLinearRegression( + const input: Array<{ distance_km: number; duration_min: number; price: number }> = samples.map(s => ({ - distance_km: s.distance_km, + distance_km: s.distance_km, duration_min: s.duration_min, - price: s.price, - })) - ); + price: s.price, + })); - if (!mlrResult) { + // Run both models + const modelA = twoStageRegression(input); // Stage 1: flag fall | Stage 2: km + min + const modelB = robustMultipleLinearRegression(input); // Current robust approach + + const best = selectBestModel(modelA, modelB, samples); + if (!best) { tier.regression = null; return tier; } - // Predict and compute RMSE/R² - const actualPrices = samples.map(s => s.price); + const { model: mlrResult, name: modelName } = best; + + // Compute final metrics using the winning model + const actualPrices = samples.map(s => s.price); const predictedPrices = samples.map(s => - mlrResult.baseFare + - mlrResult.kmRate * s.distance_km + - mlrResult.minRate * s.duration_min + mlrResult.baseFare + mlrResult.kmRate * s.distance_km + mlrResult.minRate * s.duration_min ); - const rmse = calcRMSE(actualPrices, predictedPrices); + const rmse = calcRMSE(actualPrices, predictedPrices); const rSquared = calcRSquared(actualPrices, predictedPrices); - // Detect minimum fare + // Detect minimum fare (floor charge for very short trips) const minFare = detectMinimumFare( samples.map(s => s.distance_km), samples.map(s => s.price), mlrResult.kmRate ); - // If minFare is detected and the short-ride residuals improve, - // apply minFare-adjusted model let hasMinFare = false; - let adjustedRMSE = rmse; + let adjustedRMSE = rmse; let adjustedRSquared = rSquared; if (minFare && minFare > 0) { const adjustedPredicted = samples.map(s => { - const raw = mlrResult.baseFare + mlrResult.kmRate * s.distance_km + mlrResult.minRate * s.duration_min; + const raw = mlrResult.baseFare + + mlrResult.kmRate * s.distance_km + + mlrResult.minRate * s.duration_min; return Math.max(raw, minFare); }); const adjRmse = calcRMSE(actualPrices, adjustedPredicted); - const adjRsq = calcRSquared(actualPrices, adjustedPredicted); + const adjRsq = calcRSquared(actualPrices, adjustedPredicted); - // If minimum fare improves the fit, use it if (adjRmse < rmse) { - hasMinFare = true; - adjustedRMSE = adjRmse; + hasMinFare = true; + adjustedRMSE = adjRmse; adjustedRSquared = adjRsq; } } tier.regression = { - baseFare: mlrResult.baseFare, - kmRate: mlrResult.kmRate, - minRate: mlrResult.minRate, - minFare: minFare || 0, - rmse: adjustedRMSE, - rSquared: adjustedRSquared, + baseFare: mlrResult.baseFare, + kmRate: mlrResult.kmRate, + minRate: mlrResult.minRate, + minFare: minFare || 0, + rmse: adjustedRMSE, + rSquared: adjustedRSquared, sampleCount: samples.length, hasMinFare, + modelName, // carry through for display }; return tier; } -/** - * Run regression on all tiers. - */ +/** Run regression on all tiers. */ export function analyzeAllTiers(tiers: PricingTier[]): PricingTier[] { return tiers.map(tier => analyzeTier(tier)); } diff --git a/backend/pricing-engine/src/analysis/types.ts b/backend/pricing-engine/src/analysis/types.ts index 752d2b4a..6908a2a0 100644 --- a/backend/pricing-engine/src/analysis/types.ts +++ b/backend/pricing-engine/src/analysis/types.ts @@ -58,6 +58,8 @@ export interface RegressionResult { rSquared: number; sampleCount: number; hasMinFare: boolean; + /** Which regression model was selected: 'two-stage' | 'robust-MLR' */ + modelName: string; } export interface SurgeResult { diff --git a/backend/pricing-engine/src/index.ts b/backend/pricing-engine/src/index.ts index 3b6f73ec..9927741f 100644 --- a/backend/pricing-engine/src/index.ts +++ b/backend/pricing-engine/src/index.ts @@ -183,12 +183,25 @@ async function processCompetitor( countryCode: row.country_code, })); + // Known receipts for formula validation. + // Add real receipts here as they are collected — the engine will print + // predicted vs actual with % error so you can judge formula quality at a glance. + const knownReceipts = comp.competitor_name === 'com.taxif.passenger' ? [ + { + label: 'TaxiF receipt 2026-06-11 (Amman)', + distanceKm: 2.17, + durationMin: 6 + 38 / 60, // 6 min 38 sec + actualPrice: 1.15, // 1.18 JOD total − 0.03 BookingFee + }, + ] : []; + const report = await runAnalysis(samples, { competitorName: comp.competitor_name, - countryCode: comp.country_code, - cleanOutliers: true, + countryCode: comp.country_code, + cleanOutliers: true, // surgeThresholdFraction defaults to 0.05 (5% of median price) — currency-agnostic - tierCount: 3, + tierCount: 3, + knownReceipts, }); // --- Compute peak hours once, reuse in both formulas and surge insights --- diff --git a/backend/pricing-engine/src/utils/math.ts b/backend/pricing-engine/src/utils/math.ts index 924c58e2..3fd1cfb9 100644 --- a/backend/pricing-engine/src/utils/math.ts +++ b/backend/pricing-engine/src/utils/math.ts @@ -1,5 +1,9 @@ import { median, mean } from 'simple-statistics'; +// ───────────────────────────────────────────── +// Internal helpers +// ───────────────────────────────────────────── + function pearsonCorr(x: number[], y: number[]): number { const n = Math.min(x.length, y.length); if (n < 3) return 0; @@ -17,90 +21,6 @@ function pearsonCorr(x: number[], y: number[]): number { return denom === 0 ? 0 : num / denom; } -export function multipleLinearRegression( - samples: Array<{ distance_km: number; duration_min: number; price: number }> -): { baseFare: number; kmRate: number; minRate: number } | null { - const n = samples.length; - if (n < 3) return null; - - const dists = samples.map(s => s.distance_km); - const durs = samples.map(s => s.duration_min); - const prices = samples.map(s => s.price); - const corr = pearsonCorr(dists, durs); - const lambda = Math.abs(corr) > 0.85 ? 0.5 : 0.01; - - let sumX1 = 0, sumX2 = 0, sumY = 0; - let sumX1Sq = 0, sumX2Sq = 0, sumX1X2 = 0; - let sumX1Y = 0, sumX2Y = 0; - - for (const s of samples) { - const x1 = s.distance_km, x2 = s.duration_min, y = s.price; - sumX1 += x1; sumX2 += x2; sumY += y; - sumX1Sq += x1 * x1; sumX2Sq += x2 * x2; sumX1X2 += x1 * x2; - sumX1Y += x1 * y; sumX2Y += x2 * y; - } - - const A = [ - [n, sumX1, sumX2], - [sumX1, sumX1Sq + lambda, sumX1X2], - [sumX2, sumX1X2, sumX2Sq + lambda], - ]; - const B = [sumY, sumX1Y, sumX2Y]; - - try { - const beta = gaussianElimination(A, B); - let baseFare = Math.max(0, beta[0]); - let kmRate = Math.max(0, beta[1]); - let minRate = Math.max(0, beta[2]); - - const predFull = samples.map(s => baseFare + kmRate * s.distance_km + minRate * s.duration_min); - const rmseFull = calcRMSE(prices, predFull); - - const ratioK = dists.reduce((a, d, i) => d > 0 ? a + prices[i] / d : a, 0) / dists.filter(d => d > 0).length; - if (!isFinite(ratioK)) return { baseFare, kmRate, minRate }; - - const predDistOnly = dists.map(d => ratioK * d); - const rmseDist = calcRMSE(prices, predDistOnly); - - if (rmseDist <= rmseFull * 1.10) { - return { baseFare: 0, kmRate: Math.round(ratioK * 1000) / 1000, minRate: 0 }; - } - - return { baseFare, kmRate, minRate }; - } catch { - return null; - } -} - -export function robustMultipleLinearRegression( - samples: Array<{ distance_km: number; duration_min: number; price: number }>, - maxIterations: number = 4 -): { baseFare: number; kmRate: number; minRate: number } | null { - let currentSamples = [...samples]; - let bestModel = multipleLinearRegression(currentSamples); - if (!bestModel) return null; - - const prices = samples.map(s => s.price).sort((a, b) => a - b); - const medianPrice = prices[Math.floor(prices.length / 2)]; - const fixedThreshold = Math.max(medianPrice * 0.3, 0.1); - - for (let i = 0; i < maxIterations; i++) { - const predicted = currentSamples.map( - s => bestModel!.baseFare + bestModel!.kmRate * s.distance_km + bestModel!.minRate * s.duration_min - ); - const actual = currentSamples.map(s => s.price); - const inliers = currentSamples.filter((_, idx) => actual[idx] - predicted[idx] < fixedThreshold); - - if (inliers.length < Math.max(5, samples.length * 0.3)) break; - if (inliers.length === currentSamples.length) break; - currentSamples = inliers; - const newModel = multipleLinearRegression(currentSamples); - if (!newModel) break; - bestModel = newModel; - } - return bestModel; -} - function gaussianElimination(A: number[][], B: number[]): number[] { const n = A.length; const a = A.map(row => [...row]); @@ -130,10 +50,208 @@ function gaussianElimination(A: number[][], B: number[]): number[] { return x; } +// ───────────────────────────────────────────── +// Core regression — full 3-parameter model +// price = baseFare + kmRate × dist + minRate × dur +// ───────────────────────────────────────────── + +export function multipleLinearRegression( + samples: Array<{ distance_km: number; duration_min: number; price: number }> +): { baseFare: number; kmRate: number; minRate: number } | null { + const n = samples.length; + if (n < 3) return null; + + const dists = samples.map(s => s.distance_km); + const durs = samples.map(s => s.duration_min); + const prices = samples.map(s => s.price); + + const corr = pearsonCorr(dists, durs); + // Stronger ridge when predictors are collinear (typical in taxi data) + const lambda = Math.abs(corr) > 0.85 ? 1.5 : 0.05; + + let sumX1 = 0, sumX2 = 0, sumY = 0; + let sumX1Sq = 0, sumX2Sq = 0, sumX1X2 = 0; + let sumX1Y = 0, sumX2Y = 0; + + for (const s of samples) { + const x1 = s.distance_km, x2 = s.duration_min, y = s.price; + sumX1 += x1; sumX2 += x2; sumY += y; + sumX1Sq += x1 * x1; sumX2Sq += x2 * x2; sumX1X2 += x1 * x2; + sumX1Y += x1 * y; sumX2Y += x2 * y; + } + + const A = [ + [n, sumX1, sumX2 ], + [sumX1, sumX1Sq + lambda, sumX1X2 ], + [sumX2, sumX1X2, sumX2Sq + lambda], + ]; + const B = [sumY, sumX1Y, sumX2Y]; + + try { + const beta = gaussianElimination(A, B); + return { + baseFare: Math.max(0, beta[0]), + kmRate: Math.max(0, beta[1]), + minRate: Math.max(0, beta[2]), + }; + } catch { + return null; + } +} + +// ───────────────────────────────────────────── +// Two-Stage Regression +// Stage 1: estimate flag fall from shortest rides +// Stage 2: regress residuals on (dist, dur) with no intercept +// ───────────────────────────────────────────── + +/** + * Stage 1 — Estimate the flag fall (فتحة العداد / meter opening charge). + * + * Takes the shortest 20% of rides by distance (min 5 samples) and fits + * a simple linear model: price ~ intercept + slope × dist. + * The intercept is the flag fall estimate. + * + * Clamped to [0, 65% of median price] to avoid unreasonable values. + */ +export function estimateFlagFall( + samples: Array<{ distance_km: number; duration_min: number; price: number }> +): number { + if (samples.length < 5) return 0; + + const sorted = [...samples].sort((a, b) => a.distance_km - b.distance_km); + const shortCount = Math.max(5, Math.floor(sorted.length * 0.20)); + const shortRides = sorted.slice(0, shortCount); + + // Simple OLS: price ~ a + b × dist on short rides only + const n = shortRides.length; + const sumX = shortRides.reduce((s, r) => s + r.distance_km, 0); + const sumY = shortRides.reduce((s, r) => s + r.price, 0); + const sumXX = shortRides.reduce((s, r) => s + r.distance_km ** 2, 0); + const sumXY = shortRides.reduce((s, r) => s + r.distance_km * r.price, 0); + + const denom = n * sumXX - sumX * sumX; + if (Math.abs(denom) < 1e-10) { + // Degenerate case — return a safe lower-bound estimate + return Math.min(...shortRides.map(r => r.price)) * 0.4; + } + + const slope = (n * sumXY - sumX * sumY) / denom; + const intercept = (sumY - slope * sumX) / n; + + const medianPrice = median(samples.map(s => s.price)); + return Math.max(0, Math.min(intercept, medianPrice * 0.65)); +} + +/** + * Stage 2 — Two-variable regression with no intercept. + * Fits: (price − fixedBase) ~ kmRate × dist + minRate × dur + * + * Uses ridge regularization (lambda = 2.0 when dist/dur are collinear) + * to distribute the effect between km and min rather than collapsing to one. + */ +function twoVarNoIntercept( + samples: Array<{ distance_km: number; duration_min: number; price: number }>, + fixedBase: number +): { kmRate: number; minRate: number } | null { + const n = samples.length; + if (n < 3) return null; + + const dists = samples.map(s => s.distance_km); + const durs = samples.map(s => s.duration_min); + const corr = pearsonCorr(dists, durs); + + // Higher ridge when predictors are correlated — forces balance between km and min + const lambda = Math.abs(corr) > 0.85 ? 2.0 : 0.5; + + let s11 = 0, s22 = 0, s12 = 0, s1y = 0, s2y = 0; + for (let i = 0; i < n; i++) { + const x1 = dists[i], x2 = durs[i]; + const y = samples[i].price - fixedBase; + s11 += x1 * x1; s22 += x2 * x2; s12 += x1 * x2; + s1y += x1 * y; s2y += x2 * y; + } + + // Solve 2×2 ridge system: + // [ s11+λ s12 ] [ kmRate ] [ s1y ] + // [ s12 s22+λ ] [ minRate ] = [ s2y ] + const a = s11 + lambda, b = s12, d = s22 + lambda; + const det = a * d - b * b; + if (Math.abs(det) < 1e-12) return null; + + return { + kmRate: Math.max(0, (s1y * d - s2y * b) / det), + minRate: Math.max(0, (a * s2y - b * s1y) / det), + }; +} + +/** + * Two-Stage Regression (main entry point for the engine). + * + * Properly decomposes taxi pricing into three components: + * price = baseFare (flag fall) + kmRate × dist + minRate × dur + * + * Stage 1 fixes baseFare from shortest rides. + * Stage 2 fits kmRate and minRate on residuals. + * + * This avoids the distance/duration collinearity problem by removing + * the constant component first. + */ +export function twoStageRegression( + samples: Array<{ distance_km: number; duration_min: number; price: number }> +): { baseFare: number; kmRate: number; minRate: number } | null { + if (samples.length < 5) return null; + + const baseFare = estimateFlagFall(samples); + const rates = twoVarNoIntercept(samples, baseFare); + if (!rates) return null; + + return { baseFare, kmRate: rates.kmRate, minRate: rates.minRate }; +} + +// ───────────────────────────────────────────── +// Robust regression (iterative outlier removal) +// ───────────────────────────────────────────── + +export function robustMultipleLinearRegression( + samples: Array<{ distance_km: number; duration_min: number; price: number }>, + maxIterations: number = 4 +): { baseFare: number; kmRate: number; minRate: number } | null { + let currentSamples = [...samples]; + let bestModel = multipleLinearRegression(currentSamples); + if (!bestModel) return null; + + const prices = samples.map(s => s.price).sort((a, b) => a - b); + const medianPrice = prices[Math.floor(prices.length / 2)]; + const fixedThreshold = Math.max(medianPrice * 0.3, 0.1); + + for (let i = 0; i < maxIterations; i++) { + const predicted = currentSamples.map( + s => bestModel!.baseFare + bestModel!.kmRate * s.distance_km + bestModel!.minRate * s.duration_min + ); + const actual = currentSamples.map(s => s.price); + const inliers = currentSamples.filter((_, idx) => actual[idx] - predicted[idx] < fixedThreshold); + + if (inliers.length < Math.max(5, samples.length * 0.3)) break; + if (inliers.length === currentSamples.length) break; + currentSamples = inliers; + const newModel = multipleLinearRegression(currentSamples); + if (!newModel) break; + bestModel = newModel; + } + return bestModel; +} + +// ───────────────────────────────────────────── +// Statistics utilities +// ───────────────────────────────────────────── + export function calcRMSE(actual: number[], predicted: number[]): number { const n = Math.min(actual.length, predicted.length); if (n === 0) return Infinity; - return Math.sqrt(actual.reduce((sum, a, i) => i < predicted.length ? sum + (a - predicted[i]) ** 2 : sum, 0) / n); + return Math.sqrt( + actual.reduce((sum, a, i) => i < predicted.length ? sum + (a - predicted[i]) ** 2 : sum, 0) / n + ); } export function calcRSquared(actual: number[], predicted: number[]): number { @@ -149,27 +267,26 @@ export function findInliersMAD(values: number[], threshold: number = 3.5): numbe const med = median(values); const mad = median(values.map(v => Math.abs(v - med))); if (mad === 0) return values.map((_, i) => i); - return values.map((v, i) => ({ v, i, z: 0.6745 * Math.abs(v - med) / mad })) - .filter(x => x.z < threshold).map(x => x.i); + return values + .map((v, i) => ({ v, i, z: 0.6745 * Math.abs(v - med) / mad })) + .filter(x => x.z < threshold) + .map(x => x.i); } -/** - * Compute K-Means inertia (sum of squared distances from each point to its centroid). - * Lower inertia = better clustering. - */ +// ───────────────────────────────────────────── +// K-Means clustering (multi-run for stability) +// ───────────────────────────────────────────── + function calcInertia(values: number[], assignments: number[], centroids: number[]): number { return values.reduce((sum, v, i) => sum + (v - centroids[assignments[i]]) ** 2, 0); } -/** - * Single K-Means run. Returns assignments, centroids, and inertia. - */ function kMeansOnce( values: number[], k: number, maxIterations: number ): { assignments: number[]; centroids: number[]; inertia: number } { - // K-Means++ seeding for better initialisation + // K-Means++ seeding const centroids: number[] = []; centroids.push(values[Math.floor(Math.random() * values.length)]); for (let c = 1; c < k; c++) { @@ -180,7 +297,6 @@ function kMeansOnce( r -= dists[i]; if (r <= 0) { centroids.push(values[i]); break; } } - // Fallback: if loop exits without pushing (floating point edge case) if (centroids.length < c + 1) centroids.push(values[values.length - 1]); } @@ -202,14 +318,12 @@ function kMeansOnce( } } - const inertia = calcInertia(values, assignments, centroids); - return { assignments, centroids, inertia }; + return { assignments, centroids, inertia: calcInertia(values, assignments, centroids) }; } /** - * K-Means clustering with multiple restarts. - * Runs `runs` times and returns the assignment with the lowest inertia, - * eliminating randomness instability across different executions. + * K-Means with multiple restarts — picks the run with lowest inertia + * to eliminate randomness instability across executions. */ export function kMeans( values: number[], @@ -219,30 +333,30 @@ export function kMeans( ): number[] { if (values.length < k) return values.map(() => 0); - let bestResult: { assignments: number[]; centroids: number[]; inertia: number } | null = null; - + let best: ReturnType | null = null; for (let run = 0; run < runs; run++) { const result = kMeansOnce(values, k, maxIterations); - if (bestResult === null || result.inertia < bestResult.inertia) { - bestResult = result; - } + if (!best || result.inertia < best.inertia) best = result; } - // Re-order cluster indices so label 0 = lowest centroid (economy), etc. - const { assignments, centroids } = bestResult!; + const { assignments, centroids } = best!; const order = centroids.map((c, i) => ({ c, i })).sort((a, b) => a.c - b.c); - const map = new Map(order.map((item, idx) => [item.i, idx])); + const map = new Map(order.map((item, idx) => [item.i, idx])); return assignments.map(a => map.get(a)!); } +// ───────────────────────────────────────────── +// Minimum fare detection +// ───────────────────────────────────────────── + export function detectMinimumFare(distances: number[], prices: number[], kmRate: number): number | null { if (distances.length < 5) return null; - const pairs = distances.map((d, i) => ({ d, p: prices[i] })).sort((a, b) => a.d - b.d); + const pairs = distances.map((d, i) => ({ d, p: prices[i] })).sort((a, b) => a.d - b.d); const shortCount = Math.max(5, Math.floor(pairs.length * 0.3)); const shortRides = pairs.slice(0, shortCount); - const residuals = shortRides.map(({ d, p }) => p - kmRate * d).sort((a, b) => a - b); - const trimIdx = Math.max(0, Math.floor(residuals.length * 0.1)); - const trimmed = residuals.slice(trimIdx, residuals.length - trimIdx); - const estimate = trimmed.length > 0 ? Math.max(...trimmed) : 0; + const residuals = shortRides.map(({ d, p }) => p - kmRate * d).sort((a, b) => a - b); + const trimIdx = Math.max(0, Math.floor(residuals.length * 0.1)); + const trimmed = residuals.slice(trimIdx, residuals.length - trimIdx); + const estimate = trimmed.length > 0 ? Math.max(...trimmed) : 0; return estimate > 0 ? Math.round(estimate * 100) / 100 : null; } diff --git a/backend/ride/rides/finish_ride_updates.php b/backend/ride/rides/finish_ride_updates.php index 6927be4f..b5d1406d 100644 --- a/backend/ride/rides/finish_ride_updates.php +++ b/backend/ride/rides/finish_ride_updates.php @@ -120,7 +120,25 @@ try { // Fixed-price types, Speed & Awfar: use quoted price as-is $fixedPriceTypes = ['Speed', 'Fixed Price', 'Awfar Car']; if (in_array($carType, $fixedPriceTypes)) { - $finalPrice = $quotedPrice; + $finalPrice = $quotedPrice; // Fallback if Redis fails + + // 🆕 Immutable Fare Lock: Force use of Redis locked price + try { + global $redis; + if ($redis) { + $lockedPrice = $redis->get("ride_locked_price_{$rideId}"); + if ($lockedPrice !== false) { + $finalPrice = floatval($lockedPrice); + error_log("[finish_ride_updates] Using Redis Locked Price for ride {$rideId}: {$finalPrice}"); + } else { + error_log("[finish_ride_updates] Redis Locked Price not found for ride {$rideId}. Using DB quoted price."); + } + } else { + error_log("[finish_ride_updates] Global Redis instance not available, using DB quoted price."); + } + } catch (Exception $e) { + error_log("[finish_ride_updates] Redis Error (reading locked price): " . $e->getMessage()); + } } else { // Variable pricing: calculate from actual distance $cleanDist = preg_replace('/[^0-9.]/', '', $actualDistance); diff --git a/backend/ride/rides/start_ride.php b/backend/ride/rides/start_ride.php index aff4cc58..ae165bf5 100644 --- a/backend/ride/rides/start_ride.php +++ b/backend/ride/rides/start_ride.php @@ -36,6 +36,29 @@ try { $stmtMainRide = $con->prepare("UPDATE ride SET status = ?, rideTimeStart = NOW() WHERE id = ?"); $stmtMainRide->execute([$status, $ride_id]); + // 🆕 Immutable Fare Lock: Save agreed fixed price in Redis + try { + $stmtRideInfo = $con->prepare("SELECT price, car_type FROM ride WHERE id = ? LIMIT 1"); + $stmtRideInfo->execute([$ride_id]); + $rideInfo = $stmtRideInfo->fetch(PDO::FETCH_ASSOC); + if ($rideInfo) { + $agreedPrice = floatval($rideInfo['price']); + $carTypeStr = $rideInfo['car_type'] ?? 'Fixed Price'; + $fixedPriceTypes = ['Speed', 'Fixed Price', 'Awfar Car']; + if (in_array($carTypeStr, $fixedPriceTypes)) { + global $redis; + if ($redis) { + $redis->setex("ride_locked_price_{$ride_id}", 86400, $agreedPrice); + error_log("[start_ride] Locked Fixed Price for ride {$ride_id}: {$agreedPrice}"); + } else { + error_log("[start_ride] Failed to lock price: Global Redis instance not available."); + } + } + } + } catch (Exception $e) { + error_log("[start_ride] Redis Error (locking price): " . $e->getMessage()); + } + // تحديث أو إدخال في جدول Driver Orders $checkSql = "SELECT `order_id` FROM `driver_orders` WHERE `order_id` = ?"; $checkStmt = $con->prepare($checkSql); @@ -61,21 +84,22 @@ try { // 2.5 تصفير الدين من Redis عند بدء الرحلة (كما طلبت) if ($passenger_id) { try { - $redis = new Redis(); - $redis->connect('127.0.0.1', 6379); - $redisKey = "passenger_debt_" . $passenger_id; - - // قراءة الدين الحالي من Redis قبل الحذف (إن لزم الأمر للتسجيل مستقبلاً) - $currentDebt = (float) $redis->get($redisKey); - - // تصفير / حذف الدين - $redis->del($redisKey); - - // يمكنك هنا أيضاً إدراج حركة معاكسة في جدول passengerWallet إذا أردت تسوية قاعدة البيانات - if ($currentDebt < 0) { - $positiveOffset = abs($currentDebt); - $stmtWallet = $con->prepare("INSERT INTO `passengerWallet` (passenger_id, balance) VALUES (?, ?)"); - $stmtWallet->execute([$passenger_id, $positiveOffset]); + global $redis; + if ($redis) { + $redisKey = "passenger_debt_" . $passenger_id; + + // قراءة الدين الحالي من Redis قبل الحذف (إن لزم الأمر للتسجيل مستقبلاً) + $currentDebt = (float) $redis->get($redisKey); + + // تصفير / حذف الدين + $redis->del($redisKey); + + // يمكنك هنا أيضاً إدراج حركة معاكسة في جدول passengerWallet إذا أردت تسوية قاعدة البيانات + if ($currentDebt < 0) { + $positiveOffset = abs($currentDebt); + $stmtWallet = $con->prepare("INSERT INTO `passengerWallet` (passenger_id, balance) VALUES (?, ?)"); + $stmtWallet->execute([$passenger_id, $positiveOffset]); + } } } catch (Exception $e) { error_log("Redis Error (zeroing debt): " . $e->getMessage());