null: deporder deterministic DAG build-order planner (42/42 tests, 10/10 mutants caught)

This commit is contained in:
null committed 2026-10-21 10:34:08 +00:00
1 parent a989686a81
commit 170b83ace8
8 files changed
+1298

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
=== deporder EVIDENCE ===
Wed Oct 21 10:30:31 UTC 2026
Python 3.14.7
=== 1. full suite: python3 test_deporder.py ===
test_bad_spec_exit_4 (__main__.CliTests.test_bad_spec_exit_4) ... ok
test_bad_subcommand_exit_4 (__main__.CliTests.test_bad_subcommand_exit_4) ... ok
test_cycle_exit_2 (__main__.CliTests.test_cycle_exit_2) ... ok
test_missing_file_exit_4 (__main__.CliTests.test_missing_file_exit_4) ... ok
test_ok_exit_0 (__main__.CliTests.test_ok_exit_0) ... ok
test_unknown_dep_exit_3 (__main__.CliTests.test_unknown_dep_exit_3) ... ok
test_workers_flag_overrides_spec (__main__.CliTests.test_workers_flag_overrides_spec) ... ok
test_known_length (__main__.CriticalPathTests.test_known_length) ... ok
test_matches_reference (__main__.CriticalPathTests.test_matches_reference) ... ok
test_path_is_precedence_chained (__main__.CriticalPathTests.test_path_is_precedence_chained) ... ok
test_single_node (__main__.CriticalPathTests.test_single_node) ... ok
test_tie_break_is_lexicographic (__main__.CriticalPathTests.test_tie_break_is_lexicographic) ... ok
test_acyclic_has_no_cycle (__main__.GraphTests.test_acyclic_has_no_cycle) ... ok
test_cycle_error_message (__main__.GraphTests.test_cycle_error_message) ... ok
test_cycle_in_one_component_only (__main__.GraphTests.test_cycle_in_one_component_only) ... ok
test_self_loop (__main__.GraphTests.test_self_loop) ... ok
test_three_cycle_path_is_a_real_cycle (__main__.GraphTests.test_three_cycle_path_is_a_real_cycle) ... ok
test_two_cycle (__main__.GraphTests.test_two_cycle) ... ok
test_unknown_dep_reports_sorted_pairs (__main__.GraphTests.test_unknown_dep_reports_sorted_pairs) ... ok
test_chain (__main__.OrderTests.test_chain) ... ok
test_diamond_is_lexicographic (__main__.OrderTests.test_diamond_is_lexicographic) ... ok
test_disconnected_components_interleave (__main__.OrderTests.test_disconnected_components_interleave) ... ok
test_order_prefix_property_on_random_dags (__main__.OrderTests.test_order_prefix_property_on_random_dags) ... ok
test_20k_node_graph_under_budget (__main__.PerfTests.test_20k_node_graph_under_budget) ...
[perf] 20k nodes / 39987 edges: 0.92s (critical path 20000)
ok
test_key_insertion_order_does_not_change_output (__main__.PlanTests.test_key_insertion_order_does_not_change_output) ... ok
test_plan_fields (__main__.PlanTests.test_plan_fields) ... ok
test_plan_is_repeatable (__main__.PlanTests.test_plan_is_repeatable) ... ok
test_empty (__main__.ScheduleTests.test_empty) ... ok
test_schedule_invariants_on_random_dags (__main__.ScheduleTests.test_schedule_invariants_on_random_dags) ... ok
test_single_worker_equals_serial (__main__.ScheduleTests.test_single_worker_equals_serial) ... ok
test_two_workers_never_exceed_capacity (__main__.ScheduleTests.test_two_workers_never_exceed_capacity) ... ok
test_unbounded_workers_equals_critical_path (__main__.ScheduleTests.test_unbounded_workers_equals_critical_path) ... ok
test_defaults (__main__.SpecTests.test_defaults) ... ok
test_duplicate_deps_collapse (__main__.SpecTests.test_duplicate_deps_collapse) ... ok
test_est_coerced_to_float (__main__.SpecTests.test_est_coerced_to_float) ... ok
test_rejects_malformed (__main__.SpecTests.test_rejects_malformed) ... ok
test_workers_override (__main__.SpecTests.test_workers_override) ... ok
test_chain (__main__.WaveTests.test_chain) ... ok
test_diamond (__main__.WaveTests.test_diamond) ... ok
test_parallel_roots_and_skew (__main__.WaveTests.test_parallel_roots_and_skew) ... ok
test_single_node (__main__.WaveTests.test_single_node) ... ok
test_waves_match_reference_and_cover_every_task (__main__.WaveTests.test_waves_match_reference_and_cover_every_task) ... ok
----------------------------------------------------------------------
Ran 42 tests in 1.902s
OK
SUITE_EXIT=0
=== 2. CLI on examples/pipeline.json ===
{
"critical_path": {
"length": 14.0,
"tasks": [
"fetch_sources",
"compile_core",
"unit_tests",
"package"
]
},
"makespan": 14.0,
"order": [
"fetch_sources",
"compile_core",
"compile_plugins",
"lint",
"unit_tests",
"package"
],
"schedule": [
{
"end": 2.0,
"start": 0.0,
"task": "fetch_sources",
"worker": 0
},
{
"end": 8.0,
"start": 2.0,
"task": "compile_core",
"worker": 1
},
{
"end": 5.0,
"start": 2.0,
"task": "compile_plugins",
"worker": 2
},
{
"end": 9.0,
"start": 8.0,
"task": "lint",
"worker": 0
},
{
"end": 12.0,
"start": 8.0,
"task": "unit_tests",
"worker": 2
},
{
"end": 14.0,
"start": 12.0,
"task": "package",
"worker": 1
}
],
"serial_time": 18.0,
"tasks": [
"compile_core",
"compile_plugins",
"fetch_sources",
"lint",
"package",
"unit_tests"
],
"waves": [
[
"fetch_sources"
],
[
"compile_core",
"compile_plugins"
],
[
"lint",
"unit_tests"
],
[
"package"
]
],
"workers": 3
}
CLI_EXIT=0
=== 3. CLI on examples/cycle.json ===
dependency cycle: draft -> review -> publish -> draft
CYCLE_EXIT=2
=== 4. CLI on examples/ghost.json ===
unknown dependencies: app -> missing_module
GHOST_EXIT=3
=== 5. mutation harness: python3 mutate_teeth.py ===
deporder mutation harness: 11 mutations
----------------------------------------------------------------------------------------------------
waves: drop the +1 level bump CAUGHT exit 1 | FAILED (failures=5)
why: every task collapses into wave 0
order: pop the heap tail instead of the min CAUGHT exit 1 | FAILED (failures=5)
why: lexicographic tie-break lost in Kahn
cycles: disable the residual-node check CAUGHT exit 1 | FAILED (failures=5)
why: cycle silently yields a partial order
cycle path: report a single node CAUGHT exit 1 | FAILED (failures=3)
why: reported 'cycle' is not a cycle
unknown deps: stop validating CAUGHT exit 1 | FAILED (failures=1, errors=1)
why: ghost dependency accepted
spec: allow est == 0 CAUGHT exit 1 | FAILED (failures=1)
why: zero-duration task accepted
critical path: break the predecessor tie-break CAUGHT exit 1 | FAILED (failures=1)
why: path no longer lexicographically canonical
schedule: release completions strictly before now CAUGHT exit 1 | FAILED (failures=1, errors=8)
why: worker never freed / clock stalls
schedule: double every task duration CAUGHT exit 1 | FAILED (failures=4)
why: makespan and critical-path bound violated
schedule: pin the clock to itself (kill the stall guard) CAUGHT exit 1 | FAILED (failures=1, errors=8)
why: guard fires: stalled scheduler must not spin
graph: drop the redundant set() in build_graph SURVIVED suite PASSED on mutant (not caught)
why: parse_spec already deduplicates; guard is redundant
----------------------------------------------------------------------------------------------------
caught 10/11 mutants that must die; 0 problem(s)
MUT_EXIT=0
+89
View File
@@ -0,0 +1,89 @@
# deporder — deterministic build-order planner for task DAGs
Lane: Codeforge (hard coding / agentic reasoning). Author: null.
Location of record: `/root/.hermes/wizard-outputs/null/deporder/`.
## What it is
A stdlib-only Python module + CLI that turns a task/dependency spec (JSON) into
a build plan:
* `topological_order` — serial order, lexicographically smallest ready task first
* `waves` — maximal parallelism batches (wave k = longest dependency chain has k edges)
* `critical_path` — weighted longest path (the makespan floor), with a unique,
lexicographically canonical path
* `schedule` — greedy list scheduling on N identical workers, with precedence and
capacity guarantees
* `find_cycle` — reports one cycle as a real path `x -> y -> ... -> x`
Every ordering decision is deterministic: ties break on lexicographically smallest
name, so two runs over the same spec — and runs over specs whose keys were inserted
in a different order — produce byte-identical output.
## CLI
```
python3 deporder.py plan examples/pipeline.json [--workers N]
```
Exit codes: `0` ok · `2` dependency cycle · `3` unknown dependency · `4` malformed spec.
Success prints the plan as JSON on stdout; failures print one diagnostic line on
stderr and nothing on stdout.
## Run the tests
```
python3 test_deporder.py # 42 tests, stdlib unittest, no pytest needed
python3 mutate_teeth.py # mutation harness: do the tests bite?
```
## Evidence (measured on this box, Python 3.14.7)
| Claim | How measured | Result |
|---|---|---|
| 42 tests pass | `python3 test_deporder.py` | `Ran 42 tests in 1.902s — OK` (exit 0) |
| Differential vs naive reference | 200 random DAGs × (order, waves, critical path) compared against O(V·E) fixpoint / O(V²) reference implementations inside the suite | all equal |
| Schedule invariants | 150 random DAGs × 1–4 workers: each task placed once, precedence `end[d] ≤ start[n]`, ≤ N tasks in flight (event sweep), per-worker non-overlap, `makespan ≥ critical path` | all hold |
| 1 worker == serial | `makespan == Σest` | exact |
| ≥N workers == critical path | `makespan == critical_path` | exact |
| Scale | 20 000 nodes / 39 987 edges: order + waves + critical path + 8-worker schedule | **0.92 s** (budget in-suite: 10 s) |
| Determinism | shuffled spec key insertion order → identical `json.dumps(sort_keys=True)` | identical |
| Tests have teeth | `python3 mutate_teeth.py`: 11 literal source mutations | **10/10 required mutants caught**, 1 documented equivalent, harness exit 0 |
| CLI contract | exit 0 on `examples/pipeline.json`; exit 2 + `dependency cycle: draft -> review -> publish -> draft`; exit 3 + `unknown dependencies: app -> missing_module` | as specified |
### Bugs this suite actually caught while building it
All three were found by running things, not by reading the code:
1. **Scheduler stall (infinite loop).** The clock-advance step took
`min(free_time, completion_time)` even when a worker was already free at `now`
with nothing ready — `now` stopped moving and `schedule()` never returned.
Caught by `PlanTests` hanging. Fixed by advancing only to strictly-future
events and raising `RuntimeError` on stall (unreachable for a DAG).
2. **O(V²) critical path.** `min(n for n in dist if dist[n] == max(dist.values()))`
recomputed the max inside the generator: a 20k-node plan took **15.98 s**.
Hoisting `max()` gave **0.85 s** (≈19× faster) with identical results.
3. **Dead branch found by the mutation harness.** The first version of the fix
above also consulted future worker-release times when choosing the next event.
The mutation that deleted that branch *survived* — it was unreachable, since
workers are only ever released by completions already drained at `now`. The
branch was removed rather than left as decoration.
## Mutation harness (`mutate_teeth.py`)
Each mutation is a literal source edit applied to a copy of `deporder.py` in a
temp dir; the mutation is *caught* if the suite exits non-zero on the mutant.
Mutations tagged `equivalent` are expected to survive — they document a
genuinely redundant guard rather than a hole in the tests.
See the harness output in `EVIDENCE.txt` (verbatim run log).
## Files
```
deporder.py implementation (module + CLI)
test_deporder.py 42-test stdlib suite (unit + differential + CLI + perf)
mutate_teeth.py mutation harness
examples/pipeline.json worked example (makespan 14.0 vs 18.0 serial, 3 workers)
EVIDENCE.txt verbatim transcripts of the runs quoted above
```
+410
View File
@@ -0,0 +1,410 @@
#!/usr/bin/env python3
"""deporder -- deterministic build-order planner for task dependency DAGs.
Stdlib only. Spec (JSON):
{
"tasks": {
"compile": {"deps": [], "est": 3},
"link": {"deps": ["compile"], "est": 1}
},
"workers": 2
}
Semantics
---------
* Edge set E = {(d, n) : d in tasks[n].deps} -- "d runs before n".
* `est` is a positive finite duration estimate; default 1.0.
* Every ordering decision is deterministic: ties are broken by
lexicographically smallest task name (heap order), so two runs over the
same spec -- and runs over specs whose keys are inserted in a different
order -- produce byte-identical output.
API
---
parse_spec(obj) -> (tasks, workers)
build_graph(tasks) -> (deps, rdeps) raises UnknownDepError
find_cycle(deps) -> cycle path or None
topological_order(deps) -> list[str] raises CycleError
waves(deps) -> list[list[str]] raises CycleError
critical_path(deps, est) -> (length, path) raises CycleError
schedule(deps, est, workers) -> list[dict] raises CycleError
plan(obj) -> dict full plan
main(argv) -> int CLI entry point
CLI
---
python3 deporder.py plan spec.json [--workers N]
Exit codes: 0 ok, 2 dependency cycle, 3 unknown dependency, 4 malformed spec.
"""
from __future__ import annotations
import heapq
import json
import sys
INF = float("inf")
__all__ = [
"SpecError",
"UnknownDepError",
"CycleError",
"parse_spec",
"build_graph",
"find_cycle",
"topological_order",
"waves",
"critical_path",
"schedule",
"plan",
"main",
]
class SpecError(ValueError):
"""Malformed spec (exit code 4)."""
class UnknownDepError(SpecError):
"""A dependency names a task that does not exist (exit code 3)."""
def __init__(self, pairs):
self.pairs = sorted(tuple(p) for p in pairs)
detail = ", ".join("%s -> %s" % (t, d) for t, d in self.pairs)
super().__init__("unknown dependencies: " + detail)
class CycleError(SpecError):
"""The dependency graph is not a DAG (exit code 2)."""
def __init__(self, cycle):
self.cycle = list(cycle)
super().__init__("dependency cycle: " + " -> ".join(self.cycle + [self.cycle[0]]))
# ---------------------------------------------------------------- spec parsing
def parse_spec(obj):
"""Validate a decoded spec. Returns (tasks, workers)."""
if not isinstance(obj, dict):
raise SpecError("spec must be a JSON object")
raw_tasks = obj.get("tasks")
if not isinstance(raw_tasks, dict) or not raw_tasks:
raise SpecError("spec.tasks must be a non-empty object")
tasks = {}
for name, body in raw_tasks.items():
if not isinstance(name, str) or not name:
raise SpecError("task names must be non-empty strings")
if body is None:
body = {}
if not isinstance(body, dict):
raise SpecError("task %r: body must be an object" % (name,))
raw_deps = body.get("deps", [])
if not isinstance(raw_deps, list):
raise SpecError("task %r: deps must be a list" % (name,))
deps = []
for d in raw_deps:
if not isinstance(d, str) or not d:
raise SpecError("task %r: dependency names must be non-empty strings" % (name,))
if d not in deps:
deps.append(d)
est = body.get("est", 1.0)
if isinstance(est, bool) or not isinstance(est, (int, float)):
raise SpecError("task %r: est must be a number" % (name,))
est = float(est)
if not (est > 0.0) or est == INF:
raise SpecError("task %r: est must be positive and finite" % (name,))
tasks[name] = {"deps": deps, "est": est}
workers = obj.get("workers", 1)
if isinstance(workers, bool) or not isinstance(workers, int) or workers < 1:
raise SpecError("workers must be a positive integer")
return tasks, workers
def build_graph(tasks):
"""Return (deps, rdeps) with sorted, deduplicated adjacency."""
names = sorted(tasks)
deps = {n: tuple(sorted(set(tasks[n]["deps"]))) for n in names}
unknown = [(n, d) for n in names for d in deps[n] if d not in tasks]
if unknown:
raise UnknownDepError(unknown)
rdeps = {n: [] for n in names}
for n in names:
for d in deps[n]:
rdeps[d].append(n)
for n in names:
rdeps[n].sort()
return deps, rdeps
# ------------------------------------------------------------------- cycle find
def find_cycle(deps):
"""Return one cycle as a list of names in execution order, or None.
Traverses the E = {(d, n)} direction, so the returned list [x, y, ...]
reads "x before y before ... before x".
"""
rdeps = {n: [m for m in deps if n in deps[m]] for n in deps}
for n in rdeps:
rdeps[n].sort()
WHITE, GREY, BLACK = 0, 1, 2
color = {n: WHITE for n in deps}
for root in sorted(deps):
if color[root] != WHITE:
continue
color[root] = GREY
path = [root]
stack = [(root, iter(rdeps[root]))]
while stack:
node, it = stack[-1]
descended = False
for nxt in it:
if color[nxt] == GREY:
return path[path.index(nxt):]
if color[nxt] == WHITE:
color[nxt] = GREY
path.append(nxt)
stack.append((nxt, iter(rdeps[nxt])))
descended = True
break
if not descended:
color[node] = BLACK
stack.pop()
path.pop()
return None
# ------------------------------------------------------------------- orderings
def _kahn(deps, rdeps):
"""Kahn's algorithm with a min-heap of ready tasks. Raises CycleError."""
indeg = {n: len(deps[n]) for n in deps}
ready = [n for n in deps if indeg[n] == 0]
heapq.heapify(ready)
order = []
while ready:
n = heapq.heappop(ready)
order.append(n)
for m in rdeps[n]:
indeg[m] -= 1
if indeg[m] == 0:
heapq.heappush(ready, m)
if len(order) != len(deps):
raise CycleError(find_cycle(deps) or [])
return order
def topological_order(deps):
"""A serial build order; lexicographically smallest ready task first."""
_, rdeps = build_graph({n: {"deps": list(deps[n]), "est": 1.0} for n in deps})
return _kahn(deps, rdeps)
def waves(deps):
"""Parallel build waves. wave k holds every task whose longest
dependency chain has k edges. Each wave is sorted."""
_, rdeps = build_graph({n: {"deps": list(deps[n]), "est": 1.0} for n in deps})
order = _kahn(deps, rdeps)
level = {n: 0 for n in deps}
for n in order:
if deps[n]:
level[n] = 1 + max(level[d] for d in deps[n])
depth = max(level.values()) + 1
out = [[] for _ in range(depth)]
for n in sorted(level):
out[level[n]].append(n)
return out
def critical_path(deps, est):
"""Longest weighted path. Returns (length, path).
Ties (equal-length predecessors / equal-length ends) resolve to the
lexicographically smallest name, so the answer is unique.
"""
_, rdeps = build_graph({n: {"deps": list(deps[n]), "est": float(est[n])} for n in deps})
order = _kahn(deps, rdeps)
dist = {}
pred = {}
for n in order:
best, best_d = 0.0, None
for d in deps[n]:
if best_d is None or dist[d] > best or (dist[d] == best and d < best_d):
best, best_d = dist[d], d
dist[n] = best + est[n]
pred[n] = best_d
if not dist:
return 0.0, []
longest = max(dist.values())
end = min(n for n in dist if dist[n] == longest)
path = []
cur = end
while cur is not None:
path.append(cur)
cur = pred[cur]
path.reverse()
return dist[end], path
# ------------------------------------------------------------------- scheduling
def schedule(deps, est, workers):
"""Greedy list scheduling on `workers` identical machines.
Priority: lexicographically smallest ready task. A worker takes the
first ready task at or after the moment it becomes free. Returns a list
of dicts sorted by (start, name).
"""
_, rdeps = build_graph({n: {"deps": list(deps[n]), "est": float(est[n])} for n in deps})
order = _kahn(deps, rdeps)
if not order:
return []
indeg = {n: len(deps[n]) for n in deps}
ready = [n for n in deps if indeg[n] == 0]
heapq.heapify(ready)
free = [(0.0, w) for w in range(workers)]
heapq.heapify(free)
running = [] # (end, name, worker)
out = []
now = 0.0
placed = 0
while placed < len(order):
while running and running[0][0] <= now:
end, name, w = heapq.heappop(running)
heapq.heappush(free, (end, w))
for m in rdeps[name]:
indeg[m] -= 1
if indeg[m] == 0:
heapq.heappush(ready, m)
while ready and free and free[0][0] <= now:
_, w = heapq.heappop(free)
name = heapq.heappop(ready)
end = now + est[name]
out.append({"task": name, "start": now, "end": end, "worker": w})
heapq.heappush(running, (end, name, w))
placed += 1
if placed == len(order):
break
# Every completion at or before `now` has been drained, and workers are
# only released by those completions, so the next event is strictly in
# the future. A DAG always leaves a runnable task here; the guard turns
# any hypothetical scheduler bug into a loud failure instead of a spin.
nxt = running[0][0] if running else None
if nxt is None or nxt <= now:
raise RuntimeError("scheduler stalled with %d task(s) unplaced" % (len(order) - placed))
now = nxt
out.sort(key=lambda r: (r["start"], r["task"]))
return out
# ------------------------------------------------------------------------ plan
def plan(obj):
"""Full plan dict for a decoded spec."""
tasks, workers = parse_spec(obj)
deps, _ = build_graph(tasks)
est = {n: tasks[n]["est"] for n in deps}
order = topological_order(deps)
wv = waves(deps)
cp_len, cp_path = critical_path(deps, est)
sched = schedule(deps, est, workers)
return {
"tasks": sorted(deps),
"order": order,
"waves": wv,
"critical_path": {"tasks": cp_path, "length": cp_len},
"workers": workers,
"makespan": max((r["end"] for r in sched), default=0.0),
"serial_time": sum(est.values()),
"schedule": sched,
}
# ------------------------------------------------------------------------- CLI
def main(argv=None):
argv = list(sys.argv[1:] if argv is None else argv)
if not argv or argv[0] in ("-h", "--help"):
sys.stderr.write("usage: deporder.py plan <spec.json> [--workers N]\n")
return 0 if argv else 4
if argv[0] != "plan":
sys.stderr.write("unknown command %r\n" % (argv[0],))
return 4
rest = argv[1:]
workers = None
path = None
i = 0
while i < len(rest):
if rest[i] == "--workers":
if i + 1 >= len(rest):
sys.stderr.write("--workers needs a value\n")
return 4
try:
workers = int(rest[i + 1])
except ValueError:
sys.stderr.write("--workers must be an integer\n")
return 4
i += 2
elif path is None:
path = rest[i]
i += 1
else:
sys.stderr.write("unexpected argument %r\n" % (rest[i],))
return 4
if path is None:
sys.stderr.write("missing spec path\n")
return 4
try:
with open(path, "r", encoding="utf-8") as fh:
obj = json.load(fh)
except OSError as exc:
sys.stderr.write("cannot read %s: %s\n" % (path, exc))
return 4
except ValueError as exc:
sys.stderr.write("invalid JSON in %s: %s\n" % (path, exc))
return 4
if workers is not None:
if not isinstance(obj, dict):
sys.stderr.write("spec must be a JSON object\n")
return 4
obj = dict(obj)
obj["workers"] = workers
try:
result = plan(obj)
except UnknownDepError as exc:
sys.stderr.write(str(exc) + "\n")
return 3
except CycleError as exc:
sys.stderr.write(str(exc) + "\n")
return 2
except SpecError as exc:
sys.stderr.write(str(exc) + "\n")
return 4
json.dump(result, sys.stdout, indent=2, sort_keys=True)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,7 @@
{
"tasks": {
"publish": {"deps": ["review"], "est": 1},
"review": {"deps": ["draft"], "est": 2},
"draft": {"deps": ["publish"], "est": 3}
}
}
@@ -0,0 +1,5 @@
{
"tasks": {
"app": {"deps": ["missing_module"], "est": 1}
}
}
@@ -0,0 +1,11 @@
{
"tasks": {
"fetch_sources": {"deps": [], "est": 2},
"compile_core": {"deps": ["fetch_sources"], "est": 6},
"compile_plugins": {"deps": ["fetch_sources"], "est": 3},
"unit_tests": {"deps": ["compile_core"], "est": 4},
"lint": {"deps": ["compile_core", "compile_plugins"], "est": 1},
"package": {"deps": ["unit_tests", "lint"], "est": 2}
},
"workers": 3
}
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Mutation harness for deporder: does the test suite actually have teeth?
Each mutation is a literal source edit to deporder.py, applied in an isolated
temp directory holding a copy of deporder.py + test_deporder.py. A mutation is
"caught" when the suite exits non-zero on the mutant.
Exit code is 0 only when every mutation labelled expect="caught" is caught and
every mutation labelled expect="equivalent" survives (a documented redundant
guard, not a hole in the tests).
python3 mutate_teeth.py
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
import tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "deporder.py")
TESTS = os.path.join(HERE, "test_deporder.py")
# (label, old, new, expectation, why)
MUTATIONS = [
("waves: drop the +1 level bump",
"level[n] = 1 + max(level[d] for d in deps[n])",
"level[n] = max(level[d] for d in deps[n])",
"caught", "every task collapses into wave 0"),
("order: pop the heap tail instead of the min",
"n = heapq.heappop(ready)",
"n = ready.pop()",
"caught", "lexicographic tie-break lost in Kahn"),
("cycles: disable the residual-node check",
"if len(order) != len(deps):",
"if False:",
"caught", "cycle silently yields a partial order"),
("cycle path: report a single node",
"return path[path.index(nxt):]",
"return [nxt]",
"caught", "reported 'cycle' is not a cycle"),
("unknown deps: stop validating",
"if unknown:",
"if False:",
"caught", "ghost dependency accepted"),
("spec: allow est == 0",
"if not (est > 0.0) or est == INF:",
"if not (est >= 0.0) or est == INF:",
"caught", "zero-duration task accepted"),
("critical path: break the predecessor tie-break",
"if best_d is None or dist[d] > best or (dist[d] == best and d < best_d):",
"if best_d is None or dist[d] >= best:",
"caught", "path no longer lexicographically canonical"),
("schedule: release completions strictly before now",
"while running and running[0][0] <= now:",
"while running and running[0][0] < now:",
"caught", "worker never freed / clock stalls"),
("schedule: double every task duration",
"end = now + est[name]",
"end = now + est[name] * 2",
"caught", "makespan and critical-path bound violated"),
("schedule: pin the clock to itself (kill the stall guard)",
"nxt = running[0][0] if running else None",
"nxt = now",
"caught", "guard fires: stalled scheduler must not spin"),
("graph: drop the redundant set() in build_graph",
'deps = {n: tuple(sorted(set(tasks[n]["deps"]))) for n in names}',
'deps = {n: tuple(sorted(tasks[n]["deps"])) for n in names}',
"equivalent", "parse_spec already deduplicates; guard is redundant"),
]
def run_mutant(label, old, new, timeout=300):
tmp = tempfile.mkdtemp(prefix="deporder_mut_")
try:
src = open(SRC, "r", encoding="utf-8").read()
hits = src.count(old)
if hits == 0:
return None, "ANCHOR MISSING"
mutated = src.replace(old, new)
with open(os.path.join(tmp, "deporder.py"), "w", encoding="utf-8") as fh:
fh.write(mutated)
shutil.copy(TESTS, os.path.join(tmp, "test_deporder.py"))
try:
proc = subprocess.run([sys.executable, "test_deporder.py"], cwd=tmp,
capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return True, "timeout/hang -> caught"
if proc.returncode == 0:
return False, "suite PASSED on mutant (not caught)"
tail = [ln for ln in proc.stderr.strip().splitlines() if ln.strip()]
detail = tail[-1] if tail else "nonzero exit"
return True, "exit %d | %s" % (proc.returncode, detail[:90])
finally:
shutil.rmtree(tmp, ignore_errors=True)
def main():
print("deporder mutation harness: %d mutations" % len(MUTATIONS))
print("-" * 100)
bad = 0
caught = 0
for label, old, new, expect, why in MUTATIONS:
got, detail = run_mutant(label, old, new)
if got is None:
bad += 1
status = "BROKEN (%s)" % detail
else:
ok = (got and expect == "caught") or (not got and expect == "equivalent")
bad += 0 if ok else 1
caught += 1 if got else 0
status = ("CAUGHT " if got else "SURVIVED ") + detail
if not ok:
status += " <-- UNEXPECTED (expect=%s)" % expect
print("%-46s %s" % (label, status))
print("%-46s why: %s" % ("", why))
print("-" * 100)
print("caught %d/%d mutants that must die; %d problem(s)" % (caught, len(MUTATIONS), bad))
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
+471
View File
@@ -0,0 +1,471 @@
#!/usr/bin/env python3
"""Test suite for deporder. Stdlib only: python3 test_deporder.py
Covers: spec validation, unknown-dependency reporting, cycle detection (with
cycle path), deterministic topological order, parallel waves, weighted
critical path, greedy multi-worker schedule invariants, differential testing
against naive O(V*E) reference implementations on 200 random DAGs, the CLI
contract (exit codes 0/2/3/4), and a 20k-node performance budget.
"""
from __future__ import annotations
import json
import os
import random
import subprocess
import sys
import tempfile
import time
import unittest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import deporder as D # noqa: E402
DEPORDER = os.path.join(HERE, "deporder.py")
EPS = 1e-9
# --------------------------------------------------------------- helpers
def tasks_from(deps, est=None):
est = est or {}
return {n: {"deps": list(deps[n]), "est": est.get(n, 1.0)} for n in deps}
def ref_waves(deps):
"""O(V*E) fixpoint reference for wave assignment."""
level = {n: 0 for n in deps}
changed = True
while changed:
changed = False
for n in sorted(deps):
want = 0 if not deps[n] else 1 + max(level[d] for d in deps[n])
if want != level[n]:
level[n] = want
changed = True
buckets = {}
for n, lv in level.items():
buckets.setdefault(lv, []).append(n)
return [sorted(buckets[k]) for k in sorted(buckets)]
def ref_order(deps):
"""O(V^2) reference: repeatedly emit the smallest fully-ready task."""
done = []
left = set(deps)
while left:
cand = sorted(n for n in left if all(d in done for d in deps[n]))
if not cand:
raise AssertionError("reference hit a cycle")
done.append(cand[0])
left.discard(cand[0])
return done
def ref_dist(deps, est):
"""Bellman-Ford style fixpoint for longest weighted path from any root."""
dist = {n: float(est[n]) for n in deps}
changed = True
while changed:
changed = False
for n in sorted(deps):
want = float(est[n]) + max((dist[d] for d in deps[n]), default=0.0)
if want > dist[n]:
dist[n] = want
changed = True
return dist
def rand_dag(rng, n, max_deps=3):
names = ["n%03d" % i for i in range(n)]
deps = {}
for i, nm in enumerate(names):
pool = names[:i]
k = rng.randint(0, min(max_deps, len(pool)))
deps[nm] = sorted(rng.sample(pool, k))
return deps
def check_schedule(tc, deps, est, workers, sched, makespan):
"""Assert every scheduling invariant that must hold for any valid plan."""
names = sorted(deps)
tc.assertEqual(sorted(r["task"] for r in sched), names, "each task scheduled exactly once")
by = {r["task"]: r for r in sched}
for r in sched:
tc.assertGreaterEqual(r["end"] - r["start"], float(est[r["task"]]) - EPS)
for n in names:
for d in deps[n]:
tc.assertLessEqual(by[d]["end"], by[n]["start"] + EPS, "precedence %s<%s" % (d, n))
per_worker = {}
for r in sched:
per_worker.setdefault(r["worker"], []).append(r)
for w, rs in per_worker.items():
rs.sort(key=lambda r: r["start"])
for a, b in zip(rs, rs[1:]):
tc.assertLessEqual(a["end"], b["start"] + EPS, "worker %d overlap" % w)
events = []
for r in sched:
events.append((r["start"], 1))
events.append((r["end"], -1))
events.sort()
live = 0
for _, delta in events:
live += delta
tc.assertLessEqual(live, workers, "more than %d tasks in flight" % workers)
tc.assertAlmostEqual(makespan, max(r["end"] for r in sched), places=9)
cp_len, _ = D.critical_path(deps, {n: float(est[n]) for n in deps})
tc.assertGreaterEqual(makespan, cp_len - EPS, "makespan below critical path")
def run_cli(args, spec=None):
path = None
tmpdir = None
if spec is not None:
tmpdir = tempfile.mkdtemp()
path = os.path.join(tmpdir, "spec.json")
with open(path, "w", encoding="utf-8") as fh:
fh.write(spec if isinstance(spec, str) else json.dumps(spec))
cmd = [sys.executable, DEPORDER] + list(args) + ([path] if path else [])
proc = subprocess.run(cmd, capture_output=True, text=True)
if tmpdir:
try:
os.remove(path)
os.rmdir(tmpdir)
except OSError:
pass
return proc
# ------------------------------------------------------------------ tests
class SpecTests(unittest.TestCase):
def test_defaults(self):
tasks, workers = D.parse_spec({"tasks": {"a": {}, "b": {"deps": ["a"]}}})
self.assertEqual(workers, 1)
self.assertEqual(tasks["a"], {"deps": [], "est": 1.0})
self.assertEqual(tasks["b"], {"deps": ["a"], "est": 1.0})
def test_workers_override(self):
_, workers = D.parse_spec({"tasks": {"a": {}}, "workers": 3})
self.assertEqual(workers, 3)
def test_duplicate_deps_collapse(self):
tasks, _ = D.parse_spec({"tasks": {"a": {}, "b": {"deps": ["a", "a", "a"]}}})
self.assertEqual(tasks["b"]["deps"], ["a"])
def test_est_coerced_to_float(self):
tasks, _ = D.parse_spec({"tasks": {"a": {"est": 2}}})
self.assertIsInstance(tasks["a"]["est"], float)
def test_rejects_malformed(self):
bad = [
None, [], "x", {},
{"tasks": []},
{"tasks": {}},
{"tasks": {"a": []}},
{"tasks": {"a": {"deps": "b"}}},
{"tasks": {"a": {"deps": [1]}}},
{"tasks": {"a": {"deps": [""]}}},
{"tasks": {"a": {"est": 0}}},
{"tasks": {"a": {"est": -1}}},
{"tasks": {"a": {"est": True}}},
{"tasks": {"a": {"est": "1"}}},
{"tasks": {"a": {"est": float("inf")}}},
{"tasks": {"a": {}}, "workers": 0},
{"tasks": {"a": {}}, "workers": -2},
{"tasks": {"a": {}}, "workers": 2.5},
{"tasks": {"a": {}}, "workers": True},
]
for spec in bad:
with self.assertRaises(D.SpecError, msg="should reject %r" % (spec,)):
D.parse_spec(spec)
class GraphTests(unittest.TestCase):
def test_unknown_dep_reports_sorted_pairs(self):
spec = {"tasks": {"b": {"deps": ["zz"]}, "a": {"deps": ["yy"]}}}
tasks, _ = D.parse_spec(spec)
with self.assertRaises(D.UnknownDepError) as ctx:
D.build_graph(tasks)
self.assertEqual(ctx.exception.pairs, [("a", "yy"), ("b", "zz")])
self.assertIn("a -> yy", str(ctx.exception))
def test_acyclic_has_no_cycle(self):
deps = {"a": [], "b": ["a"], "c": ["a", "b"]}
self.assertIsNone(D.find_cycle(deps))
def test_self_loop(self):
deps = {"a": ["a"]}
self.assertEqual(D.find_cycle(deps), ["a"])
with self.assertRaises(D.CycleError) as ctx:
D.topological_order(deps)
self.assertEqual(ctx.exception.cycle, ["a"])
def test_two_cycle(self):
deps = {"a": ["b"], "b": ["a"]}
cycle = D.find_cycle(deps)
self.assertEqual(len(cycle), 2)
self.assertEqual(set(cycle), {"a", "b"})
self.assertIn(cycle[0], deps[cycle[1]])
def test_three_cycle_path_is_a_real_cycle(self):
deps = {"a": ["c"], "b": ["a"], "c": ["b"]}
cycle = D.find_cycle(deps)
self.assertEqual(len(cycle), 3)
for i, n in enumerate(cycle):
self.assertIn(cycle[i - 1], deps[n], "edge %s -> %s missing" % (cycle[i - 1], n))
with self.assertRaises(D.CycleError):
D.waves(deps)
def test_cycle_in_one_component_only(self):
deps = {"a": [], "b": ["a"], "x": ["y"], "y": ["x"]}
self.assertEqual(set(D.find_cycle(deps)), {"x", "y"})
with self.assertRaises(D.CycleError):
D.topological_order(deps)
def test_cycle_error_message(self):
deps = {"a": ["b"], "b": ["a"]}
with self.assertRaises(D.CycleError) as ctx:
D.critical_path(deps, {"a": 1.0, "b": 1.0})
msg = str(ctx.exception)
self.assertTrue(msg.startswith("dependency cycle: "), msg)
head, tail = msg.split(": ", 1)
parts = tail.split(" -> ")
self.assertEqual(parts[0], parts[-1], "cycle path must return to its start")
class OrderTests(unittest.TestCase):
def test_diamond_is_lexicographic(self):
deps = {"d": ["b", "c"], "c": ["a"], "b": ["a"], "a": []}
self.assertEqual(D.topological_order(deps), ["a", "b", "c", "d"])
def test_chain(self):
deps = {"c": ["b"], "b": ["a"], "a": []}
self.assertEqual(D.topological_order(deps), ["a", "b", "c"])
def test_disconnected_components_interleave(self):
deps = {"a1": [], "b1": [], "a2": ["a1"], "b2": ["b1"]}
self.assertEqual(D.topological_order(deps), ["a1", "a2", "b1", "b2"])
self.assertEqual(D.topological_order(deps), ref_order(deps))
def test_order_prefix_property_on_random_dags(self):
rng = random.Random(20261021)
for _ in range(200):
deps = rand_dag(rng, rng.randint(1, 12))
order = D.topological_order(deps)
seen = set()
for n in order:
self.assertTrue(set(deps[n]) <= seen, "dep of %s emitted late" % n)
seen.add(n)
self.assertEqual(order, ref_order(deps))
class WaveTests(unittest.TestCase):
def test_diamond(self):
deps = {"d": ["b", "c"], "c": ["a"], "b": ["a"], "a": []}
self.assertEqual(D.waves(deps), [["a"], ["b", "c"], ["d"]])
def test_chain(self):
deps = {"c": ["b"], "b": ["a"], "a": []}
self.assertEqual(D.waves(deps), [["a"], ["b"], ["c"]])
def test_parallel_roots_and_skew(self):
deps = {"a": [], "b": [], "c": ["a"], "d": ["c"], "e": ["a", "b"]}
self.assertEqual(D.waves(deps), [["a", "b"], ["c", "e"], ["d"]])
def test_single_node(self):
self.assertEqual(D.waves({"solo": []}), [["solo"]])
def test_waves_match_reference_and_cover_every_task(self):
rng = random.Random(7)
for _ in range(200):
deps = rand_dag(rng, rng.randint(1, 14))
wv = D.waves(deps)
self.assertEqual(wv, ref_waves(deps))
self.assertEqual(sorted(x for w in wv for x in w), sorted(deps))
for w in wv:
self.assertEqual(w, sorted(w))
class CriticalPathTests(unittest.TestCase):
def test_known_length(self):
deps = {"a": [], "b": ["a"], "c": ["a"], "d": ["b", "c"]}
est = {"a": 3.0, "b": 1.0, "c": 4.0, "d": 2.0}
length, path = D.critical_path(deps, est)
self.assertAlmostEqual(length, 3.0 + 4.0 + 2.0, places=9)
self.assertEqual(path, ["a", "c", "d"])
self.assertAlmostEqual(sum(est[n] for n in path), length, places=9)
def test_path_is_precedence_chained(self):
deps = {"a": [], "b": ["a"], "c": ["a", "b"], "d": ["b"]}
est = {"a": 1.0, "b": 1.0, "c": 5.0, "d": 1.0}
_, path = D.critical_path(deps, est)
for prev, nxt in zip(path, path[1:]):
self.assertIn(prev, deps[nxt])
def test_tie_break_is_lexicographic(self):
deps = {"a": [], "b": [], "z": ["a", "b"]}
est = {"a": 2.0, "b": 2.0, "z": 1.0}
length, path = D.critical_path(deps, est)
self.assertAlmostEqual(length, 3.0, places=9)
self.assertEqual(path, ["a", "z"])
def test_single_node(self):
self.assertEqual(D.critical_path({"a": []}, {"a": 2.5}), (2.5, ["a"]))
def test_matches_reference(self):
rng = random.Random(99)
for _ in range(200):
deps = rand_dag(rng, rng.randint(1, 14))
est = {n: float(rng.choice([0.5, 1, 2, 3, 4])) for n in deps}
length, path = D.critical_path(deps, est)
self.assertAlmostEqual(length, max(ref_dist(deps, est).values()), places=9)
self.assertAlmostEqual(sum(est[n] for n in path), length, places=9)
class ScheduleTests(unittest.TestCase):
DEPS = {"a": [], "b": ["a"], "c": ["a"], "d": ["b", "c"], "e": ["d"]}
EST = {"a": 3.0, "b": 1.0, "c": 4.0, "d": 2.0, "e": 1.0}
def test_single_worker_equals_serial(self):
sched = D.schedule(self.DEPS, self.EST, 1)
check_schedule(self, self.DEPS, self.EST, 1, sched, max(r["end"] for r in sched))
self.assertAlmostEqual(max(r["end"] for r in sched), sum(self.EST.values()), places=9)
def test_unbounded_workers_equals_critical_path(self):
w = len(self.DEPS)
sched = D.schedule(self.DEPS, self.EST, w)
check_schedule(self, self.DEPS, self.EST, w, sched, max(r["end"] for r in sched))
cp, _ = D.critical_path(self.DEPS, self.EST)
self.assertAlmostEqual(max(r["end"] for r in sched), cp, places=9)
def test_two_workers_never_exceed_capacity(self):
sched = D.schedule(self.DEPS, self.EST, 2)
check_schedule(self, self.DEPS, self.EST, 2, sched, max(r["end"] for r in sched))
self.assertAlmostEqual(max(r["end"] for r in sched), 10.0, places=9)
def test_empty(self):
self.assertEqual(D.schedule({}, {}, 3), [])
def test_schedule_invariants_on_random_dags(self):
rng = random.Random(4242)
for _ in range(150):
deps = rand_dag(rng, rng.randint(1, 12))
est = {n: float(rng.choice([1, 2, 3, 5])) for n in deps}
workers = rng.randint(1, 4)
sched = D.schedule(deps, est, workers)
check_schedule(self, deps, est, workers, sched, max(r["end"] for r in sched))
class PlanTests(unittest.TestCase):
SPEC = {
"tasks": {"a": {"est": 3}, "b": {"deps": ["a"], "est": 1},
"c": {"deps": ["a"], "est": 4}, "d": {"deps": ["b", "c"], "est": 2}},
"workers": 2,
}
def test_plan_fields(self):
p = D.plan(self.SPEC)
self.assertEqual(p["order"], ["a", "b", "c", "d"])
self.assertEqual(p["waves"], [["a"], ["b", "c"], ["d"]])
self.assertEqual(p["critical_path"]["tasks"], ["a", "c", "d"])
self.assertAlmostEqual(p["critical_path"]["length"], 9.0, places=9)
self.assertAlmostEqual(p["serial_time"], 10.0, places=9)
self.assertAlmostEqual(p["makespan"], 9.0, places=9)
def test_key_insertion_order_does_not_change_output(self):
base = D.plan(self.SPEC)
shuffled = dict(reversed(list(self.SPEC["tasks"].items())))
alt = D.plan({"tasks": shuffled, "workers": 2})
self.assertEqual(json.dumps(base, sort_keys=True), json.dumps(alt, sort_keys=True))
def test_plan_is_repeatable(self):
a = json.dumps(D.plan(self.SPEC), sort_keys=True)
b = json.dumps(D.plan(self.SPEC), sort_keys=True)
self.assertEqual(a, b)
class CliTests(unittest.TestCase):
def test_ok_exit_0(self):
proc = run_cli(["plan"], PlanTests.SPEC)
self.assertEqual(proc.returncode, 0, proc.stderr)
out = json.loads(proc.stdout)
self.assertEqual(out["order"], ["a", "b", "c", "d"])
self.assertEqual(out["workers"], 2)
def test_workers_flag_overrides_spec(self):
spec = {"tasks": {"a": {}, "b": {}}}
proc = run_cli(["plan", "--workers", "2"], spec)
self.assertEqual(proc.returncode, 0, proc.stderr)
self.assertEqual(json.loads(proc.stdout)["workers"], 2)
def test_cycle_exit_2(self):
spec = {"tasks": {"a": {"deps": ["b"]}, "b": {"deps": ["a"]}}}
proc = run_cli(["plan"], spec)
self.assertEqual(proc.returncode, 2, proc.stdout)
self.assertIn("dependency cycle", proc.stderr)
self.assertEqual(proc.stdout.strip(), "")
def test_unknown_dep_exit_3(self):
proc = run_cli(["plan"], {"tasks": {"a": {"deps": ["ghost"]}}})
self.assertEqual(proc.returncode, 3)
self.assertIn("unknown dependencies: a -> ghost", proc.stderr)
def test_bad_spec_exit_4(self):
proc = run_cli(["plan"], {"tasks": {"a": {"est": -1}}})
self.assertEqual(proc.returncode, 4)
proc = run_cli(["plan"], "{not json")
self.assertEqual(proc.returncode, 4)
self.assertIn("invalid JSON", proc.stderr)
def test_missing_file_exit_4(self):
proc = subprocess.run([sys.executable, DEPORDER, "plan", "/nonexistent/spec.json"],
capture_output=True, text=True)
self.assertEqual(proc.returncode, 4)
def test_bad_subcommand_exit_4(self):
proc = run_cli(["frobnicate"], {"tasks": {"a": {}}})
self.assertEqual(proc.returncode, 4)
class PerfTests(unittest.TestCase):
def test_20k_node_graph_under_budget(self):
n = 20000
deps = {}
est = {}
for i in range(n):
nm = "t%05d" % i
deps[nm] = ["t%05d" % (i - 1)] if i else []
est[nm] = 1.0
rng = random.Random(1)
names = sorted(deps)
for _ in range(n):
j = rng.randrange(1, n)
i = rng.randrange(0, j)
deps[names[j]].append(names[i])
for nm in deps:
deps[nm] = sorted(set(deps[nm]))
t0 = time.perf_counter()
order = D.topological_order(deps)
wv = D.waves(deps)
cp_len, cp_path = D.critical_path(deps, est)
sched = D.schedule(deps, est, 8)
elapsed = time.perf_counter() - t0
self.assertEqual(len(order), n)
self.assertEqual(sorted(x for w in wv for x in w), names)
self.assertAlmostEqual(cp_len, float(len(cp_path)), places=9)
self.assertEqual(len(sched), n)
self.assertLess(elapsed, 10.0, "20k-node plan took %.2fs" % elapsed)
sys.stderr.write("\n[perf] 20k nodes / %d edges: %.2fs (critical path %.0f)\n"
% (sum(len(v) for v in deps.values()), elapsed, cp_len))
if __name__ == "__main__":
unittest.main(verbosity=2)