Skip to content

Release 0.4.1 - #297

Merged
CassNot merged 207 commits into
mainfrom
release/0.4.1
Aug 20, 2026
Merged

Release 0.4.1#297
CassNot merged 207 commits into
mainfrom
release/0.4.1

Conversation

@CassNot

@CassNot CassNot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Summary

This brings the 0.4.1 release work into the main development branch, including processor execution refactoring, noise-model improvements, measurement fixes, model updates, documentation, benchmarks, and expanded regression coverage.

Main changes

  • Refactored processor execution around typed per-call state, futures, batch chunking, remote job execution, and a dedicated PercevalAdapter.
  • Improved validation and error handling for processor capabilities, microbatch sizes, execution units, and unsupported operations.
  • Added circuit decomposition support for phase noise and imprecision, including GPU support, stochastic per-call sampling, cache validation, and regression tests.
  • Fixed noisy SLOS, g2, detector/readout occupancy, photon-loss renormalization, and partial-measurement behavior.
  • Added typed-output and return_object regression coverage.
  • Fixed QCNN amplitude-encoding dtype handling and entangling-layer initialization.
  • Added QGAN generator seeding support with documentation and tests.
  • Added feed-forward and partial-measurement guidance.
  • Added hardware-aware design documentation and QCNN scaling/performance studies.
  • Added H100 GPU benchmark results and visualization assets.
  • Added a citation registry with OpenAlex integration, validation, accessibility improvements, and citation-impact rendering for reproduced papers.
  • Added the MNIST image-classification example and PN-QNN ecosystem documentation.
  • Updated the README roadmap and release documentation.

Testing

  • Added and expanded tests across:

    • processor execution and adapters;
    • circuit noise and noisy SLOS;
    • measurements and readouts;
    • QCNN, QGAN, reservoir, and photonic-generator models;
    • citation tracking and documentation;
    • amplitude encoding and feed-forward execution.
  • Run the Python test suite

  • Run linting and type checks

  • Build the documentation with warnings treated as errors

leith karrai and others added 30 commits June 19, 2026 09:19
… limits and restored the previous code (no user warning left)
@github-actions

Copy link
Copy Markdown

✅ Coverage Report

Overall Coverage: 85.47%

Coverage Status

  • Target: 80% (warning threshold)
  • Current: 85.47%
  • Status: ✅ Meeting target

Coverage reports are informational and do not block PRs.
Generated using pytest-cov

@CassNot

CassNot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Aug 19, 2026

Copy link
Copy Markdown

Claude finished @CassNot's task in 6m 55s —— View job


Code review: Release 0.4.1

  • Read repo guidelines (AGENTS.md)
  • Gather PR diff vs origin/main
  • Review core execution refactor (execution.py, perceval_adapter.py, merlin_processor.py, process.py)
  • Review noise / simulation changes (locirc_to_tensor.py, noisy_slos.py)
  • Review measurement + model changes
  • Post review findings

Reviewed the ~2.6k changed lines under merlin/ (docs/benchmarks/notebooks skimmed). The MerlinProcessor decomposition into CallState / MerlinFuture / BatchChunker / RemoteJobRunner / PercevalAdapter is a real improvement: the Perceval-internals surface is now in one file, the per-call state is typed instead of a dict, and the MerlinModule.uid fix (class attribute → instance attribute) removes a genuine layer-cache collision bug. Test coverage for the new units is substantial (100 new tests across the 4 new core test files).

Findings below, most important first. I could not execute Python in this environment, so the reasoning is static — items marked (verify) need a run to confirm.

1. Fixed entangling-layer phases are unseeded and unrecoverable — merlin/core/components.py:248-275

Non-trainable phase shifters now get rng.uniform(0, 2π) instead of 0.0. Two consequences:

  • random.Random(self.seed) with seed=None draws from OS entropy, so neither random.seed(...) nor torch.manual_seed(...) makes a model reproducible. Every process gets a different circuit.
  • The drawn phases are circuit constants, not parameters — they are not in state_dict(). Saving a model with a non-trainable entangling layer and reloading it into a freshly built architecture yields a different unitary, so predictions change silently after a round-trip. The same applies to any model that builds an entangling layer internally and does not expose seed= upward.

