rmhladder Plan 1/5: Foundation (Schema, Classifiers, Scoring, Seed) Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Prisma schema, company seed data, and all pure domain logic (normalization/dedupe, US-location classifier, early-career classifier, relevance scoring, verification status logic) for the rmhladder job tracker — fully unit-tested, no network.
Architecture: All domain logic lives in lib/rmhladder/ as pure functions consumed later by the ladder-worker pipeline (Plan 3) and TanStack routes (Plan 4). Schema adds Ladder* models to the existing prisma/schema.prisma. Spec: docs/superpowers/specs/2026-07-05-rmhladder-design.md.
Tech Stack: TypeScript (strict), Prisma 7 + PostgreSQL, vitest, node:crypto, pnpm.
Global Constraints¶
All new models/enums prefixed
Ladderinprisma/schema.prisma; snake_case enum values exactly as listed in the spec.All domain code under
lib/rmhladder/; tests colocated as*.test.ts(vitest root config picks them up).No network calls anywhere in this plan. No new dependencies.
Run tests with
pnpm exec vitest run <path>. Run all-plan tests withpnpm exec vitest run lib/rmhladder.Commit after every green test cycle. Conventional commits (
feat:,test:style matches repo history).DB commands:
pnpm db:generate(client),pnpm db:push(dev sync). Neverdb:reset.The existing better-auth
Usermodel inprisma/schema.prismagains back-relation fields only — do not otherwise modify it.
Task 1: Prisma schema — enums + global job models¶
Files:
Modify:
prisma/schema.prisma(append at end; also add back-relations toUserin Task 2 only)
Interfaces:
Produces: Prisma models
LadderCompany,LadderSource,LadderJob,LadderVerification,LadderScrapeRun,LadderSourceError,LadderReviewTask,LadderRelevanceRuleand all enums below, available via@prisma/client.Step 1: Append enums + global models to
prisma/schema.prisma
// ---------- rmhladder ----------
enum LadderPlatform { greenhouse lever ashby smartrecruiters manual generic }
enum LadderSourceStatus { active unconfigured blocked error disabled }
enum LadderJobStatus { active expired unknown }
enum LadderRemoteStatus { onsite hybrid remote_us }
enum LadderEmploymentType { internship full_time }
enum LadderProgramType { internship summer_analyst summer_associate analyst_program rotational_program new_grad leadership_development entry_level mba other }
enum LadderEarlyCareer { yes probable no unclear }
enum LadderVerificationStatus { verified_active verified_probable unverified expired broken_link duplicate non_us_role blocked_or_inaccessible needs_manual_review }
enum LadderReviewReason { broken_link blocked js_required possible_duplicate ambiguous_early_career ambiguous_us_location low_confidence aggregator_unconfirmed }
enum LadderReviewStatus { open resolved dismissed }
enum LadderRunTrigger { cron manual }
model LadderCompany {
id String @id @default(nanoid())
name String
normalizedName String @unique
industry String
firmType String
priorityLevel Int @default(3) // 1 high .. 5 low
careerUrl String?
usEarlyCareerUrl String?
campusUrl String?
notes String?
enabled Boolean @default(true)
createdAt DateTime @default(now())
sources LadderSource[]
jobs LadderJob[]
watchlistEntries LadderWatchlistEntry[]
}
model LadderSource {
id String @id @default(nanoid())
companyId String
company LadderCompany @relation(fields: [companyId], references: [id], onDelete: Cascade)
platform LadderPlatform
slug String? // board slug for API platforms
url String? // page URL for manual/generic
status LadderSourceStatus @default(unconfigured)
lastSuccessAt DateTime?
createdAt DateTime @default(now())
errors LadderSourceError[]
@@unique([companyId, platform, slug])
}
model LadderJob {
id String @id @default(nanoid())
companyId String
company LadderCompany @relation(fields: [companyId], references: [id])
title String
normalizedTitle String
roleCategory String?
programType LadderProgramType @default(other)
industry String?
locationRaw String?
city String?
state String?
country String @default("US")
remoteStatus LadderRemoteStatus @default(onsite)
employmentType LadderEmploymentType @default(internship)
postingDate DateTime?
applicationDeadline DateTime?
startSeason String? // e.g. "Summer 2027"
graduationYearTarget Int?
schoolYearTarget String?
sourcePlatform LadderPlatform
sourceUrl String
originalPostingUrl String
canonicalApplyUrl String?
externalRequisitionId String?
descriptionSummary String?
fullDescription String?
dedupeHash String @unique
alternateUrls String[] @default([])
matchingKeywords String[] @default([])
status LadderJobStatus @default(unknown)
failedCheckCount Int @default(0)
earlyCareerScore Int @default(0)
earlyCareerClassification LadderEarlyCareer @default(unclear)
usLocationConfidence Int @default(0)
relevanceScoreBase Int @default(0)
urgencyFlag Boolean @default(false)
discoveredAt DateTime @default(now())
lastCheckedAt DateTime?
lastVerifiedAt DateTime?
verifications LadderVerification[]
reviewTasks LadderReviewTask[]
actions LadderJobAction[]
applications LadderApplication[]
alerts LadderAlert[]
@@index([status, earlyCareerClassification])
@@index([companyId, status])
}
model LadderVerification {
id String @id @default(nanoid())
jobId String
job LadderJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
status LadderVerificationStatus
confidence Int // 0-100
evidence String
checkedAt DateTime @default(now())
@@index([jobId, checkedAt])
}
model LadderScrapeRun {
id String @id @default(nanoid())
trigger LadderRunTrigger
startedAt DateTime @default(now())
finishedAt DateTime?
discoveredCount Int @default(0)
newCount Int @default(0)
verifiedCount Int @default(0)
expiredCount Int @default(0)
errorCount Int @default(0)
stats Json?
errors LadderSourceError[]
}
model LadderSourceError {
id String @id @default(nanoid())
runId String
run LadderScrapeRun @relation(fields: [runId], references: [id], onDelete: Cascade)
sourceId String
source LadderSource @relation(fields: [sourceId], references: [id], onDelete: Cascade)
errorClass String
message String
httpStatus Int?
createdAt DateTime @default(now())
}
model LadderReviewTask {
id String @id @default(nanoid())
jobId String?
job LadderJob? @relation(fields: [jobId], references: [id], onDelete: Cascade)
sourceId String?
reason LadderReviewReason
status LadderReviewStatus @default(open)
resolution String?
resolvedById String?
createdAt DateTime @default(now())
resolvedAt DateTime?
}
model LadderRelevanceRule {
id String @id @default(nanoid())
key String @unique // e.g. "program:summer_analyst", "industry:investment_banking"
label String
weight Int
enabled Boolean @default(true)
}
Note: if the repo’s schema uses cuid()/uuid() id defaults instead of nanoid(), match the existing convention — check the first existing model and mirror it.
Step 2: Validate + generate
Run: pnpm exec prisma validate && pnpm db:generate
Expected: The schema at prisma/schema.prisma is valid and client generation succeeds.
Step 3: Push to dev DB
Run: pnpm db:push
Expected: Your database is now in sync with your Prisma schema.
Step 4: Commit
git add prisma/schema.prisma
git commit -m "feat(rmhladder): add global job-tracking schema (companies, sources, jobs, verifications, runs)"
Task 2: Prisma schema — per-user models + User back-relations¶
Files:
Modify:
prisma/schema.prisma(append models; add back-relation lines inside existingmodel User)
Interfaces:
Consumes: Task 1 models/enums.
Produces:
LadderUserPrefs,LadderKeyword,LadderWatchlistEntry,LadderJobAction,LadderApplication,LadderAlert+ enumsLadderKeywordType,LadderJobActionType,LadderApplicationStatus,LadderAlertChannel,LadderAlertType,LadderDigestFrequency. Alert dup-guard:@@unique([userId, jobId, type])onLadderAlert.Step 1: Append per-user models
enum LadderKeywordType { boost block }
enum LadderJobActionType { saved applied ignored }
enum LadderApplicationStatus { not_applied planning applied networking interviewing final_round rejected offer withdrawn }
enum LadderAlertChannel { in_app email discord }
enum LadderAlertType { immediate daily_digest weekly_digest deadline changed expired review_needed }
enum LadderDigestFrequency { immediate daily weekly }
model LadderUserPrefs {
id String @id @default(nanoid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
relevanceThreshold Int @default(60)
preferredCities String[] @default([])
preferredProgramTypes LadderProgramType[] @default([])
digestFrequency LadderDigestFrequency @default(daily)
channelInApp Boolean @default(true)
channelEmail Boolean @default(false)
channelDiscord Boolean @default(false)
discordUserId String?
}
model LadderKeyword {
id String @id @default(nanoid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
keyword String
weight Int @default(10)
type LadderKeywordType @default(boost)
@@unique([userId, keyword, type])
}
model LadderWatchlistEntry {
id String @id @default(nanoid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
companyId String
company LadderCompany @relation(fields: [companyId], references: [id], onDelete: Cascade)
priority Int @default(3)
@@unique([userId, companyId])
}
model LadderJobAction {
id String @id @default(nanoid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
jobId String
job LadderJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
action LadderJobActionType
createdAt DateTime @default(now())
@@unique([userId, jobId])
}
model LadderApplication {
id String @id @default(nanoid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
jobId String
job LadderJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
status LadderApplicationStatus @default(not_applied)
appliedDate DateTime?
resumeVersion String?
coverLetter String?
referralName String?
contactEmail String?
notes String?
followUpDate DateTime?
interviewDates DateTime[] @default([])
outcome String?
updatedAt DateTime @updatedAt
@@unique([userId, jobId])
}
model LadderAlert {
id String @id @default(nanoid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
jobId String
job LadderJob @relation(fields: [jobId], references: [id], onDelete: Cascade)
channel LadderAlertChannel
type LadderAlertType
sentAt DateTime @default(now())
readAt DateTime?
@@unique([userId, jobId, type])
}
Inside the existing model User { ... }, add:
ladderPrefs LadderUserPrefs?
ladderKeywords LadderKeyword[]
ladderWatchlist LadderWatchlistEntry[]
ladderJobActions LadderJobAction[]
ladderApplications LadderApplication[]
ladderAlerts LadderAlert[]
Step 2: Validate + generate + push
Run: pnpm exec prisma validate && pnpm db:generate && pnpm db:push
Expected: valid, generated, Your database is now in sync.
Step 3: Commit
git add prisma/schema.prisma
git commit -m "feat(rmhladder): add per-user prefs, keywords, watchlist, applications, alerts schema"
Task 3: Normalization + dedupe hash¶
Files:
Create:
lib/rmhladder/normalize.tsTest:
lib/rmhladder/normalize.test.ts
Interfaces:
Produces:
normalizeCompanyName(name: string): stringnormalizeTitle(title: string): stringlocationBucket(input: { city?: string | null; state?: string | null; remoteStatus?: 'onsite'|'hybrid'|'remote_us' }): stringdedupeHash(company: string, title: string, bucket: string): string(sha256 hex ofnormalizeCompanyName(company)|normalizeTitle(title)|bucket)
Step 1: Write failing tests
// lib/rmhladder/normalize.test.ts
import { describe, expect, it } from 'vitest';
import { dedupeHash, locationBucket, normalizeCompanyName, normalizeTitle } from './normalize';
describe('normalizeCompanyName', () => {
it('lowercases, strips punctuation and corporate suffixes', () => {
expect(normalizeCompanyName('Moelis & Company')).toBe('moelis');
expect(normalizeCompanyName('Deere & Co.')).toBe('deere');
expect(normalizeCompanyName('BlackRock, Inc.')).toBe('blackrock');
expect(normalizeCompanyName("Moody's")).toBe('moodys');
expect(normalizeCompanyName('JPMorgan Chase')).toBe('jpmorgan chase');
});
it('never returns empty when the whole name is suffixes', () => {
expect(normalizeCompanyName('Partners Group')).toBe('partners group');
});
});
describe('normalizeTitle', () => {
it('lowercases, strips punctuation/req ids, collapses whitespace, keeps years', () => {
expect(normalizeTitle('Investment Banking Summer Analyst – 2027 (NYC) [R-12345]')).toBe(
'investment banking summer analyst 2027 nyc',
);
expect(normalizeTitle('Software Engineering Intern')).toBe('software engineering intern');
});
});
describe('locationBucket', () => {
it('remote beats city; city+state; state only; fallback us', () => {
expect(locationBucket({ city: 'New York', state: 'NY', remoteStatus: 'remote_us' })).toBe('remote-us');
expect(locationBucket({ city: 'New York', state: 'NY' })).toBe('new york-ny');
expect(locationBucket({ state: 'TX' })).toBe('tx');
expect(locationBucket({})).toBe('us');
});
});
describe('dedupeHash', () => {
it('is stable and insensitive to formatting differences', () => {
const a = dedupeHash('BlackRock, Inc.', 'Summer Analyst – 2027', 'new york-ny');
const b = dedupeHash('blackrock', 'summer analyst 2027', 'new york-ny');
expect(a).toBe(b);
expect(a).toMatch(/^[0-9a-f]{64}$/);
});
it('differs when year differs', () => {
expect(dedupeHash('X', 'Summer Analyst 2027', 'us')).not.toBe(dedupeHash('X', 'Summer Analyst 2028', 'us'));
});
});
Step 2: Run to verify failure
Run: pnpm exec vitest run lib/rmhladder/normalize.test.ts
Expected: FAIL — cannot resolve ./normalize.
Step 3: Implement
// lib/rmhladder/normalize.ts
import { createHash } from 'node:crypto';
const COMPANY_SUFFIXES =
/\b(incorporated|inc|llc|llp|lp|ltd|plc|corp|corporation|company|co|group|holdings|partners|management|capital markets)\b\.?/g;
export function normalizeCompanyName(name: string): string {
const cleaned = name
.toLowerCase()
.replace(/&/g, ' ')
.replace(/['’]/g, '')
.replace(/[^a-z0-9 ]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
const stripped = cleaned.replace(COMPANY_SUFFIXES, ' ').replace(/\s+/g, ' ').trim();
// Guard: "Partners Group" would strip to nothing — fall back to the pre-suffix form.
return stripped || cleaned;
}
export function normalizeTitle(title: string): string {
return title
.toLowerCase()
.replace(/\[[^\]]*\]|\(r-?\d+\)|\br-?\d{4,}\b/g, ' ') // req-id noise
.replace(/['’]/g, '')
.replace(/[^a-z0-9 ]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function locationBucket(input: {
city?: string | null;
state?: string | null;
remoteStatus?: 'onsite' | 'hybrid' | 'remote_us';
}): string {
if (input.remoteStatus === 'remote_us') return 'remote-us';
const city = input.city?.trim().toLowerCase();
const state = input.state?.trim().toLowerCase();
if (city && state) return `${city}-${state}`;
if (state) return state;
return 'us';
}
export function dedupeHash(company: string, title: string, bucket: string): string {
const key = `${normalizeCompanyName(company)}|${normalizeTitle(title)}|${bucket}`;
return createHash('sha256').update(key).digest('hex');
}
Note: normalizeCompanyName('Moelis & Company') — &→space, suffix company stripped → moelis. If the suffix regex strips a word you need (e.g. “Capital Group”), that is acceptable for hashing (both sides normalize identically); adjust tests only if a real collision between two different seeded companies appears — add a test proving the collision first.
Step 4: Run to verify pass
Run: pnpm exec vitest run lib/rmhladder/normalize.test.ts
Expected: PASS (all).
Step 5: Commit
git add lib/rmhladder/normalize.ts lib/rmhladder/normalize.test.ts
git commit -m "feat(rmhladder): normalization + dedupe hashing"
Task 4: US-location classifier¶
Files:
Create:
lib/rmhladder/classifiers/us-location.tsTest:
lib/rmhladder/classifiers/us-location.test.ts
Interfaces:
Produces:
interface LocationInput { locationRaw?: string | null; city?: string | null; state?: string | null; country?: string | null; } interface LocationResult { isUS: boolean | null; confidence: number; city: string | null; state: string | null; remoteStatus: 'onsite' | 'hybrid' | 'remote_us'; } function classifyUSLocation(input: LocationInput): LocationResult
isUS: nullmeans unclear (confidence < 50) → pipeline routes to review withambiguous_us_location.Step 1: Write failing tests
// lib/rmhladder/classifiers/us-location.test.ts
import { describe, expect, it } from 'vitest';
import { classifyUSLocation } from './us-location';
describe('classifyUSLocation', () => {
it('explicit country wins', () => {
expect(classifyUSLocation({ country: 'United States', city: 'New York' }).isUS).toBe(true);
expect(classifyUSLocation({ country: 'US' }).isUS).toBe(true);
expect(classifyUSLocation({ country: 'United Kingdom', locationRaw: 'London' }).isUS).toBe(false);
});
it('parses "City, ST" raw locations from the gazetteer', () => {
const r = classifyUSLocation({ locationRaw: 'Charlotte, NC' });
expect(r).toMatchObject({ isUS: true, city: 'Charlotte', state: 'NC' });
expect(r.confidence).toBeGreaterThanOrEqual(80);
});
it('detects Remote US', () => {
expect(classifyUSLocation({ locationRaw: 'Remote - US' }).remoteStatus).toBe('remote_us');
expect(classifyUSLocation({ locationRaw: 'Remote (United States)' }).isUS).toBe(true);
expect(classifyUSLocation({ locationRaw: 'Hybrid - New York, NY' }).remoteStatus).toBe('hybrid');
});
it('full state names count', () => {
expect(classifyUSLocation({ locationRaw: 'Austin, Texas' })).toMatchObject({ isUS: true, state: 'TX' });
});
it('known non-US cities are rejected', () => {
expect(classifyUSLocation({ locationRaw: 'London' }).isUS).toBe(false);
expect(classifyUSLocation({ locationRaw: 'Toronto, ON' }).isUS).toBe(false);
});
it('unknown location is unclear, low confidence', () => {
const r = classifyUSLocation({ locationRaw: 'Main Campus' });
expect(r.isUS).toBeNull();
expect(r.confidence).toBeLessThan(50);
});
});
Step 2: Run to verify failure
Run: pnpm exec vitest run lib/rmhladder/classifiers/us-location.test.ts
Expected: FAIL — cannot resolve ./us-location.
Step 3: Implement
// lib/rmhladder/classifiers/us-location.ts
export interface LocationInput {
locationRaw?: string | null;
city?: string | null;
state?: string | null;
country?: string | null;
}
export interface LocationResult {
isUS: boolean | null;
confidence: number;
city: string | null;
state: string | null;
remoteStatus: 'onsite' | 'hybrid' | 'remote_us';
}
const STATE_NAMES: Record<string, string> = {
alabama: 'AL', alaska: 'AK', arizona: 'AZ', arkansas: 'AR', california: 'CA', colorado: 'CO',
connecticut: 'CT', delaware: 'DE', florida: 'FL', georgia: 'GA', hawaii: 'HI', idaho: 'ID',
illinois: 'IL', indiana: 'IN', iowa: 'IA', kansas: 'KS', kentucky: 'KY', louisiana: 'LA',
maine: 'ME', maryland: 'MD', massachusetts: 'MA', michigan: 'MI', minnesota: 'MN',
mississippi: 'MS', missouri: 'MO', montana: 'MT', nebraska: 'NE', nevada: 'NV',
'new hampshire': 'NH', 'new jersey': 'NJ', 'new mexico': 'NM', 'new york': 'NY',
'north carolina': 'NC', 'north dakota': 'ND', ohio: 'OH', oklahoma: 'OK', oregon: 'OR',
pennsylvania: 'PA', 'rhode island': 'RI', 'south carolina': 'SC', 'south dakota': 'SD',
tennessee: 'TN', texas: 'TX', utah: 'UT', vermont: 'VT', virginia: 'VA', washington: 'WA',
'west virginia': 'WV', wisconsin: 'WI', wyoming: 'WY', 'district of columbia': 'DC',
};
const STATE_ABBREVS = new Set(Object.values(STATE_NAMES));
const US_COUNTRY = /^(us|usa|u\.s\.?a?\.?|united states( of america)?)$/i;
// word-bounded so "Indianapolis" doesn't match "india"
const NON_US_COUNTRY_HINT = /\b(kingdom|canada|india|singapore|australia|germany|france|japan|china|ireland|switzerland|mexico|brazil|poland|hong ?kong)\b/i;
// spec's 28 preferred locations get a confidence boost when matched
export const PREFERRED_CITIES = new Set([
'new york', 'charlotte', 'chicago', 'san francisco', 'los angeles', 'boston', 'washington',
'minneapolis', 'dallas', 'houston', 'atlanta', 'miami', 'seattle', 'austin', 'denver',
'philadelphia', 'nashville', 'phoenix', 'salt lake city', 'raleigh', 'jersey city',
'stamford', 'menlo park', 'palo alto', 'mountain view', 'san jose',
]);
const NON_US_CITIES = /\b(london|toronto|vancouver|montreal|paris|frankfurt|dublin|zurich|geneva|mumbai|bangalore|bengaluru|singapore|sydney|tokyo|hong ?kong|shanghai|beijing|warsaw|madrid|milan|amsterdam|tel aviv|mexico city|s[aã]o paulo)\b/i;
export function classifyUSLocation(input: LocationInput): LocationResult {
const raw = (input.locationRaw ?? '').trim();
const lower = raw.toLowerCase();
let remoteStatus: LocationResult['remoteStatus'] = 'onsite';
if (/\bhybrid\b/i.test(raw)) remoteStatus = 'hybrid';
else if (/\bremote\b/i.test(raw)) remoteStatus = 'remote_us'; // US-ness still checked below
// 1. Explicit country field
if (input.country) {
if (US_COUNTRY.test(input.country.trim())) {
const parsed = parseCityState(raw, input.city, input.state);
return { isUS: true, confidence: 95, remoteStatus, ...parsed };
}
return { isUS: false, confidence: 95, city: null, state: null, remoteStatus };
}
// 2. Non-US signals in raw string
if (NON_US_CITIES.test(lower) || NON_US_COUNTRY_HINT.test(lower)) {
return { isUS: false, confidence: 85, city: null, state: null, remoteStatus };
}
// 3. Remote + US markers
if (remoteStatus === 'remote_us') {
if (/\b(us|usa|united states)\b/i.test(lower) || !raw.replace(/remote|[-()]/gi, '').trim()) {
return { isUS: /\b(us|usa|united states)\b/i.test(lower) ? true : null,
confidence: /\b(us|usa|united states)\b/i.test(lower) ? 85 : 40,
city: null, state: null, remoteStatus };
}
}
// 4. City, ST / City, StateName patterns
const parsed = parseCityState(raw, input.city, input.state);
if (parsed.state) {
const conf = parsed.city && PREFERRED_CITIES.has(parsed.city.toLowerCase()) ? 90 : 80;
return { isUS: true, confidence: conf, remoteStatus, ...parsed };
}
// 5. Unclear
return { isUS: null, confidence: 30, city: parsed.city, state: null, remoteStatus };
}
function parseCityState(
raw: string,
cityField?: string | null,
stateField?: string | null,
): { city: string | null; state: string | null } {
let state = stateField?.trim().toUpperCase() ?? null;
if (state && !STATE_ABBREVS.has(state)) state = STATE_NAMES[state.toLowerCase()] ?? null;
let city = cityField?.trim() ?? null;
if (!state) {
const cleaned = raw.replace(/\b(hybrid|remote)\b\s*[-–]?\s*/gi, '');
const m = cleaned.match(/([A-Za-z .']+?),\s*([A-Za-z .]+)$/);
if (m) {
const cand = m[2].trim();
const abbr = cand.toUpperCase();
if (STATE_ABBREVS.has(abbr)) state = abbr;
else if (STATE_NAMES[cand.toLowerCase()]) state = STATE_NAMES[cand.toLowerCase()];
if (state && !city) city = m[1].trim();
}
}
return { city, state };
}
Step 4: Run to verify pass
Run: pnpm exec vitest run lib/rmhladder/classifiers/us-location.test.ts
Expected: PASS. If Remote - US case fails on the isUS value, check branch 3: Remote - US must hit the \b(us|usa|united states)\b test → isUS: true.
Step 5: Commit
git add lib/rmhladder/classifiers/us-location.ts lib/rmhladder/classifiers/us-location.test.ts
git commit -m "feat(rmhladder): US location classifier with gazetteer + remote detection"
Task 5: Early-career classifier¶
Files:
Create:
lib/rmhladder/classifiers/early-career.tsTest:
lib/rmhladder/classifiers/early-career.test.ts
Interfaces:
Produces:
type ProgramType = 'internship'|'summer_analyst'|'summer_associate'|'analyst_program'|'rotational_program'|'new_grad'|'leadership_development'|'entry_level'|'mba'|'other'; interface EarlyCareerResult { score: number; classification: 'yes'|'probable'|'no'|'unclear'; programType: ProgramType; graduationYearTarget: number | null; schoolYearTarget: string | null; } function classifyEarlyCareer(title: string, description?: string): EarlyCareerResult
Thresholds: score ≥ 70 →
yes; 50–69 →probable; ≤ 25 →no; elseunclear.Step 1: Write failing tests
// lib/rmhladder/classifiers/early-career.test.ts
import { describe, expect, it } from 'vitest';
import { classifyEarlyCareer } from './early-career';
describe('classifyEarlyCareer', () => {
it('summer analyst is a clear yes with program type', () => {
const r = classifyEarlyCareer('Investment Banking Summer Analyst 2027');
expect(r.classification).toBe('yes');
expect(r.programType).toBe('summer_analyst');
expect(r.graduationYearTarget).toBe(2027);
});
it('intern titles classify yes/internship', () => {
expect(classifyEarlyCareer('Software Engineering Intern')).toMatchObject({
classification: 'yes', programType: 'internship',
});
});
it('senior titles are no', () => {
expect(classifyEarlyCareer('Senior Software Engineer').classification).toBe('no');
expect(classifyEarlyCareer('Vice President, Corporate Development').classification).toBe('no');
expect(classifyEarlyCareer('Managing Director - M&A').classification).toBe('no');
});
it('experience requirements in description push to no', () => {
const r = classifyEarlyCareer('Strategy Associate', 'Requires 7+ years of consulting experience.');
expect(r.classification).toBe('no');
});
it('program types are detected in priority order', () => {
expect(classifyEarlyCareer('2026 Rotational Analyst Program').programType).toBe('rotational_program');
expect(classifyEarlyCareer('Finance Leadership Development Program').programType).toBe('leadership_development');
expect(classifyEarlyCareer('New Grad Software Engineer').programType).toBe('new_grad');
expect(classifyEarlyCareer('MBA Summer Associate').programType).toBe('summer_associate');
expect(classifyEarlyCareer('Product Management MBA Intern').programType).toBe('mba');
});
it('school year target detected', () => {
const r = classifyEarlyCareer('Sophomore Summer Analyst Program', 'Open to current sophomores.');
expect(r.schoolYearTarget).toBe('sophomore');
});
it('ambiguous titles are unclear', () => {
const r = classifyEarlyCareer('Operations Specialist');
expect(r.classification).toBe('unclear');
});
it('manager alone is negative but "program manager, early careers" recovers', () => {
expect(classifyEarlyCareer('Engineering Manager').classification).toBe('no');
expect(classifyEarlyCareer('Analyst, Early Careers Program').classification).toBe('yes');
});
});
Step 2: Run to verify failure
Run: pnpm exec vitest run lib/rmhladder/classifiers/early-career.test.ts
Expected: FAIL — cannot resolve ./early-career.
Step 3: Implement
// lib/rmhladder/classifiers/early-career.ts
export type ProgramType =
| 'internship' | 'summer_analyst' | 'summer_associate' | 'analyst_program'
| 'rotational_program' | 'new_grad' | 'leadership_development' | 'entry_level' | 'mba' | 'other';
export interface EarlyCareerResult {
score: number;
classification: 'yes' | 'probable' | 'no' | 'unclear';
programType: ProgramType;
graduationYearTarget: number | null;
schoolYearTarget: string | null;
}
// [pattern, weight] — title matches count double
const POSITIVE: Array<[RegExp, number]> = [
[/\bsummer analyst\b/i, 40], [/\bsummer associate\b/i, 40], [/\bintern(ship)?\b/i, 35],
[/\bnew grad(uate)?\b/i, 35], [/\brotational (analyst )?program\b/i, 35],
[/\bleadership development program\b/i, 35], [/\banalyst (development )?program\b/i, 30],
[/\bearly careers?\b/i, 30], [/\bcampus\b/i, 20], [/\buniversity program\b/i, 25],
[/\bentry[- ]level\b/i, 25], [/\brecent graduates?\b/i, 25], [/\bstudents?\b/i, 15],
[/\bassociate consultant\b/i, 25], [/\bbusiness analyst\b/i, 15],
[/\b(sophomore|junior|senior year|freshman)\b/i, 20], [/\bmba intern\b/i, 30],
[/\bclass of 20(2[5-9]|3[0-2])\b/i, 25], [/\b20(2[5-9]|3[0-2])\b/, 10],
];
const NEGATIVE: Array<[RegExp, number]> = [
[/\bvice president\b|\bvp\b/i, 60], [/\b(executive|managing) director\b/i, 60],
[/\bdirector\b/i, 50], [/\bprincipal\b/i, 50], [/\bhead of\b/i, 45],
[/\bsenior\b(?! year)/i, 30], [/\bstaff (engineer|scientist)\b/i, 40], [/\blead\b/i, 25],
[/\bmanager\b/i, 25], [/\bexpert\b/i, 25], [/\bexperienced professional\b/i, 40],
[/\blateral\b/i, 30], [/\b([5-9]|1[0-9])\+? ?(years|yrs)\b/i, 45],
];
// order matters: first match wins
const PROGRAM_TYPES: Array<[RegExp, ProgramType]> = [
[/\bmba intern\b/i, 'mba'],
[/\bsummer analyst\b/i, 'summer_analyst'],
[/\bsummer associate\b/i, 'summer_associate'],
[/\brotational\b/i, 'rotational_program'],
[/\bleadership development\b/i, 'leadership_development'],
[/\banalyst (development )?program\b/i, 'analyst_program'],
[/\bnew grad(uate)?\b/i, 'new_grad'],
[/\bintern(ship)?\b/i, 'internship'],
[/\bentry[- ]level\b/i, 'entry_level'],
[/\bmba\b/i, 'mba'],
];
const SCHOOL_YEARS = /\b(freshman|sophomore|junior|senior)\b/i;
export function classifyEarlyCareer(title: string, description = ''): EarlyCareerResult {
const text = `${title}\n${description}`;
let score = 0;
let hasNegative = false;
for (const [re, w] of POSITIVE) {
if (re.test(title)) score += w; // title hit: full weight
else if (re.test(description)) score += Math.floor(w / 2);
}
for (const [re, w] of NEGATIVE) {
if (re.test(title)) { score -= w; hasNegative = true; }
else if (re.test(description)) { score -= Math.floor(w / 2); hasNegative = true; }
}
score = Math.max(0, Math.min(100, score));
// unambiguous program markers in the title floor the score
const strongTitle = /\b(summer analyst|summer associate|new grad(uate)?|early careers?|intern(ship)?|rotational)\b/i;
if (strongTitle.test(title)) score = Math.max(score, 75);
let classification: EarlyCareerResult['classification'];
if (score >= 70) classification = 'yes';
else if (score >= 50) classification = 'probable';
else if (hasNegative && score <= 25) classification = 'no';
else classification = 'unclear';
// hard negatives in the title always kill it unless an explicit program marker is also in the title
const hardNeg = /\b(vice president|managing director|executive director|senior(?! year)|director|principal|manager|head of|staff engineer)\b/i;
const hardPos = /\b(summer analyst|summer associate|intern(ship)?|new grad|early careers?|rotational|campus)\b/i;
if (hardNeg.test(title) && !hardPos.test(title)) classification = 'no';
const programType = PROGRAM_TYPES.find(([re]) => re.test(text))?.[1] ?? 'other';
const gradMatch = text.match(/\b(20(2[5-9]|3[0-2]))\b/);
const schoolMatch = text.match(SCHOOL_YEARS);
return {
score,
classification,
programType,
graduationYearTarget: gradMatch ? Number(gradMatch[1]) : null,
schoolYearTarget: schoolMatch ? schoolMatch[1].toLowerCase() : null,
};
}
Step 4: Run to verify pass
Run: pnpm exec vitest run lib/rmhladder/classifiers/early-career.test.ts
Expected: PASS. Trace the tricky cases: 'Analyst, Early Careers Program' hits the strongTitle floor (75 → yes, no hard-negative in title); 'Operations Specialist' has no positive or negative signals → score 0, hasNegative false → unclear; 'Strategy Associate' + “7+ years” description → negative present, score ≤25 → no.
Step 5: Commit
git add lib/rmhladder/classifiers/early-career.ts lib/rmhladder/classifiers/early-career.test.ts
git commit -m "feat(rmhladder): early-career classifier with program type + grad year extraction"
Task 6: Relevance scoring¶
Files:
Create:
lib/rmhladder/scoring.tsTest:
lib/rmhladder/scoring.test.ts
Interfaces:
Consumes:
ProgramTypefrom Task 5.Produces:
interface ScorableJob { programType: ProgramType; roleCategory?: string | null; industry?: string | null; isUS: boolean; remoteStatus: 'onsite'|'hybrid'|'remote_us'; city?: string | null; postingDate?: Date | null; applicationDeadline?: Date | null; companyPriority: number; companyIsTarget: boolean; title: string; } interface UserScoringContext { keywords: Array<{ keyword: string; weight: number; type: 'boost'|'block' }>; watchlistCompanyIds: Set<string>; companyId: string; preferredCities: string[]; } function computeBaseScore(job: ScorableJob, now?: Date): { score: number; urgencyFlag: boolean } function computeUserBoost(job: ScorableJob, ctx: UserScoringContext): { boost: number; matched: string[]; blocked: boolean } const DEFAULT_RELEVANCE_RULES: Array<{ key: string; label: string; weight: number }>
Final relevance =
clamp(base + boost, 0, 100);blocked === truedisqualifies from alerts.Step 1: Write failing tests
// lib/rmhladder/scoring.test.ts
import { describe, expect, it } from 'vitest';
import { computeBaseScore, computeUserBoost } from './scoring';
const base = {
programType: 'summer_analyst' as const,
roleCategory: 'investment_banking',
industry: 'Investment Banking',
isUS: true,
remoteStatus: 'onsite' as const,
city: 'New York',
postingDate: new Date('2026-07-03'),
applicationDeadline: new Date('2026-07-15'),
companyPriority: 1,
companyIsTarget: true,
title: 'Investment Banking Summer Analyst 2027',
};
const NOW = new Date('2026-07-05');
describe('computeBaseScore', () => {
it('stacks program, industry, US, target-firm, recency, deadline weights', () => {
const { score, urgencyFlag } = computeBaseScore(base, NOW);
// US 20 + summer_analyst 30 + IB 30 + target 20 + recent 15 + deadline 10 = 125 → clamped 100
expect(score).toBe(100);
expect(urgencyFlag).toBe(true);
});
it('remote US gets +15 instead of +20', () => {
const a = computeBaseScore({ ...base, isUS: true, remoteStatus: 'remote_us' }, NOW).score;
const b = computeBaseScore({ ...base, isUS: true, remoteStatus: 'onsite' }, NOW).score;
expect(b - a).toBe(5);
});
it('senior title terms subtract', () => {
const r = computeBaseScore({ ...base, title: 'Senior Director, Strategy', programType: 'other', industry: null, companyIsTarget: false, postingDate: null, applicationDeadline: null }, NOW);
expect(r.score).toBeLessThan(25);
});
it('no deadline → no urgency flag', () => {
expect(computeBaseScore({ ...base, applicationDeadline: null }, NOW).urgencyFlag).toBe(false);
});
});
describe('computeUserBoost', () => {
const ctx = {
keywords: [
{ keyword: 'investment banking', weight: 15, type: 'boost' as const },
{ keyword: 'crypto', weight: 0, type: 'block' as const },
],
watchlistCompanyIds: new Set(['c1']),
companyId: 'c1',
preferredCities: ['New York'],
};
it('adds keyword, watchlist, and city boosts and reports matches', () => {
const r = computeUserBoost(base, ctx);
expect(r.boost).toBe(15 + 20 + 10);
expect(r.matched).toContain('investment banking');
expect(r.blocked).toBe(false);
});
it('block keyword disqualifies', () => {
const r = computeUserBoost({ ...base, title: 'Crypto Analyst Intern' }, ctx);
expect(r.blocked).toBe(true);
});
});
Step 2: Run to verify failure
Run: pnpm exec vitest run lib/rmhladder/scoring.test.ts
Expected: FAIL — cannot resolve ./scoring.
Step 3: Implement
// lib/rmhladder/scoring.ts
import type { ProgramType } from './classifiers/early-career';
export interface ScorableJob {
programType: ProgramType;
roleCategory?: string | null;
industry?: string | null;
isUS: boolean;
remoteStatus: 'onsite' | 'hybrid' | 'remote_us';
city?: string | null;
postingDate?: Date | null;
applicationDeadline?: Date | null;
companyPriority: number;
companyIsTarget: boolean;
title: string;
}
export interface UserScoringContext {
keywords: Array<{ keyword: string; weight: number; type: 'boost' | 'block' }>;
watchlistCompanyIds: Set<string>;
companyId: string;
preferredCities: string[];
}
const PROGRAM_WEIGHTS: Partial<Record<ProgramType, number>> = {
summer_analyst: 30, summer_associate: 25, internship: 25, analyst_program: 25,
new_grad: 25, rotational_program: 25, leadership_development: 18, entry_level: 15, mba: 15,
};
const INDUSTRY_WEIGHTS: Array<[RegExp, number, string]> = [
[/investment banking/i, 30, 'industry:investment_banking'],
[/corporate (banking|strategy|development)/i, 25, 'industry:corporate'],
[/consult/i, 20, 'industry:consulting'],
[/product management|product/i, 20, 'industry:product'],
[/markets|sales & trading|trading/i, 18, 'industry:markets'],
[/asset management|wealth/i, 18, 'industry:asset_management'],
[/risk/i, 18, 'industry:risk'],
[/business analyst|business operations/i, 20, 'industry:business'],
];
const TITLE_PENALTIES: Array<[RegExp, number]> = [
[/\bvp\b|vice president/i, 60], [/\bdirector\b/i, 50], [/\bprincipal\b/i, 50],
[/\bsenior\b(?! year)/i, 25], [/\bmanager\b/i, 20],
];
const DAY = 86_400_000;
export const DEFAULT_RELEVANCE_RULES = [
{ key: 'geo:us', label: 'US-based role', weight: 20 },
{ key: 'geo:remote_us', label: 'Remote US role', weight: 15 },
{ key: 'program:summer_analyst', label: 'Summer Analyst', weight: 30 },
{ key: 'program:internship', label: 'Internship', weight: 25 },
{ key: 'program:analyst_program', label: 'Analyst Program', weight: 25 },
{ key: 'program:new_grad', label: 'New Grad', weight: 25 },
{ key: 'company:target', label: 'Target company match', weight: 20 },
{ key: 'recency:7d', label: 'Posted within 7 days', weight: 15 },
{ key: 'deadline:14d', label: 'Deadline within 14 days', weight: 10 },
{ key: 'user:watchlist', label: 'Watchlisted company', weight: 20 },
{ key: 'user:city', label: 'Preferred city', weight: 10 },
];
export function computeBaseScore(job: ScorableJob, now = new Date()): { score: number; urgencyFlag: boolean } {
let score = 0;
if (job.isUS) score += job.remoteStatus === 'remote_us' ? 15 : 20;
score += PROGRAM_WEIGHTS[job.programType] ?? 0;
const haystack = `${job.industry ?? ''} ${job.roleCategory ?? ''} ${job.title}`;
const industryHit = INDUSTRY_WEIGHTS.find(([re]) => re.test(haystack));
if (industryHit) score += industryHit[1];
if (job.companyIsTarget) score += 20;
if (job.postingDate && now.getTime() - job.postingDate.getTime() <= 7 * DAY) score += 15;
let urgencyFlag = false;
if (job.applicationDeadline) {
const until = job.applicationDeadline.getTime() - now.getTime();
if (until >= 0 && until <= 14 * DAY) { score += 10; urgencyFlag = true; }
}
for (const [re, w] of TITLE_PENALTIES) if (re.test(job.title)) score -= w;
return { score: Math.max(0, Math.min(100, score)), urgencyFlag };
}
export function computeUserBoost(
job: ScorableJob,
ctx: UserScoringContext,
): { boost: number; matched: string[]; blocked: boolean } {
const haystack = `${job.title} ${job.industry ?? ''} ${job.roleCategory ?? ''}`.toLowerCase();
const matched: string[] = [];
let boost = 0;
for (const k of ctx.keywords) {
if (!haystack.includes(k.keyword.toLowerCase())) continue;
if (k.type === 'block') return { boost: 0, matched, blocked: true };
matched.push(k.keyword);
boost += k.weight;
}
if (ctx.watchlistCompanyIds.has(ctx.companyId)) boost += 20;
if (job.city && ctx.preferredCities.some((c) => c.toLowerCase() === job.city!.toLowerCase())) boost += 10;
return { boost, matched, blocked: false };
}
Step 4: Run to verify pass
Run: pnpm exec vitest run lib/rmhladder/scoring.test.ts
Expected: PASS.
Step 5: Commit
git add lib/rmhladder/scoring.ts lib/rmhladder/scoring.test.ts
git commit -m "feat(rmhladder): relevance scoring (base + per-user boost)"
Task 7: Verification status logic + alert gate¶
Files:
Create:
lib/rmhladder/verification.tsTest:
lib/rmhladder/verification.test.ts
Interfaces:
Produces:
interface VerificationEvidence { fetched: boolean; httpStatus?: number; apiSource: boolean; companyMatch: boolean; titleMatch: boolean; usConfirmed: boolean; applyPresent: boolean; reqIdPresent: boolean; closedLanguage: boolean; blocked: boolean; isSearchResultsPage: boolean; companyName: string; jobTitle: string; locationLabel?: string; platform: string; } interface VerificationOutcome { status: LadderVerificationStatus-like string; confidence: number; evidence: string; } function computeVerification(e: VerificationEvidence): VerificationOutcome function passesAlertGate(args: { status: string; confidence: number; isUS: boolean; earlyCareer: 'yes'|'probable'|'no'|'unclear'; finalRelevance: number; userThreshold: number; alreadyAlerted: boolean; blockedKeyword: boolean }): boolean
Step 1: Write failing tests
// lib/rmhladder/verification.test.ts
import { describe, expect, it } from 'vitest';
import { computeVerification, passesAlertGate } from './verification';
const good = {
fetched: true, httpStatus: 200, apiSource: true, companyMatch: true, titleMatch: true,
usConfirmed: true, applyPresent: true, reqIdPresent: true, closedLanguage: false,
blocked: false, isSearchResultsPage: false,
companyName: 'Stripe', jobTitle: 'Product Management Intern', locationLabel: 'New York, NY',
platform: 'greenhouse',
};
describe('computeVerification', () => {
it('API source with full evidence → verified_active, high confidence, readable evidence', () => {
const r = computeVerification(good);
expect(r.status).toBe('verified_active');
expect(r.confidence).toBeGreaterThanOrEqual(85);
expect(r.evidence).toContain('Stripe');
expect(r.evidence).toContain('Product Management Intern');
});
it('HTML-verified without req id → verified_probable band', () => {
const r = computeVerification({ ...good, apiSource: false, reqIdPresent: false });
expect(['verified_probable', 'verified_active']).toContain(r.status);
expect(r.confidence).toBeGreaterThanOrEqual(60);
});
it('closed language → expired regardless of other evidence', () => {
expect(computeVerification({ ...good, closedLanguage: true }).status).toBe('expired');
});
it('blocked page → blocked_or_inaccessible', () => {
expect(computeVerification({ ...good, blocked: true, fetched: false }).status).toBe('blocked_or_inaccessible');
});
it('fetch failure → broken_link', () => {
expect(computeVerification({ ...good, fetched: false, httpStatus: 404, apiSource: false }).status).toBe('broken_link');
});
it('search results page → needs_manual_review', () => {
expect(computeVerification({ ...good, isSearchResultsPage: true }).status).toBe('needs_manual_review');
});
it('weak evidence → needs_manual_review', () => {
const r = computeVerification({ ...good, apiSource: false, titleMatch: false, applyPresent: false, reqIdPresent: false });
expect(r.status).toBe('needs_manual_review');
expect(r.confidence).toBeLessThan(60);
});
});
describe('passesAlertGate', () => {
const ok = { status: 'verified_active', confidence: 90, isUS: true, earlyCareer: 'yes' as const,
finalRelevance: 75, userThreshold: 60, alreadyAlerted: false, blockedKeyword: false };
it('passes the happy path', () => expect(passesAlertGate(ok)).toBe(true));
it.each([
['unverified status', { ...ok, status: 'unverified' }],
['low confidence', { ...ok, confidence: 74 }],
['non-US', { ...ok, isUS: false }],
['not early-career', { ...ok, earlyCareer: 'no' as const }],
['below threshold', { ...ok, finalRelevance: 59 }],
['duplicate alert', { ...ok, alreadyAlerted: true }],
['blocked keyword', { ...ok, blockedKeyword: true }],
])('fails on %s', (_label, args) => expect(passesAlertGate(args)).toBe(false));
});
Step 2: Run to verify failure
Run: pnpm exec vitest run lib/rmhladder/verification.test.ts
Expected: FAIL — cannot resolve ./verification.
Step 3: Implement
// lib/rmhladder/verification.ts
export interface VerificationEvidence {
fetched: boolean;
httpStatus?: number;
apiSource: boolean;
companyMatch: boolean;
titleMatch: boolean;
usConfirmed: boolean;
applyPresent: boolean;
reqIdPresent: boolean;
closedLanguage: boolean;
blocked: boolean;
isSearchResultsPage: boolean;
companyName: string;
jobTitle: string;
locationLabel?: string;
platform: string;
}
export interface VerificationOutcome { status: string; confidence: number; evidence: string }
export function computeVerification(e: VerificationEvidence): VerificationOutcome {
if (e.blocked) return out('blocked_or_inaccessible', 0, `Page on ${e.platform} is blocked by robots.txt/anti-bot; not scraped. URL preserved for manual review.`);
if (e.closedLanguage) return out('expired', 95, `Posting for "${e.jobTitle}" at ${e.companyName} contains closed/no-longer-accepting language.`);
if (!e.fetched) return out('broken_link', 0, `Fetch failed (HTTP ${e.httpStatus ?? 'n/a'}) for "${e.jobTitle}" at ${e.companyName}.`);
if (e.isSearchResultsPage) return out('needs_manual_review', 30, `URL for "${e.jobTitle}" at ${e.companyName} resolves to a generic search/results page, not a posting.`);
let confidence = 0;
const parts: string[] = [];
if (e.apiSource) { confidence += 40; parts.push(`official ${e.platform} API returned the posting`); }
else { confidence += 20; parts.push(`page returned HTTP ${e.httpStatus ?? 200}`); }
if (e.titleMatch) { confidence += 15; parts.push(`title matched "${e.jobTitle}"`); }
if (e.companyMatch) { confidence += 10; parts.push(`page contained company name "${e.companyName}"`); }
if (e.usConfirmed) { confidence += 15; parts.push(`location confirmed US${e.locationLabel ? ` (${e.locationLabel})` : ''}`); }
if (e.applyPresent) { confidence += 10; parts.push('apply mechanism present'); }
if (e.reqIdPresent) { confidence += 5; parts.push('requisition ID present'); }
confidence += 5; parts.push('no expired/closed language detected'); // closedLanguage handled above
confidence = Math.min(100, confidence);
const evidence = `Verified because ${parts.join(', ')}.`;
if (confidence >= 85) return { status: 'verified_active', confidence, evidence };
if (confidence >= 60) return { status: 'verified_probable', confidence, evidence };
return { status: 'needs_manual_review', confidence, evidence: `Low-confidence evidence: ${parts.join(', ')}.` };
}
const out = (status: string, confidence: number, evidence: string): VerificationOutcome => ({ status, confidence, evidence });
export function passesAlertGate(args: {
status: string; confidence: number; isUS: boolean;
earlyCareer: 'yes' | 'probable' | 'no' | 'unclear';
finalRelevance: number; userThreshold: number;
alreadyAlerted: boolean; blockedKeyword: boolean;
}): boolean {
return (
(args.status === 'verified_active' || args.status === 'verified_probable') &&
args.confidence >= 75 &&
args.isUS &&
(args.earlyCareer === 'yes' || args.earlyCareer === 'probable') &&
args.finalRelevance >= args.userThreshold &&
!args.alreadyAlerted &&
!args.blockedKeyword
);
}
Step 4: Run to verify pass
Run: pnpm exec vitest run lib/rmhladder/verification.test.ts
Expected: PASS. Check the “weak evidence” case lands < 60: 20 (fetch) + 10 (company) + 15 (US) + 5 (no closed) = 50 → needs_manual_review. ✓
Step 5: Commit
git add lib/rmhladder/verification.ts lib/rmhladder/verification.test.ts
git commit -m "feat(rmhladder): verification status logic + alert gate"
Task 8: Company seed data + seed script¶
Files:
Create:
lib/rmhladder/seed/companies.ts(full firm list as data)Create:
scripts/seed-ladder.tsTest:
lib/rmhladder/seed/companies.test.tsModify:
package.json(add script"ladder:seed": "pnpm exec tsx scripts/seed-ladder.ts")
Interfaces:
Consumes:
normalizeCompanyName(Task 3), Prisma models (Tasks 1–2).Produces:
interface SeedCompany { name: string; industry: string; firmType: string; priorityLevel: number; usEarlyCareerUrl?: string } const SEED_COMPANIES: SeedCompany[] // ~400 firms
Seed script upserts companies by
normalizedNameand creates oneunconfiguredsource per company per API platform (greenhouse/lever/ashby/smartrecruiters) — the Plan 2 slug prober activates the real ones — plusmanualsources (statusactive) whereusEarlyCareerUrlis present.Step 1: Create the data file
lib/rmhladder/seed/companies.ts — groups map to { industry, firmType, priorityLevel }. Transcribe every firm from the spec’s Target Firms section (docs/superpowers/specs/2026-07-05-rmhladder-design.md § references the original list; the authoritative full list below). Structure:
// lib/rmhladder/seed/companies.ts
export interface SeedCompany {
name: string;
industry: string;
firmType: string;
priorityLevel: number; // 1 = highest
usEarlyCareerUrl?: string;
}
const group = (
names: string[], industry: string, firmType: string, priorityLevel: number,
): SeedCompany[] => names.map((name) => ({ name, industry, firmType, priorityLevel }));
export const SEED_COMPANIES: SeedCompany[] = [
...group(['Goldman Sachs', 'JPMorgan Chase', 'Morgan Stanley', 'Bank of America', 'Citi', 'Wells Fargo', 'Barclays', 'UBS', 'Deutsche Bank', 'HSBC', 'RBC Capital Markets', 'TD Securities', 'BMO Capital Markets', 'Scotiabank', 'Nomura', 'Mizuho', 'SMBC', 'MUFG', 'Macquarie'], 'Investment Banking', 'bulge_bracket', 1),
...group(['Evercore', 'Lazard', 'Centerview Partners', 'PJT Partners', 'Moelis & Company', 'Perella Weinberg Partners', 'Greenhill', 'Guggenheim Securities', 'Houlihan Lokey', 'Jefferies', 'William Blair', 'Lincoln International', 'Harris Williams', 'Piper Sandler', 'Raymond James', 'Stifel', 'Baird', 'Cowen', 'Rothschild & Co', 'Ducera Partners', 'Solomon Partners', 'LionTree', 'Qatalyst Partners', 'Allen & Company', 'FT Partners', 'Cain Brothers', 'Leerink Partners', 'Cantor Fitzgerald', 'Needham & Company'], 'Investment Banking', 'elite_boutique', 1),
...group(['KeyBanc Capital Markets', 'Fifth Third Securities', 'Truist Securities', 'Citizens Bank', 'Regions Securities', 'Huntington Bank', 'Comerica', 'PNC', 'U.S. Bank', 'BOK Financial', 'Wedbush Securities', 'Oppenheimer', 'JMP Securities'], 'Investment Banking', 'middle_market', 2),
...group(['Blackstone', 'KKR', 'Apollo', 'Carlyle', 'TPG', 'Ares Management', 'Brookfield', 'Warburg Pincus', 'Silver Lake', 'General Atlantic', 'Advent International', 'Bain Capital', 'Hellman & Friedman', 'Vista Equity Partners', 'Thoma Bravo', 'Clayton Dubilier & Rice', 'EQT', 'CVC Capital Partners', 'Permira', 'Partners Group', 'Leonard Green & Partners', 'Francisco Partners', 'Insight Partners', 'TA Associates', 'Stone Point Capital', 'Clearlake Capital', 'Roark Capital', 'New Mountain Capital', 'Welsh Carson', 'K1 Investment Management', 'H.I.G. Capital', 'GTCR', 'Summit Partners', 'Audax Group', 'Charlesbank Capital Partners', 'Littlejohn & Co.', 'American Securities', 'TowerBrook Capital Partners', 'Court Square Capital', 'L Catterton', 'Searchlight Capital Partners'], 'Private Equity', 'private_equity', 2),
...group(['Sequoia Capital', 'Andreessen Horowitz', 'Accel', 'Benchmark', 'Greylock', 'Kleiner Perkins', 'Lightspeed Venture Partners', 'Bessemer Venture Partners', 'General Catalyst', 'Founders Fund', 'Coatue', 'Thrive Capital', 'Tiger Global', 'NEA', 'IVP', 'Menlo Ventures', 'Battery Ventures', 'Khosla Ventures', 'Union Square Ventures', 'First Round Capital', 'Spark Capital', 'Sapphire Ventures', 'Greycroft', 'Redpoint Ventures'], 'Venture Capital', 'venture_capital', 3),
...group(['BlackRock', 'Vanguard', 'Fidelity', 'State Street', 'PIMCO', 'Wellington Management', 'T. Rowe Price', 'Capital Group', 'Invesco', 'Franklin Templeton', 'AllianceBernstein', 'Nuveen', 'Northern Trust', 'BNY Mellon', 'Janus Henderson'], 'Asset Management', 'asset_manager', 2),
...group(['Bridgewater Associates', 'Citadel', 'Citadel Securities', 'Point72', 'Millennium Management', 'Two Sigma', 'DE Shaw', 'Jane Street', 'Susquehanna International Group', 'Hudson River Trading', 'Optiver', 'IMC Trading', 'DRW', 'Akuna Capital', 'Jump Trading', 'Tower Research Capital'], 'Markets / Sales & Trading', 'hedge_fund_trading', 1),
...group(['McKinsey & Company', 'Bain & Company', 'Boston Consulting Group', 'Deloitte', 'PwC', 'EY', 'KPMG', 'Accenture', 'Oliver Wyman', 'Strategy&', 'LEK Consulting', 'Roland Berger', 'Simon-Kucher', 'AlixPartners', 'Alvarez & Marsal', 'FTI Consulting', 'Kearney', 'Booz Allen Hamilton', 'Capgemini', 'IBM Consulting', 'Slalom', 'ZS Associates', 'Guidehouse', 'West Monroe', 'RSM', 'Grant Thornton', 'BDO', 'Protiviti', 'Huron Consulting Group', 'Ankura', 'Berkeley Research Group', 'Charles River Associates', 'Cornerstone Research', 'Analysis Group', 'NERA Economic Consulting', 'Bates White', 'Compass Lexecon'], 'Management Consulting', 'consulting', 1),
...group(['Google', 'Microsoft', 'Apple', 'Amazon', 'Meta', 'Netflix', 'Nvidia', 'Salesforce', 'Adobe', 'Oracle', 'IBM', 'Intel', 'AMD', 'Qualcomm', 'Cisco', 'Dell', 'HP', 'ServiceNow', 'Workday', 'Atlassian', 'Snowflake', 'Databricks', 'Palantir', 'Stripe', 'Block', 'PayPal', 'Shopify', 'Uber', 'Lyft', 'Airbnb', 'DoorDash', 'Instacart', 'Coinbase', 'Robinhood', 'Reddit', 'Pinterest', 'Snap', 'Spotify', 'TikTok', 'ByteDance', 'Discord', 'Roblox', 'Epic Games', 'Electronic Arts', 'Unity', 'OpenAI', 'Anthropic', 'Scale AI', 'Anduril', 'Ramp', 'Brex', 'Plaid', 'Figma', 'Canva', 'Dropbox', 'Box', 'Okta', 'Datadog', 'Cloudflare', 'MongoDB', 'Elastic', 'GitHub', 'GitLab', 'Twilio', 'HubSpot', 'Toast', 'Samsara', 'Verkada', 'Rippling', 'Airtable', 'Asana', 'Notion'], 'Technology', 'technology', 1),
...group(['Capital One', 'American Express', 'Discover', 'Mastercard', 'Visa', 'Fiserv', 'FIS', 'Global Payments', 'Adyen', 'Affirm', 'SoFi', 'Chime', 'Bloomberg', 'S&P Global', "Moody's", 'Fitch Ratings', 'Morningstar', 'FactSet', 'MSCI', 'Nasdaq', 'NYSE', 'CME Group', 'Cboe', 'ICE', 'MarketAxess'], 'FinTech', 'fintech_data', 2),
...group(['Procter & Gamble', 'Unilever', 'Johnson & Johnson', 'PepsiCo', 'Coca-Cola', 'Nike', 'Lululemon', 'Walmart', 'Target', 'Costco', 'Home Depot', "Lowe's", 'General Electric', 'Honeywell', '3M', 'Caterpillar', 'Deere', 'Boeing', 'Lockheed Martin', 'Northrop Grumman', 'RTX', 'General Motors', 'Ford', 'Tesla', 'Rivian', 'Lucid', 'Delta Air Lines', 'United Airlines', 'American Airlines', 'Marriott', 'Hilton', 'Disney', 'Comcast', 'Warner Bros. Discovery', 'Paramount', 'Sony', 'General Mills', 'Cargill', 'Ecolab', 'Medtronic', 'UnitedHealth Group', 'Optum', 'CVS Health', 'Elevance Health', 'Cigna', 'Humana', 'Pfizer', 'Merck', 'Eli Lilly', 'AbbVie', 'Amgen', 'Gilead', 'Moderna'], 'Corporate Strategy', 'corporate', 3),
];
// Manual early-career page URLs for firms unlikely to be on API job boards (verified in Plan 2).
export const MANUAL_EARLY_CAREER_URLS: Record<string, string> = {
'Goldman Sachs': 'https://www.goldmansachs.com/careers/students',
'JPMorgan Chase': 'https://careers.jpmorgan.com/us/en/students',
'Morgan Stanley': 'https://www.morganstanley.com/careers/students-graduates',
'Bank of America': 'https://campus.bankofamerica.com',
'Citi': 'https://jobs.citi.com/students-and-graduates',
'Wells Fargo': 'https://www.wellsfargojobs.com/en/early-careers/',
'Barclays': 'https://search.jobs.barclays/early-careers',
'UBS': 'https://www.ubs.com/global/en/careers/graduates.html',
'Evercore': 'https://www.evercore.com/join-our-team/campus-recruiting/',
'Lazard': 'https://www.lazard.com/careers/students-graduates/',
'Centerview Partners': 'https://www.centerviewpartners.com/careers',
'PJT Partners': 'https://www.pjtpartners.com/careers/campus-opportunities',
'Moelis & Company': 'https://www.moelis.com/careers/students/',
'Jefferies': 'https://www.jefferies.com/careers/early-careers/',
'Houlihan Lokey': 'https://hl.com/careers/campus-recruiting/',
'McKinsey & Company': 'https://www.mckinsey.com/careers/students',
'Bain & Company': 'https://www.bain.com/careers/roles/students-grads/',
'Boston Consulting Group': 'https://careers.bcg.com/students',
'Deloitte': 'https://www2.deloitte.com/us/en/pages/careers/articles/join-deloitte-students.html',
'PwC': 'https://www.pwc.com/us/en/careers/university-relations.html',
'EY': 'https://www.ey.com/en_us/careers/students',
'KPMG': 'https://kpmg.com/us/en/careers-and-culture/university-careers.html',
'BlackRock': 'https://careers.blackrock.com/early-careers/',
'Blackstone': 'https://www.blackstone.com/careers/students/',
'Citadel': 'https://www.citadel.com/careers/students/',
'Point72': 'https://careers.point72.com/students',
'Jane Street': 'https://www.janestreet.com/join-jane-street/internships/',
'DE Shaw': 'https://www.deshaw.com/careers/internships',
'Two Sigma': 'https://careers.twosigma.com/careers/SearchJobs/internship',
'Bridgewater Associates': 'https://www.bridgewater.com/working-at-bridgewater/campus',
'Capital One': 'https://campus.capitalone.com',
'American Express': 'https://www.americanexpress.com/en-us/careers/students/',
};
(If any URL 404s during Plan 2 verification, the source gets status: error and a review task — that is the designed behavior, not a seed bug.)
Step 2: Write failing tests
// lib/rmhladder/seed/companies.test.ts
import { describe, expect, it } from 'vitest';
import { normalizeCompanyName } from '../normalize';
import { MANUAL_EARLY_CAREER_URLS, SEED_COMPANIES } from './companies';
describe('SEED_COMPANIES', () => {
it('has 300+ firms across all firm types', () => {
expect(SEED_COMPANIES.length).toBeGreaterThanOrEqual(300);
const types = new Set(SEED_COMPANIES.map((c) => c.firmType));
for (const t of ['bulge_bracket', 'elite_boutique', 'middle_market', 'private_equity', 'venture_capital', 'asset_manager', 'hedge_fund_trading', 'consulting', 'technology', 'fintech_data', 'corporate']) {
expect(types).toContain(t);
}
});
it('normalized names are unique (no dedupe collisions in seed)', () => {
const normalized = SEED_COMPANIES.map((c) => normalizeCompanyName(c.name));
const dupes = normalized.filter((n, i) => normalized.indexOf(n) !== i);
expect(dupes).toEqual([]);
});
it('every manual URL belongs to a seeded company', () => {
const names = new Set(SEED_COMPANIES.map((c) => c.name));
for (const key of Object.keys(MANUAL_EARLY_CAREER_URLS)) expect(names).toContain(key);
});
it('all URLs are https', () => {
for (const url of Object.values(MANUAL_EARLY_CAREER_URLS)) expect(url).toMatch(/^https:\/\//);
});
});
Step 3: Run tests — fix data until green
Run: pnpm exec vitest run lib/rmhladder/seed/companies.test.ts
Expected: PASS on first run. If a normalized-name collision surfaces (two groups both containing a firm), fix the data — remove the duplicate from the lower-priority group — never loosen the test. Also add non-empty normalized names to the uniqueness test if not already covered by Task 3’s guard.
Step 4: Write the seed script
// scripts/seed-ladder.ts
import { PrismaClient } from '@prisma/client';
import { normalizeCompanyName } from '../lib/rmhladder/normalize';
import { DEFAULT_RELEVANCE_RULES } from '../lib/rmhladder/scoring';
import { MANUAL_EARLY_CAREER_URLS, SEED_COMPANIES } from '../lib/rmhladder/seed/companies';
const prisma = new PrismaClient();
const API_PLATFORMS = ['greenhouse', 'lever', 'ashby', 'smartrecruiters'] as const;
async function main() {
let companies = 0;
for (const c of SEED_COMPANIES) {
const normalizedName = normalizeCompanyName(c.name);
const company = await prisma.ladderCompany.upsert({
where: { normalizedName },
update: { industry: c.industry, firmType: c.firmType, priorityLevel: c.priorityLevel },
create: {
name: c.name, normalizedName, industry: c.industry,
firmType: c.firmType, priorityLevel: c.priorityLevel,
usEarlyCareerUrl: MANUAL_EARLY_CAREER_URLS[c.name],
},
});
companies++;
for (const platform of API_PLATFORMS) {
await prisma.ladderSource.upsert({
where: { companyId_platform_slug: { companyId: company.id, platform, slug: normalizedName.replace(/ /g, '') } },
update: {},
create: { companyId: company.id, platform, slug: normalizedName.replace(/ /g, ''), status: 'unconfigured' },
});
}
const manualUrl = MANUAL_EARLY_CAREER_URLS[c.name];
if (manualUrl) {
const existing = await prisma.ladderSource.findFirst({ where: { companyId: company.id, platform: 'manual' } });
if (!existing) {
await prisma.ladderSource.create({
data: { companyId: company.id, platform: 'manual', url: manualUrl, status: 'active' },
});
}
}
}
for (const rule of DEFAULT_RELEVANCE_RULES) {
await prisma.ladderRelevanceRule.upsert({ where: { key: rule.key }, update: {}, create: rule });
}
const sources = await prisma.ladderSource.count();
console.log(`Seeded ${companies} companies, ${sources} sources, ${DEFAULT_RELEVANCE_RULES.length} relevance rules.`);
}
main().finally(() => prisma.$disconnect());
Add to package.json scripts: "ladder:seed": "pnpm exec tsx scripts/seed-ladder.ts".
Step 5: Run the seed against the dev DB
Run: pnpm ladder:seed
Expected: Seeded ~370 companies, ~1500 sources, 11 relevance rules. (counts vary with dedupe fixes; companies ≥ 300, sources ≈ companies × 4 + manual count). Re-run once more to confirm idempotency (same counts, no unique-constraint errors).
Step 6: Commit
git add lib/rmhladder/seed/ scripts/seed-ladder.ts package.json
git commit -m "feat(rmhladder): full company seed data + idempotent seed script"
Task 9: Full-suite check + plan wrap-up¶
Files: none new.
Step 1: Run everything
Run: pnpm exec vitest run lib/rmhladder && pnpm exec prisma validate && pnpm lint 2>&1 | tail -5
Expected: all rmhladder tests PASS; schema valid; no new lint errors in lib/rmhladder/** or scripts/seed-ladder.ts (pre-existing lint noise elsewhere in the repo is out of scope).
Step 2: Commit any lint fixes
git add -A lib/rmhladder scripts/seed-ladder.ts
git commit -m "chore(rmhladder): lint fixes for foundation" # only if fixes were needed
Roadmap (subsequent plans, written after this one lands)¶
Plan 2 — Adapters: Greenhouse/Lever/Ashby/SmartRecruiters API clients + fixture tests, slug prober (activates the
unconfiguredsources seeded here), manual-source + generic verifier (fetch + node-html-parser, Playwright fallback, robots.txt honor), producingVerificationEvidenceconsumed bycomputeVerification.Plan 3 — Pipeline + worker:
server/ladder-worker/with node-cron (LADDER_CRON_SCHEDULE, default0 */4 * * *), run orchestration writingLadderScrapeRun/LadderSourceError, dedupe merge viadedupeHash, recheck + 3-strike expiry, review-task creation.Plan 4 — Dashboard: 8 routes under
app/routes/rmhladder/per spec §Dashboard.Plan 5 — Alerts + CSV + README: dispatch through
passesAlertGate, Resend email, Discord DM, digests, deadline reminders, CSV import/export, README.