Files
mini-openclaw-v2/backend/test_simulation_dataset.py
T

199 lines
9.4 KiB
Python
Raw Normal View History

"""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()