Files
Jyotisha/tests/test_flexible_birth_time_engine.py
T
Jesse_Chen 814c924e4a
Independent Staging Quality Gate / validate (push) Successful in 9m20s
Independent Staging Quality Gate / publish (push) Successful in 6m51s
fix(rectification): exhaustion exit, explain layer, range reading, unknown-time scan (BUG-565–568)
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>
2026-09-07 09:10:37 +08:00

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