Skip to Content
PlansMatching Engine — Implementation Plan (pipeline)

Matching Engine — Implementation Plan (pipeline)

Source: plans/pipeline-1.1+2.1+3.2+6.1+7.1+7.2+8.1-matching-engine-impl.md · synced 2026-06-12

# Matching Engine — Implementation Plan (pipeline)

For agentic workers: REQUIRED SUB-SKILL: Use executing-plans (tasks are sequential — Layer 1 depends on JD normaliser, Layer 2 depends on Layer 1, etc.). Steps use checkbox (- [ ]) syntax for tracking. Each task ends with a commit.

Covers phases: 1.1 (extractor reuse) · 2.1 (JD parser) · 3.2 (JD normaliser) · 6.1 (Layer 1 hard fit) · 7.1 (adjacency resolver) · 7.2 (Layer 2 evidence-grounded LLM, absorbs retired 5.3) · 8.1 (Layer 3 verdict synthesis). Hard preconditions: (1) CV parser shipped (Step 5.1, shipped), (2) tag vocab + rubric content + hard rules emitted by extended bootstrap mine (amendment-3.1+5.3+6.1-bootstrap-rubric-extension). See SOURCE-OF-TRUTH.md §4.

⚠️ AMENDED 2026-06-12 — scoring locks (see SOURCE-OF-TRUTH.md §8 2026-06-12 entry). Where tasks below conflict, the following wins:

  1. Layer 1 (Tasks 9–10) scores 4 dims — sub_function_match moved out of the composite to a new Step 6.0 entry gate (build target matching/entry-gates.ts: sub-function gate + ratified drop rules, fires before scoring). Default weights: tag_overlap 0.40 / seniority_delta 0.20 / years_experience 0.20 / red_flag_penalty 0.20.
  2. New Step 6.2 rules overlay module (matching/rules-overlay.ts): boost/penalty deltas sum then clip to net ±0.5, attach block_at_l2/require_evidence flags, threshold re-check ≥ 4.0 → drop reason composite_below_4_after_rules.
  3. Layer 2 prompt must define per-dimension 1–5 scoring anchors (new task — currently undefined anywhere).

Depends on: Approved design in plans/pipeline-1.1+2.1+3.2+6.1+7.1+7.2+8.1-matching-engine-design.md

Precondition: The CV title normaliser (src/lib/cv-normaliser/) must be implemented per plans/step-5-phase-2-cv-normaliser-design.md before Task 9 (Layer 1 hard fit). Tasks 1–8 (JD side) can run independently of the CV normaliser.

Goal: Build a three-layer JD↔CV matching system that produces a defensible MatchRecord per (JD, CV) pair: deterministic Layer 1 hard-fit scoring, hybrid adjacency resolver (ratified config + live LLM inference), evidence-grounded Layer 2 deep assessment via Claude, and Layer 3 verdict synthesis.

Architecture: JD parser and JD normaliser mirror the CV pipeline shape and reuse the controlled tag vocabulary. Matching engine composes pure deterministic functions (Layers 1 + 3) with two Claude API calls (adjacency live inference + Layer 2 deep assessment). Adjacency config is JSON, recruiter-curated, hot-reloadable. All Claude calls use tool_use for structured output. Every Layer 2 dimension score must cite a verbatim CV quote — the same hallucination defence used by parser hardening.

Tech Stack: TypeScript + Next.js 14 (existing calibre-app/), pdf-parse (already installed), @anthropic-ai/sdk (already installed), Vitest.


File Map

FileResponsibility
src/lib/jd-parser/types.tsParsedJD, JDParseMetadata, JDRequirement, JDEducation
src/lib/jd-parser/parser.tsClaude tool-use call: raw JD text → ParsedJD
src/lib/jd-parser/index.tsPublic API: parseJD(filePath: string): Promise<ParsedJD> — composes existing extractText + parser
src/lib/jd-normaliser/taxonomy.tsControlled tag vocabulary (CV side + new finance-fo + ops-back-office)
src/lib/jd-normaliser/types.tsNormalisedJD interface
src/lib/jd-normaliser/normaliser.tsClaude tool-use call: ParsedJD → NormalisedJD
src/lib/jd-normaliser/index.tsPublic API: normaliseJD(parsed: ParsedJD): Promise<NormalisedJD>
src/lib/matching/types.tsLayer1Result, Layer2Result, Layer3Verdict, MatchRecord, AdjacentMatchEvidence
src/lib/matching/layer1-hard-fit.tsPure deterministic scoring functions + composite + drop logic
src/lib/matching/adjacency/config.tsLoad + validate config/adjacency-ratified.json
src/lib/matching/adjacency/live-inference.tsClaude tool-use call for AdjacentMatchEvidence
src/lib/matching/adjacency/resolver.tsTwo-tier resolver: config lookup → live inference → boost calc
src/lib/matching/layer2-deep-assessment.tsClaude tool-use call: NormalisedJD + ParsedCV + adjacency → Layer2Result
src/lib/matching/layer3-verdict.tsDeterministic 4-band verdict
src/lib/matching/weights.tsPer-sub-function rubric weights loader
src/lib/matching/index.tsPublic API: match(jd, cv): Promise<MatchRecord>
config/adjacency-ratified.jsonRecruiter-approved adjacency pairs (initially empty)
config/rubric-weights.jsonPer-sub-function dimension weights
scripts/batch-parse-jds.tsParse all 41 reference JDs → outputs/parsed-jds/
scripts/batch-normalise-jds.tsNormalise all parsed JDs → outputs/normalised-jds/
scripts/batch-match.tsRun full pipeline across all (JD, CV) pairs → outputs/match-records/
scripts/review-adjacency-proposals.tsCLI for Calibre to approve/reject live-inferred adjacency proposals
tests/lib/jd-parser/parser.test.tsJD parser unit tests
tests/lib/jd-normaliser/normaliser.test.tsJD normaliser unit tests
tests/lib/matching/layer1-hard-fit.test.tsLayer 1 pure-function unit tests
tests/lib/matching/adjacency/resolver.test.tsAdjacency resolver unit tests
tests/lib/matching/layer2-deep-assessment.test.tsLayer 2 fixture-driven tests
tests/lib/matching/layer3-verdict.test.tsLayer 3 verdict synthesis tests

All paths are relative to calibre-app/ unless otherwise stated.


Task 1: JD Parser types

Files:

  • Create: calibre-app/src/lib/jd-parser/types.ts

  • Step 1: Write the failing test

Create calibre-app/tests/lib/jd-parser/parser.test.ts:

import { describe, it, expect } from 'vitest' import type { ParsedJD, JDParseMetadata } from '@/lib/jd-parser/types' import type { ConfidenceField } from '@/lib/cv-parser/types' describe('ParsedJD types', () => { it('JDParseMetadata has the required fields', () => { const meta: JDParseMetadata = { source_file: 'JD for LNG.pdf', vertical_hint: 'Energy', parse_status: 'success', overall_confidence: 0.92, parsed_at: '2026-05-23T00:00:00.000Z', } expect(meta.parse_status).toBe('success') }) it('ParsedJD has role + requirements blocks', () => { const partial: Pick<ParsedJD, 'role'> = { role: { raw_title: { value: 'Cargo Operator (LNG)', confidence: 0.95 }, job_summary: { value: 'Manage LNG vessel ops.', confidence: 0.9 }, key_objectives: { value: ['Ensure smooth execution'], confidence: 0.9 }, }, } expect(partial.role.raw_title.value).toBe('Cargo Operator (LNG)') }) })
  • Step 2: Run test to verify it fails
cd "G:/My Drive/BL Dev work/Calibre/calibre-app" npm test tests/lib/jd-parser/parser.test.ts

Expected: FAIL — Cannot find module '@/lib/jd-parser/types'

  • Step 3: Write the types

Create calibre-app/src/lib/jd-parser/types.ts:

import type { ConfidenceField, ParseStatus } from '@/lib/cv-parser/types' export interface JDResponsibility { category: string description: string confidence: number } export interface JDEducation { qualification: ConfidenceField<string> field: ConfidenceField<string> required: ConfidenceField<boolean> } export interface JDRequirements { min_years_experience: ConfidenceField<number | null> experience_description: ConfidenceField<string> must_have_skills: ConfidenceField<string[]> nice_to_have_skills: ConfidenceField<string[]> technical_knowledge: ConfidenceField<string[]> education: JDEducation[] certifications: ConfidenceField<string[]> tools_and_systems: ConfidenceField<string[]> languages: ConfidenceField<string[]> hsse_compliance: ConfidenceField<boolean> shift_or_24_7: ConfidenceField<boolean> } export interface JDParseMetadata { source_file: string vertical_hint: string parse_status: ParseStatus overall_confidence: number parsed_at: string } export interface ParsedJD { role: { raw_title: ConfidenceField<string> job_summary: ConfidenceField<string> key_objectives: ConfidenceField<string[]> } responsibilities: JDResponsibility[] requirements: JDRequirements parse_metadata: JDParseMetadata }
  • Step 4: Run test to verify it passes
npm test tests/lib/jd-parser/parser.test.ts

Expected: PASS

  • Step 5: Commit
git add src/lib/jd-parser/types.ts tests/lib/jd-parser/parser.test.ts git commit -m "feat(jd-parser): add ParsedJD TypeScript types"

Task 2: JD Parser — Claude tool-use call

Files:

  • Create: calibre-app/src/lib/jd-parser/parser.ts

  • Modify: calibre-app/tests/lib/jd-parser/parser.test.ts

  • Step 1: Append failing tests

Append to calibre-app/tests/lib/jd-parser/parser.test.ts:

import { parseJDText } from '@/lib/jd-parser/parser' const SAMPLE_JD_TEXT = ` Job Summary Key Objectives: - Ensure smooth execution of cargo operations. - Manage voyage execution for time-chartered and spot LNG vessels. - Ensure strict compliance with HSSE standards. Key Responsibilities: - Voyage & Cargo Operations: Plan and execute LNG vessel scheduling. - Stakeholder Liaison: Primary contact between trading desks, shipowners, terminal operators. - Documentation & Reporting: Manage bills of lading, certificates, Trinity updates. Requirements: - Experience: Previous experience in LNG commercial cargo operations. - Technical Knowledge: Strong understanding of LNG cargo handling, charter party agreements. - Education: Degree or Diploma in Maritime Studies. - Flexibility: Availability to handle 24/7 trading activities. ` describe('parseJDText', () => { it('returns role with a raw_title', async () => { const result = await parseJDText(SAMPLE_JD_TEXT, 'JD for Cargo Operator (LNG).pdf', 'Energy') expect(result.role.raw_title.value).toBeTruthy() expect(result.role.raw_title.confidence).toBeGreaterThan(0) }) it('extracts at least one responsibility', async () => { const result = await parseJDText(SAMPLE_JD_TEXT, 'JD for Cargo Operator (LNG).pdf', 'Energy') expect(result.responsibilities.length).toBeGreaterThan(0) }) it('detects 24/7 / shift requirement', async () => { const result = await parseJDText(SAMPLE_JD_TEXT, 'JD for Cargo Operator (LNG).pdf', 'Energy') expect(result.requirements.shift_or_24_7.value).toBe(true) }) it('detects HSSE compliance', async () => { const result = await parseJDText(SAMPLE_JD_TEXT, 'JD for Cargo Operator (LNG).pdf', 'Energy') expect(result.requirements.hsse_compliance.value).toBe(true) }) it('parse_metadata reflects the vertical hint', async () => { const result = await parseJDText(SAMPLE_JD_TEXT, 'JD for Cargo Operator (LNG).pdf', 'Energy') expect(result.parse_metadata.vertical_hint).toBe('Energy') expect(result.parse_metadata.source_file).toBe('JD for Cargo Operator (LNG).pdf') }) })
  • Step 2: Run tests to verify they fail
npm test tests/lib/jd-parser/parser.test.ts

Expected: FAIL — Cannot find module '@/lib/jd-parser/parser'

  • Step 3: Write the parser

Create calibre-app/src/lib/jd-parser/parser.ts:

