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 仅使用提问当下时刻与地点由后端排盘;不使用本命盘替代问事盘。

diff --git a/jyotish-app/main.js b/jyotish-app/main.js index 70bfbf1a..253420e5 100644 --- a/jyotish-app/main.js +++ b/jyotish-app/main.js @@ -7177,6 +7177,10 @@ function renderPrashnaTab(chartData) { const question = $('prashna-question'); const result = $('prashna-result'); const runBtn = $('btn-run-prashna'); + const timestamp = $('prashna-timestamp'); + const lat = $('prashna-lat'); + const lon = $('prashna-lon'); + const timezone = $('prashna-timezone'); if (!category || !question || !result || !runBtn) return; const workflow = chartData?._consultationWorkflow; renderPrashnaCaseWorkspace(); @@ -7191,6 +7195,14 @@ function renderPrashnaTab(chartData) { runBtn.addEventListener('click', async () => { const questionText = question.value.trim(); const questionType = category.value || 'general'; + if (!questionText) { + result.innerHTML = '

请填写一个明确问题。

'; + 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) { +
+
+
+

主动问询式校时

+

系统先按出生时间误差生成高信息量选择题;你只需点选答案,再进入下一轮收敛。

+
+ active_rectification_questions · candidate_cluster_scoring +
+
+
+
+
+ +
+
+
+
+ +
+
+
@@ -110,6 +134,7 @@ function pctStyle(value) { function bindEvents(container) { const q = s => container.querySelector(s); bindInterviewEvents(container); + bindActiveRectificationWizard(container); q('#rect-add-btn').addEventListener('click', () => { const dEl = q('#rect-event-date'), cEl = q('#rect-event-cat'), descEl = q('#rect-event-desc'); if (!dEl.value) { dEl.focus(); return; } @@ -170,6 +195,107 @@ function bindInterviewEvents(container) { }); } +function toApiBirthTime(value) { + return value ? value.replace('T', ' ').slice(0, 16) : ''; +} + +function renderActiveRectificationQuestions(container) { + const target = container.querySelector('#rect-active-questions'); + if (!target || !activeRectificationQuestionnaire) return; + target.innerHTML = (activeRectificationQuestionnaire.questions || []).map(question => ` +
+
+ ${escapeHtml(question.prompt)} + ${escapeHtml((question.sensitivity || []).join(' / '))} · ${escapeHtml(question.window || '')} +
+
+ ${(question.options || []).map(option => ` + + `).join('')} +
+
+ `).join(''); + target.querySelectorAll('[data-active-answer]').forEach(button => { + button.addEventListener('click', () => { + const item = button.closest('[data-active-question-id]'); + if (!item) return; + activeRectificationAnswers[item.dataset.activeQuestionId] = button.dataset.activeAnswer; + item.querySelectorAll('[data-active-answer]').forEach(btn => btn.classList.remove('selected')); + button.classList.add('selected'); + }); + }); +} + +function renderActiveRectificationScore(container) { + const target = container.querySelector('#rect-active-score-result'); + if (!target || !activeRectificationScore) return; + const rankings = activeRectificationScore.candidate_cluster_rankings || []; + const nextQuestions = activeRectificationScore.next_round_questions || []; + target.innerHTML = ` +
+
候选时间簇
+
${rankings.map(row => ` +
${escapeHtml(row.cluster)}${escapeHtml(String(row.score))}
+ `).join('') || '暂无评分'}
+
下一轮优先问题
+
${nextQuestions.map(q => ` +
${escapeHtml(q.domain || q.id)}${escapeHtml(q.prompt || '')}
+ `).join('') || '暂无下一轮问题'}
+
+ `; +} + +function bindActiveRectificationWizard(container) { + const status = container.querySelector('#rect-active-status'); + container.querySelector('#rect-active-load')?.addEventListener('click', async () => { + const birthTime = toApiBirthTime(container.querySelector('#rect-active-birth-time')?.value || ''); + if (!birthTime) { + if (status) status.textContent = '请先填写出生时间中心。'; + return; + } + if (!window.JyotishAPI?.computeActiveRectificationQuestions) { + if (status) status.textContent = '本地 API 尚未加载主动问询能力。'; + return; + } + try { + if (status) status.textContent = '正在生成高信息量问题…'; + activeRectificationQuestionnaire = await window.JyotishAPI.computeActiveRectificationQuestions({ + birth_time: birthTime, + uncertainty_minutes: Number(container.querySelector('#rect-active-uncertainty')?.value || 30), + step_minutes: Number(container.querySelector('#rect-active-step')?.value || 1), + }); + activeRectificationAnswers = {}; + activeRectificationScore = null; + renderActiveRectificationQuestions(container); + renderActiveRectificationScore(container); + if (status) status.textContent = `已生成 ${(activeRectificationQuestionnaire.questions || []).length} 个选择题。`; + } catch (error) { + if (status) status.textContent = error.message || '主动问询生成失败'; + } + }); + container.querySelector('#rect-active-score')?.addEventListener('click', async () => { + if (!activeRectificationQuestionnaire) { + if (status) status.textContent = '请先生成问题。'; + return; + } + if (!window.JyotishAPI?.computeActiveRectificationScore) { + if (status) status.textContent = '本地 API 尚未加载主动问询评分能力。'; + return; + } + try { + if (status) status.textContent = '正在评分并收敛候选…'; + activeRectificationScore = await window.JyotishAPI.computeActiveRectificationScore({ + questionnaire: activeRectificationQuestionnaire, + answers: activeRectificationAnswers, + }); + renderActiveRectificationScore(container); + if (status) status.textContent = `已评分 ${activeRectificationScore.answered_count || 0} 个回答。`; + } catch (error) { + if (status) status.textContent = error.message || '主动问询评分失败'; + } + }); +} + function collectInterviewEvents(container) { const answers = [...container.querySelectorAll('.rect-interview-item')].map(item => { const saved = rectInterviewAnswers[item.dataset.questionId] || {}; diff --git a/mcp_server.py b/mcp_server.py index c26d56db..0f2f13fc 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -45,6 +45,7 @@ from mcp.server.fastmcp import FastMCP from functional_benefics import derive_functional_benefic_malefic from vedastro_priority import official_snapshot_evidence from unified_consultation_orchestrator import UnifiedConsultationOrchestrator +from skill_experience import build_skill_doctor, build_skill_onboarding, summarize_execution_status load_local_env(SCRIPT_DIR) @@ -4353,6 +4354,22 @@ def life_event_graph( } +# ============================================================================ +# Skill experience tools +# ============================================================================ + +@mcp.tool() +def skill_onboarding(payload: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + """Return the minimal next input or active rectification question set.""" + return build_skill_onboarding(payload) + + +@mcp.tool() +def skill_doctor() -> Dict[str, Any]: + """Check local Skill assets and external adapter readiness.""" + return build_skill_doctor() + + # ============================================================================ # Resources # ============================================================================ diff --git a/references/cross_project_contract/fixture_manifest.v1.json b/references/cross_project_contract/fixture_manifest.v1.json new file mode 100644 index 00000000..dd399d9d --- /dev/null +++ b/references/cross_project_contract/fixture_manifest.v1.json @@ -0,0 +1,27 @@ +{ + "schema_version": 1, + "privacy_scope": "public_synthetic_only", + "fixtures": [ + { + "id": "public_synthetic_delhi_1990_noon_mean_lahiri", + "birth": { + "synthetic": true, + "year": 1990, + "month": 1, + "day": 1, + "hour": 12, + "minute": 0, + "second": 0, + "lat": 28.6139, + "lon": 77.209, + "tz": 5.5 + }, + "effective": { + "ayanamsa": "lahiri", + "node_mode": "mean", + "timezone_offset": 5.5 + }, + "compatibility_hash": "257042461fe303aa3bff5a8333f65090832ce8a2be395f038523838d724b474b" + } + ] +} diff --git a/references/cross_project_contract/sync_ledger.json b/references/cross_project_contract/sync_ledger.json new file mode 100644 index 00000000..0cb7d103 --- /dev/null +++ b/references/cross_project_contract/sync_ledger.json @@ -0,0 +1,130 @@ +{ + "schema_version": 1, + "entries": [ + { + "source_repository": "732642856/yinduzhanxing", + "source_commit": "f4d8148fc031cfa581bce7f30410fcd63fc89202", + "target_repository": "jesse-ux/Jyotisha", + "target_commit": "fd06bdc2eefa5547ed09968a218a8590df484bf3", + "change_class": "calculation_contract", + "copied_files": [ + "references/cross_project_contract/fixture_manifest.v1.json", + "references/cross_project_contract/sync_ledger.json", + "scripts/cross_project_contract.py", + "tests/test_cross_project_contract.py" + ], + "dependency_delta": "none", + "privacy_review": "pass: public synthetic fixture only; no production configuration or user data", + "focused_tests": [ + "python3 -m pytest -q tests/test_cross_project_contract.py", + "python3 scripts/cross_project_contract.py --require-match --format json", + "python3 scripts/public_release_privacy_scan.py --json" + ], + "hash_contract_result": "pass: 257042461fe303aa3bff5a8333f65090832ce8a2be395f038523838d724b474b", + "rollback": "git revert f4d8148 / git revert fd06bdc" + }, + { + "source_repository": "732642856/yinduzhanxing", + "source_commit": "25070634e26162a2ed7a5734aa783a4e42b26546", + "target_repository": "jesse-ux/Jyotisha", + "target_commit": "b6d292a7cd1a5c50d4b920caeec11b462248c21d", + "change_class": "sync_governance", + "copied_files": [ + "references/cross_project_contract/sync_policy.v1.json", + "scripts/cross_project_sync_status.py", + "tests/test_cross_project_sync_status.py" + ], + "dependency_delta": "none", + "privacy_review": "pass: public sync policy and file hashes only; no production configuration or user data", + "focused_tests": [ + "python3 -m pytest -q tests/test_cross_project_contract.py tests/test_cross_project_sync_status.py", + "python3 scripts/cross_project_sync_status.py --peer /tmp/Jyotisha-jesse-ux --format json", + "python3 scripts/public_release_privacy_scan.py --json" + ], + "hash_contract_result": "pass: shared file sha256 comparison returned status=pass", + "rollback": "git revert 2507063 / git revert b6d292a" + }, + { + "source_repository": "732642856/yinduzhanxing", + "source_commit": "b206cc297022a82f90881bdbcc09b1fcb7b65a08", + "target_repository": "jesse-ux/Jyotisha", + "target_commit": "61655fe060577011d91b74ad1fc671bace1bd689", + "change_class": "calculation_contract", + "copied_files": [ + "scripts/domain_calculation_service.py", + "references/cross_project_contract/sync_policy.v1.json", + "tests/test_commercial_domain_calculation_contract.py", + "scripts/jyotish_api_server.py" + ], + "dependency_delta": "none", + "privacy_review": "pass: no secrets or user data; VedAstro key not written to repository", + "focused_tests": [ + "python3 -m pytest -q tests/test_commercial_domain_calculation_contract.py tests/test_cross_project_contract.py tests/test_cross_project_sync_status.py", + "python3 scripts/cross_project_contract.py --require-match --format json", + "python3 scripts/public_release_privacy_scan.py --json" + ], + "hash_contract_result": "pass: commercial API result_hash matches domain_calculation_service.compute_chart", + "rollback": "git revert b206cc2 / git revert 61655fe" + }, + { + "source_repository": "732642856/yinduzhanxing", + "source_commit": "d1d09b9432f383963d10f6604a3bb0ffe2951eb9", + "target_repository": "jesse-ux/Jyotisha", + "target_commit": "73e0ff724d70a234a3fc02b207468377bf709997", + "change_class": "calculation_contract", + "copied_files": [ + "scripts/jyotish_api_server.py", + "tests/test_commercial_domain_calculation_contract.py" + ], + "dependency_delta": "none", + "privacy_review": "pass: no secrets or user data; visible chart values now follow domain service", + "focused_tests": [ + "python3 -m pytest -q tests/test_commercial_domain_calculation_contract.py tests/test_cross_project_contract.py tests/test_cross_project_sync_status.py", + "python3 scripts/cross_project_contract.py --require-match --format json", + "python3 scripts/public_release_privacy_scan.py --json" + ], + "hash_contract_result": "pass: API Rahu/Ketu true-node visible longitudes match domain_calculation_service", + "rollback": "git revert 73e0ff7" + }, + { + "source_repository": "732642856/yinduzhanxing", + "source_commit": "9d5e909d7b5af2d034b87f617d124ec6853e53f7", + "target_repository": "jesse-ux/Jyotisha", + "target_commit": "09387d82ffe9aa59d81a2af6c66ea34d47850ed9", + "change_class": "calculation_contract", + "copied_files": [ + "scripts/jyotish_api_server.py", + "tests/test_commercial_domain_calculation_contract.py" + ], + "dependency_delta": "none", + "privacy_review": "pass: no secrets or user data; Sade Sati now follows domain true Saturn transit", + "focused_tests": [ + "python3 -m pytest -q tests/test_commercial_domain_calculation_contract.py tests/test_cross_project_contract.py tests/test_cross_project_sync_status.py", + "python3 scripts/cross_project_contract.py --require-match --format json", + "python3 scripts/public_release_privacy_scan.py --json" + ], + "hash_contract_result": "pass: API Sade Sati transit Saturn provenance matches domain_calculation_service", + "rollback": "git revert 09387d8" + }, + { + "source_repository": "732642856/yinduzhanxing", + "source_commit": "5070952ecf57568092a82b97a2142e21ebeef4e7", + "target_repository": "jesse-ux/Jyotisha", + "target_commit": "e8a8ff62b0fa86f9e24be5a0ac2604afa575dd8c", + "change_class": "calculation_contract", + "copied_files": [ + "scripts/jyotish_api_server.py", + "tests/test_commercial_domain_calculation_contract.py" + ], + "dependency_delta": "none", + "privacy_review": "pass: no secrets or user data; dasha boundary now follows domain service", + "focused_tests": [ + "python3 -m pytest -q tests/test_commercial_domain_calculation_contract.py tests/test_cross_project_contract.py tests/test_cross_project_sync_status.py", + "python3 scripts/cross_project_contract.py --require-match --format json", + "python3 scripts/public_release_privacy_scan.py --json" + ], + "hash_contract_result": "pass: API dasha start_date/current_md/remaining_years/result_hash match domain_calculation_service", + "rollback": "git revert e8a8ff6" + } + ] +} diff --git a/references/cross_project_contract/sync_policy.v1.json b/references/cross_project_contract/sync_policy.v1.json new file mode 100644 index 00000000..0f5f468b --- /dev/null +++ b/references/cross_project_contract/sync_policy.v1.json @@ -0,0 +1,37 @@ +{ + "schema_version": 1, + "sync_model": "research_validates_commercial_receives_mature", + "repositories": { + "research": "732642856/yinduzhanxing", + "commercial": "jesse-ux/Jyotisha" + }, + "directional_gates": { + "research_to_commercial": { + "source_required": "validated_in_research", + "target_required": "commercial_safe", + "required_checks": [ + "privacy_review", + "focused_tests", + "hash_contract_result" + ] + }, + "commercial_to_research": { + "source_required": "configuration_free_product_pattern", + "target_required": "local_test_double_or_no_secret", + "required_checks": [ + "privacy_review", + "focused_tests" + ] + } + }, + "shared_files": [ + "references/cross_project_contract/fixture_manifest.v1.json", + "references/cross_project_contract/sync_ledger.json", + "references/cross_project_contract/sync_policy.v1.json", + "scripts/cross_project_contract.py", + "scripts/domain_calculation_service.py", + "scripts/cross_project_sync_status.py", + "tests/test_cross_project_contract.py", + "tests/test_cross_project_sync_status.py" + ] +} diff --git a/references/rangacharya_source_cards.json b/references/rangacharya_source_cards.json new file mode 100644 index 00000000..cbac20db --- /dev/null +++ b/references/rangacharya_source_cards.json @@ -0,0 +1,88 @@ +{ + "schema_version": 1, + "created": "2026-07-16", + "cards": [ + { + "id": "rangacharya_core_arudha", + "title": "Rangacharya Arudha core counting", + "status": "transcribed", + "adjudication_enabled": false, + "evidence": { + "source_ids": ["uploaded_screenshots_20260716"], + "formula_text": "Pending formula-level transcription and source verification.", + "notes": "Covers AL, A7, A10, UL, and A1-A12 variant counting." + } + }, + { + "id": "active_effective_lagna", + "title": "Active Lagna and Effective Lagna", + "status": "transcribed", + "adjudication_enabled": false, + "evidence": { + "source_ids": ["uploaded_screenshots_20260716"], + "formula_text": "Pending formula-level transcription and source verification.", + "notes": "Runtime may report placeholder metadata only." + } + }, + { + "id": "prakriti_sanmukha", + "title": "Prakriti Chakra and Sanmukha", + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "formula direction and exception rules need source-card verification", + "evidence": { + "source_ids": ["uploaded_screenshots_20260716"], + "formula_text": "Blocked until rule text is verified.", + "notes": "No runtime calculation allowed yet." + } + }, + { + "id": "rangacharya_special_mappings", + "title": "Special divisional and graha mappings", + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "Krishnamisra/Somanatha Navamsa, Parivritti/Somanatha Drekkana, Pancansa, and Graha Chakra need separate fixtures", + "evidence": { + "source_ids": ["uploaded_screenshots_20260716"], + "formula_text": "Blocked until mapping tables are verified.", + "notes": "Raw descriptive output only after fixtures exist." + } + }, + { + "id": "rangacharya_named_yogas", + "title": "Named Rangacharya yogas", + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "named yogas need formula-level source cards and golden fixtures", + "evidence": { + "source_ids": ["uploaded_screenshots_20260716"], + "formula_text": "Blocked until each yoga has its own source card.", + "notes": "Includes Dhana, Nirdhana, Kemadruma, Vahana, Bandhana, Dustamarana, Saukhya, Buddhi, Raja, Manipravala, Amatya, Senadhipatya, Chandradhi/Lagnadhi, Karakamsa, Yogada, Kevala, and Aspecting Graha." + } + }, + { + "id": "rangacharya_ul_family_rules", + "title": "UL relationship and family rules", + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "relationship, child, miscarriage, and adoption claims need real-case calibration", + "evidence": { + "source_ids": ["uploaded_screenshots_20260716"], + "formula_text": "Blocked until source and case validation.", + "notes": "May only become rule-hit reporting before calibration." + } + }, + { + "id": "article_warehouse_future_tracks", + "title": "Future article-warehouse tracks", + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "registered only; out of Rangacharya runtime scope", + "evidence": { + "source_ids": ["local_article_warehouse_20260716"], + "formula_text": "Blocked until hash, license, and technique-level extraction.", + "notes": "Includes Tithi Lord, Panchapakshi, Rashi Tulya Navamsa, Bhrigu Pada Dasha, Tajika, Darakaraka, and spouse rules." + } + } + ] +} diff --git a/references/rangacharya_source_manifest.json b/references/rangacharya_source_manifest.json new file mode 100644 index 00000000..f3223aa4 --- /dev/null +++ b/references/rangacharya_source_manifest.json @@ -0,0 +1,127 @@ +{ + "schema_version": 1, + "created": "2026-07-16", + "sources": [ + { + "id": "uploaded_screenshots_20260716", + "kind": "user_uploaded_screenshots", + "paths": [ + "/Users/wuyongnaren/文件仓库/印度占星文章/260716/IMG_3502.PNG", + "/Users/wuyongnaren/文件仓库/印度占星文章/260716/IMG_3503.PNG", + "/Users/wuyongnaren/文件仓库/印度占星文章/260716/IMG_3504.PNG", + "/Users/wuyongnaren/文件仓库/印度占星文章/260716/IMG_3505.PNG", + "/Users/wuyongnaren/文件仓库/印度占星文章/260716/IMG_3506.PNG", + "/Users/wuyongnaren/文件仓库/印度占星文章/260716/IMG_3507.PNG" + ], + "sha256": { + "IMG_3502.PNG": "1cf628c3f5dfac372c719eba442f0cc8325114fabea4cc01855e00247bc9c031", + "IMG_3503.PNG": "f3c1f3c67a3add9c6c586e60e18186e86549ca4a5b5a1e6a6c07cc4fe223e1de", + "IMG_3504.PNG": "6c884011b1fe458da2f23a4e3e5acaf48c6ea72cd402b51b2942b3a28cee5de1", + "IMG_3505.PNG": "16af004f553fde2c1ab5d0bc7968deba46040a02e7726f735f174523e6bdccee", + "IMG_3506.PNG": "a8732bc73767983130683152079eb44697d4686435452e71d2e742f8b16ed888", + "IMG_3507.PNG": "1ff2bbc69a3fe1dab17d742437000d9938d9b5bb5e818831dafc9bf0e05a152e" + }, + "license": "user_private_reference", + "privacy": "private", + "runtime_use": "reference_only_until_formula_verified", + "extraction_status": "located_and_hashed_ocr_blocked_tesseract_missing" + }, + { + "id": "local_article_warehouse_20260716", + "kind": "local_research_archive", + "path": "/Users/wuyongnaren/文件仓库/印度占星文章", + "license": "unknown", + "privacy": "local_research", + "runtime_use": "manifest_only_until_hash_and_license_review" + }, + { + "id": "kimi_agent_archive_20260716", + "kind": "local_training_archive", + "path": "/Users/wuyongnaren/Downloads/_整理候选/安装包与压缩包/Kimi_Agent_高维印度占星师.zip", + "license": "unknown", + "privacy": "local_research", + "runtime_use": "reference_only_until_license_review" + }, + { + "id": "vedastro_official", + "kind": "external_oracle", + "url": "https://github.com/VedAstro/VedAstro", + "license": "MIT", + "privacy": "public", + "runtime_use": "oracle_raw_reference" + }, + { + "id": "pyjhora_official", + "kind": "external_oracle", + "url": "https://github.com/naturalstupid/PyJHora", + "license": "AGPL-3.0", + "privacy": "public", + "runtime_use": "isolated_external_process_only" + }, + { + "id": "jyotishganit_official", + "kind": "external_oracle", + "url": "https://github.com/northtara/jyotishganit", + "license": "MIT", + "privacy": "public", + "runtime_use": "oracle_raw_reference" + }, + { + "id": "dashaflow_official", + "kind": "external_formula_reference", + "url": "https://github.com/adarshj322/dashaflow", + "license": "MIT", + "privacy": "public", + "runtime_use": "formula_reference_only" + } + ], + "validation_ladder": [ + "transcribed", + "source_verified", + "golden_verified", + "engine_cross_checked", + "case_calibrated", + "adjudication_enabled", + "blocked" + ], + "rules": [ + { + "id": "rangacharya_core_arudha", + "label": "Rangacharya Arudha core counting", + "source_ids": ["uploaded_screenshots_20260716"], + "status": "transcribed", + "adjudication_enabled": false + }, + { + "id": "active_effective_lagna", + "label": "Active Lagna and Effective Lagna", + "source_ids": ["uploaded_screenshots_20260716"], + "status": "transcribed", + "adjudication_enabled": false + }, + { + "id": "rangacharya_special_mappings", + "label": "Krishnamisra/Somanatha Navamsa, Parivritti/Somanatha Drekkana, Pancansa Graha, Graha Chakra", + "source_ids": ["uploaded_screenshots_20260716"], + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "needs formula-level source cards before runtime use" + }, + { + "id": "rangacharya_named_yogas", + "label": "Dhana/Nirdhana/Kemadruma and other named yogas", + "source_ids": ["uploaded_screenshots_20260716"], + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "needs formula-level source cards before runtime use" + }, + { + "id": "article_warehouse_future_tracks", + "label": "Tithi Lord, Panchapakshi, Rashi Tulya Navamsa, Bhrigu Pada Dasha, Tajika, Darakaraka, spouse rules", + "source_ids": ["local_article_warehouse_20260716"], + "status": "blocked", + "adjudication_enabled": false, + "blocked_reason": "registered for later source governance; out of Rangacharya Phase 1 runtime scope" + } + ] +} diff --git a/references/real_case_calibration/replay_manifest.json b/references/real_case_calibration/replay_manifest.json index 42ad725a..c323cb8a 100644 --- a/references/real_case_calibration/replay_manifest.json +++ b/references/real_case_calibration/replay_manifest.json @@ -1,8 +1,144 @@ { - "schema_version": "1.0", - "status": "contract_ready_no_cases", + "schema_version": "2.0", + "status": "ready", "case_schema": "references/real_case_calibration/catalog.schema.json", - "cases": [], - "blocked_reason": "no_structured_outcome_replay_cases_imported", - "runtime_boundary": "This manifest defines the replay import contract only. No real-case outcome replay is complete until structured cases are imported and validated." + "selection_policy": { + "birth_time_minimum": "Rodden A", + "event_source_minimum": "verified_secondary", + "domains": {"career": 5, "marriage": 5}, + "privacy": "public_figures_only_no_user_birth_data" + }, + "cases": [ + { + "case_id": "jobs_iphone_2007", + "subject": { + "name": "Steve Jobs", "year": 1955, "month": 2, "day": 24, "hour": 19, "minute": 15, + "lat": 37.7833, "lon": -122.4167, "tz": -8.0, "node_mode": "mean", + "birth_source": {"url": "https://www.astro.com/adbvip/adbvip_02_24.htm", "source_grade": "primary", "time_accuracy_rating": "AA", "evidence_basis": "BC/BR in hand"} + }, + "source": {"url": "https://www.astro.com/adbvip/adbvip_02_24.htm", "source_grade": "primary", "license_or_quote_boundary": "facts_and_short_summary_only"}, + "chart_signature": {"benchmark_role": "blind_outcome_replay", "external_oracle_status": "pending"}, + "event_outcomes": [{"event_type": "career_breakthrough", "event_date": "2007-01-09", "domain": "career", "expected_label": "career_status", "outcome": "Apple publicly introduced the iPhone", "source_excerpt_note": "Apple Newsroom dates the announcement to 9 January 2007.", "source": {"url": "https://www.apple.com/newsroom/2007/01/09Apple-Reinvents-the-Phone-with-iPhone/", "source_grade": "primary"}}], + "similarity": {"score": 0.0, "matching_factors": ["not_user_similarity_benchmark"], "dissimilar_factors": []}, + "replay": {"outcome_replay_status": "replayed", "do_not_use_for_prediction": false, "report_path": "docs/benchmark/public_real_case_benchmark_2026_07_11.json"} + }, + { + "case_id": "obama_election_2008", + "subject": { + "name": "Barack Obama", "year": 1961, "month": 8, "day": 4, "hour": 19, "minute": 24, + "lat": 21.3, "lon": -157.8667, "tz": -10.0, "node_mode": "mean", + "birth_source": {"url": "https://www.astro.com/adbvip/adbvip_08_04.htm", "source_grade": "primary", "time_accuracy_rating": "AA", "evidence_basis": "BC/BR in hand"} + }, + "source": {"url": "https://www.astro.com/adbvip/adbvip_08_04.htm", "source_grade": "primary", "license_or_quote_boundary": "facts_and_short_summary_only"}, + "chart_signature": {"benchmark_role": "blind_outcome_replay", "external_oracle_status": "pending"}, + "event_outcomes": [{"event_type": "career_status", "event_date": "2008-11-04", "domain": "career", "expected_label": "career_status", "outcome": "Won the United States presidential election", "source_excerpt_note": "Federal Elections 2008 records the presidential general election.", "source": {"url": "https://www.fec.gov/introduction-campaign-finance/election-results-and-voting-information/federal-elections-2008/", "source_grade": "primary"}}], + "similarity": {"score": 0.0, "matching_factors": ["not_user_similarity_benchmark"], "dissimilar_factors": []}, + "replay": {"outcome_replay_status": "replayed", "do_not_use_for_prediction": false, "report_path": "docs/benchmark/public_real_case_benchmark_2026_07_11.json"} + }, + { + "case_id": "schwarzenegger_governor_2003", + "subject": { + "name": "Arnold Schwarzenegger", "year": 1947, "month": 7, "day": 30, "hour": 4, "minute": 10, + "lat": 47.0833, "lon": 15.45, "tz": 2.0, "node_mode": "mean", + "birth_source": {"url": "https://www.astro.com/adbvip/adbvip_07_30.htm", "source_grade": "verified_secondary", "time_accuracy_rating": "A", "evidence_basis": "from memory"} + }, + "source": {"url": "https://www.astro.com/adbvip/adbvip_07_30.htm", "source_grade": "verified_secondary", "license_or_quote_boundary": "facts_and_short_summary_only"}, + "chart_signature": {"benchmark_role": "blind_outcome_replay", "external_oracle_status": "pending"}, + "event_outcomes": [{"event_type": "career_status", "event_date": "2003-10-07", "domain": "career", "expected_label": "career_status", "outcome": "Won the California gubernatorial recall election", "source_excerpt_note": "California Secretary of State Statement of Vote dates the election to 7 October 2003.", "source": {"url": "https://elections.cdn.sos.ca.gov/sov/2003-special/sov-complete.pdf", "source_grade": "primary"}}], + "similarity": {"score": 0.0, "matching_factors": ["not_user_similarity_benchmark"], "dissimilar_factors": []}, + "replay": {"outcome_replay_status": "replayed", "do_not_use_for_prediction": false, "report_path": "docs/benchmark/public_real_case_benchmark_2026_07_11.json"} + }, + { + "case_id": "streep_oscar_1983", + "subject": { + "name": "Meryl Streep", "year": 1949, "month": 6, "day": 22, "hour": 8, "minute": 5, + "lat": 40.7333, "lon": -74.3667, "tz": -4.0, "node_mode": "mean", + "birth_source": {"url": "https://www.astro.com/adbvip/adbvip_06_22.htm", "source_grade": "primary", "time_accuracy_rating": "AA", "evidence_basis": "BC/BR in hand"} + }, + "source": {"url": "https://www.astro.com/adbvip/adbvip_06_22.htm", "source_grade": "primary", "license_or_quote_boundary": "facts_and_short_summary_only"}, + "chart_signature": {"benchmark_role": "blind_outcome_replay", "external_oracle_status": "pending"}, + "event_outcomes": [{"event_type": "career_award", "event_date": "1983-04-11", "domain": "career", "expected_label": "career_status", "outcome": "Won Best Actress for Sophie's Choice", "source_excerpt_note": "The 55th Academy Awards ceremony occurred on 11 April 1983.", "source": {"url": "https://www.oscars.org/oscars/ceremonies/1983", "source_grade": "primary"}}], + "similarity": {"score": 0.0, "matching_factors": ["not_user_similarity_benchmark"], "dissimilar_factors": []}, + "replay": {"outcome_replay_status": "replayed", "do_not_use_for_prediction": false, "report_path": "docs/benchmark/public_real_case_benchmark_2026_07_11.json"} + }, + { + "case_id": "aniston_emmy_2002", + "subject": { + "name": "Jennifer Aniston", "year": 1969, "month": 2, "day": 11, "hour": 22, "minute": 22, + "lat": 34.05, "lon": -118.25, "tz": -8.0, "node_mode": "mean", + "birth_source": {"url": "https://www.astro.com/adbvip/adbvip_02_11.htm", "source_grade": "primary", "time_accuracy_rating": "AA", "evidence_basis": "quoted BC/BR"} + }, + "source": {"url": "https://www.astro.com/adbvip/adbvip_02_11.htm", "source_grade": "primary", "license_or_quote_boundary": "facts_and_short_summary_only"}, + "chart_signature": {"benchmark_role": "blind_outcome_replay", "external_oracle_status": "pending"}, + "event_outcomes": [{"event_type": "career_award", "event_date": "2002-09-22", "domain": "career", "expected_label": "career_status", "outcome": "Won the Primetime Emmy for lead actress in a comedy series", "source_excerpt_note": "Television Academy lists the 2002 category result.", "source": {"url": "https://www.televisionacademy.com/awards/nominees-winners/2002/outstanding-lead-actress-in-a-comedy-series", "source_grade": "primary"}}], + "similarity": {"score": 0.0, "matching_factors": ["not_user_similarity_benchmark"], "dissimilar_factors": []}, + "replay": {"outcome_replay_status": "replayed", "do_not_use_for_prediction": false, "report_path": "docs/benchmark/public_real_case_benchmark_2026_07_11.json"} + }, + { + "case_id": "william_marriage_2011", + "subject": { + "name": "William, Prince of Wales", "year": 1982, "month": 6, "day": 21, "hour": 21, "minute": 3, + "lat": 51.5333, "lon": -0.2, "tz": 1.0, "node_mode": "mean", + "birth_source": {"url": "https://www.astro.com/adbvip/adbvip_06_21.htm", "source_grade": "primary", "time_accuracy_rating": "AA", "evidence_basis": "BC/BR in hand"} + }, + "source": {"url": "https://www.astro.com/adbvip/adbvip_06_21.htm", "source_grade": "primary", "license_or_quote_boundary": "facts_and_short_summary_only"}, + "chart_signature": {"benchmark_role": "blind_outcome_replay", "external_oracle_status": "pending"}, + "event_outcomes": [{"event_type": "legal_marriage", "event_date": "2011-04-29", "domain": "marriage", "expected_label": "legal_marriage", "outcome": "Married Catherine Middleton", "source_excerpt_note": "Royal Family records the wedding on 29 April 2011.", "source": {"url": "https://www.royal.uk/wedding-prince-william-and-miss-catherine-middleton", "source_grade": "primary"}}], + "similarity": {"score": 0.0, "matching_factors": ["not_user_similarity_benchmark"], "dissimilar_factors": []}, + "replay": {"outcome_replay_status": "replayed", "do_not_use_for_prediction": false, "report_path": "docs/benchmark/public_real_case_benchmark_2026_07_11.json"} + }, + { + "case_id": "jolie_marriage_2014", + "subject": { + "name": "Angelina Jolie", "year": 1975, "month": 6, "day": 4, "hour": 9, "minute": 9, + "lat": 34.0961, "lon": -118.2944, "tz": -7.0, "node_mode": "mean", + "birth_source": {"url": "https://www.astro.com/adbvip/adbvip_06_04.htm", "source_grade": "primary", "time_accuracy_rating": "AA", "evidence_basis": "quoted BC/BR"} + }, + "source": {"url": "https://www.astro.com/adbvip/adbvip_06_04.htm", "source_grade": "primary", "license_or_quote_boundary": "facts_and_short_summary_only"}, + "chart_signature": {"benchmark_role": "blind_outcome_replay", "external_oracle_status": "pending"}, + "event_outcomes": [{"event_type": "legal_marriage", "event_date": "2014-08-23", "domain": "marriage", "expected_label": "legal_marriage", "outcome": "Married Brad Pitt", "source_excerpt_note": "Public biography records the private wedding on 23 August 2014.", "source": {"url": "https://en.wikipedia.org/wiki/Angelina_Jolie", "source_grade": "verified_secondary"}}], + "similarity": {"score": 0.0, "matching_factors": ["not_user_similarity_benchmark"], "dissimilar_factors": []}, + "replay": {"outcome_replay_status": "replayed", "do_not_use_for_prediction": false, "report_path": "docs/benchmark/public_real_case_benchmark_2026_07_11.json"} + }, + { + "case_id": "kahlo_marriage_1929", + "subject": { + "name": "Frida Kahlo", "year": 1907, "month": 7, "day": 6, "hour": 8, "minute": 30, + "lat": 19.3333, "lon": -99.1667, "tz": -6.6111, "node_mode": "mean", + "birth_source": {"url": "https://www.astro.com/adbvip/adbvip_07_06.htm", "source_grade": "primary", "time_accuracy_rating": "AA", "evidence_basis": "BC/BR in hand"} + }, + "source": {"url": "https://www.astro.com/adbvip/adbvip_07_06.htm", "source_grade": "primary", "license_or_quote_boundary": "facts_and_short_summary_only"}, + "chart_signature": {"benchmark_role": "blind_outcome_replay", "external_oracle_status": "pending", "timezone_note": "Astro-Databank LMT m99w10"}, + "event_outcomes": [{"event_type": "legal_marriage", "event_date": "1929-08-21", "domain": "marriage", "expected_label": "legal_marriage", "outcome": "Married Diego Rivera in a civil ceremony", "source_excerpt_note": "Biography cites the Coyoacan civil ceremony on 21 August 1929.", "source": {"url": "https://en.wikipedia.org/wiki/Frida_Kahlo", "source_grade": "verified_secondary"}}], + "similarity": {"score": 0.0, "matching_factors": ["not_user_similarity_benchmark"], "dissimilar_factors": []}, + "replay": {"outcome_replay_status": "replayed", "do_not_use_for_prediction": false, "report_path": "docs/benchmark/public_real_case_benchmark_2026_07_11.json"} + }, + { + "case_id": "snoop_marriage_1997", + "subject": { + "name": "Snoop Dogg", "year": 1971, "month": 10, "day": 20, "hour": 18, "minute": 20, + "lat": 33.7667, "lon": -118.1833, "tz": -7.0, "node_mode": "mean", + "birth_source": {"url": "https://www.astro.com/adbvip/adbvip_10_20.htm", "source_grade": "primary", "time_accuracy_rating": "AA", "evidence_basis": "BC/BR in hand"} + }, + "source": {"url": "https://www.astro.com/adbvip/adbvip_10_20.htm", "source_grade": "primary", "license_or_quote_boundary": "facts_and_short_summary_only"}, + "chart_signature": {"benchmark_role": "blind_outcome_replay", "external_oracle_status": "pending"}, + "event_outcomes": [{"event_type": "legal_marriage", "event_date": "1997-06-14", "domain": "marriage", "expected_label": "legal_marriage", "outcome": "Married Shante Taylor", "source_excerpt_note": "Public biography records the marriage on 14 June 1997.", "source": {"url": "https://en.wikipedia.org/wiki/Snoop_Dogg", "source_grade": "verified_secondary"}}], + "similarity": {"score": 0.0, "matching_factors": ["not_user_similarity_benchmark"], "dissimilar_factors": []}, + "replay": {"outcome_replay_status": "replayed", "do_not_use_for_prediction": false, "report_path": "docs/benchmark/public_real_case_benchmark_2026_07_11.json"} + }, + { + "case_id": "disney_marriage_1925", + "subject": { + "name": "Walt Disney", "year": 1901, "month": 12, "day": 5, "hour": 0, "minute": 35, + "lat": 41.85, "lon": -87.65, "tz": -6.0, "node_mode": "mean", + "birth_source": {"url": "https://www.astro.com/adbvip/adbvip_12_05.htm", "source_grade": "verified_secondary", "time_accuracy_rating": "A", "evidence_basis": "from memory"} + }, + "source": {"url": "https://www.astro.com/adbvip/adbvip_12_05.htm", "source_grade": "verified_secondary", "license_or_quote_boundary": "facts_and_short_summary_only"}, + "chart_signature": {"benchmark_role": "blind_outcome_replay", "external_oracle_status": "pending"}, + "event_outcomes": [{"event_type": "legal_marriage", "event_date": "1925-07-13", "domain": "marriage", "expected_label": "legal_marriage", "outcome": "Married Lillian Bounds", "source_excerpt_note": "Walt Disney Family Museum dates the marriage to 13 July 1925.", "source": {"url": "https://www.waltdisney.org/blog/who-did-walt-disney-marry", "source_grade": "primary"}}], + "similarity": {"score": 0.0, "matching_factors": ["not_user_similarity_benchmark"], "dissimilar_factors": []}, + "replay": {"outcome_replay_status": "replayed", "do_not_use_for_prediction": false, "report_path": "docs/benchmark/public_real_case_benchmark_2026_07_11.json"} + } + ], + "runtime_boundary": "Positive-event replay only. It measures technical activation recall on known dated events; it does not establish specificity, causal validity, or scientific predictive accuracy. External JHora/PyJHora/VedAstro raw parity remains separately audited." } diff --git a/references/technique_registry.json b/references/technique_registry.json index a49cafe1..6ab387c1 100644 --- a/references/technique_registry.json +++ b/references/technique_registry.json @@ -1695,6 +1695,40 @@ }, "conclusion_policy": "Supporting evidence only; it can raise/lower confidence but cannot by itself decide an event or timing claim." }, + "rangacharya_jaimini_variant": { + "audit_label": "Rangacharya Jaimini Variant", + "commands": [ + "jaimini" + ], + "conclusion_policy": "Display current-vs-variant differences only; do not use for verdicts or timing.", + "domains": [ + "jaimini", + "arudha", + "experimental", + "audit" + ], + "entry_type": "experimental_variant", + "evidence_role": "comparison_only", + "knowledge_refs": [ + "references/rangacharya_source_manifest.json", + "references/rangacharya_source_cards.json", + "docs/superpowers/specs/2026-07-16-rangacharya-vedastro-design.md" + ], + "missing_impact": "Rangacharya-specific Arudha and named-yoga rules remain unavailable for adjudication until source cards, golden fixtures, oracle comparison, and case calibration pass.", + "name": "Rangacharya / Iranganti Jaimini Variant", + "note": "Rangacharya variant has a tested current-vs-variant diff path and API variant output, but adjudication remains disabled until source-card validation and golden fixtures pass.", + "output_paths": [ + "result.rangacharya", + "result.rangacharya_diff" + ], + "status": "comparison-only", + "user_visibility": "expert_audit", + "verification_level": { + "calculation": "experimental", + "prediction": "blocked", + "rule": "blocked" + } + }, "rashi_tulya_navamsa": { "name": "Rashi Tulya Navamsa / 本命对分盘分析", "domains": [ diff --git a/scripts/active_rectification_questions.py b/scripts/active_rectification_questions.py index c1813e41..0e9e53bf 100644 --- a/scripts/active_rectification_questions.py +++ b/scripts/active_rectification_questions.py @@ -31,19 +31,164 @@ def _parse_time(value: str) -> datetime: return datetime.strptime(value, "%Y-%m-%d %H:%M") -def _candidate_scan(center: datetime, uncertainty_minutes: int, step_minutes: int) -> dict[str, Any]: +def _candidate_scan( + center: datetime, + uncertainty_minutes: int, + step_minutes: int, + *, + lat: float | None = None, + lon: float | None = None, + tz: float | None = None, + ayanamsa: str = "lahiri", +) -> dict[str, Any]: start = center - timedelta(minutes=uncertainty_minutes) end = center + timedelta(minutes=uncertainty_minutes) + total_minutes = int((end - start).total_seconds() // 60) + candidate_count = total_minutes // step_minutes + 1 + sample_offsets = sorted({-uncertainty_minutes, 0, uncertainty_minutes}) + samples = [] + for offset in sample_offsets: + candidate = center + timedelta(minutes=offset) + if offset < 0: + cluster = "early_candidate_cluster" + elif offset > 0: + cluster = "late_candidate_cluster" + else: + cluster = "middle_candidate_cluster" + sample = { + "time": candidate.strftime("%Y-%m-%d %H:%M"), + "offset_minutes": offset, + "cluster": cluster, + "sensitivity_flags": _sensitivity_flags(abs(offset)), + } + recast = _candidate_recast(candidate, lat=lat, lon=lon, tz=tz, ayanamsa=ayanamsa) + if recast: + sample.update(recast) + samples.append(sample) + has_true_recast = all("varga_lagna" in sample for sample in samples) + has_kp_recast = all("kp_cusps" in sample for sample in samples) + computed_layers = ["time_range", "candidate_cluster", "question_sensitivity_map"] + blocked_layers = ["true_varga_recast", "true_kp_cusp_recast", "true_arudha_recast"] + if has_true_recast: + computed_layers.extend(["true_varga_recast", "true_arudha_recast"]) + blocked_layers = ["true_kp_cusp_recast"] + if has_kp_recast: + computed_layers.append("true_kp_cusp_recast") + blocked_layers = [layer for layer in blocked_layers if layer != "true_kp_cusp_recast"] return { "start": start.strftime("%Y-%m-%d %H:%M"), "end": end.strftime("%Y-%m-%d %H:%M"), "step_minutes": step_minutes, - "candidate_count": int((end - start).total_seconds() // 60 // step_minutes) + 1, + "candidate_count": candidate_count, "cluster_labels": ["early_candidate_cluster", "middle_candidate_cluster", "late_candidate_cluster"], + "samples": samples, + "sensitivity_summary": { + "method": "range_bucket_scan_v1", + "high_value_layers": ["D9", "D10", "D24", "D30", "D60", "UL", "A7", "A10", "KP_cusp"], + "computed_layers": computed_layers, + "blocked_layers": blocked_layers, + "boundary": "Candidate Varga, Arudha and KP cusp recasts are computed from the local domain chart; external oracle parity remains a separate gate.", + }, } -def build_questionnaire(birth_time: str, uncertainty_minutes: int = 30, step_minutes: int = 1) -> dict[str, Any]: +def _sensitivity_flags(abs_offset_minutes: int) -> list[str]: + flags = ["D9", "D10", "D24", "A10"] + if abs_offset_minutes >= 10: + flags.extend(["D30", "UL", "A7"]) + if abs_offset_minutes >= 20: + flags.extend(["D60", "KP_cusp"]) + return flags + + +def _candidate_recast( + candidate: datetime, + *, + lat: float | None, + lon: float | None, + tz: float | None, + ayanamsa: str, +) -> dict[str, Any] | None: + if lat is None or lon is None or tz is None: + return None + import domain_calculation_service + import jaimini + import kp_system + import varga + + chart = domain_calculation_service.compute_chart({ + "year": candidate.year, + "month": candidate.month, + "day": candidate.day, + "hour": candidate.hour, + "minute": candidate.minute, + "second": candidate.second, + "lat": lat, + "lon": lon, + "tz": tz, + "ayanamsa": ayanamsa, + }) + planet_lons = { + name: data["lon"] + for name, data in chart.get("planets", {}).items() + if name in {"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu"} + } + asc_lon = chart["ascendant"]["lon"] + vargas = varga.calc_all_vargas(planet_lons, asc_lon, divisions=[9, 10, 24, 30, 60]) + arudha = jaimini.calc_arudha_padas(int(asc_lon // 30), planet_lons) + padas = arudha.get("padas", {}) + upapada = arudha.get("upapada", {}) + return { + "ascendant": { + "lon": round(asc_lon, 6), + "sign": chart["ascendant"].get("sign"), + "degree_in_sign": chart["ascendant"].get("degree_in_sign"), + }, + "varga_lagna": { + key: value.get("Ascendant", {}) + for key, value in vargas.items() + }, + "arudha": { + "A7": padas.get("A7", {}), + "A10": padas.get("A10", {}), + "UL": upapada, + }, + "kp_cusps": _kp_cusp_snapshot(chart), + } + + +def _kp_cusp_snapshot(chart: dict[str, Any]) -> dict[str, Any]: + import kp_system + + snapshot = {} + for house_key in ("house_1", "house_4", "house_7", "house_10"): + house = chart.get("houses", {}).get(house_key, {}) + degree = house.get("cusp_degree") + if degree is None: + continue + lords = kp_system.get_kp_lords(float(degree)) + snapshot[house_key] = { + "cusp_degree": round(float(degree) % 360, 6), + "sign": lords.get("sign"), + "rasi_lord": lords.get("rasi_lord"), + "nakshatra": lords.get("nakshatra"), + "nakshatra_lord": lords.get("nakshatra_lord"), + "sub_lord": lords.get("sub_lord"), + "sub_sub_lord": lords.get("sub_sub_lord"), + } + return snapshot + + +def build_questionnaire( + birth_time: str, + uncertainty_minutes: int = 30, + step_minutes: int = 1, + *, + lat: float | None = None, + lon: float | None = None, + tz: float | None = None, + ayanamsa: str = "lahiri", +) -> dict[str, Any]: questions = [] for qid, round_id, domain, sensitivity, window, prompt, yes_bias, no_bias in QUESTION_TEMPLATES: questions.append({ @@ -64,7 +209,15 @@ def build_questionnaire(birth_time: str, uncertainty_minutes: int = 30, step_min return { "scope": "active_birth_time_rectification_questionnaire", "schema_version": 1, - "candidate_scan": _candidate_scan(_parse_time(birth_time), uncertainty_minutes, step_minutes), + "candidate_scan": _candidate_scan( + _parse_time(birth_time), + uncertainty_minutes, + step_minutes, + lat=lat, + lon=lon, + tz=tz, + ayanamsa=ayanamsa, + ), "workflow": [ "candidate_time_scan", "varga_arudha_kp_sensitivity_diff", diff --git a/scripts/candidate_time_sensitivity_scan.py b/scripts/candidate_time_sensitivity_scan.py new file mode 100644 index 00000000..295ed4b0 --- /dev/null +++ b/scripts/candidate_time_sensitivity_scan.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Scan actual local-chart differences across a birth-time candidate range.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +from collections import Counter +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +ENGINE = ROOT / "scripts" / "jyotish_engine.py" +_VARGAS = ("D4", "D9", "D10", "D24", "D30") + + +def _engine_json(command: str, payload: dict[str, Any], *, timeout: int = 20) -> dict[str, Any]: + args = ["python3", str(ENGINE), command] + for key in ("year", "month", "day", "hour", "minute", "lat", "lon", "tz"): + args.extend([f"--{key}", str(payload[key])]) + if command == "varga-full": + args.extend(["--divisions", ",".join(_VARGAS)]) + completed = subprocess.run(args, cwd=ROOT, capture_output=True, text=True, timeout=timeout, check=True) + return json.loads(completed.stdout) + + +def _all_varga_ascendants(payload: dict[str, Any]) -> dict[str, str | None]: + values = {varga: None for varga in _VARGAS} + try: + raw = _engine_json("varga-full", payload) + except subprocess.CalledProcessError: + return values + for name, chart in raw.items(): + if not isinstance(chart, dict): + continue + for varga in _VARGAS: + if name.startswith(varga + "_"): + values[varga] = (chart.get("Ascendant") or {}).get("sign") + return values + + +def scan_candidate_times(payload: dict[str, Any], *, uncertainty_minutes: int = 30, step_minutes: int = 1) -> dict[str, Any]: + required = ("year", "month", "day", "hour", "minute", "lat", "lon", "tz") + missing = [key for key in required if payload.get(key) is None] + if missing: + raise ValueError(f"missing candidate scan fields: {', '.join(missing)}") + center = datetime(int(payload["year"]), int(payload["month"]), int(payload["day"]), int(payload["hour"]), int(payload["minute"])) + step_minutes = max(int(step_minutes), 1) + uncertainty_minutes = max(int(uncertainty_minutes), 1) + rows: list[dict[str, Any]] = [] + for offset in range(-uncertainty_minutes, uncertainty_minutes + 1, step_minutes): + moment = center + timedelta(minutes=offset) + point = {**payload, "year": moment.year, "month": moment.month, "day": moment.day, "hour": moment.hour, "minute": moment.minute} + chart = _engine_json("chart", point) + asc = chart.get("ascendant", {}) + divisional = _all_varga_ascendants(point) + rows.append({ + "time": moment.strftime("%Y-%m-%d %H:%M"), + "offset_minutes": offset, + "d1_ascendant": asc.get("sign"), + "d1_degree_in_sign": asc.get("degree_in_sign"), + "divisional_ascendants": divisional, + }) + signatures = [tuple([row["d1_ascendant"], *row["divisional_ascendants"].values()]) for row in rows] + unavailable_vargas = [varga.upper() for varga in _VARGAS if all(row["divisional_ascendants"][varga.upper()] is None for row in rows)] + supported_vargas = [varga.lower() for varga in _VARGAS if varga.upper() not in unavailable_vargas] + modal = Counter(signatures).most_common(1)[0][0] + for row, signature in zip(rows, signatures): + row["sensitivity_count"] = sum(left != right for left, right in zip(signature, modal)) + row["sensitive_layers"] = [ + name for name, current, typical in zip(("D1", "D4", "D9", "D10", "D24", "D30"), signature, modal) + if current != typical + ] + transitions = [] + for previous, current in zip(rows, rows[1:]): + changed = [name for name in ("d1_ascendant", "divisional_ascendants") if previous[name] != current[name]] + if changed: + transitions.append({"between": [previous["time"], current["time"]], "changed": changed}) + return { + "scope": "candidate_time_sensitivity_scan", + "status": "local_computed", + "engine": "local_jyotish_engine", + "candidate_count": len(rows), + "center_time": center.strftime("%Y-%m-%d %H:%M"), + "uncertainty_minutes": uncertainty_minutes, + "step_minutes": step_minutes, + "rows": rows, + "transitions": transitions, + "supported_vargas": [varga.upper() for varga in supported_vargas], + "unavailable_vargas": unavailable_vargas, + "pending_layers": ["UL", "A7", "A10", "KP_cusp"], + "boundary": "Actual local D1/Varga differences only. Unsupported Varga CLI flags are explicitly unavailable. Event answers still require an explicit event-to-candidate adjudication model before minute-level rectification.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + for field, cast in (("year", int), ("month", int), ("day", int), ("hour", int), ("minute", int), ("lat", float), ("lon", float), ("tz", float)): + parser.add_argument(f"--{field}", required=True, type=cast) + parser.add_argument("--uncertainty-minutes", type=int, default=30) + parser.add_argument("--step-minutes", type=int, default=1) + args = parser.parse_args() + print(json.dumps(scan_candidate_times(vars(args), uncertainty_minutes=args.uncertainty_minutes, step_minutes=args.step_minutes), ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/career_vedastro_radar.py b/scripts/career_vedastro_radar.py new file mode 100644 index 00000000..bdc4d770 --- /dev/null +++ b/scripts/career_vedastro_radar.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""VedAstro-assisted career timing radar. + +External VedAstro signals are secondary evidence only. They do not change +local scores, dominant labels, or final career/prashna adjudication by +themselves. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import vedastro_service_adapter + + +def build_career_radar_packet(case: dict[str, Any], *, start_date: str, end_date: str, case_id: str = "user_chart") -> dict[str, Any]: + result = vedastro_service_adapter.run_range_scan_for_case( + case, + "career", + start_date, + end_date, + case_id=case_id, + ) + policy = result.get("adjudicator_policy") if isinstance(result.get("adjudicator_policy"), dict) else {} + can_change_score = bool(policy.get("can_change_score", False)) + status = result.get("status", "blocked") + return { + "scope": "career_vedastro_radar", + "status": "ok" if status == "ok" else "blocked", + "blocked_reason": None if status == "ok" else result.get("reason") or status, + "domain": "career", + "adjudicator_use": "secondary_evidence_only", + "can_change_score": can_change_score, + "can_set_final_verdict": False, + "start_date": start_date, + "end_date": end_date, + "vedastro_range_scan_result": result, + "technique_audit_row": { + "technique": "VedAstro Career Range Scan", + "used": status == "ok", + "status": status, + "role": "external_secondary_evidence", + "confidence_effect": "raises_attention_only_not_final_score" if status == "ok" else "blocked_no_effect", + }, + } + + +def _load_case(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case-json", type=Path, required=True) + parser.add_argument("--start-date", required=True) + parser.add_argument("--end-date", required=True) + parser.add_argument("--case-id", default="user_chart") + parser.add_argument("--output", type=Path) + args = parser.parse_args() + packet = build_career_radar_packet( + _load_case(args.case_json), + start_date=args.start_date, + end_date=args.end_date, + case_id=args.case_id, + ) + text = json.dumps(packet, ensure_ascii=False, indent=2, sort_keys=True) + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(text + "\n", encoding="utf-8") + print(text) + return 0 if packet["status"] == "ok" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/configure_vedastro_secret.py b/scripts/configure_vedastro_secret.py new file mode 100644 index 00000000..9d510216 --- /dev/null +++ b/scripts/configure_vedastro_secret.py @@ -0,0 +1,64 @@ +"""Configure VedAstro local secret without echoing it. + +Run manually from a trusted terminal. This script writes only to ignored local +env files; it must never be used to commit or print secrets. +""" + +from __future__ import annotations + +import getpass +from pathlib import Path + + +DEFAULT_ENV = Path(".env.local") +DEFAULT_ENDPOINT = "https://api.vedastro.org/api" + + +def update_env_text(text: str, updates: dict[str, str]) -> str: + lines = text.splitlines() + seen: set[str] = set() + output: list[str] = [] + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in line: + output.append(line) + continue + key, _value = line.split("=", 1) + key = key.strip() + if key in updates: + output.append(f"{key}={updates[key]}") + seen.add(key) + else: + output.append(line) + for key, value in updates.items(): + if key not in seen: + output.append(f"{key}={value}") + return "\n".join(output).rstrip() + "\n" + + +def write_env(path: Path, updates: dict[str, str]) -> None: + existing = path.read_text(encoding="utf-8") if path.exists() else "" + path.write_text(update_env_text(existing, updates), encoding="utf-8") + + +def main() -> int: + key = getpass.getpass("VedAstro API key (hidden): ").strip() + if not key: + print("No key entered; nothing changed.") + return 1 + endpoint = input(f"VedAstro endpoint [{DEFAULT_ENDPOINT}]: ").strip() or DEFAULT_ENDPOINT + write_env( + DEFAULT_ENV, + { + "VEDASTRO_API_KEY": key, + "VEDASTRO_API_ENDPOINT": endpoint, + "VEDASTRO_ENABLE_NETWORK": "1", + "VEDASTRO_TIMEOUT_SECONDS": "20", + }, + ) + print(f"Updated {DEFAULT_ENV}; secret value was not printed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/consultation_workflow_service.py b/scripts/consultation_workflow_service.py new file mode 100644 index 00000000..88558203 --- /dev/null +++ b/scripts/consultation_workflow_service.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +"""Shared consultation workflow boundary for API and MCP callers.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = ROOT / "scripts" +for path in (ROOT, SCRIPTS_DIR): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + + +def execute_consultation_workflow(body: dict[str, Any], *, surface: str = "api") -> dict[str, Any]: + from jyotish_api_server import JyotishAPIHandler, execute_consultation_workflow as _execute + + handler = JyotishAPIHandler.__new__(JyotishAPIHandler) + return _execute(handler, body=body, surface=surface) + + +def build_runtime_evidence_helpers(chart: dict[str, Any]) -> dict[str, Any]: + from jyotish_api_server import JyotishAPIHandler + + handler = JyotishAPIHandler.__new__(JyotishAPIHandler) + return { + "vedastro_official": handler._high_rigor_vedastro_official_summary(chart), + "vedastro_archive_manifest": handler._compute_vedastro_gateway_archives(), + "interpretation_coverage": handler._interpretation_source_runtime_coverage(chart), + } diff --git a/scripts/cross_project_contract.py b/scripts/cross_project_contract.py new file mode 100644 index 00000000..96cf3585 --- /dev/null +++ b/scripts/cross_project_contract.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Validate public synthetic calculation fixtures shared across Jyotish projects.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT / "scripts") not in sys.path: + sys.path.insert(0, str(ROOT / "scripts")) + +from jyotish_engine import compute_chart_data + + +PLANETS = ("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Rahu", "Ketu") +REQUIRED_LEDGER_FIELDS = { + "source_repository", + "source_commit", + "target_repository", + "target_commit", + "change_class", + "copied_files", + "dependency_delta", + "privacy_review", + "focused_tests", + "hash_contract_result", + "rollback", +} + + +def load_manifest(path: Path) -> dict[str, Any]: + manifest = json.loads(path.read_text(encoding="utf-8")) + if manifest.get("schema_version") != 1: + raise ValueError("fixture manifest schema_version must be 1") + if manifest.get("privacy_scope") != "public_synthetic_only": + raise ValueError("fixture manifest must be public_synthetic_only") + if not isinstance(manifest.get("fixtures"), list) or not manifest["fixtures"]: + raise ValueError("fixture manifest must contain fixtures") + return manifest + + +def load_ledger(path: Path) -> dict[str, Any]: + ledger = json.loads(path.read_text(encoding="utf-8")) + if ledger.get("schema_version") != 1 or not isinstance(ledger.get("entries"), list): + raise ValueError("sync ledger must contain schema_version=1 and entries array") + return ledger + + +def validate_ledger_entry(entry: dict[str, Any]) -> list[str]: + return sorted(REQUIRED_LEDGER_FIELDS - entry.keys()) + + +def _calculate_fixture_chart(fixture: dict[str, Any]) -> dict[str, Any]: + birth = fixture["birth"] + effective = fixture["effective"] + chart, _asc_idx, _jd, _ayanamsa = compute_chart_data( + birth["year"], birth["month"], birth["day"], birth["hour"], birth["minute"], + birth["lat"], birth["lon"], birth["tz"], node_mode=effective["node_mode"], + second=birth.get("second", 0), ayanamsa_name=effective["ayanamsa"], + ) + return chart + + +def _longitude(row: dict[str, Any]) -> float: + value = row.get("lon", row.get("degree")) + if not isinstance(value, (int, float)): + raise ValueError("chart row must provide numeric lon or degree") + return float(value) + + +def compatibility_payload(chart: dict[str, Any], fixture: dict[str, Any]) -> dict[str, Any]: + return { + "fixture_id": fixture["id"], + "birth": {key: value for key, value in fixture["birth"].items() if key != "synthetic"}, + "effective": { + "ayanamsa": fixture["effective"]["ayanamsa"], + "node_mode": fixture["effective"]["node_mode"], + "timezone_offset": fixture["effective"]["timezone_offset"], + }, + "ascendant": { + "sign": chart["ascendant"]["sign"], + "lon": _longitude(chart["ascendant"]), + }, + "planets": { + planet: {"sign": chart["planets"][planet]["sign"], "lon": _longitude(chart["planets"][planet])} + for planet in PLANETS + }, + } + + +def compatibility_hash(chart: dict[str, Any], fixture: dict[str, Any]) -> str: + encoded = json.dumps( + compatibility_payload(chart, fixture), ensure_ascii=True, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def evaluate_manifest(path: Path) -> dict[str, Any]: + manifest = load_manifest(path) + fixtures = [] + for fixture in manifest["fixtures"]: + chart = _calculate_fixture_chart(fixture) + actual = compatibility_hash(chart, fixture) + expected = fixture["compatibility_hash"] + fixtures.append( + { + "id": fixture["id"], + "expected_compatibility_hash": expected, + "actual_compatibility_hash": actual, + "matches": actual == expected, + } + ) + return {"schema_version": 1, "manifest": str(path), "fixtures": fixtures, "matches": all(row["matches"] for row in fixtures)} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--manifest", + type=Path, + default=ROOT / "references" / "cross_project_contract" / "fixture_manifest.v1.json", + ) + parser.add_argument("--format", choices=("text", "json"), default="text") + parser.add_argument("--require-match", action="store_true") + args = parser.parse_args(argv) + report = evaluate_manifest(args.manifest) + if args.format == "json": + print(json.dumps(report, ensure_ascii=False, indent=2)) + else: + for row in report["fixtures"]: + print(f"{row['id']}: {'match' if row['matches'] else 'mismatch'}") + return 0 if report["matches"] or not args.require_match else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/cross_project_sync_status.py b/scripts/cross_project_sync_status.py new file mode 100644 index 00000000..3210968c --- /dev/null +++ b/scripts/cross_project_sync_status.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Compare allow-listed shared contract files between the two Jyotish projects.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_POLICY = ROOT / "references" / "cross_project_contract" / "sync_policy.v1.json" + + +def _sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def load_policy(path: Path = DEFAULT_POLICY) -> dict[str, Any]: + policy = json.loads(path.read_text(encoding="utf-8")) + if policy.get("schema_version") != 1: + raise ValueError("sync policy schema_version must be 1") + if policy.get("sync_model") != "research_validates_commercial_receives_mature": + raise ValueError("sync policy must encode research-first commercial-mature flow") + if not isinstance(policy.get("shared_files"), list) or not policy["shared_files"]: + raise ValueError("sync policy must contain shared_files") + return policy + + +def compare_peer(peer_root: Path, *, policy_path: Path = DEFAULT_POLICY, root: Path = ROOT) -> dict[str, Any]: + policy = load_policy(policy_path) + missing: list[str] = [] + mismatched: list[str] = [] + checked: list[dict[str, str]] = [] + + for rel_path in policy["shared_files"]: + local = root / rel_path + peer = peer_root / rel_path + if not local.exists() or not peer.exists(): + missing.append(rel_path) + continue + local_hash = _sha256(local) + peer_hash = _sha256(peer) + checked.append({"path": rel_path, "local_sha256": local_hash, "peer_sha256": peer_hash}) + if local_hash != peer_hash: + mismatched.append(rel_path) + + return { + "status": "pass" if not missing and not mismatched else "fail", + "sync_model": policy["sync_model"], + "checked_count": len(checked), + "missing": missing, + "mismatched": mismatched, + "checked": checked, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--peer", type=Path, required=True, help="Path to the other Jyotish repository") + parser.add_argument("--policy", type=Path, default=DEFAULT_POLICY) + parser.add_argument("--format", choices=("json",), default="json") + args = parser.parse_args() + + report = compare_peer(args.peer, policy_path=args.policy) + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if report["status"] == "pass" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/domain_calculation_service.py b/scripts/domain_calculation_service.py new file mode 100644 index 00000000..f778cf91 --- /dev/null +++ b/scripts/domain_calculation_service.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""Canonical calculation service shared by CLI, REST, and MCP adapters.""" + +from __future__ import annotations + +import hashlib +import json +import math +import threading +from datetime import datetime +from typing import Any +from zoneinfo import ZoneInfo + +import swisseph as swe +from ayanamsa_utils import apply_ayanamsa, normalize_ayanamsa_name +from dasha_analyzer import build_dasha_timeline, lon_to_nakshatra +from jyotish_engine import SIGNS, compute_chart_data +from sade_sati import calc_sade_sati_complete + +CONTRACT_VERSION = "1.0.0" +_SWISSEPH_LOCK = threading.RLock() +_PLANET_IDS = {"Saturn": swe.SATURN} + + +class CalculationError(ValueError): + pass + + +class TimezoneInferenceError(CalculationError): + pass + + +def _canonical_hash(payload: dict[str, Any]) -> str: + encoded = json.dumps( + payload, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +def _lookup_timezone_name(lat: float, lon: float) -> str | None: + try: + from timezonefinder import TimezoneFinder + except ImportError as exc: + raise TimezoneInferenceError("timezone inference dependency unavailable") from exc + return TimezoneFinder().timezone_at(lng=lon, lat=lat) + + +def infer_timezone_offset(*, lat: float, lon: float, local_datetime: datetime) -> float: + if not (-90 <= lat <= 90 and -180 <= lon <= 180): + raise TimezoneInferenceError("timezone inference received invalid coordinates") + tz_name = _lookup_timezone_name(lat, lon) + if not tz_name: + raise TimezoneInferenceError("timezone inference returned no IANA zone") + try: + offset = local_datetime.replace(tzinfo=ZoneInfo(tz_name)).utcoffset() + except Exception as exc: + raise TimezoneInferenceError("timezone inference failed for IANA zone") from exc + if offset is None: + raise TimezoneInferenceError("timezone inference returned no UTC offset") + return offset.total_seconds() / 3600.0 + + +def _normalized_request(payload: dict[str, Any]) -> dict[str, Any]: + requested_node = str(payload.get("node_mode", payload.get("nodeMode", "mean"))).lower() + if requested_node not in {"mean", "true"}: + raise CalculationError("node_mode must be mean or true") + ayanamsa = normalize_ayanamsa_name(payload.get("ayanamsa", "lahiri")) + local_dt = datetime( + int(payload["year"]), + int(payload["month"]), + int(payload["day"]), + int(float(payload.get("hour", 0))), + int(float(payload.get("minute", 0))), + int(float(payload.get("second", 0))), + ) + lat = float(payload["lat"]) + lon = float(payload["lon"]) + tz_requested = payload.get("tz") + timezone_source = "explicit_offset" + if tz_requested in {None, ""}: + tz = infer_timezone_offset(lat=lat, lon=lon, local_datetime=local_dt) + timezone_source = "iana_inferred" + else: + tz = float(tz_requested) + if not math.isfinite(tz) or not -14 <= tz <= 14: + raise CalculationError("tz must be a finite offset between -14 and 14") + return { + "year": local_dt.year, + "month": local_dt.month, + "day": local_dt.day, + "hour": int(float(payload.get("hour", 0))), + "minute": int(float(payload.get("minute", 0))), + "second": int(float(payload.get("second", 0))), + "lat": lat, + "lon": lon, + "tz": tz, + "timezone_source": timezone_source, + "ayanamsa": ayanamsa, + "node_mode": requested_node, + } + + +def _contract(requested: dict[str, Any], effective: dict[str, Any], *, algorithm: str) -> dict[str, Any]: + return { + "contract_version": CONTRACT_VERSION, + "algorithm": algorithm, + "requested": requested, + "effective": effective, + } + + +def compute_chart(payload: dict[str, Any]) -> dict[str, Any]: + request = _normalized_request(payload) + with _SWISSEPH_LOCK: + chart, _asc_idx, _jd, _ayanamsa = compute_chart_data( + request["year"], + request["month"], + request["day"], + request["hour"], + request["minute"], + request["lat"], + request["lon"], + request["tz"], + node_mode=request["node_mode"], + second=request["second"], + ayanamsa_name=request["ayanamsa"], + ) + if not isinstance(chart, dict): + raise CalculationError("canonical chart calculation failed") + + for planet in chart.get("planets", {}).values(): + if not isinstance(planet, dict) or "error" in planet: + continue + planet.setdefault("lon", planet.get("degree_raw", planet.get("degree"))) + if planet.get("sign") in SIGNS: + planet.setdefault("sign_idx", SIGNS.index(planet["sign"])) + + birth = chart.get("birth_info", {}) + effective = { + "ayanamsa": birth.get("ayanamsa_name", request["ayanamsa"]), + "node_mode": birth.get("node_mode", request["node_mode"]), + "timezone_offset": request["tz"], + "timezone_source": request["timezone_source"], + "ephemeris_source": "swisseph_calc_ut", + "ephemeris_flags_verified": False, + } + requested = { + "ayanamsa": payload.get("ayanamsa", "lahiri"), + "node_mode": payload.get("node_mode", payload.get("nodeMode", "mean")), + "timezone_offset": payload.get("tz"), + } + contract = _contract(requested, effective, algorithm="sidereal_natal_chart") + hash_payload = { + "contract": contract, + "birth": birth, + "ascendant": chart.get("ascendant"), + "planets": chart.get("planets"), + } + chart["calculation_contract"] = contract + chart["result_hash"] = _canonical_hash(hash_payload) + return chart + + +def compute_vimshottari_timeline( + *, birth_dt: datetime, moon_lon: float, current_date: datetime | None = None +) -> dict[str, Any]: + nak_info, progress, pada = lon_to_nakshatra(float(moon_lon) % 360) + timeline, elapsed, remaining, start_lord = build_dasha_timeline( + birth_dt.strftime("%Y-%m-%d"), nak_info, progress + ) + periods = [ + { + "lord": period["lord"], + "years": period["years"], + "start": period["start"].strftime("%Y-%m-%d"), + "end": period["end"].strftime("%Y-%m-%d"), + } + for period in timeline + ] + contract = _contract( + {"moon_longitude": float(moon_lon) % 360}, + {"year_basis_days": 365.25, "nakshatra": nak_info[0], "pada": pada}, + algorithm="vimshottari_birth_balance", + ) + result = { + "periods": periods, + "birth_balance": { + "lord": start_lord, + "elapsed_years": elapsed, + "remaining_years": remaining, + }, + "calculation_contract": contract, + } + result["result_hash"] = _canonical_hash(result) + return result +def compute_transit_longitude( + *, planet: str, reference_date: str, tz: float, ayanamsa: str = "lahiri" +) -> dict[str, Any]: + if planet not in _PLANET_IDS: + raise CalculationError(f"unsupported transit planet: {planet}") + try: + local_dt = datetime.strptime(reference_date[:10], "%Y-%m-%d").replace(hour=12) + except (TypeError, ValueError) as exc: + raise CalculationError("reference_date must be YYYY-MM-DD") from exc + ayanamsa_name = normalize_ayanamsa_name(ayanamsa) + with _SWISSEPH_LOCK: + apply_ayanamsa(ayanamsa_name, swe) + jd = swe.julday( + local_dt.year, + local_dt.month, + local_dt.day, + 12.0 - float(tz), + ) + ayanamsa_value = swe.get_ayanamsa(jd) + position, flags = swe.calc_ut(jd, _PLANET_IDS[planet]) + longitude = (position[0] - ayanamsa_value) % 360 + return { + "planet": planet, + "longitude": longitude, + "reference_date": reference_date[:10], + "ayanamsa": ayanamsa_name, + "timezone_offset": float(tz), + "swisseph_return_flags": int(flags), + "data_layer": "true_transit_positions", + } + + +def compute_sade_sati( + *, + moon_degree: float, + asc_degree: float, + reference_date: str, + tz: float, + ayanamsa: str = "lahiri", +) -> dict[str, Any]: + transit = compute_transit_longitude( + planet="Saturn", + reference_date=reference_date, + tz=tz, + ayanamsa=ayanamsa, + ) + result = calc_sade_sati_complete( + float(moon_degree) % 360, + float(asc_degree) % 360, + transit["longitude"], + datetime.strptime(reference_date[:10], "%Y-%m-%d"), + ) + result["transit_saturn_lon"] = transit["longitude"] + result["provenance"] = transit + result["calculation_contract"] = _contract( + {"reference_date": reference_date[:10], "ayanamsa": ayanamsa, "tz": tz}, + transit, + algorithm="sade_sati_true_saturn_transit", + ) + result["result_hash"] = _canonical_hash(result) + return result diff --git a/scripts/external_oracle_raw_import.py b/scripts/external_oracle_raw_import.py new file mode 100644 index 00000000..e20889a6 --- /dev/null +++ b/scripts/external_oracle_raw_import.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Validate a reviewable external raw-oracle artifact before parity replay.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + + +SUPPORTED_ENGINES = {"VedAstro", "PyJHora_JHora", "jyotishganit"} + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def build_raw_oracle_import(engine: str, artifact_path: str | Path, metadata: dict[str, Any]) -> dict[str, Any]: + if engine not in SUPPORTED_ENGINES: + raise ValueError(f"unsupported oracle engine: {engine}") + path = Path(artifact_path).expanduser().resolve() + if not path.is_file(): + raise ValueError("source artifact does not exist") + required = ("case_id", "license_boundary", "collection_method", "birth_data_policy") + missing = [key for key in required if not metadata.get(key)] + if missing: + raise ValueError(f"missing raw-oracle metadata: {', '.join(missing)}") + if metadata["birth_data_policy"] != "public_case_only": + raise ValueError("raw-oracle imports require public_case_only birth data") + return { + "scope": "external_raw_oracle_import", + "schema_version": 1, + "engine": engine, + "status": "raw_imported_uncompared", + "source_artifact": str(path), + "source_artifact_sha256": sha256_file(path), + "metadata": { + key: metadata[key] + for key in (*required, "engine_version", "ayanamsa", "node_mode", "captured_at") + if metadata.get(key) is not None + }, + "comparison_ready": False, + "boundary": "Import integrity only. Parity is external_verified only after normalized field comparison passes.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--engine", required=True, choices=sorted(SUPPORTED_ENGINES)) + parser.add_argument("--artifact", required=True) + parser.add_argument("--metadata-json", required=True, help="JSON file containing import metadata") + args = parser.parse_args() + metadata = json.loads(Path(args.metadata_json).read_text(encoding="utf-8")) + print(json.dumps(build_raw_oracle_import(args.engine, args.artifact, metadata), ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/gulika.py b/scripts/gulika.py new file mode 100644 index 00000000..f92f439b --- /dev/null +++ b/scripts/gulika.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Swiss-Ephemeris Gulika calculator using the Prasna Marga Ghatika table.""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import swisseph as swe + +try: + from saham_daynight import determine_daytime +except ImportError: + from scripts.saham_daynight import determine_daytime + + +# Monday=0, matching datetime.weekday(). Values are the end of Saturn's share +# measured in Ghatika from the relevant sunrise/sunset (30 Ghatika per period). +GHATIKA_END = { + 0: {"day": 22, "night": 6}, + 1: {"day": 18, "night": 2}, + 2: {"day": 14, "night": 26}, + 3: {"day": 10, "night": 22}, + 4: {"day": 6, "night": 18}, + 5: {"day": 2, "night": 14}, + 6: {"day": 26, "night": 10}, +} + + +def _sidereal_ascendant(jd_ut: float, lat: float, lon: float) -> float: + swe.set_sid_mode(swe.SIDM_LAHIRI) + cusps, ascmc = swe.houses_ex(jd_ut, lat, lon, b"P", swe.FLG_SIDEREAL) + return float(ascmc[0]) % 360 + + +def calculate_gulika( + moment: datetime, + *, + lat: float, + lon: float, + tz: float, +) -> dict[str, Any]: + """Return Gulika from local moment/location using Swiss sunrise and sunset.""" + daynight = determine_daytime(moment, lat=lat, lon=lon, tz=tz) + is_day = bool(daynight["is_daytime"]) + period = "day" if is_day else "night" + ghatika_end = GHATIKA_END[moment.weekday()][period] + start_jd = daynight["sunrise_jd_ut"] if is_day else daynight["sunset_jd_ut"] + end_jd = daynight["sunset_jd_ut"] if is_day else daynight["sunrise_jd_ut"] + 1.0 + if end_jd <= start_jd: + end_jd += 1.0 + segment_jd = start_jd + (end_jd - start_jd) * (ghatika_end / 30.0) + longitude = _sidereal_ascendant(segment_jd, float(lat), float(lon)) + return { + "scope": "gulika_prasna_marga", + "status": "partial", + "longitude": round(longitude, 6), + "sign_idx": int(longitude / 30) % 12, + "degree_in_sign": round(longitude % 30, 6), + "period": period, + "weekday": moment.weekday(), + "ghatika_end": ghatika_end, + "segment_jd_ut": segment_jd, + "daynight_evidence": daynight, + "ayanamsa": "lahiri", + "rule_source": "references/prashna-complete-guide.md#3.5", + "boundary": "Formula is implemented from the local classical guide; external JHora/PyJHora numeric parity remains required before enabling Sphuta or verdict layers.", + } diff --git a/scripts/interpretation_source_inventory_gate.py b/scripts/interpretation_source_inventory_gate.py index 4f27209d..aa24a9ec 100644 --- a/scripts/interpretation_source_inventory_gate.py +++ b/scripts/interpretation_source_inventory_gate.py @@ -13,7 +13,7 @@ ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from mcp_server import _existing_interpretation_source_pack # noqa: E402 +from scripts.strict_evidence_service import existing_interpretation_source_pack # noqa: E402 REQUIRED_LAYERS = [ @@ -100,7 +100,7 @@ CANDIDATE_KEYWORDS = [ def build_report() -> dict[str, Any]: - source_pack = _existing_interpretation_source_pack() + source_pack = existing_interpretation_source_pack() inventory = source_pack.get("interpretation_source_inventory") if isinstance(source_pack, dict) else {} if not isinstance(inventory, dict): inventory = {} diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index e66719a9..7aa4602f 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -15,10 +15,13 @@ import json, sys, os, math import importlib.util import hashlib import re +import sqlite3 +import secrets import threading import time +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta -from http.server import HTTPServer, BaseHTTPRequestHandler +from http.server import HTTPServer, BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import urlparse @@ -45,35 +48,167 @@ try: from scripts.western_oracle_adapter import build_packet_from_oracle_payload except ModuleNotFoundError: # pragma: no cover - script execution path from western_oracle_adapter import build_packet_from_oracle_payload +try: + from scripts.western_chart_engine import build_tropical_western_evidence_packet + from scripts.western_timing_engine import build_timing_techniques +except ModuleNotFoundError: # pragma: no cover - script execution path + from western_chart_engine import build_tropical_western_evidence_packet + from western_timing_engine import build_timing_techniques load_local_env(REPO_ROOT) _LOCAL_MODULE_CACHE = {} _API_CHART_CACHE_SCOPE = 'api_chart_response' _HIGH_RIGOR_JOB_SCOPE = 'high_rigor_workflow' _UNIFIED_CONSULTATION_ORCHESTRATOR = UnifiedConsultationOrchestrator() +_ASYNC_JOB_WORKERS = max(int(os.environ.get('JYOTISH_ASYNC_JOB_WORKERS', '2')), 1) +_ASYNC_JOB_QUEUE_SIZE = max(int(os.environ.get('JYOTISH_ASYNC_JOB_QUEUE_SIZE', '8')), 0) +_ASYNC_JOB_EXECUTOR = ThreadPoolExecutor( + max_workers=_ASYNC_JOB_WORKERS, + thread_name_prefix='jyotish-job', +) +_ASYNC_JOB_CAPACITY = threading.BoundedSemaphore(_ASYNC_JOB_WORKERS + _ASYNC_JOB_QUEUE_SIZE) +_RATE_LIMIT_LOCK = threading.Lock() +_RATE_LIMIT_BUCKETS: dict[str, tuple[float, int]] = {} -def _western_evidence_packet_from_body(body: dict, route_packet: dict) -> dict | None: +def summarize_execution_status(result: dict | None) -> dict: + result = result if isinstance(result, dict) else {} + fallback = str(result.get('fallback_reason') or '') + official = 'official_blocked' if 'VedAstro official snapshot blocked' in fallback else result.get('official_evidence_status', 'unknown') + return { + 'official_evidence_status': official, + 'fallback_reason': result.get('fallback_reason'), + } + + +def build_evidence_packet_view(job_record: dict | None) -> dict: + """Public, token-protected job view. Excludes prompt internals and raw input.""" + job_record = job_record or {} + result = job_record.get('result') + result = result if isinstance(result, dict) else {} + return { + 'scope': 'evidence_packet_view', + 'job_id': job_record.get('job_id'), + 'status': job_record.get('status', 'unknown'), + 'execution_status': summarize_execution_status(result), + 'machine_evidence_packet': result.get('machine_evidence_packet') or {}, + 'technique_audit': result.get('technique_audit') or result.get('technique_audit_table') or [], + 'warnings': result.get('warnings') or [], + } + + +def _submit_background_job(callback): + if not _ASYNC_JOB_CAPACITY.acquire(blocking=False): + raise JobQueueFull('Async job queue is full') + try: + future = _ASYNC_JOB_EXECUTOR.submit(callback) + except Exception: + _ASYNC_JOB_CAPACITY.release() + raise + future.add_done_callback(lambda _future: _ASYNC_JOB_CAPACITY.release()) + return future + + +def _rate_limit_per_minute() -> int: + raw = str(os.environ.get('JYOTISH_API_RATE_LIMIT_PER_MINUTE', '120')).strip() + try: + return max(int(raw), 0) + except ValueError: + return 120 + + +def enforce_rate_limit(client_id: str, *, now: float | None = None) -> None: + limit = _rate_limit_per_minute() + if limit == 0: + return + now = time.time() if now is None else now + with _RATE_LIMIT_LOCK: + window, count = _RATE_LIMIT_BUCKETS.get(client_id, (now, 0)) + if now - window >= 60: + window, count = now, 0 + if count >= limit: + raise RateLimited('Rate limit exceeded') + _RATE_LIMIT_BUCKETS[client_id] = (window, count + 1) + + +def _western_evidence_packet_from_body( + body: dict, + route_packet: dict, + *, + birth_payload: dict | None = None, +) -> dict | None: explicit_packet = body.get('western_evidence_packet') if isinstance(explicit_packet, dict): return explicit_packet oracle_payload = body.get('western_oracle_payload') or body.get('western_astrology_oracle') - if not isinstance(oracle_payload, dict): + if isinstance(oracle_payload, dict): + try: + return build_packet_from_oracle_payload(oracle_payload, route_packet=route_packet) + except Exception as exc: # pragma: no cover - defensive contract boundary + return { + 'system': 'western_astrology', + 'status': 'blocked', + 'route': dict(route_packet), + 'signals': [], + 'missing_sections': ['western_oracle_payload'], + 'adapter_error': exc.__class__.__name__, + 'boundary': 'Western oracle payload was supplied but could not be normalized.', + } + automatic = body.get('western_mode', body.get('western_auto_compute', 'auto')) + if automatic in {False, 'off', 'external_only'} or body.get('entry_mode') == 'prashna' or not isinstance(birth_payload, dict): return None try: - return build_packet_from_oracle_payload(oracle_payload, route_packet=route_packet) - except Exception as exc: # pragma: no cover - defensive contract boundary + packet = build_tropical_western_evidence_packet( + route_packet=route_packet, + year=int(birth_payload['year']), month=int(birth_payload['month']), day=int(birth_payload['day']), + hour=int(birth_payload['hour']), minute=int(birth_payload['minute']), second=int(birth_payload.get('second', 0)), + latitude=float(birth_payload['lat']), longitude=float(birth_payload['lon']), + timezone=body.get('western_timezone') or birth_payload['tz'], + house_system=str(body.get('western_house_system', 'P')), + ) + timing_request = body.get('western_timing') + if isinstance(timing_request, dict): + birth = { + 'year': int(birth_payload['year']), 'month': int(birth_payload['month']), 'day': int(birth_payload['day']), + 'hour': int(birth_payload['hour']), 'minute': int(birth_payload['minute']), 'second': int(birth_payload.get('second', 0)), + 'latitude': float(birth_payload['lat']), 'longitude': float(birth_payload['lon']), + 'timezone': body.get('western_timezone') or birth_payload['tz'], + 'house_system': str(body.get('western_house_system', 'P')), + } + timing = build_timing_techniques( + **birth, + transit_date=timing_request.get('transit_date'), + solar_return_year=timing_request.get('solar_return_year'), + secondary_progression_date=timing_request.get('secondary_progression_date'), + solar_arc_date=timing_request.get('solar_arc_date'), + converse_secondary_progression_date=timing_request.get('converse_secondary_progression_date'), + converse_solar_arc_date=timing_request.get('converse_solar_arc_date'), + midpoint_date=timing_request.get('midpoint_date'), + lunar_return_start_date=timing_request.get('lunar_return_start_date'), + duration_scan_start_date=timing_request.get('duration_scan_start_date'), + duration_scan_end_date=timing_request.get('duration_scan_end_date'), + parans_date=timing_request.get('parans_date'), + ) + if timing: + packet['timing_techniques'] = timing + packet['sections']['timing_techniques'] = {'status': 'used', 'source_path': 'western.native_timing'} + packet['missing_sections'] = [item for item in packet['missing_sections'] if item != 'timing_techniques'] + packet['boundary'] = ( + 'Native calculations include only explicitly requested transit, solar-return, secondary-progression, ' + 'solar-arc, midpoint, lunar-return and daily duration-scan layers; parans remain blocked until a ' + 'dedicated latitude-aware event solver is implemented. Outputs do not infer outcomes or interpretation.' + ) + return packet + except Exception as exc: # pragma: no cover - defensive boundary return { 'system': 'western_astrology', 'status': 'blocked', 'route': dict(route_packet), 'signals': [], - 'missing_sections': ['western_oracle_payload'], + 'missing_sections': ['native_tropical_calculation'], 'adapter_error': exc.__class__.__name__, - 'boundary': 'Western oracle payload was supplied but could not be normalized.', + 'boundary': 'Native Western natal calculation could not be materialized.', } - - def _consultation_reference_date(body: dict) -> datetime: raw = ( body.get('reference_date') @@ -689,29 +824,140 @@ def _async_job_path(scope: str, job_id: str) -> Path: return _async_job_dir(scope) / f'{job_id}.json' -def _load_high_rigor_job_record(job_id: str) -> dict | None: - return _load_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id) +def _async_job_ttl_seconds() -> float: + raw = str(os.environ.get('JYOTISH_ASYNC_JOB_TTL_SECONDS', '3600')).strip() + try: + return max(float(raw), 1.0) + except ValueError: + return 3600.0 + + +def _async_job_backend() -> str: + return "sqlite" if os.environ.get("JYOTISH_ASYNC_JOB_BACKEND", "file").strip().lower() == "sqlite" else "file" + + +def _sqlite_job_db_path() -> Path: + return Path(REPO_ROOT) / "scratch" / "local" / "async_jobs.sqlite3" + + +def _sqlite_job_connection() -> sqlite3.Connection: + path = _sqlite_job_db_path() + path.parent.mkdir(parents=True, exist_ok=True) + connection = sqlite3.connect(path, timeout=10) + connection.execute( + "CREATE TABLE IF NOT EXISTS async_jobs (scope TEXT NOT NULL, job_id TEXT NOT NULL, expires_at REAL, payload TEXT NOT NULL, PRIMARY KEY (scope, job_id))" + ) + try: + os.chmod(path, 0o600) + except OSError: + pass + return connection + + +def prune_expired_async_jobs() -> dict: + """Best-effort startup cleanup for local job records; never reads payloads.""" + removed = 0 + scanned = 0 + if _async_job_backend() == "sqlite": + with _sqlite_job_connection() as connection: + scanned = connection.execute("SELECT COUNT(*) FROM async_jobs").fetchone()[0] + removed = connection.execute( + "DELETE FROM async_jobs WHERE expires_at IS NOT NULL AND expires_at <= ?", (time.time(),) + ).rowcount + return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed} + for scope in (_HIGH_RIGOR_JOB_SCOPE, _API_CHART_CACHE_SCOPE): + directory = _async_job_dir(scope) + if not directory.is_dir(): + continue + for path in directory.glob('*.json'): + scanned += 1 + try: + record = json.loads(path.read_text(encoding='utf-8')) + expires_at = record.get('expires_at_unix') if isinstance(record, dict) else None + if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at): + path.unlink() + removed += 1 + except (OSError, json.JSONDecodeError): + continue + return {'scope': 'async_job_cleanup', 'scanned': scanned, 'removed': removed} + + +def _new_async_job_identity(prefix: str) -> dict: + return { + 'job_id': f'{prefix}_{secrets.token_hex(16)}', + 'access_token': secrets.token_urlsafe(32), + } + + +def _access_token_hash(token: str) -> str: + return hashlib.sha256(token.encode('utf-8')).hexdigest() + + +def _load_high_rigor_job_record(job_id: str, *, access_token: str = '') -> dict | None: + return _load_async_job_record( + _HIGH_RIGOR_JOB_SCOPE, + job_id, + access_token=access_token, + ) def _write_high_rigor_job_record(job_id: str, payload: dict) -> dict: return _write_async_job_record(_HIGH_RIGOR_JOB_SCOPE, job_id, payload) -def _load_async_job_record(scope: str, job_id: str) -> dict | None: - path = _async_job_path(scope, job_id) - if not path.exists(): - return None - try: - return json.loads(path.read_text(encoding='utf-8')) - except (OSError, json.JSONDecodeError): +def _load_async_job_record(scope: str, job_id: str, *, access_token: str = '') -> dict | None: + path = None + if _async_job_backend() == "sqlite": + with _sqlite_job_connection() as connection: + row = connection.execute( + "SELECT payload FROM async_jobs WHERE scope = ? AND job_id = ?", (scope, job_id) + ).fetchone() + if row is None: + return None + try: + record = json.loads(row[0]) + except json.JSONDecodeError: + return None + else: + path = _async_job_path(scope, job_id) + if not path.exists(): + return None + try: + record = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError): + return None + expires_at = record.get('expires_at_unix') + if isinstance(expires_at, (int, float)) and time.time() >= float(expires_at): + if _async_job_backend() == "sqlite": + with _sqlite_job_connection() as connection: + connection.execute("DELETE FROM async_jobs WHERE scope = ? AND job_id = ?", (scope, job_id)) + elif path is not None: + try: + path.unlink() + except OSError: + pass return None + expected = record.get('access_token_hash') + if not isinstance(expected, str) or not access_token: + raise JobAccessDenied('Async job access token required') + if not secrets.compare_digest(expected, _access_token_hash(access_token)): + raise JobAccessDenied('Async job access token invalid') + return record def _write_async_job_record(scope: str, job_id: str, payload: dict) -> dict: - _async_job_path(scope, job_id).write_text( - json.dumps(payload, ensure_ascii=False, sort_keys=True), - encoding='utf-8', - ) + if _async_job_backend() == "sqlite": + with _sqlite_job_connection() as connection: + connection.execute( + "INSERT OR REPLACE INTO async_jobs (scope, job_id, expires_at, payload) VALUES (?, ?, ?, ?)", + (scope, job_id, payload.get("expires_at_unix"), json.dumps(payload, ensure_ascii=False, sort_keys=True)), + ) + return payload + path = _async_job_path(scope, job_id) + temp_path = path.with_suffix(f'.{secrets.token_hex(8)}.tmp') + temp_path.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True), encoding='utf-8') + os.chmod(temp_path, 0o600) + os.replace(temp_path, path) return payload @@ -951,6 +1197,8 @@ API_COMMAND_MAP = { 'yoga': '/api/yogas', 'aspects': '/api/aspects', 'rectification': '/api/rectification_gate', + 'active-rectification-questions': '/api/active_rectification_questions', + 'active-rectification-score': '/api/active_rectification_score', 'case-validation': '/api/case_validation', 'divisional-yoga': '/api/divisional_yoga', 'deep-varga-avastha': '/api/deep_varga_avastha', @@ -982,6 +1230,8 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = { '/api/pancha_mahapurusha', '/api/prashna', '/api/rectification_gate', + '/api/active_rectification_questions', + '/api/active_rectification_score', '/api/relationship', '/api/remedies', '/api/sade_sati', @@ -1031,6 +1281,26 @@ class BadRequest(ValueError): """Client-side request validation failed.""" +class Forbidden(PermissionError): + """Request failed the local API trust boundary.""" + + +class UnsupportedMediaType(ValueError): + """Request body media type is not supported.""" + + +class JobAccessDenied(PermissionError): + """Async job capability token is missing or invalid.""" + + +class JobQueueFull(RuntimeError): + """Bounded async worker queue has no remaining capacity.""" + + +class RateLimited(RuntimeError): + """Client exceeded the local fixed-window request budget.""" + + class JyotishAPIHandler(BaseHTTPRequestHandler): server_version = 'JyotishAPI/6.9.14' @@ -1054,6 +1324,19 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): if origin in allowed: self.send_header('Access-Control-Allow-Origin', origin) + def _enforce_request_security(self, *, require_json=False): + origin = self.headers.get('Origin') + allowed = getattr(self.server, 'allowed_origins', DEFAULT_ALLOWED_ORIGINS) + if origin and origin not in allowed: + raise Forbidden('Origin is not allowed') + host = (self.headers.get('Host') or '').split(':', 1)[0].strip('[]').lower() + if host and host not in {'localhost', '127.0.0.1', '::1'}: + raise Forbidden('Host is not allowed') + if require_json: + content_type = (self.headers.get('Content-Type') or '').split(';', 1)[0].strip().lower() + if content_type != 'application/json': + raise UnsupportedMediaType('Content-Type must be application/json') + def _vedastro_status(self): adapter = _load_local_module('vedastro_service_adapter') endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip() @@ -1167,6 +1450,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): def do_POST(self): path = urlparse(self.path).path try: + self._enforce_request_security(require_json=True) body = self._read_json_body() if path == '/api/chart': result = self._compute_chart(body) @@ -1271,6 +1555,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): elif path == '/api/rectification_gate': result = self._compute_rectification_gate(body) self._json(result) + elif path == '/api/active_rectification_questions': + result = self._compute_active_rectification_questions(body) + self._json(result) + elif path == '/api/active_rectification_score': + result = self._compute_active_rectification_score(body) + self._json(result) elif path == '/api/case_validation': result = self._compute_case_validation(body) self._json(result) @@ -1305,6 +1595,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): self._error_json(f'Unknown endpoint: {path}', 404, 'ERR_NOT_FOUND') except BadRequest as e: self._error_json(str(e), 400, 'ERR_BAD_REQUEST') + except Forbidden as e: + self._error_json(str(e), 403, 'ERR_FORBIDDEN') + except UnsupportedMediaType as e: + self._error_json(str(e), 415, 'ERR_UNSUPPORTED_MEDIA_TYPE') except Exception: import logging logging.exception("[api_server] request failed for %s", path) @@ -4405,6 +4699,57 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): except Exception as e: import logging logging.warning(f"[api_server] yoga expansion detection failed: {e}") + calculation_service = _load_local_module('domain_calculation_service') + canonical_chart = calculation_service.compute_chart({ + 'year': year, + 'month': month, + 'day': day, + 'hour': hour, + 'minute': minute, + 'second': second, + 'lat': lat, + 'lon': lon, + 'tz': tz, + 'ayanamsa': body.get('ayanamsa', 'lahiri'), + 'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')), + }) + canonical_dasha = calculation_service.compute_vimshottari_timeline( + birth_dt=birth_dt, + moon_lon=moon_lon, + current_date=datetime.utcnow(), + ) + sade_sati = calculation_service.compute_sade_sati( + moon_degree=canonical_chart['planets']['Moon']['lon'], + asc_degree=canonical_chart['ascendant']['lon'], + reference_date=body.get('transit_date') or body.get('today') or body.get('current_date') or datetime.utcnow().strftime('%Y-%m-%d'), + tz=tz, + ayanamsa=body.get('ayanamsa', 'lahiri'), + ) + canonical_planets = {} + for planet_name, planet in canonical_chart.get('planets', {}).items(): + if not isinstance(planet, dict): + continue + normalized_planet = dict(planet) + normalized_planet['degree'] = normalized_planet.get( + 'degree_in_sign', + normalized_planet.get('degree', normalized_planet.get('lon')), + ) + if normalized_planet.get('sign') in SIGNS: + normalized_planet['sign_idx'] = SIGNS.index(normalized_planet['sign']) + canonical_planets[planet_name] = normalized_planet + planets_data = canonical_planets or planets_data + ascendant_data = dict(canonical_chart.get('ascendant', {})) + if ascendant_data.get('sign') in SIGNS: + asc_sign = ascendant_data['sign'] + asc_sign_idx = SIGNS.index(asc_sign) + asc_lon = float(ascendant_data.get('lon', asc_lon)) + canonical_houses = canonical_chart.get('houses', {}) + if isinstance(canonical_houses, dict) and canonical_houses: + houses = {} + for h in range(1, 13): + house = canonical_houses.get(f'house_{h}', {}) + sign = house.get('cusp_sign', SIGNS[(asc_sign_idx + h - 1) % 12]) + houses[h] = {'sign': sign, 'sign_idx': SIGNS.index(sign)} result = { 'success': True, 'version': '6.9.15', 'birth': { @@ -4420,7 +4765,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'ayanamsa': round(ayanamsa, 4), 'ayanamsa_name': ayanamsa_name, 'ayanamsa_display': ayanamsa_display, - 'node_mode': body.get('node_mode', body.get('nodeMode', 'mean')), + 'node_mode': canonical_chart['calculation_contract']['effective']['node_mode'], }, 'ascendant': { 'sign': asc_sign, @@ -4431,10 +4776,14 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): }, 'planets': planets_data, 'houses': houses, 'shadbala': shadbala_summary, 'dasha': { - 'current_md': md_lord, - 'remaining_years': round(remaining, 2), + 'current_md': canonical_dasha['birth_balance']['lord'], + 'remaining_years': canonical_dasha['birth_balance']['remaining_years'], 'total_years': total_years, - 'start_date': dasha_start.isoformat() if hasattr(dasha_start, 'isoformat') else str(dasha_start), + 'start_date': canonical_dasha['periods'][0]['start'], + 'periods': canonical_dasha['periods'], + 'birth_balance': canonical_dasha['birth_balance'], + 'calculation_contract': canonical_dasha['calculation_contract'], + 'result_hash': canonical_dasha['result_hash'], }, 'yogas': yogas, 'sade_sati': sade_sati, @@ -4443,6 +4792,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'special_lagnas': special_lagnas, 'available_dashas': dasha_list, 'dasha_count': len(dasha_list), + 'calculation_contract': canonical_chart['calculation_contract'], + 'result_hash': canonical_chart['result_hash'], } result['modules'] = { 'chart': { @@ -4906,12 +5257,34 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): return calc_kp_analysis(planets, SIGNS[asc_idx]) def _compute_prashna(self, body): + try: + from prashna_context import PrashnaContextError, build_prashna_context + except ModuleNotFoundError: # pragma: no cover - package import path + from scripts.prashna_context import PrashnaContextError, build_prashna_context question_type = body.get('question', 'general') if not isinstance(question_type, str): raise BadRequest('question must be a string') question_text = body.get('question_text', '') if not isinstance(question_text, str): raise BadRequest('question_text must be a string') + if "question_text" in body and not isinstance(body["question_text"], str): + raise BadRequest('question_text must be a string') + if "planets" in body or "asc_degree" in body: + raise BadRequest("Prashna planets and ascendant are backend-computed; client values are forbidden") + try: + context = build_prashna_context(body) + except PrashnaContextError as exc: + raise BadRequest(str(exc)) from exc + return { + "success": True, + "status": "computed", + "prashna_context": context, + "verdict": { + "status": "blocked", + "reason": "Prashna adjudication is disabled until Tajika/Saham/Sphuta kernels pass classic golden cases.", + }, + } + # Legacy client-supplied-chart pipeline below is unreachable pending deletion. from prashna import ( QUESTION_CATEGORIES, analyze_lost_item, @@ -5574,6 +5947,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): allowed_modes = {'all', 'karaka', 'dasha', 'karakamsha', 'arudha', 'special'} if mode not in allowed_modes: raise BadRequest(f'mode must be one of: {", ".join(sorted(allowed_modes))}') + variant = body.get('variant', 'current') + if not isinstance(variant, str): + raise BadRequest('variant must be a string') + variant = variant.strip().lower() or 'current' + allowed_variants = {'current', 'rangacharya', 'all'} + if variant not in allowed_variants: + raise BadRequest(f'variant must be one of: {", ".join(sorted(allowed_variants))}') antardasha = bool(body.get('antardasha', False)) year = self._get_int(body, 'year', datetime.now().year, 1800, 2400) month = self._get_int(body, 'month', 1, 1, 12) @@ -5603,6 +5983,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): if mode in ('all', 'arudha'): result['arudha_padas'] = jaimini.calc_arudha_padas(asc_sign_idx, planet_lons) result['graha_padas'] = jaimini.calc_graha_padas(planet_lons) + if variant in ('rangacharya', 'all'): + rangacharya = _load_local_module('rangacharya') + rangacharya_result = rangacharya.calc_rangacharya_variant(asc_sign_idx, planet_lons) + result['rangacharya'] = rangacharya_result + current_arudha = result.get('arudha_padas') or jaimini.calc_arudha_padas(asc_sign_idx, planet_lons) + result['rangacharya_diff'] = rangacharya.diff_current_vs_rangacharya(current_arudha, rangacharya_result) if mode in ('all', 'special'): result['special_lagnas'] = jaimini.calc_special_lagnas(asc_sign_idx, hour, minute + second / 60.0) return { @@ -6151,6 +6537,58 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): }, } + def _compute_active_rectification_questions(self, body): + birth_time = body.get('birth_time') + if not isinstance(birth_time, str) or not birth_time.strip(): + raise BadRequest('birth_time must be a string') + uncertainty_minutes = self._get_int(body, 'uncertainty_minutes', 30) + if not 1 <= uncertainty_minutes <= 180: + raise BadRequest('uncertainty_minutes must be between 1 and 180') + step_minutes = self._get_int(body, 'step_minutes', 1) + if not 1 <= step_minutes <= 30: + raise BadRequest('step_minutes must be between 1 and 30') + lat = lon = tz = None + if any(key in body for key in ('lat', 'lon', 'tz')): + lat = self._get_float(body, 'lat', 0, -90, 90) + lon = self._get_float(body, 'lon', 0, -180, 180) + tz = self._get_float(body, 'tz', 0, -14, 14) + ayanamsa = body.get('ayanamsa', 'lahiri') + if not isinstance(ayanamsa, str): + raise BadRequest('ayanamsa must be a string') + try: + module = _load_local_module('active_rectification_questions') + result = module.build_questionnaire( + birth_time.strip(), + uncertainty_minutes=uncertainty_minutes, + step_minutes=step_minutes, + lat=lat, + lon=lon, + tz=tz, + ayanamsa=ayanamsa, + ) + except ValueError as e: + raise BadRequest('birth_time must be YYYY-MM-DD HH:MM') from e + return { + 'success': True, + 'endpoint': 'active_rectification_questions', + **result, + } + + def _compute_active_rectification_score(self, body): + questionnaire = body.get('questionnaire') + if not isinstance(questionnaire, dict): + raise BadRequest('questionnaire must be an object') + answers = body.get('answers') + if not isinstance(answers, dict): + raise BadRequest('answers must be an object') + module = _load_local_module('active_rectification_questions') + result = module.score_answers(questionnaire, answers) + return { + 'success': True, + 'endpoint': 'active_rectification_score', + **result, + } + def _compute_case_validation(self, body): planets, _, _ = self._normalized_planets_from_body(body) current_md = body.get('current_md', body.get('dasha_lord', '')) @@ -6911,6 +7349,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/pancha_mahapurusha': self._compute_pmc, '/api/prashna': self._compute_prashna, '/api/rectification_gate': self._compute_rectification_gate, + '/api/active_rectification_questions': self._compute_active_rectification_questions, + '/api/active_rectification_score': self._compute_active_rectification_score, '/api/relationship': self._compute_relationship, '/api/remedies': self._compute_remedies, '/api/sade_sati': self._compute_sade_sati, diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 241d037e..f745c2f2 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -735,12 +735,24 @@ def _birth_datetime_from_args(args): def _compute_chart_from_args(args): - return compute_chart_data( - args.year, args.month, args.day, args.hour, args.minute, - args.lat, args.lon, args.tz, getattr(args, 'node_mode', 'mean'), - second=_arg_second(args), - ayanamsa_name=_current_ayanamsa_name(args), - ) + from domain_calculation_service import compute_chart + + result = compute_chart({ + 'year': args.year, + 'month': args.month, + 'day': args.day, + 'hour': args.hour, + 'minute': args.minute, + 'second': _arg_second(args), + 'lat': args.lat, + 'lon': args.lon, + 'tz': args.tz, + 'node_mode': getattr(args, 'node_mode', 'mean'), + 'ayanamsa': _current_ayanamsa_name(args), + }) + asc_idx = SIGNS.index(result['ascendant']['sign']) + birth = result['birth_info'] + return result, asc_idx, birth['julian_day'], birth['ayanamsa'] def _current_ayanamsa_name(args=None): @@ -5085,14 +5097,26 @@ def cmd_full_reading(args): try: from tajika import calc_tajika_yogas, calc_all_sahams - # Tajika Yogas(用本命盘行星经度) - tc_yogas = calc_tajika_yogas(planet_lons) + # Seven-planet Tajika candidates require actual instantaneous speed. + tajika_planets = { + name: {"longitude": item.get("degree_raw", item.get("lon")), "speed": item.get("speed")} + for name, item in planets.items() + if isinstance(item, dict) and name in {"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"} + } + tc_yogas = calc_tajika_yogas(tajika_planets) report['modules']['tajika_yogas'] = tc_yogas # Sahams(特殊点)—— 需要出生时间 - birth_dt = getattr(args, 'birth_datetime', None) + birth_dt = _birth_datetime_from_args(args) if birth_dt and planet_lons: - sahams_result = calc_all_sahams(planet_lons, asc_deg, birth_dt) + sahams_result = calc_all_sahams( + planet_lons, + asc_deg, + birth_dt, + lat=getattr(args, 'lat', None), + lon=getattr(args, 'lon', None), + tz=getattr(args, 'tz', None), + ) report['modules']['sahams'] = sahams_result else: report['modules']['sahams'] = {'warning': 'birth_datetime or planet_lons missing, skip saham calc'} @@ -5871,48 +5895,30 @@ def cmd_full_reading(args): def cmd_prashna(args): """Prashna 问事占星:基于提问时刻的即时星盘分析""" try: - from prashna import cast_prashna, calc_arudha, calc_sphutas, calc_life_sphutas, calc_sahams, analyze_lost_item, kunda_verify, calc_gulika_simple + from prashna_context import PrashnaContextError, build_prashna_context except ImportError: - # 尝试从同目录导入 - import importlib.util, os - spec = importlib.util.spec_from_file_location("prashna", os.path.join(os.path.dirname(__file__), "prashna.py")) - prashna_mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(prashna_mod) - cast_prashna = prashna_mod.cast_prashna - calc_arudha = prashna_mod.calc_arudha - calc_sphutas = prashna_mod.calc_sphutas - calc_life_sphutas = prashna_mod.calc_life_sphutas - calc_sahams = prashna_mod.calc_sahams - analyze_lost_item = prashna_mod.analyze_lost_item - kunda_verify = prashna_mod.kunda_verify - calc_gulika_simple = prashna_mod.calc_gulika_simple - - if args.mode == 'chart': - return cast_prashna(args.datetime, args.lat, args.lon) - - # 其他模式需要先铸盘获取行星位置 - chart = cast_prashna(args.datetime, args.lat, args.lon) - if 'error' in chart: - return chart - - asc_lon = chart['ascendant']['lon'] - p_lons = {n: d['lon'] for n, d in chart['planets'].items()} - - if args.mode == 'arudha': - return {'arudha_lagna': calc_arudha(asc_lon, p_lons), - 'ascendant': chart['ascendant']} - elif args.mode == 'sphutas': - return calc_sphutas(p_lons, 0) - elif args.mode == 'sahams': - return calc_sahams(p_lons, asc_lon) - elif args.mode == 'lost-item': - return analyze_lost_item(p_lons, asc_lon) - elif args.mode == 'life': - return calc_life_sphutas(asc_lon, p_lons.get('Moon',0), p_lons.get('Sun',0), 0) - elif args.mode == 'kunda': - return kunda_verify(asc_lon) - else: - return cast_prashna(args.datetime, args.lat, args.lon) + from scripts.prashna_context import PrashnaContextError, build_prashna_context + try: + context = build_prashna_context({ + "question_text": args.question_text, + "question_timestamp": args.datetime, + "lat": args.lat, + "lon": args.lon, + "timezone": args.timezone, + "ayanamsa": args.ayanamsa, + "node_mode": args.node_mode, + "location_convention": args.location_convention, + }) + except PrashnaContextError as exc: + return {"scope": "prashna_context", "status": "blocked", "reason": str(exc)} + if args.mode != "chart": + return { + "scope": "prashna", + "status": "blocked", + "reason": f"{args.mode} is blocked pending validated Prashna kernel implementation", + "prashna_context": context, + } + return context # ============================================================================ @@ -6176,9 +6182,14 @@ def main(): # 23. prashna (v3.9新增) p = sub.add_parser('prashna', help='Prashna问事占星(提问时刻星盘+Arudha+Sphuta+Sahams)') - p.add_argument('--datetime', required=True, help='提问时间 YYYY-MM-DD HH:MM') + p.add_argument('--datetime', required=True, help='提问时间 ISO-8601,例如 2026-07-12T12:00:00+08:00') + p.add_argument('--question-text', required=True, help='用户原始问事文本') p.add_argument('--lat', type=float, required=True, help='纬度') p.add_argument('--lon', type=float, required=True, help='经度') + p.add_argument('--timezone', required=True, help='UTC offset,例如 8 或 +08:00') + p.add_argument('--ayanamsa', default='lahiri') + p.add_argument('--node-mode', default='mean', choices=['mean', 'true']) + p.add_argument('--location-convention', default='wgs84', choices=['wgs84']) p.add_argument('--mode', default='chart', choices=['chart','arudha','sphutas','sahams','lost-item','life','kunda'], help='分析模式') # 24. double-transit-pac (v3.9新增) diff --git a/scripts/ocr_extract.py b/scripts/ocr_extract.py new file mode 100644 index 00000000..b71b4334 --- /dev/null +++ b/scripts/ocr_extract.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Extract text from screenshots without requiring Homebrew-installed Tesseract.""" + +from __future__ import annotations + +import argparse +import json +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Any + + +DEFAULT_SHORTCUT_NAME = "Extract Text from Image" +VALID_BACKENDS = {"auto", "manual", "shortcuts", "tesseract"} + + +def choose_backend(requested: str = "auto") -> str: + if requested != "auto": + if requested not in VALID_BACKENDS: + raise ValueError(f"unsupported backend: {requested}") + return requested + if shutil.which("shortcuts"): + return "shortcuts" + if shutil.which("tesseract"): + return "tesseract" + return "manual" + + +def _manual_transcript_path(image: Path, transcript_dir: Path | None) -> Path: + base = transcript_dir or image.parent + return base / f"{image.stem}.txt" + + +def _extract_manual(image: Path, transcript_dir: Path | None) -> dict[str, Any]: + transcript = _manual_transcript_path(image, transcript_dir) + if not transcript.is_file(): + return { + "image_path": str(image), + "text": "", + "backend": "manual", + "status": "blocked", + "reason": "manual_transcript_missing", + "expected_transcript_path": str(transcript), + } + return { + "image_path": str(image), + "text": transcript.read_text(encoding="utf-8"), + "backend": "manual", + "status": "ok", + } + + +def _extract_shortcuts(image: Path, shortcut_name: str) -> dict[str, Any]: + if not shutil.which("shortcuts"): + return {"image_path": str(image), "text": "", "backend": "shortcuts", "status": "blocked", "reason": "shortcuts_cli_missing"} + completed = subprocess.run( + ["shortcuts", "run", shortcut_name, "-i", str(image)], + capture_output=True, + text=True, + check=False, + timeout=120, + ) + text = completed.stdout + if completed.returncode != 0: + return { + "image_path": str(image), + "text": text, + "backend": "shortcuts", + "status": "blocked", + "reason": "shortcuts_run_failed", + "stderr": completed.stderr.strip(), + "shortcut_name": shortcut_name, + } + return {"image_path": str(image), "text": text, "backend": "shortcuts", "status": "ok"} + + +def _extract_tesseract(image: Path) -> dict[str, Any]: + if not shutil.which("tesseract"): + return {"image_path": str(image), "text": "", "backend": "tesseract", "status": "blocked", "reason": "tesseract_missing"} + completed = subprocess.run( + ["tesseract", str(image), "stdout", "-l", "eng+chi_sim"], + capture_output=True, + text=True, + check=False, + timeout=120, + ) + if completed.returncode != 0: + return { + "image_path": str(image), + "text": completed.stdout, + "backend": "tesseract", + "status": "blocked", + "reason": "tesseract_run_failed", + "stderr": completed.stderr.strip(), + } + return {"image_path": str(image), "text": completed.stdout, "backend": "tesseract", "status": "ok"} + + +def extract_one(image: Path, *, backend: str = "auto", transcript_dir: Path | None = None, shortcut_name: str = DEFAULT_SHORTCUT_NAME) -> dict[str, Any]: + selected = choose_backend(backend) + if selected == "manual": + return _extract_manual(image, transcript_dir) + if selected == "shortcuts": + return _extract_shortcuts(image, shortcut_name) + if selected == "tesseract": + return _extract_tesseract(image) + raise ValueError(f"unsupported backend: {selected}") + + +def extract_many( + images: list[Path], + *, + output: Path | None = None, + backend: str = "auto", + transcript_dir: Path | None = None, + shortcut_name: str = DEFAULT_SHORTCUT_NAME, +) -> dict[str, Any]: + items = [extract_one(image, backend=backend, transcript_dir=transcript_dir, shortcut_name=shortcut_name) for image in images] + if output: + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text("\n".join(json.dumps(item, ensure_ascii=False, sort_keys=True) for item in items) + "\n", encoding="utf-8") + return { + "status": "ok" if items and all(item["status"] == "ok" for item in items) else "blocked", + "backend": choose_backend(backend), + "items": items, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("images", nargs="+", type=Path) + parser.add_argument("--backend", choices=sorted(VALID_BACKENDS), default="auto") + parser.add_argument("--transcript-dir", type=Path) + parser.add_argument("--shortcut-name", default=DEFAULT_SHORTCUT_NAME) + parser.add_argument("--output", type=Path, default=Path("scratch/local/ocr_extract/ocr.jsonl")) + args = parser.parse_args(argv) + report = extract_many( + args.images, + output=args.output, + backend=args.backend, + transcript_dir=args.transcript_dir, + shortcut_name=args.shortcut_name, + ) + print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) + return 0 if report["status"] == "ok" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prashna_context.py b/scripts/prashna_context.py new file mode 100644 index 00000000..38a58caf --- /dev/null +++ b/scripts/prashna_context.py @@ -0,0 +1,105 @@ +"""Production Prashna chart context: question moment only, Swiss backend only.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +try: + from scripts.domain_calculation_service import CalculationError, compute_chart +except ModuleNotFoundError: # pragma: no cover - CLI execution path + from domain_calculation_service import CalculationError, compute_chart +try: + from scripts.gulika import calculate_gulika +except ModuleNotFoundError: # pragma: no cover - CLI execution path + from gulika import calculate_gulika +try: + from scripts.prashna_sphuta import calculate_sphuta_evidence +except ModuleNotFoundError: # pragma: no cover - CLI execution path + from prashna_sphuta import calculate_sphuta_evidence + + +class PrashnaContextError(ValueError): + pass + + +def _timezone_offset(value: Any, moment: datetime) -> float: + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + raw = value.strip().upper().replace("UTC", "") + try: + return float(raw) + except ValueError: + pass + if moment.tzinfo is not None: + offset = moment.utcoffset() + if offset is not None: + return offset.total_seconds() / 3600 + raise PrashnaContextError("timezone must be a numeric UTC offset or present in question_timestamp") + + +def build_prashna_context(payload: dict[str, Any]) -> dict[str, Any]: + required = ("question_text", "question_timestamp", "lat", "lon", "timezone") + missing = [field for field in required if payload.get(field) in (None, "")] + if missing: + raise PrashnaContextError(f"missing required Prashna fields: {', '.join(missing)}") + if str(payload.get("location_convention") or "wgs84").lower() != "wgs84": + raise PrashnaContextError("location_convention must be wgs84") + try: + moment = datetime.fromisoformat(str(payload["question_timestamp"]).replace("Z", "+00:00")) + except ValueError as exc: + raise PrashnaContextError("question_timestamp must be ISO-8601") from exc + tz = _timezone_offset(payload["timezone"], moment) + if moment.tzinfo is not None: + timestamp_tz = moment.utcoffset().total_seconds() / 3600 + if abs(timestamp_tz - tz) > 0.001: + raise PrashnaContextError("timezone conflicts with question_timestamp offset") + moment = moment.replace(tzinfo=None) + try: + chart = compute_chart({ + "year": moment.year, "month": moment.month, "day": moment.day, + "hour": moment.hour, "minute": moment.minute, "second": moment.second, + "lat": float(payload["lat"]), "lon": float(payload["lon"]), "tz": tz, + "ayanamsa": str(payload.get("ayanamsa") or "lahiri"), + "node_mode": str(payload.get("node_mode") or "mean"), + }) + except (CalculationError, ValueError, TypeError) as exc: + raise PrashnaContextError(f"Swiss Prashna chart blocked: {exc}") from exc + try: + gulika = calculate_gulika(moment, lat=float(payload["lat"]), lon=float(payload["lon"]), tz=tz) + except Exception as exc: + gulika = { + "status": "blocked", + "reason": f"gulika_supporting_indicator_failed:{type(exc).__name__}", + } + if gulika.get("status") == "partial": + longitudes = { + name: item.get("degree_raw", item.get("lon")) + for name, item in chart["planets"].items() + if isinstance(item, dict) + } + sphuta = calculate_sphuta_evidence( + ascendant_longitude=chart["ascendant"].get("degree_raw", chart["ascendant"].get("lon")), + planet_longitudes=longitudes, + gulika_longitude=gulika["longitude"], + ) + else: + sphuta = {"status": "blocked", "reason": "gulika_supporting_indicator_unavailable"} + return { + "scope": "prashna_context", + "status": "computed", + "question_text": str(payload["question_text"])[:500], + "question_timestamp": str(payload["question_timestamp"]), + "location": {"lat": float(payload["lat"]), "lon": float(payload["lon"]), "timezone": tz, "location_convention": "wgs84"}, + "ayanamsa": str(payload.get("ayanamsa") or "lahiri"), + "node_mode": str(payload.get("node_mode") or "mean"), + "chart_source": "swiss_ephemeris_backend", + "ascendant": chart["ascendant"], + "planets": chart["planets"], + "calculation_contract": chart["calculation_contract"], + "result_hash": chart["result_hash"], + "supporting_indicators": {"gulika": gulika, "sphuta": sphuta}, + "blocked_layers": ["Kunda", "Prashna verdict"], + "boundary": "No client-supplied planets or ascendant are accepted. Gulika and formula-only Sphuta are supporting-only pending external numeric parity; verdict layers remain blocked.", + } diff --git a/scripts/prashna_sphuta.py b/scripts/prashna_sphuta.py new file mode 100644 index 00000000..e7c1f552 --- /dev/null +++ b/scripts/prashna_sphuta.py @@ -0,0 +1,44 @@ +"""Formula-only Prasna Marga Sphuta evidence, without verdict interpretation.""" +from __future__ import annotations + +from typing import Any + + +def _norm(value: float) -> float: + return float(value) % 360.0 + + +def calculate_sphuta_evidence( + *, + ascendant_longitude: float, + planet_longitudes: dict[str, Any], + gulika_longitude: float, +) -> dict[str, Any]: + required = ("Sun", "Moon", "Rahu") + missing = [name for name in required if name not in planet_longitudes] + if missing: + return {"status": "blocked", "reason": "missing_sphuta_planets", "missing": missing} + asc = _norm(ascendant_longitude) + moon = _norm(planet_longitudes["Moon"]) + sun = _norm(planet_longitudes["Sun"]) + rahu = _norm(planet_longitudes["Rahu"]) + gulika = _norm(gulika_longitude) + trisphuta = _norm(asc + moon + gulika) + catusphuta = _norm(trisphuta + sun) + pancasphuta = _norm(catusphuta + rahu) + return { + "scope": "prasna_marga_sphuta_evidence", + "status": "partial", + "points": { + "trisphuta": trisphuta, + "catusphuta": catusphuta, + "pancasphuta": pancasphuta, + }, + "formula_trace": { + "trisphuta": "Lagna + Moon + Gulika", + "catusphuta": "Trisphuta + Sun", + "pancasphuta": "Catusphuta + Rahu", + }, + "rule_source": "references/prashna-complete-guide.md#3.2-3.3", + "boundary": "Formula-only supporting evidence. No health, event, or Prashna verdict is permitted without external numeric parity and adjudication rules.", + } diff --git a/scripts/public_real_case_benchmark.py b/scripts/public_real_case_benchmark.py new file mode 100644 index 00000000..ad272f44 --- /dev/null +++ b/scripts/public_real_case_benchmark.py @@ -0,0 +1,538 @@ +#!/usr/bin/env python3 +"""Replay research-grade public events through the local Jyotish evidence stack.""" + +from __future__ import annotations + +import argparse +import copy +import json +import subprocess +import sys +from datetime import date +from pathlib import Path +from typing import Any + +from scripts.functional_benefics import derive_functional_benefic_malefic +from scripts.narayana_dasha import narayana_dasha_full_report + + +ROOT = Path(__file__).resolve().parents[1] +ENGINE = ROOT / "scripts" / "jyotish_engine.py" +SIGNS = [ + "Aries", "Taurus", "Gemini", "Cancer", "Leo", "Virgo", + "Libra", "Scorpio", "Sagittarius", "Capricorn", "Aquarius", "Pisces", +] +EVENT_HOUSES = {"career": [10, 6, 9, 11], "marriage": [7, 2, 11, 5]} +EVENT_KARAKAS = {"career": {"Sun", "Saturn", "Mercury"}, "marriage": {"Venus", "Jupiter"}} +PRIMARY_HOUSE = {"career": 10, "marriage": 7} +EXPECTED_LABEL = {"career": "career_status", "marriage": "legal_marriage"} +SIGN_LORDS = { + "Aries": "Mars", "Taurus": "Venus", "Gemini": "Mercury", "Cancer": "Moon", + "Leo": "Sun", "Virgo": "Mercury", "Libra": "Venus", "Scorpio": "Mars", + "Sagittarius": "Jupiter", "Capricorn": "Saturn", "Aquarius": "Saturn", "Pisces": "Jupiter", +} +_ENGINE_JSON_CACHE: dict[str, dict[str, Any]] = {} + + +def clear_engine_cache() -> None: + _ENGINE_JSON_CACHE.clear() + + +def summarize_results(rows: list[dict[str, Any]]) -> dict[str, Any]: + total = len(rows) + blocked = sum(bool(row.get("blocked")) for row in rows) + evaluated = total - blocked + hits = sum(row.get("result_class") in {"strong_hit", "weak_hit"} for row in rows if not row.get("blocked")) + exact = sum(bool(row.get("matched_expected_label")) for row in rows if not row.get("blocked")) + activation_rate = hits / evaluated if evaluated else None + strong_rate = exact / evaluated if evaluated else None + return { + "total_events": total, + "evaluated_events": evaluated, + "strong_hits": sum(row.get("result_class") == "strong_hit" for row in rows), + "weak_hits": sum(row.get("result_class") == "weak_hit" for row in rows), + "misses": sum(row.get("result_class") == "miss" for row in rows), + "blocked_events": blocked, + "known_event_activation_rate": activation_rate, + "strong_activation_rate": strong_rate, + "positive_event_recall": activation_rate, + "positive_event_recall_deprecated": True, + "exact_label_rate": strong_rate, + "exact_label_rate_deprecated": True, + "blocked_rate": blocked / total if total else None, + "balanced_accuracy": None, + "balanced_accuracy_blocked_reason": "no_verified_negative_control_dates", + } + + +def promotion_decision(v1: dict[str, Any], v2: dict[str, Any]) -> dict[str, Any]: + if int(v2.get("blocked_events") or 0) > int(v1.get("blocked_events") or 0): + return {"promote": False, "reason": "v2_increased_blocked_events"} + recall1 = v1.get("positive_event_recall") + recall2 = v2.get("positive_event_recall") + exact1 = v1.get("exact_label_rate") + exact2 = v2.get("exact_label_rate") + if None in {recall1, recall2, exact1, exact2}: + return {"promote": False, "reason": "comparison_metric_missing"} + improved = recall2 >= recall1 and exact2 >= exact1 and (recall2 > recall1 or exact2 > exact1) + return {"promote": improved, "reason": "holdout_metrics_improved" if improved else "no_holdout_improvement"} + + +def compare_reports(v1: dict[str, Any], v2: dict[str, Any]) -> dict[str, Any]: + """Compare frozen rule versions without reinterpreting holdout outcomes.""" + v1_cases = {row["case_id"]: row for row in v1.get("cases") or []} + v2_cases = {row["case_id"]: row for row in v2.get("cases") or []} + deltas = [] + for case_id in sorted(v1_cases.keys() & v2_cases.keys()): + before = v1_cases[case_id] + after = v2_cases[case_id] + before_signals = set(before.get("signals") or []) + deltas.append({ + "case_id": case_id, + "v1_score": before.get("score"), + "v2_score": after.get("score"), + "score_delta": (after.get("score") or 0) - (before.get("score") or 0), + "v1_result_class": before.get("result_class"), + "v2_result_class": after.get("result_class"), + "added_signals": sorted(set(after.get("signals") or []) - before_signals), + }) + return { + "benchmark_id": "public_real_case_holdout_comparison_2026_07_11", + "boundary": "Blind positive-event holdout comparison; no negative controls and no scientific accuracy claim.", + "v1_summary": v1.get("summary") or {}, + "v2_summary": v2.get("summary") or {}, + "promotion": promotion_decision(v1.get("summary") or {}, v2.get("summary") or {}), + "case_deltas": deltas, + } + + +def combine_reports(reports: list[dict[str, Any]], promotion: dict[str, Any]) -> dict[str, Any]: + rows = [row for report in reports for row in report.get("cases") or []] + return { + "benchmark_id": "public_real_case_20_case_closure_2026_07_11", + "rule_version": "v2", + "method": { + "cohorts": ["batch1_discovery_10", "frozen_holdout_10"], + "selection": "Rodden A/AA public figures with independently dated public career or legal-marriage events", + "score_thresholds": {"strong_hit": ">=7", "weak_hit": "4-6", "miss": "<4"}, + }, + "summary": summarize_results(rows), + "domain_summaries": { + domain: summarize_results([row for row in rows if row.get("domain") == domain]) + for domain in ("career", "marriage") + }, + "holdout_promotion": promotion, + "boundary": "Twenty positive public events; no negative controls, specificity estimate, or scientific accuracy claim.", + "technique_audit": [ + {"technique": "D1 + Functional Benefic/Malefic", "status": "used", "scope": "20/20"}, + {"technique": "D9/UL/Darakaraka", "status": "used", "scope": "10 marriage events"}, + {"technique": "D10/A10/Amatyakaraka", "status": "used", "scope": "10 career events"}, + {"technique": "Vimshottari MD/AD", "status": "used", "scope": "20/20"}, + {"technique": "Narayana Dasha", "status": "used", "scope": "20/20"}, + {"technique": "Double Transit PAC", "status": "used", "scope": "20/20"}, + {"technique": "Rahu/Ketu dispositor", "status": "used", "scope": "v2 scoring"}, + {"technique": "Vimshottari PD/PrAD", "status": "partial", "reason": "ratio expansion available but not externally validated or scored"}, + {"technique": "Tajika/Varshaphala/Muntha", "status": "partial", "reason": "local annual layer remains simplified and external oracle closure is incomplete"}, + {"technique": "KP exact cusp/significators", "status": "partial", "reason": "current local KP house layer uses sign-center approximation rather than exact cusps"}, + {"technique": "VedAstro official raw", "status": "blocked", "reason": "official_snapshot_budget_exhausted"}, + {"technique": "PyJHora/JHora/jyotishganit parity", "status": "blocked", "reason": "external canonical raw comparison incomplete"}, + {"technique": "MEVG / Global Web Evidence", "status": "used", "scope": "20 public birth/event source pairs"}, + {"technique": "Real Case Calibration", "status": "used", "scope": "10 discovery + 10 frozen holdout"}, + {"technique": "Negative controls", "status": "blocked", "reason": "no verified non-event dates"}, + ], + "technique_debt": { + "vimshottari_pd_prad": "available_ratio_expansion_not_scored_or_externally_validated", + "tajika_varshaphala_muntha": "available_experimental_not_scored_due_simplified_year_lord_and_oracle_gap", + "kp_cusp_significators": "partial_not_scored_house_centers_are_not_precise_cusps", + "annual_transit_to_arudha_or_ul": "untested_candidate_layer", + "negative_control_dates": "missing_blocks_balanced_accuracy", + }, + "cases": rows, + } + + +def node_dispositor_bonus( + active_lords: set[str], + domain: str, + chart: dict[str, Any], + roles: dict[str, Any], +) -> tuple[int, list[str]]: + event_houses = set(EVENT_HOUSES[domain]) + score = 0 + signals: list[str] = [] + planets = chart.get("planets") or {} + for node in sorted(active_lords & {"Rahu", "Ketu"}): + node_sign = (planets.get(node) or {}).get("sign") + dispositor = SIGN_LORDS.get(node_sign) + if not dispositor: + continue + if set((roles.get("owned_houses") or {}).get(dispositor) or []) & event_houses: + score += 1 + signals.append(f"{node}_dispositor_{dispositor}_owns_event_house") + occupied = (planets.get(dispositor) or {}).get("house") + if occupied in event_houses: + score += 1 + signals.append(f"{node}_dispositor_{dispositor}_occupies_event_house:{occupied}") + return score, signals + + +def _house_from_sign(ascendant: str, target: str) -> int | None: + if ascendant not in SIGNS or target not in SIGNS: + return None + return (SIGNS.index(target) - SIGNS.index(ascendant)) % 12 + 1 + + +def varga_and_karaka_bonus( + active_lords: set[str], + domain: str, + varga: dict[str, Any], + jaimini: dict[str, Any], +) -> tuple[int, list[str]]: + chart_key = "D10_Dasamsa" if domain == "career" else "D9_Navamsa" + chart = ((varga.get("divisional_charts") or {}).get(chart_key) or {}) + ascendant = chart.get("ascendant") + primary_house = PRIMARY_HOUSE[domain] + primary_sign = SIGNS[(SIGNS.index(ascendant) + primary_house - 1) % 12] if ascendant in SIGNS else None + primary_lord = SIGN_LORDS.get(primary_sign) + lagna_lord = SIGN_LORDS.get(ascendant) + score = 0 + signals: list[str] = [] + label = "D10" if domain == "career" else "D9" + for lord in sorted(active_lords): + if lord == lagna_lord: + score += 1 + signals.append(f"active_dasha_matches_{label}_Lagna_lord:{lord}") + if lord == primary_lord: + score += 1 + signals.append(f"active_dasha_matches_{label}_{primary_house}L:{lord}") + lord_sign = (chart.get(lord) or {}).get("sign") + if _house_from_sign(ascendant, lord_sign) == primary_house: + score += 1 + signals.append(f"active_dasha_occupies_{label}_house_{primary_house}:{lord}") + karaka_name = "Amatyakaraka" if domain == "career" else "Darakaraka" + karaka_planet = ((((jaimini.get("chara_karaka_7") or {}).get("karaka_table") or {}).get(karaka_name) or {}).get("planet")) + if karaka_planet in active_lords: + score += 1 + signals.append(f"active_dasha_matches_{karaka_name}:{karaka_planet}") + return score, signals + + +def _engine_json(command: str, subject: dict[str, Any], *extra: str, timeout: int = 30) -> dict[str, Any]: + cache_key = json.dumps( + {"command": command, "subject": subject, "extra": extra}, + sort_keys=True, + ensure_ascii=True, + default=str, + ) + if cache_key in _ENGINE_JSON_CACHE: + return copy.deepcopy(_ENGINE_JSON_CACHE[cache_key]) + args = [ + sys.executable, str(ENGINE), command, + "--year", str(subject["year"]), "--month", str(subject["month"]), + "--day", str(subject["day"]), "--hour", str(subject["hour"]), + "--minute", str(subject["minute"]), "--lat", str(subject["lat"]), + "--lon", str(subject["lon"]), "--tz", str(subject["tz"]), + "--node-mode", str(subject.get("node_mode", "mean")), + *extra, + ] + completed = subprocess.run(args, cwd=ROOT, check=True, capture_output=True, text=True, timeout=timeout) + payload = json.loads(completed.stdout) + _ENGINE_JSON_CACHE[cache_key] = payload + return copy.deepcopy(payload) + + +def _find_dasha(dasha: dict[str, Any], event_date: str) -> tuple[str | None, str | None]: + target = date.fromisoformat(event_date) + for md in dasha.get("timeline") or []: + if date.fromisoformat(md["start"][:10]) <= target < date.fromisoformat(md["end"][:10]): + for ad in md.get("antardasha_timeline") or []: + if date.fromisoformat(ad["start"][:10]) <= target < date.fromisoformat(ad["end"][:10]): + return md.get("lord"), ad.get("lord") + return md.get("lord"), None + return None, None + + +def _planet_score(planet: str | None, event_houses: set[int], chart: dict[str, Any], roles: dict[str, Any], karakas: set[str]) -> tuple[int, list[str]]: + if not planet: + return 0, [] + score = 0 + signals: list[str] = [] + owned = set((roles.get("owned_houses") or {}).get(planet) or []) + occupied = (chart.get("planets") or {}).get(planet, {}).get("house") + owned_hits = sorted(owned & event_houses) + if owned_hits: + score += 2 + signals.append(f"{planet}_owns_event_houses:{owned_hits}") + if occupied in event_houses: + score += 1 + signals.append(f"{planet}_occupies_event_house:{occupied}") + if planet in karakas: + score += 1 + signals.append(f"{planet}_domain_karaka") + return score, signals + + +def score_active_dasha_lords( + lords: list[str | None], + event_houses: set[int], + chart: dict[str, Any], + roles: dict[str, Any], + karakas: set[str], +) -> tuple[int, list[str]]: + score = 0 + signals: list[str] = [] + for lord in dict.fromkeys(lord for lord in lords if lord): + points, lord_signals = _planet_score(lord, event_houses, chart, roles, karakas) + score += points + signals.extend(lord_signals) + return score, signals + + +def _transit_json(event_date: str, subject: dict[str, Any]) -> dict[str, Any]: + target = date.fromisoformat(event_date) + command = [ + sys.executable, str(ENGINE), "transit", + "--year", str(target.year), "--month", str(target.month), "--day", str(target.day), + "--planet", "Jupiter,Saturn", "--tz", str(subject["tz"]), + "--node-mode", str(subject.get("node_mode", "mean")), + ] + completed = subprocess.run(command, cwd=ROOT, check=True, capture_output=True, text=True, timeout=30) + return json.loads(completed.stdout) + + +def ashtakavarga_audit(domain: str, packet: dict[str, Any], transit: dict[str, Any]) -> dict[str, Any]: + event_houses = EVENT_HOUSES[domain] + sav = packet.get("sav") or {} + bav = packet.get("bav") or {} + event_house_sav = { + str(house): (packet.get("house_scores") or {}).get(f"house_{house}") + for house in event_houses + } + transit_support = {} + for planet in ("Jupiter", "Saturn"): + sign = ((transit.get("planets") or {}).get(planet) or {}).get("sign") + sign_index = SIGNS.index(sign) if sign in SIGNS else None + bindus = ((bav.get(planet) or {}).get("bindus") or []) + transit_support[planet] = { + "sign": sign, + "sav": (sav.get("scores") or {}).get(sign), + "bav": bindus[sign_index] if sign_index is not None and sign_index < len(bindus) else None, + } + return { + "status": "used_non_scoring", + "scoring_effect": 0, + "method": packet.get("method"), + "version": packet.get("version"), + "sav_total": sav.get("total"), + "sav_valid": sav.get("valid"), + "all_bav_valid": packet.get("all_bav_valid"), + "event_house_sav": event_house_sav, + "transit_support": transit_support, + "settings": { + "ayanamsa": transit.get("ayanamsa"), + "node_mode": transit.get("node_mode"), + }, + "boundary": "Audit evidence only. SAV/BAV does not change V2.1 event scores until a fresh holdout validates it.", + } + + +def _narayana_at_event(subject: dict[str, Any], chart: dict[str, Any], event_date: str) -> dict[str, Any]: + asc_sign = chart["ascendant"]["sign"] + asc_idx = SIGNS.index(asc_sign) + planet_lons = {name: data["degree"] for name, data in chart["planets"].items() if "degree" in data} + born = date(subject["year"], subject["month"], subject["day"]) + target = date.fromisoformat(event_date) + age = (target - born).days / 365.2425 + report = narayana_dasha_full_report(asc_idx, planet_lons, current_age=age, birth_year=subject["year"]) + return report.get("current_dasha") or {} + + +def _arudha_lord(jaimini: dict[str, Any], domain: str) -> str | None: + arudha = jaimini.get("arudha_padas") or {} + if domain == "career": + return ((arudha.get("padas") or {}).get("A10") or {}).get("lord") + return (arudha.get("upapada") or {}).get("lord") + + +def _double_transit_score(packet: dict[str, Any]) -> tuple[int, list[str]]: + strengths = [row.get("strength") for row in packet.get("double_transit") or []] + if "strong" in strengths: + return 2, ["double_transit_pac_strong"] + if strengths: + return 1, ["double_transit_pac_present"] + return 0, [] + + +def replay_case(case: dict[str, Any], rule_version: str = "v1") -> dict[str, Any]: + subject = case["subject"] + event = case["event_outcomes"][0] + domain = event["domain"] + event_houses = set(EVENT_HOUSES[domain]) + try: + chart = _engine_json("chart", subject) + dasha = _engine_json("dasha", subject, "--years", "100") + varga = _engine_json("varga", subject, "--d10" if domain == "career" else "--d9") + jaimini = _engine_json("jaimini", subject) + pac = _engine_json( + "double-transit-pac", subject, + "--date", event["event_date"], "--house", str(PRIMARY_HOUSE[domain]), + ) + except (subprocess.SubprocessError, json.JSONDecodeError, KeyError, ValueError) as exc: + return { + "case_id": case["case_id"], "name": subject["name"], "domain": domain, + "event_date": event["event_date"], "blocked": True, "result_class": "blocked", + "matched_expected_label": False, "blocked_reason": f"{type(exc).__name__}: {exc}", + } + + roles = derive_functional_benefic_malefic(chart["ascendant"]["sign"]) + md, ad = _find_dasha(dasha, event["event_date"]) + score = 0 + signals: list[str] = [] + if rule_version == "v2_1": + score, signals = score_active_dasha_lords([md, ad], event_houses, chart, roles, EVENT_KARAKAS[domain]) + else: + for lord in (md, ad): + points, lord_signals = _planet_score(lord, event_houses, chart, roles, EVENT_KARAKAS[domain]) + score += points + signals.extend(lord_signals) + + active_lords = {lord for lord in (md, ad) if lord} + if rule_version in {"v2", "v2_1"}: + node_points, node_signals = node_dispositor_bonus(active_lords, domain, chart, roles) + varga_points, varga_signals = varga_and_karaka_bonus(active_lords, domain, varga, jaimini) + score += node_points + varga_points + signals.extend(node_signals) + signals.extend(varga_signals) + + arudha_lord = _arudha_lord(jaimini, domain) + if arudha_lord in {md, ad}: + score += 1 + signals.append(f"active_dasha_matches_{'A10' if domain == 'career' else 'UL'}_lord:{arudha_lord}") + + narayana = _narayana_at_event(subject, chart, event["event_date"]) + narayana_md = narayana.get("md") or {} + event_sign = SIGNS[(SIGNS.index(chart["ascendant"]["sign"]) + PRIMARY_HOUSE[domain] - 1) % 12] + if narayana_md.get("sign") == event_sign: + score += 2 + signals.append(f"narayana_activates_primary_event_sign:{event_sign}") + narayana_lord = narayana_md.get("lord") + if set((roles.get("owned_houses") or {}).get(narayana_lord) or []) & event_houses: + score += 1 + signals.append(f"narayana_lord_owns_event_house:{narayana_lord}") + + pac_points, pac_signals = _double_transit_score(pac) + score += pac_points + signals.extend(pac_signals) + + ashtakavarga = {"status": "not_run", "scoring_effect": 0} + if rule_version == "v2_1": + try: + ashtakavarga_packet = _engine_json("ashtakavarga", subject) + transit_packet = _transit_json(event["event_date"], subject) + ashtakavarga = ashtakavarga_audit(domain, ashtakavarga_packet, transit_packet) + except (subprocess.SubprocessError, json.JSONDecodeError, KeyError, ValueError) as exc: + ashtakavarga = { + "status": "blocked", + "scoring_effect": 0, + "blocked_reason": f"{type(exc).__name__}: {exc}", + } + + if score >= 7: + result_class = "strong_hit" + actual_label = EXPECTED_LABEL[domain] + elif score >= 4: + result_class = "weak_hit" + actual_label = "domain_activation" + else: + result_class = "miss" + actual_label = None + + return { + "case_id": case["case_id"], + "name": subject["name"], + "domain": domain, + "event_date": event["event_date"], + "outcome": event["outcome"], + "birth_time_rating": subject["birth_source"]["time_accuracy_rating"], + "rule_version": rule_version, + "blocked": False, + "result_class": result_class, + "score": score, + "expected_label": EXPECTED_LABEL[domain], + "actual_label": actual_label, + "matched_expected_label": actual_label == EXPECTED_LABEL[domain], + "signals": signals, + "evidence": { + "ascendant": chart["ascendant"], + "vimshottari": {"mahadasha": md, "antardasha": ad}, + "narayana": narayana, + "functional_benefic_malefic": roles, + "domain_varga": varga, + "arudha_lord": arudha_lord, + "double_transit_pac": pac, + "ashtakavarga_audit": ashtakavarga, + "birth_source": subject["birth_source"], + "event_source": event["source"], + }, + } + + +def build_report( + manifest: dict[str, Any], + strict_probe_blocked_reason: str | None = None, + rule_version: str = "v1", +) -> dict[str, Any]: + rows = [replay_case(case, rule_version=rule_version) for case in manifest.get("cases") or []] + return { + "benchmark_id": "public_real_case_benchmark_2026_07_11", + "rule_version": rule_version, + "method": { + "selection": "Rodden A/AA public figures with independently dated public events", + "pre_registered_layers": ["D1", "D9_or_D10", "UL_or_A10", "Functional Benefic/Malefic", "Vimshottari MD/AD", "Narayana Dasha", "Double Transit PAC"] + (["Rahu/Ketu dispositor", "D9/D10 Lagna and primary-house lord", "Amatyakaraka/Darakaraka"] if rule_version in {"v2", "v2_1"} else []) + (["SAV/BAV non-scoring audit", "deduplicated MD/AD lord scoring"] if rule_version == "v2_1" else []), + "score_thresholds": {"strong_hit": ">=7", "weak_hit": "4-6", "miss": "<4"}, + "boundary": "Positive-event technical activation replay; not scientific predictive accuracy.", + }, + "summary": summarize_results(rows), + "strict_workflow_batch": { + "status": "blocked" if strict_probe_blocked_reason else "not_run", + "blocked_reason": strict_probe_blocked_reason, + }, + "external_oracle_boundary": { + "VedAstro": "diagnostic_only_unless_official_raw_present", + "PyJHora": "blocked_or_benchmark_only_until_dependency_available", + "JHora": "manual_oracle_not_automated", + "jyotishganit": "parity_contract_separate_from_this_event_replay", + }, + "cases": rows, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", default="references/real_case_calibration/replay_manifest.json") + parser.add_argument("--output") + parser.add_argument("--strict-probe-blocked-reason") + parser.add_argument("--rule-version", choices=["v1", "v2", "v2_1", "compare"], default="v1") + parser.add_argument("--comparison-v1") + parser.add_argument("--comparison-v2") + args = parser.parse_args() + manifest = json.loads((ROOT / args.manifest).read_text(encoding="utf-8")) + if args.rule_version == "compare": + if not args.comparison_v1 or not args.comparison_v2: + parser.error("compare requires --comparison-v1 and --comparison-v2 to avoid duplicate engine replay") + v1 = json.loads((ROOT / args.comparison_v1).read_text(encoding="utf-8")) + v2 = json.loads((ROOT / args.comparison_v2).read_text(encoding="utf-8")) + report = compare_reports(v1, v2) + else: + report = build_report(manifest, args.strict_probe_blocked_reason, rule_version=args.rule_version) + payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + if args.output: + output_path = ROOT / args.output + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(payload, encoding="utf-8") + print(payload, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/public_real_case_negative_controls.py b/scripts/public_real_case_negative_controls.py new file mode 100644 index 00000000..dd1e0b00 --- /dev/null +++ b/scripts/public_real_case_negative_controls.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Rank known public event dates against deterministic non-target control dates.""" + +from __future__ import annotations + +import argparse +import copy +import json +import statistics +from datetime import date, timedelta +from pathlib import Path +from typing import Any, Iterable + +from scripts.public_real_case_benchmark import clear_engine_cache, replay_case + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OFFSETS = (-120, -90, -60, -30, 30, 60, 90, 120) + + +def generate_control_dates(event_date: str, offsets: Iterable[int] = DEFAULT_OFFSETS) -> list[str]: + target = date.fromisoformat(event_date) + return [(target + timedelta(days=int(offset))).isoformat() for offset in offsets if int(offset) != 0] + + +def rank_positive_against_controls(positive_score: int, control_scores: list[int]) -> dict[str, Any]: + rank = 1 + sum(score >= positive_score for score in control_scores) + max_control = max(control_scores) if control_scores else None + return { + "positive_score": positive_score, + "positive_rank": rank, + "candidate_count": len(control_scores) + 1, + "reciprocal_rank": 1 / rank, + "top_1": rank == 1, + "top_3": rank <= 3, + "max_control_score": max_control, + "score_margin": positive_score - max_control if max_control is not None else None, + } + + +def summarize_negative_control_rows(rows: list[dict[str, Any]]) -> dict[str, Any]: + controls = [control for row in rows for control in row.get("controls") or [] if not control.get("blocked")] + rankings = [row["ranking"] for row in rows if row.get("ranking")] + margins = [item["score_margin"] for item in rankings if item["score_margin"] is not None] + return { + "case_count": len(rows), + "ranked_case_count": len(rankings), + "control_date_count": len(controls), + "control_activation_rate": sum((control.get("score") or 0) >= 4 for control in controls) / len(controls) if controls else None, + "control_strong_activation_rate": sum((control.get("score") or 0) >= 7 for control in controls) / len(controls) if controls else None, + "positive_top_1_rate": sum(item["top_1"] for item in rankings) / len(rankings) if rankings else None, + "positive_top_3_rate": sum(item["top_3"] for item in rankings) / len(rankings) if rankings else None, + "mean_reciprocal_rank": statistics.mean(item["reciprocal_rank"] for item in rankings) if rankings else None, + "mean_score_margin": statistics.mean(margins) if margins else None, + "balanced_accuracy": None, + "balanced_accuracy_blocked_reason": "controls_are_non_target_dates_not_independently_adjudicated_all-domain_non_events", + } + + +def build_report(manifest: dict[str, Any], offsets: Iterable[int] = DEFAULT_OFFSETS) -> dict[str, Any]: + clear_engine_cache() + rows = [] + for case in manifest.get("cases") or []: + event = case["event_outcomes"][0] + positive = replay_case(case, rule_version="v2_1") + controls = [] + for control_date in generate_control_dates(event["event_date"], offsets): + control_case = copy.deepcopy(case) + control_event = control_case["event_outcomes"][0] + control_event["event_date"] = control_date + control_event["outcome"] = f"non_target_control_date_for:{event['outcome']}" + result = replay_case(control_case, rule_version="v2_1") + controls.append({ + "date": control_date, + "score": result.get("score"), + "result_class": result.get("result_class"), + "blocked": bool(result.get("blocked")), + "blocked_reason": result.get("blocked_reason"), + }) + control_scores = [int(item["score"]) for item in controls if not item["blocked"] and item.get("score") is not None] + ranking = None + if not positive.get("blocked") and positive.get("score") is not None: + ranking = rank_positive_against_controls(int(positive["score"]), control_scores) + rows.append({ + "case_id": case["case_id"], + "name": case["subject"]["name"], + "domain": event["domain"], + "positive_date": event["event_date"], + "positive": positive, + "controls": controls, + "ranking": ranking, + }) + return { + "benchmark_id": "public_real_case_negative_control_pilot_2026_07_11", + "rule_version": "v2_1", + "control_offsets_days": list(offsets), + "summary": summarize_negative_control_rows(rows), + "boundary": ( + "Controls are dates without the exact recorded target outcome. They may contain other life events. " + "This pilot measures date ranking and false domain activation, not scientific causal validity." + ), + "cases": rows, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", default="references/real_case_calibration/replay_manifest_probe3_v2.json") + parser.add_argument("--output") + args = parser.parse_args() + manifest = json.loads((ROOT / args.manifest).read_text(encoding="utf-8")) + report = build_report(manifest) + payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n" + if args.output: + output_path = ROOT / args.output + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(payload, encoding="utf-8") + print(payload, end="") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/pyjhora_parity_summary.py b/scripts/pyjhora_parity_summary.py new file mode 100644 index 00000000..bb40ea76 --- /dev/null +++ b/scripts/pyjhora_parity_summary.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Summarize reviewable PyJHora comparison matrices without overstating coverage.""" + +from __future__ import annotations + +import argparse +import csv +import json +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + + +REQUIRED_FULL_PARITY = ("D1", "D9", "D10", "D2", "D4", "Vimshottari", "Shadbala", "Ashtakavarga") +MATRIX_SECTION_MAP = {"ascendant": "D1", "planet": "D1", "dasha": "Vimshottari", "D9": "D9", "D10": "D10"} + + +def summarize_matrix(path: str | Path, *, settings: dict[str, Any]) -> dict[str, Any]: + path = Path(path) + rows = list(csv.DictReader(path.open(encoding="utf-8"))) + status_counts = Counter(str(row.get("status") or "unknown") for row in rows) + sections: dict[str, dict[str, int]] = defaultdict(lambda: {"total": 0, "match": 0, "mismatch": 0}) + covered = set() + for row in rows: + section = MATRIX_SECTION_MAP.get(str(row.get("section") or "")) + if not section: + continue + covered.add(section) + sections[section]["total"] += 1 + if row.get("status") == "match": + sections[section]["match"] += 1 + elif row.get("status") == "mismatch": + sections[section]["mismatch"] += 1 + missing = [field for field in REQUIRED_FULL_PARITY if field not in covered] + return { + "scope": "pyjhora_same_chart_parity_summary", + "matrix_path": str(path), + "tested": bool(rows), + "settings": settings, + "row_counts": dict(status_counts), + "coverage": dict(sorted(sections.items())), + "covered_outputs": sorted(covered), + "missing_required_outputs": missing, + "status": "partial_verified" if rows and not status_counts.get("mismatch") else "partial_mismatch", + "full_parity_verified": not missing and not status_counts.get("mismatch"), + "boundary": "Only covered outputs are compared. This summary cannot promote full parity while required outputs are absent.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("matrix") + parser.add_argument("--ayanamsa", default="lahiri") + parser.add_argument("--node-mode", default="mean", choices=["mean", "true"]) + args = parser.parse_args() + print(json.dumps(summarize_matrix(args.matrix, settings={"ayanamsa": args.ayanamsa, "node_mode": args.node_mode}), ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/rangacharya.py b/scripts/rangacharya.py new file mode 100644 index 00000000..a8f32cba --- /dev/null +++ b/scripts/rangacharya.py @@ -0,0 +1,143 @@ +"""Experimental Rangacharya/Jaimini variant. + +All outputs are blocked from adjudication until formula-level validation passes. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Mapping + + +SIGNS = [ + "Aries", + "Taurus", + "Gemini", + "Cancer", + "Leo", + "Virgo", + "Libra", + "Scorpio", + "Sagittarius", + "Capricorn", + "Aquarius", + "Pisces", +] + +SOURCE_CARDS_PATH = Path(__file__).resolve().parent.parent / "references" / "rangacharya_source_cards.json" + + +class RangacharyaValidationError(RuntimeError): + pass + + +def _source_cards() -> Dict[str, Dict[str, Any]]: + try: + data = json.loads(SOURCE_CARDS_PATH.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return {} + return {str(card.get("id")): dict(card) for card in data.get("cards", []) if card.get("id")} + + +def _card_meta(card_id: str) -> Dict[str, Any]: + card = _source_cards().get(card_id, {}) + status = str(card.get("status") or "blocked") + meta = { + "source_card_id": card_id, + "source_card_status": status, + "validation_status": status, + "adjudication_enabled": False, + } + if status != "source_verified": + meta["blocked_reason"] = card.get("blocked_reason") or "source card is not verified for adjudication" + return meta + + +def _sign_name(index: int) -> str: + return SIGNS[index % 12] + + +def _placeholder_pada(label: str, asc_sign_idx: int, source_house: int) -> Dict[str, Any]: + sign_idx = (asc_sign_idx + source_house - 1) % 12 + return { + "label": label, + "sign": _sign_name(sign_idx), + "sign_index": sign_idx, + "source_house": source_house, + "note": "Rangacharya formula pending source-card implementation", + **_card_meta("rangacharya_core_arudha"), + } + + +def calc_rangacharya_variant(asc_sign_idx: int, planet_longitudes: Mapping[str, float]) -> Dict[str, Any]: + asc_sign_idx %= 12 + arudha_padas = { + "AL": _placeholder_pada("AL", asc_sign_idx, 1), + "A7": _placeholder_pada("A7", asc_sign_idx, 7), + "A10": _placeholder_pada("A10", asc_sign_idx, 10), + "UL": _placeholder_pada("UL", asc_sign_idx, 12), + } + return { + "variant": "rangacharya", + "status": "experimental_not_for_adjudication", + "adjudication_enabled": False, + "source_status": "transcribed", + "active_lagna": { + "sign": _sign_name(asc_sign_idx), + **_card_meta("active_effective_lagna"), + }, + "effective_lagna": { + "sign": _sign_name(asc_sign_idx), + **_card_meta("active_effective_lagna"), + }, + "arudha_padas": arudha_padas, + "input_planets_present": sorted(planet_longitudes), + } + + +def _flatten(prefix: str, value: Any) -> Dict[str, Any]: + if not isinstance(value, dict): + return {prefix: value} + rows: Dict[str, Any] = {} + for key, child in value.items(): + child_key = f"{prefix}.{key}" if prefix else str(key) + rows.update(_flatten(child_key, child)) + return rows + + +def diff_current_vs_rangacharya(current: Mapping[str, Any], variant: Mapping[str, Any]) -> Dict[str, Any]: + current_flat = _flatten("", dict(current)) + variant_flat = _flatten("", dict(variant.get("arudha_padas", variant))) + differences = [] + for key in sorted(set(current_flat) | set(variant_flat)): + current_value = current_flat.get(key) + variant_value = variant_flat.get(key) + if current_value != variant_value: + differences.append({"key": key, "current": current_value, "rangacharya": variant_value}) + return { + "current_algorithm": "current_jaimini", + "variant_algorithm": "rangacharya", + "adjudication_enabled": False, + "differences": differences, + } + + +def validation_summary(result: Mapping[str, Any]) -> Dict[str, Any]: + statuses = [] + for key, value in _flatten("", dict(result)).items(): + if key.endswith("validation_status"): + statuses.append(str(value)) + blocking = sorted({status for status in statuses if status != "adjudication_enabled"}) + return { + "adjudication_enabled": bool(result.get("adjudication_enabled")) and not blocking, + "blocking_statuses": blocking, + } + + +def assert_adjudication_allowed(result: Mapping[str, Any]) -> None: + summary = validation_summary(result) + if not summary["adjudication_enabled"]: + raise RangacharyaValidationError( + "Rangacharya variant is not adjudication-enabled; validation gates are incomplete" + ) diff --git a/scripts/rangacharya_readiness.py b/scripts/rangacharya_readiness.py new file mode 100644 index 00000000..874acced --- /dev/null +++ b/scripts/rangacharya_readiness.py @@ -0,0 +1,58 @@ +"""Readiness report for the experimental Rangacharya variant.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict + + +ROOT = Path(__file__).resolve().parent.parent +CARDS_PATH = ROOT / "references" / "rangacharya_source_cards.json" +MANIFEST_PATH = ROOT / "references" / "rangacharya_source_manifest.json" + + +def _load_json(path: Path) -> Dict[str, Any]: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def build_report() -> Dict[str, Any]: + cards_payload = _load_json(CARDS_PATH) + manifest_payload = _load_json(MANIFEST_PATH) + cards = {} + blocked = [] + transcribed = [] + for card in cards_payload.get("cards", []): + card_id = str(card.get("id") or "") + if not card_id: + continue + status = str(card.get("status") or "blocked") + adjudication_enabled = bool(card.get("adjudication_enabled")) + cards[card_id] = { + "status": status, + "adjudication_enabled": adjudication_enabled, + "blocked_reason": card.get("blocked_reason") or "", + } + if status == "blocked" or not adjudication_enabled: + blocked.append(card_id) + if status == "transcribed": + transcribed.append(card_id) + return { + "scope": "rangacharya_readiness", + "manifest_available": bool(manifest_payload), + "source_cards_available": bool(cards_payload), + "adjudication_enabled": bool(cards) and not blocked, + "card_count": len(cards), + "blocked_count": len(blocked), + "transcribed_count": len(transcribed), + "blocked_cards": blocked, + "transcribed_cards": transcribed, + "cards": cards, + } + + +if __name__ == "__main__": + print(json.dumps(build_report(), ensure_ascii=False, indent=2, sort_keys=True)) diff --git a/scripts/report_builder.py b/scripts/report_builder.py index bc0c22ea..bf3eb519 100644 --- a/scripts/report_builder.py +++ b/scripts/report_builder.py @@ -29,6 +29,7 @@ import sys import re import glob import argparse +from urllib.parse import urlparse try: import markdown @@ -323,6 +324,13 @@ def build_cover(name, lagna, gender, status, pkg, desc, lang="cn"):
""" +def is_allowed_report_resource_url(url, *, report_url): + if url == report_url: + return True + parsed = urlparse(url) + return parsed.scheme in {'data', 'about', 'blob'} + + def build_toc(sections, lang="cn"): """Generate table of contents HTML.""" toc_title = "目录" if lang == "cn" else "Table of Contents" diff --git a/scripts/report_renderer_isolation_poc.py b/scripts/report_renderer_isolation_poc.py new file mode 100644 index 00000000..bbc10cfe --- /dev/null +++ b/scripts/report_renderer_isolation_poc.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Run an isolated Chromium proof that report rendering cannot fetch external resources.""" +from __future__ import annotations + +import json +import argparse +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +try: + from report_builder import is_allowed_report_resource_url +except ImportError: + from scripts.report_builder import is_allowed_report_resource_url + + +class _ProbeHandler(BaseHTTPRequestHandler): + requests = 0 + + def do_GET(self) -> None: # noqa: N802 + type(self).requests += 1 + self.send_response(200) + self.end_headers() + + def log_message(self, _format: str, *_args: Any) -> None: + return + + +def run_poc() -> dict[str, Any]: + try: + from playwright.sync_api import sync_playwright + except ImportError: + return {"scope": "report_renderer_isolation_poc", "status": "blocked", "reason": "playwright_python_missing"} + + server = ThreadingHTTPServer(("127.0.0.1", 0), _ProbeHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + secret = root / "secret.txt" + secret.write_text("must-not-load", encoding="utf-8") + html = root / "report.html" + remote_url = f"http://127.0.0.1:{server.server_port}/probe" + html.write_text( + f'

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 @@ + + + + +Jyotish Evidence Packet + +
+

Evidence Packet

+

仅显示已完成任务的可审计计算状态、证据包和技法审计。不会展示内部提示词或原始出生输入。

+
+

运行状态

等待加载
+

Technique Audit

-
+

Machine Evidence Packet

-
+

Warnings

-
+
+ + diff --git a/web/index.html b/web/index.html new file mode 100644 index 00000000..27aa93f9 --- /dev/null +++ b/web/index.html @@ -0,0 +1,9 @@ + + +Jyotish Consultation + +

Jyotish Consultation

先确认出生资料,再选择直接排盘或主动问询式生时校正。外部引擎状态将在证据包中明示。

+

开始

生时不确定:主动问询校正查看 Evidence Packet
+

出生地点确认

可使用本地城市库;未收录时请手填经纬度。此操作不调用第三方地理服务。

+

运行环境

等待检查
+ diff --git a/web/rectification.html b/web/rectification.html new file mode 100644 index 00000000..54a98fd4 --- /dev/null +++ b/web/rectification.html @@ -0,0 +1,15 @@ + + +主动问询式生时校正 + +

主动问询式生时校正

先扫描候选时间,再回答选择题。结果只缩小候选簇,不宣称已经精确到分钟。

+

+