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:
@@ -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 125–150% over budget
|
||||
BUDGET_MODERATE_RATIO: 1.10, // mild penalty if 110–125% over budget
|
||||
TIMING_GRACE_DAYS: 90, // allow ±90 days window flexibility
|
||||
} as const
|
||||
|
||||
// ── Score Architecture ────────────────────────────────────────────────────────
|
||||
|
||||
// Within each group, scores are weighted and normalized to 0–100.
|
||||
// Final = baseScore + dataQualityModifier + confidenceModifier, clamped 0–100.
|
||||
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 (0–30)
|
||||
}
|
||||
|
||||
export interface MatchEngineOutput {
|
||||
propertyId: string
|
||||
needId: string
|
||||
excluded: boolean
|
||||
excludedReason?: string
|
||||
finalScore: number // 0–100 clamped
|
||||
hardMatchScore: number // 0–100 normalized
|
||||
softFactorScore: number // 0–100 normalized
|
||||
dataQualityModifier: number
|
||||
confidenceModifier: number
|
||||
positiveFactors: ScoreFactor[]
|
||||
negativeFactors: ScoreFactor[]
|
||||
allHardFactors: ScoreFactor[]
|
||||
allSoftFactors: ScoreFactor[]
|
||||
tradeOffs: TradeOff[]
|
||||
risks: Risk[]
|
||||
missingData: MissingDataItem[]
|
||||
nextBestActions: NextBestAction[]
|
||||
}
|
||||
Reference in New Issue
Block a user