Files
troupe-standup/wizard-work/null/budgeted-planner/test_budgeted_planner.py
T

231 lines
8.7 KiB
Python

#!/usr/bin/env python3
"""test_budgeted_planner.py — real test suite for budgeted_planner.
Exits 0 iff every test passes; exits 1 on any failure (unittest semantics).
Evidence: the optimality claim is checked against an INDEPENDENT brute-force oracle
on hundreds of randomly generated instances, plus hand-computed cases, malformed
inputs, cycle detection, determinism, and a large-instance invariant sweep.
Run: python3 /root/.hermes/wizard-outputs/null/test_budgeted_planner.py
"""
from __future__ import annotations
import json
import random
import subprocess
import sys
import tempfile
import time
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from budgeted_planner import ( # noqa: E402
SpecError,
Task,
brute_force,
check_selection,
greedy,
plan_from_json,
solve,
topo_order,
)
MODULE = Path(__file__).resolve().parent / "budgeted_planner.py"
def rand_instance(rng: random.Random, n: int, edge_p: float = 0.25):
perm = [f"t{k}" for k in range(n)]
rng.shuffle(perm) # topological order
requires: dict[str, list[str]] = {t: [] for t in perm}
for a in range(n):
for b in range(a + 1, n):
if rng.random() < edge_p:
requires[perm[b]].append(perm[a])
tasks = [
Task(
id=t,
cost=rng.randint(1, 9),
value=rng.randint(0, 20),
requires=tuple(requires[t]),
)
for t in perm
]
return tasks, sum(t.cost for t in tasks)
class TestHandComputed(unittest.TestCase):
def test_chain_forces_prerequisites(self):
# t2 needs t1; budget 10. t2(7,10)+t1(4,3)=11 > 10 -> must drop t2.
tasks = [
Task("t1", 4, 3, ()),
Task("t2", 7, 10, ("t1",)),
Task("t3", 5, 6, ()),
]
r = solve(tasks, 10)
self.assertEqual(r["value"], 9) # t1 + t3
check_selection(tasks, 10, r["selected"])
def test_prereq_blows_budget(self):
tasks = [Task("a", 6, 9, ()), Task("b", 5, 100, ("a",))]
r = solve(tasks, 9)
self.assertEqual(r["value"], 9) # b unreachable: 6+5 > 9
def test_zero_budget_and_empty(self):
self.assertEqual(solve([], 5), {"selected": [], "cost": 0, "value": 0})
self.assertEqual(solve([Task("a", 1, 1, ())], 0)["value"], 0)
def test_zero_value_task_free_inclusion(self):
# budget 3 fits both; value-0 task 'a' adds nothing but is taken because the
# solver's deterministic tie-break prefers include on equal value.
tasks = [Task("a", 1, 0, ()), Task("b", 2, 5, ())]
r = solve(tasks, 3)
self.assertEqual(r["value"], 5)
self.assertEqual(r["cost"], 3)
self.assertIn("a", r["selected"])
# budget 2 cannot hold both (1+2=3): only b fits, optimum 5 either way.
self.assertEqual(solve(tasks, 2)["selected"], ["b"])
def test_tie_is_deterministic_include(self):
tasks = [Task("a", 5, 5, ()), Task("b", 5, 5, ())]
r1 = solve(tasks, 5)
r2 = solve(tasks, 5)
self.assertEqual(r1, r2)
self.assertEqual(len(r1["selected"]), 1)
class TestValidation(unittest.TestCase):
def _must_raise(self, fn, *a, **kw):
with self.assertRaises(SpecError):
fn(*a, **kw)
def test_duplicate_id(self):
self._must_raise(solve, [Task("a", 1, 1, ()), Task("a", 1, 2, ())], 5)
def test_dangling_prerequisite(self):
self._must_raise(solve, [Task("a", 1, 1, ("ghost",))], 5)
def test_cycle(self):
tasks = [Task("a", 1, 1, ("b",)), Task("b", 1, 1, ("a",))]
self._must_raise(solve, tasks, 5)
with self.assertRaises(SpecError):
topo_order(["a", "b"], {"a": ["b"], "b": ["a"]})
def test_self_cycle(self):
self._must_raise(solve, [Task("a", 1, 1, ("a",))], 5)
def test_bad_cost_value_budget(self):
self._must_raise(solve, [Task("a", 0, 1, ())], 5)
self._must_raise(solve, [Task("a", 1, -1, ())], 5)
self._must_raise(solve, [Task("a", 1, 1, ())], -1)
self._must_raise(solve, [Task("a", 1, 1, ())], True)
def test_brute_force_also_validates(self):
self._must_raise(brute_force, [Task("a", 1, 1, ("x",))], 5)
class TestOptimalityAgainstOracle(unittest.TestCase):
"""The core evidence: DP optimum == brute-force optimum on random instances."""
def test_300_random_instances(self):
rng = random.Random(20261023)
checked = 0
non_trivial = 0
for _ in range(300):
n = rng.randint(1, 11)
tasks, total_cost = rand_instance(rng, n, rng.choice([0.0, 0.15, 0.3, 0.5]))
budget = rng.choice([0, rng.randint(1, 12), rng.randint(1, total_cost + 5)])
got = solve(tasks, budget)
want = brute_force(tasks, budget)
self.assertEqual(
got["value"],
want,
f"n={n} budget={budget} got={got['value']} brute={want} "
f"tasks={[(t.id, t.cost, t.value, t.requires) for t in tasks]}",
)
check_selection(tasks, budget, got["selected"])
self.assertEqual(got["value"], sum(t.value for t in tasks if t.id in set(got["selected"])))
self.assertEqual(got["cost"], sum(t.cost for t in tasks if t.id in set(got["selected"])))
self.assertLessEqual(greedy(tasks, budget)["value"], got["value"])
checked += 1
if want > 0:
non_trivial += 1
print(f"\n cross-check: {checked} instances, {non_trivial} with positive optimum")
self.assertEqual(checked, 300)
def test_dense_graphs_match_oracle(self):
rng = random.Random(777)
for _ in range(40):
tasks, total_cost = rand_instance(rng, 8, 0.6)
budget = rng.randint(1, total_cost)
self.assertEqual(solve(tasks, budget)["value"], brute_force(tasks, budget))
def test_zero_value_and_zero_cost_edges(self):
rng = random.Random(4242)
for _ in range(60):
tasks, _ = rand_instance(rng, rng.randint(1, 10), 0.35)
rebuilt = [
Task(t.id, t.cost, 0 if rng.random() < 0.4 else t.value, t.requires)
for t in tasks
]
budget = rng.randint(0, 30)
self.assertEqual(solve(rebuilt, budget)["value"], brute_force(rebuilt, budget))
class TestLargeInstances(unittest.TestCase):
def test_wide_and_deep_invariants(self):
rng = random.Random(99)
# deep chain of 60 tasks
deep = [Task(f"c{i}", rng.randint(1, 3), rng.randint(1, 5), (f"c{i-1}",) if i else ()) for i in range(60)]
r = solve(deep, 40)
check_selection(deep, 40, r["selected"])
self.assertEqual(r["value"], sum(t.value for t in deep if t.id in set(r["selected"])))
self.assertGreaterEqual(r["value"], greedy(deep, 40)["value"])
# wide: 60 independent tasks
wide = [Task(f"w{i}", rng.randint(1, 4), rng.randint(1, 9), ()) for i in range(60)]
r2 = solve(wide, 50)
check_selection(wide, 50, r2["selected"])
self.assertLessEqual(r2["value"], sum(t.value for t in wide))
self.assertGreaterEqual(r2["value"], greedy(wide, 50)["value"])
def test_runtime_bounded(self):
rng = random.Random(5)
tasks, _ = rand_instance(rng, 40, 0.2)
t0 = time.perf_counter()
solve(tasks, 60)
dt = time.perf_counter() - t0
print(f"\n n=40 dense instance solved in {dt:.3f}s")
self.assertLess(dt, 10.0)
class TestCliAndJson(unittest.TestCase):
def test_json_roundtrip(self):
payload = {
"budget": 9,
"tasks": [
{"id": "t1", "cost": 4, "value": 3, "requires": []},
{"id": "t2", "cost": 7, "value": 10, "requires": ["t1"]},
{"id": "t3", "cost": 5, "value": 6, "requires": []},
],
}
self.assertEqual(plan_from_json(payload)["value"], 9)
def test_cli_exit_codes(self):
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
json.dump({"budget": 5, "tasks": [{"id": "a", "cost": 5, "value": 5}]}, fh)
good = fh.name
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
fh.write('{"budget": 5, "tasks": [{"id": "a", "cost": 5, "value": 5, "requires": ["z"]}]}')
bad = fh.name
ok = subprocess.run([sys.executable, str(MODULE), good], capture_output=True, text=True)
self.assertEqual(ok.returncode, 0)
self.assertEqual(json.loads(ok.stdout)["value"], 5)
badrun = subprocess.run([sys.executable, str(MODULE), bad], capture_output=True, text=True)
self.assertEqual(badrun.returncode, 2)
if __name__ == "__main__":
unittest.main(verbosity=2)