Evidence Packet
+仅显示已完成任务的可审计计算状态、证据包和技法审计。不会展示内部提示词或原始出生输入。
+ +运行状态
Technique Audit
-
Machine Evidence Packet
-
Warnings
-
diff --git a/docs/benchmark/public_real_case_benchmark_2026_07_11.json b/docs/benchmark/public_real_case_benchmark_2026_07_11.json new file mode 100644 index 00000000..d8d20f5f --- /dev/null +++ b/docs/benchmark/public_real_case_benchmark_2026_07_11.json @@ -0,0 +1,3067 @@ +{ + "benchmark_id": "public_real_case_benchmark_2026_07_11", + "cases": [ + { + "actual_label": "domain_activation", + "birth_time_rating": "AA", + "blocked": false, + "case_id": "jobs_iphone_2007", + "domain": "career", + "event_date": "2007-01-09", + "evidence": { + "arudha_lord": "Moon", + "ascendant": { + "degree": 29.0634, + "degree_in_sign": 29.0634, + "degree_in_sign_raw": 149.06338525851115, + "degree_raw": 149.0634, + "lon": 149.0634, + "lord": "Sun", + "sign": "Leo", + "sign_cn": "狮子座" + }, + "birth_source": { + "evidence_basis": "BC/BR in hand", + "source_grade": "primary", + "time_accuracy_rating": "AA", + "url": "https://www.astro.com/adbvip/adbvip_02_24.htm" + }, + "domain_varga": { + "birth_info": "1955-02-24 19:15", + "divisional_charts": { + "D10_Dasamsa": { + "Jupiter": { + "sign": "Pisces", + "sign_cn": "双鱼座" + }, + "Ketu": { + "sign": "Virgo", + "sign_cn": "处女座" + }, + "Mars": { + "sign": "Taurus", + "sign_cn": "金牛座" + }, + "Mercury": { + "sign": "Aries", + "sign_cn": "白羊座" + }, + "Moon": { + "sign": "Pisces", + "sign_cn": "双鱼座" + }, + "Rahu": { + "sign": "Pisces", + "sign_cn": "双鱼座" + }, + "Saturn": { + "sign": "Cancer", + "sign_cn": "巨蟹座" + }, + "Sun": { + "sign": "Gemini", + "sign_cn": "双子座" + }, + "Venus": { + "sign": "Virgo", + "sign_cn": "处女座" + }, + "ascendant": "Taurus" + } + } + }, + "double_transit_pac": { + "cl": { + "jupiter": { + "CL_Jupiter(宫主)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ] + }, + "saturn": { + "CL_Jupiter(宫主)": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ] + } + }, + "d1": { + "jupiter": {}, + "saturn": {} + }, + "d9": { + "jupiter": {}, + "saturn": {} + }, + "double_transit": [ + { + "jupiter_pac": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ], + "layer": "CL", + "saturn_pac": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ], + "strength": "strong", + "target": "CL_Jupiter(宫主)" + } + ], + "event_house": 10, + "stats": { + "chandra_lagna": "Pisces", + "cl_jupiter_targets": [ + "CL_Jupiter(宫主)" + ], + "cl_overlap": [ + "CL_Jupiter(宫主)" + ], + "cl_saturn_targets": [ + "CL_Jupiter(宫主)" + ], + "d1_jupiter_targets": [], + "d1_overlap": [], + "d1_saturn_targets": [], + "d9_ascendant": "Sagittarius", + "d9_jupiter_targets": [], + "d9_overlap": [], + "d9_saturn_targets": [], + "event_lord_d9_sign": "Sagittarius" + }, + "summary": "⚠️ Chandra Lagna 层 Double Transit 激活,D1/D9 未确认", + "transit_date": "2007-01-09" + }, + "event_source": { + "source_grade": "primary", + "url": "https://www.apple.com/newsroom/2007/01/09Apple-Reinvents-the-Phone-with-iPhone/" + }, + "functional_benefic_malefic": { + "ascendant": "Leo", + "effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。", + "functional_benefics": [ + "Jupiter", + "Mars", + "Sun" + ], + "functional_malefics": [ + "Mercury", + "Moon", + "Saturn", + "Venus" + ], + "functional_neutrals": [], + "owned_houses": { + "Jupiter": [ + 5, + 8 + ], + "Mars": [ + 4, + 9 + ], + "Mercury": [ + 2, + 11 + ], + "Moon": [ + 12 + ], + "Saturn": [ + 6, + 7 + ], + "Sun": [ + 1 + ], + "Venus": [ + 3, + 10 + ] + }, + "source": "strict_functional_benefic_malefic_v1", + "status": "used", + "yogakarakas": [ + "Mars" + ] + }, + "narayana": { + "ad": { + "end_age": 51.8831, + "lord": "Jupiter", + "sign": "Sagittarius", + "sign_idx": 8, + "start_age": 50.9481, + "years": 0.9351 + }, + "md": { + "end_age": 55.0, + "lord": "Mars", + "sign": "Aries", + "sign_idx": 0, + "start_age": 43.0, + "years": 12 + }, + "pd": { + "end_age": 51.8831, + "lord": "Mars", + "sign": "Scorpio", + "sign_idx": 7, + "start_age": 51.8225, + "years": 0.0606 + }, + "remaining_years": 3.12 + }, + "vimshottari": { + "antardasha": "Ketu", + "mahadasha": "Sun" + } + }, + "expected_label": "career_status", + "matched_expected_label": false, + "name": "Steve Jobs", + "outcome": "Apple publicly introduced the iPhone", + "result_class": "weak_hit", + "score": 5, + "signals": [ + "Sun_domain_karaka", + "Ketu_occupies_event_house:11", + "narayana_lord_owns_event_house:Mars", + "double_transit_pac_strong" + ] + }, + { + "actual_label": null, + "birth_time_rating": "AA", + "blocked": false, + "case_id": "obama_election_2008", + "domain": "career", + "event_date": "2008-11-04", + "evidence": { + "arudha_lord": "Saturn", + "ascendant": { + "degree": 24.724, + "degree_in_sign": 24.724, + "degree_in_sign_raw": 294.72398910355867, + "degree_raw": 294.724, + "lon": 294.724, + "lord": "Saturn", + "sign": "Capricorn", + "sign_cn": "摩羯座" + }, + "birth_source": { + "evidence_basis": "BC/BR in hand", + "source_grade": "primary", + "time_accuracy_rating": "AA", + "url": "https://www.astro.com/adbvip/adbvip_08_04.htm" + }, + "domain_varga": { + "birth_info": "1961-08-04 19:24", + "divisional_charts": { + "D10_Dasamsa": { + "Jupiter": { + "sign": "Scorpio", + "sign_cn": "天蝎座" + }, + "Ketu": { + "sign": "Pisces", + "sign_cn": "双鱼座" + }, + "Mars": { + "sign": "Taurus", + "sign_cn": "金牛座" + }, + "Mercury": { + "sign": "Gemini", + "sign_cn": "双子座" + }, + "Moon": { + "sign": "Aries", + "sign_cn": "白羊座" + }, + "Rahu": { + "sign": "Virgo", + "sign_cn": "处女座" + }, + "Saturn": { + "sign": "Virgo", + "sign_cn": "处女座" + }, + "Sun": { + "sign": "Virgo", + "sign_cn": "处女座" + }, + "Venus": { + "sign": "Leo", + "sign_cn": "狮子座" + }, + "ascendant": "Taurus" + } + } + }, + "double_transit_pac": { + "cl": { + "jupiter": { + "CL_Saturn(宫主)": [ + { + "desc": "合相(8.55°)", + "type": "Conjunction" + } + ] + }, + "saturn": {} + }, + "d1": { + "jupiter": { + "Moon(对宫主)": [ + { + "desc": "5宫相位", + "offset": 5, + "type": "Aspect" + } + ], + "Saturn(LL)": [ + { + "desc": "合相(8.55°)", + "type": "Conjunction" + } + ] + }, + "saturn": { + "Venus(宫主)": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ] + } + }, + "d9": { + "jupiter": { + "Venus_D9(Sagittarius)": [ + { + "desc": "同宫(5宫)", + "type": "Position" + }, + { + "desc": "合相(8.46°)", + "type": "Conjunction" + } + ] + }, + "saturn": { + "D9_10宫(Taurus)": [ + { + "desc": "同宫(1宫)", + "type": "Position" + } + ], + "D9_Venus(宫主)": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ] + } + }, + "double_transit": [ + { + "jupiter_pac": [ + { + "desc": "同宫(5宫)", + "type": "Position" + }, + { + "desc": "合相(8.46°)", + "type": "Conjunction" + } + ], + "layer": "D1+D9", + "saturn_pac": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ], + "strength": "moderate", + "target": "Saturn(D1)Venus(宫主) + Jupiter(D9)Venus_D9(Sagittarius)" + } + ], + "event_house": 10, + "stats": { + "chandra_lagna": "Taurus", + "cl_jupiter_targets": [ + "CL_Saturn(宫主)" + ], + "cl_overlap": [], + "cl_saturn_targets": [], + "d1_jupiter_targets": [ + "Moon(对宫主)", + "Saturn(LL)" + ], + "d1_overlap": [], + "d1_saturn_targets": [ + "Venus(宫主)" + ], + "d9_ascendant": "Leo", + "d9_jupiter_targets": [ + "Venus_D9(Sagittarius)" + ], + "d9_overlap": [], + "d9_saturn_targets": [ + "D9_10宫(Taurus)", + "D9_Venus(宫主)" + ], + "event_lord_d9_sign": "Sagittarius" + }, + "summary": "⚠️ 跨层间接 Double Transit (D1+D9),需结合 Dasha 确认", + "transit_date": "2008-11-04" + }, + "event_source": { + "source_grade": "primary", + "url": "https://www.fec.gov/introduction-campaign-finance/election-results-and-voting-information/federal-elections-2008/" + }, + "functional_benefic_malefic": { + "ascendant": "Capricorn", + "effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。", + "functional_benefics": [ + "Mercury", + "Saturn", + "Venus" + ], + "functional_malefics": [ + "Jupiter", + "Mars" + ], + "functional_neutrals": [ + "Moon", + "Sun" + ], + "owned_houses": { + "Jupiter": [ + 3, + 12 + ], + "Mars": [ + 4, + 11 + ], + "Mercury": [ + 6, + 9 + ], + "Moon": [ + 7 + ], + "Saturn": [ + 1, + 2 + ], + "Sun": [ + 8 + ], + "Venus": [ + 5, + 10 + ] + }, + "source": "strict_functional_benefic_malefic_v1", + "status": "used", + "yogakarakas": [ + "Saturn", + "Venus" + ] + }, + "narayana": { + "ad": { + "end_age": 48.3182, + "lord": "Jupiter", + "sign": "Pisces", + "sign_idx": 11, + "start_age": 47.1818, + "years": 1.1364 + }, + "md": { + "end_age": 49.0, + "lord": "Moon", + "sign": "Cancer", + "sign_idx": 3, + "start_age": 39.0, + "years": 10 + }, + "pd": { + "end_age": 47.3109, + "lord": "Jupiter", + "sign": "Pisces", + "sign_idx": 11, + "start_age": 47.1818, + "years": 0.1291 + }, + "remaining_years": 1.75 + }, + "vimshottari": { + "antardasha": "Moon", + "mahadasha": "Jupiter" + } + }, + "expected_label": "career_status", + "matched_expected_label": false, + "name": "Barack Obama", + "outcome": "Won the United States presidential election", + "result_class": "miss", + "score": 1, + "signals": [ + "double_transit_pac_present" + ] + }, + { + "actual_label": null, + "birth_time_rating": "A", + "blocked": false, + "case_id": "schwarzenegger_governor_2003", + "domain": "career", + "event_date": "2003-10-07", + "evidence": { + "arudha_lord": "Venus", + "ascendant": { + "degree": 25.9942, + "degree_in_sign": 25.9942, + "degree_in_sign_raw": 85.99419230270675, + "degree_raw": 85.9942, + "lon": 85.9942, + "lord": "Mercury", + "sign": "Gemini", + "sign_cn": "双子座" + }, + "birth_source": { + "evidence_basis": "from memory", + "source_grade": "verified_secondary", + "time_accuracy_rating": "A", + "url": "https://www.astro.com/adbvip/adbvip_07_30.htm" + }, + "domain_varga": { + "birth_info": "1947-07-30 04:10", + "divisional_charts": { + "D10_Dasamsa": { + "Jupiter": { + "sign": "Gemini", + "sign_cn": "双子座" + }, + "Ketu": { + "sign": "Leo", + "sign_cn": "狮子座" + }, + "Mars": { + "sign": "Virgo", + "sign_cn": "处女座" + }, + "Mercury": { + "sign": "Aquarius", + "sign_cn": "水瓶座" + }, + "Moon": { + "sign": "Pisces", + "sign_cn": "双鱼座" + }, + "Rahu": { + "sign": "Aquarius", + "sign_cn": "水瓶座" + }, + "Saturn": { + "sign": "Virgo", + "sign_cn": "处女座" + }, + "Sun": { + "sign": "Cancer", + "sign_cn": "巨蟹座" + }, + "Venus": { + "sign": "Aries", + "sign_cn": "白羊座" + }, + "ascendant": "Aquarius" + } + } + }, + "double_transit_pac": { + "cl": { + "jupiter": {}, + "saturn": { + "CL_10宫(Virgo)": [ + { + "desc": "3宫相位", + "offset": 3, + "type": "Aspect" + } + ], + "CL_Mercury(宫主)": [ + { + "desc": "同宫(7宫)", + "type": "Position" + }, + { + "desc": "合相(5.91°)", + "type": "Conjunction" + } + ] + } + }, + "d1": { + "jupiter": { + "10宫(Pisces)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ] + }, + "saturn": { + "Mercury(LL)": [ + { + "desc": "同宫(1宫)", + "type": "Position" + }, + { + "desc": "合相(5.91°)", + "type": "Conjunction" + } + ] + } + }, + "d9": { + "jupiter": { + "D9_10宫(Aquarius)": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "Jupiter_D9(Taurus)": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "Mercury_D9(Taurus)": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ] + }, + "saturn": {} + }, + "double_transit": [], + "event_house": 10, + "stats": { + "chandra_lagna": "Sagittarius", + "cl_jupiter_targets": [], + "cl_overlap": [], + "cl_saturn_targets": [ + "CL_10宫(Virgo)", + "CL_Mercury(宫主)" + ], + "d1_jupiter_targets": [ + "10宫(Pisces)" + ], + "d1_overlap": [], + "d1_saturn_targets": [ + "Mercury(LL)" + ], + "d9_ascendant": "Taurus", + "d9_jupiter_targets": [ + "D9_10宫(Aquarius)", + "Jupiter_D9(Taurus)", + "Mercury_D9(Taurus)" + ], + "d9_overlap": [], + "d9_saturn_targets": [], + "event_lord_d9_sign": "Taurus" + }, + "summary": "❌ 无 Double Transit PAC 激活", + "transit_date": "2003-10-07" + }, + "event_source": { + "source_grade": "primary", + "url": "https://elections.cdn.sos.ca.gov/sov/2003-special/sov-complete.pdf" + }, + "functional_benefic_malefic": { + "ascendant": "Gemini", + "effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。", + "functional_benefics": [ + "Mercury", + "Saturn", + "Venus" + ], + "functional_malefics": [ + "Mars", + "Sun" + ], + "functional_neutrals": [ + "Jupiter", + "Moon" + ], + "owned_houses": { + "Jupiter": [ + 7, + 10 + ], + "Mars": [ + 6, + 11 + ], + "Mercury": [ + 1, + 4 + ], + "Moon": [ + 2 + ], + "Saturn": [ + 8, + 9 + ], + "Sun": [ + 3 + ], + "Venus": [ + 5, + 12 + ] + }, + "source": "strict_functional_benefic_malefic_v1", + "status": "used", + "yogakarakas": [ + "Mercury" + ] + }, + "narayana": { + "ad": { + "end_age": 57.1807, + "lord": "Mercury", + "sign": "Gemini", + "sign_idx": 2, + "start_age": 55.7349, + "years": 1.4458 + }, + "md": { + "end_age": 62.0, + "lord": "Jupiter", + "sign": "Sagittarius", + "sign_idx": 8, + "start_age": 52.0, + "years": 10 + }, + "pd": { + "end_age": 56.2226, + "lord": "Sun", + "sign": "Leo", + "sign_idx": 4, + "start_age": 56.031, + "years": 0.1916 + }, + "remaining_years": 5.81 + }, + "vimshottari": { + "antardasha": "Venus", + "mahadasha": "Rahu" + } + }, + "expected_label": "career_status", + "matched_expected_label": false, + "name": "Arnold Schwarzenegger", + "outcome": "Won the California gubernatorial recall election", + "result_class": "miss", + "score": 2, + "signals": [ + "active_dasha_matches_A10_lord:Venus", + "narayana_lord_owns_event_house:Jupiter" + ] + }, + { + "actual_label": "domain_activation", + "birth_time_rating": "AA", + "blocked": false, + "case_id": "streep_oscar_1983", + "domain": "career", + "event_date": "1983-04-11", + "evidence": { + "arudha_lord": "Mercury", + "ascendant": { + "degree": 9.596, + "degree_in_sign": 9.596, + "degree_in_sign_raw": 99.59604722350997, + "degree_raw": 99.596, + "lon": 99.596, + "lord": "Moon", + "sign": "Cancer", + "sign_cn": "巨蟹座" + }, + "birth_source": { + "evidence_basis": "BC/BR in hand", + "source_grade": "primary", + "time_accuracy_rating": "AA", + "url": "https://www.astro.com/adbvip/adbvip_06_22.htm" + }, + "domain_varga": { + "birth_info": "1949-06-22 08:05", + "divisional_charts": { + "D10_Dasamsa": { + "Jupiter": { + "sign": "Scorpio", + "sign_cn": "天蝎座" + }, + "Ketu": { + "sign": "Aquarius", + "sign_cn": "水瓶座" + }, + "Mars": { + "sign": "Gemini", + "sign_cn": "双子座" + }, + "Mercury": { + "sign": "Gemini", + "sign_cn": "双子座" + }, + "Moon": { + "sign": "Scorpio", + "sign_cn": "天蝎座" + }, + "Rahu": { + "sign": "Leo", + "sign_cn": "狮子座" + }, + "Saturn": { + "sign": "Libra", + "sign_cn": "天秤座" + }, + "Sun": { + "sign": "Leo", + "sign_cn": "狮子座" + }, + "Venus": { + "sign": "Aquarius", + "sign_cn": "水瓶座" + }, + "ascendant": "Gemini" + } + } + }, + "double_transit_pac": { + "cl": { + "jupiter": { + "CL_Saturn(宫主)": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ] + }, + "saturn": { + "CL_10宫(Capricorn)": [ + { + "desc": "3宫相位", + "offset": 3, + "type": "Aspect" + } + ], + "CL_Saturn(宫主)": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ] + } + }, + "d1": { + "jupiter": { + "10宫(Aries)": [ + { + "desc": "5宫相位", + "offset": 5, + "type": "Aspect" + } + ], + "Moon(LL)": [ + { + "desc": "5宫相位", + "offset": 5, + "type": "Aspect" + } + ], + "Saturn(对宫主)": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ] + }, + "saturn": { + "Mars(宫主)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ], + "Saturn(对宫主)": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ] + } + }, + "d9": { + "jupiter": {}, + "saturn": { + "D9_Mercury(宫主)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ], + "Mars_D9(Taurus)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ], + "Moon_D9(Libra)": [ + { + "desc": "同宫(2宫)", + "type": "Position" + }, + { + "desc": "合相(6.73°)", + "type": "Conjunction" + } + ] + } + }, + "double_transit": [ + { + "jupiter_pac": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "layer": "D1", + "saturn_pac": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ], + "strength": "strong", + "target": "Saturn(对宫主)" + }, + { + "jupiter_pac": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "layer": "CL", + "saturn_pac": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ], + "strength": "strong", + "target": "CL_Saturn(宫主)" + } + ], + "event_house": 10, + "stats": { + "chandra_lagna": "Aries", + "cl_jupiter_targets": [ + "CL_Saturn(宫主)" + ], + "cl_overlap": [ + "CL_Saturn(宫主)" + ], + "cl_saturn_targets": [ + "CL_10宫(Capricorn)", + "CL_Saturn(宫主)" + ], + "d1_jupiter_targets": [ + "10宫(Aries)", + "Moon(LL)", + "Saturn(对宫主)" + ], + "d1_overlap": [ + "Saturn(对宫主)" + ], + "d1_saturn_targets": [ + "Mars(宫主)", + "Saturn(对宫主)" + ], + "d9_ascendant": "Virgo", + "d9_jupiter_targets": [], + "d9_overlap": [], + "d9_saturn_targets": [ + "D9_Mercury(宫主)", + "Mars_D9(Taurus)", + "Moon_D9(Libra)" + ], + "event_lord_d9_sign": "Taurus" + }, + "summary": "✅ Double Transit PAC 确认: D1+CL 多层激活10宫主题", + "transit_date": "1983-04-11" + }, + "event_source": { + "source_grade": "primary", + "url": "https://www.oscars.org/oscars/ceremonies/1983" + }, + "functional_benefic_malefic": { + "ascendant": "Cancer", + "effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。", + "functional_benefics": [ + "Jupiter", + "Mars", + "Moon" + ], + "functional_malefics": [ + "Mercury", + "Saturn", + "Venus" + ], + "functional_neutrals": [ + "Sun" + ], + "owned_houses": { + "Jupiter": [ + 6, + 9 + ], + "Mars": [ + 5, + 10 + ], + "Mercury": [ + 3, + 12 + ], + "Moon": [ + 1 + ], + "Saturn": [ + 7, + 8 + ], + "Sun": [ + 2 + ], + "Venus": [ + 4, + 11 + ] + }, + "source": "strict_functional_benefic_malefic_v1", + "status": "used", + "yogakarakas": [ + "Mars" + ] + }, + "narayana": { + "ad": { + "end_age": 34.1795, + "lord": "Sun", + "sign": "Leo", + "sign_idx": 4, + "start_age": 33.1538, + "years": 1.0256 + }, + "md": { + "end_age": 35.0, + "lord": "Venus", + "sign": "Libra", + "sign_idx": 6, + "start_age": 27.0, + "years": 8 + }, + "pd": { + "end_age": 33.8901, + "lord": "Jupiter", + "sign": "Pisces", + "sign_idx": 11, + "start_age": 33.7586, + "years": 0.1315 + }, + "remaining_years": 1.2 + }, + "vimshottari": { + "antardasha": "Rahu", + "mahadasha": "Rahu" + } + }, + "expected_label": "career_status", + "matched_expected_label": false, + "name": "Meryl Streep", + "outcome": "Won Best Actress for Sophie's Choice", + "result_class": "weak_hit", + "score": 5, + "signals": [ + "Rahu_occupies_event_house:9", + "Rahu_occupies_event_house:9", + "narayana_lord_owns_event_house:Venus", + "double_transit_pac_strong" + ] + }, + { + "actual_label": "domain_activation", + "birth_time_rating": "AA", + "blocked": false, + "case_id": "aniston_emmy_2002", + "domain": "career", + "event_date": "2002-09-22", + "evidence": { + "arudha_lord": "Jupiter", + "ascendant": { + "degree": 1.2377, + "degree_in_sign": 1.2377, + "degree_in_sign_raw": 181.2377135283859, + "degree_raw": 181.2377, + "lon": 181.2377, + "lord": "Venus", + "sign": "Libra", + "sign_cn": "天秤座" + }, + "birth_source": { + "evidence_basis": "quoted BC/BR", + "source_grade": "primary", + "time_accuracy_rating": "AA", + "url": "https://www.astro.com/adbvip/adbvip_02_11.htm" + }, + "domain_varga": { + "birth_info": "1969-02-11 22:22", + "divisional_charts": { + "D10_Dasamsa": { + "Jupiter": { + "sign": "Leo", + "sign_cn": "狮子座" + }, + "Ketu": { + "sign": "Cancer", + "sign_cn": "巨蟹座" + }, + "Mars": { + "sign": "Cancer", + "sign_cn": "巨蟹座" + }, + "Mercury": { + "sign": "Scorpio", + "sign_cn": "天蝎座" + }, + "Moon": { + "sign": "Aries", + "sign_cn": "白羊座" + }, + "Rahu": { + "sign": "Capricorn", + "sign_cn": "摩羯座" + }, + "Saturn": { + "sign": "Leo", + "sign_cn": "狮子座" + }, + "Sun": { + "sign": "Gemini", + "sign_cn": "双子座" + }, + "Venus": { + "sign": "Aries", + "sign_cn": "白羊座" + }, + "ascendant": "Libra" + } + } + }, + "double_transit_pac": { + "cl": { + "jupiter": {}, + "saturn": { + "CL_Sun(宫主)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ] + } + }, + "d1": { + "jupiter": { + "10宫(Cancer)": [ + { + "desc": "同宫(10宫)", + "type": "Position" + }, + { + "desc": "合相(1.81°)", + "type": "Conjunction" + } + ] + }, + "saturn": {} + }, + "d9": { + "jupiter": {}, + "saturn": {} + }, + "double_transit": [], + "event_house": 10, + "stats": { + "chandra_lagna": "Scorpio", + "cl_jupiter_targets": [], + "cl_overlap": [], + "cl_saturn_targets": [ + "CL_Sun(宫主)" + ], + "d1_jupiter_targets": [ + "10宫(Cancer)" + ], + "d1_overlap": [], + "d1_saturn_targets": [], + "d9_ascendant": "Libra", + "d9_jupiter_targets": [], + "d9_overlap": [], + "d9_saturn_targets": [], + "event_lord_d9_sign": "Pisces" + }, + "summary": "❌ 无 Double Transit PAC 激活", + "transit_date": "2002-09-22" + }, + "event_source": { + "source_grade": "primary", + "url": "https://www.televisionacademy.com/awards/nominees-winners/2002/outstanding-lead-actress-in-a-comedy-series" + }, + "functional_benefic_malefic": { + "ascendant": "Libra", + "effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。", + "functional_benefics": [ + "Mercury", + "Saturn", + "Venus" + ], + "functional_malefics": [ + "Jupiter", + "Sun" + ], + "functional_neutrals": [ + "Mars", + "Moon" + ], + "owned_houses": { + "Jupiter": [ + 3, + 6 + ], + "Mars": [ + 2, + 7 + ], + "Mercury": [ + 9, + 12 + ], + "Moon": [ + 10 + ], + "Saturn": [ + 4, + 5 + ], + "Sun": [ + 11 + ], + "Venus": [ + 1, + 8 + ] + }, + "source": "strict_functional_benefic_malefic_v1", + "status": "used", + "yogakarakas": [ + "Saturn", + "Venus" + ] + }, + "narayana": { + "ad": { + "end_age": 34.0, + "lord": "Mars", + "sign": "Scorpio", + "sign_idx": 7, + "start_age": 33.0, + "years": 1.0 + }, + "md": { + "end_age": 35.0, + "lord": "Jupiter", + "sign": "Pisces", + "sign_idx": 11, + "start_age": 29.0, + "years": 6 + }, + "pd": { + "end_age": 33.6528, + "lord": "Venus", + "sign": "Taurus", + "sign_idx": 1, + "start_age": 33.5139, + "years": 0.1389 + }, + "remaining_years": 1.39 + }, + "vimshottari": { + "antardasha": "Moon", + "mahadasha": "Moon" + } + }, + "expected_label": "career_status", + "matched_expected_label": false, + "name": "Jennifer Aniston", + "outcome": "Won the Primetime Emmy for lead actress in a comedy series", + "result_class": "weak_hit", + "score": 5, + "signals": [ + "Moon_owns_event_houses:[10]", + "Moon_owns_event_houses:[10]", + "narayana_lord_owns_event_house:Jupiter" + ] + }, + { + "actual_label": "domain_activation", + "birth_time_rating": "AA", + "blocked": false, + "case_id": "william_marriage_2011", + "domain": "marriage", + "event_date": "2011-04-29", + "evidence": { + "arudha_lord": "Moon", + "ascendant": { + "degree": 3.824, + "degree_in_sign": 3.824, + "degree_in_sign_raw": 243.8240154811706, + "degree_raw": 243.824, + "lon": 243.824, + "lord": "Jupiter", + "sign": "Sagittarius", + "sign_cn": "射手座" + }, + "birth_source": { + "evidence_basis": "BC/BR in hand", + "source_grade": "primary", + "time_accuracy_rating": "AA", + "url": "https://www.astro.com/adbvip/adbvip_06_21.htm" + }, + "domain_varga": { + "birth_info": "1982-06-21 21:03", + "divisional_charts": { + "D9_Navamsa": { + "Jupiter": { + "sign": "Sagittarius", + "sign_cn": "射手座" + }, + "Ketu": { + "sign": "Libra", + "sign_cn": "天秤座" + }, + "Mars": { + "sign": "Taurus", + "sign_cn": "金牛座" + }, + "Mercury": { + "sign": "Taurus", + "sign_cn": "金牛座" + }, + "Moon": { + "sign": "Capricorn", + "sign_cn": "摩羯座" + }, + "Rahu": { + "sign": "Aries", + "sign_cn": "白羊座" + }, + "Saturn": { + "sign": "Cancer", + "sign_cn": "巨蟹座" + }, + "Sun": { + "sign": "Scorpio", + "sign_cn": "天蝎座" + }, + "Venus": { + "sign": "Capricorn", + "sign_cn": "摩羯座" + }, + "ascendant": "Taurus" + } + } + }, + "double_transit_pac": { + "cl": { + "jupiter": { + "CL_7宫(Sagittarius)": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "CL_Jupiter(宫主)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ] + }, + "saturn": { + "CL_7宫(Sagittarius)": [ + { + "desc": "3宫相位", + "offset": 3, + "type": "Aspect" + } + ] + } + }, + "d1": { + "jupiter": { + "Jupiter(LL)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ] + }, + "saturn": {} + }, + "d9": { + "jupiter": { + "Jupiter_D9(Sagittarius)": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ] + }, + "saturn": { + "D9_Mars(宫主)": [ + { + "desc": "同宫(5宫)", + "type": "Position" + }, + { + "desc": "合相(2.40°)", + "type": "Conjunction" + } + ], + "Jupiter_D9(Sagittarius)": [ + { + "desc": "3宫相位", + "offset": 3, + "type": "Aspect" + } + ] + } + }, + "double_transit": [ + { + "jupiter_pac": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "layer": "D9", + "saturn_pac": [ + { + "desc": "3宫相位", + "offset": 3, + "type": "Aspect" + } + ], + "strength": "strong", + "target": "Jupiter_D9(Sagittarius)" + }, + { + "jupiter_pac": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "layer": "CL", + "saturn_pac": [ + { + "desc": "3宫相位", + "offset": 3, + "type": "Aspect" + } + ], + "strength": "strong", + "target": "CL_7宫(Sagittarius)" + } + ], + "event_house": 7, + "stats": { + "chandra_lagna": "Gemini", + "cl_jupiter_targets": [ + "CL_7宫(Sagittarius)", + "CL_Jupiter(宫主)" + ], + "cl_overlap": [ + "CL_7宫(Sagittarius)" + ], + "cl_saturn_targets": [ + "CL_7宫(Sagittarius)" + ], + "d1_jupiter_targets": [ + "Jupiter(LL)" + ], + "d1_overlap": [], + "d1_saturn_targets": [], + "d9_ascendant": "Taurus", + "d9_jupiter_targets": [ + "Jupiter_D9(Sagittarius)" + ], + "d9_overlap": [ + "Jupiter_D9(Sagittarius)" + ], + "d9_saturn_targets": [ + "D9_Mars(宫主)", + "Jupiter_D9(Sagittarius)" + ], + "event_lord_d9_sign": "Taurus" + }, + "summary": "✅ Double Transit PAC 确认: D9+CL 多层激活7宫主题", + "transit_date": "2011-04-29" + }, + "event_source": { + "source_grade": "primary", + "url": "https://www.royal.uk/wedding-prince-william-and-miss-catherine-middleton" + }, + "functional_benefic_malefic": { + "ascendant": "Sagittarius", + "effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。", + "functional_benefics": [ + "Jupiter", + "Mars", + "Sun" + ], + "functional_malefics": [ + "Saturn", + "Venus" + ], + "functional_neutrals": [ + "Mercury", + "Moon" + ], + "owned_houses": { + "Jupiter": [ + 1, + 4 + ], + "Mars": [ + 5, + 12 + ], + "Mercury": [ + 7, + 10 + ], + "Moon": [ + 8 + ], + "Saturn": [ + 2, + 3 + ], + "Sun": [ + 9 + ], + "Venus": [ + 6, + 11 + ] + }, + "source": "strict_functional_benefic_malefic_v1", + "status": "used", + "yogakarakas": [ + "Jupiter" + ] + }, + "narayana": { + "ad": { + "end_age": 29.2264, + "lord": "Mercury", + "sign": "Virgo", + "sign_idx": 5, + "start_age": 28.6981, + "years": 0.5283 + }, + "md": { + "end_age": 32.0, + "lord": "Jupiter", + "sign": "Pisces", + "sign_idx": 11, + "start_age": 25.0, + "years": 7 + }, + "pd": { + "end_age": 28.8725, + "lord": "Jupiter", + "sign": "Sagittarius", + "sign_idx": 8, + "start_age": 28.8227, + "years": 0.0498 + }, + "remaining_years": 3.15 + }, + "vimshottari": { + "antardasha": "Saturn", + "mahadasha": "Saturn" + } + }, + "expected_label": "legal_marriage", + "matched_expected_label": false, + "name": "William, Prince of Wales", + "outcome": "Married Catherine Middleton", + "result_class": "weak_hit", + "score": 6, + "signals": [ + "Saturn_owns_event_houses:[2]", + "Saturn_owns_event_houses:[2]", + "double_transit_pac_strong" + ] + }, + { + "actual_label": "domain_activation", + "birth_time_rating": "AA", + "blocked": false, + "case_id": "jolie_marriage_2014", + "domain": "marriage", + "event_date": "2014-08-23", + "evidence": { + "arudha_lord": "Mars", + "ascendant": { + "degree": 5.3578, + "degree_in_sign": 5.3578, + "degree_in_sign_raw": 95.35778170382213, + "degree_raw": 95.3578, + "lon": 95.3578, + "lord": "Moon", + "sign": "Cancer", + "sign_cn": "巨蟹座" + }, + "birth_source": { + "evidence_basis": "quoted BC/BR", + "source_grade": "primary", + "time_accuracy_rating": "AA", + "url": "https://www.astro.com/adbvip/adbvip_06_04.htm" + }, + "domain_varga": { + "birth_info": "1975-06-04 09:09", + "divisional_charts": { + "D9_Navamsa": { + "Jupiter": { + "sign": "Aquarius", + "sign_cn": "水瓶座" + }, + "Ketu": { + "sign": "Pisces", + "sign_cn": "双鱼座" + }, + "Mars": { + "sign": "Sagittarius", + "sign_cn": "射手座" + }, + "Mercury": { + "sign": "Virgo", + "sign_cn": "处女座" + }, + "Moon": { + "sign": "Sagittarius", + "sign_cn": "射手座" + }, + "Rahu": { + "sign": "Virgo", + "sign_cn": "处女座" + }, + "Saturn": { + "sign": "Taurus", + "sign_cn": "金牛座" + }, + "Sun": { + "sign": "Gemini", + "sign_cn": "双子座" + }, + "Venus": { + "sign": "Leo", + "sign_cn": "狮子座" + }, + "ascendant": "Leo" + } + } + }, + "double_transit_pac": { + "cl": { + "jupiter": {}, + "saturn": { + "CL_Mercury(宫主)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ] + } + }, + "d1": { + "jupiter": {}, + "saturn": { + "7宫(Capricorn)": [ + { + "desc": "3宫相位", + "offset": 3, + "type": "Aspect" + } + ] + } + }, + "d9": { + "jupiter": { + "Moon_D9(Sagittarius)": [ + { + "desc": "5宫相位", + "offset": 5, + "type": "Aspect" + } + ] + }, + "saturn": { + "D9_7宫(Aquarius)": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ], + "Saturn_D9(Taurus)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ] + } + }, + "double_transit": [], + "event_house": 7, + "stats": { + "chandra_lagna": "Pisces", + "cl_jupiter_targets": [], + "cl_overlap": [], + "cl_saturn_targets": [ + "CL_Mercury(宫主)" + ], + "d1_jupiter_targets": [], + "d1_overlap": [], + "d1_saturn_targets": [ + "7宫(Capricorn)" + ], + "d9_ascendant": "Leo", + "d9_jupiter_targets": [ + "Moon_D9(Sagittarius)" + ], + "d9_overlap": [], + "d9_saturn_targets": [ + "D9_7宫(Aquarius)", + "Saturn_D9(Taurus)" + ], + "event_lord_d9_sign": "Taurus" + }, + "summary": "❌ 无 Double Transit PAC 激活", + "transit_date": "2014-08-23" + }, + "event_source": { + "source_grade": "verified_secondary", + "url": "https://en.wikipedia.org/wiki/Angelina_Jolie" + }, + "functional_benefic_malefic": { + "ascendant": "Cancer", + "effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。", + "functional_benefics": [ + "Jupiter", + "Mars", + "Moon" + ], + "functional_malefics": [ + "Mercury", + "Saturn", + "Venus" + ], + "functional_neutrals": [ + "Sun" + ], + "owned_houses": { + "Jupiter": [ + 6, + 9 + ], + "Mars": [ + 5, + 10 + ], + "Mercury": [ + 3, + 12 + ], + "Moon": [ + 1 + ], + "Saturn": [ + 7, + 8 + ], + "Sun": [ + 2 + ], + "Venus": [ + 4, + 11 + ] + }, + "source": "strict_functional_benefic_malefic_v1", + "status": "used", + "yogakarakas": [ + "Mars" + ] + }, + "narayana": { + "ad": { + "end_age": 39.2209, + "lord": "Mars", + "sign": "Aries", + "sign_idx": 0, + "start_age": 38.8372, + "years": 0.3837 + }, + "md": { + "end_age": 41.0, + "lord": "Jupiter", + "sign": "Sagittarius", + "sign_idx": 8, + "start_age": 38.0, + "years": 3 + }, + "pd": { + "end_age": 39.2209, + "lord": "Jupiter", + "sign": "Pisces", + "sign_idx": 11, + "start_age": 39.1674, + "years": 0.0535 + }, + "remaining_years": 1.78 + }, + "vimshottari": { + "antardasha": "Ketu", + "mahadasha": "Venus" + } + }, + "expected_label": "legal_marriage", + "matched_expected_label": false, + "name": "Angelina Jolie", + "outcome": "Married Brad Pitt", + "result_class": "weak_hit", + "score": 4, + "signals": [ + "Venus_owns_event_houses:[11]", + "Venus_domain_karaka", + "Ketu_occupies_event_house:11" + ] + }, + { + "actual_label": "legal_marriage", + "birth_time_rating": "AA", + "blocked": false, + "case_id": "kahlo_marriage_1929", + "domain": "marriage", + "event_date": "1929-08-21", + "evidence": { + "arudha_lord": "Jupiter", + "ascendant": { + "degree": 0.9523, + "degree_in_sign": 0.9523, + "degree_in_sign_raw": 120.95226624616197, + "degree_raw": 120.9523, + "lon": 120.9523, + "lord": "Sun", + "sign": "Leo", + "sign_cn": "狮子座" + }, + "birth_source": { + "evidence_basis": "BC/BR in hand", + "source_grade": "primary", + "time_accuracy_rating": "AA", + "url": "https://www.astro.com/adbvip/adbvip_07_06.htm" + }, + "domain_varga": { + "birth_info": "1907-07-06 08:30", + "divisional_charts": { + "D9_Navamsa": { + "Jupiter": { + "sign": "Gemini", + "sign_cn": "双子座" + }, + "Ketu": { + "sign": "Capricorn", + "sign_cn": "摩羯座" + }, + "Mars": { + "sign": "Libra", + "sign_cn": "天秤座" + }, + "Mercury": { + "sign": "Scorpio", + "sign_cn": "天蝎座" + }, + "Moon": { + "sign": "Pisces", + "sign_cn": "双鱼座" + }, + "Rahu": { + "sign": "Cancer", + "sign_cn": "巨蟹座" + }, + "Saturn": { + "sign": "Leo", + "sign_cn": "狮子座" + }, + "Sun": { + "sign": "Aries", + "sign_cn": "白羊座" + }, + "Venus": { + "sign": "Libra", + "sign_cn": "天秤座" + }, + "ascendant": "Aries" + } + } + }, + "double_transit_pac": { + "cl": { + "jupiter": { + "CL_Mars(宫主)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ] + }, + "saturn": { + "CL_Mars(宫主)": [ + { + "desc": "同宫(8宫)", + "type": "Position" + } + ] + } + }, + "d1": { + "jupiter": { + "7宫(Aquarius)": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ] + }, + "saturn": { + "Saturn(宫主)": [ + { + "desc": "3宫相位", + "offset": 3, + "type": "Aspect" + } + ], + "Saturn(对宫主)": [ + { + "desc": "3宫相位", + "offset": 3, + "type": "Aspect" + } + ] + } + }, + "d9": { + "jupiter": {}, + "saturn": {} + }, + "double_transit": [ + { + "jupiter_pac": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ], + "layer": "CL", + "saturn_pac": [ + { + "desc": "同宫(8宫)", + "type": "Position" + } + ], + "strength": "strong", + "target": "CL_Mars(宫主)" + } + ], + "event_house": 7, + "stats": { + "chandra_lagna": "Taurus", + "cl_jupiter_targets": [ + "CL_Mars(宫主)" + ], + "cl_overlap": [ + "CL_Mars(宫主)" + ], + "cl_saturn_targets": [ + "CL_Mars(宫主)" + ], + "d1_jupiter_targets": [ + "7宫(Aquarius)" + ], + "d1_overlap": [], + "d1_saturn_targets": [ + "Saturn(宫主)", + "Saturn(对宫主)" + ], + "d9_ascendant": "Aries", + "d9_jupiter_targets": [], + "d9_overlap": [], + "d9_saturn_targets": [], + "event_lord_d9_sign": "Leo" + }, + "summary": "⚠️ Chandra Lagna 层 Double Transit 激活,D1/D9 未确认", + "transit_date": "1929-08-21" + }, + "event_source": { + "source_grade": "verified_secondary", + "url": "https://en.wikipedia.org/wiki/Frida_Kahlo" + }, + "functional_benefic_malefic": { + "ascendant": "Leo", + "effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。", + "functional_benefics": [ + "Jupiter", + "Mars", + "Sun" + ], + "functional_malefics": [ + "Mercury", + "Moon", + "Saturn", + "Venus" + ], + "functional_neutrals": [], + "owned_houses": { + "Jupiter": [ + 5, + 8 + ], + "Mars": [ + 4, + 9 + ], + "Mercury": [ + 2, + 11 + ], + "Moon": [ + 12 + ], + "Saturn": [ + 6, + 7 + ], + "Sun": [ + 1 + ], + "Venus": [ + 3, + 10 + ] + }, + "source": "strict_functional_benefic_malefic_v1", + "status": "used", + "yogakarakas": [ + "Mars" + ] + }, + "narayana": { + "ad": { + "end_age": 22.2295, + "lord": "Saturn", + "sign": "Capricorn", + "sign_idx": 9, + "start_age": 21.9672, + "years": 0.2623 + }, + "md": { + "end_age": 28.0, + "lord": "Venus", + "sign": "Libra", + "sign_idx": 6, + "start_age": 20.0, + "years": 8 + }, + "pd": { + "end_age": 22.165, + "lord": "Mercury", + "sign": "Virgo", + "sign_idx": 5, + "start_age": 22.122, + "years": 0.043 + }, + "remaining_years": 5.87 + }, + "vimshottari": { + "antardasha": "Jupiter", + "mahadasha": "Rahu" + } + }, + "expected_label": "legal_marriage", + "matched_expected_label": true, + "name": "Frida Kahlo", + "outcome": "Married Diego Rivera in a civil ceremony", + "result_class": "strong_hit", + "score": 7, + "signals": [ + "Jupiter_owns_event_houses:[5]", + "Jupiter_occupies_event_house:11", + "Jupiter_domain_karaka", + "active_dasha_matches_UL_lord:Jupiter", + "double_transit_pac_strong" + ] + }, + { + "actual_label": "legal_marriage", + "birth_time_rating": "AA", + "blocked": false, + "case_id": "snoop_marriage_1997", + "domain": "marriage", + "event_date": "1997-06-14", + "evidence": { + "arudha_lord": "Moon", + "ascendant": { + "degree": 7.3126, + "degree_in_sign": 7.3126, + "degree_in_sign_raw": 7.312583875536397, + "degree_raw": 7.3126, + "lon": 7.3126, + "lord": "Mars", + "sign": "Aries", + "sign_cn": "白羊座" + }, + "birth_source": { + "evidence_basis": "BC/BR in hand", + "source_grade": "primary", + "time_accuracy_rating": "AA", + "url": "https://www.astro.com/adbvip/adbvip_10_20.htm" + }, + "domain_varga": { + "birth_info": "1971-10-20 18:20", + "divisional_charts": { + "D9_Navamsa": { + "Jupiter": { + "sign": "Libra", + "sign_cn": "天秤座" + }, + "Ketu": { + "sign": "Sagittarius", + "sign_cn": "射手座" + }, + "Mars": { + "sign": "Virgo", + "sign_cn": "处女座" + }, + "Mercury": { + "sign": "Capricorn", + "sign_cn": "摩羯座" + }, + "Moon": { + "sign": "Aries", + "sign_cn": "白羊座" + }, + "Rahu": { + "sign": "Gemini", + "sign_cn": "双子座" + }, + "Saturn": { + "sign": "Aries", + "sign_cn": "白羊座" + }, + "Sun": { + "sign": "Scorpio", + "sign_cn": "天蝎座" + }, + "Venus": { + "sign": "Pisces", + "sign_cn": "双鱼座" + }, + "ascendant": "Gemini" + } + } + }, + "double_transit_pac": { + "cl": { + "jupiter": { + "CL_Mars(宫主)": [ + { + "desc": "同宫(4宫)", + "type": "Position" + }, + { + "desc": "合相(0.48°)", + "type": "Conjunction" + } + ] + }, + "saturn": { + "CL_Mars(宫主)": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ] + } + }, + "d1": { + "jupiter": { + "7宫(Libra)": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "Mars(LL)": [ + { + "desc": "同宫(10宫)", + "type": "Position" + }, + { + "desc": "合相(0.48°)", + "type": "Conjunction" + } + ], + "Venus(宫主)": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "Venus(对宫主)": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ] + }, + "saturn": { + "7宫(Libra)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ], + "Mars(LL)": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ], + "Venus(宫主)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ], + "Venus(对宫主)": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ] + } + }, + "d9": { + "jupiter": { + "D9_7宫(Sagittarius)": [ + { + "desc": "5宫相位", + "offset": 5, + "type": "Aspect" + } + ] + }, + "saturn": { + "D9_7宫(Sagittarius)": [ + { + "desc": "3宫相位", + "offset": 3, + "type": "Aspect" + } + ], + "Venus_D9(Pisces)": [ + { + "desc": "同宫(10宫)", + "type": "Position" + }, + { + "desc": "合相(9.65°)", + "type": "Conjunction" + } + ] + } + }, + "double_transit": [ + { + "jupiter_pac": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "layer": "D1", + "saturn_pac": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ], + "strength": "strong", + "target": "Venus(对宫主)" + }, + { + "jupiter_pac": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "layer": "D1", + "saturn_pac": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ], + "strength": "strong", + "target": "Venus(宫主)" + }, + { + "jupiter_pac": [ + { + "desc": "同宫(10宫)", + "type": "Position" + }, + { + "desc": "合相(0.48°)", + "type": "Conjunction" + } + ], + "layer": "D1", + "saturn_pac": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ], + "strength": "strong", + "target": "Mars(LL)" + }, + { + "jupiter_pac": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "layer": "D1", + "saturn_pac": [ + { + "desc": "7宫相位", + "offset": 7, + "type": "Aspect" + } + ], + "strength": "strong", + "target": "7宫(Libra)" + }, + { + "jupiter_pac": [ + { + "desc": "5宫相位", + "offset": 5, + "type": "Aspect" + } + ], + "layer": "D9", + "saturn_pac": [ + { + "desc": "3宫相位", + "offset": 3, + "type": "Aspect" + } + ], + "strength": "strong", + "target": "D9_7宫(Sagittarius)" + }, + { + "jupiter_pac": [ + { + "desc": "同宫(4宫)", + "type": "Position" + }, + { + "desc": "合相(0.48°)", + "type": "Conjunction" + } + ], + "layer": "CL", + "saturn_pac": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ], + "strength": "strong", + "target": "CL_Mars(宫主)" + }, + { + "jupiter_pac": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "layer": "D1+D9", + "saturn_pac": [ + { + "desc": "同宫(10宫)", + "type": "Position" + }, + { + "desc": "合相(9.65°)", + "type": "Conjunction" + } + ], + "strength": "moderate", + "target": "Jupiter(D1)Venus(对宫主) + Saturn(D9)Venus_D9(Pisces)" + }, + { + "jupiter_pac": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ], + "layer": "D1+D9", + "saturn_pac": [ + { + "desc": "同宫(10宫)", + "type": "Position" + }, + { + "desc": "合相(9.65°)", + "type": "Conjunction" + } + ], + "strength": "moderate", + "target": "Jupiter(D1)Venus(宫主) + Saturn(D9)Venus_D9(Pisces)" + } + ], + "event_house": 7, + "stats": { + "chandra_lagna": "Libra", + "cl_jupiter_targets": [ + "CL_Mars(宫主)" + ], + "cl_overlap": [ + "CL_Mars(宫主)" + ], + "cl_saturn_targets": [ + "CL_Mars(宫主)" + ], + "d1_jupiter_targets": [ + "7宫(Libra)", + "Mars(LL)", + "Venus(宫主)", + "Venus(对宫主)" + ], + "d1_overlap": [ + "7宫(Libra)", + "Mars(LL)", + "Venus(宫主)", + "Venus(对宫主)" + ], + "d1_saturn_targets": [ + "7宫(Libra)", + "Mars(LL)", + "Venus(宫主)", + "Venus(对宫主)" + ], + "d9_ascendant": "Gemini", + "d9_jupiter_targets": [ + "D9_7宫(Sagittarius)" + ], + "d9_overlap": [ + "D9_7宫(Sagittarius)" + ], + "d9_saturn_targets": [ + "D9_7宫(Sagittarius)", + "Venus_D9(Pisces)" + ], + "event_lord_d9_sign": "Pisces" + }, + "summary": "✅ Double Transit PAC 确认: D1+D9+CL 多层激活7宫主题", + "transit_date": "1997-06-14" + }, + "event_source": { + "source_grade": "verified_secondary", + "url": "https://en.wikipedia.org/wiki/Snoop_Dogg" + }, + "functional_benefic_malefic": { + "ascendant": "Aries", + "effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。", + "functional_benefics": [ + "Jupiter", + "Mars", + "Sun" + ], + "functional_malefics": [ + "Mercury", + "Saturn" + ], + "functional_neutrals": [ + "Moon", + "Venus" + ], + "owned_houses": { + "Jupiter": [ + 9, + 12 + ], + "Mars": [ + 1, + 8 + ], + "Mercury": [ + 3, + 6 + ], + "Moon": [ + 4 + ], + "Saturn": [ + 10, + 11 + ], + "Sun": [ + 5 + ], + "Venus": [ + 2, + 7 + ] + }, + "source": "strict_functional_benefic_malefic_v1", + "status": "used", + "yogakarakas": [ + "Mars" + ] + }, + "narayana": { + "ad": { + "end_age": 26.25, + "lord": "Venus", + "sign": "Libra", + "sign_idx": 6, + "start_age": 24.0, + "years": 2.25 + }, + "md": { + "end_age": 36.0, + "lord": "Venus", + "sign": "Libra", + "sign_idx": 6, + "start_age": 24.0, + "years": 12 + }, + "pd": { + "end_age": 25.7227, + "lord": "Mars", + "sign": "Aries", + "sign_idx": 0, + "start_age": 25.4062, + "years": 0.3164 + }, + "remaining_years": 10.35 + }, + "vimshottari": { + "antardasha": "Moon", + "mahadasha": "Saturn" + } + }, + "expected_label": "legal_marriage", + "matched_expected_label": true, + "name": "Snoop Dogg", + "outcome": "Married Shante Taylor", + "result_class": "strong_hit", + "score": 10, + "signals": [ + "Saturn_owns_event_houses:[11]", + "Saturn_occupies_event_house:2", + "Moon_occupies_event_house:7", + "active_dasha_matches_UL_lord:Moon", + "narayana_activates_primary_event_sign:Libra", + "narayana_lord_owns_event_house:Venus", + "double_transit_pac_strong" + ] + }, + { + "actual_label": "legal_marriage", + "birth_time_rating": "A", + "blocked": false, + "case_id": "disney_marriage_1925", + "domain": "marriage", + "event_date": "1925-07-13", + "evidence": { + "arudha_lord": "Venus", + "ascendant": { + "degree": 3.1696, + "degree_in_sign": 3.1696, + "degree_in_sign_raw": 153.16955776302456, + "degree_raw": 153.1696, + "lon": 153.1696, + "lord": "Mercury", + "sign": "Virgo", + "sign_cn": "处女座" + }, + "birth_source": { + "evidence_basis": "from memory", + "source_grade": "verified_secondary", + "time_accuracy_rating": "A", + "url": "https://www.astro.com/adbvip/adbvip_12_05.htm" + }, + "domain_varga": { + "birth_info": "1901-12-05 00:35", + "divisional_charts": { + "D9_Navamsa": { + "Jupiter": { + "sign": "Libra", + "sign_cn": "天秤座" + }, + "Ketu": { + "sign": "Virgo", + "sign_cn": "处女座" + }, + "Mars": { + "sign": "Leo", + "sign_cn": "狮子座" + }, + "Mercury": { + "sign": "Leo", + "sign_cn": "狮子座" + }, + "Moon": { + "sign": "Gemini", + "sign_cn": "双子座" + }, + "Rahu": { + "sign": "Pisces", + "sign_cn": "双鱼座" + }, + "Saturn": { + "sign": "Libra", + "sign_cn": "天秤座" + }, + "Sun": { + "sign": "Sagittarius", + "sign_cn": "射手座" + }, + "Venus": { + "sign": "Pisces", + "sign_cn": "双鱼座" + }, + "ascendant": "Capricorn" + } + } + }, + "double_transit_pac": { + "cl": { + "jupiter": { + "CL_Jupiter(宫主)": [ + { + "desc": "同宫(4宫)", + "type": "Position" + }, + { + "desc": "合相(1.48°)", + "type": "Conjunction" + } + ] + }, + "saturn": {} + }, + "d1": { + "jupiter": { + "Jupiter(宫主)": [ + { + "desc": "同宫(4宫)", + "type": "Position" + }, + { + "desc": "合相(1.48°)", + "type": "Conjunction" + } + ], + "Jupiter(对宫主)": [ + { + "desc": "同宫(4宫)", + "type": "Position" + }, + { + "desc": "合相(1.48°)", + "type": "Conjunction" + } + ] + }, + "saturn": {} + }, + "d9": { + "jupiter": { + "D9_Moon(宫主)": [ + { + "desc": "9宫相位", + "offset": 9, + "type": "Aspect" + } + ] + }, + "saturn": { + "D9_7宫(Cancer)": [ + { + "desc": "3宫相位", + "offset": 3, + "type": "Aspect" + } + ], + "Jupiter_D9(Libra)": [ + { + "desc": "同宫(10宫)", + "type": "Position" + }, + { + "desc": "合相(0.19°)", + "type": "Conjunction" + } + ], + "Mercury_D9(Leo)": [ + { + "desc": "10宫相位", + "offset": 10, + "type": "Aspect" + } + ] + } + }, + "double_transit": [ + { + "jupiter_pac": [ + { + "desc": "同宫(4宫)", + "type": "Position" + }, + { + "desc": "合相(1.48°)", + "type": "Conjunction" + } + ], + "layer": "D1+D9", + "saturn_pac": [ + { + "desc": "同宫(10宫)", + "type": "Position" + }, + { + "desc": "合相(0.19°)", + "type": "Conjunction" + } + ], + "strength": "moderate", + "target": "Jupiter(D1)Jupiter(宫主) + Saturn(D9)Jupiter_D9(Libra)" + }, + { + "jupiter_pac": [ + { + "desc": "同宫(4宫)", + "type": "Position" + }, + { + "desc": "合相(1.48°)", + "type": "Conjunction" + } + ], + "layer": "D1+D9", + "saturn_pac": [ + { + "desc": "同宫(10宫)", + "type": "Position" + }, + { + "desc": "合相(0.19°)", + "type": "Conjunction" + } + ], + "strength": "moderate", + "target": "Jupiter(D1)Jupiter(对宫主) + Saturn(D9)Jupiter_D9(Libra)" + } + ], + "event_house": 7, + "stats": { + "chandra_lagna": "Virgo", + "cl_jupiter_targets": [ + "CL_Jupiter(宫主)" + ], + "cl_overlap": [], + "cl_saturn_targets": [], + "d1_jupiter_targets": [ + "Jupiter(宫主)", + "Jupiter(对宫主)" + ], + "d1_overlap": [], + "d1_saturn_targets": [], + "d9_ascendant": "Capricorn", + "d9_jupiter_targets": [ + "D9_Moon(宫主)" + ], + "d9_overlap": [], + "d9_saturn_targets": [ + "D9_7宫(Cancer)", + "Jupiter_D9(Libra)", + "Mercury_D9(Leo)" + ], + "event_lord_d9_sign": "Libra" + }, + "summary": "⚠️ 跨层间接 Double Transit (D1+D9),需结合 Dasha 确认", + "transit_date": "1925-07-13" + }, + "event_source": { + "source_grade": "primary", + "url": "https://www.waltdisney.org/blog/who-did-walt-disney-marry" + }, + "functional_benefic_malefic": { + "ascendant": "Virgo", + "effect_on_confidence": "高严谨模式下必须叠加功能性宫主吉凶与自然吉凶;若功能属性与自然属性冲突,应降低置信度或显式标记冲突。", + "functional_benefics": [ + "Mercury", + "Saturn", + "Venus" + ], + "functional_malefics": [ + "Mars", + "Moon", + "Sun" + ], + "functional_neutrals": [ + "Jupiter" + ], + "owned_houses": { + "Jupiter": [ + 4, + 7 + ], + "Mars": [ + 3, + 8 + ], + "Mercury": [ + 1, + 10 + ], + "Moon": [ + 11 + ], + "Saturn": [ + 5, + 6 + ], + "Sun": [ + 12 + ], + "Venus": [ + 2, + 9 + ] + }, + "source": "strict_functional_benefic_malefic_v1", + "status": "used", + "yogakarakas": [ + "Mercury" + ] + }, + "narayana": { + "ad": { + "end_age": 23.6486, + "lord": "Mars", + "sign": "Aries", + "sign_idx": 0, + "start_age": 22.4595, + "years": 1.1892 + }, + "md": { + "end_age": 29.0, + "lord": "Saturn", + "sign": "Capricorn", + "sign_idx": 9, + "start_age": 18.0, + "years": 11 + }, + "pd": { + "end_age": 23.6486, + "lord": "Jupiter", + "sign": "Pisces", + "sign_idx": 11, + "start_age": 23.5041, + "years": 0.1445 + }, + "remaining_years": 5.4 + }, + "vimshottari": { + "antardasha": "Venus", + "mahadasha": "Rahu" + } + }, + "expected_label": "legal_marriage", + "matched_expected_label": true, + "name": "Walt Disney", + "outcome": "Married Lillian Bounds", + "result_class": "strong_hit", + "score": 8, + "signals": [ + "Rahu_occupies_event_house:2", + "Venus_owns_event_houses:[2]", + "Venus_occupies_event_house:5", + "Venus_domain_karaka", + "active_dasha_matches_UL_lord:Venus", + "narayana_lord_owns_event_house:Saturn", + "double_transit_pac_present" + ] + } + ], + "external_oracle_boundary": { + "JHora": "manual_oracle_not_automated", + "PyJHora": "blocked_or_benchmark_only_until_dependency_available", + "VedAstro": "diagnostic_only_unless_official_raw_present", + "jyotishganit": "parity_contract_separate_from_this_event_replay" + }, + "method": { + "boundary": "Positive-event technical activation replay; not scientific predictive accuracy.", + "pre_registered_layers": [ + "D1", + "D9_or_D10", + "UL_or_A10", + "Functional Benefic/Malefic", + "Vimshottari MD/AD", + "Narayana Dasha", + "Double Transit PAC" + ], + "score_thresholds": { + "miss": "<4", + "strong_hit": ">=7", + "weak_hit": "4-6" + }, + "selection": "Rodden A/AA public figures with independently dated public events" + }, + "strict_workflow_batch": { + "blocked_reason": "single_case_strict_workflow_timeout_120s", + "status": "blocked" + }, + "summary": { + "balanced_accuracy": null, + "balanced_accuracy_blocked_reason": "no_verified_negative_control_dates", + "blocked_events": 0, + "blocked_rate": 0.0, + "evaluated_events": 10, + "exact_label_rate": 0.3, + "misses": 2, + "positive_event_recall": 0.8, + "strong_hits": 3, + "total_events": 10, + "weak_hits": 5 + } +} diff --git a/jyotish-app/ai-chat.js b/jyotish-app/ai-chat.js index a15533e4..11aaa57a 100644 --- a/jyotish-app/ai-chat.js +++ b/jyotish-app/ai-chat.js @@ -444,7 +444,6 @@ function buildAISetupGuidance() { function getApiBase() { if (window.JYOTISH_API_BASE) return window.JYOTISH_API_BASE; if (import.meta.env?.VITE_JYOTISH_API_BASE) return import.meta.env.VITE_JYOTISH_API_BASE; - if (window.Capacitor?.isNativePlatform?.()) return localStorage.getItem('jyotish_api_base') || ''; return ''; // 同域部署 } diff --git a/jyotish-app/api-bridge.js b/jyotish-app/api-bridge.js index 66286f2c..2df6e136 100644 --- a/jyotish-app/api-bridge.js +++ b/jyotish-app/api-bridge.js @@ -47,6 +47,7 @@ async function postJson(path, payload, { requireModernChart = false } = {}) { continue; } activeApiBase = base; + if (data?.mode === 'async_submitted') return pollAsyncJob(data, { base }); return data; } catch (error) { lastAttempt = `${base}${path}`; @@ -60,6 +61,22 @@ async function postJson(path, payload, { requireModernChart = false } = {}) { throw lastError || new Error(buildAPIRecoveryMessage(path, '本地 API 未连接', lastAttempt)); } +async function pollAsyncJob(job, { base = activeApiBase, timeoutMs = 120000, intervalMs = 500 } = {}) { + if (!job?.poll_path || !job?.access_token) throw new Error('Async job response missing poll capability'); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resp = await fetch(`${base}${job.poll_path}`, { + headers: { Authorization: `Bearer ${job.access_token}` }, + }); + const data = await parseApiResponse(resp); + if (!resp.ok) throw new Error(buildAPIRecoveryMessage(job.poll_path, data?.error || `Job poll failed (${resp.status})`)); + if (data.status === 'completed') return data.result || data; + if (data.status === 'failed') throw new Error(data.error || 'Async job failed'); + await new Promise(resolve => setTimeout(resolve, intervalMs)); + } + throw new Error('Async job timed out'); +} + async function fetchJson(path) { let lastError = null; let lastAttempt = null; @@ -327,6 +344,14 @@ async function computeRectificationGate(payload) { return postJson('/api/rectification_gate', payload); } +async function computeActiveRectificationQuestions(payload) { + return postJson('/api/active_rectification_questions', payload); +} + +async function computeActiveRectificationScore(payload) { + return postJson('/api/active_rectification_score', payload); +} + async function computeCaseValidation(payload) { return postJson('/api/case_validation', payload); } @@ -495,12 +520,15 @@ window.JyotishAPI = { computeYogas, computeAspects, computeRectificationGate, + computeActiveRectificationQuestions, + computeActiveRectificationScore, computeCaseValidation, getRealCaseRevalidation, computeDivisionalYoga, computeKakshya, computeBhavaBala, computeTransitTriggers, + pollAsyncJob, // AI 解读 aiReading, aiFullReading, diff --git a/jyotish-app/auth.js b/jyotish-app/auth.js index d061ddfd..349c19dd 100644 --- a/jyotish-app/auth.js +++ b/jyotish-app/auth.js @@ -12,7 +12,6 @@ import { escapeAttr, escapeHtml } from './security.js'; const API_BASE = ''; // 同域部署,留空;Capacitor 打包时改为服务器地址 const TOKEN_KEY = 'jyotish_auth_token'; const USER_KEY = 'jyotish_auth_user'; -const API_BASE_KEY = 'jyotish_api_base'; // ============================================================================ // 状态 @@ -61,7 +60,7 @@ export function getUser() { return _user; } export function isLoggedIn() { return !!_token && !!_user; } export function getApiBase() { - return window.JYOTISH_API_BASE || import.meta.env?.VITE_JYOTISH_API_BASE || localStorage.getItem(API_BASE_KEY) || API_BASE; + return window.JYOTISH_API_BASE || import.meta.env?.VITE_JYOTISH_API_BASE || API_BASE; } export function onAuthChange(cb) { _onAuthChange = cb; } diff --git a/jyotish-app/index.html b/jyotish-app/index.html index e1e869cc..0d1cfa42 100644 --- a/jyotish-app/index.html +++ b/jyotish-app/index.html @@ -613,11 +613,19 @@
Prashna 使用提问当下的时刻与当前星盘数据判断具体问题,适合一次只问一个清晰问题。
+Prashna 仅使用提问当下时刻与地点由后端排盘;不使用本命盘替代问事盘。
请填写一个明确问题。
'; + return; + } + if (!timestamp?.value || !lat?.value || !lon?.value || !timezone?.value) { + result.innerHTML = '请填写提问时刻、纬度、经度与 UTC 时区。
'; + return; + } if (questionText.length > 120) { result.innerHTML = '问题请控制在 120 字以内。
'; return; @@ -7198,11 +7210,14 @@ function renderPrashnaTab(chartData) { result.innerHTML = '正在铸造 Prashna 问事盘...
'; try { const data = await window.JyotishAPI?.computePrashna?.({ - question: questionType, question_text: questionText, - planets: chartData?.planets || {}, - asc_degree: chartData?.ascendant?.lon ?? chartData?.ascendant?.degree ?? 15.5, - horary_number: chartData?.kp_horary?.horary_number || '', + question_timestamp: timestamp.value, + lat: Number(lat.value), + lon: Number(lon.value), + timezone: Number(timezone.value), + ayanamsa: 'lahiri', + node_mode: 'mean', + location_convention: 'wgs84', }); if (!data) throw new Error('本地 API 未返回结果'); recordPrashnaWorkflow(data, questionText, questionType); diff --git a/jyotish-app/public/api-bridge.js b/jyotish-app/public/api-bridge.js index 66286f2c..e013df03 100644 --- a/jyotish-app/public/api-bridge.js +++ b/jyotish-app/public/api-bridge.js @@ -327,6 +327,14 @@ async function computeRectificationGate(payload) { return postJson('/api/rectification_gate', payload); } +async function computeActiveRectificationQuestions(payload) { + return postJson('/api/active_rectification_questions', payload); +} + +async function computeActiveRectificationScore(payload) { + return postJson('/api/active_rectification_score', payload); +} + async function computeCaseValidation(payload) { return postJson('/api/case_validation', payload); } @@ -495,6 +503,8 @@ window.JyotishAPI = { computeYogas, computeAspects, computeRectificationGate, + computeActiveRectificationQuestions, + computeActiveRectificationScore, computeCaseValidation, getRealCaseRevalidation, computeDivisionalYoga, diff --git a/jyotish-app/rectification.js b/jyotish-app/rectification.js index 565a43fe..72272fd0 100644 --- a/jyotish-app/rectification.js +++ b/jyotish-app/rectification.js @@ -16,6 +16,9 @@ function fmtOffset(m) { return m === 0 ? t('rect.baseline') : `${m > 0 ? '+' : ' let rectEvents = []; let rectInterviewAnswers = {}; let rectRecommendedEvents = []; +let activeRectificationQuestionnaire = null; +let activeRectificationAnswers = {}; +let activeRectificationScore = null; export function renderRectificationTab(container) { const lang = getLang(); @@ -56,6 +59,27 @@ export function renderRectificationTab(container) { +系统先按出生时间误差生成高信息量选择题;你只需点选答案,再进入下一轮收敛。
+report
', + encoding="utf-8", + ) + report_url = html.as_uri() + blocked: list[str] = [] + try: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + context = browser.new_context(java_script_enabled=False) + page = context.new_page() + page.route( + "**/*", + lambda route: route.continue_() + if is_allowed_report_resource_url(route.request.url, report_url=report_url) + else (blocked.append(route.request.url), route.abort())[1], + ) + page.goto(report_url, wait_until="networkidle") + context.close() + browser.close() + except Exception as exc: # Browser binary/startup is an environment boundary. + return {"scope": "report_renderer_isolation_poc", "status": "blocked", "reason": f"chromium_unavailable:{type(exc).__name__}"} + return { + "scope": "report_renderer_isolation_poc", + "status": "pass" if _ProbeHandler.requests == 0 and len(blocked) >= 2 else "fail", + "http_probe_requests": _ProbeHandler.requests, + "blocked_resource_count": len(blocked), + "blocked_schemes": sorted({url.split(":", 1)[0] for url in blocked}), + } + finally: + server.shutdown() + server.server_close() + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--strict", action="store_true", help="Return nonzero unless the isolation probe passes.") + args = parser.parse_args() + result = run_poc() + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + raise SystemExit(0 if not args.strict or result["status"] == "pass" else 1) diff --git a/scripts/saham_daynight.py b/scripts/saham_daynight.py new file mode 100644 index 00000000..38ebea39 --- /dev/null +++ b/scripts/saham_daynight.py @@ -0,0 +1,41 @@ +"""Swiss Ephemeris sunrise/sunset evidence for Saham day/night formula selection.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import swisseph as swe + + +class SahamDayNightError(ValueError): + pass + + +def determine_daytime(moment: datetime, *, lat: float, lon: float, tz: float) -> dict[str, Any]: + if not -90 <= float(lat) <= 90 or not -180 <= float(lon) <= 180: + raise SahamDayNightError("invalid WGS84 latitude/longitude") + local = moment.replace(tzinfo=None) + jd = swe.julday(local.year, local.month, local.day, local.hour + local.minute / 60 + local.second / 3600 - float(tz)) + geopos = (float(lon), float(lat), 0.0) + rise_status, rise = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_RISE, geopos) + set_status, sunset = swe.rise_trans(jd - 1.0, swe.SUN, swe.CALC_SET, geopos) + if rise_status != 0 or set_status != 0: + raise SahamDayNightError("sunrise_or_sunset_unavailable_for_location_date") + sunrise_jd, sunset_jd = rise[0], sunset[0] + # Normalize the next daily events around the queried instant. + while sunrise_jd > jd: + sunrise_jd -= 1.0 + while sunset_jd > jd: + sunset_jd -= 1.0 + is_day = sunrise_jd <= jd < sunset_jd if sunrise_jd < sunset_jd else not (sunset_jd <= jd < sunrise_jd) + return { + "scope": "saham_daynight_swiss", + "status": "computed", + "is_daytime": is_day, + "julian_day_ut": jd, + "sunrise_jd_ut": sunrise_jd, + "sunset_jd_ut": sunset_jd, + "method": "swisseph.rise_trans", + "boundary": "Formula-specific +30 degree exceptions must be applied by the Saham rule layer, not inferred from house placement.", + } diff --git a/scripts/skill_experience.py b/scripts/skill_experience.py new file mode 100644 index 00000000..06f67834 --- /dev/null +++ b/scripts/skill_experience.py @@ -0,0 +1,135 @@ +"""Stable user-facing contracts shared by Skill and MCP entry points.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from scripts.active_rectification_questions import build_questionnaire, score_answers +from scripts.diagnose_external_engine_adapters import build_report as adapter_report + + +ROOT = Path(__file__).resolve().parents[1] +_REQUIRED_BIRTH_FIELDS = ("year", "month", "day", "hour", "minute", "lat", "lon") + + +def _missing_birth_fields(payload: dict[str, Any]) -> list[str]: + return [field for field in _REQUIRED_BIRTH_FIELDS if payload.get(field) is None] + + +def build_skill_onboarding(payload: dict[str, Any] | None = None) -> dict[str, Any]: + """Return the next minimal user action; never infer missing birth inputs.""" + payload = payload or {} + missing = _missing_birth_fields(payload) + if missing: + return { + "scope": "skill_onboarding", + "status": "needs_birth_data", + "entry_mode": "pending", + "missing_fields": missing, + "next_action": "collect_birth_data", + "input_template": { + "year": "YYYY", "month": "MM", "day": "DD", + "hour": "0-23", "minute": "0-59", "lat": "decimal", "lon": "decimal", + "time_uncertainty_minutes": "optional; use when birth time is approximate", + "question": "optional; career, relationship, wealth, health, general", + }, + } + + uncertainty = int(payload.get("time_uncertainty_minutes") or 0) + if uncertainty > 0: + birth_time = ( + f"{int(payload['year']):04d}-{int(payload['month']):02d}-{int(payload['day']):02d} " + f"{int(payload['hour']):02d}:{int(payload['minute']):02d}" + ) + questionnaire = build_questionnaire(birth_time, uncertainty_minutes=uncertainty) + first_question = questionnaire.get("questions", [{}])[0] + return { + "scope": "skill_onboarding", + "status": "ready", + "entry_mode": "rectification", + "next_action": "run_rectification_questionnaire", + "first_question": first_question, + "questionnaire": questionnaire, + } + + return { + "scope": "skill_onboarding", + "status": "ready", + "entry_mode": "direct_chart", + "next_action": "run_consultation_workflow", + "question": str(payload.get("question") or ""), + } + + +def build_rectification_questionnaire(payload: dict[str, Any]) -> dict[str, Any]: + """Build the active-choice questionnaire from a minimal approximate time.""" + required = ("year", "month", "day", "hour", "minute") + missing = [field for field in required if payload.get(field) is None] + if missing: + raise ValueError(f"missing rectification fields: {', '.join(missing)}") + birth_time = ( + f"{int(payload['year']):04d}-{int(payload['month']):02d}-{int(payload['day']):02d} " + f"{int(payload['hour']):02d}:{int(payload['minute']):02d}" + ) + uncertainty = max(int(payload.get("time_uncertainty_minutes") or 30), 1) + step = max(int(payload.get("step_minutes") or 1), 1) + return build_questionnaire(birth_time, uncertainty_minutes=uncertainty, step_minutes=step) + + +def score_rectification_answers(questionnaire: dict[str, Any], answers: dict[str, str]) -> dict[str, Any]: + """Score user choices; preserves the boundary against false minute precision.""" + return score_answers(questionnaire, answers or {}) + + +def build_skill_doctor() -> dict[str, Any]: + """Expose readiness, not an unsupported promise that all engines are usable.""" + assets = { + "skill_instructions": (ROOT / "SKILL.md").is_file(), + "mcp_server": (ROOT / "mcp_server.py").is_file(), + "native_engine": (ROOT / "scripts" / "jyotish_engine.py").is_file(), + "unified_orchestrator": (ROOT / "scripts" / "unified_consultation_orchestrator.py").is_file(), + } + adapters = adapter_report() + adapter_status = adapters.get("status", "blocked") + return { + "scope": "skill_doctor", + "status": "ready" if all(assets.values()) and adapter_status == "ready" else "degraded", + "core_assets": assets, + "external_engine_adapters": adapters, + "boundary": "Readiness only. An available adapter is not external raw-oracle verification.", + } + + +def _vedastro_status(result: dict[str, Any]) -> str: + engines = result.get("external_engine_cross_validation") + if isinstance(engines, dict): + engines = engines.get("engines") + vedastro = engines.get("VedAstro") if isinstance(engines, dict) else None + if isinstance(vedastro, dict): + return str(vedastro.get("status") or "") + return "" + + +def summarize_execution_status(result: dict[str, Any] | None) -> dict[str, Any]: + """Normalize official/local evidence state for every conversational surface.""" + result = result or {} + fallback_reason = str(result.get("fallback_reason") or "") + vedastro = _vedastro_status(result) + raw_status = str(result.get("official_evidence_status") or "") + if raw_status == "official_verified" or vedastro == "official_verified": + official, source = "official_verified", "official_raw" + elif fallback_reason or vedastro in {"local_fallback", "official_blocked", "blocked"}: + official, source = "official_blocked", "local_fallback" + else: + official, source = "official_not_requested", "local_or_unverified" + return { + "scope": "execution_status", + "official_evidence_status": official, + "calculation_source": source, + "fallback_reason": fallback_reason or None, + "allowed_claims": ["official_verified", "official_blocked", "local_fallback"], + "claim_boundary": ( + "Only official_verified permits claims that VedAstro official raw evidence was used." + ), + } diff --git a/scripts/skill_release_package.py b/scripts/skill_release_package.py index e69065fd..0ff81548 100644 --- a/scripts/skill_release_package.py +++ b/scripts/skill_release_package.py @@ -110,6 +110,12 @@ REQUIRED_CONTRACTS = [ "references/oracle/western_oracle_adapter_contract.md", "scripts/user_invocation_acceptance_check.py", "scripts/diagnose_external_engine_adapters.py", + "scripts/external_oracle_raw_import.py", + "scripts/three_engine_parity_runner.py", + "scripts/three_engine_parity_replay_validator.py", + "scripts/pyjhora_parity_summary.py", + "scripts/western_chart_engine.py", + "scripts/western_timing_engine.py", ] diff --git a/scripts/strict_evidence_service.py b/scripts/strict_evidence_service.py new file mode 100644 index 00000000..86a1b90d --- /dev/null +++ b/scripts/strict_evidence_service.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Stable strict-evidence service boundary. + +This module is the import target for engine/API code. The current implementation +delegates to the legacy MCP implementation while the large helper stack is being +extracted out of `mcp_server.py`. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + + +def collect_strict_evidence(route: str, result: dict[str, Any]) -> dict[str, Any]: + from mcp_server import _collect_strict_evidence + + return _collect_strict_evidence(route, result) + + +def existing_interpretation_source_pack() -> dict[str, Any]: + from mcp_server import _existing_interpretation_source_pack + + return _existing_interpretation_source_pack() diff --git a/scripts/tajika_kernel.py b/scripts/tajika_kernel.py new file mode 100644 index 00000000..14c01431 --- /dev/null +++ b/scripts/tajika_kernel.py @@ -0,0 +1,79 @@ +"""Strict seven-planet Tajika aspect kernel. + +This module deliberately exposes only the auditable interaction layer. Named +Tajika chains remain blocked until their classical definitions have golden +cases; it never treats nodes as Tajika planets. +""" + +from __future__ import annotations + +from typing import Any + + +SEVEN_PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") +DEEPTAMSA = {"Sun": 15.0, "Moon": 12.0, "Mars": 8.0, "Mercury": 7.0, "Jupiter": 9.0, "Venus": 7.0, "Saturn": 9.0} +ASPECT_ANGLES = (0.0, 60.0, 90.0, 120.0, 180.0) + + +def _signed_angle(value: float) -> float: + return (value + 180.0) % 360.0 - 180.0 + + +def _nearest_aspect(delta: float) -> tuple[float, float]: + candidates = [] + for aspect in ASPECT_ANGLES: + for target in ({0.0} if aspect in (0.0, 180.0) else {aspect, -aspect}): + candidates.append((target, _signed_angle(delta - target))) + return min(candidates, key=lambda item: abs(item[1])) + + +def calculate_tajika_interactions(planets: dict[str, dict[str, Any]]) -> dict[str, Any]: + missing = [planet for planet in SEVEN_PLANETS if planet not in planets or "longitude" not in planets[planet] or "speed" not in planets[planet]] + if missing: + return { + "scope": "tajika_seven_planet_kernel", + "status": "blocked", + "reason": "longitude_and_speed_required_for_all_seven_planets", + "missing": missing, + "nodes_excluded": True, + } + interactions = [] + for index, left in enumerate(SEVEN_PLANETS): + for right in SEVEN_PLANETS[index + 1:]: + left_lon, right_lon = float(planets[left]["longitude"]) % 360, float(planets[right]["longitude"]) % 360 + aspect, residual = _nearest_aspect(right_lon - left_lon) + orb = (DEEPTAMSA[left] + DEEPTAMSA[right]) / 2.0 + if abs(residual) > orb: + continue + relative_speed = float(planets[right]["speed"]) - float(planets[left]["speed"]) + future_residual = _signed_angle(residual + relative_speed) + applying = abs(future_residual) < abs(residual) + interactions.append({ + "planets": [left, right], + "aspect": abs(aspect), + "residual": round(residual, 6), + "average_deeptamsa": orb, + "motion": "applying" if applying else "separating", + "within_deeptamsa": True, + }) + return { + "scope": "tajika_seven_planet_kernel", + "status": "partial", + "nodes_excluded": True, + "interactions": interactions, + "candidate_yogas": [ + { + "name": "Ithasala_candidate" if row["motion"] == "applying" else "Easarapha_candidate", + "planets": row["planets"], + "aspect": row["aspect"], + "residual": row["residual"], + "average_deeptamsa": row["average_deeptamsa"], + "motion": row["motion"], + "rule_source": "references/tajika-yoga-complete-guide.md#2.1-2.2", + "status": "partial", + } + for row in interactions + ], + "blocked_named_yogas": ["Nakta", "Yamaya", "Manahoo", "Kamboola", "Ithasala/Easarapha adjudication"], + "boundary": "Candidate labels are derived only from seven-planet aspect, Deeptamsa and applying/separating evidence. Full named-yoga chains and event verdicts remain blocked pending classic golden cases.", + } diff --git a/scripts/three_engine_parity_replay_validator.py b/scripts/three_engine_parity_replay_validator.py index 5d3a9ba5..39081435 100644 --- a/scripts/three_engine_parity_replay_validator.py +++ b/scripts/three_engine_parity_replay_validator.py @@ -5,6 +5,7 @@ from __future__ import annotations import argparse +import hashlib import json from pathlib import Path from typing import Any @@ -12,6 +13,33 @@ from typing import Any REQUIRED_ENGINES = {"VedAstro", "PyJHora_JHora", "jyotishganit"} REQUIRED_ROW_FIELDS = {"section", "field", "local_value", "oracle_values", "status"} VALID_ROW_STATUSES = {"match", "mismatch", "blocked", "not_comparable"} +RAW_VERIFIED_STATUSES = {"verified", "official_verified", "imported"} + + +def _artifact_errors(engine: str, payload: Any, manifest_dir: Path) -> list[dict[str, Any]]: + if not isinstance(payload, dict): + return [{"field": f"engines.{engine}", "error": "not_object"}] + if payload.get("status") not in RAW_VERIFIED_STATUSES: + return [] + raw_path = payload.get("official_raw_response_path") or payload.get("raw_output_path") + artifact_hash = payload.get("artifact_hash") + errors: list[dict[str, Any]] = [] + if not isinstance(raw_path, str) or not raw_path: + errors.append({"field": f"engines.{engine}.raw_output_path", "error": "required_for_verified_status"}) + return errors + if not isinstance(artifact_hash, str) or len(artifact_hash) != 64: + errors.append({"field": f"engines.{engine}.artifact_hash", "error": "sha256_required_for_verified_status"}) + return errors + artifact_path = (manifest_dir / raw_path).resolve() + if not artifact_path.is_file(): + errors.append({"field": f"engines.{engine}.raw_output_path", "error": "missing_artifact"}) + return errors + actual_hash = hashlib.sha256(artifact_path.read_bytes()).hexdigest() + if actual_hash != artifact_hash: + errors.append({"field": f"engines.{engine}.artifact_hash", "error": "hash_mismatch"}) + if not isinstance(payload.get("settings"), dict): + errors.append({"field": f"engines.{engine}.settings", "error": "required_for_verified_status"}) + return errors def _row_errors(row: Any, index: int) -> list[dict[str, Any]]: @@ -37,6 +65,8 @@ def validate_manifest(path: str | Path) -> dict[str, Any]: missing_engines = sorted(REQUIRED_ENGINES - set(engines)) for engine in missing_engines: errors.append({"field": f"engines.{engine}", "error": "missing"}) + for engine, payload in engines.items(): + errors.extend(_artifact_errors(engine, payload, manifest_path.parent)) if not isinstance(rows, list): rows = [] @@ -89,9 +119,11 @@ def validate_manifest(path: str | Path) -> dict[str, Any]: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("manifest", nargs="?", default="references/oracle/three_engine_parity_replay_manifest.json") + parser.add_argument("--require-pass", action="store_true", help="Return nonzero unless all comparison rows pass.") args = parser.parse_args(argv) - print(json.dumps(validate_manifest(args.manifest), ensure_ascii=False, indent=2, sort_keys=True)) - return 0 + report = validate_manifest(args.manifest) + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if not args.require_pass or report["status"] == "pass" else 1 if __name__ == "__main__": diff --git a/scripts/three_engine_parity_runner.py b/scripts/three_engine_parity_runner.py new file mode 100644 index 00000000..3a01e87b --- /dev/null +++ b/scripts/three_engine_parity_runner.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +"""Capture a public same-chart parity packet without overstating oracle closure.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from datetime import datetime +from pathlib import Path +from typing import Any + +from domain_calculation_service import compute_chart + + +ROOT = Path(__file__).resolve().parents[1] +PYJHORA_ARTIFACT = ROOT / "references/oracle/artifacts/pyjhora_steve_jobs_dasha_stdout_20260627.txt" +JYOTISHGANIT_ROOT = ROOT / "references/open_source_sources/jyotishganit" +VEDASTRO_ARTIFACT_DIR = ROOT / "scratch/local/vedastro_adapter" + +PUBLIC_CASE = { + "case_id": "steve_jobs_public_1955_lahiri", + "year": 1955, + "month": 2, + "day": 24, + "hour": 19, + "minute": 15, + "second": 0, + "lat": 37.7749, + "lon": -122.4194, + "tz": -8.0, + "ayanamsa": "lahiri", + "node_mode": "mean", +} +PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn") +SIGNS = ("Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces") +LONGITUDE_TOLERANCE_DEGREES = 0.02 + + +def _write_json(path: Path, value: dict[str, Any]) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True), encoding="utf-8") + return path + + +def _capture_jyotishganit_raw(output_dir: Path) -> tuple[dict[str, Any], str]: + sys.path.insert(0, str(JYOTISHGANIT_ROOT)) + try: + from jyotishganit import calculate_birth_chart, get_birth_chart_json + + chart = calculate_birth_chart( + datetime( + PUBLIC_CASE["year"], + PUBLIC_CASE["month"], + PUBLIC_CASE["day"], + PUBLIC_CASE["hour"], + PUBLIC_CASE["minute"], + PUBLIC_CASE["second"], + ), + PUBLIC_CASE["lat"], + PUBLIC_CASE["lon"], + PUBLIC_CASE["tz"], + location_name="San Francisco, CA", + name="Steve Jobs (public benchmark)", + ) + raw = get_birth_chart_json(chart) + path = _write_json(output_dir / "jyotishganit_raw.json", raw) + return raw, str(path) + except Exception as exc: + return {"error": f"{exc.__class__.__name__}: {exc}"}, "" + finally: + try: + sys.path.remove(str(JYOTISHGANIT_ROOT)) + except ValueError: + pass + + +def _capture_pyjhora_structured_d1(output_dir: Path) -> tuple[dict[str, Any], str]: + try: + import contextlib + import importlib + import io + + with contextlib.redirect_stdout(io.StringIO()): + utils = importlib.import_module("jhora.utils") + charts = importlib.import_module("jhora.horoscope.chart.charts") + drik = importlib.import_module("jhora.panchanga.drik") + + jd = utils.julian_day_number( + (PUBLIC_CASE["year"], PUBLIC_CASE["month"], PUBLIC_CASE["day"]), + (PUBLIC_CASE["hour"], PUBLIC_CASE["minute"], PUBLIC_CASE["second"]), + ) + drik.set_ayanamsa_mode("LAHIRI", jd=jd) + place = drik.Place("San Francisco, CA", PUBLIC_CASE["lat"], PUBLIC_CASE["lon"], PUBLIC_CASE["tz"]) + with contextlib.redirect_stdout(io.StringIO()): + raw = charts.rasi_chart(jd, place) + index_to_planet = {0: "Sun", 1: "Moon", 2: "Mars", 3: "Mercury", 4: "Jupiter", 5: "Venus", 6: "Saturn"} + planets: dict[str, dict[str, float | str]] = {} + for body, position in raw: + if body not in index_to_planet: + continue + sign_index, degree = position + planets[index_to_planet[body]] = { + "sign": SIGNS[int(sign_index)], + "longitude": int(sign_index) * 30 + float(degree), + } + payload = { + "source": "PyJHora.jhora.horoscope.chart.charts.rasi_chart", + "settings": {"ayanamsa": "LAHIRI", "jd_input": "local_birth_time", "node_mode": "PyJHora default"}, + "raw": raw, + "planets": planets, + } + path = _write_json(output_dir / "pyjhora_structured_d1.json", payload) + return payload, str(path) + except Exception as exc: + return {"error": f"{exc.__class__.__name__}: {exc}"}, "" + + +def _vedastro_state(*, allow_network: bool) -> dict[str, Any]: + if not allow_network: + return { + "status": "blocked", + "official_raw_response_path": "", + "reason": "network_disabled_for_public_replay", + } + artifact = _latest_vedastro_official_raw_artifact() + if artifact: + return { + "status": "official_verified", + "official_raw_response_path": str(artifact), + "artifact_hash": hashlib.sha256(artifact.read_bytes()).hexdigest(), + "settings": {"ayanamsa": PUBLIC_CASE["ayanamsa"], "node_mode": PUBLIC_CASE["node_mode"]}, + "reason": "imported_latest_official_full_snapshot_artifact", + } + return { + "status": "blocked", + "official_raw_response_path": "", + "reason": "official_runner_requires_explicit_raw_capture_workflow", + } + + +def _latest_vedastro_official_raw_artifact() -> Path | None: + if not VEDASTRO_ARTIFACT_DIR.exists(): + return None + candidates: list[Path] = [] + for path in VEDASTRO_ARTIFACT_DIR.glob("official_full_snapshot-*.json"): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError: + continue + raw = payload.get("official_raw_response") or payload.get("raw_response") + source = str(raw.get("source") or "") if isinstance(raw, dict) else "" + if ( + payload.get("status") == "ok" + and source.startswith("vedastro_official") + and _artifact_matches_public_case(payload) + ): + candidates.append(path) + if not candidates: + return None + return max(candidates, key=lambda item: item.stat().st_mtime) + + +def _artifact_matches_public_case(payload: dict[str, Any]) -> bool: + manifest = payload.get("request_manifest") if isinstance(payload.get("request_manifest"), dict) else {} + text = json.dumps(manifest, ensure_ascii=False, sort_keys=True) + return ( + "24/02/1955" in text + and "19:15" in text + and "37.7749" in text + and "-122.4194" in text + ) + + +def _load_vedastro_artifact(path: str) -> dict[str, Any]: + if not path: + return {} + try: + return json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + +def _vedastro_d1(artifact: dict[str, Any], planet: str) -> dict[str, Any]: + try: + payload = artifact["snapshot_sections"]["chart_core"][planet]["Payload"]["AllPlanetData"] + return { + "sign": payload["PlanetRasiD1Sign"]["Name"], + "longitude": float(payload["PlanetNirayanaLongitude"]["TotalDegrees"]), + } + except (KeyError, TypeError, ValueError): + return {} + + +def _jyotishganit_d1(raw: dict[str, Any], planet: str) -> dict[str, Any]: + for house in (raw.get("d1Chart") or {}).get("houses") or []: + for occupant in house.get("occupants") or []: + if occupant.get("celestialBody") != planet: + continue + sign = occupant.get("sign") + degree = occupant.get("signDegrees") + if sign not in SIGNS or degree is None: + return {} + return {"sign": sign, "longitude": SIGNS.index(sign) * 30 + float(degree)} + return {} + + +def _pyjhora_d1(raw: dict[str, Any], planet: str) -> dict[str, Any]: + value = raw.get("planets", {}).get(planet) + return value if isinstance(value, dict) else {} + + +def _d1_comparison_rows( + local: dict[str, Any], + vedastro_artifact: dict[str, Any], + jyotishganit_raw: dict[str, Any], + pyjhora_raw: dict[str, Any], +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for planet in PLANETS: + local_planet = local.get("planets", {}).get(planet) or {} + vedastro = _vedastro_d1(vedastro_artifact, planet) + jyotishganit = _jyotishganit_d1(jyotishganit_raw, planet) + pyjhora = _pyjhora_d1(pyjhora_raw, planet) + local_sign = local_planet.get("sign") + sign_values = { + "VedAstro": vedastro.get("sign"), + "PyJHora_JHora": pyjhora.get("sign"), + "jyotishganit": jyotishganit.get("sign"), + } + comparable_signs = [value for value in sign_values.values() if value is not None] + rows.append({ + "section": "D1", + "field": f"{planet}.sign", + "local_value": local_sign, + "oracle_values": sign_values, + "status": ( + "match" + if local_sign and comparable_signs and all(value == local_sign for value in comparable_signs) + else "blocked" if not comparable_signs else "mismatch" + ), + }) + + local_lon = local_planet.get("lon") + longitude_values = { + "VedAstro": vedastro.get("longitude"), + "PyJHora_JHora": pyjhora.get("longitude"), + "jyotishganit": jyotishganit.get("longitude"), + } + comparable = [value for value in longitude_values.values() if isinstance(value, (int, float))] + rows.append({ + "section": "D1", + "field": f"{planet}.longitude", + "local_value": local_lon, + "oracle_values": longitude_values, + "status": ( + "match" + if isinstance(local_lon, (int, float)) + and comparable + and all(abs(value - local_lon) <= LONGITUDE_TOLERANCE_DEGREES for value in comparable) + else "blocked" if not comparable else "mismatch" + ), + }) + return rows + + +def build_public_case_replay(*, output_dir: Path, allow_vedastro_network: bool = False) -> dict[str, Any]: + output_dir.mkdir(parents=True, exist_ok=True) + local = compute_chart(PUBLIC_CASE) + jyotishganit_raw, jyotishganit_path = _capture_jyotishganit_raw(output_dir) + pyjhora_raw, pyjhora_path = _capture_pyjhora_structured_d1(output_dir) + pyjhora_available = PYJHORA_ARTIFACT.is_file() + vedastro = _vedastro_state(allow_network=allow_vedastro_network) + vedastro_artifact = _load_vedastro_artifact(vedastro.get("official_raw_response_path", "")) + rows = _d1_comparison_rows(local, vedastro_artifact, jyotishganit_raw, pyjhora_raw) + [ + { + "section": "Panchanga", + "field": "raw_capture", + "local_value": None, + "oracle_values": { + "VedAstro": None, + "PyJHora_JHora": "structured_d1_captured" if pyjhora_path else "dasha_only_artifact", + "jyotishganit": "captured" if jyotishganit_path else None, + }, + "status": "not_comparable", + "reason": "three_engine_scope_does_not_share_this_normalized_field", + }, + ] + has_blocked = any(row.get("status") == "blocked" for row in rows) + has_mismatch = any(row.get("status") == "mismatch" for row in rows) + has_required_raw = vedastro.get("status") == "official_verified" and bool(pyjhora_path) and bool(jyotishganit_path) + blocked_reason = ( + "official_vedastro_raw_missing_or_unverified" + if vedastro.get("status") != "official_verified" + else "some_comparison_rows_blocked" + if has_blocked + else "comparison_rows_mismatch" + if has_mismatch + else "none" + ) + report_status = ( + "blocked" + if not bool(jyotishganit_path) + else "mismatch" + if has_mismatch + else "partial" + if not has_required_raw or has_blocked + else "pass" + ) + pyjhora_artifact_path = pyjhora_path or (str(PYJHORA_ARTIFACT) if pyjhora_available else "") + report = { + "case_id": PUBLIC_CASE["case_id"], + "birth_data_policy": "public_case_only", + "status": report_status, + "tested": vedastro.get("status") == "official_verified" and bool(rows), + "blocked_reason": blocked_reason, + "engines": { + "VedAstro": vedastro, + "PyJHora_JHora": { + "status": "structured_captured" if pyjhora_path else "raw_imported" if pyjhora_available else "blocked", + "raw_output_path": pyjhora_artifact_path, + "artifact_hash": hashlib.sha256(Path(pyjhora_artifact_path).read_bytes()).hexdigest() if pyjhora_artifact_path else "", + "settings": {"ayanamsa": "LAHIRI", "node_mode": "PyJHora default"}, + }, + "jyotishganit": { + "status": "raw_captured" if jyotishganit_path else "blocked", + "raw_output_path": jyotishganit_path, + "error": jyotishganit_raw.get("error") if isinstance(jyotishganit_raw, dict) else None, + }, + }, + "local": { + "result_hash": local["result_hash"], + "calculation_contract": local["calculation_contract"], + }, + "comparison_rows": rows, + "runtime_boundary": ( + "This packet has real public raw artifacts and may include VedAstro official raw evidence, " + "and normalized D1 planet sign/longitude parity is tested. Non-D1 scopes still require separate gates." + ), + } + _write_json(output_dir / "three_engine_parity_replay.json", report) + return report + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--output-dir", default="scratch/local/three_engine_parity") + parser.add_argument("--allow-vedastro-network", action="store_true") + args = parser.parse_args() + report = build_public_case_replay( + output_dir=ROOT / args.output_dir, + allow_vedastro_network=args.allow_vedastro_network, + ) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/western_chart_engine.py b/scripts/western_chart_engine.py new file mode 100644 index 00000000..11bbce20 --- /dev/null +++ b/scripts/western_chart_engine.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""Native, auditable tropical Western natal-chart calculation. + +This module deliberately uses the project's existing Swiss Ephemeris binding +instead of bundling an AGPL Western astrology library. It is a calculation +layer only: transits, progressions, solar arcs, returns, and interpretation +remain separate evidence layers. +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timedelta, timezone as fixed_timezone +from typing import Any +from zoneinfo import ZoneInfo + +import swisseph as swe + +try: + from western_evidence_packet import build_western_evidence_packet +except ImportError: # pragma: no cover - package import path + from scripts.western_evidence_packet import build_western_evidence_packet + + +_PLANETS = { + "sun": swe.SUN, + "moon": swe.MOON, + "mercury": swe.MERCURY, + "venus": swe.VENUS, + "mars": swe.MARS, + "jupiter": swe.JUPITER, + "saturn": swe.SATURN, + "uranus": swe.URANUS, + "neptune": swe.NEPTUNE, + "pluto": swe.PLUTO, + "true_node": swe.TRUE_NODE, +} +_SIGNS = ( + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +) +_ELEMENTS = { + "Aries": "fire", "Leo": "fire", "Sagittarius": "fire", + "Taurus": "earth", "Virgo": "earth", "Capricorn": "earth", + "Gemini": "air", "Libra": "air", "Aquarius": "air", + "Cancer": "water", "Scorpio": "water", "Pisces": "water", +} +_MODES = { + "Aries": "cardinal", "Cancer": "cardinal", "Libra": "cardinal", "Capricorn": "cardinal", + "Taurus": "fixed", "Leo": "fixed", "Scorpio": "fixed", "Aquarius": "fixed", + "Gemini": "mutable", "Virgo": "mutable", "Sagittarius": "mutable", "Pisces": "mutable", +} +_RULERS = { + "Aries": "mars", "Taurus": "venus", "Gemini": "mercury", "Cancer": "moon", + "Leo": "sun", "Virgo": "mercury", "Libra": "venus", "Scorpio": "mars", + "Sagittarius": "jupiter", "Capricorn": "saturn", "Aquarius": "saturn", "Pisces": "jupiter", +} +_ASPECTS = {"conjunction": 0.0, "sextile": 60.0, "square": 90.0, "trine": 120.0, "opposition": 180.0} +_ORB = {"sun": 8.0, "moon": 8.0, "ascendant": 5.0, "mc": 5.0} + + +def _longitude(value: float) -> float: + return float(value) % 360.0 + + +def _point(longitude: float, *, house: int | None = None, speed: float | None = None) -> dict[str, Any]: + longitude = _longitude(longitude) + point = { + "longitude": round(longitude, 6), + "sign": _SIGNS[int(longitude // 30)], + "degree_in_sign": round(longitude % 30, 6), + } + if house is not None: + point["house"] = house + if speed is not None: + point["speed_longitude"] = round(float(speed), 8) + point["retrograde"] = bool(speed < 0) + return point + + +def _house_for_longitude(longitude: float, cusps: list[float]) -> int: + """Return Placidus house by testing each cusp-to-next-cusp circular arc.""" + longitude = _longitude(longitude) + for index, cusp in enumerate(cusps): + start = _longitude(cusp) + end = _longitude(cusps[(index + 1) % 12]) + span = (end - start) % 360.0 + if (longitude - start) % 360.0 < span: + return index + 1 + raise RuntimeError("Unable to assign longitude to a house") # pragma: no cover + + +def _orb_for(left: str, right: str) -> float: + return min(_ORB.get(left, 6.0), _ORB.get(right, 6.0)) + + +def _aspects(points: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + names = list(points) + found: list[dict[str, Any]] = [] + for index, left in enumerate(names): + for right in names[index + 1:]: + separation = abs(points[left]["longitude"] - points[right]["longitude"]) + separation = min(separation, 360.0 - separation) + allowed_orb = _orb_for(left, right) + for aspect, exact in _ASPECTS.items(): + orb = abs(separation - exact) + if orb <= allowed_orb: + found.append({ + "left": left, + "right": right, + "aspect": aspect, + "exact_degrees": exact, + "separation": round(separation, 6), + "orb": round(orb, 6), + "allowed_orb": allowed_orb, + }) + return sorted(found, key=lambda row: (row["orb"], row["left"], row["right"])) + + +def _distribution(planets: dict[str, dict[str, Any]]) -> dict[str, dict[str, int]]: + elements = {name: 0 for name in ("fire", "earth", "air", "water")} + modes = {name: 0 for name in ("cardinal", "fixed", "mutable")} + for planet in planets.values(): + elements[_ELEMENTS[planet["sign"]]] += 1 + modes[_MODES[planet["sign"]]] += 1 + return {"elements": elements, "modes": modes} + + +def _ruler_chains(cusps: list[float], planets: dict[str, dict[str, Any]]) -> dict[str, list[str]]: + chains: dict[str, list[str]] = {} + for house, cusp in enumerate(cusps, start=1): + sign = _SIGNS[int(_longitude(cusp) // 30)] + chain: list[str] = [] + current = _RULERS[sign] + for _ in range(12): + if current in chain: + break + chain.append(current) + current = _RULERS[planets[current]["sign"]] + chains[str(house)] = chain + return chains + + +def _birth_zone(value: str | float | int): + if isinstance(value, str): + return ZoneInfo(value), value + offset = float(value) + return fixed_timezone(timedelta(hours=offset)), f"UTC{offset:+g}" + + +def build_tropical_natal_chart( + *, + year: int, + month: int, + day: int, + hour: int, + minute: int, + latitude: float, + longitude: float, + timezone: str | float | int, + second: int = 0, + house_system: str = "P", +) -> dict[str, Any]: + """Calculate a tropical natal chart from local birth data using Swiss Ephemeris.""" + if len(house_system) != 1: + raise ValueError("house_system must be a single Swiss Ephemeris house-system letter") + zone, timezone_label = _birth_zone(timezone) + local = datetime(year, month, day, hour, minute, second, tzinfo=zone) + utc = local.astimezone(ZoneInfo("UTC")) + jd_ut = swe.julday(utc.year, utc.month, utc.day, utc.hour + utc.minute / 60 + utc.second / 3600) + flags = swe.FLG_SWIEPH | swe.FLG_SPEED + cusps_raw, ascmc = swe.houses_ex(jd_ut, float(latitude), float(longitude), house_system.encode("ascii"), 0) + cusps = [_longitude(cusp) for cusp in cusps_raw] + planets: dict[str, dict[str, Any]] = {} + for name, planet_id in _PLANETS.items(): + values, _ = swe.calc_ut(jd_ut, planet_id, flags) + position = _point(values[0], house=_house_for_longitude(values[0], cusps), speed=values[3]) + planets[name] = position + angles = { + "ascendant": _point(ascmc[0]), + "mc": _point(ascmc[1]), + "descendant": _point(ascmc[0] + 180.0), + "ic": _point(ascmc[1] + 180.0), + } + aspect_points = {**planets, "ascendant": angles["ascendant"], "mc": angles["mc"]} + natal = { + "ascendant": angles["ascendant"], + "mc": angles["mc"], + "angles": angles, + "planets": planets, + "houses": [{"house": index + 1, "cusp": _point(cusp)} for index, cusp in enumerate(cusps)], + "aspects": _aspects(aspect_points), + "distribution": _distribution(planets), + "house_ruler_chains": _ruler_chains(cusps, planets), + } + return { + "source_engine": "pyswisseph_tropical", + "engine_version": getattr(swe, "version", "unknown"), + "zodiac": "tropical", + "house_system": house_system.upper(), + "calculation_contract": { + "birth_timezone": timezone_label, + "local_birth_time": local.isoformat(), + "utc_birth_time": utc.isoformat(), + "julian_day_ut": round(jd_ut, 8), + "latitude": float(latitude), + "longitude": float(longitude), + "ephemeris": "Swiss Ephemeris via pyswisseph", + }, + "natal": natal, + "boundary": "Natal tropical calculation only; it does not calculate transits, progressions, solar arcs, returns, or interpretation.", + } + + +def build_tropical_western_evidence_packet(*, route_packet: dict[str, Any], **birth: Any) -> dict[str, Any]: + """Wrap direct natal calculation in the existing cross-system packet contract.""" + chart = build_tropical_natal_chart(**birth) + packet = build_western_evidence_packet( + route_packet=route_packet, + natal=chart["natal"], + timing_techniques={}, + signals=[], + ) + packet.update({ + "source_engine": chart["source_engine"], + "calculation": { + "status": "used", + "source_engine": chart["source_engine"], + "zodiac": chart["zodiac"], + "house_system": chart["house_system"], + "contract": chart["calculation_contract"], + }, + "native_chart": chart, + "boundary": chart["boundary"], + }) + return packet + + +def main() -> int: + parser = argparse.ArgumentParser(description="Calculate an auditable tropical Western natal chart.") + for name, kind in (("year", int), ("month", int), ("day", int), ("hour", int), ("minute", int)): + parser.add_argument(f"--{name}", required=True, type=kind) + parser.add_argument("--lat", required=True, type=float) + parser.add_argument("--lon", required=True, type=float) + parser.add_argument("--timezone", required=True) + parser.add_argument("--second", type=int, default=0) + parser.add_argument("--house-system", default="P") + args = parser.parse_args() + print(json.dumps(build_tropical_natal_chart( + year=args.year, month=args.month, day=args.day, hour=args.hour, minute=args.minute, second=args.second, + latitude=args.lat, longitude=args.lon, timezone=args.timezone, house_system=args.house_system, + ), ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/scripts/western_timing_engine.py b/scripts/western_timing_engine.py new file mode 100644 index 00000000..82dc0e48 --- /dev/null +++ b/scripts/western_timing_engine.py @@ -0,0 +1,449 @@ +#!/usr/bin/env python3 +"""Auditable tropical transit and solar-return evidence calculations.""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import Any +from zoneinfo import ZoneInfo + +import swisseph as swe + +try: + from western_chart_engine import _ASPECTS, _PLANETS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart +except ImportError: # pragma: no cover - package import path + from scripts.western_chart_engine import _ASPECTS, _PLANETS, _birth_zone, _longitude, _orb_for, _point, build_tropical_natal_chart + + +def _target_jd(target_date: str, timezone: str | float | int) -> tuple[float, datetime]: + zone, _ = _birth_zone(timezone) + local = datetime.fromisoformat(target_date).replace(tzinfo=zone) + utc = local.astimezone(ZoneInfo("UTC")) + jd = swe.julday(utc.year, utc.month, utc.day, utc.hour + utc.minute / 60 + utc.second / 3600) + return jd, local + + +def _cross_aspects(transits: dict[str, dict[str, Any]], natal: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + matches: list[dict[str, Any]] = [] + for transit_name, transit in transits.items(): + for natal_name, point in natal.items(): + separation = abs(transit["longitude"] - point["longitude"]) + separation = min(separation, 360.0 - separation) + allowed_orb = _orb_for(transit_name, natal_name) + for aspect, exact in _ASPECTS.items(): + orb = abs(separation - exact) + if orb <= allowed_orb: + matches.append({ + "transit_planet": transit_name, + "natal_point": natal_name, + "aspect": aspect, + "exact_degrees": exact, + "separation": round(separation, 6), + "orb": round(orb, 6), + "allowed_orb": allowed_orb, + }) + return sorted(matches, key=lambda row: (row["orb"], row["transit_planet"], row["natal_point"])) + + +def calculate_transit_to_natal(*, target_date: str, **birth: Any) -> dict[str, Any]: + """Calculate major tropical transits to natal planets and ASC/MC on a local date.""" + natal_chart = build_tropical_natal_chart(**birth) + jd, local = _target_jd(target_date, birth["timezone"]) + flags = swe.FLG_SWIEPH | swe.FLG_SPEED + planets: dict[str, dict[str, Any]] = {} + for name, planet_id in _PLANETS.items(): + values, _ = swe.calc_ut(jd, planet_id, flags) + planets[name] = _point(values[0], speed=values[3]) + natal_points = { + **natal_chart["natal"]["planets"], + "ascendant": natal_chart["natal"]["angles"]["ascendant"], + "mc": natal_chart["natal"]["angles"]["mc"], + } + return { + "technique": "transits", + "status": "used", + "target_date": target_date, + "target_local_time": local.isoformat(), + "zodiac": "tropical", + "transit_planets": planets, + "aspects": _cross_aspects(planets, natal_points), + "orb_policy": "major aspects 0/60/90/120/180; min(per-point configured orb)", + "boundary": "A dated transit snapshot only; no duration, outcome, or interpretation is inferred.", + } + + +def _jd_to_local(jd_ut: float, timezone: str | float | int) -> datetime: + zone, _ = _birth_zone(timezone) + year, month, day, hour_float = swe.revjul(jd_ut, swe.GREG_CAL) + utc = datetime(year, month, day, tzinfo=ZoneInfo("UTC")) + timedelta(hours=hour_float) + return utc.astimezone(zone) + + +def calculate_solar_return(*, target_year: int, **birth: Any) -> dict[str, Any]: + """Find the exact tropical solar return and calculate its local return chart.""" + natal_chart = build_tropical_natal_chart(**birth) + natal_sun = natal_chart["natal"]["planets"]["sun"]["longitude"] + start_jd = swe.julday(int(target_year), 1, 1, 0.0) + return_jd = swe.solcross_ut(natal_sun, start_jd, swe.FLG_SWIEPH) + return_local = _jd_to_local(return_jd, birth["timezone"]) + return_birth = { + **birth, + "year": return_local.year, + "month": return_local.month, + "day": return_local.day, + "hour": return_local.hour, + "minute": return_local.minute, + "second": return_local.second, + } + return_chart = build_tropical_natal_chart(**return_birth) + returned_sun = return_chart["natal"]["planets"]["sun"]["longitude"] + delta = abs(_longitude(returned_sun - natal_sun)) + delta = min(delta, 360.0 - delta) + return { + "technique": "solar_return", + "status": "used", + "target_year": int(target_year), + "return_julian_day_ut": round(return_jd, 8), + "return_local_time": return_local.isoformat(), + "natal_sun_longitude": natal_sun, + "return_sun_longitude": returned_sun, + "sun_longitude_delta": round(delta, 8), + "return_chart": return_chart, + "boundary": "Exact solar return time and chart only; annual topics require separate audited interpretation.", + } + + +def _birth_jd(**birth: Any) -> float: + zone, _ = _birth_zone(birth["timezone"]) + local = datetime( + int(birth["year"]), int(birth["month"]), int(birth["day"]), + int(birth["hour"]), int(birth["minute"]), int(birth.get("second", 0)), tzinfo=zone, + ) + utc = local.astimezone(ZoneInfo("UTC")) + return swe.julday(utc.year, utc.month, utc.day, utc.hour + utc.minute / 60 + utc.second / 3600) + + +def _progressed_planets(progressed_jd: float) -> dict[str, dict[str, Any]]: + flags = swe.FLG_SWIEPH | swe.FLG_SPEED + planets: dict[str, dict[str, Any]] = {} + for name, planet_id in _PLANETS.items(): + values, _ = swe.calc_ut(progressed_jd, planet_id, flags) + planets[name] = _point(values[0], speed=values[3]) + return planets + + +def calculate_secondary_progressions(*, target_date: str, **birth: Any) -> dict[str, Any]: + """Calculate progressed planets using one ephemeris day per tropical year.""" + natal_chart = build_tropical_natal_chart(**birth) + target_jd, local = _target_jd(target_date, birth["timezone"]) + birth_jd = _birth_jd(**birth) + elapsed_years = (target_jd - birth_jd) / 365.242189 + progressed_jd = birth_jd + elapsed_years + planets = _progressed_planets(progressed_jd) + natal_points = { + **natal_chart["natal"]["planets"], + "ascendant": natal_chart["natal"]["angles"]["ascendant"], + "mc": natal_chart["natal"]["angles"]["mc"], + } + return { + "technique": "secondary_progressions", + "status": "partial", + "method": "one_ephemeris_day_per_tropical_year", + "target_date": target_date, + "target_local_time": local.isoformat(), + "elapsed_tropical_years": round(elapsed_years, 8), + "progressed_julian_day_ut": round(progressed_jd, 8), + "natal_sun_longitude": natal_chart["natal"]["planets"]["sun"]["longitude"], + "progressed_planets": planets, + "aspects": _cross_aspects(planets, natal_points), + "boundary": "Progressed planets only. Progressed angles, lunar phases, stations, duration, and interpretation remain separate audited layers.", + } + + +def calculate_solar_arc_directions(*, target_date: str, **birth: Any) -> dict[str, Any]: + """Direct natal points by the true arc of the secondary progressed Sun.""" + natal_chart = build_tropical_natal_chart(**birth) + progressions = calculate_secondary_progressions(target_date=target_date, **birth) + natal_sun = natal_chart["natal"]["planets"]["sun"]["longitude"] + progressed_sun = progressions["progressed_planets"]["sun"]["longitude"] + arc = _longitude(progressed_sun - natal_sun) + natal_points = { + **natal_chart["natal"]["planets"], + "ascendant": natal_chart["natal"]["angles"]["ascendant"], + "mc": natal_chart["natal"]["angles"]["mc"], + } + directed = {name: _point(point["longitude"] + arc) for name, point in natal_points.items()} + return { + "technique": "solar_arc_directions", + "status": "partial", + "method": "secondary_progressed_sun_arc", + "target_date": target_date, + "natal_sun_longitude": natal_sun, + "progressed_sun_longitude": progressed_sun, + "solar_arc_degrees": round(arc, 8), + "directed_points": directed, + "aspects": _cross_aspects(directed, natal_points), + "boundary": "True secondary-progressed-Sun arc applied to natal planets/ASC/MC. Directional converse, latitude, parans, midpoint, duration, and event interpretation are not inferred.", + } + + +def calculate_converse_secondary_progressions(*, target_date: str, **birth: Any) -> dict[str, Any]: + """Calculate converse progressed planets using one ephemeris day per tropical year backward.""" + natal_chart = build_tropical_natal_chart(**birth) + target_jd, local = _target_jd(target_date, birth["timezone"]) + birth_jd = _birth_jd(**birth) + elapsed_years = (target_jd - birth_jd) / 365.242189 + progressed_jd = birth_jd - elapsed_years + planets = _progressed_planets(progressed_jd) + natal_points = { + **natal_chart["natal"]["planets"], + "ascendant": natal_chart["natal"]["angles"]["ascendant"], + "mc": natal_chart["natal"]["angles"]["mc"], + } + return { + "technique": "converse_secondary_progressions", + "status": "partial", + "method": "one_ephemeris_day_per_tropical_year_backward", + "target_date": target_date, + "target_local_time": local.isoformat(), + "elapsed_tropical_years": round(elapsed_years, 8), + "progressed_julian_day_ut": round(progressed_jd, 8), + "progressed_planets": planets, + "aspects": _cross_aspects(planets, natal_points), + "progressed_angles": { + "status": "blocked", + "reason": "Progressed angle method is not selected; quotidian/solar-arc/Naibod variants are not interchangeable.", + }, + "boundary": "Converse progressed planets only; progressed angles and interpretation remain blocked until a method is selected.", + } + + +def calculate_converse_solar_arc_directions(*, target_date: str, **birth: Any) -> dict[str, Any]: + """Direct natal points backward by the converse secondary-progressed Sun arc.""" + natal_chart = build_tropical_natal_chart(**birth) + progressions = calculate_converse_secondary_progressions(target_date=target_date, **birth) + natal_sun = natal_chart["natal"]["planets"]["sun"]["longitude"] + progressed_sun = progressions["progressed_planets"]["sun"]["longitude"] + arc = _longitude(natal_sun - progressed_sun) + natal_points = { + **natal_chart["natal"]["planets"], + "ascendant": natal_chart["natal"]["angles"]["ascendant"], + "mc": natal_chart["natal"]["angles"]["mc"], + } + directed = {name: _point(point["longitude"] - arc) for name, point in natal_points.items()} + return { + "technique": "converse_solar_arc_directions", + "status": "partial", + "method": "converse_secondary_progressed_sun_arc", + "target_date": target_date, + "natal_sun_longitude": natal_sun, + "converse_progressed_sun_longitude": progressed_sun, + "converse_solar_arc_degrees": round(arc, 8), + "directed_points": directed, + "aspects": _cross_aspects(directed, natal_points), + "boundary": "Backward solar arc applied to natal planets/ASC/MC. Interpretation and parans remain separate audited layers.", + } + + +def _midpoint_longitude(first: float, second: float) -> float: + diff = _longitude(second - first) + if diff > 180.0: + diff -= 360.0 + return _longitude(first + diff / 2.0) + + +def calculate_midpoints(*, target_date: str | None = None, orb: float = 1.5, **birth: Any) -> dict[str, Any]: + """Calculate natal midpoint tree and optional transit conjunction/opposition hits.""" + natal_chart = build_tropical_natal_chart(**birth) + natal_points = { + **natal_chart["natal"]["planets"], + "ascendant": natal_chart["natal"]["angles"]["ascendant"], + "mc": natal_chart["natal"]["angles"]["mc"], + } + names = [name for name in [*_PLANETS.keys(), "ascendant", "mc"] if name in natal_points] + midpoints: dict[str, dict[str, Any]] = {} + for index, first_name in enumerate(names): + for second_name in names[index + 1:]: + key = f"{first_name}/{second_name}" + lon = _midpoint_longitude(natal_points[first_name]["longitude"], natal_points[second_name]["longitude"]) + midpoints[key] = _point(lon) + result: dict[str, Any] = { + "technique": "midpoints", + "status": "used", + "method": "shortest_arc_direct_midpoints", + "orb_degrees": float(orb), + "natal_midpoints": midpoints, + "boundary": "Midpoint geometry only; hits are conjunction/opposition contacts, not interpretations.", + } + if target_date: + transit = calculate_transit_to_natal(target_date=target_date, **birth) + hits: list[dict[str, Any]] = [] + for transit_name, transit_point in transit["transit_planets"].items(): + for midpoint_name, midpoint in midpoints.items(): + separation = abs(transit_point["longitude"] - midpoint["longitude"]) + separation = min(separation, 360.0 - separation) + for aspect, exact in {"conjunction": 0.0, "opposition": 180.0}.items(): + hit_orb = abs(separation - exact) + if hit_orb <= orb: + hits.append({ + "transit_planet": transit_name, + "midpoint": midpoint_name, + "aspect": aspect, + "orb": round(hit_orb, 6), + "separation": round(separation, 6), + }) + result["target_date"] = target_date + result["transit_midpoint_hits"] = sorted(hits, key=lambda row: (row["orb"], row["transit_planet"], row["midpoint"])) + return result + + +def calculate_lunar_return(*, start_date: str, **birth: Any) -> dict[str, Any]: + """Find the next exact tropical lunar return after a local start date.""" + natal_chart = build_tropical_natal_chart(**birth) + natal_moon = natal_chart["natal"]["planets"]["moon"]["longitude"] + start_jd, _ = _target_jd(start_date, birth["timezone"]) + return_jd = swe.mooncross_ut(natal_moon, start_jd, swe.FLG_SWIEPH) + return_local = _jd_to_local(return_jd, birth["timezone"]) + return_birth = { + **birth, + "year": return_local.year, + "month": return_local.month, + "day": return_local.day, + "hour": return_local.hour, + "minute": return_local.minute, + "second": return_local.second, + } + return_chart = build_tropical_natal_chart(**return_birth) + returned_moon = return_chart["natal"]["planets"]["moon"]["longitude"] + delta = abs(_longitude(returned_moon - natal_moon)) + delta = min(delta, 360.0 - delta) + return { + "technique": "lunar_return", + "status": "used", + "method": "Swiss Ephemeris mooncross_ut tropical longitude", + "start_date": start_date, + "return_julian_day_ut": round(return_jd, 8), + "return_local_time": return_local.isoformat(), + "natal_moon_longitude": natal_moon, + "return_moon_longitude": returned_moon, + "moon_longitude_delta": round(delta, 8), + "return_chart": return_chart, + "boundary": "Exact lunar return time and chart only; monthly topics require separate audited interpretation.", + } + + +def calculate_transit_duration_scan(*, start_date: str, end_date: str, max_days: int = 370, **birth: Any) -> dict[str, Any]: + """Scan daily transit-to-natal aspect activity and group consecutive windows.""" + start = datetime.fromisoformat(start_date) + end = datetime.fromisoformat(end_date) + if end < start: + raise ValueError("end_date must be on or after start_date") + days = (end.date() - start.date()).days + 1 + if days > max_days: + raise ValueError(f"duration scan range exceeds max_days={max_days}") + daily_hits: list[dict[str, Any]] = [] + active: dict[tuple[str, str, str], dict[str, Any]] = {} + windows: list[dict[str, Any]] = [] + for offset in range(days): + current = (start + timedelta(days=offset)).date().isoformat() + transit = calculate_transit_to_natal(target_date=current, **birth) + keys = set() + for aspect in transit["aspects"]: + key = (aspect["transit_planet"], aspect["natal_point"], aspect["aspect"]) + keys.add(key) + if key not in active: + active[key] = {"start_date": current, "min_orb": aspect["orb"]} + else: + active[key]["min_orb"] = min(active[key]["min_orb"], aspect["orb"]) + for key in list(active): + if key not in keys: + row = active.pop(key) + windows.append({ + "transit_planet": key[0], + "natal_point": key[1], + "aspect": key[2], + "start_date": row["start_date"], + "end_date": (start + timedelta(days=offset - 1)).date().isoformat(), + "min_orb": round(row["min_orb"], 6), + }) + daily_hits.append({"date": current, "hit_count": len(transit["aspects"]), "aspects": transit["aspects"]}) + final_date = end.date().isoformat() + for key, row in active.items(): + windows.append({ + "transit_planet": key[0], + "natal_point": key[1], + "aspect": key[2], + "start_date": row["start_date"], + "end_date": final_date, + "min_orb": round(row["min_orb"], 6), + }) + return { + "technique": "transit_duration_scan", + "status": "used", + "method": "daily local-midnight transit snapshots grouped into consecutive aspect windows", + "start_date": start_date, + "end_date": end_date, + "days_scanned": days, + "daily_hits": daily_hits, + "windows": sorted(windows, key=lambda row: (row["start_date"], row["min_orb"], row["transit_planet"])), + "boundary": "Daily scan only; exact ingress/egress times require sub-daily root finding.", + } + + +def calculate_parans_status(*, target_date: str | None = None, **birth: Any) -> dict[str, Any]: + return { + "technique": "parans", + "status": "blocked", + "target_date": target_date, + "reason": "Parans need a dedicated rising/setting/culminating engine and latitude-aware event solver; not yet implemented in this repository.", + } + + +def build_timing_techniques( + *, + transit_date: str | None = None, + solar_return_year: int | None = None, + secondary_progression_date: str | None = None, + solar_arc_date: str | None = None, + converse_secondary_progression_date: str | None = None, + converse_solar_arc_date: str | None = None, + midpoint_date: str | None = None, + lunar_return_start_date: str | None = None, + duration_scan_start_date: str | None = None, + duration_scan_end_date: str | None = None, + parans_date: str | None = None, + **birth: Any, +) -> dict[str, Any]: + """Materialize only the requested, independently auditable timing layers.""" + techniques: dict[str, Any] = {} + if transit_date: + techniques["transits"] = calculate_transit_to_natal(target_date=transit_date, **birth) + if solar_return_year is not None: + techniques["solar_return"] = calculate_solar_return(target_year=int(solar_return_year), **birth) + if secondary_progression_date: + techniques["secondary_progressions"] = calculate_secondary_progressions( + target_date=secondary_progression_date, **birth + ) + if solar_arc_date: + techniques["solar_arc_directions"] = calculate_solar_arc_directions(target_date=solar_arc_date, **birth) + if converse_secondary_progression_date: + techniques["converse_secondary_progressions"] = calculate_converse_secondary_progressions( + target_date=converse_secondary_progression_date, **birth + ) + if converse_solar_arc_date: + techniques["converse_solar_arc_directions"] = calculate_converse_solar_arc_directions( + target_date=converse_solar_arc_date, **birth + ) + if midpoint_date: + techniques["midpoints"] = calculate_midpoints(target_date=midpoint_date, **birth) + if lunar_return_start_date: + techniques["lunar_return"] = calculate_lunar_return(start_date=lunar_return_start_date, **birth) + if duration_scan_start_date and duration_scan_end_date: + techniques["transit_duration_scan"] = calculate_transit_duration_scan( + start_date=duration_scan_start_date, + end_date=duration_scan_end_date, + **birth, + ) + if parans_date: + techniques["parans"] = calculate_parans_status(target_date=parans_date, **birth) + return techniques diff --git a/tests/test_active_rectification_api.py b/tests/test_active_rectification_api.py new file mode 100644 index 00000000..cb7a53ea --- /dev/null +++ b/tests/test_active_rectification_api.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +from jyotish_api_server import BadRequest, JyotishAPIHandler # noqa: E402 + + +def _handler() -> JyotishAPIHandler: + return JyotishAPIHandler.__new__(JyotishAPIHandler) + + +def test_active_rectification_questions_api_builds_choice_workflow() -> None: + result = _handler()._compute_active_rectification_questions( + { + "birth_time": "1993-04-17 14:49", + "uncertainty_minutes": 30, + "step_minutes": 1, + } + ) + + assert result["success"] is True + assert result["endpoint"] == "active_rectification_questions" + assert result["scope"] == "active_birth_time_rectification_questionnaire" + assert result["candidate_scan"]["start"] == "1993-04-17 14:19" + assert result["candidate_scan"]["end"] == "1993-04-17 15:19" + assert result["candidate_scan"]["candidate_count"] == 61 + assert result["questions"] + assert {option["key"] for option in result["questions"][0]["options"]} == {"A", "B", "C", "D"} + assert "dynamic_candidate_cluster_scoring" in result["workflow"] + + +def test_active_rectification_questions_api_accepts_location_for_true_recast() -> None: + result = _handler()._compute_active_rectification_questions( + { + "birth_time": "1993-04-17 14:49", + "uncertainty_minutes": 30, + "lat": 36.683333, + "lon": 114.35, + "tz": 8, + } + ) + + summary = result["candidate_scan"]["sensitivity_summary"] + assert "true_varga_recast" in summary["computed_layers"] + assert "true_arudha_recast" in summary["computed_layers"] + assert "true_kp_cusp_recast" in summary["computed_layers"] + assert "true_varga_recast" not in summary["blocked_layers"] + assert "true_kp_cusp_recast" not in summary["blocked_layers"] + + +def test_active_rectification_score_api_returns_rankings_and_next_questions() -> None: + questionnaire = _handler()._compute_active_rectification_questions( + {"birth_time": "1993-04-17 14:49", "uncertainty_minutes": 30} + ) + scored = _handler()._compute_active_rectification_score( + { + "questionnaire": questionnaire, + "answers": { + "education_environment_shift": "A", + "residence_relocation_shift": "B", + "relationship_or_partner_entry": "D", + "career_responsibility_pressure": "A", + "research_tool_expression_shift": "C", + }, + } + ) + + assert scored["success"] is True + assert scored["endpoint"] == "active_rectification_score" + assert scored["scope"] == "active_birth_time_rectification_scoring" + assert scored["answered_count"] == 5 + assert scored["candidate_cluster_rankings"] + assert scored["next_round_questions"] + assert scored["candidate_cluster_rankings"][0]["score"] >= scored["candidate_cluster_rankings"][-1]["score"] + + +def test_active_rectification_questions_api_validates_request() -> None: + with pytest.raises(BadRequest, match="birth_time must be a string"): + _handler()._compute_active_rectification_questions({}) + + with pytest.raises(BadRequest, match="uncertainty_minutes must be between 1 and 180"): + _handler()._compute_active_rectification_questions( + {"birth_time": "1993-04-17 14:49", "uncertainty_minutes": 0} + ) + + with pytest.raises(BadRequest, match="step_minutes must be between 1 and 30"): + _handler()._compute_active_rectification_questions( + {"birth_time": "1993-04-17 14:49", "step_minutes": 31} + ) + + +def test_active_rectification_score_api_validates_payload() -> None: + with pytest.raises(BadRequest, match="questionnaire must be an object"): + _handler()._compute_active_rectification_score({"answers": {}}) + + with pytest.raises(BadRequest, match="answers must be an object"): + _handler()._compute_active_rectification_score({"questionnaire": {}}) diff --git a/tests/test_active_rectification_questions.py b/tests/test_active_rectification_questions.py index aa11c3dc..d829c432 100644 --- a/tests/test_active_rectification_questions.py +++ b/tests/test_active_rectification_questions.py @@ -16,6 +16,13 @@ def test_active_rectification_questions_generate_choice_based_workflow() -> None assert {q["round"] for q in report["questions"]} == {1, 2, 3} assert all({option["key"] for option in q["options"]} == {"A", "B", "C", "D"} for q in report["questions"]) assert all("scoring_map" in q for q in report["questions"]) + assert report["candidate_scan"]["samples"] + assert report["candidate_scan"]["sensitivity_summary"] + assert {"D9", "D10", "D24", "D30", "UL", "A10", "KP_cusp"} <= set( + report["candidate_scan"]["sensitivity_summary"]["high_value_layers"] + ) + assert report["candidate_scan"]["samples"][0]["cluster"] == "early_candidate_cluster" + assert report["candidate_scan"]["samples"][-1]["cluster"] == "late_candidate_cluster" def test_active_rectification_scores_answers_and_selects_next_round() -> None: @@ -37,3 +44,27 @@ def test_active_rectification_scores_answers_and_selects_next_round() -> None: assert scored["next_round_questions"] assert scored["candidate_cluster_rankings"][0]["score"] > scored["candidate_cluster_rankings"][-1]["score"] assert "final rectification requires scoring answers against actual candidate chart differences" in scored["boundary"] + + +def test_active_rectification_recasts_candidate_vargas_when_location_is_available() -> None: + report = build_questionnaire( + "1993-04-17 14:49", + uncertainty_minutes=30, + lat=36.683333, + lon=114.35, + tz=8, + ) + summary = report["candidate_scan"]["sensitivity_summary"] + assert "true_varga_recast" in summary["computed_layers"] + assert "true_arudha_recast" in summary["computed_layers"] + assert "true_kp_cusp_recast" in summary["computed_layers"] + assert "true_varga_recast" not in summary["blocked_layers"] + assert "true_kp_cusp_recast" not in summary["blocked_layers"] + sample = report["candidate_scan"]["samples"][1] + assert sample["varga_lagna"]["D9_Navamsa"]["sign"] + assert sample["varga_lagna"]["D10_Dasamsa"]["sign"] + assert sample["arudha"]["A7"]["sign"] + assert sample["arudha"]["A10"]["sign"] + assert sample["arudha"]["UL"]["sign"] + assert sample["kp_cusps"]["house_7"]["sub_lord"] + assert sample["kp_cusps"]["house_10"]["sub_sub_lord"] diff --git a/tests/test_api_async_job_contract.py b/tests/test_api_async_job_contract.py new file mode 100644 index 00000000..994dcaf3 --- /dev/null +++ b/tests/test_api_async_job_contract.py @@ -0,0 +1,105 @@ +import json +import time +from pathlib import Path + +import pytest + +from scripts import jyotish_api_server as api + + +def test_evidence_packet_view_exposes_only_auditable_result_sections(): + packet = api.build_evidence_packet_view({ + "job_id": "job_1", + "status": "completed", + "result": { + "fallback_reason": "VedAstro official snapshot blocked: timeout", + "machine_evidence_packet": {"status": "draft", "metadata": {"capture_id": "x"}}, + "technique_audit": [{"technique": "D9", "status": "used"}], + "ai_prompt_pack": {"prompt_zh": "internal prompt"}, + }, + }) + + assert packet["job_id"] == "job_1" + assert packet["execution_status"]["official_evidence_status"] == "official_blocked" + assert packet["machine_evidence_packet"]["metadata"]["capture_id"] == "x" + assert "ai_prompt_pack" not in packet + + +def test_async_job_route_extracts_id_before_loading(monkeypatch, tmp_path): + record = {"job_id": "chart_abc", "status": "completed", "result": {}} + monkeypatch.setattr(api, "_load_async_job_record", lambda *args, **kwargs: record) + + handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler) + handler.path = "/api/chart/jobs/chart_abc" + handler.headers = {"Origin": ""} + handler._enforce_request_security = lambda: None + captured = {} + handler._json = lambda data, status=200: captured.update(data=data, status=status) + handler._error_json = lambda message, status=500, error_code="ERR_INTERNAL": captured.update(error=error_code, status=status) + handler._job_access_token = lambda: "token" + + handler.do_GET() + + assert captured["status"] == 200 + assert captured["data"]["job_id"] == "chart_abc" + + +def test_evidence_packet_page_is_present_and_does_not_embed_birth_data(): + page = Path(api.REPO_ROOT) / "web" / "evidence_packet.html" + source = page.read_text(encoding="utf-8") + + assert "Evidence Packet" in source + assert "access token" in source + assert "birth" not in source.lower() + + +def test_rectification_page_uses_choice_questionnaire_contract(): + page = Path(api.REPO_ROOT) / "web" / "rectification.html" + source = page.read_text(encoding="utf-8") + + assert "/api/rectification/questionnaire" in source + assert "/api/rectification/answers" in source + assert "候选簇排序" in source + + +def test_home_page_keeps_location_confirmation_local(): + page = Path(api.REPO_ROOT) / "web" / "index.html" + source = page.read_text(encoding="utf-8") + + assert "/api/location/resolve" in source + assert "第三方地理服务" in source + + +def test_startup_cleanup_removes_only_expired_job_records(monkeypatch, tmp_path): + expired = tmp_path / "expired.json" + active = tmp_path / "active.json" + expired.write_text(json.dumps({"expires_at_unix": time.time() - 1}), encoding="utf-8") + active.write_text(json.dumps({"expires_at_unix": time.time() + 60}), encoding="utf-8") + monkeypatch.setattr(api, "_async_job_dir", lambda scope: tmp_path) + + result = api.prune_expired_async_jobs() + + assert result["removed"] == 1 + assert not expired.exists() + assert active.exists() + + +def test_rate_limit_is_configurable_and_rejects_over_budget(monkeypatch): + api._RATE_LIMIT_BUCKETS.clear() + monkeypatch.setenv("JYOTISH_API_RATE_LIMIT_PER_MINUTE", "1") + + api.enforce_rate_limit("test-client", now=0) + with pytest.raises(api.RateLimited): + api.enforce_rate_limit("test-client", now=1) + + +def test_sqlite_async_job_backend_preserves_token_and_ttl(monkeypatch, tmp_path): + monkeypatch.setenv("JYOTISH_ASYNC_JOB_BACKEND", "sqlite") + monkeypatch.setattr(api, "_sqlite_job_db_path", lambda: tmp_path / "jobs.sqlite3") + record = {"job_id": "job_sqlite", "access_token_hash": api._access_token_hash("secret"), "expires_at_unix": time.time() + 60} + + api._write_async_job_record("test_scope", "job_sqlite", record) + + assert api._load_async_job_record("test_scope", "job_sqlite", access_token="secret")["job_id"] == "job_sqlite" + with pytest.raises(api.JobAccessDenied): + api._load_async_job_record("test_scope", "job_sqlite", access_token="wrong") diff --git a/tests/test_calculation_p0_regressions.py b/tests/test_calculation_p0_regressions.py new file mode 100644 index 00000000..05338e08 --- /dev/null +++ b/tests/test_calculation_p0_regressions.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import domain_calculation_service as calculation_service # noqa: E402 +import jyotish_api_server # noqa: E402 +from jyotish_api_server import JyotishAPIHandler # noqa: E402 +from jyotish_engine import _compute_chart_from_args # noqa: E402 + +BIRTH = { + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 28.6139, + "lon": 77.2090, + "tz": 5.5, + "ayanamsa": "lahiri", +} + + +def test_true_node_changes_effective_rahu_and_contract() -> None: + mean = calculation_service.compute_chart({**BIRTH, "node_mode": "mean"}) + true = calculation_service.compute_chart({**BIRTH, "node_mode": "true"}) + + assert mean["planets"]["Rahu"]["lon"] != pytest.approx( + true["planets"]["Rahu"]["lon"], abs=1e-8 + ) + assert mean["calculation_contract"]["effective"]["node_mode"] == "mean" + assert true["calculation_contract"]["effective"]["node_mode"] == "true" + assert mean["result_hash"] != true["result_hash"] + + +def test_vimshottari_uses_birth_balance_as_canonical_timeline() -> None: + birth_dt = datetime(1990, 1, 1, 12, 0) + result = calculation_service.compute_vimshottari_timeline( + birth_dt=birth_dt, + moon_lon=100.0, + current_date=birth_dt, + ) + + first = result["periods"][0] + assert first["lord"] == "Saturn" + assert first["start"] == "1980-07-02" + assert first["end"] == "1999-07-02" + assert result["birth_balance"]["remaining_years"] == pytest.approx(9.5) + assert result["calculation_contract"]["algorithm"] == "vimshottari_birth_balance" + + +def test_sade_sati_uses_real_saturn_transit_for_reference_date() -> None: + result = calculation_service.compute_sade_sati( + moon_degree=300.0, + asc_degree=330.0, + reference_date="2026-07-11", + tz=5.5, + ayanamsa="lahiri", + ) + oracle = calculation_service.compute_transit_longitude( + planet="Saturn", + reference_date="2026-07-11", + tz=5.5, + ayanamsa="lahiri", + ) + + assert result["transit_saturn_lon"] == pytest.approx(oracle["longitude"], abs=1e-8) + assert result["provenance"]["data_layer"] == "true_transit_positions" + assert result["provenance"]["reference_date"] == "2026-07-11" + + +def test_timezone_inference_fails_closed(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + calculation_service, + "_lookup_timezone_name", + lambda _lat, _lon: None, + ) + + with pytest.raises(calculation_service.TimezoneInferenceError, match="timezone inference"): + calculation_service.infer_timezone_offset( + lat=0.0, + lon=0.0, + local_datetime=datetime(1990, 1, 1, 12, 0), + ) + + +def test_chart_hash_matches_domain_cli_and_rest( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0") + monkeypatch.setattr( + jyotish_api_server, + "_attach_vedastro_main_entry_overview", + lambda result, _birth: result, + ) + expected = calculation_service.compute_chart({**BIRTH, "node_mode": "true"}) + cli, _asc_idx, _jd, _ayanamsa = _compute_chart_from_args( + SimpleNamespace(**BIRTH, node_mode="true") + ) + rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync( + {**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"} + ) + + assert cli["result_hash"] == expected["result_hash"] + assert rest["result_hash"] == expected["result_hash"] + assert rest["birth"]["node_mode"] == "true" + assert rest["calculation_contract"]["effective"]["node_mode"] == "true" diff --git a/tests/test_candidate_time_sensitivity_scan.py b/tests/test_candidate_time_sensitivity_scan.py new file mode 100644 index 00000000..da8a0476 --- /dev/null +++ b/tests/test_candidate_time_sensitivity_scan.py @@ -0,0 +1,27 @@ +from scripts import candidate_time_sensitivity_scan as scanner + + +def test_scanner_reports_real_divisional_transitions(monkeypatch): + def fake_engine(command, payload, timeout=20): + minute = payload["minute"] + if command == "chart": + return {"ascendant": {"sign": "Leo", "degree_in_sign": 10 + minute / 100}} + ascendant = "Aries" if minute % 2 else "Taurus" + return { + "D4_Turyamsa": {"Ascendant": {"sign": ascendant}}, + "D9_Navamsa": {"Ascendant": {"sign": ascendant}}, + "D10_Dasamsa": {"Ascendant": {"sign": ascendant}}, + "D24_Siddhamsa": {"Ascendant": {"sign": ascendant}}, + "D30_Trimsamsa": {"Ascendant": {"sign": ascendant}}, + } + + monkeypatch.setattr(scanner, "_engine_json", fake_engine) + report = scanner.scan_candidate_times( + {"year": 2000, "month": 1, "day": 1, "hour": 12, "minute": 1, "lat": 1, "lon": 1, "tz": 0}, + uncertainty_minutes=1, + ) + + assert report["candidate_count"] == 3 + assert report["transitions"] + assert report["pending_layers"] == ["UL", "A7", "A10", "KP_cusp"] + assert report["rows"][0]["divisional_ascendants"]["D9"] in {"Aries", "Taurus"} diff --git a/tests/test_career_vedastro_radar.py b/tests/test_career_vedastro_radar.py new file mode 100644 index 00000000..61a9ae2d --- /dev/null +++ b/tests/test_career_vedastro_radar.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import career_vedastro_radar # noqa: E402 + + +def test_career_radar_wraps_vedastro_range_scan_as_secondary_evidence(monkeypatch) -> None: + def fake_run(case, domain, start_date, end_date, case_id="user_chart"): + return { + "status": "ok", + "domain": domain, + "operation": "range_scan", + "evidence_ledger": [{"event_id": "CareerExpansionWindow", "date": "2026-09-01"}], + "adjudicator_policy": {"can_change_score": False}, + } + + monkeypatch.setattr(career_vedastro_radar.vedastro_service_adapter, "run_range_scan_for_case", fake_run) + + packet = career_vedastro_radar.build_career_radar_packet( + {"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "lat": 36.4, "lon": 114.2, "tz": 8}, + start_date="2026-07-16", + end_date="2026-12-31", + ) + + assert packet["status"] == "ok" + assert packet["domain"] == "career" + assert packet["adjudicator_use"] == "secondary_evidence_only" + assert packet["can_change_score"] is False + assert packet["vedastro_range_scan_result"]["evidence_ledger"][0]["event_id"] == "CareerExpansionWindow" + + +def test_career_radar_preserves_blocked_boundary(monkeypatch) -> None: + def fake_run(case, domain, start_date, end_date, case_id="user_chart"): + return {"status": "blocked", "reason": "official_endpoint_not_configured", "evidence_ledger": []} + + monkeypatch.setattr(career_vedastro_radar.vedastro_service_adapter, "run_range_scan_for_case", fake_run) + + packet = career_vedastro_radar.build_career_radar_packet( + {"year": 1990, "month": 1, "day": 1, "hour": 12, "minute": 0, "lat": 36.4, "lon": 114.2, "tz": 8}, + start_date="2026-07-16", + end_date="2026-12-31", + ) + + assert packet["status"] == "blocked" + assert packet["can_change_score"] is False + assert packet["blocked_reason"] == "official_endpoint_not_configured" diff --git a/tests/test_commercial_domain_calculation_contract.py b/tests/test_commercial_domain_calculation_contract.py new file mode 100644 index 00000000..1305f00f --- /dev/null +++ b/tests/test_commercial_domain_calculation_contract.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import sys +from datetime import datetime +from pathlib import Path + +import pytest + + +SCRIPTS = Path(__file__).resolve().parents[1] / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import domain_calculation_service as calculation_service # noqa: E402 +import jyotish_api_server # noqa: E402 +from jyotish_api_server import JyotishAPIHandler # noqa: E402 + + +BIRTH = { + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 28.6139, + "lon": 77.2090, + "tz": 5.5, + "ayanamsa": "lahiri", +} + + +def test_domain_chart_exposes_effective_params_and_result_hash() -> None: + mean = calculation_service.compute_chart({**BIRTH, "node_mode": "mean"}) + true = calculation_service.compute_chart({**BIRTH, "node_mode": "true"}) + + assert mean["calculation_contract"]["effective"]["ayanamsa"] == "lahiri" + assert true["calculation_contract"]["effective"]["node_mode"] == "true" + assert mean["planets"]["Rahu"]["lon"] != pytest.approx(true["planets"]["Rahu"]["lon"], abs=1e-8) + assert mean["result_hash"] != true["result_hash"] + + +def test_api_chart_response_uses_domain_contract_hash(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0") + monkeypatch.setattr( + jyotish_api_server, + "_attach_vedastro_main_entry_overview", + lambda result, _birth: result, + ) + expected = calculation_service.compute_chart({**BIRTH, "node_mode": "true"}) + + rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync( + {**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"} + ) + + assert rest["result_hash"] == expected["result_hash"] + assert rest["birth"]["node_mode"] == "true" + assert rest["calculation_contract"]["effective"]["node_mode"] == "true" + + +def test_api_visible_chart_values_come_from_domain_service(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0") + monkeypatch.setattr( + jyotish_api_server, + "_attach_vedastro_main_entry_overview", + lambda result, _birth: result, + ) + request = {**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"} + expected = calculation_service.compute_chart(request) + + rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(request) + + assert rest["ascendant"]["lon"] == pytest.approx(expected["ascendant"]["lon"], abs=1e-8) + for planet in ("Sun", "Moon", "Rahu", "Ketu"): + assert rest["planets"][planet]["sign"] == expected["planets"][planet]["sign"] + assert rest["planets"][planet]["lon"] == pytest.approx( + expected["planets"][planet]["lon"], abs=1e-8 + ) + + +def test_api_sade_sati_uses_domain_true_saturn_transit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0") + monkeypatch.setattr( + jyotish_api_server, + "_attach_vedastro_main_entry_overview", + lambda result, _birth: result, + ) + request = {**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"} + chart = calculation_service.compute_chart(request) + expected = calculation_service.compute_sade_sati( + moon_degree=chart["planets"]["Moon"]["lon"], + asc_degree=chart["ascendant"]["lon"], + reference_date="2026-07-11", + tz=BIRTH["tz"], + ayanamsa=BIRTH["ayanamsa"], + ) + + rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(request) + + assert rest["sade_sati"]["transit_saturn_lon"] == pytest.approx( + expected["transit_saturn_lon"], abs=1e-8 + ) + assert rest["sade_sati"]["provenance"]["data_layer"] == "true_transit_positions" + assert rest["sade_sati"]["calculation_contract"]["algorithm"] == "sade_sati_true_saturn_transit" + + +def test_api_dasha_boundary_comes_from_domain_service(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("JYOTISH_API_CHART_CACHE_TTL_SECONDS", "0") + monkeypatch.setenv("VEDASTRO_ENABLE_NETWORK", "0") + monkeypatch.setattr( + jyotish_api_server, + "_attach_vedastro_main_entry_overview", + lambda result, _birth: result, + ) + request = {**BIRTH, "node_mode": "true", "transit_date": "2026-07-11"} + chart = calculation_service.compute_chart(request) + expected = calculation_service.compute_vimshottari_timeline( + birth_dt=datetime(1990, 1, 1, 12, 0), + moon_lon=chart["planets"]["Moon"]["lon"], + ) + + rest = JyotishAPIHandler.__new__(JyotishAPIHandler)._compute_chart_sync(request) + + assert rest["dasha"]["current_md"] == expected["birth_balance"]["lord"] + assert rest["dasha"]["remaining_years"] == pytest.approx( + expected["birth_balance"]["remaining_years"], abs=1e-8 + ) + assert rest["dasha"]["start_date"] == expected["periods"][0]["start"] + assert rest["dasha"]["result_hash"] == expected["result_hash"] diff --git a/tests/test_configure_vedastro_secret.py b/tests/test_configure_vedastro_secret.py new file mode 100644 index 00000000..c251dee7 --- /dev/null +++ b/tests/test_configure_vedastro_secret.py @@ -0,0 +1,18 @@ +from scripts.configure_vedastro_secret import update_env_text + + +def test_update_env_text_adds_and_replaces_vedastro_settings(): + text = "VEDASTRO_API_ENDPOINT=https://old.example/api\nOTHER=value\n" + updated = update_env_text( + text, + { + "VEDASTRO_API_KEY": "sample-secret", + "VEDASTRO_API_ENDPOINT": "https://api.vedastro.org/api", + "VEDASTRO_ENABLE_NETWORK": "1", + }, + ) + assert "VEDASTRO_API_ENDPOINT=https://api.vedastro.org/api" in updated + assert "VEDASTRO_API_KEY=sample-secret" in updated + assert "VEDASTRO_ENABLE_NETWORK=1" in updated + assert "OTHER=value" in updated + assert "https://old.example/api" not in updated diff --git a/tests/test_cross_project_contract.py b/tests/test_cross_project_contract.py new file mode 100644 index 00000000..f840452c --- /dev/null +++ b/tests/test_cross_project_contract.py @@ -0,0 +1,77 @@ +"""Public synthetic-fixture contract shared with the commercial repository.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import cross_project_contract as contract # noqa: E402 + + +MANIFEST = ROOT / "references" / "cross_project_contract" / "fixture_manifest.v1.json" +LEDGER = ROOT / "references" / "cross_project_contract" / "sync_ledger.json" + + +def test_public_fixture_manifest_has_complete_effective_settings() -> None: + manifest = contract.load_manifest(MANIFEST) + + assert manifest["schema_version"] == 1 + assert manifest["privacy_scope"] == "public_synthetic_only" + fixture = manifest["fixtures"][0] + assert fixture["birth"]["synthetic"] is True + assert fixture["effective"] == {"ayanamsa": "lahiri", "node_mode": "mean", "timezone_offset": 5.5} + + +def test_local_calculation_matches_public_compatibility_hash() -> None: + report = contract.evaluate_manifest(MANIFEST) + + assert report["matches"] is True + assert report["fixtures"][0]["matches"] is True + + +def test_comparator_reports_tampered_expected_hash(tmp_path: Path) -> None: + manifest = json.loads(MANIFEST.read_text(encoding="utf-8")) + manifest["fixtures"][0]["compatibility_hash"] = "0" * 64 + changed = tmp_path / "fixture_manifest.v1.json" + changed.write_text(json.dumps(manifest), encoding="utf-8") + + report = contract.evaluate_manifest(changed) + + assert report["matches"] is False + assert report["fixtures"][0]["matches"] is False + + +def test_compatibility_payload_normalizes_engine_degree_field() -> None: + fixture = contract.load_manifest(MANIFEST)["fixtures"][0] + chart = { + "ascendant": {"sign": "Aries", "degree": 1.25}, + "planets": { + planet: {"sign": "Aries", "degree": float(index)} + for index, planet in enumerate(contract.PLANETS) + }, + } + + payload = contract.compatibility_payload(chart, fixture) + + assert payload["ascendant"]["lon"] == 1.25 + assert payload["planets"]["Sun"]["lon"] == 0.0 + + +def test_sync_ledger_requires_provenance_privacy_tests_hash_and_rollback() -> None: + ledger = contract.load_ledger(LEDGER) + + assert ledger["schema_version"] == 1 + assert len(ledger["entries"]) >= 1 + for entry in ledger["entries"]: + assert contract.validate_ledger_entry(entry) == [] + assert entry["privacy_review"].startswith("pass:") + missing = contract.validate_ledger_entry({"source_repository": "x"}) + assert "target_commit" in missing + assert "privacy_review" in missing + assert "rollback" in missing diff --git a/tests/test_cross_project_sync_status.py b/tests/test_cross_project_sync_status.py new file mode 100644 index 00000000..aabfed3f --- /dev/null +++ b/tests/test_cross_project_sync_status.py @@ -0,0 +1,64 @@ +"""Cross-repository sync status checks for the research/commercial pair.""" + +from __future__ import annotations + +import json +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import cross_project_sync_status as sync_status # noqa: E402 + + +POLICY = ROOT / "references" / "cross_project_contract" / "sync_policy.v1.json" + + +def _make_peer_copy(tmp_path: Path) -> Path: + peer = tmp_path / "peer" + policy = sync_status.load_policy(POLICY) + for rel_path in policy["shared_files"]: + source = ROOT / rel_path + target = peer / rel_path + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + return peer + + +def test_sync_policy_encodes_research_first_commercial_mature_rule() -> None: + policy = sync_status.load_policy(POLICY) + + assert policy["schema_version"] == 1 + assert policy["sync_model"] == "research_validates_commercial_receives_mature" + assert policy["directional_gates"]["research_to_commercial"]["source_required"] == "validated_in_research" + assert policy["directional_gates"]["research_to_commercial"]["target_required"] == "commercial_safe" + assert "references/cross_project_contract/fixture_manifest.v1.json" in policy["shared_files"] + + +def test_sync_status_passes_when_shared_files_match(tmp_path: Path) -> None: + peer = _make_peer_copy(tmp_path) + + report = sync_status.compare_peer(peer, policy_path=POLICY, root=ROOT) + + assert report["status"] == "pass" + assert report["missing"] == [] + assert report["mismatched"] == [] + assert report["checked_count"] == len(sync_status.load_policy(POLICY)["shared_files"]) + + +def test_sync_status_reports_mismatched_shared_file(tmp_path: Path) -> None: + peer = _make_peer_copy(tmp_path) + changed = peer / "references" / "cross_project_contract" / "fixture_manifest.v1.json" + data = json.loads(changed.read_text(encoding="utf-8")) + data["fixtures"][0]["compatibility_hash"] = "0" * 64 + changed.write_text(json.dumps(data, sort_keys=True), encoding="utf-8") + + report = sync_status.compare_peer(peer, policy_path=POLICY, root=ROOT) + + assert report["status"] == "fail" + assert report["missing"] == [] + assert report["mismatched"] == ["references/cross_project_contract/fixture_manifest.v1.json"] diff --git a/tests/test_external_oracle_raw_import.py b/tests/test_external_oracle_raw_import.py new file mode 100644 index 00000000..58fb6e74 --- /dev/null +++ b/tests/test_external_oracle_raw_import.py @@ -0,0 +1,35 @@ +from pathlib import Path + +import pytest + +from scripts.external_oracle_raw_import import build_raw_oracle_import + + +def _metadata(**overrides): + value = { + "case_id": "public_case", + "license_boundary": "external benchmark only", + "collection_method": "manual export", + "birth_data_policy": "public_case_only", + } + value.update(overrides) + return value + + +def test_raw_import_requires_reviewable_public_case_artifact(tmp_path: Path): + artifact = tmp_path / "oracle.json" + artifact.write_text('{"raw": true}', encoding="utf-8") + + result = build_raw_oracle_import("VedAstro", artifact, _metadata()) + + assert result["status"] == "raw_imported_uncompared" + assert result["source_artifact_sha256"] + assert result["comparison_ready"] is False + + +def test_raw_import_rejects_non_public_birth_policy(tmp_path: Path): + artifact = tmp_path / "oracle.json" + artifact.write_text("{}", encoding="utf-8") + + with pytest.raises(ValueError, match="public_case_only"): + build_raw_oracle_import("VedAstro", artifact, _metadata(birth_data_policy="private")) diff --git a/tests/test_frontend_productization.py b/tests/test_frontend_productization.py index 48e3daa4..5cee86f9 100644 --- a/tests/test_frontend_productization.py +++ b/tests/test_frontend_productization.py @@ -1397,6 +1397,8 @@ def test_api_bridge_exports_productized_backend_actions() -> None: "computeYogas", "computeAspects", "computeRectificationGate", + "computeActiveRectificationQuestions", + "computeActiveRectificationScore", "computeCaseValidation", "computeDivisionalYoga", "computeKakshya", @@ -1613,6 +1615,33 @@ def test_rectification_ui_guides_yes_no_interview_from_existing_event_assets() - assert token in rect_ui +def test_rectification_ui_exposes_active_questionnaire_api_wizard() -> None: + rect_ui = read("rectification.js") + bridge = read("api-bridge.js") + public_bridge = read("public/api-bridge.js") + for token in [ + "rect-active-wizard", + "data-active-rectification-wizard", + "rect-active-birth-time", + "rect-active-uncertainty", + "rect-active-load", + "rect-active-score", + "computeActiveRectificationQuestions", + "computeActiveRectificationScore", + "active_rectification_questions", + "candidate_cluster_rankings", + "next_round_questions", + "data-active-answer", + "activeRectificationAnswers", + ]: + assert token in rect_ui + for source in (bridge, public_bridge): + assert "computeActiveRectificationQuestions" in source + assert "computeActiveRectificationScore" in source + assert "/api/active_rectification_questions" in source + assert "/api/active_rectification_score" in source + + def test_web_entry_prefers_unified_consultation_workflow() -> None: bridge = read("api-bridge.js") public_bridge = read("public/api-bridge.js") diff --git a/tests/test_full_reading_saham_contract.py b/tests/test_full_reading_saham_contract.py new file mode 100644 index 00000000..db7fbd57 --- /dev/null +++ b/tests/test_full_reading_saham_contract.py @@ -0,0 +1,15 @@ +from pathlib import Path + + +def test_full_reading_derives_saham_datetime_from_standard_chart_args() -> None: + source = (Path(__file__).resolve().parents[1] / "scripts" / "jyotish_engine.py").read_text(encoding="utf-8") + section = source[source.index("# ── Step 4.8: Tajika Yogas + Sahams"):source.index("# ── Step 4.9:")] + assert "birth_dt = _birth_datetime_from_args(args)" in section + assert "getattr(args, 'birth_datetime'" not in section + + +def test_full_reading_supplies_actual_speeds_to_tajika_kernel() -> None: + source = (Path(__file__).resolve().parents[1] / "scripts" / "jyotish_engine.py").read_text(encoding="utf-8") + section = source[source.index("# ── Step 4.8: Tajika Yogas + Sahams"):source.index("# ── Step 4.9:")] + assert '"longitude": item.get("degree_raw", item.get("lon"))' in section + assert '"speed": item.get("speed")' in section diff --git a/tests/test_gulika.py b/tests/test_gulika.py new file mode 100644 index 00000000..0fa09021 --- /dev/null +++ b/tests/test_gulika.py @@ -0,0 +1,17 @@ +from datetime import datetime + +from scripts.gulika import GHATIKA_END, calculate_gulika + + +def test_gulika_uses_prasna_marga_weekday_table() -> None: + assert GHATIKA_END[6] == {"day": 26, "night": 10} + assert GHATIKA_END[0] == {"day": 22, "night": 6} + + +def test_gulika_returns_sidereal_segment_ascendant_with_audit_trace() -> None: + result = calculate_gulika(datetime(1990, 6, 15, 12, 0), lat=39.9042, lon=116.4074, tz=8) + + assert result["status"] == "partial" + assert 0 <= result["longitude"] < 360 + assert result["ghatika_end"] in range(0, 31) + assert result["rule_source"].endswith("#3.5") diff --git a/tests/test_jaimini_rangacharya_api.py b/tests/test_jaimini_rangacharya_api.py new file mode 100644 index 00000000..1f649542 --- /dev/null +++ b/tests/test_jaimini_rangacharya_api.py @@ -0,0 +1,50 @@ +import os +import sys + + +SCRIPTS = os.path.join(os.path.dirname(__file__), "..", "scripts") +if SCRIPTS not in sys.path: + sys.path.insert(0, SCRIPTS) + +from jyotish_api_server import JyotishAPIHandler # noqa: E402 + + +def _handler() -> JyotishAPIHandler: + return JyotishAPIHandler.__new__(JyotishAPIHandler) + + +def _planets(): + return { + "Sun": {"lon": 10.0}, + "Moon": {"lon": 45.0}, + "Mars": {"lon": 80.0}, + "Mercury": {"lon": 110.0}, + "Jupiter": {"lon": 145.0}, + "Venus": {"lon": 200.0}, + "Saturn": {"lon": 250.0}, + "Rahu": {"lon": 300.0}, + "Ketu": {"lon": 120.0}, + } + + +def test_jaimini_default_does_not_include_rangacharya(): + result = _handler()._compute_jaimini({ + "mode": "arudha", + "ascendant": {"lon": 0.0}, + "planets": _planets(), + }) + assert "rangacharya" not in result["result"] + assert "rangacharya_diff" not in result["result"] + + +def test_jaimini_variant_all_includes_current_variant_and_diff(): + result = _handler()._compute_jaimini({ + "mode": "arudha", + "variant": "all", + "ascendant": {"lon": 0.0}, + "planets": _planets(), + }) + assert result["result"]["rangacharya"]["adjudication_enabled"] is False + assert result["result"]["rangacharya_diff"]["adjudication_enabled"] is False + assert result["result"]["rangacharya"]["arudha_padas"]["AL"]["source_card_status"] == "transcribed" + assert result["result"]["rangacharya"]["active_lagna"]["source_card_id"] == "active_effective_lagna" diff --git a/tests/test_ocr_extract.py b/tests/test_ocr_extract.py new file mode 100644 index 00000000..c48a5f6b --- /dev/null +++ b/tests/test_ocr_extract.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import ocr_extract # noqa: E402 + + +def test_manual_transcript_backend_writes_jsonl(tmp_path: Path) -> None: + image = tmp_path / "IMG_3502.PNG" + image.write_bytes(b"not-a-real-image") + transcript = tmp_path / "IMG_3502.txt" + transcript.write_text("Arudha Lagna\nUpapada\n", encoding="utf-8") + output = tmp_path / "ocr.jsonl" + + report = ocr_extract.extract_many([image], output=output, transcript_dir=tmp_path, backend="manual") + + assert report["status"] == "ok" + assert report["items"][0]["backend"] == "manual" + rows = [json.loads(line) for line in output.read_text(encoding="utf-8").splitlines()] + assert rows == [ + { + "image_path": str(image), + "text": "Arudha Lagna\nUpapada\n", + "backend": "manual", + "status": "ok", + } + ] + + +def test_missing_manual_transcript_is_blocked(tmp_path: Path) -> None: + image = tmp_path / "IMG_3503.PNG" + image.write_bytes(b"not-a-real-image") + + report = ocr_extract.extract_many([image], transcript_dir=tmp_path, backend="manual") + + assert report["status"] == "blocked" + assert report["items"][0]["reason"] == "manual_transcript_missing" + + +def test_backend_auto_prefers_shortcuts_before_tesseract(monkeypatch) -> None: + monkeypatch.setattr(ocr_extract.shutil, "which", lambda name: f"/usr/bin/{name}" if name in {"shortcuts", "tesseract"} else None) + + assert ocr_extract.choose_backend("auto") == "shortcuts" diff --git a/tests/test_prashna_context.py b/tests/test_prashna_context.py new file mode 100644 index 00000000..6603e532 --- /dev/null +++ b/tests/test_prashna_context.py @@ -0,0 +1,32 @@ +from datetime import datetime + +import pytest + +from scripts.prashna_context import PrashnaContextError, build_prashna_context + + +def test_prashna_context_uses_backend_chart_from_question_moment(): + packet = build_prashna_context({ + "question_text": "Will this proceed?", + "question_timestamp": "2026-07-12T12:00:00+08:00", + "lat": 39.9042, + "lon": 116.4074, + "timezone": 8, + "ayanamsa": "lahiri", + "node_mode": "mean", + "location_convention": "wgs84", + }) + + assert packet["status"] == "computed" + assert packet["chart_source"] == "swiss_ephemeris_backend" + assert packet["ascendant"]["degree"] >= 0 + assert "Sun" in packet["planets"] + assert "question_timestamp" in packet + + +def test_prashna_context_rejects_missing_time_or_non_wgs84_location(): + base = {"question_text": "x", "lat": 1, "lon": 1, "timezone": 0} + with pytest.raises(PrashnaContextError, match="question_timestamp"): + build_prashna_context(base) + with pytest.raises(PrashnaContextError, match="location_convention"): + build_prashna_context({**base, "question_timestamp": "2026-01-01T00:00:00+00:00", "location_convention": "unknown"}) diff --git a/tests/test_prashna_entry_contract.py b/tests/test_prashna_entry_contract.py new file mode 100644 index 00000000..62185733 --- /dev/null +++ b/tests/test_prashna_entry_contract.py @@ -0,0 +1,62 @@ +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from scripts.jyotish_api_server import BadRequest, JyotishAPIHandler + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_cli_prashna_uses_question_moment_swiss_context_only(): + result = subprocess.run([ + sys.executable, "scripts/jyotish_engine.py", "prashna", + "--datetime", "2026-07-12T12:00:00+08:00", "--question-text", "Test question", + "--lat", "39.9042", "--lon", "116.4074", "--timezone", "8", + ], cwd=ROOT, text=True, capture_output=True, timeout=30, check=True) + payload = json.loads(result.stdout) + + assert payload["status"] == "computed" + assert payload["chart_source"] == "swiss_ephemeris_backend" + assert payload["supporting_indicators"]["gulika"]["status"] == "partial" + assert payload["supporting_indicators"]["sphuta"]["status"] == "partial" + assert "Kunda" in payload["blocked_layers"] + + +def test_cli_prashna_blocks_legacy_approximation_modes(): + result = subprocess.run([ + sys.executable, "scripts/jyotish_engine.py", "prashna", + "--datetime", "2026-07-12T12:00:00+08:00", "--question-text", "Test question", + "--lat", "39.9042", "--lon", "116.4074", "--timezone", "8", "--mode", "sphutas", + ], cwd=ROOT, text=True, capture_output=True, timeout=30, check=True) + payload = json.loads(result.stdout) + + assert payload["status"] == "blocked" + assert "sphutas" in payload["reason"] + + +def test_api_prashna_rejects_client_planets_and_computes_context(): + handler = JyotishAPIHandler.__new__(JyotishAPIHandler) + body = { + "question_text": "Test question", "question_timestamp": "2026-07-12T12:00:00+08:00", + "lat": 39.9042, "lon": 116.4074, "timezone": 8, + "ayanamsa": "lahiri", "node_mode": "mean", "location_convention": "wgs84", + } + result = handler._compute_prashna(body) + assert result["prashna_context"]["chart_source"] == "swiss_ephemeris_backend" + with pytest.raises(BadRequest, match="forbidden"): + handler._compute_prashna({**body, "planets": {"Sun": 0}}) + + +def test_web_prashna_collects_question_context_not_natal_chart(): + source = (ROOT / "jyotish-app" / "main.js").read_text(encoding="utf-8") + markup = (ROOT / "jyotish-app" / "index.html").read_text(encoding="utf-8") + + assert "question_timestamp: timestamp.value" in source + assert "planets: chartData?.planets" not in source + assert "asc_degree: chartData?.ascendant" not in source + for field in ("prashna-timestamp", "prashna-lat", "prashna-lon", "prashna-timezone"): + assert field in markup diff --git a/tests/test_prashna_sphuta.py b/tests/test_prashna_sphuta.py new file mode 100644 index 00000000..469902db --- /dev/null +++ b/tests/test_prashna_sphuta.py @@ -0,0 +1,23 @@ +from scripts.prashna_sphuta import calculate_sphuta_evidence + + +def test_sphuta_formula_evidence_uses_exact_gulika_input() -> None: + result = calculate_sphuta_evidence( + ascendant_longitude=10, + planet_longitudes={"Moon": 20, "Sun": 30, "Rahu": 40}, + gulika_longitude=50, + ) + + assert result["status"] == "partial" + assert result["points"] == {"trisphuta": 80.0, "catusphuta": 110.0, "pancasphuta": 150.0} + + +def test_sphuta_evidence_blocks_missing_required_planet() -> None: + result = calculate_sphuta_evidence( + ascendant_longitude=10, + planet_longitudes={"Moon": 20, "Sun": 30}, + gulika_longitude=50, + ) + + assert result["status"] == "blocked" + assert result["missing"] == ["Rahu"] diff --git a/tests/test_public_real_case_benchmark.py b/tests/test_public_real_case_benchmark.py new file mode 100644 index 00000000..644bf5c9 --- /dev/null +++ b/tests/test_public_real_case_benchmark.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from scripts.public_real_case_benchmark import ( + _engine_json, + ashtakavarga_audit, + clear_engine_cache, + combine_reports, + compare_reports, + node_dispositor_bonus, + promotion_decision, + score_active_dasha_lords, + summarize_results, + varga_and_karaka_bonus, +) + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_summary_reports_positive_recall_without_inventing_specificity() -> None: + summary = summarize_results( + [ + {"result_class": "strong_hit", "matched_expected_label": True, "blocked": False}, + {"result_class": "weak_hit", "matched_expected_label": False, "blocked": False}, + {"result_class": "miss", "matched_expected_label": False, "blocked": False}, + {"result_class": "blocked", "matched_expected_label": False, "blocked": True}, + ] + ) + assert summary["positive_event_recall"] == 2 / 3 + assert summary["exact_label_rate"] == 1 / 3 + assert summary["blocked_rate"] == 1 / 4 + assert summary["balanced_accuracy"] is None + assert summary["balanced_accuracy_blocked_reason"] == "no_verified_negative_control_dates" + assert summary["known_event_activation_rate"] == 2 / 3 + assert summary["strong_activation_rate"] == 1 / 3 + assert summary["positive_event_recall_deprecated"] is True + assert summary["exact_label_rate_deprecated"] is True + + +def test_v21_deduplicates_same_md_ad_lord() -> None: + chart = {"planets": {"Jupiter": {"house": 10}}} + roles = {"owned_houses": {"Jupiter": [9, 11]}} + + score, signals = score_active_dasha_lords( + ["Jupiter", "Jupiter"], {6, 9, 10, 11}, chart, roles, {"Sun", "Saturn", "Mercury"} + ) + + assert score == 3 + assert signals.count("Jupiter_owns_event_houses:[9, 11]") == 1 + assert signals.count("Jupiter_occupies_event_house:10") == 1 + + +def test_ashtakavarga_audit_reports_event_houses_and_transit_bav_without_scoring() -> None: + audit = ashtakavarga_audit( + "marriage", + { + "method": "Ashtakavarga", + "version": "2.1", + "sav": {"total": 337, "valid": True, "scores": {"Libra": 30, "Sagittarius": 27}}, + "all_bav_valid": True, + "house_scores": { + "house_2": {"sign": "Leo", "sav_score": 24}, + "house_5": {"sign": "Scorpio", "sav_score": 29}, + "house_7": {"sign": "Capricorn", "sav_score": 31}, + "house_11": {"sign": "Taurus", "sav_score": 32}, + }, + "bav": { + "Jupiter": {"bindus": [0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0], "total": 56, "valid": True}, + "Saturn": {"bindus": [0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0], "total": 39, "valid": True}, + }, + }, + { + "ayanamsa": 24.1, + "node_mode": "mean", + "planets": {"Jupiter": {"sign": "Libra"}, "Saturn": {"sign": "Sagittarius"}}, + }, + ) + + assert audit["status"] == "used_non_scoring" + assert audit["sav_total"] == 337 + assert audit["event_house_sav"]["7"]["sav_score"] == 31 + assert audit["transit_support"]["Jupiter"] == {"sign": "Libra", "sav": 30, "bav": 5} + assert audit["transit_support"]["Saturn"] == {"sign": "Sagittarius", "sav": 27, "bav": 4} + + +def test_engine_json_cache_reuses_identical_subject_command(monkeypatch) -> None: + calls = [] + + class Completed: + stdout = '{"ok": true}' + + def fake_run(*args, **kwargs): + calls.append((args, kwargs)) + return Completed() + + clear_engine_cache() + monkeypatch.setattr("scripts.public_real_case_benchmark.subprocess.run", fake_run) + subject = {"year": 2000, "month": 1, "day": 1, "hour": 12, "minute": 0, "lat": 0, "lon": 0, "tz": 0} + + assert _engine_json("chart", subject) == {"ok": True} + assert _engine_json("chart", subject) == {"ok": True} + assert len(calls) == 1 + clear_engine_cache() + + +def test_committed_report_matches_the_ten_case_manifest() -> None: + manifest = json.loads((ROOT / "references/real_case_calibration/replay_manifest.json").read_text(encoding="utf-8")) + report = json.loads((ROOT / "docs/benchmark/public_real_case_benchmark_2026_07_11.json").read_text(encoding="utf-8")) + assert {case["case_id"] for case in manifest["cases"]} == {case["case_id"] for case in report["cases"]} + assert report["summary"]["total_events"] == 10 + assert report["summary"]["positive_event_recall"] == 0.8 + assert report["summary"]["balanced_accuracy"] is None + + +def test_v2_node_dispositor_adds_general_event_house_support() -> None: + chart = { + "planets": { + "Rahu": {"sign": "Taurus", "house": 3}, + "Venus": {"sign": "Capricorn", "house": 10}, + } + } + roles = {"owned_houses": {"Venus": [3, 10]}} + score, signals = node_dispositor_bonus({"Rahu"}, "career", chart, roles) + assert score == 2 + assert "Rahu_dispositor_Venus_owns_event_house" in signals + assert "Rahu_dispositor_Venus_occupies_event_house:10" in signals + + +def test_v2_varga_and_chara_karaka_support_is_domain_specific() -> None: + varga = { + "divisional_charts": { + "D10_Dasamsa": { + "ascendant": "Taurus", + "Saturn": {"sign": "Aquarius"}, + } + } + } + jaimini = { + "chara_karaka_7": { + "karaka_table": {"Amatyakaraka": {"planet": "Saturn"}} + } + } + score, signals = varga_and_karaka_bonus({"Saturn"}, "career", varga, jaimini) + assert score == 3 + assert "active_dasha_matches_D10_10L:Saturn" in signals + assert "active_dasha_occupies_D10_house_10:Saturn" in signals + assert "active_dasha_matches_Amatyakaraka:Saturn" in signals + + +def test_v2_promotion_requires_holdout_improvement_without_more_blocking() -> None: + promoted = promotion_decision( + {"positive_event_recall": 0.6, "exact_label_rate": 0.2, "blocked_events": 0}, + {"positive_event_recall": 0.8, "exact_label_rate": 0.4, "blocked_events": 0}, + ) + assert promoted["promote"] is True + blocked = promotion_decision( + {"positive_event_recall": 0.6, "exact_label_rate": 0.2, "blocked_events": 0}, + {"positive_event_recall": 0.8, "exact_label_rate": 0.4, "blocked_events": 1}, + ) + assert blocked["promote"] is False + assert blocked["reason"] == "v2_increased_blocked_events" + + +def test_compare_reports_keeps_case_level_deltas_auditable() -> None: + v1 = { + "rule_version": "v1", + "summary": {"positive_event_recall": 0.5, "exact_label_rate": 0.0, "blocked_events": 0}, + "cases": [{"case_id": "case-a", "score": 3, "result_class": "miss", "signals": ["base"]}], + } + v2 = { + "rule_version": "v2", + "summary": {"positive_event_recall": 1.0, "exact_label_rate": 1.0, "blocked_events": 0}, + "cases": [{"case_id": "case-a", "score": 7, "result_class": "strong_hit", "signals": ["base", "new"]}], + } + + comparison = compare_reports(v1, v2) + + assert comparison["promotion"]["promote"] is True + assert comparison["case_deltas"] == [ + { + "case_id": "case-a", + "v1_score": 3, + "v2_score": 7, + "score_delta": 4, + "v1_result_class": "miss", + "v2_result_class": "strong_hit", + "added_signals": ["new"], + } + ] + + +def test_combine_reports_recomputes_twenty_case_summary() -> None: + batch1 = {"cases": [{"case_id": "a", "result_class": "strong_hit", "matched_expected_label": True, "blocked": False}]} + holdout = {"cases": [{"case_id": "b", "result_class": "miss", "matched_expected_label": False, "blocked": False}]} + + combined = combine_reports([batch1, holdout], {"promote": True, "reason": "holdout_metrics_improved"}) + + assert combined["summary"]["total_events"] == 2 + assert combined["summary"]["positive_event_recall"] == 0.5 + assert combined["holdout_promotion"]["promote"] is True + assert [row["case_id"] for row in combined["cases"]] == ["a", "b"] diff --git a/tests/test_rangacharya_adjudication_guard.py b/tests/test_rangacharya_adjudication_guard.py new file mode 100644 index 00000000..0e624bdd --- /dev/null +++ b/tests/test_rangacharya_adjudication_guard.py @@ -0,0 +1,16 @@ +import pytest + +from scripts import rangacharya + + +def test_assert_adjudication_allowed_rejects_default_variant(): + result = rangacharya.calc_rangacharya_variant(0, {"Sun": 10.0}) + with pytest.raises(rangacharya.RangacharyaValidationError): + rangacharya.assert_adjudication_allowed(result) + + +def test_validation_summary_lists_blocking_rules(): + result = rangacharya.calc_rangacharya_variant(0, {"Sun": 10.0}) + summary = rangacharya.validation_summary(result) + assert summary["adjudication_enabled"] is False + assert summary["blocking_statuses"] diff --git a/tests/test_rangacharya_readiness.py b/tests/test_rangacharya_readiness.py new file mode 100644 index 00000000..0aaf82db --- /dev/null +++ b/tests/test_rangacharya_readiness.py @@ -0,0 +1,14 @@ +from scripts import rangacharya_readiness + + +def test_readiness_reports_blocked_until_no_adjudication_cards(): + report = rangacharya_readiness.build_report() + assert report["scope"] == "rangacharya_readiness" + assert report["adjudication_enabled"] is False + assert report["blocked_count"] >= 1 + assert "rangacharya_core_arudha" in report["cards"] + + +def test_readiness_does_not_expose_secrets(): + report = rangacharya_readiness.build_report() + assert "sk_live_" not in str(report) diff --git a/tests/test_rangacharya_registry.py b/tests/test_rangacharya_registry.py new file mode 100644 index 00000000..5965c181 --- /dev/null +++ b/tests/test_rangacharya_registry.py @@ -0,0 +1,17 @@ +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +REGISTRY = ROOT / "references" / "technique_registry.json" + + +def test_rangacharya_variant_registered_as_comparison_only(): + techniques = json.loads(REGISTRY.read_text(encoding="utf-8"))["techniques"] + entry = techniques["rangacharya_jaimini_variant"] + assert entry["status"] == "comparison-only" + assert entry["verification_level"]["calculation"] == "experimental" + assert entry["verification_level"]["prediction"] == "blocked" + assert entry["conclusion_policy"] == "Display current-vs-variant differences only; do not use for verdicts or timing." + assert entry["evidence_role"] == "comparison_only" + assert "references/rangacharya_source_cards.json" in entry["knowledge_refs"] diff --git a/tests/test_rangacharya_source_cards.py b/tests/test_rangacharya_source_cards.py new file mode 100644 index 00000000..2fb570c7 --- /dev/null +++ b/tests/test_rangacharya_source_cards.py @@ -0,0 +1,50 @@ +import json +from pathlib import Path + + +CARDS = Path("references/rangacharya_source_cards.json") + + +REQUIRED_CARD_IDS = { + "rangacharya_core_arudha", + "active_effective_lagna", + "prakriti_sanmukha", + "rangacharya_special_mappings", + "rangacharya_named_yogas", + "rangacharya_ul_family_rules", + "article_warehouse_future_tracks", +} + + +def _cards(): + return json.loads(CARDS.read_text(encoding="utf-8")) + + +def test_source_cards_exist_with_required_schema(): + data = _cards() + assert data["schema_version"] == 1 + assert isinstance(data["cards"], list) + for card in data["cards"]: + assert card["id"] + assert card["status"] in { + "transcribed", + "source_verified", + "golden_verified", + "engine_cross_checked", + "case_calibrated", + "blocked", + } + assert card["adjudication_enabled"] is False + assert "evidence" in card + + +def test_source_cards_cover_phase2_rule_groups(): + data = _cards() + found = {card["id"] for card in data["cards"]} + assert REQUIRED_CARD_IDS <= found + + +def test_source_cards_do_not_contain_secrets(): + text = CARDS.read_text(encoding="utf-8") + assert "sk_live_" not in text + assert "VEDASTRO_API_KEY" not in text diff --git a/tests/test_rangacharya_source_manifest.py b/tests/test_rangacharya_source_manifest.py new file mode 100644 index 00000000..0f13d149 --- /dev/null +++ b/tests/test_rangacharya_source_manifest.py @@ -0,0 +1,50 @@ +import json +from pathlib import Path + + +MANIFEST = Path("references/rangacharya_source_manifest.json") + + +def test_manifest_exists_and_has_required_sections(): + data = json.loads(MANIFEST.read_text(encoding="utf-8")) + assert data["schema_version"] == 1 + assert "sources" in data + assert "rules" in data + assert "validation_ladder" in data + + +def test_manifest_does_not_contain_secrets(): + text = MANIFEST.read_text(encoding="utf-8") + assert "sk_live_" not in text + assert "api_key" not in text.lower() + + +def test_rules_default_below_adjudication(): + data = json.loads(MANIFEST.read_text(encoding="utf-8")) + for rule in data["rules"]: + assert rule["status"] in { + "transcribed", + "source_verified", + "golden_verified", + "engine_cross_checked", + "case_calibrated", + "blocked", + } + assert rule["status"] != "adjudication_enabled" + assert rule["adjudication_enabled"] is False + + +def test_screenshot_source_paths_are_located_and_hashed(): + data = json.loads(MANIFEST.read_text(encoding="utf-8")) + source = next(item for item in data["sources"] if item["id"] == "uploaded_screenshots_20260716") + assert source["extraction_status"] == "located_and_hashed_ocr_blocked_tesseract_missing" + assert len(source["paths"]) == 6 + assert all("/文件仓库/印度占星文章/260716/" in path for path in source["paths"]) + assert set(source["sha256"]) == { + "IMG_3502.PNG", + "IMG_3503.PNG", + "IMG_3504.PNG", + "IMG_3505.PNG", + "IMG_3506.PNG", + "IMG_3507.PNG", + } diff --git a/tests/test_rangacharya_variant.py b/tests/test_rangacharya_variant.py new file mode 100644 index 00000000..a263a848 --- /dev/null +++ b/tests/test_rangacharya_variant.py @@ -0,0 +1,42 @@ +from scripts import rangacharya + + +SAMPLE_LONGS = { + "Sun": 10.0, + "Moon": 45.0, + "Mars": 80.0, + "Mercury": 110.0, + "Jupiter": 145.0, + "Venus": 200.0, + "Saturn": 250.0, + "Rahu": 300.0, + "Ketu": 120.0, +} + + +def test_variant_result_is_experimental_and_not_for_adjudication(): + result = rangacharya.calc_rangacharya_variant(0, SAMPLE_LONGS) + assert result["variant"] == "rangacharya" + assert result["adjudication_enabled"] is False + assert result["status"] == "experimental_not_for_adjudication" + + +def test_variant_includes_core_sections(): + result = rangacharya.calc_rangacharya_variant(0, SAMPLE_LONGS) + assert "source_status" in result + assert "arudha_padas" in result + assert "active_lagna" in result + assert "effective_lagna" in result + assert result["arudha_padas"]["AL"]["source_card_id"] == "rangacharya_core_arudha" + assert result["arudha_padas"]["AL"]["source_card_status"] == "transcribed" + assert result["active_lagna"]["source_card_id"] == "active_effective_lagna" + assert result["effective_lagna"]["source_card_id"] == "active_effective_lagna" + + +def test_diff_marks_algorithm_names(): + current = {"AL": {"sign": "Aries"}} + variant = {"arudha_padas": {"AL": {"sign": "Taurus"}}} + diff = rangacharya.diff_current_vs_rangacharya(current, variant) + assert diff["current_algorithm"] == "current_jaimini" + assert diff["variant_algorithm"] == "rangacharya" + assert diff["differences"][0]["key"] == "AL.sign" diff --git a/tests/test_real_case_negative_controls.py b/tests/test_real_case_negative_controls.py new file mode 100644 index 00000000..2eb8183d --- /dev/null +++ b/tests/test_real_case_negative_controls.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from scripts.public_real_case_negative_controls import ( + generate_control_dates, + rank_positive_against_controls, + summarize_negative_control_rows, +) + + +def test_generate_control_dates_uses_fixed_offsets_without_positive_date() -> None: + dates = generate_control_dates("2018-05-19", offsets=(-60, -30, 30, 60)) + + assert dates == ["2018-03-20", "2018-04-19", "2018-06-18", "2018-07-18"] + assert "2018-05-19" not in dates + + +def test_rank_positive_uses_conservative_tie_ordering() -> None: + result = rank_positive_against_controls(5, [7, 5, 4]) + + assert result == { + "positive_score": 5, + "positive_rank": 3, + "candidate_count": 4, + "reciprocal_rank": 1 / 3, + "top_1": False, + "top_3": True, + "max_control_score": 7, + "score_margin": -2, + } + + +def test_negative_control_summary_reports_false_activations() -> None: + summary = summarize_negative_control_rows( + [ + { + "ranking": {"top_1": True, "top_3": True, "reciprocal_rank": 1.0, "score_margin": 2}, + "controls": [{"score": 2}, {"score": 3}], + }, + { + "ranking": {"top_1": False, "top_3": True, "reciprocal_rank": 0.5, "score_margin": -1}, + "controls": [{"score": 4}, {"score": 7}], + }, + ] + ) + + assert summary["case_count"] == 2 + assert summary["control_date_count"] == 4 + assert summary["control_activation_rate"] == 0.5 + assert summary["control_strong_activation_rate"] == 0.25 + assert summary["positive_top_1_rate"] == 0.5 + assert summary["positive_top_3_rate"] == 1.0 + assert summary["mean_reciprocal_rank"] == 0.75 + assert summary["mean_score_margin"] == 0.5 diff --git a/tests/test_report_renderer_isolation_poc.py b/tests/test_report_renderer_isolation_poc.py new file mode 100644 index 00000000..108a6edd --- /dev/null +++ b/tests/test_report_renderer_isolation_poc.py @@ -0,0 +1,9 @@ +from scripts.report_renderer_isolation_poc import run_poc + + +def test_report_renderer_isolation_poc_never_claims_pass_without_browser() -> None: + result = run_poc() + assert result["status"] in {"pass", "fail", "blocked"} + if result["status"] == "pass": + assert result["http_probe_requests"] == 0 + assert result["blocked_resource_count"] >= 2 diff --git a/tests/test_runtime_security_p0.py b/tests/test_runtime_security_p0.py new file mode 100644 index 00000000..07bb0b81 --- /dev/null +++ b/tests/test_runtime_security_p0.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import hashlib +import sys +import time +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import jyotish_api_server as api # noqa: E402 +import report_builder # noqa: E402 + + +class _Headers(dict): + def get(self, key, default=None): + return super().get(key, default) + + +class _Server: + allowed_origins = {"http://localhost:3456"} + server_address = ("127.0.0.1", 5200) + + +def _handler(headers: dict[str, str]): + handler = api.JyotishAPIHandler.__new__(api.JyotishAPIHandler) + handler.headers = _Headers(headers) + handler.server = _Server() + return handler + + +def test_untrusted_origin_is_rejected_before_post_side_effects() -> None: + handler = _handler( + { + "Origin": "https://evil.example", + "Host": "127.0.0.1:5200", + "Content-Type": "application/json", + } + ) + with pytest.raises(api.Forbidden, match="Origin"): + handler._enforce_request_security(require_json=True) + + +def test_post_requires_json_content_type() -> None: + handler = _handler( + { + "Origin": "http://localhost:3456", + "Host": "127.0.0.1:5200", + "Content-Type": "text/plain", + } + ) + with pytest.raises(api.UnsupportedMediaType): + handler._enforce_request_security(require_json=True) + + +@pytest.mark.parametrize( + "url", + [ + "https://example.com/image.png", + "http://127.0.0.1:8080/private", + "file:///etc/passwd", + "ftp://example.com/file", + ], +) +def test_report_renderer_blocks_external_and_local_resources(url: str) -> None: + assert report_builder.is_allowed_report_resource_url( + url, + report_url="file:///tmp/report.html", + ) is False + + +def test_report_renderer_allows_only_document_and_embedded_resources() -> None: + assert report_builder.is_allowed_report_resource_url( + "file:///tmp/report.html", + report_url="file:///tmp/report.html", + ) is True + assert report_builder.is_allowed_report_resource_url( + "data:image/png;base64,AA==", + report_url="file:///tmp/report.html", + ) is True + + +def test_async_job_identity_is_random_and_capability_protected( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(api, "_async_job_dir", lambda _scope: tmp_path) + first = api._new_async_job_identity("chart") + second = api._new_async_job_identity("chart") + assert first["job_id"] != second["job_id"] + assert len(first["job_id"].split("_", 1)[1]) >= 32 + assert first["access_token"] != second["access_token"] + + record = { + "job_id": first["job_id"], + "status": "queued", + "access_token_hash": hashlib.sha256(first["access_token"].encode()).hexdigest(), + "expires_at_unix": time.time() + 60, + } + api._write_async_job_record("chart", first["job_id"], record) + assert api._load_async_job_record( + "chart", first["job_id"], access_token=first["access_token"] + )["status"] == "queued" + with pytest.raises(api.JobAccessDenied): + api._load_async_job_record("chart", first["job_id"], access_token="wrong") + + +def test_expired_async_job_is_deleted( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + monkeypatch.setattr(api, "_async_job_dir", lambda _scope: tmp_path) + identity = api._new_async_job_identity("chart") + api._write_async_job_record( + "chart", + identity["job_id"], + { + "job_id": identity["job_id"], + "access_token_hash": hashlib.sha256(identity["access_token"].encode()).hexdigest(), + "expires_at_unix": time.time() - 1, + }, + ) + assert api._load_async_job_record( + "chart", identity["job_id"], access_token=identity["access_token"] + ) is None + assert not (tmp_path / f"{identity['job_id']}.json").exists() + + +def test_authenticated_frontend_does_not_use_local_storage_api_base() -> None: + auth_source = (ROOT / "jyotish-app" / "auth.js").read_text(encoding="utf-8") + chat_source = (ROOT / "jyotish-app" / "ai-chat.js").read_text(encoding="utf-8") + assert "localStorage.getItem(API_BASE_KEY)" not in auth_source + assert "localStorage.getItem('jyotish_api_base')" not in chat_source + + +def test_frontend_async_poll_uses_ephemeral_job_capability() -> None: + bridge_source = (ROOT / "jyotish-app" / "api-bridge.js").read_text(encoding="utf-8") + assert "pollAsyncJob(data, { base })" in bridge_source + assert "Authorization: `Bearer ${job.access_token}`" in bridge_source + assert "sessionStorage.setItem('jyotish_job" not in bridge_source + + +def test_background_job_queue_rejects_when_capacity_is_full( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class _FullCapacity: + def acquire(self, blocking=False): + return False + + monkeypatch.setattr(api, "_ASYNC_JOB_CAPACITY", _FullCapacity()) + with pytest.raises(api.JobQueueFull): + api._submit_background_job(lambda: None) diff --git a/tests/test_saham_daynight.py b/tests/test_saham_daynight.py new file mode 100644 index 00000000..8ad32924 --- /dev/null +++ b/tests/test_saham_daynight.py @@ -0,0 +1,13 @@ +from datetime import datetime + +from scripts.saham_daynight import determine_daytime + + +def test_swiss_daynight_does_not_use_solar_house_proxy(): + noon = determine_daytime(datetime(2026, 7, 12, 12, 0), lat=39.9042, lon=116.4074, tz=8) + midnight = determine_daytime(datetime(2026, 7, 12, 0, 0), lat=39.9042, lon=116.4074, tz=8) + + assert noon["status"] == "computed" + assert noon["is_daytime"] is True + assert midnight["is_daytime"] is False + assert noon["method"] == "swisseph.rise_trans" diff --git a/tests/test_skill_experience.py b/tests/test_skill_experience.py new file mode 100644 index 00000000..7406995e --- /dev/null +++ b/tests/test_skill_experience.py @@ -0,0 +1,82 @@ +from scripts.skill_experience import ( + build_rectification_questionnaire, + build_skill_doctor, + build_skill_onboarding, + score_rectification_answers, + summarize_execution_status, +) + + +def test_onboarding_requests_only_missing_birth_fields(): + packet = build_skill_onboarding({"year": 1993, "month": 4, "day": 17}) + + assert packet["status"] == "needs_birth_data" + assert packet["entry_mode"] == "pending" + assert packet["missing_fields"] == ["hour", "minute", "lat", "lon"] + assert packet["next_action"] == "collect_birth_data" + + +def test_onboarding_selects_rectification_for_uncertain_time(): + packet = build_skill_onboarding({ + "year": 1993, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "lat": 36.68, + "lon": 114.35, + "time_uncertainty_minutes": 20, + }) + + assert packet["status"] == "ready" + assert packet["entry_mode"] == "rectification" + assert packet["next_action"] == "run_rectification_questionnaire" + assert packet["first_question"] + + +def test_execution_status_makes_official_fallback_machine_readable(): + status = summarize_execution_status({ + "fallback_reason": "VedAstro official snapshot blocked: official_snapshot_budget_exhausted", + "external_engine_cross_validation": { + "engines": {"VedAstro": {"status": "local_fallback"}} + }, + }) + + assert status["official_evidence_status"] == "official_blocked" + assert status["calculation_source"] == "local_fallback" + assert status["fallback_reason"] == "VedAstro official snapshot blocked: official_snapshot_budget_exhausted" + assert "official_verified" in status["allowed_claims"] + + +def test_doctor_has_machine_readable_core_and_adapter_state(): + packet = build_skill_doctor() + + assert packet["scope"] == "skill_doctor" + assert "core_assets" in packet + assert "external_engine_adapters" in packet + assert packet["status"] in {"ready", "degraded"} + + +def test_mcp_exposes_skill_experience_tools(): + import mcp_server + + onboarding = mcp_server.skill_onboarding({}) + doctor = mcp_server.skill_doctor() + + assert onboarding["scope"] == "skill_onboarding" + assert doctor["scope"] == "skill_doctor" + + +def test_rectification_contract_generates_and_scores_choice_answers(): + questionnaire = build_rectification_questionnaire({ + "year": 1993, "month": 4, "day": 17, "hour": 14, "minute": 49, + "time_uncertainty_minutes": 20, + }) + scored = score_rectification_answers(questionnaire, { + "education_environment_shift": "A", + "health_crisis_or_low_period": "C", + }) + + assert questionnaire["scope"] == "active_birth_time_rectification_questionnaire" + assert scored["scope"] == "active_birth_time_rectification_scoring" + assert scored["candidate_cluster_rankings"] diff --git a/tests/test_tajika_kernel.py b/tests/test_tajika_kernel.py new file mode 100644 index 00000000..14e41981 --- /dev/null +++ b/tests/test_tajika_kernel.py @@ -0,0 +1,33 @@ +from scripts.tajika_kernel import calculate_tajika_interactions + + +def _seven(): + return { + "Sun": {"longitude": 0, "speed": 1.0}, "Moon": {"longitude": 49, "speed": 13.0}, + "Mars": {"longitude": 180, "speed": 0.5}, "Mercury": {"longitude": 260, "speed": 1.2}, + "Jupiter": {"longitude": 310, "speed": 0.08}, "Venus": {"longitude": 130, "speed": 1.0}, + "Saturn": {"longitude": 220, "speed": 0.03}, "Rahu": {"longitude": 60, "speed": -0.05}, + } + + +def test_kernel_detects_cross_sign_aspect_and_excludes_nodes(): + result = calculate_tajika_interactions(_seven()) + + pair = next(row for row in result["interactions"] if row["planets"] == ["Sun", "Moon"]) + assert pair["aspect"] == 60.0 + assert pair["motion"] == "applying" + candidate = next(row for row in result["candidate_yogas"] if row["planets"] == ["Sun", "Moon"]) + assert candidate["name"] == "Ithasala_candidate" + assert candidate["status"] == "partial" + assert result["nodes_excluded"] is True + assert all("Rahu" not in row["planets"] for row in result["interactions"]) + + +def test_kernel_blocks_missing_speed_instead_of_guessing_motion(): + planets = _seven() + del planets["Venus"]["speed"] + + result = calculate_tajika_interactions(planets) + + assert result["status"] == "blocked" + assert "Venus" in result["missing"] diff --git a/tests/test_three_engine_parity_artifact_contract.py b/tests/test_three_engine_parity_artifact_contract.py new file mode 100644 index 00000000..c55e5013 --- /dev/null +++ b/tests/test_three_engine_parity_artifact_contract.py @@ -0,0 +1,21 @@ +import json + +from scripts.three_engine_parity_replay_validator import validate_manifest + + +def test_verified_oracle_requires_raw_artifact_hash_and_settings(tmp_path): + manifest = { + "engines": { + "VedAstro": {"status": "official_verified"}, + "PyJHora_JHora": {"status": "blocked"}, + "jyotishganit": {"status": "blocked"}, + }, + "comparison_rows": [], + } + path = tmp_path / "manifest.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + + result = validate_manifest(path) + + assert result["status"] == "invalid" + assert any(error["error"] == "required_for_verified_status" for error in result["errors"]) diff --git a/tests/test_three_engine_parity_runner.py b/tests/test_three_engine_parity_runner.py new file mode 100644 index 00000000..34aebc7e --- /dev/null +++ b/tests/test_three_engine_parity_runner.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "scripts" +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) + +import three_engine_parity_runner # noqa: E402 +from three_engine_parity_runner import build_public_case_replay # noqa: E402 + + +def test_public_same_chart_replay_never_promotes_missing_vedastro_raw(tmp_path: Path) -> None: + report = build_public_case_replay(output_dir=tmp_path, allow_vedastro_network=False) + + assert report["case_id"] == "steve_jobs_public_1955_lahiri" + assert report["birth_data_policy"] == "public_case_only" + assert report["engines"]["PyJHora_JHora"]["status"] == "structured_captured" + assert report["engines"]["jyotishganit"]["status"] == "raw_captured" + assert report["engines"]["VedAstro"]["status"] == "blocked" + assert report["status"] in {"partial", "blocked"} + assert report["tested"] is False + assert report["comparison_rows"] + assert any(row["status"] == "match" for row in report["comparison_rows"]) + assert all(row["status"] in {"blocked", "match", "not_comparable"} for row in report["comparison_rows"]) + + +def test_public_same_chart_replay_imports_verified_vedastro_artifact(tmp_path: Path, monkeypatch) -> None: + artifact_root = tmp_path / "vedastro_adapter" + artifact_root.mkdir() + artifact = artifact_root / "official_full_snapshot-abc-def.json" + artifact.write_text( + json.dumps( + { + "status": "ok", + "operation": "official_full_snapshot", + "raw_response": {"source": "vedastro_official_full_snapshot", "sections": {"chart_core": {}}}, + "request_manifest": { + "settings": {"ayanamsa": "lahiri", "node_mode": "mean"}, + "requests": [ + { + "body": { + "BirthTime": { + "StdTime": "19:15 24/02/1955 -08:00", + "Location": {"Latitude": 37.7749, "Longitude": -122.4194}, + } + } + } + ], + }, + "snapshot_sections": {"chart_core": {"Status": "Pass"}}, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(three_engine_parity_runner, "VEDASTRO_ARTIFACT_DIR", artifact_root) + + report = build_public_case_replay(output_dir=tmp_path / "out", allow_vedastro_network=True) + + assert report["engines"]["VedAstro"]["status"] == "official_verified" + assert report["engines"]["VedAstro"]["official_raw_response_path"] == str(artifact) + assert len(report["engines"]["VedAstro"]["artifact_hash"]) == 64 + assert report["blocked_reason"] == "none" + + +def test_public_same_chart_replay_rejects_wrong_vedastro_artifact(tmp_path: Path, monkeypatch) -> None: + artifact_root = tmp_path / "vedastro_adapter" + artifact_root.mkdir() + artifact = artifact_root / "official_full_snapshot-wrong-chart.json" + artifact.write_text( + json.dumps( + { + "status": "ok", + "raw_response": {"source": "vedastro_official_full_snapshot"}, + "request_manifest": { + "requests": [ + { + "body": { + "BirthTime": { + "StdTime": "12:00 01/01/1990 +05:30", + "Location": {"Latitude": 28.6139, "Longitude": 77.209}, + } + } + } + ] + }, + "snapshot_sections": {"chart_core": {"Status": "Pass"}}, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(three_engine_parity_runner, "VEDASTRO_ARTIFACT_DIR", artifact_root) + + report = build_public_case_replay(output_dir=tmp_path / "out", allow_vedastro_network=True) + + assert report["engines"]["VedAstro"]["status"] == "blocked" + assert report["engines"]["VedAstro"]["reason"] == "official_runner_requires_explicit_raw_capture_workflow" + + +def test_public_same_chart_replay_adds_normalized_d1_rows(tmp_path: Path, monkeypatch) -> None: + artifact_root = tmp_path / "vedastro_adapter" + artifact_root.mkdir() + artifact = artifact_root / "official_full_snapshot-abc-def.json" + artifact.write_text( + json.dumps( + { + "status": "ok", + "raw_response": {"source": "vedastro_official_full_snapshot"}, + "request_manifest": { + "requests": [ + { + "body": { + "BirthTime": { + "StdTime": "19:15 24/02/1955 -08:00", + "Location": {"Latitude": 37.7749, "Longitude": -122.4194}, + } + } + } + ] + }, + "snapshot_sections": { + "chart_core": { + "Sun": { + "Payload": { + "AllPlanetData": { + "PlanetNirayanaLongitude": {"TotalDegrees": "312.5122"}, + "PlanetRasiD1Sign": {"Name": "Aquarius"}, + } + } + } + } + }, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(three_engine_parity_runner, "VEDASTRO_ARTIFACT_DIR", artifact_root) + + report = build_public_case_replay(output_dir=tmp_path / "out", allow_vedastro_network=True) + rows = {(row["section"], row["field"]): row for row in report["comparison_rows"]} + + assert rows[("D1", "Sun.sign")]["status"] == "match" + assert rows[("D1", "Sun.longitude")]["status"] == "match" diff --git a/tests/test_western_chart_engine.py b/tests/test_western_chart_engine.py new file mode 100644 index 00000000..53bd88e1 --- /dev/null +++ b/tests/test_western_chart_engine.py @@ -0,0 +1,107 @@ +"""Regression tests for the native tropical Western chart calculator.""" + +from __future__ import annotations + +from scripts.western_chart_engine import ( + build_tropical_natal_chart, + build_tropical_western_evidence_packet, +) +from scripts.jyotish_api_server import _western_evidence_packet_from_body +from scripts.skill_release_package import _edition_files + + +_BIRTH = { + "year": 1993, + "month": 4, + "day": 17, + "hour": 14, + "minute": 49, + "latitude": 36.683333, + "longitude": 114.35, + "timezone": "Asia/Shanghai", +} + + +def test_native_engine_calculates_auditable_tropical_natal_chart() -> None: + chart = build_tropical_natal_chart(**_BIRTH) + + assert chart["source_engine"] == "pyswisseph_tropical" + assert chart["zodiac"] == "tropical" + assert chart["house_system"] == "P" + assert chart["natal"]["planets"]["sun"]["sign"] == "Aries" + assert 26 < chart["natal"]["planets"]["sun"]["longitude"] < 28 + assert set(chart["natal"]["angles"]) == {"ascendant", "mc", "descendant", "ic"} + assert len(chart["natal"]["houses"]) == 12 + assert all(1 <= planet["house"] <= 12 for planet in chart["natal"]["planets"].values()) + assert chart["natal"]["aspects"] + assert all(aspect["orb"] <= aspect["allowed_orb"] for aspect in chart["natal"]["aspects"]) + + +def test_native_engine_marks_timing_and_interpretation_boundaries() -> None: + packet = build_tropical_western_evidence_packet(**_BIRTH, route_packet={"primary_theme": "career"}) + + assert packet["status"] == "partial" + assert packet["calculation"]["status"] == "used" + assert packet["calculation"]["source_engine"] == "pyswisseph_tropical" + assert "timing_techniques" in packet["missing_sections"] + assert "signals" in packet["missing_sections"] + assert "does not calculate transits" in packet["boundary"] + + +def test_workflow_auto_materializes_native_western_natal_without_external_json() -> None: + packet = _western_evidence_packet_from_body( + {"entry_mode": "direct_chart", "western_mode": "auto"}, + {"primary_theme": "career"}, + birth_payload={ + "year": 1993, "month": 4, "day": 17, "hour": 14, "minute": 49, + "second": 0, "lat": 36.683333, "lon": 114.35, "tz": 8, + }, + ) + + assert packet is not None + assert packet["source_engine"] == "pyswisseph_tropical" + assert packet["status"] == "partial" + + +def test_workflow_does_not_auto_attach_natal_western_data_to_prashna() -> None: + packet = _western_evidence_packet_from_body( + {"entry_mode": "prashna", "western_mode": "auto"}, + {"primary_theme": "career"}, + birth_payload={ + "year": 1993, "month": 4, "day": 17, "hour": 14, "minute": 49, + "second": 0, "lat": 36.683333, "lon": 114.35, "tz": 8, + }, + ) + + assert packet is None + + +def test_workflow_adds_only_explicit_western_timing_layers() -> None: + packet = _western_evidence_packet_from_body( + { + "entry_mode": "direct_chart", + "western_timing": { + "transit_date": "2026-07-09", + "solar_return_year": 2026, + "secondary_progression_date": "2026-07-09", + "solar_arc_date": "2026-07-09", + }, + }, + {"primary_theme": "career"}, + birth_payload={ + "year": 1993, "month": 4, "day": 17, "hour": 14, "minute": 49, + "second": 0, "lat": 36.683333, "lon": 114.35, "tz": 8, + }, + ) + + assert set(packet["timing_techniques"]) == { + "transits", "solar_return", "secondary_progressions", "solar_arc_directions", + } + assert packet["sections"]["timing_techniques"]["status"] == "used" + + +def test_release_editions_include_native_western_calculator() -> None: + assert "scripts/western_chart_engine.py" in _edition_files("basic_git") + assert "scripts/western_chart_engine.py" in _edition_files("premium_cloud_drive") + assert "scripts/western_timing_engine.py" in _edition_files("basic_git") + assert "scripts/western_timing_engine.py" in _edition_files("premium_cloud_drive") diff --git a/tests/test_western_timing_engine.py b/tests/test_western_timing_engine.py new file mode 100644 index 00000000..7510c6eb --- /dev/null +++ b/tests/test_western_timing_engine.py @@ -0,0 +1,124 @@ +"""Regression tests for native Western timing calculations.""" + +from __future__ import annotations + +from scripts.western_timing_engine import ( + build_timing_techniques, + calculate_converse_secondary_progressions, + calculate_converse_solar_arc_directions, + calculate_lunar_return, + calculate_midpoints, + calculate_parans_status, + calculate_secondary_progressions, + calculate_solar_arc_directions, + calculate_solar_return, + calculate_transit_duration_scan, + calculate_transit_to_natal, +) + + +_BIRTH = { + "year": 1993, "month": 4, "day": 17, "hour": 14, "minute": 49, + "latitude": 36.683333, "longitude": 114.35, "timezone": "Asia/Shanghai", +} + + +def test_transit_to_natal_emits_orb_auditable_aspects() -> None: + transit = calculate_transit_to_natal(**_BIRTH, target_date="2026-07-09") + + assert transit["technique"] == "transits" + assert transit["target_date"] == "2026-07-09" + assert transit["aspects"] + assert all(row["orb"] <= row["allowed_orb"] for row in transit["aspects"]) + + +def test_solar_return_calculates_return_moment_and_chart() -> None: + solar_return = calculate_solar_return(**_BIRTH, target_year=2026) + + assert solar_return["technique"] == "solar_return" + assert solar_return["target_year"] == 2026 + assert solar_return["return_chart"]["natal"]["planets"]["sun"]["sign"] == "Aries" + assert solar_return["sun_longitude_delta"] < 0.001 + + +def test_timing_builder_only_contains_requested_techniques() -> None: + timing = build_timing_techniques(**_BIRTH, transit_date="2026-07-09", solar_return_year=2026) + + assert set(timing) == {"transits", "solar_return"} + + +def test_secondary_progressions_use_declared_day_for_year_contract() -> None: + progressions = calculate_secondary_progressions(**_BIRTH, target_date="2026-07-09") + + assert progressions["technique"] == "secondary_progressions" + assert progressions["method"] == "one_ephemeris_day_per_tropical_year" + assert progressions["progressed_planets"]["sun"]["longitude"] != progressions["natal_sun_longitude"] + assert progressions["aspects"] + + +def test_solar_arc_uses_secondary_progressed_sun_arc() -> None: + directions = calculate_solar_arc_directions(**_BIRTH, target_date="2026-07-09") + + assert directions["technique"] == "solar_arc_directions" + assert directions["method"] == "secondary_progressed_sun_arc" + assert 0 < directions["solar_arc_degrees"] < 40 + assert directions["directed_points"]["sun"]["longitude"] != directions["natal_sun_longitude"] + + +def test_converse_progressions_and_solar_arc_are_auditable() -> None: + progressions = calculate_converse_secondary_progressions(**_BIRTH, target_date="2026-07-09") + assert progressions["technique"] == "converse_secondary_progressions" + assert progressions["progressed_angles"]["status"] == "blocked" + directions = calculate_converse_solar_arc_directions(**_BIRTH, target_date="2026-07-09") + assert directions["technique"] == "converse_solar_arc_directions" + assert 0 < directions["converse_solar_arc_degrees"] < 40 + assert directions["directed_points"]["sun"]["longitude"] != directions["natal_sun_longitude"] + + +def test_midpoints_emit_geometry_and_optional_transit_hits() -> None: + midpoints = calculate_midpoints(**_BIRTH, target_date="2026-07-09") + assert midpoints["technique"] == "midpoints" + assert "sun/moon" in midpoints["natal_midpoints"] + assert isinstance(midpoints["transit_midpoint_hits"], list) + + +def test_lunar_return_calculates_next_exact_return_chart() -> None: + lunar_return = calculate_lunar_return(**_BIRTH, start_date="2026-07-01") + assert lunar_return["technique"] == "lunar_return" + assert lunar_return["moon_longitude_delta"] < 0.01 + assert lunar_return["return_chart"]["natal"]["planets"]["moon"]["sign"] + + +def test_transit_duration_scan_groups_daily_windows() -> None: + scan = calculate_transit_duration_scan(**_BIRTH, start_date="2026-07-01", end_date="2026-07-03") + assert scan["technique"] == "transit_duration_scan" + assert scan["days_scanned"] == 3 + assert len(scan["daily_hits"]) == 3 + assert isinstance(scan["windows"], list) + + +def test_parans_are_explicitly_blocked_until_solver_exists() -> None: + parans = calculate_parans_status(**_BIRTH, target_date="2026-07-09") + assert parans["technique"] == "parans" + assert parans["status"] == "blocked" + + +def test_timing_builder_can_emit_advanced_layers() -> None: + timing = build_timing_techniques( + **_BIRTH, + converse_secondary_progression_date="2026-07-09", + converse_solar_arc_date="2026-07-09", + midpoint_date="2026-07-09", + lunar_return_start_date="2026-07-01", + duration_scan_start_date="2026-07-01", + duration_scan_end_date="2026-07-02", + parans_date="2026-07-09", + ) + assert { + "converse_secondary_progressions", + "converse_solar_arc_directions", + "midpoints", + "lunar_return", + "transit_duration_scan", + "parans", + } <= set(timing) diff --git a/web/evidence_packet.html b/web/evidence_packet.html new file mode 100644 index 00000000..7b0a1104 --- /dev/null +++ b/web/evidence_packet.html @@ -0,0 +1,26 @@ + + + + +仅显示已完成任务的可审计计算状态、证据包和技法审计。不会展示内部提示词或原始出生输入。
+ +-
-
-
先确认出生资料,再选择直接排盘或主动问询式生时校正。外部引擎状态将在证据包中明示。
+可使用本地城市库;未收录时请手填经纬度。此操作不调用第三方地理服务。
等待检查
先扫描候选时间,再回答选择题。结果只缩小候选簇,不宣称已经精确到分钟。
+