Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
203442d54c | ||
|
|
586561eb68 | ||
|
|
5980e66438 | ||
|
|
c4fc1044e3 | ||
|
|
856ab99169 | ||
|
|
0f05c9e26a | ||
|
|
74949d0475 | ||
|
|
805932addd | ||
|
|
798e15effe |
+11
-3
@@ -1,14 +1,22 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-12 — 对话里明说要校正就直接进入(待验收)
|
||||
## 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」那句还没改。
|
||||
- 开场扫描和盘型段扫描按资料里的岁差算。没填岁差仍是 Raman。不说秒,不说记录时间错了或不准,不说已确认。先问这几件事的开关默认关,所以这句先不承诺接下来先问。打分、闸门和题库都没改。Skill 仍是 10.0.33。设置里「生时校正目前仍固定按 Raman」那句随同批 BUG-1310 改正。
|
||||
|
||||
## 2026-10-09 — 事业判断表旁边注明不按星性写方向(已合入 staging;回测仍有 1 次,记为已知残余)
|
||||
|
||||
|
||||
+25
-8
@@ -17538,7 +17538,7 @@
|
||||
|
||||
## BUG-1306 | 对话里明说「帮我做生时纠正」进不了校正:模型不出提议卡,反而在普通对话里自己「收窄出生时间」
|
||||
|
||||
- 状态:fixed-pending-verify(实现在 `codex/rectification-explicit-entry-20261012`,未 push;T0 无主机日志,T5 无模型凭据)
|
||||
- 状态:fixed-pending-verify(直达已部署 staging;F3 跟进随 10-12 批次合入 staging;T0 无主机日志、T5 无模型凭据,真机清单 `docs/testing/rectification-explicit-entry-20261012.md` 未走前不标 resolved)
|
||||
- 首次发现 / 最近更新:2026-10-11 / 2026-10-12
|
||||
- 来源:产品在 staging(`de4cfeb8`)新对话第一句输入「请你帮我进行生时纠正」,回复截图转述。
|
||||
- 现象:回复显示「已完成 3 步」,正文说「收窄出生时间这件事能做」,要用户报有年月的经历,「对得上,区间就能往里收」;全文没有提到提议卡,界面上也没有卡片。正文还按「代表分钟」写出大运 / 子运起止年月。
|
||||
@@ -17551,11 +17551,12 @@
|
||||
- 推断(未证实):正文主动承诺能收窄,不像收到「不要提校正」的拒绝,更像模型没有调用提议工具;无模型凭据、无 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`。用户可见句长度 ≤20。真机清单 `docs/testing/rectification-explicit-entry-20261012.md`。
|
||||
- 防复发:`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`。
|
||||
- 相关记录:BUG-1247、BUG-1248(入口改为提议卡)、BUG-976/977(禁正则判意图)。
|
||||
- 复发自:无。
|
||||
- 修复版本:本分支提交,未 push,未部署。
|
||||
- 修复版本: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` 基线)。
|
||||
- 决策日志(2026-10-12,产品):选 A——用户明确要求做生时校正时直接进入校正,不先写解读、不用点卡;意图由模型判断,禁正则 / 关键词。**推翻 BUG-1248 两处**:①明确要求的这一轮不再「只出卡、点卡才进」;②`product_disabled`、`pricing_unavailable`「正文不要提校正」改为必须一句话说明做不了,不得静默。普通对话模型不得承诺收窄出生时间、不得在对话里收集经历假装校正;申报时段下不得写大运 / 子运精确起止。服务端本人 / 开关 / 进行中 case / 价格判定不变。同日另立 BUG-1307(最近分界开场)、BUG-1308(父母静态题研究)。
|
||||
|
||||
## BUG-1307 | 生时校正开场不说记录时间离最近分界多近,首轮选题面向整个窗口,用户觉得步骤多、目的不明
|
||||
@@ -17572,22 +17573,24 @@
|
||||
- 防复发:默认开场两句保持原文字;有换段句时服务端整段替换,不靠模型现编。开关关闭时排序函数返回原数组。打分、闸门、4 件 3 域未改。
|
||||
- 相关记录:BUG-1306、BUG-1115~1117(盘型口径)、BUG-1143/1144(按段选题)、BUG-691(记录优先)、BUG-1240(不说秒级)、BUG-621(历史 case 仍打开旧 Skill)。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/rectification-nearest-boundary-opening-20261012`,未推送,未部署。进度 `docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md`。
|
||||
- 修复版本:实现 `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)对出生时间分界两侧有没有区分力未经评测
|
||||
|
||||
- 状态:open(研究单 `docs/tasks/TASK-rectification-d12-parent-probes-research-20261012.md` 待领取)
|
||||
- 状态:closed_by_design(研究欠力,方向暂停。2026-10-12 研究跑完:计入案例低于预登记的 60,不是不上线的终局,也不是有功效的「无效」。不能据此改题库或计分。编码对不上原句的问题记在研究报告,不改已有编码。方向暂停,不再跑同一份维基。没有交付修复,不是 resolved)
|
||||
- 首次发现 / 最近更新: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 例持久回放没跑,不标 resolved。未部署)
|
||||
- 状态: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)`。
|
||||
@@ -17597,4 +17600,18 @@
|
||||
- 验证:公开 AA 盘上,省略岁差与改前捕获逐字段相同,显式 raman 与省略相同;同一窗口 lahiri 的缓存键和星座都不同。前端两处调用:lahiri 资料发 lahiri,未写岁差发 raman。
|
||||
- 相关记录:BUG-1115、BUG-1307、BUG-1306。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/rectification-nearest-boundary-opening-20261012`,未推送,未部署。进度见 `docs/tasks/PROGRESS-rectification-nearest-boundary-opening-20261012.md` 的 F1/F2 一节。
|
||||
- 修复版本:实现 `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 授权。
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"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
@@ -0,0 +1,112 @@
|
||||
# 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。修复版本仍是不适用。
|
||||
@@ -0,0 +1,57 @@
|
||||
# 进度: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。没有失败,没有跳过。
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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,10 @@
|
||||
| `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 静默两条);普通对话不得假装校正、申报时段不写子运起止。**先做** | 待验收 | — |
|
||||
| `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 | 待验收 | 分支 `codex/rectification-nearest-boundary-opening-20261012`,未推送 |
|
||||
| `TASK-rectification-d12-parent-probes-research-20261012.md` | — | **父母静态是非题(D12)研究(BUG-1308)**:公开案例 ≥60、预登记、乱序与正对照,过门才立实现单;纯研究可并行 | 待领取 | — |
|
||||
| `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/F2 待验收 | 本分支,未推送 |
|
||||
| `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 岁差说明同批修 | — |
|
||||
|
||||
### 聊天主链路与首页
|
||||
|
||||
|
||||
+2
-2
@@ -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,在途)
|
||||
|
||||
@@ -370,7 +370,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:** “之后的咨询、报告、星盘库和每日星语会按新岁差算。生时校正目前仍固定按 Raman,之后的版本才会跟着这项设置走。”
|
||||
- **Hint:** “之后的咨询、报告、星盘库、每日星语和新开的生时校正会按新岁差算;已经开始的那次校正仍按开始时的岁差。”(BUG-1310)
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -26,6 +26,8 @@ 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<void> | void;
|
||||
openRectificationFromChat: (sourceSessionId: string) => Promise<unknown> | void;
|
||||
};
|
||||
|
||||
export function useConsultationRun(params: ConsultationRunParams) {
|
||||
@@ -1077,7 +1077,13 @@ export function useConsultationRun(params: ConsultationRunParams) {
|
||||
if (rectificationDirectOpen?.mode === "resume" && rectificationDirectOpen.resumeSessionId) {
|
||||
void openRectificationSession(rectificationDirectOpen.resumeSessionId);
|
||||
} else if (rectificationDirectOpen?.mode === "new") {
|
||||
void openRectificationFromChat(rectificationDirectOpen.sourceSessionId);
|
||||
// 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.
|
||||
});
|
||||
}
|
||||
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;
|
||||
await openRectificationCase("homepage", null, sourceSessionId);
|
||||
if (rectificationLimitedToSelf()) return null;
|
||||
return 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);
|
||||
|
||||
@@ -64,9 +64,11 @@ async function writeMessages(client: SupabaseClient, userId: string, sessionId:
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export async function persistOpenedDirectOffer(input: {
|
||||
client: SupabaseClient;
|
||||
|
||||
@@ -28,6 +28,12 @@ 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>;
|
||||
@@ -149,8 +155,9 @@ export function bindOfferToCase(
|
||||
for (let index = stored.length - 1; index >= 0; index -= 1) {
|
||||
const offer = parseRectificationOfferRecord(stored[index]!.rectificationOffer);
|
||||
if (!offer || offer.caseId) continue;
|
||||
// A direct open records `opened` before the case id exists (BUG-1306).
|
||||
// An offer already bound to another case is left alone.
|
||||
// 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.
|
||||
if (offer.status !== "offered" && offer.status !== "opened") continue;
|
||||
const next = [...stored];
|
||||
next[index] = {
|
||||
@@ -164,7 +171,8 @@ export function bindOfferToCase(
|
||||
|
||||
/**
|
||||
* After `complete_consultation_free` appends the one-sentence reply, hang the
|
||||
* opened offer on that message so a reload can still find the source. Returns
|
||||
* 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
|
||||
* 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,6 +1073,7 @@ function offerRecord(
|
||||
hook: RectificationOfferHook,
|
||||
decision: Extract<RectificationOfferDecision, { ok: true }>,
|
||||
status: RectificationOfferRecord["status"],
|
||||
autoOpen = false,
|
||||
): RectificationOfferRecord {
|
||||
return {
|
||||
status,
|
||||
@@ -1080,13 +1081,16 @@ 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
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
async function finishExplicitRectificationRequest(
|
||||
state: ConsultationRuntimeState,
|
||||
@@ -1094,7 +1098,7 @@ async function finishExplicitRectificationRequest(
|
||||
decision: RectificationOfferDecision,
|
||||
) {
|
||||
if (decision.ok) {
|
||||
state.rectificationOffer = offerRecord(hook, decision, "opened");
|
||||
state.rectificationOffer = offerRecord(hook, decision, "offered", true);
|
||||
state.rectificationDirectEntry = {
|
||||
kind: "new",
|
||||
sentence: RECTIFICATION_DIRECT_NEW_COPY,
|
||||
|
||||
@@ -23,8 +23,13 @@ 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, /咨询、报告、星盘库和每日星语/);
|
||||
assert.match(AYANAMSA_SWITCH_HINT, /生时校正目前仍固定按 Raman/);
|
||||
// 原值:/咨询、报告、星盘库和每日星语/;新值:/咨询、报告、星盘库、每日星语/;原因:列举里加了新开的生时校正,顿号接续。
|
||||
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/);
|
||||
});
|
||||
|
||||
// TASK-chart-types-and-report-buttons-20260928 T6: every offered value must
|
||||
|
||||
@@ -4,7 +4,10 @@ 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 {
|
||||
@@ -13,10 +16,14 @@ 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";
|
||||
@@ -179,13 +186,15 @@ 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 opened offer, and does not open again", async () => {
|
||||
test("explicit pass opens once, records an offered auto-open 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("opened"));
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("offered", { autoOpen: true }));
|
||||
assert.equal(fresh.state.rectificationOffer?.status, "offered");
|
||||
assert.equal(fresh.state.rectificationOffer?.autoOpen, true);
|
||||
assert.equal(fresh.state.rectificationOffer?.caseId, undefined);
|
||||
assert.deepEqual(fresh.state.rectificationDirectEntry, {
|
||||
kind: "new",
|
||||
@@ -199,14 +208,15 @@ test("explicit pass opens once, records an opened offer, and does not open again
|
||||
assert.match(String(again.note), /已经在打开/);
|
||||
assert.match(String(again.note), /不要再写解读/);
|
||||
assert.equal(fresh.decisions(), 1);
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("opened"));
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("offered", { autoOpen: true }));
|
||||
});
|
||||
|
||||
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, "opened");
|
||||
assert.equal(direct.state.rectificationOffer?.status, "offered");
|
||||
assert.equal(direct.state.rectificationOffer?.autoOpen, true);
|
||||
assert.equal(direct.state.rectificationDirectEntry?.kind, "new");
|
||||
|
||||
const card = offerTool({ alreadyOffered: true });
|
||||
@@ -375,7 +385,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("opened");
|
||||
if (kind === "new") state.rectificationOffer = offer("offered", { autoOpen: true });
|
||||
yield { type: "text-delta", payload: { text: "然后写一段解读。" } };
|
||||
yield STOP_FINISH;
|
||||
})(),
|
||||
@@ -405,7 +415,8 @@ 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, "opened");
|
||||
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?.caseId, undefined);
|
||||
assert.deepEqual(done.type === "run.completed" ? done.rectificationDirectOpen : undefined, {
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
@@ -450,7 +461,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("opened");
|
||||
state.rectificationOffer = offer("offered", { autoOpen: true });
|
||||
yield STOP_FINISH;
|
||||
})(),
|
||||
retry: async () => {
|
||||
@@ -568,6 +579,9 @@ 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");
|
||||
@@ -586,4 +600,85 @@ 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,6 +279,53 @@ 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 })!;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,595 @@
|
||||
#!/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"
|
||||
@@ -0,0 +1,338 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,254 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user