814c924e4a
Keep askable cards after exhaustion, explain each probe, read the adopted credible range in reports and chat, and compare declared periods before the minute grid when the clock is unknown. Co-authored-by: Cursor <cursoragent@cursor.com>
305 lines
11 KiB
Python
305 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""Focused engine/API behavior for report birth-time sensitivity windows."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from hashlib import sha256
|
|
import json
|
|
import os
|
|
import sys
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
|
if SCRIPTS not in sys.path:
|
|
sys.path.insert(0, SCRIPTS)
|
|
|
|
import flexible_birth_time_profile as profile_module # noqa: E402
|
|
import jyotish_api_server as api # noqa: E402
|
|
import jyotish_engine as engine # noqa: E402
|
|
|
|
|
|
def _args(**overrides) -> SimpleNamespace:
|
|
values = {
|
|
"year": 2004,
|
|
"month": 5,
|
|
"day": 6,
|
|
"hour": 10,
|
|
"minute": 7,
|
|
"second": 0,
|
|
"lat": 12.34,
|
|
"lon": 56.78,
|
|
"tz": 5.5,
|
|
"ayanamsa": "raman",
|
|
"node_mode": "mean",
|
|
"birth_time_accuracy": "provisional",
|
|
"candidate_range": None,
|
|
"declared_window_start": None,
|
|
"declared_window_end": None,
|
|
"representative_time": None,
|
|
"uncertainty_before_minutes": None,
|
|
"uncertainty_after_minutes": None,
|
|
"event_replay_file": None,
|
|
}
|
|
values.update(overrides)
|
|
return SimpleNamespace(**values)
|
|
|
|
|
|
def _fictional_recast(candidate: datetime, **_kwargs) -> dict:
|
|
sensitive_sign = "Capricorn" if candidate.minute < 7 else "Aquarius"
|
|
return {
|
|
"ascendant": {"sign": "Aries"},
|
|
"varga_lagna": {
|
|
"D9": {"sign": "Taurus"},
|
|
"D10": {"sign": sensitive_sign},
|
|
},
|
|
"arudha": {"A7": {"sign": "Gemini"}, "A10": {"sign": sensitive_sign}, "UL": {"sign": "Cancer"}},
|
|
"kp_cusps": {"house_10": {"sub_lord": "Saturn" if candidate.minute < 7 else "Mercury"}},
|
|
}
|
|
|
|
|
|
def _digest(value: dict) -> str:
|
|
payload = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
|
return sha256(payload.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def test_clock_parser_requires_exact_hh_mm() -> None:
|
|
center = datetime(2004, 5, 6, 10, 7)
|
|
assert engine._clock_on_birth_date(center, "09:05") == datetime(2004, 5, 6, 9, 5)
|
|
assert engine._clock_on_birth_date(center, None) is None
|
|
for invalid in ("9:05", "09:05:00", "09:05 trailing", "24:00", ""):
|
|
with pytest.raises(ValueError, match="birth_time_clock_must_be_hh_mm"):
|
|
engine._clock_on_birth_date(center, invalid)
|
|
|
|
|
|
def test_explicit_reverse_or_incomplete_window_is_rejected_instead_of_falling_back() -> None:
|
|
center = datetime(2004, 5, 6, 10, 7)
|
|
with pytest.raises(ValueError, match="must_not_cross_midnight"):
|
|
engine._birth_time_candidate_window(
|
|
_args(declared_window_start="23:58", declared_window_end="00:02"),
|
|
center,
|
|
"provisional",
|
|
)
|
|
with pytest.raises(ValueError, match="requires_start_and_end"):
|
|
engine._birth_time_candidate_window(
|
|
_args(declared_window_start="10:00"),
|
|
center,
|
|
"provisional",
|
|
)
|
|
with pytest.raises(ValueError, match="birth_time_clock_must_be_hh_mm"):
|
|
engine._birth_time_candidate_window(
|
|
_args(declared_window_start="10:00 extra", declared_window_end="10:10"),
|
|
center,
|
|
"provisional",
|
|
)
|
|
|
|
fallback = engine._birth_time_candidate_window(
|
|
_args(uncertainty_before_minutes=1, uncertainty_after_minutes=1),
|
|
center,
|
|
"provisional",
|
|
)
|
|
assert tuple(value.strftime("%H:%M") for value in fallback) == ("10:06", "10:07", "10:08")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"args",
|
|
[
|
|
_args(birth_time_accuracy="confirmed"),
|
|
_args(declared_window_start="10:07", declared_window_end="10:07", representative_time="10:07"),
|
|
_args(birth_time_accuracy="approximate", uncertainty_before_minutes=0, uncertainty_after_minutes=0),
|
|
],
|
|
)
|
|
def test_confirmed_or_actual_single_minute_skips_candidate_recast(monkeypatch, args: SimpleNamespace) -> None:
|
|
monkeypatch.setattr(
|
|
profile_module,
|
|
"_recast_candidate_layers",
|
|
lambda *_args, **_kwargs: pytest.fail("single-minute input must not recast candidate charts"),
|
|
)
|
|
|
|
assert engine._build_birth_time_sensitivity(args) == {
|
|
"schema": "jyotish.report_birth_time_sensitivity.v1",
|
|
"status": "not_applicable",
|
|
"accuracy": "confirmed",
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("range_status", "end_time", "candidate_count"),
|
|
[("accepted", "10:01", 2), ("candidate", "10:14", 15)],
|
|
)
|
|
def test_multi_minute_accepted_or_candidate_windows_remain_provisional(
|
|
monkeypatch, range_status: str, end_time: str, candidate_count: int,
|
|
) -> None:
|
|
monkeypatch.setattr(profile_module, "_recast_candidate_layers", _fictional_recast)
|
|
args = _args(
|
|
candidate_range={
|
|
"status": range_status,
|
|
"start_time": "10:00",
|
|
"end_time": end_time,
|
|
"representative_time": "10:07",
|
|
},
|
|
)
|
|
|
|
first = engine._build_birth_time_sensitivity(args)
|
|
second = engine._build_birth_time_sensitivity(args)
|
|
|
|
assert first["status"] == "candidate_window_only"
|
|
assert first["accuracy"] == "provisional"
|
|
assert first["window"]["candidate_count"] == candidate_count
|
|
assert _digest(first) == _digest(second)
|
|
for minute_values in first["sensitive_evidence"]["layers"].values():
|
|
assert len({json.dumps(value, sort_keys=True) for value in minute_values.values()}) > 1
|
|
for theme in first["theme_sensitivity"].values():
|
|
for layer in theme["sensitive_layers"]:
|
|
values = first["sensitive_evidence"]["layers"][layer]
|
|
assert len({json.dumps(value, sort_keys=True) for value in values.values()}) > 1
|
|
|
|
|
|
def test_confirmed_report_packet_keeps_unrectified_sensitivity_surface() -> None:
|
|
packet = {"worksheets": {}, "raw_full_reading": {"modules": {}}}
|
|
|
|
result = engine._attach_report_governance_contracts(packet, _args(birth_time_accuracy="confirmed"))
|
|
|
|
assert result["birth_time_sensitivity"]["status"] == "not_rectified"
|
|
assert result["birth_time_sensitivity"]["reason"] == "no_candidate_window"
|
|
|
|
|
|
@pytest.mark.parametrize("accuracy", ["confirmed", "provisional", "approximate"])
|
|
def test_birth_time_accuracy_accepts_only_exact_supported_values(accuracy: str) -> None:
|
|
assert engine._birth_time_accuracy(_args(birth_time_accuracy=accuracy)) == accuracy
|
|
|
|
|
|
def test_birth_time_accuracy_defaults_to_confirmed_when_field_is_absent() -> None:
|
|
assert engine._birth_time_accuracy(SimpleNamespace()) == "confirmed"
|
|
|
|
|
|
@pytest.mark.parametrize("invalid", [None, "", "bogus", " PROVISIONAL ", "PROVISIONAL", 1])
|
|
def test_birth_time_accuracy_rejects_invalid_or_formatted_values(invalid: object) -> None:
|
|
with pytest.raises(
|
|
ValueError,
|
|
match="birth_time_accuracy_must_be_confirmed_provisional_or_approximate",
|
|
):
|
|
engine._birth_time_accuracy(_args(birth_time_accuracy=invalid))
|
|
|
|
|
|
def test_api_turns_invalid_birth_time_accuracy_into_bad_request(monkeypatch) -> None:
|
|
class FakeEngine:
|
|
@staticmethod
|
|
def cmd_full_reading(args):
|
|
engine._birth_time_accuracy(args)
|
|
return {"modules": {}}
|
|
|
|
monkeypatch.setattr(api, "_load_local_module", lambda _name: FakeEngine)
|
|
handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler)
|
|
|
|
with pytest.raises(
|
|
api.BadRequest,
|
|
match="birth_time_accuracy_must_be_confirmed_provisional_or_approximate",
|
|
):
|
|
handler._compute_full_reading_for_thematic({
|
|
"year": 2004,
|
|
"month": 5,
|
|
"day": 6,
|
|
"hour": 10,
|
|
"minute": 7,
|
|
"lat": 12.34,
|
|
"lon": 56.78,
|
|
"tz": 5.5,
|
|
"birth_time_accuracy": " PROVISIONAL ",
|
|
})
|
|
|
|
|
|
def test_api_turns_invalid_report_window_into_bad_request(monkeypatch) -> None:
|
|
class FakeEngine:
|
|
@staticmethod
|
|
def cmd_full_reading(_args):
|
|
raise ValueError("birth_time_window_must_not_cross_midnight")
|
|
|
|
monkeypatch.setattr(api, "_load_local_module", lambda _name: FakeEngine)
|
|
handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler)
|
|
|
|
with pytest.raises(api.BadRequest, match="birth_time_window_must_not_cross_midnight"):
|
|
handler._compute_full_reading_for_thematic({
|
|
"year": 2004,
|
|
"month": 5,
|
|
"day": 6,
|
|
"hour": 10,
|
|
"minute": 7,
|
|
"lat": 12.34,
|
|
"lon": 56.78,
|
|
"tz": 5.5,
|
|
"birth_time_accuracy": "provisional",
|
|
"declared_window_start": "23:58",
|
|
"declared_window_end": "00:02",
|
|
})
|
|
|
|
|
|
def test_candidate_minutes_covers_every_minute_in_a_27_minute_window() -> None:
|
|
start = datetime(2004, 5, 6, 10, 0)
|
|
representative = datetime(2004, 5, 6, 10, 7)
|
|
end = datetime(2004, 5, 6, 10, 26)
|
|
times = engine._candidate_minutes(start, representative, end)
|
|
assert times == [f"10:{index:02d}" for index in range(27)]
|
|
|
|
|
|
def test_candidate_minutes_samples_thirty_one_including_bounds_for_a_60_minute_window() -> None:
|
|
start = datetime(2004, 5, 6, 10, 0)
|
|
representative = datetime(2004, 5, 6, 10, 7)
|
|
end = datetime(2004, 5, 6, 10, 59)
|
|
times = engine._candidate_minutes(start, representative, end)
|
|
assert len(times) == 31
|
|
assert times[0] == "10:00"
|
|
assert "10:07" in times
|
|
assert times[-1] == "10:59"
|
|
assert len(set(times)) == 31
|
|
|
|
|
|
def test_sensitivity_27_minute_window_recasts_every_minute(monkeypatch) -> None:
|
|
monkeypatch.setattr(profile_module, "_recast_candidate_layers", _fictional_recast)
|
|
args = _args(
|
|
candidate_range={
|
|
"start_time": "10:00",
|
|
"end_time": "10:26",
|
|
"representative_time": "10:07",
|
|
},
|
|
)
|
|
result = engine._build_birth_time_sensitivity(args)
|
|
assert result["window"]["candidate_count"] == 27
|
|
assert result["window"]["sampled"] is False
|
|
|
|
|
|
def test_sensitivity_60_minute_window_samples_thirty_one_and_marks_sampled(monkeypatch) -> None:
|
|
monkeypatch.setattr(profile_module, "_recast_candidate_layers", _fictional_recast)
|
|
args = _args(
|
|
candidate_range={
|
|
"start_time": "10:00",
|
|
"end_time": "10:59",
|
|
"representative_time": "10:07",
|
|
},
|
|
)
|
|
result = engine._build_birth_time_sensitivity(args)
|
|
assert result["window"]["candidate_count"] == 31
|
|
assert result["window"]["sampled"] is True
|
|
times = list(result["window"]["candidate_times"])
|
|
assert times[0] == "10:00"
|
|
assert "10:07" in times
|
|
assert times[-1] == "10:59"
|
|
|
|
|
|
def test_approximate_wide_window_uses_three_coarse_samples(monkeypatch) -> None:
|
|
monkeypatch.setattr(profile_module, "_recast_candidate_layers", _fictional_recast)
|
|
args = _args(
|
|
birth_time_accuracy="approximate",
|
|
candidate_range={
|
|
"start_time": "10:00",
|
|
"end_time": "10:59",
|
|
"representative_time": "10:07",
|
|
},
|
|
)
|
|
result = engine._build_birth_time_sensitivity(args)
|
|
assert result["window"]["candidate_count"] == 3
|
|
assert result["window"]["sampled"] is True
|
|
assert set(result["window"]["candidate_times"]) == {"10:00", "10:07", "10:59"}
|