Skip to content

feat: record what each entry point reads, with python -m nodrill contract - #14

Merged
paqstd-dev merged 12 commits into
mainfrom
rfc-0009-context-contract
Aug 27, 2026
Merged

paqstd-dev merged 12 commits into
mainfrom
rfc-0009-context-contract

Conversation

@paqstd-dev

Copy link
Copy Markdown
Owner

Implements the context-contract design from the wave 4 backlog, RFC-0009.

What this adds

A parameter is visible in a signature and a context lookup is not, so whether a handler can miss in production is a question answered today by deploying.
Run a suite with NODRILL_CONTRACT set to a directory and every read is recorded against the entry point it happened under, then python -m nodrill contract renders the record into a file a pull request reviews.
A key a handler starts reading is one added line in that diff, and a set_default quietly taking over a boundary is one changed line, which is the case the whole thing exists for.

NODRILL_CONTRACT_ENTRY names the provider keys that are boundaries, since an entry point is otherwise whatever block is outermost, and anything an application opens above its boundaries would be the entry point for all of them.
NODRILL_CONTRACT_RUN groups the processes of one run and is inherited through the environment, so it is set for you and can be set by hand where the inheritance does not reach.

No new public name and no console script.
__all__ did not grow, pyproject.toml still declares no scripts, and the whole surface is python -m nodrill.

Design points

  • The instrumentation is the ledger and not a second mechanism.
    Read counting already installs a dict subclass as the registry rather than branching in use(), and the recorder rides the same subclass, so the hot path gains no line and an off run pays nothing.
    It is also the only mechanism that sees a compiled @inject wrapper, which binds its registry accessor at decoration where nothing patched later reaches it.
  • A consumer read is a subscript, and everything the library does to a registry for its own reasons reads it through get or through dict itself, which is how the recorder tells a read a user wrote from a read the library did.
  • The file is three tab-separated fields, sorted, with no file names and no line numbers, since a site moves whenever anything above it moves and a file that churns is not read.
    Not TOML, since tomllib is 3.11 and the floor here is 3.10, and not JSON, since a nested object re-indents and this file exists to be read in a diff.
  • The switch is an environment variable read once at import, because a child interpreter inherits one.
  • _errors gains the rendering every message and every recorded fact shares, which is the leaf both _debug and _audit can import, and is what keeps _audit deferred so an unarmed process never pays for its imports.

What the review changed

Two claims the feature commit makes turned out to be false, and both are fixed in this branch rather than left for a follow-up.

A forked worker recorded nothing.
multiprocessing clears the finalizer registry before a worker body runs, so registering the dump both ways was registering it once, and only spawn worked.
That is the macOS default and not the Linux one, so the pool test was green locally and would have been red on every CI job.
The child registers the dump again from an after-fork hook now, and all three start methods write a shard.

from None on the miss path was the wrong instrument.
It suppressed the caller's own in-flight exception on the use() path, and it covered none of what user code raises inside the wrapper's handler, a set_default factory above all.
The wrapper binds a sentinel in its except KeyError and takes the miss path after the handler instead, so a hit still skips the handler and nothing arrives chained to a lookup the caller never wrote.

The rest, in one commit each described in its message.
A relative directory is resolved when the variable is read rather than at exit.
A shard truncated by a killed worker, one from an unknown version and one carrying a verb nothing writes are each a message and an exit code rather than a traceback.
A repair derives the entry label again from the chain that survived and moves the restored key's read counter with it.
What the recorder keeps is capped, because an entry point is a provider key and a key built per request grew the set for the life of the process.
A section in the debugging reference had been inserted inside the debug() directive, orphaning the whole unused=True entry into a blockquote, which sphinx-build -W accepts.

Cost

use() and entering a block are untouched by the recorder when it is off.
The @inject row moved from 61 ns to 65, because taking the miss path outside the handler costs the identity test back, against 37 for the get plus sentinel spelling the branch replaced.

The benchmark harness is fixed in the same branch.
Three runs of bench.py on an unchanged tree moved all fifteen rows, by up to 72 percent, because each row was timed to completion before the next one started and the baseline every ratio divides by is one of those rows.
The table is timed over five passes now and each row keeps its best, which puts the worst spread at 14 percent and most rows inside 3, and --write replaces a row only when it moved further than a rerun moves it, so the page stops churning and its diff means something.

