Skip to content

feat(intrinsics): introduce Adapter/Identity/IOContract/WeightsBinding scaffolding (Epic #929 Phase 0)#1158

Open
planetf1 wants to merge 2 commits into
generative-computing:mainfrom
planetf1:worktree-issue-1134
Open

feat(intrinsics): introduce Adapter/Identity/IOContract/WeightsBinding scaffolding (Epic #929 Phase 0)#1158
planetf1 wants to merge 2 commits into
generative-computing:mainfrom
planetf1:worktree-issue-1134

Conversation

@planetf1
Copy link
Copy Markdown
Contributor

@planetf1 planetf1 commented May 26, 2026

Summary

Closes #1134 — Epic #929 Phase 0, Wave 1.

Why

Today's adapter hierarchy in Mellea is AdapterLocalHFAdapterIntrinsicAdapterEmbeddedIntrinsicAdapter / CustomIntrinsicAdapter. The class that a backend gets handed encodes where the weights live (locally, embedded in the model artefact, server-mediated), so every caller that wants to load, activate, or unload an adapter ends up doing isinstance branching to figure out which storage reality applies. Seven recent fix-up commits trace directly to that structure, and adding new realities (e.g. the work blocked by #1018) means new branches in every caller.

The epic's resolution is to break the adapter into three independent concerns — identity (name, type, role), I/O contract (how to build the prompt and parse the output), and weights binding (how the bytes get loaded). Each lives behind its own type, and the storage-reality variation moves from the class hierarchy into a pluggable WeightsBinding. Callers stop branching; new realities become a new binding subclass.

This PR does only the type scaffolding — the new dataclasses and ABCs, plus three NotImplementedError stubs for the three storage realities. No existing class is modified, no existing caller is migrated. That keeps this PR small and reviewable; the migration is the next phase's work.

Where this fits in Epic #929

This PR is Phase 0, Wave 1 of Epic #929 — paired with #1135 (PR #1157, catalogue revision pinning), which lands in parallel. See #929 for the full phase plan.

Deferred from this PR (do not flag as missing):

  • Caller migration — issue 1.A, blocked on this PR. Existing IntrinsicAdapter callers are not yet rewritten to construct the new Adapter shape.
  • Real binding implementations — Phase 2. The three WeightsBinding subclasses raise NotImplementedError here; download/activate/deactivate/release land later.
  • Shim removal — Phase 4. The legacy Adapter ABC in adapter.py and the IntrinsicAdapterBasedComponent alias both stay until all callers are migrated.

Reviewers should expect scaffolding only: ABCs that raise on instantiation, three subclasses that raise NotImplementedError, and one alias module that re-exports Intrinsic under its new name. Anything that uses this scaffolding to actually load weights is deliberately out of scope.

What changed

File Contents
mellea/backends/adapters/_core.py New: Adapter dataclass; Identity (frozen, role-warning); IOContract ABC (build_prompt, parse); WeightsBinding ABC (prepare, activate, deactivate, release); LocalFileBinding, EmbeddedBinding, ServerMediatedBinding stubs; AdapterSchemaMismatchError (picklable).
mellea/backends/adapters/roles.py New: KNOWN_ROLES advisory frozenset, derived from _INTRINSICS_CATALOG_ENTRIES so it cannot drift.
mellea/stdlib/components/adapter_based_component/__init__.py New: re-exports Intrinsic as AdapterBasedComponent. The chosen name; the old import path remains valid.
mellea/backends/adapters/__init__.py Updated: re-exports all new public names.
test/backends/test_adapters/test_core_types.py New: 23 unit tests covering construction, ABC enforcement, frozen / hashable behaviour, stub NotImplementedError, exception format and pickle round-trip, role-warning behaviour.
test/stdlib/components/test_adapter_based_component.py New: identity test (AdapterBasedComponent is Intrinsic) and old-import-path test.

Naming-collision resolution

The issue called out a potential collision between the existing Adapter ABC (mellea/backends/adapters/adapter.py:24) and the new Adapter dataclass.

In practice there is none: the existing ABC was never exported from mellea/backends/adapters/__init__.py — only AdapterMixin, AdapterType, IntrinsicAdapter, LocalHFAdapter, fetch_intrinsic_metadata, and get_adapter_for_intrinsic were. The new Adapter dataclass lives in _core.py and is the first thing to be exported under that name on the public surface. Both classes coexist inside the package until shim removal in Phase 4; no caller's import resolves differently.

Deviations from the literal issue spec

The review pass surfaced a few quality concerns. I tightened the spec rather than ship the looser version:

  • Identity and Adapter are @dataclass(frozen=True) — the issue just said "dataclass". Frozen makes both hashable (so they can key into adapter caches) and stops __post_init__ validation being bypassed by post-construction assignment. Phase 0 is the cheapest moment to lock this down.
  • IOContract.parse -> dict[str, object] — the issue said -> dict. Bare dict is dict[Any, Any] and violates AGENTS.md §5 strict-typing on a public ABC.
  • IOContract.build_prompt(**kwargs: object) — the issue used (...). Same typing reason.
  • Stub NotImplementedError raises now name the class and point to Phase 2 ("LocalFileBinding is a Phase 0 stub; implementation lands in Epic #929 Phase 2.") so a confused user gets a useful traceback.
  • The adapter_based_component docstring asserts AdapterBasedComponent as the chosen name rather than hedging about a pending IBM rename. The "placeholder" framing in feat(intrinsics): introduce Adapter/Identity/IOContract/WeightsBinding scaffolding (Epic #929 Phase 0) #1134 is design-tracker language only, not real ambiguity; if IBM eventually picks a different upstream term, the rename is scheduled in Phase 4. Issue feat(intrinsics): introduce Adapter/Identity/IOContract/WeightsBinding scaffolding (Epic #929 Phase 0) #1134 has a correction note at the top documenting this.

Acceptance criteria

  • Adapter, Identity, IOContract, WeightsBinding importable from mellea.backends.adapters
  • IOContract ABC enforces both build_prompt and parse as abstract
  • WeightsBinding ABC enforces all four verbs as abstract
  • LocalFileBinding, EmbeddedBinding, ServerMediatedBinding raise NotImplementedError on each verb
  • AdapterSchemaMismatchError has correct attributes and message format (and is picklable)
  • from mellea.stdlib.components.adapter_based_component import AdapterBasedComponent works
  • AdapterBasedComponent is Intrinsic evaluates True
  • Old import mellea.stdlib.components.intrinsic still works
  • Naming-collision resolution documented (above)
  • KNOWN_ROLES importable; Identity(role="unknown-role") emits UserWarning; Identity(role="answerability") does not
  • All existing tests pass unchanged
  • ruff format, ruff check, mypy clean (one pre-existing cpex import-not-found in mellea/plugins/policies.py — not from this PR)

Testing

uv run pytest test/backends/test_adapters/test_core_types.py test/stdlib/components/test_adapter_based_component.py -v

25 tests pass. The full non-qualitative suite (uv run pytest -m "not qualitative") shows no regressions caused by this PR — the only failures are the pre-existing Ollama-connectivity tests that need a running server.

Attribution

  • AI coding assistants used: Claude Code

@github-actions github-actions Bot added the enhancement New feature or request label May 26, 2026
planetf1 added a commit to planetf1/mellea that referenced this pull request May 26, 2026
…ng (generative-computing#1134)

Fixes flagged by the multi-reviewer pass on PR generative-computing#1158:

- `AdapterSchemaMismatchError` now passes structured fields (not the formatted
  message) to `Exception.__init__`, so `pickle.dumps(err)` round-trips through
  worker / process boundaries. `__str__` is overridden to keep the existing
  user-visible message format. Adds a `pickle` round-trip test.
- `IOContract.parse` now returns `dict[str, object]` (was bare `dict`) and
  `IOContract.build_prompt` typed as `**kwargs: object` to satisfy the
  AGENTS.md §5 strict-typing rule on the public ABC.
- `Identity` and `Adapter` are now `@dataclass(frozen=True)` so the `__post_init__`
  validation cannot be bypassed by post-construction assignment, and both are
  hashable (usable as dict keys / set members). New tests cover the frozen and
  hashable behaviour.
- `WeightsBinding` docstring now documents the lifecycle as an informal state
  machine (prepare → activate → deactivate → release) so Phase 2 implementations
  share a contract.
- Stub bindings (`LocalFileBinding`, `EmbeddedBinding`, `ServerMediatedBinding`)
  now raise `NotImplementedError` with a message pointing to Epic generative-computing#929 Phase 2.
- `KNOWN_ROLES` is now derived from `_INTRINSICS_CATALOG_ENTRIES` rather than
  hand-copied, eliminating the silent drift risk.
- `Identity.__post_init__` carries an inline comment explaining why the runtime
  check is needed alongside the `Literal` annotation.
- The placeholder docstring on `adapter_based_component/__init__.py` no longer
  hedges — the module asserts `AdapterBasedComponent` as the chosen name.
- `test_stub_binding_subclasses_raise_not_implemented` parametrises over verbs
  for clearer per-verb failure attribution.
- `test_identity_known_role_no_warning` no longer uses `simplefilter("error")`,
  which would have failed on any unrelated `DeprecationWarning` from imports.

All 25 unit tests pass; ruff and mypy clean on the new files.

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
planetf1 added 2 commits May 26, 2026 17:43
…g scaffolding (generative-computing#1134)

Implements Epic generative-computing#929 Phase 0 (Wave 1):

- `mellea/backends/adapters/_core.py`: new composable `Adapter` dataclass,
  `Identity`, `IOContract` ABC, `WeightsBinding` ABC, three stub bindings
  (`LocalFileBinding`, `EmbeddedBinding`, `ServerMediatedBinding`), and
  `AdapterSchemaMismatchError` exception.
- `mellea/backends/adapters/roles.py`: `KNOWN_ROLES` advisory registry
  (frozenset of known role strings seeded from the intrinsics catalog).
- `mellea/stdlib/components/adapter_based_component/__init__.py`: placeholder
  module re-exporting `Intrinsic` as `AdapterBasedComponent`; old import path
  remains valid.
- `mellea/backends/adapters/__init__.py`: all new public names exported.

Naming-collision resolution: the existing `Adapter` ABC in `adapter.py` was
already absent from `__init__.py__'s public surface. The new `Adapter`
dataclass is introduced in `_core.py` and re-exported, so no namespace
collision exists on the public API. Both coexist until shim removal in 4.1.

Closes generative-computing#1134

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
…ng (generative-computing#1134)

Fixes flagged by the multi-reviewer pass on PR generative-computing#1158:

- `AdapterSchemaMismatchError` now passes structured fields (not the formatted
  message) to `Exception.__init__`, so `pickle.dumps(err)` round-trips through
  worker / process boundaries. `__str__` is overridden to keep the existing
  user-visible message format. Adds a `pickle` round-trip test.
- `IOContract.parse` now returns `dict[str, object]` (was bare `dict`) and
  `IOContract.build_prompt` typed as `**kwargs: object` to satisfy the
  AGENTS.md §5 strict-typing rule on the public ABC.
- `Identity` and `Adapter` are now `@dataclass(frozen=True)` so the `__post_init__`
  validation cannot be bypassed by post-construction assignment, and both are
  hashable (usable as dict keys / set members). New tests cover the frozen and
  hashable behaviour.
- `WeightsBinding` docstring now documents the lifecycle as an informal state
  machine (prepare → activate → deactivate → release) so Phase 2 implementations
  share a contract.
- Stub bindings (`LocalFileBinding`, `EmbeddedBinding`, `ServerMediatedBinding`)
  now raise `NotImplementedError` with a message pointing to Epic generative-computing#929 Phase 2.
- `KNOWN_ROLES` is now derived from `_INTRINSICS_CATALOG_ENTRIES` rather than
  hand-copied, eliminating the silent drift risk.
- `Identity.__post_init__` carries an inline comment explaining why the runtime
  check is needed alongside the `Literal` annotation.
- The placeholder docstring on `adapter_based_component/__init__.py` no longer
  hedges — the module asserts `AdapterBasedComponent` as the chosen name.
- `test_stub_binding_subclasses_raise_not_implemented` parametrises over verbs
  for clearer per-verb failure attribution.
- `test_identity_known_role_no_warning` no longer uses `simplefilter("error")`,
  which would have failed on any unrelated `DeprecationWarning` from imports.

All 25 unit tests pass; ruff and mypy clean on the new files.

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Copy link
Copy Markdown
Contributor

@jakelorocco jakelorocco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@planetf1, I also think that we should just have one big feature branch in generative-computing/mellea that these are merged into. I think it's super helpful to be able to review individual PRs that correspond to phases, but I don't think we want unfinished versions of these things in the code base.

Comment on lines +101 to +112
def build_prompt(self, **kwargs: object) -> Component:
"""Build the prompt component for this adapter.

Args:
**kwargs: Adapter-specific keyword arguments (e.g. ``documents=...``,
``requirement=...``). Concrete subclasses define the keys they
accept.

Returns:
Component: The constructed prompt component.
"""
...
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is actually the wrong signature. When processing an intrinsic, we take in an Intrinsic + Context. Then, we convert the context into a series of chat messages. Finally, we utilize the intrinsic's name / role to transform those chat messages into a modified series of chat messages.

  • OpenAI => pass these messages directly to the sdk like we usually would
  • HuggingFace => utilize an intrinsics specific function that formats the messages into a format the transformers sdk can use

I think this raises two problems:

  1. we need to be able to support this current flow (ie change messages and add kwargs)
  2. we need to be able to process different types of adapters; meaning, we currently only support utilizing adapters through this granite-common / intrinsic formatters code path. Our design should be able to accommodate adapters that don't need this.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the IO Contract is the correct place for this information, ie how to process the input and output. However, I struggle to see how the backends can support this with the current structure. This may not actually be an issue though.

Comment on lines +197 to +215
def prepare(self) -> None:
raise NotImplementedError(
_PHASE_2_NOT_IMPLEMENTED.format(cls="EmbeddedBinding")
)

def activate(self) -> None:
raise NotImplementedError(
_PHASE_2_NOT_IMPLEMENTED.format(cls="EmbeddedBinding")
)

def deactivate(self) -> None:
raise NotImplementedError(
_PHASE_2_NOT_IMPLEMENTED.format(cls="EmbeddedBinding")
)

def release(self) -> None:
raise NotImplementedError(
_PHASE_2_NOT_IMPLEMENTED.format(cls="EmbeddedBinding")
)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe I mentioned this in the design document, but I'd like more clarification on how this works for EmbeddedBindings. It makes sense to me that prepare / release will grab / delete just the io.yaml file for these embedded adapters.

For activate / deactivate, it seems like they will just be no-ops. But how then do we indicate that to utilize the embedded adapter we need to pass along a special chat template kwarg? Would that live in the IOContract? If so, then the IOContract and EmbeddedBinding are linked in some way and not quite as mix-and-matchable as it originally seems. Is that expected?

Comment on lines +1 to +15
"""Advisory registry of known adapter roles.

:data:`KNOWN_ROLES` is a frozenset of role strings derived from the intrinsics
catalog. It is advisory only: callers are warned (not rejected) when a role
outside this set is used, so that custom adapters and pre-release intrinsics
are not blocked.

Deriving from the catalog (rather than hand-copying) keeps the two registries
in sync automatically — adding a new entry to ``catalog.py`` automatically
registers it as a known role.
"""

from .catalog import _INTRINSICS_CATALOG_ENTRIES

KNOWN_ROLES: frozenset[str] = frozenset(e.name for e in _INTRINSICS_CATALOG_ENTRIES)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's worth explicitly mentioning then that the names of the intrinsic catalog entrees are then the defacto standard for the names of the roles.

I also think this is somewhat binding. I wonder if it's worthwhile to specifically give each catalog entry a role so that their name can differ from the role? This would be helpful when the names of our own IBM adapters change (like what we saw with "requirement-check" vs "requirement_check").

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another way to put this: there is no standard set of roles. Mellea should just create a standard way to declare the role and force others to follow (or consult us for changes) since nobody is doing so already.

Our top-level wrapper functions like check_requirement(...) are the accepted standard for what a given "role" should input/output at a given point in time (a Mellea release).

By giving all catalog entries an explicit role as well, we allow for teams we rely on to change their naming without us having to update our code. (For example, we had to make several changes to our code when "requirement-check" changed since we hardcoded off the name).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am now going on a bit of a tangent (and feel free to correct me if we've already rejected these ideas) but I think it may actually aid our design: we may want to change the field name from "role" to adapter function or functionality. Then, when generating from an AdapterBasedComponent, we could actually allow the user to specify distinct options:

  1. The user specifically wants an adapter with the given name; if it's not that adapter, we fail
  2. The user wants any adapter that fulfills a given functionality, they don't care about the specific version / implementation

@psschwei
Copy link
Copy Markdown
Member

@planetf1, I also think that we should just have one big feature branch in generative-computing/mellea that these are merged into. I think it's super helpful to be able to review individual PRs that correspond to phases, but I don't think we want unfinished versions of these things in the code base.

Are the things we're implementing here stand-alone enough that they could live in contribs until they're finished? Would be a nice way to dogfood the idea of contribs for experiments (+ eventual migration to core), but could also be way more trouble than it's worth (I haven't really thought it through so feel free to just say no)

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.

feat(intrinsics): introduce Adapter/Identity/IOContract/WeightsBinding scaffolding (Epic #929 Phase 0)

3 participants