Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions mellea/backends/adapters/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from .capabilities import KNOWN_CAPABILITIES
from .catalog import validate_revision
from .io_contracts import get_io_contract

__all__ = [
"KNOWN_CAPABILITIES",
Expand All @@ -44,5 +45,6 @@
"WeightsBinding",
"fetch_intrinsic_metadata",
"get_adapter_for_intrinsic",
"get_io_contract",
"validate_revision",
]
112 changes: 94 additions & 18 deletions mellea/backends/adapters/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,18 @@
- :class:`IOContract` — ABC for prompt building and output parsing
- :class:`WeightsBinding` — pluggable ABC for weights lifecycle management

Also provides :class:`LocalFileBinding`, two stub :class:`WeightsBinding`
subclasses (:class:`EmbeddedBinding`, :class:`ServerMediatedBinding`), and
:class:`AdapterSchemaMismatchError`.
Also provides:

- :class:`LocalFileBinding`
- :class:`EmbeddedBinding` — stub :class:`WeightsBinding` subclass
- :class:`ServerMediatedBinding` — stub :class:`WeightsBinding` subclass
- :class:`AdapterSchemaMismatchError`
- :class:`_DictContract`, :class:`_ListContract` — generic, capability-agnostic
:class:`IOContract` implementations that validate required keys on a JSON
object or a JSON array of objects, respectively. Capability-*specific*
contracts (e.g. the guardian adapters' nested-key shapes) live in
:mod:`~mellea.backends.adapters.io_contracts` instead, alongside the
registry that maps every catalogued adapter function to its contract.

Note:
The existing :class:`~mellea.backends.adapters.adapter.Adapter` ABC in
Expand Down Expand Up @@ -44,6 +53,11 @@
"{cls} is a Phase 0 stub; implementation lands in Epic #929 Phase 2."
)

_BUILD_PROMPT_NOT_IMPLEMENTED = (
"build_prompt is not implemented; request construction still goes "
"through the legacy formatter/rewriter path, not IOContract."
)


class AdapterSchemaMismatchError(Exception):
"""Raised by :meth:`IOContract.parse` when output cannot satisfy the declared contract.
Expand All @@ -52,24 +66,33 @@ class AdapterSchemaMismatchError(Exception):
name (str): Name of the adapter whose contract was violated.
observed_keys (frozenset[str]): Keys present in the observed output.
expected_keys (frozenset[str]): Keys required by the contract.
reason (str | None): Capability-specific explanation of the mismatch.
"""

def __init__(
self, name: str, observed_keys: frozenset[str], expected_keys: frozenset[str]
self,
name: str,
observed_keys: frozenset[str],
expected_keys: frozenset[str],
reason: str | None = None,
) -> None:
self.name = name
self.observed_keys = observed_keys
self.expected_keys = expected_keys
# Pass the structured fields (not the formatted message) to Exception so
# that ``self.args`` round-trips through ``pickle`` / ``copy`` — the default
# ``Exception.__reduce__`` reconstructs by calling ``cls(*self.args)``.
# Preserve the existing three-item ``args`` shape for callers and
# cross-version pickle compatibility. ``reason`` lives in instance state,
# which pickle restores after calling this constructor with ``args``.
self.reason = reason
super().__init__(name, observed_keys, expected_keys)

