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