chore: add engineering quality gate

This commit is contained in:
732642856
2026-06-05 15:58:06 +08:00
parent 8da45c56b3
commit 625deb4007
14 changed files with 437 additions and 18 deletions
+5 -11
View File
@@ -19,16 +19,10 @@ jobs:
python-version: '3.11' python-version: '3.11'
- name: Install dependencies - name: Install dependencies
run: python -m pip install --upgrade pip && pip install -r requirements.txt run: python -m pip install --upgrade pip && pip install -r requirements.txt && pip install -r requirements-dev.txt
- name: Compile Python files - name: Run Ruff lint for v6.0.33 quality gate files
run: python -m py_compile scripts/jyotish_engine.py scripts/special_lagnas.py scripts/audit_capabilities.py tests/run_golden_cases.py run: ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py tests/test_yoga_rules_integrity.py
- name: Validate technique registry - name: Run quality gate
run: python scripts/audit_capabilities.py --mode validate run: python scripts/run_quality_gate.py --skip-yoga-logic
- name: Run unit tests
run: python -m unittest discover tests
- name: Run golden cases
run: python tests/run_golden_cases.py --python python
+6
View File
@@ -11,3 +11,9 @@ build/
brain/ brain/
COVERAGE_AUDIT_REPORT.md COVERAGE_AUDIT_REPORT.md
*.tmp *.tmp
.coverage
coverage.xml
htmlcov/
.ruff_cache/
.mypy_cache/
.hypothesis/
+17
View File
@@ -0,0 +1,17 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.6
hooks:
- id: ruff
args: ["--fix"]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-yaml
- id: check-toml
- id: check-json
- id: end-of-file-fixer
- id: trailing-whitespace
exclude: "^(references/|CHANGELOG.md|SKILL.md)"
+33
View File
@@ -1,5 +1,38 @@
# 印度占星 Skill 更新日志 # 印度占星 Skill 更新日志
## v6.0.332026-06-05)—— 工程质量门禁、属性测试与 CI 强化
> **目标**:把 v6.0.32 的准确率修复沉淀为可持续工程体系,防止 Dasha、Varga、Ashtakavarga、Yoga 规则与 full-reading 输出契约回归。
### 工程配置
- 新增 `pyproject.toml`,统一项目元数据、pytest 配置、coverage 配置与 Ruff 静态检查规则。
- 新增 `requirements-dev.txt`,将 `pytest``hypothesis``coverage``ruff` 作为开发/CI 依赖独立管理。
- 新增 `.pre-commit-config.yaml`,接入 Ruff 与基础 YAML/TOML/JSON/换行/尾随空格检查。
- 更新 `.gitignore`,忽略 coverage、Ruff、Hypothesis 等本地质量工具缓存。
### 自动化测试与质量门禁
- 新增 `scripts/run_quality_gate.py`,统一执行核心 Python 编译、关键 JSON 校验、technique registry 校验、BPHS 不变量验证、pytest 与 golden case 回归。
- 新增 `tests/test_varga_bphs.py`:用 Hypothesis 属性测试 D9 Navamsa、D10 Dasamsa、D3 Drekkana 的 BPHS 映射不变量。
- 新增 `tests/test_ashtakavarga_invariants.py`:用属性测试证明 BAV 固定总分、SAV=337、full SAV=386 与输出契约不随行星位置变化。
- 新增 `tests/test_cli_smoke.py`:覆盖 `dasha``varga``ashtakavarga``audit-capabilities` CLI smoke tests。
- 新增 `tests/test_yoga_rules_integrity.py`:校验 Yoga 规则 ID 唯一性、规则计数、低频 Yoga 高置信规则存在,以及逻辑验证报告结构。
- 更新 GitHub ActionsCI 安装开发依赖,执行 Ruff 和统一质量门禁。
### 精度边界修复
- 修复 `scripts/varga.py` 中分盘度数在星座边界处可能由 `29.999999...` 四舍五入为 `30.0000` 的显示问题;现在输出强制保持在 `[0, 30)`,避免下游范围不变量误报。
- 调整旧单测 `test_no_yoga_false_positive` 的断言范围:v6.0.32 后规则库已扩大到数百条,极简星盘可能触发其他专项 Yoga;该测试现在只约束原始目标——不误报 Raja Yoga。
### 当前验证结果
- `pytest tests`**35 passed**。
- `scripts/run_quality_gate.py --skip-yoga-logic`:通过;包含 18 项 BPHS/Ashtakavarga 不变量、technique registry 校验、pytest 与 golden case 回归。
- 新增代码 Ruff 检查:通过。
---
## v6.0.322026-06-05)—— 逻辑正确性验证框架、Dasha/Varga/Ashtakavarga 修复 ## v6.0.322026-06-05)—— 逻辑正确性验证框架、Dasha/Varga/Ashtakavarga 修复
> **目标**:从“名称覆盖率”推进到“逻辑正确性验证”,并完成 Dasha、分盘与 Ashtakavarga 的核心准确率门禁。 > **目标**:从“名称覆盖率”推进到“逻辑正确性验证”,并完成 Dasha、分盘与 Ashtakavarga 的核心准确率门禁。
+2 -2
View File
@@ -1,6 +1,6 @@
--- ---
name: jyotish-vedic-astrology name: jyotish-vedic-astrology
version: 6.0.32 version: 6.0.33
description: 印度占星(Jyotish)专业解盘与推运系统。核心能力:PDF星盘输入→严谨解盘→精确推运应期输出。触发词:印度占星、吠陀占星、Jyotish、解盘、推运、星盘分析、Dasha、Transit、Nakshatra、Yoga、出生时间矫正、PDF星盘、读取PDF、分析PDF星盘、现代解读、误判纠错、Varga分盘、综合分析、过境分析、合盘、婚姻匹配、年运盘、Prashna、Argala、Jaimini、Shadbala、Ashtakavarga、HTML报告、深度解盘。 description: 印度占星(Jyotish)专业解盘与推运系统。核心能力:PDF星盘输入→严谨解盘→精确推运应期输出。触发词:印度占星、吠陀占星、Jyotish、解盘、推运、星盘分析、Dasha、Transit、Nakshatra、Yoga、出生时间矫正、PDF星盘、读取PDF、分析PDF星盘、现代解读、误判纠错、Varga分盘、综合分析、过境分析、合盘、婚姻匹配、年运盘、Prashna、Argala、Jaimini、Shadbala、Ashtakavarga、HTML报告、深度解盘。
--- ---
@@ -10,7 +10,7 @@ description: 印度占星(Jyotish)专业解盘与推运系统。核心能力
> **严格路由**`references/strict-workflow-router.md`(⭐涉及事业/婚恋/财务/应期/技法验证时必须优先读取) > **严格路由**`references/strict-workflow-router.md`(⭐涉及事业/婚恋/财务/应期/技法验证时必须优先读取)
> **覆盖矩阵**`references/technique-capability-matrix.md`(⭐判断技法 covered/partial/missing 时必须参考) > **覆盖矩阵**`references/technique-capability-matrix.md`(⭐判断技法 covered/partial/missing 时必须参考)
> **机器注册表**`references/technique_registry.json` + `scripts/audit_capabilities.py`(⭐用于自动审计与CI门禁) > **机器注册表**`references/technique_registry.json` + `scripts/audit_capabilities.py`(⭐用于自动审计与CI门禁)
> **版本**v6.0.32-logic-validation-varga-fix | **详细变更**`CHANGELOG.md` > **版本**v6.0.33-engineering-quality-gate | **详细变更**`CHANGELOG.md`
--- ---
+63
View File
@@ -0,0 +1,63 @@
[project]
name = "jyotish-vedic-astrology-skill"
version = "6.0.33"
description = "Jyotish WorkBuddy skill calculation engine and validation suite"
requires-python = ">=3.11"
dependencies = [
"pyswisseph",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"hypothesis>=6.0",
"coverage[toml]>=7.0",
"ruff>=0.6.0",
]
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
pythonpath = ["scripts"]
addopts = "-q --strict-markers --strict-config"
markers = [
"slow: long-running validation tests",
"external: tests that depend on optional external datasets or tools",
]
[tool.coverage.run]
branch = true
source = ["scripts"]
omit = [
"scripts/add_high_confidence_yogas_batch1.py",
"scripts/build_standard_test_charts.py",
"scripts/_compute_one_chart.py",
]
[tool.coverage.report]
show_missing = true
skip_covered = true
[tool.ruff]
target-version = "py311"
line-length = 140
exclude = [
".git",
"__pycache__",
".pytest_cache",
"jyotish-app",
"benchmark",
]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = [
"E501",
"E701",
"E702",
"E741",
]
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101"]
"scripts/validate_bphs_invariants.py" = ["T201"]
+5
View File
@@ -0,0 +1,5 @@
# 开发与 CI 依赖
pytest>=8.0
hypothesis>=6.0
coverage[toml]>=7.0
ruff>=0.6.0
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Run the Jyotish skill quality gate used by local development and CI."""
from __future__ import annotations
import argparse
import json
import os
import py_compile
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PYTHON = sys.executable
COMPILE_TARGETS = [
ROOT / "scripts" / "jyotish_engine.py",
ROOT / "scripts" / "varga.py",
ROOT / "scripts" / "ashtakavarga.py",
ROOT / "scripts" / "yoga_engine.py",
ROOT / "scripts" / "audit_capabilities.py",
ROOT / "scripts" / "validate_bphs_invariants.py",
ROOT / "tests" / "run_golden_cases.py",
]
def run(cmd: list[str], *, optional: bool = False) -> bool:
print(f"\n$ {' '.join(cmd)}")
completed = subprocess.run(cmd, cwd=ROOT, text=True)
if completed.returncode == 0:
return True
if optional:
print(f"Optional step failed with exit code {completed.returncode}; continuing.")
return False
raise SystemExit(completed.returncode)
def compile_targets() -> None:
print("\n== Compile core Python files ==")
for target in COMPILE_TARGETS:
print(f"compile {target.relative_to(ROOT)}")
py_compile.compile(str(target), doraise=True)
def validate_json_files() -> None:
print("\n== Validate critical JSON files ==")
for relative in [
"references/technique_registry.json",
"references/yoga_rules.json",
"references/standard_test_charts.json",
"references/validation_logic_report.json",
"tests/golden/golden_cases.json",
]:
path = ROOT / relative
if not path.exists():
continue
with path.open("r", encoding="utf-8") as handle:
json.load(handle)
print(f"json ok {relative}")
def main() -> int:
parser = argparse.ArgumentParser(description="Run Jyotish skill quality gate")
parser.add_argument("--skip-slow", action="store_true", help="Skip slow golden-case regressions")
parser.add_argument("--skip-yoga-logic", action="store_true", help="Skip Yoga logic comparison report refresh")
args = parser.parse_args()
os.environ.setdefault("PYTHONPATH", str(ROOT / "scripts"))
compile_targets()
validate_json_files()
run([PYTHON, "scripts/audit_capabilities.py", "--mode", "validate"])
run([PYTHON, "scripts/validate_bphs_invariants.py"])
run([PYTHON, "-m", "pytest", "tests"])
if not args.skip_slow:
run([PYTHON, "tests/run_golden_cases.py", "--python", PYTHON])
if not args.skip_yoga_logic:
run([PYTHON, "scripts/validate_logic_v2.py"], optional=True)
print("\nQuality gate passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+7 -1
View File
@@ -78,8 +78,14 @@ def calc_varga(lon, div):
si=_si(lon); d=lon-si*30; ps=30.0/div; pi=int(d/ps) si=_si(lon); d=lon-si*30; ps=30.0/div; pi=int(d/ps)
# For all vargas, degree within divisional sign is scaled to 0-30 degrees. # For all vargas, degree within divisional sign is scaled to 0-30 degrees.
dp=(d-pi*ps)*div dp=(d-pi*ps)*div
# Keep the displayed divisional degree inside [0, 30). Values such as
# 29.9999997 can round to 30.0000 at sign boundaries, which breaks
# downstream range invariants while the underlying sign mapping is valid.
dp_display = round(dp, 4)
if dp_display >= 30:
dp_display = 0.0
vsi=varga_map(si,pi,div) vsi=varga_map(si,pi,div)
r={'sign':_sn(vsi),'sign_idx':vsi,'degree_in_sign':round(dp,4), r={'sign':_sn(vsi),'sign_idx':vsi,'degree_in_sign':dp_display,
'part_index':pi,'lord':SIGN_LORDS.get(_sn(vsi),'')} 'part_index':pi,'lord':SIGN_LORDS.get(_sn(vsi),'')}
if div==9: r['pada']=pi+1 if div==9: r['pada']=pi+1
return r return r
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Ashtakavarga mathematical invariant tests."""
from __future__ import annotations
from ashtakavarga import ALL_SOURCES, BAV_TOTALS, EXPECTED_SAV_TOTAL, SEVEN_PLANETS, SIGNS, calc_ashtakavarga
from hypothesis import given
from hypothesis import strategies as st
@st.composite
def planet_signs(draw: st.DrawFn) -> dict[str, dict[str, str]]:
return {planet: {"sign": draw(st.sampled_from(SIGNS))} for planet in SEVEN_PLANETS}
@given(planet_signs(), st.integers(min_value=0, max_value=11))
def test_ashtakavarga_totals_are_position_independent(planets: dict[str, dict[str, str]], asc_sign_idx: int) -> None:
result = calc_ashtakavarga(planets, asc_sign_idx)
assert result["sav"]["total"] == EXPECTED_SAV_TOTAL
assert result["sav"]["valid"] is True
assert result["sav"]["full_total_with_lagna"] == EXPECTED_SAV_TOTAL + BAV_TOTALS["Lagna"]
assert result["all_bav_valid"] is True
for planet in ALL_SOURCES:
assert result["bav"][planet]["total"] == BAV_TOTALS[planet]
assert result["bav"][planet]["valid"] is True
def test_ashtakavarga_output_contract() -> None:
planets = {planet: {"sign": SIGNS[index % 12]} for index, planet in enumerate(SEVEN_PLANETS)}
result = calc_ashtakavarga(planets, asc_sign_idx=0)
assert result["method"] == "Ashtakavarga八分法(BPHS/PVR书例校准v2.1"
assert result["version"] == "2.1"
assert set(result["sav"]["scores"].keys()) == set(SIGNS)
assert len(result["house_scores"]) == 12
assert len(result["house_scores_full"]) == 12
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""CLI smoke tests for critical Jyotish engine commands."""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
ENGINE = ROOT / "scripts" / "jyotish_engine.py"
BASE_BIRTH_ARGS = [
"--year", "1990",
"--month", "1",
"--day", "1",
"--hour", "12",
"--minute", "0",
"--lat", "39.9",
"--lon", "116.4",
"--tz", "8",
]
def run_engine(*args: str) -> dict:
completed = subprocess.run(
[sys.executable, str(ENGINE), *args],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
)
return json.loads(completed.stdout)
def test_dasha_accepts_birth_datetime_without_explicit_nakshatra() -> None:
result = run_engine("dasha", *BASE_BIRTH_ARGS, "--today", "2026-01-01")
assert "moon_nakshatra" in result
assert len(result.get("timeline", [])) == 9
assert result["timeline"][0]["is_balance"] is True
def test_varga_cli_outputs_d9_and_d10() -> None:
result = run_engine("varga", *BASE_BIRTH_ARGS, "--d9", "--d10")
charts = result.get("divisional_charts", {})
assert "D9_Navamsa" in charts
assert "D10_Dasamsa" in charts
assert "Moon" in charts["D9_Navamsa"]
def test_ashtakavarga_cli_keeps_sav_invariant() -> None:
result = run_engine("ashtakavarga", *BASE_BIRTH_ARGS)
assert result["sav"]["total"] == 337
assert result["sav"]["valid"] is True
assert result["all_bav_valid"] is True
def test_audit_capabilities_cli_validates_registry() -> None:
result = run_engine("audit-capabilities", "--mode", "validate")
assert result.get("valid") is True
+6 -4
View File
@@ -245,13 +245,15 @@ class TestYogaDetection(unittest.TestCase):
raja = [y for y in yogas if y['name'] == 'Raja Yoga'] raja = [y for y in yogas if y['name'] == 'Raja Yoga']
self.assertTrue(len(raja) > 0, "Should detect Raja Yoga when Sun+Jupiter in 1st house") self.assertTrue(len(raja) > 0, "Should detect Raja Yoga when Sun+Jupiter in 1st house")
def test_no_yoga_false_positive(self): def test_scattered_planets_do_not_trigger_raja_yoga(self):
"""测试不满足条件时不产生 Yoga""" """分散行星不应触发 Raja Yoga"""
# 行星分散,且避免落入 Sun/Moon 两侧或扩展型简化 Yoga 条件 # v6.0.32 起规则库扩大到数百条,极简星盘可能触发其他低阶/专项 Yoga
# 此回归测试只约束原始目标:不应误报 Raja Yoga。
args = self._make_yoga_args('Aries', 'Sun:Taurus:2,Mars:Leo:5') args = self._make_yoga_args('Aries', 'Sun:Taurus:2,Mars:Leo:5')
result = cmd_yoga(args) result = cmd_yoga(args)
yogas = result.get('yogas', []) yogas = result.get('yogas', [])
self.assertEqual(len(yogas), 0, "Should not detect any Yoga with scattered planets") raja = [y for y in yogas if y['name'] == 'Raja Yoga']
self.assertEqual(len(raja), 0, "Should not detect Raja Yoga with scattered planets")
if __name__ == '__main__': if __name__ == '__main__':
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Regression tests for BPHS divisional chart mappings."""
from __future__ import annotations
from hypothesis import given
from hypothesis import strategies as st
from varga import SIGNS, calc_varga, varga_map
def navamsa_ref(lon: float) -> int:
sign_index = int((lon % 360) / 30) % 12
degree_in_sign = (lon % 360) - sign_index * 30
part_index = int(degree_in_sign / (30 / 9))
if sign_index % 3 == 0:
start = sign_index
elif sign_index % 3 == 1:
start = (sign_index + 4) % 12
else:
start = (sign_index + 8) % 12
return (start + part_index) % 12
def dasamsa_ref(lon: float) -> int:
sign_index = int((lon % 360) / 30) % 12
degree_in_sign = (lon % 360) - sign_index * 30
part_index = int(degree_in_sign / 3)
start = sign_index if sign_index % 2 == 0 else (sign_index + 8) % 12
return (start + part_index) % 12
def drekkana_ref(lon: float) -> int:
sign_index = int((lon % 360) / 30) % 12
degree_in_sign = (lon % 360) - sign_index * 30
part_index = int(degree_in_sign / 10)
return (sign_index + part_index * 4) % 12
@given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False))
def test_navamsa_matches_bphs_reference(lon: float) -> None:
result = calc_varga(lon, 9)
assert result["sign_idx"] == navamsa_ref(lon)
assert result["sign"] == SIGNS[navamsa_ref(lon)]
assert 0 <= result["degree_in_sign"] < 30
@given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False))
def test_dasamsa_matches_bphs_reference(lon: float) -> None:
result = calc_varga(lon, 10)
assert result["sign_idx"] == dasamsa_ref(lon)
assert result["sign"] == SIGNS[dasamsa_ref(lon)]
assert 0 <= result["degree_in_sign"] < 30
@given(st.floats(min_value=0, max_value=359.999999, allow_nan=False, allow_infinity=False))
def test_drekkana_uses_same_plus_four_plus_eight(lon: float) -> None:
result = calc_varga(lon, 3)
assert result["sign_idx"] == drekkana_ref(lon)
def test_varga_map_boundary_examples() -> None:
assert varga_map(0, 0, 9) == 0 # Aries Navamsa starts Aries
assert varga_map(1, 0, 9) == 5 # Taurus Navamsa starts Virgo
assert varga_map(2, 0, 9) == 10 # Gemini Navamsa starts Aquarius
assert varga_map(1, 0, 10) == 9 # Taurus Dasamsa starts Capricorn
assert varga_map(0, 2, 3) == 8 # Aries third Drekkana = Sagittarius
+45
View File
@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Integrity checks for the data-driven Yoga rules registry."""
from __future__ import annotations
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
YOGA_RULES = ROOT / "references" / "yoga_rules.json"
LOGIC_REPORT = ROOT / "references" / "validation_logic_report.json"
def test_yoga_rule_ids_are_unique_and_counted() -> None:
data = json.loads(YOGA_RULES.read_text(encoding="utf-8"))
rules = data.get("rules", [])
rule_ids = [rule.get("id") for rule in rules]
assert len(rule_ids) == len(set(rule_ids))
assert data.get("total_rules") == len(rules)
assert data.get("total_enabled_rules") == sum(1 for rule in rules if rule.get("enabled", True))
def test_new_low_frequency_yogas_exist_and_are_enabled() -> None:
data = json.loads(YOGA_RULES.read_text(encoding="utf-8"))
rules_by_id = {rule.get("id"): rule for rule in data.get("rules", [])}
for rule_id in [
"bvr_106_indra_yoga",
"bvr_107_makuta_yoga",
"bvr_108_jaya_yoga",
"bvr_109_putra_malika_yoga",
]:
assert rule_id in rules_by_id
assert rules_by_id[rule_id].get("enabled", True) is True
def test_logic_validation_report_contract() -> None:
report = json.loads(LOGIC_REPORT.read_text(encoding="utf-8"))
assert report["summary"]["charts_tested"] >= 60
assert report["summary"]["comparable_rules"] >= 70
assert "precision" in report["summary"]
assert "recall" in report["summary"]
assert "f1" in report["summary"]