feat: F009 matching engine V1 — deterministic multi-criteria scoring

domain/scoring.ts: HARD_FILTER thresholds, DATA_QUALITY/CONFIDENCE modifier tables, ScoringWeightProfile type, DEFAULT_SCORING_PROFILES for Office/Retail/Light Industrial/Logistics/Production/Default (all sum to 1.00), HARD/SOFT_CRITERION_KEYS, MatchEngineOutput type.

features/matching/scoreCalculator.ts: applyHardFilters (asset type mismatch, area < 85% min, budget > 150%, excluded region → hard exclude; occupied → severe penalty), scoreArea/Location/Budget/Timing as ScoreFactor, scoreSoftFactor for 9 keys mapped to property.softFactors, calcDataQualityModifier/calcConfidenceModifier, calculateScore (resolves profile from need.weightingProfile + default, runs filters, computes normalized hard/soft scores, applies modifiers, classifies positive/negative factors, assembles MatchEngineOutput).

features/matching/tradeOffAnalyzer.ts: analyzeTradeOffs (6 patterns: location-vs-budget, area-vs-budget, timing-vs-dataQuality, prestige-vs-flexibility, futureSignal-vs-location, accessibility-vs-commute), analyzeRisks (future signal, data quality, budget, occupied, low confidence, missing critical fields), identifyMissingData (rentPricePerSqm, availabilityDate, softFactors, hardFacts, need.budgetRange).

features/matching/rankingEngine.ts: matchStrengthFromScore (>=78 STRONG, >=52 MODERATE, else WEAK), generateNextBestActions (score-based, future signal SCHEDULE, missing data VERIFY), buildFullMatch → full Match entity with ScoreBreakdown + explainabilitySummary + uncertaintyIndicators, rankMatches (score desc → resultType order → confidence desc), computeRankedMatches batch helper.