def __str__(self) -> str:
return (
message = (
f"Adapter '{self.name}' output cannot satisfy declared contract. "
f"Observed keys: {self.observed_keys}; expected: {self.expected_keys}."
)
if self.reason is not None:
message += f" Reason: {self.reason}."
return message


@dataclass(frozen=True)
Expand Down Expand Up @@ -156,9 +179,7 @@ def __init__(self, name: str, required_keys: frozenset[str]) -> None:
self._required_keys = required_keys

def build_prompt(self, **_kwargs: object) -> Component:
raise NotImplementedError(
"build_prompt is not used in Phase 1; implemented in Phase 2."
)
raise NotImplementedError(_BUILD_PROMPT_NOT_IMPLEMENTED)

def parse(self, raw: str) -> dict[str, object]:
"""Parse and validate dict-shaped adapter output.
Expand Down Expand Up @@ -186,6 +207,63 @@ def parse(self, raw: str) -> dict[str, object]:
return data


class _ListContract(IOContract):
"""Validate list-of-dicts adapter output and wrap it under key `"items"`.

Each item in the list is checked for the declared required keys. The
validated list is returned wrapped in `{"items": [...]}` so that
:func:`~mellea.stdlib.components.intrinsic._util.call_intrinsic` can always
return a plain `dict`.

Args:
name: Adapter capability name; included in
:class:`~mellea.backends.adapters.AdapterSchemaMismatchError` messages.
required_item_keys: Keys that must be present in every item dict.
"""

def __init__(self, name: str, required_item_keys: frozenset[str]) -> None:
self._name = name
self._required_item_keys = required_item_keys

def build_prompt(self, **_kwargs: object) -> Component:
raise NotImplementedError(_BUILD_PROMPT_NOT_IMPLEMENTED)

def parse(self, raw: str) -> dict[str, object]:
"""Parse and validate a list-of-dicts adapter output.

Args:
raw (str): Raw JSON string from the model.

Returns:
dict[str, object]: `{"items": [list of validated dicts]}`.
An empty list parses to `{"items": []}`.

Raises:
ValueError: When *raw* is not valid JSON, is not a JSON array, or
contains a non-object element.
AdapterSchemaMismatchError: When any item is missing a required key.
"""
data = json.loads(raw)
if not isinstance(data, list):
raise ValueError(
f"Adapter '{self._name}' output must be a JSON array, "
f"got {type(data).__name__}."
)
for item in data:
if not isinstance(item, dict):
raise ValueError(
f"Adapter '{self._name}' output array must contain only JSON "
f"objects, got a {type(item).__name__} element."
)
observed = frozenset(item.keys())
missing = self._required_item_keys - observed
if missing:
raise AdapterSchemaMismatchError(
self._name, observed, self._required_item_keys
)
return {"items": data}


class WeightsBinding(abc.ABC):
"""Abstract lifecycle interface for adapter weights.

Expand Down Expand Up @@ -653,10 +731,8 @@ class Adapter:
# right invariant — the two feed different lookup paths (registration and the
# verbs key on the binding's `qualified_name`; `_find_adapter` scans on the
# identity) and both return `None` on a miss, so a disagreement surfaces as
# "adapter not found" far from its cause. But it cannot be enforced yet: the
# ten module-level `Adapter` constants in `stdlib/components/intrinsic/rag.py`
# and `guardian.py` pair an `alora` identity with a bare, deliberately
# unconfigured `LocalFileBinding()` that defaults to LoRA. Every catalogue
# entry supports both types, so those are placeholders rather than genuine
# conflicts, and the check fired on "not configured yet". Enforce it once
# #1516 gives those constants real bindings.
# "adapter not found" far from its cause. But it cannot be enforced yet:
# the deprecated shims carry a `_ShimWeightsBinding` with no `adapter_type`
# to compare at all (their identity tracks the configured type). Enforce
# the check once those constructions carry real, typed bindings (the shims
# retire in #1144).
75 changes: 37 additions & 38 deletions mellea/backends/adapters/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,11 @@
Adapter as _AdapterCore,
AdapterSchemaMismatchError,
Identity,
IOContract,
LocalFileBinding,
WeightsBinding,
)
from .catalog import AdapterType, fetch_intrinsic_metadata
from .io_contracts import get_io_contract


class Adapter(abc.ABC):
Expand Down Expand Up @@ -96,20 +96,6 @@ def get_local_hf_path(self, base_model_name: str) -> str:
...


class _ShimIOContract(IOContract):
"""Phase 1 placeholder; Phase 2 (issue #1137) implements real I/O."""

def build_prompt(self, **kwargs: object): # type: ignore[override]
raise NotImplementedError(
"Phase 2 (issue #1137) — IOContract not yet implemented"
)

def parse(self, raw: str) -> dict[str, object]:
raise NotImplementedError(
"Phase 2 (issue #1137) — IOContract not yet implemented"
)


class _ShimWeightsBinding(WeightsBinding):
"""Phase 1 placeholder; Phase 2 (see epic #929) wires in real lifecycle."""

