// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Property Vector (§119-123, 154-155) : représentation canonique textuelle * déterministe + vecteur numérique déterministe (one-hot des catégories + * numériques normalisés). Modèle « vrai-prix-technical-v1 » : aucun appel * externe, reproductible ; l'interface `Embedder` permet de brancher plus tard * un modèle d'embedding réel. Module PUR. */ import type { MergedFacts } from "./merge"; export const EMBEDDING_MODEL = "vrai-prix-technical-v1"; export interface Embedder { model: string; embed(canonicalText: string, facts: MergedFacts): Promise | number[] } const CATS = { buildingType: ["detached", "semi_detached", "row", "plex", "condo", "chalet", "mobile", "other"], quality: ["economy", "standard", "superior", "prestige"], basement: ["none", "crawl", "unfinished", "partial", "finished", "walkout"], garage: ["none", "attached", "detached", "integrated", "carport"], roof: ["asphalt_shingle", "metal", "membrane", "cedar", "slate_tile"], roofGeometry: ["gable", "hip", "flat", "mansard", "complex"], windows: ["pvc", "hybrid", "aluminum", "wood"], heating: ["electric_baseboard", "heat_pump", "furnace_electric", "furnace_gas", "furnace_oil", "hydronic", "geothermal", "wood"], foundation: ["poured_concrete", "concrete_block", "slab_on_grade", "piers", "stone"], structure: ["wood_frame", "steel", "concrete", "log", "masonry"], siding: ["vinyl", "brick", "fiber_cement", "wood", "stone", "stucco", "aluminum", "steel"], flooring: ["hardwood", "engineered", "vinyl_plank", "laminate", "ceramic", "carpet"], pool: ["none", "above_ground", "inground"], condition: ["poor", "below_average", "average", "good", "very_good", "excellent", "renovated", "new"], } as const; const CONDITION_GROUPS = ["structure", "roof", "exterior", "windows", "interior", "kitchen", "bathroom", "mechanical", "electrical", "site"]; /** 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. */ export const EMBEDDING_DIMENSION = 8 + 4 * 3 + 6 + 5 + 5 + 5 + 4 + 8 + 5 + 5 + 8 + 6 + 3 + 12 + 10; function oneHot(list: readonly string[], v: string | null | undefined): number[] { return list.map((x) => (x === v ? 1 : 0)); } function shares(list: readonly string[], m: Record | null): number[] { return list.map((x) => Math.max(0, Math.min(1, m?.[x] ?? 0))); } export function canonicalText(m: MergedFacts): string { const t = m.buildingType.value.replace(/_/g, " "); const parts = [ `${t.charAt(0).toUpperCase()}${t.slice(1)}.`, m.yearBuilt.value ? `${m.yearBuilt.value}.` : "Year unknown.", `${Math.round(m.grossFloorAreaSqft.value)} sqft.`, `${Math.round(m.stories.value)} ${m.stories.value > 1 ? "stories" : "story"}.`, `${m.structure.value.replace(/_/g, " ")}.`, `${m.foundation.value.replace(/_/g, " ")} foundation.`, `${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.`, `${m.roof.value.replace(/_/g, " ")} ${m.roofGeometry.value} roof.`, m.garage.value.type === "none" ? "No garage." : `${m.garage.value.type} ${m.garage.value.spaces}-car garage.`, `${m.basement.value} basement${m.basementFinishedPct.value ? ` ${Math.round(m.basementFinishedPct.value * 100)}% finished` : ""}.`, `${m.kitchenQuality.value} kitchen.`, `${m.bathrooms.value} bathrooms${m.powderRooms.value ? `, ${m.powderRooms.value} powder rooms` : ""}.`, `${m.bathroomQuality.value} bathrooms quality.`, 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.` : "", `${m.heating.value.replace(/_/g, " ")}${m.hasAirConditioning.value ? ", air conditioning" : ""}${m.hasAirExchanger.value ? ", air exchanger" : ""}.`, `${m.windows.value} windows.`, m.deckSqft.value ? `Deck ${Math.round(m.deckSqft.value)} sqft.` : "", m.pool.value !== "none" ? `${m.pool.value.replace(/_/g, " ")} pool.` : "", `Overall quality ${m.quality.value}.`, Object.keys(m.conditions).length ? `Condition ${Object.entries(m.conditions).map(([k, v]) => `${k} ${v}`).join(", ")}.` : "", ]; return parts.filter(Boolean).join("\n"); } export function technicalVector(m: MergedFacts): number[] { 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); }; const v = [ ...oneHot(CATS.buildingType, m.buildingType.value), ...oneHot(CATS.quality, m.quality.value), ...oneHot(CATS.quality, m.kitchenQuality.value), ...oneHot(CATS.quality, m.bathroomQuality.value), ...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), ...oneHot(CATS.windows, m.windows.value), ...oneHot(CATS.heating, m.heating.value), ...oneHot(CATS.foundation, m.foundation.value), ...oneHot(CATS.structure, m.structure.value), ...shares(CATS.siding, m.siding.value as Record), ...shares(CATS.flooring, m.flooring.value), ...oneHot(CATS.pool, m.pool.value), // numériques normalisés 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), 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), m.hasAirConditioning.value ? 1 : 0, m.hasAirExchanger.value ? 1 : 0, Math.min(1, m.basementFinishedPct.value), Math.min(1, m.garage.value.spaces / 3), ...CONDITION_GROUPS.map(condIdx), ]; if (v.length !== EMBEDDING_DIMENSION) throw new Error(`dimension du vecteur ${v.length} ≠ ${EMBEDDING_DIMENSION}`); return v.map((x) => Math.round(x * 10000) / 10000); } export const technicalEmbedder: Embedder = { model: EMBEDDING_MODEL, embed: (_t, facts) => technicalVector(facts) }; export function cosine(a: number[], b: number[]): number { let dot = 0, na = 0, nb = 0; 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]; } return na && nb ? dot / Math.sqrt(na * nb) : 0; } /** Vecteur de structure de coût (§201) : parts normalisées par grande famille. */ export function costProfileVector(categories: { category: string; adjusted: number }[]): Record { const fam: Record = { 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"] }; const total = categories.reduce((s, c) => s + c.adjusted, 0) || 1; const out: Record = {}; 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; return out; }