physverify 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.
Files changed (43) hide show
  1. physverify/__init__.py +26 -0
  2. physverify/__main__.py +3 -0
  3. physverify/adapters/__init__.py +14 -0
  4. physverify/adapters/double_pendulum.py +55 -0
  5. physverify/adapters/lorenz.py +152 -0
  6. physverify/adapters/projectile_v1.py +92 -0
  7. physverify/adapters/projectile_v2.py +94 -0
  8. physverify/adapters/three_body.py +283 -0
  9. physverify/agent.py +265 -0
  10. physverify/checks.py +59 -0
  11. physverify/cli.py +105 -0
  12. physverify/compare.py +104 -0
  13. physverify/loaders.py +49 -0
  14. physverify/matrix.py +160 -0
  15. physverify/mms.py +118 -0
  16. physverify/order.py +131 -0
  17. physverify/problems/__init__.py +6 -0
  18. physverify/problems/_common.py +89 -0
  19. physverify/problems/heat1d.py +217 -0
  20. physverify/problems/kepler.py +176 -0
  21. physverify/problems/pendulum.py +201 -0
  22. physverify/problems/projectile.py +240 -0
  23. physverify/problems/sod.py +291 -0
  24. physverify/pytest_plugin.py +75 -0
  25. physverify/report.py +189 -0
  26. physverify/steppers.py +38 -0
  27. physverify/stochastic/__init__.py +1 -0
  28. physverify/stochastic/calibrate.py +202 -0
  29. physverify/stochastic/checks.py +378 -0
  30. physverify/stochastic/exact.py +205 -0
  31. physverify/stochastic/ising.py +170 -0
  32. physverify/stochastic/matrix.py +151 -0
  33. physverify/stochastic/reference.py +67 -0
  34. physverify/stochastic/sampler.py +253 -0
  35. physverify/stochastic/stats.py +137 -0
  36. physverify/studies.py +146 -0
  37. physverify/symbolic.py +89 -0
  38. physverify-0.1.0.dist-info/METADATA +122 -0
  39. physverify-0.1.0.dist-info/RECORD +43 -0
  40. physverify-0.1.0.dist-info/WHEEL +5 -0
  41. physverify-0.1.0.dist-info/entry_points.txt +5 -0
  42. physverify-0.1.0.dist-info/licenses/LICENSE +21 -0
  43. physverify-0.1.0.dist-info/top_level.txt +1 -0
