fix(report): bind professional report hash to the delivered packet (BUG-693, BUG-694)
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:
@@ -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` blocked(ERR-013)。未声称已云端同步。
|
||||
|
||||
## 环境缺口
|
||||
|
||||
- 无项目 `.venv`(根目录 `.venv` 是空文件);用 Anaconda 3.11.7 + pytest 9.1.1
|
||||
- 无 Chrome / 无报告页登录态:本单对用户不可感知,无真机清单
|
||||
@@ -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` |
|
||||
|
||||
### 前端基础与工程
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user