Results & Persistence

This page specifies what a run produces: the result objects returned in memory, the structured tree written to disk, how a re-run resumes, how to read results back, and how progress is reported live and served afterward.

controller.run() returns one ThreatModelResult. By default it also persists a resumable results tree to disk and streams live progress to the terminal. Both are on by default; both can be turned off.

Result objects

Three frozen dataclasses nest: a ThreatModelResult holds one TaskResult per task, and each TaskResult holds one RunResult per run.

RunResult

One target execution plus its evaluation.

TaskResult

All runs for one task.

The four stop reasons: "done" (the optimizer returned RunEndResponse(done=True)), "max_runs" (hit max_runs_per_task), "budget_exhausted" (a BudgetExhaustedError ended the loop), and "error" (an unexpected exception escaped the optimizer, target, or evaluator and the task was abandoned with its partial trajectory preserved).

ThreatModelResult

Everything for the one (scope, llm_config) this Controller measured.

Persistence

Persistence is on by default (persist=True). Set persist=False to write nothing. When on, each run lands in one self-describing directory tree that the reader API, the resume engine, and the web report all consume without hand-parsing.

The current on-disk schema is version 4 (SCHEMA_VERSION = 4). Note this is the persistence schema version, distinct from the framework version.

{results_root}/the results root, one folder per experiment
experiments.jsoncross-experiment index (sweep landing)
dashboard.htmlthe bundled web report
{slug}-{hash8}/one experiment = one threat model
manifest.jsonparams + summary + tasks[], kept current
result.jsoncompletion markerclaim-level metrics, written last
dashboard.htmlthe web report, dropped here too
logs/diagnostics.logexperiment-level diagnostics
tasks/one directory per task
00001__{goalslug}/the current (latest) result for this task
task.jsonper-task result + metrics
iterations.jsonper-run score / metric progression
trajectories/run_00001.jsonone file per run
logs/diagnostics.logtask-level diagnostics
previous_01/immutable snapshota prior run · hardlinked kept tasks
result.json  tasks/…same shape, frozen
The folder name {slug}-{hash8} is the measurement identity, so an identical re-run resolves to the same folder and resumes. result.json is written last as the completion marker; a still-in_progress manifest marks an interrupted run.

(Lock files, .experiments.lock at the root and a .lock per experiment directory, coordinate concurrent writers; they are advisory and not part of the readable data.)

Atomicity and crash safety. Every file is written to a temp path and then os.replaced; every task directory is published as a temp directory and then renamed. manifest.json reads status="in_progress" while a run is live and flips to "complete" only once result.json is written last, so an interrupted run is recognizable by its still-in_progress manifest.

Resume

Because the folder name is the measurement identity, re-running the same Controller resolves to the same folder and resumes rather than colliding:

Reading results back

superred.core.persistence exposes a public reader API, so analysis code (and the web report) never hand-parses the tree:

from superred.core.persistence import (
    load_experiments_index,   # (results_root)          -> the cross-experiment index
    load_manifest,            # (experiment_dir)         -> params + summary + tasks[]
    load_result,              # (experiment_dir)         -> claim-level final metrics
    iter_tasks,               # (experiment_dir)         -> list[TaskView] (one scalar view/task)
    iter_task_dirs,           # (experiment_dir)         -> list[Path] (the task directories)
    load_task,                # (task_dir)               -> per-task result + metrics
    load_iterations,          # (task_dir)               -> per-run progression
    load_trajectory,          # (task_dir, run_number)   -> one run's full trajectory
)

iter_tasks returns TaskView objects, a flat scalar view per task (index, goal, goal_hash, dir, status, success, stop reason, best score, run count, cost, timing, error), enough to build a summary table without loading trajectories.

Security: persisted content is sensitive

This is a red-teaming framework. Persisted trajectories contain jailbreaks, planted secrets, and exfiltrated content, and they are NOT scrubbed. LLMConfig serializes {model} only (api_key and api_base are never written), but that is the only redaction. Treat the whole results root as sensitive: it holds working attacks and whatever the target leaked under them. Keep credentials out of prompts, observable payloads, and config values, since those flow verbatim into the trajectory files. The default root (./superred-results/) should be gitignored.

Live progress reporting

The Controller prints nothing itself. It narrates a run through a reporter, an observer it calls at each lifecycle point. Two constructor arguments choose one:

The ProgressReporter protocol

ProgressReporter is a runtime-checkable Protocol in superred.core.reporting. Every method is called on the event-loop thread and must not block or await:

on_threat_model_start(ctx)   on_task_start(ev)       on_run_complete(ev)
on_task_complete(ev)         on_task_skipped(ev)      on_diagnostic(ev)
on_threat_model_end(ev)

Each receives a frozen payload (ThreatModelContext, TaskStartEvent, RunCompleteEvent, and so on) describing that moment. The built-in reporters are NullReporter (silent), PlainReporter (lines), and Dashboard (the canvas).

Concurrent Controllers share one dashboard

A sweep run through run_all renders every threat model into a single shared canvas, one lane each, rather than fighting over the terminal: run_all owns one Dashboard, calls expect(n), and pre-registers a lane per Controller, and the dashboard stays alive until all n lanes finish (so a capped sweep that pauses between waves does not tear the canvas down). A bare asyncio.gather(*(c.run() ...)) runs and persists correctly but does not coordinate the display: each run() grabs the process-global dashboard independently, so its live output can mix. rich (>=14,<15) is a core dependency.

The web report and superred serve

The framework ships a single self-contained static page, dashboard.html, that reads a results tree in the browser: it shows the metrics, filters tasks by outcome (success / failure / error), and drills into each task’s runs and trajectories. It needs no build step and is dropped into the results root (and each experiment folder) on every finalize, so it always matches the installed version.

Because the page fetches the JSON files next to it, browsers block those fetches from file://. The bundled CLI runs a tiny local server so you do not have to:

superred serve ./superred-results     # serve a results root, open the report

superred serve [dir] (the directory defaults to superred-results) rewrites the freshest dashboard.html into the directory, starts a local HTTP server, and opens the report in your browser. Flags: --host (default 127.0.0.1), --port (default 8000, falling back to a free port if it is taken), and --no-browser. This is the only subcommand today; the CLI serves results, it does not run experiments. Running an evaluation is done in Python via asyncio.run(controller.run()).