Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a799582d8 |
+1
-15
@@ -1,23 +1,9 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-12 — 设置里的岁差说明改对:新开的生时校正也跟着设置走(已合入 staging)
|
||||
|
||||
- 设置页换岁差时的说明原来写「生时校正目前仍固定按 Raman」,与实际相反。改为:之后的咨询、报告、星盘库、每日星语和新开的生时校正会按新岁差算;已经开始的那次校正仍按开始时的岁差。Skill 版本不变(BUG-1310)。
|
||||
|
||||
## 2026-10-12 — 校正还没打开时,卡片不说已经开始(已合入 staging)
|
||||
|
||||
- 明确要求做校正时,对话里的卡片在真正打开并绑定之前仍是普通提议,按钮是「开始核对出生时间」。打开失败可以再点,不会写成「已经开始」。Skill 版本不变。
|
||||
|
||||
## 2026-10-12 — 对话里明说要校正就直接进入(已部署 staging)
|
||||
## 2026-10-12 — 对话里明说要校正就直接进入(待验收)
|
||||
|
||||
- 用户这一轮明确要求做生时校正时,直接打开校正,不先写解读、不用点卡。推得不准时的提议仍走卡片。做不了会用一句话说明,不再沉默。普通对话不承诺收窄出生时间,申报时段不写大运、子运的起止年月日。
|
||||
|
||||
## 2026-10-12 — 生时校正开场说清填的时间离换段有多近(已合入 staging)
|
||||
|
||||
- 开场两句中间多一句:填的时间离最近会换的地方差几分钟,两边不一样的是父母、工作、住处、婚恋、学业、子女、钱或身体里实际不同的那几件。不到 1 分钟就说不到 1 分钟。这段时间里都不换,就说附近这些盘都不换,先从经历开始。只知道大概时段时不加这一句。
|
||||
- 本命上升也换时,这一句先说「这里连本命上升都换」,再写两边不一样的事。没有别的事时整句停在这里。只有本命上升在换,不说接下来先问。仍是一句。
|
||||
- 开场扫描和盘型段扫描按资料里的岁差算。没填岁差仍是 Raman。不说秒,不说记录时间错了或不准,不说已确认。先问这几件事的开关默认关,所以这句先不承诺接下来先问。打分、闸门和题库都没改。Skill 仍是 10.0.33。设置里「生时校正目前仍固定按 Raman」那句随同批 BUG-1310 改正。
|
||||
|
||||
## 2026-10-09 — 事业判断表旁边注明不按星性写方向(已合入 staging;回测仍有 1 次,记为已知残余)
|
||||
|
||||
- 事业判断表里,自然代表星、Jaimini 代表星、大运主星和宫主这几行旁边加了一句:只看力量与受冲,不按这颗星的本性写职业类型、做事方式或方向。其他领域不加。行和取值没有改。系统提示没有改。
|
||||
|
||||
+8
-43
@@ -17538,7 +17538,7 @@
|
||||
|
||||
## BUG-1306 | 对话里明说「帮我做生时纠正」进不了校正:模型不出提议卡,反而在普通对话里自己「收窄出生时间」
|
||||
|
||||
- 状态:fixed-pending-verify(直达已部署 staging;F3 跟进随 10-12 批次合入 staging;T0 无主机日志、T5 无模型凭据,真机清单 `docs/testing/rectification-explicit-entry-20261012.md` 未走前不标 resolved)
|
||||
- 状态:fixed-pending-verify(实现在 `codex/rectification-explicit-entry-20261012`,未 push;T0 无主机日志,T5 无模型凭据)
|
||||
- 首次发现 / 最近更新:2026-10-11 / 2026-10-12
|
||||
- 来源:产品在 staging(`de4cfeb8`)新对话第一句输入「请你帮我进行生时纠正」,回复截图转述。
|
||||
- 现象:回复显示「已完成 3 步」,正文说「收窄出生时间这件事能做」,要用户报有年月的经历,「对得上,区间就能往里收」;全文没有提到提议卡,界面上也没有卡片。正文还按「代表分钟」写出大运 / 子运起止年月。
|
||||
@@ -17551,67 +17551,32 @@
|
||||
- 推断(未证实):正文主动承诺能收窄,不像收到「不要提校正」的拒绝,更像模型没有调用提议工具;无模型凭据、无 staging 运行日志,暂未取证。另:用户若照做在普通对话里报经历,这些经历不会进入校正引擎计分,正文「区间就能往里收」是做不到的承诺;按代表分钟写大运起止,与申报时段指令「精确应期一律不可用」不符。
|
||||
- 待做:取该轮观测记录确认是否调用了工具及服务端结果(任务书 T0;观测只打到 web 容器控制台,需 staging 主机日志)。本轮没有主机和数据库,查法见进度记录,不阻塞修复。
|
||||
- 修复:不新建工具。`offer-birth-time-rectification` 增加 `explicitRequest`。这一轮明确要校正由模型填写;`userAsked` 仍是「提议过之后用户自己再要求才出卡」。服务端判定仍是 `decideRectificationOffer`(本人、开关、无进行中 case、有价)。通过则记下与提议卡同结构、状态直接为 `opened`、尚无 caseId 的记录,`run.completed` 带 `rectificationDirectOpen`,浏览器调用已有的 `openRectificationFromChat`。同一轮已有直达或已有提议时不再开第二次。`bindOfferToCase` 也会绑定这种还没有 caseId 的 `opened` 记录,刷新后按 caseId 找回。进行中的 case 不新开:查到 `agentic_rectification_cases.session_id` 就打开那次,查不到就说可以在历史对话里接着做。`product_disabled` 与 `pricing_unavailable` 对用户是同一句「出生时间核对这会儿还做不了。」家人仍是「给家人校正暂时还不支持」。四种拒绝说明都不再写「不要提校正」。没有明确要求时的拒绝仍走原来的排盘回答。直达和明确拒绝把首轮合同视为就绪,不跑「运行合同不完整」重试、不跑空答重试、不跑 Pass4,已流出的正文换成那一句。这一轮结算走 `complete_consultation_free`,不走 `complete_consultation_response`;新开时再把提议补到会话消息上,不改请求行。规则仍只定义在 `RECTIFICATION_OFFER_RULE`:不承诺收窄出生时间,不在对话里收集经历做校正。申报时段指令补「大运、子运只说在哪一段附近,不写起止年月日」。不改校正引擎、打分、闸门和数据库。
|
||||
- 跟进(F3,2026-10-12):结算不再把记录写成 `opened`。直达通过仍记同结构提议,状态保持 `offered`,只在要自动开案时带 `autoOpen: true`(省略该字段的旧记录照常解析)。卡片在开案并绑定来源之前是普通提议:价格句和「开始核对出生时间」,不说「已经开始」。浏览器仍按 `rectificationDirectOpen` 自动开案,成功路径不用点。只有 `bindOfferToCase` 在开案成功、来源绑定后把状态改成 `opened`。打开失败不把本地记录改成 `opened`,卡片可再点。不新增第四种状态。`complete_consultation_free` 与五句用户文案未改。
|
||||
- 验证:`npx tsc --noEmit` 0 错。`npm run lint` 0 error(125 条既有 warning)。定向 `rectification-explicit-entry-20261012` 与 `rectification-in-chat-20261007` 26 通过。合同套件 236 条里 235 通过;唯一失败是 Skill 符号链接 EPERM。`CI=1 npm test` 4922 条,通过 4780,失败 101,cancelled 0;失败在本轮没改的文件(无 Docker、符号链接、WSL、路径拼接、junction 下的依赖解析)。telemetry 合同按直达结算改了一条断言,改完 4 条通过。默认 `next build` 因 worktree `node_modules` junction 被 Turbopack 拒绝;`npm run build -- --webpack` 编译与 TypeScript 已过,收集 page data 时 Skill 符号链接 EPERM,没有路由表,`/` Static 与首屏 gzip 未测。T5 未跑模型。
|
||||
- 防复发:`frontend/tests/rectification-explicit-entry-20261012.test.ts` 锁住直达、同一轮不重复开案、无 caseId 的 opened 绑定与找回、四种拒绝说明、两条路径不重试、普通咨询仍重试一次、规则只定义一处、申报时段「在哪一段附近」、结算调用 `complete_consultation_free`。F3 另锁:结算记录是 `offered` 且 `autoOpen: true`、卡片在绑定前不说「已经开始」、绑定后才说已经开始、打开失败仍是 `offered`、旧的 `offered` / `opened` / `returned` 照常解析且 `returned` 不出卡。用户可见句长度 ≤20。真机清单 `docs/testing/rectification-explicit-entry-20261012.md`。
|
||||
- 防复发:`frontend/tests/rectification-explicit-entry-20261012.test.ts` 锁住直达、同一轮不重复开案、无 caseId 的 opened 绑定与找回、四种拒绝说明、两条路径不重试、普通咨询仍重试一次、规则只定义一处、申报时段「在哪一段附近」、结算调用 `complete_consultation_free`。用户可见句长度 ≤20。真机清单 `docs/testing/rectification-explicit-entry-20261012.md`。
|
||||
- 相关记录:BUG-1247、BUG-1248(入口改为提议卡)、BUG-976/977(禁正则判意图)。
|
||||
- 复发自:无。
|
||||
- 修复版本:staging `b8bdc003`(实现 `6a799582` 变基到 `9f1e8257`,代码逐字节相同;2026-10-12 Claude 验收通过、产品要求合入;部署核对:health gitCommit=b8bdc003、`requiredMigrationsPresent` true,`/login` 200,未登录 `/api/account` 401)。跟进项 F3(打开失败卡片仍说已开始)见 `TASK-rectification-1012-batch-fix-20261012`。 F3 跟进(`51a77721`)已部署 staging `586561eb`(2026-10-12 核对:health gitCommit / apiGitCommit=586561eb、`requiredMigrationsPresent` true,`/login` 200,未登录 `/api/account` 401;合并后 npm test 5025 条、失败 24 逐名同 `798e15ef` 基线)。
|
||||
- 修复版本:本分支提交,未 push,未部署。
|
||||
- 决策日志(2026-10-12,产品):选 A——用户明确要求做生时校正时直接进入校正,不先写解读、不用点卡;意图由模型判断,禁正则 / 关键词。**推翻 BUG-1248 两处**:①明确要求的这一轮不再「只出卡、点卡才进」;②`product_disabled`、`pricing_unavailable`「正文不要提校正」改为必须一句话说明做不了,不得静默。普通对话模型不得承诺收窄出生时间、不得在对话里收集经历假装校正;申报时段下不得写大运 / 子运精确起止。服务端本人 / 开关 / 进行中 case / 价格判定不变。同日另立 BUG-1307(最近分界开场)、BUG-1308(父母静态题研究)。
|
||||
|
||||
## BUG-1307 | 生时校正开场不说记录时间离最近分界多近,首轮选题面向整个窗口,用户觉得步骤多、目的不明
|
||||
|
||||
- 状态:fixed-pending-verify(开场呈现加上 F1 岁差与 F2 本命上升句。选题开关默认关:v5 77 例持久回放没有跑,±10 / ±30 真值段保留和 D9、D10 头段命中都没有新数字,红线未核。部署和真机清单完成前不标 resolved)
|
||||
- 状态:open(任务书 `docs/tasks/TASK-rectification-nearest-boundary-opening-20261012.md` 待领取)
|
||||
- 首次发现 / 最近更新:2026-10-12 / 2026-10-12
|
||||
- 来源:产品拿上游 Skill 的一次私人样例校正比较,认为上游步骤短。上游先找记录时间最近的分盘换段点,只问两侧不同的层。
|
||||
- 现象:我方开场不讲记录时间与分界的关系;`window_scan` 有逐分钟 11 层换段数据,但不以记录时间为锚;记录时间只在交付时与区间比较(`reportedTimeOffsetCopy`)。
|
||||
- 根因:扫描数据从未以记录时间为锚组织,呈现与选题都面向整个窗口。
|
||||
- 决策:只改呈现与首轮选题顺序,不改打分、闸门、4 件 3 域;对外不说秒级、不说「记录时间是错的」。
|
||||
- 修复:新模块只读 `window_scan` 的换段行,给出记录分钟前后最近的换段、两侧不同的层和前后星座。开场由服务端把这一句放在原来两句中间。申报时段、扫描失败不加这一句。窗口里没有换段,用固定的一句「附近这些盘都不换」。选题开关 `RECTIFICATION_NEAREST_BOUNDARY_PROBES=on` 才把对应领域的题排到增益序前面;默认关,开场因此不说「接下来先问这几件」。2 / 5 / 10 分钟都没有选定。Skill 仍是 10.0.33,没有改 OpeningPolicy 正文。
|
||||
- 修复(F1 / F2,2026-10-12,状态仍是 fixed-pending-verify,未部署):F1 段扫描端点收可选 `ayanamsa`,白名单与 `resolveAyanamsa` 的输出一致(raman、lahiri、kp、true_citra、true_pushya、fagan_bradley)。缺省或空白仍是 raman;别名归一后写入 profile、逐分钟排盘和返回体,并因此进缓存键。`djwhal_khul`、`sassanian` 及其他名字抛 `invalid_ayanamsa`,不退回 raman。版本字符串未改。`node_mode` 仍固定 mean,请求体不收这个字段。`fetchOpeningBoundary` 与 `scanCaseSegments` 传 `resolveAyanamsa(snapshot)`。F2 最近换段含 `d1` 时,开场同一句先写「这里连本命上升都换」,再写其余人生事;没有其余事就停在这一句。只有本命上升在换时不加「接下来先问这几件」。`topicsForLayers` 仍不给 D1 造主题词。设置提示 `AYANAMSA_SWITCH_HINT` 仍写生时校正固定 Raman,本轮未改那句。
|
||||
- 验证:公开 AA 盘定向测试覆盖段内、紧贴换段、窗口内无换段;分盘接口默认响应不增加这个字段。前端开场单测覆盖这三句和开关默认关。F1 另证:改前省略岁差的公开盘响应已逐字段对照,显式 raman 与省略相同,lahiri 换缓存键并换星座。77 例回放未跑。真机清单 `docs/testing/rectification-nearest-boundary-opening-20261012.md` 未走。打分文件未改。
|
||||
- 防复发:默认开场两句保持原文字;有换段句时服务端整段替换,不靠模型现编。开关关闭时排序函数返回原数组。打分、闸门、4 件 3 域未改。
|
||||
- 相关记录:BUG-1306、BUG-1115~1117(盘型口径)、BUG-1143/1144(按段选题)、BUG-691(记录优先)、BUG-1240(不说秒级)、BUG-621(历史 case 仍打开旧 Skill)。
|
||||
- 相关记录:BUG-1306、BUG-1115~1117(盘型口径)、BUG-1143/1144(按段选题)、BUG-691(记录优先)、BUG-1240(不说秒级)。
|
||||
- 复发自:无。
|
||||
- 修复版本:实现 `ce4e9803`。2026-10-12 Claude 验收通过(tsc 0;lint 同基线;npm 5020 条、失败 24 逐名同基线;build Static;gzip 0%;快速门全量与基线一致;Raman 请求输出与 staging `798e15ef` 逐字节相同,作为 v5 回放不变的等价证据),产品要求合入;已部署 staging `586561eb`(2026-10-12 核对:health gitCommit / apiGitCommit=586561eb、`requiredMigrationsPresent` true,`/login` 200,未登录 `/api/account` 401;合并后 npm test 5025 条、失败 24 逐名同 `798e15ef` 基线)。进度 `docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md`。
|
||||
- 修复版本:未修复。
|
||||
|
||||
## BUG-1308 | 父母静态是非题(D12)对出生时间分界两侧有没有区分力未经评测
|
||||
|
||||
- 状态:closed_by_design(研究欠力,方向暂停。2026-10-12 研究跑完:计入案例低于预登记的 60,不是不上线的终局,也不是有功效的「无效」。不能据此改题库或计分。编码对不上原句的问题记在研究报告,不改已有编码。方向暂停,不再跑同一份维基。没有交付修复,不是 resolved)
|
||||
- 状态:open(研究单 `docs/tasks/TASK-rectification-d12-parent-probes-research-20261012.md` 待领取)
|
||||
- 首次发现 / 最近更新:2026-10-12 / 2026-10-12
|
||||
- 来源:同 BUG-1307 的上游样例:上游用五道父母是非题区分 D12 换段两侧。同一输入上游两次结论相反,下结论的那次靠单条父母事实的事后解读。
|
||||
- 现象:我方无静态父母题;上游 10-02 的 D12 绑定(`bbb16115` 等)针对带日期的父母事件,静态题两边都没有评测。
|
||||
- 决策:先研究,公开案例、预登记、乱序与正对照,过门才立实现单;过门前不进题库、不计分。
|
||||
- 决策(2026-10-12,F4):方向暂停。不立实现单,不再用同一份维基重跑。被否决的替代是换传记或访谈、两名独立编码员、另开一轮预登记。状态是 closed_by_design,不是 resolved。暂停理由沿用上面的本轮数字,不另算。
|
||||
- 本轮结果:77 个公开案例都编码了。至少 4 道是/否的只有 6 例。结果 `decision=control_underpowered`。四个格子的正对照 `pipeline_exact` 都为真,但两侧不同的案例最少 5,低于预登记的 20;父宫 9 的对照精确 p 是 0.110000,没有四个格子都低于 0.01。真实一侧选中:父 9 为 4/6(p 0.309000),父 10 为 1/6(低于乱序均值)。岁差改变比例 0.361111 与 0.416667,高于 0.2。数字见 `docs/research/rectification_d12_parent_probes_2026_10_12.md` 与同目录结果 JSON。预登记提交在评测之前,评测后未改判据。
|
||||
- 相关记录:BUG-1306、BUG-1307、BUG-1141 / BUG-1240(同类研究均未过门)。
|
||||
- 复发自:无。
|
||||
- 修复版本:不适用
|
||||
|
||||
## BUG-1309 | 分盘段扫描端点写死 Raman:非 Raman 用户的盘型段与档位按 Raman 算,引擎打分按用户岁差
|
||||
|
||||
- 状态:fixed-pending-verify(端点与两处调用已改;77 例持久回放没跑,以 Raman 逐字节相同代替;非 Raman 用户真机未走,不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-12 / 2026-10-12
|
||||
- 来源:BUG-1307 分支验收(`b285011a`)时发现;同一端点在 staging 现存。
|
||||
- 现象:`/api/rectification/v5/varga-segments`(`scripts/rectification_varga_segments_api.py`)的 profile、逐分钟排盘与返回体都写死 `ayanamsa: "raman"`;调用方 `v9/segment-scan.ts` 不传岁差。校正引擎打分用 `resolveAyanamsa(snapshot)`。
|
||||
- 影响面:选了非 Raman 岁差的用户,交付卡 D9 / D10 段、档位与「不用校正」判断按 Raman 算,可能与打分所用岁差的段边界不一致(分界随岁差移动,私人样例一例 Raman 与 Lahiri 差约 7 分钟)。默认 Raman 用户不受影响。
|
||||
- 根因:BUG-1115 引入段扫描端点时只按默认岁差实现,任务书未要求透传岁差。
|
||||
- 修复:端点收可选 `ayanamsa`。白名单与前端 `resolveAyanamsa` 的输出一致;缺省或空白为 raman;别名归一进 profile、`compute_chart` 和返回体,缓存键因此随岁差变。未知名字(含 `djwhal_khul`、`sassanian`)抛 `invalid_ayanamsa`,不静默退回 raman。版本字符串未改。`node_mode` 仍是 mean。`fetchOpeningBoundary` 与 `scanCaseSegments` 发送 `resolveAyanamsa(snapshot)`,返回体岁差必须与该值一致。77 例持久回放未跑,不把缺省 raman 的本地对照说成那次回放。
|
||||
- 验证:公开 AA 盘上,省略岁差与改前捕获逐字段相同,显式 raman 与省略相同;同一窗口 lahiri 的缓存键和星座都不同。前端两处调用:lahiri 资料发 lahiri,未写岁差发 raman。
|
||||
- 相关记录:BUG-1115、BUG-1307、BUG-1306。
|
||||
- 复发自:无。
|
||||
- 修复版本:实现 `ce4e9803`(与 BUG-1307 同分支)。2026-10-12 Claude 验收通过(tsc 0;lint 同基线;npm 5020 条、失败 24 逐名同基线;build Static;gzip 0%;快速门全量与基线一致;Raman 请求输出与 staging `798e15ef` 逐字节相同,作为 v5 回放不变的等价证据),产品要求合入;已部署 staging `586561eb`(2026-10-12 核对:health gitCommit / apiGitCommit=586561eb、`requiredMigrationsPresent` true,`/login` 200,未登录 `/api/account` 401;合并后 npm test 5025 条、失败 24 逐名同 `798e15ef` 基线)。进度见 `docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md` 的 F1/F2 一节。
|
||||
|
||||
## BUG-1310 | 设置页岁差说明写「生时校正目前仍固定按 Raman」,与实际相反
|
||||
|
||||
- 状态:fixed-pending-verify(真机看一眼设置页文案后标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-12 / 2026-10-12
|
||||
- 来源:BUG-1307 / 1309 验收时发现(`frontend/src/lib/ayanamsa.ts` `AYANAMSA_SWITCH_HINT`);产品 10-12 要求修改。
|
||||
- 现象:设置页切换岁差时提示「生时校正目前仍固定按 Raman,之后的版本才会跟着这项设置走」。
|
||||
- 根因:BUG-511 起校正开案快照就带用户岁差(`case-service` 读 `profiles.ayanamsa` → `resolveAyanamsa`),打分按快照;BUG-1309 起段扫描也按快照。提示句一直没跟上。
|
||||
- 修复:改为「之后的咨询、报告、星盘库、每日星语和新开的生时校正会按新岁差算;已经开始的那次校正仍按开始时的岁差。」DESIGN 同步。
|
||||
- 验证:`frontend/tests/ayanamsa.test.ts` 改两条断言(列举 原值 `/咨询、报告、星盘库和每日星语/` → 新值 `/咨询、报告、星盘库、每日星语/`,原因:列举加入新开的生时校正;另一条:原值 `/生时校正目前仍固定按 Raman/`;新值:含「新开的生时校正会按新岁差算」「已经开始的那次校正仍按开始时的岁差」且不含「固定按 Raman」;原因:旧句与实现相反)。
|
||||
- 防复发:同上测试锁住新句并禁止旧说法。
|
||||
- 相关记录:BUG-511、BUG-1309。
|
||||
- 复发自:无。
|
||||
- 修复版本:已部署 staging `586561eb`(2026-10-12 核对:health gitCommit / apiGitCommit=586561eb、`requiredMigrationsPresent` true,`/login` 200,未登录 `/api/account` 401;合并后 npm test 5025 条、失败 24 逐名同 `798e15ef` 基线)。Claude 直接执行,产品 10-12 授权。
|
||||
- 修复版本:不适用(研究)。
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
{
|
||||
"id": "d12_parent_probes_preregistration_2026_10_12",
|
||||
"bug": "BUG-1308",
|
||||
"task": "docs/tasks/TASK-rectification-d12-parent-probes-research-20261012.md",
|
||||
"locked_before_scoring": true,
|
||||
"baseline_commit": "e6baff95",
|
||||
"does_not_overturn": [
|
||||
"docs/research/rectification_angle_timing_2026_10_01.md 与 docs/research/rectification_nadi_seconds_2026_10_05.md 里不带年份的题、以及分钟级时间技法信号弱的结论。本登记只检验静态父母是非题对 D12 上升换段两侧有没有区分力。",
|
||||
"上游 bbb16115 / fe56dd84 绑的是带日期的父母事件。本登记不搬那些代码,只借用「父亲主宫默认 9、北印备选 10」这个口径。"
|
||||
],
|
||||
"birth_records": {
|
||||
"file": "references/real_case_calibration/minute_rectification_holdout_v5.json",
|
||||
"expected_case_count": 77,
|
||||
"case_id_sha256": "e3acb0c9c8005c0c1f88ce7f37b5e274d3fcf5346b7c9f0f441089aa06687ee5",
|
||||
"case_id_sha256_note": "sha256 of the sorted case_id values joined by newline, UTF-8, no trailing newline after the last id.",
|
||||
"roster_file_on_this_commit": "references/real_case_calibration/holdout_v5_roster.json lists 57 of these 77. The other 20 are the inherited v4 cases stored in the holdout file. Both are the tracked public Rodden-AA set. No birth field is added from outside these files.",
|
||||
"clock": "Recorded minute, second fixed at 0. Timezone is the file's timezone_offset, held fixed across the window. Do not re-infer an IANA zone.",
|
||||
"privacy": "Public figures only. No real-user birth data and no private sample."
|
||||
},
|
||||
"facts": {
|
||||
"file": "references/real_case_calibration/parent_facts_v1.json",
|
||||
"answers": ["yes", "no", "unclear"],
|
||||
"inclusion": "A case is scored only if all six probes are present and at least four answers are yes or no. Others are listed as excluded and are not imputed.",
|
||||
"source_grades": ["biography", "interview", "encyclopedia"],
|
||||
"source_rule": "Every scored fact needs a URL opened for this study and a verbatim excerpt. Silent sources stay unclear. Conflicting opened sources stay unclear. Do not code a fact from an unopened page.",
|
||||
"coding": {
|
||||
"mother_more_daily_care": {
|
||||
"yes": "The source says the mother raised the child, was the main day-to-day caregiver, or the child lived with the mother and not the father during childhood.",
|
||||
"no": "The source says the father raised the child, or the child lived with the father and not the mother.",
|
||||
"unclear": "Otherwise. Naming both parents, or saying they lived together, is not a comparison and stays unclear. A parent's death is not enough unless the source says who then did the daily care."
|
||||
},
|
||||
"father_financial_lead": {
|
||||
"yes": "The source says the father supported the family, was the breadwinner, or the household lived on his income or business.",
|
||||
"no": "The source says the mother or another named person was the breadwinner, or the father did not support the family.",
|
||||
"unclear": "Otherwise. A job title alone is not financial leadership."
|
||||
},
|
||||
"father_guided_study": {
|
||||
"yes": "The source says the father taught the child, chose or insisted on the schooling, or supervised the studies.",
|
||||
"no": "The source says the father opposed the schooling, refused to support it, or was uninvolved while another named person directed it.",
|
||||
"unclear": "Otherwise. The father being educated, or a teacher of other people, is not enough. 'Parents sent the child to school' stays unclear."
|
||||
},
|
||||
"father_work_institutional": {
|
||||
"yes": "The principal occupation is military, civil service, elected or appointed government office, teacher, professor, academic, physician, surgeon, lawyer, judge, clergy of an organized religion, police, engineer, scientist, or a salaried post in a state, church, school, hospital, or firm where the source does not call him the owner.",
|
||||
"no": "A principal occupation is stated and it is trade, a self-owned business, farming the family's land, or a skill practiced on his own account, and the yes list is not the principal job.",
|
||||
"unclear": "Occupation unstated, or several jobs with no principal one."
|
||||
},
|
||||
"father_work_commerce": {
|
||||
"yes": "The principal occupation is merchant, trader, shopkeeper, owner-banker, industrialist, factory owner, company founder, or another self-owned business.",
|
||||
"no": "A principal occupation is stated and it is not commerce.",
|
||||
"unclear": "Occupation unstated. Both this probe and father_work_institutional may be yes when the source fits both clauses. Do not force one label."
|
||||
},
|
||||
"parents_long_apart": {
|
||||
"yes": "Before the subject was 18, the source says the parents lived in different places for a prolonged period, separated, divorced, or one parent was away from the family home for years.",
|
||||
"no": "The source says the parents lived together through childhood, or explicitly did not separate.",
|
||||
"unclear": "Otherwise. Death of a parent is not geographic separation by itself."
|
||||
}
|
||||
}
|
||||
},
|
||||
"engine": {
|
||||
"chart": "domain_calculation_service.compute_chart",
|
||||
"varga": "varga.calc_varga(longitude, 12) in classical_local mode, field sign_idx",
|
||||
"longitude_field": "lon",
|
||||
"d1_sign": "int(longitude // 30) % 12 for the ascendant; planet sign_idx from compute_chart for D1 bodies",
|
||||
"house_system": "whole sign",
|
||||
"house_of_sign": "(body_sign_idx - lagna_sign_idx) % 12 + 1",
|
||||
"lord_of_house": "SIGN_LORDS of SIGNS[(lagna_sign_idx + house - 1) % 12], from scripts/varga.py",
|
||||
"do_not_use": [
|
||||
"the D1 planet house field",
|
||||
"bhava cusps",
|
||||
"a newly written ephemeris",
|
||||
"vedastro varga mode"
|
||||
],
|
||||
"settings": {
|
||||
"baseline_ayanamsa": "raman",
|
||||
"baseline_node_mode": "mean",
|
||||
"ayanamsa_alt": "lahiri",
|
||||
"node_alt": "true"
|
||||
},
|
||||
"ephemeris_version": "Record swisseph version in the results. Do not choose a version in order to pass."
|
||||
},
|
||||
"grid": {
|
||||
"radii_minutes": [10, 30],
|
||||
"step_minutes": 1,
|
||||
"offsets": "Every integer from -radius through +radius, inclusive. Local clock plus the offset, date may roll.",
|
||||
"boundary": "A cut sits between offset o and o+1 when both are on the grid and their D12 lagna sign indexes differ. The representative minutes are o and o+1.",
|
||||
"nearest_cut": "Minimum of min(|o|, |o+1|); ties break by smaller |o|; then by smaller o.",
|
||||
"true_side": "The representative minute whose D12 lagna sign index equals the sign index at offset 0. The other representative is the other side. If that assignment is not exactly one minute, abort the case as a runner error. Do not guess.",
|
||||
"no_cut": "If the window has no cut, the case is no_boundary and is outside the selection denominator.",
|
||||
"segment": "A maximal run of offsets with one D12 lagna sign. Its representative is the offset in the run with smallest absolute value, ties taking the smaller offset."
|
||||
},
|
||||
"profiles": {
|
||||
"father_9": {
|
||||
"chart": "D12",
|
||||
"father_house": 9,
|
||||
"mother_house": 4,
|
||||
"note": "Parashari default, the upstream fe56dd84 main profile. Father indicators are the Sun and the lord of house 9. Mother indicators are the Moon and the lord of house 4."
|
||||
},
|
||||
"father_10": {
|
||||
"chart": "D12",
|
||||
"father_house": 10,
|
||||
"mother_house": 4,
|
||||
"note": "North-Indian alternate stored by fe56dd84. Father indicators are the Sun and the lord of house 10. Mother side stays house 4."
|
||||
},
|
||||
"d1_father_9": {
|
||||
"chart": "D1",
|
||||
"father_house": 9,
|
||||
"mother_house": 4,
|
||||
"note": "Control only. Same six formulas, read on the rasi chart. Not part of the gate. There is no D1 10th-father profile."
|
||||
}
|
||||
},
|
||||
"probes": [
|
||||
"mother_more_daily_care",
|
||||
"father_financial_lead",
|
||||
"father_guided_study",
|
||||
"father_work_institutional",
|
||||
"father_work_commerce",
|
||||
"parents_long_apart"
|
||||
],
|
||||
"stems_zh": {
|
||||
"mother_more_daily_care": "母亲是否比父亲更多负责日常照料",
|
||||
"father_financial_lead": "父亲是否是家庭财务的主要来源",
|
||||
"father_guided_study": "父亲是否指导过本人的学业",
|
||||
"father_work_institutional": "父亲的主要职业是否属于体制内或受雇专业",
|
||||
"father_work_commerce": "父亲的主要职业是否是自己经商",
|
||||
"parents_long_apart": "父母在童年是否长期分隔两地"
|
||||
},
|
||||
"reading_rules": {
|
||||
"shared": {
|
||||
"unique_indicators": "If the karaka and the house lord are the same planet, the indicator set contains that planet once.",
|
||||
"kendra": [1, 4, 7, 10],
|
||||
"conjunct": "Same sign index in the chart being read.",
|
||||
"career_house": "C = ((father_house - 1 + 9) % 12) + 1. This is the 10th from the father house. father_house 9 gives 6. father_house 10 gives 7.",
|
||||
"separated_signs": "For sign indexes a and b, d = (b - a) % 12. The pair is separated when d is 1, 5, 7, or 11. The same sign is not separated. The same planet is not separated from itself.",
|
||||
"labels": ["yes", "no", "abstain"]
|
||||
},
|
||||
"mother_more_daily_care": "Let Mk be how many distinct mother indicators sit in a kendra, and Fk the same count for father indicators. yes when Mk > Fk. no when Fk > Mk. If the counts are equal: yes when the Moon is in a kendra and the Sun is not; no when the Sun is in a kendra and the Moon is not; otherwise abstain. This is the only probe that may abstain.",
|
||||
"father_financial_lead": "yes when any father indicator is in house 2 or house 11, or the lord of house 2 is in the father house, or the lord of house 2 is conjunct any father indicator. no otherwise.",
|
||||
"father_guided_study": "yes when any father indicator is in house 5, or the lord of house 5 is in the father house, or Jupiter is in the father house, or Jupiter is conjunct any father indicator. no otherwise.",
|
||||
"father_work_institutional": "yes when Saturn or the Sun occupies C, or the lord of C is Saturn or the Sun. no otherwise.",
|
||||
"father_work_commerce": "yes when Mercury or Venus occupies C, or the lord of C is Mercury or Venus. no otherwise.",
|
||||
"parents_long_apart": "yes when the Sun and Moon are a separated pair, or the lord of house 4 and the father-house lord are a separated pair, or Rahu occupies house 4 or the father house. no otherwise. Rahu is the planet named Rahu in compute_chart. If that key is missing, the Rahu clause is false. Do not substitute another body."
|
||||
},
|
||||
"scoring": {
|
||||
"counted_probe": "The biographical answer is yes or no, and this side's prediction is yes or no. unclear and abstain are not counted.",
|
||||
"side_score": "matches minus mismatches among counted probes. A side with no counted probe scores 0.",
|
||||
"true_side_selected": "The true side's score is strictly greater than the other side's score.",
|
||||
"tie": "Equal scores. A tie is not a selection.",
|
||||
"selection_rate": "true_side_selected count divided by the number of included cases that have a cut in that window. The denominator does not drop ties.",
|
||||
"head_segment_hit": "Among segments in the same window, the true segment's score is strictly greater than every other segment's score. Not a gate.",
|
||||
"true_segment_retained": "The true segment's score is greater than or equal to every other segment's score. Not a gate.",
|
||||
"segment_denominator": "Included cases that have a cut. A one-segment window is no_boundary and is outside both segment rates."
|
||||
},
|
||||
"permutation": {
|
||||
"n": 999,
|
||||
"seed": 20261012,
|
||||
"algorithm": "Fisher-Yates driven by random.Random(seed).randrange. Do not call Random.shuffle. One stream builds 999 permutations of the included case_id list sorted alphabetically. The same 999 assignments are reused for every profile and both radii.",
|
||||
"unit": "The whole six-answer vector moves with the case. Probes are not shuffled separately.",
|
||||
"p": "(count of permutation rates greater than or equal to the observed rate, plus 1) divided by 1000. Compare this exact ratio. Do not compare a rounded display.",
|
||||
"null_mean": "Arithmetic mean of the 999 permutation rates.",
|
||||
"percentile": "Count of permutation rates strictly below the observed rate, divided by 999. Report it. The gate does not use the percentile."
|
||||
},
|
||||
"positive_control": {
|
||||
"seed": 20261013,
|
||||
"n": 999,
|
||||
"construction": "Ignore the biographical answers. For each probe, if the true side predicts yes or no, plant that label; if it abstains, plant unclear. Then use the same selection statistic and the same Fisher-Yates procedure with the control seed.",
|
||||
"pipeline_exact": "Among included cases that have a cut and whose two sides differ on at least one probe the true side does not abstain, every case must select the true side.",
|
||||
"minimum_differing_cases": 20,
|
||||
"permutation_p_max": 0.01,
|
||||
"failure": "If pipeline_exact fails, or differing cases are under 20, or the planted rate's exact p is not below 0.01, the control failed. Do not call the study a pass, and do not call the astrology result 不上线. Write 欠力 for an under-powered control and 结论无效 for a pipeline that cannot see a planted signal."
|
||||
},
|
||||
"family": {
|
||||
"cells": [
|
||||
["father_9", 10],
|
||||
["father_9", 30],
|
||||
["father_10", 10],
|
||||
["father_10", 30]
|
||||
],
|
||||
"bonferroni_alpha": 0.0125,
|
||||
"cell_significant": "Exact permutation p is strictly below 0.0125, and the observed selection rate is strictly greater than that cell's null mean.",
|
||||
"d1_not_in_family": true
|
||||
},
|
||||
"robustness": {
|
||||
"measured_on": "The baseline cell's two representative minutes. Recompute that profile's six labels at those same clock offsets under lahiri/mean and under raman/true.",
|
||||
"change": "A label in {yes, no, abstain} differs.",
|
||||
"denominator": "Boundary cases times 2 sides times 6 probes.",
|
||||
"gate_max_change_rate": 0.2,
|
||||
"node_clause": "Parents_long_apart is the only probe that reads Rahu. Still count all six probes in the denominator.",
|
||||
"concession": "If included cases are under 60 and true-node charts cannot be computed, report the blockage and apply the 0.20 line to the ayanamsa switch only. Do not waive the ayanamsa line. Do not waive either line when included cases are at least 60."
|
||||
},
|
||||
"gates": {
|
||||
"sample": "Included cases at least 60. Below that the status is 欠力 even if every numeric line is met. 欠力 is not 不上线 and is not 过门.",
|
||||
"discrimination": "At least one family cell is cell_significant.",
|
||||
"control": "The positive control meets pipeline_exact, the differing-case minimum, and permutation_p_max.",
|
||||
"robustness": "Every cell_significant cell has both change rates strictly below 0.20, subject to the small-sample node concession.",
|
||||
"pass": "sample AND discrimination AND control AND robustness. Only a pass may recommend a question, a score, or a tie-break attached to the nearest-boundary opening.",
|
||||
"fail_closed": "Included cases at least 60, the control passed, and either no cell is significant or a significant cell is not robust. Write 不上线. Do not recommend an implementation."
|
||||
},
|
||||
"not_a_gate": [
|
||||
"d1_father_9",
|
||||
"head_segment_hit",
|
||||
"true_segment_retained",
|
||||
"per-probe accuracy",
|
||||
"the permutation percentile",
|
||||
"any rule, threshold, or seed written down after the results were seen"
|
||||
],
|
||||
"after_the_fact": "A change to a reading rule, the coding manual, a seed, the family, or the alpha after results exist is a new round. Keep the old results. The new round cannot inherit this round's gate."
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,112 +0,0 @@
|
||||
# D12 父母静态是非题:分界两侧有没有区分力(2026-10-12)
|
||||
|
||||
结论:**欠力**。预登记要求至少 60 个可计分案例。本轮打开了 77 个公开页,按锁定的编码规则只有 **6** 例至少有 4 道题答成「是」或「否」。结果文件的 `decision` 是 `control_underpowered`,`decision_note` 是「欠力」。
|
||||
|
||||
这不是「不上线」的终局,也不是「这套题无效」。样本不够,不能据此改题库、计分或平局裁决。先前「不带年份的题信号弱」「分钟级时间技法信号弱」的结论也不被本轮推翻或改写。
|
||||
|
||||
给产品的一句口径:这轮公开传记里,能严格答成是非、并且够四题的父母静态题只有 6 例,不到事先定的 60 例,所以还不能决定这种题上不上校正。下一步是暂停这条方向,不是再跑同一份维基。
|
||||
|
||||
## 这轮做了什么
|
||||
|
||||
- 预登记 `docs/research/d12_parent_probes_preregistration_2026_10_12.json` 单独提交在评测之前(`ad89e410`)。评测之后没有改判据、种子、题干或编码手册。
|
||||
- 出生记录只用 `minute_rectification_holdout_v5.json` 里已有的 77 个公开 Rodden AA 案例。没有从外面补出生时间。
|
||||
- 父母事实在 `references/real_case_calibration/parent_facts_v1.json`。每题都有打开过的 https 页面、等级 `encyclopedia`(英文维基百科)和原句。来源表是该文件的 `source_table`。拿不到原句的题记「不清楚」,不编造。
|
||||
- 盘用 `domain_calculation_service.compute_chart`,D12 用 `varga.calc_varga` 的 `classical_local`。没有新写星历。基线是 Raman / mean node。对照是 Lahiri / mean,以及 Raman / true node。星历版本写在结果里:`2.10.03`。
|
||||
- 结果:`docs/research/d12_parent_probes_results_2026_10_12.json`。`PYTHONHASHSEED=0` 下连跑两次,文件 SHA256 都是 `cbf324567ed8ddf14ac4769c4f8fb90f015aeab6711b2022a81b473f81748fe8`。
|
||||
|
||||
## 不清楚率
|
||||
|
||||
分母是结果文件里的 77 条已编码案例(`unclear_rate_coded`)。六位小数用本轮同一个 `display_ratio`,由文件里的计数打出,不是另算的一套率。
|
||||
|
||||
| 题 | 是 | 否 | 不清楚 | 不清楚率 |
|
||||
| --- | ---: | ---: | ---: | --- |
|
||||
| 母亲更多日常照料 | 9 | 1 | 67 | 67/77 = 0.870130 |
|
||||
| 父亲经济主导 | 3 | 2 | 72 | 72/77 = 0.935065 |
|
||||
| 父亲指导学业 | 11 | 1 | 65 | 65/77 = 0.844156 |
|
||||
| 父亲体制内职业 | 24 | 12 | 41 | 41/77 = 0.532468 |
|
||||
| 父亲自己经商 | 14 | 38 | 25 | 25/77 = 0.324675 |
|
||||
| 父母长期分开 | 23 | 1 | 53 | 53/77 = 0.688312 |
|
||||
|
||||
职业两题相对能答。照料、谁养家、谁管学业,多数页面没有对上预登记的句子,所以保持不清楚。不满 4 道是/否的 71 例记在 `excluded_few_decisive`,不插值。`not_coded` 是空的:77 例都进了事实文件。
|
||||
|
||||
计入的 6 例(`included_case_ids`):`barack_obama_1961_aa_v4_holdout`、`donald_trump_1946_aa_v5_holdout`、`imelda_marcos_1929_aa_v5_holdout`、`kamala_harris_1964_aa_v5_holdout`、`rafael_nadal_1986_aa_v5_holdout`、`sophia_loren_1934_aa_v5_holdout`。
|
||||
|
||||
## 真实一侧选中率
|
||||
|
||||
6 例在 ±10 和 ±30 分钟里都有 D12 上升换段(`no_boundary` 都是 0)。最近分界的两侧分钟在两个窗口里是同一对,所以选中率相同。平局不算选中。
|
||||
|
||||
| 格子 | 选中 | 显示 | 乱序均值 | 高于均值 | 精确 p | 低于真实值的乱序比例 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| 父 9,±10 | 4/6 | 0.666667 | 0.490157 | 是 | 309/1000 = 0.309000 | 691/999 = 0.691692 |
|
||||
| 父 9,±30 | 4/6 | 0.666667 | 0.490157 | 是 | 309/1000 = 0.309000 | 691/999 = 0.691692 |
|
||||
| 父 10,±10 | 1/6 | 0.166667 | 0.338672 | 否 | 946/1000 = 0.946000 | 54/999 = 0.054054 |
|
||||
| 父 10,±30 | 1/6 | 0.166667 | 0.338672 | 否 | 946/1000 = 0.946000 | 54/999 = 0.054054 |
|
||||
|
||||
四个格子的 `significant` 都是 false。Bonferroni 线是 p < 0.0125 且高于乱序均值。父 9 的 p 是 0.309000,远在线的另一侧。父 10 的真实选中率低于乱序均值。
|
||||
|
||||
同窗全部 D12 段(不是过门项):
|
||||
|
||||
| 格子 | 头段命中 | 真值段保留 |
|
||||
| --- | --- | --- |
|
||||
| 父 9,±10 | 4/6 = 0.666667 | 4/6 = 0.666667 |
|
||||
| 父 9,±30 | 2/6 = 0.333333 | 4/6 = 0.666667 |
|
||||
| 父 10,±10 | 0/6 = 0.000000 | 2/6 = 0.333333 |
|
||||
| 父 10,±30 | 0/6 = 0.000000 | 1/6 = 0.166667 |
|
||||
|
||||
D1 只作对照,不是门。D1 父 9 在 ±10 和 ±30 的真实一侧选中都是 0/6(显示 0.000000)。记录分钟两侧的 D1 读法没有把真实一侧选出来。
|
||||
|
||||
## 乱序和正对照
|
||||
|
||||
乱序:999 次,种子 20261012,整组答案在计入案例之间对调。上表的 p 和分位就是这 999 次。
|
||||
|
||||
正对照:把真实一侧的是/否埋进答案(弃权改成不清楚),种子 20261013。四个格子的 `pipeline_exact` 都是 true,`missed` 都是空的。两侧读法不同的案例,流程都选中了真实一侧。
|
||||
|
||||
| 格子 | 两侧不同的案例 | 埋入后选中 | p | p < 0.01 |
|
||||
| --- | ---: | --- | --- | --- |
|
||||
| 父 9,±10 与 ±30 | 5 | 5/6 = 0.833333 | 110/1000 = 0.110000 | 否 |
|
||||
| 父 10,±10 与 ±30 | 6 | 6/6 = 1.000000 | 5/1000 = 0.005000 | 是 |
|
||||
|
||||
预登记还要求:不同案例至少 20,并且四个格子的对照 p 都低于 0.01。这里最少只有 5,父 9 的对照 p 是 0.110000。所以对照是欠力,不是「流程测不出」。测不出才会记成结论无效。
|
||||
|
||||
## 岁差和交点
|
||||
|
||||
分母是 6 个有分界的案例 × 两侧分钟 × 6 题 = 72。门是改变比例严格小于 0.2。
|
||||
|
||||
| 格子 | Lahiri 相对 Raman | 真交点相对平均交点 | 两样都低于 0.2 |
|
||||
| --- | --- | --- | --- |
|
||||
| 父 9 | 26/72 = 0.361111 | 1/72 = 0.013889 | 否 |
|
||||
| 父 10 | 30/72 = 0.416667 | 0/72 = 0.000000 | 否 |
|
||||
|
||||
交点几乎不改判读。岁差改判读的比例高于 20% 线。四个格子的 `robust` 都是 false。即便选中率以后在更大样本里显著,这一条岁差结果也不能单独当成过门。
|
||||
|
||||
## 判定
|
||||
|
||||
过门需要:计入至少 60,正对照达到预登记的力度,并且至少一个家族格子既显著又稳健。本轮三条都没有。
|
||||
|
||||
不写实现建议。题干、计分、平局裁决都不接。这是欠力,不是做完后的否定结论。方向见下一节,上面的数字不改。
|
||||
|
||||
编码按预登记从紧:只写职业头衔不算养家,也不算父亲亲自管学业;父母一起送学不算;死亡本身不算两地分开;页面没写出主业就保持不清楚。名誉领事这类短任期、页面没有写成主业的,没有记成体制内。这些取舍都在看分数之前定死,看完分数后没有为了凑 60 例而放宽。下一节核对之后,短任期这一条并没有在每例上用同一把尺子。
|
||||
|
||||
## 下一步:方向暂停
|
||||
|
||||
结论:**方向暂停**。上一节的判定保持不变:欠力,77 个公开案例计入 6,不是有功效的「无效」,也不是终局「不上线」。不改题库,不计分,不改预登记,也不改 `parent_facts_v1.json` 里已经写下的编码。
|
||||
|
||||
### 编码问题清单
|
||||
|
||||
下面每条都只对照事实文件里已经存着的原句。原句对不上修复单里的说法时,写文件实际写了什么,不另补传记。
|
||||
|
||||
1. 奥巴马(`barack_obama_1961_aa_v4_holdout`)`mother_more_daily_care` 记成 yes。这条的原句是:「Francis of Assisi Catholic Elementary School) for two years and Sekolah Dasar Negeri Menteng 01 (State Elementary School Menteng 01) for one and a half years, supplemented by English-language Calvert School homeschooling by his mother.」修复单说这句只谈上了哪所小学。文件不是这样:后半句还写了母亲用 Calvert School 做英语家庭补习。预登记的「是」要写成母亲抚养、母亲是主要日常照料者,或童年跟母亲住、不跟父亲住。这句两头都没有。记成 yes 仍然对不上预登记。他在计入的 6 例里,这里只记编码问题,不改 6 这个数。
|
||||
|
||||
2. 伊梅尔达·马科斯(`imelda_marcos_1929_aa_v5_holdout`)`father_work_institutional` 记成 yes。这条引文是:「Her parents were Vicente Orestes Romuáldez, a lawyer, and his second wife, María Remedios Trinidad who was born and raised in Baliwag, Bulacan.」它只说父亲是 a lawyer,没有说他自己开业。同一条案例里,财务题另有一句:「In November 1938, Imelda's father gave up living in Manila due to his declining fortunes in his law practice and returned to Tacloban, where he could support his family with a simpler lifestyle.」这句才出现 his law practice。预登记的「是」把 lawyer 放进体制内或受雇专业;「否」又把自营手艺算出去,条件是 yes 名单并不是主业。律师这个词在「是」的名单里,law practice 又像自己执业。两套说法在这一例上对不齐。不能把「他自己开业」写成体制内那条引文的原文。她在计入的 6 例里。编码不改,6 也不改。
|
||||
|
||||
3. 索菲亚·罗兰(`sophia_loren_1934_aa_v5_holdout`)`father_work_institutional` 记成 yes。原句是:「Her mother was a piano teacher and aspiring actress, and her father a failed engineer who worked temporarily for the national railway Ferrovie dello Stato Italiane.」这句写的是 failed engineer,以及在国营铁路 Ferrovie dello Stato Italiane 的临时工作(worked temporarily)。预登记没有「短任期不算主业」这句。它写的是 principal occupation(主要职业);不清楚是职业没写,或几份工作里没有一份主业。本报告上一节把「名誉领事这类短任期、页面没有写成主业」说成不记体制内。奥黛丽·赫本(`audrey_hepburn_1929_aa_v5_holdout`)的 `father_work_institutional` 记成 unclear,原句是:「He was an Honorary British Consul in Semarang, Dutch East Indies, from 1923 to 1924.」一年的名誉领事记成不清楚,罗兰这篇写明 temporarily 的国营铁路却记成 yes。两例不是同一把尺子。赫本不在计入的 6 例里。罗兰在。两处编码都不改。
|
||||
|
||||
4. 李小龙(`bruce_lee_1940_aa_v5_holdout`)`father_guided_study` 记成 yes。原句是:「Lee's first introduction to martial arts was through his father, from whom he learned the fundamentals of Wu-style tai chi.」这是父亲教吴氏太极的基本功,不是预登记写的教功课、选定或坚持学业、督导学习。他不在计入的 6 例里:六题里只有指导学业为 yes、自己经商为 no,其余不清楚,不满 4 道是/否。这里只把他当作已经排除的编码例子,不是计分命中,也不进 4/6 或 1/6。
|
||||
|
||||
### 不再跑同一份维基
|
||||
|
||||
被否决的另一条路:换新来源(传记或访谈),两名编码员各自编码,再开一轮新的预登记。本轮不走这条。
|
||||
|
||||
决定是方向暂停。维基这一轮 77 例只产出 6 例可计分,大约 8%(6/77)。按这个比例凑到预登记的 60,要大约 770 个同样能打开的 AA 案例,也就是 700 这个量级。这份 77 例的数据给不出。岁差改判读是 26/72 与 30/72,显示 0.361111 与 0.416667,大约 36% 和 42%,高于严格小于 0.2 的稳健性门。同一套设计把样本做大,仍不太可能过门。
|
||||
|
||||
BUG-1308 记为 closed_by_design(研究欠力,方向暂停)。没有交付修复,不是 resolved。修复版本仍是不适用。
|
||||
@@ -1,57 +0,0 @@
|
||||
# 进度:D12 父母静态题研究(BUG-1308)
|
||||
|
||||
结论:**欠力**。77 个公开案例都写了事实,计入 6,低于预登记的 60。`decision=control_underpowered`。不写不上线,不写实现建议。F4 把方向定为暂停,BUG-1308 改为 closed_by_design,不是 resolved。未推送。
|
||||
|
||||
## 范围
|
||||
|
||||
- 工作树:`codex/rectification-d12-parent-probes-research-20261012`,基线 `e6baff95`,无 upstream。
|
||||
- 只新增研究脚本、定向测试、研究文档、父母事实,以及本进度、BUG-1308 和 `docs/tasks/README.md` 的这一行。
|
||||
- 未改生产代码、打分、题库、`decision_policy`、算法身份、`.gitea`。未使用 stash。
|
||||
|
||||
## 预检
|
||||
|
||||
开工跑过 `scripts/pre_work_check.py`。该次因超时退出码 1(片段扫描与聚焦测试的时限),远端与适配器在同一次输出里没有失败。本进度不把预检写成通过。盘计算入口是现成的 `compute_chart` + `calc_varga`,没有新写星历,也没有改 `BLOCKED.md`。
|
||||
|
||||
## 预登记和数据
|
||||
|
||||
- 预登记单独提交:`ad89e410`(`research(rectification): preregister D12 parent probes (BUG-1308)`)。文件 SHA256 在结果里是 `dde1c584c58014f3e1a9ba30b6ee5c728746a3688dbb3556d6ec1a12e9bfac3e`。
|
||||
- 评测之后没有改判据。
|
||||
- 事实文件 SHA256:`1746b70102a025c412089e28ae7d250164587d02cd9d2cb0e86ee037bddca131`。
|
||||
- 来源都是本轮打开的英文维基百科,等级 encyclopedia。不清楚率见研究报告的表,计数来自结果 JSON 的 `unclear_rate_coded`。
|
||||
|
||||
## 评测
|
||||
|
||||
- Python:`C:\Users\74082\anaconda3\python.exe`(3.11.7)。仓库无 `.venv`。`PYTHONHASHSEED=0`。
|
||||
- 星历:Swiss Ephemeris `2.10.03`。
|
||||
- 结果两次运行 SHA256 相同:`cbf324567ed8ddf14ac4769c4f8fb90f015aeab6711b2022a81b473f81748fe8`。
|
||||
- 真实一侧:父 9 为 4/6(p 0.309000),父 10 为 1/6(p 0.946000,低于乱序均值)。±10 与 ±30 的最近分界是同一对分钟。
|
||||
- 正对照:四格 `pipeline_exact=true`。父 9 两侧不同 5 例,p 0.110000;父 10 两侧不同 6 例,p 0.005000。未达到「至少 20 且四格 p<0.01」。
|
||||
- 稳健性:岁差改变 26/72 与 30/72;交点改变 1/72 与 0/72。岁差不低于 0.2,`robust=false`。
|
||||
|
||||
## 测试
|
||||
|
||||
- `tests/test_d12_parent_probes.py`:15 项通过,退出码 0。
|
||||
- `tests/test_repo_privacy_markers.py`:退出码 0(在暂存之后跑,能扫到本轮新文件)。
|
||||
- `scripts/run_quality_gate.py --profile quick`:Python 段通过,其中 pytest 一步 1310.3 秒打勾。随后 `npm test` 退出码 1,因为这个工作树没有 `frontend/node_modules/tsx`。没有把依赖装进仓库。门禁停在这一步,lint 和 build 没有往下跑。这是环境缺口,不是本轮研究脚本的失败。
|
||||
|
||||
## 未做
|
||||
|
||||
- 不推送,不升 `main`,不改 workflow。
|
||||
- 不放宽编码去凑 60 例。评分当时写下的下一轮条件是:必须新的预登记,并保留本轮结果。F4 不采用这条,见下。
|
||||
|
||||
## F4 编码复核与方向暂停
|
||||
|
||||
结论:**方向暂停**。判定仍是欠力,计入 6/77,`decision=control_underpowered`。不是有功效的「无效」,也不是终局「不上线」。本分支没有 `TASK-rectification-1012-batch-fix-20261012.md`,按本轮交代写,不从别处摘任务书。
|
||||
|
||||
- 只改研究报告、本进度、BUG-1308、状态板这一行。未改生产代码、打分、题库、预登记 JSON、结果 JSON、`parent_facts_v1.json`。
|
||||
- 编码只对照事实文件里已经存着的原句。对不上的地方写文件实际写了什么。没有改编码,也没有新补传记。
|
||||
- 奥巴马照料题记成 yes 的原句是小学名称,句末还有母亲的 Calvert 家庭补习。不能写成「这句只谈小学」。预登记的「是」仍对不上:没有写成主要日常照料,也没有写成跟母亲住、不跟父亲住。他在计入的 6 例里,6 不改。
|
||||
- 马科斯体制内题记成 yes 的原句只写 a lawyer,没有写他自己开业。同一案例的财务题另有 his law practice。预登记的「是」含 lawyer,「否」含自营手艺。尺度不合。她在计入的 6 例里,编码不改。
|
||||
- 罗兰体制内题记成 yes 的原句是 failed engineer,以及国营铁路的临时工作。预登记没有「短任期不算主业」这句,写的是主要职业。赫本父亲 1923 到 1924 年的名誉领事记成 unclear,而且不在计入的 6 例里。两例不是同一把尺子。两处编码都不改。
|
||||
- 李小龙指导学业记成 yes 的原句是父亲教吴氏太极的基本功,不是学业。他不在计入的 6 例里:六题里是/否只有两道。只作已经排除的编码例子,不是计分命中,也不进 4/6 或 1/6。
|
||||
- 被否决的替代:换传记或访谈做新来源,两名编码员各自编码,再开一轮新的预登记。不采用。
|
||||
- 维基产出 6/77,大约 8%。按这个比例凑到预登记的 60,大约要 770 个 AA 案例,量级是 700。这份 77 例给不出。岁差改判读 26/72 与 30/72,显示 0.361111 与 0.416667,大约 36% 和 42%,高于严格小于 0.2 的门。同一套设计把样本做大,仍不太可能过门。
|
||||
- BUG-1308 改为 closed_by_design(研究欠力,方向暂停)。修复版本仍是不适用。不是 resolved。状态板只改 BUG-1308 这一行,没有引进别的看板。
|
||||
- 未改 `CHANGELOG.md`。仓库里没有 2026-10-12 的研究条目可追加一句。暂停的研究不是用户可见的技能变化。
|
||||
- 定向测试没有快照研究报告正文,所以没有改断言,也没有原值 / 新值 / 原因三栏。
|
||||
- `C:\Users\74082\anaconda3\python.exe -m pytest tests/test_repo_privacy_markers.py tests/test_d12_parent_probes.py -q --tb=line`:收集 78 项(隐私标记 63,D12 15),进度全是通过标记,到 100%,退出码 0。没有失败,没有跳过。
|
||||
@@ -1,120 +0,0 @@
|
||||
# PROGRESS · 生时校正开场说最近一次换段(2026-10-12,BUG-1307)
|
||||
|
||||
任务书:`TASK-rectification-nearest-boundary-opening-20261012.md`。分支 `codex/rectification-nearest-boundary-opening-20261012`,基线 `e6baff95`。只 commit,不 push。未部署。
|
||||
|
||||
## 一句话
|
||||
|
||||
开场会说填的时间离最近一次换段差几分钟、两边哪几件事不同。2 / 5 / 10 分钟都没有跑 77 例,阈值没有选定,选题开关默认关。打分没改。Skill 仍是 10.0.33。
|
||||
|
||||
## 阈值
|
||||
|
||||
没有采用。候选 2 / 5 / 10 分钟都没跑持久回放,不能说哪一档过了红线。
|
||||
|
||||
代码里的 `NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES = 5` 只在开关打开之后才用,注释写明未采纳。它不是选定结果。
|
||||
|
||||
开关:环境变量 `RECTIFICATION_NEAREST_BOUNDARY_PROBES` 必须等于 `on` 才改变出题顺序,默认关。关着的时候,开场不说「接下来先问这几件」,避免承诺一件没有做的事。
|
||||
|
||||
## 77 例回放
|
||||
|
||||
没跑。持久回放要本地 Postgres 和逐例星历,这一轮不启动。因此红线未核,按任务书让步:只上呈现,选题留开关默认关。没有为了过门改打分。
|
||||
|
||||
修后基线(`docs/research/varga_resolution_persisted_onoff_after_fix_2026_10_01.json`,段序开,生产默认窗口不超过 61 分钟;`dataset_sha256` `ab365a3e3f4953d4b538f2c0889dc98ab89444a28340c3a7b29e2a864cdd687a`)这一轮没有重测:
|
||||
|
||||
| 窗口 | 提问数 | D9 头段命中 | D10 头段命中 | 真值段保留 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| ±10 开 | 355 | 71 | 67 | 76 |
|
||||
| ±30 开 | 418 | 51 | 55 | 76 |
|
||||
|
||||
红线是 ±10 / ±30 真值段保留都不低于 76 / 76,D9、D10 头段命中不低于上表。任一格下降就要停。
|
||||
|
||||
| 阈值 | 问题数 | ±10 真值段保留 | ±30 真值段保留 | ±10 D9 头段 | ±10 D10 头段 | ±30 D9 头段 | ±30 D10 头段 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| 2 分钟 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 |
|
||||
| 5 分钟 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 |
|
||||
| 10 分钟 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 | 未跑 |
|
||||
|
||||
公开 AA 盘上傍晚 40 分钟里,离换段最远是 3 分钟。那是一张盘的测量,不是 77 例的门,也不拿来选阈值。
|
||||
|
||||
## 句数上限
|
||||
|
||||
| 项 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `isAcceptableOpeningBody` 句数上限 | 3 | 3 | 新句放在原来两句中间,没有放宽上限 |
|
||||
|
||||
没有新句时,默认开场两句与改前逐字相同。
|
||||
|
||||
## 为什么没有 10.0.34
|
||||
|
||||
OpeningPolicy 仍要求正文两句大白话,旧版本目录没有改。开场正文由服务端在模型写完后整段换成 `openingSpokenBody`,不靠模型现编,也不靠 Skill 正文。历史 case 仍读 10.0.33(BUG-621)。`RECTIFICATION_SKILL_VERSION` 仍是 `10.0.33`。
|
||||
|
||||
## 呈现
|
||||
|
||||
- 新模块 `scripts/rectification/nearest_boundary.py` 只读 `window_scan` 的 `transitions`。真正换段是前后星座不同。展示层和星座没变的行忽略。距离按钟面环形分钟算,一样近时留更早的钟点。不改 `window_scan` 的返回形状。
|
||||
- 开场扫描走现有 `POST /api/rectification/v5/varga-segments`。`target_charts` 仍是调用方要的盘;只有带了 `boundary_charts` 和 `recorded_time` 才多一个 `nearest_boundary`。默认缓存键不变。`minutes.signs` 仍只含请求的目标盘。D12 仍不能当目标盘。
|
||||
- 人话对照:婚恋、工作、住处、学业、子女、父母、钱、身体。本命上升不说成一件事。客户端只用层名白名单重写主题,不采用接口里的主题字符串。
|
||||
- 申报方式 `period_only`、没有记录钟点、或扫描失败:原来两句,不挡开场。
|
||||
- 窗口内没有换段:`你填的时间附近这些盘都不换,先从经历开始。`
|
||||
- 有换段:`你填的时间离最近会换的地方不到 1 分钟,两边不一样的是父母、工作。` 或 `差 N 分钟`。不足 1 分钟说「不到 1 分钟」。
|
||||
|
||||
## 选题开关
|
||||
|
||||
打开之后,现有增益排序不变,再把领域落在这条分界上的题排到前面,其余保持刚才的顺序。不新增题型,不改分数。问完这些再接原来的顺序。`period_only`、没有记录钟点、找不到换段、距离大于未采纳的 5 分钟、或没有领域时,不改顺序。
|
||||
|
||||
开关关闭时,排序函数返回原来的数组,同输入同分、同顺序。
|
||||
|
||||
## 没改的
|
||||
|
||||
`decision_policy.py`、闸门常量、4 件 3 域、采用门、交付卡档位、`window_scan` 输出、`page.tsx`、`use-rectification-surface.ts`、咨询入口三个文件、`scripts/jyotish_api_server.py`、数据库、`.gitea`、BUG-1306、BUG-1308、Skill 10.0.33 正文。Home() 状态数没增加。
|
||||
|
||||
## 测试
|
||||
|
||||
Python 用 Anaconda `C:\Users\74082\anaconda3\python.exe`,`PYTHONHASHSEED=0`。本仓没有 `.venv`,没有往仓库里 pip install。
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `tests/test_rectification_nearest_boundary.py` | 4 项通过。公开 AA 盘覆盖段内、紧贴换段、窗口内无换段。默认分盘响应没有 `nearest_boundary`;带上新字段后与 `window_scan` 的最近换段一致 |
|
||||
| `tsc --noEmit` | 通过,退出码 0 |
|
||||
| `npm run lint` | 0 error,125 条 warning,都在原有文件上 |
|
||||
| 开场 / 文案合同 | `rectification-nearest-boundary-opening-20261012.test.ts`、`rectification-opening-plain-20260926.test.ts`、`agent-voice-copy-contract.test.ts` 共 31 项通过 |
|
||||
| 快速门 `--profile quick` | 编译、JSON、能力清单、片段审计、解释源清单、BPHS 不变量通过。pytest 1067 passed、1 skipped、1 failed。失败的是 `tests/test_rectification_v5_services.py` 里全天 block_scan 必须在 15 秒内完成,整门里用了 38.0 秒。这个文件没改。单独再跑通过。算这台机器在长套件里的负载,不算本单回归。门禁因此停在 pytest,没有接着跑它自己的 npm test / lint / build |
|
||||
| `CI=1` `npm test` | 4915 项,通过 4774,失败 100,cancelled 0。失败都落在没改过的文件上,见下 |
|
||||
|
||||
这 100 条是 Windows 基线,不是本单回归:没有 Docker 起不来 Postgres;没有创建符号链接的权限(Skill 运行时别名 `EPERM`);没有 WSL 的 bash;部分路径被拼成 `G:\G:\...` 因而找不到目录;迁移文件在这台机器上读出来的是路径而不是正文;一条打字拒绝测试的子进程退出码是 null(超时或被杀掉),没有进到本单改过的出题参数。本单新增的开场测试在这 4774 条里面。
|
||||
|
||||
`next build`:失败。Turbopack 报 `frontend/node_modules` 这个 junction 指到文件系统根外面。这是已知环境缺口,没有改 Next 配置,也没有删 junction。首屏 gzip 和 `/` 是否仍为 Static 因此没有在这台机器上量。
|
||||
|
||||
## 真机
|
||||
|
||||
`docs/testing/rectification-nearest-boundary-opening-20261012.md`。公开盘两档:19:01 是「不到 1 分钟」,19:04 是「差 3 分钟」。公开记录 19:15 在默认前后 15 分钟窗口里是「差 2 分钟」。窗口内不换那一句,界面选不出那么窄的窗,由自动化测试覆盖。
|
||||
|
||||
## F1 / F2(2026-10-12,修复单,未推送,未部署)
|
||||
|
||||
任务书 `TASK-rectification-1012-batch-fix-20261012.md` 的 F1、F2。做在本分支上。不标 resolved。77 例持久回放(`scripts/research/varga_resolution_persisted_replay.py`)没跑,也不把下面的对照说成那次回放通过。
|
||||
|
||||
### 证明
|
||||
|
||||
改函数之前,用测试里已有的公开盘请求(不带 `ayanamsa`)跑了一次 `scan_varga_segments`,响应写在仓库外:`D:\Users\74082\AppData\Local\Temp\rectification-varga-segments-omitted-ayanamsa-prechange.json`。改完之后同一请求与这份捕获逐字段相同。显式 `"raman"` 与省略相同,缓存键仍是 `58c936a2ccdbb5e339c79f9c43b990d556b627230cd5d5d200f3fcaaea85fad4`。同一窗口的 `lahiri` 换了缓存键,也换了星座。版本字符串仍是 `rectification-varga-segments-v1`。`node_mode` 仍是 mean,请求体不收这个字段。
|
||||
|
||||
打分、`decision_policy`、闸门、`scripts/jyotish_api_server.py` 都没改。
|
||||
|
||||
### 开场句
|
||||
|
||||
最近换段的层里有 `d1` 时,同一句先写「这里连本命上升都换」,再写其余人生事。没有其余事时整句停在这里。只有本命上升在换,不加「接下来先问这几件」。句数上限仍是 3。不换、不到 1 分钟、附近这些盘都不换、只知道大概时段,都保持原来的句子。
|
||||
|
||||
### 留下的一句
|
||||
|
||||
`frontend/src/lib/ayanamsa.ts` 的 `AYANAMSA_SWITCH_HINT` 仍写「生时校正目前仍固定按 Raman,之后的版本才会跟着这项设置走。」修复单没有授权改这句。`frontend/tests/ayanamsa.test.ts` 没改。设置页这句和现在的扫描行为不一致,留给后面的单。
|
||||
|
||||
### 既有断言
|
||||
|
||||
没有改既有断言,所以没有「原值 / 新值 / 原因」三栏。新测试加在 `tests/test_rectification_nearest_boundary.py` 和 `frontend/tests/rectification-nearest-boundary-opening-20261012.test.ts`。
|
||||
|
||||
### 这轮测试
|
||||
|
||||
| 项 | 结果 |
|
||||
| --- | --- |
|
||||
| `pytest tests/test_rectification_nearest_boundary.py tests/test_varga_segments_api.py -q --tb=line` | 22 通过(5 + 17),失败 0 |
|
||||
| `npx tsx --test tests/rectification-nearest-boundary-opening-20261012.test.ts` | 7 通过,失败 0,cancelled 0 |
|
||||
| `npx tsc --noEmit` | 通过,退出码 0 |
|
||||
|
||||
没跑 `next build`,没跑全量 `npm test`,没跑 77 例回放。
|
||||
@@ -1,32 +0,0 @@
|
||||
# PROGRESS · 校正提议在开案成功后才标已开始(BUG-1306 F3,2026-10-12)
|
||||
|
||||
分支 `codex/rectification-offer-opened-state-20261012`,基线 `b8bdc003`(`origin/staging`)。未 push,未部署。状态保持 fixed-pending-verify,不是 resolved。
|
||||
|
||||
## 结论
|
||||
|
||||
明确要求做校正时,结算记下的提议仍是普通提议:状态 `offered`,并带 `autoOpen: true`。卡片写价格和题数,按钮是「开始核对出生时间」,不说「已经开始」。浏览器仍按 `rectificationDirectOpen` 自动开案。只有开案成功、`bindOfferToCase` 绑定来源之后,状态才变成 `opened`,卡片才说「已经开始」。打开失败时记录保持 `offered`,卡片可以再点。没有第四种状态。已存的 `offered` / `opened` / `returned` 照常解析。`complete_consultation_free` 和五句用户文案没改。
|
||||
|
||||
## 实现
|
||||
|
||||
`finishExplicitRectificationRequest` 不再把结算记录写成 `opened`。`autoOpen` 是可选字段,只在为真时写入;旧记录省略它也能解析。刷新走 `parseRectificationOfferRecord`,不会丢掉这个字段。`persistOpenedDirectOffer` 仍把这条还没打开的记录挂到免费回复上。客户端新开仍调用 `openRectificationFromChat`,接着做仍调用 `openRectificationSession`。开案函数失败时返回空,调用处不把本地记录改成 `opened`。成功仍由原来的绑定路径改状态。
|
||||
|
||||
## 既有断言
|
||||
|
||||
| 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- |
|
||||
| `rectification-explicit-entry-20261012.test.ts`「explicit pass…」两处 `deepEqual` 期望 `offer("opened")` | `offer("offered", { autoOpen: true })` | F3:结算保持 offered,用 autoOpen 表示这一轮要自动开案;开案绑定后才是 opened |
|
||||
| 同文件「explicitRequest bypasses…」`rectificationOffer.status` 期望 `"opened"` | `"offered"`,且 `autoOpen === true` | 同上。直达入口 `kind: "new"` 不变 |
|
||||
| 同文件直达流「run.completed」`rectificationOffer.status` 期望 `"opened"` | `"offered"`,且 `autoOpen === true`;`rectificationDirectOpen.mode` 仍是 `"new"` | 完成事件转发的是结算记录,不能再预写 opened |
|
||||
| 同文件两处流式夹具把 `state.rectificationOffer` 设成 `offer("opened")` | `offer("offered", { autoOpen: true })` | 夹具要模拟现在的结算记录,否则事件仍像开案前就已经 opened |
|
||||
|
||||
`rectification-in-chat-20261007.test.ts` 里原来的期望值没有改。普通提议仍是没有 `autoOpen` 的 `offered`。无 caseId 的旧 `opened` 记录仍能绑定。
|
||||
|
||||
## 验证
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `npx tsx --test tests/rectification-explicit-entry-20261012.test.ts` | 16 通过,0 失败,cancelled 0 |
|
||||
| `npx tsx --test tests/rectification-in-chat-20261007.test.ts` | 15 通过,0 失败,cancelled 0 |
|
||||
| `npx tsc --noEmit`(frontend) | 0 错 |
|
||||
|
||||
没有跑 `next build`(Turbopack 拒绝 `node_modules` junction)。没有跑全量 `npm test`。没有 `npm install`,没有删 junction。`Home()` 没有改,`page.tsx` 没有改。
|
||||
@@ -124,10 +124,9 @@
|
||||
| `TASK-rectification-mobile-timeline-readout-20260917.md` | `PROGRESS-rectification-mobile-timeline-readout-20260917.md` | 手机截图:时间轴读数第四项被裁成「已…」(nowrap + inset 内边距,BUG-918);「跳到最新」浮层压住选项 C(BUG-919)。灰卡与「再说一件」矛盾归修复单 BUG-915/917 | 已验收 | `e8e98bbd`(合入时重放为本分支提交,BUG-918/919)。时间轴相关 71 条 + 全量同上;手机上隐藏「已对照 N 件」符合 DESIGN §10;桌面是否也移除待产品拍板。真机清单 `docs/testing/rectification-mobile-timeline-readout-20260917.md` |
|
||||
| `TASK-mobile-viewport-scroll-lock-20260917.md` | `PROGRESS-mobile-viewport-scroll-lock-20260917.md` | iPhone 上键盘收起 / 刷新后整页上移、顶栏点不到(BUG-920):iOS 不支持 `interactive-widget`,键盘弹出时 Safari 滚动 window,`html/body overflow: hidden` 让用户拉不回来,reload 又还原 `scrollY`;代码里无任何 window 级复位。补 `ViewportScrollLock`(`scrollRestoration=manual` + `visualViewport` 复位)。与今天两单无关,既有缺陷。**T3 顶栏积分块「有点扁」(BUG-921)**:两枚芯片 44px 塞在 46px 顶栏里、字号/内边距/圆角各一套、积分块强制 64px 最小宽度;统一尺寸并把手机顶栏放到 52px | 已合入(无独立验收记录) | `1b508d5e`(BUG-920/921 resolved;已部署 staging,run 2715);真机待核 |
|
||||
| `TASK-rectification-session-composer-guard-20260917.md` | `PROGRESS-rectification-session-composer-guard-20260917.md` | 校正会话激活但校正面未开(刷新揭幕超时 / 打开失败不重试 / 删除与 popstate 回退落到列表第一条)时露出可用的普通输入框,问题发到 `/api/consult` 回 404「咨询会话不存在」;`send()` 无会话类型守卫;服务端无独立错误码。关联 BUG-505。BUG 段 924 起 | 已验收 | `6d81062b`(Claude 验收:T1–T5 全通过;409 `session_not_consultation` 在扣点前、不扣点;回退路径多修了归档一条;page.tsx 1830→1830。残留 P3:列表只剩校正会话时 `activeSession` 为空,输入框可用但发送静默无效 → BUG-935 在 fix 单);真机三条欠 |
|
||||
| `TASK-rectification-explicit-entry-20261012.md` | `PROGRESS-rectification-explicit-entry-20261012.md` | **明说要校正就直接进入(BUG-1306)**:模型判断意图(禁正则),服务端判定后直接开案并绑定来源对话;首轮排盘合同对此轮豁免;拒绝必须一句话说明(推翻 BUG-1248 静默两条);普通对话不得假装校正、申报时段不写子运起止。**先做** | 已部署 staging `b8bdc003`;F3 跟进随 10-12 批次合入,真机欠 | — |
|
||||
| `TASK-rectification-nearest-boundary-opening-20261012.md` | `PROGRESS-rectification-nearest-boundary-opening-20261012.md` | **最近分界开场(BUG-1307)**:开场说记录时间离最近换段多近、两侧哪些事不同。F1 段扫描按用户岁差(BUG-1309)、F2 本命上升换段要先说「这里连本命上升都换」,都在本分支,待验收。选题开关默认关(77 例未跑)。不改打分 / 闸门 / 4 件 3 域。未改 page.tsx | 已验收,随 10-12 批次合入 staging,真机欠 | `ce4e9803` |
|
||||
| `TASK-rectification-d12-parent-probes-research-20261012.md` | `PROGRESS-rectification-d12-parent-probes-research-20261012.md` | **父母静态是非题(D12)研究(BUG-1308)**:公开案例 ≥60、预登记、乱序与正对照,过门才立实现单;纯研究可并行 | 方向暂停,BUG-1308 closed_by_design | `docs/research/rectification_d12_parent_probes_2026_10_12.md` |
|
||||
| `TASK-rectification-1012-batch-fix-20261012.md` | `PROGRESS-rectification-nearest-boundary-opening-20261012.md` | **10-12 三单验收修复**:F1 开场与段汇总按用户岁差(BUG-1307 / BUG-1309)、F2 本命上升换段开场句,在分支 `codex/rectification-nearest-boundary-opening-20261012`,待验收。F3 打开失败卡片仍说已开始(BUG-1306)未做。F4 研究报告补编码问题与暂停结论(BUG-1308)未做 | F1–F4 已验收合入 staging;另 BUG-1310 岁差说明同批修 | — |
|
||||
| `TASK-rectification-explicit-entry-20261012.md` | `PROGRESS-rectification-explicit-entry-20261012.md` | **明说要校正就直接进入(BUG-1306)**:模型判断意图(禁正则),服务端判定后直接开案并绑定来源对话;首轮排盘合同对此轮豁免;拒绝必须一句话说明(推翻 BUG-1248 静默两条);普通对话不得假装校正、申报时段不写子运起止。**先做** | 待验收 | — |
|
||||
| `TASK-rectification-nearest-boundary-opening-20261012.md` | — | **最近分界开场(BUG-1307)**:开场说记录时间离最近分盘换段点多近、两侧哪些层不同,近分界时首轮只问该分界;不改打分 / 闸门 / 4 件 3 域,77 例回放不降;改 page.tsx / 校正开场组件的部分须在 BUG-1306 合入后 | 待领取 | — |
|
||||
| `TASK-rectification-d12-parent-probes-research-20261012.md` | — | **父母静态是非题(D12)研究(BUG-1308)**:公开案例 ≥60、预登记、乱序与正对照,过门才立实现单;纯研究可并行 | 待领取 | — |
|
||||
|
||||
### 聊天主链路与首页
|
||||
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# TASK · 生时校正 10-12 三单验收修复(2026-10-12)
|
||||
|
||||
- 基线:`origin/staging` @ `e6baff95`(三条功能分支都从这里分出)。
|
||||
- 受影响分支:
|
||||
- `codex/rectification-explicit-entry-20261012` @ `6a799582`(BUG-1306):**验收通过**,本单 F3 是合入后的跟进项。
|
||||
- `codex/rectification-nearest-boundary-opening-20261012` @ `b285011a`(BUG-1307):**未通过**,F1 修完才能合入。
|
||||
- `codex/rectification-d12-parent-probes-research-20261012` @ `11fee0a3`(BUG-1308):研究结论成立,F4 补齐文档后可合入。
|
||||
- 串行顺序:explicit-entry 先合入 staging(由产品点头)→ 把 F1/F2 做在 nearest-boundary 分支上,然后在 explicit-entry 之上变基(试合只冲突 `CHANGELOG.md`、`docs/tasks/README.md`,都是文档)→ F3 从合入后的 staging 新开分支。F4 与上述并行。
|
||||
- 合入 staging 一律等产品明说,执行方只推各自的功能分支。
|
||||
|
||||
## 1. 验收实证
|
||||
|
||||
| 分支 | 门禁 | 结论 |
|
||||
| --- | --- | --- |
|
||||
| explicit-entry | tsc 0;lint 0 error / 125 warning(同基线);npm test 5013 条,失败 25(24 条同基线,另 1 条 `consult-evidence-lookup-20260927`「a lookup does not reset the answer clock」是负载下计时抖动,单独跑 3/3 通过);build `/` Static;gzip +0.02%;page.tsx 未增长 | 通过;T0 日志取证、T5 模型实测为环境缺口 |
|
||||
| nearest-boundary | tsc 0;lint 同基线;npm test 5006 条,失败 25 逐名同基线;build Static;gzip 0%;Python 定向 136 passed;打分 / decision_policy / 闸门 / api_server diff 为空 | 门禁过,**F1 缺陷不过** |
|
||||
| d12 research | 15 测试过;结果 JSON 两次复跑逐字节一致(除 Python 版本号);预登记 `ad89e410` 早于打分 `11fee0a3`,其后 0 改动;冻结身份 / 隐私 94 项绿 | 结论「欠力」成立;下一步与编码问题未写进报告 |
|
||||
|
||||
## 2. 缺陷与根因
|
||||
|
||||
**F1(P1,BUG-1307)开场分界按 Raman 算,引擎按用户岁差算。**
|
||||
`scripts/rectification_varga_segments_api.py` 三处写死 `"ayanamsa": "raman"`(profile、逐分钟 `compute_chart`、payload);`frontend/src/lib/rectification-agentic/opening-boundary.ts::fetchOpeningBoundary` 请求体不带岁差。校正引擎打分用 `resolveAyanamsa(snapshot)`(`v9/engine-client.ts`)。分界随岁差移动:私人样例一例里 Raman 与 Lahiri 的分界相差约 7 分钟,分界落在记录时间两侧。结果:非 Raman 用户开场说的「差几分钟、两边哪里不同」与引擎实际扫描矛盾;选题开关打开后更会自相矛盾。
|
||||
任务书 `TASK-rectification-nearest-boundary-opening-20261012` 没有写岁差要求,是任务书漏写,不是执行方违规。
|
||||
|
||||
**F1b(P2,新 BUG-1309)同一端点给盘型段汇总用时也写死 Raman。**
|
||||
`v9/segment-scan.ts`(BUG-1115 那批)调同一个 `/api/rectification/v5/varga-segments`,同样不传岁差。非 Raman 用户的交付卡 D9 / D10 段与档位按 Raman 算,引擎打分按用户岁差。这是 staging 现存问题,F1 改端点时一并改。
|
||||
|
||||
**F2(P2,BUG-1307)本命上升也换段时开场不说。**
|
||||
开场只列「婚恋、住处、学业、子女、父母、钱、身体」;D1 也换时这是最大的变化,却不提。
|
||||
|
||||
**F3(P3,BUG-1306 跟进)打开失败时卡片仍说「已经开始」。**
|
||||
提议记录在服务器结算时就写成 `opened`,早于浏览器真正开案;开案失败(断网、冲突)时对话显示「出生时间核对已经开始。」。点卡片「继续核对」可重开,不卡死。
|
||||
|
||||
**F4(文档,BUG-1308)研究报告缺下一步与编码问题。**
|
||||
6 例计入中 3 例编码站不住:奥巴马「母亲照料更多=是」引文只讲上哪所小学;伊梅尔达·马科斯「父亲体制内=是」引文是自己执业律师;索菲亚·罗兰「父亲体制内=是」引文是国家铁路临时工作(预登记规定短期任职不算主业,赫本父亲名誉领事却记「不清楚」,尺度不一)。另李小龙「父亲指导学业=是」依据是教太极(未计入)。维基百科计入率约 8%,凑满预登记的 60 例需 700+ AA 例,现有数据做不到;岁差改判 36–42%(门槛 20%)。
|
||||
|
||||
## 3. 决策记录
|
||||
|
||||
1. F1:开场与段汇总一律用 `resolveAyanamsa(snapshot)`,与打分同源;岁差进缓存键。不新增「强制选岁差」(产品 10-03 已拒)。
|
||||
2. F2(Claude 定口径):D1 也换段时,开场先说「这里连本命上升都换」,再说其余会变的事;其余不变。
|
||||
3. F3:结算时提议记录写 `opening`(或保持 `offered` 并带 `autoOpen` 标记),浏览器开案成功、来源绑定后才改 `opened`;失败时卡片按 `offered` 显示,可点开。具体状态名由执行方定,须向后兼容已存的 `offered` / `opened` / `returned`。
|
||||
4. F4:本方向**暂停**,不立实现单。报告写明:继续的唯一路线是换数据源(传记 / 访谈)+ 双人独立编码 + 重新预登记;且岁差敏感 36–42% 本身使过门可能性很低。BUG-1308 状态改 `closed_by_design`(研究欠力,方向暂停)。
|
||||
|
||||
## 4. 硬红线
|
||||
|
||||
1. 不改打分、`decision_policy`、闸门、4 件 3 域、`scripts/jyotish_api_server.py`;不改迁移。
|
||||
2. F1 后 v5 77 例(全 Raman)的段汇总与回放结果必须与改前逐字节一致——岁差参数缺省值仍为 raman。
|
||||
3. 用户文案不得出现秒级、「记录时间是错的 / 不准」。
|
||||
4. 改既有断言写「原值 / 新值 / 原因」三栏;文档同提交更新(BUG_HISTORY、CHANGELOG、VOICE、DESIGN)。
|
||||
5. 不得写入真实用户出生资料。
|
||||
|
||||
## 5. 任务分解
|
||||
|
||||
| 项 | 内容 | 验收 |
|
||||
| --- | --- | --- |
|
||||
| F1 | 端点收 `ayanamsa`(白名单,与 `resolveAyanamsa` 取值一致,缺省 raman),进 profile、`compute_chart`、payload、缓存键;`fetchOpeningBoundary` 与 `segment-scan.ts` 传 `resolveAyanamsa(snapshot)` | Python 测试:同窗口 raman / lahiri 分界不同且缓存键不同;缺省等于显式 raman;前端测试:两处调用的请求体带用户岁差;v5 回放逐字节一致 |
|
||||
| F2 | D1 换段时开场句式 | 前端测试:D1 换 / 不换两种文案;VOICE 记录 |
|
||||
| F3 | 提议状态在开案成功后才转 `opened` | 前端测试:开案失败后卡片可再开、文案不说「已经开始」;旧会话三种状态照常显示 |
|
||||
| F4 | 报告与 BUG-1308 补「编码问题清单 + 下一步二选一结论 + 暂停」 | 文档;`tests/test_repo_privacy_markers.py` 过 |
|
||||
|
||||
## 6. 让步顺序
|
||||
|
||||
F1 不可让。F2 可延到下一轮(写进 BUG-1307 已知限制)。F3 可单独排期。F4 不可让(不写清下一步,研究等于没收口)。
|
||||
|
||||
## 7. 开工前置
|
||||
|
||||
```
|
||||
git fetch origin --prune
|
||||
git fetch origin +refs/heads/codex/rectification-nearest-boundary-opening-20261012:refs/remotes/origin/codex/rectification-nearest-boundary-opening-20261012
|
||||
git fetch origin +refs/heads/codex/rectification-d12-parent-probes-research-20261012:refs/remotes/origin/codex/rectification-d12-parent-probes-research-20261012
|
||||
git status -sb
|
||||
```
|
||||
|
||||
在原 worktree 续做(或按同名分支新建 worktree);F3 待 explicit-entry 合入后从 `origin/staging` 开 `codex/rectification-offer-opened-state-20261012`。
|
||||
|
||||
## 8. BUG 编号
|
||||
|
||||
F1、F2 记入 BUG-1307;F3 记入 BUG-1306;F4 记入 BUG-1308;F1b 新建 **BUG-1309**(开工时核对最大号,写作时 1308)。
|
||||
@@ -1,52 +0,0 @@
|
||||
# 生时校正:开场说最近一次换段 · 真机清单(2026-10-12,BUG-1307)
|
||||
|
||||
用公开盘,不要用真实用户的出生资料。盘是 Steve Jobs,1955-02-24,旧金山(纬度 37.77、经度 -122.42、西八区)。下面的钟点是为了走两种开场故意填的,不是在改这份公开记录。
|
||||
|
||||
校正开场按这张盘的候选窗口扫描,岁差固定 Raman、交点固定平均。星盘页如果选了别的岁差,这一句仍按 Raman 算。出生证界面写「前后 2 分钟」,校正实际开窗是前后 15 分钟。开场里「现在先在哪一段之间找」就是这个窗口。
|
||||
|
||||
选题开关默认关。中间那句说到句号为止,不出现「接下来先问这几件」。第一题仍按原来的顺序,不会因为提到学业或父母就提前。
|
||||
|
||||
扫描接口没连上时,开场仍是原来的两句。那是退路。先确认校正服务可用,再照下面走。
|
||||
|
||||
## 1. 紧贴换段
|
||||
|
||||
1. 新建这个人,申报方式选「有出生证或医院记录,精确到分钟」,钟点填 19:01。
|
||||
2. 明确说要做生时校正,进入校正。
|
||||
3. 开场正文三句。第一句里的窗口是 18:46–19:16。中间这一句逐字是:
|
||||
|
||||
你填的时间离最近会换的地方不到 1 分钟,两边不一样的是学业、父母。
|
||||
|
||||
4. 不应出现:秒、分盘、盘面、大运、区间、精确到秒、记录时间错了、记录时间不准、已确认、接下来先问这几件。
|
||||
5. 接着答一两件带年月的经历。下一题按原来的收集顺序来,不因为这句提到学业、父母就被换掉。
|
||||
|
||||
## 2. 这张盘上离得最远的一档
|
||||
|
||||
同一人,钟点改成 19:04,申报仍用出生证(窗口同样是前后 15 分钟,18:49–19:19)。「家人记得大概时间」再选「差不多准」也是这 15 分钟。
|
||||
|
||||
中间这一句逐字是:
|
||||
|
||||
你填的时间离最近会换的地方差 3 分钟,两边不一样的是学业、父母。
|
||||
|
||||
这张盘在这段傍晚里,离换段最远就是 3 分钟。这里的「远离」是量到的最大间隔,不是一个很宽的空档。
|
||||
|
||||
## 3. 顺手核对公开记录钟点
|
||||
|
||||
钟点用公开记录 19:15,出生证或「差不多准」都是 19:00–19:30。中间这一句逐字是:
|
||||
|
||||
你填的时间离最近会换的地方差 2 分钟,两边不一样的是婚恋、住处、学业、子女、父母、钱、身体。
|
||||
|
||||
没有「工作」。本命上升也换,这句话不提。
|
||||
|
||||
## 4. 只知道大概时段
|
||||
|
||||
申报方式选「只知道大概时段,或完全不知道」。开场不加中间那句,仍是原来两句:先说在哪一段里找,再说你讲几件大事和大概年月。
|
||||
|
||||
## 5. 窗口里看不到换段
|
||||
|
||||
这句话是:
|
||||
|
||||
你填的时间附近这些盘都不换,先从经历开始。
|
||||
|
||||
校正开窗至少前后 15 分钟,而这张盘几分钟就换一次,界面上选不出这么窄的窗口。这一句由自动化测试覆盖:只扫描 19:01–19:06、记录钟点 19:04,结果是窗口内没有换段,文案就是上面这一句。
|
||||
|
||||
有任何一步和上面不一样,记下是第几步,以及开场实际写了什么。不要把真实用户的出生时间写进记录。
|
||||
+3
-5
@@ -11,7 +11,7 @@
|
||||
|
||||
不新增等待动画;打开校正沿用既有一次揭幕。
|
||||
|
||||
**明确要求则直接打开(2026-10-12,BUG-1306)**:用户这一轮明说要做校正时,不再先出卡再点。回答只留一句过渡,界面沿用既有一次揭幕直接打开校正面。没有新的按钮、输入框、等待动画或 Home 状态。推得不准时的主动提议仍用 `.rectification-offer-card`。做不了时只显示一句话,不静默,也不在对话里假装校正。开案还没成功时,同一张提议卡仍是普通提议(不说「已经开始」,按钮仍是「开始核对出生时间」)。开案成功并绑定来源后才换成已开始的文案。打开失败时按钮仍可点。
|
||||
**明确要求则直接打开(2026-10-12,BUG-1306)**:用户这一轮明说要做校正时,不再先出卡再点。回答只留一句过渡,界面沿用既有一次揭幕直接打开校正面。没有新的按钮、输入框、等待动画或 Home 状态。推得不准时的主动提议仍用 `.rectification-offer-card`。做不了时只显示一句话,不静默,也不在对话里假装校正。
|
||||
|
||||
## 用量页分段计时(2026-10-05,在途)
|
||||
|
||||
@@ -64,9 +64,7 @@ This file adapts the full visual analysis in `CLAUDE_DESIGN.md` to the shipped J
|
||||
|
||||
## 校正开场与步骤回执(2026-09-26,BUG-1049 / BUG-1050)
|
||||
|
||||
开场一轮只有两段字:正文讲做法(要把出生时间缩小到更准的范围、现在先在哪段时间里找;你说几件大事和大概年月,我拿去和星盘对照),下面是问题块里的题干(六个例子 + 一个带年月的回答示例)。题干只出现一次:正文里和题干逐句相同或几乎相同的句子才会被去掉;只是开头几个字一样、内容不同的句子保留(以前按前 12 个字判定,把正文里的例子句删掉了)。
|
||||
|
||||
2026-10-12(BUG-1307):服务端已经量过换段时,正文在原来两句中间多一句,说明填的时间离最近会换的地方差几分钟、两边哪几件事不同。本命上升也换时,同一句先说「这里连本命上升都换」,再写其余会变的事;没有其余的事时,这一句就停在「这里连本命上升都换。」,不另起一句。没有新组件、没有新动效。句数上限仍是 3。只知道大概时段、这段时间里不换、或扫描没做成时,口径见 VOICE 同一节。选题顺序的开关默认关,开场不承诺「接下来先问这几件」。只有本命上升在换时也不说这句。
|
||||
开场一轮只有两段字:正文两句讲做法(要把出生时间缩小到更准的范围、现在先在哪段时间里找;你说几件大事和大概年月,我拿去和星盘对照),下面是问题块里的题干(六个例子 + 一个带年月的回答示例)。题干只出现一次:正文里和题干逐句相同或几乎相同的句子才会被去掉;只是开头几个字一样、内容不同的句子保留(以前按前 12 个字判定,把正文里的例子句删掉了)。
|
||||
|
||||
> 2026-10-01 修订:校正回复结算后不再显示步骤回执(BUG-1137),本段只适用于写作中的步骤行。
|
||||
|
||||
@@ -370,7 +368,7 @@ The self-profile editor in 星盘资料 (`ChartLibraryPanel`) owns the ayanamsa
|
||||
|
||||
- **Structure:** six 44px stacked radios reused from `.theme-preference-option`, plus a caption under the group. Raman is marked 默认. Each option has a one-line description; selected rows show the same `Check` as the theme panel.
|
||||
- **Values (2026-09-28):** `raman` (default) / `lahiri` / `kp` / `true_citra` / `true_pushya` / `fagan_bradley`, shown as Raman(默认)/ Lahiri / KP / True Citra / True Pushya / Fagan-Bradley. Only values the engine already supports (`scripts/ayanamsa_utils.py`); Djwhal Khul and Sassanian are supported by the engine but deliberately not offered. The label is spelled True Citra, matching the engine's `ayanamsa_display` that the chart page parameter table shows. Copy lives in `frontend/src/lib/ayanamsa.ts`; `profiles_ayanamsa_check` lists the same six.
|
||||
- **Hint:** “之后的咨询、报告、星盘库、每日星语和新开的生时校正会按新岁差算;已经开始的那次校正仍按开始时的岁差。”(BUG-1310)
|
||||
- **Hint:** “之后的咨询、报告、星盘库和每日星语会按新岁差算。生时校正目前仍固定按 Raman,之后的版本才会跟着这项设置走。”
|
||||
- **Placement:** after `ProfileFields` on the self-profile form only. Not on other-chart save, not on `page.tsx`.
|
||||
- **States:** default, hover, selected (`aria-checked` only), keyboard focus. Switching does not clear a confirmed birth minute.
|
||||
|
||||
|
||||
+3
-13
@@ -26,10 +26,10 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
## 生时校正从对话进入(2026-10-07,BUG-1247~1251;2026-10-12,BUG-1306)
|
||||
|
||||
- **何时提议**:用户说推得不准时先分清读法问题和时间问题。只是不认同某个解释,按纠正规则认下、改读,不提校正。不承诺能收窄出生时间,不在对话里收集经历做校正。说出过去的具体事情和盘对不上、说出生时间是估的或记不准、但没有明确要做校正,才调用提议工具。同一次对话提议过、用户没接,不再主动提;用户自己再要求时传 `userAsked=true`。规则只定义在 `consultation-thinking-plan.ts` 的 `RECTIFICATION_OFFER_RULE`。不用关键词判断。
|
||||
- **明确要求则直接进入**:用户这一轮明确要做校正,由模型判断,不用关键词。服务端放行后直接打开校正:不先写解读,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。开案还没成功时,回答下面仍是普通提议卡,不说「已经开始」;打开失败可以再点「开始核对出生时间」。开案并绑定来源之后才改成「已经开始」。
|
||||
- **明确要求则直接进入**:用户这一轮明确要做校正,由模型判断,不用关键词。服务端放行后直接打开校正:不先写解读,不用点卡。过渡只有一句「好,我们开始核对出生时间。」已经有一次没做完的,直接接着做那一次,不新开。
|
||||
- **做不了必须说一句**:开关关着,或暂时没有价格,都说「出生时间核对这会儿还做不了。」不写内部原因,不写价格,不得沉默。家人档案说「给家人校正暂时还不支持」,不开案。有进行中的校正但这次打不开原来的会话,说「可在历史对话里接着做。」
|
||||
- **正文怎么提**(没有明确要求、只是时间可能不准):一句,放在最后一段(例:「如果想确认出生时间准不准,可以用下面的卡片,答几道过去经历的题核对一下。」),不写价格和题数——卡片上写。
|
||||
- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始(开案并绑定来源之后,不是结算当时)「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。
|
||||
- **卡片**:「可以用几道过去经历的题,核对一下你的出生时间。大约 N 题,会用掉 M 点;开始答第一题时才扣。」按钮「开始核对出生时间」;已开始「出生时间核对已经开始。」/「继续核对出生时间」。点数来自 `feature_pricing`,不写死。
|
||||
- **回来**:分隔线「出生时间改为 HH:MM(生时校正)」/「出生时间没有改」;之前的回答上方「按校正前的时间」;按钮「按新时间重新看:「原问题」」。校正面按钮「回到刚才的对话」。不再说「新建对话」。
|
||||
|
||||
## 生时校正服务器固定句(2026-10-07,BUG-1258)
|
||||
@@ -135,16 +135,6 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
- 题干里的「2015 年夏天换了工作」是固定示例,不是从生日推出来的;「不得用生日推年份」照旧适用于其他题干。
|
||||
- 开场不再说「最后给区间和代表分钟,不给精确到秒」。「这只是代表性候选,不是已确认的唯一出生分钟。」仍写在交付卡上。
|
||||
|
||||
开场可以在这两句中间再加一句(2026-10-12,BUG-1307)。句数上限仍是 3,没有放宽。这一句由服务端按扫描结果原样放进去,模型不现编:
|
||||
|
||||
- 记录了钟点,而且这段时间里有换段:`你填的时间离最近会换的地方不到 1 分钟,两边不一样的是父母、工作。` 不足 1 分钟写「不到 1 分钟」,满 1 分钟写「差 N 分钟」。两边只用这些词:婚恋、工作、住处、学业、子女、父母、钱、身体。没有对应的事,就不写「两边不一样」。
|
||||
- 本命上升也在这次换段里:同一句先写「这里连本命上升都换」,再写两边不一样的事。例如「你填的时间离最近会换的地方差 4 分钟,这里连本命上升都换,两边不一样的是工作、父母。」没有别的事时是「你填的时间离最近会换的地方不到 1 分钟,这里连本命上升都换。」仍是一句,不另起一句。只有本命上升在换时,不说「接下来先问这几件」。
|
||||
- 选题开关默认关。关着的时候这一句到句号为止,不说「接下来先问这几件」。开关打开、离得近、又有这几件事时,句号才换成「,接下来先问这几件。」
|
||||
- 这段时间里都不换:`你填的时间附近这些盘都不换,先从经历开始。`
|
||||
- 只知道大概时段,或扫描没做成:不加这一句,仍是原来两句。
|
||||
|
||||
这一句不用:秒、分盘、盘面、大运、区间、精确到秒、记录时间错了、记录时间不准、已确认、D1。
|
||||
|
||||
回答上方的步骤名(完成 / 进行中)一律大白话,同一个名字只出现一次:
|
||||
|
||||
| 工具 | 完成名(旧 → 新) | 进行中句(新) |
|
||||
@@ -310,7 +300,7 @@ Jyotisha 的可见文案是产品的一部分。正确性红线(真实性、
|
||||
| 坏 | 好 | 为什么 |
|
||||
|---|---|---|
|
||||
| Rahu 大运为 [具体时间已省略] 至 [具体时间已省略]。 | Rahu 大运为 2013 年 11 月中下旬至 2031 年 11 月中下旬。按范围看,不写成单一分钟。 | 出生范围用户要拿到区间说法;算出来的边界不是要删的字。 |
|
||||
| 眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。(题干:先说你最容易想起的一两件,年月大概就行。) | 我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。(题干:先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。)服务端量到换段时,可以在这两句中间加一句,例如「你填的时间离最近会换的地方差 5 分钟,两边不一样的是父母、工作。」 | 开场正文用大白话讲做法,不用大运、盘面、代表分钟、精确到秒这类还没解释过的词,不写年份、不提问;例子和回答示例放在题干里,只出现一次(BUG-1049)。2026-10-12 起中间那句仍算在三句里面,不说秒,开关关着时不说接下来先问。本命上升也换时,这一句先写「这里连本命上升都换」,再写其余的事;只有本命上升在换时仍不说接下来先问(BUG-1307)。 |
|
||||
| 眼下按 04:45–05:15 来核对,用你记得的经历对照大运和盘面变化。最后给区间和代表分钟,不给精确到秒。(题干:先说你最容易想起的一两件,年月大概就行。) | 我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。(题干:先说一两件你记得的大事,比如上大学、第一份工作、搬到别的城市、谈恋爱或结婚、家里添丁、生病住院。说个大概年月就行,例如「2015 年夏天换了工作」。) | 开场正文两句大白话讲做法,不用大运、盘面、代表分钟、精确到秒这类还没解释过的词,不写年份、不提问;例子和回答示例放在题干里,只出现一次(BUG-1049)。 |
|
||||
| (服务端探针 2023 感情)2023 年前后,你有没有一段认真开始或结束的关系?逐字复读模板。 | 你刚才说到工作这块已经比较清楚了。2023 年前后,有没有一段认真开始或结束的关系? | 接着用户上一句,同年份同家族,不复读模板。 |
|
||||
| (服务端探针 2016 升学)2018 年你是哪年上的大学? | 2016 年前后,你是哪年上的大学? | 不得改年份。 |
|
||||
| (服务端探针 2019 入职)2019 年前后,你有没有换过工作?A. 明确发生且时间吻合 | 2019 年前后,你有没有换过工作? | 题干不写选项字面;选项由服务端画在同一条消息里。 |
|
||||
|
||||
@@ -26,8 +26,6 @@ export function RectificationOfferCard({
|
||||
onStart,
|
||||
}: Readonly<{ offer: RectificationOfferRecord; disabled: boolean; onStart: () => void }>) {
|
||||
if (offer.status === "returned") return null;
|
||||
// 「已经开始」 / 继续核对 only after the case is bound. autoOpen still shows
|
||||
// the ordinary offer and 「开始核对出生时间」 until status is opened.
|
||||
const opened = offer.status === "opened";
|
||||
return (
|
||||
<div className="rectification-offer-card" role="group" aria-label="核对出生时间">
|
||||
|
||||
@@ -156,7 +156,7 @@ export type ConsultationRunParams = {
|
||||
setOnboardingPaywallOpen?: (open: boolean) => void;
|
||||
openRectificationSession: (exactSessionId: string) => Promise<void> | void;
|
||||
/** Direct entry from this consultation (BUG-1306). Same opener as the offer card. */
|
||||
openRectificationFromChat: (sourceSessionId: string) => Promise<unknown> | void;
|
||||
openRectificationFromChat: (sourceSessionId: string) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function useConsultationRun(params: ConsultationRunParams) {
|
||||
@@ -1077,13 +1077,7 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
if (rectificationDirectOpen?.mode === "resume" && rectificationDirectOpen.resumeSessionId) {
|
||||
void openRectificationSession(rectificationDirectOpen.resumeSessionId);
|
||||
} else if (rectificationDirectOpen?.mode === "new") {
|
||||
// Stored offer stays offered. bindOfferToCase marks opened only after
|
||||
// the case opens. A failed open must not mark it opened here.
|
||||
void Promise.resolve(openRectificationFromChat(rectificationDirectOpen.sourceSessionId)).then((opened) => {
|
||||
if (opened) return;
|
||||
}, () => {
|
||||
// The opener threw. Leave the stored offer offered.
|
||||
});
|
||||
void openRectificationFromChat(rectificationDirectOpen.sourceSessionId);
|
||||
}
|
||||
try {
|
||||
await persistSession(completedSession);
|
||||
|
||||
@@ -369,8 +369,8 @@ export function useRectificationSurface(params: RectificationSurfaceParams) {
|
||||
|
||||
/** C4: the offer card under a consultation answer starts (or resumes) the case. */
|
||||
async function openRectificationFromChat(sourceSessionId: string) {
|
||||
if (rectificationLimitedToSelf()) return null;
|
||||
return openRectificationCase("homepage", null, sourceSessionId);
|
||||
if (rectificationLimitedToSelf()) return;
|
||||
await openRectificationCase("homepage", null, sourceSessionId);
|
||||
}
|
||||
|
||||
async function openRectificationSession(exactSessionId: string) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export const AYANAMSA_OPTIONS = [
|
||||
}>;
|
||||
|
||||
export const AYANAMSA_SWITCH_HINT =
|
||||
"之后的咨询、报告、星盘库、每日星语和新开的生时校正会按新岁差算;已经开始的那次校正仍按开始时的岁差。";
|
||||
"之后的咨询、报告、星盘库和每日星语会按新岁差算。生时校正目前仍固定按 Raman,之后的版本才会跟着这项设置走。";
|
||||
|
||||
export function isAyanamsaName(value: unknown): value is AyanamsaName {
|
||||
return typeof value === "string" && (AYANAMSA_VALUES as readonly string[]).includes(value);
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* Nearest divisional-chart change, from window-scan transitions only.
|
||||
* Question order is a switch and stays off unless the env flag is "on".
|
||||
*/
|
||||
|
||||
export const CHART_LAYERS = ["d9", "d10", "d4", "d5", "d24", "d7", "d12", "d2", "d11", "d30", "d1"] as const;
|
||||
|
||||
const LAYER_DOMAIN: Readonly<Record<string, string>> = {
|
||||
d9: "relationship",
|
||||
d10: "career",
|
||||
d4: "relocation",
|
||||
d5: "education",
|
||||
d24: "education",
|
||||
d7: "family",
|
||||
d12: "family",
|
||||
d2: "finance",
|
||||
d11: "finance",
|
||||
d30: "health_pressure",
|
||||
};
|
||||
|
||||
const LAYER_TOPIC: Readonly<Record<string, string>> = {
|
||||
d9: "婚恋",
|
||||
d10: "工作",
|
||||
d4: "住处",
|
||||
d5: "学业",
|
||||
d24: "学业",
|
||||
d7: "子女",
|
||||
d12: "父母",
|
||||
d2: "钱",
|
||||
d11: "钱",
|
||||
d30: "身体",
|
||||
};
|
||||
|
||||
/** Not adopted. The 77-case gate was not cleared, so the switch stays off. */
|
||||
export const NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES = 5;
|
||||
|
||||
export type BoundaryLayer = Readonly<{
|
||||
layer: string;
|
||||
fromSign: string;
|
||||
toSign: string;
|
||||
topic: string | null;
|
||||
domain: string | null;
|
||||
}>;
|
||||
|
||||
export type NearestBoundary = Readonly<{
|
||||
found: boolean;
|
||||
reason: "unavailable" | "no_transition" | null;
|
||||
at: string | null;
|
||||
distanceMinutes: number | null;
|
||||
layers: readonly BoundaryLayer[];
|
||||
topics: readonly string[];
|
||||
domains: readonly string[];
|
||||
}>;
|
||||
|
||||
const EMPTY: NearestBoundary = {
|
||||
found: false, reason: "unavailable", at: null, distanceMinutes: null, layers: [], topics: [], domains: [],
|
||||
};
|
||||
|
||||
function clock(value: string | null | undefined): number | null {
|
||||
const raw = value?.trim().slice(0, 5) ?? "";
|
||||
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(raw)) return null;
|
||||
return Number(raw.slice(0, 2)) * 60 + Number(raw.slice(3, 5));
|
||||
}
|
||||
|
||||
function distance(left: number, right: number): number {
|
||||
const span = Math.abs(left - right);
|
||||
return Math.min(span, 1440 - span);
|
||||
}
|
||||
|
||||
export function nearestBoundaryFromTransitions(
|
||||
transitions: readonly Readonly<{ layer?: string; at?: string; from_sign?: string; to_sign?: string }>[] | null | undefined,
|
||||
recordedTime: string | null | undefined,
|
||||
): NearestBoundary {
|
||||
const recorded = clock(recordedTime);
|
||||
if (recorded === null || !transitions) return EMPTY;
|
||||
const changes: { at: number; layer: string; fromSign: string; toSign: string }[] = [];
|
||||
for (const row of transitions) {
|
||||
const layer = row.layer ?? "";
|
||||
if (!CHART_LAYERS.includes(layer as (typeof CHART_LAYERS)[number])) continue;
|
||||
const before = row.from_sign;
|
||||
const after = row.to_sign;
|
||||
if (!before || !after || before === after) continue;
|
||||
const at = clock(row.at);
|
||||
if (at === null) continue;
|
||||
changes.push({ at, layer, fromSign: before, toSign: after });
|
||||
}
|
||||
if (!changes.length) return { ...EMPTY, reason: "no_transition" };
|
||||
const bestAt = changes.reduce((best, row) => {
|
||||
const next = distance(row.at, recorded);
|
||||
const current = distance(best, recorded);
|
||||
return next < current || (next === current && row.at < best) ? row.at : best;
|
||||
}, changes[0]!.at);
|
||||
const layers: BoundaryLayer[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const layer of CHART_LAYERS) {
|
||||
const row = changes.find((item) => item.at === bestAt && item.layer === layer);
|
||||
if (!row || seen.has(layer)) continue;
|
||||
seen.add(layer);
|
||||
layers.push({
|
||||
layer,
|
||||
fromSign: row.fromSign,
|
||||
toSign: row.toSign,
|
||||
topic: LAYER_TOPIC[layer] ?? null,
|
||||
domain: LAYER_DOMAIN[layer] ?? null,
|
||||
});
|
||||
}
|
||||
const topics = topicsForLayers(layers.map((row) => row.layer));
|
||||
const domains: string[] = [];
|
||||
for (const row of layers) {
|
||||
if (row.domain && !domains.includes(row.domain)) domains.push(row.domain);
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
reason: null,
|
||||
at: `${String(Math.floor(bestAt / 60)).padStart(2, "0")}:${String(bestAt % 60).padStart(2, "0")}`,
|
||||
distanceMinutes: distance(bestAt, recorded),
|
||||
layers,
|
||||
topics,
|
||||
domains,
|
||||
};
|
||||
}
|
||||
|
||||
/** Spoken topics, in chart order. Unknown layers and D1 add nothing. */
|
||||
export function topicsForLayers(layers: readonly string[]): string[] {
|
||||
const topics: string[] = [];
|
||||
for (const layer of CHART_LAYERS) {
|
||||
if (!layers.includes(layer)) continue;
|
||||
const topic = LAYER_TOPIC[layer];
|
||||
if (topic && !topics.includes(topic)) topics.push(topic);
|
||||
}
|
||||
return topics;
|
||||
}
|
||||
|
||||
export function nearestBoundaryProbesEnabled(flag: string | undefined = process.env.RECTIFICATION_NEAREST_BOUNDARY_PROBES): boolean {
|
||||
return flag === "on";
|
||||
}
|
||||
|
||||
/** Boundary-domain questions first; everything else keeps its incoming order. */
|
||||
export function orderByBoundaryDomains<T>(
|
||||
probes: readonly T[],
|
||||
domains: readonly string[],
|
||||
domainOf: (probe: T) => string,
|
||||
): readonly T[] {
|
||||
if (!domains.length || probes.length < 2) return probes;
|
||||
const wanted = new Set(domains);
|
||||
const first: T[] = [];
|
||||
const rest: T[] = [];
|
||||
for (const probe of probes) {
|
||||
const domain = domainOf(probe);
|
||||
(domain && wanted.has(domain) ? first : rest).push(probe);
|
||||
}
|
||||
if (!first.length || !rest.length) return probes;
|
||||
return [...first, ...rest];
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* Opening scan for the nearest chart change. Failures keep the original
|
||||
* two-sentence opening. Question order stays on its own switch.
|
||||
*/
|
||||
import { resolveAyanamsa } from "../ayanamsa.ts";
|
||||
import { requestCandidateIntervals, type DatedCandidateRange } from "./core/candidate-window.ts";
|
||||
import {
|
||||
CHART_LAYERS,
|
||||
NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES,
|
||||
nearestBoundaryProbesEnabled,
|
||||
topicsForLayers,
|
||||
} from "./nearest-boundary.ts";
|
||||
import type { OpeningBoundaryFacts } from "./user-copy.ts";
|
||||
|
||||
const BOUNDARY_CHARTS = ["D1", "D2", "D4", "D5", "D7", "D9", "D10", "D11", "D12", "D24", "D30"] as const;
|
||||
const LAYERS = new Set<string>(CHART_LAYERS);
|
||||
|
||||
export function openingFactsFromBoundaryPayload(
|
||||
payload: unknown,
|
||||
askFirst: boolean,
|
||||
): OpeningBoundaryFacts & { at?: string | null } {
|
||||
if (!payload || typeof payload !== "object") return { kind: "unavailable" };
|
||||
const row = payload as { found?: unknown; reason?: unknown; distance_minutes?: unknown; layers?: unknown };
|
||||
if (row.found !== true) return row.reason === "no_transition" ? { kind: "stable" } : { kind: "unavailable" };
|
||||
const distance = row.distance_minutes;
|
||||
if (typeof distance !== "number" || !Number.isInteger(distance) || distance < 0 || distance > 720) {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
const rawLayers = Array.isArray(row.layers) ? row.layers : [];
|
||||
const layers = rawLayers.flatMap((item) => {
|
||||
const layer = item && typeof item === "object" ? (item as { layer?: unknown }).layer : null;
|
||||
return typeof layer === "string" && LAYERS.has(layer) ? [layer] : [];
|
||||
});
|
||||
const d1Changed = rawLayers.some((item) => {
|
||||
const layer = item && typeof item === "object" ? (item as { layer?: unknown }).layer : null;
|
||||
return layer === "d1";
|
||||
});
|
||||
const topics = topicsForLayers(layers);
|
||||
const at = (payload as { at?: unknown }).at;
|
||||
return {
|
||||
kind: "near",
|
||||
distanceMinutes: distance,
|
||||
topics,
|
||||
d1Changed,
|
||||
...(typeof at === "string" ? { at: at.slice(0, 5) } : {}),
|
||||
askFirst: askFirst && topics.length > 0 && distance <= NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES,
|
||||
};
|
||||
}
|
||||
|
||||
function preferBoundary(
|
||||
left: OpeningBoundaryFacts & { at?: string | null },
|
||||
right: OpeningBoundaryFacts & { at?: string | null },
|
||||
): OpeningBoundaryFacts & { at?: string | null } {
|
||||
if (left.kind !== "near") return right.kind === "near" ? right : left;
|
||||
if (right.kind !== "near") return left;
|
||||
const distance = (left.distanceMinutes ?? 9999) - (right.distanceMinutes ?? 9999);
|
||||
if (distance !== 0) return distance < 0 ? left : right;
|
||||
return (left.at ?? "99:99") <= (right.at ?? "99:99") ? left : right;
|
||||
}
|
||||
|
||||
export async function fetchOpeningBoundary(input: {
|
||||
snapshot: Readonly<Record<string, unknown>>;
|
||||
range: DatedCandidateRange;
|
||||
recordedTime: string;
|
||||
}): Promise<OpeningBoundaryFacts> {
|
||||
const recorded = input.recordedTime.trim().slice(0, 5);
|
||||
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(recorded)) return { kind: "unavailable" };
|
||||
const latitude = input.snapshot.latitude;
|
||||
const longitude = input.snapshot.longitude;
|
||||
const timezone = input.snapshot.timezone_offset;
|
||||
if (typeof latitude !== "number" || typeof longitude !== "number" || typeof timezone !== "number") {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
const intervals = requestCandidateIntervals(input.snapshot, input.range);
|
||||
const askFirst = nearestBoundaryProbesEnabled();
|
||||
let best: OpeningBoundaryFacts = { kind: "unavailable" };
|
||||
for (const interval of intervals) {
|
||||
const response = await fetch(`${process.env.JYOTISH_API_BASE?.trim() || "http://127.0.0.1:5200"}/api/rectification/v5/varga-segments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: AbortSignal.timeout(60000),
|
||||
body: JSON.stringify({
|
||||
start_at: interval.start_at,
|
||||
end_at: interval.end_at,
|
||||
latitude,
|
||||
longitude,
|
||||
timezone_offset: timezone,
|
||||
target_charts: ["D1"],
|
||||
boundary_charts: BOUNDARY_CHARTS,
|
||||
recorded_time: recorded,
|
||||
ayanamsa: resolveAyanamsa(input.snapshot),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) return { kind: "unavailable" };
|
||||
const data = await response.json() as { nearest_boundary?: unknown };
|
||||
best = preferBoundary(best, openingFactsFromBoundaryPayload(data.nearest_boundary, askFirst));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
export async function resolveOpeningBoundaryFacts(input: {
|
||||
birthTimeSource?: string | null;
|
||||
reportedBirthTime?: string | null;
|
||||
candidateRange?: { start_time?: string | null; end_time?: string | null } | null;
|
||||
loadCompute: () => Promise<{ baselineBirthSnapshot: Readonly<Record<string, unknown>>; candidateRange: DatedCandidateRange }>;
|
||||
}): Promise<OpeningBoundaryFacts> {
|
||||
if (input.birthTimeSource === "period_only") return { kind: "period_only" };
|
||||
const reported = input.reportedBirthTime?.trim() ?? "";
|
||||
const range = input.candidateRange;
|
||||
if (!input.birthTimeSource || !reported || !range?.start_time || !range.end_time) return { kind: "unavailable" };
|
||||
try {
|
||||
const compute = await input.loadCompute();
|
||||
return await fetchOpeningBoundary({
|
||||
snapshot: compute.baselineBirthSnapshot,
|
||||
range: compute.candidateRange,
|
||||
recordedTime: reported,
|
||||
});
|
||||
} catch {
|
||||
return { kind: "unavailable" };
|
||||
}
|
||||
}
|
||||
@@ -76,53 +76,19 @@ export function withFirstDatedCollectInvite(stem: string): string {
|
||||
* Opening body in plain words (BUG-1049, product 2026-09-26): what we are doing
|
||||
* and how, no question, no years, no jargon. The examples live in the stem
|
||||
* right below it, so the body does not list them again.
|
||||
*
|
||||
* BUG-1307: when the server already measured a chart change, one extra sentence
|
||||
* sits between those two. The 3-sentence cap is unchanged. A natal-ascendant
|
||||
* change is named in that same sentence, before the other life topics.
|
||||
*/
|
||||
export type OpeningBoundaryFacts = Readonly<{
|
||||
kind: "period_only" | "unavailable" | "stable" | "near";
|
||||
distanceMinutes?: number | null;
|
||||
topics?: readonly string[];
|
||||
/** Natal ascendant (layer d1) changed. Not a spoken life topic. */
|
||||
d1Changed?: boolean;
|
||||
/** Only when the question-order switch is on and this boundary is inside it. */
|
||||
askFirst?: boolean;
|
||||
}>;
|
||||
|
||||
const OPENING_BOUNDARY_TOPICS = ["婚恋", "工作", "住处", "学业", "子女", "父母", "钱", "身体"] as const;
|
||||
|
||||
export function openingBoundarySentence(facts: OpeningBoundaryFacts | null | undefined): string | null {
|
||||
if (!facts || facts.kind === "period_only" || facts.kind === "unavailable") return null;
|
||||
if (facts.kind === "stable") return "你填的时间附近这些盘都不换,先从经历开始。";
|
||||
const distance = facts.distanceMinutes;
|
||||
if (typeof distance !== "number" || !Number.isInteger(distance) || distance < 0) return null;
|
||||
const topics: string[] = [];
|
||||
for (const topic of facts.topics ?? []) {
|
||||
if ((OPENING_BOUNDARY_TOPICS as readonly string[]).includes(topic) && !topics.includes(topic)) topics.push(topic);
|
||||
}
|
||||
const place = distance < 1 ? "不到 1 分钟" : `差 ${distance} 分钟`;
|
||||
const natal = facts.d1Changed ? ",这里连本命上升都换" : "";
|
||||
const sides = topics.length ? `,两边不一样的是${topics.join("、")}` : "";
|
||||
const tail = facts.askFirst && topics.length ? ",接下来先问这几件。" : "。";
|
||||
return `你填的时间离最近会换的地方${place}${natal}${sides}${tail}`;
|
||||
}
|
||||
|
||||
export function openingSpokenBody(
|
||||
range: readonly [string, string] | null,
|
||||
productDelivery?: ProductDelivery | null,
|
||||
facts?: OpeningBoundaryFacts | null,
|
||||
): string {
|
||||
export function openingSpokenBody(range: readonly [string, string] | null, productDelivery?: ProductDelivery | null): string {
|
||||
const window = formatClockRange(range);
|
||||
const head = productDelivery
|
||||
? `我们先判断现在的出生时间能不能选定用来解读的星盘${window ? `,核对窗口是 ${window}` : ""}。`
|
||||
: window
|
||||
if (productDelivery) return [
|
||||
`我们先判断现在的出生时间能不能选定用来解读的星盘${window ? `,核对窗口是 ${window}` : ""}。`,
|
||||
"做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。",
|
||||
].join("");
|
||||
return [
|
||||
window
|
||||
? `我们来把你的出生时间缩小到更准的范围,现在先在 ${window} 之间找。`
|
||||
: "我们来把你的出生时间缩小到更准的范围。";
|
||||
const tail = "做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。";
|
||||
const extra = openingBoundarySentence(facts);
|
||||
return extra ? `${head}${extra}${tail}` : `${head}${tail}`;
|
||||
: "我们来把你的出生时间缩小到更准的范围。",
|
||||
"做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。",
|
||||
].join("");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,15 +119,12 @@ export function isAcceptableOpeningBody(
|
||||
body: string,
|
||||
range: readonly [string, string] | null = null,
|
||||
productDelivery?: ProductDelivery | null,
|
||||
facts?: OpeningBoundaryFacts | null,
|
||||
): boolean {
|
||||
const spoken = body.trim();
|
||||
if (productDelivery && (!spoken.includes("用来解读的星盘") || /缩小|更准的范围|最可能|已确认/.test(spoken))) return false;
|
||||
if (!spoken || OPENING_YEAR.test(spoken)) return false;
|
||||
if (/[??]/.test(spoken)) return false;
|
||||
if (OPENING_BODY_JARGON.some((word) => spoken.includes(word))) return false;
|
||||
const required = openingBoundarySentence(facts);
|
||||
if (required && !spoken.includes(required)) return false;
|
||||
const sentences = splitAfterSentencePunctuation(spoken, "。!?").map((part) => part.trim()).filter(Boolean);
|
||||
if (sentences.length === 0 || sentences.length > 3) return false;
|
||||
if (!spoken.includes("星盘") || !spoken.includes("年月")) return false;
|
||||
@@ -690,10 +653,6 @@ export function listUserVisibleCopy(): string[] {
|
||||
FIRST_DATED_COLLECT_INVITE,
|
||||
GENERIC_COLLECT_QUESTION,
|
||||
openingSpokenBody(["04:45", "05:15"]),
|
||||
openingSpokenBody(["04:45", "05:15"], null, { kind: "near", distanceMinutes: 0, topics: ["父母", "工作"] }),
|
||||
openingSpokenBody(["04:45", "05:15"], null, { kind: "near", distanceMinutes: 5, topics: ["婚恋"], askFirst: true }),
|
||||
openingSpokenBody(["04:45", "05:15"], null, { kind: "stable" }),
|
||||
openingSpokenBody(["04:45", "05:15"], null, { kind: "period_only" }),
|
||||
RECTIFICATION_USER_COPY.uncertaintyStop,
|
||||
RECTIFICATION_USER_COPY.tiedFirstStop,
|
||||
RECTIFICATION_USER_COPY.probePoolExhaustedStop,
|
||||
|
||||
@@ -292,8 +292,6 @@ export async function handleRectificationTypedMessage(
|
||||
...catalog,
|
||||
birthDate,
|
||||
candidatesSeparated: false,
|
||||
reportedTime: dossier.case.reportedBirthTime,
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
});
|
||||
const persisted = await persistServerOwnedFocus({
|
||||
accounting,
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* enclosing `lastAttemptError`).
|
||||
*/
|
||||
import { resolveRectificationStepBudget } from "./step-budget";
|
||||
import { insertV9SkillRunReceipt, loadV9CaseCompute, loadV9CaseDossier, type V9CaseDossier } from "./tool-service";
|
||||
import { insertV9SkillRunReceipt, loadV9CaseDossier, type V9CaseDossier } from "./tool-service";
|
||||
import { loadCaseDecisionInputs } from "./decision-inputs.ts";
|
||||
import { RECTIFICATION_AGENT_TOOLS } from "./public-receipt";
|
||||
import { agentGenerationSettings, promptCacheUsage } from "../../agent-generation-settings.ts";
|
||||
@@ -20,13 +20,10 @@ import { parseAgentChoiceCopy, isPersistedFocusId } from "./choice-card";
|
||||
import {
|
||||
RECTIFICATION_USER_COPY,
|
||||
isAcceptableOpeningBody,
|
||||
openingBoundarySentence,
|
||||
openingRangeFromCandidateRange,
|
||||
openingSpokenBody,
|
||||
stripRangeProgressClaims,
|
||||
type OpeningBoundaryFacts,
|
||||
} from "../user-copy";
|
||||
import { resolveOpeningBoundaryFacts } from "../opening-boundary.ts";
|
||||
import { attemptTimeoutForRemainingBudget, canStartRetryAttempt } from "../../rectification-run-budget.ts";
|
||||
import { stripQuestionSentences, stripVerbalWindowChange } from "./collect-prompt";
|
||||
import { focusSpokenPrompt } from "./turn-question";
|
||||
@@ -123,15 +120,6 @@ export async function streamV9Attempt(
|
||||
return failedAttempt(attemptId, "skill_not_loaded");
|
||||
}
|
||||
|
||||
let openingFacts: OpeningBoundaryFacts | null = null;
|
||||
if (action === "opening") {
|
||||
openingFacts = await resolveOpeningBoundaryFacts({
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
reportedBirthTime: dossier.case.reportedBirthTime,
|
||||
candidateRange: dossier.case.candidateRange,
|
||||
loadCompute: () => loadV9CaseCompute(accounting, userId, caseId),
|
||||
});
|
||||
}
|
||||
const messages = buildAgentMessages(
|
||||
options,
|
||||
attemptNumber,
|
||||
@@ -139,7 +127,6 @@ export async function streamV9Attempt(
|
||||
skillInstructions,
|
||||
birthTimeClue,
|
||||
previousErrorCode,
|
||||
openingBoundarySentence(openingFacts),
|
||||
);
|
||||
const maxSteps = resolveRectificationStepBudget(action);
|
||||
const abortController = new AbortController();
|
||||
@@ -626,9 +613,8 @@ export async function streamV9Attempt(
|
||||
if (action === "opening") {
|
||||
const openingRange = openingRangeFromCandidateRange(latestDossier.case.candidateRange);
|
||||
const productDelivery = productDeliveryForDomain(latestDossier.case.rectificationDomain);
|
||||
const required = openingBoundarySentence(openingFacts);
|
||||
if (required || !isAcceptableOpeningBody(answerText, openingRange, productDelivery, openingFacts)) {
|
||||
answerText = openingSpokenBody(openingRange, productDelivery, openingFacts);
|
||||
if (!isAcceptableOpeningBody(answerText, openingRange, productDelivery)) {
|
||||
answerText = openingSpokenBody(openingRange, productDelivery);
|
||||
}
|
||||
}
|
||||
const latestInference = previousInferenceFromReceipt(
|
||||
|
||||
@@ -20,11 +20,7 @@ function clockWindow(range: { start_time?: string | null; end_time?: string | nu
|
||||
return start && end ? `${start}–${end}` : null;
|
||||
}
|
||||
|
||||
export function buildOpeningBrief(
|
||||
dossier: V9CaseDossier,
|
||||
birthTimeClue?: string | null,
|
||||
boundarySentence?: string | null,
|
||||
): string {
|
||||
export function buildOpeningBrief(dossier: V9CaseDossier, birthTimeClue?: string | null): string {
|
||||
const confirmed = dossier.evidence.filter((item) => item.status === "confirmed");
|
||||
const pending = dossier.evidence.filter((item) => item.status === "draft" || item.status === "pending_confirmation");
|
||||
const domains = [...new Set(confirmed.map((item) => item.domain))].slice(0, 6);
|
||||
@@ -40,9 +36,6 @@ export function buildOpeningBrief(
|
||||
const clue = typeof birthTimeClue === "string" && birthTimeClue.trim()
|
||||
? birthTimeClue.trim()
|
||||
: "";
|
||||
const boundary = typeof boundarySentence === "string" && boundarySentence.trim()
|
||||
? boundarySentence.trim()
|
||||
: "";
|
||||
return [
|
||||
"【服务端 opening brief】",
|
||||
`Case 状态:${dossier.case.status}。`,
|
||||
@@ -52,7 +45,7 @@ export function buildOpeningBrief(
|
||||
...(clue
|
||||
? [`家人或本人关于出生时段的线索(仅旁白建议,不得改搜索窗口):${clue}`]
|
||||
: []),
|
||||
`做法要点:正文两句大白话,照这个意思写:「${openingGoal}」「做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」${boundary ? `服务端另有一句,原样放在这两句之间:「${boundary}」` : ""}正文不用大运、盘面、分盘、候选、区间、代表分钟、精确到秒这类词,不写具体年份,不要提问,不列举例子,不要求先准备材料。一条消息可以报多件,想到几件说几件。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,写「先说一两件你记得的大事」即可:开场题干由服务端固定,会列出${OPENING_COLLECT_DOMAINS.join("、")},并给一个带大概年月的回答示例,正文不要重复这些例子。`,
|
||||
`做法要点:正文两句大白话,照这个意思写:「${openingGoal}」「做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。」正文不用大运、盘面、分盘、候选、区间、代表分钟、精确到秒这类词,不写具体年份,不要提问,不列举例子,不要求先准备材料。一条消息可以报多件,想到几件说几件。先用 rectification-set-focus 的 spokenPrompt 写出当前采集题,写「先说一两件你记得的大事」即可:开场题干由服务端固定,会列出${OPENING_COLLECT_DOMAINS.join("、")},并给一个带大概年月的回答示例,正文不要重复这些例子。`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -63,7 +56,6 @@ export function buildAgentMessages(
|
||||
skillInstructions: string,
|
||||
birthTimeClue: string | null = null,
|
||||
previousErrorCode: string | null = null,
|
||||
boundarySentence: string | null = null,
|
||||
): unknown[] {
|
||||
const timeContext = options.timeContext
|
||||
?? `服务端当前时间(权威):${new Date().toISOString()}。涉及“现在、今天、今年、未来几个月”等相对时间时,以此为准。`;
|
||||
@@ -84,7 +76,7 @@ export function buildAgentMessages(
|
||||
if (options.action === "opening") {
|
||||
return [bootstrap, {
|
||||
role: "user",
|
||||
content: [timeContext, caseContext, buildOpeningBrief(dossier, birthTimeClue, boundarySentence)].join("\n"),
|
||||
content: [timeContext, caseContext, buildOpeningBrief(dossier, birthTimeClue)].join("\n"),
|
||||
}];
|
||||
}
|
||||
return [bootstrap, {
|
||||
|
||||
@@ -1094,7 +1094,6 @@ export async function persistNextInterviewAfterChoice(input: {
|
||||
reportedBirthTime: liveDossier.case.reportedBirthTime,
|
||||
candidateRange: liveDossier.case.candidateRange,
|
||||
}),
|
||||
birthTimeSource: liveDossier.case.birthTimeSource,
|
||||
};
|
||||
const roundFollowup = input.continueTieBreakRound === true
|
||||
? tieBreakPersonalityFollowup(planInput)
|
||||
@@ -1904,7 +1903,6 @@ export async function persistNextInterviewIfIdle(input: {
|
||||
reportedBirthTime: dossier.case.reportedBirthTime,
|
||||
candidateRange: dossier.case.candidateRange,
|
||||
}),
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
});
|
||||
const followup = interviewToPersist(plan);
|
||||
if (shouldSkipFollowupPersist({
|
||||
|
||||
@@ -723,8 +723,6 @@ function discriminatorProbeIfFollowupCanAsk(input: {
|
||||
...(input.contrastPacket ? { contrastPacket: input.contrastPacket } : {}),
|
||||
...(input.askedKeys ? { askedProbeKeys: input.askedKeys } : {}),
|
||||
...(input.birthDate ? { birthDate: input.birthDate } : {}),
|
||||
reportedTime: input.dossier.case.reportedBirthTime,
|
||||
birthTimeSource: input.dossier.case.birthTimeSource,
|
||||
candidatesSeparated: dossierCandidatesSeparated(input.dossier),
|
||||
});
|
||||
const dropped = mergeDroppedProbes(input.inspected.dropped, plan.dropped_probes);
|
||||
@@ -927,8 +925,6 @@ export function decideFromDossier(
|
||||
remainingCandidateCount: catalog.remainingCandidateCount,
|
||||
remainingCredibleRange: catalog.remainingCredibleRange,
|
||||
guidedWindows: catalog.guidedCollectWindows,
|
||||
windowScan: catalog.windowScan,
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
...followupCaseArgs({
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
@@ -1058,8 +1054,6 @@ export function decideAfterInferenceChange(input: {
|
||||
remainingCandidateCount: catalog.remainingCandidateCount,
|
||||
remainingCredibleRange: catalog.remainingCredibleRange,
|
||||
guidedWindows: catalog.guidedCollectWindows,
|
||||
windowScan: catalog.windowScan,
|
||||
birthTimeSource: input.dossier.case.birthTimeSource,
|
||||
...followupCaseArgs({
|
||||
stage: input.dossier.case.stage,
|
||||
blockScan: input.dossier.case.blockScan,
|
||||
|
||||
@@ -151,7 +151,6 @@ export function stepStateFromCaseDossier(
|
||||
accepted: Boolean(dossier.case.acceptedTime),
|
||||
candidatesSeparated: decision.separation.sufficient,
|
||||
holdoutValidation: decision.holdoutValidation,
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
...followupCaseArgs({
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
@@ -192,7 +191,6 @@ export function nextUserActionFromDossier(
|
||||
accepted,
|
||||
candidatesSeparated: decision.separation.sufficient,
|
||||
holdoutValidation: decision.holdoutValidation,
|
||||
birthTimeSource: dossier.case.birthTimeSource,
|
||||
...followupCaseArgs({
|
||||
stage: dossier.case.stage,
|
||||
blockScan: dossier.case.blockScan,
|
||||
|
||||
@@ -91,12 +91,6 @@ import {
|
||||
} from "./choice-card.ts";
|
||||
import type { ProbeExplainCandidate } from "./probe-explain.ts";
|
||||
import { segmentProbeGains } from "../core/segment-probe-order.ts";
|
||||
import {
|
||||
NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES,
|
||||
nearestBoundaryFromTransitions,
|
||||
nearestBoundaryProbesEnabled,
|
||||
orderByBoundaryDomains,
|
||||
} from "../nearest-boundary.ts";
|
||||
import { overlayChoicePromptFromSpoken } from "./turn-narration.ts";
|
||||
import {
|
||||
GENERIC_COLLECT_QUESTION,
|
||||
@@ -1278,29 +1272,6 @@ function emptyRankedCatalog(): {
|
||||
return { locked: [], personality: [], yearless: [], dropped: [] };
|
||||
}
|
||||
|
||||
/** Gain order stays put unless the nearest-boundary switch is explicitly on. */
|
||||
export function orderDiscriminatorsForNearestBoundary<T extends {
|
||||
eventProbe?: { domain?: string | null } | null;
|
||||
contrastProbe?: { domain?: string | null } | null;
|
||||
}>(
|
||||
rows: readonly T[],
|
||||
input: {
|
||||
windowScan?: { transitions?: readonly { layer?: string; at?: string; from_sign?: string; to_sign?: string }[] } | null;
|
||||
reportedTime?: string | null;
|
||||
birthTimeSource?: string | null;
|
||||
probeFlag?: string;
|
||||
},
|
||||
): readonly T[] {
|
||||
if (!nearestBoundaryProbesEnabled(input.probeFlag) || input.birthTimeSource === "period_only" || !input.reportedTime) {
|
||||
return rows;
|
||||
}
|
||||
const boundary = nearestBoundaryFromTransitions(input.windowScan?.transitions, input.reportedTime);
|
||||
if (!boundary.found || boundary.distanceMinutes == null
|
||||
|| boundary.distanceMinutes > NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES
|
||||
|| !boundary.domains.length) return rows;
|
||||
return orderByBoundaryDomains(rows, boundary.domains, (row) => row.eventProbe?.domain ?? row.contrastProbe?.domain ?? "");
|
||||
}
|
||||
|
||||
function rankRenderableDiscriminators(input: {
|
||||
eventProbes: readonly DiscriminatingEventProbe[];
|
||||
contrastProbes: readonly CandidateDiscriminatorProbe[];
|
||||
@@ -1311,9 +1282,6 @@ function rankRenderableDiscriminators(input: {
|
||||
evidence?: readonly MethodFollowupEvidence[];
|
||||
birthDate?: string | null;
|
||||
inferenceState?: InferenceState | null;
|
||||
windowScan?: WindowScan | null;
|
||||
reportedTime?: string | null;
|
||||
birthTimeSource?: string | null;
|
||||
}): { locked: RankedDiscriminator[]; personality: RankedDiscriminator[]; yearless: RankedDiscriminator[]; dropped: DroppedProbe[] } {
|
||||
const top = input.topCandidateTimes ?? [];
|
||||
const provided = new Set((input.providedDomains ?? []).map((item) => canonicalCollectDomain(item)));
|
||||
@@ -1387,15 +1355,10 @@ function rankRenderableDiscriminators(input: {
|
||||
}
|
||||
return right.score - left.score || (right.eventProbe?.information_gain ?? right.contrastProbe?.informationGain ?? 0) - (left.eventProbe?.information_gain ?? left.contrastProbe?.informationGain ?? 0);
|
||||
});
|
||||
const ordered = orderDiscriminatorsForNearestBoundary(sorted, {
|
||||
windowScan: input.windowScan,
|
||||
reportedTime: input.reportedTime,
|
||||
birthTimeSource: input.birthTimeSource,
|
||||
});
|
||||
return {
|
||||
locked: ordered.filter((row) => discriminatorLocksScoringPeriod(row)),
|
||||
personality: ordered.filter((row) => isYearlessPersonalityRow(row)),
|
||||
yearless: ordered.filter((row) => !discriminatorLocksScoringPeriod(row) && !isYearlessPersonalityRow(row)),
|
||||
locked: sorted.filter((row) => discriminatorLocksScoringPeriod(row)),
|
||||
personality: sorted.filter((row) => isYearlessPersonalityRow(row)),
|
||||
yearless: sorted.filter((row) => !discriminatorLocksScoringPeriod(row) && !isYearlessPersonalityRow(row)),
|
||||
dropped,
|
||||
};
|
||||
}
|
||||
@@ -2311,7 +2274,6 @@ export function buildMethodFollowupPlan(input: {
|
||||
caseStage?: RectificationCaseStage;
|
||||
blockScan?: BlockScanPayload | null;
|
||||
reportedTime?: string | null;
|
||||
birthTimeSource?: string | null;
|
||||
candidateRange?: import("../core/candidate-window.ts").DatedCandidateRange | null;
|
||||
remainingLayers?: readonly string[];
|
||||
remainingSplitTimes?: readonly [string, string] | null;
|
||||
@@ -2492,9 +2454,6 @@ export function buildMethodFollowupPlan(input: {
|
||||
evidence: input.evidence,
|
||||
birthDate: input.birthDate,
|
||||
inferenceState: input.inferenceState,
|
||||
windowScan: input.windowScan,
|
||||
reportedTime: input.reportedTime,
|
||||
birthTimeSource: input.birthTimeSource,
|
||||
})
|
||||
: emptyRankedCatalog();
|
||||
const rankedDiscriminators = rankedCatalog.locked;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { resolveAyanamsa } from "../../ayanamsa.ts";
|
||||
import { requestCandidateIntervals, type DatedCandidateRange } from "../core/candidate-window.ts";
|
||||
import type { SegmentMinute, TargetChart } from "../core/segment-summary.ts";
|
||||
import { parseSegmentMinutes } from "./segment-contract.ts";
|
||||
@@ -16,8 +15,7 @@ export async function scanCaseSegments(input: {
|
||||
method: "POST", headers: { "Content-Type": "application/json" }, signal: AbortSignal.timeout(60000),
|
||||
body: JSON.stringify({ start_at: interval.start_at, end_at: interval.end_at,
|
||||
latitude: input.snapshot.latitude, longitude: input.snapshot.longitude,
|
||||
timezone_offset: input.snapshot.timezone_offset, target_charts: input.targets,
|
||||
ayanamsa: resolveAyanamsa(input.snapshot) }),
|
||||
timezone_offset: input.snapshot.timezone_offset, target_charts: input.targets }),
|
||||
});
|
||||
if (!response.ok) throw new Error("segment_scan_unavailable");
|
||||
const data = await response.json();
|
||||
@@ -25,7 +23,7 @@ export async function scanCaseSegments(input: {
|
||||
const start = Date.parse(`${interval.start_at}:00Z`);
|
||||
const expectedWidth = (Date.parse(`${interval.end_at}:00Z`) - start) / 60000 + 1;
|
||||
if (!minutes || data.start_at !== interval.start_at || data.end_at !== interval.end_at
|
||||
|| data.ayanamsa !== resolveAyanamsa(input.snapshot) || data.node_mode !== "mean" || data.step_minutes !== 1
|
||||
|| data.ayanamsa !== "raman" || data.node_mode !== "mean" || data.step_minutes !== 1
|
||||
|| data.window_minutes !== expectedWidth || minutes.length !== expectedWidth
|
||||
|| minutes.some((minute, index) => minute.offset !== index
|
||||
|| `${minute.date}T${minute.time}` !== new Date(start + index * 60000).toISOString().slice(0, 16)
|
||||
|
||||
@@ -64,11 +64,9 @@ async function writeMessages(client: SupabaseClient, userId: string, sessionId:
|
||||
}
|
||||
|
||||
/**
|
||||
* After a free direct-entry reply is appended, hang the offer on that message.
|
||||
* The record is not opened yet (`offered` + `autoOpen`); bindOfferToCase sets
|
||||
* opened after the case actually opens. Idempotent: an offer already on the
|
||||
* message (including one a later bind gave a case id) is left as it is.
|
||||
* Does not touch consultation_requests.
|
||||
* After a free direct-entry reply is appended, hang the opened offer on that
|
||||
* message. Idempotent: an offer already on the message (including one a later
|
||||
* bind gave a case id) is left as it is. Does not touch consultation_requests.
|
||||
*/
|
||||
export async function persistOpenedDirectOffer(input: {
|
||||
client: SupabaseClient;
|
||||
|
||||
@@ -28,12 +28,6 @@ export const rectificationOfferRecordSchema = z.object({
|
||||
offeredAt: z.string().min(1).max(40),
|
||||
caseId: z.string().uuid().optional(),
|
||||
rectificationSessionId: z.string().uuid().optional(),
|
||||
/**
|
||||
* Present only when this turn should open the case without a click.
|
||||
* Writers omit it unless it is true. Older stored offers omit it too.
|
||||
* The card stays an ordinary offer until status is opened (BUG-1306 F3).
|
||||
*/
|
||||
autoOpen: z.boolean().optional(),
|
||||
}).strict();
|
||||
|
||||
export type RectificationOfferRecord = z.infer<typeof rectificationOfferRecordSchema>;
|
||||
@@ -155,9 +149,8 @@ export function bindOfferToCase(
|
||||
for (let index = stored.length - 1; index >= 0; index -= 1) {
|
||||
const offer = parseRectificationOfferRecord(stored[index]!.rectificationOffer);
|
||||
if (!offer || offer.caseId) continue;
|
||||
// Offered cards, including a direct open that is still offered (autoOpen).
|
||||
// An older direct-open row stored as opened before it had a case id still binds.
|
||||
// Returned rows, and a card already bound to another case, are left alone.
|
||||
// A direct open records `opened` before the case id exists (BUG-1306).
|
||||
// An offer already bound to another case is left alone.
|
||||
if (offer.status !== "offered" && offer.status !== "opened") continue;
|
||||
const next = [...stored];
|
||||
next[index] = {
|
||||
@@ -171,8 +164,7 @@ export function bindOfferToCase(
|
||||
|
||||
/**
|
||||
* After `complete_consultation_free` appends the one-sentence reply, hang the
|
||||
* offer on that message so a reload can still find the source. The record is
|
||||
* not opened yet (still `offered`, with `autoOpen` on a direct open). Returns
|
||||
* opened offer on that message so a reload can still find the source. Returns
|
||||
* the next array, "present" when that message already has an offer (do not
|
||||
* overwrite a later case id), or "missing" when the free reply is not there yet.
|
||||
*/
|
||||
|
||||
@@ -1073,7 +1073,6 @@ function offerRecord(
|
||||
hook: RectificationOfferHook,
|
||||
decision: Extract<RectificationOfferDecision, { ok: true }>,
|
||||
status: RectificationOfferRecord["status"],
|
||||
autoOpen = false,
|
||||
): RectificationOfferRecord {
|
||||
return {
|
||||
status,
|
||||
@@ -1081,16 +1080,13 @@ function offerRecord(
|
||||
questionEstimate: decision.questionEstimate,
|
||||
sourceQuestion: hook.revisitQuestion,
|
||||
offeredAt: (hook.now?.() ?? new Date()).toISOString(),
|
||||
// Only when true. Ordinary offers and refusals omit the key.
|
||||
...(autoOpen ? { autoOpen: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Explicit request (BUG-1306): the server already decided. A pass records an
|
||||
* offered card with autoOpen and tells the stream to open. Status stays
|
||||
* offered until the case is bound. A case already in progress resumes that
|
||||
* case. Anything else is one sentence and writes no offer.
|
||||
* opened offer with no case id yet and tells the stream to open. A case
|
||||
* already in progress resumes that case. Anything else is one sentence.
|
||||
*/
|
||||
async function finishExplicitRectificationRequest(
|
||||
state: ConsultationRuntimeState,
|
||||
@@ -1098,7 +1094,7 @@ async function finishExplicitRectificationRequest(
|
||||
decision: RectificationOfferDecision,
|
||||
) {
|
||||
if (decision.ok) {
|
||||
state.rectificationOffer = offerRecord(hook, decision, "offered", true);
|
||||
state.rectificationOffer = offerRecord(hook, decision, "opened");
|
||||
state.rectificationDirectEntry = {
|
||||
kind: "new",
|
||||
sentence: RECTIFICATION_DIRECT_NEW_COPY,
|
||||
|
||||
@@ -307,7 +307,6 @@ export function safeCaseProjection(
|
||||
reportedBirthTime: parsed.case.reportedBirthTime,
|
||||
candidateRange: parsed.case.candidateRange,
|
||||
}),
|
||||
birthTimeSource: parsed.case.birthTimeSource,
|
||||
});
|
||||
const latestProjection = latest && parsed.case.stage !== "block_scan"
|
||||
? latestResultToolProjection(latest, decision, productDeliveryForDomain(parsed.case.rectificationDomain))
|
||||
@@ -773,7 +772,6 @@ function followupPlanForParsed(
|
||||
reportedBirthTime: parsed.case.reportedBirthTime,
|
||||
candidateRange: parsed.case.candidateRange,
|
||||
}),
|
||||
birthTimeSource: parsed.case.birthTimeSource,
|
||||
}),
|
||||
contrastPacket: catalog.contrastPacket,
|
||||
sessionOutcome: decision.sessionOutcome,
|
||||
|
||||
@@ -23,13 +23,8 @@ test("resolveAyanamsa defaults to Raman and accepts the six product values", ()
|
||||
assert.equal(resolveAyanamsa({ ayanamsa: "djwhal_khul" }), "raman");
|
||||
assert.equal(isAyanamsaName("raman"), true);
|
||||
assert.equal(isAyanamsaName("sidereal"), false);
|
||||
// 原值:/咨询、报告、星盘库和每日星语/;新值:/咨询、报告、星盘库、每日星语/;原因:列举里加了新开的生时校正,顿号接续。
|
||||
assert.match(AYANAMSA_SWITCH_HINT, /咨询、报告、星盘库、每日星语/);
|
||||
// 原值:/生时校正目前仍固定按 Raman/;新值:新开的校正跟设置、进行中的按开始时;
|
||||
// 原因:校正开案时快照用户岁差,打分与段扫描都按快照(BUG-1309、BUG-1310),旧句说反了。
|
||||
assert.match(AYANAMSA_SWITCH_HINT, /新开的生时校正会按新岁差算/);
|
||||
assert.match(AYANAMSA_SWITCH_HINT, /已经开始的那次校正仍按开始时的岁差/);
|
||||
assert.doesNotMatch(AYANAMSA_SWITCH_HINT, /固定按 Raman/);
|
||||
assert.match(AYANAMSA_SWITCH_HINT, /咨询、报告、星盘库和每日星语/);
|
||||
assert.match(AYANAMSA_SWITCH_HINT, /生时校正目前仍固定按 Raman/);
|
||||
});
|
||||
|
||||
// TASK-chart-types-and-report-buttons-20260928 T6: every offered value must
|
||||
|
||||
@@ -4,10 +4,7 @@ import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
import { RectificationOfferCard } from "../src/components/rectification-in-chat-cards.tsx";
|
||||
import { consultationAgentPublicEventSchema, createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts";
|
||||
import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan.ts";
|
||||
import {
|
||||
@@ -16,14 +13,10 @@ import {
|
||||
RECTIFICATION_DIRECT_RESUME_COPY,
|
||||
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
|
||||
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_OFFER_OPENED_COPY,
|
||||
RECTIFICATION_OFFER_START_LABEL,
|
||||
RECTIFICATION_OFFER_TOOL_ID,
|
||||
attachOpenedOfferToFreeMessage,
|
||||
bindOfferToCase,
|
||||
offerForCase,
|
||||
parseRectificationOfferRecord,
|
||||
rectificationOfferCardCopy,
|
||||
sourceFromSessionRows,
|
||||
type RectificationOfferRecord,
|
||||
} from "../src/lib/rectification-in-chat.ts";
|
||||
@@ -186,15 +179,13 @@ test("the free reply can carry the opened offer once, without clobbering a case
|
||||
assert.equal(withCase, "present");
|
||||
});
|
||||
|
||||
test("explicit pass opens once, records an offered auto-open offer, and does not open again", async () => {
|
||||
test("explicit pass opens once, records an opened offer, and does not open again", async () => {
|
||||
const fresh = offerTool();
|
||||
const result = await fresh.tool.execute({ explicitRequest: true, userAsked: true });
|
||||
assert.equal(result.offered, true);
|
||||
assert.equal(result.directEntry, "new");
|
||||
assert.match(String(result.note), /好,我们开始核对出生时间/);
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("offered", { autoOpen: true }));
|
||||
assert.equal(fresh.state.rectificationOffer?.status, "offered");
|
||||
assert.equal(fresh.state.rectificationOffer?.autoOpen, true);
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("opened"));
|
||||
assert.equal(fresh.state.rectificationOffer?.caseId, undefined);
|
||||
assert.deepEqual(fresh.state.rectificationDirectEntry, {
|
||||
kind: "new",
|
||||
@@ -208,15 +199,14 @@ test("explicit pass opens once, records an offered auto-open offer, and does not
|
||||
assert.match(String(again.note), /已经在打开/);
|
||||
assert.match(String(again.note), /不要再写解读/);
|
||||
assert.equal(fresh.decisions(), 1);
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("offered", { autoOpen: true }));
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("opened"));
|
||||
});
|
||||
|
||||
test("explicitRequest bypasses an earlier offer, and userAsked alone still only shows a card", async () => {
|
||||
const direct = offerTool({ alreadyOffered: true });
|
||||
const opened = await direct.tool.execute({ explicitRequest: true });
|
||||
assert.equal(opened.directEntry, "new");
|
||||
assert.equal(direct.state.rectificationOffer?.status, "offered");
|
||||
assert.equal(direct.state.rectificationOffer?.autoOpen, true);
|
||||
assert.equal(direct.state.rectificationOffer?.status, "opened");
|
||||
assert.equal(direct.state.rectificationDirectEntry?.kind, "new");
|
||||
|
||||
const card = offerTool({ alreadyOffered: true });
|
||||
@@ -385,7 +375,7 @@ test("natal and window direct entry or explicit refusal skips the runtime-contra
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
...(kind === "resume" ? { resumeSessionId: RESUME_SESSION } : {}),
|
||||
};
|
||||
if (kind === "new") state.rectificationOffer = offer("offered", { autoOpen: true });
|
||||
if (kind === "new") state.rectificationOffer = offer("opened");
|
||||
yield { type: "text-delta", payload: { text: "然后写一段解读。" } };
|
||||
yield STOP_FINISH;
|
||||
})(),
|
||||
@@ -415,8 +405,7 @@ test("natal and window direct entry or explicit refusal skips the runtime-contra
|
||||
assert.equal(done.type === "run.completed" && done.responseKind, "smalltalk");
|
||||
assert.equal(done.type === "run.completed" && done.receipt, undefined);
|
||||
if (kind === "new") {
|
||||
assert.equal(done.type === "run.completed" && done.rectificationOffer?.status, "offered");
|
||||
assert.equal(done.type === "run.completed" && done.rectificationOffer?.autoOpen, true);
|
||||
assert.equal(done.type === "run.completed" && done.rectificationOffer?.status, "opened");
|
||||
assert.equal(done.type === "run.completed" && done.rectificationOffer?.caseId, undefined);
|
||||
assert.deepEqual(done.type === "run.completed" ? done.rectificationDirectOpen : undefined, {
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
@@ -461,7 +450,7 @@ test("a reading that already streamed is replaced by the direct-entry sentence",
|
||||
sentence: RECTIFICATION_DIRECT_NEW_COPY,
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
};
|
||||
state.rectificationOffer = offer("offered", { autoOpen: true });
|
||||
state.rectificationOffer = offer("opened");
|
||||
yield STOP_FINISH;
|
||||
})(),
|
||||
retry: async () => {
|
||||
@@ -579,9 +568,6 @@ test("the offer rule is defined once, and direct entry settles with the free com
|
||||
assert.match(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
|
||||
assert.match(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
|
||||
assert.equal(hook.match(/openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/g)?.length, 1);
|
||||
const directOpen = hook.slice(hook.indexOf("rectificationDirectOpen?.mode"), hook.indexOf("await persistSession(completedSession)"));
|
||||
assert.match(directOpen, /if \(opened\) return/);
|
||||
assert.doesNotMatch(directOpen, /status:\s*"opened"/);
|
||||
assert.match(hook, /responseKind === "smalltalk" \? \{[\s\S]*?rectificationOffer/);
|
||||
|
||||
const page = src("../src/app/(app)/page.tsx");
|
||||
@@ -600,85 +586,4 @@ test("the offer rule is defined once, and direct entry settles with the free com
|
||||
rectificationOffer: offer("opened"),
|
||||
rectificationDirectOpen: { sourceSessionId: SOURCE_SESSION, mode: "new" },
|
||||
});
|
||||
consultationAgentPublicEventSchema.parse({
|
||||
type: "run.completed",
|
||||
responseKind: "smalltalk",
|
||||
rectificationOffer: offer("offered", { autoOpen: true }),
|
||||
rectificationDirectOpen: { sourceSessionId: SOURCE_SESSION, mode: "new" },
|
||||
});
|
||||
|
||||
const surface = src("../src/hooks/use-rectification-surface.ts");
|
||||
const fromChat = surface.slice(surface.indexOf("async function openRectificationFromChat"), surface.indexOf("async function openRectificationSession"));
|
||||
assert.match(fromChat, /return null/);
|
||||
assert.match(fromChat, /return openRectificationCase\("homepage", null, sourceSessionId\)/);
|
||||
});
|
||||
|
||||
function cardHtml(record: RectificationOfferRecord) {
|
||||
return renderToString(createElement(RectificationOfferCard, { offer: record, disabled: false, onStart() {} }));
|
||||
}
|
||||
|
||||
test("a direct-open offer still shows the ordinary card until the case is bound", () => {
|
||||
const pending = offer("offered", { autoOpen: true });
|
||||
const html = cardHtml(pending);
|
||||
assert.match(html, /大约 8 题,会用掉 3 点/);
|
||||
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
|
||||
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
|
||||
assert.equal(html.includes("继续核对出生时间"), false);
|
||||
assert.match(html, new RegExp(rectificationOfferCardCopy(pending).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
|
||||
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
|
||||
const bound = bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
|
||||
const opened = bound![0]!.rectificationOffer as RectificationOfferRecord;
|
||||
assert.equal(opened.status, "opened");
|
||||
assert.equal(opened.autoOpen, true);
|
||||
assert.equal(opened.caseId, CASE_ID);
|
||||
const openedHtml = cardHtml(opened);
|
||||
assert.match(openedHtml, new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
|
||||
assert.match(openedHtml, /继续核对出生时间/);
|
||||
assert.equal(openedHtml.includes(RECTIFICATION_OFFER_START_LABEL), false);
|
||||
});
|
||||
|
||||
test("a failed direct open leaves the offer offered and clickable", () => {
|
||||
const pending = offer("offered", { autoOpen: true });
|
||||
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
|
||||
const opened = null;
|
||||
const next = opened ? bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) : null;
|
||||
assert.equal(next, null);
|
||||
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).status, "offered");
|
||||
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
|
||||
const html = cardHtml(pending);
|
||||
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
|
||||
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
|
||||
assert.equal(html.includes("disabled=\"\""), false);
|
||||
assert.equal(html.includes("disabled=\"disabled\""), false);
|
||||
});
|
||||
|
||||
test("stored offers offered, opened, and returned still parse; returned hides the card", () => {
|
||||
for (const status of ["offered", "opened", "returned"] as const) {
|
||||
const parsed = parseRectificationOfferRecord(offer(status));
|
||||
assert.equal(parsed?.status, status);
|
||||
assert.equal(parsed?.autoOpen, undefined);
|
||||
assert.equal("autoOpen" in (parsed ?? {}), false);
|
||||
}
|
||||
const flagged = parseRectificationOfferRecord(offer("offered", { autoOpen: true }));
|
||||
assert.equal(flagged?.status, "offered");
|
||||
assert.equal(flagged?.autoOpen, true);
|
||||
assert.equal(parseRectificationOfferRecord({ ...offer("opened"), extra: true }), null);
|
||||
assert.equal(cardHtml(offer("returned")), "");
|
||||
assert.match(cardHtml(offer("opened")), new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
|
||||
assert.match(cardHtml(offer("offered")), new RegExp(RECTIFICATION_OFFER_START_LABEL));
|
||||
});
|
||||
|
||||
test("the free reply stores the direct-open offer still offered, with autoOpen", () => {
|
||||
// The record is not opened yet. The name of the attacher is historical.
|
||||
const sentence = RECTIFICATION_DIRECT_NEW_COPY;
|
||||
const card = offer("offered", { autoOpen: true });
|
||||
const attached = attachOpenedOfferToFreeMessage(
|
||||
[{ role: "user", text: "请帮我核对出生时间" }, { role: "assistant", text: sentence, responseKind: "smalltalk" }],
|
||||
{ sentence, offer: card },
|
||||
);
|
||||
assert.ok(Array.isArray(attached));
|
||||
assert.deepEqual(attached[1]!.rectificationOffer, card);
|
||||
assert.equal((attached[1]!.rectificationOffer as RectificationOfferRecord).status, "offered");
|
||||
assert.equal(attachOpenedOfferToFreeMessage(attached, { sentence, offer: card }), "present");
|
||||
});
|
||||
|
||||
@@ -279,53 +279,6 @@ test("the open route binds the offer only for a consultation source; the source
|
||||
assert.doesNotMatch(server, /createAdminSupabaseClient/);
|
||||
});
|
||||
|
||||
test("an auto-open offer shows the ordinary card until the case is bound, and a reload keeps autoOpen", async () => {
|
||||
const pending = offer("offered", { autoOpen: true });
|
||||
const waiting = [
|
||||
{ role: "user" as const, text: "请帮我核对出生时间" },
|
||||
{ role: "assistant" as const, text: "好,我们开始核对出生时间。", responseKind: "smalltalk" as const, rectificationOffer: pending },
|
||||
];
|
||||
const html = renderToString(createElement(ChatTranscript, transcriptProps(waiting, [])));
|
||||
assert.match(html, /开始核对出生时间/);
|
||||
assert.match(html, /大约 8 题,会用掉 3 点;开始答第一题时才扣。/);
|
||||
assert.doesNotMatch(html, /出生时间核对已经开始/);
|
||||
assert.doesNotMatch(html, /继续核对出生时间/);
|
||||
|
||||
const bound = bindOfferToCase(waiting, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
|
||||
const openedHtml = renderToString(createElement(ChatTranscript, transcriptProps(bound as ChatMessage[], [])));
|
||||
assert.match(openedHtml, /出生时间核对已经开始/);
|
||||
assert.match(openedHtml, /继续核对出生时间/);
|
||||
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).status, "opened");
|
||||
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
|
||||
|
||||
const failed = waiting;
|
||||
assert.equal((failed[1]!.rectificationOffer as RectificationOfferRecord).status, "offered");
|
||||
|
||||
const { readSessions } = await import("../src/lib/home-cloud-sync.ts");
|
||||
const reloaded = readSessions([{
|
||||
id: SOURCE_SESSION,
|
||||
title: "虚构",
|
||||
theme: "marriage",
|
||||
messages: [{ role: "assistant", text: "还没打开", rectificationOffer: pending }],
|
||||
}], null);
|
||||
assert.equal(reloaded.sessions[0]!.messages[0]!.rectificationOffer?.status, "offered");
|
||||
assert.equal(reloaded.sessions[0]!.messages[0]!.rectificationOffer?.autoOpen, true);
|
||||
const old = readSessions([{
|
||||
id: SOURCE_SESSION,
|
||||
title: "虚构",
|
||||
theme: "marriage",
|
||||
messages: [
|
||||
{ role: "assistant", text: "a", rectificationOffer: offer("offered") },
|
||||
{ role: "assistant", text: "b", rectificationOffer: offer("opened", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) },
|
||||
{ role: "assistant", text: "c", rectificationOffer: offer("returned", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) },
|
||||
],
|
||||
}], null);
|
||||
assert.deepEqual(old.sessions[0]!.messages.map((message) => message.rectificationOffer?.status), ["offered", "opened", "returned"]);
|
||||
assert.equal(old.sessions[0]!.messages[0]!.rectificationOffer?.autoOpen, undefined);
|
||||
const returnedHtml = renderToString(createElement(ChatTranscript, transcriptProps(old.sessions[0]!.messages.slice(2) as ChatMessage[], [])));
|
||||
assert.doesNotMatch(returnedHtml, /rectification-offer-card/);
|
||||
});
|
||||
|
||||
test("stored sessions keep the offer and the divider when the browser reads them back (BUG-1248/1250)", async () => {
|
||||
const { readSessions } = await import("../src/lib/home-cloud-sync.ts");
|
||||
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* BUG-1307: the opening says how close the recorded minute is to the nearest
|
||||
* chart change. Question order stays off unless the env flag is "on".
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildOpeningBrief } from "../src/lib/rectification-agentic/v9/agent-run-messages.ts";
|
||||
import { orderDiscriminatorsForNearestBoundary } from "../src/lib/rectification-agentic/v9/method-followup.ts";
|
||||
import { parseV9CaseDossier } from "../src/lib/rectification-agentic/v9/tool-service.ts";
|
||||
import { fetchOpeningBoundary, openingFactsFromBoundaryPayload, resolveOpeningBoundaryFacts } from "../src/lib/rectification-agentic/opening-boundary.ts";
|
||||
import { scanCaseSegments } from "../src/lib/rectification-agentic/v9/segment-scan.ts";
|
||||
import {
|
||||
nearestBoundaryFromTransitions,
|
||||
nearestBoundaryProbesEnabled,
|
||||
orderByBoundaryDomains,
|
||||
topicsForLayers,
|
||||
} from "../src/lib/rectification-agentic/nearest-boundary.ts";
|
||||
import {
|
||||
OPENING_BODY_JARGON,
|
||||
isAcceptableOpeningBody,
|
||||
openingBoundarySentence,
|
||||
openingSpokenBody,
|
||||
} from "../src/lib/rectification-agentic/user-copy.ts";
|
||||
import { dossierFixture } from "./rectification-v9-test-support.ts";
|
||||
|
||||
const RANGE = ["04:45", "05:15"] as const;
|
||||
const PLAIN = "我们来把你的出生时间缩小到更准的范围,现在先在 04:45–05:15 之间找。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。";
|
||||
const BANNED = ["秒", "记录时间错了", "记录时间不准", "已确认", ...OPENING_BODY_JARGON];
|
||||
|
||||
function assertPlain(text: string): void {
|
||||
for (const word of BANNED) assert.equal(text.includes(word), false, word);
|
||||
assert.doesNotMatch(text, /[??]/);
|
||||
assert.ok(text.split(/(?<=。)/).filter(Boolean).length <= 3, text);
|
||||
}
|
||||
|
||||
test("default opening stays two sentences", () => {
|
||||
assert.equal(openingSpokenBody(RANGE), PLAIN);
|
||||
assert.equal(openingSpokenBody(null), "我们来把你的出生时间缩小到更准的范围。做法很简单:你说几件人生里的大事和大概年月,我拿去和星盘对照。");
|
||||
assert.equal(openingBoundarySentence({ kind: "period_only" }), null);
|
||||
assert.equal(openingBoundarySentence({ kind: "unavailable" }), null);
|
||||
assert.equal(openingSpokenBody(RANGE, null, { kind: "period_only" }), PLAIN);
|
||||
assert.equal(isAcceptableOpeningBody(PLAIN, RANGE), true);
|
||||
});
|
||||
|
||||
test("near, under a minute, stable, and only the birth chart", () => {
|
||||
const near = openingSpokenBody(RANGE, null, { kind: "near", distanceMinutes: 4, topics: ["父母", "工作", "<script>"] });
|
||||
assert.match(near, /差 4 分钟,两边不一样的是父母、工作。/);
|
||||
assert.doesNotMatch(near, /接下来先问这几件|script/);
|
||||
const close = openingSpokenBody(RANGE, null, { kind: "near", distanceMinutes: 0, topics: ["婚恋"], askFirst: true });
|
||||
assert.match(close, /不到 1 分钟,两边不一样的是婚恋,接下来先问这几件。/);
|
||||
const stable = openingBoundarySentence({ kind: "stable" });
|
||||
assert.equal(stable, "你填的时间附近这些盘都不换,先从经历开始。");
|
||||
const onlyBirth = openingSpokenBody(RANGE, null, { kind: "near", distanceMinutes: 2, topics: [] });
|
||||
assert.match(onlyBirth, /差 2 分钟。/);
|
||||
assert.doesNotMatch(onlyBirth, /两边不一样/);
|
||||
for (const text of [near, close, openingSpokenBody(RANGE, null, { kind: "stable" }), onlyBirth]) {
|
||||
assertPlain(text);
|
||||
assert.equal(isAcceptableOpeningBody(text, RANGE), true);
|
||||
assert.equal(isAcceptableOpeningBody(PLAIN, RANGE, null, { kind: "stable" }), false);
|
||||
}
|
||||
assert.equal(isAcceptableOpeningBody(`${near}再补一句。`, RANGE), false);
|
||||
});
|
||||
|
||||
test("d1 change is named in the same sentence, and no d1 change keeps today's sentence", () => {
|
||||
const changed = openingFactsFromBoundaryPayload({
|
||||
found: true,
|
||||
distance_minutes: 4,
|
||||
layers: [{ layer: "d1" }, { layer: "d12" }, { layer: "d10" }],
|
||||
}, false);
|
||||
assert.equal(changed.d1Changed, true);
|
||||
assert.deepEqual(changed.topics, ["工作", "父母"]);
|
||||
assert.equal(openingBoundarySentence(changed), "你填的时间离最近会换的地方差 4 分钟,这里连本命上升都换,两边不一样的是工作、父母。");
|
||||
const onlyBirth = openingFactsFromBoundaryPayload({
|
||||
found: true,
|
||||
distance_minutes: 0,
|
||||
layers: [{ layer: "d1" }],
|
||||
}, true);
|
||||
assert.equal(onlyBirth.d1Changed, true);
|
||||
assert.deepEqual(onlyBirth.topics, []);
|
||||
assert.equal(onlyBirth.askFirst, false);
|
||||
assert.equal(openingBoundarySentence(onlyBirth), "你填的时间离最近会换的地方不到 1 分钟,这里连本命上升都换。");
|
||||
assert.equal(
|
||||
openingBoundarySentence({ kind: "near", distanceMinutes: 2, topics: [], d1Changed: true, askFirst: true }),
|
||||
"你填的时间离最近会换的地方差 2 分钟,这里连本命上升都换。",
|
||||
);
|
||||
const asked = openingBoundarySentence({ kind: "near", distanceMinutes: 2, topics: ["婚恋"], d1Changed: true, askFirst: true });
|
||||
assert.equal(asked, "你填的时间离最近会换的地方差 2 分钟,这里连本命上升都换,两边不一样的是婚恋,接下来先问这几件。");
|
||||
const unchanged = openingFactsFromBoundaryPayload({
|
||||
found: true,
|
||||
distance_minutes: 4,
|
||||
layers: [{ layer: "d12" }, { layer: "d10" }],
|
||||
}, false);
|
||||
assert.equal(unchanged.d1Changed, false);
|
||||
assert.equal(openingBoundarySentence(unchanged), "你填的时间离最近会换的地方差 4 分钟,两边不一样的是工作、父母。");
|
||||
assert.equal(
|
||||
openingBoundarySentence({ kind: "near", distanceMinutes: 0, topics: ["婚恋"] }),
|
||||
"你填的时间离最近会换的地方不到 1 分钟,两边不一样的是婚恋。",
|
||||
);
|
||||
assert.equal(openingBoundarySentence({ kind: "stable" }), "你填的时间附近这些盘都不换,先从经历开始。");
|
||||
assert.equal(openingBoundarySentence({ kind: "period_only" }), null);
|
||||
assert.equal(openingBoundarySentence({ kind: "unavailable" }), null);
|
||||
const askedFacts = { kind: "near" as const, distanceMinutes: 2, topics: ["婚恋"], d1Changed: true, askFirst: true };
|
||||
for (const facts of [changed, onlyBirth, askedFacts, unchanged]) {
|
||||
const text = openingSpokenBody(RANGE, null, facts);
|
||||
assertPlain(text);
|
||||
assert.equal(isAcceptableOpeningBody(text, RANGE, null, facts), true);
|
||||
assert.equal(text.includes("D1"), false);
|
||||
assert.equal(text.split(/(?<=。)/).filter(Boolean).length, 3);
|
||||
}
|
||||
assert.match(openingSpokenBody(RANGE, null, changed), /这里连本命上升都换,两边不一样的是工作、父母。/);
|
||||
});
|
||||
|
||||
test("topics come from the layer allowlist, not the payload string", () => {
|
||||
assert.deepEqual(topicsForLayers(["d12", "d10", "d1", "kp1", "nope"]), ["工作", "父母"]);
|
||||
const facts = openingFactsFromBoundaryPayload({
|
||||
found: true,
|
||||
distance_minutes: 0,
|
||||
at: "19:15",
|
||||
topics: ["已确认秒"],
|
||||
layers: [
|
||||
{ layer: "d12", topic: "记录时间错了" },
|
||||
{ layer: "kp1", topic: "工作" },
|
||||
],
|
||||
}, false);
|
||||
assert.equal(facts.kind, "near");
|
||||
assert.deepEqual(facts.topics, ["父母"]);
|
||||
assert.equal(facts.askFirst, false);
|
||||
assert.equal(openingFactsFromBoundaryPayload({ found: false, reason: "no_transition" }, true).kind, "stable");
|
||||
});
|
||||
|
||||
test("question order is a switch and defaults off", () => {
|
||||
assert.equal(nearestBoundaryProbesEnabled(undefined), false);
|
||||
assert.equal(nearestBoundaryProbesEnabled("on"), true);
|
||||
const rows = [
|
||||
{ id: "career", eventProbe: { domain: "career" }, contrastProbe: null },
|
||||
{ id: "family", eventProbe: { domain: "family" }, contrastProbe: null },
|
||||
{ id: "money", eventProbe: null, contrastProbe: { domain: "finance" } },
|
||||
];
|
||||
const scan = { transitions: [
|
||||
{ layer: "d12", at: "19:16", from_sign: "白羊座", to_sign: "金牛座" },
|
||||
{ layer: "kp1", at: "19:15", from_sign: "白羊座", to_sign: "金牛座" },
|
||||
] };
|
||||
const boundary = nearestBoundaryFromTransitions(scan.transitions, "19:15");
|
||||
assert.equal(boundary.distanceMinutes, 1);
|
||||
assert.deepEqual(boundary.domains, ["family"]);
|
||||
const input = { windowScan: scan, reportedTime: "19:15", birthTimeSource: "approximate" };
|
||||
assert.equal(orderDiscriminatorsForNearestBoundary(rows, input), rows);
|
||||
const asked = orderDiscriminatorsForNearestBoundary(rows, { ...input, probeFlag: "on" });
|
||||
assert.deepEqual(asked.map((row) => row.id), ["family", "career", "money"]);
|
||||
assert.equal(orderDiscriminatorsForNearestBoundary(rows, { ...input, probeFlag: "on", birthTimeSource: "period_only" }), rows);
|
||||
assert.equal(orderDiscriminatorsForNearestBoundary(rows, {
|
||||
...input,
|
||||
probeFlag: "on",
|
||||
windowScan: { transitions: [{ layer: "d12", at: "19:30", from_sign: "白羊座", to_sign: "金牛座" }] },
|
||||
}), rows);
|
||||
const same = orderByBoundaryDomains(rows, ["family"], (row) => row.eventProbe?.domain ?? row.contrastProbe?.domain ?? "");
|
||||
assert.equal(same[0]?.id, "family");
|
||||
});
|
||||
|
||||
test("period_only and a missing clock do not scan", async () => {
|
||||
let called = 0;
|
||||
const loadCompute = async () => {
|
||||
called += 1;
|
||||
throw new Error("should not scan");
|
||||
};
|
||||
assert.equal((await resolveOpeningBoundaryFacts({
|
||||
birthTimeSource: "period_only",
|
||||
reportedBirthTime: "19:15",
|
||||
candidateRange: { start_time: "19:00", end_time: "19:30" },
|
||||
loadCompute,
|
||||
})).kind, "period_only");
|
||||
assert.equal((await resolveOpeningBoundaryFacts({
|
||||
birthTimeSource: null,
|
||||
reportedBirthTime: null,
|
||||
candidateRange: null,
|
||||
loadCompute,
|
||||
})).kind, "unavailable");
|
||||
assert.equal(called, 0);
|
||||
const dossier = parseV9CaseDossier(dossierFixture());
|
||||
assert.ok(dossier);
|
||||
const brief = buildOpeningBrief(dossier);
|
||||
assert.match(brief, /正文两句大白话/);
|
||||
assert.doesNotMatch(brief, /服务端另有一句/);
|
||||
const withSentence = buildOpeningBrief(dossier, null, "你填的时间附近这些盘都不换,先从经历开始。");
|
||||
assert.match(withSentence, /原样放在这两句之间/);
|
||||
assert.match(brief, /正文不用大运/);
|
||||
});
|
||||
|
||||
test("opening and segment scans send the snapshot ayanamsa", async (t) => {
|
||||
const bodies: Array<Record<string, unknown>> = [];
|
||||
t.mock.method(globalThis, "fetch", async (_endpoint: unknown, init?: RequestInit) => {
|
||||
const body = JSON.parse(String(init?.body)) as {
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
ayanamsa?: string;
|
||||
boundary_charts?: unknown;
|
||||
};
|
||||
bodies.push(body);
|
||||
const start = Date.parse(`${body.start_at}:00Z`);
|
||||
const end = Date.parse(`${body.end_at}:00Z`);
|
||||
const width = (end - start) / 60000 + 1;
|
||||
const minutes = Array.from({ length: width }, (_, offset) => {
|
||||
const stamp = new Date(start + offset * 60000).toISOString().slice(0, 16);
|
||||
return { offset, date: stamp.slice(0, 10), time: stamp.slice(11), signs: { D1: 1, D9: 2, D10: 3 } };
|
||||
});
|
||||
return Response.json({
|
||||
start_at: body.start_at,
|
||||
end_at: body.end_at,
|
||||
ayanamsa: body.ayanamsa,
|
||||
node_mode: "mean",
|
||||
step_minutes: 1,
|
||||
window_minutes: width,
|
||||
minutes,
|
||||
nearest_boundary: { found: false, reason: "no_transition" },
|
||||
});
|
||||
});
|
||||
const base = {
|
||||
birth_date: "2000-06-14",
|
||||
latitude: 40,
|
||||
longitude: -74,
|
||||
timezone_offset: -4,
|
||||
reported_birth_time: "09:10",
|
||||
};
|
||||
const range = { start_time: "09:00", end_time: "09:06" };
|
||||
await fetchOpeningBoundary({ snapshot: { ...base, ayanamsa: "lahiri" }, range, recordedTime: "09:10" });
|
||||
await scanCaseSegments({ snapshot: { ...base, ayanamsa: "lahiri" }, range, targets: ["D1", "D9", "D10"] });
|
||||
await fetchOpeningBoundary({ snapshot: base, range, recordedTime: "09:10" });
|
||||
await scanCaseSegments({ snapshot: base, range, targets: ["D1", "D9", "D10"] });
|
||||
assert.equal(bodies.length, 4);
|
||||
assert.deepEqual(bodies.map((body) => body.ayanamsa), ["lahiri", "lahiri", "raman", "raman"]);
|
||||
assert.ok(Array.isArray(bodies[0]?.boundary_charts));
|
||||
assert.equal(bodies[1]?.boundary_charts, undefined);
|
||||
assert.equal(bodies[0]?.node_mode, undefined);
|
||||
assert.equal(bodies[1]?.node_mode, undefined);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
"""Nearest divisional-chart change around a recorded minute.
|
||||
|
||||
Reads ``window_scan`` transitions only. Does not score and does not change
|
||||
the scan payload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Same domain map as event_probes.LAYER_DOMAIN. Topics are the opening words.
|
||||
CHART_LAYERS = ("d9", "d10", "d4", "d5", "d24", "d7", "d12", "d2", "d11", "d30", "d1")
|
||||
LAYER_DOMAIN = {
|
||||
"d9": "relationship",
|
||||
"d10": "career",
|
||||
"d4": "relocation",
|
||||
"d5": "education",
|
||||
"d24": "education",
|
||||
"d7": "family",
|
||||
"d12": "family",
|
||||
"d2": "finance",
|
||||
"d11": "finance",
|
||||
"d30": "health_pressure",
|
||||
}
|
||||
LAYER_TOPIC = {
|
||||
"d9": "婚恋",
|
||||
"d10": "工作",
|
||||
"d4": "住处",
|
||||
"d5": "学业",
|
||||
"d24": "学业",
|
||||
"d7": "子女",
|
||||
"d12": "父母",
|
||||
"d2": "钱",
|
||||
"d11": "钱",
|
||||
"d30": "身体",
|
||||
}
|
||||
|
||||
|
||||
def _clock(value: Any) -> int | None:
|
||||
raw = str(value or "")[:5]
|
||||
if len(raw) != 5 or raw[2] != ":" or not (raw[:2].isdigit() and raw[3:].isdigit()):
|
||||
return None
|
||||
hour, minute = int(raw[:2]), int(raw[3:])
|
||||
if hour > 23 or minute > 59:
|
||||
return None
|
||||
return hour * 60 + minute
|
||||
|
||||
|
||||
def _distance(left: int, right: int) -> int:
|
||||
span = abs(left - right)
|
||||
return min(span, 1440 - span)
|
||||
|
||||
|
||||
def nearest_chart_boundary(scan: dict[str, Any] | None, recorded_time: str | None) -> dict[str, Any]:
|
||||
"""Closest real sign change to ``recorded_time``.
|
||||
|
||||
Display-only layers and segment-start rows that do not change sign are
|
||||
ignored. An equal distance keeps the earlier clock.
|
||||
"""
|
||||
recorded = _clock(recorded_time)
|
||||
rows = scan.get("transitions") if isinstance(scan, dict) else None
|
||||
if recorded is None or not isinstance(rows, list):
|
||||
return {"found": False, "reason": "unavailable", "layers": [], "topics": [], "domains": []}
|
||||
changes: list[tuple[int, str, str, str]] = []
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
layer = str(row.get("layer") or "")
|
||||
if layer not in CHART_LAYERS:
|
||||
continue
|
||||
before, after = row.get("from_sign"), row.get("to_sign")
|
||||
if not isinstance(before, str) or not isinstance(after, str) or before == after:
|
||||
continue
|
||||
at = _clock(row.get("at"))
|
||||
if at is None:
|
||||
continue
|
||||
changes.append((at, layer, before, after))
|
||||
if not changes:
|
||||
return {"found": False, "reason": "no_transition", "layers": [], "topics": [], "domains": []}
|
||||
best_at = min(changes, key=lambda item: (_distance(item[0], recorded), item[0]))[0]
|
||||
order = {layer: index for index, layer in enumerate(CHART_LAYERS)}
|
||||
layers: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for at, layer, before, after in sorted(changes, key=lambda item: order[item[1]]):
|
||||
if at != best_at or layer in seen:
|
||||
continue
|
||||
seen.add(layer)
|
||||
layers.append({
|
||||
"layer": layer,
|
||||
"from_sign": before,
|
||||
"to_sign": after,
|
||||
"topic": LAYER_TOPIC.get(layer),
|
||||
"domain": LAYER_DOMAIN.get(layer),
|
||||
})
|
||||
topics: list[str] = []
|
||||
domains: list[str] = []
|
||||
for row in layers:
|
||||
topic, domain = row["topic"], row["domain"]
|
||||
if isinstance(topic, str) and topic not in topics:
|
||||
topics.append(topic)
|
||||
if isinstance(domain, str) and domain not in domains:
|
||||
domains.append(domain)
|
||||
return {
|
||||
"found": True,
|
||||
"reason": None,
|
||||
"at": f"{best_at // 60:02d}:{best_at % 60:02d}",
|
||||
"distance_minutes": _distance(best_at, recorded),
|
||||
"layers": layers,
|
||||
"topics": topics,
|
||||
"domains": domains,
|
||||
}
|
||||
@@ -13,25 +13,7 @@ from typing import Any
|
||||
from scripts import domain_calculation_service, varga
|
||||
|
||||
TARGETS = ("D1", "D9", "D10")
|
||||
BOUNDARY_CHARTS = ("D1", "D2", "D4", "D5", "D7", "D9", "D10", "D11", "D12", "D24", "D30")
|
||||
VERSION = "rectification-varga-segments-v1"
|
||||
SEGMENT_AYANAMSAS = frozenset(("raman", "lahiri", "kp", "true_citra", "true_pushya", "fagan_bradley"))
|
||||
|
||||
|
||||
def _segment_ayanamsa(value: Any) -> str:
|
||||
"""Blank stays Raman. Unknown names raise; they do not fall back."""
|
||||
if value is None or (isinstance(value, str) and not value.strip()):
|
||||
return "raman"
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("invalid_ayanamsa")
|
||||
key = value.strip().lower().replace("-", "_")
|
||||
if key in {"krishnamurti", "krishnamurti_paddhati", "krishnamurti/kp"}:
|
||||
key = "kp"
|
||||
elif key == "true_chitra":
|
||||
key = "true_citra"
|
||||
if key not in SEGMENT_AYANAMSAS:
|
||||
raise ValueError("invalid_ayanamsa")
|
||||
return key
|
||||
|
||||
|
||||
def _number(body, name, low, high):
|
||||
@@ -63,17 +45,12 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None)
|
||||
or any(not isinstance(target, str) or target not in TARGETS for target in targets)
|
||||
or len(set(targets)) != len(targets)):
|
||||
raise ValueError("invalid_target_charts")
|
||||
boundary_charts, recorded_time = _boundary_request(body)
|
||||
ayanamsa = _segment_ayanamsa(body.get("ayanamsa"))
|
||||
profile = {
|
||||
"lat": _number(body, "latitude", -90, 90),
|
||||
"lon": _number(body, "longitude", -180, 180),
|
||||
"tz": _number(body, "timezone_offset", -14, 14),
|
||||
"ayanamsa": ayanamsa, "node_mode": "mean",
|
||||
"ayanamsa": "raman", "node_mode": "mean",
|
||||
}
|
||||
if boundary_charts:
|
||||
profile["boundary_charts"] = list(boundary_charts)
|
||||
profile["recorded_time"] = recorded_time
|
||||
key = hashlib.sha256(json.dumps({"version": VERSION, "start": str(start), "end": str(end), "targets": targets, **profile}, sort_keys=True).encode()).hexdigest()
|
||||
root = cache_dir if cache_dir is not None else Path(os.environ.get("JYOTISH_CACHE_DIR", str(Path(__file__).resolve().parents[1] / "scratch" / "local"))) / "varga_segments"
|
||||
path = root / f"{key}.json"
|
||||
@@ -86,34 +63,22 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None)
|
||||
pass
|
||||
minutes = []
|
||||
segments = {target: [] for target in targets}
|
||||
boundary_signs: list[dict[str, int]] = []
|
||||
wanted = list(dict.fromkeys([*targets, *boundary_charts]))
|
||||
divisions = [int(chart[1:]) for chart in wanted if chart not in {"D1", "D11"}]
|
||||
d11_chart = None
|
||||
if "D11" in wanted:
|
||||
from scripts.active_rectification_event_engine import _d11_chart as d11_chart
|
||||
for offset in range(width):
|
||||
moment = start + timedelta(minutes=offset)
|
||||
chart = domain_calculation_service.compute_chart({
|
||||
"lat": profile["lat"], "lon": profile["lon"], "tz": profile["tz"],
|
||||
"ayanamsa": ayanamsa, "node_mode": "mean",
|
||||
"year": moment.year, "month": moment.month, "day": moment.day,
|
||||
**profile, "year": moment.year, "month": moment.month, "day": moment.day,
|
||||
"hour": moment.hour, "minute": moment.minute,
|
||||
})
|
||||
ascendant = float(chart["ascendant"]["lon"])
|
||||
planets = {name: float(item["lon"]) for name, item in chart.get("planets", {}).items() if isinstance(item, dict) and isinstance(item.get("lon"), (int, float))}
|
||||
all_signs = {"D1": int(ascendant // 30) % 12} if "D1" in wanted else {}
|
||||
signs = {"D1": int(ascendant // 30) % 12} if "D1" in targets else {}
|
||||
divisions = [int(target[1:]) for target in targets if target != "D1"]
|
||||
if divisions:
|
||||
planets = {name: float(item["lon"]) for name, item in chart.get("planets", {}).items() if isinstance(item, dict) and isinstance(item.get("lon"), (int, float))}
|
||||
computed = varga.calc_all_vargas(planets, ascendant, divisions=divisions)
|
||||
for value in computed.values():
|
||||
all_signs[f"D{value['_meta']['div']}"] = int(value["Ascendant"]["sign_idx"])
|
||||
if "D11" in wanted:
|
||||
all_signs["D11"] = int(d11_chart(planets, ascendant)["Ascendant"]["sign_idx"])
|
||||
signs = {target: all_signs[target] for target in targets}
|
||||
signs[f"D{value['_meta']['div']}"] = int(value["Ascendant"]["sign_idx"])
|
||||
stamp = moment.strftime("%Y-%m-%dT%H:%M")
|
||||
minutes.append({"offset": offset, "time": stamp[11:], "date": stamp[:10], "signs": signs})
|
||||
if boundary_charts:
|
||||
boundary_signs.append({chart: all_signs[chart] for chart in boundary_charts if chart in all_signs})
|
||||
for target in targets:
|
||||
rows = segments[target]
|
||||
sign = signs[target]
|
||||
@@ -124,9 +89,7 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None)
|
||||
payload = {"version": VERSION, "window_key": key, "start_at": body["start_at"], "end_at": body["end_at"],
|
||||
"window_minutes": width, "target_charts": targets, "minutes": minutes,
|
||||
"segments": segments, "full_window_unique": {target: len({row['sign'] for row in rows}) == 1 for target, rows in segments.items()},
|
||||
"ayanamsa": ayanamsa, "node_mode": "mean", "step_minutes": 1}
|
||||
if boundary_charts:
|
||||
payload["nearest_boundary"] = _nearest_from_signs(boundary_signs, [row["time"] for row in minutes], recorded_time)
|
||||
"ayanamsa": "raman", "node_mode": "mean", "step_minutes": 1}
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=root, delete=False) as handle:
|
||||
json.dump(payload, handle, ensure_ascii=False, sort_keys=True)
|
||||
@@ -138,47 +101,6 @@ def scan_varga_segments(body: dict[str, Any], *, cache_dir: Path | None = None)
|
||||
return payload
|
||||
|
||||
|
||||
def _boundary_request(body: dict[str, Any]) -> tuple[tuple[str, ...], str | None]:
|
||||
if "boundary_charts" not in body:
|
||||
return (), None
|
||||
charts = body.get("boundary_charts")
|
||||
if (not isinstance(charts, list) or not charts
|
||||
or any(not isinstance(chart, str) or chart not in BOUNDARY_CHARTS for chart in charts)
|
||||
or len(set(charts)) != len(charts)):
|
||||
raise ValueError("invalid_boundary_charts")
|
||||
recorded = body.get("recorded_time")
|
||||
if not isinstance(recorded, str) or len(recorded) < 5 or recorded[2] != ":":
|
||||
raise ValueError("invalid_recorded_time")
|
||||
hour, minute = recorded[:2], recorded[3:5]
|
||||
if not (hour.isdigit() and minute.isdigit()):
|
||||
raise ValueError("invalid_recorded_time")
|
||||
hour_value, minute_value = int(hour), int(minute)
|
||||
if hour_value > 23 or minute_value > 59:
|
||||
raise ValueError("invalid_recorded_time")
|
||||
return tuple(charts), f"{hour_value:02d}:{minute_value:02d}"
|
||||
|
||||
|
||||
def _nearest_from_signs(signs_by_minute: list[dict[str, int]], times: list[str], recorded_time: str | None) -> dict[str, Any]:
|
||||
from scripts.jyotish_engine import SIGNS, SIGNS_CN
|
||||
from scripts.rectification.nearest_boundary import nearest_chart_boundary
|
||||
|
||||
transitions: list[dict[str, str]] = []
|
||||
previous: dict[str, int] | None = None
|
||||
for signs, clock in zip(signs_by_minute, times):
|
||||
if previous is not None:
|
||||
for chart, sign in signs.items():
|
||||
before = previous.get(chart)
|
||||
if isinstance(before, int) and isinstance(sign, int) and before != sign and 0 <= before <= 11 and 0 <= sign <= 11:
|
||||
transitions.append({
|
||||
"layer": chart.lower(),
|
||||
"at": clock,
|
||||
"from_sign": SIGNS_CN[SIGNS[before]],
|
||||
"to_sign": SIGNS_CN[SIGNS[sign]],
|
||||
})
|
||||
previous = signs
|
||||
return nearest_chart_boundary({"transitions": transitions}, recorded_time)
|
||||
|
||||
|
||||
def handle_varga_segments(handler, body):
|
||||
from scripts.offline_compute_mixins import BadRequest
|
||||
try:
|
||||
|
||||
@@ -1,595 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pre-registered D12 parent-probe scoring. Research only.
|
||||
|
||||
The reading rules live in
|
||||
``docs/research/d12_parent_probes_preregistration_2026_10_12.json``.
|
||||
This module does not compute an ephemeris. Callers pass longitudes from
|
||||
``domain_calculation_service.compute_chart`` and signs from ``varga.calc_varga``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from random import Random
|
||||
from typing import Any
|
||||
|
||||
_SCRIPTS = str(Path(__file__).resolve().parents[1])
|
||||
if _SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, _SCRIPTS)
|
||||
|
||||
import varga
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PREREG_PATH = ROOT / "docs" / "research" / "d12_parent_probes_preregistration_2026_10_12.json"
|
||||
HOLDOUT_PATH = ROOT / "references" / "real_case_calibration" / "minute_rectification_holdout_v5.json"
|
||||
FACTS_PATH = ROOT / "references" / "real_case_calibration" / "parent_facts_v1.json"
|
||||
RESULTS_PATH = ROOT / "docs" / "research" / "d12_parent_probes_results_2026_10_12.json"
|
||||
|
||||
PROBES: tuple[str, ...] = (
|
||||
"mother_more_daily_care",
|
||||
"father_financial_lead",
|
||||
"father_guided_study",
|
||||
"father_work_institutional",
|
||||
"father_work_commerce",
|
||||
"parents_long_apart",
|
||||
)
|
||||
KENDRA = frozenset({1, 4, 7, 10})
|
||||
SEPARATED_DELTAS = frozenset({1, 5, 7, 11})
|
||||
ANSWERS = frozenset({"yes", "no", "unclear"})
|
||||
PREDICTIONS = frozenset({"yes", "no", "abstain"})
|
||||
GRADES = frozenset({"biography", "interview", "encyclopedia"})
|
||||
ALPHA = Decimal("0.0125")
|
||||
ROBUST_MAX = Decimal("0.2")
|
||||
CONTROL_P_MAX = Decimal("0.01")
|
||||
MIN_DECISIVE = 4
|
||||
MIN_INCLUDED = 60
|
||||
MIN_DIFFERING = 20
|
||||
PERMUTATIONS = 999
|
||||
PERM_SEED = 20261012
|
||||
CONTROL_SEED = 20261013
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Placement:
|
||||
d12_lagna: int
|
||||
d1_lagna: int
|
||||
d12_signs: Mapping[str, int]
|
||||
d1_signs: Mapping[str, int]
|
||||
|
||||
|
||||
def canonical_bytes(payload: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def case_id_digest(case_ids: Sequence[str]) -> str:
|
||||
text = "\n".join(sorted(case_ids))
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def load_prereg(path: Path = PREREG_PATH) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def assert_prereg_locked(prereg: Mapping[str, Any]) -> None:
|
||||
if prereg.get("id") != "d12_parent_probes_preregistration_2026_10_12":
|
||||
raise SystemExit("preregistration id mismatch")
|
||||
if list(prereg.get("probes") or []) != list(PROBES):
|
||||
raise SystemExit("preregistration probe list mismatch")
|
||||
if int(prereg["permutation"]["n"]) != PERMUTATIONS:
|
||||
raise SystemExit("preregistration permutation count mismatch")
|
||||
if int(prereg["permutation"]["seed"]) != PERM_SEED:
|
||||
raise SystemExit("preregistration permutation seed mismatch")
|
||||
if int(prereg["positive_control"]["seed"]) != CONTROL_SEED:
|
||||
raise SystemExit("preregistration control seed mismatch")
|
||||
if int(prereg["positive_control"]["minimum_differing_cases"]) != MIN_DIFFERING:
|
||||
raise SystemExit("preregistration control minimum mismatch")
|
||||
if int(prereg["birth_records"]["expected_case_count"]) != 77:
|
||||
raise SystemExit("preregistration case count mismatch")
|
||||
if "at least 60" not in str(prereg["gates"]["sample"]):
|
||||
raise SystemExit("preregistration sample gate mismatch")
|
||||
if format(prereg["family"]["bonferroni_alpha"], ".4f") != "0.0125":
|
||||
raise SystemExit("preregistration alpha mismatch")
|
||||
if format(prereg["robustness"]["gate_max_change_rate"], ".1f") != "0.2":
|
||||
raise SystemExit("preregistration robustness line mismatch")
|
||||
if format(prereg["positive_control"]["permutation_p_max"], ".2f") != "0.01":
|
||||
raise SystemExit("preregistration control p mismatch")
|
||||
|
||||
|
||||
def house_of(body_sign: int, lagna: int) -> int:
|
||||
return (int(body_sign) - int(lagna)) % 12 + 1
|
||||
|
||||
|
||||
def lord_of(lagna: int, house: int) -> str:
|
||||
sign_index = (int(lagna) + int(house) - 1) % 12
|
||||
return varga.SIGN_LORDS[varga.SIGNS[sign_index]]
|
||||
|
||||
|
||||
def career_house(father_house: int) -> int:
|
||||
return ((int(father_house) - 1 + 9) % 12) + 1
|
||||
|
||||
|
||||
def separated(sign_a: int, sign_b: int) -> bool:
|
||||
return (int(sign_b) - int(sign_a)) % 12 in SEPARATED_DELTAS
|
||||
|
||||
|
||||
def _unique(planets: Sequence[str]) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(planets))
|
||||
|
||||
|
||||
def _in_kendra(signs: Mapping[str, int], lagna: int, planet: str) -> bool:
|
||||
return house_of(signs[planet], lagna) in KENDRA
|
||||
|
||||
|
||||
def read_probes(
|
||||
signs: Mapping[str, int],
|
||||
lagna: int,
|
||||
father_house: int,
|
||||
) -> dict[str, str]:
|
||||
"""Return yes / no / abstain for the six pre-registered probes."""
|
||||
mother = _unique(("Moon", lord_of(lagna, 4)))
|
||||
father = _unique(("Sun", lord_of(lagna, father_house)))
|
||||
mother_kendra = sum(_in_kendra(signs, lagna, planet) for planet in mother)
|
||||
father_kendra = sum(_in_kendra(signs, lagna, planet) for planet in father)
|
||||
if mother_kendra > father_kendra:
|
||||
care = "yes"
|
||||
elif father_kendra > mother_kendra:
|
||||
care = "no"
|
||||
elif _in_kendra(signs, lagna, "Moon") and not _in_kendra(signs, lagna, "Sun"):
|
||||
care = "yes"
|
||||
elif _in_kendra(signs, lagna, "Sun") and not _in_kendra(signs, lagna, "Moon"):
|
||||
care = "no"
|
||||
else:
|
||||
care = "abstain"
|
||||
|
||||
lord2 = lord_of(lagna, 2)
|
||||
financial = (
|
||||
any(house_of(signs[planet], lagna) in {2, 11} for planet in father)
|
||||
or house_of(signs[lord2], lagna) == father_house
|
||||
or any(signs[lord2] == signs[planet] for planet in father)
|
||||
)
|
||||
lord5 = lord_of(lagna, 5)
|
||||
study = (
|
||||
any(house_of(signs[planet], lagna) == 5 for planet in father)
|
||||
or house_of(signs[lord5], lagna) == father_house
|
||||
or house_of(signs["Jupiter"], lagna) == father_house
|
||||
or any(signs["Jupiter"] == signs[planet] for planet in father)
|
||||
)
|
||||
career = career_house(father_house)
|
||||
career_lord = lord_of(lagna, career)
|
||||
institutional = (
|
||||
house_of(signs["Saturn"], lagna) == career
|
||||
or house_of(signs["Sun"], lagna) == career
|
||||
or career_lord in {"Saturn", "Sun"}
|
||||
)
|
||||
commerce = (
|
||||
house_of(signs["Mercury"], lagna) == career
|
||||
or house_of(signs["Venus"], lagna) == career
|
||||
or career_lord in {"Mercury", "Venus"}
|
||||
)
|
||||
lord4 = lord_of(lagna, 4)
|
||||
father_lord = lord_of(lagna, father_house)
|
||||
lords_apart = lord4 != father_lord and separated(signs[lord4], signs[father_lord])
|
||||
rahu_house = house_of(signs["Rahu"], lagna) if "Rahu" in signs else None
|
||||
apart = (
|
||||
separated(signs["Sun"], signs["Moon"])
|
||||
or lords_apart
|
||||
or rahu_house in {4, father_house}
|
||||
)
|
||||
return {
|
||||
"mother_more_daily_care": care,
|
||||
"father_financial_lead": "yes" if financial else "no",
|
||||
"father_guided_study": "yes" if study else "no",
|
||||
"father_work_institutional": "yes" if institutional else "no",
|
||||
"father_work_commerce": "yes" if commerce else "no",
|
||||
"parents_long_apart": "yes" if apart else "no",
|
||||
}
|
||||
|
||||
|
||||
def placement_from_longitudes(
|
||||
ascendant_longitude: float,
|
||||
planet_longitudes: Mapping[str, float],
|
||||
planet_d1_signs: Mapping[str, int],
|
||||
) -> Placement:
|
||||
d12_signs = {
|
||||
name: int(varga.calc_varga(float(longitude), 12)["sign_idx"])
|
||||
for name, longitude in planet_longitudes.items()
|
||||
}
|
||||
d1_signs = {name: int(sign) for name, sign in planet_d1_signs.items()}
|
||||
ascendant = float(ascendant_longitude)
|
||||
return Placement(
|
||||
d12_lagna=int(varga.calc_varga(ascendant, 12)["sign_idx"]),
|
||||
d1_lagna=int(ascendant // 30) % 12,
|
||||
d12_signs=d12_signs,
|
||||
d1_signs=d1_signs,
|
||||
)
|
||||
|
||||
|
||||
def predict(placement: Placement, chart: str, father_house: int) -> dict[str, str]:
|
||||
if chart == "D12":
|
||||
return read_probes(placement.d12_signs, placement.d12_lagna, father_house)
|
||||
if chart == "D1":
|
||||
return read_probes(placement.d1_signs, placement.d1_lagna, father_house)
|
||||
raise ValueError(f"unknown chart {chart}")
|
||||
|
||||
|
||||
def cuts(sign_by_offset: Mapping[int, int]) -> list[tuple[int, int]]:
|
||||
offsets = sorted(int(item) for item in sign_by_offset)
|
||||
found: list[tuple[int, int]] = []
|
||||
for left, right in zip(offsets, offsets[1:]):
|
||||
if right == left + 1 and sign_by_offset[left] != sign_by_offset[right]:
|
||||
found.append((left, right))
|
||||
return found
|
||||
|
||||
|
||||
def nearest_cut(pairs: Sequence[tuple[int, int]]) -> tuple[int, int] | None:
|
||||
if not pairs:
|
||||
return None
|
||||
return min(pairs, key=lambda pair: (min(abs(pair[0]), abs(pair[1])), abs(pair[0]), pair[0]))
|
||||
|
||||
|
||||
def true_side(
|
||||
sign_by_offset: Mapping[int, int],
|
||||
pair: tuple[int, int],
|
||||
) -> tuple[int, int]:
|
||||
truth = sign_by_offset[0]
|
||||
left, right = pair
|
||||
left_true = sign_by_offset[left] == truth
|
||||
right_true = sign_by_offset[right] == truth
|
||||
if left_true == right_true:
|
||||
raise RuntimeError("boundary does not separate the recorded sign")
|
||||
if left_true:
|
||||
return left, right
|
||||
return right, left
|
||||
|
||||
|
||||
def segments(sign_by_offset: Mapping[int, int]) -> list[list[int]]:
|
||||
offsets = sorted(int(item) for item in sign_by_offset)
|
||||
if not offsets:
|
||||
return []
|
||||
runs: list[list[int]] = [[offsets[0]]]
|
||||
for offset in offsets[1:]:
|
||||
if offset == runs[-1][-1] + 1 and sign_by_offset[offset] == sign_by_offset[runs[-1][-1]]:
|
||||
runs[-1].append(offset)
|
||||
else:
|
||||
runs.append([offset])
|
||||
return runs
|
||||
|
||||
|
||||
def segment_representative(run: Sequence[int]) -> int:
|
||||
return min(run, key=lambda offset: (abs(offset), offset))
|
||||
|
||||
|
||||
def side_score(prediction: Mapping[str, str], answers: Mapping[str, str]) -> int:
|
||||
score = 0
|
||||
for probe in PROBES:
|
||||
answer = answers[probe]
|
||||
label = prediction[probe]
|
||||
if answer not in {"yes", "no"} or label not in {"yes", "no"}:
|
||||
continue
|
||||
score += 1 if label == answer else -1
|
||||
return score
|
||||
|
||||
|
||||
def fisher_yates(items: Sequence[str], rng: Random) -> list[str]:
|
||||
out = list(items)
|
||||
for index in range(len(out) - 1, 0, -1):
|
||||
swap = rng.randrange(index + 1)
|
||||
out[index], out[swap] = out[swap], out[index]
|
||||
return out
|
||||
|
||||
|
||||
def permutation_maps(case_ids: Sequence[str], seed: int, count: int = PERMUTATIONS) -> list[list[str]]:
|
||||
rng = Random(seed)
|
||||
ordered = sorted(case_ids)
|
||||
return [fisher_yates(ordered, rng) for _ in range(count)]
|
||||
|
||||
|
||||
def assign_answers(
|
||||
destinations: Sequence[str],
|
||||
sources: Sequence[str],
|
||||
answers: Mapping[str, Mapping[str, str]],
|
||||
) -> dict[str, Mapping[str, str]]:
|
||||
if len(destinations) != len(sources):
|
||||
raise RuntimeError("permutation length mismatch")
|
||||
return {dest: answers[source] for dest, source in zip(destinations, sources)}
|
||||
|
||||
|
||||
def selection_count(
|
||||
case_ids: Sequence[str],
|
||||
answers: Mapping[str, Mapping[str, str]],
|
||||
true_predictions: Mapping[str, Mapping[str, str]],
|
||||
other_predictions: Mapping[str, Mapping[str, str]],
|
||||
) -> int:
|
||||
selected = 0
|
||||
for case_id in case_ids:
|
||||
true_score = side_score(true_predictions[case_id], answers[case_id])
|
||||
other_score = side_score(other_predictions[case_id], answers[case_id])
|
||||
if true_score > other_score:
|
||||
selected += 1
|
||||
return selected
|
||||
|
||||
|
||||
def ratio_below(numer: int, denom: int, threshold: Decimal) -> bool:
|
||||
if denom <= 0:
|
||||
return False
|
||||
return Decimal(numer) / Decimal(denom) < threshold
|
||||
|
||||
|
||||
def display_ratio(numer: int, denom: int) -> str:
|
||||
if denom <= 0:
|
||||
return "0.000000"
|
||||
return format(numer / denom, ".6f")
|
||||
|
||||
|
||||
def permutation_summary(observed: int, denom: int, perm_counts: Sequence[int]) -> dict[str, Any]:
|
||||
extreme = sum(1 for count in perm_counts if count >= observed)
|
||||
below = sum(1 for count in perm_counts if count < observed)
|
||||
null_sum = sum(perm_counts)
|
||||
n_perm = len(perm_counts)
|
||||
greater = observed * n_perm > null_sum if denom > 0 else False
|
||||
return {
|
||||
"observed_numer": observed,
|
||||
"observed_denom": denom,
|
||||
"observed_display": display_ratio(observed, denom),
|
||||
"extreme": extreme,
|
||||
"p_numer": extreme + 1,
|
||||
"p_denom": n_perm + 1,
|
||||
"p_display": display_ratio(extreme + 1, n_perm + 1),
|
||||
"null_sum": null_sum,
|
||||
"null_mean_display": display_ratio(null_sum, n_perm * denom) if denom else "0.000000",
|
||||
"above_null_mean": greater,
|
||||
"percentile_numer": below,
|
||||
"percentile_denom": n_perm,
|
||||
"percentile_display": display_ratio(below, n_perm),
|
||||
}
|
||||
|
||||
|
||||
def cell_is_significant(summary: Mapping[str, Any]) -> bool:
|
||||
return bool(summary["above_null_mean"]) and ratio_below(
|
||||
int(summary["p_numer"]),
|
||||
int(summary["p_denom"]),
|
||||
ALPHA,
|
||||
)
|
||||
|
||||
|
||||
def vectors_differ(
|
||||
true_prediction: Mapping[str, str],
|
||||
other_prediction: Mapping[str, str],
|
||||
) -> bool:
|
||||
return any(
|
||||
true_prediction[probe] in {"yes", "no"} and true_prediction[probe] != other_prediction[probe]
|
||||
for probe in PROBES
|
||||
)
|
||||
|
||||
|
||||
def plant_answers(true_prediction: Mapping[str, str]) -> dict[str, str]:
|
||||
planted: dict[str, str] = {}
|
||||
for probe in PROBES:
|
||||
label = true_prediction[probe]
|
||||
planted[probe] = label if label in {"yes", "no"} else "unclear"
|
||||
return planted
|
||||
|
||||
|
||||
def change_count(
|
||||
baseline: Mapping[str, Mapping[int, Mapping[str, str]]],
|
||||
alternate: Mapping[str, Mapping[int, Mapping[str, str]]],
|
||||
sides: Mapping[str, tuple[int, int]],
|
||||
) -> int:
|
||||
changes = 0
|
||||
for case_id, pair in sides.items():
|
||||
for offset in pair:
|
||||
for probe in PROBES:
|
||||
if baseline[case_id][offset][probe] != alternate[case_id][offset][probe]:
|
||||
changes += 1
|
||||
return changes
|
||||
|
||||
|
||||
def decisive_count(answers: Mapping[str, str]) -> int:
|
||||
return sum(answer in {"yes", "no"} for answer in answers.values())
|
||||
|
||||
|
||||
def validate_fact_case(case: Mapping[str, Any]) -> dict[str, str]:
|
||||
probes = case.get("probes")
|
||||
if not isinstance(probes, dict) or set(probes) != set(PROBES):
|
||||
raise SystemExit(f"fact probes incomplete for {case.get('case_id')}")
|
||||
answers: dict[str, str] = {}
|
||||
for probe in PROBES:
|
||||
row = probes[probe]
|
||||
if not isinstance(row, dict):
|
||||
raise SystemExit(f"fact row missing for {case.get('case_id')} {probe}")
|
||||
answer = str(row.get("answer") or "")
|
||||
grade = str(row.get("source_grade") or "")
|
||||
url = str(row.get("url") or "")
|
||||
excerpt = str(row.get("excerpt") or "").strip()
|
||||
if answer not in ANSWERS or grade not in GRADES or not url.startswith("https://") or not excerpt:
|
||||
raise SystemExit(f"fact source incomplete for {case.get('case_id')} {probe}")
|
||||
answers[probe] = answer
|
||||
return answers
|
||||
|
||||
|
||||
def answer_tally(cases: Sequence[Mapping[str, str]]) -> dict[str, dict[str, int]]:
|
||||
tally = {probe: {"yes": 0, "no": 0, "unclear": 0} for probe in PROBES}
|
||||
for answers in cases:
|
||||
for probe in PROBES:
|
||||
tally[probe][answers[probe]] += 1
|
||||
return tally
|
||||
|
||||
|
||||
def window_offsets(radius: int) -> list[int]:
|
||||
return list(range(-int(radius), int(radius) + 1))
|
||||
|
||||
|
||||
def _slice_map(values: Mapping[int, Any], offsets: Sequence[int]) -> dict[int, Any]:
|
||||
return {int(offset): values[int(offset)] for offset in offsets}
|
||||
|
||||
|
||||
def case_window(
|
||||
sign_by_offset: Mapping[int, int],
|
||||
prediction_by_offset: Mapping[int, Mapping[str, str]],
|
||||
answers: Mapping[str, str],
|
||||
radius: int,
|
||||
) -> dict[str, Any]:
|
||||
signs = _slice_map(sign_by_offset, window_offsets(radius))
|
||||
pair = nearest_cut(cuts(signs))
|
||||
if pair is None:
|
||||
return {
|
||||
"boundary": False,
|
||||
"cut": None,
|
||||
"true_offset": None,
|
||||
"other_offset": None,
|
||||
"true_score": None,
|
||||
"other_score": None,
|
||||
"selected": False,
|
||||
"true_predictions": None,
|
||||
"other_predictions": None,
|
||||
"segments": 1 if signs else 0,
|
||||
"head_hit": None,
|
||||
"retained": None,
|
||||
}
|
||||
true_offset, other_offset = true_side(signs, pair)
|
||||
true_prediction = dict(prediction_by_offset[true_offset])
|
||||
other_prediction = dict(prediction_by_offset[other_offset])
|
||||
true_score = side_score(true_prediction, answers)
|
||||
other_score = side_score(other_prediction, answers)
|
||||
runs = segments(signs)
|
||||
scored: list[tuple[list[int], int]] = []
|
||||
for run in runs:
|
||||
representative = segment_representative(run)
|
||||
scored.append((run, side_score(prediction_by_offset[representative], answers)))
|
||||
true_segment = next(score for run, score in scored if 0 in run)
|
||||
other_scores = [score for run, score in scored if 0 not in run]
|
||||
return {
|
||||
"boundary": True,
|
||||
"cut": [pair[0], pair[1]],
|
||||
"true_offset": true_offset,
|
||||
"other_offset": other_offset,
|
||||
"true_score": true_score,
|
||||
"other_score": other_score,
|
||||
"selected": true_score > other_score,
|
||||
"true_predictions": true_prediction,
|
||||
"other_predictions": other_prediction,
|
||||
"segments": len(runs),
|
||||
"head_hit": true_segment > max(other_scores),
|
||||
"retained": true_segment >= max(other_scores),
|
||||
"differ": vectors_differ(true_prediction, other_prediction),
|
||||
}
|
||||
|
||||
|
||||
def evaluate_cell(
|
||||
case_ids: Sequence[str],
|
||||
signs: Mapping[str, Mapping[int, int]],
|
||||
predictions: Mapping[str, Mapping[int, Mapping[str, str]]],
|
||||
answers: Mapping[str, Mapping[str, str]],
|
||||
radius: int,
|
||||
perm_maps: Sequence[Sequence[str]],
|
||||
) -> dict[str, Any]:
|
||||
ordered = sorted(case_ids)
|
||||
rows = {
|
||||
case_id: case_window(signs[case_id], predictions[case_id], answers[case_id], radius)
|
||||
for case_id in ordered
|
||||
}
|
||||
boundary_ids = [case_id for case_id in ordered if rows[case_id]["boundary"]]
|
||||
true_predictions = {case_id: rows[case_id]["true_predictions"] for case_id in boundary_ids}
|
||||
other_predictions = {case_id: rows[case_id]["other_predictions"] for case_id in boundary_ids}
|
||||
observed = selection_count(boundary_ids, answers, true_predictions, other_predictions)
|
||||
perm_counts = []
|
||||
for sources in perm_maps:
|
||||
assigned = assign_answers(ordered, sources, answers)
|
||||
perm_counts.append(
|
||||
selection_count(boundary_ids, assigned, true_predictions, other_predictions)
|
||||
)
|
||||
summary = permutation_summary(observed, len(boundary_ids), perm_counts)
|
||||
head_hits = sum(1 for case_id in boundary_ids if rows[case_id]["head_hit"])
|
||||
retained = sum(1 for case_id in boundary_ids if rows[case_id]["retained"])
|
||||
return {
|
||||
"summary": summary,
|
||||
"significant": cell_is_significant(summary),
|
||||
"head_hit_numer": head_hits,
|
||||
"head_hit_denom": len(boundary_ids),
|
||||
"head_hit_display": display_ratio(head_hits, len(boundary_ids)),
|
||||
"retained_numer": retained,
|
||||
"retained_denom": len(boundary_ids),
|
||||
"retained_display": display_ratio(retained, len(boundary_ids)),
|
||||
"no_boundary": len(ordered) - len(boundary_ids),
|
||||
"cases": rows,
|
||||
"not_a_gate_segment_rates": True,
|
||||
}
|
||||
|
||||
|
||||
def positive_control_cell(
|
||||
case_ids: Sequence[str],
|
||||
signs: Mapping[str, Mapping[int, int]],
|
||||
predictions: Mapping[str, Mapping[int, Mapping[str, str]]],
|
||||
radius: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Plant the true side's own labels. Permute only cases that have a cut.
|
||||
|
||||
Those are the cases with a planted vector. The stream starts at the
|
||||
pre-registered control seed for every cell.
|
||||
"""
|
||||
blank = {probe: "unclear" for probe in PROBES}
|
||||
boundary_ids: list[str] = []
|
||||
true_predictions: dict[str, Mapping[str, str]] = {}
|
||||
other_predictions: dict[str, Mapping[str, str]] = {}
|
||||
planted: dict[str, dict[str, str]] = {}
|
||||
missed: list[str] = []
|
||||
differing = 0
|
||||
for case_id in sorted(case_ids):
|
||||
row = case_window(signs[case_id], predictions[case_id], blank, radius)
|
||||
if not row["boundary"]:
|
||||
continue
|
||||
boundary_ids.append(case_id)
|
||||
true_predictions[case_id] = row["true_predictions"]
|
||||
other_predictions[case_id] = row["other_predictions"]
|
||||
planted[case_id] = plant_answers(row["true_predictions"])
|
||||
if row["differ"]:
|
||||
differing += 1
|
||||
checked = case_window(signs[case_id], predictions[case_id], planted[case_id], radius)
|
||||
if not checked["selected"]:
|
||||
missed.append(case_id)
|
||||
maps = permutation_maps(boundary_ids, CONTROL_SEED, PERMUTATIONS)
|
||||
observed = selection_count(boundary_ids, planted, true_predictions, other_predictions)
|
||||
perm_counts = [
|
||||
selection_count(
|
||||
boundary_ids,
|
||||
assign_answers(boundary_ids, sources, planted),
|
||||
true_predictions,
|
||||
other_predictions,
|
||||
)
|
||||
for sources in maps
|
||||
]
|
||||
summary = permutation_summary(observed, len(boundary_ids), perm_counts)
|
||||
return {
|
||||
"summary": summary,
|
||||
"differing": differing,
|
||||
"pipeline_exact": not missed,
|
||||
"missed": missed,
|
||||
"p_below": ratio_below(int(summary["p_numer"]), int(summary["p_denom"]), CONTROL_P_MAX),
|
||||
}
|
||||
|
||||
|
||||
def decide_status(
|
||||
*,
|
||||
included: int,
|
||||
control_exact: bool,
|
||||
differing: int,
|
||||
control_p_below: bool,
|
||||
any_robust_significant: bool,
|
||||
) -> str:
|
||||
if not control_exact:
|
||||
return "control_invalid"
|
||||
if differing < MIN_DIFFERING or not control_p_below:
|
||||
return "control_underpowered"
|
||||
if included < MIN_INCLUDED:
|
||||
return "underpowered_sample"
|
||||
if any_robust_significant:
|
||||
return "pass"
|
||||
return "fail_closed"
|
||||
@@ -1,338 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Score the pre-registered D12 parent probes. Research only.
|
||||
|
||||
Charts come from domain_calculation_service.compute_chart. D12 signs come
|
||||
from varga.calc_varga. The script writes one JSON file and no timestamps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_SCRIPTS = str(Path(__file__).resolve().parents[1])
|
||||
if _SCRIPTS not in sys.path:
|
||||
sys.path.insert(0, _SCRIPTS)
|
||||
|
||||
import domain_calculation_service
|
||||
import swisseph as swe
|
||||
|
||||
from scripts.research import d12_parent_probes_lib as lib
|
||||
|
||||
PROFILES = {
|
||||
"father_9": ("D12", 9),
|
||||
"father_10": ("D12", 10),
|
||||
"d1_father_9": ("D1", 9),
|
||||
}
|
||||
FAMILY = (("father_9", 10), ("father_9", 30), ("father_10", 10), ("father_10", 30))
|
||||
SETTINGS = {
|
||||
"baseline": ("raman", "mean"),
|
||||
"lahiri_mean": ("lahiri", "mean"),
|
||||
"raman_true": ("raman", "true"),
|
||||
}
|
||||
OFFSETS = list(range(-30, 31))
|
||||
NEEDED = ("Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Rahu")
|
||||
|
||||
|
||||
def file_sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def birth_moment(birth: dict[str, Any]) -> datetime:
|
||||
clock = str(birth["time"])[:5]
|
||||
return datetime.strptime(f"{birth['date']} {clock}", "%Y-%m-%d %H:%M")
|
||||
|
||||
|
||||
def placement_at(moment: datetime, birth: dict[str, Any], ayanamsa: str, node_mode: str) -> lib.Placement:
|
||||
chart = domain_calculation_service.compute_chart(
|
||||
{
|
||||
"year": moment.year,
|
||||
"month": moment.month,
|
||||
"day": moment.day,
|
||||
"hour": moment.hour,
|
||||
"minute": moment.minute,
|
||||
"second": 0,
|
||||
"lat": float(birth["latitude"]),
|
||||
"lon": float(birth["longitude"]),
|
||||
"tz": float(birth["timezone_offset"]),
|
||||
"ayanamsa": ayanamsa,
|
||||
"node_mode": node_mode,
|
||||
}
|
||||
)
|
||||
planets = chart["planets"]
|
||||
longitudes: dict[str, float] = {}
|
||||
d1_signs: dict[str, int] = {}
|
||||
for name in NEEDED:
|
||||
row = planets.get(name)
|
||||
if not isinstance(row, dict) or "lon" not in row:
|
||||
raise RuntimeError(f"chart is missing {name}")
|
||||
longitudes[name] = float(row["lon"])
|
||||
if "sign_idx" in row:
|
||||
d1_signs[name] = int(row["sign_idx"])
|
||||
else:
|
||||
d1_signs[name] = int(float(row["lon"]) // 30) % 12
|
||||
ascendant = chart.get("ascendant") or {}
|
||||
if "lon" not in ascendant:
|
||||
raise RuntimeError("chart is missing the ascendant longitude")
|
||||
return lib.placement_from_longitudes(float(ascendant["lon"]), longitudes, d1_signs)
|
||||
|
||||
|
||||
def sky_grid(cases: list[dict[str, Any]], ayanamsa: str, node_mode: str) -> dict[str, dict[int, lib.Placement]]:
|
||||
grid: dict[str, dict[int, lib.Placement]] = {}
|
||||
for index, case in enumerate(cases, start=1):
|
||||
moment = birth_moment(case["birth"])
|
||||
rows: dict[int, lib.Placement] = {}
|
||||
for offset in OFFSETS:
|
||||
rows[offset] = placement_at(moment + timedelta(minutes=offset), case["birth"], ayanamsa, node_mode)
|
||||
grid[str(case["case_id"])] = rows
|
||||
print(f"{ayanamsa}/{node_mode} {index}/{len(cases)}", file=sys.stderr)
|
||||
return grid
|
||||
|
||||
|
||||
def signs_of(grid: dict[str, dict[int, lib.Placement]]) -> dict[str, dict[int, int]]:
|
||||
return {
|
||||
case_id: {offset: placement.d12_lagna for offset, placement in rows.items()}
|
||||
for case_id, rows in grid.items()
|
||||
}
|
||||
|
||||
|
||||
def predictions_of(
|
||||
grid: dict[str, dict[int, lib.Placement]],
|
||||
chart: str,
|
||||
father_house: int,
|
||||
) -> dict[str, dict[int, dict[str, str]]]:
|
||||
return {
|
||||
case_id: {
|
||||
offset: lib.predict(placement, chart, father_house)
|
||||
for offset, placement in rows.items()
|
||||
}
|
||||
for case_id, rows in grid.items()
|
||||
}
|
||||
|
||||
|
||||
def load_births(prereg: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
payload = json_load(lib.HOLDOUT_PATH)
|
||||
cases = list(payload["cases"])
|
||||
digest = lib.case_id_digest([str(case["case_id"]) for case in cases])
|
||||
if digest != prereg["birth_records"]["case_id_sha256"]:
|
||||
raise SystemExit("birth case-id digest does not match the preregistration")
|
||||
if len(cases) != 77:
|
||||
raise SystemExit("birth file does not contain 77 cases")
|
||||
return cases
|
||||
|
||||
|
||||
def json_load(path: Path) -> dict[str, Any]:
|
||||
import json
|
||||
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def load_answers(birth_ids: set[str]) -> tuple[dict[str, dict[str, str]], list[dict[str, Any]], list[str]]:
|
||||
if not lib.FACTS_PATH.is_file():
|
||||
raise SystemExit(f"missing facts: {lib.FACTS_PATH}")
|
||||
payload = json_load(lib.FACTS_PATH)
|
||||
coded: dict[str, dict[str, str]] = {}
|
||||
excluded: list[dict[str, Any]] = []
|
||||
for case in payload.get("cases") or []:
|
||||
case_id = str(case.get("case_id") or "")
|
||||
if case_id not in birth_ids:
|
||||
raise SystemExit(f"fact case is not in the frozen birth list: {case_id}")
|
||||
answers = lib.validate_fact_case(case)
|
||||
decisive = lib.decisive_count(answers)
|
||||
if decisive >= lib.MIN_DECISIVE:
|
||||
coded[case_id] = answers
|
||||
else:
|
||||
excluded.append({"case_id": case_id, "decisive": decisive})
|
||||
not_coded = sorted(birth_ids - set(coded) - {row["case_id"] for row in excluded})
|
||||
return coded, excluded, not_coded
|
||||
|
||||
|
||||
def robustness_block(
|
||||
rows: dict[str, dict[str, Any]],
|
||||
baseline: dict[str, dict[int, dict[str, str]]],
|
||||
alternate: dict[str, dict[int, dict[str, str]]],
|
||||
) -> dict[str, Any]:
|
||||
sides = {
|
||||
case_id: (int(row["true_offset"]), int(row["other_offset"]))
|
||||
for case_id, row in rows.items()
|
||||
if row["boundary"]
|
||||
}
|
||||
changes = lib.change_count(baseline, alternate, sides)
|
||||
denom = len(sides) * 2 * len(lib.PROBES)
|
||||
return {
|
||||
"changes": changes,
|
||||
"denom": denom,
|
||||
"display": lib.display_ratio(changes, denom),
|
||||
"below_line": lib.ratio_below(changes, denom, lib.ROBUST_MAX),
|
||||
}
|
||||
|
||||
|
||||
def public_cases(rows: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
published = []
|
||||
for case_id in sorted(rows):
|
||||
row = dict(rows[case_id])
|
||||
row["case_id"] = case_id
|
||||
row.pop("differ", None)
|
||||
published.append(row)
|
||||
return published
|
||||
|
||||
|
||||
def ephemeris_version() -> str:
|
||||
version = getattr(swe, "version", None)
|
||||
return str(version) if version else "unknown"
|
||||
|
||||
|
||||
def run() -> dict[str, Any]:
|
||||
prereg = lib.load_prereg()
|
||||
lib.assert_prereg_locked(prereg)
|
||||
births = load_births(prereg)
|
||||
birth_ids = {str(case["case_id"]) for case in births}
|
||||
answers, excluded, not_coded = load_answers(birth_ids)
|
||||
included_ids = sorted(answers)
|
||||
included_births = [case for case in births if str(case["case_id"]) in answers]
|
||||
included_births.sort(key=lambda case: str(case["case_id"]))
|
||||
skies = {
|
||||
name: sky_grid(included_births, ayanamsa, node_mode)
|
||||
for name, (ayanamsa, node_mode) in SETTINGS.items()
|
||||
}
|
||||
sign_grid = signs_of(skies["baseline"])
|
||||
predicted = {
|
||||
profile: predictions_of(skies["baseline"], chart, father_house)
|
||||
for profile, (chart, father_house) in PROFILES.items()
|
||||
}
|
||||
alternate = {
|
||||
setting: {
|
||||
profile: predictions_of(skies[setting], "D12", father_house)
|
||||
for profile, father_house in (("father_9", 9), ("father_10", 10))
|
||||
}
|
||||
for setting in ("lahiri_mean", "raman_true")
|
||||
}
|
||||
maps = lib.permutation_maps(included_ids, lib.PERM_SEED, lib.PERMUTATIONS)
|
||||
cells: dict[str, Any] = {}
|
||||
controls: dict[str, Any] = {}
|
||||
any_robust_significant = False
|
||||
for profile, radius in FAMILY:
|
||||
key = f"{profile}_r{radius}"
|
||||
evaluated = lib.evaluate_cell(
|
||||
included_ids,
|
||||
sign_grid,
|
||||
predicted[profile],
|
||||
answers,
|
||||
radius,
|
||||
maps,
|
||||
)
|
||||
ayanamsa_shift = robustness_block(
|
||||
evaluated["cases"],
|
||||
predicted[profile],
|
||||
alternate["lahiri_mean"][profile],
|
||||
)
|
||||
node_shift = robustness_block(
|
||||
evaluated["cases"],
|
||||
predicted[profile],
|
||||
alternate["raman_true"][profile],
|
||||
)
|
||||
robust = bool(ayanamsa_shift["below_line"] and node_shift["below_line"])
|
||||
if evaluated["significant"] and robust:
|
||||
any_robust_significant = True
|
||||
control = lib.positive_control_cell(included_ids, sign_grid, predicted[profile], radius)
|
||||
cells[key] = {
|
||||
"profile": profile,
|
||||
"radius_minutes": radius,
|
||||
"summary": evaluated["summary"],
|
||||
"significant": evaluated["significant"],
|
||||
"head_hit_numer": evaluated["head_hit_numer"],
|
||||
"head_hit_denom": evaluated["head_hit_denom"],
|
||||
"head_hit_display": evaluated["head_hit_display"],
|
||||
"retained_numer": evaluated["retained_numer"],
|
||||
"retained_denom": evaluated["retained_denom"],
|
||||
"retained_display": evaluated["retained_display"],
|
||||
"no_boundary": evaluated["no_boundary"],
|
||||
"robustness_ayanamsa": ayanamsa_shift,
|
||||
"robustness_node": node_shift,
|
||||
"robust": robust,
|
||||
"not_a_gate_segment_rates": True,
|
||||
"cases": public_cases(evaluated["cases"]),
|
||||
}
|
||||
controls[key] = {
|
||||
"summary": control["summary"],
|
||||
"differing": control["differing"],
|
||||
"pipeline_exact": control["pipeline_exact"],
|
||||
"missed": control["missed"],
|
||||
"p_below_0_01": control["p_below"],
|
||||
}
|
||||
d1_cells = {}
|
||||
for radius in (10, 30):
|
||||
evaluated = lib.evaluate_cell(
|
||||
included_ids,
|
||||
sign_grid,
|
||||
predicted["d1_father_9"],
|
||||
answers,
|
||||
radius,
|
||||
maps,
|
||||
)
|
||||
d1_cells[f"d1_father_9_r{radius}"] = {
|
||||
"summary": evaluated["summary"],
|
||||
"significant_if_it_were_in_the_family": evaluated["significant"],
|
||||
"head_hit_display": evaluated["head_hit_display"],
|
||||
"retained_display": evaluated["retained_display"],
|
||||
"no_boundary": evaluated["no_boundary"],
|
||||
"not_a_gate": True,
|
||||
}
|
||||
control_exact = all(row["pipeline_exact"] for row in controls.values())
|
||||
min_differing = min((row["differing"] for row in controls.values()), default=0)
|
||||
control_p_below = all(row["p_below_0_01"] for row in controls.values())
|
||||
status = lib.decide_status(
|
||||
included=len(included_ids),
|
||||
control_exact=control_exact,
|
||||
differing=min_differing,
|
||||
control_p_below=control_p_below,
|
||||
any_robust_significant=any_robust_significant,
|
||||
)
|
||||
coded_rows = []
|
||||
facts_payload = json_load(lib.FACTS_PATH)
|
||||
for case in facts_payload.get("cases") or []:
|
||||
coded_rows.append(lib.validate_fact_case(case))
|
||||
return {
|
||||
"id": "d12_parent_probes_results_2026_10_12",
|
||||
"preregistration": "docs/research/d12_parent_probes_preregistration_2026_10_12.json",
|
||||
"preregistration_sha256": file_sha256(lib.PREREG_PATH),
|
||||
"facts_sha256": file_sha256(lib.FACTS_PATH),
|
||||
"ephemeris": ephemeris_version(),
|
||||
"python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
||||
"settings": {
|
||||
"baseline": ["raman", "mean"],
|
||||
"ayanamsa_alt": ["lahiri", "mean"],
|
||||
"node_alt": ["raman", "true"],
|
||||
},
|
||||
"coded_cases": len(coded_rows),
|
||||
"included_cases": len(included_ids),
|
||||
"included_case_ids": included_ids,
|
||||
"excluded_few_decisive": excluded,
|
||||
"not_coded": not_coded,
|
||||
"unclear_rate_coded": lib.answer_tally(coded_rows),
|
||||
"unclear_rate_included": lib.answer_tally([answers[case_id] for case_id in included_ids]),
|
||||
"cells": cells,
|
||||
"positive_control": controls,
|
||||
"d1_control": d1_cells,
|
||||
"decision": status,
|
||||
"decision_note": {
|
||||
"pass": "过门",
|
||||
"fail_closed": "不上线",
|
||||
"underpowered_sample": "欠力",
|
||||
"control_underpowered": "欠力",
|
||||
"control_invalid": "结论无效",
|
||||
}[status],
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
payload = run()
|
||||
lib.RESULTS_PATH.write_bytes(lib.canonical_bytes(payload))
|
||||
print(f"wrote {lib.RESULTS_PATH} decision={payload['decision']}", file=sys.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,254 +0,0 @@
|
||||
"""D12 parent-probe research helpers. Public charts only; no production edits."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.research import d12_parent_probes_lib as lib
|
||||
|
||||
|
||||
BODIES = ("Sun", "Moon", "Mercury", "Venus", "Mars", "Jupiter", "Saturn", "Rahu", "Ketu")
|
||||
|
||||
|
||||
def signs(**updates: int) -> dict[str, int]:
|
||||
base = {name: 0 for name in BODIES}
|
||||
base.update(updates)
|
||||
return base
|
||||
|
||||
|
||||
def test_preregistration_locks_the_gate_numbers() -> None:
|
||||
prereg = lib.load_prereg()
|
||||
lib.assert_prereg_locked(prereg)
|
||||
births = lib.json.loads(lib.HOLDOUT_PATH.read_text(encoding="utf-8"))["cases"]
|
||||
digest = lib.case_id_digest([case["case_id"] for case in births])
|
||||
assert digest == prereg["birth_records"]["case_id_sha256"]
|
||||
assert len(births) == 77
|
||||
|
||||
|
||||
def test_house_lord_career_and_separation_follow_the_preregistration() -> None:
|
||||
assert lib.house_of(0, 0) == 1
|
||||
assert lib.house_of(8, 0) == 9
|
||||
assert lib.lord_of(0, 9) == "Jupiter"
|
||||
assert lib.lord_of(0, 4) == "Moon"
|
||||
assert lib.career_house(9) == 6
|
||||
assert lib.career_house(10) == 7
|
||||
assert lib.separated(0, 5)
|
||||
assert lib.separated(0, 1)
|
||||
assert not lib.separated(0, 0)
|
||||
assert not lib.separated(0, 2)
|
||||
|
||||
|
||||
def test_care_abstains_only_on_a_kendra_tie_and_financial_uses_house_2() -> None:
|
||||
abstain = lib.read_probes(signs(**{name: 1 for name in BODIES}), 0, 9)
|
||||
assert abstain["mother_more_daily_care"] == "abstain"
|
||||
assert abstain["father_financial_lead"] == "yes"
|
||||
study_no = lib.read_probes(
|
||||
signs(Sun=0, Moon=1, Jupiter=3, Mercury=4, Venus=4, Mars=4, Saturn=2, Rahu=4),
|
||||
1,
|
||||
9,
|
||||
)
|
||||
assert study_no["father_guided_study"] == "no"
|
||||
institutional = lib.read_probes(signs(Saturn=5), 0, 9)
|
||||
assert institutional["father_work_institutional"] == "yes"
|
||||
|
||||
|
||||
def test_missing_rahu_does_not_invent_a_node() -> None:
|
||||
bare = {name: 0 for name in BODIES if name != "Rahu"}
|
||||
labels = lib.read_probes(bare, 0, 9)
|
||||
assert labels["parents_long_apart"] in {"yes", "no"}
|
||||
|
||||
|
||||
def test_nearest_cut_tie_break_and_true_side() -> None:
|
||||
signs_at = {-2: 1, -1: 1, 0: 1, 1: 2, 2: 2}
|
||||
assert lib.nearest_cut(lib.cuts(signs_at)) == (0, 1)
|
||||
tied = {-3: 0, -2: 1, -1: 1, 0: 1, 1: 1, 2: 2, 3: 2}
|
||||
assert lib.nearest_cut(lib.cuts(tied)) == (1, 2)
|
||||
mirror = {-3: 0, -2: 0, -1: 1, 0: 1, 1: 1, 2: 1, 3: 2}
|
||||
assert lib.nearest_cut(lib.cuts(mirror)) == (-2, -1)
|
||||
both = {-1: 0, 0: 1, 1: 2}
|
||||
assert lib.nearest_cut(lib.cuts(both)) == (0, 1)
|
||||
assert lib.true_side(signs_at, (0, 1)) == (0, 1)
|
||||
with pytest.raises(RuntimeError):
|
||||
lib.true_side({0: 1, 1: 1}, (0, 1))
|
||||
|
||||
|
||||
def test_segment_representative_prefers_the_recorded_minute() -> None:
|
||||
assert lib.segment_representative([-2, -1, 0, 1]) == 0
|
||||
assert lib.segment_representative([-4, -3]) == -3
|
||||
assert lib.segment_representative([2, 3, 4]) == 2
|
||||
|
||||
|
||||
def test_tie_is_not_a_selection_and_unclear_is_not_counted() -> None:
|
||||
answers = {probe: "yes" for probe in lib.PROBES}
|
||||
same = {probe: "yes" for probe in lib.PROBES}
|
||||
assert lib.side_score(same, answers) == 6
|
||||
mixed = dict(same)
|
||||
mixed["parents_long_apart"] = "abstain"
|
||||
unclear = dict(answers)
|
||||
unclear["father_guided_study"] = "unclear"
|
||||
assert lib.side_score(mixed, unclear) == 4
|
||||
assert lib.selection_count(
|
||||
["a"],
|
||||
{"a": answers},
|
||||
{"a": same},
|
||||
{"a": same},
|
||||
) == 0
|
||||
|
||||
|
||||
def test_fisher_yates_is_repeatable_and_shuffles_whole_vectors() -> None:
|
||||
first = lib.permutation_maps(["b", "a", "c"], seed=20261012, count=3)
|
||||
second = lib.permutation_maps(["b", "a", "c"], seed=20261012, count=3)
|
||||
assert first == second
|
||||
assert all(sorted(row) == ["a", "b", "c"] for row in first)
|
||||
assigned = lib.assign_answers(
|
||||
["a", "b", "c"],
|
||||
["c", "a", "b"],
|
||||
{"a": {"k": "yes"}, "b": {"k": "no"}, "c": {"k": "unclear"}},
|
||||
)
|
||||
assert assigned["a"]["k"] == "unclear"
|
||||
assert assigned["b"]["k"] == "yes"
|
||||
|
||||
|
||||
def test_window_selects_the_matching_side_and_the_head_segment() -> None:
|
||||
offsets = [-1, 0, 1]
|
||||
sign_at = {offset: 0 if offset < 1 else 1 for offset in offsets}
|
||||
true_labels = {probe: "yes" for probe in lib.PROBES}
|
||||
other_labels = {probe: "no" for probe in lib.PROBES}
|
||||
predictions = {-1: true_labels, 0: true_labels, 1: other_labels}
|
||||
answers = {probe: "yes" for probe in lib.PROBES}
|
||||
row = lib.case_window(sign_at, predictions, answers, 1)
|
||||
assert row["boundary"] is True
|
||||
assert row["true_offset"] == 0
|
||||
assert row["other_offset"] == 1
|
||||
assert row["selected"] is True
|
||||
assert row["head_hit"] is True
|
||||
assert row["retained"] is True
|
||||
planted = lib.plant_answers(true_labels)
|
||||
assert planted["mother_more_daily_care"] == "yes"
|
||||
assert lib.plant_answers({"mother_more_daily_care": "abstain", **{probe: "no" for probe in lib.PROBES if probe != "mother_more_daily_care"}})[
|
||||
"mother_more_daily_care"
|
||||
] == "unclear"
|
||||
|
||||
|
||||
def test_permutation_summary_uses_the_exact_ratio() -> None:
|
||||
summary = lib.permutation_summary(5, 10, [0, 1, 5, 6])
|
||||
assert summary["extreme"] == 2
|
||||
assert summary["p_numer"] == 3
|
||||
assert summary["p_denom"] == 5
|
||||
assert summary["above_null_mean"] is True
|
||||
assert summary["percentile_numer"] == 2
|
||||
assert lib.ratio_below(12, 1000, lib.ALPHA) is True
|
||||
assert lib.ratio_below(13, 1000, lib.ALPHA) is False
|
||||
assert lib.ratio_below(199, 1000, lib.ROBUST_MAX) is True
|
||||
assert lib.ratio_below(200, 1000, lib.ROBUST_MAX) is False
|
||||
assert lib.cell_is_significant(
|
||||
{"above_null_mean": True, "p_numer": 12, "p_denom": 1000}
|
||||
) is True
|
||||
assert lib.cell_is_significant(
|
||||
{"above_null_mean": True, "p_numer": 13, "p_denom": 1000}
|
||||
) is False
|
||||
|
||||
|
||||
def test_positive_control_detects_a_planted_difference() -> None:
|
||||
sign_at = {offset: 0 if offset <= 0 else 1 for offset in range(-1, 2)}
|
||||
yes_labels = {probe: "yes" for probe in lib.PROBES}
|
||||
no_labels = {probe: "no" for probe in lib.PROBES}
|
||||
case_ids = [f"c{index:02d}" for index in range(20)]
|
||||
signs = {case_id: sign_at for case_id in case_ids}
|
||||
predicted = {}
|
||||
for index, case_id in enumerate(case_ids):
|
||||
if index % 2 == 0:
|
||||
predicted[case_id] = {-1: yes_labels, 0: yes_labels, 1: no_labels}
|
||||
else:
|
||||
predicted[case_id] = {-1: no_labels, 0: no_labels, 1: yes_labels}
|
||||
control = lib.positive_control_cell(case_ids, signs, predicted, 1)
|
||||
assert control["pipeline_exact"] is True
|
||||
assert control["differing"] == 20
|
||||
assert control["summary"]["observed_numer"] == 20
|
||||
assert control["p_below"] is True
|
||||
|
||||
|
||||
def test_decision_status_does_not_pass_a_small_or_blind_sample() -> None:
|
||||
assert lib.decide_status(
|
||||
included=77,
|
||||
control_exact=True,
|
||||
differing=30,
|
||||
control_p_below=True,
|
||||
any_robust_significant=True,
|
||||
) == "pass"
|
||||
assert lib.decide_status(
|
||||
included=59,
|
||||
control_exact=True,
|
||||
differing=30,
|
||||
control_p_below=True,
|
||||
any_robust_significant=True,
|
||||
) == "underpowered_sample"
|
||||
assert lib.decide_status(
|
||||
included=77,
|
||||
control_exact=True,
|
||||
differing=30,
|
||||
control_p_below=True,
|
||||
any_robust_significant=False,
|
||||
) == "fail_closed"
|
||||
assert lib.decide_status(
|
||||
included=77,
|
||||
control_exact=True,
|
||||
differing=19,
|
||||
control_p_below=True,
|
||||
any_robust_significant=True,
|
||||
) == "control_underpowered"
|
||||
assert lib.decide_status(
|
||||
included=77,
|
||||
control_exact=False,
|
||||
differing=30,
|
||||
control_p_below=True,
|
||||
any_robust_significant=True,
|
||||
) == "control_invalid"
|
||||
|
||||
|
||||
def test_fact_row_requires_an_opened_https_source() -> None:
|
||||
row = {
|
||||
"answer": "unclear",
|
||||
"source_grade": "encyclopedia",
|
||||
"url": "https://en.wikipedia.org/wiki/Example",
|
||||
"excerpt": "The page names the parents and does not say who did the daily care.",
|
||||
}
|
||||
case = {"case_id": "example", "probes": {probe: dict(row) for probe in lib.PROBES}}
|
||||
answers = lib.validate_fact_case(case)
|
||||
assert lib.decisive_count(answers) == 0
|
||||
broken = {"case_id": "example", "probes": {probe: dict(row) for probe in lib.PROBES}}
|
||||
broken["probes"]["parents_long_apart"]["url"] = "http://insecure.example"
|
||||
with pytest.raises(SystemExit):
|
||||
lib.validate_fact_case(broken)
|
||||
|
||||
|
||||
def test_change_count_and_canonical_bytes_are_stable() -> None:
|
||||
labels = {probe: "yes" for probe in lib.PROBES}
|
||||
flipped = dict(labels)
|
||||
flipped["parents_long_apart"] = "no"
|
||||
baseline = {"a": {0: labels, 1: labels}}
|
||||
alternate = {"a": {0: labels, 1: flipped}}
|
||||
assert lib.change_count(baseline, alternate, {"a": (0, 1)}) == 1
|
||||
payload = {"b": 1, "a": {"d": 2, "c": 3}}
|
||||
assert lib.canonical_bytes(payload) == lib.canonical_bytes(payload)
|
||||
assert lib.canonical_bytes(payload).endswith(b"\n")
|
||||
assert Decimal("0.0125") == lib.ALPHA
|
||||
|
||||
|
||||
def test_production_chart_feeds_d12_without_a_second_ephemeris() -> None:
|
||||
from scripts.research.d12_parent_probes_run import placement_at
|
||||
|
||||
birth = {
|
||||
"date": "2000-01-01",
|
||||
"time": "12:00",
|
||||
"latitude": 0.0,
|
||||
"longitude": 0.0,
|
||||
"timezone_offset": 0.0,
|
||||
}
|
||||
placement = placement_at(datetime(2000, 1, 1, 12, 0), birth, "raman", "mean")
|
||||
assert 0 <= placement.d12_lagna <= 11
|
||||
assert set(lib.predict(placement, "D12", 9)) == set(lib.PROBES)
|
||||
assert set(lib.predict(placement, "D1", 9)) == set(lib.PROBES)
|
||||
@@ -1,248 +0,0 @@
|
||||
"""Nearest chart-boundary reads window_scan transitions only.
|
||||
|
||||
Public AA chart: Steve Jobs, 1955-02-24 19:15, San Francisco
|
||||
(references/public_oracle_cases.json id steve_jobs_1955_aa).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import domain_calculation_service, varga
|
||||
from scripts.active_rectification_event_engine import _d11_chart
|
||||
from scripts.rectification.event_probes import LAYER_DOMAIN as EVENT_LAYER_DOMAIN
|
||||
from scripts.rectification.nearest_boundary import LAYER_DOMAIN, nearest_chart_boundary
|
||||
from scripts.rectification.refinement_packet import window_scan
|
||||
from scripts.rectification_varga_segments_api import BOUNDARY_CHARTS, scan_varga_segments
|
||||
|
||||
JOBS = {
|
||||
"year": 1955, "month": 2, "day": 24,
|
||||
"lat": 37.7749, "lon": -122.4194, "tz": -8.0,
|
||||
}
|
||||
DIVISIONS = [2, 4, 5, 7, 9, 10, 12, 24, 30]
|
||||
VARGA_CHARTS = ["D2", "D4", "D5", "D7", "D9", "D10", "D11", "D12", "D24", "D30"]
|
||||
|
||||
|
||||
def _features(start: datetime, count: int) -> list[dict]:
|
||||
rows = []
|
||||
for offset in range(count):
|
||||
moment = start + timedelta(minutes=offset)
|
||||
chart = domain_calculation_service.compute_chart({
|
||||
**JOBS,
|
||||
"year": moment.year, "month": moment.month, "day": moment.day,
|
||||
"hour": moment.hour, "minute": moment.minute,
|
||||
"ayanamsa": "raman", "node_mode": "mean",
|
||||
})
|
||||
ascendant = float(chart["ascendant"]["lon"])
|
||||
planets = {
|
||||
name: float(item["lon"])
|
||||
for name, item in chart.get("planets", {}).items()
|
||||
if isinstance(item, dict) and isinstance(item.get("lon"), (int, float))
|
||||
}
|
||||
computed = varga.calc_all_vargas(planets, ascendant, divisions=DIVISIONS)
|
||||
vargas = {f"D{value['_meta']['div']}": int(value["Ascendant"]["sign_idx"]) for value in computed.values()}
|
||||
vargas["D11"] = int(_d11_chart(planets, ascendant)["Ascendant"]["sign_idx"])
|
||||
rows.append({"feature": {
|
||||
"time": moment.strftime("%H:%M"),
|
||||
"ascendant_sign_index": int(ascendant // 30) % 12,
|
||||
"varga_ascendants": vargas,
|
||||
}})
|
||||
return rows
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def jobs_scan():
|
||||
built = {"static_contexts": _features(datetime(1955, 2, 24, 19, 0), 41)}
|
||||
scan = window_scan(built)
|
||||
return built, scan
|
||||
|
||||
|
||||
def test_domain_map_matches_event_probes():
|
||||
assert LAYER_DOMAIN == EVENT_LAYER_DOMAIN
|
||||
assert "d1" not in LAYER_DOMAIN
|
||||
|
||||
|
||||
def test_ignores_display_layers_and_keeps_the_earlier_tie():
|
||||
scan = {"transitions": [
|
||||
{"layer": "kp1", "at": "19:16", "from_sign": "白羊座", "to_sign": "金牛座"},
|
||||
{"layer": "hora", "at": "19:10", "from_sign": "白羊座", "to_sign": "金牛座"},
|
||||
{"layer": "d9", "at": "19:10", "from_sign": "狮子座", "to_sign": "狮子座"},
|
||||
{"layer": "d10", "at": "19:10", "from_sign": "白羊座", "to_sign": "金牛座"},
|
||||
{"layer": "d12", "at": "19:20", "from_sign": "双子座", "to_sign": "巨蟹座"},
|
||||
{"layer": "d5", "at": "19:20", "from_sign": "处女座", "to_sign": "天秤座"},
|
||||
{"layer": "d24", "at": "19:20", "from_sign": "天蝎座", "to_sign": "射手座"},
|
||||
], "scanned": True}
|
||||
before = copy.deepcopy(scan)
|
||||
tied = nearest_chart_boundary(scan, "19:15")
|
||||
assert scan == before
|
||||
assert tied["found"] is True
|
||||
assert tied["at"] == "19:10"
|
||||
assert tied["distance_minutes"] == 5
|
||||
assert [row["layer"] for row in tied["layers"]] == ["d10"]
|
||||
assert tied["topics"] == ["工作"]
|
||||
assert tied["domains"] == ["career"]
|
||||
both = nearest_chart_boundary(scan, "19:20")
|
||||
assert [row["layer"] for row in both["layers"]] == ["d5", "d24", "d12"]
|
||||
assert both["topics"] == ["学业", "父母"]
|
||||
assert both["distance_minutes"] == 0
|
||||
wrapped = nearest_chart_boundary(
|
||||
{"transitions": [{"layer": "d9", "at": "23:58", "from_sign": "白羊座", "to_sign": "金牛座"}]},
|
||||
"00:02",
|
||||
)
|
||||
assert wrapped["distance_minutes"] == 4
|
||||
assert nearest_chart_boundary({"transitions": []}, "19:15")["reason"] == "no_transition"
|
||||
assert nearest_chart_boundary(None, "19:15")["reason"] == "unavailable"
|
||||
|
||||
|
||||
def test_jobs_inside_flush_and_stable(jobs_scan):
|
||||
_built, scan = jobs_scan
|
||||
assert "nearest_boundary" not in scan
|
||||
changes = [
|
||||
row for row in scan["transitions"]
|
||||
if row.get("from_sign") and row.get("to_sign") and row["from_sign"] != row["to_sign"]
|
||||
and row["layer"] in {"d1", "d9", "d10", "d4", "d5", "d24", "d7", "d12", "d2", "d11", "d30"}
|
||||
]
|
||||
assert changes, "public AA window produced no chart change"
|
||||
flush_at = changes[0]["at"]
|
||||
flush = nearest_chart_boundary(scan, flush_at)
|
||||
assert flush["found"] is True
|
||||
assert flush["distance_minutes"] == 0
|
||||
assert flush["at"] == flush_at
|
||||
assert flush["layers"]
|
||||
assert all(row["from_sign"] != row["to_sign"] for row in flush["layers"])
|
||||
|
||||
change_clocks = {row["at"] for row in changes}
|
||||
inside_clock = None
|
||||
inside_distance = -1
|
||||
for minute in range(19 * 60, 19 * 60 + 41):
|
||||
clock = f"{minute // 60:02d}:{minute % 60:02d}"
|
||||
if clock in change_clocks:
|
||||
continue
|
||||
found = nearest_chart_boundary(scan, clock)
|
||||
distance = found["distance_minutes"]
|
||||
if found["found"] and distance > inside_distance:
|
||||
inside_clock, inside_distance = clock, distance
|
||||
assert inside_clock is not None
|
||||
assert inside_distance >= 1
|
||||
inside = nearest_chart_boundary(scan, inside_clock)
|
||||
assert inside["at"] != inside_clock
|
||||
|
||||
features = jobs_scan[0]["static_contexts"]
|
||||
stable_start = stable_count = best_start = best_count = 0
|
||||
previous = None
|
||||
for index, row in enumerate(features):
|
||||
signature = (
|
||||
row["feature"]["ascendant_sign_index"],
|
||||
tuple(sorted(row["feature"]["varga_ascendants"].items())),
|
||||
)
|
||||
if signature == previous:
|
||||
stable_count += 1
|
||||
else:
|
||||
stable_start, stable_count, previous = index, 1, signature
|
||||
if stable_count > best_count:
|
||||
best_start, best_count = stable_start, stable_count
|
||||
assert best_count >= 2
|
||||
stable_rows = features[best_start:best_start + best_count]
|
||||
stable_scan = window_scan({"static_contexts": stable_rows})
|
||||
recorded = stable_rows[best_count // 2]["feature"]["time"]
|
||||
stable = nearest_chart_boundary(stable_scan, recorded)
|
||||
assert stable["found"] is False
|
||||
assert stable["reason"] == "no_transition"
|
||||
print(
|
||||
f"JOBS_BOUNDARY flush={flush_at} layers={[row['layer'] for row in flush['layers']]} "
|
||||
f"inside={inside_clock} distance={inside_distance} at={inside['at']} "
|
||||
f"topics={inside['topics']} stable={stable_rows[0]['feature']['time']}-{stable_rows[-1]['feature']['time']} "
|
||||
f"recorded={recorded}"
|
||||
)
|
||||
|
||||
|
||||
def test_api_boundary_is_optional_and_matches_window_scan(tmp_path):
|
||||
start = datetime(1955, 2, 24, 19, 12)
|
||||
body = {
|
||||
"start_at": start.strftime("%Y-%m-%dT%H:%M"),
|
||||
"end_at": (start + timedelta(minutes=6)).strftime("%Y-%m-%dT%H:%M"),
|
||||
"latitude": JOBS["lat"], "longitude": JOBS["lon"], "timezone_offset": JOBS["tz"],
|
||||
"target_charts": ["D1", "D9", "D10"],
|
||||
}
|
||||
plain = scan_varga_segments(body, cache_dir=tmp_path)
|
||||
assert "nearest_boundary" not in plain
|
||||
assert set(plain["minutes"][0]["signs"]) == {"D1", "D9", "D10"}
|
||||
edged = scan_varga_segments({
|
||||
**body,
|
||||
"boundary_charts": list(BOUNDARY_CHARTS),
|
||||
"recorded_time": "19:15",
|
||||
}, cache_dir=tmp_path)
|
||||
assert plain["window_key"] != edged["window_key"]
|
||||
assert set(edged["minutes"][0]["signs"]) == {"D1", "D9", "D10"}
|
||||
assert "nearest_boundary" in edged
|
||||
scan = window_scan({"static_contexts": _features(start, 7)})
|
||||
assert nearest_chart_boundary(scan, "19:15") == edged["nearest_boundary"]
|
||||
with pytest.raises(ValueError, match="invalid_target_charts"):
|
||||
scan_varga_segments({**body, "target_charts": ["D12"]}, cache_dir=tmp_path)
|
||||
with pytest.raises(ValueError, match="invalid_boundary_charts"):
|
||||
scan_varga_segments({**body, "boundary_charts": ["D99"], "recorded_time": "19:15"}, cache_dir=tmp_path)
|
||||
|
||||
|
||||
# sha256 of the omitted-ayanamsa public-chart body, captured before this whitelist.
|
||||
OMITTED_AYANAMSA_WINDOW_KEY = "58c936a2ccdbb5e339c79f9c43b990d556b627230cd5d5d200f3fcaaea85fad4"
|
||||
|
||||
|
||||
def _public_segment_body() -> dict:
|
||||
start = datetime(1955, 2, 24, 19, 12)
|
||||
return {
|
||||
"start_at": start.strftime("%Y-%m-%dT%H:%M"),
|
||||
"end_at": (start + timedelta(minutes=6)).strftime("%Y-%m-%dT%H:%M"),
|
||||
"latitude": JOBS["lat"], "longitude": JOBS["lon"], "timezone_offset": JOBS["tz"],
|
||||
"target_charts": ["D1", "D9", "D10"],
|
||||
}
|
||||
|
||||
|
||||
def test_omitted_ayanamsa_matches_raman_and_lahiri_moves(tmp_path):
|
||||
from scripts.rectification_varga_segments_api import VERSION, _segment_ayanamsa
|
||||
|
||||
body = _public_segment_body()
|
||||
omitted = scan_varga_segments(body, cache_dir=tmp_path)
|
||||
assert omitted["version"] == VERSION == "rectification-varga-segments-v1"
|
||||
assert omitted["ayanamsa"] == "raman"
|
||||
assert omitted["node_mode"] == "mean"
|
||||
assert omitted["window_key"] == OMITTED_AYANAMSA_WINDOW_KEY
|
||||
assert "nearest_boundary" not in omitted
|
||||
for same in (
|
||||
{"ayanamsa": "raman"},
|
||||
{"ayanamsa": " Raman "},
|
||||
{"ayanamsa": " "},
|
||||
{"ayanamsa": None},
|
||||
{"node_mode": "true"},
|
||||
):
|
||||
assert scan_varga_segments({**body, **same}, cache_dir=tmp_path) == omitted
|
||||
|
||||
lahiri = scan_varga_segments({**body, "ayanamsa": "Lahiri"}, cache_dir=tmp_path)
|
||||
assert lahiri["ayanamsa"] == "lahiri"
|
||||
assert lahiri["node_mode"] == "mean"
|
||||
assert lahiri["window_key"] != omitted["window_key"]
|
||||
assert [row["signs"] for row in lahiri["minutes"]] != [row["signs"] for row in omitted["minutes"]]
|
||||
assert scan_varga_segments({**body, "ayanamsa": "lahiri"}, cache_dir=tmp_path) == lahiri
|
||||
|
||||
kp = scan_varga_segments({**body, "ayanamsa": "kp"}, cache_dir=tmp_path)
|
||||
assert kp["ayanamsa"] == "kp"
|
||||
assert kp["window_key"] not in {omitted["window_key"], lahiri["window_key"]}
|
||||
for alias in ("krishnamurti", "krishnamurti_paddhati", "krishnamurti/kp", "Krishnamurti-Paddhati"):
|
||||
assert scan_varga_segments({**body, "ayanamsa": alias}, cache_dir=tmp_path) == kp
|
||||
citra = scan_varga_segments({**body, "ayanamsa": "true_chitra"}, cache_dir=tmp_path)
|
||||
assert citra["ayanamsa"] == "true_citra"
|
||||
assert scan_varga_segments({**body, "ayanamsa": "true-citra"}, cache_dir=tmp_path) == citra
|
||||
pushya = scan_varga_segments({**body, "ayanamsa": "true_pushya"}, cache_dir=tmp_path)
|
||||
assert pushya["ayanamsa"] == "true_pushya"
|
||||
fagan = scan_varga_segments({**body, "ayanamsa": "fagan-bradley"}, cache_dir=tmp_path)
|
||||
assert fagan["ayanamsa"] == "fagan_bradley"
|
||||
assert len({omitted["window_key"], lahiri["window_key"], kp["window_key"], citra["window_key"], pushya["window_key"], fagan["window_key"]}) == 6
|
||||
|
||||
cached = {path.name for path in tmp_path.glob("*.json")}
|
||||
for bad in ("djwhal_khul", "sassanian", "nope", 1, True):
|
||||
with pytest.raises(ValueError, match="^invalid_ayanamsa$"):
|
||||
scan_varga_segments({**body, "ayanamsa": bad}, cache_dir=tmp_path)
|
||||
assert {path.name for path in tmp_path.glob("*.json")} == cached
|
||||
assert _segment_ayanamsa("true_chitra") == "true_citra"
|
||||
assert _segment_ayanamsa(None) == "raman"
|
||||
Reference in New Issue
Block a user