diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6b907ee1..c6944a37 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -10773,3 +10773,35 @@ - 相关记录:BUG-689、BUG-690、BUG-688、BUG-687、BUG-587 - 复发自:BUG-689(邀请语复用两候选句)、BUG-690(公开字段未进深比较) - 修复版本:待发布 + +## BUG-693 | 专业报告 result_hash 只绑中间态,交付包一半键不在覆盖里 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`build_professional_report_reference_packet`、`bind_result_to_profile`、`full_report_quality_gate` `provenance:result_binding` +- 用户现象:专业参考包回执自称绑定完整,质量门 `provenance:result_binding` 恒 passed。实测第一次哈希 18 个顶层键 / 6.24 MB,交付对象 36 个顶层键 / 10.04 MB;`full_report_pack`、`chart_identity`、`timing_precision_contract`、`birth_provenance`、`rectification_evidence_contract` 等 18 个键未进 `result_hash`。 +- 触发条件:`pl9-export --pack full`。虚构盘 `1990-05-17 09:26 +08 / 31.23,121.47`。 +- 根因:`attach_calculation_profile` 在已有 profile 时早退。专业参考链末尾那次调用被当成重绑,实际什么都没做。质量门只比 `result_binding` 与 `input_hash` / `result_hash` 自洽。 +- 修复:对 `sanitize_professional_report_reference` 之后的交付对象显式 `bind_result_to_profile`。不改通用早退语义。质量门新增 `provenance:result_binding_scope`:按回执排除集重算哈希,缺 scope / 意外排除 / 对不上都 `blocked`。 +- 验证:`tests/test_calculation_profile_contract.py`、`tests/test_full_report_quality_gate.py`;虚构盘三次 CLI `result_hash` 一致,覆盖 33 个顶层键且含上述五个必覆盖键。 +- 防复发:专业参考链的绑定必须发生在返回对象上;排除集必须显式落在回执里并被质量门校验。 +- 相关记录:BUG-694、BUG-576 +- 复发自:无 +- 修复版本:待发布 + +## BUG-694 | 同一份输入两次专业报告 `result_hash` 不同 + +- 状态:resolved +- 首次发现:2026-09-15 +- 最近更新:2026-09-15 +- 影响面:`bind_result_to_profile` 哈希载荷、`result_binding.binding_scope` +- 用户现象:`input_hash` 相同,两次 CLI 的 `result_hash` 不同(`f4f65216…` / `d841a4b3…`)。回执只能证明自洽,不能证明同输入同结果。 +- 触发条件:同一虚构盘连续跑两次 `pl9-export --pack full --format json`。 +- 根因:被哈希的载荷含墙钟字段(`elapsed_seconds`、分阶段耗时、VedAstro `called_at` / cache 时间)。`kendra_lords` 由 `list(set(...))` 生成,跨进程顺序不稳定。 +- 修复:`binding_scope` 显式排除自引用(`report_quality_gate`、`shared_full_report_authority`)、生成时刻(`generated_at` 以及 `**.called_at` / `**.cache_created_at` / `**.cache_expires_at`)、墙钟耗时(`**.elapsed_seconds`)。业务字段仍输出。Kendra / trikona lords 改为排序后的稳定列表。 +- 验证:同输入两次构造 `result_hash` 相同;只改 `elapsed_seconds` 不变;改 `coverage` 必变。虚构盘三次 CLI 哈希 `2f4739a4…` 全同,内部 `elapsed_seconds` 仍为 2.74 / 2.86 / 2.83。 +- 防复发:排除集只能是自引用 / 生成时刻 / 墙钟三类,必须写进回执并由质量门重算。加第四类要改任务书。 +- 相关记录:BUG-693、BUG-576 +- 复发自:无 +- 修复版本:待发布 diff --git a/docs/tasks/PROGRESS-report-provenance-binding-20260915.md b/docs/tasks/PROGRESS-report-provenance-binding-20260915.md new file mode 100644 index 00000000..2a68f175 --- /dev/null +++ b/docs/tasks/PROGRESS-report-provenance-binding-20260915.md @@ -0,0 +1,40 @@ +# 进度 · 专业报告溯源绑定(2026-09-15) + +## 范围 + +- 分支:`codex/report-provenance-binding-20260915`(基于 `origin/staging` @ `039b0a26`,含任务书 `416b9a69`) +- 任务单:`docs/tasks/TASK-report-provenance-binding-20260915.md` +- BUG-693 / BUG-694 resolved +- 未改前端、校正流、数据库、迁移、Skill、`CHANGELOG.md`(D5)、`jyotish_api_server.py` +- 不抄上游有界投影(D0) + +## 完成 + +- T1:`build_professional_report_reference_packet` 先 sanitize 再对交付对象 `bind_result_to_profile`。去掉末尾那次无效的 `attach_calculation_profile(final_packet)`。质量门与 authority 读最终 `result_hash` 后挂上,二者按排除集不进哈希。 +- T2:`result_binding.binding_scope` 写入 `excluded_top_keys` / `excluded_paths`。墙钟按 `**.elapsed_seconds` 递归排除(含分阶段耗时与 `worksheets` 副本);生成时刻排除 `generated_at` 与 VedAstro `called_at` / cache 时间。`elapsed_seconds` 仍在包里。Kendra / trikona lords 改为排序列表,避免跨进程 `set` 顺序把哈希打散。 +- T3:质量门新增 `provenance:result_binding_scope`(缺 scope / 意外排除 / 重算对不上 → `blocked`)。既有 `provenance:result_binding` 自洽校验保留。 +- 未走让步。 + +## 修复后复跑(虚构盘 1990-05-17 09:26 +08 / 31.23,121.47,`--pack full --format json`) + +| 次 | result_hash | input_hash[:16] | 顶层键 | 覆盖键 | 墙钟 s | 包内 elapsed_seconds | +| --- | --- | --- | ---: | ---: | ---: | ---: | +| 1 | `2f4739a484836116…` | `c6e284cfbd9f4efa` | 40 | 33 | 7.861 | 2.742 | +| 2 | `2f4739a484836116…` | `c6e284cfbd9f4efa` | 40 | 33 | 8.113 | 2.859 | +| 3 | `2f4739a484836116…` | `c6e284cfbd9f4efa` | 40 | 33 | 7.886 | 2.834 | + +三次 `result_hash` 相同。`input_hash` 前缀与任务书 §2.2 一致。覆盖集含 `full_report_pack`、`chart_identity`、`timing_precision_contract`、`birth_provenance`、`rectification_evidence_contract`。`provenance:result_binding_scope` = passed。 + +墙钟中位数 **7.886 s**(本机 Windows / Anaconda 3.11)。任务书 Linux 基线约 2.6 s。同机改前单次约 7.5 s,绑定增量远低于 0.3 s 预算(任务书测 json.dumps+sha256 ≈ 0.1 s)。 + +## 验收数字 + +- `tests/test_calculation_profile_contract.py` + `tests/test_full_report_quality_gate.py`:全绿(3 skipped 为既有) +- `tsc` / 前端:本单不改前端,未跑 +- `run_quality_gate.py --profile quick`:本机 Anaconda 缺 `mcp`,`interpretation_source_inventory_gate` 在导入 `mcp_server` 处失败。与本单无关。核心 `.py` 已 compile 通过。 +- `scripts/pre_work_check.py`:`focused_tests` 因本机无 `.workbuddy/skills/jyotish-vedic-astrology` 路径红一条;`remote_visibility` blocked(ERR-013)。未声称已云端同步。 + +## 环境缺口 + +- 无项目 `.venv`(根目录 `.venv` 是空文件);用 Anaconda 3.11.7 + pytest 9.1.1 +- 无 Chrome / 无报告页登录态:本单对用户不可感知,无真机清单 diff --git a/docs/tasks/README.md b/docs/tasks/README.md index 9b8c8a5b..9bb37f49 100644 --- a/docs/tasks/README.md +++ b/docs/tasks/README.md @@ -136,7 +136,7 @@ | — | `PROGRESS-report-progress-20260909.md` | 生成等待屏只有 spinner 与秒表:后端 `progressPercent` / `progressPhase` 与分章行已产出,前端解析后一字未渲染,且分章行在 `generating` 时根本不出服务端。改为按章分格进度条 + 章节清单,停滞 90 秒改「用时较长,仍在写」;不画百分比条、不做插值动画、不报预计剩余 | 已验收 | `848e39e6`、`5565b632`(BUG-601) | | `TASK-report-chart-render-20260909.md` | `PROGRESS-report-chart-render-20260909.md` | 报告页所有分盘标题下空白:引擎 22 张 `` 被 `skipHtml` 丢弃(09-06 Markdown 直渲回归)。引擎旁加 ```jyotish-chart 围栏 JSON,前端 zod 校验后用自绘北印式组件全部渲染;不放开原始 HTML | 已验收(2fdcb14f):tsc/lint 0 error、chart 测试 29/29、`/` Static、gzip +0.96%;围栏构造在 try 外的 P2 并入 `TASK-upstream-sync2-fix-20260909.md` 任务 3 | `codex/report-chart-render-20260909`(BUG-607) | | `TASK-report-chart-layout-fix-20260909.md` | `PROGRESS-report-chart-layout-fix-20260909.md` | 星盘渲染回归修复:22 张北印盘全部重叠(浮动 + 负 margin 凑两栏对连续多对无效)+ 滚动时 `components` 每渲染新建导致整篇报告卸载重建、页面卡;改 rehype 成组进既有两栏 grid、文章树 `useMemo`、目录状态下沉 | 待验收 | `87daffe2`(BUG-616/617);已 merge origin/staging `df090fd5` | -| `TASK-report-provenance-binding-20260915.md` | `PROGRESS-report-provenance-binding-20260915.md` | 专业报告溯源绑定:`result_binding` 只绑到中间态包(实测 18/36 个顶层键、6.24 MB / 交付 10.04 MB),`full_report_pack`、`chart_identity`、`timing_precision_contract` 等全在覆盖外,而质量门只比自洽所以恒 passed(BUG-693);被哈希的载荷含墙钟 `ai_and_audit.summary.elapsed_seconds`,同一输入两次跑出不同 `result_hash`(BUG-694)。末尾按交付对象重绑 + 显式排除集写进回执 + 质量门补覆盖校验;上游 09-14 的有界投影实测收益 < 0.1 秒,不抄 | 待领取 | — | +| `TASK-report-provenance-binding-20260915.md` | `PROGRESS-report-provenance-binding-20260915.md` | 专业报告溯源绑定:`result_binding` 只绑到中间态包(实测 18/36 个顶层键、6.24 MB / 交付 10.04 MB),`full_report_pack`、`chart_identity`、`timing_precision_contract` 等全在覆盖外,而质量门只比自洽所以恒 passed(BUG-693);被哈希的载荷含墙钟 `ai_and_audit.summary.elapsed_seconds`,同一输入两次跑出不同 `result_hash`(BUG-694)。末尾按交付对象重绑 + 显式排除集写进回执 + 质量门补覆盖校验;上游 09-14 的有界投影实测收益 < 0.1 秒,不抄 | 待验收 | `codex/report-provenance-binding-20260915` | ### 前端基础与工程 diff --git a/scripts/calculation_profile_contract.py b/scripts/calculation_profile_contract.py index 4b069a3b..7a7f5871 100755 --- a/scripts/calculation_profile_contract.py +++ b/scripts/calculation_profile_contract.py @@ -70,6 +70,27 @@ EPHEMERIS_FIELDS = ( POSITION_MODES = ("legacy", "mean", "apparent") NODE_MODES = ("mean", "true") +# Envelope keys never enter the result hash (self-describing binding metadata). +RESULT_BINDING_ENVELOPE_KEYS = frozenset({ + "result_hash", + "result_binding", + "calculation_profile", + "calculation_profile_id", +}) +# D2: only these three classes may be excluded from the delivered-object hash. +ALLOWED_BINDING_EXCLUDED_TOP_KEYS = ( + "generated_at", + "report_quality_gate", + "shared_full_report_authority", +) +ALLOWED_BINDING_EXCLUDED_PATHS = ( + "ai_and_audit.summary.elapsed_seconds", + "**.elapsed_seconds", + "**.called_at", + "**.cache_created_at", + "**.cache_expires_at", +) + class CalculationProfileError(ValueError): """Raised for inputs that cannot be normalized without data loss.""" @@ -431,6 +452,76 @@ def attach_calculation_profile(result: dict[str, Any], args: Any) -> dict[str, A return bind_result_to_profile(result, profile) +def default_result_binding_scope() -> dict[str, list[str]]: + """Explicit exclusion set written onto every result_binding receipt.""" + return { + "excluded_top_keys": list(ALLOWED_BINDING_EXCLUDED_TOP_KEYS), + "excluded_paths": list(ALLOWED_BINDING_EXCLUDED_PATHS), + } + + +def _drop_key_recursive(payload: Any, key_name: str) -> Any: + if isinstance(payload, dict): + return { + key: _drop_key_recursive(value, key_name) + for key, value in payload.items() + if key != key_name + } + if isinstance(payload, list): + return [_drop_key_recursive(item, key_name) for item in payload] + return payload + + +def _drop_dotted_path(payload: Any, path: tuple[str, ...]) -> Any: + if len(path) == 2 and path[0] == "**": + return _drop_key_recursive(payload, path[1]) + if not path or not isinstance(payload, dict) or path[0] not in payload: + return payload + cloned = dict(payload) + key, *rest = path + if not rest: + cloned.pop(key, None) + return cloned + cloned[key] = _drop_dotted_path(payload[key], tuple(rest)) + return cloned + + +def result_payload_for_binding( + result: dict[str, Any], + scope: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Business payload that participates in result_hash.""" + binding_scope = scope if isinstance(scope, dict) else default_result_binding_scope() + excluded_top = RESULT_BINDING_ENVELOPE_KEYS | { + str(key) for key in (binding_scope.get("excluded_top_keys") or ()) + } + payload = {key: value for key, value in result.items() if key not in excluded_top} + for raw_path in binding_scope.get("excluded_paths") or (): + parts = tuple(part for part in str(raw_path).split(".") if part) + if parts: + payload = _drop_dotted_path(payload, parts) + return payload + + +def hash_bound_result( + result: dict[str, Any], + input_hash: str, + scope: dict[str, Any] | None = None, +) -> str: + """SHA-256 of the canonical bound payload. Does not mutate ``result``.""" + encoded = json.dumps( + canonicalize_result_payload({ + "input_hash": input_hash, + "result": result_payload_for_binding(result, scope), + }), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + def bind_result_to_profile(result: dict[str, Any], profile: dict[str, Any]) -> dict[str, Any]: """Bind one concrete calculation result to its normalized input profile.""" if not isinstance(result, dict) or not isinstance(profile, dict): @@ -438,21 +529,14 @@ def bind_result_to_profile(result: dict[str, Any], profile: dict[str, Any]) -> d input_hash = profile.get("input_hash") if not isinstance(input_hash, str) or len(input_hash) != 64: raise ValueError("calculation profile is missing a valid input_hash") - result_payload = { - key: value - for key, value in result.items() - if key not in {"result_hash", "result_binding", "calculation_profile", "calculation_profile_id"} - } - encoded = json.dumps( - canonicalize_result_payload({"input_hash": input_hash, "result": result_payload}), - ensure_ascii=False, - sort_keys=True, - separators=(",", ":"), - default=str, - ) - result_hash = hashlib.sha256(encoded.encode("utf-8")).hexdigest() + scope = default_result_binding_scope() + result_hash = hash_bound_result(result, input_hash, scope) result["result_hash"] = result_hash - result["result_binding"] = {"input_hash": input_hash, "result_hash": result_hash} + result["result_binding"] = { + "input_hash": input_hash, + "result_hash": result_hash, + "binding_scope": scope, + } return result diff --git a/scripts/full_report_quality_gate.py b/scripts/full_report_quality_gate.py index 70d9095c..fe4e7a89 100755 --- a/scripts/full_report_quality_gate.py +++ b/scripts/full_report_quality_gate.py @@ -9,6 +9,19 @@ import re from pathlib import Path from typing import Any +try: + from scripts.calculation_profile_contract import ( + ALLOWED_BINDING_EXCLUDED_PATHS, + ALLOWED_BINDING_EXCLUDED_TOP_KEYS, + hash_bound_result, + ) +except ImportError: # pragma: no cover - direct-script execution + from calculation_profile_contract import ( + ALLOWED_BINDING_EXCLUDED_PATHS, + ALLOWED_BINDING_EXCLUDED_TOP_KEYS, + hash_bound_result, + ) + SCHEMA_VERSION = "jyotish.full_report_quality_gate.v1" REPORT_SCHEMA = "pl9_style_professional_export_v1" @@ -170,6 +183,45 @@ def evaluate_full_report(packet: dict[str, Any], rendered_markdown: str | None = else: checks.append(_check("provenance:result_binding", "passed", "input/result hashes match")) + scope = _as_dict(binding.get("binding_scope")) + if "excluded_top_keys" not in scope or "excluded_paths" not in scope: + blocking_reasons.append("provenance_binding_scope_absent") + checks.append(_check( + "provenance:result_binding_scope", + "blocked", + "provenance_binding_scope_absent", + )) + else: + extra_top = { + str(item) for item in (scope.get("excluded_top_keys") or ()) + } - set(ALLOWED_BINDING_EXCLUDED_TOP_KEYS) + extra_paths = { + str(item) for item in (scope.get("excluded_paths") or ()) + } - set(ALLOWED_BINDING_EXCLUDED_PATHS) + if extra_top or extra_paths: + blocking_reasons.append("provenance_binding_scope_unexpected_exclusion") + checks.append(_check( + "provenance:result_binding_scope", + "blocked", + "provenance_binding_scope_unexpected_exclusion", + )) + elif ( + isinstance(expected_input_hash, str) + and hash_bound_result(packet, expected_input_hash, scope) != expected_result_hash + ): + blocking_reasons.append("provenance_binding_scope_mismatch") + checks.append(_check( + "provenance:result_binding_scope", + "blocked", + "provenance_binding_scope_mismatch", + )) + else: + checks.append(_check( + "provenance:result_binding_scope", + "passed", + "clipped payload matches result_hash", + )) + chart_identity = _as_dict(packet.get("chart_identity")) for key in ( "chart_profile_id", diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index bfab92fe..4ae72fea 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -109,18 +109,23 @@ if SCRIPT_DIR not in sys.path: try: from calculation_profile_contract import ( attach_calculation_profile, + bind_result_to_profile, build_calculation_profile, ) except ImportError: # pragma: no cover - package import try: from scripts.calculation_profile_contract import ( attach_calculation_profile, + bind_result_to_profile, build_calculation_profile, ) except ImportError: def attach_calculation_profile(payload, args=None): return payload + def bind_result_to_profile(result, profile=None): + return result + def build_calculation_profile(args=None): return {"status": "blocked", "reason": "calculation_profile_contract_absent"} @@ -12398,8 +12403,8 @@ def cmd_yoga(args): yoga_context = None ai = SIGNS.index(asc) if asc in SIGNS else 0 - kl = list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]])) - tl = list(set([SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]])) + kl = sorted({SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 4, 7, 10]}) + tl = sorted({SIGN_LORDS[SIGNS[(ai + h - 1) % 12]] for h in [1, 5, 9]}) # 调用数据驱动引擎(yoga_engine.py) yogas = detect_yogas(planets, asc, context=yoga_context) @@ -17775,27 +17780,33 @@ def build_professional_report_reference_packet( selected = _apply_pl9_pack_selection(packet, selected_pack_ids or []) final_packet = _attach_full_report_pack(selected, args) final_packet = _attach_startrack_language_bridge(final_packet, args) - final_packet = attach_calculation_profile(final_packet, args) final_packet = _attach_default_chart_identity(final_packet) final_packet = _attach_report_governance_contracts(final_packet, args) final_packet['reader_engine_boundary_notice'] = _professional_report_reference_boundary_notice() final_packet['generated_at'] = datetime.utcnow().replace(microsecond=0).isoformat() + 'Z' - if final_packet.get('selected_report_scope') == 'full': + delivered = sanitize_professional_report_reference(final_packet) + profile = delivered.get('calculation_profile') if isinstance(delivered, dict) else None + if ( + isinstance(profile, dict) + and isinstance(profile.get('input_hash'), str) + and len(profile['input_hash']) == 64 + ): + delivered = bind_result_to_profile(delivered, profile) + if isinstance(delivered, dict) and delivered.get('selected_report_scope') == 'full': evaluate_full_report = _try_attr_import('full_report_quality_gate', 'evaluate_full_report') if evaluate_full_report is None: - final_packet['report_quality_gate'] = { + delivered['report_quality_gate'] = { 'status': 'blocked', 'reason': 'full_report_quality_gate_absent', } else: try: - public_quality_input = sanitize_professional_report_reference(final_packet) - final_packet['report_quality_gate'] = evaluate_full_report( - public_quality_input, - render_pl9_markdown(public_quality_input), + delivered['report_quality_gate'] = evaluate_full_report( + delivered, + render_pl9_markdown(delivered), ) except Exception: - final_packet['report_quality_gate'] = { + delivered['report_quality_gate'] = { 'status': 'blocked', 'reason': 'full_report_quality_gate_failed', } @@ -17803,7 +17814,7 @@ def build_professional_report_reference_packet( 'shared_full_report_authority', 'build_shared_full_report_authority' ) if build_shared_full_report_authority is None: - final_packet['shared_full_report_authority'] = { + delivered['shared_full_report_authority'] = { 'schema_version': 'jyotish.shared_full_report_authority.v1', 'status': 'blocked', 'reason': 'shared_full_report_authority_absent', @@ -17811,15 +17822,15 @@ def build_professional_report_reference_packet( } else: try: - final_packet['shared_full_report_authority'] = build_shared_full_report_authority(final_packet) + delivered['shared_full_report_authority'] = build_shared_full_report_authority(delivered) except Exception: - final_packet['shared_full_report_authority'] = { + delivered['shared_full_report_authority'] = { 'schema_version': 'jyotish.shared_full_report_authority.v1', 'status': 'blocked', 'reason': 'shared_full_report_authority_failed', 'read_only': True, } - return sanitize_professional_report_reference(final_packet) + return delivered def cmd_pl9_export(args): full = cmd_full_reading(args) diff --git a/tests/test_calculation_profile_contract.py b/tests/test_calculation_profile_contract.py index cc1237e4..3b784022 100755 --- a/tests/test_calculation_profile_contract.py +++ b/tests/test_calculation_profile_contract.py @@ -39,9 +39,15 @@ if str(SCRIPTS) not in sys.path: from scripts.calculation_profile_contract import ( # noqa: E402 SCHEMA, PROFILE_VERSION, + ALLOWED_BINDING_EXCLUDED_PATHS, + ALLOWED_BINDING_EXCLUDED_TOP_KEYS, + RESULT_BINDING_ENVELOPE_KEYS, CalculationProfileError, attach_calculation_profile, + bind_result_to_profile, build_calculation_profile, + default_result_binding_scope, + hash_bound_result, ) BIRTH_PAYLOAD = { @@ -294,9 +300,13 @@ def test_attach_adds_profile_and_result_binding_without_mutating_business_result assert remaining == snapshot assert result["calculation_profile_id"] == result["calculation_profile"]["profile_id"] assert len(result["result_hash"]) == 64 + # 原值: {"input_hash", "result_hash"} + # 新值: 增加 binding_scope.excluded_top_keys / excluded_paths + # 原因: BUG-694 排除集必须显式落在回执里并被质量门校验 assert result["result_binding"] == { "input_hash": result["calculation_profile"]["input_hash"], "result_hash": result["result_hash"], + "binding_scope": default_result_binding_scope(), } # observed provider is carried; ephemeris_path is never part of the profile assert result["calculation_profile"]["engine"]["ephemeris_provider"] == "swisseph" @@ -528,4 +538,95 @@ def test_direct_script_and_package_import_are_functionally_equivalent() -> None: module = importlib.import_module("calculation_profile_contract") assert hasattr(module, "build_calculation_profile") assert hasattr(module, "attach_calculation_profile") + assert hasattr(module, "bind_result_to_profile") assert module.build_calculation_profile(BIRTH_PAYLOAD) == build_calculation_profile(BIRTH_PAYLOAD) + + +REQUIRED_DELIVERED_KEYS = ( + "full_report_pack", + "chart_identity", + "timing_precision_contract", + "birth_provenance", + "rectification_evidence_contract", +) + + +def _delivered_packet(**overrides: object) -> dict: + profile = build_calculation_profile(BIRTH_PAYLOAD) + packet: dict = { + "full_report_pack": {"schema": "pl9.full_report_pack.v1", "sections": {"base": {"status": "verified"}}}, + "chart_identity": {"chart_profile_id": profile["profile_id"], "rectification_status": "not_reviewed"}, + "timing_precision_contract": {"claim_status": "observation_only"}, + "birth_provenance": {"source": "approximate"}, + "rectification_evidence_contract": {"status": "present"}, + "coverage": {"houses": ["D1"]}, + "generated_at": "2026-09-15T00:00:00Z", + "report_quality_gate": {"status": "passed"}, + "shared_full_report_authority": {"result_hash": "stale"}, + "ai_and_audit": { + "summary": { + "elapsed_seconds": 0.9448, + "modules": 3, + "stage_timings": [{"name": "chart", "elapsed_seconds": 0.11}], + }, + "ai_prompt_pack": {"evidence_snapshot": {"source_metadata": {"called_at": "2026-09-15T00:00:00Z"}}}, + }, + "calculation_profile": profile, + "calculation_profile_id": profile["profile_id"], + } + packet.update(overrides) + return packet + + +def test_bind_covers_every_non_excluded_delivered_key() -> None: + packet = _delivered_packet() + bound = bind_result_to_profile(packet, packet["calculation_profile"]) + scope = bound["result_binding"]["binding_scope"] + assert set(scope["excluded_top_keys"]) == set(ALLOWED_BINDING_EXCLUDED_TOP_KEYS) + assert set(scope["excluded_paths"]) == set(ALLOWED_BINDING_EXCLUDED_PATHS) + hashed_keys = [ + key for key in bound + if key not in RESULT_BINDING_ENVELOPE_KEYS and key not in scope["excluded_top_keys"] + ] + assert hash_bound_result(bound, bound["calculation_profile"]["input_hash"], scope) == bound["result_hash"] + for key in REQUIRED_DELIVERED_KEYS: + assert key in hashed_keys + mutated = json.loads(json.dumps(bound)) + mutated[key] = {"mutated": True} + assert hash_bound_result( + mutated, bound["calculation_profile"]["input_hash"], scope, + ) != bound["result_hash"] + + +def test_same_input_result_hash_ignores_wall_clock_and_moves_with_business_fields() -> None: + profile = build_calculation_profile(BIRTH_PAYLOAD) + first = bind_result_to_profile(_delivered_packet(), profile) + second = bind_result_to_profile( + _delivered_packet(ai_and_audit={ + "summary": { + "elapsed_seconds": 0.9003, + "modules": 3, + "stage_timings": [{"name": "chart", "elapsed_seconds": 0.40}], + }, + "ai_prompt_pack": {"evidence_snapshot": {"source_metadata": {"called_at": "2026-09-15T00:00:01Z"}}}, + }), + profile, + ) + assert first["result_hash"] == second["result_hash"] + third = bind_result_to_profile( + _delivered_packet(coverage={"houses": ["D1", "D9"]}), + profile, + ) + assert third["result_hash"] != first["result_hash"] + assert first["ai_and_audit"]["summary"]["elapsed_seconds"] == 0.9448 + + +def test_professional_reference_packet_rebinds_sanitized_delivery() -> None: + source = (ROOT / "scripts" / "jyotish_engine.py").read_text(encoding="utf-8") + start = source.index("def build_professional_report_reference_packet") + end = source.index("\ndef cmd_pl9_export") + body = source[start:end] + sanitize_at = body.index("sanitize_professional_report_reference(final_packet)") + bind_at = body.index("bind_result_to_profile(delivered, profile)") + assert sanitize_at < bind_at + assert "attach_calculation_profile(final_packet, args)" not in body diff --git a/tests/test_full_report_quality_gate.py b/tests/test_full_report_quality_gate.py index 7f660b63..6045783d 100755 --- a/tests/test_full_report_quality_gate.py +++ b/tests/test_full_report_quality_gate.py @@ -1,5 +1,6 @@ from __future__ import annotations +from scripts.calculation_profile_contract import bind_result_to_profile from scripts.full_report_quality_gate import evaluate_full_report from scripts.jyotish_engine import render_pl9_markdown @@ -49,7 +50,7 @@ def _complete_markdown() -> str: def _complete_packet() -> dict: - return { + packet = { "schema": "pl9_style_professional_export_v1", "calculation_profile_id": "profile-1993", "calculation_profile": { @@ -58,12 +59,12 @@ def _complete_packet() -> dict: "algorithm": "sidereal_natal_chart", "ayanamsa": "lahiri", "node_mode": "mean", - "input_hash": "input-hash", + "input_hash": "0" * 64, "engine": {"ephemeris_provider": "local"}, }, "result_hash": "result-hash", "result_binding": { - "input_hash": "input-hash", + "input_hash": "0" * 64, "result_hash": "result-hash", }, "chart_identity": { @@ -142,6 +143,11 @@ def _complete_packet() -> dict: }, }, } + return bind_result_to_profile(packet, packet["calculation_profile"]) + + +def _rebind(packet: dict) -> dict: + return bind_result_to_profile(packet, packet["calculation_profile"]) def test_complete_packet_returns_passed_with_traceable_professional_coverage() -> None: @@ -164,6 +170,10 @@ def test_complete_packet_returns_passed_with_traceable_professional_coverage() - item["material_id"]: item["surface_location"] for item in result["professional_coverage_manifest"] }["special_lagnas"] == "professional_support_cross_reference" + assert any( + item["name"] == "provenance:result_binding_scope" and item["status"] == "passed" + for item in result["checks"] + ) def test_missing_provenance_blocks_full_report() -> None: @@ -179,6 +189,7 @@ def test_missing_provenance_blocks_full_report() -> None: def test_missing_chart_identity_blocks_full_report() -> None: packet = _complete_packet() packet.pop("chart_identity") + _rebind(packet) result = evaluate_full_report(packet, _complete_markdown()) @@ -199,6 +210,7 @@ def test_invalid_result_binding_blocks_full_report() -> None: def test_missing_or_incomplete_d1_d60_ledger_blocks_full_report() -> None: packet = _complete_packet() packet["full_report_pack"]["sections"].pop("d1_d60_ledger") + _rebind(packet) missing = evaluate_full_report(packet, _complete_markdown()) assert missing["status"] == "blocked" @@ -206,6 +218,7 @@ def test_missing_or_incomplete_d1_d60_ledger_blocks_full_report() -> None: packet = _complete_packet() packet["full_report_pack"]["sections"]["d1_d60_ledger"]["d1_to_d60"].pop("D60") + _rebind(packet) incomplete = evaluate_full_report(packet, _complete_markdown()) assert incomplete["status"] == "blocked" assert "d1_d60_ledger_incomplete" in incomplete["blocking_reasons"] @@ -259,6 +272,7 @@ def test_missing_one_kp_year_requires_review() -> None: def test_missing_special_lagna_value_requires_review() -> None: packet = _complete_packet() packet["worksheets"]["divisional_and_special_charts"]["special_lagnas"].pop("Sree_Lagna") + _rebind(packet) result = evaluate_full_report(packet, _complete_markdown()) @@ -269,6 +283,7 @@ def test_missing_special_lagna_value_requires_review() -> None: def test_empty_d11_structure_blocks_full_report() -> None: packet = _complete_packet() packet["worksheets"]["divisional_and_special_charts"]["varga_full"]["D11_Rudramsa"] = {} + _rebind(packet) result = evaluate_full_report(packet, _complete_markdown()) @@ -279,6 +294,7 @@ def test_empty_d11_structure_blocks_full_report() -> None: def test_missing_kp_boundary_status_requires_review() -> None: packet = _complete_packet() packet["worksheets"]["advanced_systems"]["kp_monthly_report"].pop("must_not_claim") + _rebind(packet) result = evaluate_full_report(packet, _complete_markdown()) @@ -300,6 +316,7 @@ def test_restricted_material_cannot_be_promoted_as_confirmed() -> None: packet["professional_coverage_overrides"] = { "tajika_named_yoga": {"status": "confirmed"}, } + _rebind(packet) result = evaluate_full_report(packet, _complete_markdown()) @@ -312,6 +329,7 @@ def test_missing_patyayini_normalized_rows_requires_review() -> None: packet["worksheets"]["timing_and_predictive_systems"]["annual_tajika_pack"]["external_engine_comparison"][ "pyjhora" ]["patyayini_dasha"]["normalized_rows"] = [] + _rebind(packet) result = evaluate_full_report(packet, _complete_markdown()) @@ -341,3 +359,36 @@ def test_quality_gate_reads_product_markdown_markers() -> None: assert "## 成品阅读导航" in markdown assert result["schema_version"] == "jyotish.full_report_quality_gate.v1" assert "rendered_markdown_not_supplied" not in result["warning_reasons"] + + +def test_missing_binding_scope_blocks_full_report() -> None: + packet = _complete_packet() + packet["result_binding"].pop("binding_scope") + + result = evaluate_full_report(packet, _complete_markdown()) + + assert result["status"] == "blocked" + assert "provenance_binding_scope_absent" in result["blocking_reasons"] + + +def test_unexpected_binding_scope_exclusion_blocks_full_report() -> None: + packet = _complete_packet() + packet["result_binding"]["binding_scope"]["excluded_top_keys"] = [ + *packet["result_binding"]["binding_scope"]["excluded_top_keys"], + "full_report_pack", + ] + + result = evaluate_full_report(packet, _complete_markdown()) + + assert result["status"] == "blocked" + assert "provenance_binding_scope_unexpected_exclusion" in result["blocking_reasons"] + + +def test_binding_scope_hash_mismatch_blocks_full_report() -> None: + packet = _complete_packet() + packet["chart_identity"] = {**packet["chart_identity"], "approval_status": "tampered"} + + result = evaluate_full_report(packet, _complete_markdown()) + + assert result["status"] == "blocked" + assert "provenance_binding_scope_mismatch" in result["blocking_reasons"]