This is also a behavior change for existing users of add_entangling_layer(trainable=False) / trainable_outer=False (previously identity-ish fixed phases). Suggest defaulting the seed from torch's global RNG (int(torch.randint(0, 2**31 - 1, ()).item())) so global seeding reproduces, and/or exporting the drawn phases so they can be persisted. Worth a release note either way. Fix this →

2. Eager _prepare_superposition_support() adds a new failure mode — merlin/core/process.py:872-878, 969-975

Both _compute_phase_error_probabilities and _compute_probabilities now call self._prepare_superposition_support() whenever amplitude_encoding is true, before knowing whether it is needed. _prepare_superposition_tensor() raises TypeError("Input state should be a tensor") for non-tensor input states (process.py:1515), while the pre-existing gate was isinstance(self.input_state, torch.Tensor) and amplitude_encoding. layer.py:1442 sets should_use_amplitude_encoding = self.amplitude_encoding or isinstance(inferred_state, torch.Tensor), so a layer built with the deprecated amplitude_encoding=True flag and a Fock/BasicState input state now reaches the eager call (verify). The value is only used when memristors are present, so guard it:

memristive_batched = bool(memristive_current_state) and isinstance(self.input_state, torch.Tensor) and amplitude_encoding \
    and self._prepare_superposition_support().batch_size > 1

Also, in _compute_probabilities the support is computed and then discarded when _has_phase_error() short-circuits into _compute_phase_error_probabilities, which computes it again — duplicated work on every forward.

3. A failed chunk does not stop the remaining submissions — merlin/core/execution.py:184-208

The docstring says the first error is "re-raised once the remaining in-flight chunks have settled", but the scheduling loop keeps launching every remaining chunk after errors becomes non-empty; only then does line 207 raise. On a QPU that spends real shots on work whose result is already discarded. Adding and not errors to the submission condition matches the documented contract.

4. Empty batch raises inside torch.catmerlin/core/execution.py:210

split_batch(0, n) returns [], so outputs is [] and torch.cat([]) raises RuntimeError: expected a non-empty list of Tensors rather than returning an empty [0, dist_size] tensor. Cheap to handle in _offload_quantum_layer_with_chunking (early-return for B == 0).

5. Missing input columns are silently zero-filled — merlin/core/execution.py:339-346 and merlin/core/merlin_processor.py:1404-1411

circuit_params[param_name] = float(input_np[i, j]) if j < input_chunk.shape[1] else 0.0

If the layer declares more input parameters than the tensor has columns, the remainder becomes 0.0 and the job runs on a silently wrong circuit; extra columns are dropped equally silently. AGENTS.md ("do not add silent fallbacks", "raise clear errors") argues for a shape check. The block is also duplicated verbatim between the remote and local paths and would be better as one helper.

The same shape of problem appears in the command selection, duplicated in both files (execution.py:454-459, merlin_processor.py:1432-1437): when the backend advertises neither sample_count nor samples, the code falls back to cmd = "sample_count" and submits a command the backend does not support instead of raising.

6. Result-row misalignment when a row has no counts — merlin/core/merlin_processor.py:1574-1616

if "results" in result_item:
    state_counts = ...
    probs = torch.zeros(dist_size)
    if state_counts:          # <-- empty dict: nothing is appended
        ...
        output_tensors.append(probs)
else:
    output_tensors.append(torch.zeros(dist_size))

If any row's state_counts is empty (all-heralded / fully-filtered chunk row), no tensor is appended, so every subsequent row shifts up one index and a zero row is padded at the end by line 1618 — results are silently attributed to the wrong inputs. The inner post-filter path at 1588 handles this correctly; the outer check does not. Pre-existing on main, but this PR rewrites the surrounding module and adds typed-output regression coverage, so it seems worth fixing here. Fix this →

