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
+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