From 3eeb898071a9df8b80d9968db1f9f0df6a46feaf Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 28 Oct 2026 07:06:32 +0800 Subject: [PATCH] Copy the full-data packet once per report, not once per domain (issue #122, BUG-1471) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01DbBXoz1Qk3y9buDv1mkmTu --- CHANGELOG.md | 6 +++++ docs/BUG_HISTORY.md | 15 +++++++++++++ scripts/consult_card_domain_facts.py | 27 ++++++++++++++++------ scripts/pl9_full_data_export.py | 31 +++++++++++++++++++------- scripts/report_render/reading_facts.py | 8 +++++-- tests/test_consult_card_full_source.py | 29 ++++++++++++++++++++++++ 6 files changed, 99 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ade513aa..26225254 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 印度占星 Skill 更新日志 +## 2026-10-28 — 个人长报告生成快约四分之一 + +- 第一次生成一份完整的个人长报告,本机实测从约 26 秒降到约 20 秒;报告内容一字不变。 +- 两处改动:报告出稿的提速开关默认打开(issue #121);报告和咨询数据卡不再为每个领域把整份数据各复制一遍(issue #122)。 +- Skill 版本不变(BUG-1335、BUG-1471)。 + ## 2026-10-28 — 星盘页页头整齐了,深色模式下几处灰底和白边去掉 - 首页输入框在深色模式下不再有一块灰底。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index f76735ca..ae1894a2 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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`。 + diff --git a/scripts/consult_card_domain_facts.py b/scripts/consult_card_domain_facts.py index 5a8ac6e5..3d4e2e5f 100644 --- a/scripts/consult_card_domain_facts.py +++ b/scripts/consult_card_domain_facts.py @@ -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): diff --git a/scripts/pl9_full_data_export.py b/scripts/pl9_full_data_export.py index 4b3b81ba..2845eb71 100644 --- a/scripts/pl9_full_data_export.py +++ b/scripts/pl9_full_data_export.py @@ -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} diff --git a/scripts/report_render/reading_facts.py b/scripts/report_render/reading_facts.py index 4b24ae3d..0573236c 100644 --- a/scripts/report_render/reading_facts.py +++ b/scripts/report_render/reading_facts.py @@ -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(): diff --git a/tests/test_consult_card_full_source.py b/tests/test_consult_card_full_source.py index 9efbd7be..6645fd6e 100644 --- a/tests/test_consult_card_full_source.py +++ b/tests/test_consult_card_full_source.py @@ -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)