Skip to content

Experiments & variations

An experiment is a hypothesis container; variations are the configs you register under it to test that hypothesis. You drive both through chronicle.experiments and chronicle.variations, or — preferred — through the resource handles those return.

An experiment moves open → committed → concluded; either it or any single variation can branch off to retracted at any point. Committing freezes the registered design (config, inputs, report settings); concluding locks every output and bars new variations. Both are deliberate, mostly one-way gates.

   experiments.create()
          │  open
     exp.commit() ──────────────► exp.retract(reason=...)
          │  committed                    (soft; preserves the record)
          ├──► exp.variations.create(...)  (open → committed, per variation)
          ├──► exp.fork(...)  ──► new open experiment, lineage edge to this one
    exp.conclude() ────────────► exp.retract(reason=...)
          │  concluded                     (terminal record states)
     outputs locked, no new variations

Chronicle is constructed once per process (with Chronicle(...) as chronicle: is the easiest pattern). exp = chronicle.experiments.create(...) hands back an Experiment handle; its mutators (commit, conclude, retract, move) return self, so calls chain. Cached detail is dropped after each mutation — the next property access re-fetches.

Experiment states

State Set by *_at stamp Terminal?
open experiments.create() no — editable, deletable
committed exp.commit() committed_at no — accepts variations + runs
concluded exp.conclude() concluded_at yes — outputs locked
retracted exp.retract(reason=...) retracted_at yes — soft mark, record kept

Read the live state off the handle: exp.state, exp.committed_at, exp.concluded_at, exp.retracted_at, exp.hypothesis_summary. exp.data is the full server record; exp.detail includes the variation roster.

Create

Two fields are required: hypothesis_summary (the falsifiable claim) and config_yaml (the base config every variation inherits).

exp = chronicle.experiments.create(
    hypothesis_summary="ripple effect on PDE solvers in 2D",
    config_yaml=open("experiment.yaml").read(),
    rationale="Prior 1D runs showed sub-linear error growth; testing if it holds in 2D.",
    description="2D Navier-Stokes ripple sweep",
)
print(exp.id, exp.state)        # "exp_7Qk", "open"

rationale is the reasoning behind the hypothesis (read it back with exp.get_rationale()); description is a free-form blurb. Pass launch_config / accelerate_config_yaml to seed run defaults, or parent_experiment_ids=[...] to record a lineage edge at creation (add allow_retracted_parent=True to descend from a retracted parent).

Owning scope. organization_id / team_id / visibility set who owns and can read the experiment. Omitting organization_id uses the client's configured default org; pass methodic.PERSONAL to force a personal experiment owned by the calling key's user. visibility is one of "private", "organization", "team", or "public" — omit it for the scope-derived default (org/team-wide in an org context, private in personal space).

Commit

Committing freezes the experiment's registered design. After this, report_settings is locked and delete() is refused (retract instead) — but variations and runs flow.

exp.commit().variations.create(config_yaml=open("variation-1.yaml").read())

Variations

A variation is one config under the experiment. Reach the API pre-bound to this experiment as exp.variations (sugar for chronicle.variations with the id baked in); create returns a Variation handle keyed on (experiment_id, variation), where variation is an integer index.

var = exp.variations.create(
    config_yaml=open("variation-1.yaml").read(),
    name="re1000-fp32",
    hypothesis="2D ripple error stays sub-linear at Re=1000.",
    expected_outcome="MSE within 5% of the 1D baseline at step 12k.",
    input_asset_ids=["asset_abc"],     # variation-level inputs
)
print(var.variation, var.state)        # 0, "open"
Property Type Meaning
var.state str opencommitted, or retracted
var.config_yaml str the variation's config
var.launch_config dict \| None run-launch overrides
var.hypothesis str \| None pre-registered falsifiable claim
var.expected_outcome str \| None predicted result vs. baseline
var.committed_at / var.retracted_at str \| None lifecycle stamps

Edit an open variation's metadata with update (omitted fields untouched; pass "" to clear one); the server returns 409 once committed.

var.update(hypothesis="Refined: sub-linear up to Re=2000.", description="widened sweep")

var.list_inputs() / var.list_outputs() return the linked input assets and the produced outputs (checkpoints, snapshots, reports — newest first). var.unlink_input(asset_id) drops a stale input link from an open variation. See assets.md for linking datasets and other inputs, and run-lifecycle.md for executing a committed variation.

Pre-registration

hypothesis and expected_outcome are the variation's pre-registration — the falsifiable claim (tied to the eval metric) and the predicted result, recorded before you commit. Set them at create, or refine them via update while the variation is open.

Committing a variation locks its config and inputs. It is refused (409 missing_variation_hypothesis) when no hypothesis is recorded — unless you make the deliberate, audited choice to skip pre-registration:

var.commit()                              # requires a recorded hypothesis
var.commit(commit_without_hypothesis=True)  # explicit opt-out, audited

Code & git

Each experiment gets a GitHub repo, provisioned asynchronously. Watch it come up, then bind code to variations.

status = exp.wait_for_repo(timeout=300.0, poll_interval=2.0)  # polls git_status until terminal
if status.state == "ready":
    print(status.repo_url)

exp.git_status() returns a GitStatusstate is one of pending, ready, failed, archived; repo_url is set when ready, failure_reason when failed. It's cheap to poll. wait_for_repo returns once the repo reaches a terminal state (ready/failed/archived) or timeout elapses — it does not raise, so re-check .state.