Expand Down Expand Up @@ -137,7 +123,7 @@ def release(self) -> None:
class IntrinsicAdapter(LocalHFAdapter, _AdapterCore):
"""Deprecated shim for adapters that implement adapter functions.

.. deprecated::
Deprecated:
Use :class:`~mellea.backends.adapters.Adapter` directly.
`IntrinsicAdapter` will be removed in a future release (Epic #929,
issue #1144).
Expand Down Expand Up @@ -171,12 +157,13 @@ class IntrinsicAdapter(LocalHFAdapter, _AdapterCore):
adapter_type (AdapterType): The adapter type (`LORA` or `ALORA`).
config (dict): Parsed I/O transformation configuration for the adapter function.

.. note::
`identity`, `io_contract`, and `weights` are Phase 1 internal scaffolding
populated in `__init__` to satisfy the new :class:`~mellea.backends.adapters.Adapter`
protocol. They are not meaningful consumer-facing attributes; `io_contract` and
`weights` raise :exc:`NotImplementedError` and will be replaced in Phase 2
(issues #1137, #1141).
Note:
`identity`, `io_contract`, and `weights` are internal scaffolding populated
in `__init__` to satisfy the :class:`~mellea.backends.adapters.Adapter`
protocol; they are not meaningful consumer-facing attributes. `io_contract`
is the real, declared contract for `intrinsic_name` (issue #1516); `weights`
remains the Phase 1 `_ShimWeightsBinding` placeholder and raises
`NotImplementedError` until Phase 2 (issue #1141) replaces it.
"""

def __setattr__(self, name: str, value: object) -> None:
Expand Down Expand Up @@ -256,6 +243,9 @@ def __init__(
self.config: dict = config_dict

# Populate the new Adapter triple so isinstance(self, _AdapterCore) holds.
# io_contract comes from the same registry resolve_adapter() consults
# (see issue #1516), not a placeholder. weights stays the Phase 2
# _ShimWeightsBinding placeholder; that axis is #1141/#1142.
_AdapterCore.__init__(
self,
identity=Identity(
Expand All @@ -265,7 +255,7 @@ def __init__(
else "lora",
capability=intrinsic_name,
),
io_contract=_ShimIOContract(),
io_contract=get_io_contract(intrinsic_name),
weights=_ShimWeightsBinding(),
)

Expand Down Expand Up @@ -891,7 +881,7 @@ def _find_adapter(
class EmbeddedIntrinsicAdapter(_AdapterCore):
"""Deprecated shim for adapter functions embedded in a Granite Switch model.

.. deprecated::
Deprecated:
Use :class:`~mellea.backends.adapters.Adapter` directly.
`EmbeddedIntrinsicAdapter` will be removed in a future release
(Epic #929, issue #1144).
Expand All @@ -915,12 +905,18 @@ class EmbeddedIntrinsicAdapter(_AdapterCore):
config (dict): Parsed I/O transformation configuration.
technology (str): `"lora"` or `"alora"`.

.. note::
`identity`, `io_contract`, and `weights` are Phase 1 internal scaffolding
populated in `__init__` to satisfy the new :class:`~mellea.backends.adapters.Adapter`
protocol. They are not meaningful consumer-facing attributes; `io_contract` and
`weights` raise :exc:`NotImplementedError` and will be replaced in Phase 2
(issues #1137, #1142).
Note:
`identity`, `io_contract`, and `weights` are internal scaffolding
populated in `__init__` to satisfy the `Adapter` protocol; they are
not meaningful consumer-facing attributes.

- `identity`: always a real value.

- `io_contract`: the real, declared contract for `intrinsic_name`
(issue #1516); no longer a placeholder.

- `weights`: Phase 1 `_ShimWeightsBinding` placeholder; raises
`NotImplementedError` until issue #1142 replaces it.
"""

def __setattr__(self, name: str, value: object) -> None:
Expand Down Expand Up @@ -958,15 +954,18 @@ def __init__(self, intrinsic_name: str, config: dict, technology: str = "lora"):

# Populate the new Adapter triple so isinstance(self, _AdapterCore) holds.
# technology is validated above; cast to the Literal type mypy expects.
identity = Identity(
name=intrinsic_name,
adapter_type=cast(Literal["lora", "alora"], technology),
capability=intrinsic_name,
)

io_contract = get_io_contract(intrinsic_name)

weights = _ShimWeightsBinding()

_AdapterCore.__init__(
self,
identity=Identity(
name=intrinsic_name,
adapter_type=cast(Literal["lora", "alora"], technology),
capability=intrinsic_name,
),
io_contract=_ShimIOContract(),
weights=_ShimWeightsBinding(),
self, identity=identity, io_contract=io_contract, weights=weights
)

@staticmethod
Expand Down
Loading
Loading