fix(rectification): keep hour-window tail clusters and lock the search window (BUG-623, BUG-624, BUG-625)
Hour windows no longer drop later signature clusters. Credible range uses cluster coverage, and a mid-session spoken birth window gets a fixed reply without calling the model. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
|
||||
@@ -13,7 +13,12 @@ from scripts.jyotish_api_server import (
|
||||
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,
|
||||
@@ -472,7 +477,16 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
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"})
|
||||
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"])
|
||||
|
||||
@@ -1102,6 +1116,117 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
self.assertEqual(set(windows), set(times))
|
||||
self.assertLess(packet["column_compare_ms"], 1000)
|
||||
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user