Skip to content

Add coheriq.resolve_impl() to reach a candidate's resolved implementation - #15

Draft
Jim Garrison (garrison) wants to merge 3 commits into
mainfrom
get-active-impl
Draft

Jim Garrison (garrison) wants to merge 3 commits into
mainfrom
get-active-impl

Conversation

@garrison

@garrison Jim Garrison (garrison) commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Adds coheriq.resolve_impl(), so the implementation an acceleration candidate dispatches to can be reached directly.

Reviewable as two commits: the first adds the function, the second renames it from get_active_impl() for the reasons under Why resolve_impl below.

The problem

A class or function decorated with @domain.acceleration_candidate is replaced by a dispatching wrapper. For a class, the wrapper is a function object standing where the class name used to be, so construction works and nothing else does:

@domain.acceleration_candidate
class Thing:
    def __init__(self, n): self.n = n
    @classmethod
    def make(cls, n): return cls(n)

domain.materialize()

Thing(3)              # fine
isinstance(x, Thing)  # TypeError: isinstance() arg 2 must be a type, ...
Thing.make(4)         # TypeError: 'classmethod' object is not callable

__new__, subclassing, and introspection are unreachable for the same reason. This matters because swapping a Python data structure for a compiled one is a headline use case for this package, so class candidates aren't a corner case. The existing example already works around it: RaggedBatch.from_flat can't be a @classmethod, so it lives as the free function ragged_batch_from_flat.

For a function candidate the wrapper forwards calls faithfully, so the gap is narrower but real: what it hides is the implementation's identity. Which object is actually being called isn't answerable by calling it.

The change

resolve_impl(candidate) returns what the wrapper dispatches to — the domain default, or the enabled engine's override:

isinstance(x, coheriq.resolve_impl(RaggedBatch))
coheriq.resolve_impl(RaggedBatch).make(4)      # a @classmethod
coheriq.resolve_impl(compute) is engine_compute

Like calling a candidate, it resolves and freezes the implementation, so no engine can be enabled afterwards. That's why it's a function call and not an attribute — the side effect should look deliberate. It's the same side effect calling any candidate already has, not a new one. __wrapped__ (already set by functools.wraps) remains the escape hatch for the original, ignoring any engine, with no such side effect.

Also adds CoheriqTypeError(CoheriqUserError, TypeError), raised for a non-candidate. The dual inheritance means it's catchable both as CoheriqError and as a plain TypeError.

Why resolve_impl