7. Shot estimation uses different phases than execution — merlin/core/merlin_processor.py:1789-1838

  • Lines 1789-1806 validate the layer and build ValidatedLayerConfig twice, identical code back to back (the second isinstance check and config = ... are dead).
  • Line 1822 sets param_values[pname] = float(row[j] * np.pi), while both execution paths use float(input_np[i, j]) with no π scaling. The estimator is therefore asked about a different circuit configuration than the one that will run, so the returned shot counts do not correspond to the executed job. If the π factor is intentional (input convention differs at the estimator boundary), it deserves a comment; otherwise it is a bug.

8. _extract_input_params breaks on the documented None case — merlin/core/merlin_processor.py:1533-1535

ValidatedLayerConfig explicitly allows input_param_order = None, but list(config.input_param_order) then raises TypeError: 'NoneType' object is not iterable from inside a worker thread. Either reject None at validation time or return [] here.

9. noisy_slos._augmented_obb_probs — private-state coupling, device, and cost (merlin/pcvl_pytorch/noisy_slos.py:201-291, 414-421)

  • It reads obb._partitions, obb._weights and slos_graphs_list[0]._slos_graph_per_input[...] — three private members of two other objects. If the OBB partition layout changes, this breaks silently rather than at a typed boundary. A small accessor on _InputStateNoisySLOSComputeGraph would keep the invariant in one place.
  • extra_vec is allocated on self.device (line 414-416) while the rest of this diff carefully re-homes tensors to unitary.device. If those differ — exactly the case the .to(unitary.device) calls exist to paper over — cell[0] + extra_vec and the SLOS calls mix devices. The repeated post-hoc .to(unitary.device) suggests the real fix is to move the graphs once (graph.to(unitary.device)) rather than patch each output.
  • Line 290 divides by output_probs.sum(dim=1, keepdim=True) with no clamp_min, producing NaN if a sector sums to zero. Same unguarded pattern as line 908, so at least it is consistent — but both are worth a guard.
  • The inner for local_idx, key in enumerate(conv_keys_list) loop does one indexed tensor add per convolved key, per cell, per partition order, inside the forward path. index_add_ over a precomputed index tensor would collapse it to one call per cell.

10. Unbounded global decomposition cache — merlin/pcvl_pytorch/locirc_to_tensor.py:57-92

_DECOMPOSITION_CACHE is a module-level dict with no size limit or eviction, keyed on full matrix bytes. Any workload that builds converters over many distinct Unitary matrices (sweeps, per-sample unitaries, long-lived services) grows it without bound. A capped LRU, or attaching the cache to the converter/circuit rather than the module, would bound it.

Separately, the optimizer_is_default = CircuitOptimizer.optimize_rectangle is _DEFAULT_OPTIMIZE_RECTANGLE branch makes production behavior depend on whether a test has monkeypatched Perceval. It works, but it is test-awareness in library code; injecting the optimizer into _decompose_unitaries would express the same thing without the identity check.

11. super()._apply(fn, recurse=recurse) vs declared torch floor — merlin/algorithms/layer.py:1546

pyproject.toml declares torch>=2.0.0, and nn.Module._apply only gained the recurse parameter in torch 2.1. On 2.0.x this call would raise TypeError on any .to() / .cuda() (verify against the oldest supported torch — CI presumably tests only recent versions). Either raise the floor to >=2.1 or forward recurse conditionally.

12. MerlinFuture.cancel_remote() cancels other calls' jobs — merlin/core/merlin_processor.py:294-308

_cancel_all is MerlinProcessor.cancel_all, which cancels every job in _active_jobs, including jobs belonging to other concurrently outstanding futures on the same processor. Per-future cancellation therefore has processor-wide blast radius. The docstring says "cancels all active remote jobs best-effort" — accurate but easy to misread as scoped to this call. CallState already tracks per-call job ids and could scope this.

Related: when poll_job raises CancelledError/TimeoutError after PercevalAdapter.cancel_job (execution.py:525-530), the job is never _unregister_job'd, so it stays in _active_jobs for the processor's lifetime.

