231 lines
8.7 KiB
Python
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)
|