SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
3.6 KB · 73 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Validation stricte des observations canoniques (sans dépendance) :4 * prix ≤ 0, devise ≠ CAD, unité inconnue, conversion incohérente, aberration5 * par rapport aux prix récents (MAD/IQR) et aux bornes métier (référence6 * interne ×0,25…×4). Les rejets sont conservés en base, jamais supprimés.7 */8import { isKnownUnit } from "../units";9import { detectOutliers } from "../pricing";10import type { CanonicalObservation, ValidationResult } from "./types";1112export interface ValidateContext {13  /** prix récents (unité canonique) par article, autres sources incluses */14  recentPrices: (itemCode: string) => number[];15  referencePrice: (itemCode: string) => number | null;16}1718const UNIT_MAX: Record<string, number> = { pi2: 400, pi_lin: 300, unit: 200000, lump: 500000, m3: 2000, kg: 500, lb: 500, L: 500, gal: 500, h: 1000, day: 5000, pi3: 500, m2: 5000 };1920export function validateObservations(obs: CanonicalObservation[], ctx: ValidateContext): ValidationResult {21  const accepted: CanonicalObservation[] = [];22  const rejected: { obs: CanonicalObservation; reason: string }[] = [];23  const priceGroups = new Map<string, number[]>();2425  for (const o of obs) {26    const reason = basicReason(o, ctx);27    if (reason) { rejected.push({ obs: o, reason }); continue; }28    accepted.push(o);29    if (o.kind === "price") priceGroups.set(o.itemCode, [...(priceGroups.get(o.itemCode) ?? []), o.totalCost]);30  }31  // aberrations statistiques : observation vs prix récents (autres sources) + celles du lot32  const final: CanonicalObservation[] = [];33  for (const o of accepted) {34    if (o.kind !== "price") { final.push(o); continue; }35    const pool = [...ctx.recentPrices(o.itemCode), ...(priceGroups.get(o.itemCode) ?? []).filter((v) => v !== o.totalCost)];36    if (pool.length >= 2) {37      const arr = [...pool, o.totalCost];38      const out = detectOutliers(arr);39      const hit = out.find((x) => x.idx === arr.length - 1);40      if (hit) { rejected.push({ obs: o, reason: `aberration : ${hit.reason}` }); continue; }41    }42    final.push(o);43  }44  return { accepted: final, rejected };45}4647function basicReason(o: CanonicalObservation, ctx: ValidateContext): string | null {48  if (o.kind === "index") {49    if (!Number.isFinite(o.value) || o.value <= 0) return "valeur d'indice invalide";50    if (!/^\d{4}-\d{2}-\d{2}$/.test(o.period)) return "période invalide";51    return null;52  }53  if (o.kind === "labour") {54    if (!(o.baseWage > 10 && o.baseWage < 200)) return `taux horaire implausible (${o.baseWage})`;55    if (!(o.totalEmployerCost >= o.baseWage && o.totalEmployerCost < o.baseWage * 2.2)) return `coût employeur incohérent (${o.totalEmployerCost} vs ${o.baseWage})`;56    if (!/^\d{4}-\d{2}-\d{2}$/.test(o.effectiveFrom)) return "date d'entrée en vigueur invalide";57    return null;58  }59  if (o.currency !== "CAD") return `devise impossible (${o.currency})`;60  if (!(o.rawPrice > 0)) return "prix ≤ 0";61  if (!isKnownUnit(o.sourceUnit)) return `unité inconnue « ${o.sourceUnit} »`;62  if (!(o.conversionFactor > 0) || !Number.isFinite(o.conversionFactor)) return "facteur de conversion invalide";63  if (!(o.totalCost > 0)) return "prix canonique ≤ 0";64  const max = UNIT_MAX[o.canonicalUnit] ?? 1e6;65  if (o.totalCost > max) return `prix par ${o.canonicalUnit} hors bornes (${o.totalCost.toFixed(2)} > ${max}) — emballage probablement mal interprété`;66  const ref = ctx.referencePrice(o.itemCode);67  if (ref != null && ref > 0) {68    const ratio = o.totalCost / ref;69    if (ratio > 4 || ratio < 0.25) return `hors bornes métier : ${ratio.toFixed(2)}× le prix de référence (${ref})`;70  }71  return null;72}73