Upload a store day, replay it through LLM agents, branch the timeline, and compare outcomes.
BackStock is a deterministic retail simulator. You feed it a day — an initial inventory snapshot plus an ordered stream of events (sales spikes, vendor delays, cost changes, promotions). The simulation engine walks the events in sequence, asks LLM agents to make inventory and pricing decisions at the right moments, and records every state transition. Each execution is a run tied to a version (an immutable bundle of model + prompts). Because runs are reproducible, you can fork any completed run at any event, override a single decision (or swap the version), and let the compare view align the timelines so you can see exactly where the counterfactual diverges.
- Monorepo: Turborepo + Bun workspaces
- API: Bun + Elysia, Zod validation, SQLite (Drizzle ORM), RabbitMQ for async run execution
- Web: React 19 + Vite + TanStack Router/Query, Eden Treaty for fully-typed API client
- Agents: LLM endpoint on Modal, dynamic Zod-validated outputs, one-shot retry, fallback-to-no-op on failure
flowchart LR
UI[Web SPA<br/>React + TanStack]
API[Elysia API<br/>Zod + Drizzle]
DB[(SQLite)]
MQ[(RabbitMQ<br/>runs exchange)]
W[Run Worker<br/>simulation engine]
LLM[LLM endpoint<br/>Modal]
UI -- Eden Treaty --> API
API -- writes runs/days/versions --> DB
API -- publish run.requested --> MQ
MQ -- consume --> W
W -- read day/version --> DB
W -- agent calls --> LLM
W -- persist steps/decisions/impact --> DB
UI -- poll status --> API
A day is the scenario root: a seed state (SKU catalog with on-hand / price / cost / shelf-life / case-size, plus vendors with lead times) and an ordered event stream. On upload, the event stream is normalized — unknown event types are rejected, payloads are Zod-validated, references to unknown SKUs or vendors are dropped, sequence numbers are deduped, and the surviving events are renumbered 0..n. The result of normalization (kept count, ignored events with reasons) is stored alongside the day so the UI can show users why their JSON was rewritten. The day is the foundation every other feature attaches to: runs replay its events, compare aligns timelines against its event seqs, the run tree shows every fork against this day.
flowchart TD
Upload[POST /days<br/>raw JSON body]
Norm[normalizeDayEvents]
Catalog{Known type?<br/>Known sku/vendor?<br/>Valid payload?}
Renum[Renumber accepted<br/>events 0..n]
Ignored[ignored_report:<br/>reason per event]
InsDay[INSERT days<br/>seed_state + ignored_report]
InsEv[INSERT events<br/>seq, type, payload]
Resp[201 with counts<br/>+ ignored summary]
Upload --> Norm --> Catalog
Catalog -- yes --> Renum --> InsEv
Catalog -- no --> Ignored
Norm --> InsDay
InsEv --> Resp
Ignored --> Resp
Endpoints: GET /days, POST /days, GET /days/:id, GET /days/:id/events.
A version pins everything that determines agent behavior into one immutable record: inventory_prompt_version, pricing_prompt_version, model_id, and an optional policy blob. The label is unique. Every run references exactly one version, which is what makes A/B comparisons meaningful — re-running the same day under two different versions isolates the change to the agent configuration. Special model_id values (stub, stub-model) route the simulation to a deterministic stub resolver instead of hitting the LLM, which is how the test suite stays fast and predictable.
flowchart LR
NewV[POST /versions<br/>label + prompts + model]
UQ{label unique?}
Ins[INSERT versions]
Cflt[409 conflict]
Use1[Run worker<br/>picks resolver by model_id]
Stub[stubDecisionResolver]
LLM[createLlmDecisionResolver<br/>+ prompt versions]
NewV --> UQ
UQ -- yes --> Ins
UQ -- no --> Cflt
Ins --> Use1
Use1 -- model_id in STUB_MODEL_IDS --> Stub
Use1 -- otherwise --> LLM
Endpoints: GET /versions, POST /versions, GET /versions/:id.
Two agents make every decision the simulation needs: the inventory agent (triggered when a sales/promotion event pulls an SKU below its case size) and the pricing agent (triggered when a invoice_cost_change event hits an SKU). Each call sends the current store state plus the triggering event to the LLM with a Zod schema dynamically bound to the day's catalog — the model literally cannot return an unknown SKU or a price more than 5× the current one. On a parse/validation failure the agent gets one retry with the validation error appended to the conversation; if that also fails (or the call times out or the prompt is missing), the resolver returns a source: 'failure' decision with a typed failure_reason (prompt_missing, llm_timeout, llm_http_error, invalid_response). Failure decisions are no-ops — they don't order phantom cases or apply nonsense prices — and they bubble up to mark the run as done_degraded instead of done.
flowchart TD
Trig[Engine event triggers agent]
Prompt{prompt found?}
Call[POST LLM<br/>system + user JSON]
Parse[Strip ```json fence<br/>JSON.parse]
Zod[schema.safeParse<br/>SKU enum + price guardrail]
Retry[Append: 'your reply was invalid'<br/>retry once]
OK[source: 'llm'<br/>valid: true]
Fail[source: 'failure'<br/>failure_reason set<br/>order_cases=0 / price unchanged]
Trig --> Prompt
Prompt -- no --> Fail
Prompt -- yes --> Call --> Parse --> Zod
Zod -- ok --> OK
Zod -- bad --> Retry --> Parse2[Parse + Zod]
Parse2 -- ok --> OK
Parse2 -- bad --> Fail
Call -. timeout/http error .-> Fail
The engine is the deterministic core: a pure simulate(initialState, events, resolver) that produces { steps, decisions, impact }. It sorts events by seq (not wall clock), then for each event it advances current_time, fulfills any deliveries whose ETA has passed, dispatches the event to the matching apply* function (sales / promotion / vendor_delay / damage_report / invoice_cost_change / manager_override), and pushes an immutable snapshot of the new state. Orders progress through an xstate FSM (recommended → placed → in_transit → delivered | late | missed). After all events, end-of-day deliveries are applied at 22:00 and impact metrics are aggregated (waste %, stockout count, missed revenue, ending margin %, ending inventory value). The resolver is the only injectable seam — swapping it is how stubs, real LLM calls, and run-branching reuse are wired in.
flowchart TD
Init[initialState + events]
Sort[Sort events by seq]
Loop{next event?}
Tick[advance current_time<br/>applyDeliveriesDueBy]
Dispatch[dispatch by type]
Sales[sales_spike / promotion]
Cost[invoice_cost_change]
Other[vendor_delay / damage / override]
Inv[resolver inventory]
Price[resolver pricing]
Apply{source != failure?}
State[push state_snapshot<br/>+ order_state to steps]
EOD[apply 22:00 deliveries]
Imp[compute impact metrics]
Init --> Sort --> Loop
Loop -- yes --> Tick --> Dispatch
Dispatch --> Sales --> Inv --> Apply
Dispatch --> Cost --> Price --> Apply
Dispatch --> Other
Apply -- yes --> State
Apply -- no --> State
Other --> State
State --> Loop
Loop -- no --> EOD --> Imp
A run is one execution of a day under a version. Creating a run inserts a row with status='queued' and publishes {run_id} to the runs exchange; a RabbitMQ consumer (prefetchCount: 1, idempotent via the processed_messages table) loads the day, version, events, and seed state, picks a stub or LLM resolver, calls simulate, and writes everything back in a single transaction. Status transitions are queued → running → done | done_degraded | failed — done_degraded is the honest path when the run finished but at least one agent decision was a failure. Branching is what makes the system interesting: from any completed run you can fork at any event seq with either a decision override (replace exactly one agent decision with your own answer) or a version swap (re-run the whole day under a different version, fork seq 0). For decision-override forks, the engine reuses parent decisions before the fork point (source: 'reused', zero LLM cost, guaranteed identical state up to the fork), applies the override at the fork, and resolves post-fork events fresh — so divergence is mathematically isolated to the one decision you changed.
flowchart TD
POST[POST /days/:id/runs]
Ins[INSERT runs<br/>status=queued]
Pub[publish run.requested]
Cons[worker consume]
Load[load day, version,<br/>events, seed_state]
Pick{model_id stub?}
StubR[stub resolver]
LlmR[LLM resolver]
Fork{parent_run_id set?}
Wrap[wrap with forking resolver:<br/>reuse parent decisions pre-fork,<br/>override at fork seq]
Sim[simulate]
Tx[completeRunOnce TX:<br/>INSERT steps + decisions + impact<br/>status = done / done_degraded]
Fail[status = failed]
POST --> Ins --> Pub --> Cons --> Load --> Pick
Pick -- yes --> StubR --> Fork
Pick -- no --> LlmR --> Fork
Fork -- yes --> Wrap --> Sim
Fork -- no --> Sim
Sim --> Tx
Sim -. error .-> Fail
Branching UI: the day page renders a run tree (parent → children, sorted by fork_event_seq) with SVG connectors. Replay scrubs through run_steps with a playhead and lazily loads individual decisions on click. Polling on the runs list auto-refetches every 2s while any run is queued or running.
Endpoints: POST /days/:id/runs, GET /days/:id/runs, GET /runs/:id, GET /runs/:id/timeline, GET /runs/:id/impact, GET /runs/:id/decisions/:seq, POST /runs/:id/branch.
Compare aligns 2–4 completed runs from the same day side-by-side. The service first computes each run's fork descriptor (parent_run_id + fork_event_seq) and rejects mixed-ancestor selections — every run in the comparison must either share a common fork point or be a root run plus its children. Once validated, it walks seq from 0 to the max step count across all runs and at each seq emits a row containing a map of run_id → state_snapshot and run_id → decision (decision at event_seq = seq - 1). The UI fingerprints decisions (inv:sku:cases or price:sku:price) to highlight cells where one run diverged from the others, paints a blue stripe at the divergence step, and renders an impact scoreboard with pairwise deltas (waste %, waste value, stockouts, missed revenue, ending margin %, ending inventory). A bar chart shows the three headline metrics across all runs in distinct colors.
flowchart LR
Q[GET /compare?<br/>run_a&run_b&...]
Load[load each run +<br/>steps + decisions + impact]
Fork[findCommonFork<br/>across all runs]
Valid{same day?<br/>completed?<br/>shared fork?}
Align[walk seq 0..max:<br/>steps map per run<br/>decisions map per run]
Delta[pairwise impact deltas<br/>rounded 2dp]
Resp[CompareResult:<br/>timeline + impact.per_run + impact.deltas]
UI[UI fingerprints decisions<br/>highlights divergence cells]
Q --> Load --> Fork --> Valid
Valid -- no --> Err[400/409]
Valid -- yes --> Align --> Delta --> Resp --> UI
Endpoint: GET /compare?run_a=&run_b=&run_c=&run_d= (2–4 runs).
| Table | Role |
|---|---|
days |
Scenario seed state + ignored-event report |
events |
Normalized event stream per day, ordered by seq |
versions |
Immutable bundle: prompt versions + model_id + policy |
runs |
Execution row: day_id, version_id, parent_run_id, fork_event_seq, fork_change, status |
run_steps |
Per-seq state + order snapshot during a run |
decisions |
Per-event agent decisions: agent, source, valid, latency_ms, failure_reason, raw + parsed |
impacts |
End-of-run aggregates: waste %, missed revenue, ending margin %, etc. |
processed_messages |
Idempotency marker for queue consumers |
| Exchange | Type | Queue | Routing key | Producer | Consumer |
|---|---|---|---|---|---|
runs |
direct, durable | run.requested |
run.requested |
runs.service::startRun / branchRun |
workers.ts → executeRun |
Idempotency: completeRunOnce writes a row to processed_messages (subscriber_id, message_id) with a unique index, so duplicate deliveries no-op cleanly.
bun dev # full stack (API + web + SDK watcher + worker)
bun --filter web dev # web only
bun --filter @back-stock/api dev # API + worker only
bun check # format + oxlint + typecheck
bun api:test:e2e # API E2E (Eden Treaty against real server)
bun ui:test:e2e # Playwright UI E2E
bun --filter @back-stock/api db:generate # generate migrations from src/db/schema.ts
bun --filter @back-stock/api db:migrate # apply migrations
bun --filter @back-stock/api db:studio # Drizzle Studio
bun docker:up # start RabbitMQ (UI at :15672)
bun docker:downapps/
web/ React SPA (TanStack Router/Query, Eden Treaty)
src/modules/{days,runs,versions,compare,core}/
src/routes/{index,days.$dayId,runs.$runId,compare}.tsx
packages/
api/
src/modules/{days,versions,agents,simulation,runs,compare,queue,core}/
src/db/{schema.ts, migrations/}
src/workers.ts RabbitMQ consumer registration
src/sdk/ typed subpath exports for the web client
ui/ shadcn/Radix components, Tailwind v4 tokens
tsconfig/ shared tsconfigs