Keel

Grounding report.

Every verification edge in mcp-python-sdk, classified by who produces the signal and whether the actor being verified can write to that producer.

targetmcp-python-sdk
revision00a70148bcd39b40ee0b0745e0b468eac0a44efb
generated2026-07-25T02:55:11.583Z
1.00

anchored / (anchored + self_referential + unknown) = 15 / 15

anchored 15 self_referential 0 unknown 0 not_a_check 10 excluded from the denominator

Coverage (judged)

node kindjudged
ci_step16
test_target8
review_gate1

Nodes by kind, over the 25 node(s) carried in this report. Surfaces the gatherer cannot read are absent here rather than counted as unknown, which makes non-coverage Keel's own shoppable class — so it is stated, not implied.

ε-audit — not run

No verdict in this report carries a readable audit block, so nothing was re-decided and there is no agreement rate to report. This is not agreement, and it is not full agreement either: a rate over zero comparisons would state a measurement that was never taken. (“Readable” is load-bearing: a block can be present and state no comparison, and this state covers that too — see below if so.)

This run carries 0 probe-decided verdict(s), none of them contributing a readable comparison. (“Readable” rather than “audited”, because a block can arrive and state nothing — if any did, they are counted below.) Audit coverage is itself shoppable — audit none of the library and no disagreement can ever be found — so the absence is printed here rather than left to be inferred from a missing section.

This run judged 25 of 121 gathered edges. Every number on this page describes that sample, not the whole surface — 96 gathered edges are absent from all of it. A cap is disclosed rather than smoothed: an undisclosed sample is how a ratio gets shopped.

Scope. Keel measures the shape of verification, not its quality. A repo can be 100% anchored with terrible tests. Anchoring says the signal comes from outside; it does not say the signal is sufficient.

Node graph — 25 node(s), in gather order

anchored 15 not_a_check 10

5 marks are ringed in the unknown hue: an anchored verdict asserted below confidence 0.6. The ring says the claim is closer to unknown than its colour suggests. Hover a mark for its node.

anchored — 15

The producer sits outside the write boundary of the actor being verified.

pre-commit hooks (.pre-commit-config.yaml)
anchored
.pre-commit-config.yaml · review_gate

This manifest is not merely a client-side git hook here: shared.yml's `pre-commit` job runs `pre-commit/action` with `--all-files`, and that job feeds `checks` -> `all-green` (main.yml:16-23), so its exit code is on the merge path and cannot be skipped with `git commit --no-verify`. The substantive local hooks execute pinned tools over the committed tree — `uv run --frozen pyright` returns non-zero when its own analysis of the source finds a type error, and `uv run --frozen ruff check --fix --exit-non-zero-on-fix` returns non-zero when the linter both rewrites and reports; `uv lock --check` recomputes the resolution from pyproject.toml and fails when uv.lock does not match it. None of those verdicts can be flipped by an author writing a status anywhere; only by changing the source or the pinned config. Two documented limits: (a) the `readme-snippets` hook runs `scripts/update_readme_snippets.py --check`, which only diffs README code blocks against `docs_src/*.py` — two artefacts the same authors write, so that sub-edge is self-referential paperwork-against-paperwork and would stay green if both drifted together; (b) the remote hooks (`pre-commit-hooks`, `mirrors-prettier`, `markdownlint-cli`) are pinned by mutable tag `rev:`, not by digest, so their implementations live outside this repo.

producer the exit codes of the hook binaries pre-commit shells out to (ruff, pyright, uv), plus pre-commit's own detection that a hook modified a tracked file — actor can write to it: no

  • .pre-commit-config.yaml:31-64
  • .github/workflows/shared.yml:40-42
  • .github/workflows/main.yml:13-23
  • scripts/update_readme_snippets.py:1-10

decided by agent · confidence 0.75

linter/type-checker config ([tool.pyright])
anchored
pyproject.toml:150 · ci_step

This is the root-project pyright config, and pyright is actually executed on the merge path: the `pyright` local hook in .pre-commit-config.yaml runs `uv run --frozen pyright` with `pass_filenames: false`, and pre-commit runs `--all-files` inside the CI `pre-commit` job. `typeCheckingMode = "strict"` plus `include = [src, tests, docs_src, examples/...]` is what turns an inference failure in those trees into a non-zero exit, and that verdict is computed by the pinned pyright binary from the source text — an author cannot make an ill-typed file report clean by asserting anything. Documented limit: this table emits no signal itself, and the scope it declares is author-controlled (the `executionEnvironments` entries switch off reportUnusedFunction and reportPrivateUsage for tests/examples), so the execution axis is anchored while what gets enforced sits inside the write boundary.

producer the pyright process exit code — a type checker parsing the checked-out source under this table's settings — actor can write to it: no

  • pyproject.toml:150-189
  • .pre-commit-config.yaml:47-52
  • .github/workflows/shared.yml:40-42

