Skip to content

feat(backends): LocalFileBinding implements verbs (PEFT/aLoRA path) + from_catalog() (Epic #929 Phase 2) - #1454

Merged
planetf1 merged 19 commits into
generative-computing:mainfrom
planetf1:worktree-issue-1141
Aug 17, 2026
Merged

planetf1 merged 19 commits into
generative-computing:mainfrom
planetf1:worktree-issue-1141

Conversation

@planetf1

@planetf1 planetf1 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Pull Request

Issue

Fixes #1141

Summary

LocalFileBinding — the weights binding for locally-downloaded LoRA/aLoRA adapters — was four NotImplementedError stubs. This PR makes it real: working prepare/activate/deactivate/release verbs, a from_catalog() constructor, and AdapterMixin.adapter_scope() wired to actually activate/deactivate weights and fire metric hooks, instead of being a Phase 1 no-op.

Two smaller, independent bugs came along for the ride because they blocked writing correct tests for the above: IntrinsicAdapter was silently resolving revision="main" instead of forwarding the catalogue's pinned SHA, and LocalFileBinding's own revision default did the same thing.

Spans and the production generation cutover are explicitly not in this PR — see "Where this fits" below for exactly where they land instead.

Where this fits — Epic #929, Phase 2

This is #1141. Two things are deliberately not here, each with its own issue and acceptance criteria — don't file findings against their absence:

Also referenced, narrower in scope:

What changed

Area Change
LocalFileBinding prepare/activate/deactivate/release implemented; from_catalog(name) constructor; release() is enforced-terminal (bind_backend/prepare raise if called after release()); prepare() retries only the failed step after a load failure instead of silently no-op'ing forever; prepare()/release() now hold the backend's exclusivity lock around the PEFT load/unload calls, matching activate/deactivate
AdapterMixin New verbs activate_peft_adapter/deactivate_peft_adapter (extracted from _generate_with_adapter_lock's inline PEFT calls — behaviour-preserving); _adapter_activation_lock() for the exclusivity these verbs document as required
AdapterMixin.adapter_scope() Real activate → yield → deactivate, with metric hooks (ADAPTER_FUNCTION_PHASE_COMPLETE, ADAPTER_FUNCTION_INVOCATION_COMPLETE) instead of a no-op; a hook-dispatch failure can never strand the adapter active or mask the real outcome (including the invocation-complete hook itself); revision in telemetry is the resolved catalogue pin, not the raw unresolved None
Telemetry Metric hooks only — see "Telemetry" below
IntrinsicAdapter Forwards the catalogue's pinned revision to obtain_io_yaml/obtain_lora instead of implicitly resolving "main"
mellea/helpers/event_loop_helper.py _run_async_in_thread's shared coroutine-close handling covers all 13 real call sites instead of 3, including its own internal wrapper coroutine (not just the caller's); narrowed to except Exception (not BaseException) to avoid a cross-thread race on KeyboardInterrupt

Telemetry: what changed and why

The original branch called start_adapter_function_span(...) directly from mellea/backends/adapters/. Wrong mechanism — since #1181, span production belongs to plugins subscribing to hooks, and library code never opens a span itself. The mechanism was more wrong than the diff made it look:

So: all inline span code is removed (mellea/telemetry/tracing.py is back to its upstream/main state), metric hooks are the only telemetry here, the doc now states the hook/plugin rule and says plainly there's no span coverage of this lifecycle until #1466, and tests assert hooks/payloads rather than span names.

Deviations from the literal issue spec

Testing

uv run pytest test/ -m "not qualitative" — 3931 passed, 21 skipped (hardware-gated), 0 failures. ruff format/ruff check/mypy clean. test/backends/test_adapters/test_local_file_e2e.py (GPU-gated) passes against a real model, including through the lock changes in this PR.

Pre-existing failures unrelated to this diff: test/package/test_dependency_isolation.py's test_hooks/test_telemetry/test_telemetry_plugins_register reproduce identically on a clean upstream/main checkout.

Testing

  • Tests added to the respective file if code was changed
  • New code has 100% coverage if code was added
  • Ensure existing tests and github automation passes

Attribution

  • AI coding assistants used

Adding a new component, requirement, sampling strategy, or tool?

If your PR adds or modifies one of the types below, check the matching box. A checklist of type-specific review items will be posted as a comment.

  • Component
  • Requirement
  • Sampling Strategy
  • Tool

NOTE: Please ensure you have an issue that has been acknowledged by a core contributor and routed you to open a pull request against this repository. Otherwise, please open an issue before continuing with this pull request.

@github-actions github-actions Bot added the enhancement New feature or request label Jul 28, 2026
@planetf1
planetf1 marked this pull request as ready for review July 28, 2026 15:20
@planetf1
planetf1 requested a review from a team as a code owner July 28, 2026 15:20
@planetf1
planetf1 requested review from AngeloDanducci, ajbozarth and jakelorocco and removed request for AngeloDanducci and ajbozarth July 28, 2026 15:20
Comment thread mellea/backends/adapters/_core.py
Comment thread mellea/backends/adapters/adapter.py Outdated
Comment thread mellea/backends/huggingface.py
Comment thread mellea/backends/huggingface.py
Comment thread mellea/backends/adapters/_core.py Outdated
Comment thread mellea/backends/adapters/_core.py
Comment thread mellea/backends/adapters/adapter.py Outdated

@AngeloDanducci AngeloDanducci left a comment

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.

A few non-blocking notes on top of @jakelorocco's threads, which already cover the main design questions plus the adapter.py:373 guard and double-backticks nits. I've replied on the 373 thread with a related second site, and left two inline notes below: a contradictory adapter_type between identity and binding in the two new tests, and a prepare() idempotency edge. For context, the diff looks correct relative to #1141, the scope boundary (production hot path deferred to 4.1) is documented honestly, and the revision-forwarding fix has good regression coverage.

Comment thread test/backends/test_adapters/test_local_file_e2e.py Outdated
Comment thread test/backends/test_adapters/test_local_file_integration.py Outdated
Comment thread mellea/backends/adapters/_core.py Outdated

@ajbozarth ajbozarth left a comment

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.

Clean scoping, the 4.1 boundary is well-documented, and the revision-forwarding fix is a good catch with regression coverage. One blocking item on the span telemetry — two equally-fine ways to resolve it below — plus two small independent notes.

Blocker: adapter-function spans are emitted inline instead of through the tracing-plugin pattern

Spans here are opened/closed by direct start_*_span/finish_*_span calls in adapter_scope/_run_adapter_phase/prepare/release. Everywhere else in Mellea, spans are emitted by a *TracingPlugin in mellea/telemetry/tracing_plugins.py subscribing to lifecycle hooks, and core code never imports the span helpers. Inline emission is only used where code is synchronous and can't fire paired hooks (the session lifecycle spans); adapter_scope is async-capable (the e2e test awaits generate_from_context inside it), so it falls under the plugin pattern.

The underlying reason it had to be inline: the ADAPTER_FUNCTION_*_COMPLETE hooks are completion-only. This is the one hook family in HookType without a pre/start sibling — every other family (generation, component, tool, streaming, sampling, validation, session) has paired members. A span needs an opener to anchor on, so completion-only hooks can feed a metric but can't drive span open/close, which forced the inline workaround. (The metrics half of this PR is fine — it consumes those hooks correctly, the same as the other *MetricsPlugins.)

Two ways to resolve, both fine by me:

Option A — do it here, properly. Add pre/post (or start/end) hook points to the adapter-function contract and move span emission into an AdapterFunctionTracingPlugin. Metrics then read the post payload (which already carries duration/outcome) instead of a separate *_COMPLETE event. Two gaps to close while you're in there:

  • The phase enum advertises generate and parse, but nothing fires them (no span, no hook, no call site). Their real production sites are the model call in _generate_from_intrinsic (huggingface.py:575) and action.parse(result) (huggingface.py:1776) — place the hook sites there so the contract doesn't ship dead phases.
  • adapter_scope's phase and invocation hooks are currently unasserted (only prepare's is tested) — the new plugin + paired hooks should get coverage like the existing pairs have.

Option B — pull span telemetry out, follow up separately. Keep the LocalFileBinding lifecycle, from_catalog, verb extraction, and the revision fix (all self-standing), drop the inline spans, and open a follow-up issue to add adapter-function tracing through the plugin pattern. Reasonable if the hook-contract redesign is more than you want in this PR — and it lines up naturally with the 4.1 cutover, which is what would fire generate/parse anyway. Whether the metrics half stays or goes with it is your call.

Small note (file not in the diff, so can't inline it)

mellea/telemetry/metrics_plugins.py — the AdapterFunctionMetricsPlugin docstring still says "No production call site fires these hooks yet." This PR adds them; please update or remove it, depending on which option above you take.

Comment thread mellea/backends/huggingface.py
@planetf1

planetf1 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

@ajbozarth thanks — you're right, and the mechanism is more wrong than the diff makes it look.

Summary

The spans in this PR are produced the wrong way — inline in mellea/backends/
rather than from a tracing plugin. I'm removing them here instead of fixing them
in place, keeping the metric hooks, and fixing the LocalFileBinding defaults,
which is the real blocker. The root cause predates this PR, so I've filed #1464,
#1465 and #1466 under #929.

How we got here

Until #1181 in June, calling the span helpers directly from library code was
the pattern here — all five backends had a bare span = start_generate_span(...),
with the machinery in mellea/telemetry/backend_instrumentation.py. #1181 moved
that onto hooks and plugins, correctly, and deleted the module.

What it didn't do was write the new rule down. docs/docs/observability/tracing.md
still contains no mention of "plugin" — it lists which spans exist, never how one
gets produced — and there's no import contract, so restoring the old pattern is
invisible to CI. The one in-code template you'll find is mellea/stdlib/session.py,
which imports the helpers directly and is a deliberate exception. Six weeks later
docs/dev/adapter_observability.md was written describing the pre-#1181 approach,
pointing at start_backend_span / start_action_span as the model to mirror. I
followed it.

The bit I'd most like fixed: even knowing the plugin rule, I couldn't have
complied. ADAPTER_FUNCTION_INVOCATION_COMPLETE and
ADAPTER_FUNCTION_PHASE_COMPLETE are the only hook family with no pre/start
sibling, so there's no event at which a plugin could open these spans. The doc
wasn't just stale — it described the only approach the current hooks allow.

In this PR

Follow-ups under #929

One correction before anyone builds on it: the parse site you pointed at,
huggingface.py:1776, is in _generate_from_raw, not the adapter path.
_generate_from_intrinsic has no action.parse call — it goes via the
IOContract. Your generate site is right.

Shout if you'd sequence any of this differently.

@planetf1

planetf1 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the comments so far. Will wait to see if the proposed approach is in the right direction on otel (@ajbozarth ) - i read this first, and more generally on the abstraction (@jakelorocco ) - thread - (since this is more than a in-pr review comment to be handled) before addressing the various other detailed comments which need changes & closing off conversation threads etc.

@planetf1

Copy link
Copy Markdown
Contributor Author

Moved to draft pending the outcome of some design discussions — see #1486.

@planetf1

planetf1 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Progress here stalled on the API-shape question in #1486 — I've posted a summary there of
where it's got to.

@ajbozarth — none of your review depends on that outcome, so I'll work through your points
in parallel rather than waiting on it.

@planetf1
planetf1 force-pushed the worktree-issue-1141 branch from 1a51900 to a3e0d36 Compare August 11, 2026 08:29
@planetf1 planetf1 changed the title feat(backends): LocalFileBinding implements verbs (PEFT/aLoRA path) + from_catalog() + OTel spans (Epic #929 Phase 2) feat(backends): LocalFileBinding implements verbs (PEFT/aLoRA path) + from_catalog() (Epic #929 Phase 2) Aug 11, 2026
@planetf1

Copy link
Copy Markdown
Contributor Author

Unblocked and ready for review. #1486 is closed as resolved, which was what this was
waiting on, and the review points are worked through.

What changed since you last looked

  • Rebased onto current main (was 27 commits behind).
  • Spans removed — around 320 lines. mellea/telemetry/tracing.py is back to its
    upstream/main state, the helpers are unimported from _core.py and adapter.py, and
    test/telemetry/test_tracing_adapter_function.py is deleted. Metric hooks stay and are
    now the only telemetry here. docs/dev/adapter_observability.md is corrected to stop
    teaching the pre-refactor(telemetry)!: move backend tracing onto plugin/hook pattern #1181 pattern and to say plainly that there's no span coverage of this
    lifecycle until feat(telemetry): add complete adapter-function lifecycle observability #1466. Tests assert the fired hook payloads instead of span names and
    span status.
  • LocalFileBinding.revision default fixed — was "main", so all ten bare
    LocalFileBinding() in mellea/ silently opted into tracking-latest, defeating fix(intrinsics): pin catalogue entries to HF revision SHAs + deduplicate requirement_check entries (Epic #929 Phase 0) #1135's
    pinning. Now None, meaning "use the catalogue's pinned revision", resolved lazily so
    it survives a re-pin.
  • Review fixes: both hook-fire sites guarded, the two contradictory adapter_type
    test identities aligned, prepare() now fails loudly when add_adapter refuses
    registration instead of leaving a half-prepared binding, TODO(#1465) at the lock, and
    the four PR-introduced double-backtick sites swept.
  • PR body and commit message rewritten to match the branch — the previous ones
    described the spans as a shipped feature, which stopped being true on 2026-07-29 and was
    never corrected.

One thing I tried and backed out, flagged rather than buried: the Adapter.__post_init__
cross-check from #1486 that would have caught @AngeloDanducci's adapter_type bug at
construction. It broke collection of eight test modules — the ten module-level Adapter
constants in rag.py/guardian.py pair an alora identity with a deliberately
unconfigured LocalFileBinding() defaulting to LoRA, and since every catalogue entry
supports both types those are placeholders, not conflicts. The check was firing on "not
configured yet". Recorded on #1516 with a NOTE(#1516) at the site; the invariant is
right, the timing isn't.

Verification — all run on the rebased head:

uv run pytest test/ -m "not qualitative"   3909 passed, 21 skipped, 4 xpassed
uv run mypy .                              Success: no issues found in 709 source files
uv run ruff format . / ruff check .        clean
pre-commit (via commit -s)                 all hooks passed

Four threads left open on purpose, all @jakelorocco's: the lock-duplication question
(both sites), the reality-specific-verbs question, and the adapter_type-duplication
question. I answered those rather than changing code, so they're yours to close or push
back on — I didn't want to resolve my own answers.

Sequencing note: #1465 is the follow-up that routes generation through adapter_scope,
and it carries @ajbozarth's lock-reentrancy point as a named constraint. It is not
"4.1" — that's #1144 (shim removal plus docs rewrite), whose scope never mentioned
generation or locking, so this would have fallen through the gap between the two.

@planetf1
planetf1 marked this pull request as ready for review August 11, 2026 08:32
@planetf1

planetf1 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

@ajbozarth — apologies, I answered your inline threads and never replied to this review
body. I'd been reading the PR through the thread list, so your summary and its two asks
sat unaddressed for a fortnight. Both are done now.

Your blocker — I took Option B. Inline spans are gone: mellea/telemetry/tracing.py
is back to byte-identical with main (the 112 added lines removed), the five helpers are
unimported from _core.py and adapter.py, and test/telemetry/test_tracing_adapter_function.py
is deleted. The LocalFileBinding lifecycle, from_catalog, verb extraction and the
revision fix stay, as you suggested. The metrics half stays too — it consumes the
completion-only hooks correctly, and it's the only observability this PR now has.

Your diagnosis of why it had to be inline was the useful part, and I've written it into
the follow-ups rather than leaving it in a review thread: the ADAPTER_FUNCTION_* family
being the one HookType family without a pre/start sibling is recorded on #1466 as
the root cause and its first work item, not a footnote. #1464 covers the rule itself —
documenting it in docs/docs/observability/tracing.md (which today contains no occurrence
of the word "plugin") and enforcing it in CI so the next occurrence fails a check instead
of needing you to catch it. #1464 blocks #1466 for exactly the reason you'd expect: the
rewritten guidance is what #1466 should be built from.

Your small note — done. AdapterFunctionMetricsPlugin's docstring no longer claims
"No production call site fires these hooks yet". It now says which phases fire and which
don't, and is explicit about a distinction I think matters: prepare, activate and
deactivate have real firing sites, but no internal Mellea path reaches them either —
they're on the public Adapter/LocalFileBinding surface, so today they fire only for a
caller driving that surface directly. generate/parse need #1465.

Two of your Option A points landed anyway, since they weren't really Option-specific:

While tracing the same "contract ships something dead" pattern I found a third instance and
fixed it here, because unlike the others it's reachable today: adapter_scope declared
outcome: Literal["success", "error"], so it could never emit "schema_error" — and
mellea.adapter_function.parse_failures increments only on schema_error. That counter
was structurally dead. adapter_scope now classifies AdapterSchemaMismatchError
separately, which matters now rather than after #1465: adapter_scope is public, so a
caller can already parse inside the scope. #1141's parse_failures criterion was otherwise
unmeetable.

One correction to the review, for the record: the parse site you pointed at
(huggingface.py:1776) is in _generate_from_raw, not the adapter path —
_generate_from_intrinsic has no action.parse call, it goes via the IOContract. Your
generate site was right.

Verification on the current head (5ee47240):

uv run pytest test/ -m "not qualitative"   3911 passed, 21 skipped, 4 xpassed
uv run mypy .                              Success: no issues found in 709 source files
uv run ruff format . / ruff check .        clean
pre-commit                                 all hooks passed

Pushed as a separate commit on top rather than a force-push, so you can diff exactly
what moved since your review.

…ve_adapter()

LocalFileBinding and IntrinsicAdapter share the same qualified-name key
space (f"{name}_{type}") in a backend's _added_adapters registry. If a
LocalFileBinding is registered for a capability, a later
resolve_adapter() call for the same name silently fails: add_adapter's
duplicate-key guard refuses the new IntrinsicAdapter, and _find_adapter
can't see the LocalFileBinding either (not an _AdapterCore), so the
caller got a bare "Adapter 'x' not found after registration" with no
hint of the cause.

Name the occupying type in both failure points: add_adapter's warning
log, and a new check in resolve_adapter's KeyError path. Doesn't change
the underlying collision -- nothing in the codebase mixes the two
registration paths for the same name today -- just makes the failure
self-diagnosing instead of opaque. Verified the new resolve_adapter test
against the pre-fix code (fails with the old bare message) before
confirming it passes on the fix.

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
adapter_scope() moved from an unconditional no-op (Phase 1) to calling
adapter.weights.activate(), which raises NotImplementedError for the
_ShimWeightsBinding every IntrinsicAdapter/LocalHFAdapter returned by
resolve_adapter() still carries. Nothing in the codebase calls
adapter_scope() with a resolved adapter today, so nothing breaks in
practice, but this was an undocumented, unpinned behaviour change on a
public method. Add a test asserting the new raise and a note in
docs/dev/adapter_observability.md pointing at generative-computing#1465, the tracked cutover
that has to reconcile shim/unprepared bindings with real activation
before production wires generation through this path.

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
…lure, guard the invocation-complete hook, and report resolved revision

Three fixes from independently verifying two external reviews (codex,
deepseek) of this PR:

- event_loop_helper.py: closing `co` on a scheduling failure wasn't
  enough -- `_wrapped()`'s own coroutine object is created before
  `run_coroutine_threadsafe` is called, so a scheduling failure left it
  unstarted and unclosed, leaking a second "coroutine was never awaited"
  warning distinct from `co`'s. Reproduced empirically before fixing.

- adapter_scope: `_fire_invocation_complete` ran unguarded in the outer
  `finally`, so a hook-dispatch failure there could turn a clean `with`
  block into a thrown error, or replace a genuine body exception with a
  telemetry one. Wrap it in try/except that logs and swallows -- the
  activate-phase hook already got this treatment, the invocation hook
  hadn't.

- adapter_scope: revision was read from the raw `.revision` attribute,
  which is `None` for a lazily-resolved LocalFileBinding even though it
  downloaded and ran against a concrete catalogue pin. Use
  `resolved_revision()` when available so telemetry doesn't mislabel an
  effectively-pinned invocation as unpinned.

Every regression guard was run against the pre-fix code and confirmed to
fail there before confirming it passes on the fix.

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
… release() as terminal

Two fixes from independently verifying two external reviews of this PR:

- LocalFileBinding.prepare(): add_adapter() sets .backend before
  load_peft_adapter() runs. If the load raised (e.g. a transient
  download/load failure), .backend was already non-None, so the
  idempotency guard (`if self.backend is not None: return`) made every
  retry a silent no-op -- the caller got no error and no working
  adapter, forever. Track the load's own success separately (`_loaded`)
  so a retry redoes only the failed step instead of re-registering (which
  would hit the backend's own duplicate-registration guard) or silently
  doing nothing. activate()/deactivate() now also check `_loaded`, since
  "registered but not loaded" is a newly-reachable, distinct state.

- LocalFileBinding.release(): the docstring already said "terminal ...
  bind_backend() + prepare() will not revive it", but the code let
  release() clear .backend back to None, so a subsequent bind_backend()
  + prepare() on a fresh backend silently succeeded anyway. Add a
  `_released` flag; bind_backend() and prepare() now raise RuntimeError
  if called after release(), matching the contract the docstring already
  claimed.

Every regression guard was run against the pre-fix code and confirmed to
fail there before confirming it passes on the fix.

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
… adapter_scope's non-atomicity

prepare()/release() call load_peft_adapter()/unload_peft_adapter(), which
mutate the same shared PEFT model state activate_peft_adapter/
deactivate_peft_adapter document "must be called while holding
_generation_lock" for -- but neither prepare() nor release() took any
lock. Wrap both in _adapter_activation_lock(), matching the other two
verb pairs.

Also attempted, then reverted, widening _adapter_activation_lock to span
adapter_scope's whole activate/body/deactivate duration (to fix
concurrent-scope interleaving, a real gap independently found in two
external reviews) using a reentrant lock. That deadlocks the moment the
body does real async generation: the actual generation work runs on
mellea's shared event-loop thread, not the calling thread, so a
same-thread RLock doesn't provide the needed exclusivity across threads
-- confirmed by running test_local_file_e2e.py, which hung. Reverted
that change; documented the non-atomicity as a known, tracked limitation
on adapter_scope's docstring instead, with a test pinning today's actual
(non-atomic) behavior so it isn't silently "fixed" back to interleaving
or silently made worse. generative-computing#1465 (wiring real generation through this
scope) has to solve the atomicity and the threading interaction
together.

Assisted-by: Claude Code
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Assisted-by: Codex
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Assisted-by: Codex
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
planetf1 added a commit to planetf1/mellea that referenced this pull request Aug 17, 2026
…puting#1464's telemetry-doc gap (generative-computing#1483)

* docs: migrate unique content from docs/dev/ before deletion

Three pieces of useful content from docs/dev/ notes that have no
equivalent in the published docs or docstrings:

- mellea/core/backend.py: replace stale docs/dev/ reference in
  generate_from_context and _generate_from_context docstrings with the
  actual rationale — action is passed separately from ctx so shared
  context is referentially equal across calls, avoiding deep-copies in
  rejection sampling and parallel requirement checks.

- docs/docs/advanced/lora-and-alora-adapters.md: add 'How automatic
  routing works' section documenting the three exceptions to aLoRA
  routing (flag, LLMaJRequirement subtype, adapter exception) and the
  ALoraRequirement escape hatch. Previously only the flag was mentioned
  with no explanation of when or why it applies.

- docs/docs/concepts/plugins.mdx: add note after component_post_error
  explaining why component_pre_create/component_post_create are not
  implemented (Component is a Protocol, not an ABC) and pointing to
  component_pre_execute as the alternative.

These changes are preparation for removing docs/dev/ entirely in a
follow-up PR (ref generative-computing#1482, generative-computing#1483).

Assisted-by: IBM Bob
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>

* docs: close gaps left by the docs/dev/ migration prep

ec304499 migrated three pieces of confirmed-current content out of
docs/dev/ ahead of its planned deletion (generative-computing#1482, generative-computing#1483), but missed
others and left dangling references that the deletion would break:

- mellea/core/backend.py, mellea/plugins/hooks/component.py: add two
  more pieces of still-current rationale that had no equivalent in
  code — the open architectural risk in the action/ctx split once
  span-based backends land, and why component_post_success/_error are
  separate hooks rather than one success/failure union.
- mellea/backends/huggingface.py, mellea/telemetry/metrics.py,
  mellea/stdlib/sampling/sampling_algos/budget_forcing_alg.py: repoint
  or drop three in-code comments that literally said
  "See docs/dev/....md". One pointed at a filename that never existed
  (generate_signature_decisions.md vs the real
  generate_ctx_signature.md); one cited a doc for content the doc
  never actually contained. Both predate this change but would have
  become silently broken once docs/dev/ is gone.
- docs/examples/*/README.md: drop seven "Related Documentation"
  bullets linking to docs/dev/ files that are about to disappear. All
  seven point at files the prior stale-marking audit (82704ae) had
  already flagged, so there's no live replacement to link to instead.

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

* docs: delete docs/dev/ now that its current content is migrated

Ref generative-computing#1482. A maintainer questioned keeping design notes outside the
published docs at all — this replaces the mark-as-stale approach
(generative-computing#1483) with removing the directory entirely, now that the two prior
commits have folded its still-current content into code docstrings,
the published lora-and-alora-adapters/plugins docs, and (for the
bare-label-vs-mellea.*-attribute convention) the mellea-telemetry
skill.

The other six files (constrained_decoding.md, intrinsics_and_adapters.md,
mellea_library.md, mify.md, spans.md, tool_calling.md) were already
confirmed stale, unfinished, or unverified against current code by the
generative-computing#1483 audit, so nothing further needed extracting from them before
deletion.

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

* docs(telemetry): document the session.py tracing exception at its import site

generative-computing#1464 item 2: the exception was already documented at each call site
(the "Called directly, not via hook" comments), but not where a reader
skimming imports for "how do I emit a span" would look first.

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

* test(telemetry): enforce the backends-never-import-tracing invariant

generative-computing#1464 item 4. Nothing currently violates this — mellea/backends/ has
zero direct telemetry.tracing imports today — but nothing was
enforcing it, which is exactly how generative-computing#1454 happened: docs/dev's stale
guidance led an implementer to add direct start_*_span calls in
mellea/backends/, caught only by review.

Resolves relative imports via AST rather than string-matching so it
doesn't false-positive on tracing_plugins.py (a different, legitimately
imported module) and doesn't miss a violation written with different
whitespace or aliasing.

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

* docs: explain how spans are produced in tracing.md

generative-computing#1464 item 1. tracing.md documented which spans exist and what
attributes they carry, but never mentioned that library code fires
hooks and mellea/telemetry/tracing_plugins.py opens the spans — the
same gap that let docs/dev/adapter_observability.md's stale
direct-span guidance go unnoticed until it caused a real revert
(PR generative-computing#1454). Also names test_tracing_import_boundary.py as the CI
enforcement for this rule.

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

* docs+fix: address ajbozarth review on generative-computing#1483

- component.py: drop observability-flavored examples from the
  ComponentPostSuccessPayload docstring so the hook contract stays
  telemetry-agnostic.
- plugins.mdx: remove the component_pre_create/post_create deferral
  note; it's contributor-facing design rationale, not something a
  plugin author needs. Kept as a one-line comment at the HookType
  definition instead.
- lora-and-alora-adapters.md: fix exception #3, stale since generative-computing#1320
  changed schema-mismatch handling to propagate rather than fall back
  to LLM-as-a-judge.

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

* refactor: split generative-computing#1464 telemetry work into its own PR

ajbozarth's review on generative-computing#1483 asked for the generative-computing#1464 work (import-boundary
test, tracing.md section, session.py comment) to move to its own PR —
generative-computing#1482 (delete docs/dev/, migrate current content) is self-contained
and shouldn't wait on generative-computing#1464's open design questions (test scope,
exception extensibility). Removes the three additions from this
branch; they continue on telemetry-1464-import-boundary with the
requested fixes applied.

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

* fix: address Codex/Deepseek review findings on generative-computing#1483

- lora-and-alora-adapters.md: the "Use the adapter in Mellea" example
  registered its CustomIntrinsicAdapter under capability "my-adapter"
  (derived from model_id) but plain req() routing hardcodes lookup by
  "requirement-check" (huggingface.py:506) -- the mismatch meant the
  example silently fell back to regular generation, never using the
  trained adapter. Pass intrinsic_name="requirement-check" explicitly.
  Also found CustomIntrinsicAdapter is a deprecated shim (generative-computing#1144) with
  no working replacement yet -- Adapter's WeightsBinding subclasses
  are all Phase 2 stubs (NotImplementedError) -- so kept the example
  on the deprecated class with a note, rather than a broken rewrite.
  Flagged this page as additional scope on generative-computing#1144 (comment added).
  Also fixed "guaranteed" phrasing for ALoraRequirement routing: a
  matching adapter still has to be registered, or it logs and falls
  back rather than erroring.
- docusaurus.config.ts: add /dev/adapter-observability and
  /dev/hook-system redirects, missed when the other 8 /dev/ pages
  got redirects. Both are live URLs this PR's deletion turns into
  404s.
- backend.py: trim generate_from_context's action docstring entry to
  match its one-line siblings; move the design rationale (referential
  equality, span-based-backend caveat) to a code comment instead of a
  public docstring, restoring a caveat about context mutability that
  was dropped during migration and could be over-read as a guarantee.

All verified against source before applying; see PR discussion.

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

* docs: correct migrated adapter guidance

Assisted-by: Codex
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>

* fix: drop orphaned comment in HookType

Alex flagged this comment as unclear why it's here. Agreed - it's a
leftover footnote from a deleted doc, not something the code needs.

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

---------

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
@planetf1
planetf1 force-pushed the worktree-issue-1141 branch from c6747dc to 94b2b7c Compare August 17, 2026 11:24
Assisted-by: Codex
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Assisted-by: Codex
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Assisted-by: Codex
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
@planetf1

Copy link
Copy Markdown
Contributor Author

Hi @jakelorocco and @ajbozarth — the earlier approvals predate a small set of lifecycle hardening changes. These isolate phase-hook failures, clarify retry/error behaviour, reject invalid binding transitions, and serialise binding prepare/release to prevent a race. The changes are covered by focused regression tests and the full non-qualitative suite. Would you mind taking a quick fresh look at the updated lifecycle code before merge?

@ajbozarth ajbozarth left a comment

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.

Some feedback from Claude

LGTM. Verified locally: 276 adapter/event-loop/hf unit tests pass, ruff + mypy clean, and every behavioral claim in the description has a matching test (terminal release, retryable prepare, the non-atomic-scope limitation via a real two-thread test). Lock ordering (_lifecycle_lock_generation_lock) is consistent with no reverse acquisition, and the except Exception narrowing in event_loop_helper is correctly reasoned.

One optional nit inline. Non-blocking.

Comment thread mellea/backends/adapters/adapter.py

@jakelorocco jakelorocco left a comment

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.

lgtm

@planetf1
planetf1 added this pull request to the merge queue Aug 17, 2026
Merged via the queue into generative-computing:main with commit 5844232 Aug 17, 2026
10 checks passed
@planetf1
planetf1 deleted the worktree-issue-1141 branch August 17, 2026 18:53
planetf1 added a commit to planetf1/mellea that referenced this pull request Aug 20, 2026
…date build_prompt message

Continues the multi-party review follow-ups on this PR (issue generative-computing#1516):

- rag.py / guardian.py: drop the ten module-level Adapter constants.
  Their only purpose (feeding io_contract= to call_intrinsic) was removed
  by the earlier commits, nothing in production references them, and the
  generative-computing#1141/generative-computing#1142 weights work builds on the binding classes (PR generative-computing#1454 merged,
  PR generative-computing#1559 open) rather than on these constants — whose underscore
  capability axis would never match _find_adapter's name-keyed scan
  anyway. test_rag_contracts.py / test_guardian_io_contract.py now look
  contracts up via get_io_contract(), exercising the registry directly.
- _core.py NOTE(generative-computing#1516): re-pointed at the remaining placeholder
  constructions (the core.py _REQUIREMENT_CHECK_ADAPTER test stub and the
  shims' _ShimWeightsBinding).
- core.py: comment no longer assumes the rag/guardian sibling constants
  are kept.
- build_prompt NotImplementedError message consolidated into the
  _BUILD_PROMPT_NOT_IMPLEMENTED constant (was five copies).
- _util.py: call_intrinsic's Raises ValueError entry now covers
  well-formed JSON with a contract-rejected top-level shape.

Assisted-by: opencode
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
planetf1 added a commit to planetf1/mellea that referenced this pull request Aug 21, 2026
…date build_prompt message

Continues the multi-party review follow-ups on this PR (issue generative-computing#1516):

- rag.py / guardian.py: drop the ten module-level Adapter constants.
  Their only purpose (feeding io_contract= to call_intrinsic) was removed
  by the earlier commits, nothing in production references them, and the
  generative-computing#1141/generative-computing#1142 weights work builds on the binding classes (PR generative-computing#1454 merged,
  PR generative-computing#1559 open) rather than on these constants — whose underscore
  capability axis would never match _find_adapter's name-keyed scan
  anyway. test_rag_contracts.py / test_guardian_io_contract.py now look
  contracts up via get_io_contract(), exercising the registry directly.
- _core.py NOTE(generative-computing#1516): re-pointed at the remaining placeholder
  constructions (the core.py _REQUIREMENT_CHECK_ADAPTER test stub and the
  shims' _ShimWeightsBinding).
- core.py: comment no longer assumes the rag/guardian sibling constants
  are kept.
- build_prompt NotImplementedError message consolidated into the
  _BUILD_PROMPT_NOT_IMPLEMENTED constant (was five copies).
- _util.py: call_intrinsic's Raises ValueError entry now covers
  well-formed JSON with a contract-rejected top-level shape.

Assisted-by: opencode
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
planetf1 added a commit to planetf1/mellea that referenced this pull request Aug 21, 2026
…date build_prompt message

Continues the multi-party review follow-ups on this PR (issue generative-computing#1516):

- rag.py / guardian.py: drop the ten module-level Adapter constants.
  Their only purpose (feeding io_contract= to call_intrinsic) was removed
  by the earlier commits, nothing in production references them, and the
  generative-computing#1141/generative-computing#1142 weights work builds on the binding classes (PR generative-computing#1454 merged,
  PR generative-computing#1559 open) rather than on these constants — whose underscore
  capability axis would never match _find_adapter's name-keyed scan
  anyway. test_rag_contracts.py / test_guardian_io_contract.py now look
  contracts up via get_io_contract(), exercising the registry directly.
- _core.py NOTE(generative-computing#1516): re-pointed at the remaining placeholder
  constructions (the core.py _REQUIREMENT_CHECK_ADAPTER test stub and the
  shims' _ShimWeightsBinding).
- core.py: comment no longer assumes the rag/guardian sibling constants
  are kept.
- build_prompt NotImplementedError message consolidated into the
  _BUILD_PROMPT_NOT_IMPLEMENTED constant (was five copies).
- _util.py: call_intrinsic's Raises ValueError entry now covers
  well-formed JSON with a contract-rejected top-level shape.

Assisted-by: opencode
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
planetf1 added a commit to planetf1/mellea that referenced this pull request Aug 25, 2026
…adapter, not a parallel argument (generative-computing#1556)

* chore(adapters): split EmbeddedIntrinsicAdapter's io_contract/weights construction onto separate lines

Prep step for Epic generative-computing#929 Phase 2: generative-computing#1516 and generative-computing#1142 both need to change one of
io_contract/weights on the same call and the same docstring paragraph. This
splits each into its own local variable/bullet, separated by blank lines,
so the two PRs no longer touch adjacent lines and can merge in either order.

No behavior change.

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>

* test(adapters): add canonical per-adapter-function output-contract registry

Introduces mellea/backends/adapters/io_contracts.py: a capability-keyed
registry of IOContract instances, keyed by the same catalog name passed to
call_intrinsic() and resolve_adapter(). Moves _ListContract (from rag.py)
next to the existing _DictContract in _core.py, adds the guardian-specific
contracts, and adds _RequirementCheckContract to consolidate the score-range
validation core.requirement_check() previously hand-rolled after each call.

This is the single source of truth a later commit wires resolve_adapter()
and the intrinsic helpers to consume, instead of each declaring its own
IOContract instance that could silently drift from the other's.

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

* refactor(intrinsics): resolve the output contract from the adapter, not a parallel argument

call_intrinsic() resolved the adapter and discarded it, taking the output
contract instead as a separate io_contract= argument that each caller
supplied from its own module-level Adapter constant. Nothing tied the two
together, so a caller could pass a contract that didn't match the adapter
resolve_adapter() actually returned.

call_intrinsic() now keeps the adapter resolve_adapter() returns and calls
its own io_contract.parse() on the raw output; the io_contract= parameter is
gone, so a mismatched pair is no longer expressible. IntrinsicAdapter and
EmbeddedIntrinsicAdapter (the shims resolve_adapter() constructs) now look
up their contract in the io_contracts registry instead of the
_ShimIOContract placeholder, which is now unreachable and removed. The ten
module-level Adapter constants in rag.py/guardian.py keep their identity and
weights as before, and read io_contract from that same registry rather than
declaring their own instance.

core.py's three helpers (check_certainty, requirement_check,
find_context_attributions) previously had no declared contract at all — the
first two skipped validation (raw json.loads), and requirement_check
hand-rolled its own score-range check after the call. All three now have a
declared contract in the registry; requirement_check's hand-rolled
validation is replaced by _RequirementCheckContract, and
find_context_attributions reads its now-list-wrapped result via ["items"].

Weights binding is untouched — this stays entirely on the io_contract axis
(the design discussion in generative-computing#1486 that split the two). The ten constants'
placeholder LocalFileBinding() and the backed-out Adapter.__post_init__
cross-check remain future work, as noted in _core.py.

Verified against real granite-4.1-3b weights: test/stdlib/components/
intrinsic/test_core.py, test_rag.py, and test_guardian.py (qualitative,
GPU-gated) all pass on this run — 3 passed + 2 pre-existing xfails
(non-deterministic attribution count, tracked separately) for core.py, 14/14
for rag.py, 6/6 for guardian.py. No test constructs a resolve_adapter()
result carrying the old placeholder contract; test_io_contracts.py's
registry-completeness test guards that going forward.

Fixes generative-computing#1516

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

* refactor(intrinsics): address code review — export registry, drop dead constants, fix real duplicate

Three independent reviewers found the same gap from different angles: the
registry this PR introduces to close the parallel-declaration problem still
had one. mellea/stdlib/requirements/requirement.py's requirement_check_to_bool()
hand-rolled the exact score-range validation just consolidated into
_RequirementCheckContract, with a comment pointing at code this PR deleted
from core.py. It now delegates to get_io_contract("requirement-check").parse(),
which is a strict improvement on its undocumented AttributeError-on-non-dict
failure mode (now the documented ValueError the contract raises).

Also, per review:

- Export get_io_contract from mellea.backends.adapters.__init__ (and __all__),
  matching the sibling adapter-package imports. Unexported, it was invisible to
  the docs pipeline (io_contracts.mdx was pruned as "not imported by
  __init__.py") and to the AGENTS.md-mandated docstring quality gate, despite
  being the function the module's own docstring designates as the mandatory
  entry point.
- Delete _UNCERTAINTY_ADAPTER and _CONTEXT_ATTRIBUTION_ADAPTER from core.py:
  nothing referenced them — their only purpose (supplying io_contract= to
  call_intrinsic) was exactly what the prior commit removed. Keep
  _REQUIREMENT_CHECK_ADAPTER, which test_core_schema.py uses as a
  resolve_adapter() stub.
- Add a regression test per shim class (test_shims.py) asserting
  IntrinsicAdapter/EmbeddedIntrinsicAdapter carry the real registry contract,
  not a placeholder. Without it, reverting get_io_contract(intrinsic_name)
  back to a stub would have passed every existing test in the file. Verified
  by temporarily reintroducing a stub object in place of get_io_contract(): both
  new tests failed as expected, then passed again once reverted.
- Enforce the registry's exhaustiveness over known_intrinsic_names() at import
  time in io_contracts.py, mirroring the existing duplicate-effective_capability
  check in catalog.py, rather than relying solely on a test.
- Correct test_core_schema.py's module docstring, which claimed resolve_adapter
  itself runs; only its stubbed return value does.

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

* refactor(intrinsics): apply remaining review suggestions and nits

Follow-up to the review response commit — the lower-severity items all three
reviewers raised, applied where they were cheap and genuinely useful:

- adapter.py: convert the remaining `.. deprecated::` RST directives on
  IntrinsicAdapter/EmbeddedIntrinsicAdapter to Google-style `Deprecated:`
  sections, matching the `Note:` conversion already done on the same
  docstrings. (CustomIntrinsicAdapter's directive is untouched by this diff
  and left alone.)
- _core.py / io_contracts.py: replace the stale "not used in Phase 1;
  implemented in Phase 2" build_prompt placeholder message — self-contradictory
  now that this module *is* the Phase 2 work — with an accurate description of
  the current state. Updated the one test asserting on the old wording.
- _core.py: module docstring now names _ListContract and explains the
  generic-vs-capability-specific split with io_contracts.py.
- io_contracts.py: get_io_contract's docstring now states its keys are
  catalog `name`s, not `effective_capability` tokens, and corrects "permissive"
  to mean permissive about which keys are present, not about the JSON shape.
  Added the matching inline comment on the fallback return.
- io_contracts.py: comment distinguishing the two AdapterSchemaMismatchError
  raise sites in _PolicyGuardrailsContract (neither key present vs. both).
  _RequirementCheckContract's docstring now names both production consumers
  it consolidates (core.py and requirement.py) with full paths.
- test_io_contracts.py: new non-GPU test feeding the recorded
  context-attribution model output (test/stdlib/components/intrinsic/testdata)
  through the real contract. The GPU-gated equivalent is xfail(strict=False)
  for unrelated non-determinism, so it gives no CI signal on schema drift;
  this closes that gap without a GPU.

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

* test(intrinsics): add contract-wiring tests and fix docstring accuracy

Applies the multi-party review findings on this PR (issue generative-computing#1516):

- test_core_contracts.py: CI-runnable wiring tests for check_certainty
  and find_context_attributions — the two helpers that moved to
  registry-contract validation (plus the items unwrap) with only
  GPU-gated qualitative/xfail coverage before
- test_requirement.py: cover the newly-typed ValueError for non-object
  JSON in requirement_check_to_bool (was an undocumented AttributeError)
- test_io_contracts.py: guard the reverse direction of the registry
  exhaustiveness invariant (orphan keys)
- core.py: the new Raises: ValueError entries were narrower than the
  contracts' actual raise paths (wrong top-level shape is also ValueError)
- io_contracts.py: the string literal after the registry assignment was a
  dead expression — dicts have no docstring; make it a comment
- test_rag_contracts.py / test_guardian_io_contract.py: the contracts no
  longer live in rag.py/guardian.py (Phase 1 -> io_contracts.py, generative-computing#1516)
- rag.py / guardian.py / core.py: state consistently that the per-helper
  Adapter constants are weights scaffolding for generative-computing#1141/generative-computing#1142, not a second
  contract source

Assisted-by: opencode
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>

* refactor(intrinsics): drop dead per-helper Adapter constants, consolidate build_prompt message

Continues the multi-party review follow-ups on this PR (issue generative-computing#1516):

- rag.py / guardian.py: drop the ten module-level Adapter constants.
  Their only purpose (feeding io_contract= to call_intrinsic) was removed
  by the earlier commits, nothing in production references them, and the
  generative-computing#1141/generative-computing#1142 weights work builds on the binding classes (PR generative-computing#1454 merged,
  PR generative-computing#1559 open) rather than on these constants — whose underscore
  capability axis would never match _find_adapter's name-keyed scan
  anyway. test_rag_contracts.py / test_guardian_io_contract.py now look
  contracts up via get_io_contract(), exercising the registry directly.
- _core.py NOTE(generative-computing#1516): re-pointed at the remaining placeholder
  constructions (the core.py _REQUIREMENT_CHECK_ADAPTER test stub and the
  shims' _ShimWeightsBinding).
- core.py: comment no longer assumes the rag/guardian sibling constants
  are kept.
- build_prompt NotImplementedError message consolidated into the
  _BUILD_PROMPT_NOT_IMPLEMENTED constant (was five copies).
- _util.py: call_intrinsic's Raises ValueError entry now covers
  well-formed JSON with a contract-rejected top-level shape.

Assisted-by: opencode
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>

* docs(intrinsics): document registered-adapter contract precedence

call_intrinsic now parses via the resolved adapter's io_contract, so a
user-registered Adapter for a catalog name takes precedence over the
io_contracts registry for parsing. State that in the registry's module
docstring and in call_intrinsic, where the single-source-of-truth claim
otherwise overreaches for the registered-adapter path.

Assisted-by: opencode
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>

* docs(intrinsics): fix increment review findings — stale docs, unguarded stub, missing negative test

Closes out the 3-party review of the follow-up increment (b88b17d..50ff6a5):

- io_contracts.py: the module docstring still described the constant
  carrying model the increment deleted (and contradicted the precedence
  paragraph three lines below); re-point at the shims and the core.py
  test stub as the construction-time readers
- _core.py: NOTE(generative-computing#1516) claimed the shims would make the backed-out
  type-agreement check fire; _ShimWeightsBinding has no adapter_type to
  compare and shim identities track the configured type — split the two
  cases
- test_core_schema.py: pin _REQUIREMENT_CHECK_ADAPTER to the registry
  instance (the is get_io_contract(...) guard test_shims.py established
  was not extended to the one non-shim construction that survived)
- test_io_contracts.py: negative test for the context-attribution
  contract's required item keys (every sibling contract has one;
  shrinking the frozenset used to pass the whole suite)
- rag.py / guardian.py: complete the Raises ValueError accuracy pass the
  increment started on core.py/_util.py — dict contracts are not a
  JSON object, list contracts are not a JSON array / non-object element
- _util.py: the new Raises clause now covers the array element case, and
  says 'same capability' (the field _find_adapter actually matches)
- small wording fixes: 'only other tests' (docs/examples e2e runs also
  exist), missing relative pronoun in two docstrings, and the issue
  reference form in the two rewritten test headers

Assisted-by: opencode
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>

* fix(intrinsics): address remaining contract review feedback

Move the requirement-check adapter stub into its test, scope registry invariants to built-in catalogue entries, and distinguish policy-guardrails exclusivity errors.\n\nAssisted-by: Codex

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>

---------

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
jakelorocco pushed a commit to jakelorocco/mellea that referenced this pull request Aug 26, 2026
…lock reentrancy (generative-computing#1555)

* test(backends): add regression coverage for generative-computing#1465 adapter_scope routing

Add tests for the intrinsic-generation lock-reentrancy deadlock, atomicity
against concurrent callers, and activated-adapter assertions (rather than a
generation-succeeded smoke test) ahead of routing _generate_from_intrinsic
through adapter_scope. Also drop test_local_file_e2e.py's deferral note and
have it generate directly against the real model while the adapter is
active, instead of through the standard path, which always deactivates
adapters first.

These tests target a _generate_intrinsic_with_adapter_scope() helper that
does not exist yet; the implementation follows in the next commit.

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

* test(backends): surface thread exceptions in the concurrent-callers test

test_concurrent_intrinsic_calls_cannot_observe_each_others_adapter spawned
worker threads without capturing what they raised. threading.Thread swallows
exceptions from its target by default, so a totally broken
_generate_intrinsic_with_adapter_scope (e.g. missing before this issue's
fix) left `mismatches` empty and the test passed vacuously — confirmed by
running it against the pre-fix code, where it passed despite every worker
thread raising AttributeError. Capture and assert on thread exceptions so
the test actually fails when the call raises.

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

* fix(backends): route intrinsic generation through adapter_scope (generative-computing#1465)

_generate_from_intrinsic called _generate_with_adapter_lock directly,
bypassing AdapterMixin.adapter_scope entirely -- the model call ran outside
the scope whose activate()/deactivate() lifecycle generative-computing#1141 built for this. Add
_generate_intrinsic_with_adapter_scope, which builds an ephemeral Adapter
around a new _IntrinsicPeftBinding (driving this backend's own
load_peft_adapter/activate_peft_adapter/deactivate_peft_adapter -- the same
verbs the old code called directly) and runs the generate call inside
adapter_scope(), so activation and deactivation now fire the
ADAPTER_FUNCTION_PHASE_COMPLETE/ADAPTER_FUNCTION_INVOCATION_COMPLETE hooks,
and deactivation is guaranteed even if generation raises.

This reactivates the lock-reentrancy deadlock generative-computing#1454's review flagged and
_adapter_activation_lock()'s docstring tracked as a TODO(generative-computing#1465):
adapter_scope()'s activate()/deactivate() re-acquire _generation_lock (via
_adapter_activation_lock()) from inside the same critical section
_generate_intrinsic_with_adapter_scope holds it for. Fix by making
_generation_lock a threading.RLock -- reentrant acquisition on the same
thread succeeds instead of deadlocking, since this method only ever runs
inside a single asyncio.to_thread worker thread. Holding the lock for the
whole activate -> generate -> deactivate section (not just around
activation) also closes the atomicity gap adapter_scope()'s own docstring
flagged as latent: a concurrent caller's activate() can no longer interleave
with this call's generation.

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

* test(backends): assert on the adapter-function hook payload, not just that hooks fire

_generate_intrinsic_with_adapter_scope's own docstring claims activation now
fires ADAPTER_FUNCTION_PHASE_COMPLETE/ADAPTER_FUNCTION_INVOCATION_COMPLETE,
but nothing asserted on the payload -- only on lock/activation state. Add a
test using the existing capture_adapter_hooks()/hook_payloads() helpers
(already used by test_local_file_integration.py for the sibling binding) to
pin phases, outcome, name, adapter_type, binding_type, and revision.

This fails against the current code: binding_type is the invented
"intrinsic_legacy" instead of the "local_file" reality this binding actually
is, and revision is unresolved to None (mislabelled "unpinned") despite the
adapter being pinned. Both fixed in the next commit.

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

* fix(backends): fix telemetry mislabelling in the intrinsic adapter_scope path

Address code-review findings on the generative-computing#1465 adapter_scope routing:

- Thread the catalogue revision through _IntrinsicPeftBinding instead of
  leaving it unset. adapter_scope() fell back to getattr(..., None) for
  revision, so every intrinsic invocation was reported as revision=None
  ("unpinned") despite IntrinsicAdapter.intrinsic_metadata.revision always
  being a pinned catalogue SHA.
- Use "local_file" for binding_type instead of inventing "intrinsic_legacy".
  This binding loads LoRA/aLoRA weights from local disk via PEFT -- the same
  reality LocalFileBinding reports -- so it must share that value rather than
  fragment the weights-reality metric dimension into two series for one
  reality.
- Reuse adapter.identity instead of rebuilding an Identity per call. Rebuilding
  it re-ran Identity.__post_init__'s KNOWN_CAPABILITIES check on every
  generate call, firing a UserWarning on every call for hyphenated catalogue
  names (including "requirement-check", the auto-routed ALoraRequirement
  adapter) instead of once at registration.
- Split load_peft_adapter into prepare() and leave only activate_peft_adapter
  in activate(), mirroring LocalFileBinding's phase boundary. Previously the
  weights load (can take seconds on first use) was folded into the
  "activate" phase metric, making its duration bimodal and incomparable
  across the two bindings that feed the same metric.

Also reconciles adapter_scope()'s docstring, which still claimed "no caller
combines concurrent adapter_scope() calls today" and that generative-computing#1465 (this PR)
"has to solve the atomicity and threading interaction" -- both false once
_generate_intrinsic_with_adapter_scope exists. Documents that ADAPTER_FUNCTION_*
hook dispatch is synchronous and runs under _generation_lock, and that
phase="generate"/"parse" are not emitted (deferred to generative-computing#1466). Drops the
docstring's imprecise "safe because of a single asyncio.to_thread worker
thread" framing (to_thread uses a pool with many threads; the safety property
is that each invocation is wholly synchronous on whichever thread it lands on).

Renames _NoIntrinsicIOContract to _UnusedIOContract and drops embedded issue
numbers from runtime exception text (kept in docstrings instead). Types
_generate_intrinsic_with_adapter_scope's generate_func/return via a TypeVar.

The new test from the previous commit (asserting on the actual hook payload)
now passes.

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

* test(backends): address remaining code-review nits and suggestions

- test_local_file_e2e.py: restore a generate_from_context() call after
  adapter_scope exits as a composition smoke test (the direct model.generate()
  edit proved activation but dropped the file's only coverage of mellea's own
  generation path composing with a scoped-and-released adapter). Also drops
  the now-tautological isinstance(value, str) assertion (value now always
  comes from tokenizer.decode(), which is always str) in favour of asserting
  non-empty content.
- test_huggingface_unit.py: except BaseException -> except Exception in the
  concurrency test, so a real KeyboardInterrupt/SystemExit during the test
  isn't captured as a spurious "error". Tighten the two deactivation
  assertions to also check the actual set_adapter([]) call, not just the
  mock's derived active_adapters() state, so a change to the deactivation
  verb's call shape would break the test loudly instead of the mock silently
  going stale.

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

* docs(backends): note that adapter_scope() still can't activate the standard generation path

_generate_with_adapter_lock (the standard, non-intrinsic path) always
deactivates any adapter before generating, so a caller composing
generate_from_context() with adapter_scope() silently generates against the
base model regardless. Pre-existing gap, not introduced by the intrinsic
routing this PR adds -- documented here since the original note recording it
(in test_local_file_e2e.py's module docstring) was removed when that file's
own coverage moved to a direct model.generate() call.

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

* docs(backends): correct hook-dispatch and lock-ownership docstrings

Hook dispatch runs the hook coroutines on the shared _EventLoopHandler event-loop thread while the calling thread blocks; the old wording ("runs synchronously on the calling thread") hid that, along with the hazard it creates: a subscriber that re-enters a _generation_lock-holding path deadlocks the backend, which an RLock cannot prevent because reentrance only helps the owning thread. Also name _IntrinsicPeftBinding as the intrinsic-path verb acquirer in the _adapter_activation_lock() caller list (it is not LocalFileBinding's path), and qualify the to_thread claim to production invocations.

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Assisted-by: opencode

* test(backends): pin intrinsic error-path and prepare-failure telemetry

The generic adapter_scope failure contract is pinned in test_adapter_scope.py, but the intrinsic payload on an error outcome (name, adapter_type, binding_type, revision) and the prepare-failure semantics (no hooks fire, activation state unchanged, next call still succeeds) were untested despite this PR establishing them.

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Assisted-by: opencode

* test(backends): drop tautological decode assertion from the GPU e2e

model.generate returns the prompt plus completion, so the decode always contained the prompt text and the strip() assertion could never fail for the reason it purported to guard. The real proof is the active-adapter assertion straddling the raw generate call.

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Assisted-by: opencode

* docs(backends): align the rewritten hook-dispatch docstrings with the surrounding text

Second-pass fixes after re-review: the new hook-dispatch paragraph contradicted the untouched reference-example sentence ("never re-enters the event loop") two paragraphs away, so qualify it to the scope body and name the hook dispatches as the only loop traffic; state the hook-dispatch mechanism once in adapter_scope() and cross-reference it from the HF method; say the nesting fact once in the _adapter_activation_lock() docstring; and include prepare in the section name everywhere, matching the code (the lock wraps weights.prepare()).

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Assisted-by: opencode

* test(backends): name the new intrinsic telemetry tests verb-first

Match the family scheme (test_generate_intrinsic_with_adapter_scope_<behaviour>): _error_hook_payload and _prepare_failure_no_hooks name observations, not behaviours.

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Assisted-by: opencode

* docs(backends): address final re-review nits across the docstrings

Third-round fixes: fold the unearned Attributes: section out of _IntrinsicPeftBinding (AGENTS.md allows it only for transformed/computed storage); let the HF method docstring cross-reference the consequences instead of restating them; correct the over-claim that the unit tests are 'also single-threaded' (the two-thread test runs one invocation per thread); add prepare to the lock-section naming in the two test docstrings that predate the 1dfe7fa alignment; reword the concurrent test away from the now-merged 'latent until generative-computing#1465' framing; and state precisely which attributes _make_fake_intrinsic_adapter exposes for the method versus for realism.

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Assisted-by: opencode

* refactor(backends): shrink _generate_with_adapter_lock to its standard-path contract

With intrinsic generation routed through adapter_scope, no production caller still passed an adapter name to this method (all three standard call sites passed ""); the load/activate branch survived only as test-covered residue advertising a capability the standard path does not have. The method now takes no adapter name: deactivate-any-adapter, assert base model, generate, assert. The two unit tests that pinned the removed branch collapse into one pinning the remaining contract. Granite Switch's embedded activation is a separate mechanism (EmbeddedBinding.apply_activation, generative-computing#1018) and does not use this method; the orphan was orphaned by this PR, so the cleanup lands with it.

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Assisted-by: opencode

* docs(backends): frame apply_activation as generative-computing#1018/generative-computing#1142 future work; pin the shrunken method's forwarding

Delta-review fixes: the new docstring cited apply_activation as a present route but the verb does not exist in-tree (AGENTS.md: verify transcribed identifiers against source), so it is now framed as the designed generative-computing#1018 mechanism landing via generative-computing#1142; the method gains the Args/Returns blocks its sibling lock-holding helper documents; and the shrunken-contract test now captures and asserts the generate_func return so its name matches what it pins (the deactivate-then-generate ordering stays a body fact, stated as such in the docstring).

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
Assisted-by: opencode

* test(backends): cover real adapter generation under lock

Assisted-by: Codex
Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>

---------

Signed-off-by: Nigel Jones <jonesn@uk.ibm.com>
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(backends): LocalFileBinding implements verbs (PEFT/aLoRA path) + from_catalog() (Epic #929 Phase 2)

5 participants