diff --git a/BLOCKED.md b/BLOCKED.md index 86673589..3ff515b9 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,6 +1,13 @@ # BLOCKED -## BUG-984 补单:F2 混合成功身份实证触发 SQL 授权停点(2026-09-20) +## BUG-984:受控 staging 与真人验收尚未完成(2026-09-20) + +- 本 agent 不调用线上、不读取凭据;没有本轮受控账号/浏览器会话证据。自动化仅使用明确虚构 fixture 与本地 Docker PostgreSQL,不能代替发布后登录态验收。 +- 清单:`docs/testing/rectification-cross-midnight-fix-20260920.md`。推送、独立验收及部署由主会话负责,BUG-984 暂不标 resolved。 + +## ~~BUG-984 补单:F2 混合成功身份实证触发 SQL 授权停点(2026-09-20)~~ + +- **已解除**:产品明确授权继续 B;本轮新增 `20260920010000_rectification_receipt_result_identity.sql`,只替换目标函数身份 SELECT,保留权限、owner/turn/attempt 与重试语义,不改表结构、已应用迁移或历史行。以下保留授权前停点事实。 - A 已局部实现:started/failed 不写算法身份,completed 只用实际结果来源。真实工具 + 原生 golden 的同 turn compare/diagnostics 组合可产生 scoring-9、scoring-10 两条成功回执;字符串 max 返回 scoring-9,不是最近成功 scoring-10。该测试证明缺陷仍存在,不是聚合修复通过。 - 既有 RPC 输出没有各 completed 的来源身份;历史 fingerprint 也不能还原。按补单须升 B(兼容函数体迁移),但当前执行授权禁止自行迁移,已停止进一步业务实施并报告。F1/F3/F4 pending;未改 SQL/UI/缓存、未推送。见 `docs/tasks/PROGRESS-rectification-cross-midnight-fix-20260920.md`。 diff --git a/CHANGELOG.md b/CHANGELOG.md index ec3c0abf..8dbd4e3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # 印度占星 Skill 更新日志 -## 2026-09-20 — 校正开始与失败回执不再冒充结果来源(局部实现,未发布) +## 2026-09-20 — 校正旧结果保留真实来源,无法核实时只读(未发布) + +分钟与时段缓存统一核验算法、策略和输入指纹;无法取得可信当前身份时保留旧结果,提示「按旧算法产出」,不强制重算。候选交付、选择、采用和确认由服务端重新检查,历史仍按绑定 Skill 打开。新算响应与当前身份不一致也保留真实来源并只读;成功回执聚合改取选中 attempt 最近的 completed 来源,不再取版本字符串最大值。只新增兼容函数体迁移,不修改历史行或表结构。Skill 版本、评分和确认门不变;部署与真人验收另行完成。 + +## 2026-09-20 — 校正开始与失败回执不再冒充结果来源(局部实现历史记录) compare/diagnostics 开始或失败时不写算法身份,成功时只记录实际结果来源,不回退部署默认值。同轮多个成功结果仍可能被旧 SQL 字符串聚合选错,本轮因此停在迁移授权边界;缓存只读策略尚未实施,不宣称 BUG-984 已修。Skill 版本不变。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 3bdd0ab2..62b859d6 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -13015,7 +13015,10 @@ - 相关记录:BUG-427、BUG-621、BUG-981。 - 复发自:未发现同症状既有记录;既有分钟算法身份缓存门不覆盖提前返回的时段分支,回执测试未组合新版 started 与旧缓存 completed。 - F2 补充实测:原生虚构输入 golden 经真实 compare/diagnostics 工具顺序执行,仅替换算法标签模拟滚动版本;同一 turn 可写 completed scoring-9 与 scoring-10,而字符串 max 为 scoring-9。既有聚合 RPC 不暴露各成功行身份,历史 fingerprint 无法还原,应用层 A 不足以闭环。定向身份 9/9(含缺陷诊断)、tsc 0;五文件回归 61 项中 57 pass / 4 Windows symlink EPERM,未冒称 DB 聚合已验。 -- 修复版本:`codex/rectification-cross-midnight-fix-20260920` 的 F2 A 局部实现,B 授权阻塞、未推送;缓存与聚合整体验收未通过。 +- 授权恢复与修复:产品明确授权 B 后,新增兼容函数体迁移,仅将身份聚合改为选中成功/最新 attempt 的 completed 非空来源、按时间/id稳定排序;表/已应用迁移/历史行与权限不变。分钟/时段统一完整算法+策略pair与输入指纹;未知身份保留旧值只读且不强制重算,服务端采用/确认/候选选择独立重查。历史GET/刷新和工具均标注;新算响应与当前pair不一致也只读。可信当前版本已知的stale历史保留显式「重新比较」,未知身份没有入口,避免只读锁死升级。以上替代授权前局部状态,前文保留事故与停点历史。 +- 恢复验证:真实native虚构golden的minute/block旧缓存重算及当前缓存复用、部分env冲突/接口超时与故障、缺source/非最新result、历史read→compare路径均回归;F1先红4项后绿。B真实PostgreSQL先红8≠7后绿,标准DB40/40(无skip);Python日期/bridge/memoization32/32。Linux完整基线3575/3575→最终diagnostics补丁快照3588/3588(0fail/skip),首页Static、完整28资源gzip+0.040085%,tsc/lint0error;主会话独立DB40/40、最终定向40/40+tsc通过。不提前算部署通过。 +- diagnostics追加复核:旧failure回归未调用诊断工具,新增调用先得16绿/6红,确认未知身份仍会重计算;现未知身份+旧结果仅返回只读来源/notice,不执行诊断或方法,不允许分钟确认。可信身份下诊断新响应pair不一致也只读、确认false,成功来源仍是真实9/10;修后身份22/22、身份+spoken37/37;改后全量3588/3588与build已新快照复跑通过,旧delivery证据另外保留,主会话也已独立40/40及tsc0。 +- 修复版本:`codex/rectification-cross-midnight-fix-20260920` 本地实现,未推送;前置 `3f39bafc` 已由主会话确认run2819及web/API双SHA部署,本补丁仍待独立验收、发布与受控真人走查,保持 investigating。 ## BUG-985 | 同日不变性回归写死浮点分数哈希导致跨机门禁失败 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index ec19027a..0a0db89f 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -2,6 +2,12 @@ Purpose: read this file before substantial project work. It exists to stop repeat mistakes caused by multiple Codex windows, WorkBuddy mirrors, local drafts, backup folders, and partial cloud-git visibility. +## 2026-09-20 · BUG-984 Linux 标准复验与保真归档 + +- B 经产品明确授权后新增兼容函数体迁移;此前停点仍保留。Windows checkout 将 migration symlink 变成普通文本,标准扫描报 duplicate filename;Skill 注册表另有 symlink EPERM。不要改迁移扫描器或弱化安全断言,用 `git archive` 的真实 symlink 在 Linux 复验。 +- 首轮 Linux 基线 3575 项中 4 项失败来自归档无 Git index、缺 PyYAML、缺 rsync。补工具,并用源树 `git ls-files -z` 精确恢复 tracked inventory(含原本已 tracked 但现在被 ignore 的文件,不能 `git add .` 收进日志),基线 3575/3575。临时仓只有 index,不伪称完整原 Git 历史。 +- 覆盖 `/repo` 基线的同步命令被权限系统拒绝后未重试覆盖;改为创建全新快照目录,基线、失败轮和日志均保留。Docker `bug984-runner`、DinD `bug984-docker`、volume `bug984-validation` 留给独立复跑,不清理。 + ## 2026-09-20 · BUG-984 补单前置解除与聚合授权停点 - 前轮写认证 blocker 已由主会话解除:前置代码 `3f39bafc` 已推 staging 并核对;本执行树 fetch 到后续纯文档 `f09f3d80`。下面旧记录保留为历史,不再作为当前开工 blocker。 diff --git a/docs/tasks/PROGRESS-rectification-cross-midnight-fix-20260920.md b/docs/tasks/PROGRESS-rectification-cross-midnight-fix-20260920.md index 573e6da6..70f28029 100644 --- a/docs/tasks/PROGRESS-rectification-cross-midnight-fix-20260920.md +++ b/docs/tasks/PROGRESS-rectification-cross-midnight-fix-20260920.md @@ -2,7 +2,7 @@ ## 结论与授权边界 -**blocked:F2 方案 A 已完成局部实现,实测证实同一 turn 可出现不同身份的多个 completed,按补单须升级 B;未获 SQL 迁移授权,依指令停止进一步业务实施。F1 / F3 / F4 未完成,不可交付为 BUG-984 修复。** +**本地实现与自动化完成:F1/F2/F3 已实现,diagnostics追加修复后的新快照全量3588/3588、定向37/37、tsc/lint通过;标准DB40/40,首页Static、完整28资源gzip+0.040085%。主会话独立DB40/40、diagnostics最终定向40/40+tsc通过。未推送、未部署,受控真人验收仍缺口,BUG-984保持investigating。** 以下保留授权前记录,最新状态以文末「授权恢复」为准。 - 执行树:`.worktrees/rectification-cross-midnight-fix-20260920`,分支 `codex/rectification-cross-midnight-fix-20260920`。 - 前置代码基线:`3f39bafc4a1fb7fc9d0a257d2528ea1a64295792`。主会话已成功推 staging 并核对 ls-remote,**前轮 Gitea 写认证 blocker 已解除**;保留旧失败历史,不把它当本轮阻塞。 @@ -89,3 +89,66 @@ - 独立实跑身份文件 9 / 9 通过;BUG-621 历史打开与 case-service 两文件 29 / 29 通过;`tsc --noEmit` 退出 0;前置 Python 跨午夜 bridge 9 / 9 通过。混版本项只证明缺陷可达,未执行数据库聚合验收。 - 前置 Gitea run2819(SHA `3f39bafc4a1fb7fc9d0a257d2528ea1a64295792`)的 validate 已 success,最后查询整轮仍 in_progress。staging `/login` 200、匿名 `/api/account` 401;健康响应的 web/API 部署均仍 `539d4daee4d0065f5ef3b974903ea8d639a249c6`,所以前置部署尚未确认完成。这些匿名检查不能代替缓存身份受控会话验收。 - 局部实现未推送;按任务书迁移授权停点报告,F1/F3/F4 保持 pending。 + +## 授权恢复与最终实现(2026-09-20) + +产品明确回复继续并授权 B:只新增向后兼容函数体迁移、仅修回执聚合,不改表结构/已应用迁移/历史行,保留 owner/turn/attempt 与重试语义,真跑标准 test:db。恢复时 HEAD `6dd62207eb1ab04aaac8f6755146980b7abac8b2`。原停点是正确执行历史,不删除或改写。 + +主会话补报前置 run2819 completed/success,staging health 的 deployment.gitCommit 与 apiGitCommit 均等于 `3f39bafc4a1fb7fc9d0a257d2528ea1a64295792`。前置部署确认完成;本 agent 未访问线上,这不是本补丁受控身份验收。 + +### F1 / F2 / F3 + +- minute 与 block_scan 共用算法+策略完整 pair、证据指纹、范围/基线资料指纹;完整 env 保留明确覆盖语义,部分 env 补查 native pair、冲突 fail closed,不拿前端默认常量冒充部署覆盖。时段来源 policy/range 指纹只写新 JSON,不回填历史。 +- 真实原生虚构输入 golden 经 `normalize_rectification_request`→`api_service.block_scan` 生成;旧 -7/当前 -8 minute 和 late-night 缓存均实跑应用链,旧重算、当前命中。F1 红测 11 项中 4 红(partial/unknown/旧block/当前block未探测),修后全绿。 +- B 迁移 `20260920010000_rectification_receipt_result_identity.sql` 与旧函数除注释外仅身份 SELECT 不同:选中 successful attempt,否则 latest attempt;仅 completed 非空身份,started_at DESC、id DESC。真实 PostgreSQL 先红(8≠7)后绿,覆盖9/10、失败重试、同时间稳定排序、历史不重标与owner/turn/权限。 +- 未知当前身份:旧结果只读、cached=false、来源不重标,不重算、不跑 oracle、不写 focus/inference/候选/验证;历史 GET/刷新、read-case、compare 同样显示「按旧算法产出」。新算响应与可信 pair 不同也保存真实来源并只读。 +- 服务端写门覆盖 API direct accept、service accept/confirm、offer候选、选择/推断、block推进/拒绝、widen、tie-break、focus denial及闲置/出口修复。重查 dossier+当前身份,不信客户端flag;stage/source交叉缺失、非最新resultId、无source具体采用一律拒绝,正常空Case收集不误拦。无生产引用的旧 session.ts helper未扩改。 +- 独立review发现 stale 与 unknown 同锁composer会造成旧结果无法升级,已修:旧候选仍不可写,但可信当前pair已知时、非终止Case可点「重新比较」,复用既有message入口;未知身份不显示,不自动重算。测试覆盖历史投影→read-case提示compare→实际compare新来源,以及按钮handler的stale/unknown/terminal/busy派发边界。 +- 独立review再发现 diagnostics 未经过身份门,原六种failure测试没有调用该工具。补调用后22项16绿/6红,证明未知身份仍请求了diagnostics;现入口在读取compute/诊断前短路:旧来源+notice/read_only、diagnostics=null、can_confirm_exact_minute=false、executed_methods=[],无source则engine_identity_unavailable。可信身份下仍独立诊断,新响应pair不匹配只读且不能确认,completed保持实际来源,9/10聚合用例保留;修后22/22。 +- 不改Skill、input contract、评分/确认门、历史打开绑定,不删除或重标历史缓存;UI同步DESIGN,无Home状态增长。 + +### F4 证据与复跑环境 + +| 检查 | 已完成结果 | +| --- | --- | +| Linux基线全量(HEAD6dd62207应用代码) | 3575/3575,fail/skip=0 | +| 身份及历史重算定向 | 22项;含minute/block、无/完整/部分env、冲突、接口异常/超时、来源缺失与滚动响应 | +| 最终四文件业务定向(加入stale测试之前) | 112/112 | +| Python日期+bridge+memoization | 32/32;bridge重复收集已知,不称32个独立用例 | +| 标准 `npm run test:db` | 执行方40/40,主会话独立40/40(304634ms),fail/skip=0,真实PostgreSQL17;独立日志 `<临时目录>/bug984-independent-db.log` | +| 中间完整修复轮 | 3587项,3586通过/1源码断言失败:终止提示改为historyReadonly,已附三栏同步 | +| 中间build | 首页 `┌ ○ /`;25个首页直链JS/CSS gzip 655491→655584B,+93B/+0.0142% | +| 保留的旧delivery全量 | 3588/3588,fail/skip/cancelled=0,307886ms;不替代追加diagnostics后的复跑 | +| 最终diagnostics全量 | 3588/3588,fail/skip/cancelled=0,259596ms;比基线3575增加13项 | +| 最终diagnostics build/gzip | `┌ ○ /` Static;完整28资源666078→666345B,+267B/+0.040085%,在±2%内。读取index.html全部src/href的/_next/ JS/CSS,去query后去重,各文件gzipSync默认参数求和;旧25资源655491→655758B为遗漏3资源的局部统计,由本完整口径替代 | +| 最终diagnostics/按钮/身份/spoken定向 | 37/37;含22项身份、六种故障的diagnostics调用、实际handler派发与历史read→compare行为 | +| tsc/lint | 最终diagnostics后退出0;lint 0 error/119 warning,不顺修warnings | +| BUG-621及最终独立补验 | 主会话先前43/43;身份+spoken(含按钮handler)+BUG-621独立40/40、tsc退出0;日志 `<临时目录>/bug984-independent-final-targeted.log`;diagnostics补丁后再次独立40/40且tsc0 | +| 隐私门 | 明确stage全部37个本单文件(含新增fixture/SQL)后 `python -m pytest tests/test_repo_privacy_markers.py`:62/62;删除文档内两处HOME_PREFIX,日志以临时目录占位符保留 | +| 本轮部署/真人 | 未进行,BLOCKED与真人清单保留 | + +Linux保真方式:`git archive HEAD` 保留真实symlink,增量文件在打包时替换;从不将Windows整个checkout覆到Linux。首轮基线3575/3571/4,失败为缺Git index/PyYAML/rsync;装备后3575全绿。源树`git ls-files -z`精确建立tracked index,临时repo无完整历史。基线目录带新增B迁移与原DB文件新增断言,但应用源码是HEAD;DB仍同一个test,应用基线总数不变。 + +- 保留 Docker runner `bug984-runner`(node22-bookworm)、DinD `bug984-docker`、volume `bug984-validation`;无线上数据/凭据。 +- 基线 `/repo/frontend`;中间 `/repo/bug984-fixed/frontend`、`/repo/bug984-final/frontend`;最终 `/repo/bug984-delivery/frontend`。这些是容器内路径,主会话复验不要用Windows主检出路径。 +- diagnostics追加修复使用新快照 `/repo/bug984-diagnostics/frontend`,旧delivery与其证据不覆盖;最终30个修改/新增frontend文件在stage前与新快照逐文件SHA256相同(0差异),含SQL与DB测试;stage后diff-check发现新增SQL/golden的CRLF,已仅转为LF(内容/JSON/SQL语义不变),最终字节比较28相同、这2项去CRLF后相同。快照文档为创建时点;之后只补最终数字/独立证据,不影响源码测试。新增三文件也按明确清单加入临时tracked index。 +- 标准复跑:`MSYS_NO_PATHCONV=1 docker exec -w /repo/bug984-diagnostics/frontend bug984-runner npm test`;同命令末尾换 `npm run test:db`、`npm run build`、`./node_modules/.bin/tsc --noEmit`、`npm run lint`。SQL和DB测试在diagnostics补丁中未改,沿用两方实际40/40,不伪称重复跑DB。 +- 日志(volume内绝对路径):`/repo/baseline-equipped-tests.log`、`/repo/baseline-build.log`、`/repo/baseline-gzip.json`;`/repo/fixed-standard-db.log`(标准40/40);`/repo/fixed-tests.log`、`/repo/final-tests.log`(中间失败留存);旧delivery `/repo/delivery-tests.log`、`/repo/delivery-build.log`、`/repo/delivery-gzip.json`;最终 `/repo/diagnostics-tests.log`、`/repo/diagnostics-targeted.log`、`/repo/diagnostics-lint.log`、`/repo/diagnostics-build.log`、`/repo/diagnostics-gzip.json`。 +- 曾同步覆盖基线被权限系统拒绝,未重试覆盖,改为创建全新目录;首次final build抢在依赖copy完成前报next not found,等待copy完成再建成功。没有改业务/依赖声明来规避平台问题。 + +### 本轮既有断言/fixture调整(三栏) + +| 原值 | 新值 | 原因 | +| --- | --- | --- | +| 部分policy env返回algorithm null、旧cache可用、0fetch | native完整pair、旧cache不可用、1fetch | 部分身份不能绕过算法核验;完整pair仍0额外fetch | +| 版本接口失败旧cache reusable=true | false并只读 | 策略b,保留显示不等于当前缓存命中 | +| 业务test-support无显式部署身份 | 与其fixture一致的rectification-v5/policy-v2完整env | 正常业务回归有受控可信身份,故障测试单独清env;不放宽生产 | +| block业务fixture algorithm=test、无policy | 与受控env一致的v5/policy-v2 | 不改blocks/分数/选择预期,仅补真实身份语义 | +| 两个业务score替身algorithm误用event-contract-v2 | rectification-v5,event_contract_version保持原值 | 与test-support可信部署一致,不把滚动不一致误算正常业务 | +| 直接accept/confirm及collect替身没有dossier重读RPC | 补原candidate/dossier fixture | 新服务端写门必须重读,权限/确认/问题预期不变 | +| native policy滞后测试继承fixture env | 测试内清pair并恢复 | 必须真实走versions替身,不被完整env覆盖 | +| 无source采用返回candidate_not_found | RPC前result_identity_read_only | 提前拒绝不可核验具体结果,不能产生候选 | +| question/current_question/choice_card无只读分支,GET同步projection | 只读null,GET await可信projection | 历史直接刷新同样受保护,非删除断言 | +| 终止UI源码readonly && | historyReadonly && | 身份不可核验不等于Case已结束;不诱导强制新建 | + +新增源形合同全部采用真实native golden;既有业务替身只是补身份或RPC,不声称为新合同golden。所有示例明确虚构,未使用用户账号或资料。 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 7c0c7513..0e1dc606 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -293,7 +293,7 @@ | `TASK-rectification-validation-integrity-20260920.md` | `PROGRESS-rectification-validation-20260920.md` | **生时校正验证体系补缺(纯离线评测,不改打分不改产品)**:会议要求把「推断真实出生时间」与「用户认可的参考盘」分开证明。核对结论——**产品口径侧四条已落地**(`accepted`≠`confirmed` 两条写入路径、确认门 fail-closed 且 `holdout` 为 `not_ready` 使 `confirmation_allowed` 不可能为真、采用不写 `reported_birth_time`、无任何把采用率当准确率的指标;运行时也无按生日走捷径的分支);**缺口全在评测本身**。三条:① 封存契约 `rectification_sealed_holdout.v1.json` 的三个打分哈希互不相同(封存 `f41c298d` / 契约记录 `99730c84` / 基线实测 `b15d9ea1`),`official_eval_trial_count: 0`——当前实现**从未产出过一次有效官方盲测**,唯一跑过那次已被资料审计作废(top-1 `0.15`,发布门要 `0.60`),可见的 `0.45` 自带「不得当发布指标」标记(BUG-978);② 全部离线评测的候选窗**以真值为圆心**(`_candidate_moments()`、`request_from_case()` 的 `true_time`),生产以申报时间为圆心(`ENGINE_SEARCH_RADIUS_MINUTES = 15`)——「真值掉出窗外」这一失败模式从不可见(BUG-979);③ v3 封存但每例仅 3 事件、v4 有 7+ 事件却已被看过并用于调参,**无口径干净又贴近真实会话的封存集**;六题回放的 `0.80/0.55/0.35` 是「真值方向最优答」的上帝视角上界,±30/±60 仍低于发布门(BUG-980)。T1 申报偏差敏感性 sweep、T2 有效重跑 + 契约对齐 + **防复发新测试**、T3 只出 v5 采集协议、T4 记录。**硬红线:不得改 12 个打分文件、不得用封存集调参、不得把 `status` 改 `ready`。** 家庭信息(父母职业/兄弟姐妹)在拿到基线数字前不开工——现有七领域全是带日期事件,静态属性没有输入口。前置:owner-case-purge 三提交仍未合入 staging。BUG-978~980 | **已验收通过(2026-09-20,Claude 独立复算)**;独立盲测仍 blocked;门禁/部署待核验 | `932f2fff`。Claude 独立复核:T1 完整重跑 900 组合**逐例 8 字段 0 差异**、summary/specification 全等,另从 900 条逐例重算 45 格表与发布表逐位相同;T2 独立复跑 0.45/0.50/6.45/0/1.0/0.0 与哈希全序 0.05/0.10 全部一致;12 个打分文件字节未动、`status=not_ready`、`confirmation_coverage_rate=0`、实跑 `holdout_passed()`=False;新增/触及 Python 测试本机 38 条全绿;freshness 测试比对时排除 `frozen_at_utc`(BUG-693/694 教训已用上)。**执行方正确推翻任务书 T2.3**:「首次口径干净的官方盲测」与 BUG-428 防复发(已看过结果的案例不得再计入盲测)冲突,已核 BUG-428 原文,是任务书写错。环境缺口:本机 frontend 无 node_modules,tsc/lint/npm test/build 未能复核,采信执行方自报。遗留 → `TASK-rectification-cross-midnight-dasha-20260920.md`(BUG-981) | | `TASK-rectification-cross-midnight-dasha-20260920.md` | — | **跨午夜候选的 Dasha 边界错一天(生产打分)**:`scoring_service.py` 调 `merge_transition_proximity()` 只传**一个** `birth_date`,该函数用它算全部候选的 Vimshottari / Narayana 起始日期,候选之间只靠 `_context_time()` 的 `HH:MM` 区分、**日期被丢掉**。窗口跨午夜时午夜后候选的 dasha 边界整体错一天。Claude 验收时在生产调用链独立复现:窗口 `23:50→00:10`、21 个候选,**恰好那 11 个跨日候选分数错、10 个同日候选逐位相同**(幅度 +0.0267 / −0.0133,本例头名未变)。算术上界 = `cap/kernel_width`,day 精度 0.067 分/件,18 件可累计约 1.2 分,而随分钟变化项总量仅约 2.1 分 —— **上界是推的不是实测,真实幅度本单必须实测**。踩中路径:`late_night` 时段 `23:00–03:59`、`unknown` `00:00–23:59`、23:45 后或 00:15 前申报的 ±15 窗。连带发现:`calculation_spec()` 不含打分实现身份,修复后同一 spec hash 对应不同分数,历史 Case 静默失去可复现性(同 BUG-427 类型)。**产品 2026-09-20 已就三点拍板:A 修、B 修完重新冻结并重跑 T1/T2、C 让新旧结果可区分。** C 的做法经查证已修正:`calculation_spec_hash` 全在 **V4** 链路、**V9 零引用**,原提案 bump `INPUT_CONTRACT_VERSION` 对真实历史无效已作废;改为随修复 bump `engine_version`(`v9EngineVersion()` 缺省串用于 started/failed;成功回执及 minute 缓存身份来自后端,旧「只写不比」假设作废),**不得动 `skill_version`**(BUG-621:open RPC 要求绑定 Skill 等于当前版本,bump 会让历史校正打不开)。硬红线:只改「按候选日期取 dasha 起始」,不得动 kernel/cap/share 任一常数;确认门不变。BUG-981 | **核心修复与 BUG-985 已 review 通过,本地合并 b27d4de9;推 staging 认证失败,端到端仍受 BUG-984 阻塞** | 实现 `aa46da10`(分支 `codex/rectification-cross-midnight-20260920`,未合入 staging)。Claude 独立 review:修复后整窗打分与**逐候选独立重算 21/21 一致**;非跨午夜分数基线 vs 修复**逐位不变**(我测半径 10 六例 + 半径 60 三例,执行方证据覆盖 19 例 / 2299 候选全部 `bytes_equal`);kernel/cap/share/`PRECISION_WEIGHTS` 一个未动;`status=not_ready`、coverage 0、`holdout_passed()` False、官方试次 0;决策 C 执行正确(`ALGORITHM_VERSION` 7→8,`INPUT_CONTRACT_VERSION` 与 `skill_version` 未动,golden 里 `calculation_spec_hash` 不变可证);既有断言改动均带三栏说明且为加强。**P1 阻塞**:新增的 `test_same_day_public_aa_scores_keep_pre_fix_bytes` 写死 121 个浮点分数的 SHA-256,在 Claude 机器上 ordinal 2/3 红、**基线与修复分支同样红**(非修复所致),且经 `test_rectification_cross_midnight_gate.py` re-export 落入快速门 glob —— 同 glob 基线 214 passed/0 failed、`aa46da10` 4 failed,推 staging 会让门禁红。执行方自报未通过的 BUG-984 经独立确认成立且更重:`block_scan` 只比 evidence 指纹不读打分身份,而 `late_night`(299 min)/`unknown`(1439 min) 均 >120 走该分支、又恰是最易跨午夜的窗口 | | `TASK-rectification-cross-midnight-gate-fix-20260920.md` | `PROGRESS-rectification-cross-midnight-gate-fix-20260920.md` | **review 修复单:门禁级浮点哈希断言(`aa46da10` 的合入阻塞项)**:新增回归把 121 个分数的 SHA-256 写死成字面量,于是断言范围把跨机 libm/pyswisseph 差异也圈了进去。实测该组 121 个分数**全无浮点尾噪**(`repr(s)==repr(round(s,4))` 121/121),所以差异是第 4 位真的不同,靠改序列化消不掉。同一提交刚改过的 `test_rectification_engine_memoization.py` docstring 正好写着「跨机舍入已漂 1.1e-3,不得整体 `==` 比较」——教训被引用了又被踩。执行方自己的证据 JSON 用的却是正确做法(`scores_bytes_equal` = 同机基线 vs 当前)。**根因**:要证的是相对不变量(同机基线 vs 当前),却实现成绝对不变量(当前 vs 某台机器的历史哈希)。F1 改用生产代码已有的 legacy 回退路径做同机 A/B 对照(去掉 `candidate_at` 即修复前行为),验收要求**两台浮点环境不同的机器各跑一次**、且回退核心修复后该测试仍绿(证明它守的是同日不变性而非跨午夜回归的替身);F2 bridge 重复收集(4 failed = 2×2);F3 查证 `block_scan` 重算是否经过被修 helper —— 会则 BUG-981 在 late_night/unknown 路径等于没上线、BUG-984 升为阻塞项;F4 记录。**硬红线:不得删测试/skip/把哈希改成本机当前值消红,不得改已验收的打分代码。** 串行:本单 → `aa46da10` 合入 → BUG-984 补单。BUG-985 | **已 review 通过;BUG-985 resolved;合入推送被 Gitea 认证阻塞,远端仍 a3577ce2** | 实现 `25232ce4`(分支 `codex/rectification-cross-midnight-gate-fix-20260920`)。F1 改为同进程 A/B:在 `scoring_service.merge_transition_proximity` 调用边界剥掉 `candidate_at` 走生产既有 legacy 回退,对比 121 个分数与整份贡献矩阵的 canonical 字节;另加 `legacy_calls == [121]` 与 `static_contexts is contexts` 两道防空转保险。Claude 复核:写死哈希字面量 **0 残留**;**第三套环境(Linux + Python 3.13,与执行方 Windows 3.11.7 不同)定向 18 条全绿** → 两机验收闭环;**回退日期修复探针:同日 3 条全绿、跨午夜相关 4 条转红**,证明同日不变性与跨午夜正确性已真正分开;快速门 glob staging `1b646659` 200 passed/0 failed → `25232ce4` 216 passed/0 failed(+16,零新增失败,总数未降);相对合并点仅动 2 个测试文件 + 文档,`scripts/`、前端、golden、打分常数零改动。BUG-985 记录含我要求的防复发条,并正确认定为 BUG-733 同形复发。**建议 BUG-985 由 `blocked` 改 `resolved`**(证据即第三环境复跑)。环境缺口:完整快速门在 Claude 机器 120 秒超时被杀,6→4 那组数以执行方记录为准 | -| `TASK-rectification-cross-midnight-dasha-fix-20260920.md` | `PROGRESS-rectification-cross-midnight-fix-20260920.md` | **BUG-984 缓存与结果身份补单(BUG-981 的端到端阻塞项)**:`scoreAndPersistCurrentEvidence()` 的 `block_scan` 分支只比 `evidenceLedgerFingerprint` 即返回 `cached:true` 与旧 `algorithmVersion`,该返回发生在 `readV9EngineScoringIdentity()` **之前**;`minute` 分支则有身份门。F3 已查证完整调用链到 `merge_transition_proximity()`,故核心修复合入后,**证据未变的历史跨午夜时段缓存命中仍返回修复前分数**——在本单闭环前不得声称跨午夜问题已修。边界已按源码收窄:`late_night`(23:00–03:59) 跨日;`unknown`(00:00–23:59) 虽 >120 分钟但**本身同日**,选中跨午夜子时段后才触发(此处修正了 Claude 先前把两者并列的说法)。产品 2026-09-20 放行且**同日拍板 F3 策略选 b**:版本接口取不到可信身份时,旧缓存**只读展示 + 显著标注「按旧算法产出」**,否决 a(重算,会把接口抖动放大成长等待,时段扫描受 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY=2` 限流)与 c(照常复用,与已定原则冲突)。b 的三条边界:只读结果**服务端拒绝采用/确认**(靠删不靠藏,须有定向用例)、标注必须用户可见并对照 `VOICE.md`(涉界面同提交更新 `DESIGN.md`)、回执来源身份仍是产出它的版本。**F2 的 SQL 问题已查清并定序(A 先上 / B 兜底 / 第 10 版前必须解决)**:`engine_version` 一个字段被「部署声称的版本」(started/failed 行,取前端常量)与「实际产出结果的版本」(completed 行,命中旧缓存即旧版本)共用,聚合却用与版本先后无关的字符串 `max`。**该缺陷此前一直撞对,`aa46da10` 之后才变真错**:它把 `v9EngineVersion()` 缺省由 `rectification-v5` 改为 `…scoring-8`,started 行遂在字符串序上反超 completed 行 → 回执显示第 8 版而分数来自第 7 版缓存;已核 `deploy/`、`.gitea/` 未设 `RECTIFICATION_ENGINE_VERSION`,走缺省,**是真实行为**。第二个缺陷:实跑 `max("…-10","…-9") = "…-9"`,**该聚合在第 10 版静默反向**(现为第 8 版)。A=started/failed 不再写版本(应用层,必做);A 的漏洞(同 turn 多个 completed 行版本不同)**必须实测取证,不得以「应该不会」结案**;B=聚合改取成功结果那一行(只改函数体,向后兼容);C=拆列本单不做。**四项全部可开工。**硬红线:不重标/不删历史结果,不 bump Skill,不改 V4 input contract,不引入按 `engineVersion` 拒绝打开历史会话。串行:BUG-985 合入 → 本单。BUG-984 | **blocked:F2 A 局部完成,多 completed 混版本实测成立,等待 B 的 SQL 授权;F1/F3/F4 pending** | `codex/rectification-cross-midnight-fix-20260920`;前置认证已解除,代码基线 `3f39bafc`、文档基线 `f09f3d80`;本轮未推送 | +| `TASK-rectification-cross-midnight-dasha-fix-20260920.md` | `PROGRESS-rectification-cross-midnight-fix-20260920.md` | **BUG-984 缓存与结果身份补单(BUG-981 的端到端阻塞项)**:`scoreAndPersistCurrentEvidence()` 的 `block_scan` 分支只比 `evidenceLedgerFingerprint` 即返回 `cached:true` 与旧 `algorithmVersion`,该返回发生在 `readV9EngineScoringIdentity()` **之前**;`minute` 分支则有身份门。F3 已查证完整调用链到 `merge_transition_proximity()`,故核心修复合入后,**证据未变的历史跨午夜时段缓存命中仍返回修复前分数**——在本单闭环前不得声称跨午夜问题已修。边界已按源码收窄:`late_night`(23:00–03:59) 跨日;`unknown`(00:00–23:59) 虽 >120 分钟但**本身同日**,选中跨午夜子时段后才触发(此处修正了 Claude 先前把两者并列的说法)。产品 2026-09-20 放行且**同日拍板 F3 策略选 b**:版本接口取不到可信身份时,旧缓存**只读展示 + 显著标注「按旧算法产出」**,否决 a(重算,会把接口抖动放大成长等待,时段扫描受 `JYOTISH_HEAVY_COMPUTE_CONCURRENCY=2` 限流)与 c(照常复用,与已定原则冲突)。b 的三条边界:只读结果**服务端拒绝采用/确认**(靠删不靠藏,须有定向用例)、标注必须用户可见并对照 `VOICE.md`(涉界面同提交更新 `DESIGN.md`)、回执来源身份仍是产出它的版本。**F2 的 SQL 问题已查清并定序(A 先上 / B 兜底 / 第 10 版前必须解决)**:`engine_version` 一个字段被「部署声称的版本」(started/failed 行,取前端常量)与「实际产出结果的版本」(completed 行,命中旧缓存即旧版本)共用,聚合却用与版本先后无关的字符串 `max`。**该缺陷此前一直撞对,`aa46da10` 之后才变真错**:它把 `v9EngineVersion()` 缺省由 `rectification-v5` 改为 `…scoring-8`,started 行遂在字符串序上反超 completed 行 → 回执显示第 8 版而分数来自第 7 版缓存;已核 `deploy/`、`.gitea/` 未设 `RECTIFICATION_ENGINE_VERSION`,走缺省,**是真实行为**。第二个缺陷:实跑 `max("…-10","…-9") = "…-9"`,**该聚合在第 10 版静默反向**(现为第 8 版)。A=started/failed 不再写版本(应用层,必做);A 的漏洞(同 turn 多个 completed 行版本不同)**必须实测取证,不得以「应该不会」结案**;B=聚合改取成功结果那一行(只改函数体,向后兼容);C=拆列本单不做。**四项全部可开工。**硬红线:不重标/不删历史结果,不 bump Skill,不改 V4 input contract,不引入按 `engineVersion` 拒绝打开历史会话。串行:BUG-985 合入 → 本单。BUG-984 | **本地完成:diagnostics追加修复后全量3575→3588全绿,标准DB40/40,Static/完整28资源gzip+0.040085%,tsc/lint通过;主会话独立DB40与最终定向40全绿。未推送,真人/部署待主会话** | `codex/rectification-cross-midnight-fix-20260920`;前置认证已解除,代码基线 `3f39bafc`、文档基线 `f09f3d80`;本轮未推送 | ## 命名与归档 diff --git a/docs/testing/rectification-cross-midnight-fix-20260920.md b/docs/testing/rectification-cross-midnight-fix-20260920.md index cb4cc63d..d7de7bf1 100644 --- a/docs/testing/rectification-cross-midnight-fix-20260920.md +++ b/docs/testing/rectification-cross-midnight-fix-20260920.md @@ -1,18 +1,18 @@ # BUG-984 补单验收清单(2026-09-20) -当前 **blocked**:F2 A 局部实现,多 completed 混版本实测触发 B,SQL 未获授权;F1/F3 尚未实现。以下不是已通过清单,不能将本分支当完整修复部署。 +B授权停点已解除,F1/F2/F3本地实现;标准DB40/40,完整结果以PROGRESS最终记录为准。下面区分自动化与尚未完成的受控staging真人验收,不能把本地通过等同已部署。 ## 自动化续验 -- [ ] 获准 B 后新增兼容函数体迁移,不修改旧迁移,标准 `npm run test:db` 实跑。 -- [ ] 同一成功 attempt 的 started -8 / completed -7 只展示 -7;历史行字节不变。 -- [ ] 同 turn compare / diagnostics 多 completed -9 / -10 不按字符串序选 9;明确取最新成功的稳定排序与各工具来源语义。 -- [ ] 失败后重试、成功后失败、不同 attempt、历史未带新 metadata 都覆盖,不能用当前 dossier 身份替代历史来源。 -- [ ] minute / late-night block_scan 均用真实 golden 测同身份命中、旧身份重算;未知当前身份时旧缓存只读、不强制重算。 -- [ ] 无覆盖、完整覆盖、部分覆盖、版本接口超时/错误均区分当前身份与来源。 -- [ ] 不经界面直接调所有采用/确认/候选选择写入口,旧只读结果均由服务端拒绝。 -- [ ] `BUG-621` 历史按绑定 Skill 打开回归;不增加 engineVersion 打开相等门。 -- [ ] tsc、lint、定向、全量、build `/` Static、首屏 gzip ±2%;实际平台失败与基线逐项对照,不削弱断言。 +- [x] 获准 B 后新增兼容函数体迁移,不修改旧迁移,标准 `npm run test:db` 实跑40/40。 +- [x] 同一成功 attempt 的 started -8 / completed -7 只展示 -7;历史行字节不变。 +- [x] 同 turn compare / diagnostics 多 completed -9 / -10 不按字符串序选 9;明确取最新成功的稳定排序与各工具来源语义。 +- [x] 成功后失败重试、不同 attempt、历史未带新 metadata、latest failed-only均覆盖,不能用当前 dossier 身份替代历史来源。 +- [x] minute / late-night block_scan 均用真实 golden 测同身份命中、旧身份重算;未知当前身份时旧缓存只读、不强制重算。 +- [x] 无覆盖、完整覆盖、部分覆盖、版本接口超时/错误均区分当前身份与来源。 +- [x] 服务层采用/确认/推进、工具offer与compare/read/diagnostics、idle/repair行为回归,旧只读结果由服务端拒绝;真实HTTP伪造请求与浏览器交互留真人栏。 +- [x] `BUG-621` 历史按绑定 Skill 打开回归;不增加 engineVersion 打开相等门。 +- [x] diagnostics追加修复后tsc/lint0error、最终定向37/37、新快照全量3588/3588(基线3575/3575)、build `/` Static、完整28个首屏JS/CSS gzip+0.040085%;Windows平台问题以保真Linux补验,不削弱断言。 ## 受控 staging 真人验收(由主会话安排) @@ -20,9 +20,11 @@ - [ ] 先核对部署 gitCommit(允许其后纯文档差异),不是只看 HTTP 200。 - [ ] 依次测试分钟历史结果与 late-night 时段历史结果;记录是否缓存、实际来源版本、是否重算,不记个人资料。 -- [ ] 版本探测异常时旧结果仍可查看,显著出现“按旧算法产出”;没有采用/确认/选择写入口,伪造请求也拒绝。 +- [ ] 版本探测异常时旧结果仍可查看,显著出现“按旧算法产出”;没有采用/确认/选择写入口,伪造请求也拒绝;诊断工具不重计算,只读来源不变、can_confirm_exact_minute=false。 - [ ] 已生成历史回执来源保留,不显示 started 的部署版本;旧 Skill 会话仍可从侧栏打开。 -- [ ] 恢复版本接口后当前同身份缓存可正常复用,身份变化按新规则重算,不删除或重标旧结果。 +- [ ] 恢复版本接口后当前同身份缓存可正常复用;旧身份的非终止历史显示「重新比较」,点击确实启动一次既有流程,普通旧候选不能采用。打开/刷新本身不强制重算;接口再次失败则仍只读,不删除或重标旧结果。 +- [ ] 历史首次打开与刷新即有只读说明,不必先compare;unknown时无重新比较按钮,不错误显示「该校正已结束」。 +- [ ] 新算响应版本与可信当前pair不一致时保存真实来源,仍不可采用,不能被前端默认版本重标。 - [ ] 失败重试后活动与成功来源一致;多人/多个请求压力下没有把版本接口抖动放大成强制重计算。 当前没有浏览器级完成证据,本 agent 未调用线上。详见 `docs/tasks/PROGRESS-rectification-cross-midnight-fix-20260920.md`。 diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 8f0a55f9..9096532d 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -2,6 +2,10 @@ This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped Jyotisha application. `CLAUDE_DESIGN.md` remains the upstream reference; this file is the implementation contract. +## 校正历史结果身份只读(2026-09-20) + +历史打开、刷新与比较返回的结果若无法核实为当前算法及策略,消息区顶部复用 `rectification-pending-note`、`role="status"` 显示「按旧算法产出。当前暂不能核实结果,请先查看,暂不能选择或采用。」保留原历史内容和来源,不出现强制重算入口;关闭旧结果选项、候选采用及普通输入,输入提示为「当前结果仅供查看」。可信当前身份已知但与旧结果不同,非终止会话可显式点「重新比较」,复用既有发送链路;身份未知时无此入口,服务端再次探测失败也不能重算。此状态不等同 Case 已结束,不显示误导的结束提示或额外「再次校正」按钮。真正终止的历史仍沿用原结束操作。服务端独立核验所有结果写入,不以界面禁用代替权限。无新颜色、加载动画或 Home 状态。 + ## 普通对话寒暄轮(2026-09-20) 服务端标记 `responseKind: smalltalk` 的回复仅展示正文与既有头像/消息操作,不展示思考面板、活动步骤、已完成步骤或技法证据;刷新后的历史也遵循此规则。分类尚未确定、且没有任何真实 activity/thinking/timeline 时,保留消息位置,不虚构「正在分析」或步骤,不另加 spinner。咨询收到真实活动后恢复既有呈现。完成后读取服务端账户余额,寒暄预留点数原额退回,净点数不变;不增加 Home 状态。 diff --git a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts index 0b338362..c0cfe015 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/candidates/accept/route.ts @@ -22,6 +22,7 @@ const acceptSchema = z.object({ function errorResponse(error: unknown) { const message = error instanceof Error ? error.message : String(error); const known = [ + ["result_identity_read_only", 409, "按旧算法产出,暂不能采用"], ["agentic_rectification_candidate_not_found", 404, "候选记录不存在或已过期"], ["agentic_rectification_candidate_expired", 409, "候选结果已过期,请重新比较"], ["agentic_rectification_candidate_selection_blocked", 409, "当前还不能采用候选"], @@ -114,6 +115,8 @@ export async function POST(request: Request, context: RouteContext) { const { publicDecisionFields } = await import( "@/lib/rectification-agentic/core/rectification-decision" ); + const { assertV9ResultWritable } = await import("@/lib/rectification-agentic/v9/result-identity"); + await assertV9ResultWritable(accounting, user.id, caseId, parsed.data.resultId); const dossier = await loadV9CaseDossier(accounting, user.id, caseId); const decision = decideFromDossier(dossier, { currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence), diff --git a/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts index 62e900d8..d6dd234e 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/repair-exit/route.ts @@ -12,7 +12,7 @@ import { listV10ConversationFocuses, } from "@/lib/rectification-agentic/v9/tool-service"; import { ensureNonTerminalTurnExit } from "@/lib/rectification-agentic/v9/answer-choice"; -import { dossierResponse } from "@/lib/rectification-agentic/v9/case-dossier-response"; +import { dossierResponseWithIdentity } from "@/lib/rectification-agentic/v9/case-dossier-response"; export const runtime = "nodejs"; @@ -77,7 +77,7 @@ export async function POST(request: Request, context: RouteContext) { const listed = await listV10ConversationFocuses(accounting, user.id, caseId); return NextResponse.json({ ok: true, - ...dossierResponse(dossier, receipts, skillIdentity, { listed }), + ...await dossierResponseWithIdentity(dossier, receipts, skillIdentity, { listed }), }); } catch (error) { if (error instanceof RectificationToolServiceError) { diff --git a/frontend/src/app/api/rectification/cases/[caseId]/route.ts b/frontend/src/app/api/rectification/cases/[caseId]/route.ts index 924a47cb..16b01c92 100644 --- a/frontend/src/app/api/rectification/cases/[caseId]/route.ts +++ b/frontend/src/app/api/rectification/cases/[caseId]/route.ts @@ -13,7 +13,7 @@ import { listV10ConversationFocuses, safeToolErrorCode, } from "@/lib/rectification-agentic/v9/tool-service"; -import { dossierResponse } from "@/lib/rectification-agentic/v9/case-dossier-response"; +import { dossierResponseWithIdentity } from "@/lib/rectification-agentic/v9/case-dossier-response"; export const runtime = "nodejs"; @@ -96,7 +96,7 @@ export async function GET(request: Request, context: RouteContext) { }), ); const listed = await listV10ConversationFocuses(accounting, user.id, caseId); - return NextResponse.json(dossierResponse(dossier, receipts, skillIdentity, { fullDetail, listed })); + return NextResponse.json(await dossierResponseWithIdentity(dossier, receipts, skillIdentity, { fullDetail, listed })); } catch (error) { if (error instanceof RectificationToolServiceError) { const message = error.message; diff --git a/frontend/src/components/rectification-agentic-chat.tsx b/frontend/src/components/rectification-agentic-chat.tsx index 9b486a8f..3ec583a7 100644 --- a/frontend/src/components/rectification-agentic-chat.tsx +++ b/frontend/src/components/rectification-agentic-chat.tsx @@ -64,6 +64,7 @@ import { isAbortError, rectificationConversationState, rectificationInitialLiveLabel, + recompareHistoricalResult, rectificationQuestionGapState, rectificationReadonlyRangeCopy, contrastProbesFromReceipt, @@ -389,7 +390,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { const { caseId, sessionId, - readonly, + readonly: historyReadonly, shouldStartOpening, initialTurns, initialSnapshot, @@ -410,6 +411,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { headerSlot, } = props; + const [resultNotice, setResultNotice] = useState(initialSnapshot?.case?.result_notice ?? null); + const [canRecompare, setCanRecompare] = useState(initialSnapshot?.case?.result_identity?.can_recompare === true); + const readonly = historyReadonly || Boolean(resultNotice); + const conversationReadonly = historyReadonly || (Boolean(resultNotice) && !canRecompare); const [messages, setMessages] = useState(() => messagesFromTurns(initialTurns)); const [draft, setDraft] = useState(""); const queued = useQueuedMessage(); @@ -583,6 +588,8 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { } | null) => { if (!payload) return; const snapshot = payload as RectificationCaseSnapshotPayload; + setResultNotice(snapshot.case?.result_notice ?? null); + setCanRecompare(snapshot.case?.result_identity?.can_recompare === true); const nextCandidate = parseRectificationCandidateResult(payload.latest_result); const nextQuestion = currentQuestionFromSnapshot(payload.current_question); const nextChoice = parseRectificationChoiceCard(payload.choice_card); @@ -693,7 +700,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { continuation?: Readonly<{ reuseAssistantRenderKey: string; label: string }>, ) => { const trimmed = action === "message" ? messageText.trim() : ""; - if ((action === "message" && !trimmed) || readonly) return; + if ((action === "message" && !trimmed) || conversationReadonly) return; if (!continuation && busy) return; setError(""); setPending(true); @@ -1018,7 +1025,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { lastRunOutcome.current = readonly ? "readonly" : runOutcome; setPending(false); } - }, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onOpenBilling, onProfileIncomplete, readonly, rememberLiveActivity, selectedModelId, sessionId, setPending]); + }, [beginLiveRun, busy, caseId, loadCaseSnapshot, onCompleted, onMessagesChange, onOpenBilling, onProfileIncomplete, readonly, conversationReadonly, rememberLiveActivity, selectedModelId, sessionId, setPending]); useEffect(() => { if (busy) return; @@ -1688,6 +1695,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { aria-busy={busy || regeneratingMessageKey !== null} >
+ {resultNotice &&

{resultNotice}

} + {resultNotice && canRecompare && !historyReadonly && ( + + )} {pendingConsultationQuestion?.trim() && (

先陪你核对出生时间范围;结束后新建对话,按采用的时间再问:“{pendingConsultationQuestion.trim()}” @@ -1822,7 +1833,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {

)} {error &&

{error}

} - {readonly && ( + {historyReadonly && (

该校正已结束,只能查看历史。

@@ -1839,8 +1850,10 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) { inputRef={composer} value={draft} remainingId={composerRemainingId} - inputLabel={readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"} - placeholder={readonly + inputLabel={resultNotice ? "当前结果仅供查看" : readonly ? "该校正已结束,只能查看历史" : "继续描述你的经历或回答"} + placeholder={resultNotice + ? "当前结果仅供查看,暂不能选择或采用。" + : readonly ? "该校正已结束,只能查看历史;需要再次校正请新建。" : showLiveChoiceCard ? "点上面的选项即可;想补一句细节再写" diff --git a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts index bbd469d4..10993fad 100644 --- a/frontend/src/lib/rectification-agentic/v9/answer-choice.ts +++ b/frontend/src/lib/rectification-agentic/v9/answer-choice.ts @@ -619,6 +619,8 @@ export async function applyRectificationChoice( accounting: AccountingClient, command: ApplyChoiceCommand, ): Promise { + const { assertV9ResultWritable } = await import("./result-identity.ts"); + await assertV9ResultWritable(accounting, command.userId, command.caseId); const dossier = await loadV9CaseDossier(accounting, command.userId, command.caseId); if (dossier.case.sessionId !== command.sessionId) { throw new RectificationToolServiceError("agentic_rectification_case_session_mismatch"); @@ -1003,6 +1005,12 @@ export async function persistNextInterviewAfterChoice(input: { followup?: MethodFollowup | null; terminalNote?: boolean; }> { + const { resultIdentityView, OLD_ALGORITHM_NOTICE } = await import("./result-identity.ts"); + const { readV9EngineScoringIdentity } = await import("./engine-client.ts"); + const identityDossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); + if (resultIdentityView(identityDossier, await readV9EngineScoringIdentity()).read_only) { + return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE }; + } const birthDate = input.birthDate ?? null; let liveDossier = dossierWithCurrentInference(input.dossier, input.decisionState); let decisionState = input.decisionState; @@ -1296,6 +1304,8 @@ export async function requestTieBreakPersonality(input: { hostNarration: string; focus: ConversationFocus | null; }> { + const { assertV9ResultWritable } = await import("./result-identity.ts"); + await assertV9ResultWritable(input.accounting, input.userId, input.caseId); const dossier = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); if (dossier.case.acceptedTime) { return { @@ -1478,6 +1488,11 @@ export async function applyCollectFocusDenial( ? RECTIFICATION_USER_COPY.collectSkippedAck : RECTIFICATION_USER_COPY.collectDeclinedAck; const dossier = await loadV9CaseDossier(accounting, input.userId, input.caseId); + const { resultIdentityView } = await import("./result-identity.ts"); + const { readV9EngineScoringIdentity } = await import("./engine-client.ts"); + if (resultIdentityView(dossier, await readV9EngineScoringIdentity()).read_only) { + throw new RectificationToolServiceError("result_identity_read_only"); + } const focus = dossier.conversationSummary.activeFocus; if (!focus || focus.id !== input.focusId) { throw new RectificationToolServiceError("agentic_rectification_focus_not_active"); @@ -1627,6 +1642,11 @@ export async function persistNextInterviewIfIdle(input: { askedTurnId: input.askedTurnId ?? null, }); let dossier = rescored.dossier; + const { resultIdentityView, OLD_ALGORITHM_NOTICE } = await import("./result-identity.ts"); + const { readV9EngineScoringIdentity } = await import("./engine-client.ts"); + if (resultIdentityView(dossier, await readV9EngineScoringIdentity()).read_only) { + return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE }; + } const finishIdle = (result: T): T => { let hostNarration = result.hostNarration; if (dossier.case.acceptedTime && hostNarration) { @@ -2423,6 +2443,11 @@ export async function ensureNonTerminalTurnExit(input: { caseId: string; }): Promise<{ persisted: boolean; choiceReady: boolean; hostNarration: string | null; terminalNote?: boolean }> { const before = await inspectNonTerminalTurnExit(input); + const { resultIdentityView, OLD_ALGORITHM_NOTICE } = await import("./result-identity.ts"); + const { readV9EngineScoringIdentity } = await import("./engine-client.ts"); + if (resultIdentityView(before.dossier, await readV9EngineScoringIdentity()).read_only) { + return { persisted: false, choiceReady: false, hostNarration: OLD_ALGORITHM_NOTICE }; + } if (before.satisfied) { return { persisted: false, choiceReady: false, hostNarration: null }; } diff --git a/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts b/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts index 81dfaaec..ec0d6939 100644 --- a/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts +++ b/frontend/src/lib/rectification-agentic/v9/block-scan-answer.ts @@ -47,6 +47,8 @@ export async function mutateCaseForBlockChoice(input: { if (!isBlockChoiceSchema(input.schema)) { return { dossier: input.dossier, snapshotCurrent: true }; } + const { assertV9ResultWritable } = await import("./result-identity.ts"); + await assertV9ResultWritable(input.accounting, input.userId, input.caseId, null); const window = windowFromBlockChoice({ schema: input.schema, optionId: input.optionId, @@ -105,6 +107,8 @@ export async function mutateCaseForWidenWindow(input: { if (!isWidenWindowSchema(input.schema)) { return { dossier: input.dossier, snapshotCurrent: true }; } + const { assertV9ResultWritable } = await import("./result-identity.ts"); + await assertV9ResultWritable(input.accounting, input.userId, input.caseId); const chosen = windowFromWidenChoice({ schema: input.schema, optionId: input.optionId, diff --git a/frontend/src/lib/rectification-agentic/v9/block-scan.ts b/frontend/src/lib/rectification-agentic/v9/block-scan.ts index c063d926..fbffa931 100644 --- a/frontend/src/lib/rectification-agentic/v9/block-scan.ts +++ b/frontend/src/lib/rectification-agentic/v9/block-scan.ts @@ -58,6 +58,8 @@ export type BlockScanBlock = Readonly<{ export type BlockScanPayload = Readonly<{ evidenceLedgerFingerprint?: string | null; algorithmVersion?: string | null; + policyVersion?: string | null; + candidateRangeFingerprint?: string | null; minuteStep?: number | null; declinedAtFingerprint?: string | null; rounds?: number | null; @@ -132,6 +134,8 @@ export function parseBlockScanPayload(value: unknown): BlockScanPayload | null { ? row.evidence_ledger_fingerprint : null, algorithmVersion: typeof row.algorithm_version === "string" ? row.algorithm_version : null, + policyVersion: typeof row.decision_policy_version === "string" ? row.decision_policy_version : null, + candidateRangeFingerprint: typeof row.candidate_range_fingerprint === "string" ? row.candidate_range_fingerprint : null, minuteStep: typeof row.minute_step === "number" ? row.minute_step : null, declinedAtFingerprint: typeof row.declined_at_fingerprint === "string" ? row.declined_at_fingerprint @@ -145,6 +149,8 @@ export function serializeBlockScanPayload(payload: BlockScanPayload): Record +) { + return dossierResponse(args[0], args[1], args[2], { + ...args[3], liveIdentity: await readV9EngineScoringIdentity(), + }); +} + export function dossierResponse( dossier: V9CaseDossier, receipts: Array>>, skillIdentity: Awaited>, - options: { fullDetail?: boolean; listed?: ConversationFocusList } = {}, + options: { fullDetail?: boolean; listed?: ConversationFocusList; liveIdentity?: LiveEngineScoringIdentity } = {}, ) { + const identity = resultIdentityView(dossier, options.liveIdentity ?? { algorithmVersion: null, policyVersion: null }); const decision = decideFromDossier(dossier, { currentEvidenceFingerprint: evidenceLedgerFingerprint(dossier.evidence), }); @@ -38,11 +49,13 @@ export function dossierResponse( : fields; const turns = attachOfferResultToTurns(turnsWithQuestions, { resultId: dossier.latestResult?.resultId ?? null, - canAdopt: overlaid.can_adopt === true, + canAdopt: !identity.read_only && overlaid.can_adopt === true, acceptedTime: dossier.case.acceptedTime, }); return { case: { + result_identity: identity, + result_notice: identity.read_only ? OLD_ALGORITHM_NOTICE : null, case_id: dossier.case.caseId, session_id: dossier.case.sessionId, status: dossier.case.status, @@ -67,22 +80,24 @@ export function dossierResponse( status: turn.status, created_at: turn.createdAt, receipt: turnReceipt(turn.id, receipts), - question: turn.question, + question: identity.read_only ? null : turn.question, offer_result_id: turn.offer_result_id, })), evidence: dossier.evidence, latest_result: dossier.case.stage === "block_scan" || decision.sessionOutcome === "compare_blocks" ? null : dossier.latestResult - ? publicLatestResult(dossier.latestResult, decision, dossier, options.fullDetail === true) + ? { ...publicLatestResult(dossier.latestResult, decision, dossier, options.fullDetail === true), + ...(identity.read_only ? { can_adopt: false, selectionAllowed: false, confirmationAllowed: false, read_only: true } : {}) } : null, interview: { ...fields, + ...(identity.read_only ? { can_adopt: false, selection_allowed: false, confirmation_allowed: false, validated: false, read_only: true } : {}), birth_time_source: birthTimeSource, collection_progress: collectionProgressFromReceipt(dossier.latestResult?.decisionReceipt ?? null), }, - current_question: projectCurrentQuestion(dossier.conversationSummary.activeFocus), - choice_card: choiceCardFromCaseDossier(dossier), + current_question: identity.read_only ? null : projectCurrentQuestion(dossier.conversationSummary.activeFocus), + choice_card: identity.read_only ? null : choiceCardFromCaseDossier(dossier), step_state: stepStateFromCaseDossier(dossier), next_user_action: nextUserActionFromDossier(dossier), question_source: questionSourceFromFocusList(listed), diff --git a/frontend/src/lib/rectification-agentic/v9/engine-client.ts b/frontend/src/lib/rectification-agentic/v9/engine-client.ts index 24b199ce..80b05f4e 100644 --- a/frontend/src/lib/rectification-agentic/v9/engine-client.ts +++ b/frontend/src/lib/rectification-agentic/v9/engine-client.ts @@ -422,6 +422,19 @@ export function scoringIdentityFromEnginePayload( }; } +export function scoringIdentityIsTrusted(identity: LiveEngineScoringIdentity): boolean { + return Boolean(identity.algorithmVersion && identity.policyVersion); +} + +export function scoringIdentityMatches( + stored: { algorithmVersion?: string | null; policyVersion?: string | null } | null | undefined, + live: LiveEngineScoringIdentity, +): boolean { + return Boolean(stored && scoringIdentityIsTrusted(live) + && stored.algorithmVersion === live.algorithmVersion + && stored.policyVersion === live.policyVersion); +} + export function cachedEngineScoreIsReusable( stored: { evidenceLedgerFingerprint?: string | null; @@ -435,20 +448,21 @@ export function cachedEngineScoreIsReusable( if (!stored) return false; if (stored.evidenceLedgerFingerprint !== fingerprints.evidenceLedgerFingerprint) return false; if (stored.candidateRangeFingerprint !== fingerprints.candidateRangeFingerprint) return false; - if (live.policyVersion && stored.policyVersion && stored.policyVersion !== live.policyVersion) { - return false; - } - if (live.algorithmVersion && stored.algorithmVersion && stored.algorithmVersion !== live.algorithmVersion) { - return false; - } - return true; + return scoringIdentityMatches(stored, live); } export async function readV9EngineScoringIdentity(): Promise { const fromEnv = liveEngineScoringIdentityFromEnv(); - if (fromEnv.policyVersion || fromEnv.algorithmVersion) return fromEnv; + if (scoringIdentityIsTrusted(fromEnv)) return fromEnv; try { - return scoringIdentityFromEnginePayload(await getEngine("/api/rectification/v5/versions")); + const native = scoringIdentityFromEnginePayload(await getEngine("/api/rectification/v5/versions")); + // Partial overrides cannot invent the missing half of a computation identity. + // A conflicting partial override is not a coherent identity pair. + if ((fromEnv.algorithmVersion && fromEnv.algorithmVersion !== native.algorithmVersion) + || (fromEnv.policyVersion && fromEnv.policyVersion !== native.policyVersion)) { + return { algorithmVersion: null, policyVersion: null }; + } + return native; } catch { return { algorithmVersion: null, policyVersion: null }; } @@ -1014,6 +1028,7 @@ export async function runV9RangeReading(input: { export type V9BlockScanResult = Readonly<{ resultId: string | null; algorithmVersion: string; + policyVersion: string | null; minuteStep: number; blocks: readonly BlockScanBlock[]; }>; @@ -1042,6 +1057,7 @@ export async function runV9BlockScan(input: { return { resultId: typeof data.result_id === "string" ? data.result_id : null, algorithmVersion: parsed.algorithmVersion ?? String(data.algorithm_version ?? ""), + policyVersion: scoringIdentityFromEnginePayload(record(data.decision_receipt)).policyVersion, minuteStep: parsed.minuteStep ?? 10, blocks: parsed.blocks, }; diff --git a/frontend/src/lib/rectification-agentic/v9/result-identity.ts b/frontend/src/lib/rectification-agentic/v9/result-identity.ts new file mode 100644 index 00000000..5ccad798 --- /dev/null +++ b/frontend/src/lib/rectification-agentic/v9/result-identity.ts @@ -0,0 +1,42 @@ +import { + readV9EngineScoringIdentity, + scoringIdentityMatches, + scoringIdentityIsTrusted, + type LiveEngineScoringIdentity, +} from "./engine-client.ts"; +import { + loadV9CaseDossier, + RectificationToolServiceError, + type AccountingClient, + type V9CaseDossier, +} from "./tool-service.ts"; + +export const OLD_ALGORITHM_NOTICE = "按旧算法产出。当前暂不能核实结果,请先查看,暂不能选择或采用。"; + +/** Read-time projection only. Never persist or relabel a historical source. */ +export function resultIdentityView(dossier: V9CaseDossier, live: LiveEngineScoringIdentity) { + const source = dossier.case.stage === "block_scan" ? dossier.case.blockScan : dossier.latestResult; + const hasAnyResult = Boolean(dossier.case.blockScan || dossier.latestResult); + return { + read_only: hasAnyResult && !scoringIdentityMatches(source, live), + can_recompare: hasAnyResult && scoringIdentityIsTrusted(live) && !scoringIdentityMatches(source, live), + algorithm_version: source?.algorithmVersion ?? null, + policy_version: source?.policyVersion ?? null, + }; +} + +/** Server-owned check at every candidate mutation boundary, not a client flag. */ +export async function assertV9ResultWritable( + accounting: AccountingClient, userId: string, caseId: string, resultId?: string | null, +): Promise { + const dossier = await loadV9CaseDossier(accounting, userId, caseId); + if (!dossier.latestResult && !dossier.case.blockScan && resultId === undefined) return; + const live = await readV9EngineScoringIdentity(); + if (resultIdentityView(dossier, live).read_only + || (resultId === null && !(dossier.case.stage === "block_scan" ? dossier.case.blockScan : dossier.latestResult)) + || (typeof resultId === "string" && (dossier.case.stage !== "minute" + || dossier.latestResult?.resultId !== resultId + || !scoringIdentityMatches(dossier.latestResult, live)))) { + throw new RectificationToolServiceError("result_identity_read_only"); + } +} diff --git a/frontend/src/lib/rectification-agentic/v9/score-persist.ts b/frontend/src/lib/rectification-agentic/v9/score-persist.ts index 477a8ce9..6728ba12 100644 --- a/frontend/src/lib/rectification-agentic/v9/score-persist.ts +++ b/frontend/src/lib/rectification-agentic/v9/score-persist.ts @@ -31,6 +31,8 @@ import { executedMethodsFromLedger, mergeVedastroValidateIntoReceipt, readV9EngineScoringIdentity, + scoringIdentityIsTrusted, + scoringIdentityMatches, runV9BlockScan, runV9CandidateScore, runV9VedastroValidate, @@ -61,6 +63,7 @@ export type ScorePersistTimings = Readonly<{ }>; export type ScorePersistResult = Readonly<{ + readOnly?: boolean; persisted: V9PersistedCandidate | Readonly<{ resultId: string; cached: boolean; @@ -71,7 +74,7 @@ export type ScorePersistResult = Readonly<{ representativeTime: null; algorithmVersion: string; eventContractVersion: string; - policyVersion: string; + policyVersion: string | null; decisionReceipt: Readonly>; executionLedger: readonly []; }>; @@ -87,6 +90,7 @@ export async function persistBlockScanPayload(input: { userId: string; caseId: string; evidenceFingerprint: string; + rangeFingerprint?: string; scan: Awaited>; previousRounds?: number; }): Promise { @@ -96,6 +100,8 @@ export async function persistBlockScanPayload(input: { await writeV9BlockScan(input.accounting, input.userId, input.caseId, { evidence_ledger_fingerprint: input.evidenceFingerprint, algorithm_version: input.scan.algorithmVersion, + decision_policy_version: input.scan.policyVersion, + candidate_range_fingerprint: input.rangeFingerprint ?? null, minute_step: input.scan.minuteStep, rounds: isSub ? (input.previousRounds ?? 0) + 1 : 0, blocks: input.scan.blocks, @@ -167,8 +173,15 @@ export async function scoreAndPersistCurrentEvidence(input: { if (!candidateRange) throw new RectificationToolServiceError("case_range_missing"); const compute = await loadV9CaseCompute(input.accounting, input.userId, input.caseId); const evidenceFingerprint = evidenceLedgerFingerprint(dossier.evidence); + const rangeFingerprint = candidateRangeFingerprint(candidateRange, compute.baselineProfileFingerprint); + const liveIdentity = await readV9EngineScoringIdentity(); + const readOnly = !scoringIdentityIsTrusted(liveIdentity); + if (readOnly && !(dossier.case.stage === "block_scan" ? dossier.case.blockScan : dossier.latestResult)) { + throw new RectificationToolServiceError("engine_identity_unavailable"); + } if ( - dossier.case.stage === "block_scan" + !readOnly + && dossier.case.stage === "block_scan" && shouldForceMinuteAfterSubBlocks(dossier.case.blockScan) ) { await setV9CaseStage(input.accounting, input.userId, input.caseId, "minute"); @@ -176,11 +189,10 @@ export async function scoreAndPersistCurrentEvidence(input: { } if (dossier.case.stage === "block_scan") { const cachedPayload = dossier.case.blockScan; - const cached = Boolean( - cachedPayload - && cachedPayload.evidenceLedgerFingerprint === evidenceFingerprint, - ); - if (!cached) { + const cached = cachedEngineScoreIsReusable(cachedPayload, { + evidenceLedgerFingerprint: evidenceFingerprint, candidateRangeFingerprint: rangeFingerprint, + }, liveIdentity); + if (!cached && !readOnly) { const events = toEngineEvents(scorable); const extras = blockScanRequestExtras(candidateRange); const scoreStarted = Date.now(); @@ -197,11 +209,13 @@ export async function scoreAndPersistCurrentEvidence(input: { userId: input.userId, caseId: input.caseId, evidenceFingerprint, + rangeFingerprint, scan, previousRounds: cachedPayload?.rounds ?? 0, }); const reloaded = await loadV9CaseDossier(input.accounting, input.userId, input.caseId); return { + readOnly: !scoringIdentityMatches(scan, liveIdentity), persisted: { resultId: scan.resultId ?? "", cached: false, @@ -212,7 +226,7 @@ export async function scoreAndPersistCurrentEvidence(input: { representativeTime: null, algorithmVersion: scan.algorithmVersion, eventContractVersion: "", - policyVersion: "", + policyVersion: scan.policyVersion, decisionReceipt: {}, executionLedger: [], }, @@ -228,9 +242,10 @@ export async function scoreAndPersistCurrentEvidence(input: { }; } return { + readOnly, persisted: { resultId: "", - cached: true, + cached, candidates: [], overallConfidence: "low", selectionAllowed: false, @@ -238,7 +253,7 @@ export async function scoreAndPersistCurrentEvidence(input: { representativeTime: null, algorithmVersion: cachedPayload?.algorithmVersion ?? "", eventContractVersion: "", - policyVersion: "", + policyVersion: cachedPayload?.policyVersion ?? null, decisionReceipt: {}, executionLedger: [], }, @@ -253,25 +268,19 @@ export async function scoreAndPersistCurrentEvidence(input: { }, }; } - const rangeFingerprint = candidateRangeFingerprint( - candidateRange, - compute.baselineProfileFingerprint, - ); const events = toEngineEvents(scorable); const previousReceipt = keepAnswers ? dossier.latestResult?.decisionReceipt : null; const askedProbeKeys = keepAnswers ? askedSemanticKeysForEngine(previousReceipt, dossier.evidence) : []; const latest = dossier.latestResult; - const liveIdentity = await readV9EngineScoringIdentity(); if ( - keepAnswers - && latest - && cachedEngineScoreIsReusable( + latest + && (readOnly || (keepAnswers && cachedEngineScoreIsReusable( latest, { evidenceLedgerFingerprint: evidenceFingerprint, candidateRangeFingerprint: rangeFingerprint }, liveIdentity, - ) + ))) ) { const ledger = latest.executionLedger ?? []; const windowScan = windowScanFromDecisionReceipt(latest.decisionReceipt); @@ -287,7 +296,8 @@ export async function scoreAndPersistCurrentEvidence(input: { let vedastroMs = 0; let persistMs = 0; if ( - latest.selectionAllowed + !readOnly + && latest.selectionAllowed && primary && runnerUp && primary !== runnerUp @@ -317,13 +327,14 @@ export async function scoreAndPersistCurrentEvidence(input: { persistMs = Date.now() - persistStarted; } return { + readOnly, persisted: { resultId: latest.resultId, - cached: true, + cached: !readOnly, candidates, overallConfidence: latest.overallConfidence, - selectionAllowed: candidateProjection.consistent && latest.selectionAllowed, - confirmationAllowed: latest.confirmationAllowed, + selectionAllowed: !readOnly && candidateProjection.consistent && latest.selectionAllowed, + confirmationAllowed: !readOnly && latest.confirmationAllowed, representativeTime, algorithmVersion: latest.algorithmVersion, eventContractVersion: latest.eventContractVersion, @@ -340,10 +351,10 @@ export async function scoreAndPersistCurrentEvidence(input: { candidates, overallConfidence: latest.overallConfidence, marginPercent: null, - acceptanceAllowed: latest.selectionAllowed, - selectionAllowed: candidateProjection.consistent && latest.selectionAllowed, - proposeAllowed: latest.selectionAllowed, - confirmationAllowed: latest.confirmationAllowed, + acceptanceAllowed: !readOnly && latest.selectionAllowed, + selectionAllowed: !readOnly && candidateProjection.consistent && latest.selectionAllowed, + proposeAllowed: !readOnly && latest.selectionAllowed, + confirmationAllowed: !readOnly && latest.confirmationAllowed, representativeCandidateId: null, representativeTime, decisionReceipt, @@ -383,7 +394,8 @@ export async function scoreAndPersistCurrentEvidence(input: { }); const engineCompareMs = Date.now() - scoreStarted; const vedastroStarted = Date.now(); - const receipt = await persistableReceipt(score, { + const responseReadOnly = !scoringIdentityMatches(score, liveIdentity); + const receipt = responseReadOnly ? score.decisionReceipt : await persistableReceipt(score, { baselineBirthSnapshot: compute.baselineBirthSnapshot, candidateRange, events, @@ -451,8 +463,9 @@ export async function scoreAndPersistCurrentEvidence(input: { executionLedger: score.executionLedger, }); return { - persisted, - score, + readOnly: responseReadOnly, + persisted: responseReadOnly ? { ...persisted, selectionAllowed: false, confirmationAllowed: false } : persisted, + score: responseReadOnly ? { ...score, selectionAllowed: false, confirmationAllowed: false, acceptanceAllowed: false, proposeAllowed: false } : score, dossier, windowScan: score.windowScan, decisionReceipt, diff --git a/frontend/src/lib/rectification-agentic/v9/tool-service.ts b/frontend/src/lib/rectification-agentic/v9/tool-service.ts index 60d33f3b..6bdc0400 100644 --- a/frontend/src/lib/rectification-agentic/v9/tool-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/tool-service.ts @@ -10,6 +10,7 @@ * event arrays, scores or permission decisions. */ import { createHash } from "node:crypto"; +import { assertV9ResultWritable } from "./result-identity.ts"; import type { AdoptedCredibleRangePayload } from "../../report-candidate-range.ts"; import { EVIDENCE_KINDS, @@ -1546,6 +1547,7 @@ export async function advanceV9CaseFromBlockScan( caseId: string, window: { start_time: string; end_time: string }, ): Promise<{ start_time: string; end_time: string }> { + await assertV9ResultWritable(accounting, userId, caseId, null); const row = await rpc>( accounting, "advance_agentic_rectification_case_from_block_scan", @@ -1730,6 +1732,7 @@ export async function persistV9InferenceState( decisionReceipt: Readonly>; decisionStateFingerprint: string; }>> { + await assertV9ResultWritable(accounting, userId, caseId, null); const row = await rpc>( accounting, "append_agentic_rectification_inference_transition", @@ -1794,6 +1797,9 @@ export async function persistV9ChoiceAction( narration: string | null; receipt: Readonly>; }>> { + // Non-scoring choices were checked before their window/evidence mutation; + // rechecking an intentionally cleared block payload would reject its audit. + if (input.inference) await assertV9ResultWritable(accounting, userId, caseId, null); const inference = input.inference ? { expected_revision: input.inference.expectedRevision, @@ -1907,6 +1913,7 @@ export async function acceptV9Candidate( if (!uuidPattern.test(candidateId) || !uuidPattern.test(requestId)) { throw new RectificationToolServiceError("invalid_candidate_ref"); } + await assertV9ResultWritable(accounting, userId, caseId, resultId); const row = await rpc>( accounting, "accept_agentic_rectification_candidate_for_case_v2", @@ -1957,6 +1964,7 @@ export async function confirmV9BirthTime( if (!uuidPattern.test(input.candidateId) || !uuidPattern.test(input.requestId)) { throw new RectificationToolServiceError("invalid_candidate_ref"); } + await assertV9ResultWritable(accounting, userId, caseId, input.resultId); const row = await rpc>( accounting, "confirm_agentic_rectification_candidate_for_case_v2", @@ -2018,6 +2026,8 @@ export function safeToolErrorCode(error: unknown): string { "candidate_not_found", "candidate_expired", "candidate_selection_blocked", + "result_identity_read_only", + "engine_identity_unavailable", "candidate_time_not_allowed", "candidate_already_selected", "candidate_superseded", diff --git a/frontend/src/lib/rectification-surface-state.ts b/frontend/src/lib/rectification-surface-state.ts index 943fb40e..da31f12a 100644 --- a/frontend/src/lib/rectification-surface-state.ts +++ b/frontend/src/lib/rectification-surface-state.ts @@ -47,6 +47,16 @@ export const RECTIFICATION_EMPTY_ACTION_LABEL = "开始提问"; export const RECTIFICATION_HYDRATION_INCOMPLETE_NOTICE = "校正记录没有完全加载,可以继续。"; export const RECTIFICATION_STOPPED_NOTICE = "已停止,已生成的内容保留;本次不会扣点。"; +export async function recompareHistoricalResult(input: { + canRecompare: boolean; + historyReadonly: boolean; + busy: boolean; + send: (action: "message", text: string) => Promise; +}): Promise { + if (!input.canRecompare || input.historyReadonly || input.busy) return; + await input.send("message", "请按当前算法重新比较候选。"); +} + export function isAbortError(caught: unknown): boolean { return (caught instanceof DOMException || caught instanceof Error) && caught.name === "AbortError"; } @@ -79,6 +89,8 @@ export type RectificationCaseSnapshotPayload = Readonly<{ type?: unknown; }>; case?: Readonly<{ + result_notice?: string | null; + result_identity?: Readonly<{ can_recompare?: boolean }>; status?: unknown; accepted_time?: unknown; confirmed_time?: unknown; diff --git a/frontend/src/mastra/rectification-v9-tools.ts b/frontend/src/mastra/rectification-v9-tools.ts index 1d722a67..45a4f570 100644 --- a/frontend/src/mastra/rectification-v9-tools.ts +++ b/frontend/src/mastra/rectification-v9-tools.ts @@ -64,6 +64,7 @@ import { inclusiveClockWidthMinutes, indistinguishableWidthMinutes } from "@/lib import { rankActive } from "@/lib/rectification-agentic/core/convergence-evaluator"; import { unionStillValidRange } from "@/lib/rectification-agentic/core/credible-range"; import { followupCaseArgs } from "@/lib/rectification-agentic/v9/block-scan"; +import { assertV9ResultWritable, resultIdentityView, OLD_ALGORITHM_NOTICE } from "@/lib/rectification-agentic/v9/result-identity"; import { scoreAndPersistCurrentEvidence as persistCurrentEvidenceScore } from "@/lib/rectification-agentic/v9/score-persist"; import { buildConfirmationGate, @@ -158,6 +159,8 @@ import { runV9Diagnostics, runV9VedastroValidate, readV9EngineScoringIdentity, + scoringIdentityIsTrusted, + scoringIdentityMatches, cachedEngineScoreIsReusable, toEngineEvents, executedMethodsFromLedger, @@ -998,6 +1001,12 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { // Ranking stays fail-open when compute is unavailable. } const { plan: collectingPlan, contrastPacket, decision } = sessionAwareFollowupForParsed(parsed, latest, { birthDate }); + const currentDossier = await loadV9CaseDossier(accounting, userId, caseId); + if (resultIdentityView(currentDossier, await readV9EngineScoringIdentity()).read_only) { + return { collectingPlan: { ...collectingPlan, next_followup: null }, + persistedFocus: { status: "skipped" as const, focus: null, questionId: null, prompt: null }, + contrastPacket, decision }; + } const followup = collectingPlan.next_followup; const persistedFocus = await persistServerOwnedFocus({ accounting, @@ -1033,8 +1042,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { if (parsed.scorable.length === 0 || !parsed.case.candidateRange) { return { status: "skipped" as const, executedMethods: [] as const, errorCode: null, errorKind: null, cached: false, openQuestion: null }; } + const identity = resultIdentityView(dossier, await readV9EngineScoringIdentity()); const fingerprint = evidenceLedgerFingerprint(dossier.evidence); - if (parsed.case.stage !== "block_scan" && dossier.latestResult?.evidenceLedgerFingerprint === fingerprint) { + if (!identity.read_only && parsed.case.stage !== "block_scan" && dossier.latestResult?.evidenceLedgerFingerprint === fingerprint) { const persisted = parsed.latestResult ? await persistPlanFocus(parsed, parsed.latestResult) : null; @@ -1050,6 +1060,9 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { }; } const scored = await scoreAndPersistCurrentEvidence(targetCaseId); + if (scored.readOnly) { + return { status: "skipped" as const, executedMethods: [] as const, errorCode: "result_identity_read_only", errorKind: null, cached: false, openQuestion: null }; + } const latest = scored.parsed.case.stage === "block_scan" ? null : { @@ -1108,6 +1121,19 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { try { let dossier = await loadV9CaseDossier(accounting, userId, input.caseId); let parsed = parseDossierForTools(dossier); + const identity = resultIdentityView(dossier, await readV9EngineScoringIdentity()); + if (identity.read_only) { + const storedProjection = projectTurnDecision(dossier); + const projection = { ...storedProjection, read_only: true, + notice: OLD_ALGORITHM_NOTICE, can_adopt: false, current_question: null, choice_card: null, + current_probe: null, inference: null, + candidate_summary: { ...(storedProjection.candidate_summary as Record), selection_allowed: false, validated: false }, + can_recompare: identity.can_recompare, + next_action: { type: identity.can_recompare ? "compare_candidates" : "read_only", selection_allowed: false, validated: false } }; + await receipt("rectification-read-case", "case.loaded", "completed", { inputFingerprint, resultFingerprint: hashResult(projection) }); + hasReadCase = true; + return projection; + } if (input.resume === true && parsed.case.status === "paused") { await transitionV9CaseStatus( accounting, @@ -1925,6 +1951,17 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { await receipt("rectification-compare-candidates", "candidates.comparing", "started", { inputFingerprint }); try { const scored = await scoreAndPersistCurrentEvidence(input.caseId); + if (scored.readOnly) { + const projection = { read_only: true, notice: OLD_ALGORITHM_NOTICE, + algorithm_version: scored.persisted.algorithmVersion, cached: false, + candidates: scored.persisted.candidates, can_adopt: false, + selection_allowed: false, confirmation_allowed: false, open_question: null }; + await receipt("rectification-compare-candidates", "candidates.comparing", "completed", { + inputFingerprint, resultFingerprint: hashResult(projection), + engineVersion: scored.persisted.algorithmVersion ?? null, executedMethods: [], + }); + return projection; + } const latest = scored.parsed.case.stage === "block_scan" ? null : { @@ -2000,6 +2037,27 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { await receipt("rectification-read-diagnostics", "diagnostics.completed", "started", { inputFingerprint }); try { const dossier = await loadV9CaseDossier(accounting, userId, input.caseId); + const liveIdentity = await readV9EngineScoringIdentity(); + if (!scoringIdentityIsTrusted(liveIdentity)) { + const identity = resultIdentityView(dossier, liveIdentity); + if (!identity.read_only) throw new RectificationToolServiceError("engine_identity_unavailable"); + const projection = { + ...identity, + notice: OLD_ALGORITHM_NOTICE, + diagnostics: null, + can_confirm_exact_minute: false, + executed_methods: [], + }; + await receipt("rectification-read-diagnostics", "diagnostics.completed", "completed", { + inputFingerprint, + resultFingerprint: hashResult(projection), + engineVersion: identity.algorithm_version, + executedMethods: [], + }); + return projection; + } + // With a trusted identity this is an independent calculation, not a + // validation of the stored candidate. Keep its actual response source. const parsed = parseDossierForTools(dossier); if (parsed.scorable.length === 0) { throw new RectificationToolServiceError("no_scorable_evidence"); @@ -2015,12 +2073,16 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { parsed.evidence, ), }); + const readOnly = !scoringIdentityMatches(diagnostics, liveIdentity); const projection = { engine_result_id: diagnostics.engineResultId, algorithm_version: diagnostics.algorithmVersion, + policy_version: diagnostics.policyVersion, + read_only: readOnly, + notice: readOnly ? OLD_ALGORITHM_NOTICE : null, diagnostics: diagnostics.diagnostics, missing_layers: diagnostics.missingLayers, - can_confirm_exact_minute: diagnostics.canConfirmExactMinute, + can_confirm_exact_minute: !readOnly && diagnostics.canConfirmExactMinute, }; await receipt("rectification-read-diagnostics", "diagnostics.completed", "completed", { inputFingerprint, @@ -2052,6 +2114,7 @@ export function createRectificationV9Tools(ctx: RectificationV9Context) { throw new RectificationToolServiceError("no_candidate_result"); } const latest = parsed.latestResult; + await assertV9ResultWritable(accounting, userId, input.caseId, latest.resultId); const { decision } = sessionAwareFollowupForParsed(parsed, latest); const currentSnapshot = snapshotSourceFromDossier(parsed, null); const storedSnapshot = storedSnapshotSource(latest); diff --git a/frontend/supabase/migrations/20260920010000_rectification_receipt_result_identity.sql b/frontend/supabase/migrations/20260920010000_rectification_receipt_result_identity.sql new file mode 100644 index 00000000..9b5e09f2 --- /dev/null +++ b/frontend/supabase/migrations/20260920010000_rectification_receipt_result_identity.sql @@ -0,0 +1,174 @@ +-- BUG-984: result provenance comes from the last completed result in the +-- selected attempt. Preserve owner/turn/attempt checks and the public shape. +-- Compatible with old writers; no table changes or historical row updates. + +begin; + +create or replace function public.get_agentic_rectification_turn_receipt( + p_user_id uuid, + p_case_id uuid, + p_turn_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_turn public.agentic_rectification_turns%rowtype; + v_case public.agentic_rectification_cases%rowtype; + v_attempt_id uuid; + v_phases jsonb; + v_tool_activities jsonb; + v_tools jsonb; + v_methods jsonb; + v_engine_version text; +begin + if p_user_id is null or p_case_id is null or p_turn_id is null then + raise exception 'agentic_rectification_invalid_input' using errcode = 'P0001'; + end if; + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + select * into v_turn + from public.agentic_rectification_turns + where id = p_turn_id and case_id = p_case_id; + if not found then + raise exception 'agentic_rectification_turn_not_found' using errcode = 'P0001'; + end if; + + v_attempt_id := v_turn.successful_attempt_id; + if v_attempt_id is null then + select id into v_attempt_id + from public.agentic_rectification_run_attempts + where turn_id = p_turn_id + order by attempt_number desc + limit 1; + end if; + + select coalesce(jsonb_agg( + jsonb_build_object('phase', rp.phase, 'tool', rp.tool_name) + order by rp.sequence, rp.created_at, rp.id + ), '[]'::jsonb) into v_phases + from public.agentic_rectification_run_phases rp + where rp.turn_id = p_turn_id + and ( + (v_attempt_id is not null and rp.attempt_id = v_attempt_id) + or (v_attempt_id is null and rp.attempt_id is null) + ); + + with latest_terminal as materialized ( + select distinct on (tr.tool_name) + tr.id, + tr.tool_name, + tr.status, + tr.executed_methods, + tr.started_at, + tr.safe_error_code, + tr.result_fingerprint + from public.agentic_rectification_tool_receipts tr + where tr.turn_id = p_turn_id + and tr.status in ('completed', 'failed') + and ( + (v_attempt_id is not null and tr.attempt_id = v_attempt_id) + or (v_attempt_id is null and tr.attempt_id is null) + ) + order by tr.tool_name, tr.started_at desc, tr.id desc + ), + latest_started as materialized ( + select distinct on (tr.tool_name) + tr.tool_name, + tr.started_at + from public.agentic_rectification_tool_receipts tr + where tr.turn_id = p_turn_id + and tr.status = 'started' + and ( + (v_attempt_id is not null and tr.attempt_id = v_attempt_id) + or (v_attempt_id is null and tr.attempt_id is null) + ) + order by tr.tool_name, tr.started_at desc, tr.id desc + ) + select + coalesce(jsonb_agg( + jsonb_strip_nulls(jsonb_build_object( + 'tool', terminal.tool_name, + 'status', terminal.status, + 'methods', case + when terminal.status = 'completed' then terminal.executed_methods + else '[]'::jsonb + end, + 'started_at', coalesce(started.started_at, terminal.started_at), + 'elapsed_ms', greatest( + 0, + (extract(epoch from ( + terminal.started_at - coalesce(started.started_at, terminal.started_at) + )) * 1000)::int + ), + 'error', terminal.safe_error_code, + 'result_fingerprint', terminal.result_fingerprint + )) order by coalesce(started.started_at, terminal.started_at), terminal.id + ), '[]'::jsonb), + coalesce(jsonb_agg(terminal.tool_name order by coalesce(started.started_at, terminal.started_at), terminal.id) + filter (where terminal.status = 'completed'), '[]'::jsonb) + into v_tool_activities, v_tools + from latest_terminal terminal + left join latest_started started on started.tool_name = terminal.tool_name; + + with latest_terminal as materialized ( + select distinct on (tr.tool_name) + tr.tool_name, + tr.status, + tr.executed_methods + from public.agentic_rectification_tool_receipts tr + where tr.turn_id = p_turn_id + and tr.status in ('completed', 'failed') + and ( + (v_attempt_id is not null and tr.attempt_id = v_attempt_id) + or (v_attempt_id is null and tr.attempt_id is null) + ) + order by tr.tool_name, tr.started_at desc, tr.id desc + ) + select coalesce(jsonb_agg(method order by method), '[]'::jsonb) into v_methods + from ( + select distinct jsonb_array_elements_text(terminal.executed_methods) as method + from latest_terminal terminal + where terminal.status = 'completed' + ) methods; + + select tr.engine_version into v_engine_version + from public.agentic_rectification_tool_receipts tr + where tr.turn_id = p_turn_id and tr.engine_version is not null + and tr.status = 'completed' + and ( + (v_attempt_id is not null and tr.attempt_id = v_attempt_id) + or (v_attempt_id is null and tr.attempt_id is null) + ) + order by tr.started_at desc, tr.id desc + limit 1; + + return jsonb_build_object( + 'turn_id', v_turn.id, + 'attempt_id', v_attempt_id, + 'status', v_turn.status, + 'skill_name', v_case.skill_name, + 'skill_version', v_case.skill_version, + 'engine_version', v_engine_version, + 'phases', v_phases, + 'tool_activities', v_tool_activities, + 'tools', v_tools, + 'methods', v_methods, + 'started_at', v_turn.created_at, + 'completed_at', v_turn.completed_at + ); +end; +$$; + +revoke all on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid) + from public, anon, authenticated; +grant execute on function public.get_agentic_rectification_turn_receipt(uuid, uuid, uuid) + to service_role; + +commit; diff --git a/frontend/tests/database-rectification-block-scan.test.ts b/frontend/tests/database-rectification-block-scan.test.ts index 186c2cc6..5c226c28 100644 --- a/frontend/tests/database-rectification-block-scan.test.ts +++ b/frontend/tests/database-rectification-block-scan.test.ts @@ -120,6 +120,52 @@ test("block_scan RPCs are service_role-only and advance a declared period", { sk assert.equal(opened.error, null, rpcError(opened.error)); const caseId = String((opened.data as Record).case_id); + // BUG-984: identities belong to completed results, never deployment/start rows. + // All rows are synthetic; the timestamp/id order, not the version text, wins. + const turnId = fixture.psql(` + insert into public.agentic_rectification_turns (case_id, status, model_name, assistant_message) + values ('${caseId}', 'completed', 'fixture', 'fixture') returning id; + `).split("\n")[0]; + const receipt = async (owner = userId, turn = turnId) => service.rpc("get_agentic_rectification_turn_receipt", { + p_user_id: owner, p_case_id: caseId, p_turn_id: turn, + }); + const addReceipt = (id: string, status: string, version: string | null, second: number, attempt: string | null = null, tool = "rectification-compare-candidates") => fixture.psql(` + insert into public.agentic_rectification_tool_receipts + (id, case_id, turn_id, tool_name, public_phase, status, engine_version, started_at, attempt_id) + values ('${id}', '${caseId}', '${turnId}', '${tool}', 'candidates.updated', '${status}', + ${version ? `'${version}'` : "null"}, '2026-09-20 00:00:${String(second).padStart(2, "0")}+00', + ${attempt ? `'${attempt}'` : "null"}); + `); + const version = (n: number) => `rectification-v5-matrix-scoring-${n}`; + addReceipt("10000000-0000-4000-8000-000000000001", "started", version(8), 1); + addReceipt("10000000-0000-4000-8000-000000000002", "completed", version(7), 2); + assert.equal((await receipt()).data?.engine_version, version(7)); + addReceipt("10000000-0000-4000-8000-000000000003", "completed", version(9), 3); + addReceipt("10000000-0000-4000-8000-000000000004", "completed", version(10), 4, null, "rectification-read-diagnostics"); + addReceipt("10000000-0000-4000-8000-000000000005", "failed", version(99), 5); + assert.equal((await receipt()).data?.engine_version, version(10)); + // Same timestamp: stable id tie-break, independent of insertion/lexical version order. + addReceipt("10000000-0000-4000-8000-000000000006", "completed", version(11), 4); + assert.equal((await receipt()).data?.engine_version, version(11)); + const attempt1 = fixture.psql(` + insert into public.agentic_rectification_run_attempts (case_id, turn_id, attempt_number, status, completed_at) + values ('${caseId}', '${turnId}', 1, 'completed', now()) returning id; + `).split("\n")[0]; + addReceipt("10000000-0000-4000-8000-000000000007", "completed", version(10), 6, attempt1); + fixture.psql(`update public.agentic_rectification_turns set successful_attempt_id = '${attempt1}' where id = '${turnId}'`); + const attempt2 = fixture.psql(` + insert into public.agentic_rectification_run_attempts (case_id, turn_id, attempt_number, status, completed_at) + values ('${caseId}', '${turnId}', 2, 'failed', now()) returning id; + `).split("\n")[0]; + addReceipt("10000000-0000-4000-8000-000000000008", "failed", version(99), 7, attempt2); + assert.equal((await receipt()).data?.engine_version, version(10), "failed retry cannot replace successful attempt"); + fixture.psql(`update public.agentic_rectification_turns set successful_attempt_id = null where id = '${turnId}'`); + assert.equal((await receipt()).data?.engine_version, null, "latest failed-only attempt has no result provenance"); + assert.match(rpcError((await receipt("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")).error), /case_not_found/); + assert.match(rpcError((await receipt(userId, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")).error), /turn_not_found/); + assert.equal(fixture.psql(`select engine_version from public.agentic_rectification_tool_receipts where id = '10000000-0000-4000-8000-000000000002'`), version(7), "historical rows are not relabelled"); + assert.equal(fixture.psql(`select has_function_privilege('authenticated', 'public.get_agentic_rectification_turn_receipt(uuid,uuid,uuid)', 'EXECUTE')`), "f"); + const staged = await service.rpc("set_agentic_rectification_case_stage", { p_user_id: userId, p_case_id: caseId, diff --git a/frontend/tests/fixtures/rectification-engine-version-cross-midnight-block-golden.json b/frontend/tests/fixtures/rectification-engine-version-cross-midnight-block-golden.json new file mode 100644 index 00000000..1d04c9aa --- /dev/null +++ b/frontend/tests/fixtures/rectification-engine-version-cross-midnight-block-golden.json @@ -0,0 +1,5085 @@ +{ + "provenance": "Native scripts.rectification.api_service.block_scan via normalized request; explicitly fictional cross-midnight input; generated 2026-09-20", + "request": { + "birth_date": "1990-01-01", + "start_time": "23:00", + "end_time": "03:59", + "lat": 39.9, + "lon": 116.4, + "tz": 8, + "events": [ + { + "id": "00000000-0000-4000-8000-000000000001", + "domain": "education", + "event_kind": "education_start", + "date_start": "2008-01-01", + "date_end": "2008-12-31", + "precision": "year", + "summary": "Fictional education event" + }, + { + "id": "00000000-0000-4000-8000-000000000002", + "domain": "career", + "event_kind": "career_entry", + "date_start": "2012-06-01", + "date_end": "2012-06-30", + "precision": "month", + "summary": "Fictional career event" + } + ], + "minute_step": 10 + }, + "response": { + "result_id": "cbc15ff7-cbab-5106-a82f-e2120d5b3299", + "algorithm_version": "rectification-v5-matrix-scoring-8", + "calculation_spec": { + "version": "rectification-calculation-spec-v4", + "birthDate": "1990-01-01", + "candidateRange": { + "start": "23:00", + "end": "03:59" + }, + "latitude": 39.9, + "longitude": 116.4, + "timezoneOffsetHours": 8, + "ayanamsa": "raman", + "nodeMode": "mean", + "minuteStep": 10 + }, + "calculation_spec_hash": "967a39c89973e74e7e902d270ae6262f5c7ea8f9392716e21829d70faa608619", + "minute_step": 10, + "candidate_count": 30, + "precision_stage": { + "current": "block_scan" + }, + "blocks": [ + { + "period": "early_morning", + "start_time": "04:00", + "end_time": "07:59", + "relative_support": 0.0, + "top_events": [], + "candidate_count": 0 + }, + { + "period": "morning", + "start_time": "08:00", + "end_time": "11:59", + "relative_support": 0.0, + "top_events": [], + "candidate_count": 0 + }, + { + "period": "afternoon", + "start_time": "12:00", + "end_time": "17:59", + "relative_support": 0.0, + "top_events": [], + "candidate_count": 0 + }, + { + "period": "evening", + "start_time": "18:00", + "end_time": "22:59", + "relative_support": 0.0, + "top_events": [], + "candidate_count": 0 + }, + { + "period": "late_night", + "start_time": "23:00", + "end_time": "03:59", + "relative_support": 100.0, + "top_events": [ + { + "event_id": "00000000-0000-4000-8000-000000000001", + "domain": "education", + "summary": "Fictional education event" + }, + { + "event_id": "00000000-0000-4000-8000-000000000002", + "domain": "career", + "summary": "Fictional career event" + } + ], + "candidate_count": 30 + } + ], + "discriminating_event_probes": [], + "guided_collect_windows": [], + "acceptance_allowed": false, + "selection_allowed": false, + "display_allowed": false, + "decision_receipt": { + "receipt_version": "candidate-decision-receipt-v2", + "contract_version": "v2", + "event_contract_version": "rectification-event-contract-v2", + "policy_version": "rectification-candidate-policy-v3", + "decision_policy_version": "rectification-candidate-policy-v3", + "display_allowed": true, + "selection_allowed": false, + "acceptance_allowed": false, + "propose_allowed": false, + "confirmation_allowed": false, + "accept_allowed": false, + "confirm_allowed": false, + "representative_candidate_id": "6c53002d-9dc0-5dbb-920e-d28d68b5ec5d", + "representative_time": "01:00", + "overall_confidence": "low", + "margin_percent": 14.0025, + "reasons": [ + "insufficient_events", + "insufficient_diagnostic_stability", + "vimshottari_narayana_conflict", + "insufficient_confirmation_events", + "insufficient_confirmation_domains", + "insufficient_confirmation_margin", + "adjacent_minutes_indistinguishable", + "engine_exact_confirmation_not_granted", + "external_validation_not_passed", + "public_aa_holdout_not_ready" + ], + "acceptance_reasons": [ + "insufficient_events" + ], + "confirmation_reasons": [ + "insufficient_diagnostic_stability", + "vimshottari_narayana_conflict", + "insufficient_confirmation_events", + "insufficient_confirmation_domains", + "insufficient_confirmation_margin", + "adjacent_minutes_indistinguishable", + "engine_exact_confirmation_not_granted", + "external_validation_not_passed", + "public_aa_holdout_not_ready" + ], + "tie_policy": { + "score_quantum": 0.0001, + "absolute_tolerance": 0.0001, + "rounding": "ROUND_HALF_UP" + }, + "gates": { + "candidate_presence": { + "passed": true, + "candidate_count": 30 + }, + "event_quality": { + "passed": false, + "scoreable_event_count": 2, + "minimum": 3 + }, + "domain_diversity": { + "passed": true, + "scoreable_domain_count": 2, + "minimum": 2, + "domains": [ + "career", + "education" + ] + }, + "date_quality": { + "passed": true, + "precision_weight_total": 1.3, + "precision_weight_mean": 0.65, + "minimum_precision_weight_mean": 0.65, + "low_reliability_event_ids": [], + "unresolved_conflict_event_ids": [] + }, + "unique_top": { + "passed": true, + "tied_minute_count": 1 + }, + "diagnostic_quality": { + "passed": false, + "minimum_retention": 0.75, + "minimum_margin_percent": 10.0, + "margin_percent": 14.0025, + "leave_one_event_out_retention_rate": 0.5, + "leave_one_domain_out_retention_rate": 0.5, + "date_sensitivity_retention_rate": 0.6666666666666666 + }, + "required_layers": { + "passed": true, + "missing_layers": [], + "skipped_by_policy": [] + }, + "exact_confirmation": { + "passed": false, + "fail_closed": true, + "engine_granted": false, + "adjacent_passed": false, + "indistinguishable_width_minutes": 1431, + "max_confirmation_width_minutes": 5, + "external_validation_status": "not_evaluated", + "unique_minute_path": "closed_at_representative", + "required_scoreable_events": 4, + "required_scoreable_domains": 3, + "reason": "engine_and_external_validation_must_explicitly_pass", + "holdout": { + "sealed_benchmark_id": "minute_rectification_fact_ranker_v4_holdout_v3", + "status": "not_ready", + "valid_public_aa_cases": 20, + "required_cases": 20, + "top_1_rate": 0.15, + "confirmation_coverage_rate": 0.0 + } + } + }, + "house_table": { + "time": "01:00", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "natal_recast": { + "time": "01:00", + "lagna": "处女座", + "user_meaning": "本命宫位已按 01:00 重算(上升 处女座)。下面是本轮实际执行的技法,不能当作唯一分钟确认。", + "unique_minute_claim": false, + "confirmation_allowed": false + }, + "house_tables_by_time": { + "23:00": { + "time": "23:00", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "23:10": { + "time": "23:10", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "23:20": { + "time": "23:20", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "23:30": { + "time": "23:30", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "23:40": { + "time": "23:40", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "23:50": { + "time": "23:50", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "00:00": { + "time": "00:00", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "00:10": { + "time": "00:10", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "00:20": { + "time": "00:20", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "00:30": { + "time": "00:30", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "00:40": { + "time": "00:40", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "00:50": { + "time": "00:50", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "01:00": { + "time": "01:00", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "01:10": { + "time": "01:10", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "01:20": { + "time": "01:20", + "lagna": "处女座", + "houses": [ + { + "house": 1, + "sign": "处女座", + "occupants": [] + }, + { + "house": 2, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 3, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 4, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 5, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 6, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 7, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 8, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 9, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 10, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 11, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 12, + "sign": "狮子座", + "occupants": [] + } + ] + }, + "01:30": { + "time": "01:30", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "01:40": { + "time": "01:40", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "01:50": { + "time": "01:50", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "02:00": { + "time": "02:00", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "02:10": { + "time": "02:10", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "02:20": { + "time": "02:20", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "02:30": { + "time": "02:30", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "02:40": { + "time": "02:40", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "02:50": { + "time": "02:50", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "03:00": { + "time": "03:00", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "03:10": { + "time": "03:10", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "03:20": { + "time": "03:20", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "03:30": { + "time": "03:30", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "03:40": { + "time": "03:40", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + }, + "03:50": { + "time": "03:50", + "lagna": "天秤座", + "houses": [ + { + "house": 1, + "sign": "天秤座", + "occupants": [] + }, + { + "house": 2, + "sign": "天蝎座", + "occupants": [ + "火星" + ] + }, + { + "house": 3, + "sign": "射手座", + "occupants": [ + "太阳", + "土星" + ] + }, + { + "house": 4, + "sign": "摩羯座", + "occupants": [ + "水星", + "金星", + "罗睺" + ] + }, + { + "house": 5, + "sign": "水瓶座", + "occupants": [ + "月亮" + ] + }, + { + "house": 6, + "sign": "双鱼座", + "occupants": [] + }, + { + "house": 7, + "sign": "白羊座", + "occupants": [] + }, + { + "house": 8, + "sign": "金牛座", + "occupants": [] + }, + { + "house": 9, + "sign": "双子座", + "occupants": [ + "木星" + ] + }, + { + "house": 10, + "sign": "巨蟹座", + "occupants": [ + "计都" + ] + }, + { + "house": 11, + "sign": "狮子座", + "occupants": [] + }, + { + "house": 12, + "sign": "处女座", + "occupants": [] + } + ] + } + }, + "technique_audit_table": [ + { + "technique": "D1 本命盘", + "status": "executed", + "note": "本轮已按该分钟重算本命宫位。" + }, + { + "technique": "D5 成就分盘", + "status": "executed", + "note": "本轮已对照学业或被委以责任的变化。" + }, + { + "technique": "D10 事业分盘", + "status": "executed", + "note": "本轮已对照事业主题,可用 D10 上升类型表作校时方法,不是命运承诺。" + }, + { + "technique": "D24 教育分盘", + "status": "executed", + "note": "本轮已对照学业主题。" + }, + { + "technique": "Vimshottari", + "status": "executed", + "note": "本轮已对照主限。" + }, + { + "technique": "Narayana", + "status": "executed", + "note": "本轮已对照分盘大运。" + }, + { + "technique": "Gochara", + "status": "executed", + "note": "本轮已做受控行运辅助对照。" + }, + { + "technique": "Ashtakavarga", + "status": "executed", + "note": "本轮已做 Ashtakavarga 辅助对照。" + }, + { + "technique": "Shadbala", + "status": "executed", + "note": "本轮已做已核验的 Shadbala 分量辅助对照。" + }, + { + "technique": "换运贴近度", + "status": "executed", + "note": "本轮已对照日级事件与候选换运日期的贴近程度。" + }, + { + "technique": "KP 宫头", + "status": "executed", + "note": "已按 Swiss Ephemeris Placidus + Krishnamurti 观察 12 宫头;不计分,不参与提出门或确认门。" + }, + { + "technique": "VedAstro 分钟级校验", + "status": "blocked", + "note": "官方分钟敏感校验尚未跑通。未调用不等于失败,但缺这一层不能写确认。" + }, + { + "technique": "唯一分钟确认", + "status": "blocked", + "note": "本会话以代表性时间收口,不确认唯一分钟。" + } + ], + "window_scan": { + "scanned": true, + "confirmation_allowed": false, + "unique_minute_claim": false, + "transitions": [ + { + "layer": "d10", + "at": "00:10", + "user_meaning": "D10 在 00:10 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "d4", + "at": "00:10", + "user_meaning": "D4 在 00:10 发生变化", + "from_sign": "射手座", + "to_sign": "双鱼座" + }, + { + "layer": "d12", + "at": "00:10", + "user_meaning": "D12 在 00:10 发生变化", + "from_sign": "水瓶座", + "to_sign": "双鱼座" + }, + { + "layer": "d24", + "at": "00:10", + "user_meaning": "D24 在 00:10 发生变化", + "from_sign": "金牛座", + "to_sign": "巨蟹座" + }, + { + "layer": "d2", + "at": "00:10", + "user_meaning": "D2 在 00:10 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "kp1", + "at": "00:10", + "user_meaning": "KP 1宫子主 在 00:10 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "kp4", + "at": "00:10", + "user_meaning": "KP 4宫子主 在 00:10 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "kp7", + "at": "00:10", + "user_meaning": "KP 7宫子主 在 00:10 发生变化", + "from_sign": "狮子座", + "to_sign": "处女座" + }, + { + "layer": "kp10", + "at": "00:10", + "user_meaning": "KP 10宫子主 在 00:10 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "d9", + "at": "00:20", + "user_meaning": "D9 在 00:20 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "d7", + "at": "00:20", + "user_meaning": "D7 在 00:20 发生变化", + "from_sign": "双子座", + "to_sign": "巨蟹座" + }, + { + "layer": "d12", + "at": "00:20", + "user_meaning": "D12 在 00:20 发生变化", + "from_sign": "双鱼座", + "to_sign": "白羊座" + }, + { + "layer": "d24", + "at": "00:20", + "user_meaning": "D24 在 00:20 发生变化", + "from_sign": "巨蟹座", + "to_sign": "处女座" + }, + { + "layer": "d11", + "at": "00:20", + "user_meaning": "D11 在 00:20 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "pada", + "at": "00:20", + "user_meaning": "Nakshatra pada 在 00:20 发生变化" + }, + { + "layer": "kp1", + "at": "00:20", + "user_meaning": "KP 1宫子主 在 00:20 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "kp4", + "at": "00:20", + "user_meaning": "KP 4宫子主 在 00:20 发生变化", + "from_sign": "金牛座", + "to_sign": "狮子座" + }, + { + "layer": "kp7", + "at": "00:20", + "user_meaning": "KP 7宫子主 在 00:20 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "kp10", + "at": "00:20", + "user_meaning": "KP 10宫子主 在 00:20 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "d10", + "at": "00:30", + "user_meaning": "D10 在 00:30 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "d5", + "at": "00:30", + "user_meaning": "D5 在 00:30 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "d24", + "at": "00:30", + "user_meaning": "D24 在 00:30 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "d11", + "at": "00:30", + "user_meaning": "D11 在 00:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "hora", + "at": "00:30", + "user_meaning": "Hora Lagna 在 00:30 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "ghati", + "at": "00:30", + "user_meaning": "Ghati Lagna 在 00:30 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "pranapada", + "at": "00:30", + "user_meaning": "Pranapada Lagna 在 00:30 发生变化", + "from_sign": "双子座", + "to_sign": "狮子座" + }, + { + "layer": "kp1", + "at": "00:30", + "user_meaning": "KP 1宫子主 在 00:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "kp4", + "at": "00:30", + "user_meaning": "KP 4宫子主 在 00:30 发生变化", + "from_sign": "狮子座", + "to_sign": "天秤座" + }, + { + "layer": "kp7", + "at": "00:30", + "user_meaning": "KP 7宫子主 在 00:30 发生变化", + "from_sign": "天秤座", + "to_sign": "射手座" + }, + { + "layer": "kp10", + "at": "00:30", + "user_meaning": "KP 10宫子主 在 00:30 发生变化", + "from_sign": "双子座", + "to_sign": "天秤座" + }, + { + "layer": "d9", + "at": "00:40", + "user_meaning": "D9 在 00:40 发生变化", + "from_sign": "双子座", + "to_sign": "巨蟹座" + }, + { + "layer": "d10", + "at": "00:40", + "user_meaning": "D10 在 00:40 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "d7", + "at": "00:40", + "user_meaning": "D7 在 00:40 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "d12", + "at": "00:40", + "user_meaning": "D12 在 00:40 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "d24", + "at": "00:40", + "user_meaning": "D24 在 00:40 发生变化", + "from_sign": "天秤座", + "to_sign": "射手座" + }, + { + "layer": "d30", + "at": "00:40", + "user_meaning": "D30 在 00:40 发生变化", + "from_sign": "摩羯座", + "to_sign": "天蝎座" + }, + { + "layer": "pada", + "at": "00:40", + "user_meaning": "Nakshatra pada 在 00:40 发生变化" + }, + { + "layer": "kp1", + "at": "00:40", + "user_meaning": "KP 1宫子主 在 00:40 发生变化", + "from_sign": "射手座", + "to_sign": "白羊座" + }, + { + "layer": "kp4", + "at": "00:40", + "user_meaning": "KP 4宫子主 在 00:40 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "kp7", + "at": "00:40", + "user_meaning": "KP 7宫子主 在 00:40 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "kp10", + "at": "00:40", + "user_meaning": "KP 10宫子主 在 00:40 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "d9", + "at": "00:50", + "user_meaning": "D9 在 00:50 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "d4", + "at": "00:50", + "user_meaning": "D4 在 00:50 发生变化", + "from_sign": "双鱼座", + "to_sign": "双子座" + }, + { + "layer": "d12", + "at": "00:50", + "user_meaning": "D12 在 00:50 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "d24", + "at": "00:50", + "user_meaning": "D24 在 00:50 发生变化", + "from_sign": "射手座", + "to_sign": "摩羯座" + }, + { + "layer": "d11", + "at": "00:50", + "user_meaning": "D11 在 00:50 发生变化", + "from_sign": "射手座", + "to_sign": "摩羯座" + }, + { + "layer": "pada", + "at": "00:50", + "user_meaning": "Nakshatra pada 在 00:50 发生变化" + }, + { + "layer": "kp1", + "at": "00:50", + "user_meaning": "KP 1宫子主 在 00:50 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "kp4", + "at": "00:50", + "user_meaning": "KP 4宫子主 在 00:50 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "kp7", + "at": "00:50", + "user_meaning": "KP 7宫子主 在 00:50 发生变化", + "from_sign": "金牛座", + "to_sign": "巨蟹座" + }, + { + "layer": "kp10", + "at": "00:50", + "user_meaning": "KP 10宫子主 在 00:50 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "d10", + "at": "01:00", + "user_meaning": "D10 在 01:00 发生变化", + "from_sign": "射手座", + "to_sign": "摩羯座" + }, + { + "layer": "d5", + "at": "01:00", + "user_meaning": "D5 在 01:00 发生变化", + "from_sign": "狮子座", + "to_sign": "处女座" + }, + { + "layer": "d12", + "at": "01:00", + "user_meaning": "D12 在 01:00 发生变化", + "from_sign": "双子座", + "to_sign": "巨蟹座" + }, + { + "layer": "d24", + "at": "01:00", + "user_meaning": "D24 在 01:00 发生变化", + "from_sign": "摩羯座", + "to_sign": "双鱼座" + }, + { + "layer": "d11", + "at": "01:00", + "user_meaning": "D11 在 01:00 发生变化", + "from_sign": "摩羯座", + "to_sign": "水瓶座" + }, + { + "layer": "d30", + "at": "01:00", + "user_meaning": "D30 在 01:00 发生变化", + "from_sign": "天蝎座", + "to_sign": "双鱼座" + }, + { + "layer": "bhava", + "at": "01:00", + "user_meaning": "Bhava Lagna 在 01:00 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "kp1", + "at": "01:00", + "user_meaning": "KP 1宫子主 在 01:00 发生变化", + "from_sign": "金牛座", + "to_sign": "处女座" + }, + { + "layer": "kp4", + "at": "01:00", + "user_meaning": "KP 4宫子主 在 01:00 发生变化", + "from_sign": "射手座", + "to_sign": "双子座" + }, + { + "layer": "kp7", + "at": "01:00", + "user_meaning": "KP 7宫子主 在 01:00 发生变化", + "from_sign": "巨蟹座", + "to_sign": "处女座" + }, + { + "layer": "kp10", + "at": "01:00", + "user_meaning": "KP 10宫子主 在 01:00 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "d9", + "at": "01:10", + "user_meaning": "D9 在 01:10 发生变化", + "from_sign": "狮子座", + "to_sign": "处女座" + }, + { + "layer": "d10", + "at": "01:10", + "user_meaning": "D10 在 01:10 发生变化", + "from_sign": "摩羯座", + "to_sign": "水瓶座" + }, + { + "layer": "d7", + "at": "01:10", + "user_meaning": "D7 在 01:10 发生变化", + "from_sign": "狮子座", + "to_sign": "处女座" + }, + { + "layer": "d12", + "at": "01:10", + "user_meaning": "D12 在 01:10 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "d24", + "at": "01:10", + "user_meaning": "D24 在 01:10 发生变化", + "from_sign": "双鱼座", + "to_sign": "金牛座" + }, + { + "layer": "d11", + "at": "01:10", + "user_meaning": "D11 在 01:10 发生变化", + "from_sign": "水瓶座", + "to_sign": "双鱼座" + }, + { + "layer": "pada", + "at": "01:10", + "user_meaning": "Nakshatra pada 在 01:10 发生变化" + }, + { + "layer": "kp1", + "at": "01:10", + "user_meaning": "KP 1宫子主 在 01:10 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "kp4", + "at": "01:10", + "user_meaning": "KP 4宫子主 在 01:10 发生变化", + "from_sign": "双子座", + "to_sign": "处女座" + }, + { + "layer": "kp7", + "at": "01:10", + "user_meaning": "KP 7宫子主 在 01:10 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "kp10", + "at": "01:10", + "user_meaning": "KP 10宫子主 在 01:10 发生变化", + "from_sign": "金牛座", + "to_sign": "巨蟹座" + }, + { + "layer": "d24", + "at": "01:20", + "user_meaning": "D24 在 01:20 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "kp1", + "at": "01:20", + "user_meaning": "KP 1宫子主 在 01:20 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "kp4", + "at": "01:20", + "user_meaning": "KP 4宫子主 在 01:20 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "kp7", + "at": "01:20", + "user_meaning": "KP 7宫子主 在 01:20 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "kp10", + "at": "01:20", + "user_meaning": "KP 10宫子主 在 01:20 发生变化", + "from_sign": "巨蟹座", + "to_sign": "处女座" + }, + { + "layer": "d1", + "at": "01:30", + "user_meaning": "本命上升 在 01:30 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "d9", + "at": "01:30", + "user_meaning": "D9 在 01:30 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "d10", + "at": "01:30", + "user_meaning": "D10 在 01:30 发生变化", + "from_sign": "水瓶座", + "to_sign": "天秤座" + }, + { + "layer": "d4", + "at": "01:30", + "user_meaning": "D4 在 01:30 发生变化", + "from_sign": "双子座", + "to_sign": "天秤座" + }, + { + "layer": "d5", + "at": "01:30", + "user_meaning": "D5 在 01:30 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "d7", + "at": "01:30", + "user_meaning": "D7 在 01:30 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "d12", + "at": "01:30", + "user_meaning": "D12 在 01:30 发生变化", + "from_sign": "狮子座", + "to_sign": "天秤座" + }, + { + "layer": "d24", + "at": "01:30", + "user_meaning": "D24 在 01:30 发生变化", + "from_sign": "双子座", + "to_sign": "处女座" + }, + { + "layer": "d11", + "at": "01:30", + "user_meaning": "D11 在 01:30 发生变化", + "from_sign": "双鱼座", + "to_sign": "天秤座" + }, + { + "layer": "d30", + "at": "01:30", + "user_meaning": "D30 在 01:30 发生变化", + "from_sign": "双鱼座", + "to_sign": "白羊座" + }, + { + "layer": "pada", + "at": "01:30", + "user_meaning": "Nakshatra pada 在 01:30 发生变化" + }, + { + "layer": "hora", + "at": "01:30", + "user_meaning": "Hora Lagna 在 01:30 发生变化", + "from_sign": "双子座", + "to_sign": "巨蟹座" + }, + { + "layer": "ghati", + "at": "01:30", + "user_meaning": "Ghati Lagna 在 01:30 发生变化", + "from_sign": "双子座", + "to_sign": "巨蟹座" + }, + { + "layer": "pranapada", + "at": "01:30", + "user_meaning": "Pranapada Lagna 在 01:30 发生变化", + "from_sign": "狮子座", + "to_sign": "天秤座" + }, + { + "layer": "kp1", + "at": "01:30", + "user_meaning": "KP 1宫子主 在 01:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "kp4", + "at": "01:30", + "user_meaning": "KP 4宫子主 在 01:30 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "kp7", + "at": "01:30", + "user_meaning": "KP 7宫子主 在 01:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "白羊座" + }, + { + "layer": "kp10", + "at": "01:30", + "user_meaning": "KP 10宫子主 在 01:30 发生变化", + "from_sign": "处女座", + "to_sign": "天蝎座" + }, + { + "layer": "d9", + "at": "01:40", + "user_meaning": "D9 在 01:40 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "d10", + "at": "01:40", + "user_meaning": "D10 在 01:40 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "d12", + "at": "01:40", + "user_meaning": "D12 在 01:40 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "d24", + "at": "01:40", + "user_meaning": "D24 在 01:40 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "d11", + "at": "01:40", + "user_meaning": "D11 在 01:40 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "pada", + "at": "01:40", + "user_meaning": "Nakshatra pada 在 01:40 发生变化" + }, + { + "layer": "kp1", + "at": "01:40", + "user_meaning": "KP 1宫子主 在 01:40 发生变化", + "from_sign": "射手座", + "to_sign": "白羊座" + }, + { + "layer": "kp4", + "at": "01:40", + "user_meaning": "KP 4宫子主 在 01:40 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "kp7", + "at": "01:40", + "user_meaning": "KP 7宫子主 在 01:40 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "kp10", + "at": "01:40", + "user_meaning": "KP 10宫子主 在 01:40 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "d7", + "at": "01:50", + "user_meaning": "D7 在 01:50 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "d12", + "at": "01:50", + "user_meaning": "D12 在 01:50 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "d24", + "at": "01:50", + "user_meaning": "D24 在 01:50 发生变化", + "from_sign": "天秤座", + "to_sign": "射手座" + }, + { + "layer": "d11", + "at": "01:50", + "user_meaning": "D11 在 01:50 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "d30", + "at": "01:50", + "user_meaning": "D30 在 01:50 发生变化", + "from_sign": "白羊座", + "to_sign": "水瓶座" + }, + { + "layer": "kp1", + "at": "01:50", + "user_meaning": "KP 1宫子主 在 01:50 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "kp4", + "at": "01:50", + "user_meaning": "KP 4宫子主 在 01:50 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "kp7", + "at": "01:50", + "user_meaning": "KP 7宫子主 在 01:50 发生变化", + "from_sign": "金牛座", + "to_sign": "巨蟹座" + }, + { + "layer": "kp10", + "at": "01:50", + "user_meaning": "KP 10宫子主 在 01:50 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "d9", + "at": "02:00", + "user_meaning": "D9 在 02:00 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "d10", + "at": "02:00", + "user_meaning": "D10 在 02:00 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "d4", + "at": "02:00", + "user_meaning": "D4 在 02:00 发生变化", + "from_sign": "天秤座", + "to_sign": "摩羯座" + }, + { + "layer": "d5", + "at": "02:00", + "user_meaning": "D5 在 02:00 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "d12", + "at": "02:00", + "user_meaning": "D12 在 02:00 发生变化", + "from_sign": "射手座", + "to_sign": "摩羯座" + }, + { + "layer": "d24", + "at": "02:00", + "user_meaning": "D24 在 02:00 发生变化", + "from_sign": "射手座", + "to_sign": "水瓶座" + }, + { + "layer": "pada", + "at": "02:00", + "user_meaning": "Nakshatra pada 在 02:00 发生变化" + }, + { + "layer": "kp1", + "at": "02:00", + "user_meaning": "KP 1宫子主 在 02:00 发生变化", + "from_sign": "金牛座", + "to_sign": "巨蟹座" + }, + { + "layer": "kp4", + "at": "02:00", + "user_meaning": "KP 4宫子主 在 02:00 发生变化", + "from_sign": "金牛座", + "to_sign": "狮子座" + }, + { + "layer": "kp7", + "at": "02:00", + "user_meaning": "KP 7宫子主 在 02:00 发生变化", + "from_sign": "巨蟹座", + "to_sign": "处女座" + }, + { + "layer": "kp10", + "at": "02:00", + "user_meaning": "KP 10宫子主 在 02:00 发生变化", + "from_sign": "金牛座", + "to_sign": "巨蟹座" + }, + { + "layer": "d10", + "at": "02:10", + "user_meaning": "D10 在 02:10 发生变化", + "from_sign": "射手座", + "to_sign": "摩羯座" + }, + { + "layer": "d7", + "at": "02:10", + "user_meaning": "D7 在 02:10 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "d24", + "at": "02:10", + "user_meaning": "D24 在 02:10 发生变化", + "from_sign": "水瓶座", + "to_sign": "双鱼座" + }, + { + "layer": "d11", + "at": "02:10", + "user_meaning": "D11 在 02:10 发生变化", + "from_sign": "射手座", + "to_sign": "摩羯座" + }, + { + "layer": "kp1", + "at": "02:10", + "user_meaning": "KP 1宫子主 在 02:10 发生变化", + "from_sign": "巨蟹座", + "to_sign": "处女座" + }, + { + "layer": "kp4", + "at": "02:10", + "user_meaning": "KP 4宫子主 在 02:10 发生变化", + "from_sign": "狮子座", + "to_sign": "天秤座" + }, + { + "layer": "kp7", + "at": "02:10", + "user_meaning": "KP 7宫子主 在 02:10 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "kp10", + "at": "02:10", + "user_meaning": "KP 10宫子主 在 02:10 发生变化", + "from_sign": "巨蟹座", + "to_sign": "处女座" + }, + { + "layer": "d9", + "at": "02:20", + "user_meaning": "D9 在 02:20 发生变化", + "from_sign": "射手座", + "to_sign": "摩羯座" + }, + { + "layer": "d12", + "at": "02:20", + "user_meaning": "D12 在 02:20 发生变化", + "from_sign": "摩羯座", + "to_sign": "水瓶座" + }, + { + "layer": "d24", + "at": "02:20", + "user_meaning": "D24 在 02:20 发生变化", + "from_sign": "双鱼座", + "to_sign": "金牛座" + }, + { + "layer": "d11", + "at": "02:20", + "user_meaning": "D11 在 02:20 发生变化", + "from_sign": "摩羯座", + "to_sign": "水瓶座" + }, + { + "layer": "d30", + "at": "02:20", + "user_meaning": "D30 在 02:20 发生变化", + "from_sign": "水瓶座", + "to_sign": "射手座" + }, + { + "layer": "pada", + "at": "02:20", + "user_meaning": "Nakshatra pada 在 02:20 发生变化" + }, + { + "layer": "kp1", + "at": "02:20", + "user_meaning": "KP 1宫子主 在 02:20 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "kp4", + "at": "02:20", + "user_meaning": "KP 4宫子主 在 02:20 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "kp7", + "at": "02:20", + "user_meaning": "KP 7宫子主 在 02:20 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "kp10", + "at": "02:20", + "user_meaning": "KP 10宫子主 在 02:20 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "d9", + "at": "02:30", + "user_meaning": "D9 在 02:30 发生变化", + "from_sign": "摩羯座", + "to_sign": "水瓶座" + }, + { + "layer": "d10", + "at": "02:30", + "user_meaning": "D10 在 02:30 发生变化", + "from_sign": "摩羯座", + "to_sign": "水瓶座" + }, + { + "layer": "d5", + "at": "02:30", + "user_meaning": "D5 在 02:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "d7", + "at": "02:30", + "user_meaning": "D7 在 02:30 发生变化", + "from_sign": "射手座", + "to_sign": "摩羯座" + }, + { + "layer": "d12", + "at": "02:30", + "user_meaning": "D12 在 02:30 发生变化", + "from_sign": "水瓶座", + "to_sign": "双鱼座" + }, + { + "layer": "d24", + "at": "02:30", + "user_meaning": "D24 在 02:30 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "pada", + "at": "02:30", + "user_meaning": "Nakshatra pada 在 02:30 发生变化" + }, + { + "layer": "kp1", + "at": "02:30", + "user_meaning": "KP 1宫子主 在 02:30 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "kp4", + "at": "02:30", + "user_meaning": "KP 4宫子主 在 02:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "kp7", + "at": "02:30", + "user_meaning": "KP 7宫子主 在 02:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "kp10", + "at": "02:30", + "user_meaning": "KP 10宫子主 在 02:30 发生变化", + "from_sign": "天秤座", + "to_sign": "白羊座" + }, + { + "layer": "d10", + "at": "02:40", + "user_meaning": "D10 在 02:40 发生变化", + "from_sign": "水瓶座", + "to_sign": "双鱼座" + }, + { + "layer": "d4", + "at": "02:40", + "user_meaning": "D4 在 02:40 发生变化", + "from_sign": "摩羯座", + "to_sign": "白羊座" + }, + { + "layer": "d12", + "at": "02:40", + "user_meaning": "D12 在 02:40 发生变化", + "from_sign": "双鱼座", + "to_sign": "白羊座" + }, + { + "layer": "d24", + "at": "02:40", + "user_meaning": "D24 在 02:40 发生变化", + "from_sign": "双子座", + "to_sign": "狮子座" + }, + { + "layer": "d2", + "at": "02:40", + "user_meaning": "D2 在 02:40 发生变化", + "from_sign": "狮子座", + "to_sign": "巨蟹座" + }, + { + "layer": "d11", + "at": "02:40", + "user_meaning": "D11 在 02:40 发生变化", + "from_sign": "水瓶座", + "to_sign": "双鱼座" + }, + { + "layer": "hora", + "at": "02:40", + "user_meaning": "Hora Lagna 在 02:40 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "ghati", + "at": "02:40", + "user_meaning": "Ghati Lagna 在 02:40 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "pranapada", + "at": "02:40", + "user_meaning": "Pranapada Lagna 在 02:40 发生变化", + "from_sign": "天秤座", + "to_sign": "射手座" + }, + { + "layer": "kp1", + "at": "02:40", + "user_meaning": "KP 1宫子主 在 02:40 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "kp4", + "at": "02:40", + "user_meaning": "KP 4宫子主 在 02:40 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "kp7", + "at": "02:40", + "user_meaning": "KP 7宫子主 在 02:40 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "kp10", + "at": "02:40", + "user_meaning": "KP 10宫子主 在 02:40 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "d9", + "at": "02:50", + "user_meaning": "D9 在 02:50 发生变化", + "from_sign": "水瓶座", + "to_sign": "双鱼座" + }, + { + "layer": "d7", + "at": "02:50", + "user_meaning": "D7 在 02:50 发生变化", + "from_sign": "摩羯座", + "to_sign": "水瓶座" + }, + { + "layer": "d24", + "at": "02:50", + "user_meaning": "D24 在 02:50 发生变化", + "from_sign": "狮子座", + "to_sign": "处女座" + }, + { + "layer": "d11", + "at": "02:50", + "user_meaning": "D11 在 02:50 发生变化", + "from_sign": "双鱼座", + "to_sign": "白羊座" + }, + { + "layer": "pada", + "at": "02:50", + "user_meaning": "Nakshatra pada 在 02:50 发生变化" + }, + { + "layer": "kp1", + "at": "02:50", + "user_meaning": "KP 1宫子主 在 02:50 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "kp4", + "at": "02:50", + "user_meaning": "KP 4宫子主 在 02:50 发生变化", + "from_sign": "金牛座", + "to_sign": "狮子座" + }, + { + "layer": "kp7", + "at": "02:50", + "user_meaning": "KP 7宫子主 在 02:50 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "kp10", + "at": "02:50", + "user_meaning": "KP 10宫子主 在 02:50 发生变化", + "from_sign": "金牛座", + "to_sign": "狮子座" + }, + { + "layer": "d10", + "at": "03:00", + "user_meaning": "D10 在 03:00 发生变化", + "from_sign": "双鱼座", + "to_sign": "白羊座" + }, + { + "layer": "d5", + "at": "03:00", + "user_meaning": "D5 在 03:00 发生变化", + "from_sign": "射手座", + "to_sign": "摩羯座" + }, + { + "layer": "d12", + "at": "03:00", + "user_meaning": "D12 在 03:00 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "d24", + "at": "03:00", + "user_meaning": "D24 在 03:00 发生变化", + "from_sign": "处女座", + "to_sign": "天蝎座" + }, + { + "layer": "d11", + "at": "03:00", + "user_meaning": "D11 在 03:00 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "d30", + "at": "03:00", + "user_meaning": "D30 在 03:00 发生变化", + "from_sign": "射手座", + "to_sign": "双子座" + }, + { + "layer": "kp1", + "at": "03:00", + "user_meaning": "KP 1宫子主 在 03:00 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "kp4", + "at": "03:00", + "user_meaning": "KP 4宫子主 在 03:00 发生变化", + "from_sign": "狮子座", + "to_sign": "天秤座" + }, + { + "layer": "kp7", + "at": "03:00", + "user_meaning": "KP 7宫子主 在 03:00 发生变化", + "from_sign": "双子座", + "to_sign": "狮子座" + }, + { + "layer": "kp10", + "at": "03:00", + "user_meaning": "KP 10宫子主 在 03:00 发生变化", + "from_sign": "狮子座", + "to_sign": "天秤座" + }, + { + "layer": "d9", + "at": "03:10", + "user_meaning": "D9 在 03:10 发生变化", + "from_sign": "双鱼座", + "to_sign": "白羊座" + }, + { + "layer": "d10", + "at": "03:10", + "user_meaning": "D10 在 03:10 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "d7", + "at": "03:10", + "user_meaning": "D7 在 03:10 发生变化", + "from_sign": "水瓶座", + "to_sign": "双鱼座" + }, + { + "layer": "d12", + "at": "03:10", + "user_meaning": "D12 在 03:10 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "d24", + "at": "03:10", + "user_meaning": "D24 在 03:10 发生变化", + "from_sign": "天蝎座", + "to_sign": "摩羯座" + }, + { + "layer": "pada", + "at": "03:10", + "user_meaning": "Nakshatra pada 在 03:10 发生变化" + }, + { + "layer": "kp1", + "at": "03:10", + "user_meaning": "KP 1宫子主 在 03:10 发生变化", + "from_sign": "双子座", + "to_sign": "天秤座" + }, + { + "layer": "kp4", + "at": "03:10", + "user_meaning": "KP 4宫子主 在 03:10 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "kp7", + "at": "03:10", + "user_meaning": "KP 7宫子主 在 03:10 发生变化", + "from_sign": "狮子座", + "to_sign": "处女座" + }, + { + "layer": "kp10", + "at": "03:10", + "user_meaning": "KP 10宫子主 在 03:10 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "d9", + "at": "03:20", + "user_meaning": "D9 在 03:20 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "d4", + "at": "03:20", + "user_meaning": "D4 在 03:20 发生变化", + "from_sign": "白羊座", + "to_sign": "巨蟹座" + }, + { + "layer": "d12", + "at": "03:20", + "user_meaning": "D12 在 03:20 发生变化", + "from_sign": "双子座", + "to_sign": "巨蟹座" + }, + { + "layer": "d24", + "at": "03:20", + "user_meaning": "D24 在 03:20 发生变化", + "from_sign": "摩羯座", + "to_sign": "水瓶座" + }, + { + "layer": "d11", + "at": "03:20", + "user_meaning": "D11 在 03:20 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "pada", + "at": "03:20", + "user_meaning": "Nakshatra pada 在 03:20 发生变化" + }, + { + "layer": "kp1", + "at": "03:20", + "user_meaning": "KP 1宫子主 在 03:20 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "kp4", + "at": "03:20", + "user_meaning": "KP 4宫子主 在 03:20 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "kp7", + "at": "03:20", + "user_meaning": "KP 7宫子主 在 03:20 发生变化", + "from_sign": "处女座", + "to_sign": "天蝎座" + }, + { + "layer": "kp10", + "at": "03:20", + "user_meaning": "KP 10宫子主 在 03:20 发生变化", + "from_sign": "天蝎座", + "to_sign": "金牛座" + }, + { + "layer": "d10", + "at": "03:30", + "user_meaning": "D10 在 03:30 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "d5", + "at": "03:30", + "user_meaning": "D5 在 03:30 发生变化", + "from_sign": "摩羯座", + "to_sign": "水瓶座" + }, + { + "layer": "d12", + "at": "03:30", + "user_meaning": "D12 在 03:30 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "d24", + "at": "03:30", + "user_meaning": "D24 在 03:30 发生变化", + "from_sign": "水瓶座", + "to_sign": "白羊座" + }, + { + "layer": "d11", + "at": "03:30", + "user_meaning": "D11 在 03:30 发生变化", + "from_sign": "双子座", + "to_sign": "巨蟹座" + }, + { + "layer": "d30", + "at": "03:30", + "user_meaning": "D30 在 03:30 发生变化", + "from_sign": "双子座", + "to_sign": "天秤座" + }, + { + "layer": "kp1", + "at": "03:30", + "user_meaning": "KP 1宫子主 在 03:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "kp4", + "at": "03:30", + "user_meaning": "KP 4宫子主 在 03:30 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "kp7", + "at": "03:30", + "user_meaning": "KP 7宫子主 在 03:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "kp10", + "at": "03:30", + "user_meaning": "KP 10宫子主 在 03:30 发生变化", + "from_sign": "金牛座", + "to_sign": "巨蟹座" + }, + { + "layer": "d9", + "at": "03:40", + "user_meaning": "D9 在 03:40 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "d10", + "at": "03:40", + "user_meaning": "D10 在 03:40 发生变化", + "from_sign": "双子座", + "to_sign": "巨蟹座" + }, + { + "layer": "d7", + "at": "03:40", + "user_meaning": "D7 在 03:40 发生变化", + "from_sign": "双鱼座", + "to_sign": "白羊座" + }, + { + "layer": "d24", + "at": "03:40", + "user_meaning": "D24 在 03:40 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "d11", + "at": "03:40", + "user_meaning": "D11 在 03:40 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "pada", + "at": "03:40", + "user_meaning": "Nakshatra pada 在 03:40 发生变化" + }, + { + "layer": "bhava", + "at": "03:40", + "user_meaning": "Bhava Lagna 在 03:40 发生变化", + "from_sign": "狮子座", + "to_sign": "处女座" + }, + { + "layer": "kp1", + "at": "03:40", + "user_meaning": "KP 1宫子主 在 03:40 发生变化", + "from_sign": "射手座", + "to_sign": "白羊座" + }, + { + "layer": "kp4", + "at": "03:40", + "user_meaning": "KP 4宫子主 在 03:40 发生变化", + "from_sign": "金牛座", + "to_sign": "处女座" + }, + { + "layer": "kp7", + "at": "03:40", + "user_meaning": "KP 7宫子主 在 03:40 发生变化", + "from_sign": "射手座", + "to_sign": "白羊座" + }, + { + "layer": "kp10", + "at": "03:40", + "user_meaning": "KP 10宫子主 在 03:40 发生变化", + "from_sign": "巨蟹座", + "to_sign": "处女座" + }, + { + "layer": "d12", + "at": "03:50", + "user_meaning": "D12 在 03:50 发生变化", + "from_sign": "狮子座", + "to_sign": "处女座" + }, + { + "layer": "d24", + "at": "03:50", + "user_meaning": "D24 在 03:50 发生变化", + "from_sign": "金牛座", + "to_sign": "巨蟹座" + }, + { + "layer": "hora", + "at": "03:50", + "user_meaning": "Hora Lagna 在 03:50 发生变化", + "from_sign": "狮子座", + "to_sign": "处女座" + }, + { + "layer": "ghati", + "at": "03:50", + "user_meaning": "Ghati Lagna 在 03:50 发生变化", + "from_sign": "狮子座", + "to_sign": "处女座" + }, + { + "layer": "pranapada", + "at": "03:50", + "user_meaning": "Pranapada Lagna 在 03:50 发生变化", + "from_sign": "射手座", + "to_sign": "水瓶座" + }, + { + "layer": "kp1", + "at": "03:50", + "user_meaning": "KP 1宫子主 在 03:50 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "kp4", + "at": "03:50", + "user_meaning": "KP 4宫子主 在 03:50 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "kp7", + "at": "03:50", + "user_meaning": "KP 7宫子主 在 03:50 发生变化", + "from_sign": "白羊座", + "to_sign": "巨蟹座" + }, + { + "layer": "kp10", + "at": "03:50", + "user_meaning": "KP 10宫子主 在 03:50 发生变化", + "from_sign": "处女座", + "to_sign": "天蝎座" + }, + { + "layer": "d1", + "at": "23:00", + "user_meaning": "本命上升 在 23:00 发生变化", + "from_sign": "天秤座", + "to_sign": "处女座" + }, + { + "layer": "d9", + "at": "23:00", + "user_meaning": "D9 在 23:00 发生变化", + "from_sign": "双子座", + "to_sign": "摩羯座" + }, + { + "layer": "d10", + "at": "23:00", + "user_meaning": "D10 在 23:00 发生变化", + "from_sign": "巨蟹座", + "to_sign": "金牛座" + }, + { + "layer": "d4", + "at": "23:00", + "user_meaning": "D4 在 23:00 发生变化", + "from_sign": "巨蟹座", + "to_sign": "处女座" + }, + { + "layer": "d5", + "at": "23:00", + "user_meaning": "D5 在 23:00 发生变化", + "from_sign": "水瓶座", + "to_sign": "金牛座" + }, + { + "layer": "d7", + "at": "23:00", + "user_meaning": "D7 在 23:00 发生变化", + "from_sign": "白羊座", + "to_sign": "双鱼座" + }, + { + "layer": "d24", + "at": "23:00", + "user_meaning": "D24 在 23:00 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "d11", + "at": "23:00", + "user_meaning": "D11 在 23:00 发生变化", + "from_sign": "狮子座", + "to_sign": "金牛座" + }, + { + "layer": "d30", + "at": "23:00", + "user_meaning": "D30 在 23:00 发生变化", + "from_sign": "天秤座", + "to_sign": "金牛座" + }, + { + "layer": "pada", + "at": "23:00", + "user_meaning": "Nakshatra pada 在 23:00 发生变化" + }, + { + "layer": "hora", + "at": "23:00", + "user_meaning": "Hora Lagna 在 23:00 发生变化", + "from_sign": "处女座", + "to_sign": "白羊座" + }, + { + "layer": "ghati", + "at": "23:00", + "user_meaning": "Ghati Lagna 在 23:00 发生变化", + "from_sign": "处女座", + "to_sign": "白羊座" + }, + { + "layer": "bhava", + "at": "23:00", + "user_meaning": "Bhava Lagna 在 23:00 发生变化", + "from_sign": "处女座", + "to_sign": "巨蟹座" + }, + { + "layer": "pranapada", + "at": "23:00", + "user_meaning": "Pranapada Lagna 在 23:00 发生变化", + "from_sign": "水瓶座", + "to_sign": "白羊座" + }, + { + "layer": "kp1", + "at": "23:00", + "user_meaning": "KP 1宫子主 在 23:00 发生变化", + "from_sign": "金牛座", + "to_sign": "处女座" + }, + { + "layer": "kp4", + "at": "23:00", + "user_meaning": "KP 4宫子主 在 23:00 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "d9", + "at": "23:10", + "user_meaning": "D9 在 23:10 发生变化", + "from_sign": "摩羯座", + "to_sign": "水瓶座" + }, + { + "layer": "d10", + "at": "23:10", + "user_meaning": "D10 在 23:10 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "d12", + "at": "23:10", + "user_meaning": "D12 在 23:10 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "d24", + "at": "23:10", + "user_meaning": "D24 在 23:10 发生变化", + "from_sign": "狮子座", + "to_sign": "处女座" + }, + { + "layer": "d11", + "at": "23:10", + "user_meaning": "D11 在 23:10 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "pada", + "at": "23:10", + "user_meaning": "Nakshatra pada 在 23:10 发生变化" + }, + { + "layer": "kp1", + "at": "23:10", + "user_meaning": "KP 1宫子主 在 23:10 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "kp4", + "at": "23:10", + "user_meaning": "KP 4宫子主 在 23:10 发生变化", + "from_sign": "天蝎座", + "to_sign": "金牛座" + }, + { + "layer": "kp7", + "at": "23:10", + "user_meaning": "KP 7宫子主 在 23:10 发生变化", + "from_sign": "巨蟹座", + "to_sign": "处女座" + }, + { + "layer": "kp10", + "at": "23:10", + "user_meaning": "KP 10宫子主 在 23:10 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "d7", + "at": "23:20", + "user_meaning": "D7 在 23:20 发生变化", + "from_sign": "双鱼座", + "to_sign": "白羊座" + }, + { + "layer": "d12", + "at": "23:20", + "user_meaning": "D12 在 23:20 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "d24", + "at": "23:20", + "user_meaning": "D24 在 23:20 发生变化", + "from_sign": "处女座", + "to_sign": "天蝎座" + }, + { + "layer": "d11", + "at": "23:20", + "user_meaning": "D11 在 23:20 发生变化", + "from_sign": "双子座", + "to_sign": "巨蟹座" + }, + { + "layer": "d30", + "at": "23:20", + "user_meaning": "D30 在 23:20 发生变化", + "from_sign": "金牛座", + "to_sign": "处女座" + }, + { + "layer": "hora", + "at": "23:20", + "user_meaning": "Hora Lagna 在 23:20 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "ghati", + "at": "23:20", + "user_meaning": "Ghati Lagna 在 23:20 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "pranapada", + "at": "23:20", + "user_meaning": "Pranapada Lagna 在 23:20 发生变化", + "from_sign": "白羊座", + "to_sign": "双子座" + }, + { + "layer": "kp1", + "at": "23:20", + "user_meaning": "KP 1宫子主 在 23:20 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "kp4", + "at": "23:20", + "user_meaning": "KP 4宫子主 在 23:20 发生变化", + "from_sign": "金牛座", + "to_sign": "巨蟹座" + }, + { + "layer": "kp7", + "at": "23:20", + "user_meaning": "KP 7宫子主 在 23:20 发生变化", + "from_sign": "处女座", + "to_sign": "天蝎座" + }, + { + "layer": "kp10", + "at": "23:20", + "user_meaning": "KP 10宫子主 在 23:20 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "d9", + "at": "23:30", + "user_meaning": "D9 在 23:30 发生变化", + "from_sign": "水瓶座", + "to_sign": "双鱼座" + }, + { + "layer": "d10", + "at": "23:30", + "user_meaning": "D10 在 23:30 发生变化", + "from_sign": "双子座", + "to_sign": "巨蟹座" + }, + { + "layer": "d4", + "at": "23:30", + "user_meaning": "D4 在 23:30 发生变化", + "from_sign": "处女座", + "to_sign": "射手座" + }, + { + "layer": "d5", + "at": "23:30", + "user_meaning": "D5 在 23:30 发生变化", + "from_sign": "金牛座", + "to_sign": "双子座" + }, + { + "layer": "d12", + "at": "23:30", + "user_meaning": "D12 在 23:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "d24", + "at": "23:30", + "user_meaning": "D24 在 23:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "摩羯座" + }, + { + "layer": "pada", + "at": "23:30", + "user_meaning": "Nakshatra pada 在 23:30 发生变化" + }, + { + "layer": "kp1", + "at": "23:30", + "user_meaning": "KP 1宫子主 在 23:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "kp4", + "at": "23:30", + "user_meaning": "KP 4宫子主 在 23:30 发生变化", + "from_sign": "巨蟹座", + "to_sign": "处女座" + }, + { + "layer": "kp7", + "at": "23:30", + "user_meaning": "KP 7宫子主 在 23:30 发生变化", + "from_sign": "天蝎座", + "to_sign": "射手座" + }, + { + "layer": "kp10", + "at": "23:30", + "user_meaning": "KP 10宫子主 在 23:30 发生变化", + "from_sign": "金牛座", + "to_sign": "巨蟹座" + }, + { + "layer": "d10", + "at": "23:40", + "user_meaning": "D10 在 23:40 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "d7", + "at": "23:40", + "user_meaning": "D7 在 23:40 发生变化", + "from_sign": "白羊座", + "to_sign": "金牛座" + }, + { + "layer": "d24", + "at": "23:40", + "user_meaning": "D24 在 23:40 发生变化", + "from_sign": "摩羯座", + "to_sign": "水瓶座" + }, + { + "layer": "d11", + "at": "23:40", + "user_meaning": "D11 在 23:40 发生变化", + "from_sign": "巨蟹座", + "to_sign": "狮子座" + }, + { + "layer": "kp1", + "at": "23:40", + "user_meaning": "KP 1宫子主 在 23:40 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "kp4", + "at": "23:40", + "user_meaning": "KP 4宫子主 在 23:40 发生变化", + "from_sign": "处女座", + "to_sign": "天秤座" + }, + { + "layer": "kp7", + "at": "23:40", + "user_meaning": "KP 7宫子主 在 23:40 发生变化", + "from_sign": "射手座", + "to_sign": "金牛座" + }, + { + "layer": "kp10", + "at": "23:40", + "user_meaning": "KP 10宫子主 在 23:40 发生变化", + "from_sign": "巨蟹座", + "to_sign": "处女座" + }, + { + "layer": "d9", + "at": "23:50", + "user_meaning": "D9 在 23:50 发生变化", + "from_sign": "双鱼座", + "to_sign": "白羊座" + }, + { + "layer": "d12", + "at": "23:50", + "user_meaning": "D12 在 23:50 发生变化", + "from_sign": "射手座", + "to_sign": "摩羯座" + }, + { + "layer": "d24", + "at": "23:50", + "user_meaning": "D24 在 23:50 发生变化", + "from_sign": "水瓶座", + "to_sign": "白羊座" + }, + { + "layer": "d11", + "at": "23:50", + "user_meaning": "D11 在 23:50 发生变化", + "from_sign": "狮子座", + "to_sign": "处女座" + }, + { + "layer": "pada", + "at": "23:50", + "user_meaning": "Nakshatra pada 在 23:50 发生变化" + }, + { + "layer": "kp1", + "at": "23:50", + "user_meaning": "KP 1宫子主 在 23:50 发生变化", + "from_sign": "金牛座", + "to_sign": "巨蟹座" + }, + { + "layer": "kp4", + "at": "23:50", + "user_meaning": "KP 4宫子主 在 23:50 发生变化", + "from_sign": "天秤座", + "to_sign": "天蝎座" + }, + { + "layer": "kp10", + "at": "23:50", + "user_meaning": "KP 10宫子主 在 23:50 发生变化", + "from_sign": "处女座", + "to_sign": "天蝎座" + } + ], + "d1_lagna_count": 2, + "d1_candidates_differ": true, + "d9_lagna_count": 12, + "d9_candidates_differ": true, + "d10_lagna_count": 12, + "d10_candidates_differ": true, + "d4_lagna_count": 8, + "d4_candidates_differ": true, + "d5_lagna_count": 10, + "d5_candidates_differ": true, + "d7_lagna_count": 12, + "d7_candidates_differ": true, + "d12_lagna_count": 12, + "d12_candidates_differ": true, + "d24_lagna_count": 12, + "d24_candidates_differ": true, + "d2_lagna_count": 2, + "d2_candidates_differ": true, + "d11_lagna_count": 12, + "d11_candidates_differ": true, + "d30_lagna_count": 10, + "d30_candidates_differ": true, + "pada_count": 18, + "pada_candidates_differ": true, + "hora_count": 6, + "hora_candidates_differ": true, + "ghati_count": 6, + "ghati_candidates_differ": true, + "bhava_count": 3, + "bhava_candidates_differ": true, + "pranapada_count": 6, + "pranapada_candidates_differ": true, + "kp1_count": 8, + "kp1_candidates_differ": true, + "kp4_count": 8, + "kp4_candidates_differ": true, + "kp7_count": 9, + "kp7_candidates_differ": true, + "kp10_count": 9, + "kp10_candidates_differ": true, + "d9_sign_names": [ + "白羊座", + "金牛座", + "双子座", + "巨蟹座", + "狮子座", + "处女座", + "天秤座", + "天蝎座", + "射手座", + "摩羯座", + "水瓶座", + "双鱼座" + ], + "d10_sign_names": [ + "白羊座", + "金牛座", + "双子座", + "巨蟹座", + "狮子座", + "处女座", + "天秤座", + "天蝎座", + "射手座", + "摩羯座", + "水瓶座", + "双鱼座" + ] + }, + "event_dasha_ledger": [ + { + "summary": "Fictional education event", + "match": "strong", + "match_label": "强相关", + "tracks": [ + "vimshottari", + "gochara" + ], + "gochara": "activated", + "user_meaning": "Fictional education event:强相关(主限、受控行运;Gochara 激活相关宫)", + "domain": "education", + "year_month": "2008-01" + }, + { + "summary": "Fictional career event", + "match": "strong", + "match_label": "强相关", + "tracks": [ + "vimshottari", + "narayana" + ], + "gochara": "not_seen", + "user_meaning": "Fictional career event:强相关(主限、分盘大运;Gochara 未见对应)", + "domain": "career", + "year_month": "2012-06" + } + ], + "event_dasha_ledger_by_time": {}, + "prospective_windows_by_time": {}, + "column_compare_ms": 0.0, + "event_fit_rate": { + "matched": 2, + "total": 2, + "percent": 100, + "band": "high", + "label": "高度吻合(事件吻合率 ≥80%)", + "unique_minute_claim": false, + "user_meaning": "当前窗 2/2 件已确认事件与 Dasha/Gochara 吻合,高度吻合(事件吻合率 ≥80%)。这是相对拟合,不是已确认唯一出生分钟。" + }, + "dasha_agreement": { + "status": "conflict", + "vimshottari_top": "01:00", + "narayana_top": "00:30", + "user_meaning": "主限更偏向 01:00,分盘大运更偏向 00:30。冲突时不能按更高把握收口。" + }, + "lagna_contrast": { + "intervals": [ + { + "start": "00:00", + "end": "01:20", + "d1_lagna_index": 5, + "lagna": "处女座", + "lords": { + "l1": "水星", + "l4": "木星", + "l7": "木星", + "l10": "水星" + } + }, + { + "start": "01:30", + "end": "03:50", + "d1_lagna_index": 6, + "lagna": "天秤座", + "lords": { + "l1": "金星", + "l4": "土星", + "l7": "火星", + "l10": "月亮" + } + }, + { + "start": "23:00", + "end": "23:50", + "d1_lagna_index": 5, + "lagna": "处女座", + "lords": { + "l1": "水星", + "l4": "木星", + "l7": "木星", + "l10": "水星" + } + } + ], + "user_meaning": "窗口里出现两段本命上升:00:00-01:20 为处女座,01:30-03:50 为天秤座。可并列 D9/D10 类型表作校时方法,不是命运承诺,也不能确认唯一分钟。", + "unique_minute_claim": false + }, + "nakshatra_boundary": { + "near_boundary": false, + "distance_degrees": 2.3481, + "user_meaning": null, + "options": [] + }, + "precision_stage": { + "current": "block_scan" + }, + "oos_blind_prompts": [], + "discriminating_event_probes": [], + "guided_collect_windows": [], + "event_clarification_probes": [], + "evidence_collection_probes": [], + "candidate_contrast_opportunities": [], + "holdout_validation_probes": [], + "dropped_probes": [], + "dropped_asked_probe_keys": 0, + "prospective_probes": [], + "horary_observation": { + "status": "skipped", + "user_meaning": "没有第一次问起这件事的时间,占问观察未执行。", + "unique_minute_claim": false + }, + "unique_minute_claim": false + } + } +} diff --git a/frontend/tests/rectification-agentic-entry.test.ts b/frontend/tests/rectification-agentic-entry.test.ts index 0d4f1234..9d8df44e 100644 --- a/frontend/tests/rectification-agentic-entry.test.ts +++ b/frontend/tests/rectification-agentic-entry.test.ts @@ -735,14 +735,16 @@ test("time-selection cards use server adoption state and stay mutually exclusive assert.doesNotMatch(chat, /send\("message", choiceCardUserMessage/); assert.doesNotMatch(chat, /send\("message", choiceCard\?\.stop_message/); assert.doesNotMatch(chat, /choiceCardUserMessage/); - assert.match(caseProjection, /choice_card: choiceCardFromCaseDossier/); + // 原值: 直接卡片投影;新值: 只读时 null;原因: BUG-984 不能选择旧结果。 + assert.match(caseProjection, /choice_card: identity\.read_only \? null : choiceCardFromCaseDossier/); // 原值: 容器 map 用 `choiceCard && choiceCard.focus_id === question.focus_id` 和 `disabled={!liveQuestion}`。 // 新值: 行组件用 liveChoiceCard / liveQuestion;容器的 live 卡判定仍比对 latestLiveQuestion。 // 原因: BUG-725。 assert.match(messageEntry, /liveChoiceCard\s+&& liveChoiceCard\.focus_id === question\.focus_id/); assert.match(messageEntry, /disabled=\{!liveQuestion\}/); assert.match(chat, /choiceCard\s+&& choiceCard\.focus_id === latestLiveQuestion\.focus_id/); - assert.match(caseProjection, /current_question: projectCurrentQuestion/); + // 原值: 无条件问题;新值: 只读不提供可写问题;原因: BUG-984。 + assert.match(caseProjection, /current_question: identity\.read_only \? null : projectCurrentQuestion/); assert.match(caseProjection, /overlayPublicDecision/); assert.match(caseProjection, /const fields = publicDecisionFields\(decision\)/); // 原值: 断言读 cases/[caseId]/route.ts 的 interview 投影 diff --git a/frontend/tests/rectification-block-scan-20260906.test.ts b/frontend/tests/rectification-block-scan-20260906.test.ts index f3a4f4fc..efdd4705 100644 --- a/frontend/tests/rectification-block-scan-20260906.test.ts +++ b/frontend/tests/rectification-block-scan-20260906.test.ts @@ -85,7 +85,9 @@ const EVIDENCE = [ function blockPayload(extra: Record = {}) { return { evidence_ledger_fingerprint: "f".repeat(64), - algorithm_version: "test", + // Explicit fixture identity matches the test deployment; no unknown identity bypass. + algorithm_version: "rectification-v5", + decision_policy_version: "rectification-candidate-policy-v2", minute_step: 10, blocks: [...BLOCKS], ...extra, diff --git a/frontend/tests/rectification-collect-direction-20260904.test.ts b/frontend/tests/rectification-collect-direction-20260904.test.ts index e8b137e7..79719deb 100644 --- a/frontend/tests/rectification-collect-direction-20260904.test.ts +++ b/frontend/tests/rectification-collect-direction-20260904.test.ts @@ -366,6 +366,8 @@ test("persistNextInterviewAfterChoice after family denial asks the invite, not o }; const accounting = fakeAccounting({ ...receiptHandlers, + // 新增服务端身份重读 fixture;原采集/提示断言不变。 + get_agentic_rectification_case_dossier: () => dossierFixture(), set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { id: FOCUS_ID, diff --git a/frontend/tests/rectification-eight-method.test.ts b/frontend/tests/rectification-eight-method.test.ts index 0de5e8ba..49e4ad7f 100644 --- a/frontend/tests/rectification-eight-method.test.ts +++ b/frontend/tests/rectification-eight-method.test.ts @@ -206,7 +206,8 @@ const ENGINE_SCORE = { success: true, endpoint: "rectification_v5_score", result_id: "e4fbf2e0-85dc-5b42-a5a3-34e5dd4b7e62", - algorithm_version: "rectification-event-contract-v2", + // 原值 event-contract-v2;新值 rectification-v5;原因:此业务替身匹配 test-support 受控部署,事件合同仍独立保留。 + algorithm_version: "rectification-v5", event_contract_version: "rectification-event-contract-v2", decision_policy_version: "rectification-candidate-policy-v2", execution_ledger_version: "rectification-execution-ledger-v2", diff --git a/frontend/tests/rectification-engine-version-cross-midnight.test.ts b/frontend/tests/rectification-engine-version-cross-midnight.test.ts index b7ccbc72..6c55d980 100644 --- a/frontend/tests/rectification-engine-version-cross-midnight.test.ts +++ b/frontend/tests/rectification-engine-version-cross-midnight.test.ts @@ -9,7 +9,13 @@ import { v9EngineVersion, } from "../src/lib/rectification-agentic/v9/engine-client.ts"; import { openRectificationCase } from "../src/lib/rectification-agentic/v9/case-service.ts"; -import { loadV9TurnReceipt } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { loadV9TurnReceipt, parseV9CaseDossier, evidenceLedgerFingerprint, candidateRangeFingerprint } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { scoreAndPersistCurrentEvidence } from "../src/lib/rectification-agentic/v9/score-persist.ts"; +import { acceptV9Candidate, confirmV9BirthTime, advanceV9CaseFromBlockScan } from "../src/lib/rectification-agentic/v9/tool-service.ts"; +import { assertV9ResultWritable, resultIdentityView } from "../src/lib/rectification-agentic/v9/result-identity.ts"; +import { ensureNonTerminalTurnExit, persistNextInterviewIfIdle } from "../src/lib/rectification-agentic/v9/answer-choice.ts"; +import { dossierResponseWithIdentity } from "../src/lib/rectification-agentic/v9/case-dossier-response.ts"; +import { recompareHistoricalResult } from "../src/lib/rectification-surface-state.ts"; import { resolveSkillPackageVersion } from "../src/lib/skill-package-registry.ts"; import { createRectificationV9Tools } from "../src/mastra/rectification-v9-tools.ts"; import { @@ -80,7 +86,7 @@ test("native versions invalidate matching-input old scores while same-version sc assert.equal(previous.algorithmVersion, PREVIOUS); }); -test("policy-only or stale algorithm overrides bypass native versions under the existing cache policy", async (t) => { +test("partial override is completed by native identity; complete override needs no request", async (t) => { isolateIdentityEnv(t); let fetches = 0; t.mock.method(globalThis, "fetch", async () => { @@ -91,22 +97,245 @@ test("policy-only or stale algorithm overrides bypass native versions under the const stored = { ...fingerprints, algorithmVersion: PREVIOUS, policyVersion: golden.versions.decision_policy_version }; process.env.RECTIFICATION_DECISION_POLICY_VERSION = golden.versions.decision_policy_version; const policyOnly = await readV9EngineScoringIdentity(); - assert.equal(policyOnly.algorithmVersion, null); - assert.equal(cachedEngineScoreIsReusable(stored, fingerprints, policyOnly), true); + // 原值 null/true;新值 CURRENT/false;原因:部分覆盖不能绕过实际算法身份。 + assert.equal(policyOnly.algorithmVersion, CURRENT); + assert.equal(cachedEngineScoreIsReusable(stored, fingerprints, policyOnly), false); process.env.RECTIFICATION_ALGORITHM_VERSION = PREVIOUS; const staleOverride = await readV9EngineScoringIdentity(); assert.equal(staleOverride.algorithmVersion, PREVIOUS); assert.equal(cachedEngineScoreIsReusable(stored, fingerprints, staleOverride), true); - assert.equal(fetches, 0); + assert.equal(fetches, 1); // 原值 0;部分覆盖必须补齐,完整覆盖仍不请求。 }); -test("unreachable versions retain the existing unknown-identity cache fallback, not proof of rescore", async (t) => { +test("unreachable versions never validate a cached result as current", async (t) => { isolateIdentityEnv(t); t.mock.method(globalThis, "fetch", async () => { throw new Error("fixture versions unavailable"); }); const live = await readV9EngineScoringIdentity(); assert.deepEqual(live, { algorithmVersion: null, policyVersion: null }); const fingerprints = { evidenceLedgerFingerprint: "e".repeat(64), candidateRangeFingerprint: "c".repeat(64) }; - assert.equal(cachedEngineScoreIsReusable({ ...fingerprints, algorithmVersion: PREVIOUS }, fingerprints, live), true); + // 原值 true;新值 false;原因:BUG-984 策略 b,只读不是当前缓存命中。 + assert.equal(cachedEngineScoreIsReusable({ ...fingerprints, algorithmVersion: PREVIOUS }, fingerprints, live), false); +}); + +for (const storedVersion of [PREVIOUS, CURRENT]) { + test(`native late-night block cache checks actual identity: ${storedVersion}`, async (t) => { + isolateIdentityEnv(t); + const block = JSON.parse(readFileSync(new URL("./fixtures/rectification-engine-version-cross-midnight-block-golden.json", import.meta.url), "utf8")); + const range = { start_time: block.request.start_time, end_time: block.request.end_time }; + const raw = dossierFixture({ stage: "block_scan", candidateRange: range, latestResult: null }); + const fingerprint = evidenceLedgerFingerprint(parseV9CaseDossier(raw)!.evidence); + raw.case.block_scan = { + ...block.response, algorithm_version: storedVersion, + decision_policy_version: block.response.decision_receipt.decision_policy_version, + evidence_ledger_fingerprint: fingerprint, + candidate_range_fingerprint: candidateRangeFingerprint(range, "a".repeat(64)), + }; + const accounting = fakeAccounting({ + get_agentic_rectification_case_dossier: () => raw, + get_agentic_rectification_case_compute: () => ({ ...computeFixture(), candidate_range: range }), + write_agentic_rectification_block_scan: (_fn, args) => { raw.case.block_scan = args.p_block_scan as Record; return {}; }, + }); + const requests: string[] = []; + t.mock.method(globalThis, "fetch", async (url: unknown) => { + requests.push(String(url)); + return Response.json(String(url).endsWith("/versions") ? golden.versions : block.response); + }); + const opened = await dossierResponseWithIdentity(parseV9CaseDossier(raw)!, [], { status: "verified", requiresSkillAdoption: false } as never); + assert.equal(opened.case.result_identity.can_recompare, storedVersion === PREVIOUS); + assert.equal(opened.case.result_identity.read_only, storedVersion === PREVIOUS); + const result = await scoreAndPersistCurrentEvidence({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID }); + assert.equal(result.persisted.algorithmVersion, CURRENT); + assert.equal(result.persisted.cached, storedVersion === CURRENT); + assert.equal(requests.filter((url) => url.endsWith("/block_scan")).length, storedVersion === CURRENT ? 0 : 1); + assert.ok(requests.some((url) => url.endsWith("/versions"))); + }); +} + +for (const storedVersion of [PREVIOUS, CURRENT]) { + test(`native minute cache checks actual identity: ${storedVersion}`, async (t) => { + isolateIdentityEnv(t); + const range = { start_time: golden.request.start_time, end_time: golden.request.end_time }; + const raw = dossierFixture({ candidateRange: range, latestResult: null }); + raw.latest_result = { + ...golden.score, ...golden.score.decision_receipt, + result_id: golden.score.result_id, candidates: golden.score.candidate_decisions, + algorithm_version: storedVersion, decision_receipt: golden.score.decision_receipt, + evidence_ledger_fingerprint: evidenceLedgerFingerprint(parseV9CaseDossier(raw)!.evidence), + candidate_range_fingerprint: candidateRangeFingerprint(range, "a".repeat(64)), + }; + const accounting = fakeAccounting({ + get_agentic_rectification_case_dossier: () => raw, + get_agentic_rectification_case_compute: () => ({ ...computeFixture(), candidate_range: range }), + persist_agentic_rectification_candidate_v2: (_fn, args) => ({ + ...golden.score.decision_receipt, result_id: args.p_engine_result_id, + candidates: args.p_candidates, algorithm_version: args.p_algorithm_version, + decision_policy_version: args.p_decision_policy_version, decision_receipt: args.p_decision_receipt, + }), + }); + const requests: string[] = []; + t.mock.method(globalThis, "fetch", async (url: unknown) => { + requests.push(String(url)); + assert.ok(String(url).endsWith("/versions") || String(url).endsWith("/score")); + return Response.json(String(url).endsWith("/versions") ? golden.versions : golden.score); + }); + const opened = await dossierResponseWithIdentity(parseV9CaseDossier(raw)!, [], { status: "verified", requiresSkillAdoption: false } as never); + assert.equal(opened.case.result_identity.can_recompare, storedVersion === PREVIOUS); + assert.equal(opened.case.result_identity.read_only, storedVersion === PREVIOUS); + const result = await scoreAndPersistCurrentEvidence({ accounting: accounting.client, userId: USER_ID, caseId: CASE_ID, keepAnswers: true }); + assert.equal(result.persisted.algorithmVersion, CURRENT); + assert.equal(result.persisted.cached, storedVersion === CURRENT); + assert.equal(requests.filter((url) => url.endsWith("/score")).length, storedVersion === CURRENT ? 0 : 1); + assert.ok(requests.some((url) => url.endsWith("/versions"))); + }); +} + +test("stale history exposes explicit recompare and real tools replace old provenance", async (t) => { + isolateIdentityEnv(t); + const block = JSON.parse(readFileSync(new URL("./fixtures/rectification-engine-version-cross-midnight-block-golden.json", import.meta.url), "utf8")); + const range = { start_time: block.request.start_time, end_time: block.request.end_time }; + const raw = dossierFixture({ stage: "block_scan", candidateRange: range, latestResult: null, blockScan: { ...block.response, algorithm_version: PREVIOUS } }); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => raw, + get_agentic_rectification_case_compute: () => ({ ...computeFixture(), candidate_range: range }), + write_agentic_rectification_block_scan: (_fn, args) => { raw.case.block_scan = args.p_block_scan as Record; return {}; }, + set_agentic_rectification_conversation_focus: (_fn, args) => ({ focus: { id: FOCUS_ID, case_id: CASE_ID, question_id: args.p_question_id, intent: args.p_intent, target_domain: args.p_target_domain, expected_answer_schema: args.p_expected_answer_schema, status: "active" }, idempotent: false }), + }); + let scans = 0; + t.mock.method(globalThis, "fetch", async (url: unknown) => { + if (String(url).endsWith("/versions")) return Response.json(golden.versions); + assert.ok(String(url).endsWith("/block_scan")); + scans++; + return Response.json(block.response); + }); + const tools = createRectificationV9Tools({ accounting: accounting.client as never, userId: USER_ID, caseId: CASE_ID, turnId: TURN_ID }); + const read = await (tools["rectification-read-case"] as unknown as { execute(input: unknown): Promise> }).execute({ caseId: CASE_ID }); + assert.equal(read.can_recompare, true); + assert.equal((read.next_action as Record).type, "compare_candidates"); + assert.equal(scans, 0, "opening history is not forced recomputation"); + await (tools["rectification-compare-candidates"] as unknown as { execute(input: unknown): Promise }).execute({ caseId: CASE_ID }); + assert.equal(scans, 1); + const refreshed = await dossierResponseWithIdentity(parseV9CaseDossier(raw)!, [], { status: "verified", requiresSkillAdoption: false } as never); + assert.equal(refreshed.case.result_identity.read_only, false); + assert.equal(refreshed.case.result_identity.algorithm_version, CURRENT); + assert.equal(refreshed.case.result_identity.policy_version, golden.versions.decision_policy_version); + const component = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8"); + assert.match(component, /resultNotice && canRecompare && !historyReadonly/); + assert.match(component, /recompareHistoricalResult\(\{ canRecompare, historyReadonly, busy, send \}\)/); + assert.match(component, /if \(\(action === "message" && !trimmed\) \|\| conversationReadonly\) return/); + const sent: string[] = []; + const send = async (action: "message", text: string) => { sent.push(`${action}:${text}`); }; + await recompareHistoricalResult({ canRecompare: true, historyReadonly: false, busy: false, send }); + assert.deepEqual(sent, ["message:请按当前算法重新比较候选。"]); + for (const state of [{ canRecompare: false, historyReadonly: false, busy: false }, { canRecompare: true, historyReadonly: true, busy: false }, { canRecompare: true, historyReadonly: false, busy: true }]) { + await recompareHistoricalResult({ ...state, send }); + } + assert.equal(sent.length, 1, "unknown identity, terminal history and busy clicks cannot dispatch"); +}); + +test("partial conflicts and incomplete native pairs never authorize reuse", async (t) => { + isolateIdentityEnv(t); + process.env.RECTIFICATION_ALGORITHM_VERSION = PREVIOUS; + t.mock.method(globalThis, "fetch", async () => Response.json(golden.versions)); + assert.deepEqual(await readV9EngineScoringIdentity(), { algorithmVersion: null, policyVersion: null }); + delete process.env.RECTIFICATION_ALGORITHM_VERSION; + t.mock.method(globalThis, "fetch", async () => Response.json({ algorithm_version: CURRENT })); + const incomplete = await readV9EngineScoringIdentity(); + assert.equal(incomplete.policyVersion, null); + assert.equal(cachedEngineScoreIsReusable({ algorithmVersion: CURRENT, evidenceLedgerFingerprint: "e", candidateRangeFingerprint: "r" }, { evidenceLedgerFingerprint: "e", candidateRangeFingerprint: "r" }, incomplete), false); +}); + +for (const stage of ["minute", "block_scan"] as const) { + for (const failure of ["http", "timeout", "partial"] as const) { + test(`${stage} ${failure}: historical result stays read-only without recompute; server mutations reject`, async (t) => { + isolateIdentityEnv(t); + if (failure === "partial") process.env.RECTIFICATION_DECISION_POLICY_VERSION = golden.versions.decision_policy_version; + const block = JSON.parse(readFileSync(new URL("./fixtures/rectification-engine-version-cross-midnight-block-golden.json", import.meta.url), "utf8")); + const range = { start_time: golden.request.start_time, end_time: golden.request.end_time }; + const raw = dossierFixture({ stage, candidateRange: range, latestResult: { + ...golden.score, ...golden.score.decision_receipt, + result_id: golden.score.result_id, candidates: golden.score.candidate_decisions, + algorithm_version: PREVIOUS, decision_receipt: golden.score.decision_receipt, + }, blockScan: { ...block.response, algorithm_version: PREVIOUS } }); + const accounting = fakeAccounting({ + ...receiptHandlers, + get_agentic_rectification_case_dossier: () => raw, + get_agentic_rectification_case_compute: () => ({ ...computeFixture(), candidate_range: range }), + }); + const requests: string[] = []; + t.mock.method(globalThis, "fetch", async (url: unknown) => { + requests.push(String(url)); + assert.ok(String(url).endsWith("/versions"), "no scoring/oracle call on identity failure"); + if (failure === "timeout") throw new DOMException("fixture timeout", "TimeoutError"); + return Response.json({ error: "fixture_unavailable" }, { status: 503 }); + }); + const input = { accounting: accounting.client, userId: USER_ID, caseId: CASE_ID }; + const result = await scoreAndPersistCurrentEvidence(input); + assert.equal(result.readOnly, true); + assert.equal(result.persisted.cached, false); + assert.equal(result.persisted.algorithmVersion, PREVIOUS); + assert.equal(result.persisted.selectionAllowed, false); + assert.equal(result.persisted.confirmationAllowed, false); + const response = await dossierResponseWithIdentity(parseV9CaseDossier(raw)!, [], { + status: "verified", requiresSkillAdoption: false, + } as never); + assert.match(response.case.result_notice ?? "", /按旧算法产出/); + assert.equal(response.case.result_identity.algorithm_version, PREVIOUS); + assert.equal(response.case.result_identity.can_recompare, false); + assert.equal(response.choice_card, null); + const candidateId = golden.score.candidate_decisions[0].candidate_id; + await assert.rejects(acceptV9Candidate(accounting.client, USER_ID, CASE_ID, golden.score.result_id, candidateId, TURN_ID), /result_identity_read_only/); + await assert.rejects(confirmV9BirthTime(accounting.client, USER_ID, CASE_ID, { + resultId: golden.score.result_id, candidateId, requestId: TURN_ID, consentQuote: "fixture", sourceTurnId: TURN_ID, + }), /result_identity_read_only/); + await assert.rejects(advanceV9CaseFromBlockScan(accounting.client, USER_ID, CASE_ID, range), /result_identity_read_only/); + const tools = createRectificationV9Tools({ ...input, turnId: TURN_ID, accounting: accounting.client as never }); + const projection = await (tools["rectification-compare-candidates"] as unknown as { execute(input: unknown): Promise<{ read_only: boolean }> }).execute({ caseId: CASE_ID }); + assert.equal(projection.read_only, true); + const completed = accounting.calls.find((call) => call.fn === "insert_agentic_rectification_tool_receipt" && call.args.p_status === "completed"); + assert.equal(completed?.args.p_engine_version, PREVIOUS); + const readProjection = await (tools["rectification-read-case"] as unknown as { execute(input: unknown): Promise> }).execute({ caseId: CASE_ID }); + assert.equal(readProjection.read_only, true); + assert.equal((readProjection.candidate_summary as Record).selection_allowed, false); + const diagnostics = await (tools["rectification-read-diagnostics"] as unknown as { execute(input: unknown): Promise> }).execute({ caseId: CASE_ID }); + assert.equal(diagnostics.read_only, true); + assert.match(String(diagnostics.notice), /按旧算法产出/); + assert.equal(diagnostics.algorithm_version, PREVIOUS); + assert.equal(diagnostics.can_confirm_exact_minute, false); + assert.equal(diagnostics.diagnostics, null); + assert.deepEqual(diagnostics.executed_methods, []); + const diagnosticsReceipt = accounting.calls.find((call) => call.fn === "insert_agentic_rectification_tool_receipt" && call.args.p_tool_name === "rectification-read-diagnostics" && call.args.p_status === "completed"); + assert.equal(diagnosticsReceipt?.args.p_engine_version, PREVIOUS); + await assert.rejects((tools["rectification-offer-candidates"] as unknown as { execute(input: unknown): Promise }).execute({ caseId: CASE_ID }), /result_identity_read_only/); + assert.equal((await ensureNonTerminalTurnExit(input)).persisted, false); + assert.equal((await persistNextInterviewIfIdle(input)).persisted, false); + assert.ok(accounting.calls.every((call) => call.fn.startsWith("get_") || call.fn === "insert_agentic_rectification_tool_receipt"), "no candidate, focus, inference or validation writes"); + assert.ok(requests.length > 0); + }); + } +} + +test("missing stage source, absent results and nonlatest result IDs cannot authorize candidate writes", async (t) => { + isolateIdentityEnv(t); + process.env.RECTIFICATION_ALGORITHM_VERSION = CURRENT; + process.env.RECTIFICATION_DECISION_POLICY_VERSION = golden.versions.decision_policy_version; + const latest = { ...golden.score, result_id: golden.score.result_id, candidates: golden.score.candidate_decisions }; + const block = JSON.parse(readFileSync(new URL("./fixtures/rectification-engine-version-cross-midnight-block-golden.json", import.meta.url), "utf8")).response; + for (const raw of [ + dossierFixture({ stage: "block_scan", latestResult: latest, blockScan: null }), + dossierFixture({ stage: "minute", latestResult: null, blockScan: block }), + dossierFixture({ stage: "minute", latestResult: null, blockScan: null }), + dossierFixture({ stage: "minute", latestResult: latest }), + ]) { + const accounting = fakeAccounting({ get_agentic_rectification_case_dossier: () => raw }); + await assert.rejects(assertV9ResultWritable(accounting.client, USER_ID, CASE_ID, TURN_ID), /result_identity_read_only/); + assert.ok(accounting.calls.every((call) => call.fn.startsWith("get_"))); + } + const empty = dossierFixture({ latestResult: null, blockScan: null }); + const accounting = fakeAccounting({ get_agentic_rectification_case_dossier: () => empty }); + await assertV9ResultWritable(accounting.client, USER_ID, CASE_ID); // ordinary new-case collection remains allowed + await assert.rejects(assertV9ResultWritable(accounting.client, USER_ID, CASE_ID, null), /result_identity_read_only/); + assert.equal(resultIdentityView(parseV9CaseDossier(empty)!, { algorithmVersion: null, policyVersion: null }).read_only, false); }); async function runGoldenToolSequence(t: test.TestContext, options: { @@ -164,7 +393,18 @@ async function runGoldenToolSequence(t: test.TestContext, options: { for (const name of ["rectification-compare-candidates", "rectification-read-diagnostics"] as const) { const execute = () => (tools[name] as unknown as { execute(input: unknown): Promise }).execute({ caseId: CASE_ID }); if (options.diagnosticsFailure && name === "rectification-read-diagnostics") await assert.rejects(execute); - else await execute(); + else { + const projection = await execute() as { read_only?: boolean; algorithm_version?: string; can_confirm_exact_minute?: boolean }; + if (name === "rectification-compare-candidates" && options.compareVersion && options.compareVersion !== CURRENT) { + assert.equal(projection.read_only, true, "rolling response mismatch stays read-only after saving true provenance"); + assert.equal(projection.algorithm_version, options.compareVersion); + } + if (name === "rectification-read-diagnostics" && options.diagnosticsVersion && options.diagnosticsVersion !== CURRENT) { + assert.equal(projection.read_only, true); + assert.equal(projection.algorithm_version, options.diagnosticsVersion); + assert.equal(projection.can_confirm_exact_minute, false, "rolling diagnostics cannot claim current validation"); + } + } } assert.ok(calls.includes("/api/rectification/v5/score")); const persisted = accounting.calls.find((call) => call.fn === "persist_agentic_rectification_candidate_v2"); @@ -187,7 +427,7 @@ test("failed diagnostics do not claim an engine result identity", async (t) => { ]); }); -test("BUG-984 blocker: real tool sequence permits mixed completed identities in one turn", async (t) => { +test("mixed completed identities require chronological aggregation (covered by database regression)", async (t) => { // Identity-only mutation models rolling backend versions; all response shape and values // remain the real native golden. This is not a claim to have run future algorithms. const version9 = "rectification-v5-matrix-scoring-9"; @@ -196,8 +436,8 @@ test("BUG-984 blocker: real tool sequence permits mixed completed identities in const completed = receipts.filter((call) => call.args.p_status === "completed"); assert.equal(new Set(completed.map((call) => call.args.p_turn_id)).size, 1); assert.deepEqual(completed.map((call) => call.args.p_engine_version), [version9, version10]); - // Preserve the blocker as positive evidence, not a false passing acceptance test. - // F2 acceptance must replace this diagnostic once the approved aggregate fix lands. + // This proves why string max is invalid; database-rectification-block-scan.test.ts + // executes the authorized aggregate migration and verifies chronological selection. assert.equal([version9, version10].sort().at(-1), version9); assert.notEqual([version9, version10].sort().at(-1), completed.at(-1)?.args.p_engine_version); }); diff --git a/frontend/tests/rectification-occupation-dated-answer-20260911.test.ts b/frontend/tests/rectification-occupation-dated-answer-20260911.test.ts index c31570f3..0de0f5d7 100644 --- a/frontend/tests/rectification-occupation-dated-answer-20260911.test.ts +++ b/frontend/tests/rectification-occupation-dated-answer-20260911.test.ts @@ -41,7 +41,8 @@ const ENGINE_SCORE = { success: true, endpoint: "rectification_v5_score", result_id: "e4fbf2e0-85dc-5b42-a5a3-34e5dd4b7e62", - algorithm_version: "rectification-event-contract-v2", + // 原值 event-contract-v2;新值 rectification-v5;原因:此业务替身匹配 test-support 受控部署,事件合同不变。 + algorithm_version: "rectification-v5", event_contract_version: "rectification-event-contract-v2", decision_policy_version: "rectification-candidate-policy-v2", execution_ledger_version: "rectification-execution-ledger-v2", diff --git a/frontend/tests/rectification-question-in-message.test.ts b/frontend/tests/rectification-question-in-message.test.ts index d72e7851..5fc56128 100644 --- a/frontend/tests/rectification-question-in-message.test.ts +++ b/frontend/tests/rectification-question-in-message.test.ts @@ -74,7 +74,8 @@ test("question-on-turn migration adds asked_turn_id and answer_option", () => { test("GET turns attach question by asked_turn_id and never by timestamps", () => { assert.match(caseProjection, /attachQuestionsToTurns\(dossier\.turns, listed\.focuses/); - assert.match(caseProjection, /question: turn\.question/); + // 原值 turn.question;新值只读 null;原因 BUG-984,仍以 asked_turn_id 绑定历史。 + assert.match(caseProjection, /question: identity\.read_only \? null : turn\.question/); assert.match(caseProjection, /question_source: questionSourceFromFocusList\(listed\)/); const attach = readFileSync(new URL("../src/lib/rectification-agentic/v9/turn-question.ts", import.meta.url), "utf8"); // 原值: if (!focus.askedTurnId) continue diff --git a/frontend/tests/rectification-question-ownership.test.ts b/frontend/tests/rectification-question-ownership.test.ts index c1c39ae9..5e03d990 100644 --- a/frontend/tests/rectification-question-ownership.test.ts +++ b/frontend/tests/rectification-question-ownership.test.ts @@ -516,7 +516,8 @@ test("list focuses RPC error marks GET questions unavailable and warns", async ( // 新值: 投影在 case-dossier-response.ts,GET 仍 return NextResponse.json(dossierResponse) // 原因: Next App Router 不允许 route.ts 导出非 HTTP handler。 assert.match(caseDossierResponse, /question_source: questionSourceFromFocusList\(listed\)/); - assert.match(casesRoute, /return NextResponse\.json\(dossierResponse/); + // 原值同步 dossierResponse;新值 await 身份投影;原因 BUG-984 历史刷新同样核验。 + assert.match(casesRoute, /return NextResponse\.json\(await dossierResponseWithIdentity/); }); test("walkthrough-shaped chain rebuilds each assistant question from asked_turn_id", () => { diff --git a/frontend/tests/rectification-spoken-collect.test.ts b/frontend/tests/rectification-spoken-collect.test.ts index 4ee65619..2eb15303 100644 --- a/frontend/tests/rectification-spoken-collect.test.ts +++ b/frontend/tests/rectification-spoken-collect.test.ts @@ -217,7 +217,8 @@ test("missing current_question is explicit only for resumable cases", () => { assert.match(chat, /const resumableCase = caseStatus !== null && isResumableStatus\(caseStatus\)/); assert.doesNotMatch(chat, /等待服务端更新|题目加载失败,请刷新/); assert.match(chat, /RECTIFICATION_QUESTION_UNAVAILABLE_COPY/); - assert.match(chat, /readonly && \(/); + // 原值 readonly;新值 historyReadonly;原因:结果身份只读不等于 Case 已结束。 + assert.match(chat, /historyReadonly && \(/); assert.doesNotMatch(chat, /questionGap[\s\S]*caseStatus.*TERMINAL/); }); diff --git a/frontend/tests/rectification-v9-agent.test.ts b/frontend/tests/rectification-v9-agent.test.ts index 3686da27..d193f259 100644 --- a/frontend/tests/rectification-v9-agent.test.ts +++ b/frontend/tests/rectification-v9-agent.test.ts @@ -638,7 +638,8 @@ test("accept-candidate requires a server-persisted result; no tool means no minu resultId: RESULT_ID, candidateId: CANDIDATE_ID, }), - (error: unknown) => error instanceof Error && error.message.includes("candidate_not_found"), + // 原值 candidate_not_found;新值 result_identity_read_only;原因:无来源在写 RPC 前即拒绝,仍不得产生候选。 + (error: unknown) => error instanceof Error && error.message.includes("result_identity_read_only"), ); }); diff --git a/frontend/tests/rectification-v9-status-security.test.ts b/frontend/tests/rectification-v9-status-security.test.ts index 8f0b6b0d..3ca85de0 100644 --- a/frontend/tests/rectification-v9-status-security.test.ts +++ b/frontend/tests/rectification-v9-status-security.test.ts @@ -39,6 +39,7 @@ import { test("accepted is never upgraded to confirmed by the accept path", async () => { const accounting = fakeAccounting({ + get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }), accept_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", @@ -56,6 +57,7 @@ test("accepted is never upgraded to confirmed by the accept path", async () => { test("accept fails closed if the accept-only RPC reports confirmed", async () => { const accounting = fakeAccounting({ + get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }), accept_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", @@ -76,6 +78,7 @@ test("accept fails closed if the accept-only RPC reports confirmed", async () => test("confirmed requires the engine gate plus explicit grounded consent", async () => { // confirmation_allowed=false on the stored result blocks confirmation. const blocked = fakeAccounting({ + get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }), confirm_agentic_rectification_candidate_for_case_v2: () => { throw new Error("agentic_rectification_confirmation_blocked"); }, @@ -93,6 +96,7 @@ test("confirmed requires the engine gate plus explicit grounded consent", async // Confirming a time that is not the representative minute is rejected. const mismatch = fakeAccounting({ + get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }), confirm_agentic_rectification_candidate_for_case_v2: () => { throw new Error("agentic_rectification_confirm_time_mismatch"); }, @@ -110,6 +114,7 @@ test("confirmed requires the engine gate plus explicit grounded consent", async // Consent quote must be grounded in the source turn's message. const ungrounded = fakeAccounting({ + get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }), confirm_agentic_rectification_candidate_for_case_v2: () => { throw new Error("agentic_rectification_consent_not_grounded"); }, @@ -178,6 +183,7 @@ test("confirm-birth-time binds consent to the server-owned current turn", async test("candidate ownership is case-scoped: the RPC always receives the case id", async () => { const accounting = fakeAccounting({ + get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }), accept_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", @@ -200,6 +206,7 @@ test("candidate ownership is case-scoped: the RPC always receives the case id", test("accept is idempotent: replaying the same selection succeeds without a second write", async () => { const accounting = fakeAccounting({ + get_agentic_rectification_case_dossier: () => dossierFixture({ latestResult: candidateSnapshotFixture() }), accept_agentic_rectification_candidate_for_case_v2: () => ({ success: true, saved_time: "05:02", @@ -742,7 +749,15 @@ test("compare-candidates reuses a matching fingerprint without calling the engin } }); -test("compare-candidates rescores when stored policy lags the live engine", async () => { +test("compare-candidates rescores when stored policy lags the live engine", async (t) => { + const algorithm = process.env.RECTIFICATION_ALGORITHM_VERSION; + const policy = process.env.RECTIFICATION_DECISION_POLICY_VERSION; + delete process.env.RECTIFICATION_ALGORITHM_VERSION; + delete process.env.RECTIFICATION_DECISION_POLICY_VERSION; + t.after(() => { + process.env.RECTIFICATION_ALGORITHM_VERSION = algorithm; + process.env.RECTIFICATION_DECISION_POLICY_VERSION = policy; + }); const rawDossier = dossierFixture(); const parsed = parseV9CaseDossier(rawDossier); const compute = parseV9ComputeProjection(computeFixture()); diff --git a/frontend/tests/rectification-v9-test-support.ts b/frontend/tests/rectification-v9-test-support.ts index fcd7659b..29761653 100644 --- a/frontend/tests/rectification-v9-test-support.ts +++ b/frontend/tests/rectification-v9-test-support.ts @@ -1,6 +1,11 @@ import type { RectificationRpcClient } from "../src/lib/rectification-agentic/v9/tool-service.ts"; import { withRectificationRequestCache } from "../src/lib/rectification-agentic/v9/request-cache.ts"; +// Unit fixtures below describe this explicit engine/policy pair. Production now +// requires both; identity failure tests clear these and exercise real discovery. +process.env.RECTIFICATION_ALGORITHM_VERSION = "rectification-v5"; +process.env.RECTIFICATION_DECISION_POLICY_VERSION = "rectification-candidate-policy-v2"; + export const CASE_DOSSIER_RPC = "get_agentic_rectification_case_dossier"; export const CASE_COMPUTE_RPC = "get_agentic_rectification_case_compute";