13. Docs / conventions

  • New public modules merlin/core/execution.py and merlin/core/perceval_adapter.py have no entry under docs/source/api_reference/api/ and are not in the merlin.core toctree, though they export public classes (BatchChunker, RemoteJobRunner, PercevalAdapter, JobStatusSnapshot, LocalExperimentSnapshot, TokenExtractionError, RemoteJobFailedError). AGENTS.md requires every new public object to be declared in the corresponding API .rst. If these are meant to be internal, a leading underscore or an explicit "internal" note in the module docstring would make that intent clear. Fix this →
  • merlin/measurement/strategies.py:285 — the occupancy_readout : bool line is indented eight spaces inside the class Parameters block, so numpydoc will not parse it as a parameter entry (the sibling entries use four).
  • The occupancy readout now preserves sub-unit mass (readouts.py:140), but _process_batch_results still renormalizes each remote probability row (merlin_processor.py:1611-1613). Backend-executed and locally simulated distributions therefore follow opposite mass policies. Worth reconciling, or at least documenting, since the new docstrings present mass preservation as the invariant for this path.

14. Smaller items

  • merlin/core/merlin_processor.py:954-962 — the # End session lifecycle if provided comment in __exit__ describes something the code does not do.
  • merlin/core/merlin_processor.py:536-541 — error message for the input_param_order string check reads "non-integer element types"; should be "non-string".
  • merlin/core/merlin_processor.py:349-367 doctests in check_sequence would not pass if doctests were ever enabled (check_sequence(42) returns None, which prints nothing; the numpy example returns numpy scalars).
  • merlin/pcvl_pytorch/locirc_to_tensor.py:965"""Apply configured phase noise to a phase tensor in-place (returns new tensor).""" contradicts itself; the function is pure.
  • merlin/pcvl_pytorch/locirc_to_tensor.py:1046-1058 — the raw = ...; param_values.append(raw) rewrite in the BS branch is a no-op refactor leftover (and _index is unused).
  • merlin/pcvl_pytorch/noisy_slos.py:471hasattr(self, "_regular_slos_graphs") guard; initializing the attribute to [] in the other constructor branch removes the conditional.
  • merlin/algorithms/layer.py:1129-1133if len(amplitude_input.shape) == 1: batch_dim = 1 is a no-op; the block reduces to if amplitude_input.ndim > 1: batch_dim = amplitude_input.shape[0].
  • merlin/models/qcnn.py:918next(self.parameters()) raises StopIteration for a parameter-free model; next(..., None) with a fallback would be safer.
  • merlin/builder/circuit_builder.py:952-958fixed[i] if i < len(fixed) else 0.0 keeps the silent-zero fallback that components.py:254-265 was changed to prevent; since the length is now validated, this could assert instead. Also, explicitly passed fixed_inner_values are silently ignored when the matching block is trainable.

Nothing here blocks in an obviously fatal way, but items 1, 2 and 6 have silent-wrong-result potential and are the ones I would resolve before tagging.
· branch release/0.4.1

@CassNot

CassNot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

I fixed points 1 to 6 and doc formatting issues
@claude review with prioritization for future release

@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown

Claude finished @CassNot's task in 4m 57s —— View job


Re-review: Release 0.4.1 — post-fix pass, prioritized

  • Verify fixes for items 1–6
  • Verify doc-formatting fixes
  • Re-check remaining items (7–14) against current branch
  • Prioritize what's left for a future release

Part 1 — verification of the fixes

# Item Verdict
1 Unseeded fixed entangling phases ✅ fixed (components.py:270-273) — residual below
2 Eager _prepare_superposition_support() ✅ fixed (process.py:872-877, 967-976); the duplicate call on the phase-error path is gone too
3 Chunk submission continues after first failure ✅ fixed (execution.py:265-267)
4 Empty batch → torch.cat([]) ✅ fixed (merlin_processor.py:1202-1209)
5 Silent zero-fill / silent sample_count fallback ⚠️ mostly fixed — see below
6 Result-row misalignment on empty state_counts ✅ fixed (merlin_processor.py:1586-1588), with a regression test
13 occupancy_readout numpydoc indent; new-module .rst entries ✅ fixed (strategies.py:285, merlin.core.execution.rst, merlin.core.perceval_adapter.rst) — the two new module-level helpers are documented too

