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.
156 lines
8.6 KiB
Python
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
|