Command line¶
Every project gets a manage.py. The mlango script does the same job before a
project exists, and python -m mlango works when the script is not on PATH.
The commands¶
Getting started¶
| Command | Does |
|---|---|
mlango startproject NAME [DIR] |
Scaffold a project that already works. --bare skips the demo app |
manage.py startapp NAME |
Scaffold an app: datasets, models, agents, evals, admin, migrations |
manage.py check |
Validate settings, backends, wiring, migrations and the admin |
mlango startplugin NAME --kind trainer |
Scaffold a publishable package that extends mlango |
startplugin needs no project: it writes a distributable package — pyproject
with the entry point already declared, the contract with its interesting parts
commented, a LICENSE and tests — so pip install is all a project needs to reach
it. --kind is trainer, provider, storage or source. See
Extending mlango.
Bringing your own data¶
Django has inspectdb for an existing database. This is the same idea for a
data file: it samples the file and prints a Dataset you can paste into
datasets.py, so your first declaration is an edit rather than a blank page.
python manage.py inspectdata data/reviews.csv
python manage.py inspectdata data/reviews.csv --name Feedback -n 5000
python manage.py inspectdata data/reviews.csv --write --app reviews
Reads .csv, .tsv, .jsonl, .ndjson, .json and .parquet. It needs no
declarations of its own, so it works on a project you have only just created.
class Reviews(Dataset):
"""40 rows, 6 columns."""
id = IntegerField(min_value=1, max_value=40)
body = TextField()
stars = IntegerField(min_value=1, max_value=5)
country = CharField(max_length=16, choices=["GB", "US"])
verified = BooleanField()
label = LabelField(["neg", "pos"])
class Meta:
source = CSVSource("data/reviews.csv")
primary_key = "id"
What it decides, and why:
| Signal | Becomes |
|---|---|
| All values parse as whole numbers | IntegerField with the observed range |
| Any value has a decimal point | FloatField with the observed range |
true/yes/t/on and their opposites |
BooleanField |
| A dict, a list, or a string parsing as either | JSONField |
| ISO timestamps | DateTimeField |
| Few distinct values, and they repeat | CharField(choices=…) |
| Any value longer than 32 characters | TextField |
A column named label, target, y, class… |
LabelField or TargetField |
A unique column named id, uuid or *_id |
Meta.primary_key |
| Some values missing | null=True, required=False |
Two rules worth knowing. Exactly one column becomes a target — declaring two
would leave Model.get_target() unable to choose, so other categorical columns
stay CharField with choices. And a column is only given a max_length when
every sampled value is short, because a limit that turns out to be too small
rejects valid data later, while TextField never rejects anything.
It is a starting point, not an oracle. Anything it guessed at carries a comment saying so, and a column name that cannot be a Python attribute is reported rather than silently mangled.
Data¶
python manage.py dataset list
python manage.py dataset show reviews.Reviews
python manage.py dataset head reviews.Reviews -n 20
python manage.py dataset validate reviews.Reviews
python manage.py dataset materialize reviews.Reviews --notes "nightly snapshot"
python manage.py dataset versions reviews.Reviews
Migrations¶
python manage.py makemigrations [app] [-n NAME] [--dry-run] [--empty]
python manage.py migrate [app] [--plan] [--fake]
python manage.py showmigrations [app]
Training¶
python manage.py train reviews.Sentiment -p C=2.0 -p max_features=5000 \
--tag baseline --notes "first attempt" --materialize
python manage.py sweep reviews.Sentiment -p C=0.25,1,4 \
--strategy grid --metric accuracy --mode max --promote-best production
| Flag | Effect |
|---|---|
-p NAME=VALUE |
Override a hyperparameter. Repeatable |
--dataset LABEL |
Train on a different dataset |
--tag TAG |
Tag the run. Repeatable |
--seed N |
Override the seed |
--materialize |
Freeze the training view into a dataset version first |
--no-register |
Train without adding to the registry |
Trials run one after another by default. --workers runs them together:
Threads, not processes — settings and the registry are already shared, the metastore is SQLite in WAL mode built for overlapping readers and writers, and the numeric work in sklearn and torch releases the GIL.
One honest cost: the RNG seed is process-global, so concurrent trials no longer each begin from the same state. A sweep is a search rather than a number to reproduce, which is why the option exists — re-run the winning point on its own if you need its exact score back.
Prediction¶
Scoring without starting a server. The model comes from the registry, so this runs the same artefact the API would serve.
python manage.py predict reviews.Sentiment "loved every minute of it"
python manage.py predict reviews.Sentiment "great" "awful" --proba
python manage.py predict reviews.Sentiment --dataset -n 100
python manage.py predict reviews.Sentiment --dataset --filter label=pos
python manage.py predict reviews.Sentiment --file incoming.jsonl \
--format jsonl --output scored.jsonl
| Flag | Effect |
|---|---|
--dataset |
Score the model's own declared dataset |
--filter FIELD=VALUE |
Narrow the dataset. Repeatable |
--file PATH |
Score a csv/tsv/jsonl/json/parquet file |
-n N |
Stop after N records |
--version N / --stage NAME |
Which registered version to load |
--proba |
Include class probabilities |
--format table\|jsonl\|csv |
How to print it |
--output PATH |
Write to a file instead of stdout |
An id, uuid or pk on the input is carried through to the output, so a
scored file can be joined back to where it came from. If the data is missing a
feature the model needs, the command says which column is absent and what the
data does have — rather than letting the trainer fail somewhere deep inside a
vectoriser.
Explaining a version¶
Which features a trained version actually leaned on. The weights are recorded on the version row when it is registered, so this reads the metastore and never loads the artifact:
python manage.py explain reviews.Sentiment
python manage.py explain reviews.Sentiment --stage production -n 10
python manage.py explain reviews.Sentiment --json
reviews.Sentiment@v4
top 10 of 40, largest weight first
delightful ████████████████████████████████ 2.4439
dull ████████████████████████████···· -2.1614
brilliant ███████████████████████████····· 2.0495
boring ███████████████████████████····· -2.0407
badly ██████████████████████████······ -1.9794
beautifully ██████████████████████████······ 1.9708
awful █████████████████████████······· -1.8902
excellent █████████████████████████······· 1.8844
waste ███████████████████············· -1.4853
every ███████████████████············· 1.4288
A pipeline's vectoriser names its own columns, which is what turns 40,000 numbered slots into the words above. The sign is the direction of the effect — kept for binary and regression fits, where it means something, and dropped for multiclass, where a feature arguing for one class argues against another.
| Flag | Effect |
|---|---|
--version N / --stage NAME |
Which version to explain (default: newest) |
-n N |
How many features to show |
--json |
Emit the weights instead of a chart |
--recompute |
Load the artifact, re-derive the weights and store them |
--recompute is the escape hatch for a version registered before mlango knew
how to explain it. Backends that cannot name a feature — the neural ones —
report nothing rather than inventing a plausible list.
Diffing two versions¶
Aggregate metrics answer "is the new one better" and hide the answer you are afraid of: a version two points more accurate overall can still have broken forty rows that used to work. This scores both on the same data and diffs the answers.
python manage.py diff reviews.Sentiment 3 4
python manage.py diff reviews.Sentiment # production vs newest
python manage.py diff reviews.Sentiment 3 4 --show-changes 20
python manage.py diff reviews.Sentiment 3 4 --fail-on-regression
reviews.Sentiment v3 → v4 on 500 rows of reviews.Reviews
agreement 94.2%
changed 29 row(s)
neg → pos 18
pos → neg 11
Against the labels
v3 accuracy 0.8840
v4 accuracy 0.9020 +0.0180
fixed 22 row(s) wrong in v3
broke 4 row(s) right in v3
verdict a real improvement: 22 fixed against 4 broken (p=0.001)
broke is the number nobody reports and everybody wants. A promotion that
improves the average while losing rows that used to work is the kind that gets
reverted a week later, and --fail-on-regression turns it into an exit code you
can put in front of a promotion.
verdict answers the question those two counts invite. Rows both versions
get right say nothing about which is better, and neither do rows both get wrong;
only the disagreements carry information. So the question is whether a coin that
came up 22 heads in 26 tosses was fair, which is
McNemar's test, computed
exactly rather than by approximation because promotions are usually decided on a
few hundred rows.
The distinction it draws is the one that matters before a promotion: 200 fixed against 3 broken is an improvement, 38 fixed against 40 broken is a coin, and a rule that counts broken rows calls both of them a regression.
--from-log compares the two versions on requests they already answered,
rather than scoring a dataset now. That needs a
shadow deployment — both versions answering the
same traffic — and the report then carries no fixed/broke, because
production traffic has no labels:
With no version numbers it compares what is in production against the newest — which is the question you have when you are about to promote something.
| Flag | Effect |
|---|---|
--dataset LABEL |
Score a different dataset, e.g. a held-out set |
-n N |
Stop after N rows |
--show-changes N |
Print up to N rows where the two disagree |
--json |
Emit the whole report |
--fail-on-regression |
Exit non-zero if the newer one lost a row the older one got right |
--fail-on-regression significant |
Exit non-zero only when the losses beat the gains by more than chance |
--alpha P |
Significance level for the mode above. Default 0.05 |
# A curated regression suite: nothing may be lost.
python manage.py diff reviews.Sentiment --fail-on-regression
# A real dataset before a promotion: noise may pass, a real loss may not.
python manage.py diff reviews.Sentiment --fail-on-regression significant
Three renderings, one report. --format markdown produces something meant to be
posted in a pull request rather than read in a terminal, and --output writes it
to a file without touching the exit code — so a CI job can keep the report and
still go red:
python manage.py diff reviews.Sentiment --format markdown --show-changes 20 --output diff.md --fail-on-regression significant
--json is the older spelling of --format json and still means what it meant.
The workflow around this is in Continuous integration.
Promoting a version¶
The other half of the diff. promote moves a model or agent version to a
stage, and --check compares it with whoever holds that stage first — refusing
the promotion if the candidate lost rows.
python manage.py promote reviews.Sentiment 4 # to production
python manage.py promote reviews.Sentiment # the newest version
python manage.py promote reviews.Sentiment 4 --stage staging
python manage.py promote reviews.Sentiment 4 --check # lose nothing
python manage.py promote reviews.Sentiment 4 --check significant # lose nothing that matters
$ python manage.py promote reviews.Sentiment 2 --check
v1 → v2 on 500 rows of reviews.Reviews
accuracy 0.7700 → 0.8060 +0.0360
fixed 29 row(s)
broke 11 row(s)
error: Refusing to promote: v2 is wrong on 11 row(s) that v1 got right.
Inspect them with: manage.py diff reviews.Sentiment 1 2 --show-changes 11
Note that v2 is more accurate and the strict check still refuses it. That
rule is for a curated suite where nothing may be lost. On a real dataset use
--check significant, which allows a loss the evidence cannot distinguish from
a coin and refuses one it can:
verdict a real improvement: 29 fixed against 11 broken (p=0.006)
reviews.Sentiment@v2 is now at stage 'production'.
| Flag | Effect |
|---|---|
--stage NAME |
Which stage. Default production |
--check [any\|significant] |
Compare with the incumbent first, and refuse a regression |
--dataset LABEL |
Score --check against this dataset |
-n N |
Cap the rows --check scores |
--notes TEXT |
Why, recorded with the move |
--history |
List what has been promoted instead of promoting |
One verb covers models and agents — an agent version is the same idea, so
promote support.Support 3 works too. --check needs a model, because it
compares predictions; for an agent, compare two runs of its evaluation suite
with diff --eval.
Every move is recorded. The stage column is mutable — promoting v3 overwrites
what v2 was — so on its own a registry can say what is live and nothing about
how it got there:
python manage.py promote reviews.Sentiment --history # one model
python manage.py promote --history # everything
reviews.Sentiment — 3 move(s), newest first
when version move who on the strength of
---------------- ------- --------------------- ------- -------------------------------------
2026-08-27 11:26 v2 none → production denis 29 fixed / 11 broke, accuracy +0.0360
2026-08-27 11:26 v1 production → archived denis superseded by v2
2026-08-20 09:03 v1 none → production denis first one live
Three things are deliberate there. The demotion is logged too, so the history
reads as a history rather than as a list of winners. --check writes its
verdict into the row, because a promotion made on a comparison and one made on a
hunch look identical a month later unless the comparison was written down — and
a move nobody checked says not checked rather than showing a blank, which is
the most useful thing a promotion log can tell you. And the actor is the local
user, git-style; set MLANGO_ACTOR to override it, which is what a CI job
should do, since the runner's account is nobody.
From Python the same log is mlango.metastore.history:
from mlango.metastore.history import history, stage_at
history("reviews.Sentiment") # moves, newest first
stage_at("reviews.Sentiment", when=last_tuesday) # what was live then
stage_at replays the log rather than reading the version rows, because the
version rows only know about now — which is exactly the wrong thing to ask when
something broke last Tuesday.
Models mlango did not train¶
The comparison does not care where the two models came from — it needs two
things that can predict and a dataset to score them on. So you can point it at
artefacts you already have, without a Model class and without adopting
anything:
python manage.py diff --dataset reviews.Reviews \
--left models/sentiment-v3.joblib \
--right models/sentiment-v4.joblib
The dataset is required, because a saved model carries neither the rows to score
it on nor the column that holds the answer. If you do not have one declared yet,
manage.py inspectdata data/rows.csv writes it from a file.
| Flag | Effect |
|---|---|
--left URI, --right URI |
The two models. A path, or scheme:reference |
--task |
classification (default) or regression |
--target |
Column to score against. Defaults to the dataset's declared target |
--features |
Comma-separated inputs. Defaults to every field but the target and primary key |
A plain path is loaded with joblib, falling back to pickle. Other schemes come
from packages registering under the mlango.loaders entry-point group:
The function takes the part after the scheme — models:/Sentiment/3 for
mlflow:models:/Sentiment/3 — and returns anything with a predict method.
Registry clients live in those packages rather than here, because a framework
that installs somebody else's SDK to read one file is not one you want.
Regression models are compared by distance rather than equality — two float predictions are never equal — so the report gives mean and largest delta, and counts rows that got closer to the truth against rows that got further away.
Unlabelled data still works: the report then says what changed, and does not pretend to say what improved.
This one is not in the admin, and that is deliberate: it loads two models and scores a dataset, which belongs behind a command you chose to run rather than a page that loads when you click a link.
Diffing two evaluation runs¶
An agent has no version number. You change a prompt, a tool description or a model, re-run the suite, and the only thing that moves is a pass rate — which hides exactly what an accuracy hides: some of the cases that used to pass now do not, and they are usually the ones somebody complained about.
The per-case results are already stored, so this joins two runs on case_id:
python manage.py diff --eval support.AnswerQuality
python manage.py diff --eval support.AnswerQuality --runs 7c8f1020 c089b7e6
python manage.py diff --eval support.AnswerQuality --show-changes 20
python manage.py diff --eval support.AnswerQuality --fail-on-regression significant
support.AnswerQuality 7c8f1020 → c089b7e6 on 120 shared case(s)
7c8f1020 pass rate 0.8250
c089b7e6 pass rate 0.8667 +0.0417
fixed 7 case(s) failing in 7c8f1020
broke 2 case(s) passing in 7c8f1020
verdict 7 fixed against 2 broken is not distinguishable from noise (p=0.180)
reworded 11 case(s) answered differently and still passed
With no --runs, the two most recent finished runs of that suite are compared.
The eval page in the admin shows the same comparison for the last two runs. Unlike the model diff it costs nothing to render — nothing is loaded and nothing is scored, because evaluate already wrote a verdict per case.
The report also says what changed about the thing being evaluated. Each run records the target's configuration — an agent's prompt, model and step limit; a model's registered version and hyperparameters — so the diff can put a cause beside the effect:
verdict a real regression: 50 broken against 0 fixed (p=0.000)
What changed about it
version 21 → 22
C 8.0 → 0.01
max_features 5000 → 1
A long value such as a system prompt is reported as changed with both lengths rather than printed; a page of text in a terminal report helps nobody. When nothing about the target moved, the report says so — and that is informative in its own right, because the difference is then the target's own: sampling, a temperature, a tool that answered differently.
Runs recorded before this existed carry no configuration, and are reported as unknown rather than as unchanged.
reworded is the line that only matters for an agent: cases that still pass
but answer differently. For a classifier that is nothing; for something whose
output a person reads, half the product just changed without failing a test.
Cases present in only one of the runs are named, never absorbed. A suite that grew between the two runs is a different suite, and quietly folding the new cases into the totals is how a pass rate improves by adding easy questions.
| Flag | Effect |
|---|---|
--runs OLDER NEWER |
Which two runs. Defaults to the two most recent |
--show-changes N |
Print up to N cases, those whose verdict moved first |
--json |
Emit the whole report |
--fail-on-regression [any\|significant] |
Exit non-zero. Same rule as for models |
--alpha P |
Significance level. Default 0.05 |
The verdict line is the same McNemar test a model diff uses, for the same reason: seven fixed against two broken on a suite of 120 is not evidence, and a gate that treats it as evidence will be turned off within a month.
Watching for drift¶
Whether the input has moved away from what a version was trained on. Reads the prediction log, which is off until you turn it on — see Monitoring.
python manage.py drift reviews.Sentiment
python manage.py drift reviews.Sentiment --stage production --since 24h
python manage.py drift reviews.Sentiment --against reviews.Incoming
python manage.py drift reviews.Sentiment --since 24h --fail-on significant
reviews.Sentiment@v4 vs 2841 logged predictions over the last 7d
Column Kind PSI Verdict
----------------- ----------- ------ -----------
text text 0.4132 significant
label (predicted) categorical 0.1801 moderate
--fail-on exits non-zero, which is what makes this usable from a scheduled job
rather than only from a terminal.
Evaluation¶
python manage.py evaluate support.AnswerQuality
python manage.py evaluate support.AnswerQuality --show-failures
python manage.py evaluate support.AnswerQuality --min-pass-rate 0.9
Agents¶
python manage.py agent support.Support # interactive
python manage.py agent support.Support "how do I ...?" # one shot
python manage.py agent support.Support "..." --show-steps # print tool calls
python manage.py agent support.Support "..." --session user-42 # with memory
Agent versions¶
A model version is an artifact; an agent's behaviour is its declaration, so a
version is the declaration. One is recorded the first time an agent runs, and
again whenever the prompt, the model or any other Meta option changes:
python manage.py agent support.Support --versions
python manage.py agent support.Support --promote 3
python manage.py agent support.Support --promote 3 --stage staging
Version Stage Fingerprint Tools Recorded Current
------- ---------- ------------ --------------- ---------------- -------
v3 none 16c5d2295ade search_docs 2026-08-22 11:09 ←
v2 production 58fa45bab53f search_docs 2026-08-19 09:22
v1 archived a1b2c3d4e5f6 search_docs 2026-08-14 17:40
The ← marks the version matching the declaration in front of you. When nothing
is marked, the code has been edited since anything was recorded — what is
written down and what would run have parted company, and the command says so.
Registration is idempotent by fingerprint and resolved once per process, so a served agent answering a thousand requests writes one row and runs one query. Every trace records which version answered, so a trace read next month is not interpreted against today's prompt.
A version pins configuration, not code
Tools are callables and live in your source. A recorded version keeps their names, so a removed tool is visible, but it cannot restore an implementation. A registry that claimed otherwise would be lying.
Reverting a prompt records a new version with the earlier fingerprint rather than reusing the old row: the history is a log of what the declaration was and when, and "it changed back on Tuesday" is part of that.
Inspecting what happened¶
python manage.py runs list --kind train --status finished -n 20
python manage.py runs show 7c8f1020
python manage.py runs compare 7c8f1020 c089b7e6
python manage.py traces list --agent support.Support
python manage.py traces show a1b2c3d4 -v 2
Development¶
python manage.py runserver # 127.0.0.1:8000
python manage.py runserver 8080
python manage.py runserver 0.0.0.0:8080 --reload
python manage.py runserver --no-admin
python manage.py shell # IPython when available
python manage.py shell -c "print(Reviews.objects.count())"
python manage.py test # pytest, against a throwaway metastore
python manage.py test -k splits -x
python manage.py test --coverage
manage.py test points the metastore and artifact store at a temporary
directory for the duration of the run, so a test can never touch real data —
the same idea as Django creating a test database.
Common flags¶
Available on every command:
| Flag | Effect |
|---|---|
--settings MODULE |
Use a different settings module for this run |
-v 0..3 |
Quiet, normal, verbose, very verbose |
--traceback |
Show the full traceback instead of a message |
The shell¶
manage.py shell pre-imports every declared object plus a few helpers:
>>> Reviews.objects.filter(label="positive").count()
1284
>>> Sentiment.versions()
[<ModelVersion reviews.Sentiment@v2 stage=production>, ...]
>>> recent_runs(limit=3)
>>> get_trace("a1b2c3d4").spans
>>> apps.summary()
Your own commands¶
Drop a module in <app>/management/commands/ and it appears in
manage.py help — including one that overrides a built-in, which is how a
project customises train without forking the framework.
from mlango.management import BaseCommand, CommandError
class Command(BaseCommand):
help = "Import reviews from the warehouse."
def add_arguments(self, parser):
parser.add_argument("since", help="ISO date to import from.")
parser.add_argument("--dry-run", action="store_true")
def handle(self, **options):
rows = fetch_since(options["since"])
if not rows:
raise CommandError(f"Nothing to import since {options['since']}.")
self.table(
["id", "subject"],
[[r["id"], r["subject"]] for r in rows[:10]],
)
if options["dry_run"]:
self.warn("Dry run: nothing written.")
return
write(rows)
self.ok(f"Imported {len(rows)} review(s).")
Helpers available on self:
| Helper | Prints |
|---|---|
self.write(msg, level=1) |
A line, respecting -v |
self.ok(msg) / self.warn(msg) |
Green / yellow |
self.stderr(msg) |
To stderr |
self.table(headers, rows) |
An aligned table |
self.style.bold(...) etc. |
Colour, disabled when output is redirected |
Raise CommandError for anything the user should see as a message rather than a
traceback. Set requires_apps = False for a command that must run before apps
load, and requires_settings = False for one that runs without a project.