simulsi 0.2.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.
Files changed (71) hide show
  1. simulsi/__init__.py +70 -0
  2. simulsi/__main__.py +5 -0
  3. simulsi/_version.py +1 -0
  4. simulsi/analysis/__init__.py +24 -0
  5. simulsi/analysis/comparison.py +154 -0
  6. simulsi/analysis/report.py +46 -0
  7. simulsi/analysis/sensitivity.py +387 -0
  8. simulsi/benchmarks.py +128 -0
  9. simulsi/cli/__init__.py +3 -0
  10. simulsi/cli/main.py +486 -0
  11. simulsi/cli/templates.py +96 -0
  12. simulsi/config/__init__.py +19 -0
  13. simulsi/config/schema.py +263 -0
  14. simulsi/core/__init__.py +13 -0
  15. simulsi/core/checkpoint.py +159 -0
  16. simulsi/core/clock.py +87 -0
  17. simulsi/core/model.py +441 -0
  18. simulsi/core/simulation.py +670 -0
  19. simulsi/core/trace.py +79 -0
  20. simulsi/cost/__init__.py +3 -0
  21. simulsi/cost/model.py +193 -0
  22. simulsi/entities/__init__.py +3 -0
  23. simulsi/entities/entity.py +120 -0
  24. simulsi/errors.py +50 -0
  25. simulsi/events/__init__.py +3 -0
  26. simulsi/events/event.py +221 -0
  27. simulsi/experiments/__init__.py +17 -0
  28. simulsi/experiments/experiment.py +548 -0
  29. simulsi/experiments/montecarlo.py +261 -0
  30. simulsi/experiments/provenance.py +61 -0
  31. simulsi/introspection/__init__.py +21 -0
  32. simulsi/introspection/graph.py +210 -0
  33. simulsi/metrics/__init__.py +3 -0
  34. simulsi/metrics/collectors.py +348 -0
  35. simulsi/models/__init__.py +3 -0
  36. simulsi/models/queueing.py +73 -0
  37. simulsi/optimization/__init__.py +3 -0
  38. simulsi/optimization/objective.py +131 -0
  39. simulsi/processes/__init__.py +31 -0
  40. simulsi/processes/disruption.py +165 -0
  41. simulsi/processes/process.py +346 -0
  42. simulsi/py.typed +0 -0
  43. simulsi/queues/__init__.py +4 -0
  44. simulsi/queues/discipline.py +99 -0
  45. simulsi/queues/queue.py +256 -0
  46. simulsi/randomness/__init__.py +47 -0
  47. simulsi/randomness/distributions.py +449 -0
  48. simulsi/randomness/stream.py +117 -0
  49. simulsi/resources/__init__.py +3 -0
  50. simulsi/resources/resource.py +519 -0
  51. simulsi/scenarios/__init__.py +10 -0
  52. simulsi/scenarios/scenario.py +132 -0
  53. simulsi/serialization/__init__.py +23 -0
  54. simulsi/serialization/io.py +128 -0
  55. simulsi/statistics/__init__.py +37 -0
  56. simulsi/statistics/core.py +418 -0
  57. simulsi/validation/__init__.py +3 -0
  58. simulsi/validation/validate.py +168 -0
  59. simulsi/visualization/__init__.py +27 -0
  60. simulsi/visualization/plots.py +423 -0
  61. simulsi/web/__init__.py +1 -0
  62. simulsi/web/server.py +338 -0
  63. simulsi/web/static/assets/index-CNMcVqxw.css +2 -0
  64. simulsi/web/static/assets/index-DDtrYZla.js +9 -0
  65. simulsi/web/static/index.html +13 -0
  66. simulsi-0.2.0.dist-info/METADATA +206 -0
  67. simulsi-0.2.0.dist-info/RECORD +71 -0
  68. simulsi-0.2.0.dist-info/WHEEL +4 -0
  69. simulsi-0.2.0.dist-info/entry_points.txt +2 -0
  70. simulsi-0.2.0.dist-info/licenses/LICENSE +21 -0
  71. simulsi-0.2.0.dist-info/licenses/NOTICE +11 -0