Residual on #1. torch.manual_seed() now reproduces the drawn phases, which was the important half. Two things still worth a release note:

  • The phases remain circuit constants outside state_dict(), so save → rebuild → load_state_dict under a different torch seed still yields a different unitary.
  • The torch.randint draw consumes global RNG state, so adding a non-trainable entangling layer shifts the stream for every subsequent torch.randn parameter init. It also fires when fixed_inner_values/fixed_outer_values were supplied explicitly and rng is never used — cheap to skip.

Residual on #5. The extraction into build_iteration_parameters / select_sampling_command is the right shape and removes the duplication. But select_sampling_command is only strict on one of three paths: _run_chunk_local passes default_command="sample_count" unconditionally (merlin_processor.py:1441-1444), and RemoteJobRunner gets "sample_count" whenever a session is set (merlin_processor.py:1284-1286). So the silent fallback survives exactly on the session-backed remote path, which is where submitting an unsupported command costs the most. Only remote-without-session actually raises. Intentional?


Part 2 — remaining findings, prioritized for the next release

P0 — silent wrong results

A. The processor path never applies the layer's angle encoding. (new — found while verifying #5, and it subsumes #7's π question)

forward_async feeds the raw pipeline tensor straight into _offload_quantum_layer_with_chunking (merlin_processor.py:1159) → build_iteration_parameters(input_chunk, input_param_order). Local QuantumLayer.forward instead routes inputs through _prepare_input_encodingapply_angle_encoding, which applies the per-feature scales and the combinations expansion (layer_utils.py:895-955). Nothing equivalent happens on the processor path, so:

  • angle_encoding_scale != 1.0 (e.g. QuantumLayer.simple(..., angle_encoding_scale=π)) → remote runs a different circuit than local, silently, with matching column counts and no error.
  • subset_combinations=Truelen(input_param_order) > input_size, so the new strict check in build_iteration_parameters now raises ValueError: Input column count does not match…. Better than the old zero-fill, but the message points at the tensor rather than the real cause.

Both cases are invisible today because CI exercises the default scale=1.0, subset_combinations=False layer where the encoding is the identity. Suggested direction: have export_config() export the encoding spec and apply it in build_iteration_parameters, or reject layers with a non-identity encoding at should_offload() time with an explicit message. Fix this →

B. estimate_required_shots_per_input estimates a different circuit than it runs — merlin_processor.py:1796-1824. Still open from #7. param_values[pname] = float(row[j] * np.pi) while both execution paths use the unscaled value, and add_angle_encoding defaults to scale=1.0, so the π is not coming from the encoding convention either. The returned shot counts do not describe the job that will be submitted. Also still duplicated: lines 1796-1800 and 1809-1813 are the identical isinstance check + ValidatedLayerConfig(...) back to back, the second pair being dead. Fix this →

C. _extract_input_params still breaks on the documented Nonemerlin_processor.py:1540-1542. ValidatedLayerConfig explicitly accepts input_param_order = None (:397-399, :528), but list(config.input_param_order) then raises TypeError: 'NoneType' object is not iterable inside a worker thread. Return [], or reject None at validation time so the contract is stated in one place.

P1 — resource / consistency

D. Job-registry leak on cancel and timeout — execution.py:596-603. poll_job calls PercevalAdapter.cancel_job(job) and raises without self._unregister_job(job), unlike every other exit in that method (:619, :621, :635, :640, :650). Cancelled and timed-out jobs stay in _active_jobs for the processor's lifetime, so every later cancel_all() re-cancels them.

E. MerlinFuture.cancel_remote() has processor-wide blast radius — merlin_processor.py:301-315. _cancel_all is MerlinProcessor.cancel_all, so per-future cancellation kills every concurrently outstanding call's jobs. CallState already records per-call job ids (record_job_id) and could scope it.

F. Opposite mass policies for local vs remote distributions. OccupancyReadout now deliberately preserves sub-unit mass (readouts.py:141, and the new strategies.py:285-290 docstring presents that as the invariant), while _process_batch_results renormalizes every remote row (merlin_processor.py:1622-1624). Same layer, different totals depending on the backend. Reconcile or document.

