PML-325 MerLinProcessor Cleaning - #285
Conversation
Introduce CallState, a typed carrier for the per-call runtime state that forward_async() currently threads through chunk orchestration and job polling as an anonymous mutable dict: - Named mutation helpers: request_cancel, record_job_id (deduplicating), set_current_status, add_planned_chunks, mark_chunk_started/finished, status_snapshot. - Frozen JobStatus value object for the last observed backend job status. - Thread ownership of each field documented on the class; counters guarded by an internal lock. Not yet wired into MerlinProcessor; covered by dedicated no-cloud unit tests (dedup, snapshot transitions, cancel propagation, list identity).
Replace the anonymous mutable state dict in forward_async(), chunk orchestration, chunk execution, and job polling with the typed CallState object, removing all stringly-typed state keys from the processor: - forward_async() builds CallState.new(); future.status() delegates to status_snapshot() and future.job_ids aliases the live CallState list. - Chunk counters move from the processor-wide lock to CallState's own lock; job id deduplication moves inside record_job_id. - Public future.status() / cancel_remote() / job_ids contracts unchanged. - Migrate processor unit tests to the typed API (fixture + mechanical accessor updates only); behavior assertions untouched.
Extract chunk orchestration and remote job execution out of MerlinProcessor into focused units in merlin/core/execution.py: - BatchChunker owns batch splitting (split_batch), bounded-concurrency chunk pooling, job labeling, and output stitching. - RemoteJobRunner owns one chunk end to end: fresh RemoteProcessor per attempt, sampler iteration prep, command selection (probs vs sampling), job-name capping, Perceval 1.2 iterator normalization, submission retries, polling, and result mapping. MerlinProcessor becomes a coordinator: _run_chunks_pooled, _run_chunk, _submit_job, and _poll_job are thin delegates with unchanged signatures, and dependencies are injected as bound methods and deferred lambdas so live attribute mutation and monkeypatched processor methods behave exactly as before. Threading semantics (daemon chunk threads, cooperative cancel, deadline checks, polling backoff) are unchanged; no concurrency-model redesign. Add no-cloud unit tests exercising both units directly with fake jobs, samplers, and processors (splitting, concurrency bounds, stitching order, retry/give-up, cancellation, deadlines, command selection).
Replace the runtime monkey-patching of cancel_remote/status/job_ids onto plain torch.futures.Future instances with an explicit typed async handle: - Add MerlinFuture(torch.futures.Future) declaring the async contract in code and types: job_ids (live view of the CallState list), status() (same payload as before), and cancel_remote() (same cancellation and CancelledError semantics). - forward_async() now constructs and returns MerlinFuture; the closure helpers and instance-attribute assignment are gone. The unreachable CancelledError import fallback is dropped. - Export MerlinFuture from the package root for discoverability. Behavior is unchanged: the handle is still a torch.futures.Future (isinstance contract preserved), wait/done/then work as before, and the status()/job_ids/cancel_remote() surfaces are identical. Add no-cloud unit tests for the handle: return type, declared-not-patched contract, status payload shape and transitions, live job id exposure, cancellation propagation, and synchronous forward() built on the handle.
Introduce merlin/core/perceval_adapter.py, a stateless facade that owns every direct access to Perceval-internal APIs: - Token extraction (handler token/_token/auth_token attributes, Bearer header parsing, RemoteConfig fallback) and RPC handler URL access. - Remote processor cloning, session-based construction, and backend capability extraction. - Processor configuration (set_circuit/with_input/min photons filter), circuit copying, and remote shot estimation. - Sampler creation with iterations, async/sync command dispatch (probs/sample_count/samples), and the Perceval 1.2 iterator serialization shim. - Job status normalization into a JobStatusSnapshot dataclass, result retrieval, and best-effort cancellation. - Local processor rebuild with LocalExperimentSnapshot capture/restore of experiment private state (ports, detectors, mode types, heralds, postselection). Also adds Merlin-specific exceptions TokenExtractionError(ValueError) and RemoteJobFailedError(RuntimeError), subclassing the historical types so existing error-handling contracts are preserved. Not yet wired into MerlinProcessor or the execution units; covered by 45 no-cloud unit tests using fakes and monkeypatching.
MerlinProcessor no longer touches Perceval handler internals directly: - _extract_rp_token and _clone_remote_processor become one-line delegates to PercevalAdapter (kept as processor entry points so existing patch targets and callers are unaffected). - Session-based processor construction and backend capability extraction go through PercevalAdapter.build_from_session / get_backend_capabilities. - The missing-token failure now raises TokenExtractionError, a ValueError subclass carrying the same message, so existing error contracts hold.
RemoteJobRunner and the local execution path no longer touch Perceval sampler commands or job internals directly: - Sampler construction with iterations, async/sync command dispatch, and the Perceval 1.2 iterator shim go through PercevalAdapter (the runner's private copy of the shim is removed). Command selection and job naming remain Merlin logic in the runner. - Processor preparation goes through configure_processor; a set_input primitive is added because the local path must restore experiment metadata between installing the circuit and setting the input. - Job polling reads a normalized JobStatusSnapshot instead of poking is_complete/is_failed/status.* attributes; results and best-effort cancellation (including cancel_all) go through the adapter. - Failed jobs now raise RemoteJobFailedError, a RuntimeError subclass with the same message, preserving error contracts. Test changes are mechanical patch-target retargets only: the 9 sites patching (or borrowing) Sampler on the merlin_processor/execution modules now reference the perceval_adapter module where the symbol lives. No behavioral assertions changed.
The local execution path no longer touches Perceval experiment internals: - _LocalExperimentMetadata and its snapshot/restore statics are removed; PercevalAdapter.rebuild_local_processor / LocalExperimentSnapshot / restore_experiment own the experiment private state handling (_in_ports, _out_ports, mode types, heralds, _postselect, _circuit_changed). - _create_fresh_local_processor stays as the processor entry point and delegates to the adapter, preserving existing patch targets. - Circuit copying and estimator configuration/dispatch in estimate_required_shots_per_input also go through the adapter (the requests.ReadTimeout retry policy remains Merlin logic). With this, all direct Perceval handler, sampler, job, and experiment access is localized in merlin/core/perceval_adapter.py.
Docs: - Document MerlinFuture in the merlin_processor API reference (new public export) and rewrite the forward_async section that still described the removed monkey-patched future contract. Sphinx builds clean with -W --keep-going -n. - Complete NumPy docstrings (Parameters/Returns/Raises) on CallState methods and PercevalAdapter accessors that previously had one-line summaries only. Tests: - Add one-line docstrings to the 65 new tests missing them, matching the existing suite pattern. - Strengthen trivial assertions: exception tests now verify catch-as-historical-type semantics instead of bare issubclass, and the failed-job test asserts RemoteJobFailedError specifically. - Fix 4 latent ruff S106 findings in the adapter tests (fake-token literals restructured); ruff is now verified clean over merlin/ and tests/core/ without output truncation. - Replace a busy-wait with a 1ms sleep poll in the cross-thread cancel test.
|
@claude review |
|
Claude finished @CassNot's task in 4m 26s —— View job Review — PML-325 MerlinProcessor Cleaning
Overall this is a genuinely good refactor. The commit-per-step structure holds up, the units are cleanly injected, and the extractions are faithful line-for-line against the base — I diffed Below are the things I'd want addressed, roughly in order of importance. Note on verification: I was not able to run 1.
|
| Method | State |
|---|---|
BatchChunker.split_batch (execution.py:73) |
one-line |
BatchChunker.run_chunks (:83) |
one-line |
RemoteJobRunner.run_chunk (:208) |
one-line |
RemoteJobRunner.poll_job (:359) |
4 lines, no sections |
RemoteJobRunner.submit_job (:305) |
full — but see #5 |
The net effect is that the complete _poll_job documentation now lives on the thin wrapper in merlin_processor.py:1499-1531 while the implementation carries a stub.
5. RemoteJobRunner.submit_job: untyped signature + a parameter that is always the same value
def submit_job(self, sampler, nsample, job_base_label, capped_name):merlin/core/execution.py:305 — no annotations (AGENTS.md: "a typed function signature"), and capped_name is always self._capped_name at the only real call site (:267). The parameter exists solely to preserve MerlinProcessor._submit_job's 4-arg signature for the existing tests. Type it, and consider dropping the parameter (let the wrapper absorb the arity difference) so the runner doesn't carry a vestigial injection point.
6. Smaller items
chunk_concurrencyis now snapshotted, not re-read.BatchChunker.__init__(execution.py:69) captures it once; the base loop re-readself.chunk_concurrencyon every iteration (bb2821bmerlin_processor.py:1055). Mid-flight mutation no longer takes effect. Marginal in practice, but it is a divergence from "behavior unchanged" and worth either fixing (accept a callable, like the other deps) or noting. Themax(1, int(...))clamp is also now applied twice — here and atmerlin_processor.py:873.MerlinFuture.job_idsis read-only. Previouslyfut.job_ids,fut.status,fut.cancel_remotewere plain instance attributes, so assignment worked; nowjob_idsis a property with no setter and assignment raisesAttributeError. This is a net improvement, butMerlinFutureis exported at package root — worth a line in the release notes, since the PR currently states "no public signature or behavior changes."- Inconsistent guarding of
get_rpc_handler().extract_token(perceval_adapter.py:129) wraps it intry/except Exception;get_url(:175) calls it bare, andclone_remote_processor(:206) routes throughget_url. Pick one policy — AGENTS.md's failure policy argues for the unguarded version in both. TokenExtractionErrorlives in the adapter but is only raised outside it (merlin_processor.py:857);PercevalAdapter.extract_tokenreturnsNone. The docstring atperceval_adapter.py:39("Raised when no auth token can be resolved for a RemoteProcessor") describes behaviour that isn't in this module — either move the raise intoextract_tokenor reword.configure_processor(processor, circuit, None)atmerlin_processor.py:1407calls the combined helper purely to set the circuit, relying onset_input's falsy check to no-op. A dedicatedPercevalAdapter.set_circuitwould read better than theNonesentinel.BatchChunker.split_batchloops forever ifmicrobatch_size <= 0.MerlinProcessor.__init__:864assignsmicrobatch_sizewithout validation. Pre-existing, but this is now a public static method with a documented contract — a cheapif microbatch_size <= 0: raise ValueError(...)closes it.- Function-local
from concurrent.futures import CancelledErroratexecution.py:219,:375, andmerlin_processor.py:1384. Carried over from the base, butexecution.pyis a new file with no circular-import constraint — move it to the top.
What's good
- The
CallStatethread-ownership docblock (merlin_processor.py:76-90) is the kind of documentation that actually prevents future bugs. Moving the chunk counters off the processor-wide lock ontoCallState's own lock is the right call. MerlinFuturesubclassingtorch.futures.Futurepreserves theisinstancecontract while making the API discoverable and typed — andstatus()/job_idsdon't collide with anything on the torch base class.- Dependency injection in
RemoteJobRunneris done with deferred lambdas (_make_job_runner,merlin_processor.py:1251-1265) specifically so live attribute mutation keeps working. That's careful work, and it's why the parity suites pass. Item Update issue templates #1 is the one place the same discipline wasn't applied. RemoteJobFailedError(RuntimeError)/TokenExtractionError(ValueError)as subclasses of the historical types is the right way to add specificity without breaking catchers.- Test fakes in
tests/core/test_execution_units.pyare well-shaped — scriptedresult_events, recordedexecute_kwargs, real threads for the concurrency assertions. Not mock-theatre.
· branchPML_303_new
Summary
Four-ticket internal refactor of
MerlinProcessorexecuted as one stacked branch (9 commits, one logical step per commit). The processor's execution path goes from a single ~2,200-line class threading an anonymous state dict and monkey-patched futures, to a thin coordinator over typed, independently-testable units:statedict threaded throughforward_async(), chunk orchestration, and job polling is replaced by a typedCallStateobject with named mutation helpers and documented per-field thread ownership.BatchChunkerandRemoteJobRunner(merlin/core/execution.py), dependency-injected and testable with no-cloud fakes. Threading semantics unchanged; no concurrency-model redesign.cancel_remote/status/job_idsonto plaintorch.futures.Futureinstances is replaced byMerlinFuture, aFuturesubclass declaring the async contract in code and types. Exported at package root.RemoteConfig, local experiment private state) is localized in aPercevalAdapterfacade (merlin/core/perceval_adapter.py). Scope was deliberately extended to cover the local processor path (experiment_in_ports/_postselect/_circuit_changedhandling), so a Perceval version bump now requires touching only the adapter.User-visible behavior is unchanged. The PML-300 behavior-lock and PML-301 backend-parity suites pass throughout; the only test edits across the whole branch are mechanical (7 accessor lines for the CallState fixture, 10
Samplerpatch-target retargets to the module that now owns the symbol).Related Issue
Related Jira: PML-303, PML-304, PML-305, PML-306
Type of change
Proposed changes
PML-303 — typed CallState (
80c96602,025b0f12)CallState(+ frozenJobStatus):request_cancel(),record_job_id()(deduplicating, under lock),set_current_status(),add_planned_chunks(),mark_chunk_started()/finished(),status_snapshot().future.job_idsstill aliases the live CallState list;future.status()payload is byte-identical; chunk counters move from the processor-wide lock to CallState's own lock.PML-305 — execution units (
bfb1607f)BatchChunker: batch splitting, bounded-concurrency chunk pooling, job labeling, output stitching, deadline →cancel_all→TimeoutError.RemoteJobRunner: fresh RemoteProcessor per attempt, iteration prep, probs-vs-sampling command selection, job-name capping, retries/backoff, polling, result mapping via injected callable.MerlinProcessor._run_chunks_pooled/_run_chunk/_submit_job/_poll_jobbecome thin delegates with unchanged signatures; dependencies are bound methods and deferred lambdas so live attribute mutation and monkeypatched methods behave exactly as before.PML-304 — typed async handle (
d176114f)MerlinFuture(torch.futures.Future)declaresjob_ids(live property),status(),cancel_remote().forward_async()is annotated-> MerlinFuture.isinstance(fut, torch.futures.Future)contract preserved (subclassing was mandated by the parity suite). Exported frommerlin/__init__.py.PML-306 — Perceval adapter (
a41620ab,805fb59e,8075c3fe,929ea1f5)PercevalAdapter(stateless static facade): token extraction (handler attr variants, Bearer header,RemoteConfigfallback), URL, RP cloning, session build, capability snapshot, processor configuration, sampler creation/dispatch (async + sync), Perceval 1.2 iterator-serialization shim,JobStatusSnapshotnormalization, results/cancel, local processor rebuild withLocalExperimentSnapshotcapture/restore, shot estimation.TokenExtractionError(ValueError),RemoteJobFailedError(RuntimeError)— same messages, same catchability._extract_rp_token,_clone_remote_processor,_create_fresh_local_processorremain as processor entry points (delegates), so existing patch targets are unaffected.API changes
merlin.MerlinFuture.merlin.core.merlin_processor.CallState/JobStatus;merlin.core.execution.BatchChunker/RemoteJobRunner;merlin.core.perceval_adapter.PercevalAdapter/JobStatusSnapshot/LocalExperimentSnapshot/TokenExtractionError/RemoteJobFailedError.MerlinProcessor._snapshot_local_experiment_metadata,_restore_local_experiment_metadata,_copy_circuit_for_execution,_ensure_serializable_sampler_iterator,_LocalExperimentMetadata(all relocated into the adapter).MerlinProcessor.Docs & quality (
4f9d5fba)MerlinFuturedocumented in themerlin.core.merlin_processorAPI reference; theforward_asyncsection no longer describes the removed monkey-patched contract.CallState/PercevalAdapterper AGENTS.md; test files aligned with the suite's one-docstring-per-test pattern; 4 latent ruff S106 findings fixed.How to test / How to run
Expected: 275 passed, 77 skipped (skips = live-backend integration tests).
Verified clean on this branch (via
python -m sphinx -W --keep-going -n -b html docs/source <out>, exit 0).80c96602,a41620ab) is a pure addition reviewable before its wiring commit.Screenshots / Logs (optional)
n/a — behavior-preserving refactor; parity is demonstrated by the locked suites passing with only mechanical edits.
Performance considerations (optional)
Negligible. Per-call construction of the small runner/chunker/adapter objects adds a few allocations per chunk; polling cadence, thread model, retry/backoff, and remote payloads are unchanged.
Documentation
Checklist
PR title includes Jira issue key (e.g., PML-126)
"Related Jira ticket" section includes the Jira issue key (no URL)
Code formatted (ruff format)
Lint passes (ruff)
Static typing passes (mypy) if applicable — not configured for this repo; not run
Unit tests added/updated (pytest) — 104 new no-cloud tests across 4 new files
Tests pass locally (pytest) — 275 passed, 77 skipped (credentialed cloud tests)
Tests pass on GPU (pytest) — not run; no CUDA-path changes (inputs already moved to CPU for backend execution, unchanged)
Test coverage not decreased significantly — coverage increased: token extraction, job-status guards, and chunk concurrency previously had no direct coverage
Docs build locally if affected (sphinx)
With this command:
the docs are built without any warning or errors.
New public classes/methods/packages are added in the API following the methodology presented in other files.
Dependencies updated (if needed) and pinned appropriately — no dependency changes
PR description explains what changed and how to validate it