fix: harden dynamic rectification boundary
This commit is contained in:
@@ -13,8 +13,10 @@
|
|||||||
## Files changed
|
## Files changed
|
||||||
|
|
||||||
- `scripts/dynamic_rectification.py`
|
- `scripts/dynamic_rectification.py`
|
||||||
|
- `scripts/dynamic_rectification_opportunities.py`
|
||||||
- `scripts/jyotish_api_server.py`
|
- `scripts/jyotish_api_server.py`
|
||||||
- `tests/test_dynamic_rectification.py`
|
- `tests/test_dynamic_rectification.py`
|
||||||
|
- `tests/test_dynamic_rectification_scoring.py`
|
||||||
- `tests/test_active_rectification_api.py`
|
- `tests/test_active_rectification_api.py`
|
||||||
- `.superpowers/sdd/task-2-report.md`
|
- `.superpowers/sdd/task-2-report.md`
|
||||||
|
|
||||||
@@ -61,5 +63,33 @@
|
|||||||
|
|
||||||
## Concerns
|
## 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.
|
- 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.
|
||||||
|
|
||||||
|
## Review fixes
|
||||||
|
|
||||||
|
- Both dynamic routes now fail closed unless `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN` is configured and the request carries its exact bearer value. Comparison uses `secrets.compare_digest`; missing and wrong credentials are rejected before payload validation or scoring.
|
||||||
|
- Removed both routes from `API_COMMAND_MAP`, `TECHNIQUE_EXAMPLE_ENDPOINTS`, technique-example dispatch, and generated technique summaries. They remain direct authenticated POST routes only.
|
||||||
|
- Latitude, longitude, and timezone are required. The normalized location triple is persisted inside the candidate model and exact-matched during reuse.
|
||||||
|
- Adjudication preserves submitted candidate order, so an overnight `23:59` to `00:00` leader is one contiguous segment.
|
||||||
|
- Question IDs are trimmed opaque nonempty strings. Duplicate detection uses the normalized value and remains enforced.
|
||||||
|
- Split candidate-model/opportunity work into `dynamic_rectification_opportunities.py` and scoring regressions into `test_dynamic_rectification_scoring.py` without changing public entrypoints.
|
||||||
|
- Pure LOC after the split: public engine `215`, opportunity module `231`, opportunity tests `168`, scoring/auth tests `213`, active API tests `230`.
|
||||||
|
|
||||||
|
### Review RED
|
||||||
|
|
||||||
|
1. Location/timezone reuse tests initially passed for the wrong reason because the old model rejected the new location field entirely; the original same-location reuse regression also failed until location became part of the canonical model contract.
|
||||||
|
2. Overnight and opaque-ID regressions failed with UUID validation; the independent reviewer reproduction also showed wall-clock sorting split `23:59` and `00:00` into tied leaders.
|
||||||
|
3. Missing/wrong bearer regressions reached request validation/scoring instead of raising `Forbidden`; a forged unauthenticated four-row request could therefore reach the scorer.
|
||||||
|
4. Missing `lat`, `lon`, or `tz` silently normalized to zero.
|
||||||
|
5. Browser-runnable registration assertions failed because both private endpoints appeared in technique examples, command mapping, dispatch, and summaries.
|
||||||
|
|
||||||
|
### Review GREEN
|
||||||
|
|
||||||
|
1. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py tests/test_dynamic_rectification_scoring.py tests/test_active_rectification_api.py tests/test_active_rectification_questions.py tests/test_active_rectification_events.py`
|
||||||
|
- `45` passed, `0` failed.
|
||||||
|
2. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m ruff check scripts/dynamic_rectification.py scripts/dynamic_rectification_opportunities.py tests/test_dynamic_rectification.py tests/test_dynamic_rectification_scoring.py tests/test_active_rectification_api.py`
|
||||||
|
- Passed with no diagnostics after correcting one import-order finding.
|
||||||
|
3. `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m compileall -q scripts/dynamic_rectification.py scripts/dynamic_rectification_opportunities.py scripts/jyotish_api_server.py`
|
||||||
|
- Passed.
|
||||||
|
4. `git diff --check`
|
||||||
|
- Passed after removing one trailing blank line in the API regression file.
|
||||||
|
|||||||
@@ -3,26 +3,35 @@
|
|||||||
# dependencies = []
|
# dependencies = []
|
||||||
# ///
|
# ///
|
||||||
# ─── How to run ───
|
# ─── How to run ───
|
||||||
# .venv/bin/python -m pytest -q tests/test_dynamic_rectification.py
|
# .venv/bin/python -m pytest -q tests/test_dynamic_rectification_scoring.py
|
||||||
"""Candidate-backed opportunities and deterministic dynamic-choice scoring."""
|
"""Public dynamic-rectification packet and deterministic scoring entrypoints."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
from collections.abc import Sequence
|
||||||
import json
|
from typing import Literal, TypedDict
|
||||||
import math
|
from uuid import NAMESPACE_URL, uuid5
|
||||||
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"
|
from scripts.dynamic_rectification_opportunities import (
|
||||||
MIN_INFORMATION_GAIN: Final = 0.15
|
ALGORITHM_VERSION,
|
||||||
SUPPORTED_DIMENSIONS: Final = frozenset(
|
SUPPORTED_DIMENSIONS,
|
||||||
{"education", "relocation", "relationship", "career", "health_pressure"}
|
candidate_times,
|
||||||
|
candidate_window_rows,
|
||||||
|
canonical_hash,
|
||||||
|
compute_candidate_model,
|
||||||
|
experience_windows,
|
||||||
|
opportunities,
|
||||||
|
validate_candidate_model,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Confidence = Literal["low", "medium", "high"]
|
||||||
|
_candidate_times = candidate_times
|
||||||
|
_candidate_window_rows = candidate_window_rows
|
||||||
|
_canonical_hash = canonical_hash
|
||||||
|
_experience_windows = experience_windows
|
||||||
|
_opportunities = opportunities
|
||||||
|
_validate_candidate_model = validate_candidate_model
|
||||||
|
|
||||||
|
|
||||||
class ChoiceRow(TypedDict):
|
class ChoiceRow(TypedDict):
|
||||||
time: str
|
time: str
|
||||||
@@ -36,228 +45,40 @@ class WinningSegment(TypedDict):
|
|||||||
width_minutes: int
|
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:
|
def _compute_candidate_model(request: dict) -> dict:
|
||||||
return {
|
return compute_candidate_model(request, _candidate_window_rows)
|
||||||
"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:
|
def build_difference_packet(request: dict) -> dict:
|
||||||
"""Build reusable candidate activations and unused high-gain opportunities."""
|
"""Build reusable candidate activations and unused high-gain opportunities."""
|
||||||
candidates = _candidate_times(request["birth_date"], request["start_time"], request["end_time"])
|
candidates = _candidate_times(
|
||||||
|
request["birth_date"], request["start_time"], request["end_time"]
|
||||||
|
)
|
||||||
_validated_choice_evidence(request.get("evidence"), candidates)
|
_validated_choice_evidence(request.get("evidence"), candidates)
|
||||||
model = request.get("candidate_model")
|
persisted = request.get("candidate_model")
|
||||||
candidate_model = _compute_candidate_model(request) if model is None else _validate_candidate_model(model, request)
|
model = (
|
||||||
|
_compute_candidate_model(request)
|
||||||
|
if persisted is None
|
||||||
|
else _validate_candidate_model(persisted, request)
|
||||||
|
)
|
||||||
dismissed = set(request.get("dismissed_opportunity_ids", []))
|
dismissed = set(request.get("dismissed_opportunity_ids", []))
|
||||||
fingerprints = set(request.get("partition_fingerprints", []))
|
fingerprints = set(request.get("partition_fingerprints", []))
|
||||||
opportunities = [
|
unused = [
|
||||||
item for item in _opportunities(candidate_model)
|
item for item in _opportunities(model)
|
||||||
if item["opportunity_id"] not in dismissed
|
if item["opportunity_id"] not in dismissed
|
||||||
and item["candidate_partition_fingerprint"] not in fingerprints
|
and item["candidate_partition_fingerprint"] not in fingerprints
|
||||||
]
|
]
|
||||||
return {
|
return {
|
||||||
"case_id": request["case_id"],
|
"case_id": request["case_id"],
|
||||||
"scoring_version": ALGORITHM_VERSION,
|
"scoring_version": ALGORITHM_VERSION,
|
||||||
"current_range": {"start_time": request["start_time"], "end_time": request["end_time"]},
|
"current_range": {
|
||||||
"opportunities": opportunities,
|
"start_time": request["start_time"], "end_time": request["end_time"]
|
||||||
|
},
|
||||||
|
"opportunities": unused,
|
||||||
"asked_question_fingerprints": list(request.get("question_fingerprints", [])),
|
"asked_question_fingerprints": list(request.get("question_fingerprints", [])),
|
||||||
"candidate_partition_fingerprints": list(request.get("partition_fingerprints", [])),
|
"candidate_partition_fingerprints": list(request.get("partition_fingerprints", [])),
|
||||||
"recent_range_history": list(request.get("recent_ranges", [])),
|
"recent_range_history": list(request.get("recent_ranges", [])),
|
||||||
"candidate_model": candidate_model,
|
"candidate_model": model,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -271,7 +92,7 @@ def _winning_segments(rows: Sequence[ChoiceRow], top_score: float) -> list[list[
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
if row["score"] != top_score:
|
if row["score"] != top_score:
|
||||||
continue
|
continue
|
||||||
follows = segments and (
|
follows = bool(segments) and (
|
||||||
_minute_value(row["time"]) - _minute_value(segments[-1][-1]["time"])
|
_minute_value(row["time"]) - _minute_value(segments[-1][-1]["time"])
|
||||||
) % 1_440 == 1
|
) % 1_440 == 1
|
||||||
if follows:
|
if follows:
|
||||||
@@ -285,8 +106,8 @@ def adjudicate_choice_rows(
|
|||||||
rows: Sequence[ChoiceRow], *, effective_answer_count: int, dimension_count: int,
|
rows: Sequence[ChoiceRow], *, effective_answer_count: int, dimension_count: int,
|
||||||
missing_layers: Sequence[str], request_fingerprint: str = "",
|
missing_layers: Sequence[str], request_fingerprint: str = "",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Apply v2 confidence gates to precomputed effective choice evidence."""
|
"""Apply v2 confidence gates while preserving submitted candidate chronology."""
|
||||||
ranked = sorted(rows, key=lambda row: _minute_value(row["time"]))
|
ranked = list(rows)
|
||||||
scores = sorted({row["score"] for row in ranked}, reverse=True)
|
scores = sorted({row["score"] for row in ranked}, reverse=True)
|
||||||
top_score = scores[0] if scores else 0.0
|
top_score = scores[0] if scores else 0.0
|
||||||
second_score = scores[1] if len(scores) > 1 else top_score
|
second_score = scores[1] if len(scores) > 1 else top_score
|
||||||
@@ -318,7 +139,7 @@ def adjudicate_choice_rows(
|
|||||||
reasons.append("missing_mandatory_layers")
|
reasons.append("missing_mandatory_layers")
|
||||||
if confidence == "low" and effective_answer_count < 3:
|
if confidence == "low" and effective_answer_count < 3:
|
||||||
reasons.append("insufficient_effective_evidence")
|
reasons.append("insufficient_effective_evidence")
|
||||||
fingerprint = request_fingerprint or _canonical_hash(list(ranked))
|
fingerprint = request_fingerprint or _canonical_hash(ranked)
|
||||||
return {
|
return {
|
||||||
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")),
|
"result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")),
|
||||||
"confidence": confidence,
|
"confidence": confidence,
|
||||||
@@ -338,7 +159,9 @@ def adjudicate_choice_rows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _validated_choice_evidence(evidence_rows: list | None, candidates: Sequence[str]) -> tuple[list[dict], set[str]]:
|
def _validated_choice_evidence(
|
||||||
|
evidence_rows: list | None, candidates: Sequence[str],
|
||||||
|
) -> tuple[list[dict], set[str]]:
|
||||||
if not isinstance(evidence_rows, list):
|
if not isinstance(evidence_rows, list):
|
||||||
raise ValueError("choice evidence must contain partition evidence")
|
raise ValueError("choice evidence must contain partition evidence")
|
||||||
if len(evidence_rows) > 10:
|
if len(evidence_rows) > 10:
|
||||||
@@ -351,54 +174,66 @@ def _validated_choice_evidence(evidence_rows: list | None, candidates: Sequence[
|
|||||||
}
|
}
|
||||||
for evidence in evidence_rows:
|
for evidence in evidence_rows:
|
||||||
if not isinstance(evidence, dict) or set(evidence) != required:
|
if not isinstance(evidence, dict) or set(evidence) != required:
|
||||||
field = "option_id" if isinstance(evidence, dict) and "option_id" in evidence else "partition evidence"
|
field = (
|
||||||
|
"option_id"
|
||||||
|
if isinstance(evidence, dict) and "option_id" in evidence
|
||||||
|
else "partition evidence"
|
||||||
|
)
|
||||||
raise ValueError(f"choice evidence contains invalid {field}")
|
raise ValueError(f"choice evidence contains invalid {field}")
|
||||||
try:
|
question_id = evidence["question_id"].strip() if isinstance(evidence["question_id"], str) else ""
|
||||||
UUID(evidence["question_id"])
|
if not question_id:
|
||||||
except (ValueError, TypeError, AttributeError) as exc:
|
raise ValueError("partition evidence question identifier must be non-empty")
|
||||||
raise ValueError("partition evidence question_id must be a UUID") from exc
|
if question_id in question_ids:
|
||||||
if evidence["question_id"] in question_ids:
|
|
||||||
raise ValueError("duplicate question evidence is not allowed")
|
raise ValueError("duplicate question evidence is not allowed")
|
||||||
if any(
|
if any(
|
||||||
not isinstance(evidence[key], str) or not evidence[key]
|
not isinstance(evidence[key], str) or not evidence[key].strip()
|
||||||
for key in ("opportunity_id", "partition_id")
|
for key in ("opportunity_id", "partition_id")
|
||||||
):
|
):
|
||||||
raise ValueError("partition evidence identifier must be a non-empty string")
|
raise ValueError("partition evidence identifier must be a non-empty string")
|
||||||
scores = evidence["candidate_scores"]
|
_validate_scores(evidence, candidates)
|
||||||
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:
|
if evidence["dimension_code"] not in SUPPORTED_DIMENSIONS:
|
||||||
raise ValueError("choice evidence dimension is unsupported")
|
raise ValueError("choice evidence dimension is unsupported")
|
||||||
if (
|
question_ids.add(question_id)
|
||||||
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"])
|
dimensions.add(evidence["dimension_code"])
|
||||||
return evidence_rows, dimensions
|
return evidence_rows, dimensions
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_scores(evidence: dict, candidates: Sequence[str]) -> None:
|
||||||
|
import math
|
||||||
|
|
||||||
|
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 (
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
def score_choice_evidence(request: dict) -> dict:
|
def score_choice_evidence(request: dict) -> dict:
|
||||||
"""Sum only strict server-resolved primary evidence, then adjudicate it."""
|
"""Sum only strict server-resolved primary evidence, then adjudicate it."""
|
||||||
candidates = _candidate_times(request["birth_date"], request["start_time"], request["end_time"])
|
candidates = _candidate_times(
|
||||||
|
request["birth_date"], request["start_time"], request["end_time"]
|
||||||
|
)
|
||||||
evidence_rows, dimensions = _validated_choice_evidence(
|
evidence_rows, dimensions = _validated_choice_evidence(
|
||||||
request.get("choice_evidence"), candidates
|
request.get("choice_evidence"), candidates
|
||||||
)
|
)
|
||||||
totals = {candidate: 0.0 for candidate in candidates}
|
totals = {candidate: 0.0 for candidate in candidates}
|
||||||
for evidence in evidence_rows:
|
for evidence in evidence_rows:
|
||||||
scores = evidence["candidate_scores"]
|
|
||||||
gain = evidence["information_gain"]
|
|
||||||
for candidate in candidates:
|
for candidate in candidates:
|
||||||
totals[candidate] += float(scores[candidate]) * float(gain)
|
totals[candidate] += (
|
||||||
|
float(evidence["candidate_scores"][candidate])
|
||||||
|
* float(evidence["information_gain"])
|
||||||
|
)
|
||||||
rows: list[ChoiceRow] = [
|
rows: list[ChoiceRow] = [
|
||||||
{"time": candidate, "score": round(score, 6)} for candidate, score in totals.items()
|
{"time": candidate, "score": round(score, 6)} for candidate, score in totals.items()
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.11"
|
||||||
|
# dependencies = []
|
||||||
|
# ///
|
||||||
|
# ─── How to run ───
|
||||||
|
# .venv/bin/python -m pytest -q tests/test_dynamic_rectification.py
|
||||||
|
"""Candidate-model construction and opportunity partitioning for rectification."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
from typing import Final
|
||||||
|
from uuid import NAMESPACE_URL, 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"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
count = min(4, day_count, max(2, math.ceil(day_count / (6 * 365))))
|
||||||
|
boundaries = [first + timedelta(days=day_count * index // count) for index in range(count)]
|
||||||
|
return [
|
||||||
|
(start, as_of if index == 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 across every 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 []
|
||||||
|
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
|
||||||
|
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": events,
|
||||||
|
}
|
||||||
|
candidates = _candidate_datetimes(calculation_request)
|
||||||
|
rows = [_candidate_row(calculation_request, candidate) for candidate in candidates]
|
||||||
|
activations = {
|
||||||
|
event_id: {row["time"]: 0.0 for row in rows} for event_id in event_windows
|
||||||
|
}
|
||||||
|
missing = {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 else [],
|
||||||
|
}
|
||||||
|
for event_id, (dimension, window_start, window_end) in event_windows.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def compute_candidate_model(request: dict, row_builder: Callable[[dict], list[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"]},
|
||||||
|
"location": {
|
||||||
|
"lat": request["lat"],
|
||||||
|
"lon": request["lon"],
|
||||||
|
"tz": request["tz"],
|
||||||
|
},
|
||||||
|
"candidate_times": candidate_times(
|
||||||
|
request["birth_date"], request["start_time"], request["end_time"]
|
||||||
|
),
|
||||||
|
"windows": row_builder(request),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_candidate_model(model: dict, request: dict) -> dict:
|
||||||
|
expected = {
|
||||||
|
"version", "birth_date", "as_of_date", "range", "location",
|
||||||
|
"candidate_times", "windows",
|
||||||
|
}
|
||||||
|
candidates = candidate_times(request["birth_date"], request["start_time"], request["end_time"])
|
||||||
|
try:
|
||||||
|
valid_header = (
|
||||||
|
set(model) == expected
|
||||||
|
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["location"] == {
|
||||||
|
"lat": request["lat"], "lon": request["lon"], "tz": request["tz"]
|
||||||
|
}
|
||||||
|
and model["candidate_times"] == candidates
|
||||||
|
and isinstance(model["windows"], list)
|
||||||
|
)
|
||||||
|
valid_windows = _validate_windows(model["windows"], request, candidates)
|
||||||
|
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 _validate_windows(windows: list, request: dict, candidates: list[str]) -> bool:
|
||||||
|
generated = experience_windows(request["birth_date"], request["as_of_date"])
|
||||||
|
minimum = generated[0][0] if generated else date.max
|
||||||
|
maximum = date.fromisoformat(request["as_of_date"])
|
||||||
|
keys = [
|
||||||
|
(row.get("dimension_code"), row.get("window_start"), row.get("window_end"))
|
||||||
|
for row in windows if isinstance(row, dict)
|
||||||
|
]
|
||||||
|
return len(keys) == len(set(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.fromisoformat(row["window_start"])
|
||||||
|
<= date.fromisoformat(row["window_end"]) <= maximum
|
||||||
|
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 windows
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
result = []
|
||||||
|
for dimension, windows in sorted(grouped.items()):
|
||||||
|
opportunity = _dimension_opportunity(dimension, windows, model["candidate_times"])
|
||||||
|
if opportunity is not None:
|
||||||
|
result.append(opportunity)
|
||||||
|
return sorted(result, key=lambda item: (-item["estimated_information_gain"], item["opportunity_id"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _dimension_opportunity(dimension: str, windows: list[dict], candidates: list[str]) -> dict | None:
|
||||||
|
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 not 2 <= len(populated) <= 4:
|
||||||
|
return None
|
||||||
|
probabilities = [len(members) / len(candidates) for _, members in populated]
|
||||||
|
gain = -sum(value * math.log(value) for value in probabilities) / math.log(len(populated))
|
||||||
|
if gain < MIN_INFORMATION_GAIN:
|
||||||
|
return None
|
||||||
|
basis = [
|
||||||
|
{
|
||||||
|
"version": ALGORITHM_VERSION,
|
||||||
|
"dimension": dimension,
|
||||||
|
"window_start": window["window_start"],
|
||||||
|
"window_end": window["window_end"],
|
||||||
|
"members": sorted(members),
|
||||||
|
}
|
||||||
|
for window, members in populated
|
||||||
|
]
|
||||||
|
partitions = [
|
||||||
|
{
|
||||||
|
"partition_id": canonical_hash(item),
|
||||||
|
"descriptor": f"{item['window_start']}--{item['window_end']}",
|
||||||
|
"fallback_label": f"{item['window_start'][:4]}—{item['window_end'][:4]}",
|
||||||
|
"candidate_scores": {
|
||||||
|
candidate: 1.0 if candidate in item["members"] else 0.0
|
||||||
|
for candidate in candidates
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for item in basis
|
||||||
|
]
|
||||||
|
fingerprint = canonical_hash({"version": ALGORITHM_VERSION, "partitions": basis})
|
||||||
|
return {
|
||||||
|
"opportunity_id": canonical_hash({
|
||||||
|
"version": ALGORITHM_VERSION, "dimension": dimension, "partitions": basis
|
||||||
|
}),
|
||||||
|
"dimension_code": dimension,
|
||||||
|
"neutral_context": dimension,
|
||||||
|
"estimated_information_gain": round(gain, 6),
|
||||||
|
"candidate_partition_fingerprint": fingerprint,
|
||||||
|
"fallback_prompt": f"下面哪个时间段更接近你在 {dimension} 方面的明显变化?",
|
||||||
|
"partitions": partitions,
|
||||||
|
}
|
||||||
@@ -1286,8 +1286,6 @@ API_COMMAND_MAP = {
|
|||||||
'active-rectification-questions': '/api/active_rectification_questions',
|
'active-rectification-questions': '/api/active_rectification_questions',
|
||||||
'active-rectification-score': '/api/active_rectification_score',
|
'active-rectification-score': '/api/active_rectification_score',
|
||||||
'active-rectification-events': '/api/active_rectification_events',
|
'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',
|
'case-validation': '/api/case_validation',
|
||||||
'divisional-yoga': '/api/divisional_yoga',
|
'divisional-yoga': '/api/divisional_yoga',
|
||||||
'deep-varga-avastha': '/api/deep_varga_avastha',
|
'deep-varga-avastha': '/api/deep_varga_avastha',
|
||||||
@@ -1322,8 +1320,6 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = {
|
|||||||
'/api/active_rectification_questions',
|
'/api/active_rectification_questions',
|
||||||
'/api/active_rectification_score',
|
'/api/active_rectification_score',
|
||||||
'/api/active_rectification_events',
|
'/api/active_rectification_events',
|
||||||
'/api/dynamic_rectification_opportunities',
|
|
||||||
'/api/dynamic_rectification_score',
|
|
||||||
'/api/relationship',
|
'/api/relationship',
|
||||||
'/api/remedies',
|
'/api/remedies',
|
||||||
'/api/sade_sati',
|
'/api/sade_sati',
|
||||||
@@ -1449,6 +1445,13 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
|||||||
scheme, _, token = authorization.partition(' ')
|
scheme, _, token = authorization.partition(' ')
|
||||||
return token.strip() if scheme.lower() == 'bearer' else ''
|
return token.strip() if scheme.lower() == 'bearer' else ''
|
||||||
|
|
||||||
|
def _require_dynamic_rectification_token(self):
|
||||||
|
configured = os.environ.get('JYOTISH_DYNAMIC_RECTIFICATION_TOKEN', '').strip()
|
||||||
|
supplied = self._job_access_token()
|
||||||
|
matches = secrets.compare_digest(supplied, configured)
|
||||||
|
if not configured or not matches:
|
||||||
|
raise Forbidden('Dynamic rectification server token is missing or invalid')
|
||||||
|
|
||||||
def _vedastro_status(self):
|
def _vedastro_status(self):
|
||||||
adapter = _load_local_module('vedastro_service_adapter')
|
adapter = _load_local_module('vedastro_service_adapter')
|
||||||
endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip()
|
endpoint = os.environ.get('VEDASTRO_API_ENDPOINT', '').strip()
|
||||||
@@ -6909,6 +6912,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
|||||||
raise BadRequest('birth_date must be YYYY-MM-DD')
|
raise BadRequest('birth_date must be YYYY-MM-DD')
|
||||||
if not isinstance(start_time, str) or not isinstance(end_time, str):
|
if not isinstance(start_time, str) or not isinstance(end_time, str):
|
||||||
raise BadRequest('candidate times must be HH:MM')
|
raise BadRequest('candidate times must be HH:MM')
|
||||||
|
for key in ('lat', 'lon', 'tz'):
|
||||||
|
if key not in body or body[key] in (None, ''):
|
||||||
|
raise BadRequest(f'{key} is required')
|
||||||
try:
|
try:
|
||||||
datetime.strptime(birth_date, '%Y-%m-%d')
|
datetime.strptime(birth_date, '%Y-%m-%d')
|
||||||
datetime.strptime(start_time, '%H:%M')
|
datetime.strptime(start_time, '%H:%M')
|
||||||
@@ -6926,6 +6932,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def _compute_dynamic_rectification_opportunities(self, body):
|
def _compute_dynamic_rectification_opportunities(self, body):
|
||||||
|
self._require_dynamic_rectification_token()
|
||||||
allowed_fields = {
|
allowed_fields = {
|
||||||
'case_id', 'birth_date', 'as_of_date', 'start_time', 'end_time',
|
'case_id', 'birth_date', 'as_of_date', 'start_time', 'end_time',
|
||||||
'lat', 'lon', 'tz', 'candidate_model', 'evidence',
|
'lat', 'lon', 'tz', 'candidate_model', 'evidence',
|
||||||
@@ -6966,6 +6973,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
|||||||
return {'success': True, 'endpoint': 'dynamic_rectification_opportunities', **result}
|
return {'success': True, 'endpoint': 'dynamic_rectification_opportunities', **result}
|
||||||
|
|
||||||
def _compute_dynamic_rectification_score(self, body):
|
def _compute_dynamic_rectification_score(self, body):
|
||||||
|
self._require_dynamic_rectification_token()
|
||||||
allowed_fields = {
|
allowed_fields = {
|
||||||
'birth_date', 'start_time', 'end_time', 'lat', 'lon', 'tz', 'choice_evidence',
|
'birth_date', 'start_time', 'end_time', 'lat', 'lon', 'tz', 'choice_evidence',
|
||||||
}
|
}
|
||||||
@@ -7745,8 +7753,6 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
|||||||
'/api/active_rectification_questions': self._compute_active_rectification_questions,
|
'/api/active_rectification_questions': self._compute_active_rectification_questions,
|
||||||
'/api/active_rectification_score': self._compute_active_rectification_score,
|
'/api/active_rectification_score': self._compute_active_rectification_score,
|
||||||
'/api/active_rectification_events': self._compute_active_rectification_events,
|
'/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/relationship': self._compute_relationship,
|
||||||
'/api/remedies': self._compute_remedies,
|
'/api/remedies': self._compute_remedies,
|
||||||
'/api/sade_sati': self._compute_sade_sati,
|
'/api/sade_sati': self._compute_sade_sati,
|
||||||
@@ -7872,8 +7878,6 @@ class JyotishAPIHandler(BaseHTTPRequestHandler):
|
|||||||
'/api/prashna': 'Compute Prashna chart and answer evidence',
|
'/api/prashna': 'Compute Prashna chart and answer evidence',
|
||||||
'/api/rectification_gate': 'Evaluate birth-time precision gate',
|
'/api/rectification_gate': 'Evaluate birth-time precision gate',
|
||||||
'/api/active_rectification_events': 'Score dated life events against actual birth-time candidates',
|
'/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/relationship': 'Compute relationship and spouse-status evidence',
|
||||||
'/api/remedies': 'Generate low-risk remedies from doshas/strength/dasha',
|
'/api/remedies': 'Generate low-risk remedies from doshas/strength/dasha',
|
||||||
'/api/sade_sati': 'Compute Sade Sati status and phase',
|
'/api/sade_sati': 'Compute Sade Sati status and phase',
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ def _handler() -> JyotishAPIHandler:
|
|||||||
return JyotishAPIHandler.__new__(JyotishAPIHandler)
|
return JyotishAPIHandler.__new__(JyotishAPIHandler)
|
||||||
|
|
||||||
|
|
||||||
|
def _dynamic_handler(monkeypatch) -> JyotishAPIHandler:
|
||||||
|
monkeypatch.setenv("JYOTISH_DYNAMIC_RECTIFICATION_TOKEN", "server-secret")
|
||||||
|
handler = _handler()
|
||||||
|
handler.headers = {"Authorization": "Bearer server-secret"}
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
def test_active_rectification_questions_api_builds_choice_workflow() -> None:
|
def test_active_rectification_questions_api_builds_choice_workflow() -> None:
|
||||||
result = _handler()._compute_active_rectification_questions(
|
result = _handler()._compute_active_rectification_questions(
|
||||||
{
|
{
|
||||||
@@ -178,7 +185,9 @@ def test_dynamic_opportunities_api_accepts_only_server_contract(monkeypatch) ->
|
|||||||
|
|
||||||
monkeypatch.setattr(api_server, "_load_local_module", lambda _name: FakeDynamicModule)
|
monkeypatch.setattr(api_server, "_load_local_module", lambda _name: FakeDynamicModule)
|
||||||
|
|
||||||
result = _handler()._compute_dynamic_rectification_opportunities(_dynamic_base())
|
result = _dynamic_handler(monkeypatch)._compute_dynamic_rectification_opportunities(
|
||||||
|
_dynamic_base()
|
||||||
|
)
|
||||||
|
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["endpoint"] == "dynamic_rectification_opportunities"
|
assert result["endpoint"] == "dynamic_rectification_opportunities"
|
||||||
@@ -186,31 +195,38 @@ def test_dynamic_opportunities_api_accepts_only_server_contract(monkeypatch) ->
|
|||||||
assert captured[0]["lat"] == 31.23
|
assert captured[0]["lat"] == 31.23
|
||||||
|
|
||||||
|
|
||||||
def test_dynamic_opportunities_api_rejects_missing_clock_and_untrusted_fields() -> None:
|
def test_dynamic_opportunities_api_rejects_missing_clock_and_untrusted_fields(monkeypatch) -> None:
|
||||||
|
handler = _dynamic_handler(monkeypatch)
|
||||||
missing_date = _dynamic_base()
|
missing_date = _dynamic_base()
|
||||||
del missing_date["as_of_date"]
|
del missing_date["as_of_date"]
|
||||||
with pytest.raises(BadRequest, match="as_of_date"):
|
with pytest.raises(BadRequest, match="as_of_date"):
|
||||||
_handler()._compute_dynamic_rectification_opportunities(missing_date)
|
handler._compute_dynamic_rectification_opportunities(missing_date)
|
||||||
|
|
||||||
with pytest.raises(BadRequest, match="unsupported dynamic rectification opportunity field"):
|
with pytest.raises(BadRequest, match="unsupported dynamic rectification opportunity field"):
|
||||||
_handler()._compute_dynamic_rectification_opportunities(
|
handler._compute_dynamic_rectification_opportunities(
|
||||||
{**_dynamic_base(), "confidence": "high"}
|
{**_dynamic_base(), "confidence": "high"}
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(BadRequest, match="recent_ranges"):
|
with pytest.raises(BadRequest, match="recent_ranges"):
|
||||||
_handler()._compute_dynamic_rectification_opportunities(
|
handler._compute_dynamic_rectification_opportunities(
|
||||||
{**_dynamic_base(), "recent_ranges": [{"start_time": "05:30", "extra": "05:33"}]}
|
{**_dynamic_base(), "recent_ranges": [{"start_time": "05:30", "extra": "05:33"}]}
|
||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(BadRequest, match="partition evidence"):
|
with pytest.raises(BadRequest, match="partition evidence"):
|
||||||
_handler()._compute_dynamic_rectification_opportunities(
|
handler._compute_dynamic_rectification_opportunities(
|
||||||
{**_dynamic_base(), "evidence": [{"kind": "unknown"}]}
|
{**_dynamic_base(), "evidence": [{"kind": "unknown"}]}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for field in ("lat", "lon", "tz"):
|
||||||
|
missing_location = _dynamic_base()
|
||||||
|
del missing_location[field]
|
||||||
|
with pytest.raises(BadRequest, match=field):
|
||||||
|
handler._compute_dynamic_rectification_opportunities(missing_location)
|
||||||
|
|
||||||
def test_dynamic_score_api_rejects_client_option_ids_before_scoring() -> None:
|
|
||||||
|
def test_dynamic_score_api_rejects_client_option_ids_before_scoring(monkeypatch) -> None:
|
||||||
with pytest.raises(BadRequest, match="option_id"):
|
with pytest.raises(BadRequest, match="option_id"):
|
||||||
_handler()._compute_dynamic_rectification_score(
|
_dynamic_handler(monkeypatch)._compute_dynamic_rectification_score(
|
||||||
{
|
{
|
||||||
"birth_date": "1990-01-01",
|
"birth_date": "1990-01-01",
|
||||||
"start_time": "05:30",
|
"start_time": "05:30",
|
||||||
@@ -247,7 +263,7 @@ def test_dynamic_score_api_returns_versioned_candidate_result(monkeypatch) -> No
|
|||||||
|
|
||||||
monkeypatch.setattr(api_server, "_load_local_module", lambda _name: FakeDynamicModule)
|
monkeypatch.setattr(api_server, "_load_local_module", lambda _name: FakeDynamicModule)
|
||||||
|
|
||||||
result = _handler()._compute_dynamic_rectification_score(
|
result = _dynamic_handler(monkeypatch)._compute_dynamic_rectification_score(
|
||||||
{
|
{
|
||||||
"birth_date": "1990-01-01",
|
"birth_date": "1990-01-01",
|
||||||
"start_time": "05:30",
|
"start_time": "05:30",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -58,32 +57,12 @@ def _fake_model() -> dict:
|
|||||||
"birth_date": "1990-01-01",
|
"birth_date": "1990-01-01",
|
||||||
"as_of_date": "2026-07-18",
|
"as_of_date": "2026-07-18",
|
||||||
"range": {"start_time": "05:30", "end_time": "05:33"},
|
"range": {"start_time": "05:30", "end_time": "05:33"},
|
||||||
|
"location": {"lat": 31.23, "lon": 121.47, "tz": 8.0},
|
||||||
"candidate_times": ["05:30", "05:31", "05:32", "05:33"],
|
"candidate_times": ["05:30", "05:31", "05:32", "05:33"],
|
||||||
"windows": _fake_rows({}),
|
"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:
|
def test_packet_contains_only_candidate_backed_high_gain_opportunities(monkeypatch) -> None:
|
||||||
monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", _fake_rows)
|
monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", _fake_rows)
|
||||||
|
|
||||||
@@ -91,7 +70,7 @@ def test_packet_contains_only_candidate_backed_high_gain_opportunities(monkeypat
|
|||||||
|
|
||||||
assert packet["scoring_version"] == "birth-time-choice-scoring-v2"
|
assert packet["scoring_version"] == "birth-time-choice-scoring-v2"
|
||||||
assert packet["current_range"] == {"start_time": "05:30", "end_time": "05:33"}
|
assert packet["current_range"] == {"start_time": "05:30", "end_time": "05:33"}
|
||||||
assert len(packet["opportunities"]) >= 1
|
assert packet["opportunities"]
|
||||||
for opportunity in packet["opportunities"]:
|
for opportunity in packet["opportunities"]:
|
||||||
assert opportunity["estimated_information_gain"] >= 0.15
|
assert opportunity["estimated_information_gain"] >= 0.15
|
||||||
assert 2 <= len(opportunity["partitions"]) <= 4
|
assert 2 <= len(opportunity["partitions"]) <= 4
|
||||||
@@ -99,7 +78,9 @@ def test_packet_contains_only_candidate_backed_high_gain_opportunities(monkeypat
|
|||||||
opportunity["partitions"]
|
opportunity["partitions"]
|
||||||
)
|
)
|
||||||
for partition in opportunity["partitions"]:
|
for partition in opportunity["partitions"]:
|
||||||
assert set(partition["candidate_scores"]) == {"05:30", "05:31", "05:32", "05:33"}
|
assert set(partition["candidate_scores"]) == {
|
||||||
|
"05:30", "05:31", "05:32", "05:33",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_packet_excludes_used_opportunity_and_partition_fingerprints(monkeypatch) -> None:
|
def test_packet_excludes_used_opportunity_and_partition_fingerprints(monkeypatch) -> None:
|
||||||
@@ -160,6 +141,14 @@ def test_candidate_model_rejects_out_of_bounds_windows_and_boolean_activations()
|
|||||||
dynamic_rectification.build_difference_packet({**_base_request(), "candidate_model": model})
|
dynamic_rectification.build_difference_packet({**_base_request(), "candidate_model": model})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("field", "changed"), [("lat", 30.0), ("lon", 120.0), ("tz", 7.0)])
|
||||||
|
def test_candidate_model_reuse_rejects_location_or_timezone_change(field, changed) -> None:
|
||||||
|
with pytest.raises(ValueError, match="candidate model"):
|
||||||
|
dynamic_rectification.build_difference_packet({
|
||||||
|
**_base_request(), field: changed, "candidate_model": _fake_model(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def test_existing_evidence_summary_must_be_effective_partition_evidence(monkeypatch) -> None:
|
def test_existing_evidence_summary_must_be_effective_partition_evidence(monkeypatch) -> None:
|
||||||
monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", _fake_rows)
|
monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", _fake_rows)
|
||||||
|
|
||||||
@@ -169,18 +158,14 @@ def test_existing_evidence_summary_must_be_effective_partition_evidence(monkeypa
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_candidate_charts_are_computed_once_and_missing_layers_stay_dimension_scoped(
|
def test_candidate_charts_are_computed_once_and_missing_layers_are_dimension_scoped(
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
from scripts import active_rectification_event_engine
|
from scripts import active_rectification_event_engine
|
||||||
|
|
||||||
candidates = [datetime(1990, 1, 1, 5, 30), datetime(1990, 1, 1, 5, 31)]
|
candidates = [datetime(1990, 1, 1, 5, 30), datetime(1990, 1, 1, 5, 31)]
|
||||||
calls: list[datetime] = []
|
calls: list[datetime] = []
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(active_rectification_event_engine, "_candidate_datetimes", lambda _: candidates)
|
||||||
active_rectification_event_engine,
|
|
||||||
"_candidate_datetimes",
|
|
||||||
lambda _request: candidates,
|
|
||||||
)
|
|
||||||
|
|
||||||
def fake_candidate_row(request: dict, candidate: datetime) -> dict:
|
def fake_candidate_row(request: dict, candidate: datetime) -> dict:
|
||||||
calls.append(candidate)
|
calls.append(candidate)
|
||||||
@@ -195,14 +180,12 @@ def test_candidate_charts_are_computed_once_and_missing_layers_stay_dimension_sc
|
|||||||
"rule_ids": ["fixture"],
|
"rule_ids": ["fixture"],
|
||||||
"points": 1.0,
|
"points": 1.0,
|
||||||
}
|
}
|
||||||
for event in request["events"]
|
for event in request["events"] if event["domain"] != "career"
|
||||||
if event["domain"] != "career"
|
|
||||||
],
|
],
|
||||||
"missing_layers": ["D10"],
|
"missing_layers": ["D10"],
|
||||||
}
|
}
|
||||||
|
|
||||||
monkeypatch.setattr(active_rectification_event_engine, "_candidate_row", fake_candidate_row)
|
monkeypatch.setattr(active_rectification_event_engine, "_candidate_row", fake_candidate_row)
|
||||||
|
|
||||||
rows = dynamic_rectification._candidate_window_rows(_base_request())
|
rows = dynamic_rectification._candidate_window_rows(_base_request())
|
||||||
|
|
||||||
assert calls == candidates
|
assert calls == candidates
|
||||||
@@ -210,165 +193,18 @@ def test_candidate_charts_are_computed_once_and_missing_layers_stay_dimension_sc
|
|||||||
assert {tuple(row["missing_layers"]) for row in rows if row["dimension_code"] != "career"} == {()}
|
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:
|
def test_window_edges_and_under_age_cases_do_not_create_invalid_calculation(monkeypatch) -> None:
|
||||||
windows = dynamic_rectification._experience_windows("2000-01-01", "2012-01-01")
|
assert dynamic_rectification._experience_windows("2000-01-01", "2012-01-01") == [
|
||||||
|
(date(2012, 1, 1), date(2012, 1, 1)),
|
||||||
|
]
|
||||||
|
|
||||||
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
|
from scripts import active_rectification_event_engine
|
||||||
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
active_rectification_event_engine,
|
active_rectification_event_engine,
|
||||||
"_candidate_row",
|
"_candidate_row",
|
||||||
lambda *_args: pytest.fail("candidate chart should not be computed"),
|
lambda *_: pytest.fail("candidate chart should not be computed"),
|
||||||
)
|
)
|
||||||
|
assert dynamic_rectification._candidate_window_rows({
|
||||||
rows = dynamic_rectification._candidate_window_rows(
|
**_base_request(), "birth_date": "2020-01-01",
|
||||||
{**_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": ""}]}
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from scripts import dynamic_rectification
|
||||||
|
from scripts import jyotish_api_server as api_server
|
||||||
|
|
||||||
|
|
||||||
|
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 _evidence(**changes) -> dict:
|
||||||
|
return {
|
||||||
|
"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,
|
||||||
|
**changes,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _handler() -> api_server.JyotishAPIHandler:
|
||||||
|
handler = api_server.JyotishAPIHandler.__new__(api_server.JyotishAPIHandler)
|
||||||
|
handler.headers = {}
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_routes_fail_closed_and_compare_wrong_bearers_in_constant_time(
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
handler = _handler()
|
||||||
|
monkeypatch.delenv("JYOTISH_DYNAMIC_RECTIFICATION_TOKEN", raising=False)
|
||||||
|
with pytest.raises(api_server.Forbidden, match="token"):
|
||||||
|
handler._compute_dynamic_rectification_score({})
|
||||||
|
|
||||||
|
calls: list[tuple[str, str]] = []
|
||||||
|
original = api_server.secrets.compare_digest
|
||||||
|
monkeypatch.setattr(
|
||||||
|
api_server.secrets,
|
||||||
|
"compare_digest",
|
||||||
|
lambda supplied, configured: calls.append((supplied, configured))
|
||||||
|
or original(supplied, configured),
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("JYOTISH_DYNAMIC_RECTIFICATION_TOKEN", "server-secret")
|
||||||
|
handler.headers = {"Authorization": "Bearer wrong-secret"}
|
||||||
|
with pytest.raises(api_server.Forbidden, match="token"):
|
||||||
|
handler._compute_dynamic_rectification_opportunities({})
|
||||||
|
assert calls == [("wrong-secret", "server-secret")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unauthenticated_forged_scores_cannot_obtain_an_applicable_result(monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("JYOTISH_DYNAMIC_RECTIFICATION_TOKEN", "server-secret")
|
||||||
|
scores = {"05:30": 10_000.0, "05:31": 0.0, "05:32": 0.0, "05:33": 0.0}
|
||||||
|
evidence = [
|
||||||
|
_evidence(
|
||||||
|
question_id=f"question-{index}",
|
||||||
|
opportunity_id=f"opportunity-{index}",
|
||||||
|
partition_id=f"partition-{index}",
|
||||||
|
dimension_code=dimension,
|
||||||
|
candidate_scores=scores,
|
||||||
|
information_gain=1.0,
|
||||||
|
)
|
||||||
|
for index, dimension in enumerate(
|
||||||
|
["career", "relationship", "education", "career"], start=1
|
||||||
|
)
|
||||||
|
]
|
||||||
|
with pytest.raises(api_server.Forbidden, match="token"):
|
||||||
|
_handler()._compute_dynamic_rectification_score({
|
||||||
|
**_score_request(), "choice_evidence": evidence,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_dynamic_routes_are_not_browser_runnable_technique_examples() -> None:
|
||||||
|
endpoints = {
|
||||||
|
"/api/dynamic_rectification_opportunities",
|
||||||
|
"/api/dynamic_rectification_score",
|
||||||
|
}
|
||||||
|
assert endpoints.isdisjoint(api_server.TECHNIQUE_EXAMPLE_ENDPOINTS)
|
||||||
|
assert endpoints.isdisjoint(api_server.API_COMMAND_MAP.values())
|
||||||
|
for endpoint in endpoints:
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
_handler()._dispatch_technique_endpoint(endpoint, {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_primary_choice_changes_rankings_and_returns_a_real_range() -> None:
|
||||||
|
result = dynamic_rectification.score_choice_evidence(
|
||||||
|
{**_score_request(), "choice_evidence": [_evidence()]}
|
||||||
|
)
|
||||||
|
|
||||||
|
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_candidate_membership_independent_of_json_key_order() -> None:
|
||||||
|
scores = {"05:33": 0.0, "05:32": 1.0, "05:31": 1.0, "05:30": 0.0}
|
||||||
|
result = dynamic_rectification.score_choice_evidence(
|
||||||
|
{**_score_request(), "choice_evidence": [_evidence(candidate_scores=scores)]}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["winning_segment"]["start_time"] == "05:31"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_midnight_leaders_form_one_chronological_segment() -> None:
|
||||||
|
scores = {"23:58": 0.0, "23:59": 1.0, "00:00": 1.0, "00:01": 0.0}
|
||||||
|
evidence = [
|
||||||
|
_evidence(
|
||||||
|
question_id=f"question-{index}",
|
||||||
|
opportunity_id=f"opportunity-{index}",
|
||||||
|
partition_id=f"partition-{index}",
|
||||||
|
dimension_code=dimension,
|
||||||
|
candidate_scores=scores,
|
||||||
|
information_gain=1.0,
|
||||||
|
)
|
||||||
|
for index, dimension in enumerate(
|
||||||
|
["career", "relationship", "education", "career"], start=1
|
||||||
|
)
|
||||||
|
]
|
||||||
|
result = dynamic_rectification.score_choice_evidence({
|
||||||
|
**_score_request(),
|
||||||
|
"start_time": "23:58",
|
||||||
|
"end_time": "00:01",
|
||||||
|
"choice_evidence": evidence,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert result["confidence"] == "high"
|
||||||
|
assert result["winning_segment"] == {
|
||||||
|
"start_time": "23:59",
|
||||||
|
"end_time": "00:00",
|
||||||
|
"representative_time": "23:59",
|
||||||
|
"width_minutes": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_opaque_trimmed_question_ids_are_valid_and_duplicates_remain_rejected() -> None:
|
||||||
|
evidence = _evidence(question_id=" question-career-window ")
|
||||||
|
result = dynamic_rectification.score_choice_evidence(
|
||||||
|
{**_score_request(), "choice_evidence": [evidence]}
|
||||||
|
)
|
||||||
|
assert result["effective_answer_count"] == 1
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="duplicate question"):
|
||||||
|
dynamic_rectification.score_choice_evidence({
|
||||||
|
**_score_request(),
|
||||||
|
"choice_evidence": [
|
||||||
|
evidence,
|
||||||
|
{**evidence, "question_id": "question-career-window"},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
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 = _evidence()
|
||||||
|
with pytest.raises(ValueError, match="option_id"):
|
||||||
|
dynamic_rectification.score_choice_evidence({
|
||||||
|
**_score_request(), "choice_evidence": [{**evidence, "option_id": "client"}],
|
||||||
|
})
|
||||||
|
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