ocalc 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.
ocalc/__init__.py ADDED
@@ -0,0 +1,216 @@
1
+ """Measure a Python function and estimate its time and space complexity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+
10
+ from ocalc.bench import BenchResult, Point, benchmark
11
+ from ocalc.comment import (
12
+ Protocol,
13
+ apply_comment,
14
+ existing_comment,
15
+ format_comment,
16
+ format_table,
17
+ )
18
+ from ocalc.fit import FitResult, fit_curve
19
+ from ocalc.generate import (
20
+ FunctionNotFound,
21
+ NeedSample,
22
+ callable_location,
23
+ load_function,
24
+ parse_sample,
25
+ plan_inputs,
26
+ )
27
+ from ocalc.static import FunctionEstimate, SourceAnalysis, analyze_source
28
+
29
+ __version__ = "0.1.0"
30
+
31
+ __all__ = [
32
+ "FunctionEstimate",
33
+ "FunctionNotFound",
34
+ "NeedSample",
35
+ "Report",
36
+ "SourceAnalysis",
37
+ "analyze_source",
38
+ "annotate_file",
39
+ "measure",
40
+ "parse_sample",
41
+ ]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Report:
46
+ comment: str
47
+ points: list[Point]
48
+ time: FitResult
49
+ space: FitResult
50
+ worst_s: float
51
+ median_s: float
52
+ worst_n: int | None
53
+ protocol: Protocol
54
+
55
+
56
+ def measure(
57
+ func,
58
+ *sample_args,
59
+ budget: float | None = None,
60
+ min_n: int | None = None,
61
+ max_n: int | None = None,
62
+ measures: int | None = None,
63
+ repeats: int | None = None,
64
+ file: str | None = None,
65
+ qualname: str | None = None,
66
+ protocol: Protocol | None = None,
67
+ previous_s: float | None = None,
68
+ **sample_kwargs,
69
+ ) -> Report:
70
+ """Measure a function and return its complexity report.
71
+
72
+ Args:
73
+ func: The callable to measure.
74
+ sample_args: Example positional arguments. The first collection grows with ``n``.
75
+ budget: Seconds to spend measuring. The default is 5.
76
+ min_n: Smallest input size.
77
+ max_n: Largest input size.
78
+ measures: How many sizes to try.
79
+ repeats: Timed calls at each size.
80
+ file: Source file, when the callable should be re-imported for the timed run.
81
+ qualname: Qualname used to re-import ``file``.
82
+ protocol: Stored sizes and budget from an earlier comment.
83
+ previous_s: Worst time from that earlier comment.
84
+ sample_kwargs: Example keyword arguments.
85
+
86
+ Returns:
87
+ The comment, the size table, and the fitted time and space classes.
88
+ """
89
+ plan = plan_inputs(func, sample_args, sample_kwargs)
90
+ if protocol is not None and protocol.constant and plan.mode == "constant":
91
+ if budget is None:
92
+ budget = protocol.budget
93
+ if repeats is None:
94
+ repeats = protocol.repeats
95
+ elif protocol is not None and not protocol.constant and plan.mode != "constant":
96
+ if budget is None:
97
+ budget = protocol.budget
98
+ if min_n is None:
99
+ min_n = protocol.min_n
100
+ if max_n is None:
101
+ max_n = protocol.max_n
102
+ if measures is None:
103
+ measures = protocol.measures
104
+ if repeats is None:
105
+ repeats = protocol.repeats
106
+
107
+ located_file, located_name = callable_location(func)
108
+ file = file or located_file
109
+ qualname = qualname or located_name
110
+ result = benchmark(
111
+ func,
112
+ plan,
113
+ budget=5.0 if budget is None else budget,
114
+ min_n=min_n,
115
+ max_n=max_n,
116
+ measures=measures,
117
+ repeats=repeats,
118
+ file=file,
119
+ qualname=qualname,
120
+ )
121
+ time_fit, space_fit = _fits(result)
122
+ comment = format_comment(
123
+ time=time_fit,
124
+ space=space_fit,
125
+ worst_s=result.worst_s,
126
+ worst_n=result.worst_n,
127
+ median_s=result.median_s,
128
+ protocol=result.protocol,
129
+ previous_s=previous_s,
130
+ )
131
+ return Report(
132
+ comment=comment,
133
+ points=result.points,
134
+ time=time_fit,
135
+ space=space_fit,
136
+ worst_s=result.worst_s,
137
+ median_s=result.median_s,
138
+ worst_n=result.worst_n,
139
+ protocol=result.protocol,
140
+ )
141
+
142
+
143
+ def annotate_file(
144
+ path,
145
+ qualname: str,
146
+ *sample_args,
147
+ budget: float | None = None,
148
+ min_n: int | None = None,
149
+ max_n: int | None = None,
150
+ measures: int | None = None,
151
+ repeats: int | None = None,
152
+ **sample_kwargs,
153
+ ) -> Report:
154
+ """Benchmark a function in a file and write the comment above it.
155
+
156
+ A later call reuses the protocol stored in the existing comment and records
157
+ the previous worst time.
158
+
159
+ Args:
160
+ path: Python file that defines the function.
161
+ qualname: Function qualname, such as ``two_sum`` or ``Box.run``.
162
+ sample_args: Example positional arguments.
163
+ budget: Seconds to spend measuring.
164
+ min_n: Smallest input size.
165
+ max_n: Largest input size.
166
+ measures: How many sizes to try.
167
+ repeats: Timed calls at each size.
168
+ sample_kwargs: Example keyword arguments.
169
+
170
+ Returns:
171
+ The report that was written into the file.
172
+ """
173
+ file_path = Path(path)
174
+ source = file_path.read_text(encoding="utf-8")
175
+ previous = existing_comment(source, qualname)
176
+ protocol = previous.protocol if previous is not None else None
177
+ previous_s = previous.worst_s if previous is not None else None
178
+ func = load_function(file_path, qualname)
179
+ report = measure(
180
+ func,
181
+ *sample_args,
182
+ budget=budget,
183
+ min_n=min_n,
184
+ max_n=max_n,
185
+ measures=measures,
186
+ repeats=repeats,
187
+ file=str(file_path.resolve()),
188
+ qualname=qualname,
189
+ protocol=protocol,
190
+ previous_s=previous_s,
191
+ **sample_kwargs,
192
+ )
193
+ updated = apply_comment(source, qualname, report.comment)
194
+ with file_path.open("w", encoding="utf-8", newline="") as handle:
195
+ handle.write(updated)
196
+ return report
197
+
198
+
199
+ def _fits(result: BenchResult) -> tuple[FitResult, FitResult]:
200
+ if result.constant:
201
+ done = FitResult("1", "O(1)", "high")
202
+ return done, done
203
+ sizes = [point.n for point in result.points]
204
+ times = [point.time_s for point in result.points]
205
+ time_fit = fit_curve(sizes, times, exponential=result.exponential)
206
+ return time_fit, _space_fit(sizes, [point.memory_bytes for point in result.points])
207
+
208
+
209
+ def _space_fit(sizes, memory) -> FitResult:
210
+ used = np.asarray(memory, dtype=float)
211
+ if used.size == 0 or np.nanmax(used) <= 0:
212
+ return FitResult("1", "O(1)", "high")
213
+ positive = used[used > 0]
214
+ floor = float(positive.min()) if positive.size else 1.0
215
+ used = np.where(used > 0, used, floor)
216
+ return fit_curve(sizes, used)
ocalc/bench.py ADDED
@@ -0,0 +1,287 @@
1
+ """Time a function across a size grid and record peak Python allocations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import inspect
7
+ import math
8
+ import multiprocessing
9
+ import time
10
+ import tracemalloc
11
+ from dataclasses import dataclass
12
+ from statistics import median
13
+
14
+ from ocalc.comment import Protocol
15
+ from ocalc.generate import InputPlan, load_function
16
+
17
+ DEFAULT_BUDGET = 5.0
18
+ DEFAULT_MEASURES = 8
19
+ DEFAULT_MIN_N = 100
20
+ DEFAULT_MAX_N = 100_000
21
+ DEFAULT_MIN_N_INT = 8
22
+ _REPEATS = 5
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Point:
27
+ n: int | None
28
+ time_s: float
29
+ memory_bytes: float
30
+
31
+
32
+ @dataclass
33
+ class BenchResult:
34
+ points: list[Point]
35
+ protocol: Protocol
36
+ exponential: bool
37
+ constant: bool
38
+ timed_out: bool
39
+ worst_s: float
40
+ median_s: float
41
+ worst_n: int | None
42
+
43
+
44
+ def geometric_sizes(min_n: int, max_n: int, measures: int) -> list[int]:
45
+ """Evenly spaced sizes on a log scale, including both ends."""
46
+ if measures < 1:
47
+ raise ValueError("measures must be at least 1")
48
+ if min_n > max_n:
49
+ raise ValueError("min_n cannot be greater than max_n")
50
+ if measures == 1 or min_n == max_n:
51
+ return [int(max_n)]
52
+ ratio = (max_n / min_n) ** (1 / (measures - 1))
53
+ raw = [int(round(min_n * ratio**i)) for i in range(measures)]
54
+ raw[0] = int(min_n)
55
+ raw[-1] = int(max_n)
56
+ sizes: list[int] = []
57
+ for size in raw:
58
+ size = max(int(min_n), size)
59
+ if not sizes or size > sizes[-1]:
60
+ sizes.append(size)
61
+ if sizes[-1] != int(max_n):
62
+ sizes.append(int(max_n))
63
+ return sizes
64
+
65
+
66
+ def benchmark(
67
+ func,
68
+ plan: InputPlan,
69
+ *,
70
+ budget: float,
71
+ min_n: int | None,
72
+ max_n: int | None,
73
+ measures: int | None,
74
+ repeats: int | None,
75
+ file: str | None,
76
+ qualname: str | None,
77
+ ) -> BenchResult:
78
+ measures = DEFAULT_MEASURES if measures is None else measures
79
+ budget = DEFAULT_BUDGET if budget is None else budget
80
+ repeats = _REPEATS if repeats is None else max(1, int(repeats))
81
+ if plan.mode == "constant":
82
+ return _benchmark_constant(func, plan, budget, repeats, file, qualname)
83
+
84
+ if plan.mode == "int_n":
85
+ min_n = DEFAULT_MIN_N_INT if min_n is None else min_n
86
+ max_n = DEFAULT_MAX_N if max_n is None else max_n
87
+ else:
88
+ min_n = DEFAULT_MIN_N if min_n is None else min_n
89
+ max_n = DEFAULT_MAX_N if max_n is None else max_n
90
+ protocol = Protocol(int(min_n), int(max_n), int(measures), float(budget), repeats=repeats)
91
+ sizes = geometric_sizes(protocol.min_n, protocol.max_n, protocol.measures)
92
+ points: list[Point] = []
93
+ exponential = False
94
+ timed_out = False
95
+ started = time.perf_counter()
96
+
97
+ for size in sizes:
98
+ remaining = budget - (time.perf_counter() - started)
99
+ if remaining <= 0.05:
100
+ break
101
+ if len(points) >= 2 and _explosive(
102
+ points[-2].n, points[-2].time_s, points[-1].n, points[-1].time_s
103
+ ):
104
+ exponential = True
105
+ break
106
+ if points:
107
+ ratio = size / points[-1].n
108
+ predicted = points[-1].time_s * max(ratio, 1.0) ** 2
109
+ if predicted > remaining:
110
+ break
111
+ args, kwargs = plan.build(size)
112
+ status, times, peak = _measure_call(
113
+ func,
114
+ args,
115
+ kwargs,
116
+ file=file,
117
+ qualname=qualname,
118
+ repeats=repeats,
119
+ timeout=max(0.2, remaining),
120
+ )
121
+ if status == "timeout":
122
+ timed_out = True
123
+ if points and (
124
+ points[-1].n <= 40
125
+ and points[-1].time_s > 1e-4
126
+ or (
127
+ len(points) >= 2
128
+ and _explosive(points[-2].n, points[-2].time_s, points[-1].n, points[-1].time_s)
129
+ )
130
+ ):
131
+ exponential = True
132
+ break
133
+ if status == "error":
134
+ raise RuntimeError(times)
135
+ point = Point(size, float(median(times)), float(peak))
136
+ points.append(point)
137
+ if len(points) >= 2 and _explosive(
138
+ points[-2].n, points[-2].time_s, points[-1].n, points[-1].time_s
139
+ ):
140
+ exponential = True
141
+ break
142
+
143
+ if not points:
144
+ raise RuntimeError(
145
+ "No measurement finished within the budget. Raise --budget or lower --min-n."
146
+ )
147
+ slowest = max(points, key=lambda item: item.time_s)
148
+ return BenchResult(
149
+ points=points,
150
+ protocol=protocol,
151
+ exponential=exponential,
152
+ constant=False,
153
+ timed_out=timed_out,
154
+ worst_s=slowest.time_s,
155
+ median_s=float(median(item.time_s for item in points)),
156
+ worst_n=slowest.n,
157
+ )
158
+
159
+
160
+ def _benchmark_constant(func, plan, budget, repeats, file, qualname) -> BenchResult:
161
+ args, kwargs = plan.build(0)
162
+ status, times, peak = _measure_call(
163
+ func,
164
+ args,
165
+ kwargs,
166
+ file=file,
167
+ qualname=qualname,
168
+ repeats=repeats,
169
+ timeout=max(0.2, budget),
170
+ )
171
+ if status == "timeout":
172
+ raise RuntimeError(
173
+ "No measurement finished within the budget. Raise --budget or lower --min-n."
174
+ )
175
+ if status == "error":
176
+ raise RuntimeError(times)
177
+ protocol = Protocol(0, 0, 0, float(budget), constant=True, repeats=repeats)
178
+ point = Point(None, float(median(times)), float(peak))
179
+ return BenchResult(
180
+ points=[point],
181
+ protocol=protocol,
182
+ exponential=False,
183
+ constant=True,
184
+ timed_out=False,
185
+ worst_s=float(max(times)),
186
+ median_s=float(median(times)),
187
+ worst_n=None,
188
+ )
189
+
190
+
191
+ def _explosive(n0, t0, n1, t1) -> bool:
192
+ if t0 <= 0 or t1 <= 0 or n1 <= n0:
193
+ return False
194
+ n_ratio = n1 / n0
195
+ t_ratio = t1 / t0
196
+ if n_ratio < 1.5 and t_ratio > 2 and n1 <= 64 and t1 > 1e-4:
197
+ return True
198
+ if t1 < 1e-3 or n_ratio <= 1:
199
+ return False
200
+ exponent = math.log(t_ratio) / math.log(n_ratio)
201
+ return exponent > 5
202
+
203
+
204
+ def _call(func, args, kwargs):
205
+ result = func(*args, **kwargs)
206
+ if inspect.iscoroutine(result):
207
+ return asyncio.run(result)
208
+ return result
209
+
210
+
211
+ def _measure_call(func, args, kwargs, *, file, qualname, repeats, timeout):
212
+ if file and qualname:
213
+ return _run_process(file, qualname, args, kwargs, repeats, timeout)
214
+ times, peak = collect_sample(func, args, kwargs, repeats)
215
+ return "ok", times, peak
216
+
217
+
218
+ def collect_sample(func, args, kwargs, repeats: int = _REPEATS):
219
+ """Warm up, time several calls, then record one tracemalloc peak."""
220
+ _call(func, args, kwargs)
221
+ batch = 1
222
+
223
+ def once() -> float:
224
+ started = time.perf_counter()
225
+ for _ in range(batch):
226
+ _call(func, args, kwargs)
227
+ return (time.perf_counter() - started) / batch
228
+
229
+ first = once()
230
+ if first < 0.001:
231
+ batch = min(10_000, max(1, math.ceil(0.002 / max(first, 1e-9))))
232
+ first = once()
233
+ times = [first]
234
+ if first <= 0.2:
235
+ for _ in range(repeats - 1):
236
+ duration = once()
237
+ times.append(duration)
238
+ if duration > 0.2:
239
+ break
240
+ tracemalloc.start()
241
+ try:
242
+ _call(func, args, kwargs)
243
+ _current, peak = tracemalloc.get_traced_memory()
244
+ finally:
245
+ tracemalloc.stop()
246
+ return times, float(peak)
247
+
248
+
249
+ def _run_process(file, qualname, args, kwargs, repeats, timeout):
250
+ context = multiprocessing.get_context("spawn")
251
+ parent, child = context.Pipe(duplex=False)
252
+ process = context.Process(
253
+ target=_worker,
254
+ args=(child, str(file), qualname, args, kwargs, repeats),
255
+ daemon=True,
256
+ )
257
+ process.start()
258
+ child.close()
259
+ try:
260
+ process.join(timeout)
261
+ if process.is_alive():
262
+ process.terminate()
263
+ process.join()
264
+ return "timeout", None, None
265
+ if not parent.poll(0.2):
266
+ return "error", "The measurement process stopped without a result.", None
267
+ message = parent.recv()
268
+ finally:
269
+ parent.close()
270
+ if not message or message[0] != "ok":
271
+ detail = message[1] if message and len(message) > 1 else "measurement failed"
272
+ return "error", detail, None
273
+ return "ok", message[1], message[2]
274
+
275
+
276
+ def _worker(conn, file, qualname, args, kwargs, repeats):
277
+ try:
278
+ func = load_function(file, qualname)
279
+ times, peak = collect_sample(func, args, kwargs, repeats)
280
+ conn.send(("ok", times, peak))
281
+ except Exception as exc:
282
+ try:
283
+ conn.send(("err", f"{type(exc).__name__}: {exc}"))
284
+ except Exception:
285
+ pass
286
+ finally:
287
+ conn.close()
ocalc/cli.py ADDED
@@ -0,0 +1,86 @@
1
+ """Command line for measuring and annotating functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from ocalc import FunctionNotFound, NeedSample, annotate_file, measure, parse_sample
9
+ from ocalc.comment import format_metrics, format_table
10
+ from ocalc.generate import load_function
11
+
12
+
13
+ def main(argv=None) -> int:
14
+ parser = argparse.ArgumentParser(
15
+ prog="ocalc",
16
+ description="Measure a Python function and estimate its time and space complexity.",
17
+ )
18
+ commands = parser.add_subparsers(dest="command", required=True)
19
+
20
+ measure_cmd = commands.add_parser("measure", help="Print a complexity report.")
21
+ annotate_cmd = commands.add_parser("annotate", help="Write the complexity comment above a function.")
22
+ for command in (measure_cmd, annotate_cmd):
23
+ command.add_argument("file", help="Python file that defines the function.")
24
+ command.add_argument("-f", "--function", required=True, help="Function qualname, such as two_sum.")
25
+ command.add_argument(
26
+ "--sample",
27
+ default="",
28
+ help='Literal arguments, for example "[1, 2, 3], 9".',
29
+ )
30
+ protocol = command.add_argument_group("protocol")
31
+ protocol.add_argument("--budget", type=float, default=None, help="Seconds to spend measuring. Default: 5.")
32
+ protocol.add_argument("--min-n", type=int, default=None, help="Smallest input size. Default: 100, or 8 for a single int.")
33
+ protocol.add_argument("--max-n", type=int, default=None, help="Largest input size. Default: 100000.")
34
+ protocol.add_argument("--measures", type=int, default=None, help="How many sizes to try. Default: 8.")
35
+ protocol.add_argument("--repeats", type=int, default=None, help="Timed calls at each size. Default: 5.")
36
+ commands.add_parser("mcp", help="Serve the measure and annotate tools over stdio.")
37
+
38
+ args = parser.parse_args(argv)
39
+ if args.command == "mcp":
40
+ from ocalc.mcp_server import serve
41
+
42
+ serve()
43
+ return 0
44
+ try:
45
+ return _run(args)
46
+ except NeedSample as exc:
47
+ print(exc, file=sys.stderr)
48
+ return 2
49
+ except FunctionNotFound as exc:
50
+ print(exc, file=sys.stderr)
51
+ return 2
52
+ except (RuntimeError, OSError) as exc:
53
+ print(exc, file=sys.stderr)
54
+ return 1
55
+
56
+
57
+ def _run(args) -> int:
58
+ sample_args, sample_kwargs = parse_sample(args.sample)
59
+ options = dict(
60
+ budget=args.budget,
61
+ min_n=args.min_n,
62
+ max_n=args.max_n,
63
+ measures=args.measures,
64
+ repeats=args.repeats,
65
+ )
66
+ if args.command == "measure":
67
+ func = load_function(args.file, args.function)
68
+ report = measure(
69
+ func,
70
+ *sample_args,
71
+ file=args.file,
72
+ qualname=args.function,
73
+ **sample_kwargs,
74
+ **options,
75
+ )
76
+ print(format_table(report.points))
77
+ print()
78
+ print(format_metrics(report.comment))
79
+ return 0
80
+ report = annotate_file(args.file, args.function, *sample_args, **sample_kwargs, **options)
81
+ print(format_metrics(report.comment))
82
+ return 0
83
+
84
+
85
+ if __name__ == "__main__":
86
+ raise SystemExit(main())