← back to Nationalrealestate

src/ingest/parcels/owner_guard.ts

148 lines

// Shared owner-name sanity guard for every parcel ingester.
//
// Why this exists: county owner feeds occasionally carry a value in the owner
// field that is not an owner name at all — it's a placeholder token or a stray
// identifier that leaked in from an adjacent column. Two defect shapes recur in
// the local mirror (usre.parcel):
//
//   1. PLACEHOLDER TOKENS — the feed fills the owner column with a literal
//      stand-in when the real owner is withheld/unknown: "CURRENT OWNER" (809),
//      "UNKNOWN" (719), "OWNER OF RECORD" (32), "OWNER" (28), "CURRENT RESIDENT"
//      (25), "TAXPAYER" (16), plus "NA"/"NONE"/"N/A"/"NULL"/"PRIVATE"/"REDACTED".
//      ~1,641 such rows, spread across cook_il (17031), deschutes (41017),
//      wake_nc (37183), and NYC boroughs.
//
//   2. NON-NAME JUNK — a numeric/punctuation-only value with NO letter at all:
//      Cook County PINs like "09364120170000", address fragments like "189-191",
//      or a bare year "1960" that landed in the owner field. ~144 such rows. A
//      real person or entity name always contains at least one letter, so a
//      value with zero letters is never a valid owner.
//
// Before this util, ingesters emitted owner_name with a variety of one-off
// helpers (`s()`, `clean()`, `.trim() || null`) that pass these straight
// through. This centralizes ONE tested rule, applied at the shared upsert
// chokepoint, so every owner-emit point drops the same defect to null.
//
// IMPORTANT — what this does NOT do (over-correction guard):
//   * It does NOT reject short names. Two-character owner values (JS, MH, DM,
//     "PI" ×126) are legitimate initials / short entity names and are KEPT —
//     the earlier "len <= 2 is junk" hypothesis was rejected after inspecting
//     the data (those are real owners, not defects).
//   * It does NOT reject a name merely because it contains digits — "3M CO",
//     "7-ELEVEN INC", "1ST NATIONAL BANK", "JOHN SMITH 2ND" are all real; the
//     rule is "has at least one LETTER", not "has no digits".
//   * A NULL/empty owner is left as null (already-absent data, not a defect to
//     act on — many feeds legitimately withhold owner for privacy).
//
// Contract: input is a raw string/nullish owner value. Output is a cleaned,
// trimmed, whitespace-collapsed owner string, or null when the input is missing,
// empty, a known placeholder token, or contains no letters. It never throws and
// never mutates its input.

// Placeholder tokens that mean "no real owner given" — compared case-insensitively
// against the trimmed, whitespace-collapsed value.
const PLACEHOLDER_TOKENS = new Set<string>([
  'OWNER',
  'CURRENT OWNER',
  'CURRENT RESIDENT',
  'OWNER OF RECORD',
  'TAXPAYER',
  'PRIVATE',
  'REDACTED',
  'UNKNOWN',
  'NONE',
  'NULL',
  'N/A',
  'NA',
  'TBD',
  'NOT AVAILABLE',
  'NOT ON FILE',
  'NOT PROVIDED',
  'SEE OWNER',
]);

/**
 * Parse and sanity-check a raw owner name.
 *
 * Returns a cleaned owner string, or null when the input is missing, blank, a
 * known placeholder token (case-insensitive), or contains no letter at all
 * (numeric/punctuation-only leak). Legitimate short names and names containing
 * digits are preserved.
 *
 * @param raw  an owner name as string, null, or undefined
 */
export function sanitizeOwnerName(raw: string | null | undefined): string | null {
  if (raw == null) return null;
  // Collapse internal whitespace runs and trim so "  CURRENT   OWNER " matches
  // the placeholder set and so the stored value is tidy.
  const cleaned = String(raw).replace(/\s+/g, ' ').trim();
  if (cleaned === '') return null;
  if (PLACEHOLDER_TOKENS.has(cleaned.toUpperCase())) return null;
  // A real owner name has at least one letter (ASCII or Unicode). A value with
  // zero letters (all digits / punctuation) is a leaked identifier, not a name.
  if (!/\p{L}/u.test(cleaned)) return null;
  return cleaned;
}

// --- self-test (run: `tsx src/ingest/parcels/owner_guard.ts`) ----------------
// No test framework is installed in this repo, so the util ships its own
// deterministic self-test that exits non-zero on any failure. Runs only when
// executed directly, never on import.
const isMain = (() => {
  try {
    return typeof process !== 'undefined'
      && process.argv[1] != null
      && import.meta.url === new URL(`file://${process.argv[1]}`).href;
  } catch { return false; }
})();

if (isMain) {
  const cases: Array<[string | null | undefined, string | null, string]> = [
    // [input, expected, label]
    ['JOHN SMITH', 'JOHN SMITH', 'ordinary owner name kept'],
    ['Smith, John A', 'Smith, John A', 'name with comma/initial kept'],
    ['  SMITH   FAMILY   TRUST ', 'SMITH FAMILY TRUST', 'whitespace collapsed + trimmed'],
    ['JS', 'JS', 'legit 2-char initials kept (NOT over-corrected)'],
    ['PI', 'PI', 'legit 2-char entity kept (126 rows in mirror)'],
    ['C', 'C', 'legit single-letter kept'],
    ['3M CO', '3M CO', 'name starting with a digit kept'],
    ['7-ELEVEN INC', '7-ELEVEN INC', 'digit+punct+letters kept'],
    ['1ST NATIONAL BANK', '1ST NATIONAL BANK', 'ordinal-with-letters kept'],
    ["O'BRIEN & SONS", "O'BRIEN & SONS", 'apostrophe/ampersand name kept'],
    ['CURRENT OWNER', null, 'placeholder token rejected (809 rows)'],
    ['current owner', null, 'placeholder token case-insensitive'],
    ['UNKNOWN', null, 'UNKNOWN placeholder rejected (719 rows)'],
    ['OWNER', null, 'OWNER placeholder rejected'],
    ['OWNER OF RECORD', null, 'OWNER OF RECORD rejected'],
    ['TAXPAYER', null, 'TAXPAYER rejected'],
    ['N/A', null, 'N/A rejected'],
    ['NA', null, 'NA rejected'],
    ['NONE', null, 'NONE rejected'],
    ['NULL', null, 'literal NULL string rejected'],
    ['09364120170000', null, 'all-digit Cook PIN leak rejected'],
    ['189-191', null, 'address fragment (digits+dash, no letter) rejected'],
    ['1960', null, 'bare year leaked into owner rejected'],
    ['14 21 101 054 2417', null, 'spaced numeric PIN rejected'],
    ['.', null, 'lone punctuation rejected'],
    ['---', null, 'all-dash rejected'],
    ['', null, 'empty string -> null'],
    ['   ', null, 'whitespace-only -> null'],
    [null, null, 'null -> null'],
    [undefined, null, 'undefined -> null'],
  ];
  let failed = 0;
  for (const [input, expected, label] of cases) {
    const got = sanitizeOwnerName(input);
    const ok = got === expected;
    if (!ok) {
      failed++;
      console.error(`FAIL: ${label}\n  input=${JSON.stringify(input)} expected=${JSON.stringify(expected)} got=${JSON.stringify(got)}`);
    }
  }
  if (failed) {
    console.error(`\n${failed}/${cases.length} sanitizeOwnerName cases FAILED`);
    process.exit(1);
  }
  console.log(`sanitizeOwnerName: all ${cases.length} cases passed`);
}