G. noisy_slos unguarded normalization and device split. output_probs / output_probs.sum(dim=1, keepdim=True) (noisy_slos.py:290, mirrored at :908) → NaN for a zero-mass sector; clamp_min on both. And extra_vec is allocated on self.device (:474-476) while everything else in this diff is re-homed to unitary.device — the repeated .to(unitary.device) patches suggest moving the graphs once instead.

P2 — maintainability / scaling

H. Unbounded global _DECOMPOSITION_CACHElocirc_to_tensor.py:57-80. Module-level dict keyed on full matrix bytes, no cap, no eviction. Parameter sweeps, per-sample unitaries, and long-lived services grow it without bound. Cap it (LRU) or attach it to the converter.

I. optimizer_is_default = CircuitOptimizer.optimize_rectangle is _DEFAULT_OPTIMIZE_RECTANGLE makes library behavior depend on whether a test has monkeypatched Perceval. Injecting the optimizer into _decompose_unitaries expresses the same intent without the identity check.

J. super()._apply(fn, recurse=recurse)layer.py:1546. pyproject.toml:34 declares torch>=2.0.0,<2.13; nn.Module._apply only gained recurse in 2.1, so .to()/.cuda() would TypeError on 2.0.x. Raise the floor to >=2.1 or forward conditionally. (static reasoning — CI presumably only tests recent torch)

K. _augmented_obb_probs private coupling and inner-loop cost — noisy_slos.py:247-289. Reaches into obb._partitions, obb._weights, and slos_graphs_list[0]._slos_graph_per_input[...]; an accessor on _InputStateNoisySLOSComputeGraph would keep the invariant in one place. The for local_idx, key in enumerate(conv_keys_list) scatter is one indexed add per convolved key per cell per order, in the forward path — index_add_ over a precomputed index tensor collapses it to one call per cell.

L. BatchChunker.run chunk accounting on early abort — execution.py:238. add_planned_chunks(len(chunks)) counts all chunks, but after the new early-abort only started chunks are ever marked finished, so status() reports chunks_done < chunks_total for a call that is fully settled. Cosmetic (the future raises), but the snapshot is now reachable in a state it never was before.

P3 — nits, batch them whenever

  • merlin_processor.py:544-548input_param_order error says "non-integer element types"; should be "non-string".
  • merlin_processor.py:960-967# End session lifecycle if provided in __exit__ describes something the code does not do.
  • merlin_processor.py:349-367check_sequence doctests would fail if doctests were ever enabled.
  • locirc_to_tensor.py:970"""Apply … in-place (returns new tensor).""" contradicts itself; the function is pure.
  • locirc_to_tensor.py:1064-1070raw = …; param_values.append(raw) is a no-op refactor leftover; _index unused.
  • noisy_slos.py:471hasattr(self, "_regular_slos_graphs"); initialize to [] in the other constructor branch and drop the conditional.
  • layer.py:1129-1133if len(amplitude_input.shape) == 1: batch_dim = 1 is a no-op; reduces to if amplitude_input.ndim > 1: batch_dim = amplitude_input.shape[0].
  • qcnn.py:918next(self.parameters()) raises StopIteration for a parameter-free model.
  • circuit_builder.py:954, :959fixed[i] if i < len(fixed) else 0.0 keeps the silent-zero fallback that components.py:260-267 now validates against; an assert would be honest. Explicitly passed fixed_inner_values are also silently ignored when the matching block is trainable.

Verdict for tagging 0.4.1

The three silent-wrong-result items I flagged as blocking (1, 2, 6) are resolved and covered by tests. Nothing in Part 2 is a regression introduced by this PR — A, B, C, D, E and H all predate it or arrived with the refactor in an already-latent form. A is the one I would want a release note for, since "run the same model on a processor" is the headline feature of this release and a non-default angle_encoding_scale diverges without any error.

Suggested next-release order: A → B → C (correctness), then D, E, F (resource/consistency), then the rest as cleanup.

Static review only — I could not execute Python or the test suite in this environment, so items marked as verified were confirmed by reading the fix commits and current sources, not by running them.
· branch release/0.4.1

@ben9871 ben9871 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.

J'approuve

@CassNot
CassNot merged commit b98d44b into main Aug 20, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants