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
+143
View File
@@ -0,0 +1,143 @@
import type { ScoreFactor, TradeOff, Risk, MissingDataItem, NextBestAction } from './match'
// ── Hard Filter Thresholds ────────────────────────────────────────────────────
export const HARD_FILTER = {
AREA_MIN_TOLERANCE: 0.85, // exclude if property < 85% of need's min area
AREA_MAX_RATIO: 2.50, // severe penalty if property > 2.5× need's max area
BUDGET_EXCLUSION_RATIO: 1.50, // exclude if rent > 150% of max budget/m²
BUDGET_SEVERE_RATIO: 1.25, // severe penalty if 125150% over budget
BUDGET_MODERATE_RATIO: 1.10, // mild penalty if 110125% over budget
TIMING_GRACE_DAYS: 90, // allow ±90 days window flexibility
} as const
// ── Score Architecture ────────────────────────────────────────────────────────
// Within each group, scores are weighted and normalized to 0100.
// Final = baseScore + dataQualityModifier + confidenceModifier, clamped 0100.
export const SCORE_SPLIT = {
HARD_CRITERIA: 0.60, // expected contribution from hard criteria group
SOFT_FACTORS: 0.40, // expected contribution from soft factors group
} as const
export const HARD_CRITERION_KEYS = ['area', 'location', 'budget', 'timing'] as const
export type HardCriterionKey = typeof HARD_CRITERION_KEYS[number]
export const SOFT_FACTOR_KEYS = [
'prestige', 'accessibility', 'expansionPotential', 'flexibility',
'visibility', 'footfall', 'talentAccess', 'esg', 'taxEnvironment',
] as const
export type SoftFactorKey = typeof SOFT_FACTOR_KEYS[number]
// ── Modifier Tables ───────────────────────────────────────────────────────────
export const DATA_QUALITY_MODIFIER = {
EXCELLENT: +5, // dataQuality.score >= 0.85
GOOD: 0, // >= 0.70
FAIR: -5, // >= 0.55
POOR: -10, // >= 0.40
CRITICAL: -15, // < 0.40
} as const
export const CONFIDENCE_MODIFIER = {
VERIFIED_HIGH: +3, // VERIFIED_PORTFOLIO + confidenceScore >= 0.80
VERIFIED_MEDIUM: 0, // VERIFIED_PORTFOLIO + confidenceScore < 0.80
EXTERNAL_MARKET: -3, // EXTERNAL_MARKET result type
FUTURE_AVAILABILITY: -15, // FUTURE_AVAILABILITY — never treat as confirmed availability
LOW_CONFIDENCE: -10, // confidenceScore < 0.50 (stacks with above)
} as const
// ── Scoring Weight Profile ────────────────────────────────────────────────────
export interface ScoringWeightProfile {
// Hard criteria
area: number
location: number
budget: number
timing: number
// Soft factors
prestige: number
accessibility: number
expansionPotential: number
flexibility: number
visibility: number
footfall: number
talentAccess: number
esg: number
taxEnvironment: number
[key: string]: number
}
// ── Default Profiles per Asset Type ──────────────────────────────────────────
// Each profile sums to 1.00. No magic numbers — weights reflect domain logic.
export const DEFAULT_SCORING_PROFILES: Record<string, ScoringWeightProfile> = {
// Büro: ÖV-Anbindung, Talent Access, Prestige, ESG stark gewichtet
OFFICE: {
area: 0.18, location: 0.18, budget: 0.15, timing: 0.09,
prestige: 0.07, accessibility: 0.11, expansionPotential: 0.05,
flexibility: 0.05, visibility: 0.02, footfall: 0.01,
talentAccess: 0.07, esg: 0.02, taxEnvironment: 0.00,
},
// Retail: Frequenz, Sichtbarkeit und Standort dominieren
RETAIL: {
area: 0.10, location: 0.15, budget: 0.13, timing: 0.06,
prestige: 0.04, accessibility: 0.07, expansionPotential: 0.03,
flexibility: 0.08, visibility: 0.14, footfall: 0.18,
talentAccess: 0.01, esg: 0.01, taxEnvironment: 0.00,
},
// Light Industrial: Fläche, Andienung (accessibility), Infrastruktur
LIGHT_INDUSTRIAL: {
area: 0.20, location: 0.12, budget: 0.18, timing: 0.10,
prestige: 0.01, accessibility: 0.14, expansionPotential: 0.07,
flexibility: 0.05, visibility: 0.01, footfall: 0.00,
talentAccess: 0.04, esg: 0.04, taxEnvironment: 0.04,
},
// Logistik: Autobahnanbindung (accessibility), Andienung, Fläche, Verfügbarkeit
LOGISTICS: {
area: 0.18, location: 0.18, budget: 0.14, timing: 0.13,
prestige: 0.01, accessibility: 0.18, expansionPotential: 0.06,
flexibility: 0.04, visibility: 0.01, footfall: 0.00,
talentAccess: 0.02, esg: 0.02, taxEnvironment: 0.03,
},
PRODUCTION: {
area: 0.22, location: 0.13, budget: 0.18, timing: 0.10,
prestige: 0.01, accessibility: 0.13, expansionPotential: 0.08,
flexibility: 0.04, visibility: 0.01, footfall: 0.00,
talentAccess: 0.04, esg: 0.03, taxEnvironment: 0.03,
},
DEFAULT: {
area: 0.20, location: 0.18, budget: 0.18, timing: 0.10,
prestige: 0.05, accessibility: 0.09, expansionPotential: 0.05,
flexibility: 0.05, visibility: 0.02, footfall: 0.02,
talentAccess: 0.03, esg: 0.02, taxEnvironment: 0.01,
},
}
// ── Engine IO Types ───────────────────────────────────────────────────────────
export interface HardFilterResult {
excluded: boolean
reason?: string
severePenalty: number // extra points deducted on top of criterion score (030)
}
export interface MatchEngineOutput {
propertyId: string
needId: string
excluded: boolean
excludedReason?: string
finalScore: number // 0100 clamped
hardMatchScore: number // 0100 normalized
softFactorScore: number // 0100 normalized
dataQualityModifier: number
confidenceModifier: number
positiveFactors: ScoreFactor[]
negativeFactors: ScoreFactor[]
allHardFactors: ScoreFactor[]
allSoftFactors: ScoreFactor[]
tradeOffs: TradeOff[]
risks: Risk[]
missingData: MissingDataItem[]
nextBestActions: NextBestAction[]
}