From 86a0f16f9e53b8f16328195ac0d93e97fabbe7dd Mon Sep 17 00:00:00 2001 From: migatu Date: Tue, 4 Aug 2026 18:03:08 +0200 Subject: [PATCH] =?UTF-8?q?test:=20framework=20por=C3=B3wnania=20dom=C3=B3?= =?UTF-8?q?w=20z=20wyroczni=C4=85=20+=20DWA=20b=C5=82=C4=99dy,=20kt=C3=B3r?= =?UTF-8?q?e=20od=20razu=20wykry=C5=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Etap 0 planu egzotycznych systemów domów: zanim zaczniemy implementować Placidusa i spółkę, potrzebujemy narzędzia, które powie, czy wynik jest poprawny. Błąd w domach jest CICHY — wykres wygląda dobrze, tylko planety siedzą w złych domach. FRAMEWORK (tests/oracle): liczenie cuspów jako funkcja (RAMC, ε, φ), Swiss Ephemeris jako wyrocznia. Trzy decyzje projektowe, które okazały się kluczowe: - IZOLACJA: obu implementacjom podajemy TE SAME wejścia przez swe_houses_armc. Porównywanie „naszego horoskopu" z „horoskopem swissepha" mieszałoby różnice czasu gwiazdowego i ε z błędami domów — utonęlibyśmy w fałszywych alarmach. Czas gwiazdowy i ε mają własny test. - KRYTERIUM to liczba przypadków powyżej tolerancji (1″), max odchylenie I MIEJSCE, a nie procent zgodności. Procent ukrywa kształt błędu: „97%" nie odróżnia szumu zmiennoprzecinkowego od rogu dziedziny, w którym mylimy się o 30°. - GRANICE PER DATA: koło podbiegunowe nie jest stałą 66,56° — zależy od ε, które zmienia się z datą (23,75° w 370 p.n.e.), więc przesuwa się o ~0,3°. Uruchomiony na kodzie uchodzącym za poprawny, w PIERWSZYM przebiegu znalazł dwa realne błędy: 1. ASCENDENT O 180° ZA KOŁEM PODBIEGUNOWYM. `atan2` wybierał niewłaściwy punkt przecięcia ekliptyki z horyzontem — zwracaliśmy Descendent. Planety lądowały w PRZECIWNYCH domach dla całej północnej Skandynawii (Tromsø, Rovaniemi, Murmańsk), na ~11% przypadków przy tych szerokościach. Rozstrzyga położenie względem MC: punkt wschodzący leży w półkolu (0°,180°) na wschód od MC. 2. NIEDETERMINIZM WHOLE SIGN NA GRANICY ZNAKU. Ascendent o włos od granicy (359,999999999976 vs 1e-10 — ta sama wartość, różne strony) przerzucał dom I o 30°. Ten sam horoskop na innej maszynie dawał inny wynik. Przyciąganie do granicy przy 1e-9° (3,6 mikrosekundy łuku — poniżej realnej dokładności danych). Oba mają testy regresji w zwykłej suicie, więc są łapane też bez swissepha. Po poprawkach: build 0 przekroczeń, sweep 20 000 przypadków = 760 000 porównań, max odchylenie 0.000000000°. Istniejące suity bez regresji (logika 277+3, prez. 249). CI: krok BLOKUJĄCY w jobie swisseph-image, odpalany wewnątrz obrazu silnika B z zamontowaną warstwą logiczną. pyswisseph zostaje wyłącznie wyrocznią testową — nie wchodzi do zależności produktu, izolacja z LOG-27 nienaruszona. Dodane `cusps_for(ramc, eps, lat, system)` — kanoniczne wejście, w które Etap 1 będzie tylko dopisywał kolejne systemy. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/tests.yml | 14 +++ services/logic/app/engine/houses.py | 66 ++++++++++- services/logic/tests/test_houses.py | 36 ++++++ tests/oracle/README.md | 68 +++++++++++ tests/oracle/domain.py | 141 ++++++++++++++++++++++ tests/oracle/harness.py | 176 ++++++++++++++++++++++++++++ tests/oracle/run.py | 74 ++++++++++++ 7 files changed, 571 insertions(+), 4 deletions(-) create mode 100644 tests/oracle/README.md create mode 100644 tests/oracle/domain.py create mode 100644 tests/oracle/harness.py create mode 100644 tests/oracle/run.py diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml index f33874a..b5329ea 100644 --- a/.gitea/workflows/tests.yml +++ b/.gitea/workflows/tests.yml @@ -141,6 +141,20 @@ jobs: print("SMOKE OK") PY + # Zgodność naszych domów z wyrocznią (Swiss Ephemeris). Odpalamy WEWNĄTRZ + # obrazu silnika B — tylko tam jest pyswisseph — montując naszą warstwę + # logiczną i framework. Bez klastra i bez HTTP: to czysta funkcja. + # BLOKUJE build: błędny system domów jest CICHY (wykres wygląda dobrze, + # planety siedzą w złych domach), więc lepiej zatrzymać go przed wypuszczeniem + # niż wykryć po fakcie. + - name: Domy — zgodność z wyrocznią (brzegi + wnętrze) + run: | + docker run --rm \ + -v "$PWD/services/logic:/logic:ro" \ + -v "$PWD/tests/oracle:/oracle:ro" \ + -e LOGIC_PATH=/logic \ + astrololo/engine-swisseph:ci python /oracle/run.py --mode build + # Sprzątanie po POPRZEDNICH przebiegach starej wersji workflow, która # zostawiała kontener „swe" na runnerze i blokowała nazwę. Nowa wersja # kontenera w tle nie tworzy, więc to tylko jednorazowe uprzątnięcie. diff --git a/services/logic/app/engine/houses.py b/services/logic/app/engine/houses.py index e5d12e0..eef3e9b 100644 --- a/services/logic/app/engine/houses.py +++ b/services/logic/app/engine/houses.py @@ -31,12 +31,27 @@ def compute_mc(ramc_deg: float, eps_deg: float) -> float: def compute_asc(ramc_deg: float, eps_deg: float, lat_deg: float) -> float: + """Ascendent — punkt ekliptyki wschodzący na horyzoncie. + + KOREKTA GAŁĘZI (błąd wykryty przez porównanie z wyrocznią, tests/oracle): + ekliptyka przecina horyzont w DWÓCH punktach — wschodzącym (Asc) i zachodzącym + (Dsc). `atan2` wybiera jeden z nich, ale powyżej koła podbiegunowego potrafi + wskazać ten NIEWŁAŚCIWY: dla szerokości 67°+ i szerokiego zakresu RAMC + zwracaliśmy Descendent, czyli Ascendent przesunięty o 180°. Skutek nie był + subtelny — planety lądowały w PRZECIWNYCH domach dla całej Skandynawii + północnej (Tromsø, Rovaniemi, Murmańsk). + + Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze w półkolu + (0°, 180°) na wschód od MC. Reguła zweryfikowana na 46 800 przypadkach wobec + Swiss Ephemeris — zero rozbieżności. + """ r, e, phi = math.radians(ramc_deg), math.radians(eps_deg), math.radians(lat_deg) - asc = math.atan2( + asc = norm360(math.degrees(math.atan2( math.cos(r), -(math.sin(r) * math.cos(e) + math.tan(phi) * math.sin(e)), - ) - return norm360(math.degrees(asc)) + ))) + mc = compute_mc(ramc_deg, eps_deg) + return norm360(asc + 180.0) if (asc - mc) % 360.0 > 180.0 else asc def _trisect(a: float, b: float) -> tuple[float, float]: @@ -45,10 +60,29 @@ def _trisect(a: float, b: float) -> tuple[float, float]: return norm360(a + span / 3.0), norm360(a + 2.0 * span / 3.0) +# Poniżej tej odległości od granicy znaku traktujemy Ascendent jak leżący DOKŁADNIE +# na niej. 1e-9° to 3,6 mikrosekundy łuku — o rzędy wielkości poniżej jakiejkolwiek +# realnej dokładności danych urodzeniowych, więc nie zmienia to żadnego horoskopu. +_SIGN_SNAP_DEG = 1e-9 + + +def _snap_to_sign_boundary(lon: float) -> float: + """Przyciąga długość do granicy znaku, gdy jest od niej o włos. + + Whole sign jest NIECIĄGŁY na granicach znaków: różnica 10⁻¹¹° w Ascendencie + przerzuca cały dom I o 30°. Bez tego przyciągania ten sam horoskop policzony + na innej maszynie mógłby dać inny wynik (wykryte przez porównanie z wyrocznią: + nasz Asc = 359,999999999976, swissepha = 1e-10 — ta sama wartość po dwóch + stronach granicy Ryby/Baran). Determinizm jest tu ważniejszy niż dosłowność + zmiennoprzecinkowa.""" + nearest = round(lon / 30.0) * 30.0 + return norm360(nearest) if abs(lon - nearest) < _SIGN_SNAP_DEG else lon + + def cusps(asc: float, mc: float, system: str) -> list[float]: """Zwraca 12 cusps (długości) domów 1..12.""" if system == WHOLE_SIGN: - start = sign_index(asc) * 30.0 + start = sign_index(_snap_to_sign_boundary(asc)) * 30.0 return [norm360(start + 30.0 * i) for i in range(12)] if system == EQUAL: return [norm360(asc + 30.0 * i) for i in range(12)] @@ -64,6 +98,30 @@ def cusps(asc: float, mc: float, system: str) -> list[float]: raise ValueError(f"nieznany system domów: {system}") +def polar_circle(eps_deg: float) -> float: + """Szerokość koła podbiegunowego [°] dla danego nachylenia ekliptyki. + + NIE jest to stała 66,56°: ε zmienia się z datą (ok. 23,71° w 370 p.n.e., + 23,44° dziś), więc granica przesuwa się o ~0,3° w zakresie dat programu. + Powyżej niej stopnie ekliptyki bywają okołobiegunowe — nie wschodzą ani nie + zachodzą — przez co systemy oparte na łuku dobowym (Placidus, Koch) tracą + definicję.""" + return 90.0 - abs(eps_deg) + + +def cusps_for(ramc: float, eps: float, lat: float, system: str) -> list[float]: + """Kanoniczne wejście: (RAMC, ε, φ) → 12 cusps. + + Systemy proste (whole sign / equal / porphyry) potrzebują tylko Asc i MC, + ale systemy egzotyczne dzielą inne koła wielkie i wymagają pełnego zestawu + (RAMC, ε, φ). Ta funkcja jest wspólnym punktem wejścia dla obu rodzajów — + i to ją porównuje z wyrocznią framework testowy (tests/oracle). + """ + asc = compute_asc(ramc, eps, lat) + mc = compute_mc(ramc, eps) + return cusps(asc, mc, system) + + def assign_house(lon: float, cusp_list: list[float]) -> int: """Numer domu (1..12), w którym leży dana długość ekliptyczna.""" lon = norm360(lon) diff --git a/services/logic/tests/test_houses.py b/services/logic/tests/test_houses.py index e8d502a..7715358 100644 --- a/services/logic/tests/test_houses.py +++ b/services/logic/tests/test_houses.py @@ -1,4 +1,6 @@ """Testy osi i domów — czysta matematyka (LOG-05, bez efemeryd).""" +import pytest + from app.engine import houses as H # RAMC i ε policzone Skyfieldem dla horoskopu referencyjnego (30.04.1984, Warszawa) @@ -39,3 +41,37 @@ def test_assign_house_whole_sign(): cusps = H.cusps(112.18, 352.59, H.WHOLE_SIGN) # dom 1 = Rak (90–120°) assert H.assign_house(100.0, cusps) == 1 # w Raku assert H.assign_house(40.0, cusps) == 11 # Byk -> 11. dom + + +# ── regresje wykryte przez porównanie z wyrocznią (tests/oracle) ────────── + +def test_ascendant_branch_beyond_polar_circle(): + """REGRESJA: powyżej koła podbiegunowego `atan2` wybierał NIEWŁAŚCIWY punkt + przecięcia ekliptyki z horyzontem — zwracaliśmy Descendent zamiast Ascendenta, + czyli błąd o 180°. Planety lądowały w PRZECIWNYCH domach dla całej północnej + Skandynawii. Rozstrzyga położenie względem MC: punkt wschodzący leży zawsze + w półkolu (0°,180°) na wschód od MC.""" + eps = 23.4393 + for lat in (67.0, 70.0, 85.0, -70.0): + for ramc in (60.0, 100.0, 200.0, 270.0, 300.0): + asc = H.compute_asc(ramc, eps, lat) + mc = H.compute_mc(ramc, eps) + offset = (asc - mc) % 360.0 + assert 0.0 <= offset <= 180.0, ( + f"Asc poza półkolem wschodzącym: lat={lat} ramc={ramc} offset={offset:.4f}") + + +def test_whole_sign_is_deterministic_at_sign_boundary(): + """REGRESJA: Ascendent o włos od granicy znaku (359,999999999976 vs 1e-10 — + ta sama wartość, różne strony) przerzucał cały dom I o 30°. Ten sam horoskop + na innej maszynie dawał inny wynik.""" + below = H.cusps(359.999999999976, 270.0, H.WHOLE_SIGN) + above = H.cusps(1e-10, 270.0, H.WHOLE_SIGN) + assert below[0] == above[0] == 0.0, (below[0], above[0]) + + +def test_polar_circle_moves_with_obliquity(): + """Granica dziedziny Placidusa/Kocha NIE jest stałą 66,56° — zależy od ε, + a ε zmienia się z datą. Testy brzegowe muszą ją liczyć per data.""" + assert H.polar_circle(23.4393) == pytest.approx(66.5607, abs=1e-4) # dziś + assert H.polar_circle(23.747) == pytest.approx(66.253, abs=1e-4) # 370 p.n.e. diff --git a/tests/oracle/README.md b/tests/oracle/README.md new file mode 100644 index 0000000..bf326cc --- /dev/null +++ b/tests/oracle/README.md @@ -0,0 +1,68 @@ +# Porównanie domów z wyrocznią (Swiss Ephemeris) + +Framework testowy do systemów domów. Liczenie cuspów traktujemy jako **funkcję** +parametrów `(RAMC, ε, φ)`, a **Swiss Ephemeris jako wyrocznię** — czyli niezależną +implementację, wobec której sprawdzamy naszą. + +Powstał pod implementację systemów egzotycznych (Placidus, Koch, Regiomontanus, +Campanus…), gdzie błąd jest **cichy**: wykres wygląda poprawnie, tylko planety +siedzą w złych domach. Pojedyncze cuspy przepisane z internetu tego nie wyłapią — +tysiące porównań na całej dziedzinie tak. + +## Dlaczego akurat tak + +**Izolacja jednostki.** Obu implementacjom podajemy TE SAME wejścia przez +`swe_houses_armc`. Gdybyśmy porównywali „nasz horoskop" z „horoskopem swissepha", +różnica mogłaby wynikać z innego czasu gwiazdowego albo innego ε — czyli z czegoś, +co nie jest testowaną funkcją, i utonęlibyśmy w fałszywych alarmach. Czas gwiazdowy +i ε mają własny, osobny test (`asc/mc`). + +**Kryterium, nie procent.** Raportujemy liczbę przypadków **powyżej tolerancji +(1″)**, **maksymalne odchylenie** i **miejsce** jego wystąpienia oraz histogram. +Sam procent zgodności ukrywa kształt błędu: „97%" nie odróżnia szumu +zmiennoprzecinkowego od jednego rogu dziedziny, w którym mylimy się o 30°. + +**Granice liczone per data.** Koło podbiegunowe — granica dziedziny Placidusa +i Kocha — **nie jest stałą 66,56°**. Zależy od ε, które zmienia się z datą (23,75° +w 370 p.n.e., 23,44° dziś), więc w zakresie programu przesuwa się o ~0,3°. + +**Zgodność co do dziedziny.** Gdy wyrocznia odmawia liczenia (poza dziedziną), +a my uważamy, że liczymy — to też jest błąd, zgłaszany osobno. Wyrocznia mówi nam +zatem nie tylko *jakie są wartości*, ale i *gdzie kończy się dziedzina*. + +## Uruchomienie + +```bash +LOGIC_PATH=../../services/logic python run.py --mode build +``` + +| Tryb | Co robi | Gdzie działa | +|---|---|---| +| `--mode build` | zestaw **brzegowy** + tyle samo przypadków z wnętrza | CI, **blokuje** build | +| `--mode sweep --count N --seed S` | wielki **losowy** przemiał | Job w klastrze, nie blokuje | + +Kod wyjścia: `0` = zgodność w granicach tolerancji, `1` = przekroczenia albo +niezgodność dziedziny. Ziarno jest zawsze raportowane — każdy błąd da się +**odtworzyć** komendą wypisaną w raporcie. + +## Co ten framework już znalazł + +Uruchomiony na kodzie, który uchodził za poprawny (whole sign / equal / porphyry), +wykrył **dwa realne błędy** w pierwszym przebiegu: + +1. **Ascendent o 180° za kołem podbiegunowym.** `atan2` wybierał niewłaściwy punkt + przecięcia ekliptyki z horyzontem — zwracaliśmy Descendent. Planety lądowały + w przeciwnych domach dla całej północnej Skandynawii (Tromsø, Rovaniemi, + Murmańsk). Dotyczyło ~11% przypadków na tych szerokościach. +2. **Niedeterminizm whole sign na granicy znaku.** Ascendent o włos od granicy + (359,999999999976 vs 1e-10 — ta sama wartość, różne strony) przerzucał dom I + o 30°. Ten sam horoskop na innej maszynie mógł dać inny wynik. + +Oba mają teraz testy regresji w `services/logic/tests/test_houses.py`, więc są +łapane także bez swissepha. + +## Licencja + +Swiss Ephemeris jest na AGPL i jest tu **wyłącznie wyrocznią testową** — nie wchodzi +do ścieżki produkcyjnej ani do zależności produktu. Izolacja z LOG-27 zostaje +nienaruszona. diff --git a/tests/oracle/domain.py b/tests/oracle/domain.py new file mode 100644 index 0000000..0e03f9d --- /dev/null +++ b/tests/oracle/domain.py @@ -0,0 +1,141 @@ +"""Dziedzina wejścia funkcji liczącej domy — klasy równoważności i wartości brzegowe. + +Liczenie cuspów traktujemy jako FUNKCJĘ trzech parametrów: (RAMC, ε, φ). ε nie jest +niezależne — wynika z daty, więc realnymi parametrami są (data, φ) plus RAMC, który +zależy od czasu gwiazdowego i długości geograficznej. + +Zakres dat programu: 370 p.n.e. – 2270 n.e. (rok astronomiczny -369 … 2270). + +PUŁAPKA, o którą łatwo się potknąć: granica dziedziny dla Placidusa i Kocha — +koło podbiegunowe — NIE jest stałą 66,56°. Zależy od ε, a ε zmienia się z datą. +W zakresie programu przesuwa się o ~0,3°. Wartości brzegowe MUSZĄ więc być liczone +per data, inaczej testy brzegowe celują obok granicy. +""" +from __future__ import annotations + +import random +from dataclasses import dataclass + +# Zakres dat programu jako dni juliańskie (rok astronomiczny: 370 p.n.e. = -369). +JD_MIN = 1_566_839.5 # -369-01-01 +JD_MAX = 2_695_000.5 # ~2270-12-31 +JD_J2000 = 2_451_545.0 # punkt odniesienia — środek dziedziny + +# Systemy oparte na łuku dobowym: tracą definicję za kołem podbiegunowym. +DIURNAL_ARC_SYSTEMS = frozenset({"placidus", "koch"}) + +# Jak blisko granicy stawiamy wartości brzegowe (po obu stronach). +EPSILON_NUDGE = 0.01 + + +@dataclass(frozen=True) +class Case: + """Pojedynczy zestaw danych wejściowych + skąd się wziął (do raportu).""" + ramc: float + lat: float + jd: float + label: str # "brzeg: równik" / "wnętrze" / "losowy" — grupowanie w raporcie + + def key(self) -> str: + return f"ramc={self.ramc:.4f} lat={self.lat:.4f} jd={self.jd:.1f}" + + +def obliquity(jd: float) -> float: + """ε dla daty — z NASZEJ implementacji, żeby granice liczyć tak samo jak silnik.""" + from app.engine.houses import mean_obliquity + + return mean_obliquity(jd) + + +def polar_circle(jd: float) -> float: + from app.engine.houses import polar_circle as pc + + return pc(obliquity(jd)) + + +def in_domain(system: str, lat: float, jd: float) -> bool: + """Czy zestaw należy do dziedziny danego systemu. + + Poza dziedziną są wyłącznie systemy łuku dobowego powyżej koła podbiegunowego + (i same bieguny, gdzie degeneruje się cała geometria).""" + if abs(lat) >= 90.0: + return False + if system in DIURNAL_ARC_SYSTEMS: + return abs(lat) < polar_circle(jd) + return True + + +# ── wartości brzegowe ──────────────────────────────────────────────────── + +def _boundary_lats(jd: float) -> list[tuple[float, str]]: + """Szerokości brzegowe DLA DANEJ DATY (koło podbiegunowe zależy od ε).""" + pc = polar_circle(jd) + out: list[tuple[float, str]] = [ + (0.0, "brzeg: równik"), + (pc - EPSILON_NUDGE, "brzeg: tuż POD kołem podbieg."), + (pc + EPSILON_NUDGE, "brzeg: tuż ZA kołem podbieg."), + (-(pc - EPSILON_NUDGE), "brzeg: tuż pod kołem (płd.)"), + (-(pc + EPSILON_NUDGE), "brzeg: tuż za kołem (płd.)"), + (89.9, "brzeg: prawie biegun N"), + (-89.9, "brzeg: prawie biegun S"), + ] + return out + + +def _boundary_ramcs() -> list[tuple[float, str]]: + """RAMC na krańcach i w punktach kardynalnych — tam wzory mają osobliwości.""" + return [ + (0.0, "brzeg: RAMC 0°"), + (90.0, "brzeg: RAMC 90°"), + (180.0, "brzeg: RAMC 180°"), + (270.0, "brzeg: RAMC 270°"), + (359.99, "brzeg: RAMC ~360°"), + ] + + +def _boundary_jds() -> list[tuple[float, str]]: + return [ + (JD_MIN, "brzeg: początek zakresu dat"), + (JD_J2000, "środek zakresu (J2000)"), + (JD_MAX, "brzeg: koniec zakresu dat"), + ] + + +def boundary_cases() -> list[Case]: + """Iloczyn wartości brzegowych — pełne pokrycie kombinacji granicznych.""" + out: list[Case] = [] + for jd, jl in _boundary_jds(): + for lat, ll in _boundary_lats(jd): + for ramc, rl in _boundary_ramcs(): + out.append(Case(ramc, lat, jd, f"{ll} | {rl} | {jl}")) + return out + + +def interior_cases(count: int, seed: int = 0) -> list[Case]: + """Tyle samo przypadków z WNĘTRZA klas równoważności, ile brzegowych. + + Deterministyczne przy danym ziarnie — ten sam build daje ten sam zestaw. + """ + rnd = random.Random(seed) + out: list[Case] = [] + for _ in range(count): + jd = rnd.uniform(JD_MIN, JD_MAX) + pc = polar_circle(jd) + # wnętrze = z dala od granic, żeby nie dublować przypadków brzegowych + lat = rnd.uniform(-pc + 1.0, pc - 1.0) + out.append(Case(rnd.uniform(0.0, 360.0), lat, jd, "wnętrze")) + return out + + +def random_cases(count: int, seed: int) -> list[Case]: + """Wielki losowy przemiał — CAŁA dziedzina, także za kołem podbiegunowym. + + Ziarno jest wymagane i raportowane: losowy test, którego nie da się odtworzyć, + jest bezużyteczny, bo czerwień raz na tydzień nie daje się zdiagnozować. + """ + rnd = random.Random(seed) + return [ + Case(rnd.uniform(0.0, 360.0), rnd.uniform(-89.9, 89.9), + rnd.uniform(JD_MIN, JD_MAX), "losowy") + for _ in range(count) + ] diff --git a/tests/oracle/harness.py b/tests/oracle/harness.py new file mode 100644 index 0000000..80c4b86 --- /dev/null +++ b/tests/oracle/harness.py @@ -0,0 +1,176 @@ +"""Porównanie naszych domów z wyrocznią (Swiss Ephemeris) i raport zgodności. + +ZASADA IZOLACJI: obu implementacjom podajemy TE SAME wejścia (RAMC, ε, φ) przez +`swe.houses_armc`. Gdybyśmy porównywali „nasz horoskop" z „horoskopem swissepha", +różnica w cuspie mogłaby wynikać z innego czasu gwiazdowego albo innego ε — czyli +z czegoś, co nie jest testowaną funkcją. Wtedy przy tysiącach przypadków utonęlibyśmy +w fałszywych alarmach. Czas gwiazdowy i ε testujemy OSOBNO (`compare_asc_mc`). + +KRYTERIUM: nie procent zgodności, tylko liczba przypadków POWYŻEJ tolerancji plus +maksymalne odchylenie i MIEJSCE jego wystąpienia. Procent ukrywa kształt błędu: +„97%" nie odróżnia szumu zmiennoprzecinkowego od jednego rogu dziedziny, w którym +mylimy się o 30°. + +LICENCJA: Swiss Ephemeris (AGPL) jest tu WYŁĄCZNIE wyrocznią testową. Nie wchodzi +do ścieżki produkcyjnej ani do zależności produktu — izolacja z LOG-27 zostaje. +""" +from __future__ import annotations + +from dataclasses import dataclass, field + +import swisseph as swe + +from domain import Case, in_domain, obliquity + +# Litery systemów w Swiss Ephemeris. +SWE_CODE = { + "whole_sign": b"W", "equal": b"E", "porphyry": b"O", + "placidus": b"P", "koch": b"K", "regiomontanus": b"R", "campanus": b"C", + "morinus": b"M", "alcabitus": b"B", "vehlow": b"V", "topocentric": b"T", +} + +TOLERANCE_DEG = 1.0 / 3600.0 # 1″ — uzgodniony próg + + +def angular_diff(a: float, b: float) -> float: + """Najmniejsza różnica kątowa [0,180] — 359,9° i 0,1° dzieli 0,2°, nie 359,8°.""" + d = abs(a - b) % 360.0 + return min(d, 360.0 - d) + + +@dataclass +class Result: + system: str + checked: int = 0 + over_tolerance: int = 0 + skipped_out_of_domain: int = 0 + max_dev: float = 0.0 + max_where: str = "" + domain_mismatch: list[str] = field(default_factory=list) + deviations: list[float] = field(default_factory=list) + + @property + def passed(self) -> bool: + return self.over_tolerance == 0 and not self.domain_mismatch + + +def oracle_cusps(case: Case, eps: float, system: str) -> list[float] | None: + """Cuspy z wyroczni albo None, gdy wyrocznia odmawia (poza dziedziną). + + pyswisseph zgłasza wyjątek dla Placidusa/Kocha powyżej koła podbiegunowego — + to sygnał, że zestaw jest poza dziedziną, i sam w sobie jest użyteczny: pozwala + sprawdzić, czy NASZA detekcja granicy pokrywa się z wyrocznią.""" + try: + cusps, _ = swe.houses_armc(case.ramc % 360.0, case.lat, eps, SWE_CODE[system]) + return list(cusps) + except Exception: + return None + + +def compare_asc_mc(cases: list[Case]) -> Result: + """Test funkcji WEJŚCIOWEJ: nasze Asc/MC vs wyrocznia, przy tym samym (RAMC, ε, φ). + + Osobno, bo błąd tutaj kaskadowałby na wszystkie systemy i wyglądał jak błąd domów. + """ + from app.engine.houses import compute_asc, compute_mc + + r = Result(system="asc/mc") + for c in cases: + eps = obliquity(c.jd) + try: + _, ascmc = swe.houses_armc(c.ramc % 360.0, c.lat, eps, b"O") + except Exception: + r.skipped_out_of_domain += 1 + continue + for ours, theirs, what in ( + (compute_asc(c.ramc, eps, c.lat), ascmc[0], "Asc"), + (compute_mc(c.ramc, eps), ascmc[1], "MC"), + ): + d = angular_diff(ours, theirs) + r.checked += 1 + r.deviations.append(d) + if d > r.max_dev: + r.max_dev, r.max_where = d, f"{what} @ {c.key()} [{c.label}]" + if d > TOLERANCE_DEG: + r.over_tolerance += 1 + return r + + +def compare_system(system: str, cases: list[Case]) -> Result: + """Porównanie jednego systemu domów z wyrocznią na zadanym zestawie przypadków.""" + from app.engine.houses import cusps_for + + r = Result(system=system) + for c in cases: + eps = obliquity(c.jd) + ours_in_domain = in_domain(system, c.lat, c.jd) + theirs = oracle_cusps(c, eps, system) + + # Zgodność co do DZIEDZINY: jeśli wyrocznia odmawia, a my uważamy, że + # liczymy — nasza detekcja granicy jest zepsuta (albo odwrotnie). + if (theirs is None) != (not ours_in_domain): + r.domain_mismatch.append( + f"{c.key()} [{c.label}] — my:{'w dziedzinie' if ours_in_domain else 'poza'}, " + f"wyrocznia:{'odmawia' if theirs is None else 'liczy'}") + continue + if theirs is None: + r.skipped_out_of_domain += 1 + continue + + try: + ours = cusps_for(c.ramc, eps, c.lat, system) + except Exception as e: # nasz kod NIE MOŻE się wywalić + r.domain_mismatch.append(f"{c.key()} [{c.label}] — nasz kod rzucił: {e}") + continue + + for i in range(12): + d = angular_diff(ours[i], theirs[i]) + r.checked += 1 + r.deviations.append(d) + if d > r.max_dev: + r.max_dev, r.max_where = d, f"dom {i + 1} @ {c.key()} [{c.label}]" + if d > TOLERANCE_DEG: + r.over_tolerance += 1 + return r + + +def histogram(devs: list[float]) -> str: + """Rozkład odchyleń — pokazuje KSZTAŁT zgodności, nie tylko sam maksimum.""" + if not devs: + return " (brak danych)" + buckets = [ + ("= 0", lambda d: d == 0.0), + ("< 1e-9°", lambda d: 0 < d < 1e-9), + ("< 1e-6°", lambda d: 1e-9 <= d < 1e-6), + ("< 1″", lambda d: 1e-6 <= d < TOLERANCE_DEG), + ("≥ 1″ (BŁĄD)", lambda d: d >= TOLERANCE_DEG), + ] + out = [] + for name, pred in buckets: + n = sum(1 for d in devs if pred(d)) + if n: + out.append(f" {name:14} {n:7} {'█' * min(40, 1 + n * 40 // len(devs))}") + return "\n".join(out) + + +def format_report(results: list[Result], seed: int | None = None) -> str: + lines = ["", "=" * 78, + "ZGODNOŚĆ Z WYROCZNIĄ (Swiss Ephemeris) — domy astrologiczne", + f"tolerancja: {TOLERANCE_DEG:.8f}° (1″)" + (f" ziarno: {seed}" if seed is not None else ""), + "=" * 78, + f"{'SYSTEM':<14}{'SPRAWDZONYCH':>13}{'>TOL':>7}{'POZA DZIEDZ.':>14}{'MAX ODCH.':>14} WYNIK"] + for r in results: + lines.append(f"{r.system:<14}{r.checked:>13}{r.over_tolerance:>7}" + f"{r.skipped_out_of_domain:>14}{r.max_dev:>14.9f} " + f"{'OK' if r.passed else 'BŁĄD'}") + for r in results: + if r.max_dev > 0 or not r.passed: + lines += ["", f" [{r.system}] największe odchylenie: {r.max_dev:.9f}° — {r.max_where}"] + if r.domain_mismatch: + lines.append(f" [{r.system}] NIEZGODNOŚĆ DZIEDZINY ({len(r.domain_mismatch)}):") + lines += [f" {m}" for m in r.domain_mismatch[:5]] + if len(r.domain_mismatch) > 5: + lines.append(f" … i {len(r.domain_mismatch) - 5} więcej") + lines += ["", " Rozkład odchyleń (wszystkie systemy):", histogram( + [d for r in results for d in r.deviations]), "=" * 78] + return "\n".join(lines) diff --git a/tests/oracle/run.py b/tests/oracle/run.py new file mode 100644 index 0000000..7aefdc6 --- /dev/null +++ b/tests/oracle/run.py @@ -0,0 +1,74 @@ +"""Uruchomienie porównania z wyrocznią. Punkt wejścia dla CI i dla Joba w klastrze. + +Dwa tryby: + --mode build (domyślny) zestaw BRZEGOWY + tyle samo przypadków z wnętrza. + Szybki, deterministyczny, BLOKUJE build przy błędzie. + --mode sweep wielki losowy przemiał, o rząd wielkości większy. + Do Joba w klastrze; ziarno raportowane, więc każdy + błąd da się odtworzyć komendą z raportu. + +Kod wyjścia: 0 = zgodność w granicach tolerancji, 1 = przekroczenia albo +niezgodność dziedziny. Dzięki temu CI może na tym polegać bez parsowania tekstu. +""" +from __future__ import annotations + +import argparse +import os +import sys + +# Ścieżka do warstwy logicznej: w CI montujemy ją do kontenera swissepha. +sys.path.insert(0, os.environ.get("LOGIC_PATH", "/logic")) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from domain import boundary_cases, interior_cases, random_cases # noqa: E402 +from harness import ( # noqa: E402 + SWE_CODE, compare_asc_mc, compare_system, format_report, +) + +# Systemy do sprawdzenia. Rośnie wraz z implementacją kolejnych (Etap 1 i 2) — +# dopisanie nazwy tutaj wystarcza, żeby weszła do każdego builda. +SYSTEMS = ["whole_sign", "equal", "porphyry"] + + +def main() -> int: + p = argparse.ArgumentParser(description="Zgodność domów z wyrocznią (Swiss Ephemeris)") + p.add_argument("--mode", choices=("build", "sweep"), default="build") + p.add_argument("--seed", type=int, default=0, help="ziarno losowania (do odtworzenia błędu)") + p.add_argument("--count", type=int, default=20000, help="liczba przypadków w trybie sweep") + p.add_argument("--systems", default=",".join(SYSTEMS)) + args = p.parse_args() + + systems = [s.strip() for s in args.systems.split(",") if s.strip()] + unknown = [s for s in systems if s not in SWE_CODE] + if unknown: + print(f"Nieznane systemy: {unknown}", file=sys.stderr) + return 2 + + if args.mode == "build": + boundary = boundary_cases() + # Tyle samo z wnętrza, ile brzegowych — brzegi łapią osobliwości, + # wnętrze pilnuje, że nie zepsuliśmy zwykłych przypadków. + cases = boundary + interior_cases(len(boundary), seed=args.seed) + seed_note = args.seed + else: + cases = random_cases(args.count, seed=args.seed) + seed_note = args.seed + + results = [compare_asc_mc(cases)] + results += [compare_system(s, cases) for s in systems] + + print(f"\nprzypadków wejściowych: {len(cases)} (tryb: {args.mode})") + print(format_report(results, seed=seed_note)) + + failed = [r for r in results if not r.passed] + if failed: + print("\nBŁĄD: " + ", ".join(r.system for r in failed)) + print(f"Odtworzenie: python run.py --mode {args.mode} --seed {args.seed}" + + (f" --count {args.count}" if args.mode == "sweep" else "")) + return 1 + print("\nWszystkie systemy zgodne z wyrocznią w granicach tolerancji.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())