decided by agent · confidence 0.8

linter/type-checker config ([tool.ruff])
anchored
pyproject.toml:191 · ci_step

`line-length = 120` is the threshold ruff's pycodestyle E501 rule compares against, and `E` is in the selected rule set (pyproject.toml:195-207); `target-version = "py310"` is what the selected `UP` (pyupgrade) rules rewrite/report against. Ruff runs on the merge path via the `ruff` pre-commit hook (`ruff check --fix --exit-non-zero-on-fix`) inside the CI pre-commit job, so a 121-character line makes that job exit non-zero. The producer is the linter binary reading the committed file, not a claim the repo makes about itself. Documented limit: the number itself is author-editable, and the table on its own emits nothing — it parameterizes a check that runs elsewhere.

producer the ruff process exit code, computed from the source text under this table's line-length and target-version settings — actor can write to it: no

  • pyproject.toml:191-193
  • pyproject.toml:195-207
  • .pre-commit-config.yaml:40-46

decided by agent · confidence 0.6

linter/type-checker config ([tool.ruff.lint])
anchored
pyproject.toml:195 · ci_step

`select = [C4, C90, D212, E, F, I, PERF, UP, TID251]` is precisely the list of analyses that can fail the CI `pre-commit` job, which runs `uv run --frozen ruff check --fix --exit-non-zero-on-fix` over all files. An unused import (F401) or an unsorted import block (I001) is detected by ruff parsing the committed source, so an author cannot make a violating file report clean without editing the file or editing this list. Documented limit: editing this list is exactly the escape hatch — the rule set is inside the write boundary even though each verdict is produced outside it; and `ignore = ["PERF203"]` is a suppression this table also carries.

producer the ruff process exit code — this table is the enabled rule set whose violations produce that non-zero exit — actor can write to it: no

  • pyproject.toml:195-207
  • .pre-commit-config.yaml:40-46
  • .github/workflows/shared.yml:40-42

decided by agent · confidence 0.75

linter/type-checker config ([tool.ruff.lint.flake8-tidy-imports.banned-api])
anchored
pyproject.toml:209 · ci_step

`TID251` appears in `[tool.ruff.lint].select`, so this banned-api entry is live: ruff parses the import graph of the committed source and exits non-zero on any `pydantic.RootModel` reference outside the per-file exemption for generated validators. The producer is the linter executing over the file, and the check runs on the merge path through the pre-commit CI job. Documented limit: the ban list is author-editable and one directory is already exempted via per-file-ignores, so the scope of enforcement lives inside the boundary while each individual verdict does not.

producer the ruff process exit code via rule TID251, which is in the selected rule set — actor can write to it: no

  • pyproject.toml:209-211
  • pyproject.toml:195-207
  • pyproject.toml:216-221

decided by agent · confidence 0.7

linter/type-checker config ([tool.ruff.lint.mccabe])
anchored
pyproject.toml:213 · ci_step

`C90` is in `select`, so this table's `max-complexity = 24` is the threshold ruff compares a computed cyclomatic-complexity number against; a function above it fails the CI pre-commit job. The complexity number is derived by the linter from the code's branch structure, not declared by the author. Documented limit, and it is a large one: the threshold was raised from the default 10 to 24, so the enforced bar is set inside the write boundary even though the measurement is not.

producer the ruff process exit code via rule C901 (mccabe), computed by walking the AST of the committed source — actor can write to it: no

  • pyproject.toml:213-214
  • pyproject.toml:195-207

decided by agent · confidence 0.65

pytest config ([tool.pytest.ini_options])
anchored
pyproject.toml:239 · test_target

This table is what makes the CI test run strict rather than advisory: `filterwarnings = ["error"]` promotes any warning emitted at runtime into a raised exception that fails the test, and `xfail_strict = true` fails the run when a test marked xfail unexpectedly passes. Both conditions are decided by the interpreter while the code actually executes in `coverage run -m pytest -n auto` (shared.yml:104), across the 3.10-3.14 x {ubuntu, windows} x {locked, lowest-direct} matrix, and that job gates the merge through `all-green`. An author cannot suppress a real DeprecationWarning by declaring anything; only by fixing the code or adding an explicit ignore entry here. Two documented limits: the four `ignore:` entries are exactly such author-authored suppressions, and the deeper oracle limit applies to the suite this configures — execution is anchored, but the assertions were written by the same people as the implementation.

producer the pytest process exit code, produced by the CPython runtime executing the suite under these settings — actor can write to it: no

  • pyproject.toml:239-277
  • .github/workflows/shared.yml:96-106
  • .github/workflows/main.yml:16-23

decided by agent · confidence 0.7