To push code, mint a scoped token:

token = exp.mint_git_token()    # 1-hour install token, scoped to this repo
# clone:  https://x:{token.token}@github.com/<org>/<repo>

The token has Administration stripped — it can push to user/... branches you create, but branch protection rejects pushes to agent/*. Bind a branch to an open variation so the commit-time rename (and any agent) knows which branch is the variation's:

var.set_git_ref("variation/0")    # records branch → variation; SHA locks at commit

set_git_ref records the mapping without pinning a SHA (that happens at variation commit); it rejects the protected agent/* namespace and 409s on a committed variation. You can also pass git_ref= straight to variations.create.

Lineage & forking

Fork creates a new experiment whose repo mirrors the source's full git history, with a lineage edge back to the source:

child = exp.fork(
    hypothesis_summary="ripple effect in 3D",
    rationale="2D held; extending to 3D.",
    config_yaml=open("3d.yaml").read(),   # optional; defaults to the parent's
)

The fork starts open — commit it and add variations like any experiment. Add allow_retracted_parent=True to fork from a retracted experiment.

Walk the lineage graph with exp.get_lineage(direction=..., depth=...). When an ancestor is retracted, surface it on descendants:

exp.get_upstream_retractions(depth=3, full_chain=True, since="2026-01-01T00:00:00Z")

This reports retractions among the experiment's ancestors so you can decide whether a result still stands on sound foundations.

Findings (the running summary)

A finding is the one-line "what's working / what's not" signal per variation that lands on the experiment page's running-summary header and the activity feed. Record one whenever you've judged a variation's outcome — from its metrics, not the run's succeed/fail status:

summary = exp.record_finding(
    status="working",                    # "working" | "partial" | "not_working"
    summary="lr warmup fixed the divergence",
    evidence_variation=2,                # the variation the evidence comes from
    source_asset_id=report_id,           # optional: the report you judged from
)

The server keys the summary on evidence_variation — recording again for the same variation replaces its finding, so refine freely as reports land. The call returns the updated running summary ({version, updated_at, findings: [...]}), also readable off exp.detail. Requires Write on the experiment; MCP-native agents have the same surface as the chronicle.record_finding tool.

Distillation

Distillation runs a review-gated agent that synthesizes findings — across a single variation, the whole experiment, or a filtered corpus — into a takeaways_report (and optionally a research_report). It runs async; the call returns a distillation_job_id.

job = exp.distill(scope="experiment")                       # cross-variation synthesis
job = exp.distill(scope="variation", variation_id=0)        # one variation
job = exp.distill(scope="corpus", corpus_filter={...})      # filtered corpus
print(job["distillation_job_id"])

scope is one of "variation", "experiment", "corpus" (the last requires corpus_filter); write_research_report defaults to True for experiment scope. Poll GET /experiments/{id}/agents to watch progress. The resulting report is review-gated — concluding the experiment depends on an approved takeaways report (see below).

Conclude & retract

Conclude is terminal: it locks every output and bars new variations. It gates on a satisfying experiment-level takeaways_report — if none exists, the server schedules a distillation and returns 409 with a distillation_job_id to poll, after which you re-issue conclude.

exp.conclude()
exp.conclude(on_exist_action="keep")        # gate on the existing approved report
exp.conclude(on_exist_action="regenerate")  # supersede it, distill fresh

on_exist_action ("keep" or "regenerate") is required when a takeaways report already exists, else 409 takeaways_report_exists. Conclude is not idempotent — re-concluding raises 409 already_concluded.

Retract is a soft mark that preserves the record (config, runs, reports stay readable) while flagging the work as withdrawn. It applies to an experiment or a single variation, at any lifecycle stage, and always takes a reason:

exp.retract(reason="baseline dataset had a labeling bug", document_asset_id="asset_def")
var.retract(reason="config typo invalidated the sweep")

document_asset_id optionally links a write-up explaining the retraction.

Delete is the hard counterpart, and only for an open (uncommitted) experiment — it removes the experiment and everything it owns (variations, runs, links, the repo). It's refused (409) once committed or concluded (retract instead), or if another experiment was derived from it.

exp.delete()    # only while open; handle is dead afterward

Move (personal → org)

Transfer a personal experiment into an organization. The org's admins gain read + administer and visibility sets who else can read; the original creator keeps their access (the move only adds org reach).

exp.move(organization_id="org_123", team_id="team_9", visibility="organization")

Personal → org only — it 409s if the experiment is already org-owned, or if its slug collides in the target org (rename it first). Requires Administer on the experiment and membership of the target org.

Agent config & continuous exploration

Experiments carry an agent_config block tuning the distillation and exploration agents. Read it with exp.get_agent_config() ({} if never set), replace it wholesale with exp.set_agent_config(config) (pass {} to clear). For the continuous-exploration loop — which wakes a synthesis pass when distillations complete — use the convenience wrapper, which read-modify-writes just that sub-block (other knobs preserved):

exp.set_continuous_exploration(
    enabled=True,
    trigger_scope="variation",   # "variation" | "experiment" | "both"
    cooldown_minutes=30,         # 0..1440; 0 = fire on every completion
)

Listing

chronicle.experiments.list(...) returns one page; iter(...) walks every page server-side. Filter by status (the lifecycle state) or created_by.

for summary in chronicle.experiments.iter(status="committed"):
    print(summary.id, summary.hypothesis_summary)

See the API reference for the full handle surface, and reports.md for rendering an experiment's hypothesis and takeaways documents.