diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 00000000..a1951300 --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -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. diff --git a/scripts/dynamic_rectification.py b/scripts/dynamic_rectification.py new file mode 100644 index 00000000..02a4bdc3 --- /dev/null +++ b/scripts/dynamic_rectification.py @@ -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), + ) diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 10b54cc0..996839f8 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -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', diff --git a/tests/test_active_rectification_api.py b/tests/test_active_rectification_api.py index 65fea8d5..678cdc29 100644 --- a/tests/test_active_rectification_api.py +++ b/tests/test_active_rectification_api.py @@ -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" diff --git a/tests/test_dynamic_rectification.py b/tests/test_dynamic_rectification.py new file mode 100644 index 00000000..6fd88137 --- /dev/null +++ b/tests/test_dynamic_rectification.py @@ -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": ""}]} + )