feat(rectification): expose candidate result reports
This commit is contained in:
@@ -29,3 +29,9 @@
|
||||
- **顺手活一律未做,登记在此:** 其一,`frontend/src/lib/skill-package-registry.ts:523` 的 `readFileSync(currentRegistryPath, "utf8")` 触发 Turbopack 构建警告「Dynamic filesystem access causes tracing of the whole project」,会把整个项目(含 `public/`)打进 server 产物,影响部署体积;升级前后都存在,与本轮无关,未改。其二,`npx eslint` 有 4 个既有 `no-unused-vars` warning,分别在 `birth-time-candidate-result.tsx:143`、`birth-time-candidate-completion.ts:10,11`、`tests/identity-auth-factory.test.ts:48`,未改。其三,`eslint-config-next` 仍是 16.2.10、与 next 16.3.1 版本号不同步,但 eslint 实测 0 error,按「不许顺手升别的依赖」未动。
|
||||
- **测试环境噪声(非阻塞,已自行消化):** `frontend/tests/rectification-v9-database.test.ts` 的「v9 migration applies on a fresh database and re-applies idempotently」在全量并发下偶发失败(`database migration failed`,1 !== 0),单独重跑 7/7 通过、全量重跑 1592/1592 通过。靠 Docker 起临时 Postgres,判定为资源争用型 flake,与 Next 版本无关。未改任何测试文件。
|
||||
- **文件名偏离:** 任务书要求新建 `PROGRESS.md`,但根目录已有受版本控制的 `progress.md`(1025 行)且本机文件系统大小写不敏感,写 `PROGRESS.md` 等于覆盖清单外的文件,故进度记录落在 `PROGRESS-react-compiler-20260817.md`。
|
||||
|
||||
## 生时校正收敛重构任务 0(2026-08-31,分支 `codex/rectification-convergence-impl-20260830`)
|
||||
|
||||
- 无法获取任务书要求的上游 `interview_playbook.md`、`evidence_thresholds.md`:任务书所指的 `~/.workbuddy/skills/jyotish-birth-time-rectification/` 在当前执行环境不存在,仓库内只有测试对该外部路径的引用;未伪造文件,也没有可验证的上游来源可供导入。
|
||||
- 无法获取一次真实本地校正会话完整记录:当前仓库没有可证明为真实线上会话的完整原始记录,执行环境也没有受控会话/上游维护者提供的记录。因此无法可靠回答轮数、最终区间宽度、`confidence` 与 `can_apply`。
|
||||
- 该信息收集缺口不阻塞任务 1–3,按 v2 任务书继续实现并在进度文件中标记为未验证;不得据此声称已验证“固定题数后停止”的上游机制。
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# 生时校正收敛重构进度(2026-08-30)
|
||||
|
||||
## 基线与范围
|
||||
|
||||
- Git 根目录:`/Users/jesse/Downloads/Copse/astrology/yinduzhanxing`
|
||||
- 隔离 worktree:`/Users/jesse/Downloads/Copse/astrology/.worktrees/rectification-convergence-impl-20260830`
|
||||
- 分支:`codex/rectification-convergence-impl-20260830`
|
||||
- 基线:`origin/staging` @ `db6716e76c7a44adb568701dc811de52d8d29a96`
|
||||
- staging 同步范围:`7db2dd2d..db6716e7 staging -> staging`
|
||||
- 当前状态:已提交,未 push 或 deploy。
|
||||
|
||||
## 任务 0:上游资料与真实会话
|
||||
|
||||
当前无法完成,原因已记录在 `BLOCKED.md`:外部 `~/.workbuddy/skills/jyotish-birth-time-rectification/` 不存在,仓库没有受控的真实本地校正会话完整记录,也没有可验证的上游资料来源。因此以下三项不能被事实化回答:总轮数、最终交付区间宽度、`confidence` / `can_apply`。
|
||||
|
||||
基于 v2 任务书与当前仓库/上游代码比较,可以确认本轮落点不是更换评分器,而是恢复两个缺失的生产契约:
|
||||
|
||||
1. 服务端持久化预算必须被生产判定消费,超预算仍交付候选区间并结束;
|
||||
2. 第 0 轮起就要有可读的候选结果/校正报告,停下来时要说明下一步与局限。
|
||||
|
||||
尚不能确认或声称上游机制等于“问完固定题数后给出候选区间并停止”。
|
||||
|
||||
## 任务 1:预算与终止机制
|
||||
|
||||
已实现:
|
||||
|
||||
- 接入服务端持久化的 `inferenceRounds`、`effectiveAnswerCount`、`plateauRounds`。
|
||||
- 复用既有预算常量:`DEFAULT_MAX_DISCRIMINATION_ROUNDS = 8`、`RECTIFICATION_POLICY.maxPlateauRounds`、`EFFECTIVE_ANSWER_SAFETY_CAP = 10`。
|
||||
- `!separation.sufficient` 分支在继续 `discriminate` 前进行预算检查;超预算统一走既有 `completeWithRange(..., "exhausted")`。
|
||||
- 未修改 `convergence-evaluator.ts`、confirmation gate blocker 或 sealed holdout 阈值。
|
||||
- 新增 `frontend/tests/rectification-convergence-budget.test.ts`,覆盖可终止、连续拒答终止、超预算交付区间、区间宽度单调性。
|
||||
|
||||
## 任务 2:提问权与问题槽
|
||||
|
||||
已完成并审核:
|
||||
|
||||
- live path 不再 import/call `spoken-answer.ts`;该旧解析/拼接模块已删除。
|
||||
- 模型只输出确认/承接正文;`current_question` / `choice_card` 由服务端结构化返回。
|
||||
- UI 使用统一问题槽展示选择题和自由输入提示;缺失问题时显示可监控状态。
|
||||
- 旧历史回放保留原始 assistant 正文,不再重新解析或拼接问题。
|
||||
|
||||
## 任务 3:候选结果交代契约
|
||||
|
||||
已实现:
|
||||
|
||||
- 恢复 `build_candidate_result_summary()`。
|
||||
- `score_candidates()` / `diagnostics()` 返回 `candidate_summary`、`next_step_codes`、`stability.label` 与 `rectification_report`。
|
||||
- 报告包含当前候选区间、代表分钟及“代表性候选,不是唯一解”标记、置信度、逐条事件证据状态、technique layers、被排除候选、confirmation gate blocker 文案与局限声明。
|
||||
- `events=[]` 时第 0 轮区间回退到用户声明的出生窗口;跨午夜窗口已验证可用;不生成唯一分钟结论。
|
||||
- 新增回归覆盖零证据、跨午夜窗口、事件方法、排除候选、报告一致性与 `stability.label`。
|
||||
|
||||
## 已通过验证
|
||||
|
||||
- `python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45`:pass。
|
||||
- `python3.12 -m pytest -q tests/test_active_rectification_events.py tests/test_rectification_v5_services.py`:通过(当前修改对应测试)。
|
||||
- 前端聚焦测试:79/79 通过:
|
||||
`rectification-decision-authority.test.ts`、`rectification-decide-next-action.test.ts`、`rectification-inference-machine.test.ts`、`rectification-range-offer-deadend.test.ts`、`rectification-collect-stall.test.ts`、`rectification-convergence-budget.test.ts`。
|
||||
- `git diff --check`:通过。
|
||||
- 任务 2 聚焦测试:98/98 通过;相关补充测试(含问题槽 CSS 合同):38/38 通过。
|
||||
- `./node_modules/.bin/tsc --noEmit`:通过。
|
||||
- `npm run lint`:0 errors;25 个既有 warnings。
|
||||
- `npm run build`:通过;仅有既有 Turbopack filesystem tracing warnings。
|
||||
- `npm run test:db`:34/34 通过,fail=0。
|
||||
- 前端全量测试:2338/2338 通过,fail=0;使用 `PYTHON=/opt/anaconda3/bin/python3.12`,避免默认解释器缺少 PyYAML 的环境性失败。
|
||||
|
||||
## 交付状态
|
||||
|
||||
- 任务 1、任务 2、任务 3 已分别精确提交。
|
||||
- 任务 4(标定数据入口)与任务 5(清理/同步机制)按任务书明确留待后续,不在本轮实现。
|
||||
- 未 push、未 deploy;等待用户明确要求。
|
||||
@@ -108,6 +108,57 @@ class CandidateResult(TypedDict):
|
||||
stability_diagnostics: dict[str, Any]
|
||||
missing_layers: list[str]
|
||||
candidate_ranking_summary: NotRequired[list[dict[str, Any]]]
|
||||
candidate_summary: NotRequired[dict[str, Any]]
|
||||
|
||||
|
||||
def build_candidate_result_summary(result: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Project a candidate result into stable, non-confirmatory next steps."""
|
||||
supported: dict[str, dict[str, Any]] = {}
|
||||
unconfirmed: dict[str, dict[str, Any]] = {}
|
||||
contradictory: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for item in result.get("evidence", []):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
domain = str(item.get("domain") or "unknown")
|
||||
points = float(item.get("points") or 0)
|
||||
bucket = supported if points > 0 else contradictory if points < 0 else unconfirmed
|
||||
row = bucket.setdefault(domain, {"domain": domain, "event_count": 0, "total_points": 0.0, "rule_ids": set()})
|
||||
row["event_count"] += 1
|
||||
row["total_points"] += points
|
||||
row["rule_ids"].update(str(rule_id) for rule_id in item.get("rule_ids", []))
|
||||
|
||||
def rows(bucket: dict[str, dict[str, Any]], *, include_points: bool) -> list[dict[str, Any]]:
|
||||
output = []
|
||||
for row in bucket.values():
|
||||
item: dict[str, Any] = {
|
||||
"domain": row["domain"],
|
||||
"event_count": row["event_count"],
|
||||
"rule_ids": sorted(row["rule_ids"]),
|
||||
}
|
||||
if include_points:
|
||||
item["total_points"] = round(row["total_points"], 3)
|
||||
output.append(item)
|
||||
return sorted(output, key=lambda item: (-item.get("total_points", 0), item["domain"]))
|
||||
|
||||
segment = result.get("winning_segment")
|
||||
next_steps: list[str] = []
|
||||
if int(result.get("event_count") or 0) < 5:
|
||||
next_steps.append("collect_at_least_five_events")
|
||||
if float(result.get("margin_percent") or 0) <= 0 or "tied_leader" in (result.get("reasons") or []):
|
||||
next_steps.append("resolve_candidate_tie_or_narrow_window")
|
||||
if isinstance(segment, dict) and int(segment.get("width_minutes") or 0) > 5:
|
||||
next_steps.append("narrow_window_before_minute_claim")
|
||||
next_steps.append("do_not_apply_as_birth_time_truth")
|
||||
return {
|
||||
"claim_status": "candidate_range_not_birth_time_truth",
|
||||
"candidate_range": segment,
|
||||
"supporting_evidence": rows(supported, include_points=True),
|
||||
"unconfirmed_evidence": rows(unconfirmed, include_points=False),
|
||||
"contradictory_evidence": rows(contradictory, include_points=True),
|
||||
"reasons": list(result.get("reasons") or []),
|
||||
"next_step_codes": next_steps,
|
||||
}
|
||||
|
||||
|
||||
def precision_weight(precision: EventPrecision) -> float:
|
||||
@@ -347,4 +398,6 @@ def score_life_events(request: RectificationEventRequest) -> CandidateResult:
|
||||
"""Compute actual candidate rows, then apply the versioned confidence gates."""
|
||||
from scripts.active_rectification_event_engine import compute_event_candidate_result
|
||||
|
||||
return compute_event_candidate_result(request)
|
||||
result = compute_event_candidate_result(request)
|
||||
result["candidate_summary"] = build_candidate_result_summary(result)
|
||||
return result
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Sequence
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
from scripts.active_rectification_events import build_candidate_result_summary
|
||||
from scripts.rectification.candidate_feature_service import build_candidate_feature_snapshot
|
||||
from scripts.rectification.contracts import EVENT_CONTRACT_VERSION, RectificationRequest
|
||||
from scripts.rectification.contracts import (
|
||||
EVENT_CONTRACT_VERSION,
|
||||
RectificationRequest,
|
||||
is_primary_scoreable_event,
|
||||
)
|
||||
from scripts.rectification.decision_policy import (
|
||||
EXECUTION_LEDGER_VERSION,
|
||||
POLICY_VERSION,
|
||||
@@ -23,6 +28,130 @@ from scripts.rectification.scoring_service import (
|
||||
)
|
||||
|
||||
|
||||
def _clock_minutes(value: str) -> int:
|
||||
hour, minute = value[:5].split(":", 1)
|
||||
return int(hour) * 60 + int(minute)
|
||||
|
||||
|
||||
def _window_width(start_time: str, end_time: str) -> int:
|
||||
return (_clock_minutes(end_time) - _clock_minutes(start_time)) % 1_440 + 1
|
||||
|
||||
|
||||
def _report_candidate_range(
|
||||
request: RectificationRequest,
|
||||
candidate_scores: Sequence[dict[str, Any]],
|
||||
representative_time: str | None,
|
||||
) -> dict[str, Any]:
|
||||
top_score = max((float(row.get("score") or 0) for row in candidate_scores), default=None)
|
||||
top_times = [
|
||||
str(row.get("time"))[:5]
|
||||
for row in candidate_scores
|
||||
if top_score is not None and float(row.get("score") or 0) == top_score
|
||||
]
|
||||
if not top_times:
|
||||
return {
|
||||
"start_time": request["start_time"],
|
||||
"end_time": request["end_time"],
|
||||
"representative_time": representative_time,
|
||||
"width_minutes": _window_width(request["start_time"], request["end_time"]),
|
||||
"representative_is_unique": False,
|
||||
}
|
||||
return {
|
||||
"start_time": top_times[0],
|
||||
"end_time": top_times[-1],
|
||||
"representative_time": representative_time or top_times[len(top_times) // 2],
|
||||
"width_minutes": len(top_times),
|
||||
"representative_is_unique": False,
|
||||
}
|
||||
|
||||
|
||||
def _report_evidence(
|
||||
request: RectificationRequest,
|
||||
built: dict[str, Any],
|
||||
representative_time: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
matrix = built.get("matrix") or {}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for event in request.get("events") or []:
|
||||
if not is_primary_scoreable_event(event):
|
||||
continue
|
||||
contribution = (matrix.get(event["id"]) or {}).get(representative_time or "")
|
||||
contribution = contribution if isinstance(contribution, dict) else {}
|
||||
points = float(contribution.get("points") or 0)
|
||||
status = "supporting" if points > 0 else "contradictory" if points < 0 else "unconfirmed"
|
||||
rows.append({
|
||||
"event_id": event["id"],
|
||||
"summary": str(event.get("summary") or "").strip(),
|
||||
"domain": event["domain"],
|
||||
"date": {
|
||||
"start": event["date_start"],
|
||||
"end": event["date_end"],
|
||||
"precision": event["precision"],
|
||||
},
|
||||
"status": status,
|
||||
"supports_candidate_time": representative_time if status == "supporting" else None,
|
||||
"methods": sorted({str(layer) for layer in contribution.get("technique_layers") or []}),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _report_excluded_candidates(
|
||||
candidate_decisions: Sequence[dict[str, Any]],
|
||||
representative_time: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"time": str(candidate.get("time") or "")[:5],
|
||||
"reason": "not_the_leading_candidate",
|
||||
"representative_time": representative_time,
|
||||
}
|
||||
for candidate in candidate_decisions
|
||||
if str(candidate.get("time") or "")[:5] != (representative_time or "")
|
||||
]
|
||||
|
||||
|
||||
def _confirmation_blockers(receipt: dict[str, Any]) -> list[dict[str, str]]:
|
||||
allowed = {"VedAstro 分钟级校验", "唯一分钟确认"}
|
||||
return [
|
||||
{
|
||||
"technique": str(row.get("technique")),
|
||||
"status": str(row.get("status")),
|
||||
"user_meaning": str(row.get("note") or ""),
|
||||
}
|
||||
for row in receipt.get("technique_audit_table") or []
|
||||
if isinstance(row, dict)
|
||||
and str(row.get("technique")) in allowed
|
||||
and str(row.get("status")) != "executed"
|
||||
]
|
||||
|
||||
|
||||
def _rectification_report(
|
||||
request: RectificationRequest,
|
||||
built: dict[str, Any],
|
||||
candidate_scores: Sequence[dict[str, Any]],
|
||||
candidate_decisions: Sequence[dict[str, Any]],
|
||||
receipt: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
representative_time = str(receipt.get("representative_time") or "")[:5] or None
|
||||
blockers = _confirmation_blockers(receipt)
|
||||
candidate_range = _report_candidate_range(request, candidate_scores, representative_time)
|
||||
limitations = [item["user_meaning"] for item in blockers if item["user_meaning"]]
|
||||
if not limitations:
|
||||
limitations.append("本会话以代表性时间收口,不确认唯一分钟。")
|
||||
return {
|
||||
"candidate_range": candidate_range,
|
||||
"representative_time": representative_time,
|
||||
"representative_label": "代表性候选,不是唯一解",
|
||||
"confidence": receipt.get("overall_confidence", "low"),
|
||||
"evidence": _report_evidence(request, built, representative_time),
|
||||
"excluded_candidates": _report_excluded_candidates(candidate_decisions, representative_time),
|
||||
"next_step_codes": [],
|
||||
"confirmation_gate_blockers": blockers,
|
||||
"limitations": limitations,
|
||||
"claim_status": "candidate_range_not_birth_time_truth",
|
||||
}
|
||||
|
||||
|
||||
def candidate_features(request: RectificationRequest) -> dict[str, Any]:
|
||||
spec = calculation_spec(request)
|
||||
spec_hash = sha256(spec)
|
||||
@@ -55,6 +184,35 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
|
||||
decision_receipt = build_decision_receipt(request, candidate_decisions, built, diagnostic_values)
|
||||
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
|
||||
report_range = _report_candidate_range(request, rows, representative_time)
|
||||
report_evidence = _report_evidence(request, built, representative_time)
|
||||
summary_evidence = [
|
||||
{
|
||||
"event_id": item["event_id"],
|
||||
"domain": item["domain"],
|
||||
"candidate_time": representative_time or "",
|
||||
"rule_ids": item["methods"],
|
||||
"points": 1 if item["status"] == "supporting" else -1 if item["status"] == "contradictory" else 0,
|
||||
}
|
||||
for item in report_evidence
|
||||
]
|
||||
candidate_summary = build_candidate_result_summary({
|
||||
"winning_segment": report_range,
|
||||
"event_count": len(scoring_request.get("events", [])),
|
||||
"margin_percent": decision_receipt.get("margin_percent", 0),
|
||||
"reasons": decision_receipt.get("reasons", []),
|
||||
"evidence": summary_evidence,
|
||||
})
|
||||
candidate_summary["stability"] = {"label": decision_receipt.get("overall_confidence", "low")}
|
||||
rectification_report = _rectification_report(
|
||||
request, built, [{
|
||||
"time": row["time"],
|
||||
"score": row["score"],
|
||||
} for row in rows], candidate_decisions, decision_receipt,
|
||||
)
|
||||
rectification_report["next_step_codes"] = candidate_summary["next_step_codes"]
|
||||
candidate_summary["report"] = rectification_report
|
||||
return {
|
||||
"result_id": result_id,
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
@@ -78,6 +236,10 @@ def score_candidates(request: RectificationRequest) -> dict[str, Any]:
|
||||
scoring_request, spec_hash, built.get("static_contexts")
|
||||
),
|
||||
"diagnostics": diagnostic_values,
|
||||
"candidate_summary": candidate_summary,
|
||||
"next_step_codes": candidate_summary["next_step_codes"],
|
||||
"stability": candidate_summary["stability"],
|
||||
"rectification_report": rectification_report,
|
||||
"robustness": {
|
||||
"neighbor_support_minutes": diagnostic_values.get("neighbor_support_minutes", 0),
|
||||
"leave_one_out_retention_rate": diagnostic_values.get("leave_one_event_out_retention_rate", 0),
|
||||
@@ -112,6 +274,10 @@ def diagnostics(request: RectificationRequest) -> dict[str, Any]:
|
||||
"execution_ledger_version": scored["execution_ledger_version"],
|
||||
"execution_ledger": scored["execution_ledger"],
|
||||
"diagnostics": scored["diagnostics"],
|
||||
"candidate_summary": scored.get("candidate_summary", {"next_step_codes": ["do_not_apply_as_birth_time_truth"]}),
|
||||
"next_step_codes": scored.get("next_step_codes", ["do_not_apply_as_birth_time_truth"]),
|
||||
"stability": scored.get("stability", {"label": scored.get("overall_confidence", "low")}),
|
||||
"rectification_report": scored.get("rectification_report", {}),
|
||||
"missing_layers": scored["missing_layers"],
|
||||
"display_allowed": scored["display_allowed"],
|
||||
"selection_allowed": scored["selection_allowed"],
|
||||
|
||||
@@ -156,8 +156,8 @@ def normalize_rectification_request(body: Any, *, today: date | None = None) ->
|
||||
if not isinstance(end_time, str) or not _CLOCK.fullmatch(end_time):
|
||||
raise ValueError("end_time must be HH:MM")
|
||||
events = body.get("events")
|
||||
if not isinstance(events, list) or not 1 <= len(events) <= 100:
|
||||
raise ValueError("events must contain between 1 and 100 items")
|
||||
if not isinstance(events, list) or not 0 <= len(events) <= 100:
|
||||
raise ValueError("events must contain between 0 and 100 items")
|
||||
upper_date = today or date.today()
|
||||
cleaned_events: list[LifeEvent] = []
|
||||
for index, raw_event in enumerate(events):
|
||||
|
||||
@@ -680,6 +680,72 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
self.assertEqual(API_COMMAND_MAP[command], endpoint)
|
||||
self.assertIn(endpoint, TECHNIQUE_EXAMPLE_ENDPOINTS)
|
||||
|
||||
def test_zero_evidence_returns_declared_window_report(self):
|
||||
body = request(start_time="23:58", end_time="00:02")
|
||||
body["events"] = []
|
||||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||||
|
||||
result = score_candidates(normalized)
|
||||
report = result["rectification_report"]
|
||||
|
||||
self.assertEqual(report["candidate_range"]["start_time"], "23:58")
|
||||
self.assertEqual(report["candidate_range"]["end_time"], "00:02")
|
||||
self.assertEqual(report["candidate_range"]["width_minutes"], 5)
|
||||
self.assertIsNone(report["representative_time"])
|
||||
self.assertEqual(report["evidence"], [])
|
||||
self.assertFalse(report["candidate_range"]["representative_is_unique"])
|
||||
self.assertIn("do_not_apply_as_birth_time_truth", result["next_step_codes"])
|
||||
|
||||
def test_report_exposes_evidence_excluded_candidates_and_diagnostics_consistently(self):
|
||||
body = request()
|
||||
body["events"] = [
|
||||
event(1, "education", "education_start"),
|
||||
event(2, "career", "promotion"),
|
||||
]
|
||||
normalized = normalize_rectification_request(body, today=date(2026, 7, 28))
|
||||
built = {
|
||||
"candidate_times": ["05:13", "05:14", "05:15"],
|
||||
"matrix": {
|
||||
item["id"]: {
|
||||
"05:13": {"points": 4, "rule_ids": ["D10:test"], "technique_layers": ["D10"]},
|
||||
"05:14": {"points": 4, "rule_ids": ["D9:test"], "technique_layers": ["D9"]},
|
||||
"05:15": {"points": -1, "rule_ids": ["D1:test"], "technique_layers": ["D1"]},
|
||||
}
|
||||
for item in normalized["events"]
|
||||
},
|
||||
"date_sensitivity": [],
|
||||
"missing_layers": [],
|
||||
"static_contexts": [
|
||||
{"feature": {"time": "05:13", "ascendant_sign_index": 1}},
|
||||
{"feature": {"time": "05:14", "ascendant_sign_index": 2}},
|
||||
{"feature": {"time": "05:15", "ascendant_sign_index": 3}},
|
||||
],
|
||||
}
|
||||
diagnostic_values = {
|
||||
"primary_cluster_retention_rate": 1,
|
||||
"leave_one_event_out_retention_rate": 1,
|
||||
"leave_one_domain_out_retention_rate": 1,
|
||||
"date_sensitivity_retention_rate": 1,
|
||||
"neighbor_support_minutes": 1,
|
||||
"primary_secondary_margin_percent": 30,
|
||||
}
|
||||
with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch(
|
||||
"scripts.rectification.api_service.build_candidate_feature_snapshot", return_value={}
|
||||
), patch("scripts.rectification.api_service.run_diagnostics", return_value=diagnostic_values):
|
||||
scored = score_candidates(normalized)
|
||||
diagnostic_result = diagnostics(normalized)
|
||||
|
||||
report = scored["rectification_report"]
|
||||
self.assertEqual(report["candidate_range"]["start_time"], "05:13")
|
||||
self.assertEqual(report["candidate_range"]["end_time"], "05:14")
|
||||
self.assertEqual(report["representative_time"], "05:13")
|
||||
self.assertEqual([row["status"] for row in report["evidence"]], ["supporting", "supporting"])
|
||||
self.assertEqual(report["evidence"][0]["methods"], ["D10"])
|
||||
self.assertGreaterEqual(len(report["excluded_candidates"]), 1)
|
||||
self.assertEqual(scored["candidate_summary"]["stability"]["label"], scored["overall_confidence"])
|
||||
self.assertEqual(diagnostic_result["rectification_report"], report)
|
||||
self.assertEqual(diagnostic_result["next_step_codes"], scored["next_step_codes"])
|
||||
|
||||
def test_http_handler_enforces_subject_and_event_kind_boundaries(self):
|
||||
handler = object.__new__(JyotishAPIHandler)
|
||||
retained = handler._rectification_v5_request(request(domain="family", event_kind="family_event"))
|
||||
|
||||
Reference in New Issue
Block a user