services/matchService.ts: computeMatch(need, property) and computeMatchesForNeed(needId) wired to engine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Sutter
2026-05-16 12:54:45 +02:00
parent 93b8000d48
commit 3152de004c
5 changed files with 1020 additions and 0 deletions
+414
View File
@@ -0,0 +1,414 @@
import type { Need } from '../../domain/need'
import type { Property } from '../../domain/property'
import type { ScoreFactor } from '../../domain/match'
import { ResultType, AvailabilityStatus, AssetType } from '../../domain/enums'
import {
HARD_FILTER,
DATA_QUALITY_MODIFIER,
CONFIDENCE_MODIFIER,
HARD_CRITERION_KEYS,
SOFT_FACTOR_KEYS,
DEFAULT_SCORING_PROFILES,
} from '../../domain/scoring'
import type { ScoringWeightProfile, HardFilterResult, MatchEngineOutput, SoftFactorKey } from '../../domain/scoring'
import { analyzeTradeOffs, analyzeRisks, identifyMissingData } from './tradeOffAnalyzer'
import { generateNextBestActions } from './rankingEngine'
// ── Profile resolution ────────────────────────────────────────────────────────
function resolveProfile(need: Need, property: Property): ScoringWeightProfile {
const base = { ...(DEFAULT_SCORING_PROFILES[property.assetType] ?? DEFAULT_SCORING_PROFILES.DEFAULT) }
const np = need.weightingProfile
if (!np) return base
// Apply need's custom core weights, then renormalize the full profile to 1.00
const CORE = ['area', 'location', 'budget', 'timing', 'prestige', 'accessibility', 'expansionPotential', 'flexibility']
for (const key of CORE) {
if (typeof np[key] === 'number') base[key] = np[key]
}
const total = Object.values(base).reduce((s, v) => s + v, 0)
if (total > 0) for (const key of Object.keys(base)) base[key] /= total
return base
}
// ── Hard Filters ──────────────────────────────────────────────────────────────
export function applyHardFilters(need: Need, property: Property): HardFilterResult {
const assetOk = property.assetType === need.assetType
|| property.assetType === AssetType.MIXED
|| need.assetType === AssetType.UNKNOWN
if (!assetOk) {
return { excluded: true, reason: `Nutzungstyp ${property.assetType} stimmt nicht mit ${need.assetType} überein`, severePenalty: 0 }
}
// Area: hard exclude below tolerance
const areaMin = need.requiredArea?.min ?? 0
const propArea = property.areaSqmMin ?? property.areaSqm
if (areaMin > 0 && propArea < areaMin * HARD_FILTER.AREA_MIN_TOLERANCE) {
return {
excluded: true,
reason: `Fläche ${propArea} m² unterschreitet Minimum ${areaMin} m² um mehr als ${Math.round((1 - HARD_FILTER.AREA_MIN_TOLERANCE) * 100)}%`,
severePenalty: 0,
}
}
// Region exclusion
const city = property.location.city.toLowerCase()
const excluded = (need.excludedLocations ?? []).map(l => l.toLowerCase())
if (excluded.some(e => city.includes(e) || e.includes(city))) {
return { excluded: true, reason: `Standort ${property.location.city} ist ausgeschlossen`, severePenalty: 0 }
}
// Budget: hard exclude if massively over
const maxBudget = need.budgetRange?.maxPerSqm ?? 0
if (maxBudget > 0 && property.rentPricePerSqm > maxBudget * HARD_FILTER.BUDGET_EXCLUSION_RATIO) {
return {
excluded: true,
reason: `Miete CHF ${property.rentPricePerSqm}/m² überschreitet Budget CHF ${maxBudget}/m² um mehr als ${Math.round((HARD_FILTER.BUDGET_EXCLUSION_RATIO - 1) * 100)}%`,
severePenalty: 0,
}
}
// Usage/zoning: occupied property is severe penalty, not exclude
if (property.availabilityStatus === AvailabilityStatus.OCCUPIED) {
return { excluded: false, reason: undefined, severePenalty: 25 }
}
return { excluded: false, reason: undefined, severePenalty: 0 }
}
// ── Hard Criterion Scorers ────────────────────────────────────────────────────
function scoreArea(need: Need, property: Property, weight: number): ScoreFactor {
const { min = 0, max = Infinity } = need.requiredArea ?? {}
const area = property.areaSqm
const areaMin = property.areaSqmMin ?? area
const areaMax = property.areaSqmMax ?? area
let score: number
let explanation: string
// Flexible property — check range overlap
const overlap = areaMin <= max && areaMax >= min
if (overlap) {
score = 100
explanation = `Fläche ${areaMin === areaMax ? `${area}` : `${areaMin}${areaMax}`} m² deckt Bedarf ${min}${max} m² ab`
} else if (area > max) {
const ratio = area / max
score = ratio <= HARD_FILTER.AREA_MAX_RATIO
? Math.max(40, Math.round(100 - (ratio - 1) * 50))
: 20
explanation = `Fläche ${area} m² überschreitet Maximum ${max} m² (${Math.round((ratio - 1) * 100)}% zu viel)`
} else {
// Area between tolerance and min — mild penalty
const ratio = area / min
score = Math.round(40 + ratio * 30)
explanation = `Fläche ${area} m² leicht unter Minimum ${min}`
}
return { criterion: 'area', weight, score, contribution: score * weight, explanation }
}
function scoreLocation(need: Need, property: Property, weight: number): ScoreFactor {
const city = property.location.city.toLowerCase()
const canton = (property.location.canton ?? '').toLowerCase()
const preferred = (need.preferredLocations ?? []).map(l => l.toLowerCase())
let score: number
let explanation: string
if (preferred.length === 0) {
score = 70
explanation = 'Kein Standortwunsch — neutral bewertet'
} else if (preferred.some(p => city.includes(p) || p.includes(city))) {
score = 100
explanation = `Standort ${property.location.city} entspricht Präferenz`
} else if (canton && preferred.some(p => p.includes(canton) || canton.includes(p))) {
score = 60
explanation = `Gleicher Kanton wie Präferenz (${property.location.canton})`
} else {
score = 35
explanation = `Standort ${property.location.city} nicht in Präferenzliste`
}
return { criterion: 'location', weight, score, contribution: score * weight, explanation }
}
function scoreBudget(need: Need, property: Property, weight: number): ScoreFactor {
const maxBudget = need.budgetRange?.maxPerSqm ?? 0
const rent = property.rentPricePerSqm
let score: number
let explanation: string
if (maxBudget <= 0) {
score = 60
explanation = 'Kein Budget angegeben — neutral bewertet'
} else if (rent <= maxBudget) {
const ratio = rent / maxBudget
// Very cheap can indicate quality issues — slight penalty below 50% of budget
score = ratio >= 0.50 ? 100 : 88
explanation = `Miete CHF ${rent}/m² liegt ${Math.round((1 - ratio) * 100)}% unter Budget CHF ${maxBudget}/m²`
} else {
const overRatio = rent / maxBudget
if (overRatio <= HARD_FILTER.BUDGET_MODERATE_RATIO) {
score = 75
explanation = `Miete CHF ${rent}/m² leicht über Budget (+${Math.round((overRatio - 1) * 100)}%)`
} else if (overRatio <= HARD_FILTER.BUDGET_SEVERE_RATIO) {
score = 45
explanation = `Miete CHF ${rent}/m² merklich über Budget (+${Math.round((overRatio - 1) * 100)}%)`
} else {
score = 20
explanation = `Miete CHF ${rent}/m² stark über Budget (+${Math.round((overRatio - 1) * 100)}%)`
}
}
return { criterion: 'budget', weight, score, contribution: score * weight, explanation }
}
function scoreTiming(need: Need, property: Property, weight: number): ScoreFactor {
const isFutureSignal = property.resultType === ResultType.FUTURE_AVAILABILITY
const rawDate = property.availabilityDate
const propDate = rawDate && rawDate !== '' ? new Date(rawDate) : null
const earliest = need.timing?.earliestMoveIn ? new Date(need.timing.earliestMoveIn) : null
const latest = need.timing?.latestMoveIn ? new Date(need.timing.latestMoveIn) : null
const graceMs = HARD_FILTER.TIMING_GRACE_DAYS * 86_400_000
let score: number
let explanation: string
// CRITICAL RULE: Future availability is never treated as confirmed
if (isFutureSignal) {
if (!propDate) {
score = 30
explanation = 'Zukünftiges Signal — kein Datum, Verfügbarkeit unbestätigt'
} else if (latest && propDate.getTime() > latest.getTime() + graceMs) {
score = 20
explanation = `Zukünftiges Signal — erwartet ${rawDate}, nach gewünschtem Zeitfenster (unbestätigt)`
} else if (earliest && propDate.getTime() < earliest.getTime()) {
score = 50
explanation = `Zukünftiges Signal — erwartet ${rawDate}, vor gewünschtem Einzug (unbestätigt)`
} else {
score = 42
explanation = `Zukünftiges Signal — Zeitfenster passt, Verfügbarkeit jedoch unbestätigt`
}
return { criterion: 'timing', weight, score, contribution: score * weight, explanation }
}
const isNow = property.availabilityStatus === AvailabilityStatus.AVAILABLE_NOW
|| property.availabilityStatus === AvailabilityStatus.AVAILABLE_SOON
if (isNow) {
const tooEarly = earliest && new Date() < earliest
score = tooEarly ? 80 : 100
explanation = tooEarly
? `Sofort verfügbar — Einzug jedoch erst ab ${need.timing?.earliestMoveIn} geplant`
: 'Sofort verfügbar — entspricht Verfügbarkeitswunsch'
return { criterion: 'timing', weight, score, contribution: score * weight, explanation }
}
if (!propDate) {
score = 38
explanation = 'Kein Verfügbarkeitsdatum angegeben'
return { criterion: 'timing', weight, score, contribution: score * weight, explanation }
}
if (earliest && latest) {
const t = propDate.getTime()
if (t >= earliest.getTime() && t <= latest.getTime()) {
score = 95
explanation = `Verfügbar ${rawDate} liegt im Einzugsfenster`
} else if (t < earliest.getTime()) {
const diff = earliest.getTime() - t
score = diff < graceMs ? 80 : 65
explanation = `Verfügbar ${rawDate} vor gewünschtem Einzug — kurze Leerstandszeit`
} else {
const diff = t - latest.getTime()
score = diff < graceMs ? 55 : 28
explanation = `Verfügbar ${rawDate} nach gewünschtem Zeitfenster`
}
} else {
score = 65
explanation = `Verfügbar ${rawDate}`
}
return { criterion: 'timing', weight, score, contribution: score * weight, explanation }
}
// ── Soft Factor Scorer ────────────────────────────────────────────────────────
function scoreSoftFactor(key: SoftFactorKey, weight: number, property: Property): ScoreFactor {
const sf = property.softFactors
const hf = property.hardFacts
const rawValue = (() => {
switch (key) {
case 'prestige': return sf?.prestigeScore ?? sf?.prestige
case 'accessibility': return sf?.commuterAccessScore ?? sf?.accessibility
?? (hf?.publicTransportScore !== undefined ? hf.publicTransportScore / 10 : undefined)
case 'expansionPotential': return sf?.expansionPotentialScore
case 'flexibility': return sf?.flexibilityScore
case 'visibility': return sf?.visibilityScore
case 'footfall': return sf?.footfallScore
case 'talentAccess': return sf?.talentAccessScore ?? sf?.talentAccess
case 'esg': return sf?.esgScore
case 'taxEnvironment': return sf?.taxEnvironmentScore
default: return undefined
}
})()
if (rawValue === undefined || rawValue === null) {
// Missing data → neutral 50 (does not help, does not hurt)
return {
criterion: key,
weight,
score: 50,
contribution: 50 * weight,
explanation: `${key}: keine Daten verfügbar — neutral bewertet`,
}
}
// Soft factor values are 01 scale → convert to 0100
const score = Math.round(Math.min(100, Math.max(0, rawValue * 100)))
const LABELS: Record<SoftFactorKey, string> = {
prestige: 'Prestige', accessibility: 'Erreichbarkeit', expansionPotential: 'Expansion',
flexibility: 'Flexibilität', visibility: 'Sichtbarkeit', footfall: 'Passantenfrequenz',
talentAccess: 'Talent-Zugang', esg: 'ESG', taxEnvironment: 'Steuerumfeld',
}
return {
criterion: key,
weight,
score,
contribution: score * weight,
explanation: `${LABELS[key] ?? key}: ${score}/100`,
}
}
// ── Modifier Calculators ──────────────────────────────────────────────────────
export function calcDataQualityModifier(property: Property): number {
const s = property.dataQuality?.score ?? 0.5
if (s >= 0.85) return DATA_QUALITY_MODIFIER.EXCELLENT
if (s >= 0.70) return DATA_QUALITY_MODIFIER.GOOD
if (s >= 0.55) return DATA_QUALITY_MODIFIER.FAIR
if (s >= 0.40) return DATA_QUALITY_MODIFIER.POOR
return DATA_QUALITY_MODIFIER.CRITICAL
}
export function calcConfidenceModifier(property: Property): number {
let mod = 0
if (property.resultType === ResultType.FUTURE_AVAILABILITY) {
mod += CONFIDENCE_MODIFIER.FUTURE_AVAILABILITY
} else if (property.resultType === ResultType.EXTERNAL_MARKET) {
mod += CONFIDENCE_MODIFIER.EXTERNAL_MARKET
} else {
// VERIFIED_PORTFOLIO
mod += property.confidenceScore >= 0.80
? CONFIDENCE_MODIFIER.VERIFIED_HIGH
: CONFIDENCE_MODIFIER.VERIFIED_MEDIUM
}
if (property.confidenceScore < 0.50) {
mod += CONFIDENCE_MODIFIER.LOW_CONFIDENCE
}
return mod
}
// ── Main Engine Function ──────────────────────────────────────────────────────
export function calculateScore(need: Need, property: Property): MatchEngineOutput {
const hardFilter = applyHardFilters(need, property)
if (hardFilter.excluded) {
return {
propertyId: property.id,
needId: need.id,
excluded: true,
excludedReason: hardFilter.reason,
finalScore: 0,
hardMatchScore: 0,
softFactorScore: 0,
dataQualityModifier: 0,
confidenceModifier: 0,
positiveFactors: [],
negativeFactors: [],
allHardFactors: [],
allSoftFactors: [],
tradeOffs: [],
risks: [],
missingData: identifyMissingData(property, need),
nextBestActions: [],
}
}
const profile = resolveProfile(need, property)
// ── Hard criteria scoring ──────────────────────────────────────────────────
const hardFactors: ScoreFactor[] = [
scoreArea(need, property, profile.area),
scoreLocation(need, property, profile.location),
scoreBudget(need, property, profile.budget),
scoreTiming(need, property, profile.timing),
]
const hardWeightSum = HARD_CRITERION_KEYS.reduce((s, k) => s + profile[k], 0)
const hardRaw = hardFactors.reduce((s, f) => s + f.contribution, 0)
const hardMatchScore = hardWeightSum > 0 ? Math.round(hardRaw / hardWeightSum) : 0
// ── Soft factor scoring ────────────────────────────────────────────────────
const softFactors: ScoreFactor[] = SOFT_FACTOR_KEYS
.filter(k => (profile[k] ?? 0) > 0)
.map(k => scoreSoftFactor(k, profile[k], property))
const softWeightSum = SOFT_FACTOR_KEYS.reduce((s, k) => s + (profile[k] ?? 0), 0)
const softRaw = softFactors.reduce((s, f) => s + f.contribution, 0)
const softFactorScore = softWeightSum > 0 ? Math.round(softRaw / softWeightSum) : 50
// ── Modifiers ──────────────────────────────────────────────────────────────
const dqMod = calcDataQualityModifier(property)
const confMod = calcConfidenceModifier(property)
// ── Final score: weighted sum of both groups + modifiers ──────────────────
// Each group already normalized 0100; combine per SCORE_SPLIT, then apply modifiers
const baseScore = hardMatchScore * 0.60 + softFactorScore * 0.40
const rawFinal = baseScore + dqMod + confMod - hardFilter.severePenalty
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification ──────────────────────────────────────────────────
const allFactors = [...hardFactors, ...softFactors]
const THRESHOLD_POSITIVE = 70
const THRESHOLD_NEGATIVE = 45
const positiveFactors = allFactors
.filter(f => f.score >= THRESHOLD_POSITIVE)
.sort((a, b) => b.contribution - a.contribution)
.slice(0, 4)
const negativeFactors = allFactors
.filter(f => f.score < THRESHOLD_NEGATIVE)
.sort((a, b) => a.contribution - b.contribution)
.slice(0, 4)
const tradeOffs = analyzeTradeOffs(hardFactors, softFactors, need, property)
const risks = analyzeRisks(property, hardFactors)
const missingData = identifyMissingData(property, need)
const output: MatchEngineOutput = {
propertyId: property.id,
needId: need.id,
excluded: false,
finalScore,
hardMatchScore,
softFactorScore,
dataQualityModifier: dqMod,
confidenceModifier: confMod,
positiveFactors,
negativeFactors,
allHardFactors: hardFactors,
allSoftFactors: softFactors,
tradeOffs,
risks,
missingData,
nextBestActions: [], // filled by rankingEngine
}
output.nextBestActions = generateNextBestActions(output, property, need)
return output
}