[object Object]

← back to Rentv

pr-intelligence: Cody c3 — two-tier name predicate (HARD title/product words reject anywhere; SOFT filler needs all-tokens), 16/16 known junk rejected, 10/10 real names pass

b3dfe478cbf1159f06c120302e2d34bc3c043f16 · 2026-07-30 20:04:04 -0700 · Steve Abrams

Files touched

Diff

commit b3dfe478cbf1159f06c120302e2d34bc3c043f16
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 30 20:04:04 2026 -0700

    pr-intelligence: Cody c3 — two-tier name predicate (HARD title/product words reject anywhere; SOFT filler needs all-tokens), 16/16 known junk rejected, 10/10 real names pass
---
 src/pr/lib/normalize.js     | 51 ++++++++++++++++++++++++++++++---------------
 test/pr/integration.test.js |  4 ++--
 2 files changed, 36 insertions(+), 19 deletions(-)

diff --git a/src/pr/lib/normalize.js b/src/pr/lib/normalize.js
index 0811478c..a959490c 100644
--- a/src/pr/lib/normalize.js
+++ b/src/pr/lib/normalize.js
@@ -71,34 +71,51 @@ function normalizeLinkedInUrl(url) {
   return `https://www.linkedin.com/${m[1].toLowerCase()}/${m[2].replace(/\/+$/, '')}`;
 }
 
