feat: record what each entry point reads, with python -m nodrill contract - #14
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_CONTRACTset to a directory and every read is recorded against the entry point it happened under, thenpython -m nodrill contractrenders 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_defaultquietly taking over a boundary is one changed line, which is the case the whole thing exists for.NODRILL_CONTRACT_ENTRYnames 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_RUNgroups 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.tomlstill declares no scripts, and the whole surface ispython -m nodrill.Design points
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
@injectwrapper, which binds its registry accessor at decoration where nothing patched later reaches it.getor throughdictitself, which is how the recorder tells a read a user wrote from a read the library did.Not TOML, since
tomllibis 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._errorsgains the rendering every message and every recorded fact shares, which is the leaf both_debugand_auditcan import, and is what keeps_auditdeferred 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.
multiprocessingclears the finalizer registry before a worker body runs, so registering the dump both ways was registering it once, and onlyspawnworked.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 Noneon 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, aset_defaultfactory above all.The wrapper binds a sentinel in its
except KeyErrorand 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 wholeunused=Trueentry into a blockquote, whichsphinx-build -Waccepts.Cost
use()and entering a block are untouched by the recorder when it is off.The
@injectrow moved from 61 ns to 65, because taking the miss path outside the handler costs the identity test back, against 37 for thegetplus sentinel spelling the branch replaced.The benchmark harness is fixed in the same branch.
Three runs of
bench.pyon 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
--writereplaces 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-randomlyseeds.