rasad-sim 0.1.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ahmed Aly
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,333 @@
1
+ Metadata-Version: 2.4
2
+ Name: rasad-sim
3
+ Version: 0.1.0
4
+ Summary: Measure how much of a stochastic simulation's result is a property of the model and how much is the random seed.
5
+ Author: Ahmed Aly
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/ahmedaly0904-bit64/rasad
8
+ Project-URL: Source, https://github.com/ahmedaly0904-bit64/rasad
9
+ Project-URL: Issues, https://github.com/ahmedaly0904-bit64/rasad/issues
10
+ Keywords: simulation,reproducibility,agent-based-modeling,uncertainty-quantification,monte-carlo,sensitivity-analysis
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Topic :: Scientific/Engineering
16
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: numpy>=1.26
22
+ Requires-Dist: plotly>=5.9
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest>=8.0; extra == "dev"
25
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
26
+ Provides-Extra: examples
27
+ Requires-Dist: mesa>=3.5; extra == "examples"
28
+ Requires-Dist: networkx>=3.0; extra == "examples"
29
+ Requires-Dist: pandas>=2.0; extra == "examples"
30
+ Requires-Dist: simpy>=4.1; extra == "examples"
31
+ Dynamic: license-file
32
+
33
+ # Rasad · رَصَد
34
+
35
+ **Measure how much of your simulation's result is a property of the model, and how much is the random seed.**
36
+
37
+ Stochastic simulations are usually reported without error bars. A paper says *"the civilisation
38
+ collapsed in year 240"* and never answers two questions:
39
+
40
+ 1. If the random seed changes, is it still year 240?
41
+ 2. Is this a property of the model, or an accident of one run?
42
+
43
+ Rasad answers both by measurement.
44
+
45
+ ---
46
+
47
+ ## Why this exists
48
+
49
+ This tool was not designed in the abstract. It was built because another project needed it.
50
+
51
+ **Omran** is a simulation of Ibn Khaldun's theory of *asabiyyah*. Ibn Khaldun (1332–1406) was
52
+ a historian who argued in the *Muqaddimah* that civilisations rise and fall on a single
53
+ force: **asabiyyah**, the cohesion that lets a group act as one. A group with strong
54
+ cohesion overtakes a settled, comfortable one; then comfort erodes its own cohesion over
55
+ generations, and it is overtaken in turn. The cycle repeats. He called the study of this
56
+ *ʿilm al-ʿumran* — the science of human social organisation — which is where the project
57
+ takes its name.
58
+
59
+ Omran models that computationally: nations on a grid, populations that grow and starve,
60
+ ideas that spread between neighbours like an infection, wars along contested borders. Run it
61
+ and you get numbers — a final population, a count of wars, a year the collapse happened.
62
+
63
+ The problem was that nobody knew whether those numbers meant anything.
64
+
65
+ Change the random seed and they change. Was a collapse in year 240 a property of the model,
66
+ or an accident of one run? There was no way to answer without measuring, and no tool that
67
+ measured it — the frameworks that do sensitivity analysis are built for engineering and
68
+ physics models, and the agent-based modelling field is repeatedly criticised for publishing
69
+ results without error bars.
70
+
71
+ So Rasad was written to answer that question, under one hard constraint: **it must not touch
72
+ Omran.** It observes from the outside and modifies nothing, the way an instrument measures a
73
+ specimen without altering it. That constraint shaped the architecture — and it is why Rasad
74
+ works on any simulation, not just the one it was written for.
75
+
76
+ What it found is in [`FINDINGS.md`](FINDINGS.md), and it was not what anyone expected.
77
+
78
+ ---
79
+
80
+ ## Install
81
+
82
+ ```bash
83
+ pip install rasad-sim
84
+ ```
85
+
86
+ The distribution is `rasad-sim` because `rasad` collides with an existing name on
87
+ PyPI. The import is unaffected:
88
+
89
+ ```python
90
+ import rasad
91
+ ```
92
+
93
+ Python 3.12+ · numpy · plotly
94
+
95
+ ## Use
96
+
97
+ Describe your simulation as one function:
98
+
99
+ ```python
100
+ def run(params: dict, seed: int) -> dict:
101
+ """One run. A number is a final result; a list of numbers is a time series."""
102
+ ...
103
+ ```
104
+
105
+ Then:
106
+
107
+ ```python
108
+ import rasad
109
+
110
+ report = rasad.measure(run, params={"growth": 0.03}, runs=100)
111
+ print(report.summary())
112
+ report.plot().write_html("divergence.html")
113
+ ```
114
+
115
+ You get, for every output: mean, standard deviation, a 90% interval, and a variability class —
116
+ **low**, **moderate**, or **high** — plus a curve showing how far the runs drift
117
+ apart over time.
118
+
119
+ ---
120
+
121
+ ## What it found in practice
122
+
123
+ ### A simulation that could not reproduce itself
124
+
125
+ Applied to [Omran](https://github.com/ahmedaly0904-bit64/Omran), an agent-based model of
126
+ Ibn Khaldun's theory of *asabiyyah*, **without modifying a line of it**:
127
+
128
+ ```
129
+ final_total_population: mean 4,649.39 | cv 0.2841 | p05-p95 [2,704.75, 6,774.30] | high
130
+ survivors: mean 1.14 | cv 0.3059 | p05-p95 [1.00, 2.00] | high
131
+ total_wars: mean 13.44 | cv 0.3986 | p05-p95 [6.00, 22.10] | high
132
+ total_famines: mean 0.00 | cv 0.0000 | p05-p95 [0.00, 0.00] | low
133
+ ```
134
+
135
+ No numeric output of the model had low variability. Worse, the measurement exposed something the author
136
+ did not know: **the same seed produced different results in different processes.**
137
+
138
+ Six runs with seed `1`, thirty simulated years:
139
+
140
+ ```
141
+ 1428 · 1428 · 1512 · 1426 · 1426 · 1512
142
+ ```
143
+
144
+ Comparing the population curve year by year located the split: the runs are **identical for
145
+ nineteen years**, then diverge at year twenty by **one individual** — which becomes hundreds
146
+ by year one hundred. That is error propagation, measured.
147
+
148
+ This was then confirmed with Rasad out of the path entirely: Omran's own `main.py` carries a
149
+ hard-coded `random.seed(42)`, and running that file directly eight times gave **four distinct
150
+ results**.
151
+
152
+ Full write-up: [`FINDINGS.md`](FINDINGS.md)
153
+
154
+ ### Aggregates can be stable while their parts are noise
155
+
156
+ On a [SimPy](https://simpy.readthedocs.io) machine-shop simulation
157
+ ([`examples/simpy_machine_shop.py`](examples/simpy_machine_shop.py)):
158
+
159
+ | Output | cv | Verdict |
160
+ |---|---|---|
161
+ | total parts produced | 0.014 | **low** |
162
+ | best machine · worst machine | 0.017 | **low** |
163
+ | **gap between best and worst** | **0.31** | **high** |
164
+
165
+ The shop's total output is stable. The gap between machines is pure noise. Anyone looking at
166
+ one run and saying *"machine 7 is underperforming, investigate it"* is chasing a random seed.
167
+
168
+ **Averages hide fragility.** That alone is a reason to measure what you publish.
169
+
170
+ ---
171
+
172
+ ## Verified against simulations it was not written for
173
+
174
+ | Framework | Models | Result |
175
+ |---|---|---|
176
+ | [Mesa](https://github.com/projectmesa/mesa) | Schelling, WolfSheep, Boltzmann | works; WolfSheep's sheep population has high variability (cv 2.95 — usually extinct, occasionally not) |
177
+ | [SimPy](https://simpy.readthedocs.io) | machine shop | works; see above |
178
+ | [EoN](https://epidemicsonnetworks.readthedocs.io) | SIR on a network | works; epidemic duration has high variability (7.5 → 14.7) |
179
+ | [Omran](https://github.com/ahmedaly0904-bit64/Omran) | asabiyyah model | works; see above |
180
+
181
+ Examples: [`examples/`](examples/)
182
+
183
+ A control worth stating: the SimPy and Mesa examples reproduce byte-identically across
184
+ separate processes. That establishes that Omran's non-reproducibility is a bug in Omran,
185
+ and that Rasad's own pipeline is deterministic.
186
+
187
+ ---
188
+
189
+ ## The class is a convention. The numbers are the result.
190
+
191
+ The default cutoffs — 0.05 and 0.20 — **are a choice, not a theory**. An output at cv 0.21
192
+ reads *high*; raise the cutoff to 0.25 and the same data reads *moderate*.
193
+
194
+ So every report prints the thresholds it used and labels them as a convention:
195
+
196
+ ```
197
+ thresholds (a convention, not a rule): low < 0.0500 <= moderate <= 0.2000 < high
198
+ ```
199
+
200
+ And they belong to the caller:
201
+
202
+ ```python
203
+ rasad.measure(run, params={}, runs=100,
204
+ thresholds={"low": 0.01, "moderate": 0.05})
205
+ ```
206
+
207
+ **The real result is the interval.** `p05-p95 [7.46, 14.74]` says the duration may double,
208
+ without needing a word on top of it.
209
+
210
+ ---
211
+
212
+ ## Limitations
213
+
214
+ - **Only the seed varies.** Parameters are held fixed, so *"which parameter drives the
215
+ result?"* is not answered yet. Sensitivity analysis is the next version.
216
+ - **Execution is sequential.** No parallelism.
217
+ - **One value per output name per run.** A model whose keys change between runs is rejected.
218
+
219
+ Accepted outputs: Python numbers, numpy scalars, 1-D numeric numpy arrays, lists and tuples.
220
+ **Booleans are always rejected** — alone or inside a list — because an average of ones and
221
+ zeros means nothing.
222
+
223
+ ---
224
+
225
+ ## Development
226
+
227
+ ```bash
228
+ python -m venv .venv
229
+ .venv/bin/pip install -e ".[dev]"
230
+ .venv/bin/pytest tests -v
231
+ ```
232
+
233
+ 73 tests · 97% line coverage · 357 of 452 mutants killed · linted with ruff
234
+
235
+ The coverage figure is the weakest of the three. Mutation testing is what showed why:
236
+ `validate_thresholds` was fully covered and still accepted two equal cutoffs, because
237
+ no test passed the one input that separates `<` from `<=`.
238
+
239
+ The statistics are tested against models whose answers are known analytically: a constant
240
+ must give a standard deviation of exactly zero; a random walk's divergence must grow as the
241
+ square root of time. Those tests caught real bugs — including a collapsed axis that would
242
+ have produced plausible, meaningless numbers.
243
+
244
+ ## Notes on the implementation
245
+
246
+ Three analyses of the code, in Arabic:
247
+
248
+ - [فئات المدخلات](docs/input-classes.md) — the five input classes `summarize()` actually
249
+ distinguishes, each with the test that covers it, and why branch order decides which class
250
+ a list falls into.
251
+ - [كم تشغيلة تكفي؟](docs/how-many-runs.md) — the standard error of the mean applied to Omran:
252
+ 100 runs pin the mean to 2.8%, and reaching 1% costs 784.
253
+ - [حدود الوحدات](docs/module-boundaries.md) — the deletion test applied to each of Rasad's own
254
+ modules, separating splits forced by something that happened from splits made on a guess.
255
+
256
+ ---
257
+
258
+ ## بالعربية
259
+
260
+ **رَصَد** أداة تقيس صلابة نتائج المحاكاة: أيُّ المخرجات خاصيةٌ في النموذج، وأيُّها أثرٌ للبذرة العشوائية.
261
+
262
+ تُنشر نتائج المحاكاة العشوائية غالبًا بلا حدود خطأ. يُقال «انهارت الحضارة في السنة ٢٤٠» دون
263
+ الإجابة على سؤالين: هل يظل الرقم ٢٤٠ لو تغيّرت البذرة؟ وهل هذه خاصية في النموذج أم صدفة في
264
+ تشغيلة واحدة؟ يجيب رَصَد عنهما بالقياس لا بالتقدير.
265
+
266
+ يوصّف المستخدم محاكاته بدالةٍ واحدة تستقبل المعاملات والبذرة وترجّع قاموس مخرجات. يشغّلها
267
+ رَصَد مرارًا ببذورٍ مختلفة، ثم يعرض لكل مخرَج متوسطه وانحرافه ومدى تسعين بالمئة وتصنيفًا لتغايره —
268
+ **low** أو **moderate** أو **high** — مع منحنى يبيّن اتساع التباعد بين التشغيلات عبر الزمن.
269
+
270
+ طُبِّق على أربعة مشاريع لم يُكتب لأجلها، فكشف في أحدها — محاكاة لنظرية العصبية عند ابن خلدون —
271
+ أنها **لا تعيد إنتاج نتائجها بالبذرة نفسها**: تشغيلتان متطابقتان تفترقان عند السنة العشرين
272
+ بفارق فردٍ واحد، يصير مئاتٍ بحلول السنة المئة. وهذا انتشار الخطأ في صورته المقيسة.
273
+
274
+ ### لماذا كُتب
275
+
276
+ لم تُصمَّم هذه الأداة في الفراغ، بل كُتبت لأن مشروعًا آخر احتاجها.
277
+
278
+ **عُمران** محاكاةٌ لنظرية العصبية عند ابن خلدون (١٣٣٢–١٤٠٦)، الذي رأى في *المقدمة* أن الحضارات
279
+ تنهض وتسقط بقوةٍ واحدة: **العصبية**، أي التماسك الذي يجعل الجماعة تفعل كأنها واحد. جماعةٌ
280
+ عصبيتها قوية تغلب جماعةً مستقرةً مترفة، ثم يُفسد الترف عصبيتها هي عبر الأجيال، فتُغلَب بدورها.
281
+ وسمّى ابن خلدون دراسة ذلك **علم العمران**، ومنه أخذ المشروع اسمه.
282
+
283
+ يحاكي عُمران هذا حاسوبيًّا: دولٌ على شبكة، وسكانٌ ينمون ويجوعون، وأفكارٌ تنتقل بين الجيران
284
+ كالعدوى، وحروبٌ على الحدود المتنازعة. تشغّله فتخرج لك أرقام — سكانٌ في النهاية، وعدد حروب،
285
+ وسنةٌ وقع فيها الانهيار.
286
+
287
+ والمشكلة أن أحدًا لم يكن يعرف: هل لهذه الأرقام معنى؟ غيّر البذرة العشوائية تتغيّر. فهل الانهيار
288
+ في السنة ٢٤٠ خاصيةٌ في النموذج أم صدفةُ تشغيلةٍ واحدة؟ لا سبيل إلى الجواب إلا بالقياس، ولم تكن
289
+ هناك أداةٌ تقيسه.
290
+
291
+ فكُتب رَصَد لهذا السؤال، بقيدٍ واحدٍ صارم: **ألّا يمسّ عُمران**. يراقبه من خارجه ولا يعدّل فيه
292
+ حرفًا، كما يقيس المِجهر عيّنةً دون أن يغيّرها. وهذا القيد هو الذي شكّل بنية الأداة — وهو سبب
293
+ عملها على أي محاكاة، لا على التي كُتبت لأجلها وحدها.
294
+
295
+ ### التصنيف
296
+
297
+ وحدود التصنيف الافتراضية اصطلاحٌ لا قاعدة، ولذلك يعلنها كل تقرير ويتركها بيد المستخدم.
298
+ **النتيجة الحقيقية هي المدى**، لا التصنيف الذي يعلوه.
299
+
300
+ ---
301
+
302
+ ## How this was built — full disclosure
303
+
304
+ Most of the code here was written by AI models under explicit delegation and human review.
305
+
306
+ | Stage | Owner |
307
+ |---|---|
308
+ | Specification and architecture | Ahmed, in dialogue with Claude (Opus 5) |
309
+ | **Test authoring** | Written into each task brief **before** implementation; the implementer was forbidden from altering a character |
310
+ | Implementation | DeepSeek V4 Flash, via `opencode` + AgentRouter — seven tasks |
311
+ | Review gates | Claude (Opus 5): every diff read, tests run independently of the implementer's claim |
312
+ | Decisions and merges | Ahmed — every commit after review |
313
+
314
+ **The ordering is what matters, not the tooling: the tests came first and were the
315
+ specification.** The model was never asked to write code and then write the thing that proves
316
+ it correct. It was given a written contract and held to it.
317
+
318
+ What the gates actually caught: a dead condition in a type check, a deprecated import, a
319
+ missing return annotation, unreadable number formatting. **No logic error got through** —
320
+ credit to the tests, not to the model.
321
+
322
+ And what none of them caught: an output that was constantly zero was classified as *high* variability
323
+ when it was the most stable number in the report. Neither the reference models nor the review
324
+ found it — **the real data did, on the first run against Omran.**
325
+
326
+ That is the boundary. Tests prove the arithmetic is right; only real data reveals the case
327
+ nobody thought to write a test for.
328
+
329
+ ---
330
+
331
+ ## License
332
+
333
+ MIT
@@ -0,0 +1,301 @@
1
+ # Rasad · رَصَد
2
+
3
+ **Measure how much of your simulation's result is a property of the model, and how much is the random seed.**
4
+
5
+ Stochastic simulations are usually reported without error bars. A paper says *"the civilisation
6
+ collapsed in year 240"* and never answers two questions:
7
+
8
+ 1. If the random seed changes, is it still year 240?
9
+ 2. Is this a property of the model, or an accident of one run?
10
+
11
+ Rasad answers both by measurement.
12
+
13
+ ---
14
+
15
+ ## Why this exists
16
+
17
+ This tool was not designed in the abstract. It was built because another project needed it.
18
+
19
+ **Omran** is a simulation of Ibn Khaldun's theory of *asabiyyah*. Ibn Khaldun (1332–1406) was
20
+ a historian who argued in the *Muqaddimah* that civilisations rise and fall on a single
21
+ force: **asabiyyah**, the cohesion that lets a group act as one. A group with strong
22
+ cohesion overtakes a settled, comfortable one; then comfort erodes its own cohesion over
23
+ generations, and it is overtaken in turn. The cycle repeats. He called the study of this
24
+ *ʿilm al-ʿumran* — the science of human social organisation — which is where the project
25
+ takes its name.
26
+
27
+ Omran models that computationally: nations on a grid, populations that grow and starve,
28
+ ideas that spread between neighbours like an infection, wars along contested borders. Run it
29
+ and you get numbers — a final population, a count of wars, a year the collapse happened.
30
+
31
+ The problem was that nobody knew whether those numbers meant anything.
32
+
33
+ Change the random seed and they change. Was a collapse in year 240 a property of the model,
34
+ or an accident of one run? There was no way to answer without measuring, and no tool that
35
+ measured it — the frameworks that do sensitivity analysis are built for engineering and
36
+ physics models, and the agent-based modelling field is repeatedly criticised for publishing
37
+ results without error bars.
38
+
39
+ So Rasad was written to answer that question, under one hard constraint: **it must not touch
40
+ Omran.** It observes from the outside and modifies nothing, the way an instrument measures a
41
+ specimen without altering it. That constraint shaped the architecture — and it is why Rasad
42
+ works on any simulation, not just the one it was written for.
43
+
44
+ What it found is in [`FINDINGS.md`](FINDINGS.md), and it was not what anyone expected.
45
+
46
+ ---
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ pip install rasad-sim
52
+ ```
53
+
54
+ The distribution is `rasad-sim` because `rasad` collides with an existing name on
55
+ PyPI. The import is unaffected:
56
+
57
+ ```python
58
+ import rasad
59
+ ```
60
+
61
+ Python 3.12+ · numpy · plotly
62
+
63
+ ## Use
64
+
65
+ Describe your simulation as one function:
66
+
67
+ ```python
68
+ def run(params: dict, seed: int) -> dict:
69
+ """One run. A number is a final result; a list of numbers is a time series."""
70
+ ...
71
+ ```
72
+
73
+ Then:
74
+
75
+ ```python
76
+ import rasad
77
+
78
+ report = rasad.measure(run, params={"growth": 0.03}, runs=100)
79
+ print(report.summary())
80
+ report.plot().write_html("divergence.html")
81
+ ```
82
+
83
+ You get, for every output: mean, standard deviation, a 90% interval, and a variability class —
84
+ **low**, **moderate**, or **high** — plus a curve showing how far the runs drift
85
+ apart over time.
86
+
87
+ ---
88
+
89
+ ## What it found in practice
90
+
91
+ ### A simulation that could not reproduce itself
92
+
93
+ Applied to [Omran](https://github.com/ahmedaly0904-bit64/Omran), an agent-based model of
94
+ Ibn Khaldun's theory of *asabiyyah*, **without modifying a line of it**:
95
+
96
+ ```
97
+ final_total_population: mean 4,649.39 | cv 0.2841 | p05-p95 [2,704.75, 6,774.30] | high
98
+ survivors: mean 1.14 | cv 0.3059 | p05-p95 [1.00, 2.00] | high
99
+ total_wars: mean 13.44 | cv 0.3986 | p05-p95 [6.00, 22.10] | high
100
+ total_famines: mean 0.00 | cv 0.0000 | p05-p95 [0.00, 0.00] | low
101
+ ```
102
+
103
+ No numeric output of the model had low variability. Worse, the measurement exposed something the author
104
+ did not know: **the same seed produced different results in different processes.**
105
+
106
+ Six runs with seed `1`, thirty simulated years:
107
+
108
+ ```
109
+ 1428 · 1428 · 1512 · 1426 · 1426 · 1512
110
+ ```
111
+
112
+ Comparing the population curve year by year located the split: the runs are **identical for
113
+ nineteen years**, then diverge at year twenty by **one individual** — which becomes hundreds
114
+ by year one hundred. That is error propagation, measured.
115
+
116
+ This was then confirmed with Rasad out of the path entirely: Omran's own `main.py` carries a
117
+ hard-coded `random.seed(42)`, and running that file directly eight times gave **four distinct
118
+ results**.
119
+
120
+ Full write-up: [`FINDINGS.md`](FINDINGS.md)
121
+
122
+ ### Aggregates can be stable while their parts are noise
123
+
124
+ On a [SimPy](https://simpy.readthedocs.io) machine-shop simulation
125
+ ([`examples/simpy_machine_shop.py`](examples/simpy_machine_shop.py)):
126
+
127
+ | Output | cv | Verdict |
128
+ |---|---|---|
129
+ | total parts produced | 0.014 | **low** |
130
+ | best machine · worst machine | 0.017 | **low** |
131
+ | **gap between best and worst** | **0.31** | **high** |
132
+
133
+ The shop's total output is stable. The gap between machines is pure noise. Anyone looking at
134
+ one run and saying *"machine 7 is underperforming, investigate it"* is chasing a random seed.
135
+
136
+ **Averages hide fragility.** That alone is a reason to measure what you publish.
137
+
138
+ ---
139
+
140
+ ## Verified against simulations it was not written for
141
+
142
+ | Framework | Models | Result |
143
+ |---|---|---|
144
+ | [Mesa](https://github.com/projectmesa/mesa) | Schelling, WolfSheep, Boltzmann | works; WolfSheep's sheep population has high variability (cv 2.95 — usually extinct, occasionally not) |
145
+ | [SimPy](https://simpy.readthedocs.io) | machine shop | works; see above |
146
+ | [EoN](https://epidemicsonnetworks.readthedocs.io) | SIR on a network | works; epidemic duration has high variability (7.5 → 14.7) |
147
+ | [Omran](https://github.com/ahmedaly0904-bit64/Omran) | asabiyyah model | works; see above |
148
+
149
+ Examples: [`examples/`](examples/)
150
+
151
+ A control worth stating: the SimPy and Mesa examples reproduce byte-identically across
152
+ separate processes. That establishes that Omran's non-reproducibility is a bug in Omran,
153
+ and that Rasad's own pipeline is deterministic.
154
+
155
+ ---
156
+
157
+ ## The class is a convention. The numbers are the result.
158
+
159
+ The default cutoffs — 0.05 and 0.20 — **are a choice, not a theory**. An output at cv 0.21
160
+ reads *high*; raise the cutoff to 0.25 and the same data reads *moderate*.
161
+
162
+ So every report prints the thresholds it used and labels them as a convention:
163
+
164
+ ```
165
+ thresholds (a convention, not a rule): low < 0.0500 <= moderate <= 0.2000 < high
166
+ ```
167
+
168
+ And they belong to the caller:
169
+
170
+ ```python
171
+ rasad.measure(run, params={}, runs=100,
172
+ thresholds={"low": 0.01, "moderate": 0.05})
173
+ ```
174
+
175
+ **The real result is the interval.** `p05-p95 [7.46, 14.74]` says the duration may double,
176
+ without needing a word on top of it.
177
+
178
+ ---
179
+
180
+ ## Limitations
181
+
182
+ - **Only the seed varies.** Parameters are held fixed, so *"which parameter drives the
183
+ result?"* is not answered yet. Sensitivity analysis is the next version.
184
+ - **Execution is sequential.** No parallelism.
185
+ - **One value per output name per run.** A model whose keys change between runs is rejected.
186
+
187
+ Accepted outputs: Python numbers, numpy scalars, 1-D numeric numpy arrays, lists and tuples.
188
+ **Booleans are always rejected** — alone or inside a list — because an average of ones and
189
+ zeros means nothing.
190
+
191
+ ---
192
+
193
+ ## Development
194
+
195
+ ```bash
196
+ python -m venv .venv
197
+ .venv/bin/pip install -e ".[dev]"
198
+ .venv/bin/pytest tests -v
199
+ ```
200
+
201
+ 73 tests · 97% line coverage · 357 of 452 mutants killed · linted with ruff
202
+
203
+ The coverage figure is the weakest of the three. Mutation testing is what showed why:
204
+ `validate_thresholds` was fully covered and still accepted two equal cutoffs, because
205
+ no test passed the one input that separates `<` from `<=`.
206
+
207
+ The statistics are tested against models whose answers are known analytically: a constant
208
+ must give a standard deviation of exactly zero; a random walk's divergence must grow as the
209
+ square root of time. Those tests caught real bugs — including a collapsed axis that would
210
+ have produced plausible, meaningless numbers.
211
+
212
+ ## Notes on the implementation
213
+
214
+ Three analyses of the code, in Arabic:
215
+
216
+ - [فئات المدخلات](docs/input-classes.md) — the five input classes `summarize()` actually
217
+ distinguishes, each with the test that covers it, and why branch order decides which class
218
+ a list falls into.
219
+ - [كم تشغيلة تكفي؟](docs/how-many-runs.md) — the standard error of the mean applied to Omran:
220
+ 100 runs pin the mean to 2.8%, and reaching 1% costs 784.
221
+ - [حدود الوحدات](docs/module-boundaries.md) — the deletion test applied to each of Rasad's own
222
+ modules, separating splits forced by something that happened from splits made on a guess.
223
+
224
+ ---
225
+
226
+ ## بالعربية
227
+
228
+ **رَصَد** أداة تقيس صلابة نتائج المحاكاة: أيُّ المخرجات خاصيةٌ في النموذج، وأيُّها أثرٌ للبذرة العشوائية.
229
+
230
+ تُنشر نتائج المحاكاة العشوائية غالبًا بلا حدود خطأ. يُقال «انهارت الحضارة في السنة ٢٤٠» دون
231
+ الإجابة على سؤالين: هل يظل الرقم ٢٤٠ لو تغيّرت البذرة؟ وهل هذه خاصية في النموذج أم صدفة في
232
+ تشغيلة واحدة؟ يجيب رَصَد عنهما بالقياس لا بالتقدير.
233
+
234
+ يوصّف المستخدم محاكاته بدالةٍ واحدة تستقبل المعاملات والبذرة وترجّع قاموس مخرجات. يشغّلها
235
+ رَصَد مرارًا ببذورٍ مختلفة، ثم يعرض لكل مخرَج متوسطه وانحرافه ومدى تسعين بالمئة وتصنيفًا لتغايره —
236
+ **low** أو **moderate** أو **high** — مع منحنى يبيّن اتساع التباعد بين التشغيلات عبر الزمن.
237
+
238
+ طُبِّق على أربعة مشاريع لم يُكتب لأجلها، فكشف في أحدها — محاكاة لنظرية العصبية عند ابن خلدون —
239
+ أنها **لا تعيد إنتاج نتائجها بالبذرة نفسها**: تشغيلتان متطابقتان تفترقان عند السنة العشرين
240
+ بفارق فردٍ واحد، يصير مئاتٍ بحلول السنة المئة. وهذا انتشار الخطأ في صورته المقيسة.
241
+
242
+ ### لماذا كُتب
243
+
244
+ لم تُصمَّم هذه الأداة في الفراغ، بل كُتبت لأن مشروعًا آخر احتاجها.
245
+
246
+ **عُمران** محاكاةٌ لنظرية العصبية عند ابن خلدون (١٣٣٢–١٤٠٦)، الذي رأى في *المقدمة* أن الحضارات
247
+ تنهض وتسقط بقوةٍ واحدة: **العصبية**، أي التماسك الذي يجعل الجماعة تفعل كأنها واحد. جماعةٌ
248
+ عصبيتها قوية تغلب جماعةً مستقرةً مترفة، ثم يُفسد الترف عصبيتها هي عبر الأجيال، فتُغلَب بدورها.
249
+ وسمّى ابن خلدون دراسة ذلك **علم العمران**، ومنه أخذ المشروع اسمه.
250
+
251
+ يحاكي عُمران هذا حاسوبيًّا: دولٌ على شبكة، وسكانٌ ينمون ويجوعون، وأفكارٌ تنتقل بين الجيران
252
+ كالعدوى، وحروبٌ على الحدود المتنازعة. تشغّله فتخرج لك أرقام — سكانٌ في النهاية، وعدد حروب،
253
+ وسنةٌ وقع فيها الانهيار.
254
+
255
+ والمشكلة أن أحدًا لم يكن يعرف: هل لهذه الأرقام معنى؟ غيّر البذرة العشوائية تتغيّر. فهل الانهيار
256
+ في السنة ٢٤٠ خاصيةٌ في النموذج أم صدفةُ تشغيلةٍ واحدة؟ لا سبيل إلى الجواب إلا بالقياس، ولم تكن
257
+ هناك أداةٌ تقيسه.
258
+
259
+ فكُتب رَصَد لهذا السؤال، بقيدٍ واحدٍ صارم: **ألّا يمسّ عُمران**. يراقبه من خارجه ولا يعدّل فيه
260
+ حرفًا، كما يقيس المِجهر عيّنةً دون أن يغيّرها. وهذا القيد هو الذي شكّل بنية الأداة — وهو سبب
261
+ عملها على أي محاكاة، لا على التي كُتبت لأجلها وحدها.
262
+
263
+ ### التصنيف
264
+
265
+ وحدود التصنيف الافتراضية اصطلاحٌ لا قاعدة، ولذلك يعلنها كل تقرير ويتركها بيد المستخدم.
266
+ **النتيجة الحقيقية هي المدى**، لا التصنيف الذي يعلوه.
267
+
268
+ ---
269
+
270
+ ## How this was built — full disclosure
271
+
272
+ Most of the code here was written by AI models under explicit delegation and human review.
273
+
274
+ | Stage | Owner |
275
+ |---|---|
276
+ | Specification and architecture | Ahmed, in dialogue with Claude (Opus 5) |
277
+ | **Test authoring** | Written into each task brief **before** implementation; the implementer was forbidden from altering a character |
278
+ | Implementation | DeepSeek V4 Flash, via `opencode` + AgentRouter — seven tasks |
279
+ | Review gates | Claude (Opus 5): every diff read, tests run independently of the implementer's claim |
280
+ | Decisions and merges | Ahmed — every commit after review |
281
+
282
+ **The ordering is what matters, not the tooling: the tests came first and were the
283
+ specification.** The model was never asked to write code and then write the thing that proves
284
+ it correct. It was given a written contract and held to it.
285
+
286
+ What the gates actually caught: a dead condition in a type check, a deprecated import, a
287
+ missing return annotation, unreadable number formatting. **No logic error got through** —
288
+ credit to the tests, not to the model.
289
+
290
+ And what none of them caught: an output that was constantly zero was classified as *high* variability
291
+ when it was the most stable number in the report. Neither the reference models nor the review
292
+ found it — **the real data did, on the first run against Omran.**
293
+
294
+ That is the boundary. Tests prove the arithmetic is right; only real data reveals the case
295
+ nobody thought to write a test for.
296
+
297
+ ---
298
+
299
+ ## License
300
+
301
+ MIT