coverage config ([tool.coverage.run])
anchored
pyproject.toml:279 · ci_step

This table decides what the enforced 100% gate is measured over: `branch = true` adds branch arcs to the denominator, and `source = [src, src/mcp-types/mcp_types, tests]` puts the whole library and the test tree in scope, so an unexecuted line or an untaken branch arc anywhere in those trees drops the number below `fail_under = 100` and fails `coverage report` in shared.yml. The coverage figure is produced by the interpreter's trace callback while the code runs — an author cannot mark a line covered without executing it. Documented limits: the four-entry `omit` list is a suppression that shrinks the measured surface, and coverage measures REACH, not correctness; a fully-covered line can still be wrong.

producer coverage.py's tracer, recording which lines and branch arcs the CPython interpreter actually executed during the CI pytest run — actor can write to it: no

  • pyproject.toml:279-290
  • pyproject.toml:292-303
  • .github/workflows/shared.yml:102-106

decided by agent · confidence 0.6

coverage config ([tool.coverage.report])
anchored
pyproject.toml:292 · ci_step

`fail_under = 100` is the gate condition itself: `uv run coverage report` in the CI test job exits non-zero whenever the combined trace shows any measured line or branch arc that the interpreter did not execute, and that job reaches the merge through `checks` -> `all-green`. The number is not a field anyone writes — it is derived by coverage.py from what actually ran, so the only way to raise it is to execute the code. Documented limits: (a) coverage measures REACH, not correctness — 100% says every line ran, never that any assertion about it was right; (b) `exclude_also` (and the `omit` list in [tool.coverage.run]) are author-controlled exclusions that shrink the denominator, which is why the repo pairs this with a `strict-no-cover` step that fails when a `# pragma: no cover` line IS executed.

producer coverage.py's process exit code, computed from the line/branch execution trace collected by the interpreter during the test run — actor can write to it: no

  • pyproject.toml:292-303
  • .github/workflows/shared.yml:102-110
  • .github/workflows/main.yml:16-23
  • AGENTS.md: "CI requires 100% (fail_under = 100, branch = true)"

decided by agent · confidence 0.85

pytest config (conftest.py)
anchored
tests/examples/conftest.py · test_target

Unlike the other conftests in this sample, this one authors a failing condition of its own: `pytest.mark.xfail(strict=True, ...)` at line 102 makes an unexpectedly-PASSING leg fail the run, so a story listed as broken in `manifest.toml` that starts working red-lines CI until the manifest is updated. It also imports each story's server/client module for real (`importlib.import_module`) across the (variant x transport x era) expansion, so a signature break surfaces as a collection error decided by the interpreter, not by any claim in the repo. The whole expansion executes inside `coverage run -m pytest -n auto` in shared.yml, which gates the merge. Documented limits: the matrix itself (`manifest.toml` defaults, the `xfail` list) is authored inside the write boundary, and the oracle limit applies — execution is anchored, the assertions in the story bodies were written by the same authors as the SDK.

producer the pytest runtime executing the expanded story matrix — including the strict-xfail arcs this file attaches — actor can write to it: no

  • tests/examples/conftest.py:95-105
  • tests/examples/conftest.py:60-93
  • .github/workflows/shared.yml:96-106

decided by agent · confidence 0.5 · confidence below 0.6 on an anchored verdict

pytest config (conftest.py)
anchored
tests/interaction/conftest.py · test_target

This conftest does verify: `_assert_never_stamped` (line 88) asserts that a handshake-era result carries no `SERVER_INFO_META_KEY`, and the modern branch routes through `tests._stamp.unstamped`, whose contract is that the stamp MUST be present. Every interaction test that compares a full result runs one of those two assertions, and the failure is raised by the interpreter while the real client/server pair is exercised over the parametrized (transport, spec_version) cells that `pytest_generate_tests` computes from `REQUIREMENTS`. The run happens in `coverage run -m pytest` in shared.yml and gates the merge. Documented limits: the requirement manifest that drives the cells — including its declared xfails — is authored in-repo, and the oracle limit stands (execution decides, but the SDK authors wrote the expectation).

producer the pytest runtime raising AssertionError from the era-aware stamp assertions this module installs into every interaction test — actor can write to it: no

  • tests/interaction/conftest.py:80-91
  • tests/interaction/conftest.py:63-70
  • .github/workflows/shared.yml:96-106

decided by agent · confidence 0.6

linter/type-checker config ([tool.ruff.lint])
anchored
examples/clients/sse-polling-client/pyproject.toml:27 · ci_step

