Skip to Content
PlansTaxonomy 3-Level Amendment

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[]):

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):

  1. 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
  2. Filename hint — when the folder is flat (e.g. Finance), the filename (e.g. “JD for Relationship Manager”) maps to the sub-vertical (rm)
  3. 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:

  1. Folder hint for primary_sub_vertical (Calibre’s filename convention (Sub-function, Specialisation).pdf gives the dominant signal)
  2. Content inference for secondary_sub_verticals — Claude reads the parsed CV’s commodity_specialisations, vertical_signals, employment_history[].description_summary and 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

PlanSection to overrideNew 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.mdTasks 4–10 — JD normaliser, Layer 1, Adjacency resolverTasks 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)

FileChange kindNotes
src/lib/cv-normaliser/types.tsNewNormalisedCV per Schema 2
src/lib/jd-normaliser/types.tsNewNormalisedJD per Schema 1
src/lib/matching/types.tsNewLayer1Result per Schema 3 — replaces older sketch in jd-cv-matching impl plan
src/lib/matching/rubrics/loader.tsNew3-step resolution chain per Schema 4
src/lib/matching/adjacency/config.tsNewReads new triple-form per Schema 5
config/rubrics/_default.jsonNewGlobal fallback rubric
config/rubrics/{sub_function}.default.jsonNewOne per sub_function — initial weights
config/rubrics/{sub_function}.{sub_vertical}.jsonOptionalCreated as Calibre populates per-cell tuning

All paths are relative to calibre-app/.


Validation Strategy

  • Schema compatibility: unit tests for NormalisedJD, NormalisedCV, Layer1Result ensure 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

QuestionOwnerResolution path
Should secondary_sub_verticals[] cap at 3 to keep tag-overlap math tractable?EngineeringYes, 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?EngineeringNo 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)?EngineeringSparse 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.

Last updated on