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