feat: score dynamic birth time choices

This commit is contained in:
Jesse_Chen
2026-07-19 00:37:13 +08:00
parent 883d8dcba2
commit 4abd1e3b6d
5 changed files with 1073 additions and 0 deletions
+65
View File
@@ -0,0 +1,65 @@
# Task 2 — Deterministic Candidate Opportunities and Choice Scoring
## Implementation
- Added the versioned `birth-time-choice-scoring-v2` engine for reusable minute candidates, bounded life-stage windows, candidate-backed partitions, normalized information gain, and deterministic choice adjudication.
- Reused the existing local chart, D4/D9/D10/D24/D30, Vimshottari, and Narayana calculation path. Each candidate chart is computed once for the complete synthetic window set, then its activation rows are reused across dimensions.
- Persisted candidate models are strictly rebound to birth date, `as_of_date`, range, canonical candidate minutes, supported dimensions, bounded window dates, finite non-boolean activation values, and mandatory-layer shape before reuse.
- Fingerprint inputs contain only the scoring version, dimension code, ISO window boundaries, and sorted candidate memberships. User-facing prose never enters a hash basis.
- Added deterministic high/medium/low gates. Only high confidence can set `can_apply=true`; low and medium remain non-applicable. Public evidence is always empty and compatibility counts mirror effective answers/dimensions.
- Unknown, unmatched, free-text, client `option_id`, duplicate questions, empty server identifiers, unsupported dimensions, out-of-range candidate keys, negative/non-finite scores, and more than 10 effective evidence rows are rejected before scoring.
- Added strict legacy-safe POST routing for `/api/dynamic_rectification_opportunities` and `/api/dynamic_rectification_score`; existing active-rectification endpoints and their behavior were not changed.
## Files changed
- `scripts/dynamic_rectification.py`
- `scripts/jyotish_api_server.py`
- `tests/test_dynamic_rectification.py`
- `tests/test_active_rectification_api.py`
- `.superpowers/sdd/task-2-report.md`
## RED
1. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py -k packet`
- Collection failed as expected with `ImportError: cannot import name 'dynamic_rectification' from 'scripts'`.
2. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py -k 'primary_choice or unknown or high_confidence'`
- Three tests failed as expected because `score_choice_evidence` and `adjudicate_choice_rows` did not exist.
3. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_active_rectification_api.py -k dynamic`
- Four tests failed as expected because both dynamic API handler methods did not exist.
4. Candidate-model hardening regressions failed before their fixes: out-of-bounds windows and boolean activations were accepted, unmatched text was silently ignored, and semantically identical score maps were rejected when JSON key order differed.
5. The candidate reuse regression showed a missing D10 layer incorrectly blocking every dimension instead of career only.
6. The persisted-clock edge regression produced an invalid window ending before it began on the exact twelfth birthday; the under-age regression also showed unnecessary chart computation before age 12.
7. Final strict-boundary self-review reproduced acceptance of a negative candidate score and an empty persisted partition ID; both are now rejected before score accumulation.
## GREEN
1. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py tests/test_active_rectification_api.py tests/test_active_rectification_questions.py tests/test_active_rectification_events.py`
- `38` passed, `0` failed.
2. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m ruff check scripts/dynamic_rectification.py tests/test_dynamic_rectification.py tests/test_active_rectification_api.py`
- Passed with no diagnostics.
3. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m compileall -q scripts/dynamic_rectification.py scripts/jyotish_api_server.py`
- Passed.
4. `git diff --check`
- Passed with no whitespace errors.
5. Real local-engine smoke using persisted `as_of_date=2026-07-18`, range `05:30—05:31`
- Built version `birth-time-choice-scoring-v2`, `2` candidate minutes, and `20` bounded dimension/window activation rows. It correctly returned no opportunity when those two real candidates had no scoreable partition gain.
## Pre-work gate
- Ran `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`.
- The gate remained red only on the unrelated fragment-governance assertion: `candidate_count` expected `0`, observed `2`. Remote visibility was also reported as blocked, so no cloud-sync claim is made.
## Self-review
- Candidate generation owns one versioned deterministic rectification boundary and delegates chart/Dasha mathematics to the existing engine rather than duplicating it.
- Untrusted HTTP payloads are allowlisted at the API boundary; persisted candidate models and service-resolved evidence are parsed again at the deterministic module boundary before expensive computation or scoring.
- Candidate-model reuse is deterministic across days because every window derives from persisted `as_of_date`; the process clock is never read.
- Effective evidence is the only scored input. Answered-count/UI semantics remain outside the scorer, so unknown and unmatched choices cannot become score evidence.
- Hash bases were manually inspected and contain no descriptors, labels, prompts, notes, or other prose.
- Legacy endpoints remain byte-for-byte unchanged except for adjacent registration of the two new routes; the full legacy focused suites stayed green.
- No dependencies, logging, mutable module state, broad exception handlers, or model-controlled confidence fields were introduced.
## Concerns
- `scripts/dynamic_rectification.py` is above the optional 250 pure-LOC design guideline because the approved Task 2 ownership explicitly requires candidate generation, persisted-model validation, opportunity construction, and versioned scoring in this single module. Splitting it would require expanding the approved file ownership; the code is separated into small pure helpers meanwhile.
- Full-file Ruff on `scripts/jyotish_api_server.py` still reports inherited baseline debt (import ordering, legacy f-strings, an existing undefined `swe`, and other unrelated diagnostics). Ruff is clean for the new module and both modified test files; compileall and all focused suites pass.
+411
View File
@@ -0,0 +1,411 @@
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
# ─── How to run ───
# .venv/bin/python -m pytest -q tests/test_dynamic_rectification.py
"""Candidate-backed opportunities and deterministic dynamic-choice scoring."""
from __future__ import annotations
import hashlib
import json
import math
from collections import defaultdict
from collections.abc import Mapping, Sequence
from datetime import date, datetime, time, timedelta
from typing import Final, Literal, TypedDict
from uuid import NAMESPACE_URL, UUID, uuid5
ALGORITHM_VERSION: Final = "birth-time-choice-scoring-v2"
MIN_INFORMATION_GAIN: Final = 0.15
SUPPORTED_DIMENSIONS: Final = frozenset(
{"education", "relocation", "relationship", "career", "health_pressure"}
)
class ChoiceRow(TypedDict):
time: str
score: float
class WinningSegment(TypedDict):
start_time: str
end_time: str
representative_time: str
width_minutes: int
Confidence = Literal["low", "medium", "high"]
def _canonical_hash(value: Mapping | Sequence) -> str:
encoded = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _candidate_times(birth_date: str, start_time: str, end_time: str) -> list[str]:
day = date.fromisoformat(birth_date)
start = datetime.combine(day, time.fromisoformat(start_time))
end = datetime.combine(day, time.fromisoformat(end_time))
if end < start:
end += timedelta(days=1)
count = int((end - start).total_seconds() // 60) + 1
if not 1 <= count <= 1_440:
raise ValueError("candidate range must contain between 1 and 1440 minutes")
return [(start + timedelta(minutes=offset)).strftime("%H:%M") for offset in range(count)]
def _experience_windows(birth_date: str, as_of_date: str) -> list[tuple[date, date]]:
born = date.fromisoformat(birth_date)
as_of = date.fromisoformat(as_of_date)
try:
first = born.replace(year=born.year + 12)
except ValueError:
first = born.replace(year=born.year + 12, day=28)
if as_of < first:
return []
day_count = (as_of - first).days + 1
window_count = min(4, day_count, max(2, math.ceil(day_count / (6 * 365))))
boundaries = [first + timedelta(days=day_count * index // window_count) for index in range(window_count)]
return [
(start, as_of if index == window_count - 1 else boundaries[index + 1] - timedelta(days=1))
for index, start in enumerate(boundaries)
]
def _candidate_window_rows(request: dict) -> list[dict]:
"""Compute each candidate chart once and reuse it for every dimension/window."""
from scripts.active_rectification_event_engine import (
DOMAIN_CONFIG,
_candidate_datetimes,
_candidate_row,
)
windows = _experience_windows(request["birth_date"], request["as_of_date"])
if not windows:
return []
synthetic_events = []
event_windows: dict[str, tuple[str, date, date]] = {}
for dimension in sorted(SUPPORTED_DIMENSIONS):
for window_start, window_end in windows:
event_id = str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{dimension}:{window_start}:{window_end}"))
midpoint = window_start + (window_end - window_start) / 2
synthetic_events.append(
{"id": event_id, "domain": dimension, "date": midpoint.isoformat(), "precision": "day"}
)
event_windows[event_id] = (dimension, window_start, window_end)
calculation_request = {
"birth_date": request["birth_date"],
"start_time": request["start_time"],
"end_time": request["end_time"],
"lat": request["lat"],
"lon": request["lon"],
"tz": request["tz"],
"events": synthetic_events,
}
rows = [_candidate_row(calculation_request, candidate) for candidate in _candidate_datetimes(calculation_request)]
activations = {
event_id: {row["time"]: 0.0 for row in rows}
for event_id in event_windows
}
missing_layers = sorted({layer for row in rows for layer in row["missing_layers"]})
for row in rows:
for evidence in row["evidence"]:
activations[evidence["event_id"]][row["time"]] = float(evidence["points"])
return [
{
"dimension_code": dimension,
"window_start": window_start.isoformat(),
"window_end": window_end.isoformat(),
"activations": activations[event_id],
"missing_layers": [DOMAIN_CONFIG[dimension][0]]
if DOMAIN_CONFIG[dimension][0] in missing_layers else [],
}
for event_id, (dimension, window_start, window_end) in event_windows.items()
]
def _compute_candidate_model(request: dict) -> dict:
return {
"version": ALGORITHM_VERSION,
"birth_date": request["birth_date"],
"as_of_date": request["as_of_date"],
"range": {"start_time": request["start_time"], "end_time": request["end_time"]},
"candidate_times": _candidate_times(request["birth_date"], request["start_time"], request["end_time"]),
"windows": _candidate_window_rows(request),
}
def _validate_candidate_model(model: dict, request: dict) -> dict:
expected_keys = {"version", "birth_date", "as_of_date", "range", "candidate_times", "windows"}
candidates = _candidate_times(request["birth_date"], request["start_time"], request["end_time"])
try:
valid_header = (
set(model) == expected_keys
and model["version"] == ALGORITHM_VERSION
and model["birth_date"] == request["birth_date"]
and model["as_of_date"] == request["as_of_date"]
and model["range"] == {"start_time": request["start_time"], "end_time": request["end_time"]}
and model["candidate_times"] == candidates
and isinstance(model["windows"], list)
)
first_window = _experience_windows(request["birth_date"], request["as_of_date"])
minimum_date = first_window[0][0] if first_window else date.max
maximum_date = date.fromisoformat(request["as_of_date"])
window_keys = [
(row.get("dimension_code"), row.get("window_start"), row.get("window_end"))
for row in model["windows"] if isinstance(row, dict)
]
valid_windows = len(window_keys) == len(set(window_keys)) and all(
isinstance(row, dict)
and set(row) == {"dimension_code", "window_start", "window_end", "activations", "missing_layers"}
and row["dimension_code"] in SUPPORTED_DIMENSIONS
and minimum_date <= date.fromisoformat(row["window_start"])
<= date.fromisoformat(row["window_end"]) <= maximum_date
and isinstance(row["activations"], dict)
and set(row["activations"]) == set(candidates)
and all(
not isinstance(score, bool)
and isinstance(score, int | float)
and math.isfinite(score)
and score >= 0
for score in row["activations"].values()
)
and isinstance(row["missing_layers"], list)
and all(isinstance(layer, str) and layer for layer in row["missing_layers"])
for row in model["windows"]
)
except (KeyError, TypeError, ValueError):
valid_header = valid_windows = False
if not valid_header or not valid_windows:
raise ValueError("candidate model does not match the submitted request")
return model
def _opportunities(model: dict) -> list[dict]:
grouped: dict[str, list[dict]] = defaultdict(list)
for row in model["windows"]:
if not row["missing_layers"]:
grouped[row["dimension_code"]].append(row)
opportunities = []
candidates = model["candidate_times"]
for dimension, windows in sorted(grouped.items()):
memberships: dict[int, list[str]] = defaultdict(list)
for candidate in candidates:
winner = max(range(len(windows)), key=lambda index: (windows[index]["activations"][candidate], -index))
memberships[winner].append(candidate)
populated = [(windows[index], members) for index, members in sorted(memberships.items()) if members]
if not 2 <= len(populated) <= 4:
continue
probabilities = [len(members) / len(candidates) for _, members in populated]
information_gain = -sum(value * math.log(value) for value in probabilities) / math.log(len(populated))
if information_gain < MIN_INFORMATION_GAIN:
continue
partition_basis = []
partitions = []
for window, members in populated:
basis = {
"version": ALGORITHM_VERSION,
"dimension": dimension,
"window_start": window["window_start"],
"window_end": window["window_end"],
"members": sorted(members),
}
partition_basis.append(basis)
partitions.append(
{
"partition_id": _canonical_hash(basis),
"descriptor": f"{window['window_start']}--{window['window_end']}",
"fallback_label": f"{window['window_start'][:4]}{window['window_end'][:4]}",
"candidate_scores": {candidate: 1.0 if candidate in members else 0.0 for candidate in candidates},
}
)
fingerprint = _canonical_hash({"version": ALGORITHM_VERSION, "partitions": partition_basis})
opportunities.append(
{
"opportunity_id": _canonical_hash({"version": ALGORITHM_VERSION, "dimension": dimension, "partitions": partition_basis}),
"dimension_code": dimension,
"neutral_context": dimension,
"estimated_information_gain": round(information_gain, 6),
"candidate_partition_fingerprint": fingerprint,
"fallback_prompt": f"下面哪个时间段更接近你在 {dimension} 方面的明显变化?",
"partitions": partitions,
}
)
return sorted(opportunities, key=lambda item: (-item["estimated_information_gain"], item["opportunity_id"]))
def build_difference_packet(request: dict) -> dict:
"""Build reusable candidate activations and unused high-gain opportunities."""
candidates = _candidate_times(request["birth_date"], request["start_time"], request["end_time"])
_validated_choice_evidence(request.get("evidence"), candidates)
model = request.get("candidate_model")
candidate_model = _compute_candidate_model(request) if model is None else _validate_candidate_model(model, request)
dismissed = set(request.get("dismissed_opportunity_ids", []))
fingerprints = set(request.get("partition_fingerprints", []))
opportunities = [
item for item in _opportunities(candidate_model)
if item["opportunity_id"] not in dismissed
and item["candidate_partition_fingerprint"] not in fingerprints
]
return {
"case_id": request["case_id"],
"scoring_version": ALGORITHM_VERSION,
"current_range": {"start_time": request["start_time"], "end_time": request["end_time"]},
"opportunities": opportunities,
"asked_question_fingerprints": list(request.get("question_fingerprints", [])),
"candidate_partition_fingerprints": list(request.get("partition_fingerprints", [])),
"recent_range_history": list(request.get("recent_ranges", [])),
"candidate_model": candidate_model,
}
def _minute_value(value: str) -> int:
hour, minute = value.split(":", maxsplit=1)
return int(hour) * 60 + int(minute)
def _winning_segments(rows: Sequence[ChoiceRow], top_score: float) -> list[list[ChoiceRow]]:
segments: list[list[ChoiceRow]] = []
for row in rows:
if row["score"] != top_score:
continue
follows = segments and (
_minute_value(row["time"]) - _minute_value(segments[-1][-1]["time"])
) % 1_440 == 1
if follows:
segments[-1].append(row)
else:
segments.append([row])
return segments
def adjudicate_choice_rows(
rows: Sequence[ChoiceRow], *, effective_answer_count: int, dimension_count: int,
missing_layers: Sequence[str], request_fingerprint: str = "",
) -> dict:
"""Apply v2 confidence gates to precomputed effective choice evidence."""
ranked = sorted(rows, key=lambda row: _minute_value(row["time"]))
scores = sorted({row["score"] for row in ranked}, reverse=True)
top_score = scores[0] if scores else 0.0
second_score = scores[1] if len(scores) > 1 else top_score
segments = _winning_segments(ranked, top_score) if ranked else []
winning_rows = segments[0] if len(segments) == 1 else []
segment: WinningSegment | None = None
if winning_rows:
segment = {
"start_time": winning_rows[0]["time"],
"end_time": winning_rows[-1]["time"],
"representative_time": winning_rows[(len(winning_rows) - 1) // 2]["time"],
"width_minutes": len(winning_rows),
}
margin = round((top_score - second_score) / max(abs(top_score), 1.0) * 100, 2)
blocked = len(segments) != 1 or bool(missing_layers)
high = (
not blocked and effective_answer_count >= 4 and dimension_count >= 3
and segment is not None and segment["width_minutes"] <= 5 and margin >= 20
)
medium = (
not blocked and effective_answer_count >= 3 and dimension_count >= 2
and segment is not None and segment["width_minutes"] <= 15 and margin >= 10
)
confidence: Confidence = "high" if high else "medium" if medium else "low"
reasons = []
if len(segments) != 1:
reasons.append("tied_leader" if segments else "no_candidate_rows")
if missing_layers:
reasons.append("missing_mandatory_layers")
if confidence == "low" and effective_answer_count < 3:
reasons.append("insufficient_effective_evidence")
fingerprint = request_fingerprint or _canonical_hash(list(ranked))
return {
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")),
"confidence": confidence,
"can_apply": confidence == "high",
"winning_segment": segment,
"event_count": effective_answer_count,
"domain_count": dimension_count,
"top_score": top_score,
"second_score": second_score,
"margin_percent": margin,
"reasons": reasons,
"evidence": [],
"algorithm_version": ALGORITHM_VERSION,
"evidence_mode": "dynamic_choice",
"effective_answer_count": effective_answer_count,
"dimension_count": dimension_count,
}
def _validated_choice_evidence(evidence_rows: list | None, candidates: Sequence[str]) -> tuple[list[dict], set[str]]:
if not isinstance(evidence_rows, list):
raise ValueError("choice evidence must contain partition evidence")
if len(evidence_rows) > 10:
raise ValueError("choice evidence may contain at most 10 rows")
question_ids: set[str] = set()
dimensions: set[str] = set()
required = {
"question_id", "opportunity_id", "partition_id", "dimension_code",
"candidate_scores", "information_gain",
}
for evidence in evidence_rows:
if not isinstance(evidence, dict) or set(evidence) != required:
field = "option_id" if isinstance(evidence, dict) and "option_id" in evidence else "partition evidence"
raise ValueError(f"choice evidence contains invalid {field}")
try:
UUID(evidence["question_id"])
except (ValueError, TypeError, AttributeError) as exc:
raise ValueError("partition evidence question_id must be a UUID") from exc
if evidence["question_id"] in question_ids:
raise ValueError("duplicate question evidence is not allowed")
if any(
not isinstance(evidence[key], str) or not evidence[key]
for key in ("opportunity_id", "partition_id")
):
raise ValueError("partition evidence identifier must be a non-empty string")
scores = evidence["candidate_scores"]
gain = evidence["information_gain"]
valid_scores = isinstance(scores, dict) and set(scores) == set(candidates) and all(
not isinstance(score, bool)
and isinstance(score, int | float)
and math.isfinite(score)
and score >= 0
for score in scores.values()
)
if not valid_scores:
raise ValueError("candidate scores must exactly match the submitted range")
if evidence["dimension_code"] not in SUPPORTED_DIMENSIONS:
raise ValueError("choice evidence dimension is unsupported")
if (
isinstance(gain, bool) or not isinstance(gain, int | float)
or not math.isfinite(gain) or gain < 0
):
raise ValueError("choice evidence information gain must be finite")
question_ids.add(evidence["question_id"])
dimensions.add(evidence["dimension_code"])
return evidence_rows, dimensions
def score_choice_evidence(request: dict) -> dict:
"""Sum only strict server-resolved primary evidence, then adjudicate it."""
candidates = _candidate_times(request["birth_date"], request["start_time"], request["end_time"])
evidence_rows, dimensions = _validated_choice_evidence(
request.get("choice_evidence"), candidates
)
totals = {candidate: 0.0 for candidate in candidates}
for evidence in evidence_rows:
scores = evidence["candidate_scores"]
gain = evidence["information_gain"]
for candidate in candidates:
totals[candidate] += float(scores[candidate]) * float(gain)
rows: list[ChoiceRow] = [
{"time": candidate, "score": round(score, 6)} for candidate, score in totals.items()
]
return adjudicate_choice_rows(
rows,
effective_answer_count=len(evidence_rows),
dimension_count=len(dimensions),
missing_layers=[],
request_fingerprint=_canonical_hash(request),
)
+98
View File
@@ -1286,6 +1286,8 @@ API_COMMAND_MAP = {
'active-rectification-questions': '/api/active_rectification_questions',
'active-rectification-score': '/api/active_rectification_score',
'active-rectification-events': '/api/active_rectification_events',
'dynamic-rectification-opportunities': '/api/dynamic_rectification_opportunities',
'dynamic-rectification-score': '/api/dynamic_rectification_score',
'case-validation': '/api/case_validation',
'divisional-yoga': '/api/divisional_yoga',
'deep-varga-avastha': '/api/deep_varga_avastha',
@@ -1320,6 +1322,8 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = {
'/api/active_rectification_questions',
'/api/active_rectification_score',
'/api/active_rectification_events',
'/api/dynamic_rectification_opportunities',
'/api/dynamic_rectification_score',
'/api/relationship',
'/api/remedies',
'/api/sade_sati',
@@ -1745,6 +1749,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
elif path == '/api/active_rectification_events':
result = self._compute_active_rectification_events(body)
self._json(result)
elif path == '/api/dynamic_rectification_opportunities':
result = self._compute_dynamic_rectification_opportunities(body)
self._json(result)
elif path == '/api/dynamic_rectification_score':
result = self._compute_dynamic_rectification_score(body)
self._json(result)
elif path == '/api/case_validation':
result = self._compute_case_validation(body)
self._json(result)
@@ -6888,6 +6898,90 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
**result,
}
def _dynamic_rectification_base(self, body, allowed_fields, field_label):
unsupported_fields = sorted(set(body) - allowed_fields)
if unsupported_fields:
raise BadRequest(f'unsupported dynamic rectification {field_label} field: {unsupported_fields[0]}')
birth_date = body.get('birth_date')
start_time = body.get('start_time')
end_time = body.get('end_time')
if not isinstance(birth_date, str):
raise BadRequest('birth_date must be YYYY-MM-DD')
if not isinstance(start_time, str) or not isinstance(end_time, str):
raise BadRequest('candidate times must be HH:MM')
try:
datetime.strptime(birth_date, '%Y-%m-%d')
datetime.strptime(start_time, '%H:%M')
datetime.strptime(end_time, '%H:%M')
except ValueError as exc:
raise BadRequest('birth date or candidate time has invalid format') from exc
return {
**body,
'birth_date': birth_date,
'start_time': start_time,
'end_time': end_time,
'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),
}
def _compute_dynamic_rectification_opportunities(self, body):
allowed_fields = {
'case_id', 'birth_date', 'as_of_date', 'start_time', 'end_time',
'lat', 'lon', 'tz', 'candidate_model', 'evidence',
'dismissed_opportunity_ids', 'question_fingerprints',
'partition_fingerprints', 'recent_ranges',
}
normalized = self._dynamic_rectification_base(body, allowed_fields, 'opportunity')
case_id = normalized.get('case_id')
as_of_date = normalized.get('as_of_date')
if not isinstance(case_id, str) or not case_id.strip():
raise BadRequest('case_id must be a non-empty string')
if not isinstance(as_of_date, str):
raise BadRequest('as_of_date must be YYYY-MM-DD')
try:
datetime.strptime(as_of_date, '%Y-%m-%d')
except ValueError as exc:
raise BadRequest('as_of_date must be YYYY-MM-DD') from exc
for key in ('evidence', 'dismissed_opportunity_ids', 'question_fingerprints', 'partition_fingerprints', 'recent_ranges'):
if not isinstance(normalized.get(key), list):
raise BadRequest(f'{key} must be an array')
if any(not isinstance(item, str) or not item for key in ('dismissed_opportunity_ids', 'question_fingerprints', 'partition_fingerprints') for item in normalized[key]):
raise BadRequest('dynamic rectification fingerprints and IDs must be non-empty strings')
for item in normalized['recent_ranges']:
if not isinstance(item, dict) or set(item) != {'start_time', 'end_time'}:
raise BadRequest('recent_ranges must contain only start_time and end_time')
try:
datetime.strptime(item['start_time'], '%H:%M')
datetime.strptime(item['end_time'], '%H:%M')
except (KeyError, TypeError, ValueError) as exc:
raise BadRequest('recent_ranges must contain valid HH:MM times') from exc
if normalized.get('candidate_model') is not None and not isinstance(normalized['candidate_model'], dict):
raise BadRequest('candidate_model must be an object')
module = _load_local_module('dynamic_rectification')
try:
result = module.build_difference_packet(normalized)
except (KeyError, TypeError, ValueError) as exc:
raise BadRequest(str(exc)) from exc
return {'success': True, 'endpoint': 'dynamic_rectification_opportunities', **result}
def _compute_dynamic_rectification_score(self, body):
allowed_fields = {
'birth_date', 'start_time', 'end_time', 'lat', 'lon', 'tz', 'choice_evidence',
}
normalized = self._dynamic_rectification_base(body, allowed_fields, 'score')
evidence = normalized.get('choice_evidence')
if not isinstance(evidence, list):
raise BadRequest('choice_evidence must be an array')
if any(isinstance(item, dict) and 'option_id' in item for item in evidence):
raise BadRequest('option_id is client-owned and cannot be scored')
module = _load_local_module('dynamic_rectification')
try:
result = module.score_choice_evidence(normalized)
except (KeyError, TypeError, ValueError) as exc:
raise BadRequest(str(exc)) from exc
return {'success': True, 'endpoint': 'dynamic_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', ''))
@@ -7651,6 +7745,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'/api/active_rectification_questions': self._compute_active_rectification_questions,
'/api/active_rectification_score': self._compute_active_rectification_score,
'/api/active_rectification_events': self._compute_active_rectification_events,
'/api/dynamic_rectification_opportunities': self._compute_dynamic_rectification_opportunities,
'/api/dynamic_rectification_score': self._compute_dynamic_rectification_score,
'/api/relationship': self._compute_relationship,
'/api/remedies': self._compute_remedies,
'/api/sade_sati': self._compute_sade_sati,
@@ -7776,6 +7872,8 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
'/api/prashna': 'Compute Prashna chart and answer evidence',
'/api/rectification_gate': 'Evaluate birth-time precision gate',
'/api/active_rectification_events': 'Score dated life events against actual birth-time candidates',
'/api/dynamic_rectification_opportunities': 'Build candidate-backed dynamic rectification opportunities',
'/api/dynamic_rectification_score': 'Score server-resolved dynamic rectification choices',
'/api/relationship': 'Compute relationship and spouse-status evidence',
'/api/remedies': 'Generate low-risk remedies from doshas/strength/dasha',
'/api/sade_sati': 'Compute Sade Sati status and phase',
+125
View File
@@ -9,6 +9,7 @@ SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
if str(SCRIPTS) not in sys.path:
sys.path.insert(0, str(SCRIPTS))
import jyotish_api_server as api_server # noqa: E402
from jyotish_api_server import BadRequest, JyotishAPIHandler # noqa: E402
@@ -137,3 +138,127 @@ def test_active_rectification_events_api_rejects_client_scores() -> None:
"events": [],
"confidence": "high",
})
def _dynamic_base() -> dict:
return {
"case_id": "case-1",
"birth_date": "1990-01-01",
"as_of_date": "2026-07-18",
"start_time": "05:30",
"end_time": "05:33",
"lat": 31.23,
"lon": 121.47,
"tz": 8.0,
"evidence": [],
"dismissed_opportunity_ids": [],
"question_fingerprints": [],
"partition_fingerprints": [],
"recent_ranges": [],
}
def test_dynamic_opportunities_api_accepts_only_server_contract(monkeypatch) -> None:
captured: list[dict] = []
class FakeDynamicModule:
@staticmethod
def build_difference_packet(payload: dict) -> dict:
captured.append(payload)
return {
"case_id": payload["case_id"],
"scoring_version": "birth-time-choice-scoring-v2",
"current_range": {"start_time": payload["start_time"], "end_time": payload["end_time"]},
"opportunities": [],
"asked_question_fingerprints": [],
"candidate_partition_fingerprints": [],
"recent_range_history": [],
"candidate_model": {},
}
monkeypatch.setattr(api_server, "_load_local_module", lambda _name: FakeDynamicModule)
result = _handler()._compute_dynamic_rectification_opportunities(_dynamic_base())
assert result["success"] is True
assert result["endpoint"] == "dynamic_rectification_opportunities"
assert captured[0]["as_of_date"] == "2026-07-18"
assert captured[0]["lat"] == 31.23
def test_dynamic_opportunities_api_rejects_missing_clock_and_untrusted_fields() -> None:
missing_date = _dynamic_base()
del missing_date["as_of_date"]
with pytest.raises(BadRequest, match="as_of_date"):
_handler()._compute_dynamic_rectification_opportunities(missing_date)
with pytest.raises(BadRequest, match="unsupported dynamic rectification opportunity field"):
_handler()._compute_dynamic_rectification_opportunities(
{**_dynamic_base(), "confidence": "high"}
)
with pytest.raises(BadRequest, match="recent_ranges"):
_handler()._compute_dynamic_rectification_opportunities(
{**_dynamic_base(), "recent_ranges": [{"start_time": "05:30", "extra": "05:33"}]}
)
with pytest.raises(BadRequest, match="partition evidence"):
_handler()._compute_dynamic_rectification_opportunities(
{**_dynamic_base(), "evidence": [{"kind": "unknown"}]}
)
def test_dynamic_score_api_rejects_client_option_ids_before_scoring() -> None:
with pytest.raises(BadRequest, match="option_id"):
_handler()._compute_dynamic_rectification_score(
{
"birth_date": "1990-01-01",
"start_time": "05:30",
"end_time": "05:33",
"lat": 31.23,
"lon": 121.47,
"tz": 8.0,
"choice_evidence": [{"option_id": "client-owned"}],
}
)
def test_dynamic_score_api_returns_versioned_candidate_result(monkeypatch) -> None:
class FakeDynamicModule:
@staticmethod
def score_choice_evidence(_payload: dict) -> dict:
return {
"result_id": "result-1",
"confidence": "low",
"can_apply": False,
"winning_segment": None,
"event_count": 0,
"domain_count": 0,
"top_score": 0.0,
"second_score": 0.0,
"margin_percent": 0.0,
"reasons": ["insufficient_effective_evidence"],
"evidence": [],
"algorithm_version": "birth-time-choice-scoring-v2",
"evidence_mode": "dynamic_choice",
"effective_answer_count": 0,
"dimension_count": 0,
}
monkeypatch.setattr(api_server, "_load_local_module", lambda _name: FakeDynamicModule)
result = _handler()._compute_dynamic_rectification_score(
{
"birth_date": "1990-01-01",
"start_time": "05:30",
"end_time": "05:33",
"lat": 31.23,
"lon": 121.47,
"tz": 8.0,
"choice_evidence": [],
}
)
assert result["success"] is True
assert result["endpoint"] == "dynamic_rectification_score"
assert result["algorithm_version"] == "birth-time-choice-scoring-v2"
+374
View File
@@ -0,0 +1,374 @@
from __future__ import annotations
from datetime import date, datetime
from uuid import uuid4
import pytest
from scripts import dynamic_rectification
def _base_request() -> dict:
return {
"case_id": "case-1",
"birth_date": "1990-01-01",
"as_of_date": "2026-07-18",
"start_time": "05:30",
"end_time": "05:33",
"lat": 31.23,
"lon": 121.47,
"tz": 8.0,
"evidence": [],
"dismissed_opportunity_ids": [],
"question_fingerprints": [],
"partition_fingerprints": [],
"recent_ranges": [],
}
def _fake_rows(_request: dict) -> list[dict]:
return [
{
"dimension_code": "career",
"window_start": "2014-01-01",
"window_end": "2017-12-31",
"activations": {"05:30": 5.0, "05:31": 1.0, "05:32": 0.0, "05:33": 0.0},
"missing_layers": [],
},
{
"dimension_code": "career",
"window_start": "2018-01-01",
"window_end": "2021-12-31",
"activations": {"05:30": 0.0, "05:31": 5.0, "05:32": 4.0, "05:33": 0.0},
"missing_layers": [],
},
{
"dimension_code": "career",
"window_start": "2022-01-01",
"window_end": "2026-07-18",
"activations": {"05:30": 0.0, "05:31": 0.0, "05:32": 1.0, "05:33": 5.0},
"missing_layers": [],
},
]
def _fake_model() -> dict:
return {
"version": "birth-time-choice-scoring-v2",
"birth_date": "1990-01-01",
"as_of_date": "2026-07-18",
"range": {"start_time": "05:30", "end_time": "05:33"},
"candidate_times": ["05:30", "05:31", "05:32", "05:33"],
"windows": _fake_rows({}),
}
def _score_request() -> dict:
return {
"birth_date": "1990-01-01",
"start_time": "05:30",
"end_time": "05:33",
"lat": 31.23,
"lon": 121.47,
"tz": 8.0,
"choice_evidence": [],
}
def _decisive_rows() -> list[dict]:
return [
{"time": "05:30", "score": 20.0},
{"time": "05:31", "score": 20.0},
{"time": "05:32", "score": 20.0},
{"time": "05:33", "score": 10.0},
]
def test_packet_contains_only_candidate_backed_high_gain_opportunities(monkeypatch) -> None:
monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", _fake_rows)
packet = dynamic_rectification.build_difference_packet(_base_request())
assert packet["scoring_version"] == "birth-time-choice-scoring-v2"
assert packet["current_range"] == {"start_time": "05:30", "end_time": "05:33"}
assert len(packet["opportunities"]) >= 1
for opportunity in packet["opportunities"]:
assert opportunity["estimated_information_gain"] >= 0.15
assert 2 <= len(opportunity["partitions"]) <= 4
assert len({item["partition_id"] for item in opportunity["partitions"]}) == len(
opportunity["partitions"]
)
for partition in opportunity["partitions"]:
assert set(partition["candidate_scores"]) == {"05:30", "05:31", "05:32", "05:33"}
def test_packet_excludes_used_opportunity_and_partition_fingerprints(monkeypatch) -> None:
monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", _fake_rows)
first = dynamic_rectification.build_difference_packet(_base_request())
used = first["opportunities"][0]
request = _base_request()
request["dismissed_opportunity_ids"] = [used["opportunity_id"]]
request["partition_fingerprints"] = [used["candidate_partition_fingerprint"]]
second = dynamic_rectification.build_difference_packet(request)
assert all(item["opportunity_id"] != used["opportunity_id"] for item in second["opportunities"])
assert all(
item["candidate_partition_fingerprint"] != used["candidate_partition_fingerprint"]
for item in second["opportunities"]
)
def test_packet_reuses_the_persisted_candidate_model(monkeypatch) -> None:
calls: list[dict] = []
monkeypatch.setattr(
dynamic_rectification,
"_compute_candidate_model",
lambda request: calls.append(request) or _fake_model(),
)
first = dynamic_rectification.build_difference_packet(_base_request())
second = dynamic_rectification.build_difference_packet(
{**_base_request(), "candidate_model": first["candidate_model"]}
)
assert len(calls) == 1
assert second["candidate_model"] == first["candidate_model"]
def test_candidate_model_rejects_wrong_range_and_non_finite_activation() -> None:
model = _fake_model()
model["range"] = {"start_time": "05:31", "end_time": "05:33"}
with pytest.raises(ValueError, match="candidate model"):
dynamic_rectification.build_difference_packet({**_base_request(), "candidate_model": model})
model = _fake_model()
model["windows"][0]["activations"]["05:30"] = float("nan")
with pytest.raises(ValueError, match="candidate model"):
dynamic_rectification.build_difference_packet({**_base_request(), "candidate_model": model})
def test_candidate_model_rejects_out_of_bounds_windows_and_boolean_activations() -> None:
model = _fake_model()
model["windows"][0]["window_end"] = "2027-01-01"
with pytest.raises(ValueError, match="candidate model"):
dynamic_rectification.build_difference_packet({**_base_request(), "candidate_model": model})
model = _fake_model()
model["windows"][0]["activations"]["05:30"] = True
with pytest.raises(ValueError, match="candidate model"):
dynamic_rectification.build_difference_packet({**_base_request(), "candidate_model": model})
def test_existing_evidence_summary_must_be_effective_partition_evidence(monkeypatch) -> None:
monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", _fake_rows)
with pytest.raises(ValueError, match="partition evidence"):
dynamic_rectification.build_difference_packet(
{**_base_request(), "evidence": [{"kind": "unmatched", "note": "free text"}]}
)
def test_candidate_charts_are_computed_once_and_missing_layers_stay_dimension_scoped(
monkeypatch,
) -> None:
from scripts import active_rectification_event_engine
candidates = [datetime(1990, 1, 1, 5, 30), datetime(1990, 1, 1, 5, 31)]
calls: list[datetime] = []
monkeypatch.setattr(
active_rectification_event_engine,
"_candidate_datetimes",
lambda _request: candidates,
)
def fake_candidate_row(request: dict, candidate: datetime) -> dict:
calls.append(candidate)
return {
"time": candidate.strftime("%H:%M"),
"score": 0.0,
"evidence": [
{
"event_id": event["id"],
"domain": event["domain"],
"candidate_time": candidate.strftime("%H:%M"),
"rule_ids": ["fixture"],
"points": 1.0,
}
for event in request["events"]
if event["domain"] != "career"
],
"missing_layers": ["D10"],
}
monkeypatch.setattr(active_rectification_event_engine, "_candidate_row", fake_candidate_row)
rows = dynamic_rectification._candidate_window_rows(_base_request())
assert calls == candidates
assert {tuple(row["missing_layers"]) for row in rows if row["dimension_code"] == "career"} == {("D10",)}
assert {tuple(row["missing_layers"]) for row in rows if row["dimension_code"] != "career"} == {()}
def test_experience_windows_remain_valid_on_the_twelfth_birthday() -> None:
windows = dynamic_rectification._experience_windows("2000-01-01", "2012-01-01")
assert windows == [(date(2012, 1, 1), date(2012, 1, 1))]
def test_candidate_engine_is_not_called_before_age_twelve(monkeypatch) -> None:
from scripts import active_rectification_event_engine
monkeypatch.setattr(
active_rectification_event_engine,
"_candidate_row",
lambda *_args: pytest.fail("candidate chart should not be computed"),
)
rows = dynamic_rectification._candidate_window_rows(
{**_base_request(), "birth_date": "2020-01-01"}
)
assert rows == []
def test_primary_choice_changes_rankings_and_returns_a_real_range() -> None:
result = dynamic_rectification.score_choice_evidence(
{
**_score_request(),
"choice_evidence": [
{
"question_id": str(uuid4()),
"opportunity_id": "career-window",
"partition_id": "career-2020-2022",
"dimension_code": "career",
"candidate_scores": {"05:30": 0.0, "05:31": 1.0, "05:32": 1.0, "05:33": 0.0},
"information_gain": 0.5,
}
],
}
)
assert result["effective_answer_count"] == 1
assert result["winning_segment"] == {
"start_time": "05:31",
"end_time": "05:32",
"representative_time": "05:31",
"width_minutes": 2,
}
assert result["can_apply"] is False
assert result["evidence"] == []
def test_score_accepts_canonical_candidate_membership_independent_of_json_key_order() -> None:
result = dynamic_rectification.score_choice_evidence(
{
**_score_request(),
"choice_evidence": [
{
"question_id": str(uuid4()),
"opportunity_id": "career-window",
"partition_id": "career-2020-2022",
"dimension_code": "career",
"candidate_scores": {"05:33": 0.0, "05:32": 1.0, "05:31": 1.0, "05:30": 0.0},
"information_gain": 0.5,
}
],
}
)
assert result["winning_segment"]["start_time"] == "05:31"
def test_unknown_and_unmatched_are_never_choice_evidence() -> None:
with pytest.raises(ValueError, match="partition evidence"):
dynamic_rectification.score_choice_evidence(
{**_score_request(), "choice_evidence": [{"kind": "unknown"}]}
)
def test_high_confidence_requires_versioned_hard_gates() -> None:
result = dynamic_rectification.adjudicate_choice_rows(
_decisive_rows(),
effective_answer_count=4,
dimension_count=3,
missing_layers=[],
)
assert result["confidence"] == "high"
assert result["can_apply"] is True
assert result["winning_segment"]["width_minutes"] <= 5
assert result["margin_percent"] >= 20
assert result["algorithm_version"] == "birth-time-choice-scoring-v2"
def test_medium_and_missing_layers_never_allow_application() -> None:
medium = dynamic_rectification.adjudicate_choice_rows(
_decisive_rows(),
effective_answer_count=3,
dimension_count=2,
missing_layers=[],
)
blocked = dynamic_rectification.adjudicate_choice_rows(
_decisive_rows(),
effective_answer_count=4,
dimension_count=3,
missing_layers=["D10"],
)
assert medium["confidence"] == "medium"
assert medium["can_apply"] is False
assert blocked["confidence"] == "low"
assert blocked["can_apply"] is False
def test_score_rejects_client_fields_duplicates_caps_and_invalid_scores() -> None:
evidence = {
"question_id": str(uuid4()),
"opportunity_id": "career-window",
"partition_id": "career-2020-2022",
"dimension_code": "career",
"candidate_scores": {"05:30": 0.0, "05:31": 1.0, "05:32": 1.0, "05:33": 0.0},
"information_gain": 0.5,
}
with pytest.raises(ValueError, match="option_id"):
dynamic_rectification.score_choice_evidence(
{**_score_request(), "choice_evidence": [{**evidence, "option_id": "client-owned"}]}
)
with pytest.raises(ValueError, match="duplicate question"):
dynamic_rectification.score_choice_evidence(
{**_score_request(), "choice_evidence": [evidence, evidence]}
)
with pytest.raises(ValueError, match="at most 10"):
dynamic_rectification.score_choice_evidence(
{
**_score_request(),
"choice_evidence": [
{**evidence, "question_id": str(uuid4())} for _ in range(11)
],
}
)
with pytest.raises(ValueError, match="candidate scores"):
dynamic_rectification.score_choice_evidence(
{
**_score_request(),
"choice_evidence": [
{**evidence, "candidate_scores": {**evidence["candidate_scores"], "05:34": 1.0}}
],
}
)
with pytest.raises(ValueError, match="candidate scores"):
dynamic_rectification.score_choice_evidence(
{
**_score_request(),
"choice_evidence": [
{
**evidence,
"candidate_scores": {**evidence["candidate_scores"], "05:30": -1.0},
}
],
}
)
with pytest.raises(ValueError, match="identifier"):
dynamic_rectification.score_choice_evidence(
{**_score_request(), "choice_evidence": [{**evidence, "partition_id": ""}]}
)