Predictive crowd management for Grand Prix circuits. Participating phones become anonymous crowd nodes; the system infers crowd state, predicts where congestion will form, simulates interventions against a counterfactual, reviews them for safety, and reroutes people before the bottleneck exists.
We don't just detect crowds. We predict where they will get stuck and move them before they do.
OBSERVE -> UNDERSTAND -> PREDICT -> SIMULATE -> REDIRECT -> OBSERVE AGAIN
Planning documents live in plan/ (5,142 lines across 12 documents). This README is
the status report: what exists, what is wired to what, and what is still missing.
| Runtime | Bun 1.3.14, TypeScript 6, one workspace, one lockfile |
| Source | ~9,650 lines TS/TSX across 5 packages and 2 apps |
| Tests | 141 passing across 19 files, 7 workspaces |
| Typecheck | clean |
| Proof gate | passes, seeded, reproducible |
| Circuits | 1 of 23 committed (Silverstone) |
| Overall | core loop real and running; mesh radio, LLM agent, and phone sensing not connected |
The honest one-line summary: the prediction and intervention loop is real and demonstrably works. The differentiator layer around it (real radios, LLM narration, live phone data) is built but unplugged.
packages/
contracts/ authored TypeScript wire contracts + 48 standards constants + JSON Schema codegen
core/ pure engines, no I/O: state, routing, prediction, intervention, safety, mesh, refinement
cli/ headless command surface (standards, circuit, sim, mesh, refine)
api/ Bun HTTP + WebSocket server, scenario session, spectator feed
agent/ Crowd Ops LLM agent behind a safety-reviewed proposal seam
apps/
dashboard/ operator console (Vite, vanilla TS, no framework)
mobile/ spectator app (Expo / React Native) + native Kotlin mesh module
circuits/
index.yaml all 23 rounds of 2026 indexed with per-circuit coordinate frames
silverstone/ the one committed data pack + rendered SVG
plan/ vision, architecture, decisions, standards, methods, open questions
Every item below was executed, not read off a checklist.
bun run crowdflow -- sim ab silverstone --count 6000 --ticks 700 --seed 42
A/B - Silverstone Circuit, post-race-egress
6000 spectators, seed 42, participation 18%
identical seed both arms; only the intervention differs
metric without with change
peak density (ped/m2) 4.0 4.0 +0.0%
critical zone-seconds 2746.0 1792.0 -34.7%
building zone-seconds 7560.0 7050.0 -6.7%
peak simultaneous critical zones 9.0 7.0 -22.2%
peak queued (people) 1808.0 1569.0 -13.2%
arrived 270.0 270.0 +0.0%
mean walk (s) 992.0 992.0 +0.0%
p95 walk (s) 1236.0 1236.0 +0.0%
interventions dispatched 0.0 1.0 +0.0%
rejected by safety 0.0 0.0 +0.0%
GATE PASSED
Both arms use the same seed. Only the intervention differs. This is a real counterfactual, not a printed constant.
Boots, holds a session, streams ticks. Endpoints:
| Method | Path | Status |
|---|---|---|
| GET | /api/health |
works |
| GET | /api/standards |
works, returns Fruin LOS bands with citation |
| GET | /api/circuits |
works, reports 1875 zones / 2404 edges |
| GET | /api/circuits/:id/geometry |
works |
| GET | /api/circuits/:id/scenarios |
works, resolves real stand and car park IDs |
| GET | /api/session |
works |
| POST | /api/session |
works, starts a seeded scenario |
| POST | /api/session/control |
works, play / pause / step / speed |
| GET | /api/spectator/view |
works, returns a routed path with per-leg walk times |
| WS | /ws |
works, hello frame then tick frames plus 500 ms heartbeat |
A tick envelope carries per-zone density, flow, queue excess, mean speed, Fruin LOS grade,
band, and a confidence object with observed node count, freshness, accuracy and a
reportable flag. Unobserved zones report unknown, never empty.
Real geometry imported from OSM and the f1-circuits dataset. Silverstone: 1,875 zones, 2,404 edges, 5,891 m track length, correct lat/lon origin and metric frame, 17 tagged grandstands. Deterministic SVG render committed alongside it.
Builds in 124 ms to 34 KB JS + 12 KB CSS. Seven panels: header, map, zone table, prediction,
intervention alternatives, event feed, metrics strip. Connects over the Vite proxy with ws: true.
Fetches geometry only after the socket hello names the circuit, and degrades to zone IDs rather
than blocking on a blank screen. Link state is drawn in the header and frame age counts up in
front of the operator.
crowdflow standards
crowdflow band <density-persons-m2>
crowdflow circuit list|show|import|validate|render [id]
crowdflow sim run|traces|ab [id] [--count N --ticks N --seed N]
crowdflow mesh compare [--nodes N --ticks N --seed N]
crowdflow refine run [id] --traces file.jsonl --participation 0.18 [--apply]
All present and dispatching.
| Workspace | Files | Tests |
|---|---|---|
@crowdflow/contracts |
1 | 7 |
@crowdflow/core |
5 | 19 |
@crowdflow/cli |
1 | 3 |
@crowdflow/api |
1 | 4 |
@crowdflow/agent |
1 | 5 |
crowdflow-dashboard |
2 | 22 |
crowdflow-spectator |
8 | 81 |
| Total | 19 | 141 |
make test runs typecheck then every suite. Both pass.
Single source of truth. TypeScript is authored, JSON Schema is generated (39 schema files) with
a byte-for-byte drift test in CI via make codegen. 48 exported standards constants, each with
a citation or a measurement; anything unmeasured is prefixed ASSUMED_ so it cannot hide.
Position is metric x/y in the circuit's own frame. Latitude and longitude exist only at the
pack origin, deliberately.
Pure. Performs no I/O. 24 exported modules:
| Module | Purpose | State |
|---|---|---|
state/flow + state/engine |
density-based zone aggregation, confidence, LOS grading | working |
routing/graph |
dynamic graph, congestion-weighted Dijkstra, bounded LRU, crossing-aware | working |
prediction/baseline |
deterministic baseline-v1, needs 3 ticks of history |
working |
intervention/whatif |
counterfactual over 5 diversion fractions, walk cost 8 per minute | working |
safety/engine |
mandatory review; nothing dispatches without a verdict | working |
loop |
the tick loop, 300 s command TTL | working |
metrics |
run metrics including the A/B gate figures | working |
simulation/model + scenario |
seeded crowd simulator, the only CrowdNode producer |
working |
venue + venue-build |
OSM import, metric frame, barrier subtraction, width provenance | working |
participation |
private keyed Bottom-k, Chapman capture-recapture estimation | working |
mesh/privacy |
on-device planar-Laplace noise with epsilon attached | working, unused by any handset |
mesh/policy + uplink + fanin |
Spray-and-Wait, uplink election, dedupe and clock-skew fan-in | working, simulated only |
mesh/simulator |
seeded protocol comparison over one shared moving topology | working, transport-free by design |
refinement/* |
trace matching, capacity, staleness audit, desire lines, reports | working, dry-run first |
random |
seeded RNG, the reason every run reproduces | working |
HTTP + WebSocket over Bun's Node-compatible server. Owns ScenarioSession (the tick loop
adapter) and SpectatorFeed (translates operator conclusions into the small phone-facing view).
The operator envelope never reaches the app. Relative simulation freshness is converted to
wall-clock exactly once, at this boundary.
Equal adapter to the API, not a wrapper around it. Owns circuit import, validation, SVG render, seeded simulation, trace production and pack refinement write-back.
CrowdOpsAgent with a Toolbox, an InsightEngine that computes statistical self and peer
baselines before any narration, and a proposal seam that can only produce
{command, verdict} records. It has no dispatch operation. Model client is abstracted behind
ModelClient with a FakeModelClient used by the tests.
Vanilla TypeScript, no UI framework. Type-only imports from @crowdflow/api/wire, so nothing
crosses the bundle boundary at runtime and there is no generated alias to drift.
Expo / React Native / React Native Web. Six screens: Ahead, Walk, Hold, Rerouted, Arrival,
Offline. Every screen is a pure function of a SpectatorView. Two shells: LiveShell (polls the
API every 2 s) and DemoShell (scripted mock day). Native Kotlin mesh module at
modules/mesh with a MeshNetwork interface, foreground service, and typed error surface.
contracts ──> core ──> cli ──> api ──> dashboard FULLY CONNECTED, RUNNING
│ │
│ └──> mobile (HTTP, only when env-configured)
│
└──> mobile (type-only)
agent ──> (nothing) ORPHANED
mesh Kotlin ──> StubMeshNetwork only STUBBED
phone GPS ──> (does not exist) ABSENT
contracts -> core -> cli: the CLI drives the engines directly and produces the gate.core -> api -> dashboard: session ticks flow over WebSocket into live panels.api -> mobile:LiveSpectatorFeedpolls/api/spectator/viewand renders real routes.- Both apps consume authored TypeScript contracts directly, so there is no codegen drift risk.
| Component | Reality |
|---|---|
@crowdflow/agent |
Zero importers outside its own tests. Not referenced by the API, CLI, dashboard, Makefile, or any config. No ANTHROPIC_API_KEY is read anywhere in the repo. It is a library with no caller. |
StubMeshNetwork |
The only MeshNetwork implementation. In-memory, isOnline hardcoded false, talks to nobody. No Wi-Fi Aware, Wi-Fi Direct or BLE exists. |
| Phone location | No expo-location, no navigator.geolocation, no FusedLocation, no location plugin in app.json, no iOS usage description. The single ACCESS_FINE_LOCATION grant is maxSdkVersion="32" and exists solely because Android 12 and earlier gate BLE and Wi-Fi scanning behind it; the API 33+ permissions carry usesPermissionFlags="neverForLocation". |
CrowdNode from handsets |
The shared contract exists and carries position and accuracy_m, but the only producer in the repo is core/src/simulation/model.ts. The phone never constructs one. The "phones become crowd nodes" premise is currently fed entirely by simulated walkers. |
| Mobile live mode | App.tsx renders LiveShell only when EXPO_PUBLIC_CROWDFLOW_API, _ORIGIN and _DESTINATION are all set. Otherwise it falls back to DemoShell reading feed/mock.ts. Origin and destination are static config for the session; nothing updates as the user walks. |
| File | State | Consequence |
|---|---|---|
crossings.json |
{} |
D5 (time-gated crossings) is implemented in routing/graph but never exercised |
constraints.json |
three empty arrays | no-route zones, emergency exits and accessible routes are all unconstrained |
| widths | 2,402 of 2,404 edges untrustworthy | capacity figures lean on assumed defaults |
| circuits | 1 of 23 committed | importer is generic, only Silverstone is proven |
Runtime and contracts
- Bun 1.3 with one root workspace and one lockfile
- strict TypeScript across contracts, core, CLI, API and agent
- authored TypeScript wire contracts consumed directly by both apps
- deterministic TypeScript to JSON Schema generation with drift tests (39 schemas)
- 48 standards constants, each cited, measured, or explicitly
ASSUMED_ - Kotlin confined to
apps/mobile/modules/mesh/android - no Python, no second package manager, no second lockfile
Venue and loop
- cached OSM / f1-circuits import, metric frame, barrier subtraction
- provenance-aware widths and conservative semantic attachment
- deterministic SVG render
- dynamic graph, crossing availability, bounded LRU, constrained routing
- density-based state aggregation; unknown never means empty
- deterministic prediction, counterfactual intervention, mandatory safety review
- tick loop with command TTL
- seeded CLI simulation, trace production, dry-run-first refinement, A/B evaluation
- Silverstone pack validates at 1,875 zones / 2,404 edges
- full seed-42 gate: critical zone-seconds 2746 to 1792, one dispatch
Surfaces
- dense operator console: map, zone table, prediction, alternatives, feed, metrics
- link state and frame age visible to the operator at all times
- phone-sized spectator view; operator envelope never reaches the app
- unobserved route legs remain
unknown - only the exact safety-dispatched reroute becomes an offer, retained until expiry
- freshness translated to wall-clock exactly once, at the API boundary
- live mobile shell uses the API when configured
Mesh and privacy (simulated)
- native Expo module, autolinking, Android foreground-service lifecycle
- bounded dedupe and buffer, Spray-and-Wait defaults, rate-limited urgent flooding
- seeded shared-topology protocol comparison with explicit coverage reporting
- opportunistic uplink election
- fan-in dedupes overlapping reports and reconciles one-way clock skew
- on-device planar-Laplace trace noise with epsilon attached
- private keyed Bottom-k, coordinated overlap, Chapman participation estimation
Refinement and agent
- trace matching, privacy-debiased width, sustained capacity, staleness audit
- desire lines are proposals; adoption requires explicit operator application
- no recapture overlap returns unknown participation
- statistical self and peer baselines computed before any narration
- agent has no dispatch operation, only safety-reviewed
{command, verdict}records
Blocking a real deployment
- no location acquisition on the phone at all: add
expo-location, permissions, iOS usage description, and a foreground position stream - no path from a handset to a
CrowdNode: the contract and privacy layer exist, nothing fills them - origin and destination are static env vars; the app does not know where the user is or that they have moved
- real Wi-Fi Aware to Wi-Fi Direct to BLE transport behind
MeshNetwork - Android Kotlin has never been compiled; this host has no
ANDROID_HOME - no handset capability or walk tests
Wiring already-built components
-
@crowdflow/agentis not reachable from any surface: no API route, no CLI subcommand, no dashboard panel, no key plumbing - agent default model is
claude-opus-4-6, a generation behind current (packages/agent/src/client.ts:20) - agent sends
thinking: {type: 'enabled', budget_tokens}, which is deprecated on 4.6 and returns a 400 on Opus 5, Sonnet 5 and 4.7 or later; it needs{type: 'adaptive'}plusoutput_config.effort(packages/agent/src/client.ts:26) - mobile defaults to the mock feed; live mode is opt-in via three env vars
Data
- Silverstone crossing identities and schedules;
crossings.jsonis empty - venue constraints: no-route zones, emergency exits, accessible routes all empty
- edge widths: 2,402 of 2,404 remain untrustworthy
- a second deep circuit pack; the importer is generic but unproven beyond one venue
- no
SKILL.mdexists for any circuit, so the agent half of D4 is unimplemented - measured live-event participation, capacity, radio range and hop latency
Housekeeping
- Expo / React Native / Metro advisories: 18 open (7 moderate, 11 high). No patched compatible dependency chain is published; the package manager's suggested fix is an incompatible downgrade
make install bun install --frozen-lockfile
make console API + dashboard, http://127.0.0.1:5199
make api API only, http://127.0.0.1:8099
make dashboard console only
make test typecheck + every Vitest suite
make codegen regenerate JSON Schema and fail on drift
make gate the seeded Silverstone intervention A/B
make build build every workspace
Mobile, live mode:
EXPO_PUBLIC_CROWDFLOW_API=http://127.0.0.1:8099 \
EXPO_PUBLIC_CROWDFLOW_ORIGIN=stand_227342440 \
EXPO_PUBLIC_CROWDFLOW_DESTINATION=park_1120614867 \
bun run --filter crowdflow-spectator start
Without those three variables the app renders the scripted demo instead.
These hold today and should be treated as load-bearing:
- assumptions are registered or labelled
ASSUMED_ coreperforms no I/O- density is the sole operational classifier, never flow
- simulator and phone share one
CrowdNodecontract - nothing dispatches without
SafetyEngine.review() - the LLM never computes a route, density or prediction
- simulations and policy comparisons are seeded
- circuit selection is data-driven, never hardcoded
| Decision | |
|---|---|
| D1 | Bun/TypeScript monorepo; contracts authored once in TypeScript, JSON Schema generated |
| D2 | CLI-first over a pure core library; API and CLI are equal adapters |
| D3 | React Native + Expo; mesh is a native Kotlin module at the MeshNetwork seam |
| D4 | Each circuit is a data pack plus a SKILL.md for the agent |
| D5 | Track crossings are time-gated, so routing is time-dependent |
| D6 | Venue structure is imported from OSM and the official map; traces refine it, never create it |
| D7 | Any connected node is a gateway; no fixed uplink infrastructure |
| D8 | The surfaces are deliberately asymmetric: app simple and beautiful, console dense and organised |
Rationale, alternatives and revisit conditions are in plan/decisions.md.