Public alpha · CLI + GitHub Action
Keep every LLM workflow in sync.
When a model or eval dataset changes, Driftless runs your test command, repairs only the files you allow, and opens a pull request with evidence — or blocks the change if quality drops.
What Driftless does
Providers retire models. Teams change gold labels. A cheaper model looks tempting. In each case, swapping an ID is rarely enough: the prompt that worked yesterday often fails today, and you need numbers before you ship.
Driftless is a command-line tool (the GitHub Action just runs the same commands). You describe the task once in a driftless.yml file. Driftless then:
- Runs your existing eval command under the old model and the new one.
- Repairs only the files you list as editable (usually prompts).
- Checks the winner on holdout rows it did not tune on.
- Prints a report and can open a PR — or an issue if the bar is not met.
You own the eval, the quality bar, and the credentials. Driftless orchestrates the loop. It does not reimplement your parser, retrieval, or tools.
If you know Poetry and Dependabot: driftless.yml is the manifest (model + eval dataset), the prompt is the lockfile, and delivery is a gated PR. LLM behavior is empirical, so Driftless scores candidates on your eval instead of resolving versions.
Words you'll see
| Term | Meaning |
|---|---|
| Workflow | One LLM task in the repo (a classifier, a RAG answerer, an agent). |
| Contract | The driftless.yml file: how to run the task, what may be edited, what “good” means. |
| Harness | Your command (run.command) that writes one JSON object per line. |
| Compare | Score current vs target model. No file edits. |
| Migrate | Try to repair allowed files so the target still meets the bar. |
-w | Short for --workflow, the name of the workflow in driftless.yml. |
| Generator | Who writes the repair: none (no edits), fixture (bundled demo patch), llm (calls a provider). |
| Holdout | Eval rows saved for a final check. The repair loop never trains on them. |
| Thresholds | Numbers such as min_f1: 0.9 that a candidate must beat. |
Installation
Python 3.10 or newer. For the quickstart you only need the CLI — no API key:
pip install driftless
Skip this unless you are developing Driftless itself:
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
Automatic prompt repair on your app needs a provider key. Install the extra and export one key:
pip install "driftless[llm]" # openai + anthropic SDKs
export OPENAI_API_KEY=sk-... # or ANTHROPIC_API_KEY
Whichever key is present chooses the provider. The same contract works with OpenAI or Anthropic.
Quickstart
Copy the bundled ticket classifier. It runs without an API key. -w support_classifier is the workflow name inside driftless.yml.
pip install driftless
driftless copy-example support-classifier --out-dir driftless-classifier-demo
cd driftless-classifier-demo
driftless validate -w support_classifier
driftless compare -w support_classifier --to gpt-4o-mini
validate checks the wiring. compare runs the current model and a cheaper target on the same four rows and does not edit files. You should see F1 1.000 → 0.000 and cost 0.024 → 0.004.
Read FAIL min_f1: 0.000 >= 0.9 as: the cheap model scored 0.000, you required at least 0.9, so a bare swap is not safe to ship. That failure is the demo working.
Four rows only. This proves install and gating. It is not production evidence. Use a real eval and --generator llm before shipping your own workflow.
Blocked path — no prompt edits, still no API key. migrate is expected to exit non-zero. Run the later commands anyway:
driftless migrate -w support_classifier --to gpt-4o-mini --generator none
driftless report -w support_classifier
driftless open-pr -w support_classifier
Passing path — applies the known-good patch shipped with this example:
driftless migrate -w support_classifier --to gpt-4o-mini --generator fixture
driftless report -w support_classifier
driftless open-pr -w support_classifier
Expect PASS. open-pr only prints what it would open unless you add --create. Do not use fixture on your own app. This example is a local simulator: --generator llm is refused here.
For a harness that calls OpenAI, use copy-example support-classifier-live (needs OPENAI_API_KEY). Other key-free demos: copy-example rag-qa and copy-example tool-agent. See use cases for which example matches your app.
Use cases
The quickstart is a classifier. Same product loop, different app shape:
| Your app | Start here | -w | Guide |
|---|---|---|---|
| Classifier (no key) | copy-example support-classifier |
support_classifier |
Model swap, label change |
| Classifier (live OpenAI) | copy-example support-classifier-live |
support_classifier_live |
Needs OPENAI_API_KEY. --generator llm is allowed here. |
| RAG QA | copy-example rag-qa |
rag_qa |
RAG guide, contracts |
| Tool-calling agent | copy-example tool-agent |
support_agent |
Agent guide, contracts |
| Summarizer / free-form | No bundled example. Bring a harness and grade with eval.judge. |
your workflow | LLM judge |
Classifier, RAG, and agent copies are local simulators: --generator llm is refused. Use --generator fixture for the key-free pass, or the live classifier / a customer harness for real repair.
Public alpha
Driftless is for teams that already have an eval they can run from the command line. The bundled examples need no API key. Putting it on your own app needs a filled-in contract, a reliable harness, and a plan for provider cost and secrets. There is no hosted bot; see what 1.0 will include.
Before CI, read the known limits and cost guidance. Coming from 0.2.x? Use the 0.3 upgrade guide.
Adopt Driftless in an existing repository
The bundled example is a tour. For your own app, you still bring the eval — Driftless will not invent one. If you are unsure which command to run, use the command chooser.
- Discover and configure: find model usage, then turn it into a
driftless.ymldraft. - Validate locally: make sure your eval command runs, then compare one target model.
- Set boundaries: review which files may be edited, the quality bar, and the provider budget.
- Add CI last: generate workflows with
init-ci, review the files, then commit only the jobs you want.
cd your-existing-repo
driftless scan
driftless configure <workflow> --apply
# Review the inferred contract; resolve any remaining placeholders.
driftless validate -w <workflow>
driftless compare -w <workflow> --to <model>
driftless init-ci
configure --apply writes a reviewable draft and safely creates or appends root driftless.yml without rewriting existing comments. It prefills description, harness paths, model/env, a cheaper same-provider target when known, and common readonly trees. Driftless refuses to run until every remaining placeholder is resolved. init-ci infers app setup from pyproject.toml / requirements.txt / Node manifests; override with --setup-command when needed.
If discovery does not fit your setup, start from the smallest working contract or run driftless init. The Getting Started walkthrough shows that minimum file, then a fuller draft-to-contract example.
The workflow contract
driftless.yml is the one file you fill in. Each named block under workflows: is one LLM task. Unknown keys are errors, so typos fail fast instead of being ignored.
Smallest working contract
For a classifier with gold labels, this is enough to validate and compare. You do not need config_file, token fields, or a second workflow.
version: 1
workflows:
my_classifier:
model:
current: gpt-4
env_var: MODEL
files:
editable:
- prompts/system.md
run:
command: python evals/run_eval.py
input_path: evals/inputs.jsonl
output_path: evals/outputs.jsonl
eval:
labels_path: evals/gold.jsonl
label_field: label
thresholds:
min_f1: 0.90
The harness must write one JSON object per line to output_path and read the model from the env var. model.current is Driftless's baseline, not what the app reads at runtime. Skip config_file unless a PR should rewrite an in-repo model ID. Not a classifier? Use eval.score_field, eval.pass_field, or eval.judge instead of labels.
A fuller contract can add holdout, cost gates, schema checks, and config_file once that minimum runs:
workflows:
support_classifier:
run:
command: "python evals/run_eval.py"
input_path: evals/inputs.jsonl
output_path: evals/outputs.jsonl
timeout_seconds: 600
model:
current: gpt-3.5-turbo
target_candidates: [gpt-4o-mini]
env_var: SUPPORT_CLASSIFIER_MODEL
config_file: config/llm.yml
config_path: model
files:
editable: [prompts/system.md, prompts/examples.yml]
context: [src/parser.py]
readonly: [src/**]
eval:
labels_path: evals/labels.jsonl
schema_path: schemas/ticket.schema.json
label_field: category
split: { tuning: 60%, seed: 7 }
thresholds:
min_f1: 0.9
max_schema_error_rate: 0.02
migration:
max_iterations: 4
holdout_required: true
split_seed_count: 1
You need one way to swap the model at runtime. env_var is enough. A workflow may also set config_file:
env_var— Driftless sets this environment variable when it runs your command, so the same script can use a different model during eval.config_file+config_path— a path into a JSON/YAML file. Used when opening a PR, to write the new model ID into the repo.
Write scope is an exact allowlist. Only exact file paths in files.editable may be changed. A directory such as src/** in files.readonly documents a protected area; it does not define the write boundary or grant access elsewhere. Put parser/schema files the generator should inspect in files.context, and keep schemas, product code, eval labels, and secrets out of files.editable.
Percentages are ergonomic: 60%, 60, and 0.6 all parse to 0.6.
Command chooser
Start here if you know the goal but not the command:
- Try it without keys:
copy-example support-classifier, then the quickstart. - Is my setup wired?
validate. Is a cheaper model safe?compare. - Repair for a new model:
migrate. Repair after labels changed:refine. - Let CI decide what work exists:
plan. Preview a PR:open-pr(add--createonly to actually open it).
See the complete command chooser for label audits, judge checks, and automation variants.
The migration loop
In everyday terms: split the eval into a tuning set and a holdout set. Try the new model with the old prompt. If that already passes, stop. If not, generate small prompt edits, score them on tuning data, keep the smallest edit that helps, and only accept a winner that also passes on holdout — rows the loop never trained on.
The same loop powers migrate and refine:
- Migrate — the model changed. Repair until your thresholds pass.
- Refine — the eval data changed. Keep the model pinned, chase a better score, then suggest new thresholds.
The sketch below is for readers who want the exact algorithm. You can skip it and still use the CLI. (evaluate means: apply files in a backup/restore sandbox, run your real command, score the split.)
run_migration(W, M_target, generator G, objective O, seed):
# ── Setup ──────────────────────────────────────────────
tuning, holdout ← split(W.dataset, seed) # deterministic, seeded
baseline ← evaluate(M_current, current_files, tuning)
naive_target ← evaluate(M_target, current_files, tuning)
# ── Short-circuit: is a bare model swap enough? (migrate) ─
if O = MEET_THRESHOLDS and passes(baseline, naive_target)
and passes_on(holdout):
return model_change_only # just bump the model ID
# ── Iterative repair ───────────────────────────────────
original ← current editable files # frozen, for diff sizing
best ← naive_target ; best_files ← {} ; best_size ← 0
width ← G.num_candidates ; widened ← false # adaptive search width
for i in 1 .. W.migration.max_iterations:
clusters ← cluster_failures(best.rows) # group similar errors
context ← { clusters, failing & correct examples,
attempt history, editable + readonly files }
candidates ← G.generate(context, escalated_width if widened else width)
if candidates = ∅: break
improved ← false
for patch in candidates:
size ← diff_size(patch, original)
try:
check_scope(patch) # reject edits outside files.editable
cand ← evaluate(M_target, apply(patch), tuning)
except error: # patch broke the workflow
log(failed) ; continue # skip it — never abort the run
better ← score(cand, O) > score(best, O)
tie ← score(cand, O) = score(best, O) and size < best_size
if better or tie: # tie → the smaller edit wins
best, best_files, best_size ← cand, patch.files, size
improved ← true
if O = MEET_THRESHOLDS and passes(baseline, best)
and passes_on(holdout, best_files):
commit(best_files) ; return pass # validated on never-tuned data
if improved: widened ← false # progress → cheap width
else if not widened: widened ← true ; continue # stall → widen once
else: break # stalled at full width
# ── Resolve outcome ────────────────────────────────────
if O = MAXIMIZE: # refine
validate best_files on holdout (no-regression vs. current)
suggest fresh thresholds from holdout metrics
return pass if best beats naive_target else no_change
else: # migrate, thresholds unmet
return partial if best improved over naive_target else blocked
Holdout validation is what makes the result honest: the winning patch must perform on data the loop never optimized against.
Invariants the loop guarantees, regardless of what a generator proposes:
- Sandboxed trials — every candidate is applied via backup → run → restore; the working tree is written only on a committed pass.
- Crash isolation — a candidate that breaks the workflow (e.g. emits invalid YAML/JSON) is logged as a failed attempt and skipped; it can't abort the run.
- Minimal-change tie-breaker — on an exact score tie the smaller edit wins; against the no-op baseline (
best_size = 0) a same-scoring patch is rejected, so the loop never makes a change that doesn't help. - Stall-escalation — a stalled iteration widens the candidate pool once (to
max(width × 3, 5)) before giving up — cheap when easy, broad when stuck. - Holdout gate — nothing is committed until it clears a split it never tuned against.
Migration statuses
| Status | Meaning | Files committed? |
|---|---|---|
| model_change_only | The new model already passes with the old prompt. | No — just a model-ID change. |
| pass | Repair succeeded and holdout validated. | Yes. |
| partial | Improved over the naive swap but below thresholds. | No. |
| blocked | Could not recover quality within budget. | No. |
| no_change | refine: nothing beat the current prompt on the new dataset. | No. |
migrate exits non-zero on partial / blocked, so it gates CI naturally. A blocked migration still produces a full report and files an actionable issue.
Safety guarantees
These are enforced by the engine, not left to the patch generator:
- Edit-scope enforcement — any patch touching a file outside
files.editableis rejected before it is applied. - Sandboxed application — candidate edits are applied with originals backed up and always restored, so evaluation never leaves the repo dirty.
- Holdout gating — nothing is committed unless it passes thresholds on the holdout split.
- No auto-merge, no force-push —
open-pris a dry run by default;--createopens a PR/issue but never merges or pushes to the base branch.
Known limits
- The supported product is the command-line tool plus a GitHub Action you run in CI you control. There is no hosted bot.
- Embedding migrations and vector-index rebuilds are out of scope; RAG migrations keep retrieval fixed.
- Agent tools should be fake or sandboxed, and provider lifecycle data is bundled/CI-refreshed rather than a live hosted catalog.
- Small evals can produce noisy passes. Use representative data, meaningful holdout, and multiple seeds for high-risk changes. See eval confidence.
--generator fixtureonly repairs bundled simulators.--generator llmis refused on those; usesupport-classifier-liveor a customer harness.
Read the full limits and ownership boundaries before broad rollout.
Cost & budgets
compare runs current and target workflows. migrate adds candidate evaluations and holdout; judge grading can add one provider call per output on every evaluation. Start with a representative sample, migration.max_iterations: 2–3, one candidate at a time, and a required holdout. Expand only after the report shows useful progress.
Estimate rows × models × candidates × iterations (plus judge/tool calls), set provider-side spend limits, and keep open-pr dry-run until the result is understood. See cost shapes and suggested starting budgets.
Upgrading
Read the changelog before changing release lines. Version 0.3 rejects legacy migration.allow_* fields: replace them with exact files.editable paths, run validate locally, preview one compare, and only then update CI. Follow the complete 0.3 upgrade guide.
Triggers & policy
Most commands answer can we change the model. Policy answers when should we even look — optional, like a Dependabot config. A trigger is only a candidate; your eval still decides whether it is worth it.
| Trigger | Tier | Behavior |
|---|---|---|
deprecation | Forced | Within the warn window it always surfaces — a validated migration opens a PR; a blocked one files an issue. Urgency escalates as the retirement date nears. |
cost | Opportunistic | Surfaces only if a candidate is sufficiently cheaper with quality within tolerance. |
quality | Opportunistic | Surfaces only if a candidate measurably improves quality. |
new_model | Opportunistic | Surfaces a newly released candidate that passes your eval. |
A .driftless/policy.yml configures per-trigger thresholds, candidate allow/deny globs (preview models denied by default), and an ignore snooze list. The plan command wires this together as a CI triage step.
Today discovery emits deprecation triggers from the bundled lifecycle data. Cost / quality / new-model discovery plug in once a richer model catalog (pricing, release dates) is wired up.
CLI commands
| Command | Purpose |
|---|---|
copy-example <name> | Copy a bundled project: support-classifier, support-classifier-live, rag-qa, or tool-agent. |
init | Scaffold a neutral driftless.yml with explicit placeholders. |
init-policy | Scaffold migration-trigger policy. |
init-ci | Scaffold GitHub Actions; infers app setup when possible, or pass --setup-command; opt into --refine-on-push. |
scan | Find probable LLM usage and at-risk models. |
plan | Discover at-risk workflows and apply the migration policy (CI triage). |
configure <workflow> --apply | Write a reviewable draft and safely create or append root driftless.yml. |
validate -w <w> | Check driftless.yml and run your eval command once. |
audit-labels -w <w> | Find duplicate inputs with disagreeing gold labels (--fail for CI). |
judge-check -w <w> | Measure judge↔human agreement on a calibration set (--enforce to gate). |
calibrate -w <w> | Measure the baseline and suggest starting thresholds. |
compare -w <w> --to <model> | Baseline vs. target scorecard; add --enforce for a failing CI exit. |
migrate -w <w> --to <model> | Repair + validate + produce migrated files. |
refine -w <w> | Re-optimize the prompt for a changed dataset (model pinned). |
poll [--act] | Detect external eval changes and optionally refine. |
report [-w <w>] | Render the latest migration report(s). |
view [-w <w>] | Open the local optimization run viewer. |
open-pr -w <w> | Open a PR (or issue) whose body is the evidence report: summary, scorecard, unified diffs, attempt log, holdout checks. |
Useful flags on migrate:
--generator llm|none|fixture— repair strategy (LLM-backed by default;noneis a dry analysis;fixturereproduces bundled-example patches).--to <model>— the target model to migrate to (otherwise the contract's candidates are used).--strict-label-audit— block when duplicate/near-duplicate inputs disagree on gold labels (warns by default).
Contract schema reference
| Block | Key fields | Purpose |
|---|---|---|
run | command, input_path, output_path, timeout_seconds | How to execute the real workflow. |
model | current, target_candidates, env_var, config_file, config_path | Which model and how to override it. |
files | editable[], context[], readonly[] | editable is the only write scope. context is loaded for optimizer reference but never edited; readonly explicitly records other non-editable paths. |
eval | labels_path, schema_path, label_field, id_field, split | How to score outputs. |
thresholds | min_f1, min_precision, min_recall, max_schema_error_rate, max_cost_increase, max_latency_increase | What must hold to pass. |
migration | max_iterations, holdout_required, split_seed_count | Search budget and validation behavior. Edit permission comes only from files.editable. |
repair | system_prompt(_path), guidance, user_template(_path) | Customize the LLM repair prompt. |
Evaluation metrics
compare and migrate load the output JSONL your command writes, align it with gold labels, validate each record against the JSON schema, and compute:
By default they compare current prompt on current model vs current prompt on target. When the prompt was never source-optimized, that delta mixes prompt debt with model drift — see Measuring migration gains.
- Accuracy + macro precision / recall / F1 (per-class breakdown retained for failure clustering).
- Schema error rate — unparseable or schema-invalid records.
- Refusal rate — empty/
nulllabels, a truthyrefusedfield, or values listed ineval.refusal_values. - Average latency — derived from run duration / record count.
- Total cost — only when the workflow emits a per-record
cost_field. Token-based estimates are never fabricated.
Measuring migration gains
compare and migrate score your current prompt on the current model (baseline) and the same prompt on the target (naive_target). That mirrors flipping a model ID in prod without touching the prompt — the right default.
But when the prompt was never tuned to its ceiling on the source model, the delta conflates two effects:
- Prompt debt — under-optimization that would improve on either model.
- Model-induced drift — quality lost because the target behaves differently.
A headline "+0.07 F1 after migration" can be mostly debt, not repair. The refine path (dataset change, model pinned) avoids this; model migration needs a control.
2×2 control
Optimize on the source model first, then switch. Example from the testbed (macro-F1, 290 labels, real API calls, gpt-3.5-turbo → gpt-4o-mini):
| Prompt | Source | Target |
|---|---|---|
| P0 — original hand prompt | 0.922 | 0.904 |
| Psrc* — optimized for source | 0.993 (A) | 0.921 (B) |
| Ptgt* — optimized for target | 1.000 (C) | 0.987 (D) |
- P0 → A = prompt debt on the source (not migration).
- A → B (−0.072) = true model-induced drift from a strong baseline.
- B → D (+0.066) = gain from re-tuning after the switch.
Report migration repair relative to (B) — target model + source-optimized prompt — not the raw hand prompt. If baseline is far below your bar, run refine on the source model first, then compare --to again. Offline simulator regressions in the testbed do not reproduce on real gpt-4o-mini; validate model-switch claims on live APIs.
Full write-up and repro steps: Measuring migration gains honestly.
Run viewer
After migrate or refine, inspect the optimization trajectory in a local web UI — iteration metrics chart, failure-cluster trends, and a full attempt log (rationales, scores, diff sizes, accept/reject).
driftless view # opens http://localhost:8777/runs.html
driftless view -w support_classifier
The viewer reads .driftless/migrations/<workflow>.json from the current project. You can also load any result JSON via file picker or drag-and-drop. Static demo: runs.html → Load saved passing fixture. Reproduce that passing path locally with migrate --generator fixture on a bundled example. The --generator none quickstart still ends BLOCKED.
Repair & custom generators
The engine defines a PatchGenerator protocol; the repair strategy is swappable. Three ship out of the box:
LLMPatchGenerator(default) — asks an LLM to rewrite the editable files to fix the observed failure clusters. Provider-neutral, requests strict JSON, and varies temperature across candidates.NoOpPatchGenerator(--generator none) — proposes nothing; the loop becomes a dry analysis, useful offline and for CI gating.FixturePatchGenerator(--generator fixture) — applies the known-good patch for a bundled example so a passing repair is reproducible without provider keys.
You can customize repair via the contract's repair: block — append domain guidance, fully replace the system prompt, or supply a user_template with {{placeholder}} substitution (placeholders include failure_clusters, failing_examples, editable_files, metrics, and target_model).
A generator only ever proposes — the engine owns acceptance. See The migration loop for the full algorithm (sandboxing, crash isolation, the minimal-change tie-breaker, stall-escalation, and holdout gating).
See Repair prompts & custom generators for writing your own deterministic, rule-based generator.
GitHub Action
A composite GitHub Action wraps the CLI so scans and migrations can run in CI. After validating locally, run driftless init-ci, review the generated files (and any inferred setup step), and commit only the automation you need. Override setup with --setup-command when inference does not fit. Refinement remains manually dispatched unless --refine-on-push is explicit.
# .github/workflows/driftless-plan-preview.yml
name: Driftless plan preview
on:
schedule: [{ cron: "0 9 * * 1" }]
workflow_dispatch: {}
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: driftless-dev/driftless@v0.3.6
with:
command: plan
This is a policy plan preview, not the dependency-only scan workflow. A scheduled plan gates CI when a deprecated model needs attention; a manually-triggered migrate opens a PR (or an issue when blocked) with the evidence attached.
Ready to try it? Head back to the quickstart, or explore the project on GitHub.