fix(report): bind professional report hash to the delivered packet (BUG-693, BUG-694)
Independent Staging Quality Gate / validate (push) Successful in 9m45s
Independent Staging Quality Gate / publish (push) Successful in 2m14s

Rebind after sanitize, write an explicit binding_scope, and make the quality gate recompute coverage. Wall-clock fields stay in the packet but out of the hash.
This commit is contained in:
jesse-ux
2026-09-15 10:09:26 +08:00
parent 039b0a2608
commit 1d2aeffaef
8 changed files with 403 additions and 32 deletions
+32
View File
@@ -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
- 复发自:无
- 修复版本:待发布
@@ -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` blockedERR-013)。未声称已云端同步。
## 环境缺口
- 无项目 `.venv`(根目录 `.venv` 是空文件);用 Anaconda 3.11.7 + pytest 9.1.1
- 无 Chrome / 无报告页登录态:本单对用户不可感知,无真机清单
+1 -1
View File
@@ -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 张 `<svg>``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` |
### 前端基础与工程
+98 -14
View File
@@ -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
+52
View File
@@ -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",
+25 -14
View File
@@ -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)
+101
View File
@@ -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
+54 -3
View File
@@ -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"]