Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions apps/studio/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ struct IgnitionProjectTraceFile {
struct IgnitionProjectCheckpointFile {
path: String,
entry: Value,
payload: Option<Value>,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -410,7 +411,7 @@ fn run_checkpoint_inference_path(
let checkpoint_index_path = source_run_dir
.join("checkpoints")
.join(CHECKPOINT_INDEX_FILE);
let (checkpoints, _) = read_checkpoint_index(&checkpoint_index_path)?;
let (checkpoints, _) = read_checkpoint_index(&root, &checkpoint_index_path)?;
let checkpoint = checkpoints
.into_iter()
.find(|entry| entry.entry.get("id").and_then(Value::as_str) == Some(checkpoint_id.as_str()))
Expand Down Expand Up @@ -612,8 +613,10 @@ fn read_project_runs(

let (metrics, metric_bytes) = read_metrics(&run_dir.join(METRICS_FILE))?;
let (traces, trace_bytes) = read_trace_files(root, &run_dir.join("traces"))?;
let (checkpoints, checkpoint_bytes) =
read_checkpoint_index(&run_dir.join("checkpoints").join(CHECKPOINT_INDEX_FILE))?;
let (checkpoints, checkpoint_bytes) = read_checkpoint_index(
root,
&run_dir.join("checkpoints").join(CHECKPOINT_INDEX_FILE),
)?;

byte_length += metric_bytes + trace_bytes + checkpoint_bytes;

Expand Down Expand Up @@ -683,7 +686,10 @@ fn read_trace_files(
Ok((traces, byte_length))
}

fn read_checkpoint_index(path: &Path) -> Result<(Vec<IgnitionProjectCheckpointFile>, u64), String> {
fn read_checkpoint_index(
root: &Path,
path: &Path,
) -> Result<(Vec<IgnitionProjectCheckpointFile>, u64), String> {
if !path.exists() {
return Ok((Vec::new(), 0));
}
Expand All @@ -696,9 +702,15 @@ fn read_checkpoint_index(path: &Path) -> Result<(Vec<IgnitionProjectCheckpointFi

for entry in entries {
validate_checkpoint_entry(entry)?;
let payload =
project_relative_json_path(root, json_string(entry, "path")?, "checkpoint payload")
.ok()
.and_then(|payload_path| read_json_value(&payload_path, "checkpoint payload").ok())
.map(|(payload, _)| payload);
checkpoints.push(IgnitionProjectCheckpointFile {
path: json_string(entry, "path")?.to_string(),
entry: entry.clone(),
payload,
});
}

Expand Down
46 changes: 46 additions & 0 deletions apps/studio/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,10 +360,27 @@ describe("Studio app shell", () => {
expect(inferenceCalls).toBe(1);
expect(text).toContain("studio-checkpoint-inference-final");
expect(text).toContain("checkpoint_inference");
expect(text).toContain("Payload Preview");
expect(text).toContain("Linked Inference Runs");
expect(text).toContain("inference replay");
expect(text).toContain("Frame Detail");
expect(text).toContain("Reward Debugger");
expect(text).not.toContain("No replay loaded.");

const baselineSelect = renderer?.root.findByProps({
"aria-label": "Compare against run",
});

act(() => {
baselineSelect?.props.onChange({ target: { value: "native-run" } });
});

const comparisonText = JSON.stringify(renderer?.toJSON());

expect(comparisonText).toContain("Compare against");
expect(comparisonText).toContain("inference replay");
expect(comparisonText).toContain("training run");

act(() => renderer?.unmount());
});

Expand Down Expand Up @@ -1036,6 +1053,34 @@ function chartPointLabel(renderer: ReactTestRenderer | undefined): string {
.join("") ?? "";
}

function nativeCheckpointPayload() {
return {
version: 1,
algorithm: "tabular-q-learning",
envId: "Target2D-v0",
observationShape: [4],
actions: ["right"],
config: {
learningRate: 0.1,
discount: 0.95,
epsilon: 0,
initialQ: 0,
observationPrecision: 2,
seed: 7,
},
metrics: {
states: 1,
transitions: 1,
episodes: 1,
lastTdError: 0,
},
qTable: {
"0,0,1,0": [1],
},
createdAt: "2026-05-28T00:01:00.000Z",
};
}

function nativeProjectDirectory(): NativeIgnitionProjectDirectory {
const createdAt = "2026-05-28T00:00:00.000Z";
const runUpdatedAt = "2026-05-28T00:01:00.000Z";
Expand Down Expand Up @@ -1132,6 +1177,7 @@ function nativeProjectDirectory(): NativeIgnitionProjectDirectory {
path: "runs/native-run/checkpoints/final.json",
createdAt: runUpdatedAt,
},
payload: nativeCheckpointPayload(),
}],
}],
artifacts: [{
Expand Down
66 changes: 47 additions & 19 deletions apps/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ import {
type NativeStudioApi,
type NativeTrainingMetricEvent,
} from "./native";
import { studioWorkspaceFromNativeProjectDirectory } from "./native-project";
import {
studioCheckpointViewsFromNativeProjectDirectory,
studioWorkspaceFromNativeProjectDirectory,
} from "./native-project";
import { sampleWorkspace } from "./sample-workspace";
import { StudioViewportPanel } from "./StudioViewportPanel";
import "./styles.css";
Expand Down Expand Up @@ -301,6 +304,20 @@ export function App({
setLoadError(undefined);
}

function applyNativeProjectDirectory(
directory: NativeCheckpointInferenceRunResult["project"],
options: { readonly runId?: string; readonly episodeId?: string } = {},
): StudioWorkspaceView {
const nextWorkspace = studioWorkspaceFromNativeProjectDirectory(directory, options);

applyWorkspaceView(nextWorkspace);
setCheckpointViews(studioCheckpointViewsFromNativeProjectDirectory(directory, nextWorkspace));
setLoadedProjectPath(directory.path);
setLoadedSource(directory.directoryName);

return nextWorkspace;
}

function applyNativeTrainingResult(result: NativeLocalTrainingRunResult) {
const trainingSession = result.trainingSession;

Expand All @@ -309,12 +326,8 @@ export function App({
}

const runId = trainingSession.run?.id;
const nextWorkspace = studioWorkspaceFromNativeProjectDirectory(
result.project,
runId === undefined ? {} : { runId },
);

applyWorkspaceView(nextWorkspace);
applyNativeProjectDirectory(result.project, runId === undefined ? {} : { runId });
setTrainingSessionViews((views) => upsertTrainingSessionView(views, trainingSession));
setSelectedTrainingSessionId(trainingSession.id);

Expand All @@ -325,26 +338,20 @@ export function App({
if (runId !== undefined) {
setSelectedRunId(runId);
}

setLoadedProjectPath(result.project.path);
setLoadedSource(result.project.directoryName);
}

function applyNativeCheckpointInferenceResult(
result: NativeCheckpointInferenceRunResult,
checkpoint: StoredCheckpoint,
) {
const nextWorkspace = studioWorkspaceFromNativeProjectDirectory(result.project, {
applyNativeProjectDirectory(result.project, {
runId: result.runId,
episodeId: result.episodeId,
});

applyWorkspaceView(nextWorkspace);
setSelectedRunId(result.runId);
setSelectedEpisodeId(result.episodeId);
setSelectedCheckpointKey(checkpointKey(checkpoint));
setLoadedProjectPath(result.project.path);
setLoadedSource(result.project.directoryName);
}

function ingestTrainingMetricEvent(event: NativeTrainingMetricEvent) {
Expand Down Expand Up @@ -395,9 +402,7 @@ export function App({
return;
}

applyStudioArtifact(studioWorkspaceFromNativeProjectDirectory(directory));
setLoadedSource(directory.directoryName);
setLoadedProjectPath(directory.path);
applyNativeProjectDirectory(directory);
} catch (error) {
setLoadError(error instanceof Error ? error.message : String(error));
}
Expand Down Expand Up @@ -613,7 +618,10 @@ export function App({
<div className="panel-heading">
<div>
<h2>{selectedHistoryRow?.runId ?? "No run selected"}</h2>
<p>{selectedHistoryRow?.algorithm ?? "No algorithm"}</p>
<p>
{selectedHistoryRow?.algorithm ?? "No algorithm"}
{selectedHistoryRow === undefined ? "" : ` · ${runModeLabel(selectedHistoryRow)}`}
</p>
</div>
<span className={`status-pill ${selectedHistoryRow?.status ?? "created"}`}>
{selectedHistoryRow?.status ?? "empty"}
Expand Down Expand Up @@ -740,7 +748,7 @@ function RunComparisonPanel(props: {
<div className="comparison-selected-run">
<span>Focus run</span>
<strong>{selectedRun.runId}</strong>
<small>{selectedRun.algorithm}</small>
<small>{selectedRun.algorithm} · {runModeLabel(selectedRun)}</small>
</div>
<label className="comparison-select-group">
<span>Compare against</span>
Expand Down Expand Up @@ -797,6 +805,7 @@ function RunComparisonPanel(props: {
<span className="comparison-run-main">
<strong>{row.runId}</strong>
<small>{row.algorithm}</small>
<small>{runModeLabel(row)}</small>
</span>
<span className="comparison-score-cell">
<strong>{formatNumber(row.score ?? row.bestReward)}</strong>
Expand All @@ -808,6 +817,7 @@ function RunComparisonPanel(props: {
<small>{formatPercent(row.successRate)} success</small>
<small>{formatNumber(row.totalSteps)} steps</small>
<small>{row.checkpointCount} ckpt</small>
<small>{runModeLabel(row)}</small>
</span>
</button>
))}
Expand Down Expand Up @@ -1950,7 +1960,25 @@ function comparisonOptionLabel(row: StudioRunHistoryRow): string {
const rank = row.rank === undefined ? "unranked" : `rank ${row.rank}`;
const score = formatNumber(row.score ?? row.bestReward);

return `${rank} / ${score}`;
return `${rank} / ${score} / ${runModeLabel(row)}`;
}

function runModeLabel(row: StudioRunHistoryRow | undefined): string {
if (row === undefined) {
return "run";
}

const algorithm = row.algorithm.toLowerCase();

if (algorithm.includes("inference")) {
return "inference replay";
}

if (row.status === "running" || row.status === "created") {
return "training run";
}

return "training run";
}

function firstCheckpointKey(workspace: StudioWorkspaceView, environmentId?: string): string | undefined {
Expand Down
67 changes: 67 additions & 0 deletions apps/studio/src/native-project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
StoredCheckpoint,
StoredExportedArtifact,
StoredExportedArtifactKind,
StudioCheckpointView,
StudioProjectRunSnapshot,
StudioProjectTraceSnapshot,
StudioWorkspaceViewOptions,
Expand Down Expand Up @@ -78,6 +79,52 @@ export function studioWorkspaceFromNativeProjectDirectory(
}, options);
}

export function studioCheckpointViewsFromNativeProjectDirectory(
directory: NativeIgnitionProjectDirectory,
workspace: StudioWorkspaceView,
): StudioCheckpointView[] {
return directory.runs.flatMap((runDirectory) =>
runDirectory.checkpoints.flatMap((checkpointFile) => {
if (checkpointFile.payload === undefined) {
return [];
}

const checkpoint = asStoredCheckpoint(checkpointFile.entry);
const sourceRun = workspace.projectView.history.find((row) => row.runId === checkpoint.runId);

if (sourceRun === undefined) {
return [];
}

const inferenceRunIds = new Set(
directory.runs
.filter((candidate) => isCheckpointInferenceRun(candidate.run, checkpoint))
.map((candidate) => candidate.id),
);
const inferenceRuns = workspace.projectView.history.filter((row) => inferenceRunIds.has(row.runId));

return [{
kind: "ignitionrl.studio-checkpoint-view" as const,
generatedAt: workspace.generatedAt,
project: workspace.projectView.project,
checkpoint,
sourceRun,
inferenceRuns,
comparison: workspace.projectView.comparison,
metricNames: workspace.projectView.metricNames,
payload: checkpointFile.payload,
counts: {
inferenceRuns: inferenceRuns.length,
sourceRunMetrics: sourceRun.metricCount,
sourceRunTraces: sourceRun.traceCount,
sourceRunCheckpoints: sourceRun.checkpointCount,
payloadIncluded: true,
},
}];
})
);
}

function asProjectManifest(value: unknown): IgnitionProjectManifest {
const record = expectRecord(value, "project manifest");

Expand Down Expand Up @@ -186,6 +233,26 @@ function asStoredExportedArtifact(file: NativeIgnitionProjectArtifactFile): Stor
};
}

function isCheckpointInferenceRun(run: unknown, checkpoint: StoredCheckpoint): boolean {
const record = maybeRecord(run);
const config = maybeRecord(record?.config);
const metadata = maybeRecord(record?.metadata);
const sourceRunId = stringFromUnknown(config?.sourceRunId) ?? stringFromUnknown(metadata?.sourceRunId);
const checkpointId = stringFromUnknown(config?.checkpointId) ?? stringFromUnknown(metadata?.checkpointId);

return sourceRunId === checkpoint.runId && checkpointId === checkpoint.id;
}

function maybeRecord(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined;
}

function stringFromUnknown(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
}

function expectRecord(value: unknown, label: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`Selected project has an invalid ${label}.`);
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type NativeIgnitionProjectTraceFile = {
export type NativeIgnitionProjectCheckpointFile = {
readonly path: string;
readonly entry: unknown;
readonly payload?: unknown;
};

export type NativeIgnitionProjectRunDirectory = {
Expand Down
Loading