Files
Jyotisha/tests/test_rectification_midnight_date_anchor.py
T
jesse-ux b85c4a686a
Independent Staging Quality Gate / validate (push) Successful in 13m27s
Independent Staging Quality Gate / publish (push) Failing after 1h0m1s
fix(rectification): anchor candidate windows to civil dates across midnight
Carry explicit local date intervals instead of inferring the day from clock
order. Cluster width, delivery, adoption, and reports keep the actual civil
date; adopted date is stored separately from the reported birth_date.

Algorithm identity is scoring-9 / spec-v5. Scoring weights, confirmation
thresholds, and Skill version are unchanged. Isolated Linux final-3 gates
passed; four pre-existing Python failures remain. This is not a production
release.
2026-09-21 02:55:00 +08:00

156 lines
8.6 KiB
Python

"""BUG-982/983: explicit civil-date windows; synthetic dates, native computations."""
from datetime import datetime, timedelta
import pytest
from scripts.active_rectification_event_engine import _candidate_datetimes
from scripts.rectification.decision_policy import _cluster_span, indistinguishable_width_minutes
def test_declared_minute_stays_on_declared_date():
moments = _candidate_datetimes({
"birth_date": "2000-06-15", "start_time": "23:55", "end_time": "00:25",
"candidate_intervals": [{"start_at": "2000-06-14T23:55", "end_at": "2000-06-15T00:25"}],
})
assert datetime(2000, 6, 15, 0, 10) in moments
assert datetime(2000, 6, 16, 0, 10) not in moments
def test_late_night_unknown_is_two_segments_of_same_day():
moments = _candidate_datetimes({
"birth_date": "2000-06-15", "start_time": "23:00", "end_time": "03:59",
"candidate_intervals": [
{"start_at": "2000-06-15T00:00", "end_at": "2000-06-15T03:59"},
{"start_at": "2000-06-15T23:00", "end_at": "2000-06-15T23:59"},
],
})
assert len(moments) == 300
assert {at.date().isoformat() for at in moments} == {"2000-06-15"}
assert not any(4 <= at.hour < 23 for at in moments)
def test_cluster_span_uses_window_order_across_midnight():
row = {"time": "23:58", "cluster_times": ["23:58", "23:59", "00:00"],
"cluster_positions": [
{"time": time, "window_index": i, "window_offset_minutes": i,
"segment_index": 0, "candidate_date": "2000-06-14" if i < 2 else "2000-06-15"}
for i, time in enumerate(["23:58", "23:59", "00:00"])
]}
assert _cluster_span(row) == (["23:58", "23:59", "00:00"], "23:58", "00:00")
assert indistinguishable_width_minutes([{
"rank": 1, "time": "23:58", "tied_minute_count": 3,
"cluster_start": "23:58", "cluster_end": "00:00",
"cluster_intervals": [{"start_index": 0, "end_index": 2,
"start_offset_minutes": 0, "end_offset_minutes": 2,
"start_at": "2000-06-14T23:58", "end_at": "2000-06-15T00:00", "segment_index": 0}],
}]) == 3
@pytest.mark.parametrize("step", [1, 2, 5, 10, 15])
def test_same_day_candidate_enumeration_unchanged(step):
request = {"birth_date": "2000-06-15", "start_time": "11:45", "end_time": "12:15", "minute_step": step}
assert _candidate_datetimes(request) == _candidate_datetimes({**request, "candidate_intervals": [
{"start_at": "2000-06-15T11:45", "end_at": "2000-06-15T12:15"},
]})
@pytest.mark.parametrize("date", ["2000-01-01", "2000-03-01", "2000-02-29", "2001-03-01", "2000-12-31"])
@pytest.mark.parametrize("clock", ["00:00", "00:10", "23:59"])
def test_calendar_boundaries(date, clock):
center = datetime.fromisoformat(f"{date}T{clock}")
start, end = center - timedelta(minutes=15), center + timedelta(minutes=15)
request = {"birth_date": date, "start_time": start.strftime("%H:%M"), "end_time": end.strftime("%H:%M"),
"candidate_intervals": [{"start_at": start.isoformat(timespec="minutes"), "end_at": end.isoformat(timespec="minutes")}]}
moments = _candidate_datetimes(request)
assert moments == [start + timedelta(minutes=i) for i in range(31)]
assert moments[15] == center
@pytest.mark.parametrize("step", [2, 15])
def test_nondivisible_sampling_preserves_legacy_endpoint_rule(step):
request = {"birth_date": "2000-01-01", "start_time": "12:00", "end_time": "12:31", "minute_step": step}
moments = _candidate_datetimes(request)
assert [at.minute for at in moments] == list(range(0, 32, step))
assert moments[-1].minute == 30 # legacy range() never appends an unaligned endpoint
@pytest.mark.parametrize("parts", [
[{"start_at": "2000-02-30T00:00", "end_at": "2000-03-01T00:00"}],
[{"start_at": "1999-12-30T23:59", "end_at": "1999-12-30T23:59"}],
[{"start_at": "2000-01-03T00:00", "end_at": "2000-01-03T00:00"}],
[{"start_at": "2000-01-01T01:00", "end_at": "2000-01-01T00:00"}],
[{"start_at": "2000-01-01T00:00", "end_at": "2000-01-01T01:00"}, {"start_at": "2000-01-01T01:00", "end_at": "2000-01-01T02:00"}],
[{"start_at": "2000-01-01T00:00", "end_at": "2000-01-01T00:01"}, {"start_at": "2000-01-02T00:00", "end_at": "2000-01-02T00:01"}],
[{"start_at": "2000-01-01T00:00Z", "end_at": "2000-01-01T00:01Z"}],
])
def test_invalid_intervals_fail_closed(parts):
from scripts.rectification.candidate_window import candidate_intervals
with pytest.raises(ValueError):
candidate_intervals({"birth_date": "2000-01-01", "start_time": "00:00", "end_time": "23:59", "candidate_intervals": parts})
@pytest.mark.parametrize("parts", [None, [], {}, "", [None], [{"start_at": "2000-01-01T00:00"}]])
def test_api_normalization_rejects_present_invalid_intervals(parts):
from scripts.rectification.contracts import normalize_rectification_request
request = {"birth_date": "2000-01-01", "start_time": "23:55", "end_time": "00:25",
"lat": 0, "lon": 0, "tz": 0, "events": []}
legacy = normalize_rectification_request(request)
assert "candidate_intervals" not in legacy
with pytest.raises(ValueError):
normalize_rectification_request({**request, "candidate_intervals": parts})
def test_report_sampler_recasts_actual_dates_without_gap(monkeypatch):
from types import SimpleNamespace
from scripts import jyotish_engine
import flexible_birth_time_profile as profile
captured = []
def recast(at, **kwargs):
captured.append(at)
return {"ascendant": {"sign": "Aries"}}
monkeypatch.setattr(profile, "_recast_candidate_layers", recast)
args = SimpleNamespace(year=2000, month=3, day=1, hour=0, minute=0, lat=0, lon=0, tz=0,
birth_time_accuracy="provisional", ayanamsa="raman", candidate_range={"start_time": "23:58", "end_time": "00:02",
"candidate_intervals": [{"start_at": "2000-02-29T23:58", "end_at": "2000-03-01T00:02"}]})
result = jyotish_engine._build_birth_time_sensitivity(args)
assert captured == [datetime(2000, 2, 29, 23, 58)+timedelta(minutes=i) for i in range(5)]
assert result["status"] == "candidate_window_only"
captured.clear()
args.candidate_range = {"start_time": "23:00", "end_time": "03:59", "candidate_intervals": [
{"start_at": "2000-03-01T00:00", "end_at": "2000-03-01T03:59"},
{"start_at": "2000-03-01T23:00", "end_at": "2000-03-01T23:59"}]}
jyotish_engine._build_birth_time_sensitivity(args)
assert len(captured) == 31
assert all(at.date().isoformat() == "2000-03-01" and (at.hour < 4 or at.hour == 23) for at in captured)
def test_segment_cap_merge_retains_all_minutes_without_daytime():
from scripts.rectification.candidate_window import candidate_positions, intervals_from_positions, interval_union_width
from scripts.rectification.candidate_contrast import select_signature_representatives
request = {"birth_date": "2000-01-01", "start_time": "23:00", "end_time": "03:59", "candidate_intervals": [
{"start_at": "2000-01-01T00:00", "end_at": "2000-01-01T03:59"},
{"start_at": "2000-01-01T23:00", "end_at": "2000-01-01T23:59"}]}
positions = candidate_positions(request, _candidate_datetimes(request))
contexts = [{**row, "feature": {"time": row["time"], "ascendant_sign_index": row["window_index"] % 70}} for row in positions]
rows = [{"time": row["time"], "score": 1.0} for row in positions]
result = select_signature_representatives(rows, contexts)
assert len(result) == 64
assert {time for row in result for time in row["cluster_times"]} == {row["time"] for row in positions}
intervals = [part for row in result for part in intervals_from_positions(row["cluster_positions"])]
assert interval_union_width(intervals) == 300
assert {part["segment_index"] for part in intervals} == {0, 1}
def test_same_signature_different_segments_and_step_offsets():
from scripts.rectification.candidate_window import candidate_positions, intervals_from_positions, interval_union_width
from scripts.rectification.candidate_contrast import cluster_contexts_by_signature
request = {"birth_date": "2000-01-01", "start_time": "23:00", "end_time": "03:59", "minute_step": 2,
"candidate_intervals": [{"start_at": "2000-01-01T00:00", "end_at": "2000-01-01T03:59"},
{"start_at": "2000-01-01T23:00", "end_at": "2000-01-01T23:59"}]}
rows = candidate_positions(request, _candidate_datetimes(request))
contexts = [{**row, "feature": {"time": row["time"]}} for row in rows]
assert len(cluster_contexts_by_signature(contexts)) == 2
assert rows[120]["window_offset_minutes"] == 1380
assert rows[120]["window_index"] == 120
assert interval_union_width(intervals_from_positions(rows)) == 298