Files
property-match/src/features/matching/scoreCalculator.ts
T
Benjamin Sutter 9df5d285f5 fix: remove hidden score modifiers + fix Zürich auto-inference false positive
Score formula simplified to: finalScore = round(hard×60% + soft×40%)
Removes dataQualityModifier, confidenceModifier, mustHaveEval.scoreImpact —
the breakdown panel now always matches the displayed score.

Also removes 'industrie groove' from Zürich-only location signals — the phrase
describes an aesthetic preference, not a district reference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:33:55 +02:00

632 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
import { softFactorEnrichmentService } from '../../services/softFactorEnrichmentService'
import { scoreMustHaves } from './mustHaveScorer'
// ── 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',
'visibility', 'footfall', 'talentAccess', 'esg', 'taxEnvironment',
]
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 district = (property.location.district ?? '').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 = 80
explanation = 'Kein Standortwunsch — flexibel bewertet'
} else if (district && preferred.some(p => {
// "Zürich Kreis 5".includes("Kreis 5") or pSuffix "kreis 5" ⊂ district
if (p.includes(district)) return true
const pSuffix = p.replace(/^zürich\s+/i, '').trim()
// Only use pSuffix when the regex actually removed a prefix (avoids 'zürich' falsely
// matching district names like 'Zürich-West' because 'Zürich-West'.includes('zürich'))
return pSuffix !== p && pSuffix.length > 0 && district.includes(pSuffix)
})) {
score = 100
explanation = `Bezirk ${property.location.district} entspricht Präferenz`
} else if (preferred.some(p => city.includes(p) || p.includes(city))) {
// City matches but check if preferred has district-specific entries for this city
// → penalise when a specific district was requested but this property is in a different one
const hasDistrictSpecifics = preferred.some(p => p.includes(city) && p.length > city.length + 2)
score = hasDistrictSpecifics ? 70 : 100
explanation = score === 100
? `Standort ${property.location.city} entspricht Präferenz`
: `${property.location.city}${property.location.district ? ` (${property.location.district})` : ''} — Lage akzeptiert, bevorzugter Bezirk nicht erfüllt`
} 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': {
if (sf?.footfallScore !== undefined) return sf.footfallScore
// Map passerbyFrequency string (RETAIL properties) to numeric score
const pfMap: Record<string, number> = { HIGH: 85, MEDIUM_HIGH: 68, MEDIUM: 50, LOW: 30 }
const pf = (sf as { passerbyFrequency?: string } | undefined)?.passerbyFrequency
return pf !== undefined ? (pfMap[pf] ?? 50) : undefined
}
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) {
// No direct data — try location-intelligence estimate
const est = softFactorEnrichmentService.estimate(key, property)
if (est) {
const score = Math.round(Math.min(100, Math.max(0, est.score * 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 — Schätzung basierend auf Standort (${est.label})`,
estimated: true,
}
}
return {
criterion: key,
weight,
score: 50,
contribution: 50 * weight,
explanation: `${key}: keine Daten verfügbar — neutral bewertet`,
}
}
// Support both 01 float scale (enrichment estimates) and 0100 integer scale (mock data)
const score = rawValue > 1
? Math.round(Math.min(100, Math.max(0, rawValue)))
: 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`,
}
}
// ── Structured Requirement Scorers ───────────────────────────────────────────
const FIT_OUT_LEVELS: Record<string, number> = { SHELL: 0, BASIC: 1, FULL: 2, PREMIUM: 3 }
function scoreGroundFloor(need: Need, property: Property): ScoreFactor | null {
if (!need.requireGroundFloor) return null
const floor = property.hardFacts?.floor ?? property.floorLevel
if (floor === undefined) {
return { criterion: 'groundFloor', weight: 0.05, score: 50, contribution: 2.5, explanation: 'Erdgeschoss erforderlich — Stockwerk nicht dokumentiert', estimated: true }
}
const passed = floor === 0
const score = passed ? 100 : 20
return {
criterion: 'groundFloor',
weight: 0.05,
score,
contribution: score * 0.05,
explanation: passed ? `Erdgeschoss bestätigt (Etage ${floor})` : `Nicht Erdgeschoss (Etage ${floor}) — EG erforderlich`,
}
}
function scoreParkingMin(need: Need, property: Property): ScoreFactor | null {
const min = need.requiredParkingMin
if (!min || min <= 0) return null
const available = property.hardFacts?.parking ?? property.softFactors?.parkingSpots ?? 0
if (available === 0 && property.hardFacts?.parking === undefined) {
return { criterion: 'parkingMin', weight: 0.04, score: 50, contribution: 2, explanation: `Mind. ${min} Parkplätze erforderlich — keine Daten`, estimated: true }
}
const ratio = Math.min(1, available / min)
const score = available >= min ? 100 : Math.round(ratio * 60)
return {
criterion: 'parkingMin',
weight: 0.04,
score,
contribution: score * 0.04,
explanation: available >= min
? `${available} Parkplätze vorhanden (mind. ${min} erforderlich)`
: `Nur ${available} Parkplätze (mind. ${min} erforderlich)`,
}
}
function scoreAirConditioning(need: Need, property: Property): ScoreFactor | null {
if (!need.requireAirConditioning) return null
const hasAC = property.hardFacts?.hasAirConditioning
if (hasAC === undefined) {
return { criterion: 'airConditioning', weight: 0.03, score: 50, contribution: 1.5, explanation: 'Klimaanlage erforderlich — nicht dokumentiert', estimated: true }
}
const score = hasAC ? 100 : 15
return {
criterion: 'airConditioning',
weight: 0.03,
score,
contribution: score * 0.03,
explanation: hasAC ? 'Klimaanlage vorhanden' : 'Keine Klimaanlage — Klimaanlage erforderlich',
}
}
function scoreLoadingDock(need: Need, property: Property): ScoreFactor | null {
if (!need.requireLoadingDock) return null
const docks = property.hardFacts?.loadingDocksCount
if (docks === undefined) {
return { criterion: 'loadingDock', weight: 0.05, score: 50, contribution: 2.5, explanation: 'Laderampe erforderlich — keine Daten', estimated: true }
}
const passed = docks > 0
const score = passed ? 100 : 15
return {
criterion: 'loadingDock',
weight: 0.05,
score,
contribution: score * 0.05,
explanation: passed ? `${docks} Laderampe(n) vorhanden` : 'Keine Laderampe vorhanden — erforderlich',
}
}
function scoreBarrierFree(need: Need, property: Property): ScoreFactor | null {
if (!need.requireBarrierFree) return null
const ok = property.hardFacts?.isBarrierFree
if (ok === undefined) {
return { criterion: 'barrierFree', weight: 0.03, score: 50, contribution: 1.5, explanation: 'Barrierefreiheit erforderlich — nicht dokumentiert', estimated: true }
}
const score = ok ? 100 : 20
return {
criterion: 'barrierFree',
weight: 0.03,
score,
contribution: score * 0.03,
explanation: ok ? 'Barrierefrei bestätigt' : 'Nicht barrierefrei — Barrierefreiheit erforderlich',
}
}
function scoreCeilingHeight(need: Need, property: Property): ScoreFactor | null {
const min = need.minCeilingHeightM
if (!min || min <= 0) return null
const actual = property.hardFacts?.ceilingHeightM
if (actual === undefined) {
return { criterion: 'ceilingHeight', weight: 0.04, score: 50, contribution: 2, explanation: `Mind. ${min}m Deckenhöhe erforderlich — keine Daten`, estimated: true }
}
const passed = actual >= min
const score = passed ? 100 : Math.max(10, Math.round((actual / min) * 70))
return {
criterion: 'ceilingHeight',
weight: 0.04,
score,
contribution: score * 0.04,
explanation: passed
? `Deckenhöhe ${actual}m ≥ Minimum ${min}m`
: `Deckenhöhe ${actual}m unter Minimum ${min}m`,
}
}
function scoreMinContractDuration(need: Need, property: Property): ScoreFactor | null {
const min = need.minContractDurationMonths
if (!min || min <= 0) return null
const available = property.contractDurationMonths
if (available === undefined) {
return { criterion: 'contractDuration', weight: 0.03, score: 50, contribution: 1.5, explanation: `Mind. ${Math.round(min / 12)} Jahre Laufzeit gewünscht — keine Daten`, estimated: true }
}
const passed = available >= min
const score = passed ? 100 : Math.max(20, Math.round((available / min) * 70))
return {
criterion: 'contractDuration',
weight: 0.03,
score,
contribution: score * 0.03,
explanation: passed
? `${Math.round(available / 12)} Jahre Vertragslaufzeit — erfüllt (mind. ${Math.round(min / 12)} Jahre gewünscht)`
: `Nur ${Math.round(available / 12)} Jahre — mind. ${Math.round(min / 12)} Jahre gewünscht`,
}
}
function scoreFitOut(need: Need, property: Property): ScoreFactor | null {
if (!need.requiredFitOut) return null
const propFitOut = property.hardFacts?.fitOut
if (!propFitOut) {
return { criterion: 'fitOut', weight: 0.04, score: 50, contribution: 2, explanation: `Ausbaustandard ${need.requiredFitOut} erforderlich — keine Daten`, estimated: true }
}
const reqLevel = FIT_OUT_LEVELS[need.requiredFitOut] ?? 1
const propLevel = FIT_OUT_LEVELS[propFitOut] ?? 0
const score = propLevel >= reqLevel ? 100 : Math.max(10, Math.round(50 - (reqLevel - propLevel) * 25))
const LABELS: Record<string, string> = { SHELL: 'Rohbau', BASIC: 'Basisausbau', FULL: 'Vollausbau', PREMIUM: 'Premiumausbau' }
return {
criterion: 'fitOut',
weight: 0.04,
score,
contribution: score * 0.04,
explanation: propLevel >= reqLevel
? `Ausbaustandard ${LABELS[propFitOut] ?? propFitOut} erfüllt Anforderung ${LABELS[need.requiredFitOut] ?? need.requiredFitOut}`
: `${LABELS[propFitOut] ?? propFitOut}${LABELS[need.requiredFitOut] ?? need.requiredFitOut} erforderlich`,
}
}
// ── 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.MAISON_WORK) {
mod += CONFIDENCE_MODIFIER.MAISON_WORK
} 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 coreHardFactors: ScoreFactor[] = [
scoreArea(need, property, profile.area),
scoreLocation(need, property, profile.location),
scoreBudget(need, property, profile.budget),
scoreTiming(need, property, profile.timing),
]
// Structured requirement scorers — optional hard factors
const structuredFactors: ScoreFactor[] = [
scoreGroundFloor(need, property),
scoreParkingMin(need, property),
scoreAirConditioning(need, property),
scoreLoadingDock(need, property),
scoreBarrierFree(need, property),
scoreCeilingHeight(need, property),
scoreMinContractDuration(need, property),
scoreFitOut(need, property),
].filter((f): f is ScoreFactor => f !== null)
const hardFactors = [...coreHardFactors, ...structuredFactors]
const hardWeightSum = HARD_CRITERION_KEYS.reduce((s, k) => s + profile[k], 0)
+ structuredFactors.reduce((s, f) => s + f.weight, 0)
const hardRaw = hardFactors.reduce((s, f) => s + f.contribution, 0)
const hardMatchScore = hardWeightSum > 0 ? Math.min(100, Math.round(hardRaw / hardWeightSum)) : 0
// ── Soft factor scoring ────────────────────────────────────────────────────
// All 9 factors always included in output so breakdown is always complete.
// Only weighted factors (weight > 0) contribute to the score calculation.
const allSoftDisplay: ScoreFactor[] = SOFT_FACTOR_KEYS
.map(k => scoreSoftFactor(k, profile[k] ?? 0, property))
const softWeightSum = SOFT_FACTOR_KEYS.reduce((s, k) => s + (profile[k] ?? 0), 0)
const softRaw = allSoftDisplay.filter(f => f.weight > 0).reduce((s, f) => s + f.contribution, 0)
const softFactorScore = softWeightSum > 0 ? Math.min(100, Math.round(softRaw / softWeightSum)) : 50
// ── Must-have criteria evaluation ─────────────────────────────────────────
const allMustHaveText = [
...(need.mustCriteriaText ?? []),
...(need.mustHaveCriteria?.map(c => c.criterion) ?? []),
]
const mustHaveEval = scoreMustHaves(allMustHaveText, property)
// ── Final score: hard/soft weighted sum, clamped 0100 ───────────────────
const rawFinal = hardMatchScore * 0.60 + softFactorScore * 0.40 - hardFilter.severePenalty
const finalScore = Math.round(Math.min(100, Math.max(0, rawFinal)))
// ── Factor classification — only use weighted soft factors for positive/negative ──
const weightedSoftFactors = allSoftDisplay.filter(f => f.weight > 0)
const allFactors = [...hardFactors, ...weightedSoftFactors]
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, weightedSoftFactors, 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: 0,
confidenceModifier: 0,
positiveFactors,
negativeFactors,
allHardFactors: hardFactors,
allSoftFactors: allSoftDisplay,
tradeOffs,
risks,
missingData,
mustHaveEvaluation: mustHaveEval.results.length > 0 ? mustHaveEval.results : undefined,
nextBestActions: [], // filled by rankingEngine
}
output.nextBestActions = generateNextBestActions(output, property, need)
return output
}