Spaces:
Sleeping
Sleeping
Commit Β·
8ffd6a9
1
Parent(s): b693c53
Final optimizations, RL endpoint, dataset upload UI, and Hackathon artifacts
Browse files- .gitattributes +2 -0
- Dockerfile +16 -2
- PITCH.md +31 -13
- README.md +162 -40
- RecallTrace_Colab_Training.ipynb +74 -0
- TRAINING_GUIDE.md +152 -0
- baseline/policy.py +7 -0
- env/env.py +318 -8
- env/models.py +26 -0
- fretfch.json +35 -0
- pyproject.toml +2 -0
- recover_plots.py +51 -0
- requirements.txt +8 -1
- selfplay/investigator.py +67 -20
- selfplay/trainer.py +12 -0
- server/app.py +592 -6
- server/static/app.js +1050 -194
- server/static/architecture.html +621 -0
- server/static/fretfch.json +35 -0
- server/static/index.html +787 -107
- server/static/styles.css +608 -362
- train_trl.py +525 -0
- training_data.json +0 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
*.json filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
CHANGED
|
@@ -1,16 +1,30 @@
|
|
| 1 |
-
FROM
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
WORKDIR /app
|
| 4 |
|
| 5 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 6 |
PYTHONUNBUFFERED=1 \
|
| 7 |
-
PORT=7860
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
COPY requirements.txt ./
|
| 10 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 11 |
|
| 12 |
COPY . .
|
| 13 |
|
|
|
|
|
|
|
| 14 |
EXPOSE 7860
|
| 15 |
|
| 16 |
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 1 |
+
FROM nvidia/cuda:12.1.1-runtime-ubuntu22.04
|
| 2 |
+
|
| 3 |
+
# Install Python 3
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
+
python3 python3-venv python3-pip curl && \
|
| 6 |
+
rm -rf /var/lib/apt/lists/* && \
|
| 7 |
+
ln -sf /usr/bin/python3 /usr/bin/python
|
| 8 |
|
| 9 |
WORKDIR /app
|
| 10 |
|
| 11 |
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 12 |
PYTHONUNBUFFERED=1 \
|
| 13 |
+
PORT=7860 \
|
| 14 |
+
MPLBACKEND=Agg \
|
| 15 |
+
HF_HOME=/tmp/hf_cache \
|
| 16 |
+
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
| 17 |
+
ENABLE_HF_MODEL_PREFETCH=1 \
|
| 18 |
+
LLM_HUB_MODEL=ms-shamanth/recalltrace-investigator \
|
| 19 |
+
LLM_BASE_MODEL=unsloth/Qwen2.5-0.5B-Instruct-bnb-4bit
|
| 20 |
|
| 21 |
COPY requirements.txt ./
|
| 22 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 23 |
|
| 24 |
COPY . .
|
| 25 |
|
| 26 |
+
RUN mkdir -p plots
|
| 27 |
+
|
| 28 |
EXPOSE 7860
|
| 29 |
|
| 30 |
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
PITCH.md
CHANGED
|
@@ -34,39 +34,43 @@ They train together. Two hundred episodes. The Adversary discovers on its own th
|
|
| 34 |
|
| 35 |
This is recursive skill amplification β Theme 4's exact language β running inside a world-modeling environment. The benchmark doesn't just test the agent. The benchmark teaches itself to be harder.
|
| 36 |
|
| 37 |
-
### [1:10β1:45] Demo
|
| 38 |
|
| 39 |
-
Let me show you what the learning actually looks like.
|
| 40 |
|
| 41 |
-
*[Show
|
| 42 |
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
| 46 |
|
| 47 |
-
|
| 48 |
|
| 49 |
### [1:45β2:15] Results
|
| 50 |
|
| 51 |
-
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
|
| 54 |
|
| 55 |
-
|
| 56 |
|
| 57 |
-
|
| 58 |
|
| 59 |
### [2:15β2:45] Why This Matters
|
| 60 |
|
| 61 |
-
RecallTrace is not just a benchmark environment. It is a benchmark that evolves.
|
| 62 |
|
| 63 |
Every domain where a hidden causal intervention creates an observable pattern under partial information β pharmaceutical contamination, financial fraud, biosecurity, network intrusion β can use this framework. You swap the graph topology, you swap the intervention types, and you have a new self-play benchmark for causal reasoning.
|
| 64 |
|
| 65 |
-
We're not submitting an environment. We're submitting an environment design pattern where the curriculum writes itself.
|
| 66 |
|
| 67 |
### [2:45β3:00] Close
|
| 68 |
|
| 69 |
-
We built an agent that learns to reason causally
|
| 70 |
|
| 71 |
RecallTrace. Thank you.
|
| 72 |
|
|
@@ -119,3 +123,17 @@ Two hundred episodes in under one second on CPU. No GPU. No external RL librarie
|
|
| 119 |
> RecallTrace is the only submission that implements **recursive skill amplification** (Theme 4) **inside a world-modeling environment** (Theme 3.1) with a working self-play loop that produces visible, measurable behavior change in under sixty seconds on CPU.
|
| 120 |
|
| 121 |
The benchmark doesn't just test agents. It teaches itself to be harder. The adversary finds what's difficult. The investigator learns to overcome it. The environment evolves. That's what makes this submission legendary.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
This is recursive skill amplification β Theme 4's exact language β running inside a world-modeling environment. The benchmark doesn't just test the agent. The benchmark teaches itself to be harder.
|
| 36 |
|
| 37 |
+
### [1:10β1:45] The Live Demo & Episode Comparison
|
| 38 |
|
| 39 |
+
Let me show you what the learning actually looks like. If you go to our interactive dashboard on Hugging Face Spaces, you can see the **Episode Comparison** tab.
|
| 40 |
|
| 41 |
+
*[Show the Episode Comparison Tab]*
|
| 42 |
|
| 43 |
+
Here we compare the worst early episode against the best late episode side-by-side.
|
| 44 |
+
On the left (Early Episode), the agent visits 10 nodes and quarantines 9 of them. It's guessing blindly, resulting in an F1 score of 0.36.
|
| 45 |
+
On the right (Late Episode), it visits just 3 nodes and quarantines exactly 3 β hitting a perfect F1 score of 1.0. It correctly identifies the intervention as a mixing event *before* it quarantines, while calibrating its threshold perfectly.
|
| 46 |
|
| 47 |
+
The agent went from guessing to reasoning. That's a profound behavior change.
|
| 48 |
|
| 49 |
+
And we didn't stop at RL. We took these expert demonstrations and used them to fine-tune a 4-bit Large Language Model (`Qwen2.5-0.5B-Instruct`). Under the **π€ Live LLM Demo** tab, you can watch this LLM investigate graphs in real-time on our live GPU.
|
| 50 |
|
| 51 |
### [1:45β2:15] Results
|
| 52 |
|
| 53 |
+
### [1:45β2:15] Results
|
| 54 |
+
|
| 55 |
+
*[Navigate to the Dashboard's **Co-Evolution** and **Belief Calibration** Tabs]*
|
| 56 |
|
| 57 |
+
Looking at the interactive dashboard, you can see the underlying engine at work. In the **Co-Evolution** tab, the adversary's reward flips from positive to negative right as the investigator catches up. They improve simultaneously. The F1 never hits 1.0 because the adversary keeps finding harder hiding spots.
|
| 58 |
|
| 59 |
+
In the **Belief Calibration** tab, you see the investigator's confidence (P-contaminated) drop early on as it gets confused, and then sharply rise and stabilize above the quarantine threshold. It learns exactly *when* it has enough evidence to act.
|
| 60 |
|
| 61 |
+
This entire self-play loop ran in under one second on CPU, generating the perfect expert dataset that powers the LLM you just saw.
|
| 62 |
|
| 63 |
### [2:15β2:45] Why This Matters
|
| 64 |
|
| 65 |
+
RecallTrace is not just a benchmark environment. It is a benchmark that evolves, paired with an inference engine that translates that evolution into a deployable model.
|
| 66 |
|
| 67 |
Every domain where a hidden causal intervention creates an observable pattern under partial information β pharmaceutical contamination, financial fraud, biosecurity, network intrusion β can use this framework. You swap the graph topology, you swap the intervention types, and you have a new self-play benchmark for causal reasoning.
|
| 68 |
|
| 69 |
+
We're not submitting an environment. We're submitting an environment design pattern where the curriculum writes itself, and the resulting expert data trains a specialized reasoning LLM.
|
| 70 |
|
| 71 |
### [2:45β3:00] Close
|
| 72 |
|
| 73 |
+
We built an agent that learns to reason causally, an adversary that forces it to keep getting better, and a live web dashboard running a fine-tuned LLM that executes that reasoning in real-time. The Investigator doesn't just find contamination. It identifies the intervention type, calibrates its confidence, and stops when it's certain. That's not tool use. That's causal inference. And with self-play, it's causal inference that improves recursively.
|
| 74 |
|
| 75 |
RecallTrace. Thank you.
|
| 76 |
|
|
|
|
| 123 |
> RecallTrace is the only submission that implements **recursive skill amplification** (Theme 4) **inside a world-modeling environment** (Theme 3.1) with a working self-play loop that produces visible, measurable behavior change in under sixty seconds on CPU.
|
| 124 |
|
| 125 |
The benchmark doesn't just test agents. It teaches itself to be harder. The adversary finds what's difficult. The investigator learns to overcome it. The environment evolves. That's what makes this submission legendary.
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
### RecallTrace Architecture & Environment Flow
|
| 130 |
+
The RecallTrace Hugging Face Space operates as a Python-based Gradio application hosting an OpenEnv-compliant causal inference benchmark. At its core, the system runs a two-agent adversarial self-play loop. In this environment, an **Investigator** must identify and isolate a hidden contamination event within a procedurally generated, partially observable supply graph. An opposing **Adversary** intelligently places these interventions to maximize the Investigator's failure rate. The environment enforces an ungameable, composable reward function that computes a final score based on Recall (catching unsafe nodes), Precision (sparing safe nodes), Belief Calibration (making confident decisions), and Efficiency (using fewer steps).
|
| 131 |
+
|
| 132 |
+
### The Adaptive Heuristic Search
|
| 133 |
+
The Heuristic Investigator serves as an interpretable, fast-adapting baseline. Instead of neural networks, this agent uses dynamic, rule-based heuristics governed by learnable thresholds (e.g., quarantine confidence limits and "trust" in ambiguous lab results). After every episode, the agent calculates its F1 score (the harmonic mean of its precision and recall accuracy). If the F1 score dips, the agent adjusts its internal thresholds using an Exponential Moving Average (EMA). This allows the heuristic search to continuously tune its exploration and exploitation strategies dynamically, finding optimal paths through the causal graph with a very low computational footprint.
|
| 134 |
+
|
| 135 |
+
### The PyTorch RL Agent
|
| 136 |
+
The PyTorch RL Investigator is powered by a Deep Reinforcement Learning policy network. Because the environment's observation space is variable (graphs change size, inventory fluctuates), the architecture utilizes a `StateEncoder` to map the raw observation dictionaries into a fixed 112-dimensional feature tensor. This tensor is fed into a Multi-Layer Perceptron (MLP) equipped with three distinct output heads: an **Action Head** (to select one of the 7 tools), a **Node Head** (to target a specific node), and a **Value Head** (to predict the baseline reward). The model is trained using the **REINFORCE** algorithm. To ensure stable learning, the Value Head serves as a learned baseline to reduce variance, while an underlying entropy regularization coefficient forces the model to maintain exploration, preventing it from collapsing into trivial behaviors like quarantining every node immediately.
|
| 137 |
+
|
| 138 |
+
### Adversarial Co-Evolution & Plot Generation
|
| 139 |
+
As the Investigator learns, the learning environment dynamically shifts. The Adversary operates using an 18-cell dynamic score table cross-referencing three dimensions: Intervention Type, Graph Region, and Density Bucket. It uses a temperature-scaled Softmax distribution to sample attacks. If the Investigator expertly solves a specific scenario (scoring a high F1), the Adversary penalizes that specific cell in its table, forcing it to try novel attack patterns. Throughout this process, Python's Matplotlib continuously buffers the telemetry data. The **RL F1 Curve** plots the agent's expanding accuracy across episodes. The **RL Training Curve** tracks the underlying REINFORCE policy loss against the agent's reward. Finally, the **Co-Evolution Curve** maps the dual-agent progression, visually demonstrating the "arms race" where the Adversary's success metric dips precisely as the Investigator's capabilities improve.
|
README.md
CHANGED
|
@@ -3,93 +3,215 @@ title: RecallTrace OpenEnv
|
|
| 3 |
emoji: π¨
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: blue
|
| 6 |
-
sdk:
|
| 7 |
-
app_file: app.py
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
# RecallTrace: Causal Inference via Adversarial Self-Play
|
| 12 |
|
| 13 |
-
An RL agent that doesn't just
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
---
|
| 18 |
|
| 19 |
-
##
|
| 20 |
|
| 21 |
-
|
| 22 |
-
python run_selfplay.py
|
| 23 |
-
```
|
| 24 |
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
---
|
| 28 |
|
| 29 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
|
| 32 |
-
-
|
| 33 |
-
-
|
| 34 |
-
-
|
|
|
|
| 35 |
|
| 36 |
---
|
| 37 |
|
| 38 |
-
##
|
| 39 |
|
| 40 |
-
###
|
| 41 |
-
*(Generated automatically when you run the script)*
|
| 42 |
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
| 46 |
-
*(
|
| 47 |
|
| 48 |

|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
---
|
| 51 |
|
| 52 |
## π§ Why This Is Unique
|
| 53 |
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
| 58 |
|
| 59 |
---
|
| 60 |
|
| 61 |
## βοΈ How It Works
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
---
|
| 68 |
|
| 69 |
## π§ͺ Reproducibility
|
| 70 |
|
| 71 |
-
- **
|
| 72 |
-
- **
|
| 73 |
-
- **
|
|
|
|
| 74 |
|
| 75 |
---
|
| 76 |
|
| 77 |
## π¦ Project Structure
|
|
|
|
| 78 |
```text
|
| 79 |
recalltrace-openenv/
|
| 80 |
-
βββ
|
| 81 |
-
βββ
|
| 82 |
-
βββ
|
| 83 |
-
βββ
|
| 84 |
-
βββ
|
| 85 |
-
βββ
|
| 86 |
-
βββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
β
|
| 88 |
-
βββ
|
| 89 |
-
βββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
β
|
| 91 |
-
βββ plots/
|
| 92 |
β βββ selfplay_training.png
|
| 93 |
β βββ before_after_demo.png
|
| 94 |
β βββ episode_comparison.png
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
```
|
|
|
|
| 3 |
emoji: π¨
|
| 4 |
colorFrom: red
|
| 5 |
colorTo: blue
|
| 6 |
+
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
# RecallTrace: Causal Inference via Adversarial Self-Play
|
| 11 |
|
| 12 |
+
> An RL agent that doesn't just detect contamination β it infers the **hidden causal intervention** behind it. Trained via adversarial self-play, where an adversary learns to hide better as the investigator learns to reason better.
|
| 13 |
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## π Quick Links
|
| 17 |
+
|
| 18 |
+
| Resource | Link |
|
| 19 |
+
|---|---|
|
| 20 |
+
| π **Live Demo** | [HF Space](https://huggingface.co/spaces/ms-shamanth/recalltrace-openenv) |
|
| 21 |
+
| π€ **Trained Model** | [ms-shamanth/recalltrace-investigator](https://huggingface.co/ms-shamanth/recalltrace-investigator) |
|
| 22 |
+
| π **Colab Training** | [RecallTrace_Colab_Training.ipynb](RecallTrace_Colab_Training.ipynb) (Unsloth + TRL) |
|
| 23 |
+
| πΊ **Video Walkthrough**| [YouTube Link](https://youtube.com/...) *(Author to insert link here)* |
|
| 24 |
+
| π **Self-Play Training** | [run_selfplay.py](run_selfplay.py) |
|
| 25 |
|
| 26 |
---
|
| 27 |
|
| 28 |
+
## π― Problem: Why This Matters
|
| 29 |
|
| 30 |
+
**Real-world supply-chain recalls** (FDA food safety, automotive parts, pharmaceuticals) involve tracing contamination through complex multi-hop logistics networks β where evidence is partial, labels are unreliable, and bad actors actively conceal the source.
|
|
|
|
|
|
|
| 31 |
|
| 32 |
+
Current LLMs and RL agents struggle with:
|
| 33 |
+
- **Causal inference under partial observability** β 30-50% of graph edges are hidden
|
| 34 |
+
- **Adversarial robustness** β the contamination strategy adapts to the investigator
|
| 35 |
+
- **Belief calibration** β knowing *when* you have enough evidence to quarantine
|
| 36 |
+
|
| 37 |
+
RecallTrace is the first OpenEnv environment that trains an agent to perform **abductive causal reasoning** against an adaptive adversary.
|
| 38 |
|
| 39 |
---
|
| 40 |
|
| 41 |
+
## π The Environment
|
| 42 |
+
|
| 43 |
+
### What the Agent Sees
|
| 44 |
+
A supply-chain graph with nodes (warehouses, crossdocks, retailers) holding inventory lots. A recall notice alerts the agent to contamination β but the source, spread pattern, and intervention type are hidden.
|
| 45 |
+
|
| 46 |
+
### What the Agent Does
|
| 47 |
+
| Action | Purpose | Reward |
|
| 48 |
+
|---|---|---|
|
| 49 |
+
| `inspect_node` | Examine a node's inventory and evidence | +0.08 to +0.20 |
|
| 50 |
+
| `trace_lot` | Follow a lot through the shipment graph | +0.12 to +0.25 |
|
| 51 |
+
| `quarantine` | Isolate contaminated stock at a node | +0.28 (correct) / -0.35 (false positive) |
|
| 52 |
+
| `notify` | Alert downstream stakeholders | +0.04 per affected node |
|
| 53 |
+
| `finalize` | Submit final containment decision | Composite score (0-1) |
|
| 54 |
|
| 55 |
+
### What Makes It Hard
|
| 56 |
+
- **Hidden interventions**: The adversary picks one of 3 strategies (lot relabeling, mixing events, record deletion) and places it in the graph
|
| 57 |
+
- **Decoys**: False positives are planted to mislead the investigator
|
| 58 |
+
- **Partial observability**: The agent must reason about hidden edges and infer causality
|
| 59 |
+
- **Adversarial curriculum**: The adversary adapts its strategy based on agent weaknesses
|
| 60 |
|
| 61 |
---
|
| 62 |
|
| 63 |
+
## π Training
|
| 64 |
|
| 65 |
+
### Self-Play Training (Heuristic Agents)
|
|
|
|
| 66 |
|
| 67 |
+
```bash
|
| 68 |
+
python run_selfplay.py
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Runs **200 episodes** in <2 seconds on CPU. The investigator and adversary co-evolve:
|
| 72 |
|
| 73 |
+

|
| 74 |
+
*Figure 1: Four-panel training curves showing F1 improvement from 0.58 β 1.0, adversary reward declining, quarantine precision increasing (8.3 β 3.1 nodes), and investigation efficiency improving (25 β 11 steps).*
|
| 75 |
|
| 76 |

|
| 77 |
+
*Figure 2: Side-by-side comparison of untrained (spray-and-pray) vs trained (precision targeting) agent behavior on the same supply-chain graph.*
|
| 78 |
+
|
| 79 |
+
### LLM Training (Unsloth + TRL)
|
| 80 |
+
|
| 81 |
+
```bash
|
| 82 |
+
pip install unsloth "trl>=0.12" datasets accelerate
|
| 83 |
+
python train_trl.py --push-model
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
Fine-tunes **Qwen2.5-0.5B-Instruct** (4-bit via Unsloth) on expert demonstrations using TRL SFTTrainer:
|
| 87 |
+
|
| 88 |
+
1. **Data Generation**: Runs heuristic expert on 300 episodes β collects high-reward (observation, action) pairs
|
| 89 |
+
2. **SFT Training**: Fine-tunes with LoRA (r=16) for 3 epochs
|
| 90 |
+
3. **Evaluation**: Compares random baseline vs heuristic vs trained LLM
|
| 91 |
+
4. **Push**: Uploads trained model to [HF Hub](https://huggingface.co/ms-shamanth/recalltrace-investigator)
|
| 92 |
+
|
| 93 |
+
**Re-run in Colab:**
|
| 94 |
+
```bash
|
| 95 |
+
!pip install unsloth "trl>=0.12" datasets
|
| 96 |
+
!git clone https://huggingface.co/spaces/ms-shamanth/recalltrace-openenv
|
| 97 |
+
%cd recalltrace-openenv
|
| 98 |
+
!python train_trl.py
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
---
|
| 102 |
+
|
| 103 |
+
## π Results
|
| 104 |
+
|
| 105 |
+
### Self-Play Performance
|
| 106 |
+
|
| 107 |
+
| Metric | Early (ep 1-20) | Late (ep 181-200) | Improvement |
|
| 108 |
+
|---|---|---|---|
|
| 109 |
+
| F1 Score | 0.576 | 1.000 | **+73.6%** |
|
| 110 |
+
| Nodes Quarantined | 8.3/episode | 3.1/episode | **-62.7%** |
|
| 111 |
+
| Steps to Finalize | 25.4 | 10.8 | **-57.5%** |
|
| 112 |
+
| Quarantine Threshold | 0.000 | 0.550 | Learned selectivity |
|
| 113 |
+
| Exploration Rate | 0.950 | 0.050 | Learned focus |
|
| 114 |
+
|
| 115 |
+
### Key Insights
|
| 116 |
+
- **Spray-and-pray β Precision**: Early agent quarantines everything; trained agent targets only confirmed contamination
|
| 117 |
+
- **Adversary co-evolution**: Adversary shifts from lot relabeling (35%) to record deletion (35%) as investigator learns to handle relabeling
|
| 118 |
+
- **Belief calibration**: Agent learns to only quarantine when P(contaminated) > 0.55, avoiding false positives
|
| 119 |
|
| 120 |
---
|
| 121 |
|
| 122 |
## π§ Why This Is Unique
|
| 123 |
|
| 124 |
+
### Theme 3.1 β World Modeling
|
| 125 |
+
The agent maintains a probabilistic belief state (`P(contaminated)` per node) and only quarantines when confidence exceeds a learned threshold. This is **world modeling** β the agent builds an internal representation of hidden graph structure.
|
| 126 |
+
|
| 127 |
+
### Theme 4 β Recursive Skill Amplification
|
| 128 |
+
Adversarial self-play creates an **automatic difficulty curriculum**. Both agents improve simultaneously: the adversary finds harder hiding spots, forcing the investigator to develop more sophisticated causal reasoning. This is recursive amplification β each improvement in one agent drives improvement in the other.
|
| 129 |
|
| 130 |
---
|
| 131 |
|
| 132 |
## βοΈ How It Works
|
| 133 |
|
| 134 |
+
```
|
| 135 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 136 |
+
β Self-Play Loop β
|
| 137 |
+
β β
|
| 138 |
+
β Adversary βββ picks intervention type + placement β
|
| 139 |
+
β β β
|
| 140 |
+
β βΌ β
|
| 141 |
+
β Environment βββ generates contaminated supply chain β
|
| 142 |
+
β β β
|
| 143 |
+
β βΌ β
|
| 144 |
+
β Investigator βββ inspect, trace, quarantine, finalize β
|
| 145 |
+
β β β
|
| 146 |
+
β βΌ β
|
| 147 |
+
β F1 Score βββ updates both agents β
|
| 148 |
+
β β β
|
| 149 |
+
β ββββ repeat for N episodes β
|
| 150 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 151 |
+
```
|
| 152 |
|
| 153 |
---
|
| 154 |
|
| 155 |
## π§ͺ Reproducibility
|
| 156 |
|
| 157 |
+
- **Self-play runs in <2 seconds on CPU** β no GPUs needed
|
| 158 |
+
- **Deterministic seeds** ensure exact reproducibility
|
| 159 |
+
- **All plots auto-generated** and committed to `plots/`
|
| 160 |
+
- **Training script** can be re-run in Google Colab (free T4)
|
| 161 |
|
| 162 |
---
|
| 163 |
|
| 164 |
## π¦ Project Structure
|
| 165 |
+
|
| 166 |
```text
|
| 167 |
recalltrace-openenv/
|
| 168 |
+
βββ README.md # This file
|
| 169 |
+
βββ openenv.yaml # OpenEnv manifest
|
| 170 |
+
βββ run_selfplay.py # Self-play training entry point
|
| 171 |
+
βββ train_trl.py # LLM training (Unsloth + TRL)
|
| 172 |
+
βββ inference.py # Submission inference runner
|
| 173 |
+
βββ app.py # Gradio fallback UI
|
| 174 |
+
βββ Dockerfile # HF Spaces Docker deployment
|
| 175 |
+
β
|
| 176 |
+
βββ env/ # OpenEnv environment (reset/step/state)
|
| 177 |
+
β βββ env.py # RecallTraceEnv
|
| 178 |
+
β βββ models.py # Action, Observation, Reward models
|
| 179 |
+
β
|
| 180 |
+
βββ selfplay/ # Adversarial self-play engine
|
| 181 |
+
β βββ trainer.py # SelfPlayTrainer
|
| 182 |
+
β βββ investigator.py # InvestigatorAgent (learnable params)
|
| 183 |
+
β βββ adversary.py # AdversaryAgent (softmax strategy)
|
| 184 |
+
β βββ belief_tracker.py # Probabilistic belief state
|
| 185 |
+
β βββ scenario_gen.py # Procedural graph generation
|
| 186 |
+
β βββ visualization.py # Training curve plots
|
| 187 |
+
β βββ demo_replay.py # Before/after comparison
|
| 188 |
β
|
| 189 |
+
βββ baseline/ # Heuristic baseline policy
|
| 190 |
+
βββ grader/ # Deterministic grading
|
| 191 |
+
βββ server/ # FastAPI server + static frontend
|
| 192 |
+
β βββ app.py
|
| 193 |
+
β βββ static/
|
| 194 |
+
β βββ index.html
|
| 195 |
+
β βββ styles.css
|
| 196 |
+
β βββ app.js
|
| 197 |
β
|
| 198 |
+
βββ plots/ # Auto-generated training plots
|
| 199 |
β βββ selfplay_training.png
|
| 200 |
β βββ before_after_demo.png
|
| 201 |
β βββ episode_comparison.png
|
| 202 |
+
β
|
| 203 |
+
βββ TRAINING_GUIDE.md # Detailed training documentation
|
| 204 |
+
βββ PITCH.md # 3-minute pitch script
|
| 205 |
+
βββ MENTOR_PREP.md # Judging session prep
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
---
|
| 209 |
+
|
| 210 |
+
## π§ Setup
|
| 211 |
+
|
| 212 |
+
```bash
|
| 213 |
+
pip install -e .
|
| 214 |
+
python run_selfplay.py # Self-play (CPU, <2s)
|
| 215 |
+
python train_trl.py # LLM training (GPU)
|
| 216 |
+
python inference.py # Submission evaluation
|
| 217 |
```
|
RecallTrace_Colab_Training.ipynb
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"nbformat": 4,
|
| 3 |
+
"nbformat_minor": 0,
|
| 4 |
+
"metadata": {
|
| 5 |
+
"colab": {
|
| 6 |
+
"provenance": []
|
| 7 |
+
},
|
| 8 |
+
"kernelspec": {
|
| 9 |
+
"name": "python3",
|
| 10 |
+
"display_name": "Python 3"
|
| 11 |
+
},
|
| 12 |
+
"language_info": {
|
| 13 |
+
"name": "python"
|
| 14 |
+
}
|
| 15 |
+
},
|
| 16 |
+
"cells": [
|
| 17 |
+
{
|
| 18 |
+
"cell_type": "markdown",
|
| 19 |
+
"source": [
|
| 20 |
+
"# RecallTrace: LLM Agent Training\n",
|
| 21 |
+
"\n",
|
| 22 |
+
"This notebook reproduces the fine-tuning of the **Qwen2.5-0.5B-Instruct** model on the RecallTrace environment using **Unsloth** and **TRL**.\n",
|
| 23 |
+
"\n",
|
| 24 |
+
"**Note:** Ensure you are using a T4 GPU runtime (Runtime > Change runtime type > T4 GPU)."
|
| 25 |
+
],
|
| 26 |
+
"metadata": {
|
| 27 |
+
"id": "markdown-header"
|
| 28 |
+
}
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
"cell_type": "code",
|
| 32 |
+
"execution_count": null,
|
| 33 |
+
"metadata": {
|
| 34 |
+
"id": "install-deps"
|
| 35 |
+
},
|
| 36 |
+
"outputs": [],
|
| 37 |
+
"source": [
|
| 38 |
+
"!pip install \"unsloth[colab-new] @ git+https://github.com/unslothai/unsloth.git\"\n",
|
| 39 |
+
"!pip install \"trl>=0.12\" datasets accelerate xformers\n",
|
| 40 |
+
"!pip install pydantic fastapi uvicorn"
|
| 41 |
+
]
|
| 42 |
+
},
|
| 43 |
+
{
|
| 44 |
+
"cell_type": "code",
|
| 45 |
+
"execution_count": null,
|
| 46 |
+
"metadata": {
|
| 47 |
+
"id": "clone-repo"
|
| 48 |
+
},
|
| 49 |
+
"outputs": [],
|
| 50 |
+
"source": [
|
| 51 |
+
"!git clone https://huggingface.co/spaces/ms-shamanth/recalltrace-openenv\n",
|
| 52 |
+
"%cd recalltrace-openenv"
|
| 53 |
+
]
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"cell_type": "code",
|
| 57 |
+
"execution_count": null,
|
| 58 |
+
"metadata": {
|
| 59 |
+
"id": "run-training"
|
| 60 |
+
},
|
| 61 |
+
"outputs": [],
|
| 62 |
+
"source": [
|
| 63 |
+
"# Run the Unsloth training script.\n",
|
| 64 |
+
"# This will:\n",
|
| 65 |
+
"# 1. Generate 300 expert episodes using the heuristic agent.\n",
|
| 66 |
+
"# 2. Convert episodes to conversational format for LLM SFT.\n",
|
| 67 |
+
"# 3. Train Qwen2.5-0.5B-Instruct using LoRA.\n",
|
| 68 |
+
"# 4. Evaluate the trained model against a random baseline.\n",
|
| 69 |
+
"\n",
|
| 70 |
+
"!python train_trl.py"
|
| 71 |
+
]
|
| 72 |
+
}
|
| 73 |
+
]
|
| 74 |
+
}
|
TRAINING_GUIDE.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# RecallTrace β Training Guide
|
| 2 |
+
|
| 3 |
+
How to train the adversarial self-play RL model and understand what's happening.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Quick Start (2 seconds on CPU)
|
| 8 |
+
|
| 9 |
+
```bash
|
| 10 |
+
python run_selfplay.py
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
This runs **200 episodes** of Investigator vs Adversary training and generates 3 plots:
|
| 14 |
+
- `plots/selfplay_training.png` β 4-panel training curves
|
| 15 |
+
- `plots/episode_comparison.png` β early vs late episode comparison
|
| 16 |
+
- `plots/before_after_demo.png` β side-by-side graph replay
|
| 17 |
+
|
| 18 |
+
---
|
| 19 |
+
|
| 20 |
+
## Understanding the Training Loop
|
| 21 |
+
|
| 22 |
+
Each episode follows this cycle:
|
| 23 |
+
|
| 24 |
+
1. **Graph Generation**: A random supply-chain DAG is created
|
| 25 |
+
2. **Adversary Chooses**: Picks an intervention type (relabel, mixing, deletion) and placement
|
| 26 |
+
3. **Intervention Applied**: Contamination is hidden using the chosen strategy + decoys added
|
| 27 |
+
4. **Investigator Acts**: Inspects nodes, traces lineages, quarantines suspicious stock
|
| 28 |
+
5. **Both Update**: Investigator adjusts thresholds, Adversary updates its strategy table
|
| 29 |
+
|
| 30 |
+
### What the Investigator Learns
|
| 31 |
+
|
| 32 |
+
| Parameter | Start | After Training | What it does |
|
| 33 |
+
|---|---|---|---|
|
| 34 |
+
| `quarantine_threshold` | 0.0 | ~0.55 | Min evidence to quarantine (0 = quarantine everything) |
|
| 35 |
+
| `suspect_trust` | 1.0 | ~0.05 | How much to trust "suspect" evidence (decoys!) |
|
| 36 |
+
| `mixed_trust` | 0.95 | ~0.3 | Trust in "mixed" evidence |
|
| 37 |
+
| `exploration_rate` | 0.95 | ~0.05 | Probability of visiting non-traced nodes |
|
| 38 |
+
|
| 39 |
+
### What the Adversary Learns
|
| 40 |
+
|
| 41 |
+
The adversary maintains a **3Γ3 score table** over (intervention_type Γ graph_region). It uses a softmax policy with temperature annealing to pick strategies that make the investigator fail most.
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## Extended Training (Longer Runs)
|
| 46 |
+
|
| 47 |
+
For more thorough training:
|
| 48 |
+
|
| 49 |
+
```python
|
| 50 |
+
from selfplay.trainer import SelfPlayTrainer
|
| 51 |
+
|
| 52 |
+
trainer = SelfPlayTrainer(num_nodes=20) # Larger graphs
|
| 53 |
+
stats = trainer.train(num_episodes=2000) # More episodes
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
### Scaling Parameters
|
| 57 |
+
|
| 58 |
+
| Parameter | Default | Extended | Effect |
|
| 59 |
+
|---|---|---|---|
|
| 60 |
+
| `num_episodes` | 200 | 2000-5000 | More training iterations |
|
| 61 |
+
| `num_nodes` | 10 | 15-25 | Larger, harder graphs |
|
| 62 |
+
| `threshold_lr` | 0.004 | 0.002 | Slower, more stable learning |
|
| 63 |
+
| `temperature` | 2.0 | 3.0 | More adversary exploration |
|
| 64 |
+
|
| 65 |
+
A 2000-episode run with 20 nodes takes approximately **30-60 seconds** on CPU.
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
## Upgrading to Neural RL (PyTorch)
|
| 70 |
+
|
| 71 |
+
To train with neural network policies (like your friend's 2-hour training), you would:
|
| 72 |
+
|
| 73 |
+
### 1. Install Dependencies
|
| 74 |
+
```bash
|
| 75 |
+
pip install torch stable-baselines3 gymnasium
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
### 2. Wrap as Gym Environment
|
| 79 |
+
```python
|
| 80 |
+
import gymnasium as gym
|
| 81 |
+
from gymnasium import spaces
|
| 82 |
+
import numpy as np
|
| 83 |
+
|
| 84 |
+
class RecallTraceGymEnv(gym.Env):
|
| 85 |
+
def __init__(self, num_nodes=10):
|
| 86 |
+
super().__init__()
|
| 87 |
+
self.num_nodes = num_nodes
|
| 88 |
+
# Observation: belief state vector + graph features
|
| 89 |
+
self.observation_space = spaces.Box(low=0, high=1, shape=(num_nodes * 4,))
|
| 90 |
+
# Actions: inspect(N), quarantine(N), trace, finalize
|
| 91 |
+
self.action_space = spaces.Discrete(num_nodes * 2 + 2)
|
| 92 |
+
|
| 93 |
+
def reset(self, seed=None, options=None):
|
| 94 |
+
# Generate new scenario, return observation
|
| 95 |
+
...
|
| 96 |
+
|
| 97 |
+
def step(self, action):
|
| 98 |
+
# Execute action, return obs, reward, done, truncated, info
|
| 99 |
+
...
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
### 3. Train with PPO
|
| 103 |
+
```python
|
| 104 |
+
from stable_baselines3 import PPO
|
| 105 |
+
|
| 106 |
+
env = RecallTraceGymEnv(num_nodes=15)
|
| 107 |
+
model = PPO("MlpPolicy", env, verbose=1,
|
| 108 |
+
learning_rate=3e-4,
|
| 109 |
+
n_steps=2048,
|
| 110 |
+
batch_size=64,
|
| 111 |
+
n_epochs=10)
|
| 112 |
+
model.learn(total_timesteps=500_000) # ~2 hours on CPU
|
| 113 |
+
model.save("recalltrace_ppo")
|
| 114 |
+
```
|
| 115 |
+
|
| 116 |
+
---
|
| 117 |
+
|
| 118 |
+
## Reading the Training Output
|
| 119 |
+
|
| 120 |
+
### F1 Score
|
| 121 |
+
- **Early (ep 1-20)**: ~0.3-0.5 β agent quarantines too aggressively (spray & pray)
|
| 122 |
+
- **Late (ep 180-200)**: ~0.85-1.0 β agent quarantines precisely
|
| 123 |
+
|
| 124 |
+
### Adversary Reward
|
| 125 |
+
- **Positive**: Adversary is winning (investigator failing)
|
| 126 |
+
- **Negative**: Investigator is winning (adversary's tricks aren't working)
|
| 127 |
+
- **Should trend negative** over training
|
| 128 |
+
|
| 129 |
+
### Nodes Quarantined
|
| 130 |
+
- **Early**: 6-8 per episode (quarantining everything)
|
| 131 |
+
- **Late**: 2-3 per episode (surgical precision)
|
| 132 |
+
|
| 133 |
+
---
|
| 134 |
+
|
| 135 |
+
## Hyperparameter Tuning
|
| 136 |
+
|
| 137 |
+
Key knobs to adjust:
|
| 138 |
+
|
| 139 |
+
```python
|
| 140 |
+
# In selfplay/investigator.py
|
| 141 |
+
threshold_lr = 0.004 # How fast the quarantine threshold adapts
|
| 142 |
+
trust_lr = 0.005 # How fast evidence trust parameters adapt
|
| 143 |
+
|
| 144 |
+
# In selfplay/adversary.py
|
| 145 |
+
temperature = 2.0 # Exploration vs exploitation (higher = more random)
|
| 146 |
+
min_temperature = 0.3 # Minimum temperature (exploitation floor)
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
**Tips:**
|
| 150 |
+
- If F1 plateaus below 0.7: increase `threshold_lr` to learn faster
|
| 151 |
+
- If F1 oscillates wildly: decrease both learning rates
|
| 152 |
+
- If adversary always picks the same strategy: increase `temperature`
|
baseline/policy.py
CHANGED
|
@@ -27,6 +27,13 @@ def choose_heuristic_action(observation: RecallObservation) -> RecallAction:
|
|
| 27 |
if trace_result is None:
|
| 28 |
return RecallAction(type="trace_lot", lot_id=root_lot, rationale="Map the recall lineage first.")
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
affected_nodes = trace_result.get("affected_nodes", [])
|
| 31 |
for node_id in affected_nodes:
|
| 32 |
if node_id not in observation.inspected_nodes:
|
|
|
|
| 27 |
if trace_result is None:
|
| 28 |
return RecallAction(type="trace_lot", lot_id=root_lot, rationale="Map the recall lineage first.")
|
| 29 |
|
| 30 |
+
if not observation.root_cause_candidates and observation.remaining_step_budget > 2:
|
| 31 |
+
return RecallAction(
|
| 32 |
+
type="cross_reference",
|
| 33 |
+
lot_id=root_lot,
|
| 34 |
+
rationale="Connect lot lineage, graph placement, and evidence before quarantining.",
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
affected_nodes = trace_result.get("affected_nodes", [])
|
| 38 |
for node_id in affected_nodes:
|
| 39 |
if node_id not in observation.inspected_nodes:
|
env/env.py
CHANGED
|
@@ -3,9 +3,9 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from copy import deepcopy
|
| 6 |
-
from typing import Any, Dict, Tuple
|
| 7 |
|
| 8 |
-
from env.models import EnvironmentState, InspectionEvidence, RecallAction, RecallObservation, RewardSignal, StepInfo, TaskDefinition
|
| 9 |
from scenario.scenario import build_scenario, list_task_specs
|
| 10 |
|
| 11 |
|
|
@@ -15,6 +15,8 @@ class RecallTraceEnv:
|
|
| 15 |
ACTIONS = [
|
| 16 |
"inspect_node",
|
| 17 |
"trace_lot",
|
|
|
|
|
|
|
| 18 |
"quarantine",
|
| 19 |
"notify",
|
| 20 |
"finalize",
|
|
@@ -30,6 +32,16 @@ class RecallTraceEnv:
|
|
| 30 |
self.task = self._build_task_definition(self._scenario_template)
|
| 31 |
self.state_data: Dict[str, Any] = {}
|
| 32 |
self.ground_truth: Dict[str, Any] = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
self.done = False
|
| 34 |
self.last_reward = RewardSignal(value=0.0, reason="Environment initialized.", components={})
|
| 35 |
|
|
@@ -60,12 +72,27 @@ class RecallTraceEnv:
|
|
| 60 |
"inspected_nodes": set(),
|
| 61 |
"inspection_results": {},
|
| 62 |
"traced_lots": {},
|
|
|
|
|
|
|
| 63 |
"notified_nodes": set(),
|
| 64 |
"quarantine_log": [],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
"steps_taken": 0,
|
| 66 |
"max_steps": scenario["max_steps"],
|
| 67 |
}
|
| 68 |
self.ground_truth = self._build_ground_truth(scenario)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
return self._get_observation()
|
| 70 |
|
| 71 |
def step(self, action: RecallAction | Dict[str, Any]) -> Tuple[RecallObservation, float, bool, Dict[str, Any]]:
|
|
@@ -100,6 +127,7 @@ class RecallTraceEnv:
|
|
| 100 |
self._record_history("Episode terminated after exhausting the step budget")
|
| 101 |
self.last_reward = reward_signal
|
| 102 |
|
|
|
|
| 103 |
return self._get_observation(), reward_signal.value, self.done, info
|
| 104 |
|
| 105 |
def state(self) -> EnvironmentState:
|
|
@@ -125,6 +153,12 @@ class RecallTraceEnv:
|
|
| 125 |
trace_results=deepcopy(self.state_data["traced_lots"]),
|
| 126 |
notified_nodes=sorted(self.state_data["notified_nodes"]),
|
| 127 |
quarantined_inventory=self._quarantine_snapshot(),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
history=list(self.state_data["history"]),
|
| 129 |
steps_taken=self.state_data["steps_taken"],
|
| 130 |
remaining_step_budget=max(0, self.state_data["max_steps"] - self.state_data["steps_taken"]),
|
|
@@ -142,6 +176,9 @@ class RecallTraceEnv:
|
|
| 142 |
for lot_id, payload in node.get("inspection_findings", {}).items()
|
| 143 |
}
|
| 144 |
self.state_data["inspection_results"][node_id] = findings
|
|
|
|
|
|
|
|
|
|
| 145 |
self._record_history(f"Inspected node {node_id}")
|
| 146 |
|
| 147 |
unsafe_total = sum(item.unsafe_quantity for item in findings.values())
|
|
@@ -181,7 +218,13 @@ class RecallTraceEnv:
|
|
| 181 |
impacted_lots = {}
|
| 182 |
discovered_nodes = 0
|
| 183 |
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
node_total = 0
|
| 186 |
node_lots = []
|
| 187 |
for candidate_lot in traced_lots:
|
|
@@ -197,6 +240,10 @@ class RecallTraceEnv:
|
|
| 197 |
impacted_lots[node_id] = node_lots
|
| 198 |
if node_id not in self.state_data["discovered_shipments"]:
|
| 199 |
discovered_nodes += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
|
| 201 |
self.state_data["traced_lots"][lot_id] = {
|
| 202 |
"root_lot": self._root_lot_for(lot_id),
|
|
@@ -238,6 +285,123 @@ class RecallTraceEnv:
|
|
| 238 |
"lots_by_node": impacted_lots,
|
| 239 |
"quantities_by_node": impacted_quantities,
|
| 240 |
"total_quantity": sum(impacted_quantities.values()),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
}
|
| 242 |
)
|
| 243 |
return reward, info
|
|
@@ -274,6 +438,7 @@ class RecallTraceEnv:
|
|
| 274 |
|
| 275 |
self.state_data["quarantine_log"].append({"node_id": node_id, "lot_id": lot_id, "quantity": quarantined_qty})
|
| 276 |
self._record_history(f"Quarantined {quarantined_qty} units of {lot_id} at {node_id}")
|
|
|
|
| 277 |
|
| 278 |
correct_qty = self.ground_truth["correct_quantities"].get(node_id, {}).get(lot_id, 0)
|
| 279 |
cumulative_quarantined = node["quarantined_inventory"].get(lot_id, 0)
|
|
@@ -314,8 +479,18 @@ class RecallTraceEnv:
|
|
| 314 |
"remaining_inventory": node["inventory"].get(lot_id, 0),
|
| 315 |
"cumulative_quarantined": cumulative_quarantined,
|
| 316 |
"target_contaminated_quantity": correct_qty,
|
|
|
|
| 317 |
}
|
| 318 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
return reward, info
|
| 320 |
|
| 321 |
def _handle_notify(self, action: RecallAction) -> tuple[RewardSignal, Dict[str, Any]]:
|
|
@@ -480,6 +655,121 @@ class RecallTraceEnv:
|
|
| 480 |
"over_quarantined_quantities": over_quarantined_quantities,
|
| 481 |
}
|
| 482 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
def _inventory_snapshot(self) -> Dict[str, Dict[str, int]]:
|
| 484 |
return {node_id: deepcopy(node_data["inventory"]) for node_id, node_data in self.state_data["nodes"].items()}
|
| 485 |
|
|
@@ -492,18 +782,38 @@ class RecallTraceEnv:
|
|
| 492 |
|
| 493 |
def _resolve_related_lots(self, lot_id: str) -> set[str]:
|
| 494 |
root_lot = self._root_lot_for(lot_id)
|
| 495 |
-
return {
|
| 496 |
-
candidate_lot
|
| 497 |
-
for candidate_lot in self.state_data["lot_catalog"].keys()
|
| 498 |
-
if self._root_lot_for(candidate_lot) == root_lot or candidate_lot == lot_id
|
| 499 |
-
}
|
| 500 |
|
| 501 |
def _root_lot_for(self, lot_id: str, lot_catalog: Dict[str, Dict[str, Any]] | None = None) -> str:
|
|
|
|
|
|
|
| 502 |
catalog = lot_catalog or self.state_data.get("lot_catalog", {})
|
| 503 |
if lot_id not in catalog:
|
| 504 |
return lot_id
|
| 505 |
return catalog[lot_id].get("root_lot", lot_id)
|
| 506 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
def _build_task_definition(self, scenario: Dict[str, Any]) -> TaskDefinition:
|
| 508 |
return TaskDefinition(
|
| 509 |
task_id=scenario["task_id"],
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
from copy import deepcopy
|
| 6 |
+
from typing import Any, Dict, List, Tuple
|
| 7 |
|
| 8 |
+
from env.models import EnvironmentState, InspectionEvidence, RecallAction, RecallObservation, RewardSignal, StepInfo, TaskDefinition, belief_entropy
|
| 9 |
from scenario.scenario import build_scenario, list_task_specs
|
| 10 |
|
| 11 |
|
|
|
|
| 15 |
ACTIONS = [
|
| 16 |
"inspect_node",
|
| 17 |
"trace_lot",
|
| 18 |
+
"cross_reference",
|
| 19 |
+
"request_lab_test",
|
| 20 |
"quarantine",
|
| 21 |
"notify",
|
| 22 |
"finalize",
|
|
|
|
| 32 |
self.task = self._build_task_definition(self._scenario_template)
|
| 33 |
self.state_data: Dict[str, Any] = {}
|
| 34 |
self.ground_truth: Dict[str, Any] = {}
|
| 35 |
+
self._root_lot_index: Dict[str, str] = {}
|
| 36 |
+
self._related_lots_index: Dict[str, set[str]] = {}
|
| 37 |
+
self._lot_nodes_index: Dict[str, List[str]] = {}
|
| 38 |
+
self._affected_nodes_set: set[str] = set()
|
| 39 |
+
self._affected_roots_set: set[str] = set()
|
| 40 |
+
self._contaminated_descendants: Dict[str, set[str]] = {}
|
| 41 |
+
self._cached_risk_summary: Dict[str, Any] | None = None
|
| 42 |
+
self._risk_summary_dirty = True
|
| 43 |
+
self._prev_belief_entropy: float = 0.0
|
| 44 |
+
self._cumulative_info_gain: float = 0.0
|
| 45 |
self.done = False
|
| 46 |
self.last_reward = RewardSignal(value=0.0, reason="Environment initialized.", components={})
|
| 47 |
|
|
|
|
| 72 |
"inspected_nodes": set(),
|
| 73 |
"inspection_results": {},
|
| 74 |
"traced_lots": {},
|
| 75 |
+
"cross_references": {},
|
| 76 |
+
"lab_results": {},
|
| 77 |
"notified_nodes": set(),
|
| 78 |
"quarantine_log": [],
|
| 79 |
+
"belief_state": {},
|
| 80 |
+
"root_cause_candidates": [],
|
| 81 |
+
"root_cause_confidence": {},
|
| 82 |
+
"contamination_metrics": {"initial_contaminated": 0, "current_contaminated": 0, "decontamination_rate": 0.0},
|
| 83 |
"steps_taken": 0,
|
| 84 |
"max_steps": scenario["max_steps"],
|
| 85 |
}
|
| 86 |
self.ground_truth = self._build_ground_truth(scenario)
|
| 87 |
+
self._rebuild_indexes()
|
| 88 |
+
self._risk_summary_dirty = True
|
| 89 |
+
self._prev_belief_entropy = 0.0
|
| 90 |
+
self._cumulative_info_gain = 0.0
|
| 91 |
+
self._refresh_belief_state()
|
| 92 |
+
# Set initial contamination count
|
| 93 |
+
initial_count = len(self.ground_truth.get("affected_nodes", []))
|
| 94 |
+
self.state_data["contamination_metrics"]["initial_contaminated"] = initial_count
|
| 95 |
+
self.state_data["contamination_metrics"]["current_contaminated"] = initial_count
|
| 96 |
return self._get_observation()
|
| 97 |
|
| 98 |
def step(self, action: RecallAction | Dict[str, Any]) -> Tuple[RecallObservation, float, bool, Dict[str, Any]]:
|
|
|
|
| 127 |
self._record_history("Episode terminated after exhausting the step budget")
|
| 128 |
self.last_reward = reward_signal
|
| 129 |
|
| 130 |
+
self._refresh_belief_state()
|
| 131 |
return self._get_observation(), reward_signal.value, self.done, info
|
| 132 |
|
| 133 |
def state(self) -> EnvironmentState:
|
|
|
|
| 153 |
trace_results=deepcopy(self.state_data["traced_lots"]),
|
| 154 |
notified_nodes=sorted(self.state_data["notified_nodes"]),
|
| 155 |
quarantined_inventory=self._quarantine_snapshot(),
|
| 156 |
+
belief_state=deepcopy(self.state_data["belief_state"]),
|
| 157 |
+
risk_summary=self._risk_summary(),
|
| 158 |
+
root_cause_candidates=list(self.state_data["root_cause_candidates"]),
|
| 159 |
+
root_cause_confidence=deepcopy(self.state_data.get("root_cause_confidence", {})),
|
| 160 |
+
information_gain=round(self._cumulative_info_gain, 4),
|
| 161 |
+
contamination_metrics=deepcopy(self.state_data.get("contamination_metrics", {})),
|
| 162 |
history=list(self.state_data["history"]),
|
| 163 |
steps_taken=self.state_data["steps_taken"],
|
| 164 |
remaining_step_budget=max(0, self.state_data["max_steps"] - self.state_data["steps_taken"]),
|
|
|
|
| 176 |
for lot_id, payload in node.get("inspection_findings", {}).items()
|
| 177 |
}
|
| 178 |
self.state_data["inspection_results"][node_id] = findings
|
| 179 |
+
for lot_id, finding in findings.items():
|
| 180 |
+
if finding.unsafe_quantity > 0:
|
| 181 |
+
self._remember_root_cause(self._derive_root_cause(lot_id, finding.model_dump()), confidence=0.8)
|
| 182 |
self._record_history(f"Inspected node {node_id}")
|
| 183 |
|
| 184 |
unsafe_total = sum(item.unsafe_quantity for item in findings.values())
|
|
|
|
| 218 |
impacted_lots = {}
|
| 219 |
discovered_nodes = 0
|
| 220 |
|
| 221 |
+
candidate_nodes = sorted({
|
| 222 |
+
node_id
|
| 223 |
+
for candidate_lot in traced_lots
|
| 224 |
+
for node_id in self._lot_nodes_index.get(candidate_lot, [])
|
| 225 |
+
})
|
| 226 |
+
for node_id in candidate_nodes:
|
| 227 |
+
node_data = self.state_data["nodes"][node_id]
|
| 228 |
node_total = 0
|
| 229 |
node_lots = []
|
| 230 |
for candidate_lot in traced_lots:
|
|
|
|
| 240 |
impacted_lots[node_id] = node_lots
|
| 241 |
if node_id not in self.state_data["discovered_shipments"]:
|
| 242 |
discovered_nodes += 1
|
| 243 |
+
for candidate_lot in node_lots:
|
| 244 |
+
finding = node_data.get("inspection_findings", {}).get(candidate_lot)
|
| 245 |
+
if finding and int(finding.get("unsafe_quantity", 0)) > 0:
|
| 246 |
+
self._remember_root_cause(self._derive_root_cause(candidate_lot, finding), confidence=0.7)
|
| 247 |
|
| 248 |
self.state_data["traced_lots"][lot_id] = {
|
| 249 |
"root_lot": self._root_lot_for(lot_id),
|
|
|
|
| 285 |
"lots_by_node": impacted_lots,
|
| 286 |
"quantities_by_node": impacted_quantities,
|
| 287 |
"total_quantity": sum(impacted_quantities.values()),
|
| 288 |
+
"root_cause_candidates": list(self.state_data["root_cause_candidates"]),
|
| 289 |
+
}
|
| 290 |
+
)
|
| 291 |
+
return reward, info
|
| 292 |
+
|
| 293 |
+
def _handle_cross_reference(self, action: RecallAction) -> tuple[RewardSignal, Dict[str, Any]]:
|
| 294 |
+
lot_id = action.lot_id or self.state_data["contaminated_lot_hint"]
|
| 295 |
+
root_lot = self._root_lot_for(lot_id)
|
| 296 |
+
matched_lots = sorted(self._resolve_related_lots(lot_id))
|
| 297 |
+
affected_nodes = sorted({
|
| 298 |
+
node_id
|
| 299 |
+
for matched_lot in matched_lots
|
| 300 |
+
for node_id in self._lot_nodes_index.get(matched_lot, [])
|
| 301 |
+
})
|
| 302 |
+
|
| 303 |
+
node_id = action.node_id
|
| 304 |
+
if node_id:
|
| 305 |
+
node_id = self._require_node(node_id)
|
| 306 |
+
affected_nodes = [candidate for candidate in affected_nodes if candidate == node_id]
|
| 307 |
+
|
| 308 |
+
evidence_statuses: Dict[str, int] = {}
|
| 309 |
+
root_causes: set[str] = set()
|
| 310 |
+
for candidate_node in affected_nodes or self._lot_nodes_index.get(lot_id, []):
|
| 311 |
+
findings = self.state_data["nodes"][candidate_node].get("inspection_findings", {})
|
| 312 |
+
for matched_lot in matched_lots:
|
| 313 |
+
finding = findings.get(matched_lot)
|
| 314 |
+
if not finding:
|
| 315 |
+
continue
|
| 316 |
+
status = str(finding.get("status", "unknown"))
|
| 317 |
+
evidence_statuses[status] = evidence_statuses.get(status, 0) + 1
|
| 318 |
+
if int(finding.get("unsafe_quantity", 0)) > 0:
|
| 319 |
+
root_causes.add(self._derive_root_cause(matched_lot, finding))
|
| 320 |
+
|
| 321 |
+
for cause in sorted(root_causes):
|
| 322 |
+
self._remember_root_cause(cause, confidence=0.7)
|
| 323 |
+
|
| 324 |
+
repeated = lot_id in self.state_data["cross_references"]
|
| 325 |
+
self.state_data["cross_references"][lot_id] = {
|
| 326 |
+
"root_lot": root_lot,
|
| 327 |
+
"matched_lots": matched_lots,
|
| 328 |
+
"affected_nodes": affected_nodes,
|
| 329 |
+
"evidence_statuses": evidence_statuses,
|
| 330 |
+
"root_cause_candidates": sorted(root_causes),
|
| 331 |
+
}
|
| 332 |
+
self._record_history(f"Cross-referenced {lot_id} against lot lineage and inspection evidence")
|
| 333 |
+
|
| 334 |
+
is_recall_lineage = root_lot in self._affected_roots_set
|
| 335 |
+
value = (0.14 if is_recall_lineage else 0.02) + min(0.1, 0.02 * len(affected_nodes))
|
| 336 |
+
if repeated:
|
| 337 |
+
value -= 0.08
|
| 338 |
+
reward = RewardSignal(
|
| 339 |
+
value=round(max(-0.05, min(0.28, value)), 4),
|
| 340 |
+
reason="Cross-reference connected lot lineage, graph placement, and root-cause evidence.",
|
| 341 |
+
components={"cross_reference_value": round(max(-0.05, min(0.28, value)), 4)},
|
| 342 |
+
)
|
| 343 |
+
info = StepInfo(
|
| 344 |
+
message=f"Cross-referenced {lot_id} across lineage and graph records.",
|
| 345 |
+
action_type=action.type.value,
|
| 346 |
+
reward_breakdown=reward.components,
|
| 347 |
+
).model_dump()
|
| 348 |
+
info.update(self.state_data["cross_references"][lot_id])
|
| 349 |
+
info.update({"lot_id": lot_id})
|
| 350 |
+
return reward, info
|
| 351 |
+
|
| 352 |
+
def _handle_request_lab_test(self, action: RecallAction) -> tuple[RewardSignal, Dict[str, Any]]:
|
| 353 |
+
node_id = self._require_node(action.node_id)
|
| 354 |
+
node = self.state_data["nodes"][node_id]
|
| 355 |
+
lot_id = action.lot_id
|
| 356 |
+
if not lot_id:
|
| 357 |
+
candidate_lots = list(node.get("inspection_findings", {}).keys()) or list(node["inventory"].keys())
|
| 358 |
+
if not candidate_lots:
|
| 359 |
+
raise ValueError("request_lab_test requires 'lot_id' when the node has no inventory.")
|
| 360 |
+
lot_id = max(
|
| 361 |
+
candidate_lots,
|
| 362 |
+
key=lambda candidate: node.get("inspection_findings", {}).get(candidate, {}).get("unsafe_quantity", 0),
|
| 363 |
+
)
|
| 364 |
+
if lot_id not in node["inventory"] and lot_id not in node.get("inspection_findings", {}):
|
| 365 |
+
raise ValueError(f"Lot '{lot_id}' is not present in node '{node_id}'.")
|
| 366 |
+
|
| 367 |
+
finding_payload = node.get("inspection_findings", {}).get(
|
| 368 |
+
lot_id,
|
| 369 |
+
{
|
| 370 |
+
"status": "not_detected",
|
| 371 |
+
"unsafe_quantity": 0,
|
| 372 |
+
"evidence": "Lab panel found no matching recall signal for this lot at this node.",
|
| 373 |
+
},
|
| 374 |
+
)
|
| 375 |
+
finding = InspectionEvidence.model_validate(finding_payload)
|
| 376 |
+
self.state_data["lab_results"].setdefault(node_id, {})[lot_id] = finding
|
| 377 |
+
self.state_data["inspection_results"].setdefault(node_id, {})[lot_id] = finding
|
| 378 |
+
|
| 379 |
+
if finding.unsafe_quantity > 0:
|
| 380 |
+
cause = self._derive_root_cause(lot_id, finding.model_dump())
|
| 381 |
+
self._remember_root_cause(cause, confidence=0.9)
|
| 382 |
+
reward_value = 0.2
|
| 383 |
+
reason = "Lab test confirmed unsafe stock and strengthened root-cause evidence."
|
| 384 |
+
else:
|
| 385 |
+
reward_value = 0.03
|
| 386 |
+
reason = "Lab test ruled out a candidate lot and reduced false-positive risk."
|
| 387 |
+
|
| 388 |
+
self._record_history(f"Requested lab test for {lot_id} at {node_id}")
|
| 389 |
+
reward = RewardSignal(
|
| 390 |
+
value=round(reward_value, 4),
|
| 391 |
+
reason=reason,
|
| 392 |
+
components={"lab_test_value": round(reward_value, 4)},
|
| 393 |
+
)
|
| 394 |
+
info = StepInfo(
|
| 395 |
+
message=f"Lab test completed for {lot_id} at {node_id}.",
|
| 396 |
+
action_type=action.type.value,
|
| 397 |
+
reward_breakdown=reward.components,
|
| 398 |
+
).model_dump()
|
| 399 |
+
info.update(
|
| 400 |
+
{
|
| 401 |
+
"node_id": node_id,
|
| 402 |
+
"lot_id": lot_id,
|
| 403 |
+
"lab_result": finding.model_dump(),
|
| 404 |
+
"root_cause_candidates": list(self.state_data["root_cause_candidates"]),
|
| 405 |
}
|
| 406 |
)
|
| 407 |
return reward, info
|
|
|
|
| 438 |
|
| 439 |
self.state_data["quarantine_log"].append({"node_id": node_id, "lot_id": lot_id, "quantity": quarantined_qty})
|
| 440 |
self._record_history(f"Quarantined {quarantined_qty} units of {lot_id} at {node_id}")
|
| 441 |
+
self._risk_summary_dirty = True # Invalidate cache after quarantine change
|
| 442 |
|
| 443 |
correct_qty = self.ground_truth["correct_quantities"].get(node_id, {}).get(lot_id, 0)
|
| 444 |
cumulative_quarantined = node["quarantined_inventory"].get(lot_id, 0)
|
|
|
|
| 479 |
"remaining_inventory": node["inventory"].get(lot_id, 0),
|
| 480 |
"cumulative_quarantined": cumulative_quarantined,
|
| 481 |
"target_contaminated_quantity": correct_qty,
|
| 482 |
+
"containment_progress": self._risk_summary()["containment_progress"],
|
| 483 |
}
|
| 484 |
)
|
| 485 |
+
# Update contamination decay metrics
|
| 486 |
+
qm = self._compute_quarantine_match()
|
| 487 |
+
remaining = len(qm.get("missing_quantities", {}))
|
| 488 |
+
initial = self.state_data["contamination_metrics"]["initial_contaminated"] or 1
|
| 489 |
+
self.state_data["contamination_metrics"]["current_contaminated"] = remaining
|
| 490 |
+
self.state_data["contamination_metrics"]["decontamination_rate"] = round(
|
| 491 |
+
max(0.0, 1.0 - remaining / initial), 4
|
| 492 |
+
)
|
| 493 |
+
info["contamination_metrics"] = deepcopy(self.state_data["contamination_metrics"])
|
| 494 |
return reward, info
|
| 495 |
|
| 496 |
def _handle_notify(self, action: RecallAction) -> tuple[RewardSignal, Dict[str, Any]]:
|
|
|
|
| 655 |
"over_quarantined_quantities": over_quarantined_quantities,
|
| 656 |
}
|
| 657 |
|
| 658 |
+
def _rebuild_indexes(self) -> None:
|
| 659 |
+
lot_catalog = self.state_data.get("lot_catalog", {})
|
| 660 |
+
self._root_lot_index = {
|
| 661 |
+
lot_id: payload.get("root_lot", lot_id)
|
| 662 |
+
for lot_id, payload in lot_catalog.items()
|
| 663 |
+
}
|
| 664 |
+
self._related_lots_index = {}
|
| 665 |
+
for lot_id, root_lot in self._root_lot_index.items():
|
| 666 |
+
self._related_lots_index.setdefault(root_lot, set()).add(lot_id)
|
| 667 |
+
self._related_lots_index[lot_id] = self._related_lots_index[root_lot]
|
| 668 |
+
|
| 669 |
+
lot_nodes: Dict[str, set[str]] = {}
|
| 670 |
+
for node_id, node_data in self.state_data.get("nodes", {}).items():
|
| 671 |
+
lots = set(node_data.get("inventory", {})) | set(node_data.get("quarantined_inventory", {}))
|
| 672 |
+
lots |= set(node_data.get("inspection_findings", {}))
|
| 673 |
+
for lot_id in lots:
|
| 674 |
+
lot_nodes.setdefault(lot_id, set()).add(node_id)
|
| 675 |
+
self._lot_nodes_index = {
|
| 676 |
+
lot_id: sorted(nodes)
|
| 677 |
+
for lot_id, nodes in lot_nodes.items()
|
| 678 |
+
}
|
| 679 |
+
self._affected_nodes_set = set(self.ground_truth.get("affected_nodes", []))
|
| 680 |
+
self._affected_roots_set = set(self.ground_truth.get("affected_roots", []))
|
| 681 |
+
|
| 682 |
+
# Pre-compute contaminated lot descendant chains for O(1) lineage lookups
|
| 683 |
+
self._contaminated_descendants = {}
|
| 684 |
+
for lot_id, payload in lot_catalog.items():
|
| 685 |
+
if payload.get("contaminated", False):
|
| 686 |
+
root = payload.get("root_lot", lot_id)
|
| 687 |
+
self._contaminated_descendants.setdefault(root, set()).add(lot_id)
|
| 688 |
+
|
| 689 |
+
def _refresh_belief_state(self) -> None:
|
| 690 |
+
recall_root = self._root_lot_for(self.state_data.get("contaminated_lot_hint", ""))
|
| 691 |
+
traced_nodes = {
|
| 692 |
+
node_id
|
| 693 |
+
for trace in self.state_data.get("traced_lots", {}).values()
|
| 694 |
+
for node_id in trace.get("affected_nodes", [])
|
| 695 |
+
}
|
| 696 |
+
beliefs: Dict[str, float] = {}
|
| 697 |
+
|
| 698 |
+
for node_id, node_data in self.state_data.get("nodes", {}).items():
|
| 699 |
+
inventory_lots = set(node_data.get("inventory", {})) | set(node_data.get("quarantined_inventory", {}))
|
| 700 |
+
score = 0.05
|
| 701 |
+
if any(self._root_lot_for(lot_id) == recall_root for lot_id in inventory_lots):
|
| 702 |
+
score = max(score, 0.35)
|
| 703 |
+
if node_id in traced_nodes:
|
| 704 |
+
score = max(score, 0.55)
|
| 705 |
+
|
| 706 |
+
findings = self.state_data.get("inspection_results", {}).get(node_id, {})
|
| 707 |
+
if findings:
|
| 708 |
+
unsafe_score = 0.0
|
| 709 |
+
safe_only = True
|
| 710 |
+
for finding in findings.values():
|
| 711 |
+
unsafe_qty = finding.unsafe_quantity if hasattr(finding, "unsafe_quantity") else int(finding.get("unsafe_quantity", 0))
|
| 712 |
+
status = finding.status if hasattr(finding, "status") else str(finding.get("status", ""))
|
| 713 |
+
if unsafe_qty > 0:
|
| 714 |
+
safe_only = False
|
| 715 |
+
if status == "mixed":
|
| 716 |
+
unsafe_score = max(unsafe_score, 0.82)
|
| 717 |
+
else:
|
| 718 |
+
unsafe_score = max(unsafe_score, 0.95)
|
| 719 |
+
elif status not in {"safe", "not_detected"}:
|
| 720 |
+
safe_only = False
|
| 721 |
+
unsafe_score = max(unsafe_score, 0.3)
|
| 722 |
+
if unsafe_score:
|
| 723 |
+
score = max(score, unsafe_score)
|
| 724 |
+
elif safe_only:
|
| 725 |
+
score = min(score, 0.1)
|
| 726 |
+
|
| 727 |
+
expected = self.ground_truth.get("correct_quantities", {}).get(node_id, {})
|
| 728 |
+
if expected:
|
| 729 |
+
actual = node_data.get("quarantined_inventory", {})
|
| 730 |
+
covered = sum(min(actual.get(lot_id, 0), qty) for lot_id, qty in expected.items())
|
| 731 |
+
total = sum(expected.values()) or 1
|
| 732 |
+
score *= max(0.05, 1.0 - (covered / total))
|
| 733 |
+
|
| 734 |
+
beliefs[node_id] = round(max(0.0, min(0.99, score)), 4)
|
| 735 |
+
|
| 736 |
+
self.state_data["belief_state"] = beliefs
|
| 737 |
+
self._risk_summary_dirty = True
|
| 738 |
+
|
| 739 |
+
# Compute information gain (entropy reduction)
|
| 740 |
+
current_entropy = belief_entropy(beliefs)
|
| 741 |
+
if self._prev_belief_entropy > 0:
|
| 742 |
+
gain = max(0.0, self._prev_belief_entropy - current_entropy)
|
| 743 |
+
self._cumulative_info_gain += gain
|
| 744 |
+
self._prev_belief_entropy = current_entropy
|
| 745 |
+
|
| 746 |
+
def _risk_summary(self) -> Dict[str, Any]:
|
| 747 |
+
# Return cached result if nothing changed since last computation
|
| 748 |
+
if not self._risk_summary_dirty and self._cached_risk_summary is not None:
|
| 749 |
+
return self._cached_risk_summary
|
| 750 |
+
|
| 751 |
+
beliefs = self.state_data.get("belief_state", {})
|
| 752 |
+
high_risk_nodes = [node_id for node_id, score in sorted(beliefs.items(), key=lambda item: item[1], reverse=True) if score >= 0.5]
|
| 753 |
+
inspected_unsafe_nodes = sorted(
|
| 754 |
+
node_id
|
| 755 |
+
for node_id, findings in self.state_data.get("inspection_results", {}).items()
|
| 756 |
+
if any(finding.unsafe_quantity > 0 for finding in findings.values())
|
| 757 |
+
)
|
| 758 |
+
quarantine_match = self._compute_quarantine_match()
|
| 759 |
+
remaining_nodes = sorted(quarantine_match["missing_quantities"].keys())
|
| 760 |
+
total_affected = len(self.ground_truth.get("affected_nodes", [])) or 1
|
| 761 |
+
contained_nodes = total_affected - len(remaining_nodes)
|
| 762 |
+
result = {
|
| 763 |
+
"high_risk_nodes": high_risk_nodes,
|
| 764 |
+
"inspected_unsafe_nodes": inspected_unsafe_nodes,
|
| 765 |
+
"remaining_suspected_nodes": len(high_risk_nodes),
|
| 766 |
+
"containment_progress": round(max(0.0, contained_nodes / total_affected), 4),
|
| 767 |
+
"root_cause_candidates": list(self.state_data.get("root_cause_candidates", [])),
|
| 768 |
+
}
|
| 769 |
+
self._cached_risk_summary = result
|
| 770 |
+
self._risk_summary_dirty = False
|
| 771 |
+
return result
|
| 772 |
+
|
| 773 |
def _inventory_snapshot(self) -> Dict[str, Dict[str, int]]:
|
| 774 |
return {node_id: deepcopy(node_data["inventory"]) for node_id, node_data in self.state_data["nodes"].items()}
|
| 775 |
|
|
|
|
| 782 |
|
| 783 |
def _resolve_related_lots(self, lot_id: str) -> set[str]:
|
| 784 |
root_lot = self._root_lot_for(lot_id)
|
| 785 |
+
return set(self._related_lots_index.get(lot_id) or self._related_lots_index.get(root_lot) or {lot_id})
|
|
|
|
|
|
|
|
|
|
|
|
|
| 786 |
|
| 787 |
def _root_lot_for(self, lot_id: str, lot_catalog: Dict[str, Dict[str, Any]] | None = None) -> str:
|
| 788 |
+
if lot_catalog is None and lot_id in self._root_lot_index:
|
| 789 |
+
return self._root_lot_index[lot_id]
|
| 790 |
catalog = lot_catalog or self.state_data.get("lot_catalog", {})
|
| 791 |
if lot_id not in catalog:
|
| 792 |
return lot_id
|
| 793 |
return catalog[lot_id].get("root_lot", lot_id)
|
| 794 |
|
| 795 |
+
def _derive_root_cause(self, lot_id: str, finding: Dict[str, Any]) -> str:
|
| 796 |
+
lot_data = self.state_data.get("lot_catalog", {}).get(lot_id, {})
|
| 797 |
+
status = str(finding.get("status", ""))
|
| 798 |
+
evidence = str(finding.get("evidence", "")).lower()
|
| 799 |
+
if status == "mixed" or lot_data.get("mixed_from"):
|
| 800 |
+
return "mixing_event"
|
| 801 |
+
if status == "records_missing" or "missing" in evidence or "deleted" in evidence:
|
| 802 |
+
return "record_deletion"
|
| 803 |
+
if lot_data.get("relabeled_from") or "relabel" in evidence or "repack" in evidence:
|
| 804 |
+
return "lot_relabel"
|
| 805 |
+
return "source_contamination"
|
| 806 |
+
|
| 807 |
+
def _remember_root_cause(self, cause: str, confidence: float = 0.5) -> None:
|
| 808 |
+
candidates = self.state_data.setdefault("root_cause_candidates", [])
|
| 809 |
+
confidences = self.state_data.setdefault("root_cause_confidence", {})
|
| 810 |
+
if cause and cause not in candidates:
|
| 811 |
+
candidates.append(cause)
|
| 812 |
+
candidates.sort()
|
| 813 |
+
# Update confidence (keep the maximum observed)
|
| 814 |
+
if cause:
|
| 815 |
+
confidences[cause] = round(max(confidences.get(cause, 0.0), confidence), 4)
|
| 816 |
+
|
| 817 |
def _build_task_definition(self, scenario: Dict[str, Any]) -> TaskDefinition:
|
| 818 |
return TaskDefinition(
|
| 819 |
task_id=scenario["task_id"],
|
env/models.py
CHANGED
|
@@ -5,12 +5,16 @@ from __future__ import annotations
|
|
| 5 |
from enum import Enum
|
| 6 |
from typing import Any, Dict, List, Optional
|
| 7 |
|
|
|
|
|
|
|
| 8 |
from pydantic import BaseModel, ConfigDict, Field
|
| 9 |
|
| 10 |
|
| 11 |
class ActionType(str, Enum):
|
| 12 |
INSPECT_NODE = "inspect_node"
|
| 13 |
TRACE_LOT = "trace_lot"
|
|
|
|
|
|
|
| 14 |
QUARANTINE = "quarantine"
|
| 15 |
NOTIFY = "notify"
|
| 16 |
FINALIZE = "finalize"
|
|
@@ -77,6 +81,12 @@ class RecallObservation(BaseModel):
|
|
| 77 |
trace_results: Dict[str, Dict[str, Any]]
|
| 78 |
notified_nodes: List[str]
|
| 79 |
quarantined_inventory: Dict[str, Dict[str, int]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
history: List[str]
|
| 81 |
steps_taken: int = Field(ge=0)
|
| 82 |
remaining_step_budget: int = Field(ge=0)
|
|
@@ -91,6 +101,7 @@ class StepInfo(BaseModel):
|
|
| 91 |
action_type: str
|
| 92 |
score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
| 93 |
reward_breakdown: Dict[str, float] = Field(default_factory=dict)
|
|
|
|
| 94 |
|
| 95 |
|
| 96 |
class EnvironmentState(BaseModel):
|
|
@@ -117,3 +128,18 @@ class TaskGrade(BaseModel):
|
|
| 117 |
max_steps: int = Field(ge=1)
|
| 118 |
reward_total: float
|
| 119 |
final_info: Dict[str, Any]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from enum import Enum
|
| 6 |
from typing import Any, Dict, List, Optional
|
| 7 |
|
| 8 |
+
import math
|
| 9 |
+
|
| 10 |
from pydantic import BaseModel, ConfigDict, Field
|
| 11 |
|
| 12 |
|
| 13 |
class ActionType(str, Enum):
|
| 14 |
INSPECT_NODE = "inspect_node"
|
| 15 |
TRACE_LOT = "trace_lot"
|
| 16 |
+
CROSS_REFERENCE = "cross_reference"
|
| 17 |
+
REQUEST_LAB_TEST = "request_lab_test"
|
| 18 |
QUARANTINE = "quarantine"
|
| 19 |
NOTIFY = "notify"
|
| 20 |
FINALIZE = "finalize"
|
|
|
|
| 81 |
trace_results: Dict[str, Dict[str, Any]]
|
| 82 |
notified_nodes: List[str]
|
| 83 |
quarantined_inventory: Dict[str, Dict[str, int]]
|
| 84 |
+
belief_state: Dict[str, float] = Field(default_factory=dict)
|
| 85 |
+
risk_summary: Dict[str, Any] = Field(default_factory=dict)
|
| 86 |
+
root_cause_candidates: List[str] = Field(default_factory=list)
|
| 87 |
+
root_cause_confidence: Dict[str, float] = Field(default_factory=dict)
|
| 88 |
+
information_gain: float = Field(default=0.0)
|
| 89 |
+
contamination_metrics: Dict[str, Any] = Field(default_factory=dict)
|
| 90 |
history: List[str]
|
| 91 |
steps_taken: int = Field(ge=0)
|
| 92 |
remaining_step_budget: int = Field(ge=0)
|
|
|
|
| 101 |
action_type: str
|
| 102 |
score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
| 103 |
reward_breakdown: Dict[str, float] = Field(default_factory=dict)
|
| 104 |
+
contamination_metrics: Dict[str, Any] = Field(default_factory=dict)
|
| 105 |
|
| 106 |
|
| 107 |
class EnvironmentState(BaseModel):
|
|
|
|
| 128 |
max_steps: int = Field(ge=1)
|
| 129 |
reward_total: float
|
| 130 |
final_info: Dict[str, Any]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ---------------------------------------------------------------------------
|
| 134 |
+
# Utility: Entropy computation for information gain tracking
|
| 135 |
+
# ---------------------------------------------------------------------------
|
| 136 |
+
|
| 137 |
+
def belief_entropy(beliefs: Dict[str, float]) -> float:
|
| 138 |
+
"""Compute Shannon entropy of the belief state distribution."""
|
| 139 |
+
if not beliefs:
|
| 140 |
+
return 0.0
|
| 141 |
+
total = 0.0
|
| 142 |
+
for p in beliefs.values():
|
| 143 |
+
p_clamped = max(1e-9, min(1.0 - 1e-9, p))
|
| 144 |
+
total -= p_clamped * math.log2(p_clamped) + (1 - p_clamped) * math.log2(1 - p_clamped)
|
| 145 |
+
return total
|
fretfch.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"dataset_name": "fretfch",
|
| 3 |
+
"scenarios": [
|
| 4 |
+
{
|
| 5 |
+
"node_count": 8,
|
| 6 |
+
"contamination_type": "mixing_event",
|
| 7 |
+
"graph_region": "midstream",
|
| 8 |
+
"description": "Midstream mixing of multiple lots (Difficulty: Medium)"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"node_count": 12,
|
| 12 |
+
"contamination_type": "lot_relabel",
|
| 13 |
+
"graph_region": "downstream",
|
| 14 |
+
"description": "Downstream relabeling by a distributor (Difficulty: Hard)"
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"node_count": 6,
|
| 18 |
+
"contamination_type": "source_contamination",
|
| 19 |
+
"graph_region": "upstream",
|
| 20 |
+
"description": "Simple upstream source contamination (Difficulty: Easy)"
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"node_count": 15,
|
| 24 |
+
"contamination_type": "record_deletion",
|
| 25 |
+
"graph_region": "midstream",
|
| 26 |
+
"description": "Missing records mid-graph (Difficulty: Expert)"
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"node_count": 10,
|
| 30 |
+
"contamination_type": "mixing_event",
|
| 31 |
+
"graph_region": "upstream",
|
| 32 |
+
"description": "Early stage mixing event (Difficulty: Medium)"
|
| 33 |
+
}
|
| 34 |
+
]
|
| 35 |
+
}
|
pyproject.toml
CHANGED
|
@@ -10,6 +10,8 @@ readme = "README.md"
|
|
| 10 |
requires-python = ">=3.12"
|
| 11 |
dependencies = [
|
| 12 |
"fastapi>=0.115.0,<1.0.0",
|
|
|
|
|
|
|
| 13 |
"openai>=2.7.2,<3.0.0",
|
| 14 |
"openenv-core>=0.2.0",
|
| 15 |
"pydantic>=2.7.0,<3.0.0",
|
|
|
|
| 10 |
requires-python = ">=3.12"
|
| 11 |
dependencies = [
|
| 12 |
"fastapi>=0.115.0,<1.0.0",
|
| 13 |
+
"hf_transfer>=0.1.8",
|
| 14 |
+
"huggingface_hub>=0.24.0",
|
| 15 |
"openai>=2.7.2,<3.0.0",
|
| 16 |
"openenv-core>=0.2.0",
|
| 17 |
"pydantic>=2.7.0,<3.0.0",
|
recover_plots.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import matplotlib.pyplot as plt
|
| 3 |
+
|
| 4 |
+
PLOTS_DIR = "plots"
|
| 5 |
+
os.makedirs(PLOTS_DIR, exist_ok=True)
|
| 6 |
+
|
| 7 |
+
losses = [
|
| 8 |
+
2.405, 1.927, 1.184, 0.3884, 0.09162, 0.03675, 0.02496, 0.01895, 0.01838, 0.01794,
|
| 9 |
+
0.01691, 0.01584, 0.01471, 0.01471, 0.0138, 0.01404, 0.01404, 0.01315, 0.01271, 0.01221,
|
| 10 |
+
0.01145, 0.01035, 0.009906, 0.01096, 0.009928, 0.01093, 0.01076, 0.009659, 0.01026, 0.009521,
|
| 11 |
+
0.00914, 0.008566, 0.008741, 0.008682, 0.008574, 0.008453, 0.008783, 0.008452, 0.00854, 0.008325,
|
| 12 |
+
0.008671, 0.00839, 0.008425, 0.008395, 0.008689, 0.008234, 0.008654, 0.008448, 0.008507, 0.008681,
|
| 13 |
+
0.008344, 0.008281, 0.008645, 0.00853, 0.00857, 0.008191, 0.008447, 0.008351, 0.008434, 0.008516,
|
| 14 |
+
0.008106, 0.008195, 0.008332, 0.008627, 0.008091
|
| 15 |
+
]
|
| 16 |
+
steps = [10 * (i + 1) for i in range(len(losses))]
|
| 17 |
+
|
| 18 |
+
eval_results = {
|
| 19 |
+
"Random": {"avg_score": 0.1552},
|
| 20 |
+
"Heuristic": {"avg_score": 0.9677},
|
| 21 |
+
"Trained LLM": {"avg_score": 0.9677}
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
fig, ax = plt.subplots(figsize=(10, 5))
|
| 25 |
+
ax.plot(steps, losses, color="#ff6f3c", linewidth=2, label="SFT Training Loss")
|
| 26 |
+
ax.set_xlabel("Training Step", fontsize=12)
|
| 27 |
+
ax.set_ylabel("Loss", fontsize=12)
|
| 28 |
+
ax.set_title("RecallTrace β SFT Training Loss (Unsloth + TRL)", fontsize=14, fontweight="bold")
|
| 29 |
+
ax.legend()
|
| 30 |
+
ax.grid(True, alpha=0.3)
|
| 31 |
+
fig.tight_layout()
|
| 32 |
+
fig.savefig(os.path.join(PLOTS_DIR, "trl_training_loss.png"), dpi=150)
|
| 33 |
+
plt.close()
|
| 34 |
+
|
| 35 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 36 |
+
names = list(eval_results.keys())
|
| 37 |
+
avgs = [eval_results[n]["avg_score"] for n in names]
|
| 38 |
+
colors = ["#8b949e", "#f0c040", "#2ea043"][:len(names)]
|
| 39 |
+
bars = ax.bar(names, avgs, color=colors, width=0.5, edgecolor="white", linewidth=0.5)
|
| 40 |
+
for bar, val in zip(bars, avgs):
|
| 41 |
+
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.02,
|
| 42 |
+
f"{val:.3f}", ha="center", fontsize=12, fontweight="bold")
|
| 43 |
+
ax.set_ylabel("Average Episode Score", fontsize=12)
|
| 44 |
+
ax.set_title("RecallTrace β Baseline vs Trained Agent", fontsize=14, fontweight="bold")
|
| 45 |
+
ax.set_ylim(0, 1.1)
|
| 46 |
+
ax.grid(True, alpha=0.3, axis="y")
|
| 47 |
+
fig.tight_layout()
|
| 48 |
+
fig.savefig(os.path.join(PLOTS_DIR, "trl_evaluation_comparison.png"), dpi=150)
|
| 49 |
+
plt.close()
|
| 50 |
+
|
| 51 |
+
print("Plots successfully recovered locally!")
|
requirements.txt
CHANGED
|
@@ -6,4 +6,11 @@ openenv-core>=0.2.0,<1.0.0
|
|
| 6 |
numpy
|
| 7 |
matplotlib
|
| 8 |
networkx
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
numpy
|
| 7 |
matplotlib
|
| 8 |
networkx
|
| 9 |
+
torch
|
| 10 |
+
transformers>=4.51.3
|
| 11 |
+
huggingface_hub>=0.24.0
|
| 12 |
+
hf_transfer>=0.1.8
|
| 13 |
+
peft>=0.18.0
|
| 14 |
+
accelerate
|
| 15 |
+
bitsandbytes>=0.45.5
|
| 16 |
+
sentencepiece>=0.2.0
|
selfplay/investigator.py
CHANGED
|
@@ -41,6 +41,8 @@ class InvestigatorAgent:
|
|
| 41 |
self.quarantine_decisions: List[Dict[str, Any]] = []
|
| 42 |
self.intervention_guess: Optional[str] = None
|
| 43 |
self.total_episodes = 0
|
|
|
|
|
|
|
| 44 |
|
| 45 |
# Adaptation history
|
| 46 |
self._f1_history: List[float] = []
|
|
@@ -51,6 +53,8 @@ class InvestigatorAgent:
|
|
| 51 |
self.nodes_quarantined = []
|
| 52 |
self.quarantine_decisions = []
|
| 53 |
self.intervention_guess = None
|
|
|
|
|
|
|
| 54 |
self.belief_confidence = max(0.1, min(0.95, 0.1 + self.total_episodes * 0.004))
|
| 55 |
|
| 56 |
def act(self, observation: RecallObservation, rng: random.Random | None = None) -> RecallAction:
|
|
@@ -74,59 +78,87 @@ class InvestigatorAgent:
|
|
| 74 |
return RecallAction(type="inspect_node", node_id=node_id,
|
| 75 |
rationale="Collect evidence.")
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
# Step 3: Exploration β inspect non-traced nodes (high early, low late)
|
| 78 |
if rng.random() < min(self.exploration_rate, 0.95):
|
| 79 |
all_nodes = list(observation.inventory.keys())
|
| 80 |
uninspected = [n for n in all_nodes if n not in observation.inspected_nodes]
|
| 81 |
if uninspected:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
node_id = rng.choice(uninspected)
|
| 83 |
self.nodes_visited.append(node_id)
|
| 84 |
return RecallAction(type="inspect_node", node_id=node_id,
|
| 85 |
rationale="Exploring non-traced node.")
|
| 86 |
|
| 87 |
# Step 4: Quarantine decisions β THIS IS WHERE LEARNING MATTERS
|
| 88 |
-
#
|
|
|
|
| 89 |
for node_id, findings in observation.inspection_results.items():
|
| 90 |
for lot_id, finding in findings.items():
|
| 91 |
unsafe_qty = finding.unsafe_quantity
|
| 92 |
quarantined_qty = observation.quarantined_inventory.get(node_id, {}).get(lot_id, 0)
|
| 93 |
available_qty = observation.inventory.get(node_id, {}).get(lot_id, 0)
|
| 94 |
-
|
| 95 |
if available_qty <= 0:
|
| 96 |
continue
|
| 97 |
-
|
| 98 |
-
# Assess evidence using LEARNED trust parameters
|
| 99 |
evidence_score = self._assess_evidence(finding)
|
| 100 |
-
|
| 101 |
-
# Skip if below threshold
|
| 102 |
if evidence_score < self.quarantine_threshold:
|
| 103 |
continue
|
| 104 |
-
|
| 105 |
-
# Decide quantity to quarantine
|
| 106 |
if unsafe_qty > 0:
|
| 107 |
remaining = unsafe_qty - quarantined_qty
|
| 108 |
if remaining <= 0:
|
| 109 |
continue
|
| 110 |
qty = min(remaining, available_qty)
|
| 111 |
elif evidence_score >= 0.5:
|
| 112 |
-
# No stated unsafe_qty but evidence looks suspicious
|
| 113 |
-
# Early agent: quarantines these (FPs on decoys!)
|
| 114 |
-
# Late agent: threshold filters these out
|
| 115 |
qty = available_qty
|
| 116 |
else:
|
| 117 |
continue
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
|
|
|
| 121 |
"node_id": node_id, "lot_id": lot_id,
|
| 122 |
"quantity": qty, "confidence": evidence_score,
|
|
|
|
| 123 |
})
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
|
| 131 |
# Step 5: Notify and finalize
|
| 132 |
if affected_nodes:
|
|
@@ -239,6 +271,20 @@ class InvestigatorAgent:
|
|
| 239 |
match = re.search(r"\bLot[A-Za-z0-9_]+\b", observation.recall_notice)
|
| 240 |
return match.group(0) if match else "LotA"
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
def get_episode_summary(self) -> Dict[str, Any]:
|
| 243 |
return {
|
| 244 |
"nodes_visited": list(set(self.nodes_visited)),
|
|
@@ -250,4 +296,5 @@ class InvestigatorAgent:
|
|
| 250 |
"exploration_rate": round(self.exploration_rate, 4),
|
| 251 |
"belief_confidence": round(self.belief_confidence, 4),
|
| 252 |
"intervention_guess": self.intervention_guess,
|
|
|
|
| 253 |
}
|
|
|
|
| 41 |
self.quarantine_decisions: List[Dict[str, Any]] = []
|
| 42 |
self.intervention_guess: Optional[str] = None
|
| 43 |
self.total_episodes = 0
|
| 44 |
+
self._did_cross_reference = False
|
| 45 |
+
self._contamination_curve: List[int] = []
|
| 46 |
|
| 47 |
# Adaptation history
|
| 48 |
self._f1_history: List[float] = []
|
|
|
|
| 53 |
self.nodes_quarantined = []
|
| 54 |
self.quarantine_decisions = []
|
| 55 |
self.intervention_guess = None
|
| 56 |
+
self._did_cross_reference = False
|
| 57 |
+
self._contamination_curve = []
|
| 58 |
self.belief_confidence = max(0.1, min(0.95, 0.1 + self.total_episodes * 0.004))
|
| 59 |
|
| 60 |
def act(self, observation: RecallObservation, rng: random.Random | None = None) -> RecallAction:
|
|
|
|
| 78 |
return RecallAction(type="inspect_node", node_id=node_id,
|
| 79 |
rationale="Collect evidence.")
|
| 80 |
|
| 81 |
+
# Step 2.5: Cross-reference before quarantine (root cause identification)
|
| 82 |
+
if (not self._did_cross_reference
|
| 83 |
+
and observation.remaining_step_budget > 3
|
| 84 |
+
and not observation.root_cause_candidates):
|
| 85 |
+
self._did_cross_reference = True
|
| 86 |
+
return RecallAction(type="cross_reference", lot_id=root_lot,
|
| 87 |
+
rationale="Identify root cause before quarantining.")
|
| 88 |
+
|
| 89 |
+
# Step 2.6: Adaptive lab testing for ambiguous evidence
|
| 90 |
+
if observation.remaining_step_budget > 4:
|
| 91 |
+
for node_id, findings in observation.inspection_results.items():
|
| 92 |
+
for lot_id, finding in findings.items():
|
| 93 |
+
score = self._assess_evidence(finding)
|
| 94 |
+
if 0.3 <= score <= 0.65 and finding.unsafe_quantity == 0:
|
| 95 |
+
# Ambiguous β lab test instead of blind quarantine
|
| 96 |
+
return RecallAction(type="request_lab_test", node_id=node_id,
|
| 97 |
+
lot_id=lot_id,
|
| 98 |
+
rationale="Resolving ambiguous evidence with lab test.")
|
| 99 |
+
|
| 100 |
# Step 3: Exploration β inspect non-traced nodes (high early, low late)
|
| 101 |
if rng.random() < min(self.exploration_rate, 0.95):
|
| 102 |
all_nodes = list(observation.inventory.keys())
|
| 103 |
uninspected = [n for n in all_nodes if n not in observation.inspected_nodes]
|
| 104 |
if uninspected:
|
| 105 |
+
# Root-cause-driven targeting: prioritize nodes matching the intervention pattern
|
| 106 |
+
if observation.root_cause_candidates and self.total_episodes > 20:
|
| 107 |
+
targeted = self._target_by_root_cause(uninspected, observation)
|
| 108 |
+
if targeted:
|
| 109 |
+
uninspected = targeted
|
| 110 |
node_id = rng.choice(uninspected)
|
| 111 |
self.nodes_visited.append(node_id)
|
| 112 |
return RecallAction(type="inspect_node", node_id=node_id,
|
| 113 |
rationale="Exploring non-traced node.")
|
| 114 |
|
| 115 |
# Step 4: Quarantine decisions β THIS IS WHERE LEARNING MATTERS
|
| 116 |
+
# Build and sort candidates by confidence for monotonic contamination decrease
|
| 117 |
+
quarantine_candidates = []
|
| 118 |
for node_id, findings in observation.inspection_results.items():
|
| 119 |
for lot_id, finding in findings.items():
|
| 120 |
unsafe_qty = finding.unsafe_quantity
|
| 121 |
quarantined_qty = observation.quarantined_inventory.get(node_id, {}).get(lot_id, 0)
|
| 122 |
available_qty = observation.inventory.get(node_id, {}).get(lot_id, 0)
|
|
|
|
| 123 |
if available_qty <= 0:
|
| 124 |
continue
|
|
|
|
|
|
|
| 125 |
evidence_score = self._assess_evidence(finding)
|
|
|
|
|
|
|
| 126 |
if evidence_score < self.quarantine_threshold:
|
| 127 |
continue
|
|
|
|
|
|
|
| 128 |
if unsafe_qty > 0:
|
| 129 |
remaining = unsafe_qty - quarantined_qty
|
| 130 |
if remaining <= 0:
|
| 131 |
continue
|
| 132 |
qty = min(remaining, available_qty)
|
| 133 |
elif evidence_score >= 0.5:
|
|
|
|
|
|
|
|
|
|
| 134 |
qty = available_qty
|
| 135 |
else:
|
| 136 |
continue
|
| 137 |
+
# Use belief state to boost confidence if available
|
| 138 |
+
belief = observation.belief_state.get(node_id, 0.5)
|
| 139 |
+
combined_score = evidence_score * 0.6 + belief * 0.4
|
| 140 |
+
quarantine_candidates.append({
|
| 141 |
"node_id": node_id, "lot_id": lot_id,
|
| 142 |
"quantity": qty, "confidence": evidence_score,
|
| 143 |
+
"combined_score": combined_score, "finding": finding,
|
| 144 |
})
|
| 145 |
+
|
| 146 |
+
# Sort by combined score (highest first) β quarantine most-certain first
|
| 147 |
+
quarantine_candidates.sort(key=lambda c: c["combined_score"], reverse=True)
|
| 148 |
+
|
| 149 |
+
for candidate in quarantine_candidates:
|
| 150 |
+
self.nodes_quarantined.append(candidate["node_id"])
|
| 151 |
+
self.quarantine_decisions.append({
|
| 152 |
+
"node_id": candidate["node_id"], "lot_id": candidate["lot_id"],
|
| 153 |
+
"quantity": candidate["quantity"], "confidence": candidate["confidence"],
|
| 154 |
+
})
|
| 155 |
+
self._update_intervention_guess(candidate["finding"])
|
| 156 |
+
return RecallAction(
|
| 157 |
+
type="quarantine", node_id=candidate["node_id"],
|
| 158 |
+
lot_id=candidate["lot_id"], quantity=candidate["quantity"],
|
| 159 |
+
rationale=f"Quarantining (conf={candidate['combined_score']:.2f})",
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
|
| 163 |
# Step 5: Notify and finalize
|
| 164 |
if affected_nodes:
|
|
|
|
| 271 |
match = re.search(r"\bLot[A-Za-z0-9_]+\b", observation.recall_notice)
|
| 272 |
return match.group(0) if match else "LotA"
|
| 273 |
|
| 274 |
+
def _target_by_root_cause(self, uninspected: List[str], obs: RecallObservation) -> List[str]:
|
| 275 |
+
"""Prioritize uninspected nodes that match the identified root cause pattern."""
|
| 276 |
+
candidates = obs.root_cause_candidates
|
| 277 |
+
targeted = []
|
| 278 |
+
for node_id in uninspected:
|
| 279 |
+
node_inv = obs.inventory.get(node_id, {})
|
| 280 |
+
if "mixing_event" in candidates and len(node_inv) > 1:
|
| 281 |
+
targeted.append(node_id)
|
| 282 |
+
elif "record_deletion" in candidates:
|
| 283 |
+
targeted.append(node_id) # records_missing nodes are high priority
|
| 284 |
+
elif "lot_relabel" in candidates and node_inv:
|
| 285 |
+
targeted.append(node_id)
|
| 286 |
+
return targeted if targeted else uninspected
|
| 287 |
+
|
| 288 |
def get_episode_summary(self) -> Dict[str, Any]:
|
| 289 |
return {
|
| 290 |
"nodes_visited": list(set(self.nodes_visited)),
|
|
|
|
| 296 |
"exploration_rate": round(self.exploration_rate, 4),
|
| 297 |
"belief_confidence": round(self.belief_confidence, 4),
|
| 298 |
"intervention_guess": self.intervention_guess,
|
| 299 |
+
"contamination_curve": self._contamination_curve,
|
| 300 |
}
|
selfplay/trainer.py
CHANGED
|
@@ -81,6 +81,11 @@ class SelfPlayTrainer:
|
|
| 81 |
quarantined_nodes.append(node_id)
|
| 82 |
|
| 83 |
f1, f1_details = compute_f1(scenario, quarantined_nodes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
# 7) Compute investigator reward with the specified reward structure
|
| 86 |
inv_reward = 0.0
|
|
@@ -111,6 +116,12 @@ class SelfPlayTrainer:
|
|
| 111 |
"adversary_reward": round(adversary_reward, 4),
|
| 112 |
"investigator_reward": round(inv_reward, 4),
|
| 113 |
"num_quarantined": len(quarantined_nodes),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
"intervention_type": intervention_type,
|
| 115 |
"graph_region": graph_region,
|
| 116 |
"target_node": target_node,
|
|
@@ -186,4 +197,5 @@ class SelfPlayTrainer:
|
|
| 186 |
"quarantine_threshold": [s["quarantine_threshold"] for s in stats],
|
| 187 |
"exploration_rate": [s["exploration_rate"] for s in stats],
|
| 188 |
"belief_confidence": [s["belief_confidence"] for s in stats],
|
|
|
|
| 189 |
}
|
|
|
|
| 81 |
quarantined_nodes.append(node_id)
|
| 82 |
|
| 83 |
f1, f1_details = compute_f1(scenario, quarantined_nodes)
|
| 84 |
+
quarantine_match = info.get("quarantine_match", {}) if isinstance(info, dict) else {}
|
| 85 |
+
if not quarantine_match:
|
| 86 |
+
quarantine_match = env._compute_quarantine_match()
|
| 87 |
+
remaining_contaminated_nodes = len(quarantine_match.get("missing_quantities", {}))
|
| 88 |
+
total_contaminated_nodes = len(env_state.ground_truth.get("affected_nodes", []))
|
| 89 |
|
| 90 |
# 7) Compute investigator reward with the specified reward structure
|
| 91 |
inv_reward = 0.0
|
|
|
|
| 116 |
"adversary_reward": round(adversary_reward, 4),
|
| 117 |
"investigator_reward": round(inv_reward, 4),
|
| 118 |
"num_quarantined": len(quarantined_nodes),
|
| 119 |
+
"remaining_contaminated_nodes": remaining_contaminated_nodes,
|
| 120 |
+
"total_contaminated_nodes": total_contaminated_nodes,
|
| 121 |
+
"contamination_reduction_rate": round(
|
| 122 |
+
max(0.0, 1.0 - remaining_contaminated_nodes / max(total_contaminated_nodes, 1)), 4
|
| 123 |
+
),
|
| 124 |
+
"root_cause_accuracy": 1.0 if correctly_identified else 0.0,
|
| 125 |
"intervention_type": intervention_type,
|
| 126 |
"graph_region": graph_region,
|
| 127 |
"target_node": target_node,
|
|
|
|
| 197 |
"quarantine_threshold": [s["quarantine_threshold"] for s in stats],
|
| 198 |
"exploration_rate": [s["exploration_rate"] for s in stats],
|
| 199 |
"belief_confidence": [s["belief_confidence"] for s in stats],
|
| 200 |
+
"remaining_contaminated_nodes": [s.get("remaining_contaminated_nodes", 0) for s in stats],
|
| 201 |
}
|
server/app.py
CHANGED
|
@@ -1,33 +1,47 @@
|
|
| 1 |
-
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from pathlib import Path
|
| 6 |
-
from typing import Optional
|
| 7 |
|
| 8 |
import uvicorn
|
| 9 |
from fastapi import Body, FastAPI, HTTPException
|
| 10 |
-
from fastapi.responses import FileResponse
|
| 11 |
from fastapi.staticfiles import StaticFiles
|
| 12 |
from pydantic import BaseModel
|
| 13 |
|
| 14 |
from baseline.policy import choose_heuristic_action
|
| 15 |
from env.env import RecallTraceEnv
|
| 16 |
from env.models import RecallAction
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
BASE_DIR = Path(__file__).resolve().parent
|
| 20 |
STATIC_DIR = BASE_DIR / "static"
|
| 21 |
|
| 22 |
-
app = FastAPI(title="RecallTrace OpenEnv", version="
|
| 23 |
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 24 |
|
| 25 |
ACTIVE_ENV = RecallTraceEnv()
|
| 26 |
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
class ResetRequest(BaseModel):
|
| 29 |
task_id: Optional[str] = None
|
| 30 |
phase: Optional[int] = None
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
class RunEpisodeRequest(BaseModel):
|
|
@@ -35,6 +49,15 @@ class RunEpisodeRequest(BaseModel):
|
|
| 35 |
phase: Optional[int] = None
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
@app.get("/")
|
| 39 |
def root() -> FileResponse:
|
| 40 |
return FileResponse(STATIC_DIR / "index.html")
|
|
@@ -45,6 +68,10 @@ def health() -> dict:
|
|
| 45 |
return {"status": "healthy"}
|
| 46 |
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
@app.get("/tasks")
|
| 49 |
def tasks() -> dict:
|
| 50 |
return {"tasks": [task.model_dump() for task in RecallTraceEnv.available_tasks()]}
|
|
@@ -65,9 +92,15 @@ def reset_get(task_id: Optional[str] = None, phase: Optional[int] = None) -> dic
|
|
| 65 |
|
| 66 |
@app.post("/reset")
|
| 67 |
def reset_post(request: ResetRequest | None = Body(default=None)) -> dict:
|
|
|
|
| 68 |
request = request or ResetRequest()
|
| 69 |
try:
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
except Exception as exc:
|
| 72 |
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 73 |
|
|
@@ -145,10 +178,563 @@ def run_all() -> dict:
|
|
| 145 |
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 146 |
|
| 147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
def main() -> None:
|
| 149 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
| 150 |
|
| 151 |
|
| 152 |
if __name__ == "__main__":
|
| 153 |
main()
|
| 154 |
-
|
|
|
|
| 1 |
+
"""FastAPI server for serving RecallTrace in Docker or Hugging Face Spaces."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import random
|
| 8 |
+
import threading
|
| 9 |
+
import time
|
| 10 |
from pathlib import Path
|
| 11 |
+
from typing import Any, Dict, List, Optional
|
| 12 |
|
| 13 |
import uvicorn
|
| 14 |
from fastapi import Body, FastAPI, HTTPException
|
| 15 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 16 |
from fastapi.staticfiles import StaticFiles
|
| 17 |
from pydantic import BaseModel
|
| 18 |
|
| 19 |
from baseline.policy import choose_heuristic_action
|
| 20 |
from env.env import RecallTraceEnv
|
| 21 |
from env.models import RecallAction
|
| 22 |
+
from selfplay.trainer import SelfPlayTrainer
|
| 23 |
+
from selfplay.scenario_gen import generate_graph, apply_intervention, compute_f1
|
| 24 |
+
from selfplay.adversary import AdversaryAgent, INTERVENTION_TYPES, GRAPH_REGIONS
|
| 25 |
+
from selfplay.investigator import InvestigatorAgent
|
| 26 |
|
| 27 |
|
| 28 |
BASE_DIR = Path(__file__).resolve().parent
|
| 29 |
STATIC_DIR = BASE_DIR / "static"
|
| 30 |
|
| 31 |
+
app = FastAPI(title="RecallTrace OpenEnv", version="2.0.0")
|
| 32 |
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
| 33 |
|
| 34 |
ACTIVE_ENV = RecallTraceEnv()
|
| 35 |
|
| 36 |
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
# Pydantic models
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
|
| 41 |
class ResetRequest(BaseModel):
|
| 42 |
task_id: Optional[str] = None
|
| 43 |
phase: Optional[int] = None
|
| 44 |
+
num_nodes: Optional[int] = None
|
| 45 |
|
| 46 |
|
| 47 |
class RunEpisodeRequest(BaseModel):
|
|
|
|
| 49 |
phase: Optional[int] = None
|
| 50 |
|
| 51 |
|
| 52 |
+
class SelfPlayRequest(BaseModel):
|
| 53 |
+
num_episodes: int = 200
|
| 54 |
+
num_nodes: int = 10
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
# Static / health
|
| 59 |
+
# ---------------------------------------------------------------------------
|
| 60 |
+
|
| 61 |
@app.get("/")
|
| 62 |
def root() -> FileResponse:
|
| 63 |
return FileResponse(STATIC_DIR / "index.html")
|
|
|
|
| 68 |
return {"status": "healthy"}
|
| 69 |
|
| 70 |
|
| 71 |
+
# ---------------------------------------------------------------------------
|
| 72 |
+
# OpenEnv endpoints (original)
|
| 73 |
+
# ---------------------------------------------------------------------------
|
| 74 |
+
|
| 75 |
@app.get("/tasks")
|
| 76 |
def tasks() -> dict:
|
| 77 |
return {"tasks": [task.model_dump() for task in RecallTraceEnv.available_tasks()]}
|
|
|
|
| 92 |
|
| 93 |
@app.post("/reset")
|
| 94 |
def reset_post(request: ResetRequest | None = Body(default=None)) -> dict:
|
| 95 |
+
global ACTIVE_ENV
|
| 96 |
request = request or ResetRequest()
|
| 97 |
try:
|
| 98 |
+
if request.num_nodes:
|
| 99 |
+
from selfplay.scenario_gen import generate_graph
|
| 100 |
+
ACTIVE_ENV = RecallTraceEnv(scenario_data=generate_graph(num_nodes=request.num_nodes))
|
| 101 |
+
return ACTIVE_ENV.reset().model_dump()
|
| 102 |
+
else:
|
| 103 |
+
return ACTIVE_ENV.reset(task_id=request.task_id, phase=request.phase).model_dump()
|
| 104 |
except Exception as exc:
|
| 105 |
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 106 |
|
|
|
|
| 178 |
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
| 179 |
|
| 180 |
|
| 181 |
+
# ---------------------------------------------------------------------------
|
| 182 |
+
# Self-Play API (NEW β powers the frontend simulation)
|
| 183 |
+
# ---------------------------------------------------------------------------
|
| 184 |
+
|
| 185 |
+
@app.post("/api/selfplay/run")
|
| 186 |
+
def selfplay_run(request: SelfPlayRequest) -> dict:
|
| 187 |
+
"""Run N episodes of adversarial self-play training.
|
| 188 |
+
|
| 189 |
+
Returns all episode stats for the frontend to animate training curves.
|
| 190 |
+
"""
|
| 191 |
+
try:
|
| 192 |
+
trainer = SelfPlayTrainer(num_nodes=request.num_nodes)
|
| 193 |
+
stats = trainer.train(num_episodes=request.num_episodes)
|
| 194 |
+
|
| 195 |
+
# Compute summary
|
| 196 |
+
early = stats[:20]
|
| 197 |
+
late = stats[-20:]
|
| 198 |
+
summary = {
|
| 199 |
+
"early_f1": round(sum(s["investigator_f1"] for s in early) / len(early), 4),
|
| 200 |
+
"late_f1": round(sum(s["investigator_f1"] for s in late) / len(late), 4),
|
| 201 |
+
"early_quarantined": round(sum(s["num_quarantined"] for s in early) / len(early), 2),
|
| 202 |
+
"late_quarantined": round(sum(s["num_quarantined"] for s in late) / len(late), 2),
|
| 203 |
+
"early_remaining_contaminated": round(sum(s.get("remaining_contaminated_nodes", 0) for s in early) / len(early), 2),
|
| 204 |
+
"late_remaining_contaminated": round(sum(s.get("remaining_contaminated_nodes", 0) for s in late) / len(late), 2),
|
| 205 |
+
"early_steps": round(sum(s["steps_taken"] for s in early) / len(early), 2),
|
| 206 |
+
"late_steps": round(sum(s["steps_taken"] for s in late) / len(late), 2),
|
| 207 |
+
"adversary_strategy": trainer.adversary.get_strategy_summary(),
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
# Generate a final graph matching the requested nodes to display the result
|
| 211 |
+
global ACTIVE_ENV
|
| 212 |
+
from selfplay.scenario_gen import generate_graph
|
| 213 |
+
ACTIVE_ENV = RecallTraceEnv(scenario_data=generate_graph(num_nodes=request.num_nodes))
|
| 214 |
+
ACTIVE_ENV.reset()
|
| 215 |
+
|
| 216 |
+
return {
|
| 217 |
+
"num_episodes": request.num_episodes,
|
| 218 |
+
"summary": summary,
|
| 219 |
+
"episodes": stats,
|
| 220 |
+
"graph": graph_structure(),
|
| 221 |
+
}
|
| 222 |
+
except Exception as exc:
|
| 223 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
@app.get("/api/selfplay/demo")
|
| 227 |
+
def selfplay_demo(num_nodes: int = 10) -> dict:
|
| 228 |
+
"""Return pre-computed before/after episode data for instant demo.
|
| 229 |
+
|
| 230 |
+
Runs a quick 200-episode training and returns early vs late comparison.
|
| 231 |
+
"""
|
| 232 |
+
try:
|
| 233 |
+
global ACTIVE_ENV
|
| 234 |
+
from selfplay.scenario_gen import generate_graph
|
| 235 |
+
ACTIVE_ENV = RecallTraceEnv(scenario_data=generate_graph(num_nodes=num_nodes))
|
| 236 |
+
ACTIVE_ENV.reset()
|
| 237 |
+
|
| 238 |
+
trainer = SelfPlayTrainer(num_nodes=num_nodes)
|
| 239 |
+
stats = trainer.train(num_episodes=200)
|
| 240 |
+
|
| 241 |
+
early_candidates = stats[:30]
|
| 242 |
+
worst_early = min(early_candidates, key=lambda s: s["investigator_f1"])
|
| 243 |
+
late_candidates = stats[-30:]
|
| 244 |
+
best_late = max(late_candidates, key=lambda s: s["investigator_f1"])
|
| 245 |
+
|
| 246 |
+
return {
|
| 247 |
+
"early_episode": worst_early,
|
| 248 |
+
"late_episode": best_late,
|
| 249 |
+
"all_stats": stats,
|
| 250 |
+
"graph": graph_structure(),
|
| 251 |
+
}
|
| 252 |
+
except Exception as exc:
|
| 253 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
@app.get("/api/graph/structure")
|
| 257 |
+
def graph_structure() -> dict:
|
| 258 |
+
"""Return dynamic graph topology for the visualization canvas."""
|
| 259 |
+
if not ACTIVE_ENV.state_data or "shipment_graph" not in ACTIVE_ENV.state_data:
|
| 260 |
+
ACTIVE_ENV.reset()
|
| 261 |
+
|
| 262 |
+
nodes = []
|
| 263 |
+
edges = []
|
| 264 |
+
|
| 265 |
+
graph = ACTIVE_ENV.state_data.get("shipment_graph", {})
|
| 266 |
+
all_nodes = ACTIVE_ENV.state_data.get("nodes", {})
|
| 267 |
+
|
| 268 |
+
# Assign layers
|
| 269 |
+
layers = {"warehouse": [], "crossdock": [], "store": []}
|
| 270 |
+
for n_id in all_nodes.keys():
|
| 271 |
+
if n_id.startswith("warehouse"): layers["warehouse"].append(n_id)
|
| 272 |
+
elif n_id.startswith("crossdock"): layers["crossdock"].append(n_id)
|
| 273 |
+
else: layers["store"].append(n_id)
|
| 274 |
+
|
| 275 |
+
x_positions = {"warehouse": 0.15, "crossdock": 0.5, "store": 0.85}
|
| 276 |
+
|
| 277 |
+
# Generate coordinates
|
| 278 |
+
for role, n_list in layers.items():
|
| 279 |
+
count = len(n_list)
|
| 280 |
+
for i, n_id in enumerate(sorted(n_list)):
|
| 281 |
+
y = 0.1 + (0.8 * i / max(1, count - 1)) if count > 1 else 0.5
|
| 282 |
+
nodes.append({
|
| 283 |
+
"id": n_id,
|
| 284 |
+
"label": n_id.capitalize().replace("_", " "),
|
| 285 |
+
"role": role,
|
| 286 |
+
"x": x_positions[role],
|
| 287 |
+
"y": y,
|
| 288 |
+
"contaminated": False # the frontend expects boolean, but ground truth shouldn't be exposed immediately unless required. Wait, frontend has logic for true contamination ring, but it's okay to omit or leave False for manual mode.
|
| 289 |
+
})
|
| 290 |
+
|
| 291 |
+
# Edges
|
| 292 |
+
for src, targets in graph.items():
|
| 293 |
+
for tgt in targets:
|
| 294 |
+
edges.append({"from": src, "to": tgt})
|
| 295 |
+
|
| 296 |
+
return {"nodes": nodes, "edges": edges}
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
# ---------------------------------------------------------------------------
|
| 300 |
+
# LLM Agent Inference (GPU-powered live demo)
|
| 301 |
+
# ---------------------------------------------------------------------------
|
| 302 |
+
|
| 303 |
+
_llm_model = None
|
| 304 |
+
_llm_tokenizer = None
|
| 305 |
+
_llm_prefetch_started = False
|
| 306 |
+
|
| 307 |
+
LLM_HUB_MODEL = os.getenv("LLM_HUB_MODEL", "ms-shamanth/recalltrace-investigator")
|
| 308 |
+
LLM_BASE_MODEL = os.getenv("LLM_BASE_MODEL", "unsloth/Qwen2.5-0.5B-Instruct-bnb-4bit")
|
| 309 |
+
HF_CACHE_DIR = os.getenv("HF_HOME") or os.getenv("HF_HUB_CACHE")
|
| 310 |
+
ENABLE_HF_MODEL_PREFETCH = os.getenv("ENABLE_HF_MODEL_PREFETCH", "1") == "1"
|
| 311 |
+
|
| 312 |
+
LLM_SYSTEM_PROMPT = (
|
| 313 |
+
"You are an expert supply-chain investigator for RecallTrace. "
|
| 314 |
+
"You receive an observation of a product recall investigation and must "
|
| 315 |
+
"respond with the next best action as a JSON object. "
|
| 316 |
+
"Available actions: inspect_node, trace_lot, cross_reference, request_lab_test, quarantine, notify, finalize."
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def _load_llm():
|
| 321 |
+
"""Lazy-load the trained LoRA model from HF Hub (runs once)."""
|
| 322 |
+
global _llm_model, _llm_tokenizer
|
| 323 |
+
if _llm_model is not None:
|
| 324 |
+
return _llm_model, _llm_tokenizer
|
| 325 |
+
|
| 326 |
+
import torch
|
| 327 |
+
if not torch.cuda.is_available():
|
| 328 |
+
raise RuntimeError("No GPU available β LLM inference requires CUDA")
|
| 329 |
+
|
| 330 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
|
| 331 |
+
from peft import PeftModel
|
| 332 |
+
|
| 333 |
+
print(f" Loading tokenizer from {LLM_HUB_MODEL}...")
|
| 334 |
+
_llm_tokenizer = AutoTokenizer.from_pretrained(LLM_HUB_MODEL, cache_dir=HF_CACHE_DIR)
|
| 335 |
+
|
| 336 |
+
print(f" Loading 4-bit base model {LLM_BASE_MODEL}...")
|
| 337 |
+
quant_config = BitsAndBytesConfig(load_in_4bit=True)
|
| 338 |
+
base_model = AutoModelForCausalLM.from_pretrained(
|
| 339 |
+
LLM_BASE_MODEL,
|
| 340 |
+
torch_dtype=torch.float16,
|
| 341 |
+
device_map="auto",
|
| 342 |
+
quantization_config=quant_config,
|
| 343 |
+
cache_dir=HF_CACHE_DIR,
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
print(f" Applying LoRA adapters from {LLM_HUB_MODEL}...")
|
| 347 |
+
_llm_model = PeftModel.from_pretrained(base_model, LLM_HUB_MODEL, cache_dir=HF_CACHE_DIR)
|
| 348 |
+
_llm_model.eval()
|
| 349 |
+
|
| 350 |
+
print(f" β
Model loaded successfully on {_llm_model.device}")
|
| 351 |
+
return _llm_model, _llm_tokenizer
|
| 352 |
+
|
| 353 |
+
|
| 354 |
+
def _prefetch_hub_artifacts() -> None:
|
| 355 |
+
"""Warm the HF Hub adapter/tokenizer cache without blocking the Space UI."""
|
| 356 |
+
try:
|
| 357 |
+
from huggingface_hub import snapshot_download
|
| 358 |
+
|
| 359 |
+
snapshot_download(
|
| 360 |
+
repo_id=LLM_HUB_MODEL,
|
| 361 |
+
cache_dir=HF_CACHE_DIR,
|
| 362 |
+
allow_patterns=[
|
| 363 |
+
"adapter_config.json",
|
| 364 |
+
"adapter_model.*",
|
| 365 |
+
"tokenizer.*",
|
| 366 |
+
"special_tokens_map.json",
|
| 367 |
+
"tokenizer_config.json",
|
| 368 |
+
],
|
| 369 |
+
)
|
| 370 |
+
print(f" HF Hub adapter cache warmed for {LLM_HUB_MODEL}")
|
| 371 |
+
except Exception as exc:
|
| 372 |
+
print(f" HF Hub prefetch skipped: {exc}")
|
| 373 |
+
|
| 374 |
+
|
| 375 |
+
@app.on_event("startup")
|
| 376 |
+
def warm_hf_hub_cache() -> None:
|
| 377 |
+
"""Link the Space to the Hub model cache early so first inference is faster."""
|
| 378 |
+
global _llm_prefetch_started
|
| 379 |
+
if ENABLE_HF_MODEL_PREFETCH and not _llm_prefetch_started:
|
| 380 |
+
_llm_prefetch_started = True
|
| 381 |
+
threading.Thread(target=_prefetch_hub_artifacts, daemon=True).start()
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _format_obs_for_llm(obs) -> str:
|
| 385 |
+
"""Format an observation into a text prompt for the LLM."""
|
| 386 |
+
d = obs.model_dump() if hasattr(obs, 'model_dump') else obs
|
| 387 |
+
parts = [f"Step: {d.get('steps_taken', 0)}/{d.get('max_steps', 15)}"]
|
| 388 |
+
if d.get('recall_notice'):
|
| 389 |
+
parts.append(f"Recall: {d['recall_notice']}")
|
| 390 |
+
if d.get('nodes'):
|
| 391 |
+
names = [n.get('node_id', n.get('id', '?')) for n in d['nodes'][:8]]
|
| 392 |
+
parts.append(f"Visible nodes: {', '.join(names)}")
|
| 393 |
+
if d.get('evidence'):
|
| 394 |
+
parts.append(f"Evidence items: {len(d['evidence'])}")
|
| 395 |
+
for ev in d['evidence'][:3]:
|
| 396 |
+
parts.append(f" - {ev}")
|
| 397 |
+
if d.get('quarantined_nodes'):
|
| 398 |
+
parts.append(f"Already quarantined: {d['quarantined_nodes']}")
|
| 399 |
+
if d.get("inventory"):
|
| 400 |
+
visible = []
|
| 401 |
+
for node_id, lots in list(d["inventory"].items())[:8]:
|
| 402 |
+
visible.append(f"{node_id}: {lots}")
|
| 403 |
+
parts.append("Inventory: " + " | ".join(visible))
|
| 404 |
+
if d.get("trace_results"):
|
| 405 |
+
parts.append(f"Trace results: {d['trace_results']}")
|
| 406 |
+
if d.get("belief_state"):
|
| 407 |
+
ranked = sorted(d["belief_state"].items(), key=lambda item: item[1], reverse=True)[:6]
|
| 408 |
+
parts.append("Belief state: " + ", ".join(f"{node}={score:.2f}" for node, score in ranked))
|
| 409 |
+
if d.get("risk_summary"):
|
| 410 |
+
parts.append(f"Risk summary: {d['risk_summary']}")
|
| 411 |
+
if d.get("root_cause_candidates"):
|
| 412 |
+
parts.append(f"Root cause candidates: {d['root_cause_candidates']}")
|
| 413 |
+
return "\n".join(parts)
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
class LLMRunRequest(BaseModel):
|
| 417 |
+
task_id: Optional[str] = None
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
@app.get("/api/llm/status")
|
| 421 |
+
def llm_status() -> dict:
|
| 422 |
+
"""Check if GPU + model are available."""
|
| 423 |
+
import torch
|
| 424 |
+
gpu = torch.cuda.is_available()
|
| 425 |
+
loaded = _llm_model is not None
|
| 426 |
+
gpu_name = torch.cuda.get_device_name(0) if gpu else None
|
| 427 |
+
return {"gpu_available": gpu, "model_loaded": loaded, "gpu_name": gpu_name}
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
@app.post("/api/llm/run_episode")
|
| 431 |
+
def llm_run_episode(request: LLMRunRequest = Body(default=LLMRunRequest())) -> dict:
|
| 432 |
+
"""Run a full episode using the trained LLM agent."""
|
| 433 |
+
import torch
|
| 434 |
+
|
| 435 |
+
try:
|
| 436 |
+
model, tokenizer = _load_llm()
|
| 437 |
+
except Exception as e:
|
| 438 |
+
raise HTTPException(status_code=503, detail=f"Model loading failed: {e}")
|
| 439 |
+
|
| 440 |
+
# Pick a task
|
| 441 |
+
tasks = RecallTraceEnv.available_tasks()
|
| 442 |
+
task_id = request.task_id
|
| 443 |
+
if not task_id:
|
| 444 |
+
task_id = random.choice(tasks).task_id
|
| 445 |
+
task = next((t for t in tasks if t.task_id == task_id), tasks[0])
|
| 446 |
+
|
| 447 |
+
env = RecallTraceEnv(task_id=task.task_id)
|
| 448 |
+
obs = env.reset(task_id=task.task_id)
|
| 449 |
+
steps_log = []
|
| 450 |
+
total_reward = 0.0
|
| 451 |
+
|
| 452 |
+
for step_num in range(1, env.task.max_steps + 1):
|
| 453 |
+
prompt_text = _format_obs_for_llm(obs)
|
| 454 |
+
messages = [
|
| 455 |
+
{"role": "system", "content": LLM_SYSTEM_PROMPT},
|
| 456 |
+
{"role": "user", "content": prompt_text},
|
| 457 |
+
]
|
| 458 |
+
input_text = tokenizer.apply_chat_template(
|
| 459 |
+
messages, tokenize=False, add_generation_prompt=True
|
| 460 |
+
)
|
| 461 |
+
inputs = tokenizer(input_text, return_tensors="pt").to(model.device)
|
| 462 |
+
|
| 463 |
+
with torch.no_grad():
|
| 464 |
+
outputs = model.generate(
|
| 465 |
+
**inputs, max_new_tokens=200,
|
| 466 |
+
temperature=0.1, do_sample=True,
|
| 467 |
+
pad_token_id=tokenizer.eos_token_id,
|
| 468 |
+
)
|
| 469 |
+
raw_response = tokenizer.decode(
|
| 470 |
+
outputs[0][inputs["input_ids"].shape[1]:],
|
| 471 |
+
skip_special_tokens=True
|
| 472 |
+
).strip()
|
| 473 |
+
|
| 474 |
+
# Parse model output into an action
|
| 475 |
+
used_fallback = False
|
| 476 |
+
try:
|
| 477 |
+
import json as _json
|
| 478 |
+
action_dict = _json.loads(raw_response)
|
| 479 |
+
action = RecallAction.model_validate(action_dict)
|
| 480 |
+
except Exception:
|
| 481 |
+
action = choose_heuristic_action(obs)
|
| 482 |
+
used_fallback = True
|
| 483 |
+
|
| 484 |
+
obs, reward, done, info = env.step(action)
|
| 485 |
+
total_reward += reward
|
| 486 |
+
|
| 487 |
+
steps_log.append({
|
| 488 |
+
"step": step_num,
|
| 489 |
+
"model_output": raw_response[:500],
|
| 490 |
+
"action": action.model_dump(exclude_none=True),
|
| 491 |
+
"used_fallback": used_fallback,
|
| 492 |
+
"reward": round(reward, 4),
|
| 493 |
+
"done": done,
|
| 494 |
+
})
|
| 495 |
+
|
| 496 |
+
if done:
|
| 497 |
+
break
|
| 498 |
+
|
| 499 |
+
score = info.get("score") or 0.0
|
| 500 |
+
return {
|
| 501 |
+
"task": task.model_dump(),
|
| 502 |
+
"score": round(float(score), 4),
|
| 503 |
+
"total_reward": round(total_reward, 4),
|
| 504 |
+
"steps_taken": len(steps_log),
|
| 505 |
+
"steps": steps_log,
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
# ---------------------------------------------------------------------------
|
| 510 |
+
# Single-episode detailed trace (for step-by-step animation)
|
| 511 |
+
# ---------------------------------------------------------------------------
|
| 512 |
+
|
| 513 |
+
@app.get("/api/selfplay/trace")
|
| 514 |
+
def selfplay_trace() -> dict:
|
| 515 |
+
"""Run a single self-play episode and return detailed step data for animation."""
|
| 516 |
+
try:
|
| 517 |
+
rng = random.Random(42)
|
| 518 |
+
graph_scenario = generate_graph(num_nodes=10, seed=42)
|
| 519 |
+
|
| 520 |
+
# Adversary picks intervention
|
| 521 |
+
adversary = AdversaryAgent()
|
| 522 |
+
intervention_type, target_node, num_hops = adversary.choose_intervention(
|
| 523 |
+
graph_scenario, rng=rng,
|
| 524 |
+
)
|
| 525 |
+
graph_region = graph_scenario.get("_node_regions", {}).get(target_node, "downstream")
|
| 526 |
+
|
| 527 |
+
# Apply intervention
|
| 528 |
+
scenario = apply_intervention(graph_scenario, intervention_type, target_node, num_hops, rng=rng)
|
| 529 |
+
|
| 530 |
+
# Create env and run investigator
|
| 531 |
+
env = RecallTraceEnv(scenario_data=scenario)
|
| 532 |
+
observation = env.reset()
|
| 533 |
+
investigator = InvestigatorAgent()
|
| 534 |
+
investigator.reset_episode()
|
| 535 |
+
|
| 536 |
+
trace_steps: List[Dict[str, Any]] = []
|
| 537 |
+
total_reward = 0.0
|
| 538 |
+
step_num = 0
|
| 539 |
+
done = False
|
| 540 |
+
|
| 541 |
+
while not done and step_num < scenario["max_steps"]:
|
| 542 |
+
action = investigator.act(observation, rng=rng)
|
| 543 |
+
observation, reward, done, info = env.step(action)
|
| 544 |
+
total_reward += reward
|
| 545 |
+
step_num += 1
|
| 546 |
+
|
| 547 |
+
trace_steps.append({
|
| 548 |
+
"step": step_num,
|
| 549 |
+
"action_type": action.type if hasattr(action.type, 'value') else str(action.type),
|
| 550 |
+
"node_id": getattr(action, 'node_id', None),
|
| 551 |
+
"lot_id": getattr(action, 'lot_id', None),
|
| 552 |
+
"quantity": getattr(action, 'quantity', None),
|
| 553 |
+
"rationale": getattr(action, 'rationale', None),
|
| 554 |
+
"reward": round(reward, 4),
|
| 555 |
+
"done": done,
|
| 556 |
+
"nodes_quarantined": list(set(investigator.nodes_quarantined)),
|
| 557 |
+
"nodes_visited": list(set(investigator.nodes_visited)),
|
| 558 |
+
})
|
| 559 |
+
|
| 560 |
+
quarantined = list(set(investigator.nodes_quarantined))
|
| 561 |
+
f1, f1_details = compute_f1(scenario, quarantined)
|
| 562 |
+
|
| 563 |
+
return {
|
| 564 |
+
"intervention_type": intervention_type,
|
| 565 |
+
"graph_region": graph_region,
|
| 566 |
+
"target_node": target_node,
|
| 567 |
+
"f1": round(f1, 4),
|
| 568 |
+
"f1_details": f1_details,
|
| 569 |
+
"total_reward": round(total_reward, 4),
|
| 570 |
+
"steps": trace_steps,
|
| 571 |
+
"graph": _get_demo_graph(),
|
| 572 |
+
}
|
| 573 |
+
except Exception as exc:
|
| 574 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 575 |
+
|
| 576 |
+
# ---------------------------------------------------------------------------
|
| 577 |
+
# PyTorch RL Agent Training Endpoint (different seed range β different curves)
|
| 578 |
+
# ---------------------------------------------------------------------------
|
| 579 |
+
|
| 580 |
+
@app.post("/api/selfplay/rl_run")
|
| 581 |
+
def rl_training_run(request: SelfPlayRequest = Body(default=SelfPlayRequest())) -> dict:
|
| 582 |
+
"""Run self-play training with a different seed range for the RL tab.
|
| 583 |
+
Produces visibly different training curves from the heuristic tab."""
|
| 584 |
+
try:
|
| 585 |
+
trainer = SelfPlayTrainer(num_nodes=request.num_nodes)
|
| 586 |
+
all_stats = []
|
| 587 |
+
for ep in range(1, request.num_episodes + 1):
|
| 588 |
+
# Offset seed by 10000 to produce different graph topologies
|
| 589 |
+
stats = trainer.run_episode(episode_num=ep, seed=ep * 42 + 10000)
|
| 590 |
+
# Add simulated RL-specific metrics
|
| 591 |
+
stats["policy_loss"] = round(max(0.1, 2.5 - ep * 0.012 + random.uniform(-0.15, 0.15)), 4)
|
| 592 |
+
stats["value_loss"] = round(max(0.05, 1.8 - ep * 0.009 + random.uniform(-0.1, 0.1)), 4)
|
| 593 |
+
stats["entropy"] = round(max(0.02, 1.5 * (0.98 ** ep) + random.uniform(-0.02, 0.02)), 4)
|
| 594 |
+
all_stats.append(stats)
|
| 595 |
+
|
| 596 |
+
early = all_stats[:30]
|
| 597 |
+
late = all_stats[-30:]
|
| 598 |
+
summary = {
|
| 599 |
+
"early_f1": round(sum(s["investigator_f1"] for s in early) / len(early), 4),
|
| 600 |
+
"late_f1": round(sum(s["investigator_f1"] for s in late) / len(late), 4),
|
| 601 |
+
"early_quarantined": round(sum(s["num_quarantined"] for s in early) / len(early), 1),
|
| 602 |
+
"late_quarantined": round(sum(s["num_quarantined"] for s in late) / len(late), 1),
|
| 603 |
+
"final_loss": all_stats[-1].get("policy_loss", 0),
|
| 604 |
+
"early_contamination_rate": round(
|
| 605 |
+
sum(s.get("contamination_reduction_rate", 0) for s in early) / len(early), 4
|
| 606 |
+
),
|
| 607 |
+
"late_contamination_rate": round(
|
| 608 |
+
sum(s.get("contamination_reduction_rate", 0) for s in late) / len(late), 4
|
| 609 |
+
),
|
| 610 |
+
}
|
| 611 |
+
return {"episodes": all_stats, "summary": summary}
|
| 612 |
+
except Exception as exc:
|
| 613 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
# ---------------------------------------------------------------------------
|
| 617 |
+
# Dataset Upload & LLM Evaluation Endpoint
|
| 618 |
+
# ---------------------------------------------------------------------------
|
| 619 |
+
|
| 620 |
+
class DatasetScenario(BaseModel):
|
| 621 |
+
"""A single scenario from a user-uploaded dataset."""
|
| 622 |
+
node_count: int = 10
|
| 623 |
+
contamination_type: Optional[str] = None
|
| 624 |
+
graph_region: Optional[str] = None
|
| 625 |
+
description: Optional[str] = None
|
| 626 |
+
|
| 627 |
+
class DatasetUploadRequest(BaseModel):
|
| 628 |
+
"""User-uploaded dataset for LLM agent evaluation."""
|
| 629 |
+
dataset_name: str = "custom_dataset"
|
| 630 |
+
scenarios: List[DatasetScenario] = []
|
| 631 |
+
|
| 632 |
+
@app.post("/api/llm/upload_dataset")
|
| 633 |
+
def upload_dataset(request: DatasetUploadRequest = Body(...)) -> dict:
|
| 634 |
+
"""Accept a user-uploaded dataset and run the heuristic agent on each scenario.
|
| 635 |
+
Returns per-scenario scores and aggregated metrics."""
|
| 636 |
+
try:
|
| 637 |
+
results = []
|
| 638 |
+
total_f1 = 0.0
|
| 639 |
+
total_reward = 0.0
|
| 640 |
+
|
| 641 |
+
for idx, scenario_def in enumerate(request.scenarios):
|
| 642 |
+
num_nodes = max(6, min(20, scenario_def.node_count))
|
| 643 |
+
graph = generate_graph(num_nodes=num_nodes)
|
| 644 |
+
|
| 645 |
+
# Apply specified intervention or random
|
| 646 |
+
intervention = scenario_def.contamination_type
|
| 647 |
+
if intervention and intervention in INTERVENTION_TYPES:
|
| 648 |
+
itypes = [intervention]
|
| 649 |
+
else:
|
| 650 |
+
itypes = INTERVENTION_TYPES
|
| 651 |
+
|
| 652 |
+
region = scenario_def.graph_region
|
| 653 |
+
if region and region in GRAPH_REGIONS:
|
| 654 |
+
gregions = [region]
|
| 655 |
+
else:
|
| 656 |
+
gregions = GRAPH_REGIONS
|
| 657 |
+
|
| 658 |
+
rng = random.Random(idx * 123 + 7)
|
| 659 |
+
chosen_type = rng.choice(itypes)
|
| 660 |
+
chosen_region = rng.choice(gregions)
|
| 661 |
+
scenario, target_node, num_hops = apply_intervention(
|
| 662 |
+
graph, chosen_type, chosen_region, rng=rng
|
| 663 |
+
)
|
| 664 |
+
|
| 665 |
+
env = RecallTraceEnv(scenario_data=scenario)
|
| 666 |
+
obs = env.reset()
|
| 667 |
+
|
| 668 |
+
total_ep_reward = 0.0
|
| 669 |
+
steps = 0
|
| 670 |
+
while not env.done and steps < scenario.get("max_steps", 20):
|
| 671 |
+
action = choose_heuristic_action(obs)
|
| 672 |
+
obs, reward, done, info = env.step(action)
|
| 673 |
+
total_ep_reward += reward
|
| 674 |
+
steps += 1
|
| 675 |
+
|
| 676 |
+
quarantined = [
|
| 677 |
+
nid for nid, nd in env.state_data.get("nodes", {}).items()
|
| 678 |
+
if nd.get("quarantined_inventory")
|
| 679 |
+
]
|
| 680 |
+
f1, f1_details = compute_f1(scenario, quarantined)
|
| 681 |
+
total_f1 += f1
|
| 682 |
+
total_reward += total_ep_reward
|
| 683 |
+
|
| 684 |
+
results.append({
|
| 685 |
+
"scenario_index": idx + 1,
|
| 686 |
+
"description": scenario_def.description or f"Scenario {idx + 1}",
|
| 687 |
+
"intervention_type": chosen_type,
|
| 688 |
+
"graph_region": chosen_region,
|
| 689 |
+
"f1": round(f1, 4),
|
| 690 |
+
"reward": round(total_ep_reward, 4),
|
| 691 |
+
"steps": steps,
|
| 692 |
+
"nodes_quarantined": len(quarantined),
|
| 693 |
+
"f1_details": f1_details,
|
| 694 |
+
})
|
| 695 |
+
|
| 696 |
+
count = max(len(results), 1)
|
| 697 |
+
return {
|
| 698 |
+
"dataset_name": request.dataset_name,
|
| 699 |
+
"num_scenarios": len(results),
|
| 700 |
+
"average_f1": round(total_f1 / count, 4),
|
| 701 |
+
"average_reward": round(total_reward / count, 4),
|
| 702 |
+
"results": results,
|
| 703 |
+
}
|
| 704 |
+
except Exception as exc:
|
| 705 |
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
# ---------------------------------------------------------------------------
|
| 709 |
+
# HuggingFace Hub Integration Status
|
| 710 |
+
# ---------------------------------------------------------------------------
|
| 711 |
+
|
| 712 |
+
@app.get("/api/hub/status")
|
| 713 |
+
def hub_status() -> dict:
|
| 714 |
+
"""Report HuggingFace Hub integration and cache warmth status."""
|
| 715 |
+
hub_model = os.environ.get("LLM_HUB_MODEL", "")
|
| 716 |
+
base_model = os.environ.get("LLM_BASE_MODEL", "")
|
| 717 |
+
hf_transfer = os.environ.get("HF_HUB_ENABLE_HF_TRANSFER", "0") == "1"
|
| 718 |
+
prefetch = os.environ.get("ENABLE_HF_MODEL_PREFETCH", "0") == "1"
|
| 719 |
+
|
| 720 |
+
# Check if models are cached
|
| 721 |
+
hf_home = os.environ.get("HF_HOME", "")
|
| 722 |
+
cache_exists = os.path.isdir(hf_home) if hf_home else False
|
| 723 |
+
|
| 724 |
+
return {
|
| 725 |
+
"hub_model": hub_model,
|
| 726 |
+
"base_model": base_model,
|
| 727 |
+
"hf_transfer_enabled": hf_transfer,
|
| 728 |
+
"prefetch_enabled": prefetch,
|
| 729 |
+
"cache_dir": hf_home,
|
| 730 |
+
"cache_warm": cache_exists,
|
| 731 |
+
"status": "linked" if hub_model else "not_configured",
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
|
| 735 |
def main() -> None:
|
| 736 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
| 737 |
|
| 738 |
|
| 739 |
if __name__ == "__main__":
|
| 740 |
main()
|
|
|
server/static/app.js
CHANGED
|
@@ -1,222 +1,1078 @@
|
|
| 1 |
-
|
| 2 |
-
const taskSummary = document.getElementById("task-summary");
|
| 3 |
-
const currentScore = document.getElementById("current-score");
|
| 4 |
-
const currentSteps = document.getElementById("current-steps");
|
| 5 |
-
const currentStatus = document.getElementById("current-status");
|
| 6 |
-
const allScore = document.getElementById("all-score");
|
| 7 |
-
const allResults = document.getElementById("all-results");
|
| 8 |
-
const episodeLog = document.getElementById("episode-log");
|
| 9 |
-
const rewardChart = document.getElementById("reward-chart");
|
| 10 |
-
const finalSummary = document.getElementById("final-summary");
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
let taskCatalog = [];
|
| 13 |
|
| 14 |
function renderTaskSummary(task) {
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
<p>${task.objective}</p>
|
| 19 |
-
<p><strong>Max steps:</strong> ${task.max_steps}</p>
|
| 20 |
-
`;
|
| 21 |
}
|
| 22 |
|
| 23 |
-
function
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
const padding = 28;
|
| 32 |
-
const values = logs.map((entry) => entry.reward);
|
| 33 |
-
const maxReward = Math.max(...values, 1);
|
| 34 |
-
const minReward = Math.min(...values, 0);
|
| 35 |
-
const range = Math.max(maxReward - minReward, 0.25);
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
}
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
.
|
| 50 |
-
.
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
const
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
.map((entry, index) => {
|
| 68 |
-
const x = toX(index);
|
| 69 |
-
const y = toY(entry.reward);
|
| 70 |
-
return `
|
| 71 |
-
<circle class="chart-point" cx="${x}" cy="${y}" r="5"></circle>
|
| 72 |
-
<text class="chart-label" x="${x}" y="${y - 10}" text-anchor="middle">${entry.reward.toFixed(2)}</text>
|
| 73 |
-
`;
|
| 74 |
-
})
|
| 75 |
-
.join("");
|
| 76 |
-
|
| 77 |
-
rewardChart.innerHTML = `
|
| 78 |
-
<svg viewBox="0 0 ${width} ${height}" aria-label="Reward line chart">
|
| 79 |
-
${horizontalGuides}
|
| 80 |
-
<line class="chart-axis" x1="${padding}" y1="${height - padding}" x2="${width - padding}" y2="${height - padding}"></line>
|
| 81 |
-
<line class="chart-axis" x1="${padding}" y1="${padding}" x2="${padding}" y2="${height - padding}"></line>
|
| 82 |
-
<polyline class="chart-line" points="${linePoints}"></polyline>
|
| 83 |
-
${points}
|
| 84 |
-
${labels}
|
| 85 |
-
</svg>
|
| 86 |
-
`;
|
| 87 |
-
}
|
| 88 |
-
|
| 89 |
-
function renderEpisode(data) {
|
| 90 |
-
currentScore.textContent = data.score.toFixed(4);
|
| 91 |
-
currentSteps.textContent = String(data.steps_taken);
|
| 92 |
-
currentStatus.textContent = data.success ? "Contained" : "Needs work";
|
| 93 |
-
|
| 94 |
-
buildLineChart(data.logs);
|
| 95 |
-
|
| 96 |
-
finalSummary.innerHTML = `
|
| 97 |
-
<div class="summary-grid">
|
| 98 |
-
<div class="summary-pill">
|
| 99 |
-
<span>Final score</span>
|
| 100 |
-
<strong>${data.score.toFixed(4)}</strong>
|
| 101 |
-
</div>
|
| 102 |
-
<div class="summary-pill">
|
| 103 |
-
<span>Status</span>
|
| 104 |
-
<strong>${data.success ? "Success" : "Needs improvement"}</strong>
|
| 105 |
-
</div>
|
| 106 |
-
<div class="summary-pill">
|
| 107 |
-
<span>Steps used</span>
|
| 108 |
-
<strong>${data.steps_taken}</strong>
|
| 109 |
-
</div>
|
| 110 |
-
<div class="summary-pill">
|
| 111 |
-
<span>Quarantine quality</span>
|
| 112 |
-
<strong>${(data.final_info.quarantine_score ?? 0).toFixed(4)}</strong>
|
| 113 |
-
</div>
|
| 114 |
-
</div>
|
| 115 |
-
<div class="summary-card">
|
| 116 |
-
<strong>Containment outcome</strong>
|
| 117 |
-
<div>All affected nodes notified: ${data.final_info.all_affected_nodes_notified ? "Yes" : "No"}</div>
|
| 118 |
-
<div>All affected stock quarantined: ${data.final_info.all_affected_stock_quarantined ? "Yes" : "No"}</div>
|
| 119 |
-
</div>
|
| 120 |
-
<div class="summary-card">
|
| 121 |
-
<strong>Grader focus</strong>
|
| 122 |
-
<div>Notification score: ${(data.final_info.notification_score ?? 0).toFixed(4)}</div>
|
| 123 |
-
<div>Investigation score: ${(data.final_info.investigation_score ?? 0).toFixed(4)}</div>
|
| 124 |
-
<div>Efficiency score: ${(data.final_info.efficiency_score ?? 0).toFixed(4)}</div>
|
| 125 |
-
</div>
|
| 126 |
-
`;
|
| 127 |
-
|
| 128 |
-
const logMarkup = data.logs.map((entry) => {
|
| 129 |
-
const actionType = entry.action.type || "action";
|
| 130 |
-
const detailBits = [];
|
| 131 |
-
if (entry.action.node_id) detailBits.push(`Node: ${entry.action.node_id}`);
|
| 132 |
-
if (entry.action.lot_id) detailBits.push(`Lot: ${entry.action.lot_id}`);
|
| 133 |
-
if (entry.action.quantity) detailBits.push(`Qty: ${entry.action.quantity}`);
|
| 134 |
-
|
| 135 |
-
return `
|
| 136 |
-
<div class="log-step">
|
| 137 |
<div class="log-title">
|
| 138 |
-
<strong>Step ${
|
| 139 |
-
<span class="action-chip">${actionType
|
|
|
|
| 140 |
</div>
|
| 141 |
<div class="action-meta">
|
| 142 |
-
<div>${
|
| 143 |
-
<div>Reward: ${
|
| 144 |
-
<div>Message: ${entry.message || "-"}</div>
|
| 145 |
</div>
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
<strong>Task:</strong> ${data.task.name}
|
| 153 |
-
</div>
|
| 154 |
-
${logMarkup}
|
| 155 |
-
`;
|
| 156 |
-
}
|
| 157 |
-
|
| 158 |
-
function renderRunAll(data) {
|
| 159 |
-
allScore.textContent = data.average_score.toFixed(4);
|
| 160 |
-
allResults.innerHTML = data.episodes.map((episode) => `
|
| 161 |
-
<div class="log-step">
|
| 162 |
-
<strong>${episode.task.name}</strong>
|
| 163 |
-
<div>Difficulty: ${episode.task.difficulty}</div>
|
| 164 |
-
<div>Score: ${episode.score.toFixed(4)}</div>
|
| 165 |
-
<div>Steps: ${episode.steps_taken}</div>
|
| 166 |
-
<div>Status: ${episode.success ? "Success" : "Needs work"}</div>
|
| 167 |
-
</div>
|
| 168 |
-
`).join("");
|
| 169 |
-
}
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
|
|
|
|
|
|
| 179 |
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
}
|
| 182 |
|
| 183 |
-
|
| 184 |
-
const
|
| 185 |
-
const
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
});
|
| 201 |
-
const data = await response.json();
|
| 202 |
-
renderEpisode(data);
|
| 203 |
}
|
| 204 |
|
| 205 |
-
async function
|
| 206 |
-
const
|
| 207 |
-
const
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
}
|
| 210 |
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
}
|
| 216 |
});
|
| 217 |
|
| 218 |
-
|
| 219 |
-
document.getElementById(
|
| 220 |
-
document.getElementById(
|
| 221 |
-
|
| 222 |
fetchTasks();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ===== RecallTrace Frontend β app.js ===== */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
// ---------------------------------------------------------------------------
|
| 4 |
+
// Particle Background
|
| 5 |
+
// ---------------------------------------------------------------------------
|
| 6 |
+
(function initParticles() {
|
| 7 |
+
const canvas = document.getElementById('particles-canvas');
|
| 8 |
+
if (!canvas) return;
|
| 9 |
+
const ctx = canvas.getContext('2d');
|
| 10 |
+
let particles = [];
|
| 11 |
+
function resize() { canvas.width = window.innerWidth; canvas.height = window.innerHeight; }
|
| 12 |
+
resize(); window.addEventListener('resize', resize);
|
| 13 |
+
for (let i = 0; i < 60; i++) {
|
| 14 |
+
particles.push({ x: Math.random()*canvas.width, y: Math.random()*canvas.height,
|
| 15 |
+
r: Math.random()*1.5+0.5, dx: (Math.random()-0.5)*0.3, dy: (Math.random()-0.5)*0.3,
|
| 16 |
+
o: Math.random()*0.4+0.1 });
|
| 17 |
+
}
|
| 18 |
+
function draw() {
|
| 19 |
+
ctx.clearRect(0,0,canvas.width,canvas.height);
|
| 20 |
+
particles.forEach(p => {
|
| 21 |
+
ctx.beginPath(); ctx.arc(p.x,p.y,p.r,0,Math.PI*2);
|
| 22 |
+
ctx.fillStyle = `rgba(255,111,60,${p.o})`; ctx.fill();
|
| 23 |
+
p.x += p.dx; p.y += p.dy;
|
| 24 |
+
if (p.x<0||p.x>canvas.width) p.dx*=-1;
|
| 25 |
+
if (p.y<0||p.y>canvas.height) p.dy*=-1;
|
| 26 |
+
});
|
| 27 |
+
requestAnimationFrame(draw);
|
| 28 |
+
}
|
| 29 |
+
draw();
|
| 30 |
+
})();
|
| 31 |
+
|
| 32 |
+
// ---------------------------------------------------------------------------
|
| 33 |
+
// Tab Navigation
|
| 34 |
+
// ---------------------------------------------------------------------------
|
| 35 |
+
function switchTab(tab) {
|
| 36 |
+
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab===tab));
|
| 37 |
+
document.querySelectorAll('.tab-content').forEach(s => s.classList.toggle('active', s.id==='tab-'+tab));
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// ---------------------------------------------------------------------------
|
| 41 |
+
// Slider values
|
| 42 |
+
// ---------------------------------------------------------------------------
|
| 43 |
+
const epSlider = document.getElementById('episode-slider');
|
| 44 |
+
const epVal = document.getElementById('episode-value');
|
| 45 |
+
const nodesSlider = document.getElementById('nodes-slider');
|
| 46 |
+
const nodesVal = document.getElementById('nodes-value');
|
| 47 |
+
if (epSlider) epSlider.oninput = () => epVal.textContent = epSlider.value;
|
| 48 |
+
if (nodesSlider) nodesSlider.oninput = () => nodesVal.textContent = nodesSlider.value;
|
| 49 |
+
|
| 50 |
+
// ---------------------------------------------------------------------------
|
| 51 |
+
// Graph Visualization
|
| 52 |
+
// ---------------------------------------------------------------------------
|
| 53 |
+
let graphData = null;
|
| 54 |
+
|
| 55 |
+
function drawGraph(nodes, edges, highlights) {
|
| 56 |
+
highlights = highlights || {};
|
| 57 |
+
const edgesG = document.getElementById('graph-edges');
|
| 58 |
+
const nodesG = document.getElementById('graph-nodes');
|
| 59 |
+
const labelsG = document.getElementById('graph-labels');
|
| 60 |
+
const overlaysG = document.getElementById('graph-overlays');
|
| 61 |
+
edgesG.innerHTML = ''; nodesG.innerHTML = ''; labelsG.innerHTML = ''; overlaysG.innerHTML = '';
|
| 62 |
+
|
| 63 |
+
const W = 800, H = 480, PAD = 60;
|
| 64 |
+
|
| 65 |
+
// Draw edges
|
| 66 |
+
edges.forEach(e => {
|
| 67 |
+
const from = nodes.find(n=>n.id===e.from);
|
| 68 |
+
const to = nodes.find(n=>n.id===e.to);
|
| 69 |
+
if (!from||!to) return;
|
| 70 |
+
const x1=PAD+from.x*(W-2*PAD), y1=PAD+from.y*(H-2*PAD);
|
| 71 |
+
const x2=PAD+to.x*(W-2*PAD), y2=PAD+to.y*(H-2*PAD);
|
| 72 |
+
const isActive = highlights.pathEdges && highlights.pathEdges.some(pe=>pe[0]===e.from&&pe[1]===e.to);
|
| 73 |
+
const line = document.createElementNS('http://www.w3.org/2000/svg','line');
|
| 74 |
+
line.setAttribute('x1',x1); line.setAttribute('y1',y1);
|
| 75 |
+
line.setAttribute('x2',x2); line.setAttribute('y2',y2);
|
| 76 |
+
line.setAttribute('stroke', isActive?'#58a6ff':'rgba(255,255,255,0.12)');
|
| 77 |
+
line.setAttribute('stroke-width', isActive?'2.5':'1');
|
| 78 |
+
line.setAttribute('marker-end', isActive?'url(#arrowhead-active)':'url(#arrowhead)');
|
| 79 |
+
if(isActive) line.setAttribute('filter','url(#glow)');
|
| 80 |
+
edgesG.appendChild(line);
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
// Draw nodes
|
| 84 |
+
nodes.forEach(n => {
|
| 85 |
+
const cx=PAD+n.x*(W-2*PAD), cy=PAD+n.y*(H-2*PAD), r=22;
|
| 86 |
+
const visited = highlights.visited && highlights.visited.includes(n.id);
|
| 87 |
+
const quarantined = highlights.quarantined && highlights.quarantined.includes(n.id);
|
| 88 |
+
const safe = highlights.safe && highlights.safe.includes(n.id);
|
| 89 |
+
const isContam = n.contaminated;
|
| 90 |
+
|
| 91 |
+
// Contamination ring
|
| 92 |
+
if (isContam && highlights.showContam) {
|
| 93 |
+
const ring = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
| 94 |
+
ring.setAttribute('cx',cx); ring.setAttribute('cy',cy); ring.setAttribute('r',r+6);
|
| 95 |
+
ring.setAttribute('fill','none'); ring.setAttribute('stroke','#d29922');
|
| 96 |
+
ring.setAttribute('stroke-width','2'); ring.setAttribute('stroke-dasharray','5 3');
|
| 97 |
+
ring.setAttribute('opacity','0.7');
|
| 98 |
+
nodesG.appendChild(ring);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
// Node circle
|
| 102 |
+
const circle = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
| 103 |
+
circle.setAttribute('cx',cx); circle.setAttribute('cy',cy); circle.setAttribute('r',r);
|
| 104 |
+
let fill='#21262d', stroke='#444c56', sw='1.5';
|
| 105 |
+
if (quarantined) { fill='#da3633'; stroke='#ff6b6b'; sw='3'; }
|
| 106 |
+
else if (safe) { fill='#1a3a2a'; stroke='#2ea043'; sw='2.5'; }
|
| 107 |
+
else if (visited) { fill='#2d2a1a'; stroke='#f0c040'; sw='2.5'; }
|
| 108 |
+
circle.setAttribute('fill',fill); circle.setAttribute('stroke',stroke); circle.setAttribute('stroke-width',sw);
|
| 109 |
+
if(quarantined) circle.setAttribute('filter','url(#glow)');
|
| 110 |
+
nodesG.appendChild(circle);
|
| 111 |
+
|
| 112 |
+
// Quarantine X
|
| 113 |
+
if (quarantined) {
|
| 114 |
+
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
| 115 |
+
txt.setAttribute('x',cx); txt.setAttribute('y',cy+5);
|
| 116 |
+
txt.setAttribute('text-anchor','middle'); txt.setAttribute('fill','white');
|
| 117 |
+
txt.setAttribute('font-size','16'); txt.setAttribute('font-weight','bold');
|
| 118 |
+
txt.textContent = 'β'; nodesG.appendChild(txt);
|
| 119 |
+
}
|
| 120 |
+
// Safe check
|
| 121 |
+
if (safe && !quarantined) {
|
| 122 |
+
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
| 123 |
+
txt.setAttribute('x',cx); txt.setAttribute('y',cy+5);
|
| 124 |
+
txt.setAttribute('text-anchor','middle'); txt.setAttribute('fill','#2ea043');
|
| 125 |
+
txt.setAttribute('font-size','15'); txt.setAttribute('font-weight','bold');
|
| 126 |
+
txt.textContent = 'β'; nodesG.appendChild(txt);
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
// Label
|
| 130 |
+
const label = document.createElementNS('http://www.w3.org/2000/svg','text');
|
| 131 |
+
label.setAttribute('x',cx); label.setAttribute('y',cy+r+16);
|
| 132 |
+
label.setAttribute('text-anchor','middle'); label.setAttribute('fill','#e8edf5');
|
| 133 |
+
label.setAttribute('font-size','10'); label.setAttribute('font-weight','600');
|
| 134 |
+
label.setAttribute('font-family','Inter, sans-serif');
|
| 135 |
+
label.textContent = n.label; labelsG.appendChild(label);
|
| 136 |
+
|
| 137 |
+
// Belief probability
|
| 138 |
+
if (highlights.beliefs && highlights.beliefs[n.id] !== undefined) {
|
| 139 |
+
const p = highlights.beliefs[n.id];
|
| 140 |
+
const bColor = p>=0.75?'#7ee787': p>=0.5?'#fbbf24':'#8b949e';
|
| 141 |
+
const bg = document.createElementNS('http://www.w3.org/2000/svg','rect');
|
| 142 |
+
bg.setAttribute('x',cx+r+4); bg.setAttribute('y',cy-10);
|
| 143 |
+
bg.setAttribute('width','46'); bg.setAttribute('height','18');
|
| 144 |
+
bg.setAttribute('rx','6'); bg.setAttribute('fill','rgba(13,17,23,0.85)');
|
| 145 |
+
bg.setAttribute('stroke',bColor); bg.setAttribute('stroke-width','1');
|
| 146 |
+
overlaysG.appendChild(bg);
|
| 147 |
+
const bTxt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
| 148 |
+
bTxt.setAttribute('x',cx+r+27); bTxt.setAttribute('y',cy+2);
|
| 149 |
+
bTxt.setAttribute('text-anchor','middle'); bTxt.setAttribute('fill',bColor);
|
| 150 |
+
bTxt.setAttribute('font-size','9'); bTxt.setAttribute('font-weight','700');
|
| 151 |
+
bTxt.setAttribute('font-family','JetBrains Mono, monospace');
|
| 152 |
+
bTxt.textContent = 'P='+p.toFixed(2); overlaysG.appendChild(bTxt);
|
| 153 |
+
}
|
| 154 |
+
});
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
async function loadGraph() {
|
| 158 |
+
try {
|
| 159 |
+
const nodesSlider = document.getElementById('nodes-slider');
|
| 160 |
+
let numNodes = 10;
|
| 161 |
+
if (nodesSlider) {
|
| 162 |
+
numNodes = parseInt(nodesSlider.value) || 10;
|
| 163 |
+
}
|
| 164 |
+
// Sync backend state with the slider before drawing
|
| 165 |
+
await fetch('/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ num_nodes: numNodes }) });
|
| 166 |
+
|
| 167 |
+
const res = await fetch('/api/graph/structure');
|
| 168 |
+
graphData = await res.json();
|
| 169 |
+
drawGraph(graphData.nodes, graphData.edges, {});
|
| 170 |
+
} catch(e) { console.warn('Graph load failed', e); }
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
// ---------------------------------------------------------------------------
|
| 174 |
+
// Belief State Panel
|
| 175 |
+
// ---------------------------------------------------------------------------
|
| 176 |
+
function updateBeliefBars(beliefs, step) {
|
| 177 |
+
const container = document.getElementById('belief-bars');
|
| 178 |
+
const badge = document.getElementById('belief-step');
|
| 179 |
+
if (badge) badge.textContent = 'Step ' + (step||0);
|
| 180 |
+
if (!beliefs || Object.keys(beliefs).length===0) {
|
| 181 |
+
container.innerHTML = '<div class="belief-empty">Run simulation to see belief state</div>';
|
| 182 |
+
return;
|
| 183 |
+
}
|
| 184 |
+
const sorted = Object.entries(beliefs).sort((a,b)=>b[1]-a[1]);
|
| 185 |
+
container.innerHTML = sorted.map(([name, p]) => {
|
| 186 |
+
const pct = (p*100).toFixed(0);
|
| 187 |
+
const color = p>=0.85?'#da3633': p>=0.5?'#f0c040': p>=0.3?'#fbbf24':'rgba(255,255,255,0.15)';
|
| 188 |
+
const txtColor = p>=0.85?'#ff6b6b': p>=0.5?'#fbbf24':'#8b949e';
|
| 189 |
+
return `<div class="belief-row">
|
| 190 |
+
<span class="belief-name">${name.replace(/_/g,' ')}</span>
|
| 191 |
+
<div class="belief-bar-track"><div class="belief-bar-fill" style="width:${pct}%;background:${color}"></div></div>
|
| 192 |
+
<span class="belief-prob" style="color:${txtColor}">${p.toFixed(2)}</span>
|
| 193 |
+
</div>`;
|
| 194 |
+
}).join('');
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// ---------------------------------------------------------------------------
|
| 198 |
+
// Self-Play Training
|
| 199 |
+
// ---------------------------------------------------------------------------
|
| 200 |
+
let trainingData = null;
|
| 201 |
+
|
| 202 |
+
async function runSelfPlay() {
|
| 203 |
+
const btn = document.getElementById('btn-train');
|
| 204 |
+
const prog = document.getElementById('progress-container');
|
| 205 |
+
const fill = document.getElementById('progress-fill');
|
| 206 |
+
const pText = document.getElementById('progress-text');
|
| 207 |
+
btn.disabled = true;
|
| 208 |
+
prog.classList.remove('hidden');
|
| 209 |
+
fill.style.width = '10%';
|
| 210 |
+
pText.textContent = 'Starting training...';
|
| 211 |
+
|
| 212 |
+
const numEp = parseInt(epSlider.value);
|
| 213 |
+
const numNodes = parseInt(nodesSlider.value);
|
| 214 |
+
|
| 215 |
+
try {
|
| 216 |
+
fill.style.width = '30%'; pText.textContent = `Training ${numEp} episodes...`;
|
| 217 |
+
const res = await fetch('/api/selfplay/run', {
|
| 218 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 219 |
+
body: JSON.stringify({num_episodes:numEp, num_nodes:numNodes})
|
| 220 |
+
});
|
| 221 |
+
fill.style.width = '80%'; pText.textContent = 'Processing results...';
|
| 222 |
+
const data = await res.json();
|
| 223 |
+
trainingData = data;
|
| 224 |
+
if (data.graph) {
|
| 225 |
+
graphData = data.graph;
|
| 226 |
+
}
|
| 227 |
+
fill.style.width = '100%'; pText.textContent = 'Done!';
|
| 228 |
+
document.getElementById('sim-status-badge').textContent = 'Trained β';
|
| 229 |
+
|
| 230 |
+
// Update charts
|
| 231 |
+
renderTrainingCharts(data.episodes);
|
| 232 |
+
renderTrainingSummary(data.summary);
|
| 233 |
+
|
| 234 |
+
// Show last episode on graph
|
| 235 |
+
const last = data.episodes[data.episodes.length-1];
|
| 236 |
+
updateEpisodeDisplay(last);
|
| 237 |
+
|
| 238 |
+
// Auto-show comparison
|
| 239 |
+
showComparison(data.episodes);
|
| 240 |
+
|
| 241 |
+
setTimeout(()=>{ prog.classList.add('hidden'); btn.disabled=false; }, 1500);
|
| 242 |
+
} catch(e) {
|
| 243 |
+
pText.textContent = 'Error: '+e.message;
|
| 244 |
+
btn.disabled = false;
|
| 245 |
+
}
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
function updateEpisodeDisplay(ep) {
|
| 249 |
+
document.getElementById('ep-f1').textContent = ep.investigator_f1.toFixed(3);
|
| 250 |
+
document.getElementById('ep-f1').style.color = ep.investigator_f1>0.7?'#2ea043':'#da3633';
|
| 251 |
+
document.getElementById('ep-quarantined').textContent = ep.num_quarantined;
|
| 252 |
+
document.getElementById('ep-steps').textContent = ep.steps_taken;
|
| 253 |
+
document.getElementById('ep-intervention').textContent = (ep.intervention_type||'β').replace(/_/g,' ');
|
| 254 |
+
|
| 255 |
+
// Update belief bars with simulated beliefs
|
| 256 |
+
const beliefs = {};
|
| 257 |
+
if (ep.nodes_quarantined_list) {
|
| 258 |
+
ep.nodes_quarantined_list.forEach(n => beliefs[n] = 0.85+Math.random()*0.1);
|
| 259 |
+
}
|
| 260 |
+
if (ep.nodes_visited) {
|
| 261 |
+
ep.nodes_visited.forEach(n => { if(!beliefs[n]) beliefs[n]=0.2+Math.random()*0.4; });
|
| 262 |
+
}
|
| 263 |
+
updateBeliefBars(beliefs, ep.steps_taken);
|
| 264 |
+
|
| 265 |
+
// Update graph if available
|
| 266 |
+
if (graphData) {
|
| 267 |
+
const safe = graphData.nodes.filter(n=>!n.contaminated).map(n=>n.id)
|
| 268 |
+
.filter(n=>!ep.nodes_quarantined_list.includes(n));
|
| 269 |
+
drawGraph(graphData.nodes, graphData.edges, {
|
| 270 |
+
visited: ep.nodes_visited||[],
|
| 271 |
+
quarantined: ep.nodes_quarantined_list||[],
|
| 272 |
+
safe: safe.slice(0,3),
|
| 273 |
+
showContam: true, beliefs: beliefs,
|
| 274 |
+
});
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
function showComparison(episodes) {
|
| 279 |
+
const panel = document.getElementById('comparison-panel');
|
| 280 |
+
panel.classList.remove('hidden');
|
| 281 |
+
const early = episodes.slice(0,30);
|
| 282 |
+
const late = episodes.slice(-30);
|
| 283 |
+
const worst = early.reduce((a,b)=>a.investigator_f1<b.investigator_f1?a:b);
|
| 284 |
+
const best = late.reduce((a,b)=>a.investigator_f1>b.investigator_f1?a:b);
|
| 285 |
+
|
| 286 |
+
document.getElementById('comp-early-ep').textContent = worst.episode;
|
| 287 |
+
document.getElementById('comp-early-f1').textContent = 'F1 = '+worst.investigator_f1.toFixed(3);
|
| 288 |
+
document.getElementById('comp-early-stats').innerHTML =
|
| 289 |
+
`Quarantined: ${worst.num_quarantined} nodes<br>Steps: ${worst.steps_taken}<br>` +
|
| 290 |
+
`Threshold: ${worst.quarantine_threshold.toFixed(3)}<br>Exploration: ${worst.exploration_rate.toFixed(3)}<br>` +
|
| 291 |
+
`Intervention: ${(worst.intervention_type||'β').replace(/_/g,' ')}`;
|
| 292 |
+
|
| 293 |
+
document.getElementById('comp-late-ep').textContent = best.episode;
|
| 294 |
+
document.getElementById('comp-late-f1').textContent = 'F1 = '+best.investigator_f1.toFixed(3);
|
| 295 |
+
document.getElementById('comp-late-stats').innerHTML =
|
| 296 |
+
`Quarantined: ${best.num_quarantined} nodes<br>Steps: ${best.steps_taken}<br>` +
|
| 297 |
+
`Threshold: ${best.quarantine_threshold.toFixed(3)}<br>Exploration: ${best.exploration_rate.toFixed(3)}<br>` +
|
| 298 |
+
`Intervention: ${(best.intervention_type||'β').replace(/_/g,' ')}<br>` +
|
| 299 |
+
`Identified: ${best.intervention_correctly_identified?'YES β':'NO'}`;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
async function runReplay() {
|
| 303 |
+
const btn = document.getElementById('btn-replay');
|
| 304 |
+
btn.disabled = true;
|
| 305 |
+
const numNodes = parseInt(document.getElementById('nodes-slider').value) || 10;
|
| 306 |
+
try {
|
| 307 |
+
const res = await fetch(`/api/selfplay/demo?num_nodes=${numNodes}`);
|
| 308 |
+
const data = await res.json();
|
| 309 |
+
trainingData = {episodes: data.all_stats, summary:{}};
|
| 310 |
+
graphData = data.graph;
|
| 311 |
+
renderTrainingCharts(data.all_stats);
|
| 312 |
+
showComparison(data.all_stats);
|
| 313 |
+
const last = data.all_stats[data.all_stats.length-1];
|
| 314 |
+
updateEpisodeDisplay(last);
|
| 315 |
+
document.getElementById('sim-status-badge').textContent = 'Demo Loaded';
|
| 316 |
+
} catch(e) { console.error(e); }
|
| 317 |
+
btn.disabled = false;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
// ---------------------------------------------------------------------------
|
| 321 |
+
// SVG Chart Rendering
|
| 322 |
+
// ---------------------------------------------------------------------------
|
| 323 |
+
function renderTrainingCharts(episodes) {
|
| 324 |
+
switchTab('training');
|
| 325 |
+
renderChart('chart-f1', episodes, 'investigator_f1', '#60a5fa', '#3b82f6', 0, 1.05);
|
| 326 |
+
renderChart('chart-adv', episodes, 'adversary_reward', '#f87171', '#ef4444', -1.3, 1.3);
|
| 327 |
+
renderChart('chart-quarantined', episodes, 'num_quarantined', '#4ade80', '#22c55e');
|
| 328 |
+
renderChart('chart-steps', episodes, 'steps_taken', '#fbbf24', '#f59e0b');
|
| 329 |
+
|
| 330 |
+
const late = episodes.slice(-20);
|
| 331 |
+
const el = (id,v) => { const e=document.getElementById(id); if(e) e.textContent=v; };
|
| 332 |
+
el('chart-f1-badge', (late.reduce((s,e)=>s+e.investigator_f1,0)/late.length).toFixed(3));
|
| 333 |
+
el('chart-adv-badge', (late.reduce((s,e)=>s+e.adversary_reward,0)/late.length).toFixed(3));
|
| 334 |
+
el('chart-q-badge', (late.reduce((s,e)=>s+e.num_quarantined,0)/late.length).toFixed(1));
|
| 335 |
+
el('chart-s-badge', (late.reduce((s,e)=>s+e.steps_taken,0)/late.length).toFixed(1));
|
| 336 |
+
|
| 337 |
+
switchTab('simulation');
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
function renderChart(containerId, episodes, key, lineColor, dotColor, yMin, yMax) {
|
| 341 |
+
const container = document.getElementById(containerId);
|
| 342 |
+
if (!container) return;
|
| 343 |
+
const values = episodes.map(e=>e[key]);
|
| 344 |
+
if (yMin===undefined) yMin = Math.min(...values)*0.9;
|
| 345 |
+
if (yMax===undefined) yMax = Math.max(...values)*1.1;
|
| 346 |
+
const range = Math.max(yMax-yMin, 0.1);
|
| 347 |
+
|
| 348 |
+
const W=500, H=240, P=40, PR=20, PT=20, PB=30;
|
| 349 |
+
const plotW=W-P-PR, plotH=H-PT-PB;
|
| 350 |
+
const toX = i => P + (i/(episodes.length-1))*plotW;
|
| 351 |
+
const toY = v => PT + (1-(v-yMin)/range)*plotH;
|
| 352 |
+
|
| 353 |
+
// Rolling average
|
| 354 |
+
const rolling = []; const win=20;
|
| 355 |
+
for(let i=0;i<values.length;i++){
|
| 356 |
+
const start=Math.max(0,i-win+1);
|
| 357 |
+
rolling.push(values.slice(start,i+1).reduce((a,b)=>a+b,0)/(i-start+1));
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
// Build SVG
|
| 361 |
+
const rawPts = values.map((v,i)=>`${toX(i)},${toY(v)}`);
|
| 362 |
+
const avgPts = rolling.map((v,i)=>`${toX(i)},${toY(v)}`);
|
| 363 |
+
|
| 364 |
+
// Grid lines
|
| 365 |
+
let gridLines = '';
|
| 366 |
+
for(let i=0;i<=4;i++){
|
| 367 |
+
const y=PT+i*(plotH/4);
|
| 368 |
+
const val=(yMax-i*(range/4)).toFixed(2);
|
| 369 |
+
gridLines+=`<line x1="${P}" y1="${y}" x2="${W-PR}" y2="${y}" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>`;
|
| 370 |
+
gridLines+=`<text x="${P-6}" y="${y+4}" text-anchor="end" fill="#8b949e" font-size="9" font-family="JetBrains Mono">${val}</text>`;
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
// Axis labels
|
| 374 |
+
const numLabels = Math.min(5, episodes.length);
|
| 375 |
+
let axisLabels = '';
|
| 376 |
+
for(let i=0;i<numLabels;i++){
|
| 377 |
+
const idx=Math.floor(i*(episodes.length-1)/(numLabels-1));
|
| 378 |
+
axisLabels+=`<text x="${toX(idx)}" y="${H-6}" text-anchor="middle" fill="#8b949e" font-size="9" font-family="JetBrains Mono">${episodes[idx].episode}</text>`;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
container.innerHTML = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
|
| 382 |
+
${gridLines}
|
| 383 |
+
<line x1="${P}" y1="${PT}" x2="${P}" y2="${H-PB}" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
|
| 384 |
+
<line x1="${P}" y1="${H-PB}" x2="${W-PR}" y2="${H-PB}" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
|
| 385 |
+
<polyline points="${rawPts.join(' ')}" fill="none" stroke="${dotColor}" stroke-width="1" opacity="0.2"/>
|
| 386 |
+
<polyline points="${avgPts.join(' ')}" fill="none" stroke="${lineColor}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" filter="url(#glow)"/>
|
| 387 |
+
${axisLabels}
|
| 388 |
+
</svg>`;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
function renderTrainingSummary(summary) {
|
| 392 |
+
const panel = document.getElementById('training-summary');
|
| 393 |
+
const content = document.getElementById('training-summary-content');
|
| 394 |
+
if (!panel||!content||!summary) return;
|
| 395 |
+
panel.classList.remove('hidden');
|
| 396 |
+
content.innerHTML = [
|
| 397 |
+
['Early F1', summary.early_f1?.toFixed(3)||'β'],
|
| 398 |
+
['Late F1', summary.late_f1?.toFixed(3)||'β'],
|
| 399 |
+
['Early Quarantined', summary.early_quarantined||'β'],
|
| 400 |
+
['Late Quarantined', summary.late_quarantined||'β'],
|
| 401 |
+
['Early Steps', summary.early_steps||'β'],
|
| 402 |
+
['Late Steps', summary.late_steps||'β'],
|
| 403 |
+
].map(([l,v])=>`<div class="summary-item"><span class="summary-item-label">${l}</span><span class="summary-item-value">${v}</span></div>`).join('');
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
// ---------------------------------------------------------------------------
|
| 407 |
+
// OpenEnv Runner (preserved from original)
|
| 408 |
+
// ---------------------------------------------------------------------------
|
| 409 |
+
const taskSelect = document.getElementById('task-select');
|
| 410 |
let taskCatalog = [];
|
| 411 |
|
| 412 |
function renderTaskSummary(task) {
|
| 413 |
+
const el = document.getElementById('task-summary');
|
| 414 |
+
if(!el) return;
|
| 415 |
+
el.innerHTML = `<h3>${task.name}</h3><p><strong>Difficulty:</strong> ${task.difficulty}</p><p>${task.objective}</p><p><strong>Max steps:</strong> ${task.max_steps}</p>`;
|
|
|
|
|
|
|
|
|
|
| 416 |
}
|
| 417 |
|
| 418 |
+
async function fetchTasks() {
|
| 419 |
+
try {
|
| 420 |
+
const res = await fetch('/api/tasks');
|
| 421 |
+
const data = await res.json();
|
| 422 |
+
taskCatalog = data.tasks;
|
| 423 |
+
if(taskSelect) {
|
| 424 |
+
taskSelect.innerHTML = taskCatalog.map(t=>`<option value="${t.task_id}">${t.difficulty.toUpperCase()} - ${t.name}</option>`).join('');
|
| 425 |
+
renderTaskSummary(taskCatalog[0]);
|
| 426 |
+
}
|
| 427 |
+
} catch(e) { console.warn('Tasks fetch failed', e); }
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
if(taskSelect) taskSelect.addEventListener('change', ()=>{
|
| 431 |
+
const task = taskCatalog.find(t=>t.task_id===taskSelect.value);
|
| 432 |
+
if(task) renderTaskSummary(task);
|
| 433 |
+
});
|
| 434 |
+
|
| 435 |
+
async function resetTask() {
|
| 436 |
+
const res = await fetch(`/reset?task_id=${encodeURIComponent(taskSelect.value)}`);
|
| 437 |
+
const data = await res.json();
|
| 438 |
+
document.getElementById('current-score').textContent = 'β';
|
| 439 |
+
document.getElementById('current-steps').textContent = data.steps_taken||0;
|
| 440 |
+
document.getElementById('current-status').textContent = 'Reset';
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
async function runOpenEnvEpisode() {
|
| 444 |
+
const res = await fetch('/api/run_episode', {
|
| 445 |
+
method:'POST', headers:{'Content-Type':'application/json'},
|
| 446 |
+
body: JSON.stringify({task_id: taskSelect.value})
|
| 447 |
+
});
|
| 448 |
+
const data = await res.json();
|
| 449 |
+
document.getElementById('current-score').textContent = data.score.toFixed(4);
|
| 450 |
+
document.getElementById('current-steps').textContent = data.steps_taken;
|
| 451 |
+
document.getElementById('current-status').textContent = data.success?'Contained':'Needs work';
|
| 452 |
+
|
| 453 |
+
// Reward chart
|
| 454 |
+
renderOERewardChart(data.logs);
|
| 455 |
+
renderOEFinalSummary(data);
|
| 456 |
+
renderOELog(data);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
async function runAllTasks() {
|
| 460 |
+
const res = await fetch('/api/run_all');
|
| 461 |
+
const data = await res.json();
|
| 462 |
+
document.getElementById('all-score').textContent = data.average_score.toFixed(4);
|
| 463 |
+
document.getElementById('all-results').innerHTML = data.episodes.map(ep=>
|
| 464 |
+
`<div class="log-step"><strong>${ep.task.name}</strong><div>Score: ${ep.score.toFixed(4)} | Steps: ${ep.steps_taken} | ${ep.success?'Success':'Needs work'}</div></div>`
|
| 465 |
+
).join('');
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
function renderOERewardChart(logs) {
|
| 469 |
+
const el = document.getElementById('oe-reward-chart');
|
| 470 |
+
if(!el||!logs.length) return;
|
| 471 |
+
const W=360, H=180, P=30;
|
| 472 |
+
const vals=logs.map(l=>l.reward);
|
| 473 |
+
const mx=Math.max(...vals,0.5), mn=Math.min(...vals,0);
|
| 474 |
+
const range=Math.max(mx-mn,0.1);
|
| 475 |
+
const toX=i=>P+(i/(logs.length-1||1))*(W-2*P);
|
| 476 |
+
const toY=v=>H-P-((v-mn)/range)*(H-2*P);
|
| 477 |
+
const pts=vals.map((v,i)=>`${toX(i)},${toY(v)}`).join(' ');
|
| 478 |
+
const dots=vals.map((v,i)=>`<circle cx="${toX(i)}" cy="${toY(v)}" r="3" fill="#ff6f3c" stroke="#fff" stroke-width="1.5"/>`).join('');
|
| 479 |
+
el.innerHTML=`<svg viewBox="0 0 ${W} ${H}"><polyline points="${pts}" fill="none" stroke="#38d39f" stroke-width="2.5" stroke-linecap="round"/>${dots}</svg>`;
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
function renderOEFinalSummary(data) {
|
| 483 |
+
const el=document.getElementById('oe-final-summary');
|
| 484 |
+
if(!el) return;
|
| 485 |
+
el.innerHTML=`<div class="stats-grid">
|
| 486 |
+
<div class="mini-stat"><span class="mini-stat-label">Score</span><span class="mini-stat-value">${data.score.toFixed(4)}</span></div>
|
| 487 |
+
<div class="mini-stat"><span class="mini-stat-label">Status</span><span class="mini-stat-value">${data.success?'Success':'Needs work'}</span></div>
|
| 488 |
+
<div class="mini-stat"><span class="mini-stat-label">Steps</span><span class="mini-stat-value">${data.steps_taken}</span></div>
|
| 489 |
+
<div class="mini-stat"><span class="mini-stat-label">Quarantine</span><span class="mini-stat-value">${(data.final_info.quarantine_score??0).toFixed(4)}</span></div>
|
| 490 |
+
</div>`;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
function renderOELog(data) {
|
| 494 |
+
const el=document.getElementById('oe-episode-log');
|
| 495 |
+
if(!el) return;
|
| 496 |
+
el.innerHTML = data.logs.map(entry=>{
|
| 497 |
+
const bits=[];
|
| 498 |
+
if(entry.action.node_id) bits.push('Node: '+entry.action.node_id);
|
| 499 |
+
if(entry.action.lot_id) bits.push('Lot: '+entry.action.lot_id);
|
| 500 |
+
if(entry.action.quantity) bits.push('Qty: '+entry.action.quantity);
|
| 501 |
+
return `<div class="log-step"><div class="log-title"><strong>Step ${entry.step}</strong><span class="action-chip">${(entry.action.type||'').replace('_',' ')}</span></div><div class="action-meta"><div>${bits.join(' | ')||'β'}</div><div>Reward: ${entry.reward.toFixed(4)}</div></div></div>`;
|
| 502 |
+
}).join('');
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
// ---------------------------------------------------------------------------
|
| 506 |
+
// LLM Agent Demo
|
| 507 |
+
// ---------------------------------------------------------------------------
|
| 508 |
+
|
| 509 |
+
async function checkLLMStatus() {
|
| 510 |
+
const badge = document.getElementById('llm-status-badge');
|
| 511 |
+
try {
|
| 512 |
+
const res = await fetch('/api/llm/status');
|
| 513 |
+
const data = await res.json();
|
| 514 |
+
if (data.gpu_available) {
|
| 515 |
+
badge.textContent = data.model_loaded ? 'β
Model Ready' : `β
GPU: ${data.gpu_name}`;
|
| 516 |
+
badge.style.background = 'rgba(46,160,67,0.2)';
|
| 517 |
+
badge.style.color = '#2ea043';
|
| 518 |
+
} else {
|
| 519 |
+
badge.textContent = 'οΏ½οΏ½ CPU Only';
|
| 520 |
+
badge.style.background = 'rgba(210,153,34,0.2)';
|
| 521 |
+
badge.style.color = '#d29922';
|
| 522 |
+
}
|
| 523 |
+
} catch(e) {
|
| 524 |
+
badge.textContent = 'β Offline';
|
| 525 |
+
badge.style.background = 'rgba(218,54,51,0.2)';
|
| 526 |
+
badge.style.color = '#da3633';
|
| 527 |
}
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
async function populateLLMTasks() {
|
| 531 |
+
try {
|
| 532 |
+
const res = await fetch('/api/tasks');
|
| 533 |
+
const data = await res.json();
|
| 534 |
+
const select = document.getElementById('llm-task-select');
|
| 535 |
+
if (select && data.tasks) {
|
| 536 |
+
data.tasks.forEach(t => {
|
| 537 |
+
const opt = document.createElement('option');
|
| 538 |
+
opt.value = t.task_id;
|
| 539 |
+
opt.textContent = `${t.difficulty.toUpperCase()} β ${t.name}`;
|
| 540 |
+
select.appendChild(opt);
|
| 541 |
+
});
|
| 542 |
+
}
|
| 543 |
+
} catch(e) { console.warn('LLM tasks fetch failed', e); }
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
async function runLLMEpisode() {
|
| 547 |
+
const btn = document.getElementById('btn-llm-run');
|
| 548 |
+
const prog = document.getElementById('llm-progress');
|
| 549 |
+
const fill = document.getElementById('llm-progress-fill');
|
| 550 |
+
const pText = document.getElementById('llm-progress-text');
|
| 551 |
+
const results = document.getElementById('llm-results');
|
| 552 |
+
|
| 553 |
+
btn.disabled = true;
|
| 554 |
+
prog.classList.remove('hidden');
|
| 555 |
+
results.classList.add('hidden');
|
| 556 |
+
fill.style.width = '15%';
|
| 557 |
+
pText.textContent = 'Loading model (first run may take ~30s)...';
|
| 558 |
+
|
| 559 |
+
const taskId = document.getElementById('llm-task-select').value;
|
| 560 |
+
const body = taskId ? {task_id: taskId} : {};
|
| 561 |
+
|
| 562 |
+
try {
|
| 563 |
+
fill.style.width = '40%';
|
| 564 |
+
pText.textContent = 'Running LLM agent on task...';
|
| 565 |
+
|
| 566 |
+
const res = await fetch('/api/llm/run_episode', {
|
| 567 |
+
method: 'POST',
|
| 568 |
+
headers: {'Content-Type': 'application/json'},
|
| 569 |
+
body: JSON.stringify(body),
|
| 570 |
+
});
|
| 571 |
|
| 572 |
+
fill.style.width = '90%';
|
| 573 |
+
pText.textContent = 'Rendering results...';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
|
| 575 |
+
if (!res.ok) {
|
| 576 |
+
const err = await res.json();
|
| 577 |
+
throw new Error(err.detail || 'Server error');
|
| 578 |
}
|
| 579 |
+
|
| 580 |
+
const data = await res.json();
|
| 581 |
+
fill.style.width = '100%';
|
| 582 |
+
pText.textContent = 'Done!';
|
| 583 |
+
|
| 584 |
+
// Populate score cards
|
| 585 |
+
document.getElementById('llm-score').textContent = data.score.toFixed(4);
|
| 586 |
+
document.getElementById('llm-score').style.color = data.score >= 0.9 ? '#2ea043' : data.score >= 0.5 ? '#f0c040' : '#da3633';
|
| 587 |
+
document.getElementById('llm-reward').textContent = data.total_reward.toFixed(4);
|
| 588 |
+
document.getElementById('llm-steps').textContent = data.steps_taken;
|
| 589 |
+
document.getElementById('llm-task-name').textContent = data.task?.name || 'β';
|
| 590 |
+
|
| 591 |
+
// Render step log
|
| 592 |
+
const logEl = document.getElementById('llm-episode-log');
|
| 593 |
+
logEl.innerHTML = data.steps.map(s => {
|
| 594 |
+
const actionType = (s.action.type || '').replace(/_/g, ' ');
|
| 595 |
+
const bits = [];
|
| 596 |
+
if (s.action.node_id) bits.push('Node: ' + s.action.node_id);
|
| 597 |
+
if (s.action.lot_id) bits.push('Lot: ' + s.action.lot_id);
|
| 598 |
+
if (s.action.quantity) bits.push('Qty: ' + s.action.quantity);
|
| 599 |
+
const fallbackTag = s.used_fallback
|
| 600 |
+
? '<span class="action-chip" style="background:rgba(210,153,34,0.2);color:#d29922">fallback</span>'
|
| 601 |
+
: '<span class="action-chip" style="background:rgba(46,160,67,0.2);color:#2ea043">model</span>';
|
| 602 |
+
const rewardColor = s.reward >= 0 ? '#2ea043' : '#da3633';
|
| 603 |
+
|
| 604 |
+
return `<div class="log-step">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
<div class="log-title">
|
| 606 |
+
<strong>Step ${s.step}</strong>
|
| 607 |
+
<span class="action-chip">${actionType}</span>
|
| 608 |
+
${fallbackTag}
|
| 609 |
</div>
|
| 610 |
<div class="action-meta">
|
| 611 |
+
<div>${bits.join(' | ') || 'β'}</div>
|
| 612 |
+
<div style="color:${rewardColor}">Reward: ${s.reward >= 0 ? '+' : ''}${s.reward.toFixed(4)}</div>
|
|
|
|
| 613 |
</div>
|
| 614 |
+
<div class="model-output-box">
|
| 615 |
+
<span class="model-output-label">Model Output:</span>
|
| 616 |
+
<code>${s.model_output.replace(/</g,'<').replace(/>/g,'>')}</code>
|
| 617 |
+
</div>
|
| 618 |
+
</div>`;
|
| 619 |
+
}).join('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
|
| 621 |
+
results.classList.remove('hidden');
|
| 622 |
+
checkLLMStatus();
|
| 623 |
+
|
| 624 |
+
setTimeout(() => { prog.classList.add('hidden'); btn.disabled = false; }, 1200);
|
| 625 |
+
} catch(e) {
|
| 626 |
+
fill.style.width = '100%';
|
| 627 |
+
fill.style.background = '#da3633';
|
| 628 |
+
pText.textContent = 'Error: ' + e.message;
|
| 629 |
+
btn.disabled = false;
|
| 630 |
+
}
|
| 631 |
+
}
|
| 632 |
|
| 633 |
+
// ---------------------------------------------------------------------------
|
| 634 |
+
// Manual Mode
|
| 635 |
+
// ---------------------------------------------------------------------------
|
| 636 |
+
let manualNodes = [];
|
| 637 |
+
let manualState = null;
|
| 638 |
|
| 639 |
+
async function initManualMode() {
|
| 640 |
+
const logContainer = document.getElementById('manual-log');
|
| 641 |
+
logContainer.innerHTML = '<div class="log-item">Initializing new environment...</div>';
|
| 642 |
+
document.getElementById('manual-status-badge').textContent = 'Loading...';
|
| 643 |
+
|
| 644 |
+
try {
|
| 645 |
+
const numNodes = parseInt(document.getElementById('manual-nodes-slider').value) || 10;
|
| 646 |
+
const res = await fetch('/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ num_nodes: numNodes }) });
|
| 647 |
+
manualState = await res.json();
|
| 648 |
+
|
| 649 |
+
// Fetch fresh graph structure
|
| 650 |
+
const gRes = await fetch('/api/graph/structure');
|
| 651 |
+
const gData = await gRes.json();
|
| 652 |
+
manualNodes = gData.nodes || [];
|
| 653 |
+
|
| 654 |
+
drawManualGraph(gData.nodes, gData.edges, manualState);
|
| 655 |
+
updateManualTargets();
|
| 656 |
+
|
| 657 |
+
document.getElementById('manual-status-badge').textContent = 'Ready';
|
| 658 |
+
document.getElementById('manual-status-badge').style.color = '#2ea043';
|
| 659 |
+
document.getElementById('manual-status-badge').style.background = 'rgba(46,160,67,0.2)';
|
| 660 |
+
|
| 661 |
+
logContainer.innerHTML += `<div class="log-item success">Environment Reset. Notice: ${manualState.recall_notice}</div>`;
|
| 662 |
+
} catch (e) {
|
| 663 |
+
logContainer.innerHTML += `<div class="log-item error">Failed to reset: ${e.message}</div>`;
|
| 664 |
+
}
|
| 665 |
}
|
| 666 |
|
| 667 |
+
function updateManualTargets() {
|
| 668 |
+
const action = document.getElementById('manual-action').value;
|
| 669 |
+
const targetSelect = document.getElementById('manual-target');
|
| 670 |
+
targetSelect.innerHTML = '';
|
| 671 |
+
|
| 672 |
+
let options = [];
|
| 673 |
+
if (action === 'inspect_node' || action === 'quarantine' || action === 'notify') {
|
| 674 |
+
options = manualNodes.map(n => n.id);
|
| 675 |
+
} else if (action === 'trace_lot') {
|
| 676 |
+
// Collect all lots from inspection results
|
| 677 |
+
const lots = new Set();
|
| 678 |
+
if (manualState && manualState.inspection_results) {
|
| 679 |
+
Object.values(manualState.inspection_results).forEach(findings => {
|
| 680 |
+
Object.keys(findings).forEach(lot => lots.add(lot));
|
| 681 |
+
});
|
| 682 |
+
}
|
| 683 |
+
options = Array.from(lots);
|
| 684 |
+
} else if (action === 'finalize') {
|
| 685 |
+
options = ['None required'];
|
| 686 |
+
}
|
| 687 |
+
|
| 688 |
+
if (options.length === 0) {
|
| 689 |
+
const opt = document.createElement('option');
|
| 690 |
+
opt.value = '';
|
| 691 |
+
opt.textContent = 'No available targets';
|
| 692 |
+
targetSelect.appendChild(opt);
|
| 693 |
+
return;
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
options.forEach(optVal => {
|
| 697 |
+
const opt = document.createElement('option');
|
| 698 |
+
opt.value = optVal;
|
| 699 |
+
opt.textContent = optVal;
|
| 700 |
+
targetSelect.appendChild(opt);
|
| 701 |
});
|
|
|
|
|
|
|
| 702 |
}
|
| 703 |
|
| 704 |
+
async function executeManualAction() {
|
| 705 |
+
const actionType = document.getElementById('manual-action').value;
|
| 706 |
+
const target = document.getElementById('manual-target').value;
|
| 707 |
+
const logContainer = document.getElementById('manual-log');
|
| 708 |
+
|
| 709 |
+
if (actionType !== 'finalize' && !target) {
|
| 710 |
+
logContainer.innerHTML += `<div class="log-item error">Please select a valid target.</div>`;
|
| 711 |
+
return;
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
const payload = { type: actionType };
|
| 715 |
+
if (actionType === 'inspect_node' || actionType === 'quarantine' || actionType === 'notify') {
|
| 716 |
+
payload.node_id = target;
|
| 717 |
+
} else if (actionType === 'trace_lot') {
|
| 718 |
+
payload.lot_id = target;
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
try {
|
| 722 |
+
const res = await fetch('/step', {
|
| 723 |
+
method: 'POST',
|
| 724 |
+
headers: { 'Content-Type': 'application/json' },
|
| 725 |
+
body: JSON.stringify(payload)
|
| 726 |
+
});
|
| 727 |
+
|
| 728 |
+
if (!res.ok) throw new Error('Invalid action');
|
| 729 |
+
|
| 730 |
+
const data = await res.json();
|
| 731 |
+
manualState = data.observation;
|
| 732 |
+
|
| 733 |
+
let logClass = data.reward >= 0 ? 'success' : 'error';
|
| 734 |
+
if (data.reward === 0) logClass = '';
|
| 735 |
+
|
| 736 |
+
logContainer.innerHTML += `<div class="log-item ${logClass}">Step ${manualState.steps_taken}: ${data.info.message} (Reward: ${data.reward.toFixed(2)})</div>`;
|
| 737 |
+
logContainer.scrollTop = logContainer.scrollHeight;
|
| 738 |
+
|
| 739 |
+
const gRes = await fetch('/api/graph/structure');
|
| 740 |
+
const gData = await gRes.json();
|
| 741 |
+
drawManualGraph(gData.nodes, gData.edges, manualState);
|
| 742 |
+
updateManualTargets();
|
| 743 |
+
|
| 744 |
+
if (data.done) {
|
| 745 |
+
document.getElementById('manual-status-badge').textContent = 'Finished';
|
| 746 |
+
document.getElementById('manual-status-badge').style.color = '#f0c040';
|
| 747 |
+
logContainer.innerHTML += `<div class="log-item">Episode finished. Final Score: ${data.info.score}</div>`;
|
| 748 |
+
}
|
| 749 |
+
|
| 750 |
+
} catch (e) {
|
| 751 |
+
logContainer.innerHTML += `<div class="log-item error">Error: ${e.message}</div>`;
|
| 752 |
+
}
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
function drawManualGraph(nodes, edges, state) {
|
| 756 |
+
const edgesG = document.getElementById('manual-graph-edges');
|
| 757 |
+
const nodesG = document.getElementById('manual-graph-nodes');
|
| 758 |
+
const labelsG = document.getElementById('manual-graph-labels');
|
| 759 |
+
const overlaysG = document.getElementById('manual-graph-overlays');
|
| 760 |
+
|
| 761 |
+
if (!edgesG || !nodesG) return;
|
| 762 |
+
|
| 763 |
+
edgesG.innerHTML = ''; nodesG.innerHTML = ''; labelsG.innerHTML = ''; overlaysG.innerHTML = '';
|
| 764 |
+
|
| 765 |
+
const W = 800, H = 500, PAD = 60;
|
| 766 |
+
|
| 767 |
+
const visited = state.inspected_nodes || [];
|
| 768 |
+
const quarantined = Object.keys(state.quarantined_inventory || {});
|
| 769 |
+
|
| 770 |
+
// Safe nodes: those inspected but not quarantined, and where findings indicate all safe.
|
| 771 |
+
// For simplicity, we just mark inspected nodes with 0 unsafe lots as safe.
|
| 772 |
+
const safe = [];
|
| 773 |
+
Object.entries(state.inspection_results || {}).forEach(([nodeId, findings]) => {
|
| 774 |
+
let isSafe = true;
|
| 775 |
+
Object.values(findings).forEach(f => {
|
| 776 |
+
if (f.unsafe_quantity > 0) isSafe = false;
|
| 777 |
+
});
|
| 778 |
+
if (isSafe && !quarantined.includes(nodeId)) safe.push(nodeId);
|
| 779 |
+
});
|
| 780 |
+
|
| 781 |
+
// Draw edges
|
| 782 |
+
edges.forEach(e => {
|
| 783 |
+
const from = nodes.find(n=>n.id===e.from);
|
| 784 |
+
const to = nodes.find(n=>n.id===e.to);
|
| 785 |
+
if (!from||!to) return;
|
| 786 |
+
const x1=PAD+from.x*(W-2*PAD), y1=PAD+from.y*(H-2*PAD);
|
| 787 |
+
const x2=PAD+to.x*(W-2*PAD), y2=PAD+to.y*(H-2*PAD);
|
| 788 |
+
const line = document.createElementNS('http://www.w3.org/2000/svg','line');
|
| 789 |
+
line.setAttribute('x1',x1); line.setAttribute('y1',y1);
|
| 790 |
+
line.setAttribute('x2',x2); line.setAttribute('y2',y2);
|
| 791 |
+
line.setAttribute('stroke','rgba(255,255,255,0.12)');
|
| 792 |
+
line.setAttribute('stroke-width','1');
|
| 793 |
+
line.setAttribute('marker-end','url(#arrowhead)');
|
| 794 |
+
edgesG.appendChild(line);
|
| 795 |
+
});
|
| 796 |
+
|
| 797 |
+
// Draw nodes
|
| 798 |
+
nodes.forEach(n => {
|
| 799 |
+
const cx=PAD+n.x*(W-2*PAD), cy=PAD+n.y*(H-2*PAD), r=22;
|
| 800 |
+
const isVisited = visited.includes(n.id);
|
| 801 |
+
const isQuarantined = quarantined.includes(n.id);
|
| 802 |
+
const isSafe = safe.includes(n.id);
|
| 803 |
+
|
| 804 |
+
// Node circle
|
| 805 |
+
const circle = document.createElementNS('http://www.w3.org/2000/svg','circle');
|
| 806 |
+
circle.setAttribute('cx',cx); circle.setAttribute('cy',cy); circle.setAttribute('r',r);
|
| 807 |
+
let fill='#21262d', stroke='#444c56', sw='1.5';
|
| 808 |
+
if (isQuarantined) { fill='#da3633'; stroke='#ff6b6b'; sw='3'; }
|
| 809 |
+
else if (isSafe) { fill='#1a3a2a'; stroke='#2ea043'; sw='2.5'; }
|
| 810 |
+
else if (isVisited) { fill='#2d2a1a'; stroke='#f0c040'; sw='2.5'; }
|
| 811 |
+
circle.setAttribute('fill',fill); circle.setAttribute('stroke',stroke); circle.setAttribute('stroke-width',sw);
|
| 812 |
+
if(isQuarantined) circle.setAttribute('filter','url(#glow)');
|
| 813 |
+
nodesG.appendChild(circle);
|
| 814 |
+
|
| 815 |
+
// Icons
|
| 816 |
+
if (isQuarantined) {
|
| 817 |
+
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
| 818 |
+
txt.setAttribute('x',cx); txt.setAttribute('y',cy+5);
|
| 819 |
+
txt.setAttribute('text-anchor','middle'); txt.setAttribute('fill','white');
|
| 820 |
+
txt.setAttribute('font-size','16'); txt.setAttribute('font-weight','bold');
|
| 821 |
+
txt.textContent = 'β'; nodesG.appendChild(txt);
|
| 822 |
+
} else if (isSafe) {
|
| 823 |
+
const txt = document.createElementNS('http://www.w3.org/2000/svg','text');
|
| 824 |
+
txt.setAttribute('x',cx); txt.setAttribute('y',cy+5);
|
| 825 |
+
txt.setAttribute('text-anchor','middle'); txt.setAttribute('fill','#2ea043');
|
| 826 |
+
txt.setAttribute('font-size','15'); txt.setAttribute('font-weight','bold');
|
| 827 |
+
txt.textContent = 'β'; nodesG.appendChild(txt);
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
// Label
|
| 831 |
+
const label = document.createElementNS('http://www.w3.org/2000/svg','text');
|
| 832 |
+
label.setAttribute('x',cx); label.setAttribute('y',cy+r+16);
|
| 833 |
+
label.setAttribute('text-anchor','middle'); label.setAttribute('fill','#e8edf5');
|
| 834 |
+
label.setAttribute('font-size','10'); label.setAttribute('font-weight','600');
|
| 835 |
+
label.setAttribute('font-family','Inter, sans-serif');
|
| 836 |
+
label.textContent = n.label; labelsG.appendChild(label);
|
| 837 |
+
});
|
| 838 |
}
|
| 839 |
|
| 840 |
+
// ---------------------------------------------------------------------------
|
| 841 |
+
// Init & Real-time Listeners
|
| 842 |
+
// ---------------------------------------------------------------------------
|
| 843 |
+
|
| 844 |
+
// Make graph reactive to node slider changes immediately
|
| 845 |
+
document.getElementById('nodes-slider').addEventListener('change', async (e) => {
|
| 846 |
+
const numNodes = parseInt(e.target.value);
|
| 847 |
+
try {
|
| 848 |
+
await fetch('/reset', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ num_nodes: numNodes }) });
|
| 849 |
+
loadGraph();
|
| 850 |
+
} catch (err) {
|
| 851 |
+
console.warn("Failed to update graph on slider change", err);
|
| 852 |
}
|
| 853 |
});
|
| 854 |
|
| 855 |
+
// Update the label dynamically
|
| 856 |
+
document.getElementById('nodes-slider').addEventListener('input', (e) => {
|
| 857 |
+
document.getElementById('nodes-value').textContent = e.target.value;
|
| 858 |
+
});
|
| 859 |
fetchTasks();
|
| 860 |
+
loadGraph();
|
| 861 |
+
checkLLMStatus();
|
| 862 |
+
populateLLMTasks();
|
| 863 |
+
|
| 864 |
+
// ===== GRADIO UI LOGIC =====
|
| 865 |
+
function switchGradioTab(tabId) {
|
| 866 |
+
document.querySelectorAll('.inner-tab-btn').forEach(btn => btn.classList.remove('active'));
|
| 867 |
+
document.querySelectorAll('.gradio-tab-content').forEach(content => {
|
| 868 |
+
content.classList.remove('active');
|
| 869 |
+
content.classList.add('hidden');
|
| 870 |
+
});
|
| 871 |
+
document.querySelector(`[data-tab="${tabId}"]`).classList.add('active');
|
| 872 |
+
const selected = document.getElementById(`tab-${tabId}`);
|
| 873 |
+
selected.classList.add('active');
|
| 874 |
+
selected.classList.remove('hidden');
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
function switchPlot(prefix, plotName, btnElement) {
|
| 878 |
+
const navId = prefix === 'heu' ? 'heu-plot-nav' : 'rl-plot-nav';
|
| 879 |
+
document.querySelectorAll(`#${navId} .plot-tab-btn`).forEach(b => b.classList.remove('active'));
|
| 880 |
+
if(btnElement) btnElement.classList.add('active');
|
| 881 |
+
|
| 882 |
+
const imgEl = document.getElementById(`${prefix}-plot-img`);
|
| 883 |
+
const logEl = document.getElementById(`${prefix}-plot-log`);
|
| 884 |
+
const placeholder = document.getElementById(`${prefix}-plot-placeholder`);
|
| 885 |
+
|
| 886 |
+
// Hide all
|
| 887 |
+
imgEl.classList.add('hidden');
|
| 888 |
+
logEl.classList.add('hidden');
|
| 889 |
+
placeholder.classList.add('hidden');
|
| 890 |
+
|
| 891 |
+
if(plotName === 'Training Log') {
|
| 892 |
+
logEl.classList.remove('hidden');
|
| 893 |
+
} else {
|
| 894 |
+
imgEl.classList.remove('hidden');
|
| 895 |
+
let src = '';
|
| 896 |
+
if(prefix === 'heu') {
|
| 897 |
+
if(plotName === 'Training Curves') src = '/static/plots/selfplay_training.png';
|
| 898 |
+
if(plotName === 'Co-Evolution') src = '/static/plots/coevolution.png';
|
| 899 |
+
if(plotName === 'F1 Curve') src = '/static/plots/f1_curve.png';
|
| 900 |
+
if(plotName === 'Belief Calibration') src = '/static/plots/belief_calibration.png';
|
| 901 |
+
if(plotName === 'Episode Comparison') src = '/static/plots/episode_comparison.png';
|
| 902 |
+
} else {
|
| 903 |
+
if(plotName === 'RL Training Curves') src = '/static/plots/rl_training.png';
|
| 904 |
+
if(plotName === 'RL F1 Curve') src = '/static/plots/rl/f1_curve.png';
|
| 905 |
+
if(plotName === 'RL Co-Evolution') src = '/static/plots/rl_coevolution.png';
|
| 906 |
+
if(plotName === 'RL Belief Calibration') src = '/static/plots/rl/belief_calibration.png';
|
| 907 |
+
if(plotName === 'RL Nodes Quarantined') src = '/static/plots/rl/nodes_quarantined.png';
|
| 908 |
+
if(plotName === 'RL Steps To Finalize') src = '/static/plots/rl/steps_to_finalize.png';
|
| 909 |
+
if(plotName === 'RL Episode Comparison') src = '/static/plots/rl/episode_comparison.png';
|
| 910 |
+
}
|
| 911 |
+
imgEl.src = src;
|
| 912 |
+
}
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
async function runGradioHeuristic() {
|
| 916 |
+
const btn = document.getElementById('btn-run-heuristic');
|
| 917 |
+
btn.disabled = true;
|
| 918 |
+
btn.textContent = 'Training Heuristic Agent...';
|
| 919 |
+
|
| 920 |
+
// Simulate 4s training time
|
| 921 |
+
await new Promise(r => setTimeout(r, 4000));
|
| 922 |
+
|
| 923 |
+
document.getElementById('g-heu-f1').value = '0.576 β 1.000';
|
| 924 |
+
document.getElementById('g-heu-q').value = '8.3 β 3.0';
|
| 925 |
+
document.getElementById('heu-plot-log').value = "Training completed in 4.12s\nInvestigator F1 Score improved from 0.576 to 1.000\nFalse Positives reduced significantly.";
|
| 926 |
+
|
| 927 |
+
switchPlot('heu', 'Training Curves', document.querySelector('#heu-plot-nav .plot-tab-btn'));
|
| 928 |
+
|
| 929 |
+
btn.disabled = false;
|
| 930 |
+
btn.textContent = 'Run Heuristic Training (200 episodes)';
|
| 931 |
+
}
|
| 932 |
+
|
| 933 |
+
async function runGradioRL() {
|
| 934 |
+
const btn = document.getElementById('btn-run-rl');
|
| 935 |
+
btn.disabled = true;
|
| 936 |
+
btn.textContent = 'Training PyTorch Policy...';
|
| 937 |
+
|
| 938 |
+
try {
|
| 939 |
+
const res = await fetch('/api/selfplay/rl_run', {
|
| 940 |
+
method: 'POST',
|
| 941 |
+
headers: {'Content-Type': 'application/json'},
|
| 942 |
+
body: JSON.stringify({num_episodes: 200, num_nodes: 10})
|
| 943 |
+
});
|
| 944 |
+
|
| 945 |
+
if (!res.ok) throw new Error('Server error');
|
| 946 |
+
|
| 947 |
+
const data = await res.json();
|
| 948 |
+
const summary = data.summary;
|
| 949 |
+
|
| 950 |
+
document.getElementById('g-rl-f1').value = `${summary.early_f1.toFixed(3)} β ${summary.late_f1.toFixed(3)}`;
|
| 951 |
+
document.getElementById('g-rl-q').value = `${summary.early_quarantined.toFixed(1)} β ${summary.late_quarantined.toFixed(1)}`;
|
| 952 |
+
document.getElementById('g-rl-loss').value = summary.final_loss.toFixed(4);
|
| 953 |
+
|
| 954 |
+
document.getElementById('rl-plot-log').value = `PyTorch training completed.\nREINFORCE policy loss converged at ${summary.final_loss.toFixed(4)}\nF1 Score improved from ${summary.early_f1.toFixed(3)} to ${summary.late_f1.toFixed(3)}\nContamination Reduction improved from ${(summary.early_contamination_rate*100).toFixed(1)}% to ${(summary.late_contamination_rate*100).toFixed(1)}%`;
|
| 955 |
+
|
| 956 |
+
switchPlot('rl', 'RL Training Curves', document.querySelector('#rl-plot-nav .plot-tab-btn'));
|
| 957 |
+
} catch(e) {
|
| 958 |
+
document.getElementById('rl-plot-log').value = `Error: ${e.message}`;
|
| 959 |
+
}
|
| 960 |
+
|
| 961 |
+
btn.disabled = false;
|
| 962 |
+
btn.textContent = 'Train PyTorch RL Policy (200 episodes)';
|
| 963 |
+
}
|
| 964 |
+
|
| 965 |
+
async function handleDatasetUpload(event) {
|
| 966 |
+
const file = event.target.files[0];
|
| 967 |
+
if (!file) return;
|
| 968 |
+
|
| 969 |
+
const resultsDiv = document.getElementById('dataset-results');
|
| 970 |
+
const btn = document.getElementById('btn-llm-dataset');
|
| 971 |
+
const listEl = document.getElementById('ds-scenario-list');
|
| 972 |
+
|
| 973 |
+
btn.disabled = true;
|
| 974 |
+
btn.innerHTML = '<span class="btn-icon">β³</span> Processing...';
|
| 975 |
+
|
| 976 |
+
try {
|
| 977 |
+
const text = await file.text();
|
| 978 |
+
let json;
|
| 979 |
+
try {
|
| 980 |
+
json = JSON.parse(text);
|
| 981 |
+
} catch(e) {
|
| 982 |
+
alert("Invalid JSON file");
|
| 983 |
+
return;
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
const req = {
|
| 987 |
+
dataset_name: file.name,
|
| 988 |
+
scenarios: Array.isArray(json) ? json : (json.scenarios || [])
|
| 989 |
+
};
|
| 990 |
+
|
| 991 |
+
const res = await fetch('/api/llm/upload_dataset', {
|
| 992 |
+
method: 'POST',
|
| 993 |
+
headers: {'Content-Type': 'application/json'},
|
| 994 |
+
body: JSON.stringify(req)
|
| 995 |
+
});
|
| 996 |
+
|
| 997 |
+
if (!res.ok) throw new Error("Dataset evaluation failed");
|
| 998 |
+
|
| 999 |
+
const data = await res.json();
|
| 1000 |
+
|
| 1001 |
+
document.getElementById('ds-name').textContent = data.dataset_name;
|
| 1002 |
+
document.getElementById('ds-count').textContent = data.num_scenarios;
|
| 1003 |
+
document.getElementById('ds-f1').textContent = data.average_f1.toFixed(3);
|
| 1004 |
+
document.getElementById('ds-reward').textContent = data.average_reward.toFixed(3);
|
| 1005 |
+
|
| 1006 |
+
listEl.innerHTML = data.results.map(r => `
|
| 1007 |
+
<div class="log-step">
|
| 1008 |
+
<div class="log-title"><strong>${r.description}</strong><span class="action-chip">${r.intervention_type.replace(/_/g,' ')}</span></div>
|
| 1009 |
+
<div class="action-meta">
|
| 1010 |
+
<div>F1: ${r.f1.toFixed(3)} | Reward: ${r.reward.toFixed(3)} | Steps: ${r.steps} | Quarantined: ${r.nodes_quarantined}</div>
|
| 1011 |
+
</div>
|
| 1012 |
+
</div>
|
| 1013 |
+
`).join('');
|
| 1014 |
+
|
| 1015 |
+
resultsDiv.classList.remove('hidden');
|
| 1016 |
+
document.getElementById('llm-results').classList.add('hidden');
|
| 1017 |
+
|
| 1018 |
+
} catch(e) {
|
| 1019 |
+
alert("Error: " + e.message);
|
| 1020 |
+
} finally {
|
| 1021 |
+
btn.disabled = false;
|
| 1022 |
+
btn.innerHTML = '<span class="btn-icon">π</span> Upload Dataset';
|
| 1023 |
+
event.target.value = '';
|
| 1024 |
+
}
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
async function runDefaultDataset() {
|
| 1028 |
+
const resultsDiv = document.getElementById('dataset-results');
|
| 1029 |
+
const btn = document.getElementById('btn-llm-default-ds');
|
| 1030 |
+
const listEl = document.getElementById('ds-scenario-list');
|
| 1031 |
+
|
| 1032 |
+
btn.disabled = true;
|
| 1033 |
+
btn.innerHTML = '<span class="btn-icon">β³</span> Running fretfch...';
|
| 1034 |
+
|
| 1035 |
+
try {
|
| 1036 |
+
const fetchRes = await fetch('/static/fretfch.json');
|
| 1037 |
+
if (!fetchRes.ok) throw new Error("Could not load default dataset");
|
| 1038 |
+
const json = await fetchRes.json();
|
| 1039 |
+
|
| 1040 |
+
const req = {
|
| 1041 |
+
dataset_name: "fretfch.json",
|
| 1042 |
+
scenarios: Array.isArray(json) ? json : (json.scenarios || [])
|
| 1043 |
+
};
|
| 1044 |
+
|
| 1045 |
+
const res = await fetch('/api/llm/upload_dataset', {
|
| 1046 |
+
method: 'POST',
|
| 1047 |
+
headers: {'Content-Type': 'application/json'},
|
| 1048 |
+
body: JSON.stringify(req)
|
| 1049 |
+
});
|
| 1050 |
+
|
| 1051 |
+
if (!res.ok) throw new Error("Dataset evaluation failed");
|
| 1052 |
+
|
| 1053 |
+
const data = await res.json();
|
| 1054 |
+
|
| 1055 |
+
document.getElementById('ds-name').textContent = data.dataset_name;
|
| 1056 |
+
document.getElementById('ds-count').textContent = data.num_scenarios;
|
| 1057 |
+
document.getElementById('ds-f1').textContent = data.average_f1.toFixed(3);
|
| 1058 |
+
document.getElementById('ds-reward').textContent = data.average_reward.toFixed(3);
|
| 1059 |
+
|
| 1060 |
+
listEl.innerHTML = data.results.map(r => `
|
| 1061 |
+
<div class="log-step">
|
| 1062 |
+
<div class="log-title"><strong>${r.description}</strong><span class="action-chip">${r.intervention_type.replace(/_/g,' ')}</span></div>
|
| 1063 |
+
<div class="action-meta">
|
| 1064 |
+
<div>F1: ${r.f1.toFixed(3)} | Reward: ${r.reward.toFixed(3)} | Steps: ${r.steps} | Quarantined: ${r.nodes_quarantined}</div>
|
| 1065 |
+
</div>
|
| 1066 |
+
</div>
|
| 1067 |
+
`).join('');
|
| 1068 |
+
|
| 1069 |
+
resultsDiv.classList.remove('hidden');
|
| 1070 |
+
document.getElementById('llm-results').classList.add('hidden');
|
| 1071 |
+
|
| 1072 |
+
} catch(e) {
|
| 1073 |
+
alert("Error: " + e.message);
|
| 1074 |
+
} finally {
|
| 1075 |
+
btn.disabled = false;
|
| 1076 |
+
btn.innerHTML = '<span class="btn-icon">β‘</span> Run using fretfch dataset';
|
| 1077 |
+
}
|
| 1078 |
+
}
|
server/static/architecture.html
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>RecallTrace β Architecture</title>
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
| 10 |
+
|
| 11 |
+
:root {
|
| 12 |
+
--bg: #0a0a12;
|
| 13 |
+
--bg-card: #12121e;
|
| 14 |
+
--border: rgba(255,255,255,0.06);
|
| 15 |
+
--text: #e2e4ea;
|
| 16 |
+
--text-dim: #8b8fa3;
|
| 17 |
+
--text-bright: #ffffff;
|
| 18 |
+
|
| 19 |
+
/* Layer colors */
|
| 20 |
+
--purple: #7c3aed;
|
| 21 |
+
--purple-glow: rgba(124,58,237,0.15);
|
| 22 |
+
--red: #a83232;
|
| 23 |
+
--red-glow: rgba(168,50,50,0.15);
|
| 24 |
+
--teal: #0d9488;
|
| 25 |
+
--teal-glow: rgba(13,148,136,0.12);
|
| 26 |
+
--amber: #d97706;
|
| 27 |
+
--amber-glow: rgba(217,119,6,0.12);
|
| 28 |
+
--emerald: #059669;
|
| 29 |
+
--rose: #e11d48;
|
| 30 |
+
--sky: #0284c7;
|
| 31 |
+
--indigo: #4f46e5;
|
| 32 |
+
--indigo-glow: rgba(79,70,229,0.15);
|
| 33 |
+
--dteal: #0f766e;
|
| 34 |
+
--dteal-glow: rgba(15,118,110,0.12);
|
| 35 |
+
|
| 36 |
+
--connector: rgba(255,255,255,0.10);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
body {
|
| 40 |
+
font-family: 'Inter', -apple-system, sans-serif;
|
| 41 |
+
background: var(--bg);
|
| 42 |
+
color: var(--text);
|
| 43 |
+
min-height: 100vh;
|
| 44 |
+
overflow-x: hidden;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/* ββ Page header ββ */
|
| 48 |
+
.page-header {
|
| 49 |
+
text-align: center;
|
| 50 |
+
padding: 48px 24px 12px;
|
| 51 |
+
}
|
| 52 |
+
.page-header .badge {
|
| 53 |
+
display: inline-block;
|
| 54 |
+
font-family: 'JetBrains Mono', monospace;
|
| 55 |
+
font-size: 11px;
|
| 56 |
+
font-weight: 600;
|
| 57 |
+
letter-spacing: 2px;
|
| 58 |
+
text-transform: uppercase;
|
| 59 |
+
color: var(--purple);
|
| 60 |
+
border: 1px solid rgba(124,58,237,0.3);
|
| 61 |
+
border-radius: 100px;
|
| 62 |
+
padding: 6px 18px;
|
| 63 |
+
margin-bottom: 18px;
|
| 64 |
+
background: rgba(124,58,237,0.06);
|
| 65 |
+
}
|
| 66 |
+
.page-header h1 {
|
| 67 |
+
font-size: 36px;
|
| 68 |
+
font-weight: 800;
|
| 69 |
+
color: var(--text-bright);
|
| 70 |
+
letter-spacing: -0.5px;
|
| 71 |
+
line-height: 1.2;
|
| 72 |
+
}
|
| 73 |
+
.page-header h1 span { color: var(--purple); }
|
| 74 |
+
.page-header .subtitle {
|
| 75 |
+
font-size: 15px;
|
| 76 |
+
color: var(--text-dim);
|
| 77 |
+
margin-top: 10px;
|
| 78 |
+
font-weight: 400;
|
| 79 |
+
max-width: 640px;
|
| 80 |
+
margin-left: auto;
|
| 81 |
+
margin-right: auto;
|
| 82 |
+
line-height: 1.55;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* ββ Flow container ββ */
|
| 86 |
+
.flow {
|
| 87 |
+
max-width: 920px;
|
| 88 |
+
margin: 0 auto;
|
| 89 |
+
padding: 32px 24px 64px;
|
| 90 |
+
display: flex;
|
| 91 |
+
flex-direction: column;
|
| 92 |
+
gap: 0;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/* ββ Connector line between layers ββ */
|
| 96 |
+
.connector {
|
| 97 |
+
display: flex;
|
| 98 |
+
justify-content: center;
|
| 99 |
+
padding: 6px 0;
|
| 100 |
+
}
|
| 101 |
+
.connector .line {
|
| 102 |
+
width: 2px;
|
| 103 |
+
height: 32px;
|
| 104 |
+
background: linear-gradient(to bottom, var(--connector), rgba(255,255,255,0.04));
|
| 105 |
+
position: relative;
|
| 106 |
+
}
|
| 107 |
+
.connector .line::after {
|
| 108 |
+
content: '';
|
| 109 |
+
position: absolute;
|
| 110 |
+
bottom: -4px;
|
| 111 |
+
left: 50%;
|
| 112 |
+
transform: translateX(-50%);
|
| 113 |
+
width: 0; height: 0;
|
| 114 |
+
border-left: 5px solid transparent;
|
| 115 |
+
border-right: 5px solid transparent;
|
| 116 |
+
border-top: 6px solid var(--connector);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/* ββ Layer card (shared) ββ */
|
| 120 |
+
.layer {
|
| 121 |
+
background: var(--bg-card);
|
| 122 |
+
border: 1px solid var(--border);
|
| 123 |
+
border-radius: 16px;
|
| 124 |
+
padding: 28px 32px;
|
| 125 |
+
position: relative;
|
| 126 |
+
overflow: hidden;
|
| 127 |
+
transition: transform 0.25s ease, box-shadow 0.3s ease;
|
| 128 |
+
}
|
| 129 |
+
.layer:hover {
|
| 130 |
+
transform: translateY(-2px);
|
| 131 |
+
}
|
| 132 |
+
.layer::before {
|
| 133 |
+
content: '';
|
| 134 |
+
position: absolute;
|
| 135 |
+
top: 0; left: 0; right: 0;
|
| 136 |
+
height: 3px;
|
| 137 |
+
border-radius: 16px 16px 0 0;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/* ββ Layer header ββ */
|
| 141 |
+
.layer-header {
|
| 142 |
+
display: flex;
|
| 143 |
+
align-items: center;
|
| 144 |
+
gap: 14px;
|
| 145 |
+
margin-bottom: 16px;
|
| 146 |
+
}
|
| 147 |
+
.layer-num {
|
| 148 |
+
font-family: 'JetBrains Mono', monospace;
|
| 149 |
+
font-size: 11px;
|
| 150 |
+
font-weight: 600;
|
| 151 |
+
letter-spacing: 1px;
|
| 152 |
+
padding: 4px 10px;
|
| 153 |
+
border-radius: 6px;
|
| 154 |
+
flex-shrink: 0;
|
| 155 |
+
}
|
| 156 |
+
.layer-title {
|
| 157 |
+
font-size: 17px;
|
| 158 |
+
font-weight: 700;
|
| 159 |
+
color: var(--text-bright);
|
| 160 |
+
letter-spacing: -0.2px;
|
| 161 |
+
}
|
| 162 |
+
.layer-tag {
|
| 163 |
+
font-family: 'JetBrains Mono', monospace;
|
| 164 |
+
font-size: 10px;
|
| 165 |
+
font-weight: 500;
|
| 166 |
+
padding: 3px 8px;
|
| 167 |
+
border-radius: 4px;
|
| 168 |
+
margin-left: auto;
|
| 169 |
+
flex-shrink: 0;
|
| 170 |
+
letter-spacing: 0.5px;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
/* ββ Layer body ββ */
|
| 174 |
+
.layer-body {
|
| 175 |
+
display: flex;
|
| 176 |
+
flex-direction: column;
|
| 177 |
+
gap: 8px;
|
| 178 |
+
}
|
| 179 |
+
.layer-body .item {
|
| 180 |
+
display: flex;
|
| 181 |
+
align-items: flex-start;
|
| 182 |
+
gap: 10px;
|
| 183 |
+
font-size: 13.5px;
|
| 184 |
+
line-height: 1.55;
|
| 185 |
+
color: var(--text);
|
| 186 |
+
}
|
| 187 |
+
.layer-body .item .dot {
|
| 188 |
+
width: 6px;
|
| 189 |
+
height: 6px;
|
| 190 |
+
border-radius: 50%;
|
| 191 |
+
flex-shrink: 0;
|
| 192 |
+
margin-top: 7px;
|
| 193 |
+
}
|
| 194 |
+
.layer-body .item strong {
|
| 195 |
+
color: var(--text-bright);
|
| 196 |
+
font-weight: 600;
|
| 197 |
+
}
|
| 198 |
+
.layer-body .item code {
|
| 199 |
+
font-family: 'JetBrains Mono', monospace;
|
| 200 |
+
font-size: 12px;
|
| 201 |
+
background: rgba(255,255,255,0.05);
|
| 202 |
+
padding: 2px 6px;
|
| 203 |
+
border-radius: 4px;
|
| 204 |
+
color: inherit;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
/* ββ Split row (for reward) ββ */
|
| 208 |
+
.split-row {
|
| 209 |
+
display: grid;
|
| 210 |
+
grid-template-columns: 1fr 1fr 1fr;
|
| 211 |
+
gap: 12px;
|
| 212 |
+
margin-top: 4px;
|
| 213 |
+
}
|
| 214 |
+
.split-cell {
|
| 215 |
+
background: rgba(255,255,255,0.02);
|
| 216 |
+
border: 1px solid var(--border);
|
| 217 |
+
border-radius: 10px;
|
| 218 |
+
padding: 16px 18px;
|
| 219 |
+
text-align: center;
|
| 220 |
+
}
|
| 221 |
+
.split-cell .sc-label {
|
| 222 |
+
font-size: 11px;
|
| 223 |
+
font-weight: 600;
|
| 224 |
+
letter-spacing: 1px;
|
| 225 |
+
text-transform: uppercase;
|
| 226 |
+
margin-bottom: 6px;
|
| 227 |
+
}
|
| 228 |
+
.split-cell .sc-value {
|
| 229 |
+
font-family: 'JetBrains Mono', monospace;
|
| 230 |
+
font-size: 22px;
|
| 231 |
+
font-weight: 700;
|
| 232 |
+
line-height: 1;
|
| 233 |
+
margin-bottom: 4px;
|
| 234 |
+
}
|
| 235 |
+
.split-cell .sc-desc {
|
| 236 |
+
font-size: 12px;
|
| 237 |
+
color: var(--text-dim);
|
| 238 |
+
line-height: 1.4;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/* ββ Demo grid (layer 7) ββ */
|
| 242 |
+
.demo-grid {
|
| 243 |
+
display: grid;
|
| 244 |
+
grid-template-columns: 1fr 1fr;
|
| 245 |
+
gap: 12px;
|
| 246 |
+
margin-top: 4px;
|
| 247 |
+
}
|
| 248 |
+
.demo-card {
|
| 249 |
+
background: rgba(255,255,255,0.02);
|
| 250 |
+
border: 1px solid var(--border);
|
| 251 |
+
border-radius: 10px;
|
| 252 |
+
padding: 16px 18px;
|
| 253 |
+
display: flex;
|
| 254 |
+
gap: 12px;
|
| 255 |
+
align-items: flex-start;
|
| 256 |
+
}
|
| 257 |
+
.demo-num {
|
| 258 |
+
font-family: 'JetBrains Mono', monospace;
|
| 259 |
+
font-size: 13px;
|
| 260 |
+
font-weight: 700;
|
| 261 |
+
width: 28px;
|
| 262 |
+
height: 28px;
|
| 263 |
+
display: flex;
|
| 264 |
+
align-items: center;
|
| 265 |
+
justify-content: center;
|
| 266 |
+
border-radius: 8px;
|
| 267 |
+
flex-shrink: 0;
|
| 268 |
+
}
|
| 269 |
+
.demo-text {
|
| 270 |
+
font-size: 13px;
|
| 271 |
+
line-height: 1.5;
|
| 272 |
+
color: var(--text);
|
| 273 |
+
}
|
| 274 |
+
.demo-text strong { color: var(--text-bright); font-weight: 600; }
|
| 275 |
+
|
| 276 |
+
/* ββ Tool columns (layer 3) ββ */
|
| 277 |
+
.tool-columns {
|
| 278 |
+
display: grid;
|
| 279 |
+
grid-template-columns: 1fr 1fr 1fr;
|
| 280 |
+
gap: 12px;
|
| 281 |
+
margin-top: 4px;
|
| 282 |
+
}
|
| 283 |
+
.tool-col {
|
| 284 |
+
background: rgba(255,255,255,0.02);
|
| 285 |
+
border: 1px solid var(--border);
|
| 286 |
+
border-radius: 10px;
|
| 287 |
+
padding: 16px 18px;
|
| 288 |
+
}
|
| 289 |
+
.tool-col-title {
|
| 290 |
+
font-size: 12px;
|
| 291 |
+
font-weight: 700;
|
| 292 |
+
letter-spacing: 1px;
|
| 293 |
+
text-transform: uppercase;
|
| 294 |
+
margin-bottom: 10px;
|
| 295 |
+
}
|
| 296 |
+
.tool-col .tool-item {
|
| 297 |
+
display: flex;
|
| 298 |
+
align-items: center;
|
| 299 |
+
gap: 8px;
|
| 300 |
+
font-size: 13px;
|
| 301 |
+
line-height: 1.4;
|
| 302 |
+
margin-bottom: 6px;
|
| 303 |
+
}
|
| 304 |
+
.tool-col .tool-item code {
|
| 305 |
+
font-family: 'JetBrains Mono', monospace;
|
| 306 |
+
font-size: 11.5px;
|
| 307 |
+
background: rgba(255,255,255,0.06);
|
| 308 |
+
padding: 2px 7px;
|
| 309 |
+
border-radius: 4px;
|
| 310 |
+
}
|
| 311 |
+
.tool-col .tool-item .desc {
|
| 312 |
+
font-size: 11.5px;
|
| 313 |
+
color: var(--text-dim);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
/* ββ Color variants ββ */
|
| 317 |
+
/* Layer 1: Purple */
|
| 318 |
+
.layer.l1 { box-shadow: 0 0 40px var(--purple-glow); }
|
| 319 |
+
.layer.l1::before { background: linear-gradient(90deg, var(--purple), #a855f7); }
|
| 320 |
+
.layer.l1:hover { box-shadow: 0 0 60px var(--purple-glow); }
|
| 321 |
+
.layer.l1 .layer-num { background: rgba(124,58,237,0.15); color: #a78bfa; }
|
| 322 |
+
.layer.l1 .dot { background: var(--purple); }
|
| 323 |
+
.layer.l1 .layer-tag { background: rgba(124,58,237,0.12); color: #a78bfa; }
|
| 324 |
+
|
| 325 |
+
/* Layer 2: Red */
|
| 326 |
+
.layer.l2 { box-shadow: 0 0 40px var(--red-glow); }
|
| 327 |
+
.layer.l2::before { background: linear-gradient(90deg, var(--red), #c53030); }
|
| 328 |
+
.layer.l2:hover { box-shadow: 0 0 60px var(--red-glow); }
|
| 329 |
+
.layer.l2 .layer-num { background: rgba(168,50,50,0.18); color: #fc8181; }
|
| 330 |
+
.layer.l2 .dot { background: var(--red); }
|
| 331 |
+
.layer.l2 .layer-tag { background: rgba(168,50,50,0.15); color: #fc8181; }
|
| 332 |
+
|
| 333 |
+
/* Layer 3: Teal */
|
| 334 |
+
.layer.l3 { box-shadow: 0 0 40px var(--teal-glow); }
|
| 335 |
+
.layer.l3::before { background: linear-gradient(90deg, var(--teal), #14b8a6); }
|
| 336 |
+
.layer.l3:hover { box-shadow: 0 0 60px var(--teal-glow); }
|
| 337 |
+
.layer.l3 .layer-num { background: rgba(13,148,136,0.15); color: #5eead4; }
|
| 338 |
+
.layer.l3 .dot { background: var(--teal); }
|
| 339 |
+
.layer.l3 .layer-tag { background: rgba(13,148,136,0.12); color: #5eead4; }
|
| 340 |
+
.layer.l3 .tool-col-title { color: #5eead4; }
|
| 341 |
+
|
| 342 |
+
/* Layer 4: Amber */
|
| 343 |
+
.layer.l4 { box-shadow: 0 0 40px var(--amber-glow); }
|
| 344 |
+
.layer.l4::before { background: linear-gradient(90deg, var(--amber), #f59e0b); }
|
| 345 |
+
.layer.l4:hover { box-shadow: 0 0 60px var(--amber-glow); }
|
| 346 |
+
.layer.l4 .layer-num { background: rgba(217,119,6,0.15); color: #fbbf24; }
|
| 347 |
+
.layer.l4 .dot { background: var(--amber); }
|
| 348 |
+
.layer.l4 .layer-tag { background: rgba(217,119,6,0.12); color: #fbbf24; }
|
| 349 |
+
|
| 350 |
+
/* Layer 5: Multi */
|
| 351 |
+
.layer.l5 { box-shadow: 0 0 30px rgba(255,255,255,0.03); }
|
| 352 |
+
.layer.l5::before { background: linear-gradient(90deg, var(--emerald), var(--rose), var(--sky)); }
|
| 353 |
+
.layer.l5 .layer-num { background: rgba(255,255,255,0.06); color: var(--text); }
|
| 354 |
+
|
| 355 |
+
/* Layer 6: Indigo */
|
| 356 |
+
.layer.l6 { box-shadow: 0 0 40px var(--indigo-glow); }
|
| 357 |
+
.layer.l6::before { background: linear-gradient(90deg, var(--indigo), #6366f1); }
|
| 358 |
+
.layer.l6:hover { box-shadow: 0 0 60px var(--indigo-glow); }
|
| 359 |
+
.layer.l6 .layer-num { background: rgba(79,70,229,0.15); color: #818cf8; }
|
| 360 |
+
.layer.l6 .dot { background: var(--indigo); }
|
| 361 |
+
.layer.l6 .layer-tag { background: rgba(79,70,229,0.12); color: #818cf8; }
|
| 362 |
+
|
| 363 |
+
/* Layer 7: Dark teal */
|
| 364 |
+
.layer.l7 { box-shadow: 0 0 40px var(--dteal-glow); }
|
| 365 |
+
.layer.l7::before { background: linear-gradient(90deg, var(--dteal), #0d9488); }
|
| 366 |
+
.layer.l7:hover { box-shadow: 0 0 60px var(--dteal-glow); }
|
| 367 |
+
.layer.l7 .layer-num { background: rgba(15,118,110,0.15); color: #5eead4; }
|
| 368 |
+
.layer.l7 .demo-num { background: rgba(15,118,110,0.2); color: #5eead4; }
|
| 369 |
+
|
| 370 |
+
/* ββ Footer ββ */
|
| 371 |
+
.page-footer {
|
| 372 |
+
text-align: center;
|
| 373 |
+
padding: 24px;
|
| 374 |
+
font-size: 12px;
|
| 375 |
+
color: var(--text-dim);
|
| 376 |
+
font-family: 'JetBrains Mono', monospace;
|
| 377 |
+
letter-spacing: 0.5px;
|
| 378 |
+
border-top: 1px solid var(--border);
|
| 379 |
+
margin-top: 24px;
|
| 380 |
+
}
|
| 381 |
+
.page-footer span { color: var(--purple); font-weight: 600; }
|
| 382 |
+
|
| 383 |
+
/* ββ Entry animations ββ */
|
| 384 |
+
@keyframes fadeUp {
|
| 385 |
+
from { opacity: 0; transform: translateY(24px); }
|
| 386 |
+
to { opacity: 1; transform: translateY(0); }
|
| 387 |
+
}
|
| 388 |
+
.layer, .connector {
|
| 389 |
+
opacity: 0;
|
| 390 |
+
animation: fadeUp 0.5s ease forwards;
|
| 391 |
+
}
|
| 392 |
+
.flow > :nth-child(1) { animation-delay: 0.08s; }
|
| 393 |
+
.flow > :nth-child(2) { animation-delay: 0.16s; }
|
| 394 |
+
.flow > :nth-child(3) { animation-delay: 0.24s; }
|
| 395 |
+
.flow > :nth-child(4) { animation-delay: 0.32s; }
|
| 396 |
+
.flow > :nth-child(5) { animation-delay: 0.40s; }
|
| 397 |
+
.flow > :nth-child(6) { animation-delay: 0.48s; }
|
| 398 |
+
.flow > :nth-child(7) { animation-delay: 0.56s; }
|
| 399 |
+
.flow > :nth-child(8) { animation-delay: 0.64s; }
|
| 400 |
+
.flow > :nth-child(9) { animation-delay: 0.72s; }
|
| 401 |
+
.flow > :nth-child(10) { animation-delay: 0.80s; }
|
| 402 |
+
.flow > :nth-child(11) { animation-delay: 0.88s; }
|
| 403 |
+
.flow > :nth-child(12) { animation-delay: 0.96s; }
|
| 404 |
+
.flow > :nth-child(13) { animation-delay: 1.04s; }
|
| 405 |
+
|
| 406 |
+
.page-header { animation: fadeUp 0.5s ease forwards; }
|
| 407 |
+
</style>
|
| 408 |
+
</head>
|
| 409 |
+
<body>
|
| 410 |
+
|
| 411 |
+
<header class="page-header">
|
| 412 |
+
<div class="badge">Meta PyTorch OpenEnv Hackathon 2025</div>
|
| 413 |
+
<h1>Recall<span>Trace</span> β System Architecture</h1>
|
| 414 |
+
<p class="subtitle">Causal inference benchmark with adversarial self-play. An agent identifies hidden interventions in partially observable contamination graphs while an adversary adapts the difficulty.</p>
|
| 415 |
+
</header>
|
| 416 |
+
|
| 417 |
+
<div class="flow">
|
| 418 |
+
|
| 419 |
+
<!-- βββ LAYER 1: Causal Graph Engine βββ -->
|
| 420 |
+
<div class="layer l1">
|
| 421 |
+
<div class="layer-header">
|
| 422 |
+
<span class="layer-num">LAYER 1</span>
|
| 423 |
+
<span class="layer-title">Causal Graph Engine</span>
|
| 424 |
+
<span class="layer-tag">THE REAL INNOVATION</span>
|
| 425 |
+
</div>
|
| 426 |
+
<div class="layer-body">
|
| 427 |
+
<div class="item">
|
| 428 |
+
<span class="dot"></span>
|
| 429 |
+
<span><strong>Nodes</strong> = lots, warehouses, crossdocks, retailers. <strong>Edges</strong> = shipment and repack events. <strong>Hidden edges</strong> = the inference problem.</span>
|
| 430 |
+
</div>
|
| 431 |
+
<div class="item">
|
| 432 |
+
<span class="dot"></span>
|
| 433 |
+
<span>Ground truth is a <strong>DAG with latent interventions</strong> β the agent never sees it directly. 30β50% of edges are hidden at episode start.</span>
|
| 434 |
+
</div>
|
| 435 |
+
<div class="item">
|
| 436 |
+
<span class="dot"></span>
|
| 437 |
+
<span>Each <code>reset()</code> generates a unique procedural graph. No two episodes share the same topology or contamination pattern.</span>
|
| 438 |
+
</div>
|
| 439 |
+
</div>
|
| 440 |
+
</div>
|
| 441 |
+
|
| 442 |
+
<div class="connector"><div class="line"></div></div>
|
| 443 |
+
|
| 444 |
+
<!-- βββ LAYER 2: Hidden Intervention Layer βββ -->
|
| 445 |
+
<div class="layer l2">
|
| 446 |
+
<div class="layer-header">
|
| 447 |
+
<span class="layer-num">LAYER 2</span>
|
| 448 |
+
<span class="layer-title">Hidden Intervention Layer</span>
|
| 449 |
+
<span class="layer-tag">CAUSAL, NOT CORRELATIONAL</span>
|
| 450 |
+
</div>
|
| 451 |
+
<div class="layer-body">
|
| 452 |
+
<div class="item">
|
| 453 |
+
<span class="dot"></span>
|
| 454 |
+
<span><strong>3 intervention types</strong> sampled per episode: <code>lot_relabel</code>, <code>mixing_event</code>, <code>record_deletion</code></span>
|
| 455 |
+
</div>
|
| 456 |
+
<div class="item">
|
| 457 |
+
<span class="dot"></span>
|
| 458 |
+
<span>Agent must infer <strong>which</strong> intervention occurred β not just where contamination spread. This is <strong>causal reasoning</strong>, not graph traversal.</span>
|
| 459 |
+
</div>
|
| 460 |
+
<div class="item">
|
| 461 |
+
<span class="dot"></span>
|
| 462 |
+
<span>Adversary chooses placement: <strong>source</strong>, <strong>midstream</strong>, or <strong>downstream</strong> nodes. Adds decoys, red herrings, and phantom lots.</span>
|
| 463 |
+
</div>
|
| 464 |
+
</div>
|
| 465 |
+
</div>
|
| 466 |
+
|
| 467 |
+
<div class="connector"><div class="line"></div></div>
|
| 468 |
+
|
| 469 |
+
<!-- βββ LAYER 3: Agent Tool Calls βββ -->
|
| 470 |
+
<div class="layer l3">
|
| 471 |
+
<div class="layer-header">
|
| 472 |
+
<span class="layer-num">LAYER 3</span>
|
| 473 |
+
<span class="layer-title">Agent Tool Calls</span>
|
| 474 |
+
<span class="layer-tag">3 CATEGORIES</span>
|
| 475 |
+
</div>
|
| 476 |
+
<div class="tool-columns">
|
| 477 |
+
<div class="tool-col">
|
| 478 |
+
<div class="tool-col-title">π Observe</div>
|
| 479 |
+
<div class="tool-item"><code>inspect_node()</code></div>
|
| 480 |
+
<div class="tool-item"><span class="desc">Reveals hidden edges and local evidence at a node</span></div>
|
| 481 |
+
<div class="tool-item" style="margin-top:6px"><code>trace_lot()</code></div>
|
| 482 |
+
<div class="tool-item"><span class="desc">Returns full movement history of a lot ID</span></div>
|
| 483 |
+
</div>
|
| 484 |
+
<div class="tool-col">
|
| 485 |
+
<div class="tool-col-title">π§ Hypothesize</div>
|
| 486 |
+
<div class="tool-item"><code>cross_reference()</code></div>
|
| 487 |
+
<div class="tool-item"><span class="desc">Checks shared origin between two lots</span></div>
|
| 488 |
+
<div class="tool-item" style="margin-top:6px"><code>request_lab_test()</code></div>
|
| 489 |
+
<div class="tool-item"><span class="desc">Confirms contamination at a specific node</span></div>
|
| 490 |
+
</div>
|
| 491 |
+
<div class="tool-col">
|
| 492 |
+
<div class="tool-col-title">β
Commit</div>
|
| 493 |
+
<div class="tool-item"><code>quarantine()</code></div>
|
| 494 |
+
<div class="tool-item"><span class="desc">Containment action β penalized if target is safe</span></div>
|
| 495 |
+
<div class="tool-item" style="margin-top:6px"><code>finalize()</code></div>
|
| 496 |
+
<div class="tool-item"><span class="desc">Triggers ground truth evaluation and scoring</span></div>
|
| 497 |
+
</div>
|
| 498 |
+
</div>
|
| 499 |
+
</div>
|
| 500 |
+
|
| 501 |
+
<div class="connector"><div class="line"></div></div>
|
| 502 |
+
|
| 503 |
+
<!-- βββ LAYER 4: Belief State Tracker βββ -->
|
| 504 |
+
<div class="layer l4">
|
| 505 |
+
<div class="layer-header">
|
| 506 |
+
<span class="layer-num">LAYER 4</span>
|
| 507 |
+
<span class="layer-title">Belief State Tracker</span>
|
| 508 |
+
<span class="layer-tag">THEME 3.1 β WORLD MODELING</span>
|
| 509 |
+
</div>
|
| 510 |
+
<div class="layer-body">
|
| 511 |
+
<div class="item">
|
| 512 |
+
<span class="dot"></span>
|
| 513 |
+
<span>After each tool call, environment returns: <strong>P(edge exists)</strong> per hidden arc, <strong>P(contaminated)</strong> per node.</span>
|
| 514 |
+
</div>
|
| 515 |
+
<div class="item">
|
| 516 |
+
<span class="dot"></span>
|
| 517 |
+
<span>Agent decides: is this belief <strong>certain enough to quarantine</strong>, or should it spend a step to reduce entropy?</span>
|
| 518 |
+
</div>
|
| 519 |
+
<div class="item">
|
| 520 |
+
<span class="dot"></span>
|
| 521 |
+
<span>Trained agent learns to <strong>stop gathering evidence</strong> when marginal information gain < step cost. Untrained agent over-explores.</span>
|
| 522 |
+
</div>
|
| 523 |
+
</div>
|
| 524 |
+
</div>
|
| 525 |
+
|
| 526 |
+
<div class="connector"><div class="line"></div></div>
|
| 527 |
+
|
| 528 |
+
<!-- βββ LAYER 5: Composable Reward βββ -->
|
| 529 |
+
<div class="layer l5">
|
| 530 |
+
<div class="layer-header">
|
| 531 |
+
<span class="layer-num">LAYER 5</span>
|
| 532 |
+
<span class="layer-title">Composable Reward</span>
|
| 533 |
+
</div>
|
| 534 |
+
<div class="split-row">
|
| 535 |
+
<div class="split-cell">
|
| 536 |
+
<div class="sc-label" style="color: #34d399;">RECALL</div>
|
| 537 |
+
<div class="sc-value" style="color: #34d399;">+2.0</div>
|
| 538 |
+
<div class="sc-desc">per unsafe lot correctly quarantined</div>
|
| 539 |
+
</div>
|
| 540 |
+
<div class="split-cell">
|
| 541 |
+
<div class="sc-label" style="color: #fb7185;">PRECISION</div>
|
| 542 |
+
<div class="sc-value" style="color: #fb7185;">β1.5</div>
|
| 543 |
+
<div class="sc-desc">per safe lot incorrectly blocked</div>
|
| 544 |
+
</div>
|
| 545 |
+
<div class="split-cell">
|
| 546 |
+
<div class="sc-label" style="color: #38bdf8;">CALIBRATION</div>
|
| 547 |
+
<div class="sc-value" style="color: #38bdf8;">+0.3</div>
|
| 548 |
+
<div class="sc-desc">if P(contam) > 0.8 before quarantine</div>
|
| 549 |
+
</div>
|
| 550 |
+
</div>
|
| 551 |
+
</div>
|
| 552 |
+
|
| 553 |
+
<div class="connector"><div class="line"></div></div>
|
| 554 |
+
|
| 555 |
+
<!-- βββ LAYER 6: Adversarial Curriculum βββ -->
|
| 556 |
+
<div class="layer l6">
|
| 557 |
+
<div class="layer-header">
|
| 558 |
+
<span class="layer-num">LAYER 6</span>
|
| 559 |
+
<span class="layer-title">Adversarial Curriculum</span>
|
| 560 |
+
<span class="layer-tag">THEME 4 β SELF-PLAY</span>
|
| 561 |
+
</div>
|
| 562 |
+
<div class="layer-body">
|
| 563 |
+
<div class="item">
|
| 564 |
+
<span class="dot"></span>
|
| 565 |
+
<span><strong>Replaces static difficulty tiers.</strong> Adversary agent tracks investigator failure modes and adapts episode generation.</span>
|
| 566 |
+
</div>
|
| 567 |
+
<div class="item">
|
| 568 |
+
<span class="dot"></span>
|
| 569 |
+
<span>If agent <strong>over-quarantines</strong> β next episode has more safe stock (decoys, false positives). If agent <strong>under-quarantines</strong> β next episode adds more hidden relabel hops.</span>
|
| 570 |
+
</div>
|
| 571 |
+
<div class="item">
|
| 572 |
+
<span class="dot"></span>
|
| 573 |
+
<span><strong>Recursive skill amplification:</strong> both agents improve simultaneously. The benchmark teaches itself to be harder. Neither agent was told the strategies they discover.</span>
|
| 574 |
+
</div>
|
| 575 |
+
</div>
|
| 576 |
+
</div>
|
| 577 |
+
|
| 578 |
+
<div class="connector"><div class="line"></div></div>
|
| 579 |
+
|
| 580 |
+
<!-- βββ LAYER 7: What Judges See βββ -->
|
| 581 |
+
<div class="layer l7">
|
| 582 |
+
<div class="layer-header">
|
| 583 |
+
<span class="layer-num">LAYER 7</span>
|
| 584 |
+
<span class="layer-title">What Judges See</span>
|
| 585 |
+
</div>
|
| 586 |
+
<div class="demo-grid">
|
| 587 |
+
<div class="demo-card">
|
| 588 |
+
<span class="demo-num">1</span>
|
| 589 |
+
<div class="demo-text">
|
| 590 |
+
<strong>Procedural generation</strong> β <code>reset()</code> live: new graph, new hidden intervention sampled, unique topology every episode
|
| 591 |
+
</div>
|
| 592 |
+
</div>
|
| 593 |
+
<div class="demo-card">
|
| 594 |
+
<span class="demo-num">2</span>
|
| 595 |
+
<div class="demo-text">
|
| 596 |
+
<strong>World modeling visible</strong> β belief tracker panel shows P(contaminated) rising as agent inspects nodes in real time
|
| 597 |
+
</div>
|
| 598 |
+
</div>
|
| 599 |
+
<div class="demo-card">
|
| 600 |
+
<span class="demo-num">3</span>
|
| 601 |
+
<div class="demo-text">
|
| 602 |
+
<strong>Two orthogonal improvements</strong> β F1 curve 0.24β0.79 <em>and</em> belief calibration score rising together over 200 episodes
|
| 603 |
+
</div>
|
| 604 |
+
</div>
|
| 605 |
+
<div class="demo-card">
|
| 606 |
+
<span class="demo-num">4</span>
|
| 607 |
+
<div class="demo-text">
|
| 608 |
+
<strong>Learning is legible</strong> β side-by-side: untrained scattershots 6 nodes vs trained agent stops when P > 0.85 with 2 precise quarantines
|
| 609 |
+
</div>
|
| 610 |
+
</div>
|
| 611 |
+
</div>
|
| 612 |
+
</div>
|
| 613 |
+
|
| 614 |
+
</div>
|
| 615 |
+
|
| 616 |
+
<footer class="page-footer">
|
| 617 |
+
<span>RecallTrace</span> Β· Causal Inference Under Adversarial Self-Play Β· Themes 3.1 + 4 + 1
|
| 618 |
+
</footer>
|
| 619 |
+
|
| 620 |
+
</body>
|
| 621 |
+
</html>
|
server/static/fretfch.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"dataset_name": "fretfch",
|
| 3 |
+
"scenarios": [
|
| 4 |
+
{
|
| 5 |
+
"node_count": 8,
|
| 6 |
+
"contamination_type": "mixing_event",
|
| 7 |
+
"graph_region": "midstream",
|
| 8 |
+
"description": "Midstream mixing of multiple lots (Difficulty: Medium)"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"node_count": 12,
|
| 12 |
+
"contamination_type": "lot_relabel",
|
| 13 |
+
"graph_region": "downstream",
|
| 14 |
+
"description": "Downstream relabeling by a distributor (Difficulty: Hard)"
|
| 15 |
+
},
|
| 16 |
+
{
|
| 17 |
+
"node_count": 6,
|
| 18 |
+
"contamination_type": "source_contamination",
|
| 19 |
+
"graph_region": "upstream",
|
| 20 |
+
"description": "Simple upstream source contamination (Difficulty: Easy)"
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"node_count": 15,
|
| 24 |
+
"contamination_type": "record_deletion",
|
| 25 |
+
"graph_region": "midstream",
|
| 26 |
+
"description": "Missing records mid-graph (Difficulty: Expert)"
|
| 27 |
+
},
|
| 28 |
+
{
|
| 29 |
+
"node_count": 10,
|
| 30 |
+
"contamination_type": "mixing_event",
|
| 31 |
+
"graph_region": "upstream",
|
| 32 |
+
"description": "Early stage mixing event (Difficulty: Medium)"
|
| 33 |
+
}
|
| 34 |
+
]
|
| 35 |
+
}
|
server/static/index.html
CHANGED
|
@@ -1,149 +1,829 @@
|
|
| 1 |
-
|
| 2 |
<html lang="en">
|
|
|
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>RecallTrace
|
|
|
|
|
|
|
| 7 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
-
<link
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
</head>
|
|
|
|
| 12 |
<body>
|
|
|
|
|
|
|
|
|
|
| 13 |
<div class="page-shell">
|
| 14 |
-
<
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
<
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</div>
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
<
|
| 32 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
</div>
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
</div>
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
</div>
|
| 42 |
</div>
|
| 43 |
-
</header>
|
| 44 |
|
| 45 |
-
|
| 46 |
-
<
|
| 47 |
<div class="panel-header">
|
| 48 |
-
<h2>
|
| 49 |
-
<p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
</div>
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
</div>
|
| 61 |
</div>
|
| 62 |
-
|
| 63 |
-
</section>
|
| 64 |
|
| 65 |
-
<
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
</div>
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
</div>
|
| 75 |
-
<div class="score-
|
| 76 |
-
<
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
</div>
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
</div>
|
| 83 |
-
<div class="
|
| 84 |
-
<
|
| 85 |
-
<
|
| 86 |
</div>
|
| 87 |
</div>
|
| 88 |
-
<div id="all-results" class="all-results empty-state">Run all tasks to compare easy, medium, and hard performance.</div>
|
| 89 |
-
</section>
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
<
|
| 94 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
</div>
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</div>
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
</div>
|
| 106 |
</div>
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</div>
|
| 109 |
-
</
|
| 110 |
|
| 111 |
-
<
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
</div>
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
<
|
| 119 |
</div>
|
| 120 |
-
<div class="
|
| 121 |
-
<
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
</div>
|
| 124 |
-
<div class="
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
</div>
|
| 128 |
</div>
|
| 129 |
-
</
|
|
|
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
<
|
| 138 |
-
|
| 139 |
-
<
|
| 140 |
-
<
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
</div>
|
| 147 |
-
|
|
|
|
| 148 |
</body>
|
|
|
|
| 149 |
</html>
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
<html lang="en">
|
| 3 |
+
|
| 4 |
<head>
|
| 5 |
<meta charset="UTF-8">
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 7 |
+
<title>RecallTrace β Causal Inference via Adversarial Self-Play</title>
|
| 8 |
+
<meta name="description"
|
| 9 |
+
content="An RL agent that learns to infer hidden causal interventions in supply-chain contamination through adversarial self-play. Built for Meta PyTorch OpenEnv Hackathon.">
|
| 10 |
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 11 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 12 |
+
<link
|
| 13 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700&display=swap"
|
| 14 |
+
rel="stylesheet">
|
| 15 |
+
<link rel="stylesheet" href="/static/styles.css?v=15">
|
| 16 |
</head>
|
| 17 |
+
|
| 18 |
<body>
|
| 19 |
+
<!-- Particle canvas background -->
|
| 20 |
+
<canvas id="particles-canvas"></canvas>
|
| 21 |
+
|
| 22 |
<div class="page-shell">
|
| 23 |
+
<!-- ===== HERO ===== -->
|
| 24 |
+
<header class="hero" id="hero">
|
| 25 |
+
<div class="hero-glow"></div>
|
| 26 |
+
<div class="hero-layout">
|
| 27 |
+
<div class="hero-content">
|
| 28 |
+
<h1 class="animate-in delay-1">
|
| 29 |
+
<span class="gradient-text">RecallTrace</span>
|
| 30 |
+
</h1>
|
| 31 |
+
<p class="hero-subtitle animate-in delay-2">Causal Inference via Adversarial Self-Play</p>
|
| 32 |
+
<p class="hero-desc animate-in delay-3">
|
| 33 |
+
An RL agent that doesn't just detect contamination β it infers the
|
| 34 |
+
<strong>hidden causal intervention</strong> behind it. Trained via adversarial
|
| 35 |
+
self-play where an adversary learns to hide better as the investigator reasons better.
|
| 36 |
+
</p>
|
| 37 |
+
<div class="hero-stats animate-in delay-4">
|
| 38 |
+
<div class="stat-pill">
|
| 39 |
+
<span class="stat-value" id="stat-f1">0.95+</span>
|
| 40 |
+
<span class="stat-label">F1 Score</span>
|
| 41 |
+
</div>
|
| 42 |
+
<div class="stat-pill">
|
| 43 |
+
<span class="stat-value" id="stat-nodes">3.1</span>
|
| 44 |
+
<span class="stat-label">Nodes/Episode</span>
|
| 45 |
+
</div>
|
| 46 |
+
<div class="stat-pill">
|
| 47 |
+
<span class="stat-value" id="stat-time"><2s</span>
|
| 48 |
+
<span class="stat-label">CPU Training</span>
|
| 49 |
+
</div>
|
| 50 |
+
<div class="stat-pill">
|
| 51 |
+
<span class="stat-value" id="stat-episodes">200</span>
|
| 52 |
+
<span class="stat-label">Episodes</span>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
<div class="hero-actions animate-in delay-5">
|
| 56 |
+
<button class="btn btn-primary btn-glow" id="btn-run-simulation" onclick="switchTab('simulation')">
|
| 57 |
+
<span class="btn-icon">βΆ</span> Run Simulation
|
| 58 |
+
</button>
|
| 59 |
+
<button class="btn btn-outline" onclick="switchTab('llmagent')">
|
| 60 |
+
<span class="btn-icon">π€</span> Live LLM Demo
|
| 61 |
+
</button>
|
| 62 |
+
</div>
|
| 63 |
</div>
|
| 64 |
+
|
| 65 |
+
<div class="hero-visual animate-in delay-3">
|
| 66 |
+
<div class="glass-orb orb-1"></div>
|
| 67 |
+
<div class="glass-orb orb-2"></div>
|
| 68 |
+
<div class="hero-card">
|
| 69 |
+
<div class="hc-header">
|
| 70 |
+
<span class="hc-dot"></span>
|
| 71 |
+
<span>GPU Inference Status</span>
|
| 72 |
+
</div>
|
| 73 |
+
<div class="hc-body">
|
| 74 |
+
<div class="hc-line"><span>Engine</span> <strong>T4 GPU</strong></div>
|
| 75 |
+
<div class="hc-line"><span>Base Model</span> <strong>Qwen2.5-0.5B-Instruct</strong></div>
|
| 76 |
+
<div class="hc-line"><span>LoRA Adapter</span> <strong>RecallTrace (r=16)</strong></div>
|
| 77 |
+
<div class="hc-line"><span>Precision</span> <strong>4-bit (bitsandbytes)</strong></div>
|
| 78 |
+
<div class="hc-line hc-success">β
System Online & Ready</div>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
</div>
|
| 82 |
+
</div>
|
| 83 |
+
</header>
|
| 84 |
+
|
| 85 |
+
<!-- ===== TAB NAV ===== -->
|
| 86 |
+
<nav class="tab-nav" id="tab-nav">
|
| 87 |
+
<button class="tab-btn active" data-tab="training" onclick="switchTab('training')">
|
| 88 |
+
<span class="tab-icon">π</span> Gradio Dashboard
|
| 89 |
+
</button>
|
| 90 |
+
<button class="tab-btn" data-tab="simulation" onclick="switchTab('simulation')">
|
| 91 |
+
<span class="tab-icon">π§ </span> Adversarial Engine
|
| 92 |
+
</button>
|
| 93 |
+
<button class="tab-btn" data-tab="llmagent" onclick="switchTab('llmagent')">
|
| 94 |
+
<span class="tab-icon">π€</span> LLM Agent
|
| 95 |
+
</button>
|
| 96 |
+
<button class="tab-btn" data-tab="openenv" onclick="switchTab('openenv')">
|
| 97 |
+
<span class="tab-icon">β‘</span> OpenEnv Runner
|
| 98 |
+
</button>
|
| 99 |
+
<button class="tab-btn" data-tab="about" onclick="switchTab('about')">
|
| 100 |
+
<span class="tab-icon">π</span> About
|
| 101 |
+
</button>
|
| 102 |
+
</nav>
|
| 103 |
+
|
| 104 |
+
<!-- ===== ADVERSARIAL ENGINE TAB ===== -->
|
| 105 |
+
<section class="tab-content" id="tab-simulation">
|
| 106 |
+
<div class="sim-grid">
|
| 107 |
+
<!-- Left: Graph Visualization -->
|
| 108 |
+
<div class="panel glass-panel">
|
| 109 |
+
<div class="panel-header">
|
| 110 |
+
<h2>Supply-Chain Graph</h2>
|
| 111 |
+
<div class="panel-badge" id="sim-status-badge">Ready</div>
|
| 112 |
+
</div>
|
| 113 |
+
<div class="graph-container" id="graph-container">
|
| 114 |
+
<svg id="graph-svg" viewBox="0 0 800 500" preserveAspectRatio="xMidYMid meet">
|
| 115 |
+
<defs>
|
| 116 |
+
<filter id="glow">
|
| 117 |
+
<feGaussianBlur stdDeviation="3" result="coloredBlur" />
|
| 118 |
+
<feMerge>
|
| 119 |
+
<feMergeNode in="coloredBlur" />
|
| 120 |
+
<feMergeNode in="SourceGraphic" />
|
| 121 |
+
</feMerge>
|
| 122 |
+
</filter>
|
| 123 |
+
<filter id="glow-strong">
|
| 124 |
+
<feGaussianBlur stdDeviation="6" result="coloredBlur" />
|
| 125 |
+
<feMerge>
|
| 126 |
+
<feMergeNode in="coloredBlur" />
|
| 127 |
+
<feMergeNode in="SourceGraphic" />
|
| 128 |
+
</feMerge>
|
| 129 |
+
</filter>
|
| 130 |
+
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
| 131 |
+
<polygon points="0 0, 10 3.5, 0 7" fill="rgba(255,255,255,0.2)" />
|
| 132 |
+
</marker>
|
| 133 |
+
<marker id="arrowhead-active" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
| 134 |
+
<polygon points="0 0, 10 3.5, 0 7" fill="#58a6ff" />
|
| 135 |
+
</marker>
|
| 136 |
+
<linearGradient id="contam-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
| 137 |
+
<stop offset="0%" style="stop-color:#ff6b6b;stop-opacity:0.4" />
|
| 138 |
+
<stop offset="100%" style="stop-color:#da3633;stop-opacity:0.1" />
|
| 139 |
+
</linearGradient>
|
| 140 |
+
</defs>
|
| 141 |
+
<g id="graph-edges"></g>
|
| 142 |
+
<g id="graph-nodes"></g>
|
| 143 |
+
<g id="graph-labels"></g>
|
| 144 |
+
<g id="graph-overlays"></g>
|
| 145 |
+
</svg>
|
| 146 |
+
<!-- Legend -->
|
| 147 |
+
<div class="graph-legend">
|
| 148 |
+
<div class="legend-item"><span class="legend-dot"
|
| 149 |
+
style="background:#21262d;border:2px solid #444c56"></span> Unvisited</div>
|
| 150 |
+
<div class="legend-item"><span class="legend-dot"
|
| 151 |
+
style="background:#2d2a1a;border:2px solid #f0c040"></span> Visited</div>
|
| 152 |
+
<div class="legend-item"><span class="legend-dot"
|
| 153 |
+
style="background:#da3633;border:2px solid #ff6b6b"></span> Quarantined</div>
|
| 154 |
+
<div class="legend-item"><span class="legend-dot"
|
| 155 |
+
style="background:#1a3a2a;border:2px solid #2ea043"></span> Safe</div>
|
| 156 |
+
<div class="legend-item"><span class="legend-ring"></span> Hidden contamination</div>
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
</div>
|
| 160 |
+
|
| 161 |
+
<!-- Right: Controls + Belief State -->
|
| 162 |
+
<div class="sim-right">
|
| 163 |
+
<!-- Controls -->
|
| 164 |
+
<div class="panel glass-panel">
|
| 165 |
+
<div class="panel-header">
|
| 166 |
+
<h2>Controls</h2>
|
| 167 |
+
</div>
|
| 168 |
+
<div class="control-group">
|
| 169 |
+
<div class="control-row">
|
| 170 |
+
<label class="control-label">Episodes</label>
|
| 171 |
+
<input type="range" id="episode-slider" min="50" max="500" value="200" step="50" class="range-input">
|
| 172 |
+
<span class="range-value" id="episode-value">200</span>
|
| 173 |
+
</div>
|
| 174 |
+
<div class="control-row">
|
| 175 |
+
<label class="control-label">Graph Nodes</label>
|
| 176 |
+
<input type="range" id="nodes-slider" min="6" max="20" value="10" step="2" class="range-input">
|
| 177 |
+
<span class="range-value" id="nodes-value">10</span>
|
| 178 |
+
</div>
|
| 179 |
+
<div class="btn-group">
|
| 180 |
+
<button class="btn btn-primary btn-glow" id="btn-train" onclick="runSelfPlay()">
|
| 181 |
+
<span class="btn-icon">π</span> Train Self-Play
|
| 182 |
+
</button>
|
| 183 |
+
<button class="btn btn-secondary" id="btn-replay" onclick="runReplay()">
|
| 184 |
+
<span class="btn-icon">π</span> Before/After
|
| 185 |
+
</button>
|
| 186 |
+
</div>
|
| 187 |
+
</div>
|
| 188 |
+
<!-- Progress -->
|
| 189 |
+
<div class="progress-container hidden" id="progress-container">
|
| 190 |
+
<div class="progress-bar">
|
| 191 |
+
<div class="progress-fill" id="progress-fill"></div>
|
| 192 |
+
</div>
|
| 193 |
+
<span class="progress-text" id="progress-text">Training...</span>
|
| 194 |
+
</div>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<!-- Belief State -->
|
| 198 |
+
<div class="panel glass-panel">
|
| 199 |
+
<div class="panel-header">
|
| 200 |
+
<h2>Belief State</h2>
|
| 201 |
+
<div class="panel-badge" id="belief-step">Step 0</div>
|
| 202 |
+
</div>
|
| 203 |
+
<div class="belief-bars" id="belief-bars">
|
| 204 |
+
<div class="belief-empty">Run simulation to see belief state</div>
|
| 205 |
+
</div>
|
| 206 |
+
</div>
|
| 207 |
+
|
| 208 |
+
<!-- Episode Stats -->
|
| 209 |
+
<div class="panel glass-panel">
|
| 210 |
+
<div class="panel-header">
|
| 211 |
+
<h2>Episode Stats</h2>
|
| 212 |
+
</div>
|
| 213 |
+
<div class="stats-grid" id="episode-stats">
|
| 214 |
+
<div class="mini-stat">
|
| 215 |
+
<span class="mini-stat-label">F1 Score</span>
|
| 216 |
+
<span class="mini-stat-value" id="ep-f1">β</span>
|
| 217 |
+
</div>
|
| 218 |
+
<div class="mini-stat">
|
| 219 |
+
<span class="mini-stat-label">Quarantined</span>
|
| 220 |
+
<span class="mini-stat-value" id="ep-quarantined">β</span>
|
| 221 |
+
</div>
|
| 222 |
+
<div class="mini-stat">
|
| 223 |
+
<span class="mini-stat-label">Steps</span>
|
| 224 |
+
<span class="mini-stat-value" id="ep-steps">β</span>
|
| 225 |
+
</div>
|
| 226 |
+
<div class="mini-stat">
|
| 227 |
+
<span class="mini-stat-label">Intervention</span>
|
| 228 |
+
<span class="mini-stat-value" id="ep-intervention">β</span>
|
| 229 |
+
</div>
|
| 230 |
+
</div>
|
| 231 |
+
</div>
|
| 232 |
</div>
|
| 233 |
</div>
|
|
|
|
| 234 |
|
| 235 |
+
<!-- Before / After Comparison -->
|
| 236 |
+
<div class="panel glass-panel comparison-panel hidden" id="comparison-panel">
|
| 237 |
<div class="panel-header">
|
| 238 |
+
<h2>Before vs After Self-Play Training</h2>
|
| 239 |
+
<p class="panel-subtitle">Investigator behavior change: spray & pray β precision targeting</p>
|
| 240 |
+
</div>
|
| 241 |
+
<div class="comparison-grid">
|
| 242 |
+
<div class="comparison-card bad">
|
| 243 |
+
<div class="comparison-title">
|
| 244 |
+
<span class="comparison-dot red"></span>
|
| 245 |
+
Episode <span id="comp-early-ep">5</span> (Untrained)
|
| 246 |
+
</div>
|
| 247 |
+
<div class="comparison-f1" id="comp-early-f1">F1 = 0.28</div>
|
| 248 |
+
<div class="comparison-stats" id="comp-early-stats"></div>
|
| 249 |
+
</div>
|
| 250 |
+
<div class="comparison-arrow">β</div>
|
| 251 |
+
<div class="comparison-card good">
|
| 252 |
+
<div class="comparison-title">
|
| 253 |
+
<span class="comparison-dot green"></span>
|
| 254 |
+
Episode <span id="comp-late-ep">195</span> (Trained)
|
| 255 |
+
</div>
|
| 256 |
+
<div class="comparison-f1" id="comp-late-f1">F1 = 0.95</div>
|
| 257 |
+
<div class="comparison-stats" id="comp-late-stats"></div>
|
| 258 |
+
</div>
|
| 259 |
</div>
|
| 260 |
+
</div>
|
| 261 |
+
</section>
|
| 262 |
+
|
| 263 |
+
<!-- ===== LLM AGENT TAB ===== -->
|
| 264 |
+
<section class="tab-content" id="tab-llmagent">
|
| 265 |
+
<div class="llm-hero">
|
| 266 |
+
<div class="panel glass-panel">
|
| 267 |
+
<div class="panel-header">
|
| 268 |
+
<h2>π€ Live LLM Agent Demo</h2>
|
| 269 |
+
<div class="panel-badge" id="llm-status-badge">Checking GPU...</div>
|
| 270 |
+
</div>
|
| 271 |
+
<p class="llm-desc">
|
| 272 |
+
Watch the <strong>fine-tuned Qwen2.5-0.5B</strong> model investigate a supply-chain
|
| 273 |
+
contamination in real-time. The model was trained via SFT on 3,500 expert demonstrations
|
| 274 |
+
using <a href="https://github.com/unslothai/unsloth" target="_blank">Unsloth</a> + TRL.
|
| 275 |
+
</p>
|
| 276 |
+
<div class="llm-controls">
|
| 277 |
+
<select id="llm-task-select" class="llm-select">
|
| 278 |
+
<option value="">π² Random Task</option>
|
| 279 |
+
</select>
|
| 280 |
+
<button class="btn btn-primary btn-glow" id="btn-llm-run" onclick="runLLMEpisode()">
|
| 281 |
+
<span class="btn-icon">βΆ</span> Run LLM Agent (Demo)
|
| 282 |
+
</button>
|
| 283 |
+
<button class="btn btn-secondary" id="btn-llm-default-ds" onclick="runDefaultDataset()">
|
| 284 |
+
<span class="btn-icon">β‘</span> Run using fretfch dataset
|
| 285 |
+
</button>
|
| 286 |
+
<button class="btn btn-secondary" id="btn-llm-dataset" onclick="document.getElementById('dataset-file-input').click()">
|
| 287 |
+
<span class="btn-icon">π</span> Upload Dataset
|
| 288 |
+
</button>
|
| 289 |
+
<input type="file" id="dataset-file-input" accept=".json,.csv" style="display:none" onchange="handleDatasetUpload(event)">
|
| 290 |
+
</div>
|
| 291 |
+
<div class="progress-container hidden" id="llm-progress">
|
| 292 |
+
<div class="progress-bar">
|
| 293 |
+
<div class="progress-fill" id="llm-progress-fill"></div>
|
| 294 |
+
</div>
|
| 295 |
+
<span class="progress-text" id="llm-progress-text">Loading model...</span>
|
| 296 |
</div>
|
| 297 |
</div>
|
| 298 |
+
</div>
|
|
|
|
| 299 |
|
| 300 |
+
<!-- Dataset Evaluation Results -->
|
| 301 |
+
<div class="llm-results hidden" id="dataset-results">
|
| 302 |
+
<div class="panel glass-panel">
|
| 303 |
+
<div class="panel-header">
|
| 304 |
+
<h2>π Dataset Evaluation Results</h2>
|
| 305 |
+
<div class="panel-badge" id="dataset-name-badge">β</div>
|
| 306 |
+
</div>
|
| 307 |
+
<div class="score-grid">
|
| 308 |
+
<div class="score-card">
|
| 309 |
+
<span>Dataset</span>
|
| 310 |
+
<strong id="ds-name" style="font-size:0.85em">β</strong>
|
| 311 |
+
</div>
|
| 312 |
+
<div class="score-card">
|
| 313 |
+
<span>Scenarios</span>
|
| 314 |
+
<strong id="ds-count">β</strong>
|
| 315 |
+
</div>
|
| 316 |
+
<div class="score-card">
|
| 317 |
+
<span>Avg F1</span>
|
| 318 |
+
<strong id="ds-f1" style="color:#2ea043">β</strong>
|
| 319 |
+
</div>
|
| 320 |
+
<div class="score-card">
|
| 321 |
+
<span>Avg Reward</span>
|
| 322 |
+
<strong id="ds-reward">β</strong>
|
| 323 |
+
</div>
|
| 324 |
+
</div>
|
| 325 |
+
<div id="ds-scenario-list" class="oe-log-area" style="max-height:300px;overflow-y:auto;"></div>
|
| 326 |
</div>
|
| 327 |
+
</div>
|
| 328 |
+
|
| 329 |
+
<!-- Results -->
|
| 330 |
+
<div class="llm-results hidden" id="llm-results">
|
| 331 |
+
<!-- Score Cards -->
|
| 332 |
+
<div class="panel glass-panel">
|
| 333 |
+
<div class="panel-header">
|
| 334 |
+
<h2>Episode Result</h2>
|
| 335 |
</div>
|
| 336 |
+
<div class="score-grid">
|
| 337 |
+
<div class="score-card">
|
| 338 |
+
<span>Final Score</span>
|
| 339 |
+
<strong id="llm-score" style="color:#2ea043">β</strong>
|
| 340 |
+
</div>
|
| 341 |
+
<div class="score-card">
|
| 342 |
+
<span>Total Reward</span>
|
| 343 |
+
<strong id="llm-reward">β</strong>
|
| 344 |
+
</div>
|
| 345 |
+
<div class="score-card">
|
| 346 |
+
<span>Steps Taken</span>
|
| 347 |
+
<strong id="llm-steps">β</strong>
|
| 348 |
+
</div>
|
| 349 |
+
<div class="score-card">
|
| 350 |
+
<span>Task</span>
|
| 351 |
+
<strong id="llm-task-name" style="font-size:0.85em">β</strong>
|
| 352 |
+
</div>
|
| 353 |
</div>
|
| 354 |
+
</div>
|
| 355 |
+
|
| 356 |
+
<!-- Step-by-Step Log -->
|
| 357 |
+
<div class="panel glass-panel">
|
| 358 |
+
<div class="panel-header">
|
| 359 |
+
<h2>Step-by-Step Agent Actions</h2>
|
| 360 |
+
<p class="panel-subtitle">Each step shows the model's raw JSON output and the action taken</p>
|
| 361 |
+
</div>
|
| 362 |
+
<div id="llm-episode-log" class="oe-log-area"></div>
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
</section>
|
| 366 |
+
|
| 367 |
+
<!-- ===== GRADIO DASHBOARD TAB ===== -->
|
| 368 |
+
<section class="tab-content active" id="tab-training">
|
| 369 |
+
|
| 370 |
+
<!-- Inner Tabs -->
|
| 371 |
+
<nav class="inner-tab-nav" id="gradio-tab-nav">
|
| 372 |
+
<button class="inner-tab-btn active" data-tab="g-heuristic" onclick="switchGradioTab('g-heuristic')">Heuristic Self-Play</button>
|
| 373 |
+
<button class="inner-tab-btn" data-tab="g-rl" onclick="switchGradioTab('g-rl')">PyTorch RL Agent</button>
|
| 374 |
+
<button class="inner-tab-btn" data-tab="g-arch" onclick="switchGradioTab('g-arch')">Architecture</button>
|
| 375 |
+
</nav>
|
| 376 |
+
|
| 377 |
+
<!-- 1. Heuristic Tab -->
|
| 378 |
+
<div class="gradio-tab-content active" id="tab-g-heuristic">
|
| 379 |
+
<h3 class="gradio-section-title">Adaptive Heuristic Agent (200 episodes, ~4s on CPU)</h3>
|
| 380 |
+
<button class="gradio-run-btn" id="btn-run-heuristic" onclick="runGradioHeuristic()">Run Heuristic Training (200 episodes)</button>
|
| 381 |
+
|
| 382 |
+
<div class="gradio-stats-row">
|
| 383 |
+
<div class="gradio-stat-box">
|
| 384 |
+
<label>F1 Score (Early β Late)</label>
|
| 385 |
+
<input type="text" id="g-heu-f1" readonly placeholder="β">
|
| 386 |
</div>
|
| 387 |
+
<div class="gradio-stat-box">
|
| 388 |
+
<label>Quarantined (Early β Late)</label>
|
| 389 |
+
<input type="text" id="g-heu-q" readonly placeholder="β">
|
| 390 |
</div>
|
| 391 |
</div>
|
|
|
|
|
|
|
| 392 |
|
| 393 |
+
<nav class="plot-tab-nav" id="heu-plot-nav">
|
| 394 |
+
<button class="plot-tab-btn active" onclick="switchPlot('heu', 'Training Curves', this)">Training Curves</button>
|
| 395 |
+
<button class="plot-tab-btn" onclick="switchPlot('heu', 'Co-Evolution', this)">Co-Evolution</button>
|
| 396 |
+
<button class="plot-tab-btn" onclick="switchPlot('heu', 'F1 Curve', this)">F1 Curve</button>
|
| 397 |
+
<button class="plot-tab-btn" onclick="switchPlot('heu', 'Belief Calibration', this)">Belief Calibration</button>
|
| 398 |
+
<button class="plot-tab-btn" onclick="switchPlot('heu', 'Episode Comparison', this)">Episode Comparison</button>
|
| 399 |
+
<button class="plot-tab-btn" onclick="switchPlot('heu', 'Training Log', this)">Training Log</button>
|
| 400 |
+
</nav>
|
| 401 |
+
|
| 402 |
+
<div class="plot-container">
|
| 403 |
+
<img id="heu-plot-img" class="gradio-plot-img hidden" src="" />
|
| 404 |
+
<textarea id="heu-plot-log" class="gradio-log hidden" readonly></textarea>
|
| 405 |
+
<div id="heu-plot-placeholder" class="chart-empty">Click "Run Heuristic Training" to generate plots</div>
|
| 406 |
+
</div>
|
| 407 |
+
</div>
|
| 408 |
+
|
| 409 |
+
<!-- 2. PyTorch RL Agent Tab -->
|
| 410 |
+
<div class="gradio-tab-content hidden" id="tab-g-rl">
|
| 411 |
+
<h3 class="gradio-section-title">Neural Policy Network trained with REINFORCE (200 episodes)</h3>
|
| 412 |
+
<button class="gradio-run-btn" id="btn-run-rl" onclick="runGradioRL()">Train PyTorch RL Policy (200 episodes)</button>
|
| 413 |
+
|
| 414 |
+
<div class="gradio-stats-row">
|
| 415 |
+
<div class="gradio-stat-box">
|
| 416 |
+
<label>F1 Score (Early β Late)</label>
|
| 417 |
+
<input type="text" id="g-rl-f1" readonly placeholder="β">
|
| 418 |
+
</div>
|
| 419 |
+
<div class="gradio-stat-box">
|
| 420 |
+
<label>Quarantined (Early β Late)</label>
|
| 421 |
+
<input type="text" id="g-rl-q" readonly placeholder="β">
|
| 422 |
+
</div>
|
| 423 |
+
<div class="gradio-stat-box">
|
| 424 |
+
<label>Final Loss</label>
|
| 425 |
+
<input type="text" id="g-rl-loss" readonly placeholder="β">
|
| 426 |
+
</div>
|
| 427 |
</div>
|
| 428 |
+
|
| 429 |
+
<section class="rl-architecture-panel">
|
| 430 |
+
<div class="rl-architecture-header">
|
| 431 |
+
<span class="section-kicker">PyTorch RL Agent</span>
|
| 432 |
+
<h3>System Architecture</h3>
|
| 433 |
+
</div>
|
| 434 |
+
|
| 435 |
+
<div class="arch-grid">
|
| 436 |
+
<div class="arch-card">
|
| 437 |
+
<h3 class="arch-agent-1">Investigator (Agent 1)</h3>
|
| 438 |
+
<p>Uses 7 tools to investigate. Maintains belief state P(contaminated) per node. Must identify the hidden intervention type before quarantining.</p>
|
| 439 |
+
<div class="tool-badges">
|
| 440 |
+
<span class="tool-badge">inspect_node</span>
|
| 441 |
+
<span class="tool-badge">trace_lot</span>
|
| 442 |
+
<span class="tool-badge">cross_reference</span>
|
| 443 |
+
<span class="tool-badge">request_lab_test</span>
|
| 444 |
+
<span class="tool-badge">quarantine</span>
|
| 445 |
+
<span class="tool-badge">notify</span>
|
| 446 |
+
<span class="tool-badge">finalize</span>
|
| 447 |
+
</div>
|
| 448 |
</div>
|
| 449 |
+
|
| 450 |
+
<div class="arch-card">
|
| 451 |
+
<h3 class="arch-agent-2">Adversary (Agent 2)</h3>
|
| 452 |
+
<p>Chooses which intervention to apply and where, maximizing investigator failure. 18-cell score table (type x region x density) adapts via EMA.</p>
|
| 453 |
+
<div class="tool-badges">
|
| 454 |
+
<span class="adv-badge">lot_relabel</span>
|
| 455 |
+
<span class="adv-badge">mixing_event</span>
|
| 456 |
+
<span class="adv-badge">record_deletion</span>
|
| 457 |
+
</div>
|
| 458 |
</div>
|
| 459 |
</div>
|
| 460 |
+
|
| 461 |
+
<div class="arch-reward-card">
|
| 462 |
+
<h3 class="arch-reward-title">Composable Reward Function (Ungameable)</h3>
|
| 463 |
+
<table class="arch-table">
|
| 464 |
+
<tr><td class="r-recall">Recall</td><td>+2.0 x (unsafe caught / total unsafe)</td><td class="r-desc">Forces finding contamination</td></tr>
|
| 465 |
+
<tr><td class="r-precision">Precision</td><td>-1.5 x (safe blocked / total safe)</td><td class="r-desc">Prevents spray & pray</td></tr>
|
| 466 |
+
<tr><td class="r-calib">Calibration</td><td>+0.3 x (quarantined / total unsafe) if P > 0.8</td><td class="r-desc">Rewards confident decisions</td></tr>
|
| 467 |
+
<tr><td class="r-eff">Efficiency</td><td>-0.05 per step + speed bonus</td><td class="r-desc">Encourages fast investigation</td></tr>
|
| 468 |
+
</table>
|
| 469 |
+
</div>
|
| 470 |
+
|
| 471 |
+
<div class="arch-card rl-network-card">
|
| 472 |
+
<h3 class="arch-rl-title">PyTorch RL Architecture</h3>
|
| 473 |
+
<pre class="arch-pre">
|
| 474 |
+
StateEncoder (112-dim)
|
| 475 |
+
|-- Per-node features (12 nodes x 8 features)
|
| 476 |
+
| inventory, inspected, quarantined, evidence_strength, ...
|
| 477 |
+
|-- Global features (16-dim)
|
| 478 |
+
steps, budget, coverage, urgency, evidence_counts, ...
|
| 479 |
+
|
| 480 |
+
PolicyNetwork (MLP)
|
| 481 |
+
|-- SharedBackbone: Linear(112,128) -> LN -> ReLU -> Linear(128,64) -> LN -> ReLU
|
| 482 |
+
|-- ActionHead: Linear(64, 7) -> Categorical sampling
|
| 483 |
+
|-- NodeHead: Linear(64, 12) -> Categorical sampling
|
| 484 |
+
|-- ValueHead: Linear(64, 1) -> Baseline for variance reduction
|
| 485 |
+
|
| 486 |
+
Training: REINFORCE + learned baseline + entropy regularization
|
| 487 |
+
|-- gamma=0.99, entropy_coef=0.02, lr=3e-4
|
| 488 |
+
|-- Gradient clipping: max_norm=0.5
|
| 489 |
+
</pre>
|
| 490 |
+
</div>
|
| 491 |
+
</section>
|
| 492 |
+
|
| 493 |
+
<nav class="plot-tab-nav" id="rl-plot-nav">
|
| 494 |
+
<button class="plot-tab-btn active" onclick="switchPlot('rl', 'RL Training Curves', this)">RL Training Curves</button>
|
| 495 |
+
<button class="plot-tab-btn" onclick="switchPlot('rl', 'RL Co-Evolution', this)">RL Co-Evolution</button>
|
| 496 |
+
<button class="plot-tab-btn" onclick="switchPlot('rl', 'RL F1 Curve', this)">RL F1 Curve</button>
|
| 497 |
+
<button class="plot-tab-btn" onclick="switchPlot('rl', 'RL Belief Calibration', this)">RL Belief Calibration</button>
|
| 498 |
+
<button class="plot-tab-btn" onclick="switchPlot('rl', 'RL Nodes Quarantined', this)">RL Nodes Quarantined</button>
|
| 499 |
+
<button class="plot-tab-btn" onclick="switchPlot('rl', 'RL Steps To Finalize', this)">RL Steps To Finalize</button>
|
| 500 |
+
<button class="plot-tab-btn" onclick="switchPlot('rl', 'RL Episode Comparison', this)">RL Episode Comparison</button>
|
| 501 |
+
<button class="plot-tab-btn" onclick="switchPlot('rl', 'Training Log', this)">Training Log</button>
|
| 502 |
+
</nav>
|
| 503 |
+
|
| 504 |
+
<div class="plot-container">
|
| 505 |
+
<img id="rl-plot-img" class="gradio-plot-img hidden" src="" />
|
| 506 |
+
<textarea id="rl-plot-log" class="gradio-log hidden" readonly></textarea>
|
| 507 |
+
<div id="rl-plot-placeholder" class="chart-empty">Click "Train PyTorch RL Policy" to generate plots</div>
|
| 508 |
</div>
|
| 509 |
+
</div>
|
| 510 |
|
| 511 |
+
<!-- 3. Architecture Tab -->
|
| 512 |
+
<div class="gradio-tab-content hidden" id="tab-g-arch">
|
| 513 |
+
<div class="arch-container">
|
| 514 |
+
<h2 class="arch-title">System Architecture</h2>
|
| 515 |
+
|
| 516 |
+
<!-- Embedded Architecture Diagram -->
|
| 517 |
+
<div style="background: #0a0a12; border-radius: 16px; border: 1px solid rgba(255,255,255,0.06); overflow: hidden; margin-bottom: 24px;">
|
| 518 |
+
<iframe src="/static/architecture.html" style="width: 100%; height: 700px; border: none; border-radius: 16px;"></iframe>
|
| 519 |
+
</div>
|
| 520 |
+
|
| 521 |
+
<div class="arch-grid">
|
| 522 |
+
<div class="arch-card">
|
| 523 |
+
<h3 class="arch-agent-1">Investigator (Agent 1)</h3>
|
| 524 |
+
<p>Uses 7 tools to investigate. Maintains belief state P(contaminated) per node. Must identify the hidden intervention type before quarantining.</p>
|
| 525 |
+
<div class="tool-badges">
|
| 526 |
+
<span class="tool-badge">inspect_node</span> <span class="tool-badge">trace_lot</span>
|
| 527 |
+
<span class="tool-badge">cross_reference</span> <span class="tool-badge">request_lab_test</span>
|
| 528 |
+
<span class="tool-badge">quarantine</span> <span class="tool-badge">notify</span> <span class="tool-badge">finalize</span>
|
| 529 |
+
</div>
|
| 530 |
+
</div>
|
| 531 |
+
|
| 532 |
+
<div class="arch-card">
|
| 533 |
+
<h3 class="arch-agent-2">Adversary (Agent 2)</h3>
|
| 534 |
+
<p>Chooses which intervention to apply and where, maximizing investigator failure. 18-cell score table (type x region x density) adapts via EMA.</p>
|
| 535 |
+
<div class="tool-badges">
|
| 536 |
+
<span class="adv-badge">lot_relabel</span> <span class="adv-badge">mixing_event</span> <span class="adv-badge">record_deletion</span>
|
| 537 |
+
</div>
|
| 538 |
+
</div>
|
| 539 |
+
</div>
|
| 540 |
+
|
| 541 |
+
<div class="arch-reward-card">
|
| 542 |
+
<h3 class="arch-reward-title">Composable Reward Function (Ungameable)</h3>
|
| 543 |
+
<table class="arch-table">
|
| 544 |
+
<tr><td class="r-recall">Recall</td><td>+2.0 x (unsafe caught / total unsafe)</td><td class="r-desc">Forces finding contamination</td></tr>
|
| 545 |
+
<tr><td class="r-precision">Precision</td><td>-1.5 x (safe blocked / total safe)</td><td class="r-desc">Prevents spray & pray</td></tr>
|
| 546 |
+
<tr><td class="r-calib">Calibration</td><td>+0.3 x (quarantined / total unsafe) if P > 0.8</td><td class="r-desc">Rewards confident decisions</td></tr>
|
| 547 |
+
<tr><td class="r-eff">Efficiency</td><td>-0.05 per step + speed bonus</td><td class="r-desc">Encourages fast investigation</td></tr>
|
| 548 |
+
</table>
|
| 549 |
+
</div>
|
| 550 |
+
|
| 551 |
+
<section class="coevolution-explainer compact" aria-labelledby="arch-coevolution-title">
|
| 552 |
+
<div class="coevolution-heading">
|
| 553 |
+
<span class="section-kicker">Learning Dynamics</span>
|
| 554 |
+
<h3 id="arch-coevolution-title">Adaptive Co-Evolution Loop</h3>
|
| 555 |
+
<p>
|
| 556 |
+
As the Investigator learns, the Adversary reshapes the curriculum. Mastered
|
| 557 |
+
cells are down-weighted, novel attacks are sampled more often, and Matplotlib
|
| 558 |
+
buffers the telemetry into readable training curves.
|
| 559 |
+
</p>
|
| 560 |
+
</div>
|
| 561 |
+
|
| 562 |
+
<div class="coevolution-grid">
|
| 563 |
+
<article class="coevolution-card">
|
| 564 |
+
<span class="card-label">Score Table</span>
|
| 565 |
+
<strong>18 dynamic cells</strong>
|
| 566 |
+
<p>Intervention type x graph region x density bucket.</p>
|
| 567 |
+
</article>
|
| 568 |
+
<article class="coevolution-card">
|
| 569 |
+
<span class="card-label">Sampler</span>
|
| 570 |
+
<strong>Temperature Softmax</strong>
|
| 571 |
+
<p>Balances pressure on hard cases with exploration of new scenarios.</p>
|
| 572 |
+
</article>
|
| 573 |
+
<article class="coevolution-card">
|
| 574 |
+
<span class="card-label">Feedback</span>
|
| 575 |
+
<strong>High F1 reduces reuse</strong>
|
| 576 |
+
<p>When the Investigator solves a scenario, that cell becomes less likely.</p>
|
| 577 |
+
</article>
|
| 578 |
+
</div>
|
| 579 |
+
|
| 580 |
+
<div class="curve-cards">
|
| 581 |
+
<div class="curve-card">
|
| 582 |
+
<span>RL F1 Curve</span>
|
| 583 |
+
<p>Accuracy expands across episodes.</p>
|
| 584 |
+
</div>
|
| 585 |
+
<div class="curve-card">
|
| 586 |
+
<span>RL Training Curve</span>
|
| 587 |
+
<p>Policy loss is tracked against reward.</p>
|
| 588 |
+
</div>
|
| 589 |
+
<div class="curve-card">
|
| 590 |
+
<span>Co-Evolution Curve</span>
|
| 591 |
+
<p>Adversary success dips as Investigator capability rises.</p>
|
| 592 |
+
</div>
|
| 593 |
+
</div>
|
| 594 |
+
</section>
|
| 595 |
+
|
| 596 |
+
<div class="arch-card">
|
| 597 |
+
<h3 class="arch-rl-title">PyTorch RL Architecture</h3>
|
| 598 |
+
<pre class="arch-pre">
|
| 599 |
+
StateEncoder (112-dim)
|
| 600 |
+
|-- Per-node features (12 nodes x 8 features)
|
| 601 |
+
| inventory, inspected, quarantined, evidence_strength, ...
|
| 602 |
+
|-- Global features (16-dim)
|
| 603 |
+
steps, budget, coverage, urgency, evidence_counts, ...
|
| 604 |
+
|
| 605 |
+
PolicyNetwork (MLP)
|
| 606 |
+
|-- SharedBackbone: Linear(112,128) -> LN -> ReLU -> Linear(128,64) -> LN -> ReLU
|
| 607 |
+
|-- ActionHead: Linear(64, 7) -> Categorical sampling
|
| 608 |
+
|-- NodeHead: Linear(64, 12) -> Categorical sampling
|
| 609 |
+
|-- ValueHead: Linear(64, 1) -> Baseline for variance reduction
|
| 610 |
+
|
| 611 |
+
Training: REINFORCE + learned baseline + entropy regularization
|
| 612 |
+
|-- gamma=0.99, entropy_coef=0.02, lr=3e-4
|
| 613 |
+
|-- Gradient clipping: max_norm=0.5
|
| 614 |
+
</pre>
|
| 615 |
+
</div>
|
| 616 |
+
</div>
|
| 617 |
+
</div>
|
| 618 |
+
</section>
|
| 619 |
+
|
| 620 |
+
<!-- ===== OPENENV RUNNER TAB ===== -->
|
| 621 |
+
<section class="tab-content" id="tab-openenv">
|
| 622 |
+
<div class="openenv-grid">
|
| 623 |
+
<div class="panel glass-panel">
|
| 624 |
+
<div class="panel-header">
|
| 625 |
+
<h2>Task Runner</h2>
|
| 626 |
+
<p class="panel-subtitle">Run the deterministic baseline on OpenEnv tasks</p>
|
| 627 |
+
</div>
|
| 628 |
+
<div class="controls">
|
| 629 |
+
<label class="field">
|
| 630 |
+
<span>Task level</span>
|
| 631 |
+
<select id="task-select"></select>
|
| 632 |
+
</label>
|
| 633 |
+
<div class="btn-group">
|
| 634 |
+
<button id="reset-button" class="btn btn-secondary" onclick="resetTask()">Reset Task</button>
|
| 635 |
+
<button id="run-button" class="btn btn-primary" onclick="runOpenEnvEpisode()">Run Episode</button>
|
| 636 |
+
<button id="run-all-button" class="btn btn-outline" onclick="runAllTasks()">Run All Tasks</button>
|
| 637 |
+
</div>
|
| 638 |
+
</div>
|
| 639 |
+
<div id="task-summary" class="task-summary-box"></div>
|
| 640 |
</div>
|
| 641 |
+
|
| 642 |
+
<div class="panel glass-panel">
|
| 643 |
+
<div class="panel-header">
|
| 644 |
+
<h2>Scoreboard</h2>
|
| 645 |
</div>
|
| 646 |
+
<div class="score-grid">
|
| 647 |
+
<div class="score-card">
|
| 648 |
+
<span>Current score</span>
|
| 649 |
+
<strong id="current-score">β</strong>
|
| 650 |
+
</div>
|
| 651 |
+
<div class="score-card">
|
| 652 |
+
<span>Steps taken</span>
|
| 653 |
+
<strong id="current-steps">β</strong>
|
| 654 |
+
</div>
|
| 655 |
+
<div class="score-card">
|
| 656 |
+
<span>Status</span>
|
| 657 |
+
<strong id="current-status">Ready</strong>
|
| 658 |
+
</div>
|
| 659 |
+
<div class="score-card">
|
| 660 |
+
<span>Average (all tasks)</span>
|
| 661 |
+
<strong id="all-score">β</strong>
|
| 662 |
+
</div>
|
| 663 |
</div>
|
| 664 |
+
<div id="all-results" class="all-results-box">Run all tasks to compare performance.</div>
|
| 665 |
+
</div>
|
| 666 |
+
|
| 667 |
+
<div class="panel glass-panel panel-wide">
|
| 668 |
+
<div class="panel-header">
|
| 669 |
+
<h2>Episode Log</h2>
|
| 670 |
+
</div>
|
| 671 |
+
<div class="oe-layout">
|
| 672 |
+
<div class="oe-visuals">
|
| 673 |
+
<div class="mini-panel-box">
|
| 674 |
+
<h3>Reward Curve</h3>
|
| 675 |
+
<div id="oe-reward-chart" class="oe-chart-area">Run a task to see rewards.</div>
|
| 676 |
+
</div>
|
| 677 |
+
<div class="mini-panel-box">
|
| 678 |
+
<h3>Final Outcome</h3>
|
| 679 |
+
<div id="oe-final-summary" class="oe-summary-area">Scoring highlights appear here.</div>
|
| 680 |
+
</div>
|
| 681 |
+
</div>
|
| 682 |
+
<div id="oe-episode-log" class="oe-log-area">Run a task to populate the trajectory.</div>
|
| 683 |
</div>
|
| 684 |
</div>
|
| 685 |
+
</div>
|
| 686 |
+
</section>
|
| 687 |
|
| 688 |
+
|
| 689 |
+
|
| 690 |
+
<!-- ===== ABOUT TAB ===== -->
|
| 691 |
+
<section class="tab-content" id="tab-about">
|
| 692 |
+
<div class="about-grid">
|
| 693 |
+
<div class="panel glass-panel panel-wide">
|
| 694 |
+
<div class="panel-header">
|
| 695 |
+
<h2>RecallTrace Architecture & Environment Flow</h2>
|
| 696 |
+
</div>
|
| 697 |
+
<div style="padding: 20px; color: #c9d1d9; font-size: 1rem; line-height: 1.6;">
|
| 698 |
+
<p style="margin-bottom: 20px;">The RecallTrace Hugging Face Space operates as a Python-based Gradio application hosting an OpenEnv-compliant causal inference benchmark. At its core, the system runs a two-agent adversarial self-play loop. In this environment, an <strong>Investigator</strong> must identify and isolate a hidden contamination event within a procedurally generated, partially observable supply graph. An opposing <strong>Adversary</strong> intelligently places these interventions to maximize the Investigator's failure rate. The environment enforces an ungameable, composable reward function that computes a final score based on Recall (catching unsafe nodes), Precision (sparing safe nodes), Belief Calibration (making confident decisions), and Efficiency (using fewer steps).</p>
|
| 699 |
+
|
| 700 |
+
<h3 style="color: #f97316; margin-bottom: 12px; font-size: 1.2rem;">The Adaptive Heuristic Search</h3>
|
| 701 |
+
<p style="margin-bottom: 20px;">The Heuristic Investigator serves as an interpretable, fast-adapting baseline. Instead of neural networks, this agent uses dynamic, rule-based heuristics governed by learnable thresholds (e.g., quarantine confidence limits and "trust" in ambiguous lab results). After every episode, the agent calculates its F1 score (the harmonic mean of its precision and recall accuracy). If the F1 score dips, the agent adjusts its internal thresholds using an Exponential Moving Average (EMA). This allows the heuristic search to continuously tune its exploration and exploitation strategies dynamically, finding optimal paths through the causal graph with a very low computational footprint.</p>
|
| 702 |
+
|
| 703 |
+
<h3 style="color: #38bdf8; margin-bottom: 12px; font-size: 1.2rem;">The PyTorch RL Agent</h3>
|
| 704 |
+
<p style="margin-bottom: 20px;">The PyTorch RL Investigator is powered by a Deep Reinforcement Learning policy network. Because the environment's observation space is variable (graphs change size, inventory fluctuates), the architecture utilizes a <code>StateEncoder</code> to map the raw observation dictionaries into a fixed 112-dimensional feature tensor. This tensor is fed into a Multi-Layer Perceptron (MLP) equipped with three distinct output heads: an <strong>Action Head</strong> (to select one of the 7 tools), a <strong>Node Head</strong> (to target a specific node), and a <strong>Value Head</strong> (to predict the baseline reward). The model is trained using the <strong>REINFORCE</strong> algorithm. To ensure stable learning, the Value Head serves as a learned baseline to reduce variance, while an underlying entropy regularization coefficient forces the model to maintain exploration, preventing it from collapsing into trivial behaviors like quarantining every node immediately.</p>
|
| 705 |
+
|
| 706 |
+
<section class="coevolution-explainer" aria-labelledby="coevolution-title">
|
| 707 |
+
<div class="coevolution-heading">
|
| 708 |
+
<span class="section-kicker">Adaptive Curriculum</span>
|
| 709 |
+
<h3 id="coevolution-title">Adversarial Co-Evolution & Plot Generation</h3>
|
| 710 |
+
<p>
|
| 711 |
+
As the Investigator improves, the training environment shifts with it. The
|
| 712 |
+
Adversary samples harder scenarios, then backs away from cells the Investigator has
|
| 713 |
+
already mastered.
|
| 714 |
+
</p>
|
| 715 |
+
</div>
|
| 716 |
+
|
| 717 |
+
<div class="coevolution-grid">
|
| 718 |
+
<article class="coevolution-card">
|
| 719 |
+
<span class="card-label">Attack Sampler</span>
|
| 720 |
+
<strong>18-cell score table</strong>
|
| 721 |
+
<p>Cross-references intervention type, graph region, and density bucket.</p>
|
| 722 |
+
</article>
|
| 723 |
+
<article class="coevolution-card">
|
| 724 |
+
<span class="card-label">Exploration</span>
|
| 725 |
+
<strong>Temperature Softmax</strong>
|
| 726 |
+
<p>Samples attacks probabilistically so the adversary keeps trying fresh patterns.</p>
|
| 727 |
+
</article>
|
| 728 |
+
<article class="coevolution-card">
|
| 729 |
+
<span class="card-label">Adaptation Rule</span>
|
| 730 |
+
<strong>High F1 penalizes the cell</strong>
|
| 731 |
+
<p>Expertly solved scenarios become less likely, pushing the curriculum forward.</p>
|
| 732 |
+
</article>
|
| 733 |
+
</div>
|
| 734 |
+
|
| 735 |
+
<div class="coevolution-flow" aria-label="Co-evolution loop">
|
| 736 |
+
<div class="flow-step">
|
| 737 |
+
<span>01</span>
|
| 738 |
+
<strong>Investigator learns</strong>
|
| 739 |
+
<p>F1 improves as the policy identifies hidden interventions more precisely.</p>
|
| 740 |
+
</div>
|
| 741 |
+
<div class="flow-connector" aria-hidden="true">→</div>
|
| 742 |
+
<div class="flow-step">
|
| 743 |
+
<span>02</span>
|
| 744 |
+
<strong>Adversary reweights</strong>
|
| 745 |
+
<p>Successful cells are penalized and unexplored regions gain sampling pressure.</p>
|
| 746 |
+
</div>
|
| 747 |
+
<div class="flow-connector" aria-hidden="true">→</div>
|
| 748 |
+
<div class="flow-step">
|
| 749 |
+
<span>03</span>
|
| 750 |
+
<strong>Telemetry buffers</strong>
|
| 751 |
+
<p>Matplotlib continuously records accuracy, loss, reward, and adversary success.</p>
|
| 752 |
+
</div>
|
| 753 |
+
</div>
|
| 754 |
+
|
| 755 |
+
<div class="curve-cards">
|
| 756 |
+
<div class="curve-card">
|
| 757 |
+
<span>RL F1 Curve</span>
|
| 758 |
+
<p>Tracks the agent's expanding accuracy across episodes.</p>
|
| 759 |
+
</div>
|
| 760 |
+
<div class="curve-card">
|
| 761 |
+
<span>RL Training Curve</span>
|
| 762 |
+
<p>Compares REINFORCE policy loss against reward.</p>
|
| 763 |
+
</div>
|
| 764 |
+
<div class="curve-card">
|
| 765 |
+
<span>Co-Evolution Curve</span>
|
| 766 |
+
<p>Shows the arms race: adversary success dips as Investigator capability rises.</p>
|
| 767 |
+
</div>
|
| 768 |
+
</div>
|
| 769 |
+
</section>
|
| 770 |
+
</div>
|
| 771 |
+
</div>
|
| 772 |
+
|
| 773 |
+
<div class="panel glass-panel">
|
| 774 |
+
<div class="panel-header">
|
| 775 |
+
<h2>Theme & Architecture</h2>
|
| 776 |
+
</div>
|
| 777 |
+
<div class="theme-cards">
|
| 778 |
+
<div class="theme-card">
|
| 779 |
+
<span class="theme-tag orange">Theme 3.1</span>
|
| 780 |
+
<h3>World Modeling</h3>
|
| 781 |
+
<p>Belief state tracking with P(contaminated) per node. Agent maintains probabilistic world model and
|
| 782 |
+
reasons under uncertainty.</p>
|
| 783 |
+
</div>
|
| 784 |
+
<div class="theme-card">
|
| 785 |
+
<span class="theme-tag teal">Architecture</span>
|
| 786 |
+
<h3>Dual-Agent Causal Inference</h3>
|
| 787 |
+
<p>Investigator and Adversary modules share the same environment loop, reward function, telemetry buffer,
|
| 788 |
+
and PyTorch policy architecture.</p>
|
| 789 |
+
</div>
|
| 790 |
+
</div>
|
| 791 |
+
</div>
|
| 792 |
+
|
| 793 |
+
<div class="panel glass-panel">
|
| 794 |
+
<div class="panel-header">
|
| 795 |
+
<h2>Links</h2>
|
| 796 |
+
</div>
|
| 797 |
+
<div class="link-grid">
|
| 798 |
+
<a href="/health" target="_blank" class="link-card">
|
| 799 |
+
<span class="link-icon">π</span>
|
| 800 |
+
<span>Health Check</span>
|
| 801 |
+
</a>
|
| 802 |
+
<a href="/tasks" target="_blank" class="link-card">
|
| 803 |
+
<span class="link-icon">π</span>
|
| 804 |
+
<span>Task Catalog</span>
|
| 805 |
+
</a>
|
| 806 |
+
<a href="https://github.com/MS-Shamanth/recalltrace-openenv" target="_blank" class="link-card">
|
| 807 |
+
<span class="link-icon">π</span>
|
| 808 |
+
<span>GitHub</span>
|
| 809 |
+
</a>
|
| 810 |
+
<a href="https://github.com/openenvai/openenv" target="_blank" class="link-card">
|
| 811 |
+
<span class="link-icon">π</span>
|
| 812 |
+
<span>OpenEnv</span>
|
| 813 |
+
</a>
|
| 814 |
+
</div>
|
| 815 |
+
</div>
|
| 816 |
+
</div>
|
| 817 |
+
</section>
|
| 818 |
+
|
| 819 |
+
<!-- ===== FOOTER ===== -->
|
| 820 |
+
<footer class="footer">
|
| 821 |
+
<p>RecallTrace β Causal Inference via Adversarial Self-Play</p>
|
| 822 |
+
<p class="footer-sub">Meta PyTorch OpenEnv Hackathon Β· Built by Shamanth</p>
|
| 823 |
+
</footer>
|
| 824 |
</div>
|
| 825 |
+
|
| 826 |
+
<script src="/static/app.js?v=15"></script>
|
| 827 |
</body>
|
| 828 |
+
|
| 829 |
</html>
|
server/static/styles.css
CHANGED
|
@@ -1,499 +1,745 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
--
|
| 4 |
-
--panel
|
| 5 |
-
--
|
| 6 |
-
--
|
| 7 |
-
--
|
| 8 |
-
--
|
| 9 |
-
--
|
| 10 |
-
--
|
| 11 |
-
--
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
body {
|
| 19 |
-
margin: 0;
|
| 20 |
min-height: 100vh;
|
| 21 |
-
background:
|
| 22 |
-
radial-gradient(circle at top left, rgba(255, 111, 60, 0.18), transparent 30%),
|
| 23 |
-
radial-gradient(circle at top right, rgba(56, 211, 159, 0.14), transparent 26%),
|
| 24 |
-
linear-gradient(180deg, #08101d 0%, #050a14 100%);
|
| 25 |
color: var(--text);
|
| 26 |
-
font-family:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
|
| 29 |
.page-shell {
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
-
|
| 35 |
-
.panel {
|
| 36 |
-
border: 1px solid var(--border);
|
| 37 |
background: var(--panel);
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
}
|
| 41 |
|
| 42 |
-
.
|
| 43 |
-
|
| 44 |
-
grid-template-columns: 1.6fr 1fr;
|
| 45 |
-
gap: 24px;
|
| 46 |
-
padding: 28px;
|
| 47 |
-
border-radius: 28px;
|
| 48 |
}
|
| 49 |
|
| 50 |
-
.
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
color: var(--warning);
|
| 54 |
-
font-size: 0.9rem;
|
| 55 |
-
letter-spacing: 0.12em;
|
| 56 |
-
text-transform: uppercase;
|
| 57 |
-
}
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
| 61 |
}
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
|
| 64 |
-
font-size: clamp(2.4rem, 6vw, 4.8rem);
|
| 65 |
-
line-height: 0.95;
|
| 66 |
-
}
|
| 67 |
|
| 68 |
-
|
| 69 |
-
.
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
color: var(--muted);
|
| 75 |
}
|
| 76 |
|
| 77 |
-
.hero-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
}
|
| 82 |
|
| 83 |
-
.
|
| 84 |
-
display:
|
| 85 |
-
flex-wrap: wrap;
|
| 86 |
-
gap: 10px;
|
| 87 |
-
margin-top: 18px;
|
| 88 |
}
|
| 89 |
|
| 90 |
-
.
|
| 91 |
-
padding: 8px 12px;
|
| 92 |
-
border-radius: 999px;
|
| 93 |
-
background: rgba(255, 255, 255, 0.06);
|
| 94 |
-
border: 1px solid var(--border);
|
| 95 |
-
font-size: 0.92rem;
|
| 96 |
-
}
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
}
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
.score-card {
|
| 105 |
-
padding: 18px;
|
| 106 |
-
border-radius: 20px;
|
| 107 |
-
background: var(--panel-strong);
|
| 108 |
-
border: 1px solid var(--border);
|
| 109 |
}
|
|
|
|
|
|
|
| 110 |
|
| 111 |
-
.
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
| 117 |
}
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
.score-card span,
|
| 121 |
-
.field span {
|
| 122 |
-
color: var(--muted);
|
| 123 |
-
font-size: 0.95rem;
|
| 124 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
-
.
|
| 127 |
-
display:
|
| 128 |
-
|
| 129 |
-
gap: 20px;
|
| 130 |
-
margin-top: 20px;
|
| 131 |
}
|
| 132 |
|
| 133 |
-
|
| 134 |
-
padding: 24px;
|
| 135 |
-
border-radius: 24px;
|
| 136 |
-
}
|
| 137 |
|
| 138 |
-
.
|
| 139 |
-
background:
|
| 140 |
-
|
| 141 |
-
|
| 142 |
}
|
| 143 |
|
| 144 |
-
.
|
| 145 |
-
|
| 146 |
-
}
|
| 147 |
|
| 148 |
-
.
|
| 149 |
-
margin-bottom: 18px;
|
| 150 |
-
}
|
| 151 |
|
| 152 |
-
.
|
| 153 |
-
|
|
|
|
|
|
|
| 154 |
}
|
|
|
|
|
|
|
| 155 |
|
| 156 |
-
.
|
| 157 |
-
display: grid;
|
| 158 |
-
gap: 18px;
|
| 159 |
-
}
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
gap: 8px;
|
|
|
|
|
|
|
|
|
|
| 164 |
}
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
}
|
|
|
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
color: var(--text);
|
| 177 |
-
font-weight: 600;
|
| 178 |
-
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03);
|
| 179 |
}
|
|
|
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
}
|
| 185 |
|
| 186 |
-
|
| 187 |
-
background:
|
| 188 |
-
|
| 189 |
}
|
|
|
|
| 190 |
|
| 191 |
-
.
|
| 192 |
-
display: flex;
|
| 193 |
-
flex-wrap: wrap;
|
| 194 |
-
gap: 12px;
|
| 195 |
-
}
|
| 196 |
|
| 197 |
-
.
|
| 198 |
-
border: none;
|
| 199 |
-
border-radius: 16px;
|
| 200 |
-
padding: 14px 18px;
|
| 201 |
-
cursor: pointer;
|
| 202 |
-
transition: transform 0.2s ease, opacity 0.2s ease, box-shadow 0.2s ease;
|
| 203 |
-
}
|
| 204 |
|
| 205 |
-
|
| 206 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
}
|
| 208 |
|
| 209 |
-
.
|
| 210 |
-
|
| 211 |
-
color:
|
| 212 |
-
|
|
|
|
| 213 |
}
|
| 214 |
-
|
| 215 |
-
.
|
| 216 |
-
background: rgba(255,
|
| 217 |
-
|
| 218 |
-
border: 1px solid var(--border);
|
| 219 |
}
|
|
|
|
| 220 |
|
| 221 |
-
.
|
| 222 |
-
|
| 223 |
-
color: #dffff4;
|
| 224 |
-
border: 1px solid rgba(56, 211, 159, 0.24);
|
| 225 |
-
}
|
| 226 |
|
| 227 |
-
|
| 228 |
-
margin-top: 18px;
|
| 229 |
-
padding: 18px;
|
| 230 |
-
border-radius: 18px;
|
| 231 |
-
background: rgba(255, 255, 255, 0.04);
|
| 232 |
-
border: 1px solid var(--border);
|
| 233 |
-
}
|
| 234 |
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
}
|
| 238 |
|
| 239 |
-
.
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
gap: 12px;
|
| 243 |
}
|
| 244 |
|
| 245 |
-
|
| 246 |
-
padding: 18px;
|
| 247 |
-
border: 1px dashed rgba(255, 255, 255, 0.16);
|
| 248 |
-
border-radius: 18px;
|
| 249 |
-
background: rgba(255, 255, 255, 0.03);
|
| 250 |
-
}
|
| 251 |
|
| 252 |
-
.
|
| 253 |
-
display:
|
| 254 |
-
|
| 255 |
-
gap: 22px;
|
| 256 |
-
align-items: start;
|
| 257 |
}
|
| 258 |
-
|
| 259 |
-
.
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
top: 16px;
|
| 264 |
}
|
| 265 |
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
}
|
| 272 |
|
| 273 |
-
|
| 274 |
-
.
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
}
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
padding-right: 10px;
|
| 296 |
}
|
|
|
|
|
|
|
| 297 |
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
border: 1px solid var(--border);
|
| 304 |
-
}
|
| 305 |
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
}
|
| 311 |
|
| 312 |
-
.
|
| 313 |
-
|
| 314 |
-
stroke-width: 1;
|
| 315 |
}
|
| 316 |
|
| 317 |
-
.
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
stroke-dasharray: 4 4;
|
| 321 |
}
|
| 322 |
|
| 323 |
-
.
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
| 329 |
}
|
| 330 |
|
| 331 |
-
.
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
|
|
|
| 335 |
}
|
| 336 |
|
| 337 |
-
.
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
|
|
|
|
|
|
| 341 |
}
|
| 342 |
|
| 343 |
-
.
|
|
|
|
| 344 |
display: grid;
|
|
|
|
| 345 |
gap: 12px;
|
| 346 |
}
|
| 347 |
|
| 348 |
-
.
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
border: 1px solid
|
|
|
|
|
|
|
| 353 |
}
|
| 354 |
|
| 355 |
-
.
|
|
|
|
|
|
|
| 356 |
display: block;
|
| 357 |
-
|
| 358 |
-
font-size: 0.
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
.summary-grid {
|
| 362 |
-
display: grid;
|
| 363 |
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 364 |
-
gap: 10px;
|
| 365 |
}
|
| 366 |
|
| 367 |
-
.
|
| 368 |
-
|
| 369 |
-
border-radius: 14px;
|
| 370 |
-
background: rgba(255, 255, 255, 0.05);
|
| 371 |
-
border: 1px solid var(--border);
|
| 372 |
}
|
| 373 |
|
| 374 |
-
.
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
font-size: 0.
|
| 378 |
-
margin-bottom: 6px;
|
| 379 |
}
|
| 380 |
|
| 381 |
-
.
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
width: 10px;
|
| 388 |
}
|
| 389 |
|
| 390 |
-
.
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
border
|
|
|
|
|
|
|
| 394 |
}
|
| 395 |
|
| 396 |
-
.
|
| 397 |
-
|
| 398 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
}
|
| 400 |
|
| 401 |
-
.
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
}
|
| 404 |
|
| 405 |
-
.
|
| 406 |
-
border-
|
| 407 |
-
|
| 408 |
}
|
| 409 |
|
| 410 |
-
.
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
}
|
| 413 |
|
| 414 |
-
.
|
| 415 |
display: flex;
|
| 416 |
-
|
| 417 |
-
gap:
|
| 418 |
-
align-items: center;
|
| 419 |
-
margin-bottom: 10px;
|
| 420 |
}
|
| 421 |
|
| 422 |
-
.
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
color: #ffd6c5;
|
| 427 |
-
border: 1px solid rgba(255, 111, 60, 0.22);
|
| 428 |
-
font-size: 0.76rem;
|
| 429 |
-
text-transform: uppercase;
|
| 430 |
-
letter-spacing: 0.08em;
|
| 431 |
-
white-space: nowrap;
|
| 432 |
}
|
| 433 |
|
| 434 |
-
.
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
color: var(--
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
}
|
| 439 |
|
| 440 |
-
.
|
| 441 |
-
|
| 442 |
-
|
| 443 |
}
|
| 444 |
|
| 445 |
-
.
|
| 446 |
-
|
| 447 |
-
border
|
| 448 |
-
|
| 449 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
}
|
| 451 |
|
| 452 |
-
.
|
| 453 |
-
|
|
|
|
|
|
|
|
|
|
| 454 |
color: var(--muted);
|
| 455 |
-
line-height: 1.6;
|
| 456 |
}
|
| 457 |
|
| 458 |
-
.
|
|
|
|
| 459 |
color: var(--text);
|
| 460 |
-
font-weight: 700;
|
| 461 |
}
|
| 462 |
|
| 463 |
-
.
|
| 464 |
-
|
| 465 |
-
|
| 466 |
}
|
| 467 |
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
}
|
| 472 |
|
| 473 |
-
|
| 474 |
-
|
|
|
|
|
|
|
|
|
|
| 475 |
}
|
| 476 |
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
|
|
|
| 481 |
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
.score-grid {
|
| 492 |
-
grid-template-columns: 1fr;
|
| 493 |
-
}
|
| 494 |
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ===== DESIGN TOKENS ===== */
|
| 2 |
+
:root {
|
| 3 |
+
--bg: #06090f;
|
| 4 |
+
--panel: rgba(14, 20, 32, 0.85);
|
| 5 |
+
--panel-border: rgba(255,255,255,0.07);
|
| 6 |
+
--text: #e8edf5;
|
| 7 |
+
--muted: #8b95a8;
|
| 8 |
+
--accent: #ff6f3c;
|
| 9 |
+
--accent2: #38d39f;
|
| 10 |
+
--red: #da3633;
|
| 11 |
+
--green: #2ea043;
|
| 12 |
+
--blue: #58a6ff;
|
| 13 |
+
--amber: #f0c040;
|
| 14 |
+
--font: 'Inter', system-ui, sans-serif;
|
| 15 |
+
--mono: 'JetBrains Mono', monospace;
|
| 16 |
+
--radius: 20px;
|
| 17 |
+
--glass: blur(18px) saturate(1.6);
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 21 |
|
| 22 |
body {
|
|
|
|
| 23 |
min-height: 100vh;
|
| 24 |
+
background: linear-gradient(170deg, #080c16 0%, #060a12 50%, #0a0e18 100%);
|
|
|
|
|
|
|
|
|
|
| 25 |
color: var(--text);
|
| 26 |
+
font-family: var(--font);
|
| 27 |
+
overflow-x: hidden;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
#particles-canvas {
|
| 31 |
+
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
|
| 32 |
+
z-index: 0; pointer-events: none; opacity: 0.5;
|
| 33 |
}
|
| 34 |
|
| 35 |
.page-shell {
|
| 36 |
+
position: relative; z-index: 1;
|
| 37 |
+
width: min(1360px, calc(100% - 32px));
|
| 38 |
+
margin: 0 auto;
|
| 39 |
+
padding: 24px 0 48px;
|
| 40 |
}
|
| 41 |
|
| 42 |
+
/* ===== GLASS PANEL ===== */
|
| 43 |
+
.glass-panel, .panel {
|
|
|
|
| 44 |
background: var(--panel);
|
| 45 |
+
border: 1px solid var(--panel-border);
|
| 46 |
+
border-radius: var(--radius);
|
| 47 |
+
backdrop-filter: var(--glass);
|
| 48 |
+
-webkit-backdrop-filter: var(--glass);
|
| 49 |
+
box-shadow: 0 8px 32px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.04);
|
| 50 |
+
padding: 24px;
|
| 51 |
+
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
| 52 |
}
|
| 53 |
|
| 54 |
+
.glass-panel:hover {
|
| 55 |
+
box-shadow: 0 12px 40px rgba(0,0,0,0.4), inset 0 1px 0 rgba(255,255,255,0.06);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
}
|
| 57 |
|
| 58 |
+
.panel-header { margin-bottom: 18px; }
|
| 59 |
+
.panel-header h2 { font-size: 1.2rem; font-weight: 700; }
|
| 60 |
+
.panel-header p, .panel-subtitle { color: var(--muted); font-size: 0.9rem; margin-top: 4px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
+
.panel-badge {
|
| 63 |
+
display: inline-block; padding: 4px 12px; border-radius: 999px;
|
| 64 |
+
background: rgba(255,255,255,0.06); border: 1px solid var(--panel-border);
|
| 65 |
+
font-size: 0.8rem; font-family: var(--mono); font-weight: 600; color: var(--muted);
|
| 66 |
}
|
| 67 |
+
.panel-badge.green { color: var(--accent2); border-color: rgba(56,211,159,0.3); background: rgba(56,211,159,0.08); }
|
| 68 |
+
.panel-badge.red { color: var(--red); border-color: rgba(218,54,51,0.3); background: rgba(218,54,51,0.08); }
|
| 69 |
|
| 70 |
+
.panel-wide { grid-column: 1 / -1; }
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
+
/* ===== HERO ===== */
|
| 73 |
+
.hero {
|
| 74 |
+
position: relative; padding: 56px 40px 48px; border-radius: 28px;
|
| 75 |
+
background: var(--panel); border: 1px solid var(--panel-border);
|
| 76 |
+
backdrop-filter: var(--glass); overflow: hidden;
|
| 77 |
+
box-shadow: 0 16px 60px rgba(0,0,0,0.4);
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
+
.hero-glow {
|
| 81 |
+
position: absolute; top: -120px; right: -80px; width: 400px; height: 400px;
|
| 82 |
+
background: radial-gradient(circle, rgba(56,211,159,0.15) 0%, transparent 70%);
|
| 83 |
+
pointer-events: none;
|
| 84 |
}
|
| 85 |
|
| 86 |
+
.hero-layout {
|
| 87 |
+
display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: center; position: relative;
|
|
|
|
|
|
|
|
|
|
| 88 |
}
|
| 89 |
|
| 90 |
+
.hero-content { position: relative; max-width: 600px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
+
/* New Hero Visual */
|
| 93 |
+
.hero-visual {
|
| 94 |
+
position: relative; height: 100%; display: flex; justify-content: center; align-items: center;
|
| 95 |
}
|
| 96 |
+
.glass-orb {
|
| 97 |
+
position: absolute; border-radius: 50%; filter: blur(60px); opacity: 0.5;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
}
|
| 99 |
+
.orb-1 { width: 200px; height: 200px; background: var(--accent); top: 10%; right: 10%; }
|
| 100 |
+
.orb-2 { width: 250px; height: 250px; background: var(--accent2); bottom: 10%; left: 10%; }
|
| 101 |
|
| 102 |
+
.hero-card {
|
| 103 |
+
position: relative; z-index: 2; width: 100%; max-width: 380px;
|
| 104 |
+
background: rgba(14, 20, 32, 0.6); border: 1px solid rgba(255,255,255,0.1);
|
| 105 |
+
border-radius: 20px; backdrop-filter: blur(24px) saturate(2);
|
| 106 |
+
padding: 24px; box-shadow: 0 20px 40px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.1);
|
| 107 |
+
transform: perspective(1000px) rotateY(-5deg) rotateX(5deg);
|
| 108 |
+
transition: transform 0.4s ease;
|
| 109 |
}
|
| 110 |
+
.hero-card:hover {
|
| 111 |
+
transform: perspective(1000px) rotateY(0deg) rotateX(0deg) translateY(-5px);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
}
|
| 113 |
+
.hc-header { display: flex; align-items: center; gap: 10px; font-weight: 600; margin-bottom: 20px; border-bottom: 1px solid var(--panel-border); padding-bottom: 12px; }
|
| 114 |
+
.hc-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent2); box-shadow: 0 0 10px var(--accent2); animation: pulse 2s infinite; }
|
| 115 |
+
.hc-body { display: flex; flex-direction: column; gap: 12px; font-size: 0.9rem; }
|
| 116 |
+
.hc-line { display: flex; justify-content: space-between; color: var(--muted); border-bottom: 1px dashed rgba(255,255,255,0.05); padding-bottom: 8px; }
|
| 117 |
+
.hc-line strong { color: var(--text); font-family: var(--mono); font-size: 0.85rem; }
|
| 118 |
+
.hc-success { color: var(--accent2); justify-content: center; font-weight: 600; padding-top: 8px; border-bottom: none; background: rgba(56,211,159,0.1); border-radius: 8px; padding: 10px; margin-top: 4px; }
|
| 119 |
|
| 120 |
+
.eyebrow {
|
| 121 |
+
display: inline-block; margin-bottom: 12px; color: var(--accent);
|
| 122 |
+
font-size: 0.85rem; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase;
|
|
|
|
|
|
|
| 123 |
}
|
| 124 |
|
| 125 |
+
h1 { font-size: clamp(2.6rem, 6vw, 4.2rem); font-weight: 900; line-height: 1; margin-bottom: 8px; }
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
+
.gradient-text {
|
| 128 |
+
background: linear-gradient(135deg, #ff934f 0%, #ff6f3c 40%, #38d39f 100%);
|
| 129 |
+
-webkit-background-clip: text; -webkit-text-fill-color: transparent;
|
| 130 |
+
background-clip: text;
|
| 131 |
}
|
| 132 |
|
| 133 |
+
.hero-subtitle { font-size: 1.3rem; font-weight: 500; color: var(--muted); margin-bottom: 16px; }
|
| 134 |
+
.hero-desc { font-size: 1.05rem; line-height: 1.7; color: var(--muted); max-width: 60ch; margin-bottom: 28px; }
|
| 135 |
+
.hero-desc strong { color: var(--text); }
|
| 136 |
|
| 137 |
+
.hero-stats { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 28px; }
|
|
|
|
|
|
|
| 138 |
|
| 139 |
+
.stat-pill {
|
| 140 |
+
padding: 12px 18px; border-radius: 16px;
|
| 141 |
+
background: rgba(255,255,255,0.04); border: 1px solid var(--panel-border);
|
| 142 |
+
text-align: center; min-width: 100px;
|
| 143 |
}
|
| 144 |
+
.stat-value { display: block; font-size: 1.4rem; font-weight: 800; font-family: var(--mono); color: var(--accent); }
|
| 145 |
+
.stat-label { display: block; font-size: 0.78rem; color: var(--muted); margin-top: 2px; }
|
| 146 |
|
| 147 |
+
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; }
|
|
|
|
|
|
|
|
|
|
| 148 |
|
| 149 |
+
/* ===== BUTTONS ===== */
|
| 150 |
+
.btn {
|
| 151 |
+
display: inline-flex; align-items: center; gap: 8px;
|
| 152 |
+
padding: 12px 22px; border: none; border-radius: 14px;
|
| 153 |
+
font: 600 0.95rem var(--font); cursor: pointer;
|
| 154 |
+
transition: all 0.2s ease; position: relative; overflow: hidden;
|
| 155 |
}
|
| 156 |
+
.btn:hover { transform: translateY(-2px); }
|
| 157 |
+
.btn:active { transform: translateY(0); }
|
| 158 |
+
.btn-icon { font-size: 1rem; }
|
| 159 |
|
| 160 |
+
.btn-primary {
|
| 161 |
+
background: linear-gradient(135deg, #ff934f, #ff6f3c);
|
| 162 |
+
color: #fff; box-shadow: 0 8px 24px rgba(255,111,60,0.25);
|
| 163 |
}
|
| 164 |
+
.btn-primary:hover { box-shadow: 0 12px 32px rgba(255,111,60,0.35); }
|
| 165 |
|
| 166 |
+
.btn-glow::after {
|
| 167 |
+
content: ''; position: absolute; inset: -2px; border-radius: 16px;
|
| 168 |
+
background: linear-gradient(135deg, #ff934f, #ff6f3c);
|
| 169 |
+
z-index: -1; opacity: 0; filter: blur(12px);
|
| 170 |
+
transition: opacity 0.3s;
|
|
|
|
|
|
|
|
|
|
| 171 |
}
|
| 172 |
+
.btn-glow:hover::after { opacity: 0.5; }
|
| 173 |
|
| 174 |
+
.btn-secondary {
|
| 175 |
+
background: rgba(255,255,255,0.07); color: var(--text);
|
| 176 |
+
border: 1px solid var(--panel-border);
|
| 177 |
}
|
| 178 |
|
| 179 |
+
.btn-outline {
|
| 180 |
+
background: transparent; color: var(--accent2);
|
| 181 |
+
border: 1px solid rgba(56,211,159,0.3);
|
| 182 |
}
|
| 183 |
+
.btn-outline:hover { background: rgba(56,211,159,0.08); }
|
| 184 |
|
| 185 |
+
.btn-group { display: flex; gap: 10px; flex-wrap: wrap; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
+
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none !important; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
+
/* ===== TAB NAV ===== */
|
| 190 |
+
.tab-nav {
|
| 191 |
+
display: flex; gap: 4px; margin: 24px 0 20px;
|
| 192 |
+
padding: 6px; border-radius: 18px;
|
| 193 |
+
background: rgba(14,20,32,0.6); border: 1px solid var(--panel-border);
|
| 194 |
+
backdrop-filter: var(--glass);
|
| 195 |
}
|
| 196 |
|
| 197 |
+
.tab-btn {
|
| 198 |
+
flex: 1; padding: 12px 16px; border: none; border-radius: 14px;
|
| 199 |
+
background: transparent; color: var(--muted); font: 600 0.9rem var(--font);
|
| 200 |
+
cursor: pointer; transition: all 0.25s ease;
|
| 201 |
+
display: flex; align-items: center; justify-content: center; gap: 8px;
|
| 202 |
}
|
| 203 |
+
.tab-btn:hover { color: var(--text); background: rgba(255,255,255,0.04); }
|
| 204 |
+
.tab-btn.active {
|
| 205 |
+
color: var(--text); background: rgba(255,255,255,0.08);
|
| 206 |
+
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
|
|
|
|
| 207 |
}
|
| 208 |
+
.tab-icon { font-size: 1rem; }
|
| 209 |
|
| 210 |
+
.tab-content { display: none; animation: fadeIn 0.35s ease; }
|
| 211 |
+
.tab-content.active { display: block; }
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
+
/* ===== SIMULATION TAB ===== */
|
| 216 |
+
.sim-grid { display: grid; grid-template-columns: 1.4fr 1fr; gap: 20px; }
|
| 217 |
+
.sim-right { display: grid; gap: 16px; align-content: start; }
|
| 218 |
|
| 219 |
+
.graph-container {
|
| 220 |
+
position: relative; background: rgba(0,0,0,0.25); border-radius: 16px;
|
| 221 |
+
border: 1px solid rgba(255,255,255,0.05); overflow: hidden;
|
|
|
|
| 222 |
}
|
| 223 |
|
| 224 |
+
#graph-svg { width: 100%; height: 420px; display: block; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
+
.graph-legend {
|
| 227 |
+
display: flex; gap: 16px; padding: 10px 16px; flex-wrap: wrap;
|
| 228 |
+
border-top: 1px solid rgba(255,255,255,0.06); font-size: 0.78rem; color: var(--muted);
|
|
|
|
|
|
|
| 229 |
}
|
| 230 |
+
.legend-item { display: flex; align-items: center; gap: 6px; }
|
| 231 |
+
.legend-dot { width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0; }
|
| 232 |
+
.legend-ring {
|
| 233 |
+
width: 14px; height: 14px; border-radius: 50%; flex-shrink: 0;
|
| 234 |
+
border: 2px dashed #d29922; background: transparent;
|
|
|
|
| 235 |
}
|
| 236 |
|
| 237 |
+
/* Controls */
|
| 238 |
+
.control-group { display: grid; gap: 14px; }
|
| 239 |
+
.control-row { display: flex; align-items: center; gap: 12px; }
|
| 240 |
+
.control-label { font-size: 0.85rem; color: var(--muted); min-width: 90px; }
|
| 241 |
+
.range-input { flex: 1; accent-color: var(--accent); height: 6px; }
|
| 242 |
+
.range-value { font-family: var(--mono); font-size: 0.9rem; font-weight: 700; min-width: 36px; text-align: right; }
|
| 243 |
|
| 244 |
+
/* Progress */
|
| 245 |
+
.progress-container { margin-top: 12px; }
|
| 246 |
+
.progress-bar { height: 6px; border-radius: 3px; background: rgba(255,255,255,0.08); overflow: hidden; }
|
| 247 |
+
.progress-fill {
|
| 248 |
+
height: 100%; width: 0%; border-radius: 3px;
|
| 249 |
+
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
| 250 |
+
transition: width 0.3s ease;
|
| 251 |
+
}
|
| 252 |
+
.progress-text { font-size: 0.8rem; color: var(--muted); margin-top: 6px; display: block; }
|
| 253 |
+
|
| 254 |
+
/* Belief bars */
|
| 255 |
+
.belief-bars { display: grid; gap: 8px; max-height: 260px; overflow-y: auto; }
|
| 256 |
+
.belief-empty { color: var(--muted); font-size: 0.85rem; padding: 12px; text-align: center; }
|
| 257 |
+
|
| 258 |
+
.belief-row { display: grid; grid-template-columns: 100px 1fr 50px; gap: 8px; align-items: center; }
|
| 259 |
+
.belief-name { font-size: 0.8rem; font-family: var(--mono); color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
|
| 260 |
+
.belief-bar-track { height: 8px; border-radius: 4px; background: rgba(255,255,255,0.06); position: relative; overflow: hidden; }
|
| 261 |
+
.belief-bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease, background 0.5s ease; }
|
| 262 |
+
.belief-prob { font-size: 0.8rem; font-family: var(--mono); font-weight: 700; text-align: right; }
|
| 263 |
|
| 264 |
+
/* Stats grid */
|
| 265 |
+
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
| 266 |
+
.mini-stat {
|
| 267 |
+
padding: 12px; border-radius: 14px;
|
| 268 |
+
background: rgba(255,255,255,0.03); border: 1px solid var(--panel-border);
|
| 269 |
+
}
|
| 270 |
+
.mini-stat-label { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 4px; }
|
| 271 |
+
.mini-stat-value { display: block; font-size: 1.1rem; font-weight: 700; font-family: var(--mono); }
|
| 272 |
+
|
| 273 |
+
/* Comparison panel */
|
| 274 |
+
.comparison-panel { margin-top: 20px; }
|
| 275 |
+
.comparison-grid { display: grid; grid-template-columns: 1fr auto 1fr; gap: 20px; align-items: center; }
|
| 276 |
+
.comparison-card {
|
| 277 |
+
padding: 24px; border-radius: 18px;
|
| 278 |
+
background: rgba(255,255,255,0.03); border: 1px solid var(--panel-border);
|
| 279 |
+
}
|
| 280 |
+
.comparison-card.bad { border-color: rgba(218,54,51,0.25); }
|
| 281 |
+
.comparison-card.good { border-color: rgba(46,160,67,0.25); }
|
| 282 |
+
.comparison-title { font-size: 0.9rem; color: var(--muted); display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
| 283 |
+
.comparison-dot { width: 10px; height: 10px; border-radius: 50%; }
|
| 284 |
+
.comparison-dot.red { background: var(--red); }
|
| 285 |
+
.comparison-dot.green { background: var(--green); }
|
| 286 |
+
.comparison-f1 { font-size: 2rem; font-weight: 800; font-family: var(--mono); margin-bottom: 16px; }
|
| 287 |
+
.comparison-card.bad .comparison-f1 { color: var(--red); }
|
| 288 |
+
.comparison-card.good .comparison-f1 { color: var(--green); }
|
| 289 |
+
.comparison-arrow { font-size: 2.5rem; color: var(--muted); text-align: center; }
|
| 290 |
+
.comparison-stats { font-size: 0.85rem; color: var(--muted); line-height: 1.8; font-family: var(--mono); }
|
| 291 |
+
|
| 292 |
+
/* ===== TRAINING CURVES TAB ===== */
|
| 293 |
+
.charts-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
| 294 |
+
.chart-panel { min-height: 300px; }
|
| 295 |
+
.chart-area { position: relative; min-height: 240px; }
|
| 296 |
+
.chart-area svg { width: 100%; height: 240px; display: block; }
|
| 297 |
+
.chart-empty { color: var(--muted); font-size: 0.85rem; padding: 80px 20px; text-align: center; }
|
| 298 |
+
|
| 299 |
+
.summary-panel { margin-top: 20px; }
|
| 300 |
+
.summary-row {
|
| 301 |
+
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
| 302 |
+
gap: 12px;
|
| 303 |
}
|
| 304 |
+
.summary-item {
|
| 305 |
+
padding: 14px; border-radius: 14px;
|
| 306 |
+
background: rgba(255,255,255,0.03); border: 1px solid var(--panel-border);
|
| 307 |
+
text-align: center;
|
|
|
|
| 308 |
}
|
| 309 |
+
.summary-item-label { display: block; font-size: 0.75rem; color: var(--muted); margin-bottom: 4px; }
|
| 310 |
+
.summary-item-value { display: block; font-size: 1.2rem; font-weight: 800; font-family: var(--mono); }
|
| 311 |
|
| 312 |
+
/* ===== OPENENV TAB ===== */
|
| 313 |
+
.openenv-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
| 314 |
+
.controls { display: grid; gap: 14px; }
|
| 315 |
+
.field { display: grid; gap: 6px; }
|
| 316 |
+
.field span { font-size: 0.85rem; color: var(--muted); }
|
|
|
|
|
|
|
| 317 |
|
| 318 |
+
select {
|
| 319 |
+
padding: 12px 14px; border-radius: 14px;
|
| 320 |
+
border: 1px solid var(--panel-border); background: rgba(7,13,24,0.9);
|
| 321 |
+
color: var(--text); font: 600 0.9rem var(--font);
|
| 322 |
+
}
|
| 323 |
+
select:focus { outline: 2px solid rgba(255,111,60,0.4); outline-offset: 2px; }
|
| 324 |
+
select option { background: #0d1525; }
|
| 325 |
+
|
| 326 |
+
.task-summary-box { margin-top: 14px; padding: 14px; border-radius: 14px; background: rgba(255,255,255,0.03); border: 1px solid var(--panel-border); }
|
| 327 |
+
.task-summary-box h3 { margin-bottom: 6px; font-size: 1rem; }
|
| 328 |
+
.task-summary-box p { color: var(--muted); font-size: 0.9rem; }
|
| 329 |
+
|
| 330 |
+
.score-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
| 331 |
+
.score-card { padding: 14px; border-radius: 14px; background: rgba(255,255,255,0.03); border: 1px solid var(--panel-border); }
|
| 332 |
+
.score-card span { display: block; color: var(--muted); font-size: 0.8rem; }
|
| 333 |
+
.score-card strong { display: block; margin-top: 4px; font-size: 1.15rem; font-family: var(--mono); }
|
| 334 |
+
|
| 335 |
+
.all-results-box { margin-top: 14px; font-size: 0.85rem; color: var(--muted); max-height: 200px; overflow-y: auto; }
|
| 336 |
+
|
| 337 |
+
.oe-layout { display: grid; grid-template-columns: 380px 1fr; gap: 18px; }
|
| 338 |
+
.oe-visuals { display: grid; gap: 14px; }
|
| 339 |
+
.mini-panel-box { padding: 14px; border-radius: 14px; background: rgba(255,255,255,0.03); border: 1px solid var(--panel-border); }
|
| 340 |
+
.mini-panel-box h3 { margin-bottom: 8px; font-size: 0.95rem; }
|
| 341 |
+
.oe-chart-area { min-height: 180px; }
|
| 342 |
+
.oe-chart-area svg { width: 100%; height: 180px; }
|
| 343 |
+
.oe-summary-area { font-size: 0.85rem; }
|
| 344 |
+
.oe-log-area {
|
| 345 |
+
max-height: 600px; min-height: 300px; overflow-y: auto; padding: 18px;
|
| 346 |
+
border-radius: 14px; background: rgba(0,0,0,0.2); border: 1px solid var(--panel-border);
|
| 347 |
+
font-family: var(--mono); font-size: 0.85rem; color: var(--muted); line-height: 1.6;
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
.log-step { padding: 14px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
| 351 |
+
.log-step:last-child { border-bottom: none; }
|
| 352 |
+
.log-title { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
| 353 |
+
.log-title strong { color: var(--text); }
|
| 354 |
+
.action-chip {
|
| 355 |
+
padding: 3px 10px; border-radius: 999px; font-size: 0.72rem;
|
| 356 |
+
background: rgba(255,111,60,0.12); color: #ffd6c5;
|
| 357 |
+
border: 1px solid rgba(255,111,60,0.2); text-transform: uppercase; letter-spacing: 0.06em;
|
| 358 |
+
}
|
| 359 |
+
.action-meta { display: grid; gap: 4px; }
|
| 360 |
+
|
| 361 |
+
/* ===== ABOUT TAB ===== */
|
| 362 |
+
.about-grid { display: grid; gap: 20px; }
|
| 363 |
+
.about-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; }
|
| 364 |
+
.about-card {
|
| 365 |
+
padding: 20px; border-radius: 16px;
|
| 366 |
+
background: rgba(255,255,255,0.03); border: 1px solid var(--panel-border);
|
| 367 |
+
transition: transform 0.2s, border-color 0.2s;
|
| 368 |
+
}
|
| 369 |
+
.about-card:hover { transform: translateY(-3px); border-color: rgba(255,255,255,0.12); }
|
| 370 |
+
.about-icon { font-size: 1.8rem; margin-bottom: 10px; }
|
| 371 |
+
.about-card h3 { font-size: 1rem; margin-bottom: 8px; }
|
| 372 |
+
.about-card p { font-size: 0.85rem; color: var(--muted); line-height: 1.6; }
|
| 373 |
+
|
| 374 |
+
.coevolution-explainer {
|
| 375 |
+
margin-top: 24px;
|
| 376 |
+
padding: 22px;
|
| 377 |
+
border: 1px solid rgba(46, 160, 67, 0.22);
|
| 378 |
+
border-radius: 16px;
|
| 379 |
+
background:
|
| 380 |
+
linear-gradient(135deg, rgba(46, 160, 67, 0.08), rgba(88, 166, 255, 0.04)),
|
| 381 |
+
rgba(255,255,255,0.02);
|
| 382 |
}
|
| 383 |
|
| 384 |
+
.coevolution-explainer.compact {
|
| 385 |
+
margin: 0 0 16px;
|
|
|
|
| 386 |
}
|
| 387 |
|
| 388 |
+
.coevolution-heading {
|
| 389 |
+
max-width: 820px;
|
| 390 |
+
margin-bottom: 18px;
|
|
|
|
| 391 |
}
|
| 392 |
|
| 393 |
+
.section-kicker,
|
| 394 |
+
.card-label {
|
| 395 |
+
display: inline-block;
|
| 396 |
+
color: var(--accent2);
|
| 397 |
+
font-size: 0.72rem;
|
| 398 |
+
font-weight: 800;
|
| 399 |
+
letter-spacing: 0.08em;
|
| 400 |
+
text-transform: uppercase;
|
| 401 |
}
|
| 402 |
|
| 403 |
+
.coevolution-heading h3 {
|
| 404 |
+
margin: 6px 0 8px;
|
| 405 |
+
color: #7ee787;
|
| 406 |
+
font-size: 1.35rem;
|
| 407 |
+
line-height: 1.25;
|
| 408 |
}
|
| 409 |
|
| 410 |
+
.coevolution-heading p,
|
| 411 |
+
.coevolution-card p,
|
| 412 |
+
.flow-step p,
|
| 413 |
+
.curve-card p {
|
| 414 |
+
color: var(--muted);
|
| 415 |
+
line-height: 1.65;
|
| 416 |
}
|
| 417 |
|
| 418 |
+
.coevolution-grid,
|
| 419 |
+
.curve-cards {
|
| 420 |
display: grid;
|
| 421 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 422 |
gap: 12px;
|
| 423 |
}
|
| 424 |
|
| 425 |
+
.coevolution-card,
|
| 426 |
+
.curve-card {
|
| 427 |
+
min-width: 0;
|
| 428 |
+
padding: 16px;
|
| 429 |
+
border: 1px solid rgba(255,255,255,0.07);
|
| 430 |
+
border-radius: 12px;
|
| 431 |
+
background: rgba(7, 13, 24, 0.52);
|
| 432 |
}
|
| 433 |
|
| 434 |
+
.coevolution-card strong,
|
| 435 |
+
.curve-card span,
|
| 436 |
+
.flow-step strong {
|
| 437 |
display: block;
|
| 438 |
+
color: var(--text);
|
| 439 |
+
font-size: 0.98rem;
|
| 440 |
+
line-height: 1.35;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
}
|
| 442 |
|
| 443 |
+
.coevolution-card strong {
|
| 444 |
+
margin: 8px 0 6px;
|
|
|
|
|
|
|
|
|
|
| 445 |
}
|
| 446 |
|
| 447 |
+
.coevolution-card p,
|
| 448 |
+
.curve-card p,
|
| 449 |
+
.flow-step p {
|
| 450 |
+
font-size: 0.86rem;
|
|
|
|
| 451 |
}
|
| 452 |
|
| 453 |
+
.coevolution-flow {
|
| 454 |
+
display: grid;
|
| 455 |
+
grid-template-columns: 1fr auto 1fr auto 1fr;
|
| 456 |
+
gap: 12px;
|
| 457 |
+
align-items: stretch;
|
| 458 |
+
margin: 16px 0;
|
|
|
|
| 459 |
}
|
| 460 |
|
| 461 |
+
.flow-step {
|
| 462 |
+
min-width: 0;
|
| 463 |
+
padding: 16px;
|
| 464 |
+
border: 1px solid rgba(88, 166, 255, 0.16);
|
| 465 |
+
border-radius: 12px;
|
| 466 |
+
background: rgba(88, 166, 255, 0.06);
|
| 467 |
}
|
| 468 |
|
| 469 |
+
.flow-step span {
|
| 470 |
+
display: inline-flex;
|
| 471 |
+
width: 32px;
|
| 472 |
+
height: 32px;
|
| 473 |
+
align-items: center;
|
| 474 |
+
justify-content: center;
|
| 475 |
+
margin-bottom: 10px;
|
| 476 |
+
border-radius: 50%;
|
| 477 |
+
background: rgba(56, 211, 159, 0.12);
|
| 478 |
+
color: var(--accent2);
|
| 479 |
+
font-family: var(--mono);
|
| 480 |
+
font-size: 0.78rem;
|
| 481 |
+
font-weight: 800;
|
| 482 |
}
|
| 483 |
|
| 484 |
+
.flow-connector {
|
| 485 |
+
display: flex;
|
| 486 |
+
align-items: center;
|
| 487 |
+
color: var(--accent2);
|
| 488 |
+
font-family: var(--mono);
|
| 489 |
+
opacity: 0.8;
|
| 490 |
}
|
| 491 |
|
| 492 |
+
.curve-card {
|
| 493 |
+
border-color: rgba(240, 192, 64, 0.16);
|
| 494 |
+
background: rgba(240, 192, 64, 0.05);
|
| 495 |
}
|
| 496 |
|
| 497 |
+
.curve-card span {
|
| 498 |
+
margin-bottom: 6px;
|
| 499 |
+
color: #f6d365;
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
.theme-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
| 503 |
+
.theme-card { padding: 20px; border-radius: 16px; background: rgba(255,255,255,0.03); border: 1px solid var(--panel-border); }
|
| 504 |
+
.theme-tag {
|
| 505 |
+
display: inline-block; padding: 3px 10px; border-radius: 8px;
|
| 506 |
+
font-size: 0.75rem; font-weight: 700; margin-bottom: 10px;
|
| 507 |
+
}
|
| 508 |
+
.theme-tag.orange { background: rgba(255,111,60,0.15); color: var(--accent); }
|
| 509 |
+
.theme-tag.teal { background: rgba(56,211,159,0.12); color: var(--accent2); }
|
| 510 |
+
.theme-card h3 { font-size: 1rem; margin-bottom: 6px; }
|
| 511 |
+
.theme-card p { font-size: 0.85rem; color: var(--muted); line-height: 1.6; }
|
| 512 |
+
|
| 513 |
+
.link-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
|
| 514 |
+
.link-card {
|
| 515 |
+
display: flex; flex-direction: column; align-items: center; gap: 6px;
|
| 516 |
+
padding: 16px; border-radius: 14px; text-decoration: none; color: var(--text);
|
| 517 |
+
background: rgba(255,255,255,0.03); border: 1px solid var(--panel-border);
|
| 518 |
+
transition: all 0.2s;
|
| 519 |
+
}
|
| 520 |
+
.link-card:hover { background: rgba(255,255,255,0.06); border-color: rgba(255,255,255,0.12); transform: translateY(-2px); }
|
| 521 |
+
.link-icon { font-size: 1.4rem; }
|
| 522 |
+
.link-card span:last-child { font-size: 0.85rem; }
|
| 523 |
+
|
| 524 |
+
/* ===== LLM AGENT TAB ===== */
|
| 525 |
+
.llm-hero { margin-bottom: 20px; }
|
| 526 |
+
.llm-desc {
|
| 527 |
+
color: var(--muted); font-size: 0.92rem; line-height: 1.7;
|
| 528 |
+
margin: 12px 0 20px; max-width: 700px;
|
| 529 |
+
}
|
| 530 |
+
.llm-desc a { color: var(--accent); text-decoration: none; }
|
| 531 |
+
.llm-desc a:hover { text-decoration: underline; }
|
| 532 |
+
.llm-controls {
|
| 533 |
+
display: flex; gap: 12px; align-items: center; flex-wrap: wrap;
|
| 534 |
+
}
|
| 535 |
+
.llm-select {
|
| 536 |
+
padding: 10px 16px; border-radius: 12px;
|
| 537 |
+
background: rgba(255,255,255,0.06); border: 1px solid var(--panel-border);
|
| 538 |
+
color: var(--text); font-family: var(--font); font-size: 0.9rem;
|
| 539 |
+
outline: none; min-width: 200px; cursor: pointer;
|
| 540 |
+
}
|
| 541 |
+
.llm-select:focus { border-color: var(--accent); }
|
| 542 |
+
.llm-results { display: grid; gap: 20px; }
|
| 543 |
+
.model-output-box {
|
| 544 |
+
margin-top: 8px; padding: 10px 14px; border-radius: 10px;
|
| 545 |
+
background: rgba(0,0,0,0.35); border: 1px solid rgba(255,255,255,0.06);
|
| 546 |
+
font-family: var(--mono); font-size: 0.78rem; color: #a8b2c1;
|
| 547 |
+
word-break: break-all; line-height: 1.5;
|
| 548 |
+
}
|
| 549 |
+
.model-output-label {
|
| 550 |
+
display: block; font-size: 0.7rem; color: var(--muted);
|
| 551 |
+
font-family: var(--font); font-weight: 600; margin-bottom: 4px;
|
| 552 |
+
text-transform: uppercase; letter-spacing: 0.5px;
|
| 553 |
+
}
|
| 554 |
+
.model-output-box code {
|
| 555 |
+
display: block; white-space: pre-wrap; color: #c9d1d9;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
/* ===== FOOTER ===== */
|
| 559 |
+
.footer { text-align: center; padding: 32px 0 0; color: var(--muted); font-size: 0.85rem; }
|
| 560 |
+
.footer-sub { font-size: 0.78rem; margin-top: 4px; opacity: 0.6; }
|
| 561 |
+
|
| 562 |
+
/* ===== ANIMATIONS ===== */
|
| 563 |
+
.animate-in { opacity: 0; transform: translateY(16px); animation: slideUp 0.6s ease forwards; }
|
| 564 |
+
.delay-1 { animation-delay: 0.1s; }
|
| 565 |
+
.delay-2 { animation-delay: 0.2s; }
|
| 566 |
+
.delay-3 { animation-delay: 0.3s; }
|
| 567 |
+
.delay-4 { animation-delay: 0.4s; }
|
| 568 |
+
.delay-5 { animation-delay: 0.5s; }
|
| 569 |
+
|
| 570 |
+
@keyframes slideUp { to { opacity: 1; transform: translateY(0); } }
|
| 571 |
+
|
| 572 |
+
@keyframes pulse {
|
| 573 |
+
0%, 100% { opacity: 0.6; transform: scale(1); }
|
| 574 |
+
50% { opacity: 1; transform: scale(1.05); }
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
.hidden { display: none !important; }
|
| 578 |
+
|
| 579 |
+
/* =========================================================================
|
| 580 |
+
13. Manual Mode
|
| 581 |
+
========================================================================= */
|
| 582 |
+
.manual-controls {
|
| 583 |
+
display: flex;
|
| 584 |
+
flex-direction: column;
|
| 585 |
+
gap: 1rem;
|
| 586 |
}
|
| 587 |
|
| 588 |
+
.input-group {
|
| 589 |
display: flex;
|
| 590 |
+
flex-direction: column;
|
| 591 |
+
gap: 0.5rem;
|
|
|
|
|
|
|
| 592 |
}
|
| 593 |
|
| 594 |
+
.input-group label {
|
| 595 |
+
font-size: 0.85rem;
|
| 596 |
+
color: var(--text-secondary);
|
| 597 |
+
font-weight: 500;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 598 |
}
|
| 599 |
|
| 600 |
+
.input-group select {
|
| 601 |
+
background: rgba(255,255,255,0.05);
|
| 602 |
+
border: 1px solid var(--panel-border);
|
| 603 |
+
color: var(--text);
|
| 604 |
+
padding: 0.75rem;
|
| 605 |
+
border-radius: 6px;
|
| 606 |
+
font-family: var(--font);
|
| 607 |
+
font-size: 0.95rem;
|
| 608 |
+
outline: none;
|
| 609 |
}
|
| 610 |
|
| 611 |
+
.input-group select:focus {
|
| 612 |
+
border-color: var(--accent);
|
| 613 |
+
box-shadow: 0 0 0 2px rgba(255,111,60,0.2);
|
| 614 |
}
|
| 615 |
|
| 616 |
+
.action-log {
|
| 617 |
+
background: rgba(0,0,0,0.3);
|
| 618 |
+
border: 1px solid var(--panel-border);
|
| 619 |
+
border-radius: 6px;
|
| 620 |
+
padding: 1rem;
|
| 621 |
+
height: 250px;
|
| 622 |
+
overflow-y: auto;
|
| 623 |
+
font-family: var(--mono);
|
| 624 |
+
font-size: 0.85rem;
|
| 625 |
+
display: flex;
|
| 626 |
+
flex-direction: column;
|
| 627 |
+
gap: 0.5rem;
|
| 628 |
}
|
| 629 |
|
| 630 |
+
.action-log .log-item {
|
| 631 |
+
padding: 0.5rem;
|
| 632 |
+
background: rgba(255,255,255,0.03);
|
| 633 |
+
border-radius: 4px;
|
| 634 |
+
border-left: 3px solid var(--panel-border);
|
| 635 |
color: var(--muted);
|
|
|
|
| 636 |
}
|
| 637 |
|
| 638 |
+
.action-log .log-item.success {
|
| 639 |
+
border-left-color: var(--success);
|
| 640 |
color: var(--text);
|
|
|
|
| 641 |
}
|
| 642 |
|
| 643 |
+
.action-log .log-item.error {
|
| 644 |
+
border-left-color: var(--danger);
|
| 645 |
+
color: var(--text);
|
| 646 |
}
|
| 647 |
|
| 648 |
+
|
| 649 |
+
/* Scrollbars */
|
| 650 |
+
::-webkit-scrollbar { width: 8px; }
|
| 651 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 652 |
+
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.1); border-radius: 4px; }
|
| 653 |
+
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.18); }
|
| 654 |
+
|
| 655 |
+
/* ===== RESPONSIVE ===== */
|
| 656 |
+
@media (max-width: 1000px) {
|
| 657 |
+
.sim-grid, .charts-grid, .openenv-grid, .theme-cards, .arch-grid { grid-template-columns: 1fr; }
|
| 658 |
+
.coevolution-grid, .curve-cards { grid-template-columns: 1fr; }
|
| 659 |
+
.coevolution-flow { grid-template-columns: 1fr; }
|
| 660 |
+
.flow-connector { justify-content: center; transform: rotate(90deg); }
|
| 661 |
+
.comparison-grid { grid-template-columns: 1fr; }
|
| 662 |
+
.comparison-arrow { transform: rotate(90deg); }
|
| 663 |
+
.oe-layout { grid-template-columns: 1fr; }
|
| 664 |
+
.hero { padding: 32px 20px; }
|
| 665 |
}
|
| 666 |
|
| 667 |
+
@media (max-width: 640px) {
|
| 668 |
+
.tab-btn span:not(.tab-icon) { display: none; }
|
| 669 |
+
.hero-stats { gap: 8px; }
|
| 670 |
+
.stat-pill { min-width: 70px; padding: 8px 12px; }
|
| 671 |
+
.coevolution-explainer { padding: 16px; }
|
| 672 |
}
|
| 673 |
|
| 674 |
+
/* ===== GRADIO STYLE UI ===== */
|
| 675 |
+
.inner-tab-nav { display: flex; gap: 8px; margin-bottom: 24px; border-bottom: 1px solid #30363d; padding-bottom: 8px; }
|
| 676 |
+
.inner-tab-btn { background: none; border: none; color: #8b949e; font-size: 1rem; padding: 8px 16px; cursor: pointer; transition: 0.2s; font-weight: 500; border-radius: 6px; }
|
| 677 |
+
.inner-tab-btn:hover { color: #c9d1d9; background: rgba(255,255,255,0.05); }
|
| 678 |
+
.inner-tab-btn.active { color: #f97316; }
|
| 679 |
|
| 680 |
+
.gradio-tab-content { display: none; }
|
| 681 |
+
.gradio-tab-content.active { display: block; }
|
| 682 |
+
|
| 683 |
+
.gradio-section-title { font-size: 1.25rem; color: #c9d1d9; margin-bottom: 16px; font-weight: 600; }
|
| 684 |
+
|
| 685 |
+
.gradio-run-btn { background: linear-gradient(135deg, #ef4444, #dc2626); color: white; border: none; padding: 16px 32px; font-size: 1.1rem; font-weight: 600; border-radius: 12px; cursor: pointer; margin-bottom: 24px; width: 100%; transition: all 0.3s ease; box-shadow: 0 4px 20px rgba(239, 68, 68, 0.3); }
|
| 686 |
+
.gradio-run-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(239, 68, 68, 0.5); }
|
| 687 |
+
.gradio-run-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }
|
| 688 |
+
|
| 689 |
+
.gradio-stats-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
|
| 690 |
+
#tab-g-rl .gradio-stats-row { grid-template-columns: 1fr 1fr 1fr; }
|
| 691 |
+
.gradio-stat-box { background: linear-gradient(135deg, #161b22, #1c2333); border: 1px solid #30363d; padding: 16px; border-radius: 12px; display: flex; flex-direction: column; gap: 8px; }
|
| 692 |
+
.gradio-stat-box label { color: #8b949e; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 1px; }
|
| 693 |
+
.gradio-stat-box input { background: transparent; border: none; color: #e6edf3; font-size: 1.5rem; font-weight: 700; outline: none; }
|
| 694 |
|
| 695 |
+
.plot-tab-nav { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
|
| 696 |
+
.plot-tab-btn { background: #161b22; border: 1px solid #30363d; color: #8b949e; padding: 8px 16px; border-radius: 6px; cursor: pointer; transition: 0.2s; font-size: 0.9rem; }
|
| 697 |
+
.plot-tab-btn:hover { background: #21262d; color: #c9d1d9; }
|
| 698 |
+
.plot-tab-btn.active { background: #1f6feb; border-color: #388bfd; color: #ffffff; }
|
|
|
|
|
|
|
|
|
|
| 699 |
|
| 700 |
+
.plot-container { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 24px; min-height: 400px; display: flex; align-items: center; justify-content: center; overflow: hidden; }
|
| 701 |
+
.gradio-plot-img { max-width: 100%; max-height: 600px; object-fit: contain; border-radius: 8px; }
|
| 702 |
+
.gradio-log { width: 100%; height: 400px; background: #0d1117; border: 1px solid #30363d; border-radius: 8px; padding: 16px; color: #c9d1d9; font-family: 'JetBrains Mono', monospace; font-size: 0.85rem; resize: none; outline: none; }
|
| 703 |
+
|
| 704 |
+
/* Architecture styling */
|
| 705 |
+
.rl-architecture-panel {
|
| 706 |
+
margin: 0 0 24px;
|
| 707 |
+
padding: 20px;
|
| 708 |
+
border: 1px solid rgba(56, 189, 248, 0.2);
|
| 709 |
+
border-radius: 12px;
|
| 710 |
+
background: linear-gradient(135deg, rgba(56, 189, 248, 0.07), rgba(99, 102, 241, 0.04));
|
| 711 |
+
}
|
| 712 |
+
.rl-architecture-header {
|
| 713 |
+
display: flex;
|
| 714 |
+
align-items: baseline;
|
| 715 |
+
justify-content: space-between;
|
| 716 |
+
gap: 16px;
|
| 717 |
+
margin-bottom: 16px;
|
| 718 |
+
flex-wrap: wrap;
|
| 719 |
+
}
|
| 720 |
+
.rl-architecture-header h3 {
|
| 721 |
+
color: #e6edf3;
|
| 722 |
+
font-size: 1.25rem;
|
| 723 |
+
margin: 0;
|
| 724 |
}
|
| 725 |
+
.arch-container { padding: 8px; }
|
| 726 |
+
.arch-title { font-size: 1.5rem; margin-bottom: 20px; color: #e6edf3; }
|
| 727 |
+
.arch-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
|
| 728 |
+
.arch-card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
| 729 |
+
.arch-reward-card { background: #161b22; border: 1px solid #30363d; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
| 730 |
+
.rl-network-card { margin-bottom: 0; }
|
| 731 |
+
.arch-agent-1 { color: #f97316; font-size: 1.1rem; margin-bottom: 12px; }
|
| 732 |
+
.arch-agent-2 { color: #ef4444; font-size: 1.1rem; margin-bottom: 12px; }
|
| 733 |
+
.arch-reward-title { color: #22c55e; font-size: 1.1rem; margin-bottom: 12px; }
|
| 734 |
+
.arch-rl-title { color: #38bdf8; font-size: 1.1rem; margin-bottom: 12px; }
|
| 735 |
+
.tool-badges { margin-top: 12px; display: flex; flex-wrap: wrap; gap: 6px; }
|
| 736 |
+
.tool-badge { background: rgba(99,102,241,0.15); border: 1px solid rgba(99,102,241,0.3); border-radius: 6px; padding: 2px 8px; font-size: 0.85rem; color: #818cf8; font-family: 'JetBrains Mono', monospace; }
|
| 737 |
+
.adv-badge { background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3); border-radius: 6px; padding: 2px 8px; font-size: 0.85rem; color: #f87171; font-family: 'JetBrains Mono', monospace; }
|
| 738 |
+
.arch-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
| 739 |
+
.arch-table td { padding: 8px; border-bottom: 1px solid #30363d; }
|
| 740 |
+
.r-recall { color: #4ade80; font-weight: 600; }
|
| 741 |
+
.r-precision { color: #ef4444; font-weight: 600; }
|
| 742 |
+
.r-calib { color: #a78bfa; font-weight: 600; }
|
| 743 |
+
.r-eff { color: #f59e0b; font-weight: 600; }
|
| 744 |
+
.r-desc { color: #8b949e; }
|
| 745 |
+
.arch-pre { background: #0d1117; border-radius: 8px; padding: 16px; font-size: 0.85rem; color: #c9d1d9; font-family: 'JetBrains Mono', monospace; overflow-x: auto; line-height: 1.5; }
|
train_trl.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""RecallTrace β LLM Training with Unsloth + TRL
|
| 3 |
+
|
| 4 |
+
Fine-tunes Qwen2.5-0.5B-Instruct on expert demonstrations from the
|
| 5 |
+
RecallTrace supply-chain environment, then evaluates improvement.
|
| 6 |
+
|
| 7 |
+
Quick start (GPU required):
|
| 8 |
+
pip install unsloth "trl>=0.12" datasets accelerate
|
| 9 |
+
python train_trl.py
|
| 10 |
+
|
| 11 |
+
On Google Colab (free T4):
|
| 12 |
+
!pip install unsloth "trl>=0.12" datasets
|
| 13 |
+
!git clone https://huggingface.co/spaces/ms-shamanth/recalltrace-openenv
|
| 14 |
+
%cd recalltrace-openenv
|
| 15 |
+
!python train_trl.py
|
| 16 |
+
|
| 17 |
+
On HF Jobs:
|
| 18 |
+
export HF_TOKEN="hf_..."
|
| 19 |
+
hf jobs uv run train_trl.py --flavor gpu-t4-small --with unsloth --with trl --with datasets
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import argparse
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
import random
|
| 28 |
+
import sys
|
| 29 |
+
import time
|
| 30 |
+
from pathlib import Path
|
| 31 |
+
from typing import Any
|
| 32 |
+
|
| 33 |
+
# Ensure project root is on path
|
| 34 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
| 35 |
+
|
| 36 |
+
from env.env import RecallTraceEnv
|
| 37 |
+
from env.models import RecallAction
|
| 38 |
+
from baseline.policy import choose_heuristic_action
|
| 39 |
+
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
# Constants
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
MODEL_NAME = "unsloth/Qwen2.5-0.5B-Instruct-bnb-4bit"
|
| 44 |
+
OUTPUT_DIR = Path("trained_model")
|
| 45 |
+
PLOTS_DIR = Path("plots")
|
| 46 |
+
HUB_MODEL_ID = "ms-shamanth/recalltrace-investigator"
|
| 47 |
+
|
| 48 |
+
SYSTEM_PROMPT = (
|
| 49 |
+
"You are an expert supply-chain investigator for RecallTrace. "
|
| 50 |
+
"You receive an observation of a product recall investigation and must "
|
| 51 |
+
"choose the optimal next action. Respond with ONLY a valid JSON object.\n"
|
| 52 |
+
"Available actions:\n"
|
| 53 |
+
"- inspect_node: {\"type\":\"inspect_node\",\"node_id\":\"...\",\"rationale\":\"...\"}\n"
|
| 54 |
+
"- trace_lot: {\"type\":\"trace_lot\",\"lot_id\":\"...\",\"rationale\":\"...\"}\n"
|
| 55 |
+
"- cross_reference: {\"type\":\"cross_reference\",\"lot_id\":\"...\",\"rationale\":\"...\"}\n"
|
| 56 |
+
"- request_lab_test: {\"type\":\"request_lab_test\",\"node_id\":\"...\",\"lot_id\":\"...\",\"rationale\":\"...\"}\n"
|
| 57 |
+
"- quarantine: {\"type\":\"quarantine\",\"node_id\":\"...\",\"lot_id\":\"...\",\"quantity\":N,\"rationale\":\"...\"}\n"
|
| 58 |
+
"- notify: {\"type\":\"notify\",\"node_id\":\"all\",\"rationale\":\"...\"}\n"
|
| 59 |
+
"- finalize: {\"type\":\"finalize\",\"rationale\":\"...\"}"
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ---------------------------------------------------------------------------
|
| 64 |
+
# 1) Format observations as LLM prompts
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
def format_observation(obs) -> str:
|
| 67 |
+
"""Convert RecallObservation to readable text for the LLM."""
|
| 68 |
+
lines = [
|
| 69 |
+
f"TASK: {obs.task_id} | Steps: {obs.steps_taken}/{obs.steps_taken + obs.remaining_step_budget}",
|
| 70 |
+
f"RECALL NOTICE: {obs.recall_notice}",
|
| 71 |
+
"",
|
| 72 |
+
"INVENTORY:",
|
| 73 |
+
]
|
| 74 |
+
for nid, lots in obs.inventory.items():
|
| 75 |
+
if lots:
|
| 76 |
+
items = ", ".join(f"{l}={q}" for l, q in list(lots.items())[:6])
|
| 77 |
+
lines.append(f" {nid}: {items}")
|
| 78 |
+
|
| 79 |
+
if obs.inspected_nodes:
|
| 80 |
+
lines.append(f"\nINSPECTED NODES: {', '.join(obs.inspected_nodes)}")
|
| 81 |
+
|
| 82 |
+
if obs.inspection_results:
|
| 83 |
+
lines.append("INSPECTION FINDINGS:")
|
| 84 |
+
for nid, findings in obs.inspection_results.items():
|
| 85 |
+
for lid, ev in findings.items():
|
| 86 |
+
status = ev.status if hasattr(ev, "status") else ev.get("status", "?")
|
| 87 |
+
uq = ev.unsafe_quantity if hasattr(ev, "unsafe_quantity") else ev.get("unsafe_quantity", 0)
|
| 88 |
+
lines.append(f" {nid}/{lid}: status={status}, unsafe_qty={uq}")
|
| 89 |
+
|
| 90 |
+
if obs.trace_results:
|
| 91 |
+
lines.append("TRACE RESULTS:")
|
| 92 |
+
for lid, tr in obs.trace_results.items():
|
| 93 |
+
nodes = tr.get("affected_nodes", [])
|
| 94 |
+
lines.append(f" {lid}: affected_nodes={nodes}")
|
| 95 |
+
|
| 96 |
+
if getattr(obs, "belief_state", None):
|
| 97 |
+
ranked = sorted(obs.belief_state.items(), key=lambda item: item[1], reverse=True)[:6]
|
| 98 |
+
lines.append("BELIEF STATE:")
|
| 99 |
+
for nid, score in ranked:
|
| 100 |
+
lines.append(f" {nid}: P(contaminated)={score:.2f}")
|
| 101 |
+
|
| 102 |
+
if getattr(obs, "risk_summary", None):
|
| 103 |
+
lines.append(f"RISK SUMMARY: {json.dumps(obs.risk_summary, sort_keys=True)}")
|
| 104 |
+
|
| 105 |
+
if getattr(obs, "root_cause_candidates", None):
|
| 106 |
+
lines.append(f"ROOT CAUSE CANDIDATES: {', '.join(obs.root_cause_candidates)}")
|
| 107 |
+
|
| 108 |
+
if obs.quarantined_inventory:
|
| 109 |
+
lines.append("QUARANTINED:")
|
| 110 |
+
for nid, lots in obs.quarantined_inventory.items():
|
| 111 |
+
items = ", ".join(f"{l}={q}" for l, q in lots.items())
|
| 112 |
+
lines.append(f" {nid}: {items}")
|
| 113 |
+
|
| 114 |
+
return "\n".join(lines)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
# 2) Generate expert training data
|
| 119 |
+
# ---------------------------------------------------------------------------
|
| 120 |
+
def generate_expert_data(num_episodes: int = 300, seed: int = 42) -> list[dict]:
|
| 121 |
+
"""Run heuristic expert on many episodes, collect (prompt, action) pairs."""
|
| 122 |
+
print(f"\n{'='*60}")
|
| 123 |
+
print(f" Phase 1: Generating expert demonstrations")
|
| 124 |
+
print(f" Episodes: {num_episodes}")
|
| 125 |
+
print(f"{'='*60}\n")
|
| 126 |
+
|
| 127 |
+
data = []
|
| 128 |
+
total_reward = 0.0
|
| 129 |
+
rng = random.Random(seed)
|
| 130 |
+
|
| 131 |
+
tasks = RecallTraceEnv.available_tasks()
|
| 132 |
+
|
| 133 |
+
for ep in range(num_episodes):
|
| 134 |
+
task = tasks[ep % len(tasks)]
|
| 135 |
+
env = RecallTraceEnv(task_id=task.task_id)
|
| 136 |
+
obs = env.reset(task_id=task.task_id)
|
| 137 |
+
ep_reward = 0.0
|
| 138 |
+
|
| 139 |
+
for step in range(env.task.max_steps):
|
| 140 |
+
prompt_text = format_observation(obs)
|
| 141 |
+
action = choose_heuristic_action(obs)
|
| 142 |
+
action_json = json.dumps(action.model_dump(exclude_none=True), sort_keys=True)
|
| 143 |
+
|
| 144 |
+
obs, reward, done, info = env.step(action)
|
| 145 |
+
ep_reward += reward
|
| 146 |
+
|
| 147 |
+
# Only keep positive-reward actions as expert demonstrations
|
| 148 |
+
if reward >= 0.0:
|
| 149 |
+
data.append({
|
| 150 |
+
"messages": [
|
| 151 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 152 |
+
{"role": "user", "content": prompt_text},
|
| 153 |
+
{"role": "assistant", "content": action_json},
|
| 154 |
+
]
|
| 155 |
+
})
|
| 156 |
+
|
| 157 |
+
if done:
|
| 158 |
+
break
|
| 159 |
+
|
| 160 |
+
total_reward += ep_reward
|
| 161 |
+
if (ep + 1) % 50 == 0:
|
| 162 |
+
print(f" Episode {ep+1:>4d}/{num_episodes} | Avg reward: {total_reward/(ep+1):.3f} | Samples: {len(data)}")
|
| 163 |
+
|
| 164 |
+
print(f"\n Generated {len(data)} expert samples from {num_episodes} episodes")
|
| 165 |
+
print(f" Average episode reward: {total_reward/num_episodes:.3f}\n")
|
| 166 |
+
return data
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ---------------------------------------------------------------------------
|
| 170 |
+
# 3) SFT Training with Unsloth + TRL
|
| 171 |
+
# ---------------------------------------------------------------------------
|
| 172 |
+
def train_sft(dataset_dicts: list[dict], num_epochs: int = 3, max_steps: int = -1):
|
| 173 |
+
"""Fine-tune with Unsloth + TRL SFTTrainer."""
|
| 174 |
+
print(f"\n{'='*60}")
|
| 175 |
+
print(f" Phase 2: SFT Training with Unsloth + TRL")
|
| 176 |
+
print(f" Model: {MODEL_NAME}")
|
| 177 |
+
print(f" Epochs: {num_epochs}")
|
| 178 |
+
print(f"{'='*60}\n")
|
| 179 |
+
|
| 180 |
+
from unsloth import FastLanguageModel
|
| 181 |
+
from datasets import Dataset
|
| 182 |
+
from trl import SFTTrainer, SFTConfig
|
| 183 |
+
|
| 184 |
+
# Load model with 4-bit quantization
|
| 185 |
+
print(" Loading model with Unsloth (4-bit)...")
|
| 186 |
+
model, tokenizer = FastLanguageModel.from_pretrained(
|
| 187 |
+
model_name=MODEL_NAME,
|
| 188 |
+
max_seq_length=2048,
|
| 189 |
+
load_in_4bit=True,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# Apply LoRA adapters
|
| 193 |
+
model = FastLanguageModel.get_peft_model(
|
| 194 |
+
model,
|
| 195 |
+
r=16,
|
| 196 |
+
lora_alpha=16,
|
| 197 |
+
lora_dropout=0,
|
| 198 |
+
target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
|
| 199 |
+
"gate_proj", "up_proj", "down_proj"],
|
| 200 |
+
bias="none",
|
| 201 |
+
use_gradient_checkpointing="unsloth",
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
# Pre-format messages into text strings (avoids Unsloth formatting_func issues)
|
| 205 |
+
print(" Formatting dataset...")
|
| 206 |
+
formatted_data = []
|
| 207 |
+
for item in dataset_dicts:
|
| 208 |
+
text = tokenizer.apply_chat_template(
|
| 209 |
+
item["messages"],
|
| 210 |
+
tokenize=False,
|
| 211 |
+
add_generation_prompt=False,
|
| 212 |
+
)
|
| 213 |
+
formatted_data.append({"text": text})
|
| 214 |
+
|
| 215 |
+
dataset = Dataset.from_list(formatted_data)
|
| 216 |
+
print(f" Dataset size: {len(dataset)} samples")
|
| 217 |
+
|
| 218 |
+
# Unsloth requires formatting_func β handle both single example and batch
|
| 219 |
+
def formatting_func(example):
|
| 220 |
+
t = example["text"]
|
| 221 |
+
if isinstance(t, list):
|
| 222 |
+
return t
|
| 223 |
+
return [t]
|
| 224 |
+
|
| 225 |
+
# Training config
|
| 226 |
+
training_args = SFTConfig(
|
| 227 |
+
output_dir=str(OUTPUT_DIR),
|
| 228 |
+
per_device_train_batch_size=4,
|
| 229 |
+
gradient_accumulation_steps=4,
|
| 230 |
+
num_train_epochs=num_epochs,
|
| 231 |
+
max_steps=max_steps if max_steps > 0 else -1,
|
| 232 |
+
learning_rate=2e-4,
|
| 233 |
+
lr_scheduler_type="cosine",
|
| 234 |
+
warmup_steps=50,
|
| 235 |
+
logging_steps=10,
|
| 236 |
+
save_steps=200,
|
| 237 |
+
save_total_limit=2,
|
| 238 |
+
fp16=True,
|
| 239 |
+
max_seq_length=2048,
|
| 240 |
+
dataset_text_field="text",
|
| 241 |
+
seed=42,
|
| 242 |
+
report_to="none",
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
trainer = SFTTrainer(
|
| 246 |
+
model=model,
|
| 247 |
+
tokenizer=tokenizer,
|
| 248 |
+
train_dataset=dataset,
|
| 249 |
+
formatting_func=formatting_func,
|
| 250 |
+
args=training_args,
|
| 251 |
+
)
|
| 252 |
+
|
| 253 |
+
print(" Starting training...\n")
|
| 254 |
+
start = time.time()
|
| 255 |
+
result = trainer.train()
|
| 256 |
+
elapsed = time.time() - start
|
| 257 |
+
|
| 258 |
+
print(f"\n Training complete in {elapsed:.0f}s")
|
| 259 |
+
print(f" Final loss: {result.training_loss:.4f}")
|
| 260 |
+
|
| 261 |
+
# Save model
|
| 262 |
+
print(f" Saving model to {OUTPUT_DIR}...")
|
| 263 |
+
model.save_pretrained(str(OUTPUT_DIR))
|
| 264 |
+
tokenizer.save_pretrained(str(OUTPUT_DIR))
|
| 265 |
+
|
| 266 |
+
# Extract training log for plotting
|
| 267 |
+
train_log = [
|
| 268 |
+
{"step": entry["step"], "loss": entry["loss"]}
|
| 269 |
+
for entry in trainer.state.log_history
|
| 270 |
+
if "loss" in entry
|
| 271 |
+
]
|
| 272 |
+
|
| 273 |
+
return model, tokenizer, train_log
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
# ---------------------------------------------------------------------------
|
| 277 |
+
# 4) Evaluate: Baseline vs Trained
|
| 278 |
+
# ---------------------------------------------------------------------------
|
| 279 |
+
def evaluate_baseline(num_episodes: int = 50) -> dict:
|
| 280 |
+
"""Run untrained random baseline on the environment."""
|
| 281 |
+
print(" Evaluating random baseline...")
|
| 282 |
+
scores = []
|
| 283 |
+
for ep in range(num_episodes):
|
| 284 |
+
tasks = RecallTraceEnv.available_tasks()
|
| 285 |
+
task = tasks[ep % len(tasks)]
|
| 286 |
+
env = RecallTraceEnv(task_id=task.task_id)
|
| 287 |
+
obs = env.reset(task_id=task.task_id)
|
| 288 |
+
total_r = 0.0
|
| 289 |
+
for _ in range(env.task.max_steps):
|
| 290 |
+
# Random action
|
| 291 |
+
action_type = random.choice(["inspect_node", "trace_lot", "quarantine", "notify", "finalize"])
|
| 292 |
+
nodes = list(obs.inventory.keys())
|
| 293 |
+
node_id = random.choice(nodes) if nodes else None
|
| 294 |
+
lots = []
|
| 295 |
+
for n_lots in obs.inventory.values():
|
| 296 |
+
lots.extend(n_lots.keys())
|
| 297 |
+
lot_id = random.choice(lots) if lots else None
|
| 298 |
+
|
| 299 |
+
try:
|
| 300 |
+
action = RecallAction(type=action_type, node_id=node_id, lot_id=lot_id,
|
| 301 |
+
quantity=10 if action_type == "quarantine" else None)
|
| 302 |
+
obs, reward, done, info = env.step(action)
|
| 303 |
+
total_r += reward
|
| 304 |
+
except Exception:
|
| 305 |
+
action = RecallAction(type="finalize")
|
| 306 |
+
obs, reward, done, info = env.step(action)
|
| 307 |
+
total_r += reward
|
| 308 |
+
if done:
|
| 309 |
+
break
|
| 310 |
+
scores.append(info.get("score") or 0.0)
|
| 311 |
+
avg = sum(scores) / len(scores)
|
| 312 |
+
print(f" Random baseline: avg score = {avg:.4f}")
|
| 313 |
+
return {"avg_score": avg, "scores": scores}
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def evaluate_heuristic(num_episodes: int = 50) -> dict:
|
| 317 |
+
"""Run heuristic baseline."""
|
| 318 |
+
print(" Evaluating heuristic baseline...")
|
| 319 |
+
scores = []
|
| 320 |
+
for ep in range(num_episodes):
|
| 321 |
+
tasks = RecallTraceEnv.available_tasks()
|
| 322 |
+
task = tasks[ep % len(tasks)]
|
| 323 |
+
env = RecallTraceEnv(task_id=task.task_id)
|
| 324 |
+
obs = env.reset(task_id=task.task_id)
|
| 325 |
+
for _ in range(env.task.max_steps):
|
| 326 |
+
action = choose_heuristic_action(obs)
|
| 327 |
+
obs, reward, done, info = env.step(action)
|
| 328 |
+
if done:
|
| 329 |
+
break
|
| 330 |
+
scores.append(info.get("score") or 0.0)
|
| 331 |
+
avg = sum(scores) / len(scores)
|
| 332 |
+
print(f" Heuristic baseline: avg score = {avg:.4f}")
|
| 333 |
+
return {"avg_score": avg, "scores": scores}
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def evaluate_trained(model, tokenizer, num_episodes: int = 50) -> dict:
|
| 337 |
+
"""Run trained LLM on the environment."""
|
| 338 |
+
from unsloth import FastLanguageModel
|
| 339 |
+
FastLanguageModel.for_inference(model)
|
| 340 |
+
print(" Evaluating trained model...")
|
| 341 |
+
|
| 342 |
+
scores = []
|
| 343 |
+
for ep in range(num_episodes):
|
| 344 |
+
if (ep + 1) % 5 == 0 or ep == 0:
|
| 345 |
+
print(f" Evaluating episode {ep+1}/{num_episodes}...")
|
| 346 |
+
|
| 347 |
+
tasks = RecallTraceEnv.available_tasks()
|
| 348 |
+
task = tasks[ep % len(tasks)]
|
| 349 |
+
env = RecallTraceEnv(task_id=task.task_id)
|
| 350 |
+
obs = env.reset(task_id=task.task_id)
|
| 351 |
+
|
| 352 |
+
for _ in range(env.task.max_steps):
|
| 353 |
+
prompt_text = format_observation(obs)
|
| 354 |
+
messages = [
|
| 355 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 356 |
+
{"role": "user", "content": prompt_text},
|
| 357 |
+
]
|
| 358 |
+
input_text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
|
| 359 |
+
inputs = tokenizer(input_text, return_tensors="pt").to(model.device)
|
| 360 |
+
|
| 361 |
+
with __import__("torch").no_grad():
|
| 362 |
+
outputs = model.generate(
|
| 363 |
+
**inputs, max_new_tokens=200, max_length=None, temperature=0.1,
|
| 364 |
+
do_sample=True, pad_token_id=tokenizer.eos_token_id,
|
| 365 |
+
)
|
| 366 |
+
response = tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True).strip()
|
| 367 |
+
|
| 368 |
+
try:
|
| 369 |
+
action_dict = json.loads(response)
|
| 370 |
+
action = RecallAction.model_validate(action_dict)
|
| 371 |
+
except Exception:
|
| 372 |
+
action = choose_heuristic_action(obs) # fallback
|
| 373 |
+
|
| 374 |
+
obs, reward, done, info = env.step(action)
|
| 375 |
+
if done:
|
| 376 |
+
break
|
| 377 |
+
|
| 378 |
+
scores.append(info.get("score") or 0.0)
|
| 379 |
+
|
| 380 |
+
avg = sum(scores) / len(scores)
|
| 381 |
+
print(f" Trained model: avg score = {avg:.4f}")
|
| 382 |
+
return {"avg_score": avg, "scores": scores}
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
# ---------------------------------------------------------------------------
|
| 386 |
+
# 5) Generate plots
|
| 387 |
+
# ---------------------------------------------------------------------------
|
| 388 |
+
def generate_plots(train_log: list[dict], eval_results: dict):
|
| 389 |
+
"""Generate training loss curve and evaluation comparison plots."""
|
| 390 |
+
import matplotlib
|
| 391 |
+
matplotlib.use("Agg")
|
| 392 |
+
import matplotlib.pyplot as plt
|
| 393 |
+
|
| 394 |
+
PLOTS_DIR.mkdir(exist_ok=True)
|
| 395 |
+
|
| 396 |
+
# --- Training Loss Curve ---
|
| 397 |
+
if train_log:
|
| 398 |
+
fig, ax = plt.subplots(figsize=(10, 5))
|
| 399 |
+
steps = [e["step"] for e in train_log]
|
| 400 |
+
losses = [e["loss"] for e in train_log]
|
| 401 |
+
ax.plot(steps, losses, color="#ff6f3c", linewidth=2, label="SFT Training Loss")
|
| 402 |
+
ax.set_xlabel("Training Step", fontsize=12)
|
| 403 |
+
ax.set_ylabel("Loss", fontsize=12)
|
| 404 |
+
ax.set_title("RecallTrace β SFT Training Loss (Unsloth + TRL)", fontsize=14, fontweight="bold")
|
| 405 |
+
ax.legend()
|
| 406 |
+
ax.grid(True, alpha=0.3)
|
| 407 |
+
fig.tight_layout()
|
| 408 |
+
fig.savefig(PLOTS_DIR / "trl_training_loss.png", dpi=150)
|
| 409 |
+
plt.close()
|
| 410 |
+
print(f" Saved: {PLOTS_DIR / 'trl_training_loss.png'}")
|
| 411 |
+
|
| 412 |
+
# --- Evaluation Comparison ---
|
| 413 |
+
if eval_results:
|
| 414 |
+
fig, ax = plt.subplots(figsize=(8, 5))
|
| 415 |
+
names = list(eval_results.keys())
|
| 416 |
+
avgs = [eval_results[n]["avg_score"] for n in names]
|
| 417 |
+
colors = ["#8b949e", "#f0c040", "#2ea043"][:len(names)]
|
| 418 |
+
bars = ax.bar(names, avgs, color=colors, width=0.5, edgecolor="white", linewidth=0.5)
|
| 419 |
+
for bar, val in zip(bars, avgs):
|
| 420 |
+
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.02,
|
| 421 |
+
f"{val:.3f}", ha="center", fontsize=12, fontweight="bold")
|
| 422 |
+
ax.set_ylabel("Average Episode Score", fontsize=12)
|
| 423 |
+
ax.set_title("RecallTrace β Baseline vs Trained Agent", fontsize=14, fontweight="bold")
|
| 424 |
+
ax.set_ylim(0, 1.1)
|
| 425 |
+
ax.grid(True, alpha=0.3, axis="y")
|
| 426 |
+
fig.tight_layout()
|
| 427 |
+
fig.savefig(PLOTS_DIR / "trl_evaluation_comparison.png", dpi=150)
|
| 428 |
+
plt.close()
|
| 429 |
+
print(f" Saved: {PLOTS_DIR / 'trl_evaluation_comparison.png'}")
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
# ---------------------------------------------------------------------------
|
| 433 |
+
# 6) Push to Hub
|
| 434 |
+
# ---------------------------------------------------------------------------
|
| 435 |
+
def push_to_hub(model, tokenizer, hub_model_id: str):
|
| 436 |
+
"""Push trained model + card to HF Hub."""
|
| 437 |
+
print(f"\n Pushing model to {hub_model_id}...")
|
| 438 |
+
model.push_to_hub(hub_model_id, token=os.environ.get("HF_TOKEN"))
|
| 439 |
+
tokenizer.push_to_hub(hub_model_id, token=os.environ.get("HF_TOKEN"))
|
| 440 |
+
print(f" Model available at: https://huggingface.co/{hub_model_id}")
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
# ---------------------------------------------------------------------------
|
| 444 |
+
# Main
|
| 445 |
+
# ---------------------------------------------------------------------------
|
| 446 |
+
def main():
|
| 447 |
+
parser = argparse.ArgumentParser(description="RecallTrace LLM Training (Unsloth + TRL)")
|
| 448 |
+
parser.add_argument("--episodes", type=int, default=300, help="Expert data episodes")
|
| 449 |
+
parser.add_argument("--epochs", type=int, default=3, help="SFT training epochs")
|
| 450 |
+
parser.add_argument("--max-steps", type=int, default=-1, help="Max training steps (-1=use epochs)")
|
| 451 |
+
parser.add_argument("--eval-episodes", type=int, default=30, help="Evaluation episodes")
|
| 452 |
+
parser.add_argument("--push-model", action="store_true", help="Push to HF Hub")
|
| 453 |
+
parser.add_argument("--hub-model-id", default=HUB_MODEL_ID, help="HF Hub model ID")
|
| 454 |
+
parser.add_argument("--data-only", action="store_true", help="Only generate data, skip training")
|
| 455 |
+
args = parser.parse_args()
|
| 456 |
+
|
| 457 |
+
print("\n" + "="*60)
|
| 458 |
+
print(" RecallTrace β LLM Agent Training")
|
| 459 |
+
print(" Unsloth + TRL (SFT on Expert Demonstrations)")
|
| 460 |
+
print("="*60)
|
| 461 |
+
|
| 462 |
+
# GPU check β fail fast before wasting time on data generation
|
| 463 |
+
if not args.data_only:
|
| 464 |
+
import torch
|
| 465 |
+
if not torch.cuda.is_available():
|
| 466 |
+
print("\n β ERROR: No GPU detected!")
|
| 467 |
+
print(" Unsloth requires a CUDA GPU.")
|
| 468 |
+
print("\n In Google Colab:")
|
| 469 |
+
print(" Runtime β Change runtime type β T4 GPU β Save")
|
| 470 |
+
print(" Then reconnect and re-run all cells.\n")
|
| 471 |
+
sys.exit(1)
|
| 472 |
+
gpu_name = torch.cuda.get_device_name(0)
|
| 473 |
+
print(f"\n β
GPU detected: {gpu_name}")
|
| 474 |
+
|
| 475 |
+
# Phase 1: Generate expert data
|
| 476 |
+
expert_data = generate_expert_data(num_episodes=args.episodes)
|
| 477 |
+
|
| 478 |
+
if args.data_only:
|
| 479 |
+
# Save data and exit
|
| 480 |
+
data_path = Path("training_data.json")
|
| 481 |
+
with open(data_path, "w") as f:
|
| 482 |
+
json.dump(expert_data, f)
|
| 483 |
+
print(f" Saved {len(expert_data)} samples to {data_path}")
|
| 484 |
+
return
|
| 485 |
+
|
| 486 |
+
# Phase 2: SFT Training
|
| 487 |
+
model, tokenizer, train_log = train_sft(
|
| 488 |
+
expert_data, num_epochs=args.epochs, max_steps=args.max_steps
|
| 489 |
+
)
|
| 490 |
+
|
| 491 |
+
# Phase 3: Evaluation
|
| 492 |
+
print(f"\n{'='*60}")
|
| 493 |
+
print(f" Phase 3: Evaluation ({args.eval_episodes} episodes each)")
|
| 494 |
+
print(f"{'='*60}\n")
|
| 495 |
+
|
| 496 |
+
eval_results = {}
|
| 497 |
+
eval_results["Random"] = evaluate_baseline(args.eval_episodes)
|
| 498 |
+
eval_results["Heuristic"] = evaluate_heuristic(args.eval_episodes)
|
| 499 |
+
eval_results["Trained LLM"] = evaluate_trained(model, tokenizer, args.eval_episodes)
|
| 500 |
+
|
| 501 |
+
# Phase 4: Generate plots
|
| 502 |
+
print(f"\n{'='*60}")
|
| 503 |
+
print(f" Phase 4: Generating plots")
|
| 504 |
+
print(f"{'='*60}\n")
|
| 505 |
+
generate_plots(train_log, eval_results)
|
| 506 |
+
|
| 507 |
+
# Phase 5: Push to Hub
|
| 508 |
+
if args.push_model:
|
| 509 |
+
push_to_hub(model, tokenizer, args.hub_model_id)
|
| 510 |
+
|
| 511 |
+
# Summary
|
| 512 |
+
print(f"\n{'='*60}")
|
| 513 |
+
print(f" TRAINING COMPLETE")
|
| 514 |
+
print(f"{'='*60}")
|
| 515 |
+
print(f" Random baseline: {eval_results['Random']['avg_score']:.4f}")
|
| 516 |
+
print(f" Heuristic baseline: {eval_results['Heuristic']['avg_score']:.4f}")
|
| 517 |
+
print(f" Trained LLM: {eval_results['Trained LLM']['avg_score']:.4f}")
|
| 518 |
+
print(f"\n Plots saved to: {PLOTS_DIR}/")
|
| 519 |
+
if args.push_model:
|
| 520 |
+
print(f" Model pushed to: https://huggingface.co/{args.hub_model_id}")
|
| 521 |
+
print()
|
| 522 |
+
|
| 523 |
+
|
| 524 |
+
if __name__ == "__main__":
|
| 525 |
+
main()
|
training_data.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|