#!/usr/bin/env python3
"""Rebuild the fictional Northstar Dispatch fixtures. No network or real data.
Run from anywhere: python tools/generate_data.py
The seeds and generating assumptions are fixed in this source; no rerolling to
obtain a desired p-value. Use Python 3.12 for the tested reproduction route.
"""
from __future__ import annotations
import csv
from collections import Counter
import json
import math
from pathlib import Path
import random
import re
ROOT = Path(__file__).resolve().parents[1]
SHIPMENT_SEED = 20261014
TRIAL_SEED = 20261015
SHIPMENT_FIELDS = ["shipment_id", "week", "hub", "distance_km", "service",
"promised_minutes", "actual_minutes", "late", "satisfaction"]
TRIAL_FIELDS = ["person_id", "group", "duration_minutes"]
def generate_shipments() -> list[dict]:
"""240 observational teaching records; hub is associated with job mix."""
rng = random.Random(SHIPMENT_SEED)
rows = []
# Shared conditions create possible within-week dependence. Do not use
# an independent-row t test to infer a hub effect from these observations.
week_conditions = [-2.0, 1.5, 0.0, 4.0, -1.5, 2.5, 0.5, 3.0]
for week in range(1, 9):
for hub in ("East", "West"):
services = (["priority"] * (5 if hub == "East" else 10)
+ ["standard"] * (10 if hub == "East" else 5))
rng.shuffle(services)
for service in services:
distance = round(rng.uniform(20, 55) if hub == "East"
else rng.uniform(5, 35), 1)
priority = service == "priority"
# Neither equation has a direct hub term. Observers would not
# normally know the real process or all relevant confounders.
promised = round(25 + 1.7 * distance - 8 * priority, 1)
duration = (15 + 1.7 * distance - 5 * priority
+ week_conditions[week - 1] + rng.gauss(0, 7))
if rng.random() < 0.08:
duration += rng.uniform(15, 30)
actual = round(max(5.0, duration), 1)
late = int(actual > promised) # equality is on time
latent_rating = round(4.4 - 1.4 * late + rng.gauss(0, 0.7))
rating = min(5, max(1, latent_rating))
# Late deliveries are less likely to receive a response.
satisfaction = None if rng.random() < (0.45 if late else 0.10) else rating
rows.append(dict(shipment_id=f"S{len(rows) + 1:04d}", week=week,
hub=hub, distance_km=distance, service=service,
promised_minutes=promised, actual_minutes=actual,
late=late, satisfaction=satisfaction))
return rows
def generate_trial() -> list[dict]:
"""Fictional independent-person randomized fixture, unrelated to shipments."""
rng = random.Random(TRIAL_SEED)
groups = ["control"] * 20 + ["checklist"] * 20
rng.shuffle(groups) # balanced random assignment before generating outcomes
rows = []
for i, group in enumerate(groups, 1):
duration = round(24.0 - 3.0 * (group == "checklist") + rng.gauss(0, 4.2), 2)
if duration <= 0:
raise ValueError("Fixture generated an impossible duration; revise documented model.")
rows.append(dict(person_id=f"P{i:03d}", group=group, duration_minutes=duration))
return rows
def to_text_row(row: dict) -> dict[str, str]:
return {field: "" if value is None else str(value) for field, value in row.items()}
def corrupt_export(rows: list[dict]) -> list[dict[str, str]]:
"""248-row export: 240 recoverable records, 3 duplicate copies, 5 invalid extras."""
raw = [to_text_row(row) for row in rows]
# Six formatting-only changes, across three records. Values remain knowable.
raw[0]["hub"] = " east "
raw[0]["service"] = "PRIORITY" if raw[0]["service"] == "priority" else "STANDARD"
raw[1]["shipment_id"] = " " + raw[1]["shipment_id"] + " "
raw[1]["distance_km"] = " " + raw[1]["distance_km"] + " "
raw[2]["hub"] = "WEST" if raw[2]["hub"] == "West" else "EAST"
raw[2]["service"] = " " + raw[2]["service"] + " "
# Two stale derived flags; recompute using the documented deadline rule.
for index in (9, 19):
raw[index]["late"] = str(1 - int(raw[index]["late"]))
raw.extend(dict(raw[index]) for index in (39, 79, 119))
# Invalid appended records are not repairable from the export; quarantine.
corruptions = [("distance_km", "-4"), ("actual_minutes", "unknown"),
("hub", "North"), ("promised_minutes", ""), ("satisfaction", "7")]
for i, (field, value) in enumerate(corruptions, 1):
invalid = to_text_row(rows[150 + i])
invalid["shipment_id"] = f"X{i:04d}"
invalid[field] = value
raw.append(invalid)
return raw
def clean_shipment_rows(raw_rows: list[dict[str, str]]) -> tuple[list[dict], dict]:
"""Normalize, validate, deduplicate, derive late, and log every decision.
Invalid non-missing satisfaction is quarantined, not silently replaced by
a missing rating. Conflicting duplicates quarantine the entire ID; only
identical canonical duplicates can be safely collapsed.
"""
audit = {"input_rows": len(raw_rows), "formatting_cells_normalized": 0,
"late_flags_recomputed": 0, "exact_duplicate_rows_removed": 0,
"invalid_rows_quarantined": 0, "conflicting_rows_quarantined": 0,
"conflicting_ids_quarantined": [], "quarantine": [], "row_dispositions": []}
# Delay duplicate decisions until every validated source row for each ID is
# known. A later conflict must reclassify the first row AND earlier copies.
valid_groups: dict[str, list[tuple[int, dict]]] = {}
candidates: dict[str, dict] = {}
reasons = Counter()
for source_line, source in enumerate(raw_rows, start=2):
row = {field: str(source.get(field, "") or "").strip() for field in SHIPMENT_FIELDS}
row["hub"] = row["hub"].title()
row["service"] = row["service"].lower()
audit["formatting_cells_normalized"] += sum(row[f] != str(source.get(f, "") or "")
for f in SHIPMENT_FIELDS)
reason = None
parsed = {}
try:
if not re.fullmatch(r"[SX]\d{4}", row["shipment_id"]):
raise ValueError("invalid shipment_id")
parsed["shipment_id"] = row["shipment_id"]
if not re.fullmatch(r"[1-8]", row["week"]):
raise ValueError("week must be an integer 1–8")
parsed["week"] = int(row["week"])
if row["hub"] not in ("East", "West"):
raise ValueError("hub outside East/West")
parsed["hub"] = row["hub"]
for field in ("distance_km", "promised_minutes", "actual_minutes"):
try:
value = float(row[field])
except ValueError:
raise ValueError(f"{field} is not numeric") from None
if not math.isfinite(value) or value <= 0:
raise ValueError(f"{field} must be finite and positive")
parsed[field] = value
if row["service"] not in ("standard", "priority"):
raise ValueError("service outside standard/priority")
parsed["service"] = row["service"]
if row["satisfaction"] == "":
parsed["satisfaction"] = None
elif re.fullmatch(r"[1-5]", row["satisfaction"]):
parsed["satisfaction"] = int(row["satisfaction"])
else:
raise ValueError("satisfaction must be 1–5 or blank")
parsed["late"] = int(parsed["actual_minutes"] > parsed["promised_minutes"])
if row["late"] != str(parsed["late"]):
audit["late_flags_recomputed"] += 1
except ValueError as error:
reason = str(error)
if reason:
audit["invalid_rows_quarantined"] += 1
reasons[reason] += 1
audit["quarantine"].append({"csv_line": source_line,
"shipment_id": row["shipment_id"], "reason": reason})
audit["row_dispositions"].append({"csv_line": source_line,
"shipment_id": row["shipment_id"],
"disposition": "invalid_quarantined"})
continue
valid_groups.setdefault(parsed["shipment_id"], []).append((source_line, parsed))
for key, records in valid_groups.items():
first = records[0][1]
conflicting = any(parsed != first for _, parsed in records[1:])
if conflicting:
audit["conflicting_ids_quarantined"].append(key)
audit["conflicting_rows_quarantined"] += len(records)
else:
candidates[key] = first
audit["exact_duplicate_rows_removed"] += len(records) - 1
for position, (source_line, _) in enumerate(records):
if conflicting:
disposition = "conflict_quarantined"
audit["quarantine"].append({"csv_line": source_line, "shipment_id": key,
"reason": "conflicting duplicate ID; all validated records for ID removed"})
else:
disposition = "accepted" if position == 0 else "exact_duplicate_removed"
audit["row_dispositions"].append({"csv_line": source_line, "shipment_id": key,
"disposition": disposition})
clean = [candidates[key] for key in sorted(candidates)]
audit["conflicting_ids_quarantined"].sort()
audit["quarantine"].sort(key=lambda row: row["csv_line"])
audit["row_dispositions"].sort(key=lambda row: row["csv_line"])
audit["invalid_reasons"] = dict(sorted(reasons.items()))
audit["output_rows"] = len(clean)
audit["accepted_rows"] = len(clean)
audit["total_rows_quarantined"] = (audit["invalid_rows_quarantined"]
+ audit["conflicting_rows_quarantined"])
if (audit["input_rows"] != audit["accepted_rows"] + audit["total_rows_quarantined"]
+ audit["exact_duplicate_rows_removed"]):
raise RuntimeError("Cleaning row dispositions do not reconcile.")
return clean, audit
def write_csv(path: Path, rows: list[dict], fields: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.DictWriter(handle, fieldnames=fields, lineterminator="\n")
writer.writeheader()
writer.writerows(rows)
def main() -> None:
base = generate_shipments()
raw = corrupt_export(base)
clean, audit = clean_shipment_rows(raw)
if clean != base:
raise RuntimeError("Cleaning failed to recover the intended observed base.")
write_csv(ROOT / "data/shipments_raw.csv", raw, SHIPMENT_FIELDS)
write_csv(ROOT / "data/shipments_clean.csv", clean, SHIPMENT_FIELDS)
write_csv(ROOT / "data/checklist_trial.csv", generate_trial(), TRIAL_FIELDS)
(ROOT / "outputs").mkdir(exist_ok=True)
(ROOT / "outputs/cleaning_audit.json").write_text(json.dumps(audit, indent=2) + "\n", encoding="utf-8")
print(f"Generated {len(raw)} raw rows → {len(clean)} clean shipments; 40 fictional trial tasks.")
if __name__ == "__main__":
main()