A capability lockfile for local models. Catch silent regressions when you swap a model, quant, or runtime.
Project description
probelock
A capability lockfile for local models. It records what a model does on a set of tool-calling and output checks, and fails CI when a model/quant/runtime swap lowers a score.
llama-3.1-8b @ Q8_0 (ollama) → llama-3.1-8b @ Q4_K_M (ollama)
Capability Baseline Candidate Δ Status
arg_validity 1.00 0.67 -0.33 REGRESSION
arity_robustness 1.00 0.67 -0.33 REGRESSION
format_adherence 1.00 1.00 +0.00 ok
needle_in_tools 1.00 0.33 -0.67 REGRESSION
no_hallucinated_tool 1.00 0.67 -0.33 REGRESSION
required_args 1.00 1.00 +0.00 ok
structured_output 1.00 0.33 -0.67 REGRESSION
tool_discrimination 1.00 0.33 -0.67 REGRESSION
tool_permission 1.00 0.67 -0.33 REGRESSION
tool_restraint 1.00 0.67 -0.33 REGRESSION
tool_selection 1.00 0.67 -0.33 REGRESSION
FAIL — capabilities regressed or removed: arg_validity, arity_robustness,
needle_in_tools, no_hallucinated_tool, structured_output, tool_discrimination,
tool_permission, tool_restraint, tool_selection
Here the Q4 quant scores 0.33–0.67 on several capabilities where Q8 scored 1.00.
probelock gate exits non-zero when a capability drops past the threshold.
How it differs from promptfoo
promptfoo is a test framework you author. probelock is a lockfile you commit.
- Probes are derived from your tool schemas. Point it at the OpenAI-style tool definitions your agent already ships, and it generates a fixed, reproducible battery of capability checks. You write no test cases.
- No LLM judge. Every probe is scored by code: JSON-schema validation, exact match, or a tool-name check. Run it twice on the same model and the numbers match. (promptfoo relies on assertions you write and often on model-graded evals, which vary across runs.)
- It compares a model against its own baseline, across a model/quant/runtime swap, rather than producing an absolute leaderboard. You only ever compare like with like, on your box, with your tools, so the "benchmarks are gameable/hardware-dependent" objection doesn't apply.
Install & run (only needs uv)
Run it without installing, or install it into the current environment:
uvx probelock --help # run the latest release
pip install probelock # or install it
To run an unreleased revision straight from git:
uvx --from git+https://github.com/kelkalot/probelock probelock --help
The examples below use uv run from a checkout of this repo. No model is required
for the demo — a deterministic SimulatedClient stands in for two quant levels of
the same model:
# from the probelock/ project dir
uv run probelock derive --tools examples/agent_tools.json # see the probe battery
uv run probelock probe --tools examples/agent_tools.json --simulate fixtures/profile_q8.json -o q8.lock
uv run probelock probe --tools examples/agent_tools.json --simulate fixtures/profile_q4.json -o q4.lock
uv run probelock diff q8.lock q4.lock
uv run probelock gate --baseline q8.lock --candidate q4.lock # exits non-zero
Against a local model, swap --simulate for an OpenAI-compatible endpoint:
uv run probelock probe --tools examples/agent_tools.json \
--endpoint http://localhost:11434/v1 --model llama3.1:8b-instruct-q4_K_M \
--quant Q4_K_M --runtime ollama --timeout 120 -o q4.lock
A probe the model rejects (e.g. "model does not support tools") or that times out scores 0 for that capability and the run continues, so a model that can't tool-call still produces a lockfile. An unreachable server, a 404 (wrong model or URL), or a run where every probe fails aborts the run, so a misconfiguration never becomes a poisoned all-zeros baseline.
Providers & frameworks
probelock speaks one protocol — OpenAI /v1/chat/completions with OpenAI-style
tools — so anything that exposes it works with --endpoint. For providers that
don't (Anthropic, Gemini, …), route through a unified SDK with --via. Every path
is deterministic; none of them put an LLM in the loop.
| You have… | Use |
|---|---|
| Ollama, vLLM, llama.cpp server, LM Studio, HF TGI, OpenAI, OpenRouter, Together… | --endpoint <url>/v1 --model <name> (vLLM needs --enable-auto-tool-choice; llama.cpp needs --jinja) |
| Anthropic / Gemini / Mistral / Bedrock / … (any-llm) | --via anyllm --model anthropic/claude-3-5-sonnet |
| Any of 100+ providers (litellm SDK) | --via litellm --model anthropic/claude-3-5-sonnet |
| A running LiteLLM proxy | --endpoint http://litellm:4000/v1 --model <name> (no extra) |
In-process HF transformers / MLX (no server) |
not yet — add a small Client adapter |
pip install 'probelock[anyllm]' # or 'probelock[litellm]'
probelock probe --tools tools.json --via anyllm --model mistral/mistral-large-latest \
--samples 5 --temperature 0.7 -o candidate.lock
--via clients reuse the same caching, sampling, and error semantics as
--endpoint; they're thin adapters over each SDK's OpenAI-shaped response. Add a
new backend by implementing the tiny Client protocol — that's the only seam.
Recorded demo (Ollama)
demo/ has runs against a local Ollama server: a committed qwen3.5:9b
baseline vs a gemma3:1b candidate (which does not support tool-calling). See
demo/DEMO.md for the transcript, or replay it:
asciinema play demo/probelock-demo.cast # or: bash demo/demo.sh
The tool-calling capabilities drop 1.00 → 0.00 and the gate exits non-zero.
tool_restraint, tool_permission, and no_hallucinated_tool stay 1.00 (a
model that can't call tools can't misuse one), and gemma3:1b scores 1.00 on
format_adherence vs 0.50 for qwen3.5:9b. The diff is per-capability.
Also committed: qwen3.5:9b vs lfm2.5-thinking:1.2b:
uv run probelock diff demo/qwen3.5-9b.lock demo/lfm2.5-thinking.lock
The 1.2B model matches qwen3.5:9b on tool selection, discrimination,
needle_in_tools, arg_validity, required_args, and the three safety probes;
structured_output and arity_robustness drop 1.00 → 0.33.
The capabilities (all scored deterministically)
| Capability | What it checks | Scorer |
|---|---|---|
tool_selection |
Calls the right tool for the task | tool-name match |
tool_discrimination |
Calls the right tool and no other (picks precisely) | tool-name set |
needle_in_tools |
Finds the right tool when many (15+) are offered | tool-name match |
arg_validity |
Emitted args validate against the tool's JSON schema | jsonschema |
required_args |
All required args present and non-empty | key presence |
arity_robustness |
Fills every parameter (required + optional) when asked | all-present |
structured_output |
Emits schema-valid JSON on demand (no tools, no fences) | parse + jsonschema |
tool_restraint |
Does not call a tool for a task that needs none (over-trigger) | no tool call |
tool_permission |
Does not call a tool it was explicitly forbidden to use | forbidden tool absent |
no_hallucinated_tool |
Does not fabricate a call to a tool that wasn't offered | called ⊆ offered |
format_adherence |
Follows an exact output constraint | exact match |
Three are negative probes (a higher score means the bad behavior didn't
happen): tool_restraint (over-triggering), tool_permission (calling a forbidden
tool), and no_hallucinated_tool (fabricating a tool). All probes are derived from
the tool schemas, not hand-authored.
Architecture
tool schemas ──▶ derive probes ──▶ Client ──▶ ResponseMessage ──▶ deterministic scorer ──▶ Lockfile
(your agent) (zero authoring) (model) (the only model (no LLM judge) (commit it)
-touching part)
Lockfile + Lockfile ──▶ diff / gate
The only nondeterministic part is the Client; everything else is pure, so the
same inputs produce the same lockfile and the same diff. At temperature 0 the
client caches identical requests, so the probes that share one request (the tool
checks for a given tool) hit the network once. The SimulatedClient crafts correct or
incorrect responses that the real scorers grade, so the scoring path runs even
with no model present.
Sampling & noisy gates
With one sample per probe, a capability backed by 3 tools quantizes to
{0, 0.33, 0.67, 1.0} — a single flip moves it 0.33, far past the default 0.05
gate. So:
probe --samples N [--temperature T]runs each probe N times and records the pass-rate (raise the temperature for sampling variance).gate --confidence 0.95only fails on a drop that is statistically significant for the recorded trial count (a one-sided two-proportion test). Sub-significant drops are shown asnoisy ↓and do not fail the gate; raise--samplesto confirm or clear them.
A total collapse (e.g. 1.00 → 0.00) is significant even at low N; a single-flip
1.00 → 0.67 over 3 trials is noisy until you raise --samples.
In CI
probelock init scaffolds a probelock.tools.json and a
.github/workflows/probelock.yml to start from. Commit a baseline lockfile, then
gate each candidate:
- run: uvx probelock probe
--tools tools.json --endpoint $LLM_URL --model $MODEL --samples 5 --temperature 0.7 -o candidate.lock
- run: uvx probelock gate
--baseline probelock.lock --candidate candidate.lock --max-drop 0.05 --confidence 0.95
A composite GitHub Action (action.yml) wraps those two steps. To
show the result on a pull request, render the diff as Markdown (or --format html
for a self-contained page):
probelock diff probelock.lock candidate.lock --format markdown >> "$GITHUB_STEP_SUMMARY"
Roadmap
- A
--json-modestructured_outputprobe (response_format) beside the strict prompt path. - Trend view: compare a capability across more than two lockfiles (a quant ladder Q8→Q5→Q4→Q3).
- In-process backends (HF
transformers/ MLX) via a smallClientadapter, no server required.
License
Apache-2.0 — see LICENSE.
Acknowledgements
Built with Claude Code.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file probelock-0.1.0.tar.gz.
File metadata
- Download URL: probelock-0.1.0.tar.gz
- Upload date:
- Size: 49.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fcacdacd3e5ad3babcd1cbc6ee9e44e4d5e7c386db579e0cceb35a5695ec2187
|
|
| MD5 |
ac4537a9aaefc57ffa2fbab4c21c45c7
|
|
| BLAKE2b-256 |
5d7f7ecf391846dd574ae212abf29c76805a8e20799bb5d2cc5c14d81cf2b506
|
Provenance
The following attestation bundles were made for probelock-0.1.0.tar.gz:
Publisher:
release.yml on kelkalot/probelock
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
probelock-0.1.0.tar.gz -
Subject digest:
fcacdacd3e5ad3babcd1cbc6ee9e44e4d5e7c386db579e0cceb35a5695ec2187 - Sigstore transparency entry: 2021338931
- Sigstore integration time:
-
Permalink:
kelkalot/probelock@543cad1f6ccef1b50ecbba6d1ae1c7eda06f2d5c -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/kelkalot
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@543cad1f6ccef1b50ecbba6d1ae1c7eda06f2d5c -
Trigger Event:
push
-
Statement type:
File details
Details for the file probelock-0.1.0-py3-none-any.whl.
File metadata
- Download URL: probelock-0.1.0-py3-none-any.whl
- Upload date:
- Size: 36.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
27cc4600236c6015832151dd797b82a57b406b3124372303c3cb381e7745b0a8
|
|
| MD5 |
adf48f99d3d9082b347380721d06b61a
|
|
| BLAKE2b-256 |
33e915d08936a420ffabdc1f536886818b8a96fd747c6c03a2dc45a212363d8c
|
Provenance
The following attestation bundles were made for probelock-0.1.0-py3-none-any.whl:
Publisher:
release.yml on kelkalot/probelock
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
probelock-0.1.0-py3-none-any.whl -
Subject digest:
27cc4600236c6015832151dd797b82a57b406b3124372303c3cb381e7745b0a8 - Sigstore transparency entry: 2021338980
- Sigstore integration time:
-
Permalink:
kelkalot/probelock@543cad1f6ccef1b50ecbba6d1ae1c7eda06f2d5c -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/kelkalot
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@543cad1f6ccef1b50ecbba6d1ae1c7eda06f2d5c -
Trigger Event:
push
-
Statement type: