# Project export: crowd-physics

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2026
- Tagline: Crowdphysics is a system that watches any crowd through a camera, learns the fluid-like physics of how crowds move, predicts crowd crushes & other death-causing scenarios 3 minutes before they happen.
- Devpost: https://devpost.com/software/crowd-physics
- GitHub: https://github.com/dhyutin/CrowdPhysics
- Video: https://www.youtube.com/embed/XuAuPTI6K74?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Sree Dhyuti Nimmagadda (51 commits)

## Devpost submission (written by the team)

### Inspiration

Coming from India, I've seen far too many times that crowds are not managed well, and that overcrowding is almost always handled poorly. The Kumbh Mela, one of the largest human gatherings on Earth, sees people lose their lives to crowd crushes nearly every time it is held. I've lost some close relatives to such incidents. And it isn't just India: Itaewon in Seoul, the Love Parade in Germany, and Astroworld in the US, a lot of concerts, political gatherings, strikes/rallies, etc are all recent reminders that crowd crushes keep happening, in every place, at events that were planned months in advance. A "crowd crush" is a physics problem before it is a human one. A lot of scientists have provided evidence that an average human crowd behaves similar to how a fluid flows. I felt like this concept could be used to model human crowd and use that information to do better crowd management in events regardless of their scale. TL;DR CrowdPhysics turns any existing camera into a crowd-crush early-warning system preventing potential accidents in crowding. It also lets you simulate a venue's crowd flow, through the same perception pipeline, before the event ever happens and plan crowd management efficiently.

### What it does

CrowdPhysics is a two-mode platform for crowd safety. Monitor mode turns any existing camera feed — a CCTV stream, a phone, or a public webcam — into a crush-risk early-warning system. It reads the crowd purely as fluid dynamics: it extracts the optical flow, feeds it to a learned world model, and forecasts what the crowd will do next. When the model becomes "surprised" — when the real crowd starts behaving in a way it has never seen during calm footage — the system raises a warning before the crush forms. Claude then explains, in plain language, what is happening, decides the crush-risk percentage, and recommends what to do. Simulate mode is the pre-event planning tool. Upload a photo or video of a venue, and agents reconstruct the space in 3D, simulate a crowd flowing through it, and surface the danger zones — peak-pressure sectors, bottlenecks, Fruin level-of-service, and time-to-crush — before a single person arrives. It then suggests how to arrange entrances, flow, and staff to make the layout safe. Architecture Two modes share one perception core — optical flow → a learned world model → an anomaly signal — and one explainer (Claude). Monitor pipeline — frame to warning. Every consecutive frame pair becomes an optical-flow field, compressed to a 256-dim feature vector, encoded by the world model into a 64-dim latent z, and rolled forward. The gap between what the model predicted and what actually happened is the danger signal, which the RL agent and Claude turn into a calibrated risk and a recommended intervention. At the end of that pipeline sits a multi-agent decision framework: no single model decides alone. The anomaly status, the world-model's imagined futures, the statistical trend, the RL agent's recommended intervention, and a counterfactual "prove the fix works" are all fused — and Claude reasons over the whole picture to produce one calibrated verdict, a plain-language briefing, and a recommended action. Plan pipeline — photo to safe layout. Upload a photo or video; Claude vision reconstructs the venue in 3D, the crowd simulator fills it, and we surface danger zones, Fruin level-of-service, and an arrangement plan before anyone arrives. The Sim → RAFT bridge. The simulator is a pressure-grid fluid model with no individual people — so there is nothing for optical flow to track. We close that gap by seeding massless particles at the entry ports, advecting them through the simulated velocity field, and rendering them as a video. Running that synthetic crowd through the same RAFT extractor used on live cameras yields the optical flow an operator should expect at each door — so a layout is validated against the perception system itself, before the event. How I built it Challenges I ran into Anomaly detection with no disaster data. Real crowd-crush footage is scarce and ethically fraught, so we could not train a supervised classifier. We had to invert the problem: train only on normal physics and treat the world model's prediction error as the danger signal. Putting the stochasticity in the right place. Our first world model was a "half-VAE" — the encoder was deterministic but the KL term acted on the transition, so the latent space was never shaped toward a prior and our ||z|| danger score was meaningless. Rebuilding it as a proper posterior-vs-prior RSSM was the fix that made the anomaly signal principled. Proving the model actually learned physics. It's easy to claim a self-supervised model "understands" a crowd. We had to prove it by linearly probing the latent space. Connecting simulation to perception. The simulator outputs fields, not people, so optical flow had nothing to track. Building the particle-advection renderer — auto-scaling velocity to visible pixel motion and measuring magnitude-weighted flow at each door — was what finally let the same RAFT pipeline validate a layout before the event. Real-time end to end. Chaining RAFT → world model → anomaly scoring → RL → Claude while keeping the feed responsive took a lot of profiling and a lazy-loaded, calibration-aware inference layer, plus running Claude's risk assessment on a non-blocking background thread. Accomplishments that I'm proud of The world model genuinely discovered crowd physics on its own. By linearly probing the unlabeled latent space, we recovered interpretable physical concepts with high fidelity: Boundary stress — compression at walls and barriers, the literal mechanism of a crush — was recovered at R² = 0.99, even though we never told the model what a wall is. And the latent dimensions we couldn't explain still separated pre-anomaly frames from calm ones by 0.91σ, meaning the model encodes early-warning signal we don't yet have names for. This is a standard mechanistic interpretability based proof that the world model learned physics principles intrinsically. I'm also proud that: The whole thing is genuinely two products in one — pre-event simulation and live monitoring — and it runs on cameras that already exist, requiring no new hardware. Other experiments World model v1 → v2. We started with a deterministic CNN-encoder + LSTM transition and migrated to a stochastic RSSM after the latent probe showed the danger score wasn't grounded. Self-supervised RAFT fine-tuning. We fine-tuned RAFT on unlabeled crowd video to sharpen flow on dense, low-contrast scenes (raft_crowd.pt). "Prove the fix works" counterfactuals. Using the RL effect model, we roll the crowd forward two ways — do nothing vs. apply the recommended intervention — so the projected impact of acting now is visible as the gap between two risk curves. Minutes-ahead forecasting. Beyond the immediate surprise signal, we extrapolate the risk trend to project crush risk minutes into the future.