physverify/report.py ADDED
@@ -0,0 +1,189 @@
1
+ """Run a list of checks and report the results as text, JSON or a self-contained HTML page."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ import math
8
+ import time
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from .checks import Check, OrderResult, Result, run_check
13
+ from .loaders import load_module_from_path
14
+
15
+
16
+ @dataclass
17
+ class Outcome:
18
+ check: Check
19
+ result: Result
20
+ seconds: float
21
+
22
+
23
+ def run_checks(checks) -> list[Outcome]:
24
+ out = []
25
+ for check in checks:
26
+ start = time.perf_counter()
27
+ result = run_check(check)
28
+ out.append(Outcome(check, result, time.perf_counter() - start))
29
+ return out
30
+
31
+
32
+ def load_spec(path):
33
+ """Import a spec file and return its checks.
34
+
35
+ A spec is a Python file that defines `checks()`, returning a list of
36
+ `physverify.Check`. Its folder is put on sys.path first, so it can import
37
+ the solver next to it.
38
+ """
39
+ import sys
40
+
41
+ path = Path(path).resolve()
42
+ folder = str(path.parent)
43
+ if folder not in sys.path:
44
+ sys.path.insert(0, folder)
45
+ name = "physverify_spec_" + "".join(c if c.isalnum() else "_" for c in str(path))
46
+ module = load_module_from_path(name, path)
47
+ if not callable(getattr(module, "checks", None)):
48
+ raise AttributeError(f"{path} does not define checks()")
49
+ checks = list(module.checks())
50
+ for check in checks:
51
+ if not isinstance(check, Check):
52
+ raise TypeError(f"{path}: checks() returned {type(check).__name__}, not physverify.Check")
53
+ return checks
54
+
55
+
56
+ def _number(x):
57
+ x = float(x)
58
+ return x if math.isfinite(x) else None
59
+
60
+
61
+ def outcome_to_dict(o: Outcome) -> dict:
62
+ d = {
63
+ "family": o.check.family,
64
+ "label": o.check.label,
65
+ "status": o.result.status,
66
+ "ok": o.result.ok,
67
+ "message": o.result.message,
68
+ "seconds": round(o.seconds, 4),
69
+ }
70
+ if isinstance(o.result, OrderResult):
71
+ d["expected_order"] = _number(o.result.expected)
72
+ d["observed_order"] = _number(o.result.fitted)
73
+ d["h"] = [_number(h) for h in o.result.hs]
74
+ d["error"] = [_number(e) for e in o.result.errors]
75
+ return d
76
+
77
+
78
+ def summary(outcomes) -> dict:
79
+ return {
80
+ "total": len(outcomes),
81
+ "ok": sum(o.result.ok for o in outcomes),
82
+ "not_ok": sum(not o.result.ok for o in outcomes),
83
+ "by_status": {s: sum(o.result.status == s for o in outcomes) for s in sorted({o.result.status for o in outcomes})},
84
+ }
85
+
86
+
87
+ def to_json(outcomes, source: str = "") -> str:
88
+ from . import __version__
89
+
90
+ payload = {
91
+ "physverify": __version__,
92
+ "source": source,
93
+ "summary": summary(outcomes),
94
+ "checks": [outcome_to_dict(o) for o in outcomes],
95
+ }
96
+ return json.dumps(payload, indent=2)
97
+
98
+
99
+ def to_text(outcomes) -> str:
100
+ lines = [f"{o.check.family:10s} {o.result.status:12s} {o.check.label}: {o.result.message}" for o in outcomes]
101
+ s = summary(outcomes)
102
+ lines.append(f"{s['ok']} of {s['total']} checks passed")
103
+ return "\n".join(lines)
104
+
105
+
106
+ def _svg_plot(result: OrderResult, width=220, height=150) -> str:
107
+ """Log-log plot of error against step size, with a line of the expected slope."""
108
+ pts = [(h, e) for h, e in zip(result.hs, result.errors) if h > 0 and e > 0 and math.isfinite(e)]
109
+ if len(pts) < 2:
110
+ return ""
111
+ lx = [math.log10(h) for h, _ in pts]
112
+ ly = [math.log10(e) for _, e in pts]
113
+ x0, x1 = min(lx), max(lx)
114
+ y0, y1 = min(ly), max(ly)
115
+ expected_line = []
116
+ if math.isfinite(result.expected):
117
+ # through the finest point, with the expected slope
118
+ yf = ly[-1] if lx[-1] == x0 else ly[lx.index(x0)]
119
+ expected_line = [(x0, yf), (x1, yf + result.expected * (x1 - x0))]
120
+ y0 = min(y0, *[p[1] for p in expected_line])
121
+ y1 = max(y1, *[p[1] for p in expected_line])
122
+ pad = 14
123
+ sx = (width - 2 * pad) / ((x1 - x0) or 1.0)
124
+ sy = (height - 2 * pad) / ((y1 - y0) or 1.0)
125
+
126
+ def xy(x, y):
127
+ return f"{pad + (x - x0) * sx:.1f},{height - pad - (y - y0) * sy:.1f}"
128
+
129
+ parts = [f'<svg viewBox="0 0 {width} {height}" width="{width}" height="{height}" role="img" aria-label="error against step size, log-log">']
130
+ parts.append(f'<rect x="0.5" y="0.5" width="{width - 1}" height="{height - 1}" class="frame"/>')
131
+ if expected_line:
132
+ parts.append(f'<polyline points="{xy(*expected_line[0])} {xy(*expected_line[1])}" class="expected"/>')
133
+ parts.append('<polyline points="' + " ".join(xy(x, y) for x, y in zip(lx, ly)) + '" class="observed"/>')
134
+ for x, y in zip(lx, ly):
135
+ cx, cy = xy(x, y).split(",")
136
+ parts.append(f'<circle cx="{cx}" cy="{cy}" r="2.5" class="point"/>')
137
+ parts.append("</svg>")
138
+ return "".join(parts)
139
+
140
+
141
+ _CSS = """
142
+ :root { --bg:#ffffff; --fg:#1d1d1f; --muted:#6b6b70; --line:#d9d9de; --ok:#1a7f37; --bad:#b42318; --warn:#9a6700; --accent:#2f5bd3; }
143
+ @media (prefers-color-scheme: dark) { :root { --bg:#16171a; --fg:#ececf0; --muted:#a0a0a8; --line:#33343a; --ok:#4ac26b; --bad:#ff7b72; --warn:#d4a72c; --accent:#7aa2ff; } }
144
+ body { background:var(--bg); color:var(--fg); font:15px/1.5 system-ui, sans-serif; margin:0 auto; max-width:1100px; padding:24px 16px; }
145
+ h1 { font-size:22px; margin:0 0 4px; } p.meta { color:var(--muted); margin:0 0 20px; }
146
+ table { border-collapse:collapse; width:100%; } th, td { border-bottom:1px solid var(--line); padding:8px 6px; text-align:left; vertical-align:top; }
147
+ th { color:var(--muted); font-weight:600; font-size:13px; }
148
+ .status { font-weight:700; white-space:nowrap; } .ok { color:var(--ok); } .bad { color:var(--bad); } .warn { color:var(--warn); }
149
+ .wrap { overflow-x:auto; } svg .frame { fill:none; stroke:var(--line); } svg .observed { fill:none; stroke:var(--accent); stroke-width:2; }
150
+ svg .expected { fill:none; stroke:var(--muted); stroke-dasharray:4 3; } svg .point { fill:var(--accent); }
151
+ td.num { font-variant-numeric:tabular-nums; white-space:nowrap; }
152
+ """
153
+
154
+
155
+ def to_html(outcomes, title: str = "physverify report", source: str = "") -> str:
156
+ from . import __version__
157
+
158
+ s = summary(outcomes)
159
+ rows = []
160
+ for o in outcomes:
161
+ r = o.result
162
+ cls = "ok" if r.ok else ("warn" if r.status == "INCONCLUSIVE" else "bad")
163
+ plot = _svg_plot(r) if isinstance(r, OrderResult) else ""
164
+ order = ""
165
+ if isinstance(r, OrderResult) and math.isfinite(r.fitted):
166
+ order = f"{r.fitted:.2f}" + (f" (expected {r.expected:g})" if math.isfinite(r.expected) else "")
167
+ rows.append(
168
+ "<tr>"
169
+ f'<td class="status {cls}">{html.escape(r.status)}</td>'
170
+ f"<td>{html.escape(o.check.family)}</td>"
171
+ f"<td>{html.escape(o.check.label)}</td>"
172
+ f"<td>{html.escape(r.message)}</td>"
173
+ f'<td class="num">{html.escape(order)}</td>'
174
+ f"<td>{plot}</td>"
175
+ "</tr>"
176
+ )
177
+ return (
178
+ "<!doctype html>\n<html lang=\"en\"><head><meta charset=\"utf-8\">"
179
+ '<meta name="viewport" content="width=device-width, initial-scale=1">'
180
+ f"<title>{html.escape(title)}</title><style>{_CSS}</style></head><body>"
181
+ f"<h1>{html.escape(title)}</h1>"
182
+ f'<p class="meta">{s["ok"]} of {s["total"]} checks passed. physverify {html.escape(__version__)}'
183
+ + (f" · {html.escape(source)}" if source else "")
184
+ + ". Plots: error against step size on log-log axes; dashed line has the expected slope.</p>"
185
+ '<div class="wrap"><table><thead><tr><th>Status</th><th>Family</th><th>Check</th><th>Message</th>'
186
+ "<th>Observed order</th><th>Convergence</th></tr></thead><tbody>"
187
+ + "".join(rows)
188
+ + "</tbody></table></div></body></html>\n"
189
+ )
physverify/steppers.py ADDED
@@ -0,0 +1,38 @@
1
+ """Reference time steppers and a fixed-step driver.
2
+
3
+ These are used (a) to build independent reference solutions and (b) as the
4
+ stepper when only a right-hand side, not a whole solver, is under test.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import numpy as np
10
+
11
+
12
+ def euler(f, t, y, h):
13
+ return y + h * f(t, y)
14
+
15
+
16
+ def midpoint(f, t, y, h):
17
+ k1 = f(t, y)
18
+ return y + h * f(t + 0.5 * h, y + 0.5 * h * k1)
19
+
20
+
21
+ def rk4(f, t, y, h):
22
+ k1 = f(t, y)
23
+ k2 = f(t + 0.5 * h, y + 0.5 * h * k1)
24
+ k3 = f(t + 0.5 * h, y + 0.5 * h * k2)
25
+ k4 = f(t + h, y + h * k3)
26
+ return y + (h / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4)
27
+
28
+
29
+ STEPPERS = {"euler": (euler, 1), "midpoint": (midpoint, 2), "rk4": (rk4, 4)}
30
+
31
+
32
+ def integrate_fixed(f, y0, t0, t1, n_steps, stepper=rk4):
33
+ """Advance y' = f(t, y) from t0 to t1 in n_steps equal steps."""
34
+ h = (t1 - t0) / n_steps
35
+ y = np.array(y0, dtype=float)
36
+ for i in range(n_steps):
37
+ y = stepper(f, t0 + i * h, y, h)
38
+ return y
@@ -0,0 +1 @@
1
+ """Verification checks for stochastic (Monte Carlo) codes: the 2D Ising model. See docs/ising.md."""
@@ -0,0 +1,202 @@
1
+ """Calibration of the statistical checks, and detection power against run length.
2
+
3
+ python -m physverify.stochastic.calibrate results/ising_calibration.md
4
+
5
+ Calibration (milestone M2): many independent replicate runs of the bug-free
6
+ sampler, each analysed exactly as a check would. Two rates are counted:
7
+
8
+ * per comparison, the fraction of single t-tests rejected at nominal level a.
9
+ This should be close to a; if it is much larger, the error bars are too small.
10
+ * per check, the fraction of verdicts that are FAIL at the check's
11
+ false-alarm rate alpha (Bonferroni over its comparisons). It must not exceed
12
+ alpha, and is expected to be below it because Bonferroni is conservative.
13
+
14
+ Power (milestone M3): for a few bugs, the probability that the exact-distribution
15
+ check flags the bug, as a function of the number of sweeps per chain.
16
+
17
+ The replicate runs are simulated together as one large set of independent chains.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import math
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ import numpy as np
27
+
28
+ from ..checks import FAIL
29
+ from . import exact as X
30
+ from .checks import distribution_comparisons, moment_comparisons
31
+ from .sampler import IsingSampler
32
+ from .stats import t_critical, verdict
33
+
34
+
35
+ def binomial_interval(k, n, z=1.96):
36
+ """Wilson score interval for a proportion."""
37
+ if n == 0:
38
+ return (0.0, 1.0)
39
+ p = k / n
40
+ centre = (p + z * z / (2 * n)) / (1 + z * z / n)
41
+ half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / (1 + z * z / n)
42
+ return max(0.0, centre - half), min(1.0, centre + half)
43
+
44
+
45
+ def _groups(e, m, R, C):
46
+ for g in range(R):
47
+ yield e[g * C:(g + 1) * C], m[g * C:(g + 1) * C]
48
+
49
+
50
+ def calibrate(R=1000, C=32, n_sweeps=200, seed=2026, levels=(0.05, 0.01), alpha_check=0.05):
51
+ """Returns rows (test, T, per-comparison rejection rates at each level, FAIL rate of the check)."""
52
+ sampler = IsingSampler("metropolis")
53
+ rows = []
54
+ for name, T in (("exact_distribution", 2.5), ("moments", X.T_C), ("moments", 1.8)):
55
+ e, m = sampler.sample(4, T, 0.0, n_sweeps, seed=seed, n_chains=R * C)
56
+ seed += 1
57
+ per_level = {a: [0, 0] for a in levels}
58
+ fails = 0
59
+ for eg, mg in _groups(e, m, R, C):
60
+ if name == "exact_distribution":
61
+ comps = distribution_comparisons(eg, mg, 4, T)
62
+ else:
63
+ comps = moment_comparisons(eg, mg, X.exact_moments(4, T), "")
64
+ for c in comps:
65
+ for a in levels:
66
+ crit = t_critical(a, float(c.dof))
67
+ per_level[a][0] += abs(c.estimate - c.exact) > crit * c.se
68
+ per_level[a][1] += 1
69
+ fails += verdict(comps, alpha_check).status == FAIL
70
+ rows.append((name, T, {a: tuple(v) for a, v in per_level.items()}, fails, R))
71
+ return rows
72
+
73
+
74
+ POWER_BUGS = (
75
+ None,
76
+ "reject_zero_de",
77
+ "exp_de_times_t",
78
+ "acceptance_table_off_by_one",
79
+ "same_random_number",
80
+ "sweep_skips_last_row",
81
+ "heatbath_missing_2",
82
+ "synchronous_update",
83
+ )
84
+ POWER_SWEEPS = (10, 25, 50, 100, 200)
85
+
86
+
87
+ def power(R=50, C=16, sweeps=POWER_SWEEPS, seed=3000, T=2.5):
88
+ """Detection probability of exact_distribution (alpha 0.001) for each bug and run length."""
89
+ from . import ising
90
+
91
+ table = {}
92
+ for bug in POWER_BUGS:
93
+ algorithm = ising.BUGS[bug].algorithm if bug else "metropolis"
94
+ sampler = IsingSampler(algorithm, frozenset([bug]) if bug else frozenset())
95
+ probs = []
96
+ for n in sweeps:
97
+ e, m = sampler.sample(4, T, 0.0, n, seed=seed, n_chains=R * C)
98
+ seed += 1
99
+ hits = sum(verdict(distribution_comparisons(eg, mg, 4, T)).status == FAIL for eg, mg in _groups(e, m, R, C))
100
+ probs.append(hits / R)
101
+ table[bug or "(no bug)"] = probs
102
+ return table
103
+
104
+
105
+ def power_svg(table, sweeps=POWER_SWEEPS, width=560, height=300) -> str:
106
+ """A small line chart of detection probability against sweeps per chain (log x axis)."""
107
+ pad_l, pad_r, pad_t, pad_b = 46, 170, 14, 36
108
+ x0, x1 = math.log10(sweeps[0]), math.log10(sweeps[-1])
109
+
110
+ def px(n):
111
+ return pad_l + (math.log10(n) - x0) / (x1 - x0) * (width - pad_l - pad_r)
112
+
113
+ def py(p):
114
+ return pad_t + (1 - p) * (height - pad_t - pad_b)
115
+
116
+ colours = ["#6b6b70", "#9a6700", "#2f5bd3", "#1a7f37", "#b42318", "#8250df", "#0a7ea4", "#bf3989"]
117
+ parts = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}" font-family="sans-serif" font-size="11">']
118
+ parts.append(f'<rect width="{width}" height="{height}" fill="white"/>')
119
+ for p in (0, 0.5, 1):
120
+ parts.append(f'<line x1="{pad_l}" x2="{width - pad_r}" y1="{py(p):.1f}" y2="{py(p):.1f}" stroke="#ddd"/>')
121
+ parts.append(f'<text x="{pad_l - 6}" y="{py(p) + 4:.1f}" text-anchor="end">{p:g}</text>')
122
+ for n in sweeps:
123
+ parts.append(f'<text x="{px(n):.1f}" y="{height - pad_b + 16}" text-anchor="middle">{n}</text>')
124
+ parts.append(f'<text x="{(pad_l + width - pad_r) / 2:.1f}" y="{height - 4}" text-anchor="middle">sweeps per chain (16 chains per run)</text>')
125
+ parts.append(f'<text x="12" y="{(pad_t + height - pad_b) / 2:.1f}" transform="rotate(-90 12 {(pad_t + height - pad_b) / 2:.1f})" text-anchor="middle">P(flagged)</text>')
126
+ for k, (name, probs) in enumerate(table.items()):
127
+ col = colours[k % len(colours)]
128
+ pts = " ".join(f"{px(n):.1f},{py(p):.1f}" for n, p in zip(sweeps, probs))
129
+ dash = ' stroke-dasharray="4 3"' if name in ("(no bug)", "reject_zero_de") else ""
130
+ parts.append(f'<polyline points="{pts}" fill="none" stroke="{col}" stroke-width="2"{dash}/>')
131
+ parts.append(f'<text x="{width - pad_r + 8}" y="{pad_t + 12 + 15 * k}" fill="{col}">{name}</text>')
132
+ parts.append("</svg>")
133
+ return "\n".join(parts) + "\n"
134
+
135
+
136
+ def to_markdown(cal, table, R_power=50) -> str:
137
+ lines = [
138
+ "# Ising checks: calibration and power",
139
+ "",
140
+ "Generated by `python -m physverify.stochastic.calibrate results/ising_calibration.md`.",
141
+ "",
142
+ "## Calibration on the bug-free Metropolis sampler",
143
+ "",
144
+ "Each replicate is 32 independent chains of 200 sweeps on L = 4 (after the sampler's burn-in of 100),",
145
+ "analysed exactly as the check analyses its chains. 95% Wilson intervals in brackets.",
146
+ "",
147
+ "| Test | T | Comparisons | Rejected at 0.05 | Rejected at 0.01 | Check FAIL rate at alpha = 0.05 |",
148
+ "|---|---|---|---|---|---|",
149
+ ]
150
+ for name, T, per, fails, R in cal:
151
+ k5, n5 = per[0.05]
152
+ k1, n1 = per[0.01]
153
+ lo5, hi5 = binomial_interval(k5, n5)
154
+ lo1, hi1 = binomial_interval(k1, n1)
155
+ lo, hi = binomial_interval(fails, R)
156
+ lines.append(
157
+ f"| {name} | {T:.4g} | {n5} | {k5 / n5:.4f} [{lo5:.4f}, {hi5:.4f}] | {k1 / n1:.4f} [{lo1:.4f}, {hi1:.4f}] "
158
+ f"| {fails} of {R} = {fails / R:.3f} [{lo:.3f}, {hi:.3f}] |"
159
+ )
160
+ lines += [
161
+ "",
162
+ "Reading: per-comparison rejection rates near the nominal level mean the error bars are right; the check-level",
163
+ "FAIL rate must stay at or below its alpha (Bonferroni makes it conservative). The checks themselves use",
164
+ "alpha = 0.001, which cannot be measured directly with 1000 replicates; the 0.05 runs test the same machinery.",
165
+ "",
166
+ "## Power: probability that `exact_distribution` flags a bug",
167
+ "",
168
+ f"{R_power} replicate runs per cell, each 16 chains on L = 4 at T = 2.5, alpha = 0.001.",
169
+ "",
170
+ "| Bug | " + " | ".join(f"{n} sweeps" for n in POWER_SWEEPS) + " |",
171
+ "|---|" + "---|" * len(POWER_SWEEPS),
172
+ ]
173
+ for name, probs in table.items():
174
+ lines.append(f"| {name} | " + " | ".join(f"{p:.2f}" for p in probs) + " |")
175
+ lines += [
176
+ "",
177
+ "`(no bug)` should stay near 0 (false alarms). `reject_zero_de` is the equivalent mutant: it keeps the",
178
+ "Boltzmann distribution, so a distribution check should not flag it at any run length.",
179
+ "",
180
+ "![power curves](ising_power.svg)",
181
+ "",
182
+ ]
183
+ return "\n".join(lines)
184
+
185
+
186
+ def main(argv=None) -> int:
187
+ argv = sys.argv[1:] if argv is None else argv
188
+ cal = calibrate()
189
+ print("calibration done", file=sys.stderr)
190
+ table = power()
191
+ text = to_markdown(cal, table)
192
+ print(text)
193
+ if argv:
194
+ path = Path(argv[0])
195
+ path.parent.mkdir(parents=True, exist_ok=True)
196
+ path.write_text(text, encoding="utf-8")
197
+ path.with_name("ising_power.svg").write_text(power_svg(table), encoding="utf-8")
198
+ return 0
199
+
200
+
201
+ if __name__ == "__main__":
202
+ raise SystemExit(main())