Skip to content

Commit b03e86a

Browse files
committed
Add MK2 population sanity check harness
1 parent a774cf1 commit b03e86a

1 file changed

Lines changed: 144 additions & 0 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Population-level sanity check harness for Engine MK2 schedules."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import random
7+
import statistics
8+
import sys
9+
from collections import defaultdict
10+
from datetime import date
11+
from pathlib import Path
12+
from typing import Callable, Dict, Iterable, List, Sequence, Tuple
13+
14+
ROOT = Path(__file__).resolve().parents[1]
15+
if str(ROOT) not in sys.path:
16+
sys.path.insert(0, str(ROOT))
17+
18+
from archetypes import (
19+
create_exhausted_parent,
20+
create_night_owl_freelancer,
21+
create_office_worker,
22+
)
23+
from engines.engine_mk2 import EngineMK2
24+
from models import PersonProfile
25+
26+
ARCHETYPE_FACTORIES: Dict[str, Callable[[], PersonProfile]] = {
27+
"office": create_office_worker,
28+
"parent": create_exhausted_parent,
29+
"freelancer": create_night_owl_freelancer,
30+
}
31+
32+
33+
def pick_archetype(choice: Sequence[str], rng: random.Random) -> str:
34+
if not choice:
35+
raise ValueError("At least one archetype must be provided")
36+
return rng.choice(choice)
37+
38+
39+
def build_profile(archetype: str) -> PersonProfile:
40+
try:
41+
factory = ARCHETYPE_FACTORIES[archetype]
42+
except KeyError as exc:
43+
raise ValueError(f"Unknown archetype '{archetype}'") from exc
44+
return factory()
45+
46+
47+
def summarise_events(events: Iterable[Dict[str, object]]) -> Dict[str, float]:
48+
totals: Dict[str, int] = defaultdict(int)
49+
for event in events:
50+
activity = str(event.get("activity"))
51+
minutes = int(event.get("duration_minutes", 0))
52+
totals[activity] += minutes
53+
return {activity: round(minutes / 60.0, 2) for activity, minutes in totals.items()}
54+
55+
56+
def compute_min_mean_max(values: Sequence[float]) -> Tuple[float, float, float]:
57+
if not values:
58+
return (0.0, 0.0, 0.0)
59+
return (min(values), statistics.mean(values), max(values))
60+
61+
62+
def bucketize(values: Sequence[float], bucket_edges: Sequence[int]) -> List[Tuple[str, int]]:
63+
labels: List[Tuple[str, int]] = []
64+
if not values:
65+
return labels
66+
67+
buckets = [0 for _ in range(len(bucket_edges) + 1)]
68+
for value in values:
69+
placed = False
70+
for index, edge in enumerate(bucket_edges):
71+
if value < edge:
72+
buckets[index] += 1
73+
placed = True
74+
break
75+
if not placed:
76+
buckets[-1] += 1
77+
78+
lower = 0
79+
for index, count in enumerate(buckets):
80+
if index < len(bucket_edges):
81+
upper = bucket_edges[index]
82+
label = f"{lower}{upper}h"
83+
lower = upper
84+
else:
85+
label = f">= {lower}h"
86+
labels.append((label, count))
87+
return labels
88+
89+
90+
def main() -> None:
91+
parser = argparse.ArgumentParser(description=__doc__)
92+
parser.add_argument("--samples", type=int, default=100, help="Number of synthetic people to generate")
93+
parser.add_argument(
94+
"--archetypes",
95+
nargs="*",
96+
default=list(ARCHETYPE_FACTORIES.keys()),
97+
help="Subset of archetypes to sample from",
98+
)
99+
parser.add_argument(
100+
"--seed", type=int, default=42, help="Seed for sampling the population and weekly schedules"
101+
)
102+
args = parser.parse_args()
103+
104+
rng = random.Random(args.seed)
105+
engine = EngineMK2()
106+
107+
sleep_totals: List[float] = []
108+
work_totals: List[float] = []
109+
free_totals: List[float] = []
110+
111+
for index in range(args.samples):
112+
archetype = pick_archetype(args.archetypes, rng)
113+
profile = build_profile(archetype)
114+
week_seed = rng.randint(0, 10_000_000)
115+
result = engine.generate_complete_week(
116+
profile=profile,
117+
start_date=date(2024, 1, 1),
118+
week_seed=week_seed,
119+
)
120+
121+
summary = summarise_events(result["events"])
122+
sleep_totals.append(summary.get("sleep", 0.0))
123+
work_totals.append(summary.get("work", 0.0))
124+
free_totals.append(summary.get("free time", 0.0))
125+
126+
sleep_stats = compute_min_mean_max(sleep_totals)
127+
work_stats = compute_min_mean_max(work_totals)
128+
free_stats = compute_min_mean_max(free_totals)
129+
130+
print(f"Population size: {args.samples}")
131+
print("Archetypes:", ", ".join(sorted(set(args.archetypes))))
132+
print()
133+
print("Weekly sleep hours (min/mean/max): {:.2f} / {:.2f} / {:.2f}".format(*sleep_stats))
134+
print("Weekly work hours (min/mean/max): {:.2f} / {:.2f} / {:.2f}".format(*work_stats))
135+
print("Weekly free-time hours (min/mean/max): {:.2f} / {:.2f} / {:.2f}".format(*free_stats))
136+
137+
print()
138+
print("Sleep distribution (hours/week):")
139+
for label, count in bucketize(sleep_totals, [20, 40, 60]):
140+
print(f" {label:<8} : {count}")
141+
142+
143+
if __name__ == "__main__":
144+
main()

0 commit comments

Comments
 (0)