### What we learned

Self-supervised "surprise" is a remarkably powerful safety signal — you can detect danger you never trained on, as long as you've learned what "normal" looks like. Where you put stochasticity in a latent model matters enormously; the RSSM formulation wasn't just cleaner, it was the difference between a meaningful danger score and noise. Linear probing is an underrated way to verify that a model learned something real, and it turned a black box into our most compelling demo. Model-based RL (Dyna / Dreamer-style) lets you train a useful intervention policy entirely in imagination — no real catastrophes required. Validating a simulator through the same perception model you deploy is a powerful sanity check — it catches layouts that look fine on a heatmap but read as a bottleneck to the optical-flow pipeline.

### What's next

for CrowdPhysics Multi-camera fusion — stitch several feeds into one venue-wide pressure field for full situational coverage. Calibrated, deployable alerts — push warnings to staff radios, SMS, and agent networks with venue-specific instructions. Richer venue reconstruction — go from a single photo to a true 3D layout for higher-fidelity Plan-mode simulations. Edge deployment — run the pipeline on-site for privacy and zero-latency monitoring at large events. Naming the unknown — investigate the unexplained latent dimensions that already predict danger, and turn them into new, named safety metrics.

## README (from the GitHub repository)

<div align="center">
  <img src="brand/crowd_physics_logo.png" width="640" alt="CrowdPhysics — Plan safe. Monitor live. Never react." />
</div>

# CrowdPhysics

**Live crowd-crush early warning + pre-event crowd-flow simulation — on cameras that already exist.**

A crush is a *physics* problem before it is a human one. By the time a camera operator sees people falling, it is already too late. CrowdPhysics reads a crowd as pure fluid dynamics, learns what "normal" looks like, and warns *before* the crush forms — and lets you simulate a venue's crowd flow before the event, through the same perception pipeline.

---

## What it does

CrowdPhysics is one platform with two modes:

- **Monitor mode (live).** Turns any CCTV stream, phone, or public webcam into a crush-risk early-warning system. It extracts optical flow, feeds it to a self-supervised world model, and forecasts what the crowd will do next. When the model becomes *surprised* — the crowd behaves in a way it never saw during calm footage — it raises a warning. Claude then explains what's happening, decides a calibrated crush-risk %, and recommends an action.

- **Simulate mode (pre-event).** Upload a photo or video of a venue; agents reconstruct it in 3D, fill it with a simulated crowd, and surface danger zones, Fruin level-of-service, and a safe arrangement plan. The **Sim → RAFT bridge** then renders the simulation as a synthetic-crowd video and runs it through the *same* optical-flow extractor used live — previewing the inflow/outflow each entrance and exit should show on the day.

The signature of the product is the visualization: instead of a red dot on a surveillance feed, the crowd is rendered as a CFD-style pressure field. The people disappear, and only the physics remains.

> It learned crowd physics on its own — a linear probe of the unlabeled latent space recovers crowd velocity (R² 0.83), turbulence (0.78), backward pressure (0.84), and **boundary stress — the literal mechanism of a crush — at R² 0.94**, without ever being told what a wall is.

---

## Architecture

Two modes share one perception core — optical flow → a learned world model → an anomaly signal — and one explainer (Claude).

### Monitor pipeline — frame to warning

Every frame pair becomes an optical-flow field → a 256-d feature vector → a 64-d latent `z` → an autoregressive rollout. The gap between predicted and actual is the danger signal.

![Monitor pipeline](devpost/monitoring_pipeline_architecture.png)

At the end of the pipeline, a **multi-agent decision framework** fuses every signal — no single model decides alone.

![Multi-agent decision framework](devpost/monitor_decision_framework.png)

### Simulate pipeline — photo to safe layout

![Simulation pipeline](devpost/simulation_pipeline_architecture.png)

### Sim → RAFT bridge — validate a layout through the same eyes that will watch it

![Sim to RAFT bridge](devpost/sim_to_raft_bridge.png)

---

## Tools used

| Layer | Stack |
| --- | --- |
| **Perception** | PyTorch · RAFT (`torchvision`, optionally fine-tuned `raft_crowd.pt`) with a Farneback fallback |
| **World model** | Latent dynamics — CNN/MLP encoder + stochastic LSTM transition, 256 → 64-d latent (RSSM v2 explored, v1 shipped) |
| **Decision (RL)** | Dyna-style model-based RL with Conservative Q-Learning (CQL), trained in imagination |
| **Agent / LLM** | Claude (Sonnet) via Anthropic — vision reconstruction, behavior planning, safety reports, agent-decided live risk |
| **Live capture** | Browserbase (cloud headless browser) · yt-dlp · OpenCV |
| **Simulation** | Pressure-grid CFD crowd model — time-varying arrivals, density-dependent speed, Fruin LOS |
| **Observability** | Arize AX (OpenTelemetry tracing + LLM-as-judge evals) |
| **Alerts** | Fetch.ai heartbeat agent · Slack / Discord / webhook / Twilio SMS |
| **App** | FastAPI (backend) · Next.js + React Three Fiber / Three.js · Recharts · Tailwind |

---

## Dataset

The world model and the RAFT fine-tuning are trained on a small set of **publicly available YouTube clips of crowds walking and moving** — stadium crowds, pedestrian flows, and crowd-dynamics demonstrations. The clips live in `data/videos/` and are loaded directly by the training scripts (`scripts/train.py`, `scripts/finetune_raft.py`).

Two things to note:

- **No labels, no disaster footage.** Training is entirely **self-supervised** on *normal* crowd motion — the model only ever learns what calm looks like, and danger is inferred as deviation ("surprise") from that. Real crush footage is scarce and ethically fraught, so none is used.
- **Intentionally small (hackathon scope).** A handful of clips is enough to demonstrate the pipeline and recover physics via the latent probe, but it's also why held-out generalization is modest — broader, more varied footage is the obvious next step.

To use your own data, drop `.mp4` / `.avi` / `.mov` files into `data/videos/` and re-run the training scripts below.

---

## Model results

All numbers are reproducible from the scripts in `scripts/` and the JSON artifacts in `results/`.