The function was originally get_active_impl(). Two problems with that name, both fixed by the second commit:

  • get_ reads as a side-effect-free accessor, and this call isn't one. It freezes the implementation permanently. Naming it get_ put the name at odds with the docstring, which has to warn about exactly that. resolve carries the effect in the verb, and it's already this codebase's word for the operation — _ensure_realized_impl, _resolve_domain, and the docstrings that describe a call as resolving the implementation.
  • It was confusable with available_implementations()-style name-returning functions, in particular active_implementation() (Add available_engines() and active_implementation() introspection functions #14). That one returns an implementation's name; this returns the implementation object. The near-identical names implied a kinship that misrepresented the return types, so dropping the echo removes the collision — whereas spelling this one resolve_implementation() would have tightened it.

impl is kept rather than spelled out: it's what the code calls the object throughout, and the abbreviation is what distinguishes the object-returning function from the name-returning one.

The private wrapper attribute carrying the hook is renamed to _coheriq_resolve_impl to match, so the one place a reader looks to understand how the public function reaches the implementation doesn't still use the old vocabulary.

It accepts both kinds of candidate

An earlier draft restricted this to classes, on the reasoning that a function wrapper already forwards everything that matters. That was too strong — identity is a fair thing to ask for, whether to compare with is or to inspect the resolved function itself, and calling through the wrapper can never answer it.

Restricting it also protected nothing: calling a function candidate already freezes the domain, so refusing the direct route just leaves the indirect one open.

Design notes

A free function, not a method. mypy special-cases class decorators and won't substitute a decorator's return type onto the class symbol, so a RaggedBatch.resolve_impl() method errors at every call site. The alternatives were a mixin base (works, but imposes inheritance on domain authors) or a metaclass — rejected, because it conflicts with nanobind's metaclass for bound C++ types, and engines are exactly where nanobind shows up.

The signature is identity-preserving (_F -> _F, bound to Callable). Because of the existing if TYPE_CHECKING and isclass(f): return f branch, a decorated class reads to a type checker as a constructor callable rather than type[...] — and a decorated function matches that same shape. So a type[_T] return would be correct for classes and simply wrong for functions (it would claim type[int] for a function returning int). The identity signature is accurate for every input. The cost is that isinstance() against the result needs a cast; an overload can recover that later if it's worth it.

No default_impl() counterpart. The default implementation is only ever worth referencing from within default code — but that code only runs when the default is the active implementation, where active and default are the same thing. For the genuine remaining case (introspection, tests), __wrapped__ already covers it.

Caveat worth flagging

coheriq doesn't ship a py.typed marker, so downstream consumers currently see everything imported from coheriq as Any — which means the type-checking discussion above is dormant until that changes. I confirmed this empirically: from outside the package both calls reveal Any regardless of the signature. Adding the marker is tracked separately in #16 and isn't part of this PR.

The runtime motivation (isinstance / @classmethod / __new__ being broken, and function identity being unreachable) is live today regardless.

Testing

  • tox -e py — 36 passed, of which 12 are new (TestResolveImpl)
  • tox -e lint — green: ruff, copyright check, mypy, pylint 10.00/10 on src/ and test/, nbqa, reno
  • tox -e doctest, tox -e docs — both green

New tests cover: resolution to the default class; isinstance and __new__ becoming usable; reflecting an active engine; the freeze side effect; __wrapped__ staying the original class under an active engine; rejection of non-candidates including the dual-catchability of the error; and, for function candidates, identity against both the default and an engine override, agreement between calling the result and calling the wrapper, and the freeze.

Notes


This PR was generated by Claude Opus 5 under my guidance.

…ntation

A class or function marked with @domain.acceleration_candidate becomes a
dispatching wrapper in user code, and the implementation it dispatches to is
not otherwise reachable.

For a class the gap is severe, because the wrapper is a function object
standing where the class name used to be.  Construction works, but nothing
else a class can do does: isinstance() rejects the wrapper as its second
argument, @classmethod alternate constructors raise "'classmethod' object is
not callable", and __new__ and subclassing are unreachable.  The example
domain already works around this -- RaggedBatch.from_flat cannot be a
@classmethod, so it exists as the free function ragged_batch_from_flat.

For a function the wrapper forwards calls faithfully, so the gap is narrower
but real: what it hides is the implementation's identity.  Which object is
actually being called is not answerable by calling it, and that is a fair
question to ask -- to compare with "is", or to inspect the resolved function
itself.

get_active_impl(candidate) returns what the wrapper dispatches to: the domain
default, or the enabled engine's override.  It accepts either kind of
candidate; restricting it to classes would withhold the identity of a
function implementation for no benefit, since calling a function candidate
already freezes the domain, so nothing is protected by refusing.  Like
calling a candidate, it resolves and freezes the implementation, which is why
it is a function call rather than an attribute.  __wrapped__ (already set by
functools.wraps) remains the escape hatch for the original, ignoring any
engine, with no such side effect.

Deliberately a free function, not a method: mypy special-cases class
decorators and will not substitute a decorator's return type onto the class
symbol, so a method errors at every call site.  A metaclass would work but
conflicts with nanobind's metaclass for bound C++ types, which engines use.

The signature is identity-preserving (_F -> _F, bound to Callable) rather
than returning type[_T].  Because of the existing "if TYPE_CHECKING and
isclass(f): return f" branch, a decorated class reads to a type checker as a
constructor callable rather than type[...], and a decorated function matches
that same shape -- so a type[_T] return would be correct for classes and
simply wrong for functions.  The identity signature is accurate for every
input; the cost is that isinstance() against the result needs a cast until a
type[_T] overload is worth adding.

Note that coheriq does not ship a py.typed marker, so the type-level
behavior above is dormant downstream until it does.  The runtime motivation
is live today regardless.

No get_default_impl() counterpart: the default is only referenceable from
within default code, but that code only runs when the default is active,
where active == default already.

The docs entries for get_active_impl and CoheriqTypeError are new here.

Adds a release note.

Assisted-by: Claude Opus 5
Two problems with the old name.

It read as a plain accessor, but the call is not one: like calling a
candidate, it resolves and freezes the implementation, after which no engine
can be enabled.  A "get_" prefix suggests a side-effect-free read, so the
name worked against the docstring that had to warn about the side effect.
"resolve" carries it in the verb, and matches the vocabulary the code already
uses for this operation -- _ensure_realized_impl, _resolve_domain, and the
docstrings describing a call as resolving the implementation.

It was also confusable with active_implementation(), which is a near-identical
name for a function returning something different in kind: that one returns
an implementation's *name*, this one returns the implementation object.  The
echo between the two names implied a kinship that misrepresented the return
types.  Dropping it removes the near-miss pair rather than making it tighter,
which is what naming this resolve_implementation() would have done.

"impl" is kept rather than spelled out, since it is what the code calls the
object throughout, and it distinguishes the object-returning function from
the name-returning one.

The wrapper attribute carrying the hook is renamed to _coheriq_resolve_impl
to match.  It is private, but leaving it named after the old function would
strand the vocabulary at the one place a reader goes to understand how the
public function reaches the implementation.

The release note file is renamed for the same reason; it is the only note on
this branch and has not been released, so its slug is free to change.

Assisted-by: Claude Opus 5
@garrison Jim Garrison (garrison) changed the title Add coheriq.get_active_impl() to reach a candidate's resolved implementation Add coheriq.resolve_impl() to reach a candidate's resolved implementation Sep 25, 2026
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 36934638601

Coverage increased (+1.1%) to 87.168%

Details

  • Coverage increased (+1.1%) from the base build.
  • Patch coverage: 13 of 13 lines across 3 files are fully covered (100%).
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 226
Covered Lines: 197
Line Coverage: 87.17%
Coverage Strength: 0.87 hits per line

💛 - Coveralls

@garrison Jim Garrison (garrison) added the enhancement New feature or request label Oct 5, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants