diff --git a/services/hometitle/coverage/base.css b/services/hometitle/coverage/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/services/hometitle/coverage/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/services/hometitle/coverage/block-navigation.js b/services/hometitle/coverage/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/services/hometitle/coverage/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/services/hometitle/coverage/change-detector.ts.html b/services/hometitle/coverage/change-detector.ts.html new file mode 100644 index 0000000..9fee0c4 --- /dev/null +++ b/services/hometitle/coverage/change-detector.ts.html @@ -0,0 +1,691 @@ + + + + +
++ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 | + + + + + + + + + + +1x + + + + + + + + +11x + +5x + + + + + +5x + +2x +2x + +2x +2x + +1x +1x + +1x +1x + + + + +11x + + + +5x + + + + + + + +5x +5x + + + +15x + +15x + + + + + + + +15x + +15x +14x +14x +14x + + +10x +9x +9x + + +5x + + + +15x + +13x +13x +16x + +6x +6x + +3x +3x + +3x +3x +3x +3x +3x + + +2x +2x + +2x + + + +13x + + + + + + + +11x +11x + +11x + + + + + + + + +11x +66x +66x + +66x +11x + + + +11x +11x + +11x +11x + +11x +11x +4x +7x +2x +5x +1x +4x +1x + + +11x + + + + + + + + + + + + +11x + +11x + +11x +77x +77x +77x +1x + + + + + + + + +11x + + + +5x +5x +5x +5x + + + + | import {
+ PropertySnapshot,
+ ChangeDetectionResult,
+ ChangeType,
+ Severity,
+ PropertyChange,
+ DetectionConfig,
+ Address,
+} from './types';
+import { matchRecords } from './matcher.service';
+
+const DEFAULT_DETECTION_CONFIG: DetectionConfig = {
+ ownershipNameThreshold: 0.7,
+ deedDateSensitivity: 0.9,
+ taxAmountChangePercent: 15,
+};
+
+function classifyFieldChange(field: string, oldValue: unknown, newValue: unknown, config: DetectionConfig): PropertyChange {
+ let changeType: ChangeType;
+
+ switch (field) {
+ case 'ownerName':
+ changeType =
+ typeof oldValue === 'string' && typeof newValue === 'string'
+ ? isSignificantNameChange(oldValue, newValue, config)
+ ? 'ownership_transfer'
+ : 'metadata_change'
+ : 'ownership_transfer';
+ break;
+ case 'deedDate':
+ changeType = 'deed_change';
+ break;
+ case 'taxAmount':
+ changeType = 'tax_change';
+ break;
+ case 'lienCount':
+ changeType = (newValue as number) > (oldValue as number) ? 'lien_filing' : 'metadata_change';
+ break;
+ case 'taxId':
+ changeType = 'deed_change';
+ break;
+ default:
+ changeType = 'metadata_change';
+ }
+
+ return { field, oldValue, newValue, changeType };
+}
+
+function isSignificantNameChange(oldName: string, newName: string, config: DetectionConfig): boolean {
+ const dummyAddress: Address = {
+ streetNumber: '0',
+ streetName: 'dummy',
+ city: 'dummy',
+ state: 'XX',
+ zip: '00000',
+ };
+
+ const result = matchRecords(oldName, dummyAddress, newName, dummyAddress);
+ return result.nameScore < config.ownershipNameThreshold;
+}
+
+function determineSeverity(changes: PropertyChange[], config: DetectionConfig): Severity {
+ const severityOverrides = config.severityOverrides || {};
+
+ const typeToSeverity: Record<ChangeType, Severity> = {
+ ownership_transfer: severityOverrides['ownership_transfer'] || 'major',
+ deed_change: severityOverrides['deed_change'] || 'moderate',
+ lien_filing: severityOverrides['lien_filing'] || 'moderate',
+ tax_change: severityOverrides['tax_change'] || 'minor',
+ metadata_change: severityOverrides['metadata_change'] || 'minor',
+ };
+
+ const severityOrder: Severity[] = ['major', 'moderate', 'minor'];
+
+ for (const change of changes) {
+ const sev = typeToSeverity[change.changeType];
+ const idx = severityOrder.indexOf(sev);
+ if (idx === 0) return 'major';
+ }
+
+ for (const change of changes) {
+ const sev = typeToSeverity[change.changeType];
+ if (sev === 'moderate') return 'moderate';
+ }
+
+ return 'minor';
+}
+
+function computeChangeConfidence(changes: PropertyChange[], config: DetectionConfig): number {
+ if (changes.length === 0) return 0;
+
+ let totalConfidence = 0;
+ for (const change of changes) {
+ switch (change.changeType) {
+ case 'ownership_transfer':
+ totalConfidence += 0.95;
+ break;
+ case 'deed_change':
+ totalConfidence += config.deedDateSensitivity;
+ break;
+ case 'tax_change': {
+ const oldVal = change.oldValue as number;
+ const newVal = change.newValue as number;
+ const pctChange = oldVal ? Math.abs(newVal - oldVal) / oldVal * 100 : 100;
+ totalConfidence += pctChange >= config.taxAmountChangePercent ? 0.85 : 0.5;
+ break;
+ }
+ case 'lien_filing':
+ totalConfidence += 0.9;
+ break;
+ default:
+ totalConfidence += 0.4;
+ }
+ }
+
+ return Math.round((totalConfidence / changes.length) * 1000) / 1000;
+}
+
+export function detectChanges(
+ previous: PropertySnapshot,
+ current: PropertySnapshot,
+ config?: Partial<DetectionConfig>,
+): ChangeDetectionResult {
+ const effectiveConfig = { ...DEFAULT_DETECTION_CONFIG, ...config };
+ const changes: PropertyChange[] = [];
+
+ const fieldsToCompare: (keyof Omit<PropertySnapshot, 'id' | 'capturedAt' | 'propertyId'>)[] = [
+ 'ownerName',
+ 'deedDate',
+ 'taxId',
+ 'taxAmount',
+ 'lienCount',
+ 'propertyType',
+ ];
+
+ for (const field of fieldsToCompare) {
+ const oldValue = previous[field];
+ const newValue = current[field];
+
+ if (oldValue !== newValue) {
+ changes.push(classifyFieldChange(field, oldValue, newValue, effectiveConfig));
+ }
+ }
+
+ const addressChanges = detectAddressChanges(previous.address, current.address);
+ changes.push(...addressChanges);
+
+ const severity = determineSeverity(changes, effectiveConfig);
+ const confidence = computeChangeConfidence(changes, effectiveConfig);
+
+ let changeType: ChangeType = 'metadata_change';
+ if (changes.some(c => c.changeType === 'ownership_transfer')) {
+ changeType = 'ownership_transfer';
+ } else if (changes.some(c => c.changeType === 'deed_change')) {
+ changeType = 'deed_change';
+ } else if (changes.some(c => c.changeType === 'lien_filing')) {
+ changeType = 'lien_filing';
+ } else if (changes.some(c => c.changeType === 'tax_change')) {
+ changeType = 'tax_change';
+ }
+
+ return {
+ propertyId: previous.propertyId,
+ changeType,
+ severity,
+ confidence,
+ changes,
+ previousSnapshot: previous,
+ currentSnapshot: current,
+ detectedAt: new Date().toISOString(),
+ };
+}
+
+function detectAddressChanges(oldAddr: Address, newAddr: Address): PropertyChange[] {
+ const changes: PropertyChange[] = [];
+
+ const addressFields: (keyof Address)[] = ['streetNumber', 'streetName', 'streetType', 'unit', 'city', 'state', 'zip'];
+
+ for (const field of addressFields) {
+ const oldVal = oldAddr[field];
+ const newVal = newAddr[field];
+ if (oldVal !== newVal) {
+ changes.push({
+ field: `address.${field}`,
+ oldValue: oldVal,
+ newValue: newVal,
+ changeType: 'metadata_change',
+ });
+ }
+ }
+
+ return changes;
+}
+
+export function shouldTriggerAlert(result: ChangeDetectionResult, minSeverity: Severity = 'moderate'): boolean {
+ const severityOrder: Severity[] = ['minor', 'moderate', 'major'];
+ const resultIdx = severityOrder.indexOf(result.severity);
+ const minIdx = severityOrder.indexOf(minSeverity);
+ return resultIdx >= minIdx && result.confidence >= 0.7;
+}
+
+export { classifyFieldChange, determineSeverity, computeChangeConfidence };
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| change-detector.ts | +
+
+ |
+ 98.83% | +85/86 | +91.07% | +51/56 | +100% | +11/11 | +98.73% | +78/79 | +
| index.ts | +
+
+ |
+ 0% | +0/0 | +0% | +0/0 | +0% | +0/0 | +0% | +0/0 | +
| matcher.service.ts | +
+
+ |
+ 97.61% | +123/126 | +93.42% | +71/76 | +93.75% | +15/16 | +99.12% | +113/114 | +
| types.ts | +
+
+ |
+ 0% | +0/0 | +0% | +0/0 | +0% | +0/0 | +0% | +0/0 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | export {
+ matchRecords,
+ getConfigForPropertyType,
+ parseName,
+ normalizeString,
+ normalizeStreetType,
+ levenshteinDistance,
+ similarityScore,
+} from './matcher.service';
+
+export {
+ detectChanges,
+ shouldTriggerAlert,
+ classifyFieldChange,
+ determineSeverity,
+ computeChangeConfidence,
+} from './change-detector';
+
+export type {
+ PropertyRecord,
+ Address,
+ PropertyType,
+ PropertySnapshot,
+ MatchResult,
+ MatchDetails,
+ FieldMatch,
+ ChangeDetectionResult,
+ ChangeType,
+ Severity,
+ PropertyChange,
+ MatchingConfig,
+ DetectionConfig,
+ NormalizedTokens,
+} from './types';
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 | + + + + + + + + + + +1x + + + + + + + + +11x + +5x + + + + + +5x + +2x +2x + +2x +2x + +1x +1x + +1x +1x + + + + +11x + + + +5x + + + + + + + +5x +5x + + + +15x + +15x + + + + + + + +15x + +15x +14x +14x +14x + + +10x +9x +9x + + +5x + + + +15x + +13x +13x +16x + +6x +6x + +3x +3x + +3x +3x +3x +3x +3x + + +2x +2x + +2x + + + +13x + + + + + + + +11x +11x + +11x + + + + + + + + +11x +66x +66x + +66x +11x + + + +11x +11x + +11x +11x + +11x +11x +4x +7x +2x +5x +1x +4x +1x + + +11x + + + + + + + + + + + + +11x + +11x + +11x +77x +77x +77x +1x + + + + + + + + +11x + + + +5x +5x +5x +5x + + + + | import {
+ PropertySnapshot,
+ ChangeDetectionResult,
+ ChangeType,
+ Severity,
+ PropertyChange,
+ DetectionConfig,
+ Address,
+} from './types';
+import { matchRecords } from './matcher.service';
+
+const DEFAULT_DETECTION_CONFIG: DetectionConfig = {
+ ownershipNameThreshold: 0.7,
+ deedDateSensitivity: 0.9,
+ taxAmountChangePercent: 15,
+};
+
+function classifyFieldChange(field: string, oldValue: unknown, newValue: unknown, config: DetectionConfig): PropertyChange {
+ let changeType: ChangeType;
+
+ switch (field) {
+ case 'ownerName':
+ changeType =
+ typeof oldValue === 'string' && typeof newValue === 'string'
+ ? isSignificantNameChange(oldValue, newValue, config)
+ ? 'ownership_transfer'
+ : 'metadata_change'
+ : 'ownership_transfer';
+ break;
+ case 'deedDate':
+ changeType = 'deed_change';
+ break;
+ case 'taxAmount':
+ changeType = 'tax_change';
+ break;
+ case 'lienCount':
+ changeType = (newValue as number) > (oldValue as number) ? 'lien_filing' : 'metadata_change';
+ break;
+ case 'taxId':
+ changeType = 'deed_change';
+ break;
+ default:
+ changeType = 'metadata_change';
+ }
+
+ return { field, oldValue, newValue, changeType };
+}
+
+function isSignificantNameChange(oldName: string, newName: string, config: DetectionConfig): boolean {
+ const dummyAddress: Address = {
+ streetNumber: '0',
+ streetName: 'dummy',
+ city: 'dummy',
+ state: 'XX',
+ zip: '00000',
+ };
+
+ const result = matchRecords(oldName, dummyAddress, newName, dummyAddress);
+ return result.nameScore < config.ownershipNameThreshold;
+}
+
+function determineSeverity(changes: PropertyChange[], config: DetectionConfig): Severity {
+ const severityOverrides = config.severityOverrides || {};
+
+ const typeToSeverity: Record<ChangeType, Severity> = {
+ ownership_transfer: severityOverrides['ownership_transfer'] || 'major',
+ deed_change: severityOverrides['deed_change'] || 'moderate',
+ lien_filing: severityOverrides['lien_filing'] || 'moderate',
+ tax_change: severityOverrides['tax_change'] || 'minor',
+ metadata_change: severityOverrides['metadata_change'] || 'minor',
+ };
+
+ const severityOrder: Severity[] = ['major', 'moderate', 'minor'];
+
+ for (const change of changes) {
+ const sev = typeToSeverity[change.changeType];
+ const idx = severityOrder.indexOf(sev);
+ if (idx === 0) return 'major';
+ }
+
+ for (const change of changes) {
+ const sev = typeToSeverity[change.changeType];
+ if (sev === 'moderate') return 'moderate';
+ }
+
+ return 'minor';
+}
+
+function computeChangeConfidence(changes: PropertyChange[], config: DetectionConfig): number {
+ if (changes.length === 0) return 0;
+
+ let totalConfidence = 0;
+ for (const change of changes) {
+ switch (change.changeType) {
+ case 'ownership_transfer':
+ totalConfidence += 0.95;
+ break;
+ case 'deed_change':
+ totalConfidence += config.deedDateSensitivity;
+ break;
+ case 'tax_change': {
+ const oldVal = change.oldValue as number;
+ const newVal = change.newValue as number;
+ const pctChange = oldVal ? Math.abs(newVal - oldVal) / oldVal * 100 : 100;
+ totalConfidence += pctChange >= config.taxAmountChangePercent ? 0.85 : 0.5;
+ break;
+ }
+ case 'lien_filing':
+ totalConfidence += 0.9;
+ break;
+ default:
+ totalConfidence += 0.4;
+ }
+ }
+
+ return Math.round((totalConfidence / changes.length) * 1000) / 1000;
+}
+
+export function detectChanges(
+ previous: PropertySnapshot,
+ current: PropertySnapshot,
+ config?: Partial<DetectionConfig>,
+): ChangeDetectionResult {
+ const effectiveConfig = { ...DEFAULT_DETECTION_CONFIG, ...config };
+ const changes: PropertyChange[] = [];
+
+ const fieldsToCompare: (keyof Omit<PropertySnapshot, 'id' | 'capturedAt' | 'propertyId'>)[] = [
+ 'ownerName',
+ 'deedDate',
+ 'taxId',
+ 'taxAmount',
+ 'lienCount',
+ 'propertyType',
+ ];
+
+ for (const field of fieldsToCompare) {
+ const oldValue = previous[field];
+ const newValue = current[field];
+
+ if (oldValue !== newValue) {
+ changes.push(classifyFieldChange(field, oldValue, newValue, effectiveConfig));
+ }
+ }
+
+ const addressChanges = detectAddressChanges(previous.address, current.address);
+ changes.push(...addressChanges);
+
+ const severity = determineSeverity(changes, effectiveConfig);
+ const confidence = computeChangeConfidence(changes, effectiveConfig);
+
+ let changeType: ChangeType = 'metadata_change';
+ if (changes.some(c => c.changeType === 'ownership_transfer')) {
+ changeType = 'ownership_transfer';
+ } else if (changes.some(c => c.changeType === 'deed_change')) {
+ changeType = 'deed_change';
+ } else if (changes.some(c => c.changeType === 'lien_filing')) {
+ changeType = 'lien_filing';
+ } else if (changes.some(c => c.changeType === 'tax_change')) {
+ changeType = 'tax_change';
+ }
+
+ return {
+ propertyId: previous.propertyId,
+ changeType,
+ severity,
+ confidence,
+ changes,
+ previousSnapshot: previous,
+ currentSnapshot: current,
+ detectedAt: new Date().toISOString(),
+ };
+}
+
+function detectAddressChanges(oldAddr: Address, newAddr: Address): PropertyChange[] {
+ const changes: PropertyChange[] = [];
+
+ const addressFields: (keyof Address)[] = ['streetNumber', 'streetName', 'streetType', 'unit', 'city', 'state', 'zip'];
+
+ for (const field of addressFields) {
+ const oldVal = oldAddr[field];
+ const newVal = newAddr[field];
+ if (oldVal !== newVal) {
+ changes.push({
+ field: `address.${field}`,
+ oldValue: oldVal,
+ newValue: newVal,
+ changeType: 'metadata_change',
+ });
+ }
+ }
+
+ return changes;
+}
+
+export function shouldTriggerAlert(result: ChangeDetectionResult, minSeverity: Severity = 'moderate'): boolean {
+ const severityOrder: Severity[] = ['minor', 'moderate', 'major'];
+ const resultIdx = severityOrder.indexOf(result.severity);
+ const minIdx = severityOrder.indexOf(minSeverity);
+ return resultIdx >= minIdx && result.confidence >= 0.7;
+}
+
+export { classifyFieldChange, determineSeverity, computeChangeConfidence };
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| File | ++ | Statements | ++ | Branches | ++ | Functions | ++ | Lines | ++ |
|---|---|---|---|---|---|---|---|---|---|
| change-detector.ts | +
+
+ |
+ 98.83% | +85/86 | +91.07% | +51/56 | +100% | +11/11 | +98.73% | +78/79 | +
| index.ts | +
+
+ |
+ 0% | +0/0 | +0% | +0/0 | +0% | +0/0 | +0% | +0/0 | +
| matcher.service.ts | +
+
+ |
+ 97.61% | +123/126 | +93.42% | +71/76 | +93.75% | +15/16 | +99.12% | +113/114 | +
| types.ts | +
+
+ |
+ 0% | +0/0 | +0% | +0/0 | +0% | +0/0 | +0% | +0/0 | +
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + | export {
+ matchRecords,
+ getConfigForPropertyType,
+ parseName,
+ normalizeString,
+ normalizeStreetType,
+ levenshteinDistance,
+ similarityScore,
+} from './matcher.service';
+
+export {
+ detectChanges,
+ shouldTriggerAlert,
+ classifyFieldChange,
+ determineSeverity,
+ computeChangeConfidence,
+} from './change-detector';
+
+export type {
+ PropertyRecord,
+ Address,
+ PropertyType,
+ PropertySnapshot,
+ MatchResult,
+ MatchDetails,
+ FieldMatch,
+ ChangeDetectionResult,
+ ChangeType,
+ Severity,
+ PropertyChange,
+ MatchingConfig,
+ DetectionConfig,
+ NormalizedTokens,
+} from './types';
+ |
+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +
+ +| 1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83 +84 +85 +86 +87 +88 +89 +90 +91 +92 +93 +94 +95 +96 +97 +98 +99 +100 +101 +102 +103 +104 +105 +106 +107 +108 +109 +110 +111 +112 +113 +114 +115 +116 +117 +118 +119 +120 +121 +122 +123 +124 +125 +126 +127 +128 +129 +130 +131 +132 +133 +134 +135 +136 +137 +138 +139 +140 +141 +142 +143 +144 +145 +146 +147 +148 +149 +150 +151 +152 +153 +154 +155 +156 +157 +158 +159 +160 +161 +162 +163 +164 +165 +166 +167 +168 +169 +170 +171 +172 +173 +174 +175 +176 +177 +178 +179 +180 +181 +182 +183 +184 +185 +186 +187 +188 +189 +190 +191 +192 +193 +194 +195 +196 +197 +198 +199 +200 +201 +202 +203 +204 +205 +206 +207 +208 +209 +210 +211 +212 +213 +214 +215 +216 +217 +218 +219 +220 +221 +222 +223 +224 +225 +226 +227 +228 +229 +230 +231 +232 +233 +234 +235 +236 +237 +238 +239 +240 +241 +242 +243 +244 +245 +246 +247 +248 +249 +250 +251 +252 +253 +254 +255 +256 +257 +258 +259 +260 +261 +262 +263 +264 +265 +266 +267 +268 +269 +270 +271 +272 +273 +274 +275 +276 +277 +278 +279 +280 +281 +282 +283 +284 +285 +286 +287 +288 +289 +290 +291 +292 +293 +294 +295 +296 +297 +298 +299 +300 +301 +302 +303 +304 +305 +306 +307 +308 +309 +310 | + + + + + + + + + +2x + + + + + + +2x + + + + +2x + + + + +2x + + + + + + + + + + + + + + + + + +2x + + + + + + + +56x +2622x + + +56x +323x +1950x +1950x + + + + + + + +56x + + + +39x +38x + + + +848x + + + + + + + + +37x +37x + +37x +37x +37x +37x + +37x + +36x +36x +2x + + +36x +36x +2x + + +36x + +36x +1x +35x +31x +31x + +4x +4x +4x + + +36x + + +36x +4x +4x +4x + + + +36x + + + +69x +69x + + + +30x + + + + + + + + +30x + + + +300x +300x +300x + +300x +252x + +250x + +36x +36x +36x + +36x + + + +9x +9x +9x + +9x + + + + +9x +9x + + + +15x +15x +15x + +15x +15x +1x +1x +1x +1x +1x + +1x +1x + + +15x +15x + + + +15x +15x +15x + + + +15x +15x +15x +15x + + +15x + +15x +9x +9x +9x + + + +15x + + + + + + + + +15x + + + + + + + + + +15x + +15x +15x + +15x + +15x + +15x + +15x +15x +15x +15x +15x +15x + + + +15x +15x +15x +15x + +15x +15x + +15x + + + + +15x + + + + + + + + + + + + + + + + + + +15x + + + + + + + + + +3x + + + + | import {
+ Address,
+ MatchResult,
+ MatchDetails,
+ FieldMatch,
+ MatchingConfig,
+ NormalizedTokens,
+ PropertyType,
+} from './types';
+
+const DEFAULT_CONFIG: MatchingConfig = {
+ nameThreshold: 0.85,
+ addressThreshold: 0.9,
+ overallThreshold: 0.85,
+ geocodingRadiusMeters: 100,
+};
+
+const COMMON_PREFIXES = new Set([
+ 'mr', 'mrs', 'ms', 'miss', 'dr', 'prof', 'jr', 'sr', 'junior', 'senior',
+ 'ii', 'iii', 'iv', 'rev', 'st', 'hon', 'esq',
+]);
+
+const COMMON_SUFFIXES = new Set([
+ 'jr', 'sr', 'junior', 'senior', 'ii', 'iii', 'iv', 'v', 'esq',
+ 'phd', 'md', 'llm', 'cpa',
+]);
+
+const STREET_TYPE_MAP: Record<string, string> = {
+ 'st': 'street', 'street': 'street',
+ 'ave': 'avenue', 'avenue': 'avenue',
+ 'blvd': 'boulevard', 'boulevard': 'boulevard',
+ 'dr': 'drive', 'drive': 'drive',
+ 'ln': 'lane', 'lane': 'lane',
+ 'ct': 'court', 'court': 'court',
+ 'pl': 'place', 'place': 'place',
+ 'rd': 'road', 'road': 'road',
+ 'way': 'way',
+ 'trl': 'trail', 'trail': 'trail',
+ 'hwy': 'highway', 'highway': 'highway',
+ 'pkwy': 'parkway', 'parkway': 'parkway',
+ 'cir': 'circle', 'circle': 'circle',
+ 'sq': 'square', 'square': 'square',
+ 'ter': 'terrace', 'terrace': 'terrace',
+};
+
+const PROPERTY_TYPE_CONFIGS: Record<PropertyType, Partial<MatchingConfig>> = {
+ 'residential': { nameThreshold: 0.85, addressThreshold: 0.9 },
+ 'commercial': { nameThreshold: 0.8, addressThreshold: 0.9 },
+ 'land': { nameThreshold: 0.8, addressThreshold: 0.85 },
+ 'multi-family': { nameThreshold: 0.8, addressThreshold: 0.9 },
+};
+
+function levenshteinDistance(a: string, b: string): number {
+ const matrix: number[][] = Array.from({ length: b.length + 1 }, (_, i) =>
+ Array.from({ length: a.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
+ );
+
+ for (let i = 1; i <= b.length; i++) {
+ for (let j = 1; j <= a.length; j++) {
+ const cost = a[j - 1] === b[i - 1] ? 0 : 1;
+ matrix[i][j] = Math.min(
+ matrix[i - 1][j] + 1,
+ matrix[i][j - 1] + 1,
+ matrix[i - 1][j - 1] + cost,
+ );
+ }
+ }
+
+ return matrix[b.length][a.length];
+}
+
+function similarityScore(distance: number, maxLen: number): number {
+ if (maxLen === 0) return 1.0;
+ return 1.0 - distance / maxLen;
+}
+
+function normalizeString(str: string): string {
+ return str
+ .toLowerCase()
+ .replace(/[''']/g, '')
+ .replace(/[^a-z0-9\s]/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function parseName(name: string): NormalizedTokens {
+ const clean = normalizeString(name);
+ const parts = clean.split(' ').filter(Boolean);
+
+ let firstName = '';
+ let lastName = '';
+ let middleName = '';
+ const initials: string[] = [];
+
+ if (parts.length === 0) return { firstName, lastName, middleName, initials };
+
+ let startIdx = 0;
+ while (startIdx < parts.length && COMMON_PREFIXES.has(parts[startIdx])) {
+ startIdx++;
+ }
+
+ let endIdx = parts.length;
+ while (endIdx > startIdx + 1 && COMMON_SUFFIXES.has(parts[endIdx - 1])) {
+ endIdx--;
+ }
+
+ const coreParts = parts.slice(startIdx, endIdx);
+
+ if (coreParts.length === 1) {
+ lastName = coreParts[0];
+ } else if (coreParts.length === 2) {
+ firstName = coreParts[0];
+ lastName = coreParts[1];
+ } else {
+ firstName = coreParts[0];
+ lastName = coreParts[coreParts.length - 1];
+ middleName = coreParts.slice(1, -1).join(' ');
+ }
+
+ Iif (firstName.length === 1) {
+ initials.push(firstName);
+ }
+ if (middleName) {
+ const middleParts = middleName.split(' ');
+ for (const mp of middleParts) {
+ if (mp.length === 1) initials.push(mp);
+ }
+ }
+
+ return { firstName, lastName, middleName, initials };
+}
+
+function normalizeStreetType(type: string): string {
+ const clean = normalizeString(type);
+ return STREET_TYPE_MAP[clean] || clean;
+}
+
+function normalizeAddress(addr: Address): string {
+ const parts = [
+ addr.streetNumber,
+ normalizeString(addr.streetName),
+ addr.streetType ? normalizeStreetType(addr.streetType) : '',
+ addr.unit ? normalizeString(addr.unit) : '',
+ normalizeString(addr.city),
+ addr.state.toLowerCase(),
+ addr.zip,
+ ].filter(Boolean);
+ return parts.join(' ');
+}
+
+function computeFieldMatch(valueA: string, valueB: string, normalizeFn?: (v: string) => string): FieldMatch {
+ const normFn = normalizeFn || normalizeString;
+ const normalizedA = normFn(valueA);
+ const normalizedB = normFn(valueB);
+
+ if (!normalizedA && !normalizedB) return { valueA, valueB, normalizedA, normalizedB, score: 1.0 };
+ if (!normalizedA || !normalizedB) return { valueA, valueB, normalizedA, normalizedB, score: 0.0 };
+
+ if (normalizedA === normalizedB) return { valueA, valueB, normalizedA, normalizedB, score: 1.0 };
+
+ const dist = levenshteinDistance(normalizedA, normalizedB);
+ const maxLen = Math.max(normalizedA.length, normalizedB.length);
+ const score = similarityScore(dist, maxLen);
+
+ return { valueA, valueB, normalizedA, normalizedB, score: Math.round(score * 1000) / 1000 };
+}
+
+function haversineDistance(lat1: number, lon1: number, lat2: number, lon2: number): number {
+ const R = 6371000;
+ const dLat = ((lat2 - lat1) * Math.PI) / 180;
+ const dLon = ((lon2 - lon1) * Math.PI) / 180;
+ const a =
+ Math.sin(dLat / 2) * Math.sin(dLat / 2) +
+ Math.cos((lat1 * Math.PI) / 180) *
+ Math.cos((lat2 * Math.PI) / 180) *
+ Math.sin(dLon / 2) *
+ Math.sin(dLon / 2);
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+ return R * c;
+}
+
+function computeNameScore(tokensA: NormalizedTokens, tokensB: NormalizedTokens): number {
+ const firstScore = computeFieldMatch(tokensA.firstName, tokensB.firstName).score;
+ const lastScore = computeFieldMatch(tokensA.lastName, tokensB.lastName).score;
+ const middleScore = computeFieldMatch(tokensA.middleName, tokensB.middleName).score;
+
+ let initialMatchScore = 1.0;
+ if (tokensA.initials.length > 0 || tokensB.initials.length > 0) {
+ const allInitialsA = new Set(tokensA.initials.map(i => i.toLowerCase()));
+ const allInitialsB = new Set(tokensB.initials.map(i => i.toLowerCase()));
+ let matched = 0;
+ for (const init of allInitialsA) {
+ Iif (allInitialsB.has(init)) matched++;
+ }
+ const total = Math.max(allInitialsA.size, allInitialsB.size);
+ initialMatchScore = total > 0 ? matched / total : 1.0;
+ }
+
+ const weighted = (lastScore * 0.45) + (firstScore * 0.35) + (middleScore * 0.1) + (initialMatchScore * 0.1);
+ return Math.round(weighted * 1000) / 1000;
+}
+
+function computeAddressScore(addrA: Address, addrB: Address, config: MatchingConfig): { score: number; geocodingDistance?: number } {
+ const numberMatch = computeFieldMatch(addrA.streetNumber, addrB.streetNumber).score;
+ const streetMatch = computeFieldMatch(addrA.streetName, addrB.streetName, normalizeString).score;
+ const typeMatch = computeFieldMatch(
+ addrA.streetType ? normalizeStreetType(addrA.streetType) : '',
+ addrB.streetType ? normalizeStreetType(addrB.streetType) : '',
+ ).score;
+ const unitMatch = computeFieldMatch(addrA.unit || '', addrB.unit || '').score;
+ const cityMatch = computeFieldMatch(addrA.city, addrB.city).score;
+ const stateMatch = computeFieldMatch(addrA.state, addrB.state).score;
+ const zipMatch = computeFieldMatch(addrA.zip, addrB.zip).score;
+
+ let geocodingDistance: number | undefined;
+ let geoScore = 0.0;
+
+ if (addrA.latitude && addrA.longitude && addrB.latitude && addrB.longitude) {
+ geocodingDistance = haversineDistance(addrA.latitude, addrA.longitude, addrB.latitude, addrB.longitude);
+ const maxDist = config.geocodingRadiusMeters;
+ geoScore = geocodingDistance <= maxDist ? 1.0 : Math.max(0, 1.0 - (geocodingDistance - maxDist) / (maxDist * 5));
+ }
+
+ const weighted =
+ (numberMatch * 0.2) +
+ (streetMatch * 0.25) +
+ (typeMatch * 0.1) +
+ (unitMatch * 0.1) +
+ (cityMatch * 0.1) +
+ (stateMatch * 0.1) +
+ (zipMatch * 0.1) +
+ (geoScore * (geocodingDistance !== undefined ? 0.05 : 0));
+
+ return { score: Math.round(weighted * 1000) / 1000, geocodingDistance };
+}
+
+export function matchRecords(
+ nameA: string,
+ addressA: Address,
+ nameB: string,
+ addressB: Address,
+ config?: Partial<MatchingConfig>,
+): MatchResult {
+ const effectiveConfig = { ...DEFAULT_CONFIG, ...config };
+
+ const tokensA = parseName(nameA);
+ const tokensB = parseName(nameB);
+
+ const nameScore = computeNameScore(tokensA, tokensB);
+
+ const { score: addressScore, geocodingDistance } = computeAddressScore(addressA, addressB, effectiveConfig);
+
+ const overallConfidence = Math.round((nameScore * 0.5 + addressScore * 0.5) * 1000) / 1000;
+
+ const firstMatch = computeFieldMatch(tokensA.firstName, tokensB.firstName);
+ const lastMatch = computeFieldMatch(tokensA.lastName, tokensB.lastName);
+ const middleMatch = computeFieldMatch(tokensA.middleName, tokensB.middleName);
+ const numberMatch = computeFieldMatch(addressA.streetNumber, addressB.streetNumber);
+ const streetMatch = computeFieldMatch(addressA.streetName, addressB.streetName, normalizeString);
+ const typeMatch = computeFieldMatch(
+ addressA.streetType ? normalizeStreetType(addressA.streetType) : '',
+ addressB.streetType ? normalizeStreetType(addressB.streetType) : '',
+ );
+ const unitMatch = computeFieldMatch(addressA.unit || '', addressB.unit || '');
+ const cityMatch = computeFieldMatch(addressA.city, addressB.city);
+ const stateMatch = computeFieldMatch(addressA.state, addressB.state);
+ const zipMatch = computeFieldMatch(addressA.zip, addressB.zip);
+
+ const normalizedA = normalizeAddress(addressA);
+ const normalizedB = normalizeAddress(addressB);
+
+ const dist = levenshteinDistance(
+ normalizeString(nameA),
+ normalizeString(nameB),
+ );
+
+ const details: MatchDetails = {
+ nameNormalized: [normalizeString(nameA), normalizeString(nameB)],
+ addressNormalized: [normalizedA, normalizedB],
+ levenshteinDistance: dist,
+ geocodingDistance,
+ fields: {
+ firstName: firstMatch,
+ lastName: lastMatch,
+ middleName: middleMatch,
+ streetNumber: numberMatch,
+ streetName: streetMatch,
+ streetType: typeMatch,
+ unit: unitMatch,
+ city: cityMatch,
+ state: stateMatch,
+ zip: zipMatch,
+ },
+ };
+
+ return {
+ nameScore,
+ addressScore,
+ overallConfidence,
+ isMatch: overallConfidence >= effectiveConfig.overallThreshold,
+ details,
+ };
+}
+
+export function getConfigForPropertyType(type: PropertyType): MatchingConfig {
+ return { ...DEFAULT_CONFIG, ...PROPERTY_TYPE_CONFIGS[type] };
+}
+
+export { parseName, normalizeString, normalizeStreetType, levenshteinDistance, similarityScore };
+ |