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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user