career-ops calibrate 模式实战:用确定性校准报告验证评分体系是否真的预测了求职结果
【免费下载链接】career-opsOpen-source AI job search: scan job portals, evaluate listings into a structured A-H report with a global 1-5 score, tailor your CV, track applications — runs locally in your AI coding CLI (Claude Code, Codex, OpenCode, Antigravity…)项目地址: https://gitcode.com/GitHub_Trending/ca/career-ops
本篇基于 career-ops 仓库的 calibrate 模式定义 及其核心脚本 calibrate.mjs,讲清这个"学习闭环"的完整工作机制:如何运行node calibrate.mjs --json生成分数段(score band)× 面试率 / offer 率的校准报告,如何解读 separating / flat / inverted / insufficient 四种判定,以及脚本在证据优先级、小样本底线(honest floors)、在途申请剔除等细节上的确定性实现。读完你能独立完成一次自我校准,知道报告的每个数字从哪来、哪些数字被刻意 withheld、以及这个模式为什么"只报告证据、从不改规则"。
一、calibrate 模式在项目中的定位:闭合"评估 → 结果"的学习闭环
career-ops 的整体工作流是:扫描职位 → 生成 A–H 结构化评估报告并给出 1–5 分 → 按分数决定投递 → 记录结果。calibrate 模式对应模式表中的这一行(见 modes/README.md):
calibrate— Advisory report: do your evaluation scores predict your real outcomes? Reads/outcomedata; never changes scoring
它解决的核心问题在 modes/calibrate.md 的标题里就直接写明了:评分是否预测了你自己的结果(Does the scoring predict YOUR outcomes?)。此前评分只负责"事前排序",但从没有回头核对"高分投递是否真的转化得更好"。calibrate 闭合的正是这条学习闭环(issue #1724):由 /outcome 模式 记录到data/outcomes/的结果日志会被读回来,与当初投递前的评估分数做对照分析。
与 /outcome 模式 的分工是明确的:outcome 是写入端(对话式记录结果类型、阶段、逐字反馈,并把档案归档到data/outcomes/{num}_{company_slug}_{role_slug}/);calibrate 是读取端(聚合这些日志与 tracker,输出校准统计)。写入端支持的结果类型与 CLI 参数(interview_progress、offer_received、hired、offer_declined、rejected、no_response、interview_only等)在 outcome 模式文档中有完整定义,calibrate 报告中的每一个计数都以这些数据为原材料。
二、三条不可协商的约束(Non-negotiables)
modes/calibrate.md 把模式的边界浓缩为三条硬性约束,这三条贯穿了脚本的全部实现:
- Advisory only(仅建议):这个模式永不编辑评分规则、阈值、
modes/_shared.md或任何配置。它报告证据,用户自行决定如何使用。calibrate.mjs 的文件头注释同样声明:"Strictly advisory. This script never edits scoring rules, thresholds, modes/_shared.md, or any user file — it reads, aggregates, and reports."从源码看,整个脚本只调用了readFileSync/readdirSync/existsSync,没有任何写文件操作。 - Deterministic(确定性):所有数字来自
calibrate.mjs的纯本地解析——无网络、无 API key、无 LLM 计算。Agent 呈现报告时不得重新计算、调整或"优化"脚本打印的任何比率。 - Honest floors(诚实底线):脚本把某个比率标记为
(n too small)时,就按原样呈现。绝不允许把"3 个里成了 2 个"的轶事包装成百分比。
三、运行方式与完整参数
模式文档给出的标准调用是:
node calibrate.mjs --json结合 calibrate.mjs 文件头的 Usage 说明与 CLI 入口实现,完整的运行形态如下:
| 命令 | 行为 |
|---|---|
node calibrate.mjs | 输出人类可读报告(renderHuman) |
node calibrate.mjs --json | 向 stdout 输出完整 JSON,供 Agent/模式流程消费 |
node calibrate.mjs --min-band-n 5 | 调整每段的样本量下限(floor),默认 5,必须是 ≥1 的整数,否则以退出码 2 报错 |
node calibrate.mjs --self-test | 运行内置自测(覆盖聚合逻辑的各分支,不触碰文件系统) |
CLI 入口的执行顺序(见 calibrate.mjs 末尾的 main guard 部分):
- 若带
--self-test,先跑自测; - 解析
--min-band-n,非法值直接报错退出; - 通过
resolveTrackerPath()定位 tracker(data/applications.md); - 若 tracker 不存在,打印
No tracker found at ... — nothing to calibrate yet.并退出——模式文档要求此时如实告知用户,并指向/outcome:闭环需要先有记录的结果才谈得上任何结论,不要用猜测去填补空白; - 依次加载 tracker 行、结果日志(journals)、阶段台账(ledger),调用核心聚合
computeCalibration(),最后按--json与否输出。
四、报告结构与呈现顺序
模式文档规定呈现必须按以下顺序(这也是renderHuman()的实际输出顺序):
- 判定句(Verdict),逐字呈现——它是报告的主标题。四种判定:
separating:评分对你是有区分力的——高分段面试率显著高于低分段,"相信分数正在得到回报";flat:尚无明显区分,样本积累后通常会朝某个方向收敛;inverted:信号反转——低分段转化得比高分段更好,此时不应继续盲信下一个 4.5 分,而应先回读高分被拒报告的共性;insufficient:已解决的结果不足以比较分数段,提示"继续用/outcome记录并重跑"。
- 分数段表(score band × n × 面试率 × offer 率),按脚本打印的原样呈现;
- 在途(in-flight)计数:仍处于 Applied/Responded 且未记录结果的申请——这正是总数与 tracker 行数对不上的原因;
- 已记录的反馈信号(如有):把这些反馈当作关于你这次求职的数据来引用;若多条反馈出现同一模式(例如同一个技能缺口被两次点名),用一句话指出它。
一个renderHuman生成的报告骨架如下(数字为示意):
📐 Calibration — your scores vs your real outcomes Resolved outcomes: 12 · in flight (not counted): 5 · unscored: 2 | Score band | n | Interviews | Offers | Interview rate | Offer rate | |---|---|---|---|---|---| | <3.5 | 3 | 0 | 0 | (n too small) | | | 3.5-3.9 | 5 | 2 | 0 | 40% | 0% | | 4.0-4.4 | 4 | 2 | 1 | 50% | 25% | | >=4.5 | 0 | 0 | 0 | (n too small) | | Verdict: No meaningful separation yet: ... Signals from recorded feedback (latest 10): - #7 Acme: "Strong system design, gaps in Go depth" This report is advisory. It never changes scoring rules — that conversation belongs to you.五、源码纵深:分数段、样本底线与判定阈值
5.1 四个固定分数段
分数段的划分硬编码在 calibrate.mjs 中:
const BANDS = [ { key: '<3.5', min: -Infinity, max: 3.5 }, { key: '3.5-3.9', min: 3.5, max: 4.0 }, { key: '4.0-4.4', min: 4.0, max: 4.5 }, { key: '>=4.5', min: 4.5, max: Infinity }, ];对应 career-ops 全局 1–5 分制评估报告的分数输出(4.4/5这类单元格形式会被parseFloat正确解析——它在斜杠处停止;空白或文本分数变成null而不是 0,避免一行"无分"申请被静默落进最低分段)。
5.2 样本底线:(n too small)的实现
computeCalibration()对每个分数段的处理(calibrate.mjs):
- 段内样本数
n ≥ minBandN(默认 5)时,输出四舍五入的百分比比率; - 不足时,计数仍会显示(读者仍能看到原始轶事),但比率被置为
null,渲染为(n too small)。
这就是"Honest floors"约束的代码形态:小样本下拒绝制造伪比率。
5.3 判定逻辑:±10 个百分点的判定带
判定只比较通过样本底线的最高段与最低段(calibrate.mjs):
- 不足两个可比段 →
insufficient("这不是比较,就明说"); - 差值
gap = 高段面试率 − 低段面试率:gap ≥ 10→separating;gap ≤ −10→inverted;- 其余 →
flat。
--min-band-n因此是"灵敏度旋钮":样本少时调低它能看到更多比率,代价是离诚实底线更近——这正是模式文档要求"脚本 withheld 就照实呈现"的原因。
六、源码纵深:三源证据的优先级
这是 calibrate 最有信息量的部分。每一行申请的结果可能来自三个来源,优先级固定(calibrate.mjs):
- 结果日志(journal)——
data/outcomes/{...}/outcome.md,由outcome.mjs写入,记录"实际发生了什么",优先级最高; - 阶段台账(ledger)——
data/status-log.tsv(由set-status.mjs写入的状态迁移流水),记录一行经过过哪些阶段。因此一个已拒 offer 现在停在 Discarded 的行仍计为"达到过 offer";一次以拒绝告终的面试仍计为"达到过面试"; - tracker 当前状态——只是工作流快照,会丢失历史,因此优先级最低。
6.1 结果类型的两层语义
日志侧的语义表JOURNAL_OUTCOMES(calibrate.mjs)把七种规范结果映射到两个累积层级:
| 结果类型 | reachedInterview(一级) | reachedOffer(二级) | terminal |
|---|---|---|---|
hired/offer_received/offer_declined | ✓ | ✓ | ✓ |
interview_progress | ✓ | ✗ | ✗ |
interview_only | ✓ | ✗ | ✓ |
rejected/no_response | ✗ | ✗ | ✓ |
注意两个容易误读的点:拒掉 offer 仍算二级达成(offer_declined 证明评分选中了一个能匹配的角色);interview_only(面完无果)仍算一级达成。tracker 侧的兜底表TRACKER_TERMINAL同理:只有interview/offer/hired/rejected能独立定论,applied/responded判定为在途,evaluated/discarded等无阶段历史的行则完全在校准人群之外。
6.2 日志解析:"最后一条 entry 才是真相"
parseOutcomeJournal()(calibrate.mjs)按## Entry:切分 append-only 日志,关键契约是最后一条 entry 代表当前真相——一个先interview_progress后rejected的申请必须读作 rejected,而不是"它历史上最幸福的时刻"。实现上,只要存在 Outcome Type 字段就无条件覆盖latestType(包括解析为 null 的情况),防止最后一条无法识别的 entry 让上一条"幸存";无法识别时回落到 tracker 状态,这才是诚实答案。反馈只收集>引用的逐字内容,跳过None recorded空标记。
6.3 在途剔除:为什么总数对不上 tracker 行数
applied/responded且无日志结果的行被推入inFlight并从所有比率中排除。源码注释解释得很直白:把它算作失败会惩罚最近的申请,算作成功会美化一切——它既不是数据点,也不该消失,所以单独计数呈现。这正对应模式文档中"呈现 in-flight count"的固定步骤。
6.4 日志目录的分裂与合并
日志目录形如{num}_{company_slug}_{role_slug}。在早期版本中,目录名由 tracker 行当时的文本派生:两次记录之间编辑了 Role 单元格(比如把 "Senior Backend Engineer" 规范成 "Sr. Backend Engineer"),第二条 entry 就会落到新目录,一个申请被劈成两个半份日志。现在由 lib/outcome-dir.mjs 的outcomeDirsFor()按行号前缀收集全部候选目录,按 outcome.md 的 mtime 最新者优先排序,calibrate.mjs读取dirs[0];若确实存在分裂(dirs.length > 1),会在结果中标记splitAcross报告而非静默修复——修复意味着移动用户档案,一个只读报告不该做这件事。回归测试 tests/outcome-journal-fork.test.mjs 用真实 CLI 复现了"两次记录之间编辑 Role"的场景,断言目录数始终为 1 且 append-only 历史完整保留。
七、单一结果词汇表:防止读写两端漂移
outcome.mjs接受的结果类型会原样写入日志。任何读取日志的一方必须接受写入端接受的全部拼写,否则记录的结果对它就是隐形的。lib/outcome-types.mjs 是这一词汇表的唯一事实源:
OUTCOME_MAP共14 种接受拼写 → 各自的 tracker 状态与默认备注;CANONICAL_OUTCOMES是其中7 种规范名(outcome.mjs的 USAGE 行宣称的集合);- 其余 7 种是别名(
offer→offer_received、declined→offer_declined、ghosted→no_response、accepted→hired、rejection→rejected、interview/stage_reached→interview_progress),由canonicalOutcome()统一解析(小写、-归一为_,所以手改日志里写的Offer-Declined也能解析)。
这个设计有一个血泪背景,lib/outcome-types.mjs 的注释与 tests/outcome-vocabulary-drift.test.mjs 都记录了:calibrate 曾携带一份私有的 7 项词汇副本,导致declined、ghosted在校准中彻底消失——而"拒掉 offer"恰恰是评分预测良好最有力的证据之一。现在该模块在加载时做覆盖率检查(OUTCOME_MAP 里出现的新拼写若无规范含义会直接 throw),漂移测试则双向断言:写入端接受的每种拼写都必须能解析到 calibrate 的JOURNAL_OUTCOMES中的一个键,且JOURNAL_OUTCOMES恰好等于规范集合,不多不少。
八、Tracker 列映射:一个曾导致"全体静默掉出人群"的坑
loadTrackerRows()依赖 tracker-parse.mjs 的resolveColumns()定位表头。该函数期望整行数组(自己在其中找表头);早期版本误传单个表头字符串,导致逐字符迭代找不到表头,静默回落到无 Via 列的 9 列旧版列映射。后果在带可选 Via 列(位于 Company 与 Role 之间,#1596)的 tracker 上最典型:Role 起所有字段左移一格读取,status读到的是分数单元格("4.5/5"),没有任何状态能匹配,每一行都静默掉出人群——报告呈现"0 resolved / 0 in-flight"和一条虚假的"数据不足"判定,用户却以为自己只是"历史还不够"。回归测试 tests/calibrate-tracker-colmap.test.mjs 同时覆盖带 Via 的 10 列 tracker 与旧版 9 列 tracker,断言 status、score、company 均读到正确列。
九、这个模式永远不做的事(Must-never 清单)
modes/calibrate.md 末尾的禁止清单值得逐条内化,因为它定义了 Agent 与校准数据的正确相处方式:
- 不建议编辑
modes/_shared.md或任何评分规则。如果用户问"那我是不是该改评分?",诚实的回答是:全局评分体系保持不变——证据支持的是调整你自己的投递阈值与目标组合,这是用户的决定。 - 不把校准结果自动回灌到评估中。career-ops 中任何地方都没有 auto-tuning,这个模式也不会引入它。
同时,inverted判定下的正确后续动作也写得很清楚:读一读高分被拒报告的共性——这是一段对话(可以逐份与用户过那些具体报告),而不是自动重打分。
十、验证与回归保障
围绕 calibrate 的确定性契约,仓库提供了三层验证:
- 内置自测:
node calibrate.mjs --self-test直接对内存数据驱动parseOutcomeJournal/computeCalibration,覆盖"最后一条 entry 胜出"、separating/inverted/insufficient 判定、小样本 withheld、journal 优先于 tracker、在途与未投递行排除、ledger 证据解析等分支(calibrate.mjs)。 - 列映射回归:tests/calibrate-tracker-colmap.test.mjs 锁定
loadTrackerRows对两种 tracker 形态的解析。 - 词汇漂移防护:tests/outcome-vocabulary-drift.test.mjs 从
OUTCOME_MAP派生全量拼写普查,新别名加入写入端的当天就会被覆盖到。
CLI 入口还有一个工程细节:--self-test放在 main guard(lib/is-main-module.mjs 的isMainModule检查)之内——否则任何导入本模块做单测的测试进程都会对真实 tracker 跑一遍完整 CLI。
十一、实操建议:什么时候跑 calibrate,怎么用它
结合文档与源码,实用的使用节奏是:
- 前提:tracker(
data/applications.md)存在,且已用/outcome记录了若干结果。每段需 ≥5(默认 floor)个已解决的结果,两个分数段达标才有可比性——也就是说,积累到约 10 个已闭环申请后,报告才开始有判定价值。 - 每次收到新结果后重跑:
node calibrate.mjs。判定从insufficient→flat→separating/inverted的演进本身就是求职数据的累积过程。 separating:维持现有策略,分数可作为投递优先级信号;flat:继续记录,不做任何规则改动;inverted:最值得花时间——按模式文档的建议,把高分被拒的报告拉出来与用户一起过,找出高分假设与真实结果的系统性偏差(如目标公司类型错配),随后调整的是个人投递阈值与目标组合,而非全局评分。
需要牢记的是这个模式的性质:它输出的是"评分体系在你身上表现如何"的证据快照,是本地、确定、可复算的(同一份 tracker + 日志 + 台账,任何时候重跑结果一致)。career-ops 用"读取、聚合、报告、绝不回写"这一设计,把校准从一条可能自我强化的自动化回路,变成了一个人主导的反思工具。
【免费下载链接】career-opsOpen-source AI job search: scan job portals, evaluate listings into a structured A-H report with a global 1-5 score, tailor your CV, track applications — runs locally in your AI coding CLI (Claude Code, Codex, OpenCode, Antigravity…)项目地址: https://gitcode.com/GitHub_Trending/ca/career-ops
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考