Skip to content

Architecture

How the pieces fit, what may import what, and what actually happens when you run a command. Every diagram here is text in the repository, so it can be reviewed in a diff.

The layers

graph TD
    subgraph foundation[" "]
        CORE["<b>core</b><br/><small>fields · metaclass · Options · registry<br/>settings · signals · exceptions</small>"]
    end

    subgraph persistence[" "]
        META["<b>metastore</b><br/><small>10 tables · sessions</small>"]
        MIG["<b>migrations</b><br/><small>autodetector · writer · executor</small>"]
        STORE["<b>storage</b><br/><small>artifacts</small>"]
    end

    subgraph families[" "]
        DATA["<b>data</b><br/><small>Dataset · QuerySet · Sources</small>"]
        TRAIN["<b>training</b><br/><small>Model · trainers · runs · sweeps</small>"]
        AGENTS["<b>agents</b><br/><small>Agent · tools · memory · providers</small>"]
        EVALS["<b>evals</b><br/><small>Eval · scorers</small>"]
    end

    subgraph surfaces[" "]
        ADMIN["<b>admin</b>"]
        SERVE["<b>serve</b>"]
        CLI["<b>management</b><br/><small>manage.py</small>"]
    end

    CORE --> META
    CORE --> STORE
    META --> MIG
    CORE --> DATA
    CORE --> TRAIN
    CORE --> AGENTS
    CORE --> EVALS
    META -.-> DATA
    META -.-> TRAIN
    META -.-> AGENTS
    META -.-> EVALS
    DATA --> ADMIN
    TRAIN --> ADMIN
    AGENTS --> ADMIN
    EVALS --> ADMIN
    DATA --> SERVE
    TRAIN --> SERVE
    AGENTS --> SERVE
    ADMIN --> CLI
    SERVE --> CLI

The rules, which tests/ and code review both enforce:

Layer May import Must not import
core the standard library anything else in mlango
metastore, storage core the four families
data, training, agents, evals core, metastore, storage each other
admin, serve, management everything, through _meta

The one that matters most is the third. The four families not importing each other is what lets you use the agent half without the ML half, and it is why a project that declares only datasets never loads a line of agent code.

A helper needed by two families belongs in core. core/serialization.py exists for exactly that reason: agents and evals were both reaching into a private function in training.

How a declaration becomes metadata

sequenceDiagram
    participant P as Your class body
    participant M as DeclarativeMeta
    participant O as Options (_meta)
    participant R as Registry

    P->>M: class Urgency(Model)
    M->>M: collect Field instances, in declaration order
    M->>M: walk the MRO for inherited fields and Meta options
    M->>O: build Options(kind, label, app_label, fields, extras)
    O->>O: validate Meta keys against _meta_options
    M->>P: replace each Field with a FieldDescriptor
    M->>P: call _prepare() (Dataset gains .objects)
    M->>R: register unless Meta.abstract
    Note over R: apps.get_model("tickets.Urgency") now resolves

Two consequences worth knowing.

Meta options inherit. Python class bodies do not inherit on their own, so a subclass writing its own class Meta would silently lose everything the parent declared. Options.inherit_extras() fills in what the subclass did not name, which is what makes a reusable base class like TextClassifier possible. abstract is excluded, or every subclass of an abstract base would be abstract too.

An unknown Meta key is an error. It lists the allowed ones, because a silently ignored option is a bug you find weeks later.

What manage.py train actually does

sequenceDiagram
    autonumber
    participant U as You
    participant C as manage.py
    participant R as Registry
    participant M as Model
    participant Q as QuerySet
    participant T as Trainer
    participant DB as Metastore
    participant S as Storage

    U->>C: train tickets.Urgency -p C=2.0
    C->>C: load settings, mlango.setup()
    C->>R: autodiscover datasets/models/agents/evals per app
    C->>R: get_model("tickets.Urgency")
    C->>M: Urgency(C=2.0) — fields validate the value
    M->>DB: RunContext.start() — seed, device, git commit, host
    M->>Q: dataset.objects → split by hash of the key
    M->>DB: record _data_fingerprint
    M->>T: fit(train, validation, run, callbacks)
    loop each epoch
        T->>DB: log_metrics()
        T->>C: callbacks.emit(on_epoch_end)
    end
    T->>S: save the fitted artifact
    M->>DB: register ModelVersion v1
    M->>DB: run finished

Everything after step 5 happens whether you asked for it or not. That is the inversion: the parts that are easy to forget are the parts you do not write.

The metastore

Ten tables, SQLite by default and the same schema on Postgres.

erDiagram
    RUN ||--o{ METRIC : records
    RUN ||--o{ ARTIFACT : writes
    RUN ||--o{ MODEL_VERSION : registers
    RUN ||--o{ TRACE : produces
    RUN ||--o{ EVAL_RESULT : scores
    DATASET_VERSION ||--o{ RUN : "was read by"
    TRACE ||--o{ SPAN : "step by step"
    MIGRATION {
        string app
        string name
        datetime applied
    }
    RUN {
        string uuid
        string kind
        string target
        string status
        json params
        json summary
        string git_commit
    }
    MODEL_VERSION {
        int version
        string stage
        string path
    }
    MODEL_VERSION ||--o{ PREDICTION : "answered"
    PREDICTION {
        int version
        json inputs
        json output
        datetime created_at
    }
    DATASET_VERSION {
        int version
        string content_hash
        string fingerprint
    }

RUN.kind is one of train, sweep, eval or agent, which is why a sweep and its trials, or an agent call and a training run, all live in one history and one admin.

Two identities are deliberately separate on a dataset version: fingerprint is the hash of the declaration, content_hash is the hash of the rows. A schema change and a data change are different events, and telling them apart six months later is the point of storing both.

Extension points

Every one of these is a settings entry pointing at a dotted path, so replacing one never means forking the framework.

Point Setting Contract
Trainer TRAINERS fit, predict, save, load
LLM provider PROVIDERS one method: complete()
Artifact storage STORAGE["BACKEND"] path, open, save_bytes, read_bytes, exists, delete, size, listdir
Serving middleware SERVE_MIDDLEWARE ASGI middleware, outermost first
Training callbacks DEFAULT_CALLBACKS any subset of the Callback hooks
Data source Meta.source iterable of dicts, optionally count()
Commands <app>/management/commands/ a Command(BaseCommand), may override a built-in

The narrow ones are narrow on purpose. A provider is one method because the agent loop, tool dispatch, memory and tracing belong to the framework: swapping providers must not be able to change how an agent behaves.

Request paths

graph LR
    REQ([HTTP request]) --> MW["SERVE_MIDDLEWARE<br/><small>logging · api key · rate limit · guardrails</small>"]
    MW --> R{path}
    R -->|/admin| ADM["Admin<br/><small>Jinja2, inline SVG</small>"]
    R -->|/api/...| EP["Endpoint from a declaration"]
    R -->|/api/docs| DOC["OpenAPI from _meta"]
    EP --> LOAD["Registered version<br/><small>loaded once, cached</small>"]
    ADM --> DB[(Metastore)]
    LOAD --> ST[(Storage)]

The admin and the API are one ASGI application, so manage.py runserver is a single process in development and the same object behind gunicorn in production.