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