diff --git a/.gitattributes b/.gitattributes
index 7ebe0b9..1d558c2 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,2 +1,2 @@
-models/*.json text eol=lf
-evidence/*.json text eol=lf
+# Vite's committed production bundle contains third-party template-literal whitespace (not authored source).
+web/assets/*.js -whitespace
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f58f246..d541735 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -4,9 +4,11 @@ on:
push:
branches: [main]
pull_request:
+ schedule:
+ - cron: "17 3 * * 1"
permissions:
- contents: write
+ contents: read
jobs:
simulator:
@@ -16,71 +18,81 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- - name: Unit tests
+ - name: Python unit and contract tests
run: python -m unittest discover -s tests -v
- - name: Deterministic experiment smoke test
+ - name: Deterministic experiment smoke
run: python scripts/run_experiment.py --scenario evening --seconds 180 --seeds 2 --output /tmp/elevator-smoke.json
- - name: Destination-control experiment smoke test
+ - name: Destination-control smoke
run: python scripts/run_experiment.py --scenario evening --seconds 120 --seeds 3 --control-mode destination --output /tmp/elevator-destination-smoke.json
- - name: M3 30-seed six-scenario evidence matrix
- if: github.event_name == 'pull_request'
- run: |
- mkdir -p evidence
- python scripts/run_experiment.py --matrix --seconds 180 --seeds 30 --output evidence/m3-evidence.json
- - name: M3 statistical regression baseline
- if: github.event_name == 'pull_request'
- run: python scripts/check_regression_baseline.py evidence/m3-evidence.json
- - name: Upload M3 evidence artifacts
- if: github.event_name == 'pull_request'
- uses: actions/upload-artifact@v4
+ - name: Versioned artifact and trace validation
+ run: python scripts/validate_artifacts.py
+ - name: Release audit
+ run: python scripts/audit_release.py
+
+ frontend:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
with:
- name: m3-statistical-evidence
- path: evidence/
- retention-days: 14
+ node-version: "22"
+ cache: npm
+ cache-dependency-path: frontend/package-lock.json
+ - name: Install frontend dependencies
+ run: npm ci --prefix frontend
+ - name: TypeScript
+ run: npm run typecheck --prefix frontend
+ - name: Frontend unit tests
+ run: npm test --prefix frontend
+ - name: Production build into web
+ run: npm run build --prefix frontend
+
+ browser:
+ runs-on: ubuntu-latest
+ needs: [simulator, frontend]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
- uses: actions/setup-node@v4
- if: github.event_name == 'pull_request' && github.head_ref == 'feat/m4-digital-twin-replay'
with:
node-version: "22"
- - name: Install browser QA dependencies
- if: github.event_name == 'pull_request' && github.head_ref == 'feat/m4-digital-twin-replay'
+ cache: npm
+ - name: Install browser and frontend dependencies
run: |
- npm install
+ npm ci
+ npm ci --prefix frontend
+ npm run build --prefix frontend
npx playwright install --with-deps chromium
- - name: Start simulator web server
- if: github.event_name == 'pull_request' && github.head_ref == 'feat/m4-digital-twin-replay'
- run: |
- python -m app.server --port 4173 > /tmp/elevator-server.log 2>&1 &
- echo $! > /tmp/elevator-server.pid
- for attempt in {1..30}; do
- if curl --fail --silent http://127.0.0.1:4173/api/snapshot > /dev/null; then
- exit 0
- fi
- sleep 1
- done
- cat /tmp/elevator-server.log
- exit 1
- - name: M4 browser API/replay consistency
- if: github.event_name == 'pull_request' && github.head_ref == 'feat/m4-digital-twin-replay'
+ - name: Playwright E2E, responsive and visual regression
run: npm run test:e2e
- - name: Preserve M4 README screenshot on PR branch
- if: github.event_name == 'pull_request' && github.head_ref == 'feat/m4-digital-twin-replay' && hashFiles('docs/assets/m4-dashboard.png') == ''
- env:
- HEAD_BRANCH: ${{ github.head_ref }}
- run: |
- git fetch origin "$HEAD_BRANCH"
- git switch -C "$HEAD_BRANCH" "origin/$HEAD_BRANCH"
- mkdir -p docs/assets
- cp artifacts/m4-dashboard-desktop.png docs/assets/m4-dashboard.png
- git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git add docs/assets/m4-dashboard.png
- git commit -m "docs(m4): capture validated digital twin"
- git push origin "HEAD:$HEAD_BRANCH"
- - name: Upload M4 visual evidence
- if: always() && github.event_name == 'pull_request' && github.head_ref == 'feat/m4-digital-twin-replay'
+ - name: Upload browser QA evidence
+ if: always()
uses: actions/upload-artifact@v4
with:
- name: m4-visual-evidence
- path: artifacts/
+ name: m8-browser-qa
+ path: |
+ artifacts/
+ test-results/
if-no-files-found: warn
retention-days: 14
+
+ statistical-regression:
+ if: github.event_name == 'push' || github.event_name == 'schedule' || contains(github.event.pull_request.labels.*.name, 'full-statistical-regression')
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+ - name: M3 30-seed six-scenario evidence matrix
+ run: python scripts/run_experiment.py --matrix --seconds 180 --seeds 30 --output /tmp/m3-evidence.json
+ - name: M3 statistical regression baseline
+ run: python scripts/check_regression_baseline.py /tmp/m3-evidence.json
+ - name: Upload statistical evidence
+ uses: actions/upload-artifact@v4
+ with:
+ name: m3-statistical-regression
+ path: /tmp/m3-evidence.json
+ retention-days: 14
diff --git a/.gitignore b/.gitignore
index bed71b1..ba6402d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,9 @@ coverage/
.DS_Store
Thumbs.db
*.log
+*.tsbuildinfo
experiment-*.json
trace-*.json
+frontend/vite.config.js
+frontend/vite.config.d.ts
diff --git a/README.md b/README.md
index 481f238..20d6844 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,30 @@
# Elevator Queue Lab
-**A reproducible elevator group-control research lab for an 18-floor office building.**
+**A reproducible decision-intelligence workbench for elevator group-control research.**
**Live demo:** [https://elevator.oosu.dev/](https://elevator.oosu.dev/)
-Elevator Queue Lab starts from a practical failure mode: a hall call is assigned to a car, that
-car later becomes full or follows a poor route, and the passenger waits while the controller keeps
-treating the stale assignment as valid. The project turns that observation into a controlled
-simulation and optimization problem.
+Elevator Queue Lab lets a reviewer **simulate, observe, inspect, explain, replay, compare and
+falsify** dispatch decisions from passenger-level evidence. It starts from a practical failure
+mode: a hall call is assigned to a car, that car later becomes full or follows a poor route, and
+the passenger waits while the controller keeps treating the stale assignment as valid.
+
+## 30-second orientation
+
+| Question | Answer |
+| --- | --- |
+| **What problem is being studied?** | Stale elevator assignments under stochastic load: a car that looked good at assignment time may become a poor or impossible pickup before it arrives. |
+| **What is CAPR?** | Capacity-Aware Predictive Reassignment: route-insertion ETA + predicted residual capacity + route/load/age scoring + continuous reassignment with hysteresis. |
+| **What did the experiments show?** | CAPR is **traffic-regime dependent**, not globally superior. Lunch is a clean M3 candidate improvement; several other regimes expose wait/energy trade-offs or no mean-wait win. |
+| **What is Counterflow Criticality?** | M7 evidence supports a fuzzy congestion × counterflow transition: continuous reassignment becomes more valuable when traffic intensity and opposing directional flow rise together. The fitted threshold is **not** a universal critical constant. |
+| **Did RL beat the heuristics?** | No general superiority was established. The fixed M5 Dueling Double DQN improves only the held-out mixed-day mixture cleanly; five base regimes regress versus collective. |
+| **Can I reproduce it?** | Yes. The repository keeps deterministic traces, versioned provenance, committed statistical evidence, a production Python server path, unit/contract tests, Playwright E2E and visual regression. |
+
+The product flow is:
+
+```text
+SIMULATE → OBSERVE → INSPECT → EXPLAIN → REPLAY → COMPARE → FALSIFY
+```
The target building has **18 floors and six passenger elevators**: three low-zone cars and three
high-zone cars. The default synthetic workplace mix is deliberately lobby-centric: **85% of trips
@@ -17,11 +34,12 @@ passenger is represented from arrival at a hall call
through boarding and destination arrival, so dispatch decisions can be evaluated on passenger
outcomes instead of visual car movement alone.
-
+
-The screenshot above is captured from the deployed public simulator at `elevator.oosu.dev` after
-Chromium verifies the live UI/API contract: all six cars, all 18 floors, the evidence cards, the
-learned-policy control path and simulator audit. It is not a mockup or static chart fixture.
+The screenshot above is captured by Playwright from the real Python server and production React
+build after CAPR has produced a reassignment. It is not a mockup or static chart fixture. The public
+demo uses the same single-process Python/static-serving contract; this exact M8 commit is only
+considered publicly verified after it has been deployed and remote QA has passed.
> **Research takeaway — congestion alone is not the trigger.** Continuous predictive reassignment
> becomes valuable when **heavy traffic and enough counterflow rise together**, because opposing
@@ -44,7 +62,12 @@ superiority**.
## Current executable surface
+- React + TypeScript authored frontend in `frontend/`, with Vite producing the committed `web/`
+ production artifact so `python -m app.server --port 4173` remains the one-command runtime;
+- Decision Intelligence Product Shell with Live Operations, Runs, Dispatch Analysis, Experiments,
+ Counterflow Criticality, Models and Object Explorer workbenches;
- deterministic origin/destination passenger traces with canonical JSON + SHA-256 identity;
+- versioned trace/run/dispatch/evidence artifact catalog with seed/scenario/policy/config provenance;
- sub-second six-car simulator with acceleration/deceleration, doors and passenger transfer time;
- low/high zoned banks and both conventional hall-call and destination-control grouping;
- sticky, nearest-car, collective, queue-aware and CAPR dispatch policies;
@@ -55,6 +78,13 @@ superiority**.
- floor queue heatmap plus live/replay wait and queue time series sourced only from simulator state;
- dispatch event stream and candidate-level decision inspector for assignment/reassignment reasoning;
- deterministic saved-run replay with a timeline scrubber and live/replay state switching;
+- browseable Elevator/Passenger/HallCall/DispatchDecision/Run/Experiment/Model/Evidence objects;
+- read-only Decision Trace graph projected from simulator state, decision history and the event
+ ledger, with pan/zoom/fit-view and inspector linkage;
+- deterministic **Ask This Run** explanations backed by recorded evidence and committed M3
+ comparisons; no LLM is required or treated as a source of truth;
+- typed semantic `ChartSpec` validation and registered React renderers instead of arbitrary
+ generated HTML/JavaScript;
- 30-seed common-random-number experiment engine with morning/lunch/normal/evening/shock/mixed-day;
- P50/P95/P99 wait, journey time, throughput, unfinished queue, reassignment latency, floor fairness,
capacity misses and a transparent comparative energy proxy;
@@ -70,7 +100,9 @@ superiority**.
- `rl` runtime policy selectable in the live digital twin using the checked-in M5 model;
- JSON + run-level CSV + summary CSV evidence artifacts, paired effect sizes and guardrail flags;
- Playwright browser verification that visible metrics, all six cars and all 18 floor queues match API/replay state;
-- responsive desktop/mobile visual QA with screenshots generated from that same verified browser run.
+- deterministic visual regression for Live Operations desktop/mobile, Replay, Experiments, Theory,
+ Models and Explorer/Decision Trace;
+- `npm run capture:showcase` portfolio capture from the real backend and production frontend.
## 30-seed evidence: CAPR is regime-dependent
@@ -173,21 +205,33 @@ limitations, and `docs/M6_EVIDENCE_SUMMARY.md` for tables generated directly fro
## Run locally
-Python 3.11+ is enough for the simulator and research server.
+Python 3.11+ is enough to run the committed production workbench. Node is only required when
+changing or rebuilding the React frontend.
```bash
python -m app.server --port 4173
```
Open `http://127.0.0.1:4173`. The UI can switch traffic regime, policy, simulation speed and
-conventional versus destination-control call input, save the current run, scrub deterministic
-replay frames and inspect the M3 comparison evidence.
+conventional versus destination-control call input, save and scrub replay, inspect dispatch
+decisions, browse run objects and Decision Trace, query deterministic explanations and inspect
+committed M3/M5/M7 evidence.
+
+Frontend contributor gates:
+
+```bash
+npm ci --prefix frontend
+npm run typecheck --prefix frontend
+npm test --prefix frontend
+npm run build --prefix frontend
+```
Run validation and evidence generation:
```bash
python -m unittest discover -s tests -v
python scripts/generate_trace.py --scenario evening --seconds 600 --seed 7 --output /tmp/evening-trace.json
+python scripts/generate_trace.py --scenario evening --seconds 600 --seed 7 --output /tmp/evening-trace.json --package-dir /tmp/evening-trace-package
python scripts/run_experiment.py --scenario evening --seconds 600 --seeds 3
python scripts/run_experiment.py --scenario evening --seconds 600 --seeds 3 --control-mode destination
python scripts/run_experiment.py --matrix --seconds 180 --seeds 30 --output evidence/m3-evidence.json
@@ -201,24 +245,32 @@ python scripts/audit_release.py
For browser/API visual verification:
```bash
-npm install
+npm ci
+npm ci --prefix frontend
+npm run frontend:build
npx playwright install chromium
-python -m app.server --port 4173
npm run test:e2e
+npm run capture:showcase
```
+Playwright starts the production Python server automatically unless `BASE_URL` points at an
+already-running instance. Intentional visual-baseline updates use `npm run test:e2e:update`.
+
The matrix command emits a self-describing JSON artifact plus `*.runs.csv` and `*.summary.csv`.
Artifacts explicitly record a zero-second warm-up and the measurement window used by the run.
## Project status
-**M0 reproducibility through M6 portfolio release are implemented.** The fixed M5 checkpoint has a
-30-seed held-out release artifact, the final report/plots are generated from committed evidence,
-and the public Mac mini deployment is live at `https://elevator.oosu.dev/`. External Chromium QA
-verifies HTTP 200, six cars, all 18 floors, five evidence cards, the RL `mixed_day` control path,
-simulator audit success, zero failed browser requests and zero console errors.
-`docs/ROADMAP.md` is the canonical work queue and `AGENTS.md` defines the continuation contract for
-future coding sessions.
+**M0 through M8 are implemented in the repository.** The research simulator, 30-seed evidence,
+negative/mixed learned-controller result and M7 falsification evidence are preserved; M8 adds the
+typed decision-intelligence product layer without rewriting the simulator core. See
+`docs/M8_PRODUCT_WORKBENCH.md` for the architecture and migration contract.
+
+The existing public Mac mini demo remains at `https://elevator.oosu.dev/`. A local or PR M8 result
+is not treated as proof that the public instance has been upgraded: deployment and remote
+Chromium/API QA must be run against the exact M8 commit before that claim is made.
+`docs/ROADMAP.md` remains the canonical milestone record and `AGENTS.md` defines the continuation
+contract.
## Methodology references
@@ -239,4 +291,4 @@ MIT, unless a later dependency or imported dataset requires a narrower notice.
[`control-systems`](https://github.com/topics/control-systems) · [`dispatch-algorithm`](https://github.com/topics/dispatch-algorithm) · [`elevator`](https://github.com/topics/elevator) · [`elevator-control`](https://github.com/topics/elevator-control) · [`gymnasium`](https://github.com/topics/gymnasium) · [`machine-learning`](https://github.com/topics/machine-learning) · [`operations-research`](https://github.com/topics/operations-research) · [`optimization`](https://github.com/topics/optimization) · [`queueing-theory`](https://github.com/topics/queueing-theory) · [`reinforcement-learning`](https://github.com/topics/reinforcement-learning) · [`research-tool`](https://github.com/topics/research-tool) · [`simulation`](https://github.com/topics/simulation) · [`traffic-simulation`](https://github.com/topics/traffic-simulation)
**Implementation stack / 구현 스택**
-[`python`](https://github.com/topics/python)
+[`python`](https://github.com/topics/python) · [`react`](https://github.com/topics/react) · [`typescript`](https://github.com/topics/typescript) · [`playwright`](https://github.com/topics/playwright)
diff --git a/app/artifacts.py b/app/artifacts.py
new file mode 100644
index 0000000..00ce6b0
--- /dev/null
+++ b/app/artifacts.py
@@ -0,0 +1,225 @@
+from __future__ import annotations
+
+import hashlib
+import json
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any
+
+from .domain import SimulationConfig
+from .simulator import ElevatorSimulation
+from .trace import PassengerTrace
+
+
+ROOT = Path(__file__).resolve().parents[1]
+SIMULATOR_VERSION = "0.1.0"
+ARTIFACT_VERSION = "1.0.0"
+
+
+def _created_at() -> str:
+ """Return an ISO-8601 timestamp for transient artifact envelopes."""
+
+ return datetime.now(UTC).isoformat()
+
+
+def _sha256_json(payload: object) -> str:
+ """Hash a JSON-compatible value with deterministic serialization."""
+
+ raw = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
+ return hashlib.sha256(raw).hexdigest()
+
+
+def build_passenger_trace_manifest(
+ trace: PassengerTrace,
+ config: SimulationConfig,
+) -> dict[str, Any]:
+ """Build the canonical versioned manifest for a materialized passenger trace."""
+
+ identity_payload = {
+ "scenario": trace.scenario,
+ "seed": trace.seed,
+ "building": config.as_dict(),
+ "trace_sha256": trace.digest,
+ }
+ return {
+ "schema_version": "elevator-queue-lab.trace-manifest.v1",
+ "artifact_version": ARTIFACT_VERSION,
+ "generator_version": "elevator-queue-lab.demand.v2",
+ "seed": trace.seed,
+ "scenario": trace.scenario,
+ "building_configuration": config.as_dict(),
+ "traffic_contract": "office-demand-85-10-5",
+ "duration_seconds": trace.duration_seconds,
+ "sha256": trace.digest,
+ "identity_sha256": _sha256_json(identity_payload),
+ "created_at": _created_at(),
+ "source": "materialized_trace",
+ }
+
+
+def build_trace_manifest(simulation: ElevatorSimulation) -> dict[str, Any]:
+ """Describe the deterministic passenger source without mutating it."""
+
+ if simulation.trace is not None:
+ return build_passenger_trace_manifest(simulation.trace, simulation.config)
+ identity_payload = {
+ "scenario": simulation.scenario,
+ "seed": simulation.seed,
+ "building": simulation.config.as_dict(),
+ "trace_sha256": None,
+ }
+ return {
+ "schema_version": "elevator-queue-lab.trace-manifest.v1",
+ "artifact_version": ARTIFACT_VERSION,
+ "generator_version": "elevator-queue-lab.demand.v2",
+ "seed": simulation.seed,
+ "scenario": simulation.scenario,
+ "building_configuration": simulation.config.as_dict(),
+ "traffic_contract": "office-demand-85-10-5",
+ "duration_seconds": int(simulation.sim_time),
+ "sha256": None,
+ "identity_sha256": _sha256_json(identity_payload),
+ "created_at": _created_at(),
+ "source": "seeded_demand_generator",
+ }
+
+
+def build_run_artifact(simulation: ElevatorSimulation, run_id: str) -> dict[str, Any]:
+ """Create a versioned envelope around the current simulator run."""
+
+ snapshot = simulation.snapshot()
+ trace_manifest = build_trace_manifest(simulation)
+ created_at = _created_at()
+ provenance = {
+ "schema_version": "elevator-queue-lab.provenance.v1",
+ "artifact_version": ARTIFACT_VERSION,
+ "simulator_version": SIMULATOR_VERSION,
+ "source": "live_simulator",
+ "seed": simulation.seed,
+ "scenario": simulation.scenario,
+ "policy": simulation.policy_name,
+ "config": simulation.config.as_dict(),
+ "created_at": created_at,
+ "trace_sha256": trace_manifest["sha256"],
+ "trace_identity_sha256": trace_manifest["identity_sha256"],
+ "evidence_source": None,
+ "evidence_sources": [
+ "evidence/m3-regression-baseline.json",
+ "evidence/m5-heldout-evaluation.json",
+ "evidence/m6-heldout-30seed.json",
+ "evidence/m7-bidirectional-load-sweep.json",
+ "evidence/m7-threshold-validation.json",
+ ],
+ }
+ return {
+ "schema_version": "elevator-queue-lab.run.v1",
+ "artifact_version": ARTIFACT_VERSION,
+ "simulator_version": SIMULATOR_VERSION,
+ "run_id": run_id,
+ "scenario": simulation.scenario,
+ "policy": simulation.policy_name,
+ "seed": simulation.seed,
+ "sim_time": snapshot["sim_time"],
+ "trace_sha256": trace_manifest["sha256"],
+ "metrics": snapshot["metrics"],
+ "provenance": provenance,
+ "trace_manifest": trace_manifest,
+ "created_at": created_at,
+ }
+
+
+def build_artifact_catalog(simulation: ElevatorSimulation, run_id: str) -> dict[str, Any]:
+ """Expose committed evidence and the live run as typed artifact references."""
+
+ created_at = _created_at()
+ trace_manifest = build_trace_manifest(simulation)
+ live_provenance = {
+ "schema_version": "elevator-queue-lab.provenance.v1",
+ "artifact_version": ARTIFACT_VERSION,
+ "simulator_version": SIMULATOR_VERSION,
+ "source": "live_simulator",
+ "seed": simulation.seed,
+ "scenario": simulation.scenario,
+ "policy": simulation.policy_name,
+ "config": simulation.config.as_dict(),
+ "created_at": created_at,
+ "trace_sha256": trace_manifest["sha256"],
+ "evidence_source": None,
+ }
+ committed = [
+ ("ExperimentArtifact", "evidence/m3-regression-baseline.json"),
+ ("PolicyEvaluationArtifact", "evidence/m5-heldout-evaluation.json"),
+ ("PolicyEvaluationArtifact", "evidence/m6-heldout-30seed.json"),
+ ("TheoryEvidenceArtifact", "evidence/m7-bidirectional-load-sweep.json"),
+ ("TheoryEvidenceArtifact", "evidence/m7-threshold-validation.json"),
+ ("ModelArtifact", "models/m5-ddqn-baseline.json"),
+ ]
+ artifacts: list[dict[str, Any]] = [
+ {
+ "artifact_type": "SimulationRunArtifact",
+ "schema_version": "elevator-queue-lab.run.v1",
+ "artifact_version": ARTIFACT_VERSION,
+ "artifact_id": run_id,
+ "source": "live_simulator",
+ "sha256": None,
+ "provenance": dict(live_provenance),
+ },
+ {
+ "artifact_type": "PassengerTraceArtifact",
+ "schema_version": trace_manifest["schema_version"],
+ "artifact_version": ARTIFACT_VERSION,
+ "artifact_id": f"{run_id}:trace",
+ "source": trace_manifest["source"],
+ "sha256": trace_manifest["sha256"],
+ "provenance": {
+ **live_provenance,
+ "source": trace_manifest["source"],
+ },
+ },
+ {
+ "artifact_type": "DispatchDecisionArtifact",
+ "schema_version": "elevator-queue-lab.dispatch-decisions.v1",
+ "artifact_version": ARTIFACT_VERSION,
+ "artifact_id": f"{run_id}:dispatch-decisions",
+ "source": "decision_history",
+ "sha256": _sha256_json(simulation.decision_history),
+ "provenance": {
+ **live_provenance,
+ "source": "decision_history",
+ },
+ }
+ ]
+ for artifact_type, relative_path in committed:
+ path = ROOT / relative_path
+ if not path.is_file():
+ continue
+ raw = path.read_bytes()
+ payload = json.loads(raw)
+ artifacts.append(
+ {
+ "artifact_type": artifact_type,
+ "schema_version": payload.get("schema", "unknown"),
+ "artifact_version": ARTIFACT_VERSION,
+ "artifact_id": relative_path,
+ "source": relative_path,
+ "sha256": hashlib.sha256(raw).hexdigest(),
+ "provenance": {
+ "schema_version": "elevator-queue-lab.provenance.v1",
+ "artifact_version": ARTIFACT_VERSION,
+ "simulator_version": SIMULATOR_VERSION,
+ "source": relative_path,
+ "seed": None,
+ "scenario": None,
+ "policy": None,
+ "config": None,
+ "created_at": created_at,
+ "trace_sha256": None,
+ "evidence_source": relative_path,
+ },
+ }
+ )
+ return {
+ "schema": "elevator-queue-lab.artifact-catalog.v1",
+ "run_id": run_id,
+ "artifacts": artifacts,
+ }
diff --git a/app/server.py b/app/server.py
index 81bf5ad..a716330 100644
--- a/app/server.py
+++ b/app/server.py
@@ -6,14 +6,23 @@
import mimetypes
import threading
import time
+import uuid
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
-from urllib.parse import urlparse
+from urllib.parse import parse_qs, urlparse
+from .artifacts import build_run_artifact
from .domain import SimulationConfig
from .simulator import ElevatorSimulation
+from .workbench import (
+ answer_run_question,
+ build_artifacts_payload,
+ build_decision_graph,
+ build_models_payload,
+ build_objects,
+)
ROOT = Path(__file__).resolve().parents[1]
@@ -33,6 +42,7 @@ def __init__(self) -> None:
self.scenario = "morning"
self.policy = "capr"
self.control_mode = "conventional"
+ self.run_id = self._new_run_id()
self.simulation = self._new_simulation()
self.replay_frames: list[dict[str, Any]] = []
self.saved_replay: dict[str, Any] | None = None
@@ -42,6 +52,9 @@ def __init__(self) -> None:
self.thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start()
+ def _new_run_id(self) -> str:
+ return f"run-{uuid.uuid4().hex[:12]}"
+
def _new_simulation(self) -> ElevatorSimulation:
return ElevatorSimulation(
self.scenario,
@@ -104,7 +117,7 @@ def control(self, payload: dict[str, Any]) -> dict[str, Any]:
if "speed" in payload:
self.speed = max(1, min(120, int(payload["speed"])))
requires_reset = (
- action == "reset"
+ action in {"reset", "reset_paused"}
or next_scenario != self.scenario
or next_policy != self.policy
or next_control_mode != self.control_mode
@@ -113,11 +126,12 @@ def control(self, payload: dict[str, Any]) -> dict[str, Any]:
self.scenario = next_scenario
self.policy = next_policy
self.control_mode = next_control_mode
+ self.run_id = self._new_run_id()
self.simulation = self._new_simulation()
self.replay_frames = []
self._last_replay_second = -1
self._record_replay_frame(force=True)
- if action == "pause":
+ if action in {"pause", "reset_paused"}:
self.running = False
elif action in {"start", "reset", "update"}:
self.running = True
@@ -191,6 +205,26 @@ def theory(self) -> dict[str, Any]:
"validation": json.loads(M7_VALIDATION.read_text(encoding="utf-8")),
}
+ def run_artifact(self) -> dict[str, Any]:
+ with self.lock:
+ return build_run_artifact(self.simulation, self.run_id)
+
+ def objects(self, object_type: str | None = None) -> dict[str, Any]:
+ with self.lock:
+ return build_objects(self.simulation, self.run_id, object_type)
+
+ def graph(self) -> dict[str, Any]:
+ with self.lock:
+ return build_decision_graph(self.simulation, self.run_id)
+
+ def ask(self, question: str) -> dict[str, Any]:
+ with self.lock:
+ return answer_run_question(self.simulation, self.run_id, question)
+
+ def artifacts(self) -> dict[str, Any]:
+ with self.lock:
+ return build_artifacts_payload(self.simulation, self.run_id)
+
class Handler(BaseHTTPRequestHandler):
runner: SimulationRunner
@@ -224,6 +258,35 @@ def do_GET(self) -> None: # noqa: N802
{"error": str(exc)},
status=HTTPStatus.NOT_FOUND,
)
+ if parsed.path == "/api/runs":
+ return self._send_json(
+ {
+ "schema": "elevator-queue-lab.runs.v1",
+ "runs": [self.runner.run_artifact()],
+ }
+ )
+ if parsed.path == f"/api/runs/{self.runner.run_id}":
+ return self._send_json(self.runner.run_artifact())
+ if parsed.path == f"/api/runs/{self.runner.run_id}/objects":
+ object_type = parse_qs(parsed.query).get("type", [None])[0]
+ return self._send_json(self.runner.objects(object_type))
+ if parsed.path == f"/api/runs/{self.runner.run_id}/decisions":
+ return self._send_json(self.runner.objects("DispatchDecision"))
+ if parsed.path == f"/api/runs/{self.runner.run_id}/graph":
+ return self._send_json(self.runner.graph())
+ if parsed.path == f"/api/runs/{self.runner.run_id}/ask":
+ question = parse_qs(parsed.query).get("q", [""])[0]
+ return self._send_json(self.runner.ask(question))
+ if parsed.path == "/api/artifacts":
+ return self._send_json(self.runner.artifacts())
+ if parsed.path == "/api/models":
+ try:
+ return self._send_json(build_models_payload())
+ except FileNotFoundError as exc:
+ return self._send_json(
+ {"error": str(exc)},
+ status=HTTPStatus.NOT_FOUND,
+ )
path = WEB_ROOT / ("index.html" if parsed.path == "/" else parsed.path.lstrip("/"))
resolved = path.resolve()
if not str(resolved).startswith(str(WEB_ROOT.resolve())) or not resolved.is_file():
diff --git a/app/workbench.py b/app/workbench.py
new file mode 100644
index 0000000..d78b521
--- /dev/null
+++ b/app/workbench.py
@@ -0,0 +1,571 @@
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any
+
+from .artifacts import build_artifact_catalog, build_run_artifact
+from .simulator import ElevatorSimulation
+
+
+ROOT = Path(__file__).resolve().parents[1]
+M3_BASELINE = ROOT / "evidence" / "m3-regression-baseline.json"
+M5_EVALUATION = ROOT / "evidence" / "m5-heldout-evaluation.json"
+M6_EVALUATION = ROOT / "evidence" / "m6-heldout-30seed.json"
+M7_DISCOVERY = ROOT / "evidence" / "m7-bidirectional-load-sweep.json"
+M7_VALIDATION = ROOT / "evidence" / "m7-threshold-validation.json"
+M5_MODEL = ROOT / "models" / "m5-ddqn-baseline.json"
+
+OBJECT_TYPES = (
+ "Elevator",
+ "Passenger",
+ "HallCall",
+ "DispatchDecision",
+ "SimulationRun",
+ "Scenario",
+ "Policy",
+ "Experiment",
+ "Model",
+ "Evidence",
+ "TheoryEvidence",
+)
+
+SCENARIOS = ("morning", "lunch", "normal", "evening", "shock", "mixed_day")
+POLICIES = ("legacy_sticky", "nearest_car", "collective", "queue_aware", "capr", "rl")
+
+
+def _load(path: Path) -> dict[str, Any]:
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def _passenger_objects(simulation: ElevatorSimulation) -> list[dict[str, Any]]:
+ by_id: dict[int, dict[str, Any]] = {}
+ for event in simulation.ledger.events:
+ if event.passenger_id is None:
+ continue
+ passenger = by_id.setdefault(
+ event.passenger_id,
+ {
+ "id": f"P-{event.passenger_id}",
+ "object_type": "Passenger",
+ "passenger_id": event.passenger_id,
+ "origin": None,
+ "destination": None,
+ "arrival": None,
+ "boarding": None,
+ "arrival_at_destination": None,
+ "elevator_id": None,
+ "status": "waiting",
+ "event_sequences": [],
+ },
+ )
+ passenger["event_sequences"].append(event.sequence)
+ if event.kind == "arrival":
+ passenger["origin"] = event.floor
+ passenger["destination"] = event.details.get("destination")
+ passenger["arrival"] = event.sim_time
+ elif event.kind == "board":
+ passenger["boarding"] = event.sim_time
+ passenger["elevator_id"] = event.elevator_id
+ passenger["status"] = "onboard"
+ elif event.kind == "alight":
+ passenger["arrival_at_destination"] = event.sim_time
+ passenger["status"] = "served"
+ elif event.kind == "abandon":
+ passenger["status"] = "abandoned"
+
+ for passenger in by_id.values():
+ arrival = passenger["arrival"]
+ boarding = passenger["boarding"]
+ finished = passenger["arrival_at_destination"]
+ passenger["wait"] = (
+ round(float(boarding) - float(arrival), 3)
+ if arrival is not None and boarding is not None
+ else None
+ )
+ passenger["journey_time"] = (
+ round(float(finished) - float(arrival), 3)
+ if arrival is not None and finished is not None
+ else None
+ )
+ return sorted(by_id.values(), key=lambda item: int(item["passenger_id"]), reverse=True)
+
+
+def _call_id(call: dict[str, Any]) -> str:
+ direction = "up" if int(call["direction"]) > 0 else "down"
+ destination = call.get("destination")
+ suffix = str(destination) if destination is not None else "any"
+ return f"C-{call['bank']}-{call['floor']}-{direction}-{suffix}"
+
+
+def _hall_call_objects(simulation: ElevatorSimulation) -> list[dict[str, Any]]:
+ calls_by_id: dict[str, dict[str, Any]] = {}
+ for call in simulation.snapshot()["calls"]:
+ item = dict(call)
+ item["id"] = _call_id(item)
+ item["object_type"] = "HallCall"
+ item["status"] = "active"
+ item["assignment_history"] = []
+ item["reassignment_history"] = []
+ item["pickup_history"] = []
+ item["final_pickup"] = None
+ item["queue_size"] = next(
+ (
+ simulation.call_queue_size(key)
+ for key, active in simulation.hall_calls.items()
+ if active.floor == item["floor"]
+ and active.direction == item["direction"]
+ and active.bank == item["bank"]
+ and active.destination == item["destination"]
+ ),
+ 0,
+ )
+ calls_by_id[item["id"]] = item
+
+ for decision in simulation.decision_history:
+ callspec = {
+ "bank": decision.get("bank"),
+ "floor": decision.get("floor"),
+ "direction": decision.get("direction"),
+ "destination": decision.get("destination"),
+ }
+ call_id = _call_id(callspec)
+ item = calls_by_id.setdefault(
+ call_id,
+ {
+ "id": call_id,
+ "object_type": "HallCall",
+ "floor": decision.get("floor"),
+ "direction": decision.get("direction"),
+ "bank": decision.get("bank"),
+ "destination": decision.get("destination"),
+ "assigned": None,
+ "wait": None,
+ "missed": 0,
+ "assigned_score": None,
+ "queue_size": 0,
+ "status": "historical",
+ "assignment_history": [],
+ "reassignment_history": [],
+ "pickup_history": [],
+ "final_pickup": None,
+ },
+ )
+ previous = decision.get("current_assignment")
+ chosen = decision.get("chosen_elevator_id")
+ assignment = {
+ "sim_time": decision.get("sim_time"),
+ "previous_elevator_id": previous,
+ "selected_elevator_id": chosen,
+ "queue_size": decision.get("queue_size"),
+ "reason": decision.get("reason"),
+ }
+ item["assignment_history"].append(assignment)
+ if previous and chosen and previous != chosen:
+ item["reassignment_history"].append(assignment)
+ if chosen:
+ item["assigned"] = chosen
+ if decision.get("queue_size") is not None:
+ item["queue_size"] = decision.get("queue_size")
+
+ passengers = {item["passenger_id"]: item for item in _passenger_objects(simulation)}
+ destination_mode = simulation.config.control_mode == "destination"
+ for event in simulation.ledger.events:
+ if event.kind != "board" or event.passenger_id is None:
+ continue
+ passenger = passengers.get(event.passenger_id)
+ if passenger is None:
+ continue
+ origin = passenger.get("origin")
+ destination = passenger.get("destination")
+ if not isinstance(origin, int) or not isinstance(destination, int):
+ continue
+ direction = 1 if destination > origin else -1
+ non_lobby_floor = destination if origin == 1 else origin
+ bank = "low" if non_lobby_floor <= simulation.config.low_zone_max else "high"
+ call_id = _call_id(
+ {
+ "bank": bank,
+ "floor": origin,
+ "direction": direction,
+ "destination": destination if destination_mode else None,
+ }
+ )
+ item = calls_by_id.get(call_id)
+ if item is None:
+ continue
+ pickup = {
+ "sim_time": event.sim_time,
+ "passenger_id": event.passenger_id,
+ "elevator_id": event.elevator_id,
+ }
+ item["pickup_history"].append(pickup)
+ item["final_pickup"] = pickup
+ if item["status"] != "active":
+ item["status"] = "served"
+ return sorted(
+ calls_by_id.values(),
+ key=lambda item: float(item["assignment_history"][-1]["sim_time"] if item["assignment_history"] else 0),
+ reverse=True,
+ )
+
+
+def _decision_objects(simulation: ElevatorSimulation) -> list[dict[str, Any]]:
+ decisions: list[dict[str, Any]] = []
+ for index, decision in enumerate(simulation.decision_history, start=1):
+ item = dict(decision)
+ item["id"] = f"D-{index}"
+ item["object_type"] = "DispatchDecision"
+ item["policy"] = simulation.policy_name
+ item["timestamp"] = item.get("sim_time")
+ callspec = {
+ "bank": item.get("bank"),
+ "floor": item.get("floor"),
+ "direction": item.get("direction"),
+ "destination": item.get("destination"),
+ }
+ item["hall_call_id"] = _call_id(callspec)
+ decisions.append(item)
+ return list(reversed(decisions))
+
+
+def build_objects(
+ simulation: ElevatorSimulation,
+ run_id: str,
+ object_type: str | None = None,
+) -> dict[str, Any]:
+ """Project simulator/evidence state into browseable workbench objects."""
+
+ snapshot = simulation.snapshot()
+ m3 = _load(M3_BASELINE) if M3_BASELINE.is_file() else {"scenarios": {}}
+ m5 = _load(M5_EVALUATION) if M5_EVALUATION.is_file() else {}
+ model = _load(M5_MODEL) if M5_MODEL.is_file() else {}
+ collections: dict[str, list[dict[str, Any]]] = {
+ "Elevator": [
+ {"id": item["id"], "object_type": "Elevator", **item}
+ for item in snapshot["elevators"]
+ ],
+ "Passenger": _passenger_objects(simulation),
+ "HallCall": _hall_call_objects(simulation),
+ "DispatchDecision": _decision_objects(simulation),
+ "SimulationRun": [
+ {
+ "id": run_id,
+ "object_type": "SimulationRun",
+ **build_run_artifact(simulation, run_id),
+ }
+ ],
+ "Scenario": [
+ {"id": f"scenario:{name}", "object_type": "Scenario", "name": name}
+ for name in SCENARIOS
+ ],
+ "Policy": [
+ {"id": f"policy:{name}", "object_type": "Policy", "name": name}
+ for name in POLICIES
+ ],
+ "Experiment": [
+ {
+ "id": f"experiment:m3:{name}",
+ "object_type": "Experiment",
+ "scenario": name,
+ "source": "evidence/m3-regression-baseline.json",
+ "policies": list(payload.get("policies", {})),
+ }
+ for name, payload in m3.get("scenarios", {}).items()
+ ],
+ "Model": [
+ {
+ "id": "model:m5-ddqn-baseline",
+ "object_type": "Model",
+ "schema": model.get("schema"),
+ "architecture": model.get("architecture"),
+ "observation_size": model.get("observation_size"),
+ "actions": model.get("actions", []),
+ "metadata": model.get("metadata", {}),
+ "held_out_verdict": m5.get("verdict", {}),
+ }
+ ] if model else [],
+ "Evidence": [
+ {
+ "id": f"evidence:{path.name}",
+ "object_type": "Evidence",
+ "source": str(path.relative_to(ROOT)),
+ "schema": _load(path).get("schema"),
+ }
+ for path in (M3_BASELINE, M5_EVALUATION, M6_EVALUATION)
+ if path.is_file()
+ ],
+ "TheoryEvidence": [
+ {
+ "id": f"theory:{path.stem}",
+ "object_type": "TheoryEvidence",
+ "source": str(path.relative_to(ROOT)),
+ "schema": _load(path).get("schema"),
+ }
+ for path in (M7_DISCOVERY, M7_VALIDATION)
+ if path.is_file()
+ ],
+ }
+ selected = collections.get(object_type, []) if object_type else [
+ item for name in OBJECT_TYPES for item in collections.get(name, [])
+ ]
+ return {
+ "schema": "elevator-queue-lab.objects.v1",
+ "run_id": run_id,
+ "object_types": list(OBJECT_TYPES),
+ "selected_type": object_type,
+ "objects": selected,
+ "counts": {name: len(collections.get(name, [])) for name in OBJECT_TYPES},
+ }
+
+
+def build_decision_graph(simulation: ElevatorSimulation, run_id: str) -> dict[str, Any]:
+ """Build a graph projection without introducing a graph database."""
+
+ all_passengers = _passenger_objects(simulation)
+ boarded = [item for item in all_passengers if item.get("boarding") is not None][:24]
+ unboarded = [item for item in all_passengers if item.get("boarding") is None][:16]
+ passengers = boarded + unboarded
+ decisions = _decision_objects(simulation)[:40]
+ decision_call_ids = {str(item["hall_call_id"]) for item in decisions}
+ calls = [item for item in _hall_call_objects(simulation) if item["id"] in decision_call_ids]
+ snapshot = simulation.snapshot()
+ nodes: list[dict[str, Any]] = []
+ edges: list[dict[str, Any]] = []
+ seen_nodes: set[str] = set()
+
+ def add_node(node_id: str, kind: str, label: str, data: dict[str, Any]) -> None:
+ if node_id in seen_nodes:
+ return
+ seen_nodes.add(node_id)
+ nodes.append({"id": node_id, "type": kind, "label": label, "data": data})
+
+ def add_edge(source: str, target: str, relation: str, evidence: str) -> None:
+ if source in seen_nodes and target in seen_nodes:
+ edges.append(
+ {
+ "id": f"{source}->{target}:{relation}",
+ "source": source,
+ "target": target,
+ "relation": relation,
+ "evidence": evidence,
+ }
+ )
+
+ for elevator in snapshot["elevators"]:
+ add_node(elevator["id"], "Elevator", elevator["id"], elevator)
+ for call in calls:
+ add_node(call["id"], "HallCall", f"{call['floor']}F {'↑' if call['direction'] > 0 else '↓'}", call)
+ for passenger in passengers:
+ add_node(passenger["id"], "Passenger", passenger["id"], passenger)
+ for decision in decisions:
+ add_node(decision["id"], "DispatchDecision", decision["id"], decision)
+ add_node(
+ "metric:wait",
+ "WaitMetric",
+ "Run wait metrics",
+ {
+ "avg_wait": snapshot["metrics"]["avg_wait"],
+ "p95_wait": snapshot["metrics"]["p95_wait"],
+ "max_wait": snapshot["metrics"]["max_wait"],
+ "source": "snapshot.metrics",
+ },
+ )
+
+ for passenger in passengers:
+ origin = passenger.get("origin")
+ destination = passenger.get("destination")
+ if isinstance(origin, int) and isinstance(destination, int):
+ expected_direction = 1 if destination > origin else -1
+ non_lobby_floor = destination if origin == 1 else origin
+ bank = "low" if non_lobby_floor <= simulation.config.low_zone_max else "high"
+ passenger_call_id = _call_id(
+ {
+ "bank": bank,
+ "floor": origin,
+ "direction": expected_direction,
+ "destination": destination if simulation.config.control_mode == "destination" else None,
+ }
+ )
+ add_edge(passenger["id"], passenger_call_id, "generated", "passenger arrival ledger")
+ elevator_id = passenger.get("elevator_id")
+ if isinstance(elevator_id, str):
+ add_edge(passenger["id"], elevator_id, "boarded", "event ledger board")
+ pickup_id = f"pickup:{passenger['id']}"
+ add_node(
+ pickup_id,
+ "Pickup",
+ f"Pickup {passenger['id']}",
+ {
+ "passenger_id": passenger.get("passenger_id"),
+ "elevator_id": elevator_id,
+ "boarding": passenger.get("boarding"),
+ "wait": passenger.get("wait"),
+ "origin": passenger.get("origin"),
+ },
+ )
+ add_edge(elevator_id, pickup_id, "produced", "event ledger board")
+ add_edge(pickup_id, "metric:wait", "contributed_to", "passenger wait sample")
+ for decision in decisions:
+ hall_call_id = str(decision["hall_call_id"])
+ add_edge(hall_call_id, decision["id"], "evaluated_by", "dispatch decision ledger")
+ chosen = decision.get("chosen_elevator_id")
+ if isinstance(chosen, str):
+ add_edge(decision["id"], chosen, "selected", str(decision.get("reason", "")))
+ previous = decision.get("current_assignment")
+ if isinstance(previous, str) and previous != chosen:
+ add_edge(decision["id"], previous, "previous_assignment", "dispatch decision ledger")
+
+ return {
+ "schema": "elevator-queue-lab.decision-graph.v1",
+ "run_id": run_id,
+ "nodes": nodes,
+ "edges": edges,
+ "provenance": {
+ "source": "simulator snapshot + decision ledger + event ledger",
+ "database": None,
+ "projection": "read_only",
+ },
+ }
+
+
+def _compare_with_collective(simulation: ElevatorSimulation) -> tuple[str, list[dict[str, Any]]]:
+ if not M3_BASELINE.is_file():
+ return "Committed M3 evidence is unavailable.", []
+ baseline = _load(M3_BASELINE)
+ scenario = baseline.get("scenarios", {}).get(simulation.scenario)
+ if not scenario:
+ return f"No committed M3 scenario named {simulation.scenario} is available.", []
+ policies = scenario.get("policies", {})
+ current = policies.get(simulation.policy_name)
+ collective = policies.get("collective")
+ if current is None or collective is None:
+ return "This live policy is not present in the committed M3 comparison artifact.", []
+ delta = float(current["avg_wait"]) - float(collective["avg_wait"])
+ direction = "lower" if delta < 0 else "higher"
+ answer = (
+ f"In committed 30-seed M3 evidence for {simulation.scenario}, {simulation.policy_name} "
+ f"has mean AWT {current['avg_wait']:.2f}s versus collective {collective['avg_wait']:.2f}s "
+ f"({abs(delta):.2f}s {direction}). Guardrail classification: "
+ f"{current.get('guardrail_classification', 'unknown')}."
+ )
+ return answer, [
+ {"source": "evidence/m3-regression-baseline.json", "policy": simulation.policy_name, "data": current},
+ {"source": "evidence/m3-regression-baseline.json", "policy": "collective", "data": collective},
+ ]
+
+
+def answer_run_question(simulation: ElevatorSimulation, run_id: str, question: str) -> dict[str, Any]:
+ """Answer a constrained question from deterministic run evidence only."""
+
+ normalized = question.strip()
+ lowered = normalized.lower()
+ evidence: list[dict[str, Any]] = []
+ limitations = [
+ "Explanations report observed simulator evidence and controller reasons; they do not infer unrecorded causal mechanisms.",
+ "Committed statistical comparisons describe synthetic scenarios, not a universal elevator-control theorem.",
+ ]
+ intent = "run_summary"
+ answer = (
+ f"Run {run_id} is at simulation time {int(simulation.sim_time)}s with policy "
+ f"{simulation.policy_name} in the {simulation.scenario} scenario."
+ )
+
+ passenger_match = re.search(r"p[- ]?(\d+)", lowered)
+ decision_match = re.search(r"d[- ]?(\d+)", lowered)
+ if passenger_match:
+ intent = "passenger_explanation"
+ passenger_id = int(passenger_match.group(1))
+ passenger = next(
+ (item for item in _passenger_objects(simulation) if item["passenger_id"] == passenger_id),
+ None,
+ )
+ passenger_events = [
+ event.to_dict()
+ for event in simulation.ledger.events
+ if event.passenger_id == passenger_id
+ ]
+ if passenger is None:
+ answer = f"Passenger P-{passenger_id} is not present in the current run ledger."
+ else:
+ wait = passenger.get("wait")
+ wait_text = f" waited {wait:.2f}s" if isinstance(wait, (int, float)) else " has not boarded yet"
+ answer = (
+ f"Passenger P-{passenger_id}{wait_text}. The recorded lifecycle is "
+ f"{passenger['status']} from {passenger['origin']}F to {passenger['destination']}F"
+ + (f" on {passenger['elevator_id']}." if passenger.get("elevator_id") else ".")
+ )
+ evidence.append({"source": "event_ledger", "passenger": passenger, "events": passenger_events})
+ elif decision_match:
+ intent = "decision_explanation"
+ decision_id = f"D-{int(decision_match.group(1))}"
+ decision = next((item for item in _decision_objects(simulation) if item["id"] == decision_id), None)
+ if decision is None:
+ answer = f"Decision {decision_id} is not present in the retained decision ledger."
+ else:
+ answer = str(decision.get("reason", "No controller reason was recorded."))
+ evidence.append({"source": "decision_ledger", "decision": decision})
+ elif "compare" in lowered and "collective" in lowered:
+ intent = "policy_comparison"
+ answer, evidence = _compare_with_collective(simulation)
+ elif "reassign" in lowered:
+ intent = "reassignment_explanation"
+ event = next(
+ (event for event in reversed(simulation.ledger.events) if event.kind in {"assignment_invalidated", "reassign"}),
+ None,
+ )
+ if event is None:
+ answer = "No reassignment event has been recorded in the current run yet."
+ else:
+ answer = (
+ f"The latest reassignment-related event occurred at {event.sim_time:.2f}s on "
+ f"{event.floor}F. Recorded details: {json.dumps(event.details, sort_keys=True)}"
+ )
+ evidence.append({"source": "event_ledger", "event": event.to_dict()})
+ elif "why" in lowered or "choose" in lowered or "selected" in lowered:
+ intent = "latest_dispatch_explanation"
+ decision = _decision_objects(simulation)[0] if simulation.decision_history else None
+ if decision is None:
+ answer = "No dispatch decision has been recorded in the current run yet."
+ else:
+ answer = str(decision.get("reason", "No controller reason was recorded."))
+ evidence.append({"source": "decision_ledger", "decision": decision})
+ elif "p95" in lowered or "spike" in lowered:
+ intent = "tail_metric_explanation"
+ metrics = simulation.snapshot()["metrics"]
+ answer = (
+ f"Current P95 wait is {metrics['p95_wait']:.2f}s with {metrics['current_queue']} passengers waiting. "
+ "The run ledger can show coincident capacity misses/reassignments, but this interface does not assign a causal driver without recorded evidence."
+ )
+ evidence.append({"source": "snapshot.metrics", "metrics": metrics})
+
+ return {
+ "schema": "elevator-queue-lab.ask-run.v1",
+ "run_id": run_id,
+ "question": normalized,
+ "intent": intent,
+ "answer": answer,
+ "evidence": evidence,
+ "limitations": limitations,
+ "expression_layer": "deterministic",
+ "llm_required": False,
+ }
+
+
+def build_models_payload() -> dict[str, Any]:
+ model = _load(M5_MODEL)
+ evaluation = _load(M5_EVALUATION)
+ return {
+ "schema": "elevator-queue-lab.models-ui.v1",
+ "model": model,
+ "evaluation": evaluation,
+ "source": {
+ "model": "models/m5-ddqn-baseline.json",
+ "evaluation": "evidence/m5-heldout-evaluation.json",
+ },
+ }
+
+
+def build_artifacts_payload(simulation: ElevatorSimulation, run_id: str) -> dict[str, Any]:
+ return build_artifact_catalog(simulation, run_id)
diff --git a/contracts/schemas/passenger-trace.schema.json b/contracts/schemas/passenger-trace.schema.json
new file mode 100644
index 0000000..f3b23b3
--- /dev/null
+++ b/contracts/schemas/passenger-trace.schema.json
@@ -0,0 +1,28 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://elevator.oosu.dev/contracts/passenger-trace.schema.json",
+ "title": "PassengerTraceArtifact",
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["schema", "scenario", "seed", "duration_seconds", "events"],
+ "properties": {
+ "schema": { "const": "elevator-queue-lab.passenger-trace.v1" },
+ "scenario": { "type": "string", "minLength": 1 },
+ "seed": { "type": "integer" },
+ "duration_seconds": { "type": "integer", "minimum": 1 },
+ "events": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "additionalProperties": false,
+ "required": ["at", "passenger_id", "origin", "destination"],
+ "properties": {
+ "at": { "type": "integer", "minimum": 1 },
+ "passenger_id": { "type": "integer", "minimum": 1 },
+ "origin": { "type": "integer", "minimum": 1, "maximum": 18 },
+ "destination": { "type": "integer", "minimum": 1, "maximum": 18 }
+ }
+ }
+ }
+ }
+}
diff --git a/contracts/schemas/run-artifact.schema.json b/contracts/schemas/run-artifact.schema.json
new file mode 100644
index 0000000..92b979d
--- /dev/null
+++ b/contracts/schemas/run-artifact.schema.json
@@ -0,0 +1,36 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://elevator.oosu.dev/contracts/run-artifact.schema.json",
+ "title": "SimulationRunArtifact",
+ "type": "object",
+ "required": [
+ "schema_version",
+ "artifact_version",
+ "simulator_version",
+ "run_id",
+ "scenario",
+ "policy",
+ "seed",
+ "sim_time",
+ "trace_sha256",
+ "metrics",
+ "provenance",
+ "trace_manifest",
+ "created_at"
+ ],
+ "properties": {
+ "schema_version": { "const": "elevator-queue-lab.run.v1" },
+ "artifact_version": { "type": "string" },
+ "simulator_version": { "type": "string" },
+ "run_id": { "type": "string", "minLength": 1 },
+ "scenario": { "type": "string" },
+ "policy": { "type": "string" },
+ "seed": { "type": "integer" },
+ "sim_time": { "type": "number", "minimum": 0 },
+ "trace_sha256": { "type": ["string", "null"] },
+ "metrics": { "type": "object" },
+ "provenance": { "type": "object" },
+ "trace_manifest": { "$ref": "trace-manifest.schema.json" },
+ "created_at": { "type": "string" }
+ }
+}
diff --git a/contracts/schemas/trace-manifest.schema.json b/contracts/schemas/trace-manifest.schema.json
new file mode 100644
index 0000000..f5ff057
--- /dev/null
+++ b/contracts/schemas/trace-manifest.schema.json
@@ -0,0 +1,68 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://elevator-queue-lab.local/contracts/trace-manifest.schema.json",
+ "title": "Elevator Queue Lab Passenger Trace Manifest",
+ "type": "object",
+ "additionalProperties": true,
+ "required": [
+ "schema_version",
+ "artifact_version",
+ "generator_version",
+ "seed",
+ "scenario",
+ "building_configuration",
+ "traffic_contract",
+ "duration_seconds",
+ "sha256",
+ "identity_sha256",
+ "created_at",
+ "source"
+ ],
+ "properties": {
+ "schema_version": {
+ "const": "elevator-queue-lab.trace-manifest.v1"
+ },
+ "artifact_version": {
+ "type": "string",
+ "minLength": 1
+ },
+ "generator_version": {
+ "type": "string",
+ "minLength": 1
+ },
+ "seed": {
+ "type": "integer"
+ },
+ "scenario": {
+ "type": "string",
+ "minLength": 1
+ },
+ "building_configuration": {
+ "type": "object"
+ },
+ "traffic_contract": {
+ "type": "string",
+ "minLength": 1
+ },
+ "duration_seconds": {
+ "type": "number",
+ "minimum": 0
+ },
+ "sha256": {
+ "type": ["string", "null"],
+ "pattern": "^[0-9a-f]{64}$"
+ },
+ "identity_sha256": {
+ "type": "string",
+ "pattern": "^[0-9a-f]{64}$"
+ },
+ "created_at": {
+ "type": "string",
+ "minLength": 1
+ },
+ "source": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+}
diff --git a/docs/M8_PRODUCT_WORKBENCH.md b/docs/M8_PRODUCT_WORKBENCH.md
new file mode 100644
index 0000000..296bc48
--- /dev/null
+++ b/docs/M8_PRODUCT_WORKBENCH.md
@@ -0,0 +1,383 @@
+# M8 — Decision Intelligence Product Workbench
+
+M8 evolves Elevator Queue Lab from a research simulator with a dashboard into a reproducible
+decision-intelligence workbench. The simulator remains authoritative. The product layer adds typed
+projections over existing simulator state, ledgers and committed evidence so a reviewer can move
+through the full research loop:
+
+`SIMULATE → OBSERVE → INSPECT → EXPLAIN → REPLAY → COMPARE → FALSIFY`
+
+> Elevator Queue Lab is a reproducible decision-intelligence workbench for elevator group-control
+> research: simulate, compare, inspect, replay, explain, and falsify dispatch decisions from
+> passenger-level evidence.
+
+## 1. Previous architecture
+
+Before M8 the runtime was already research-grade but the browser surface was concentrated in a
+large vanilla-JavaScript implementation:
+
+```text
+app/ simulator + server
+ ↓ JSON API
+web/index.html
+web/app.js
+web/queue-trend.js
+web/styles.css
+```
+
+That architecture delivered deterministic replay, API-consistent live state, committed experiment
+evidence and the M7 theory panel. Its main limitation was product composition: navigation, object
+exploration, analytics renderers and inspector behavior were difficult to evolve independently.
+
+## 2. M8 architecture
+
+M8 keeps the Python simulator and HTTP server intact and inserts read-only evidence projections
+between the core and the product workbenches.
+
+```text
+Passenger demand / canonical trace
+ ↓
+ ElevatorSimulation
+ ├─ snapshot state
+ ├─ decision history
+ └─ event ledger
+ ↓
+ read-only projections
+ ├─ run artifacts
+ ├─ object catalog
+ ├─ decision graph
+ └─ deterministic explanations
+ ↓
+ Python HTTP API
+ ↓
+frontend/ React + TypeScript source
+ ├─ Product Shell
+ ├─ Live Operations
+ ├─ Runs / Dispatch
+ ├─ Experiments / Theory / Models
+ └─ Object Explorer / Decision Trace
+ ↓ Vite build
+web/ committed production artifact
+ ↓
+python -m app.server --port 4173
+```
+
+The important boundary is one-way: the workbench can inspect and request simulator controls, but
+it does not replace simulator truth with browser-side state or generated analytics.
+
+## 3. Frontend boundary
+
+`frontend/` is the authored UI source. It contains React components, TypeScript API contracts,
+semantic chart specifications, tests and design tokens. `frontend/vite.config.ts` builds directly
+to `../web` with `emptyOutDir: true`.
+
+`web/` is therefore a deployment artifact, not a second authored frontend. A clean checkout can
+still run the product with only Python because the production build is committed:
+
+```bash
+python3 -m app.server --port 4173
+```
+
+Frontend contributors use:
+
+```bash
+npm ci --prefix frontend
+npm run typecheck --prefix frontend
+npm test --prefix frontend
+npm run build --prefix frontend
+```
+
+The migration intentionally preserves the existing Python static-serving path and Mac mini
+service/proxy deployment contract.
+
+## 4. Simulation-core boundary
+
+M8 does not rewrite the elevator engine, CAPR controller, traffic generator, experiment runner or
+learning environment. `app/artifacts.py` and `app/workbench.py` project existing data into
+product-oriented contracts.
+
+The projections are read-only with respect to the research core:
+
+- live state comes from `ElevatorSimulation.snapshot()`;
+- passenger lifecycle evidence comes from the event ledger;
+- candidate and assignment evidence comes from `decision_history`;
+- statistical comparisons come from committed evidence JSON;
+- learned-controller claims come from the committed M5 model/evaluation artifacts;
+- counterflow claims come from committed M7 discovery and held-out validation artifacts.
+
+No Neo4j, PostgreSQL or secondary analytical source of truth is introduced.
+
+## 5. Versioned evidence artifacts
+
+M8 exposes a catalog containing these artifact types:
+
+| Artifact type | Primary source | Purpose |
+| --- | --- | --- |
+| `PassengerTraceArtifact` | materialized trace or seeded demand contract | Reproduce passenger arrivals and trace identity |
+| `SimulationRunArtifact` | current simulator | Reproduce run configuration, policy and metrics |
+| `DispatchDecisionArtifact` | decision history | Preserve controller candidate/selection evidence |
+| `ExperimentArtifact` | M3 evidence | Compare repeated seeded policy experiments |
+| `PolicyEvaluationArtifact` | M5/M6 evidence | Preserve held-out policy/model verdicts |
+| `ModelArtifact` | M5 model JSON | Identify the fixed learned checkpoint |
+| `TheoryEvidenceArtifact` | M7 evidence | Preserve discovery and falsification evidence |
+
+Every catalog entry carries provenance keys for schema/artifact/simulator version, source, seed,
+scenario, policy, configuration, creation time, trace SHA-256 and evidence source. Fields that do
+not semantically apply to a committed aggregate artifact are explicitly `null` rather than
+invented.
+
+The canonical trace manifest adds metadata around the pre-existing trace digest without changing
+trace identity. Its contract includes:
+
+```text
+schema_version
+generator_version
+seed
+scenario
+building_configuration
+traffic_contract
+duration_seconds
+sha256
+created_at
+```
+
+`scripts/generate_trace.py --package-dir ...` can emit:
+
+```text
+trace.json
+manifest.json
+schema.json
+validation.json
+```
+
+The legacy trace JSON format and `PassengerTrace.digest` remain valid and unchanged.
+
+## 6. Workbench API
+
+Existing APIs remain available:
+
+```text
+/api/health
+/api/snapshot
+/api/control
+/api/replay
+/api/experiment
+/api/theory
+```
+
+M8 adds run- and evidence-oriented endpoints:
+
+```text
+/api/runs
+/api/runs/{run_id}
+/api/runs/{run_id}/objects
+/api/runs/{run_id}/decisions
+/api/runs/{run_id}/graph
+/api/runs/{run_id}/ask?question=...
+/api/artifacts
+/api/models
+```
+
+Run scoping makes it explicit which simulator identity an object, graph or explanation belongs to.
+The additive `reset_paused` control action exists for deterministic browser/visual verification: it
+atomically creates a new zero-second run and leaves it paused so tests can advance exact one-second
+steps without racing the background simulation loop.
+
+## 7. Object Explorer and Decision Trace
+
+The object projection exposes:
+
+```text
+Elevator
+Passenger
+HallCall
+DispatchDecision
+SimulationRun
+Scenario
+Policy
+Experiment
+Model
+Evidence
+TheoryEvidence
+```
+
+The Decision Trace is a read-only graph projection over JSON state and ledgers. It uses React Flow
+for pan/zoom/fit-view interaction and links evidence through relationships such as:
+
+```text
+Passenger ─generated→ HallCall
+HallCall ─evaluated_by→ DispatchDecision
+DispatchDecision ─selected→ Elevator
+Elevator ─produced→ Pickup
+Pickup ─contributed_to→ WaitMetric
+```
+
+Selecting graph/object evidence exposes structured data rather than hiding the source in a
+visual-only graph.
+
+## 8. Semantic ChartSpec
+
+Analytics rendering follows:
+
+```text
+Evidence → typed ChartSpec → runtime validation → registered React renderer
+```
+
+Supported semantic specs include `TimeSeries`, `PolicyComparison`, `Distribution`,
+`TradeoffScatter`, `FloorHeatmap`, `MetricCard`, `ConfidenceInterval` and `ExperimentMatrix`.
+
+The renderer does not execute arbitrary JavaScript and does not accept arbitrary generated HTML.
+Metrics must already exist in simulator/API/committed evidence contracts before a chart can render
+them.
+
+## 9. Evidence flow by workbench
+
+### Live Operations
+
+Uses `/api/snapshot` and the existing control/replay APIs. It preserves 18 floors, six cars, queue
+state, active calls, assignment links, load, phase, route, wait/queue metrics, heatmap, decision
+candidates, event stream, pause/resume/reset, scenario/policy/control-mode/speed controls and saved
+replay.
+
+### Experiments
+
+Uses only `evidence/m3-regression-baseline.json` through `/api/experiment`, including the 30 actual
+per-seed AWT observations, confidence intervals, tail metrics, fairness, energy and guardrail
+classification.
+
+### Theory
+
+Uses `evidence/m7-bidirectional-load-sweep.json` and
+`evidence/m7-threshold-validation.json`. The UI keeps both parts of the conclusion visible:
+
+> Congestion alone is not the trigger. Continuous predictive reassignment becomes valuable when
+> traffic intensity and counterflow rise together.
+
+and:
+
+> The threshold is fuzzy and is not a universal critical constant.
+
+### Models
+
+Uses `models/m5-ddqn-baseline.json` and `evidence/m5-heldout-evaluation.json`. The fixed M5 result
+remains negative/mixed: `accepted_as_general_improvement` is false, with only the held-out mixed-day
+mixture classified as a candidate improvement.
+
+## 10. Ask This Run
+
+Ask This Run is deterministic in M8:
+
+```text
+question
+ ↓
+bounded intent parser
+ ↓
+run/evidence query
+ ↓
+evidence package
+ ↓
+deterministic explanation
+```
+
+It can explain a passenger lifecycle, a recorded decision, the latest reassignment evidence, a tail
+metric snapshot, or compare the current policy with collective using committed M3 evidence. The
+response always returns its evidence payload and limitations. No LLM is required and an LLM is not
+the source of truth.
+
+## 11. CI and regression strategy
+
+Every pull request runs:
+
+```text
+Python unit/contract tests
+deterministic experiment smoke
+destination-control smoke
+artifact/trace validation
+release audit
+frontend npm ci
+TypeScript gate
+frontend unit tests
+production build into web/
+Playwright Chromium install
+browser E2E + responsive + visual regression
+```
+
+The previous branch-name condition that limited Playwright to the M4 feature branch is removed.
+
+The full 30-seed × six-scenario statistical regression is intentionally separated because it is
+much more expensive. It runs on `main`, on the weekly schedule, or when a PR carries the
+`full-statistical-regression` label. This keeps every PR fast enough to use while preserving the
+release-quality evidence gate.
+
+Visual regression targets information loss and layout regressions, not pixel-perfect design
+competition. Baselines cover Live Operations desktop/mobile, Replay, Experiments, Theory, Models
+and Explorer/Decision Trace. Update them intentionally with:
+
+```bash
+npm run test:e2e:update
+```
+
+## 12. Showcase capture
+
+`npm run capture:showcase` starts/uses the same production Python server path as E2E and captures
+real workbench state after browser interaction. Assets cover Live Operations after a CAPR
+reassignment, Replay, Experiments, Theory, Models and Explorer/Decision Trace.
+
+The capture is evidence of the actual product path; it is not a mock design export.
+
+## 13. Deployment compatibility
+
+The production contract remains:
+
+```bash
+python3 -m app.server --port 4173
+```
+
+The server continues to serve `web/` and the API from one process. M8 therefore does not require a
+Node runtime on the deployed Mac mini once `web/` has been built and committed. The existing
+`deploy/` service/proxy structure can continue to target the same Python command and port.
+
+Public deployment is considered verified only after the M8 commit is actually deployed and remote
+browser/API checks pass; local workbench completion alone is not treated as proof of production
+deployment.
+
+## 14. Migration strategy
+
+The migration is deliberately incremental and backward-compatible:
+
+1. preserve the simulator, evidence files and HTTP behavior;
+2. add read-only projection contracts and tests;
+3. build React/TypeScript source in parallel with the old browser implementation;
+4. make Vite emit the production artifact to `web/`;
+5. migrate Playwright selectors to semantic/stable selectors without reducing coverage;
+6. preserve replay and API consistency before removing the old authored vanilla files;
+7. generalize CI only after local Python/frontend/browser gates pass.
+
+This makes the React migration a product-layer replacement rather than a research-core rewrite.
+
+## 15. Reference and license policy
+
+M8 used reference projects only within their verified license boundaries:
+
+- **BIST reference project:** architecture/documentation/demo-automation patterns only;
+- **Palantir-style local clone:** interaction architecture ideas such as shell, rail, inspector,
+ split workbench and compact data presentation only; no source code copied because a root license
+ was not verified;
+- **team-repos/gen_data and ontology_dashboard:** artifact/provenance/CI architecture patterns only;
+ no source code copied because a root license was not verified;
+- **Data Formulator (MIT):** semantic chart-spec and renderer-separation ideas;
+- **OpenGenerativeUI (MIT):** typed planner/spec/registered-renderer idea only; Elevator Queue Lab
+ explicitly does not adopt arbitrary HTML/JavaScript execution.
+
+## 16. Research-integrity constraints
+
+M8 does not change the conclusions established by prior evidence:
+
+- CAPR is not globally superior;
+- the fixed M5 RL controller is not generally superior;
+- the counterflow threshold is fuzzy rather than a universal critical constant;
+- the demand model is synthetic and is not real-building telemetry;
+- charts and explanations may only use simulator state or committed evidence;
+- no browser or LLM layer may fabricate a metric or override recorded provenance.
+
+Those constraints are product requirements, not caveats to hide from the interface.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index c7cbb33..073a7f5 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -131,3 +131,24 @@ demo without private infrastructure.
- [x] expose discovery + held-out theory evidence in the live dashboard through `/api/theory`;
- [x] deploy the M7 theory dashboard and re-verify the public demo;
- [x] elevate the counterflow-criticality operating rule into a first-read key takeaway in the public dashboard and README.
+
+## M8 — Decision Intelligence Product Workbench
+
+- [x] migrate the authored frontend from the M4 vanilla-JS monolith to React + TypeScript while keeping `web/` as the committed production artifact;
+- [x] add an operations/research Product Shell with Live Operations, Runs, Dispatch Analysis, Experiments, Theory, Models and Explorer workbenches;
+- [x] preserve the 18-floor / six-car live digital twin, control surface, decision inspector and deterministic replay flow;
+- [x] add versioned run, passenger-trace, dispatch-decision, experiment, policy-evaluation, model and theory-evidence artifact projections with provenance;
+- [x] add browseable research objects and a read-only Decision Trace graph projected from simulator state, decision history and the event ledger;
+- [x] introduce a typed semantic `ChartSpec` contract with runtime validation and registered React renderers rather than arbitrary generated HTML/JS;
+- [x] expose committed M3/M5/M7 evidence through dedicated Experiment, Model and Counterflow Criticality workbenches without fabricating metrics;
+- [x] add deterministic Ask This Run explanations backed only by recorded run evidence and committed comparisons;
+- [x] generalize CI so Python contracts, frontend type/unit/build gates and Playwright E2E/visual QA run on every pull request;
+- [x] keep the expensive 30-seed statistical regression on `main`, schedule, or an explicit PR label instead of every PR;
+- [x] add deterministic visual-regression baselines for Live Operations desktop/mobile, Replay, Experiments, Theory, Models and Explorer/Decision Trace;
+- [x] add `npm run capture:showcase` to capture portfolio assets from the real Python server and production frontend;
+- [x] document the frontend/simulator boundary, artifact contracts, graph projection, evidence flow, CI strategy, deployment compatibility and reference-license policy.
+
+**Gate:** A reviewer can inspect a live dispatch decision from passenger → call → candidate evaluation
+→ assignment/reassignment → pickup → resulting metric, reproduce the run from its artifact
+provenance, compare policies statistically, and verify the same state through automated browser/API
+tests.
diff --git a/docs/assets/showcase/m8-experiments.png b/docs/assets/showcase/m8-experiments.png
new file mode 100644
index 0000000..081c6ee
Binary files /dev/null and b/docs/assets/showcase/m8-experiments.png differ
diff --git a/docs/assets/showcase/m8-explorer.png b/docs/assets/showcase/m8-explorer.png
new file mode 100644
index 0000000..8b56151
Binary files /dev/null and b/docs/assets/showcase/m8-explorer.png differ
diff --git a/docs/assets/showcase/m8-live-operations.png b/docs/assets/showcase/m8-live-operations.png
new file mode 100644
index 0000000..6697f37
Binary files /dev/null and b/docs/assets/showcase/m8-live-operations.png differ
diff --git a/docs/assets/showcase/m8-models.png b/docs/assets/showcase/m8-models.png
new file mode 100644
index 0000000..b71ab71
Binary files /dev/null and b/docs/assets/showcase/m8-models.png differ
diff --git a/docs/assets/showcase/m8-replay.png b/docs/assets/showcase/m8-replay.png
new file mode 100644
index 0000000..2e4398a
Binary files /dev/null and b/docs/assets/showcase/m8-replay.png differ
diff --git a/docs/assets/showcase/m8-theory.png b/docs/assets/showcase/m8-theory.png
new file mode 100644
index 0000000..610ddd8
Binary files /dev/null and b/docs/assets/showcase/m8-theory.png differ
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..2c00386
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,16 @@
+
+
+
{subtitle}
+| Car | ETA | Route | Projected load | Residual | Score | Feasible |
|---|---|---|---|---|---|---|
| {candidate.elevator_id} | {candidate.pickup_eta.toFixed(2)}s | {candidate.route_cost.toFixed(2)}s | {candidate.projected_load} | {candidate.residual_capacity} | {candidate.score.toFixed(2)} | {candidate.feasible ? "yes" : "no"} |
No retained decision evidence.
} +| Policy | Mean | P95 | P99 | Worst floor | Energy | Paired Δ | Guardrail |
|---|---|---|---|---|---|---|---|
| {POLICY_LABELS[policy]} | {fmt(evidence.avg_wait)} | {fmt(evidence.p95_wait)} | {fmt(evidence.p99_wait)} | {fmt(evidence.worst_floor_mean_wait)} | {fmt(evidence.energy_proxy, 1)} | {fmt(evidence.avg_wait_delta_vs_collective)} | {evidence.guardrail_classification.replaceAll("_", " ")} |
P50, fairness, capacity misses, and reassignment latency are shown only when a committed artifact exposes those aggregations. The M3 regression baseline does not contain every one of those fields, so this UI does not synthesize them.
+{JSON.stringify(selection[key], null, 2)}{JSON.stringify(answer.evidence, null, 2)}Waiting for the first dispatch decision…
+{decision.reason}
+| Car | ETA | Residual | Score | Feasible |
|---|---|---|---|---|
| {candidate.elevator_id}{candidate.elevator_id === decision.chosen_elevator_id ? " ✓" : ""} | +{candidate.pickup_eta.toFixed(1)}s | +{candidate.residual_capacity} | +{candidate.score.toFixed(1)} | +{candidate.feasible ? "yes" : "no"} | +
{String(verdict.interpretation ?? "Held-out evidence does not support a general superiority claim.")}
+The learned policy runs through the same simulator assignment/reassignment invariants. Newer model architecture is not presented as automatically better.
+| Scenario | Policy | AWT | P95 | Worst floor | Energy | Guardrail |
|---|---|---|---|---|---|---|
| {row.scenario} | {row.policy} | {Number(row.avg_wait).toFixed(2)} | {Number(row.p95_wait).toFixed(2)} | {Number(row.worst_floor_mean_wait).toFixed(2)} | {Number(row.energy_proxy).toFixed(1)} | {row.guardrail} |
{JSON.stringify({ schema: model.schema, training_contract: evaluation.training_contract, held_out_contract: evaluation.held_out_contract, verdict }, null, 2)}{JSON.stringify(run.trace_manifest, null, 2)}{JSON.stringify(run.provenance, null, 2)}| Type | Schema | Source | SHA-256 |
|---|---|---|---|
| {artifact.artifact_type} | {artifact.schema_version} | {artifact.source} | {artifact.sha256?.slice(0, 16) ?? "live"} |
Continuous predictive reassignment becomes valuable when traffic intensity and counterflow rise together.
+ This is a synthetic-system empirical rule with a fuzzy threshold, not a universal theorem. +The held-out accuracy is {Number(result.accuracy).toFixed(3)}, with false positives and a fuzzy phase transition. The threshold is useful as a falsifiable operating hypothesis, not a hard physical law.
+18-floor digital twin · predictive dispatch · reproducible statistical evidence
-Values below come directly from the checked-in M3 statistical regression baseline. Decision rank prefers guardrail-clean candidates, then the collective reference, then faster-but-riskier tradeoffs. Energy is a comparative proxy, not kWh.
- - - - -Gaussian-kernel density over the 30 observed per-seed average-wait values. This is an empirical run-to-run distribution, not an assumption that passenger waits are normally distributed.
-| Rank | -Policy | -Evidence | -AWT · 95% CI | -P95 | -P99 | -Worst floor | -Energy | -Δ vs collective | -Speed rank | -
|---|
Save the current buffered run, then scrub the exact simulator frames. Replay uses saved API state rather than reconstructed chart values.
-- M7 holds CAPR scoring and parking fixed, then turns only continuous predictive reassignment - on/off. The question is whether counterflow creates enough route competition for reassignment to - pay for its churn. This is a falsifiable empirical phase hypothesis for this simulator — not a - universal queueing theorem or a novelty claim. -
-This is a simulator sanity diagnostic, not an M/M/c assumption. Elevator service is route- and state-dependent.
-