← Files Investment BankingARCHIVED FILE
skills/cim-teardown/scripts/validate_outputs.py
11.3 KB · Oct 2, 2026 · 00:27 UTC
#!/usr/bin/env python3
"""Validate required teardown outputs.
Usage:
python scripts/validate_outputs.py outputs/
Checks:
- required CSVs exist
- headers match templates
- ID formats and uniqueness
- cross-links reference existing IDs
- citations present (or CITATION_TBD)
- priority score math (if components provided)
Dependency-free (standard library only).
"""
from __future__ import annotations
import csv
import re
import sys
from pathlib import Path
CLAIMS_HEADERS = [
"Claim ID",
"Claim Text (Verbatim)",
"Category",
"Subcategory",
"Claim Type",
"Metric(s)",
"Period / As-of",
"Scope (Segment/Geo/Cohort)",
"Qualifiers",
"CIM Citation",
"Supporting Doc Citations",
"Confidence (1-5)",
"Materiality (H/M/L)",
"Implied Assumptions",
"Required Evidence (Minimum)",
"Verification Method",
"Linked Question IDs",
"Linked Red-Flag IDs",
"Status",
"Notes",
]
EVIDENCE_HEADERS = [
"Evidence ID",
"Related Claim ID(s)",
"Evidence Name",
"Evidence Type (Export/Doc/Report)",
"System of Record",
"Owner (Seller)",
"Granularity Required",
"Period Covered",
"Format Required",
"Purpose (What it proves)",
"Acceptance Criteria",
"CIM Citation",
"Received? (Y/N)",
"Received Date",
"Location (Data room path)",
"Issues / Gaps",
"Status",
]
QUESTIONS_HEADERS = [
"Question ID",
"Related Claim ID(s)",
"Priority Score",
"Impact (1-5)",
"Uncertainty (1-5)",
"Downside Risk (1-5)",
"Time Urgency (1-5)",
"Category",
"Question (Falsification-first)",
"Why it matters",
"Evidence Request (Exact)",
"Tie-out / Analysis Plan",
"Owner (Your team)",
"Owner (Seller)",
"Meeting Type (Email/Call/Data room)",
"Due Date",
"Status",
"CIM Citation",
"Follow-ups / Branching",
"Notes",
]
REDFLAG_HEADERS = [
"Red-Flag ID",
"Severity (1-5)",
"Related Claim ID(s)",
"Related Question ID(s)",
"Red Flag Description",
"Detection Method / Rule",
"Evidence Needed",
"What resolves it",
"Potential Impact",
"Status",
"Owner",
"CIM Citation",
"Notes",
]
WORKPLAN_HEADERS = [
"Workstream",
"Task ID",
"Task Description",
"Related Claim ID(s)",
"Evidence ID(s)",
"Question ID(s)",
"Owner (Your team)",
"Seller Owner",
"Dependencies",
"Start Date",
"Due Date",
"Status",
"CIM Citation",
"Output Artifact",
"Notes",
]
REQUIRED = {
"claims_ledger.csv": CLAIMS_HEADERS,
"evidence_checklist.csv": EVIDENCE_HEADERS,
"diligence_questions.csv": QUESTIONS_HEADERS,
"red_flag_register.csv": REDFLAG_HEADERS,
"workplan.csv": WORKPLAN_HEADERS,
}
ID_PATTERNS = {
"claim": re.compile(r"^C-\d{4}$"),
"evidence": re.compile(r"^E-\d{4}$"),
"question": re.compile(r"^Q-\d{4}$"),
"redflag": re.compile(r"^RF-\d{4}$"),
"task": re.compile(r"^T-\d{4}$"),
}
def _split_ids(value: str) -> list[str]:
if value is None:
return []
value = value.strip()
if not value:
return []
# Accept comma, semicolon, or pipe-separated lists
parts = re.split(r"[;,|]", value)
return [p.strip() for p in parts if p.strip()]
def _read_csv(path: Path) -> tuple[list[str], list[dict[str, str]]]:
with path.open("r", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
headers = reader.fieldnames or []
rows = list(reader)
return headers, rows
def _assert_headers(file: str, actual: list[str], expected: list[str], errors: list[str]) -> None:
if actual == expected:
return
# Allow superset, but keep strict ordering for the expected prefix.
if len(actual) >= len(expected) and actual[: len(expected)] == expected:
# warn-only behavior (still accept)
return
errors.append(
f"{file}: header mismatch. Expected {len(expected)} cols; got {len(actual)}.\n"
f"Expected: {expected}\nGot: {actual}"
)
def _require_nonempty(field_value: str, context: str, errors: list[str]) -> None:
if field_value is None or not str(field_value).strip():
errors.append(f"Missing required field: {context}")
def main() -> int:
if len(sys.argv) != 2:
print("Usage: python scripts/validate_outputs.py outputs/")
return 1
out_dir = Path(sys.argv[1])
if not out_dir.exists() or not out_dir.is_dir():
print(f"ERROR: outputs directory not found: {out_dir}")
return 1
errors: list[str] = []
# Load files
data: dict[str, tuple[list[str], list[dict[str, str]]]] = {}
for fname, expected_headers in REQUIRED.items():
path = out_dir / fname
if not path.exists():
errors.append(f"Missing required output file: {fname}")
continue
headers, rows = _read_csv(path)
_assert_headers(fname, headers, expected_headers, errors)
data[fname] = (headers, rows)
if errors:
print("❌ Output validation failed (missing files/headers):")
for e in errors:
print(" -", e)
return 1
# Collect IDs
claim_ids: set[str] = set()
evidence_ids: set[str] = set()
question_ids: set[str] = set()
redflag_ids: set[str] = set()
task_ids: set[str] = set()
# Claims
_, claim_rows = data["claims_ledger.csv"]
for i, r in enumerate(claim_rows, start=2):
cid = (r.get("Claim ID") or "").strip()
if not cid:
errors.append(f"claims_ledger.csv:{i} missing Claim ID")
continue
if cid in claim_ids:
errors.append(f"claims_ledger.csv:{i} duplicate Claim ID: {cid}")
claim_ids.add(cid)
if not ID_PATTERNS["claim"].match(cid):
errors.append(f"claims_ledger.csv:{i} invalid Claim ID format: {cid}")
status = (r.get("Status") or "").strip().upper()
citation = (r.get("CIM Citation") or "").strip()
if status != "DEPRECATED":
_require_nonempty(citation, f"claims_ledger.csv:{i} CIM Citation", errors)
# Evidence
_, evid_rows = data["evidence_checklist.csv"]
for i, r in enumerate(evid_rows, start=2):
eid = (r.get("Evidence ID") or "").strip()
if not eid:
errors.append(f"evidence_checklist.csv:{i} missing Evidence ID")
continue
if eid in evidence_ids:
errors.append(f"evidence_checklist.csv:{i} duplicate Evidence ID: {eid}")
evidence_ids.add(eid)
if not ID_PATTERNS["evidence"].match(eid):
errors.append(f"evidence_checklist.csv:{i} invalid Evidence ID format: {eid}")
rel = _split_ids(r.get("Related Claim ID(s)") or "")
for cid in rel:
if cid not in claim_ids:
errors.append(f"evidence_checklist.csv:{i} references unknown Claim ID: {cid}")
# Questions
_, q_rows = data["diligence_questions.csv"]
for i, r in enumerate(q_rows, start=2):
qid = (r.get("Question ID") or "").strip()
if not qid:
errors.append(f"diligence_questions.csv:{i} missing Question ID")
continue
if qid in question_ids:
errors.append(f"diligence_questions.csv:{i} duplicate Question ID: {qid}")
question_ids.add(qid)
if not ID_PATTERNS["question"].match(qid):
errors.append(f"diligence_questions.csv:{i} invalid Question ID format: {qid}")
rel = _split_ids(r.get("Related Claim ID(s)") or "")
for cid in rel:
if cid not in claim_ids:
errors.append(f"diligence_questions.csv:{i} references unknown Claim ID: {cid}")
# Citation required when question is active
status = (r.get("Status") or "").strip().upper()
if status != "DEPRECATED":
_require_nonempty(
(r.get("CIM Citation") or "").strip(),
f"diligence_questions.csv:{i} CIM Citation",
errors,
)
# Priority math check (if numeric)
def _to_int(x: str) -> int | None:
x = (x or "").strip()
if not x:
return None
try:
return int(float(x))
except Exception:
return None
ps = _to_int(r.get("Priority Score") or "")
impact = _to_int(r.get("Impact (1-5)") or "")
unc = _to_int(r.get("Uncertainty (1-5)") or "")
down = _to_int(r.get("Downside Risk (1-5)") or "")
urg = _to_int(r.get("Time Urgency (1-5)") or "")
if None not in (ps, impact, unc, down, urg):
expected = impact * unc * down * urg
if ps != expected:
errors.append(
f"diligence_questions.csv:{i} Priority Score mismatch: got {ps}, expected {expected} (impact*uncertainty*downside*urgency)"
)
# Red flags
_, rf_rows = data["red_flag_register.csv"]
for i, r in enumerate(rf_rows, start=2):
rfid = (r.get("Red-Flag ID") or "").strip()
if not rfid:
errors.append(f"red_flag_register.csv:{i} missing Red-Flag ID")
continue
if rfid in redflag_ids:
errors.append(f"red_flag_register.csv:{i} duplicate Red-Flag ID: {rfid}")
redflag_ids.add(rfid)
if not ID_PATTERNS["redflag"].match(rfid):
errors.append(f"red_flag_register.csv:{i} invalid Red-Flag ID format: {rfid}")
rel_c = _split_ids(r.get("Related Claim ID(s)") or "")
rel_q = _split_ids(r.get("Related Question ID(s)") or "")
for cid in rel_c:
if cid not in claim_ids:
errors.append(f"red_flag_register.csv:{i} references unknown Claim ID: {cid}")
for qid in rel_q:
if qid not in question_ids:
errors.append(f"red_flag_register.csv:{i} references unknown Question ID: {qid}")
status = (r.get("Status") or "").strip().upper()
if status != "DEPRECATED":
_require_nonempty(
(r.get("What resolves it") or "").strip(),
f"red_flag_register.csv:{i} What resolves it",
errors,
)
# Workplan
_, t_rows = data["workplan.csv"]
for i, r in enumerate(t_rows, start=2):
tid = (r.get("Task ID") or "").strip()
if not tid:
errors.append(f"workplan.csv:{i} missing Task ID")
continue
if tid in task_ids:
errors.append(f"workplan.csv:{i} duplicate Task ID: {tid}")
task_ids.add(tid)
if not ID_PATTERNS["task"].match(tid):
errors.append(f"workplan.csv:{i} invalid Task ID format: {tid}")
for cid in _split_ids(r.get("Related Claim ID(s)") or ""):
if cid and cid not in claim_ids:
errors.append(f"workplan.csv:{i} references unknown Claim ID: {cid}")
for eid in _split_ids(r.get("Evidence ID(s)") or ""):
if eid and eid not in evidence_ids:
errors.append(f"workplan.csv:{i} references unknown Evidence ID: {eid}")
for qid in _split_ids(r.get("Question ID(s)") or ""):
if qid and qid not in question_ids:
errors.append(f"workplan.csv:{i} references unknown Question ID: {qid}")
if errors:
print("❌ Output validation failed:")
for e in errors:
print(" -", e)
return 1
print("✅ Outputs look valid")
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: c5c8fbc0b8eec067def52c04688cdfd51d9ef306c7c5d234a589ed242a5e7e1f