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 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)