feat: score dynamic birth time choices
This commit is contained in:
@@ -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.
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": ""}]}
|
||||
)
|
||||
Reference in New Issue
Block a user