pydp-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pydp/__init__.py +76 -0
- pydp/analyze.py +185 -0
- pydp/engine.py +360 -0
- pydp/explain.py +55 -0
- pydp/graph.py +152 -0
- pydp/optimize.py +176 -0
- pydp/state.py +40 -0
- pydp/stats.py +53 -0
- pydp/storage.py +66 -0
- pydp/templates.py +322 -0
- pydp/visualize.py +103 -0
- pydp_engine-0.1.0.dist-info/METADATA +266 -0
- pydp_engine-0.1.0.dist-info/RECORD +15 -0
- pydp_engine-0.1.0.dist-info/WHEEL +5 -0
- pydp_engine-0.1.0.dist-info/top_level.txt +1 -0
pydp/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""PyDP -- an intelligent, declarative Dynamic Programming engine.
|
|
2
|
+
|
|
3
|
+
Describe the recurrence; the engine handles memoization, dependency analysis,
|
|
4
|
+
execution strategy, memory optimization, statistics, and visualization.
|
|
5
|
+
|
|
6
|
+
Quick start::
|
|
7
|
+
|
|
8
|
+
from pydp import dp
|
|
9
|
+
|
|
10
|
+
@dp
|
|
11
|
+
def fib(solve, n):
|
|
12
|
+
if n < 2:
|
|
13
|
+
return n
|
|
14
|
+
return solve(n - 1) + solve(n - 2)
|
|
15
|
+
|
|
16
|
+
print(fib(30)) # 832040
|
|
17
|
+
print(fib.stats.summary())
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from .analyze import (
|
|
23
|
+
BaseCase,
|
|
24
|
+
RecurrenceAnalysis,
|
|
25
|
+
RecursiveCall,
|
|
26
|
+
analyze,
|
|
27
|
+
analyze_source,
|
|
28
|
+
)
|
|
29
|
+
from .engine import CyclicDependencyError, DPProblem, dp
|
|
30
|
+
from .explain import explain
|
|
31
|
+
from .graph import DependencyGraph
|
|
32
|
+
from .optimize import (
|
|
33
|
+
ComplexityEstimate,
|
|
34
|
+
MemoryOptimization,
|
|
35
|
+
analyze_memory,
|
|
36
|
+
estimate_complexity,
|
|
37
|
+
suggestions,
|
|
38
|
+
)
|
|
39
|
+
from .stats import PerformanceStats
|
|
40
|
+
from .storage import StorageAnalysis, analyze_storage
|
|
41
|
+
from .visualize import (
|
|
42
|
+
dependency_text,
|
|
43
|
+
heatmap_text,
|
|
44
|
+
recursion_tree,
|
|
45
|
+
to_graphviz,
|
|
46
|
+
)
|
|
47
|
+
from . import templates
|
|
48
|
+
|
|
49
|
+
__version__ = "0.1.0"
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"dp",
|
|
53
|
+
"DPProblem",
|
|
54
|
+
"CyclicDependencyError",
|
|
55
|
+
"DependencyGraph",
|
|
56
|
+
"PerformanceStats",
|
|
57
|
+
"StorageAnalysis",
|
|
58
|
+
"analyze_storage",
|
|
59
|
+
"MemoryOptimization",
|
|
60
|
+
"ComplexityEstimate",
|
|
61
|
+
"analyze_memory",
|
|
62
|
+
"estimate_complexity",
|
|
63
|
+
"suggestions",
|
|
64
|
+
"explain",
|
|
65
|
+
"analyze",
|
|
66
|
+
"analyze_source",
|
|
67
|
+
"RecurrenceAnalysis",
|
|
68
|
+
"RecursiveCall",
|
|
69
|
+
"BaseCase",
|
|
70
|
+
"recursion_tree",
|
|
71
|
+
"dependency_text",
|
|
72
|
+
"heatmap_text",
|
|
73
|
+
"to_graphviz",
|
|
74
|
+
"templates",
|
|
75
|
+
"__version__",
|
|
76
|
+
]
|
pydp/analyze.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""AST-based static analysis of a recurrence function.
|
|
2
|
+
|
|
3
|
+
This inspects the *source* of a recurrence (before it ever runs) and infers its
|
|
4
|
+
structure: the solve handle, the state parameters, the base cases, and the
|
|
5
|
+
recursive transitions. It complements the runtime dependency graph in
|
|
6
|
+
:mod:`pydp.graph` — that one observes what actually happened, this one reads
|
|
7
|
+
what the code says.
|
|
8
|
+
|
|
9
|
+
Analysis is best-effort: it degrades gracefully (empty / partial results)
|
|
10
|
+
when source is unavailable (e.g. lambdas defined in the REPL) or when the
|
|
11
|
+
recurrence uses constructs it does not model.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import inspect
|
|
18
|
+
import textwrap
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any, Callable, List, Optional
|
|
21
|
+
|
|
22
|
+
from .engine import DPProblem
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class RecursiveCall:
|
|
27
|
+
"""A call to the solve handle found in the source."""
|
|
28
|
+
args_src: List[str] # source text of each argument
|
|
29
|
+
lineno: int
|
|
30
|
+
|
|
31
|
+
def __str__(self) -> str:
|
|
32
|
+
return f"solve({', '.join(self.args_src)}) @line {self.lineno}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class BaseCase:
|
|
37
|
+
"""A return statement that contains no recursive call (a terminating case)."""
|
|
38
|
+
condition_src: Optional[str] # guarding condition, if any
|
|
39
|
+
value_src: str
|
|
40
|
+
lineno: int
|
|
41
|
+
|
|
42
|
+
def __str__(self) -> str:
|
|
43
|
+
if self.condition_src:
|
|
44
|
+
return f"if {self.condition_src}: return {self.value_src} @line {self.lineno}"
|
|
45
|
+
return f"return {self.value_src} @line {self.lineno}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class RecurrenceAnalysis:
|
|
50
|
+
name: str
|
|
51
|
+
solve_param: Optional[str]
|
|
52
|
+
state_params: List[str]
|
|
53
|
+
base_cases: List[BaseCase] = field(default_factory=list)
|
|
54
|
+
recursive_calls: List[RecursiveCall] = field(default_factory=list)
|
|
55
|
+
source_available: bool = True
|
|
56
|
+
notes: List[str] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def dimensionality(self) -> int:
|
|
60
|
+
return len(self.state_params)
|
|
61
|
+
|
|
62
|
+
def summary(self) -> str:
|
|
63
|
+
lines = [f"Recurrence analysis: {self.name!r}", "-" * 44]
|
|
64
|
+
if not self.source_available:
|
|
65
|
+
lines.append(" (source unavailable — static analysis skipped)")
|
|
66
|
+
return "\n".join(lines)
|
|
67
|
+
lines.append(f" Solve handle : {self.solve_param}")
|
|
68
|
+
lines.append(f" State parameters : {', '.join(self.state_params) or '(none)'}")
|
|
69
|
+
lines.append(f" Dimensionality : {self.dimensionality}-D")
|
|
70
|
+
lines.append(f" Base cases : {len(self.base_cases)}")
|
|
71
|
+
for bc in self.base_cases:
|
|
72
|
+
lines.append(f" - {bc}")
|
|
73
|
+
lines.append(f" Recursive calls : {len(self.recursive_calls)}")
|
|
74
|
+
for rc in self.recursive_calls:
|
|
75
|
+
lines.append(f" - {rc}")
|
|
76
|
+
if self.notes:
|
|
77
|
+
lines.append(" Notes:")
|
|
78
|
+
for n in self.notes:
|
|
79
|
+
lines.append(f" * {n}")
|
|
80
|
+
return "\n".join(lines)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _unparse(node: ast.AST) -> str:
|
|
84
|
+
try:
|
|
85
|
+
return ast.unparse(node) # py3.9+
|
|
86
|
+
except Exception: # pragma: no cover - defensive
|
|
87
|
+
return "<expr>"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _Visitor(ast.NodeVisitor):
|
|
91
|
+
def __init__(self, solve_param: str) -> None:
|
|
92
|
+
self.solve_param = solve_param
|
|
93
|
+
self.recursive_calls: List[RecursiveCall] = []
|
|
94
|
+
|
|
95
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
96
|
+
func = node.func
|
|
97
|
+
if isinstance(func, ast.Name) and func.id == self.solve_param:
|
|
98
|
+
self.recursive_calls.append(
|
|
99
|
+
RecursiveCall(args_src=[_unparse(a) for a in node.args],
|
|
100
|
+
lineno=getattr(node, "lineno", -1))
|
|
101
|
+
)
|
|
102
|
+
self.generic_visit(node)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _contains_solve_call(node: ast.AST, solve_param: str) -> bool:
|
|
106
|
+
for child in ast.walk(node):
|
|
107
|
+
if (isinstance(child, ast.Call) and isinstance(child.func, ast.Name)
|
|
108
|
+
and child.func.id == solve_param):
|
|
109
|
+
return True
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _get_function_def(func: Callable[..., Any]):
|
|
114
|
+
src = textwrap.dedent(inspect.getsource(func))
|
|
115
|
+
module = ast.parse(src)
|
|
116
|
+
for node in module.body:
|
|
117
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
118
|
+
return node
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def analyze_source(func: Callable[..., Any], name: Optional[str] = None) -> RecurrenceAnalysis:
|
|
123
|
+
"""Statically analyze a recurrence *function* (not a DPProblem)."""
|
|
124
|
+
display = name or getattr(func, "__name__", "recurrence")
|
|
125
|
+
try:
|
|
126
|
+
fn_def = _get_function_def(func)
|
|
127
|
+
except (OSError, TypeError):
|
|
128
|
+
return RecurrenceAnalysis(display, None, [], source_available=False)
|
|
129
|
+
|
|
130
|
+
if fn_def is None:
|
|
131
|
+
return RecurrenceAnalysis(display, None, [], source_available=False,
|
|
132
|
+
notes=["could not locate a function definition"])
|
|
133
|
+
|
|
134
|
+
params = [a.arg for a in fn_def.args.args]
|
|
135
|
+
solve_param = params[0] if params else None
|
|
136
|
+
state_params = params[1:] if len(params) > 1 else []
|
|
137
|
+
|
|
138
|
+
analysis = RecurrenceAnalysis(display, solve_param, state_params)
|
|
139
|
+
|
|
140
|
+
if solve_param is None:
|
|
141
|
+
analysis.notes.append("recurrence has no parameters; expected solve(...) handle first")
|
|
142
|
+
return analysis
|
|
143
|
+
|
|
144
|
+
# Recursive calls anywhere in the body.
|
|
145
|
+
visitor = _Visitor(solve_param)
|
|
146
|
+
visitor.visit(fn_def)
|
|
147
|
+
analysis.recursive_calls = visitor.recursive_calls
|
|
148
|
+
|
|
149
|
+
# Base cases: return statements with no recursive call in the returned expr.
|
|
150
|
+
for node in ast.walk(fn_def):
|
|
151
|
+
if isinstance(node, ast.Return) and node.value is not None:
|
|
152
|
+
if not _contains_solve_call(node.value, solve_param):
|
|
153
|
+
cond = _enclosing_condition(fn_def, node)
|
|
154
|
+
analysis.base_cases.append(
|
|
155
|
+
BaseCase(condition_src=cond,
|
|
156
|
+
value_src=_unparse(node.value),
|
|
157
|
+
lineno=getattr(node, "lineno", -1))
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if not analysis.recursive_calls:
|
|
161
|
+
analysis.notes.append("no recursive solve(...) calls found — is this actually a DP recurrence?")
|
|
162
|
+
if not analysis.base_cases:
|
|
163
|
+
analysis.notes.append("no base case detected — recurrence may not terminate")
|
|
164
|
+
|
|
165
|
+
return analysis
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _enclosing_condition(fn_def: ast.AST, target: ast.Return) -> Optional[str]:
|
|
169
|
+
"""Find the innermost `if` test whose body directly contains `target`."""
|
|
170
|
+
found: Optional[str] = None
|
|
171
|
+
|
|
172
|
+
class Finder(ast.NodeVisitor):
|
|
173
|
+
def visit_If(self, node: ast.If) -> None:
|
|
174
|
+
if any(stmt is target for stmt in node.body):
|
|
175
|
+
nonlocal found
|
|
176
|
+
found = _unparse(node.test)
|
|
177
|
+
self.generic_visit(node)
|
|
178
|
+
|
|
179
|
+
Finder().visit(fn_def)
|
|
180
|
+
return found
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def analyze(problem: DPProblem, name: Optional[str] = None) -> RecurrenceAnalysis:
|
|
184
|
+
"""Statically analyze the recurrence backing a :class:`DPProblem`."""
|
|
185
|
+
return analyze_source(problem.func, name=name or problem.name)
|
pydp/engine.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""The PyDP core engine.
|
|
2
|
+
|
|
3
|
+
Users declare a recurrence as a plain function whose first parameter is a
|
|
4
|
+
``solve`` handle used to request sub-states::
|
|
5
|
+
|
|
6
|
+
@dp
|
|
7
|
+
def fib(solve, n):
|
|
8
|
+
if n < 2:
|
|
9
|
+
return n
|
|
10
|
+
return solve(n - 1) + solve(n - 2)
|
|
11
|
+
|
|
12
|
+
fib(30) # -> 832040
|
|
13
|
+
fib.stats # PerformanceStats for the last run
|
|
14
|
+
fib.graph # DependencyGraph for the last run
|
|
15
|
+
|
|
16
|
+
The engine transparently memoizes states, records the dependency graph and
|
|
17
|
+
recursion tree, collects performance statistics, and can execute the recurrence
|
|
18
|
+
either top-down (default) or via an automatically derived bottom-up order.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import threading
|
|
26
|
+
import time
|
|
27
|
+
from typing import Any, Callable, Dict, Hashable, List, Optional, Tuple
|
|
28
|
+
|
|
29
|
+
from .graph import DependencyGraph
|
|
30
|
+
from .state import make_key
|
|
31
|
+
from .stats import PerformanceStats
|
|
32
|
+
from .storage import StorageAnalysis, analyze_storage
|
|
33
|
+
|
|
34
|
+
# States requested during the current top-level run but not yet computed are
|
|
35
|
+
# pushed onto a stack so we can attribute dependency edges to their parent.
|
|
36
|
+
_SENTINEL = object()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class CyclicDependencyError(ValueError):
|
|
40
|
+
"""Raised when a recurrence asks for a state that is still being computed."""
|
|
41
|
+
|
|
42
|
+
# Deep recurrences can exceed the interpreter's default limit *and* the OS C
|
|
43
|
+
# stack. sys.setrecursionlimit only raises the former; to grow the actual stack
|
|
44
|
+
# we run the computation in a worker thread with an enlarged stack size. The
|
|
45
|
+
# maximum stack size accepted by threading.stack_size is platform-dependent
|
|
46
|
+
# (e.g. Windows rejects >~256 MB), so we probe a descending list of candidates.
|
|
47
|
+
_STACK_CANDIDATES_MB = (200, 128, 64, 32, 16)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _apply_big_stack_size() -> None:
|
|
51
|
+
for mb in _STACK_CANDIDATES_MB:
|
|
52
|
+
try:
|
|
53
|
+
threading.stack_size(mb * 1024 * 1024)
|
|
54
|
+
return
|
|
55
|
+
except (ValueError, RuntimeError, OverflowError):
|
|
56
|
+
continue
|
|
57
|
+
# No candidate accepted; leave the default stack in place.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run_with_big_stack(fn: Callable[[], Any]) -> Any:
|
|
61
|
+
box: Dict[str, Any] = {}
|
|
62
|
+
|
|
63
|
+
def target() -> None:
|
|
64
|
+
try:
|
|
65
|
+
box["value"] = fn()
|
|
66
|
+
except BaseException as exc: # propagate to caller thread
|
|
67
|
+
box["error"] = exc
|
|
68
|
+
|
|
69
|
+
old_stack = threading.stack_size()
|
|
70
|
+
_apply_big_stack_size()
|
|
71
|
+
worker = threading.Thread(target=target)
|
|
72
|
+
worker.start()
|
|
73
|
+
worker.join()
|
|
74
|
+
try:
|
|
75
|
+
threading.stack_size(old_stack)
|
|
76
|
+
except (ValueError, RuntimeError):
|
|
77
|
+
pass
|
|
78
|
+
if "error" in box:
|
|
79
|
+
raise box["error"]
|
|
80
|
+
return box.get("value")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class _RunContext:
|
|
84
|
+
"""Mutable per-run state shared between the solver and the problem."""
|
|
85
|
+
|
|
86
|
+
def __init__(self) -> None:
|
|
87
|
+
self.memo: Dict[Hashable, Any] = {}
|
|
88
|
+
self.args_of: Dict[Hashable, Tuple[Any, ...]] = {}
|
|
89
|
+
self.graph = DependencyGraph()
|
|
90
|
+
self.stats = PerformanceStats()
|
|
91
|
+
self.call_stack: List[Hashable] = []
|
|
92
|
+
# States currently being evaluated (on the recursion stack). Used to
|
|
93
|
+
# detect cyclic dependencies before they overflow the C stack.
|
|
94
|
+
self.in_progress: set = set()
|
|
95
|
+
# Full recursion tree: parent-key -> ordered list of child keys.
|
|
96
|
+
self.tree_children: Dict[Optional[Hashable], List[Hashable]] = {}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DPProblem:
|
|
100
|
+
"""A declarative dynamic-programming problem.
|
|
101
|
+
|
|
102
|
+
Wrap a recurrence function with :func:`dp` (or construct directly). Calling
|
|
103
|
+
the instance solves for the requested state and caches artifacts from the
|
|
104
|
+
most recent run on ``.stats``, ``.graph``, ``.table`` and ``.tree``.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
func: Callable[..., Any],
|
|
110
|
+
name: Optional[str] = None,
|
|
111
|
+
strategy: str = "top-down",
|
|
112
|
+
recursion_limit: int = 1_000_000,
|
|
113
|
+
) -> None:
|
|
114
|
+
if strategy not in ("top-down", "bottom-up", "auto", "parallel"):
|
|
115
|
+
raise ValueError(f"unknown strategy: {strategy!r}")
|
|
116
|
+
self.func = func
|
|
117
|
+
self.name = name or getattr(func, "__name__", "dp")
|
|
118
|
+
self.strategy = strategy
|
|
119
|
+
self.recursion_limit = recursion_limit
|
|
120
|
+
|
|
121
|
+
# Artifacts from the most recent run.
|
|
122
|
+
self.stats: Optional[PerformanceStats] = None
|
|
123
|
+
self.graph: Optional[DependencyGraph] = None
|
|
124
|
+
self.table: Dict[Hashable, Any] = {}
|
|
125
|
+
self.tree: Dict[Optional[Hashable], List[Hashable]] = {}
|
|
126
|
+
self._last_ctx: Optional[_RunContext] = None
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------ #
|
|
129
|
+
# Public entry points
|
|
130
|
+
# ------------------------------------------------------------------ #
|
|
131
|
+
def __call__(self, *args: Any, strategy: Optional[str] = None,
|
|
132
|
+
workers: Optional[int] = None) -> Any:
|
|
133
|
+
strat = strategy or self.strategy
|
|
134
|
+
if strat == "auto":
|
|
135
|
+
strat = "top-down"
|
|
136
|
+
if strat == "top-down":
|
|
137
|
+
return self._run_top_down(args)
|
|
138
|
+
if strat == "parallel":
|
|
139
|
+
return self._run_bottom_up(args, parallel=True, workers=workers)
|
|
140
|
+
return self._run_bottom_up(args)
|
|
141
|
+
|
|
142
|
+
def solve(self, *args: Any, strategy: Optional[str] = None) -> Any:
|
|
143
|
+
"""Alias for calling the problem; reads naturally in templates."""
|
|
144
|
+
return self.__call__(*args, strategy=strategy)
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------ #
|
|
147
|
+
# Top-down execution (demand-driven recursion + memoization)
|
|
148
|
+
# ------------------------------------------------------------------ #
|
|
149
|
+
def _run_top_down(self, args: Tuple[Any, ...]) -> Any:
|
|
150
|
+
ctx = _RunContext()
|
|
151
|
+
ctx.stats.strategy = "top-down"
|
|
152
|
+
self._install(ctx)
|
|
153
|
+
old_limit = sys.getrecursionlimit()
|
|
154
|
+
sys.setrecursionlimit(max(old_limit, self.recursion_limit))
|
|
155
|
+
start = time.perf_counter()
|
|
156
|
+
try:
|
|
157
|
+
result = _run_with_big_stack(lambda: self._compute(ctx, args))
|
|
158
|
+
finally:
|
|
159
|
+
ctx.stats.execution_time = time.perf_counter() - start
|
|
160
|
+
ctx.stats.peak_states_in_memory = len(ctx.memo)
|
|
161
|
+
sys.setrecursionlimit(old_limit)
|
|
162
|
+
self._publish(ctx)
|
|
163
|
+
return result
|
|
164
|
+
|
|
165
|
+
def _compute(self, ctx: _RunContext, args: Tuple[Any, ...]) -> Any:
|
|
166
|
+
key = make_key(args)
|
|
167
|
+
parent = ctx.call_stack[-1] if ctx.call_stack else None
|
|
168
|
+
|
|
169
|
+
# Record structure regardless of cache state.
|
|
170
|
+
ctx.graph.add_node(key)
|
|
171
|
+
if parent is not None:
|
|
172
|
+
ctx.graph.add_edge(parent, key)
|
|
173
|
+
ctx.tree_children.setdefault(parent, []).append(key)
|
|
174
|
+
|
|
175
|
+
if key in ctx.memo:
|
|
176
|
+
ctx.stats.cache_hits += 1
|
|
177
|
+
return ctx.memo[key]
|
|
178
|
+
|
|
179
|
+
if key in ctx.in_progress:
|
|
180
|
+
raise CyclicDependencyError(
|
|
181
|
+
f"state {key!r} depends on itself (cyclic recurrence); "
|
|
182
|
+
"a well-formed DP recurrence must be acyclic."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
ctx.stats.cache_misses += 1
|
|
186
|
+
ctx.stats.states_explored += 1
|
|
187
|
+
ctx.args_of[key] = args
|
|
188
|
+
|
|
189
|
+
ctx.call_stack.append(key)
|
|
190
|
+
ctx.in_progress.add(key)
|
|
191
|
+
depth = len(ctx.call_stack)
|
|
192
|
+
if depth > ctx.stats.max_recursion_depth:
|
|
193
|
+
ctx.stats.max_recursion_depth = depth
|
|
194
|
+
try:
|
|
195
|
+
value = self.func(ctx.solver, *args)
|
|
196
|
+
finally:
|
|
197
|
+
ctx.call_stack.pop()
|
|
198
|
+
ctx.in_progress.discard(key)
|
|
199
|
+
|
|
200
|
+
ctx.memo[key] = value
|
|
201
|
+
return value
|
|
202
|
+
|
|
203
|
+
# ------------------------------------------------------------------ #
|
|
204
|
+
# Bottom-up execution (topological order over the discovered DAG)
|
|
205
|
+
# ------------------------------------------------------------------ #
|
|
206
|
+
def _run_bottom_up(self, args: Tuple[Any, ...], parallel: bool = False,
|
|
207
|
+
workers: Optional[int] = None) -> Any:
|
|
208
|
+
# Phase 1: discover the dependency DAG and the args of every state via
|
|
209
|
+
# a top-down pass (values memoized so recursion stays shallow after the
|
|
210
|
+
# first descent).
|
|
211
|
+
discovery = _RunContext()
|
|
212
|
+
label = "parallel" if parallel else "bottom-up"
|
|
213
|
+
discovery.stats.strategy = label
|
|
214
|
+
self._install(discovery)
|
|
215
|
+
old_limit = sys.getrecursionlimit()
|
|
216
|
+
sys.setrecursionlimit(max(old_limit, self.recursion_limit))
|
|
217
|
+
start = time.perf_counter()
|
|
218
|
+
try:
|
|
219
|
+
_run_with_big_stack(lambda: self._compute(discovery, args))
|
|
220
|
+
if discovery.graph.has_cycle():
|
|
221
|
+
raise CyclicDependencyError(
|
|
222
|
+
f"recurrence has cyclic state dependencies; cannot solve "
|
|
223
|
+
f"{label} (use strategy='top-down')."
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
ctx = _RunContext()
|
|
227
|
+
ctx.stats.strategy = label
|
|
228
|
+
ctx.args_of = discovery.args_of
|
|
229
|
+
|
|
230
|
+
if parallel:
|
|
231
|
+
self._eval_parallel(ctx, discovery, workers)
|
|
232
|
+
else:
|
|
233
|
+
self._eval_sequential(ctx, discovery)
|
|
234
|
+
|
|
235
|
+
result = ctx.memo[make_key(args)]
|
|
236
|
+
ctx.stats.execution_time = time.perf_counter() - start
|
|
237
|
+
ctx.stats.max_recursion_depth = 1
|
|
238
|
+
ctx.stats.peak_states_in_memory = len(ctx.memo)
|
|
239
|
+
for dep_from, dep_to in discovery.graph.edges():
|
|
240
|
+
ctx.graph.add_edge(dep_from, dep_to)
|
|
241
|
+
self._publish(ctx)
|
|
242
|
+
finally:
|
|
243
|
+
sys.setrecursionlimit(old_limit)
|
|
244
|
+
return result
|
|
245
|
+
|
|
246
|
+
def _eval_sequential(self, ctx: "_RunContext", discovery: "_RunContext") -> None:
|
|
247
|
+
"""Re-evaluate every state in dependency order (single-threaded)."""
|
|
248
|
+
self._install(ctx)
|
|
249
|
+
order = discovery.graph.topological_order()
|
|
250
|
+
for key in order:
|
|
251
|
+
state_args = discovery.args_of.get(key)
|
|
252
|
+
if state_args is None:
|
|
253
|
+
continue
|
|
254
|
+
ctx.graph.add_node(key)
|
|
255
|
+
ctx.stats.states_explored += 1
|
|
256
|
+
ctx.stats.cache_misses += 1
|
|
257
|
+
ctx.call_stack.append(key)
|
|
258
|
+
try:
|
|
259
|
+
value = self.func(ctx.solver, *state_args)
|
|
260
|
+
finally:
|
|
261
|
+
ctx.call_stack.pop()
|
|
262
|
+
ctx.memo[key] = value
|
|
263
|
+
|
|
264
|
+
def _eval_parallel(self, ctx: "_RunContext", discovery: "_RunContext",
|
|
265
|
+
workers: Optional[int]) -> None:
|
|
266
|
+
"""Evaluate topological layers concurrently.
|
|
267
|
+
|
|
268
|
+
Every state in a layer depends only on earlier layers, whose values are
|
|
269
|
+
already in ``completed``. Each worker gets a read-only solver over that
|
|
270
|
+
shared dict, so no worker mutates state another worker reads.
|
|
271
|
+
|
|
272
|
+
Note: the recurrence body runs in Python, so the GIL bounds CPU-bound
|
|
273
|
+
speedup; the win is real for recurrences that release the GIL (I/O,
|
|
274
|
+
NumPy) and the layering is what a genuine multi-process backend would
|
|
275
|
+
also consume. Determinism and correctness are guaranteed regardless.
|
|
276
|
+
"""
|
|
277
|
+
import concurrent.futures as _cf
|
|
278
|
+
|
|
279
|
+
completed: Dict[Hashable, Any] = {}
|
|
280
|
+
|
|
281
|
+
def readonly_solver(*sub_args: Any) -> Any:
|
|
282
|
+
return completed[make_key(sub_args)]
|
|
283
|
+
|
|
284
|
+
layers = discovery.graph.topological_layers()
|
|
285
|
+
max_workers = workers or min(32, (os.cpu_count() or 2))
|
|
286
|
+
|
|
287
|
+
def evaluate(key: Hashable) -> Tuple[Hashable, Any]:
|
|
288
|
+
state_args = discovery.args_of[key]
|
|
289
|
+
return key, self.func(readonly_solver, *state_args)
|
|
290
|
+
|
|
291
|
+
with _cf.ThreadPoolExecutor(max_workers=max_workers) as pool:
|
|
292
|
+
for layer in layers:
|
|
293
|
+
runnable = [k for k in layer if k in discovery.args_of]
|
|
294
|
+
results = list(pool.map(evaluate, runnable))
|
|
295
|
+
for key, value in results:
|
|
296
|
+
completed[key] = value
|
|
297
|
+
ctx.memo[key] = value
|
|
298
|
+
ctx.graph.add_node(key)
|
|
299
|
+
ctx.stats.states_explored += 1
|
|
300
|
+
ctx.stats.cache_misses += 1
|
|
301
|
+
|
|
302
|
+
# ------------------------------------------------------------------ #
|
|
303
|
+
# Solver handle passed into the recurrence
|
|
304
|
+
# ------------------------------------------------------------------ #
|
|
305
|
+
def _install(self, ctx: _RunContext) -> None:
|
|
306
|
+
def solver(*sub_args: Any) -> Any:
|
|
307
|
+
return self._compute(ctx, sub_args)
|
|
308
|
+
ctx.solver = solver # type: ignore[attr-defined]
|
|
309
|
+
|
|
310
|
+
def _publish(self, ctx: _RunContext) -> None:
|
|
311
|
+
self.stats = ctx.stats
|
|
312
|
+
self.graph = ctx.graph
|
|
313
|
+
self.table = dict(ctx.memo)
|
|
314
|
+
self.tree = ctx.tree_children
|
|
315
|
+
self._last_ctx = ctx
|
|
316
|
+
|
|
317
|
+
# ------------------------------------------------------------------ #
|
|
318
|
+
# Analysis helpers
|
|
319
|
+
# ------------------------------------------------------------------ #
|
|
320
|
+
def storage(self) -> StorageAnalysis:
|
|
321
|
+
"""Recommend a storage mechanism for the most recent run's states."""
|
|
322
|
+
if self.graph is None:
|
|
323
|
+
raise RuntimeError("run the problem before analyzing storage")
|
|
324
|
+
return analyze_storage(self.graph.nodes)
|
|
325
|
+
|
|
326
|
+
def clear(self) -> None:
|
|
327
|
+
self.stats = None
|
|
328
|
+
self.graph = None
|
|
329
|
+
self.table = {}
|
|
330
|
+
self.tree = {}
|
|
331
|
+
self._last_ctx = None
|
|
332
|
+
|
|
333
|
+
def __repr__(self) -> str:
|
|
334
|
+
return f"<DPProblem {self.name!r} strategy={self.strategy!r}>"
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def dp(
|
|
338
|
+
func: Optional[Callable[..., Any]] = None,
|
|
339
|
+
*,
|
|
340
|
+
strategy: str = "top-down",
|
|
341
|
+
name: Optional[str] = None,
|
|
342
|
+
recursion_limit: int = 1_000_000,
|
|
343
|
+
) -> Any:
|
|
344
|
+
"""Decorator turning a recurrence function into a :class:`DPProblem`.
|
|
345
|
+
|
|
346
|
+
Usage::
|
|
347
|
+
|
|
348
|
+
@dp
|
|
349
|
+
def fib(solve, n): ...
|
|
350
|
+
|
|
351
|
+
@dp(strategy="bottom-up")
|
|
352
|
+
def knap(solve, i, cap): ...
|
|
353
|
+
"""
|
|
354
|
+
def wrap(f: Callable[..., Any]) -> DPProblem:
|
|
355
|
+
return DPProblem(f, name=name, strategy=strategy,
|
|
356
|
+
recursion_limit=recursion_limit)
|
|
357
|
+
|
|
358
|
+
if func is not None:
|
|
359
|
+
return wrap(func)
|
|
360
|
+
return wrap
|
pydp/explain.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Educational mode: narrate how a solved DP problem behaved."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import List
|
|
6
|
+
|
|
7
|
+
from .engine import DPProblem
|
|
8
|
+
from .optimize import estimate_complexity, suggestions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def explain(problem: DPProblem, max_states: int = 12) -> str:
|
|
12
|
+
if problem.graph is None or problem.stats is None:
|
|
13
|
+
raise RuntimeError("run the problem before explaining it")
|
|
14
|
+
|
|
15
|
+
graph = problem.graph
|
|
16
|
+
stats = problem.stats
|
|
17
|
+
lines: List[str] = []
|
|
18
|
+
lines.append(f"How '{problem.name}' was solved")
|
|
19
|
+
lines.append("=" * 44)
|
|
20
|
+
|
|
21
|
+
lines.append("")
|
|
22
|
+
lines.append("1. State space")
|
|
23
|
+
lines.append(f" Explored {len(graph.nodes)} distinct states using the "
|
|
24
|
+
f"{stats.strategy} strategy.")
|
|
25
|
+
|
|
26
|
+
lines.append("")
|
|
27
|
+
lines.append("2. Dependency structure")
|
|
28
|
+
acyclic = not graph.has_cycle()
|
|
29
|
+
lines.append(f" The state graph is {'acyclic (a DAG)' if acyclic else 'cyclic'} "
|
|
30
|
+
f"with {graph.edge_count()} edges.")
|
|
31
|
+
if acyclic:
|
|
32
|
+
try:
|
|
33
|
+
order = graph.topological_order()
|
|
34
|
+
shown = ", ".join(str(s) for s in order[:max_states])
|
|
35
|
+
more = "" if len(order) <= max_states else ", ..."
|
|
36
|
+
lines.append(f" Valid evaluation order (dependencies first): {shown}{more}")
|
|
37
|
+
except ValueError:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
lines.append("")
|
|
41
|
+
lines.append("3. Why memoization helps")
|
|
42
|
+
lines.append(f" {stats.cache_hits} of {stats.total_lookups} lookups were cache "
|
|
43
|
+
f"hits ({stats.hit_rate:.0%}); each hit avoided recomputing an "
|
|
44
|
+
"entire sub-problem.")
|
|
45
|
+
|
|
46
|
+
lines.append("")
|
|
47
|
+
lines.append("4. Complexity")
|
|
48
|
+
lines.append(f" {estimate_complexity(problem)}")
|
|
49
|
+
|
|
50
|
+
lines.append("")
|
|
51
|
+
lines.append("5. Optimization suggestions")
|
|
52
|
+
for s in suggestions(problem):
|
|
53
|
+
lines.append(f" - {s}")
|
|
54
|
+
|
|
55
|
+
return "\n".join(lines)
|