Skip to content

refactor(intrinsics): resolve the adapter's output contract from the adapter, not a parallel argument (Epic #929 Phase 2) #1516

Closed

Description

@planetf1

Parent epic: #929
Design discussion: #1486
Phase: 2
Blocks: #1144


Problem

An adapter's output contract and the adapter itself travel two separate paths, and nothing
ties them together.

call_intrinsic resolves the adapter and then discards it:

# mellea/stdlib/components/intrinsic/_util.py:185
backend.resolve_adapter(intrinsic_name)

The contract it needs arrives instead as a separate parameter (_util.py:150), which each
caller supplies from a module-level Adapter constant — ten call sites, six in rag.py
and four in guardian.py:

# mellea/stdlib/components/intrinsic/rag.py:208
io_contract=_ANSWERABILITY_ADAPTER.io_contract,

So the object that describes how to parse the output is chosen by the caller, while the
adapter that actually produced the output is resolved by the backend. Nothing in the type
system requires the two to refer to the same capability. Passing the wrong constant is a
silent mismatch.

The obvious fix — read io_contract off the resolved adapter — does not work today, and
that is the substance of this issue.

The blocker

resolve_adapter returns adapters whose io_contract is a placeholder that raises:

# mellea/backends/adapters/adapter.py:87
class _ShimIOContract(IOContract):
    """Phase 1 placeholder; Phase 2 (issue #1137) implements real I/O."""

    def parse(self, raw: str) -> dict[str, object]:
        raise NotImplementedError(...)

Both shim adapter classes construct with it — IntrinsicAdapter (adapter.py:257) and
EmbeddedIntrinsicAdapter (adapter.py:663) — and those are what resolution hands back.
Reading the contract off the resolved object today therefore raises on every adapter
function call.

The docstring above points at #1137 as the issue that implements real I/O, but #1137 is
closed. It built the real contracts as the module-level constants in rag.py and
guardian.py; the resolution path never got them. That is the gap: real contracts exist,
but only on the far side of the split this issue is about.

Behaviour change to account for

core.py's three helpers (check_certainty, requirement_check,
find_context_attributions) pass no contract at all today, so their output goes through
raw json.loads (_util.py:219). Once resolution supplies a real contract they would
start validating. That is a behaviour change, not a refactor: each needs its expected
output contract declared and verified against current weights, the same way #1137 did for
rag.py.

requirement_check additionally hand-rolls its own validation after the call
(core.py:~72, mirroring requirement_check_to_bool() in requirement.py) with a comment
saying Phase 2 will consolidate it via IOContract. That consolidation belongs here.

Scope

  • mellea/stdlib/components/intrinsic/_util.py — call_intrinsic
  • The resolution path in mellea/backends/adapters/adapter.py
  • core.py's three helpers gain declared output contracts
  • The ten io_contract= call sites in rag.py and guardian.py

Out of scope

Acceptance criteria

  • call_intrinsic uses the adapter returned by resolve_adapter rather than discarding it
  • The output contract is obtained from that resolved adapter, not from a separate argument
  • call_intrinsic no longer takes an io_contract parameter
  • Resolution yields real contracts; no adapter reachable through resolve_adapter carries _ShimIOContract
  • core.py's three helpers each have a declared, documented output contract
  • requirement_check's hand-rolled post-call validation is replaced by its contract
  • Supplying a capability name whose contract disagrees with the resolved adapter is no longer expressible
  • ruff format, ruff check, mypy clean

Test plan

  • Per helper across rag.py, guardian.py, core.py: output missing a required field
    raises AdapterSchemaMismatchError; output with an extra optional field does not
  • core.py's three helpers: contracts verified against current weights, with the
    verification recorded in the PR description (as refactor(intrinsics): rag.py whole-file migration to new Adapter types (Epic #929 Phase 1) #1137 did)
  • Existing adapter-function tests pass unchanged (behavioural neutrality on the happy path)
  • No test constructs an adapter whose io_contract is _ShimIOContract

Activity

  1. added
    p1High: important bugs (workaround exists) or high-value core features. Do soon, not on fire.
    area/adapter-functionsGranite adapter functions: framework and adaptiers including RAG, Guardian, Core
    area/backendsProvider-specific work: Ollama, HF, LiteLLM, OpenAI, Bedrock, vLLM
    and removed
    area/backendsProvider-specific work: Ollama, HF, LiteLLM, OpenAI, Bedrock, vLLM
    on Aug 10, 2026
  2. self-assigned this
    on Aug 10, 2026
  3. planetf1 commented on Aug 11, 2026

    @planetf1
    ContributorAuthor

    Extra scope found while finishing #1454: the ten constants carry placeholder bindings

    Discovered by trying the "cross-check only" stopgap from #1486 — having
    Adapter.__post_init__ reject a binding whose adapter_type disagrees with its
    identity's — and having to back it out. Recording it here because this issue is the one
    that gives these constants real content.

    What's there today. The ten module-level Adapter constants in
    mellea/stdlib/components/intrinsic/rag.py (6) and guardian.py (4) are each built like
    this:

    _ANSWERABILITY_ADAPTER = Adapter(
        identity=Identity("answerability", "alora", capability="answerability"),
        io_contract=_DictContract("answerability", frozenset({"answerability"})),
        weights=LocalFileBinding(),          # <-- name="", repo_id="", adapter_type=LORA
    )

    So each declares alora in its identity and pairs it with a bare LocalFileBinding()
    whose adapter_type defaults to AdapterType.LORA, whose name and repo_id are empty
    strings, and whose revision was "main" until #1454 changed the default to None
    (= "use the catalogue's pinned revision").

    Why it isn't a bug today. Nothing resolves these constants — that's this issue's whole
    premise — so weights is never used, and every catalogue entry supports both lora and
    alora, so alora is a legitimate choice rather than a contradiction. They are
    unconfigured placeholders, not inconsistent objects. That distinction is why the
    cross-check had to come out: it fired on "not configured yet", which is the wrong signal,
    and it broke collection of eight test modules at import time.

    Why it matters for this issue. Once resolution hands back these Adapter objects
    rather than _AdapterCore shims, their weights stops being decorative, and three things
    become live at once:

    1. identity.adapter_type says alora while weights.adapter_type says lora, and the
      two feed different lookup paths — registration and every verb key on the binding's
      qualified_name, while _find_adapter scans on the identity. Both return None on a
      miss, so a disagreement surfaces as "adapter not found" a long way from its cause.
    2. name="" makes qualified_name "_lora" for all ten, so they collide with each
      other in _added_adapters. feat(backends): LocalFileBinding implements verbs (PEFT/aLoRA path) + from_catalog() (Epic #929 Phase 2) #1454 added a loud prepare() guard against an empty
      name, so this fails clearly rather than silently — but it does fail.
    3. repo_id="" means there's nothing for obtain_lora to download from.

    Suggested addition to this issue's scope: give the ten constants real bindings —
    LocalFileBinding.from_catalog(<capability>) is the obvious candidate, though note it
    resolves adapter_types[0] (lora) rather than the alora the identities currently
    declare, so which of the two is intended needs deciding rather than assuming. Then
    enforce the invariant: reinstate the Adapter.__post_init__ cross-check, so this class of
    drift is caught at construction instead of as a silent lookup miss. _core.py carries a
    NOTE(#1516) at the spot with the same reasoning.

    Related: @AngeloDanducci found the same drift in #1454's own tests, where an identity
    saying alora sat next to a binding that had loaded the lora weights and the test
    passed because nothing asserted the identity. Those two call sites are fixed in #1454;
    the systemic version is the ten above.

  4. 2 remaining items

  5. added 11 commits that reference this issue on Aug 21, 2026
    f28c197
    d16cfa7
    4e5ad99
    2dafe58
    2050f92
    cadc9df
    bc017a2
    1363fec
    cb45a34
    320498c
    bfe937a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area/adapter-functionsGranite adapter functions: framework and adaptiers including RAG, Guardian, Corep1High: important bugs (workaround exists) or high-value core features. Do soon, not on fire.refactor

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions