fix(rectification): keep clock-stamped events out of window intercept (BUG-631, BUG-632)
Independent Staging Quality Gate / validate (push) Successful in 12m36s
Independent Staging Quality Gate / publish (push) Successful in 2m11s

Dated life events with clock times were swallowed as birth-window replies, and compare columns still stopped at the first nine candidates after the hour-window cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-09-10 09:23:35 +08:00
co-authored by Cursor
parent 2de2aa0b71
commit 719ff55a09
19 changed files with 395 additions and 24 deletions
+7
View File
@@ -1,5 +1,12 @@
# 印度占星 Skill 更新日志
## 2026-09-10 — 带钟点的经历不再被当成申报出生时段;对照列按全部候选分钟来算
生时校正做到一半时,如果说的是带钟点的经历(比如某日 20:00 左右分手、下午 3 点到 5 点被撞),助手不再误回「搜索范围按资料定、过程中不改」。只有在明确说出生时间,或只报一个钟点范围时,才用这句固定回复。
对照卡上的经历对照和未来时段按引擎里全部候选分钟来算(最多 64 个),不再只算前 9 个。一小时窗里若有 17 个候选,三列都能查到对照,不再出现「这一分钟还没对照」。Skill 版本不变。
## 2026-09-09 — 性格点选题只在分不开时才问,且只作排序参考
生时校正的性格类点选题(相处方式、做事风格、月宿边界)不再和带年月的经历题同权。带年月的区分题还问得完时,先问那些题;只在问完仍分不开、还剩两个以上候选时才出性格题。答了只轻轻排序,不会因为性格自评把某一分钟淘汰。验证报告里这两行标成「参考」。Skill 10.0.21。
+32
View File
@@ -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 截断)
- 修复版本:待发布
@@ -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 留下的)。
+1 -1
View File
@@ -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-626627 |
| `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-629Skill 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-631632 |
| `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-631632 |
### 聊天主链路与首页
@@ -173,6 +173,7 @@
- 口述采集没有「没有」「记不清」按钮,也没有「先这样」;输入框上方不再挂快捷回答
- 想跳过可以打字「没有」或「记不清」
- 每轮助手正文一句复述(可另接一句范围变化);气泡里没有「本轮对照了…」,也没有「很有帮助 / 很有价值 / 很有分量 / 特别有用」
- 报经历时带钟点(「20:00 左右分手」「下午 3 点到 5 点被车撞」)不得被当成申报出生时段,不得回「搜索范围开始时按资料定、过程中不改」
## 8. 性格题只作平局参考(BUG-629)
@@ -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:4514:50」「我是 14:47 左右生的」仍走固定回复。
+1 -1
View File
@@ -545,7 +545,7 @@ Admin 的 antd `<Spin>` 是独立设计系统,不在此表。
卡顶保留范围与经历数;卡底边界句保留;「查看验证报告」默认收起。不预标「排盘用」。点「更像这个」走现有 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. 校正右栏盘面
@@ -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;
}
@@ -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 = /出生时间|出生|生于|时辰|几点生|钟点是|(?<![发产陌])生的/;
const CALENDAR_DATE = /\d{4}\s*年|\d{1,2}\s*月|\d{1,2}\s*日/;
const BARE_FILLER = /上午|下午|傍晚|夜里|晚上|清晨|中午|我的|我|是|大概|大约|差不多|可能|就|在|的|了|吧|啊|呢|嗯|[,。,.、\s]|[-–—~]/g;
export type DeclaredBirthWindow =
| { kind: "range"; start: string; end: string }
@@ -20,21 +26,49 @@ export function parseDeclaredBirthWindow(message: string): DeclaredBirthWindow |
if (range) {
const start = normalizeClock(range[1] ?? "");
const end = normalizeClock(range[2] ?? "");
if (start && end && start !== end) return { kind: "range", start, end };
if (start && end && start !== end && shouldInterceptDeclaredWindow(text, range)) {
return { kind: "range", start, end };
}
if (start && end && start !== end) return null;
}
const around = AROUND_PATTERN.exec(text);
if (around) {
const time = normalizeClock(around[1] ?? "");
if (time) return { kind: "around", time };
if (time && shouldInterceptDeclaredWindow(text, around)) {
return { kind: "around", time };
}
}
return null;
}
function shouldInterceptDeclaredWindow(text: string, match: RegExpExecArray): boolean {
if (BIRTH_CONTEXT.test(text)) return true;
if (CALENDAR_DATE.test(text)) return false;
return isBareClockUtterance(text, match);
}
function isBareClockUtterance(text: string, match: RegExpExecArray): boolean {
const leftover = `${text.slice(0, match.index)}${text.slice(match.index + match[0].length)}`;
return leftover.replace(BARE_FILLER, "") === "";
}
function normalizeClock(raw: string): string | null {
const match = /([01]?\d|2[0-3])\s*[::点]\s*([0-5]?\d)/.exec(raw);
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[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 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`;
}
@@ -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<string, unknown> {
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<V9EngineScoreResult> {
const data = await postEngine("/api/rectification/v5/score", engineRequestBody(input));
const candidates = readCandidates(data.candidate_decisions, input.candidateRange);
@@ -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();
@@ -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:4514: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,
);
});
@@ -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"]);
});
@@ -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", () => {
+1 -1
View File
@@ -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(
+16 -1
View File
@@ -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:
+18 -1
View File
@@ -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":
+23 -10
View File
@@ -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:
+57
View File
@@ -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)