### Latent probe — did the world model learn physics?

We freeze the shipped world model (v1), encode crowd video into the 64-d latent, and fit a probe from the latent to each *measured* physics quantity. High R² means the concept is linearly recoverable from the latent the model built on its own.

| Concept | R² (linear, in-sample) | R² (held-out, group k-fold) |
| --- | --- | --- |
| Crowd velocity | 0.83 | 0.54 |
| Turbulence | 0.78 | 0.33 |
| Backward pressure | 0.84 | 0.56 |
| **Boundary stress** (the literal mechanism of a crush) | **0.94** | 0.26 |

Plus the unexplained latent dimensions still separate pre-anomaly frames from calm ones by **1.56σ** — early-warning signal the model encodes that we don't yet have names for. The in-sample numbers show the concept *is* represented; the held-out numbers are honest about how much generalizes from this small dataset. *(Source: `results/probe_results.json`, `scripts/probe_latent.py`.)*

### Model selection — v1 vs RSSM v2

We built a Dreamer/RSSM-style v2 and compared it to v1 on a combined probe + surprise-separation score. **v1 won and shipped.**

| Model | Mean linear-probe R² | Surprise separation | Combined score |
| --- | --- | --- | --- |
| **v1 (shipped)** | 0.85 | **1.56σ** | **1.63** |
| RSSM v2 | 0.87 | 1.25σ | 1.49 |

*(Source: `results/probe_compare_results.json`, `scripts/probe_compare.py`.)*

### Training curves (selected models)

**World model (v1, shipped)** — self-supervised loss (reconstruction + KL + transition) converging to ≈ **0.045** under cosine LR decay:

![World model training loss](results/world_model_training.png)

**Intervention RL (Dyna + Conservative Q-Learning)** — trained entirely in the world model's imagination. Over 15k imagined episodes the 50-episode average reward climbs to ≈ **62** (best ≈ 68) as the TD/CQL loss decays and ε anneals:

![RL policy training curves](results/rl_policy_training.png)

*(Curves written live by `metrics_logger.py`; sources in `logs/`.)*

---

## Getting started

### Prerequisites

