Decision log¶
Short records of the choices that shape this system: context, decision, and what we gave up.
D1. Model routes, not one-to-one assignments¶
Context. The brief allows a simple resource ↔ request pairing. An engineer handles several jobs
in a shift (the generator caps each at 4–6, max_jobs, a modelling choice rather than a measured fab
fact), and the order decides whether later windows are reachable.
Decision. Each engineer gets an ordered route, so this is a VRPTW with skills (technician routing
and scheduling).
Trade-off. Harder than bipartite matching. It's also why Hungarian has to run in rounds, which turned
out to be one of the more interesting findings.
D2. One constraint engine shared by every strategy¶
Decision. All feasibility and cost logic lives in planner.py. External solvers (PyVRP) are
replayed through it.
Why. Comparisons are only fair if every strategy is scored by identical rules. It also means
hard-constraint tests cover all strategies at once.
D3. Manhattan distance on a floor plan, SVG instead of a map¶
Decision. Positions are metres on the fab floor whose size comes from the fab profile (400 × 240 m for fab 1, 300 × 180 m for fab 2), and walking is Manhattan distance along aisles. Rendered as SVG. Why. That's how people move in a fab, and it needs no map tiles or keys. The brief explicitly allows SVG.
D4. Which advanced algorithms¶
Context. Literature review: ALNS (Ropke & Pisinger 2006; Cordeau, Laporte, Pasin & Ropke 2010
and Kovacs et al. 2012, applied to technician routing with skill levels) and HGS/ILS (Vidal; PyVRP)
dominate. On the published VRPTW benchmarks, PyVRP reaches about 0.2–0.8% gaps against about 10% for
OR-Tools. The pinned pyvrp==0.14.0 ships iterated local search; its hybrid genetic search is the
method behind the competition results (ANALYSIS §4).
Decision. Add PyVRP (best quality per second, 1.2 MB wheel, MIT), a hand-written ALNS (optimises
our exact objective including balance), and an exact MILP baseline (SciPy/HiGHS, no extra dependency).
Rejected. OR-Tools routing: heavier install (pandas, protobuf), slower cold starts on serverless, and
weaker than PyVRP at short time limits. VROOM: its lexicographic objective can't express our weights.
D5. Deterministic solvers¶
Decision. Seeds are fixed. Search stops on an iteration budget, and time is only a safety cap. Why. Users shouldn't see plans change on refresh, and determinism makes content-addressed caching valid. Results that hit the time cap aren't shared across instances.
D6. Two-tier plan cache keyed by content¶
Decision. SHA-256 of the canonical input plus ENGINE_VERSION. An in-process LRU first, then a
plan_cache table.
Why. Slider back-and-forth and multiple viewers are the common case. A database tier means a cold
serverless instance still benefits. Bumping the version invalidates everything without a migration.
D7. SQLite locally, Postgres in production, behind one Store¶
Context. The brief requires running locally with no keys. Production needs durable shared state.
Decision. create_store() picks SQLite (default) or Postgres (FAB_DATABASE_URL, Supabase-ready:
psycopg 3, no prepared statements for the transaction pooler). The same contract tests run against both.
Trade-off. Two implementations to keep in step, which the shared test suite enforces.
D8. SSE over the event log, not WebSockets or Supabase Realtime¶
Context. Vercel functions can't hold WebSockets. Supabase Realtime would lock the realtime path to
one vendor and wouldn't work locally without keys.
Decision. An append-only shift_events table, and SSE that tails it by id with Last-Event-ID
resume and a 25 s response window.
Why. Works identically on a laptop, in Docker and on serverless. Multi-instance fan-out is free
because the database is the bus. Polling the log while a stream is open is cheap at dashboard scale,
and it backs off when a shift is quiet (every 0.25 s while busy, up to 2 s idle). Postgres
LISTEN/NOTIFY is the upgrade if streams ever number in the thousands.
D9. Server-owned clock, client-driven¶
Decision. The live shift's clock only moves when a client calls advance.
Why. No background process is needed, which suits serverless. In a real fab the clock would be wall
time and events would come from the MES; the same report_job / advance entry points would serve.
D10. Stability penalty in live re-planning¶
Decision. Re-assigning a job away from its previous engineer costs Weights.stability (default 25).
Why. Re-optimising from scratch on every event produces "nervous" plans that would reshuffle the
whole floor for a 1% gain. People need plans that stay put.
D11. Repair history: k-NN with a dependency-free embedder¶
Decision. Hashed n-gram embeddings and k-NN regression for duration. The search backend was
Qdrant at first; since D28 it's an exact NumPy search, with Qdrant used only when a server is
configured.
Why. Satisfies the "ML-based" option in a way that can be explained: every prediction comes with
the neighbours behind it. No model download, so it works offline and cold-starts fast.
HashEmbedder can be swapped for a neural model (e.g. fastembed) without changing anything else.
Honest limit. The history is synthetic. The pipeline is the deliverable, not the predictions.
D12. Progressive, cancellable UI requests¶
Decision. One request per strategy, in parallel. Results render as they arrive, stale requests are aborted, and the previous results stay on screen while new ones solve. Why. Greedy answers in milliseconds and PyVRP in about a second. Waiting for the slowest would waste the fast ones.
D13. Visual system: one ink, one signal¶
Decision. Warm neutrals, Geist and Geist Mono, hairline rules, square corners, and a single orange signal colour for what deserves attention: the recommended plan, live state, bottleneck work. Light by default, a separately tuned dark theme, and the system setting is respected. Why. Five hues for five strategies competed with the content. Every strategy is already named on its mark, so colour can do one job, "this is the recommendation", instead of five. Contrast was checked for both themes, with the orange stepped darker for text on light backgrounds (5.4:1).
D14. "Best value" recommendations that refuse false positives¶
Context. Cost, latency and coverage pull against each other, and on a single shift small cost differences are mostly noise. Decision. The default goal applies three steps in order: 1. Never give up bottleneck or priority coverage. 2. Treat every plan within a noise margin (2% or 5 points) of the cheapest as equally cheap. 3. Among those, pick the fastest to compute.
The benchmark goes further: a strategy is only "worse" when the paired bootstrap 95% CI of its cost difference against the best excludes zero. Otherwise it's reported as tied. Why. Paying 1 s of latency for a 1% "saving" that wouldn't replicate is the false positive to avoid. A slower solver has to earn its latency with a difference that is real.
D15. Embedded Qdrant is in-memory per process (superseded by D28)¶
Context. On-disk embedded Qdrant locks its folder exclusively, so a second process (a reloader,
a second uvicorn worker, a second container) failed with 500s.
Decision. In memory by default. The index is deterministic and rebuilds in about 0.6 s. On-disk is
opt-in (FAB_QDRANT_PATH) and falls back to memory if locked. Shared state belongs on a Qdrant server
(FAB_QDRANT_URL).
Superseded. D28 made exact NumPy search the default, which removed the embedded index (and this
problem) entirely. The fallback still applies when FAB_QDRANT_PATH is set.
D16. Search budgets sized from the quality curve, not by feel¶
Context. On Vercel, PyVRP at 3000 iterations hit the 3 s cap: slower, non-reproducible, and
uncacheable.
Decision. ALNS 300 and PyVRP 1000 iterations. Going higher (ALNS 600, PyVRP 3000) doubles or
triples latency for a further 0.1–0.5 points of cost. Configurable, and part of the cache key.
Honest note. By D14's own rule (plans within 2% are tied; take the fastest), PyVRP's knee is
250 iterations, not 1000: the measured curve (ANALYSIS, "Sizing the search budget") shows 1000
buying 0.6 points of cost over 250 at 3.7× the latency (294 vs 80 ms), inside the noise margin.
1000 is kept because every figure in ANALYSIS was measured at that budget and the plan cache keys on
it; a deployment that values latency should set FAB_PYVRP_ITERATIONS=250 and lose nothing the
recommendation rule would notice.
D17. Fab-specific knowledge is data (fab profiles), not code¶
Context. The first build hard-coded one fab: its floor, tool families, shift and fault
catalogue. Each new customer site would have meant a code change and a release.
Decision. One validated JSON profile per fab (backend/app/fabs/profiles). The generator, repair
history, API and UI all read it. A second fab (200mm, 8-hour shifts, photo as the constraint) ships
alongside. Honestly described, it differs from fab 1 in constants: tool families and their bays,
fault catalogue, shift length and start, floor size, walking speed and response SLAs. It uses the
same generator template (the same staffing rule, PM-duration set and job-mix mechanics), so it
proves that the engine reads everything from the profile, not that the engine copes with a
structurally different fab (two-person jobs, a second bottleneck, split shifts). Fab 1 was converted
with a golden test proving its generated scenarios are byte-identical to the old hard-coded
generator; the golden hashes are regenerated whenever the generator's rules change on purpose.
Trade-off. Profile authors must describe the floor and fault catalogue. The onboarding guide and
schema validation keep that tractable.
D18. Authentication via a managed identity provider; demo sign-in only locally¶
Decision. Supabase Auth in UAT and production. The API verifies Supabase-issued tokens: JWKS for
asymmetric keys, falling back to the legacy HS256 secret. Locally, a one-click demo sign-in keeps the
"runs locally with no keys" requirement. Settings refuse demo mode in production unless
explicitly allowed with a non-default secret (fail closed).
Why. Passwords, resets, magic links and MFA are a product in their own right; storing passwords
here would add risk and no value. Roles (viewer < dispatcher) and fab access come from
app_metadata, which users can't edit, with an email allow-list to bootstrap the first dispatchers.
D19. Tenancy by fab, and 404 rather than 403 across tenants¶
Decision. Every fab-specific request and every live shift is checked against the user's fab list. A shift or fab outside it returns 404, so one customer can't confirm another's fab exists.
D20. Versioned, additive migrations under an advisory lock¶
Decision. Numbered migrations recorded in schema_migrations, applied at startup under a
Postgres advisory lock. Additive only; destructive changes are split across releases. Health
reports schema_ok, which the post-deploy smoke test checks.
Why. "Create if missing" can't evolve a schema. A runner that serialises serverless cold starts
and is visible in health checks can.
D21. Real-time safety: clock lease, idempotency keys, presence¶
Decision. Only one dispatcher drives a shift's clock: a 20 s lease, renewed by each tick and
released on pause. Mutating live calls accept an Idempotency-Key; a retry returns the first
response. Open SSE streams heartbeat presence.
Why. Two people pressing Play, or a flaky network retrying "advance 60 minutes", would otherwise
corrupt the shift. These are the failure modes of a multi-user real-time tool.
D22. Promotion pipeline: main is UAT, tags are production¶
Decision. Pull requests get CI and a preview deploy. main deploys to UAT (its own database).
A SemVer tag re-runs CI on that commit, must already be on main, and fast-forwards the
production branch that Vercel deploys from. Every deployment is smoke-tested.
Why. Nothing reaches production without passing UAT and CI on the exact commit. Rollback is
promoting the previous deployment, and production can't move backwards by accident.
D23. Three test layers; the UI is tested against captured API responses¶
Decision. pytest for the engine and API; Vitest + Testing Library for components, rendering JSON
exported from the real API (scripts/export_ui_fixtures.py) behind a fake fetch; Playwright for
end-to-end flows against the real API and UI on desktop and a phone viewport. A backend test
compares the committed fixtures' shape with live responses. Tests query by role and accessible name,
not CSS classes.
Why. Hand-written mocks drift silently from the API, so component tests pass against data that
no longer exists; the contract test turns that drift into a failure. Component tests are fast and
precise; only a browser proves SSE, the Vite proxy, layout and touch work together. Querying by role
keeps the UI accessible: the floor plan's jobs became real buttons because the tests needed them to be.
D24. A grounded assistant: retrieval and tools first, an LLM only to reword¶
Decision. The assistant classifies the question, then answers only from tools: retrieval over the help articles (lexical: hashed n-gram vectors plus keyword, heading and section-text matches) and the planning service for the shift on screen. Every answer carries citations and actions. Below a measured retrieval score it says "I don't know". An LLM is optional and only rewords the grounded answer; citations and actions never come from it. Why. A dispatcher acts on these answers, so a confident wrong one is worse than none. Grounding makes answers checkable and identical to what the screen shows; the refusal threshold (0.5) is set on an evaluation set that runs in CI end to end (keyword phrasings, paraphrases and off-topic questions, figures in ai.md), and tasks the app doesn't do are refused by rule. It also meets the brief's no-keys rule, and makes the LLM a swappable enhancement, not a dependency.
D25. One-click guest access for the public demo¶
Decision. Supabase anonymous sign-in behind a "Try it as a guest" button; guests get
FAB_GUEST_ROLE (dispatcher on the public demo, viewer by default, or refused).
Why. Supabase's built-in email sender only reaches the project's own team, and a reviewer
shouldn't depend on email at all. Anonymous users are real Supabase users, so the API's verification,
rate limits and fab scoping apply unchanged, and a guest can't escalate.
D26. One source for every document, and a site that builds strictly¶
Decision. The documentation site (MkDocs Material on GitHub Pages) generates its user guide from
the in-app help articles, its project pages from CHANGELOG/SECURITY/CONTRIBUTING, and its API
reference from the running app's OpenAPI schema. It builds with --strict on every PR. Tests keep
the site's navigation, the app's docs links and the release version in step.
Why. Documentation that's written twice drifts. Here the help center, the assistant's knowledge
and the public docs are the same text, and a broken link fails CI instead of a reader.
D27. Make the first five minutes effortless¶
Decision. Three ways in, each one step: the live app with guest access, GitHub Codespaces, and
python3 run.py (versions checked, dependencies installed once, both servers started, browser
opened). In the app: a tour, contextual help, About & support, and a crash screen with a prefilled
report.
Why. A product is judged in its first minutes. Every extra step between "curious" and "using it"
loses people, and a confusing failure loses them for good.
D28. Exact vector search until the data needs a vector database¶
Decision. Repair-history k-NN runs as an exact NumPy dot product by default; Qdrant is used only
when FAB_QDRANT_URL (or FAB_QDRANT_PATH) is set.
Why. 1,800 repairs per fab is a 0.1 ms matrix product. A vector database added a dependency and
~0.6 s to every cold start for no accuracy gain (exact search is more accurate than approximate).
The interface is unchanged, so real history at millions of repairs moves to Qdrant with one setting.
D29. A relational read model beside the shift document¶
Decision. Each write to a shift also replaces its rows in assignments, in the same transaction.
Why. The JSON document is the right write model: one atomic, version-checked re-plan. But the
domain must be queryable: "what did E03 work on this week?" shouldn't parse documents. Writing both
in one transaction means the read model can never disagree with the truth.
D30. Rate limits in two layers¶
Decision. An in-memory token bucket per instance rejects bursts without touching the database; each allowed
request from a signed-in caller costs one upsert to a per-minute counter in the database, which
enforces the quota across instances. If the database is unreachable, cheap requests
pass; the expensive endpoints (benchmarks, the optimality gap, starting a shift, an LLM-backed
answer) refuse with 503 and Retry-After instead. Unauthenticated endpoints use the in-memory
bucket only.
Why. On serverless every instance counts alone, so an in-memory limit alone barely limits. The
shared counter costs one upsert, only for signed-in callers on the endpoints that need it (a request
that cost nothing to authenticate must not cost a database write). Failing open keeps a database
blip from taking the whole API down with it, but only where a flood would be cheap; where it
wouldn't be, the limiter is the thing that must hold.
D31. An ingestion API for equipment systems¶
Decision. POST /api/ingest/tool-downs, authenticated by a per-fab token stored as a SHA-256
hash, with a mandatory Idempotency-Key; the tool-down joins the fab's live shift.
Why. In a fab, tools and the MES detect faults, not dispatchers. Integrations retry, so
idempotency is required, not optional. A token scoped to one fab and one action limits what a leaked
token can do.
D32. Self-serve account deletion that keeps the audit trail¶
Decision. DELETE /api/auth/me deletes a user's feedback, presence and pending replays, replaces
their email in shared shift history with "a deleted user", and on Supabase removes the sign-in
account.
Why. Privacy law gives users the right to erasure without having to ask. Shift history belongs to
the fab, so it's anonymised rather than deleted: the record of what happened stays intact without
identifying anyone.
D33. Spend effort on uncertainty, not on more solvers¶
Decision. Keep five strategies plus the exact optimum; don't add neural or LLM-evolved solvers. Why. The lineup includes the strongest open-source engine for the routing core of this problem (PyVRP: the pinned version runs its iterated local search; the hybrid genetic search behind its DIMACS and EURO-NeurIPS VRPTW wins is a different algorithm from the same library), and the headroom above it is measured, not assumed: proven optimal on 80 full-size shifts, PyVRP is 3.9% from the optimum on average, and the exact method captures that gap when the plan is made ahead of time. The problem class is the technician routing and scheduling problem, and no strategy was run on its published instances (ANALYSIS §4), so "state of the art" is a claim about the VRPTW engine, not the whole problem. Neural solvers at best match HGS, need training and can't explain decisions; LLM-evolved heuristics are a design-time tool for improving operators. The research frontier for technician routing is dynamic and stochastic dispatch, so that's where the next improvement lies, alongside making the exact optimum fast enough for live use (ANALYSIS §4).
D34. A database per fab¶
Decision. FAB_TENANT_DATABASES gives a fab its own database for all of its operational data;
shift ids carry the fab so requests route without a lookup; user-level data stays in the default
database.
Why. Fab data (tool faults, repair history, staffing) is commercially sensitive, and customers
expect it to be separable: backed up, retained, moved or deleted per fab. A shared database with a
fab_id filter isolates logically; a database per fab isolates physically and scales out with the
number of fabs. The next step, where a fab requires it, is a deployment per fab on site, which needs
no code change: the same images pointed at that fab's database.
D35. Scale by decomposition, and measure it¶
Decision. Treat a large organisation as many small problems, split by fab, skill group, shift and area (about 15–60 engineers each), solved independently and in parallel. Don't build one solver for 20,000 engineers. Why. Engineers don't cross fabs, certifications split a fab into near-independent groups, and dispatch is per shift and area, so the split should lose little. What was measured is the speed of the decomposition, not its cost: in the scale study the groups are independent by construction (each area is generated and planned on its own, a fresh plan rather than a re-plan), so the 14.6 s for a 20,000-engineer company's 222 areas (6,660 on shift) on a laptop with 16 workers and no area hitting the solver's cap, and the 83 ms median live re-plan of one tool-down, show that the split is fast, not that it is lossless. What the studies do show about quality is that solving the same work undecomposed is slower and further from optimal: PyVRP's certified gap grows from 3.9% at 14 × 45 to at most about 11% at 20 × 65 and 12–13% at 30 × 100 (one seed), and at 300 × 1,000 a 3 s search does not improve on its regret-2 warm start at all (ANALYSIS, "Scale"). Trade-off. Work that genuinely crosses a boundary (a shared expert, a cross-bay job) needs a coordination step between areas; at the measured sizes that hasn't been needed.
D36. Prove optimality gaps instead of estimating them¶
Decision. Measure every strategy against the proven optimum at the size it runs: exact set partitioning (route enumeration + MILP) on all 80 benchmark shifts at 14 × 45, and certified bounds by column generation where full enumeration stops scaling (30 × 100: optimum between 1002.8 and 1012.7). Why. Gaps measured on small shifts flattered the heuristics: ALNS and PyVRP are within 0.2% of the optimum on 4 × 12 and 5.7% and 3.9% from it at 14 × 45. A claim about quality is only as good as the size it was measured at. The study fails if any heuristic beats the "optimum", which guards the proof itself.