Spaces:
Running
Running
Add model-driven local runtime and dynamic demo flow
Browse files- docs/ayush/notes.md +11 -0
- docs/changes.md +6 -0
- docs/kush/notes.md +48 -0
- frontend/src/App.tsx +2 -0
- frontend/src/components/CharacterStage.tsx +7 -1
- frontend/src/components/Controls.tsx +9 -1
- frontend/src/components/EpisodeResultsReport.tsx +32 -10
- frontend/src/components/Header.tsx +2 -1
- frontend/src/components/JudgeAuditPanel.tsx +14 -6
- frontend/src/data/trainingArtifacts.ts +73 -0
- frontend/src/lib/api.ts +70 -1
- frontend/src/lib/demo.ts +14 -0
- frontend/src/pages/ComparePage.tsx +20 -0
- frontend/src/pages/DashboardPage.tsx +19 -9
- frontend/src/pages/EpisodePage.tsx +91 -19
- frontend/src/pages/PolicyComparePage.tsx +244 -0
- frontend/src/types/index.ts +9 -0
- pyproject.toml +1 -0
- replicalab/agents/__init__.py +4 -0
- replicalab/agents/scientist_policy.py +135 -0
- replicalab/config.py +58 -0
- server/app.py +305 -36
- tests/test_scientist_policy.py +85 -0
- tests/test_server.py +92 -0
docs/ayush/notes.md
CHANGED
|
@@ -38,3 +38,14 @@ Current ART/OpenEnv runtime note:
|
|
| 38 |
- The main remaining work is experiment quality iteration, not missing training
|
| 39 |
infrastructure.
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
- The main remaining work is experiment quality iteration, not missing training
|
| 39 |
infrastructure.
|
| 40 |
|
| 41 |
+
Current localhost model-runtime note:
|
| 42 |
+
|
| 43 |
+
- `server/app.py` now exposes `/runtime` and `/agent-step` so the local app can run a backend-selected Scientist policy instead of the frontend stub.
|
| 44 |
+
- Anthropic-backed Scientist inference was wired, but the current Anthropic account cannot be used live because the API billing balance is too low.
|
| 45 |
+
- Localhost therefore currently runs in `ollama` mode with `glm-5:cloud` as the working model-backed Scientist path.
|
| 46 |
+
- The server applies a small deterministic safety adapter to model outputs before env stepping:
|
| 47 |
+
- trims controls to fit sample size
|
| 48 |
+
- aligns equipment and reagent requests to the available inventory
|
| 49 |
+
- clamps duration to the current lab time limit
|
| 50 |
+
- If the local model stalls or errors, `/agent-step` falls back to the deterministic baseline Scientist and records that in the step metadata as `scientist_runtime=ollama_fallback`.
|
| 51 |
+
|
docs/changes.md
CHANGED
|
@@ -83,4 +83,10 @@ Rules:
|
|
| 83 |
| 2026-03-08 | Person B (Ayush) | Frontend backend-availability diagnostics | Added a startup health check and contextual network-error messaging to the replication setup flow after the live demo surfaced a generic `Failed to fetch` banner | The page gave no actionable explanation when the local API server on port `7860` was not running, which made a recoverable environment issue look like an application bug | `Controls.tsx` now checks backend health on load, `frontend/src/lib/api.ts` rewrites fetch-network failures into an explicit uvicorn startup instruction, the frontend was rebuilt, and the local FastAPI server was restarted and verified healthy on `127.0.0.1:7860` | Keep the backend running during demo prep or use the integrated backend-served frontend on `http://127.0.0.1:7860` |
|
| 84 |
| 2026-03-08 | Person B (Ayush) | Frontend live demo outcomes and results report | Extended the one-click demo into three seeded story modes with a detailed post-episode report instead of a single autoplay path that stopped at the generic episode view | The hackathon demo needs to show distinct judged outcomes: immediate agreement, multi-round learning opportunity, and failure to reach agreement, all backed by real episode values rather than static mock copy | Added `fast-agreement`, `learning-opportunity`, and `no-agreement` dashboard launches; routed episode autoplay through scripted but backend-valid action sequences; added a results report with live reward charts, terminal score bars, training interpretation, reliability labeling, and tool-install suggestions; rebuilt the frontend and verified all three seeded ML runs against the live backend | If Oracle-backed narrative summaries are added later, keep the deterministic judge verdict and real score traces as the source of truth for the report |
|
| 85 |
| 2026-03-08 | Person B (Ayush) | Frontend training-status reporting | Replaced the packaged training teaser with an artifact-backed training page and honest improvement guidance | The demo needs a place to show training logs, real achieved values, and whether more training is still required; the earlier dashboard card implied progress but did not expose the real run outputs | Added a dedicated `/training` route, header navigation, a shared frontend data module sourced from real run summaries, and a new training page with checkpoint charts, compare bars, log highlights, preview-artifact status, and explicit `needs more training` analysis; rebuilt the frontend and reverified backend serving on `/training` | If future runs improve beyond baseline, update the shared training artifact data module first so the dashboard and training page stay consistent |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
|
|
|
| 83 |
| 2026-03-08 | Person B (Ayush) | Frontend backend-availability diagnostics | Added a startup health check and contextual network-error messaging to the replication setup flow after the live demo surfaced a generic `Failed to fetch` banner | The page gave no actionable explanation when the local API server on port `7860` was not running, which made a recoverable environment issue look like an application bug | `Controls.tsx` now checks backend health on load, `frontend/src/lib/api.ts` rewrites fetch-network failures into an explicit uvicorn startup instruction, the frontend was rebuilt, and the local FastAPI server was restarted and verified healthy on `127.0.0.1:7860` | Keep the backend running during demo prep or use the integrated backend-served frontend on `http://127.0.0.1:7860` |
|
| 84 |
| 2026-03-08 | Person B (Ayush) | Frontend live demo outcomes and results report | Extended the one-click demo into three seeded story modes with a detailed post-episode report instead of a single autoplay path that stopped at the generic episode view | The hackathon demo needs to show distinct judged outcomes: immediate agreement, multi-round learning opportunity, and failure to reach agreement, all backed by real episode values rather than static mock copy | Added `fast-agreement`, `learning-opportunity`, and `no-agreement` dashboard launches; routed episode autoplay through scripted but backend-valid action sequences; added a results report with live reward charts, terminal score bars, training interpretation, reliability labeling, and tool-install suggestions; rebuilt the frontend and verified all three seeded ML runs against the live backend | If Oracle-backed narrative summaries are added later, keep the deterministic judge verdict and real score traces as the source of truth for the report |
|
| 85 |
| 2026-03-08 | Person B (Ayush) | Frontend training-status reporting | Replaced the packaged training teaser with an artifact-backed training page and honest improvement guidance | The demo needs a place to show training logs, real achieved values, and whether more training is still required; the earlier dashboard card implied progress but did not expose the real run outputs | Added a dedicated `/training` route, header navigation, a shared frontend data module sourced from real run summaries, and a new training page with checkpoint charts, compare bars, log highlights, preview-artifact status, and explicit `needs more training` analysis; rebuilt the frontend and reverified backend serving on `/training` | If future runs improve beyond baseline, update the shared training artifact data module first so the dashboard and training page stay consistent |
|
| 86 |
+
| 2026-03-08 | Person B (Ayush) | Demo video generation | Added a reproducible local builder for the one-minute demo video instead of relying on manual screen recording only | The demo now needs a fast, repeatable way to regenerate the final video with current UI states, a fresh voiceover, and ffmpeg assembly whenever the frontend story changes | Added `scripts/build_demo_video.py` plus `docs/demo_video_script_60s.md`; the script reads the ElevenLabs key from `.env`, captures the real dashboard, episode, and training screens with Selenium, synthesizes the voiceover, writes subtitles, and builds `replicalab/outputs/demo_video/replicalab_demo_60s.mp4` | If the narration or demo scenes change, rerun `python scripts/build_demo_video.py` to regenerate the assets from the current app state |
|
| 87 |
+
| 2026-03-08 | Person B (Ayush) | Hugging Face Space deployment | Redeployed the live HF Space from the current local app state after the hosted URL was serving an old backend-only container | The Space repo and runtime SHAs had drifted behind the local `master` branch, so the public URL showed the API landing page instead of the React app even though the repo already contained the multi-stage Docker build and SPA-serving server code | Synced the deployment files to `ayushozha/replicalab` through the Hugging Face API, restarted the Space, and verified that `https://ayushozha-replicalab.hf.space/` now serves the built frontend while `/health` still reports the real environment | If the Space serves the API-only page again, compare the Space repo SHA and runtime SHA first before assuming the frontend build is broken |
|
| 88 |
+
| 2026-03-08 | Person B (Ayush) | Frontend policy-results clarification | Added a separate baseline-vs-trained-vs-oracle page and clarified that the current public compare bench is still running the deterministic live runtime | The existing `/compare` page looked like a model-policy comparison, but it actually replays seeded benchmark episodes with the default Scientist action builder plus deterministic backend logic, which was confusing for the demo narrative | Added `/policies` as a dedicated policy-results page with live/runtime status, baseline vs trained artifact values, and an explicit oracle-not-mounted status; updated the header navigation and added a runtime clarification callout plus deep link on `/compare` | Keep `/compare` focused on seeded scenario benchmarking and use `/policies` when the audience asks whether the current app is actually running a trained or oracle-backed model |
|
| 89 |
+
| 2026-03-08 | Person B (Ayush) | Localhost model-driven Scientist runtime | Added a backend-selected Scientist runtime path for localhost episodes and switched the live local mode from the blocked Anthropic path to Ollama | The repo needed a real localhost model-driven flow rather than the frontend default action builder, but the current Anthropic account cannot make live API calls because its credit balance is exhausted | Added `/runtime` and `/agent-step`, wired Anthropic and Ollama Scientist backends, made non-demo episode stepping prefer the backend model path, added a deterministic safety adapter plus baseline fallback for fragile local generations, and verified live localhost stepping with `glm-5:cloud` through Ollama | If Anthropic credits are replenished later, restart the backend with `REPLICALAB_SCIENTIST_RUNTIME=anthropic` to use that path instead |
|
| 90 |
+
| 2026-03-08 | Person B (Ayush) | Frontend live-run randomness and judge semantics | Changed the default dashboard live run from one fixed scripted scenario to a random seeded paper episode, and split accepted-with-weaknesses presentation from outright failure presentation | The main demo CTA kept launching the same fixed `fast-agreement` route, which made the product feel canned, and the judge UI was showing `Accept` alongside `Failure Reasons`, which looked contradictory even though the backend semantics were agreement-based | The hero CTA now generates a fresh live route per click, the fixed outcome cards are explicitly labeled scripted, and accepted verdicts with residual gaps now render as `Accept with caveats` / `Conditional` instead of green accept plus red failure messaging | If the team later changes backend verdict semantics, keep the UI wording aligned so agreement and replicability remain separate concepts |
|
| 91 |
+
| 2026-03-08 | Person B (Ayush) | Frontend caveat-state consistency | Tightened the remaining frontend success states so caveated accepts no longer behave like clean wins | After the judge-panel wording fix, the stage animation, first-round “good paper” label, and completion toast could still celebrate an accepted-but-weak protocol as a full success | `CharacterStage`, `EpisodePage`, and `EpisodeResultsReport` now treat accepted-with-caveats runs as partial outcomes, and live reset checks confirmed the dynamic route surfaces distinct paper briefs across scenario families when using the real reset contract | Keep any future verdict-label changes aligned across audit copy, stage emotion, toasts, and post-episode summaries |
|
| 92 |
|
docs/kush/notes.md
CHANGED
|
@@ -42,3 +42,51 @@ Durable deviations belong in `docs/changes.md`.
|
|
| 42 |
- more training and parser/invalid-action cleanup are still needed
|
| 43 |
- Header nav now includes `Training`, dashboard training CTA points there, and the dashboard training teaser uses the same artifact-backed data.
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
- more training and parser/invalid-action cleanup are still needed
|
| 43 |
- Header nav now includes `Training`, dashboard training CTA points there, and the dashboard training teaser uses the same artifact-backed data.
|
| 44 |
|
| 45 |
+
## 2026-03-08 automated demo video build
|
| 46 |
+
|
| 47 |
+
- Added `scripts/build_demo_video.py` to synthesize an ElevenLabs voiceover from `.env`, capture clean frontend screenshots, generate captioned slides, and build the final mp4 with `ffmpeg`.
|
| 48 |
+
- Added `docs/demo_video_script_60s.md` as the canonical one-minute narration and shot list.
|
| 49 |
+
- Generated the current outputs under `replicalab/outputs/demo_video/`:
|
| 50 |
+
- `audio/voiceover.mp3`
|
| 51 |
+
- `replicalab_demo_60s.mp4`
|
| 52 |
+
- `text/voiceover.txt`
|
| 53 |
+
- `text/voiceover.srt`
|
| 54 |
+
|
| 55 |
+
## 2026-03-08 Hugging Face Space redeploy
|
| 56 |
+
|
| 57 |
+
- Investigated the public Space after it showed only the backend landing page instead of the React app.
|
| 58 |
+
- Confirmed the repo already had the correct multi-stage Dockerfile and SPA-serving `server/app.py`, but the runtime SHA was still pinned to an older backend-only container.
|
| 59 |
+
- Synced the current app files to `ayushozha/replicalab` through the Hugging Face API, restarted the Space, and waited for the runtime SHA to advance to the new repo revision.
|
| 60 |
+
- Reverified:
|
| 61 |
+
- `https://ayushozha-replicalab.hf.space/` now serves the React frontend
|
| 62 |
+
- `https://ayushozha-replicalab.hf.space/episode?...` returns `200`
|
| 63 |
+
- `https://ayushozha-replicalab.hf.space/health` still reports `{\"status\":\"ok\",\"env\":\"real\",\"version\":\"0.1.0\"}`
|
| 64 |
+
|
| 65 |
+
## 2026-03-08 policy-results clarification page
|
| 66 |
+
|
| 67 |
+
- Added a dedicated `/policies` frontend route for the question: baseline vs trained vs oracle.
|
| 68 |
+
- The new page makes the current runtime explicit:
|
| 69 |
+
- `/compare` is still the seeded deterministic benchmark bench
|
| 70 |
+
- the public app is not currently mounting the trained Scientist adapter
|
| 71 |
+
- the public app is not currently mounting the Anthropic oracle path
|
| 72 |
+
- the Judge remains deterministic
|
| 73 |
+
- Updated `/compare` with a callout so it no longer implies that it is already comparing live mounted model policies.
|
| 74 |
+
|
| 75 |
+
## 2026-03-08 localhost model-backed Scientist mode
|
| 76 |
+
|
| 77 |
+
- Added live runtime detection to the episode flow through `/runtime`.
|
| 78 |
+
- Non-demo localhost episodes now prefer the backend `/agent-step` route instead of the frontend default action builder when a model runtime is available.
|
| 79 |
+
- The episode page now surfaces the current Scientist runtime directly in the UI so it is clear whether localhost is using baseline or a model-backed path.
|
| 80 |
+
- Current live localhost mode is `ollama` with `glm-5:cloud`.
|
| 81 |
+
- Anthropic-backed Scientist mode exists in code, but the current Anthropic account cannot run live due to insufficient API credits, so localhost falls back to the Ollama runtime for real model-driven stepping.
|
| 82 |
+
|
| 83 |
+
## 2026-03-08 dynamic live-run and judge-caveat cleanup
|
| 84 |
+
|
| 85 |
+
- The main dashboard CTA no longer launches the same fixed seeded flow every time.
|
| 86 |
+
- `Replicate a Random Paper` now generates a fresh seeded route with a random scenario family, difficulty, and seed, then autostarts the live episode path.
|
| 87 |
+
- The three fixed cards remain available, but are now labeled as scripted outcomes rather than the default live experience.
|
| 88 |
+
- Accepted verdicts that still carry weak-component reasons are now shown as `Accept with caveats` in the judge-facing UI instead of `Accept` plus a contradictory `Failure Reasons` block.
|
| 89 |
+
- The results page now reports those cases as conditional replication candidates rather than clean wins.
|
| 90 |
+
- The stage animation and completion toast now treat accepted-with-caveats runs as partial wins instead of full celebratory successes.
|
| 91 |
+
- Live reset verification confirmed the random path can surface distinct paper briefs across scenario families, including CIFAR-10 replication and offline mean-reversion backtest cases.
|
| 92 |
+
|
frontend/src/App.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { Routes, Route } from 'react-router-dom';
|
|
| 2 |
import EpisodePage from '@/pages/EpisodePage';
|
| 3 |
import DashboardPage from '@/pages/DashboardPage';
|
| 4 |
import ComparePage from '@/pages/ComparePage';
|
|
|
|
| 5 |
import TrainingPage from '@/pages/TrainingPage';
|
| 6 |
import Header from '@/components/Header';
|
| 7 |
import { ToastProvider } from '@/components/Toast';
|
|
@@ -20,6 +21,7 @@ export default function App() {
|
|
| 20 |
<Route path="/episode/:episodeId" element={<EpisodePage />} />
|
| 21 |
<Route path="/training" element={<TrainingPage />} />
|
| 22 |
<Route path="/compare" element={<ComparePage />} />
|
|
|
|
| 23 |
</Routes>
|
| 24 |
<Onboarding show={showOnboarding} onDismiss={dismissOnboarding} />
|
| 25 |
</div>
|
|
|
|
| 2 |
import EpisodePage from '@/pages/EpisodePage';
|
| 3 |
import DashboardPage from '@/pages/DashboardPage';
|
| 4 |
import ComparePage from '@/pages/ComparePage';
|
| 5 |
+
import PolicyComparePage from '@/pages/PolicyComparePage';
|
| 6 |
import TrainingPage from '@/pages/TrainingPage';
|
| 7 |
import Header from '@/components/Header';
|
| 8 |
import { ToastProvider } from '@/components/Toast';
|
|
|
|
| 21 |
<Route path="/episode/:episodeId" element={<EpisodePage />} />
|
| 22 |
<Route path="/training" element={<TrainingPage />} />
|
| 23 |
<Route path="/compare" element={<ComparePage />} />
|
| 24 |
+
<Route path="/policies" element={<PolicyComparePage />} />
|
| 25 |
</Routes>
|
| 26 |
<Onboarding show={showOnboarding} onDismiss={dismissOnboarding} />
|
| 27 |
</div>
|
frontend/src/components/CharacterStage.tsx
CHANGED
|
@@ -32,6 +32,8 @@ export default function CharacterStage({
|
|
| 32 |
}: CharacterStageProps) {
|
| 33 |
const lastAction = getActionFromMessage(lastMessage);
|
| 34 |
const speakingRole = lastMessage?.role;
|
|
|
|
|
|
|
| 35 |
|
| 36 |
const scientistAction =
|
| 37 |
speakingRole === 'scientist' ? lastAction : undefined;
|
|
@@ -42,7 +44,11 @@ export default function CharacterStage({
|
|
| 42 |
phase === 'judging'
|
| 43 |
? 'scoring'
|
| 44 |
: phase === 'complete' && judgeAudit
|
| 45 |
-
?
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
: undefined;
|
| 47 |
|
| 48 |
return (
|
|
|
|
| 32 |
}: CharacterStageProps) {
|
| 33 |
const lastAction = getActionFromMessage(lastMessage);
|
| 34 |
const speakingRole = lastMessage?.role;
|
| 35 |
+
const hasAcceptCaveats =
|
| 36 |
+
judgeAudit?.verdict === 'accept' && (judgeAudit.top_failure_reasons?.length ?? 0) > 0;
|
| 37 |
|
| 38 |
const scientistAction =
|
| 39 |
speakingRole === 'scientist' ? lastAction : undefined;
|
|
|
|
| 44 |
phase === 'judging'
|
| 45 |
? 'scoring'
|
| 46 |
: phase === 'complete' && judgeAudit
|
| 47 |
+
? hasAcceptCaveats
|
| 48 |
+
? 'verdict_partial'
|
| 49 |
+
: judgeAudit.verdict === 'accept' || judgeAudit.verdict === 'success'
|
| 50 |
+
? 'verdict_success'
|
| 51 |
+
: 'verdict_failure'
|
| 52 |
: undefined;
|
| 53 |
|
| 54 |
return (
|
frontend/src/components/Controls.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { useEffect, useState } from 'react';
|
| 2 |
import { Play, RotateCcw, Dices } from 'lucide-react';
|
| 3 |
-
import type { Difficulty, ScenarioTemplate, ResetParams } from '@/types';
|
| 4 |
import { cn } from '@/lib/utils';
|
| 5 |
import { sfx } from '@/lib/audio';
|
| 6 |
import { healthCheck } from '@/lib/api';
|
|
@@ -26,6 +26,7 @@ interface ControlsProps {
|
|
| 26 |
initialSeed?: number;
|
| 27 |
initialTemplate?: ScenarioTemplate;
|
| 28 |
initialDifficulty?: Difficulty;
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
export default function Controls({
|
|
@@ -37,6 +38,7 @@ export default function Controls({
|
|
| 37 |
initialSeed,
|
| 38 |
initialTemplate,
|
| 39 |
initialDifficulty,
|
|
|
|
| 40 |
}: ControlsProps) {
|
| 41 |
const [seed, setSeed] = useState<string>(initialSeed?.toString() ?? '42');
|
| 42 |
const [template, setTemplate] = useState<ScenarioTemplate>(initialTemplate ?? 'ml_benchmark');
|
|
@@ -109,6 +111,12 @@ export default function Controls({
|
|
| 109 |
>
|
| 110 |
{backendMessage}
|
| 111 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
</div>
|
| 113 |
<div className="space-y-3">
|
| 114 |
<div>
|
|
|
|
| 1 |
import { useEffect, useState } from 'react';
|
| 2 |
import { Play, RotateCcw, Dices } from 'lucide-react';
|
| 3 |
+
import type { BackendRuntimeStatus, Difficulty, ScenarioTemplate, ResetParams } from '@/types';
|
| 4 |
import { cn } from '@/lib/utils';
|
| 5 |
import { sfx } from '@/lib/audio';
|
| 6 |
import { healthCheck } from '@/lib/api';
|
|
|
|
| 26 |
initialSeed?: number;
|
| 27 |
initialTemplate?: ScenarioTemplate;
|
| 28 |
initialDifficulty?: Difficulty;
|
| 29 |
+
runtimeStatus?: BackendRuntimeStatus | null;
|
| 30 |
}
|
| 31 |
|
| 32 |
export default function Controls({
|
|
|
|
| 38 |
initialSeed,
|
| 39 |
initialTemplate,
|
| 40 |
initialDifficulty,
|
| 41 |
+
runtimeStatus,
|
| 42 |
}: ControlsProps) {
|
| 43 |
const [seed, setSeed] = useState<string>(initialSeed?.toString() ?? '42');
|
| 44 |
const [template, setTemplate] = useState<ScenarioTemplate>(initialTemplate ?? 'ml_benchmark');
|
|
|
|
| 111 |
>
|
| 112 |
{backendMessage}
|
| 113 |
</p>
|
| 114 |
+
{runtimeStatus && (
|
| 115 |
+
<p className="mt-1 text-xs text-muted-foreground">
|
| 116 |
+
Scientist runtime: <span className="font-medium text-foreground">{runtimeStatus.scientist_runtime}</span>
|
| 117 |
+
{' '}({runtimeStatus.scientist_model})
|
| 118 |
+
</p>
|
| 119 |
+
)}
|
| 120 |
</div>
|
| 121 |
<div className="space-y-3">
|
| 122 |
<div>
|
frontend/src/components/EpisodeResultsReport.tsx
CHANGED
|
@@ -37,6 +37,10 @@ type OutcomeProfile = {
|
|
| 37 |
icon: typeof CheckCircle2;
|
| 38 |
};
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
const TOOL_STACK: Record<string, { title: string; packages: string[]; commands: string[] }> = {
|
| 41 |
math_reasoning: {
|
| 42 |
title: 'Suggested proof-and-verification stack',
|
|
@@ -78,19 +82,24 @@ function computeConfidencePercent(episode: EpisodeState): number {
|
|
| 78 |
}
|
| 79 |
|
| 80 |
function buildReliabilityLabel(episode: EpisodeState): string {
|
| 81 |
-
if (episode.judge_audit?.verdict ==
|
| 82 |
-
return '
|
| 83 |
}
|
| 84 |
-
if (episode
|
| 85 |
-
return '
|
|
|
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
-
return '
|
| 88 |
}
|
| 89 |
|
| 90 |
function buildOutcomeProfile(episode: EpisodeState): OutcomeProfile {
|
|
|
|
| 91 |
const firstRoundAccepted =
|
| 92 |
episode.step_history[0]?.lab_manager_action_type === 'accept' &&
|
| 93 |
-
episode.judge_audit?.verdict === 'accept'
|
|
|
|
| 94 |
const disagreementRounds = episode.step_history.filter(
|
| 95 |
(trace) => trace.lab_manager_action_type && trace.lab_manager_action_type !== 'accept',
|
| 96 |
).length;
|
|
@@ -105,6 +114,16 @@ function buildOutcomeProfile(episode: EpisodeState): OutcomeProfile {
|
|
| 105 |
};
|
| 106 |
}
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
if (episode.judge_audit?.verdict === 'accept') {
|
| 109 |
return {
|
| 110 |
tone: 'learning',
|
|
@@ -203,6 +222,7 @@ export default function EpisodeResultsReport({ episode, className }: EpisodeResu
|
|
| 203 |
const profile = buildOutcomeProfile(episode);
|
| 204 |
const confidencePercent = computeConfidencePercent(episode);
|
| 205 |
const reliabilityLabel = buildReliabilityLabel(episode);
|
|
|
|
| 206 |
const disagreementRounds = episode.step_history.filter(
|
| 207 |
(trace) => trace.lab_manager_action_type && trace.lab_manager_action_type !== 'accept',
|
| 208 |
).length;
|
|
@@ -240,7 +260,9 @@ export default function EpisodeResultsReport({ episode, className }: EpisodeResu
|
|
| 240 |
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{profile.subtitle}</p>
|
| 241 |
<p className="mt-3 max-w-3xl text-sm">
|
| 242 |
{profile.tone !== 'reject'
|
| 243 |
-
?
|
|
|
|
|
|
|
| 244 |
: `ReplicaLab rejects this paper for the current setup. The paper reliability score is ${confidencePercent}% until the blocking constraints are addressed.`}
|
| 245 |
</p>
|
| 246 |
</div>
|
|
@@ -256,13 +278,13 @@ export default function EpisodeResultsReport({ episode, className }: EpisodeResu
|
|
| 256 |
icon={<CheckCircle2 className="h-4 w-4" />}
|
| 257 |
label="Paper reliability quality"
|
| 258 |
value={reliabilityLabel}
|
| 259 |
-
hint="Good, learning opportunity, or bad based on the judged outcome."
|
| 260 |
/>
|
| 261 |
<MetricCard
|
| 262 |
icon={<FileCheck2 className="h-4 w-4" />}
|
| 263 |
label="Judge verdict"
|
| 264 |
-
value={episode.judge_audit?.verdict ?? 'unknown'}
|
| 265 |
-
hint="
|
| 266 |
/>
|
| 267 |
<MetricCard
|
| 268 |
icon={<TrendingUp className="h-4 w-4" />}
|
|
|
|
| 37 |
icon: typeof CheckCircle2;
|
| 38 |
};
|
| 39 |
|
| 40 |
+
function hasAcceptCaveats(episode: EpisodeState): boolean {
|
| 41 |
+
return episode.judge_audit?.verdict === 'accept' && (episode.judge_audit?.top_failure_reasons.length ?? 0) > 0;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
const TOOL_STACK: Record<string, { title: string; packages: string[]; commands: string[] }> = {
|
| 45 |
math_reasoning: {
|
| 46 |
title: 'Suggested proof-and-verification stack',
|
|
|
|
| 82 |
}
|
| 83 |
|
| 84 |
function buildReliabilityLabel(episode: EpisodeState): string {
|
| 85 |
+
if (episode.judge_audit?.verdict !== 'accept') {
|
| 86 |
+
return 'Bad';
|
| 87 |
}
|
| 88 |
+
if (hasAcceptCaveats(episode)) {
|
| 89 |
+
return 'Conditional';
|
| 90 |
+
}
|
| 91 |
+
if (episode.step_history[0]?.lab_manager_action_type === 'accept') {
|
| 92 |
+
return 'Good';
|
| 93 |
}
|
| 94 |
+
return 'Needs iteration';
|
| 95 |
}
|
| 96 |
|
| 97 |
function buildOutcomeProfile(episode: EpisodeState): OutcomeProfile {
|
| 98 |
+
const acceptedWithCaveats = hasAcceptCaveats(episode);
|
| 99 |
const firstRoundAccepted =
|
| 100 |
episode.step_history[0]?.lab_manager_action_type === 'accept' &&
|
| 101 |
+
episode.judge_audit?.verdict === 'accept' &&
|
| 102 |
+
!acceptedWithCaveats;
|
| 103 |
const disagreementRounds = episode.step_history.filter(
|
| 104 |
(trace) => trace.lab_manager_action_type && trace.lab_manager_action_type !== 'accept',
|
| 105 |
).length;
|
|
|
|
| 114 |
};
|
| 115 |
}
|
| 116 |
|
| 117 |
+
if (acceptedWithCaveats) {
|
| 118 |
+
return {
|
| 119 |
+
tone: 'learning',
|
| 120 |
+
title: 'Completed: Accepted with caveats',
|
| 121 |
+
subtitle: 'The agents reached agreement, but the final plan still has visible gaps against the hidden reference requirements.',
|
| 122 |
+
badge: 'Conditional replication candidate',
|
| 123 |
+
icon: AlertTriangle,
|
| 124 |
+
};
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
if (episode.judge_audit?.verdict === 'accept') {
|
| 128 |
return {
|
| 129 |
tone: 'learning',
|
|
|
|
| 222 |
const profile = buildOutcomeProfile(episode);
|
| 223 |
const confidencePercent = computeConfidencePercent(episode);
|
| 224 |
const reliabilityLabel = buildReliabilityLabel(episode);
|
| 225 |
+
const acceptedWithCaveats = hasAcceptCaveats(episode);
|
| 226 |
const disagreementRounds = episode.step_history.filter(
|
| 227 |
(trace) => trace.lab_manager_action_type && trace.lab_manager_action_type !== 'accept',
|
| 228 |
).length;
|
|
|
|
| 260 |
<p className="mt-2 max-w-3xl text-sm text-muted-foreground">{profile.subtitle}</p>
|
| 261 |
<p className="mt-3 max-w-3xl text-sm">
|
| 262 |
{profile.tone !== 'reject'
|
| 263 |
+
? acceptedWithCaveats
|
| 264 |
+
? `ReplicaLab scores this paper as conditionally replicable in the current lab setup with ${confidencePercent}% confidence. The caveats below should be addressed before claiming a clean reproduction.`
|
| 265 |
+
: `ReplicaLab scores this paper as replicable in the current lab setup with ${confidencePercent}% confidence.`
|
| 266 |
: `ReplicaLab rejects this paper for the current setup. The paper reliability score is ${confidencePercent}% until the blocking constraints are addressed.`}
|
| 267 |
</p>
|
| 268 |
</div>
|
|
|
|
| 278 |
icon={<CheckCircle2 className="h-4 w-4" />}
|
| 279 |
label="Paper reliability quality"
|
| 280 |
value={reliabilityLabel}
|
| 281 |
+
hint="Good, conditional, learning opportunity, or bad based on the judged outcome."
|
| 282 |
/>
|
| 283 |
<MetricCard
|
| 284 |
icon={<FileCheck2 className="h-4 w-4" />}
|
| 285 |
label="Judge verdict"
|
| 286 |
+
value={acceptedWithCaveats ? 'accept_with_caveats' : episode.judge_audit?.verdict ?? 'unknown'}
|
| 287 |
+
hint="Deterministic decision with caveats separated from outright rejection."
|
| 288 |
/>
|
| 289 |
<MetricCard
|
| 290 |
icon={<TrendingUp className="h-4 w-4" />}
|
frontend/src/components/Header.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
import { Link, useLocation } from 'react-router-dom';
|
| 3 |
-
import { FlaskConical, LayoutDashboard, Play, Sun, Moon, Volume2, VolumeX, HelpCircle, GitCompareArrows, GraduationCap } from 'lucide-react';
|
| 4 |
import { cn } from '@/lib/utils';
|
| 5 |
import { useTheme } from '@/lib/useTheme';
|
| 6 |
import { toggleMute, isMuted } from '@/lib/audio';
|
|
@@ -10,6 +10,7 @@ const navItems = [
|
|
| 10 |
{ to: '/episode', label: 'Episode', icon: Play },
|
| 11 |
{ to: '/training', label: 'Training', icon: GraduationCap },
|
| 12 |
{ to: '/compare', label: 'Compare', icon: GitCompareArrows },
|
|
|
|
| 13 |
];
|
| 14 |
|
| 15 |
export default function Header({ onShowTutorial }: { onShowTutorial?: () => void }) {
|
|
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
import { Link, useLocation } from 'react-router-dom';
|
| 3 |
+
import { FlaskConical, LayoutDashboard, Play, Sun, Moon, Volume2, VolumeX, HelpCircle, GitCompareArrows, GraduationCap, BrainCircuit } from 'lucide-react';
|
| 4 |
import { cn } from '@/lib/utils';
|
| 5 |
import { useTheme } from '@/lib/useTheme';
|
| 6 |
import { toggleMute, isMuted } from '@/lib/audio';
|
|
|
|
| 10 |
{ to: '/episode', label: 'Episode', icon: Play },
|
| 11 |
{ to: '/training', label: 'Training', icon: GraduationCap },
|
| 12 |
{ to: '/compare', label: 'Compare', icon: GitCompareArrows },
|
| 13 |
+
{ to: '/policies', label: 'Policies', icon: BrainCircuit },
|
| 14 |
];
|
| 15 |
|
| 16 |
export default function Header({ onShowTutorial }: { onShowTutorial?: () => void }) {
|
frontend/src/components/JudgeAuditPanel.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
| 2 |
import type { JudgeAudit } from '@/types';
|
| 3 |
import { cn, verdictColor } from '@/lib/utils';
|
| 4 |
import CharacterAvatar from '@/components/CharacterAvatar';
|
|
@@ -11,12 +11,20 @@ interface JudgeAuditPanelProps {
|
|
| 11 |
export default function JudgeAuditPanel({ audit, className }: JudgeAuditPanelProps) {
|
| 12 |
if (!audit) return null;
|
| 13 |
|
|
|
|
| 14 |
const VerdictIcon =
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
? CheckCircle
|
| 17 |
: audit.verdict === 'failure' || audit.verdict === 'reject'
|
| 18 |
? XCircle
|
| 19 |
: AlertCircle;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
return (
|
| 22 |
<div className={cn('rounded-lg border border-judge/30 bg-judge/5 p-4', className)}>
|
|
@@ -24,9 +32,9 @@ export default function JudgeAuditPanel({ audit, className }: JudgeAuditPanelPro
|
|
| 24 |
<CharacterAvatar role="judge" size="sm" />
|
| 25 |
<div>
|
| 26 |
<h2 className="text-sm font-semibold">Judge Aldric's Verdict</h2>
|
| 27 |
-
<div className={cn('flex items-center gap-1 text-xs font-semibold', verdictColor(audit.verdict))}>
|
| 28 |
<VerdictIcon className="h-3.5 w-3.5" />
|
| 29 |
-
{
|
| 30 |
</div>
|
| 31 |
</div>
|
| 32 |
</div>
|
|
@@ -47,10 +55,10 @@ export default function JudgeAuditPanel({ audit, className }: JudgeAuditPanelPro
|
|
| 47 |
|
| 48 |
{audit.top_failure_reasons.length > 0 && (
|
| 49 |
<div>
|
| 50 |
-
<h3 className=
|
| 51 |
<ul className="space-y-1">
|
| 52 |
{audit.top_failure_reasons.map((reason, i) => (
|
| 53 |
-
<li key={i} className=
|
| 54 |
<XCircle className="mt-0.5 h-3 w-3 shrink-0" />
|
| 55 |
{reason}
|
| 56 |
</li>
|
|
|
|
| 1 |
+
import { CheckCircle, XCircle, AlertCircle, AlertTriangle } from 'lucide-react';
|
| 2 |
import type { JudgeAudit } from '@/types';
|
| 3 |
import { cn, verdictColor } from '@/lib/utils';
|
| 4 |
import CharacterAvatar from '@/components/CharacterAvatar';
|
|
|
|
| 11 |
export default function JudgeAuditPanel({ audit, className }: JudgeAuditPanelProps) {
|
| 12 |
if (!audit) return null;
|
| 13 |
|
| 14 |
+
const hasCaveats = audit.verdict === 'accept' && audit.top_failure_reasons.length > 0;
|
| 15 |
const VerdictIcon =
|
| 16 |
+
hasCaveats
|
| 17 |
+
? AlertTriangle
|
| 18 |
+
: audit.verdict === 'success' || audit.verdict === 'accept'
|
| 19 |
? CheckCircle
|
| 20 |
: audit.verdict === 'failure' || audit.verdict === 'reject'
|
| 21 |
? XCircle
|
| 22 |
: AlertCircle;
|
| 23 |
+
const verdictLabel = hasCaveats
|
| 24 |
+
? 'Accept with caveats'
|
| 25 |
+
: audit.verdict.charAt(0).toUpperCase() + audit.verdict.slice(1);
|
| 26 |
+
const reasonsLabel = hasCaveats ? 'Caveats to address' : 'Failure Reasons';
|
| 27 |
+
const reasonsColor = hasCaveats ? 'text-judge' : 'text-destructive';
|
| 28 |
|
| 29 |
return (
|
| 30 |
<div className={cn('rounded-lg border border-judge/30 bg-judge/5 p-4', className)}>
|
|
|
|
| 32 |
<CharacterAvatar role="judge" size="sm" />
|
| 33 |
<div>
|
| 34 |
<h2 className="text-sm font-semibold">Judge Aldric's Verdict</h2>
|
| 35 |
+
<div className={cn('flex items-center gap-1 text-xs font-semibold', hasCaveats ? 'text-judge' : verdictColor(audit.verdict))}>
|
| 36 |
<VerdictIcon className="h-3.5 w-3.5" />
|
| 37 |
+
{verdictLabel}
|
| 38 |
</div>
|
| 39 |
</div>
|
| 40 |
</div>
|
|
|
|
| 55 |
|
| 56 |
{audit.top_failure_reasons.length > 0 && (
|
| 57 |
<div>
|
| 58 |
+
<h3 className={cn('mb-1.5 text-xs font-medium', reasonsColor)}>{reasonsLabel}</h3>
|
| 59 |
<ul className="space-y-1">
|
| 60 |
{audit.top_failure_reasons.map((reason, i) => (
|
| 61 |
+
<li key={i} className={cn('flex items-start gap-1.5 text-sm', reasonsColor)}>
|
| 62 |
<XCircle className="mt-0.5 h-3 w-3 shrink-0" />
|
| 63 |
{reason}
|
| 64 |
</li>
|
frontend/src/data/trainingArtifacts.ts
CHANGED
|
@@ -40,6 +40,21 @@ export interface PreviewArtifact {
|
|
| 40 |
};
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
export const HOLDOUT_COMPARE: TrainingComparison = {
|
| 44 |
baseline: [
|
| 45 |
{ episode: 1, reward: 4.925, rigor: 0.85, feasibility: 1.0, fidelity: 0.45, rounds_used: 2, agreement: true, invalid_actions: 0 },
|
|
@@ -208,3 +223,61 @@ export const TRAINING_ASSESSMENT = {
|
|
| 208 |
'Finish the Lab Manager SFT run and evaluate Scientist-plus-Lab-Manager together instead of only Scientist RL.',
|
| 209 |
],
|
| 210 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
};
|
| 41 |
}
|
| 42 |
|
| 43 |
+
export interface PolicySnapshot {
|
| 44 |
+
id: 'baseline' | 'trained' | 'oracle';
|
| 45 |
+
label: string;
|
| 46 |
+
scientistMode: string;
|
| 47 |
+
labManagerMode: string;
|
| 48 |
+
judgeMode: string;
|
| 49 |
+
source: string;
|
| 50 |
+
status: 'live' | 'artifact' | 'planned';
|
| 51 |
+
averageReward: number | null;
|
| 52 |
+
agreementRate: number | null;
|
| 53 |
+
averageRounds: number | null;
|
| 54 |
+
invalidRate: number | null;
|
| 55 |
+
summary: string;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
export const HOLDOUT_COMPARE: TrainingComparison = {
|
| 59 |
baseline: [
|
| 60 |
{ episode: 1, reward: 4.925, rigor: 0.85, feasibility: 1.0, fidelity: 0.45, rounds_used: 2, agreement: true, invalid_actions: 0 },
|
|
|
|
| 223 |
'Finish the Lab Manager SFT run and evaluate Scientist-plus-Lab-Manager together instead of only Scientist RL.',
|
| 224 |
],
|
| 225 |
};
|
| 226 |
+
|
| 227 |
+
export const POLICY_COMPARE: PolicySnapshot[] = [
|
| 228 |
+
{
|
| 229 |
+
id: 'baseline',
|
| 230 |
+
label: 'Baseline runtime',
|
| 231 |
+
scientistMode: 'Deterministic frontend action builder, not a mounted LLM adapter',
|
| 232 |
+
labManagerMode: 'Deterministic feasibility pipeline in the backend',
|
| 233 |
+
judgeMode: 'Deterministic rubric and audit',
|
| 234 |
+
source: 'Live runtime and local baseline evaluation',
|
| 235 |
+
status: 'live',
|
| 236 |
+
averageReward: HOLDOUT_COMPARE.summary.baseline_avg_reward,
|
| 237 |
+
agreementRate: HOLDOUT_COMPARE.summary.baseline_agreement_rate,
|
| 238 |
+
averageRounds: HOLDOUT_COMPARE.summary.baseline_avg_rounds,
|
| 239 |
+
invalidRate: HOLDOUT_COMPARE.summary.baseline_invalid_rate,
|
| 240 |
+
summary:
|
| 241 |
+
'This is the policy path used by the current /compare page. It reaches agreement reliably and stays fully judge-grounded, but it is not yet using the trained Scientist adapter.',
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
id: 'trained',
|
| 245 |
+
label: 'Trained Scientist',
|
| 246 |
+
scientistMode: 'Artifact-backed Scientist RL adapter evaluation',
|
| 247 |
+
labManagerMode: 'Deterministic feasibility pipeline in the backend',
|
| 248 |
+
judgeMode: 'Deterministic rubric and audit',
|
| 249 |
+
source: 'Hold-out compare artifact from the training pipeline',
|
| 250 |
+
status: 'artifact',
|
| 251 |
+
averageReward: HOLDOUT_COMPARE.summary.trained_avg_reward,
|
| 252 |
+
agreementRate: HOLDOUT_COMPARE.summary.trained_agreement_rate,
|
| 253 |
+
averageRounds: HOLDOUT_COMPARE.summary.trained_avg_rounds,
|
| 254 |
+
invalidRate: HOLDOUT_COMPARE.summary.trained_invalid_rate,
|
| 255 |
+
summary:
|
| 256 |
+
'The training pipeline ran successfully, but this adapter still underperforms the baseline badly on held-out seeded evaluation because invalid actions remain too high.',
|
| 257 |
+
},
|
| 258 |
+
{
|
| 259 |
+
id: 'oracle',
|
| 260 |
+
label: 'Oracle-assisted V2',
|
| 261 |
+
scientistMode: 'Planned Anthropic-assisted path, not mounted in the public runtime',
|
| 262 |
+
labManagerMode: 'Optional oracle narration and post-mortem path exists in code, not live in demo runtime',
|
| 263 |
+
judgeMode: 'Still deterministic even when oracle features are enabled',
|
| 264 |
+
source: 'Architecture target only, no committed evaluation artifact yet',
|
| 265 |
+
status: 'planned',
|
| 266 |
+
averageReward: null,
|
| 267 |
+
agreementRate: null,
|
| 268 |
+
averageRounds: null,
|
| 269 |
+
invalidRate: null,
|
| 270 |
+
summary:
|
| 271 |
+
'The oracle path exists in the codebase as a V2 extension, but there is no live public run or artifact-backed benchmark result wired into the app yet, so we should not claim oracle gains here.',
|
| 272 |
+
},
|
| 273 |
+
];
|
| 274 |
+
|
| 275 |
+
export const CURRENT_RUNTIME_MODEL_STATUS = {
|
| 276 |
+
comparePageUsesLiveModel: false,
|
| 277 |
+
episodePageUsesLiveModel: false,
|
| 278 |
+
backendUsesOracle: false,
|
| 279 |
+
backendUsesDeterministicLabManager: true,
|
| 280 |
+
backendUsesDeterministicJudge: true,
|
| 281 |
+
note:
|
| 282 |
+
'Right now the public demo runtime is not loading a trained Scientist adapter or an Anthropic oracle. The Scientist moves come from the frontend default action builder or the protocol editor, while the backend Lab Manager and Judge stay deterministic.',
|
| 283 |
+
};
|
frontend/src/lib/api.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
| 16 |
ScenarioTemplate,
|
| 17 |
Difficulty,
|
| 18 |
EpisodeStepTrace,
|
|
|
|
| 19 |
} from '@/types';
|
| 20 |
|
| 21 |
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
|
|
@@ -157,7 +158,7 @@ function buildRoundTrace(
|
|
| 157 |
// REST API functions
|
| 158 |
// ---------------------------------------------------------------------------
|
| 159 |
|
| 160 |
-
export async function healthCheck(): Promise<{ status: string }> {
|
| 161 |
try {
|
| 162 |
const res = await fetch(`${BASE_URL}/health`);
|
| 163 |
if (!res.ok) {
|
|
@@ -169,6 +170,18 @@ export async function healthCheck(): Promise<{ status: string }> {
|
|
| 169 |
}
|
| 170 |
}
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
export async function getScenarios(): Promise<BackendScenarioFamily[]> {
|
| 173 |
try {
|
| 174 |
const res = await fetch(`${BASE_URL}/scenarios`);
|
|
@@ -271,6 +284,62 @@ export async function stepEpisode(
|
|
| 271 |
};
|
| 272 |
}
|
| 273 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
export async function getReplay(episodeId: string): Promise<unknown> {
|
| 275 |
try {
|
| 276 |
const res = await fetch(`${BASE_URL}/replay/${episodeId}`);
|
|
|
|
| 16 |
ScenarioTemplate,
|
| 17 |
Difficulty,
|
| 18 |
EpisodeStepTrace,
|
| 19 |
+
BackendRuntimeStatus,
|
| 20 |
} from '@/types';
|
| 21 |
|
| 22 |
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? '/api';
|
|
|
|
| 158 |
// REST API functions
|
| 159 |
// ---------------------------------------------------------------------------
|
| 160 |
|
| 161 |
+
export async function healthCheck(): Promise<{ status: string; env?: string; version?: string }> {
|
| 162 |
try {
|
| 163 |
const res = await fetch(`${BASE_URL}/health`);
|
| 164 |
if (!res.ok) {
|
|
|
|
| 170 |
}
|
| 171 |
}
|
| 172 |
|
| 173 |
+
export async function getRuntimeStatus(): Promise<BackendRuntimeStatus> {
|
| 174 |
+
try {
|
| 175 |
+
const res = await fetch(`${BASE_URL}/runtime`);
|
| 176 |
+
if (!res.ok) {
|
| 177 |
+
throw new Error(`Failed to fetch runtime status: ${res.status}`);
|
| 178 |
+
}
|
| 179 |
+
return res.json();
|
| 180 |
+
} catch (error) {
|
| 181 |
+
throw normalizeFetchError(error, 'Failed to fetch runtime status');
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
export async function getScenarios(): Promise<BackendScenarioFamily[]> {
|
| 186 |
try {
|
| 187 |
const res = await fetch(`${BASE_URL}/scenarios`);
|
|
|
|
| 284 |
};
|
| 285 |
}
|
| 286 |
|
| 287 |
+
export async function agentStepEpisode(
|
| 288 |
+
sessionId: string,
|
| 289 |
+
prevState: EpisodeState,
|
| 290 |
+
): Promise<EpisodeState> {
|
| 291 |
+
let data: BackendStepResult;
|
| 292 |
+
try {
|
| 293 |
+
const res = await fetch(`${BASE_URL}/agent-step`, {
|
| 294 |
+
method: 'POST',
|
| 295 |
+
headers: { 'Content-Type': 'application/json' },
|
| 296 |
+
body: JSON.stringify({ session_id: sessionId }),
|
| 297 |
+
});
|
| 298 |
+
if (!res.ok) {
|
| 299 |
+
const text = await res.text();
|
| 300 |
+
throw new Error(`Failed to run model-backed scientist step: ${text}`);
|
| 301 |
+
}
|
| 302 |
+
data = await res.json();
|
| 303 |
+
} catch (error) {
|
| 304 |
+
throw normalizeFetchError(error, 'Failed to run model-backed scientist step');
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
let scores: ScoreBreakdown | null = null;
|
| 308 |
+
let judgeAudit: JudgeAudit | null = null;
|
| 309 |
+
if (data.done && data.info.reward_breakdown) {
|
| 310 |
+
scores = adaptRewardBreakdown(data.info.reward_breakdown, data.reward);
|
| 311 |
+
judgeAudit = {
|
| 312 |
+
verdict: data.info.verdict ?? 'unknown',
|
| 313 |
+
judge_notes: data.info.judge_notes ? [data.info.judge_notes] : [],
|
| 314 |
+
top_failure_reasons: data.info.top_failure_reasons,
|
| 315 |
+
score_breakdown: scores,
|
| 316 |
+
};
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
const obs = data.observation;
|
| 320 |
+
const conversation = obs?.scientist
|
| 321 |
+
? adaptConversation(obs.scientist.conversation_history)
|
| 322 |
+
: prevState.conversation;
|
| 323 |
+
const protocol = obs?.scientist?.current_protocol ?? prevState.protocol;
|
| 324 |
+
const round = obs?.scientist?.round_number ?? prevState.round + 1;
|
| 325 |
+
const cumulativeReward = data.info.cumulative_reward ?? prevState.cumulative_reward + data.reward;
|
| 326 |
+
const labConstraints = obs ? adaptLabConstraints(obs) : prevState.lab_constraints;
|
| 327 |
+
const roundTrace = buildRoundTrace(prevState, data);
|
| 328 |
+
|
| 329 |
+
return {
|
| 330 |
+
...prevState,
|
| 331 |
+
round,
|
| 332 |
+
done: data.done,
|
| 333 |
+
protocol,
|
| 334 |
+
conversation,
|
| 335 |
+
lab_constraints: labConstraints,
|
| 336 |
+
scores,
|
| 337 |
+
judge_audit: judgeAudit,
|
| 338 |
+
cumulative_reward: cumulativeReward,
|
| 339 |
+
step_history: [...prevState.step_history, roundTrace],
|
| 340 |
+
};
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
export async function getReplay(episodeId: string): Promise<unknown> {
|
| 344 |
try {
|
| 345 |
const res = await fetch(`${BASE_URL}/replay/${episodeId}`);
|
frontend/src/lib/demo.ts
CHANGED
|
@@ -33,6 +33,20 @@ export const DEMO_CASES: DemoCaseMeta[] = [
|
|
| 33 |
},
|
| 34 |
];
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
export function parseDemoCase(value: string | null): DemoCase | undefined {
|
| 37 |
return DEMO_CASES.find((item) => item.id === value)?.id;
|
| 38 |
}
|
|
|
|
| 33 |
},
|
| 34 |
];
|
| 35 |
|
| 36 |
+
const LIVE_TEMPLATES = ['math_reasoning', 'ml_benchmark', 'finance_trading'] as const;
|
| 37 |
+
const LIVE_DIFFICULTIES = ['easy', 'medium', 'medium', 'hard'] as const;
|
| 38 |
+
|
| 39 |
+
function sampleRandom<T>(items: readonly T[]): T {
|
| 40 |
+
return items[Math.floor(Math.random() * items.length)];
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
export function buildLiveEpisodePath(): string {
|
| 44 |
+
const template = sampleRandom(LIVE_TEMPLATES);
|
| 45 |
+
const difficulty = sampleRandom(LIVE_DIFFICULTIES);
|
| 46 |
+
const seed = Math.floor(Math.random() * 9000) + 1000;
|
| 47 |
+
return `/episode?template=${template}&difficulty=${difficulty}&seed=${seed}&autostart=1&autoplay=1`;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
export function parseDemoCase(value: string | null): DemoCase | undefined {
|
| 51 |
return DEMO_CASES.find((item) => item.id === value)?.id;
|
| 52 |
}
|
frontend/src/pages/ComparePage.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { motion } from 'framer-motion';
|
| 2 |
import { GitCompareArrows } from 'lucide-react';
|
|
|
|
| 3 |
import EpisodeComparison from '@/components/EpisodeComparison';
|
| 4 |
|
| 5 |
export default function ComparePage() {
|
|
@@ -42,6 +43,25 @@ export default function ComparePage() {
|
|
| 42 |
</div>
|
| 43 |
</motion.div>
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
<motion.div
|
| 46 |
initial={{ opacity: 0, y: 20 }}
|
| 47 |
animate={{ opacity: 1, y: 0 }}
|
|
|
|
| 1 |
import { motion } from 'framer-motion';
|
| 2 |
import { GitCompareArrows } from 'lucide-react';
|
| 3 |
+
import { Link } from 'react-router-dom';
|
| 4 |
import EpisodeComparison from '@/components/EpisodeComparison';
|
| 5 |
|
| 6 |
export default function ComparePage() {
|
|
|
|
| 43 |
</div>
|
| 44 |
</motion.div>
|
| 45 |
|
| 46 |
+
<motion.div
|
| 47 |
+
className="mb-6 rounded-lg border border-judge/30 bg-judge/5 p-4"
|
| 48 |
+
initial={{ opacity: 0, y: 10 }}
|
| 49 |
+
animate={{ opacity: 1, y: 0 }}
|
| 50 |
+
transition={{ delay: 0.15 }}
|
| 51 |
+
>
|
| 52 |
+
<h2 className="text-sm font-semibold text-judge">Important: what is running here</h2>
|
| 53 |
+
<p className="mt-1 text-sm text-muted-foreground">
|
| 54 |
+
This page currently benchmarks the live deterministic runtime. It does not mount the trained Scientist adapter
|
| 55 |
+
or the Anthropic oracle in the public app yet.
|
| 56 |
+
</p>
|
| 57 |
+
<Link
|
| 58 |
+
to="/policies"
|
| 59 |
+
className="mt-3 inline-flex items-center rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
| 60 |
+
>
|
| 61 |
+
Open baseline vs trained vs oracle
|
| 62 |
+
</Link>
|
| 63 |
+
</motion.div>
|
| 64 |
+
|
| 65 |
<motion.div
|
| 66 |
initial={{ opacity: 0, y: 20 }}
|
| 67 |
animate={{ opacity: 1, y: 0 }}
|
frontend/src/pages/DashboardPage.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
import { Suspense } from 'react';
|
| 2 |
-
import { Link } from 'react-router-dom';
|
| 3 |
import {
|
| 4 |
FlaskConical,
|
| 5 |
Play,
|
|
@@ -19,7 +19,7 @@ import AnimatedCharacter from '@/components/AnimatedCharacter';
|
|
| 19 |
import MoleculeScene from '@/components/MoleculeScene';
|
| 20 |
import TiltCard from '@/components/TiltCard';
|
| 21 |
import { cn } from '@/lib/utils';
|
| 22 |
-
import { DEMO_CASES } from '@/lib/demo';
|
| 23 |
|
| 24 |
const SCENARIOS = [
|
| 25 |
{
|
|
@@ -105,6 +105,12 @@ const FLOW = [
|
|
| 105 |
];
|
| 106 |
|
| 107 |
export default function DashboardPage() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
return (
|
| 109 |
<div className="mx-auto max-w-screen-xl px-4 py-8">
|
| 110 |
{/* Hero with 3D background */}
|
|
@@ -179,13 +185,14 @@ export default function DashboardPage() {
|
|
| 179 |
</div>
|
| 180 |
|
| 181 |
<div className="mt-8 flex items-center justify-center gap-3">
|
| 182 |
-
<
|
| 183 |
-
|
|
|
|
| 184 |
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 shadow-lg shadow-primary/25"
|
| 185 |
>
|
| 186 |
<Play className="h-4 w-4" />
|
| 187 |
-
Replicate a Paper
|
| 188 |
-
</
|
| 189 |
<Link
|
| 190 |
to="/training"
|
| 191 |
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background/80 backdrop-blur-sm px-5 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-muted"
|
|
@@ -204,6 +211,9 @@ export default function DashboardPage() {
|
|
| 204 |
</div>
|
| 205 |
))}
|
| 206 |
</div>
|
|
|
|
|
|
|
|
|
|
| 207 |
</div>
|
| 208 |
</section>
|
| 209 |
|
|
@@ -301,9 +311,9 @@ export default function DashboardPage() {
|
|
| 301 |
</section>
|
| 302 |
|
| 303 |
<section className="mb-16">
|
| 304 |
-
<h2 className="mb-2 text-center text-xl font-semibold">
|
| 305 |
<p className="mb-8 text-center text-sm text-muted-foreground">
|
| 306 |
-
|
| 307 |
</p>
|
| 308 |
<div className="grid gap-4 md:grid-cols-3">
|
| 309 |
{DEMO_CASES.map((demo) => {
|
|
|
|
| 1 |
+
import { Suspense, useCallback } from 'react';
|
| 2 |
+
import { Link, useNavigate } from 'react-router-dom';
|
| 3 |
import {
|
| 4 |
FlaskConical,
|
| 5 |
Play,
|
|
|
|
| 19 |
import MoleculeScene from '@/components/MoleculeScene';
|
| 20 |
import TiltCard from '@/components/TiltCard';
|
| 21 |
import { cn } from '@/lib/utils';
|
| 22 |
+
import { buildLiveEpisodePath, DEMO_CASES } from '@/lib/demo';
|
| 23 |
|
| 24 |
const SCENARIOS = [
|
| 25 |
{
|
|
|
|
| 105 |
];
|
| 106 |
|
| 107 |
export default function DashboardPage() {
|
| 108 |
+
const navigate = useNavigate();
|
| 109 |
+
|
| 110 |
+
const launchDynamicEpisode = useCallback(() => {
|
| 111 |
+
navigate(buildLiveEpisodePath());
|
| 112 |
+
}, [navigate]);
|
| 113 |
+
|
| 114 |
return (
|
| 115 |
<div className="mx-auto max-w-screen-xl px-4 py-8">
|
| 116 |
{/* Hero with 3D background */}
|
|
|
|
| 185 |
</div>
|
| 186 |
|
| 187 |
<div className="mt-8 flex items-center justify-center gap-3">
|
| 188 |
+
<button
|
| 189 |
+
type="button"
|
| 190 |
+
onClick={launchDynamicEpisode}
|
| 191 |
className="inline-flex items-center gap-2 rounded-lg bg-primary px-5 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 shadow-lg shadow-primary/25"
|
| 192 |
>
|
| 193 |
<Play className="h-4 w-4" />
|
| 194 |
+
Replicate a Random Paper
|
| 195 |
+
</button>
|
| 196 |
<Link
|
| 197 |
to="/training"
|
| 198 |
className="inline-flex items-center gap-2 rounded-lg border border-border bg-background/80 backdrop-blur-sm px-5 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-muted"
|
|
|
|
| 211 |
</div>
|
| 212 |
))}
|
| 213 |
</div>
|
| 214 |
+
<p className="mx-auto mt-3 max-w-2xl text-xs text-muted-foreground">
|
| 215 |
+
The main button now launches a different seeded paper-derived benchmark each time. Use the cards below only when you want a fixed scripted outcome for the demo.
|
| 216 |
+
</p>
|
| 217 |
</div>
|
| 218 |
</section>
|
| 219 |
|
|
|
|
| 311 |
</section>
|
| 312 |
|
| 313 |
<section className="mb-16">
|
| 314 |
+
<h2 className="mb-2 text-center text-xl font-semibold">Scripted Demo Outcomes</h2>
|
| 315 |
<p className="mb-8 text-center text-sm text-muted-foreground">
|
| 316 |
+
Use these only when you want a fixed story: immediate agreement, multi-round learning, or clear rejection.
|
| 317 |
</p>
|
| 318 |
<div className="grid gap-4 md:grid-cols-3">
|
| 319 |
{DEMO_CASES.map((demo) => {
|
frontend/src/pages/EpisodePage.tsx
CHANGED
|
@@ -2,8 +2,8 @@ import { useState, useCallback, useMemo, useEffect, useRef, Suspense } from 'rea
|
|
| 2 |
import { useNavigate, useSearchParams } from 'react-router-dom';
|
| 3 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 4 |
import { FileText, FlaskConical, Scale, TrendingUp, Play, Pencil } from 'lucide-react';
|
| 5 |
-
import type { EpisodeState, ResetParams, ScientistAction } from '@/types';
|
| 6 |
-
import { resetEpisode, stepEpisode } from '@/lib/api';
|
| 7 |
import { sfx, startAmbient, stopAmbient } from '@/lib/audio';
|
| 8 |
import { fireSuccessConfetti, fireGavelConfetti } from '@/lib/confetti';
|
| 9 |
import { buildDemoScientistAction, DEMO_CASES, parseDemoCase } from '@/lib/demo';
|
|
@@ -40,6 +40,7 @@ export default function EpisodePage() {
|
|
| 40 |
const [isJudging, setIsJudging] = useState(false);
|
| 41 |
const [error, setError] = useState<string | null>(null);
|
| 42 |
const [autoStartTriggered, setAutoStartTriggered] = useState(false);
|
|
|
|
| 43 |
|
| 44 |
// Feature 5: Auto-play
|
| 45 |
const [autoPlaying, setAutoPlaying] = useState(false);
|
|
@@ -64,18 +65,26 @@ export default function EpisodePage() {
|
|
| 64 |
const parsed = Number.parseInt(value, 10);
|
| 65 |
return Number.isNaN(parsed) ? undefined : parsed;
|
| 66 |
}, [searchParams]);
|
| 67 |
-
const
|
| 68 |
-
() => searchParams.get('demo') === '1'
|
| 69 |
[searchParams],
|
| 70 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
const demoCase = useMemo(() => parseDemoCase(searchParams.get('demoCase')), [searchParams]);
|
| 72 |
const demoMeta = useMemo(
|
| 73 |
() => DEMO_CASES.find((item) => item.id === demoCase),
|
| 74 |
[demoCase],
|
| 75 |
);
|
| 76 |
const autoPlayRequested = useMemo(
|
| 77 |
-
() =>
|
| 78 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
);
|
| 80 |
|
| 81 |
const phase = useMemo(() => {
|
|
@@ -98,6 +107,9 @@ export default function EpisodePage() {
|
|
| 98 |
useEffect(() => {
|
| 99 |
const prev = prevPhaseRef.current;
|
| 100 |
prevPhaseRef.current = phase;
|
|
|
|
|
|
|
|
|
|
| 101 |
|
| 102 |
if (prev === 'waiting' && phase === 'negotiating') {
|
| 103 |
sfx.episodeStart();
|
|
@@ -112,18 +124,21 @@ export default function EpisodePage() {
|
|
| 112 |
stopAmbient();
|
| 113 |
sfx.scoreReveal();
|
| 114 |
const verdict = episode?.judge_audit?.verdict;
|
| 115 |
-
if (verdict === 'accept' || verdict === 'success') {
|
| 116 |
setTimeout(() => {
|
| 117 |
sfx.success();
|
| 118 |
fireSuccessConfetti();
|
| 119 |
}, 400);
|
| 120 |
toast('Episode complete - Agreement reached!', 'success');
|
|
|
|
|
|
|
|
|
|
| 121 |
} else if (verdict) {
|
| 122 |
setTimeout(() => sfx.failure(), 400);
|
| 123 |
toast(`Episode complete - Verdict: ${verdict}`, 'warning');
|
| 124 |
}
|
| 125 |
}
|
| 126 |
-
}, [phase, episode?.judge_audit
|
| 127 |
|
| 128 |
useEffect(() => {
|
| 129 |
if (!episode?.conversation.length) return;
|
|
@@ -142,6 +157,28 @@ export default function EpisodePage() {
|
|
| 142 |
return () => stopAmbient();
|
| 143 |
}, []);
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
useEffect(() => {
|
| 146 |
if (episode?.done) {
|
| 147 |
resultsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
@@ -165,9 +202,12 @@ export default function EpisodePage() {
|
|
| 165 |
nextSearch.set('template', state.template);
|
| 166 |
nextSearch.set('difficulty', state.difficulty);
|
| 167 |
nextSearch.set('seed', String(state.seed));
|
| 168 |
-
if (
|
| 169 |
nextSearch.set('demo', '1');
|
| 170 |
}
|
|
|
|
|
|
|
|
|
|
| 171 |
if (autoPlayRequested) {
|
| 172 |
nextSearch.set('autoplay', '1');
|
| 173 |
}
|
|
@@ -183,7 +223,7 @@ export default function EpisodePage() {
|
|
| 183 |
} finally {
|
| 184 |
setLoading(false);
|
| 185 |
}
|
| 186 |
-
}, [autoPlayRequested,
|
| 187 |
|
| 188 |
const handleStepWithAction = useCallback(async (action?: ScientistAction) => {
|
| 189 |
if (!episode || episode.done) return;
|
|
@@ -203,7 +243,9 @@ export default function EpisodePage() {
|
|
| 203 |
await new Promise((r) => setTimeout(r, 2000));
|
| 204 |
}
|
| 205 |
|
| 206 |
-
const state =
|
|
|
|
|
|
|
| 207 |
|
| 208 |
if (state.done && !isLastRound) {
|
| 209 |
setIsJudging(true);
|
|
@@ -223,10 +265,10 @@ export default function EpisodePage() {
|
|
| 223 |
} finally {
|
| 224 |
setLoading(false);
|
| 225 |
}
|
| 226 |
-
}, [demoCase, episode, toast]);
|
| 227 |
|
| 228 |
useEffect(() => {
|
| 229 |
-
if (!
|
| 230 |
return;
|
| 231 |
}
|
| 232 |
setAutoStartTriggered(true);
|
|
@@ -237,7 +279,7 @@ export default function EpisodePage() {
|
|
| 237 |
});
|
| 238 |
}, [
|
| 239 |
autoStartTriggered,
|
| 240 |
-
|
| 241 |
episode,
|
| 242 |
handleStart,
|
| 243 |
initialDifficulty,
|
|
@@ -318,11 +360,11 @@ export default function EpisodePage() {
|
|
| 318 |
|
| 319 |
<motion.div className="mb-8 text-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.4 }}>
|
| 320 |
<h1 className="mb-2 text-2xl font-bold">
|
| 321 |
-
{
|
| 322 |
</h1>
|
| 323 |
<p className="text-muted-foreground">
|
| 324 |
-
{
|
| 325 |
-
? 'Loading the
|
| 326 |
: 'Pick a seeded paper-derived benchmark, parse it into the environment, and watch the agents negotiate a reproducible plan.'}
|
| 327 |
</p>
|
| 328 |
<ShortcutHint className="mt-2" />
|
|
@@ -334,7 +376,7 @@ export default function EpisodePage() {
|
|
| 334 |
</motion.div>
|
| 335 |
)}
|
| 336 |
|
| 337 |
-
{!
|
| 338 |
<motion.div className="w-full max-w-sm" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.5 }}>
|
| 339 |
<Controls
|
| 340 |
onStart={handleStart}
|
|
@@ -343,6 +385,7 @@ export default function EpisodePage() {
|
|
| 343 |
initialSeed={initialSeed}
|
| 344 |
initialTemplate={initialTemplate}
|
| 345 |
initialDifficulty={initialDifficulty}
|
|
|
|
| 346 |
/>
|
| 347 |
</motion.div>
|
| 348 |
)}
|
|
@@ -413,7 +456,7 @@ export default function EpisodePage() {
|
|
| 413 |
))}
|
| 414 |
</motion.div>
|
| 415 |
|
| 416 |
-
{demoMeta && (
|
| 417 |
<motion.div
|
| 418 |
className="mb-4 rounded-xl border border-primary/20 bg-primary/5 p-4"
|
| 419 |
initial={{ opacity: 0, y: 8 }}
|
|
@@ -435,6 +478,34 @@ export default function EpisodePage() {
|
|
| 435 |
</motion.div>
|
| 436 |
)}
|
| 437 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
{!episode.done && episode.conversation.length === 0 && (
|
| 439 |
<motion.div
|
| 440 |
className="mb-4 rounded-lg border border-primary/20 bg-primary/5 p-4"
|
|
@@ -519,6 +590,7 @@ export default function EpisodePage() {
|
|
| 519 |
onStep={!episode.done ? handleStep : undefined}
|
| 520 |
disabled={loading}
|
| 521 |
episodeActive={true}
|
|
|
|
| 522 |
/>
|
| 523 |
|
| 524 |
{/* Feature 5: Auto-play controls */}
|
|
|
|
| 2 |
import { useNavigate, useSearchParams } from 'react-router-dom';
|
| 3 |
import { motion, AnimatePresence } from 'framer-motion';
|
| 4 |
import { FileText, FlaskConical, Scale, TrendingUp, Play, Pencil } from 'lucide-react';
|
| 5 |
+
import type { BackendRuntimeStatus, EpisodeState, ResetParams, ScientistAction } from '@/types';
|
| 6 |
+
import { agentStepEpisode, getRuntimeStatus, resetEpisode, stepEpisode } from '@/lib/api';
|
| 7 |
import { sfx, startAmbient, stopAmbient } from '@/lib/audio';
|
| 8 |
import { fireSuccessConfetti, fireGavelConfetti } from '@/lib/confetti';
|
| 9 |
import { buildDemoScientistAction, DEMO_CASES, parseDemoCase } from '@/lib/demo';
|
|
|
|
| 40 |
const [isJudging, setIsJudging] = useState(false);
|
| 41 |
const [error, setError] = useState<string | null>(null);
|
| 42 |
const [autoStartTriggered, setAutoStartTriggered] = useState(false);
|
| 43 |
+
const [runtimeStatus, setRuntimeStatus] = useState<BackendRuntimeStatus | null>(null);
|
| 44 |
|
| 45 |
// Feature 5: Auto-play
|
| 46 |
const [autoPlaying, setAutoPlaying] = useState(false);
|
|
|
|
| 65 |
const parsed = Number.parseInt(value, 10);
|
| 66 |
return Number.isNaN(parsed) ? undefined : parsed;
|
| 67 |
}, [searchParams]);
|
| 68 |
+
const scriptedDemoRequested = useMemo(
|
| 69 |
+
() => searchParams.get('demo') === '1',
|
| 70 |
[searchParams],
|
| 71 |
);
|
| 72 |
+
const autoStartRequested = useMemo(
|
| 73 |
+
() => scriptedDemoRequested || searchParams.get('autostart') === '1',
|
| 74 |
+
[scriptedDemoRequested, searchParams],
|
| 75 |
+
);
|
| 76 |
const demoCase = useMemo(() => parseDemoCase(searchParams.get('demoCase')), [searchParams]);
|
| 77 |
const demoMeta = useMemo(
|
| 78 |
() => DEMO_CASES.find((item) => item.id === demoCase),
|
| 79 |
[demoCase],
|
| 80 |
);
|
| 81 |
const autoPlayRequested = useMemo(
|
| 82 |
+
() => autoStartRequested || searchParams.get('autoplay') === '1',
|
| 83 |
+
[autoStartRequested, searchParams],
|
| 84 |
+
);
|
| 85 |
+
const backendModelStepAvailable = useMemo(
|
| 86 |
+
() => !scriptedDemoRequested && Boolean(runtimeStatus?.agent_step_available),
|
| 87 |
+
[scriptedDemoRequested, runtimeStatus?.agent_step_available],
|
| 88 |
);
|
| 89 |
|
| 90 |
const phase = useMemo(() => {
|
|
|
|
| 107 |
useEffect(() => {
|
| 108 |
const prev = prevPhaseRef.current;
|
| 109 |
prevPhaseRef.current = phase;
|
| 110 |
+
const hasAcceptCaveats =
|
| 111 |
+
episode?.judge_audit?.verdict === 'accept' &&
|
| 112 |
+
(episode.judge_audit?.top_failure_reasons.length ?? 0) > 0;
|
| 113 |
|
| 114 |
if (prev === 'waiting' && phase === 'negotiating') {
|
| 115 |
sfx.episodeStart();
|
|
|
|
| 124 |
stopAmbient();
|
| 125 |
sfx.scoreReveal();
|
| 126 |
const verdict = episode?.judge_audit?.verdict;
|
| 127 |
+
if ((verdict === 'accept' || verdict === 'success') && !hasAcceptCaveats) {
|
| 128 |
setTimeout(() => {
|
| 129 |
sfx.success();
|
| 130 |
fireSuccessConfetti();
|
| 131 |
}, 400);
|
| 132 |
toast('Episode complete - Agreement reached!', 'success');
|
| 133 |
+
} else if (verdict === 'accept' && hasAcceptCaveats) {
|
| 134 |
+
setTimeout(() => sfx.roundTick(), 400);
|
| 135 |
+
toast('Episode complete - Accepted with caveats', 'warning');
|
| 136 |
} else if (verdict) {
|
| 137 |
setTimeout(() => sfx.failure(), 400);
|
| 138 |
toast(`Episode complete - Verdict: ${verdict}`, 'warning');
|
| 139 |
}
|
| 140 |
}
|
| 141 |
+
}, [phase, episode?.judge_audit, toast]);
|
| 142 |
|
| 143 |
useEffect(() => {
|
| 144 |
if (!episode?.conversation.length) return;
|
|
|
|
| 157 |
return () => stopAmbient();
|
| 158 |
}, []);
|
| 159 |
|
| 160 |
+
useEffect(() => {
|
| 161 |
+
let cancelled = false;
|
| 162 |
+
|
| 163 |
+
async function loadRuntimeStatus() {
|
| 164 |
+
try {
|
| 165 |
+
const status = await getRuntimeStatus();
|
| 166 |
+
if (!cancelled) {
|
| 167 |
+
setRuntimeStatus(status);
|
| 168 |
+
}
|
| 169 |
+
} catch (runtimeError) {
|
| 170 |
+
if (!cancelled) {
|
| 171 |
+
console.warn('Failed to load runtime status', runtimeError);
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
void loadRuntimeStatus();
|
| 177 |
+
return () => {
|
| 178 |
+
cancelled = true;
|
| 179 |
+
};
|
| 180 |
+
}, []);
|
| 181 |
+
|
| 182 |
useEffect(() => {
|
| 183 |
if (episode?.done) {
|
| 184 |
resultsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
|
|
| 202 |
nextSearch.set('template', state.template);
|
| 203 |
nextSearch.set('difficulty', state.difficulty);
|
| 204 |
nextSearch.set('seed', String(state.seed));
|
| 205 |
+
if (scriptedDemoRequested) {
|
| 206 |
nextSearch.set('demo', '1');
|
| 207 |
}
|
| 208 |
+
if (autoStartRequested && !scriptedDemoRequested) {
|
| 209 |
+
nextSearch.set('autostart', '1');
|
| 210 |
+
}
|
| 211 |
if (autoPlayRequested) {
|
| 212 |
nextSearch.set('autoplay', '1');
|
| 213 |
}
|
|
|
|
| 223 |
} finally {
|
| 224 |
setLoading(false);
|
| 225 |
}
|
| 226 |
+
}, [autoPlayRequested, autoStartRequested, demoCase, navigate, scriptedDemoRequested, toast]);
|
| 227 |
|
| 228 |
const handleStepWithAction = useCallback(async (action?: ScientistAction) => {
|
| 229 |
if (!episode || episode.done) return;
|
|
|
|
| 243 |
await new Promise((r) => setTimeout(r, 2000));
|
| 244 |
}
|
| 245 |
|
| 246 |
+
const state = !action && backendModelStepAvailable
|
| 247 |
+
? await agentStepEpisode(episode.session_id, episode)
|
| 248 |
+
: await stepEpisode(episode.session_id, finalAction, episode);
|
| 249 |
|
| 250 |
if (state.done && !isLastRound) {
|
| 251 |
setIsJudging(true);
|
|
|
|
| 265 |
} finally {
|
| 266 |
setLoading(false);
|
| 267 |
}
|
| 268 |
+
}, [backendModelStepAvailable, demoCase, episode, toast]);
|
| 269 |
|
| 270 |
useEffect(() => {
|
| 271 |
+
if (!autoStartRequested || autoStartTriggered || episode || loading) {
|
| 272 |
return;
|
| 273 |
}
|
| 274 |
setAutoStartTriggered(true);
|
|
|
|
| 279 |
});
|
| 280 |
}, [
|
| 281 |
autoStartTriggered,
|
| 282 |
+
autoStartRequested,
|
| 283 |
episode,
|
| 284 |
handleStart,
|
| 285 |
initialDifficulty,
|
|
|
|
| 360 |
|
| 361 |
<motion.div className="mb-8 text-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.4 }}>
|
| 362 |
<h1 className="mb-2 text-2xl font-bold">
|
| 363 |
+
{autoStartRequested ? 'Launching Live Replication Run' : 'Start a Paper Replication Episode'}
|
| 364 |
</h1>
|
| 365 |
<p className="text-muted-foreground">
|
| 366 |
+
{autoStartRequested
|
| 367 |
+
? 'Loading the benchmark, starting the agents, and running the negotiation automatically.'
|
| 368 |
: 'Pick a seeded paper-derived benchmark, parse it into the environment, and watch the agents negotiate a reproducible plan.'}
|
| 369 |
</p>
|
| 370 |
<ShortcutHint className="mt-2" />
|
|
|
|
| 376 |
</motion.div>
|
| 377 |
)}
|
| 378 |
|
| 379 |
+
{!autoStartRequested && (
|
| 380 |
<motion.div className="w-full max-w-sm" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.5 }}>
|
| 381 |
<Controls
|
| 382 |
onStart={handleStart}
|
|
|
|
| 385 |
initialSeed={initialSeed}
|
| 386 |
initialTemplate={initialTemplate}
|
| 387 |
initialDifficulty={initialDifficulty}
|
| 388 |
+
runtimeStatus={runtimeStatus}
|
| 389 |
/>
|
| 390 |
</motion.div>
|
| 391 |
)}
|
|
|
|
| 456 |
))}
|
| 457 |
</motion.div>
|
| 458 |
|
| 459 |
+
{demoMeta && scriptedDemoRequested && (
|
| 460 |
<motion.div
|
| 461 |
className="mb-4 rounded-xl border border-primary/20 bg-primary/5 p-4"
|
| 462 |
initial={{ opacity: 0, y: 8 }}
|
|
|
|
| 478 |
</motion.div>
|
| 479 |
)}
|
| 480 |
|
| 481 |
+
{!scriptedDemoRequested && runtimeStatus && (
|
| 482 |
+
<motion.div
|
| 483 |
+
className="mb-4 rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4"
|
| 484 |
+
initial={{ opacity: 0, y: 8 }}
|
| 485 |
+
animate={{ opacity: 1, y: 0 }}
|
| 486 |
+
transition={{ delay: 0.16 }}
|
| 487 |
+
>
|
| 488 |
+
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
| 489 |
+
<div>
|
| 490 |
+
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-emerald-600">
|
| 491 |
+
Scientist Runtime
|
| 492 |
+
</div>
|
| 493 |
+
<h2 className="mt-1 text-base font-semibold">
|
| 494 |
+
{(runtimeStatus.scientist_runtime === 'anthropic' || runtimeStatus.scientist_runtime === 'ollama') && runtimeStatus.scientist_ready
|
| 495 |
+
? 'Localhost is using a model-backed Scientist policy.'
|
| 496 |
+
: runtimeStatus.scientist_runtime === 'anthropic' || runtimeStatus.scientist_runtime === 'ollama'
|
| 497 |
+
? 'A model runtime is configured, but it is not ready.'
|
| 498 |
+
: 'Localhost is using the deterministic baseline Scientist policy.'}
|
| 499 |
+
</h2>
|
| 500 |
+
<p className="mt-1 text-sm text-muted-foreground">{runtimeStatus.note}</p>
|
| 501 |
+
</div>
|
| 502 |
+
<div className="rounded-lg border border-border bg-background px-3 py-2 text-xs text-muted-foreground">
|
| 503 |
+
{runtimeStatus.scientist_runtime} · {runtimeStatus.scientist_model}
|
| 504 |
+
</div>
|
| 505 |
+
</div>
|
| 506 |
+
</motion.div>
|
| 507 |
+
)}
|
| 508 |
+
|
| 509 |
{!episode.done && episode.conversation.length === 0 && (
|
| 510 |
<motion.div
|
| 511 |
className="mb-4 rounded-lg border border-primary/20 bg-primary/5 p-4"
|
|
|
|
| 590 |
onStep={!episode.done ? handleStep : undefined}
|
| 591 |
disabled={loading}
|
| 592 |
episodeActive={true}
|
| 593 |
+
runtimeStatus={runtimeStatus}
|
| 594 |
/>
|
| 595 |
|
| 596 |
{/* Feature 5: Auto-play controls */}
|
frontend/src/pages/PolicyComparePage.tsx
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { ReactNode } from 'react';
|
| 2 |
+
import { motion } from 'framer-motion';
|
| 3 |
+
import { Bot, BrainCircuit, CheckCircle2, FlaskConical, ShieldAlert } from 'lucide-react';
|
| 4 |
+
import {
|
| 5 |
+
ResponsiveContainer,
|
| 6 |
+
BarChart,
|
| 7 |
+
Bar,
|
| 8 |
+
CartesianGrid,
|
| 9 |
+
XAxis,
|
| 10 |
+
YAxis,
|
| 11 |
+
Tooltip,
|
| 12 |
+
Legend,
|
| 13 |
+
} from 'recharts';
|
| 14 |
+
import { CURRENT_RUNTIME_MODEL_STATUS, POLICY_COMPARE } from '@/data/trainingArtifacts';
|
| 15 |
+
import { cn, formatReward, formatScore } from '@/lib/utils';
|
| 16 |
+
|
| 17 |
+
const measuredPolicies = POLICY_COMPARE.filter((policy) => policy.averageReward !== null);
|
| 18 |
+
const chartRows = measuredPolicies.map((policy) => ({
|
| 19 |
+
label: policy.label,
|
| 20 |
+
reward: Number((policy.averageReward ?? 0).toFixed(2)),
|
| 21 |
+
agreement: Number(((policy.agreementRate ?? 0) * 100).toFixed(1)),
|
| 22 |
+
invalid: Number(((policy.invalidRate ?? 0) * 100).toFixed(1)),
|
| 23 |
+
}));
|
| 24 |
+
|
| 25 |
+
export default function PolicyComparePage() {
|
| 26 |
+
return (
|
| 27 |
+
<div className="mx-auto max-w-screen-xl px-4 py-8">
|
| 28 |
+
<motion.div
|
| 29 |
+
className="mb-8 text-center"
|
| 30 |
+
initial={{ opacity: 0, y: -10 }}
|
| 31 |
+
animate={{ opacity: 1, y: 0 }}
|
| 32 |
+
>
|
| 33 |
+
<div className="mb-3 inline-flex items-center gap-2 rounded-full bg-primary/10 px-4 py-1.5 text-sm font-medium text-primary">
|
| 34 |
+
<BrainCircuit className="h-4 w-4" />
|
| 35 |
+
Baseline Vs Trained Vs Oracle
|
| 36 |
+
</div>
|
| 37 |
+
<h1 className="mb-2 text-3xl font-bold tracking-tight">Policy Runtime And Results</h1>
|
| 38 |
+
<p className="mx-auto max-w-3xl text-muted-foreground">
|
| 39 |
+
This page separates three things that were easy to conflate in the demo: the live deterministic baseline
|
| 40 |
+
runtime, the trained Scientist artifact, and the planned oracle-assisted V2 path.
|
| 41 |
+
</p>
|
| 42 |
+
</motion.div>
|
| 43 |
+
|
| 44 |
+
<motion.div
|
| 45 |
+
className="mb-6 rounded-xl border border-judge/30 bg-judge/5 p-5"
|
| 46 |
+
initial={{ opacity: 0, y: 8 }}
|
| 47 |
+
animate={{ opacity: 1, y: 0 }}
|
| 48 |
+
transition={{ delay: 0.05 }}
|
| 49 |
+
>
|
| 50 |
+
<div className="mb-2 flex items-center gap-2 text-sm font-semibold text-judge">
|
| 51 |
+
<ShieldAlert className="h-4 w-4" />
|
| 52 |
+
Are we even running a model right now?
|
| 53 |
+
</div>
|
| 54 |
+
<p className="text-sm text-muted-foreground">{CURRENT_RUNTIME_MODEL_STATUS.note}</p>
|
| 55 |
+
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
| 56 |
+
<RuntimeFlag
|
| 57 |
+
label="Compare page"
|
| 58 |
+
value={CURRENT_RUNTIME_MODEL_STATUS.comparePageUsesLiveModel ? 'Model-backed' : 'Deterministic runtime'}
|
| 59 |
+
positive={CURRENT_RUNTIME_MODEL_STATUS.comparePageUsesLiveModel}
|
| 60 |
+
/>
|
| 61 |
+
<RuntimeFlag
|
| 62 |
+
label="Episode page"
|
| 63 |
+
value={CURRENT_RUNTIME_MODEL_STATUS.episodePageUsesLiveModel ? 'Model-backed' : 'Deterministic runtime'}
|
| 64 |
+
positive={CURRENT_RUNTIME_MODEL_STATUS.episodePageUsesLiveModel}
|
| 65 |
+
/>
|
| 66 |
+
<RuntimeFlag
|
| 67 |
+
label="Oracle path"
|
| 68 |
+
value={CURRENT_RUNTIME_MODEL_STATUS.backendUsesOracle ? 'Enabled' : 'Disabled in public runtime'}
|
| 69 |
+
positive={CURRENT_RUNTIME_MODEL_STATUS.backendUsesOracle}
|
| 70 |
+
/>
|
| 71 |
+
<RuntimeFlag
|
| 72 |
+
label="Judge"
|
| 73 |
+
value={CURRENT_RUNTIME_MODEL_STATUS.backendUsesDeterministicJudge ? 'Deterministic' : 'Model-scored'}
|
| 74 |
+
positive={CURRENT_RUNTIME_MODEL_STATUS.backendUsesDeterministicJudge}
|
| 75 |
+
/>
|
| 76 |
+
</div>
|
| 77 |
+
</motion.div>
|
| 78 |
+
|
| 79 |
+
<motion.div
|
| 80 |
+
className="mb-6 grid gap-4 lg:grid-cols-3"
|
| 81 |
+
initial={{ opacity: 0, y: 10 }}
|
| 82 |
+
animate={{ opacity: 1, y: 0 }}
|
| 83 |
+
transition={{ delay: 0.1 }}
|
| 84 |
+
>
|
| 85 |
+
{POLICY_COMPARE.map((policy) => (
|
| 86 |
+
<PolicyCard key={policy.id} policy={policy} />
|
| 87 |
+
))}
|
| 88 |
+
</motion.div>
|
| 89 |
+
|
| 90 |
+
<motion.div
|
| 91 |
+
className="mb-6 rounded-xl border border-border bg-card p-5"
|
| 92 |
+
initial={{ opacity: 0, y: 12 }}
|
| 93 |
+
animate={{ opacity: 1, y: 0 }}
|
| 94 |
+
transition={{ delay: 0.15 }}
|
| 95 |
+
>
|
| 96 |
+
<div className="mb-4">
|
| 97 |
+
<h2 className="text-base font-semibold">Measured policies only</h2>
|
| 98 |
+
<p className="mt-1 text-sm text-muted-foreground">
|
| 99 |
+
The chart below only includes policy variants that already have actual numeric results. The oracle lane is
|
| 100 |
+
intentionally excluded until a real evaluation artifact exists.
|
| 101 |
+
</p>
|
| 102 |
+
</div>
|
| 103 |
+
<div className="h-72">
|
| 104 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 105 |
+
<BarChart data={chartRows}>
|
| 106 |
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-border)" />
|
| 107 |
+
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
|
| 108 |
+
<YAxis tick={{ fontSize: 11 }} />
|
| 109 |
+
<Tooltip />
|
| 110 |
+
<Legend wrapperStyle={{ fontSize: '12px' }} />
|
| 111 |
+
<Bar dataKey="reward" fill="var(--color-primary)" radius={[6, 6, 0, 0]} name="Avg reward" />
|
| 112 |
+
<Bar dataKey="agreement" fill="var(--color-lab-manager)" radius={[6, 6, 0, 0]} name="Agreement %" />
|
| 113 |
+
<Bar dataKey="invalid" fill="var(--color-destructive)" radius={[6, 6, 0, 0]} name="Invalid %" />
|
| 114 |
+
</BarChart>
|
| 115 |
+
</ResponsiveContainer>
|
| 116 |
+
</div>
|
| 117 |
+
</motion.div>
|
| 118 |
+
|
| 119 |
+
<motion.div
|
| 120 |
+
className="rounded-xl border border-border bg-card p-5"
|
| 121 |
+
initial={{ opacity: 0, y: 12 }}
|
| 122 |
+
animate={{ opacity: 1, y: 0 }}
|
| 123 |
+
transition={{ delay: 0.2 }}
|
| 124 |
+
>
|
| 125 |
+
<h2 className="mb-4 text-base font-semibold">What each lane actually means</h2>
|
| 126 |
+
<div className="grid gap-4 lg:grid-cols-3">
|
| 127 |
+
<MeaningCard
|
| 128 |
+
icon={<FlaskConical className="h-4 w-4" />}
|
| 129 |
+
title="Baseline"
|
| 130 |
+
body="This is the current live runtime. It uses the default Scientist action builder plus the deterministic Lab Manager and Judge. It is stable, but it is not a trained LLM policy."
|
| 131 |
+
/>
|
| 132 |
+
<MeaningCard
|
| 133 |
+
icon={<Bot className="h-4 w-4" />}
|
| 134 |
+
title="Trained"
|
| 135 |
+
body="This lane uses the artifact-backed Scientist training results. The adapter exists and was evaluated, but it still loses badly to the deterministic baseline on hold-out seeds."
|
| 136 |
+
/>
|
| 137 |
+
<MeaningCard
|
| 138 |
+
icon={<CheckCircle2 className="h-4 w-4" />}
|
| 139 |
+
title="Oracle"
|
| 140 |
+
body="This is the planned Anthropic-assisted V2 lane. The code path exists, but the public app is not currently mounting it and there is no benchmark result we should claim yet."
|
| 141 |
+
/>
|
| 142 |
+
</div>
|
| 143 |
+
</motion.div>
|
| 144 |
+
</div>
|
| 145 |
+
);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function RuntimeFlag({
|
| 149 |
+
label,
|
| 150 |
+
value,
|
| 151 |
+
positive,
|
| 152 |
+
}: {
|
| 153 |
+
label: string;
|
| 154 |
+
value: string;
|
| 155 |
+
positive: boolean;
|
| 156 |
+
}) {
|
| 157 |
+
return (
|
| 158 |
+
<div className="rounded-lg border border-border bg-background px-3 py-3">
|
| 159 |
+
<div className="text-xs font-medium text-muted-foreground">{label}</div>
|
| 160 |
+
<div className={cn('mt-1 text-sm font-semibold', positive ? 'text-lab-manager' : 'text-judge')}>
|
| 161 |
+
{value}
|
| 162 |
+
</div>
|
| 163 |
+
</div>
|
| 164 |
+
);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
function PolicyCard({
|
| 168 |
+
policy,
|
| 169 |
+
}: {
|
| 170 |
+
policy: (typeof POLICY_COMPARE)[number];
|
| 171 |
+
}) {
|
| 172 |
+
const tone =
|
| 173 |
+
policy.status === 'live'
|
| 174 |
+
? 'border-lab-manager/30'
|
| 175 |
+
: policy.status === 'artifact'
|
| 176 |
+
? 'border-judge/30'
|
| 177 |
+
: 'border-border';
|
| 178 |
+
const badgeTone =
|
| 179 |
+
policy.status === 'live'
|
| 180 |
+
? 'bg-lab-manager/10 text-lab-manager'
|
| 181 |
+
: policy.status === 'artifact'
|
| 182 |
+
? 'bg-judge/10 text-judge'
|
| 183 |
+
: 'bg-muted text-muted-foreground';
|
| 184 |
+
|
| 185 |
+
return (
|
| 186 |
+
<div className={cn('rounded-xl border bg-card p-5', tone)}>
|
| 187 |
+
<div className="mb-3 flex items-start justify-between gap-3">
|
| 188 |
+
<div>
|
| 189 |
+
<h2 className="text-base font-semibold">{policy.label}</h2>
|
| 190 |
+
<p className="mt-1 text-xs text-muted-foreground">{policy.source}</p>
|
| 191 |
+
</div>
|
| 192 |
+
<span className={cn('rounded-full px-2 py-1 text-[11px] font-medium', badgeTone)}>
|
| 193 |
+
{policy.status}
|
| 194 |
+
</span>
|
| 195 |
+
</div>
|
| 196 |
+
|
| 197 |
+
<div className="mb-4 grid grid-cols-2 gap-2">
|
| 198 |
+
<MetricTile label="Avg reward" value={policy.averageReward === null ? 'Not run' : formatReward(policy.averageReward)} />
|
| 199 |
+
<MetricTile label="Agreement" value={policy.agreementRate === null ? 'Not run' : formatScore(policy.agreementRate)} />
|
| 200 |
+
<MetricTile label="Avg rounds" value={policy.averageRounds === null ? 'Not run' : policy.averageRounds.toFixed(1)} />
|
| 201 |
+
<MetricTile label="Invalid rate" value={policy.invalidRate === null ? 'Not run' : formatScore(policy.invalidRate)} />
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
<div className="space-y-2 text-xs text-muted-foreground">
|
| 205 |
+
<div><span className="font-semibold text-foreground">Scientist:</span> {policy.scientistMode}</div>
|
| 206 |
+
<div><span className="font-semibold text-foreground">Lab Manager:</span> {policy.labManagerMode}</div>
|
| 207 |
+
<div><span className="font-semibold text-foreground">Judge:</span> {policy.judgeMode}</div>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
<p className="mt-4 rounded-lg border border-border bg-muted/30 px-3 py-3 text-xs text-muted-foreground">
|
| 211 |
+
{policy.summary}
|
| 212 |
+
</p>
|
| 213 |
+
</div>
|
| 214 |
+
);
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
function MetricTile({ label, value }: { label: string; value: string }) {
|
| 218 |
+
return (
|
| 219 |
+
<div className="rounded-lg border border-border bg-muted/30 px-3 py-2">
|
| 220 |
+
<div className="text-[11px] text-muted-foreground">{label}</div>
|
| 221 |
+
<div className="mt-1 text-sm font-semibold">{value}</div>
|
| 222 |
+
</div>
|
| 223 |
+
);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
function MeaningCard({
|
| 227 |
+
icon,
|
| 228 |
+
title,
|
| 229 |
+
body,
|
| 230 |
+
}: {
|
| 231 |
+
icon: ReactNode;
|
| 232 |
+
title: string;
|
| 233 |
+
body: string;
|
| 234 |
+
}) {
|
| 235 |
+
return (
|
| 236 |
+
<div className="rounded-lg border border-border bg-muted/20 p-4">
|
| 237 |
+
<div className="mb-2 flex items-center gap-2 text-sm font-semibold">
|
| 238 |
+
{icon}
|
| 239 |
+
{title}
|
| 240 |
+
</div>
|
| 241 |
+
<p className="text-sm text-muted-foreground">{body}</p>
|
| 242 |
+
</div>
|
| 243 |
+
);
|
| 244 |
+
}
|
frontend/src/types/index.ts
CHANGED
|
@@ -229,6 +229,15 @@ export interface BackendScenarioFamily {
|
|
| 229 |
difficulties: string[];
|
| 230 |
}
|
| 231 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
// --- Training metrics ---
|
| 233 |
|
| 234 |
export interface TrainingMetrics {
|
|
|
|
| 229 |
difficulties: string[];
|
| 230 |
}
|
| 231 |
|
| 232 |
+
export interface BackendRuntimeStatus {
|
| 233 |
+
scientist_runtime: string;
|
| 234 |
+
scientist_model: string;
|
| 235 |
+
scientist_ready: boolean;
|
| 236 |
+
agent_step_available: boolean;
|
| 237 |
+
available_runtimes: string[];
|
| 238 |
+
note: string;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
// --- Training metrics ---
|
| 242 |
|
| 243 |
export interface TrainingMetrics {
|
pyproject.toml
CHANGED
|
@@ -17,6 +17,7 @@ dependencies = [
|
|
| 17 |
"fastapi>=0.115,<1.0",
|
| 18 |
"uvicorn[standard]>=0.34,<1.0",
|
| 19 |
"websockets>=15.0,<17.0",
|
|
|
|
| 20 |
"openenv-core[core]>=0.2.1,<0.3.0",
|
| 21 |
]
|
| 22 |
|
|
|
|
| 17 |
"fastapi>=0.115,<1.0",
|
| 18 |
"uvicorn[standard]>=0.34,<1.0",
|
| 19 |
"websockets>=15.0,<17.0",
|
| 20 |
+
"httpx>=0.27,<1.0",
|
| 21 |
"openenv-core[core]>=0.2.1,<0.3.0",
|
| 22 |
]
|
| 23 |
|
replicalab/agents/__init__.py
CHANGED
|
@@ -17,7 +17,9 @@ from .scientist_policy import (
|
|
| 17 |
RetryMetadata,
|
| 18 |
ScientistCallResult,
|
| 19 |
ScientistOutputParseError,
|
|
|
|
| 20 |
build_baseline_scientist_action,
|
|
|
|
| 21 |
build_remote_scientist_policy,
|
| 22 |
build_scientist_system_prompt,
|
| 23 |
call_scientist_with_retry,
|
|
@@ -33,7 +35,9 @@ __all__ = [
|
|
| 33 |
"RetryMetadata",
|
| 34 |
"ScientistCallResult",
|
| 35 |
"ScientistOutputParseError",
|
|
|
|
| 36 |
"SuggestionChange",
|
|
|
|
| 37 |
"build_baseline_scientist_action",
|
| 38 |
"build_remote_scientist_policy",
|
| 39 |
"build_judge_audit",
|
|
|
|
| 17 |
RetryMetadata,
|
| 18 |
ScientistCallResult,
|
| 19 |
ScientistOutputParseError,
|
| 20 |
+
build_anthropic_scientist_policy,
|
| 21 |
build_baseline_scientist_action,
|
| 22 |
+
build_ollama_scientist_policy,
|
| 23 |
build_remote_scientist_policy,
|
| 24 |
build_scientist_system_prompt,
|
| 25 |
call_scientist_with_retry,
|
|
|
|
| 35 |
"RetryMetadata",
|
| 36 |
"ScientistCallResult",
|
| 37 |
"ScientistOutputParseError",
|
| 38 |
+
"build_anthropic_scientist_policy",
|
| 39 |
"SuggestionChange",
|
| 40 |
+
"build_ollama_scientist_policy",
|
| 41 |
"build_baseline_scientist_action",
|
| 42 |
"build_remote_scientist_policy",
|
| 43 |
"build_judge_audit",
|
replicalab/agents/scientist_policy.py
CHANGED
|
@@ -19,6 +19,7 @@ import re
|
|
| 19 |
from importlib import import_module
|
| 20 |
from typing import Any, Callable, Literal, Mapping
|
| 21 |
|
|
|
|
| 22 |
from pydantic import BaseModel, ConfigDict, ValidationError
|
| 23 |
|
| 24 |
from replicalab.models import (
|
|
@@ -804,6 +805,127 @@ def build_remote_scientist_policy(
|
|
| 804 |
return policy_fn
|
| 805 |
|
| 806 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 807 |
def _build_live_scientist_system_prompt(
|
| 808 |
observation: ScientistObservation,
|
| 809 |
*,
|
|
@@ -877,3 +999,16 @@ def _extract_message_content(content: Any) -> str:
|
|
| 877 |
parts.append(str(text))
|
| 878 |
return "\n".join(parts)
|
| 879 |
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
from importlib import import_module
|
| 20 |
from typing import Any, Callable, Literal, Mapping
|
| 21 |
|
| 22 |
+
import httpx
|
| 23 |
from pydantic import BaseModel, ConfigDict, ValidationError
|
| 24 |
|
| 25 |
from replicalab.models import (
|
|
|
|
| 805 |
return policy_fn
|
| 806 |
|
| 807 |
|
| 808 |
+
def build_anthropic_scientist_policy(
|
| 809 |
+
*,
|
| 810 |
+
api_key: str,
|
| 811 |
+
model: str,
|
| 812 |
+
max_completion_tokens: int = 450,
|
| 813 |
+
temperature: float = 0.0,
|
| 814 |
+
max_retries: int = 2,
|
| 815 |
+
timeout_seconds: float = 60.0,
|
| 816 |
+
base_url: str = "https://api.anthropic.com/v1/messages",
|
| 817 |
+
client: httpx.Client | None = None,
|
| 818 |
+
) -> Callable[[ScientistObservation], ScientistAction]:
|
| 819 |
+
"""Create a sync Scientist policy callable backed by Anthropic Messages API."""
|
| 820 |
+
|
| 821 |
+
owned_client = client is None
|
| 822 |
+
transport = client or httpx.Client(timeout=timeout_seconds)
|
| 823 |
+
|
| 824 |
+
def generate_fn(messages: list[dict[str, str]]) -> str:
|
| 825 |
+
system_blocks = [msg["content"] for msg in messages if msg["role"] == "system"]
|
| 826 |
+
request_messages = [
|
| 827 |
+
{"role": msg["role"], "content": msg["content"]}
|
| 828 |
+
for msg in messages
|
| 829 |
+
if msg["role"] in {"user", "assistant"}
|
| 830 |
+
]
|
| 831 |
+
response = transport.post(
|
| 832 |
+
base_url,
|
| 833 |
+
headers={
|
| 834 |
+
"x-api-key": api_key,
|
| 835 |
+
"anthropic-version": "2023-06-01",
|
| 836 |
+
"content-type": "application/json",
|
| 837 |
+
},
|
| 838 |
+
json={
|
| 839 |
+
"model": model,
|
| 840 |
+
"max_tokens": max_completion_tokens,
|
| 841 |
+
"temperature": temperature,
|
| 842 |
+
"system": "\n\n".join(system_blocks),
|
| 843 |
+
"messages": request_messages,
|
| 844 |
+
},
|
| 845 |
+
)
|
| 846 |
+
response.raise_for_status()
|
| 847 |
+
payload = response.json()
|
| 848 |
+
return _extract_anthropic_message_text(payload.get("content", []))
|
| 849 |
+
|
| 850 |
+
def policy_fn(
|
| 851 |
+
observation: ScientistObservation,
|
| 852 |
+
*,
|
| 853 |
+
seed: int | None = None,
|
| 854 |
+
scenario: str | None = None,
|
| 855 |
+
difficulty: str | None = None,
|
| 856 |
+
) -> ScientistAction:
|
| 857 |
+
result = call_scientist_with_retry(
|
| 858 |
+
generate_fn,
|
| 859 |
+
_build_live_scientist_system_prompt(
|
| 860 |
+
observation,
|
| 861 |
+
difficulty=difficulty,
|
| 862 |
+
scenario=scenario,
|
| 863 |
+
),
|
| 864 |
+
observation,
|
| 865 |
+
max_retries=max_retries,
|
| 866 |
+
)
|
| 867 |
+
return result.action
|
| 868 |
+
|
| 869 |
+
setattr(policy_fn, "_replicalab_client", transport)
|
| 870 |
+
setattr(policy_fn, "_replicalab_owned_client", owned_client)
|
| 871 |
+
return policy_fn
|
| 872 |
+
|
| 873 |
+
|
| 874 |
+
def build_ollama_scientist_policy(
|
| 875 |
+
*,
|
| 876 |
+
model: str,
|
| 877 |
+
max_retries: int = 2,
|
| 878 |
+
temperature: float = 0.0,
|
| 879 |
+
timeout_seconds: float = 60.0,
|
| 880 |
+
base_url: str = "http://127.0.0.1:11434/api/chat",
|
| 881 |
+
client: httpx.Client | None = None,
|
| 882 |
+
) -> Callable[[ScientistObservation], ScientistAction]:
|
| 883 |
+
"""Create a sync Scientist policy callable backed by a local Ollama model."""
|
| 884 |
+
|
| 885 |
+
owned_client = client is None
|
| 886 |
+
transport = client or httpx.Client(timeout=timeout_seconds)
|
| 887 |
+
|
| 888 |
+
def generate_fn(messages: list[dict[str, str]]) -> str:
|
| 889 |
+
response = transport.post(
|
| 890 |
+
base_url,
|
| 891 |
+
json={
|
| 892 |
+
"model": model,
|
| 893 |
+
"messages": messages,
|
| 894 |
+
"stream": False,
|
| 895 |
+
"format": "json",
|
| 896 |
+
"options": {
|
| 897 |
+
"temperature": temperature,
|
| 898 |
+
},
|
| 899 |
+
},
|
| 900 |
+
)
|
| 901 |
+
response.raise_for_status()
|
| 902 |
+
payload = response.json()
|
| 903 |
+
return _extract_message_content(payload.get("message", {}).get("content", ""))
|
| 904 |
+
|
| 905 |
+
def policy_fn(
|
| 906 |
+
observation: ScientistObservation,
|
| 907 |
+
*,
|
| 908 |
+
seed: int | None = None,
|
| 909 |
+
scenario: str | None = None,
|
| 910 |
+
difficulty: str | None = None,
|
| 911 |
+
) -> ScientistAction:
|
| 912 |
+
result = call_scientist_with_retry(
|
| 913 |
+
generate_fn,
|
| 914 |
+
_build_live_scientist_system_prompt(
|
| 915 |
+
observation,
|
| 916 |
+
difficulty=difficulty,
|
| 917 |
+
scenario=scenario,
|
| 918 |
+
),
|
| 919 |
+
observation,
|
| 920 |
+
max_retries=max_retries,
|
| 921 |
+
)
|
| 922 |
+
return result.action
|
| 923 |
+
|
| 924 |
+
setattr(policy_fn, "_replicalab_client", transport)
|
| 925 |
+
setattr(policy_fn, "_replicalab_owned_client", owned_client)
|
| 926 |
+
return policy_fn
|
| 927 |
+
|
| 928 |
+
|
| 929 |
def _build_live_scientist_system_prompt(
|
| 930 |
observation: ScientistObservation,
|
| 931 |
*,
|
|
|
|
| 999 |
parts.append(str(text))
|
| 1000 |
return "\n".join(parts)
|
| 1001 |
return ""
|
| 1002 |
+
|
| 1003 |
+
|
| 1004 |
+
def _extract_anthropic_message_text(content: list[dict[str, Any]]) -> str:
|
| 1005 |
+
parts: list[str] = []
|
| 1006 |
+
for block in content:
|
| 1007 |
+
if not isinstance(block, dict):
|
| 1008 |
+
continue
|
| 1009 |
+
if block.get("type") != "text":
|
| 1010 |
+
continue
|
| 1011 |
+
text = block.get("text")
|
| 1012 |
+
if text:
|
| 1013 |
+
parts.append(str(text))
|
| 1014 |
+
return "\n".join(parts)
|
replicalab/config.py
CHANGED
|
@@ -10,6 +10,26 @@ from __future__ import annotations
|
|
| 10 |
|
| 11 |
import os
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
DEFAULT_SCENARIO_TEMPLATE = "math_reasoning"
|
| 14 |
DEFAULT_DIFFICULTY = "easy"
|
| 15 |
|
|
@@ -41,6 +61,44 @@ ORACLE_SCENARIO_CACHE_DIR = os.environ.get(
|
|
| 41 |
".scenario_cache",
|
| 42 |
)
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
# Deterministic reward shaping constants.
|
| 45 |
STEP_PROTOCOL_DELTA_SCALE = 0.25
|
| 46 |
STEP_PROTOCOL_DELTA_CAP = 0.3
|
|
|
|
| 10 |
|
| 11 |
import os
|
| 12 |
|
| 13 |
+
|
| 14 |
+
def _get_env_float(name: str, default: float) -> float:
|
| 15 |
+
raw = os.environ.get(name)
|
| 16 |
+
if raw is None:
|
| 17 |
+
return default
|
| 18 |
+
try:
|
| 19 |
+
return float(raw)
|
| 20 |
+
except ValueError:
|
| 21 |
+
return default
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _get_env_int(name: str, default: int) -> int:
|
| 25 |
+
raw = os.environ.get(name)
|
| 26 |
+
if raw is None:
|
| 27 |
+
return default
|
| 28 |
+
try:
|
| 29 |
+
return int(raw)
|
| 30 |
+
except ValueError:
|
| 31 |
+
return default
|
| 32 |
+
|
| 33 |
DEFAULT_SCENARIO_TEMPLATE = "math_reasoning"
|
| 34 |
DEFAULT_DIFFICULTY = "easy"
|
| 35 |
|
|
|
|
| 61 |
".scenario_cache",
|
| 62 |
)
|
| 63 |
|
| 64 |
+
|
| 65 |
+
def get_scientist_runtime() -> str:
|
| 66 |
+
configured = os.environ.get("REPLICALAB_SCIENTIST_RUNTIME")
|
| 67 |
+
if configured:
|
| 68 |
+
return configured.strip().lower()
|
| 69 |
+
return "anthropic" if os.environ.get("ANTHROPIC_API_KEY") else "baseline"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def get_scientist_model() -> str:
|
| 73 |
+
return os.environ.get("REPLICALAB_SCIENTIST_MODEL", "claude-3-5-haiku-latest")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def get_scientist_ollama_model() -> str:
|
| 77 |
+
return os.environ.get("REPLICALAB_SCIENTIST_OLLAMA_MODEL", "glm-5:cloud")
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def get_scientist_ollama_base_url() -> str:
|
| 81 |
+
return os.environ.get(
|
| 82 |
+
"REPLICALAB_SCIENTIST_OLLAMA_BASE_URL",
|
| 83 |
+
"http://127.0.0.1:11434/api/chat",
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def get_scientist_max_retries() -> int:
|
| 88 |
+
return _get_env_int("REPLICALAB_SCIENTIST_MAX_RETRIES", 2)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def get_scientist_max_completion_tokens() -> int:
|
| 92 |
+
return _get_env_int("REPLICALAB_SCIENTIST_MAX_COMPLETION_TOKENS", 450)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def get_scientist_temperature() -> float:
|
| 96 |
+
return _get_env_float("REPLICALAB_SCIENTIST_TEMPERATURE", 0.0)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def get_scientist_timeout_seconds() -> float:
|
| 100 |
+
return _get_env_float("REPLICALAB_SCIENTIST_TIMEOUT_SECONDS", 60.0)
|
| 101 |
+
|
| 102 |
# Deterministic reward shaping constants.
|
| 103 |
STEP_PROTOCOL_DELTA_SCALE = 0.25
|
| 104 |
STEP_PROTOCOL_DELTA_CAP = 0.3
|
server/app.py
CHANGED
|
@@ -22,6 +22,7 @@ from __future__ import annotations
|
|
| 22 |
import asyncio
|
| 23 |
import json
|
| 24 |
import logging
|
|
|
|
| 25 |
import time
|
| 26 |
import uuid
|
| 27 |
from contextlib import asynccontextmanager
|
|
@@ -35,7 +36,28 @@ from fastapi.responses import FileResponse, HTMLResponse
|
|
| 35 |
from fastapi.staticfiles import StaticFiles
|
| 36 |
from pydantic import BaseModel
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
from replicalab.agents import (
|
|
|
|
|
|
|
|
|
|
| 39 |
check_feasibility,
|
| 40 |
compose_lab_manager_response,
|
| 41 |
suggest_alternative,
|
|
@@ -50,6 +72,14 @@ from replicalab.config import (
|
|
| 50 |
SESSION_TTL_SECONDS,
|
| 51 |
STUB_ACCEPT_REWARD,
|
| 52 |
WS_IDLE_TIMEOUT_SECONDS,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
)
|
| 54 |
from replicalab.utils.logging import log_episode_reward, write_episode_log
|
| 55 |
from replicalab.scenarios import (
|
|
@@ -347,6 +377,225 @@ _sessions: dict[str, dict[str, Any]] = {}
|
|
| 347 |
_replay_store: dict[str, EpisodeLog] = {}
|
| 348 |
# { episode_id: EpisodeLog }
|
| 349 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
def _touch(session_id: str) -> None:
|
| 352 |
if session_id in _sessions:
|
|
@@ -444,6 +693,19 @@ class StepRequest(BaseModel):
|
|
| 444 |
action: ScientistAction
|
| 445 |
|
| 446 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
# ---------------------------------------------------------------------------
|
| 448 |
# REST endpoints
|
| 449 |
# ---------------------------------------------------------------------------
|
|
@@ -776,6 +1038,11 @@ async def health():
|
|
| 776 |
}
|
| 777 |
|
| 778 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 779 |
@_api.get("/scenarios", response_model=ScenariosResponse)
|
| 780 |
async def list_scenarios():
|
| 781 |
return ScenariosResponse(scenarios=SCENARIOS)
|
|
@@ -802,6 +1069,10 @@ async def reset_episode(req: ResetRequest):
|
|
| 802 |
"episode_id": episode_id,
|
| 803 |
"total_steps": 0,
|
| 804 |
"invalid_action_count": 0,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
}
|
| 806 |
|
| 807 |
log.info("REST reset | session=%s episode=%s", session_id, episode_id)
|
|
@@ -818,48 +1089,46 @@ async def step_episode(req: StepRequest):
|
|
| 818 |
env = session["env"]
|
| 819 |
|
| 820 |
result = env.step(req.action)
|
|
|
|
| 821 |
|
| 822 |
-
session["total_steps"] = session.get("total_steps", 0) + 1
|
| 823 |
-
if result.info.error is not None:
|
| 824 |
-
session["invalid_action_count"] = session.get("invalid_action_count", 0) + 1
|
| 825 |
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
session["episode_id"],
|
| 831 |
-
state,
|
| 832 |
-
result,
|
| 833 |
-
invalid_action_count=session.get("invalid_action_count", 0),
|
| 834 |
-
total_steps=session.get("total_steps", 0),
|
| 835 |
-
)
|
| 836 |
-
_replay_store[session["episode_id"]] = episode_log
|
| 837 |
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
episode_id=session["episode_id"],
|
| 842 |
-
seed=state.seed,
|
| 843 |
-
scenario_template=state.scenario_template,
|
| 844 |
-
difficulty=state.difficulty,
|
| 845 |
-
total_reward=state.reward,
|
| 846 |
-
breakdown=result.info.reward_breakdown,
|
| 847 |
-
rounds_used=state.round_number,
|
| 848 |
-
agreement_reached=result.info.agreement_reached,
|
| 849 |
-
verdict=result.info.verdict or "",
|
| 850 |
-
judge_notes=result.info.judge_notes or "",
|
| 851 |
-
)
|
| 852 |
-
except Exception:
|
| 853 |
-
log.exception("Failed to persist episode log to disk")
|
| 854 |
|
| 855 |
-
|
| 856 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 857 |
req.session_id,
|
| 858 |
-
session["episode_id"],
|
| 859 |
-
result.reward,
|
| 860 |
)
|
| 861 |
-
|
| 862 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 863 |
|
| 864 |
|
| 865 |
@_api.get("/replay/{episode_id}", response_model=EpisodeLog)
|
|
|
|
| 22 |
import asyncio
|
| 23 |
import json
|
| 24 |
import logging
|
| 25 |
+
import os
|
| 26 |
import time
|
| 27 |
import uuid
|
| 28 |
from contextlib import asynccontextmanager
|
|
|
|
| 36 |
from fastapi.staticfiles import StaticFiles
|
| 37 |
from pydantic import BaseModel
|
| 38 |
|
| 39 |
+
|
| 40 |
+
def _load_local_env() -> None:
|
| 41 |
+
env_path = Path(__file__).resolve().parent.parent / ".env"
|
| 42 |
+
if not env_path.is_file():
|
| 43 |
+
return
|
| 44 |
+
for raw_line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
| 45 |
+
line = raw_line.strip()
|
| 46 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 47 |
+
continue
|
| 48 |
+
key, value = line.split("=", 1)
|
| 49 |
+
key = key.strip()
|
| 50 |
+
if not key:
|
| 51 |
+
continue
|
| 52 |
+
os.environ.setdefault(key, value.strip().strip('"').strip("'"))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
_load_local_env()
|
| 56 |
+
|
| 57 |
from replicalab.agents import (
|
| 58 |
+
build_anthropic_scientist_policy,
|
| 59 |
+
build_baseline_scientist_action,
|
| 60 |
+
build_ollama_scientist_policy,
|
| 61 |
check_feasibility,
|
| 62 |
compose_lab_manager_response,
|
| 63 |
suggest_alternative,
|
|
|
|
| 72 |
SESSION_TTL_SECONDS,
|
| 73 |
STUB_ACCEPT_REWARD,
|
| 74 |
WS_IDLE_TIMEOUT_SECONDS,
|
| 75 |
+
get_scientist_max_completion_tokens,
|
| 76 |
+
get_scientist_max_retries,
|
| 77 |
+
get_scientist_model,
|
| 78 |
+
get_scientist_ollama_base_url,
|
| 79 |
+
get_scientist_ollama_model,
|
| 80 |
+
get_scientist_runtime,
|
| 81 |
+
get_scientist_temperature,
|
| 82 |
+
get_scientist_timeout_seconds,
|
| 83 |
)
|
| 84 |
from replicalab.utils.logging import log_episode_reward, write_episode_log
|
| 85 |
from replicalab.scenarios import (
|
|
|
|
| 377 |
_replay_store: dict[str, EpisodeLog] = {}
|
| 378 |
# { episode_id: EpisodeLog }
|
| 379 |
|
| 380 |
+
_SCIENTIST_POLICY_CACHE: dict[tuple[Any, ...], Any] = {}
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def _scientist_runtime_status() -> dict[str, Any]:
|
| 384 |
+
runtime = get_scientist_runtime()
|
| 385 |
+
if runtime == "anthropic":
|
| 386 |
+
model = get_scientist_model()
|
| 387 |
+
elif runtime == "ollama":
|
| 388 |
+
model = get_scientist_ollama_model()
|
| 389 |
+
else:
|
| 390 |
+
model = "baseline-heuristic"
|
| 391 |
+
anthropic_ready = bool(os.environ.get("ANTHROPIC_API_KEY"))
|
| 392 |
+
ready = (
|
| 393 |
+
runtime == "baseline"
|
| 394 |
+
or (runtime == "anthropic" and anthropic_ready)
|
| 395 |
+
or runtime == "ollama"
|
| 396 |
+
)
|
| 397 |
+
if runtime == "anthropic" and ready:
|
| 398 |
+
note = "Episodes can use backend model-driven Scientist inference through Anthropic."
|
| 399 |
+
elif runtime == "ollama":
|
| 400 |
+
note = "Episodes can use backend model-driven Scientist inference through the local Ollama runtime."
|
| 401 |
+
else:
|
| 402 |
+
note = "Episodes use the deterministic baseline Scientist policy."
|
| 403 |
+
return {
|
| 404 |
+
"scientist_runtime": runtime,
|
| 405 |
+
"scientist_model": model,
|
| 406 |
+
"scientist_ready": ready,
|
| 407 |
+
"agent_step_available": ready,
|
| 408 |
+
"available_runtimes": ["baseline", "anthropic", "ollama"],
|
| 409 |
+
"note": note,
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def _get_scientist_policy():
|
| 414 |
+
runtime = get_scientist_runtime()
|
| 415 |
+
if runtime == "baseline":
|
| 416 |
+
return build_baseline_scientist_action
|
| 417 |
+
if runtime == "anthropic":
|
| 418 |
+
api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
| 419 |
+
if not api_key:
|
| 420 |
+
raise RuntimeError("ANTHROPIC_API_KEY is not configured for Anthropic Scientist mode.")
|
| 421 |
+
cache_key = (
|
| 422 |
+
runtime,
|
| 423 |
+
get_scientist_model(),
|
| 424 |
+
get_scientist_max_completion_tokens(),
|
| 425 |
+
get_scientist_temperature(),
|
| 426 |
+
get_scientist_max_retries(),
|
| 427 |
+
get_scientist_timeout_seconds(),
|
| 428 |
+
)
|
| 429 |
+
elif runtime == "ollama":
|
| 430 |
+
cache_key = (
|
| 431 |
+
runtime,
|
| 432 |
+
get_scientist_ollama_model(),
|
| 433 |
+
get_scientist_ollama_base_url(),
|
| 434 |
+
get_scientist_temperature(),
|
| 435 |
+
get_scientist_max_retries(),
|
| 436 |
+
get_scientist_timeout_seconds(),
|
| 437 |
+
)
|
| 438 |
+
else:
|
| 439 |
+
raise RuntimeError(f"Unsupported scientist runtime '{runtime}'.")
|
| 440 |
+
cached = _SCIENTIST_POLICY_CACHE.get(cache_key)
|
| 441 |
+
if cached is not None:
|
| 442 |
+
return cached
|
| 443 |
+
|
| 444 |
+
if runtime == "anthropic":
|
| 445 |
+
policy = build_anthropic_scientist_policy(
|
| 446 |
+
api_key=api_key,
|
| 447 |
+
model=get_scientist_model(),
|
| 448 |
+
max_completion_tokens=get_scientist_max_completion_tokens(),
|
| 449 |
+
temperature=get_scientist_temperature(),
|
| 450 |
+
max_retries=get_scientist_max_retries(),
|
| 451 |
+
timeout_seconds=get_scientist_timeout_seconds(),
|
| 452 |
+
)
|
| 453 |
+
else:
|
| 454 |
+
policy = build_ollama_scientist_policy(
|
| 455 |
+
model=get_scientist_ollama_model(),
|
| 456 |
+
base_url=get_scientist_ollama_base_url(),
|
| 457 |
+
temperature=get_scientist_temperature(),
|
| 458 |
+
max_retries=0,
|
| 459 |
+
timeout_seconds=get_scientist_timeout_seconds(),
|
| 460 |
+
)
|
| 461 |
+
_SCIENTIST_POLICY_CACHE.clear()
|
| 462 |
+
_SCIENTIST_POLICY_CACHE[cache_key] = policy
|
| 463 |
+
return policy
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def _normalize_runtime_scientist_action(
|
| 467 |
+
session: dict[str, Any],
|
| 468 |
+
action: ScientistAction,
|
| 469 |
+
) -> tuple[ScientistAction, list[str]]:
|
| 470 |
+
observation = session.get("last_observation")
|
| 471 |
+
lab_obs = observation.lab_manager if observation is not None else None
|
| 472 |
+
action_type = (
|
| 473 |
+
action.action_type.value
|
| 474 |
+
if hasattr(action.action_type, "value")
|
| 475 |
+
else str(action.action_type)
|
| 476 |
+
)
|
| 477 |
+
if action_type not in {"propose_protocol", "revise_protocol"}:
|
| 478 |
+
return action, []
|
| 479 |
+
|
| 480 |
+
updates: dict[str, Any] = {}
|
| 481 |
+
notes: list[str] = []
|
| 482 |
+
max_controls = max(0, action.sample_size - 1)
|
| 483 |
+
if len(action.controls) > max_controls:
|
| 484 |
+
updates["controls"] = list(action.controls[:max_controls])
|
| 485 |
+
notes.append("trimmed_controls_to_fit_sample_size")
|
| 486 |
+
|
| 487 |
+
if lab_obs is not None:
|
| 488 |
+
if action.duration_days > lab_obs.time_limit_days:
|
| 489 |
+
updates["duration_days"] = lab_obs.time_limit_days
|
| 490 |
+
notes.append("clamped_duration_to_time_limit")
|
| 491 |
+
|
| 492 |
+
if lab_obs.equipment_available:
|
| 493 |
+
available_equipment = set(lab_obs.equipment_available)
|
| 494 |
+
filtered_equipment = [
|
| 495 |
+
item for item in action.required_equipment if item in available_equipment
|
| 496 |
+
]
|
| 497 |
+
if not filtered_equipment:
|
| 498 |
+
filtered_equipment = list(lab_obs.equipment_available[:1])
|
| 499 |
+
if filtered_equipment != list(action.required_equipment):
|
| 500 |
+
updates["required_equipment"] = filtered_equipment
|
| 501 |
+
notes.append("aligned_equipment_to_available_inventory")
|
| 502 |
+
|
| 503 |
+
if lab_obs.reagents_in_stock:
|
| 504 |
+
available_reagents = set(lab_obs.reagents_in_stock)
|
| 505 |
+
filtered_reagents = [
|
| 506 |
+
item for item in action.required_reagents if item in available_reagents
|
| 507 |
+
]
|
| 508 |
+
if not filtered_reagents:
|
| 509 |
+
filtered_reagents = list(lab_obs.reagents_in_stock[:1])
|
| 510 |
+
if filtered_reagents != list(action.required_reagents):
|
| 511 |
+
updates["required_reagents"] = filtered_reagents
|
| 512 |
+
notes.append("aligned_reagents_to_available_inventory")
|
| 513 |
+
|
| 514 |
+
if not updates:
|
| 515 |
+
return action, []
|
| 516 |
+
return action.model_copy(update=updates), notes
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def _resolve_scientist_action(session: dict[str, Any]) -> tuple[ScientistAction, dict[str, Any]]:
|
| 520 |
+
observation = session.get("last_observation")
|
| 521 |
+
if observation is None or observation.scientist is None:
|
| 522 |
+
raise RuntimeError("Session has no active Scientist observation. Reset the episode first.")
|
| 523 |
+
|
| 524 |
+
runtime = get_scientist_runtime()
|
| 525 |
+
if runtime == "baseline":
|
| 526 |
+
action = build_baseline_scientist_action(observation.scientist)
|
| 527 |
+
else:
|
| 528 |
+
policy = _get_scientist_policy()
|
| 529 |
+
action = policy(
|
| 530 |
+
observation.scientist,
|
| 531 |
+
seed=session.get("seed"),
|
| 532 |
+
scenario=session.get("scenario"),
|
| 533 |
+
difficulty=session.get("difficulty"),
|
| 534 |
+
)
|
| 535 |
+
raw_action = action.model_dump(mode="json")
|
| 536 |
+
action, normalization_notes = _normalize_runtime_scientist_action(session, action)
|
| 537 |
+
|
| 538 |
+
metadata = {
|
| 539 |
+
"scientist_runtime": runtime,
|
| 540 |
+
"scientist_model": (
|
| 541 |
+
get_scientist_model()
|
| 542 |
+
if runtime == "anthropic"
|
| 543 |
+
else get_scientist_ollama_model()
|
| 544 |
+
if runtime == "ollama"
|
| 545 |
+
else "baseline-heuristic"
|
| 546 |
+
),
|
| 547 |
+
"scientist_action": action.model_dump(mode="json"),
|
| 548 |
+
"scientist_action_raw": raw_action,
|
| 549 |
+
"scientist_safety_adjustments": normalization_notes,
|
| 550 |
+
}
|
| 551 |
+
return action, metadata
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
def _record_session_step(session_id: str, result: StepResult) -> StepResult:
|
| 555 |
+
session = _sessions[session_id]
|
| 556 |
+
session["total_steps"] = session.get("total_steps", 0) + 1
|
| 557 |
+
if result.observation is not None:
|
| 558 |
+
session["last_observation"] = result.observation
|
| 559 |
+
if result.info.error is not None:
|
| 560 |
+
session["invalid_action_count"] = session.get("invalid_action_count", 0) + 1
|
| 561 |
+
|
| 562 |
+
if result.done:
|
| 563 |
+
state = session["env"].state()
|
| 564 |
+
episode_log = _build_episode_log(
|
| 565 |
+
session["episode_id"],
|
| 566 |
+
state,
|
| 567 |
+
result,
|
| 568 |
+
invalid_action_count=session.get("invalid_action_count", 0),
|
| 569 |
+
total_steps=session.get("total_steps", 0),
|
| 570 |
+
)
|
| 571 |
+
_replay_store[session["episode_id"]] = episode_log
|
| 572 |
+
|
| 573 |
+
try:
|
| 574 |
+
write_episode_log(episode_log)
|
| 575 |
+
log_episode_reward(
|
| 576 |
+
episode_id=session["episode_id"],
|
| 577 |
+
seed=state.seed,
|
| 578 |
+
scenario_template=state.scenario_template,
|
| 579 |
+
difficulty=state.difficulty,
|
| 580 |
+
total_reward=state.reward,
|
| 581 |
+
breakdown=result.info.reward_breakdown,
|
| 582 |
+
rounds_used=state.round_number,
|
| 583 |
+
agreement_reached=result.info.agreement_reached,
|
| 584 |
+
verdict=result.info.verdict or "",
|
| 585 |
+
judge_notes=result.info.judge_notes or "",
|
| 586 |
+
)
|
| 587 |
+
except Exception:
|
| 588 |
+
log.exception("Failed to persist episode log to disk")
|
| 589 |
+
|
| 590 |
+
log.info(
|
| 591 |
+
"Episode done | session=%s episode=%s reward=%.2f",
|
| 592 |
+
session_id,
|
| 593 |
+
session["episode_id"],
|
| 594 |
+
result.reward,
|
| 595 |
+
)
|
| 596 |
+
|
| 597 |
+
return result
|
| 598 |
+
|
| 599 |
|
| 600 |
def _touch(session_id: str) -> None:
|
| 601 |
if session_id in _sessions:
|
|
|
|
| 693 |
action: ScientistAction
|
| 694 |
|
| 695 |
|
| 696 |
+
class AgentStepRequest(BaseModel):
|
| 697 |
+
session_id: str
|
| 698 |
+
|
| 699 |
+
|
| 700 |
+
class RuntimeStatusResponse(BaseModel):
|
| 701 |
+
scientist_runtime: str
|
| 702 |
+
scientist_model: str
|
| 703 |
+
scientist_ready: bool
|
| 704 |
+
agent_step_available: bool
|
| 705 |
+
available_runtimes: list[str]
|
| 706 |
+
note: str
|
| 707 |
+
|
| 708 |
+
|
| 709 |
# ---------------------------------------------------------------------------
|
| 710 |
# REST endpoints
|
| 711 |
# ---------------------------------------------------------------------------
|
|
|
|
| 1038 |
}
|
| 1039 |
|
| 1040 |
|
| 1041 |
+
@_api.get("/runtime", response_model=RuntimeStatusResponse)
|
| 1042 |
+
async def runtime_status():
|
| 1043 |
+
return RuntimeStatusResponse.model_validate(_scientist_runtime_status())
|
| 1044 |
+
|
| 1045 |
+
|
| 1046 |
@_api.get("/scenarios", response_model=ScenariosResponse)
|
| 1047 |
async def list_scenarios():
|
| 1048 |
return ScenariosResponse(scenarios=SCENARIOS)
|
|
|
|
| 1069 |
"episode_id": episode_id,
|
| 1070 |
"total_steps": 0,
|
| 1071 |
"invalid_action_count": 0,
|
| 1072 |
+
"last_observation": obs,
|
| 1073 |
+
"seed": req.seed,
|
| 1074 |
+
"scenario": req.scenario,
|
| 1075 |
+
"difficulty": req.difficulty,
|
| 1076 |
}
|
| 1077 |
|
| 1078 |
log.info("REST reset | session=%s episode=%s", session_id, episode_id)
|
|
|
|
| 1089 |
env = session["env"]
|
| 1090 |
|
| 1091 |
result = env.step(req.action)
|
| 1092 |
+
return _record_session_step(req.session_id, result)
|
| 1093 |
|
|
|
|
|
|
|
|
|
|
| 1094 |
|
| 1095 |
+
@_api.post("/agent-step", response_model=StepResult)
|
| 1096 |
+
async def agent_step_episode(req: AgentStepRequest):
|
| 1097 |
+
if req.session_id not in _sessions:
|
| 1098 |
+
raise HTTPException(status_code=404, detail="Session not found. Call /reset first.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1099 |
|
| 1100 |
+
_touch(req.session_id)
|
| 1101 |
+
session = _sessions[req.session_id]
|
| 1102 |
+
env = session["env"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1103 |
|
| 1104 |
+
try:
|
| 1105 |
+
action, metadata = _resolve_scientist_action(session)
|
| 1106 |
+
except Exception as exc:
|
| 1107 |
+
runtime = get_scientist_runtime()
|
| 1108 |
+
observation = session.get("last_observation")
|
| 1109 |
+
if observation is None or observation.scientist is None or runtime == "baseline":
|
| 1110 |
+
log.exception("Scientist runtime failed for session %s", req.session_id)
|
| 1111 |
+
raise HTTPException(status_code=503, detail=f"Scientist runtime failed: {exc}") from exc
|
| 1112 |
+
log.exception(
|
| 1113 |
+
"Scientist runtime failed for session %s; falling back to baseline",
|
| 1114 |
req.session_id,
|
|
|
|
|
|
|
| 1115 |
)
|
| 1116 |
+
action = build_baseline_scientist_action(observation.scientist)
|
| 1117 |
+
metadata = {
|
| 1118 |
+
"scientist_runtime": f"{runtime}_fallback",
|
| 1119 |
+
"scientist_model": "baseline-heuristic",
|
| 1120 |
+
"scientist_action": action.model_dump(mode="json"),
|
| 1121 |
+
"scientist_action_raw": None,
|
| 1122 |
+
"scientist_safety_adjustments": ["fallback_to_baseline_after_runtime_error"],
|
| 1123 |
+
"scientist_error": str(exc),
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
result = env.step(action)
|
| 1127 |
+
result.info = StepInfo.model_validate({
|
| 1128 |
+
**result.info.model_dump(mode="json"),
|
| 1129 |
+
**metadata,
|
| 1130 |
+
})
|
| 1131 |
+
return _record_session_step(req.session_id, result)
|
| 1132 |
|
| 1133 |
|
| 1134 |
@_api.get("/replay/{episode_id}", response_model=EpisodeLog)
|
tests/test_scientist_policy.py
CHANGED
|
@@ -1,12 +1,17 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
import pytest
|
| 4 |
|
| 5 |
from replicalab.agents.scientist_policy import (
|
| 6 |
RetryMetadata,
|
| 7 |
ScientistCallResult,
|
| 8 |
ScientistOutputParseError,
|
|
|
|
| 9 |
build_baseline_scientist_action,
|
|
|
|
| 10 |
build_scientist_system_prompt,
|
| 11 |
call_scientist_with_retry,
|
| 12 |
format_scientist_observation,
|
|
@@ -900,3 +905,83 @@ def test_baseline_scientist_accepts_at_final_round_even_with_blocker() -> None:
|
|
| 900 |
action = build_baseline_scientist_action(obs)
|
| 901 |
|
| 902 |
assert action.action_type is ScientistActionType.ACCEPT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
import pytest
|
| 7 |
|
| 8 |
from replicalab.agents.scientist_policy import (
|
| 9 |
RetryMetadata,
|
| 10 |
ScientistCallResult,
|
| 11 |
ScientistOutputParseError,
|
| 12 |
+
build_anthropic_scientist_policy,
|
| 13 |
build_baseline_scientist_action,
|
| 14 |
+
build_ollama_scientist_policy,
|
| 15 |
build_scientist_system_prompt,
|
| 16 |
call_scientist_with_retry,
|
| 17 |
format_scientist_observation,
|
|
|
|
| 905 |
action = build_baseline_scientist_action(obs)
|
| 906 |
|
| 907 |
assert action.action_type is ScientistActionType.ACCEPT
|
| 908 |
+
|
| 909 |
+
|
| 910 |
+
def test_build_anthropic_scientist_policy_calls_messages_api() -> None:
|
| 911 |
+
captured: dict[str, object] = {}
|
| 912 |
+
|
| 913 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 914 |
+
captured["headers"] = dict(request.headers)
|
| 915 |
+
captured["payload"] = json.loads(request.content.decode("utf-8"))
|
| 916 |
+
return httpx.Response(
|
| 917 |
+
200,
|
| 918 |
+
json={
|
| 919 |
+
"content": [
|
| 920 |
+
{
|
| 921 |
+
"type": "text",
|
| 922 |
+
"text": _VALID_REQUEST_INFO_JSON,
|
| 923 |
+
}
|
| 924 |
+
]
|
| 925 |
+
},
|
| 926 |
+
)
|
| 927 |
+
|
| 928 |
+
client = httpx.Client(transport=httpx.MockTransport(handler))
|
| 929 |
+
policy = build_anthropic_scientist_policy(
|
| 930 |
+
api_key="test-key",
|
| 931 |
+
model="claude-test",
|
| 932 |
+
client=client,
|
| 933 |
+
base_url="https://api.anthropic.com/v1/messages",
|
| 934 |
+
)
|
| 935 |
+
|
| 936 |
+
action = policy(
|
| 937 |
+
_base_observation(),
|
| 938 |
+
scenario="ml_benchmark",
|
| 939 |
+
difficulty="medium",
|
| 940 |
+
)
|
| 941 |
+
|
| 942 |
+
assert action.action_type is ScientistActionType.REQUEST_INFO
|
| 943 |
+
payload = captured["payload"]
|
| 944 |
+
assert isinstance(payload, dict)
|
| 945 |
+
assert payload["model"] == "claude-test"
|
| 946 |
+
assert payload["temperature"] == 0.0
|
| 947 |
+
assert payload["messages"][0]["role"] == "user"
|
| 948 |
+
headers = captured["headers"]
|
| 949 |
+
assert isinstance(headers, dict)
|
| 950 |
+
assert headers["x-api-key"] == "test-key"
|
| 951 |
+
|
| 952 |
+
|
| 953 |
+
def test_build_ollama_scientist_policy_calls_local_chat_api() -> None:
|
| 954 |
+
captured: dict[str, object] = {}
|
| 955 |
+
|
| 956 |
+
def handler(request: httpx.Request) -> httpx.Response:
|
| 957 |
+
captured["payload"] = json.loads(request.content.decode("utf-8"))
|
| 958 |
+
return httpx.Response(
|
| 959 |
+
200,
|
| 960 |
+
json={
|
| 961 |
+
"message": {
|
| 962 |
+
"role": "assistant",
|
| 963 |
+
"content": _VALID_REQUEST_INFO_JSON,
|
| 964 |
+
}
|
| 965 |
+
},
|
| 966 |
+
)
|
| 967 |
+
|
| 968 |
+
client = httpx.Client(transport=httpx.MockTransport(handler))
|
| 969 |
+
policy = build_ollama_scientist_policy(
|
| 970 |
+
model="glm-5:cloud",
|
| 971 |
+
client=client,
|
| 972 |
+
base_url="http://127.0.0.1:11434/api/chat",
|
| 973 |
+
)
|
| 974 |
+
|
| 975 |
+
action = policy(
|
| 976 |
+
_base_observation(),
|
| 977 |
+
scenario="finance_trading",
|
| 978 |
+
difficulty="medium",
|
| 979 |
+
)
|
| 980 |
+
|
| 981 |
+
assert action.action_type is ScientistActionType.REQUEST_INFO
|
| 982 |
+
payload = captured["payload"]
|
| 983 |
+
assert isinstance(payload, dict)
|
| 984 |
+
assert payload["model"] == "glm-5:cloud"
|
| 985 |
+
assert payload["stream"] is False
|
| 986 |
+
assert payload["format"] == "json"
|
| 987 |
+
assert payload["messages"][0]["role"] == "system"
|
tests/test_server.py
CHANGED
|
@@ -18,6 +18,7 @@ import pytest
|
|
| 18 |
from fastapi.testclient import TestClient
|
| 19 |
from starlette.websockets import WebSocketDisconnect
|
| 20 |
|
|
|
|
| 21 |
from server.app import app
|
| 22 |
|
| 23 |
_EXPECTED_FAMILIES = {"math_reasoning", "ml_benchmark", "finance_trading"}
|
|
@@ -54,6 +55,38 @@ class TestHealthEndpoint:
|
|
| 54 |
assert r1 == r2
|
| 55 |
|
| 56 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
class TestLogConfig:
|
| 58 |
"""OBS 02 — log level configurability."""
|
| 59 |
|
|
@@ -491,6 +524,65 @@ class TestStepEndpoint:
|
|
| 491 |
)
|
| 492 |
|
| 493 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
# ---------------------------------------------------------------------------
|
| 495 |
# WebSocket handler — API 06
|
| 496 |
# ---------------------------------------------------------------------------
|
|
|
|
| 18 |
from fastapi.testclient import TestClient
|
| 19 |
from starlette.websockets import WebSocketDisconnect
|
| 20 |
|
| 21 |
+
from replicalab.models import ScientistAction
|
| 22 |
from server.app import app
|
| 23 |
|
| 24 |
_EXPECTED_FAMILIES = {"math_reasoning", "ml_benchmark", "finance_trading"}
|
|
|
|
| 55 |
assert r1 == r2
|
| 56 |
|
| 57 |
|
| 58 |
+
class TestRuntimeEndpoint:
|
| 59 |
+
"""Local runtime metadata for model-backed Scientist stepping."""
|
| 60 |
+
|
| 61 |
+
def test_runtime_defaults_to_baseline_without_api_key(
|
| 62 |
+
self, client: TestClient, monkeypatch
|
| 63 |
+
) -> None:
|
| 64 |
+
monkeypatch.delenv("REPLICALAB_SCIENTIST_RUNTIME", raising=False)
|
| 65 |
+
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
| 66 |
+
|
| 67 |
+
resp = client.get("/runtime")
|
| 68 |
+
|
| 69 |
+
assert resp.status_code == 200
|
| 70 |
+
data = resp.json()
|
| 71 |
+
assert data["scientist_runtime"] == "baseline"
|
| 72 |
+
assert data["scientist_model"] == "baseline-heuristic"
|
| 73 |
+
assert data["agent_step_available"] is True
|
| 74 |
+
|
| 75 |
+
def test_runtime_reports_anthropic_when_enabled(
|
| 76 |
+
self, client: TestClient, monkeypatch
|
| 77 |
+
) -> None:
|
| 78 |
+
monkeypatch.setenv("REPLICALAB_SCIENTIST_RUNTIME", "anthropic")
|
| 79 |
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
| 80 |
+
|
| 81 |
+
resp = client.get("/runtime")
|
| 82 |
+
|
| 83 |
+
assert resp.status_code == 200
|
| 84 |
+
data = resp.json()
|
| 85 |
+
assert data["scientist_runtime"] == "anthropic"
|
| 86 |
+
assert data["scientist_ready"] is True
|
| 87 |
+
assert data["agent_step_available"] is True
|
| 88 |
+
|
| 89 |
+
|
| 90 |
class TestLogConfig:
|
| 91 |
"""OBS 02 — log level configurability."""
|
| 92 |
|
|
|
|
| 524 |
)
|
| 525 |
|
| 526 |
|
| 527 |
+
class TestAgentStepEndpoint:
|
| 528 |
+
"""POST /agent-step uses the configured Scientist runtime."""
|
| 529 |
+
|
| 530 |
+
def test_agent_step_runs_runtime_policy(
|
| 531 |
+
self, client: TestClient, monkeypatch
|
| 532 |
+
) -> None:
|
| 533 |
+
import server.app as server_app
|
| 534 |
+
|
| 535 |
+
monkeypatch.setenv("REPLICALAB_SCIENTIST_RUNTIME", "anthropic")
|
| 536 |
+
monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key")
|
| 537 |
+
server_app._SCIENTIST_POLICY_CACHE.clear()
|
| 538 |
+
|
| 539 |
+
action = ScientistAction.model_validate(_good_action_payload(client))
|
| 540 |
+
|
| 541 |
+
def fake_policy(observation, **kwargs):
|
| 542 |
+
assert observation.paper_title
|
| 543 |
+
assert kwargs["scenario"] == "ml_benchmark"
|
| 544 |
+
assert kwargs["difficulty"] == "medium"
|
| 545 |
+
return action
|
| 546 |
+
|
| 547 |
+
monkeypatch.setattr(server_app, "_get_scientist_policy", lambda: fake_policy)
|
| 548 |
+
|
| 549 |
+
reset_data = _reset(client, scenario="ml_benchmark", difficulty="medium")
|
| 550 |
+
resp = client.post("/agent-step", json={"session_id": reset_data["session_id"]})
|
| 551 |
+
|
| 552 |
+
assert resp.status_code == 200
|
| 553 |
+
data = resp.json()
|
| 554 |
+
assert data["info"]["scientist_runtime"] == "anthropic"
|
| 555 |
+
assert data["info"]["scientist_model"]
|
| 556 |
+
assert data["info"]["scientist_action"]["action_type"] == "propose_protocol"
|
| 557 |
+
assert data["observation"]["scientist"]["round_number"] == 1
|
| 558 |
+
|
| 559 |
+
def test_agent_step_invalid_session_returns_404(self, client: TestClient) -> None:
|
| 560 |
+
resp = client.post("/agent-step", json={"session_id": "missing"})
|
| 561 |
+
|
| 562 |
+
assert resp.status_code == 404
|
| 563 |
+
assert "Session not found" in resp.json()["detail"]
|
| 564 |
+
|
| 565 |
+
def test_agent_step_falls_back_to_baseline_on_runtime_failure(
|
| 566 |
+
self, client: TestClient, monkeypatch
|
| 567 |
+
) -> None:
|
| 568 |
+
import server.app as server_app
|
| 569 |
+
|
| 570 |
+
monkeypatch.setenv("REPLICALAB_SCIENTIST_RUNTIME", "ollama")
|
| 571 |
+
monkeypatch.setattr(
|
| 572 |
+
server_app,
|
| 573 |
+
"_resolve_scientist_action",
|
| 574 |
+
lambda session: (_ for _ in ()).throw(RuntimeError("model timeout")),
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
reset_data = _reset(client, scenario="math_reasoning", difficulty="easy")
|
| 578 |
+
resp = client.post("/agent-step", json={"session_id": reset_data["session_id"]})
|
| 579 |
+
|
| 580 |
+
assert resp.status_code == 200
|
| 581 |
+
data = resp.json()
|
| 582 |
+
assert data["info"]["scientist_runtime"] == "ollama_fallback"
|
| 583 |
+
assert data["info"]["scientist_error"] == "model timeout"
|
| 584 |
+
|
| 585 |
+
|
| 586 |
# ---------------------------------------------------------------------------
|
| 587 |
# WebSocket handler — API 06
|
| 588 |
# ---------------------------------------------------------------------------
|