ModelDiff
Regression testing for local AI.
ModelDiff is a local-first testing framework for detecting behavioral regressions across AI model versions, inference configurations, prompt templates, and runtime changes. You describe test cases in a version-controlled YAML or JSON file, run them against a baseline and a candidate configuration, and get a report of what broke, what got fixed, and what merely changed.
No API keys are needed. The framework, its test suite, and the example workflow run fully offline. Running against a live model is optional and uses a local Ollama server.
Why
Swapping a model version, quantization level, prompt template, inference runtime, or generation setting can silently break an application that depends on the model's output:
- JSON that used to parse now arrives wrapped in a Markdown code fence.
- A classifier starts returning a different label.
- A tool call passes
7730where the schema expects"7730". - A required field disappears from a structured response.
- Responses get noticeably slower.
Ordinary unit tests do not cover these cases well, because the "code" that changed is a model. ModelDiff gives you repeatable test cases with explicit pass criteria, so a model or config change can be checked like any other change.
Features
- Test cases as data. Suites in YAML or JSON, validated against a schema, with error messages that name the case and field at fault. Every problem in the file is reported at once.
- Six check types.
Type Passes when exactoutput equals expected(whitespace-stripped by default; optional case-insensitive)jsonoutput parses as JSON and, if schemais given, validates against that JSON Schemaclassificationthe predicted label equals expectedand is one oflabels(if given)required_fieldsthe JSON output contains every dotted path in requiredand matchesvaluestool_callthe output is a tool call with the expected name, and its arguments equalargumentsand/or validate againstarguments_schemacustoma Python function you register returns a pass - Baseline vs candidate comparison that separates regressions (pass to fail) from fixes (fail to pass), cases still failing, behavior changes (passing in both runs but with different output), classification label changes, and cases missing from one side.
- Reproducible result files (
modeldiff.run/v1JSON) recording the output or its hash, status, failure reason, latency, timestamp, runtime and model metadata, and errors. Keys that look like secrets are redacted. - Reports in text, Markdown, or JSON, written to stdout or a file.
- Latency comparison with an opt-in threshold. Slowdowns are reported but do not fail the build unless you ask them to.
- Optional process memory measurement via
psutil, clearly labelled as ModelDiff's own process. - Two providers.
replayserves recorded outputs and needs no model;ollamacalls a local Ollama server using only the standard library. - CI-friendly CLI with stable exit codes.
What ModelDiff does not do: it does not judge semantic correctness, factuality, or hallucination. A case passes or fails only according to its explicit criterion. When an output changes but the criterion is still met, ModelDiff reports a behavior change for you to review, not a regression.
Installation
Requires Python 3.11 or newer.
pip install openwave-modeldiff
pip install "openwave-modeldiff[memory]" # optional: per-case process memory via psutil
The package is named openwave-modeldiff on PyPI because modeldiff is taken by an unrelated project. The import name and the command are both modeldiff.
To work from a clone instead, for example to contribute:
git clone https://github.com/OpenWave-India/modeldiff.git
cd modeldiff
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e .
Optional extras:
pip install -e ".[memory]" # per-case process memory via psutil
pip install -e ".[dev]" # pytest and ruff for development
Quick start
The repository includes an example suite for a customer-support assistant, with two sets of recorded outputs. These outputs are hand-written fixtures that illustrate typical regressions. They are not real model outputs.
# 1. Check the suite file. The example uses a custom check defined in checks.py.
modeldiff validate examples/quickstart/suite.yaml --plugin examples/quickstart/checks.py
# 2. Produce a baseline and a candidate result file.
modeldiff run examples/quickstart/suite.yaml \
--config examples/quickstart/configs/baseline.yaml \
--plugin examples/quickstart/checks.py \
--output out/baseline.json
modeldiff run examples/quickstart/suite.yaml \
--config examples/quickstart/configs/candidate.yaml \
--plugin examples/quickstart/checks.py \
--output out/candidate.json
# 3. Compare them.
modeldiff compare out/baseline.json out/candidate.json --latency-threshold 25 --min-latency-delta 100
Output of step 3 (exit code 1 because regressions were found):
ModelDiff comparison: support-assistant
Baseline: baseline-q8 Candidate: candidate-q4
REGRESSIONS FOUND (4) - 9 cases: 4 regressed, 1 fixed, 0 still failing, 4 passing in both, 3 passing with changed output
Regressions (4):
ticket-json [json] pass -> fail: output is not valid JSON (line 1, column 1: Expecting value)
ticket-required-fields [required_fields] pass -> fail: missing field(s): shipping.city
intent-billing [classification] pass -> fail: predicted 'technical', expected 'billing'
tool-lookup [tool_call] pass -> fail: tool arguments differ: order_id=7730 (expected '7730')
Fixed (1):
sentiment-positive [classification] fail -> pass
Passing with changed output (review, not a regression) (3):
sentiment-negative [classification] pass -> pass
tool-refund [tool_call] pass -> pass
summary-length [custom] pass -> pass
Classification changes (2):
sentiment-positive: 'neutral' -> 'positive'
intent-billing: 'billing' -> 'technical'
Warnings:
summary-length: latency 1720.0 ms exceeds max_latency_ms 1500 (new)
Latency (median over 9 cases): 420.0 ms -> 450.0 ms (+7.1%; baseline recorded, candidate recorded)
Slower than +25%:
summary-length: 900.0 ms -> 1720.0 ms
For a Markdown report, for example to attach to a pull request:
modeldiff compare out/baseline.json out/candidate.json --format markdown --output report.md
| Case | Type | Change | Output changed | Latency | Candidate reason |
| --- | --- | --- | --- | --- | --- |
| `ticket-json` | json | regression (pass -> fail) | yes | 820.0 ms -> 870.0 ms | output is not valid JSON (line 1, column 1: Expecting value) |
| `intent-billing` | classification | regression (pass -> fail) | yes (`billing` -> `technical`) | 148.0 ms -> 151.0 ms | predicted 'technical', expected 'billing' |
| `sentiment-positive` | classification | fixed (fail -> pass) | yes (`neutral` -> `positive`) | 155.0 ms -> 158.0 ms | |
| `greeting-exact` | exact | unchanged pass (pass -> pass) | no | 310.0 ms -> 330.0 ms | |
...
Structured extraction example
The examples/extraction suite demonstrates invoice and address extraction with
JSON Schema and required-field checks. Its baseline and candidate outputs are
hand-written replay fixtures, so the workflow is deterministic and needs no
model or network access:
modeldiff validate examples/extraction/suite.yaml
modeldiff run examples/extraction/suite.yaml \
--config examples/extraction/configs/baseline.yaml \
--output out/extraction-baseline.json
modeldiff run examples/extraction/suite.yaml \
--config examples/extraction/configs/candidate.yaml \
--output out/extraction-candidate.json
modeldiff compare out/extraction-baseline.json out/extraction-candidate.json
The candidate intentionally removes the invoice total and changes the billing
city, producing regressions for invoice-json and billing-address. It also
adds a valid payment-method field to payment-status, which is reported as a
passing output change rather than a regression.
Writing test cases
suite: support-assistant # required; both runs must use the same suite
description: Optional free text.
cases:
- id: ticket-json # required, unique; letters, digits, . _ -
type: json # required; one of the six types
input: "Extract the ticket as JSON ..." # required; the prompt
schema: # type-specific fields
type: object
required: [customer, priority]
tags: [structured] # optional; select with `run --tag`
metadata: {owner: support} # optional; free-form, not interpreted
generation: {temperature: 0} # optional; passed to the provider (Ollama options)
performance:
max_latency_ms: 1500 # optional; warns by default, see --enforce-performance
Fields used by each type:
| Type | Fields | Options |
|---|---|---|
exact |
expected (string) |
strip (default true), ignore_case (default false), normalize_whitespace (default false: collapses runs of whitespace including newlines into a single space; reported mismatch position refers to the normalized text) |
json |
schema (JSON Schema, optional) |
extract (code_fence, optional) |
classification |
expected (label), labels (optional allowed set) |
normalize (default true: strip and case-fold) |
required_fields |
required (list of dotted paths), values (path: expected value) |
extract (code_fence, optional) |
tool_call |
expected.name, expected.arguments, expected.arguments_schema |
extract (code_fence, optional) |
custom |
check (registered name), params (any mapping) |
none |
Classification failures for outputs outside labels list the allowed labels in
suite order, using the configured normalization. Lists longer than ten labels
show the first ten followed by ....
Dotted paths address nested objects and list items: customer.name, items.0.sku.
options: {extract: code_fence} extracts content from a single Markdown code fence (e.g. ```json ... ```) before parsing. If the output does not contain exactly one fenced block, the whole output is parsed.
values and expected.arguments compare by JSON type: a boolean never equals a number, so true does not match 1, while 1 and 1.0 are equal. Nested objects and lists compare the same way.
tool_call accepts these output shapes: {"name", "arguments"}, {"function": {"name", "arguments"}} (arguments may be a JSON string), and {"tool_calls": [...]} or a list containing exactly one call.
Custom checks
# my_checks.py
from modeldiff import CheckResult, register_check
@register_check("max_words")
def max_words(case, output):
limit = case.params["limit"]
words = len(output.split())
if words > limit:
return CheckResult(False, f"{words} words, limit is {limit}")
return CheckResult(True)
- id: summary-length
type: custom
check: max_words
params: {limit: 25}
input: "Summarize in under 25 words: ..."
Load the checks with --plugin my_checks.py, or with --plugin package.module for an importable module. A check can return a bool instead of a CheckResult. If a check raises an exception, the case is recorded with status error and the run continues.
Run configurations
For a field-by-field reference to suite files, run configurations, recorded outputs, run results, and comparison JSON, see File formats.
A config describes one side of a comparison.
# Replay recorded outputs; no model needed.
id: baseline-q8
provider: replay
outputs: ../recorded/baseline.json # relative to this config file
capture_output: full # full | hash | none
A recorded-output file maps case IDs to outputs. An entry can be a plain string or a mapping with output and optional latency_ms and metadata:
{"outputs": {
"greeting-exact": "Hello! How can I help you today?",
"ticket-json": {"output": "{\"customer\": \"Priya\"}", "latency_ms": 820}
}}
Replay runs warn on stderr when a recorded-output file contains case IDs that are not in the suite. Cases that exist in the suite but are excluded by --tag do not trigger this warning. The warning does not change the exit code.
When latency_ms is present, ModelDiff uses it and marks the duration as recorded. Otherwise it measures the call and marks it measured. Reports state which source each run used.
Using a local model (optional)
ModelDiff can generate outputs with a local Ollama server. Nothing in the core workflow or test suite requires it.
ollama pull llama3.1:8b
ollama serve
# examples/ollama/llama3.1-8b.yaml
id: llama3.1-8b
provider: ollama
model: llama3.1:8b
base_url: http://localhost:11434
options: {temperature: 0, seed: 42} # Ollama generation options; case `generation` overrides
timeout_s: 120
# format: json # optional, passed to Ollama
# system: "You are ..." # optional system prompt
modeldiff run my-suite.yaml --config examples/ollama/llama3.1-8b.yaml --output out/llama.json
The adapter calls POST /api/generate with stream: false. Token counts and durations reported by Ollama are stored in each case's provider_metadata. If the server cannot be reached, the case is recorded with status error and a message saying so.
To compare two quantizations, point two configs at two model tags, run both, and compare the result files. Set a fixed seed and temperature: 0 to reduce run-to-run variation. Even so, some runtimes are not bit-for-bit deterministic, so run a config against itself to see how much noise to expect.
CLI reference
modeldiff validate SUITE [SUITE ...] [--plugin P ...]
modeldiff run SUITE --config CFG --output OUT [--tag T ...] [--capture full|hash|none]
[--plugin P ...] [--enforce-performance] [--fail-on-failure]
modeldiff compare BASELINE CANDIDATE [--format text|markdown|json|junit] [--output FILE]
[--latency-threshold PCT] [--min-latency-delta MS] [--fail-on-latency]
[--allow-suite-mismatch] [--exit-zero]
modeldiff report RESULTS [--format text|markdown|json] [--output FILE]
modeldiff schema {suite,config,result} [--output FILE]
For CI integrations, modeldiff compare --format junit writes JUnit XML.
Each comparison case becomes a <testcase>: regressions and still-failing
cases are reported as <failure>, candidate execution errors as <error>,
and cases missing from the candidate as <skipped>. Passing cases with
changed output receive a <system-out> note; they are not counted as failures.
Fixed and unchanged passing cases have no failure element. JUnit is supported
by compare, not by the single-run report command.
validate checks every supplied suite file, prints an OK: line for each valid
file, and reports each invalid file on stderr. It checks the remaining files
after an error and exits with code 2 if any file is invalid; otherwise it exits
with code 0. Any --plugin options apply to all files:
modeldiff validate suites/support.yaml suites/billing.yaml --plugin checks.py
| Exit code | Meaning |
|---|---|
| 0 | Success. For compare, no regressions. |
| 1 | compare found regressions (or flagged slowdowns with --fail-on-latency); run --fail-on-failure saw a failing or erroring case. |
| 2 | Invalid input: malformed suite, config, or result file, or bad arguments. |
Comparison reports show candidate per-case warnings, with (new) marking warnings absent from the baseline. JSON comparisons include baseline_warnings and candidate_warnings for every case. Warnings do not change regression counts or exit codes.
compare refuses to compare runs of different suites. New result files store a case_sha256 for each case, so a full run and a --tag subset can be compared: cases left out are reported as missing. Changed definitions are rejected with the affected case IDs unless you pass --allow-suite-mismatch. If either run lacks any per-case hashes, comparison keeps the older suite_sha256 check instead. python -m modeldiff works the same as modeldiff.
JSON Schemas and editor support
ModelDiff keeps the schemas used by the loader available through the CLI. Print one to stdout or save it to a file:
modeldiff schema suite > suite.schema.json
modeldiff schema config -o config.schema.json
modeldiff schema result -o result.schema.json
The suite schema can be used with the VS Code YAML extension by putting this comment at
the top of a suite file (adjust the path as needed):
# yaml-language-server: $schema=./suite.schema.json
suite: support-assistant
cases: []
Each emitted schema includes its JSON Schema draft identifier and a descriptive title.
In CI
- run: modeldiff run tests/llm/suite.yaml -c tests/llm/candidate.yaml -o out/candidate.json
- run: modeldiff compare tests/llm/baseline.json out/candidate.json --format markdown --output report.md
Commit a baseline result file and compare each candidate against it. Timing in shared CI runners is noisy, so latency is reported but does not fail the build unless you add --fail-on-latency.
Result files and privacy
run writes a modeldiff.run/v1 JSON file containing the suite name and hash, the config (with secret-looking keys such as api_key, token, password replaced by <redacted>), runtime metadata (ModelDiff and Python versions, OS name, provider details), and one record per case:
{
"case_id": "ticket-json",
"type": "json",
"status": "fail",
"timestamp": "2026-10-29T03:53:20.887+00:00",
"reason": "output is not valid JSON (line 1, column 1: Expecting value)",
"output": "```json\n{\"customer\": \"Priya\", ...}\n```",
"output_sha256": "243a96ab...",
"duration_ms": 870.0,
"duration_source": "recorded",
"memory": {"process_rss_bytes": 33091584, "process_rss_delta_bytes": 0},
"details": {},
"warnings": [],
"error": null,
"provider_metadata": {},
"tags": ["structured"]
}
Model outputs can contain personal or confidential data, especially when prompts are built from real records. Use capture_output to control what is stored:
| Mode | Stored | Change detection |
|---|---|---|
full (default) |
output text and SHA-256 | yes |
hash |
SHA-256 only | yes (whether it changed, not how) |
none |
nothing | no; reported as unknown |
Pass/fail is always computed from the full output in memory, whatever the capture mode. Note that prompts (input) live in your suite file, and failure reasons can quote fragments of output, for example a wrong label or argument value. Review result files before you commit or share them.
Measurements
| Metric | Measured | Notes |
|---|---|---|
| Latency | Wall-clock time around the provider call (time.perf_counter), or the recorded value from a replay file |
Includes HTTP overhead for Ollama. |
| Process memory | RSS of the ModelDiff process before and after each case, when psutil is installed |
Does not include the Ollama server process, the model weights, or GPU memory. |
| Token counts and server timings | Whatever Ollama reports | Stored as-is in provider_metadata, not compared. |
When psutil is not installed, result files list memory under unavailable_metrics.
Architecture
src/modeldiff/
cases.py load and validate suite files (YAML/JSON + JSON Schema)
checks.py the six check types and the custom check registry
config.py run config files, secret redaction
providers.py replay and Ollama providers
runner.py run a suite with a provider; timing and memory
results.py result records and the modeldiff.run/v1 file format
compare.py baseline vs candidate comparison
report.py text / Markdown / JSON rendering
cli.py argparse CLI
Data flows in one direction: suite + config → runner → result file → compare → report. Each stage reads and writes plain data, so you can produce result files with your own tooling and still use compare and report. See docs/architecture.md for design decisions and dependency choices.
Development
pip install -e ".[dev,memory]"
pytest
ruff check src tests examples
ruff format --check src tests examples
The test suite uses fixtures and mocks only. It does not download models or make network calls.
Limitations
- No semantic or similarity scoring. A changed output that still meets its criterion is reported for review, not judged.
- One live provider (Ollama,
/api/generate, single-turn prompts). Chat-style message lists and native tool-call responses are not sent or parsed from the provider.tool_callchecks evaluate the output text. - Cases run sequentially. There is no parallelism and no repeated sampling to estimate variance.
- Memory measurement covers the ModelDiff process only.
- Latency thresholds compare single measurements per case and are sensitive to noise.
Roadmap
These are ideas, not commitments. See the issue tracker for what is being worked on.
- Repeated runs per case, with variance reported, to separate noise from change.
- A chat/messages input format and a provider for OpenAI-compatible local servers (llama.cpp, vLLM, LM Studio).
Contributing
Contributions are welcome. Start with CONTRIBUTING.md, and look for issues labelled good first issue. Please follow the Code of Conduct. To report security issues, see SECURITY.md.
One issue at a time
To give everyone a chance, especially people making their first contribution:
- Work on one issue at a time. Claim the next one after your pull request is merged.
- Issues labelled
good first issueare for first-time contributors. After your first merged pull request, please leave them for newcomers and pickhelp wantedor other issues instead. - Pull requests for issues that weren't assigned to you, or beyond the one-at-a-time limit, may be closed so others can take the issue.
- If there's no update on an assigned issue for 7 days, a maintainer may unassign it so someone else can take it. If you need more time, just comment on the issue.
If ModelDiff is useful to you, a star on the repository helps other people find it. For project news, follow OpenWave India on LinkedIn.
Contributors
Thanks to everyone who has contributed to ModelDiff. Contributors are added here, following the All Contributors specification, once their first pull request is merged. See CHANGELOG.md for what each release includes.
bfs2021 💻 ⚠️ |
anant raj 💻 ⚠️ 📖 |
Chirag Jangir 💻 ⚠️ 📖 |
Mrunmayee Daware 💻 ⚠️ 📖 |
Faisal Orakzai 💻 ⚠️ 📖 |
swetak 📖 |
Doomguy 💻 ⚠️ 📖 |
Siddardha Darapureddy 💻 ⚠️ 📖 |
Awais Ur Rehman 💻 ⚠️ 📖 |
Muhammad Hamid 💻 ⚠️ |
License
ModelDiff is released under the MIT License. Contributions are accepted under the same license.
Metadata
Release files for openwave-modeldiff 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| openwave_modeldiff-0.2.0.tar.gz | 60.0 kB | With provenance | Details for openwave_modeldiff-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| openwave_modeldiff-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for openwave_modeldiff-0.2.0-py3-none-any.whl |
Total release size: 99.1 kB
Release files / openwave_modeldiff-0.2.0.tar.gz
| Download URL | openwave_modeldiff-0.2.0.tar.gz |
|---|---|
| Size | 60.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
edf9b0664853cbfcb94e0f5a059f99281f834f052cf590cf2f88d08bb3490e1d
|
|
BLAKE2b-256 checksum How to use checksums |
21324fe1c4a3dded4bd7afd258d6345790ad126b95f83a07dd7d72c0aeabd0c6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 2, 2026.
Transparency logRelease files / openwave_modeldiff-0.2.0-py3-none-any.whl
| Download URL | openwave_modeldiff-0.2.0-py3-none-any.whl |
|---|---|
| Size | 39.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
dbda9b88ea94bf86850ade2a1fe5f82f65e7542cf9a9730635124fd8b9305dd0
|
|
BLAKE2b-256 checksum How to use checksums |
a90cc1d23677573020ce181505d6aaceb3a56f6ad7e0464d2c149248196d3e98
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 2, 2026.
Transparency logSecurity for openwave-modeldiff 0.2.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for openwave_modeldiff-0.2.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Nov 2, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.