Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

27 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

BackStock

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.


Stack

  • 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
Loading

Features

1. Days

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
Loading

Endpoints: GET /days, POST /days, GET /days/:id, GET /days/:id/events.


2. Versions

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
Loading

Endpoints: GET /versions, POST /versions, GET /versions/:id.


3. Agents

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
Loading

4. Simulation Engine

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
Loading

5. Runs (with branching)

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 | faileddone_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
Loading

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.


6. Compare

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
Loading

Endpoint: GET /compare?run_a=&run_b=&run_c=&run_d= (2–4 runs).


Data model (SQLite via Drizzle)

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

Queue topology (RabbitMQ)

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.


Commands

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:down

Repo layout

apps/
  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

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages