From ed137c257445e044e2bb392363091228a3a6efa1 Mon Sep 17 00:00:00 2001 From: kjh2064 Date: Mon, 13 Jul 2026 00:31:13 +0900 Subject: [PATCH] feat(dotnet): add normalization contract harness --- .gitea/workflows/ci.yml | 3 + AGENTS.md | 2 + docs/ROADMAP_WBS.md | 1 + .../WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml | 85 +++++++++++++++++++ spec/41_release_dag.yaml | 16 ++++ ...lidate_dotnet_normalization_contract_v1.py | 38 +++++++++ ...lidate_dotnet_normalization_contract_v1.py | 78 +++++++++++++++++ 7 files changed, 223 insertions(+) create mode 100644 docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml create mode 100644 tests/unit/test_validate_dotnet_normalization_contract_v1.py create mode 100644 tools/validate_dotnet_normalization_contract_v1.py diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 275abfee..b21b5783 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -181,6 +181,9 @@ jobs: - name: Validate Dotnet Scheduler Contract run: python3 tools/validate_dotnet_scheduler_contract_v1.py + - name: Validate Dotnet Normalization Contract + run: python3 tools/validate_dotnet_normalization_contract_v1.py + - name: Build Calibration Priority Backlog diff --git a/AGENTS.md b/AGENTS.md index d2819246..6bced5aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,11 +102,13 @@ - `docs/WBS_10_DOTNET_PARITY_CONTRACT.yaml`: WBS-10 핵심 계산기 parity 계약. - `docs/WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml`: WBS-10 provenance payload 표준 계약. - `docs/WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml`: WBS-10 scheduler state machine 계약. +- `docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml`: WBS-10 normalization/read model 계약. - `tools/validate_dotnet_migration_roadmap_v1.py`: WBS-10 상세 로드맵 YAML validator. - `tools/validate_dotnet_migration_execution_plan_v1.py`: WBS-10 실행 분해 계획 validator. - `tools/validate_dotnet_parity_contract_v1.py`: WBS-10 parity 계약 validator. - `tools/validate_dotnet_provenance_contract_v1.py`: WBS-10 provenance 계약 validator. - `tools/validate_dotnet_scheduler_contract_v1.py`: WBS-10 scheduler 계약 validator. +- `tools/validate_dotnet_normalization_contract_v1.py`: WBS-10 normalization 계약 validator. - `Temp/snapshot_admin_approval_packet_v1.json`: snapshot admin approval packet export. - `Temp/snapshot_admin_approval_packet_v1.md`: snapshot admin approval packet summary. - `Temp/`: 실행 결과와 캐시. 라우팅 대상은 아니며 runtime consumer만 읽는다. diff --git a/docs/ROADMAP_WBS.md b/docs/ROADMAP_WBS.md index 2aec4c24..6ed66cf1 100644 --- a/docs/ROADMAP_WBS.md +++ b/docs/ROADMAP_WBS.md @@ -1471,6 +1471,7 @@ WBS-8.8 (KIS 리팩터) — 독립적 (원격 병행) > parity 계약: [WBS_10_DOTNET_PARITY_CONTRACT.yaml](./WBS_10_DOTNET_PARITY_CONTRACT.yaml) > provenance 계약: [WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml](./WBS_10_DOTNET_PROVENANCE_CONTRACT.yaml) > scheduler contract: [WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml](./WBS_10_DOTNET_SCHEDULER_CONTRACT.yaml) +> normalization contract: [WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml](./WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml) > 현황 진단(2026-06-26): .NET 프로젝트는 Python 엔진(41 모듈, 14,500 LOC) 대비 5~10%(~1,400 LOC) 수준. > Domain 계산기 6개·데이터 모델 8개·KIS/Naver/Yahoo 클라이언트·PostgreSQL 마이그레이션·Razor Pages 어드민 대시보드 기본 구현 완료. diff --git a/docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml b/docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml new file mode 100644 index 00000000..c613bb50 --- /dev/null +++ b/docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml @@ -0,0 +1,85 @@ +formula_id: WBS_10_DOTNET_NORMALIZATION_CONTRACT_V1 +owner: QuantEngine +status: draft +goal: "쓰기 경로 정규화와 읽기 경로 역정규화 경계를 고정한다." + +canonical_write_path: + schema: engine_history + tables: + - source_observation + - factor_definition + - factor_observation + - decision_event + - decision_factor_evidence + - outcome_evaluation + invariant: + - "source_observation은 원천 관측 1건당 1행" + - "factor_definition은 (factor_id, factor_version) 단일 원장" + - "factor_observation은 observation_id를 반드시 참조" + - "decision_event는 decision_key로 단일 식별" + - "outcome_evaluation은 decision_id + horizon_days 조합으로 단일 식별" + +canonical_read_path: + view: engine_history.training_example_v1 + purpose: "모델 학습/캘리브레이션용 역정규화 projection" + consumers: + - model_training + - calibration_jobs + - diagnostics + +forbidden_patterns: + - "읽기 모델을 쓰기 원장으로 사용" + - "원천 payload를 읽기 projection에 중복 저장" + - "직렬 UI 조회를 위해 원장 테이블을 직접 조인해 장기 유지" + +expected_fields: + normalized_tables: + source_observation: + - observation_id + - observed_at + - instrument_id + - source_name + - source_version + - payload + - provenance + factor_observation: + - factor_observation_id + - observation_id + - factor_id + - factor_version + - observed_at + - numeric_value + - text_value + - gate + - provenance + decision_event: + - decision_id + - decision_key + - decided_at + - instrument_id + - action + - gate + - score + - source_version + - trace + - provenance + denormalized_view: + - decision_id + - decision_key + - decided_at + - instrument_id + - action + - decision_gate + - score + - source_version + - horizon_days + - realized_return + - benchmark_return + - excess_return + - outcome_class + - evaluation_gate + - factor_features + +notes: + - "정규화는 쓰기 중복 제거와 provenance 보존이 목적이다." + - "역정규화는 학습/진단 편의용 projection으로만 허용한다." diff --git a/spec/41_release_dag.yaml b/spec/41_release_dag.yaml index f764d4ca..c3e0b7e2 100644 --- a/spec/41_release_dag.yaml +++ b/spec/41_release_dag.yaml @@ -2361,6 +2361,22 @@ dag: - Temp/wbs_10_dotnet_scheduler_contract_v1.json strict: true timeout_sec: 60 + validate_dotnet_normalization_contract: + artifact_policy: keep + cache_key: validate_dotnet_normalization_contract_v1 + command: + - python + - tools/validate_dotnet_normalization_contract_v1.py + depends_on: [] + id: validate_dotnet_normalization_contract + inputs: + - tools/validate_dotnet_normalization_contract_v1.py + - docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml + note: WBS-10 정규화 원장과 역정규화 read model 경계를 검증한다. + outputs: + - Temp/wbs_10_dotnet_normalization_contract_v1.json + strict: true + timeout_sec: 60 validate_specs: artifact_policy: keep cache_key: validate_specs_v1 diff --git a/tests/unit/test_validate_dotnet_normalization_contract_v1.py b/tests/unit/test_validate_dotnet_normalization_contract_v1.py new file mode 100644 index 00000000..6857a1a2 --- /dev/null +++ b/tests/unit/test_validate_dotnet_normalization_contract_v1.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +def test_validate_dotnet_normalization_contract_passes() -> None: + root = Path(__file__).resolve().parents[2] + proc = subprocess.run( + [sys.executable, str(root / "tools" / "validate_dotnet_normalization_contract_v1.py")], + cwd=root, + capture_output=True, + text=True, + ) + assert proc.returncode == 0, proc.stdout + proc.stderr + payload = json.loads(proc.stdout) + assert payload["gate"] == "PASS" + + +def test_validate_dotnet_normalization_contract_reports_missing_view() -> None: + root = Path(__file__).resolve().parents[2] + temp = root / "Temp" / "test_wbs_10_normalization_contract_bad.yaml" + temp.write_text( + "formula_id: WBS_10_DOTNET_NORMALIZATION_CONTRACT_V1\ngoal: 쓰기 경로 정규화와 읽기 경로 역정규화 경계를 고정한다.\ncanonical_write_path:\n schema: engine_history\n tables: [source_observation]\ncanonical_read_path:\n view: wrong_view\nforbidden_patterns: [x]\n", + encoding="utf-8", + ) + proc = subprocess.run( + [sys.executable, str(root / "tools" / "validate_dotnet_normalization_contract_v1.py"), "--contract", str(temp)], + cwd=root, + capture_output=True, + text=True, + ) + assert proc.returncode != 0 + payload = json.loads(proc.stdout) + assert payload["gate"] == "FAIL" + assert "canonical_read_path.view" in payload["missing"] diff --git a/tools/validate_dotnet_normalization_contract_v1.py b/tools/validate_dotnet_normalization_contract_v1.py new file mode 100644 index 00000000..8d65dee7 --- /dev/null +++ b/tools/validate_dotnet_normalization_contract_v1.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +import yaml + + +def load_yaml(path: Path) -> dict[str, Any]: + if not path.exists(): + raise FileNotFoundError(path) + return yaml.safe_load(path.read_text(encoding="utf-8")) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Validate WBS-10 dotnet normalization contract") + parser.add_argument("--contract", default="docs/WBS_10_DOTNET_NORMALIZATION_CONTRACT.yaml") + args = parser.parse_args(argv) + + contract_path = Path(args.contract).resolve() + payload: dict[str, Any] = { + "formula_id": "WBS_10_DOTNET_NORMALIZATION_CONTRACT_V1", + "gate": "FAIL", + "missing": [], + "evidence": {"contract": str(contract_path)}, + } + + try: + data = load_yaml(contract_path) + except FileNotFoundError: + payload["missing"].append("contract missing") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 1 + + if data.get("formula_id") != "WBS_10_DOTNET_NORMALIZATION_CONTRACT_V1": + payload["missing"].append("formula_id") + if data.get("goal") != "쓰기 경로 정규화와 읽기 경로 역정규화 경계를 고정한다.": + payload["missing"].append("goal") + + write_path = data.get("canonical_write_path") or {} + if write_path.get("schema") != "engine_history": + payload["missing"].append("canonical_write_path.schema") + tables = write_path.get("tables") or [] + for required in { + "source_observation", + "factor_definition", + "factor_observation", + "decision_event", + "decision_factor_evidence", + "outcome_evaluation", + } - set(tables): + payload["missing"].append(f"{required} missing") + + if data.get("canonical_read_path", {}).get("view") != "engine_history.training_example_v1": + payload["missing"].append("canonical_read_path.view") + + if not data.get("forbidden_patterns"): + payload["missing"].append("forbidden_patterns") + + payload["gate"] = "PASS" if not payload["missing"] else "FAIL" + payload["message"] = ( + "WBS-10 dotnet normalization contract validation passed." + if payload["gate"] == "PASS" + else "WBS-10 dotnet normalization contract validation failed." + ) + + out_path = contract_path.parent.parent / "Temp" / "wbs_10_dotnet_normalization_contract_v1.json" + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 if payload["gate"] == "PASS" else 1 + + +if __name__ == "__main__": + raise SystemExit(main())