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:
- Layer 1 (Tasks 9–10) scores 4 dims —
sub_function_matchmoved out of the composite to a new Step 6.0 entry gate (build targetmatching/entry-gates.ts: sub-function gate + ratifieddroprules, fires before scoring). Default weights:tag_overlap0.40 /seniority_delta0.20 /years_experience0.20 /red_flag_penalty0.20.- New Step 6.2 rules overlay module (
matching/rules-overlay.ts): boost/penalty deltas sum then clip to net ±0.5, attachblock_at_l2/require_evidenceflags, threshold re-check ≥ 4.0 → drop reasoncomposite_below_4_after_rules.- 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
| File | Responsibility |
|---|---|
src/lib/jd-parser/types.ts | ParsedJD, JDParseMetadata, JDRequirement, JDEducation |
src/lib/jd-parser/parser.ts | Claude tool-use call: raw JD text → ParsedJD |
src/lib/jd-parser/index.ts | Public API: parseJD(filePath: string): Promise<ParsedJD> — composes existing extractText + parser |
src/lib/jd-normaliser/taxonomy.ts | Controlled tag vocabulary (CV side + new finance-fo + ops-back-office) |
src/lib/jd-normaliser/types.ts | NormalisedJD interface |
src/lib/jd-normaliser/normaliser.ts | Claude tool-use call: ParsedJD → NormalisedJD |
src/lib/jd-normaliser/index.ts | Public API: normaliseJD(parsed: ParsedJD): Promise<NormalisedJD> |
src/lib/matching/types.ts | Layer1Result, Layer2Result, Layer3Verdict, MatchRecord, AdjacentMatchEvidence |
src/lib/matching/layer1-hard-fit.ts | Pure deterministic scoring functions + composite + drop logic |
src/lib/matching/adjacency/config.ts | Load + validate config/adjacency-ratified.json |
src/lib/matching/adjacency/live-inference.ts | Claude tool-use call for AdjacentMatchEvidence |
src/lib/matching/adjacency/resolver.ts | Two-tier resolver: config lookup → live inference → boost calc |
src/lib/matching/layer2-deep-assessment.ts | Claude tool-use call: NormalisedJD + ParsedCV + adjacency → Layer2Result |
src/lib/matching/layer3-verdict.ts | Deterministic 4-band verdict |
src/lib/matching/weights.ts | Per-sub-function rubric weights loader |
src/lib/matching/index.ts | Public API: match(jd, cv): Promise<MatchRecord> |
config/adjacency-ratified.json | Recruiter-approved adjacency pairs (initially empty) |
config/rubric-weights.json | Per-sub-function dimension weights |
scripts/batch-parse-jds.ts | Parse all 41 reference JDs → outputs/parsed-jds/ |
scripts/batch-normalise-jds.ts | Normalise all parsed JDs → outputs/normalised-jds/ |
scripts/batch-match.ts | Run full pipeline across all (JD, CV) pairs → outputs/match-records/ |
scripts/review-adjacency-proposals.ts | CLI for Calibre to approve/reject live-inferred adjacency proposals |
tests/lib/jd-parser/parser.test.ts | JD parser unit tests |
tests/lib/jd-normaliser/normaliser.test.ts | JD normaliser unit tests |
tests/lib/matching/layer1-hard-fit.test.ts | Layer 1 pure-function unit tests |
tests/lib/matching/adjacency/resolver.test.ts | Adjacency resolver unit tests |
tests/lib/matching/layer2-deep-assessment.test.ts | Layer 2 fixture-driven tests |
tests/lib/matching/layer3-verdict.test.ts | Layer 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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-jdsExpected: ≥ 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_skillslooks sensible andvertical_hintmatches 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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-jdsExpected 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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.tsExpected: 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-matchExpected: 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-matchNote: 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-adjacencyCalibre 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-matchNow 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,RubricWeightsdefined 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.