Review

Reviewed with a multi-agent pass at max effort over the branch diff, across correctness, reuse, simplification, altitude and conventions.
Twenty-three findings survived verification, each reproduced with a runnable case rather than inferred, and every one is either fixed here or argued in .claude/rules/design-invariants.md.
The last commit is a prose pass over the whole project, twenty docstring paragraphs that ran a line longer than they had to, eighteen two-line comments against a rule that says one, and the colons, semicolons and em dashes the house style does not use.

The gate is green throughout, 1145 tests at 100 percent branch coverage with no omit and no pragma, mypy strict, pyright, and a docs build with warnings as errors.
Largely written with an AI assistant, which the checklist asks be said in the description.

Docs

A how-to framed as the task, a reference section for the three variables and the command, and updates to the design and performance pages.
Recording this repository's own suite gives a contract that is byte for byte identical across pytest-randomly seeds.

provider() checked its three flags by looping over a **kwargs dict, so every call packed a dict to catch a mistake almost no call makes.
Spelling the three tests out takes about 135 ns off entering a block, which was a seventh of what a block cost.
use() and _Provider.__enter__ looked _registry.get up on the module every time, which the @Inject wrapper already stopped doing by binding it at decoration, and binding it once here too takes about five percent off a read.

Measured interleaved against the parent revision, alternating rounds, with the value-passed-in row and bare ContextVar.get() flat as controls.
use() 60.5 to 57.9 ns, use(Config) alone 44.7 to 42.3, enter and exit 1002 to 830, the same with providers already open 1025 to 871, extend=True 1656 to 1491, sealed=True 2524 to 2378.

Both spellings look arbitrary from the code, so the argument for each is on the design page rather than only in a comment.
The benchmark table is left for the change that follows, so the numbers are regenerated once rather than twice.
…ract

A parameter is visible in a signature and a context lookup is not, so whether a handler can miss in production is a question answered today by deploying.
Run a suite with NODRILL_CONTRACT set to a directory and every read is recorded against the entry point it happened under, then python -m nodrill contract renders the record into a file a pull request reviews.
A key a handler starts reading is one added line in that diff, and a set_default quietly taking over a boundary is one changed line, which is the case the whole thing exists for.

The instrumentation is the ledger and not a second mechanism.
Read counting already installs a dict subclass as the registry rather than branching in use(), and the recorder rides the same subclass, so the hot path gains no line and an off run pays nothing, measured interleaved at under two percent on every reference row with the controls moving the same amount.
It is also the only mechanism that sees a compiled @Inject wrapper, which binds its registry accessor at decoration where nothing patched later reaches it.
That wrapper reads with a subscript in a try rather than get() with a sentinel, which is both faster and, since a read through get is deliberately not recorded, the reason injected reads appear in a contract at all.
The change belongs here rather than with the speed work it also happens to be.

An entry point is the key of a block with nothing open above it, or of one NODRILL_CONTRACT_ENTRY names.
The first rule alone does not survive an ordinary application, where anything opened above the boundaries becomes the entry point for all of them, so naming the boundaries is what makes the first column mean anything.
It is a variable rather than a sixth provider() keyword because a keyword on a released function cannot be taken back and a variable can.
Outermost is read off the chain of open blocks rather than off the kind of mapping a block inherited, since a block closing out of order leaves a repaired mapping that outlives its chain and would hand its dead label to whatever opened next.

The recorder sits above the defaults probe in _resolve_miss rather than on the raise, since a set_default factory and a use(key, default=...) both return before anything reports a miss, and that miss is the one worth surfacing.
_resolve_miss now raises from None, because the wrapper calls it inside its own except KeyError and a caller must not be able to tell.

The file is three tab-separated fields, sorted, and carries no file names and no line numbers.
A tab rather than padding, since one long key would rewrite every line, and rather than two spaces, since repr escapes a tab and a newline but not a space and a key may hold two in a row.
The verb carries the whole answer, requires or set_default or default, so every line is one shape.
Not TOML, since tomllib is 3.11 and the floor here is 3.10, and not JSON, since a nested object re-indents and this file exists to be read in a diff.

