A concrete type in front of the ABC check on the per-item attribute path - #3
Merged
Conversation
`_attribute` ran `isinstance(value, Mapping)` once per attribute per item. `Mapping.__instancecheck__` is Python-level and dispatches into `_abc._abc_instancecheck`: 99.9ns against 27.4ns for a C type check, and cProfile put it at ~1,500 calls per evaluation of the canonical pipeline. `isinstance(value, (dict, Mapping))` answers a plain dict on the first entry and never enters the ABC. Measured 6 to 11% on the collections tier by interleaving the arms within one process over fifteen rounds, four runs. The change was specified as `type(value) is dict`, on a reading in which `isinstance(v, dict)` here was worth nothing. That did not reproduce: four interleaved runs put the three spellings inside each other's spread on plain dicts, and 7 to 11% apart on rows of `OrderedDict`, which only `isinstance` catches. The tuple form is also already the house pattern, beside `_guards._SIZED` and `_guards.HASHABLE_CONTAINERS`. Reordering the tuple gives all of it back and changes no behaviour, so the guard is a call count rather than a timing gate: a plain dict must reach the ABC zero times and a ChainMap once per row, and the order is read off the source. Both fail on a reorder, verified by making one. Claude-Session: https://claude.ai/code/session_01Esnm9mNDpqRWf4QAVHCwHo
Medians against the previous commit on one box: map +13.7%, max_by +11.0%, group_by +10.3%, canonical_pipeline +7.4%. pluck and bare_comparison are the controls and both sit still, which is what makes the rest an attribution. Claude-Session: https://claude.ai/code/session_01Esnm9mNDpqRWf4QAVHCwHo
This was referenced Aug 24, 2026
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.
What changed, and why
Evaluator._attributetestedisinstance(value, Mapping), and it runs once per attribute peritem.
collections.abc.Mapping.__instancecheck__is Python-level and dispatches into_abc._abc_instancecheck: 99.9 ns against 27.4 ns for a C type check, measured over twomillion calls.
cProfileover the canonical pipeline put<frozen abc>.__instancecheck__at300,000 calls for 200 evaluations, about 1,500 per evaluation, with
_attributethe third-largestentry by cumulative time.
It is now
isinstance(value, (dict, Mapping)).isinstancewalks a tuple left to right and stopsat the first hit, so a plain
dictis answered by the C type check and never enters the ABC.No behaviour change on any mapping kind. One line in
src/safeexpr/_eval.py; the rest of thediff is tests and the receipt.
The gain
mainand this branch benchmarked within the same ten minutes on one box, medians and minimums:mapmax_bygroup_bysplit_joincanonical_pipelinelower_eachwhere_then_mapwherenested_predicatepluckbare_comparisonThe shape is the argument rather than any single row.
pluckandbare_comparisonare thecontrols and both sit still:
pluckreads a key directly and walks no attribute per item, andbare_comparisondoes one attribute in the whole expression. Every row that reads a field per itemmoves, by roughly the fraction of its per-item work that was the ABC call.
Separately, three arms interleaved within one process over fifteen rounds, four independent runs,
against an ABC-only arm built by rewriting the shipped handler's own source: 6 to 11%, with the
method and the full table in
tests/benchmarks/test_attribute_path_bench.py.The guard is a call count, not a benchmark
Reordering the tuple to
(Mapping, dict)gives the entire gain back and changes no behaviour, sonothing would fail. A timing gate would not catch it either: this box's own noise floor is above
10%, which
tests/benchmarks/test_scalar_tiers_bench.pyalready records.So the two tests that actually protect this are counts and source reads, in
tests/test_eval.py:test_a_plain_dict_never_reaches_the_abcswaps_eval's module-levelMappingfor a countingprobe and asserts zero consultations over a 200-row
map(_.name).test_a_mapping_that_is_not_a_dict_still_reaches_the_abcasserts 200, so the test abovecannot pass on a guard that dropped the ABC half entirely.
test_the_concrete_type_is_tested_before_the_abcreads the tuple's order off the source.Verified by making the changes they exist to catch. Reordering the tuple fails two of them;
removing the fast path fails the same two.
Regression battery
Every mapping kind a host might realistically pass, in
tests/test_eval.py:test_regression_attribute_a_non_dict_mapping_still_reads_its_keysoverChainMap,MappingProxyTypeand a customcollections.abc.Mapping. This is the test that catches theguard being simplified to its concrete half, which is silent and data-dependent.
test_regression_attribute_a_dict_subclass_reads_its_keysoverOrderedDict,Counter,defaultdict.test_regression_attribute_a_missing_field_reports_the_same_message_on_both_paths, includingthe did-you-mean text.
test_regression_attribute_a_mapping_key_still_wins_over_a_method--d.itemsis the key"items", neverdict.items, on both halves.test_regression_attribute_a_dunder_is_still_blocked_on_both_paths.test_regression_attribute_a_non_mapping_still_needs_registration.test_regression_attribute_a_mapping_subclass_cannot_reach_getattr-- aMappingcarrying areal attribute named like a missing key returns "no field", not the attribute.
Plus a Hypothesis differential in
tests/test_transform_properties.pyover the existingTREESstrategy, asserting every mapping kind agrees with a plain
dicton value, error type and message.Two things worth knowing beyond this PR
Overriding a node handler on
Evaluator, or on a subclass, silently has no effect._DISPATCHis built in the class body and binds the original function objects, so
_evalkeeps calling theunpatched handler. An A/B written that way measures 0.5% and is wrong by a factor of twenty.
Anything swapping a handler must patch
Evaluator._DISPATCH.Counteris not a drop-in fordictin a differential.Counter.__missing__returns 0, soc["absent"]is a value and not aKeyError. That is the standard library deciding about its ownmapping, through the same
value[key]an ordinary program uses. It is pinned bytest_a_mapping_that_answers_a_missing_key_is_allowed_torather than excluded silently.A finding for the benchmark lane
The 10% gate in
CLAUDE.mdcomparesmean, andmeanis unusable on this workload. Against abaseline taken minutes earlier on the same machine,
meanreportspluckregressing 45% andbare_comparison14% for a change that provably cannot touch either.bare_comparisonrecorded a max of 2,278 us against a min of 15.5 us in one run, a 147x outlier, and one of those in
twenty thousand rounds moves the mean further than the effect being measured. Median and minimum
agree within ~3 points on every row above.
Whatever threshold the benchmark lane picks, it should gate on
medianormin.Checks
ruff check,ruff format --check,mypy --strictcleanmeasurable change to runtime)
mainon the same machine, table aboveMerge order: this one first, then the compile-cache work, then the measurement lane.