ayushozha commited on
Commit
4ea916a
·
1 Parent(s): 65e91b3

Add model-driven local runtime and dynamic demo flow

Browse files
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
- ? `verdict_${judgeAudit.verdict}`
 
 
 
 
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 === 'accept' && episode.step_history[0]?.lab_manager_action_type === 'accept') {
82
- return 'Good';
83
  }
84
- if (episode.judge_audit?.verdict === 'accept') {
85
- return 'Needs iteration';
 
 
 
86
  }
87
- return 'Bad';
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
- ? `ReplicaLab scores this paper as replicable in the current lab setup with ${confidencePercent}% confidence.`
 
 
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="Canonical deterministic decision."
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
- audit.verdict === 'success' || audit.verdict === 'accept'
 
 
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
- {audit.verdict.charAt(0).toUpperCase() + audit.verdict.slice(1)}
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="mb-1.5 text-xs font-medium text-destructive">Failure Reasons</h3>
51
  <ul className="space-y-1">
52
  {audit.top_failure_reasons.map((reason, i) => (
53
- <li key={i} className="flex items-start gap-1.5 text-sm text-destructive">
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
- <Link
183
- to="/episode?template=ml_benchmark&difficulty=medium&seed=101&demo=1&autoplay=1&demoCase=fast-agreement"
 
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
- </Link>
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">Live Demo Outcomes</h2>
305
  <p className="mb-8 text-center text-sm text-muted-foreground">
306
- Pick the exact story you want to show: immediate agreement, multi-round learning, or clear rejection.
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 demoRequested = useMemo(
68
- () => searchParams.get('demo') === '1' || searchParams.get('autostart') === '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
- () => demoRequested || searchParams.get('autoplay') === '1',
78
- [demoRequested, searchParams],
 
 
 
 
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?.verdict, toast]);
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 (demoRequested) {
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, demoCase, demoRequested, navigate, toast]);
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 = await stepEpisode(episode.session_id, finalAction, episode);
 
 
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 (!demoRequested || autoStartTriggered || episode || loading) {
230
  return;
231
  }
232
  setAutoStartTriggered(true);
@@ -237,7 +279,7 @@ export default function EpisodePage() {
237
  });
238
  }, [
239
  autoStartTriggered,
240
- demoRequested,
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
- {demoRequested ? 'Launching Live Replication Demo' : 'Start a Paper Replication Episode'}
322
  </h1>
323
  <p className="text-muted-foreground">
324
- {demoRequested
325
- ? 'Loading the seeded benchmark, starting the agents, and running the negotiation automatically.'
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
- {!demoRequested && (
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
- # Store completed episode log for replay and persist to disk (ENV 09)
827
- if result.done:
828
- state = env.state()
829
- episode_log = _build_episode_log(
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
- try:
839
- write_episode_log(episode_log)
840
- log_episode_reward(
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
- log.info(
856
- "Episode done | session=%s episode=%s reward=%.2f",
 
 
 
 
 
 
 
 
857
  req.session_id,
858
- session["episode_id"],
859
- result.reward,
860
  )
861
-
862
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  # ---------------------------------------------------------------------------