The switch is an environment variable read once at import, because a child interpreter inherits one.
A pool worker needs one thing more, since multiprocessing exits through os._exit, which runs finalizers and never atexit, so the dump is registered both ways and made idempotent.
Every process of a run shares a run id, so a directory reused later yields the newer contract rather than the union of both.

No new public name and no console script.
python -m nodrill is what pip and json.tool are spelled as, pyproject.toml still declares no scripts, and coverage stays at 100 with no omit and no pragma.
Recording this repository's own suite gives 85 facts under 63 entry points, byte for byte identical across three pytest-randomly seeds.
A forked worker recorded nothing, because multiprocessing clears the
finalizer registry before the worker body runs, so the child now registers
the dump again from an after-fork hook.  Only spawn worked before, which is
the macOS default and not the Linux one, so the pool test was green here and
red on every CI job.

The directory NODRILL_CONTRACT names is resolved when the variable is read
rather than at exit, since the hooks run after a program may have moved.

The reader is defensive now.  A shard truncated by a killed worker, one from
a version this reader does not know, and one carrying a verb nothing writes
are each a message and the exit code the reference page promises.  _VERBS is
what _parse refuses a line outside of, which is what gives the constant a
reader.  The write side is guarded the same way, since it runs in an exit
hook where a raise is a traceback the process still exits zero after, and
the newline and the encoding are named rather than left to the platform.

The @Inject wrapper binds a sentinel in its except KeyError and takes the
miss path after the handler, so nothing a set_default factory raises arrives
chained to a lookup the caller never wrote.  That is what from None was
reaching for, and from None was wrong in both directions, since it also hid
the caller's own in-flight exception on the use() path.

A repair derives the entry label again from the chain that survived, and
moves the restored key's read counter to the block it was restored from, so
neither the label nor a later read belongs to the block that just left.  The
repair reads through dict.__getitem__, which is invisible to the recorder
and to the read counter alike and still raises if the key is missing.

What the recorder keeps is capped, because an entry point is a provider key
and a key built per request grew the set for the life of the process.

Recording a contract is scoped by _recording() rather than by a fixture
flipping four module globals, and the fixture clearing the declared set is
what lets the suite run under the environment its own how-to documents.
Three runs of bench.py on an unchanged tree moved all fifteen rows, by up to
72 percent, and moved the ratio column on fourteen of them.  Each row was
timed to completion before the next one started, so a row was hostage to
whatever the machine did during its own second, and the baseline every ratio
divides by is one of those rows.

The whole table is timed five times over now and every row keeps its best
pass, which is the right estimator because noise only ever adds time.  The
loop count each row settles on in pass one is reused, so the run costs what
it did.  On the same three back-to-back runs the worst spread is 14 percent
and most rows are inside 3.

Writing is what the page actually needed.  A row is replaced only when it
moved further than a rerun moves it, so --write on an unchanged tree says so
and writes nothing, and a diff of that page means a real change rather than
the weather on the machine that ran it.  The ratios derive from the numbers
the page carries, so the two columns agree and the table reproduces itself.

The @Inject row moved for a real reason, since taking the miss path outside
the handler costs the identity test back.  It is 65 ns against 61, still
well under the 37 the get-plus-sentinel spelling cost.
Twenty docstring paragraphs ran a line longer than they had to, ending on
one or two words that the sentence above could have carried.  _debug ended a
paragraph on "look.", _declare on "avoid." and on "declared.", _portable on
"inside.".  Each is trimmed by a few words rather than rewritten.

Two of them were worth restructuring instead.  NoProviderError had "as
attributes" stranded at the end, three lines from the "Carries" it belongs
to, and annotate_exceptions was ten lines carrying four separate thoughts.

Eighteen comments spanned two lines, against a rule that says one, and in
almost all of them the second line held five or six words.  The two in
_frozen and _sealed were the same sentence twice, and the one in _core spent
its first line restating what the line under it declares.

