astrologybazi-api / rule_contract.py
th
feat(bazi): add evidence-first dayun assessment
0d792fe
Raw History Blame Contribute Delete
14.1 kB
"""Auditable registry for the current Da Yun scoring signals.
This module is intentionally *not* imported by the production calculation
path. It describes what the existing heuristic scorer does, where each signal
is implemented, and what evidence it does (and does not) have. Keeping this
registry separate prevents a traditional rule, a library default, and an
empirical claim from being silently treated as the same kind of authority.
The registry is a guardrail for future changes, not a claim that the listed
rules predict life outcomes. Every weighted signal remains uncalibrated until
we have a blinded, labelled evaluation set.
"""
from __future__ import annotations
import re
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
RULE_CONTRACT_VERSION = "dayun-rules-v1"
SCORE_METHOD = "heuristic-v2-single-relation-source"
SCORE_CONFIDENCE = "low"
# A time split is a traditional attribution convention, not a measured
# probability of an event in one half of a decade.
TIME_ATTRIBUTION_POLICY: Dict[str, Any] = {
"stem": {
"window": "first_5_years",
"status": "heuristic",
"implementation": "dayun_analyzer.py:_judge_dayun_quality",
},
"branch": {
"window": "last_5_years",
"status": "heuristic",
"implementation": "dayun_analyzer.py:_judge_dayun_quality",
},
"sui_yun_bing_lin": {
"window": "matching_liunian_only",
"status": "event_marker_not_decade_weight",
"implementation": "dayun_analyzer.py:_find_sui_yun_bing_lin_years",
},
}
def _rule(
rule_id: str,
category: str,
description: str,
effect: str,
weight: Any,
implementation: str,
double_count_group: str,
*,
unit: Optional[str] = None,
evidence_status: str = "unvalidated",
source_kind: str = "traditional-heuristic",
source_refs: Sequence[str] = (),
notes: str = "",
) -> Dict[str, Any]:
"""Create one normalized rule entry.
An empty ``source_refs`` list is allowed when the only provenance is the
current code or an unverified traditional convention. The validator then
keeps the entry in the unvalidated bucket.
"""
return {
"id": rule_id,
"category": category,
"description": description,
"effect": effect,
"weight": weight,
"unit": unit or ("score_points" if effect in {"positive", "negative", "mixed"} else "policy"),
"implementation": implementation,
"double_count_group": double_count_group,
"evidence_status": evidence_status,
"source_kind": source_kind,
"source_refs": list(source_refs),
"notes": notes,
}
# This is a map of the current scorer, not a proposed list of new rules. The
# explicit groups make it possible to reject a future second 六合/冲/害
# implementation before it changes a user's score.
RULE_SPECS: Sequence[Mapping[str, Any]] = (
_rule(
"dayun.base.score", "baseline", "Start each decade at the neutral baseline",
"neutral", 50, "dayun_analyzer.py:_judge_dayun_quality", "baseline",
unit="score_points",
notes="Display score only; not a percentile or probability.",
),
_rule(
"dayun.stem.yongshen_element", "element_alignment", "Stem matches Yongshen element",
"positive", 18, "dayun_analyzer.py:_judge_dayun_quality", "stem_element",
notes="The five-year attribution is a traditional convention.",
),
_rule(
"dayun.stem.jishen_element", "element_alignment", "Stem matches Jishen element",
"negative", -15, "dayun_analyzer.py:_judge_dayun_quality", "stem_element",
),
_rule(
"dayun.branch.yongshen_element", "element_alignment", "Branch matches Yongshen element",
"positive", 22, "dayun_analyzer.py:_judge_dayun_quality", "branch_element",
notes="The five-year attribution is a traditional convention.",
),
_rule(
"dayun.branch.jishen_element", "element_alignment", "Branch matches Jishen element",
"negative", -18, "dayun_analyzer.py:_judge_dayun_quality", "branch_element",
),
_rule(
"dayun.siku.muku.weak", "siku", "Weak day master meets assigned tomb/storehouse",
"negative", -18, "dayun_analyzer.py:_judge_dayun_quality", "siku_role",
notes="Traditional structural signal; no outcome calibration yet.",
),
_rule(
"dayun.siku.muku.strong", "siku", "Strong day master meets assigned tomb/storehouse",
"positive", 10, "dayun_analyzer.py:_judge_dayun_quality", "siku_role",
),
_rule(
"dayun.siku.caiku.weak", "siku", "Weak day master meets assigned wealth storehouse",
"negative", -12, "dayun_analyzer.py:_judge_dayun_quality", "siku_role",
),
_rule(
"dayun.siku.caiku.strong", "siku", "Strong day master meets assigned wealth storehouse",
"positive", 20, "dayun_analyzer.py:_judge_dayun_quality", "siku_role",
),
_rule(
"dayun.siku.storage_yongshen", "siku", "Storehouse hidden element matches Yongshen",
"positive", 15, "dayun_analyzer.py:_judge_dayun_quality", "siku_role",
),
_rule(
"dayun.siku.storage_jishen", "siku", "Storehouse hidden element matches Jishen",
"negative", -12, "dayun_analyzer.py:_judge_dayun_quality", "siku_role",
),
_rule(
"dayun.siku.hidden_yongshen", "siku", "Neutral storehouse contains residual Yongshen qi",
"positive", 5, "dayun_analyzer.py:_judge_dayun_quality", "siku_role",
),
_rule(
"dayun.combination.both_yongshen", "combination", "Stem and branch both match Yongshen",
"positive", 15, "dayun_analyzer.py:_judge_dayun_quality", "element_combination",
),
_rule(
"dayun.combination.both_jishen", "combination", "Stem and branch both match Jishen",
"negative", -5, "dayun_analyzer.py:_judge_dayun_quality", "element_combination",
),
_rule(
"dayun.same_day_master_stem", "stem_relation", "Dayun stem equals day-master stem",
"positive", 8, "dayun_analyzer.py:_judge_dayun_quality", "stem_relation",
notes="Suppressed for the weak-storehouse risk guardrail.",
),
_rule(
"dayun.tian_ke_di_chong", "pillar_relation", "Dayun forms Tian Ke Di Chong with day pillar",
"negative", -25, "dayun_analyzer.py:_judge_dayun_quality", "pillar_relation",
),
_rule(
"dayun.fu_yin", "pillar_relation", "Dayun pillar repeats a natal pillar",
"negative", -15, "dayun_analyzer.py:_judge_dayun_quality", "pillar_relation",
),
_rule(
"dayun.shensha.tianyi", "shensha", "Tianyi Guiren marker",
"positive", 15, "dayun_analyzer.py:_judge_dayun_quality", "shensha",
notes="Auxiliary symbolic marker; never sufficient on its own.",
),
_rule(
"dayun.shensha.yima", "shensha", "Yima marker",
"positive", 8, "dayun_analyzer.py:_judge_dayun_quality", "shensha",
notes="Auxiliary symbolic marker; effect is not an outcome probability.",
),
_rule(
"dayun.shensha.taohua", "shensha", "Taohua/Xianchi marker",
"positive", 5, "dayun_analyzer.py:_judge_dayun_quality", "shensha",
notes="Auxiliary marker; relationship outcomes require reality checks.",
),
_rule(
"dayun.shensha.yangren", "shensha", "Yangren marker",
"negative", -10, "dayun_analyzer.py:_judge_dayun_quality", "shensha",
notes="Auxiliary marker; health language must stay non-diagnostic.",
),
_rule(
"dayun.shensha.kongwang", "shensha", "Kongwang marker",
"negative", -12, "dayun_analyzer.py:_judge_dayun_quality", "shensha",
notes="Auxiliary marker; not independently validated.",
),
_rule(
"dayun.tengod.bijie", "ten_god", "Fellow/rob-wealth ten-god is favorable",
"positive", 12, "dayun_analyzer.py:_judge_dayun_quality", "ten_god",
),
_rule(
"dayun.tengod.wealth", "ten_god", "Wealth ten-god is favorable",
"positive", 15, "dayun_analyzer.py:_judge_dayun_quality", "ten_god",
),
_rule(
"dayun.tengod.official", "ten_god", "Officer/Seven-killings ten-god is favorable",
"positive", 15, "dayun_analyzer.py:_judge_dayun_quality", "ten_god",
),
_rule(
"dayun.tengod.resource", "ten_god", "Resource ten-god is favorable",
"positive", 15, "dayun_analyzer.py:_judge_dayun_quality", "ten_god",
),
_rule(
"dayun.relationship.liuhe", "relationship", "Liuhe adjustment from single calculator",
"positive", 10, "xingchonghehe_calculator.py:check_dayun_relationship", "relationship_calculator",
notes="Only this calculator may apply Liuhe/Chong/Hai/Xing/Sanhe weights.",
),
_rule(
"dayun.relationship.risky_he", "relationship", "Risky Liuhe adjustment from single calculator",
"positive", 5, "xingchonghehe_calculator.py:check_dayun_relationship", "relationship_calculator",
),
_rule(
"dayun.relationship.chong", "relationship", "Liu Chong adjustment by severity",
"negative", "-severity*5", "xingchonghehe_calculator.py:check_dayun_relationship", "relationship_calculator",
),
_rule(
"dayun.relationship.hai", "relationship", "Liu Hai adjustment by severity",
"negative", "-severity*2", "xingchonghehe_calculator.py:check_dayun_relationship", "relationship_calculator",
),
_rule(
"dayun.relationship.xing", "relationship", "New Xing adjustment by severity",
"negative", "-severity*5", "xingchonghehe_calculator.py:check_dayun_relationship", "relationship_calculator",
),
_rule(
"dayun.relationship.sanhe_half", "relationship", "New half-Sanhe adjustment before sign",
"mixed", 12, "xingchonghehe_calculator.py:check_dayun_relationship", "relationship_calculator",
),
_rule(
"dayun.relationship.sanhe_completion", "relationship", "Half-Sanhe to completed-Sanhe adjustment",
"mixed", 8, "xingchonghehe_calculator.py:check_dayun_relationship", "relationship_calculator",
),
_rule(
"dayun.relationship.sanhe_full", "relationship", "New completed-Sanhe adjustment before sign",
"mixed", 20, "xingchonghehe_calculator.py:check_dayun_relationship", "relationship_calculator",
),
_rule(
"dayun.score.clamp", "output_guardrail", "Clamp heuristic score to display range 0..100",
"neutral", "0..100", "dayun_analyzer.py:_judge_dayun_quality", "output_guardrail",
notes="Display bound, not probability calibration.",
),
)
RULES_BY_ID: Dict[str, Mapping[str, Any]] = {
str(rule["id"]): rule for rule in RULE_SPECS
}
_RULE_ID_PATTERN = re.compile(r"^[a-z0-9]+(?:[._-][a-z0-9]+)+$")
_EVIDENCE_STATUSES = {
"unvalidated", "calendar-verified", "shadow-checked", "empirically-calibrated",
}
_SOURCE_KINDS = {
"traditional-heuristic", "internal-code", "external-calendar", "shadow-implementation",
}
def weighted_rule_ids() -> List[str]:
"""Return rules which can change a score, excluding policy-only entries."""
return [str(rule["id"]) for rule in RULE_SPECS if rule["unit"] == "score_points"]
def validate_rule_contract(rules: Iterable[Mapping[str, Any]] = RULE_SPECS) -> Dict[str, Any]:
"""Validate registry invariants and return a machine-readable report."""
entries = list(rules)
errors: List[str] = []
seen: set[str] = set()
for index, rule in enumerate(entries):
prefix = f"rules[{index}]"
rule_id = str(rule.get("id", ""))
if not rule_id or not _RULE_ID_PATTERN.fullmatch(rule_id):
errors.append(f"{prefix}.id is missing or malformed: {rule_id!r}")
if rule_id in seen:
errors.append(f"duplicate rule id: {rule_id}")
seen.add(rule_id)
for key in ("category", "description", "effect", "implementation", "double_count_group"):
if not rule.get(key):
errors.append(f"{prefix}.{key} is required")
if rule.get("effect") not in {"neutral", "positive", "negative", "mixed"}:
errors.append(f"{prefix}.effect is invalid: {rule.get('effect')!r}")
if rule.get("evidence_status") not in _EVIDENCE_STATUSES:
errors.append(f"{prefix}.evidence_status is invalid: {rule.get('evidence_status')!r}")
if rule.get("source_kind") not in _SOURCE_KINDS:
errors.append(f"{prefix}.source_kind is invalid: {rule.get('source_kind')!r}")
if rule.get("unit") == "score_points" and rule.get("weight") is None:
errors.append(f"{prefix}.weighted rule has no weight")
if rule.get("evidence_status") == "empirically-calibrated":
errors.append(f"{prefix} cannot claim empirical calibration before a labelled protocol")
group_to_ids: Dict[str, List[str]] = {}
for rule in entries:
group = str(rule.get("double_count_group", ""))
group_to_ids.setdefault(group, []).append(str(rule.get("id", "")))
weighted_unvalidated = [
str(rule["id"])
for rule in entries
if rule.get("unit") == "score_points"
and rule.get("evidence_status") != "empirically-calibrated"
]
return {
"version": RULE_CONTRACT_VERSION,
"score_method": SCORE_METHOD,
"score_confidence": SCORE_CONFIDENCE,
"passed": not errors,
"rule_count": len(entries),
"weighted_rule_count": len(weighted_unvalidated),
"weighted_unvalidated": weighted_unvalidated,
"double_count_groups": group_to_ids,
"time_attribution_policy": TIME_ATTRIBUTION_POLICY,
"errors": errors,
}
def get_rule(rule_id: str) -> Mapping[str, Any]:
"""Return one rule or raise a useful error for an unregistered rule."""
try:
return RULES_BY_ID[rule_id]
except KeyError as exc:
raise KeyError(f"Unregistered Dayun rule: {rule_id}") from exc
if __name__ == "__main__":
import json
print(json.dumps(validate_rule_contract(), ensure_ascii=False, indent=2))