Skills › Scientific Agent Skills › simpy › _common.py
_common.py
478 lines · skills/simpy/scripts/_common.py at 5f2bc03
1#!/usr/bin/env python3
2"""Shared bounded-I/O and statistical helpers for the SimPy CLIs."""
4from __future__ import annotations
6import hashlib
7import json
8import math
9import os
10import stat
11import tempfile
12from collections.abc import Iterable, Mapping
13from pathlib import Path
14from statistics import fmean, stdev
15from typing import Any
18SIMPY_VERSION = "4.1.2"
19PINNED_INSTALL = 'uv pip install "simpy==4.1.2"'
20DEFAULT_SEED = 20_260_723
21MAX_CONFIG_BYTES = 64 * 1024
22MAX_INPUT_BYTES = 32 * 1024 * 1024
23MAX_REPORT_BYTES = 8 * 1024 * 1024
24MAX_SIM_TIME = 1_000_000.0
25MAX_EVENTS = 1_000_000
26MAX_ENTITIES = 100_000
27MAX_REPLICATIONS = 1_000
28MAX_QUEUE_CAPACITY = 100_000
29MAX_TRACE_RECORDS = 1_000_000
32class CliError(ValueError):
33 """An expected validation or command-line error."""
36def load_simpy(*, required: bool = True) -> Any | None:
37 """Import SimPy on demand or raise a concise installation error."""
39 try:
40 import simpy
41 except ModuleNotFoundError as exc:
42 if exc.name != "simpy":
43 raise
44 if not required:
45 return None
46 raise CliError(
47 f"SimPy {SIMPY_VERSION} is required for simulation execution; "
48 f"install it with `{PINNED_INSTALL}`"
49 ) from exc
50 if required and simpy.__version__ != SIMPY_VERSION:
51 raise CliError(
52 f"SimPy {SIMPY_VERSION} is required for pinned scheduler instrumentation; "
53 f"found {simpy.__version__}; install it with `{PINNED_INSTALL}`"
54 )
55 return simpy
58def _reject_constant(value: str) -> None:
59 raise CliError(f"non-standard JSON constant is not allowed: {value}")
62def _unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
63 result: dict[str, Any] = {}
64 for key, value in pairs:
65 if key in result:
66 raise CliError(f"duplicate JSON key is not allowed: {key!r}")
67 result[key] = value
68 return result
71def checked_input_file(
72 value: str | os.PathLike[str],
73 *,
74 suffixes: Iterable[str],
75 max_bytes: int = MAX_INPUT_BYTES,
76) -> Path:
77 """Return a bounded regular local file, rejecting URLs and symlinks."""
79 raw = os.fspath(value)
80 if "://" in raw:
81 raise CliError("network URLs are not accepted; provide a local file")
82 path = Path(raw).expanduser()
83 if path.is_symlink():
84 raise CliError(f"input must not be a symlink: {path}")
85 try:
86 info = path.stat()
87 except OSError as exc:
88 raise CliError(f"cannot access input file {path}: {exc}") from exc
89 if not stat.S_ISREG(info.st_mode):
90 raise CliError(f"input is not a regular file: {path}")
91 if info.st_size > max_bytes:
92 raise CliError(f"input is {info.st_size} bytes; limit is {max_bytes}")
93 allowed = {suffix.lower() for suffix in suffixes}
94 if path.suffix.lower() not in allowed:
95 raise CliError(f"input suffix must be one of: {', '.join(sorted(allowed))}")
96 return path.resolve()
99def checked_output_file(
100 value: str | os.PathLike[str],
101 *,
102 suffixes: Iterable[str],
103 force: bool = False,
104) -> Path:
105 """Validate an explicit local output without following symlinks."""
107 raw = os.fspath(value)
108 if "://" in raw:
109 raise CliError("network URLs are not accepted as output paths")
110 path = Path(raw).expanduser()
111 if path.name in {"", ".", ".."}:
112 raise CliError("output must name a file")
113 if path.is_symlink():
114 raise CliError(f"output must not be a symlink: {path}")
115 allowed = {suffix.lower() for suffix in suffixes}
116 if path.suffix.lower() not in allowed:
117 raise CliError(f"output suffix must be one of: {', '.join(sorted(allowed))}")
118 parent = path.parent
119 if not parent.exists() or not parent.is_dir() or parent.is_symlink():
120 raise CliError(f"output parent must be an existing regular directory: {parent}")
121 if path.exists():
122 if not path.is_file():
123 raise CliError(f"output exists and is not a regular file: {path}")
124 if not force:
125 raise CliError(f"refusing to overwrite existing output: {path}")
126 return parent.resolve() / path.name
129def atomic_write_bytes(path: Path, payload: bytes, *, force: bool = False) -> None:
130 """Write bytes through a private same-directory temporary file."""
132 destination = checked_output_file(path, suffixes={path.suffix}, force=force)
133 descriptor, temporary_name = tempfile.mkstemp(
134 prefix=f".{destination.name}.", suffix=".tmp", dir=destination.parent
135 )
136 temporary = Path(temporary_name)
137 try:
138 with os.fdopen(descriptor, "wb") as handle:
139 handle.write(payload)
140 handle.flush()
141 os.fsync(handle.fileno())
142 os.chmod(temporary, 0o600)
143 if destination.exists() and not force:
144 raise CliError(f"refusing to overwrite existing output: {destination}")
145 os.replace(temporary, destination)
146 finally:
147 temporary.unlink(missing_ok=True)
150def strict_json_bytes(document: Any) -> bytes:
151 """Serialize deterministic RFC-compatible JSON with a size cap."""
153 try:
154 payload = (
155 json.dumps(
156 document,
157 indent=2,
158 sort_keys=True,
159 ensure_ascii=False,
160 allow_nan=False,
161 )
162 + "\n"
163 ).encode("utf-8")
164 except (TypeError, ValueError) as exc:
165 raise CliError(f"report is not strict JSON: {exc}") from exc
166 if len(payload) > MAX_REPORT_BYTES:
167 raise CliError(
168 f"report is {len(payload)} bytes; limit is {MAX_REPORT_BYTES} bytes"
169 )
170 return payload
173def emit_json(
174 document: Any,
175 *,
176 output: str | os.PathLike[str] | None = None,
177 force: bool = False,
178) -> None:
179 """Print deterministic JSON or write it atomically."""
181 payload = strict_json_bytes(document)
182 if output is None:
183 print(payload.decode("utf-8"), end="")
184 return
185 destination = checked_output_file(output, suffixes={".json"}, force=force)
186 atomic_write_bytes(destination, payload, force=force)
189def emit_text(
190 text: str,
191 *,
192 output: str | os.PathLike[str],
193 suffixes: Iterable[str],
194 force: bool = False,
195) -> Path:
196 """Write bounded text atomically and return its destination."""
198 payload = text.encode("utf-8")
199 if len(payload) > MAX_REPORT_BYTES:
200 raise CliError(
201 f"output is {len(payload)} bytes; limit is {MAX_REPORT_BYTES} bytes"
202 )
203 destination = checked_output_file(output, suffixes=suffixes, force=force)
204 atomic_write_bytes(destination, payload, force=force)
205 return destination
208def load_json_object(value: str | os.PathLike[str]) -> dict[str, Any]:
209 """Load a bounded strict JSON object from a local file."""
211 path = checked_input_file(
212 value, suffixes={".json"}, max_bytes=MAX_CONFIG_BYTES
213 )
214 try:
215 with path.open("r", encoding="utf-8") as handle:
216 document = json.load(
217 handle,
218 parse_constant=_reject_constant,
219 object_pairs_hook=_unique_object,
220 )
221 except CliError:
222 raise
223 except (OSError, UnicodeError, json.JSONDecodeError) as exc:
224 raise CliError(f"cannot read valid JSON from {path.name}: {exc}") from exc
225 if not isinstance(document, dict):
226 raise CliError("configuration root must be a JSON object")
227 return document
230def parse_json_line(line: str, *, line_number: int) -> dict[str, Any]:
231 """Parse one strict JSON object from a JSON Lines input."""
233 try:
234 value = json.loads(
235 line,
236 parse_constant=_reject_constant,
237 object_pairs_hook=_unique_object,
238 )
239 except CliError:
240 raise
241 except json.JSONDecodeError as exc:
242 raise CliError(f"invalid JSON on line {line_number}: {exc.msg}") from exc
243 if not isinstance(value, dict):
244 raise CliError(f"line {line_number} must contain a JSON object")
245 return value
248def validate_keys(
249 value: Mapping[str, Any],
250 *,
251 allowed: Iterable[str],
252 required: Iterable[str] = (),
253 context: str = "configuration",
254) -> None:
255 """Reject unknown keys and report required keys."""
257 allowed_set = set(allowed)
258 required_set = set(required)
259 unknown = sorted(set(value) - allowed_set)
260 missing = sorted(required_set - set(value))
261 if unknown:
262 raise CliError(f"{context} has unknown keys: {', '.join(unknown)}")
263 if missing:
264 raise CliError(f"{context} is missing keys: {', '.join(missing)}")
267def integer(
268 value: Any,
269 *,
270 name: str,
271 minimum: int,
272 maximum: int,
273) -> int:
274 """Validate a bounded JSON integer, rejecting booleans."""
276 if isinstance(value, bool) or not isinstance(value, int):
277 raise CliError(f"{name} must be an integer")
278 if not minimum <= value <= maximum:
279 raise CliError(f"{name} must be from {minimum} through {maximum}")
280 return value
283def finite_number(
284 value: Any,
285 *,
286 name: str,
287 minimum: float | None = None,
288 maximum: float | None = None,
289 minimum_inclusive: bool = True,
290) -> float:
291 """Validate a finite JSON number with optional bounds."""
293 if isinstance(value, bool) or not isinstance(value, (int, float)):
294 raise CliError(f"{name} must be a number")
295 result = float(value)
296 if not math.isfinite(result):
297 raise CliError(f"{name} must be finite")
298 if minimum is not None:
299 valid = result >= minimum if minimum_inclusive else result > minimum
300 if not valid:
301 qualifier = "at least" if minimum_inclusive else "greater than"
302 raise CliError(f"{name} must be {qualifier} {minimum}")
303 if maximum is not None and result > maximum:
304 raise CliError(f"{name} must be no greater than {maximum}")
305 return result
308def derive_seed(base_seed: int, replication: int, stream: str) -> int:
309 """Derive a stable 64-bit seed for one replication and random stream."""
311 integer(base_seed, name="base_seed", minimum=0, maximum=2**63 - 1)
312 integer(
313 replication,
314 name="replication",
315 minimum=0,
316 maximum=MAX_REPLICATIONS - 1,
317 )
318 if not stream or len(stream) > 64 or not stream.isascii():
319 raise CliError("stream label must be 1-64 ASCII characters")
320 payload = f"simpy-skill-v1|{base_seed}|{replication}|{stream}".encode("ascii")
321 return int.from_bytes(hashlib.blake2b(payload, digest_size=8).digest(), "big")
324def _beta_continued_fraction(a: float, b: float, x: float) -> float:
325 """Evaluate the continued fraction used by regularized incomplete beta."""
327 maximum_iterations = 250
328 epsilon = 3.0e-14
329 minimum = 1.0e-300
330 qab = a + b
331 qap = a + 1.0
332 qam = a - 1.0
333 c = 1.0
334 d = 1.0 - qab * x / qap
335 if abs(d) < minimum:
336 d = minimum
337 d = 1.0 / d
338 result = d
339 for iteration in range(1, maximum_iterations + 1):
340 doubled = 2 * iteration
341 coefficient = (
342 iteration
343 * (b - iteration)
344 * x
345 / ((qam + doubled) * (a + doubled))
346 )
347 d = 1.0 + coefficient * d
348 if abs(d) < minimum:
349 d = minimum
350 c = 1.0 + coefficient / c
351 if abs(c) < minimum:
352 c = minimum
353 d = 1.0 / d
354 result *= d * c
355 coefficient = -(
356 (a + iteration)
357 * (qab + iteration)
358 * x
359 / ((a + doubled) * (qap + doubled))
360 )
361 d = 1.0 + coefficient * d
362 if abs(d) < minimum:
363 d = minimum
364 c = 1.0 + coefficient / c
365 if abs(c) < minimum:
366 c = minimum
367 d = 1.0 / d
368 delta = d * c
369 result *= delta
370 if abs(delta - 1.0) <= epsilon:
371 return result
372 raise CliError("Student-t calculation did not converge")
375def _regularized_incomplete_beta(a: float, b: float, x: float) -> float:
376 if not 0.0 <= x <= 1.0:
377 raise CliError("internal beta argument is outside [0, 1]")
378 if x in {0.0, 1.0}:
379 return x
380 log_term = (
381 math.lgamma(a + b)
382 - math.lgamma(a)
383 - math.lgamma(b)
384 + a * math.log(x)
385 + b * math.log1p(-x)
386 )
387 term = math.exp(log_term)
388 if x < (a + 1.0) / (a + b + 2.0):
389 return term * _beta_continued_fraction(a, b, x) / a
390 return 1.0 - term * _beta_continued_fraction(b, a, 1.0 - x) / b
393def student_t_cdf(value: float, degrees_of_freedom: int) -> float:
394 """Return the Student-t CDF using the incomplete-beta identity."""
396 integer(
397 degrees_of_freedom,
398 name="degrees_of_freedom",
399 minimum=1,
400 maximum=MAX_REPLICATIONS - 1,
401 )
402 if not math.isfinite(value):
403 return 0.0 if value < 0 else 1.0
404 if value == 0:
405 return 0.5
406 ratio = degrees_of_freedom / (degrees_of_freedom + value * value)
407 tail = 0.5 * _regularized_incomplete_beta(
408 degrees_of_freedom / 2.0, 0.5, ratio
409 )
410 return 1.0 - tail if value > 0 else tail
413def student_t_quantile(probability: float, degrees_of_freedom: int) -> float:
414 """Numerically invert the Student-t CDF for 0.5 < p < 1."""
416 if not 0.5 < probability < 1.0:
417 raise CliError("Student-t probability must be between 0.5 and 1")
418 integer(
419 degrees_of_freedom,
420 name="degrees_of_freedom",
421 minimum=1,
422 maximum=MAX_REPLICATIONS - 1,
423 )
424 lower = 0.0
425 upper = 1.0
426 while student_t_cdf(upper, degrees_of_freedom) < probability:
427 upper *= 2.0
428 if upper > 1_000_000:
429 raise CliError("Student-t quantile could not be bracketed")
430 for _ in range(90):
431 midpoint = (lower + upper) / 2.0
432 if student_t_cdf(midpoint, degrees_of_freedom) < probability:
433 lower = midpoint
434 else:
435 upper = midpoint
436 return (lower + upper) / 2.0
439def mean_confidence_interval(
440 values: Iterable[float], *, confidence: float = 0.95
441) -> dict[str, float | int]:
442 """Return a two-sided Student-t interval across independent estimates."""
444 observations = [float(value) for value in values]
445 if len(observations) < 2:
446 raise CliError(
447 "a confidence interval requires at least two independent replications"
448 )
449 if len(observations) > MAX_REPLICATIONS:
450 raise CliError(f"at most {MAX_REPLICATIONS} replications are allowed")
451 if any(not math.isfinite(value) for value in observations):
452 raise CliError("confidence-interval observations must be finite")
453 confidence = finite_number(
454 confidence,
455 name="confidence",
456 minimum=0.50,
457 maximum=0.999,
458 minimum_inclusive=False,
459 )
460 count = len(observations)
461 estimate = fmean(observations)
462 standard_deviation = stdev(observations)
463 standard_error = standard_deviation / math.sqrt(count)
464 critical = student_t_quantile(
465 0.5 + confidence / 2.0, degrees_of_freedom=count - 1
466 )
467 half_width = critical * standard_error
468 return {
469 "confidence": confidence,
470 "degrees_of_freedom": count - 1,
471 "half_width": half_width,
472 "lower": estimate - half_width,
473 "mean": estimate,
474 "replications": count,
475 "standard_deviation": standard_deviation,
476 "standard_error": standard_error,
477 "upper": estimate + half_width,
478 }