Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.
TypeScript 90.2%
JavaScript 3.5%
Python 3.4%
CSS 1.9%
HTML 0.6%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Property Vector (§119-123, 154-155) : représentation canonique textuelle4 * déterministe + vecteur numérique déterministe (one-hot des catégories +5 * numériques normalisés). Modèle « vrai-prix-technical-v1 » : aucun appel6 * externe, reproductible ; l'interface `Embedder` permet de brancher plus tard7 * un modèle d'embedding réel. Module PUR.8 */9import type { MergedFacts } from "./merge";1011export const EMBEDDING_MODEL = "vrai-prix-technical-v1";1213export interface Embedder { model: string; embed(canonicalText: string, facts: MergedFacts): Promise<number[]> | number[] }1415const CATS = {16 buildingType: ["detached", "semi_detached", "row", "plex", "condo", "chalet", "mobile", "other"],17 quality: ["economy", "standard", "superior", "prestige"],18 basement: ["none", "crawl", "unfinished", "partial", "finished", "walkout"],19 garage: ["none", "attached", "detached", "integrated", "carport"],20 roof: ["asphalt_shingle", "metal", "membrane", "cedar", "slate_tile"],21 roofGeometry: ["gable", "hip", "flat", "mansard", "complex"],22 windows: ["pvc", "hybrid", "aluminum", "wood"],23 heating: ["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas", "furnace_oil", "hydronic", "geothermal", "wood"],24 foundation: ["poured_concrete", "concrete_block", "slab_on_grade", "piers", "stone"],25 structure: ["wood_frame", "steel", "concrete", "log", "masonry"],26 siding: ["vinyl", "brick", "fiber_cement", "wood", "stone", "stucco", "aluminum", "steel"],27 flooring: ["hardwood", "engineered", "vinyl_plank", "laminate", "ceramic", "carpet"],28 pool: ["none", "above_ground", "inground"],29 condition: ["poor", "below_average", "average", "good", "very_good", "excellent", "renovated", "new"],30} as const;31const CONDITION_GROUPS = ["structure", "roof", "exterior", "windows", "interior", "kitchen", "bathroom", "mechanical", "electrical", "site"];3233/** Dimension fixe du vecteur (documentée) : 8+4×3+6+5+5+5+4+8+5+5+8+6+3 = 80 catégoriels + 12 numériques + 10 conditions = 102. */34export const EMBEDDING_DIMENSION = 8 + 4 * 3 + 6 + 5 + 5 + 5 + 4 + 8 + 5 + 5 + 8 + 6 + 3 + 12 + 10;3536function oneHot(list: readonly string[], v: string | null | undefined): number[] {37 return list.map((x) => (x === v ? 1 : 0));38}39function shares(list: readonly string[], m: Record<string, number | undefined> | null): number[] {40 return list.map((x) => Math.max(0, Math.min(1, m?.[x] ?? 0)));41}4243export function canonicalText(m: MergedFacts): string {44 const t = m.buildingType.value.replace(/_/g, " ");45 const parts = [46 `${t.charAt(0).toUpperCase()}${t.slice(1)}.`,47 m.yearBuilt.value ? `${m.yearBuilt.value}.` : "Year unknown.",48 `${Math.round(m.grossFloorAreaSqft.value)} sqft.`,49 `${Math.round(m.stories.value)} ${m.stories.value > 1 ? "stories" : "story"}.`,50 `${m.structure.value.replace(/_/g, " ")}.`,51 `${m.foundation.value.replace(/_/g, " ")} foundation.`,52 `${Object.entries(m.siding.value).sort((a, b) => (b[1] ?? 0) - (a[1] ?? 0)).map(([k, v]) => `${k.replace(/_/g, " ")} ${Math.round((v ?? 0) * 100)}%`).join(", ")} exterior.`,53 `${m.roof.value.replace(/_/g, " ")} ${m.roofGeometry.value} roof.`,54 m.garage.value.type === "none" ? "No garage." : `${m.garage.value.type} ${m.garage.value.spaces}-car garage.`,55 `${m.basement.value} basement${m.basementFinishedPct.value ? ` ${Math.round(m.basementFinishedPct.value * 100)}% finished` : ""}.`,56 `${m.kitchenQuality.value} kitchen.`,57 `${m.bathrooms.value} bathrooms${m.powderRooms.value ? `, ${m.powderRooms.value} powder rooms` : ""}.`,58 `${m.bathroomQuality.value} bathrooms quality.`,59 Object.keys(m.flooring.value ?? {}).length ? `${Object.entries(m.flooring.value).sort((a, b) => b[1] - a[1]).map(([k]) => k.replace(/_/g, " ")).join(" and ")} floors.` : "",60 `${m.heating.value.replace(/_/g, " ")}${m.hasAirConditioning.value ? ", air conditioning" : ""}${m.hasAirExchanger.value ? ", air exchanger" : ""}.`,61 `${m.windows.value} windows.`,62 m.deckSqft.value ? `Deck ${Math.round(m.deckSqft.value)} sqft.` : "",63 m.pool.value !== "none" ? `${m.pool.value.replace(/_/g, " ")} pool.` : "",64 `Overall quality ${m.quality.value}.`,65 Object.keys(m.conditions).length ? `Condition ${Object.entries(m.conditions).map(([k, v]) => `${k} ${v}`).join(", ")}.` : "",66 ];67 return parts.filter(Boolean).join("\n");68}6970export function technicalVector(m: MergedFacts): number[] {71 const condIdx = (g: string) => { const c = m.conditions[g]; const i = c ? CATS.condition.indexOf(c as (typeof CATS.condition)[number]) : -1; return i < 0 ? 0.5 : i / (CATS.condition.length - 1); };72 const v = [73 ...oneHot(CATS.buildingType, m.buildingType.value),74 ...oneHot(CATS.quality, m.quality.value), ...oneHot(CATS.quality, m.kitchenQuality.value), ...oneHot(CATS.quality, m.bathroomQuality.value),75 ...oneHot(CATS.basement, m.basement.value), ...oneHot(CATS.garage, m.garage.value.type), ...oneHot(CATS.roof, m.roof.value), ...oneHot(CATS.roofGeometry, m.roofGeometry.value),76 ...oneHot(CATS.windows, m.windows.value), ...oneHot(CATS.heating, m.heating.value), ...oneHot(CATS.foundation, m.foundation.value), ...oneHot(CATS.structure, m.structure.value),77 ...shares(CATS.siding, m.siding.value as Record<string, number | undefined>), ...shares(CATS.flooring, m.flooring.value), ...oneHot(CATS.pool, m.pool.value),78 // numériques normalisés79 Math.min(1, Math.log10(Math.max(200, m.grossFloorAreaSqft.value)) / 4.5), Math.min(1, m.stories.value / 4), Math.min(1, ((new Date().getFullYear() - (m.yearBuilt.value ?? 1980)) || 0) / 120),80 Math.min(1, m.bathrooms.value / 5), Math.min(1, m.powderRooms.value / 3), Math.min(1, (m.bedrooms.value ?? 3) / 6), Math.min(1, m.deckSqft.value / 1000), Math.min(1, m.drivewaySqft.value / 2000),81 m.hasAirConditioning.value ? 1 : 0, m.hasAirExchanger.value ? 1 : 0, Math.min(1, m.basementFinishedPct.value), Math.min(1, m.garage.value.spaces / 3),82 ...CONDITION_GROUPS.map(condIdx),83 ];84 if (v.length !== EMBEDDING_DIMENSION) throw new Error(`dimension du vecteur ${v.length} ≠ ${EMBEDDING_DIMENSION}`);85 return v.map((x) => Math.round(x * 10000) / 10000);86}8788export const technicalEmbedder: Embedder = { model: EMBEDDING_MODEL, embed: (_t, facts) => technicalVector(facts) };8990export function cosine(a: number[], b: number[]): number {91 let dot = 0, na = 0, nb = 0;92 for (let i = 0; i < Math.min(a.length, b.length); i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }93 return na && nb ? dot / Math.sqrt(na * nb) : 0;94}9596/** Vecteur de structure de coût (§201) : parts normalisées par grande famille. */97export function costProfileVector(categories: { category: string; adjusted: number }[]): Record<string, number> {98 const fam: Record<string, string[]> = { foundation: ["site", "excavation", "foundation"], structure: ["structure"], envelope: ["roofing", "envelope", "openings", "insulation", "finishes_ext"], finish: ["interior", "kitchen", "bathroom", "basement"], mechanical: ["plumbing", "hvac", "electrical"], exterior: ["garage", "exterior"] };99 const total = categories.reduce((s, c) => s + c.adjusted, 0) || 1;100 const out: Record<string, number> = {};101 for (const [k, cats] of Object.entries(fam)) out[k] = Math.round((1000 * categories.filter((c) => cats.includes(c.category)).reduce((s, c) => s + c.adjusted, 0)) / total) / 1000;102 return out;103}104