Copy the full-data packet once per report, not once per domain (issue #122, BUG-1471)
Independent Staging Quality Gate / validate (push) Successful in 15m27s
Independent Staging Quality Gate / publish (push) Successful in 3m43s

read_full_data_domain deep-copied the shared worksheets on every call; a long
report reads five domains twice, so ten copies for read-only use. The shared
part is now copied once (full_data_domain_base) and reused for every domain's
facts and their leaf check; each domain's facts are still copied out so domains
share no objects. Long reports byte-identical on four fictional charts; with
#121, -24–29% end to end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbBXoz1Qk3y9buDv1mkmTu
This commit is contained in:
Jesse_Chen
2026-10-28 07:06:32 +08:00
co-authored by Claude Opus 5.5
parent f8fa4d66ef
commit 3eeb898071
6 changed files with 99 additions and 17 deletions
+6
View File
@@ -1,5 +1,11 @@
# 印度占星 Skill 更新日志
## 2026-10-28 — 个人长报告生成快约四分之一
- 第一次生成一份完整的个人长报告,本机实测从约 26 秒降到约 20 秒;报告内容一字不变。
- 两处改动:报告出稿的提速开关默认打开(issue #121);报告和咨询数据卡不再为每个领域把整份数据各复制一遍(issue #122)。
- Skill 版本不变(BUG-1335、BUG-1471)。
## 2026-10-28 — 星盘页页头整齐了,深色模式下几处灰底和白边去掉
- 首页输入框在深色模式下不再有一块灰底。
+15
View File
@@ -20065,3 +20065,18 @@
- 复发自:BUG-1466(深色变体未覆盖)
- 修复版本:分支 `codex/ui-polish-140-20261028`。
## BUG-1471 | 长报告与咨询数据卡每个领域都把整份数据包深拷贝一遍
- 状态:resolved(代码 + 回归测试;待部署)
- 首次发现 / 最近更新:2026-10-28 / 2026-10-28(issue #118 代码体检 → #122)
- 影响面:个人长报告(模型写的「读盘」前置事实)、咨询数据卡;`scripts/pl9_full_data_export.py` `read_full_data_domain`、`scripts/consult_card_domain_facts.py`、`scripts/report_render/reading_facts.py`。
- 现象:长报告冷生成慢;profile 口径下整包 deepcopy 合计约 9 s,其中 `read_full_data_domain` 约 5 s。
- 触发条件:任何长报告生成、任何带数据卡的咨询轮。
- 根因:`read_full_data_domain` 每调一次就把 `worksheets` 等共享部分整份深拷贝。长报告读 5 个领域,生成事实一遍、逐条核对又一遍,共 10 份;读的人全是只读。
- 修复:新增 `full_data_domain_base`,同一个数据包只深拷一次;`read_full_data_domain(..., base=)` 复用它。`build_consult_card_facts` 每次调用只建一次,长报告的事实与核对共用同一份。各领域产出的事实再各自深拷贝(体量小),保持「不同领域的结果互不共享对象」与改前一致。
- 验证:`scripts/research/report_reference_perf_ab.py --mask-pyjhora`(四张虚构盘各三次)改前 / 改后、开关开 / 关全部组合下完整报告逐字节相同;与改前相比,叠加 #121 后端到端快 24%~29%(中位约 26%),单论本单在开关打开时再快 1%~7%。JSON 与参考版两项附加检查在改前代码上连跑两次即不同(输出自身跨进程不确定),无法用跨代码 sha 比对,已注明。新测试 `tests/test_consult_card_full_source.py::test_all_domains_share_one_packet_copy_and_still_match_the_per_domain_path`:只建一次共享拷贝、结果与逐领域路径一致、领域间不共享对象。Python 快速门全部 Python 步骤通过(前端 npm 步因本工作树无依赖未跑,本单无前端改动)。
- 防复发:上面的测试;只读用途的「视图」不要每次整份深拷贝,多处读同一包时共享一份。
- 关联:BUG-1335(同一份报告的出稿与哈希重复劳动,#121 已开快路径)。溯源哈希仍在出稿后由质量门重算一次以核对,这是有意的完整性检查,未合并。
- 复发自:无
- 修复版本:分支 `codex/report-fast-default-20261028`。
+20 -7
View File
@@ -8,10 +8,12 @@ judgment table; the card builder does that from these leaves.
from __future__ import annotations
import copy
import json
from collections.abc import Mapping
from typing import Any
from pl9_full_data_export import read_full_data_domain
from pl9_full_data_export import full_data_domain_base, read_full_data_domain
PRECISION_SENTENCE = "出生时间精度不够,不参与"
RAHU_NOTE = "罗计不单独定功能吉凶"
@@ -783,10 +785,16 @@ def _factors(domain: str, timing: dict[str, Any], karakas: dict[str, dict[str, A
return factors
def build_domain_facts(packet: dict, domain: str, *, minute_confirmed: bool) -> dict[str, Any]:
def build_domain_facts(
packet: dict,
domain: str,
*,
minute_confirmed: bool,
base: Mapping | None = None,
) -> dict[str, Any]:
if domain not in PACKET_DOMAIN:
raise KeyError(domain)
view = read_full_data_domain(packet, PACKET_DOMAIN[domain])
view = read_full_data_domain(packet, PACKET_DOMAIN[domain], base=base)
varga = {
"career": "D10", "wealth": "D2", "marriage": "D9", "health": "D6",
"children": "D7", "parents": "D12", "education": "D24", "migration": "D4",
@@ -824,7 +832,9 @@ def build_domain_facts(packet: dict, domain: str, *, minute_confirmed: bool) ->
encoded = json.dumps(facts, ensure_ascii=False, default=str)
if "tajika_named_yogas" in encoded:
raise RuntimeError("consult card facts must not read tajika_named_yogas")
return facts
# Leaves may be objects of the view. With a shared base, two domains' facts
# would share them; each domain's facts stay their own, as before.
return facts if base is None else copy.deepcopy(facts)
def build_consult_card_facts(
@@ -833,13 +843,16 @@ def build_consult_card_facts(
*,
minute_confirmed: bool | None,
body: dict | None = None,
base: Mapping | None = None,
) -> dict[str, Any]:
confirmed = minute_confirmed_from_body(body) if minute_confirmed is None else minute_confirmed
rows = []
if base is None and any(domain in PACKET_DOMAIN for domain in domains):
base = full_data_domain_base(packet)
for domain in domains:
if domain not in PACKET_DOMAIN:
continue
rows.append(build_domain_facts(packet, domain, minute_confirmed=confirmed))
rows.append(build_domain_facts(packet, domain, minute_confirmed=confirmed, base=base))
return {
"schema": "consult-card-facts-v1",
"minute_confirmed": confirmed,
@@ -848,9 +861,9 @@ def build_consult_card_facts(
}
def assert_fact_leaves(packet: dict, facts: dict[str, Any]) -> None:
def assert_fact_leaves(packet: dict, facts: dict[str, Any], *, base: Mapping | None = None) -> None:
"""Every copied leaf equals the packet. Derived selections are not leaves."""
view = read_full_data_domain(packet, facts["packet_domain"])
view = read_full_data_domain(packet, facts["packet_domain"], base=base)
def check(item: Any) -> None:
if isinstance(item, list):
+23 -8
View File
@@ -2472,19 +2472,18 @@ def _append_annual_method_reference(markdown: str, packet: dict, language: str)
return markdown.rstrip() + "\n\n" + extra + "\n"
def read_full_data_domain(packet: Mapping, domain: str) -> dict:
"""Deep-copied packet view for one consult domain.
def full_data_domain_base(packet: Mapping) -> dict:
"""The detached, domain-independent part of every domain view.
Field selection belongs to the consult card. This entry only freezes the
domain name and detaches the copy from the live packet.
One deep copy of the packet's shared sections. ``read_full_data_domain``
used to make this copy once per domain, and a long report reads five
domains twice (facts, then the leaf check): ten copies of the whole
``worksheets`` tree for read-only use (issue #122). Callers that read
several domains of one packet build this once and pass it as ``base``.
"""
if domain not in FULL_DATA_DOMAINS:
raise KeyError(domain)
if not isinstance(packet, Mapping):
raise TypeError("packet must be a mapping")
copied = copy.deepcopy({
"domain": domain,
"label": FULL_DATA_DOMAINS[domain],
"report_version": packet.get("report_version"),
"birth_info": packet.get("birth_info"),
"core_chart": packet.get("core_chart"),
@@ -2496,3 +2495,19 @@ def read_full_data_domain(packet: Mapping, domain: str) -> dict:
if isinstance(timing, dict):
timing.pop("pyjhora_annual_method_reference", None)
return copied
def read_full_data_domain(packet: Mapping, domain: str, *, base: Mapping | None = None) -> dict:
"""Detached packet view for one consult domain.
Field selection belongs to the consult card. This entry only freezes the
domain name and detaches the view from the live packet. With ``base`` (from
``full_data_domain_base`` of the same packet) the shared sections are that
one copy; views of different domains then share it and must be read only.
"""
if domain not in FULL_DATA_DOMAINS:
raise KeyError(domain)
if not isinstance(packet, Mapping):
raise TypeError("packet must be a mapping")
shared = full_data_domain_base(packet) if base is None else base
return {"domain": domain, "label": FULL_DATA_DOMAINS[domain], **shared}
+6 -2
View File
@@ -14,9 +14,11 @@ from typing import Any
try:
from scripts.consult_card_domain_facts import assert_fact_leaves, build_consult_card_facts, dig
from scripts.consult_full_data_cache import _attach_graha_drishti
from scripts.pl9_full_data_export import full_data_domain_base
except ModuleNotFoundError: # pragma: no cover - direct scripts/ execution path
from consult_card_domain_facts import assert_fact_leaves, build_consult_card_facts, dig
from consult_full_data_cache import _attach_graha_drishti
from pl9_full_data_export import full_data_domain_base
READING_FACTS_SCHEMA = "report-reading-facts-v1"
READING_DOMAINS = ("general", "career", "wealth", "marriage", "health")
@@ -87,10 +89,12 @@ def build_report_reading_facts(
) -> dict[str, Any]:
"""Compact, packet-sourced facts for the reading. Raises on any non-packet leaf."""
working = _attach_graha_drishti(copy.deepcopy(packet), full_reading)
raw = build_consult_card_facts(working, list(READING_DOMAINS), minute_confirmed=minute_confirmed)
# One detached copy of the packet serves every domain's facts and their leaf check (issue #122).
base = full_data_domain_base(working)
raw = build_consult_card_facts(working, list(READING_DOMAINS), minute_confirmed=minute_confirmed, base=base)
rows = {row["domain"]: row for row in raw["domains"]}
for row in rows.values():
assert_fact_leaves(working, row)
assert_fact_leaves(working, row, base=base)
general = rows["general"]
planets = {}
for name, record in general["planets"].items():
+29
View File
@@ -425,3 +425,32 @@ def test_one_warm_at_a_time_and_same_key_user_waits_for_that_build(monkeypatch):
warm_thread.join(timeout=5)
user_thread.join(timeout=5)
_drop_cache(same_identity)
def test_all_domains_share_one_packet_copy_and_still_match_the_per_domain_path(monkeypatch):
"""Issue #122: a report reads five domains twice; the packet is copied once, not ten times."""
import consult_card_domain_facts as facts_module
from consult_card_domain_facts import build_consult_card_facts
packet = _packet()
expected = [build_domain_facts(packet, domain, minute_confirmed=True) for domain in CONSULTATION_DOMAINS]
calls = []
real_base = facts_module.full_data_domain_base
def counting_base(value):
calls.append(1)
return real_base(value)
monkeypatch.setattr(facts_module, "full_data_domain_base", counting_base)
shared = build_consult_card_facts(packet, list(CONSULTATION_DOMAINS), minute_confirmed=True)
assert len(calls) == 1
assert json.dumps(shared["domains"], ensure_ascii=False, sort_keys=True, default=str) == json.dumps(
expected, ensure_ascii=False, sort_keys=True, default=str,
)
# Each domain's facts stay their own objects, as when every domain copied the packet.
first, second = shared["domains"][0], shared["domains"][1]
assert first["planets"]["Sun"] is not second["planets"]["Sun"]
assert first["timing"] is not second["timing"]
base = real_base(packet)
for row in shared["domains"]:
assert_fact_leaves(packet, row, base=base)