feat(v2): T04 模拟案例生成与数据集划分

先边界 fixture 再批量生成;全程程序组合场景,**不做任何模型调用**(14 §8 末)。

新增 services/simulation_dataset.py:
- generate(seed, scale):程序化生成虚构项目,同 seed 同版本**逐条一致**
- split():按关联组 + 时间分桶(组内不拆),以组的最早发生时间排序
- assert_group_integrity / assert_source_allowed:组不跨集合;保留集来源禁止用于提炼
- manifest():构造 DatasetManifest 契约体

契约补 T04 才该有的部分(T00/04 当初有意推迟):EventOutcome 七值枚举、
OutcomeCount、DatasetManifest。含两条硬约束——合成数据必须 demo 域(BR-34)、
计数不得为 0(空结果用不适用表达)。

配比按 14 §8:通过 1800(直接 900 + 补件后 900)/ 未通过 600 / 不计入 600
(附条件 100 + 未送审 175 + 退出 175 + 待定 150)= 3000。
两个建模决定:
- "补件后通过"是 approved 的**子类型**(outcome_subtype),不是独立 outcome ——
  06 §3 要求区分首次提交与补件后,但设计 §9.1 的 EventOutcome 只有 7 个值,不自扩充
- conditional 是否计入目标由客户映射决定(OQ-05),故 label=None,绝不默认当通过

实现中被自己的检查抓到两个 bug:
1. 分组与近重复用了两套独立公式,导致 near_duplicate_of 指向别组记录,
   assert_group_integrity 直接报"近重复案例跨集合"。现组号与近重复由同一序号唯一推出
2. split() 末段取剩余可能为负(极端小样本),会静默切出错误的划分。已夹到非负

测试:新增 test_simulation_dataset.py 24 项(边界 fixture 10 + 生成 10 + 清单 4 +
落盘 2),含 AC-35 的可复现性与 AC-36 的组不跨集合/保留集来源拒绝。
全量 272 项 OK(248 → 272);verify.py 10/10 PASS。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 18:30:34 +08:00
co-authored by Claude Code
parent 669e969c41
commit 8fef00d268
3 changed files with 509 additions and 0 deletions
+55
View File
@@ -67,6 +67,22 @@ class DataDomain(StrEnum):
production = "production"
class EventOutcome(StrEnum):
"""评审会结果编码(docs/v2/03-detailed-design.md §9.1)。
`conditional`(附条件通过)是否计入目标由 MappingProfile 固定;P0 **不得**默认
当作 approved(E-26 / BR-21)。
"""
approved = "approved"
rejected = "rejected"
deferred = "deferred"
withdrawn = "withdrawn"
not_submitted = "not_submitted"
conditional = "conditional"
unknown = "unknown"
class Ref(Contract):
"""通用版本引用(docs/v2/03-detailed-design.md §9.1)。"""
@@ -311,3 +327,42 @@ class FactConflict(Contract):
if stray or self.selected_evidence_ids:
raise ValueError("unresolved 不得带解决字段")
return self
# ---------------------------------------------------------------- 数据集(T04)
class OutcomeCount(Contract):
outcome: EventOutcome
count: int = Field(ge=0)
class DatasetManifest(Contract):
"""数据集清单(docs/v2/03-detailed-design.md §9.2「数据集服务」)。
用于证明"同 seed / 同版本生成两次,规范化清单一致"(AC-35),并承载划分信息。
"""
id: Identifier
version: str = Field(min_length=1, max_length=ID_MAX)
data_domain: DataDomain
seed: int | None = Field(default=None, ge=0)
generator_version: str | None = Field(default=None, max_length=ID_MAX)
template_version: str | None = Field(default=None, max_length=ID_MAX)
source_hash: str = Field(pattern=_SHA256.pattern)
split_id: str = Field(min_length=1, max_length=ID_MAX)
split_policy_version: str = Field(min_length=1, max_length=ID_MAX)
group_assignments_ref: str = Field(min_length=1, max_length=ID_MAX)
counts: list[OutcomeCount] = Field(default_factory=list, max_length=32)
created_at: AwareDatetime
synthetic: bool
@model_validator(mode="after")
def _check_labels_and_domain(self) -> DatasetManifest:
outcomes = [item.outcome for item in self.counts]
if len(set(outcomes)) != len(outcomes):
raise ValueError("同一结果编码不得重复计数")
if any(item.count == 0 for item in self.counts):
raise ValueError("计数为 0 的结果不应出现;空结果用不适用表达")
# 合成数据必须标 demo:不得让模拟数据冒充生产域(BR-34)
if self.synthetic and self.data_domain != DataDomain.demo:
raise ValueError("合成数据必须为 demo 数据域")
return self