import Anthropic from '@anthropic-ai/sdk' import type { ParsedJD, JDParseMetadata } from './types' import type { ParseStatus, ConfidenceField } from '@/lib/cv-parser/types' const client = new Anthropic() const JD_EXTRACTION_TOOL: Anthropic.Tool = { name: 'extract_jd_data', description: 'Extract structured data from a Job Description text. Assign a confidence score (0.0–1.0) to every field. Use null when a field is absent.', input_schema: { type: 'object' as const, required: ['role', 'responsibilities', 'requirements'], properties: { role: { type: 'object', required: ['raw_title', 'job_summary', 'key_objectives'], properties: { raw_title: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'string' }, confidence: { type: 'number' } } }, job_summary: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'string' }, confidence: { type: 'number' } } }, key_objectives: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'array', items: { type: 'string' } }, confidence: { type: 'number' } } }, }, }, responsibilities: { type: 'array', items: { type: 'object', required: ['category', 'description', 'confidence'], properties: { category: { type: 'string' }, description: { type: 'string' }, confidence: { type: 'number' }, }, }, }, requirements: { type: 'object', required: [ 'min_years_experience', 'experience_description', 'must_have_skills', 'nice_to_have_skills', 'technical_knowledge', 'education', 'certifications', 'tools_and_systems', 'languages', 'hsse_compliance', 'shift_or_24_7', ], properties: { min_years_experience: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: ['number', 'null'] }, confidence: { type: 'number' } } }, experience_description: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'string' }, confidence: { type: 'number' } } }, must_have_skills: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'array', items: { type: 'string' } }, confidence: { type: 'number' } } }, nice_to_have_skills: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'array', items: { type: 'string' } }, confidence: { type: 'number' } } }, technical_knowledge: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'array', items: { type: 'string' } }, confidence: { type: 'number' } } }, education: { type: 'array', items: { type: 'object', required: ['qualification', 'field', 'required'], properties: { qualification: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'string' }, confidence: { type: 'number' } } }, field: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'string' }, confidence: { type: 'number' } } }, required: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'boolean' }, confidence: { type: 'number' } } }, }, }, }, certifications: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'array', items: { type: 'string' } }, confidence: { type: 'number' } } }, tools_and_systems: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'array', items: { type: 'string' } }, confidence: { type: 'number' } } }, languages: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'array', items: { type: 'string' } }, confidence: { type: 'number' } } }, hsse_compliance: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'boolean' }, confidence: { type: 'number' } } }, shift_or_24_7: { type: 'object', required: ['value', 'confidence'], properties: { value: { type: 'boolean' }, confidence: { type: 'number' } } }, }, }, }, }, } function computeOverallConfidence(jd: Omit<ParsedJD, 'parse_metadata'>): number { const scores: number[] = [] const walk = (obj: unknown) => { if (!obj || typeof obj !== 'object') return const o = obj as Record<string, unknown> if ('confidence' in o && typeof o.confidence === 'number') { scores.push(o.confidence) return } for (const v of Object.values(o)) { if (Array.isArray(v)) v.forEach(walk) else walk(v) } } walk(jd) if (scores.length === 0) return 0 return Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 100) / 100 } function deriveParseStatus(jd: Omit<ParsedJD, 'parse_metadata'>, overall: number): ParseStatus { const required: ConfidenceField<unknown>[] = [ jd.role.raw_title, jd.role.job_summary, jd.requirements.experience_description, ] const lowConf = required.some(f => f.confidence === null || (f.confidence as number) < 0.6) if (lowConf || overall < 0.7) return 'partial' return 'success' } export async function parseJDText(rawText: string, sourceFile: string, verticalHint: string): Promise<ParsedJD> { const response = await client.messages.create({ model: 'claude-sonnet-4-6', max_tokens: 4096, tools: [JD_EXTRACTION_TOOL], tool_choice: { type: 'tool', name: 'extract_jd_data' }, messages: [ { role: 'user', content: `You are a specialist recruitment data extraction system for the commodities, maritime, energy, and finance sectors. Extract all structured data from the following Job Description. For every field: - Assign confidence 0.0 (uncertain) to 1.0 (unambiguous). - For min_years_experience: extract the numeric floor if the JD says "X+ years" or "minimum X years". Use null when not specified. - For must_have_skills: skills/experiences phrased as required ("must have", "required", "essential"). - For nice_to_have_skills: skills phrased as preferred ("preferred", "ideally", "bonus"). - For technical_knowledge: domain-specific knowledge (HSSE, charter party agreements, ISDA, financial modelling, etc.). - For tools_and_systems: named software/platforms (Trinity, Bloomberg, SAP, etc.). - For hsse_compliance: true if HSSE / safety / health / environment compliance is mentioned as a requirement. - For shift_or_24_7: true if the JD mentions 24/7 coverage, shift work, or out-of-hours operational responsibility. JD TEXT: --- ${rawText} ---`, }, ], }) const toolUse = response.content.find(b => b.type === 'tool_use') if (!toolUse || toolUse.type !== 'tool_use') { throw new Error('Claude did not return a tool_use block') } const extracted = toolUse.input as Omit<ParsedJD, 'parse_metadata'> const overall = computeOverallConfidence(extracted) const parse_metadata: JDParseMetadata = { source_file: sourceFile, vertical_hint: verticalHint, parse_status: deriveParseStatus(extracted, overall), overall_confidence: overall, parsed_at: new Date().toISOString(), } return { ...extracted, parse_metadata } }
  • Step 4: Run tests to verify they pass
npm test tests/lib/jd-parser/parser.test.ts

Expected: PASS (requires ANTHROPIC_API_KEY in .env.local)

  • Step 5: Commit
git add src/lib/jd-parser/parser.ts tests/lib/jd-parser/parser.test.ts git commit -m "feat(jd-parser): add Claude-powered JD parser with tool_use structured output"

Task 3: JD Parser public API

Files:

  • Create: calibre-app/src/lib/jd-parser/index.ts

  • Step 1: Append failing test

Append to calibre-app/tests/lib/jd-parser/parser.test.ts:

import { parseJD } from '@/lib/jd-parser' import path from 'path' describe('parseJD (public API)', () => { it('parses a real JD PDF end-to-end', async () => { const filePath = path.resolve( __dirname, '../../../../../reference/JD/Operations- cargo, commodities brokerage/Energy/JD for Cargo Operator (LNG).pdf' ) const result = await parseJD(filePath) expect(result.parse_metadata.parse_status).not.toBe('failed') expect(result.role.raw_title.value.toLowerCase()).toContain('lng') expect(result.parse_metadata.vertical_hint).toBe('Energy') }) })
  • Step 2: Run test to verify it fails
npm test tests/lib/jd-parser/parser.test.ts

Expected: FAIL — Cannot find module '@/lib/jd-parser'

  • Step 3: Write index.ts

Create calibre-app/src/lib/jd-parser/index.ts:

import path from 'path' import { extractText } from '@/lib/cv-parser/extractor' import { parseJDText } from './parser' import type { ParsedJD } from './types' export type { ParsedJD } from './types' function deriveVerticalHint(filePath: string): string { const parts = filePath.replace(/\\/g, '/').split('/') const jdIdx = parts.findIndex(p => p === 'JD') if (jdIdx === -1 || jdIdx === parts.length - 1) return 'Unknown' const tail = parts.slice(jdIdx + 1, -1) return tail.length > 0 ? tail[tail.length - 1] : 'Unknown' } export async function parseJD(filePath: string): Promise<ParsedJD> { const sourceFile = path.basename(filePath) const verticalHint = deriveVerticalHint(filePath) const extraction = await extractText(filePath) if (extraction.status === 'failed') { throw new Error(`Extraction failed for ${sourceFile}: ${extraction.reason ?? 'unknown'}`) } return parseJDText(extraction.text, sourceFile, verticalHint) }
  • Step 4: Run test to verify it passes
npm test tests/lib/jd-parser/parser.test.ts

Expected: PASS

  • Step 5: Commit
git add src/lib/jd-parser/index.ts git commit -m "feat(jd-parser): add parseJD public API + vertical-hint derivation from path"

Task 4: Batch parse JDs

Files:

  • Create: calibre-app/scripts/batch-parse-jds.ts

  • Create: calibre-app/outputs/parsed-jds/.gitkeep

  • Step 1: Write the script

Create calibre-app/scripts/batch-parse-jds.ts:

import fs from 'fs' import path from 'path' import { parseJD } from '../src/lib/jd-parser' const JD_ROOT = path.resolve(__dirname, '../../reference/JD') const OUTPUT_DIR = path.resolve(__dirname, '../outputs/parsed-jds') function walkPdfs(dir: string): string[] { const out: string[] = [] for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name) if (entry.isDirectory()) out.push(...walkPdfs(full)) else if (path.extname(entry.name).toLowerCase() === '.pdf') out.push(full) } return out } async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }) const files = walkPdfs(JD_ROOT) console.log(`Found ${files.length} JD PDFs.`) const results = { success: 0, partial: 0, failed: 0 } for (const filePath of files) { const fileName = path.basename(filePath) const outFile = path.join(OUTPUT_DIR, `${path.basename(fileName, '.pdf')}.json`) process.stdout.write(` Parsing: ${fileName} ... `) try { const parsed = await parseJD(filePath) fs.writeFileSync(outFile, JSON.stringify(parsed, null, 2)) const status = parsed.parse_metadata.parse_status console.log(`${status} (confidence: ${parsed.parse_metadata.overall_confidence.toFixed(2)})`) results[status]++ } catch (err) { console.log(`ERROR: ${err instanceof Error ? err.message : err}`) results.failed++ } await new Promise(r => setTimeout(r, 500)) } console.log('\n─────────────────────────────') console.log(`✅ success: ${results.success}`) console.log(`⚠️ partial: ${results.partial}`) console.log(`❌ failed: ${results.failed}`) console.log(`Output: ${OUTPUT_DIR}`) } main().catch(console.error)
  • Step 2: Add npm script

Edit calibre-app/package.json — add to "scripts":

"batch-parse-jds": "tsx scripts/batch-parse-jds.ts"
  • Step 3: Add .gitkeep
mkdir -p outputs/parsed-jds touch outputs/parsed-jds/.gitkeep echo "outputs/parsed-jds/" >> .gitignore
  • Step 4: Run against all 41 JDs
npm run batch-parse-jds

Expected: ≥ 38 of 41 JDs parse as success or partial. Failures must be logged.

  • Step 5: Spot-check 3 JSONs — Cargo (LNG), Finance (Portfolio Manager), Risk (Credit Risk). Verify requirements.must_have_skills looks sensible and vertical_hint matches the folder.
cat "outputs/parsed-jds/JD for Cargo Operator (LNG).json" cat "outputs/parsed-jds/JD for Portfolio Manager.json" cat "outputs/parsed-jds/JD for Credit Risk.json"
  • Step 6: Commit
git add scripts/batch-parse-jds.ts outputs/parsed-jds/.gitkeep package.json .gitignore git commit -m "feat(jd-parser): add batch JD parsing script + ignore parsed-jds output"

Task 5: Taxonomy — extend with finance-fo + ops-back-office

Files:

  • Create: calibre-app/src/lib/jd-normaliser/taxonomy.ts

  • Create: calibre-app/tests/lib/jd-normaliser/taxonomy.test.ts

  • Step 1: Write the failing test

Create calibre-app/tests/lib/jd-normaliser/taxonomy.test.ts:

import { describe, it, expect } from 'vitest' import { TAXONOMY, SUB_FUNCTIONS, isValidTag } from '@/lib/jd-normaliser/taxonomy' describe('taxonomy', () => { it('exports the five sub-functions', () => { expect(SUB_FUNCTIONS).toEqual([ 'cargo-ops', 'ship-ops', 'risk-analytics', 'finance-fo', 'ops-back-office', ]) }) it('cargo-ops tags include energy:lng and agri:grains', () => { expect(TAXONOMY['cargo-ops']).toContain('energy:lng') expect(TAXONOMY['cargo-ops']).toContain('agri:grains') }) it('finance-fo has expected tags', () => { expect(TAXONOMY['finance-fo']).toContain('finance:portfolio-management') expect(TAXONOMY['finance-fo']).toContain('finance:institutional-sales') }) it('ops-back-office has expected tags', () => { expect(TAXONOMY['ops-back-office']).toContain('ops:aml') expect(TAXONOMY['ops-back-office']).toContain('ops:compliance') }) it('isValidTag accepts known tags and rejects unknown', () => { expect(isValidTag('energy:lng')).toBe(true) expect(isValidTag('bogus:tag')).toBe(false) }) })
  • Step 2: Run test to verify it fails
npm test tests/lib/jd-normaliser/taxonomy.test.ts

Expected: FAIL — Cannot find module '@/lib/jd-normaliser/taxonomy'

  • Step 3: Write the taxonomy

Create calibre-app/src/lib/jd-normaliser/taxonomy.ts:

export const SUB_FUNCTIONS = [ 'cargo-ops', 'ship-ops', 'risk-analytics', 'finance-fo', 'ops-back-office', ] as const export type SubFunction = typeof SUB_FUNCTIONS[number] export const SUB_FUNCTION_DISPLAY: Record<SubFunction, string> = { 'cargo-ops': 'Operations - cargo, commodities brokerage', 'ship-ops': 'Operations - ship, shipping brokerage', 'risk-analytics': 'Risk - Analytics', 'finance-fo': 'Finance - RM, wealth management, FA, institutional sales', 'ops-back-office': 'Operations - banks, finance brokerage, funds, asset management', } export const TAXONOMY: Record<SubFunction, string[]> = { 'cargo-ops': [ 'energy:oil', 'energy:coal', 'energy:lng', 'energy:lpg', 'energy:petrochemicals', 'energy:light-ends', 'energy:middle-distillates', 'metals:base', 'metals:ferrous', 'metals:precious', 'agri:grains', 'agri:edible-oils', 'agri:veg-oils', 'agri:cocoa', 'agri:coffee', 'agri:sugar', 'agri:rice', 'agri:fertilizers', 'agri:biofuels', 'agri:oleochemicals', 'agri:edible-nuts', ], 'ship-ops': [ 'vessel:tanker', 'vessel:chemical-tanker', 'vessel:lng-carrier', 'vessel:lpg-carrier', 'vessel:drybulk', 'vessel:container', 'vessel:offshore-support', ], 'risk-analytics': [ 'risk:market', 'risk:credit', 'risk:operational', 'risk:analytics', 'risk:controlling', 'risk:product-control', ], 'finance-fo': [ 'finance:portfolio-management', 'finance:relationship-management', 'finance:wealth-management', 'finance:financial-trading', 'finance:institutional-sales', 'finance:private-wealth-sales', ], 'ops-back-office': [ 'ops:aml', 'ops:compliance', 'ops:assistant-rm', 'ops:finance-operations', ], } const ALL_TAGS = new Set<string>( Object.values(TAXONOMY).flat() ) export function isValidTag(tag: string): boolean { return ALL_TAGS.has(tag) } export function tagsForSubFunction(sf: SubFunction): string[] { return TAXONOMY[sf] }
  • Step 4: Run test to verify it passes