The CI pre-commit job runs `ruff check --fix --exit-non-zero-on-fix` over the whole tree with `pass_filenames: false`, and ruff resolves settings hierarchically — for files under this directory the nearest pyproject.toml with a `[tool.ruff]` section wins. `select = ["E", "F", "I"]` therefore means an undefined name (F821) or an unsorted import block in this example fails the merge-gating job, with the verdict computed by the linter from the source text. Documented limits: the enforced set here is NARROWER than the root's (no C90/UP/TID251/PERF), so this table's net effect on this subtree is relaxation as much as enforcement; and confidence is moderate because it depends on ruff's hierarchical resolution rather than on an explicit per-example CI step.

producer the ruff process exit code — ruff resolves the closest config to each file, so this table is the live rule set for this example's sources during the repo-wide run — actor can write to it: no

  • examples/clients/sse-polling-client/pyproject.toml:27-29
  • .pre-commit-config.yaml:40-46
  • pyproject.toml:195-207 (root select, superset)

decided by agent · confidence 0.55 · confidence below 0.6 on an anchored verdict

linter/type-checker config ([tool.ruff])
anchored
examples/clients/sse-polling-client/pyproject.toml:31 · ci_step

`E` is selected in the sibling `[tool.ruff.lint]` table, so `line-length = 120` is the number ruff compares each line of this example against during the repo-wide `ruff check` in the CI pre-commit job; `target-version = "py310"` sets the syntax level the parser accepts. The failing condition is produced by the linter reading the file. Documented limits: the values duplicate the root config, so the marginal enforcement this table adds is nil; and it emits no signal itself — it parameterizes a run that happens elsewhere.

producer the ruff process exit code via rule E501, whose threshold is this table's line-length, applied to this subtree by ruff's nearest-config resolution — actor can write to it: no

  • examples/clients/sse-polling-client/pyproject.toml:31-33
  • examples/clients/sse-polling-client/pyproject.toml:27-29
  • pyproject.toml:191-193

decided by agent · confidence 0.5 · confidence below 0.6 on an anchored verdict

linter/type-checker config ([tool.ruff.lint])
anchored
examples/clients/simple-chatbot/pyproject.toml:38 · ci_step

Ruff picks the closest `[tool.ruff]`-bearing config for each file, so `select = ["E", "F", "I"]` governs this example's sources when the CI pre-commit job runs `ruff check --fix --exit-non-zero-on-fix` across the tree. A pyflakes error or an unsorted import in this package fails that job, and the verdict is the linter's reading of the source, not a claim the repo makes. Documented limits: this set is narrower than the root's, so the table also relaxes what would otherwise apply; and confidence is moderate because it depends on ruff's hierarchical config resolution rather than on a dedicated CI step for this package.

producer the ruff process exit code — this is the rule set ruff resolves for this example's files during the repo-wide check — actor can write to it: no

  • examples/clients/simple-chatbot/pyproject.toml:38-40
  • .pre-commit-config.yaml:40-46
  • pyproject.toml:195-207

decided by agent · confidence 0.55 · confidence below 0.6 on an anchored verdict

linter/type-checker config ([tool.ruff])
anchored
examples/clients/simple-chatbot/pyproject.toml:42 · ci_step

With `E` selected in the sibling lint table, `line-length = 120` is the threshold ruff enforces on this example's files during the merge-gating `ruff check` run, and `target-version = "py310"` fixes the syntax level. The failing condition is computed by the linter from the file's bytes. Documented limits: the values are identical to the root config so the table adds no enforcement the root would not already provide, and the table itself produces no signal — it configures a tool that runs elsewhere.

producer the ruff process exit code via rule E501 under this table's line-length, applied to this subtree by nearest-config resolution — actor can write to it: no

  • examples/clients/simple-chatbot/pyproject.toml:42-44
  • examples/clients/simple-chatbot/pyproject.toml:38-40
  • pyproject.toml:191-193

decided by agent · confidence 0.5 · confidence below 0.6 on an anchored verdict

not_a_check — 10

Asserts nothing about correctness, so it is excluded from the denominator. This is the one shoppable class: mis-filing a real check here shrinks the denominator and inflates the score, so each row carries the same burden of argument as any other verdict.

linter/type-checker config ([tool.ruff.lint.per-file-ignores])
not_a_check
pyproject.toml:216 · ci_step

Every entry here is a suppression: F401 for `__init__.py`, six codes for the generated mcp-types validators, E501 and PLW0603 for two test modules. It can only prevent ruff from reporting a violation; there is no configuration of this table that causes a run to fail that would otherwise pass, so it emits no pass/fail signal of any kind. It is filed here rather than as a check because it asserts nothing about correctness — not because it was hard to trace; the enforced side of ruff is captured by the `select` table, which is judged separately, and mis-filing this as a check would put a pure escape hatch in the numerator.

producer nothing — the table only removes rule codes from consideration for four path globs — actor can write to it: not established

  • pyproject.toml:216-221
  • pyproject.toml:195-207

decided by agent · confidence 0.7