- Python 3.10+ and Node.js 18+
- An [Anthropic API key](https://console.anthropic.com/) (required for the Claude-powered features)

### 1. Backend (FastAPI · port 8000)

```bash
# from the repo root
python3 -m venv .venv && source .venv/bin/activate
pip install -r backend/requirements.txt

# add your keys (see "Environment" below)
echo 'ANTHROPIC_API_KEY=sk-ant-...' > .env

# run the API (loads the world model + RL policy at startup)
python3 backend/main.py
```

The API serves on `http://localhost:8000`.

### 2. Frontend (Next.js · port 3000)

```bash
cd frontend
npm install
npm run dev
```

Open **http://localhost:3000**. The UI talks to the backend at `http://localhost:8000` by default (override with `NEXT_PUBLIC_API_URL`).

### Environment

Create a `.env` in the repo root (auto-loaded by the backend). Only `ANTHROPIC_API_KEY` is required:

```bash
ANTHROPIC_API_KEY=sk-a

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 96 recognized source files, 1034 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- FastAPI (technology) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — detected in the code
- PyTorch (technology) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- AI coding agent: Cursor — evidence: config files committed to the repository

## Codebase structure (from repository index)

### Files (113 of 113)

```
.agents/skills/arize-admin/references/ax-profiles.md
.agents/skills/arize-admin/references/ax-setup.md
.agents/skills/arize-admin/references/REFERENCE.md
.agents/skills/arize-admin/SKILL.md
.agents/skills/arize-ai-provider-integration/references/ax-profiles.md
.agents/skills/arize-ai-provider-integration/references/ax-setup.md
.agents/skills/arize-ai-provider-integration/SKILL.md
.agents/skills/arize-annotation/references/ax-profiles.md
.agents/skills/arize-annotation/references/ax-setup.md
.agents/skills/arize-annotation/SKILL.md
.agents/skills/arize-compliance-audit/references/compliance-checklist-template.md
.agents/skills/arize-compliance-audit/references/eu-ai-act-gpai.md
.agents/skills/arize-compliance-audit/references/iso-42001.md
.agents/skills/arize-compliance-audit/references/us-ai-compliance.md
.agents/skills/arize-compliance-audit/SKILL.md
.agents/skills/arize-dataset/references/ax-profiles.md
.agents/skills/arize-dataset/references/ax-setup.md
.agents/skills/arize-dataset/SKILL.md
.agents/skills/arize-evaluator/references/ax-profiles.md
.agents/skills/arize-evaluator/references/ax-setup.md
.agents/skills/arize-evaluator/SKILL.md
.agents/skills/arize-experiment/references/ax-profiles.md
.agents/skills/arize-experiment/references/ax-setup.md
.agents/skills/arize-experiment/SKILL.md
.agents/skills/arize-instrumentation/references/ax-profiles.md
.agents/skills/arize-instrumentation/references/integration-routing.md
.agents/skills/arize-instrumentation/references/manual-spans.md
.agents/skills/arize-instrumentation/references/tracing-assistant-mcp.md
.agents/skills/arize-instrumentation/SKILL.md
.agents/skills/arize-link/references/EXAMPLES.md
.agents/skills/arize-link/SKILL.md
.agents/skills/arize-prompt-optimization/references/ax-profiles.md
.agents/skills/arize-prompt-optimization/references/ax-setup.md
.agents/skills/arize-prompt-optimization/SKILL.md
.agents/skills/arize-prompts/references/ax-profiles.md
.agents/skills/arize-prompts/references/ax-setup.md
.agents/skills/arize-prompts/references/cli-prompts.md
.agents/skills/arize-prompts/SKILL.md
.agents/skills/arize-trace/references/ax-profiles.md
.agents/skills/arize-trace/references/ax-setup.md
.agents/skills/arize-trace/SKILL.md
.cursor/mcp.json
.gitignore
agents/browserbase_monitor.py
agents/fetch_agent.py
agents/requirements.txt
agents/youtube_monitor.py
alerts.py
anomaly_detector.py
backend/main.py
backend/Procfile
backend/railway.toml
backend/requirements.txt
claude_interpreter.py
devpost/architecture.py
devpost/bridge_architecture.py
devpost/content.md
devpost/decision_framework.py
devpost/make_deck.py
dyna_trainer.py
flow_extractor.py
frontend/.eslintrc.json
frontend/.gitignore
frontend/app/globals.css
frontend/app/layout.tsx
frontend/app/page.tsx
frontend/components/AgentTrace.tsx
frontend/components/EventIntake.tsx
frontend/components/ExpectedFlowPanel.tsx
frontend/components/FilmPlayer.tsx
frontend/components/ForecastPanel.tsx
frontend/components/InterventionImpact.tsx
frontend/components/MonitorTab.tsx
frontend/components/PlanPoints.tsx
frontend/components/PlanTab.tsx
frontend/components/PlaybackBar.tsx
frontend/components/ScenarioCompare.tsx
frontend/components/TrendPanel.tsx
frontend/components/Venue3D.tsx
frontend/lib/api.ts
frontend/lib/venuePins.ts
frontend/next.config.mjs
frontend/package.json
frontend/postcss.config.mjs
frontend/README.md
frontend/tailwind.config.ts
frontend/tsconfig.json
frontend/vercel.json
instrumentation.py
metrics_logger.py
README.md
results/probe_compare_results.json
results/probe_mlp_results.json
results/probe_results.json
rl_policy.py
scripts/finetune_raft.py
scripts/probe_compare.py
scripts/probe_latent.py
scripts/probe_mlp.py
scripts/train_rl.py
scripts/train_v2.py
scripts/train.py
scripts/tune_world_model.py
simulation_engine.py
skills-lock.json
tests/demo_inference.py
tests/test_anomaly_detector.py
tests/test_claude_interpreter.py
tests/test_flow_extractor.py
tests/test_rl_policy.py
tests/test_world_model.py
world_model_v2.py
world_model.py
```

### Dependencies

- agents/requirements.txt: numpy@<2, opencv-python-headless, playwright, requests, uagents, yt-dlp
- backend/requirements.txt: anthropic@>=0.84.0, arize-otel, fastapi, numpy@<2, opencv-python-headless, openinference-instrumentation-anthropic, Pillow, playwright, python-multipart, requests, torch, torchvision, uvicorn[standard], yt-dlp
- frontend/package.json: @react-three/drei@^9.122.0, @react-three/fiber@^8.18.0, @types/node@^20, @types/react@^18, @types/react-dom@^18, @types/three@^0.169.0, autoprefixer@^10.0.1, eslint@^8, eslint-config-next@14.2.3, next@14.2.3, postcss@^8, react@^18, react-dom@^18, react-markdown@^9.0.1, recharts@^2.12.7, tailwindcss@^3.4.1, three@^0.169.0, typescript@^5

### Recent commits (newest first)

- add dataset, model results, and training curves to README
- final
- push final
- clean repo
- updated readme
- final
- agent updates
- more
- code reorg
- UI changes
- push
- UI and orchestraton changes for monitor and simulator
- simulator
- UI
- UI
- simulation + UI changes in monitor
- monitor ui fix
- live
- extrapolateing for minutes future preds
- same

## Key source files (fetched from GitHub, selected and truncated for size)

### devpost/content.md

```markdown
## Inspiration

Coming from India, I've seen far too many times that crowds are not managed well, and that overcrowding is almost always handled poorly. The Kumbh Mela, one of the largest human gatherings on Earth, sees people lose their lives to crowd crushes nearly every time it is held. I've lost some close relatives to such incidents. And it isn't just India: Itaewon in Seoul, the Love Parade in Germany, and Astroworld in the US, a lot of concerts, political gatherings, strikes/rallies, etc are all recent reminders that crowd crushes keep happening, in every place, at events that were planned months in advance.

The tragic part is that a crush is a *physics* problem before it is a human one. By the time a camera operator sees people falling, it is already too late. Event-planning agencies design venues ahead of time, but they have no way to know whether their layout will turn into a deadly bottleneck once real people fill it. I wanted to build a tool that solves both halves of that problem: the planning *and* the live monitoring so that event organizers can prevent a disaster instead of reacting to one.

## One liner

CrowdPhysics turns any existing camera into a crowd-crush early-warning system — and lets you simulate a venue's crowd flow, through the same perception pipeline, before the event ever happens.

## What it does

CrowdPhysics is a two-mode platform for crowd safety.

**Monitor mode** turns any existing camera feed — a CCTV stream, a phone, or a public webcam — into a crush-risk early-warning system. It reads the crowd purely as fluid dynamics: it extracts the optical flow, feeds it to a learned world model, and forecasts what the crowd will do next. When the model becomes "surprised" — when the real crowd starts behaving in a way it has never seen during calm footage — the system raises a warning *before* the crush forms. Claude then explains, in plain language, what is happening, decides the crush-risk percentage, and recommends what to do.

**Simulate mode** is the pre-event planning tool. Upload a photo or video of a venue, and agents reconstruct the space in 3D, simulate a crowd flowing through it, and surface the danger zones — peak-pressure sectors, bottlenecks, Fruin level-of-service, and time-to-crush — before a single person arrives. It then suggests how to arrange entrances, flow, and staff to make the layout safe.

A capability I am especially excited about lives in Simulate mode: **expected entry/exit flow**. We render the simulation as a synthetic-crowd video and push it through the *exact same RAFT optical-flow pipeline* used on live cameras — so before the event, an operator can preview the flow each door *should* show, and validate door placement against the perception system itself.


## Architecture

Two modes share one perception core — optical flow → a learned world model → an anomaly signal — and one explainer (Claude).

**Monitor pipeline — frame to warning.** Every consecutive frame pair becomes an optical-
[truncated — 9513 more characters]
```

### .agents/skills/arize-link/SKILL.md

```markdown
---
name: arize-link
description: Generates deep links to the Arize UI for traces, spans, sessions, datasets, labeling queues, evaluators, and annotation configs. Produces clickable URLs for sharing Arize resources with team members. Use when the user wants to link to or open a trace, span, session, dataset, evaluator, or annotation config in the Arize UI.
metadata:
  author: arize
  version: "1.0"
---

# Arize Link

Generate deep links to the Arize UI for traces, spans, sessions, datasets, labeling queues, evaluators, and annotation configs.

## When to Use

- User wants a link to a trace, span, session, dataset, labeling queue, evaluator, or annotation config
- You have IDs from exported data or logs and need to link back to the UI
- User asks to "open" or "view" any of the above in Arize

## Required Inputs

Collect from the user or context (exported trace data, parsed URLs):

| Always required | Resource-specific |
|---|---|
| `org_id` (base64) | `project_id` + `trace_id` [+ `span_id`] — trace/span |
| `space_id` (base64) | `project_id` + `session_id` — session |
| | `dataset_id` — dataset |
| | `queue_id` — specific queue (omit for list) |
| | `evaluator_id` [+ `version`] — evaluator |

**All path IDs must be base64-encoded** (characters: `A-Za-z0-9+/=`). A raw numeric ID produces a valid-looking URL that 404s. If the user provides a number, ask them to copy the ID directly from their Arize browser URL (`https://app.arize.com/organizations/{org_id}/spaces/{space_id}/…`). If you have a raw internal ID (e.g. `Organization:1:abC1`), base64-encode it before inserting into the URL.

## URL Templates

Base URL: `https://app.arize.com` (override for on-prem)

**Trace** (add `&selectedSpanId={span_id}` to highlight a specific span):
```
{base_url}/organizations/{org_id}/spaces/{space_id}/projects/{project_id}?selectedTraceId={trace_id}&queryFilterA=&selectedTab=llmTracing&timeZoneA=America%2FLos_Angeles&startA={start_ms}&endA={end_ms}&envA=tracing&modelType=generative_llm
```

**Session:**
```
{base_url}/organizations/{org_id}/spaces/{space_id}/projects/{project_id}?selectedSessionId={session_id}&queryFilterA=&selectedTab=llmTracing&timeZoneA=America%2FLos_Angeles&startA={start_ms}&endA={end_ms}&envA=tracing&modelType=generative_llm
```

**Dataset** (`selectedTab`: `examples` or `experiments`):
```
{base_url}/organizations/{org_id}/spaces/{space_id}/datasets/{dataset_id}?selectedTab=examples
```

**Queue list / specific queue:**
```
{base_url}/organizations/{org_id}/spaces/{space_id}/queues
{base_url}/organizations/{org_id}/spaces/{space_id}/queues/{queue_id}
```

**Evaluator** (omit `?version=…` for latest):
```
{base_url}/organizations/{org_id}/spaces/{space_id}/evaluators/{evaluator_id}
{base_url}/organizations/{org_id}/spaces/{space_id}/evaluators/{evaluator_id}?version={version_url_encoded}
```
The `version` value must be URL-encoded (e.g., trailing `=` → `%3D`).

**Annotation configs:**
```
{base_url}/organizations/{org_id}/spaces/{space_id}/a
[truncated — 1481 more characters]
```

### agents/requirements.txt

```
uagents
requests
playwright
yt-dlp
opencv-python-headless
numpy<2

```

### backend/requirements.txt

```
fastapi
uvicorn[standard]
python-multipart
anthropic>=0.84.0
arize-otel
openinference-instrumentation-anthropic
torch
torchvision
opencv-python-headless
numpy<2
Pillow
playwright
yt-dlp
requests

```

### frontend/package.json

```
{
  "name": "crowdphysics-ui",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@react-three/drei": "^9.122.0",
    "@react-three/fiber": "^8.18.0",
    "@types/three": "^0.169.0",
    "next": "14.2.3",
    "react": "^18",
    "react-dom": "^18",
    "react-markdown": "^9.0.1",
    "recharts": "^2.12.7",
    "three": "^0.169.0"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "autoprefixer": "^10.0.1",
    "eslint": "^8",
    "eslint-config-next": "14.2.3",
    "postcss": "^8",
    "tailwindcss": "^3.4.1",
    "typescript": "^5"
  }
}

```

### frontend/app/layout.tsx

```typescript
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "CrowdPhysics — AI Crowd Safety Platform",
  description:
    "Plan safe. Monitor live. Never react. Crowd fluid dynamics + AI safety platform.",
  icons: { icon: "/crowd_physics_logo.png" },
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <head>
        <link rel="preconnect" href="https://fonts.googleapis.com" />
        <link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
      </head>
      <body className="h-screen overflow-hidden bg-void text-text1">{children}</body>
    </html>
  );
}

```

### frontend/app/page.tsx

```typescript
"use client";

import { useState } from "react";
import MonitorTab from "@/components/MonitorTab";
import PlanTab from "@/components/PlanTab";

type Mode = "monitor" | "plan";

const MODES: Record<Mode, { label: string; tagline: string; desc: string; icon: React.ReactNode }> = {
  monitor: {
    label: "Monitor",
    tagline: "Real-time crowd safety",
    desc: "Pull a live camera feed or upload video. See the crowd flow, the world model's forecast of what happens next, and the agents reasoning about what's safe — live.",
    icon: (
      <svg viewBox="0 0 24 24" className="w-7 h-7" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
        <rect x="2" y="4" width="15" height="12" rx="2" />
        <path d="M17 9l5-2.5v11L17 15" />
        <circle cx="9.5" cy="10" r="2.5" />
      </svg>
    ),
  },
  plan: {
    label: "Simulate",
    tagline: "Build the crowd in 3D before the event",
    desc: "Upload a photo or video of a location. Agents rebuild it as a navigable 3D venue, fill it with a simulated crowd, race layout scenarios, and design the safest plan for your event.",
    icon: (
      <svg viewBox="0 0 24 24" className="w-7 h-7" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
        <path d="M9 3v18m6-18v18M3 9h18M3 15h18" />
        <rect x="3" y="3" width="18" height="18" rx="2" />
      </svg>
    ),
  },
};

const SPONSORS = ["Anthropic Claude", "Browserbase", "Fetch.ai Agentverse", "Arize"];

function Landing({ onEnter }: { onEnter: (m: Mode) => void }) {
  return (
    <div className="min-h-screen flex flex-col bg-void overflow-y-auto">
      {/* ambient glow */}
      <div
        className="pointer-events-none fixed inset-0 opacity-60"
        style={{
          background:
            "radial-gradient(900px 500px at 50% -10%, rgba(94,23,235,0.14), transparent 70%), radial-gradient(700px 400px at 100% 100%, rgba(226,169,241,0.08), transparent 70%)",
        }}
      />
      <div className="relative flex-1 flex flex-col items-center justify-center px-6 py-16 max-w-5xl mx-auto w-full">
        {/* brand logo */}
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src="/crowd_physics_logo.png"
          alt="CrowdPhysics — Plan safe. Monitor live. Never react."
          className="w-full max-w-md mb-8 select-none rounded-xl"
          style={{ mixBlendMode: "lighten" }}
        />

        <h1 className="display text-2xl sm:text-3xl font-bold text-center text-text1 leading-tight max-w-3xl">
          See the crowd&apos;s future <span style={{ color: "#e2a9f1" }}>before</span> it becomes a crisis.
        </h1>
        <p className="text-text3 text-center mt-4 max-w-xl text-sm leading-relaxed">
          A world model learns crowd fluid dynamics from raw video. Agents read the
          flow, forecast the danger, and tell you what to do — in plain language.
        </p>

        {/* two entry cards */}
        <div className="grid sm:grid-cols-2 gap-4 mt-12 w-full max-w-3xl">
          {(Object.keys(MODES) as Mode[]).map((m) => {
            const meta = MODES[m];
            return (
              <button
                key={m}
                onClick={() => onEnter(m)}
                className="card group text-left p-6 transition-all duration-200 hover:-translate-y-1"
                style={{ borderColor: "#21262D" }}
              >
                <div className="w-12 h-12 rounded-xl flex items-center justify-center text-teal mb-4 border border-teal/20"
                  style={{ background: "rgba(94,23,235,0.10)" }}>
                  {meta.icon}
                </div>
                <div className="flex items-center gap-2">
                  <h2 className="display text-xl font-bold text-text1">{meta.label}</h2>
                  <svg className="w-4 h-4 text-text3 group-hover:text-teal group-hover:translate-x-0.5 transition-all" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="2">
                    <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
                  </svg>
                </div>
                <p className="font-mono text-[10px] text-teal/80 uppercase tracking-wider mt-1">{meta.tagline}</p>
                <p className="text-text3 text-[13px] leading-relaxed mt-3">{meta.desc}</p>
              </button>
            );
          })}
        </div>
      </div>

      {/* footer */}
      <div className="relative border-t border-border px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-2 max-w-5xl mx-auto w-full">
        <p className="font-mono text-[9px] text-text3/60">UC Berkeley AI Hackathon 2026</p>
        <div className="flex items-center gap-3 flex-wrap justify-center">
          <span className="font-mono text-[9px] text-text3/50">Built with</span>
          {SPONSORS.map((s) => (
            <span key={s} className="font-mono text-[9px] text-text3/70">{s}</span>
          ))}
        </div>
      </div>
    </div>
  );
}

export default function Home() {
  const [entered, setEntered] = useState(false);
  const [mode, setMode] = useState<Mode>("monitor");

  if (!entered) {
    return <Landing onEnter={(m) => { setMode(m); setEntered(true); }} />;
  }

  return (
    <div className="flex flex-col h-screen bg-void overflow-hidden">
      {/* ── Top bar ──────────────────────────────────── */}
      <header className="flex items-center justify-between px-5 py-2.5 border-b border-border flex-shrink-0"
        style={{ background: "linear-gradient(90deg, #10151D 0%, #0D1117 100%)" }}>

        {/* brand → home */}
        <button onClick={() => setEntered(false)} className="flex items-center group" title="Back to home">
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img
            src="/crowd_physics_logo.png"
            alt="CrowdPhysics"
            className="h-11 w-auto object-contain group-hover:opacity-80 transiti
[truncated — 2004 more characters]
```

### instrumentation.py

```python
# instrumentation.py
"""
Arize AX tracing setup for CrowdPhysics.

Auto-instruments every Anthropic (Claude) call made through
claude_interpreter.py and ships OpenInference spans to Arize AX.

Call setup_tracing() ONCE at process startup, before the Anthropic
client issues any requests. Safe to call multiple times (no-ops after
the first successful registration). Fails gracefully if credentials are
missing so the app still runs without observability.

Env vars:
    ARIZE_SPACE_ID   — from app.arize.com space settings
    ARIZE_API_KEY    — from app.arize.com space settings
    ARIZE_PROJECT    — optional project name (default: crowdphysics)
"""

from __future__ import annotations

import os

_INITIALIZED = False


def setup_tracing() -> bool:
    """Register Arize tracing + Anthropic instrumentor. Returns True if active."""
    global _INITIALIZED
    if _INITIALIZED:
        return True

    space_id = os.environ.get("ARIZE_SPACE_ID")
    api_key = os.environ.get("ARIZE_API_KEY")

    if not space_id or not api_key:
        print(
            "[arize] ⚠  ARIZE_SPACE_ID / ARIZE_API_KEY not set — "
            "tracing disabled (app runs normally)."
        )
        return False

    try:
        from arize.otel import register
        from openinference.instrumentation.anthropic import AnthropicInstrumentor

        tracer_provider = register(
            space_id=space_id,
            api_key=api_key,
            project_name=os.environ.get("ARIZE_PROJECT", "crowdphysics"),
        )
        AnthropicInstrumentor().instrument(tracer_provider=tracer_provider)

        _INITIALIZED = True
        print("[arize] ✓ Tracing active — Claude calls streaming to Arize AX")
        return True
    except Exception as exc:
        print(f"[arize] ⚠  Tracing setup failed ({exc}) — continuing without it")
        return False


def _get_tracer():
    """OTel tracer if Arize tracing is active, else None."""
    if not _INITIALIZED:
        return None
    try:
        from opentelemetry import trace
        return trace.get_tracer("crowdphysics.evals")
    except Exception:
        return None


def trace_evaluation(span_name: str, eval_name: str, fn):
    """
    Run `fn()` (an LLM-as-judge that returns {score, label, rationale, ...})
    inside a dedicated Arize span and attach the result as an evaluation.

    The score/label/explanation are written as `eval.<eval_name>.*` span
    attributes, which Arize AX ingests and displays as an evaluation on the
    trace. Fully best-effort: if tracing is off or anything fails, `fn()` still
    runs and its result is returned unchanged.

    Returns whatever `fn()` returns (or None on judge failure).
    """
    tracer = _get_tracer()
    if tracer is None:
        try:
            return fn()
        except Exception:
            return None

    with tracer.start_as_current_span(span_name) as span:
        try:
            span.set_attribute("openinference.span.kind", "EVALUATOR")
        except Exception:
            pass
        try:
            result = fn()
        except Exception as exc:
            try:
                span.set_attribute("error.message", str(exc))
            except Exception:
                pass
            return None

        if isinstance(result, dict):
            try:
                if result.get("score") is not None:
                    span.set_attribute(f"eval.{eval_name}.score", float(result["score"]))
                if result.get("label") is not None:
                    span.set_attribute(f"eval.{eval_name}.label", str(result["label"]))
                expl = result.get("rationale") or result.get("explanation")
                if expl:
                    span.set_attribute(f"eval.{eval_name}.explanation", str(expl)[:1000])
            except Exception:
                pass
        return result

```

### rl_policy.py

```python
# rl_policy.py
"""
Phase 3a: RL Policy
Conservative Q-Learning with Dueling DQN architecture.
"""

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np


# ─── ACTION SPACE ─────────────────────────────────────────────────────────────

ACTIONS = {
    0: ("monitor",          "Observe. No intervention needed."),
    1: ("increase_egress",  "Open exits — reduce crowd compression."),
    2: ("reduce_ingress",   "Slow or halt incoming crowd flow."),
    3: ("lateral_redirect", "Guide crowd sideways — relieve pressure zone."),
    4: ("disperse",         "Signal crowd to spread out."),
    5: ("partial_evac",     "Clear high-pressure zone via nearest safe exit."),
    6: ("full_evac",        "Full evacuation. Contact emergency services.")
}
N_ACTIONS = len(ACTIONS)


# ─── Q-NETWORK ────────────────────────────────────────────────────────────────

class CrowdQNetwork(nn.Module):
    """
    Dueling DQN: separate value and advantage streams.

    Why dueling?
    - Value stream: "how dangerous is this situation overall?"
    - Advantage stream: "which action is relatively better?"
    - Combined: Q = V + (A - mean(A))
    - More stable than standard DQN, better at safety-critical decisions

    Input: latent crowd state z (64-dim)
    Output: Q-value per action (7 values)
    """
    def __init__(self, latent_dim=64, n_actions=N_ACTIONS, hidden_dim=256):
        super().__init__()
        self.n_actions = n_actions

        # Shared feature extraction
        self.shared = nn.Sequential(
            nn.Linear(latent_dim, hidden_dim),
            nn.LayerNorm(hidden_dim),
            nn.SiLU(),
            nn.Linear(hidden_dim, hidden_dim),
            nn.SiLU(),
            nn.Linear(hidden_dim, 128)
        )

        # Value stream: single scalar
        self.value_stream = nn.Sequential(
            nn.Linear(128, 64),
            nn.SiLU(),
            nn.Linear(64, 1)
        )

        # Advantage stream: one per action
        self.advantage_stream = nn.Sequential(
            nn.Linear(128, 64),
            nn.SiLU(),
            nn.Linear(64, n_actions)
        )

    def forward(self, z):
        """
        z: (batch, latent_dim) or (latent_dim,)
        Returns: Q-values (batch, n_actions)
        """
        if z.dim() == 1:
            z = z.unsqueeze(0)

        features = self.shared(z)
        value = self.value_stream(features)          # (batch, 1)
        advantage = self.advantage_stream(features)  # (batch, n_actions)

        # Dueling combination
        q = value + advantage - advantage.mean(dim=-1, keepdim=True)
        return q

    def best_action(self, z):
        """Get best action index"""
        with torch.no_grad():
            q = self.forward(z)
            return int(q.argmax(dim=-1).item())

    def get_full_recommendation(self, z):
        """
        Get complete intervention recommendation with all details.
        This is what goes to Claude for explanation.
        """
        with torch.no_grad():
            q = self.forward(z)
            q_np = q[0].numpy()

        best_idx = int(q_np.argmax())
        probs = F.softmax(torch.FloatTensor(q_np), dim=0).numpy()

        ranked = sorted(
            [(i, float(q_np[i]), ACTIONS[i][0], ACTIONS[i][1])
             for i in range(N_ACTIONS)],
            key=lambda x: -x[1]
        )

        return {
            'action_idx': best_idx,
            'action_name': ACTIONS[best_idx][0],
            'action_description': ACTIONS[best_idx][1],
            'confidence': float(probs[best_idx]),
            'q_values': {
                ACTIONS[i][0]: float(q_np[i]) for i in range(N_ACTIONS)
            },
            'top_3': [
                {
                    'rank': r+1,
                    'action': name,
                    'description': desc,
                    'q_value': round(float(q), 3)
                }
                for r, (i, q, name, desc) in enumerate(ranked[:3])
            ],
            'all_q': q_np.tolist()
        }


# ─── CQL LOSS ─────────────────────────────────────────────────────────────────

def compute_cql_loss(q_net, target_net, batch, gamma=0.99, alpha=0.5):
    """
    Conservative Q-Learning loss.

    = TD loss (standard Q-learning)
    + alpha * CQL penalty (conservative regularization)

    CQL penalty: log(sum(exp(Q(s,a)))) - Q(s, a_taken)
    This penalizes high Q-values for actions not in the dataset.
    Effect: policy only recommends actions it has seen work.
    Perfect for safety-critical systems.

    Args:
        alpha: CQL weight. Higher = more conservative.
               0.5 is good for crowd safety.
    """
    states, actions, rewards, next_states, dones = batch

    # Current Q-values
    q_values = q_net(states)                              # (B, n_actions)
    q_taken = q_values.gather(
        1, actions.unsqueeze(1)
    ).squeeze(1)                                           # (B,)

    # Target Q-values (Double DQN: action from q_net, value from target)
    with torch.no_grad():
        next_actions = q_net(next_states).argmax(dim=1)
        next_q = target_net(next_states).gather(
            1, next_actions.unsqueeze(1)
        ).squeeze(1)
        targets = rewards + gamma * next_q * (1 - dones)

    # TD loss
    td_loss = F.smooth_l1_loss(q_taken, targets)

    # CQL penalty
    cql_penalty = (
        torch.logsumexp(q_values, dim=1) - q_taken
    ).mean()

    total = td_loss + alpha * cql_penalty

    return total, {
        'td_loss': round(float(td_loss), 5),
        'cql_loss': round(float(cql_penalty), 5),
        'total_loss': round(float(total), 5),
        'mean_q': round(float(q_taken.mean()), 4)
    }

```

### metrics_logger.py

```python
# metrics_logger.py
"""
Lightweight training-metrics logger shared by every training job.

For each run it writes, under logs/<job>_<timestamp>/:
  - metrics.csv   crash-safe, one row per logged step (appended live)
  - summary.json  config + final/best values + run metadata
  - curves.png    loss/metric curves (one subplot per metric)

No external services or accounts. matplotlib is optional — if it's not
installed the CSV/JSON are still written and a warning is printed.

Usage:
    log = MetricsLogger("world_model", config={"epochs": 80})
    for epoch in range(epochs):
        ...
        log.log(epoch, loss=avg, best=best_loss)
    log.close(plot_keys=["loss", "best"])
"""

from __future__ import annotations

import csv
import json
import time
from pathlib import Path
from typing import Any


class MetricsLogger:
    def __init__(self, job_name: str, out_dir: str = "logs",
                 config: dict[str, Any] | None = None):
        self.job_name = job_name
        self.start = time.time()
        self.run_id = f"{job_name}_{time.strftime('%Y%m%d_%H%M%S')}"
        self.dir = Path(out_dir) / self.run_id
        self.dir.mkdir(parents=True, exist_ok=True)

        self.csv_path = self.dir / "metrics.csv"
        self.json_path = self.dir / "summary.json"
        self.png_path = self.dir / "curves.png"

        self.rows: list[dict[str, Any]] = []
        self.fieldnames: list[str] | None = None
        self.config = config or {}

        print(f"[metrics] run '{self.run_id}' → {self.dir}/")

    # ── logging ───────────────────────────────────────────────────────────────

    def log(self, step: int, **metrics: Any) -> dict[str, Any]:
        """Record one step. Numeric values are cast to float."""
        row: dict[str, Any] = {
            "step": int(step),
            "elapsed_s": round(time.time() - self.start, 1),
        }
        for k, v in metrics.items():
            try:
                row[k] = float(v)
            except (TypeError, ValueError):
                row[k] = v
        self.rows.append(row)
        self._write_csv(row)
        return row

    def _write_csv(self, row: dict[str, Any]) -> None:
        # If a new key appears, rewrite the whole file with a unified header.
        needs_rewrite = self.fieldnames is None or any(
            k not in self.fieldnames for k in row)
        if needs_rewrite:
            keys: list[str] = []
            for r in self.rows:
                for k in r:
                    if k not in keys:
                        keys.append(k)
            self.fieldnames = keys
            with open(self.csv_path, "w", newline="") as f:
                w = csv.DictWriter(f, fieldnames=self.fieldnames)
                w.writeheader()
                w.writerows(self.rows)
        else:
            with open(self.csv_path, "a", newline="") as f:
                w = csv.DictWriter(f, fieldnames=self.fieldnames)
                w.writerow(row)

    # ── finalize ───────────────────────────────────────────────────────────────

    def close(self, plot_keys: list[str] | None = None) -> None:
        """Write summary.json and render curves.png."""
        numeric_keys = self._numeric_keys()
        summary = {
            "run_id": self.run_id,
            "job": self.job_name,
            "config": self.config,
            "n_steps": len(self.rows),
            "duration_s": round(time.time() - self.start, 1),
            "final": self.rows[-1] if self.rows else {},
            "best": self._best(numeric_keys),
            "files": {
                "csv": str(self.csv_path),
                "png": str(self.png_path),
            },
        }
        with open(self.json_path, "w") as f:
            json.dump(summary, f, indent=2)

        self._plot(plot_keys or numeric_keys)
        print(f"[metrics] saved summary → {self.json_path}")

    def _numeric_keys(self) -> list[str]:
        skip = {"step", "elapsed_s"}
        keys: list[str] = []
        for r in self.rows:
            for k, v in r.items():
                if k not in skip and k not in keys and isinstance(v, float):
                    keys.append(k)
        return keys

    def _best(self, keys: list[str]) -> dict[str, Any]:
        """Best (min for losses, max for rewards/accuracy) per metric."""
        best: dict[str, Any] = {}
        for k in keys:
            vals = [r[k] for r in self.rows if isinstance(r.get(k), float)]
            if not vals:
                continue
            is_loss = "loss" in k.lower() or k.lower() in ("best",)
            best[k] = round(min(vals) if is_loss else max(vals), 6)
        return best

    def _plot(self, keys: list[str]) -> None:
        keys = [k for k in keys if any(isinstance(r.get(k), float)
                                       for r in self.rows)]
        if not keys:
            return
        try:
            import matplotlib
            matplotlib.use("Agg")
            import matplotlib.pyplot as plt
        except Exception as exc:  # noqa: BLE001
            print(f"[metrics] matplotlib unavailable ({exc}); "
                  f"skipping PNG. Data is in {self.csv_path}")
            return

        steps = [r["step"] for r in self.rows]
        n = len(keys)
        fig, axes = plt.subplots(n, 1, figsize=(8, 2.6 * n), squeeze=False)
        for ax, k in zip(axes[:, 0], keys):
            ys = [r.get(k) for r in self.rows]
            xs = [s for s, y in zip(steps, ys) if isinstance(y, float)]
            yy = [y for y in ys if isinstance(y, float)]
            ax.plot(xs, yy, color="#0ea5e9", linewidth=1.6)
            ax.set_title(f"{self.job_name} — {k}", fontsize=10)
            ax.set_xlabel("step")
            ax.set_ylabel(k)
            ax.grid(True, alpha=0.25)
        fig.tight_layout()
        fig.savefig(self.png_path, dpi=120)
        plt.close(fig)
        print(f"[metrics] saved curves → {self.png_path}")

```

[83 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]