-// Nav-chrome / section-header words that masquerade as Name-Case people on team pages
-// ("Who We Are", "Personal Login", "Leaving Exchange Bank" — all confirmed in prod).
-const COMMON_NONNAME_WORDS = new Set([
+// Two-tier non-name vocabulary (Cody gates, cycles 2+3).
+// HARD: title / product / section words that are NEVER part of a wanted human name —
+//   ANY occurrence rejects ("Chief People Officer", "First Industrial Realty",
+//   "Statement Savings Account" all die here even when other tokens look name-ish).
+// SOFT: filler that only rejects when the WHOLE string is filler ("Who We Are") —
+//   so "Larry Page"-class real names with one ambiguous token survive.
+const HARD_NONNAME_WORDS = new Set([
+  'chief', 'officer', 'officers', 'president', 'vice', 'deputy', 'manager', 'director',
+  'directors', 'executive', 'senior', 'junior', 'associate', 'assistant', 'coordinator',
+  'specialist', 'analyst', 'administrator', 'admin', 'oversight', 'leadership',
+  'management', 'market', 'markets', 'money', 'savings', 'checking', 'account',
+  'accounts', 'loan', 'loans', 'mortgage', 'deposit', 'deposits', 'realty', 'properties',
+  'commercial', 'residential', 'industrial', 'intelligence', 'spotlight', 'industry',
+  'success', 'stories', 'story', 'choose', 'features', 'feature', 'improvement',
+  'performance', 'venues', 'venue', 'center', 'centers', 'centre', 'data', 'solutions',
+  'strategies', 'insights', 'overview', 'resources', 'foundation', 'login', 'banking',
+  'welcome', 'careers', 'privacy', 'policy', 'profile', 'profiles', 'employee',
+  'employees', 'portal', 'menu', 'website', 'contact', 'services', 'staff', 'why',
+  'how', 'what', 'statement', 'client', 'clients', 'testimonials', 'awards',
+]);
+const SOFT_NONNAME_WORDS = new Set([
   'who', 'we', 'are', 'our', 'your', 'the', 'and', 'about', 'us', 'meet', 'team',
-  'board', 'directors', 'director', 'management', 'leadership', 'staff', 'people',
-  'login', 'log', 'sign', 'online', 'personal', 'business', 'banking', 'bank',
-  'welcome', 'home', 'contact', 'leaving', 'exchange', 'visit', 'partner', 'partners',
-  'website', 'privacy', 'policy', 'careers', 'news', 'events', 'services', 'search',
-  'menu', 'group', 'company', 'officers', 'executive', 'senior', 'more', 'view', 'all',
-  'of', 'for', 'with', 'from', 'to', 'in', 'at', 'on', 'read', 'learn', 'get', 'our',
-  'profile', 'profiles', 'employee', 'employees', 'account', 'portal', 'apply', 'join',
-  'see', 'click', 'bio', 'bios', 'info', 'details', 'page', 'load', 'show', 'full',
+  'board', 'people', 'home', 'news', 'events', 'search', 'group', 'company', 'more',
+  'view', 'all', 'of', 'for', 'with', 'from', 'to', 'in', 'at', 'on', 'read', 'learn',
+  'get', 'see', 'click', 'bio', 'bios', 'info', 'details', 'page', 'load', 'show',
+  'full', 'apply', 'join', 'visit', 'partner', 'partners', 'business', 'bank', 'banks',
+  'community', 'online', 'personal', 'leaving', 'exchange', 'sign', 'log', 'head',
+  'lead', 'agent', 'live', 'first', 'trust', 'united', 'american', 'pacific',
 ]);
 
 /**
- * Is this string plausibly a HUMAN name? (Cody gate, cycle 2: nav chrome like
- * "Who We Are" classified as leadership and polluted the people table.)
- * Requires 2–4 tokens, each ≥2 chars, no digits/HTML entities, ≤40 chars, and at
- * least one token that is NOT a common non-name word.
+ * Is this string plausibly a HUMAN name? Shape: 2–4 alpha tokens, ≤40 chars, no
+ * digits/entities; NO hard non-name token anywhere; at least one token that isn't
+ * soft filler. Callers can override for legit unusual names (audited).
  */
 function isLikelyPersonName(name) {
   const s = String(name || '').trim();
   if (s.length < 4 || s.length > 40) return false;
-  if (/[&<>@\d;#]/.test(s)) return false;                 // entities, emails, digits
+  if (/[&<>@\d;#|]/.test(s)) return false;                // entities, emails, digits, pipes
   const tokens = s.split(/\s+/);
   if (tokens.length < 2 || tokens.length > 4) return false;
   if (!tokens.every((t) => /^[A-Za-z'’.-]{2,}$/.test(t) || /^[A-Z]\.?$/.test(t))) return false;
-  const hasProper = tokens.some((t) => !COMMON_NONNAME_WORDS.has(t.toLowerCase().replace(/[^a-z]/g, '')));
+  const low = tokens.map((t) => t.toLowerCase().replace(/[^a-z]/g, ''));
+  if (low.some((t) => HARD_NONNAME_WORDS.has(t))) return false;
+  const hasProper = low.some((t) => !SOFT_NONNAME_WORDS.has(t));
   return hasProper;
 }
 
diff --git a/test/pr/integration.test.js b/test/pr/integration.test.js
index b4bf54c2..2dd03cd8 100644
--- a/test/pr/integration.test.js
+++ b/test/pr/integration.test.js
@@ -97,8 +97,8 @@ test('inferred email can be stored but NEVER becomes outreach-eligible', async (
 
 test('campaign enrollment is transactional and skips suppressed contacts', async () => {
   const org = (await organizations.create({ display_name: 'Campaign Org', website_url: 'https://campaign-org-test.com', organization_type: 'pr_agency', state_presence: ['CA'], metros: ['la'] }, { evidence: SRC('campaign org'), actor: 'test' })).org;
-  const good = (await people.create({ full_name: 'Good Contact', exact_title: 'Account Director', organization_id: org.id, public_work_email: 'good@campaign-org-test.com', email_verification_status: 'company_site_verified' }, { evidence: SRC('good'), actor: 'test' })).person;
-  const bad = (await people.create({ full_name: 'Suppressed Contact', exact_title: 'Media Relations Director', organization_id: org.id, public_work_email: 'stop@campaign-org-test.com', email_verification_status: 'company_site_verified' }, { evidence: SRC('bad'), actor: 'test' })).person;
+  const good = (await people.create({ full_name: 'Gwen Goodman', exact_title: 'Account Director', organization_id: org.id, public_work_email: 'good@campaign-org-test.com', email_verification_status: 'company_site_verified' }, { evidence: SRC('good'), actor: 'test' })).person;
+  const bad = (await people.create({ full_name: 'Sam Suppressa', exact_title: 'Media Relations Director', organization_id: org.id, public_work_email: 'stop@campaign-org-test.com', email_verification_status: 'company_site_verified' }, { evidence: SRC('bad'), actor: 'test' })).person;
   await suppression.add({ email: 'stop@campaign-org-test.com', person_id: bad.id, reason: 'opt_out', actor: 'test' });
   const camp = await campaigns.create({ name: 'Test Campaign', state: 'CA' }, 'test');
   const res = await campaigns.enroll(camp.id, [good.id, bad.id], 'test');

← d37f9138 pr-intelligence: headless team-page extractor (local Playwri  ·  back to Rentv  ·  yoloforever: cycle 3 ledger (SHIP 3/3 local) 6ffb2d7c →