grocery-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,15 @@
1
+ # virtual environment and caches
2
+ .venv/
3
+ __pycache__/
4
+ *.pyc
5
+ .pytest_cache/
6
+
7
+ # local assistant settings
8
+ .claude/
9
+
10
+ # workbook build artifact (rebuilt from data/visible on first run)
11
+ data/store.duckdb
12
+ data/store.duckdb.wal
13
+
14
+ # marimo's own local session cache (not notebook content)
15
+ __marimo__/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ChinhMai
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,286 @@
1
+ Metadata-Version: 2.4
2
+ Name: grocery-sim
3
+ Version: 0.1.0
4
+ Summary: A standalone structural microsimulation of a neighborhood grocery store, with a fully known generating process.
5
+ Project-URL: Homepage, https://github.com/ChinhMaiGit/GroceryStoreData
6
+ Project-URL: Repository, https://github.com/ChinhMaiGit/GroceryStoreData
7
+ Project-URL: Paper, https://github.com/ChinhMaiGit/GroceryStoreData/blob/main/paper/paper.pdf
8
+ Author-email: ChinhMai <chinhmai.work@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Education
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Education
17
+ Classifier: Topic :: Scientific/Engineering
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: duckdb>=1.5.4
20
+ Requires-Dist: marimo>=0.23.1
21
+ Requires-Dist: numpy>=2.5.1
22
+ Requires-Dist: openpyxl>=3.1.5
23
+ Requires-Dist: pandas>=2.2
24
+ Requires-Dist: pulp>=3.3.2
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=8; extra == 'test'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # grocery-sim
30
+
31
+ A standalone, installable Python package that simulates three years in the
32
+ life of a small neighborhood grocery store — customers, an owner, a
33
+ calendar of weather and macro shocks, a daily market, and a full paper
34
+ trail (receipts, invoices, a ledger, a tax filing) — and hands the result
35
+ back as an object with a small, fixed API.
36
+
37
+ Every number it produces has a documented, traceable cause; nothing is
38
+ sampled from a convenient distribution and called a business. **The theory
39
+ behind why the model is built this way — the distributional choices, the
40
+ causal structure, the exogenous/endogenous split — is not repeated here.**
41
+ See `paper/paper.pdf` (and `documents/PHASE1..5_DETAILS.md` for the
42
+ underlying design documents) in the parent project for that. This README
43
+ only covers the package: how to run it, what you can configure, and what
44
+ each method returns.
45
+
46
+ ## Install
47
+
48
+ From this directory:
49
+
50
+ ```bash
51
+ uv pip install -e .
52
+ # or: pip install -e .
53
+ ```
54
+
55
+ ## How it works
56
+
57
+ A run has four stages, always in this order:
58
+
59
+ 1. **`setup(settings)`** validates a settings dict against a fixed schema
60
+ (below) and resolves it against defaults. Nothing is simulated yet —
61
+ this step only catches typos, impossible dates, and unsupported
62
+ combinations early, raising `SettingsError`.
63
+ 2. **`simulate()`** builds the world (locations, customers, the owner's
64
+ opening decision), performs the scripted year(s) day by day, runs the
65
+ recording layer (which deliberately dirties the visible documents the
66
+ way real paperwork is dirty), and exports the result. It then
67
+ automatically runs the validation battery (below) and raises
68
+ `ValidationError` if a bookkeeping invariant broke.
69
+ 3. **Query the result** — `data()`, `db()`, `erd()`, `describe()` — as many
70
+ times as you like, in any order, without re-running anything.
71
+ 4. **`create_analysis(path)`** scaffolds a marimo notebook against the
72
+ result, and `export_settings(path)` / `cleanup()` handle reuse and
73
+ teardown.
74
+
75
+ Internally, every run is built from two layers that never talk to each
76
+ other in the reverse direction:
77
+
78
+ - **A scripted, exogenous layer** — the calendar, weather, and the macro
79
+ events described below — decided in advance, independent of anything the
80
+ simulated customers or owner do. This is what `settings.events` controls.
81
+ - **An emergent, endogenous layer** — individual customers making a
82
+ discrete choice at the shelf, an owner following fixed heuristic rules
83
+ (restocking, pricing, and — if allowed — expanding) in response to what
84
+ he actually observes, never to the ground truth. This is what
85
+ `settings.potential_investment` partially controls (see below): you can
86
+ allow or forbid a decision, but you cannot script when or whether it
87
+ actually happens — that stays the model's own emergent output.
88
+
89
+ The practical consequence: two runs that differ only in one scripted event
90
+ being present or absent are otherwise identical (the same customers make
91
+ the same choices on the same days), so the difference between them is an
92
+ exact causal effect, not a correlation. This is also why nothing in the
93
+ package lets one event *cause* another — a "war causes a government
94
+ response two months later" story has to be authored by you as two
95
+ separately dated events; the model has no mechanism for one event to
96
+ trigger another, on purpose, because real macro causation is exogenous
97
+ from a single shop's point of view too.
98
+
99
+ ## The object API
100
+
101
+ ```python
102
+ from grocery_sim import GroceryStoreSimulation, ValidationError
103
+
104
+ sim = GroceryStoreSimulation()
105
+ ```
106
+
107
+ | Method / attribute | Returns |
108
+ |---|---|
109
+ | `sim.setup(settings)` | `self`, after validating; raises `SettingsError` |
110
+ | `sim.simulate(out_dir=None)` | `self`, after running phases 1-3 + recording + export + validation; raises `ValidationError` on a structural failure |
111
+ | `sim.data(include_hidden=False)` | a `SimulationData` object — attribute access per table (`sim.data().receipts`, `.cost_sheet`, ...) plus a compact schema summary as its `repr()` |
112
+ | `sim.db(path=None, include_hidden=False)` | a `duckdb` connection with every table loaded (`path=None` → in-memory) |
113
+ | `sim.erd(include_hidden=False)` | a Mermaid `erDiagram` string, inferred from shared key-like column names across the exported tables |
114
+ | `sim.describe()` | a business-case brief (markdown string): a letter, a Q&A-style intake interview (year-by-year narrative, honest records caveats), a data table, and a question list, all narrated by a reproducible fictional owner persona (name, gender/pronoun, age, prior career — keyed off `random_seed`) around this run's own real events and results. Costs one extra internal simulation on the first call if a competitor/war/operational_hazard event is active (a counterfactual twin, used to ground the owner's guess about the cause); cached after that, so repeated calls are free and return the same text |
115
+ | `sim.settings` | the resolved settings dict (JSON round-trippable) |
116
+ | `sim.validation` | the full validation report: `{"structural_ok": bool, "checks": [...]}` |
117
+ | `sim.create_analysis(path, mode="student")` | writes a scaffolded marimo notebook to `path`; `mode="instructor"` also fills in starter queries |
118
+ | `sim.export_settings(path)` | writes `sim.settings` to JSON, reloadable via `sim2.setup(json.load(open(path)))` |
119
+ | `sim.cleanup()` | removes the temporary export directory, if `simulate()` created one |
120
+
121
+ `sim.data()` and `sim.db()` both accept `include_hidden=True` to also load
122
+ the hidden answer-key tables (customer-level true parameters, the demand
123
+ modifiers, the profit triptych, ...) — useful for building or checking an
124
+ analysis, never appropriate to hand to a student working the case cold.
125
+
126
+ ## Possible scenarios: the settings schema
127
+
128
+ ```python
129
+ settings = dict(
130
+ basic = dict(
131
+ name = "Corner Grocer",
132
+ random_seed = 1124,
133
+ year = 3, # 1 or 3 — see "Known limitations"
134
+ budget = 60_000,
135
+ year_start = "2025",
136
+ retain_earning = True, # Phase 5 only; required for any expansion
137
+ retain_earning_from = "2026-01",
138
+ ),
139
+ events = dict(
140
+ war = None, # broad supply shock, all categories
141
+ typhoon = None, # 3-day storm: traffic collapse + fresh-goods cost spike
142
+ food_vat_cut = None, # reduced VAT-rate group cut to 5%
143
+ tax_cut = None, # standard VAT-rate group cut to 15%
144
+ tax_raise = None, # a direct levy on gross revenue
145
+ competitor = None, # discounter entry; requires year == 3, single-date only
146
+ operational_hazard = None, # freezer/equipment failure; requires year == 3
147
+ ),
148
+ potential_investment = dict(
149
+ more_staff = True, # allows the hire-and-expand decision to fire
150
+ bigger_store = False, # allows a shelf-capacity expansion to fire
151
+ upgrade_infrastructure = False, # allows a spoilage-reducing upgrade to fire
152
+ ),
153
+ )
154
+ ```
155
+
156
+ **`war`, `typhoon`, `food_vat_cut`, `tax_cut`, `tax_raise`, and
157
+ `operational_hazard`** each also accept a *list* of dates instead of one,
158
+ to make the same event happen more than once:
159
+
160
+ ```python
161
+ events = dict(war = ["2025-02", "2026-06", "2027-11"])
162
+ ```
163
+
164
+ **`competitor` stays single-date.** It is a permanent regime change once
165
+ triggered (a defection ramp that never resets, not a transient shock), so a
166
+ second entry would need its own calibration decision about how two
167
+ competitors' effects compound — not modeled; passing a list raises.
168
+
169
+ **`potential_investment` is an allow/forbid switch only.** All three keys
170
+ share one rule: you can never set a threshold, a date, or a size for any of
171
+ them. Each fires — or doesn't — entirely on the model's own retained-
172
+ earnings/spendable-cash trigger, independently of the others, and any
173
+ combination of the three can fire in the same three-year run. `more_store`
174
+ (a second physical location) is not in the schema at all — passing it,
175
+ even as `False`, raises `SettingsError`.
176
+
177
+ For a worked example that pushes nearly every one of these at once — three
178
+ wars, three typhoons, three equipment failures, both tax cuts, a competitor
179
+ entry, and an endogenous expansion, all in one three-year run — see
180
+ `cases/extreme_stress_test/analysis_notebook.py`.
181
+
182
+ ## Validation
183
+
184
+ Every `simulate()` call runs the full validation battery automatically and
185
+ prints a three-part report. Checks are tiered:
186
+
187
+ - **structural** — bookkeeping identities, conservation, referential
188
+ integrity, the recording layer's recoverability contract. These have
189
+ nothing to do with which events are scripted; a failure here is a
190
+ generator bug, always. `simulate()` raises `ValidationError` if any
191
+ structural check fails.
192
+ - **core** — exactly one check, `oracle > realized (information pays)`: is
193
+ a perfectly-informed operator's profit actually higher than the
194
+ realistically-forecasting owner's? Reported prominently, never fatal — a
195
+ dense enough pile-up of shocks can legitimately erase this gap (the
196
+ extreme-stress-test case is a worked example where it does).
197
+ - **band** — magnitude ranges calibrated against a quiet, one-year
198
+ baseline (Fri-Sun revenue split, card share, spoilage as a percent of
199
+ revenue, repricing cadence, ...). These are *expected* to diverge once
200
+ real events are scripted, or occasionally even in a quiet run from
201
+ ordinary seed variance. Reported, never fatal.
202
+
203
+ Access the summary directly: `sim.validation["structural_ok"]` and
204
+ `[c for c in sim.validation["checks"] if c["tier"] == "core"]`.
205
+
206
+ Two bands ("realized capture near 0.65 target" and "repricing cadence
207
+ realistic") were found running consistently out of range on multi-year
208
+ runs during development; one turned out to be a real denominator bug in
209
+ the validator (now fixed), the other was genuine seed variance on an
210
+ already-tight calibration (left as-is). See the inline comments beside
211
+ each check in `validate.py` for the full account if you need to trust a
212
+ specific check's history.
213
+
214
+ ## Testing
215
+
216
+ ```bash
217
+ uv pip install -e ".[test]"
218
+ pytest
219
+ ```
220
+
221
+ `tests/` is a real regression suite, not the ad hoc scripts used during
222
+ development: settings-validation errors (`test_settings.py`), `persona.py`
223
+ in isolation (`test_persona.py`), `describe.py`'s pure logic
224
+ (`test_describe_unit.py`), and full simulations across a spread of
225
+ settings — every event individually, every investment individually, all
226
+ of them combined, several seeds, reproducibility, and `describe()` against
227
+ real results (`test_simulation.py`, `test_describe_integration.py`). The
228
+ simulation-backed tests are slow (each one is a real multi-year run); the
229
+ suite reuses a handful of session-scoped fixtures rather than re-simulating
230
+ per test, but a full run still takes tens of minutes.
231
+
232
+ CI (`.github/workflows/tests.yml`) runs this same suite on every push and
233
+ pull request against `main`.
234
+
235
+ ## Known limitations (read before relying on these)
236
+
237
+ - **`basic.year` only supports `1` or `3`.** The three-year arc's holiday
238
+ calendar, panel churn, and scripted year-two/year-three events are
239
+ hand-calibrated to exactly three years; arbitrary horizons are future work.
240
+ - **`basic.year_start` relabels exported dates but the internal calendar
241
+ always runs from 2025-01-01.** Day-of-week alignment follows 2025's
242
+ calendar, not the real target year's, if you pick a `year_start` other
243
+ than `"2025"`.
244
+ - **`potential_investment.more_store` is excluded entirely** — see above.
245
+ - **`events.competitor` is single-fire** — see above.
246
+ - **`describe()` and `create_analysis()` are templates, not the full
247
+ authoring apparatus.** `describe()` builds a fictional owner (name,
248
+ gender/pronoun, age, prior career, town — cosmetic only, see
249
+ `persona.py`) and a narrative brief around this run's *real* settings
250
+ and results: a letter, a Q&A-style intake interview with one block per
251
+ year (which events fired, when, what they cost, whether an investment
252
+ paid for itself), and an honest records-imperfections note computed
253
+ directly from the exported tables (void/mis-ring counts, duplicate
254
+ invoice postings, weather-log gaps — never invented, never from the
255
+ hidden answer key). Every number in the brief is rounded the way an
256
+ owner would round it in their own head — never a hidden simulation
257
+ parameter (the case method's epistemic firewall, see `describe.py`'s
258
+ module docstring). If a competitor, war, or operational-hazard event is
259
+ active, the owner fixates on it as the likely cause of a bad year — a
260
+ realistic but not necessarily correct instinct — and `describe()`
261
+ quietly runs one counterfactual twin (same settings and seed, that
262
+ event removed) to decide, honestly, how confident the brief's closing
263
+ "Stakes" section should sound; the computed number itself is never
264
+ printed. This makes the *first* `describe()` call on a run with such an
265
+ event noticeably slower (one extra internal simulation); the result is
266
+ cached on the instance afterwards. `create_analysis()` scaffolds a
267
+ condensed slice of the project's analysis catalog with correct,
268
+ executable starter queries in instructor mode — a running start, not a
269
+ finished analysis.
270
+ - **`db()`'s `random_seed` scoping is process-global, not instance-local.**
271
+ Two `GroceryStoreSimulation` instances with different seeds must be run
272
+ sequentially, not concurrently, in the same process.
273
+
274
+ ## Package layout
275
+
276
+ See `grocery_sim/__init__.py` for the module-by-module map. The package
277
+ mirrors the archived `archive/datagen/` implementation's one-module-per-design-
278
+ document structure (`phase1.py`, `phase2.py`, `phase3.py`, `world.py`,
279
+ `recording.py`, `export.py`, `keys.py`, `params.py`), plus this package's
280
+ own additions: `events.py` (the composer described above), `settings.py`
281
+ (the schema), `schema.py` (the ERD), `persona.py` (the fictional owner
282
+ identity), `describe.py` (the brief built around that persona),
283
+ `analysis.py`, and `simulation.py` (the `GroceryStoreSimulation` class
284
+ itself). `validate.py`
285
+ is the original single-arm validation battery, generalized into the tiered
286
+ structural/core/band report described above.
@@ -0,0 +1,258 @@
1
+ # grocery-sim
2
+
3
+ A standalone, installable Python package that simulates three years in the
4
+ life of a small neighborhood grocery store — customers, an owner, a
5
+ calendar of weather and macro shocks, a daily market, and a full paper
6
+ trail (receipts, invoices, a ledger, a tax filing) — and hands the result
7
+ back as an object with a small, fixed API.
8
+
9
+ Every number it produces has a documented, traceable cause; nothing is
10
+ sampled from a convenient distribution and called a business. **The theory
11
+ behind why the model is built this way — the distributional choices, the
12
+ causal structure, the exogenous/endogenous split — is not repeated here.**
13
+ See `paper/paper.pdf` (and `documents/PHASE1..5_DETAILS.md` for the
14
+ underlying design documents) in the parent project for that. This README
15
+ only covers the package: how to run it, what you can configure, and what
16
+ each method returns.
17
+
18
+ ## Install
19
+
20
+ From this directory:
21
+
22
+ ```bash
23
+ uv pip install -e .
24
+ # or: pip install -e .
25
+ ```
26
+
27
+ ## How it works
28
+
29
+ A run has four stages, always in this order:
30
+
31
+ 1. **`setup(settings)`** validates a settings dict against a fixed schema
32
+ (below) and resolves it against defaults. Nothing is simulated yet —
33
+ this step only catches typos, impossible dates, and unsupported
34
+ combinations early, raising `SettingsError`.
35
+ 2. **`simulate()`** builds the world (locations, customers, the owner's
36
+ opening decision), performs the scripted year(s) day by day, runs the
37
+ recording layer (which deliberately dirties the visible documents the
38
+ way real paperwork is dirty), and exports the result. It then
39
+ automatically runs the validation battery (below) and raises
40
+ `ValidationError` if a bookkeeping invariant broke.
41
+ 3. **Query the result** — `data()`, `db()`, `erd()`, `describe()` — as many
42
+ times as you like, in any order, without re-running anything.
43
+ 4. **`create_analysis(path)`** scaffolds a marimo notebook against the
44
+ result, and `export_settings(path)` / `cleanup()` handle reuse and
45
+ teardown.
46
+
47
+ Internally, every run is built from two layers that never talk to each
48
+ other in the reverse direction:
49
+
50
+ - **A scripted, exogenous layer** — the calendar, weather, and the macro
51
+ events described below — decided in advance, independent of anything the
52
+ simulated customers or owner do. This is what `settings.events` controls.
53
+ - **An emergent, endogenous layer** — individual customers making a
54
+ discrete choice at the shelf, an owner following fixed heuristic rules
55
+ (restocking, pricing, and — if allowed — expanding) in response to what
56
+ he actually observes, never to the ground truth. This is what
57
+ `settings.potential_investment` partially controls (see below): you can
58
+ allow or forbid a decision, but you cannot script when or whether it
59
+ actually happens — that stays the model's own emergent output.
60
+
61
+ The practical consequence: two runs that differ only in one scripted event
62
+ being present or absent are otherwise identical (the same customers make
63
+ the same choices on the same days), so the difference between them is an
64
+ exact causal effect, not a correlation. This is also why nothing in the
65
+ package lets one event *cause* another — a "war causes a government
66
+ response two months later" story has to be authored by you as two
67
+ separately dated events; the model has no mechanism for one event to
68
+ trigger another, on purpose, because real macro causation is exogenous
69
+ from a single shop's point of view too.
70
+
71
+ ## The object API
72
+
73
+ ```python
74
+ from grocery_sim import GroceryStoreSimulation, ValidationError
75
+
76
+ sim = GroceryStoreSimulation()
77
+ ```
78
+
79
+ | Method / attribute | Returns |
80
+ |---|---|
81
+ | `sim.setup(settings)` | `self`, after validating; raises `SettingsError` |
82
+ | `sim.simulate(out_dir=None)` | `self`, after running phases 1-3 + recording + export + validation; raises `ValidationError` on a structural failure |
83
+ | `sim.data(include_hidden=False)` | a `SimulationData` object — attribute access per table (`sim.data().receipts`, `.cost_sheet`, ...) plus a compact schema summary as its `repr()` |
84
+ | `sim.db(path=None, include_hidden=False)` | a `duckdb` connection with every table loaded (`path=None` → in-memory) |
85
+ | `sim.erd(include_hidden=False)` | a Mermaid `erDiagram` string, inferred from shared key-like column names across the exported tables |
86
+ | `sim.describe()` | a business-case brief (markdown string): a letter, a Q&A-style intake interview (year-by-year narrative, honest records caveats), a data table, and a question list, all narrated by a reproducible fictional owner persona (name, gender/pronoun, age, prior career — keyed off `random_seed`) around this run's own real events and results. Costs one extra internal simulation on the first call if a competitor/war/operational_hazard event is active (a counterfactual twin, used to ground the owner's guess about the cause); cached after that, so repeated calls are free and return the same text |
87
+ | `sim.settings` | the resolved settings dict (JSON round-trippable) |
88
+ | `sim.validation` | the full validation report: `{"structural_ok": bool, "checks": [...]}` |
89
+ | `sim.create_analysis(path, mode="student")` | writes a scaffolded marimo notebook to `path`; `mode="instructor"` also fills in starter queries |
90
+ | `sim.export_settings(path)` | writes `sim.settings` to JSON, reloadable via `sim2.setup(json.load(open(path)))` |
91
+ | `sim.cleanup()` | removes the temporary export directory, if `simulate()` created one |
92
+
93
+ `sim.data()` and `sim.db()` both accept `include_hidden=True` to also load
94
+ the hidden answer-key tables (customer-level true parameters, the demand
95
+ modifiers, the profit triptych, ...) — useful for building or checking an
96
+ analysis, never appropriate to hand to a student working the case cold.
97
+
98
+ ## Possible scenarios: the settings schema
99
+
100
+ ```python
101
+ settings = dict(
102
+ basic = dict(
103
+ name = "Corner Grocer",
104
+ random_seed = 1124,
105
+ year = 3, # 1 or 3 — see "Known limitations"
106
+ budget = 60_000,
107
+ year_start = "2025",
108
+ retain_earning = True, # Phase 5 only; required for any expansion
109
+ retain_earning_from = "2026-01",
110
+ ),
111
+ events = dict(
112
+ war = None, # broad supply shock, all categories
113
+ typhoon = None, # 3-day storm: traffic collapse + fresh-goods cost spike
114
+ food_vat_cut = None, # reduced VAT-rate group cut to 5%
115
+ tax_cut = None, # standard VAT-rate group cut to 15%
116
+ tax_raise = None, # a direct levy on gross revenue
117
+ competitor = None, # discounter entry; requires year == 3, single-date only
118
+ operational_hazard = None, # freezer/equipment failure; requires year == 3
119
+ ),
120
+ potential_investment = dict(
121
+ more_staff = True, # allows the hire-and-expand decision to fire
122
+ bigger_store = False, # allows a shelf-capacity expansion to fire
123
+ upgrade_infrastructure = False, # allows a spoilage-reducing upgrade to fire
124
+ ),
125
+ )
126
+ ```
127
+
128
+ **`war`, `typhoon`, `food_vat_cut`, `tax_cut`, `tax_raise`, and
129
+ `operational_hazard`** each also accept a *list* of dates instead of one,
130
+ to make the same event happen more than once:
131
+
132
+ ```python
133
+ events = dict(war = ["2025-02", "2026-06", "2027-11"])
134
+ ```
135
+
136
+ **`competitor` stays single-date.** It is a permanent regime change once
137
+ triggered (a defection ramp that never resets, not a transient shock), so a
138
+ second entry would need its own calibration decision about how two
139
+ competitors' effects compound — not modeled; passing a list raises.
140
+
141
+ **`potential_investment` is an allow/forbid switch only.** All three keys
142
+ share one rule: you can never set a threshold, a date, or a size for any of
143
+ them. Each fires — or doesn't — entirely on the model's own retained-
144
+ earnings/spendable-cash trigger, independently of the others, and any
145
+ combination of the three can fire in the same three-year run. `more_store`
146
+ (a second physical location) is not in the schema at all — passing it,
147
+ even as `False`, raises `SettingsError`.
148
+
149
+ For a worked example that pushes nearly every one of these at once — three
150
+ wars, three typhoons, three equipment failures, both tax cuts, a competitor
151
+ entry, and an endogenous expansion, all in one three-year run — see
152
+ `cases/extreme_stress_test/analysis_notebook.py`.
153
+
154
+ ## Validation
155
+
156
+ Every `simulate()` call runs the full validation battery automatically and
157
+ prints a three-part report. Checks are tiered:
158
+
159
+ - **structural** — bookkeeping identities, conservation, referential
160
+ integrity, the recording layer's recoverability contract. These have
161
+ nothing to do with which events are scripted; a failure here is a
162
+ generator bug, always. `simulate()` raises `ValidationError` if any
163
+ structural check fails.
164
+ - **core** — exactly one check, `oracle > realized (information pays)`: is
165
+ a perfectly-informed operator's profit actually higher than the
166
+ realistically-forecasting owner's? Reported prominently, never fatal — a
167
+ dense enough pile-up of shocks can legitimately erase this gap (the
168
+ extreme-stress-test case is a worked example where it does).
169
+ - **band** — magnitude ranges calibrated against a quiet, one-year
170
+ baseline (Fri-Sun revenue split, card share, spoilage as a percent of
171
+ revenue, repricing cadence, ...). These are *expected* to diverge once
172
+ real events are scripted, or occasionally even in a quiet run from
173
+ ordinary seed variance. Reported, never fatal.
174
+
175
+ Access the summary directly: `sim.validation["structural_ok"]` and
176
+ `[c for c in sim.validation["checks"] if c["tier"] == "core"]`.
177
+
178
+ Two bands ("realized capture near 0.65 target" and "repricing cadence
179
+ realistic") were found running consistently out of range on multi-year
180
+ runs during development; one turned out to be a real denominator bug in
181
+ the validator (now fixed), the other was genuine seed variance on an
182
+ already-tight calibration (left as-is). See the inline comments beside
183
+ each check in `validate.py` for the full account if you need to trust a
184
+ specific check's history.
185
+
186
+ ## Testing
187
+
188
+ ```bash
189
+ uv pip install -e ".[test]"
190
+ pytest
191
+ ```
192
+
193
+ `tests/` is a real regression suite, not the ad hoc scripts used during
194
+ development: settings-validation errors (`test_settings.py`), `persona.py`
195
+ in isolation (`test_persona.py`), `describe.py`'s pure logic
196
+ (`test_describe_unit.py`), and full simulations across a spread of
197
+ settings — every event individually, every investment individually, all
198
+ of them combined, several seeds, reproducibility, and `describe()` against
199
+ real results (`test_simulation.py`, `test_describe_integration.py`). The
200
+ simulation-backed tests are slow (each one is a real multi-year run); the
201
+ suite reuses a handful of session-scoped fixtures rather than re-simulating
202
+ per test, but a full run still takes tens of minutes.
203
+
204
+ CI (`.github/workflows/tests.yml`) runs this same suite on every push and
205
+ pull request against `main`.
206
+
207
+ ## Known limitations (read before relying on these)
208
+
209
+ - **`basic.year` only supports `1` or `3`.** The three-year arc's holiday
210
+ calendar, panel churn, and scripted year-two/year-three events are
211
+ hand-calibrated to exactly three years; arbitrary horizons are future work.
212
+ - **`basic.year_start` relabels exported dates but the internal calendar
213
+ always runs from 2025-01-01.** Day-of-week alignment follows 2025's
214
+ calendar, not the real target year's, if you pick a `year_start` other
215
+ than `"2025"`.
216
+ - **`potential_investment.more_store` is excluded entirely** — see above.
217
+ - **`events.competitor` is single-fire** — see above.
218
+ - **`describe()` and `create_analysis()` are templates, not the full
219
+ authoring apparatus.** `describe()` builds a fictional owner (name,
220
+ gender/pronoun, age, prior career, town — cosmetic only, see
221
+ `persona.py`) and a narrative brief around this run's *real* settings
222
+ and results: a letter, a Q&A-style intake interview with one block per
223
+ year (which events fired, when, what they cost, whether an investment
224
+ paid for itself), and an honest records-imperfections note computed
225
+ directly from the exported tables (void/mis-ring counts, duplicate
226
+ invoice postings, weather-log gaps — never invented, never from the
227
+ hidden answer key). Every number in the brief is rounded the way an
228
+ owner would round it in their own head — never a hidden simulation
229
+ parameter (the case method's epistemic firewall, see `describe.py`'s
230
+ module docstring). If a competitor, war, or operational-hazard event is
231
+ active, the owner fixates on it as the likely cause of a bad year — a
232
+ realistic but not necessarily correct instinct — and `describe()`
233
+ quietly runs one counterfactual twin (same settings and seed, that
234
+ event removed) to decide, honestly, how confident the brief's closing
235
+ "Stakes" section should sound; the computed number itself is never
236
+ printed. This makes the *first* `describe()` call on a run with such an
237
+ event noticeably slower (one extra internal simulation); the result is
238
+ cached on the instance afterwards. `create_analysis()` scaffolds a
239
+ condensed slice of the project's analysis catalog with correct,
240
+ executable starter queries in instructor mode — a running start, not a
241
+ finished analysis.
242
+ - **`db()`'s `random_seed` scoping is process-global, not instance-local.**
243
+ Two `GroceryStoreSimulation` instances with different seeds must be run
244
+ sequentially, not concurrently, in the same process.
245
+
246
+ ## Package layout
247
+
248
+ See `grocery_sim/__init__.py` for the module-by-module map. The package
249
+ mirrors the archived `archive/datagen/` implementation's one-module-per-design-
250
+ document structure (`phase1.py`, `phase2.py`, `phase3.py`, `world.py`,
251
+ `recording.py`, `export.py`, `keys.py`, `params.py`), plus this package's
252
+ own additions: `events.py` (the composer described above), `settings.py`
253
+ (the schema), `schema.py` (the ERD), `persona.py` (the fictional owner
254
+ identity), `describe.py` (the brief built around that persona),
255
+ `analysis.py`, and `simulation.py` (the `GroceryStoreSimulation` class
256
+ itself). `validate.py`
257
+ is the original single-arm validation battery, generalized into the tiered
258
+ structural/core/band report described above.
Binary file
@@ -0,0 +1,56 @@
1
+ """grocery_sim — a standalone structural microsimulation of a neighborhood
2
+ grocery store, usable as a library.
3
+
4
+ from grocery_sim import GroceryStoreSimulation
5
+
6
+ sim = GroceryStoreSimulation()
7
+ sim.setup(settings) # validate a settings dict (see settings.py)
8
+ sim.simulate() # run phases 1-3 + the recording layer + export
9
+ sim.data() # in-memory tables (SimulationData)
10
+ sim.db() # a duckdb connection over the same tables
11
+ sim.erd() # a Mermaid ER diagram string
12
+ sim.describe() # a business-case brief: a fictional owner
13
+ # narrating this run's real events and results
14
+ sim.settings # the resolved settings dict
15
+ sim.create_analysis(path) # a scaffolded marimo notebook
16
+
17
+ This package is extracted from, and mirrors, the `archive/datagen/` implementation
18
+ behind the project's own generated cases (data/scenarios/, cases/). Each
19
+ module here implements one design document (documents/PHASE1..5_DETAILS.md)
20
+ or one cross-cutting concern:
21
+
22
+ keys.py the randomness discipline: master seed, keyed streams, tokens
23
+ params.py every parameter dict — the single source of truth
24
+ phase1.py the world at t = 0 (P1): locations, customers, the owner's MILP
25
+ phase2.py the script of the year (P2): calendar, weather, paths, events
26
+ world.py the World class assembling phases 1-2, plus the P5 panel flow
27
+ phase3.py the daily market loop (P3) + the P5 finance/expansion layer
28
+ recording.py the recording layer (P3 §20): how the books get dirty
29
+ export.py writes one run's visible/ and hidden/ split
30
+ validate.py the self-validation suite
31
+ scenarios.py the original named single-arm scenario registry (reference)
32
+ events.py the composer: settings.events/potential_investment -> a
33
+ scenario spec, generalizing scenarios.py to arbitrary,
34
+ independently dated, combinable events
35
+ settings.py the settings schema: defaults, merging, validation
36
+ schema.py a heuristic ER diagram over one run's exported tables
37
+ persona.py a reproducible fictional owner identity (cosmetic only)
38
+ describe.py the business-case brief generator, narrating a run's
39
+ real settings and results through that persona
40
+ analysis.py a scaffolded marimo notebook generator
41
+ simulation.py GroceryStoreSimulation — the public class
42
+
43
+ Known limitations of this first pass (see each module's own docstring for
44
+ detail): potential_investment.more_store (a second physical location) is
45
+ excluded entirely, not just disabled; basic.year only supports 1 or 3;
46
+ basic.year_start relabels exported dates but does not re-derive day-of-week
47
+ alignment for the target year (see events.py).
48
+ """
49
+
50
+ from .simulation import GroceryStoreSimulation, SimulationData, ValidationError
51
+
52
+ __all__ = [
53
+ "GroceryStoreSimulation",
54
+ "SimulationData",
55
+ "ValidationError",
56
+ ]