Step 5b — Industry-Expert Filter (Design Spec)
Source: plans/step-5.3-industry-filter-design.md · synced 2026-06-12
# Step 5b — Industry-Expert Filter (Design Spec) Date: 2026-05-25 Phase: A.4. 9-step coverage: Step 5b (recruiter intuition layer, complementary to 5a LLM inference). Status: Draft — awaiting greenlight + the Calibre team scaffold-review session. Depends on: Locked tag vocab from Step 3a bootstrap. Normalised JD + CV records. Layer 1 hard-fit composite score. Closes: D8 in STATE-LOG.md.
⚠️ AMENDED 2026-06-12 — Step 6 restructure (see SOURCE-OF-TRUTH.md §8 2026-06-12 entry). Where this doc conflicts, the following wins:
- Step ID 5.3 retired. This filter is split into Step 6.0 (
drop-type hard gates — fire BEFORE Layer 1 scoring) and Step 6.2 (boost/penalty +block_at_l2/require_evidenceflags — fire AFTER the Layer 1 composite). This filename keeps the 5.3 prefix to preserve links.- Boost/penalty deltas are net-capped at ±0.5 — all firing deltas sum, then clip to ±0.5 on the composite. Any per-rule delta values in this doc (±0.3–1.5) are superseded. Rationale: rules nudge borderline candidates; a rule strong enough to overturn the rubric belongs as an L1 dimension.
- Code targets:
matching/entry-gates.ts(6.0) +matching/rules-overlay.ts(6.2) — replacesmatching/industry-filter/.
Context
Layer 1 (Step 6) is deterministic — sub-function match, tag overlap, seniority delta, years, red-flag penalty. Layer 2 (Step 7) is LLM-driven evidence-grounded deep assessment. Between them, neither captures recruiter intuition — the rules the Calibre team would apply in seconds that no rubric or LLM would surface on its own:
- “Anyone from $competitor we don’t touch.”
- “Singapore-based only — no relocation candidates.”
- “Top-tier shop alumni get a boost regardless of years.”
- “LNG cargo role — must have HSSE certification, no exceptions.”
- “China-coverage role — must speak Mandarin even if JD doesn’t say.”
- “More than two 18-month tenures back-to-back = pass, no matter how good the CV reads.”
- “If they’ve never worked Singapore hours, they won’t survive 24/7 ops.”
These are recruiter taste rules. They are not “skills” or “experience” — they’re business policies + pattern recognition the Calibre team have built over a career. The grant calls this the “Conditional Mapping Logic engine (recruiter-validated rules)” in AD2.
Step 5b is the system that encodes those rules, applies them mechanically, and lets Calibre + the Calibre team ratify/extend them over time. The ratified rule file IS the proprietary domain layer.
Architectural Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Position in pipeline | After Layer 1 hard-fit composite is computed; before Layer 2 deep assessment | Hard-fit filters obvious misses cheaply; expert filter trims survivor pool further before expensive Claude calls; Layer 2 only runs on (L1 + 5b)-passing candidates |
| Rule storage | JSON config file, hot-reloadable | Same pattern as adjacency resolver. Editable without code change. Versionable. |
| Rule kinds | Hard (drop / block) + Soft (boost / penalty) | Hard = “do not surface”. Soft = “shift composite score by ±x”. |
| Discovery | Two-tier: scaffold + live LLM proposal | Same pattern as adjacency. Scaffold seeds the file; Claude proposes new rules from observed match outputs; the Calibre team ratify or reject. |
| Audit value | Every rule has author, ratification date, rationale | Grant-defensible. Recruiter-validated. |
| Scope of effect | Per-tenant (Calibre has its own ruleset; another agency would have theirs) | Multi-tenancy hook for Step 10 |
Where Step 5b Sits in the Pipeline
Normalised JD ─┠┌─ Normalised CV
│ │
â–¼ â–¼
┌────────────────────────────────────â”
│ Layer 1 — Hard Fit (Step 6) │
│ Composite < 4 → drop │
└─────────────┬──────────────────────┘
│ survivors
â–¼
┌────────────────────────────────────â”
│ Step 5b — Industry-Expert Filter │ ↠THIS PLAN
│ Apply ratified rules: │
│ hard rules: drop / block │
│ soft rules: boost / penalty │
│ Composite score updated │
│ Composite < 4 (post-rules) → drop │
└─────────────┬──────────────────────┘
│ survivors
â–¼
┌────────────────────────────────────â”
│ Layer 2 — Deep Assessment (Step 7)│
│ Evidence-grounded LLM │
└────────────────────────────────────┘Rule Schema
interface ExpertRule {
id: string // ULID
category: RuleCategory // see below
scope: RuleScope // when this rule fires
effect: RuleEffect // what happens
rationale: string // 1–2 sentence why (audit-facing)
ratified_by: string // "team@calibre.sg"
ratified_at: string // ISO 8601
source: "scaffold" | "live_inference" | "manual"
active: boolean // can disable without deleting
}
type RuleCategory =
| "geography" // location requirements
| "competitor" // company exclusion
| "pedigree" // alumni boost / penalty
| "career_shape" // tenure patterns
| "certification" // hard cert requirement
| "language" // language requirement
| "compensation" // comp-band signal (advanced — phase 2)
| "custom" // anything else Calibre adds
type RuleScope =
| { applies_to: "all" }
| { applies_to: "sub_function"; sub_function: string }
| { applies_to: "sub_vertical"; sub_function: string; sub_vertical: string }
| { applies_to: "tag"; tag: string } // fires when JD requires this tag
| { applies_to: "jd_match"; jd_pattern: string } // fires when JD raw text matches regex
type RuleEffect =
| { kind: "drop"; reason_code: string } // hard — candidate excluded
| { kind: "block_at_l2"; reason_code: string } // soft hard — flag but keep visible
| { kind: "boost"; delta: number; reason_code: string } // +N to composite
| { kind: "penalty"; delta: number; reason_code: string } // –N to composite
| { kind: "require_evidence"; evidence_field: string } // raise L2 threshold for this dimension
interface ExpertRuleTrigger {
// Each rule has a trigger predicate that is evaluated against a (NormalisedJD, NormalisedCV, Layer1Result) tuple
predicate: TriggerPredicate
}
type TriggerPredicate =
| { type: "cv_location_not_in"; values: string[] }
| { type: "cv_employer_in"; values: string[] }
| { type: "cv_employer_matches"; pattern: string }
| { type: "cv_consecutive_short_tenures"; threshold_months: number; count: number }
| { type: "cv_missing_certification"; cert: string }
| { type: "cv_missing_language"; language: string }
| { type: "cv_tag_present"; tag: string }
| { type: "cv_employer_attended_school"; school_pattern: string }
| { type: "combo"; op: "and" | "or"; predicates: TriggerPredicate[] }The rule is fully declarative — JSON only, no code per rule.
Example Rules (scaffold seeds)
These are the ~10 rules built into the initial scaffold. the Calibre team audit + tune + extend.
{
"version": "scaffold-2026-05-25",
"tenant_id": "calibre",
"rules": [
{
"id": "01HXR-GEO-SG",
"category": "geography",
"scope": { "applies_to": "all" },
"trigger": { "type": "cv_location_not_in", "values": ["Singapore", "SG"] },
"effect": { "kind": "penalty", "delta": -1.5, "reason_code": "non-sg-location" },
"rationale": "Calibre's mandate base is Singapore-resident. Non-SG candidates carry relocation friction.",
"source": "scaffold",
"active": true
},
{
"id": "01HXR-SHORT-TENURE",
"category": "career_shape",
"scope": { "applies_to": "all" },
"trigger": { "type": "cv_consecutive_short_tenures", "threshold_months": 18, "count": 2 },
"effect": { "kind": "penalty", "delta": -1.0, "reason_code": "back-to-back-short-tenure" },
"rationale": "Two back-to-back 18-month tenures is a stability red flag in Singapore commodities."
},
{
"id": "01HXR-LNG-HSSE",
"category": "certification",
"scope": { "applies_to": "sub_vertical", "sub_function": "cargo-ops", "sub_vertical": "energy" },
"trigger": {
"type": "combo",
"op": "and",
"predicates": [
{ "type": "cv_tag_present", "tag": "lng" },
{ "type": "cv_missing_certification", "cert": "HSSE" }
]
},
"effect": { "kind": "block_at_l2", "reason_code": "lng-no-hsse" },
"rationale": "LNG cargo roles in Singapore require HSSE certification by regulation. Block at L2 unless Calibre overrides."
},
{
"id": "01HXR-CHINA-MANDARIN",
"category": "language",
"scope": { "applies_to": "jd_match", "jd_pattern": "(?i)china|mandarin|prc|sinochem|cnoo" },
"trigger": { "type": "cv_missing_language", "language": "Mandarin" },
"effect": { "kind": "drop", "reason_code": "china-cov-no-mandarin" },
"rationale": "China-coverage roles fail without Mandarin. Drop early."
},
{
"id": "01HXR-TOP-TIER-BOOST",
"category": "pedigree",
"scope": { "applies_to": "all" },
"trigger": {
"type": "cv_employer_in",
"values": ["Vitol", "Trafigura", "Glencore", "Mercuria", "Gunvor", "BP Trading", "Shell Trading"]
},
"effect": { "kind": "boost", "delta": 0.5, "reason_code": "top-tier-alumni" },
"rationale": "Top-tier shop alumni signal demonstrably stronger ops chops in commodities sub-function."
}
]
}The full scaffold will have 10–15 rules covering each RuleCategory. Calibre + the Calibre team’s job is to (a) say which scaffold rules to keep, (b) tune deltas, (c) add their own.
Live LLM Proposal Loop (mirrors adjacency resolver)
After Layer 1 + 5b runs across all 41 JDs × ~107 CVs:
- The system identifies match decisions where Layer 1 + 5b agree the candidate passes but the Calibre team’s gut would say “no” (or vice versa). Detection signals:
- L1 composite + 5b adjustment lands at composite ≥ 4 but Calibre marks it “Strong no” during review
- Conversely L1 + 5b drops a candidate but Calibre marks “Strong yes”
- Claude is given the (JD, CV, L1 result, applied rules) tuple and asked: “what rule, if added, would have flipped this decision the right way?”
- Claude returns:
interface ProposedRule {
proposed_rule: ExpertRule
evidence: {
jd_quote: string
cv_quote: string
}
recommend_ratification: boolean
rationale: string
}- Proposed rules land in a review queue. the Calibre team approve → write to ratified config. Reject → write to a deny-list.
Self-improving. Same pattern as adjacency. Calibre never has to write a rule from scratch — Claude drafts; Calibre ratifies.
Application Order
For each (JD, CV) pair that survives Layer 1:
- Load all
active === truerules for the tenant - Filter rules whose
scopematches the JD/CV - For each matching rule, evaluate
trigger.predicate - Collect fired rules into a list
- Hard-rule pass first: if any fired rule has
effect.kind === "drop"→ drop the candidate, return{ outcome: "dropped", fired_rules: [...] } - Block-at-L2 pass: if any fired rule has
effect.kind === "block_at_l2"→ setblock_at_l2: true, but candidate continues - Score adjustment pass: sum all
boostandpenaltydeltas → adjust Layer 1 composite - Post-adjustment threshold: if adjusted composite < 4 → drop with
reason: "composite_below_4_after_rules" - Return final result + full audit trail of which rules fired
interface Layer5BResult {
jd_id: string
cv_id: string
l1_composite_in: number
l1_composite_out: number
fired_rules: { rule_id: string; effect: RuleEffect; evidence_summary: string }[]
outcome: "passed" | "dropped"
drop_reason?: string
block_at_l2: boolean
}Every match decision now has a full audit trail of which recruiter rules fired and why. This is the grant audit artifact.
File Map
| File | Responsibility |
|---|---|
src/lib/matching/expert-filter/types.ts | ExpertRule, TriggerPredicate, Layer5BResult, ProposedRule |
src/lib/matching/expert-filter/config.ts | Load + validate config/expert-filter-rules.json |
src/lib/matching/expert-filter/evaluator.ts | Pure functions: evaluate a TriggerPredicate against (JD, CV, L1Result) |
src/lib/matching/expert-filter/applier.ts | The 9-step application order above |
src/lib/matching/expert-filter/live-inference.ts | Claude call: propose new rule from a disagreement case |
src/lib/matching/expert-filter/index.ts | Public API: applyExpertFilter(jd, cv, l1Result, ruleset): Layer5BResult |
config/expert-filter-rules.scaffold.json | Scaffold (the 10–15 starter rules) — read-only seed |
config/expert-filter-rules.json | RATIFIED (read-write, recruiter-managed) |
config/expert-filter-rules.audit.json | Full history of ratifications, rejections, edits |
scripts/review-rule-proposals.ts | CLI for the Calibre team to approve/reject ProposedRule items |
tests/lib/matching/expert-filter/evaluator.test.ts | Each predicate has unit tests with fixtures |
tests/lib/matching/expert-filter/applier.test.ts | Order-of-application correctness |
Validation Strategy
| Check | How |
|---|---|
| Scaffold correctness | Calibre walks through each of the 10–15 scaffold rules and confirms intent matches his mental model |
| End-to-end | Run on the full 41 JDs × ~107 CVs matrix after Layer 1. Calibre + the Calibre team spot-review 30 drops and 30 passes. Disagreement rate should be < 20 % |
| Live inference | Cold-start with the scaffold only. Run for 2 weeks. Measure: how many proposed rules Calibre approves vs rejects. Approval rate ≥ 50 % means Claude is useful as a proposer |
| Cost containment | A run on the full matrix at production scale should not exceed 0.10 USD per (JD, CV) pair (the L2 deep assessment is the cost driver, not 5b — 5b is mostly deterministic) |
| Audit | Every drop / boost / penalty in any match output traces to a rule_id + rationale + ratified_by |
What This Plan Does NOT Cover
- Recruiter UI for rule approval / live inference review — Step 10 productisation surface
- Rule version diffs between time T and T+30 days — phase 2 of recruiter UX
- Tenant-level rule isolation in DB — Step 10 Supabase RLS work
- Cross-tenant rule templates (e.g. “Singapore Geography Pack”) — productisation feature; not in MVP
- Compensation-band rules — flagged as
RuleCategory: compensationbut no scaffold rule; Calibre adds when he has the comp data
Open Questions
| Question | Owner | Resolution path |
|---|---|---|
| When a hard rule fires, is the candidate hidden from Calibre’s UI entirely, or shown with a “dropped by rule X” tag? | Calibre | Default: shown with reason. Calibre can override per-candidate. |
Should boost deltas be additive or multiplicative? | Engineering | Additive. Simpler audit. Composite math stays linear. |
| Can a single proposed rule trigger 2+ drops in one pass? Audit shows duplicate fires? | Engineering | Yes, both fire; both are logged. Composite math sums all penalties cleanly. |
| How does live inference get the disagreement data if there’s no recruiter UI yet (Phase A)? | Engineering | CLI tool — Calibre writes verdicts to a JSON file weekly; live inference reads that file. |
Annex 3 Traceability
| Annex 3 clause | This plan |
|---|---|
| AD2 “Conditional Mapping Logic engine (recruiter-validated rules)“ | This entire plan. The ratified rules file is the audit artifact. |
| AD2 “AI Scoring Engine (weighted, vertical-specific criteria)“ | The scope predicate makes rules sub-function- and sub-vertical-aware. |
| Annex 4 “marketable to other Headhunting Agencies” | Per-tenant ruleset isolation supports white-labelling for other agencies in Step 10. |
Next Step
Calibre + the Calibre team scaffold-review session. Walk through the 10–15 scaffold rules, tune deltas, add 3–5 of their own from memory. Then invoke writing-plans to produce the implementation plan.