Taxonomy 3-Level Amendment
Source: plans/amendment-3.2+5.2+6.1-taxonomy-3level.md · synced 2026-06-12
# Taxonomy 3-Level Amendment Date: 2026-05-25 Phase: A.3. 9-step coverage: amends Steps 3, 5, 6 to a 3-level taxonomy. Status: Approved — overrides the older schemas in two prior plans. Depends on: ✅ Sub-vertical structure derived from Calibre’s JD folders (locked in SOURCE-OF-TRUTH.md §3b). Step 3a vocab bootstrap design (step-3.1-vocab-bootstrap-design.md). Closes: D9 in STATE-LOG.md.
Context
Two earlier plans assumed a 2-level taxonomy (sub_function + flat tags[]):
- plans/step-5-phase-2-cv-normaliser-design.md —
NormalisedResultfor CV side - plans/pipeline-1.1+2.1+3.2+6.1+7.1+7.2+8.1-matching-engine-design.md —
NormalisedJD+Layer1Result+ rubric loader for JD side and matching engine
The canonical taxonomy is now 3-level: sub_function → sub_vertical → tag. Sub-verticals were derived from Calibre’s actual JD folder structure (e.g. cargo-ops.{agri, energy, metals}).
This doc is the single amendment that ripples through both older plans. After this amendment is approved, the two older plans get banner notes pointing here for their schema sections.
Rather than rewriting the older plans in place (which would lose audit traceability of how the design evolved), the amendment lives standalone. Implementation tasks read THIS doc for schemas, the older plans for everything else (pipeline shape, Claude tool-use patterns, validation strategy, etc.).
Schema 1 — NormalisedJD (was: jd-cv-matching design §JD Normaliser)
Before (2-level)
interface NormalisedJD {
sub_function: "cargo-ops" | "ship-ops" | "risk-analytics" | "finance-fo" | "ops-back-office"
sub_function_display: string
tags: string[] // flat — strings from a controlled list
normalised_title: string
seniority_tier: "Junior" | "Mid" | "Senior" | "Lead" | "Head"
min_years: number | null
must_have_tags: string[]
nice_to_have_tags: string[]
confidence: number
}After (3-level)
type SubFunction =
| "cargo-ops" | "ship-ops" | "risk-analytics" | "finance-fo" | "ops-back-office"
type SubVertical = string // e.g. "agri", "energy", "metals", "tanker", "credit", "aml", ...
// bounded set per sub_function — defined in SOURCE-OF-TRUTH.md §3b
interface NormalisedJD {
sub_function: SubFunction
sub_function_display: string
sub_vertical: SubVertical
sub_vertical_display: string
tags: string[] // tags drawn from the (sub_function, sub_vertical) cell's vocab
must_have_tags: string[]
nice_to_have_tags: string[]
normalised_title: string
seniority_tier: "Junior" | "Mid" | "Senior" | "Lead" | "Head"
min_years: number | null
confidence: number
}Key change: sub_vertical is a required field. Tags are read from the vocab for the specific (sub_function, sub_vertical) cell — not a global pool. The JD normaliser prompt must classify both sub_function AND sub_vertical before assigning tags.
Sub-vertical assignment for the JD normaliser comes from three signals (in priority order):
- Folder hint — when the JD is in a sub-vertical folder (e.g.
JD/Operations- cargo, commodities brokerage/Agri/), the folder name maps directly to the sub-vertical - Filename hint — when the folder is flat (e.g. Finance), the filename (e.g. “JD for Relationship Manager”) maps to the sub-vertical (
rm) - Content inference — Claude reads the JD text and proposes a sub-vertical from the bounded set; this is the fallback when neither folder nor filename gives a clean signal
Schema 2 — NormalisedCV (replaces title-normaliser NormalisedResult)
Before (2-level)
interface NormalisedResult {
sub_function: "cargo-ops" | "ship-ops" | "risk-analytics"
sub_function_display: string
tags: string[]
normalised_title: string
seniority_tier: "Junior" | "Mid" | "Senior" | "Lead" | "Head"
search_queries: string[]
confidence: number
}After (3-level)
interface NormalisedCV {
// Identity
cv_id: string // FK to parsed CV record
// Primary classification
sub_function: SubFunction
sub_function_display: string
// Sub-vertical (CV side may span multiple)
primary_sub_vertical: SubVertical // dominant sub-vertical
primary_sub_vertical_display: string
secondary_sub_verticals: SubVertical[] // CVs that touch multiple — kept for tag intersection at L1
// Tags (drawn from each (sub_function, sub_vertical) cell's vocab)
// Tags are flat for matching, but each tag carries provenance
tags: { slug: string; sub_vertical: SubVertical }[]
// Display + sourcing
normalised_title: string
seniority_tier: "Junior" | "Mid" | "Senior" | "Lead" | "Head"
search_queries: string[] // for Apollo / LinkedIn sourcing (Step 4)
confidence: number
}Key change vs JDs: a CV can span multiple sub-verticals (an LNG operator’s CV touches both LNG and LPG; a multi-asset PM touches several sub-verticals). Tags carry their sub-vertical origin so Layer 1 set intersection works cleanly.
Sub-vertical assignment for the CV normaliser uses:
- Folder hint for
primary_sub_vertical(Calibre’s filename convention(Sub-function, Specialisation).pdfgives the dominant signal) - Content inference for
secondary_sub_verticals— Claude reads the parsed CV’scommodity_specialisations,vertical_signals,employment_history[].description_summaryand proposes any additional sub-verticals from the bounded set
Schema 3 — Layer 1 Hard Fit (replaces jd-cv-matching design §Layer 1)
Before
interface Layer1Result {
jd_id: string
cv_id: string
scores: {
sub_function: number
tag_overlap: number
seniority: number
years: number
red_flag_penalty: number
}
composite: number
passes: boolean
drop_reason?: ...
adjacent_match?: AdjacentMatchEvidence
}After
interface Layer1Result {
jd_id: string
cv_id: string
scores: {
sub_function: number // 5 if equal, 0 if not (hard gate)
sub_vertical: number // 5 if primary equals; 3 if in secondary[]; 1 if adjacent (resolver); 0 otherwise
tag_overlap: number // computed within the (sub_function, sub_vertical) cell
seniority: number
years: number
red_flag_penalty: number
}
composite: number // weighted average, weights configurable per cell
passes: boolean
drop_reason?:
| "sub_function_mismatch"
| "sub_vertical_mismatch"
| "composite_below_4"
| "hard_gate"
adjacent_match?: AdjacentMatchEvidence // can boost sub_vertical AND tag_overlap now
}Key change: sub_vertical is a new dimension. Hard-gate behaviour:
sub_function: 0→ drop (unchanged)sub_vertical: 0→ drop, unless an adjacency entry (e.g.cargo-ops.tanker ↔ cargo-ops.lng) is ratified
Tag overlap is now computed against the JD’s must_have_tags filtered by (sub_function, sub_vertical) of the JD, intersected with the CV’s tags that originated in that same cell (or in a secondary sub-vertical for CVs that span).
Schema 4 — Rubric Config Loader
Before
function loadRubric(subFunction: SubFunction): RubricConfig {
// Returns one rubric per sub_function
}After
function loadRubric(subFunction: SubFunction, subVertical: SubVertical): RubricConfig {
// Resolution order:
// 1. config/rubrics/{sub_function}.{sub_vertical}.json — most specific
// 2. config/rubrics/{sub_function}.default.json — sub-function fallback
// 3. config/rubrics/_default.json — global fallback
// Each step merges: cell-specific overrides win, fallback fills gaps
}Why fallback chain: at the start, only _default.json exists. As Calibre populates sub-vertical-specific rubrics (e.g. weight cargo-ops.energy differently because LNG ops needs HSSE > seniority), they slot in and override. No code change.
Schema 5 — Adjacency Resolver Config
Before
{
"adjacencies": [
{ "from": "vessel:tanker", "to": "energy:lng", "weight": 0.8, ... }
]
}After
{
"adjacencies": [
{
"from": { "sub_function": "ship-ops", "sub_vertical": "tanker", "tag": null },
"to": { "sub_function": "cargo-ops", "sub_vertical": "energy", "tag": "lng" },
"weight": 0.8,
"rationale": "LNG carrier ops experience transfers directly to LNG cargo handling.",
"ratified_by": "team@calibre.sg",
"ratified_at": "2026-05-25T..."
}
]
}Key change: adjacencies are now full triples (sub_function, sub_vertical, tag). Setting tag: null makes the adjacency fire at sub-vertical level (i.e. any tag within ship-ops.tanker triggers an adjacency to cargo-ops.energy.lng). Setting tag: "lng" on both sides makes it a precise tag-pair adjacency.
This is strictly a superset — old 2-level adjacencies can be expressed by setting both tag fields and leaving sub-vertical implicit from the tag’s home cell.
Knock-On Effects
Effects on existing plans
| Plan | Section to override | New reference |
|---|---|---|
| step-5-phase-2-cv-normaliser-design.md | §“Output Schema” + §“Sub-function Taxonomy” + §“Controlled Tag Vocabulary” | Schemas 1, 2, 5 here |
| pipeline-1.1+2.1+3.2+6.1+7.1+7.2+8.1-matching-engine-design.md | §“JD Normaliser — Output Schema” + §“Layer 1 — Hard Fit” + §“Adjacency Resolver” | Schemas 1, 3, 5 here |
| pipeline-1.1+2.1+3.2+6.1+7.1+7.2+8.1-matching-engine-impl.md | Tasks 4–10 — JD normaliser, Layer 1, Adjacency resolver | Tasks need code-level amendment after this design is approved; do it in the impl plan rewrite, not here |
Banner notes already in both older design docs flag the override. No code has been written against the old schemas yet, so the rewrite is design-only.
Effects on rubric-configs
The existing reference/rubric-configs/*.json stubs are at sub-function granularity (commodities, maritime, offshore, tech-gaming). They become defaults in the new resolution chain: config/rubrics/{sub_function}.default.json. The new sub-vertical-level rubric files (cargo-ops.energy.json, cargo-ops.agri.json, etc.) get added as Calibre populates them.
Effects on adjacency-ratified.json
The file does not exist yet (no code to break). When the file is created in the matching engine implementation, it uses the new schema. No migration needed.
Effects on tag-vocab.json (Step 3a output)
The bootstrap design already produces vocab keyed by (sub_function, sub_vertical). No further amendment needed there.
File Map (changes)
| File | Change kind | Notes |
|---|---|---|
src/lib/cv-normaliser/types.ts | New | NormalisedCV per Schema 2 |
src/lib/jd-normaliser/types.ts | New | NormalisedJD per Schema 1 |
src/lib/matching/types.ts | New | Layer1Result per Schema 3 — replaces older sketch in jd-cv-matching impl plan |
src/lib/matching/rubrics/loader.ts | New | 3-step resolution chain per Schema 4 |
src/lib/matching/adjacency/config.ts | New | Reads new triple-form per Schema 5 |
config/rubrics/_default.json | New | Global fallback rubric |
config/rubrics/{sub_function}.default.json | New | One per sub_function — initial weights |
config/rubrics/{sub_function}.{sub_vertical}.json | Optional | Created as Calibre populates per-cell tuning |
All paths are relative to calibre-app/.
Validation Strategy
- Schema compatibility: unit tests for
NormalisedJD,NormalisedCV,Layer1Resultensure typed access works at every depth - Rubric resolution: unit tests for
loadRubric(SF, SV)cover all three resolution paths (most-specific, default, global) - Adjacency expressiveness: existing scenarios (Tanker → LNG, edible-oils → veg-oils) all expressible in the new triple form
- End-to-end: Layer 1 dry-run on full 41 × 107 matrix after CV + JD normalisers ship; spot-check 20 (JD, CV) pairs for ranking sanity
What This Amendment Does NOT Cover
- Implementation plan rewrite — that comes after this design is approved. The implementation plan (jd-cv-matching impl) gets a separate amendment plan or full rewrite once D9 is closed.
- UI surfacing of sub-vertical — recruiter UI shows the 3 levels; Step 10 scope.
- Backward-compat for any existing data — there is none. CV parser output stays untouched (no taxonomy fields). Old schemas had no shipped consumers.
Open Questions
| Question | Owner | Resolution path |
|---|---|---|
Should secondary_sub_verticals[] cap at 3 to keep tag-overlap math tractable? | Engineering | Yes, cap at 3. Beyond that, the CV is genuinely cross-functional and likely needs human review anyway. |
| For flat sub-functions (Risk, Finance, ops-back-office), the sub-vertical IS the role itself — does this double-count signal? | Engineering | No double-count. Each rubric in those cells just has a different shape. Calibre will see one rubric per sub-vertical, which is correct. |
What if Step 3a bootstrap produces zero tags for a sparse cell (e.g. cargo-ops.metals.precious-metals — 1 JD)? | Engineering | Sparse cells use the default rubric and skip tag-overlap scoring (composite still computable from other dimensions). |
Next Step
Approve → write the implementation-plan amendment for jd-cv-matching impl Tasks 4–10. After Step 3a bootstrap delivers a locked vocab, the impl plan can proceed.