npm test tests/lib/jd-normaliser/taxonomy.test.ts

Expected: PASS

  • Step 5: Commit
git add src/lib/jd-normaliser/taxonomy.ts tests/lib/jd-normaliser/taxonomy.test.ts git commit -m "feat(jd-normaliser): add taxonomy with cargo/ship/risk/finance/ops-back-office sub-functions"

Task 6: JD Normaliser

Files:

  • Create: calibre-app/src/lib/jd-normaliser/types.ts

  • Create: calibre-app/src/lib/jd-normaliser/normaliser.ts

  • Create: calibre-app/src/lib/jd-normaliser/index.ts

  • Create: calibre-app/tests/lib/jd-normaliser/normaliser.test.ts

  • Step 1: Write types

Create calibre-app/src/lib/jd-normaliser/types.ts:

import type { SubFunction } from './taxonomy' export type SeniorityTier = 'Junior' | 'Mid' | 'Senior' | 'Lead' | 'Head' export interface NormalisedJD { sub_function: SubFunction sub_function_display: string tags: string[] normalised_title: string seniority_tier: SeniorityTier min_years: number | null must_have_tags: string[] nice_to_have_tags: string[] confidence: number }
  • Step 2: Write the failing tests

Create calibre-app/tests/lib/jd-normaliser/normaliser.test.ts:

import { describe, it, expect } from 'vitest' import fs from 'fs' import path from 'path' import { normaliseJD } from '@/lib/jd-normaliser' import type { ParsedJD } from '@/lib/jd-parser/types' import { isValidTag } from '@/lib/jd-normaliser/taxonomy' function loadParsedJD(name: string): ParsedJD { const filePath = path.resolve(__dirname, '../../../outputs/parsed-jds', name) return JSON.parse(fs.readFileSync(filePath, 'utf-8')) } describe('normaliseJD', () => { it('classifies an LNG cargo JD as cargo-ops with energy:lng tag', async () => { const parsed = loadParsedJD('JD for Cargo Operator (LNG).json') const result = await normaliseJD(parsed) expect(result.sub_function).toBe('cargo-ops') expect(result.must_have_tags).toContain('energy:lng') }) it('classifies a Portfolio Manager JD as finance-fo', async () => { const parsed = loadParsedJD('JD for Portfolio Manager.json') const result = await normaliseJD(parsed) expect(result.sub_function).toBe('finance-fo') expect(result.must_have_tags).toContain('finance:portfolio-management') }) it('classifies a Credit Risk JD as risk-analytics with risk:credit', async () => { const parsed = loadParsedJD('JD for Credit Risk.json') const result = await normaliseJD(parsed) expect(result.sub_function).toBe('risk-analytics') expect(result.must_have_tags).toContain('risk:credit') }) it('all output tags are valid taxonomy entries', async () => { const parsed = loadParsedJD('JD for Cargo Operator (LNG).json') const result = await normaliseJD(parsed) for (const tag of [...result.tags, ...result.must_have_tags, ...result.nice_to_have_tags]) { expect(isValidTag(tag)).toBe(true) } }) })
  • Step 3: Run tests to verify they fail
npm test tests/lib/jd-normaliser/normaliser.test.ts

Expected: FAIL — Cannot find module '@/lib/jd-normaliser'

  • Step 4: Write the normaliser

Create calibre-app/src/lib/jd-normaliser/normaliser.ts:

import Anthropic from '@anthropic-ai/sdk' import type { ParsedJD } from '@/lib/jd-parser/types' import type { NormalisedJD } from './types' import { TAXONOMY, SUB_FUNCTIONS, SUB_FUNCTION_DISPLAY, isValidTag, type SubFunction, } from './taxonomy' const client = new Anthropic() const NORMALISE_TOOL: Anthropic.Tool = { name: 'normalise_jd', description: 'Classify a parsed JD against the Calibre taxonomy.', input_schema: { type: 'object' as const, required: ['sub_function', 'tags', 'must_have_tags', 'nice_to_have_tags', 'normalised_title', 'seniority_tier', 'min_years', 'confidence'], properties: { sub_function: { type: 'string', enum: [...SUB_FUNCTIONS] }, tags: { type: 'array', items: { type: 'string' } }, must_have_tags: { type: 'array', items: { type: 'string' } }, nice_to_have_tags: { type: 'array', items: { type: 'string' } }, normalised_title: { type: 'string' }, seniority_tier: { type: 'string', enum: ['Junior', 'Mid', 'Senior', 'Lead', 'Head'] }, min_years: { type: ['number', 'null'] }, confidence: { type: 'number' }, }, }, } export async function normaliseJD(jd: ParsedJD): Promise<NormalisedJD> { const taxonomyJson = JSON.stringify(TAXONOMY, null, 2) const response = await client.messages.create({ model: 'claude-sonnet-4-6', max_tokens: 2048, tools: [NORMALISE_TOOL], tool_choice: { type: 'tool', name: 'normalise_jd' }, messages: [ { role: 'user', content: `You are classifying a Job Description against Calibre's recruitment taxonomy. CONTROLLED VOCABULARY (only return tags from this list): ${taxonomyJson} Rules: - sub_function: pick the single best fit. - tags: ALL applicable tags from the chosen sub_function's tag list. - must_have_tags: subset of tags that the JD explicitly requires. - nice_to_have_tags: tags that are preferred / nice-to-have / adjacent. - normalised_title: standardised title (e.g. "Cargo Operations Executive - LNG"). - seniority_tier: Junior 0-2y, Mid 3-5y, Senior 6-10y, Lead 10-15y, Head 15+y. Cross-check title + JD min_years. - min_years: copy from JD if specified; null otherwise. - confidence: 0.0-1.0, your overall certainty in this classification. PARSED JD: ${JSON.stringify(jd, null, 2)}`, }, ], }) const toolUse = response.content.find(b => b.type === 'tool_use') if (!toolUse || toolUse.type !== 'tool_use') { throw new Error('Claude did not return a tool_use block for JD normalisation') } const out = toolUse.input as Omit<NormalisedJD, 'sub_function_display'> const sf = out.sub_function as SubFunction const cleanTags = (arr: string[]) => arr.filter(isValidTag) return { sub_function: sf, sub_function_display: SUB_FUNCTION_DISPLAY[sf], tags: cleanTags(out.tags ?? []), must_have_tags: cleanTags(out.must_have_tags ?? []), nice_to_have_tags: cleanTags(out.nice_to_have_tags ?? []), normalised_title: out.normalised_title, seniority_tier: out.seniority_tier, min_years: out.min_years, confidence: out.confidence, } }
  • Step 5: Write index.ts

Create calibre-app/src/lib/jd-normaliser/index.ts:

export { normaliseJD } from './normaliser' export type { NormalisedJD, SeniorityTier } from './types' export { SUB_FUNCTIONS, SUB_FUNCTION_DISPLAY, TAXONOMY, isValidTag, tagsForSubFunction, } from './taxonomy' export type { SubFunction } from './taxonomy'
  • Step 6: Run tests to verify they pass
npm test tests/lib/jd-normaliser/normaliser.test.ts

Expected: PASS (requires outputs/parsed-jds/*.json from Task 4 — re-run npm run batch-parse-jds first if missing)

  • Step 7: Commit
git add src/lib/jd-normaliser/ tests/lib/jd-normaliser/normaliser.test.ts git commit -m "feat(jd-normaliser): classify JDs against controlled tag taxonomy via Claude"

Task 7: Batch normalise JDs + filename validation

Files:

  • Create: calibre-app/scripts/batch-normalise-jds.ts

  • Step 1: Write the script

Create calibre-app/scripts/batch-normalise-jds.ts:

import fs from 'fs' import path from 'path' import { normaliseJD } from '../src/lib/jd-normaliser' import type { ParsedJD } from '../src/lib/jd-parser/types' const PARSED_DIR = path.resolve(__dirname, '../outputs/parsed-jds') const OUTPUT_DIR = path.resolve(__dirname, '../outputs/normalised-jds') interface ValidationFailure { file: string reason: string } async function main() { fs.mkdirSync(OUTPUT_DIR, { recursive: true }) const files = fs.readdirSync(PARSED_DIR).filter(f => f.endsWith('.json')) const failures: ValidationFailure[] = [] const subFunctionCounts: Record<string, number> = {} for (const file of files) { const parsedPath = path.join(PARSED_DIR, file) const parsed: ParsedJD = JSON.parse(fs.readFileSync(parsedPath, 'utf-8')) process.stdout.write(` Normalising: ${file} ... `) try { const normalised = await normaliseJD(parsed) fs.writeFileSync( path.join(OUTPUT_DIR, file), JSON.stringify(normalised, null, 2) ) subFunctionCounts[normalised.sub_function] = (subFunctionCounts[normalised.sub_function] ?? 0) + 1 console.log(`${normalised.sub_function} / ${normalised.seniority_tier} (conf ${normalised.confidence.toFixed(2)})`) } catch (err) { failures.push({ file, reason: err instanceof Error ? err.message : String(err) }) console.log(`ERROR`) } await new Promise(r => setTimeout(r, 500)) } console.log('\nSub-function distribution:') for (const [sf, count] of Object.entries(subFunctionCounts)) { console.log(` ${sf}: ${count}`) } if (failures.length) { console.log('\nFailures:') for (const f of failures) console.log(` ${f.file} — ${f.reason}`) process.exit(1) } } main().catch(err => { console.error(err); process.exit(1) })
  • Step 2: Add npm script

Edit calibre-app/package.json — add to "scripts":

"batch-normalise-jds": "tsx scripts/batch-normalise-jds.ts"
  • Step 3: Add output ignore
echo "outputs/normalised-jds/" >> .gitignore
  • Step 4: Run the batch
npm run batch-normalise-jds

Expected output (approximate):

Sub-function distribution: cargo-ops: 19 ship-ops: 8 risk-analytics: 5 finance-fo: 5 ops-back-office: 4
  • Step 5: Spot-check folder vs sub_function

Inspect 3 outputs from different verticals and confirm sub_function matches the source folder.

  • Step 6: Commit
git add scripts/batch-normalise-jds.ts package.json .gitignore git commit -m "feat(jd-normaliser): batch-normalise all 41 JDs into NormalisedJD records"

Task 8: Matching — types

Files:

  • Create: calibre-app/src/lib/matching/types.ts

  • Create: calibre-app/tests/lib/matching/types.test.ts

  • Step 1: Write the failing test

Create calibre-app/tests/lib/matching/types.test.ts:

import { describe, it, expect } from 'vitest' import type { Layer1Result, Layer2Result, Layer3Verdict, AdjacentMatchEvidence, MatchRecord, } from '@/lib/matching/types' describe('matching types', () => { it('Layer1Result composite is a number and passes is boolean', () => { const r: Layer1Result = { jd_id: 'JD for Cargo Operator (LNG)', cv_id: 'Adrian Tan (Risk, Oil)', scores: { sub_function: 5, tag_overlap: 4, seniority: 5, years: 5, red_flag_penalty: 0 }, composite: 4.6, passes: true, } expect(r.composite).toBeCloseTo(4.6) expect(r.passes).toBe(true) }) it('Layer3Verdict is one of four bands', () => { const verdicts: Layer3Verdict['band'][] = ['Strong yes', 'Soft yes', 'Soft no', 'Strong no'] expect(verdicts.length).toBe(4) }) })
  • Step 2: Run test to verify it fails
npm test tests/lib/matching/types.test.ts

Expected: FAIL — Cannot find module '@/lib/matching/types'

  • Step 3: Write the types

Create calibre-app/src/lib/matching/types.ts:

import type { NormalisedJD } from '@/lib/jd-normaliser' export interface Layer1Scores { sub_function: number tag_overlap: number seniority: number years: number red_flag_penalty: number } export interface Layer1Result { jd_id: string cv_id: string scores: Layer1Scores composite: number passes: boolean drop_reason?: 'sub_function_mismatch' | 'composite_below_4' | 'hard_gate' adjacent_match?: AdjacentMatchEvidence } export interface AdjacentMatchEvidence { proposed_adjacency: { from: string; to: string } weight: number reasoning: string cv_evidence: string jd_evidence: string recommend_ratification: boolean status: 'live_inference' | 'ratified_config' } export interface Layer2DimensionScore { score: 1 | 2 | 3 | 4 | 5 evidence: string[] notes: string low_evidence?: boolean } export interface Layer2RedFlagOrStrength { signal: string evidence: string } export interface Layer2Result { jd_id: string cv_id: string executive_summary: string dimensions: { experience_alignment: Layer2DimensionScore skills_adjacency: Layer2DimensionScore | null technical_knowledge: Layer2DimensionScore tools_and_systems: Layer2DimensionScore education_certifications: Layer2DimensionScore red_flags: { items: Layer2RedFlagOrStrength[] } strengths: { items: Layer2RedFlagOrStrength[] } } composite_l2_score: number confidence: number } export interface Layer3Verdict { band: 'Strong yes' | 'Soft yes' | 'Soft no' | 'Strong no' rationale: string } export interface MatchRecord { jd_id: string cv_id: string jd_snapshot: NormalisedJD layer1: Layer1Result layer2: Layer2Result | null layer3: Layer3Verdict generated_at: string }
  • Step 4: Run test to verify it passes
npm test tests/lib/matching/types.test.ts

Expected: PASS

  • Step 5: Commit
git add src/lib/matching/types.ts tests/lib/matching/types.test.ts git commit -m "feat(matching): add Layer1/Layer2/Layer3 + MatchRecord types"

Task 9: Layer 1 — Hard Fit (pure deterministic functions)

Precondition: CV normaliser (src/lib/cv-normaliser/) exists per its design spec. This task imports NormalisedCV from @/lib/cv-normaliser.

Files:

  • Create: calibre-app/src/lib/matching/layer1-hard-fit.ts

  • Create: calibre-app/tests/lib/matching/layer1-hard-fit.test.ts

  • Step 1: Write the failing tests

Create calibre-app/tests/lib/matching/layer1-hard-fit.test.ts:

import { describe, it, expect } from 'vitest' import { scoreSubFunction, scoreTagOverlap, scoreSeniority, scoreYears, scoreRedFlagPenalty, computeLayer1, } from '@/lib/matching/layer1-hard-fit' import type { NormalisedJD } from '@/lib/jd-normaliser' import type { NormalisedCV } from '@/lib/cv-normaliser' import type { ParsedCV } from '@/lib/cv-parser/types' const jd: NormalisedJD = { sub_function: 'cargo-ops', sub_function_display: 'Operations - cargo, commodities brokerage', tags: ['energy:lng'], must_have_tags: ['energy:lng'], nice_to_have_tags: ['vessel:lng-carrier'], normalised_title: 'Cargo Operations Executive - LNG', seniority_tier: 'Mid', min_years: 3, confidence: 0.9, } const cvLngMid: NormalisedCV = { sub_function: 'cargo-ops', sub_function_display: 'Operations - cargo, commodities brokerage', tags: ['energy:lng', 'energy:lpg'], normalised_title: 'LNG Cargo Operations Executive', seniority_tier: 'Mid', search_queries: [], confidence: 0.9, } const parsedCvClean: Partial<ParsedCV> = { signals: { total_years_experience: { value: 4, confidence: 0.9 }, career_gaps: { value: [], confidence: 0.9 }, short_tenures: { value: [], confidence: 0.9 }, } as ParsedCV['signals'], parse_metadata: { source_file: '', file_type: 'pdf', parse_status: 'success', unreadable_reason: null, overall_confidence: 0.9, parsed_at: '', ungrounded_items: [], stale_cv: false, last_role_end: null, }, } describe('scoreSubFunction', () => { it('exact match scores 5', () => { expect(scoreSubFunction(jd, cvLngMid)).toBe(5) }) it('mismatch scores 0', () => { expect(scoreSubFunction(jd, { ...cvLngMid, sub_function: 'risk-analytics' })).toBe(0) }) }) describe('scoreTagOverlap', () => { it('full must-have overlap scores 5', () => { expect(scoreTagOverlap(jd, cvLngMid)).toBe(5) }) it('zero overlap scores 0', () => { const cvNoLng = { ...cvLngMid, tags: ['energy:oil'] } expect(scoreTagOverlap(jd, cvNoLng)).toBe(0) }) it('half overlap on two must-haves scores 2.5', () => { const jdTwo = { ...jd, must_have_tags: ['energy:lng', 'vessel:lng-carrier'] } expect(scoreTagOverlap(jdTwo, cvLngMid)).toBeCloseTo(2.5) }) }) describe('scoreSeniority', () => { it('exact tier scores 5', () => { expect(scoreSeniority(jd, cvLngMid)).toBe(5) }) it('off-by-one tier scores 3', () => { expect(scoreSeniority(jd, { ...cvLngMid, seniority_tier: 'Senior' })).toBe(3) }) it('off-by-two tier scores 1', () => { expect(scoreSeniority(jd, { ...cvLngMid, seniority_tier: 'Lead' })).toBe(1) }) it('off-by-three+ scores 0', () => { expect(scoreSeniority(jd, { ...cvLngMid, seniority_tier: 'Head' })).toBe(0) }) }) describe('scoreYears', () => { it('above floor scores 5', () => { expect(scoreYears(jd, parsedCvClean as ParsedCV)).toBe(5) }) it('within 80% of floor scores 3', () => { const cv = { ...parsedCvClean, signals: { ...parsedCvClean.signals!, total_years_experience: { value: 2.5, confidence: 0.9 } } } as ParsedCV expect(scoreYears(jd, cv)).toBe(3) }) it('below 80% of floor scores 0', () => { const cv = { ...parsedCvClean, signals: { ...parsedCvClean.signals!, total_years_experience: { value: 1, confidence: 0.9 } } } as ParsedCV expect(scoreYears(jd, cv)).toBe(0) }) it('null floor scores 5', () => { expect(scoreYears({ ...jd, min_years: null }, parsedCvClean as ParsedCV)).toBe(5) }) }) describe('scoreRedFlagPenalty', () => { it('clean CV has 0 penalty', () => { expect(scoreRedFlagPenalty(parsedCvClean as ParsedCV)).toBe(0) }) it('stale CV penalises -2', () => { const cv = { ...parsedCvClean, parse_metadata: { ...parsedCvClean.parse_metadata!, stale_cv: true } } as ParsedCV expect(scoreRedFlagPenalty(cv)).toBe(-2) }) it('one career gap penalises -1', () => { const cv = { ...parsedCvClean, signals: { ...parsedCvClean.signals!, career_gaps: { value: [{ from: '2022-01', to: '2022-08', months: 7 }], confidence: 0.9 } } } as ParsedCV expect(scoreRedFlagPenalty(cv)).toBe(-1) }) }) describe('computeLayer1', () => { it('strong match passes with composite >= 4', () => { const r = computeLayer1(jd, cvLngMid, parsedCvClean as ParsedCV, 'jd-1', 'cv-1') expect(r.passes).toBe(true) expect(r.composite).toBeGreaterThanOrEqual(4) }) it('sub-function mismatch drops with reason', () => { const r = computeLayer1(jd, { ...cvLngMid, sub_function: 'risk-analytics' }, parsedCvClean as ParsedCV, 'jd-1', 'cv-2') expect(r.passes).toBe(false) expect(r.drop_reason).toBe('sub_function_mismatch') }) it('composite < 4 drops with composite_below_4', () => { const cv = { ...cvLngMid, tags: ['energy:oil'], seniority_tier: 'Junior' as const } const parsedThin = { ...parsedCvClean, signals: { ...parsedCvClean.signals!, total_years_experience: { value: 1, confidence: 0.9 } } } as ParsedCV const r = computeLayer1(jd, cv, parsedThin, 'jd-1', 'cv-3') expect(r.passes).toBe(false) expect(r.drop_reason).toBe('composite_below_4') }) })
  • Step 2: Run tests to verify they fail
npm test tests/lib/matching/layer1-hard-fit.test.ts

Expected: FAIL — Cannot find module '@/lib/matching/layer1-hard-fit'

  • Step 3: Write the Layer 1 module

Create calibre-app/src/lib/matching/layer1-hard-fit.ts:

import type { NormalisedJD, SeniorityTier } from '@/lib/jd-normaliser' import type { NormalisedCV } from '@/lib/cv-normaliser' import type { ParsedCV } from '@/lib/cv-parser/types' import type { Layer1Result, Layer1Scores } from './types' const TIERS: SeniorityTier[] = ['Junior', 'Mid', 'Senior', 'Lead', 'Head'] export function scoreSubFunction(jd: NormalisedJD, cv: NormalisedCV): number { return jd.sub_function === cv.sub_function ? 5 : 0 } export function scoreTagOverlap(jd: NormalisedJD, cv: NormalisedCV): number { if (jd.must_have_tags.length === 0) return 5 const cvTagSet = new Set(cv.tags) const overlap = jd.must_have_tags.filter(t => cvTagSet.has(t)).length return Math.round((5 * overlap / jd.must_have_tags.length) * 100) / 100 } export function scoreSeniority(jd: NormalisedJD, cv: NormalisedCV): number { const delta = Math.abs(TIERS.indexOf(jd.seniority_tier) - TIERS.indexOf(cv.seniority_tier)) if (delta === 0) return 5 if (delta === 1) return 3 if (delta === 2) return 1 return 0 } export function scoreYears(jd: NormalisedJD, parsedCv: ParsedCV): number { if (jd.min_years === null) return 5 const years = parsedCv.signals.total_years_experience.value if (years >= jd.min_years) return 5 if (years >= jd.min_years * 0.8) return 3 return 0 } export function scoreRedFlagPenalty(parsedCv: ParsedCV): number { let penalty = 0 if (parsedCv.parse_metadata.stale_cv) penalty -= 2 const gaps = parsedCv.signals.career_gaps.value.filter(g => g.months > 6).length penalty -= gaps const shortTenureClusters = parsedCv.signals.short_tenures.value.length >= 2 ? 1 : 0 penalty -= shortTenureClusters return penalty } export function computeLayer1( jd: NormalisedJD, cv: NormalisedCV, parsedCv: ParsedCV, jd_id: string, cv_id: string, ): Layer1Result { const scores: Layer1Scores = { sub_function: scoreSubFunction(jd, cv), tag_overlap: scoreTagOverlap(jd, cv), seniority: scoreSeniority(jd, cv), years: scoreYears(jd, parsedCv), red_flag_penalty: scoreRedFlagPenalty(parsedCv), } const composite = Math.round( ((scores.sub_function + scores.tag_overlap + scores.seniority + scores.years) / 4 + scores.red_flag_penalty) * 100, ) / 100 if (scores.sub_function === 0) { return { jd_id, cv_id, scores, composite, passes: false, drop_reason: 'sub_function_mismatch' } } if (composite < 4) { return { jd_id, cv_id, scores, composite, passes: false, drop_reason: 'composite_below_4' } } return { jd_id, cv_id, scores, composite, passes: true } }
  • Step 4: Run tests to verify they pass
npm test tests/lib/matching/layer1-hard-fit.test.ts

Expected: PASS

  • Step 5: Commit
git add src/lib/matching/layer1-hard-fit.ts tests/lib/matching/layer1-hard-fit.test.ts git commit -m "feat(matching): add Layer 1 deterministic hard-fit scoring"

Task 10: Adjacency config loader

Files:

  • Create: calibre-app/config/adjacency-ratified.json

  • Create: calibre-app/src/lib/matching/adjacency/config.ts

  • Create: calibre-app/tests/lib/matching/adjacency/config.test.ts

  • Step 1: Seed an empty config file

Create calibre-app/config/adjacency-ratified.json:

{ "adjacencies": [], "deny_list": [] }
  • Step 2: Write the failing test

Create calibre-app/tests/lib/matching/adjacency/config.test.ts:

import { describe, it, expect, beforeEach } from 'vitest' import fs from 'fs' import path from 'path' import { loadAdjacencyConfig, lookupAdjacency, isDenied, appendRatified, appendDenied, } from '@/lib/matching/adjacency/config' const CONFIG_PATH = path.resolve(__dirname, '../../../../config/adjacency-ratified.json') describe('adjacency config', () => { beforeEach(() => { fs.writeFileSync(CONFIG_PATH, JSON.stringify({ adjacencies: [], deny_list: [] }, null, 2)) }) it('loadAdjacencyConfig returns empty defaults', () => { const cfg = loadAdjacencyConfig() expect(cfg.adjacencies).toEqual([]) expect(cfg.deny_list).toEqual([]) }) it('appendRatified persists a new pair', () => { appendRatified({ from: 'vessel:tanker', to: 'energy:lng', weight: 0.8, rationale: 'LNG carriers are tankers.', ratified_by: 'team@calibre.sg' }) const cfg = loadAdjacencyConfig() expect(cfg.adjacencies).toHaveLength(1) expect(cfg.adjacencies[0].weight).toBe(0.8) }) it('lookupAdjacency finds a ratified pair', () => { appendRatified({ from: 'vessel:tanker', to: 'energy:lng', weight: 0.8, rationale: '...', ratified_by: 'team@calibre.sg' }) const hit = lookupAdjacency('vessel:tanker', 'energy:lng') expect(hit?.weight).toBe(0.8) }) it('appendDenied adds to deny_list and isDenied returns true', () => { appendDenied({ from: 'vessel:container', to: 'energy:lng', rationale: 'Box ship ops does not transfer.', rejected_by: 'team@calibre.sg' }) expect(isDenied('vessel:container', 'energy:lng')).toBe(true) }) })
  • Step 3: Run tests to verify they fail
npm test tests/lib/matching/adjacency/config.test.ts

Expected: FAIL — Cannot find module '@/lib/matching/adjacency/config'

  • Step 4: Write the config module

Create calibre-app/src/lib/matching/adjacency/config.ts:

import fs from 'fs' import path from 'path' const CONFIG_PATH = path.resolve(process.cwd(), 'config/adjacency-ratified.json') export interface RatifiedAdjacency { from: string to: string weight: number rationale: string ratified_by: string ratified_at?: string } export interface DenyEntry { from: string to: string rationale: string rejected_by: string rejected_at?: string } export interface AdjacencyConfig { adjacencies: RatifiedAdjacency[] deny_list: DenyEntry[] } export function loadAdjacencyConfig(): AdjacencyConfig { if (!fs.existsSync(CONFIG_PATH)) { return { adjacencies: [], deny_list: [] } } const raw = fs.readFileSync(CONFIG_PATH, 'utf-8') return JSON.parse(raw) as AdjacencyConfig } function saveAdjacencyConfig(cfg: AdjacencyConfig): void { fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true }) fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2)) } function pairMatches(from: string, to: string, a: { from: string; to: string }): boolean { return (a.from === from && a.to === to) || (a.from === to && a.to === from) } export function lookupAdjacency(from: string, to: string): RatifiedAdjacency | null { const cfg = loadAdjacencyConfig() return cfg.adjacencies.find(a => pairMatches(from, to, a)) ?? null } export function isDenied(from: string, to: string): boolean { const cfg = loadAdjacencyConfig() return cfg.deny_list.some(d => pairMatches(from, to, d)) } export function appendRatified(entry: Omit<RatifiedAdjacency, 'ratified_at'> & { ratified_at?: string }): void { const cfg = loadAdjacencyConfig() cfg.adjacencies.push({ ...entry, ratified_at: entry.ratified_at ?? new Date().toISOString() }) saveAdjacencyConfig(cfg) } export function appendDenied(entry: Omit<DenyEntry, 'rejected_at'> & { rejected_at?: string }): void { const cfg = loadAdjacencyConfig() cfg.deny_list.push({ ...entry, rejected_at: entry.rejected_at ?? new Date().toISOString() }) saveAdjacencyConfig(cfg) }
  • Step 5: Run tests to verify they pass
npm test tests/lib/matching/adjacency/config.test.ts

Expected: PASS

  • Step 6: Commit
git add config/adjacency-ratified.json src/lib/matching/adjacency/config.ts tests/lib/matching/adjacency/config.test.ts git commit -m "feat(matching): add adjacency config loader (ratified + deny-list)"

Task 11: Adjacency — live inference Claude call

Files:

  • Create: calibre-app/src/lib/matching/adjacency/live-inference.ts

  • Create: calibre-app/tests/lib/matching/adjacency/live-inference.test.ts

  • Step 1: Write the failing test

Create calibre-app/tests/lib/matching/adjacency/live-inference.test.ts:

import { describe, it, expect } from 'vitest' import fs from 'fs' import path from 'path' import { proposeAdjacency } from '@/lib/matching/adjacency/live-inference' import type { ParsedCV } from '@/lib/cv-parser/types' import type { NormalisedJD } from '@/lib/jd-normaliser' function loadJSON<T>(rel: string): T { return JSON.parse(fs.readFileSync(path.resolve(__dirname, rel), 'utf-8')) } describe('proposeAdjacency', () => { it('proposes vessel:tanker → energy:lng for a Tanker CV against an LNG JD', async () => { const jd = loadJSON<NormalisedJD>('../../../../outputs/normalised-jds/JD for Cargo Operator (LNG).json') const cv = loadJSON<ParsedCV>('../../../../outputs/parsed-cvs/Amardeep Singh (Ship Ops, Tanker).json') const proposal = await proposeAdjacency({ jd, cv, cvTags: ['vessel:tanker'], }) expect(proposal).not.toBeNull() expect(proposal!.proposed_adjacency.to).toBe('energy:lng') expect(proposal!.weight).toBeGreaterThan(0) expect(proposal!.cv_evidence.length).toBeGreaterThan(0) }, 60000) })
  • Step 2: Run test to verify it fails
npm test tests/lib/matching/adjacency/live-inference.test.ts

Expected: FAIL — Cannot find module '@/lib/matching/adjacency/live-inference'

  • Step 3: Write the live-inference module

Create calibre-app/src/lib/matching/adjacency/live-inference.ts:

import Anthropic from '@anthropic-ai/sdk' import type { ParsedCV } from '@/lib/cv-parser/types' import type { NormalisedJD } from '@/lib/jd-normaliser' import type { AdjacentMatchEvidence } from '../types' import { loadAdjacencyConfig } from './config' const client = new Anthropic() const ADJACENCY_TOOL: Anthropic.Tool = { name: 'propose_adjacency', description: 'Propose a skills-adjacency pair between a CV tag and a JD must-have tag, with verbatim evidence.', input_schema: { type: 'object' as const, required: ['proposed_adjacency', 'weight', 'reasoning', 'cv_evidence', 'jd_evidence', 'recommend_ratification'], properties: { proposed_adjacency: { type: 'object', required: ['from', 'to'], properties: { from: { type: 'string' }, to: { type: 'string' } }, }, weight: { type: 'number', minimum: 0, maximum: 1 }, reasoning: { type: 'string' }, cv_evidence: { type: 'string' }, jd_evidence: { type: 'string' }, recommend_ratification: { type: 'boolean' }, }, }, } interface ProposeArgs { jd: NormalisedJD cv: ParsedCV cvTags: string[] } export async function proposeAdjacency({ jd, cv, cvTags }: ProposeArgs): Promise<AdjacentMatchEvidence | null> { const ratified = loadAdjacencyConfig().adjacencies const cvEmployment = cv.employment_history.map(e => ({ title: e.raw_title.value, employer: e.employer.value, description: e.description_summary.value, })) const response = await client.messages.create({ model: 'claude-sonnet-4-6', max_tokens: 1024, tools: [ADJACENCY_TOOL], tool_choice: { type: 'tool', name: 'propose_adjacency' }, messages: [ { role: 'user', content: `You are evaluating whether a candidate's experience transfers to a Job Description's required tags. The candidate's tags do NOT directly intersect the JD's must-have tags. Your job is to propose ONE adjacency pair if a transferable skill exists, with verbatim evidence from BOTH the CV and the JD. JD must-have tags: ${JSON.stringify(jd.must_have_tags)} JD normalised title: ${jd.normalised_title} CV tags: ${JSON.stringify(cvTags)} CV employment history (titles + summaries): ${JSON.stringify(cvEmployment, null, 2)} Existing ratified adjacencies (use these as the pattern for what counts as valid transfer): ${JSON.stringify(ratified, null, 2)} Rules: - Pick exactly ONE proposed_adjacency: from = a CV tag, to = a JD must-have tag. - weight = 0.0 (no transfer) to 1.0 (perfect transfer). Be conservative — 0.5-0.8 is typical for genuine adjacency. - cv_evidence MUST be a verbatim quote from the CV employment history above. - jd_evidence MUST be the JD must-have tag string itself. - recommend_ratification = true ONLY if the adjacency is clear and would generalise to other candidates.`, }, ], }) const toolUse = response.content.find(b => b.type === 'tool_use') if (!toolUse || toolUse.type !== 'tool_use') return null const out = toolUse.input as Omit<AdjacentMatchEvidence, 'status'> return { ...out, status: 'live_inference' } }
  • Step 4: Run test to verify it passes
npm test tests/lib/matching/adjacency/live-inference.test.ts

Expected: PASS (requires outputs/normalised-jds/*.json from Task 7 and outputs/parsed-cvs/*.json from CV parser)

  • Step 5: Commit
git add src/lib/matching/adjacency/live-inference.ts tests/lib/matching/adjacency/live-inference.test.ts git commit -m "feat(matching): add live-inference adjacency proposal via Claude"

Task 12: Adjacency resolver — composition + boost calc

Files:

  • Create: calibre-app/src/lib/matching/adjacency/resolver.ts

  • Create: calibre-app/tests/lib/matching/adjacency/resolver.test.ts

  • Step 1: Write the failing tests

Create calibre-app/tests/lib/matching/adjacency/resolver.test.ts:

import { describe, it, expect, beforeEach } from 'vitest' import fs from 'fs' import path from 'path' import { resolveAdjacency, applyAdjacencyBoost } from '@/lib/matching/adjacency/resolver' import type { NormalisedJD } from '@/lib/jd-normaliser' import type { NormalisedCV } from '@/lib/cv-normaliser' import type { ParsedCV } from '@/lib/cv-parser/types' const CONFIG_PATH = path.resolve(__dirname, '../../../../config/adjacency-ratified.json') const jd: NormalisedJD = { sub_function: 'cargo-ops', sub_function_display: '...', tags: ['energy:lng'], must_have_tags: ['energy:lng'], nice_to_have_tags: [], normalised_title: 'LNG Cargo Ops', seniority_tier: 'Mid', min_years: 3, confidence: 0.9, } const cvTanker: NormalisedCV = { sub_function: 'ship-ops', sub_function_display: '...', tags: ['vessel:tanker'], normalised_title: 'Tanker Ops', seniority_tier: 'Mid', search_queries: [], confidence: 0.9, } const minimalParsedCv: Partial<ParsedCV> = { employment_history: [{ raw_title: { value: 'Tanker Operator', confidence: 0.9 }, employer: { value: 'BW Maritime', confidence: 0.9 }, start_date: { value: '2020-01', confidence: 0.9 }, end_date: { value: null, confidence: null }, description_summary: { value: 'Handled chemical tankers across SE Asia routes.', confidence: 0.9 }, }], } describe('resolveAdjacency', () => { beforeEach(() => { fs.writeFileSync(CONFIG_PATH, JSON.stringify({ adjacencies: [], deny_list: [] }, null, 2)) }) it('returns null when CV tags fully intersect JD must-haves', async () => { const cvLng: NormalisedCV = { ...cvTanker, tags: ['energy:lng'], sub_function: 'cargo-ops' } const r = await resolveAdjacency(jd, cvLng, minimalParsedCv as ParsedCV) expect(r).toBeNull() }) it('uses ratified config when a matching entry exists', async () => { fs.writeFileSync(CONFIG_PATH, JSON.stringify({ adjacencies: [{ from: 'vessel:tanker', to: 'energy:lng', weight: 0.8, rationale: 'LNG carriers are tankers.', ratified_by: 'team@calibre.sg' }], deny_list: [], }, null, 2)) const r = await resolveAdjacency(jd, cvTanker, minimalParsedCv as ParsedCV) expect(r?.status).toBe('ratified_config') expect(r?.weight).toBe(0.8) }) it('returns null when pair is in deny_list (no live inference)', async () => { fs.writeFileSync(CONFIG_PATH, JSON.stringify({ adjacencies: [], deny_list: [{ from: 'vessel:tanker', to: 'energy:lng', rationale: 'rejected', rejected_by: 'team@calibre.sg' }], }, null, 2)) const r = await resolveAdjacency(jd, cvTanker, minimalParsedCv as ParsedCV) expect(r).toBeNull() }) }) describe('applyAdjacencyBoost', () => { it('boosts tag_overlap by weight when adjacency hits a must-have', () => { const original = { tag_overlap: 0 } const boosted = applyAdjacencyBoost(original, jd, { proposed_adjacency: { from: 'vessel:tanker', to: 'energy:lng' }, weight: 0.8, reasoning: '...', cv_evidence: '...', jd_evidence: 'energy:lng', recommend_ratification: true, status: 'ratified_config', }) expect(boosted.tag_overlap).toBe(4) // 0.8 * 5 }) it('does not boost when adjacency target is not in must_have_tags', () => { const r = applyAdjacencyBoost({ tag_overlap: 1 }, jd, { proposed_adjacency: { from: 'vessel:tanker', to: 'energy:oil' }, weight: 0.8, reasoning: '...', cv_evidence: '...', jd_evidence: 'energy:oil', recommend_ratification: true, status: 'ratified_config', }) expect(r.tag_overlap).toBe(1) }) })
  • Step 2: Run tests to verify they fail
npm test tests/lib/matching/adjacency/resolver.test.ts

Expected: FAIL — Cannot find module '@/lib/matching/adjacency/resolver'

  • Step 3: Write the resolver

Create calibre-app/src/lib/matching/adjacency/resolver.ts:

import type { NormalisedJD } from '@/lib/jd-normaliser' import type { NormalisedCV } from '@/lib/cv-normaliser' import type { ParsedCV } from '@/lib/cv-parser/types' import type { AdjacentMatchEvidence } from '../types' import { lookupAdjacency, isDenied } from './config' import { proposeAdjacency } from './live-inference' export async function resolveAdjacency( jd: NormalisedJD, cv: NormalisedCV, parsedCv: ParsedCV, ): Promise<AdjacentMatchEvidence | null> { const cvTagSet = new Set(cv.tags) const intersects = jd.must_have_tags.some(t => cvTagSet.has(t)) if (intersects) return null for (const jdTag of jd.must_have_tags) { for (const cvTag of cv.tags) { if (isDenied(cvTag, jdTag)) continue const hit = lookupAdjacency(cvTag, jdTag) if (hit) { return { proposed_adjacency: { from: cvTag, to: jdTag }, weight: hit.weight, reasoning: hit.rationale, cv_evidence: parsedCv.employment_history[0]?.description_summary.value ?? '', jd_evidence: jdTag, recommend_ratification: false, status: 'ratified_config', } } } } const allowedCvTags = cv.tags.filter(cvTag => jd.must_have_tags.every(jdTag => !isDenied(cvTag, jdTag)) ) if (allowedCvTags.length === 0) return null return proposeAdjacency({ jd, cv: parsedCv, cvTags: allowedCvTags }) } export function applyAdjacencyBoost( scores: { tag_overlap: number }, jd: NormalisedJD, evidence: AdjacentMatchEvidence, ): { tag_overlap: number } { if (!jd.must_have_tags.includes(evidence.proposed_adjacency.to)) { return scores } const contribution = (5 * evidence.weight) / Math.max(jd.must_have_tags.length, 1) return { tag_overlap: Math.round((scores.tag_overlap + contribution) * 100) / 100, } }
  • Step 4: Run tests to verify they pass
npm test tests/lib/matching/adjacency/resolver.test.ts

Expected: PASS

  • Step 5: Commit
git add src/lib/matching/adjacency/resolver.ts tests/lib/matching/adjacency/resolver.test.ts git commit -m "feat(matching): add adjacency resolver (config → deny-list → live inference) + boost calc"

Task 13: Rubric weights config

Files:

  • Create: calibre-app/config/rubric-weights.json

  • Create: calibre-app/src/lib/matching/weights.ts

  • Create: calibre-app/tests/lib/matching/weights.test.ts

  • Step 1: Seed the weights config

Create calibre-app/config/rubric-weights.json:

{ "cargo-ops": { "experience_alignment": 0.30, "technical_knowledge": 0.25, "tools_and_systems": 0.15, "education_certifications": 0.10, "skills_adjacency": 0.20 }, "ship-ops": { "experience_alignment": 0.30, "technical_knowledge": 0.25, "tools_and_systems": 0.15, "education_certifications": 0.10, "skills_adjacency": 0.20 }, "risk-analytics": { "experience_alignment": 0.25, "technical_knowledge": 0.30, "tools_and_systems": 0.15, "education_certifications": 0.20, "skills_adjacency": 0.10 }, "finance-fo": { "experience_alignment": 0.25, "technical_knowledge": 0.20, "tools_and_systems": 0.10, "education_certifications": 0.30, "skills_adjacency": 0.15 }, "ops-back-office": { "experience_alignment": 0.30, "technical_knowledge": 0.25, "tools_and_systems": 0.15, "education_certifications": 0.20, "skills_adjacency": 0.10 } }
  • Step 2: Write the failing test

Create calibre-app/tests/lib/matching/weights.test.ts:

import { describe, it, expect } from 'vitest' import { loadWeights, weightsFor } from '@/lib/matching/weights' describe('weights', () => { it('loads weights for all five sub-functions', () => { const w = loadWeights() expect(Object.keys(w).sort()).toEqual([ 'cargo-ops', 'finance-fo', 'ops-back-office', 'risk-analytics', 'ship-ops', ]) }) it('weightsFor returns 5 dimension weights summing to 1.0', () => { const w = weightsFor('cargo-ops') const total = Object.values(w).reduce((a, b) => a + b, 0) expect(total).toBeCloseTo(1.0, 5) }) })
  • Step 3: Run test to verify it fails
npm test tests/lib/matching/weights.test.ts

Expected: FAIL — Cannot find module '@/lib/matching/weights'

  • Step 4: Write the loader

Create calibre-app/src/lib/matching/weights.ts:

import fs from 'fs' import path from 'path' import type { SubFunction } from '@/lib/jd-normaliser' export interface RubricWeights { experience_alignment: number technical_knowledge: number tools_and_systems: number education_certifications: number skills_adjacency: number } const WEIGHTS_PATH = path.resolve(process.cwd(), 'config/rubric-weights.json') export function loadWeights(): Record<SubFunction, RubricWeights> { const raw = fs.readFileSync(WEIGHTS_PATH, 'utf-8') return JSON.parse(raw) as Record<SubFunction, RubricWeights> } export function weightsFor(sf: SubFunction): RubricWeights { return loadWeights()[sf] }
  • Step 5: Run test to verify it passes
npm test tests/lib/matching/weights.test.ts

Expected: PASS

  • Step 6: Commit
git add config/rubric-weights.json src/lib/matching/weights.ts tests/lib/matching/weights.test.ts git commit -m "feat(matching): add per-sub-function rubric weights config + loader"

Task 14: Layer 2 — Deep Assessment (Claude, evidence-grounded)

Files:

  • Create: calibre-app/src/lib/matching/layer2-deep-assessment.ts

  • Create: calibre-app/tests/lib/matching/layer2-deep-assessment.test.ts

  • Step 1: Write the failing tests

Create calibre-app/tests/lib/matching/layer2-deep-assessment.test.ts:

import { describe, it, expect } from 'vitest' import fs from 'fs' import path from 'path' import { runLayer2 } from '@/lib/matching/layer2-deep-assessment' import type { NormalisedJD } from '@/lib/jd-normaliser' import type { ParsedCV } from '@/lib/cv-parser/types' function loadJSON<T>(rel: string): T { return JSON.parse(fs.readFileSync(path.resolve(__dirname, rel), 'utf-8')) } describe('runLayer2', () => { it('produces a Layer2Result with evidence-backed dimension scores', async () => { const jd = loadJSON<NormalisedJD>('../../../outputs/normalised-jds/JD for Cargo Operator (LNG).json') const cv = loadJSON<ParsedCV>('../../../outputs/parsed-cvs/Aarul Selven (Cargo Ops, Fuel Oil).json') const result = await runLayer2({ jd, parsedCv: cv, jd_id: 'JD-LNG', cv_id: 'Aarul', adjacency: null, }) expect(result.executive_summary.length).toBeGreaterThan(20) expect(result.dimensions.experience_alignment.evidence.length).toBeGreaterThan(0) expect(result.dimensions.technical_knowledge.score).toBeGreaterThanOrEqual(1) expect(result.composite_l2_score).toBeGreaterThanOrEqual(1) expect(result.composite_l2_score).toBeLessThanOrEqual(5) }, 120000) it('forces low_evidence dimensions to score 1', async () => { const jd = loadJSON<NormalisedJD>('../../../outputs/normalised-jds/JD for Cargo Operator (LNG).json') // Use a CV from a totally different vertical to force low evidence on technical knowledge const cv = loadJSON<ParsedCV>('../../../outputs/parsed-cvs/Adrian Tan (Risk, Oil).json') const result = await runLayer2({ jd, parsedCv: cv, jd_id: 'JD-LNG', cv_id: 'Adrian', adjacency: null, }) // At least one dimension should be flagged low_evidence OR score 1 const dims = [ result.dimensions.experience_alignment, result.dimensions.technical_knowledge, result.dimensions.tools_and_systems, ] expect(dims.some(d => d.low_evidence || d.score === 1)).toBe(true) }, 120000) })
  • Step 2: Run tests to verify they fail
npm test tests/lib/matching/layer2-deep-assessment.test.ts

Expected: FAIL — Cannot find module '@/lib/matching/layer2-deep-assessment'

  • Step 3: Write the Layer 2 module

Create calibre-app/src/lib/matching/layer2-deep-assessment.ts:

import Anthropic from '@anthropic-ai/sdk' import type { NormalisedJD } from '@/lib/jd-normaliser' import type { ParsedCV } from '@/lib/cv-parser/types' import type { Layer2Result, AdjacentMatchEvidence, Layer2DimensionScore } from './types' import { weightsFor } from './weights' const client = new Anthropic() const dimensionSchema = { type: 'object', required: ['score', 'evidence', 'notes'], properties: { score: { type: 'integer', minimum: 1, maximum: 5 }, evidence: { type: 'array', items: { type: 'string' } }, notes: { type: 'string' }, }, } const LAYER2_TOOL: Anthropic.Tool = { name: 'deep_assess', description: 'Score a candidate against a JD across rubric dimensions, with verbatim CV evidence for every dimension score.', input_schema: { type: 'object' as const, required: ['executive_summary', 'dimensions', 'confidence'], properties: { executive_summary: { type: 'string' }, dimensions: { type: 'object', required: ['experience_alignment', 'technical_knowledge', 'tools_and_systems', 'education_certifications', 'red_flags', 'strengths'], properties: { experience_alignment: dimensionSchema, skills_adjacency: { ...dimensionSchema, type: ['object', 'null'] }, technical_knowledge: dimensionSchema, tools_and_systems: dimensionSchema, education_certifications: dimensionSchema, red_flags: { type: 'object', required: ['items'], properties: { items: { type: 'array', items: { type: 'object', required: ['signal', 'evidence'], properties: { signal: { type: 'string' }, evidence: { type: 'string' } }, }, }, }, }, strengths: { type: 'object', required: ['items'], properties: { items: { type: 'array', items: { type: 'object', required: ['signal', 'evidence'], properties: { signal: { type: 'string' }, evidence: { type: 'string' } }, }, }, }, }, }, }, confidence: { type: 'number' }, }, }, } interface RunArgs { jd: NormalisedJD parsedCv: ParsedCV jd_id: string cv_id: string adjacency: AdjacentMatchEvidence | null } function enforceLowEvidence(d: Layer2DimensionScore): Layer2DimensionScore { if (!d.evidence || d.evidence.length === 0) { return { ...d, score: 1, low_evidence: true } } return d } export async function runLayer2({ jd, parsedCv, jd_id, cv_id, adjacency }: RunArgs): Promise<Layer2Result> { const adjacencyBlock = adjacency ? `\nADJACENCY EVIDENCE (treat this candidate's ${adjacency.proposed_adjacency.from} experience as transferable to ${adjacency.proposed_adjacency.to}, weight ${adjacency.weight}):\nReasoning: ${adjacency.reasoning}\nCV quote: "${adjacency.cv_evidence}"\nScore skills_adjacency dimension. Otherwise pass null for it.\n` : '' const response = await client.messages.create({ model: 'claude-sonnet-4-6', max_tokens: 4096, tools: [LAYER2_TOOL], tool_choice: { type: 'tool', name: 'deep_assess' }, messages: [ { role: 'user', content: `You are a specialist recruiter performing a deep candidate assessment. Score the candidate on each rubric dimension from 1 (no fit) to 5 (excellent fit). CRITICAL RULES: - EVERY dimension score MUST cite at least one verbatim quote from the CV employment history or signals. Quote exactly — no paraphrasing. - If you cannot find a verbatim quote supporting the dimension, return an empty evidence array. The system will force that dimension to score 1. - red_flags include: career gaps > 6 months, short tenures, role downgrades, stale CV, geographic mismatches, missing certifications the JD requires. - strengths include: anything that exceeds JD requirements. ${adjacencyBlock} JD (normalised): ${JSON.stringify(jd, null, 2)} PARSED CV (with full employment history): ${JSON.stringify(parsedCv, null, 2)}`, }, ], }) const toolUse = response.content.find(b => b.type === 'tool_use') if (!toolUse || toolUse.type !== 'tool_use') { throw new Error('Claude did not return a tool_use block for Layer 2') } const out = toolUse.input as Omit<Layer2Result, 'jd_id' | 'cv_id' | 'composite_l2_score'> const dimensions = { experience_alignment: enforceLowEvidence(out.dimensions.experience_alignment), skills_adjacency: out.dimensions.skills_adjacency ? enforceLowEvidence(out.dimensions.skills_adjacency) : null, technical_knowledge: enforceLowEvidence(out.dimensions.technical_knowledge), tools_and_systems: enforceLowEvidence(out.dimensions.tools_and_systems), education_certifications: enforceLowEvidence(out.dimensions.education_certifications), red_flags: out.dimensions.red_flags, strengths: out.dimensions.strengths, } const w = weightsFor(jd.sub_function) const adjacencyTerm = dimensions.skills_adjacency ? dimensions.skills_adjacency.score * w.skills_adjacency : 0 const composite = dimensions.experience_alignment.score * w.experience_alignment + dimensions.technical_knowledge.score * w.technical_knowledge + dimensions.tools_and_systems.score * w.tools_and_systems + dimensions.education_certifications.score * w.education_certifications + adjacencyTerm return { jd_id, cv_id, executive_summary: out.executive_summary, dimensions, composite_l2_score: Math.round(composite * 100) / 100, confidence: out.confidence, } }
  • Step 4: Run tests to verify they pass
npm test tests/lib/matching/layer2-deep-assessment.test.ts

Expected: PASS

  • Step 5: Commit
git add src/lib/matching/layer2-deep-assessment.ts tests/lib/matching/layer2-deep-assessment.test.ts git commit -m "feat(matching): add Layer 2 evidence-grounded deep assessment (Claude tool_use)"

Task 15: Layer 3 — Verdict synthesis

Files:

  • Create: calibre-app/src/lib/matching/layer3-verdict.ts

  • Create: calibre-app/tests/lib/matching/layer3-verdict.test.ts

  • Step 1: Write the failing tests

Create calibre-app/tests/lib/matching/layer3-verdict.test.ts:

import { describe, it, expect } from 'vitest' import { computeVerdict } from '@/lib/matching/layer3-verdict' import type { Layer1Result, Layer2Result } from '@/lib/matching/types' function l1(passes: boolean, composite: number, drop?: Layer1Result['drop_reason']): Layer1Result { return { jd_id: 'jd', cv_id: 'cv', scores: { sub_function: 5, tag_overlap: 5, seniority: 5, years: 5, red_flag_penalty: 0 }, composite, passes, drop_reason: drop, } } function l2(composite: number, redFlagSeverity = 0): Layer2Result { return { jd_id: 'jd', cv_id: 'cv', executive_summary: 'x', dimensions: { experience_alignment: { score: 4, evidence: ['x'], notes: '' }, skills_adjacency: null, technical_knowledge: { score: 4, evidence: ['x'], notes: '' }, tools_and_systems: { score: 4, evidence: ['x'], notes: '' }, education_certifications: { score: 4, evidence: ['x'], notes: '' }, red_flags: { items: redFlagSeverity >= 4 ? [{ signal: 'severe', evidence: 'x' }] : [] }, strengths: { items: [] }, }, composite_l2_score: composite, confidence: 0.9, } } describe('computeVerdict', () => { it('returns Strong no when Layer 1 dropped', () => { const v = computeVerdict(l1(false, 1, 'composite_below_4'), null) expect(v.band).toBe('Strong no') }) it('returns Strong yes for L1>=4.5 + L2>=4.2 with no severe red flags', () => { const v = computeVerdict(l1(true, 4.7), l2(4.4)) expect(v.band).toBe('Strong yes') }) it('returns Soft yes for L1>=4 + L2>=3.5', () => { const v = computeVerdict(l1(true, 4.2), l2(3.8)) expect(v.band).toBe('Soft yes') }) it('returns Soft no for L1>=4 + L2<3.5', () => { const v = computeVerdict(l1(true, 4.1), l2(3.0)) expect(v.band).toBe('Soft no') }) it('returns Strong no when L2 composite < 2.5', () => { const v = computeVerdict(l1(true, 4.5), l2(2.0)) expect(v.band).toBe('Strong no') }) it('severe red flag downgrades Strong yes to Soft yes', () => { const v = computeVerdict(l1(true, 4.7), l2(4.4, 5)) expect(v.band).toBe('Soft yes') }) })
  • Step 2: Run tests to verify they fail
npm test tests/lib/matching/layer3-verdict.test.ts

Expected: FAIL — Cannot find module '@/lib/matching/layer3-verdict'

  • Step 3: Write the verdict module

Create calibre-app/src/lib/matching/layer3-verdict.ts:

import type { Layer1Result, Layer2Result, Layer3Verdict } from './types' function hasSevereRedFlag(l2: Layer2Result): boolean { // Treat any red_flag whose signal contains keywords like "severe", "missing required", // or any role downgrade as severity >= 4. Currently a simple heuristic. const signals = l2.dimensions.red_flags.items.map(i => i.signal.toLowerCase()) return signals.some(s => s.includes('severe') || s.includes('missing required') || s.includes('role downgrade') || s.includes('stale') ) } export function computeVerdict(l1: Layer1Result, l2: Layer2Result | null): Layer3Verdict { if (!l1.passes) { return { band: 'Strong no', rationale: `Layer 1 drop: ${l1.drop_reason ?? 'unknown'}` } } if (!l2) { return { band: 'Strong no', rationale: 'Layer 2 not run' } } if (l2.composite_l2_score < 2.5) { return { band: 'Strong no', rationale: `Layer 2 composite ${l2.composite_l2_score} < 2.5` } } const severeRedFlag = hasSevereRedFlag(l2) if (l1.composite >= 4.5 && l2.composite_l2_score >= 4.2 && !severeRedFlag) { return { band: 'Strong yes', rationale: `L1 ${l1.composite}, L2 ${l2.composite_l2_score}, no severe red flags` } } if (l1.composite >= 4.0 && l2.composite_l2_score >= 3.5) { return { band: 'Soft yes', rationale: `L1 ${l1.composite}, L2 ${l2.composite_l2_score}${severeRedFlag ? ' (severe red flag — downgraded)' : ''}` } } return { band: 'Soft no', rationale: `L1 ${l1.composite}, L2 ${l2.composite_l2_score}` } }
  • Step 4: Run tests to verify they pass
npm test tests/lib/matching/layer3-verdict.test.ts

Expected: PASS

  • Step 5: Commit
git add src/lib/matching/layer3-verdict.ts tests/lib/matching/layer3-verdict.test.ts git commit -m "feat(matching): add Layer 3 deterministic 4-band verdict synthesis"

Task 16: Public match() API

Files:

  • Create: calibre-app/src/lib/matching/index.ts

  • Create: calibre-app/tests/lib/matching/index.test.ts

  • Step 1: Write the failing test

Create calibre-app/tests/lib/matching/index.test.ts:

import { describe, it, expect } from 'vitest' import fs from 'fs' import path from 'path' import { match } from '@/lib/matching' import type { NormalisedJD } from '@/lib/jd-normaliser' import type { NormalisedCV } from '@/lib/cv-normaliser' import type { ParsedCV } from '@/lib/cv-parser/types' function loadJSON<T>(rel: string): T { return JSON.parse(fs.readFileSync(path.resolve(__dirname, rel), 'utf-8')) } describe('match (public API)', () => { it('produces a MatchRecord with all layers populated for a good match', async () => { const jd = loadJSON<NormalisedJD>('../../../outputs/normalised-jds/JD for Cargo Operator (LNG).json') const cv = loadJSON<NormalisedCV>('../../../outputs/normalised-cvs/Aarul Selven (Cargo Ops, Fuel Oil).json') const parsedCv = loadJSON<ParsedCV>('../../../outputs/parsed-cvs/Aarul Selven (Cargo Ops, Fuel Oil).json') const record = await match({ jd, cv, parsedCv, jd_id: 'JD-LNG', cv_id: 'Aarul', }) expect(record.layer1).toBeTruthy() expect(['Strong yes', 'Soft yes', 'Soft no', 'Strong no']).toContain(record.layer3.band) }, 120000) })
  • Step 2: Run test to verify it fails
npm test tests/lib/matching/index.test.ts

Expected: FAIL — Cannot find module '@/lib/matching'

  • Step 3: Write the public API

Create calibre-app/src/lib/matching/index.ts:

import type { NormalisedJD } from '@/lib/jd-normaliser' import type { NormalisedCV } from '@/lib/cv-normaliser' import type { ParsedCV } from '@/lib/cv-parser/types' import type { MatchRecord } from './types' import { computeLayer1 } from './layer1-hard-fit' import { resolveAdjacency, applyAdjacencyBoost } from './adjacency/resolver' import { runLayer2 } from './layer2-deep-assessment' import { computeVerdict } from './layer3-verdict' export type { MatchRecord, Layer1Result, Layer2Result, Layer3Verdict, AdjacentMatchEvidence } from './types' interface MatchArgs { jd: NormalisedJD cv: NormalisedCV parsedCv: ParsedCV jd_id: string cv_id: string } export async function match({ jd, cv, parsedCv, jd_id, cv_id }: MatchArgs): Promise<MatchRecord> { let layer1 = computeLayer1(jd, cv, parsedCv, jd_id, cv_id) if (!layer1.passes && layer1.drop_reason !== 'sub_function_mismatch') { const adjacency = await resolveAdjacency(jd, cv, parsedCv) if (adjacency) { const boosted = applyAdjacencyBoost({ tag_overlap: layer1.scores.tag_overlap }, jd, adjacency) const scores = { ...layer1.scores, tag_overlap: boosted.tag_overlap } const composite = Math.round( ((scores.sub_function + scores.tag_overlap + scores.seniority + scores.years) / 4 + scores.red_flag_penalty) * 100, ) / 100 layer1 = { ...layer1, scores, composite, passes: composite >= 4, drop_reason: composite >= 4 ? undefined : 'composite_below_4', adjacent_match: adjacency, } } } else if (layer1.passes) { const adjacency = await resolveAdjacency(jd, cv, parsedCv) if (adjacency) layer1 = { ...layer1, adjacent_match: adjacency } } let layer2: Awaited<ReturnType<typeof runLayer2>> | null = null if (layer1.passes) { layer2 = await runLayer2({ jd, parsedCv, jd_id, cv_id, adjacency: layer1.adjacent_match ?? null, }) } const layer3 = computeVerdict(layer1, layer2) return { jd_id, cv_id, jd_snapshot: jd, layer1, layer2, layer3, generated_at: new Date().toISOString(), } }
  • Step 4: Run test to verify it passes
npm test tests/lib/matching/index.test.ts

Expected: PASS (requires outputs/normalised-cvs/*.json from the CV normaliser implementation — precondition)

  • Step 5: Commit
git add src/lib/matching/index.ts tests/lib/matching/index.test.ts git commit -m "feat(matching): add match() public API composing L1 + adjacency + L2 + L3"

Task 17: Batch match script

Files:

  • Create: calibre-app/scripts/batch-match.ts

  • Step 1: Write the script

Create calibre-app/scripts/batch-match.ts:

import fs from 'fs' import path from 'path' import { match } from '../src/lib/matching' import type { NormalisedJD } from '../src/lib/jd-normaliser' import type { NormalisedCV } from '../src/lib/cv-normaliser' import type { ParsedCV } from '../src/lib/cv-parser/types' const NORM_JDS = path.resolve(__dirname, '../outputs/normalised-jds') const NORM_CVS = path.resolve(__dirname, '../outputs/normalised-cvs') const PARSED_CVS = path.resolve(__dirname, '../outputs/parsed-cvs') const OUT = path.resolve(__dirname, '../outputs/match-records') async function main() { fs.mkdirSync(OUT, { recursive: true }) const jdFiles = fs.readdirSync(NORM_JDS).filter(f => f.endsWith('.json')) const cvFiles = fs.readdirSync(NORM_CVS).filter(f => f.endsWith('.json')) console.log(`Matching ${jdFiles.length} JDs × ${cvFiles.length} CVs = ${jdFiles.length * cvFiles.length} pairs`) const verdictCounts: Record<string, number> = { 'Strong yes': 0, 'Soft yes': 0, 'Soft no': 0, 'Strong no': 0, } let pairsRun = 0 let pairsDroppedAtL1 = 0 let pairsRunL2 = 0 for (const jdFile of jdFiles) { const jdId = path.basename(jdFile, '.json') const jd = JSON.parse(fs.readFileSync(path.join(NORM_JDS, jdFile), 'utf-8')) as NormalisedJD for (const cvFile of cvFiles) { const cvId = path.basename(cvFile, '.json') const cv = JSON.parse(fs.readFileSync(path.join(NORM_CVS, cvFile), 'utf-8')) as NormalisedCV const parsedCv = JSON.parse(fs.readFileSync(path.join(PARSED_CVS, cvFile), 'utf-8')) as ParsedCV pairsRun++ const outFile = path.join(OUT, `${jdId}__${cvId}.json`) process.stdout.write(` [${pairsRun}] ${jdId} × ${cvId} ... `) try { const record = await match({ jd, cv, parsedCv, jd_id: jdId, cv_id: cvId }) fs.writeFileSync(outFile, JSON.stringify(record, null, 2)) verdictCounts[record.layer3.band]++ if (!record.layer1.passes) pairsDroppedAtL1++ if (record.layer2) pairsRunL2++ console.log(record.layer3.band) } catch (err) { console.log(`ERROR: ${err instanceof Error ? err.message : err}`) } await new Promise(r => setTimeout(r, 300)) } } console.log('\n─────────────────────────────') console.log(`Pairs run: ${pairsRun}`) console.log(`Dropped at Layer 1: ${pairsDroppedAtL1}`) console.log(`Layer 2 evaluated: ${pairsRunL2}`) console.log('Verdict distribution:') for (const [band, n] of Object.entries(verdictCounts)) { console.log(` ${band}: ${n}`) } } main().catch(err => { console.error(err); process.exit(1) })
  • Step 2: Add npm script

Edit calibre-app/package.json — add to "scripts":

"batch-match": "tsx scripts/batch-match.ts"
  • Step 3: Add output ignore
echo "outputs/match-records/" >> .gitignore
  • Step 4: Dry-run on a 5-JD × 10-CV subset first

Temporarily limit the script by editing jdFiles and cvFiles with .slice(0, 5) and .slice(0, 10). Run:

npm run batch-match

Expected: 50 pairs run, output JSONs land in outputs/match-records/. Sanity-check verdict distribution is non-degenerate (not all Strong no).

Revert the .slice after the dry-run.

  • Step 5: Commit
git add scripts/batch-match.ts package.json .gitignore git commit -m "feat(matching): add batch-match script across all (JD, CV) pairs"

Task 18: Adjacency review CLI

Files:

  • Create: calibre-app/scripts/review-adjacency-proposals.ts

  • Step 1: Write the script

Create calibre-app/scripts/review-adjacency-proposals.ts:

import fs from 'fs' import path from 'path' import readline from 'readline' import { appendRatified, appendDenied, lookupAdjacency, isDenied } from '../src/lib/matching/adjacency/config' import type { MatchRecord } from '../src/lib/matching' const MATCH_DIR = path.resolve(__dirname, '../outputs/match-records') interface ProposalRow { pair: { from: string; to: string } weight: number reasoning: string cv_evidence: string jd_evidence: string seen_in: string[] } function collectProposals(): ProposalRow[] { const grouped = new Map<string, ProposalRow>() for (const file of fs.readdirSync(MATCH_DIR).filter(f => f.endsWith('.json'))) { const record: MatchRecord = JSON.parse(fs.readFileSync(path.join(MATCH_DIR, file), 'utf-8')) const ev = record.layer1.adjacent_match if (!ev || ev.status !== 'live_inference') continue const key = `${ev.proposed_adjacency.from}→${ev.proposed_adjacency.to}` if (lookupAdjacency(ev.proposed_adjacency.from, ev.proposed_adjacency.to)) continue if (isDenied(ev.proposed_adjacency.from, ev.proposed_adjacency.to)) continue if (!grouped.has(key)) { grouped.set(key, { pair: ev.proposed_adjacency, weight: ev.weight, reasoning: ev.reasoning, cv_evidence: ev.cv_evidence, jd_evidence: ev.jd_evidence, seen_in: [], }) } grouped.get(key)!.seen_in.push(file) } return Array.from(grouped.values()).sort((a, b) => b.seen_in.length - a.seen_in.length) } async function prompt(rl: readline.Interface, q: string): Promise<string> { return new Promise(res => rl.question(q, res)) } async function main() { const proposals = collectProposals() console.log(`${proposals.length} novel adjacency proposals to review.\n`) const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) for (const p of proposals) { console.log('─────────────────────────────') console.log(`Pair: ${p.pair.from} → ${p.pair.to}`) console.log(`Weight: ${p.weight}`) console.log(`Reasoning: ${p.reasoning}`) console.log(`CV evidence: "${p.cv_evidence}"`) console.log(`JD evidence: "${p.jd_evidence}"`) console.log(`Seen in: ${p.seen_in.length} match pair(s)`) const ans = (await prompt(rl, 'Approve (a), Reject (r), Skip (s)? ')).trim().toLowerCase() if (ans === 'a') { const who = (await prompt(rl, 'Ratified by (email): ')).trim() appendRatified({ from: p.pair.from, to: p.pair.to, weight: p.weight, rationale: p.reasoning, ratified_by: who }) console.log(' → Ratified.\n') } else if (ans === 'r') { const who = (await prompt(rl, 'Rejected by (email): ')).trim() const why = (await prompt(rl, 'Reason: ')).trim() appendDenied({ from: p.pair.from, to: p.pair.to, rationale: why, rejected_by: who }) console.log(' → Denied.\n') } else { console.log(' → Skipped.\n') } } rl.close() } main().catch(err => { console.error(err); process.exit(1) })
  • Step 2: Add npm script

Edit calibre-app/package.json — add to "scripts":

"review-adjacency": "tsx scripts/review-adjacency-proposals.ts"
  • Step 3: Commit
git add scripts/review-adjacency-proposals.ts package.json git commit -m "feat(matching): add CLI for Calibre to review/ratify adjacency proposals"

Task 19: Bootstrap adjacency pass + ground-truth validation

This is a process task — no new code. It is the system-bootstrap activity called out in the design spec under “Validation Strategy”.

  • Step 1: Cold-start full batch match

With config/adjacency-ratified.json still empty:

npm run batch-match

Note: this will fire proposeAdjacency for every (JD, CV) pair where Layer 1 falls short due to tag mismatch — potentially hundreds of Claude calls. Budget the run.

  • Step 2: Review proposals with Calibre
npm run review-adjacency

Calibre approves or rejects each unique proposed pair. Ratified pairs populate config/adjacency-ratified.json. Rejected pairs populate the deny-list.

  • Step 3: Re-run batch match with seeded config
npm run batch-match

Now most pairs hit the ratified config — live inference fires only for novel pairs. Verdict distribution should shift: more Soft yes / Strong yes than the cold-start pass.

  • Step 4: Calibre rates 20 ground-truth pairs

Calibre picks 20 (JD, CV) pairs spread across all five sub-functions. For each, he writes his expected verdict (Strong yes / Soft yes / Soft no / Strong no) in a checked-in spreadsheet at outputs/ground-truth-20.csv:

jd_id,cv_id,calibre_verdict,calibre_notes JD for Cargo Operator (LNG),Aarul Selven (Cargo Ops, Fuel Oil),Soft yes,Strong oil ops background, LNG learnable ...
  • Step 5: Compare system verdicts to ground truth
node -e " const fs = require('fs'); const path = require('path'); const csv = fs.readFileSync('outputs/ground-truth-20.csv', 'utf-8').trim().split('\n').slice(1); let agree = 0; for (const row of csv) { const [jdId, cvId, expected] = row.split(','); const file = path.join('outputs/match-records', \`\${jdId}__\${cvId}.json\`); if (!fs.existsSync(file)) { console.log('MISSING:', jdId, cvId); continue; } const record = JSON.parse(fs.readFileSync(file, 'utf-8')); const actual = record.layer3.band; const ok = actual === expected; if (ok) agree++; console.log(\`\${ok ? '✓' : '✗'} \${jdId} × \${cvId}: expected \${expected}, got \${actual}\`); } console.log(\`Agreement: \${agree}/20 = \${(agree*5)}%\`); "

Target: ≥ 16/20 = 80% agreement. Disagreements get logged and reviewed — they surface either a weight tuning gap or a missing rubric dimension.

  • Step 6: Commit the ground-truth file
git add outputs/ground-truth-20.csv git commit -m "test(matching): add Calibre's 20 ground-truth (JD, CV) verdicts"

Task 20: Update workspace docs

Files:

  • Modify: calibre-app/../context/current-data.md

  • Modify: calibre-app/../grant/audit-trail.md

  • Step 1: Update current-data.md

Edit G:/My Drive/BL Dev work/Calibre/context/current-data.md. Append to the “Reference CV Data” section:

## JD Reference Set (2026-05-23) 41 JDs added to `reference/JD/` covering five sub-functions: - Operations - cargo, commodities brokerage (19): Agri (9), Energy (7), Metals (3) - Operations - ship, ship brokerage (8): Drybulk/Container/Offshore (4), Tanker (4) - Risk (5) - Finance - RM, wealth management, FA, institutional sales (5) - Operations - banks, finance brokerage, funds, asset management (4) ## JD–CV Matching System (2026-05-23) Implemented per [plans/pipeline-1.1+2.1+3.2+6.1+7.1+7.2+8.1-matching-engine-design.md](../plans/pipeline-1.1+2.1+3.2+6.1+7.1+7.2+8.1-matching-engine-design.md) and [plans/pipeline-1.1+2.1+3.2+6.1+7.1+7.2+8.1-matching-engine-impl.md](../plans/pipeline-1.1+2.1+3.2+6.1+7.1+7.2+8.1-matching-engine-impl.md). - JD parser + normaliser mirror the CV pipeline, share the controlled tag vocabulary. - Taxonomy extended: `finance-fo`, `ops-back-office` added. - Layer 1 hard fit: deterministic, composite < 4 drops. - Adjacency resolver: hybrid (ratified config → deny-list → live Claude inference). Ratified config bootstrapped from cold-start pass with Calibre. - Layer 2 deep assessment: Claude tool_use, every dimension score requires a verbatim CV quote. - Layer 3 verdict: deterministic 4-band (Strong yes / Soft yes / Soft no / Strong no). - Validation: 20 ground-truth pairs from Calibre — target ≥ 80% agreement.
  • Step 2: Update audit-trail.md

Edit G:/My Drive/BL Dev work/Calibre/grant/audit-trail.md. Add a new section for Step 4 + Steps 5–8 deliverables:

## Annex 3 Deliverable 2 — AI Candidate Filtering & Matching | Component | Files | Maps to scope.md line | |---|---|---| | JD parser | `calibre-app/src/lib/jd-parser/` | "JD parser (extract role, skills, requirements from uploaded JD)" | | JD normaliser + taxonomy | `calibre-app/src/lib/jd-normaliser/` | "Title normalisation + skills adjacency mapping" | | Layer 1 hard fit | `calibre-app/src/lib/matching/layer1-hard-fit.ts` | "Two-layer filter: hard fit (score <4 drop) → deep assessment" | | Ratified adjacency config | `calibre-app/config/adjacency-ratified.json` | "Conditional Mapping Logic engine (recruiter-validated rules)" | | Live-inference adjacency | `calibre-app/src/lib/matching/adjacency/live-inference.ts` | "Title normalisation + skills adjacency mapping" | | Layer 2 deep assessment | `calibre-app/src/lib/matching/layer2-deep-assessment.ts` | "AI Scoring Engine (weighted, vertical-specific criteria)" | | Layer 3 verdict | `calibre-app/src/lib/matching/layer3-verdict.ts` | "Composite ranking + shortlist generation" | | Per-sub-function rubric weights | `calibre-app/config/rubric-weights.json` | "Configurable rubric system per tenant — productisable" | | Ground-truth validation set | `calibre-app/outputs/ground-truth-20.csv` | Defensibility evidence for audit |
  • Step 3: Commit
cd "G:/My Drive/BL Dev work/Calibre" git add context/current-data.md grant/audit-trail.md git commit -m "docs: log JD–CV matching system delivery in current-data + audit-trail"

Self-Review Checklist

  • Spec coverage:
    • JD parser + types ✓ (Tasks 1–3)
    • Batch parse JDs ✓ (Task 4)
    • Taxonomy extension (finance-fo, ops-back-office) ✓ (Task 5)
    • JD normaliser + batch ✓ (Tasks 6–7)
    • Match types ✓ (Task 8)
    • Layer 1 hard fit ✓ (Task 9)
    • Adjacency config + live inference + resolver ✓ (Tasks 10–12)
    • Rubric weights ✓ (Task 13)
    • Layer 2 deep assessment with verbatim-quote enforcement ✓ (Task 14)
    • Layer 3 verdict ✓ (Task 15)
    • Public match() API ✓ (Task 16)
    • Batch match script ✓ (Task 17)
    • Adjacency review CLI ✓ (Task 18)
    • Cold-start bootstrap + 20-pair ground-truth validation ✓ (Task 19)
    • Workspace docs update ✓ (Task 20)
  • No placeholders: Every step has exact code, exact paths, exact commands.
  • Type consistency: NormalisedJD, NormalisedCV, ParsedCV, Layer1Result, Layer2Result, Layer3Verdict, MatchRecord, AdjacentMatchEvidence, SubFunction, SeniorityTier, RubricWeights defined once, used consistently across all tasks.
  • Precondition flagged: Task 9 onward depends on CV normaliser implementation. Called out at top of plan and at Task 9 header.
  • Audit traceability: Task 20 writes the audit-trail entries mapping every component back to scope.md.

What this plan does NOT cover

  • CV normaliser implementation — separate plan, per plans/step-5-phase-2-cv-normaliser-design.md
  • Supabase storage of MatchRecords, ParsedJDs, NormalisedJDs — infrastructure setup task
  • Recruiter UI for verdict review, adjacency approval queue, override workflow — Step 10
  • JD intake UX (upload, paste, URL scrape) — Step 10
  • Outreach generation per match — Step 9
  • Cost monitoring + rate limiting for Claude calls — operational concern, separate doc
  • Multi-tenant scoping (tenant-id on every MatchRecord, per-tenant taxonomy overrides) — Step 10 productisation pass

Execution Handoff

Plan complete and saved to plans/pipeline-1.1+2.1+3.2+6.1+7.1+7.2+8.1-matching-engine-impl.md.

Recommended execution path: executing-plans skill with checkpoint review after Task 4 (JD parsing complete), Task 7 (normalisation complete), Task 9 (Layer 1 testable in isolation), Task 14 (full pipeline operational against fixtures), and Task 19 (validated against Calibre’s ground truth).

If CV normaliser implementation is not yet done, execute Tasks 1–8 of this plan first (independent of the CV normaliser), then pause to implement the CV normaliser per its own design spec, then resume from Task 9.

Last updated on