Cache work that depends only on the candidate minute, drop unread response fields, and reject windows above 241 candidates before any chart is built. Eleven canonical scenario hashes match 77057a819. Sealed and reported-offset replays match the 2026-10-08 records (changed_trial_count 8 and 581) and are stored as scoring_perf_2026_10_13.
Co-Authored-By: Grok (agent loop)
1303 lines
60 KiB
Python
1303 lines
60 KiB
Python
from __future__ import annotations
|
||
|
||
import unittest
|
||
from datetime import date, datetime
|
||
from unittest.mock import patch
|
||
from uuid import UUID
|
||
|
||
from scripts.active_rectification_event_engine import _candidate_datetimes
|
||
from scripts.jyotish_api_server import (
|
||
API_COMMAND_MAP,
|
||
TECHNIQUE_EXAMPLE_ENDPOINTS,
|
||
BadRequest,
|
||
JyotishAPIHandler,
|
||
)
|
||
from scripts.rectification.api_service import diagnostics, score_candidates
|
||
from scripts.rectification.candidate_contrast import (
|
||
MAX_PUBLIC_CLUSTERS,
|
||
select_signature_representatives,
|
||
)
|
||
from scripts.rectification.contracts import normalize_rectification_request
|
||
from scripts.rectification.decision_policy import build_candidate_decisions
|
||
from scripts.rectification.scoring_service import (
|
||
build_event_contribution_matrix,
|
||
calculation_spec,
|
||
sample_event_dates,
|
||
score_from_matrix,
|
||
sha256,
|
||
)
|
||
|
||
EVENT_ID = "00000000-0000-4000-8000-000000000001"
|
||
|
||
|
||
def event(index: int, domain: str, event_kind: str, *, precision: str = "day"):
|
||
return {
|
||
"id": f"00000000-0000-4000-8000-{index:012d}",
|
||
"domain": domain,
|
||
"event_kind": event_kind,
|
||
"date_start": "2016-09-15",
|
||
"date_end": "2016-09-15",
|
||
"precision": precision,
|
||
"summary": event_kind,
|
||
}
|
||
|
||
|
||
def request(
|
||
*,
|
||
precision: str = "month",
|
||
event_kind: str = "education_milestone",
|
||
domain: str = "education",
|
||
start_time: str = "05:13",
|
||
end_time: str = "05:15",
|
||
date_start: str = "2016-09-01",
|
||
date_end: str = "2016-09-30",
|
||
):
|
||
return {
|
||
"birth_date": "1997-08-08",
|
||
"start_time": start_time,
|
||
"end_time": end_time,
|
||
"lat": 36.419,
|
||
"lon": 114.213,
|
||
"tz": 8,
|
||
"events": [{
|
||
"id": EVENT_ID,
|
||
"domain": domain,
|
||
"event_kind": event_kind,
|
||
"date_start": date_start,
|
||
"date_end": date_end,
|
||
"precision": precision,
|
||
"summary": "大学入学",
|
||
}],
|
||
}
|
||
|
||
|
||
class RectificationV5ServicesTest(unittest.TestCase):
|
||
def test_event_contract_v2_accepts_native_kinds_and_retains_background_events(self):
|
||
supported = {
|
||
"education": ("education_start", "education_completion", "education_interruption", "education_change"),
|
||
"career": ("career_entry", "career_change", "promotion", "career_pressure", "career_exit", "business_start"),
|
||
"relationship": ("relationship_start", "relationship_commitment", "relationship_separation", "relationship_end"),
|
||
"relocation": ("relocation", "foreign_move", "return", "home_change"),
|
||
"finance": ("finance_gain", "finance_loss", "income_change", "asset_change"),
|
||
"health": ("self_health_event", "pressure_period"),
|
||
"family": ("family_event",),
|
||
"appearance": ("appearance_note",),
|
||
"marks": ("birthmark_or_scar",),
|
||
"occupation": ("occupation_note",),
|
||
"horary": ("horary_query",),
|
||
"other": ("other",),
|
||
}
|
||
|
||
for domain, kinds in supported.items():
|
||
for event_kind in kinds:
|
||
normalized = normalize_rectification_request(
|
||
request(domain=domain, event_kind=event_kind),
|
||
today=date(2026, 7, 28),
|
||
)
|
||
self.assertEqual(normalized["events"][0]["domain"], domain)
|
||
self.assertEqual(normalized["events"][0]["event_kind"], event_kind)
|
||
|
||
def test_event_contract_v2_preserves_server_provenance_and_enforces_self_scoring(self):
|
||
body = request(domain="career", event_kind="career_entry")
|
||
body["events"][0].update({
|
||
"source_turn_id": "33333333-3333-4333-8333-333333333333",
|
||
"subject": "self",
|
||
"date_source": "user_quote",
|
||
"date_reliability": "month_exact",
|
||
"date_corroboration": "劳动合同",
|
||
"date_conflict_status": "none",
|
||
})
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
event_value = normalized["events"][0]
|
||
self.assertEqual(event_value["source_turn_id"], "33333333-3333-4333-8333-333333333333")
|
||
self.assertEqual(event_value["subject"], "self")
|
||
self.assertEqual(event_value["date_source"], "user_quote")
|
||
self.assertEqual(event_value["date_reliability"], "month_exact")
|
||
self.assertEqual(event_value["date_corroboration"], "劳动合同")
|
||
self.assertEqual(event_value["date_conflict_status"], "none")
|
||
|
||
body["events"][0]["subject"] = "family"
|
||
with self.assertRaisesRegex(ValueError, "subject must be self for scoreable events"):
|
||
normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
|
||
def test_background_only_events_are_retained_without_invoking_the_scoring_engine(self):
|
||
body = request()
|
||
body["events"] = [
|
||
event(2, "other", "other", precision="year"),
|
||
event(3, "horary", "horary_query", precision="day"),
|
||
]
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
|
||
def fail_if_called(_request):
|
||
self.fail("background events must not reach the scoring engine")
|
||
|
||
built = build_event_contribution_matrix(normalized, row_provider=fail_if_called)
|
||
|
||
self.assertEqual(built["candidate_times"], [])
|
||
self.assertEqual(built["matrix"], {})
|
||
self.assertEqual(built["date_sensitivity"], [])
|
||
|
||
def test_family_events_are_scoreable_and_reach_the_engine(self):
|
||
body = request()
|
||
body["events"] = [event(1, "family", "family_event")]
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
seen = []
|
||
|
||
def rows(value):
|
||
seen.append(value["events"][0]["domain"])
|
||
event_value = value["events"][0]
|
||
return [{
|
||
"time": "05:13",
|
||
"score": 4,
|
||
"evidence": [{
|
||
"event_id": event_value["id"],
|
||
"domain": event_value["domain"],
|
||
"candidate_time": "05:13",
|
||
"rule_ids": ["vim_md_domain_house", "d1-rashi", "d12-dwadashamsha"],
|
||
"points": 4,
|
||
}],
|
||
"missing_layers": [],
|
||
}]
|
||
|
||
built = build_event_contribution_matrix(normalized, row_provider=rows)
|
||
self.assertEqual(seen, ["family"])
|
||
self.assertEqual(built["matrix"][normalized["events"][0]["id"]]["05:13"]["technique_layers"], [
|
||
"d1-rashi", "d12-dwadashamsha", "d3-drekkana", "d7-saptamsha", "vim_md_domain_house",
|
||
])
|
||
|
||
def test_declared_date_precision_changes_real_contribution_weight(self):
|
||
def rows(value):
|
||
event = value["events"][0]
|
||
return [{
|
||
"time": "05:13",
|
||
"score": 10,
|
||
"evidence": [{
|
||
"event_id": event["id"],
|
||
"domain": event["domain"],
|
||
"candidate_time": "05:13",
|
||
"rule_ids": ["vim_md_domain_house"],
|
||
"points": 10,
|
||
}],
|
||
"missing_layers": [],
|
||
}]
|
||
|
||
cases = {
|
||
"day": request(precision="day", event_kind="education_start", date_start="2016-09-15", date_end="2016-09-15"),
|
||
"month": request(precision="month", event_kind="education_start"),
|
||
"year": request(
|
||
precision="year",
|
||
event_kind="education_start",
|
||
date_start="2016-01-01",
|
||
date_end="2016-12-31",
|
||
),
|
||
}
|
||
points = {}
|
||
for precision, body in cases.items():
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
built = build_event_contribution_matrix(normalized, row_provider=rows)
|
||
points[precision] = built["matrix"][EVENT_ID]["05:13"]["points"]
|
||
|
||
self.assertEqual(points, {"day": 10, "month": 8, "year": 5})
|
||
|
||
def test_calculation_spec_hash_matches_typescript_for_integral_timezone(self):
|
||
normalized = normalize_rectification_request(request(), today=date(2026, 7, 28))
|
||
self.assertEqual(
|
||
sha256(calculation_spec(normalized)),
|
||
"d68a59ae345a87bcdf7ad45fee913d3aca02c553f896ce6c173a8bd4ab327af3",
|
||
)
|
||
|
||
def test_cross_midnight_range_preserves_next_day_datetimes_and_typescript_hash(self):
|
||
normalized = normalize_rectification_request(
|
||
request(start_time="23:00", end_time="03:59"),
|
||
today=date(2026, 7, 28),
|
||
)
|
||
|
||
candidates = _candidate_datetimes(normalized)
|
||
|
||
self.assertEqual(len(candidates), 300)
|
||
self.assertEqual(candidates[0].isoformat(), "1997-08-08T23:00:00")
|
||
self.assertEqual(candidates[60].isoformat(), "1997-08-09T00:00:00")
|
||
self.assertEqual(candidates[-1].isoformat(), "1997-08-09T03:59:00")
|
||
self.assertEqual(
|
||
sha256(calculation_spec(normalized)),
|
||
"1923a1ee2fa873f1a26c97b23d05cfee38ec38071b60076a014ad17b2844cedd",
|
||
)
|
||
|
||
def test_candidate_range_boundaries_stay_bounded_and_equal_is_one_minute(self):
|
||
full_day = normalize_rectification_request(
|
||
request(start_time="00:00", end_time="23:59"),
|
||
today=date(2026, 7, 28),
|
||
)
|
||
equal = normalize_rectification_request(
|
||
request(start_time="05:13", end_time="05:13"),
|
||
today=date(2026, 7, 28),
|
||
)
|
||
|
||
self.assertEqual(len(_candidate_datetimes(full_day)), 1_440)
|
||
self.assertEqual(len(_candidate_datetimes(equal)), 1)
|
||
with self.assertRaisesRegex(ValueError, "end_time must be HH:MM"):
|
||
normalize_rectification_request(
|
||
request(start_time="00:00", end_time="24:00"),
|
||
today=date(2026, 7, 28),
|
||
)
|
||
|
||
def test_daytime_candidate_range_remains_on_birth_date(self):
|
||
normalized = normalize_rectification_request(request(), today=date(2026, 7, 28))
|
||
|
||
candidates = _candidate_datetimes(normalized)
|
||
|
||
self.assertEqual([value.isoformat() for value in candidates], [
|
||
"1997-08-08T05:13:00",
|
||
"1997-08-08T05:14:00",
|
||
"1997-08-08T05:15:00",
|
||
])
|
||
|
||
def test_shared_validator_retains_background_events_and_rejects_non_self_health(self):
|
||
background = normalize_rectification_request(
|
||
request(domain="family", event_kind="family_event"), today=date(2026, 7, 28)
|
||
)
|
||
self.assertEqual(background["events"][0]["event_kind"], "family_event")
|
||
with self.assertRaisesRegex(ValueError, "event_kind does not match domain"):
|
||
normalize_rectification_request(
|
||
request(domain="family", event_kind="family_bereavement"), today=date(2026, 7, 28)
|
||
)
|
||
with self.assertRaisesRegex(ValueError, "event_kind does not match domain"):
|
||
normalize_rectification_request(request(domain="health_pressure", event_kind="family_health_event"), today=date(2026, 7, 28))
|
||
normalized = normalize_rectification_request(request(domain="health_pressure", event_kind="self_health_event"), today=date(2026, 7, 28))
|
||
self.assertEqual(normalized["events"][0]["event_kind"], "self_health_event")
|
||
|
||
def test_relationship_end_is_native_and_uses_pressure_semantics(self):
|
||
normalized = normalize_rectification_request(
|
||
request(domain="relationship", event_kind="relationship_end", precision="day"),
|
||
today=date(2026, 7, 28),
|
||
)
|
||
|
||
def rows(value):
|
||
event_value = value["events"][0]
|
||
return [{
|
||
"time": "05:13", "score": 10,
|
||
"evidence": [{
|
||
"event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:13",
|
||
"rule_ids": ["controlled_transit_saturn_domain_house", f"event_kind:{event_value['event_kind']}"],
|
||
"points": 10,
|
||
}],
|
||
"missing_layers": [],
|
||
}]
|
||
|
||
built = build_event_contribution_matrix(normalized, row_provider=rows)
|
||
contribution = built["matrix"][EVENT_ID]["05:13"]
|
||
self.assertIn("event_kind_profile:relationship_end:pressure", contribution["rule_ids"])
|
||
self.assertGreater(contribution["points"], 10)
|
||
|
||
def test_relationship_start_and_change_use_distinct_rule_conditioned_profiles(self):
|
||
def relationship_rows(_value):
|
||
return [
|
||
{
|
||
"time": "05:13", "score": 10,
|
||
"evidence": [{
|
||
"event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:13",
|
||
"rule_ids": ["vim_md_domain_house", "vim_ad_functional_benefic_auxiliary"],
|
||
"points": 10,
|
||
}],
|
||
"missing_layers": [],
|
||
},
|
||
{
|
||
"time": "05:14", "score": 9,
|
||
"evidence": [{
|
||
"event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:14",
|
||
"rule_ids": ["controlled_transit_saturn_domain_house", "vim_ad_functional_malefic_auxiliary"],
|
||
"points": 9,
|
||
}],
|
||
"missing_layers": [],
|
||
},
|
||
]
|
||
|
||
start = normalize_rectification_request(
|
||
request(domain="relationship", event_kind="relationship_start"), today=date(2026, 7, 28)
|
||
)
|
||
change = normalize_rectification_request(
|
||
request(domain="relationship", event_kind="relationship_change"), today=date(2026, 7, 28)
|
||
)
|
||
|
||
start_matrix = build_event_contribution_matrix(start, row_provider=relationship_rows)
|
||
change_matrix = build_event_contribution_matrix(change, row_provider=relationship_rows)
|
||
|
||
self.assertGreater(start_matrix["matrix"][EVENT_ID]["05:13"]["points"], start_matrix["matrix"][EVENT_ID]["05:14"]["points"] )
|
||
self.assertGreater(change_matrix["matrix"][EVENT_ID]["05:14"]["points"], change_matrix["matrix"][EVENT_ID]["05:13"]["points"] )
|
||
self.assertEqual(change_matrix["date_sensitivity"][0]["sample_winners"], ["05:14", "05:14", "05:14"])
|
||
self.assertNotIn("event_kind_profile", change_matrix["matrix"][EVENT_ID]["05:14"]["technique_layers"])
|
||
|
||
def test_relationship_kind_profile_does_not_create_points_without_activation(self):
|
||
normalized = normalize_rectification_request(
|
||
request(domain="relationship", event_kind="relationship_change", precision="day"),
|
||
today=date(2026, 7, 28),
|
||
)
|
||
|
||
def rows(_value):
|
||
return [{
|
||
"time": "05:13", "score": 0,
|
||
"evidence": [{
|
||
"event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:13",
|
||
"rule_ids": ["no_domain_activation", "event_kind:relationship_change"],
|
||
"points": 0,
|
||
}],
|
||
"missing_layers": [],
|
||
}]
|
||
|
||
built = build_event_contribution_matrix(normalized, row_provider=rows)
|
||
self.assertEqual(built["matrix"][EVENT_ID]["05:13"]["points"], 0)
|
||
|
||
def test_date_sampling_preserves_declared_range_and_uses_bounded_samples(self):
|
||
base = request()["events"][0]
|
||
self.assertEqual(sample_event_dates({**base, "precision": "month"}), ["2016-09-01", "2016-09-15", "2016-09-30"])
|
||
year = {**base, "precision": "year", "date_start": "2016-01-01", "date_end": "2016-12-31"}
|
||
self.assertEqual(len(sample_event_dates(year)), 12)
|
||
ranged = {**base, "precision": "range", "date_start": "2015-01-01", "date_end": "2016-12-31"}
|
||
self.assertLessEqual(len(sample_event_dates(ranged)), 12)
|
||
|
||
def test_contribution_matrix_and_leave_out_diagnostics_use_matrix_math(self):
|
||
normalized = normalize_rectification_request(request(), today=date(2026, 7, 28))
|
||
|
||
def rows(value):
|
||
sampled = value["events"][0]["date"]
|
||
shift = {"2016-09-01": 0, "2016-09-15": 1, "2016-09-30": 2}[sampled]
|
||
return [{
|
||
"time": candidate,
|
||
"score": points + shift,
|
||
"evidence": [{
|
||
"event_id": EVENT_ID,
|
||
"domain": "education",
|
||
"candidate_time": candidate,
|
||
"rule_ids": ["D24:test"],
|
||
"points": points + shift,
|
||
}],
|
||
"missing_layers": ["KP_cusps"],
|
||
} for candidate, points in [("05:13", 9), ("05:14", 10), ("05:15", 8)]]
|
||
|
||
built = build_event_contribution_matrix(normalized, row_provider=rows)
|
||
scored = score_from_matrix(normalized, built)
|
||
self.assertEqual(built["matrix"][EVENT_ID]["05:14"]["points"], 8.8)
|
||
self.assertEqual(scored[1]["score"], 8.8)
|
||
self.assertEqual(built["missing_layers"], ["KP_cusps"])
|
||
|
||
def test_formal_score_and_diagnostics_endpoints_share_the_service_bundle(self):
|
||
normalized = normalize_rectification_request(request(), today=date(2026, 7, 28))
|
||
built = {
|
||
"candidate_times": ["05:13", "05:14"],
|
||
"matrix": {EVENT_ID: {
|
||
"05:13": {"points": 10, "rule_ids": ["D24:a"], "technique_layers": ["D24"]},
|
||
"05:14": {"points": 8, "rule_ids": ["D24:b"], "technique_layers": ["D24"]},
|
||
}},
|
||
"date_sensitivity": [{
|
||
"event_id": EVENT_ID,
|
||
"declared_date_range": {"start": "2016-09-01", "end": "2016-09-30", "precision": "month"},
|
||
"sample_dates": ["2016-09-01", "2016-09-15", "2016-09-30"],
|
||
"winner_retention_rate": 1,
|
||
"score_variance": 1,
|
||
"sample_winners": ["05:13", "05:13", "05:13"],
|
||
}],
|
||
"missing_layers": ["KP_cusps"],
|
||
"static_contexts": [{"feature": {"time": "05:13"}}, {"feature": {"time": "05:14"}}],
|
||
}
|
||
feature = {
|
||
"calculation_spec_hash": "0" * 64,
|
||
"algorithm_version": "rectification-v5-matrix-scoring-2",
|
||
"candidate_count": 2,
|
||
"feature_hash": "1" * 64,
|
||
"features": [{"time": "05:13"}, {"time": "05:14"}],
|
||
}
|
||
with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch(
|
||
"scripts.rectification.api_service.build_candidate_feature_snapshot", return_value=feature
|
||
):
|
||
scored = score_candidates(normalized)
|
||
diagnostic_result = diagnostics(normalized)
|
||
self.assertFalse(scored["can_confirm_exact_minute"])
|
||
# 原值: assertIn("event_contribution_matrix", scored)
|
||
# 新值: 默认 assertNotIn;include_internal=True 时仍在
|
||
# 原因: BUG-1325,前台没有读取点,默认响应不再带内部矩阵
|
||
self.assertNotIn("event_contribution_matrix", scored)
|
||
self.assertNotIn("candidate_decision_receipt", scored)
|
||
internal = score_candidates(normalized, include_internal=True)
|
||
self.assertIn("event_contribution_matrix", internal)
|
||
self.assertIn("candidate_decision_receipt", internal)
|
||
self.assertEqual(diagnostic_result["diagnostics"]["leave_one_event_out_retention_rate"], 1)
|
||
self.assertFalse(diagnostic_result["can_confirm_exact_minute"])
|
||
|
||
def test_score_endpoint_returns_candidate_decision_receipt_v2_and_real_execution_ledger(self):
|
||
body = request()
|
||
body["events"] = [
|
||
event(1, "education", "education_start"),
|
||
event(2, "career", "promotion", precision="month"),
|
||
event(3, "finance", "finance_gain"),
|
||
event(5, "family", "family_event"),
|
||
event(4, "other", "other", precision="year"),
|
||
]
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
scored_ids = [item["id"] for item in normalized["events"] if item["domain"] != "other"]
|
||
built = {
|
||
"candidate_times": ["05:13", "05:14", "05:15"],
|
||
"matrix": {
|
||
event_id: {
|
||
"05:13": {"points": 4, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]},
|
||
"05:14": {"points": 2, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]},
|
||
"05:15": {"points": 1, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]},
|
||
}
|
||
for event_id in scored_ids
|
||
},
|
||
"date_sensitivity": [
|
||
{
|
||
"event_id": event_id,
|
||
"declared_date_range": {"start": "2016-09-15", "end": "2016-09-15", "precision": "day"},
|
||
"sample_dates": ["2016-09-15"],
|
||
"winner_retention_rate": 1,
|
||
"score_variance": 0,
|
||
"sample_winners": ["05:13"],
|
||
}
|
||
for event_id in scored_ids
|
||
],
|
||
"missing_layers": [],
|
||
"static_contexts": [],
|
||
}
|
||
diagnostic_values = {
|
||
"primary_cluster_retention_rate": 1,
|
||
"leave_one_event_out_retention_rate": .9,
|
||
"leave_one_domain_out_retention_rate": .9,
|
||
"date_sensitivity_retention_rate": .9,
|
||
"neighbor_support_minutes": 1,
|
||
"primary_secondary_margin_percent": 30,
|
||
"event_date_sensitivity": built["date_sensitivity"],
|
||
}
|
||
feature = {
|
||
"calculation_spec_hash": "0" * 64,
|
||
"algorithm_version": "rectification-v5-matrix-scoring-2",
|
||
"candidate_count": 3,
|
||
"feature_hash": "1" * 64,
|
||
"features": [],
|
||
}
|
||
with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch(
|
||
"scripts.rectification.api_service.build_candidate_feature_snapshot", return_value=feature
|
||
), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values):
|
||
first = score_candidates(normalized)
|
||
second = score_candidates(normalized)
|
||
|
||
self.assertEqual(first["event_contract_version"], "rectification-event-contract-v2")
|
||
self.assertEqual(first["candidate_decisions"], second["candidate_decisions"])
|
||
self.assertEqual(sum(item["relative_support"] for item in first["candidate_decisions"]), 100)
|
||
for rank, candidate in enumerate(first["candidate_decisions"], start=1):
|
||
self.assertEqual(set(candidate), {
|
||
"candidate_id",
|
||
"rank",
|
||
"time",
|
||
"relative_support",
|
||
"tied_minute_count",
|
||
"cluster_times",
|
||
"cluster_start",
|
||
"cluster_end",
|
||
})
|
||
self.assertEqual(candidate["rank"], rank)
|
||
UUID(candidate["candidate_id"])
|
||
|
||
# 原值: first["candidate_decision_receipt"]
|
||
# 新值: first["decision_receipt"](同一个对象)
|
||
# 原因: BUG-1325,默认响应不再重复挂回执
|
||
receipt = first["decision_receipt"]
|
||
self.assertEqual(receipt["receipt_version"], "candidate-decision-receipt-v2")
|
||
self.assertTrue(receipt["selection_allowed"])
|
||
self.assertTrue(receipt["acceptance_allowed"])
|
||
self.assertFalse(receipt["confirmation_allowed"])
|
||
self.assertTrue(receipt["gates"]["domain_diversity"]["passed"])
|
||
self.assertTrue(receipt["gates"]["date_quality"]["passed"])
|
||
self.assertTrue(receipt["gates"]["exact_confirmation"]["fail_closed"])
|
||
self.assertEqual(receipt["gates"]["exact_confirmation"]["external_validation_status"], "not_evaluated")
|
||
self.assertNotEqual(receipt["gates"]["exact_confirmation"]["external_validation_status"], "fail")
|
||
|
||
self.assertEqual(first["decision_receipt"], receipt)
|
||
# 原值: policy-v3(C2 holdout 校准默认仍 proportional,未 bump);新值: policy-v4;原因: R1 区间门槛 3 件 2 域 → 4 件 3 域(BUG-1193)
|
||
self.assertEqual(first["decision_policy_version"], "rectification-candidate-policy-v4")
|
||
self.assertTrue(receipt["display_allowed"])
|
||
self.assertTrue(receipt["accept_allowed"])
|
||
self.assertFalse(receipt["confirm_allowed"])
|
||
self.assertIsNotNone(receipt["representative_candidate_id"])
|
||
self.assertEqual(receipt["representative_time"], "05:13")
|
||
|
||
entries = first["execution_ledger"]
|
||
background = next(item for item in entries if item.get("event_id") == normalized["events"][4]["id"])
|
||
self.assertEqual(background["status"], "retained_not_scored")
|
||
executed = next(item for item in entries if item.get("event_id") == normalized["events"][0]["id"])
|
||
self.assertEqual(executed["technique_layers"], ["vim_md_domain_house"])
|
||
|
||
def test_diagnostics_endpoint_mirrors_candidate_decision_policy_fields(self):
|
||
normalized = normalize_rectification_request(request(), today=date(2026, 7, 28))
|
||
scored = {
|
||
"result_id": "00000000-0000-4000-8000-000000000099",
|
||
"algorithm_version": "rectification-v5-matrix-scoring-2",
|
||
"event_contract_version": "rectification-event-contract-v2",
|
||
"decision_policy_version": "rectification-candidate-policy-v2",
|
||
"calculation_spec_hash": "0" * 64,
|
||
"candidate_decisions": [],
|
||
"candidate_decision_receipt": {},
|
||
"decision_receipt": {},
|
||
"execution_ledger_version": "rectification-execution-ledger-v2",
|
||
"execution_ledger": [],
|
||
"diagnostics": {},
|
||
"missing_layers": [],
|
||
"display_allowed": False,
|
||
"selection_allowed": False,
|
||
"acceptance_allowed": False,
|
||
"propose_allowed": False,
|
||
"confirmation_allowed": False,
|
||
"representative_candidate_id": None,
|
||
"representative_time": None,
|
||
"overall_confidence": "low",
|
||
"margin_percent": 0.0,
|
||
}
|
||
with patch("scripts.rectification.api_service.score_candidates", return_value=scored):
|
||
result = diagnostics(normalized)
|
||
|
||
for field in (
|
||
"display_allowed",
|
||
"selection_allowed",
|
||
"acceptance_allowed",
|
||
"propose_allowed",
|
||
"confirmation_allowed",
|
||
"representative_candidate_id",
|
||
"representative_time",
|
||
"overall_confidence",
|
||
"margin_percent",
|
||
):
|
||
self.assertEqual(result[field], scored[field])
|
||
self.assertFalse(result["can_confirm_exact_minute"])
|
||
|
||
def test_single_domain_or_quantized_top_tie_blocks_candidate_acceptance(self):
|
||
body = request()
|
||
body["events"] = [
|
||
event(1, "career", "career_entry"),
|
||
event(2, "career", "promotion"),
|
||
event(3, "career", "career_change"),
|
||
]
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
built = {
|
||
"candidate_times": ["05:13", "05:14"],
|
||
"matrix": {
|
||
item["id"]: {
|
||
"05:13": {"points": 3.333346, "rule_ids": ["D10:test"], "technique_layers": ["D10"]},
|
||
"05:14": {"points": 3.333333, "rule_ids": ["D10:test"], "technique_layers": ["D10"]},
|
||
}
|
||
for item in normalized["events"]
|
||
},
|
||
"date_sensitivity": [],
|
||
"missing_layers": [],
|
||
"static_contexts": [],
|
||
}
|
||
diagnostic_values = {
|
||
"primary_cluster_retention_rate": 1,
|
||
"leave_one_event_out_retention_rate": 1,
|
||
"leave_one_domain_out_retention_rate": 1,
|
||
"date_sensitivity_retention_rate": 1,
|
||
"neighbor_support_minutes": 1,
|
||
"primary_secondary_margin_percent": 50,
|
||
}
|
||
with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch(
|
||
"scripts.rectification.api_service.build_candidate_feature_snapshot", return_value={}
|
||
), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values):
|
||
result = score_candidates(normalized)
|
||
|
||
# 原值: result["candidate_decision_receipt"];新值: result["decision_receipt"];原因: BUG-1325
|
||
receipt = result["decision_receipt"]
|
||
self.assertFalse(receipt["acceptance_allowed"])
|
||
self.assertIn("insufficient_domain_diversity", receipt["reasons"])
|
||
self.assertIn("tied_top_score", receipt["reasons"])
|
||
self.assertIn("tied_top_score", receipt["confirmation_reasons"])
|
||
self.assertNotIn("tied_top_score", receipt["acceptance_reasons"])
|
||
self.assertEqual(result["candidate_decisions"][0]["tied_minute_count"], 2)
|
||
self.assertEqual(receipt["tie_policy"]["score_quantum"], .0001)
|
||
self.assertEqual(receipt["tie_policy"]["absolute_tolerance"], .0001)
|
||
|
||
def test_two_domain_quantized_top_tie_allows_adoption_but_blocks_confirmation(self):
|
||
body = request()
|
||
body["events"] = [
|
||
event(1, "career", "career_entry"),
|
||
event(2, "career", "promotion"),
|
||
event(3, "education", "education_start"),
|
||
event(4, "family", "family_event"),
|
||
]
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
built = {
|
||
"candidate_times": ["05:13", "05:14"],
|
||
"matrix": {
|
||
item["id"]: {
|
||
"05:13": {"points": 3.333346, "rule_ids": ["D10:test"], "technique_layers": ["D10"]},
|
||
"05:14": {"points": 3.333333, "rule_ids": ["D10:test"], "technique_layers": ["D10"]},
|
||
}
|
||
for item in normalized["events"]
|
||
},
|
||
"date_sensitivity": [],
|
||
"missing_layers": [],
|
||
"static_contexts": [],
|
||
}
|
||
diagnostic_values = {
|
||
"primary_cluster_retention_rate": 1,
|
||
"leave_one_event_out_retention_rate": 1,
|
||
"leave_one_domain_out_retention_rate": 1,
|
||
"date_sensitivity_retention_rate": 1,
|
||
"neighbor_support_minutes": 1,
|
||
"primary_secondary_margin_percent": 50,
|
||
}
|
||
with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch(
|
||
"scripts.rectification.api_service.build_candidate_feature_snapshot", return_value={}
|
||
), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values):
|
||
result = score_candidates(normalized)
|
||
|
||
# 原值: result["candidate_decision_receipt"];新值: result["decision_receipt"];原因: BUG-1325
|
||
receipt = result["decision_receipt"]
|
||
self.assertTrue(receipt["acceptance_allowed"])
|
||
self.assertTrue(receipt["selection_allowed"])
|
||
self.assertFalse(receipt["confirmation_allowed"])
|
||
self.assertIn("tied_top_score", receipt["confirmation_reasons"])
|
||
self.assertNotIn("tied_top_score", receipt["acceptance_reasons"])
|
||
self.assertEqual(result["candidate_decisions"][0]["tied_minute_count"], 2)
|
||
self.assertFalse(result["can_confirm_exact_minute"])
|
||
|
||
def test_low_date_quality_blocks_acceptance_even_with_multiple_domains_and_a_unique_candidate(self):
|
||
body = request()
|
||
body["events"] = [
|
||
event(1, "education", "education_start", precision="year"),
|
||
event(2, "career", "promotion", precision="year"),
|
||
event(3, "finance", "finance_gain", precision="year"),
|
||
]
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
built = {
|
||
"candidate_times": ["05:13", "05:14"],
|
||
"matrix": {
|
||
item["id"]: {
|
||
"05:13": {"points": 4, "rule_ids": ["test"], "technique_layers": ["test"]},
|
||
"05:14": {"points": 1, "rule_ids": ["test"], "technique_layers": ["test"]},
|
||
}
|
||
for item in normalized["events"]
|
||
},
|
||
"date_sensitivity": [],
|
||
"missing_layers": [],
|
||
"static_contexts": [],
|
||
}
|
||
diagnostic_values = {
|
||
"leave_one_event_out_retention_rate": 1,
|
||
"leave_one_domain_out_retention_rate": 1,
|
||
"date_sensitivity_retention_rate": 1,
|
||
"primary_secondary_margin_percent": 50,
|
||
}
|
||
with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch(
|
||
"scripts.rectification.api_service.build_candidate_feature_snapshot", return_value={}
|
||
), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values):
|
||
result = score_candidates(normalized)
|
||
|
||
# 原值: result["candidate_decision_receipt"];新值: result["decision_receipt"];原因: BUG-1325
|
||
receipt = result["decision_receipt"]
|
||
self.assertTrue(result["candidate_decisions"])
|
||
self.assertFalse(receipt["acceptance_allowed"])
|
||
self.assertFalse(receipt["gates"]["date_quality"]["passed"])
|
||
self.assertIn("low_date_quality", receipt["reasons"])
|
||
self.assertFalse(result["can_confirm_exact_minute"])
|
||
|
||
def test_http_registry_exposes_all_v5_endpoints(self):
|
||
expected = {
|
||
"rectification-v5-candidate-features": "/api/rectification/v5/candidate-features",
|
||
"rectification-v5-score": "/api/rectification/v5/score",
|
||
"rectification-v5-diagnostics": "/api/rectification/v5/diagnostics",
|
||
"rectification-v5-versions": "/api/rectification/v5/versions",
|
||
}
|
||
for command, endpoint in expected.items():
|
||
self.assertEqual(API_COMMAND_MAP[command], endpoint)
|
||
self.assertIn(endpoint, TECHNIQUE_EXAMPLE_ENDPOINTS)
|
||
|
||
def test_engine_scoring_versions_match_score_identity(self):
|
||
from scripts.rectification.api_service import engine_scoring_versions
|
||
from scripts.rectification.decision_policy import POLICY_VERSION
|
||
from scripts.rectification.scoring_service import ALGORITHM_VERSION
|
||
|
||
versions = engine_scoring_versions()
|
||
self.assertEqual(versions["algorithm_version"], ALGORITHM_VERSION)
|
||
self.assertEqual(versions["decision_policy_version"], POLICY_VERSION)
|
||
handler = object.__new__(JyotishAPIHandler)
|
||
payload = handler._compute_rectification_v5_versions()
|
||
self.assertEqual(payload["algorithm_version"], ALGORITHM_VERSION)
|
||
self.assertEqual(payload["decision_policy_version"], POLICY_VERSION)
|
||
self.assertEqual(payload["endpoint"], "rectification_v5_versions")
|
||
|
||
def test_zero_evidence_returns_declared_window_report(self):
|
||
body = request(start_time="23:58", end_time="00:02")
|
||
body["events"] = []
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
|
||
result = score_candidates(normalized)
|
||
report = result["rectification_report"]
|
||
|
||
self.assertEqual(report["candidate_range"]["start_time"], "23:58")
|
||
self.assertEqual(report["candidate_range"]["end_time"], "00:02")
|
||
self.assertEqual(report["candidate_range"]["width_minutes"], 5)
|
||
self.assertIsNone(report["representative_time"])
|
||
self.assertEqual(report["evidence"], [])
|
||
self.assertFalse(report["candidate_range"]["representative_is_unique"])
|
||
self.assertIn("do_not_apply_as_birth_time_truth", result["next_step_codes"])
|
||
|
||
def test_report_exposes_evidence_excluded_candidates_and_diagnostics_consistently(self):
|
||
body = request()
|
||
body["events"] = [
|
||
event(1, "education", "education_start"),
|
||
event(2, "career", "promotion"),
|
||
]
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
built = {
|
||
"candidate_times": ["05:13", "05:14", "05:15"],
|
||
"matrix": {
|
||
item["id"]: {
|
||
"05:13": {"points": 4, "rule_ids": ["D10:test"], "technique_layers": ["D10"]},
|
||
"05:14": {"points": 4, "rule_ids": ["D9:test"], "technique_layers": ["D9"]},
|
||
"05:15": {"points": -1, "rule_ids": ["D1:test"], "technique_layers": ["D1"]},
|
||
}
|
||
for item in normalized["events"]
|
||
},
|
||
"date_sensitivity": [],
|
||
"missing_layers": [],
|
||
"static_contexts": [
|
||
{"feature": {"time": "05:13", "ascendant_sign_index": 1}},
|
||
{"feature": {"time": "05:14", "ascendant_sign_index": 2}},
|
||
{"feature": {"time": "05:15", "ascendant_sign_index": 3}},
|
||
],
|
||
}
|
||
diagnostic_values = {
|
||
"primary_cluster_retention_rate": 1,
|
||
"leave_one_event_out_retention_rate": 1,
|
||
"leave_one_domain_out_retention_rate": 1,
|
||
"date_sensitivity_retention_rate": 1,
|
||
"neighbor_support_minutes": 1,
|
||
"primary_secondary_margin_percent": 30,
|
||
}
|
||
with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch(
|
||
"scripts.rectification.api_service.build_candidate_feature_snapshot", return_value={}
|
||
), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values):
|
||
scored = score_candidates(normalized)
|
||
diagnostic_result = diagnostics(normalized)
|
||
|
||
report = scored["rectification_report"]
|
||
self.assertEqual(report["candidate_range"]["start_time"], "05:13")
|
||
self.assertEqual(report["candidate_range"]["end_time"], "05:14")
|
||
self.assertEqual(report["representative_time"], "05:13")
|
||
self.assertEqual([row["status"] for row in report["evidence"]], ["supporting", "supporting"])
|
||
self.assertEqual(report["evidence"][0]["methods"], ["D10"])
|
||
self.assertGreaterEqual(len(report["excluded_candidates"]), 1)
|
||
self.assertEqual(scored["candidate_summary"]["stability"]["label"], scored["overall_confidence"])
|
||
self.assertEqual(diagnostic_result["rectification_report"], report)
|
||
self.assertEqual(diagnostic_result["next_step_codes"], scored["next_step_codes"])
|
||
|
||
def test_http_handler_enforces_subject_and_event_kind_boundaries(self):
|
||
handler = object.__new__(JyotishAPIHandler)
|
||
retained = handler._rectification_v5_request(request(domain="family", event_kind="family_event"))
|
||
self.assertEqual(retained["events"][0]["event_kind"], "family_event")
|
||
with self.assertRaisesRegex(BadRequest, "event_kind does not match domain"):
|
||
handler._rectification_v5_request(request(domain="family", event_kind="family_bereavement"))
|
||
with self.assertRaisesRegex(BadRequest, "event_kind does not match domain"):
|
||
handler._rectification_v5_request(request(domain="health_pressure", event_kind="family_health_event"))
|
||
normalized = handler._rectification_v5_request(
|
||
request(domain="health_pressure", event_kind="self_health_event")
|
||
)
|
||
self.assertEqual(normalized["events"][0]["event_kind"], "self_health_event")
|
||
|
||
def test_v4_compatibility_and_v5_score_handlers_share_the_v5_service(self):
|
||
handler = object.__new__(JyotishAPIHandler)
|
||
result = {"result_id": "00000000-0000-4000-8000-000000000099", "can_confirm_exact_minute": False}
|
||
with patch("scripts.rectification.api_service.score_candidates", return_value=result) as scorer:
|
||
v5 = handler._compute_rectification_v5_score(request())
|
||
v4 = handler._compute_active_rectification_events_v4(request())
|
||
self.assertEqual(scorer.call_count, 2)
|
||
self.assertEqual(v5["endpoint"], "rectification_v5_score")
|
||
self.assertEqual(v4["endpoint"], "active_rectification_events_v4")
|
||
self.assertEqual(v5["result_id"], v4["result_id"])
|
||
self.assertFalse(v5["can_confirm_exact_minute"])
|
||
self.assertFalse(v4["can_confirm_exact_minute"])
|
||
|
||
def test_minute_step_default_keeps_calculation_spec_hash_and_full_grid(self):
|
||
normalized = normalize_rectification_request(request(), today=date(2026, 7, 28))
|
||
self.assertNotIn("minute_step", normalized)
|
||
self.assertEqual(
|
||
sha256(calculation_spec(normalized)),
|
||
"d68a59ae345a87bcdf7ad45fee913d3aca02c553f896ce6c173a8bd4ab327af3",
|
||
)
|
||
candidates = _candidate_datetimes(normalized)
|
||
self.assertEqual(len(candidates), 3)
|
||
with_step = normalize_rectification_request({**request(), "minute_step": 1}, today=date(2026, 7, 28))
|
||
self.assertNotIn("minute_step", with_step)
|
||
self.assertEqual(
|
||
[value.isoformat() for value in candidates],
|
||
[value.isoformat() for value in _candidate_datetimes(with_step)],
|
||
)
|
||
|
||
def test_minute_step_ten_yields_one_hundred_forty_four_full_day_candidates(self):
|
||
normalized = normalize_rectification_request(
|
||
{**request(start_time="00:00", end_time="23:59"), "minute_step": 10},
|
||
today=date(2026, 7, 28),
|
||
)
|
||
self.assertEqual(normalized["minute_step"], 10)
|
||
self.assertEqual(calculation_spec(normalized)["minuteStep"], 10)
|
||
candidates = _candidate_datetimes(normalized)
|
||
self.assertEqual(len(candidates), 144)
|
||
self.assertEqual(candidates[0].isoformat(), "1997-08-08T00:00:00")
|
||
self.assertEqual(candidates[1].isoformat(), "1997-08-08T00:10:00")
|
||
self.assertEqual(candidates[-1].isoformat(), "1997-08-08T23:50:00")
|
||
|
||
def test_block_scan_five_periods_sum_to_one_hundred(self):
|
||
from scripts.rectification.api_service import BLOCK_SCAN_PERIODS, block_scan
|
||
from uuid import uuid4
|
||
|
||
events = [
|
||
{
|
||
"id": str(uuid4()),
|
||
"domain": domain,
|
||
"event_kind": kind,
|
||
"date_start": start,
|
||
"date_end": end,
|
||
"precision": precision,
|
||
"summary": kind,
|
||
}
|
||
for domain, kind, start, end, precision in (
|
||
("education", "education_start", "2016-09-01", "2016-09-30", "month"),
|
||
("relationship", "relationship_end", "2024-08-08", "2024-08-08", "day"),
|
||
("relocation", "relocation", "2023-07-01", "2023-07-31", "month"),
|
||
)
|
||
]
|
||
normalized = normalize_rectification_request(
|
||
{
|
||
"birth_date": "1998-03-15",
|
||
"start_time": "00:00",
|
||
"end_time": "23:59",
|
||
"lat": 39.9042,
|
||
"lon": 116.4074,
|
||
"tz": 8.0,
|
||
"minute_step": 10,
|
||
"events": events,
|
||
},
|
||
today=date(2026, 7, 28),
|
||
)
|
||
result = block_scan(normalized)
|
||
self.assertEqual(result["candidate_count"], 144)
|
||
self.assertEqual(len(result["blocks"]), 5)
|
||
self.assertEqual(
|
||
[(row["period"], row["start_time"], row["end_time"]) for row in result["blocks"]],
|
||
list(BLOCK_SCAN_PERIODS),
|
||
)
|
||
self.assertEqual(round(sum(float(row["relative_support"]) for row in result["blocks"]), 1), 100.0)
|
||
self.assertFalse(result["acceptance_allowed"])
|
||
self.assertFalse(result["selection_allowed"])
|
||
self.assertEqual(result["precision_stage"]["current"], "block_scan")
|
||
self.assertEqual(result["discriminating_event_probes"], [])
|
||
self.assertEqual(result["decision_receipt"]["precision_stage"]["current"], "block_scan")
|
||
|
||
def test_block_scan_seven_events_finishes_within_fifteen_seconds(self):
|
||
import time
|
||
from uuid import uuid4
|
||
from scripts.rectification.api_service import block_scan
|
||
|
||
events = [
|
||
{
|
||
"id": str(uuid4()),
|
||
"domain": domain,
|
||
"event_kind": kind,
|
||
"date_start": start,
|
||
"date_end": end,
|
||
"precision": precision,
|
||
"summary": kind,
|
||
}
|
||
for domain, kind, start, end, precision in (
|
||
("education", "education_start", "2016-09-01", "2016-09-30", "month"),
|
||
("relationship", "relationship_end", "2024-08-08", "2024-08-08", "day"),
|
||
("relocation", "relocation", "2023-07-01", "2023-07-31", "month"),
|
||
("career", "career_entry", "2018-07-01", "2018-07-31", "month"),
|
||
("finance", "income_change", "2021-03-01", "2021-03-31", "month"),
|
||
("health", "self_health_event", "2020-11-01", "2020-11-30", "month"),
|
||
("family", "family_event", "2019-05-01", "2019-05-31", "month"),
|
||
)
|
||
]
|
||
normalized = normalize_rectification_request(
|
||
{
|
||
"birth_date": "1998-03-15",
|
||
"start_time": "00:00",
|
||
"end_time": "23:59",
|
||
"lat": 39.9042,
|
||
"lon": 116.4074,
|
||
"tz": 8.0,
|
||
"minute_step": 10,
|
||
"events": events,
|
||
},
|
||
today=date(2026, 7, 28),
|
||
)
|
||
started = time.time()
|
||
result = block_scan(normalized)
|
||
elapsed = time.time() - started
|
||
self.assertEqual(result["candidate_count"], 144)
|
||
self.assertLessEqual(elapsed, 15, f"block_scan took {elapsed:.1f}s")
|
||
|
||
def test_block_scan_equal_scores_share_evenly_and_length_does_not_win(self):
|
||
from scripts.rectification.api_service import block_scan
|
||
|
||
def grid(score_for):
|
||
return [
|
||
{"time": f"{hour:02d}:{minute:02d}", "score": score_for(hour), "supporting_event_ids": []}
|
||
for hour in range(24)
|
||
for minute in range(0, 60, 10)
|
||
]
|
||
|
||
equal_rows = grid(lambda _hour: 12.0)
|
||
morning_rows = grid(lambda hour: 20.0 if 4 <= hour <= 7 else 10.0)
|
||
dummy = {
|
||
"result_id": "00000000-0000-4000-8000-000000000099",
|
||
"algorithm_version": "test",
|
||
"calculation_spec": {},
|
||
"calculation_spec_hash": "abc",
|
||
"decision_receipt": {},
|
||
}
|
||
request_body = {
|
||
"birth_date": "1998-03-15",
|
||
"start_time": "00:00",
|
||
"end_time": "23:59",
|
||
"lat": 39.9042,
|
||
"lon": 116.4074,
|
||
"tz": 8.0,
|
||
"minute_step": 10,
|
||
"events": [],
|
||
}
|
||
with patch("scripts.rectification.api_service.score_candidates", return_value={**dummy, "candidate_scores": equal_rows}):
|
||
equal = block_scan(request_body)
|
||
self.assertEqual([row["relative_support"] for row in equal["blocks"]], [20.0, 20.0, 20.0, 20.0, 20.0])
|
||
self.assertEqual([row["candidate_count"] for row in equal["blocks"]], [24, 24, 36, 30, 30])
|
||
with patch("scripts.rectification.api_service.score_candidates", return_value={**dummy, "candidate_scores": morning_rows}):
|
||
morning = block_scan(request_body)
|
||
shares = {row["period"]: row["relative_support"] for row in morning["blocks"]}
|
||
self.assertEqual(max(shares, key=shares.get), "early_morning")
|
||
self.assertGreater(shares["early_morning"], shares["afternoon"])
|
||
self.assertEqual(shares["afternoon"], 0.0)
|
||
|
||
def test_block_scan_custom_three_blocks_sum_to_one_hundred(self):
|
||
from scripts.rectification.api_service import block_scan
|
||
|
||
dummy = {
|
||
"result_id": "00000000-0000-4000-8000-000000000099",
|
||
"algorithm_version": "test",
|
||
"calculation_spec": {},
|
||
"calculation_spec_hash": "abc",
|
||
"decision_receipt": {},
|
||
}
|
||
rows = [
|
||
{"time": f"{hour:02d}:{minute:02d}", "score": 12.0, "supporting_event_ids": []}
|
||
for hour in range(12, 18)
|
||
for minute in range(0, 60, 10)
|
||
if not (hour == 17 and minute > 50)
|
||
]
|
||
request_body = {
|
||
"birth_date": "1998-03-15",
|
||
"start_time": "12:00",
|
||
"end_time": "17:59",
|
||
"lat": 39.9042,
|
||
"lon": 116.4074,
|
||
"tz": 8.0,
|
||
"minute_step": 10,
|
||
"events": [],
|
||
"blocks": [
|
||
{"label": "sub_1", "start_time": "12:00", "end_time": "13:59"},
|
||
{"label": "sub_2", "start_time": "14:00", "end_time": "15:59"},
|
||
{"label": "sub_3", "start_time": "16:00", "end_time": "17:59"},
|
||
],
|
||
}
|
||
with patch("scripts.rectification.api_service.score_candidates", return_value={**dummy, "candidate_scores": rows}):
|
||
result = block_scan(request_body)
|
||
self.assertEqual(len(result["blocks"]), 3)
|
||
self.assertEqual(round(sum(float(row["relative_support"]) for row in result["blocks"]), 1), 100.0)
|
||
shares = sorted(float(row["relative_support"]) for row in result["blocks"])
|
||
self.assertEqual(shares, [33.3, 33.3, 33.4])
|
||
|
||
def test_block_scan_rejects_overlapping_or_out_of_window_blocks(self):
|
||
from scripts.rectification.contracts import normalize_rectification_request
|
||
|
||
base = {
|
||
"birth_date": "1998-03-15",
|
||
"start_time": "12:00",
|
||
"end_time": "17:59",
|
||
"lat": 39.9042,
|
||
"lon": 116.4074,
|
||
"tz": 8.0,
|
||
"events": [],
|
||
}
|
||
with self.assertRaises(ValueError):
|
||
normalize_rectification_request({
|
||
**base,
|
||
"blocks": [
|
||
{"label": "sub_1", "start_time": "12:00", "end_time": "14:00"},
|
||
{"label": "sub_2", "start_time": "13:30", "end_time": "15:00"},
|
||
],
|
||
}, today=date(2026, 7, 28))
|
||
with self.assertRaises(ValueError):
|
||
normalize_rectification_request({
|
||
**base,
|
||
"blocks": [
|
||
{"label": "sub_1", "start_time": "03:00", "end_time": "04:00"},
|
||
],
|
||
}, today=date(2026, 7, 28))
|
||
|
||
def test_compare_returns_ledger_and_windows_by_candidate_time(self):
|
||
from scripts.rectification.refinement_packet import build_refinement_packet
|
||
|
||
body = request()
|
||
body["events"] = [
|
||
event(1, "education", "education_start"),
|
||
event(2, "career", "promotion", precision="month"),
|
||
event(3, "finance", "finance_gain"),
|
||
]
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
times = ["05:13", "05:14", "05:15"]
|
||
scored_ids = [item["id"] for item in normalized["events"]]
|
||
built = {
|
||
"candidate_times": times,
|
||
"matrix": {
|
||
event_id: {
|
||
"05:13": {"points": 4, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]},
|
||
"05:14": {"points": 2, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]},
|
||
"05:15": {"points": 1, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]},
|
||
}
|
||
for event_id in scored_ids
|
||
},
|
||
"date_sensitivity": [],
|
||
"missing_layers": [],
|
||
"static_contexts": [],
|
||
}
|
||
packet = build_refinement_packet(
|
||
normalized,
|
||
built,
|
||
representative_time="05:13",
|
||
candidate_times=times,
|
||
column_times=times,
|
||
include_discriminators=False,
|
||
)
|
||
ledgers = packet["event_dasha_ledger_by_time"]
|
||
windows = packet["prospective_windows_by_time"]
|
||
self.assertEqual(set(ledgers), {"05:13", "05:14", "05:15"})
|
||
self.assertEqual(set(windows), {"05:13", "05:14", "05:15"})
|
||
for clock in times:
|
||
self.assertRegex(clock, r"^[0-2]\d:[0-5]\d$")
|
||
self.assertIsInstance(ledgers[clock], list)
|
||
self.assertIsInstance(windows[clock], list)
|
||
self.assertEqual(ledgers["05:13"], packet["event_dasha_ledger"])
|
||
self.assertGreater(len(packet["event_dasha_ledger"]), 0)
|
||
self.assertIn("year_month", packet["event_dasha_ledger"][0])
|
||
self.assertEqual(packet["event_dasha_ledger"][0]["year_month"], "2016-09")
|
||
|
||
def test_compare_ledgers_cover_all_engine_candidate_times(self):
|
||
from scripts.rectification.refinement_packet import (
|
||
build_refinement_packet,
|
||
column_times_for_compare,
|
||
)
|
||
|
||
body = request()
|
||
body["events"] = [
|
||
event(1, "education", "education_start"),
|
||
event(2, "career", "promotion", precision="month"),
|
||
]
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
times = ["04:47", "04:49", "04:51", "04:53", "04:55"]
|
||
scored_ids = [item["id"] for item in normalized["events"]]
|
||
built = {
|
||
"candidate_times": times,
|
||
"matrix": {
|
||
event_id: {
|
||
clock: {"points": 4, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]}
|
||
for clock in times
|
||
}
|
||
for event_id in scored_ids
|
||
},
|
||
"date_sensitivity": [],
|
||
"missing_layers": [],
|
||
"static_contexts": [],
|
||
}
|
||
self.assertEqual(column_times_for_compare(times, "04:49"), times)
|
||
packet = build_refinement_packet(
|
||
normalized,
|
||
built,
|
||
representative_time="04:49",
|
||
candidate_times=times,
|
||
include_discriminators=False,
|
||
)
|
||
ledgers = packet["event_dasha_ledger_by_time"]
|
||
windows = packet["prospective_windows_by_time"]
|
||
self.assertEqual(set(ledgers), set(times))
|
||
self.assertEqual(set(windows), set(times))
|
||
self.assertLess(packet["column_compare_ms"], 1000)
|
||
|
||
def test_compare_ledgers_cover_seventeen_engine_candidates(self):
|
||
from scripts.rectification.refinement_packet import (
|
||
COLUMN_COMPARE_BUDGET_MS,
|
||
build_refinement_packet,
|
||
column_times_for_compare,
|
||
)
|
||
|
||
times = [f"14:{minute:02d}" for minute in range(17)]
|
||
self.assertEqual(column_times_for_compare(times, "14:16"), times)
|
||
overflow = [f"{index // 60:02d}:{index % 60:02d}" for index in range(65)]
|
||
self.assertEqual(len(column_times_for_compare(overflow, overflow[0])), MAX_PUBLIC_CLUSTERS)
|
||
|
||
body = request()
|
||
body["events"] = [
|
||
event(1, "education", "education_start"),
|
||
event(2, "career", "promotion", precision="month"),
|
||
]
|
||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||
scored_ids = [item["id"] for item in normalized["events"]]
|
||
built = {
|
||
"candidate_times": times,
|
||
"matrix": {
|
||
event_id: {
|
||
clock: {"points": 4, "rule_ids": ["vim_md_domain_house"], "technique_layers": ["vim_md_domain_house"]}
|
||
for clock in times
|
||
}
|
||
for event_id in scored_ids
|
||
},
|
||
"date_sensitivity": [],
|
||
"missing_layers": [],
|
||
"static_contexts": [],
|
||
}
|
||
packet = build_refinement_packet(
|
||
normalized,
|
||
built,
|
||
representative_time="14:16",
|
||
candidate_times=times,
|
||
include_discriminators=False,
|
||
)
|
||
self.assertEqual(set(packet["event_dasha_ledger_by_time"]), set(times))
|
||
self.assertEqual(set(packet["prospective_windows_by_time"]), set(times))
|
||
self.assertLess(packet["column_compare_ms"], COLUMN_COMPARE_BUDGET_MS)
|
||
subset = build_refinement_packet(
|
||
normalized,
|
||
built,
|
||
representative_time="14:00",
|
||
candidate_times=times,
|
||
column_times=times[:3],
|
||
include_discriminators=False,
|
||
)
|
||
self.assertEqual(set(subset["event_dasha_ledger_by_time"]), set(times[:3]))
|
||
body["column_times"] = times[:3]
|
||
self.assertEqual(
|
||
normalize_rectification_request(body, today=date(2026, 7, 28))["column_times"],
|
||
times[:3],
|
||
)
|
||
|
||
def test_hour_window_keeps_all_seventeen_signature_clusters_including_late_tail(self):
|
||
rows, contexts = _hour_window_signature_fixture()
|
||
public = select_signature_representatives(rows, contexts)
|
||
times = {row["time"] for row in public}
|
||
self.assertEqual(MAX_PUBLIC_CLUSTERS, 64)
|
||
self.assertEqual(len(public), 17)
|
||
self.assertTrue(times & {"14:46", "14:47", "14:48", "14:49", "14:50", "14:51"})
|
||
self.assertIn("15:00", {time for row in rows for time in [row["time"]]})
|
||
decisions = build_candidate_decisions(
|
||
rows,
|
||
result_id="00000000-0000-4000-8000-000000000623",
|
||
static_contexts=contexts,
|
||
)
|
||
self.assertEqual(len(decisions), 17)
|
||
late = next(item for item in decisions if item["time"] in {"14:46", "14:47", "14:48", "14:49", "14:50", "14:51"})
|
||
self.assertEqual(late["cluster_start"], "14:46")
|
||
self.assertEqual(late["cluster_end"], "14:51")
|
||
self.assertEqual(late["cluster_times"][0], "14:46")
|
||
self.assertEqual(late["cluster_times"][-1], "14:51")
|
||
|
||
def test_cluster_coverage_not_representative_minute_sets_credible_span(self):
|
||
times = _clock_span("14:40", "14:45")
|
||
rows = [
|
||
{"time": time, "score": 20 if time == "14:40" else 11, "evidence": [], "missing_layers": []}
|
||
for time in times
|
||
]
|
||
contexts = [_signature_context(time, 3) for time in times]
|
||
decisions = build_candidate_decisions(
|
||
rows,
|
||
result_id="00000000-0000-4000-8000-000000000624",
|
||
static_contexts=contexts,
|
||
)
|
||
self.assertEqual(len(decisions), 1)
|
||
self.assertEqual(decisions[0]["time"], "14:40")
|
||
self.assertEqual(decisions[0]["cluster_start"], "14:40")
|
||
self.assertEqual(decisions[0]["cluster_end"], "14:45")
|
||
self.assertEqual(decisions[0]["cluster_times"], times)
|
||
|
||
def test_adjacent_low_score_merge_keeps_the_window_tail(self):
|
||
rows = []
|
||
contexts = []
|
||
for index in range(65):
|
||
time = f"{index // 60:02d}:{index % 60:02d}"
|
||
score = 1 if index >= 63 else 20
|
||
rows.append({"time": time, "score": score, "evidence": [], "missing_layers": []})
|
||
contexts.append(_signature_context(time, index))
|
||
public = select_signature_representatives(rows, contexts)
|
||
self.assertEqual(len(public), 64)
|
||
covered = {time for row in public for time in row.get("cluster_times", [row["time"]])}
|
||
self.assertIn("01:04", covered)
|
||
|
||
|
||
def _clock_span(start: str, end: str) -> list[str]:
|
||
start_minutes = int(start[:2]) * 60 + int(start[3:5])
|
||
end_minutes = int(end[:2]) * 60 + int(end[3:5])
|
||
return [f"{minute // 60:02d}:{minute % 60:02d}" for minute in range(start_minutes, end_minutes + 1)]
|
||
|
||
|
||
def _signature_context(time: str, signature_index: int) -> dict:
|
||
hour, minute = (int(part) for part in time.split(":"))
|
||
return {
|
||
"candidate_at": datetime(1997, 8, 8, hour, minute),
|
||
"ascendant_index": signature_index % 12,
|
||
"planet_longitudes": {"Moon": 13.0 * (signature_index % 27)},
|
||
"feature": {
|
||
"time": time,
|
||
"ascendant_sign_index": signature_index % 12,
|
||
"moon_degree": 13.0 * (signature_index % 27),
|
||
"varga_ascendants": {
|
||
"D4": (signature_index // 12) % 12,
|
||
"D9": (signature_index // 3) % 12,
|
||
"D10": (signature_index // 5) % 12,
|
||
"D24": (signature_index // 7) % 12,
|
||
"D12": (signature_index // 11) % 12,
|
||
},
|
||
},
|
||
}
|
||
|
||
|
||
def _hour_window_signature_fixture() -> tuple[list[dict], list[dict]]:
|
||
ranges = [
|
||
("14:00", "14:03"),
|
||
("14:04", "14:06"),
|
||
("14:07", "14:09"),
|
||
("14:10", "14:12"),
|
||
("14:13", "14:15"),
|
||
("14:16", "14:18"),
|
||
("14:19", "14:21"),
|
||
("14:22", "14:24"),
|
||
("14:25", "14:28"),
|
||
("14:29", "14:32"),
|
||
("14:33", "14:36"),
|
||
("14:37", "14:39"),
|
||
("14:40", "14:45"),
|
||
("14:46", "14:51"),
|
||
("14:52", "14:53"),
|
||
("14:54", "14:57"),
|
||
("14:58", "15:00"),
|
||
]
|
||
rows: list[dict] = []
|
||
contexts: list[dict] = []
|
||
for index, (start, end) in enumerate(ranges):
|
||
times = _clock_span(start, end)
|
||
for offset, time in enumerate(times):
|
||
score = 30 + index if offset == 0 else 10 + index
|
||
if start == "14:46":
|
||
score = 90 if offset == 0 else 80
|
||
rows.append({"time": time, "score": score, "evidence": [], "missing_layers": []})
|
||
contexts.append(_signature_context(time, index))
|
||
return rows, contexts
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|