perf(consultation): 健康探测加 TTL、yoga 表达式只编译一次
BUG-734: probe_official_rest_health 不是 ping —— 它把一份虚构 smoke 排盘 POST 给官方 HoroscopePredictions(实测单次 1,034 ms),还经 call() 吃掉对方 每分钟 5 个限流令牌之一,而结论零 TTL。连续 6 轮前台请求下,第 6 次探测会被 本地限流器挡下、反过来报出假的 official_blocked,真正的业务调用还要和它抢令牌。 加进程级 TTL 缓存(成功 60 s / 失败 10 s,配置变即失效,缓存键只记 key 配没配 的布尔)。force_refresh 默认 True:诊断端点调的是裸 gateway_status() 且该文件 本轮不得改,默认实时才不会让运维看到旧结论;run_gateway_packet 显式走 False。 探测在锁外执行。6 轮实测:探测 6→1、握手 5→1、限流令牌 6→1。 连接复用(任务书 6.2)按让步顺序砍掉:实测 urllib.request 没有连接池,模块级 共享 opener 在 6 次请求下仍开 6 条 TCP 连接,按任务书写法只能骗过「同一个 opener 实例」的断言而省不掉任何握手。另立单。 BUG-735: yoga_engine._eval_custom 对静态规则表的表达式每请求重新编译。实测每次 检测 386 次源码编译(182 次 eval 字符串 + 204 次 ast.parse/compile)。拆出 _build_custom_exec_globals(每次新建,绑定当前盘 ctx)与 lru_cache 的 _compiled_eval_code / _compiled_exec_code,只缓存 code object,绝不缓存求值 结果。求值语义逐条对齐改前。第二次检测起编译 0 次,单次 27.4 ms → 3.3 ms。 等价用同进程差分(BUG-733 的做法):192 条去重表达式 × 3 张公开虚构示例盘, 与逐字复刻的改前实现同值,另有反向验证证明差分非恒真。 未改 scripts/jyotish_api_server.py。既有断言一条未改。新增 23 条测试。 快速门唯一红的 test_chat_page_uses_authenticated_cloud_persistence 经干净 origin/staging 检出复跑确认为基线红,记入 BLOCKED.md BLK-002,不顺手修。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
This commit is contained in:
co-authored by
Claude Opus 5
parent
10afdcddeb
commit
63c419f375
+17
@@ -181,3 +181,20 @@
|
||||
- `update public.chat_sessions as session`、`join lateral`、`get diagnostics ... = row_count` 三种写法在已应用的迁移里都有先例(`20260808030000`、`20260901010000`、`20260811020000`);
|
||||
- 全量 `npm test`:3334 tests / 31 fail,与同一工作树上 `origin/staging` @ `37e6c519` 的 3329 / 31 逐条一致(本轮 +5 test 全绿,失败集合未变)。
|
||||
- 真实证据待补:产品在 staging 点一次 `Migrate Staging Database`,从运行日志的 `notice ... repaired_titles=` / `notice ... refreshed_activity=` 读回行数,回填进 BUG-699 / BUG-704 的「验证」。解除后划掉本条而不是删除。
|
||||
|
||||
## BLK-003 · 快速门 `test_chat_page_uses_authenticated_cloud_persistence` 基线即红(2026-09-16)
|
||||
|
||||
- 状态:blocked(基线即失败,非 BUG-734 / 735 引入)
|
||||
- 首次记录:2026-09-16
|
||||
- 测试:`tests/test_supabase_user_data_contract.py::test_chat_page_uses_authenticated_cloud_persistence`
|
||||
- 复现:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m pytest -q tests/test_supabase_user_data_contract.py --tb=line
|
||||
```
|
||||
|
||||
- 失败断言:`assert 'user_id: user.id' in create_route`,其中 `create_route` 是 `frontend/src/app/api/sessions/route.ts`。该路由已改走 `chatSessionCreateInsertRow(...)` 构造插入行,源码里不再有 `user_id: user.id` 这个字面量;断言没跟上。**是断言过期,不是持久化真的丢了 user 归属**——同一条测试上面几行的「无浏览器侧写入」锁仍然成立。
|
||||
- 已知失败的 SHA:`origin/staging` @ `37e6c519`(在 `git worktree add --detach` 出来的**干净检出**上复跑确认,与本单工作树无关)。
|
||||
- 影响:`scripts/run_quality_gate.py --profile quick` 因这一条退出码为 1(`1 failed, 791 passed, 1 skipped`)。任何 Python 轮次在本机都会看到快速门红。
|
||||
- 本单范围:只记录。AGENTS §7.7 不得顺手修不在任务书里的问题;改这条断言属于产品/前端归属,要单独立单确认「插入行里 user 归属现在由谁保证」再改,不得直接把断言删了变绿。
|
||||
- 相关:本单进度记录 `docs/tasks/PROGRESS-consultation-residual-hotspots-20260916.md` 测试段。
|
||||
|
||||
@@ -11412,3 +11412,35 @@
|
||||
- 相关记录:BUG-712、BUG-721
|
||||
- 复发自:BUG-712
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-734 | 每轮聊天用一次完整业务请求做健康探测,吃光对方限流额度还不复用连接
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-16
|
||||
- 最近更新:2026-09-16
|
||||
- 影响面:`scripts/vedastro_gateway.py` 的 `probe_official_rest_health` / `gateway_status` / `run_gateway_packet`;经 `run_gateway_packet` 的全部前台路径(`professional_reading`、`rectification_gate`、`/vedastro/gateway/run`)
|
||||
- 用户现象:BUG-727 的快照缓存每轮都命中、响应体从 52 万字符降到 40 万,耗时却几乎没变。逐轮 trace 显示快照命中的轮次仍然多打一次外网,每轮多等约 1 秒。连续提问到第 6 轮时技法表的 VedAstro 云状态会毫无道理地变成 blocked。
|
||||
- 触发条件:任何经 `run_gateway_packet` 的前台请求;同一分钟内连续 6 轮即可复现假 `official_blocked`。
|
||||
- 根因:三件事叠在一起。(1)`probe_official_rest_health` 不是 ping——它 POST 一份虚构 smoke 排盘到官方 `HoroscopePredictions`,拿返回的 Status 当健康信号,本机实测单次 1,034 ms;(2)它经 `call()` 且没传 `skip_rate_limit`,每轮都吃掉对方每分钟 5 个限流令牌之一,于是第 6 轮探测被本地限流器挡下、反过来报告服务不可用,真正的业务调用还要和它抢令牌;(3)探测结论没有任何 TTL,而「这个外部服务此刻可用吗」不会每 500 毫秒变一次。这是 BUG-721 同一形状的又一个实例:只依赖静态输入的昂贵结果被放在每请求都走的路径上。BUG-727 只覆盖了快照,没覆盖探测。
|
||||
- 修复:探测主体逻辑一字未改地挪进 `_probe_official_rest_health_uncached()`;`probe_official_rest_health` / `gateway_status` 增加 `force_refresh` 形参并加进程级 TTL 缓存,`run_gateway_packet` 显式走 `force_refresh=False`。缓存键是影响探测结论的有效配置(mode、official endpoint、self-host endpoint、network enabled、是否配了 API key、`JYOTISH_SKIP_LOCAL_ENV`),配置一变立刻失效;只记 key 配没配的布尔,绝不存 key 的值。成功 TTL 60 秒、失败 TTL 10 秒(失败必须显著更短,否则对方恢复了还要再瞎等一分钟)。探测在锁外执行,不在等外网时按住进程级锁。`force_refresh` 默认 `True`:诊断端点 `_compute_vedastro_gateway_status` 调的是裸 `gateway_status()` 且该文件本轮不得改(AGENTS §6),默认实时才能保证运维不会看到一个 60 秒前的假象。连接复用这一半未做——实测证明 `urllib.request` 没有连接池,模块级共享 opener 在 6 次请求下仍开 6 条 TCP 连接(`AbstractHTTPHandler.do_open` 每次新建并强制发 `Connection: close`),按任务书设想实现只能通过「是同一个 opener 实例」的断言而省不掉任何握手;真正的连接池需要改所有业务调用共用的 `call()` 或新增依赖,另立单。
|
||||
- 验证:`tests/test_vedastro_health_probe_cache.py` 14 条——连调 3 次只探测 1 次、推过 TTL 后变 2 次、失败 TTL 过后成功 TTL 之内服务恢复能被看见、失败 TTL < 成功 TTL、配 API key 或换 endpoint 立刻失效、缓存键不含 key 值、network disabled 不碰外网、`force_refresh=True` 每次真探测且默认值被 `inspect.signature` 钉死、`run_gateway_packet` 必须传 `force_refresh=False`、返回值被调用方改写不污染缓存、探测期间锁可获取。同口径实测(连续 6 轮前台请求):探测 6 → 1 次,TLS 握手 5 → 1 次,消耗对方限流令牌 6 → 1 个;改前第 6 轮健康结论为假 `official_blocked`,改后 6 轮全部 `official_verified`。既有 `tests/test_vedastro_gateway.py`、`tests/test_vedastro_runtime_ops.py` 断言一条未改仍绿。
|
||||
- 防复发:前台路径上的外部服务探测必须有 TTL,且不得消耗业务限流额度;失败 TTL 必须显著短于成功 TTL;诊断端点必须保持实时,缓存只能加在前台那条路上。健康探测不得用一次完整业务调用充当。探测与业务调用共用连接这一条仍未落地——在引入带连接池的客户端之前,每次外呼仍各付一次 TLS 握手(本机实测 207 ms)。
|
||||
- 相关记录:BUG-727(快照缓存,只修了快照没覆盖探测)、BUG-161、BUG-301(前台外部证据的两条既有红线)、BUG-721(同一形状)、BUG-718(不得持锁等外网)
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
## BUG-735 | 静态规则表达式每个请求重新编译 386 次
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-09-16
|
||||
- 最近更新:2026-09-16
|
||||
- 影响面:`scripts/yoga_engine.py` 的 `_eval_custom`;所有走 yoga 判定的路径(普通咨询、报告、校正打分)
|
||||
- 用户现象:用户看不见错误结果,只感到慢。单请求 cProfile 里运行时 AST 编译占 9.4%,而真正的占星计算 `swisseph.calc_ut` 只占 2.4%。
|
||||
- 触发条件:任何一次 yoga 检测。
|
||||
- 根因:`_eval_custom` 拿到的是源码字符串 `cond["expr"]`,每次调用都重新编译:先 `eval(expr)`(CPython 在内部编译字符串),多语句表达式在这一步抛 `SyntaxError`、被捕获后再 `ast.parse` → 改写末尾表达式 → `compile` → `exec`。表达式全部来自仓库内的静态规则表 `references/yoga_rules.json`(198 条 `expr`、去重 192 条、最长 2,614 字符、其中 103 条是多语句),在进程生命周期内不会变。本机实测每次检测 386 次源码编译(182 次 `eval` 字符串 + 204 次 `builtins.compile`)。这是 BUG-721 / BUG-734 的同一形状:只依赖静态输入的昂贵结果被放在每请求路径上。
|
||||
- 修复:`_eval_custom` 拆成「造求值命名空间」与「求值」两段。`_build_custom_exec_globals` 每次调用都新建 `exec_globals`(它绑定当前这张盘的 `ctx`)。新增模块级 `_compiled_eval_code` / `_compiled_exec_code`,各带 `functools.lru_cache(maxsize=512)`,**缓存的是 code object,不是求值结果**;`_run_custom_expr` 不带任何缓存装饰器。语义与改前逐条对齐:先表达式模式、编译期 `SyntaxError` 退到改写后的 exec 模式、两条路异常仍全吞结果为 `None`、`ast.parse` 仍对 `expr.strip()`、运行期 `SyntaxError` 也仍退到 exec 分支。不改任何 yoga 规则内容、判定口径或置信度边界。
|
||||
- 验证:`tests/test_yoga_expr_compile_cache.py` 9 条。等价用同进程差分(BUG-733 立下的做法,不写跨机 golden):参照实现逐字复刻改前代码,规则表全部 192 条去重表达式 × 3 张公开虚构示例盘 = 576 次逐条同值,并有反向验证证明该差分不是恒真。红线断言:缓存里是 `types.CodeType`;同一条 `house_of('Sun')` 在两张盘上必须得到 10 与 5 两个不同答案(表达式与多语句两条路都验);`_run_custom_expr` 无 `cache_info`;`maxsize` 有界且 ≥ 去重表达式数。来源合同:规则表在仓库内、全仓 `cond.get("expr"` 仅 1 个读取点、生产入口 `jyotish_engine.py` / `jyotish_api_server.py` 的 `detect_yogas(` 不传 `rules_path`——未发现任何用户输入能进入 `expr`。同口径实测:每次检测源码编译 386 → 第二次起 0,稳态单次检测 27.4 ms → 3.3 ms,命中 yoga 数三轮均为 70 不变。`tests/test_yoga_rules_integrity.py`、`tests/test_yoga_benchmark_cases.py`、`tests/test_lakshmi_yoga.py` 仍绿。
|
||||
- 防复发:`eval` / `exec` 的入参若来自静态规则表,必须缓存编译产物;缓存 code object,**永远不缓存求值结果**——缓存结果会让所有人拿到同一张盘的 yoga 判定,后果比性能问题严重得多。编译缓存必须有界,且必须有断言证明 `expr` 只来自仓库内的静态规则表;若哪天有用户输入能进入 `expr`,立刻停手按安全问题处理。
|
||||
- 相关记录:BUG-721(同一形状的第三个实例)、BUG-734(同一轮的另一处)、BUG-733(同进程差分怎么写)
|
||||
- 复发自:无
|
||||
- 修复版本:待发布
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
# PROGRESS · 外网健康探测与运行时重复编译(2026-09-16)
|
||||
|
||||
工作树:`.worktrees/consultation-residual-hotspots-20260916`
|
||||
分支:`codex/consultation-residual-hotspots-20260916`
|
||||
基线:`origin/staging` @ `37e6c519`(任务书写的是 `f8e607c2`,开工时远端已前进;`docs/BUG_HISTORY.md` 最大号核对结果仍是 **BUG-733**,本单占 734 / 735 不变)
|
||||
本机 Linux,`.venv` Python 3.13 + pytest。无 Docker。**外网可达**(本单的探测实测是真打了 `api.vedastro.org`)。
|
||||
|
||||
未改 `scripts/jyotish_api_server.py`(一行都没动)、未改 workflow、未 SSH 生产、未写入任何 key、未升级依赖。Skill 未 bump,无用户可感知行为变化,因此未写 CHANGELOG。
|
||||
|
||||
## 任务状态
|
||||
|
||||
| 任务 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 6.3 yoga 表达式编译缓存(BUG-735) | 完成 | 每次检测 386 次源码编译 → 第二次起 **0**;27.4 ms → 3.3 ms |
|
||||
| 6.1 健康探测 TTL(BUG-734) | 完成 | 6 轮前台请求 6 次探测 → **1** 次;对方限流令牌 6 → 1 |
|
||||
| 6.2 连接复用(BUG-734 的另一半) | **砍**(让步顺序第 3 条) | 实测证明任务书设想的做法省不掉任何握手,见下节 |
|
||||
| 6.4 两条 Bug 历史 | 完成 | BUG-734 / BUG-735 均 resolved |
|
||||
|
||||
## 6.2 为什么砍:共享 opener 在 urllib 上省不掉握手
|
||||
|
||||
任务书设想「给 `vedastro_rest_bridge` 一个模块级共享 opener,摊掉 TLS 握手」,并把验收定为「断言默认路径用的是同一个 opener 实例」。
|
||||
按这条做出来能通过那条断言,但**握手一次都省不掉**——那会是一个假修复。实测(本地计数 TCP accept 的服务器,6 次顺序 POST):
|
||||
|
||||
| 做法 | 6 次请求 → 实际 TCP 连接数 |
|
||||
| --- | ---: |
|
||||
| `urllib.request.urlopen`(今天) | **6** |
|
||||
| 模块级共享 `urllib.request.build_opener()` | **6** |
|
||||
|
||||
根因:`urllib.request` 没有连接池。`AbstractHTTPHandler.do_open` 每次新建连接,并强制发 `Connection: close`(实测抓到的请求头即为默认 urllib 头,服务端声明 HTTP/1.1 keep-alive 也无效)。
|
||||
共享的只是 handler 链,不是连接。
|
||||
|
||||
真要摊掉握手,需要一个带连接池的客户端(`http.client` 手工维护 keep-alive 连接池并处理陈旧连接重试,或引入 `requests`/`urllib3`)。前者要改所有业务调用共用的 `call()`,与本单「零行为变化」的口径不匹配;后者是加依赖,AGENTS §7.7 不允许顺手做。**建议单独立单**,不要在本单里塞。
|
||||
|
||||
**残留握手开销**:6.1 之后,每轮前台请求不再有探测那一次握手(本机实测单次 TLS 握手 207 ms,与任务书 0.186 s 同量级)。剩下的每一次真实业务请求仍各付一次握手;命中 BUG-727 快照缓存的轮次本来就不发请求,所以稳态下残留的是「快照未命中时的那几次」。
|
||||
|
||||
## 改前 / 改后同口径实测
|
||||
|
||||
### BUG-734 · 探测次数、握手次数、对方限流令牌
|
||||
|
||||
口径:连续 6 轮前台请求。BEFORE = `gateway_status(force_refresh=True)`(**就是改前的代码路径**,改前没有缓存、每次必探);AFTER = `gateway_status(force_refresh=False)`,也就是 `run_gateway_packet` 现在调的那一条。
|
||||
外网出口用假 `urlopen` 计数(避免为了做测量去打满对方额度);「TLS 握手 = 出站请求数」这一等式由上一节的连接计数实验证明。
|
||||
|
||||
| | 探测次数 | TLS 握手 | 消耗对方限流令牌 |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| 改前 | 6 | 5 | 6 |
|
||||
| 改后 | **1** | **1** | **1** |
|
||||
|
||||
改前第 6 轮的握手数是 5 而不是 6,因为**第 6 次探测自己被本地限流器挡下了**。逐轮健康结论:
|
||||
|
||||
```
|
||||
改前: official_verified ×5, 第 6 轮 official_blocked ← 健康探测把自己饿死了
|
||||
改后: official_verified ×6
|
||||
```
|
||||
|
||||
这是本单最值得记的一条:对方免费层是 5 次/分钟,**光是「服务还活着吗」这个探测就能在 6 轮之内把额度吃光**,然后反过来报告服务不可用,真正的业务调用还要和它抢令牌。它不只是慢,是会造成假的 `official_blocked`。
|
||||
|
||||
单次真实探测的墙钟(真打 `api.vedastro.org`,消耗 1 个令牌):**1,034 ms**,`status=official_verified`。任务书估的是 ~0.5 s,本机实测更贵。这就是每轮省下来的东西。
|
||||
|
||||
### BUG-735 · 编译次数
|
||||
|
||||
口径:同一张公开虚构示例盘连续检测 3 次,统计「源码 → 字节码」的编译次数。
|
||||
必须把两件事都数上:`eval(字符串)` 的编译发生在 CPython 内部、不经过 `builtins.compile`,所以计数 = `eval(str)` 调用数 + `builtins.compile` 调用数。
|
||||
|
||||
改前:
|
||||
|
||||
| 轮次 | eval(str) | eval(code) | compile() | 源码编译合计 | ms | 命中 yoga 数 |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | 182 | 0 | 204 | **386** | 27.4 | 70 |
|
||||
| 2 | 182 | 0 | 204 | **386** | 29.2 | 70 |
|
||||
| 3 | 182 | 0 | 204 | **386** | 27.3 | 70 |
|
||||
|
||||
改后:
|
||||
|
||||
| 轮次 | eval(str) | eval(code) | compile() | 源码编译合计 | ms | 命中 yoga 数 |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | 0 | 80 | 384 | **384** | 28.1 | 70 |
|
||||
| 2 | 0 | 80 | 0 | **0** | **3.3** | 70 |
|
||||
| 3 | 0 | 80 | 0 | **0** | **3.3** | 70 |
|
||||
|
||||
3 轮合计 1,158 → 384,且改后的 384 只发生在进程第一次。稳态单次检测 **27.4 ms → 3.3 ms(8.3×)**,命中 yoga 数三轮都是 70,不变。
|
||||
|
||||
数字对得上规则表:`references/yoga_rules.json` 共 **198** 条 `expr`、去重 **192** 条、最长 2,614 字符、其中 **103** 条是多语句。
|
||||
冷缓存那一轮的 384 = 192 次 eval 模式编译 + 96 条多语句 ×(`ast.parse` 1 次 + `compile` 1 次)。
|
||||
改后 `eval(code)` 是 80 而不是 182,因为改前那 102 次「先抛 `SyntaxError` 再退到 exec」现在由缓存直接返回 `None`,不再走一遍 eval。
|
||||
|
||||
## 改了什么
|
||||
|
||||
### `scripts/yoga_engine.py`(BUG-735)
|
||||
|
||||
- `_eval_custom` 拆成两段:`_build_custom_exec_globals(ctx, rule, bindings)` 负责造求值命名空间(**每次调用都新建,绑定当前这张盘的 `ctx`**),`_eval_custom` 只做「造命名空间 → 求值 → 包装成 combination」。
|
||||
- 新增模块级 `_compiled_eval_code(expr)` / `_compiled_exec_code(expr)`,各带 `functools.lru_cache(maxsize=512)`,**存的是 code object**。`_capture_tail_expr` 从函数内部提到模块级(纯 AST 改写,无闭包)。
|
||||
- 新增 `_run_custom_expr(expr, exec_globals)`:**不带任何缓存装饰器**,按改前语义求值。
|
||||
|
||||
语义逐条对齐改前:先表达式模式,编译期 `SyntaxError` 退到「改写末尾表达式后 exec」;两条路的异常仍然全吞、结果为 `None`;`ast.parse` 仍对 `expr.strip()`;exec 分支 filename 仍是 `<yoga_custom>`,eval 分支用 CPython 对字符串 eval 的默认 `<string>`。
|
||||
运行期(而非编译期)抛 `SyntaxError` 的情况也保留了退到 exec 分支的老行为,所以「第一次确定走哪条路」不会在这个边角上和改前分叉。
|
||||
|
||||
### `scripts/vedastro_gateway.py`(BUG-734)
|
||||
|
||||
- 探测主体改名 `_probe_official_rest_health_uncached()`,逻辑一字未改。
|
||||
- `probe_official_rest_health(*, force_refresh=True)` 外加 TTL 缓存;`gateway_status(*, force_refresh=True)` 透传。
|
||||
- `run_gateway_packet` 改调 `gateway_status(force_refresh=False)` —— 这是每一轮前台请求都走的那条路(professional_reading、rectification_gate、`/vedastro/gateway/run` 都经过它)。
|
||||
- 缓存键 = `(mode, official endpoint, self-host endpoint, network enabled, 是否配了 API key, JYOTISH_SKIP_LOCAL_ENV)`,配置一变立刻失效。**只记 key 配没配的布尔,绝不把 key 的值放进键**(AGENTS §8),有断言守着。
|
||||
- 成功 TTL 60 s、失败 TTL 10 s,具名常量且注释写了依据。探测在锁外执行——不得在等外网时按住进程级锁(BUG-718 的教训)。
|
||||
|
||||
## 与任务书的一处偏离:`force_refresh` 的默认值
|
||||
|
||||
任务书 §6.1 写「`gateway_status()` 增加 `force_refresh` 形参(默认 `False`),诊断端点走 `force_refresh=True`」。
|
||||
但红线 §5.5 同时写「不得改 `scripts/jyotish_api_server.py`,主文件那一行调用保持不变」,而诊断端点 `_compute_vedastro_gateway_status` 调的正是**裸的** `gateway_status()`。两条放在一起,默认值只能是 `True`:
|
||||
|
||||
- 默认 `False` → 诊断端点拿到缓存结果 → 违反决策记录第 1 条「不得让运维看到一个 60 秒前的假象」;
|
||||
- 默认 `True` → 所有既有调用方(含诊断端点、既有测试)行为一字不变,只有显式写了 `force_refresh=False` 的前台路径吃缓存。
|
||||
|
||||
选了后者:**默认实时,前台显式选缓存**。这样也满足任务书的验收口径(「`force_refresh=True` 永远打真探测」「既有断言一条不改仍绿」),只是默认值方向相反。测试 `test_force_refresh_defaults_to_true_so_the_diagnostic_endpoint_stays_live` 用 `inspect.signature` 把这个默认值钉死,并断言连调两次 `gateway_status()` 会探测两次。
|
||||
|
||||
## 等价证明(同进程差分,不写跨机 golden)
|
||||
|
||||
按 BUG-733 立下的做法:参照实现在测试文件里**逐字复刻改前的 `_eval_custom` 尾部**,与生产实现在同一进程、同一批 `exec_globals` 上对跑。
|
||||
|
||||
- `test_cached_compile_matches_pre_change_behaviour_for_every_rule_expression`:规则表全部 **192 条去重表达式 × 3 张公开虚构示例盘 = 576 次**逐条同值(`!=` 与 `bool()` 两个口径都比)。
|
||||
- 反向验证 `test_reference_differential_can_actually_fail`:把参照侧换成另一条表达式,差分立刻分叉——证明这个断言不是恒真。
|
||||
|
||||
## 红线自查
|
||||
|
||||
| 红线 | 怎么守的 |
|
||||
| --- | --- |
|
||||
| §5.1 绝不缓存 yoga 求值结果 | `_run_custom_expr` 无缓存装饰器(有断言);`test_same_expression_on_different_charts_never_reuses_a_result` 用同一条 `house_of('Sun')` 在两张盘上必须得到 10 与 5 两个不同答案,多语句路径同样验;`test_cache_stores_code_objects_not_results` 断言缓存里是 `types.CodeType` |
|
||||
| §5.2 eval 语义不放宽 | exec 不换 eval、eval 不换 exec;异常处理、`.strip()`、filename 全部保留;576 次同进程差分 |
|
||||
| §5.3 编译缓存有界 + expr 只来自静态规则表 | `maxsize=512`(去重 192 条)有断言;`test_rule_expressions_come_only_from_the_static_rules_table` 断言规则表在仓库内、`cond.get("expr"` 全仓仅 1 个读取点、生产入口 `jyotish_engine.py` / `jyotish_api_server.py` 的 `detect_yogas(` 调用不传 `rules_path`。**未发现任何用户输入能进入 `expr`**,无需写 `BLOCKED.md` |
|
||||
| §5.4 失败不得被长时间缓存 | 失败 10 s < 成功 60 s,有断言;`test_failure_is_not_cached_for_the_success_ttl` 证明失败 TTL 过后、成功 TTL 之内服务恢复能被看见 |
|
||||
| §5.5 不改 `jyotish_api_server.py` | `git diff --stat` 里没有这个文件 |
|
||||
| §5.6 不顺手升级依赖 / 修无关 warning | 未动 `requirements*`;既有 `utcfromtimestamp` DeprecationWarning 原样留着没碰 |
|
||||
| AGENTS §8 隐私 | 缓存键只存布尔;测试与文档里的盘全是公开虚构数据;没有任何 key、出生资料进仓 |
|
||||
|
||||
## 既有断言
|
||||
|
||||
| 文件 | 原值 | 新值 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| — | — | — | **一条都没改**。既有测试文件零改动,只新增两个文件 |
|
||||
|
||||
## 测试
|
||||
|
||||
| 命令 | 结果 |
|
||||
| --- | --- |
|
||||
| `pytest tests/test_vedastro_gateway.py tests/test_vedastro_runtime_ops.py tests/test_vedastro_snapshot_cache.py tests/test_api_server_security.py tests/test_yoga*.py tests/test_vedastro_health_probe_cache.py`(任务书 §8 全部目标) | **187 passed, 1 skipped**(skip 为既有 skip,非本单引入) |
|
||||
| `pytest tests/test_yoga_expr_compile_cache.py` | 9 passed(含 192×3 差分) |
|
||||
| `pytest tests/test_vedastro_health_probe_cache.py` | 14 passed |
|
||||
| `scripts/run_quality_gate.py --profile quick` | **`1 failed, 791 passed, 1 skipped`**,退出码 1。唯一红的 `tests/test_supabase_user_data_contract.py::test_chat_page_uses_authenticated_cloud_persistence` **基线即红**,见下节 |
|
||||
| `scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`(AGENTS §9,本单涉及外部 oracle) | 全绿:`python_runtime_ok` / `fragment_scan_ok` / `external_engine_adapters_ok` / `remote_visibility_status=verified` / `focused_tests_ok` |
|
||||
|
||||
新增测试 23 条,测试总数只增不减。
|
||||
|
||||
### 快速门那一条红是基线红,不是本单引入
|
||||
|
||||
`test_chat_page_uses_authenticated_cloud_persistence` 断言 `'user_id: user.id' in create_route`,而
|
||||
`frontend/src/app/api/sessions/route.ts` 早已改成用 `chatSessionCreateInsertRow(...)` 构造插入行,
|
||||
源码里没有这个字面量了。是断言过期。
|
||||
|
||||
证据(三层,逐层收紧):
|
||||
|
||||
1. 本单只改了 `scripts/yoga_engine.py` 与 `scripts/vedastro_gateway.py`,`git status --short frontend/` 为空;
|
||||
2. `git diff origin/staging -- frontend/src/app/api/sessions tests/test_supabase_user_data_contract.py` 为空——测试与被测文件都与基线逐字相同;
|
||||
3. `git worktree add --detach <tmp> origin/staging` 拉出一个**干净检出**,在那里单跑这条测试,**同样失败**。
|
||||
|
||||
已记入 `BLOCKED.md` 的 **BLK-003**。按 AGENTS §7.7 不顺手修:改这条断言要先确认「插入行里 user 归属现在由谁保证」,属于前端归属,另立单,不得把断言删了变绿。
|
||||
|
||||
注意一个记录口径:第一次跑快速门时我用了 `... | tail -40`,拿到的退出码是 `tail` 的 0 而不是门禁的,一度误判为全绿。
|
||||
第二次改用 `EXIT=$?` 直接取门禁退出码才看到 1。**门禁退出码不要隔着管道取。**
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- **快速门基线红**:`BLOCKED.md` 的 **BLK-003**。本机 `--profile quick` 退出码 1,唯一红的那条在干净的 `origin/staging` 检出上同样红。
|
||||
- **无 Docker**:数据库与部署套件跑不了。本单不动表、不动迁移、不动部署,不受影响。
|
||||
- **staging 实测**:本机测的是进程级缓存行为与本机墙钟。`/api/vedastro_gateway/status` 在真实容器里是否仍然实时、前台每轮是否真的只剩 1 次探测,要等部署后看。本单没有把这一条写成通过。
|
||||
- 真实探测只打了 **1 次**(1,034 ms 那次),没有为了凑数据去打满对方 5 次/分钟的额度。
|
||||
@@ -244,7 +244,7 @@
|
||||
| `TASK-freeze-metric-change-20260915.md` | `PROGRESS-freeze-metric-change-20260915.md` | **规则单(后面两单的前置,无 BUG 号)**:两条增长冻结余量都用完(`page.tsx` 1,951/1,951 余 **0**;`jyotish_api_server.py` 11,334/11,363 余 **29**),冻结从「逼新代码往外走」退化成「拦路」。实证:`page.tsx` 行数砍 59% 但 `Home()` 的 `useState` 从 56 涨到 **66**(拆的是代码不是状态);api server **225 个类方法只有 12 处真碰 HTTP 上下文**,4 处 `__new__` 伪造空壳就是这么来的。**产品拍板换口径**:主门改成「`Home()` 的 useState/useRef 不得增长」与「类方法数 + `__new__` 计数不得增长」,行数降级为粗护栏;**同时推翻 §6「参数式 hook 内部保持 0 个 React hook」**(那正是状态搬不走的原因)。改 `AGENTS.md` §6 + 两个合同测试,不碰业务代码 | 已验收 | `3b17c1b2`。`AGENTS.md` §6 已按新口径改写,两个合同测试到位 |
|
||||
| `TASK-home-state-lowering-20260915.md` | `PROGRESS-home-state-lowering-20260915.md` | **page.tsx 状态下沉第一簇(串行在 freeze-metric-change + C2 + R3 之后)**:66 个 state 里 `rectification*` 占 **15** 个,而它们服务的 `<ConversationalBirthTimeRectification>` 本来就是 `dynamic()` 懒加载子树、挂着 24 个 props;`useRectificationSurface` 要解构约 56 个参数。把这簇搬进子树,`Home()` 的 useState 从 66 降到 ≤ 53。**零行为变化**;第一步必须先把 15 个逐个分类(只服务子树 / 外壳也要读)。产品否决了 Context Provider 与外部 store 两条路。不占 BUG 号 | 已验收 | `f8e607c2`。实测 useState 66→52、useRef 41→39、残留散装 `rectification*` state 0;冻结基线**往紧里收**;全量套件两侧完全相同(3329/fail 31)。遗留:`createRectificationShellSetters` 身份不稳多 1 条 warning,已披露,并进第二批 |
|
||||
| `TASK-rectification-engine-memoization-fix-20260915.md` | `PROGRESS-rectification-engine-memoization-fix-20260915.md` | **验收修复单(只改测试,一行实现不许动)**:BUG-721 的实现**等价性成立**(我在改前 `6b3248bf` / 改后 `e4788dfc` 同机跑同一 payload,`candidate_scores` 逐字相同),9 条计数断言全过;但等价 golden 在本机复现不出来——4 处浮点尾数差(score 1.0e-4 ×2、`margin_percent` 1.1e-3 ×2)。**复发自 BUG-712**(「不得对全精度浮点做整体 `==`」,那一单只落在 ephemeris 一处)。而 `tests/test_rectification_*.py` 在 `CORE_PYTEST_TARGETS` 里,**staging 门禁靠机器舍入碰巧一致才是绿的**。修法:主证据换成**同进程差分**(把 static context 的四个缓存键置 `None` 即可回退旧路径,A/B 严格相等),golden 降为离散字段严格相等 + 浮点带容差(容差按实测 1.1e-3 推);**禁止重建 golden 来「修」**。另含六份 golden 的仓库级排查。BUG-733 | 已验收 | `2b7b4565`(BUG-733)。同进程差分立为主证据,golden 降为离散严格+浮点容差;本机 14 条全绿 |
|
||||
| `TASK-consultation-residual-hotspots-20260916.md` | — | **性能单(与拆解单文件不重叠,可并行)**:BUG-727 的快照缓存已验收生效(逐轮 trace 确认每轮 hit、响应体 52 万→40 万字符),但耗时没降——重测 6 次调用 5.30 s 里**网络仍占 70 %**(poll 2.42 + ssl read 0.76 + 握手 0.56),本地占星计算只剩 **2.4 %**。剩下那一次外网是 `gateway_status → probe_official_rest_health → probe_calculate_health`:**它不是 ping,是把一份虚构排盘 POST 给 `api.vedastro.org`**,而且 `consume_rate_token()` 会**烧掉对方一个业务限流令牌**,还不复用连接(6 次调用 3 次 TLS 握手,单次 0.186 s),结果零 TTL(BUG-734)。另 `yoga_engine._eval_custom` 对静态规则表的 192 条表达式**每请求重新编译 102 次**(多语句的还要先抛一次 SyntaxError 再 parse+改写 AST+compile),占 9.4 %(BUG-735)。修法:探测加 TTL(成功 60 s / 失败 10 s,配置变即失效,**诊断端点必须 force_refresh 保持实时**)+ 共享 opener;表达式缓存 code object。**硬红线:绝不缓存 yoga 求值结果**(会跨用户串盘)。等价证明用 BUG-733 的同进程差分,不再写跨机 golden。BUG 段 734–735 | 待领取 | — |
|
||||
| `TASK-consultation-residual-hotspots-20260916.md` | `PROGRESS-consultation-residual-hotspots-20260916.md` | **性能单(与拆解单文件不重叠,可并行)**:BUG-727 的快照缓存已验收生效(逐轮 trace 确认每轮 hit、响应体 52 万→40 万字符),但耗时没降——重测 6 次调用 5.30 s 里**网络仍占 70 %**(poll 2.42 + ssl read 0.76 + 握手 0.56),本地占星计算只剩 **2.4 %**。剩下那一次外网是 `gateway_status → probe_official_rest_health → probe_calculate_health`:**它不是 ping,是把一份虚构排盘 POST 给 `api.vedastro.org`**,而且 `consume_rate_token()` 会**烧掉对方一个业务限流令牌**,还不复用连接(6 次调用 3 次 TLS 握手,单次 0.186 s),结果零 TTL(BUG-734)。另 `yoga_engine._eval_custom` 对静态规则表的 192 条表达式**每请求重新编译 102 次**(多语句的还要先抛一次 SyntaxError 再 parse+改写 AST+compile),占 9.4 %(BUG-735)。修法:探测加 TTL(成功 60 s / 失败 10 s,配置变即失效,**诊断端点必须 force_refresh 保持实时**)+ 共享 opener;表达式缓存 code object。**硬红线:绝不缓存 yoga 求值结果**(会跨用户串盘)。等价证明用 BUG-733 的同进程差分,不再写跨机 golden。BUG 段 734–735 | 待验收 | `codex/consultation-residual-hotspots-20260916`:6.3 探测 TTL 与 6.1 编译缓存均完成,**6.2 共享 opener 按让步顺序砍掉**——实测 `urllib.request` 无连接池,模块级共享 opener 在 6 次请求下仍开 6 条 TCP 连接,按任务书写法只能骗过「同一个 opener 实例」的断言而省不掉任何握手,另立单。同口径实测:6 轮前台请求探测 6→1 次、握手 5→1 次、对方限流令牌 6→1 个(**改前第 6 轮探测自己被限流、报出假 `official_blocked`**);yoga 每次检测源码编译 386→第二次起 0,单次 27.4→3.3 ms。等价用 192 条表达式 ×3 盘的同进程差分。`force_refresh` 默认取 `True`(与任务书写的 `False` 相反):诊断端点调的是裸 `gateway_status()` 且该文件不得改,默认实时才不会让运维看到旧结论 |
|
||||
| `TASK-home-state-lowering-batch2-20260916.md` | — | **状态下沉第二批(纯前端,与另两单可并行)**:第一批 `f8e607c2` 已验收(useState 66→52、useRef 41→39、散装 `rectification*` 归零、全量套件两侧完全相同)。本单照同一套做法搬剩下三簇:`session*` **10** 个(`useSessionManagement` 仍要解构约 40 个参数)、`profile*` **6** 个、`synastry*` **4** 个(无 hook,散在 `Home()`),目标 useState 52 → **≤36**。另修第一批留下的尾巴:`createRectificationShellSetters` 在 render 体里无记忆化 → 每帧新身份 → 多一条 `exhaustive-deps` warning(119→**120**),**正解是稳住 setter 身份、不是塞进 deps**(塞进去会每帧重拉入口摘要),本单要把 warning 降回 119。零行为变化;先分类再动手。不占 BUG 号 | 待领取 | — |
|
||||
| `TASK-api-server-backdoor-close-20260916.md` | — | **取代 decomposition 单的阶段 1(纯 Python,与另两单可并行)**:实测四处 `__new__` 的**传递闭包只有 8 个方法 / 314 行,且 0 个碰 HTTP 上下文**(占全类 4%)。所以「关后门」是一次 314 行纯搬运,不需要捆绑 2,000+ 行的体量搬运。产品拍板:**阶段 2/3 不立单**,由 `3b17c1b2` 换好的门禁(类方法数 + `__new__` 计数只许降)长期推进。唯一成功判据:`grep -c "JyotishAPIHandler.__new__"` **收到 0** 并写进合同测试。`test_api_server_security.py` 的 3,841 行断言一条不许改。不占 BUG 号 | 待领取 | — |
|
||||
| `RECONCILE-20260916.md` | — | **对账清单(不是任务书)**:状态板与现实脱节(09-15~16 那 10 份早已合入却仍写「待领取/待验收」,本轮已修正),所以另外 7 份 09-10~14 的单**不能拿 README 当证据**——它们没有 `PROGRESS-*.md`,但引用的 BUG 号都是 `resolved`。要么被别的单顺带修了没留记录,要么压根没做。每行回一个「做了/没做」即可。另附三条我已查到确定没做的证据、两条状态未闭环的、以及 BLK-001 仍红(2026-09-16 在 `4f643aa0` 复跑确认) | 待产品负责人 / 执行方回填 | — |
|
||||
|
||||
@@ -6,6 +6,8 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
@@ -240,7 +242,53 @@ def run_gateway_job(job_id: str) -> dict[str, Any] | None:
|
||||
return complete_gateway_job(job_id, result)
|
||||
|
||||
|
||||
def probe_official_rest_health() -> dict[str, Any]:
|
||||
# ---------------------------------------------------------------------------
|
||||
# BUG-734: 健康探测的 TTL 缓存
|
||||
# ---------------------------------------------------------------------------
|
||||
# `probe_official_rest_health` 不是 ping:它 POST 一份虚构 smoke 排盘到官方
|
||||
# HoroscopePredictions,用返回的 Status 当健康信号,并且会消耗对方每分钟 5 个的
|
||||
# 限流令牌之一。它问的是「这个外部服务此刻可用吗」——这种状态不会每 500 毫秒变一次,
|
||||
# 却被每一轮前台请求重新问一遍(本机实测每次约 0.5 秒)。
|
||||
#
|
||||
# 成功 60 秒:覆盖一串连续问答,又不超过对方限流窗口的长度,
|
||||
# 「已恢复」被压住的时间不会超过一个限流窗口。
|
||||
# 失败 10 秒:对方恢复后我们最多瞎等 10 秒。必须显著短于成功 TTL(否则服务恢复了
|
||||
# 我们还要再等一分钟),这是任务书 §5.4 的红线。
|
||||
_REST_HEALTH_SUCCESS_TTL_SECONDS = 60.0
|
||||
_REST_HEALTH_FAILURE_TTL_SECONDS = 10.0
|
||||
|
||||
_REST_HEALTH_LOCK = threading.Lock()
|
||||
_REST_HEALTH_CACHE: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""单调时钟;测试把它推过 TTL。用 monotonic 而非墙钟,避免 NTP 校时影响过期判断。"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _rest_health_cache_key() -> tuple[Any, ...]:
|
||||
"""影响探测结论的有效配置。配置一变立刻失效。
|
||||
|
||||
只记 ``VEDASTRO_API_KEY`` 是否配置的布尔值,绝不把 key 本身放进键(AGENTS §8)。
|
||||
"""
|
||||
config = build_gateway_config()
|
||||
return (
|
||||
config["mode"],
|
||||
os.environ.get("VEDASTRO_API_ENDPOINT", OFFICIAL_ENDPOINT).strip(),
|
||||
os.environ.get("VEDASTRO_SELF_HOST_ENDPOINT", "").strip(),
|
||||
_official_network_enabled(),
|
||||
bool(os.environ.get("VEDASTRO_API_KEY", "").strip()),
|
||||
os.environ.get("JYOTISH_SKIP_LOCAL_ENV", "").strip().lower(),
|
||||
)
|
||||
|
||||
|
||||
def reset_rest_health_cache() -> None:
|
||||
"""清空探测缓存。测试与运维用;生产路径不调用。"""
|
||||
with _REST_HEALTH_LOCK:
|
||||
_REST_HEALTH_CACHE.clear()
|
||||
|
||||
|
||||
def _probe_official_rest_health_uncached() -> dict[str, Any]:
|
||||
"""Health of the default official path: REST ``/api/Calculate`` Status Pass."""
|
||||
base = {
|
||||
"transport": "rest",
|
||||
@@ -260,12 +308,44 @@ def probe_official_rest_health() -> dict[str, Any]:
|
||||
return {**base, **result}
|
||||
|
||||
|
||||
def gateway_status() -> dict[str, Any]:
|
||||
def probe_official_rest_health(*, force_refresh: bool = True) -> dict[str, Any]:
|
||||
"""Health of the default official path.
|
||||
|
||||
``force_refresh=True``(默认)永远打真探测——诊断端点靠的就是这个,
|
||||
运维不得看到一个 60 秒前的假象。前台每轮都走的路径显式传 ``force_refresh=False``
|
||||
去读 TTL 缓存。
|
||||
"""
|
||||
if force_refresh:
|
||||
return _probe_official_rest_health_uncached()
|
||||
|
||||
key = _rest_health_cache_key()
|
||||
with _REST_HEALTH_LOCK:
|
||||
entry = _REST_HEALTH_CACHE.get("entry")
|
||||
if entry is not None and entry["key"] == key and entry["expires_at"] > _monotonic():
|
||||
return dict(entry["result"])
|
||||
|
||||
# 探测本身不持锁:不得在等外网时把进程级锁按住(BUG-718 的教训)。
|
||||
result = _probe_official_rest_health_uncached()
|
||||
ttl = (
|
||||
_REST_HEALTH_SUCCESS_TTL_SECONDS
|
||||
if result.get("status") == "official_verified"
|
||||
else _REST_HEALTH_FAILURE_TTL_SECONDS
|
||||
)
|
||||
with _REST_HEALTH_LOCK:
|
||||
_REST_HEALTH_CACHE["entry"] = {
|
||||
"key": key,
|
||||
"expires_at": _monotonic() + ttl,
|
||||
"result": dict(result),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def gateway_status(*, force_refresh: bool = True) -> dict[str, Any]:
|
||||
from scripts.diagnose_vedastro_mode import build_report as build_vedastro_mode_report
|
||||
|
||||
readiness = build_vedastro_mode_report()
|
||||
config = build_gateway_config()
|
||||
rest_health = probe_official_rest_health()
|
||||
rest_health = probe_official_rest_health(force_refresh=force_refresh)
|
||||
return {
|
||||
"scope": "vedastro_gateway",
|
||||
"mode": config["mode"],
|
||||
@@ -387,7 +467,10 @@ def run_gateway_packet(
|
||||
) -> dict[str, Any]:
|
||||
from scripts.vedastro_user_entrypoint import build_report
|
||||
|
||||
gateway = gateway_status()
|
||||
# BUG-734: 这是每一轮前台请求都走的路。健康探测读 TTL 缓存,
|
||||
# 不再每轮拿一个对方的限流令牌去做一次完整业务调用。
|
||||
# 诊断端点仍走默认的 force_refresh=True。
|
||||
gateway = gateway_status(force_refresh=False)
|
||||
args = _entrypoint_args(case, question, themes, reference_date)
|
||||
report = build_report(args)
|
||||
catalog = report.get("official_capability_catalog") or {}
|
||||
|
||||
+107
-51
@@ -11,6 +11,7 @@ Yoga 规则引擎 v1.0 (数据驱动架构)
|
||||
"""
|
||||
|
||||
import ast
|
||||
import functools
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Any, Optional
|
||||
@@ -544,6 +545,94 @@ class YogaContext:
|
||||
return lst[0] if lst else None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# custom 规则表达式的编译缓存(BUG-735)
|
||||
# ============================================================================
|
||||
# `expr` 只来自仓库内的静态规则表 references/yoga_rules.json,进程生命周期内不变,
|
||||
# 改前却每个请求重新编译一遍(本机实测 386 次/次检测:182 次 eval 字符串编译 +
|
||||
# 204 次 ast.parse/compile)。这里只缓存 code object。
|
||||
#
|
||||
# 硬约束:求值命名空间 exec_globals 依赖每次不同的盘上下文(ctx),必须每次新建;
|
||||
# 绝不缓存求值结果——缓存结果会让所有人拿到同一张盘的 yoga 判定。
|
||||
#
|
||||
# maxsize 512:规则表去重后 192 条表达式,留出增长余量,同时给缓存一个硬上界。
|
||||
_CUSTOM_EXPR_CACHE_MAXSIZE = 512
|
||||
|
||||
|
||||
def _capture_tail_expr(block):
|
||||
"""把代码块末尾表达式改写为 __result__ = <expr>;递归处理 if/for/while 分支。"""
|
||||
if not block:
|
||||
return block
|
||||
last = block[-1]
|
||||
if isinstance(last, ast.Expr):
|
||||
block[-1] = ast.Assign(targets=[ast.Name(id="__result__", ctx=ast.Store())], value=last.value)
|
||||
elif isinstance(last, ast.If):
|
||||
last.body = _capture_tail_expr(last.body)
|
||||
last.orelse = _capture_tail_expr(last.orelse)
|
||||
elif isinstance(last, (ast.For, ast.While)):
|
||||
last.body = _capture_tail_expr(last.body)
|
||||
last.orelse = _capture_tail_expr(last.orelse)
|
||||
elif isinstance(last, ast.Try):
|
||||
last.body = _capture_tail_expr(last.body)
|
||||
last.orelse = _capture_tail_expr(last.orelse)
|
||||
last.finalbody = _capture_tail_expr(last.finalbody)
|
||||
for handler in last.handlers:
|
||||
handler.body = _capture_tail_expr(handler.body)
|
||||
return block
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=_CUSTOM_EXPR_CACHE_MAXSIZE)
|
||||
def _compiled_eval_code(expr):
|
||||
"""表达式模式的 code object;不是单个 expression 时返回 None(改前由 SyntaxError 表达)。"""
|
||||
try:
|
||||
return compile(expr, "<string>", "eval")
|
||||
except SyntaxError:
|
||||
return None
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=_CUSTOM_EXPR_CACHE_MAXSIZE)
|
||||
def _compiled_exec_code(expr):
|
||||
"""多语句模式的 code object:先按 AST 捕获末尾表达式,再编译。
|
||||
|
||||
v6.0.32: 支持多行语句(if/else/for 等)+ 末尾表达式求值模式。
|
||||
不能简单 split 最后一行:多行 if/else 的最后一行经常只是 else 分支内部表达式,
|
||||
直接 exec 前半段会产生缩进不完整。这里用 AST 捕获“实际执行分支”的末尾表达式。
|
||||
"""
|
||||
try:
|
||||
tree = ast.parse(expr.strip(), mode="exec")
|
||||
tree.body = _capture_tail_expr(tree.body)
|
||||
ast.fix_missing_locations(tree)
|
||||
return compile(tree, "<yoga_custom>", "exec")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _run_custom_expr(expr, exec_globals):
|
||||
"""按改前语义求值 custom 表达式:先表达式模式,SyntaxError 退到多语句 exec 模式。
|
||||
|
||||
只有 code object 来自缓存;`exec_globals` 由调用方每次新建。
|
||||
"""
|
||||
result = None
|
||||
code = _compiled_eval_code(expr)
|
||||
if code is not None:
|
||||
try:
|
||||
# 同一命名空间同时作为 globals/locals,保证 list/dict comprehension 能访问 BENEFICS 等名称。
|
||||
result = eval(code, exec_globals, exec_globals)
|
||||
except SyntaxError:
|
||||
code = None # 运行期 SyntaxError:与改前一样退到多语句分支
|
||||
except Exception:
|
||||
return None
|
||||
if code is None:
|
||||
exec_code = _compiled_exec_code(expr)
|
||||
if exec_code is not None:
|
||||
try:
|
||||
exec(exec_code, exec_globals, exec_globals)
|
||||
result = exec_globals.get("__result__")
|
||||
except Exception:
|
||||
return None
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# YogaEngine: 规则加载与检测
|
||||
# ============================================================================
|
||||
@@ -1238,6 +1327,23 @@ class YogaEngine:
|
||||
if not expr:
|
||||
return []
|
||||
|
||||
# BUG-735: exec_globals 每次新建(依赖当前盘 ctx),只有 code object 走进程级缓存。
|
||||
exec_globals = self._build_custom_exec_globals(ctx, rule, bindings)
|
||||
result = _run_custom_expr(expr, exec_globals)
|
||||
|
||||
if result:
|
||||
combo = cond.get("combo_template", "自定义条件满足")
|
||||
if callable(combo):
|
||||
combo = combo(ctx, bindings)
|
||||
strength = cond.get("strength", rule.get("strength", "中"))
|
||||
if callable(strength):
|
||||
strength = strength(ctx, bindings)
|
||||
return [{"combination": combo, "strength": strength}]
|
||||
return []
|
||||
|
||||
def _build_custom_exec_globals(self, ctx, rule, bindings):
|
||||
"""构造 custom 表达式的求值命名空间。每次调用都新建,绑定当前这张盘的 ctx。"""
|
||||
|
||||
def house_of(p): return ctx.house_of(p)
|
||||
def sign_of(p): return ctx.sign_of(p)
|
||||
def deg_of(p): return ctx.degree_of(p)
|
||||
@@ -1542,57 +1648,7 @@ class YogaEngine:
|
||||
"kendra_lords_list": _kendra_lords_list,
|
||||
"trikona_lords_list": _trikona_lords_list,
|
||||
}
|
||||
# v6.0.32: 支持多行语句(if/else/for等)+ 末尾表达式求值模式。
|
||||
# 不能简单 split 最后一行:多行 if/else 的最后一行经常只是 else 分支内部表达式,
|
||||
# 直接 exec 前半段会产生缩进不完整。这里用 AST 捕获“实际执行分支”的末尾表达式。
|
||||
result = None
|
||||
exec_globals = {"__builtins__": {}, **safe_globals}
|
||||
|
||||
def _capture_tail_expr(block):
|
||||
"""把代码块末尾表达式改写为 __result__ = <expr>;递归处理 if/for/while 分支。"""
|
||||
if not block:
|
||||
return block
|
||||
last = block[-1]
|
||||
if isinstance(last, ast.Expr):
|
||||
block[-1] = ast.Assign(targets=[ast.Name(id="__result__", ctx=ast.Store())], value=last.value)
|
||||
elif isinstance(last, ast.If):
|
||||
last.body = _capture_tail_expr(last.body)
|
||||
last.orelse = _capture_tail_expr(last.orelse)
|
||||
elif isinstance(last, (ast.For, ast.While)):
|
||||
last.body = _capture_tail_expr(last.body)
|
||||
last.orelse = _capture_tail_expr(last.orelse)
|
||||
elif isinstance(last, ast.Try):
|
||||
last.body = _capture_tail_expr(last.body)
|
||||
last.orelse = _capture_tail_expr(last.orelse)
|
||||
last.finalbody = _capture_tail_expr(last.finalbody)
|
||||
for handler in last.handlers:
|
||||
handler.body = _capture_tail_expr(handler.body)
|
||||
return block
|
||||
|
||||
try:
|
||||
# 同一命名空间同时作为 globals/locals,保证 list/dict comprehension 能访问 BENEFICS 等名称。
|
||||
result = eval(expr, exec_globals, exec_globals)
|
||||
except SyntaxError:
|
||||
try:
|
||||
tree = ast.parse(expr.strip(), mode="exec")
|
||||
tree.body = _capture_tail_expr(tree.body)
|
||||
ast.fix_missing_locations(tree)
|
||||
exec(compile(tree, "<yoga_custom>", "exec"), exec_globals, exec_globals)
|
||||
result = exec_globals.get("__result__")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if result:
|
||||
combo = cond.get("combo_template", "自定义条件满足")
|
||||
if callable(combo):
|
||||
combo = combo(ctx, bindings)
|
||||
strength = cond.get("strength", rule.get("strength", "中"))
|
||||
if callable(strength):
|
||||
strength = strength(ctx, bindings)
|
||||
return [{"combination": combo, "strength": strength}]
|
||||
return []
|
||||
return {"__builtins__": {}, **safe_globals}
|
||||
|
||||
def _eval_semantic_condition(self, ctype, cond, ctx, rule, bindings):
|
||||
"""兼容批量抽取规则中的语义型条件。返回保守布尔匹配。"""
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BUG-734: 官方 REST 健康探测的 TTL 缓存。
|
||||
|
||||
探测不是 ping——它 POST 一份虚构 smoke 排盘到官方 HoroscopePredictions,
|
||||
并消耗对方每分钟 5 个限流令牌之一。改前每一轮前台请求都做一次。
|
||||
|
||||
两条边界同时成立:
|
||||
- 前台路径(`run_gateway_packet`)读缓存;
|
||||
- 诊断端点仍然实时,运维不得看到一个 60 秒前的假象。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts import vedastro_gateway
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_cache(monkeypatch):
|
||||
"""每个用例都从空缓存、确定的配置起步。"""
|
||||
monkeypatch.delenv("JYOTISH_SKIP_LOCAL_ENV", raising=False)
|
||||
monkeypatch.delenv("VEDASTRO_API_KEY", raising=False)
|
||||
monkeypatch.delenv("VEDASTRO_SELF_HOST_ENDPOINT", raising=False)
|
||||
monkeypatch.delenv("VEDASTRO_API_ENDPOINT", raising=False)
|
||||
monkeypatch.delenv("VEDASTRO_GATEWAY_MODE", raising=False)
|
||||
monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "1")
|
||||
vedastro_gateway.reset_rest_health_cache()
|
||||
yield
|
||||
vedastro_gateway.reset_rest_health_cache()
|
||||
|
||||
|
||||
class _Clock:
|
||||
"""可推进的单调时钟。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.now = 1000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
def advance(self, seconds: float) -> None:
|
||||
self.now += seconds
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock(monkeypatch) -> _Clock:
|
||||
c = _Clock()
|
||||
monkeypatch.setattr(vedastro_gateway, "_monotonic", c)
|
||||
return c
|
||||
|
||||
|
||||
class _ProbeCounter:
|
||||
"""替掉真正的外网探测,统计它被调了几次。"""
|
||||
|
||||
def __init__(self, status: str = "official_verified") -> None:
|
||||
self.calls = 0
|
||||
self.status = status
|
||||
|
||||
def __call__(self, *args, **kwargs) -> dict:
|
||||
self.calls += 1
|
||||
reason = "status_pass" if self.status == "official_verified" else "URLError"
|
||||
return {
|
||||
"status": self.status,
|
||||
"reason": reason,
|
||||
"endpoint": vedastro_gateway.OFFICIAL_CALCULATE_ENDPOINT,
|
||||
"transport": "rest",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def probe(monkeypatch) -> _ProbeCounter:
|
||||
counter = _ProbeCounter()
|
||||
from scripts import vedastro_rest_bridge
|
||||
|
||||
monkeypatch.setattr(vedastro_rest_bridge, "probe_calculate_health", counter)
|
||||
return counter
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TTL 本身
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_three_cached_probes_hit_the_network_once(probe, clock) -> None:
|
||||
results = [vedastro_gateway.probe_official_rest_health(force_refresh=False) for _ in range(3)]
|
||||
|
||||
assert probe.calls == 1, f"连调 3 次只应探测 1 次,实际 {probe.calls} 次"
|
||||
assert all(r["status"] == "official_verified" for r in results)
|
||||
assert results[0] == results[1] == results[2]
|
||||
|
||||
|
||||
def test_success_result_is_re_probed_after_the_success_ttl(probe, clock) -> None:
|
||||
vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
assert probe.calls == 1
|
||||
|
||||
clock.advance(vedastro_gateway._REST_HEALTH_SUCCESS_TTL_SECONDS - 1)
|
||||
vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
assert probe.calls == 1, "TTL 之内不得重新探测"
|
||||
|
||||
clock.advance(2)
|
||||
vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
assert probe.calls == 2, "推过 TTL 后必须重新探测"
|
||||
|
||||
|
||||
def test_failure_is_not_cached_for_the_success_ttl(probe, clock) -> None:
|
||||
"""红线 §5.4:对方恢复了我们不能还要再瞎等一分钟。"""
|
||||
probe.status = "official_blocked"
|
||||
assert vedastro_gateway.probe_official_rest_health(force_refresh=False)["status"] == "official_blocked"
|
||||
assert probe.calls == 1
|
||||
|
||||
# 失败 TTL 之内:不重探。
|
||||
clock.advance(vedastro_gateway._REST_HEALTH_FAILURE_TTL_SECONDS - 1)
|
||||
vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
assert probe.calls == 1
|
||||
|
||||
# 过了失败 TTL、但还远在成功 TTL 之内:必须重探。
|
||||
clock.advance(2)
|
||||
assert clock.now < 1000.0 + vedastro_gateway._REST_HEALTH_SUCCESS_TTL_SECONDS
|
||||
probe.status = "official_verified"
|
||||
recovered = vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
assert probe.calls == 2
|
||||
assert recovered["status"] == "official_verified", "服务恢复后必须能在失败 TTL 内被看见"
|
||||
|
||||
|
||||
def test_failure_ttl_is_clearly_shorter_than_success_ttl() -> None:
|
||||
assert vedastro_gateway._REST_HEALTH_FAILURE_TTL_SECONDS < vedastro_gateway._REST_HEALTH_SUCCESS_TTL_SECONDS
|
||||
assert vedastro_gateway._REST_HEALTH_SUCCESS_TTL_SECONDS <= 60.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 配置一变立刻失效
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_configuration_change_invalidates_the_cache(probe, clock, monkeypatch) -> None:
|
||||
vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
assert probe.calls == 1
|
||||
|
||||
monkeypatch.setenv("VEDASTRO_API_KEY", "fictional-test-key")
|
||||
vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
assert probe.calls == 2, "配了 API key 之后必须重新探测"
|
||||
|
||||
vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
assert probe.calls == 2, "同一份新配置应当重新命中缓存"
|
||||
|
||||
monkeypatch.setenv("VEDASTRO_API_ENDPOINT", "https://example.invalid/api")
|
||||
vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
assert probe.calls == 3, "换 endpoint 必须重新探测"
|
||||
|
||||
|
||||
def test_cache_key_never_carries_the_secret_value(monkeypatch) -> None:
|
||||
"""AGENTS §8:缓存键只记「配没配」,不记 key 本身。"""
|
||||
monkeypatch.setenv("VEDASTRO_API_KEY", "fictional-test-key")
|
||||
key_with = vedastro_gateway._rest_health_cache_key()
|
||||
monkeypatch.delenv("VEDASTRO_API_KEY")
|
||||
key_without = vedastro_gateway._rest_health_cache_key()
|
||||
|
||||
assert "fictional-test-key" not in repr(key_with), "缓存键里出现了 key 的值"
|
||||
assert key_with != key_without, "配没配 key 必须能区分开,否则缓存会跨配置串"
|
||||
|
||||
|
||||
def test_network_disabled_is_not_probed_at_all(monkeypatch, clock) -> None:
|
||||
from scripts import vedastro_rest_bridge
|
||||
|
||||
def explode(*args, **kwargs): # pragma: no cover - must never run
|
||||
raise AssertionError("network disabled 时不得触碰外网")
|
||||
|
||||
monkeypatch.setattr(vedastro_rest_bridge, "probe_calculate_health", explode)
|
||||
monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0")
|
||||
result = vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
assert result["status"] == "official_blocked"
|
||||
assert result["reason"] == "network_disabled"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 诊断端点必须实时
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_force_refresh_always_probes(probe, clock) -> None:
|
||||
for _ in range(3):
|
||||
vedastro_gateway.probe_official_rest_health(force_refresh=True)
|
||||
assert probe.calls == 3, "force_refresh 必须每次都打真探测"
|
||||
|
||||
|
||||
def test_force_refresh_defaults_to_true_so_the_diagnostic_endpoint_stays_live(probe, clock) -> None:
|
||||
"""`_compute_vedastro_gateway_status` 调的是裸 `gateway_status()`(该文件本单不得改)。
|
||||
|
||||
因此默认值必须是实时探测,否则运维会看到一个 60 秒前的假象。
|
||||
"""
|
||||
for fn in (vedastro_gateway.probe_official_rest_health, vedastro_gateway.gateway_status):
|
||||
assert inspect.signature(fn).parameters["force_refresh"].default is True
|
||||
|
||||
vedastro_gateway.gateway_status()
|
||||
vedastro_gateway.gateway_status()
|
||||
assert probe.calls == 2, "诊断端点默认路径不得走缓存"
|
||||
|
||||
|
||||
def test_forced_probe_sees_the_current_state_even_while_a_stale_entry_exists(probe, clock) -> None:
|
||||
"""前台缓存里躺着一条旧结论时,诊断端点仍必须看到此刻的真实状态。"""
|
||||
probe.status = "official_blocked"
|
||||
vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
assert probe.calls == 1
|
||||
|
||||
probe.status = "official_verified"
|
||||
forced = vedastro_gateway.probe_official_rest_health(force_refresh=True)
|
||||
assert forced["status"] == "official_verified"
|
||||
assert probe.calls == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 前台路径
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_gateway_status_passes_force_refresh_through(probe, clock) -> None:
|
||||
vedastro_gateway.gateway_status(force_refresh=False)
|
||||
vedastro_gateway.gateway_status(force_refresh=False)
|
||||
vedastro_gateway.gateway_status(force_refresh=False)
|
||||
assert probe.calls == 1
|
||||
|
||||
|
||||
def test_run_gateway_packet_uses_the_cached_probe(monkeypatch, probe, clock) -> None:
|
||||
"""每一轮前台请求不得各拿一个对方的限流令牌去做健康探测。"""
|
||||
seen: list[bool] = []
|
||||
real_status = vedastro_gateway.gateway_status
|
||||
|
||||
def spy(*, force_refresh: bool = True):
|
||||
seen.append(force_refresh)
|
||||
return real_status(force_refresh=force_refresh)
|
||||
|
||||
monkeypatch.setattr(vedastro_gateway, "gateway_status", spy)
|
||||
monkeypatch.setattr(
|
||||
vedastro_gateway,
|
||||
"_entrypoint_args",
|
||||
lambda *a, **k: __import__("types").SimpleNamespace(),
|
||||
)
|
||||
import sys
|
||||
import types as _types
|
||||
|
||||
stub = _types.ModuleType("scripts.vedastro_user_entrypoint")
|
||||
stub.build_report = lambda args: {"input": {}, "runtime_mode": {}}
|
||||
monkeypatch.setitem(sys.modules, "scripts.vedastro_user_entrypoint", stub)
|
||||
|
||||
for _ in range(4):
|
||||
vedastro_gateway.run_gateway_packet({"year": 1955}, question="x")
|
||||
|
||||
assert seen == [False, False, False, False], "前台路径必须显式走缓存"
|
||||
assert probe.calls == 1, f"4 轮前台请求只应探测 1 次,实际 {probe.calls} 次"
|
||||
|
||||
|
||||
def test_cached_result_cannot_be_mutated_by_a_caller(probe, clock) -> None:
|
||||
first = vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
first["status"] = "tampered"
|
||||
second = vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
assert second["status"] == "official_verified"
|
||||
assert probe.calls == 1
|
||||
|
||||
|
||||
def test_probe_does_not_hold_the_lock_while_on_the_network(monkeypatch, clock) -> None:
|
||||
"""不得在等外网时把进程级锁按住(BUG-718 的教训)。"""
|
||||
observed: list[bool] = []
|
||||
|
||||
def probing(*args, **kwargs):
|
||||
acquired = vedastro_gateway._REST_HEALTH_LOCK.acquire(blocking=False)
|
||||
observed.append(acquired)
|
||||
if acquired:
|
||||
vedastro_gateway._REST_HEALTH_LOCK.release()
|
||||
return {"status": "official_verified", "reason": "status_pass", "transport": "rest"}
|
||||
|
||||
from scripts import vedastro_rest_bridge
|
||||
|
||||
monkeypatch.setattr(vedastro_rest_bridge, "probe_calculate_health", probing)
|
||||
vedastro_gateway.probe_official_rest_health(force_refresh=False)
|
||||
|
||||
assert observed == [True], "探测期间锁必须是可获取的"
|
||||
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BUG-735: custom yoga 表达式的编译缓存。
|
||||
|
||||
三件事各自独立证明:
|
||||
|
||||
1. **等价**:对规则表里全部去重表达式,缓存版本与改前实现逐条同值。
|
||||
证明方式是同进程差分(BUG-733 立下的做法):参照实现在本文件里逐字复刻改前代码,
|
||||
与生产实现在同一进程、同一批 exec_globals 上对跑,不写跨机 golden。
|
||||
2. **只缓存 code object**:同一条表达式配不同的盘必须得到不同答案。
|
||||
这条是红线——缓存求值结果会让所有人拿到同一张盘的 yoga 判定。
|
||||
3. **编译次数**:第二次检测的源码编译次数为 0,不随请求数线性增长。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import builtins
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
RULES_PATH = ROOT / "references" / "yoga_rules.json"
|
||||
|
||||
import yoga_engine # noqa: E402
|
||||
from yoga_engine import YogaContext, YogaEngine # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 公开虚构示例盘。不是任何真实用户的出生资料。
|
||||
# ---------------------------------------------------------------------------
|
||||
SAMPLE_CHARTS = [
|
||||
(
|
||||
"Aries",
|
||||
{
|
||||
"Sun": {"house": 10, "sign": "Capricorn", "degree": 16.4},
|
||||
"Moon": {"house": 4, "sign": "Cancer", "degree": 2.1},
|
||||
"Mars": {"house": 12, "sign": "Pisces", "degree": 23.8},
|
||||
"Mercury": {"house": 9, "sign": "Sagittarius", "degree": 27.3},
|
||||
"Jupiter": {"house": 4, "sign": "Cancer", "degree": 8.9},
|
||||
"Venus": {"house": 9, "sign": "Sagittarius", "degree": 13.6},
|
||||
"Saturn": {"house": 10, "sign": "Capricorn", "degree": 20.2},
|
||||
"Rahu": {"house": 8, "sign": "Scorpio", "degree": 1.5},
|
||||
"Ketu": {"house": 2, "sign": "Taurus", "degree": 1.5},
|
||||
},
|
||||
),
|
||||
(
|
||||
"Leo",
|
||||
{
|
||||
"Sun": {"house": 5, "sign": "Gemini", "degree": 10.0},
|
||||
"Moon": {"house": 5, "sign": "Gemini", "degree": 15.0},
|
||||
"Mars": {"house": 8, "sign": "Pisces", "degree": 10.0},
|
||||
"Mercury": {"house": 10, "sign": "Capricorn", "degree": 10.0},
|
||||
"Jupiter": {"house": 11, "sign": "Sagittarius", "degree": 5.0},
|
||||
"Venus": {"house": 2, "sign": "Libra", "degree": 10.0},
|
||||
"Saturn": {"house": 7, "sign": "Aquarius", "degree": 10.0},
|
||||
"Rahu": {"house": 6, "sign": "Capricorn", "degree": 10.0},
|
||||
"Ketu": {"house": 12, "sign": "Cancer", "degree": 10.0},
|
||||
},
|
||||
),
|
||||
(
|
||||
"Libra",
|
||||
{
|
||||
"Sun": {"house": 1, "sign": "Libra", "degree": 22.0},
|
||||
"Moon": {"house": 7, "sign": "Aries", "degree": 4.0},
|
||||
"Mars": {"house": 4, "sign": "Capricorn", "degree": 28.0},
|
||||
"Mercury": {"house": 2, "sign": "Scorpio", "degree": 3.0},
|
||||
"Jupiter": {"house": 10, "sign": "Cancer", "degree": 17.0},
|
||||
"Venus": {"house": 12, "sign": "Virgo", "degree": 9.0},
|
||||
"Saturn": {"house": 1, "sign": "Libra", "degree": 6.0},
|
||||
"Rahu": {"house": 3, "sign": "Sagittarius", "degree": 19.0},
|
||||
"Ketu": {"house": 9, "sign": "Gemini", "degree": 19.0},
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _all_rule_expressions() -> list[str]:
|
||||
"""规则表里全部 `expr`(去重后按文本排序,保证用例顺序稳定)。"""
|
||||
data = json.loads(RULES_PATH.read_text(encoding="utf-8"))
|
||||
found: list[str] = []
|
||||
|
||||
def walk(node: object) -> None:
|
||||
if isinstance(node, dict):
|
||||
value = node.get("expr")
|
||||
if isinstance(value, str) and value.strip():
|
||||
found.append(value)
|
||||
for child in node.values():
|
||||
walk(child)
|
||||
elif isinstance(node, list):
|
||||
for child in node:
|
||||
walk(child)
|
||||
|
||||
walk(data)
|
||||
return sorted(set(found))
|
||||
|
||||
|
||||
RULE_EXPRESSIONS = _all_rule_expressions()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 参照实现:BUG-735 改前的 `_eval_custom` 尾部,逐字复刻。
|
||||
# ---------------------------------------------------------------------------
|
||||
def _reference_capture_tail_expr(block):
|
||||
if not block:
|
||||
return block
|
||||
last = block[-1]
|
||||
if isinstance(last, ast.Expr):
|
||||
block[-1] = ast.Assign(targets=[ast.Name(id="__result__", ctx=ast.Store())], value=last.value)
|
||||
elif isinstance(last, ast.If):
|
||||
last.body = _reference_capture_tail_expr(last.body)
|
||||
last.orelse = _reference_capture_tail_expr(last.orelse)
|
||||
elif isinstance(last, (ast.For, ast.While)):
|
||||
last.body = _reference_capture_tail_expr(last.body)
|
||||
last.orelse = _reference_capture_tail_expr(last.orelse)
|
||||
elif isinstance(last, ast.Try):
|
||||
last.body = _reference_capture_tail_expr(last.body)
|
||||
last.orelse = _reference_capture_tail_expr(last.orelse)
|
||||
last.finalbody = _reference_capture_tail_expr(last.finalbody)
|
||||
for handler in last.handlers:
|
||||
handler.body = _reference_capture_tail_expr(handler.body)
|
||||
return block
|
||||
|
||||
|
||||
def _reference_run(expr, exec_globals):
|
||||
"""改前行为:每次重新编译;先 eval,SyntaxError 退到改写过的多语句 exec。"""
|
||||
result = None
|
||||
try:
|
||||
result = eval(expr, exec_globals, exec_globals)
|
||||
except SyntaxError:
|
||||
try:
|
||||
tree = ast.parse(expr.strip(), mode="exec")
|
||||
tree.body = _reference_capture_tail_expr(tree.body)
|
||||
ast.fix_missing_locations(tree)
|
||||
exec(compile(tree, "<yoga_custom>", "exec"), exec_globals, exec_globals)
|
||||
result = exec_globals.get("__result__")
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def engine() -> YogaEngine:
|
||||
return YogaEngine(str(RULES_PATH))
|
||||
|
||||
|
||||
def _globals_for(engine: YogaEngine, chart_index: int) -> dict:
|
||||
ascendant, planets = SAMPLE_CHARTS[chart_index]
|
||||
ctx = YogaContext(planets, ascendant)
|
||||
return engine._build_custom_exec_globals(ctx, {}, {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. 等价:同进程差分
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_cached_compile_matches_pre_change_behaviour_for_every_rule_expression(engine) -> None:
|
||||
"""全部 192 条去重表达式,缓存版与参照版在三张示例盘上逐条同值。"""
|
||||
assert len(RULE_EXPRESSIONS) >= 190, "规则表表达式数量异常,等价证明的覆盖面不足"
|
||||
|
||||
mismatches = []
|
||||
for chart_index in range(len(SAMPLE_CHARTS)):
|
||||
for expr in RULE_EXPRESSIONS:
|
||||
# 两侧各用一份全新命名空间:exec 会往里写 __result__,不能共用。
|
||||
live = yoga_engine._run_custom_expr(expr, _globals_for(engine, chart_index))
|
||||
reference = _reference_run(expr, _globals_for(engine, chart_index))
|
||||
if live != reference or bool(live) != bool(reference):
|
||||
mismatches.append((chart_index, expr[:80], reference, live))
|
||||
|
||||
assert not mismatches, f"{len(mismatches)} 条表达式与改前行为不同: {mismatches[:3]}"
|
||||
|
||||
|
||||
def test_reference_differential_can_actually_fail(engine) -> None:
|
||||
"""反向验证:差分不是恒真——喂一个被改坏的表达式两侧就该分叉。"""
|
||||
exec_globals_a = _globals_for(engine, 0)
|
||||
exec_globals_b = _globals_for(engine, 0)
|
||||
assert yoga_engine._run_custom_expr("house_of('Sun')", exec_globals_a) == _reference_run(
|
||||
"house_of('Sun')", exec_globals_b
|
||||
)
|
||||
assert yoga_engine._run_custom_expr("house_of('Sun')", _globals_for(engine, 0)) != _reference_run(
|
||||
"house_of('Moon')", _globals_for(engine, 0)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. 红线:只缓存 code object,绝不缓存求值结果
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_cache_stores_code_objects_not_results() -> None:
|
||||
expr = "house_of('Sun')"
|
||||
yoga_engine._compiled_eval_code.cache_clear()
|
||||
compiled = yoga_engine._compiled_eval_code(expr)
|
||||
assert isinstance(compiled, types.CodeType), "eval 分支缓存里必须是 code object"
|
||||
|
||||
multi = "x = house_of('Sun')\nx"
|
||||
yoga_engine._compiled_exec_code.cache_clear()
|
||||
assert yoga_engine._compiled_eval_code(multi) is None, "多语句表达式在 eval 模式下必须仍然是 None"
|
||||
assert isinstance(yoga_engine._compiled_exec_code(multi), types.CodeType)
|
||||
|
||||
|
||||
def test_same_expression_on_different_charts_never_reuses_a_result(engine) -> None:
|
||||
"""红线:同一条表达式配不同的盘必须给出不同答案(不得跨盘串结果)。"""
|
||||
expr = "house_of('Sun')"
|
||||
first = yoga_engine._run_custom_expr(expr, _globals_for(engine, 0))
|
||||
second = yoga_engine._run_custom_expr(expr, _globals_for(engine, 1))
|
||||
third = yoga_engine._run_custom_expr(expr, _globals_for(engine, 0))
|
||||
|
||||
assert first == 10 and second == 5, "示例盘的 Sun 宫位应当不同,否则这条断言没有鉴别力"
|
||||
assert first != second, "求值结果被缓存了:同一表达式在不同盘上返回了同一个答案"
|
||||
assert first == third, "同一张盘重复求值应当稳定"
|
||||
|
||||
multi = "h = house_of('Sun')\nh + 100"
|
||||
assert yoga_engine._run_custom_expr(multi, _globals_for(engine, 0)) == 110
|
||||
assert yoga_engine._run_custom_expr(multi, _globals_for(engine, 1)) == 105
|
||||
|
||||
|
||||
def test_run_custom_expr_is_not_memoized() -> None:
|
||||
"""求值入口本身不得带缓存装饰器。"""
|
||||
assert not hasattr(yoga_engine._run_custom_expr, "cache_info")
|
||||
assert hasattr(yoga_engine._compiled_eval_code, "cache_info")
|
||||
assert hasattr(yoga_engine._compiled_exec_code, "cache_info")
|
||||
|
||||
|
||||
def test_compile_cache_is_bounded() -> None:
|
||||
"""编译缓存必须有界(红线 §5.3)。"""
|
||||
for cached in (yoga_engine._compiled_eval_code, yoga_engine._compiled_exec_code):
|
||||
maxsize = cached.cache_info().maxsize
|
||||
assert maxsize is not None, "编译缓存不得无界"
|
||||
assert maxsize >= 256, "maxsize 必须容纳规则表去重后的表达式数量"
|
||||
assert yoga_engine._CUSTOM_EXPR_CACHE_MAXSIZE >= len(RULE_EXPRESSIONS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. 编译次数
|
||||
# ---------------------------------------------------------------------------
|
||||
class _CompileCounter:
|
||||
"""统计一次检测里发生了多少次「源码 → 字节码」编译。
|
||||
|
||||
`eval(str)` 的编译发生在 CPython 内部、不经过 builtins.compile,
|
||||
所以两者都要数:`eval(str)` 调用数 + `builtins.compile` 调用数。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.eval_str = 0
|
||||
self.eval_code = 0
|
||||
self.compile = 0
|
||||
self._orig_eval = builtins.eval
|
||||
self._orig_compile = builtins.compile
|
||||
|
||||
def __enter__(self) -> "_CompileCounter":
|
||||
def counting_eval(source, *args, **kwargs):
|
||||
if isinstance(source, str):
|
||||
self.eval_str += 1
|
||||
else:
|
||||
self.eval_code += 1
|
||||
return self._orig_eval(source, *args, **kwargs)
|
||||
|
||||
def counting_compile(source, *args, **kwargs):
|
||||
self.compile += 1
|
||||
return self._orig_compile(source, *args, **kwargs)
|
||||
|
||||
builtins.eval = counting_eval
|
||||
builtins.compile = counting_compile
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
builtins.eval = self._orig_eval
|
||||
builtins.compile = self._orig_compile
|
||||
|
||||
@property
|
||||
def source_compiles(self) -> int:
|
||||
return self.eval_str + self.compile
|
||||
|
||||
|
||||
def test_second_detection_compiles_nothing(engine) -> None:
|
||||
ascendant, planets = SAMPLE_CHARTS[0]
|
||||
yoga_engine._compiled_eval_code.cache_clear()
|
||||
yoga_engine._compiled_exec_code.cache_clear()
|
||||
|
||||
with _CompileCounter() as first:
|
||||
first_result = engine.detect(planets, ascendant)
|
||||
with _CompileCounter() as second:
|
||||
second_result = engine.detect(planets, ascendant)
|
||||
with _CompileCounter() as third:
|
||||
third_result = engine.detect(planets, ascendant)
|
||||
|
||||
assert first.source_compiles > 0, "冷缓存的第一次检测必须真的编译"
|
||||
assert second.source_compiles == 0, f"第二次检测仍编译了 {second.source_compiles} 次"
|
||||
assert third.source_compiles == 0, "编译次数不得随请求数增长"
|
||||
assert second.eval_code > 0, "第二次检测应当在复用 code object"
|
||||
assert second.eval_str == 0, "不得再有字符串 eval"
|
||||
assert first_result == second_result == third_result
|
||||
|
||||
|
||||
def test_detection_output_is_unchanged_across_charts(engine) -> None:
|
||||
"""缓存不得改变任何一张盘的判定输出。"""
|
||||
for ascendant, planets in SAMPLE_CHARTS:
|
||||
yoga_engine._compiled_eval_code.cache_clear()
|
||||
yoga_engine._compiled_exec_code.cache_clear()
|
||||
cold = engine.detect(planets, ascendant)
|
||||
warm = engine.detect(planets, ascendant)
|
||||
assert cold == warm
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. 来源合同:expr 只来自仓库内的静态规则表
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_rule_expressions_come_only_from_the_static_rules_table() -> None:
|
||||
"""红线 §5.3:若有用户输入能进入 `expr`,立刻停手。"""
|
||||
assert RULES_PATH.is_file()
|
||||
assert RULES_PATH.resolve().is_relative_to(ROOT.resolve())
|
||||
for expr in RULE_EXPRESSIONS:
|
||||
assert isinstance(expr, str)
|
||||
|
||||
source = (ROOT / "scripts" / "yoga_engine.py").read_text(encoding="utf-8")
|
||||
# 表达式的唯一读取点。
|
||||
assert source.count('cond.get("expr"') == 1
|
||||
|
||||
# 生产入口不得把 rules_path 交给调用方:默认路径是仓库内的规则表。
|
||||
for entry in ("scripts/jyotish_engine.py", "scripts/jyotish_api_server.py"):
|
||||
text = (ROOT / entry).read_text(encoding="utf-8")
|
||||
for line in text.splitlines():
|
||||
if "detect_yogas(" in line and "def " not in line:
|
||||
assert "rules_path" not in line, f"{entry} 给 yoga 引擎传了自定义规则路径: {line.strip()}"
|
||||
Reference in New Issue
Block a user