199 lines
9.4 KiB
Python
199 lines
9.4 KiB
Python
"""T04 模拟案例生成与数据集划分测试。
|
|||
|
|
|
||
|
|
先边界 fixture 固化逻辑(0 例、单组、近重复、跨时间边界、泄漏),
|
||
|
|
再验证批量生成的可复现性与配比(AC-35/36)。
|
||
|
|
|
||
|
|
全部使用临时目录;绝不写真实数据根。也不做任何模型调用。
|
||
|
|
"""
|
||
|
|
|
||
|
|
import tempfile
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
from contracts.prescreen import DatasetManifest, EventOutcome
|
||
|
|
from services import simulation_dataset as sim
|
||
|
|
|
||
|
|
|
||
|
|
def record(record_id, group, occurred, outcome="approved", label=True, near=None):
|
||
|
|
return {
|
||
|
|
"id": record_id, "synthetic": True, "data_domain": "demo",
|
||
|
|
"customer": f"虚构客户{group}", "customer_group": group,
|
||
|
|
"near_duplicate_of": near, "occurred_at": occurred, "outcome": outcome,
|
||
|
|
"outcome_subtype": outcome, "label": label, "initial_facts": {},
|
||
|
|
"trace": {"generator_version": sim.GENERATOR_VERSION, "seed": 1},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class BoundaryFixtureTests(unittest.TestCase):
|
||
|
|
"""边界先行:小样本手工 fixture,不依赖批量生成的规模。"""
|
||
|
|
|
||
|
|
def test_empty_input_is_rejected(self):
|
||
|
|
with self.assertRaises(ValueError) as ctx:
|
||
|
|
sim.split([])
|
||
|
|
self.assertIn("没有可划分", str(ctx.exception))
|
||
|
|
|
||
|
|
def test_single_group_stays_in_one_split(self):
|
||
|
|
records = [record(f"SIM-{i}", "CG-1", f"2024-01-0{i + 1}T00:00:00+00:00") for i in range(3)]
|
||
|
|
result = sim.split(records)
|
||
|
|
self.assertEqual(len({result["assignments"][r["id"]] for r in records}), 1)
|
||
|
|
|
||
|
|
def test_group_never_splits_across_splits(self):
|
||
|
|
records = []
|
||
|
|
for group in range(6):
|
||
|
|
for member in range(4):
|
||
|
|
day = group * 4 + member + 1
|
||
|
|
month = 1 + (day // 28)
|
||
|
|
records.append(record(f"SIM-{group}-{member}", f"CG-{group}",
|
||
|
|
f"2024-{month:02d}-{day % 28 + 1:02d}T00:00:00+00:00"))
|
||
|
|
result = sim.split(records)
|
||
|
|
sim.assert_group_integrity(records, result["assignments"])
|
||
|
|
|
||
|
|
def test_near_duplicate_must_share_a_split(self):
|
||
|
|
records = [record("SIM-1", "CG-1", "2024-01-01T00:00:00+00:00"),
|
||
|
|
record("SIM-2", "CG-1", "2024-06-01T00:00:00+00:00", near="SIM-1")]
|
||
|
|
result = sim.split(records)
|
||
|
|
self.assertEqual(result["assignments"]["SIM-1"], result["assignments"]["SIM-2"])
|
||
|
|
|
||
|
|
def test_near_duplicate_across_groups_is_detected(self):
|
||
|
|
# 人为把近重复放到不同组 → 必须被发现
|
||
|
|
records = [record("SIM-1", "CG-1", "2024-01-01T00:00:00+00:00"),
|
||
|
|
record("SIM-2", "CG-2", "2024-06-01T00:00:00+00:00", near="SIM-1")]
|
||
|
|
assignments = {"SIM-1": "development", "SIM-2": "holdout", "CG-1": "development", "CG-2": "holdout"}
|
||
|
|
with self.assertRaises(ValueError) as ctx:
|
||
|
|
sim.assert_group_integrity(records, assignments)
|
||
|
|
self.assertIn("跨集合", str(ctx.exception))
|
||
|
|
|
||
|
|
def test_group_straddling_split_is_detected(self):
|
||
|
|
records = [record("SIM-1", "CG-1", "2024-01-01T00:00:00+00:00"),
|
||
|
|
record("SIM-2", "CG-1", "2024-02-01T00:00:00+00:00")]
|
||
|
|
with self.assertRaises(ValueError):
|
||
|
|
sim.assert_group_integrity(records, {"SIM-1": "development", "SIM-2": "holdout"})
|
||
|
|
|
||
|
|
def test_holdout_source_cannot_be_used_for_extraction(self):
|
||
|
|
split_result = {"assignments": {"SIM-1": "development", "SIM-2": "holdout"}}
|
||
|
|
sim.assert_source_allowed(split_result, ["SIM-1"], "development")
|
||
|
|
with self.assertRaises(ValueError) as ctx:
|
||
|
|
sim.assert_source_allowed(split_result, ["SIM-1", "SIM-2"], "development")
|
||
|
|
self.assertIn("保留集", str(ctx.exception))
|
||
|
|
self.assertIn("作废", str(ctx.exception))
|
||
|
|
|
||
|
|
def test_holdout_itself_is_never_an_extraction_target(self):
|
||
|
|
with self.assertRaises(ValueError):
|
||
|
|
sim.assert_source_allowed({"assignments": {}}, ["SIM-1"], "holdout")
|
||
|
|
|
||
|
|
def test_unmapped_outcome_must_not_carry_a_label(self):
|
||
|
|
sim.assert_labelled_outcomes_are_mapped([record("SIM-1", "CG-1", "x", "approved", True)])
|
||
|
|
with self.assertRaises(ValueError) as ctx:
|
||
|
|
sim.assert_labelled_outcomes_are_mapped([record("SIM-1", "CG-1", "x", "conditional", True)])
|
||
|
|
self.assertIn("尚未确认口径", str(ctx.exception))
|
||
|
|
|
||
|
|
|
||
|
|
class GenerationTests(unittest.TestCase):
|
||
|
|
def test_same_seed_and_version_reproduce_identical_records(self):
|
||
|
|
first = sim.generate(seed=20260929, scale=0.02)
|
||
|
|
second = sim.generate(seed=20260929, scale=0.02)
|
||
|
|
self.assertEqual(first, second, "同 seed 同版本必须逐条一致")
|
||
|
|
self.assertEqual(sim.source_hash(first), sim.source_hash(second))
|
||
|
|
|
||
|
|
def test_different_seed_changes_the_data(self):
|
||
|
|
self.assertNotEqual(sim.source_hash(sim.generate(seed=1, scale=0.02)),
|
||
|
|
sim.source_hash(sim.generate(seed=2, scale=0.02)))
|
||
|
|
|
||
|
|
def test_records_cover_multiple_outcomes(self):
|
||
|
|
records = sim.generate(seed=7, scale=0.02)
|
||
|
|
outcomes = {r["outcome"] for r in records}
|
||
|
|
self.assertGreaterEqual(len(outcomes), 4, f"结果过于单一:{outcomes}")
|
||
|
|
|
||
|
|
def test_every_record_is_marked_synthetic_and_demo(self):
|
||
|
|
for item in sim.generate(seed=7, scale=0.02):
|
||
|
|
self.assertTrue(item["synthetic"])
|
||
|
|
self.assertEqual(item["data_domain"], "demo")
|
||
|
|
|
||
|
|
def test_unsubmitted_and_conditional_have_no_label(self):
|
||
|
|
# BR-21 / E-26:未送审、退出、待定、附条件一律不得当否决或通过
|
||
|
|
records = sim.generate(seed=7, scale=0.05)
|
||
|
|
for item in records:
|
||
|
|
if item["outcome"] in ("not_submitted", "withdrawn", "deferred", "conditional"):
|
||
|
|
self.assertIsNone(item["label"], f'{item["outcome"]} 不应带标签')
|
||
|
|
|
||
|
|
def test_supplement_and_direct_are_distinguished(self):
|
||
|
|
records = sim.generate(seed=7, scale=0.05)
|
||
|
|
subtypes = {r["outcome_subtype"] for r in records if r["outcome"] == "approved"}
|
||
|
|
self.assertEqual(subtypes, {"direct", "after_supplement"})
|
||
|
|
|
||
|
|
def test_outcome_values_are_all_in_the_contract_enum(self):
|
||
|
|
allowed = {item.value for item in EventOutcome}
|
||
|
|
for item in sim.generate(seed=7, scale=0.02):
|
||
|
|
self.assertIn(item["outcome"], allowed)
|
||
|
|
|
||
|
|
def test_scale_validation(self):
|
||
|
|
with self.assertRaises(ValueError):
|
||
|
|
sim.generate(scale=0)
|
||
|
|
with self.assertRaises(ValueError):
|
||
|
|
sim.generate(seed=-1)
|
||
|
|
|
||
|
|
def test_full_scale_matches_documented_mix(self):
|
||
|
|
# 14 §8 的配比:通过 1800 / 未通过 600 / 不计入 600,合计 3000
|
||
|
|
records = sim.generate()
|
||
|
|
self.assertEqual(len(records), 3000)
|
||
|
|
by_outcome = {}
|
||
|
|
for item in records:
|
||
|
|
by_outcome[item["outcome"]] = by_outcome.get(item["outcome"], 0) + 1
|
||
|
|
self.assertEqual(by_outcome["approved"], 1800)
|
||
|
|
self.assertEqual(by_outcome["rejected"], 600)
|
||
|
|
self.assertEqual(by_outcome["conditional"] + by_outcome["not_submitted"]
|
||
|
|
+ by_outcome["withdrawn"] + by_outcome["deferred"], 600)
|
||
|
|
|
||
|
|
|
||
|
|
class ManifestTests(unittest.TestCase):
|
||
|
|
def test_manifest_is_contract_valid_and_marked_demo(self):
|
||
|
|
records = sim.generate(seed=7, scale=0.05)
|
||
|
|
body = sim.manifest(records, sim.split(records), seed=7)
|
||
|
|
DatasetManifest(**body) # 不合法会抛
|
||
|
|
self.assertEqual(body["data_domain"], "demo")
|
||
|
|
self.assertTrue(body["synthetic"])
|
||
|
|
self.assertEqual(body["split_policy_version"], sim.SPLIT_POLICY_VERSION)
|
||
|
|
self.assertEqual(body["generator_version"], sim.GENERATOR_VERSION)
|
||
|
|
self.assertEqual(len(body["source_hash"]), 64)
|
||
|
|
|
||
|
|
def test_manifest_counts_match_records(self):
|
||
|
|
records = sim.generate(seed=7, scale=0.05)
|
||
|
|
body = sim.manifest(records, sim.split(records), seed=7)
|
||
|
|
total = sum(item["count"] for item in body["counts"])
|
||
|
|
self.assertEqual(total, len(records))
|
||
|
|
|
||
|
|
def test_two_runs_produce_the_same_manifest_hash(self):
|
||
|
|
# AC-35:同 seed/版本生成两次,规范化清单一致
|
||
|
|
first = sim.manifest(sim.generate(seed=11, scale=0.05), sim.split(sim.generate(seed=11, scale=0.05)), seed=11)
|
||
|
|
second = sim.manifest(sim.generate(seed=11, scale=0.05), sim.split(sim.generate(seed=11, scale=0.05)), seed=11)
|
||
|
|
self.assertEqual(first["source_hash"], second["source_hash"])
|
||
|
|
self.assertEqual(first["group_assignments_ref"], second["group_assignments_ref"])
|
||
|
|
self.assertEqual(first["counts"], second["counts"])
|
||
|
|
|
||
|
|
def test_synthetic_manifest_cannot_claim_production_domain(self):
|
||
|
|
records = sim.generate(seed=7, scale=0.02)
|
||
|
|
body = sim.manifest(records, sim.split(records), seed=7)
|
||
|
|
body["data_domain"] = "production"
|
||
|
|
with self.assertRaises(Exception):
|
||
|
|
DatasetManifest(**body)
|
||
|
|
|
||
|
|
|
||
|
|
class WriteTests(unittest.TestCase):
|
||
|
|
def test_write_refuses_paths_outside_the_data_root(self):
|
||
|
|
with tempfile.TemporaryDirectory() as tmp:
|
||
|
|
with self.assertRaises(ValueError) as ctx:
|
||
|
|
sim.write([], Path(tmp))
|
||
|
|
self.assertIn("xingbang_data", str(ctx.exception))
|
||
|
|
|
||
|
|
def test_write_only_targets_the_ignored_data_root(self):
|
||
|
|
records = sim.generate(seed=7, scale=0.02)
|
||
|
|
with tempfile.TemporaryDirectory() as tmp:
|
||
|
|
target = Path(tmp) / "xingbang_data" / "simulation"
|
||
|
|
path = sim.write(records, target)
|
||
|
|
self.assertTrue(path.exists())
|
||
|
|
self.assertEqual(len(path.read_text(encoding="utf-8").strip().splitlines()), len(records))
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|