Files
Jyotisha/tests/test_yoga_expr_compile_cache.py
T
Jesse_ChenandClaude Opus 5 63c419f375
Independent Staging Quality Gate / validate (push) Canceled after 27s
Independent Staging Quality Gate / publish (push) Canceled after 0s
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
2026-09-16 02:01:33 +00:00

329 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()}"