feat: extend external validation parity

This commit is contained in:
732642856
2026-07-17 19:51:31 +08:00
parent f2c4a0d915
commit e3569160d8
14 changed files with 4453 additions and 170 deletions
+1 -1
View File
@@ -6291,7 +6291,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
lon = self._get_float(body, 'lon', 0, -180, 180)
tz = self._parse_timezone(body, lat, lon, year, month, day, birth_hour, birth_minute, birth_second)
jd = swe.julday(year, month, day, birth_hour_decimal - tz)
context = shadbala_module.build_shadbala_context(jd, lat, lon, body.get('ayanamsa', 'lahiri'))
context = shadbala_module.build_shadbala_context(jd, lat, lon, body.get('ayanamsa', 'lahiri'), tz)
result = shadbala_module.calc_shadbala(
planets,
SIGNS[asc_sign_idx],
+4 -3
View File
@@ -3364,7 +3364,7 @@ def cmd_shadbala(args):
birth_hour = _birth_hour_decimal(args.hour, args.minute, _arg_second(args))
sun_lon = planets.get("Sun", {}).get("degree", 0)
moon_lon = planets.get("Moon", {}).get("degree", 0)
context = build_shadbala_context(jd, args.lat, args.lon, _current_ayanamsa_name(args))
context = build_shadbala_context(jd, args.lat, args.lon, _current_ayanamsa_name(args), args.tz)
return calc_shadbala(planets, asc_sign, birth_hour, sun_lon, moon_lon, context=context)
@@ -3757,7 +3757,7 @@ def cmd_audit(args):
birth_hour = _birth_hour_decimal(args.hour, args.minute, _arg_second(args))
sun_lon = planets.get('Sun', {}).get('degree', 0)
moon_lon = planets.get('Moon', {}).get('degree', 0)
context = build_shadbala_context(jd, args.lat, args.lon, _current_ayanamsa_name(args))
context = build_shadbala_context(jd, args.lat, args.lon, _current_ayanamsa_name(args), args.tz)
shadbala = calc_shadbala(planets, asc_sign, birth_hour, sun_lon, moon_lon, context=context)
report['audit']['P9_shadbala'] = {
'summary': shadbala.get('summary', {}),
@@ -5522,10 +5522,11 @@ def cmd_full_reading(args):
# ── Step 10: Shadbala六重力量 ──
try:
from shadbala import calc_shadbala
from shadbala import build_shadbala_context, calc_shadbala
birth_hour = _birth_hour_decimal(args.hour, args.minute, _arg_second(args))
sun_lon = planet_lons.get('Sun', 0)
moon_lon = planet_lons.get('Moon', 0)
context = build_shadbala_context(jd, args.lat, args.lon, _current_ayanamsa_name(args), args.tz)
shadbala_result = calc_shadbala(planets, asc_sign, birth_hour, sun_lon, moon_lon, context=context)
# 添加顶层汇总
if isinstance(shadbala_result, dict):
+30 -5
View File
@@ -17,6 +17,10 @@ ROOT = Path(__file__).resolve().parents[1]
DEFAULT_OFFSETS = (-120, -90, -60, -30, 30, 60, 90, 120)
def parse_offsets(raw: str) -> tuple[int, ...]:
return tuple(dict.fromkeys(value for value in (int(item.strip()) for item in raw.split(",")) if value != 0))
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]
@@ -41,16 +45,36 @@ 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]
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
specificity_proxy = 1 - control_activation_rate if control_activation_rate is not None else None
strong_specificity_proxy = 1 - control_strong_activation_rate if control_strong_activation_rate is not None else None
timing_precision_pass = (
positive_top_3_rate is not None
and specificity_proxy is not None
and positive_top_3_rate >= 0.6
and specificity_proxy >= 0.6
)
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,
"control_activation_rate": control_activation_rate,
"control_strong_activation_rate": control_strong_activation_rate,
"specificity_proxy": specificity_proxy,
"strong_specificity_proxy": strong_specificity_proxy,
"positive_top_1_rate": positive_top_1_rate,
"positive_top_3_rate": positive_top_3_rate,
"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,
"timing_precision_gate": {
"status": "pass" if timing_precision_pass else "blocked",
"positive_top_3_minimum": 0.6,
"specificity_proxy_minimum": 0.6,
"reason": None if timing_precision_pass else "positive_dates_do_not_rank_above_controls_with_required_specificity",
},
"balanced_accuracy": None,
"balanced_accuracy_blocked_reason": "controls_are_non_target_dates_not_independently_adjudicated_all-domain_non_events",
}
@@ -106,9 +130,10 @@ 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")
parser.add_argument("--offsets", default=",".join(str(value) for value in DEFAULT_OFFSETS))
args = parser.parse_args()
manifest = json.loads((ROOT / args.manifest).read_text(encoding="utf-8"))
report = build_report(manifest)
report = build_report(manifest, parse_offsets(args.offsets))
payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
if args.output:
output_path = ROOT / args.output
+200 -7
View File
@@ -125,9 +125,36 @@ _MOOLATRIKONA = {
"Mercury": (5, 16.0, 20.0), "Jupiter": (8, 0.0, 10.0),
"Venus": (6, 0.0, 15.0), "Saturn": (10, 0.0, 20.0),
}
_PLANET_INDEX = {name: index for index, name in enumerate(("Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"))}
_INDEX_PLANET = {index: name for name, index in _PLANET_INDEX.items()}
_ABDA_WEEKDAY_PLANETS = ("Mars", "Mercury", "Jupiter", "Venus", "Saturn", "Sun", "Moon")
_HORA_ORDER = ("Saturn", "Jupiter", "Mars", "Sun", "Venus", "Mercury", "Moon")
_SURYA_CIVIL_DAYS = 1_577_917_828
_SURYA_REVOLUTIONS = {
"Sun": 4_320_000, "Mars": 2_296_832, "Mercury": 17_937_060,
"Jupiter": 364_220, "Venus": 7_022_376, "Saturn": 146_568,
}
_UJJAIN_LONGITUDE = 75.7885
def build_shadbala_context(jd_ut: float, lat: float, lon: float, ayanamsa: str = "lahiri") -> Dict:
def _solar_event_local_hour(jd_ut: float, lat: float, lon: float, timezone: float, event: int) -> float:
local_jd = jd_ut + timezone / 24.0
year, month, day, _ = swe.revjul(local_jd, swe.GREG_CAL)
local_midnight = swe.julday(year, month, day, 0.0, swe.GREG_CAL)
utc_midnight = local_midnight - timezone / 24.0
_, times = swe.rise_trans(
utc_midnight,
swe.SUN,
event | swe.BIT_HINDU_RISING,
(float(lon), float(lat), 0.0),
0.0,
0.0,
swe.FLG_SWIEPH | swe.FLG_SIDEREAL | swe.FLG_NONUT,
)
return (times[0] + timezone / 24.0 - local_midnight) * 24.0
def build_shadbala_context(jd_ut: float, lat: float, lon: float, ayanamsa: str = "lahiri", timezone: float = 0.0) -> Dict:
"""Build precise sidereal house context from standard birth inputs."""
sid_mode = swe.SIDM_LAHIRI
if str(ayanamsa).lower() in {"raman"}:
@@ -136,7 +163,19 @@ def build_shadbala_context(jd_ut: float, lat: float, lon: float, ayanamsa: str =
sid_mode = swe.SIDM_KRISHNAMURTI
swe.set_sid_mode(sid_mode)
cusps, _ = swe.houses_ex(float(jd_ut), float(lat), float(lon), b"P", swe.FLG_SIDEREAL)
return {"jd_ut": float(jd_ut), "lat": float(lat), "lon": float(lon), "ayanamsa": str(ayanamsa), "house_midpoints": [float(value) % 360 for value in cusps]}
local_jd = float(jd_ut) + float(timezone) / 24.0
year, month, day, local_hour = swe.revjul(local_jd, swe.GREG_CAL)
sunrise = _solar_event_local_hour(jd_ut, lat, lon, timezone, swe.CALC_RISE)
sunset = _solar_event_local_hour(jd_ut, lat, lon, timezone, swe.CALC_SET)
previous_sunset = _solar_event_local_hour(jd_ut - 1.0, lat, lon, timezone, swe.CALC_SET)
return {
"jd_ut": float(jd_ut), "jd_local": local_jd,
"lat": float(lat), "lon": float(lon), "timezone": float(timezone),
"year": int(year), "month": int(month), "day": int(day), "local_hour": float(local_hour),
"ayanamsa": str(ayanamsa), "ayanamsa_degrees": float(swe.get_ayanamsa_ut(float(jd_ut))),
"sunrise_hour": sunrise, "sunset_hour": sunset, "previous_sunset_hour": previous_sunset,
"house_midpoints": [float(value) % 360 for value in cusps],
}
def calc_dig_bala_precise(pname: str, planet_lon: float, house_midpoints: list[float]) -> float:
@@ -299,6 +338,155 @@ def calc_sthana_bala_precise(pname: str, all_planets: Dict, house: int) -> Dict:
}
def _lagrange_interpolate(xs: tuple[float, ...], ys: tuple[float, ...], value: float) -> float:
total = 0.0
for i, x_i in enumerate(xs):
term = ys[i]
for j, x_j in enumerate(xs):
if i != j:
term *= (value - x_j) / (x_i - x_j)
total += term
return total
def _ayana_bala(pname: str, longitude: float, ayanamsa: float) -> tuple[float, float]:
raw_tropical = longitude + ayanamsa
tropical = raw_tropical % 360.0
north = raw_tropical < 180.0
folded = tropical
if 90.0 < tropical < 180.0:
folded = 180.0 - tropical
elif 180.0 < tropical < 270.0:
folded = tropical - 180.0
elif tropical > 270.0:
folded = 360.0 - tropical
sign = 1.0
if north and pname in {"Moon", "Saturn"}:
sign = -1.0
elif not north and pname in {"Sun", "Mars", "Jupiter", "Venus"}:
sign = -1.0
if pname == "Mercury":
sign = 1.0
declination = sign * _lagrange_interpolate(
(0.0, 15.0, 30.0, 45.0, 60.0, 75.0, 90.0),
(0.0, 362 / 60.0, 703 / 60.0, 1002 / 60.0, 1238 / 60.0, 1388 / 60.0, 24.0),
round(folded, 2),
)
bala = round((24.0 + declination) * 1.25, 2)
if pname == "Sun":
bala = round(bala * 2.0, 2)
return bala, round(declination, 4)
def _days_elapsed_since_base(year: int, base_year: int = 1951, base_days: int = 174) -> int:
years = year - base_year
leap_years = sum(1 for value in range(base_year + 1, year + 1) if value % 4 == 0 and (value % 100 != 0 or value % 400 == 0))
return base_days + leap_years * 366 + (years - leap_years) * 365
def _calendar_lords(context: Dict) -> Dict[str, str]:
year, month, day = context["year"], context["month"], context["day"]
local_jd = context["jd_local"]
year_start = swe.julday(year, 1, 1, 0.0, swe.GREG_CAL)
elapsed = int(local_jd - year_start + 1.0)
ahargana = _days_elapsed_since_base(year - 1) + elapsed
abda = _ABDA_WEEKDAY_PLANETS[(int(ahargana // 360) * 3 + 1) % 7]
masa = _ABDA_WEEKDAY_PLANETS[(int(ahargana // 30) * 2 + 1) % 7]
vaara_ahargana = _days_elapsed_since_base(year - 1, 1827, 244) + elapsed
if context["local_hour"] < context["sunrise_hour"]:
vaara_ahargana -= 1
vaara = _ABDA_WEEKDAY_PLANETS[int(vaara_ahargana) % 7]
civil_weekday = int(math.ceil(local_jd + 1.0) % 7)
local_hour = context["local_hour"]
if local_hour < context["sunrise_hour"]:
civil_weekday = (civil_weekday - 1) % 7
local_hour += 24.0
hora_index = (int(local_hour - context["sunrise_hour"]) + civil_weekday + 1) % 7
return {"abda": abda, "masa": masa, "vaara": vaara, "hora": _HORA_ORDER[hora_index]}
def calc_kala_bala_precise(pname: str, all_planets: Dict, context: Dict) -> Dict:
"""Classical temporal strength from local solar events and calendar lords."""
local_hour = context["local_hour"]
midnight = (context["sunrise_hour"] + context["previous_sunset_hour"]) / 2.0
midnight = 12.0 - midnight if midnight < 12.0 else midnight - 12.0
unnata = (local_hour - midnight) * 5.0 if local_hour < 12.0 else (24.0 + midnight - local_hour) * 5.0
if pname == "Mercury":
nathonnata = 60.0
elif pname in {"Sun", "Jupiter", "Venus"}:
nathonnata = round(unnata, 2)
else:
nathonnata = round(60.0 - unnata, 2)
benefics, malefics = classify_drik_planets(all_planets)
phase = round(abs(_planet_longitude("Sun", all_planets) - _planet_longitude("Moon", all_planets)) / 3.0, 2)
paksha = phase if pname in benefics else round(60.0 - phase, 2) if pname in malefics else 0.0
if pname == "Moon":
paksha = round(paksha * 2.0, 2)
sunrise, sunset = context["sunrise_hour"], context["sunset_hour"]
day_length = sunset - sunrise
night_length = 24.0 - day_length
tribhaga_lord = None
if sunrise <= local_hour < sunset:
tribhaga_lord = ("Mercury", "Sun", "Saturn")[min(2, int((local_hour - sunrise) / (day_length / 3.0)))]
else:
since_sunset = (local_hour - sunset) % 24.0
tribhaga_lord = ("Moon", "Venus", "Mars")[min(2, int(since_sunset / (night_length / 3.0)))]
tribhaga = 60.0 if pname in {"Jupiter", tribhaga_lord} else 0.0
lords = _calendar_lords(context)
calendar = (15.0 if pname == lords["abda"] else 0.0) + (30.0 if pname == lords["masa"] else 0.0) + (45.0 if pname == lords["vaara"] else 0.0) + (60.0 if pname == lords["hora"] else 0.0)
ayana, declination = _ayana_bala(pname, _planet_longitude(pname, all_planets), context["ayanamsa_degrees"])
total = round(nathonnata + paksha + tribhaga + calendar + ayana, 2)
return {
"nathonnata": nathonnata, "paksha": paksha, "tribhaga": tribhaga,
"abda": 15.0 if pname == lords["abda"] else 0.0,
"masa": 30.0 if pname == lords["masa"] else 0.0,
"vaara": 45.0 if pname == lords["vaara"] else 0.0,
"hora": 60.0 if pname == lords["hora"] else 0.0,
"ayana": ayana, "declination": declination, "yuddha": 0.0,
"total": total,
}
def _surya_mean_longitude(pname: str, context: Dict) -> float:
"""Surya Siddhanta mean motion with desantara correction from Ujjain."""
local_jd = context["jd_local"]
kali_days = int(local_jd - 588_465.0)
weekday = int(math.ceil(local_jd + 1.0) % 7)
kali_days += weekday - 5 - kali_days % 7
daily_motion = round(_SURYA_REVOLUTIONS[pname] / _SURYA_CIVIL_DAYS * 360.0, 7)
mean_longitude = (kali_days * daily_motion) % 360.0
desantara = (_UJJAIN_LONGITUDE - context["lon"]) / 360.0 * daily_motion
return (mean_longitude + desantara) % 360.0
def calc_chesta_bala_precise(pname: str, all_planets: Dict, kala: Dict, context: Dict) -> float:
"""BPHS bounded Chesta Bala using mean motion and Seeghrochcha.
Mean-motion/Seeghrochcha structure and constants are adapted from the MIT
jyotishganit reference retained under references/open_source_sources.
"""
if pname == "Sun":
return round(float(kala["ayana"]), 2)
if pname == "Moon":
return round(float(kala["paksha"]), 2)
sun_mean = _surya_mean_longitude("Sun", context)
planet_mean = _surya_mean_longitude(pname, context)
if pname in {"Mercury", "Venus"}:
seeghrochcha, mean_longitude = planet_mean, sun_mean
else:
seeghrochcha, mean_longitude = sun_mean, planet_mean
average_longitude = (_planet_longitude(pname, all_planets) + mean_longitude) / 2.0
chesta_kendra = abs(seeghrochcha - average_longitude)
if chesta_kendra > 180.0:
chesta_kendra = 360.0 - chesta_kendra
return round(max(0.0, chesta_kendra) / 3.0, 2)
def calc_shadbala(planets: Dict, asc_sign: str, birth_hour: float,
sun_lon: float, moon_lon: float,
birth_minute: float = 0.0, context: Dict | None = None) -> Dict:
@@ -333,8 +521,8 @@ def calc_shadbala(planets: Dict, asc_sign: str, birth_hour: float,
sthana = calc_sthana_bala_precise(pname, planets, house)
dig = calc_dig_bala_precise(pname, lon, context["house_midpoints"]) if context and context.get("house_midpoints") else calc_dig_bala(pname, house)
kala = calc_kala_bala(pname, is_night, sun_northern, sun_lon, moon_lon, birth_hour, birth_minute)
chesta = calc_chesta_bala(pname, retro, speed, sun_lon, moon_lon)
kala = calc_kala_bala_precise(pname, planets, context) if context else calc_kala_bala(pname, is_night, sun_northern, sun_lon, moon_lon, birth_hour, birth_minute)
chesta = calc_chesta_bala_precise(pname, planets, kala, context) if context else calc_chesta_bala(pname, retro, speed, sun_lon, moon_lon)
naisargika = NAISARGIKA_BALA.get(pname, 30.0)
drik = calc_drik_bala(pname, sign, house, planets)
@@ -357,8 +545,8 @@ def calc_shadbala(planets: Dict, asc_sign: str, birth_hour: float,
sthana = calc_sthana_bala_precise(pname, planets, house)
dig = calc_dig_bala_precise(pname, lon, context["house_midpoints"]) if context and context.get("house_midpoints") else calc_dig_bala(pname, house)
kala = calc_kala_bala(pname, is_night, sun_northern, sun_lon, moon_lon, birth_hour, birth_minute)
chesta = calc_chesta_bala(pname, retro, speed, sun_lon, moon_lon)
kala = calc_kala_bala_precise(pname, planets, context) if context else calc_kala_bala(pname, is_night, sun_northern, sun_lon, moon_lon, birth_hour, birth_minute)
chesta = calc_chesta_bala_precise(pname, planets, kala, context) if context else calc_chesta_bala(pname, retro, speed, sun_lon, moon_lon)
naisargika = NAISARGIKA_BALA.get(pname, 30.0)
drik = calc_drik_bala(pname, sign, house, planets)
@@ -408,7 +596,12 @@ def calc_shadbala(planets: Dict, asc_sign: str, birth_hour: float,
bhava_bala = calc_bhava_bala(planets, asc_sign)
return {
'method': 'Shadbala六重力量(v6.9.15: absolute Rupas, Kendra三档+Bhava Bala+Hora Lord+Seeghrochcha Sun',
'method': 'Shadbala六重力量(absolute Virupas; precise Sthana/Dig/Kala/Drik; bounded BPHS Chesta',
'method_variants': {
'kala': 'bphs_local_solar_events_ahargana_declination' if context else 'legacy_approximation',
'chesta': 'bphs_bounded_surya_mean_motion_seeghrochcha' if context else 'legacy_speed_bands',
},
'external_parity_boundary': 'Chesta remains cross-engine method-conflicted; totals inherit that boundary.',
'is_night_birth': is_night,
'sun_uttarayana': sun_northern,
'planets': results,
@@ -0,0 +1,71 @@
#!/usr/bin/env python3
"""Audit VedAstro's official MIT source contract without invoking the service."""
from __future__ import annotations
import argparse
import hashlib
import json
import re
import subprocess
from pathlib import Path
def _read(root: Path, relative: str) -> str:
return (root / relative).read_text(encoding="utf-8-sig")
def audit_source(root: Path) -> dict:
project = _read(root, "Library/Library.csproj")
time_source = _read(root, "Library/Data/Time.cs")
tools_source = _read(root, "Library/Logic/Tools.cs")
core_source = _read(root, "Library/Logic/Calculate/Core.cs")
version_match = re.search(r"<Version>([^<]+)</Version>", project)
checks = {
"mit_license": "<PackageLicenseExpression>MIT</PackageLicenseExpression>" in project,
"offset_parse_exact": 'DateTimeOffset.ParseExact(timezoneRaw, "zzz"' in tools_source,
"zero_offset_auto_lookup": "parsedTimezone == null || parsedTimezone == TimeSpan.Zero" in tools_source and "GeoLocationToTimezone" in tools_source,
"time_keeps_offset": "DateTimeOffset.ParseExact(stdDateTimeText, Time.DateTimeFormat" in time_source and "HH:mm dd/MM/yyyy zzz" in time_source,
"all_planet_longitude_is_nirayana": "AllPlanetLongitude(Time time)" in core_source and "PlanetNirayanaLongitude" in core_source,
}
commit = None
try:
commit = subprocess.run(["git", "-C", str(root), "rev-parse", "HEAD"], check=True, capture_output=True, text=True).stdout.strip()
except (OSError, subprocess.CalledProcessError):
pass
files = {}
for relative in ("Library/Library.csproj", "Library/Data/Time.cs", "Library/Logic/Tools.cs", "Library/Logic/Calculate/Core.cs"):
files[relative] = hashlib.sha256((root / relative).read_bytes()).hexdigest()
return {
"source": "https://github.com/VedAstro/VedAstro",
"source_commit": commit,
"library_version": version_match.group(1) if version_match else None,
"source_contract_status": "verified" if all(checks.values()) else "blocked",
"checks": checks,
"timezone_contract": {
"zero_offset": "auto_lookup_sentinel" if checks["zero_offset_auto_lookup"] else "blocked",
"negative_offset": "literal_offset" if checks["offset_parse_exact"] else "blocked",
"conclusion": "+00:00 and the location's real offset may intentionally produce identical results.",
},
"longitude_contract": {"AllPlanetLongitude": "nirayana" if checks["all_planet_longitude_is_nirayana"] else "blocked"},
"deployment_identity_status": "blocked",
"deployment_identity_reason": "Remote responses expose neither library version nor source commit.",
"files": files,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("source_root", type=Path)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
report = audit_source(args.source_root)
text = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(text, encoding="utf-8")
print(text, end="")
return 0 if report["source_contract_status"] == "verified" else 2
if __name__ == "__main__":
raise SystemExit(main())