Sixteen docstrings and comments carried a colon, a semicolon or an em dash.
A colon was usually two thoughts glued together, so the versions without one
are shorter.  The same holds for the contributor docs, where CONTRIBUTING
alone had six semicolons, five colons and four em dashes, and where two
paragraphs put a second sentence on a line the semantic-line-break rule
wants to itself.

Also gone: a "Note that" docstring that said nothing the signature did not,
four uses of "simply", and an em dash printed as a column separator in two
examples.
A relative NODRILL_CONTRACT named one place to the process that was armed
and another to a child that started somewhere else, so that child's shard
landed in a directory nobody renders and a whole boundary left the contract
with no diagnostic.  The resolved directory is now written back over the
variable beside the run id.

A declared boundary that opened and read nothing was indistinguishable from
one no block opened, so the command told a handler that reads nothing its key
had been renamed, on every run.  The recorder notes the open as a fourth verb
and the render drops it again as soon as that boundary has a read of its own.

The benchmark recomputed its ratio column from unrounded timings while
republishing the rounded nanoseconds, so a settled table could still rewrite
the page.  Dividing after rounding makes the table a function of its own ns
column.
The how-to carried the spelling of every field and every verb, which is
reference material sitting in a task page, so a reader who wanted only the
format had to read a recipe to find it.  The how-to keeps the run, the CI
wiring and what the file is worth, and points at the format once.
_summary hardcoded the plural verb, so one leftover shard printed as "1 shard from an earlier run were left out".
The sentence now reads "Left out 1 shard from an earlier run." and agrees at any count.

The empty file refusal put the prose "an empty file" through repr, so it read as literal file content.
_refuse now takes an already rendered fragment and the three real call sites pass repr themselves.

carried() indexed the first rule of the marker block unguarded, so --write against a block with no rule died with IndexError after paying for the whole timing run.
It hands back an empty table instead and the block is filled.
The bench module docstring said the comparable rows are reached five ways, while ORDER has six and the performance page says six.
Its label comment credited the README, which carries no table, rather than the performance page that does.

The shortened __copy__ comment in _frozen and _sealed had dropped the reason it exists, that on the instance __getattr__ would hand copy the target's own hook.

The __main__ docstring spent half its length explaining the __name__ guard, which every Python reader already knows.

CONTRIBUTING said pyright checks src, while pyproject includes src and tests/cycle, and its make test cell read as if pytest is always narrowed.

serve_http in the audit app claimed it opens both keys the handler reads, and the handler reads four.

Two paragraphs in _portable had been edited without reflowing, leaving a short line inside each.
The summary called leftovers "from an earlier run", which is one run, while
three recordings into one directory leave shards from three different ones.
It says "from before this run" now, which holds whether the leftovers came
from one earlier run, from several, or from one run that had several
processes.
… apart

The wrap() row was labelled "per call into a thread" and no thread is
involved, since wrap() replays a snapshot into a fresh context and returns.
It is labelled as the replay it measures, and an Executor.submit row sits
beside it for what a real handoff costs, which is more than an order of
magnitude on top.

Three reads the page discussed had no row, a namespace attribute, which is
the shape extend=True and adopt() both produce, a miss that falls back to a
call-site default, which turns out to be the dearest read in the table, and
a read under debug(unused=True), where the page quoted a multiple nothing
reproduced.  Measuring it put that multiple at three and a half rather than
three, which the two other pages quoting it now say as well.

Entering a provider was priced at one provider open and at eight, four
percent apart, which reads as flat against a claim that the copy scales with
depth.  A sixty-four row shows the slope the other two hide.

The page told the reader to measure both revisions in one sitting and gave
no way to do it.  --save records a run and --against compares the next one
to it, printing the deltas and marking what a rerun would not explain, and
it refuses a file saved on another machine.  --passes raises the pass count
for a row that will not settle.

A run whose rows and ORDER disagree now fails after one pass rather than at
render, and the write path refuses a table that does not read back as its
own rows.
@paqstd-dev
paqstd-dev merged commit 32fb1c2 into main Aug 27, 2026
13 checks passed
@paqstd-dev
paqstd-dev deleted the rfc-0009-context-contract branch August 27, 2026 16:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant