fix(rectification): anchor candidate windows to civil dates across midnight
Carry explicit local date intervals instead of inferring the day from clock order. Cluster width, delivery, adoption, and reports keep the actual civil date; adopted date is stored separately from the reported birth_date. Algorithm identity is scoring-9 / spec-v5. Scoring weights, confirmation thresholds, and Skill version are unchanged. Isolated Linux final-3 gates passed; four pre-existing Python failures remain. This is not a production release.
This commit is contained in:
@@ -88,19 +88,12 @@ def _event_datetime(event: LifeEvent) -> datetime:
|
||||
|
||||
|
||||
def _candidate_datetimes(request: RectificationEventRequest) -> list[datetime]:
|
||||
birth_date = date.fromisoformat(request["birth_date"])
|
||||
start = datetime.combine(birth_date, time.fromisoformat(request["start_time"]))
|
||||
end = datetime.combine(birth_date, time.fromisoformat(request["end_time"]))
|
||||
if end < start:
|
||||
end += timedelta(days=1)
|
||||
minute_count = int((end - start).total_seconds() // 60) + 1
|
||||
if minute_count < 1 or minute_count > 1_440:
|
||||
raise RectificationEventCalculationError("candidate_range_out_of_bounds")
|
||||
raw_step = request.get("minute_step") if isinstance(request, dict) else None
|
||||
step = int(raw_step or 1)
|
||||
if step < 1 or step > 15:
|
||||
raise RectificationEventCalculationError("minute_step_out_of_bounds")
|
||||
return [start + timedelta(minutes=offset) for offset in range(0, minute_count, step)]
|
||||
from scripts.rectification.candidate_window import enumerate_candidate_window
|
||||
|
||||
try:
|
||||
return enumerate_candidate_window(request)
|
||||
except ValueError as exc:
|
||||
raise RectificationEventCalculationError(str(exc)) from exc
|
||||
|
||||
|
||||
def _active_vimshottari(
|
||||
@@ -611,7 +604,11 @@ def compute_candidate_static_contexts(
|
||||
candidates: Sequence[datetime] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
candidate_datetimes = list(candidates) if candidates is not None else _candidate_datetimes(request)
|
||||
return [build_candidate_static_context(request, candidate) for candidate in candidate_datetimes]
|
||||
from scripts.rectification.candidate_window import candidate_positions
|
||||
|
||||
positions = candidate_positions(request, candidate_datetimes)
|
||||
return [{**build_candidate_static_context(request, candidate), **position}
|
||||
for candidate, position in zip(candidate_datetimes, positions)]
|
||||
|
||||
|
||||
def _candidate_row(
|
||||
@@ -737,6 +734,9 @@ def _canonical_input_contract(request: RectificationEventRequest) -> tuple[dict,
|
||||
"ephemeris_source": "swisseph_calc_ut",
|
||||
},
|
||||
}
|
||||
if "candidate_intervals" in request:
|
||||
payload["schema_version"] = "rectification-candidate-input-v3"
|
||||
payload["candidate_intervals"] = request["candidate_intervals"]
|
||||
normalized = json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":"))
|
||||
return payload, hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@@ -88,9 +88,21 @@ def build_flexible_birth_time_profile_from_window(
|
||||
ayanamsa: str = "raman",
|
||||
node_mode: str = "mean",
|
||||
source_reference: Mapping[str, Any],
|
||||
candidate_intervals: list[dict[str, str]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
start, end = _parse_window(birth_date, start_time, end_time)
|
||||
normalized_times = _normalize_candidate_times(birth_date, start, end, candidate_times)
|
||||
if candidate_intervals is None:
|
||||
start, end = _parse_window(birth_date, start_time, end_time)
|
||||
normalized_times = _normalize_candidate_times(birth_date, start, end, candidate_times)
|
||||
else:
|
||||
from scripts.rectification.candidate_window import enumerate_candidate_window
|
||||
moments = enumerate_candidate_window({"birth_date": birth_date, "start_time": start_time,
|
||||
"end_time": end_time, "candidate_intervals": candidate_intervals})
|
||||
by_clock = {value.strftime("%H:%M"): value for value in moments}
|
||||
if not 2 <= len(candidate_times) <= MAX_CANDIDATE_MINUTES or len(set(candidate_times)) != len(candidate_times):
|
||||
raise FlexibleBirthTimeProfileError("candidate_count_must_be_two_to_thirty_one")
|
||||
if any(value not in by_clock for value in candidate_times):
|
||||
raise FlexibleBirthTimeProfileError("candidate_time_outside_window")
|
||||
normalized_times = sorted(by_clock[value] for value in candidate_times)
|
||||
candidates = [
|
||||
_candidate_from_recast(
|
||||
candidate_at=value,
|
||||
@@ -103,6 +115,14 @@ def build_flexible_birth_time_profile_from_window(
|
||||
for value in normalized_times
|
||||
]
|
||||
profile = build_flexible_birth_time_profile(candidates, source_reference=source_reference)
|
||||
if candidate_intervals is not None:
|
||||
# Keep civil chronology and declared gaps instead of the legacy clock-sorted envelope.
|
||||
clocks = [value.strftime("%H:%M") for value in normalized_times]
|
||||
profile["birth_time_window"].update({"start_time": start_time, "end_time": end_time,
|
||||
"candidate_times": clocks, "candidate_intervals": deepcopy(candidate_intervals),
|
||||
"candidate_datetimes": [value.isoformat(timespec="minutes") for value in normalized_times]})
|
||||
refs = {row["candidate_time"]: row for row in profile["candidate_references"]}
|
||||
profile["candidate_references"] = [{**refs[value.strftime("%H:%M")], "candidate_date": value.date().isoformat()} for value in normalized_times]
|
||||
profile["calculation_profile"] = {
|
||||
"ayanamsa": ayanamsa,
|
||||
"node_mode": node_mode,
|
||||
|
||||
@@ -8951,11 +8951,10 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
request_body.pop('candidate_times', None)
|
||||
request = self._rectification_v5_request(request_body)
|
||||
from scripts.active_rectification_event_engine import _candidate_datetimes
|
||||
allowed_candidate_times = {value.strftime('%H:%M') for value in _candidate_datetimes(request)}
|
||||
allowed_candidate_times = {value.strftime('%H:%M'): value for value in _candidate_datetimes(request)}
|
||||
if any(value not in allowed_candidate_times for value in candidate_times):
|
||||
raise BadRequest('candidate_times must fall within the V5 candidate range')
|
||||
|
||||
birth_day = datetime.strptime(request['birth_date'], '%Y-%m-%d')
|
||||
selected_events, eligible_event_count, unsupported_events = _select_vedastro_rectification_events(
|
||||
request['events']
|
||||
)
|
||||
@@ -8968,6 +8967,7 @@ class JyotishAPIHandler(BaseHTTPRequestHandler, VedastroEvidenceMixin, SynastryM
|
||||
raw_reports = []
|
||||
|
||||
for role, candidate_time in zip(('primary', 'runner_up'), candidate_times):
|
||||
birth_day = allowed_candidate_times[candidate_time]
|
||||
hour, minute = candidate_time.split(':', 1)
|
||||
candidate_case = {
|
||||
'year': birth_day.year,
|
||||
|
||||
+27
-10
@@ -2241,20 +2241,36 @@ def _build_birth_time_sensitivity(args) -> dict:
|
||||
}
|
||||
|
||||
center = _birth_datetime_from_args(args)
|
||||
start, representative, end = _birth_time_candidate_window(args, center, accuracy)
|
||||
declared_range = getattr(args, "candidate_range", None) or {}
|
||||
dated_intervals = None
|
||||
dated_moments = None
|
||||
if isinstance(declared_range, dict) and "candidate_intervals" in declared_range:
|
||||
from scripts.rectification.candidate_window import candidate_intervals, enumerate_candidate_window
|
||||
dated_request = {"birth_date": center.date().isoformat(), "start_time": declared_range.get("start_time"),
|
||||
"end_time": declared_range.get("end_time"), "candidate_intervals": declared_range["candidate_intervals"]}
|
||||
dated_intervals = candidate_intervals(dated_request)
|
||||
dated_moments = enumerate_candidate_window(dated_request)
|
||||
start, end = dated_moments[0], dated_moments[-1]
|
||||
representative = min(dated_moments, key=lambda value: abs((value-center).total_seconds()))
|
||||
else:
|
||||
start, representative, end = _birth_time_candidate_window(args, center, accuracy)
|
||||
if start == end:
|
||||
return {
|
||||
"schema": "jyotish.report_birth_time_sensitivity.v1",
|
||||
"status": "not_applicable",
|
||||
"accuracy": "confirmed",
|
||||
}
|
||||
minute_count = int((end - start).total_seconds() // 60) + 1
|
||||
candidate_times = _candidate_minutes(
|
||||
start,
|
||||
representative,
|
||||
end,
|
||||
coarse=accuracy == "approximate" and minute_count > 31,
|
||||
)
|
||||
minute_count = len(dated_moments) if dated_moments is not None else int((end - start).total_seconds() // 60) + 1
|
||||
if dated_moments is not None:
|
||||
# Sample the declared-minute ordinal, never the wall-clock gap between segments.
|
||||
origin = datetime(2000, 1, 1)
|
||||
sampled = _candidate_minutes(origin, origin + timedelta(minutes=dated_moments.index(representative)),
|
||||
origin + timedelta(minutes=minute_count-1), coarse=accuracy == "approximate" and minute_count > 31)
|
||||
candidate_times = [dated_moments[int(clock[:2])*60+int(clock[3:])].strftime("%H:%M") for clock in sampled]
|
||||
else:
|
||||
candidate_times = _candidate_minutes(
|
||||
start, representative, end, coarse=accuracy == "approximate" and minute_count > 31,
|
||||
)
|
||||
try:
|
||||
from flexible_birth_time_profile import build_flexible_birth_time_profile_from_window
|
||||
from flexible_birth_time_report_support import build_flexible_birth_time_report_support
|
||||
@@ -2266,9 +2282,10 @@ def _build_birth_time_sensitivity(args) -> dict:
|
||||
|
||||
profile = build_flexible_birth_time_profile_from_window(
|
||||
birth_date=center.strftime("%Y-%m-%d"),
|
||||
start_time=start.strftime("%H:%M"),
|
||||
end_time=end.strftime("%H:%M"),
|
||||
start_time=declared_range["start_time"] if dated_intervals else start.strftime("%H:%M"),
|
||||
end_time=declared_range["end_time"] if dated_intervals else end.strftime("%H:%M"),
|
||||
candidate_times=candidate_times,
|
||||
**({"candidate_intervals": dated_intervals} if dated_intervals is not None else {}),
|
||||
lat=float(args.lat),
|
||||
lon=float(args.lon),
|
||||
tz=float(args.tz),
|
||||
|
||||
@@ -48,6 +48,17 @@ def _report_candidate_range(
|
||||
for row in candidate_scores
|
||||
if top_score is not None and float(row.get("score") or 0) == top_score
|
||||
]
|
||||
if "candidate_intervals" in request:
|
||||
from scripts.rectification.candidate_window import candidate_positions, enumerate_candidate_window, intervals_from_positions, interval_union_width
|
||||
positions = candidate_positions(request, enumerate_candidate_window(request))
|
||||
selected = [row for row in positions if not top_times or row["time"] in top_times]
|
||||
parts = intervals_from_positions(selected)
|
||||
return {
|
||||
"start_time": selected[0]["time"], "end_time": selected[-1]["time"],
|
||||
"candidate_intervals": [{"start_at": part["start_at"], "end_at": part["end_at"]} for part in parts],
|
||||
"representative_time": representative_time or selected[len(selected) // 2]["time"],
|
||||
"width_minutes": interval_union_width(parts), "representative_is_unique": False,
|
||||
}
|
||||
if not top_times:
|
||||
return {
|
||||
"start_time": request["start_time"],
|
||||
@@ -227,6 +238,9 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
|
||||
static_contexts=built.get("static_contexts") if isinstance(built.get("static_contexts"), list) else None,
|
||||
)
|
||||
decision_receipt = build_decision_receipt(request, candidate_decisions, built, diagnostic_values)
|
||||
from scripts.rectification.candidate_window import candidate_intervals
|
||||
decision_receipt.update({"candidate_window_contract": "dated-v1", "candidate_intervals": candidate_intervals(request),
|
||||
"candidate_timezone_offset": request["tz"], "candidate_timezone_id": request.get("timezone_id", "")})
|
||||
execution_ledger = build_execution_ledger(request, built, diagnostic_values, candidate_decisions)
|
||||
representative = candidate_decisions[0] if candidate_decisions else None
|
||||
representative_time = str(representative.get("time") or "")[:5] if representative else None
|
||||
@@ -495,7 +509,9 @@ def block_scan(request: RectificationRequest) -> dict[str, Any]:
|
||||
member_scores = [float(row.get("score") or 0) for row in members]
|
||||
mean = (sum(member_scores) / len(member_scores)) if member_scores else 0.0
|
||||
raw_support.append(max(mean - min_day, 0.0))
|
||||
from scripts.rectification.candidate_window import narrow_candidate_intervals
|
||||
blocks.append({
|
||||
"candidate_intervals": narrow_candidate_intervals(request, start_time, end_time),
|
||||
"period": period,
|
||||
"start_time": start_time,
|
||||
"end_time": end_time,
|
||||
|
||||
@@ -164,17 +164,18 @@ def context_time(context: dict[str, Any]) -> str | None:
|
||||
def cluster_contexts_by_signature(
|
||||
contexts: Sequence[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
buckets: dict[tuple[int | None, ...], list[dict[str, Any]]] = {}
|
||||
buckets: dict[tuple[Any, ...], list[dict[str, Any]]] = {}
|
||||
for context in contexts:
|
||||
if not isinstance(context, dict):
|
||||
continue
|
||||
time = context_time(context)
|
||||
if not time:
|
||||
continue
|
||||
buckets.setdefault(feature_signature(context), []).append(context)
|
||||
buckets.setdefault((context.get("segment_index", 0), *feature_signature(context)), []).append(context)
|
||||
clusters: list[dict[str, Any]] = []
|
||||
for signature, members in buckets.items():
|
||||
ordered = sorted(members, key=lambda item: _clock(str(context_time(item))))
|
||||
for key, members in buckets.items():
|
||||
signature = key[1:]
|
||||
ordered = sorted(members, key=lambda item: item.get("window_index", _clock(str(context_time(item)))))
|
||||
times = [str(context_time(item)) for item in ordered]
|
||||
clusters.append({
|
||||
"signature": signature,
|
||||
@@ -184,7 +185,7 @@ def cluster_contexts_by_signature(
|
||||
"representative_time": times[len(times) // 2],
|
||||
"representative": ordered[len(ordered) // 2],
|
||||
})
|
||||
clusters.sort(key=lambda item: _clock(item["representative_time"]))
|
||||
clusters.sort(key=lambda item: item["representative"].get("window_index", _clock(item["representative_time"])))
|
||||
return clusters
|
||||
|
||||
|
||||
@@ -271,7 +272,9 @@ def cap_clusters_by_adjacent_merge(
|
||||
best_index = index
|
||||
left = work[best_index]
|
||||
right = work[best_index + 1]
|
||||
merged_times = sorted(set(left["times"] + right["times"]), key=_clock)
|
||||
contexts = list(left.get("contexts") or []) + list(right.get("contexts") or [])
|
||||
order = {context_time(row): row.get("window_index", _clock(str(context_time(row)))) for row in contexts}
|
||||
merged_times = sorted(set(left["times"] + right["times"]), key=lambda clock: order.get(clock, _clock(clock)))
|
||||
work[best_index] = {
|
||||
"signature": left.get("signature"),
|
||||
"signature_key": f"{left.get('signature_key')}+{right.get('signature_key')}",
|
||||
@@ -296,7 +299,8 @@ def select_signature_representatives(
|
||||
context for context in (static_contexts or [])
|
||||
if isinstance(context, dict) and context_time(context) in by_time
|
||||
]
|
||||
if len(contexts) >= 2:
|
||||
order = {context_time(row): row.get("window_index", _clock(str(context_time(row)))) for row in contexts}
|
||||
if contexts:
|
||||
clusters = cluster_contexts_by_signature(contexts)
|
||||
else:
|
||||
clusters = _adjacent_score_clusters(list(by_time.values()))
|
||||
@@ -306,17 +310,22 @@ def select_signature_representatives(
|
||||
members = [by_time[time] for time in cluster["times"] if time in by_time]
|
||||
if not members:
|
||||
continue
|
||||
best = max(members, key=lambda row: (float(row.get("score") or 0), str(row.get("time"))))
|
||||
best = max(members, key=lambda row: (float(row.get("score") or 0), order.get(row.get("time"), _clock(str(row.get("time"))))))
|
||||
positions = [{key: context[key] for key in ("time", "candidate_date", "window_index", "window_offset_minutes", "segment_index")}
|
||||
for context in cluster.get("contexts", []) if "window_index" in context]
|
||||
representative_position = next((row for row in positions if row["time"] == best["time"]), {})
|
||||
representatives.append({
|
||||
**best,
|
||||
**representative_position,
|
||||
"cluster_times": [time for time in cluster["times"] if time in by_time],
|
||||
**({"cluster_positions": positions} if positions else {}),
|
||||
})
|
||||
representatives.sort(key=lambda row: (-float(row.get("score") or 0), str(row.get("time"))))
|
||||
representatives.sort(key=lambda row: (-float(row.get("score") or 0), order.get(row.get("time"), _clock(str(row.get("time"))))))
|
||||
return representatives or list(rows)[:1]
|
||||
|
||||
|
||||
def _adjacent_score_clusters(rows: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
ordered = sorted(rows, key=lambda row: _clock(str(_hhmm(row.get("time")))))
|
||||
ordered = sorted(rows, key=lambda row: row.get("window_index", _clock(str(_hhmm(row.get("time"))))))
|
||||
groups: list[list[dict[str, Any]]] = []
|
||||
for row in ordered:
|
||||
current = groups[-1] if groups else None
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Civil-date candidate windows. Ordinals order samples; offsets measure minutes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
_LOCAL_MINUTE = re.compile(r"\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d\Z")
|
||||
|
||||
|
||||
def candidate_intervals(request: dict[str, Any]) -> list[dict[str, str]]:
|
||||
raw = request.get("candidate_intervals")
|
||||
if "candidate_intervals" not in request:
|
||||
start = datetime.fromisoformat(f'{request["birth_date"]}T{request["start_time"]}')
|
||||
end = datetime.fromisoformat(f'{request["birth_date"]}T{request["end_time"]}')
|
||||
if end < start:
|
||||
end += timedelta(days=1)
|
||||
return [{"start_at": start.isoformat(timespec="minutes"), "end_at": end.isoformat(timespec="minutes")}]
|
||||
if not isinstance(raw, list) or not 1 <= len(raw) <= 2:
|
||||
raise ValueError("candidate_intervals must contain one or two intervals")
|
||||
anchor = datetime.fromisoformat(f'{request["birth_date"]}T00:00')
|
||||
cleaned, clocks = [], set()
|
||||
previous_end = None
|
||||
total = 0
|
||||
for item in raw:
|
||||
if not isinstance(item, dict) or set(item) != {"start_at", "end_at"}:
|
||||
raise ValueError("candidate_intervals must contain start_at/end_at only")
|
||||
values = [item.get(key) for key in ("start_at", "end_at")]
|
||||
if any(not isinstance(value, str) or not _LOCAL_MINUTE.fullmatch(value) for value in values):
|
||||
raise ValueError("candidate_intervals require local YYYY-MM-DDTHH:MM")
|
||||
start, end = map(datetime.fromisoformat, values)
|
||||
if end < start or (previous_end is not None and start <= previous_end):
|
||||
raise ValueError("candidate_intervals must be ordered and non-overlapping")
|
||||
if start < anchor - timedelta(days=1) or end >= anchor + timedelta(days=2):
|
||||
raise ValueError("candidate_intervals date out of bounds")
|
||||
width = int((end - start).total_seconds() // 60) + 1
|
||||
total += width
|
||||
if total > 1440 or width < 1:
|
||||
raise ValueError("candidate_range_out_of_bounds")
|
||||
for offset in range(width):
|
||||
clock = (start + timedelta(minutes=offset)).strftime("%H:%M")
|
||||
if clock in clocks:
|
||||
raise ValueError("candidate_intervals duplicate clock identity")
|
||||
clocks.add(clock)
|
||||
cleaned.append({"start_at": values[0], "end_at": values[1]})
|
||||
previous_end = end
|
||||
# Legacy clock envelope remains an inclusion boundary, never a date anchor.
|
||||
lower, upper = request["start_time"], request["end_time"]
|
||||
if any(not (lower <= clock <= upper if lower <= upper else clock >= lower or clock <= upper) for clock in clocks):
|
||||
raise ValueError("candidate_intervals outside clock window")
|
||||
return cleaned
|
||||
|
||||
|
||||
def narrow_candidate_intervals(request: dict[str, Any], start_time: str, end_time: str) -> list[dict[str, str]]:
|
||||
result = []
|
||||
for interval in candidate_intervals(request):
|
||||
start, end = (datetime.fromisoformat(interval[key]) for key in ("start_at", "end_at"))
|
||||
run = None
|
||||
for offset in range(int((end - start).total_seconds() // 60) + 1):
|
||||
at = start + timedelta(minutes=offset)
|
||||
clock = at.strftime("%H:%M")
|
||||
inside = start_time <= clock <= end_time if start_time <= end_time else clock >= start_time or clock <= end_time
|
||||
if inside:
|
||||
stamp = at.isoformat(timespec="minutes")
|
||||
run = {"start_at": run["start_at"] if run else stamp, "end_at": stamp}
|
||||
elif run:
|
||||
result.append(run)
|
||||
run = None
|
||||
if run:
|
||||
result.append(run)
|
||||
return result
|
||||
|
||||
|
||||
def enumerate_candidate_window(request: dict[str, Any]) -> list[datetime]:
|
||||
raw_step = request.get("minute_step", 1)
|
||||
if isinstance(raw_step, bool) or not isinstance(raw_step, int) or not 1 <= raw_step <= 15:
|
||||
raise ValueError("minute_step_out_of_bounds")
|
||||
moments = []
|
||||
for interval in candidate_intervals(request):
|
||||
start, end = (datetime.fromisoformat(interval[key]) for key in ("start_at", "end_at"))
|
||||
width = int((end - start).total_seconds() // 60) + 1
|
||||
if not 1 <= width <= 1440:
|
||||
raise ValueError("candidate_range_out_of_bounds")
|
||||
moments.extend(start + timedelta(minutes=offset) for offset in range(0, width, raw_step))
|
||||
return moments
|
||||
|
||||
|
||||
def candidate_positions(request: dict[str, Any], moments: list[datetime]) -> list[dict[str, Any]]:
|
||||
intervals = candidate_intervals(request)
|
||||
origin = datetime.fromisoformat(intervals[0]["start_at"])
|
||||
bounds = [(datetime.fromisoformat(row["start_at"]), datetime.fromisoformat(row["end_at"])) for row in intervals]
|
||||
return [{"time": at.strftime("%H:%M"), "candidate_date": at.date().isoformat(),
|
||||
"window_index": index, "window_offset_minutes": int((at - origin).total_seconds() // 60),
|
||||
"segment_index": next(i for i, (start, end) in enumerate(bounds) if start <= at <= end)}
|
||||
for index, at in enumerate(moments)]
|
||||
|
||||
|
||||
def intervals_from_positions(positions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
groups: dict[int, list[dict[str, Any]]] = {}
|
||||
for row in sorted(positions, key=lambda item: item["window_index"]):
|
||||
groups.setdefault(row["segment_index"], []).append(row)
|
||||
result = []
|
||||
for segment, members in groups.items():
|
||||
first, last = members[0], members[-1]
|
||||
result.append({"segment_index": segment, "start_index": first["window_index"], "end_index": last["window_index"],
|
||||
"start_offset_minutes": first["window_offset_minutes"], "end_offset_minutes": last["window_offset_minutes"],
|
||||
"start_at": f'{first["candidate_date"]}T{first["time"]}',
|
||||
"end_at": f'{last["candidate_date"]}T{last["time"]}'})
|
||||
return result
|
||||
|
||||
|
||||
def interval_union_width(intervals: list[dict[str, Any]]) -> int:
|
||||
"""Keep historical within-segment envelope; never fill a declared segment gap."""
|
||||
groups: dict[int, list[dict[str, Any]]] = {}
|
||||
for row in sorted(intervals, key=lambda item: item["start_index"]):
|
||||
groups.setdefault(row["segment_index"], []).append(row)
|
||||
return sum(max(row["end_offset_minutes"] for row in rows) - min(row["start_offset_minutes"] for row in rows) + 1
|
||||
for rows in groups.values())
|
||||
@@ -53,7 +53,7 @@ _EVENT_PROVENANCE_FIELDS = frozenset({
|
||||
_REQUEST_FIELDS = frozenset({
|
||||
"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events",
|
||||
"ayanamsa", "node_mode", "asked_probe_keys", "declined_domains", "column_times", "minute_step", "blocks",
|
||||
"refresh_probes",
|
||||
"refresh_probes", "candidate_intervals",
|
||||
}) | _REQUEST_PROVENANCE_FIELDS
|
||||
ASKED_PROBE_KEY_MAX_LENGTH = 200
|
||||
_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) | _EVENT_PROVENANCE_FIELDS
|
||||
@@ -175,6 +175,7 @@ class RectificationRequest(TypedDict):
|
||||
refresh_probes: NotRequired[bool]
|
||||
minute_step: NotRequired[int]
|
||||
blocks: NotRequired[list[dict[str, Any]]]
|
||||
candidate_intervals: NotRequired[list[dict[str, str]]]
|
||||
|
||||
|
||||
JsonObject = dict[str, Any]
|
||||
@@ -406,4 +407,7 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
cleaned_request["minute_step"] = minute_step
|
||||
if "blocks" in body:
|
||||
cleaned_request["blocks"] = _normalize_blocks(body, start_time, end_time)
|
||||
if "candidate_intervals" in body:
|
||||
from scripts.rectification.candidate_window import candidate_intervals
|
||||
cleaned_request["candidate_intervals"] = candidate_intervals(body)
|
||||
return cast(RectificationRequest, cleaned_request)
|
||||
|
||||
@@ -156,6 +156,11 @@ def indistinguishable_width_minutes(candidates: Sequence[dict[str, Any]]) -> int
|
||||
return 0
|
||||
ranked = sorted(candidates, key=lambda row: int(row.get("rank") or 0))
|
||||
top = ranked[0]
|
||||
if all(row.get("cluster_intervals") for row in ranked):
|
||||
from scripts.rectification.candidate_window import interval_union_width
|
||||
return max(int(top.get("tied_minute_count") or 1), interval_union_width([
|
||||
interval for row in ranked for interval in row["cluster_intervals"]
|
||||
]), 1)
|
||||
starts: list[int] = []
|
||||
ends: list[int] = []
|
||||
for row in ranked:
|
||||
@@ -428,7 +433,12 @@ def build_candidate_decisions(
|
||||
for other in all_scores
|
||||
)
|
||||
cluster_times, cluster_start, cluster_end = _cluster_span(row)
|
||||
from scripts.rectification.candidate_window import intervals_from_positions
|
||||
position = {key: row[key] for key in ("candidate_date", "window_index", "window_offset_minutes", "segment_index") if key in row}
|
||||
coverage = intervals_from_positions(row.get("cluster_positions", []))
|
||||
decisions.append({
|
||||
**position,
|
||||
**({"cluster_intervals": coverage} if coverage else {}),
|
||||
"candidate_id": str(uuid5(NAMESPACE_URL, f"{POLICY_VERSION}:{result_id}:{row['time']}")),
|
||||
"rank": index + 1,
|
||||
"time": row["time"],
|
||||
@@ -455,7 +465,8 @@ def _cluster_span(row: dict[str, Any]) -> tuple[list[str], str, str]:
|
||||
if not times:
|
||||
clock = str(row.get("time") or "")[:5]
|
||||
times = [clock] if len(clock) == 5 and clock[2] == ":" else []
|
||||
times.sort(key=lambda value: int(value[:2]) * 60 + int(value[3:5]))
|
||||
positions = {item["time"]: item["window_index"] for item in row.get("cluster_positions", [])}
|
||||
times.sort(key=lambda value: positions.get(value, int(value[:2]) * 60 + int(value[3:5])))
|
||||
start = times[0] if times else str(row.get("time") or "")[:5]
|
||||
end = times[-1] if times else start
|
||||
return times, start, end
|
||||
|
||||
@@ -98,8 +98,12 @@ def _features(built: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
continue
|
||||
time = _feature_time(feature)
|
||||
if time:
|
||||
rows.append(feature)
|
||||
rows.sort(key=lambda item: _clock(str(_feature_time(item))))
|
||||
if "candidate_intervals" in built:
|
||||
position = {key: context[key] for key in ("candidate_date", "window_index", "window_offset_minutes", "segment_index")}
|
||||
rows.append({**feature, **position})
|
||||
else:
|
||||
rows.append(feature)
|
||||
rows.sort(key=lambda item: item.get("window_index", _clock(str(_feature_time(item)))))
|
||||
return rows
|
||||
|
||||
|
||||
@@ -224,27 +228,43 @@ def window_scan(
|
||||
counts: dict[str, set[int]] = {layer: set() for layer in _LAYER_LABEL}
|
||||
transitions: list[dict[str, Any]] = []
|
||||
previous: dict[str, int | None] | None = None
|
||||
for feature in _features(built):
|
||||
previous_segment: int | None = None
|
||||
features = _features(built)
|
||||
if start_minute is not None and end_minute is not None:
|
||||
features = [feature for feature in features
|
||||
if start_minute <= _clock(str(_feature_time(feature))) <= end_minute]
|
||||
segment_bounds: dict[int, tuple[str, str]] = {}
|
||||
for feature in features:
|
||||
if "window_index" in feature:
|
||||
segment = feature["segment_index"]
|
||||
stamp = f'{feature["candidate_date"]}T{_feature_time(feature)}'
|
||||
segment_bounds[segment] = (segment_bounds.get(segment, (stamp, stamp))[0], stamp)
|
||||
for feature in features:
|
||||
time = _feature_time(feature)
|
||||
if time and start_minute is not None and end_minute is not None:
|
||||
clock = _clock(time)
|
||||
if clock < start_minute or clock > end_minute:
|
||||
continue
|
||||
current = {layer: _scan_layer_value(feature, layer) for layer in _LAYER_LABEL}
|
||||
for layer, bucket in counts.items():
|
||||
value = current[layer]
|
||||
if isinstance(value, int):
|
||||
bucket.add(value)
|
||||
if previous and time:
|
||||
dated = "window_index" in feature
|
||||
segment_start = dated and feature["segment_index"] != previous_segment
|
||||
if (previous or segment_start) and time:
|
||||
for layer, label in _LAYER_LABEL.items():
|
||||
before = previous[layer]
|
||||
after = current[layer]
|
||||
if isinstance(before, int) and isinstance(after, int) and before != after:
|
||||
before = after if segment_start else previous[layer]
|
||||
if isinstance(before, int) and isinstance(after, int) and (before != after or segment_start):
|
||||
row = {
|
||||
"layer": layer,
|
||||
"at": time,
|
||||
"user_meaning": f"{label} 在 {time} 发生变化",
|
||||
}
|
||||
if dated:
|
||||
row.update({key: feature[key] for key in ("candidate_date", "window_index", "window_offset_minutes", "segment_index")})
|
||||
start_at, end_at = segment_bounds[feature["segment_index"]]
|
||||
row.update(segment_start_at=start_at, segment_end_at=end_at)
|
||||
if segment_start:
|
||||
row["segment_start"] = True
|
||||
row["user_meaning"] = f"{label} 在这一段起点的状态"
|
||||
from_sign = _sign_name(before)
|
||||
to_sign = _sign_name(after)
|
||||
if from_sign and to_sign:
|
||||
@@ -252,6 +272,7 @@ def window_scan(
|
||||
row["to_sign"] = to_sign
|
||||
transitions.append(row)
|
||||
previous = current
|
||||
previous_segment = feature.get("segment_index")
|
||||
payload: dict[str, Any] = {
|
||||
"scanned": True,
|
||||
"confirmation_allowed": False,
|
||||
@@ -472,8 +493,10 @@ def lagna_contrast(built: dict[str, Any]) -> dict[str, Any] | None:
|
||||
index = feature.get("ascendant_sign_index")
|
||||
if not time or not isinstance(index, int) or index < 0 or index > 11:
|
||||
continue
|
||||
if current and current["d1_lagna_index"] == index:
|
||||
if current and current["d1_lagna_index"] == index and current.get("segment_index") == feature.get("segment_index"):
|
||||
current["end"] = time
|
||||
if "candidate_date" in feature:
|
||||
current["end_at"] = f'{feature["candidate_date"]}T{time}'
|
||||
continue
|
||||
if current:
|
||||
intervals.append(current)
|
||||
@@ -482,6 +505,8 @@ def lagna_contrast(built: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"start": time,
|
||||
"end": time,
|
||||
"d1_lagna_index": index,
|
||||
**({"segment_index": feature["segment_index"], "start_at": f'{feature["candidate_date"]}T{time}',
|
||||
"end_at": f'{feature["candidate_date"]}T{time}'} if "candidate_date" in feature else {}),
|
||||
"lagna": sign,
|
||||
"lords": {
|
||||
"l1": _house_lord_zh(index, 1),
|
||||
@@ -495,11 +520,16 @@ def lagna_contrast(built: dict[str, Any]) -> dict[str, Any] | None:
|
||||
if len(intervals) < 2:
|
||||
return None
|
||||
left, right = intervals[0], intervals[1]
|
||||
def interval_label(interval: dict[str, Any]) -> str:
|
||||
if "start_at" in interval:
|
||||
return f"{interval['start_at'].replace('T', ' ')}–{interval['end_at'].replace('T', ' ')}"
|
||||
return f"{interval['start']}-{interval['end']}"
|
||||
|
||||
return {
|
||||
"intervals": intervals[:3],
|
||||
"user_meaning": (
|
||||
f"窗口里出现两段本命上升:{left['start']}-{left['end']} 为{left['lagna']},"
|
||||
f"{right['start']}-{right['end']} 为{right['lagna']}。"
|
||||
f"窗口里出现两段本命上升:{interval_label(left)} 为{left['lagna']},"
|
||||
f"{interval_label(right)} 为{right['lagna']}。"
|
||||
"可并列 D9/D10 类型表作校时方法,不是命运承诺,也不能确认唯一分钟。"
|
||||
),
|
||||
"unique_minute_claim": False,
|
||||
@@ -618,6 +648,24 @@ def cluster_scan(
|
||||
width_minutes: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Scan only the indistinguishable candidate cluster, not the full declared range."""
|
||||
if "candidate_intervals" in built:
|
||||
wanted = {str(value)[:5] for value in [*candidate_times, representative_time] if value}
|
||||
features = _features(built)
|
||||
selected: dict[int, list[int]] = {}
|
||||
for feature in features:
|
||||
if _feature_time(feature) in wanted:
|
||||
selected.setdefault(feature["segment_index"], []).append(feature["window_offset_minutes"])
|
||||
if not selected:
|
||||
return window_scan(built)
|
||||
bounds = {segment: (min(points), max(points)) for segment, points in selected.items()}
|
||||
if len(bounds) == 1:
|
||||
segment, (lo, hi) = next(iter(bounds.items()))
|
||||
extra = max(int(width_minutes or 0) - (hi - lo + 1), 0)
|
||||
bounds[segment] = (lo - extra // 2, hi + extra - extra // 2)
|
||||
contexts = [row for row in built.get("static_contexts") or []
|
||||
if row.get("segment_index") in bounds
|
||||
and bounds[row["segment_index"]][0] <= row["window_offset_minutes"] <= bounds[row["segment_index"]][1]]
|
||||
return window_scan({**built, "static_contexts": contexts})
|
||||
clocks: list[int] = []
|
||||
for raw in [*candidate_times, representative_time]:
|
||||
if isinstance(raw, str) and len(raw) >= 5:
|
||||
|
||||
@@ -13,8 +13,8 @@ from scripts.rectification.dasha_transition_proximity import merge_transition_pr
|
||||
from scripts.rectification.contracts import LifeEvent, RectificationRequest, is_scoreable_event
|
||||
from scripts.rectification.case_holdout import holdout_event_ids
|
||||
|
||||
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-8"
|
||||
INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v4"
|
||||
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-9"
|
||||
INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v5"
|
||||
PRECISION_WEIGHTS = {
|
||||
"day": 1.0,
|
||||
"month": 0.8,
|
||||
@@ -115,7 +115,7 @@ def _legacy_request(request: RectificationRequest, event: LifeEvent, sampled_dat
|
||||
"date": sampled_date, "precision": "day", "summary": event.get("summary", ""),
|
||||
}],
|
||||
}
|
||||
for key in ("ayanamsa", "node_mode", "minute_step"):
|
||||
for key in ("ayanamsa", "node_mode", "minute_step", "candidate_intervals"):
|
||||
if key in request:
|
||||
legacy_request[key] = request[key]
|
||||
return legacy_request
|
||||
@@ -307,6 +307,7 @@ def build_event_contribution_matrix(
|
||||
"date_sensitivity": date_sensitivity,
|
||||
"missing_layers": sorted(missing_layers),
|
||||
"static_contexts": static_contexts,
|
||||
**({"candidate_intervals": request["candidate_intervals"]} if "candidate_intervals" in request else {}),
|
||||
}
|
||||
|
||||
|
||||
@@ -341,7 +342,7 @@ def calculation_spec(request: RectificationRequest) -> dict[str, Any]:
|
||||
return int(value) if value.is_integer() else value
|
||||
|
||||
spec = {
|
||||
"version": INPUT_CONTRACT_VERSION,
|
||||
"version": INPUT_CONTRACT_VERSION if "candidate_intervals" in request else "rectification-calculation-spec-v4",
|
||||
"birthDate": request["birth_date"],
|
||||
"candidateRange": {"start": request["start_time"], "end": request["end_time"]},
|
||||
"latitude": json_number(request["lat"]),
|
||||
@@ -359,6 +360,8 @@ def calculation_spec(request: RectificationRequest) -> dict[str, Any]:
|
||||
):
|
||||
if source in request:
|
||||
spec[target] = request[source] # type: ignore[literal-required]
|
||||
if "candidate_intervals" in request:
|
||||
spec["candidateIntervals"] = request["candidate_intervals"]
|
||||
return spec
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Independent-process native A/B and dated-window goldens; not an accuracy benchmark."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import importlib
|
||||
import json
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def load(root: Path):
|
||||
for name in list(sys.modules):
|
||||
if name == "scripts" or name.startswith("scripts."):
|
||||
del sys.modules[name]
|
||||
sys.path.insert(0, str(root))
|
||||
importlib.invalidate_caches()
|
||||
from scripts.rectification.api_service import score_candidates
|
||||
from scripts.research.probe_supply_after_six import request_from_case
|
||||
from scripts.rectification.decision_policy import indistinguishable_width_minutes
|
||||
return score_candidates, request_from_case, indistinguishable_width_minutes
|
||||
|
||||
|
||||
def canonical(value):
|
||||
return json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":")).encode()
|
||||
|
||||
|
||||
def projection(result, width):
|
||||
new_fields = {"candidate_id", "candidate_date", "window_index", "window_offset_minutes", "segment_index", "cluster_intervals"}
|
||||
return {
|
||||
"candidate_scores": result["candidate_scores"],
|
||||
"matrix": result["event_contribution_matrix"],
|
||||
"decisions": [{key: value for key, value in row.items() if key not in new_fields} for row in result["candidate_decisions"]],
|
||||
"width": width(result["candidate_decisions"]),
|
||||
"confirmation_allowed": result["confirmation_allowed"],
|
||||
"selection_allowed": result["selection_allowed"],
|
||||
"representative_time": result["decision_receipt"]["representative_time"],
|
||||
}
|
||||
|
||||
|
||||
def worker(root: Path, dated: bool):
|
||||
score, make_request, width = load(root)
|
||||
dataset = ROOT / "references/real_case_calibration/minute_rectification_holdout_v3.json"
|
||||
result = []
|
||||
for case in json.loads(dataset.read_text(encoding="utf-8"))["cases"][:3]:
|
||||
request = make_request(case)
|
||||
if dated:
|
||||
request["candidate_intervals"] = [{"start_at": f'{request["birth_date"]}T{request["start_time"]}', "end_at": f'{request["birth_date"]}T{request["end_time"]}'}]
|
||||
result.append(projection(score(request), width))
|
||||
print(json.dumps(result))
|
||||
|
||||
|
||||
def independent(root: Path, dated: bool):
|
||||
completed = subprocess.run([sys.executable, str(Path(__file__).resolve()), "--worker", str(root), *( ["--dated"] if dated else [])],
|
||||
cwd=root, check=True, capture_output=True, text=True, encoding="utf-8")
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def main():
|
||||
if "--worker" in sys.argv:
|
||||
worker(Path(sys.argv[sys.argv.index("--worker") + 1]), "--dated" in sys.argv)
|
||||
return
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--baseline", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, default=ROOT / "artifacts/midnight-date-anchor")
|
||||
parser.add_argument("--golden", action="store_true")
|
||||
parser.add_argument("--golden-path", type=Path, default=ROOT / "frontend/tests/fixtures/rectification-midnight-date-anchor.native.json")
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
dataset = ROOT / "references/real_case_calibration/minute_rectification_holdout_v3.json"
|
||||
cases = json.loads(dataset.read_text(encoding="utf-8"))["cases"][:3]
|
||||
assert all(case["birth"]["source"]["rodden_rating"] == "AA" for case in cases)
|
||||
old = independent(args.baseline, False)
|
||||
current = independent(ROOT, True)
|
||||
comparisons = []
|
||||
for case, before, after in zip(cases, old, current):
|
||||
fields = {key: canonical(before[key]) == canonical(after[key]) for key in before}
|
||||
comparisons.append({"case_id": case["case_id"], "source": case["birth"]["source"], "fields": fields,
|
||||
"before_sha256": hashlib.sha256(canonical(before)).hexdigest(), "after_sha256": hashlib.sha256(canonical(after)).hexdigest(),
|
||||
"candidate_minutes": len(after["candidate_scores"]), "public_clusters": len(after["decisions"]), "width": after["width"]})
|
||||
print("current", case["case_id"], fields, flush=True)
|
||||
report = {"baseline": str(args.baseline), "dataset_sha256": hashlib.sha256(dataset.read_bytes()).hexdigest(),
|
||||
"same_machine_independent_processes": True, "python_executable": sys.executable, "numeric_tolerance": 0, "comparisons": comparisons,
|
||||
"excluded_fields": "new date/ordinal metadata; result/candidate IDs intentionally change with algorithm identity"}
|
||||
with (args.output / "independent-process-aa-ab.json").open("x", encoding="utf-8") as stream:
|
||||
json.dump(report, stream, indent=2)
|
||||
assert all(all(row["fields"].values()) for row in comparisons), "same-day native A/B changed"
|
||||
if args.golden:
|
||||
score, _, _ = load(ROOT)
|
||||
# Explicitly fictional birth/event facts; response is produced only by the unmocked native engine.
|
||||
request = {"birth_date": "2000-03-01", "start_time": "23:58", "end_time": "00:02", "lat": 0.0, "lon": 0.0, "tz": 0.0,
|
||||
"candidate_intervals": [{"start_at": "2000-02-29T23:58", "end_at": "2000-03-01T00:02"}],
|
||||
"events": [{"id": "00000000-0000-4000-8000-000000000001", "domain": "career", "event_kind": "career_entry",
|
||||
"date_start": "2020-01-01", "date_end": "2020-01-01", "precision": "day", "summary": "Fictional career entry for date-contract testing"}]}
|
||||
from scripts.rectification.contracts import normalize_rectification_request
|
||||
response = score(normalize_rectification_request(request))
|
||||
target = args.golden_path
|
||||
with target.open("x", encoding="utf-8") as stream:
|
||||
json.dump({"provenance": {"kind": "unmocked_native_engine", "facts": "explicitly_fictional", "generator": "scripts/research/midnight_date_anchor_regression.py --baseline <baseline-worktree> --golden", "algorithm": response["algorithm_version"]}, "request": request, "response": response}, stream, ensure_ascii=False, indent=2)
|
||||
print("golden", target, flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -30,8 +30,8 @@ from scripts.research.sealed_holdout_rerun import (
|
||||
OFFSETS = (-30, -20, -15, -10, -8, -5, -3, 0, 3, 5, 8, 10, 15, 20, 30)
|
||||
RADII = (15, 30, 60)
|
||||
MINUTE_STEP = 1
|
||||
FREEZE = ROOT / "docs/research/reported_offset_cross_midnight_2026_09_20.final.freeze.json"
|
||||
REPORT = ROOT / "docs/research/reported_offset_cross_midnight_2026_09_20.json"
|
||||
FREEZE = ROOT / "docs/research/reported_offset_midnight_anchor_2026_09_21.freeze.json"
|
||||
REPORT = ROOT / "docs/research/reported_offset_midnight_anchor_2026_09_21.json"
|
||||
LEGACY_REPORT = ROOT / "docs/research/reported_offset_2026_09_20.json"
|
||||
|
||||
|
||||
|
||||
@@ -27,8 +27,8 @@ from scripts.minute_rectification_feature_facts_v4 import build_feature_fact_row
|
||||
from scripts.minute_rectification_holdout_validator import validate
|
||||
|
||||
DATASET = ROOT / "references/real_case_calibration/minute_rectification_holdout_v3.json"
|
||||
FREEZE = ROOT / "docs/research/sealed_holdout_rerun_cross_midnight_2026_09_20.final.freeze.json"
|
||||
REPORT = ROOT / "docs/research/sealed_holdout_rerun_cross_midnight_2026_09_20.json"
|
||||
FREEZE = ROOT / "docs/research/sealed_holdout_rerun_midnight_anchor_2026_09_21.freeze.json"
|
||||
REPORT = ROOT / "docs/research/sealed_holdout_rerun_midnight_anchor_2026_09_21.json"
|
||||
LEGACY_REPORT = ROOT / "docs/research/sealed_holdout_rerun_2026_09_20.json"
|
||||
ARCHIVE = ROOT / "docs/research/history/rectification_pre_cross_midnight_2026_09_20"
|
||||
PRODUCTION_FILES = [
|
||||
@@ -38,6 +38,10 @@ PRODUCTION_FILES = [
|
||||
"scripts/rectification/case_holdout.py",
|
||||
"scripts/rectification/contracts.py",
|
||||
"scripts/rectification/event_probes.py",
|
||||
"scripts/rectification/candidate_window.py",
|
||||
"scripts/rectification/decision_policy.py",
|
||||
"scripts/rectification/refinement_packet.py",
|
||||
"scripts/rectification/api_service.py",
|
||||
]
|
||||
RESEARCH_FILES = [
|
||||
"scripts/research/reported_offset_sweep.py",
|
||||
|
||||
Reference in New Issue
Block a user