linter/type-checker config ([tool.ruff.lint.pylint])
not_a_check
pyproject.toml:223 · ci_step

`select` at pyproject.toml:195-207 enables C4, C90, D212, E, F, I, PERF, UP and TID251. No `PL*` code is selected, so ruff never runs the pylint-family rules these values configure (`max-args`, `max-branches`, `max-returns`, `max-statements`, `allow-magic-value-types`). The table is inert on the CI path: no value in it can make `ruff check` exit non-zero, and raising or lowering any of these numbers changes no outcome. Recorded as not_a_check on that specific, checkable ground rather than as `unknown` — the fork point is fully traceable, and the honest statement is that there is no signal here at all.

producer nothing — these settings parameterize ruff's `PL` rules, and `PL` is not in the selected rule set — actor can write to it: not established

  • pyproject.toml:223-228
  • pyproject.toml:195-207

decided by agent · confidence 0.65

coverage config ([tool.coverage.paths])
not_a_check
pyproject.toml:305 · ci_step

The three entries are the same tree seen from three machines (local `src/`, the Linux runner's `/home/runner/work/...`, the Windows runner's `D:\a\...`). It declares no threshold and enables no analysis; its only effect is that data files from different runners resolve to one set of files during `coverage combine`. There is no content for this table that fails a run on its own — the failing condition lives in `[tool.coverage.report].fail_under`, judged separately — so it asserts nothing about correctness.

producer nothing — the table maps runner-specific absolute paths onto the repo-relative `src/` prefix so `coverage combine` can merge data files — actor can write to it: not established

  • pyproject.toml:305-310
  • .github/workflows/shared.yml:105

decided by agent · confidence 0.6

pytest config (conftest.py)
not_a_check
tests/conftest.py · test_target

Read in full (68 lines, no `assert`, no `pytest.fail`, no `raise`), this file only supplies the suite's execution context: it pins the anyio backend to asyncio, holds a module-scoped runner lease to cap event-loop churn on Windows, and scopes the OTel tracer so span capture does not leak between tests. It emits no pass/fail verdict — the gating signal for this suite is the `coverage run -m pytest` step in shared.yml, which the gatherer surfaced as its own node (in the 121 gathered, outside this 25-node sample). Filing the fixture module as a check would double-count that same pytest exit code under a second node.

producer nothing — the module sets one environment variable and defines three pytest fixtures (`anyio_backend`, `_module_runner_lease`, a `capfire` override) — actor can write to it: not established

  • tests/conftest.py:1-68
  • grep 'assert|raise|pytest.fail' tests/conftest.py -> only prose in docstrings
  • .github/workflows/shared.yml:96-106

decided by agent · confidence 0.6

pytest config (conftest.py)
not_a_check
tests/transports/stdio/conftest.py · test_target

Both fixtures monkeypatch a seam and then call straight through to the real `_create_platform_compatible_process` / `_terminate_process_tree`, recording the calls into a list for tests to inspect; the file contains no assertion of its own (77 lines, zero `assert`). The verdicts about stdio lifecycle are produced by the test bodies that read those lists, running under the CI pytest step. `_kill_spawn_groups` is a reaper, not an oracle: it prevents orphaned subprocesses from a crashed test and cannot itself fail a run.

producer nothing — the module defines two recording fixtures (`spawned_processes`, `terminate_calls`) and a teardown that SIGKILLs leaked process groups — actor can write to it: not established

  • tests/transports/stdio/conftest.py:1-77
  • tests/transports/stdio/conftest.py:61-77

decided by agent · confidence 0.65

pytest config (conftest.py)
not_a_check
tests/server/conftest.py · test_target

`finished()` filters exported spans down to the `mcp-python-sdk` instrumentation scope and hands the list to the test; the fixture clears the exporter before and after each test. 46 lines, no assertion. It makes span assertions POSSIBLE but performs none, so no configuration of this file can turn a passing run red on its own — the pass/fail comes from the server tests that call `spans.finished()` inside the CI pytest step.

producer nothing — the module defines a `SpanCapture` adapter over logfire's in-memory `TestExporter` and one `spans` fixture — actor can write to it: not established

  • tests/server/conftest.py:1-46

decided by agent · confidence 0.65

pytest config (conftest.py)
not_a_check
tests/shared/conftest.py · test_target

It determines WHAT runs — every contract test using `pair_factory` executes twice, once per implementation — but it verifies nothing itself: 61 lines, no assertion, no strict xfail. Widening a matrix is not a verdict; the verdicts are the assertions in the dispatcher contract tests, produced by the interpreter during the CI pytest run. Filing it as a check would credit the numerator for test scaffolding.

producer nothing — the module builds two `Dispatcher` pairs (direct and JSONRPC-over-memory-streams) and exposes them as a parametrized `pair_factory` fixture — actor can write to it: not established

  • tests/shared/conftest.py:1-61

