diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d4100ea..f687e2ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 印度占星 Skill 更新日志 +## 2026-09-10 — 带钟点的经历不再被当成申报出生时段;对照列按全部候选分钟来算 + +生时校正做到一半时,如果说的是带钟点的经历(比如某日 20:00 左右分手、下午 3 点到 5 点被撞),助手不再误回「搜索范围按资料定、过程中不改」。只有在明确说出生时间,或只报一个钟点范围时,才用这句固定回复。 + +对照卡上的经历对照和未来时段按引擎里全部候选分钟来算(最多 64 个),不再只算前 9 个。一小时窗里若有 17 个候选,三列都能查到对照,不再出现「这一分钟还没对照」。Skill 版本不变。 + + ## 2026-09-09 — 性格点选题只在分不开时才问,且只作排序参考 生时校正的性格类点选题(相处方式、做事风格、月宿边界)不再和带年月的经历题同权。带年月的区分题还问得完时,先问那些题;只在问完仍分不开、还剩两个以上候选时才出性格题。答了只轻轻排序,不会因为性格自评把某一分钟淘汰。验证报告里这两行标成「参考」。Skill 10.0.21。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 4677328f..4e9a2799 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -9763,3 +9763,35 @@ - 相关记录:BUG-205、BUG-214、BUG-255、BUG-257、BUG-612 - 复发自:BUG-214(同一句用户文案和合同门;本条是模型根本没成功调计算工具,不是失败计数把成功次数算超) - 修复版本:待发布 + +## BUG-631 | 申报时段拦截把带钟点的经历当成改窗口 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-10 +- 影响面:`parseDeclaredBirthWindow`、`POST /api/rectification/agent` 自由文本、采集/点选题下的经历落库 +- 用户现象:用户报经历时带了钟点(例如「2024 年 8 月 8 日 20:00 左右分手」),助手立刻回复「搜索范围开始时按资料定、过程中不改」,这句话不进模型,证据也不落库。 +- 触发条件:校正做到一半,自由文本里出现 `HH:MM` / `X 点 Y 分` 加「到 / 至 / -」或「左右 / 前后」,同时这句话其实是经历而不是申报出生时段。 +- 根因:BUG-625 只按钟点样式拦截。经历里的钟点和申报出生时段共用同一套样式,没有出生语境或年月日门。 +- 修复:命中钟点后再判语境。含出生语境词才算申报;含年月日且无出生语境一律当经历;只有裸钟点范围(可带「大概 / 下午」这类虚词)仍拦截。 +- 验证:`frontend/tests/rectification-declared-window-20260909.test.ts`;既有 `rectification-window-cluster-cap-20260909.test.ts` 补了三句经历不拦截。 +- 防复发:不得只靠钟点正则拦截自由文本;带年月日的经历即使含「20:00 左右」也必须进模型。走查见 `docs/testing/rectification-window-cluster-cap-20260909.md` 第 4 节。 +- 相关记录:BUG-625 +- 复发自:BUG-625(拦截范围过宽) +- 修复版本:待发布 + +## BUG-632 | 经历对照只算引擎前 9 个候选,推断前三会显示还没对照 + +- 状态:resolved +- 首次发现:2026-09-09 +- 最近更新:2026-09-10 +- 影响面:`column_times_for_compare`、三列对照卡 `event_dasha_ledger_by_time` / `prospective_windows_by_time` +- 用户现象:一小时窗有 17 个引擎候选时,对照卡某一列写「这一分钟还没对照」。 +- 触发条件:BUG-623 之后公开候选可以超过 9 个;推断层前三不在引擎候选列表的前 9 个里。 +- 根因:BUG-614 把对照从「引擎分数最高的三个」改成「引擎候选列表」,但仍保留 `limit=9`。卡片按推断前三查表,键不在这 9 个里就显示没对照。 +- 修复:默认上限改为公开候选全集(`MAX_PUBLIC_CLUSTERS`,64)。`column_times` 仍可传入更小的 active 集合;`column_compare_ms` 继续记录耗时,预算 3 秒。 +- 验证:`tests/test_rectification_v5_services.py`:17 个候选 → by_time 17 键,65 个截到 64,`column_compare_ms` 低于 3000;`column_times` 子集 3 键。前端 `columnTimesForSlowCompare` 在上一轮超过 3 秒时把 active 分钟写入引擎请求。 +- 防复发:不得把对照列截回前 9;推断层查表的分钟必须落在 by_time 键集合里。超 3 秒时只允许改传入的 `column_times`,不能再静默丢掉后段候选。 +- 相关记录:BUG-614、BUG-623 +- 复发自:BUG-614(对照集合仍被 9 截断) +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-rectification-followups-20260909.md b/docs/tasks/PROGRESS-rectification-followups-20260909.md new file mode 100644 index 00000000..57f592b6 --- /dev/null +++ b/docs/tasks/PROGRESS-rectification-followups-20260909.md @@ -0,0 +1,38 @@ +# PROGRESS · 验收补漏:申报时段误伤经历;by_time 只算 9 个(2026-09-09) + +工作树:`.worktrees/rectification-followups-20260909` +分支:`codex/rectification-followups-20260909` +任务书:`docs/tasks/TASK-rectification-followups-20260909.md` +基线:任务书写 `fef54b6e`;开工时接到 `origin/staging` @ `2de2aa0b`(任务书已在其上)。本单不依赖 BUG-626~628。 + +## 开工回执 + +- 编号 **BUG-631** / **BUG-632**(`docs/BUG_HISTORY.md` 当时最大已发布号是 630)。 +- 顺序:解析语境门 → 对照列上限 64 → 超 3 秒时把 active 分钟作为 `column_times` 传入 → 记录。 + +## 已完成 + +- **BUG-631**:`parseDeclaredBirthWindow` 命中钟点后再判语境。含出生语境词才算申报;含年月日且无出生语境一律当经历;裸钟点范围(可带「大概 / 下午」)仍拦截。`route.ts` 仍只在解析命中时固定回复。 +- **BUG-632**:`column_times_for_compare` 默认 `limit=MAX_PUBLIC_CLUSTERS`(64)。17 个候选 → by_time 17 键。`column_compare_ms` 预算 `COLUMN_COMPARE_BUDGET_MS=3000`。上一轮超过 3 秒时,TS `columnTimesForSlowCompare` 把推断层 active 分钟写入引擎请求的 `column_times`;决策层只保留仍在公开候选里的那些分钟。 +- 记录:BUG-631/632、CHANGELOG、DESIGN §11、走查第 4 节、场景清单第 7 条、本文件。Skill 版本不变(10.0.21)。 + +## 验收命令(实测) + +工作树 `codex/rectification-followups-20260909`,相对 `origin/staging` @ `2de2aa0b`。 + +| 命令 | 结果 | +| --- | --- | +| `frontend` `./node_modules/.bin/tsx --test tests/rectification-declared-window-20260909.test.ts tests/rectification-window-cluster-cap-20260909.test.ts tests/rectification-v9-engine-contract.test.ts` | **23/23 pass** | +| `frontend` `./node_modules/.bin/tsx --test tests/rectification-v9-agent.test.ts` | **23/23 pass** | +| `.venv/bin/python -m pytest tests/test_rectification_v5_services.py -q` | **40 passed** | +| `git diff --check` | 干净 | + +## 环境缺口 + +- 无登录态、无 Chrome:`docs/testing/rectification-window-cluster-cap-20260909.md` 第 4 节真人走查未做。 +- 未跑完整前端套件 / `next build`。 + +## 未做 + +- 未 commit、未 push、未提升 `main`。 +- Skill 未升版。intake 自定义更小范围仍等产品答复(BUG-625 留下的)。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 6a4eab12..987eaa12 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -93,7 +93,7 @@ | `TASK-rectification-skipped-health-deadend-20260909.md` | `PROGRESS-rectification-skipped-health-deadend-20260909.md` | 真实用户:健康题「记不清」后职业答完即断(『没有拿到下一个问题』):holdout 的 `declined` 未归并 health/health_pressure,把跳过的健康线再问一次,撞同 id 焦点 `duplicate_focus` 静默;出口闸门把 `exhausted` 当已交付不修复;『重新加载』只重取快照 | 待执行 | `codex/rectification-skipped-health-deadend-20260909`(BUG-626~627) | | `TASK-rectification-domain-alias-audit-20260909.md` | `PROGRESS-rectification-domain-alias-audit-20260909.md` | 领域命名审计:健康线 `health`(账本/焦点)与 `health_pressure`(计划/引擎)在十处比较里六处未归并(holdout declined、reverse-verify、conflict probes、probeYearAlreadyCovered、引擎 oos_blind_prompts / _event_years / volunteered);职业线焦点存为 `other` 只靠 questionId 兜底。其余六领域三层同名无问题。决策:TS/Python 各一个归并函数 + 源码合同禁字面量比较 | 待执行 | `codex/rectification-domain-alias-audit-20260909`(BUG-628) | | `TASK-rectification-yearless-probe-downgrade-20260909.md` | `PROGRESS-rectification-yearless-probe-downgrade-20260909.md` | 产品拍板:无年月性格题(D9/D10 风格、月宿边界)降级为平局裁决——带年月题问完且候选仍分不开才问,分值减半(±1)、不计淘汰,报告标「参考」;三列卡性格描述不动;新增离线命中率测量脚本 + 导出 SQL(有出生证用户,聚合);Skill 10.0.21 | 已验收通过 | `fef54b6e`(BUG-629,Skill 10.0.21) | -| `TASK-rectification-followups-20260909.md` | `PROGRESS-rectification-followups-20260909.md` | 验收补漏:申报时段拦截只看钟点样式,带钟点的经历(『20:00 左右分手』『3 点到 5 点被车撞』)会被吞(BUG-631);by_time 只算引擎前 9 个候选,一小时窗 17 个候选时卡片列写『还没对照』(BUG-632) | 待执行 | `codex/rectification-followups-20260909`(BUG-631~632) | +| `TASK-rectification-followups-20260909.md` | `PROGRESS-rectification-followups-20260909.md` | 验收补漏:申报时段拦截只看钟点样式,带钟点的经历(『20:00 左右分手』『3 点到 5 点被车撞』)会被吞(BUG-631);by_time 只算引擎前 9 个候选,一小时窗 17 个候选时卡片列写『还没对照』(BUG-632) | 待验收 | `codex/rectification-followups-20260909`(BUG-631~632) | ### 聊天主链路与首页 diff --git a/docs/testing/rectification-scenarios-20260907.md b/docs/testing/rectification-scenarios-20260907.md index b99152ff..165028e5 100644 --- a/docs/testing/rectification-scenarios-20260907.md +++ b/docs/testing/rectification-scenarios-20260907.md @@ -173,6 +173,7 @@ - 口述采集没有「没有」「记不清」按钮,也没有「先这样」;输入框上方不再挂快捷回答 - 想跳过可以打字「没有」或「记不清」 - 每轮助手正文一句复述(可另接一句范围变化);气泡里没有「本轮对照了…」,也没有「很有帮助 / 很有价值 / 很有分量 / 特别有用」 +- 报经历时带钟点(「20:00 左右分手」「下午 3 点到 5 点被车撞」)不得被当成申报出生时段,不得回「搜索范围开始时按资料定、过程中不改」 ## 8. 性格题只作平局参考(BUG-629) diff --git a/docs/testing/rectification-window-cluster-cap-20260909.md b/docs/testing/rectification-window-cluster-cap-20260909.md index 934ffd1f..3052fbc9 100644 --- a/docs/testing/rectification-window-cluster-cap-20260909.md +++ b/docs/testing/rectification-window-cluster-cap-20260909.md @@ -27,3 +27,17 @@ - [ ] 不得出现「明白了,以你说的为准」或改写搜索窗口。 - [ ] Skill 10.0.20 写明不得口头承认。 + +## 4. 报经历带钟点不得被当成申报时段 + +采集或点选题还挂着时,用虚构经历带上钟点(不要用真实资料): + +- 「2024 年 8 月 8 日 20:00 左右分手」 +- 「下午 3 点到 5 点被车撞」 +- 「2019 年 10 月 23 日 9 点半入职」 + +期望: + +- [ ] 不出现「搜索范围开始时按资料定、过程中不改」那句固定回复。 +- [ ] 这句话进模型 / 证据账本,不当成申报出生时段。 +- [ ] 「我的出生时间是 14 点 45 到 14 点 50」「14:45–14:50」「我是 14:47 左右生的」仍走固定回复。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 9d7c9751..336c9e6c 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -545,7 +545,7 @@ Admin 的 antd `` 是独立设计系统,不在此表。 卡顶保留范围与经历数;卡底边界句保留;「查看验证报告」默认收起。不预标「排盘用」。点「更像这个」走现有 accept RPC。采用过程中整张卡留在原处;已采用列按钮禁用。收尾句跟卡片同一列。 -引擎 `event_dasha_ledger_by_time` / `prospective_windows_by_time` 的键是引擎 `candidate_times` 去重全集(上限 9),不是引擎分数前三。卡片仍只投影后验前三。 +引擎 `event_dasha_ledger_by_time` / `prospective_windows_by_time` 的键是引擎 `candidate_times` 去重全集(上限 64),不是引擎分数前三。卡片仍只投影后验前三。若上一轮 64 分钟对照超过 3 秒,下一轮可以把推断层仍有效的分钟作为 `column_times` 传入。 ## 12. 校正右栏盘面 diff --git a/frontend/src/lib/rectification-agentic/v9/column-times-for-compare.ts b/frontend/src/lib/rectification-agentic/v9/column-times-for-compare.ts new file mode 100644 index 00000000..042d28bd --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/column-times-for-compare.ts @@ -0,0 +1,30 @@ +/** + * by_time ledgers default to the full engine candidate set (≤64). + * If a previous compare of that set exceeded the budget, the next score + * may pass only still-active inference minutes (BUG-632). + */ + +export const COLUMN_COMPARE_BUDGET_MS = 3000; +export const MAX_COLUMN_COMPARE_TIMES = 64; +const CLOCK = /^(?:[01]\d|2[0-3]):[0-5]\d$/; + +export function columnTimesForSlowCompare(input: { + previousColumnCompareMs: number | null | undefined; + activeTimes: readonly string[] | null | undefined; +}): string[] | undefined { + if ( + input.previousColumnCompareMs == null + || !Number.isFinite(input.previousColumnCompareMs) + || input.previousColumnCompareMs <= COLUMN_COMPARE_BUDGET_MS + ) { + return undefined; + } + const seen: string[] = []; + for (const raw of input.activeTimes ?? []) { + const clock = raw.slice(0, 5); + if (!CLOCK.test(clock) || seen.includes(clock)) continue; + seen.push(clock); + if (seen.length >= MAX_COLUMN_COMPARE_TIMES) break; + } + return seen.length > 0 ? seen : undefined; +} diff --git a/frontend/src/lib/rectification-agentic/v9/declared-window-utterance.ts b/frontend/src/lib/rectification-agentic/v9/declared-window-utterance.ts index f08b869c..69bcb7d3 100644 --- a/frontend/src/lib/rectification-agentic/v9/declared-window-utterance.ts +++ b/frontend/src/lib/rectification-agentic/v9/declared-window-utterance.ts @@ -1,13 +1,19 @@ /** * Mid-session spoken birth-time windows are not a search-window change. * Detect them so the route can answer with a fixed reply and skip the model. + * + * Clock-shaped text is not enough (BUG-631): dated life events and other + * non-birth sentences that happen to contain HH:MM must still reach the model. */ -const CLOCK = /(?:[01]?\d|2[0-3])\s*[::点]\s*[0-5]?\d(?:\s*分)?/; +const CLOCK = /(?:[01]?\d|2[0-3])\s*[::点]\s*(?:半|[0-5]?\d(?:\s*分)?)?/; const RANGE_SEP = /\s*(?:到|至|[-–—~~])\s*/; const AROUND = /\s*(?:左右|前后)/; const RANGE_PATTERN = new RegExp(`(${CLOCK.source})${RANGE_SEP.source}(${CLOCK.source})`); const AROUND_PATTERN = new RegExp(`(${CLOCK.source})${AROUND.source}`); +const BIRTH_CONTEXT = /出生时间|出生|生于|时辰|几点生|钟点是|(? 23 || !Number.isInteger(minute) || minute > 59) return null; - return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; + const half = /([01]?\d|2[0-3])\s*[::点]\s*半/.exec(raw); + if (half) { + const hour = Number(half[1]); + if (!Number.isInteger(hour) || hour > 23) return null; + return `${String(hour).padStart(2, "0")}:30`; + } + const withMinutes = /([01]?\d|2[0-3])\s*[::点]\s*([0-5]?\d)/.exec(raw); + if (withMinutes) { + const hour = Number(withMinutes[1]); + const minute = Number(withMinutes[2]); + if (!Number.isInteger(hour) || hour > 23 || !Number.isInteger(minute) || minute > 59) return null; + return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; + } + const hourOnly = /([01]?\d|2[0-3])\s*点/.exec(raw); + if (!hourOnly) return null; + const hour = Number(hourOnly[1]); + if (!Number.isInteger(hour) || hour > 23) return null; + return `${String(hour).padStart(2, "0")}:00`; } diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index 962deb5a..42627224 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -518,6 +518,7 @@ export function engineRequestBody(input: { candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; askedProbeKeys?: readonly string[]; + columnTimes?: readonly string[]; }): Record { const snapshot = input.baselineBirthSnapshot; const birthDate = String(snapshot.birth_date ?? ""); @@ -531,6 +532,11 @@ export function engineRequestBody(input: { throw new RectificationEngineError("no_scorable_evidence", "no scorable evidence for the engine"); } const askedProbeKeys = sanitizeAskedProbeKeysForEngine(input.askedProbeKeys); + const columnTimes = [...new Set( + (input.columnTimes ?? []) + .map((value) => value.slice(0, 5)) + .filter((value) => timePattern.test(value)), + )].slice(0, 64); return { birth_date: birthDate, start_time: input.candidateRange.start_time, @@ -546,6 +552,7 @@ export function engineRequestBody(input: { timezone_source: snapshot.timezone_source, local_time_status: snapshot.local_time_status, ...(askedProbeKeys.length ? { asked_probe_keys: askedProbeKeys } : {}), + ...(columnTimes.length ? { column_times: columnTimes } : {}), }; } @@ -659,6 +666,7 @@ export async function runV9CandidateScore(input: { candidateRange: { start_time: string; end_time: string }; events: readonly V9EngineEvent[]; askedProbeKeys?: readonly string[]; + columnTimes?: readonly string[]; }): Promise { const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input)); const candidates = readCandidates(data.candidate_decisions, input.candidateRange); diff --git a/frontend/src/lib/rectification-agentic/v9/score-persist.ts b/frontend/src/lib/rectification-agentic/v9/score-persist.ts index 8cf07fc3..80bcd5e1 100644 --- a/frontend/src/lib/rectification-agentic/v9/score-persist.ts +++ b/frontend/src/lib/rectification-agentic/v9/score-persist.ts @@ -22,6 +22,7 @@ import { buildCaseInferenceState, previousInferenceFromReceipt, } from "./inference-adapter.ts"; +import { columnTimesForSlowCompare } from "./column-times-for-compare.ts"; import { refinementFromDecisionReceipt } from "./refinement-packet.ts"; import { blockScanRequestExtras } from "./search-window.ts"; import { @@ -360,11 +361,23 @@ export async function scoreAndPersistCurrentEvidence(input: { }; } const scoreStarted = Date.now(); + const latestReceipt = dossier.latestResult?.decisionReceipt; + const previousCompareMs = typeof latestReceipt?.column_compare_ms === "number" + ? latestReceipt.column_compare_ms + : null; + const previousInference = previousInferenceFromReceipt(latestReceipt); + const columnTimes = columnTimesForSlowCompare({ + previousColumnCompareMs: previousCompareMs, + activeTimes: previousInference?.candidates + .filter((candidate) => candidate.status === "active") + .map((candidate) => candidate.time) ?? [], + }); const score = await runV9CandidateScore({ baselineBirthSnapshot: compute.baselineBirthSnapshot, candidateRange, events, askedProbeKeys, + columnTimes, }); const engineCompareMs = Date.now() - scoreStarted; const vedastroStarted = Date.now(); diff --git a/frontend/tests/rectification-declared-window-20260909.test.ts b/frontend/tests/rectification-declared-window-20260909.test.ts new file mode 100644 index 00000000..3d487e8f --- /dev/null +++ b/frontend/tests/rectification-declared-window-20260909.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { + COLUMN_COMPARE_BUDGET_MS, + columnTimesForSlowCompare, +} from "../src/lib/rectification-agentic/v9/column-times-for-compare.ts"; +import { parseDeclaredBirthWindow } from "../src/lib/rectification-agentic/v9/declared-window-utterance.ts"; + +const ROUTE = readFileSync( + new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), + "utf8", +); + +test("dated or event-clock sentences are not treated as a declared birth window", () => { + assert.equal(parseDeclaredBirthWindow("2024 年 8 月 8 日 20:00 左右分手"), null); + assert.equal(parseDeclaredBirthWindow("下午 3 点到 5 点被车撞"), null); + assert.equal(parseDeclaredBirthWindow("2019 年 10 月 23 日 9 点半入职"), null); + assert.equal(parseDeclaredBirthWindow("20:00 左右分手"), null); + assert.equal(parseDeclaredBirthWindow("2014年入学,大概秋天"), null); +}); + +test("birth-context and bare clock ranges still count as a declared window", () => { + assert.deepEqual( + parseDeclaredBirthWindow("我的出生时间是 14 点 45 到 14 点 50"), + { kind: "range", start: "14:45", end: "14:50" }, + ); + assert.deepEqual( + parseDeclaredBirthWindow("我是 14:47 左右生的"), + { kind: "around", time: "14:47" }, + ); + assert.deepEqual( + parseDeclaredBirthWindow("14:45–14:50"), + { kind: "range", start: "14:45", end: "14:50" }, + ); + assert.deepEqual( + parseDeclaredBirthWindow("14:47 左右"), + { kind: "around", time: "14:47" }, + ); + assert.deepEqual( + parseDeclaredBirthWindow("大概 14:47 左右"), + { kind: "around", time: "14:47" }, + ); +}); + +test("choice-focus declared window is still answered from the route before the model", () => { + const intercept = ROUTE.indexOf('if (action === "message" && parseDeclaredBirthWindow'); + const selectedModel = ROUTE.indexOf("const selectedModel = isStructuredChoice"); + assert.ok(intercept > 0); + assert.ok(selectedModel > intercept); +}); + +test("score persist passes active minutes only after a slow by_time compare", () => { + const persist = readFileSync( + new URL("../src/lib/rectification-agentic/v9/score-persist.ts", import.meta.url), + "utf8", + ); + assert.match(persist, /columnTimesForSlowCompare/); + assert.match(persist, /columnTimes,/); +}); + +test("slow by_time compares may pass only active inference minutes", () => { + assert.equal( + columnTimesForSlowCompare({ previousColumnCompareMs: 1200, activeTimes: ["14:46", "14:47", "14:48"] }), + undefined, + ); + assert.deepEqual( + columnTimesForSlowCompare({ + previousColumnCompareMs: COLUMN_COMPARE_BUDGET_MS + 1, + activeTimes: ["14:46", "14:47", "14:46", "bad"], + }), + ["14:46", "14:47"], + ); + assert.equal( + columnTimesForSlowCompare({ previousColumnCompareMs: 4000, activeTimes: [] }), + undefined, + ); +}); diff --git a/frontend/tests/rectification-v9-engine-contract.test.ts b/frontend/tests/rectification-v9-engine-contract.test.ts index 1eac86ff..49fbac17 100644 --- a/frontend/tests/rectification-v9-engine-contract.test.ts +++ b/frontend/tests/rectification-v9-engine-contract.test.ts @@ -511,3 +511,13 @@ test("engineRequestBody drops varga split hashes and keeps short semantic keys", assert.ok(keys.every((key) => key.length <= 120)); assert.deepEqual(sanitizeAskedProbeKeysForEngine([VARGA_SPLIT_HASH, "varga.d9.style"]), ["varga.d9.style"]); }); + +test("engineRequestBody forwards a slow-compare column_times subset", () => { + const body = engineRequestBody({ + baselineBirthSnapshot: SNAPSHOT, + candidateRange: RANGE, + events: toEngineEvents(EVIDENCE), + columnTimes: ["14:46", "14:47", "14:46", "not-a-clock"], + }); + assert.deepEqual(body.column_times, ["14:46", "14:47"]); +}); diff --git a/frontend/tests/rectification-window-cluster-cap-20260909.test.ts b/frontend/tests/rectification-window-cluster-cap-20260909.test.ts index 8b967d96..9c694aac 100644 --- a/frontend/tests/rectification-window-cluster-cap-20260909.test.ts +++ b/frontend/tests/rectification-window-cluster-cap-20260909.test.ts @@ -35,6 +35,9 @@ test("declared birth-window utterances parse ranges and around-times", () => { assert.equal(parseDeclaredBirthWindow("没有"), null); assert.equal(parseDeclaredBirthWindow("2014年入学,大概秋天"), null); assert.equal(parseDeclaredBirthWindow("继续吧"), null); + assert.equal(parseDeclaredBirthWindow("2024 年 8 月 8 日 20:00 左右分手"), null); + assert.equal(parseDeclaredBirthWindow("下午 3 点到 5 点被车撞"), null); + assert.equal(parseDeclaredBirthWindow("2019 年 10 月 23 日 9 点半入职"), null); }); test("choice-focus declared window is answered from the route before the model", () => { diff --git a/scripts/rectification/api_service.py b/scripts/rectification/api_service.py index 938949ea..0865ac72 100644 --- a/scripts/rectification/api_service.py +++ b/scripts/rectification/api_service.py @@ -218,7 +218,7 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]: fingerprint = sha256({ key: value for key, value in request.items() - if key not in {"asked_probe_keys", "dropped_asked_probe_keys"} + if key not in {"asked_probe_keys", "dropped_asked_probe_keys", "column_times"} }) result_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")) candidate_decisions = build_candidate_decisions( diff --git a/scripts/rectification/contracts.py b/scripts/rectification/contracts.py index e5b3679a..cb27a90d 100644 --- a/scripts/rectification/contracts.py +++ b/scripts/rectification/contracts.py @@ -52,7 +52,7 @@ _EVENT_PROVENANCE_FIELDS = frozenset({ }) _REQUEST_FIELDS = frozenset({ "birth_date", "start_time", "end_time", "lat", "lon", "tz", "events", - "ayanamsa", "node_mode", "asked_probe_keys", "minute_step", "blocks", + "ayanamsa", "node_mode", "asked_probe_keys", "column_times", "minute_step", "blocks", }) | _REQUEST_PROVENANCE_FIELDS ASKED_PROBE_KEY_MAX_LENGTH = 200 _EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS @@ -169,6 +169,7 @@ class RectificationRequest(TypedDict): local_time_status: NotRequired[str | None] asked_probe_keys: NotRequired[list[str]] dropped_asked_probe_keys: NotRequired[int] + column_times: NotRequired[list[str]] minute_step: NotRequired[int] blocks: NotRequired[list[dict[str, Any]]] @@ -354,6 +355,20 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) -> cleaned_request["asked_probe_keys"] = cleaned_keys if dropped: cleaned_request["dropped_asked_probe_keys"] = dropped + if "column_times" in body: + raw_times = body.get("column_times") + if not isinstance(raw_times, list) or not 1 <= len(raw_times) <= 64: + raise ValueError("column_times must contain between 1 and 64 HH:MM values") + cleaned_times: list[str] = [] + seen_times: set[str] = set() + for index, item in enumerate(raw_times): + if not isinstance(item, str) or not _CLOCK.fullmatch(item): + raise ValueError(f"column_times[{index}] must be HH:MM") + if item in seen_times: + continue + seen_times.add(item) + cleaned_times.append(item) + cleaned_request["column_times"] = cleaned_times if "minute_step" in body: minute_step = body.get("minute_step") if isinstance(minute_step, bool) or not isinstance(minute_step, int) or not 1 <= minute_step <= 15: diff --git a/scripts/rectification/decision_policy.py b/scripts/rectification/decision_policy.py index dde7a2d2..52e1a5b9 100644 --- a/scripts/rectification/decision_policy.py +++ b/scripts/rectification/decision_policy.py @@ -51,6 +51,23 @@ def _decimal(value: Any, default: str = "0") -> Decimal: return Decimal(default) +def _column_times_for_packet( + request: RectificationRequest, + candidate_decisions: Sequence[dict[str, Any]], +) -> list[str]: + decision_times = [ + str(item.get("time") or "")[:5] + for item in candidate_decisions + if str(item.get("time") or "")[:5] + ] + requested = request.get("column_times") + if not isinstance(requested, list) or not requested: + return decision_times + wanted = {str(item)[:5] for item in requested if isinstance(item, str)} + subset = [time for time in decision_times if time in wanted] + return subset or decision_times + + _AUDIT_LABELS = { "d1-rashi": ("D1 本命盘", "本轮已按该分钟重算本命宫位。"), "d2-hora": ("D2 财帛分盘", "本轮已对照财帛主题。"), @@ -588,7 +605,7 @@ def build_decision_receipt( candidate_times=grid_times, cluster_width_minutes=width, include_discriminators=int(request.get("minute_step") or 1) <= 1, - column_times=[item["time"] for item in candidate_decisions], + column_times=_column_times_for_packet(request, candidate_decisions), ) if packet["dasha_agreement"]["status"] == "conflict": if overall_confidence == "high": diff --git a/scripts/rectification/refinement_packet.py b/scripts/rectification/refinement_packet.py index 3b4453a9..9458f188 100644 --- a/scripts/rectification/refinement_packet.py +++ b/scripts/rectification/refinement_packet.py @@ -9,8 +9,11 @@ from __future__ import annotations from typing import Any, Sequence +from scripts.rectification.candidate_contrast import MAX_PUBLIC_CLUSTERS from scripts.rectification.house_table import PLANET_ZH, SIGN_LORDS, SIGNS, SIGNS_CN +COLUMN_COMPARE_BUDGET_MS = 3000 + NAKSHATRA_SPAN = 40.0 / 3.0 NAKSHATRA_BOUNDARY_DEGREES = 2.0 MATCH_LABELS = { @@ -263,12 +266,14 @@ def column_times_for_compare( candidate_times: Sequence[str], representative_time: str | None, *, - limit: int = 9, + limit: int = MAX_PUBLIC_CLUSTERS, ) -> list[str]: """Unique HH:MM keys for by_time ledgers/windows, capped at `limit`. Compare cards still project at most three posterior columns; this set is - the engine candidate list so those clocks can look up a row. + the full public candidate list (≤64) so inference-layer clocks can look up + a row. Callers that already know a smaller active set may pass it as + `column_times` when a 64-minute compare would exceed COLUMN_COMPARE_BUDGET_MS. """ seen: list[str] = [] for raw in candidate_times: @@ -621,14 +626,22 @@ def build_refinement_packet( scan = window_scan(built) cluster = cluster_scan(built, candidate_times, representative_time, cluster_width_minutes) ledger = event_dasha_ledger(request, built, representative_time) - columns = column_times_for_compare( - column_times if column_times is not None else candidate_times, - representative_time, - ) - compare_started = perf_counter() - ledgers_by_time = event_dasha_ledgers_by_time(request, built, columns) - windows_by_time = prospective_windows_by_time(request, built, columns) - column_compare_ms = round((perf_counter() - compare_started) * 1000, 1) + minute_step = request.get("minute_step") + skip_column_compare = isinstance(minute_step, int) and not isinstance(minute_step, bool) and minute_step > 1 + if skip_column_compare: + columns: list[str] = [] + ledgers_by_time: dict[str, Any] = {} + windows_by_time: dict[str, Any] = {} + column_compare_ms = 0.0 + else: + columns = column_times_for_compare( + column_times if column_times is not None else candidate_times, + representative_time, + ) + compare_started = perf_counter() + ledgers_by_time = event_dasha_ledgers_by_time(request, built, columns) + windows_by_time = prospective_windows_by_time(request, built, columns) + column_compare_ms = round((perf_counter() - compare_started) * 1000, 1) agreement = dasha_agreement(built, candidate_times) stage = precision_stage(cluster, len(request.get("events") or [])) if not include_discriminators: diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py index aeeb9104..36d9b143 100644 --- a/tests/test_rectification_v5_services.py +++ b/tests/test_rectification_v5_services.py @@ -1116,6 +1116,63 @@ class RectificationV5ServicesTest(unittest.TestCase): self.assertEqual(set(windows), set(times)) self.assertLess(packet["column_compare_ms"], 1000) + def test_compare_ledgers_cover_seventeen_engine_candidates(self): + from scripts.rectification.refinement_packet import ( + COLUMN_COMPARE_BUDGET_MS, + build_refinement_packet, + column_times_for_compare, + ) + + times = [f"14:{minute:02d}" for minute in range(17)] + self.assertEqual(column_times_for_compare(times, "14:16"), times) + overflow = [f"{index // 60:02d}:{index % 60:02d}" for index in range(65)] + self.assertEqual(len(column_times_for_compare(overflow, overflow[0])), MAX_PUBLIC_CLUSTERS) + + body = request() + body["events"] = [ + event(1, "education", "education_start"), + event(2, "career", "promotion", precision="month"), + ] + normalized = normalize_rectification_request(body, today=date(2026, 7, 28)) + scored_ids = [item["id"] for item in normalized["events"]] + built = { + "candidate_times": times, + "matrix": { + event_id: { + clock: {"points": 4, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]} + for clock in times + } + for event_id in scored_ids + }, + "date_sensitivity": [], + "missing_layers": [], + "static_contexts": [], + } + packet = build_refinement_packet( + normalized, + built, + representative_time="14:16", + candidate_times=times, + include_discriminators=False, + ) + self.assertEqual(set(packet["event_dasha_ledger_by_time"]), set(times)) + self.assertEqual(set(packet["prospective_windows_by_time"]), set(times)) + self.assertLess(packet["column_compare_ms"], COLUMN_COMPARE_BUDGET_MS) + subset = build_refinement_packet( + normalized, + built, + representative_time="14:00", + candidate_times=times, + column_times=times[:3], + include_discriminators=False, + ) + self.assertEqual(set(subset["event_dasha_ledger_by_time"]), set(times[:3])) + body["column_times"] = times[:3] + self.assertEqual( + normalize_rectification_request(body, today=date(2026, 7, 28))["column_times"], + times[:3], + ) + def test_hour_window_keeps_all_seventeen_signature_clusters_including_late_tail(self): rows, contexts = _hour_window_signature_fixture() public = select_signature_representatives(rows, contexts)