Step 6.1 — Rubric Bootstrap Extension Implementation Plan
Source: plans/step-6-phase-1-rubric-bootstrap-impl.md · synced 2026-06-12
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Step coverage: Step 3.1 (Stage 1 prompt extension) · Step 5.3 (hard-gate + boost/penalty rule scaffolds) · Step 6.1 (22 per-sub-vertical rubric configs).
Design: amendment-3.1+5.3+6.1-bootstrap-rubric-extension.md
Extends: step-3.1-vocab-bootstrap-impl.md — same harness, same branch, same test convention.
Repo: calibre-app/ (branch feat/vocab-bootstrap already cut; this plan adds commits on top).
Outputs (Stage 4 candidates): calibre-app/config/tag-vocab.candidate.json (already shipped) + reference/expert-rules/hard-gates.candidate.json + reference/expert-rules/boost-penalty.candidate.json + reference/rubric-configs/{sub-function}.{sub-vertical}.candidate.json × 22.
Outputs (Stage 5 ratified): strip .candidate → canonical paths. Audit log per artefact.
Goal: Extend the existing Step 3.1 harness so a single live mine emits three artefact families instead of one. Ratify all three in one Dan + Jon session. This unblocks Step 3.2, Step 5.2, Step 5.3, and Step 6.1 in parallel — see amendment §7.
Architecture (recap — full version in amendment §3): Stage 1 prompt extended to also pull stated_must_haves, stated_nice_to_haves, stated_disqualifiers, stated_seniority_floor, stated_years_floor, and recurring_hard_rules per JD. Stage 2 unchanged. Stages 3b and 3c are new sibling Claude tool-use calls per cell. Stage 4 is extended to emit two new artefact families. Stage 5 is the new three-worksheet ratification CLI.
Tech Stack: Unchanged from Step 3.1 — TypeScript 5 (strict) · Next.js 16 / Node 20 · @anthropic-ai/sdk 0.52 · Vitest 3.1 · tsx 4.19. No new dependencies.
Cost & scale: Mine fires from same orchestrator; extended prompt adds ~+0.5 USD per JD vs Step 3.1. ~41 JD calls + ~20 Stage 3a calls + ~20 Stage 3b calls + ~22 Stage 3c calls ≈ ~103 Claude calls total. Estimate USD 20–40. Wallclock ~30–45 min mine + ~3–4 h build + ~2 h Dan + Jon ratification.
Code location anchor: the vocab-bootstrap modules live at calibre-app/src/engine/step-3-normalise/ (not src/lib/vocab-bootstrap/ — that path was renamed in the Step 3.1 build). Tests live at calibre-app/tests/engine/step-3-normalise/. All paths in this plan use the current locations.
File Structure (delta vs Step 3.1)
calibre-app/
├── src/engine/step-3-normalise/
│ ├── types.ts # EXTEND: add JD stated-* fields · HardRuleCandidate · SignalCandidate · ExpertRule · RubricConfig · Signal · TitleNorm
│ ├── jd-term-extractor.ts # EXTEND Stage 1: add 6 new fields to tool schema + prompt
│ ├── rule-clusterer.ts # NEW Stage 3b: cluster recurring binary patterns → ExpertRule[]
│ ├── signal-clusterer.ts # NEW Stage 3c: cluster signal candidates per cell → Signal[]
│ ├── rubric-emitter.ts # NEW Stage 4c: signals + frequency → RubricConfig (one cell)
│ ├── ratified-applier.ts # NEW: apply ratification actions to expert-rules + rubric-configs (mirrors locked-vocab.ts)
│ └── index.ts # EXTEND: re-export new modules
├── scripts/
│ ├── run-vocab-bootstrap.ts # EXTEND: after Stage 3a/4a, run Stage 3b/4b and Stage 3c/4c; write 3 new artefact families
│ ├── review-vocab-candidate.ts # UNCHANGED — still drives worksheet 1 (vocab) only
│ └── review-rubric-bootstrap.ts # NEW: drives worksheets 2 (expert rules) + 3 (22 rubrics) sequentially, then promotes + archives
├── config/
│ └── tag-vocab.candidate.json # UNCHANGED location (Step 3.1 output)
└── tests/engine/step-3-normalise/
├── jd-term-extractor.test.ts # EXTEND: assert new 6 fields in tool response
├── rule-clusterer.test.ts # NEW
├── signal-clusterer.test.ts # NEW
├── rubric-emitter.test.ts # NEW
├── ratified-applier.test.ts # NEW
└── orchestration.test.ts # EXTEND: assert 3-artefact wiring
reference/
├── expert-rules/ # NEW dir
│ ├── hard-gates.candidate.json # Stage 4b output
│ ├── hard-gates.json # Stage 5 ratified output
│ ├── hard-gates.audit.json # Stage 5 audit log
│ ├── boost-penalty.candidate.json # Stage 4b output
│ ├── boost-penalty.json # Stage 5 ratified output
│ └── boost-penalty.audit.json # Stage 5 audit log
└── rubric-configs/
├── README.md # UNCHANGED (already updated to point at amendment plan)
├── {sub-function}.{sub-vertical}.candidate.json # Stage 4c × 22
├── {sub-function}.{sub-vertical}.json # Stage 5 ratified × 22
└── rubric-configs.audit.json # Stage 5 audit log (one shared log for all 22)The 4 grant-vertical rubric stubs (commodities.json, maritime.json, offshore.json, tech-gaming.json) were already archived 2026-06-11 to _archive/2026-06-11-rubric-stubs-4-vertical/. No backwards-compatibility shim is built — see amendment §2 row “Output file paths”.
Conventions (read once, follow throughout)
-
Same conventions as Step 3.1 impl plan §“Conventions” — strict TS, no
any, Vitest,@/alias forsrc/, Claude tool-use shape mirrors cv-parser pattern, frequent commits, all work onfeat/vocab-bootstrapbranch. -
Sub-function + sub-vertical IDs — use the canonical 22-cell enumeration from SOURCE-OF-TRUTH.md §3b:
sub_function sub_verticals cells cargo-opsagri,energy,metals3 ship-opstanker,drybulk,container,offshore-support,broker5 risk-analyticscredit,market,operational,product-controller,trading-analytics5 finance-form,wealth-mgmt,institutional-sales,portfolio-mgmt,trading5 ops-back-officeaml,compliance,assistant-rm,finance-ops4 Total 22 -
Task numbering continues from Step 3.1 — Step 3.1 impl plan ended at Task 12. This plan starts at Task 13 and runs through Task 24.
-
reference/expert-rules/andreference/rubric-configs/live at the workspace root (outsidecalibre-app/). The orchestrator + ratification CLI usepath.resolve(__dirname, '../../reference/...')— mirrors howrun-vocab-bootstrap.tsalready resolvesreference/JD/.
Task 13 — Extend types module for the 3-artefact mine
Files:
-
Modify:
calibre-app/src/engine/step-3-normalise/types.ts -
Test: none (pure type module)
-
Step 1: Add the new types
Append to types.ts:
// ------------------------------------------------------------------
// Step 6.1 / Step 5.3 — extended Stage 1 JD fields
// ------------------------------------------------------------------
/**
* Hard-rule candidate observed in a JD — used by Stage 3b to cluster into ExpertRule[].
*/
export interface HardRuleCandidate {
pattern_kind:
| 'geo_must_be_in'
| 'geo_must_not_be_in'
| 'employer_must_be_in'
| 'employer_must_not_be_in'
| 'cert_required'
| 'language_required'
| 'tenure_floor_years'
| 'custom'
values: string[] // e.g. ['Singapore'] · ['Vitol', 'Trafigura'] · ['HSSE']
raw_quote: string // verbatim JD text that triggered the inference
}
/**
* Stage 1 JD-side fields added on top of Step 3.1's DocTerms.
*/
export interface JDStatedFields {
stated_must_haves: string[] // explicit "Required:" / "Must have:" bullets
stated_nice_to_haves: string[] // explicit "Preferred:" / "Bonus:" bullets
stated_disqualifiers: string[] // explicit "Not eligible:" / "Not suitable:" bullets
stated_seniority_floor: string | null // e.g. "Senior", "5+ years"
stated_years_floor: number | null
recurring_hard_rules: HardRuleCandidate[]
}
/**
* Extended DocTerms — same as Step 3.1 DocTerms plus JDStatedFields (JD only; null for CVs).
*/
export interface DocTermsExtended extends DocTerms {
jd_stated?: JDStatedFields // present only when source_kind === 'jd'
}
// ------------------------------------------------------------------
// Step 5.3 — Expert rules (mirrors plans/step-5.3-industry-filter-design.md §Rule Schema)
// ------------------------------------------------------------------
export type RuleCategory =
| 'geography' | 'competitor' | 'pedigree' | 'career_shape'
| 'certification' | 'language' | 'compensation' | 'custom'
export type RuleScope =
| { applies_to: 'all' }
| { applies_to: 'sub_function'; sub_function: SubFunction }
| { applies_to: 'sub_vertical'; sub_function: SubFunction; sub_vertical: SubVertical }
| { applies_to: 'tag'; tag: string }
| { applies_to: 'jd_match'; jd_pattern: string }
export 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[] }
export type RuleEffect =
| { kind: 'drop'; reason_code: string }
| { kind: 'block_at_l2'; reason_code: string }
| { kind: 'boost'; delta: number; reason_code: string }
| { kind: 'penalty'; delta: number; reason_code: string }
| { kind: 'require_evidence'; evidence_field: string }
export interface ExpertRule {
id: string // ULID — generated at emit time
category: RuleCategory
scope: RuleScope
trigger: TriggerPredicate
effect: RuleEffect
rationale: string
ratified_by?: string // populated at Stage 5
ratified_at?: string
source: 'scaffold' | 'live_inference' | 'manual'
active: boolean // default true
source_docs: string[] // JDs the rule was inferred from (audit trail)
observed_in_pct: number // 0–1, share of JDs in scope that surfaced this pattern
}
export interface ExpertRulesFile {
version: string // 'bootstrap-YYYY-MM-DD' or 'ratified-YYYY-MM-DD'
tenant_id: string // 'calibre'
generated_from: { run_id: string; cv_count: number; jd_count: number }
rules: ExpertRule[]
}
// ------------------------------------------------------------------
// Step 6.1 — per-sub-vertical rubric configs
// ------------------------------------------------------------------
export interface Signal {
type: 'tag' | 'keyword' | 'certification' | 'employer_class' | 'geography' | 'language'
values: string[]
weight: number // 1–5 (relative within signal kind)
source_docs: string[]
observed_in_pct: number // 0–1
}
export interface TitleNorm {
from_pattern: string // regex
to_canonical: string
seniority_signal?: 'Junior' | 'Mid' | 'Senior' | 'Lead' | 'Head'
}
export interface RubricScoringWeights {
sub_function_match: number // default 0.20
tag_overlap: number // default 0.30
seniority_delta: number // default 0.20
years_experience: number // default 0.15
red_flag_penalty: number // default 0.15
}
export interface RubricConfig {
sub_function: SubFunction
sub_vertical: SubVertical
display_name: string
version: string // 'bootstrap-YYYY-MM-DD' or 'ratified-YYYY-MM-DD'
generated_from: { run_id: string; cv_count_in_cell: number; jd_count_in_cell: number }
must_have_signals: Signal[]
nice_to_have_signals: Signal[]
disqualifying_signals: Signal[]
title_normalisations: TitleNorm[]
scoring_weights: RubricScoringWeights // must sum to 1.0
minimum_pass_score: number // default 4.0
status: 'bootstrap-draft' | 'ratified' | 'deprecated'
ratified_by?: string
ratified_at?: string
notes?: string // free text — thin-data warnings, merges, deferrals
}
// ------------------------------------------------------------------
// Stage 5 — ratification entries for the 2 new artefact families
// ------------------------------------------------------------------
export interface RuleAuditEntry {
timestamp: string
rule_id: string // ULID from candidate
action: 'approve' | 'tune_delta' | 'tune_trigger' | 'reject' | 'add'
resolved_rule?: ExpertRule // present for approve / tune / add
reason: string
reviewer: string
}
export interface RubricAuditEntry {
timestamp: string
sub_function: SubFunction
sub_vertical: SubVertical
target: 'signal' | 'weight' | 'cell'
action:
| 'approve_signal' | 'move_to_must' | 'move_to_nice' | 'move_to_disqualifying'
| 'drop_signal' | 'add_signal'
| 'adjust_weight' | 'approve_thin' | 'merge_cells' | 'defer_cell'
payload: unknown // shape varies by action — typed at point of use
reason: string
reviewer: string
}
export interface RuleAuditLog { version: string; entries: RuleAuditEntry[] }
export interface RubricAuditLog { version: string; entries: RubricAuditEntry[] }- Step 2: Verify it compiles
Run: npx tsc --noEmit
Expected: PASS.
- Step 3: Commit
git add src/engine/step-3-normalise/types.ts
git commit -m "feat(rubric-bootstrap): extend types for 3-artefact mine (Step 6.1 Task 13)"Task 14 — Extend Stage 1: JD term extractor pulls 6 new fields
Extend jdToDocTerms to populate JDStatedFields alongside the existing categorical/tools/cert/geo/seniority/technical lists. One Claude call per JD — the new fields ride the same tool-use response. Acceptance: extended tool schema validates against a canned response; absent fields default to [] / null.
Files:
-
Modify:
calibre-app/src/engine/step-3-normalise/jd-term-extractor.ts -
Modify:
calibre-app/tests/engine/step-3-normalise/jd-term-extractor.test.ts -
Step 1: Extend the failing test first
Open tests/engine/step-3-normalise/jd-term-extractor.test.ts. Add a new test case below the existing 'returns DocTerms with source_kind=jd ...' test:
it('extracts the 6 extended JD-stated fields when present', async () => {
const fakeClient = {
messages: {
create: vi.fn().mockResolvedValue({
content: [{
type: 'tool_use', name: 'extract_jd_terms',
input: {
// Existing Step 3.1 fields
categorical_terms: ['LNG'], tools_and_systems: [], certifications: ['HSSE'],
geo_signals: ['Singapore'], seniority_indicators: ['Senior'], technical_knowledge: [],
// New Step 6.1 fields
stated_must_haves: ['5+ years LNG ops', 'HSSE cert'],
stated_nice_to_haves: ['Mandarin'],
stated_disqualifiers: ['No tanker-only background'],
stated_seniority_floor: 'Senior',
stated_years_floor: 5,
recurring_hard_rules: [
{ pattern_kind: 'geo_must_be_in', values: ['Singapore'], raw_quote: 'Singapore-based only' },
{ pattern_kind: 'cert_required', values: ['HSSE'], raw_quote: 'HSSE certification mandatory' },
],
},
}],
}),
},
} as unknown as import('@anthropic-ai/sdk').default
const result = await jdToDocTerms(
fakeClient, 'sample JD text', 'reference/JD/.../JD for LNG Operator.pdf',
'cargo-ops', 'energy'
)
expect(result.jd_stated).toBeDefined()
expect(result.jd_stated!.stated_must_haves).toEqual(['5+ years LNG ops', 'HSSE cert'])
expect(result.jd_stated!.stated_years_floor).toBe(5)
expect(result.jd_stated!.recurring_hard_rules).toHaveLength(2)
expect(result.jd_stated!.recurring_hard_rules[0].pattern_kind).toBe('geo_must_be_in')
})
it('defaults extended fields to empty / null when Claude returns nothing for them', async () => {
const fakeClient = {
messages: {
create: vi.fn().mockResolvedValue({
content: [{
type: 'tool_use', name: 'extract_jd_terms',
input: {
categorical_terms: [], tools_and_systems: [], certifications: [],
geo_signals: [], seniority_indicators: [], technical_knowledge: [],
stated_must_haves: [], stated_nice_to_haves: [], stated_disqualifiers: [],
stated_seniority_floor: null, stated_years_floor: null, recurring_hard_rules: [],
},
}],
}),
},
} as unknown as import('@anthropic-ai/sdk').default
const result = await jdToDocTerms(fakeClient, 'x', 'p', 'cargo-ops', 'energy')
expect(result.jd_stated!.stated_must_haves).toEqual([])
expect(result.jd_stated!.stated_seniority_floor).toBeNull()
expect(result.jd_stated!.recurring_hard_rules).toEqual([])
})Run: npx vitest run tests/engine/step-3-normalise/jd-term-extractor.test.ts
Expected: FAIL (jd_stated is undefined; tool schema rejects new fields).
- Step 2: Extend the tool schema + prompt + return shape
Edit src/engine/step-3-normalise/jd-term-extractor.ts:
-
Inside
JD_TERM_TOOL.input_schema.properties, add 6 new entries:stated_must_haves: { type: 'array', items: { type: 'string' }, description: 'Explicit "Required:" / "Must have:" bullets. Verbatim.' }stated_nice_to_haves: { type: 'array', items: { type: 'string' }, description: 'Explicit "Preferred:" / "Bonus:" / "Nice to have:" bullets.' }stated_disqualifiers: { type: 'array', items: { type: 'string' }, description: 'Explicit "Not eligible:" / "Not suitable for:" bullets.' }stated_seniority_floor: { type: ['string', 'null'], description: 'Required seniority level: "Junior" / "Mid" / "Senior" / "Lead" / "Head" / "Director" or null.' }stated_years_floor: { type: ['number', 'null'], description: 'Minimum years of experience required, or null.' }recurring_hard_rules: { type: 'array', items: { type: 'object', required: ['pattern_kind', 'values', 'raw_quote'], properties: { pattern_kind: { type: 'string', enum: ['geo_must_be_in', 'geo_must_not_be_in', 'employer_must_be_in', 'employer_must_not_be_in', 'cert_required', 'language_required', 'tenure_floor_years', 'custom'] }, values: { type: 'array', items: { type: 'string' } }, raw_quote: { type: 'string' } } } }
-
Add all 6 keys to
input_schema.required. -
Update
SYSTEM_PROMPT— append:Also extract stated must-haves, nice-to-haves, disqualifiers, seniority floor, years floor, and recurring hard rules. Hard rules are binary patterns the JD enforces — geographic restrictions, employer blacklists/whitelists, certification requirements, language requirements, tenure floors. Quote the JD verbatim in raw_quote. -
Update the
inputcast insidejdToDocTermsto include the 6 new fields with their TypeScript types. -
Update the return statement to include
jd_stated: { stated_must_haves: input.stated_must_haves, stated_nice_to_haves: input.stated_nice_to_haves, stated_disqualifiers: input.stated_disqualifiers, stated_seniority_floor: input.stated_seniority_floor, stated_years_floor: input.stated_years_floor, recurring_hard_rules: input.recurring_hard_rules }. -
Change the return type from
DocTermstoDocTermsExtendedand update the import.
- Step 3: Run, watch pass
Run: npx vitest run tests/engine/step-3-normalise/jd-term-extractor.test.ts
Expected: PASS, all original tests + 2 new tests green.
- Step 4: Commit
git add src/engine/step-3-normalise/jd-term-extractor.ts tests/engine/step-3-normalise/jd-term-extractor.test.ts
git commit -m "feat(rubric-bootstrap): extend Stage 1 JD prompt with 6 stated fields (Step 6.1 Task 14)"Task 15 — Stage 3b — Rule clusterer (recurring binary patterns → ExpertRule[])
Cluster recurring_hard_rules[] + stated_disqualifiers[] from every JD in the cell into ExpertRule[]. One Claude tool-use call per cell. Split by effect.kind is mechanical post-Claude — clusterer just returns the full rule set.
Files:
-
Create:
calibre-app/src/engine/step-3-normalise/rule-clusterer.ts -
Create:
calibre-app/tests/engine/step-3-normalise/rule-clusterer.test.ts -
Step 1: Write the failing test
Create tests/engine/step-3-normalise/rule-clusterer.test.ts:
import { describe, it, expect, vi } from 'vitest'
import { clusterRules } from '@/engine/step-3-normalise/rule-clusterer'
import type { DocTermsExtended } from '@/engine/step-3-normalise/types'
describe('clusterRules', () => {
it('returns ExpertRule[] for one cell, split-able by effect.kind', async () => {
const jdDocs: DocTermsExtended[] = [
{
source_file: 'jd-1.pdf', source_kind: 'jd', sub_function: 'cargo-ops',
sub_verticals: ['energy'], categorical_terms: [], tools_and_systems: [],
certifications: [], geo_signals: [], seniority_indicators: [], technical_knowledge: [],
jd_stated: {
stated_must_haves: ['HSSE cert'], stated_nice_to_haves: [],
stated_disqualifiers: ['No tanker-only candidates'],
stated_seniority_floor: null, stated_years_floor: null,
recurring_hard_rules: [
{ pattern_kind: 'geo_must_be_in', values: ['Singapore'], raw_quote: 'SG only' },
{ pattern_kind: 'cert_required', values: ['HSSE'], raw_quote: 'HSSE mandatory' },
],
},
},
]
const fakeClient = {
messages: {
create: vi.fn().mockResolvedValue({
content: [{
type: 'tool_use', name: 'cluster_expert_rules',
input: {
rules: [
{
category: 'geography', scope: { applies_to: 'sub_vertical', sub_function: 'cargo-ops', sub_vertical: 'energy' },
trigger: { type: 'cv_location_not_in', values: ['Singapore'] },
effect: { kind: 'penalty', delta: -1.5, reason_code: 'non-sg' },
rationale: 'energy ops require SG residency',
source_docs: ['jd-1.pdf'], observed_in_pct: 1.0,
},
{
category: 'certification', scope: { applies_to: 'sub_vertical', sub_function: 'cargo-ops', sub_vertical: 'energy' },
trigger: { type: 'cv_missing_certification', cert: 'HSSE' },
effect: { kind: 'block_at_l2', reason_code: 'no-hsse' },
rationale: 'LNG roles need HSSE',
source_docs: ['jd-1.pdf'], observed_in_pct: 1.0,
},
],
},
}],
}),
},
} as unknown as import('@anthropic-ai/sdk').default
const rules = await clusterRules(fakeClient, 'cargo-ops', 'energy', jdDocs)
expect(rules).toHaveLength(2)
expect(rules.every(r => typeof r.id === 'string' && r.id.length > 0)).toBe(true) // ULIDs assigned
expect(rules.every(r => r.source === 'live_inference')).toBe(true)
expect(rules.every(r => r.active === true)).toBe(true)
expect(rules.filter(r => r.effect.kind === 'block_at_l2')).toHaveLength(1)
})
it('returns [] when the cell has no recurring_hard_rules and no stated_disqualifiers', async () => {
const jdDocs: DocTermsExtended[] = [{
source_file: 'jd-empty.pdf', source_kind: 'jd', sub_function: 'cargo-ops',
sub_verticals: ['energy'], categorical_terms: [], tools_and_systems: [],
certifications: [], geo_signals: [], seniority_indicators: [], technical_knowledge: [],
jd_stated: {
stated_must_haves: [], stated_nice_to_haves: [], stated_disqualifiers: [],
stated_seniority_floor: null, stated_years_floor: null, recurring_hard_rules: [],
},
}]
const fakeClient = { messages: { create: vi.fn() } } as unknown as import('@anthropic-ai/sdk').default
const rules = await clusterRules(fakeClient, 'cargo-ops', 'energy', jdDocs)
expect(rules).toEqual([])
expect(fakeClient.messages.create).not.toHaveBeenCalled() // skip Claude call when no input
})
})- Step 2: Run, watch fail
Run: npx vitest run tests/engine/step-3-normalise/rule-clusterer.test.ts
Expected: FAIL — module missing.
- Step 3: Implement
Create src/engine/step-3-normalise/rule-clusterer.ts — a Claude tool-use module that mirrors clusterer.ts. Tool name: cluster_expert_rules. Input schema: array of rule objects matching the ExpertRule shape minus id / ratified_* / active / source. Post-Claude: assign ULID id, set source: 'live_inference', active: true. Short-circuit and return [] if no JD in the cell has recurring_hard_rules.length > 0 AND no stated_disqualifiers.length > 0.
Reference the existing clusterer.ts for the tool-use + schema pattern. Use crypto.randomUUID() for the ULID (acceptable substitute — no new dep).
System prompt seed: You are encoding recruiter heuristics as deterministic rules. Given the binary patterns this JD set enforces, cluster them into a minimal ExpertRule set. Aim for 5–10 rules per cell. Each rule must trace to ≥1 JD via source_docs. Include the 10 example rules from step-5.3-industry-filter-design.md §Example Rules verbatim in the user message as exemplars.
- Step 4: Run, watch pass
Run: npx vitest run tests/engine/step-3-normalise/rule-clusterer.test.ts
Expected: PASS, 2/2.
- Step 5: Commit
git add src/engine/step-3-normalise/rule-clusterer.ts tests/engine/step-3-normalise/rule-clusterer.test.ts
git commit -m "feat(rubric-bootstrap): Stage 3b rule clusterer (Step 6.1 Task 15)"Task 16 — Stage 3c — Signal clusterer (signal candidates per cell)
Cluster stated_must_haves[] + stated_nice_to_haves[] + stated_disqualifiers[] per cell into proposed Signal[]. One Claude tool-use call per cell. Output is three lists: must / nice / disqualifying. Frequency calculation happens deterministically in Task 17 — this task only produces the signal proposals.
Files:
-
Create:
calibre-app/src/engine/step-3-normalise/signal-clusterer.ts -
Create:
calibre-app/tests/engine/step-3-normalise/signal-clusterer.test.ts -
Step 1: Write the failing test
Create tests/engine/step-3-normalise/signal-clusterer.test.ts. Cover:
- Returns
{ must_have_signals, nice_to_have_signals, disqualifying_signals }for one cell from a fake Claude response containing 2 signals per tier. - Asserts
Signal.type∈ the locked enum (tag·keyword·certification·employer_class·geography·language). - Skips Claude call and returns three empty arrays when the cell’s JDs surface zero stated_* entries.
Pattern: mirror the test shape from clusterer.test.ts and the new rule-clusterer.test.ts.
- Step 2: Implement
Create src/engine/step-3-normalise/signal-clusterer.ts. Tool name: cluster_cell_signals. Tool schema: three array properties (must_have_signals, nice_to_have_signals, disqualifying_signals), each item carries { type, values, source_docs }. Weight and observed_in_pct are computed downstream (Task 17), NOT in this Claude call — leave them out of the schema. Short-circuit + return three []s when input is empty.
System prompt seed: You are a recruitment-rubric extractor. Given JD-stated must-haves, nice-to-haves, and disqualifiers for one (sub_function, sub_vertical) cell, propose Signal[] for each tier. Each Signal groups synonymous JD statements into a typed cluster (tag / keyword / certification / employer_class / geography / language). Drop noise. Aim for 4–10 must, 3–8 nice, 1–5 disqualifying per cell.
- Step 3: Run, watch pass
Run: npx vitest run tests/engine/step-3-normalise/signal-clusterer.test.ts
Expected: PASS, all tests green.
- Step 4: Commit
git add src/engine/step-3-normalise/signal-clusterer.ts tests/engine/step-3-normalise/signal-clusterer.test.ts
git commit -m "feat(rubric-bootstrap): Stage 3c signal clusterer (Step 6.1 Task 16)"Task 17 — Rubric emitter (Stage 4c — Signal[] + frequency → RubricConfig)
Pure function. Takes the Stage 3c output for one cell + the full JD list for that cell + the run metadata, and emits a RubricConfig. This is where observed_in_pct and weight get calculated deterministically. Per amendment §4c bootstrap-weight rule:
- Signals with
observed_in_pct ≥ 0.7→must_have_signalswithweight = 5. - Signals with
0.3 ≤ observed_in_pct < 0.7→nice_to_have_signalswithweight = round(2 + 3*(observed_in_pct - 0.3) / 0.4)(i.e. 2–3 proportional). - Below 0.3 → discarded.
Disqualifying signals always land in disqualifying_signals regardless of frequency (Dan rescues at ratification).
Default scoring_weights and minimum_pass_score come from amendment §4c schema:
{ sub_function_match: 0.20, tag_overlap: 0.30, seniority_delta: 0.20, years_experience: 0.15, red_flag_penalty: 0.15 } and minimum_pass_score: 4.0. Defaults match pipeline-1.1+2.1+3.2+6.1+7.1+7.2+8.1-matching-engine-design.md §Layer 1.
title_normalisations are populated empty ([]) at bootstrap — Dan adds during ratification.
Files:
-
Create:
calibre-app/src/engine/step-3-normalise/rubric-emitter.ts -
Create:
calibre-app/tests/engine/step-3-normalise/rubric-emitter.test.ts -
Step 1: Write the failing test
Create tests/engine/step-3-normalise/rubric-emitter.test.ts. Cover:
- Frequency promotion — signal observed in 8/10 JDs →
must_have_signalswithweight === 5. - Frequency middle band — signal in 4/10 JDs →
nice_to_have_signalswithweight === 2(round(2 + 3*0.1/0.4) = 3— adjust expectation to match the formula precisely). - Frequency floor — signal in 2/10 JDs → dropped.
- Disqualifying signals copied through regardless of frequency.
- Default
scoring_weightssum to exactly 1.0. display_namederived from the(sub_function, sub_vertical)lookup table.generated_from.jd_count_in_cellandcv_count_in_cellpopulated from input args.status === 'bootstrap-draft'andversionmatchesbootstrap-YYYY-MM-DD.
- Step 2: Implement
Create src/engine/step-3-normalise/rubric-emitter.ts. Pure function emitRubric(args: { run_id, sub_function, sub_vertical, jds, cvs_in_cell, signals: { must, nice, disqualifying } }): RubricConfig. Include a DISPLAY_NAME_MAP constant for all 22 cells (e.g. 'cargo-ops.energy' → 'Cargo Operations — Energy (LNG/LPG/Crude/Petrochem)'). Source the strings from amendment §4c examples + SOURCE-OF-TRUTH.md §3a/§3b.
- Step 3: Run, watch pass
Run: npx vitest run tests/engine/step-3-normalise/rubric-emitter.test.ts
Expected: PASS, all 8 tests.
- Step 4: Commit
git add src/engine/step-3-normalise/rubric-emitter.ts tests/engine/step-3-normalise/rubric-emitter.test.ts
git commit -m "feat(rubric-bootstrap): Stage 4c rubric emitter with frequency-weighted promotion (Step 6.1 Task 17)"Task 18 — Extend orchestrator: wire Stages 3b/3c/4b/4c into the mine
Modify scripts/run-vocab-bootstrap.ts. After Stage 3a/4a finishes (vocab candidate), run Stage 3b + Stage 3c per cell, then Stage 4b (split into hard-gates vs boost-penalty by effect.kind) and Stage 4c (one rubric per cell). Write all three new artefact families.
Files:
-
Modify:
calibre-app/scripts/run-vocab-bootstrap.ts -
Modify:
calibre-app/src/engine/step-3-normalise/index.ts -
Step 1: Re-export new modules
Edit src/engine/step-3-normalise/index.ts — add:
export { clusterRules } from './rule-clusterer'
export { clusterSignals } from './signal-clusterer'
export { emitRubric } from './rubric-emitter'- Step 2: Extend the orchestrator
In scripts/run-vocab-bootstrap.ts:
-
Add path constants:
const EXPERT_RULES_DIR = path.resolve(__dirname, '../../reference/expert-rules') const RUBRICS_DIR = path.resolve(__dirname, '../../reference/rubric-configs')and
fs.mkdirSync(EXPERT_RULES_DIR, { recursive: true })/ same for RUBRICS_DIR at the top ofmain(). -
Generate a
run_idviacrypto.randomUUID()at the top ofmain(). Thread it through Stages 3b/3c/4b/4c. -
After the existing Stage 3 loop (which currently produces
candidateCells: CellVocabCandidate[]), add a parallel loop per cell:const allRules: ExpertRule[] = [] const rubrics: RubricConfig[] = [] for (const bundle of cells) { const cellJDs = allDocs.filter(d => d.source_kind === 'jd' && d.sub_function === bundle.sub_function && d.sub_verticals.includes(bundle.sub_vertical)) const cellCVs = allDocs.filter(d => d.source_kind === 'cv' && d.sub_function === bundle.sub_function && d.sub_verticals.includes(bundle.sub_vertical)) // Stage 3b try { const rules = await vb.clusterRules(client, bundle.sub_function, bundle.sub_vertical, cellJDs) allRules.push(...rules) } catch (err) { console.error(` 3b ✗ ${bundle.sub_function}/${bundle.sub_vertical}: ${(err as Error).message}`) } // Stage 3c try { const sigs = await vb.clusterSignals(client, bundle.sub_function, bundle.sub_vertical, cellJDs) const rubric = vb.emitRubric({ run_id, sub_function: bundle.sub_function, sub_vertical: bundle.sub_vertical, jds: cellJDs, cvs_in_cell: cellCVs.length, signals: sigs, }) rubrics.push(rubric) } catch (err) { console.error(` 3c ✗ ${bundle.sub_function}/${bundle.sub_vertical}: ${(err as Error).message}`) } } -
Add Stage 4b: split
allRulesbyeffect.kindand write two files:const hardGates: ExpertRulesFile = { version: `bootstrap-${dateStr}`, tenant_id: 'calibre', generated_from: { run_id, cv_count: cvDocs.length, jd_count: jdDocs.length }, rules: allRules.filter(r => r.effect.kind === 'drop' || r.effect.kind === 'block_at_l2'), } const boostPenalty: ExpertRulesFile = { version: `bootstrap-${dateStr}`, tenant_id: 'calibre', generated_from: { run_id, cv_count: cvDocs.length, jd_count: jdDocs.length }, rules: allRules.filter(r => ['boost', 'penalty', 'require_evidence'].includes(r.effect.kind)), } fs.writeFileSync(path.join(EXPERT_RULES_DIR, 'hard-gates.candidate.json'), JSON.stringify(hardGates, null, 2)) fs.writeFileSync(path.join(EXPERT_RULES_DIR, 'boost-penalty.candidate.json'), JSON.stringify(boostPenalty, null, 2)) -
Add Stage 4c: write one file per rubric:
for (const rubric of rubrics) { const file = `${rubric.sub_function}.${rubric.sub_vertical}.candidate.json` fs.writeFileSync(path.join(RUBRICS_DIR, file), JSON.stringify(rubric, null, 2)) } -
Update the final summary log:
console.log(${rubrics.length} rubrics, ${allRules.length} rules (${hardGates.rules.length} hard + ${boostPenalty.rules.length} boost/penalty)).
- Step 3: Type-check (cannot run live yet — that’s Task 23)
Run: npx tsc --noEmit
Expected: PASS.
- Step 4: Commit
git add scripts/run-vocab-bootstrap.ts src/engine/step-3-normalise/index.ts
git commit -m "feat(rubric-bootstrap): orchestrator wires Stages 3b/3c/4b/4c (Step 6.1 Task 18)"Task 19 — Ratified applier (apply ratification actions to expert rules + rubrics)
Pure-function module mirroring locked-vocab.ts. Two entry points: applyRuleActions(candidate, actions, reviewer) and applyRubricActions(candidates, actions, reviewer). Returns ratified files + audit logs.
Files:
-
Create:
calibre-app/src/engine/step-3-normalise/ratified-applier.ts -
Create:
calibre-app/tests/engine/step-3-normalise/ratified-applier.test.ts -
Step 1: Write the failing test
Create tests/engine/step-3-normalise/ratified-applier.test.ts. Cover for rules:
approve— rule copied through withratified_by+ratified_atset.tune_delta— boost/penalty rule’seffect.deltareplaced; rationale appended.tune_trigger— trigger predicate replaced.reject— rule absent from ratified file; entry persists in audit.add— new rule appended with fresh ULID.
Cover for rubrics:
approve_signal— signal carried through to ratified file.move_to_must— signal moves from nice/disqualifying tier to must.move_to_nice— signal moves from must/disqualifying to nice.move_to_disqualifying— signal moves to disqualifying.drop_signal— signal removed from ratified cell.adjust_weight—scoring_weightsupdated; assert post-update sum still ≈1.0 (within 1e-9).approve_thin— cell carried through withnotes: 'thin data — accepted as growth area'.merge_cells— two cells consolidated; merged cell’snotesrecords the merge.defer_cell— cell absent from ratified output; audit records deferral.add_signal— new signal inserted into the named tier.
- Step 2: Implement
Create src/engine/step-3-normalise/ratified-applier.ts with applyRuleActions + applyRubricActions. Mirrors locked-vocab.ts structure (work map + switch on action kind + final emit + audit log).
- Step 3: Run, watch pass
Run: npx vitest run tests/engine/step-3-normalise/ratified-applier.test.ts
Expected: PASS, 15/15.
- Step 4: Commit
git add src/engine/step-3-normalise/ratified-applier.ts tests/engine/step-3-normalise/ratified-applier.test.ts
git commit -m "feat(rubric-bootstrap): ratification action appliers for rules + rubrics (Step 6.1 Task 19)"Task 20 — Ratification CLI (scripts/review-rubric-bootstrap.ts) — xlsx-driven batch mode
Batch CLI. Reads the marked-up xlsx workbook emitted by Task 23 (from outputs/rubric-ratification/ratification-YYYY-MM-DD.xlsx), parses every tab’s Decision + Tuned_value + Notes columns into action objects, then applies them via the appliers from Task 19. No interactive console row-walking. Worksheet 1 (vocab tab) is parsed here too — review-vocab-candidate.ts from Step 3.1 stays for the legacy interactive path but is bypassed when running the unified xlsx flow. After action application, writes .ratified.json siblings then promotes them by stripping .candidate from filenames + writing audit logs. Archive verification of the 4 grant-vertical stub files runs at the end (no-op confirmation they remain in _archive/ from 2026-06-11).
The applier surface (applyRuleActions + applyRubricActions) from Task 19 is unchanged — Task 20 only swaps the input source from interactive prompts to xlsx parsing. A third entry point applyVocabActions (from Step 3.1’s locked-vocab.ts) is invoked for the vocab tab.
See: amendment §5 Stage 5 — single session, three worksheets. Implementation surface differs (xlsx workbook end-to-end) but decision taxonomy per tab matches the amendment table verbatim.
Files:
- Create:
calibre-app/scripts/review-rubric-bootstrap.ts
(No interactive flow — Task 22 covers fixture-driven validation; live workbook validation runs at Task 24.)
- Step 1: Implement the CLI
Create scripts/review-rubric-bootstrap.ts. Skeleton:
import fs from 'fs'
import path from 'path'
import * as XLSX from 'xlsx'
// eslint-disable-next-line @typescript-eslint/no-require-imports
const vb = require('../src/engine/step-3-normalise') as typeof import('../src/engine/step-3-normalise')
const EXPERT_RULES_DIR = path.resolve(__dirname, '../../reference/expert-rules')
const RUBRICS_DIR = path.resolve(__dirname, '../../reference/rubric-configs')
const TAG_VOCAB_PATH = path.resolve(__dirname, '../config/tag-vocab.candidate.json')
interface CliArgs { input: string; reviewer: string }
function parseArgs(): CliArgs {
const args = process.argv.slice(2)
const get = (flag: string) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined }
const input = get('--input')
if (!input) throw new Error('Missing --input <path-to-ratification.xlsx>')
return { input, reviewer: get('--reviewer') ?? 'calibre-team' }
}
async function main() {
const { input, reviewer } = parseArgs()
const wb = XLSX.readFile(input)
// ─── Tab 1: tag-vocab ───
const vocabRows = XLSX.utils.sheet_to_json<Record<string, string>>(wb.Sheets['tag-vocab'])
const vocabCand = JSON.parse(fs.readFileSync(TAG_VOCAB_PATH, 'utf-8'))
const vocabActions = vb.parseVocabSheet(vocabRows, reviewer)
// ─── Tab 2: hard-gates + Tab 3: boost-penalty ───
const hardGatesCand = JSON.parse(fs.readFileSync(path.join(EXPERT_RULES_DIR, 'hard-gates.candidate.json'), 'utf-8'))
const boostCand = JSON.parse(fs.readFileSync(path.join(EXPERT_RULES_DIR, 'boost-penalty.candidate.json'), 'utf-8'))
const hardGateRows = XLSX.utils.sheet_to_json<Record<string, string>>(wb.Sheets['hard-gates'])
const boostRows = XLSX.utils.sheet_to_json<Record<string, string>>(wb.Sheets['boost-penalty'])
const ruleActions = vb.parseRuleSheets(hardGateRows, boostRows, reviewer)
// ─── Tabs 4–25: rubrics × 22 ───
const rubricFiles = fs.readdirSync(RUBRICS_DIR).filter(f => f.endsWith('.candidate.json'))
const rubricCands = rubricFiles.map(f => JSON.parse(fs.readFileSync(path.join(RUBRICS_DIR, f), 'utf-8')))
const rubricActions = vb.parseRubricSheets(wb, rubricCands, reviewer)
// Apply
const { vocabRatified, auditVocab } = vb.applyVocabActions(vocabCand, vocabActions, reviewer)
const allRules = [...hardGatesCand.rules, ...boostCand.rules]
const { hardGatesRatified, boostRatified, auditRules } = vb.applyRuleActions(allRules, ruleActions, reviewer)
const { rubricsRatified, auditRubrics } = vb.applyRubricActions(rubricCands, rubricActions, reviewer)
// Stage 5a — write .ratified.json siblings (for audit trail)
fs.writeFileSync(path.join(EXPERT_RULES_DIR, 'hard-gates.ratified.json'), JSON.stringify(hardGatesRatified, null, 2))
fs.writeFileSync(path.join(EXPERT_RULES_DIR, 'boost-penalty.ratified.json'), JSON.stringify(boostRatified, null, 2))
for (const r of rubricsRatified) {
fs.writeFileSync(path.join(RUBRICS_DIR, `${r.sub_function}.${r.sub_vertical}.ratified.json`), JSON.stringify(r, null, 2))
}
// Stage 5b — promote: strip .candidate / .ratified → canonical paths
fs.writeFileSync(path.resolve(__dirname, '../config/tag-vocab.json'), JSON.stringify(vocabRatified, null, 2))
fs.renameSync(path.join(EXPERT_RULES_DIR, 'hard-gates.ratified.json'), path.join(EXPERT_RULES_DIR, 'hard-gates.json'))
fs.renameSync(path.join(EXPERT_RULES_DIR, 'boost-penalty.ratified.json'), path.join(EXPERT_RULES_DIR, 'boost-penalty.json'))
for (const r of rubricsRatified) {
const src = path.join(RUBRICS_DIR, `${r.sub_function}.${r.sub_vertical}.ratified.json`)
const dst = path.join(RUBRICS_DIR, `${r.sub_function}.${r.sub_vertical}.json`)
fs.renameSync(src, dst)
}
// Audit logs
fs.writeFileSync(path.resolve(__dirname, '../config/tag-vocab.audit.json'), JSON.stringify(auditVocab, null, 2))
fs.writeFileSync(path.join(EXPERT_RULES_DIR, 'hard-gates.audit.json'), JSON.stringify({ ...auditRules, scope: 'hard-gates' }, null, 2))
fs.writeFileSync(path.join(EXPERT_RULES_DIR, 'boost-penalty.audit.json'), JSON.stringify({ ...auditRules, scope: 'boost-penalty' }, null, 2))
fs.writeFileSync(path.join(RUBRICS_DIR, 'rubric-configs.audit.json'), JSON.stringify(auditRubrics, null, 2))
// Stage 5c — archive verification (4 grant-vertical stubs)
const ARCHIVED_STUB_NAMES = ['commodities.json', 'maritime.json', 'offshore.json', 'tech-gaming.json']
for (const stub of ARCHIVED_STUB_NAMES) {
if (fs.existsSync(path.join(RUBRICS_DIR, stub))) {
console.warn(`⚠ legacy stub still at ${stub} — move to _archive/2026-06-11-rubric-stubs-4-vertical/ manually`)
}
}
// Stage 5d — summary table (per tab: approved / tuned / rejected / skipped)
printSummary({ vocabActions, ruleActions, rubricActions })
console.log(`\n✓ Ratified ${hardGatesRatified.rules.length} hard gates + ${boostRatified.rules.length} boost/penalty + ${rubricsRatified.length} rubrics`)
}
main().catch(err => { console.error(err); process.exit(1) })Decision-column parsing rules (mirror the dropdown values from Task 23):
- Tab
tag-vocab—approve/rename(useTuned_valueas new name) /merge(useTuned_valueas merge target) /reject. Blank cell → skipped (no action recorded). - Tabs
hard-gates+boost-penalty—approve/tune-delta(read numericTuned_valueforeffect.delta) /reject/replace-trigger(readTuned_valueas serialized predicate JSON). - Tabs
{sub-function}.{sub-vertical}× 22 — per-signal row:approve/move-to-must/move-to-nice/move-to-disq/drop. Weight header block rows (rows 1–8):Tuned_valueupdates the namedscoring_weightsfield; rationale captured inNotes.
Move the parser helpers (parseVocabSheet, parseRuleSheets, parseRubricSheets, printSummary) into src/engine/step-3-normalise/ratified-applier.ts next to the appliers so they share the action-type definitions. Re-export from index.ts.
- Step 2: Add npm script
Edit calibre-app/package.json "scripts":
"rubric-review": "tsx scripts/review-rubric-bootstrap.ts"Invocation: npm run rubric-review -- --input ../outputs/rubric-ratification/ratification-2026-06-12.xlsx --reviewer dan+jon.
- Step 3: Acceptance test — fixture xlsx in → canonical paths out
Create tests/engine/step-3-normalise/review-rubric-bootstrap.test.ts. Pattern: build an in-memory xlsx fixture using XLSX.utils.book_new() with 25 tabs and a small marked-up set of rows per tab, write it to os.tmpdir(), point the CLI at it, then assert:
config/tag-vocab.jsonmaterialises with only approved/renamed/merged tags (rejected tags absent).reference/expert-rules/hard-gates.json+boost-penalty.jsonmaterialise —effect.deltareflects anytune-deltaoverrides;replace-triggerrows show the new predicate.- Exactly 22 ratified rubric files written to
reference/rubric-configs/({sub-function}.{sub-vertical}.json) — none retain.candidate/.ratifiedsiblings. - Each weight-block override on a rubric tab lands in the corresponding
scoring_weightsfield; post-override sum still ≈1.0 within 1e-9. - Per-tab summary counts (
rows-approved / rows-tuned / rows-rejected / rows-skipped) match the fixture’s known marks. - Audit logs exist at
config/tag-vocab.audit.json+reference/expert-rules/{hard-gates,boost-penalty}.audit.json+reference/rubric-configs/rubric-configs.audit.jsonwith non-emptyentriesarrays.
Drive the CLI via await import('../../../scripts/review-rubric-bootstrap') with process.argv swapped; restore after.
Run: npx vitest run tests/engine/step-3-normalise/review-rubric-bootstrap.test.ts
Expected: PASS, 6/6.
- Step 4: Type-check
Run: npx tsc --noEmit
Expected: PASS.
- Step 5: Commit
git add scripts/review-rubric-bootstrap.ts src/engine/step-3-normalise/ratified-applier.ts src/engine/step-3-normalise/index.ts tests/engine/step-3-normalise/review-rubric-bootstrap.test.ts package.json
git commit -m "feat(rubric-bootstrap): xlsx-driven batch ratification CLI (Step 6.1 Task 20)"Task 21 — End-to-end orchestration test (mocked, no Claude)
Extend the existing tests/engine/step-3-normalise/orchestration.test.ts to assert the 3-artefact wiring. Mocked Claude responses for Stage 3a, 3b, 3c. Verify the orchestrator pure-function path emits the right shapes.
Files:
-
Modify:
calibre-app/tests/engine/step-3-normalise/orchestration.test.ts -
Step 1: Add a new
describeblock
Append a new describe('Stages 3b + 3c + 4b + 4c wiring') block. Mock the Anthropic client to return canned responses for cluster_expert_rules and cluster_cell_signals calls. Assert:
clusterRulesoutput flows into a hard-gates list + boost-penalty list split correctly byeffect.kind.clusterSignalsoutput flows intoemitRubricand produces aRubricConfigwith correctdisplay_name.- Final orchestrator state has
allRules.length > 0andrubrics.length === number of cells fed in.
- Step 2: Run the full suite — assert nothing regressed
Run: npm test
Expected: ALL 47 original tests + ~20 new tests (Tasks 14–19) pass.
- Step 3: Commit
git add tests/engine/step-3-normalise/orchestration.test.ts
git commit -m "test(rubric-bootstrap): Stages 3b+3c+4b+4c wiring (Step 6.1 Task 21)"Task 22 — Live mine + validation
Execute the extended pipeline. Inspect the three artefact families before handing off to Dan + Jon.
- Step 1: Pre-flight — confirm vocab artefact exists and JD inventory is 41
Run: ls calibre-app/config/tag-vocab.candidate.json && find reference/JD -type f \( -name "*.pdf" -o -name "*.docx" -o -name "*.doc" \) | wc -l
Expected: file exists; count 41.
- Step 2: Run the extended bootstrap
Run: cd calibre-app && npm run vocab-bootstrap 2>&1 | tee /tmp/rubric-bootstrap-run.log
Expected:
- Stage 1: same as Step 3.1 + per-JD prompt now extracts 6 new fields without errors.
- Stage 2: same 15–25 cells.
- Stage 3a: tag vocab candidate written (overwrites prior file).
- Stage 3b:
cluster_expert_rulescalled per cell that has anyrecurring_hard_rulesorstated_disqualifiers. Log showsN rulesper cell. - Stage 3c:
cluster_cell_signalscalled per cell. Log showsM signals (must=X, nice=Y, disqual=Z). - Stage 4: writes
reference/expert-rules/hard-gates.candidate.json,reference/expert-rules/boost-penalty.candidate.json, and 22 files atreference/rubric-configs/{sub-function}.{sub-vertical}.candidate.json.
Estimated wall-clock: 30–45 min. Cost: USD 20–40.
- Step 3: Validate cardinality + invariants
Create calibre-app/scripts/validate-rubric-candidates.ts (mirrors validate-candidate.ts from Step 3.1 Task 11). Check:
- Both expert-rules files contain only rules with the right
effect.kind(hard-gates ∈{drop, block_at_l2}; boost-penalty ∈{boost, penalty, require_evidence}). - Total rule count across both files: 30–60 (amendment §4b cardinality target).
- Exactly 22 rubric-config files exist, one per cell in SOURCE-OF-TRUTH.md §3b.
- Every rubric has
scoring_weightssumming to 1.0 within 1e-9. - Every rubric has
must_have_signals.length + nice_to_have_signals.length ≥ 1OR is flaggedcoverage_low: truevianotes. - Every signal in every rubric has
0 ≤ observed_in_pct ≤ 1,1 ≤ weight ≤ 5, andsource_docs.length ≥ 1.
Run: tsx scripts/validate-rubric-candidates.ts
Expected: 0 errors. Warnings acceptable for thin cells (finance-fo, ops-back-office).
- Step 4: Commit the live candidates + validator
git add scripts/validate-rubric-candidates.ts reference/expert-rules/ reference/rubric-configs/
git commit -m "chore(rubric-bootstrap): live candidates + validator (Step 6.1 Task 22)"Task 23 — Emit ratification xlsx workbook + pre-circulate to Dan + Jon
Per amendment §8 R6 mitigation — pre-circulate 24h ahead so the session is for disputed-row discussion, not first read. The implementation surface for amendment §5 Stage 5 is one xlsx workbook with 25 tabs, not Markdown tables. Decision taxonomy per tab matches the amendment §5 Stage 5 table verbatim — only the rendering surface differs.
Files:
-
Create:
calibre-app/scripts/emit-ratification-workbook.ts -
Output:
outputs/rubric-ratification/ratification-YYYY-MM-DD.xlsx(one workbook, 25 tabs) -
Step 0: Install xlsx dependency
xlsx (SheetJS) is not yet in calibre-app/package.json. Add it:
cd calibre-app && npm install xlsxVerify package.json "dependencies" now lists "xlsx": "^0.18.x" (or whatever the current pinned major is) and commit package.json + package-lock.json in Step 5 below.
- Step 1: Implement the workbook emitter
Create scripts/emit-ratification-workbook.ts. Skeleton:
import fs from 'fs'
import path from 'path'
import * as XLSX from 'xlsx'
const SUB_VERTICAL_CELLS: Array<{ sub_function: string; sub_vertical: string }> = [
// 22 cells from SOURCE-OF-TRUTH.md §3b — enumerate verbatim
{ sub_function: 'cargo-ops', sub_vertical: 'agri' },
{ sub_function: 'cargo-ops', sub_vertical: 'energy' },
{ sub_function: 'cargo-ops', sub_vertical: 'metals' },
// ... ship-ops × 5, risk-analytics × 5, finance-fo × 5, ops-back-office × 4
]
const VOCAB_DECISIONS = ['approve', 'rename', 'merge', 'reject']
const RULE_DECISIONS = ['approve', 'tune-delta', 'reject', 'replace-trigger']
const RUBRIC_DECISIONS = ['approve', 'move-to-must', 'move-to-nice', 'move-to-disq', 'drop']
const COLUMN_HEADERS = ['proposed_value', 'evidence (source_docs + observed_in_pct)', 'Decision', 'Tuned_value', 'Rationale', 'Notes']
function addDecisionValidation(ws: XLSX.WorkSheet, decisionCol: string, firstRow: number, lastRow: number, allowed: string[]): void {
// SheetJS data-validation hook: set !dataValidation on the sheet
// Reference: https://docs.sheetjs.com/docs/csf/features/dvranges
ws['!dataValidations'] = ws['!dataValidations'] ?? []
ws['!dataValidations'].push({
sqref: `${decisionCol}${firstRow}:${decisionCol}${lastRow}`,
type: 'list',
formula1: `"${allowed.join(',')}"`,
})
}
function main() {
const date = new Date().toISOString().slice(0, 10)
const outDir = path.resolve(__dirname, '../../outputs/rubric-ratification')
fs.mkdirSync(outDir, { recursive: true })
const wb = XLSX.utils.book_new()
// Tab 1: tag-vocab
const vocab = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../config/tag-vocab.candidate.json'), 'utf-8'))
const vocabRows = vocab.tags.map((t: { tag: string; source_docs: string[]; observed_in_pct: number }) => ({
proposed_value: t.tag,
'evidence (source_docs + observed_in_pct)': `${t.source_docs.join('; ')} (${(t.observed_in_pct * 100).toFixed(0)}%)`,
Decision: '', Tuned_value: '', Rationale: '', Notes: '',
}))
const vocabWs = XLSX.utils.json_to_sheet(vocabRows, { header: COLUMN_HEADERS })
addDecisionValidation(vocabWs, 'C', 2, vocabRows.length + 1, VOCAB_DECISIONS)
XLSX.utils.book_append_sheet(wb, vocabWs, 'tag-vocab')
// Tab 2: hard-gates
const hardGates = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../reference/expert-rules/hard-gates.candidate.json'), 'utf-8'))
const hardRows = hardGates.rules.map(serializeRuleRow)
const hardWs = XLSX.utils.json_to_sheet(hardRows, { header: COLUMN_HEADERS })
addDecisionValidation(hardWs, 'C', 2, hardRows.length + 1, RULE_DECISIONS)
XLSX.utils.book_append_sheet(wb, hardWs, 'hard-gates')
// Tab 3: boost-penalty
const boost = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../../reference/expert-rules/boost-penalty.candidate.json'), 'utf-8'))
const boostRows = boost.rules.map(serializeRuleRow)
const boostWs = XLSX.utils.json_to_sheet(boostRows, { header: COLUMN_HEADERS })
addDecisionValidation(boostWs, 'C', 2, boostRows.length + 1, RULE_DECISIONS)
XLSX.utils.book_append_sheet(wb, boostWs, 'boost-penalty')
// Tabs 4–25: rubrics × 22 — sub-functions kept contiguous so Dan can pattern-match across siblings
// (mitigates amendment §8 R3 — cognitive load)
for (const cell of SUB_VERTICAL_CELLS) {
const rubricPath = path.resolve(__dirname, `../../reference/rubric-configs/${cell.sub_function}.${cell.sub_vertical}.candidate.json`)
if (!fs.existsSync(rubricPath)) continue
const rubric = JSON.parse(fs.readFileSync(rubricPath, 'utf-8'))
const ws = buildRubricSheet(rubric)
XLSX.utils.book_append_sheet(wb, ws, `${cell.sub_function}.${cell.sub_vertical}`)
}
const outFile = path.join(outDir, `ratification-${date}.xlsx`)
XLSX.writeFile(wb, outFile)
console.log(`✓ Wrote ${outFile} — ${wb.SheetNames.length} tabs`)
}
function serializeRuleRow(r: { id: string; rationale: string; effect: { kind: string; delta?: number }; source_docs: string[]; observed_in_pct: number }) {
return {
proposed_value: `${r.id} — ${r.rationale} [${r.effect.kind}${r.effect.delta !== undefined ? ' Δ=' + r.effect.delta : ''}]`,
'evidence (source_docs + observed_in_pct)': `${r.source_docs.join('; ')} (${(r.observed_in_pct * 100).toFixed(0)}%)`,
Decision: '', Tuned_value: '', Rationale: '', Notes: '',
}
}
function buildRubricSheet(rubric: {
sub_function: string; sub_vertical: string; display_name: string
scoring_weights: Record<string, number>; minimum_pass_score: number
must_have_signals: Array<{ values: string[]; weight: number; source_docs: string[]; observed_in_pct: number }>
nice_to_have_signals: Array<{ values: string[]; weight: number; source_docs: string[]; observed_in_pct: number }>
disqualifying_signals: Array<{ values: string[]; weight: number; source_docs: string[]; observed_in_pct: number }>
}): XLSX.WorkSheet {
// Header block (rows 1–8): display_name + 5 scoring weights — Dan tunes inline via Tuned_value column
// Row 9: blank spacer · Row 10: column headers · Row 11+: signals
const aoa: (string | number)[][] = []
aoa.push([`Rubric: ${rubric.display_name}`])
aoa.push(['weight_field', 'current', 'Decision', 'Tuned_value', 'Rationale', 'Notes'])
for (const k of ['sub_function_match', 'tag_overlap', 'seniority_delta', 'years_experience', 'red_flag_penalty']) {
aoa.push([k, rubric.scoring_weights[k], '', '', '', ''])
}
aoa.push(['minimum_pass_score', rubric.minimum_pass_score, '', '', '', ''])
aoa.push([]) // spacer row 9
aoa.push(COLUMN_HEADERS) // row 10
const sigRows: (string | number)[][] = []
for (const tier of ['must', 'nice', 'disqualifying'] as const) {
const list = tier === 'must' ? rubric.must_have_signals : tier === 'nice' ? rubric.nice_to_have_signals : rubric.disqualifying_signals
for (const s of list) {
sigRows.push([
`[${tier}] ${s.values.join(' / ')} (weight=${s.weight})`,
`${s.source_docs.join('; ')} (${(s.observed_in_pct * 100).toFixed(0)}%)`,
'', '', '', '',
])
}
}
aoa.push(...sigRows)
const ws = XLSX.utils.aoa_to_sheet(aoa)
// Decision dropdown on signal rows only (starts at row 11 in 1-indexed terms)
const firstSignalRow = 11
const lastSignalRow = 10 + sigRows.length
if (sigRows.length > 0) addDecisionValidation(ws, 'C', firstSignalRow, lastSignalRow, RUBRIC_DECISIONS)
return ws
}
main()Tab layout summary:
- Tab 1
tag-vocab— one row per proposed tag; Decision dropdown =approve / rename / merge / reject. - Tab 2
hard-gates— one row per rule witheffect.kind ∈ {drop, block_at_l2}; Decision dropdown =approve / tune-delta / reject / replace-trigger. - Tab 3
boost-penalty— one row per rule witheffect.kind ∈ {boost, penalty, require_evidence}; same Decision dropdown ashard-gates. - Tabs 4–25
{sub-function}.{sub-vertical}× 22 — header block (rows 1–8: title + 5 scoring weights +minimum_pass_score, tuneable in-place viaTuned_valuecolumn); blank spacer; column headers; signal rows tagged[must]/[nice]/[disqualifying]. Decision dropdown on signal rows =approve / move-to-must / move-to-nice / move-to-disq / drop.
Every tab carries the same 6 columns: proposed_value | evidence (source_docs + observed_in_pct) | Decision | Tuned_value | Rationale | Notes.
- Step 2: Acceptance test — workbook structure
Create tests/engine/step-3-normalise/emit-ratification-workbook.test.ts. Build a minimal fixture (tag-vocab.candidate.json + hard-gates.candidate.json + boost-penalty.candidate.json + 22 rubric candidate files with 1–2 signals each) in os.tmpdir(), run the emitter via dynamic import with cwd redirected, then re-read the produced xlsx via XLSX.readFile and assert:
- Workbook has exactly 25 tabs — names:
tag-vocab,hard-gates,boost-penalty, plus 22{sub-function}.{sub-vertical}tabs matching SOT §3b. - Every tab’s row 1 (header row, except rubric tabs where it’s row 10) contains the 6 expected columns verbatim.
- Decision dropdown allowed values on each tab match the locked taxonomy (
tag-vocab→ 4 values;hard-gates+boost-penalty→ 4 values; rubric tabs → 5 values). Read viaws['!dataValidations']. - Rubric tabs carry the weight block in rows 1–8 (5 named
scoring_weightsrows +minimum_pass_score) with editableTuned_valuecells. - Sub-functions stay contiguous in tab order (per amendment §8 R3 mitigation).
Run: npx vitest run tests/engine/step-3-normalise/emit-ratification-workbook.test.ts
Expected: PASS, 5/5.
- Step 3: Emit the live workbook
Run: cd calibre-app && tsx scripts/emit-ratification-workbook.ts
Expected: outputs/rubric-ratification/ratification-YYYY-MM-DD.xlsx written; console reports 25 tabs.
- Step 4: Email Dan + Jon
Subject: Rubric-bootstrap ratification — please review by [date]. Attach the single xlsx workbook. Body explains the tab order walks in sequence: vocab → expert rules (hard-gates then boost-penalty) → 22 rubrics grouped by sub-function. Total estimated session time: 2 hours. Decision dropdowns are pre-validated on every Decision column; Tuned_value is free text.
- Step 5: Commit the emitter, test, dep bump, and workbook
git add calibre-app/scripts/emit-ratification-workbook.ts calibre-app/tests/engine/step-3-normalise/emit-ratification-workbook.test.ts calibre-app/package.json calibre-app/package-lock.json outputs/rubric-ratification/
git commit -m "chore(rubric-bootstrap): emit ratification xlsx workbook + pre-circulate (Step 6.1 Task 23)"Task 24 — Ratification session + promote to canonical paths
Manual step. The xlsx workbook is the working surface; CLI is post-session batch mode only.
- Step 1: Schedule the session
Block 2 hours with Dan + Jon, ~24h after pre-circulation (Task 23). Walk Tab 1 vocab (~30 min) → Tab 2 hard-gates + Tab 3 boost-penalty (~30 min) → Tabs 4–25 rubrics × 22 (~60 min, grouped by sub-function).
- Step 2: Walk the xlsx live with Dan + Jon
Open outputs/rubric-ratification/ratification-YYYY-MM-DD.xlsx on screenshare. Dan + Jon’s pre-filled Decision columns are the starting point — session resolves disputed rows only, applies Tuned_value adjustments, and Dan adds 3–5 new rows from memory directly in the xlsx. Save the marked-up file as ratification-YYYY-MM-DD.final.xlsx.
- Step 3: Run
npm run rubric-reviewin batch mode
npm run rubric-review -- --input outputs/rubric-ratification/ratification-2026-06-XX.final.xlsx --reviewer "dan+jon@calibre.sg"CLI reads the marked-up xlsx, applies every row’s action via the Task 19 appliers, writes .ratified.json siblings, promotes to canonical paths, archives 4 old grant-vertical rubric stubs. Prints summary table (rows-approved / tuned / rejected / skipped per tab).
- Step 4: Inspect the ratified outputs
Run:
ls reference/expert-rules/*.json
ls reference/rubric-configs/*.json
cat reference/expert-rules/hard-gates.audit.json | jq '.entries | group_by(.action) | map({action: .[0].action, count: length})'
cat reference/rubric-configs/rubric-configs.audit.json | jq '.entries | length'Expected: 2 ratified rules files (no .candidate / .ratified siblings remain); 22 ratified rubric files; audit logs with non-empty entries.
- Step 5: Confirm legacy stubs stay archived
Run: ls _archive/2026-06-11-rubric-stubs-4-vertical/ && ls reference/rubric-configs/{commodities,maritime,offshore,tech-gaming}.json 2>/dev/null
Expected: archive contains the 4 old stubs; canonical path does NOT contain them.
- Step 6: Commit the ratified outputs
git add reference/expert-rules/ reference/rubric-configs/
git commit -m "feat(rubric-bootstrap): lock expert rules + 22 rubrics via Dan + Jon ratification (Step 6.1 Task 24 — Step 6.1 Phase 1 complete)"- Step 7: Update SOURCE-OF-TRUTH + PLANS-LOG
In the outer Calibre/ repo:
- Edit
SOURCE-OF-TRUTH.md§4 Step 5.3 status →✅ Locked YYYY-MM-DD. - Edit
SOURCE-OF-TRUTH.md§4 Step 6.1 status →✅ Rubric content locked YYYY-MM-DD — scorer build next. - Edit
plans/PLANS-LOG.mdrows for Step 5.3 and Step 6.1 →✅ Locked. - Update the engine map (
outputs/engine-map-YYYY-MM-DD.html) accordingly.
git add SOURCE-OF-TRUTH.md plans/PLANS-LOG.md outputs/
git commit -m "docs(sot): Step 5.3 + Step 6.1 rubric content locked"
git push origin main- Step 8: Notify downstream
Step 3.2 (JD normaliser), Step 5.2 (CV normaliser), Step 5.3 (runtime loader), Step 6.1 (Layer 1 scorer) all unblock. Open the next impl plans in parallel per amendment §10.
Self-Review
1. Spec coverage — every stage of the amendment has at least one task:
- Stage 1 extension (amendment §5 Stage 1): Tasks 13 (types) + 14 (extractor).
- Stage 3b (amendment §5 Stage 3b): Task 15.
- Stage 3c (amendment §5 Stage 3c): Task 16.
- Stage 4b/4c (amendment §5 Stage 4): Tasks 17 (emitter) + 18 (orchestrator).
- Stage 5 (amendment §5 Stage 5): Tasks 19 (appliers) + 20 (CLI) + 23 (pre-circulate) + 24 (drive session).
- Validation (amendment §“What This Plan Does NOT Cover”): Tasks 21 (mocked E2E) + 22 (live + validator).
2. Placeholder scan — no TBDs. Every task names the files it touches and the acceptance test that proves it.
3. Type consistency — DocTermsExtended, JDStatedFields, HardRuleCandidate, ExpertRule, Signal, RubricConfig, RuleAuditEntry, RubricAuditEntry all defined in Task 13 and used consistently from Task 14 onward.
4. Skill alignment — TDD throughout (test before implementation in every code task). Frequent commits (one per task). DRY: Step 3.1 harness reused (Stage 2 unchanged; Stage 3a/4a untouched). YAGNI: no backwards-compatibility shims for the archived 4-vertical stubs; no rubric versioning beyond bootstrap → ratified; no multi-tenant rubric overrides (deferred to Step 10).
Execution Handoff
Plan complete and saved to plans/step-6-phase-1-rubric-bootstrap-impl.md. Two execution options:
1. Subagent-Driven (recommended) — Dispatch a fresh subagent per task. Review between tasks.
2. Inline Execution — Execute tasks in this session using the executing-plans skill.
Which approach?