decided by agent · confidence 0.65

pytest config (conftest.py)
not_a_check
tests/client/conftest.py · test_target

The spy forwards every message to the original stream and records it so tests can inspect what was sent; the two `assert` statements at lines 131-132 are internal plumbing guards (`client_spy was not initialized`) that fire only when the fixture is used out of order, and the `assert len(requests) == 1` at line 92 is inside a docstring example, not executed. Nothing here asserts anything about the SDK's behaviour — the client tests that read `spies.get_client_requests(...)` do, under the CI pytest step.

producer nothing — the module defines a `SpyMemoryObjectSendStream` wrapper and a `stream_spy` fixture that patches `create_client_server_memory_streams` — actor can write to it: not established

  • tests/client/conftest.py:1-135
  • tests/client/conftest.py:92
  • tests/client/conftest.py:131-132

decided by agent · confidence 0.6

linter/type-checker config ([tool.pyright])
not_a_check
examples/clients/sse-polling-client/pyproject.toml:22 · ci_step

Pyright reads its configuration from the project root it is executed in; it does not do per-directory config discovery the way ruff does. The only pyright invocation on the merge path is the pre-commit local hook `uv run --frozen pyright` with `pass_filenames: false`, which runs at the repo root and therefore uses the ROOT `[tool.pyright]` table (whose `include` already covers `examples/clients`). No workflow step changes directory into this example, and no per-example CI job exists (grep of .github/workflows for `examples` returns only a comment about a smoke-test env var). So `venvPath = "."` / `venv = ".venv"` here describe a local editor/dev environment; no value in this table can make any CI job exit non-zero. Confidence is held down because this rests on pyright's config-resolution semantics rather than on a line I can point at in a workflow.

producer nothing on the CI path — no pipeline step invokes pyright with this directory as its project root — actor can write to it: not established

  • examples/clients/sse-polling-client/pyproject.toml:22-26
  • .pre-commit-config.yaml:47-52
  • pyproject.toml:150-160
  • grep -rn 'examples' .github/workflows/ -> shared.yml:99 (comment only)

decided by agent · confidence 0.55

linter/type-checker config ([tool.pyright])
not_a_check
examples/clients/simple-chatbot/pyproject.toml:33 · ci_step

Same fork point as the sibling example package: the only merge-gating pyright run is the repo-root pre-commit hook `uv run --frozen pyright` (`pass_filenames: false`), which reads the ROOT `[tool.pyright]` table; pyright does not walk down into nested pyproject.toml files for configuration the way ruff does, and no workflow job cds into `examples/clients/*`. This table's `include`/`venvPath`/`venv` therefore describe a local per-example virtualenv for an editor or a manual run, and no value in it can change any CI outcome. Confidence held down because the claim rests on pyright's config-resolution behaviour rather than on an explicit workflow line.

producer nothing on the CI path — no pipeline step invokes pyright with this directory as its project root — actor can write to it: not established

  • examples/clients/simple-chatbot/pyproject.toml:33-36
  • .pre-commit-config.yaml:47-52
  • pyproject.toml:150-160

decided by agent · confidence 0.55

Crystallization curve

Keel crystallization curve Crystallization curve over 15 runs from <repo>/reports. falls — total fitted change -66.5% of the mean across 15 runs (R^2 0.28, so the line explains less than half the variance — read the raw squares) CRYSTALLIZATION CURVE 15 sequential runs · 345 nodes judged of 1838 gathered · 124 anchored Keel corpus — 15 repositories, 2026-07-24 — measured corpus (declared on the command line) estimated tokens per node estimated tokens / judged node 0 300 600 run 0 · keel · estimated tokens per node = 498 · judged 25 of 32 gathered run 1 · anthropic-sdk-python · estimated tokens per node = 369 · judged 25 of 41 gathered run 2 · openai-python · estimated tokens per node = 398 · judged 25 of 95 gathered run 3 · vercel-ai · estimated tokens per node = 410 · judged 25 of 1014 gathered run 4 · aider · estimated tokens per node = 204 · judged 25 of 62 gathered run 5 · browser-use · estimated tokens per node = 378 · judged 25 of 104 gathered run 6 · mcp-python-sdk · estimated tokens per node = 592 · judged 25 of 121 gathered run 7 · simonw-llm · estimated tokens per node = 189 · judged 25 of 38 gathered run 8 · tiktoken · estimated tokens per node = 240 run 9 · requests · estimated tokens per node = 290 · judged 25 of 106 gathered run 10 · flask · estimated tokens per node = 408 · judged 25 of 59 gathered run 11 · sinatra · estimated tokens per node = 265 · judged 25 of 59 gathered run 12 · commander-js · estimated tokens per node = 316 run 13 · anthropic-quickstarts · estimated tokens per node = 310 · judged 25 of 62 gathered run 14 · tiktoken · estimated tokens per node = 53 0* 1* 2* 3* 4* 5* 6* 7* 8 9* 10* 11* 12 13* 14 seconds per node measured s / judged node 0.0 60.0 120.0 run 0 · keel · seconds per node = 0.1 · judged 25 of 32 gathered run 1 · anthropic-sdk-python · seconds per node = 15.8 · judged 25 of 41 gathered run 2 · openai-python · seconds per node = 15.4 · judged 25 of 95 gathered run 3 · vercel-ai · seconds per node = 105.9 · judged 25 of 1014 gathered run 4 · aider · seconds per node = 21.3 · judged 25 of 62 gathered run 5 · browser-use · seconds per node = 21.7 · judged 25 of 104 gathered run 6 · mcp-python-sdk · seconds per node = 20.1 · judged 25 of 121 gathered run 7 · simonw-llm · seconds per node = 11.8 · judged 25 of 38 gathered run 8 · tiktoken · seconds per node = 23.7 run 9 · requests · seconds per node = 16.8 · judged 25 of 106 gathered run 10 · flask · seconds per node = 20.5 · judged 25 of 59 gathered run 11 · sinatra · seconds per node = 16.9 · judged 25 of 59 gathered run 12 · commander-js · seconds per node = 18.7 run 13 · anthropic-quickstarts · seconds per node = 20.4 · judged 25 of 62 gathered run 14 · tiktoken · seconds per node = 0.1 0* 1* 2* 3* 4* 5* 6* 7* 8 9* 10* 11* 12 13* 14 probe-decided share measured share of decided nodes 0.00 0.50 1.00 run 0 · keel · probe-decided share = 0.00 · judged 25 of 32 gathered run 1 · anthropic-sdk-python · probe-decided share = 0.00 · judged 25 of 41 gathered run 2 · openai-python · probe-decided share = 0.16 · judged 25 of 95 gathered run 3 · vercel-ai · probe-decided share = 0.08 · judged 25 of 1014 gathered run 4 · aider · probe-decided share = 0.56 · judged 25 of 62 gathered run 5 · browser-use · probe-decided share = 0.32 · judged 25 of 104 gathered run 6 · mcp-python-sdk · probe-decided share = 0.00 · judged 25 of 121 gathered run 7 · simonw-llm · probe-decided share = 0.56 · judged 25 of 38 gathered run 8 · tiktoken · probe-decided share = 0.58 run 9 · requests · probe-decided share = 0.28 · judged 25 of 106 gathered run 10 · flask · probe-decided share = 0.00 · judged 25 of 59 gathered run 11 · sinatra · probe-decided share = 0.28 · judged 25 of 59 gathered run 12 · commander-js · probe-decided share = 0.19 run 13 · anthropic-quickstarts · probe-decided share = 0.24 · judged 25 of 62 gathered run 14 · tiktoken · probe-decided share = 1.00 0* 1* 2* 3* 4* 5* 6* 7* 8 9* 10* 11* 12 13* 14 probe library size measured probes in library 0 15 30 run 0 · keel · probe library size = 0 · judged 25 of 32 gathered run 1 · anthropic-sdk-python · probe library size = 3 · judged 25 of 41 gathered run 2 · openai-python · probe library size = 5 · judged 25 of 95 gathered run 3 · vercel-ai · probe library size = 8 · judged 25 of 1014 gathered run 4 · aider · probe library size = 10 · judged 25 of 62 gathered run 5 · browser-use · probe library size = 12 · judged 25 of 104 gathered run 6 · mcp-python-sdk · probe library size = 13 · judged 25 of 121 gathered run 7 · simonw-llm · probe library size = 15 · judged 25 of 38 gathered run 8 · tiktoken · probe library size = 17 run 9 · requests · probe library size = 19 · judged 25 of 106 gathered run 10 · flask · probe library size = 22 · judged 25 of 59 gathered run 11 · sinatra · probe library size = 23 · judged 25 of 59 gathered run 12 · commander-js · probe library size = 26 run 13 · anthropic-quickstarts · probe library size = 29 · judged 25 of 62 gathered run 14 · tiktoken · probe library size = 29 0* 1* 2* 3* 4* 5* 6* 7* 8 9* 10* 11* 12 13* 14 x axis: run index in the recorded corpus order (listed below). Squares are the raw per-run values; the dashed line is an ordinary-least-squares fit and is never shown without them. The fit is clipped to the panel, and withheld entirely (with the panel saying so) where a straight line would predict values the points cannot take — R^2 for every fit is in the trend notes below. * on run 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 13 = judged fewer nodes than gathered; per-node values are per JUDGED node. Coverage (judged) ci_step 224 · script 64 · test_target 46 · review_gate 10 · deploy_gate 1 Trend estimated tokens per node: falls — total fitted change -66.5% of the mean across 15 runs (R^2 0.28, so the line explains less than half the variance — read the raw squares). raw first 498 -> last 53. seconds per node: falls — total fitted change -72.9% of the mean across 15 runs (R^2 0.04, so the line explains less than half the variance — read the raw squares). raw first 0.1 -> last 0.1. probe-decided share: rises — total fitted change 144.9% of the mean across 15 runs (R^2 0.21, so the line explains less than half the variance — read the raw squares). raw first 0.00 -> last 1.00. probe library size: rises — total fitted change 187.7% of the mean across 15 runs (R^2 0.99). raw first 0 -> last 29. Run order run order derived from Report.generatedAt (ascending, filename as tiebreak) — no order.json manifest present 0 keel -> 1 anthropic-sdk-python -> 2 openai-python -> 3 vercel-ai -> 4 aider -> 5 browser-use -> 6 mcp-python-sdk -> 7 simonw-llm -> 8 tiktoken -> 9 requests -> 10 flask -> 11 sinatra -> 12 commander-js -> 13 anthropic-quickstarts -> 14 tiktoken Shuffle check shuffle check INCOMPLETE — the curve direction ran, the ratio direction did not Permutation (400 draws, seed 20260724): median |delta normalized slope| 0.633, 92% of draws move it by >= 0.15. A permutation reorders ALREADY-RECORDED runs; it cannot reproduce what a genuine re-run in a different order would have cost, because the probe library would have accumulated differently. It is a sanity signal on order-dependence, never a substitute for the empirical re-run. Ratio stability under shuffle was NOT checked: no re-run supplied (--shuffled) and none declared in corpus.meta.json. Permuting recorded runs cannot move a per-target ratio, so the permutation result below says nothing about it. Supply a re-run with --shuffled <dir>. Disclosures - Skipped 3 file(s) that are not usable run reports: corpus-summary.json (no string "target"); curve.json (no string "target"); keel.bindings.json (no "nodes" array). - Run 0 (keel) judged 25 of 32 gathered nodes — a cap. Per-node cost below is per JUDGED node. - Run 1 (anthropic-sdk-python) judged 25 of 41 gathered nodes — a cap. Per-node cost below is per JUDGED node. - Run 2 (openai-python) judged 25 of 95 gathered nodes — a cap. Per-node cost below is per JUDGED node. - Run 3 (vercel-ai) judged 25 of 1014 gathered nodes — a cap. Per-node cost below is per JUDGED node. - Run 4 (aider) judged 25 of 62 gathered nodes — a cap. Per-node cost below is per JUDGED node. - Run 5 (browser-use) judged 25 of 104 gathered nodes — a cap. Per-node cost below is per JUDGED node. - Run 6 (mcp-python-sdk) judged 25 of 121 gathered nodes — a cap. Per-node cost below is per JUDGED node. - Run 7 (simonw-llm) judged 25 of 38 gathered nodes — a cap. Per-node cost below is per JUDGED node. - Run 9 (requests) judged 25 of 106 gathered nodes — a cap. Per-node cost below is per JUDGED node. - Run 10 (flask) judged 25 of 59 gathered nodes — a cap. Per-node cost below is per JUDGED node. - Run 11 (sinatra) judged 25 of 59 gathered nodes — a cap. Per-node cost below is per JUDGED node. - Run 13 (anthropic-quickstarts) judged 25 of 62 gathered nodes — a cap. Per-node cost below is per JUDGED node. - Run 14 (tiktoken): decidedByProbe + decidedByAgent = 10 but nodesSampled = 12. Probe-decided share uses the decided total (10). - Provenance "measured" was declared on the COMMAND LINE (--provenance), not in <repo>/reports/corpus.meta.json. The declaration therefore lives in the invocation and is only as trustworthy as the run sheet that records it. - Token counts are ESTIMATES in 15 of 15 run(s) (RunEconomics.tokensEstimated). No API exposes session token usage to a skill, so the token axis reads "estimated tokens" and nothing here claims a measured token count. Scope. Keel measures the shape of verification, not its quality. A repo can be 100% anchored with terrible tests. Anchoring says the signal comes from outside; it does not say the signal is sufficient.

Run economics

0 decided by probe
25 decided by agent
0% probe-decided share
25 / 121 nodes judged / gathered
7,501 estimated tokens in
7,309 estimated tokens out
8m 24s wall clock
1 probes minted
13 probe library size

Token counts are estimated, and labelled so: a skill running inside an agent session has no API for its own usage, so the figure is ceil(chars/4) over the judgment payloads and responses. Wall clock and probe-decided share are measured directly. 25 of 121 gathered edges were judged — the cap is printed here and beside the ratio, never applied silently.