feat(rectification): add adaptive question selector

This commit is contained in:
Jesse_Chen
2026-09-03 23:48:14 +08:00
parent 49cd66c537
commit 0be6fcfb4c
12 changed files with 741 additions and 32 deletions
+12 -8
View File
@@ -4,11 +4,11 @@ from scripts.active_rectification_questions import build_questionnaire, score_an
def test_active_rectification_questions_generate_choice_based_workflow() -> None:
report = build_questionnaire("1955-02-24 19:15", uncertainty_minutes=30)
report = build_questionnaire("2001-02-03 10:20", uncertainty_minutes=30)
assert report["scope"] == "active_birth_time_rectification_questionnaire"
assert report["candidate_scan"]["start"] == "1955-02-24 18:45"
assert report["candidate_scan"]["end"] == "1955-02-24 19:45"
assert report["candidate_scan"]["start"] == "2001-02-03 09:50"
assert report["candidate_scan"]["end"] == "2001-02-03 10:50"
assert report["candidate_scan"]["candidate_count"] == 61
assert len(report["candidate_scan"]["samples"]) == 61
assert report["candidate_scan"]["sensitivity_summary"]["method"] == "minute_feature_scan_v2"
@@ -25,10 +25,13 @@ def test_active_rectification_questions_generate_choice_based_workflow() -> None
)
assert report["candidate_scan"]["samples"][0]["cluster"] == "early_candidate_cluster"
assert report["candidate_scan"]["samples"][-1]["cluster"] == "late_candidate_cluster"
assert report["selection"]["selected_questions"]
assert all("factual_reliability" in question for question in report["questions"])
assert all("positive_cluster" in question and "negative_cluster" in question for question in report["questions"])
def test_active_rectification_scores_answers_and_selects_next_round() -> None:
report = build_questionnaire("1955-02-24 19:15", uncertainty_minutes=30)
report = build_questionnaire("2001-02-03 10:20", uncertainty_minutes=30)
scored = score_answers(
report,
{
@@ -44,16 +47,17 @@ def test_active_rectification_scores_answers_and_selects_next_round() -> None:
assert scored["answered_count"] == 5
assert scored["next_round"] == 2
assert scored["next_round_questions"]
assert scored["next_round_selection"]["selected_questions"]
assert scored["candidate_cluster_rankings"][0]["score"] > scored["candidate_cluster_rankings"][-1]["score"]
assert "does not convert candidates into birth-time truth" in scored["boundary"]
def test_active_rectification_recasts_candidate_vargas_when_location_is_available() -> None:
report = build_questionnaire(
"1993-04-17 14:49",
uncertainty_minutes=30,
lat=36.683333,
lon=114.35,
"2001-02-03 10:20",
uncertainty_minutes=1,
lat=25.04,
lon=121.56,
tz=8,
)
summary = report["candidate_scan"]["sensitivity_summary"]
+156
View File
@@ -0,0 +1,156 @@
from __future__ import annotations
from scripts.active_rectification_questions import build_questionnaire
from scripts.active_rectification_selector import select_next_questions
def test_selector_asks_one_question_and_ranks_by_separation() -> None:
questionnaire = build_questionnaire("2001-02-03 10:20", uncertainty_minutes=30)
selection = questionnaire["selection"]
assert len(selection["selected_questions"]) == 1
assert selection["selected_questions"][0]["id"] == questionnaire["questions"][0]["id"]
assert selection["selected_questions"][0]["why_asked"]
assert selection["selected_questions"][0]["candidate_ids_distinguished"]
assert selection["selected_questions"][0]["technique_routes"]
assert selection["ranking"]
def test_selector_skips_non_discriminating_questions() -> None:
questionnaire = {
"question_bank": [
{
"id": "neutral_only",
"domain": "fine_timing",
"prompt": "几乎同时吗?",
"sensitivity": ["KP_cusp"],
"scoring_map": {
"A": {"cluster": "neutral", "points": 0},
"B": {"cluster": "neutral", "points": 0},
"C": {"cluster": "neutral", "points": 0},
"D": {"cluster": "neutral", "points": 0},
},
},
{
"id": "usable_question",
"domain": "career",
"prompt": "工作是否变动?",
"sensitivity": ["D10"],
"scoring_map": {
"A": {"cluster": "career_up", "points": 2},
"B": {"cluster": "career_up", "points": 1},
"C": {"cluster": "career_down", "points": -2},
"D": {"cluster": "neutral", "points": 0},
},
},
],
"candidate_scan": {"candidate_count": 61},
}
selection = select_next_questions(questionnaire, {}, limit=1)
assert selection["selected_questions"][0]["id"] == "usable_question"
assert any(item["question_id"] == "neutral_only" and item["skipped"] for item in selection["ranking"])
def test_selector_changes_domain_after_uncertainty() -> None:
questionnaire = build_questionnaire("2001-02-03 10:20", uncertainty_minutes=30)
first = questionnaire["questions"][0]["id"]
selection = select_next_questions(
{
"question_bank": questionnaire["questions"],
"candidate_scan": questionnaire["candidate_scan"],
},
{first: "D"},
limit=1,
)
assert selection["selected_questions"]
assert selection["selected_questions"][0]["id"] != first
assert selection["selected_questions"][0]["domain"] != questionnaire["questions"][0]["domain"]
def test_selector_stops_when_no_answer_can_improve_separation() -> None:
questionnaire = {
"question_bank": [
{
"id": "fine_only",
"domain": "fine_timing",
"prompt": "先内后外吗?",
"sensitivity": ["KP_cusp"],
"scoring_map": {
"A": {"cluster": "neutral", "points": 0},
"B": {"cluster": "neutral", "points": 0},
"C": {"cluster": "neutral", "points": 0},
"D": {"cluster": "neutral", "points": 0},
},
}
],
"candidate_scan": {"candidate_count": 61},
}
selection = select_next_questions(questionnaire, {}, limit=1)
assert selection["selected_questions"] == []
assert selection["stop"] is True
assert selection["stop_reason"] == "no_answer_can_improve_separation"
def _minute_question(question_id: str, domain: str, layer: str) -> dict[str, object]:
return {
"id": question_id,
"domain": domain,
"prompt": "这个虚构事件是否发生?",
"sensitivity": [layer],
"positive_cluster": f"{question_id}_yes",
"negative_cluster": f"{question_id}_no",
"factual_reliability": 0.9,
"domain_priority": 1,
"scoring_map": {
"A": {"cluster": f"{question_id}_yes", "points": 2},
"B": {"cluster": f"{question_id}_yes", "points": 1},
"C": {"cluster": f"{question_id}_no", "points": -2},
"D": {"cluster": "neutral", "points": 0},
},
}
def test_selector_changes_with_remaining_candidate_window() -> None:
questions = [
_minute_question("relationship_split", "relationship", "D9"),
_minute_question("career_split", "career", "D10"),
]
relationship_window = {
"candidate_count": 3,
"transitions": [{"between": ["10:19", "10:20"]}],
"rows": [
{"divisional_ascendants": {"D9": {"sign": "Aries", "degree": 1}, "D10": {"sign": "Leo"}}},
{"divisional_ascendants": {"D9": {"sign": "Taurus", "degree": 2}, "D10": {"sign": "Leo"}}},
{"divisional_ascendants": {"D9": {"sign": "Gemini", "degree": 3}, "D10": {"sign": "Leo"}}},
],
}
career_window = {
"candidate_count": 3,
"transitions": [{"between": ["10:20", "10:21"]}],
"rows": [
{"divisional_ascendants": {"D9": {"sign": "Aries"}, "D10": {"sign": "Leo", "degree": 1}}},
{"divisional_ascendants": {"D9": {"sign": "Aries"}, "D10": {"sign": "Virgo", "degree": 2}}},
{"divisional_ascendants": {"D9": {"sign": "Aries"}, "D10": {"sign": "Libra", "degree": 3}}},
],
}
first = select_next_questions(
{"question_bank": questions, "candidate_scan": {"candidate_count": 3, "minute_scan": relationship_window}},
{},
)
second = select_next_questions(
{"question_bank": questions, "candidate_scan": {"candidate_count": 3, "minute_scan": career_window}},
{},
)
assert first["selected_questions"][0]["id"] == "relationship_split"
assert second["selected_questions"][0]["id"] == "career_split"
assert first["selected_questions"][0]["minute_relevance"] > 0
assert second["selected_questions"][0]["minute_relevance"] > 0
@@ -5,14 +5,14 @@ def test_scanner_reports_real_divisional_transitions(monkeypatch):
def fake_engine(command, payload, timeout=20):
minute = payload["minute"]
if command == "chart":
return {"ascendant": {"sign": "Leo", "degree_in_sign": 10 + minute / 100}}
return {"ascendant": {"sign": "Leo", "degree_in_sign": 10 + minute / 100, "lon": 130 + minute / 100}}
ascendant = "Aries" if minute % 2 else "Taurus"
return {
"D4_Turyamsa": {"Ascendant": {"sign": ascendant}},
"D9_Navamsa": {"Ascendant": {"sign": ascendant}},
"D10_Dasamsa": {"Ascendant": {"sign": ascendant}},
"D24_Siddhamsa": {"Ascendant": {"sign": ascendant}},
"D30_Trimsamsa": {"Ascendant": {"sign": ascendant}},
"D4_Turyamsa": {"Ascendant": {"sign": ascendant, "degree_in_sign": minute / 10, "lon": minute}},
"D9_Navamsa": {"Ascendant": {"sign": ascendant, "degree_in_sign": minute / 10, "lon": minute}},
"D10_Dasamsa": {"Ascendant": {"sign": ascendant, "degree_in_sign": minute / 10, "lon": minute}},
"D24_Siddhamsa": {"Ascendant": {"sign": ascendant, "degree_in_sign": minute / 10, "lon": minute}},
"D30_Trimsamsa": {"Ascendant": {"sign": ascendant, "degree_in_sign": minute / 10, "lon": minute}},
}
monkeypatch.setattr(scanner, "_engine_json", fake_engine)
@@ -24,7 +24,9 @@ def test_scanner_reports_real_divisional_transitions(monkeypatch):
assert report["candidate_count"] == 3
assert report["transitions"]
assert report["pending_layers"] == ["UL", "A7", "A10", "KP_cusp"]
assert report["rows"][0]["divisional_ascendants"]["D9"] in {"Aries", "Taurus"}
assert report["rows"][0]["d1_longitude"] is not None
assert report["rows"][0]["divisional_ascendants"]["D9"]["sign"] in {"Aries", "Taurus"}
assert "D1" not in scanner._VARGAS
assert report["input_contract"]["settings"]["node_mode"] == "mean"
assert report["rows"][0]["input_fingerprint"] != report["rows"][1]["input_fingerprint"]
assert report["stability_contract"]["minute_confirmation_allowed"] is False