simulsi/benchmarks.py ADDED
@@ -0,0 +1,128 @@
1
+ """Reproducible micro- and macro-benchmarks (used by ``simulsi benchmark``).
2
+
3
+ Numbers depend on the machine and Python version; record them with the
4
+ environment (see :func:`simulsi.experiments.provenance.environment`).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ import tracemalloc
11
+ from collections.abc import Callable
12
+ from dataclasses import asdict, dataclass
13
+ from typing import Any
14
+
15
+ from simulsi.core.simulation import Simulation
16
+ from simulsi.experiments.experiment import Experiment
17
+ from simulsi.models.queueing import mmc
18
+ from simulsi.randomness.distributions import Exponential
19
+
20
+
21
+ @dataclass
22
+ class BenchmarkResult:
23
+ name: str
24
+ size: int
25
+ events: int
26
+ seconds: float
27
+ peak_memory_mb: float | None = None
28
+
29
+ @property
30
+ def events_per_second(self) -> float:
31
+ return self.events / self.seconds if self.seconds > 0 else float("nan")
32
+
33
+ def to_dict(self) -> dict[str, Any]:
34
+ d = asdict(self)
35
+ d["events_per_second"] = self.events_per_second
36
+ return d
37
+
38
+
39
+ def _timer_chain(n_events: int) -> Simulation:
40
+ """Pure engine overhead: ``n_events`` callback events, 100 interleaved chains."""
41
+ sim = Simulation(seed=1, keep_values=False, record_series=False)
42
+ chains = 100
43
+ per_chain = n_events // chains
44
+ stream = sim.stream("delays")
45
+
46
+ def make(remaining: list[int]) -> Callable[[], None]:
47
+ def tick() -> None:
48
+ remaining[0] -= 1
49
+ if remaining[0] > 0:
50
+ sim.call_at(tick, delay=stream.exponential(1.0))
51
+
52
+ return tick
53
+
54
+ for _ in range(chains):
55
+ sim.call_at(make([per_chain]), delay=stream.exponential(1.0))
56
+ return sim
57
+
58
+
59
+ def _process_queue(n_customers: int) -> Simulation:
60
+ """Process-based M/M/2 queue: generator processes, resource contention, metrics."""
61
+ sim = Simulation(seed=1, keep_values=False, record_series=False, keep_entity_history=False)
62
+ server = sim.resource("server", 2)
63
+ inter, svc = Exponential(rate=1.8), Exponential(rate=1.0)
64
+ a, s = sim.stream("a"), sim.stream("s")
65
+
66
+ def customer(sim: Simulation) -> Any:
67
+ yield from sim.use(server, svc.sample(s))
68
+
69
+ def source(sim: Simulation) -> Any:
70
+ for _ in range(n_customers):
71
+ yield inter.sample(a)
72
+ sim.process(customer(sim))
73
+
74
+ sim.process(source(sim))
75
+ return sim
76
+
77
+
78
+ SCENARIOS: dict[str, Callable[[int], Simulation]] = {
79
+ "events": _timer_chain,
80
+ "processes": _process_queue,
81
+ }
82
+
83
+
84
+ def run_engine_benchmark(name: str, size: int, *, memory: bool = False) -> BenchmarkResult:
85
+ sim = SCENARIOS[name](size)
86
+ if memory:
87
+ tracemalloc.start()
88
+ t0 = time.perf_counter()
89
+ sim.run()
90
+ dt = time.perf_counter() - t0
91
+ peak = None
92
+ if memory:
93
+ _, peak_b = tracemalloc.get_traced_memory()
94
+ tracemalloc.stop()
95
+ peak = peak_b / 1e6
96
+ return BenchmarkResult(name, size, sim.events_processed, dt, peak)
97
+
98
+
99
+ def run_experiment_benchmark(
100
+ replications: int, workers: int, duration: float = 20_000.0
101
+ ) -> BenchmarkResult:
102
+ m = mmc.with_options(duration=duration, warmup=0.0)
103
+ exp = Experiment(m, replications=replications, seed=0, workers=workers)
104
+ t0 = time.perf_counter()
105
+ res = exp.run()
106
+ dt = time.perf_counter() - t0
107
+ return BenchmarkResult(
108
+ f"experiment(workers={workers})", replications, sum(r.events for r in res.records), dt
109
+ )
110
+
111
+
112
+ def default_suite(
113
+ sizes: tuple[int, ...] = (10_000, 100_000, 1_000_000),
114
+ *,
115
+ memory: bool = True,
116
+ workers: tuple[int, ...] = (1, 4),
117
+ replications: int = 16,
118
+ ) -> list[BenchmarkResult]:
119
+ out = []
120
+ for name in SCENARIOS:
121
+ for n in sizes:
122
+ out.append(run_engine_benchmark(name, n))
123
+ if memory and n <= 100_000:
124
+ mem = run_engine_benchmark(name, n, memory=True)
125
+ out[-1].peak_memory_mb = mem.peak_memory_mb
126
+ for w in workers:
127
+ out.append(run_experiment_benchmark(replications, w))
128
+ return out
@@ -0,0 +1,3 @@
1
+ from simulsi.cli.main import build_parser, main
2
+
3
+ __all__ = ["build_parser", "main"]
simulsi/cli/main.py ADDED
@@ -0,0 +1,486 @@
1
+ """Command-line interface: ``simulsi <command>``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import math
8
+ import sys
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import yaml
14
+
15
+ from simulsi._version import __version__
16
+ from simulsi.errors import ConfigError, SimulsiError
17
+
18
+ EXIT_OK, EXIT_ERROR, EXIT_INVALID = 0, 1, 2
19
+
20
+
21
+ def _print(text: str = "") -> None:
22
+ sys.stdout.write(text + "\n")
23
+
24
+
25
+ def _err(text: str) -> None:
26
+ sys.stderr.write(f"error: {text}\n")
27
+
28
+
29
+ def _parse_params(items: Sequence[str] | None) -> dict[str, Any]:
30
+ """``key=value`` pairs; values are parsed as YAML scalars (safe) so numbers stay numbers."""
31
+ out: dict[str, Any] = {}
32
+ for item in items or []:
33
+ key, sep, raw = item.partition("=")
34
+ if not sep or not key.strip():
35
+ raise ConfigError(f"--param expects key=value, got {item!r}")
36
+ try:
37
+ out[key.strip()] = yaml.safe_load(raw)
38
+ except yaml.YAMLError as exc:
39
+ raise ConfigError(f"cannot parse value for {key!r}: {exc}") from exc
40
+ return out
41
+
42
+
43
+ def _metrics_table(metrics: dict[str, float], only: Sequence[str] | None = None) -> str:
44
+ from simulsi.analysis.report import format_table
45
+
46
+ keys = [k for k in (only or sorted(metrics)) if k in metrics]
47
+ return format_table([{"metric": k, "value": metrics[k]} for k in keys])
48
+
49
+
50
+ def _is_config(target: str) -> bool:
51
+ return target.endswith((".yaml", ".yml"))
52
+
53
+
54
+ # -- commands ----------------------------------------------------------------------
55
+
56
+
57
+ def cmd_init(args: argparse.Namespace) -> int:
58
+ from simulsi.cli import templates
59
+
60
+ d = Path(args.directory)
61
+ d.mkdir(parents=True, exist_ok=True)
62
+ files = {
63
+ "model.py": templates.MODEL_PY,
64
+ "experiment.yaml": templates.EXPERIMENT_YAML,
65
+ "README.md": templates.README_MD.format(name=d.resolve().name),
66
+ }
67
+ existing = [f for f in files if (d / f).exists()]
68
+ if existing and not args.force:
69
+ _err(f"{', '.join(existing)} already exist in {d}; use --force to overwrite")
70
+ return EXIT_ERROR
71
+ for name, content in files.items():
72
+ (d / name).write_text(content, encoding="utf-8")
73
+ _print(f"created SimulSI project in {d}/ ({', '.join(files)})")
74
+ _print(
75
+ f"next: cd {d} && simulsi validate experiment.yaml && simulsi experiment experiment.yaml"
76
+ )
77
+ return EXIT_OK
78
+
79
+
80
+ def cmd_validate(args: argparse.Namespace) -> int:
81
+ from simulsi.config.schema import load_experiment, resolve_model
82
+ from simulsi.validation import validate_model
83
+
84
+ target: str = args.target
85
+ try:
86
+ if _is_config(target):
87
+ exp, _cfg = load_experiment(target, allow_outside=args.allow_outside)
88
+ _print(
89
+ f"configuration OK: {len(exp.scenarios)} scenario(s) x {exp.replications} replication(s)"
90
+ )
91
+ reports = [
92
+ (s.name, validate_model(exp.model, s.parameters, smoke_duration=args.smoke))
93
+ for s in exp.scenarios
94
+ ]
95
+ else:
96
+ m = resolve_model(target)
97
+ reports = [
98
+ ("model", validate_model(m, _parse_params(args.param), smoke_duration=args.smoke))
99
+ ]
100
+ except (ConfigError, SimulsiError) as exc:
101
+ _err(str(exc))
102
+ return EXIT_INVALID
103
+ ok = True
104
+ for name, rep in reports:
105
+ ok = ok and rep.ok
106
+ _print(f"[{name}] {rep.format()}")
107
+ return EXIT_OK if ok else EXIT_INVALID
108
+
109
+
110
+ def cmd_run(args: argparse.Namespace) -> int:
111
+ from simulsi.config.schema import load_config, resolve_model
112
+
113
+ params = _parse_params(args.param)
114
+ target: str = args.target
115
+ if _is_config(target):
116
+ cfg = load_config(target)
117
+ m = resolve_model(cfg.model, base_dir=Path(target).parent, allow_outside=args.allow_outside)
118
+ params = {**cfg.parameters, **params}
119
+ seed = args.seed if args.seed is not None else cfg.simulation.seed
120
+ duration = args.duration or cfg.simulation.duration
121
+ else:
122
+ m = resolve_model(target)
123
+ seed = args.seed if args.seed is not None else 0
124
+ duration = args.duration
125
+ result = m.simulate(params, seed=seed, duration=duration, trace=bool(args.trace))
126
+ if args.trace and result.log is not None:
127
+ p = result.log.export(args.trace)
128
+ sys.stderr.write(f"wrote {len(result.log)} log records to {p}\n")
129
+ if args.json:
130
+ from simulsi.serialization.io import to_jsonable
131
+
132
+ _print(json.dumps(to_jsonable(result.to_dict()), indent=2, allow_nan=False))
133
+ return EXIT_OK
134
+ _print(
135
+ f"{m.name}: seed={result.seed} t=[{result.start_time:g}, {result.end_time:g}] "
136
+ f"events={result.events_processed} wall={result.wall_time:.3f}s"
137
+ )
138
+ _print(_metrics_table(result.metrics, args.metric))
139
+ for w in result.warnings:
140
+ _print(f"warning: {w}")
141
+ return EXIT_OK
142
+
143
+
144
+ def cmd_experiment(args: argparse.Namespace) -> int:
145
+ from simulsi.analysis.comparison import compare
146
+ from simulsi.config.schema import load_experiment
147
+ from simulsi.cost.model import CostModel
148
+
149
+ exp, cfg = load_experiment(args.config, allow_outside=args.allow_outside)
150
+ if args.replications:
151
+ exp.replications = args.replications
152
+ if args.workers:
153
+ exp.workers = args.workers
154
+ if args.seed is not None:
155
+ exp.seed = args.seed
156
+ total = len(exp.scenarios) * exp.replications
157
+ _print(
158
+ f"experiment {exp.name!r}: model={exp.model.name} scenarios={len(exp.scenarios)} "
159
+ f"replications={exp.replications} workers={exp.workers}"
160
+ )
161
+
162
+ def progress(done: int, n: int) -> None:
163
+ if not args.quiet and (done == n or done % max(1, n // 20) == 0):
164
+ sys.stderr.write(f"\r {done}/{n} runs")
165
+ if done == n:
166
+ sys.stderr.write("\n")
167
+
168
+ result = exp.run(checkpoint=args.checkpoint, progress=progress)
169
+ if cfg.cost:
170
+ result.derive(CostModel.from_dict(cfg.cost).metrics)
171
+ metrics = cfg.metrics
172
+ if cfg.cost and metrics is not None:
173
+ metrics = [*metrics, "cost.total", "cost.profit"]
174
+ _print(result.format_summary(metrics, confidence=cfg.experiment.confidence))
175
+ if len(result.scenarios) > 1 and cfg.baseline in result.scenarios:
176
+ _print("")
177
+ _print(
178
+ f"Comparison with {cfg.baseline!r} ({cfg.experiment.confidence:.0%} CI; * = CI excludes 0):"
179
+ )
180
+ _print(
181
+ compare(
182
+ result, cfg.baseline, metrics=metrics, confidence=cfg.experiment.confidence
183
+ ).format()
184
+ )
185
+ if result.errors:
186
+ _print(
187
+ f"\n{len(result.errors)} of {total} runs failed; first error: {result.errors[0].error}"
188
+ )
189
+ out = args.output or cfg.output.directory
190
+ if out:
191
+ if args.output:
192
+ directory = Path(args.output)
193
+ else:
194
+ from simulsi.serialization.io import safe_child
195
+
196
+ base = Path(args.config).resolve().parent
197
+ try:
198
+ directory = safe_child(base, out) if not args.allow_outside else base / out
199
+ except ValueError as exc:
200
+ raise ConfigError(
201
+ f"output.directory {out!r} is outside the configuration directory; "
202
+ "use -o to choose a location explicitly"
203
+ ) from exc
204
+ result.save(directory, formats=cfg.output.formats)
205
+ _print(f"\nsaved results to {directory}/")
206
+ return EXIT_OK if not result.errors else EXIT_ERROR
207
+
208
+
209
+ def _load_result(path: str) -> Any:
210
+ from simulsi.experiments.experiment import ExperimentResult
211
+
212
+ return ExperimentResult.load(path)
213
+
214
+
215
+ def cmd_analyze(args: argparse.Namespace) -> int:
216
+ from simulsi.analysis.comparison import compare
217
+ from simulsi.analysis.report import format_table
218
+
219
+ result = _load_result(args.results)
220
+ md = result.metadata
221
+ metrics = args.metric or None
222
+ if args.json:
223
+ payload: dict[str, Any] = {
224
+ "metadata": md.to_dict(),
225
+ "summary": result.summary(metrics, confidence=args.confidence),
226
+ }
227
+ if args.baseline in result.scenarios and len(result.scenarios) > 1:
228
+ payload["comparison"] = compare(
229
+ result, args.baseline, metrics=metrics, confidence=args.confidence
230
+ ).to_dicts()
231
+ from simulsi.serialization.io import to_jsonable
232
+
233
+ _print(json.dumps(to_jsonable(payload), indent=2, allow_nan=False))
234
+ return EXIT_OK
235
+ _print(
236
+ f"{md.experiment_id} model={md.model_name} v{md.model_version} seed={md.seed} "
237
+ f"replications={md.replications} created={md.timestamp}"
238
+ )
239
+ if md.git and md.git.get("commit"):
240
+ _print(f"git {md.git['commit'][:12]}{' (dirty)' if md.git.get('dirty') else ''}")
241
+ _print("")
242
+ _print(result.format_summary(metrics, confidence=args.confidence))
243
+ if args.baseline in result.scenarios and len(result.scenarios) > 1:
244
+ _print("")
245
+ _print(compare(result, args.baseline, metrics=metrics, confidence=args.confidence).format())
246
+ if args.precision:
247
+ rows = []
248
+ for sc in result.scenarios:
249
+ for m in metrics or result.metric_names:
250
+ adv = result.replication_advice(
251
+ m, sc, relative_precision=args.precision, confidence=args.confidence
252
+ )
253
+ rows.append(
254
+ {
255
+ "scenario": sc,
256
+ "metric": m,
257
+ "n": adv.n,
258
+ "rel_half_width": adv.relative_half_width,
259
+ "required_n": adv.required_n if adv.required_n is not None else math.nan,
260
+ "enough": "yes" if adv.sufficient else "no",
261
+ "note": adv.note,
262
+ }
263
+ )
264
+ _print("")
265
+ _print(f"Replication analysis (target relative half-width {args.precision:g}):")
266
+ _print(format_table(rows))
267
+ return EXIT_OK
268
+
269
+
270
+ def cmd_benchmark(args: argparse.Namespace) -> int:
271
+ from simulsi.analysis.report import format_table
272
+ from simulsi.benchmarks import default_suite
273
+ from simulsi.experiments.provenance import environment
274
+
275
+ sizes = tuple(args.sizes) if args.sizes else (10_000, 100_000, 1_000_000)
276
+ results = default_suite(
277
+ sizes,
278
+ memory=not args.no_memory,
279
+ workers=tuple(args.workers),
280
+ replications=args.replications,
281
+ )
282
+ rows = [r.to_dict() for r in results]
283
+ if args.json:
284
+ from simulsi.serialization.io import to_jsonable
285
+
286
+ _print(json.dumps(to_jsonable({"environment": environment(), "results": rows}), indent=2))
287
+ return EXIT_OK
288
+ _print(
289
+ format_table(
290
+ rows, ["name", "size", "events", "seconds", "events_per_second", "peak_memory_mb"]
291
+ )
292
+ )
293
+ env = environment()
294
+ _print(f"\npython {env['python']} on {env['platform']} ({env['cpu_count']} CPUs)")
295
+ return EXIT_OK
296
+
297
+
298
+ def cmd_visualize(args: argparse.Namespace) -> int:
299
+ from simulsi.visualization import plots
300
+
301
+ out = Path(args.out)
302
+ out.mkdir(parents=True, exist_ok=True)
303
+ ext = "html" if args.backend == "plotly" else args.format
304
+ written: list[Path] = []
305
+
306
+ def save(fig: Any, name: str) -> None:
307
+ p = out / f"{name}.{ext}"
308
+ plots.save_figure(fig, str(p))
309
+ written.append(p)
310
+
311
+ if args.target.endswith(".py") or args.target.startswith("builtin:"):
312
+ from simulsi.config.schema import resolve_model
313
+
314
+ m = resolve_model(args.target)
315
+ res = m.simulate(_parse_params(args.param), seed=args.seed, trace=True, record_series=True)
316
+ save(plots.plot_queue_length(res, backend=args.backend), "queue_length")
317
+ save(plots.plot_utilization(res, backend=args.backend), "utilization")
318
+ if res.log is not None and len(res.log):
319
+ save(plots.plot_timeline(res.log, backend=args.backend), "timeline")
320
+ save(
321
+ plots.plot_entity_trajectories(
322
+ res.log, backend=args.backend, end_time=res.end_time
323
+ ),
324
+ "trajectories",
325
+ )
326
+ else:
327
+ from simulsi.analysis.comparison import compare
328
+
329
+ result = _load_result(args.target)
330
+ metrics = args.metric or result.metric_names[:3]
331
+ for m in metrics:
332
+ safe = m.replace("/", "_")
333
+ for sc in result.scenarios:
334
+ save(
335
+ plots.plot_distribution(
336
+ result.values(m, sc), title=f"{m} - {sc}", label=m, backend=args.backend
337
+ ),
338
+ f"dist_{safe}_{sc}",
339
+ )
340
+ save(
341
+ plots.plot_convergence(
342
+ result.values(m, result.scenarios[0]),
343
+ title=f"Convergence: {m}",
344
+ backend=args.backend,
345
+ ),
346
+ f"convergence_{safe}",
347
+ )
348
+ if args.baseline in result.scenarios and len(result.scenarios) > 1:
349
+ cmp = compare(result, args.baseline, metrics=metrics)
350
+ save(plots.plot_comparison(cmp, relative=True, backend=args.backend), "comparison")
351
+ for p in written:
352
+ _print(f"wrote {p}")
353
+ return EXIT_OK
354
+
355
+
356
+ def cmd_ui(args: argparse.Namespace) -> int:
357
+ from simulsi.config.schema import resolve_model
358
+ from simulsi.web.server import serve
359
+
360
+ models = {ref: resolve_model(ref) for ref in args.model or []}
361
+ return serve(
362
+ host=args.host,
363
+ port=args.port,
364
+ results=[Path(p) for p in args.results],
365
+ models=models,
366
+ open_browser=not args.no_browser,
367
+ )
368
+
369
+
370
+ # -- parser ----------------------------------------------------------------------------
371
+
372
+
373
+ def build_parser() -> argparse.ArgumentParser:
374
+ p = argparse.ArgumentParser(
375
+ prog="simulsi", description="SimulSI - model the system, simulate the future."
376
+ )
377
+ p.add_argument("--version", action="version", version=f"simulsi {__version__}")
378
+ sub = p.add_subparsers(dest="command", required=True)
379
+
380
+ s = sub.add_parser("init", help="create a starter project (model.py + experiment.yaml)")
381
+ s.add_argument("directory", nargs="?", default=".")
382
+ s.add_argument("--force", action="store_true", help="overwrite existing files")
383
+ s.set_defaults(func=cmd_init)
384
+
385
+ model_help = (
386
+ "model reference: file.py[:attr], package.module:attr, builtin:mmc, or a config .yaml"
387
+ )
388
+ allow = {
389
+ "action": "store_true",
390
+ "help": "allow config files to reference model files outside their directory",
391
+ }
392
+
393
+ s = sub.add_parser("validate", help="validate a configuration or model (schema + smoke run)")
394
+ s.add_argument("target", help=model_help)
395
+ s.add_argument("--param", "-p", action="append", metavar="KEY=VALUE")
396
+ s.add_argument(
397
+ "--smoke", type=float, default=None, help="smoke-run length (default: 10%% of duration)"
398
+ )
399
+ s.add_argument("--allow-outside", **allow) # type: ignore[arg-type]
400
+ s.set_defaults(func=cmd_validate)
401
+
402
+ s = sub.add_parser("run", help="run one replication and print its metrics")
403
+ s.add_argument("target", help=model_help)
404
+ s.add_argument("--param", "-p", action="append", metavar="KEY=VALUE")
405
+ s.add_argument("--seed", type=int, default=None)
406
+ s.add_argument("--duration", type=float, default=None)
407
+ s.add_argument("--metric", "-m", action="append", help="only show these metrics")
408
+ s.add_argument("--trace", metavar="FILE", help="write the event log (.json/.csv/.parquet)")
409
+ s.add_argument("--json", action="store_true", help="print the full result as JSON")
410
+ s.add_argument("--allow-outside", **allow) # type: ignore[arg-type]
411
+ s.set_defaults(func=cmd_run)
412
+
413
+ s = sub.add_parser("experiment", help="run an experiment from a YAML configuration")
414
+ s.add_argument("config")
415
+ s.add_argument("--replications", "-r", type=int)
416
+ s.add_argument("--workers", "-w", type=int)
417
+ s.add_argument("--seed", type=int)
418
+ s.add_argument("--output", "-o", help="results directory (overrides output.directory)")
419
+ s.add_argument("--checkpoint", help="JSON-lines checkpoint file for resumable runs")
420
+ s.add_argument("--quiet", "-q", action="store_true")
421
+ s.add_argument("--allow-outside", **allow) # type: ignore[arg-type]
422
+ s.set_defaults(func=cmd_experiment)
423
+
424
+ s = sub.add_parser("analyze", help="summarise and compare saved experiment results")
425
+ s.add_argument("results", help="results directory or experiment.json")
426
+ s.add_argument("--metric", "-m", action="append")
427
+ s.add_argument("--baseline", default="baseline")
428
+ s.add_argument("--confidence", type=float, default=0.95)
429
+ s.add_argument(
430
+ "--precision",
431
+ type=float,
432
+ help="relative precision target for replication advice, e.g. 0.05",
433
+ )
434
+ s.add_argument("--json", action="store_true")
435
+ s.set_defaults(func=cmd_analyze)
436
+
437
+ s = sub.add_parser("benchmark", help="measure engine and experiment throughput on this machine")
438
+ s.add_argument("--sizes", type=int, nargs="+", help="event counts (default 10k 100k 1M)")
439
+ s.add_argument("--workers", type=int, nargs="+", default=[1, 4])
440
+ s.add_argument("--replications", type=int, default=16)
441
+ s.add_argument("--no-memory", action="store_true", help="skip tracemalloc runs")
442
+ s.add_argument("--json", action="store_true")
443
+ s.set_defaults(func=cmd_benchmark)
444
+
445
+ s = sub.add_parser("visualize", help="plot saved results, or run a model once and plot it")
446
+ s.add_argument("target", help="results directory/experiment.json, or a model reference")
447
+ s.add_argument("--out", "-o", default="plots")
448
+ s.add_argument("--metric", "-m", action="append")
449
+ s.add_argument("--baseline", default="baseline")
450
+ s.add_argument("--backend", choices=["matplotlib", "plotly"], default="matplotlib")
451
+ s.add_argument("--format", choices=["png", "svg", "pdf"], default="png")
452
+ s.add_argument("--param", "-p", action="append", metavar="KEY=VALUE")
453
+ s.add_argument("--seed", type=int, default=0)
454
+ s.set_defaults(func=cmd_visualize)
455
+
456
+ s = sub.add_parser("ui", help="start the local web dashboard (127.0.0.1 only by default)")
457
+ s.add_argument("results", nargs="*", help="saved experiment results to preload")
458
+ s.add_argument(
459
+ "--model", action="append", metavar="REF", help="extra model the dashboard may run"
460
+ )
461
+ s.add_argument("--host", default="127.0.0.1")
462
+ s.add_argument("--port", type=int, default=8642)
463
+ s.add_argument("--no-browser", action="store_true")
464
+ s.set_defaults(func=cmd_ui)
465
+ return p
466
+
467
+
468
+ def main(argv: Sequence[str] | None = None) -> int:
469
+ parser = build_parser()
470
+ args = parser.parse_args(argv)
471
+ try:
472
+ code: int = args.func(args)
473
+ except (ConfigError, SimulsiError, FileNotFoundError, KeyError) as exc:
474
+ _err(str(exc) if not isinstance(exc, KeyError) else f"not found: {exc}")
475
+ return EXIT_INVALID
476
+ except ImportError as exc:
477
+ _err(str(exc))
478
+ return EXIT_ERROR
479
+ except KeyboardInterrupt:
480
+ _err("interrupted")
481
+ return 130
482
+ return code
483
+
484
+
485
+ if __name__ == "__main__": # pragma: no cover
486
+ sys.exit(main())