bab0718700
Web export now calls the same full pack as the long skill report and caches an owner-only Markdown download. Appendix failure stays unavailable and does not change the main report status. Co-authored-by: Cursor <cursoragent@cursor.com>
532 lines
20 KiB
Python
Executable File
532 lines
20 KiB
Python
Executable File
"""Focused regression tests for the shared calculation-profile contract.
|
|
|
|
The shared module is the single profile producer for
|
|
``scripts/domain_calculation_service.py`` (CLI/REST/MCP canonical chart) and
|
|
``scripts/jyotish_engine.py`` command wrappers. These tests pin down:
|
|
|
|
* stable canonical ``profile_id`` / ``input_hash`` / ``profile_hash``;
|
|
* dynamic settings preserved and reflected in the hashes;
|
|
* privacy: the profile never stores birth date/time/place/coordinates
|
|
(AGENTS hard-constraint 6.6);
|
|
* observed-only ephemeris provenance, with ``ephemeris_path`` excluded from
|
|
profile identity (cross-machine stability);
|
|
* ``attach_calculation_profile`` adds exactly two keys without mutating
|
|
existing business fields;
|
|
* dict and Namespace entry points are equivalent; root and nested birth
|
|
inputs are equivalent;
|
|
* malformed inputs raise structured ``CalculationProfileError`` (or retain an
|
|
IANA timezone name) instead of silently producing a lossy profile;
|
|
* domain-service and CLI producers yield the same profile hash for the same
|
|
input;
|
|
* the dasha-master-pack profile gate contract keeps working.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SCRIPTS = str(ROOT / "scripts")
|
|
if str(SCRIPTS) not in sys.path:
|
|
sys.path.insert(0, str(SCRIPTS))
|
|
|
|
from scripts.calculation_profile_contract import ( # noqa: E402
|
|
SCHEMA,
|
|
PROFILE_VERSION,
|
|
CalculationProfileError,
|
|
attach_calculation_profile,
|
|
build_calculation_profile,
|
|
)
|
|
|
|
BIRTH_PAYLOAD = {
|
|
"birth": {
|
|
"date": "1990-01-01",
|
|
"time": "12:00:00",
|
|
"utc_offset": "+08:00",
|
|
"latitude": 39.9,
|
|
"longitude": 116.4,
|
|
"place": "Beijing",
|
|
"coordinate_precision": "city",
|
|
},
|
|
"settings": {
|
|
"ayanamsa": "lahiri",
|
|
"node_mode": "mean",
|
|
"position_mode": "legacy",
|
|
"house_system": "whole_sign",
|
|
"dasha_year_days": 365.25,
|
|
},
|
|
}
|
|
|
|
FORBIDDEN_PROFILE_KEYS = (
|
|
"birth",
|
|
"birth_input",
|
|
"location",
|
|
"date",
|
|
"time",
|
|
"year",
|
|
"month",
|
|
"day",
|
|
"hour",
|
|
"minute",
|
|
"second",
|
|
"lat",
|
|
"lon",
|
|
"latitude",
|
|
"longitude",
|
|
"place",
|
|
)
|
|
|
|
|
|
def _args(**overrides) -> SimpleNamespace:
|
|
values = {
|
|
"year": 1990,
|
|
"month": 1,
|
|
"day": 1,
|
|
"hour": 12,
|
|
"minute": 0,
|
|
"second": 0,
|
|
"lat": 39.9,
|
|
"lon": 116.4,
|
|
"tz": 8.0,
|
|
"ayanamsa": "lahiri",
|
|
"node_mode": "mean",
|
|
"position_mode": "legacy",
|
|
"house_system": "whole_sign",
|
|
"dasha_year_days": 365.25,
|
|
"validate": False,
|
|
}
|
|
values.update(overrides)
|
|
return SimpleNamespace(**values)
|
|
|
|
|
|
def _assert_no_birth_fields(profile: dict, path: str = "") -> None:
|
|
for key, value in profile.items():
|
|
full = f"{path}.{key}" if path else key
|
|
assert key not in FORBIDDEN_PROFILE_KEYS, f"birth data leaked via {full}"
|
|
if isinstance(value, dict):
|
|
_assert_no_birth_fields(value, full)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# stability and parameter sensitivity
|
|
# ---------------------------------------------------------------------------
|
|
def test_profile_is_stable_across_repeated_builds() -> None:
|
|
first = build_calculation_profile(BIRTH_PAYLOAD)
|
|
repeated = build_calculation_profile(BIRTH_PAYLOAD)
|
|
|
|
assert first["schema"] == SCHEMA
|
|
assert first["profile_version"] == PROFILE_VERSION
|
|
assert first["profile_id"] == repeated["profile_id"]
|
|
assert first["input_hash"] == repeated["input_hash"]
|
|
assert first["profile_hash"] == repeated["profile_hash"]
|
|
assert first["profile_id"] == first["profile_hash"]
|
|
assert len(first["profile_id"]) == 64
|
|
|
|
|
|
def test_parameter_change_changes_profile_id_and_hashes() -> None:
|
|
base = build_calculation_profile(BIRTH_PAYLOAD)
|
|
|
|
for key, value in (
|
|
("node_mode", "true"),
|
|
("ayanamsa", "raman"),
|
|
("position_mode", "apparent"),
|
|
("dasha_year_days", 365.25636),
|
|
("house_system", "sripati"),
|
|
):
|
|
changed_payload = json.loads(json.dumps(BIRTH_PAYLOAD))
|
|
changed_payload["settings"][key] = value
|
|
changed = build_calculation_profile(changed_payload)
|
|
assert changed["profile_id"] != base["profile_id"], key
|
|
assert changed["input_hash"] != base["input_hash"], key
|
|
|
|
tz_changed = json.loads(json.dumps(BIRTH_PAYLOAD))
|
|
tz_changed["birth"]["utc_offset"] = "-08:00"
|
|
assert build_calculation_profile(tz_changed)["profile_id"] != base["profile_id"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# privacy (AGENTS 6.6) and flat compatibility keys
|
|
# ---------------------------------------------------------------------------
|
|
def test_profile_never_contains_birth_fields() -> None:
|
|
profile = build_calculation_profile(BIRTH_PAYLOAD)
|
|
_assert_no_birth_fields(profile)
|
|
|
|
|
|
def test_profile_preserves_flat_compat_keys_and_effective_settings() -> None:
|
|
profile = build_calculation_profile(BIRTH_PAYLOAD)
|
|
|
|
assert profile["ayanamsa"] == "lahiri"
|
|
assert profile["node_mode"] == "mean"
|
|
assert profile["position_mode"] == "legacy"
|
|
assert profile["house_system"] == "whole_sign"
|
|
assert profile["dasha_year_days"] == 365.25
|
|
assert profile["solar_return_location_mode"] == "birth_place"
|
|
assert profile["annual_year_policy"] == "solar_return_exact"
|
|
assert profile["coordinate_precision"] == "city"
|
|
assert profile["algorithm"] == "sidereal_natal_chart"
|
|
assert profile["timezone"] == {"name": "UTC+08:00", "utc_offset": "+08:00"}
|
|
assert profile["effective_settings"]["ayanamsa"] == "lahiri"
|
|
assert profile["effective_settings"]["node_mode"] == "mean"
|
|
assert profile["effective_settings"]["timezone_offset"] == "+08:00"
|
|
assert profile["effective_settings"]["dasha_year_days"] == 365.25
|
|
|
|
|
|
def test_iana_timezone_name_is_retained() -> None:
|
|
profile = build_calculation_profile({
|
|
"birth": {
|
|
"date": "1990-01-01",
|
|
"time": "12:00:00",
|
|
"timezone": "Asia/Shanghai",
|
|
"latitude": 31.2,
|
|
"longitude": 121.5,
|
|
},
|
|
"settings": {},
|
|
})
|
|
assert profile["timezone"]["name"] == "Asia/Shanghai"
|
|
|
|
# an IANA-like value in the tz slot is retained as the name, not dropped
|
|
via_tz_slot = build_calculation_profile({
|
|
"birth": {"date": "1990-01-01", "tz": "Asia/Shanghai", "latitude": 31.2, "longitude": 121.5},
|
|
})
|
|
assert via_tz_slot["timezone"]["name"] == "Asia/Shanghai"
|
|
assert via_tz_slot["timezone"]["utc_offset"] is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ephemeris provenance: observed-only, path-excluded
|
|
# ---------------------------------------------------------------------------
|
|
def test_ephemeris_provider_is_never_fabricated() -> None:
|
|
plain = build_calculation_profile(BIRTH_PAYLOAD)
|
|
assert plain["engine"]["ephemeris_provider"] == "not_observed"
|
|
assert plain["engine"]["ephemeris_flags_verified"] is False
|
|
assert plain["engine"]["ephemeris_source"] is None
|
|
|
|
observed = build_calculation_profile({
|
|
"birth": {"date": "1990-01-01"},
|
|
"engine": {
|
|
"ephemeris_provider": "moshier",
|
|
"ephemeris_flags": 4,
|
|
"ephemeris_flags_verified": True,
|
|
"ephemeris_source": "moshier_calc_ut",
|
|
},
|
|
})
|
|
assert observed["engine"]["ephemeris_provider"] == "moshier"
|
|
assert observed["engine"]["ephemeris_flags_verified"] is True
|
|
|
|
|
|
def test_ephemeris_path_is_excluded_from_profile_and_hash() -> None:
|
|
def build_with_path(path: str) -> dict:
|
|
return build_calculation_profile({
|
|
"birth": {"date": "1990-01-01", "time": "12:00:00"},
|
|
"engine": {
|
|
"ephemeris_provider": "swisseph",
|
|
"ephemeris_flags": 66,
|
|
"ephemeris_flags_verified": True,
|
|
"ephemeris_source": "swisseph_calc_ut",
|
|
"ephemeris_policy": "bundled_swisseph_preferred_observed_provider_recorded",
|
|
"ephemeris_path": path,
|
|
},
|
|
})
|
|
|
|
first = build_with_path("/Users/alice/repo/references/open_source_sources/vedic-astro-skills/ephe")
|
|
second = build_with_path("/tmp/elsewhere/completely/different/ephe")
|
|
|
|
assert "ephemeris_path" not in first["engine"]
|
|
assert "ephemeris_path" not in second["engine"]
|
|
assert first["profile_id"] == second["profile_id"]
|
|
assert first["input_hash"] == second["input_hash"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# attach semantics
|
|
# ---------------------------------------------------------------------------
|
|
def test_attach_adds_profile_and_result_binding_without_mutating_business_result() -> None:
|
|
result = {
|
|
"planets": {"Sun": {"lon": 100.0, "sign": "Cancer"}},
|
|
"ascendant": {"lon": 90.0},
|
|
"meta": {
|
|
"ephemeris_provider": "swisseph",
|
|
"ephemeris_flags": 2,
|
|
"ephemeris_flags_verified": True,
|
|
"ephemeris_source": "swisseph_calc_ut",
|
|
"ephemeris_policy": "bundled_swisseph_preferred_observed_provider_recorded",
|
|
"ephemeris_path": "/machine/specific/ephe",
|
|
},
|
|
}
|
|
snapshot = json.dumps(
|
|
{key: value for key, value in result.items()},
|
|
sort_keys=True,
|
|
default=str,
|
|
)
|
|
|
|
returned = attach_calculation_profile(result, _args())
|
|
|
|
assert returned is result
|
|
assert set(result.keys()) == {
|
|
"planets",
|
|
"ascendant",
|
|
"meta",
|
|
"calculation_profile",
|
|
"calculation_profile_id",
|
|
"result_hash",
|
|
"result_binding",
|
|
}
|
|
remaining = json.dumps(
|
|
{
|
|
key: value
|
|
for key, value in result.items()
|
|
if key not in (
|
|
"calculation_profile",
|
|
"calculation_profile_id",
|
|
"result_hash",
|
|
"result_binding",
|
|
)
|
|
},
|
|
sort_keys=True,
|
|
default=str,
|
|
)
|
|
assert remaining == snapshot
|
|
assert result["calculation_profile_id"] == result["calculation_profile"]["profile_id"]
|
|
assert len(result["result_hash"]) == 64
|
|
assert result["result_binding"] == {
|
|
"input_hash": result["calculation_profile"]["input_hash"],
|
|
"result_hash": result["result_hash"],
|
|
}
|
|
# observed provider is carried; ephemeris_path is never part of the profile
|
|
assert result["calculation_profile"]["engine"]["ephemeris_provider"] == "swisseph"
|
|
assert "ephemeris_path" not in result["calculation_profile"]["engine"]
|
|
_assert_no_birth_fields(result["calculation_profile"])
|
|
|
|
|
|
def test_attach_prefers_existing_canonical_profile() -> None:
|
|
canonical = build_calculation_profile(BIRTH_PAYLOAD)
|
|
result = {"planets": {}, "calculation_profile": canonical}
|
|
returned = attach_calculation_profile(result, _args())
|
|
assert returned["calculation_profile"] is canonical
|
|
assert returned["calculation_profile_id"] == canonical["profile_id"]
|
|
|
|
|
|
def test_dict_and_namespace_entry_are_equivalent() -> None:
|
|
dict_profile = attach_calculation_profile({}, dict(
|
|
year=1990, month=1, day=1, hour=12, minute=0, second=0,
|
|
lat=39.9, lon=116.4, tz=8.0,
|
|
ayanamsa="lahiri", node_mode="mean", position_mode="legacy",
|
|
house_system="whole_sign", dasha_year_days=365.25,
|
|
))["calculation_profile"]
|
|
ns_profile = attach_calculation_profile({}, _args())["calculation_profile"]
|
|
assert dict_profile["profile_id"] == ns_profile["profile_id"]
|
|
assert dict_profile["input_hash"] == ns_profile["input_hash"]
|
|
|
|
|
|
def test_root_and_nested_birth_inputs_are_equivalent() -> None:
|
|
nested = build_calculation_profile({
|
|
"birth": {
|
|
"date": "1990-01-01",
|
|
"time": "12:00:00",
|
|
"utc_offset": "+08:00",
|
|
"latitude": 39.9,
|
|
"longitude": 116.4,
|
|
},
|
|
"settings": {
|
|
"ayanamsa": "lahiri", "node_mode": "mean", "position_mode": "legacy",
|
|
"house_system": "whole_sign", "dasha_year_days": 365.25,
|
|
},
|
|
})
|
|
root = build_calculation_profile({
|
|
"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "second": 0,
|
|
"lat": 39.9, "lon": 116.4, "tz": 8.0,
|
|
"settings": {
|
|
"ayanamsa": "lahiri", "node_mode": "mean", "position_mode": "legacy",
|
|
"house_system": "whole_sign", "dasha_year_days": 365.25,
|
|
},
|
|
})
|
|
assert root["input_hash"] == nested["input_hash"]
|
|
assert root["profile_id"] == nested["profile_id"]
|
|
|
|
|
|
def test_string_date_time_parts_and_fractional_seconds_do_not_crash() -> None:
|
|
profile = build_calculation_profile({
|
|
"birth": {
|
|
"date": "1990-1-1",
|
|
"time": "12:00:30.5",
|
|
"utc_offset": "+08:00",
|
|
"lat": "39.9",
|
|
"lon": "116.4",
|
|
},
|
|
"settings": {"node_mode": "mean", "ayanamsa": "lahiri"},
|
|
})
|
|
assert profile["profile_id"]
|
|
assert profile["timezone"]["utc_offset"] == "+08:00"
|
|
assert profile["input_hash"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# structured errors
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.parametrize(
|
|
"payload, fragment",
|
|
[
|
|
({"birth": {"utc_offset": "abc"}}, "UTC offset"),
|
|
({"birth": {"date": "garbage"}}, "invalid date"),
|
|
({"birth": {"date": "1990-13-01"}}, "out of range"),
|
|
({"birth": {"time": "25:00"}}, "invalid time"),
|
|
({"birth": {"time": "12:00:99"}}, "invalid time"),
|
|
({"birth": {"time": "not-a-time"}}, "invalid time"),
|
|
({"year": 1990, "month": 1}, "incomplete birth date"),
|
|
({"lat": float("nan")}, "must be finite"),
|
|
({"birth": {"tz": "bogus"}}, "UTC offset"),
|
|
],
|
|
)
|
|
def test_unparseable_inputs_raise_structured_error(payload: dict, fragment: str) -> None:
|
|
with pytest.raises(CalculationProfileError, match=fragment):
|
|
build_calculation_profile(payload)
|
|
|
|
|
|
def test_unparseable_inputs_are_subclass_of_value_error() -> None:
|
|
assert issubclass(CalculationProfileError, ValueError)
|
|
|
|
|
|
def test_annual_style_payload_without_settings_uses_defaults() -> None:
|
|
profile = build_calculation_profile({
|
|
"birth": {
|
|
"date": "1996-12-07",
|
|
"time": "10:34:00",
|
|
"utc_offset": "+05:30",
|
|
"latitude": 13.0878,
|
|
"longitude": 80.2785,
|
|
},
|
|
"location": {"place": "Chennai", "latitude": 13.0878, "longitude": 80.2785},
|
|
"ayanamsa": "lahiri",
|
|
"node_mode": "mean",
|
|
"house_system": "whole_sign",
|
|
})
|
|
|
|
assert profile["ayanamsa"] == "lahiri"
|
|
assert profile["node_mode"] == "mean"
|
|
assert profile["position_mode"] == "legacy"
|
|
assert profile["timezone"]["name"] == "UTC+05:30"
|
|
assert profile["profile_id"]
|
|
# place/coordinates are hashed but never stored
|
|
_assert_no_birth_fields(profile)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# dasha-master-pack gate contract
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.skip(reason="professional_parity_closure is not vendored into the product tree")
|
|
def test_dasha_master_profile_gate_contract_keeps_working() -> None:
|
|
from scripts.professional_parity_closure import build_dasha_master_profile_gate
|
|
|
|
shared_profile = build_calculation_profile(BIRTH_PAYLOAD)
|
|
gate = build_dasha_master_profile_gate(
|
|
{"vimshottari": {"execution_status": "executed", "confidence_status": "verified"}},
|
|
{**shared_profile, "pl9_profile_status": "parameter_sensitive"},
|
|
{"vimshottari": {"status": "blocked"}},
|
|
)
|
|
|
|
assert gate["missing_profile_fields"] == []
|
|
assert gate["profile"]["profile_id"] == shared_profile["profile_id"]
|
|
assert gate["profile"]["input_hash"] == shared_profile["input_hash"]
|
|
assert gate["profile"]["ayanamsa"] == "lahiri"
|
|
assert gate["profile"]["node_mode"] == "mean"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cross-producer consistency (domain service vs CLI)
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.skip(reason="product cmd_chart and domain_calculation_service do not attach calculation_profile")
|
|
def test_domain_service_and_cli_producers_share_profile_hash() -> None:
|
|
import domain_calculation_service as calculation_service
|
|
import jyotish_engine
|
|
|
|
birth = {
|
|
"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "second": 0,
|
|
"lat": 39.9, "lon": 116.4, "tz": 8.0, "ayanamsa": "lahiri",
|
|
"node_mode": "mean", "position_mode": "legacy",
|
|
}
|
|
domain = calculation_service.compute_chart(birth)
|
|
cli = jyotish_engine.cmd_chart(_args())
|
|
|
|
assert cli["calculation_profile"]["profile_hash"] == domain["calculation_profile"]["profile_hash"]
|
|
assert cli["calculation_profile_id"] == domain["calculation_profile"]["profile_hash"]
|
|
assert cli["calculation_profile"]["input_hash"] == domain["calculation_profile"]["input_hash"]
|
|
|
|
# a fresh attach with the observed meta bound to the same args agrees too
|
|
fresh = attach_calculation_profile({"meta": domain["meta"]}, _args())
|
|
assert fresh["calculation_profile"]["profile_hash"] == domain["calculation_profile"]["profile_hash"]
|
|
|
|
|
|
@pytest.mark.skip(reason="product domain_calculation_service.compute_chart does not attach calculation_profile")
|
|
def test_domain_service_profile_is_privacy_compliant() -> None:
|
|
import domain_calculation_service as calculation_service
|
|
|
|
profile = calculation_service.compute_chart({
|
|
"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "second": 0,
|
|
"lat": 39.9, "lon": 116.4, "tz": 8.0, "ayanamsa": "lahiri",
|
|
"node_mode": "mean", "position_mode": "legacy",
|
|
})["calculation_profile"]
|
|
|
|
_assert_no_birth_fields(profile)
|
|
assert profile["profile_version"] == "1.0"
|
|
assert profile["effective_settings"]["ayanamsa"] == "lahiri"
|
|
assert profile["engine"]["ephemeris_flags_verified"] is True
|
|
assert isinstance(profile["engine"]["ephemeris_flags"], int)
|
|
assert "ephemeris_path" not in profile["engine"]
|
|
|
|
|
|
def test_annual_tajika_pack_profile_stays_privacy_compliant(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The annual pack reads birth/location from its input payload, never from
|
|
the profile (AGENTS 6.6); the embedded profile stays birth-free.
|
|
|
|
``report_pack_contract`` is an unrelated missing module on this branch; a
|
|
minimal test-only shim isolates this test from that gap.
|
|
"""
|
|
import sys
|
|
import types
|
|
|
|
shim = types.ModuleType("report_pack_contract")
|
|
shim.normalize_report_pack_contract = lambda pack, pack_id="x": {
|
|
"schema": "unified_report_pack_contract.v1",
|
|
"pack_id": pack_id,
|
|
}
|
|
# scoped to this test only; monkeypatch restores sys.modules afterwards
|
|
monkeypatch.setitem(sys.modules, "report_pack_contract", shim)
|
|
|
|
from scripts.annual_tajika_pack import build_annual_tajika_pack
|
|
|
|
pack = build_annual_tajika_pack({
|
|
"birth": {
|
|
"date": "1990-01-01",
|
|
"time": "12:00:00",
|
|
"utc_offset": "+08:00",
|
|
"latitude": 39.9,
|
|
"longitude": 116.4,
|
|
"place": "Beijing",
|
|
},
|
|
"settings": {"ayanamsa": "lahiri", "node_mode": "mean"},
|
|
"target_year": 2026,
|
|
})
|
|
assert pack["schema"] == "jyotish.annual_tajika_pack.v1"
|
|
_assert_no_birth_fields(pack["profile"])
|
|
assert pack["profile"]["profile_id"]
|
|
|
|
|
|
def test_direct_script_and_package_import_are_functionally_equivalent() -> None:
|
|
"""Both import modes must work and produce identical profiles.
|
|
|
|
Python loads the two spellings as distinct module objects, so the contract
|
|
(per ERR-114) is functional equivalence, not object identity.
|
|
"""
|
|
module = importlib.import_module("calculation_profile_contract")
|
|
assert hasattr(module, "build_calculation_profile")
|
|
assert hasattr(module, "attach_calculation_profile")
|
|
assert module.build_calculation_profile(BIRTH_PAYLOAD) == build_calculation_profile(BIRTH_PAYLOAD)
|