Compare commits

..
Author SHA1 Message Date
jesse-uxandGrok (agent loop) 81e2e1cfed 服务器缓存按期限清理,审计快照默认不再落地
排盘缓存、官方快照、负缓存、网关任务和分盘段过期后会删掉。同一张盘的数字写法合成一把缓存键。失败快照只用短期限。自测留存、异步任务、增长合同、安全缓存和隐私标记;一万个文件的清理慢于 2 秒,命中率提升不到 2.5 倍,都按实测写入记录。

Co-Authored-By: Grok (agent loop)
2026-10-14 04:53:30 +08:00
50 changed files with 1800 additions and 46717 deletions
+2
View File
@@ -10,6 +10,8 @@ VEDASTRO_SELF_HOST_ENDPOINT=https://your-domain.example.com/vedastro
# VEDASTRO_ENABLE_NETWORK=0
# Cache / TTL / free-tier queue. These keep ordinary users from calling upstream directly.
# 失败结果另有短 TTL(VEDASTRO_OFFICIAL_FULL_SNAPSHOT_NEGATIVE_TTL_SECONDS,默认 300 秒,且不超过成功 TTL)。
# 这些缓存含出生资料,7 天为上限。
VEDASTRO_CACHE_TTL_SECONDS=604800
VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS=604800
VEDASTRO_GATEWAY_QUEUE_ENABLED=1
+3 -6
View File
@@ -1,12 +1,9 @@
# 印度占星 Skill 更新日志
## 2026-10-14 — 回答生成时侧栏不再跟着闪动 / 卡顿(待合入 staging)
## 2026-10-14 — 服务器不再长期保留出生资料副本
- 回答生成时侧栏不再跟着闪动 / 卡顿。点会话、改名、换人仍是当前这一下的操作。Skill 版本不变(BUG-1329)。
## 2026-10-14 — 校正每轮重算更快(待合入 staging)
- 校正每轮重算更快(2 小时窗口约快一倍以上),结果不变。一次要算的分钟数超过 241 会直接拒绝,不再占着计算空位排盘。Skill 版本不 bump(BUG-1323~1325、BUG-1345)。
- 排盘缓存、官方快照和相关临时文件会按期限删掉,不再一直堆在服务器上。审计用的快照文件默认不再落地。失败的官方快照只短时间复用。
- Skill 版本不变(BUG-1330~1332)。
## 2026-10-14 — 婚恋评级改成证据档位,KP 判宫和大运年长对齐(待合入 staging)
+37 -67
View File
@@ -17795,78 +17795,48 @@
- 复发自:无。
- 修复版本:未修复。
## 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-1329 | 流式回答期间首页侧栏每帧整栏重渲染
## BUG-1330 | 排盘缓存只进不出,同一张盘还会存成多份
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
- 来源:issue #59。任务书 `docs/tasks/TASK-home-sidebar-rerender-20261013.md`。
- 现象:回答逐字出来时,左侧会话列表跟着每一帧重画。会话越多越重。外观、顺序和点击结果没有变。
- 触发条件:首页已登录,回答按帧写入。每一帧都会让首页再渲染一次。
- 根因:BUG-971 切断了「首页订阅自己的注册」这条反馈,但注册仍然每次渲染都发布一份新对象。会话回调和可见列表每次渲染都是新的,外壳就跟着提交一次。不是回归,是当时留下的开销。
- 修复:外壳拿到的是稳定包装,点下去仍执行首页最新一次渲染的回调。注册只在会话、当前项、账户或开关这些数据变了才发布;列表逐条对象没变就不重建行。可见列表按当前人物记忆。侧栏上下文对象也不再每次新建。没有运行时开关。
- 验证:同一套计数测试。改前 60 帧注册 60 次、侧栏提交 60 次;改后注册 0 次、提交 0 次。换当前项、改标题、换人各发布 1 次。包装函数身份不变,调用记到的是最新一次渲染。详见 `docs/tasks/PROGRESS-home-sidebar-rerender-20261013.md`。2026-10-14 补了一条旧检查:账户菜单里的充值仍须打开 billing,来源仍是 account-menu,只是改认稳定包装的写法。
- 防复发:外壳注册须按数据变化发布。任何流式会话面要有侧栏提交次数的按帧断言。`frontend/tests/home-sidebar-rerender.test.tsx` 在 60 帧内要求注册 0 次、侧栏提交 0 次。
- 相关记录:BUG-971、BUG-725、BUG-473、BUG-744、BUG-745、BUG-746。
- 来源:issue #60。任务书 `docs/tasks/TASK-api-disk-cache-retention-20261013.md` T1、T3。
- 现象:排盘结果写在磁盘缓存里。过期只在读取时判断,文件一直留着。启动时的清理扫的是异步任务目录,不是这份缓存。同一张盘因为小时、经纬度写成数字或字符串,会各存一份。
- 触发条件:反复排盘,或同一次出生时间用不同写法提交。服务长时间不重启时,异步任务记录也会一直留到被轮询。
- 根因:写缓存的人只做了读取时的过期判断。写清理的人以为缓存和异步任务在同一个目录、同一种记录。缓存键直接用了请求里的原值,计算前却会先把这些值解析成数字。
- 修复:新的留存模块按缓存写入时间和排盘缓存期限删除过期文件,坏文件超过一天也删。启动时清理一次,之后按周期在后台再清。新写入的缓存文件改为仅本人可读,并先写临时文件再换上。缓存键把能解析的数字收成同一种写法,缓存格式版本从 5 改为 6,旧文件自然失效后被清掉。快速排盘和完整排盘仍然是两把不同的键。
- 验证:`tests/test_api_scratch_retention.py`、`tests/test_api_async_job_contract.py`、`tests/test_api_server_security.py -k cache`、`tests/test_api_server_growth_contract.py`。数值写法的逐字节比较见 `scripts/research/api_chart_cache_byte_diff.py`。
- 防复发:新增磁盘缓存必须同时在 `scripts/api_scratch_retention.py` 登记留存规则。`tests/test_api_scratch_retention.py` 会检查 `scripts/` 里新的 `scratch/local/` 目录有没有登记。
- 相关记录:BUG-161、BUG-1331、BUG-1332。
- 复发自:无。
- 修复版本:分支 `codex/home-sidebar-rerender-20261013`,尚未合入 staging。
- 修复版本:分支 `codex/api-disk-cache-retention-20261013`,尚未合入 staging。
## BUG-1345 | 打分接口没有候选数上限,超宽窗口会占着重计算槽
## BUG-1331 | VedAstro 审计文件留着出生资料,失败快照被缓存太久
- 状态: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。
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
- 来源:issue #60。任务书 T2。
- 现象:每次官方快照归一都会在磁盘留一份带缩进的审计文件,里面有出生时间。失败、不完整、被挡住的快照和成功结果用同一个长缓存期限。状态接口报出的期限没有算上回落值,可能和实际不一致。
- 触发条件:在线排盘或报告走到官方快照。样例环境把成功缓存写成 7 天时,一次失败也会被原样复用 7 天。
- 根因:审计落盘最早只给离线比对用,后来同一条归一进了在线请求,开关没有跟着关掉。语义缓存把「拿到一个结果」当成「可以长期复用」。状态接口自己读了一个变量,没有走实际生效的那个函数。
- 修复:审计落盘默认关闭,需要比对时再显式打开。打开时改为紧凑文件、仅本人可读、先写临时文件再换上。失败、不完整、被挡住的快照用短期限,默认 300 秒,并且不会长过成功期限。状态接口改为报出含回落的成功期限,并单独报出失败期限。过期的语义缓存、业务响应缓存和审计文件由留存模块删除。
- 验证:`tests/test_api_scratch_retention.py` 覆盖默认关闭、打开后的文件、失败 301 秒后不再命中、状态接口回落。既有 VedAstro 适配器测试仍通过。需要审计文件的那一条测试改为显式打开开关。
- 防复发:新增磁盘缓存必须同时在 `scripts/api_scratch_retention.py` 登记留存规则。
- 相关记录:BUG-734、BUG-301、BUG-1330。
- 复发自:无。
- 修复版本:分支 `codex/rect-scoring-perf-20261013`,尚未合入 staging。
- 修复版本:分支 `codex/api-disk-cache-retention-20261013`,尚未合入 staging。
## BUG-1332 | 前台快照、负缓存、网关任务和分盘段缓存没有清理
- 状态:fixed-pending-verify
- 首次发现 / 最近更新:2026-10-13 / 2026-10-14
- 来源:issue #60。任务书 T4。
- 现象:前台快照缓存超过回看窗口后不会再被读,但文件不删。负缓存只认当天,昨天的文件一直在。网关任务和分盘段缓存也只增不减。这些目录在持久卷上,部署不会清空。
- 触发条件:使用前台官方快照、咨询负缓存、网关队列或校正分盘段。
- 根因:当初只规定了什么时候还能读,没有规定什么时候可以删。
- 修复:前台快照早于「今天减去回看天数再减一天」就删。负缓存早于今天(UTC)就删。已完成或失败、并且超过保留时间的网关任务连同原始响应一起删;排队中和进行中的不删。分盘段按修改时间保留,默认 7 天。只删符合原有文件名的文件,其它文件不碰。读写和缓存键都没有改。
- 验证:`tests/test_api_scratch_retention.py` 覆盖这四个目录的留、删和不碰。`tests/test_vedastro_snapshot_cache.py` 与 `tests/test_vedastro_consultation_snapshot.py` 断言未改。
- 防复发:新增磁盘缓存必须同时在 `scripts/api_scratch_retention.py` 登记留存规则。
- 相关记录:BUG-727、BUG-1231、BUG-1330。
- 复发自:无。
- 修复版本:分支 `codex/api-disk-cache-retention-20261013`,尚未合入 staging。
-2
View File
@@ -451,8 +451,6 @@ 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 结果里有依赖集合 / 哈希顺序的结构进入了指纹。打分分数本身未见变化(未系统核对)。
@@ -1,118 +0,0 @@
{
"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
@@ -1,116 +0,0 @@
{
"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,51 @@
# PROGRESS · API 磁盘缓存留存(2026-10-14)
- 分支:`codex/api-disk-cache-retention-20261013`
- 任务书基线写的是 `77057a819`。本分支从当时的 `origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f` 拉出。A/B 的父提交用的是这个 SHA,不是任务书里的旧基线。
- 接口文件行数:10946(验收口径 ≤ 10970)。类方法数和伪造点没有增加。
## 做了什么
- T1:新增 `scripts/api_scratch_retention.py`。启动时清理全部已登记目录,并按 `JYOTISH_SCRATCH_PRUNE_INTERVAL_SECONDS`(默认 1800,0 表示不启后台线程)周期清理。排盘缓存新文件改为仅本人可读,先写临时文件再换上。`prune_expired_async_jobs` 只留同名薄包装。
- T2:`VEDASTRO_WRITE_AUDIT_ARTIFACTS` 默认关。关掉时不写审计文件,`artifact_path` 为 `None`。打开时写紧凑 JSON,仅本人可读,先写临时文件再换上。成功快照仍用原 TTL;失败、不完整、被挡住的用短 TTL,默认 300 秒,并且不超过成功 TTL。旧缓存没有 `negative` 标记时仍按成功的长期限读。状态接口改报含回落的成功期限,并多报失败期限。`.env.cn.example` 只加了注释,两个 604800 没改。
- 成功的判据:`available is True` 且 `status == "ok"`。`partial` 即使 `available` 为真也算失败缓存。
- T3:年月日能整除才收成整数,不截断 1990.5。时分秒和经纬度、时区能解析成有限数字就收成浮点,不四舍五入。解析不了的保持原值。日期取前 10 位,空值仍是空值。缓存格式版本 5 → 6。快速排盘和完整排盘仍是两把键。
- T4:前台快照、负缓存、网关任务、分盘段都进了登记表。只删符合原文件名的过期文件。网关排队中和进行中的不删。读写逻辑和键没改。
- T5:脚本在 `scripts/research/api_disk_cache_ab.py`,逐字节比较在 `scripts/research/api_chart_cache_byte_diff.py`。记录在 `docs/testing/api-disk-cache-retention-ab-20261013.md`。
- T6:没有改咨询卡片缓存。数字用任务书 §2.5,没有重测:未命中构建 1.9 秒;文件 5097751 字节;命中时解析中位 68.5 毫秒;整次命中 82.5 毫秒;两天大约每个活跃用户 10 MB。可选方向仍是两条:按领域切片只读需要的部分;清理改为每隔几分钟一次,而不是每次读取都扫全目录。没有做进程内已解析包缓存。
- T7:BUG-1330、BUG-1331、BUG-1332。变更说明一句。任务索引改为待验收。没有改界面说明。
## 命令和结果
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 新留存、异步任务、增长合同 | `pytest tests/test_api_scratch_retention.py tests/test_api_async_job_contract.py tests/test_api_server_growth_contract.py -q` | 26 通过 |
| 安全缓存 | `pytest tests/test_api_server_security.py -q -k cache` | 6 通过 |
| 任务书列出的 VedAstro / 咨询套件 | 见任务书 §8 那一条 | 4 失败,见下。其余通过 |
| 需要审计文件的执行器测试 | `pytest tests/test_vedastro_service_adapter_executor.py::test_vedastro_range_scan_records_hashes_and_artifact_path -q` | 1 通过 |
| 逐字节比较与 A/B | `python scripts/research/api_disk_cache_ab.py` | 退出码 0。命中率 B/A = 1.333。七天后 B 剩 40 个文件,等于当天键数。审计文件 0。负缓存剩 1。一万个文件的清理 20.174 秒 |
| 接口行数 | 按换行计数 | 10946 |
既有断言改动只有一处,在 `tests/test_vedastro_service_adapter_executor.py`:原值是子进程沿用进程环境;新值是显式打开审计落盘;原因是默认关闭后这条测试仍要检查文件存在。这是测试前置,不是放宽断言。
## 未达到的目标(目标不改)
- 混合写法再重复 3 次时,命中率只是 A 的 1.333 倍,不是 2.5 倍。重复本身已经让 A 命中三分之二。
- 一万个过期文件的清理用了 20.174 秒,不是 2 秒内。清理不在请求线程。默认间隔和失败短期限没有因此改掉。
## 本机套件里原先就会红的三条
- `tests/test_vedastro_gateway.py` 要求原始响应文件权限是 0600。Windows 报出来的是可写,不是 0600。这条断言没改。新测试在 Linux 上仍检查 0600,Windows 上只检查文件写出来了。
- `tests/test_vedastro_runtime_ops.py` 要求已安装的 VedAstro 是 1.23.25,本机是 1.23.30。与这次缓存改动无关。
- `tests/test_three_engine_parity_runner.py` 的公开盘回放把 PyJHora 记成 raw_imported,不是 structured_captured,同时在下载星历。运行器没改。
## 环境缺口
- 生产和测试环境里成功缓存的实际秒数,这份仓库读不到。部署后看状态接口的两个期限数字即可。
- 生产卷上已经堆了多少文件,这里不知道。
- 主测量挡住了 VedAstro 导入。本机有 SDK,官方端口能连通。不挡 SDK 的探针仍关闭外网取数,一次完整排盘 4.167 秒,没有报错,没有拉真实快照。
## 未做
- 没有改部署文件、生产环境变量、数据库,也没有手工清生产卷。
- 没有把咨询卡片缓存并进周期清理。
@@ -1,70 +0,0 @@
# PROGRESS · 流式回答时侧栏不再跟着每帧重画(issue #59)
- 分支:`codex/home-sidebar-rerender-20261013`
- 实际基线:`origin/staging` `66cd60081f37a57680274633fcb3fb942d01bb2f`
- 任务书基线写成 `77057a819`,计划评论里的 base 是 `2994588ad4d858dead4835943e5883c5aa2b03f8`。开工时远端 staging 已是上面的 `66cd6008`。A/B 就跑在这个提交上,没有另开只读工作树(本轮不能离开当前工作树)。方向没有改,不需要拍板。
- `docs/BUG_HISTORY.md` 在这个基线上停在 BUG-1322。BUG-1323~1328、1345、1346 属于还没合入的别的分支,本单只用 BUG-1329。
- 未改 `page.tsx`。没有运行时开关。没有启用 React Compiler。Skill 版本不变。浏览器 A/B 未跑。
## 做了什么
外壳拿到的点击函数不再每次渲染换新,点下去仍执行首页刚渲染的那一版。注册只在数据变了才发给侧栏:会话行要逐条还是同一个对象才算没变,没变就不重建行。可见会话按当前人物记忆。侧栏自己的上下文对象也不再每次新建。卸载首页时仍会清掉注册。
## 断言三栏
| 位置 | 原值 | 新值 | 原因 |
| --- | --- | --- | --- |
| `frontend/tests/session-list-lifecycle.test.tsx` 外壳「打开资料」函数 | `assert.notEqual(onOpenProfile, initialCallback)` | `assert.equal(...)`;再改一版后仍是同一个函数,调用记录从 `[1]` 加强到 `[1, 2]` | 包装函数身份稳定。调用必须落到最新一次渲染,不能只删掉「不相等」 |
`HomeProbe` 仍是每次渲染新回调、新数组,没有改成常量。
## 基线失败(改代码之前,同一测试,3 次,退出码都是 1)
失败停在 `assert.equal(duringFrames.registers, 0)`,实际是 60。60 帧里侧栏提交也是 60。换当前项、改标题、换人各注册 1 次;只改回调版本也会再注册 1 次。剔除函数后的内容摘要:
| 档 | 60 帧注册 | 60 帧提交 | 60 帧耗时三次(毫秒) | 中位 | 内容摘要 |
| --- | --- | --- | --- | --- | --- |
| 30 条虚构会话 | 60 | 60 | 2774.964 / 2778.961 / 4202.085 | 2778.961 | `b18f05a3d83be5b41584787b7c0495f1fcec8ef7b8ecfbdbb3c083c40796c103` |
| 100 条虚构会话 | 60 | 60 | 10530.828 / 11799.253 / 12284.103 | 11799.253 | `cef0a63d6f3fe72e541f9ac882b8eaf305b6933fdd07ca3002e8cd03bb139d25` |
更早一次探针把注册函数也做成每次渲染新建,60 帧数到 120 次注册、60 次提交。那是清理函数跟着新函数又清了一次,不是产品路径。交付用的测试和生产一样,用提供者给出的稳定注册函数,所以改前是 60 而不是 120。
## 改后(同一测试,3 次,退出码都是 0)
| 档 | 60 帧注册 | 60 帧提交 | 60 帧耗时中位 | 换当前项 | 改标题 | 只改回调版本 | 换人 | 内容摘要 |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| 30 | 0 | 0 | 0 | 注册 1、提交 1 | 注册 1,标题更新 | 注册 0,调用记到第 4 版 | 注册 1,列表换成另一人 | 与改前相同 |
| 100 | 0 | 0 | 0 | 注册 1、提交 1 | 注册 1,标题更新 | 注册 0,调用记到第 4 版 | 注册 1,列表换成另一人 | 与改前相同 |
三次摘要都与上表相同。门槛达到:60 帧内注册 0、侧栏提交 0,内容摘要逐字节一致。
## 自测
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `node frontend/node_modules/typescript/bin/tsc --noEmit -p frontend` | 退出码 0 |
| 规范 | `npm run lint --prefix frontend` | 0 error,126 条原有 warning。本单文件没有新增 warning,没有 `eslint-disable` |
| 定向测试 | `tsx --test` 下表 10 个文件 | tests 94,pass 94,fail 0,cancelled 0 |
| 数据库 | 未跑 | 本单不碰数据库 |
| 浏览器 | 未跑 | 浏览器 A/B 未跑。步骤在 `docs/testing/home-sidebar-rerender-20261013.md` |
| 隐私标记 | `.venv\Scripts\python.exe -m pytest tests/test_repo_privacy_markers.py -q` | 通过 |
定向文件:`home-sidebar-rerender`、`session-list-lifecycle`、`session-list-provider`、`sidebar-contract`、`sidebar-state`、`secondary-page-entry`、`account-dialog-inert-20260918`、`home-shell-growth-contract`、`home-streaming-render-split`、`chat-profile-picker`。
全量 `npm test`、`next build`、Python 快速门未跑,留给验收。
## 2026-10-14 修复单
按修复单合并了最新 `origin/staging`。只改了 `frontend/tests/chat-navigation-a11y-contract.test.ts` 里账户菜单「充值」那一条源码断言,改成认稳定包装的写法,仍然要求打开 billing 且来源是 `account-menu`。业务代码没有改。
又搜了一遍 `onOpenProfile`、`onOpenGeneral`、`onOpenLogout`、`onNewChat`、`onSelectSession`、`onLoadMore`。测试里其余命中的是侧栏组件自己的入口,或校正点数不足时打开充值,不是这条旧写法,没有改。
和上一版 `63509431` 比,网页业务目录里多出来的只有合并测试线带入的校正提速(`engine-client.ts`),不是这单写的。这单自己的提交不碰 `frontend/src`。
| 检查 | 命令 | 结果 |
| --- | --- | --- |
| 类型 | `tsc --noEmit -p frontend` | 通过,退出码 0 |
| 规范 | `npm run lint`(在 frontend) | 0 error,125 条原有 warning。本单改的测试文件没有新增 warning |
| 这条旧检查 | `tsx --test tests/chat-navigation-a11y-contract.test.ts` | 13 通过,0 失败,0 取消 |
全量 `npm test` 仍留给验收。
@@ -1,97 +0,0 @@
# 校正打分提速进度(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 快速门。
+3 -5
View File
@@ -467,12 +467,10 @@
| 任务书 | 进度 | 主题 | 状态 | 落点 |
| --- | --- | --- | --- | --- |
| `TASK-rect-scoring-perf-20261013.md` | `PROGRESS-rect-scoring-perf-20261013.md` | 校正打分提速:引导窗口平方扫描、候选不变量重复算、响应带无读取点字段、候选数上限 241(BUG-1323~1325、1345) | 待验收 | `codex/rect-scoring-perf-20261013` |
| `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-node-runtime-resources-fix-20261014.md` | — | **#58 第 1 次打回**:`4c3485bc` 其余全过;压测脚本放在 `frontend/scripts/` 却从 `frontend/tests/` 导入,镜像构建会失败(BUG-1140 形状,任务书指定的路径有误)。挪到 `frontend/tests/` 下非 `.test` 文件名 | 待领取 | `codex/node-runtime-resources-20261013` |
| `TASK-home-sidebar-rerender-20261013.md` | `PROGRESS-home-sidebar-rerender-20261013.md` | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待验收 | `codex/home-sidebar-rerender-20261013` |
| `TASK-home-sidebar-rerender-fix-20261014.md` | `PROGRESS-home-sidebar-rerender-20261013.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-home-sidebar-rerender-20261013.md` | — | 流式回答时侧栏每帧整栏重渲染(BUG-1329,A/B) | 待领取 | `codex/home-sidebar-rerender-20261013` |
| `TASK-api-disk-cache-retention-20261013.md` | `PROGRESS-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` |
| `TASK-frontend-dead-code-20261013.md` | — | 前端死代码 17 文件与无调用方接口删除,含未登录重计算接口(BUG-1342) | 待领取 | `codex/frontend-dead-code-20261013` |
@@ -1,36 +0,0 @@
# 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 次打回;再打回会停下等产品负责人。
@@ -1,39 +0,0 @@
# 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,66 @@
# 磁盘缓存留存 A/B(2026-10-14)
对照的是本分支的父提交 `66cd60081f37a57680274633fcb3fb942d01bb2f`(建分支时的 `origin/staging`)。任务书里写的 `77057a819` 已经不是当前测试线头。A 用 `git show` 把父提交的接口文件载入同一进程,并把它的仓库路径指回本工作树,否则临时副本找不到引擎。B 是本分支。
演练用虚构盘,不写真实出生资料。文件都在系统临时目录,不写工作树里的 `scratch/`。
主测量挡住了 `jhora` 和 `vedastro` 的导入。本机装了 VedAstro SDK,并且能连上官方端口;另有一组不挡 SDK、但仍关闭外网取数,见文末。
## 命令
```powershell
.venv\Scripts\python.exe scripts\research\api_chart_cache_byte_diff.py
.venv\Scripts\python.exe scripts\research\api_disk_cache_ab.py
.venv\Scripts\python.exe scripts\research\api_disk_cache_ab.py --with-sdk --sdk-probe
```
## 命中率
20 张虚构盘 × 3 种写法 × 快速/完整 × 7 天 × 每种重复 3 次 = 2520 次取键。取键用的是真实缓存键函数,没有把 2520 次都做成完整排盘。
| | 改前 A | 改后 B | 变化 |
| --- | --- | --- | --- |
| 不同缓存键 | 840 | 280 | 少 560 |
| 命中次数 | 1680 | 2240 | 多 560 |
| 命中率 | 0.6667 | 0.8889 | B / A = 1.333 |
目标是混合写法下 B 至少为 A 的 2.5 倍。按任务书规定的「重复 3 次」算,没有达到:A 自己靠重复就已经有三分之二命中,写法合并只能再抬到约 89%。若不算这 3 次重复、只看三种写法各问一次,A 的命中率是 0,B 是 0.6667。目标数字不改。
## 七天后的文件
留存演练写的是短记录,用来数文件,不是完整盘面。期限 900 秒,只有当天的键还没过期。
| | 改前 A | 改后 B |
| --- | --- | --- |
| 清理前文件数 | 840 | 280 |
| 清理前字节 | 109200 | 36400 |
| 清理后文件数 | 840(旧代码没有这份清理) | 40 |
| 当天仍有效的键 | 120 | 40 |
B 清理后的文件数等于当天有效键数,达到「不超过当天活跃键」的目标。
审计文件:B 在默认关闭时写出 0 个。达到目标。
负缓存:种入当天和前一天各一份,清理后剩 1 份(当天)。达到目标。
## 清理耗时
| 场景 | 结果 |
| --- | --- |
| 预置 10000 个已过期排盘缓存后,整次清理 | 20.174 秒,排盘目录 20170.902 毫秒,删掉 10000,剩下 0 |
| 上面七天演练里的排盘目录 | 370.161 毫秒 |
目标是 10000 个文件在 2 秒内、且不在请求线程。本次在后台函数里测,不在请求线程;耗时没有进 2 秒。这是 Windows 上逐个读文件再删除的实测,目标不改。默认清理间隔仍是 1800 秒,失败快照短期限仍是 300 秒,不因为这次没进 2 秒就改默认值。
## 排盘耗时(一次虚构盘,快速排盘,外网关闭)
| | 中位数 |
| --- | --- |
| 未命中 3 次 | 23.036 毫秒 |
| 命中 3 次 | 5.249 毫秒 |
## 响应是否还是同一张盘
数值等价的写法(小时 12 / 12.0 / "12",纬度数字和字符串,时区 5.5 和 "5.5",秒缺省和 0)在快速排盘下去掉 `runtime_cache` 后逐字节相同。完整排盘只差官方快照缓存上的写入时刻和到期时刻,盘面没有别的差异。父提交和本分支对同一张快速排盘、去掉 `runtime_cache` 后逐字节相同。因此这些字段可以合并进同一把缓存键。
不挡 SDK 的一组:本机有 VedAstro SDK,官方端口能连通。探针仍关闭外网取数。一次完整排盘 4.167 秒,没有报错。没有拉取真实快照。
@@ -1,12 +0,0 @@
# 真机走查 · 回答生成时侧栏不再跟着闪(BUG-1329,issue #59)
浏览器 A/B 未跑。部署到 staging 后,在 `https://staging.jyotisha.chat` 登录一个没有真实会话内容需要露出的账号,按顺序看:
1. 电脑浏览器打开首页。左侧会话列表的样子、顺序、分组和以前一样。
2. 发一个普通问题,等回答一个字一个字出来。这段时间左侧列表不跟着闪、不跟着整栏重画。回答本身仍是逐字出现。
3. 回答过程中点另一条会话,能打开那一条。点当前这条的菜单,改名、置顶、分享、删除仍可用,改的是刚刚点的那一条。
4. 右上角账户菜单仍能打开资料、通用、账单和退出。
5. 换一个人物后,左侧列表换成那个人的会话,不串到别人的。
6. 手机宽度再看一遍第 2 步。侧栏抽屉的开关和以前一样。
任何一步不对,在 issue #59 里写是哪一步、哪台设备,并把标签改回 `agent/待规划`。不要贴出真实会话内容。
@@ -1,18 +0,0 @@
# A/B · 回答生成时侧栏重画(BUG-1329,issue #59)
同一测试 `frontend/tests/home-sidebar-rerender.test.tsx`,同一输入。A 是改代码前的 `66cd60081f37a57680274633fcb3fb942d01bb2f`(任务书写的 `77057a819` 已被后续提交盖过)。B 是分支 `codex/home-sidebar-rerender-20261013`。各跑 3 次。会话都是虚构的「Synthetic session N」。没有真实会话内容。
度量是 60 帧里的注册次数、侧栏提交次数、这 60 帧的 Profiler 耗时合计。内容用剔除函数字段后的 SHA-256 比较。
| 档 | A 注册 / 提交 / 耗时中位 | B 注册 / 提交 / 耗时中位 | 内容 |
| --- | --- | --- | --- |
| 30 条 | 60 / 60 / 2778.961 毫秒 | 0 / 0 / 0 | 两边都是 `b18f05a3d83be5b41584787b7c0495f1fcec8ef7b8ecfbdbb3c083c40796c103` |
| 100 条 | 60 / 60 / 11799.253 毫秒 | 0 / 0 / 0 | 两边都是 `cef0a63d6f3fe72e541f9ac882b8eaf305b6933fdd07ca3002e8cd03bb139d25` |
A 的三次 30 条耗时:2774.964、2778.961、4202.085。100 条:10530.828、11799.253、12284.103。B 的三次都是 0。
正确性:换当前项后 B 注册 1 次,外壳看到新的当前项。改一行标题后标题更新。换成另一人的会话后列表换掉。只改回调版本不再注册,点「打开会话」和「改名」记到的都是第 4 版。A 在只改回调版本时还会再注册 1 次;B 不注册,但调用结果相同。
判定:B 在 60 帧内注册 0、侧栏提交 0,内容摘要一致,`session-list-lifecycle` 全绿。可以交付。
浏览器 A/B 未跑。
-1
View File
@@ -551,7 +551,6 @@ one skeleton, none of them carrying the sidebar.
`children` through unchanged. Registering fresh controls/rows must not render
their producer again: that feedback loop can starve client navigation.
Business changes still publish current callbacks; Home unmount clears them.
流式期间侧栏不随回答帧重渲染(BUG-1329)。
- **One sidebar component, two modes.** `AppSidebar` is the only sidebar in the
product. `/` passes `controls`; the secondary layout does not, and without it
the same markup renders read-only. There used to be a second component for
+4 -14
View File
@@ -8,7 +8,6 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
@@ -170,13 +169,7 @@ export function SidebarProvider({
else setOpen(!open);
}, [isMobile, open, openMobile, setOpen, setOpenMobile]);
const setInsetTrigger = useCallback((element: HTMLButtonElement | null) => {
insetTriggerRef.current = element;
}, []);
const setSidebarSurface = useCallback((element: HTMLElement | null) => {
sidebarSurfaceRef.current = element;
}, []);
const context = useMemo<SidebarProviderContextValue>(() => ({
const context: SidebarProviderContextValue = {
state: open ? "expanded" : "collapsed",
open,
setOpen,
@@ -186,12 +179,9 @@ export function SidebarProvider({
ready,
isMobile,
toggleSidebar,
setInsetTrigger,
setSidebarSurface,
}), [
open, openMobile, viewport, ready, isMobile, setOpen, setOpenMobile, toggleSidebar,
setInsetTrigger, setSidebarSurface,
]);
setInsetTrigger: (element) => { insetTriggerRef.current = element; },
setSidebarSurface: (element) => { sidebarSurfaceRef.current = element; },
};
return (
<SidebarContext.Provider value={context}>
+30 -152
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, type Dispatch, type RefObject, type SetStateAction } from "react";
import { useEffect, type Dispatch, type RefObject, type SetStateAction } from "react";
import type { ChatSession } from "@/lib/home-types";
import type { Account } from "@/lib/home-types";
@@ -43,68 +43,6 @@ type HomeShellRegistrationParams = {
visibleSessions: readonly ChatSession[];
};
type LatestShellActions = {
sessions: readonly ChatSession[];
loadMoreSessions: () => void;
renameSession: (session: ChatSession) => void | Promise<void>;
selectSession: (sessionId: string) => void;
shareSession: (session: ChatSession) => void | Promise<void>;
startNewChat: () => void | Promise<unknown>;
togglePinnedSession: (sessionId: string) => void;
openAccountDialog: (dialog: AccountDialogName, options?: { source?: string }) => void;
setSessionMenuId: Dispatch<SetStateAction<string | null>>;
setAccountMenuOpen: Dispatch<SetStateAction<boolean>>;
setPendingSessionDeletion: Dispatch<SetStateAction<ChatSession | null>>;
};
type PublishedShellInputs = {
hydrated: boolean;
account: Account;
profileName: string;
accountMenuOpen: boolean;
modalOpen: boolean;
activeSessionId: string | undefined;
rectificationOpeningSessionId: string | null;
rectificationSurfaceOpen: boolean;
creatingSession: boolean;
pendingSessionId: string | null;
cancellationPending: boolean;
modelCatalog: PublicLanguageModelCatalog | null;
sessionMenuId: string | null;
sessionsCursor: string | null;
accountTrigger: RefObject<HTMLButtonElement | null>;
chartLibrary: readonly ChartLibraryRecord[];
visibleSessions: readonly ChatSession[];
};
function sameVisibleSessions(left: readonly ChatSession[], right: readonly ChatSession[]) {
if (left.length !== right.length) return false;
for (let index = 0; index < left.length; index += 1) {
if (left[index] !== right[index]) return false;
}
return true;
}
function samePublishedInputs(previous: PublishedShellInputs, next: PublishedShellInputs) {
return previous.hydrated === next.hydrated
&& previous.account === next.account
&& previous.profileName === next.profileName
&& previous.accountMenuOpen === next.accountMenuOpen
&& previous.modalOpen === next.modalOpen
&& previous.activeSessionId === next.activeSessionId
&& previous.rectificationOpeningSessionId === next.rectificationOpeningSessionId
&& previous.rectificationSurfaceOpen === next.rectificationSurfaceOpen
&& previous.creatingSession === next.creatingSession
&& previous.pendingSessionId === next.pendingSessionId
&& previous.cancellationPending === next.cancellationPending
&& previous.modelCatalog === next.modelCatalog
&& previous.sessionMenuId === next.sessionMenuId
&& previous.sessionsCursor === next.sessionsCursor
&& previous.accountTrigger === next.accountTrigger
&& previous.chartLibrary === next.chartLibrary
&& sameVisibleSessions(previous.visibleSessions, next.visibleSessions);
}
export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
const {
account,
@@ -137,27 +75,6 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
togglePinnedSession,
visibleSessions,
} = params;
const profileName = profile.name;
const latestRef = useRef<LatestShellActions | null>(null);
const publishedRef = useRef<PublishedShellInputs | null>(null);
useEffect(() => {
// No dependency list: this has to land after every render and before the
// publish effect, so a later click runs this render's callbacks (BUG-725).
latestRef.current = {
sessions,
loadMoreSessions,
renameSession,
selectSession,
shareSession,
startNewChat,
togglePinnedSession,
openAccountDialog,
setSessionMenuId,
setAccountMenuOpen,
setPendingSessionDeletion,
};
});
useEffect(() => {
// The inline dead-screen script stands down only after the app has revealed.
@@ -167,59 +84,12 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
document.documentElement.dataset.hydrated = "1";
}, [hydrated]);
const stableControls = useMemo(() => ({
onLoadMore: () => { latestRef.current?.loadMoreSessions(); },
onMenuSessionChange: (sessionId: string | null) => { latestRef.current?.setSessionMenuId(sessionId); },
onTogglePinned: (sessionId: string) => { latestRef.current?.togglePinnedSession(sessionId); },
onRename: (sessionId: string) => {
const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId);
if (session) void latestRef.current?.renameSession(session);
},
onShare: (sessionId: string) => {
const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId);
if (session) void latestRef.current?.shareSession(session);
},
onDelete: (sessionId: string) => {
const session = latestRef.current?.sessions.find((candidate) => candidate.id === sessionId);
if (session) latestRef.current?.setPendingSessionDeletion(session);
},
onAccountMenuOpenChange: (open: boolean) => { latestRef.current?.setAccountMenuOpen(open); },
onNewChat: () => { void latestRef.current?.startNewChat(); },
onSelectSession: (sessionId: string) => { latestRef.current?.selectSession(sessionId); },
onOpenProfile: () => { latestRef.current?.openAccountDialog("profile"); },
onOpenGeneral: () => { latestRef.current?.openAccountDialog("general"); },
onOpenBilling: () => { latestRef.current?.openAccountDialog("billing", { source: "account-menu" }); },
onOpenLogout: () => { latestRef.current?.openAccountDialog("logout"); },
}), []);
useEffect(() => {
if (!hydrated || !account) {
publishedRef.current = null;
registerShellControls(null);
return;
}
const next: PublishedShellInputs = {
hydrated,
account,
profileName,
accountMenuOpen,
modalOpen,
activeSessionId,
rectificationOpeningSessionId,
rectificationSurfaceOpen,
creatingSession,
pendingSessionId,
cancellationPending,
modelCatalog,
sessionMenuId,
sessionsCursor,
accountTrigger,
chartLibrary,
visibleSessions,
};
if (publishedRef.current && samePublishedInputs(publishedRef.current, next)) return;
publishedRef.current = next;
const name = profileName.trim();
const name = profile.name.trim();
const email = account.user.email || "尚未读取邮箱";
registerShellControls({
escapeBlocked: accountMenuOpen || modalOpen,
@@ -242,34 +112,42 @@ export function useHomeShellRegistration(params: HomeShellRegistrationParams) {
accountTriggerRef: accountTrigger,
sessionControls: {
hasMore: Boolean(sessionsCursor),
onLoadMore: stableControls.onLoadMore,
onLoadMore: loadMoreSessions,
menuSessionId: sessionMenuId,
disabled: Boolean(pendingSessionId) || cancellationPending,
onMenuSessionChange: stableControls.onMenuSessionChange,
onTogglePinned: stableControls.onTogglePinned,
onRename: stableControls.onRename,
onShare: stableControls.onShare,
onDelete: stableControls.onDelete,
onMenuSessionChange: setSessionMenuId,
onTogglePinned: togglePinnedSession,
onRename: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void renameSession(session);
},
onShare: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) void shareSession(session);
},
onDelete: (sessionId) => {
const session = sessions.find((candidate) => candidate.id === sessionId);
if (session) setPendingSessionDeletion(session);
},
},
onAccountMenuOpenChange: stableControls.onAccountMenuOpenChange,
onNewChat: stableControls.onNewChat,
onSelectSession: stableControls.onSelectSession,
onOpenProfile: stableControls.onOpenProfile,
onOpenGeneral: stableControls.onOpenGeneral,
onOpenBilling: stableControls.onOpenBilling,
onOpenLogout: stableControls.onOpenLogout,
onAccountMenuOpenChange: setAccountMenuOpen,
onNewChat: () => { void startNewChat(); },
onSelectSession: selectSession,
onOpenProfile: () => openAccountDialog("profile"),
onOpenGeneral: () => openAccountDialog("general"),
onOpenBilling: () => openAccountDialog("billing", { source: "account-menu" }),
onOpenLogout: () => openAccountDialog("logout"),
},
});
}, [
account, accountMenuOpen, accountTrigger, activeSessionId, cancellationPending,
chartLibrary, creatingSession, hydrated, modalOpen, modelCatalog,
pendingSessionId, profileName,
chartLibrary, creatingSession, hydrated, loadMoreSessions, modalOpen, modelCatalog,
openAccountDialog, pendingSessionId, profile.name,
rectificationOpeningSessionId, rectificationSurfaceOpen,
registerShellControls, sessionMenuId, sessionsCursor, stableControls, visibleSessions,
registerShellControls, renameSession, selectSession, sessionMenuId, sessions,
sessionsCursor, setAccountMenuOpen, setPendingSessionDeletion, setSessionMenuId,
shareSession, startNewChat, togglePinnedSession, visibleSessions,
]);
useEffect(() => () => {
publishedRef.current = null;
registerShellControls(null);
}, [registerShellControls]);
useEffect(() => () => registerShellControls(null), [registerShellControls]);
}
+4 -5
View File
@@ -1,7 +1,7 @@
"use client";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { showChatNotice as setComposerNotice } from "@/lib/chat-notice";
import { writeChatSession } from "@/lib/chat-session-write-contract";
@@ -12,7 +12,7 @@ import {
writeSessionUrl,
} from "@/lib/chat-session-url";
import { sessionMatchesSubject } from "@/lib/chat-profile-picker-model";
import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog, useCurrentSubject } from "@/lib/current-subject";
import { currentSubjectCatalogReady, readCurrentSubjectId, readCurrentSubjectScope, subscribeCurrentSubject, subscribeSubjectCatalog } from "@/lib/current-subject";
import { mergeSubjectSessionRows } from "@/lib/subject-session-merge";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
import { fallbackSessionId } from "@/lib/rectification-session-composer-guard";
@@ -181,9 +181,8 @@ export function useSessionManagement(params: SessionManagementParams) {
if (!isUnsavedEmptyConsultation(session)) cloudCreatedIds.current.add(session.id);
}
const subjectId = useCurrentSubject();
const visibleSessions = useMemo(() => sortSessions(sessions.filter((session) => isListedSidebarSession(session)
&& sessionMatchesSubject(session, subjectId))), [sessions, subjectId]);
const visibleSessions = sortSessions(sessions.filter((session) => isListedSidebarSession(session)
&& sessionMatchesSubject(session, readCurrentSubjectId())));
function updateSession(sessionId: string, change: (session: ChatSession) => ChatSession) {
setSessions((current) => current.map((session) => (session.id === sessionId ? change(session) : session)));
@@ -330,27 +330,17 @@ async function readEngineJsonTimed(
const elapsedMs = Date.now() - started;
const data = await response.json().catch(() => null);
if (!response.ok) {
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";
const kind: EngineCallFailureKind = response.status === 429 ? "busy" : "http_error";
logEngineCallFailure({
path,
kind,
elapsedMs,
httpStatus: response.status,
});
const message = errorText || `Jyotish API ${response.status}`;
throw new RectificationEngineError(tooManyCandidates ? "too_many_candidates" : persistenceCodeForKind(kind), message, {
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, {
kind,
httpStatus: response.status,
retryAfterSeconds: retryAfterFromHeaders(response.headers) ?? undefined,
@@ -39,10 +39,7 @@ test("in-app destinations navigate client-side so the chat survives the round tr
assert.match(sidebarSource, /<SidebarMenuLink\n\s*className="report-nav-button"/);
assert.doesNotMatch(sidebarSource, /window\.location/);
// 原值: /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/
// 新值: /onOpenBilling: \(\) => \{ latestRef\.current\?\.openAccountDialog\("billing", \{ source: "account-menu" \}\); \}/
// 原因: 侧栏按钮改成稳定包装,点下去仍打开充值,来源仍是账户菜单。不得放宽成只查 "billing"。
assert.match(pageSource, /onOpenBilling: \(\) => \{ latestRef\.current\?\.openAccountDialog\("billing", \{ source: "account-menu" \}\); \}/);
assert.match(pageSource, /onOpenBilling: \(\) => openAccountDialog\("billing", \{ source: "account-menu" \}\)/);
assert.match(pageSource, /openAccountDialog\("billing", \{ returnTarget: event\.currentTarget, source: "credits" \}\)/);
assert.match(pageSource, /openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/);
// 原值: /if \(response\.status === 402\) openAccountDialog\("billing", \{ source: "insufficient-credits" \}\)/
@@ -1,304 +0,0 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import test from "node:test";
import { Profiler, useCallback, useEffect, useState, type ProfilerOnRenderCallback, type ReactNode } from "react";
import { AppSidebar } from "../src/components/app-sidebar.tsx";
import { SidebarInset, SidebarProvider } from "../src/components/ui/sidebar.tsx";
import { useHomeShellRegistration } from "../src/hooks/use-home-shell-registration.ts";
import { resetSubjectCatalogForTests } from "../src/lib/current-subject.ts";
import { emptyProfile, type Account, type ChatSession } from "../src/lib/home-types.ts";
import {
SessionListProvider,
sessionListSidebarModel,
useSessionList,
useShellRegistration,
type ShellRegistration,
} from "../src/lib/session-list-context.tsx";
import { createClientLifecycleHarness } from "./react-client-lifecycle-test-support.ts";
const account: Account = {
user: { id: "11111111-1111-4111-8111-111111111111", email: null },
profile: { ...emptyProfile, name: "Synthetic" },
avatar: null, credits: 10, isAdmin: false, adminUrl: null,
rectificationPriceCredits: 0, activeSubscription: null,
hasConfirmedBirthTime: false, hasUsableBirthTime: false,
};
const trigger = { current: null };
const charts: never[] = [];
function syntheticSession(index: number, title = `Synthetic session ${index}`): ChatSession {
return {
id: `session-${index}`,
title,
theme: "general",
modelId: "test",
messages: [],
createdAt: 1,
updatedAt: 1_700_000_000_000 + index,
sessionType: "consultation",
rectificationCaseId: null,
chartProfileId: null,
chartProfileName: null,
chartProfileRole: null,
pinned: false,
archivedAt: null,
messagesHydrated: false,
};
}
function registrationData(registration: ShellRegistration | null) {
if (!registration) return null;
return {
activeSessionId: registration.activeSessionId,
openingSessionId: registration.openingSessionId,
escapeBlocked: registration.escapeBlocked,
insetClassName: registration.insetClassName,
insetInert: registration.insetInert,
account: registration.sidebarAccount ?? null,
sessions: registration.sidebarSessions ?? [],
newChatDisabled: registration.controls.newChatDisabled,
creatingSession: registration.controls.creatingSession,
accountMenuOpen: registration.controls.accountMenuOpen,
hasMore: registration.controls.sessionControls.hasMore ?? false,
menuSessionId: registration.controls.sessionControls.menuSessionId,
disabled: registration.controls.sessionControls.disabled,
};
}
function payloadSha(data: ReturnType<typeof registrationData>) {
return createHash("sha256").update(JSON.stringify(data)).digest("hex");
}
async function runProbe(sessionCount: number) {
const harness = createClientLifecycleHarness();
resetSubjectCatalogForTests();
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
const storageValues = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (key: string) => storageValues.get(key) ?? null,
setItem: (key: string, value: string) => { storageValues.set(key, String(value)); },
removeItem: (key: string) => { storageValues.delete(key); },
clear: () => { storageValues.clear(); },
key: (index: number) => Array.from(storageValues.keys())[index] ?? null,
get length() { return storageValues.size; },
} satisfies Storage,
});
const originalFetch = globalThis.fetch;
const originalIntersection = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
class FakeIntersectionObserver {
observe() {}
disconnect() {}
unobserve() {}
}
Object.defineProperty(globalThis, "IntersectionObserver", {
configurable: true,
writable: true,
value: FakeIntersectionObserver,
});
globalThis.fetch = (async (input: unknown) => {
const url = String(input);
const payload = url === "/api/account" ? account
: url === "/api/chart-profiles" ? { profiles: [] }
: { sessions: [], nextCursor: null };
return new Response(JSON.stringify(payload), { status: 200 });
}) as typeof fetch;
const metrics = { registers: 0, commits: 0, actualDuration: 0, recording: false };
const selectRevisions: number[] = [];
const renameRevisions: number[] = [];
const driver: {
setFrame: (frame: number) => void;
setActive: (id: string) => void;
retitle: () => void;
switchPerson: () => void;
setRevision: (revision: number) => void;
} = {
setFrame: () => {},
setActive: () => {},
retitle: () => {},
switchPerson: () => {},
setRevision: () => {},
};
let latestShell: ShellRegistration | null = null;
const currentShell = (): ShellRegistration | null => latestShell;
const initialSessions = Array.from({ length: sessionCount }, (_, index) => syntheticSession(index));
const otherPerson = [syntheticSession(sessionCount + 1, "Other person session")];
function HomeProbe() {
const list = useSessionList();
const [frame, setFrame] = useState(0);
const [activeSessionId, setActive] = useState(initialSessions[0]?.id ?? null);
const [sessions, setSessions] = useState(initialSessions);
const [revision, setRevision] = useState(0);
// Production passes SessionListProvider's stable registrar. A fresh wrapper
// every render would rerun the unmount cleanup and publish twice per frame.
const registerShellControls = useCallback((registration: ShellRegistration | null) => {
if (metrics.recording) metrics.registers += 1;
list.registerShellControls(registration);
}, [list]);
useHomeShellRegistration({
account: list.account,
accountMenuOpen: false,
accountTrigger: trigger,
activeSessionId: activeSessionId ?? undefined,
cancellationPending: false,
chartLibrary: charts,
creatingSession: false,
hydrated: list.settled,
loadMoreSessions: () => {},
modalOpen: false,
modelCatalog: null,
openAccountDialog: () => {},
pendingSessionId: null,
profile: { ...emptyProfile, name: "Synthetic" },
rectificationOpeningSessionId: null,
rectificationSurfaceOpen: false,
registerShellControls,
renameSession: () => { renameRevisions.push(revision); },
selectSession: () => { selectRevisions.push(revision); },
sessionMenuId: null,
sessions,
sessionsCursor: null,
setAccountMenuOpen: () => {},
setPendingSessionDeletion: () => {},
setSessionMenuId: () => {},
shareSession: () => {},
startNewChat: () => {},
togglePinnedSession: () => {},
// New array every render. Element identity stays until a real edit.
visibleSessions: sessions.filter(() => true),
});
void frame;
useEffect(() => {
driver.setFrame = setFrame;
driver.setActive = setActive;
driver.retitle = () => setSessions((current) => current.map((session, index) => (
index === 0 ? { ...session, title: "Renamed synthetic" } : session
)));
driver.switchPerson = () => setSessions(otherPerson);
driver.setRevision = setRevision;
}, []);
return null;
}
const onSidebarRender: ProfilerOnRenderCallback = (_id, phase, actualDuration) => {
if (!metrics.recording) return;
if (phase !== "mount" && phase !== "update") return;
metrics.commits += 1;
metrics.actualDuration += actualDuration;
};
function Shell({ children }: { children: ReactNode }) {
const list = useSessionList();
const registration = useShellRegistration();
const model = sessionListSidebarModel(list, registration);
useEffect(() => { latestShell = registration; });
return (
<SidebarProvider escapeBlocked={registration?.escapeBlocked ?? false}>
<Profiler id="sidebar" onRender={onSidebarRender}>
<AppSidebar
sessions={model.sessions}
activeSessionId={registration?.activeSessionId ?? null}
openingSessionId={registration?.openingSessionId ?? null}
account={model.account}
settled={list.settled}
signedOut={list.signedOut}
controls={registration?.controls}
/>
</Profiler>
<SidebarInset inert={registration?.insetInert}>{children}</SidebarInset>
</SidebarProvider>
);
}
const restore = () => {
globalThis.fetch = originalFetch;
resetSubjectCatalogForTests();
if (originalStorage) Object.defineProperty(globalThis, "localStorage", originalStorage);
else Reflect.deleteProperty(globalThis, "localStorage");
if (originalIntersection) Object.defineProperty(globalThis, "IntersectionObserver", originalIntersection);
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
};
try {
await harness.render(
<SessionListProvider>
<Shell><HomeProbe /></Shell>
</SessionListProvider>,
);
await harness.idle();
assert.equal(harness.errors.length, 0);
assert.ok(latestShell, "hydrated home registers once before the frame run");
metrics.recording = true;
metrics.registers = 0;
metrics.commits = 0;
metrics.actualDuration = 0;
for (let frame = 1; frame <= 60; frame += 1) {
await harness.update(() => driver.setFrame(frame));
}
const duringFrames = {
registers: metrics.registers,
commits: metrics.commits,
actualDuration: Number(metrics.actualDuration.toFixed(3)),
};
metrics.registers = 0;
metrics.commits = 0;
await harness.update(() => driver.setActive("session-active"));
const afterActiveRegisters = metrics.registers;
const afterActiveCommits = metrics.commits;
const afterActive = registrationData(currentShell());
metrics.registers = 0;
await harness.update(() => driver.retitle());
const afterTitleRegisters = metrics.registers;
const renamedTitle = currentShell()?.sidebarSessions?.[0]?.title ?? null;
metrics.registers = 0;
await harness.update(() => driver.setRevision(4));
const afterRevisionRegisters = metrics.registers;
currentShell()?.controls.onSelectSession("session-0");
currentShell()?.controls.sessionControls.onRename("session-0");
metrics.registers = 0;
await harness.update(() => driver.switchPerson());
const afterSwitchRegisters = metrics.registers;
const otherTitle = currentShell()?.sidebarSessions?.[0]?.title ?? null;
const report = {
sessions: sessionCount,
duringFrames,
afterActiveRegisters,
afterActiveCommits,
afterActiveSha: payloadSha(afterActive),
afterActive,
afterTitleRegisters,
renamedTitle,
afterRevisionRegisters,
selectRevisions,
renameRevisions,
afterSwitchRegisters,
otherTitle,
};
console.log(`SIDEBAR_AB ${JSON.stringify(report)}`);
assert.equal(duringFrames.registers, 0);
assert.equal(duringFrames.commits, 0);
assert.equal(afterActiveRegisters, 1);
assert.equal(afterActive?.activeSessionId, "session-active");
assert.equal(afterTitleRegisters, 1);
assert.equal(renamedTitle, "Renamed synthetic");
assert.equal(afterRevisionRegisters, 0);
assert.deepEqual(selectRevisions, [4]);
assert.deepEqual(renameRevisions, [4]);
assert.equal(afterSwitchRegisters, 1);
assert.equal(otherTitle, "Other person session");
return report;
} finally {
await harness.close();
restore();
}
}
for (const sessionCount of [30, 100]) {
test(`streaming frames do not republish the sidebar (${sessionCount} sessions)`, async () => {
await runProbe(sessionCount);
});
}
@@ -105,11 +105,8 @@ 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_scoring_perf_2026_10_13.json", import.meta.url),
new URL("../../docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.json", import.meta.url),
"utf8",
)) as {
frozen_record: { implementation_sha256: string };
@@ -255,10 +252,9 @@ 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_scoring_perf_2026_10_13.json",
"docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.json",
);
assert.equal(currentTreeRerun.official_valid_independent_blind, false);
assert.equal(productHoldout.current_tree_scorer.matches_metrics_scorer, false);
@@ -712,30 +712,3 @@ 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);
}
});
@@ -182,16 +182,9 @@ test("shell receives current callbacks, account and session state without freezi
assert.equal(app.shell?.activeSessionId, "revision-1");
assert.equal(app.shell?.insetInert, true);
assert.equal(app.shell?.escapeBlocked, true);
// 原值:assert.notEqual(onOpenProfile, initialCallback),每次注册都是新函数。
// 新值:包装函数身份不变;再改一版后仍是同一个函数,调用记到的是当前 revision。
// 原因:外壳拿稳定包装,点下去执行 Home 最新一次渲染的回调(BUG-971 / BUG-1329)。
assert.equal(app.shell?.controls.onOpenProfile, initialCallback);
assert.notEqual(app.shell?.controls.onOpenProfile, initialCallback);
app.shell?.controls.onOpenProfile();
assert.deepEqual(app.callbackRevisions, [1]);
await app.revise(2);
assert.equal(app.shell?.controls.onOpenProfile, initialCallback);
app.shell?.controls.onOpenProfile();
assert.deepEqual(app.callbackRevisions, [1, 2]);
await app.harness.update(() => {
app.list.setAccount({ ...account, credits: 27 });
app.list.setSessions([row]);
+43 -43
View File
@@ -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-13",
"evaluated_on": "2026-10-08",
"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": "1c146bf5273edf871d885e9e2e68ee1c8a2f71759b54b4b878f63e771f44197c",
"implementation_sha256": "511158c8387658d51f6055bd7afd15730b8b25314510d48f52498ed737c824d7",
"extended_identity": {
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
"production_scoring_files": [
@@ -52,7 +52,7 @@
"scripts/shadbala.py",
"scripts/varga.py"
],
"production_scoring_sha256": "89c146df190e8902731ea8111d1aff490fede00c919f529c7cf4c3e629a75579",
"production_scoring_sha256": "b72dbd61feec556c3863cb1b4496ded677c7077324a71b86e31722250a8d1528",
"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": "085ca37ab70e1da18e6941161868618b22c9c9f09c043d0195de7f5650363453",
"research_implementation_sha256": "12dc4049d8feeaf9e8f7df54666e7de7e8de6b45f195f52999bdbd52217908d9",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
"scripts/active_rectification_event_engine.py": "cdc0e68edc770a2a1810fbdb68cbfd46dcd50b267c739c496fcc078c5e26b5b4",
"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": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
"scripts/rectification/api_service.py": "e316ced0f3796d0378a0fe1aad950ee25922ae8afd53add578ed59ca8b2f0f79",
"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/dasha_transition_proximity.py": "0afba494aa12899621173c3cc941c6c1d0be9cec0bdf4c4a1c9bcd004ab22de8",
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
"scripts/rectification/event_probes.py": "9bbadc246e6b8e94bb047522c3e5645613f508b3473ef40b38a207c77fdad58c",
"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/research/reported_offset_sweep.py": "9df602c938f87a7b540e97c6c31a263f07b4ed0ccaeb1d2fac39b0d3d27ba0ed",
"scripts/research/sealed_holdout_rerun.py": "a7f8a1c2230e33441e335810f13cfd723f3f8ad5b03a3657cb606e29d8559f19",
"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_scoring_perf_2026_10_13.json",
"source_report": "docs/research/sealed_holdout_rerun_ephemeris_nutation_2026_10_08.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-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",
"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",
"extended_identity": {
"historical_artifacts_manifest_sha256": "6102a26a840be207b5858b3a4c0509274468ae9071e86d308cbbac7371d6864e",
"production_scoring_files": [
@@ -144,7 +144,7 @@
"scripts/shadbala.py",
"scripts/varga.py"
],
"production_scoring_sha256": "89c146df190e8902731ea8111d1aff490fede00c919f529c7cf4c3e629a75579",
"production_scoring_sha256": "b72dbd61feec556c3863cb1b4496ded677c7077324a71b86e31722250a8d1528",
"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": "085ca37ab70e1da18e6941161868618b22c9c9f09c043d0195de7f5650363453",
"research_implementation_sha256": "12dc4049d8feeaf9e8f7df54666e7de7e8de6b45f195f52999bdbd52217908d9",
"file_sha256": {
"scripts/active_rectification_event_engine.py": "96c537d221a84680ddaaba74e3335162c44b5bbd9a60d10e69550a2095910d98",
"scripts/active_rectification_event_engine.py": "cdc0e68edc770a2a1810fbdb68cbfd46dcd50b267c739c496fcc078c5e26b5b4",
"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": "089730097d3872a4f7b5d7dd5574bb7e9ab4d2e246d4a380800bc824d7979b95",
"scripts/rectification/api_service.py": "e316ced0f3796d0378a0fe1aad950ee25922ae8afd53add578ed59ca8b2f0f79",
"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/dasha_transition_proximity.py": "0afba494aa12899621173c3cc941c6c1d0be9cec0bdf4c4a1c9bcd004ab22de8",
"scripts/rectification/decision_policy.py": "80e8de4680d19a052213bb821d2a9c94143a2962235aeb27f6ad81374d2e60d5",
"scripts/rectification/event_probes.py": "f5ca9d54501f67a1546fcf5b01da0c1277dec2e2c6df481884e8900d14909a7c",
"scripts/rectification/event_probes.py": "9bbadc246e6b8e94bb047522c3e5645613f508b3473ef40b38a207c77fdad58c",
"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/research/reported_offset_sweep.py": "9df602c938f87a7b540e97c6c31a263f07b4ed0ccaeb1d2fac39b0d3d27ba0ed",
"scripts/research/sealed_holdout_rerun.py": "a7f8a1c2230e33441e335810f13cfd723f3f8ad5b03a3657cb606e29d8559f19",
"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 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",
"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",
"previous_fixed_protocol_rerun": {
"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."
"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."
}
},
"previous_fixed_protocol_rerun": {
@@ -330,25 +330,25 @@
"verified_minute_claim_allowed": false
},
"current_tree_reported_offset_replay": {
"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",
"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",
"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-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.",
"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.",
"previous_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",
"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."
"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."
}
},
"previous_reported_offset_replay": {
+7 -46
View File
@@ -127,7 +127,6 @@ 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(
@@ -135,30 +134,10 @@ def _active_narayana(
planet_longitudes,
)
age = max((event_at - birth_at).total_seconds() / (365.2425 * 86_400), 0.0)
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
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")
def _varga_chart(charts: dict, prefix: str) -> dict | None:
@@ -219,15 +198,13 @@ 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)
if functional is None:
functional = functional_benefics.derive_functional_benefic_malefic(
natal_chart["ascendant"].get("sign")
)
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
@@ -565,18 +542,6 @@ 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(
@@ -630,8 +595,6 @@ 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,
}
@@ -686,7 +649,6 @@ 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")
@@ -702,7 +664,6 @@ def _candidate_row(
vimshottari=vimshottari,
narayana=narayana,
arudha_padas=arudha_padas,
functional=context.get("functional_profile"),
))
transit_rules = _controlled_transit_rules(
request,
+609
View File
@@ -0,0 +1,609 @@
"""Retention rules for scratch/local caches.
Counts only. Never prints file names or cache payloads.
"""
from __future__ import annotations
import json
import math
import os
import re
import secrets
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Callable
ROOT = Path(__file__).resolve().parents[1]
UNREADABLE_KEEP_SECONDS = 24 * 60 * 60
_HEX64_JSON = re.compile(r"^[0-9a-f]{64}\.json$")
_ARTIFACT_JSON = re.compile(r"^[A-Za-z0-9_]+-[0-9a-f]{12}-[0-9a-f]{12}\.json$")
_GATEWAY_JOB_JSON = re.compile(r"^vgw_[0-9a-f]{20}\.json$")
_PRUNE_THREAD_NAME = "jyotish-scratch-prune"
# Directory names under scratch/local that this module already knows how to prune.
# Subdirectories of vedastro_adapter are listed so a future writer is not a new top-level dir.
REGISTERED_DIR_NAMES = frozenset(
{
"api_chart_cache",
"high_rigor_jobs",
"api_chart_response_jobs",
"async_jobs.sqlite3",
"vedastro_adapter",
"vedastro_snapshot_cache",
"vedastro_snapshot_negative_cache",
"vedastro_gateway_jobs",
"varga_segments",
}
)
# Offline tools or caches that already prune themselves. Not a new retention rule.
OFFLINE_SCRATCH_NAMES = frozenset(
{
"consult_full_data_cache",
"ocr_extract",
"three_engine_parity",
"public_release_deny_patterns.txt",
"pdf_review_123456",
}
)
REGISTRY: tuple[dict[str, Any], ...] = (
{
"name": "api_chart_cache",
"holds_birth_data": True,
"deletable": "created_at plus chart TTL, or unreadable json older than 24h",
},
{
"name": "high_rigor_jobs",
"holds_birth_data": True,
"deletable": "expires_at_unix has passed; bad json is kept",
},
{
"name": "api_chart_response_jobs",
"holds_birth_data": True,
"deletable": "expires_at_unix has passed; bad json is kept",
},
{
"name": "async_jobs.sqlite3",
"holds_birth_data": True,
"deletable": "sqlite rows whose expires_at has passed",
},
{
"name": "vedastro_adapter",
"holds_birth_data": True,
"deletable": "audit artifacts, response_cache, and official_full_snapshot_cache",
},
{
"name": "vedastro_snapshot_cache",
"holds_birth_data": True,
"deletable": "reference_date before today UTC minus MAX_STALE_DAYS minus 1",
},
{
"name": "vedastro_snapshot_negative_cache",
"holds_birth_data": True,
"deletable": "reference_date before today UTC",
},
{
"name": "vedastro_gateway_jobs",
"holds_birth_data": True,
"deletable": "completed or failed jobs older than the keep window, plus their raw archive",
},
{
"name": "varga_segments",
"holds_birth_data": True,
"deletable": "mtime older than JYOTISH_VARGA_SEGMENTS_KEEP_DAYS",
},
)
def write_private_json(path: Path, payload: Any) -> None:
"""Atomic UTF-8 JSON, mode 0600. Compact, stable key order."""
path.parent.mkdir(parents=True, exist_ok=True)
temp = path.with_name(f"{path.name}.{secrets.token_hex(8)}.tmp")
temp.write_text(
json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")),
encoding="utf-8",
)
try:
os.chmod(temp, 0o600)
except OSError:
pass
os.replace(temp, path)
try:
os.chmod(path, 0o600)
except OSError:
pass
def _finite_float(value: Any) -> float | None:
if isinstance(value, bool) or value is None:
return None
if isinstance(value, (int, float)):
number = float(value)
elif isinstance(value, str):
text = value.strip()
if not text:
return None
try:
number = float(text)
except ValueError:
return None
else:
return None
if not math.isfinite(number):
return None
return number
def _exact_int(value: Any) -> int | None:
if isinstance(value, bool) or value is None:
return None
if isinstance(value, int):
return value
if isinstance(value, float):
if not math.isfinite(value) or not value.is_integer():
return None
return int(value)
if isinstance(value, str) and re.fullmatch(r"[+-]?\d+", value.strip()):
return int(value.strip())
return None
def normalize_chart_cache_birth(body: dict | None) -> dict[str, Any]:
"""Cache-key birth fields. Unparseable values stay as they arrived."""
payload = body if isinstance(body, dict) else {}
normalized: dict[str, Any] = {}
for key in ("year", "month", "day"):
parsed = _exact_int(payload.get(key))
normalized[key] = payload.get(key) if parsed is None else parsed
for key in ("hour", "minute", "lat", "lon", "tz"):
raw = payload.get(key)
parsed = _finite_float(raw)
normalized[key] = raw if parsed is None else parsed
second_raw = payload.get("second", 0)
second = _finite_float(second_raw)
normalized["second"] = second_raw if second is None else second
return normalized
def normalize_chart_cache_day(value: Any) -> Any:
if value is None or value == "":
return value
return str(value)[:10]
def _blank() -> dict[str, int]:
return {"scanned": 0, "removed": 0, "errors": 0}
def _older_than(path: Path, moment: float, seconds: float) -> bool:
try:
return moment - path.stat().st_mtime > seconds
except OSError:
return False
def _unlink(path: Path, stats: dict[str, int]) -> None:
try:
path.unlink()
stats["removed"] += 1
except OSError:
stats["errors"] += 1
def _repo_root() -> Path:
return ROOT
def _api_module(api_module=None):
if api_module is not None:
return api_module
import jyotish_api_server as api
return api
def prune_async_job_records(now: float | None = None, api_module=None) -> dict[str, Any]:
"""Same file and sqlite rules as the old startup cleanup. Bad job JSON is kept."""
api = _api_module(api_module)
moment = time.time() if now is None else float(now)
scanned = 0
removed = 0
errors = 0
directories: dict[str, dict[str, int]] = {}
if api._async_job_backend() == "sqlite":
try:
with api._sqlite_job_connection() as connection:
scanned = int(connection.execute("SELECT COUNT(*) FROM async_jobs").fetchone()[0])
deleted = connection.execute(
"DELETE FROM async_jobs WHERE expires_at IS NOT NULL AND expires_at <= ?",
(moment,),
).rowcount
removed = int(deleted or 0)
except OSError:
errors += 1
directories["high_rigor_jobs"] = _blank()
directories["api_chart_response_jobs"] = _blank()
directories["async_jobs_sqlite"] = {"scanned": scanned, "removed": removed, "errors": errors}
return {
"scope": "async_job_cleanup",
"scanned": scanned,
"removed": removed,
"errors": errors,
"directories": directories,
}
for scope, label in (
(api._HIGH_RIGOR_JOB_SCOPE, "high_rigor_jobs"),
(api._API_CHART_CACHE_SCOPE, "api_chart_response_jobs"),
):
stats = _blank()
try:
directory = api._async_job_dir(scope)
except OSError:
stats["errors"] += 1
directories[label] = stats
errors += stats["errors"]
continue
if directory.is_dir():
for path in directory.glob("*.json"):
stats["scanned"] += 1
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
continue
except OSError:
stats["errors"] += 1
continue
expires_at = record.get("expires_at_unix") if isinstance(record, dict) else None
if isinstance(expires_at, (int, float)) and not isinstance(expires_at, bool) and moment >= float(expires_at):
_unlink(path, stats)
directories[label] = stats
scanned += stats["scanned"]
removed += stats["removed"]
errors += stats["errors"]
return {
"scope": "async_job_cleanup",
"scanned": scanned,
"removed": removed,
"errors": errors,
"directories": directories,
}
def _prune_chart_cache(moment: float, api_module=None) -> dict[str, int]:
api = _api_module(api_module)
stats = _blank()
try:
directory = api._api_chart_cache_dir()
ttl = api._api_chart_cache_ttl_seconds()
except OSError:
stats["errors"] += 1
return stats
if not directory.is_dir():
return stats
for path in directory.glob("*.json"):
if not _HEX64_JSON.match(path.name):
continue
stats["scanned"] += 1
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
_unlink(path, stats)
continue
except OSError:
stats["errors"] += 1
continue
created_at = record.get("created_at") if isinstance(record, dict) else None
if not isinstance(created_at, (int, float)) or isinstance(created_at, bool):
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
_unlink(path, stats)
continue
if moment - float(created_at) > ttl:
_unlink(path, stats)
return stats
def _adapter():
import vedastro_service_adapter as adapter
return adapter
def _prune_created_at_dir(directory: Path, moment: float, ttl_for_record: Callable[[dict], float]) -> dict[str, int]:
stats = _blank()
if not directory.is_dir():
return stats
for path in directory.glob("*.json"):
if not _HEX64_JSON.match(path.name):
continue
stats["scanned"] += 1
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
_unlink(path, stats)
continue
except OSError:
stats["errors"] += 1
continue
created_at = record.get("created_at") if isinstance(record, dict) else None
if not isinstance(created_at, (int, float)) or isinstance(created_at, bool):
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
_unlink(path, stats)
continue
try:
ttl = float(ttl_for_record(record))
except (TypeError, ValueError):
stats["errors"] += 1
continue
if moment - float(created_at) > ttl:
_unlink(path, stats)
return stats
def _prune_response_cache(moment: float) -> dict[str, int]:
adapter = _adapter()
return _prune_created_at_dir(
Path(adapter.ARTIFACT_DIR) / "response_cache",
moment,
lambda _record: adapter._cache_ttl_seconds(),
)
def _prune_snapshot_semantic_cache(moment: float) -> dict[str, int]:
adapter = _adapter()
def ttl_for(record: dict) -> float:
if record.get("negative") is True:
return adapter._official_full_snapshot_negative_ttl_seconds()
return adapter._official_full_snapshot_cache_ttl_seconds()
return _prune_created_at_dir(
Path(adapter.ARTIFACT_DIR) / "official_full_snapshot_cache",
moment,
ttl_for,
)
def _audit_keep_seconds() -> float:
raw = os.environ.get("VEDASTRO_AUDIT_ARTIFACT_KEEP_DAYS", "7").strip()
try:
days = float(raw) if raw else 7.0
except ValueError:
days = 7.0
return max(0.0, days) * 86400.0
def _prune_audit_artifacts(moment: float) -> dict[str, int]:
adapter = _adapter()
stats = _blank()
directory = Path(adapter.ARTIFACT_DIR)
if not directory.is_dir():
return stats
enabled = adapter._audit_artifacts_enabled()
max_age = _audit_keep_seconds() if enabled else UNREADABLE_KEEP_SECONDS
for path in directory.glob("*.json"):
if not path.is_file() or not _ARTIFACT_JSON.match(path.name):
continue
stats["scanned"] += 1
if _older_than(path, moment, max_age):
_unlink(path, stats)
return stats
def _parse_day(value: Any):
if not isinstance(value, str):
return None
raw = value[:10]
try:
return datetime.strptime(raw, "%Y-%m-%d").date()
except ValueError:
return None
def _prune_reference_dated(directory: Path, moment: float, *, earliest_kept) -> dict[str, int]:
stats = _blank()
if not directory.is_dir():
return stats
for path in directory.glob("*.json"):
if not _HEX64_JSON.match(path.name):
continue
stats["scanned"] += 1
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
_unlink(path, stats)
continue
except OSError:
stats["errors"] += 1
continue
ref = _parse_day(record.get("reference_date") if isinstance(record, dict) else None)
if ref is None:
if _older_than(path, moment, UNREADABLE_KEEP_SECONDS):
_unlink(path, stats)
continue
if ref < earliest_kept:
_unlink(path, stats)
return stats
def _prune_vedastro_snapshot_cache(moment: float) -> dict[str, int]:
from vedastro_snapshot_cache import MAX_STALE_DAYS
today = datetime.fromtimestamp(moment, timezone.utc).date()
earliest = today - timedelta(days=int(MAX_STALE_DAYS) + 1)
raw = os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", "").strip()
directory = Path(raw) if raw else _repo_root() / "scratch" / "local" / "vedastro_snapshot_cache"
return _prune_reference_dated(directory, moment, earliest_kept=earliest)
def _prune_vedastro_negative_cache(moment: float) -> dict[str, int]:
today = datetime.fromtimestamp(moment, timezone.utc).date()
raw = os.environ.get("JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR", "").strip()
directory = Path(raw) if raw else _repo_root() / "scratch" / "local" / "vedastro_snapshot_negative_cache"
return _prune_reference_dated(directory, moment, earliest_kept=today)
def _parse_updated_at(value: Any) -> float | None:
if not isinstance(value, str) or not value.strip():
return None
text = value.strip().replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
def _gateway_keep_seconds() -> float:
raw = os.environ.get("VEDASTRO_GATEWAY_JOB_KEEP_SECONDS", "86400").strip()
try:
return max(0.0, float(raw) if raw else 86400.0)
except ValueError:
return 86400.0
def _prune_gateway_jobs(moment: float) -> dict[str, int]:
stats = _blank()
raw = os.environ.get("VEDASTRO_GATEWAY_QUEUE_DIR", "").strip()
directory = Path(raw).expanduser() if raw else _repo_root() / "scratch" / "local" / "vedastro_gateway_jobs"
if not directory.is_dir():
return stats
keep = _gateway_keep_seconds()
for path in directory.glob("*.json"):
if not _GATEWAY_JOB_JSON.match(path.name):
continue
stats["scanned"] += 1
try:
record = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
continue
except OSError:
stats["errors"] += 1
continue
if not isinstance(record, dict):
continue
status = str(record.get("status") or "")
if status not in {"completed", "failed"}:
continue
updated = _parse_updated_at(record.get("updated_at"))
if updated is None or moment - updated <= keep:
continue
companion = directory / f"{path.stem}.official_raw_response.json"
_unlink(path, stats)
if companion.is_file():
stats["scanned"] += 1
_unlink(companion, stats)
return stats
def _varga_keep_seconds() -> float:
raw = os.environ.get("JYOTISH_VARGA_SEGMENTS_KEEP_DAYS", "7").strip()
try:
days = float(raw) if raw else 7.0
except ValueError:
days = 7.0
return max(0.0, days) * 86400.0
def _prune_varga_segments(moment: float) -> dict[str, int]:
stats = _blank()
raw = os.environ.get("JYOTISH_CACHE_DIR", "").strip()
root = Path(raw) if raw else _repo_root() / "scratch" / "local"
directory = root / "varga_segments"
if not directory.is_dir():
return stats
max_age = _varga_keep_seconds()
for path in directory.glob("*.json"):
if not _HEX64_JSON.match(path.name):
continue
stats["scanned"] += 1
if _older_than(path, moment, max_age):
_unlink(path, stats)
return stats
def _stamp(stats: dict[str, int], started: float) -> dict[str, Any]:
return {
"scanned": int(stats.get("scanned", 0) or 0),
"removed": int(stats.get("removed", 0) or 0),
"errors": int(stats.get("errors", 0) or 0),
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
}
def prune_all(now: float | None = None, api_module=None) -> dict[str, Any]:
"""Prune every registered directory. Exceptions become error counts."""
moment = time.time() if now is None else float(now)
api = _api_module(api_module)
started = time.perf_counter()
directories: dict[str, dict[str, Any]] = {}
def _run(name: str, func: Callable[[float], dict[str, int]]) -> None:
t0 = time.perf_counter()
try:
stats = func(moment)
except Exception:
stats = {"scanned": 0, "removed": 0, "errors": 1}
directories[name] = _stamp(stats, t0)
_run("api_chart_cache", lambda moment: _prune_chart_cache(moment, api))
job_started = time.perf_counter()
try:
job = prune_async_job_records(moment, api)
job_dirs = job.get("directories") or {}
except Exception:
job_dirs = {
"high_rigor_jobs": {"scanned": 0, "removed": 0, "errors": 1},
"api_chart_response_jobs": _blank(),
}
job_elapsed = round((time.perf_counter() - job_started) * 1000, 3)
for name, stats in job_dirs.items():
stamped = _stamp(stats, job_started)
stamped["elapsed_ms"] = job_elapsed
directories[name] = stamped
_run("vedastro_response_cache", _prune_response_cache)
_run("vedastro_official_full_snapshot_cache", _prune_snapshot_semantic_cache)
_run("vedastro_audit_artifacts", _prune_audit_artifacts)
_run("vedastro_snapshot_cache", _prune_vedastro_snapshot_cache)
_run("vedastro_snapshot_negative_cache", _prune_vedastro_negative_cache)
_run("vedastro_gateway_jobs", _prune_gateway_jobs)
_run("varga_segments", _prune_varga_segments)
return {
"scanned": sum(item["scanned"] for item in directories.values()),
"removed": sum(item["removed"] for item in directories.values()),
"errors": sum(item["errors"] for item in directories.values()),
"elapsed_ms": round((time.perf_counter() - started) * 1000, 3),
"directories": directories,
}
def start_periodic_prune(interval_s: float | None = None):
"""Daemon thread. interval <= 0 starts nothing. Startup prune is separate."""
if interval_s is None:
raw = os.environ.get("JYOTISH_SCRATCH_PRUNE_INTERVAL_SECONDS", "1800").strip()
try:
interval_s = float(raw) if raw else 1800.0
except ValueError:
interval_s = 1800.0
if interval_s <= 0:
return None
def _loop() -> None:
while True:
time.sleep(interval_s)
try:
prune_all()
except Exception:
continue
thread = threading.Thread(target=_loop, name=_PRUNE_THREAD_NAME, daemon=True)
thread.start()
return thread
+23 -47
View File
@@ -2590,24 +2590,17 @@ def _vedastro_runtime_fingerprint() -> dict:
def _build_api_chart_cache_payload(body: dict) -> dict:
import api_scratch_retention as retention
today = body.get('today') or body.get('current_date')
return {
'cache_schema_version': 5,
'birth': {
'year': body.get('year'),
'month': body.get('month'),
'day': body.get('day'),
'hour': body.get('hour'),
'minute': body.get('minute'),
'second': body.get('second', 0),
'lat': body.get('lat'),
'lon': body.get('lon'),
'tz': body.get('tz'),
},
'cache_schema_version': 6,
'birth': retention.normalize_chart_cache_birth(body),
'calculation': {
'ayanamsa': _request_ayanamsa(body),
'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')),
'today': body.get('today') or body.get('current_date'),
'transit_date': body.get('transit_date'),
'today': retention.normalize_chart_cache_day(today),
'transit_date': retention.normalize_chart_cache_day(body.get('transit_date')),
'skip_vedastro_main_entry_overview': bool(body.get('skip_vedastro_main_entry_overview')),
},
'vedastro_runtime': _vedastro_runtime_fingerprint(),
@@ -2678,14 +2671,15 @@ def _store_api_chart_response_cache(cache_payload: dict, payload: dict) -> dict:
)
ttl_seconds = _api_chart_cache_ttl_seconds()
if ttl_seconds > 0:
record = {
'cache_key': cache_key,
'created_at': created_at_unix,
'payload': payload_with_cache,
}
_api_chart_cache_path(cache_key).write_text(
json.dumps(record, ensure_ascii=False, sort_keys=True),
encoding='utf-8',
import api_scratch_retention as retention
retention.write_private_json(
_api_chart_cache_path(cache_key),
{
'cache_key': cache_key,
'created_at': created_at_unix,
'payload': payload_with_cache,
},
)
return payload_with_cache
@@ -2738,30 +2732,9 @@ def _sqlite_job_connection() -> sqlite3.Connection:
def prune_expired_async_jobs() -> dict:
"""Best-effort startup cleanup for local job records; never reads payloads."""
removed = 0
scanned = 0
if _async_job_backend() == "sqlite":
with _sqlite_job_connection() as connection:
scanned = connection.execute("SELECT COUNT(*) FROM async_jobs").fetchone()[0]
removed = connection.execute(
"DELETE FROM async_jobs WHERE expires_at IS NOT NULL AND expires_at <= ?", (time.time(),)
).rowcount
return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed}
for scope in (_HIGH_RIGOR_JOB_SCOPE, _API_CHART_CACHE_SCOPE):
directory = _async_job_dir(scope)
if not directory.is_dir():
continue
for path in directory.glob('*.json'):
scanned += 1
try:
record = json.loads(path.read_text(encoding='utf-8'))
expires_at = record.get('expires_at_unix') if isinstance(record, dict) else None
if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at):
path.unlink()
removed += 1
except (OSError, json.JSONDecodeError):
continue
return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed}
import api_scratch_retention as retention
return retention.prune_async_job_records(api_module=sys.modules[__name__])
def _new_async_job_identity(prefix: str) -> dict:
@@ -10902,7 +10875,10 @@ def _parse_allowed_hosts(value):
def start_server(port=5200, host='127.0.0.1', allowed_origins=None, allowed_hosts=None):
cleanup = prune_expired_async_jobs()
import api_scratch_retention as retention
cleanup = retention.prune_all(api_module=sys.modules[__name__])
retention.start_periodic_prune()
server = ThreadingHTTPServer((host, port), JyotishAPIHandler)
server.daemon_threads = True
server.allowed_origins = allowed_origins or DEFAULT_ALLOWED_ORIGINS
+9 -60
View File
@@ -4,9 +4,7 @@ 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,
@@ -197,35 +195,6 @@ 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 {
@@ -250,14 +219,7 @@ def candidate_features(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)
def score_candidates(request: RectificationRequest) -> dict[str, Any]:
scoring_request = scoreable_request(request)
built = build_event_contribution_matrix(scoring_request)
rows = score_from_matrix(scoring_request, built)
@@ -310,7 +272,7 @@ def score_candidates(
)
rectification_report["next_step_codes"] = candidate_summary["next_step_codes"]
candidate_summary["report"] = rectification_report
payload = {
return {
"result_id": result_id,
"algorithm_version": ALGORITHM_VERSION,
"event_contract_version": EVENT_CONTRACT_VERSION,
@@ -324,9 +286,11 @@ def score_candidates(
"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")
),
@@ -353,20 +317,10 @@ def score_candidates(
"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,
*,
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)
def diagnostics(request: RectificationRequest) -> dict[str, Any]:
scored = score_candidates(request)
return {
"result_id": scored["result_id"],
"algorithm_version": scored["algorithm_version"],
@@ -374,6 +328,7 @@ def diagnostics(
"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"],
@@ -505,11 +460,7 @@ def _normalize_relative_support(raw: Sequence[float]) -> list[float]:
return shares
def block_scan(
request: RectificationRequest,
*,
enforce_candidate_cap: bool = True,
) -> dict[str, Any]:
def block_scan(request: RectificationRequest) -> 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:
@@ -521,9 +472,7 @@ def block_scan(
if step <= 1:
step = 10
scoring_request = {**request, "minute_step": step}
if enforce_candidate_cap:
_reject_if_too_many_candidates(scoring_request)
scored = score_candidates(scoring_request, enforce_candidate_cap=enforce_candidate_cap)
scored = score_candidates(scoring_request)
events_by_id = {
str(event.get("id") or ""): event
for event in request.get("events") or []
@@ -130,8 +130,6 @@ 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] = {}
@@ -158,15 +156,14 @@ 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
moon_key = round(float(moon), 6)
vim_key = (candidate_date, moon_key)
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), 1, 9999)
vim_cache[vim_key] = _vim_start_dates(candidate_date, float(moon), lo, hi)
pd_cache[vim_key] = _vim_start_dates(
candidate_date,
float(moon),
1,
9999,
lo,
hi,
include_pratyantar=True,
)
planets = context.get("planet_longitudes") or {}
@@ -174,26 +171,25 @@ def merge_transition_proximity(
narayana_key = (
candidate_date,
int(asc) if isinstance(asc, int) else None,
moon_key,
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, 1, 9999)
_narayana_start_dates(int(asc), planets, candidate_date, lo, hi)
if isinstance(asc, int) and isinstance(planets, dict)
else None
)
ad_starts = [item for item in vim_cache[vim_key] if lo <= item.year <= hi]
ad_starts = vim_cache[vim_key]
ad_set = set(ad_starts)
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]
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_starts or [],
narayana_starts=narayana_cache[narayana_key] or [],
)
if scored["points"] <= 0 and not scored["rule_ids"]:
continue
+3 -10
View File
@@ -814,7 +814,6 @@ def _score_year(
vimshottari=vimshottari,
narayana=narayana,
arudha_padas=context.get("arudha_padas") or {},
functional=context.get("functional_profile"),
)
@@ -1914,12 +1913,8 @@ 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 "")
@@ -1933,12 +1928,10 @@ 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 tracks in work_tracks:
dates = tracks.get(track) or []
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):
@@ -0,0 +1,28 @@
"""Byte-compare numeric chart-cache spellings. See api_disk_cache_ab.py."""
from __future__ import annotations
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import api_disk_cache_ab as ab
def main() -> dict:
parent_error = None
try:
parent = ab._load_parent()
except Exception as exc:
parent = None
parent_error = exc.__class__.__name__
report = ab.run_byte_diff(parent or ab.current)
report["parent_import"] = parent_error or "ok"
report["parent"] = ab.PARENT
return report
if __name__ == "__main__":
json.dump(main(), sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
+442
View File
@@ -0,0 +1,442 @@
"""A/B for chart-cache keys, retention, and response bytes.
Parent functions come from git show of the branch base (origin/staging at
branch creation), not from the task text's older baseline. Scratch files
stay in a temporary directory.
"""
from __future__ import annotations
import importlib.util
import json
import os
import socket
import statistics
import subprocess
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
PARENT = "66cd60081f37a57680274633fcb3fb942d01bb2f"
os.environ.setdefault("JYOTISH_SKIP_LOCAL_ENV", "1")
os.environ.setdefault("VEDASTRO_ENABLE_NETWORK", "0")
_SDK_PRESENT = importlib.util.find_spec("vedastro") is not None
def _network_reachable() -> bool:
try:
sock = socket.create_connection(("api.vedastro.org", 443), 3)
except OSError:
return False
sock.close()
return True
_NETWORK = _network_reachable()
class _Block:
def find_spec(self, name, path=None, target=None):
if name.split(".")[0] in {"jhora", "vedastro"}:
raise ModuleNotFoundError(f"No module named '{name}'")
return None
if "--with-sdk" not in sys.argv:
sys.meta_path.insert(0, _Block())
sys.path.insert(0, str(ROOT / "scripts"))
sys.path.insert(0, str(ROOT))
import api_scratch_retention as retention # noqa: E402
import jyotish_api_server as current # noqa: E402
import vedastro_service_adapter as adapter # noqa: E402
def _load_parent():
raw = subprocess.check_output(["git", "show", f"{PARENT}:scripts/jyotish_api_server.py"], text=True)
raw = raw.replace(
"SCRIPTS_DIR = os.path.dirname(os.path.abspath(__file__))",
f"SCRIPTS_DIR = {str(ROOT / 'scripts')!r}",
)
raw = raw.replace(
"REPO_ROOT = os.path.abspath(os.path.join(SCRIPTS_DIR, '..'))",
f"REPO_ROOT = {str(ROOT)!r}",
)
directory = Path(tempfile.mkdtemp(prefix="jyotish-cache-a-"))
path = directory / "jyotish_api_server_parent.py"
path.write_text(raw, encoding="utf-8")
spec = importlib.util.spec_from_file_location("jyotish_api_server_parent", path)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _handler(module):
cls = module.JyotishAPIHandler
return cls.__new__(cls)
def _strip(payload: dict) -> str:
cloned = json.loads(json.dumps(payload, default=str))
cloned.pop("runtime_cache", None)
return json.dumps(cloned, ensure_ascii=False, sort_keys=True, default=str)
def _diff_paths(left: str, right: str, limit: int = 12) -> list[str]:
a = json.loads(left)
b = json.loads(right)
found: list[str] = []
def walk(one, two, prefix: str) -> None:
if len(found) >= limit:
return
if type(one) is not type(two):
found.append(prefix or "$")
return
if isinstance(one, dict):
for key in sorted(set(one) | set(two)):
if key not in one or key not in two:
found.append(f"{prefix}.{key}")
continue
walk(one[key], two[key], f"{prefix}.{key}")
elif isinstance(one, list):
if len(one) != len(two):
found.append(f"{prefix}[]")
return
for index, (item, other) in enumerate(zip(one, two)):
walk(item, other, f"{prefix}[{index}]")
elif one != two:
found.append(prefix or "$")
walk(a, b, "")
return found
def _body(hour, lat, **extra) -> dict:
payload = {
"year": 1990,
"month": 1,
"day": 1,
"minute": 0,
"lon": 20.0,
"tz": 5.5,
"hour": hour,
"lat": lat,
"today": "2026-10-14",
}
payload.update(extra)
return payload
def run_byte_diff(parent=None) -> dict:
os.environ["JYOTISH_API_CHART_CACHE_TTL_SECONDS"] = "0"
os.environ["VEDASTRO_WRITE_AUDIT_ARTIFACTS"] = "0"
parent = parent or current
groups = {
"hour": [_body(12, 10.0), _body(12.0, 10.0), _body("12", 10.0)],
"lat": [_body(12, 10.0), _body(12, "10.0")],
"tz": [_body(12, 10.0), _body(12, 10.0, tz="5.5")],
"second": [_body(12, 10.0), _body(12, 10.0, second=0)],
}
report = {"groups": {}, "stable_repeat": None}
handler = _handler(current)
started = time.perf_counter()
try:
sample = handler._compute_chart_sync(_body(12, 10.0, skip_vedastro_main_entry_overview=True))
except Exception as exc:
return {"ok": False, "error": exc.__class__.__name__, "elapsed_s": round(time.perf_counter() - started, 3)}
sample_s = round(time.perf_counter() - started, 3)
repeat = handler._compute_chart_sync(_body(12, 10.0, skip_vedastro_main_entry_overview=True))
report["stable_repeat"] = _strip(sample) == _strip(repeat)
report["sample_seconds"] = sample_s
if not report["stable_repeat"]:
report["volatile_paths"] = _diff_paths(_strip(sample), _strip(repeat))
for name, bodies in groups.items():
for skip in (True, False):
rendered = []
for body in bodies:
item = dict(body)
if skip:
item["skip_vedastro_main_entry_overview"] = True
rendered.append(_strip(handler._compute_chart_sync(item)))
same = len(set(rendered)) == 1
entry = {"skip": skip, "same": same}
if not same:
entry["paths"] = _diff_paths(rendered[0], rendered[1])
clocks = ("cache_created_at", "cache_expires_at", "called_at")
entry["only_clock_fields"] = bool(entry["paths"]) and all(
path.endswith(clocks) for path in entry["paths"]
)
else:
entry["only_clock_fields"] = False
report["groups"][f"{name}:skip={skip}"] = entry
if parent is not current:
a_handler = _handler(parent)
b_handler = handler
body = _body(12, 10.0, skip_vedastro_main_entry_overview=True)
a_text = _strip(a_handler._compute_chart_sync(body))
b_text = _strip(b_handler._compute_chart_sync(body))
report["a_vs_b_same"] = a_text == b_text
if a_text != b_text:
report["a_vs_b_paths"] = _diff_paths(a_text, b_text)
report["ok"] = all(item["same"] or item.get("only_clock_fields") for item in report["groups"].values())
return report
def _requests():
charts = []
for index in range(20):
charts.append(
{
"year": 1990,
"month": 1,
"day": 1 + (index % 27),
"minute": index % 60,
"lon": float(index),
"tz": 5.5,
}
)
spellings = (
{"hour": 12, "lat": 10.0},
{"hour": 12.0, "lat": "10.0"},
{"hour": "12", "lat": 10.0},
)
days = [f"2026-10-{day:02d}" for day in range(8, 15)]
rows = []
for chart in charts:
for spelling in spellings:
for skip in (False, True):
for day in days:
body = dict(chart)
body.update(spelling)
body["today"] = day
if skip:
body["skip_vedastro_main_entry_overview"] = True
rows.append(body)
return rows
def _key(module, body) -> str:
return module._api_chart_cache_key(module._build_api_chart_cache_payload(body))
def _hit_rate(module, rows) -> dict:
seen = set()
hits = 0
for body in rows:
for _repeat in range(3):
key = _key(module, body)
if key in seen:
hits += 1
else:
seen.add(key)
total = len(rows) * 3
return {"requests": total, "unique_keys": len(seen), "hits": hits, "hit_rate": round(hits / total, 4)}
def _seed_and_prune(module, rows) -> dict:
now = time.time()
os.environ["JYOTISH_API_CHART_CACHE_TTL_SECONDS"] = "900"
scratch = Path(tempfile.mkdtemp(prefix="jyotish-cache-b-"))
chart = scratch / "api_chart_cache"
jobs = scratch / "jobs"
adapter_dir = scratch / "adapter"
chart.mkdir()
jobs.mkdir()
current._api_chart_cache_dir = lambda: chart
current._async_job_dir = lambda scope: jobs
current._async_job_backend = lambda: "file"
adapter.ARTIFACT_DIR = adapter_dir
os.environ["JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR"] = str(scratch / "snap")
os.environ["JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR"] = str(scratch / "neg")
os.environ["VEDASTRO_GATEWAY_QUEUE_DIR"] = str(scratch / "gw")
os.environ["JYOTISH_CACHE_DIR"] = str(scratch / "cache")
days = sorted({body["today"] for body in rows})
newest = days[-1]
written = {}
for body in rows:
key = _key(module, body)
age_days = days.index(body["today"])
age_days = len(days) - 1 - age_days
written[key] = now - age_days * 86400
for key, created in written.items():
path = chart / f"{key}.json"
path.write_text(json.dumps({"cache_key": key, "created_at": created, "payload": {}}), encoding="utf-8")
before = len(list(chart.glob("*.json")))
before_bytes = sum(path.stat().st_size for path in chart.glob("*.json"))
today_keys = len({_key(module, body) for body in rows if body["today"] == newest})
if module is current:
prune = retention.prune_all(now=now)
after = len(list(chart.glob("*.json")))
elapsed = prune["directories"]["api_chart_cache"]["elapsed_ms"]
else:
after = before
elapsed = None
return {
"files_before": before,
"bytes_before": before_bytes,
"files_after": after,
"today_active_keys": today_keys,
"chart_prune_ms": elapsed,
}
def _artifact_counts() -> dict:
scratch = Path(tempfile.mkdtemp(prefix="jyotish-artifact-ab-"))
adapter.ARTIFACT_DIR = scratch
os.environ.pop("VEDASTRO_WRITE_AUDIT_ARTIFACTS", None)
result = {
"operation": "official_full_snapshot",
"source_metadata": {
"operation": "official_full_snapshot",
"request_hash": "a" * 64,
"response_hash": "b" * 64,
},
}
adapter._write_artifact(result)
return {"b_artifact_files": len(list(scratch.glob("*.json")))}
def _negative_cache_after_prune() -> dict:
scratch = Path(tempfile.mkdtemp(prefix="jyotish-neg-ab-"))
os.environ["JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR"] = str(scratch)
os.environ["JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR"] = str(scratch / "unused-snap")
os.environ["VEDASTRO_GATEWAY_QUEUE_DIR"] = str(scratch / "unused-gw")
os.environ["JYOTISH_CACHE_DIR"] = str(scratch / "unused-cache")
current._api_chart_cache_dir = lambda: scratch / "unused-chart"
current._async_job_dir = lambda scope: scratch / "unused-jobs"
(scratch / "unused-chart").mkdir()
(scratch / "unused-jobs").mkdir()
today = time.strftime("%Y-%m-%d", time.gmtime())
yesterday = time.strftime("%Y-%m-%d", time.gmtime(time.time() - 86400))
for label, day in (("a" * 64, today), ("b" * 64, yesterday)):
path = scratch / f"{label}.json"
path.write_text(json.dumps({"reference_date": day}), encoding="utf-8")
retention.prune_all(now=time.time())
return {"files_after": len(list(scratch.glob("*.json")))}
def _prune_10k() -> dict:
scratch = Path(tempfile.mkdtemp(prefix="jyotish-prune-10k-"))
chart = scratch / "chart"
chart.mkdir()
current._api_chart_cache_dir = lambda: chart
current._async_job_dir = lambda scope: scratch / "jobs"
(scratch / "jobs").mkdir()
current._async_job_backend = lambda: "file"
adapter.ARTIFACT_DIR = scratch / "adapter"
os.environ["JYOTISH_API_CHART_CACHE_TTL_SECONDS"] = "900"
os.environ["JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR"] = str(scratch / "snap")
os.environ["JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR"] = str(scratch / "neg")
os.environ["VEDASTRO_GATEWAY_QUEUE_DIR"] = str(scratch / "gw")
os.environ["JYOTISH_CACHE_DIR"] = str(scratch / "cache")
for index in range(10000):
path = chart / f"{index:064x}.json"
path.write_text('{"created_at":1,"payload":{}}', encoding="utf-8")
started = time.perf_counter()
result = retention.prune_all(now=time.time())
elapsed = time.perf_counter() - started
return {
"prune_all_seconds": round(elapsed, 3),
"chart_removed": result["directories"]["api_chart_cache"]["removed"],
"chart_elapsed_ms": result["directories"]["api_chart_cache"]["elapsed_ms"],
"files_left": len(list(chart.glob("*.json"))),
}
def _one_chart_timing() -> dict:
os.environ["JYOTISH_API_CHART_CACHE_TTL_SECONDS"] = "0"
handler = _handler(current)
body = _body(12, 10.0, skip_vedastro_main_entry_overview=True)
misses = []
for _index in range(3):
started = time.perf_counter()
handler._compute_chart_sync(body)
misses.append((time.perf_counter() - started) * 1000)
os.environ["JYOTISH_API_CHART_CACHE_TTL_SECONDS"] = "900"
scratch = Path(tempfile.mkdtemp(prefix="jyotish-hit-time-"))
current._api_chart_cache_dir = lambda: scratch
cold = handler._compute_chart_sync(body)
hits = []
for _index in range(3):
started = time.perf_counter()
hit = handler._compute_chart_sync(body)
hits.append((time.perf_counter() - started) * 1000)
assert hit.get("runtime_cache", {}).get("cache_hit") is True
return {
"miss_median_ms": round(statistics.median(misses), 3),
"hit_median_ms": round(statistics.median(hits), 3),
"cold_has_runtime_cache": isinstance(cold, dict) and "runtime_cache" in cold,
}
def main() -> dict:
parent_error = None
try:
parent = _load_parent()
except Exception as exc:
parent = None
parent_error = exc.__class__.__name__
rows = _requests()
side_a = _hit_rate(parent, rows) if parent is not None else None
side_b = _hit_rate(current, rows)
report = {
"parent": PARENT,
"parent_import": parent_error or "ok",
"vedastro_sdk": _SDK_PRESENT,
"vedastro_network": _NETWORK,
"hit_rate_a": side_a,
"hit_rate_b": side_b,
"artifacts": _artifact_counts(),
"negative_cache_files_after_prune": _negative_cache_after_prune(),
"prune_10k": _prune_10k(),
}
if side_a and side_a["hit_rate"]:
report["hit_rate_ratio_b_over_a"] = round(side_b["hit_rate"] / side_a["hit_rate"], 3)
try:
report["files_b"] = _seed_and_prune(current, rows)
if parent is not None:
report["files_a"] = _seed_and_prune(parent, rows)
except Exception as exc:
report["files_error"] = exc.__class__.__name__
try:
report["timing"] = _one_chart_timing()
except Exception as exc:
report["timing_error"] = exc.__class__.__name__
try:
report["byte_diff"] = run_byte_diff(parent or current)
except Exception as exc:
report["byte_diff"] = {"ok": False, "error": exc.__class__.__name__}
return report
def _sdk_probe() -> dict:
os.environ["JYOTISH_API_CHART_CACHE_TTL_SECONDS"] = "0"
os.environ["VEDASTRO_ENABLE_NETWORK"] = "0"
os.environ["VEDASTRO_WRITE_AUDIT_ARTIFACTS"] = "0"
handler = _handler(current)
body = _body(12, 10.0)
started = time.perf_counter()
try:
handler._compute_chart_sync(body)
error = None
except Exception as exc:
error = type(exc).__name__
return {
"blocked_vedastro": False,
"vedastro_sdk": _SDK_PRESENT,
"vedastro_network": _NETWORK,
"network_enabled": False,
"elapsed_s": round(time.perf_counter() - started, 3),
"error": error,
}
if __name__ == "__main__":
payload = _sdk_probe() if "--sdk-probe" in sys.argv else main()
json.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
sys.stdout.write("\n")
@@ -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, include_internal=True), width))
result.append(projection(score(request), width))
print(json.dumps(result))
@@ -1,257 +0,0 @@
#!/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()
+2 -5
View File
@@ -30,11 +30,8 @@ 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
# 原值: 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"
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"
LEGACY_REPORT = ROOT / "docs/research/reported_offset_2026_09_20.json"
+2 -5
View File
@@ -27,11 +27,8 @@ 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"
# 原值: 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"
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"
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 = [
+26 -1
View File
@@ -340,6 +340,30 @@ def probe_official_rest_health(*, force_refresh: bool = True) -> dict[str, Any]:
return result
def _gateway_snapshot_ttl_helpers():
try:
from vedastro_service_adapter import (
_official_full_snapshot_cache_ttl_seconds,
_official_full_snapshot_negative_ttl_seconds,
)
except ModuleNotFoundError:
from scripts.vedastro_service_adapter import (
_official_full_snapshot_cache_ttl_seconds,
_official_full_snapshot_negative_ttl_seconds,
)
return _official_full_snapshot_cache_ttl_seconds, _official_full_snapshot_negative_ttl_seconds
def _gateway_official_snapshot_ttl_seconds() -> float:
positive, _negative = _gateway_snapshot_ttl_helpers()
return positive()
def _gateway_official_snapshot_negative_ttl_seconds() -> float:
_positive, negative = _gateway_snapshot_ttl_helpers()
return negative()
def gateway_status(*, force_refresh: bool = True) -> dict[str, Any]:
from scripts.diagnose_vedastro_mode import build_report as build_vedastro_mode_report
@@ -368,7 +392,8 @@ def gateway_status(*, force_refresh: bool = True) -> dict[str, Any]:
),
"sdk_version": _sdk_version(),
"cache_ttl_seconds": config["cache_ttl_seconds"],
"official_full_snapshot_cache_ttl_seconds": _int_env("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", 0),
"official_full_snapshot_cache_ttl_seconds": _gateway_official_snapshot_ttl_seconds(),
"official_full_snapshot_negative_cache_ttl_seconds": _gateway_official_snapshot_negative_ttl_seconds(),
"queue_enabled": config["queue_enabled"],
"fail_open_local": config["fail_open_local"],
"official_readiness": {
+61 -16
View File
@@ -425,14 +425,23 @@ def _repo_relative(path: Path) -> str:
return str(path)
def _write_artifact(result: dict[str, Any]) -> str:
def _audit_artifacts_enabled() -> bool:
raw = os.environ.get("VEDASTRO_WRITE_AUDIT_ARTIFACTS", "0").strip().lower()
return raw in {"1", "true", "yes", "on"}
def _write_artifact(result: dict[str, Any]) -> str | None:
if not _audit_artifacts_enabled():
return None
metadata = result.get("source_metadata") or {}
artifact = _artifact_path(
str(metadata.get("operation") or result.get("operation") or "calculation"),
str(metadata.get("request_hash") or "no-request-hash"),
str(metadata.get("response_hash") or "no-response-hash"),
)
artifact.write_text(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8")
from api_scratch_retention import write_private_json
write_private_json(artifact, result)
return _repo_relative(artifact)
@@ -486,6 +495,22 @@ def _official_full_snapshot_cache_ttl_seconds() -> float:
return 0.0
def _official_full_snapshot_negative_ttl_seconds() -> float:
normal = _official_full_snapshot_cache_ttl_seconds()
if normal <= 0:
return 0.0
raw = os.environ.get("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_NEGATIVE_TTL_SECONDS", "300").strip()
try:
negative = max(0.0, float(raw)) if raw else 300.0
except ValueError:
negative = 300.0
return min(negative, normal)
def _official_full_snapshot_result_is_success(payload: dict[str, Any]) -> bool:
return payload.get("available") is True and payload.get("status") == "ok"
def _free_tier_max_requests() -> int:
raw = os.environ.get(FREE_TIER_MAX_REQUESTS_ENV, "").strip()
if not raw:
@@ -1719,9 +1744,11 @@ def _attach_official_full_snapshot_semantic_cache_metadata(
cache_key: str,
created_at_unix: float,
cache_hit: bool,
ttl_seconds: float | None = None,
) -> dict[str, Any]:
payload_copy = json.loads(json.dumps(payload))
ttl_seconds = _official_full_snapshot_cache_ttl_seconds()
if ttl_seconds is None:
ttl_seconds = _official_full_snapshot_cache_ttl_seconds()
created_at = datetime.utcfromtimestamp(created_at_unix).strftime("%Y-%m-%dT%H:%M:%SZ")
expires_at = datetime.utcfromtimestamp(created_at_unix + ttl_seconds).strftime("%Y-%m-%dT%H:%M:%SZ")
metadata = dict(payload_copy.get("source_metadata") or {})
@@ -1745,9 +1772,15 @@ def _rebind_official_full_snapshot_case_identity(payload: dict[str, Any], case_i
return payload_copy
def _semantic_cache_ttl_seconds(record: dict[str, Any]) -> float:
# Records written before the negative flag are successes and keep the long TTL.
if record.get("negative") is True:
return _official_full_snapshot_negative_ttl_seconds()
return _official_full_snapshot_cache_ttl_seconds()
def _load_official_full_snapshot_semantic_cache(case: dict[str, Any], case_id: str) -> dict[str, Any] | None:
ttl_seconds = _official_full_snapshot_cache_ttl_seconds()
if ttl_seconds <= 0:
if _official_full_snapshot_cache_ttl_seconds() <= 0:
return None
cache_key = _official_full_snapshot_cache_key(case, case_id)
cache_path = _official_full_snapshot_cache_path(cache_key)
@@ -1763,7 +1796,8 @@ def _load_official_full_snapshot_semantic_cache(case: dict[str, Any], case_id: s
payload = record.get("payload")
if not isinstance(created_at_raw, (int, float)) or not isinstance(payload, dict):
return None
if time.time() - float(created_at_raw) > ttl_seconds:
ttl_seconds = _semantic_cache_ttl_seconds(record)
if ttl_seconds <= 0 or time.time() - float(created_at_raw) > ttl_seconds:
return None
rebound = _rebind_official_full_snapshot_case_identity(payload, case_id)
return _attach_official_full_snapshot_semantic_cache_metadata(
@@ -1771,28 +1805,37 @@ def _load_official_full_snapshot_semantic_cache(case: dict[str, Any], case_id: s
cache_key=cache_key,
created_at_unix=float(created_at_raw),
cache_hit=True,
ttl_seconds=ttl_seconds,
)
def _store_official_full_snapshot_semantic_cache(case: dict[str, Any], case_id: str, payload: dict[str, Any]) -> dict[str, Any]:
cache_key = _official_full_snapshot_cache_key(case, case_id)
created_at_unix = time.time()
negative = not _official_full_snapshot_result_is_success(payload)
ttl_seconds = (
_official_full_snapshot_negative_ttl_seconds()
if negative
else _official_full_snapshot_cache_ttl_seconds()
)
payload_copy = _attach_official_full_snapshot_semantic_cache_metadata(
payload,
cache_key=cache_key,
created_at_unix=created_at_unix,
cache_hit=False,
ttl_seconds=ttl_seconds,
)
ttl_seconds = _official_full_snapshot_cache_ttl_seconds()
if ttl_seconds > 0:
record = {
"cache_key": cache_key,
"created_at": created_at_unix,
"payload": payload_copy,
}
_official_full_snapshot_cache_path(cache_key).write_text(
json.dumps(record, ensure_ascii=False, sort_keys=True),
encoding="utf-8",
from api_scratch_retention import write_private_json
write_private_json(
_official_full_snapshot_cache_path(cache_key),
{
"cache_key": cache_key,
"created_at": created_at_unix,
"negative": negative,
"payload": payload_copy,
},
)
return payload_copy
@@ -1882,7 +1925,9 @@ def _store_cached_payload(cache_key: str, payload: dict[str, Any]) -> dict[str,
"created_at": created_at_unix,
"payload": payload_copy,
}
cache_path.write_text(json.dumps(record, ensure_ascii=False, sort_keys=True), encoding="utf-8")
from api_scratch_retention import write_private_json
write_private_json(cache_path, record)
return payload_copy
-245
View File
@@ -1,245 +0,0 @@
"""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)
+314
View File
@@ -0,0 +1,314 @@
"""Retention, chart-cache keys, and VedAstro audit switch."""
from __future__ import annotations
import hashlib
import json
import os
import re
import threading
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
import api_scratch_retention as retention
import jyotish_api_server as api
import vedastro_service_adapter as adapter
ROOT = Path(__file__).resolve().parents[1]
def _hex(label: str) -> str:
return hashlib.sha256(label.encode("utf-8")).hexdigest() + ".json"
def _isolate(monkeypatch, tmp_path: Path) -> None:
chart = tmp_path / "chart"
jobs = tmp_path / "jobs"
chart.mkdir()
jobs.mkdir()
monkeypatch.setattr(api, "_api_chart_cache_dir", lambda: chart)
monkeypatch.setattr(api, "_async_job_dir", lambda scope: jobs)
monkeypatch.setattr(api, "_async_job_backend", lambda: "file")
monkeypatch.setattr(adapter, "ARTIFACT_DIR", tmp_path / "adapter")
monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_CACHE_DIR", str(tmp_path / "snap"))
monkeypatch.setenv("JYOTISH_VEDASTRO_SNAPSHOT_NEGATIVE_CACHE_DIR", str(tmp_path / "neg"))
monkeypatch.setenv("VEDASTRO_GATEWAY_QUEUE_DIR", str(tmp_path / "gw"))
monkeypatch.setenv("JYOTISH_CACHE_DIR", str(tmp_path / "cache"))
monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "900")
monkeypatch.delenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", raising=False)
def _write(path: Path, payload) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload), encoding="utf-8")
def test_prune_all_drops_expired_and_old_bad_chart_cache_only(monkeypatch, tmp_path):
_isolate(monkeypatch, tmp_path)
chart = api._api_chart_cache_dir()
now = time.time()
fresh = chart / _hex("fresh")
expired = chart / _hex("expired")
bad_old = chart / _hex("bad-old")
bad_young = chart / _hex("bad-young")
stranger = chart / "notes.json"
_write(fresh, {"cache_key": "a", "created_at": now, "payload": {}})
_write(expired, {"cache_key": "b", "created_at": now - 5000, "payload": {}})
bad_old.write_text("{", encoding="utf-8")
bad_young.write_text("{", encoding="utf-8")
stranger.write_text("{}", encoding="utf-8")
old = now - (25 * 3600)
os.utime(bad_old, (old, old))
os.utime(stranger, (old, old))
result = retention.prune_all(now=now)
assert fresh.exists()
assert bad_young.exists()
assert stranger.exists()
assert not expired.exists()
assert not bad_old.exists()
assert result["directories"]["api_chart_cache"]["removed"] == 2
assert result["errors"] == 0
def test_job_cleanup_still_removes_only_expired_records(monkeypatch, tmp_path):
_isolate(monkeypatch, tmp_path)
jobs = api._async_job_dir("any")
expired = jobs / "expired.json"
active = jobs / "active.json"
bad = jobs / "bad.json"
expired.write_text(json.dumps({"expires_at_unix": time.time() - 1}), encoding="utf-8")
active.write_text(json.dumps({"expires_at_unix": time.time() + 60}), encoding="utf-8")
bad.write_text("{", encoding="utf-8")
result = api.prune_expired_async_jobs()
assert result["removed"] == 1
assert not expired.exists()
assert active.exists()
assert bad.exists()
def test_interval_zero_does_not_start_a_thread():
before = [item.name for item in threading.enumerate()]
assert retention.start_periodic_prune(0) is None
assert [item.name for item in threading.enumerate()] == before
def test_prune_counts_unlink_errors_without_raising(monkeypatch, tmp_path):
_isolate(monkeypatch, tmp_path)
chart = api._api_chart_cache_dir()
target = chart / _hex("stuck")
_write(target, {"created_at": time.time() - 5000, "payload": {}})
real_unlink = Path.unlink
def _boom(self, *args, **kwargs):
if self == target:
raise OSError("denied")
return real_unlink(self, *args, **kwargs)
monkeypatch.setattr(Path, "unlink", _boom)
result = retention.prune_all(now=time.time())
assert result["errors"] > 0
assert target.exists()
def _chart_key(body):
return api._api_chart_cache_key(api._build_api_chart_cache_payload(body))
def test_numeric_birth_spellings_share_one_chart_cache_key():
base = {
"year": 1990,
"month": 1,
"day": 1,
"minute": 0,
"lat": 28.6139,
"lon": 77.2090,
"tz": 5.5,
"today": "2026-10-14",
}
keys = {_chart_key(dict(base, hour=hour)) for hour in (12, 12.0, "12")}
assert len(keys) == 1
assert _chart_key(dict(base, hour=12, second=0)) in keys
assert _chart_key(dict(base, hour=12, lat="28.6139")) in keys
assert _chart_key(dict(base, hour=12, tz="5.5")) in keys
assert _chart_key(dict(base, hour=12, tz="Asia/Kolkata")) not in keys
skipped = _chart_key(dict(base, hour=12, skip_vedastro_main_entry_overview=True))
assert skipped not in keys
assert api._build_api_chart_cache_payload(base)["cache_schema_version"] == 6
def test_audit_artifact_default_off_and_compact_when_on(monkeypatch, tmp_path):
monkeypatch.setattr(adapter, "ARTIFACT_DIR", tmp_path)
monkeypatch.delenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", raising=False)
manifest = {"requests": [{"section": "chart_core"}], "reference_date": "2026-10-14"}
quiet = adapter._normalize_official_full_snapshot_success(
"https://example.test/api",
manifest,
{"chart_core": {"Status": "Pass"}},
{"chart_core": "ok"},
1,
[],
)
assert quiet["source_metadata"]["artifact_path"] is None
assert list(tmp_path.rglob("*.json")) == []
monkeypatch.setenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", "1")
written = adapter._normalize_official_full_snapshot_success(
"https://example.test/api",
manifest,
{"chart_core": {"Status": "Pass"}},
{"chart_core": "ok"},
1,
[],
)
rel = written["source_metadata"]["artifact_path"]
path = Path(rel)
if not path.is_absolute():
path = adapter.ROOT / rel
assert path.exists()
# Linux reports the mode bit. Windows keeps the file writable and does not surface 0600.
if os.name != "nt":
assert path.stat().st_mode & 0o777 == 0o600
text = path.read_text(encoding="utf-8")
assert "\n" not in text
assert json.loads(text)["status"] == "ok"
def test_failed_snapshot_expires_before_success(monkeypatch, tmp_path):
monkeypatch.setattr(adapter, "ARTIFACT_DIR", tmp_path)
monkeypatch.setenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", "604800")
monkeypatch.delenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_NEGATIVE_TTL_SECONDS", raising=False)
calls = {"fail": 0, "ok": 0}
def fake_run(case, case_id="user_chart"):
if case.get("day") == 2:
calls["fail"] += 1
return {"available": False, "status": "timeout", "source_metadata": {}}
calls["ok"] += 1
return {"available": True, "status": "ok", "source_metadata": {}}
monkeypatch.setattr(adapter, "_run_official_full_snapshot_case", fake_run)
fail_case = {"year": 1990, "month": 1, "day": 2, "hour": 12, "lat": 1.0, "lon": 2.0, "tz": 5.5}
ok_case = {"year": 1990, "month": 1, "day": 3, "hour": 12, "lat": 1.0, "lon": 2.0, "tz": 5.5}
adapter.run_official_full_snapshot_for_case(fail_case, case_id="fail")
adapter.run_official_full_snapshot_for_case(ok_case, case_id="ok")
cache_dir = tmp_path / "official_full_snapshot_cache"
aged = time.time() - 301
for path in cache_dir.glob("*.json"):
record = json.loads(path.read_text(encoding="utf-8"))
record["created_at"] = aged
path.write_text(json.dumps(record), encoding="utf-8")
if record.get("negative") is True:
assert record["negative"] is True
adapter.run_official_full_snapshot_for_case(fail_case, case_id="fail")
adapter.run_official_full_snapshot_for_case(ok_case, case_id="ok")
assert calls["fail"] == 2
assert calls["ok"] == 1
def test_gateway_status_reports_cache_ttl_fallback(monkeypatch):
from scripts import vedastro_gateway
monkeypatch.setenv("JYOTISH_SKIP_LOCAL_ENV", "1")
monkeypatch.delenv("VEDASTRO_OFFICIAL_FULL_SNAPSHOT_CACHE_TTL_SECONDS", raising=False)
monkeypatch.setenv("VEDASTRO_CACHE_TTL_SECONDS", "604800")
status = vedastro_gateway.gateway_status()
assert status["official_full_snapshot_cache_ttl_seconds"] == 604800
assert status["official_full_snapshot_negative_cache_ttl_seconds"] == 300
def _day(offset: int) -> str:
return (datetime.now(timezone.utc).date() + timedelta(days=offset)).isoformat()
def test_snapshot_negative_gateway_and_varga_prune_rules(monkeypatch, tmp_path):
_isolate(monkeypatch, tmp_path)
now = time.time()
today = datetime.fromtimestamp(now, timezone.utc).date()
snap = tmp_path / "snap"
neg = tmp_path / "neg"
gw = tmp_path / "gw"
varga = tmp_path / "cache" / "varga_segments"
keep_ref = (today - timedelta(days=7)).isoformat()
drop_ref = (today - timedelta(days=9)).isoformat()
_write(snap / _hex("snap-keep"), {"schema": "vedastro_snapshot_cache.v1", "reference_date": keep_ref})
_write(snap / _hex("snap-drop"), {"schema": "vedastro_snapshot_cache.v1", "reference_date": drop_ref})
_write(snap / "notes.json", {"reference_date": "1990-01-01"})
_write(neg / _hex("neg-today"), {"schema": "vedastro_snapshot_negative_cache.v1", "reference_date": today.isoformat()})
_write(neg / _hex("neg-yesterday"), {"schema": "vedastro_snapshot_negative_cache.v1", "reference_date": (today - timedelta(days=1)).isoformat()})
_write(neg / "stray.json", {"reference_date": "1990-01-01"})
job_id = "vgw_" + "ab" * 10
old_stamp = datetime.fromtimestamp(now - 90000, timezone.utc).isoformat()
fresh_stamp = datetime.fromtimestamp(now, timezone.utc).isoformat()
_write(gw / f"{job_id}.json", {"job_id": job_id, "status": "completed", "updated_at": old_stamp})
(gw / f"{job_id}.official_raw_response.json").write_text("{}", encoding="utf-8")
running = "vgw_" + "cd" * 10
_write(gw / f"{running}.json", {"job_id": running, "status": "running", "updated_at": old_stamp})
failed = "vgw_" + "ef" * 10
_write(gw / f"{failed}.json", {"job_id": failed, "status": "failed", "updated_at": fresh_stamp})
(gw / "notes.json").write_text("{}", encoding="utf-8")
old_varga = varga / _hex("old-segment")
young_varga = varga / _hex("young-segment")
_write(old_varga, {"window_key": "x"})
_write(young_varga, {"window_key": "y"})
_write(varga / "notes.json", {"window_key": "z"})
os.utime(old_varga, (now - 8 * 86400, now - 8 * 86400))
monkeypatch.setenv("VEDASTRO_WRITE_AUDIT_ARTIFACTS", "0")
artifact = tmp_path / "adapter" / "official_full_snapshot-0123456789ab-0123456789ab.json"
artifact.parent.mkdir(parents=True, exist_ok=True)
artifact.write_text("{}", encoding="utf-8")
os.utime(artifact, (now - 25 * 3600, now - 25 * 3600))
kept_artifact = tmp_path / "adapter" / "method_catalog_snapshot.json"
kept_artifact.write_text("{}", encoding="utf-8")
os.utime(kept_artifact, (now - 25 * 3600, now - 25 * 3600))
retention.prune_all(now=now)
assert (snap / _hex("snap-keep")).exists()
assert not (snap / _hex("snap-drop")).exists()
assert (snap / "notes.json").exists()
assert (neg / _hex("neg-today")).exists()
assert not (neg / _hex("neg-yesterday")).exists()
assert (neg / "stray.json").exists()
assert not (gw / f"{job_id}.json").exists()
assert not (gw / f"{job_id}.official_raw_response.json").exists()
assert (gw / f"{running}.json").exists()
assert (gw / f"{failed}.json").exists()
assert (gw / "notes.json").exists()
assert not old_varga.exists()
assert young_varga.exists()
assert (varga / "notes.json").exists()
assert not artifact.exists()
assert kept_artifact.exists()
def _scratch_local_names() -> set[str]:
names: set[str] = set()
slash = re.compile(r"scratch/local/([A-Za-z0-9_.]+)")
joined = re.compile(
r"""['\"]scratch['\"]\s*/\s*['\"]local['\"]\s*/\s*(?:f)?['\"]([A-Za-z0-9_.]+)['\"]"""
)
after_local = re.compile(
r"""['\"]local['\"]\s*\)*\s*/\s*['\"]([A-Za-z0-9_.]+)['\"]"""
)
for path in (ROOT / "scripts").rglob("*.py"):
text = path.read_text(encoding="utf-8")
names.update(slash.findall(text))
names.update(joined.findall(text))
names.update(after_local.findall(text))
return names
def test_new_scratch_local_dirs_are_registered_or_whitelisted():
allowed = set(retention.REGISTERED_DIR_NAMES) | set(retention.OFFLINE_SCRATCH_NAMES)
found = _scratch_local_names()
unexpected = sorted(found - allowed)
assert unexpected == [], unexpected
assert "api_chart_cache" in found
assert "consult_full_data_cache" in retention.OFFLINE_SCRATCH_NAMES
-361
View File
@@ -1,361 +0,0 @@
"""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,16 +100,11 @@ 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
+5 -18
View File
@@ -412,14 +412,7 @@ class RectificationV5ServicesTest(unittest.TestCase):
scored = score_candidates(normalized)
diagnostic_result = diagnostics(normalized)
self.assertFalse(scored["can_confirm_exact_minute"])
# 原值: 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.assertIn("event_contribution_matrix", scored)
self.assertEqual(diagnostic_result["diagnostics"]["leave_one_event_out_retention_rate"], 1)
self.assertFalse(diagnostic_result["can_confirm_exact_minute"])
@@ -497,10 +490,7 @@ class RectificationV5ServicesTest(unittest.TestCase):
self.assertEqual(candidate["rank"], rank)
UUID(candidate["candidate_id"])
# 原值: first["candidate_decision_receipt"]
# 新值: first["decision_receipt"](同一个对象)
# 原因: BUG-1325,默认响应不再重复挂回执
receipt = first["decision_receipt"]
receipt = first["candidate_decision_receipt"]
self.assertEqual(receipt["receipt_version"], "candidate-decision-receipt-v2")
self.assertTrue(receipt["selection_allowed"])
self.assertTrue(receipt["acceptance_allowed"])
@@ -602,8 +592,7 @@ class RectificationV5ServicesTest(unittest.TestCase):
), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values):
result = score_candidates(normalized)
# 原值: result["candidate_decision_receipt"];新值: result["decision_receipt"];原因: BUG-1325
receipt = result["decision_receipt"]
receipt = result["candidate_decision_receipt"]
self.assertFalse(receipt["acceptance_allowed"])
self.assertIn("insufficient_domain_diversity", receipt["reasons"])
self.assertIn("tied_top_score", receipt["reasons"])
@@ -648,8 +637,7 @@ class RectificationV5ServicesTest(unittest.TestCase):
), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values):
result = score_candidates(normalized)
# 原值: result["candidate_decision_receipt"];新值: result["decision_receipt"];原因: BUG-1325
receipt = result["decision_receipt"]
receipt = result["candidate_decision_receipt"]
self.assertTrue(receipt["acceptance_allowed"])
self.assertTrue(receipt["selection_allowed"])
self.assertFalse(receipt["confirmation_allowed"])
@@ -690,8 +678,7 @@ class RectificationV5ServicesTest(unittest.TestCase):
), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values):
result = score_candidates(normalized)
# 原值: result["candidate_decision_receipt"];新值: result["decision_receipt"];原因: BUG-1325
receipt = result["decision_receipt"]
receipt = result["candidate_decision_receipt"]
self.assertTrue(result["candidate_decisions"])
self.assertFalse(receipt["acceptance_allowed"])
self.assertFalse(receipt["gates"]["date_quality"]["passed"])
+1 -4
View File
@@ -242,10 +242,7 @@ def _collision_spans(path: str, source: str, rules: tuple[Rule, ...]) -> list[tu
return []
spans = []
for node in ast.walk(functions[0]):
# 原值: 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}:
if not isinstance(node, ast.Set) or node.lineno not in {1182, 1190}:
continue
try:
values = ast.literal_eval(node)
@@ -43,11 +43,6 @@ 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/",
)
@@ -769,6 +769,10 @@ def test_vedastro_range_scan_records_hashes_and_artifact_path() -> None:
env = os.environ.copy()
env["VEDASTRO_API_ENDPOINT"] = f"http://127.0.0.1:{server.server_port}/vedastro"
env["VEDASTRO_ENABLE_NETWORK"] = "1"
# 原值:沿用进程环境,没有 VEDASTRO_WRITE_AUDIT_ARTIFACTS。
# 新值:显式设为 1。
# 原因:审计落盘默认关闭后,这条测试仍要断言文件存在,所以打开开关。这是测试前置,不是放宽断言。
env["VEDASTRO_WRITE_AUDIT_ARTIFACTS"] = "1"
completed = subprocess.run(
[
sys.executable,