Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3893783e4c | ||
|
|
f197774812 | ||
|
|
0a4ba23658 | ||
|
|
85055cf9fa | ||
|
|
4c3485bc23 | ||
|
|
ad8b24bf9b | ||
|
|
8e0503f9c0 |
@@ -1,5 +1,13 @@
|
||||
# 印度占星 Skill 更新日志
|
||||
|
||||
## 2026-10-14 — 服务器长期运行不再逐日涨内存;数据库访问少走几趟(待合入 staging)
|
||||
|
||||
- 服务器长期运行不再逐日涨内存;数据库访问少走几趟。Skill 版本不变(BUG-1326~1328、BUG-1346)。
|
||||
|
||||
## 2026-10-14 — 校正每轮重算更快(待合入 staging)
|
||||
|
||||
- 校正每轮重算更快(2 小时窗口约快一倍以上),结果不变。一次要算的分钟数超过 241 会直接拒绝,不再占着计算空位排盘。Skill 版本不 bump(BUG-1323~1325、BUG-1345)。
|
||||
|
||||
## 2026-10-14 — 婚恋评级改成证据档位,KP 判宫和大运年长对齐(待合入 staging)
|
||||
|
||||
- 婚恋结构指数不再写成婚姻稳定、离婚、再婚或建议婚前咨询。中文报告用中文档位,英文报告用英文档位。配偶星分数按 0 到 1 显示,不再写成百分制。
|
||||
|
||||
@@ -31,3 +31,11 @@ JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=<independent-openssl-rand-base64-32-output>
|
||||
# these server-side values through env_file; they are not NEXT_PUBLIC values.
|
||||
PERSONAL_REPORT_ENABLED=true
|
||||
PERSONAL_REPORT_DAILY_LIMIT=5
|
||||
|
||||
# Local Postgres adapter. Unset or on enables both. Exact off restores the
|
||||
# previous per-statement sequence. Measured default is on (2026-10-14).
|
||||
JYOTISHA_PG_METADATA_CACHE=on
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP=on
|
||||
# Ordinary session reads stay uncached unless this is exactly on. Password
|
||||
# change, account deletion, and admin routes bypass the cache. maxAge is 300s.
|
||||
#IDENTITY_SESSION_COOKIE_CACHE=off
|
||||
|
||||
@@ -194,6 +194,8 @@ SITE_ADDRESS=https://staging.jyotisha.chat
|
||||
|
||||
Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the four role-specific server-only database URLs, the exact `AUTH_USER_ORIGIN=https://staging.jyotisha.chat` and `ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat`, the single `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Both hosts run the same application and Better Auth service, but cookies remain host-only; the admin host `/` redirects to `/admin`, and unauthenticated admin requests continue to `/login` on that host. Better Auth trusts only those two origins, while unknown identity hosts fail closed. Persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production uses the same architecture only after the reviewed migration and cutover. See `docs/operations/self-hosted-identity.md` for identity validation.
|
||||
|
||||
The web service already loads its env file, so these server-only switches do not need a Compose change. `JYOTISHA_PG_METADATA_CACHE` and `JYOTISHA_PG_MERGED_SESSION_SETUP` default to on when unset; set either to `off` to restore the previous database roundtrip sequence. `IDENTITY_SESSION_COOKIE_CACHE` defaults to off; set it to `on` to cache ordinary user-session reads for 300 seconds. Names and defaults are also in `deploy/.env.staging.identity.example`. Do not put secrets in these three values.
|
||||
|
||||
After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful push gate for the exact SHA and must use the staging workflow ref.
|
||||
|
||||
### First-deploy sequence
|
||||
|
||||
+125
-5
@@ -15304,17 +15304,17 @@
|
||||
## BUG-1140 | staging 镜像打包失败:`frontend/scripts` 下的研究脚本引用了 `frontend/tests` 的夹具
|
||||
|
||||
- 状态:resolved(修复已推 staging;以门禁 publish 通过与 `/api/health` 部署版本为最终证据)
|
||||
- 首次发现 / 最近更新:2026-10-01 / 2026-10-01
|
||||
- 首次发现 / 最近更新:2026-10-01 / 2026-10-14
|
||||
- 影响面:Gitea `backend-quality-gate` 的 publish 作业(`deploy/railway-web.Dockerfile` 构建阶段 `npm run build`)。validate 通过、publish 失败,未部署,staging 停在上一版。
|
||||
- 用户现象:盘型口径(BUG-1115~1117、1131)推送后 staging 不更新。
|
||||
- 触发条件:`frontend/scripts/rectification-segment-persisted-replay.ts`(盘型口径研究的 PG 持久回放 harness)`import … from "../tests/helpers/postgres-fixture.ts"`。web 镜像只复制 `frontend/src`、`frontend/scripts` 等,不复制 `frontend/tests`;`frontend/tsconfig.json` 的 include 为 `**/*.ts`,`next build` 类型检查覆盖 scripts,于是 `TS2307 Cannot find module '../tests/helpers/postgres-fixture.ts'`。
|
||||
- 根因:本地与验收机都有 `frontend/tests`,`tsc --noEmit` 与 `next build` 均通过,门禁 validate 也在完整检出上跑,没有任何一步按镜像的复制清单构建。
|
||||
- 修复:harness 移到 `frontend/tests/research/rectification-segment-persisted-replay.ts`(相对路径随之调整),Python driver `scripts/research/varga_resolution_persisted_replay.py` 指向新路径;新增 `frontend/tests/runtime-sources-no-test-imports.test.ts`:`src/` 与 `scripts/` 不得 import / `new URL` 引用 `tests/`。
|
||||
- 验证:按 `railway-web.Dockerfile` 的 COPY 清单从提交 `git archive` 出构建目录,修复前 `npm run build` 报上述 TS2307、exit 1,修复后 exit 0、`/` Static;守卫测试 2/2,且对旧 harness 文本判定为违规;移动后 harness 1 例 ±10 冒烟 `runner_exit=0`。
|
||||
- 防复发:守卫测试进 `npm test`(门禁 validate 必跑);错误台账 ERR-113。
|
||||
- 相关记录:BUG-1115~1117、BUG-1131。
|
||||
- 复发自:无
|
||||
- 修复版本:`codex/staging-publish-fix-20261001`。
|
||||
- 防复发:守卫测试进 `npm test`(门禁 validate 必跑);错误台账 ERR-113。2026-10-14 这道守卫在合入前拦住了同一形状:issue #58 的往返对照脚本曾放在 `frontend/scripts/` 并导入测试夹具。脚本改放到 `frontend/tests/bench-local-pg-roundtrips.ts`,文件名不是 `.test.ts`,不会被全量测试收进去。业务代码没有改。原任务书里的旧路径保持不动。
|
||||
- 相关记录:BUG-1115~1117、BUG-1131、BUG-1327。
|
||||
- 复发自:无。2026-10-14 是同一形状,被守卫拦住,未进测试环境。
|
||||
- 修复版本:`codex/staging-publish-fix-20261001`。2026-10-14 的同类挪动在 `codex/node-runtime-resources-20261013`。
|
||||
|
||||
## BUG-1141 | 生时校正缺少度数级时间信号(角度类技法研究)
|
||||
|
||||
@@ -17795,3 +17795,123 @@
|
||||
- 复发自:无。
|
||||
- 修复版本:未修复。
|
||||
|
||||
## BUG-1323 | 校正引导窗口按候选两两比较,窗口一长就平方变慢
|
||||
|
||||
- 状态:fixed-pending-verify(尚未合入 staging)
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
|
||||
- 来源:issue #57。任务书 `docs/tasks/TASK-rect-scoring-perf-20261013.md`。
|
||||
- 现象:校正把时间窗拉到大约两小时时,每一轮重算要多花好几秒。人看到的候选和分数并没有因此变多。
|
||||
- 触发条件:同一次打分里的候选分钟变多。候选越密,引导窗口这一段越接近按平方增长。
|
||||
- 根因:`guided_collect_windows` 对代表候选两两比较时,每一对都重新扫全部候选的大运起点。起点只跟「哪一条运、第几个分界」有关,同一分界算一次就够。
|
||||
- 修复:每个候选的起点先算好。某个分界已经比出不同并收过窗口后,后面的候选对跳过这个分界。窗口的排序、去重和截断没有改。
|
||||
- 验证:`tests/test_rect_scoring_perf_20261013.py` 用基线函数副本对照四种窗口,输出相同;`_context_time` 次数不超过候选数的 16 倍。同机 121 候选这一段从约 4.8 秒降到约 0.6 秒。11 个场景的规范化输出 sha256 与基线 `77057a819` 全部相同。
|
||||
- 防复发:上面的线性计数断言。
|
||||
- 相关记录:BUG-749、BUG-1324。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/rect-scoring-perf-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1324 | 只跟候选有关的三样东西仍在每个事件里重算
|
||||
|
||||
- 状态:fixed-pending-verify(尚未合入 staging)
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
|
||||
- 来源:issue #57。关联 BUG-721。
|
||||
- 现象:同一个候选分钟上,功能吉凶、那罗延子运、大运换段邻近被每个事件各算一遍。两小时窗口里这三段合计大约一秒多。
|
||||
- 触发条件:一次打分有多个事件、多个候选。
|
||||
- 根因:功能吉凶只看上升星座,那罗延子运表只看该候选的大运,换段起点只看该候选的月亮(那罗延再加上升)。旧的 BUG-721 要求缓存在请求级静态上下文的顶层,新代码没有照做,测试又只用三到五个候选,所以没拦住。
|
||||
- 修复:这两份结果放在候选静态上下文的顶层,不放进特征,因此不改特征指纹。打分时直接用。换段邻近按候选日期和月亮度数各算一次全范围起点,再按事件年份过滤;过滤和原来在事件窗口里现算等价,因为原来的函数只会收下窗口内的年份,空结果也不依赖窗口。探针路径仍不接收子运表,这一项记在进度里,本单不改。
|
||||
- 验证:`tests/test_rect_scoring_perf_20261013.py`。功能吉凶在一次 121 候选打分里调用 121 次。打分循环里不再调用 `get_current_narayana_dasha`。缓存结果与现算在 0 到 120 岁、步长 0.01 年上一致。换段矩阵与基线副本一致,含跨午夜。`feature_hash` 不变。
|
||||
- 防复发:上述计数断言,以及记忆化测试把这两个顶层键列入可清空层。
|
||||
- 相关记录:BUG-721、BUG-1323、BUG-1325。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/rect-scoring-perf-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1325 | 打分响应里带着没有读取点的内部矩阵和重复回执
|
||||
|
||||
- 状态:fixed-pending-verify(尚未合入 staging)
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
|
||||
- 来源:issue #57。关联 BUG-728。
|
||||
- 现象:两小时窗口的默认响应大约 2 MB。其中事件贡献矩阵和一份与对外回执重复的内部回执没有产品读取点。
|
||||
- 触发条件:调用校正打分。诊断接口也重复带了那份内部回执。
|
||||
- 根因:矩阵和内部回执直接放进了对外响应。新增字段之前没有先找读取点。
|
||||
- 修复:默认响应去掉 `event_contribution_matrix` 和 `candidate_decision_receipt`。研究脚本需要矩阵时显式打开 `include_internal=True`。对外仍用原来的 `decision_receipt`。算法版本和策略版本都不改。
|
||||
- 验证:`tests/test_rectification_v5_services.py` 默认断言这两个键不在,打开开关后都在。同机 121 候选默认规范化响应约 0.89 MB。11 个场景在打开内部字段并去掉计时字段后,sha256 与基线全部相同。
|
||||
- 防复发:默认响应断言这两个键不存在。新响应字段先找读取点。
|
||||
- 相关记录:BUG-728、BUG-1324。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/rect-scoring-perf-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1326 | 个人报告工人空转时,取消监听一直留在进程上
|
||||
|
||||
- 状态:resolved(代码+回归测试),生产内存曲线待部署后观察
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
|
||||
- 来源:issue #58。任务书 T1。
|
||||
- 现象:报告工人长时间没有活干时,进程内存逐日上涨。取消这次工人时,之前每一次等待都还挂着一个取消监听。
|
||||
- 触发条件:工人循环用进程级取消信号,两次领任务之间按间隔等待。等待到点之后监听不摘。
|
||||
- 根因:等待用了「只触发一次」的取消监听。这个选项只在真的取消时摘掉监听。计时器自己到点时,监听还留在信号上。信号活得和进程一样长,监听就一直积着。
|
||||
- 修复:计时器到点时先摘掉监听再结束等待;真的取消时清掉计时器并结束等待。已经取消的信号仍立刻返回。计时器仍不挡住进程退出。引导打分里同一写法的等待也改成到点摘监听。
|
||||
- 验证:`frontend/tests/personal-report-worker-sleep-listener.test.ts` 4 通过。空转和抛错各 200 次后,取消前监听数是 0,取消后循环马上结束。修之前的同形等待在第 200 次时留下 199 个监听(断言 `199 !== 0`)。
|
||||
- 防复发:上述测试锁住「200 次之后监听数为 0」。
|
||||
- 相关记录:BUG-1328。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1327 | 本地数据库每一问都重复查表结构和设置身份
|
||||
|
||||
- 状态:resolved(代码+回归测试)。2026-10-14 对照通过后,两个开关默认打开。
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
|
||||
- 来源:issue #58。任务书 T2。
|
||||
- 现象:同一次页面操作里,本地数据库要来回很多趟。表有哪些列、主键是什么、函数怎么调用,每次都现查。设置身份也要单独走一趟。
|
||||
- 触发条件:网页通过本地 Postgres 适配层读写真业务表。
|
||||
- 根因:元数据没有进程内缓存。设置角色和两条身份参数各是一条语句。事务的开始和提交仍各走一趟,这是故意留下的。
|
||||
- 修复:`JYOTISHA_PG_METADATA_CACHE` 缓存列类型、主键和函数签名,300 秒过期;缓存键带连接串,列和函数再带角色。不缓存空结果,也不缓存「函数不存在」。业务语句报列、表、函数或类型不匹配时,清掉该连接串的全部元数据并照旧返回错误,不自动重试。关掉某个连接或全部连接时,对应元数据一起清掉。`JYOTISHA_PG_MERGED_SESSION_SETUP` 把设置角色和两条身份合成一条参数化语句。角色仍只允许既有的两个业务角色。开始和提交不并进这条语句。两个开关都写成 `off` 时,语句顺序与改前一致。未设置或 `on` 为打开。只读且没有身份时省掉事务的做法已否决:那会把角色留在连接上,还回池子后可能给下一个请求。
|
||||
- 验证:不连数据库的往返单测锁住关闭时 6 条、打开且命中缓存时 4 条。真库测试确认事务内身份生效,连接归还后再借出时身份已空。对照脚本第二次(带三组原始数)整段往返 49 降到 32,耗时中位数 75.84 毫秒降到 36.48 毫秒,返回内容逐字节一致。第一次同样是 49 降到 32。`npm run test:db` 两轮都是 81 项、通过 68、失败 13、取消 0。失败名称与 Windows 已知清单逐条相同,没有多出来的。
|
||||
- 防复发:往返单测锁住关闭时的语句顺序,以及角色、参数名、报错、过期和关闭连接都会让缓存失效。
|
||||
- 相关记录:BUG-166、BUG-578、BUG-596、BUG-926。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1328 | 三张只进不出的内存表会随运行变大
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
|
||||
- 来源:issue #58。任务书 T3。
|
||||
- 现象:校正交付去重、重算尝试和请求限流各有一张进程内的表。旧条目读的时候已经不算数,却不删除,进程活得越久表越大。
|
||||
- 触发条件:长时间有人做校正或调用限流接口。
|
||||
- 根因:写入只增加,没有过期清理,也没有条数上限。
|
||||
- 修复:交付记录超过 60 秒就删,最多留 1 万条。重算记录改存时间,超过 6 小时就删,最多留 5 千条。限流按本进程见过的最大窗口定期删掉已经出窗的键;1 小时窗口的键不会在 61 秒时被删。限流的对外类型不变,调用方仍可传入自己的表。
|
||||
- 验证:交付、重算、限流的新测试通过。窗口内的允许或拒绝与原来的断言一致,没有改旧断言。同一道校正题在 6 小时内仍不会重算第二次;超过 6 小时或被挤出后可能再算一次,这比「进程一重启就可能再算」更收敛。
|
||||
- 防复发:硬上限和过期都有测试。1 小时窗口的键在短窗口清理后必须还在。
|
||||
- 相关记录:BUG-1326。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1345 | 打分接口没有候选数上限,超宽窗口会占着重计算槽
|
||||
|
||||
- 状态:fixed-pending-verify(尚未合入 staging)
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #57。产品 2026-10-14 决定上限 241,与前端放宽到的最长区间一致。
|
||||
- 现象:直接调用打分可以一次排一整天、每分钟一个候选。计算还没开始就把重计算槽占住几分钟。
|
||||
- 触发条件:候选分钟数超过 241。产品路径的两小时、步长 1,最多 121 个,不受影响。全天扫描在步长 2、5、10 下仍低于上限。
|
||||
- 根因:打分按请求里的窗口枚举候选,没有上限。
|
||||
- 修复:超过 241 在排盘前拒绝,HTTP 400,正文里有 `too_many_candidates`。接口文件没有改,所以错误码仍是原来的 `ERR_BAD_REQUEST`。前端把这种 400 当成坏请求,不按「服务忙」重试。研究脚本可以用 `enforce_candidate_cap=False` 跳过,本单的冻结重跑没有用到这个开关。
|
||||
- 验证:`tests/test_rect_scoring_perf_20261013.py` 覆盖 240、241 通过,242 和全天步长 1 在 50 毫秒内拒绝,步长 2、5、10 通过。前端 `rectification-v9-engine-contract.test.ts` 断言这种 400 不重试。
|
||||
- 防复发:上述上限测试。
|
||||
- 相关记录:BUG-1325。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/rect-scoring-perf-20261013`,尚未合入 staging。
|
||||
|
||||
## BUG-1346 | 每次带登录的请求都重新读一次会话
|
||||
|
||||
- 状态:resolved(代码+回归测试)。开关默认关闭:查库次数已下降,真实身份库的耗时对照还没做,未达到默认打开的门槛。
|
||||
- 首次发现 / 最近更新:2026-10-14 / 2026-10-14
|
||||
- 来源:issue #58。产品负责人 2026-10-14 决定可以开。任务书 T6。
|
||||
- 现象:每个已登录请求都到身份库读一次会话。
|
||||
- 触发条件:用户端 Better Auth 配置没有会话读缓存。后台没有另一份登录配置。
|
||||
- 根因:用户端会话配置未启用 `cookieCache`。
|
||||
- 修复:只有环境变量 `IDENTITY_SESSION_COOKIE_CACHE` 精确等于 `on` 时,用户端会话才缓存 300 秒。未设置或其它值与现在一样,每次读库。改密码、注销账户、以及全部后台入口每次都跳过缓存。改邮箱、两步验证和后台插件自带的敏感入口本来就会重新读库,这次没有改它们。咨询里的注销检查和后台的封禁检查仍每次查库。会话能活多久、改密码后是否作废、Cookie 的安全属性、表结构都没改。代价:缓存打开后,一台设备改了密码,其它设备上的旧登录最多还能再浏览 300 秒,这段时间做不了改密码和注销。
|
||||
- 验证:`frontend/tests/identity-session-cookie-cache.test.ts` 4 通过。缓存打开时连续 3 次普通读取只查会话 1 次,敏感读取每次都查;缓存关闭时 3 次都查。会话被清掉后,敏感读取立即无效,普通读取在 300 秒内仍有效,超过后无效。
|
||||
- 防复发:上述测试锁住开关关闭时不出现缓存配置,以及 300 秒后普通读取失效。
|
||||
- 相关记录:BUG-1327。
|
||||
- 复发自:无。
|
||||
- 修复版本:分支 `codex/node-runtime-resources-20261013`,尚未合入 staging。
|
||||
|
||||
|
||||
@@ -451,6 +451,8 @@ Prevention: 引擎/上游同步类任务必须 fetch 后核对 `origin/main`,
|
||||
|
||||
Prevention: 引擎/打分路径的性能任务书开工前先查这两份清单;动到其中任何文件,即使输出不变,也要把「按研究协议重新冻结 sealed holdout / reported offset 记录」写进任务分解,否则不要合入。同机 A/B 是必要证据,但不能代替重新冻结。关联 BUG-1047、BUG-985。
|
||||
|
||||
2026-10-14:校正打分提速(BUG-1323~1325、BUG-1345)改了 `event_probes.py`、`dasha_transition_proximity.py`、`active_rectification_event_engine.py`、`api_service.py`。与 `ephemeris_nutation_2026_10_08` 对照输出不变后,另存标签 `scoring_perf_2026_10_13`。nutation 那四份文件没有改。
|
||||
|
||||
## ERR-111 | Shadbala 特征指纹随 PYTHONHASHSEED 变化,同一输入跨进程 `feature_hash` 不同 | observed 2026-09-26
|
||||
|
||||
同一台机器、同一份代码、同一虚构请求连跑两次 `score_candidates`:`candidate_feature_snapshot.features[*].fingerprints.shadbala` / `static` 与 `feature_hash` 每次都不同;固定 `PYTHONHASHSEED=0` 后两次逐字节一致。说明 Shadbala 结果里有依赖集合 / 哈希顺序的结构进入了指纹。打分分数本身未见变化(未系统核对)。
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"record_version": "reported-offset-native-candidate-date-v3",
|
||||
"frozen_at_utc": "2026-10-13T17:45:35.555216+00:00",
|
||||
"ayanamsa": "raman",
|
||||
"node_mode": "mean",
|
||||
"radii_minutes": [
|
||||
15,
|
||||
30,
|
||||
60
|
||||
],
|
||||
"offsets_minutes": [
|
||||
-30,
|
||||
-20,
|
||||
-15,
|
||||
-10,
|
||||
-8,
|
||||
-5,
|
||||
-3,
|
||||
0,
|
||||
3,
|
||||
5,
|
||||
8,
|
||||
10,
|
||||
15,
|
||||
20,
|
||||
30
|
||||
],
|
||||
"minute_step": 1,
|
||||
"dataset": "references/real_case_calibration/minute_rectification_holdout_v3.json",
|
||||
"dataset_sha256": "45aa76ab79dfc6c6404b6252083e1aa5c7e2e2b7de811fc11042a8ad2da3f0ea",
|
||||
"dataset_benchmark_id": "minute_rectification_fact_ranker_v4_holdout_v3",
|
||||
"implementation_sha256": "1c146bf5273edf871d885e9e2e68ee1c8a2f71759b54b4b878f63e771f44197c",
|
||||
"implementation_sha256_prefix": "1c146bf5273edf87",
|
||||
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
|
||||
"production_scoring_files": [
|
||||
"scripts/active_rectification_event_engine.py",
|
||||
"scripts/active_rectification_events.py",
|
||||
"scripts/ashtakavarga.py",
|
||||
"scripts/dasha_analyzer.py",
|
||||
"scripts/divisional_charts_extended.py",
|
||||
"scripts/domain_calculation_service.py",
|
||||
"scripts/functional_benefics.py",
|
||||
"scripts/jaimini.py",
|
||||
"scripts/minute_rectification_fact_ranker_v4.py",
|
||||
"scripts/minute_rectification_feature_facts_v4.py",
|
||||
"scripts/narayana_dasha.py",
|
||||
"scripts/rectification/api_service.py",
|
||||
"scripts/rectification/candidate_contrast.py",
|
||||
"scripts/rectification/candidate_window.py",
|
||||
"scripts/rectification/case_holdout.py",
|
||||
"scripts/rectification/contracts.py",
|
||||
"scripts/rectification/dasha_transition_proximity.py",
|
||||
"scripts/rectification/decision_policy.py",
|
||||
"scripts/rectification/event_probes.py",
|
||||
"scripts/rectification/refinement_packet.py",
|
||||
"scripts/rectification/scoring_service.py",
|
||||
"scripts/shadbala.py",
|
||||
"scripts/varga.py"
|
||||
],
|
||||
"production_scoring_sha256": "89c146df190e8902731ea8111d1aff490fede00c919f529c7cf4c3e629a75579",
|
||||
"research_files": [
|
||||
"scripts/research/reported_offset_sweep.py",
|
||||
"scripts/research/cluster_width_lib.py",
|
||||
"scripts/research/probe_supply_after_six.py",
|
||||
"scripts/research/sealed_holdout_rerun.py",
|
||||
"scripts/minute_rectification_blind_eval.py",
|
||||
"scripts/minute_rectification_fact_blind_eval_v4.py",
|
||||
"scripts/minute_rectification_holdout_validator.py"
|
||||
],
|
||||
"research_implementation_sha256": "085ca37ab70e1da18e6941161868618b22c9c9f09c043d0195de7f5650363453",
|
||||
"file_sha256": {
|
||||
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
|
||||
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
|
||||
"scripts/ashtakavarga.py": "9bfc0b8a06af6122fa6c3881706e64f28b9ba58b53d98ff55957d4741d820f3f",
|
||||
"scripts/dasha_analyzer.py": "5e53b1b4d4c7414c08dd0e7716f120a63c70ec3428ec93547f64d55c580da0e1",
|
||||
"scripts/divisional_charts_extended.py": "e9820e80f14fddc86ab5495d11455d16c2d3c9502a9a242fcf9efc41186e0d11",
|
||||
"scripts/domain_calculation_service.py": "66923a2bc69daf65c965d2973c4b9234edbec5147909eaf3609b6b377ce3c7ca",
|
||||
"scripts/functional_benefics.py": "cbaac3b8c09b97f8724b5300e9a402cbf77089af6c7db8788550d40fd35ac57b",
|
||||
"scripts/jaimini.py": "6b88658da5404da9b136686d92a04be462d61a39da6e8de4255021e2ed2d1173",
|
||||
"scripts/minute_rectification_blind_eval.py": "07aa30d8bad3278669495d5fea8be937aaf6367c8af980ff4f2fe9aa84ccc6ff",
|
||||
"scripts/minute_rectification_fact_blind_eval_v4.py": "bdf460e3467208e8b87345c5beb2128f3e22de7eeffd68f72b281b3ad9affc92",
|
||||
"scripts/minute_rectification_fact_ranker_v4.py": "f1a0d8d2482cd754d2e7f253a9606b0373519435a2bf58cfc3753e2b9ae0fb76",
|
||||
"scripts/minute_rectification_feature_facts_v4.py": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
|
||||
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
|
||||
"scripts/narayana_dasha.py": "c7aaefd4b7b7ea98a5b94e0f7798e5d0139c7423d10ce4ebfff258a460d4456f",
|
||||
"scripts/rectification/api_service.py": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
|
||||
"scripts/rectification/candidate_contrast.py": "eee7da71c0574a08876404b78ca111153fa95fb83ace82b9fdca141d1992e273",
|
||||
"scripts/rectification/candidate_window.py": "2fbc5058b12049b15fca375cdc125b8283e19f9f3e4bedcc291e344501891bef",
|
||||
"scripts/rectification/case_holdout.py": "fe0e699b617d9e216acdfeff87af4221c1d6f4c101bf33e1dbf14e2bcd75a8b5",
|
||||
"scripts/rectification/contracts.py": "c6dc6bd230e38f9f2126c224aae9ddbd6415304c15340f8c7cd3f06f6ef3160f",
|
||||
"scripts/rectification/dasha_transition_proximity.py": "5ce4ed27b0f06c681abc34e5fd998fafb10c153a0070aa36c99ddfa869a2cbf2",
|
||||
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
|
||||
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
|
||||
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
|
||||
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
|
||||
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
|
||||
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
|
||||
"scripts/research/reported_offset_sweep.py": "bdcaac92ece761dbcbd35d5a8f751e6b4b11f8475ef8152a5ea4d36ceb38356a",
|
||||
"scripts/research/sealed_holdout_rerun.py": "27904b39a64ff5f1b95ae3c8fc65ca0f3a8b4d8e3f4b88fbbf5d85ffeb53db0d",
|
||||
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
|
||||
"scripts/varga.py": "c1f06967c3d4d214edadff139c24aa31e59fb628477174235abe14f799a99076"
|
||||
},
|
||||
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock",
|
||||
"scorer": "native_event_contribution_matrix_not_shadow_fact_ranker",
|
||||
"delivery": "initial_signature_clusters_peak_gap_lt_8_envelope_no_answers_no_elimination",
|
||||
"rank": "score_desc_then_sha256(benchmark_id:case_id:candidate_time)",
|
||||
"grid": "every_minute_inclusive_not_production_two_minute_sampling",
|
||||
"cross_midnight": "date_aware_candidates_distance_and_native_single_matrix",
|
||||
"evaluator_sha256": "bdcaac92ece761dbcbd35d5a8f751e6b4b11f8475ef8152a5ea4d36ceb38356a",
|
||||
"replay_revision": "native_candidate_date_v3",
|
||||
"supersedes": "candidate_date_grouped_v2_identity_and_path_not_assumed_numerically_wrong",
|
||||
"is_blind_evaluation": false,
|
||||
"truth_hidden_from_ranker": true,
|
||||
"official_valid_independent_blind": false,
|
||||
"official_blind_trial_count": 0,
|
||||
"results_previously_seen": true,
|
||||
"must_not_use_for_tuning": true
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"record_version": "exposed-v3-fixed-protocol-cross-midnight-rerun-v2",
|
||||
"extended_identity": {
|
||||
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
|
||||
"production_scoring_files": [
|
||||
"scripts/active_rectification_event_engine.py",
|
||||
"scripts/active_rectification_events.py",
|
||||
"scripts/ashtakavarga.py",
|
||||
"scripts/dasha_analyzer.py",
|
||||
"scripts/divisional_charts_extended.py",
|
||||
"scripts/domain_calculation_service.py",
|
||||
"scripts/functional_benefics.py",
|
||||
"scripts/jaimini.py",
|
||||
"scripts/minute_rectification_fact_ranker_v4.py",
|
||||
"scripts/minute_rectification_feature_facts_v4.py",
|
||||
"scripts/narayana_dasha.py",
|
||||
"scripts/rectification/api_service.py",
|
||||
"scripts/rectification/candidate_contrast.py",
|
||||
"scripts/rectification/candidate_window.py",
|
||||
"scripts/rectification/case_holdout.py",
|
||||
"scripts/rectification/contracts.py",
|
||||
"scripts/rectification/dasha_transition_proximity.py",
|
||||
"scripts/rectification/decision_policy.py",
|
||||
"scripts/rectification/event_probes.py",
|
||||
"scripts/rectification/refinement_packet.py",
|
||||
"scripts/rectification/scoring_service.py",
|
||||
"scripts/shadbala.py",
|
||||
"scripts/varga.py"
|
||||
],
|
||||
"production_scoring_sha256": "89c146df190e8902731ea8111d1aff490fede00c919f529c7cf4c3e629a75579",
|
||||
"research_files": [
|
||||
"scripts/research/reported_offset_sweep.py",
|
||||
"scripts/research/cluster_width_lib.py",
|
||||
"scripts/research/probe_supply_after_six.py",
|
||||
"scripts/research/sealed_holdout_rerun.py",
|
||||
"scripts/minute_rectification_blind_eval.py",
|
||||
"scripts/minute_rectification_fact_blind_eval_v4.py",
|
||||
"scripts/minute_rectification_holdout_validator.py"
|
||||
],
|
||||
"research_implementation_sha256": "085ca37ab70e1da18e6941161868618b22c9c9f09c043d0195de7f5650363453",
|
||||
"file_sha256": {
|
||||
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
|
||||
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
|
||||
"scripts/ashtakavarga.py": "9bfc0b8a06af6122fa6c3881706e64f28b9ba58b53d98ff55957d4741d820f3f",
|
||||
"scripts/dasha_analyzer.py": "5e53b1b4d4c7414c08dd0e7716f120a63c70ec3428ec93547f64d55c580da0e1",
|
||||
"scripts/divisional_charts_extended.py": "e9820e80f14fddc86ab5495d11455d16c2d3c9502a9a242fcf9efc41186e0d11",
|
||||
"scripts/domain_calculation_service.py": "66923a2bc69daf65c965d2973c4b9234edbec5147909eaf3609b6b377ce3c7ca",
|
||||
"scripts/functional_benefics.py": "cbaac3b8c09b97f8724b5300e9a402cbf77089af6c7db8788550d40fd35ac57b",
|
||||
"scripts/jaimini.py": "6b88658da5404da9b136686d92a04be462d61a39da6e8de4255021e2ed2d1173",
|
||||
"scripts/minute_rectification_blind_eval.py": "07aa30d8bad3278669495d5fea8be937aaf6367c8af980ff4f2fe9aa84ccc6ff",
|
||||
"scripts/minute_rectification_fact_blind_eval_v4.py": "bdf460e3467208e8b87345c5beb2128f3e22de7eeffd68f72b281b3ad9affc92",
|
||||
"scripts/minute_rectification_fact_ranker_v4.py": "f1a0d8d2482cd754d2e7f253a9606b0373519435a2bf58cfc3753e2b9ae0fb76",
|
||||
"scripts/minute_rectification_feature_facts_v4.py": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
|
||||
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
|
||||
"scripts/narayana_dasha.py": "c7aaefd4b7b7ea98a5b94e0f7798e5d0139c7423d10ce4ebfff258a460d4456f",
|
||||
"scripts/rectification/api_service.py": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
|
||||
"scripts/rectification/candidate_contrast.py": "eee7da71c0574a08876404b78ca111153fa95fb83ace82b9fdca141d1992e273",
|
||||
"scripts/rectification/candidate_window.py": "2fbc5058b12049b15fca375cdc125b8283e19f9f3e4bedcc291e344501891bef",
|
||||
"scripts/rectification/case_holdout.py": "fe0e699b617d9e216acdfeff87af4221c1d6f4c101bf33e1dbf14e2bcd75a8b5",
|
||||
"scripts/rectification/contracts.py": "c6dc6bd230e38f9f2126c224aae9ddbd6415304c15340f8c7cd3f06f6ef3160f",
|
||||
"scripts/rectification/dasha_transition_proximity.py": "5ce4ed27b0f06c681abc34e5fd998fafb10c153a0070aa36c99ddfa869a2cbf2",
|
||||
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
|
||||
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
|
||||
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
|
||||
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
|
||||
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
|
||||
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
|
||||
"scripts/research/reported_offset_sweep.py": "bdcaac92ece761dbcbd35d5a8f751e6b4b11f8475ef8152a5ea4d36ceb38356a",
|
||||
"scripts/research/sealed_holdout_rerun.py": "27904b39a64ff5f1b95ae3c8fc65ca0f3a8b4d8e3f4b88fbbf5d85ffeb53db0d",
|
||||
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
|
||||
"scripts/varga.py": "c1f06967c3d4d214edadff139c24aa31e59fb628477174235abe14f799a99076"
|
||||
},
|
||||
"hash_scope": "explicit_identity_file_sets_not_a_transitive_dependency_lock"
|
||||
},
|
||||
"production_identity_scope": "context_only_shadow_scorer_does_not_call_transition_proximity",
|
||||
"frozen_at_utc": "2026-10-13T17:44:49.007680+00:00",
|
||||
"dataset_path": "references/real_case_calibration/minute_rectification_holdout_v3.json",
|
||||
"dataset_sha256": "45aa76ab79dfc6c6404b6252083e1aa5c7e2e2b7de811fc11042a8ad2da3f0ea",
|
||||
"algorithm_version": "birth-time-event-fact-ranker-v4-shadow",
|
||||
"implementation_sha256": "1c146bf5273edf871d885e9e2e68ee1c8a2f71759b54b4b878f63e771f44197c",
|
||||
"files": [
|
||||
"scripts/active_rectification_event_engine.py",
|
||||
"scripts/active_rectification_events.py",
|
||||
"scripts/ashtakavarga.py",
|
||||
"scripts/dasha_analyzer.py",
|
||||
"scripts/divisional_charts_extended.py",
|
||||
"scripts/domain_calculation_service.py",
|
||||
"scripts/jaimini.py",
|
||||
"scripts/minute_rectification_fact_ranker_v4.py",
|
||||
"scripts/minute_rectification_feature_facts_v4.py",
|
||||
"scripts/narayana_dasha.py",
|
||||
"scripts/shadbala.py",
|
||||
"scripts/varga.py"
|
||||
],
|
||||
"historical_frozen_sha256": "f41c298dd6cdcebe7a93e632f7191954be7987f6012ca2a34cecb6e447fbf196",
|
||||
"evaluator_sha256": "27904b39a64ff5f1b95ae3c8fc65ca0f3a8b4d8e3f4b88fbbf5d85ffeb53db0d",
|
||||
"ayanamsa": "raman",
|
||||
"node_mode": "mean",
|
||||
"candidate_radius_minutes": [
|
||||
10
|
||||
],
|
||||
"minute_step": 1,
|
||||
"release_metrics": {
|
||||
"top_1_rate_minimum": 0.6,
|
||||
"top_3_rate_minimum": 0.85,
|
||||
"mean_absolute_minute_error_maximum": 2.0,
|
||||
"false_confirmation_rate_maximum": 0.05,
|
||||
"correct_insufficient_evidence_rejection_rate_minimum": 0.9
|
||||
},
|
||||
"results_previously_seen": true,
|
||||
"official_valid_independent_blind": false,
|
||||
"must_not_use_for_tuning": true,
|
||||
"tie_breaker": "sha256(benchmark_id:case_id:candidate_time); published minute is never passed to the ranker",
|
||||
"metric_rank_definition": "competition_rank_1_plus_strictly_higher_scores_legacy_protocol",
|
||||
"extra_metric_rank_definition": "score_desc_then_existing_opaque_sha256_total_order"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
# 进度 · 服务器内存与数据库往返(2026-10-13)
|
||||
|
||||
issue #58。分支 `codex/node-runtime-resources-20261013`。
|
||||
|
||||
2026-10-14 按修复单把往返对照脚本从 `frontend/scripts/bench-local-pg-roundtrips.ts` 挪到 `frontend/tests/bench-local-pg-roundtrips.ts`。文件名不是 `.test.ts`,全量测试不会自动收进去。业务代码没有改,对照数字没有重跑。原任务书里的旧路径保持不动。
|
||||
|
||||
任务书基线写的是 `77057a819`,计划评论里的基线是 `2994588ad4d858dead4835943e5883c5aa2b03f8`。本分支实际从当时的 `origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f` 拉出。没有因此停下来问产品,改动仍按任务书做。
|
||||
|
||||
## 做了什么
|
||||
|
||||
- 报告工人的等待:计时器到点就摘掉取消监听。引导打分里同一段等待也摘掉。空转不再往进程上堆监听。
|
||||
- 本地数据库:列、主键、函数签名缓存 300 秒;设置角色和两条身份合成一条语句。开始和提交仍分开。两个开关默认打开,写成 `off` 时语句顺序与改前一致。
|
||||
- 三张内存表:交付记录超过 60 秒删除,最多 1 万条;重算记录超过 6 小时删除,最多 5 千条;限流按见过的最大窗口清理已出窗的键。
|
||||
- 登录读缓存:只有 `IDENTITY_SESSION_COOKIE_CACHE=on` 才缓存 300 秒。默认关闭。
|
||||
|
||||
## 不做的
|
||||
|
||||
只读、又没有身份时省掉事务:不做。那种做法只能用会话级角色,归还连接前再清。往返数不降,而且一旦没清掉,下一次请求会带着上一次的角色。违反「连接还回池子时不能带着角色或身份」。
|
||||
|
||||
`begin` 没有并进一条多语句查询。
|
||||
|
||||
## 语义变化
|
||||
|
||||
同一道校正题、同一份证据,6 小时后或因为超过 5 千条被挤出后,可能再重算一次。这比「进程重启后才会再算」更收敛。60 秒内的交付去重、窗口内的限流允许或拒绝,都没有改。
|
||||
|
||||
## 基线失败
|
||||
|
||||
修之前的等待写法,在第 200 次空转时取消信号上还有 199 个监听。同形脚本输出:`listenersAt200=199`,`assertion failed: 199 !== 0`。修之后的测试断言是 0,并且通过。
|
||||
|
||||
## 登录读取的调用点
|
||||
|
||||
敏感,每次跳过缓存:
|
||||
|
||||
- 改密码:`frontend/src/app/api/account/password/route.ts`
|
||||
- 注销账户:`frontend/src/app/api/account/deletion/route.ts`
|
||||
- 全部后台入口:`frontend/src/lib/admin/auth.ts`
|
||||
|
||||
其余业务接口走 `createServerSupabaseClient()`,不带跳过参数。开关打开后,这些普通读取可以走 300 秒缓存。
|
||||
|
||||
改邮箱、两步验证、后台插件自带的敏感入口本来就会重新读库,这次没改。咨询里检查「正在注销」的那次调用,以及后台查封禁的语句,都没有改成缓存。
|
||||
|
||||
## 对照结论
|
||||
|
||||
详见 `docs/testing/node-runtime-resources-ab-20261013.md`。两个数据库开关一起:往返 49 → 32(低于 70%),耗时中位数 75.84 → 36.48 毫秒(下降 51.9%),返回内容一致。`npm run test:db` 开关关、开关开各一轮,都是 81 项、通过 68、失败 13、取消 0。失败名称与 `tools/agent-loop/windows-known-failures.txt` 的 13 条相同,没有多出来的。运行器因此退出码是 1,这 13 条是 Windows 上原有的。两个数据库开关默认打开。
|
||||
|
||||
登录读缓存的查库次数在测试里从 3 降到 1,但没有对真实身份库做耗时对照,默认保持关闭。要打开,把 `IDENTITY_SESSION_COOKIE_CACHE` 设成 `on`。
|
||||
|
||||
网页容器已经用 env 文件读环境变量,所以这三个名字不必改 Compose。
|
||||
|
||||
## 自测
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `tsc --noEmit -p frontend` | 通过,退出码 0 |
|
||||
| 规范 | `npm run lint`(在 frontend) | 0 error,125 条原有 warning,未改 |
|
||||
| 本单测试 | `tsx --test` 任务书点名的文件加登录缓存测试 | 134 通过,0 失败,0 取消 |
|
||||
| 真库身份 | `database-local-postgres-session-setup.test.ts` | 1 通过。事务内角色和身份生效,连接归还后再借出时身份已空 |
|
||||
| 数据库套件 | `npm run test:db`,两开关先关后开 | 各 81 / 通过 68 / 失败 13 / 取消 0,名称与已知清单一致 |
|
||||
| 往返对照 | `tests/bench-local-pg-roundtrips.ts`(按修复单从 `scripts/` 挪来) | 见上,`payloadMismatch` false。数字未重跑 |
|
||||
|
||||
没有跑前端全量 `npm test`、Python 快速门、`next build`。生产内存曲线要等部署后看。真机两台设备改密码的步骤在 `docs/testing/node-runtime-resources-20261013.md`,登录缓存默认关着,那份清单的第 2 步要先把开关打开才看得到。
|
||||
|
||||
## 2026-10-14 修复单
|
||||
|
||||
按 `docs/tasks/TASK-node-runtime-resources-fix-20261014.md`:先合并最新 `origin/staging`,再把对照脚本挪进测试目录。业务代码没有改。对照数字没有重跑。
|
||||
|
||||
和上一版 `4c3485bc` 比,网页业务目录里多出来的只有合并进来的校正提速改动(`frontend/src/lib/rectification-agentic/v9/engine-client.ts`),不是这单写的。这单自己的提交不碰 `frontend/src`。
|
||||
|
||||
| 检查 | 命令 | 结果 |
|
||||
| --- | --- | --- |
|
||||
| 类型 | `tsc --noEmit -p frontend` | 通过,退出码 0 |
|
||||
| 规范 | `npm run lint`(在 frontend) | 0 error,125 条原有 warning,未改 |
|
||||
| 镜像导入守卫 | `tsx --test tests/runtime-sources-no-test-imports.test.ts` | 2 通过,0 失败,0 取消 |
|
||||
@@ -0,0 +1,97 @@
|
||||
# 校正打分提速进度(2026-10-14)
|
||||
|
||||
结论:两小时窗口的打分大约快到原来的 38%,结果不变。11 个场景的规范化输出与基线逐字节相同。密封重跑 20 例的 trials、metrics 与历史比较和 2026-10-08 那份记录逐字节相同,`changed_trial_count` 仍是 8。报偏移 900 例的核对写在文末。
|
||||
|
||||
## 基线
|
||||
|
||||
| 项 | 值 |
|
||||
| --- | --- |
|
||||
| 任务书基线 | `77057a819` |
|
||||
| 计划评论里的 base | `2994588ad` |
|
||||
| 本分支实际起点 | `74384be0243b16e7a261c2afdea2ca12bab92285`(当时的 `origin/staging`) |
|
||||
| 冻结文件相对 `77057a819` | `git diff --stat` 为空,所以计时和逐字节对照仍用这份基线 |
|
||||
|
||||
机器:13th Gen Intel Core i5-13400F,16 逻辑处理器,Windows。同一进程里先跑基线 5 次再跑新代码 5 次,取中位数。`PYTHONHASHSEED=0`。绝对秒数比任务书写的 3.8–4.8 秒高,因为那组数字不是这台机器测的。下面的比例以这次同机对照为准。
|
||||
|
||||
## 同机耗时(5 次中位数)
|
||||
|
||||
| 窗口 | 候选 | 基线 | 现在 | 相对基线 | 目标 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 12:00–12:20 | 21 | 0.5359 s | 0.3185 s | 59% | 不慢于基线 +5% |
|
||||
| 11:45–12:15 | 31 | 1.9700 s | 0.8696 s | 44% | — |
|
||||
| 11:00–13:00 | 121 | 10.6181 s | 4.0695 s | 38% | ≤ 45% |
|
||||
| 全天 `block_scan` 步长 10 | 144 | 6.7187 s | 3.8025 s | 57% | 不慢于基线 |
|
||||
|
||||
121 候选分段(中位数,秒):
|
||||
|
||||
| 段 | 基线 | 现在 | 相对 |
|
||||
| --- | --- | --- | --- |
|
||||
| `guided_collect_windows` | 4.8700 | 0.6116 | 13%(目标 ≤ 25%) |
|
||||
| `merge_transition_proximity` | 1.7124 | 0.4497 | 26%(目标 ≤ 35%) |
|
||||
| `get_current_narayana_dasha` | 0.7562 | 0.0387 | 探针路径仍会调用,打分循环为 0 |
|
||||
| `derive_functional_benefic_malefic` | 0.3295 | 0.0081 | 一次打分 121 次,每个候选 1 次 |
|
||||
| `build_candidate_static_context` | 0.9830 | 1.0625 | 这里多算了两份缓存,总时间仍下降 |
|
||||
|
||||
121 候选默认响应的规范化字节:基线 2,071,995(响应里带着矩阵),现在 930,631(约 0.89 MB)。目标 ≤ 0.96 MB。
|
||||
|
||||
## 11 个场景 sha256
|
||||
|
||||
两边都用 `PYTHONHASHSEED=0`,`sort_keys=True`,去掉 `column_compare_ms`。打分场景打开 `include_internal=True`。诊断场景另外去掉 `candidate_decision_receipt`,因为新诊断不再重复那份内部回执。基线工作树是一次性检出 `77057a819`,比完已删除。
|
||||
|
||||
| 场景 | sha256 | 字节 | 相同 |
|
||||
| --- | --- | --- | --- |
|
||||
| 12:00–12:20 | `2bc6c777ac4957ce99277649879f7f8ded5c8f246361d73cf18454a3ac7466b0` | 429250 | 是 |
|
||||
| 11:45–12:15 | `f886afac4b27a8a1f36c771bfdf1565e8df1f4f579d19e73288b0549a9544bda` | 597372 | 是 |
|
||||
| 11:00–13:00 | `2d5dcb5cd6e92664bba692d59f75eabdc4b55f822817b7a6f5106c89fd8c8708` | 2071995 | 是 |
|
||||
| 23:20–00:40 | `73aeb9b7a4d0a3a2b5643aff5e2ef779f15e2e765eefc60aa3985b347ca4b9c1` | 1255752 | 是 |
|
||||
| declined relationship+career | `9e48c7cf4b21c133bb52b85e73b0ab7891826a0125a5ef428f6a280503585ee8` | 1082766 | 是 |
|
||||
| refresh probes + column times | `7c977c19c48448e92a7845225ba04897359f29bbaf92e4ff2c69a83e7b070b1a` | 932456 | 是 |
|
||||
| 10:00–13:00 步长 2 | `e03adc5cd88e4df144186afc88cead358b67db37784717e8331f256507357cdc` | 1405125 | 是 |
|
||||
| block_scan 全天步长 10 | `38b27ab49744a7a8036017d10e919f2b8b8baa58fd53590e4b0a35d9689eb1e9` | 394426 | 是 |
|
||||
| block_scan 10:00–14:00 步长 5 | `6c13482afd5b5de9545185b3c92145f40629550556b2f0e83b009e690d933133` | 129483 | 是 |
|
||||
| 记忆化测试的公开请求 | `e0eb1c449a2a42cd081b15af5dece3f97fcffae627ab605d9514b413617964a7` | 90384 | 是 |
|
||||
| diagnostics 11:00–13:00 | `5a9d9ad7304ac01419ba85b6a24c23d1bc9b00ec1df417a7bb9385ce8dfea83b` | 456088 | 是 |
|
||||
|
||||
## 计数与等价
|
||||
|
||||
- 引导窗口:一次调用里 `_context_time` ≤ 16 × 候选数。121 与 21 的次数比不超过 (121/21)×1.2。输出与基线函数副本四种形状相同。
|
||||
- 换段邻近:一次调用里 `_vim_start_dates` ≤ 2 × 候选数,`_narayana_start_dates` ≤ 1 × 候选数。先按年份 1–9999 算全表,再按事件年份过滤。`event_probes.py` 里这两个函数只在年份落在窗口内时追加,空结果不依赖窗口,所以过滤后的列表与原来在窗口里现算相同。跨午夜矩阵也相同。
|
||||
- 功能吉凶:一次 121 候选打分调用 121 次。放在静态上下文顶层,不进 `feature`,`feature_hash` 不变。
|
||||
- 那罗延子运:打分循环(`_candidate_row`)里 `get_current_narayana_dasha` 为 0。0 到 120 岁、步长 0.01 年,缓存查找与现算相同。探针路径 `event_probes._score_year` 仍不接收这张表,所以整次打分里这个函数不是 0。这是任务书明确不改的偏差。
|
||||
- 候选上限:240 和 241 通过;242 和全天步长 1 在排盘前 50 毫秒内拒绝。步长 2、5、10 的产品窗口通过。全天 `block_scan` 在没给步长时自己改成 10,不触发上限。研究脚本里的回放半径到 120 正好 241,不超限。冻结重跑不经过 `score_candidates`,上限不影响那 900 例。
|
||||
|
||||
超过上限时 HTTP 400,正文含 `too_many_candidates`。API 入口文件没有改,所以错误码仍是 `ERR_BAD_REQUEST`。前端把这种 400 当成坏请求,不按服务忙重试。
|
||||
|
||||
## 三栏
|
||||
|
||||
| 位置 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tests/test_rectification_engine_memoization.py` 缓存键 | 4 个 | 6 个,加上功能吉凶和那罗延子运表 | BUG-1324 |
|
||||
| `tests/test_rectification_v5_services.py` 矩阵断言 | 默认响应里有矩阵 | 默认没有;`include_internal=True` 才有 | BUG-1325 |
|
||||
| 同文件回执读取 | `candidate_decision_receipt` | `decision_receipt` | BUG-1325 |
|
||||
| 两份研究脚本的 `FREEZE` / `REPORT` | `ephemeris_nutation_2026_10_08` | `scoring_perf_2026_10_13` | BUG-1323~1325 改了冻结身份文件,输出不变,ERR-110 另存新记录 |
|
||||
| 密封合同当前指针 | nutation 记录 | `scoring_perf_2026_10_13`;nutation 放进该对象里的 `previous_*` | 同上 |
|
||||
| `tests/test_sealed_holdout_contract_freshness.py` 排除前缀 | 到 nutation | 再排除两份新记录 | 同上 |
|
||||
| `frontend/tests/rectification-confirmation-gate.test.ts` 冻结路径 | nutation 报告 | `sealed_holdout_rerun_scoring_perf_2026_10_13.json` | 同上 |
|
||||
| `tests/test_repo_privacy_markers.py` 时钟集合行号 | 1182、1190 | 1195、1203 | BUG-1325 的三栏注释把原集合下移,钟点没变 |
|
||||
|
||||
## 冻结核对
|
||||
|
||||
标签 `scoring_perf_2026_10_13`。nutation 四份文件没有改。
|
||||
|
||||
密封重跑:trials 规范化 JSON 与 `sealed_holdout_rerun_ephemeris_nutation_2026_10_08.json` 逐字节相同,metrics 相同,`historical_comparison` 相同,`changed_trial_count` 仍是 8。新报告 sha256 `25e2e59ecab81b528f0f27369a8638a907ea551f57fdd8572db00fe25a139641`。冻结记录 sha256 `1ce7f7ac67221e10bb4d176f5e8af9299fbae7586717d83048b7e3c124e43e7c`。当前树遗留 12 文件哈希 `1c146bf5273edf871d885e9e2e68ee1c8a2f71759b54b4b878f63e771f44197c`(`active_rectification_event_engine.py` 在这 12 个文件里,所以这份哈希变了,输出没变)。
|
||||
|
||||
报偏移 900 例:trials 规范化 JSON、summary、`historical_comparison` 都与 `reported_offset_ephemeris_nutation_2026_10_08.json` 相同。`changed_trial_count` 仍是 581。这份报告没有 `metrics` 键,旧报告也没有。新报告 sha256 `9466cf3945a63621104157fb34291cb23205ddb045effa7e70f3c35647516443`。冻结记录 sha256 `1560f24ffea19116c904272907ff9cce24904960406676f3aada3c8c174cf98a`。重跑从 `2026-10-13T17:45:36Z` 到 `2026-10-13T18:02:16Z`。
|
||||
|
||||
## 本单不做
|
||||
|
||||
- 探针路径的大运用的是请求日期,并且不接收那罗延子运表。
|
||||
- 候选特征快照瘦身要动前端契约。
|
||||
- 引导窗口和换段邻近共用同一张大运起点表。
|
||||
|
||||
## 自测
|
||||
|
||||
- `tests/test_rect_scoring_perf_20261013.py` 与任务书点名的校正测试、`tests/test_rectification_*.py`(当时尚未改指针的完整性门除外)退出码 0。
|
||||
- 指针改完后 `tests/test_sealed_holdout_contract_freshness.py` 退出码 0。
|
||||
- 前端 `tsc --noEmit` 退出码 0,`npm run lint` 0 error。`rectification-v9-engine-contract.test.ts` 18 通过、0 失败、0 取消。`rectification-confirmation-gate.test.ts` 8 通过、0 失败、0 取消。
|
||||
- 没有跑前端全量,也没有跑 Python 快速门。
|
||||
@@ -467,9 +467,11 @@
|
||||
|
||||
| 任务书 | 进度 | 主题 | 状态 | 落点 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `TASK-rect-scoring-perf-20261013.md` | — | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段、候选数上限 241(BUG-1323~1325、1345) | 待领取 | `codex/rect-scoring-perf-20261013` |
|
||||
| `TASK-node-runtime-resources-20261013.md` | — | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待领取 | `codex/node-runtime-resources-20261013` |
|
||||
| `TASK-rect-scoring-perf-20261013.md` | `PROGRESS-rect-scoring-perf-20261013.md` | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段、候选数上限 241(BUG-1323~1325、1345) | 待验收 | `codex/rect-scoring-perf-20261013` |
|
||||
| `TASK-node-runtime-resources-20261013.md` | `PROGRESS-node-runtime-resources-20261013.md` | 报告 worker abort 监听泄漏、本地 PG 适配层往返与元数据缓存、无上限内存表、用户端会话读缓存(BUG-1326~1328、1346,PG 与缓存部分 A/B) | 待验收 | `codex/node-runtime-resources-20261013` |
|
||||
| `TASK-node-runtime-resources-fix-20261014.md` | `PROGRESS-node-runtime-resources-20261013.md` | **#58 第 1 次打回**:`4c3485bc` 其余全过;压测脚本放在 `frontend/scripts/` 却从 `frontend/tests/` 导入,镜像构建会失败(BUG-1140 形状,任务书指定的路径有误)。挪到 `frontend/tests/` 下非 `.test` 文件名 | 待验收 | `codex/node-runtime-resources-20261013` |
|
||||
| `TASK-home-sidebar-rerender-20261013.md` | — | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待领取 | `codex/home-sidebar-rerender-20261013` |
|
||||
| `TASK-home-sidebar-rerender-fix-20261014.md` | — | **#59 第 1 次打回**:`63509431` 其余全过;`chat-navigation-a11y-contract` 按源码文本断言 `onOpenBilling` 旧写法未跟改(§7.8) | 待领取 | `codex/home-sidebar-rerender-20261013` |
|
||||
| `TASK-api-disk-cache-retention-20261013.md` | — | API 磁盘缓存只进不出、VedAstro 审计文件留存出生资料、失败快照久缓存、缓存键规范化(BUG-1330~1332,A/B) | 待领取 | `codex/api-disk-cache-retention-20261013` |
|
||||
| `TASK-report-reference-perf-20261013.md` | — | 长报告出稿提速:术语替换命中预检、哈希单次遍历、同盘复用(BUG-1335~1336,A/B) | 待领取 | `codex/report-reference-perf-20261013` |
|
||||
| `TASK-consult-prep-parallel-20261013.md` | — | 普通对话发问前准备步骤并行、模型版本短缓存、多领域并发开关(BUG-1337~1339,A/B) | 待领取 | `codex/consult-prep-parallel-20261013` |
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# TASK · 侧栏重渲染修复单(issue #59,第 1 次打回)
|
||||
|
||||
- 日期:2026-10-14
|
||||
- 原任务书:`docs/tasks/TASK-home-sidebar-rerender-20261013.md`
|
||||
- 被验 SHA:`63509431529d212c7847371e7717660d823b40e6`(`codex/home-sidebar-rerender-20261013`)
|
||||
- 执行方:Grok,同一分支续做(落后 staging 时用 merge,不改写已推历史)
|
||||
- BUG 编号:沿用 BUG-1329
|
||||
|
||||
## 1. 验收结论:一项未通过,其余全部通过
|
||||
|
||||
| 检查 | 结果 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 前端全量 `npm test`(与 staging 基线逐名比对) | **未通过:新增 1 条失败** | `frontend/tests/chat-navigation-a11y-contract.test.ts`「in-app destinations navigate client-side so the chat survives the round trip」:`The input did not match the regular expression /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/` |
|
||||
| T1 稳定包装 + 比较后发布 | 通过 | 逐行看过:`latestRef` 在无依赖 effect 里写,发布 effect 在其后;未冻结首次闭包;`HomeProbe` 的故意不稳定输入未改 |
|
||||
| T2 `visibleSessions` 记忆化 | 通过 | — |
|
||||
| T3 `SidebarProvider` context 记忆化 | 通过 | — |
|
||||
| 改既有断言 | 通过 | `session-list-lifecycle` 三栏齐全,且加强了(再改一版后仍调到最新) |
|
||||
| tsc / lint | 通过 | 0 错 / 0 error |
|
||||
|
||||
**根因**:`use-home-shell-registration.ts` 把 `onOpenBilling` 从 `() => openAccountDialog("billing", { source: "account-menu" })` 改成了 `() => { latestRef.current?.openAccountDialog("billing", { source: "account-menu" }); }`,而 `chat-navigation-a11y-contract.test.ts` 第 42 行按源码文本断言旧写法(AGENTS.md §7.8:改写语句前要 grep 按源码文本断言的合同测试,同一轮跟着改)。
|
||||
|
||||
## 2. 修法要求
|
||||
|
||||
1. 只改 `frontend/tests/chat-navigation-a11y-contract.test.ts` 第 42 行这条断言,按 §7.3 写「原值 / 新值 / 原因」三栏。新正则要**仍然锁住意图**:账户菜单里的「充值」打开 billing 且 `source: "account-menu"`。例如匹配 `onOpenBilling: \(\) => \{ latestRef\.current\?\.openAccountDialog\("billing", \{ source: "account-menu" \}\); \}`;不得放宽成只查 `"billing"`。
|
||||
2. 再 grep 一遍:`grep -rn "onOpenProfile\|onOpenGeneral\|onOpenLogout\|onNewChat\|onSelectSession\|onLoadMore" frontend/tests`,有按旧写法断言源码文本的同样处理(本次全量里只红了这一条,但要确认)。
|
||||
3. 业务代码不改。先把最新 `origin/staging` 合并进分支(用 merge)。
|
||||
4. PROGRESS 末尾记一行。
|
||||
|
||||
## 3. 验收标准
|
||||
|
||||
- 前端全量 `npm test` 失败清单与 staging 基线逐名相同;
|
||||
- `git diff 63509431..<新 SHA> -- frontend/src` 为空。
|
||||
|
||||
## 4. 让步顺序
|
||||
|
||||
无。第 1 次打回;再打回会停下等产品负责人。
|
||||
@@ -0,0 +1,39 @@
|
||||
# TASK · 服务器内存与数据库往返修复单(issue #58,第 1 次打回)
|
||||
|
||||
- 日期:2026-10-14
|
||||
- 原任务书:`docs/tasks/TASK-node-runtime-resources-20261013.md`
|
||||
- 被验 SHA:`4c3485bc2352e8f9de097d81d9979092ee2bb280`(`codex/node-runtime-resources-20261013`)
|
||||
- 执行方:Grok,同一分支续做(落后 staging 时用 merge,不改写已推历史)
|
||||
- BUG 编号:沿用 BUG-1326~1328、1346
|
||||
|
||||
## 1. 验收结论:一项未通过,其余全部通过
|
||||
|
||||
| 检查 | 结果 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 前端全量 `npm test`(与 staging 基线逐名比对) | **未通过:新增 1 条真实失败** | `runtime-sources-no-test-imports.test.ts`「files copied into the web image never import from frontend/tests」:`actual: ['scripts/bench-local-pg-roundtrips.ts']` |
|
||||
| 同上,另一条新增 | 环境缺口(不算未通过) | `database-local-postgres-session-setup.test.ts` 报 `spawnSync docker ENOENT`,本机无 Docker;以 Grok 的 test:db 回报为准 |
|
||||
| T1 报告 worker 摘监听 / T1b | 通过 | 代码与新测试逐行看过 |
|
||||
| T2 元数据缓存 + 合并身份设置 | 通过 | 角色仍只来自构造函数白名单;只缓存表结构;`off` 走原语句顺序;A/B 记录 49→32 往返、中位数降 51.9%、逐字节一致 |
|
||||
| T3 三张内存表 | 通过 | — |
|
||||
| T6 登录读缓存 | 通过(默认关,理由成立) | 改密码 / 注销 / 后台三处已强制绕过;默认关是因为没有身份库真实耗时对照,符合原任务书门槛 |
|
||||
| tsc / lint / next build | 通过 | 0 错 / 0 error / `/` 仍 Static |
|
||||
| 越界 / 隐私 | 通过 | — |
|
||||
|
||||
**根因**:`frontend/scripts/bench-local-pg-roundtrips.ts` 第 16 行 `import { startPostgresFixture } from "../tests/helpers/postgres-fixture.ts";`。web 镜像只复制 `frontend/src` 与 `frontend/scripts`,不复制 `frontend/tests`,而 `next build` 会类型检查 `frontend/` 下所有 `.ts`——推上 staging 后镜像构建会失败(BUG-1140,同一形状)。**这条路径是原任务书 T4 指定的,错在任务书,不是执行偏差。**
|
||||
|
||||
## 2. 修法要求
|
||||
|
||||
1. 把脚本挪到 `frontend/tests/bench-local-pg-roundtrips.ts`(文件名**不得**以 `.test.ts` 结尾,避免被 `npm test` 自动收进去),导入改成 `./helpers/postgres-fixture.ts`;脚本头注释里的运行命令改成 `npx tsx tests/bench-local-pg-roundtrips.ts`。
|
||||
2. 同步改引用:`docs/tasks/PROGRESS-node-runtime-resources-20261013.md`、`docs/testing/node-runtime-resources-ab-20261013.md`。原任务书不改(历史记录),在 PROGRESS 里写一行「按修复单挪位置」。
|
||||
3. 不改任何业务代码;A/B 数字不用重跑。
|
||||
4. 先把最新 `origin/staging` 合并进分支(用 merge)。
|
||||
|
||||
## 3. 验收标准
|
||||
|
||||
- `frontend/tests/runtime-sources-no-test-imports.test.ts` 通过;
|
||||
- 前端全量 `npm test` 的失败清单只剩 staging 基线那 24 条 + 本单新增的 Docker 测试(无 Docker 时);
|
||||
- `git diff 4c3485bc2..<新 SHA> -- frontend/src` 为空(业务代码一字未动)。
|
||||
|
||||
## 4. 让步顺序
|
||||
|
||||
无。这是第 1 次打回;再打回会停下等产品负责人。
|
||||
@@ -0,0 +1,13 @@
|
||||
# 真机清单 · 登录缓存与长时间运行(2026-10-13)
|
||||
|
||||
对应 BUG-1326、BUG-1346。在 staging 上用两个浏览器(或一台电脑、一台手机)登录同一个测试账号。不要把账号、口令或页面原文记进仓库。
|
||||
|
||||
本轮代码里,登录读缓存默认关闭。要做下面第 2–4 步,服务器环境变量 `IDENTITY_SESSION_COOKIE_CACHE` 必须是 `on`。未设置或其它值时,改密码后另一台设备会马上要求重新登录,第 2 步不会出现「还能浏览大约 5 分钟」。
|
||||
|
||||
| 步 | 怎么做 | 通过标准 |
|
||||
| --- | --- | --- |
|
||||
| 1 | 两台设备都登录,各自打开首页并翻一两页 | 都能正常看到自己的会话,不要求反复登录 |
|
||||
| 2 | 设备 A 修改密码。5 分钟内用设备 B 只浏览首页、会话列表和星盘,不要改密码、不要注销 | 设备 B 在这 5 分钟里仍能浏览 |
|
||||
| 3 | 仍在这 5 分钟内,用设备 B 尝试修改密码或申请注销账户 | 被拒绝,不能拿旧登录做这两件事 |
|
||||
| 4 | 等到改密码已超过 5 分钟,再用设备 B 刷新 | 被要求重新登录 |
|
||||
| 5 | 用新密码在设备 B 重新登录,再浏览一次 | 能进入,会话列表还在 |
|
||||
@@ -0,0 +1,21 @@
|
||||
# 本地数据库往返对照(2026-10-14)
|
||||
|
||||
环境:Windows,Docker 29.8.0,Node v22.23.2。脚本 `frontend/tests/bench-local-pg-roundtrips.ts`(2026-10-14 按修复单从 `frontend/scripts/` 挪来,数字未重跑)。库是测试用的临时 Postgres,数据是虚构用户。每组 50 遍,重复 3 组,先各热身 1 遍。往返数是每组平均值的中位数。耗时中位数是三组中位数的中位数。p95 是全部 150 遍的单遍耗时。
|
||||
|
||||
判定用第二次运行(输出里带三组原始数)。第一次运行同样是 49 降到 32,中位数 89.51 毫秒降到 32.70 毫秒,内容一致,方向相同。
|
||||
|
||||
| 模式 | 开关 | 往返 | 中位数(毫秒) | p95(毫秒) | 三组(往返 / 中位数毫秒) |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| A | 两个都关 | 49 | 75.84 | 88.25 | 49 / 82.51;49 / 75.84;49 / 73.81 |
|
||||
| B | 两个都开 | 32 | 36.48 | 39.47 | 32 / 36.66;32 / 36.48;32 / 35.71 |
|
||||
| B1 | 只开元数据缓存 | 40 | 40.54 | 44.37 | 40 / 41.56;40 / 40.54;40 / 39.86 |
|
||||
| B2 | 只开合并身份设置 | 41 | 67.83 | 72.75 | 41 / 68.46;41 / 66.47;41 / 67.83 |
|
||||
|
||||
- 往返:32 ≤ 49 的 70%(34.3)。通过。
|
||||
- 耗时:下降 (75.84 − 36.48) / 75.84 = 51.9%,不低于 15%。通过。
|
||||
- 四种模式的业务返回逐字节一致(`payloadMismatch` 为 false)。通过。
|
||||
- 只开其中一个时,往返都还高于 70% 那一刀;只合并身份设置时,这一次耗时只降了约 10.6%。两个一起才过门槛,所以两个都默认打开。写成 `off` 仍走改前的语句顺序。
|
||||
|
||||
登录读缓存没有放进这次真库计时。内存里的登录测试看到普通读取的查库次数从 3 降到 1,但那不是身份库的耗时对照,所以 `IDENTITY_SESSION_COOKIE_CACHE` 保持默认关闭。
|
||||
|
||||
`npm run test:db` 两轮都是 81 项、通过 68、失败 13、取消 0。失败名称与 `tools/agent-loop/windows-known-failures.txt` 的 13 条逐条相同,没有多出来的。因此两个数据库开关默认打开。
|
||||
@@ -21,7 +21,7 @@ export const dynamic = "force-dynamic";
|
||||
*/
|
||||
|
||||
async function currentUserId(): Promise<string | null> {
|
||||
const supabase = await createServerSupabaseClient();
|
||||
const supabase = await createServerSupabaseClient({ disableCookieCache: true });
|
||||
const { data: { user }, error } = await supabase.auth.getUser();
|
||||
return error || !user ? null : user.id;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ async function userSession(request: Request) {
|
||||
return null;
|
||||
}
|
||||
const services = getIdentityAuthServices();
|
||||
const session = await services.user.api.getSession({ headers: request.headers });
|
||||
const session = await services.user.api.getSession({
|
||||
headers: request.headers,
|
||||
query: { disableCookieCache: true },
|
||||
});
|
||||
return session ? { services, session } : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export async function requirePermission(
|
||||
requireIdentityServerSession(
|
||||
getIdentityAuthServices().user.api,
|
||||
adminHeaders,
|
||||
{ disableCookieCache: true },
|
||||
),
|
||||
loadAdminSession,
|
||||
});
|
||||
|
||||
@@ -34,10 +34,14 @@ export async function runBirthTimeScoringPoll(input: PollInput): Promise<PollRes
|
||||
export function scoringPollDelay(attempt: number, signal: AbortSignal): Promise<void> {
|
||||
const duration = Math.min(400 * (2 ** attempt), 3_200);
|
||||
return new Promise((resolve) => {
|
||||
const timer = window.setTimeout(resolve, duration);
|
||||
signal.addEventListener("abort", () => {
|
||||
const onAbort = () => {
|
||||
window.clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
};
|
||||
const timer = window.setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, duration);
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -169,7 +169,18 @@ export function upsertConflictColumns(options?: { onConflict?: string }): string
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async function primaryKeyColumns(client: PoolClient, table: string): Promise<string[]> {
|
||||
async function primaryKeyColumns(
|
||||
client: PoolClient,
|
||||
table: string,
|
||||
scope: MetadataScope,
|
||||
): Promise<string[]> {
|
||||
const cacheKey = metadataCacheEnabled()
|
||||
? `${scope.connectionString}|${table}`
|
||||
: null;
|
||||
if (cacheKey) {
|
||||
const cached = readTimed(metadataCache().primaryKeys, cacheKey);
|
||||
if (cached) return [...cached];
|
||||
}
|
||||
identifier(table);
|
||||
const result = await client.query<{ column_name: string }>(
|
||||
`
|
||||
@@ -182,7 +193,9 @@ async function primaryKeyColumns(client: PoolClient, table: string): Promise<str
|
||||
`,
|
||||
[table],
|
||||
);
|
||||
return result.rows.map((row) => row.column_name);
|
||||
const columns = result.rows.map((row) => row.column_name);
|
||||
if (cacheKey && columns.length > 0) writeTimed(metadataCache().primaryKeys, cacheKey, [...columns]);
|
||||
return columns;
|
||||
}
|
||||
|
||||
function queryError(error: unknown): QueryError {
|
||||
@@ -220,7 +233,115 @@ function localDataPool(connectionString: string): Pool {
|
||||
return pool;
|
||||
}
|
||||
|
||||
const METADATA_TTL_MS = 300_000;
|
||||
const METADATA_INVALIDATING_CODES = new Set(["42703", "42P01", "42883", "42804"]);
|
||||
|
||||
type MetadataScope = Readonly<{
|
||||
connectionString: string;
|
||||
role: LocalDatabaseRole | null;
|
||||
}>;
|
||||
|
||||
type TimedCacheEntry<T> = Readonly<{ value: T; storedAt: number }>;
|
||||
|
||||
type LocalPostgresMetadataCache = {
|
||||
columns: Map<string, TimedCacheEntry<Map<string, string>>>;
|
||||
primaryKeys: Map<string, TimedCacheEntry<string[]>>;
|
||||
functions: Map<string, TimedCacheEntry<FunctionMetadata>>;
|
||||
};
|
||||
|
||||
const metadataGlobal = globalThis as typeof globalThis & {
|
||||
jyotishaLocalPostgresMetadata?: LocalPostgresMetadataCache;
|
||||
jyotishaLocalPostgresMetadataNow?: () => number;
|
||||
};
|
||||
|
||||
function metadataCache(): LocalPostgresMetadataCache {
|
||||
metadataGlobal.jyotishaLocalPostgresMetadata ??= {
|
||||
columns: new Map(),
|
||||
primaryKeys: new Map(),
|
||||
functions: new Map(),
|
||||
};
|
||||
return metadataGlobal.jyotishaLocalPostgresMetadata;
|
||||
}
|
||||
|
||||
function metadataNow(): number {
|
||||
return metadataGlobal.jyotishaLocalPostgresMetadataNow?.() ?? Date.now();
|
||||
}
|
||||
|
||||
function metadataCacheEnabled(): boolean {
|
||||
// 2026-10-14 A/B passed. Unset means on. Exact "off" keeps the old query sequence.
|
||||
return process.env.JYOTISHA_PG_METADATA_CACHE !== "off";
|
||||
}
|
||||
|
||||
function mergedSessionSetupEnabled(): boolean {
|
||||
return process.env.JYOTISHA_PG_MERGED_SESSION_SETUP !== "off";
|
||||
}
|
||||
|
||||
function roleCacheKey(role: LocalDatabaseRole | null): string {
|
||||
return role ?? "owner";
|
||||
}
|
||||
|
||||
function readTimed<T>(
|
||||
store: Map<string, TimedCacheEntry<T>>,
|
||||
key: string,
|
||||
): T | undefined {
|
||||
const entry = store.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (metadataNow() - entry.storedAt >= METADATA_TTL_MS) {
|
||||
store.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
function writeTimed<T>(
|
||||
store: Map<string, TimedCacheEntry<T>>,
|
||||
key: string,
|
||||
value: T,
|
||||
): void {
|
||||
store.set(key, { value, storedAt: metadataNow() });
|
||||
}
|
||||
|
||||
function dropMetadataForConnection(connectionString: string): void {
|
||||
const cache = metadataGlobal.jyotishaLocalPostgresMetadata;
|
||||
if (!cache) return;
|
||||
const prefix = `${connectionString}|`;
|
||||
for (const store of [cache.columns, cache.primaryKeys, cache.functions]) {
|
||||
for (const key of store.keys()) {
|
||||
if (key.startsWith(prefix)) store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function postgresErrorCode(error: unknown): string | undefined {
|
||||
if (!error || typeof error !== "object") return undefined;
|
||||
const code = (error as { code?: unknown }).code;
|
||||
return typeof code === "string" ? code : undefined;
|
||||
}
|
||||
|
||||
export function clearLocalPostgresMetadataCacheForTests(): void {
|
||||
metadataGlobal.jyotishaLocalPostgresMetadata = {
|
||||
columns: new Map(),
|
||||
primaryKeys: new Map(),
|
||||
functions: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function setLocalPostgresMetadataClockForTests(now: (() => number) | null): void {
|
||||
if (now) metadataGlobal.jyotishaLocalPostgresMetadataNow = now;
|
||||
else delete metadataGlobal.jyotishaLocalPostgresMetadataNow;
|
||||
}
|
||||
|
||||
export function installLocalPostgresPoolForTests(connectionString: string, pool: Pool): void {
|
||||
poolGlobal.jyotishaLocalDataPools ??= new Map();
|
||||
poolGlobal.jyotishaLocalDataPools.set(connectionString, pool);
|
||||
}
|
||||
|
||||
export function localPostgresPoolForTests(connectionString: string): Pool {
|
||||
return localDataPool(connectionString);
|
||||
}
|
||||
|
||||
export async function closeLocalPostgresDataPool(connectionString: string): Promise<void> {
|
||||
dropMetadataForConnection(connectionString);
|
||||
const pools = poolGlobal.jyotishaLocalDataPools;
|
||||
const pool = pools?.get(connectionString);
|
||||
if (!pool) return;
|
||||
@@ -229,6 +350,11 @@ export async function closeLocalPostgresDataPool(connectionString: string): Prom
|
||||
}
|
||||
|
||||
export async function closeLocalPostgresDataPools(): Promise<void> {
|
||||
metadataGlobal.jyotishaLocalPostgresMetadata = {
|
||||
columns: new Map(),
|
||||
primaryKeys: new Map(),
|
||||
functions: new Map(),
|
||||
};
|
||||
const pools = poolGlobal.jyotishaLocalDataPools;
|
||||
poolGlobal.jyotishaLocalDataPools = new Map();
|
||||
if (!pools) return;
|
||||
@@ -237,6 +363,7 @@ export async function closeLocalPostgresDataPools(): Promise<void> {
|
||||
|
||||
async function inBusinessTransaction<T>(
|
||||
pool: Pool,
|
||||
connectionString: string,
|
||||
identity: LocalIdentity,
|
||||
role: LocalDatabaseRole | null,
|
||||
run: (client: PoolClient) => Promise<T>,
|
||||
@@ -244,15 +371,32 @@ async function inBusinessTransaction<T>(
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin");
|
||||
if (role !== null) await client.query(`set local role ${role}`);
|
||||
await client.query(
|
||||
"select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)",
|
||||
[identity?.id ?? "", identity?.email ?? ""],
|
||||
);
|
||||
if (mergedSessionSetupEnabled()) {
|
||||
if (role !== null) {
|
||||
await client.query(
|
||||
"select set_config('role', $1, true), set_config('request.jwt.claim.sub', $2, true), set_config('request.jwt.claim.email', $3, true)",
|
||||
[role, identity?.id ?? "", identity?.email ?? ""],
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
"select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)",
|
||||
[identity?.id ?? "", identity?.email ?? ""],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (role !== null) await client.query(`set local role ${role}`);
|
||||
await client.query(
|
||||
"select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)",
|
||||
[identity?.id ?? "", identity?.email ?? ""],
|
||||
);
|
||||
}
|
||||
const result = await run(client);
|
||||
await client.query("commit");
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (METADATA_INVALIDATING_CODES.has(postgresErrorCode(error) ?? "")) {
|
||||
dropMetadataForConnection(connectionString);
|
||||
}
|
||||
await client.query("rollback").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -263,7 +407,15 @@ async function inBusinessTransaction<T>(
|
||||
async function columnTypes(
|
||||
client: PoolClient,
|
||||
table: string,
|
||||
scope: MetadataScope,
|
||||
): Promise<Map<string, string>> {
|
||||
const cacheKey = metadataCacheEnabled()
|
||||
? `${scope.connectionString}|${roleCacheKey(scope.role)}|${table}`
|
||||
: null;
|
||||
if (cacheKey) {
|
||||
const cached = readTimed(metadataCache().columns, cacheKey);
|
||||
if (cached) return new Map(cached);
|
||||
}
|
||||
const result = await client.query<{ column_name: string; udt_name: string }>(
|
||||
`
|
||||
select column_name, udt_name
|
||||
@@ -272,7 +424,9 @@ async function columnTypes(
|
||||
`,
|
||||
[table],
|
||||
);
|
||||
return new Map(result.rows.map((row) => [row.column_name, row.udt_name]));
|
||||
const types = new Map(result.rows.map((row) => [row.column_name, row.udt_name]));
|
||||
if (cacheKey && types.size > 0) writeTimed(metadataCache().columns, cacheKey, new Map(types));
|
||||
return types;
|
||||
}
|
||||
|
||||
function databaseValue(type: string | undefined, value: unknown): unknown {
|
||||
@@ -314,6 +468,7 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
private readonly connectionString: string,
|
||||
private readonly identity: LocalIdentity,
|
||||
private readonly role: LocalDatabaseRole | null,
|
||||
private readonly table: string,
|
||||
@@ -516,10 +671,14 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
try {
|
||||
return await inBusinessTransaction(
|
||||
this.pool,
|
||||
this.connectionString,
|
||||
this.identity,
|
||||
this.role,
|
||||
async (client) => {
|
||||
const types = await columnTypes(client, this.table);
|
||||
const types = await columnTypes(client, this.table, {
|
||||
connectionString: this.connectionString,
|
||||
role: this.role,
|
||||
});
|
||||
const parameters: unknown[] = [];
|
||||
let sql: string;
|
||||
|
||||
@@ -563,7 +722,10 @@ class LocalPostgresQueryBuilder implements PromiseLike<QueryResult> {
|
||||
if (this.mutation.kind === "upsert") {
|
||||
let conflict = this.mutation.conflict;
|
||||
if (conflict.length === 0) {
|
||||
conflict = await primaryKeyColumns(client, this.table);
|
||||
conflict = await primaryKeyColumns(client, this.table, {
|
||||
connectionString: this.connectionString,
|
||||
role: this.role,
|
||||
});
|
||||
}
|
||||
if (conflict.length === 0) {
|
||||
throw new Error("upsert requires a conflict target");
|
||||
@@ -648,7 +810,21 @@ async function functionMetadata(
|
||||
client: PoolClient,
|
||||
functionName: string,
|
||||
argumentNames: readonly string[],
|
||||
scope: MetadataScope,
|
||||
): Promise<FunctionMetadata> {
|
||||
const cacheKey = metadataCacheEnabled()
|
||||
? `${scope.connectionString}|${roleCacheKey(scope.role)}|${functionName}|${[...argumentNames].sort().join(",")}`
|
||||
: null;
|
||||
if (cacheKey) {
|
||||
const cached = readTimed(metadataCache().functions, cacheKey);
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
argument_names: cached.argument_names ? [...cached.argument_names] : null,
|
||||
argument_types: [...cached.argument_types],
|
||||
};
|
||||
}
|
||||
}
|
||||
const result = await client.query<FunctionMetadata>(
|
||||
`
|
||||
select
|
||||
@@ -671,6 +847,13 @@ async function functionMetadata(
|
||||
);
|
||||
const metadata = result.rows[0];
|
||||
if (!metadata) throw new Error("database function not found");
|
||||
if (cacheKey) {
|
||||
writeTimed(metadataCache().functions, cacheKey, {
|
||||
...metadata,
|
||||
argument_names: metadata.argument_names ? [...metadata.argument_names] : null,
|
||||
argument_types: [...metadata.argument_types],
|
||||
});
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
@@ -691,12 +874,14 @@ export class LocalPostgresDataClient {
|
||||
}>;
|
||||
|
||||
private readonly pool: Pool;
|
||||
private readonly connectionString: string;
|
||||
|
||||
constructor(
|
||||
connectionString: string,
|
||||
private readonly identity: LocalIdentity,
|
||||
private readonly role: LocalDatabaseRole | null,
|
||||
) {
|
||||
this.connectionString = connectionString;
|
||||
if (role !== null && role !== "authenticated" && role !== "service_role") {
|
||||
throw new Error("unsupported database role");
|
||||
}
|
||||
@@ -709,6 +894,7 @@ export class LocalPostgresDataClient {
|
||||
from(table: string) {
|
||||
return new LocalPostgresQueryBuilder(
|
||||
this.pool,
|
||||
this.connectionString,
|
||||
this.identity,
|
||||
this.role,
|
||||
table,
|
||||
@@ -723,12 +909,16 @@ export class LocalPostgresDataClient {
|
||||
identifier(functionName);
|
||||
return await inBusinessTransaction(
|
||||
this.pool,
|
||||
this.connectionString,
|
||||
this.identity,
|
||||
this.role,
|
||||
async (client) => {
|
||||
const names = Object.keys(args);
|
||||
names.forEach(identifier);
|
||||
const metadata = await functionMetadata(client, functionName, names);
|
||||
const metadata = await functionMetadata(client, functionName, names, {
|
||||
connectionString: this.connectionString,
|
||||
role: this.role,
|
||||
});
|
||||
const typeByName = new Map(
|
||||
(metadata.argument_names ?? []).map((name, index) => [
|
||||
name,
|
||||
|
||||
@@ -173,13 +173,17 @@ function defaultRetryDelayMs(job: PersonalReportJobRecord): number {
|
||||
async function defaultSleep(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = globalThis.setTimeout(resolve, milliseconds);
|
||||
const candidate = timer as ReturnType<typeof globalThis.setTimeout> & { unref?: () => void };
|
||||
candidate.unref?.();
|
||||
signal?.addEventListener("abort", () => {
|
||||
const onAbort = () => {
|
||||
globalThis.clearTimeout(timer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
};
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, milliseconds);
|
||||
const candidate = timer as ReturnType<typeof globalThis.setTimeout> & { unref?: () => void };
|
||||
candidate.unref?.();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -188,12 +188,47 @@ export async function rescoreMinuteAfterWindowChange(
|
||||
|
||||
export { persistBlockScanPayload } from "./score-persist.ts";
|
||||
|
||||
const rescoreAttempts = new Map<string, true>();
|
||||
const RESCORE_ATTEMPT_TTL_MS = 6 * 60 * 60 * 1000;
|
||||
const RESCORE_ATTEMPT_CAP = 5_000;
|
||||
|
||||
const rescoreAttempts = new Map<string, number>();
|
||||
|
||||
export function resetStaleMinuteRescoreAttemptsForTests(): void {
|
||||
rescoreAttempts.clear();
|
||||
}
|
||||
|
||||
function pruneRescoreAttempts(now: number): void {
|
||||
for (const [key, at] of rescoreAttempts) {
|
||||
if (now - at > RESCORE_ATTEMPT_TTL_MS) rescoreAttempts.delete(key);
|
||||
}
|
||||
while (rescoreAttempts.size > RESCORE_ATTEMPT_CAP) {
|
||||
const oldest = rescoreAttempts.keys().next().value;
|
||||
if (oldest === undefined) return;
|
||||
rescoreAttempts.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true when this key should be scored now. A fresh key is remembered. */
|
||||
function claimRescoreAttempt(key: string, now: number): boolean {
|
||||
pruneRescoreAttempts(now);
|
||||
const existing = rescoreAttempts.get(key);
|
||||
if (existing !== undefined && now - existing <= RESCORE_ATTEMPT_TTL_MS) return false;
|
||||
if (!rescoreAttempts.has(key) && rescoreAttempts.size >= RESCORE_ATTEMPT_CAP) {
|
||||
const oldest = rescoreAttempts.keys().next().value;
|
||||
if (oldest !== undefined) rescoreAttempts.delete(oldest);
|
||||
}
|
||||
rescoreAttempts.set(key, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function noteRescoreAttemptForTests(key: string, at: number): void {
|
||||
claimRescoreAttempt(key, at);
|
||||
}
|
||||
|
||||
export function rescoreAttemptCountForTests(): number {
|
||||
return rescoreAttempts.size;
|
||||
}
|
||||
|
||||
function rescoreAttemptKey(caseId: string, fingerprint: string, askedTurnId?: string | null): string {
|
||||
return `${caseId}:${askedTurnId ?? ""}:${fingerprint}`;
|
||||
}
|
||||
@@ -219,10 +254,9 @@ export async function rescoreStaleMinuteSnapshotIfNeeded(input: {
|
||||
}
|
||||
const fingerprint = evidenceLedgerFingerprint(dossier.evidence);
|
||||
const key = rescoreAttemptKey(input.caseId, fingerprint, input.askedTurnId);
|
||||
if (rescoreAttempts.has(key)) {
|
||||
if (!claimRescoreAttempt(key, Date.now())) {
|
||||
return { dossier, snapshotCurrent: false, rescoreAttempted: false };
|
||||
}
|
||||
rescoreAttempts.set(key, true);
|
||||
try {
|
||||
await rescoreMinuteAfterWindowChange(input.accounting, input.userId, input.caseId, {
|
||||
keepAnswers: true,
|
||||
|
||||
@@ -6,11 +6,30 @@
|
||||
*/
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
const DELIVERY_MARK_CAP = 10_000;
|
||||
|
||||
type DeliveryMark = Readonly<{ resultId: string; at: number; narration?: string }>;
|
||||
|
||||
const delivered = new Map<string, DeliveryMark>();
|
||||
|
||||
function pruneDelivered(now: number): void {
|
||||
for (const [caseId, mark] of delivered) {
|
||||
if (now - mark.at > WINDOW_MS) delivered.delete(caseId);
|
||||
}
|
||||
}
|
||||
|
||||
function makeRoomForDeliveryMark(): void {
|
||||
while (delivered.size >= DELIVERY_MARK_CAP) {
|
||||
const oldest = delivered.keys().next().value;
|
||||
if (oldest === undefined) return;
|
||||
delivered.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
export function deliveryTurnGuardSizeForTests(): number {
|
||||
return delivered.size;
|
||||
}
|
||||
|
||||
export type DeliveryTurnTrigger =
|
||||
| "finalizeSuccessfulTurnExit"
|
||||
| "persistExhaustionGateTurn"
|
||||
@@ -39,8 +58,10 @@ export function markDeliveryTurn(input: {
|
||||
}): void {
|
||||
if (!input.caseId || !input.resultId) return;
|
||||
const now = input.now ?? Date.now();
|
||||
pruneDelivered(now);
|
||||
const narration = input.narration?.replace(/\s+/g, " ").trim() || undefined;
|
||||
const existing = delivered.get(input.caseId);
|
||||
if (!existing) makeRoomForDeliveryMark();
|
||||
if (existing && existing.resultId === input.resultId && now - existing.at <= WINDOW_MS) {
|
||||
if (existing.narration) return;
|
||||
delivered.set(input.caseId, {
|
||||
|
||||
@@ -330,17 +330,27 @@ async function readEngineJsonTimed(
|
||||
const elapsedMs = Date.now() - started;
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const kind: EngineCallFailureKind = response.status === 429 ? "busy" : "http_error";
|
||||
const errorText = data && typeof data === "object"
|
||||
? String((data as Record<string, unknown>).error ?? (data as Record<string, unknown>).message ?? "")
|
||||
: "";
|
||||
const errorCode = data && typeof data === "object"
|
||||
? String((data as Record<string, unknown>).error_code ?? "")
|
||||
: "";
|
||||
const tooManyCandidates = response.status === 400
|
||||
&& (errorCode === "too_many_candidates" || errorText.includes("too_many_candidates"));
|
||||
const kind: EngineCallFailureKind = response.status === 429
|
||||
? "busy"
|
||||
: tooManyCandidates
|
||||
? "bad_payload"
|
||||
: "http_error";
|
||||
logEngineCallFailure({
|
||||
path,
|
||||
kind,
|
||||
elapsedMs,
|
||||
httpStatus: response.status,
|
||||
});
|
||||
const message = data && typeof data === "object"
|
||||
? String((data as Record<string, unknown>).error ?? (data as Record<string, unknown>).message ?? `Jyotish API ${response.status}`)
|
||||
: `Jyotish API ${response.status}`;
|
||||
throw new RectificationEngineError(persistenceCodeForKind(kind), message, {
|
||||
const message = errorText || `Jyotish API ${response.status}`;
|
||||
throw new RectificationEngineError(tooManyCandidates ? "too_many_candidates" : persistenceCodeForKind(kind), message, {
|
||||
kind,
|
||||
httpStatus: response.status,
|
||||
retryAfterSeconds: retryAfterFromHeaders(response.headers) ?? undefined,
|
||||
|
||||
@@ -32,9 +32,36 @@ function defaultStore(): RequestRateLimitStore {
|
||||
return state.jyotishaRequestRateLimits;
|
||||
}
|
||||
|
||||
let seenMaxWindowMs = 0;
|
||||
let consumeCalls = 0;
|
||||
|
||||
export function resetRequestRateLimitMaintenanceForTests(): void {
|
||||
seenMaxWindowMs = 0;
|
||||
consumeCalls = 0;
|
||||
}
|
||||
|
||||
function sweepExpiredRateLimitKeys(
|
||||
store: RequestRateLimitStore,
|
||||
now: number,
|
||||
maxWindowMs: number,
|
||||
): void {
|
||||
for (const [key, stamps] of store) {
|
||||
let newest = Number.NEGATIVE_INFINITY;
|
||||
for (const stamp of stamps) {
|
||||
if (stamp > newest) newest = stamp;
|
||||
}
|
||||
if (!Number.isFinite(newest) || newest <= now - maxWindowMs) store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function consumeRequestRateLimit(input: RequestRateLimitInput): RequestRateLimitDecision {
|
||||
if (input.windowMs > seenMaxWindowMs) seenMaxWindowMs = input.windowMs;
|
||||
consumeCalls += 1;
|
||||
const now = input.now ?? Date.now();
|
||||
const store = input.store ?? defaultStore();
|
||||
if (consumeCalls % 1_000 === 0 || store.size > 5_000) {
|
||||
sweepExpiredRateLimitKeys(store, now, seenMaxWindowMs);
|
||||
}
|
||||
const windowStart = now - input.windowMs;
|
||||
const recent = (store.get(input.key) ?? []).filter((timestamp) => timestamp > windowStart);
|
||||
if (recent.length >= input.limit) {
|
||||
|
||||
@@ -9,12 +9,15 @@ import { getIdentityAuthServices } from "@/modules/identity/auth";
|
||||
import { readIdentitySession } from "@/modules/identity/session";
|
||||
import { getSupabasePublicConfig } from "./config";
|
||||
|
||||
export async function createServerSupabaseClient() {
|
||||
export async function createServerSupabaseClient(options?: {
|
||||
readonly disableCookieCache?: boolean;
|
||||
}) {
|
||||
if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") {
|
||||
const requestHeaders = new Headers(await headers());
|
||||
const session = await readIdentitySession(
|
||||
getIdentityAuthServices().user.api,
|
||||
requestHeaders,
|
||||
options?.disableCookieCache ? { disableCookieCache: true } : undefined,
|
||||
);
|
||||
return createLocalPostgresDataClient(
|
||||
readDatabaseUrl(process.env, "APP_DATABASE_URL"),
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { createHmac } from "node:crypto";
|
||||
import type { Pool } from "pg";
|
||||
import type { BetterAuthOptions } from "better-auth";
|
||||
import { admin, emailOTP, twoFactor, type EmailOTPOptions } from "better-auth/plugins";
|
||||
|
||||
@@ -11,10 +10,19 @@ export type AdminUserAuthorizer = (userId: string) => Promise<boolean>;
|
||||
|
||||
interface BuildAuthOptionsInput {
|
||||
config: SelfHostedIdentityConfig;
|
||||
database: Pool;
|
||||
database: BetterAuthOptions["database"];
|
||||
emailSender: EmailOtpSender;
|
||||
}
|
||||
|
||||
export const IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS = 300;
|
||||
|
||||
/** Server-only. Unset or any value other than "on" keeps today's uncached session reads. */
|
||||
export function identitySessionCookieCacheEnabled(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return env.IDENTITY_SESSION_COOKIE_CACHE === "on";
|
||||
}
|
||||
|
||||
function otpIdempotencyKey(
|
||||
secret: string,
|
||||
email: string,
|
||||
@@ -86,7 +94,15 @@ export function buildAuthOptions({
|
||||
trustedOrigins: [config.userOrigin, config.adminOrigin],
|
||||
telemetry: { enabled: false },
|
||||
user: identityModelMapping.user,
|
||||
session: identityModelMapping.session,
|
||||
session: identitySessionCookieCacheEnabled()
|
||||
? {
|
||||
...identityModelMapping.session,
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS,
|
||||
},
|
||||
}
|
||||
: identityModelMapping.session,
|
||||
account: identityModelMapping.account,
|
||||
verification: identityModelMapping.verification,
|
||||
rateLimit: {
|
||||
|
||||
@@ -18,8 +18,15 @@ export interface IdentityServerSession extends IdentitySession {
|
||||
sessionToken: string;
|
||||
}
|
||||
|
||||
export interface IdentitySessionQuery {
|
||||
readonly disableCookieCache?: boolean;
|
||||
}
|
||||
|
||||
export interface IdentitySessionReader {
|
||||
getSession(input: { headers: Headers }): Promise<RawIdentitySession | null>;
|
||||
getSession(input: {
|
||||
headers: Headers;
|
||||
query?: IdentitySessionQuery;
|
||||
}): Promise<RawIdentitySession | null>;
|
||||
}
|
||||
|
||||
export class IdentityAuthorizationError extends Error {
|
||||
@@ -43,8 +50,12 @@ function parseRoles(role: string | null | undefined): string[] {
|
||||
export async function readIdentityServerSession(
|
||||
reader: IdentitySessionReader,
|
||||
requestHeaders: Headers,
|
||||
options?: IdentitySessionQuery,
|
||||
): Promise<IdentityServerSession | null> {
|
||||
const value = await reader.getSession({ headers: requestHeaders });
|
||||
const value = await reader.getSession({
|
||||
headers: requestHeaders,
|
||||
...(options?.disableCookieCache ? { query: { disableCookieCache: true } } : {}),
|
||||
});
|
||||
if (!value) return null;
|
||||
|
||||
const expiresAt = new Date(value.session.expiresAt);
|
||||
@@ -71,16 +82,18 @@ export async function readIdentityServerSession(
|
||||
export async function readIdentitySession(
|
||||
reader: IdentitySessionReader,
|
||||
requestHeaders: Headers,
|
||||
options?: IdentitySessionQuery,
|
||||
): Promise<IdentitySession | null> {
|
||||
const session = await readIdentityServerSession(reader, requestHeaders);
|
||||
const session = await readIdentityServerSession(reader, requestHeaders, options);
|
||||
return session ? { user: session.user, expiresAt: session.expiresAt } : null;
|
||||
}
|
||||
|
||||
export async function requireIdentityServerSession(
|
||||
reader: IdentitySessionReader,
|
||||
requestHeaders: Headers,
|
||||
options?: IdentitySessionQuery,
|
||||
): Promise<IdentityServerSession> {
|
||||
const session = await readIdentityServerSession(reader, requestHeaders);
|
||||
const session = await readIdentityServerSession(reader, requestHeaders, options);
|
||||
if (!session) throw new IdentityAuthorizationError("Authentication required", 401);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Count local-Postgres adapter roundtrips for a fictional homepage plus consult write.
|
||||
* Run from frontend/: npx tsx tests/bench-local-pg-roundtrips.ts
|
||||
* Prints JSON. Does not print connection strings.
|
||||
*/
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import type { Pool, PoolClient } from "pg";
|
||||
|
||||
import {
|
||||
closeLocalPostgresDataPools,
|
||||
clearLocalPostgresMetadataCacheForTests,
|
||||
createLocalPostgresDataClient,
|
||||
localPostgresPoolForTests,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const userId = "018f4e6d-7a11-7000-8000-0000000000bb";
|
||||
const sessionId = "018f4e6d-7a11-7000-8000-0000000000cc";
|
||||
const chartId = "018f4e6d-7a11-7000-8000-0000000000dd";
|
||||
|
||||
const schemaSql = `
|
||||
create table if not exists public.profiles (
|
||||
id uuid primary key,
|
||||
display_name text
|
||||
);
|
||||
create table if not exists public.chart_profiles (
|
||||
id uuid primary key,
|
||||
user_id uuid not null,
|
||||
label text,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
create table if not exists public.chat_sessions (
|
||||
id uuid primary key,
|
||||
user_id uuid not null,
|
||||
title text,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
grant usage on schema public to authenticated, service_role;
|
||||
grant select, insert, update, delete on public.profiles, public.chart_profiles, public.chat_sessions to authenticated, service_role;
|
||||
insert into public.profiles (id, display_name) values ('${userId}', 'Fictional Person')
|
||||
on conflict (id) do nothing;
|
||||
insert into public.chart_profiles (id, user_id, label) values ('${chartId}', '${userId}', 'home')
|
||||
on conflict (id) do nothing;
|
||||
insert into public.chat_sessions (id, user_id, title) values ('${sessionId}', '${userId}', 'consult')
|
||||
on conflict (id) do nothing;
|
||||
create or replace function public.session_probe()
|
||||
returns table(db_user text)
|
||||
language sql stable
|
||||
as $$ select current_user::text $$;
|
||||
grant execute on function public.session_probe() to service_role, authenticated;
|
||||
`;
|
||||
|
||||
type Mode = { cache: "on" | "off"; merged: "on" | "off" };
|
||||
|
||||
function percentile(values: number[], ratio: number): number {
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(ratio * sorted.length) - 1));
|
||||
return sorted[index] ?? 0;
|
||||
}
|
||||
|
||||
function median(values: number[]): number {
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
return sorted[Math.floor(sorted.length / 2)] ?? 0;
|
||||
}
|
||||
|
||||
const benchCounter = { roundtrips: 0 };
|
||||
const queryWrapped = new WeakSet<object>();
|
||||
|
||||
async function withCountingPools(
|
||||
urls: readonly string[],
|
||||
run: () => Promise<unknown>,
|
||||
): Promise<{ roundtrips: number; elapsedMs: number; payload: string }> {
|
||||
const restores: Array<() => void> = [];
|
||||
benchCounter.roundtrips = 0;
|
||||
for (const url of urls) {
|
||||
const pool = localPostgresPoolForTests(url) as Pool & {
|
||||
connect: () => Promise<PoolClient>;
|
||||
};
|
||||
const original = pool.connect.bind(pool);
|
||||
pool.connect = async () => {
|
||||
const client = await original();
|
||||
if (!queryWrapped.has(client)) {
|
||||
queryWrapped.add(client);
|
||||
const query = client.query.bind(client) as (input: unknown, values?: unknown) => Promise<unknown>;
|
||||
client.query = ((input: unknown, values?: unknown) => {
|
||||
benchCounter.roundtrips += 1;
|
||||
return values === undefined ? query(input) : query(input, values);
|
||||
}) as PoolClient["query"];
|
||||
}
|
||||
return client;
|
||||
};
|
||||
restores.push(() => {
|
||||
pool.connect = original;
|
||||
});
|
||||
}
|
||||
const started = performance.now();
|
||||
try {
|
||||
const payload = await run();
|
||||
return {
|
||||
roundtrips: benchCounter.roundtrips,
|
||||
elapsedMs: performance.now() - started,
|
||||
payload: JSON.stringify(payload),
|
||||
};
|
||||
} finally {
|
||||
for (const restore of restores) restore();
|
||||
}
|
||||
}
|
||||
|
||||
async function sequence(url: string, serviceUrl: string): Promise<unknown[]> {
|
||||
const user = createLocalPostgresDataClient(url, { id: userId, email: "person@example.com" }, "authenticated");
|
||||
const service = createLocalPostgresDataClient(serviceUrl, null, "service_role");
|
||||
const profile = await user.from("profiles").select("id,display_name").eq("id", userId).maybeSingle();
|
||||
const charts = await user.from("chart_profiles").select("id,label").eq("user_id", userId).order("updated_at", { ascending: false }).limit(40);
|
||||
const sessions = await user.from("chat_sessions").select("id,title").eq("user_id", userId).order("updated_at", { ascending: false }).limit(40);
|
||||
const updated = await user.from("chat_sessions").update({ title: "consult-bench" }).eq("id", sessionId);
|
||||
const upsertConflict = await user.from("chat_sessions").upsert(
|
||||
{ id: sessionId, user_id: userId, title: "consult-bench" },
|
||||
{ onConflict: "id" },
|
||||
);
|
||||
const upsertKey = await user.from("chat_sessions").upsert({ id: sessionId, user_id: userId, title: "consult-bench" });
|
||||
const rpc = await service.rpc("session_probe");
|
||||
const serviceRead = await service.from("profiles").select("id").eq("id", userId).maybeSingle();
|
||||
return [profile, charts, sessions, updated, upsertConflict, upsertKey, rpc, serviceRead].map((result) => ({
|
||||
data: result.data ?? null,
|
||||
error: result.error ?? null,
|
||||
count: "count" in result ? result.count ?? null : null,
|
||||
}));
|
||||
}
|
||||
|
||||
async function measure(url: string, serviceUrl: string, mode: Mode): Promise<{
|
||||
roundtrips: number;
|
||||
medianMs: number;
|
||||
p95Ms: number;
|
||||
payload: string;
|
||||
groups: Array<{ roundtrips: number; medianMs: number }>;
|
||||
}> {
|
||||
process.env.JYOTISHA_PG_METADATA_CACHE = mode.cache;
|
||||
process.env.JYOTISHA_PG_MERGED_SESSION_SETUP = mode.merged;
|
||||
clearLocalPostgresMetadataCacheForTests();
|
||||
await sequence(url, serviceUrl);
|
||||
const groupMedians: number[] = [];
|
||||
const groupRoundtrips: number[] = [];
|
||||
const allTimes: number[] = [];
|
||||
let payload = "";
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const group: number[] = [];
|
||||
let roundtrips = 0;
|
||||
for (let repeat = 0; repeat < 50; repeat += 1) {
|
||||
const sample = await withCountingPools([url, serviceUrl], () => sequence(url, serviceUrl));
|
||||
group.push(sample.elapsedMs);
|
||||
allTimes.push(sample.elapsedMs);
|
||||
roundtrips += sample.roundtrips;
|
||||
payload = sample.payload;
|
||||
}
|
||||
groupMedians.push(median(group));
|
||||
groupRoundtrips.push(Math.round(roundtrips / 50));
|
||||
}
|
||||
return {
|
||||
roundtrips: median(groupRoundtrips),
|
||||
medianMs: median(groupMedians),
|
||||
p95Ms: percentile(allTimes, 0.95),
|
||||
payload,
|
||||
groups: groupRoundtrips.map((roundtrips, index) => ({
|
||||
roundtrips,
|
||||
medianMs: Number((groupMedians[index] ?? 0).toFixed(2)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const docker = spawnSync("docker", ["info"], { encoding: "utf8" });
|
||||
if (docker.status !== 0) {
|
||||
console.log(JSON.stringify({ ok: false, reason: "docker-unavailable" }));
|
||||
process.exit(2);
|
||||
}
|
||||
const fixture = startPostgresFixture();
|
||||
const appUrl = fixture.connectionUrl("app_runtime", "app-runtime-test-password");
|
||||
const serviceUrl = fixture.connectionUrl("service_runtime", "service-runtime-test-password");
|
||||
try {
|
||||
fixture.psqlScriptAs("postgres", "postgres-test-password", schemaSql);
|
||||
const modes: Record<string, Mode> = {
|
||||
A: { cache: "off", merged: "off" },
|
||||
B: { cache: "on", merged: "on" },
|
||||
B1: { cache: "on", merged: "off" },
|
||||
B2: { cache: "off", merged: "on" },
|
||||
};
|
||||
const results: Record<string, { roundtrips: number; medianMs: number; p95Ms: number; groups: Array<{ roundtrips: number; medianMs: number }> }> = {};
|
||||
let payloadA = "";
|
||||
let payloadMismatch = false;
|
||||
for (const [name, mode] of Object.entries(modes)) {
|
||||
const measured = await measure(appUrl, serviceUrl, mode);
|
||||
results[name] = {
|
||||
roundtrips: measured.roundtrips,
|
||||
medianMs: Number(measured.medianMs.toFixed(2)),
|
||||
p95Ms: Number(measured.p95Ms.toFixed(2)),
|
||||
groups: measured.groups,
|
||||
};
|
||||
if (name === "A") payloadA = measured.payload;
|
||||
else if (measured.payload !== payloadA) payloadMismatch = true;
|
||||
clearLocalPostgresMetadataCacheForTests();
|
||||
await closeLocalPostgresDataPools();
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
repeats: 3,
|
||||
repsPerGroup: 50,
|
||||
payloadMismatch,
|
||||
results,
|
||||
node: process.version,
|
||||
}));
|
||||
} finally {
|
||||
await closeLocalPostgresDataPools();
|
||||
fixture.stop();
|
||||
delete process.env.JYOTISHA_PG_METADATA_CACHE;
|
||||
delete process.env.JYOTISHA_PG_MERGED_SESSION_SETUP;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : "bench failed";
|
||||
console.log(JSON.stringify({ ok: false, reason: message }));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
closeLocalPostgresDataPools,
|
||||
createLocalPostgresDataClient,
|
||||
localPostgresPoolForTests,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
|
||||
|
||||
const probeSql = `
|
||||
create or replace function public.session_probe()
|
||||
returns table(db_user text, claim_sub text)
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select current_user::text, current_setting('request.jwt.claim.sub', true);
|
||||
$$;
|
||||
grant usage on schema public to authenticated, service_role;
|
||||
grant execute on function public.session_probe() to authenticated, service_role;
|
||||
`;
|
||||
|
||||
test("merged session setup applies the role only inside the transaction", async () => {
|
||||
const previous = {
|
||||
cache: process.env.JYOTISHA_PG_METADATA_CACHE,
|
||||
merged: process.env.JYOTISHA_PG_MERGED_SESSION_SETUP,
|
||||
};
|
||||
process.env.JYOTISHA_PG_METADATA_CACHE = "on";
|
||||
process.env.JYOTISHA_PG_MERGED_SESSION_SETUP = "on";
|
||||
const fixture = startPostgresFixture();
|
||||
const identity = {
|
||||
id: "018f4e6d-7a11-7000-8000-0000000000aa",
|
||||
email: "person@example.com",
|
||||
};
|
||||
try {
|
||||
fixture.psqlScriptAs("postgres", "postgres-test-password", probeSql);
|
||||
const url = fixture.connectionUrl("app_runtime", "app-runtime-test-password");
|
||||
const client = createLocalPostgresDataClient(url, identity, "authenticated");
|
||||
const probe = await client.rpc("session_probe");
|
||||
assert.equal(probe.error, null, probe.error?.message);
|
||||
const row = Array.isArray(probe.data) ? probe.data[0] as { db_user?: string; claim_sub?: string } : null;
|
||||
assert.equal(row?.db_user, "authenticated");
|
||||
assert.equal(row?.claim_sub, identity.id);
|
||||
|
||||
const pool = localPostgresPoolForTests(url);
|
||||
const borrowed = await pool.connect();
|
||||
try {
|
||||
const after = await borrowed.query<{ db_user: string; claim_sub: string | null }>(
|
||||
"select current_user as db_user, current_setting('request.jwt.claim.sub', true) as claim_sub",
|
||||
);
|
||||
assert.equal(after.rows[0]?.db_user, "app_runtime");
|
||||
assert.equal(after.rows[0]?.claim_sub ?? "", "");
|
||||
} finally {
|
||||
borrowed.release();
|
||||
}
|
||||
} finally {
|
||||
await closeLocalPostgresDataPools();
|
||||
fixture.stop();
|
||||
if (previous.cache === undefined) delete process.env.JYOTISHA_PG_METADATA_CACHE;
|
||||
else process.env.JYOTISHA_PG_METADATA_CACHE = previous.cache;
|
||||
if (previous.merged === undefined) delete process.env.JYOTISHA_PG_MERGED_SESSION_SETUP;
|
||||
else process.env.JYOTISHA_PG_MERGED_SESSION_SETUP = previous.merged;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
alreadyDelivered,
|
||||
deliveryCarrierNarration,
|
||||
deliveryTurnGuardSizeForTests,
|
||||
markDeliveryTurn,
|
||||
resetDeliveryTurnGuardForTests,
|
||||
} from "../src/lib/rectification-agentic/v9/delivery-turn-guard.ts";
|
||||
|
||||
test("a delivery mark older than 60 seconds is removed on the next write", () => {
|
||||
resetDeliveryTurnGuardForTests();
|
||||
markDeliveryTurn({
|
||||
caseId: "case-a",
|
||||
resultId: "result-a",
|
||||
now: 0,
|
||||
narration: "已经交付这段说明",
|
||||
});
|
||||
assert.equal(alreadyDelivered({
|
||||
caseId: "case-a",
|
||||
resultId: "result-a",
|
||||
hasUserMessage: false,
|
||||
now: 60_000,
|
||||
}), true);
|
||||
assert.equal(deliveryCarrierNarration({
|
||||
caseId: "case-a",
|
||||
resultId: "result-a",
|
||||
now: 60_000,
|
||||
}), "已经交付这段说明");
|
||||
|
||||
markDeliveryTurn({ caseId: "case-b", resultId: "result-b", now: 60_001 });
|
||||
assert.equal(deliveryTurnGuardSizeForTests(), 1);
|
||||
assert.equal(alreadyDelivered({
|
||||
caseId: "case-a",
|
||||
resultId: "result-a",
|
||||
hasUserMessage: false,
|
||||
now: 60_001,
|
||||
}), false);
|
||||
assert.equal(alreadyDelivered({
|
||||
caseId: "case-b",
|
||||
resultId: "result-b",
|
||||
hasUserMessage: false,
|
||||
now: 70_000,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test("delivery marks stay within the hard cap", () => {
|
||||
resetDeliveryTurnGuardForTests();
|
||||
for (let index = 0; index < 10_001; index += 1) {
|
||||
markDeliveryTurn({ caseId: `case-${index}`, resultId: "result", now: 1_000 });
|
||||
}
|
||||
assert.ok(deliveryTurnGuardSizeForTests() <= 10_000);
|
||||
assert.equal(alreadyDelivered({
|
||||
caseId: "case-10000",
|
||||
resultId: "result",
|
||||
hasUserMessage: false,
|
||||
now: 1_000,
|
||||
}), true);
|
||||
resetDeliveryTurnGuardForTests();
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { memoryAdapter, type MemoryDB } from "../node_modules/better-auth/node_modules/@better-auth/memory-adapter/dist/index.mjs";
|
||||
import type { BetterAuthOptions } from "better-auth";
|
||||
|
||||
import {
|
||||
buildAuthOptions,
|
||||
IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS,
|
||||
} from "../src/modules/identity/auth-factory.ts";
|
||||
import { FakeEmailOtpSender } from "../src/modules/identity/email/fake-email-otp-sender.ts";
|
||||
import type { SelfHostedIdentityConfig } from "../src/modules/identity/config.ts";
|
||||
import {
|
||||
readIdentitySession,
|
||||
requireIdentityServerSession,
|
||||
type IdentitySessionReader,
|
||||
} from "../src/modules/identity/session.ts";
|
||||
|
||||
const config: SelfHostedIdentityConfig = {
|
||||
provider: "self-hosted",
|
||||
databaseUrl: "postgresql://identity_runtime:test-password@postgres:5432/jyotisha",
|
||||
userOrigin: "https://staging.jyotisha.chat",
|
||||
adminOrigin: "https://admin.staging.jyotisha.chat",
|
||||
userSecret: "user-secret-that-is-at-least-32-bytes-long",
|
||||
resendApiKey: "re_test",
|
||||
resendFrom: "Jyotisha <login@staging.jyotisha.chat>",
|
||||
testOtp: null,
|
||||
};
|
||||
|
||||
function withCacheFlag(value: string | undefined, run: () => void): void {
|
||||
const previous = process.env.IDENTITY_SESSION_COOKIE_CACHE;
|
||||
if (value === undefined) delete process.env.IDENTITY_SESSION_COOKIE_CACHE;
|
||||
else process.env.IDENTITY_SESSION_COOKIE_CACHE = value;
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.IDENTITY_SESSION_COOKIE_CACHE;
|
||||
else process.env.IDENTITY_SESSION_COOKIE_CACHE = previous;
|
||||
}
|
||||
}
|
||||
|
||||
test("user auth omits cookie cache unless the server switch is on", () => {
|
||||
withCacheFlag(undefined, () => {
|
||||
const options = buildAuthOptions({
|
||||
config,
|
||||
database: memoryAdapter({}),
|
||||
emailSender: new FakeEmailOtpSender(),
|
||||
});
|
||||
assert.equal(options.advanced?.cookiePrefix, "jyotisha-user");
|
||||
assert.equal(options.session && "cookieCache" in options.session, false);
|
||||
});
|
||||
withCacheFlag("on", () => {
|
||||
const options = buildAuthOptions({
|
||||
config,
|
||||
database: memoryAdapter({}),
|
||||
emailSender: new FakeEmailOtpSender(),
|
||||
});
|
||||
assert.deepEqual(options.session?.cookieCache, {
|
||||
enabled: true,
|
||||
maxAge: IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("sensitive session readers pass disableCookieCache and ordinary readers do not", async () => {
|
||||
const queries: Array<{ disableCookieCache?: boolean }> = [];
|
||||
const reader: IdentitySessionReader = {
|
||||
async getSession(input) {
|
||||
queries.push({ disableCookieCache: input.query?.disableCookieCache });
|
||||
return {
|
||||
session: {
|
||||
id: "session-1",
|
||||
token: "session-token",
|
||||
expiresAt: new Date("2030-01-01T00:00:00.000Z"),
|
||||
},
|
||||
user: {
|
||||
id: "018f4e6d-7a11-7000-8000-000000000001",
|
||||
email: "person@example.com",
|
||||
emailVerified: true,
|
||||
name: "Person",
|
||||
image: null,
|
||||
role: "user",
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
await readIdentitySession(reader, new Headers());
|
||||
await requireIdentityServerSession(reader, new Headers(), { disableCookieCache: true });
|
||||
assert.deepEqual(queries, [
|
||||
{ disableCookieCache: undefined },
|
||||
{ disableCookieCache: true },
|
||||
]);
|
||||
});
|
||||
|
||||
type SetCookieHeaders = Headers & { getSetCookie?: () => string[] };
|
||||
|
||||
function cookieHeaderFrom(response: Response, previous = ""): string {
|
||||
const headers = response.headers as SetCookieHeaders;
|
||||
const setCookies = headers.getSetCookie?.()
|
||||
?? (headers.get("set-cookie")?.split(/,(?=\s*[^;,=\s]+=[^;,]*)/g) ?? []);
|
||||
const fresh = setCookies
|
||||
.map((value) => value.split(";", 1)[0] ?? "")
|
||||
.filter((value) => value.includes("=") && value.slice(value.indexOf("=") + 1).length > 0);
|
||||
const jar = new Map<string, string>();
|
||||
for (const part of previous.split("; ").filter(Boolean)) {
|
||||
jar.set(part.slice(0, part.indexOf("=")), part);
|
||||
}
|
||||
for (const part of fresh) jar.set(part.slice(0, part.indexOf("=")), part);
|
||||
return [...jar.values()].join("; ");
|
||||
}
|
||||
|
||||
function createCacheAuth(enabled: boolean) {
|
||||
const db: MemoryDB = {
|
||||
users: [],
|
||||
sessions: [],
|
||||
accounts: [],
|
||||
verifications: [],
|
||||
two_factors: [],
|
||||
otp_rate_limits: [],
|
||||
};
|
||||
const sessionReads: string[] = [];
|
||||
const factory = memoryAdapter(db);
|
||||
const database = ((options: BetterAuthOptions) => {
|
||||
const adapter = factory(options);
|
||||
return {
|
||||
...adapter,
|
||||
async findOne<T>(data: { model: string }) {
|
||||
if (data.model === "session" || data.model === "sessions") sessionReads.push(data.model);
|
||||
return adapter.findOne<T>(data as never);
|
||||
},
|
||||
};
|
||||
}) as BetterAuthOptions["database"];
|
||||
const previous = process.env.IDENTITY_SESSION_COOKIE_CACHE;
|
||||
process.env.IDENTITY_SESSION_COOKIE_CACHE = enabled ? "on" : "off";
|
||||
try {
|
||||
const built = buildAuthOptions({
|
||||
config,
|
||||
database,
|
||||
emailSender: new FakeEmailOtpSender(),
|
||||
});
|
||||
const auth = betterAuth({
|
||||
...built,
|
||||
emailAndPassword: {
|
||||
...built.emailAndPassword,
|
||||
enabled: true,
|
||||
disableSignUp: false,
|
||||
},
|
||||
});
|
||||
return { auth, db, sessionReads };
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.IDENTITY_SESSION_COOKIE_CACHE;
|
||||
else process.env.IDENTITY_SESSION_COOKIE_CACHE = previous;
|
||||
}
|
||||
}
|
||||
|
||||
type CacheAuth = ReturnType<typeof createCacheAuth>["auth"];
|
||||
|
||||
async function signIn(auth: CacheAuth): Promise<string> {
|
||||
const email = "person@example.com";
|
||||
const password = "long-enough-password";
|
||||
const origin = config.userOrigin;
|
||||
const signedUp = await auth.api.signUpEmail({
|
||||
body: { email, password, name: "Person" },
|
||||
headers: new Headers({ origin }),
|
||||
asResponse: true,
|
||||
});
|
||||
assert.equal(signedUp.status, 200, await signedUp.clone().text());
|
||||
let cookie = cookieHeaderFrom(signedUp);
|
||||
if (!cookie.includes("session_token")) {
|
||||
const signedIn = await auth.api.signInEmail({
|
||||
body: { email, password },
|
||||
headers: new Headers({ origin }),
|
||||
asResponse: true,
|
||||
});
|
||||
assert.equal(signedIn.status, 200, await signedIn.clone().text());
|
||||
cookie = cookieHeaderFrom(signedIn, cookie);
|
||||
}
|
||||
return cookie;
|
||||
}
|
||||
|
||||
async function readSession(
|
||||
auth: CacheAuth,
|
||||
cookie: string,
|
||||
disableCookieCache = false,
|
||||
): Promise<{ cookie: string; status: number; userId: string | null }> {
|
||||
const response = await auth.api.getSession({
|
||||
headers: new Headers({ cookie, origin: config.userOrigin }),
|
||||
query: disableCookieCache ? { disableCookieCache: true } : undefined,
|
||||
asResponse: true,
|
||||
});
|
||||
const body = await response.json() as { user?: { id?: string } } | null;
|
||||
return {
|
||||
cookie: cookieHeaderFrom(response, cookie),
|
||||
status: response.status,
|
||||
userId: body?.user?.id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
test("cookie cache serves three ordinary reads from one database read", async () => {
|
||||
const { auth, db, sessionReads } = createCacheAuth(true);
|
||||
const cookie = await signIn(auth);
|
||||
sessionReads.length = 0;
|
||||
let current = cookie
|
||||
.split("; ")
|
||||
.filter((part) => !part.slice(0, part.indexOf("=")).includes("session_data"))
|
||||
.join("; ");
|
||||
const reads: Array<string | null> = [];
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const result = await readSession(auth, current);
|
||||
current = result.cookie;
|
||||
reads.push(result.userId);
|
||||
}
|
||||
assert.deepEqual(reads, [reads[0], reads[0], reads[0]]);
|
||||
assert.ok(reads[0]);
|
||||
assert.equal(sessionReads.length, 1);
|
||||
const cachedCookie = current;
|
||||
|
||||
sessionReads.length = 0;
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const result = await readSession(auth, cachedCookie, true);
|
||||
assert.ok(result.userId);
|
||||
}
|
||||
assert.equal(sessionReads.length, 3);
|
||||
|
||||
const sessions = db.sessions ?? db.session;
|
||||
assert.ok(Array.isArray(sessions));
|
||||
sessions.length = 0;
|
||||
const sensitive = await readSession(auth, cachedCookie, true);
|
||||
assert.equal(sensitive.userId, null);
|
||||
const ordinary = await readSession(auth, cachedCookie, false);
|
||||
assert.equal(ordinary.userId, reads[0]);
|
||||
|
||||
const realNow = Date.now;
|
||||
const started = realNow();
|
||||
Date.now = () => started + (IDENTITY_SESSION_COOKIE_CACHE_MAX_AGE_SECONDS + 1) * 1000;
|
||||
try {
|
||||
const expired = await readSession(auth, cachedCookie, false);
|
||||
assert.equal(expired.userId, null);
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("cookie cache off reads the database on every ordinary request", async () => {
|
||||
const { auth, sessionReads } = createCacheAuth(false);
|
||||
const cookie = await signIn(auth);
|
||||
sessionReads.length = 0;
|
||||
let current = cookie
|
||||
.split("; ")
|
||||
.filter((part) => !part.slice(0, part.indexOf("=")).includes("session_data"))
|
||||
.join("; ");
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
const result = await readSession(auth, current);
|
||||
assert.ok(result.userId);
|
||||
current = result.cookie;
|
||||
}
|
||||
assert.equal(sessionReads.length, 3);
|
||||
});
|
||||
@@ -0,0 +1,286 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import type { Pool } from "pg";
|
||||
|
||||
import {
|
||||
clearLocalPostgresMetadataCacheForTests,
|
||||
closeLocalPostgresDataPool,
|
||||
createLocalPostgresDataClient,
|
||||
installLocalPostgresPoolForTests,
|
||||
setLocalPostgresMetadataClockForTests,
|
||||
type LocalDatabaseRole,
|
||||
} from "../src/lib/db/local-postgres-client-core.ts";
|
||||
|
||||
type QueryCall = { sql: string; params: readonly unknown[] };
|
||||
|
||||
function tag(sql: string): string {
|
||||
const compact = sql.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
if (compact === "begin") return "begin";
|
||||
if (compact === "commit") return "commit";
|
||||
if (compact === "rollback") return "rollback";
|
||||
if (compact.startsWith("set local role")) return "set local role";
|
||||
if (compact.includes("set_config('role'")) return "set_config(role)";
|
||||
if (compact.includes("set_config('request.jwt.claim.sub'")) return "set_config";
|
||||
if (compact.includes("information_schema.columns")) return "information_schema";
|
||||
if (compact.includes("from pg_index")) return "primary_key";
|
||||
if (compact.includes("from pg_proc")) return "pg_proc";
|
||||
return "business";
|
||||
}
|
||||
|
||||
function installFakePool(
|
||||
url: string,
|
||||
options: {
|
||||
columns?: ReadonlyArray<{ column_name: string; udt_name: string }> | null;
|
||||
primaryKeys?: readonly string[] | null;
|
||||
functions?: ReadonlyArray<Record<string, unknown>> | null;
|
||||
failBusinessCode?: () => string | null;
|
||||
} = {},
|
||||
): QueryCall[] {
|
||||
const calls: QueryCall[] = [];
|
||||
const client = {
|
||||
async query(sql: string, params: readonly unknown[] = []) {
|
||||
calls.push({ sql, params });
|
||||
const label = tag(sql);
|
||||
if (label === "information_schema") {
|
||||
const rows = options.columns === null
|
||||
? []
|
||||
: [...(options.columns ?? [{ column_name: "id", udt_name: "uuid" }])];
|
||||
return { rows, rowCount: rows.length };
|
||||
}
|
||||
if (label === "primary_key") {
|
||||
const names = options.primaryKeys === null ? [] : [...(options.primaryKeys ?? ["id"])];
|
||||
const rows = names.map((column_name) => ({ column_name }));
|
||||
return { rows, rowCount: rows.length };
|
||||
}
|
||||
if (label === "pg_proc") {
|
||||
const rows = options.functions === null
|
||||
? []
|
||||
: [...(options.functions ?? [{
|
||||
proretset: false,
|
||||
return_type: "text",
|
||||
argument_names: ["left_name", "right_name"],
|
||||
argument_types: ["text", "text"],
|
||||
}])];
|
||||
return { rows, rowCount: rows.length };
|
||||
}
|
||||
if (label === "business") {
|
||||
const code = options.failBusinessCode?.() ?? null;
|
||||
if (code) {
|
||||
const error = new Error("business sql failed") as Error & { code: string };
|
||||
error.code = code;
|
||||
throw error;
|
||||
}
|
||||
if (sql.toLowerCase().includes(" as value")) return { rows: [{ value: "ok" }], rowCount: 1 };
|
||||
}
|
||||
return { rows: [], rowCount: 0 };
|
||||
},
|
||||
release() {},
|
||||
};
|
||||
const pool = {
|
||||
async connect() {
|
||||
return client;
|
||||
},
|
||||
async end() {},
|
||||
};
|
||||
installLocalPostgresPoolForTests(url, pool as unknown as Pool);
|
||||
return calls;
|
||||
}
|
||||
|
||||
async function withSwitches(
|
||||
values: Record<string, string | undefined>,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const previous = new Map<string, string | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
clearLocalPostgresMetadataCacheForTests();
|
||||
setLocalPostgresMetadataClockForTests(null);
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
for (const [key, value] of previous) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
clearLocalPostgresMetadataCacheForTests();
|
||||
setLocalPostgresMetadataClockForTests(null);
|
||||
}
|
||||
}
|
||||
|
||||
const identity = { id: "018f4e6d-7a11-7000-8000-0000000000aa", email: "person@example.com" };
|
||||
|
||||
function clientFor(url: string, role: LocalDatabaseRole | null = "authenticated") {
|
||||
return createLocalPostgresDataClient(url, role === "service_role" ? null : identity, role);
|
||||
}
|
||||
|
||||
test("metadata cache and merged session setup stay off the baseline query sequence", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "off",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-off@127.0.0.1:1/jyotisha";
|
||||
const calls = installFakePool(url);
|
||||
const client = clientFor(url);
|
||||
const first = await client.from("profiles").select("id").eq("id", identity.id);
|
||||
assert.equal(first.error, null);
|
||||
assert.deepEqual(calls.map((call) => tag(call.sql)), [
|
||||
"begin",
|
||||
"set local role",
|
||||
"set_config",
|
||||
"information_schema",
|
||||
"business",
|
||||
"commit",
|
||||
]);
|
||||
await client.from("profiles").select("id").eq("id", identity.id);
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 2);
|
||||
});
|
||||
});
|
||||
|
||||
test("cached metadata and merged session setup drop a repeat select and rpc to four roundtrips", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "on",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "on",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-on@127.0.0.1:1/jyotisha";
|
||||
const calls = installFakePool(url);
|
||||
const client = clientFor(url);
|
||||
await client.from("profiles").select("id").eq("id", identity.id);
|
||||
calls.length = 0;
|
||||
await client.from("profiles").select("id").eq("id", identity.id);
|
||||
assert.deepEqual(calls.map((call) => tag(call.sql)), [
|
||||
"begin",
|
||||
"set_config(role)",
|
||||
"business",
|
||||
"commit",
|
||||
]);
|
||||
|
||||
await client.rpc("sample_probe", { left_name: "a", right_name: "b" });
|
||||
calls.length = 0;
|
||||
const rpc = await client.rpc("sample_probe", { left_name: "a", right_name: "b" });
|
||||
assert.equal(rpc.error, null);
|
||||
assert.deepEqual(calls.map((call) => tag(call.sql)), [
|
||||
"begin",
|
||||
"set_config(role)",
|
||||
"business",
|
||||
"commit",
|
||||
]);
|
||||
|
||||
await client.from("profiles").upsert({ id: identity.id });
|
||||
calls.length = 0;
|
||||
await client.from("profiles").upsert({ id: identity.id });
|
||||
assert.deepEqual(calls.map((call) => tag(call.sql)), [
|
||||
"begin",
|
||||
"set_config(role)",
|
||||
"business",
|
||||
"commit",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("column cache is per role and function cache is per argument names", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "on",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "on",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-keys@127.0.0.1:1/jyotisha";
|
||||
const calls = installFakePool(url);
|
||||
const authenticated = clientFor(url, "authenticated");
|
||||
const service = clientFor(url, "service_role");
|
||||
await authenticated.from("profiles").select("id");
|
||||
calls.length = 0;
|
||||
await service.from("profiles").select("id");
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1);
|
||||
|
||||
await authenticated.rpc("sample_probe", { left_name: "a" });
|
||||
calls.length = 0;
|
||||
await authenticated.rpc("sample_probe", { right_name: "b" });
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "pg_proc").length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("undefined column, expiry, pool close, and empty metadata all miss the cache", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "on",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-invalidate@127.0.0.1:1/jyotisha";
|
||||
let failuresLeft = 1;
|
||||
const calls = installFakePool(url, {
|
||||
failBusinessCode: () => {
|
||||
if (failuresLeft <= 0) return null;
|
||||
failuresLeft -= 1;
|
||||
return "42703";
|
||||
},
|
||||
});
|
||||
const client = clientFor(url);
|
||||
const failed = await client.from("profiles").select("id");
|
||||
assert.equal(failed.error?.code, "42703");
|
||||
calls.length = 0;
|
||||
const retried = await client.from("profiles").select("id");
|
||||
assert.equal(retried.error, null);
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("metadata older than 300 seconds is queried again", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "on",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-ttl@127.0.0.1:1/jyotisha";
|
||||
let now = 10_000;
|
||||
setLocalPostgresMetadataClockForTests(() => now);
|
||||
const calls = installFakePool(url);
|
||||
const client = clientFor(url);
|
||||
await client.from("profiles").select("id");
|
||||
calls.length = 0;
|
||||
await client.from("profiles").select("id");
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 0);
|
||||
now += 300_000;
|
||||
calls.length = 0;
|
||||
await client.from("profiles").select("id");
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("an empty column list and a missing function are not cached", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "on",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-empty@127.0.0.1:1/jyotisha";
|
||||
const calls = installFakePool(url, { columns: null, functions: null, primaryKeys: null });
|
||||
const client = clientFor(url);
|
||||
await client.from("profiles").select("id");
|
||||
calls.length = 0;
|
||||
await client.from("profiles").select("id");
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "information_schema").length, 1);
|
||||
|
||||
const missing = await client.rpc("missing_probe", { left_name: "a" });
|
||||
assert.match(missing.error?.message ?? "", /not found/);
|
||||
calls.length = 0;
|
||||
await client.rpc("missing_probe", { left_name: "a" });
|
||||
assert.equal(calls.filter((call) => tag(call.sql) === "pg_proc").length, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("closing the pool drops that url from the metadata cache", async () => {
|
||||
await withSwitches({
|
||||
JYOTISHA_PG_METADATA_CACHE: "on",
|
||||
JYOTISHA_PG_MERGED_SESSION_SETUP: "off",
|
||||
}, async () => {
|
||||
const url = "postgresql://roundtrip-close@127.0.0.1:1/jyotisha";
|
||||
const calls = installFakePool(url);
|
||||
const client = clientFor(url);
|
||||
await client.from("profiles").select("id");
|
||||
await closeLocalPostgresDataPool(url);
|
||||
const reopenedCalls = installFakePool(url);
|
||||
const reopened = clientFor(url);
|
||||
await reopened.from("profiles").select("id");
|
||||
assert.equal(reopenedCalls.filter((call) => tag(call.sql) === "information_schema").length, 1);
|
||||
assert.ok(calls.length > 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { getEventListeners } from "node:events";
|
||||
import test from "node:test";
|
||||
|
||||
import { scoringPollDelay } from "../src/lib/birth-time-guided-polling.ts";
|
||||
import { runPersonalReportWorkerLoop } from "../src/lib/personal-report-worker-core.ts";
|
||||
|
||||
const idle = {
|
||||
recovery: { retryingCount: 0, failedCount: 0 },
|
||||
outcome: "idle" as const,
|
||||
jobId: null,
|
||||
};
|
||||
|
||||
function keepAlive(): () => void {
|
||||
const timer = setInterval(() => {}, 1_000);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
|
||||
test("idle report-worker sleeps do not leave abort listeners", async () => {
|
||||
const stop = keepAlive();
|
||||
const controller = new AbortController();
|
||||
let ticks = 0;
|
||||
let listenersAt200 = -1;
|
||||
let abortedAt = 0;
|
||||
try {
|
||||
await runPersonalReportWorkerLoop(
|
||||
{
|
||||
async tick() {
|
||||
ticks += 1;
|
||||
if (ticks === 200) {
|
||||
listenersAt200 = getEventListeners(controller.signal, "abort").length;
|
||||
abortedAt = Date.now();
|
||||
controller.abort();
|
||||
}
|
||||
return idle;
|
||||
},
|
||||
},
|
||||
{ pollIntervalMs: 1, signal: controller.signal },
|
||||
);
|
||||
assert.equal(listenersAt200, 0);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
assert.ok(Date.now() - abortedAt < 250);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("thrown report-worker ticks do not leave abort listeners", async () => {
|
||||
const stop = keepAlive();
|
||||
const controller = new AbortController();
|
||||
let ticks = 0;
|
||||
let listenersAt200 = -1;
|
||||
let abortedAt = 0;
|
||||
try {
|
||||
await runPersonalReportWorkerLoop(
|
||||
{
|
||||
async tick() {
|
||||
ticks += 1;
|
||||
if (ticks === 200) {
|
||||
listenersAt200 = getEventListeners(controller.signal, "abort").length;
|
||||
abortedAt = Date.now();
|
||||
controller.abort();
|
||||
}
|
||||
throw new Error("tick failed");
|
||||
},
|
||||
},
|
||||
{ pollIntervalMs: 1, signal: controller.signal },
|
||||
);
|
||||
assert.equal(listenersAt200, 0);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
assert.ok(Date.now() - abortedAt < 250);
|
||||
} finally {
|
||||
stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("scoring poll delay removes its abort listener when the timer fires", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
};
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
await scoringPollDelay(0, controller.signal);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
} finally {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
}
|
||||
});
|
||||
|
||||
test("scoring poll delay resolves as soon as the signal aborts", async () => {
|
||||
const previousWindow = (globalThis as { window?: unknown }).window;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
setTimeout: globalThis.setTimeout.bind(globalThis),
|
||||
clearTimeout: globalThis.clearTimeout.bind(globalThis),
|
||||
};
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const started = Date.now();
|
||||
const pending = scoringPollDelay(0, controller.signal);
|
||||
controller.abort();
|
||||
await pending;
|
||||
assert.ok(Date.now() - started < 200);
|
||||
assert.equal(getEventListeners(controller.signal, "abort").length, 0);
|
||||
} finally {
|
||||
(globalThis as { window?: unknown }).window = previousWindow;
|
||||
}
|
||||
});
|
||||
@@ -105,8 +105,11 @@ const diagnosticReport = JSON.parse(readFileSync(
|
||||
// 原值(8):docs/research/sealed_holdout_rerun_upstream_sync6_2026_10_07.json
|
||||
// 新值(8):docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.json
|
||||
// 原因(8):章动修正与 scoring-12 改了冻结身份(ERR-110,BUG-1275)。影子重跑不是发布指标,确认门行为与阈值不变。
|
||||
// 原值(9):docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.json
|
||||
// 新值(9):docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.json
|
||||
// 原因(9):BUG-1323~1325 改了冻结身份文件,输出不变,ERR-110 另存新记录。确认门行为与阈值不变。
|
||||
const currentTreeRerun = JSON.parse(readFileSync(
|
||||
new URL("../../docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.json", import.meta.url),
|
||||
new URL("../../docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.json", import.meta.url),
|
||||
"utf8",
|
||||
)) as {
|
||||
frozen_record: { implementation_sha256: string };
|
||||
@@ -252,9 +255,10 @@ test("sealed holdout aggregates match the v3 report and still block confirmation
|
||||
// 原值(5):…upstream_sync5_2026_10_02.json / 新值(5):…astrologer_rulings_batch1_2026_10_03.json / 原因(5):TASK-astrologer-rulings-batch1 重新冻结(同上)。
|
||||
// 原值(6):…astrologer_rulings_batch1_2026_10_03.json / 新值(6):…upstream_sync6_2026_10_07.json / 原因(6):sync6 的 D5/D11 与 scoring-11 重新冻结(ERR-110,BUG-1260)。
|
||||
// 原值(7):…upstream_sync6_2026_10_07.json / 新值(7):…ephemeris_nutation_2026_10_08.json / 原因(7):章动修正与 scoring-12 重新冻结(ERR-110,BUG-1275)。
|
||||
// 原值(8):…ephemeris_nutation_2026_10_08.json / 新值(8):…scoring_perf_2026_10_13.json / 原因(8):BUG-1323~1325 改了冻结身份文件,输出不变,ERR-110 另存新记录。
|
||||
assert.equal(
|
||||
productHoldout.current_tree_scorer.source_report,
|
||||
"docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.json",
|
||||
"docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.json",
|
||||
);
|
||||
assert.equal(currentTreeRerun.official_valid_independent_blind, false);
|
||||
assert.equal(productHoldout.current_tree_scorer.matches_metrics_scorer, false);
|
||||
|
||||
@@ -712,3 +712,30 @@ test("busy retries that still fail keep the busy kind", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("400 too_many_candidates is a bad request and is not retried", async () => {
|
||||
const waits: number[] = [];
|
||||
setEngineBusyWaitForTests(async (ms) => {
|
||||
waits.push(ms);
|
||||
});
|
||||
const stub = stubEngineSequence([{
|
||||
status: 400,
|
||||
body: { error: "too_many_candidates: limit=241 count=1440", error_code: "ERR_BAD_REQUEST" },
|
||||
}]);
|
||||
try {
|
||||
const result = await captureWarnings(() => runV9CandidateScore(SCORE_INPUT));
|
||||
assert.equal(result.result.status, "rejected");
|
||||
if (result.result.status === "rejected") {
|
||||
const error = result.result.reason;
|
||||
assert.ok(error instanceof RectificationEngineError);
|
||||
assert.equal(error.kind, "bad_payload");
|
||||
assert.equal(error.code, "too_many_candidates");
|
||||
assert.equal(error.httpStatus, 400);
|
||||
}
|
||||
assert.equal(waits.length, 0);
|
||||
assert.equal(stub.calls(), 1);
|
||||
} finally {
|
||||
stub.restore();
|
||||
setEngineBusyWaitForTests(null);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { consumeRequestRateLimit } from "../src/lib/request-rate-limit.ts";
|
||||
import {
|
||||
consumeRequestRateLimit,
|
||||
resetRequestRateLimitMaintenanceForTests,
|
||||
} from "../src/lib/request-rate-limit.ts";
|
||||
|
||||
test("a user is allowed up to the window limit and then must wait", () => {
|
||||
const store = new Map<string, number[]>();
|
||||
@@ -46,3 +49,64 @@ test("a user is allowed up to the window limit and then must wait", () => {
|
||||
assert.deepEqual(otherUser, { ok: true });
|
||||
assert.deepEqual(afterWindow, { ok: true });
|
||||
});
|
||||
|
||||
test("keys past the longest seen window are swept, and a one-hour key survives 61 seconds", () => {
|
||||
resetRequestRateLimitMaintenanceForTests();
|
||||
const shortWindow = new Map<string, number[]>();
|
||||
consumeRequestRateLimit({
|
||||
key: "old",
|
||||
limit: 5,
|
||||
windowMs: 60_000,
|
||||
now: 0,
|
||||
store: shortWindow,
|
||||
});
|
||||
for (let index = 0; index < 998; index += 1) {
|
||||
consumeRequestRateLimit({
|
||||
key: `pad:${index}`,
|
||||
limit: 5,
|
||||
windowMs: 60_000,
|
||||
now: 0,
|
||||
store: shortWindow,
|
||||
});
|
||||
}
|
||||
consumeRequestRateLimit({
|
||||
key: "trigger",
|
||||
limit: 5,
|
||||
windowMs: 60_000,
|
||||
now: 61_000,
|
||||
store: shortWindow,
|
||||
});
|
||||
assert.equal(shortWindow.has("old"), false);
|
||||
assert.equal(shortWindow.get("old"), undefined);
|
||||
assert.equal(shortWindow.has("trigger"), true);
|
||||
assert.equal(shortWindow.size, 1);
|
||||
|
||||
resetRequestRateLimitMaintenanceForTests();
|
||||
const mixed = new Map<string, number[]>();
|
||||
consumeRequestRateLimit({
|
||||
key: "synastry:person",
|
||||
limit: 8,
|
||||
windowMs: 60 * 60_000,
|
||||
now: 0,
|
||||
store: mixed,
|
||||
});
|
||||
consumeRequestRateLimit({
|
||||
key: "ephemeris:person",
|
||||
limit: 30,
|
||||
windowMs: 60_000,
|
||||
now: 0,
|
||||
store: mixed,
|
||||
});
|
||||
for (let index = 0; index < 998; index += 1) {
|
||||
consumeRequestRateLimit({
|
||||
key: `later:${index}`,
|
||||
limit: 5,
|
||||
windowMs: 60_000,
|
||||
now: 61_000,
|
||||
store: mixed,
|
||||
});
|
||||
}
|
||||
assert.equal(mixed.has("synastry:person"), true);
|
||||
assert.equal(mixed.has("ephemeris:person"), true);
|
||||
resetRequestRateLimitMaintenanceForTests();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
noteRescoreAttemptForTests,
|
||||
rescoreAttemptCountForTests,
|
||||
resetStaleMinuteRescoreAttemptsForTests,
|
||||
} from "../src/lib/rectification-agentic/v9/block-scan-answer.ts";
|
||||
|
||||
const SIX_HOURS_MS = 6 * 60 * 60 * 1000;
|
||||
|
||||
test("rescore attempts stay within 5000 entries", () => {
|
||||
resetStaleMinuteRescoreAttemptsForTests();
|
||||
for (let index = 0; index < 5_001; index += 1) {
|
||||
noteRescoreAttemptForTests(`case-${index}:turn:fingerprint`, 1_000);
|
||||
}
|
||||
assert.ok(rescoreAttemptCountForTests() <= 5_000);
|
||||
resetStaleMinuteRescoreAttemptsForTests();
|
||||
});
|
||||
|
||||
test("a rescore attempt older than six hours is dropped on the next write", () => {
|
||||
resetStaleMinuteRescoreAttemptsForTests();
|
||||
noteRescoreAttemptForTests("case-old:turn:fingerprint", 0);
|
||||
noteRescoreAttemptForTests("case-fresh:turn:fingerprint", SIX_HOURS_MS);
|
||||
assert.equal(rescoreAttemptCountForTests(), 2);
|
||||
noteRescoreAttemptForTests("case-new:turn:fingerprint", SIX_HOURS_MS + 1);
|
||||
assert.equal(rescoreAttemptCountForTests(), 2);
|
||||
noteRescoreAttemptForTests("case-fresh:turn:fingerprint", SIX_HOURS_MS + 1);
|
||||
assert.equal(rescoreAttemptCountForTests(), 2);
|
||||
resetStaleMinuteRescoreAttemptsForTests();
|
||||
});
|
||||
@@ -9,7 +9,7 @@
|
||||
"dataset_path": "references/real_case_calibration/minute_rectification_holdout_v3.json",
|
||||
"report_path": "references/real_case_calibration/minute_rectification_holdout_v3_report.json",
|
||||
"source_audit_status": "corrected_known_date_errors",
|
||||
"evaluated_on": "2026-10-08",
|
||||
"evaluated_on": "2026-10-13",
|
||||
"evaluated_on_scope": "latest_fixed_protocol_rerun_not_historical_runtime_metrics",
|
||||
"historical_source_audit_status": "invalidated_after_replay",
|
||||
"report_status": "invalidated_after_source_audit",
|
||||
@@ -24,7 +24,7 @@
|
||||
"evaluated_on": "2026-07-21"
|
||||
},
|
||||
"current_tree_scorer": {
|
||||
"implementation_sha256": "511158c8387658d51f6055bd7afd15730b8b25314510d48f52498ed737c824d7",
|
||||
"implementation_sha256": "1c146bf5273edf871d885e9e2e68ee1c8a2f71759b54b4b878f63e771f44197c",
|
||||
"extended_identity": {
|
||||
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
|
||||
"production_scoring_files": [
|
||||
@@ -52,7 +52,7 @@
|
||||
"scripts/shadbala.py",
|
||||
"scripts/varga.py"
|
||||
],
|
||||
"production_scoring_sha256": "b72dbd61feec556c3863cb1b4496ded677c7077324a71b86e31722250a8d1528",
|
||||
"production_scoring_sha256": "89c146df190e8902731ea8111d1aff490fede00c919f529c7cf4c3e629a75579",
|
||||
"research_files": [
|
||||
"scripts/research/reported_offset_sweep.py",
|
||||
"scripts/research/cluster_width_lib.py",
|
||||
@@ -62,9 +62,9 @@
|
||||
"scripts/minute_rectification_fact_blind_eval_v4.py",
|
||||
"scripts/minute_rectification_holdout_validator.py"
|
||||
],
|
||||
"research_implementation_sha256": "12dc4049d8feeaf9e8f7df54666e7de7e8de6b45f195f52999bdbd52217908d9",
|
||||
"research_implementation_sha256": "085ca37ab70e1da18e6941161868618b22c9c9f09c043d0195de7f5650363453",
|
||||
"file_sha256": {
|
||||
"scripts/active_rectification_event_engine.py": "cdc0e68edc770a2a1810fbdb68cbfd46dcd50b267c739c496fcc078c5e26b5b4",
|
||||
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
|
||||
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
|
||||
"scripts/ashtakavarga.py": "9bfc0b8a06af6122fa6c3881706e64f28b9ba58b53d98ff55957d4741d820f3f",
|
||||
"scripts/dasha_analyzer.py": "5e53b1b4d4c7414c08dd0e7716f120a63c70ec3428ec93547f64d55c580da0e1",
|
||||
@@ -78,20 +78,20 @@
|
||||
"scripts/minute_rectification_feature_facts_v4.py": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
|
||||
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
|
||||
"scripts/narayana_dasha.py": "c7aaefd4b7b7ea98a5b94e0f7798e5d0139c7423d10ce4ebfff258a460d4456f",
|
||||
"scripts/rectification/api_service.py": "e316ced0f3796d0378a0fe1aad950ee25922ae8afd53add578ed59ca8b2f0f79",
|
||||
"scripts/rectification/api_service.py": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
|
||||
"scripts/rectification/candidate_contrast.py": "eee7da71c0574a08876404b78ca111153fa95fb83ace82b9fdca141d1992e273",
|
||||
"scripts/rectification/candidate_window.py": "2fbc5058b12049b15fca375cdc125b8283e19f9f3e4bedcc291e344501891bef",
|
||||
"scripts/rectification/case_holdout.py": "fe0e699b617d9e216acdfeff87af4221c1d6f4c101bf33e1dbf14e2bcd75a8b5",
|
||||
"scripts/rectification/contracts.py": "c6dc6bd230e38f9f2126c224aae9ddbd6415304c15340f8c7cd3f06f6ef3160f",
|
||||
"scripts/rectification/dasha_transition_proximity.py": "0afba494aa12899621173c3cc941c6c1d0be9cec0bdf4c4a1c9bcd004ab22de8",
|
||||
"scripts/rectification/dasha_transition_proximity.py": "5ce4ed27b0f06c681abc34e5fd998fafb10c153a0070aa36c99ddfa869a2cbf2",
|
||||
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
|
||||
"scripts/rectification/event_probes.py": "9bbadc246e6b8e94bb047522c3e5645613f508b3473ef40b38a207c77fdad58c",
|
||||
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
|
||||
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
|
||||
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
|
||||
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
|
||||
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
|
||||
"scripts/research/reported_offset_sweep.py": "9df602c938f87a7b540e97c6c31a263f07b4ed0ccaeb1d2fac39b0d3d27ba0ed",
|
||||
"scripts/research/sealed_holdout_rerun.py": "a7f8a1c2230e33441e335810f13cfd723f3f8ad5b03a3657cb606e29d8559f19",
|
||||
"scripts/research/reported_offset_sweep.py": "bdcaac92ece761dbcbd35d5a8f751e6b4b11f8475ef8152a5ea4d36ceb38356a",
|
||||
"scripts/research/sealed_holdout_rerun.py": "27904b39a64ff5f1b95ae3c8fc65ca0f3a8b4d8e3f4b88fbbf5d85ffeb53db0d",
|
||||
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
|
||||
"scripts/varga.py": "c1f06967c3d4d214edadff139c24aa31e59fb628477174235abe14f799a99076"
|
||||
},
|
||||
@@ -100,7 +100,7 @@
|
||||
"matches_metrics_scorer": false,
|
||||
"official_eval_implementation_hash_matches": false,
|
||||
"official_eval_trial_count": 0,
|
||||
"source_report": "docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.json",
|
||||
"source_report": "docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.json",
|
||||
"fixed_protocol_rerun_trial_count": 20,
|
||||
"fixed_protocol_rerun_hash_matches": true,
|
||||
"metrics": {
|
||||
@@ -113,10 +113,10 @@
|
||||
}
|
||||
},
|
||||
"current_tree_fixed_protocol_rerun": {
|
||||
"evaluated_on": "2026-10-08",
|
||||
"report_path": "docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.json",
|
||||
"freeze_record_path": "docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.freeze.json",
|
||||
"implementation_sha256": "511158c8387658d51f6055bd7afd15730b8b25314510d48f52498ed737c824d7",
|
||||
"evaluated_on": "2026-10-13",
|
||||
"report_path": "docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.json",
|
||||
"freeze_record_path": "docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.freeze.json",
|
||||
"implementation_sha256": "1c146bf5273edf871d885e9e2e68ee1c8a2f71759b54b4b878f63e771f44197c",
|
||||
"extended_identity": {
|
||||
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
|
||||
"production_scoring_files": [
|
||||
@@ -144,7 +144,7 @@
|
||||
"scripts/shadbala.py",
|
||||
"scripts/varga.py"
|
||||
],
|
||||
"production_scoring_sha256": "b72dbd61feec556c3863cb1b4496ded677c7077324a71b86e31722250a8d1528",
|
||||
"production_scoring_sha256": "89c146df190e8902731ea8111d1aff490fede00c919f529c7cf4c3e629a75579",
|
||||
"research_files": [
|
||||
"scripts/research/reported_offset_sweep.py",
|
||||
"scripts/research/cluster_width_lib.py",
|
||||
@@ -154,9 +154,9 @@
|
||||
"scripts/minute_rectification_fact_blind_eval_v4.py",
|
||||
"scripts/minute_rectification_holdout_validator.py"
|
||||
],
|
||||
"research_implementation_sha256": "12dc4049d8feeaf9e8f7df54666e7de7e8de6b45f195f52999bdbd52217908d9",
|
||||
"research_implementation_sha256": "085ca37ab70e1da18e6941161868618b22c9c9f09c043d0195de7f5650363453",
|
||||
"file_sha256": {
|
||||
"scripts/active_rectification_event_engine.py": "cdc0e68edc770a2a1810fbdb68cbfd46dcd50b267c739c496fcc078c5e26b5b4",
|
||||
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
|
||||
"scripts/active_rectification_events.py": "03e333ccf1103caf685dfe50fe890ab1793e945037085f50ce834a4a3b232222",
|
||||
"scripts/ashtakavarga.py": "9bfc0b8a06af6122fa6c3881706e64f28b9ba58b53d98ff55957d4741d820f3f",
|
||||
"scripts/dasha_analyzer.py": "5e53b1b4d4c7414c08dd0e7716f120a63c70ec3428ec93547f64d55c580da0e1",
|
||||
@@ -170,20 +170,20 @@
|
||||
"scripts/minute_rectification_feature_facts_v4.py": "3d899a2cdd4825b8e32062443d487edcd2173a4ea55a36eb9f3abe587c0faf0f",
|
||||
"scripts/minute_rectification_holdout_validator.py": "b60b1c752e58672beaee8b36eb3b2348169726cda070ec969db0f13e7ed715af",
|
||||
"scripts/narayana_dasha.py": "c7aaefd4b7b7ea98a5b94e0f7798e5d0139c7423d10ce4ebfff258a460d4456f",
|
||||
"scripts/rectification/api_service.py": "e316ced0f3796d0378a0fe1aad950ee25922ae8afd53add578ed59ca8b2f0f79",
|
||||
"scripts/rectification/api_service.py": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
|
||||
"scripts/rectification/candidate_contrast.py": "eee7da71c0574a08876404b78ca111153fa95fb83ace82b9fdca141d1992e273",
|
||||
"scripts/rectification/candidate_window.py": "2fbc5058b12049b15fca375cdc125b8283e19f9f3e4bedcc291e344501891bef",
|
||||
"scripts/rectification/case_holdout.py": "fe0e699b617d9e216acdfeff87af4221c1d6f4c101bf33e1dbf14e2bcd75a8b5",
|
||||
"scripts/rectification/contracts.py": "c6dc6bd230e38f9f2126c224aae9ddbd6415304c15340f8c7cd3f06f6ef3160f",
|
||||
"scripts/rectification/dasha_transition_proximity.py": "0afba494aa12899621173c3cc941c6c1d0be9cec0bdf4c4a1c9bcd004ab22de8",
|
||||
"scripts/rectification/dasha_transition_proximity.py": "5ce4ed27b0f06c681abc34e5fd998fafb10c153a0070aa36c99ddfa869a2cbf2",
|
||||
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
|
||||
"scripts/rectification/event_probes.py": "9bbadc246e6b8e94bb047522c3e5645613f508b3473ef40b38a207c77fdad58c",
|
||||
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
|
||||
"scripts/rectification/refinement_packet.py": "dbeea5aa780889785ce850f3881e8b7f67bb44594e9bc382c65b2e730884a2f4",
|
||||
"scripts/rectification/scoring_service.py": "fa056fa30d9e6ffa4b75295fc49c793e35b26ebeca64ca843d26c8abef3050f2",
|
||||
"scripts/research/cluster_width_lib.py": "1d8f2ffa7795d62069deebb2de8e16749317037d4ed3f24a2c0092676e5cfcc6",
|
||||
"scripts/research/probe_supply_after_six.py": "098a5b4398d6f8d996917d809c2fc922ad64031d3b70660f8ef7c876aba92438",
|
||||
"scripts/research/reported_offset_sweep.py": "9df602c938f87a7b540e97c6c31a263f07b4ed0ccaeb1d2fac39b0d3d27ba0ed",
|
||||
"scripts/research/sealed_holdout_rerun.py": "a7f8a1c2230e33441e335810f13cfd723f3f8ad5b03a3657cb606e29d8559f19",
|
||||
"scripts/research/reported_offset_sweep.py": "bdcaac92ece761dbcbd35d5a8f751e6b4b11f8475ef8152a5ea4d36ceb38356a",
|
||||
"scripts/research/sealed_holdout_rerun.py": "27904b39a64ff5f1b95ae3c8fc65ca0f3a8b4d8e3f4b88fbbf5d85ffeb53db0d",
|
||||
"scripts/shadbala.py": "334dc9d8e4292f0c95d40fd4c29285aa89b9e49d4f187fb9498f4bb0ad93bdfc",
|
||||
"scripts/varga.py": "c1f06967c3d4d214edadff139c24aa31e59fb628477174235abe14f799a99076"
|
||||
},
|
||||
@@ -201,15 +201,15 @@
|
||||
"verified_minute_claim_allowed": false,
|
||||
"metric_gates_passed": false,
|
||||
"events_per_case": 3,
|
||||
"boundary": "Previously exposed v3 shadow fixed-protocol rerun after the 2026-10-08 nutation fix (ERR-110, BUG-1275). Sidereal longitudes now include nutation and the scoring identity is scoring-12. Versus the 2026-09-20 historical report, changed_trial_count is 8. Not a release metric. Prior sync6 report and freeze preserved byte-for-byte.",
|
||||
"report_sha256": "9dec7743b12b5c25534096553a227809d285c1e4e32f6b10a58d80887a264e85",
|
||||
"freeze_record_sha256": "ed6c87e252a2a4d3212f7fb32d292623c0ddb48653e0550e7e5496441af274ef",
|
||||
"replay_started_at_utc": "2026-10-08T10:23:40.470851+00:00",
|
||||
"replay_finished_at_utc": "2026-10-08T10:23:44.334924+00:00",
|
||||
"boundary": "Previously exposed v3 shadow fixed-protocol rerun after the scoring speedup (ERR-110, BUG-1323 to BUG-1325 and BUG-1345). Trials and metrics match the 2026-10-08 nutation report. Versus the 2026-09-20 historical report, changed_trial_count stays 8. Not a release metric. Prior nutation report and freeze preserved byte-for-byte.",
|
||||
"report_sha256": "25e2e59ecab81b528f0f27369a8638a907ea551f57fdd8572db00fe25a139641",
|
||||
"freeze_record_sha256": "1ce7f7ac67221e10bb4d176f5e8af9299fbae7586717d83048b7e3c124e43e7c",
|
||||
"replay_started_at_utc": "2026-10-13T17:45:00.642234+00:00",
|
||||
"replay_finished_at_utc": "2026-10-13T17:45:04.577716+00:00",
|
||||
"previous_fixed_protocol_rerun": {
|
||||
"report_path": "docs/research/sealed_holdout_rerun_upstream_sync6_2026_10_07.json",
|
||||
"freeze_record_path": "docs/research/sealed_holdout_rerun_upstream_sync6_2026_10_07.freeze.json",
|
||||
"superseded_reason": "Ephemeris nutation (ERR-110, BUG-1275). Prior sync6 report and freeze preserved byte-for-byte."
|
||||
"report_path": "docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.json",
|
||||
"freeze_record_path": "docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.freeze.json",
|
||||
"superseded_reason": "BUG-1323 to BUG-1325 changed frozen identity files. Output is unchanged, so ERR-110 stored a new record. Prior nutation report and freeze preserved byte-for-byte."
|
||||
}
|
||||
},
|
||||
"previous_fixed_protocol_rerun": {
|
||||
@@ -330,25 +330,25 @@
|
||||
"verified_minute_claim_allowed": false
|
||||
},
|
||||
"current_tree_reported_offset_replay": {
|
||||
"report_path": "docs/research/reported_offset_ephemeris_nutation_2026_10_08.json",
|
||||
"report_sha256": "b433a906280a5b12e4e48fd5e86292b73d21a627f490e3083d719aceea3252c9",
|
||||
"freeze_record_path": "docs/research/reported_offset_ephemeris_nutation_2026_10_08.freeze.json",
|
||||
"freeze_record_sha256": "bc43921250cdcc160fb51d88ebcae0262d180e18c0ab2b14791605b09d639d39",
|
||||
"report_path": "docs/research/reported_offset_scoring_perf_2026_10_13.json",
|
||||
"report_sha256": "9466cf3945a63621104157fb34291cb23205ddb045effa7e70f3c35647516443",
|
||||
"freeze_record_path": "docs/research/reported_offset_scoring_perf_2026_10_13.freeze.json",
|
||||
"freeze_record_sha256": "1560f24ffea19116c904272907ff9cce24904960406676f3aada3c8c174cf98a",
|
||||
"scope": "reported_offset_sensitivity_not_product_accuracy",
|
||||
"trial_count": 900,
|
||||
"official_valid_independent_blind": false,
|
||||
"official_blind_trial_count": 0,
|
||||
"must_not_use_for_tuning": true,
|
||||
"must_not_claim_as_release_metrics": true,
|
||||
"replay_started_at_utc": "2026-10-08T10:23:40.483622+00:00",
|
||||
"replay_finished_at_utc": "2026-10-08T10:38:50.744210+00:00",
|
||||
"boundary": "Previously exposed reported-offset sensitivity after the 2026-10-08 nutation fix (ERR-110, BUG-1275). Versus the 2026-09-20 historical report, changed_trial_count is 581 of 900. Not a release metric. No tuning. Prior sync6 report and freeze preserved byte-for-byte.",
|
||||
"replay_started_at_utc": "2026-10-13T17:45:36.012964+00:00",
|
||||
"replay_finished_at_utc": "2026-10-13T18:02:16.333181+00:00",
|
||||
"boundary": "Previously exposed reported-offset sensitivity after the scoring speedup (ERR-110, BUG-1323 to BUG-1325 and BUG-1345). Summary and trials match the 2026-10-08 nutation report. Versus the 2026-09-20 historical report, changed_trial_count stays 581 of 900. Not a release metric. No tuning. Prior nutation report and freeze preserved byte-for-byte.",
|
||||
"previous_reported_offset_replay": {
|
||||
"report_path": "docs/research/reported_offset_upstream_sync6_2026_10_07.json",
|
||||
"report_sha256": "1b9c9c18b6820a2beca8d7ddef20b6fa0778c5d43dd987c0c214e2f9ff00660f",
|
||||
"freeze_record_path": "docs/research/reported_offset_upstream_sync6_2026_10_07.freeze.json",
|
||||
"freeze_record_sha256": "65c7ab537b3fecc6d3e175bb2ab702b5630fe41bcff40bd551917cc32977b366",
|
||||
"superseded_reason": "Ephemeris nutation (ERR-110, BUG-1275). Prior sync6 report and freeze preserved byte-for-byte. Versus the 2026-09-20 historical report, changed_trial_count is 581 of 900. Not used for tuning."
|
||||
"report_path": "docs/research/reported_offset_ephemeris_nutation_2026_10_08.json",
|
||||
"report_sha256": "b433a906280a5b12e4e48fd5e86292b73d21a627f490e3083d719aceea3252c9",
|
||||
"freeze_record_path": "docs/research/reported_offset_ephemeris_nutation_2026_10_08.freeze.json",
|
||||
"freeze_record_sha256": "bc43921250cdcc160fb51d88ebcae0262d180e18c0ab2b14791605b09d639d39",
|
||||
"superseded_reason": "BUG-1323 to BUG-1325 changed frozen identity files. Output is unchanged, so ERR-110 stored a new record. Versus the 2026-09-20 historical report, changed_trial_count stays 581 of 900. Prior nutation report and freeze preserved byte-for-byte."
|
||||
}
|
||||
},
|
||||
"previous_reported_offset_replay": {
|
||||
|
||||
@@ -127,6 +127,7 @@ def _active_narayana(
|
||||
birth_at: datetime,
|
||||
event_at: datetime,
|
||||
periods: list[dict[str, Any]] | None = None,
|
||||
antardasha_by_md: dict[int, list[dict[str, Any]]] | None = None,
|
||||
) -> tuple[int | None, int | None]:
|
||||
if periods is None:
|
||||
periods = narayana_dasha.calc_narayana_mahadasha(
|
||||
@@ -134,10 +135,30 @@ def _active_narayana(
|
||||
planet_longitudes,
|
||||
)
|
||||
age = max((event_at - birth_at).total_seconds() / (365.2425 * 86_400), 0.0)
|
||||
active = narayana_dasha.get_current_narayana_dasha(periods, age)
|
||||
major = active.get("md") or {}
|
||||
minor = active.get("ad") or {}
|
||||
return major.get("sign_idx"), minor.get("sign_idx")
|
||||
if antardasha_by_md is None:
|
||||
active = narayana_dasha.get_current_narayana_dasha(periods, age)
|
||||
major = active.get("md") or {}
|
||||
minor = active.get("ad") or {}
|
||||
return major.get("sign_idx"), minor.get("sign_idx")
|
||||
# BUG-1324: same MD/AD lookup as narayana_dasha.get_current_narayana_dasha
|
||||
# lines 769-800 (total_cycle, cycle_start, age_in_cycle, MD loop, AD loop).
|
||||
# PD (lines 801-812) is unused by scoring and is not computed here.
|
||||
if not periods:
|
||||
return None, None
|
||||
total_cycle = sum(p["years"] for p in periods)
|
||||
if total_cycle == 0:
|
||||
return None, None
|
||||
cycle_start = min(float(p.get("start_age", 0)) for p in periods)
|
||||
age_in_cycle = ((age - cycle_start) % total_cycle) + cycle_start
|
||||
for period in periods:
|
||||
if period["start_age"] <= age_in_cycle < period["end_age"]:
|
||||
md_sign = period.get("sign_idx")
|
||||
ads = antardasha_by_md.get(period["sign_idx"]) or []
|
||||
for ad in ads:
|
||||
if ad["start_age"] <= age_in_cycle < ad["end_age"]:
|
||||
return md_sign, ad.get("sign_idx")
|
||||
return md_sign, None
|
||||
return None, None
|
||||
|
||||
|
||||
def _varga_chart(charts: dict, prefix: str) -> dict | None:
|
||||
@@ -198,13 +219,15 @@ def _score_event(
|
||||
vimshottari: tuple[str, str, str],
|
||||
narayana: tuple[int | None, int | None],
|
||||
arudha_padas: dict,
|
||||
functional: dict | None = None,
|
||||
) -> CandidateEvidence:
|
||||
_, target_houses = DOMAIN_CONFIG[event["domain"]]
|
||||
ascendant_index = int(natal_chart["ascendant"]["lon"] // 30)
|
||||
target_lords = _house_lords(ascendant_index, target_houses)
|
||||
functional = functional_benefics.derive_functional_benefic_malefic(
|
||||
natal_chart["ascendant"].get("sign")
|
||||
)
|
||||
if functional is None:
|
||||
functional = functional_benefics.derive_functional_benefic_malefic(
|
||||
natal_chart["ascendant"].get("sign")
|
||||
)
|
||||
functional_benefics_set = set(functional.get("functional_benefics") or [])
|
||||
functional_malefics_set = set(functional.get("functional_malefics") or [])
|
||||
major_lord, minor_lord, pratyantar_lord = vimshottari
|
||||
@@ -542,6 +565,18 @@ def build_candidate_static_context(
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
narayana_periods = None
|
||||
functional_profile = functional_benefics.derive_functional_benefic_malefic(
|
||||
chart["ascendant"].get("sign")
|
||||
)
|
||||
narayana_antardasha_by_md = None
|
||||
if narayana_periods:
|
||||
narayana_antardasha_by_md = {
|
||||
period["sign_idx"]: narayana_dasha.calc_narayana_antardasha(
|
||||
narayana_periods,
|
||||
period["sign_idx"],
|
||||
)
|
||||
for period in narayana_periods
|
||||
}
|
||||
|
||||
birth_info = chart.get("birth_info") if isinstance(chart.get("birth_info"), dict) else {}
|
||||
kp_snapshot = observe_kp_cusps(
|
||||
@@ -595,6 +630,8 @@ def build_candidate_static_context(
|
||||
"shadbala_result": shadbala_result,
|
||||
"vimshottari_timeline": vimshottari_timeline,
|
||||
"narayana_periods": narayana_periods,
|
||||
"functional_profile": functional_profile,
|
||||
"narayana_antardasha_by_md": narayana_antardasha_by_md,
|
||||
}
|
||||
|
||||
|
||||
@@ -649,6 +686,7 @@ def _candidate_row(
|
||||
candidate_at,
|
||||
event_at,
|
||||
context.get("narayana_periods"),
|
||||
context.get("narayana_antardasha_by_md"),
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
missing_layers.append("Narayana_MD_AD")
|
||||
@@ -664,6 +702,7 @@ def _candidate_row(
|
||||
vimshottari=vimshottari,
|
||||
narayana=narayana,
|
||||
arudha_padas=arudha_padas,
|
||||
functional=context.get("functional_profile"),
|
||||
))
|
||||
transit_rules = _controlled_transit_rules(
|
||||
request,
|
||||
|
||||
@@ -4,7 +4,9 @@ from typing import Any, Mapping, Sequence
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from scripts.active_rectification_events import build_candidate_result_summary
|
||||
from scripts.offline_compute_mixins import BadRequest
|
||||
from scripts.rectification.candidate_feature_service import build_candidate_feature_snapshot
|
||||
from scripts.rectification.candidate_window import enumerate_candidate_window
|
||||
from scripts.rectification.contracts import (
|
||||
EVENT_CONTRACT_VERSION,
|
||||
RectificationRequest,
|
||||
@@ -195,6 +197,35 @@ def _rectification_report(
|
||||
}
|
||||
|
||||
|
||||
MAX_CANDIDATES_PER_REQUEST = 241
|
||||
|
||||
|
||||
class TooManyCandidates(BadRequest):
|
||||
"""Rejected before any chart is computed.
|
||||
|
||||
The API server maps every BadRequest to HTTP 400 with error_code
|
||||
ERR_BAD_REQUEST and this task must not edit that server. The token
|
||||
too_many_candidates lives in the message, with the cap and the count.
|
||||
"""
|
||||
|
||||
code = "too_many_candidates"
|
||||
|
||||
def __init__(self, count: int) -> None:
|
||||
self.count = int(count)
|
||||
self.limit = MAX_CANDIDATES_PER_REQUEST
|
||||
super().__init__(f"too_many_candidates: limit={self.limit} count={self.count}")
|
||||
|
||||
|
||||
def _candidate_count(request: RectificationRequest) -> int:
|
||||
return len(enumerate_candidate_window(request))
|
||||
|
||||
|
||||
def _reject_if_too_many_candidates(request: RectificationRequest) -> None:
|
||||
count = _candidate_count(request)
|
||||
if count > MAX_CANDIDATES_PER_REQUEST:
|
||||
raise TooManyCandidates(count)
|
||||
|
||||
|
||||
def engine_scoring_versions() -> dict[str, str]:
|
||||
"""Identity fields also returned by `/api/rectification/v5/score`, without scoring."""
|
||||
return {
|
||||
@@ -219,7 +250,14 @@ def candidate_features(request: RectificationRequest) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def score_candidates(request: RectificationRequest) -> dict[str, Any]:
|
||||
def score_candidates(
|
||||
request: RectificationRequest,
|
||||
*,
|
||||
include_internal: bool = False,
|
||||
enforce_candidate_cap: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if enforce_candidate_cap:
|
||||
_reject_if_too_many_candidates(request)
|
||||
scoring_request = scoreable_request(request)
|
||||
built = build_event_contribution_matrix(scoring_request)
|
||||
rows = score_from_matrix(scoring_request, built)
|
||||
@@ -272,7 +310,7 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
|
||||
)
|
||||
rectification_report["next_step_codes"] = candidate_summary["next_step_codes"]
|
||||
candidate_summary["report"] = rectification_report
|
||||
return {
|
||||
payload = {
|
||||
"result_id": result_id,
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
"event_contract_version": EVENT_CONTRACT_VERSION,
|
||||
@@ -286,11 +324,9 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
|
||||
"conflicting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] < 0],
|
||||
} for row in rows],
|
||||
"candidate_decisions": candidate_decisions,
|
||||
"candidate_decision_receipt": decision_receipt,
|
||||
"decision_receipt": decision_receipt,
|
||||
"execution_ledger_version": EXECUTION_LEDGER_VERSION,
|
||||
"execution_ledger": execution_ledger,
|
||||
"event_contribution_matrix": built["matrix"],
|
||||
"candidate_feature_snapshot": build_candidate_feature_snapshot(
|
||||
scoring_request, spec_hash, built.get("static_contexts")
|
||||
),
|
||||
@@ -317,10 +353,20 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
|
||||
"margin_percent": decision_receipt["margin_percent"],
|
||||
"can_confirm_exact_minute": decision_receipt["confirmation_allowed"],
|
||||
}
|
||||
if include_internal:
|
||||
payload["candidate_decision_receipt"] = decision_receipt
|
||||
payload["event_contribution_matrix"] = built["matrix"]
|
||||
return payload
|
||||
|
||||
|
||||
def diagnostics(request: RectificationRequest) -> dict[str, Any]:
|
||||
scored = score_candidates(request)
|
||||
def diagnostics(
|
||||
request: RectificationRequest,
|
||||
*,
|
||||
enforce_candidate_cap: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if enforce_candidate_cap:
|
||||
_reject_if_too_many_candidates(request)
|
||||
scored = score_candidates(request, enforce_candidate_cap=enforce_candidate_cap)
|
||||
return {
|
||||
"result_id": scored["result_id"],
|
||||
"algorithm_version": scored["algorithm_version"],
|
||||
@@ -328,7 +374,6 @@ def diagnostics(request: RectificationRequest) -> dict[str, Any]:
|
||||
"decision_policy_version": scored["decision_policy_version"],
|
||||
"calculation_spec_hash": scored["calculation_spec_hash"],
|
||||
"candidate_decisions": scored["candidate_decisions"],
|
||||
"candidate_decision_receipt": scored["candidate_decision_receipt"],
|
||||
"decision_receipt": scored["decision_receipt"],
|
||||
"execution_ledger_version": scored["execution_ledger_version"],
|
||||
"execution_ledger": scored["execution_ledger"],
|
||||
@@ -460,7 +505,11 @@ def _normalize_relative_support(raw: Sequence[float]) -> list[float]:
|
||||
return shares
|
||||
|
||||
|
||||
def block_scan(request: RectificationRequest) -> dict[str, Any]:
|
||||
def block_scan(
|
||||
request: RectificationRequest,
|
||||
*,
|
||||
enforce_candidate_cap: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Aggregate event scores into declared periods or caller-supplied sub-blocks."""
|
||||
requested_step = request.get("minute_step")
|
||||
if isinstance(requested_step, int) and requested_step > 1:
|
||||
@@ -472,7 +521,9 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]:
|
||||
if step <= 1:
|
||||
step = 10
|
||||
scoring_request = {**request, "minute_step": step}
|
||||
scored = score_candidates(scoring_request)
|
||||
if enforce_candidate_cap:
|
||||
_reject_if_too_many_candidates(scoring_request)
|
||||
scored = score_candidates(scoring_request, enforce_candidate_cap=enforce_candidate_cap)
|
||||
events_by_id = {
|
||||
str(event.get("id") or ""): event
|
||||
for event in request.get("events") or []
|
||||
|
||||
@@ -130,6 +130,8 @@ def merge_transition_proximity(
|
||||
for context in static_contexts
|
||||
if isinstance(context, dict) and (time := _context_time(context))
|
||||
}
|
||||
# BUG-1324: one unfiltered dasha walk per candidate, then filter by event year.
|
||||
# Released when this function returns. Not a module-level cache.
|
||||
vim_cache: dict[tuple[Any, ...], list[date]] = {}
|
||||
pd_cache: dict[tuple[Any, ...], list[date]] = {}
|
||||
narayana_cache: dict[tuple[Any, ...], list[date] | None] = {}
|
||||
@@ -156,14 +158,15 @@ def merge_transition_proximity(
|
||||
# Legacy time-only contexts still use the request's date.
|
||||
candidate_at = context.get("candidate_at")
|
||||
candidate_date = candidate_at.date().isoformat() if isinstance(candidate_at, datetime) else birth_date
|
||||
vim_key = (candidate_date, round(float(moon), 6), lo, hi)
|
||||
moon_key = round(float(moon), 6)
|
||||
vim_key = (candidate_date, moon_key)
|
||||
if vim_key not in vim_cache:
|
||||
vim_cache[vim_key] = _vim_start_dates(candidate_date, float(moon), lo, hi)
|
||||
vim_cache[vim_key] = _vim_start_dates(candidate_date, float(moon), 1, 9999)
|
||||
pd_cache[vim_key] = _vim_start_dates(
|
||||
candidate_date,
|
||||
float(moon),
|
||||
lo,
|
||||
hi,
|
||||
1,
|
||||
9999,
|
||||
include_pratyantar=True,
|
||||
)
|
||||
planets = context.get("planet_longitudes") or {}
|
||||
@@ -171,25 +174,26 @@ def merge_transition_proximity(
|
||||
narayana_key = (
|
||||
candidate_date,
|
||||
int(asc) if isinstance(asc, int) else None,
|
||||
lo,
|
||||
hi,
|
||||
round(float(moon), 6),
|
||||
moon_key,
|
||||
)
|
||||
if narayana_key not in narayana_cache:
|
||||
narayana_cache[narayana_key] = (
|
||||
_narayana_start_dates(int(asc), planets, candidate_date, lo, hi)
|
||||
_narayana_start_dates(int(asc), planets, candidate_date, 1, 9999)
|
||||
if isinstance(asc, int) and isinstance(planets, dict)
|
||||
else None
|
||||
)
|
||||
ad_starts = vim_cache[vim_key]
|
||||
ad_starts = [item for item in vim_cache[vim_key] if lo <= item.year <= hi]
|
||||
ad_set = set(ad_starts)
|
||||
pd_only = [item for item in pd_cache[vim_key] if item not in ad_set]
|
||||
pd_only = [item for item in pd_cache[vim_key] if lo <= item.year <= hi and item not in ad_set]
|
||||
narayana_starts = narayana_cache[narayana_key]
|
||||
if narayana_starts is not None:
|
||||
narayana_starts = [item for item in narayana_starts if lo <= item.year <= hi]
|
||||
scored = score_transition_proximity(
|
||||
event_date=event_date,
|
||||
precision=precision,
|
||||
vim_starts=ad_starts,
|
||||
vim_pd_starts=pd_only,
|
||||
narayana_starts=narayana_cache[narayana_key] or [],
|
||||
narayana_starts=narayana_starts or [],
|
||||
)
|
||||
if scored["points"] <= 0 and not scored["rule_ids"]:
|
||||
continue
|
||||
|
||||
@@ -814,6 +814,7 @@ def _score_year(
|
||||
vimshottari=vimshottari,
|
||||
narayana=narayana,
|
||||
arudha_padas=context.get("arudha_padas") or {},
|
||||
functional=context.get("functional_profile"),
|
||||
)
|
||||
|
||||
|
||||
@@ -1913,8 +1914,12 @@ def guided_collect_windows(
|
||||
vim_cache=vim_cache,
|
||||
narayana_cache=narayana_cache,
|
||||
)
|
||||
# BUG-1323: gathered depends only on (track, boundary index). Prefetch once
|
||||
# per candidate and skip a boundary after the one==two check has passed.
|
||||
work_tracks = [starts_by_time.get(_context_time(context) or "") or {} for context in work]
|
||||
rows: list[dict[str, Any]] = []
|
||||
seen: set[tuple[int, int, int, str]] = set()
|
||||
done: set[tuple[str, int]] = set()
|
||||
for left, right in _representative_pairs(work):
|
||||
left_time = str(_context_time(left) or "")
|
||||
right_time = str(_context_time(right) or "")
|
||||
@@ -1928,10 +1933,12 @@ def guided_collect_windows(
|
||||
for index, (one, two) in enumerate(zip(left_dates, right_dates)):
|
||||
if one == two:
|
||||
continue
|
||||
if (track, index) in done:
|
||||
continue
|
||||
done.add((track, index))
|
||||
gathered: list[date] = []
|
||||
for context in work:
|
||||
time = _context_time(context)
|
||||
dates = (starts_by_time.get(time or "") or {}).get(track) or []
|
||||
for tracks in work_tracks:
|
||||
dates = tracks.get(track) or []
|
||||
if index < len(dates):
|
||||
gathered.append(dates[index])
|
||||
if len(gathered) < 2 or min(gathered) == max(gathered):
|
||||
|
||||
@@ -50,7 +50,7 @@ def worker(root: Path, dated: bool):
|
||||
request = make_request(case)
|
||||
if dated:
|
||||
request["candidate_intervals"] = [{"start_at": f'{request["birth_date"]}T{request["start_time"]}', "end_at": f'{request["birth_date"]}T{request["end_time"]}'}]
|
||||
result.append(projection(score(request), width))
|
||||
result.append(projection(score(request, include_internal=True), width))
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bench and canonical dump for the 2026-10-13 rectification scoring speedup.
|
||||
|
||||
Read-only aside from the dump files the caller asks for. Cross-process dumps
|
||||
must be launched with PYTHONHASHSEED=0. The baseline tree has no
|
||||
include_internal parameter; that call falls back to the old signature.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import statistics
|
||||
import sys
|
||||
import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.rectification.api_service import block_scan, diagnostics, score_candidates
|
||||
from scripts.rectification.contracts import normalize_rectification_request
|
||||
from scripts.rectification import event_probes
|
||||
from scripts.rectification import scoring_service
|
||||
from scripts import active_rectification_event_engine as event_engine
|
||||
|
||||
FROZEN_TODAY = date(2026, 9, 16)
|
||||
EVENT_SPECS = (
|
||||
("education", "education_start", "2008-01-01", "2008-12-31", "year"),
|
||||
("career", "career_entry", "2012-06-01", "2012-06-30", "month"),
|
||||
("relocation", "relocation", "2014-03-01", "2014-03-31", "month"),
|
||||
("relationship", "relationship_commitment", "2016-10-01", "2016-10-31", "month"),
|
||||
("career", "promotion", "2018-01-01", "2018-12-31", "year"),
|
||||
("finance", "asset_change", "2019-05-01", "2019-05-31", "month"),
|
||||
("family", "family_event", "2020-08-01", "2020-08-31", "month"),
|
||||
("health", "self_health_event", "2022-01-01", "2022-12-31", "year"),
|
||||
)
|
||||
|
||||
|
||||
class _FrozenDate(date):
|
||||
@classmethod
|
||||
def today(cls) -> date:
|
||||
return FROZEN_TODAY
|
||||
|
||||
|
||||
def _events() -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for index, (domain, kind, start, end, precision) in enumerate(EVENT_SPECS, start=1):
|
||||
rows.append({
|
||||
"id": f"00000000-0000-4000-8000-{index:012d}",
|
||||
"domain": domain,
|
||||
"event_kind": kind,
|
||||
"date_start": start,
|
||||
"date_end": end,
|
||||
"precision": precision,
|
||||
"summary": "虚构",
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def make_request(start: str, end: str, **extra: Any) -> dict[str, Any]:
|
||||
body = {
|
||||
"birth_date": "1990-01-01",
|
||||
"start_time": start,
|
||||
"end_time": end,
|
||||
"lat": 39.9,
|
||||
"lon": 116.4,
|
||||
"tz": 8,
|
||||
"events": _events(),
|
||||
**extra,
|
||||
}
|
||||
return normalize_rectification_request(body, today=FROZEN_TODAY)
|
||||
|
||||
|
||||
def _strip(value: Any, keys: set[str]) -> Any:
|
||||
if isinstance(value, dict):
|
||||
return {key: _strip(item, keys) for key, item in value.items() if key not in keys}
|
||||
if isinstance(value, list):
|
||||
return [_strip(item, keys) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":")).encode("ascii")
|
||||
|
||||
|
||||
def _score(request: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
return score_candidates(request, include_internal=True)
|
||||
except TypeError:
|
||||
return score_candidates(request)
|
||||
|
||||
|
||||
def _call(kind: str, request: dict[str, Any], *, internal: bool = False) -> dict[str, Any]:
|
||||
if kind == "score":
|
||||
# Bench times the product call. Dump asks for the internal matrix so
|
||||
# the baseline tree, which always returned it, stays comparable.
|
||||
if internal:
|
||||
return _score(request)
|
||||
return score_candidates(request)
|
||||
if kind == "block":
|
||||
return block_scan(request)
|
||||
if kind == "diagnostics":
|
||||
return diagnostics(request)
|
||||
raise ValueError(kind)
|
||||
|
||||
|
||||
def scenarios() -> list[dict[str, Any]]:
|
||||
from tests.test_rectification_engine_memoization import public_score_request
|
||||
|
||||
rows = [
|
||||
{"name": "12:00-12:20", "kind": "score", "request": make_request("12:00", "12:20")},
|
||||
{"name": "11:45-12:15", "kind": "score", "request": make_request("11:45", "12:15")},
|
||||
{"name": "11:00-13:00", "kind": "score", "request": make_request("11:00", "13:00")},
|
||||
{"name": "23:20-00:40", "kind": "score", "request": make_request("23:20", "00:40")},
|
||||
{"name": "declined-relationship-career", "kind": "score", "request": make_request(
|
||||
"11:30", "12:30", declined_domains=["relationship", "career"],
|
||||
)},
|
||||
{"name": "refresh-probes-columns", "kind": "score", "request": make_request(
|
||||
"11:30", "12:30", refresh_probes=True, column_times=["11:40", "12:10", "12:20"],
|
||||
)},
|
||||
{"name": "minute-step-2", "kind": "score", "request": make_request("10:00", "13:00", minute_step=2)},
|
||||
{"name": "block-step-10", "kind": "block", "request": make_request("00:00", "23:59", minute_step=10)},
|
||||
{"name": "block-step-5", "kind": "block", "request": make_request("10:00", "14:00", minute_step=5)},
|
||||
{"name": "public-score-request", "kind": "score", "request": normalize_rectification_request(
|
||||
public_score_request(), today=FROZEN_TODAY,
|
||||
)},
|
||||
{"name": "diagnostics-11:00-13:00", "kind": "diagnostics", "request": make_request("11:00", "13:00")},
|
||||
]
|
||||
return rows
|
||||
|
||||
|
||||
def _strip_keys(kind: str) -> set[str]:
|
||||
# diagnostics no longer repeats the receipt. Drop it on both sides so the
|
||||
# remaining payload is what must stay byte-identical.
|
||||
if kind == "diagnostics":
|
||||
return {"column_compare_ms", "candidate_decision_receipt"}
|
||||
return {"column_compare_ms"}
|
||||
|
||||
|
||||
class _SegmentTimer:
|
||||
def __init__(self) -> None:
|
||||
self.totals = {
|
||||
"guided_collect_windows": 0.0,
|
||||
"merge_transition_proximity": 0.0,
|
||||
"get_current_narayana_dasha": 0.0,
|
||||
"derive_functional_benefic_malefic": 0.0,
|
||||
"build_candidate_static_context": 0.0,
|
||||
}
|
||||
|
||||
def wrap(self, owner: Any, name: str, key: str) -> Callable[[], None]:
|
||||
original = getattr(owner, name)
|
||||
|
||||
def wrapped(*args: Any, **kwargs: Any) -> Any:
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
return original(*args, **kwargs)
|
||||
finally:
|
||||
self.totals[key] += time.perf_counter() - started
|
||||
|
||||
setattr(owner, name, wrapped)
|
||||
|
||||
def restore() -> None:
|
||||
setattr(owner, name, original)
|
||||
|
||||
return restore
|
||||
|
||||
|
||||
def _median(samples: list[float]) -> float:
|
||||
return round(statistics.median(samples), 4)
|
||||
|
||||
|
||||
def bench() -> dict[str, Any]:
|
||||
jobs = [
|
||||
("12:00-12:20", "score", make_request("12:00", "12:20")),
|
||||
("11:45-12:15", "score", make_request("11:45", "12:15")),
|
||||
("11:00-13:00", "score", make_request("11:00", "13:00")),
|
||||
("block-step-10", "block", make_request("00:00", "23:59", minute_step=10)),
|
||||
]
|
||||
rows = []
|
||||
event_probes.date = _FrozenDate
|
||||
try:
|
||||
for name, kind, request in jobs:
|
||||
samples = []
|
||||
segments = []
|
||||
sizes = []
|
||||
counts = []
|
||||
for _ in range(5):
|
||||
timer = _SegmentTimer()
|
||||
restores = [
|
||||
timer.wrap(event_probes, "guided_collect_windows", "guided_collect_windows"),
|
||||
timer.wrap(scoring_service, "merge_transition_proximity", "merge_transition_proximity"),
|
||||
timer.wrap(event_engine.narayana_dasha, "get_current_narayana_dasha", "get_current_narayana_dasha"),
|
||||
timer.wrap(event_engine.functional_benefics, "derive_functional_benefic_malefic", "derive_functional_benefic_malefic"),
|
||||
timer.wrap(event_engine, "build_candidate_static_context", "build_candidate_static_context"),
|
||||
]
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
result = _call(kind, request)
|
||||
finally:
|
||||
for restore in restores:
|
||||
restore()
|
||||
elapsed = time.perf_counter() - started
|
||||
samples.append(elapsed)
|
||||
segments.append({key: round(value, 4) for key, value in timer.totals.items()})
|
||||
sizes.append(len(_canonical(_strip(result, _strip_keys(kind)))))
|
||||
counts.append(result.get("candidate_count") or len(result.get("candidate_scores") or []))
|
||||
median_segments: dict[str, float] = {}
|
||||
for key in segments[0]:
|
||||
median_segments[key] = _median([item[key] for item in segments])
|
||||
rows.append({
|
||||
"window": name,
|
||||
"candidate_count": counts[0],
|
||||
"median_seconds": _median(samples),
|
||||
"segment_median_seconds": median_segments,
|
||||
"response_bytes": int(statistics.median(sizes)),
|
||||
})
|
||||
finally:
|
||||
event_probes.date = date
|
||||
return {"runs": 5, "rows": rows}
|
||||
|
||||
|
||||
def dump(out_dir: Path) -> list[dict[str, str]]:
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
written = []
|
||||
event_probes.date = _FrozenDate
|
||||
try:
|
||||
for row in scenarios():
|
||||
result = _call(row["kind"], row["request"], internal=True)
|
||||
payload = _strip(result, _strip_keys(row["kind"]))
|
||||
raw = _canonical(payload)
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
path = out_dir / f"{row['name'].replace(':', '')}.json"
|
||||
path.write_bytes(raw)
|
||||
written.append({"name": row["name"], "sha256": digest, "bytes": str(len(raw))})
|
||||
print(f"{row['name']}\t{digest}\t{len(raw)}")
|
||||
finally:
|
||||
event_probes.date = date
|
||||
return written
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=("bench", "dump"))
|
||||
parser.add_argument("--out", type=Path, default=ROOT / "artifacts" / "rect-scoring-perf-20261013")
|
||||
args = parser.parse_args()
|
||||
if args.command == "bench":
|
||||
print(json.dumps(bench(), ensure_ascii=True, indent=2))
|
||||
else:
|
||||
dump(args.out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -30,8 +30,11 @@ from scripts.research.sealed_holdout_rerun import (
|
||||
OFFSETS = (-30, -20, -15, -10, -8, -5, -3, 0, 3, 5, 8, 10, 15, 20, 30)
|
||||
RADII = (15, 30, 60)
|
||||
MINUTE_STEP = 1
|
||||
FREEZE = ROOT / "docs/research/reported_offset_ephemeris_nutation_2026_10_08.freeze.json"
|
||||
REPORT = ROOT / "docs/research/reported_offset_ephemeris_nutation_2026_10_08.json"
|
||||
# 原值: docs/research/reported_offset_ephemeris_nutation_2026_10_08.{freeze.json,json}
|
||||
# 新值: docs/research/reported_offset_scoring_perf_2026_10_13.{freeze.json,json}
|
||||
# 原因: BUG-1323~1325 改了冻结身份文件,输出不变,ERR-110 另存新记录
|
||||
FREEZE = ROOT / "docs/research/reported_offset_scoring_perf_2026_10_13.freeze.json"
|
||||
REPORT = ROOT / "docs/research/reported_offset_scoring_perf_2026_10_13.json"
|
||||
LEGACY_REPORT = ROOT / "docs/research/reported_offset_2026_09_20.json"
|
||||
|
||||
|
||||
|
||||
@@ -27,8 +27,11 @@ from scripts.minute_rectification_feature_facts_v4 import build_feature_fact_row
|
||||
from scripts.minute_rectification_holdout_validator import validate
|
||||
|
||||
DATASET = ROOT / "references/real_case_calibration/minute_rectification_holdout_v3.json"
|
||||
FREEZE = ROOT / "docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.freeze.json"
|
||||
REPORT = ROOT / "docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.json"
|
||||
# 原值: docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.{freeze.json,json}
|
||||
# 新值: docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.{freeze.json,json}
|
||||
# 原因: BUG-1323~1325 改了冻结身份文件,输出不变,ERR-110 另存新记录
|
||||
FREEZE = ROOT / "docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.freeze.json"
|
||||
REPORT = ROOT / "docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13.json"
|
||||
LEGACY_REPORT = ROOT / "docs/research/sealed_holdout_rerun_2026_09_20.json"
|
||||
ARCHIVE = ROOT / "docs/research/history/rectification_pre_cross_midnight_2026_09_20"
|
||||
PRODUCTION_FILES = [
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Baseline copies for TASK-rect-scoring-perf-20261013.
|
||||
|
||||
Source commit: 77057a819
|
||||
git show 77057a819:scripts/rectification/event_probes.py sha256: 9bbadc246e6b8e94bb047522c3e5645613f508b3473ef40b38a207c77fdad58c
|
||||
git show 77057a819:scripts/rectification/dasha_transition_proximity.py sha256: 0afba494aa12899621173c3cc941c6c1d0be9cec0bdf4c4a1c9bcd004ab22de8
|
||||
|
||||
The helpers these functions call are unchanged by this task and are imported
|
||||
from the current modules. Only guided_collect_windows and
|
||||
merge_transition_proximity differ from the copies below.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Sequence
|
||||
|
||||
from scripts.rectification.dasha_transition_proximity import (
|
||||
_context_time as _proximity_context_time,
|
||||
representative_event_date,
|
||||
score_transition_proximity,
|
||||
)
|
||||
from scripts.rectification.event_probes import (
|
||||
GUIDED_ANY_DOMAIN,
|
||||
GUIDED_COLLECT_LIMIT,
|
||||
GUIDED_DOMAIN_ORDER,
|
||||
_GUIDED_TRACK_DOMAIN,
|
||||
_birth_year,
|
||||
_context_time as _probe_context_time,
|
||||
_guided_track_starts,
|
||||
_guided_year_windows,
|
||||
_narayana_start_dates,
|
||||
_remaining_contexts,
|
||||
_representative_pairs,
|
||||
_scoreable,
|
||||
_static_contexts,
|
||||
_vim_start_dates,
|
||||
)
|
||||
|
||||
# Both copies call `_context_time`. They are rebound onto separate globals
|
||||
# below so each keeps the helper it had in its source module.
|
||||
_context_time = _probe_context_time
|
||||
|
||||
def guided_collect_windows(
|
||||
request: dict[str, Any],
|
||||
built: dict[str, Any],
|
||||
*,
|
||||
candidate_times: Sequence[str],
|
||||
today: date | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Boundary windows for guided collect. Does not change auto probe generation."""
|
||||
birth_date = str(request.get("birth_date") or "").strip()
|
||||
birth_year = _birth_year(birth_date)
|
||||
if birth_year is None:
|
||||
return []
|
||||
try:
|
||||
datetime.strptime(birth_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return []
|
||||
remaining = _remaining_contexts(built, candidate_times)
|
||||
if len(remaining) < 2:
|
||||
remaining = _static_contexts(built)
|
||||
work = [item for item in remaining if _scoreable(item)]
|
||||
if len(work) < 2:
|
||||
return []
|
||||
declined = {
|
||||
str(item).strip()
|
||||
for item in (request.get("declined_domains") or [])
|
||||
if str(item).strip() in GUIDED_DOMAIN_ORDER
|
||||
}
|
||||
now = today or date.today()
|
||||
lo, hi = birth_year + 5, min(now.year, birth_year + 80)
|
||||
vim_cache: dict[tuple[Any, ...], list[date]] = {}
|
||||
narayana_cache: dict[tuple[Any, ...], list[date] | None] = {}
|
||||
starts_by_time: dict[str, dict[str, list[date]]] = {}
|
||||
for context in work:
|
||||
time = _context_time(context)
|
||||
if not time:
|
||||
continue
|
||||
starts_by_time[time] = _guided_track_starts(
|
||||
context,
|
||||
birth_date=birth_date,
|
||||
lo=lo,
|
||||
hi=hi,
|
||||
include_pratyantar=True,
|
||||
varga_narayana=True,
|
||||
vim_cache=vim_cache,
|
||||
narayana_cache=narayana_cache,
|
||||
)
|
||||
rows: list[dict[str, Any]] = []
|
||||
seen: set[tuple[int, int, int, str]] = set()
|
||||
for left, right in _representative_pairs(work):
|
||||
left_time = str(_context_time(left) or "")
|
||||
right_time = str(_context_time(right) or "")
|
||||
left_tracks = starts_by_time.get(left_time) or {}
|
||||
right_tracks = starts_by_time.get(right_time) or {}
|
||||
for track in ("vim", "nara", "nara:d9", "nara:d10"):
|
||||
left_dates = left_tracks.get(track) or []
|
||||
right_dates = right_tracks.get(track) or []
|
||||
if not left_dates or not right_dates:
|
||||
continue
|
||||
for index, (one, two) in enumerate(zip(left_dates, right_dates)):
|
||||
if one == two:
|
||||
continue
|
||||
gathered: list[date] = []
|
||||
for context in work:
|
||||
time = _context_time(context)
|
||||
dates = (starts_by_time.get(time or "") or {}).get(track) or []
|
||||
if index < len(dates):
|
||||
gathered.append(dates[index])
|
||||
if len(gathered) < 2 or min(gathered) == max(gathered):
|
||||
continue
|
||||
ordered = sorted(gathered)
|
||||
cut = ordered[0] + (ordered[-1] - ordered[0]) / 2
|
||||
left_n = sum(1 for item in gathered if item <= cut)
|
||||
right_n = len(gathered) - left_n
|
||||
if left_n < 1 or right_n < 1:
|
||||
continue
|
||||
# BUG-749: only the D9 / D10 Narayana tracks carry a domain.
|
||||
# Vimshottari and natal Narayana boundaries say nothing about
|
||||
# which area of life moved, so the window is asked open
|
||||
# ("any") instead of being round-robined onto one domain.
|
||||
track_domain = _GUIDED_TRACK_DOMAIN.get(track)
|
||||
domain = (
|
||||
track_domain
|
||||
if track_domain and track_domain not in declined
|
||||
else GUIDED_ANY_DOMAIN
|
||||
)
|
||||
for year, month_lo, month_hi in _guided_year_windows(gathered):
|
||||
if year < lo or year > hi:
|
||||
continue
|
||||
key = (year, month_lo, month_hi, domain)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
rows.append({
|
||||
"year": year,
|
||||
"month_lo": month_lo,
|
||||
"month_hi": month_hi,
|
||||
"domain": domain,
|
||||
"split": {"left": left_n, "right": right_n},
|
||||
})
|
||||
rows.sort(key=lambda row: (
|
||||
abs(row["split"]["left"] - row["split"]["right"]),
|
||||
abs(now.year - int(row["year"])),
|
||||
-int(row["year"]),
|
||||
int(row["month_lo"]),
|
||||
str(row["domain"]),
|
||||
))
|
||||
return rows[:GUIDED_COLLECT_LIMIT]
|
||||
|
||||
|
||||
def merge_transition_proximity(
|
||||
matrix: dict[str, dict[str, dict[str, Any]]],
|
||||
events: Sequence[dict[str, Any]],
|
||||
static_contexts: Sequence[dict[str, Any]],
|
||||
birth_date: str,
|
||||
*,
|
||||
public_technique_layers: Callable[[str, Sequence[str]], list[str]],
|
||||
) -> None:
|
||||
by_time = {
|
||||
time: context
|
||||
for context in static_contexts
|
||||
if isinstance(context, dict) and (time := _context_time(context))
|
||||
}
|
||||
vim_cache: dict[tuple[Any, ...], list[date]] = {}
|
||||
pd_cache: dict[tuple[Any, ...], list[date]] = {}
|
||||
narayana_cache: dict[tuple[Any, ...], list[date] | None] = {}
|
||||
for event in events:
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
event_id = str(event.get("id") or "")
|
||||
cells = matrix.get(event_id)
|
||||
if not event_id or not isinstance(cells, dict):
|
||||
continue
|
||||
event_date = representative_event_date(event)
|
||||
if event_date is None:
|
||||
continue
|
||||
precision = str(event.get("precision") or "")
|
||||
lo, hi = event_date.year - 1, event_date.year + 1
|
||||
for time, cell in cells.items():
|
||||
context = by_time.get(str(time)[:5])
|
||||
if not isinstance(cell, dict) or not isinstance(context, dict):
|
||||
continue
|
||||
moon = (context.get("planet_longitudes") or {}).get("Moon")
|
||||
if not isinstance(moon, (int, float)):
|
||||
continue
|
||||
# Native contexts retain the local date even when the window crosses midnight.
|
||||
# Legacy time-only contexts still use the request's date.
|
||||
candidate_at = context.get("candidate_at")
|
||||
candidate_date = candidate_at.date().isoformat() if isinstance(candidate_at, datetime) else birth_date
|
||||
vim_key = (candidate_date, round(float(moon), 6), lo, hi)
|
||||
if vim_key not in vim_cache:
|
||||
vim_cache[vim_key] = _vim_start_dates(candidate_date, float(moon), lo, hi)
|
||||
pd_cache[vim_key] = _vim_start_dates(
|
||||
candidate_date,
|
||||
float(moon),
|
||||
lo,
|
||||
hi,
|
||||
include_pratyantar=True,
|
||||
)
|
||||
planets = context.get("planet_longitudes") or {}
|
||||
asc = context.get("ascendant_index")
|
||||
narayana_key = (
|
||||
candidate_date,
|
||||
int(asc) if isinstance(asc, int) else None,
|
||||
lo,
|
||||
hi,
|
||||
round(float(moon), 6),
|
||||
)
|
||||
if narayana_key not in narayana_cache:
|
||||
narayana_cache[narayana_key] = (
|
||||
_narayana_start_dates(int(asc), planets, candidate_date, lo, hi)
|
||||
if isinstance(asc, int) and isinstance(planets, dict)
|
||||
else None
|
||||
)
|
||||
ad_starts = vim_cache[vim_key]
|
||||
ad_set = set(ad_starts)
|
||||
pd_only = [item for item in pd_cache[vim_key] if item not in ad_set]
|
||||
scored = score_transition_proximity(
|
||||
event_date=event_date,
|
||||
precision=precision,
|
||||
vim_starts=ad_starts,
|
||||
vim_pd_starts=pd_only,
|
||||
narayana_starts=narayana_cache[narayana_key] or [],
|
||||
)
|
||||
if scored["points"] <= 0 and not scored["rule_ids"]:
|
||||
continue
|
||||
cell["points"] = round(float(cell.get("points") or 0) + float(scored["points"]), 4)
|
||||
cell["rule_ids"] = sorted({
|
||||
*list(cell.get("rule_ids") or []),
|
||||
*scored["rule_ids"],
|
||||
})
|
||||
domain = str(event.get("domain") or cell.get("domain") or "")
|
||||
cell["technique_layers"] = public_technique_layers(domain, cell["rule_ids"])
|
||||
|
||||
|
||||
import types as _types
|
||||
|
||||
def _rebind(function, context_time):
|
||||
globs = dict(function.__globals__)
|
||||
globs["_context_time"] = context_time
|
||||
return _types.FunctionType(function.__code__, globs, function.__name__)
|
||||
|
||||
|
||||
guided_collect_windows = _rebind(guided_collect_windows, _probe_context_time)
|
||||
merge_transition_proximity = _rebind(merge_transition_proximity, _proximity_context_time)
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Output-identical speedups for rectification scoring (BUG-1323, 1324, 1325, 1345)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import inspect
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.active_rectification_event_engine import (
|
||||
_active_narayana,
|
||||
build_candidate_static_context,
|
||||
compute_event_candidate_rows,
|
||||
)
|
||||
from scripts.rectification.api_service import (
|
||||
MAX_CANDIDATES_PER_REQUEST,
|
||||
TooManyCandidates,
|
||||
_candidate_count,
|
||||
_reject_if_too_many_candidates,
|
||||
block_scan,
|
||||
diagnostics,
|
||||
score_candidates,
|
||||
)
|
||||
from scripts.rectification.contracts import normalize_rectification_request
|
||||
from scripts.rectification.dasha_transition_proximity import merge_transition_proximity
|
||||
from scripts.rectification.event_probes import guided_collect_windows
|
||||
from scripts.rectification.scoring_service import build_event_contribution_matrix, sha256
|
||||
import scripts.active_rectification_event_engine as event_engine
|
||||
from scripts.rectification import dasha_transition_proximity as proximity
|
||||
from scripts.rectification import event_probes
|
||||
from scripts.rectification import scoring_service
|
||||
from tests._baseline_rect_scoring_20261013 import (
|
||||
guided_collect_windows as baseline_guided_collect_windows,
|
||||
merge_transition_proximity as baseline_merge_transition_proximity,
|
||||
)
|
||||
|
||||
FROZEN_TODAY = date(2026, 9, 16)
|
||||
EVENT_SPECS = (
|
||||
("education", "education_start", "2008-01-01", "2008-12-31", "year"),
|
||||
("career", "career_entry", "2012-06-01", "2012-06-30", "month"),
|
||||
("relocation", "relocation", "2014-03-01", "2014-03-31", "month"),
|
||||
("relationship", "relationship_commitment", "2016-10-01", "2016-10-31", "month"),
|
||||
("career", "promotion", "2018-01-01", "2018-12-31", "year"),
|
||||
("finance", "asset_change", "2019-05-01", "2019-05-31", "month"),
|
||||
("family", "family_event", "2020-08-01", "2020-08-31", "month"),
|
||||
("health", "self_health_event", "2022-01-01", "2022-12-31", "year"),
|
||||
)
|
||||
|
||||
|
||||
class _FrozenDate(date):
|
||||
@classmethod
|
||||
def today(cls) -> date:
|
||||
return FROZEN_TODAY
|
||||
|
||||
|
||||
def _events() -> list[dict]:
|
||||
rows = []
|
||||
for index, (domain, kind, start, end, precision) in enumerate(EVENT_SPECS, start=1):
|
||||
rows.append({
|
||||
"id": f"00000000-0000-4000-8000-{index:012d}",
|
||||
"domain": domain,
|
||||
"event_kind": kind,
|
||||
"date_start": start,
|
||||
"date_end": end,
|
||||
"precision": precision,
|
||||
"summary": "虚构",
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _request(start: str, end: str, **extra) -> dict:
|
||||
body = {
|
||||
"birth_date": "1990-01-01",
|
||||
"start_time": start,
|
||||
"end_time": end,
|
||||
"lat": 39.9,
|
||||
"lon": 116.4,
|
||||
"tz": 8,
|
||||
"events": _events(),
|
||||
**extra,
|
||||
}
|
||||
return normalize_rectification_request(body, today=FROZEN_TODAY)
|
||||
|
||||
|
||||
def _count(monkeypatch, owner, name: str) -> list[int]:
|
||||
original = getattr(owner, name)
|
||||
counter = [0]
|
||||
|
||||
def wrapped(*args, **kwargs):
|
||||
counter[0] += 1
|
||||
return original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(owner, name, wrapped)
|
||||
return counter
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def wide_case():
|
||||
request = _request("11:00", "13:00")
|
||||
captured: dict = {}
|
||||
original = scoring_service.merge_transition_proximity
|
||||
|
||||
def grab(matrix, events, contexts, birth_date, *, public_technique_layers):
|
||||
captured["matrix"] = copy.deepcopy(matrix)
|
||||
captured["events"] = list(events)
|
||||
captured["contexts"] = contexts
|
||||
captured["birth_date"] = birth_date
|
||||
captured["layers"] = public_technique_layers
|
||||
return original(
|
||||
matrix, events, contexts, birth_date,
|
||||
public_technique_layers=public_technique_layers,
|
||||
)
|
||||
|
||||
with patch.object(event_probes, "date", _FrozenDate), patch.object(
|
||||
scoring_service, "merge_transition_proximity", grab,
|
||||
):
|
||||
built = build_event_contribution_matrix(request)
|
||||
return request, built, captured
|
||||
|
||||
|
||||
def test_guided_collect_windows_matches_baseline_and_is_linear(wide_case, monkeypatch) -> None:
|
||||
request, built, _captured = wide_case
|
||||
times = list(built["candidate_times"])
|
||||
assert len(times) == 121
|
||||
variants = [
|
||||
(request, times),
|
||||
(request, times[::2]),
|
||||
(request, times[:3]),
|
||||
({**request, "declined_domains": ["relationship"]}, times),
|
||||
]
|
||||
with patch.object(event_probes, "date", _FrozenDate):
|
||||
for variant_request, variant_times in variants:
|
||||
assert guided_collect_windows(
|
||||
variant_request, built, candidate_times=variant_times, today=FROZEN_TODAY,
|
||||
) == baseline_guided_collect_windows(
|
||||
variant_request, built, candidate_times=variant_times, today=FROZEN_TODAY,
|
||||
)
|
||||
short_built = {
|
||||
**built,
|
||||
"static_contexts": list(built["static_contexts"])[:21],
|
||||
"candidate_times": times[:21],
|
||||
}
|
||||
full_calls = _count(monkeypatch, event_probes, "_context_time")
|
||||
guided_collect_windows(request, built, candidate_times=times, today=FROZEN_TODAY)
|
||||
full = full_calls[0]
|
||||
short_calls = _count(monkeypatch, event_probes, "_context_time")
|
||||
guided_collect_windows(request, short_built, candidate_times=times[:21], today=FROZEN_TODAY)
|
||||
short = short_calls[0]
|
||||
assert full <= 16 * 121
|
||||
assert short <= 16 * 21
|
||||
assert full / short <= (121 / 21) * 1.2
|
||||
|
||||
|
||||
def test_merge_transition_proximity_matches_baseline(wide_case, monkeypatch) -> None:
|
||||
_request_wide, _built, captured = wide_case
|
||||
vim_calls = _count(monkeypatch, proximity, "_vim_start_dates")
|
||||
nara_calls = _count(monkeypatch, proximity, "_narayana_start_dates")
|
||||
fresh = copy.deepcopy(captured["matrix"])
|
||||
base = copy.deepcopy(captured["matrix"])
|
||||
merge_transition_proximity(
|
||||
fresh, captured["events"], captured["contexts"], captured["birth_date"],
|
||||
public_technique_layers=captured["layers"],
|
||||
)
|
||||
baseline_merge_transition_proximity(
|
||||
base, captured["events"], captured["contexts"], captured["birth_date"],
|
||||
public_technique_layers=captured["layers"],
|
||||
)
|
||||
assert fresh == base
|
||||
assert vim_calls[0] <= 2 * len(captured["contexts"])
|
||||
assert nara_calls[0] <= len(captured["contexts"])
|
||||
|
||||
midnight = _request("23:20", "00:40")
|
||||
midnight_captured: dict = {}
|
||||
original = scoring_service.merge_transition_proximity
|
||||
|
||||
def grab(matrix, events, contexts, birth_date, *, public_technique_layers):
|
||||
midnight_captured["matrix"] = copy.deepcopy(matrix)
|
||||
midnight_captured["events"] = list(events)
|
||||
midnight_captured["contexts"] = contexts
|
||||
midnight_captured["birth_date"] = birth_date
|
||||
midnight_captured["layers"] = public_technique_layers
|
||||
return original(matrix, events, contexts, birth_date, public_technique_layers=public_technique_layers)
|
||||
|
||||
with patch.object(event_probes, "date", _FrozenDate), patch.object(
|
||||
scoring_service, "merge_transition_proximity", grab,
|
||||
):
|
||||
build_event_contribution_matrix(midnight)
|
||||
fresh_night = copy.deepcopy(midnight_captured["matrix"])
|
||||
base_night = copy.deepcopy(midnight_captured["matrix"])
|
||||
merge_transition_proximity(
|
||||
fresh_night, midnight_captured["events"], midnight_captured["contexts"], midnight_captured["birth_date"],
|
||||
public_technique_layers=midnight_captured["layers"],
|
||||
)
|
||||
baseline_merge_transition_proximity(
|
||||
base_night, midnight_captured["events"], midnight_captured["contexts"], midnight_captured["birth_date"],
|
||||
public_technique_layers=midnight_captured["layers"],
|
||||
)
|
||||
assert fresh_night == base_night
|
||||
|
||||
|
||||
def test_static_context_caches_match_fallback_and_stay_out_of_features(wide_case, monkeypatch) -> None:
|
||||
request, built, _captured = wide_case
|
||||
contexts = list(built["static_contexts"])
|
||||
assert len(contexts) == 121
|
||||
for context in contexts:
|
||||
assert "functional_profile" not in context["feature"]
|
||||
assert "narayana_antardasha_by_md" not in context["feature"]
|
||||
assert context.get("functional_profile")
|
||||
assert context.get("narayana_antardasha_by_md")
|
||||
features = [context["feature"] for context in contexts]
|
||||
cleared = []
|
||||
for context in contexts:
|
||||
clone = dict(context)
|
||||
clone["functional_profile"] = None
|
||||
clone["narayana_antardasha_by_md"] = None
|
||||
cleared.append(clone)
|
||||
engine_request = {
|
||||
"birth_date": "1990-01-01",
|
||||
"start_time": "11:00",
|
||||
"end_time": "13:00",
|
||||
"lat": 39.9,
|
||||
"lon": 116.4,
|
||||
"tz": 8,
|
||||
"events": [{
|
||||
"id": "00000000-0000-4000-8000-000000000002",
|
||||
"domain": "career",
|
||||
"event_kind": "career_entry",
|
||||
"date": "2012-06-15",
|
||||
"precision": "day",
|
||||
"summary": "虚构",
|
||||
}],
|
||||
}
|
||||
assert compute_event_candidate_rows(engine_request, static_contexts=contexts) == compute_event_candidate_rows(
|
||||
engine_request, static_contexts=cleared,
|
||||
)
|
||||
assert sha256(features) == sha256([dict(feature) for feature in features])
|
||||
|
||||
functional_calls = _count(
|
||||
monkeypatch, event_engine.functional_benefics, "derive_functional_benefic_malefic",
|
||||
)
|
||||
narayana_calls = [0]
|
||||
original = event_engine.narayana_dasha.get_current_narayana_dasha
|
||||
|
||||
def wrapped(*args, **kwargs):
|
||||
caller = inspect.stack()[1].filename.replace("\\", "/")
|
||||
names = [frame.function for frame in inspect.stack()]
|
||||
if caller.endswith("/active_rectification_event_engine.py") and "_candidate_row" in names:
|
||||
narayana_calls[0] += 1
|
||||
return original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(event_engine.narayana_dasha, "get_current_narayana_dasha", wrapped)
|
||||
with patch.object(event_probes, "date", _FrozenDate):
|
||||
scored = score_candidates(request, include_internal=True)
|
||||
functional_during_score = functional_calls[0]
|
||||
compact = score_candidates(request)
|
||||
assert functional_during_score == 121
|
||||
# Probe path (_score_year) is out of scope and still calls get_current.
|
||||
# The scoring loop (_candidate_row) must not.
|
||||
assert narayana_calls[0] == 0
|
||||
assert "event_contribution_matrix" in scored
|
||||
assert "candidate_decision_receipt" in scored
|
||||
assert "event_contribution_matrix" not in compact
|
||||
assert "candidate_decision_receipt" not in compact
|
||||
|
||||
def without_timing(value):
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: without_timing(item)
|
||||
for key, item in value.items()
|
||||
if key not in {"column_compare_ms", "event_contribution_matrix", "candidate_decision_receipt"}
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [without_timing(item) for item in value]
|
||||
return value
|
||||
|
||||
assert without_timing(compact) == without_timing(scored)
|
||||
assert len(scored["candidate_feature_snapshot"]["features"]) == 121
|
||||
assert scored["candidate_feature_snapshot"]["feature_hash"] == compact["candidate_feature_snapshot"]["feature_hash"]
|
||||
|
||||
|
||||
def test_cached_narayana_matches_live_lookup_across_ages(wide_case) -> None:
|
||||
_request, built, _captured = wide_case
|
||||
contexts = list(built["static_contexts"])[:3]
|
||||
assert len(contexts) == 3
|
||||
for context in contexts:
|
||||
periods = context["narayana_periods"]
|
||||
table = context["narayana_antardasha_by_md"]
|
||||
birth = context["candidate_at"]
|
||||
planets = context["planet_longitudes"]
|
||||
asc = context["ascendant_index"]
|
||||
|
||||
def at_age(age: float) -> datetime:
|
||||
return birth + timedelta(seconds=age * 365.2425 * 86400)
|
||||
|
||||
ages = [index / 100 for index in range(0, 12001)]
|
||||
total = sum(period["years"] for period in periods)
|
||||
cycle_start = min(float(period.get("start_age", 0)) for period in periods)
|
||||
edges = [cycle_start, cycle_start + total]
|
||||
edges.extend(float(period["start_age"]) for period in periods)
|
||||
edges.extend(float(period["end_age"]) for period in periods)
|
||||
for edge in edges:
|
||||
for delta in (-1e-9, 0.0, 1e-9):
|
||||
if edge + delta >= 0:
|
||||
ages.append(edge + delta)
|
||||
for age in ages:
|
||||
event_at = at_age(age)
|
||||
cached = _active_narayana(asc, planets, birth, event_at, periods, table)
|
||||
live = _active_narayana(asc, planets, birth, event_at, periods, None)
|
||||
assert cached == live
|
||||
|
||||
|
||||
def test_candidate_cap_rejects_before_charts_and_allows_product_windows() -> None:
|
||||
assert _candidate_count(_request("00:00", "03:59")) == 240
|
||||
assert _candidate_count(_request("00:00", "04:00")) == 241
|
||||
assert _candidate_count(_request("00:00", "04:01")) == 242
|
||||
_reject_if_too_many_candidates(_request("00:00", "03:59"))
|
||||
_reject_if_too_many_candidates(_request("00:00", "04:00"))
|
||||
with pytest.raises(TooManyCandidates) as rejected:
|
||||
_reject_if_too_many_candidates(_request("00:00", "04:01"))
|
||||
assert rejected.value.code == "too_many_candidates"
|
||||
assert rejected.value.limit == MAX_CANDIDATES_PER_REQUEST == 241
|
||||
assert "count=242" in str(rejected.value)
|
||||
|
||||
full_day = _request("00:00", "23:59")
|
||||
assert _candidate_count(full_day) == 1440
|
||||
started = time.perf_counter()
|
||||
with pytest.raises(TooManyCandidates) as full:
|
||||
score_candidates(full_day)
|
||||
assert time.perf_counter() - started < 0.05
|
||||
assert "count=1440" in str(full.value)
|
||||
with pytest.raises(TooManyCandidates):
|
||||
diagnostics(full_day)
|
||||
|
||||
step_10 = _request("00:00", "23:59", minute_step=10)
|
||||
assert _candidate_count(step_10) == 144
|
||||
_reject_if_too_many_candidates(step_10)
|
||||
step_2 = _request("00:00", "03:00", minute_step=2)
|
||||
step_5 = _request("00:00", "06:00", minute_step=5)
|
||||
assert _candidate_count(step_2) <= 91
|
||||
assert _candidate_count(step_5) <= 121
|
||||
_reject_if_too_many_candidates(step_2)
|
||||
_reject_if_too_many_candidates(step_5)
|
||||
with patch(
|
||||
"scripts.rectification.api_service.score_candidates",
|
||||
return_value={"candidate_scores": [], "decision_receipt": {}},
|
||||
) as scored:
|
||||
block_scan(step_10)
|
||||
block_scan(step_2)
|
||||
block_scan(step_5)
|
||||
assert scored.call_count == 3
|
||||
|
||||
|
||||
def test_one_candidate_context_exposes_top_level_caches() -> None:
|
||||
request = _request("12:00", "12:00")
|
||||
context = build_candidate_static_context(request, datetime(1990, 1, 1, 12, 0))
|
||||
assert "functional_profile" not in context["feature"]
|
||||
assert "narayana_antardasha_by_md" not in context["feature"]
|
||||
assert context["functional_profile"]["functional_benefics"] or context["functional_profile"]["functional_malefics"]
|
||||
assert set(context["narayana_antardasha_by_md"]) == {period["sign_idx"] for period in context["narayana_periods"]}
|
||||
@@ -100,11 +100,16 @@ HISTORICAL_SOURCE_COMMIT = "a8d29d1b6cc37ff865ddec6c8bccdf9aa889ee53"
|
||||
CURRENT_POLICY_VERSION = "rectification-candidate-policy-v4"
|
||||
# Nutation fix commit that moves these scores (BUG-1275). v4 keeps V4_SOURCE_COMMIT.
|
||||
SOURCE_COMMIT = "e5f2a75183381c4a027014c7f1e89ddcb8393bd3"
|
||||
# 原值: 4 个缓存键(ashtakavarga / shadbala / vimshottari / narayana_periods)
|
||||
# 新值: 再加 functional_profile、narayana_antardasha_by_md,共 6 个
|
||||
# 原因: BUG-1324。这两项只依赖候选分钟,放在静态上下文顶层;置 None 仍现算,输出应相同
|
||||
CACHE_LAYER_KEYS = (
|
||||
"ashtakavarga_result",
|
||||
"shadbala_result",
|
||||
"vimshottari_timeline",
|
||||
"narayana_periods",
|
||||
"functional_profile",
|
||||
"narayana_antardasha_by_md",
|
||||
)
|
||||
# TASK-rectification-engine-memoization-fix-20260915 §2 measured max
|
||||
# cross-machine drift of 1.1e-3 on decision_receipt.margin_percent
|
||||
|
||||
@@ -412,7 +412,14 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
scored = score_candidates(normalized)
|
||||
diagnostic_result = diagnostics(normalized)
|
||||
self.assertFalse(scored["can_confirm_exact_minute"])
|
||||
self.assertIn("event_contribution_matrix", scored)
|
||||
# 原值: assertIn("event_contribution_matrix", scored)
|
||||
# 新值: 默认 assertNotIn;include_internal=True 时仍在
|
||||
# 原因: BUG-1325,前台没有读取点,默认响应不再带内部矩阵
|
||||
self.assertNotIn("event_contribution_matrix", scored)
|
||||
self.assertNotIn("candidate_decision_receipt", scored)
|
||||
internal = score_candidates(normalized, include_internal=True)
|
||||
self.assertIn("event_contribution_matrix", internal)
|
||||
self.assertIn("candidate_decision_receipt", internal)
|
||||
self.assertEqual(diagnostic_result["diagnostics"]["leave_one_event_out_retention_rate"], 1)
|
||||
self.assertFalse(diagnostic_result["can_confirm_exact_minute"])
|
||||
|
||||
@@ -490,7 +497,10 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
self.assertEqual(candidate["rank"], rank)
|
||||
UUID(candidate["candidate_id"])
|
||||
|
||||
receipt = first["candidate_decision_receipt"]
|
||||
# 原值: first["candidate_decision_receipt"]
|
||||
# 新值: first["decision_receipt"](同一个对象)
|
||||
# 原因: BUG-1325,默认响应不再重复挂回执
|
||||
receipt = first["decision_receipt"]
|
||||
self.assertEqual(receipt["receipt_version"], "candidate-decision-receipt-v2")
|
||||
self.assertTrue(receipt["selection_allowed"])
|
||||
self.assertTrue(receipt["acceptance_allowed"])
|
||||
@@ -592,7 +602,8 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values):
|
||||
result = score_candidates(normalized)
|
||||
|
||||
receipt = result["candidate_decision_receipt"]
|
||||
# 原值: result["candidate_decision_receipt"];新值: result["decision_receipt"];原因: BUG-1325
|
||||
receipt = result["decision_receipt"]
|
||||
self.assertFalse(receipt["acceptance_allowed"])
|
||||
self.assertIn("insufficient_domain_diversity", receipt["reasons"])
|
||||
self.assertIn("tied_top_score", receipt["reasons"])
|
||||
@@ -637,7 +648,8 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values):
|
||||
result = score_candidates(normalized)
|
||||
|
||||
receipt = result["candidate_decision_receipt"]
|
||||
# 原值: result["candidate_decision_receipt"];新值: result["decision_receipt"];原因: BUG-1325
|
||||
receipt = result["decision_receipt"]
|
||||
self.assertTrue(receipt["acceptance_allowed"])
|
||||
self.assertTrue(receipt["selection_allowed"])
|
||||
self.assertFalse(receipt["confirmation_allowed"])
|
||||
@@ -678,7 +690,8 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values):
|
||||
result = score_candidates(normalized)
|
||||
|
||||
receipt = result["candidate_decision_receipt"]
|
||||
# 原值: result["candidate_decision_receipt"];新值: result["decision_receipt"];原因: BUG-1325
|
||||
receipt = result["decision_receipt"]
|
||||
self.assertTrue(result["candidate_decisions"])
|
||||
self.assertFalse(receipt["acceptance_allowed"])
|
||||
self.assertFalse(receipt["gates"]["date_quality"]["passed"])
|
||||
|
||||
@@ -242,7 +242,10 @@ def _collision_spans(path: str, source: str, rules: tuple[Rule, ...]) -> list[tu
|
||||
return []
|
||||
spans = []
|
||||
for node in ast.walk(functions[0]):
|
||||
if not isinstance(node, ast.Set) or node.lineno not in {1182, 1190}:
|
||||
# 原值: node.lineno not in {1182, 1190}
|
||||
# 新值: node.lineno not in {1195, 1203}
|
||||
# 原因: BUG-1325 在本文件前面加了三栏注释,这两处时钟集合整段下移,数值本身没变
|
||||
if not isinstance(node, ast.Set) or node.lineno not in {1195, 1203}:
|
||||
continue
|
||||
try:
|
||||
values = ast.literal_eval(node)
|
||||
|
||||
@@ -43,6 +43,11 @@ IDENTITY_FORBIDDEN_PREFIXES = (
|
||||
# 原因: ERR-110,章动修正与 scoring-12 改了冻结身份,另存新记录,旧记录留在清单外(BUG-1275)
|
||||
"docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08",
|
||||
"docs/research/reported_offset_ephemeris_nutation_2026_10_08",
|
||||
# 原值: 排除到 ephemeris_nutation_2026_10_08
|
||||
# 新值: 再排除 scoring_perf_2026_10_13 两份冻结路径
|
||||
# 原因: BUG-1323~1325 改了冻结身份文件,输出不变,ERR-110 另存新记录
|
||||
"docs/research/sealed_holdout_rerun_scoring_perf_2026_10_13",
|
||||
"docs/research/reported_offset_scoring_perf_2026_10_13",
|
||||
"docs/research/history/",
|
||||
"artifacts/",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user