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.
- grocery_sim-0.1.0/.gitignore +15 -0
- grocery_sim-0.1.0/LICENSE +21 -0
- grocery_sim-0.1.0/PKG-INFO +286 -0
- grocery_sim-0.1.0/README.md +258 -0
- grocery_sim-0.1.0/grocery_sim/SKUs.xlsx +0 -0
- grocery_sim-0.1.0/grocery_sim/__init__.py +56 -0
- grocery_sim-0.1.0/grocery_sim/analysis.py +121 -0
- grocery_sim-0.1.0/grocery_sim/describe.py +555 -0
- grocery_sim-0.1.0/grocery_sim/events.py +263 -0
- grocery_sim-0.1.0/grocery_sim/export.py +273 -0
- grocery_sim-0.1.0/grocery_sim/keys.py +47 -0
- grocery_sim-0.1.0/grocery_sim/params.py +578 -0
- grocery_sim-0.1.0/grocery_sim/persona.py +67 -0
- grocery_sim-0.1.0/grocery_sim/phase1.py +295 -0
- grocery_sim-0.1.0/grocery_sim/phase2.py +343 -0
- grocery_sim-0.1.0/grocery_sim/phase3.py +1066 -0
- grocery_sim-0.1.0/grocery_sim/recording.py +476 -0
- grocery_sim-0.1.0/grocery_sim/scenarios.py +290 -0
- grocery_sim-0.1.0/grocery_sim/schema.py +47 -0
- grocery_sim-0.1.0/grocery_sim/settings.py +170 -0
- grocery_sim-0.1.0/grocery_sim/simulation.py +319 -0
- grocery_sim-0.1.0/grocery_sim/validate.py +749 -0
- grocery_sim-0.1.0/grocery_sim/world.py +442 -0
- grocery_sim-0.1.0/pyproject.toml +49 -0
- grocery_sim-0.1.0/tests/__init__.py +0 -0
- grocery_sim-0.1.0/tests/conftest.py +94 -0
- grocery_sim-0.1.0/tests/test_describe_integration.py +65 -0
- grocery_sim-0.1.0/tests/test_describe_unit.py +49 -0
- grocery_sim-0.1.0/tests/test_persona.py +48 -0
- grocery_sim-0.1.0/tests/test_settings.py +129 -0
- grocery_sim-0.1.0/tests/test_simulation.py +117 -0
|
@@ -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
|
+
]
|