Source code for quchip.inverse_design.fit

"""Fit bare chip parameters to a numerical dressed specification.

Devices and couplings declare how their input numbers map to dressed targets.
The fitter reads the desired chip structurally rather than diagonalizing it,
and a bounded nonlinear least-squares solve evaluates the candidate chips.

References
----------

Dispersive regime (cross-Kerr and dressed frequencies):

- Koch et al., *Phys. Rev. A* **76**, 042319 (2007),
  "Charge-insensitive qubit design derived from the Cooper pair box"
  — the DuffingTransmon approximation and its dispersive shifts.
- Gambetta et al., *Phys. Rev. A* **74**, 042318 (2006),
  "Qubit-photon interactions in a cavity" — dispersive frequency shifts.
  Coupling seeding here uses a numerical signed cross-Kerr root, requiring
  a bracket across ``seed_strength_bounds`` without assuming monotonicity.

JAX traceability
----------------

Every bare parameter here (``device.freq``, ``device.anharmonicity``, the
``coupling_strength`` of a coupling) is a sweepable, differentiable quantity. A
chip that uses a JAX-native backend supplies a traced dressed-observable
residual and an exact Jacobian. SciPy uses their concrete values only at the
bounded trust-region boundary. The optimizer itself is not JAX-traceable, but
the output :class:`~quchip.chip.chip.Chip` stays fully traceable for every
downstream operation.
"""

from __future__ import annotations

import warnings
from collections.abc import Mapping
from numbers import Integral
from typing import Any, Literal

import jax
import jax.numpy as jnp
import numpy as np
from scipy.optimize import brentq, least_squares

from quchip.analysis.effective_hamiltonian import effective_hamiltonian_between_states
from quchip.backend import _backend_context
from quchip.chip import Chip
from quchip.cutoff import active_cutoff
from quchip.inverse_design.observables import (
    TargetSpec,
    build_dressed_target_specs,
)
from quchip.inverse_design.subsystems import (
    build_local_subsystem,
    device_labels_for_local_eval,
)
from quchip.inverse_design.types import FitADressResult, FitParameterReport, ObservableReport
from quchip.utils.labeling import resolve_label

# Largest total energy-level index that each target kind reads from the dressed spectrum.
_TARGET_LEVEL_SUMS = {"freq": 1, "exchange_rate": 1, "anharmonicity": 2, "cross_kerr": 2}


def _pair_labels(label: Any, kind: str) -> tuple[str, str]:
    """Validate a 2-tuple label for pair observables (``zz``, ``exchange``)."""
    if not isinstance(label, tuple) or len(label) != 2:
        raise ValueError(f"{kind} targets require a 2-tuple of device labels, got {label!r}")
    return str(label[0]), str(label[1])


def _static_exchange_rate(chip: Chip, label: Any) -> Any:
    """Read the real single-excitation off-diagonal element in GHz through
    the public effective_hamiltonian_between_states calculation."""
    a, b = _pair_labels(label, "exchange")
    n = len(chip.devices)
    state_a = [0] * n
    state_b = [0] * n
    state_a[chip.device_index(a)] = 1
    state_b[chip.device_index(b)] = 1
    h_eff = effective_hamiltonian_between_states(chip, tuple(state_a), tuple(state_b))
    return jnp.real(h_eff[0, 1])


def _estimate_bare_g(
    chip: Chip,
    coupling,
    spec: TargetSpec,
    seed_strength_bounds: tuple[float, float] = (1e-6, 0.25),
) -> float:
    """Root-seed the signed full cross-Kerr on an isolated two-device copy.

    Require the target to be bracketed by seed_strength_bounds; monotonicity
    is unnecessary for brentq. Preserve the coupling copy/rebind path, including
    constructor-only state, and the parent backend. Set trials through the
    coupling-strength interface. An unbracketed target raises, never saturates."""
    target_val = float(spec.target)
    if target_val == 0.0:
        return 0.0
    dev_a = coupling.device_a.copy()
    dev_b = coupling.device_b.copy()
    device_map = {dev_a.label: dev_a, dev_b.label: dev_b}
    sub_coupling = coupling.copy(device_map)
    sub = Chip(
        [dev_a, dev_b],
        [sub_coupling],
        frame=chip.frame,
        approximation=chip.approximation,
        basis=chip.basis,
        backend=chip.backend,
        space=chip.space,
    )

    def obs_at_strength(strength: float) -> float:
        sub_coupling.set_coupling_strength(strength)
        return float(sub.static_zz(sub_coupling.device_a, sub_coupling.device_b))

    lo, hi = seed_strength_bounds
    obs_lo, obs_hi = obs_at_strength(lo), obs_at_strength(hi)
    if not min(obs_lo, obs_hi) <= target_val <= max(obs_lo, obs_hi):
        raise ValueError(
            f"Cannot seed a bare coupling strength for {coupling.label!r}'s target "
            f"{spec.kind}={target_val!r}: not bracketed by the observable at "
            f"seed_strength_bounds={seed_strength_bounds!r} "
            f"(observable(lo)={obs_lo!r}, observable(hi)={obs_hi!r})."
        )
    return brentq(lambda strength: obs_at_strength(strength) - target_val, lo, hi)


def _selected_parameter_names(
    chip: Chip,
    device_selection: Mapping[str, tuple[str, ...]],
    coupling_selection: Mapping[str, tuple[str, ...]],
) -> list[str]:
    """Free parameter paths in chip order and declared parameter order."""
    names = [
        f"{device.label}.{name}"
        for device in chip.devices
        for name in device.tunable_params()
        if name in device_selection.get(device.label, ())
    ]
    names.extend(
        f"{coupling.label}.{coupling.coupling_strength_name}"
        for coupling in chip.couplings
        if coupling.coupling_strength_name in coupling_selection.get(coupling.label, ())
    )
    return names


def _resolve_vary(
    chip: Chip,
    vary: Mapping,
) -> tuple[dict[str, tuple[str, ...]], dict[str, tuple[str, ...]]]:
    """Resolve complete device/coupling free-parameter allowlists.

    Absent components and empty selections stay frozen. Accept objects or labels;
    reject duplicate labels, unknown tunables and bare string selections. Return
    (device_selection, coupling_selection) mappings."""
    devices_by_label = {device.label: device for device in chip.devices}
    couplings_by_label = {coupling.label: coupling for coupling in chip.couplings}
    known_labels = sorted(set(devices_by_label) | set(couplings_by_label))

    device_selection: dict[str, tuple[str, ...]] = {}
    coupling_selection: dict[str, tuple[str, ...]] = {}
    seen_labels: set[str] = set()

    for key, value in vary.items():
        label = resolve_label(key)
        if label in seen_labels:
            raise ValueError(f"vary has duplicate entries resolving to component {label!r}.")
        seen_labels.add(label)

        if isinstance(value, str):
            raise ValueError(
                f"vary[{label!r}] must be a collection of parameter names (e.g. ('{value}',)), "
                f"got the bare string {value!r}."
            )
        try:
            names = tuple(str(name) for name in value)
        except TypeError as exc:
            raise ValueError(f"vary[{label!r}] must be an iterable of parameter names, got {value!r}.") from exc

        if label in devices_by_label:
            device = devices_by_label[label]
            available = tuple(device.tunable_params())
            unknown = [name for name in names if name not in available]
            if unknown:
                raise ValueError(
                    f"vary[{label!r}] names {unknown} are not tunable parameters of "
                    f"{type(device).__name__} {label!r}. Available: {list(available)}."
                )
            device_selection[label] = names
        elif label in couplings_by_label:
            coupling = couplings_by_label[label]
            available = (coupling.coupling_strength_name,)
            unknown = [name for name in names if name not in available]
            if unknown:
                raise ValueError(
                    f"vary[{label!r}] names {unknown} are not the declared coupling-strength "
                    f"parameter of {type(coupling).__name__} {label!r}. Available: {list(available)}."
                )
            coupling_selection[label] = names
        else:
            raise ValueError(
                f"vary key {label!r} does not match any device or coupling on the chip. "
                f"Available labels: {known_labels}."
            )

    return device_selection, coupling_selection


def _pack_initial_params(
    chip: Chip,
    target_specs: tuple[TargetSpec, ...],
    seed_strength_bounds: tuple[float, float] = (1e-6, 0.25),
    *,
    device_selection: Mapping[str, tuple[str, ...]],
    coupling_selection: Mapping[str, tuple[str, ...]],
) -> tuple[list[str], np.ndarray]:
    """Pack selected parameters in declaration order, root-seeding nonlinear
    cross-Kerr targets; other couplings retain their declared strengths."""
    names = _selected_parameter_names(chip, device_selection, coupling_selection)
    name_set = set(names)
    values: list[float] = []
    for device in chip.devices:
        for param_name, param_value in device.tunable_params().items():
            if f"{device.label}.{param_name}" in name_set:
                values.append(float(param_value))
    coupling_specs = {
        spec.label: spec for spec in target_specs if isinstance(spec.label, str) and spec.kind == "cross_kerr"
    }
    for coupling in chip.couplings:
        key = f"{coupling.label}.{coupling.coupling_strength_name}"
        if key not in name_set:
            continue
        spec = coupling_specs.get(coupling.label)
        if spec is not None:
            if not coupling.reduces_to_crosskerr:
                values.append(float(spec.target))
            else:
                values.append(_estimate_bare_g(chip, coupling, spec, seed_strength_bounds))
        else:
            values.append(float(coupling.coupling_strength))
    return names, np.asarray(values, dtype=float)


def _rebuild_candidate(chip: Chip, names: list[str], values: Any) -> Chip:
    """Rebind the selected fit parameters on an isolated chip copy."""
    return chip.with_params(dict(zip(names, values, strict=True)))


def _working_chip(candidate: Chip, label: Any, evaluator: str) -> Chip:
    """Pick the full chip or a one-hop local neighborhood for this target."""
    if evaluator != "local":
        return candidate
    return build_local_subsystem(candidate, device_labels_for_local_eval(candidate, label))


def _coupling_by_label(chip: Chip, label: Any):
    """Return the coupling on *chip* with the given label."""
    return next(c for c in chip.couplings if c.label == label)


def _target_model(candidate: Chip, spec: TargetSpec, evaluator: str, max_hilbert_dim: int) -> Chip:
    """Resolve and check the actual model before each spectral target evaluation."""
    locator = spec.label
    if isinstance(locator, str) and locator in candidate.coupling_map:
        coupling = candidate.coupling_map[locator]
        locator = (coupling.device_a_label, coupling.device_b_label)
    working = _working_chip(candidate, locator, evaluator)
    if spec.kind in _TARGET_LEVEL_SUMS:
        policy = active_cutoff(working)
        if policy is not None and _TARGET_LEVEL_SUMS[spec.kind] > policy.max_total:
            raise ValueError(
                f"The {spec.kind} target for {spec.label!r} reads labels with total excitation "
                f"{_TARGET_LEVEL_SUMS[spec.kind]}, outside the excitation cutoff N <= {policy.max_total}. "
                "Raise max_total."
            )
        dimension = working.analysis._dressed_dimension(_TARGET_LEVEL_SUMS[spec.kind])
        if dimension > max_hilbert_dim:
            raise ValueError(
                f"The {evaluator} model for {spec.label!r} diagonalizes a matrix of dimension {dimension}, "
                f"exceeding max_hilbert_dim={max_hilbert_dim}. Increase the limit to evaluate this model."
            )
    return working


def _evaluate_checked_spec(candidate: Chip, spec: TargetSpec, evaluator: str, max_hilbert_dim: int) -> Any:
    """Apply the matrix budget to the selected model before evaluating its observable."""
    working = _target_model(candidate, spec, evaluator, max_hilbert_dim)
    return _evaluate_spec(working, spec, "full")


def _evaluate_spec(candidate: Chip, spec: TargetSpec, evaluator: str) -> Any:
    """Compute the observable this ``spec`` anchors, on the appropriate working chip."""
    match spec.kind:
        case "freq":
            return _working_chip(candidate, spec.label, evaluator).freq(spec.label)
        case "anharmonicity":
            return _working_chip(candidate, spec.label, evaluator).dressed_anharmonicity(spec.label)
        case "cross_kerr":
            if isinstance(spec.label, tuple):
                working = _working_chip(candidate, spec.label, evaluator)
                a, b = _pair_labels(spec.label, "cross_kerr")
                return working.static_zz(a, b)
            coupling = _coupling_by_label(candidate, spec.label)
            working = _working_chip(
                candidate,
                (coupling.device_a_label, coupling.device_b_label),
                evaluator,
            )
            return working.static_zz(coupling.device_a_label, coupling.device_b_label)
        case "exchange_rate":
            if not isinstance(spec.label, tuple):
                coupling = _coupling_by_label(candidate, spec.label)
                label = (coupling.device_a_label, coupling.device_b_label)
            else:
                label = spec.label
            return _static_exchange_rate(_working_chip(candidate, label, evaluator), label)
        case "coupling_strength":
            return _coupling_by_label(candidate, spec.label).coupling_strength
        case _:
            raise ValueError(f"Unknown spec kind {spec.kind!r}")


def _auto_bounds(chip: Chip, names: list[str], x0: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
    """Use device-owned parameter bounds and sign-symmetric coupling-strength bounds."""
    devices_by_label = {device.label: device for device in chip.devices}
    couplings_by_label = {coupling.label: coupling for coupling in chip.couplings}
    lower: list[float] = []
    upper: list[float] = []
    for name, value in zip(names, x0, strict=True):
        owner_label, _, param_name = name.rpartition(".")
        device = devices_by_label.get(owner_label)
        if device is not None:
            lo, hi = device.tunable_param_bounds(param_name, float(value))
            lower.append(float(lo))
            upper.append(float(hi))
            continue
        coupling = couplings_by_label.get(owner_label)
        if coupling is not None and param_name == coupling.coupling_strength_name:
            mag = max(0.25, 2.0 * abs(value)) if value != 0.0 else 0.25
            lower.append(-mag)
            upper.append(mag)
            continue
        raise ValueError(f"Unsupported latent parameter {name!r}")
    return np.asarray(lower, dtype=float), np.asarray(upper, dtype=float)


def _parameter_seed_metadata(
    chip: Chip,
    names: list[str],
    target_specs: tuple[TargetSpec, ...],
    *,
    user_starts: set[str],
) -> dict[str, tuple[str, str | None]]:
    """Describe where each optimizer start and coupling-sign branch came from."""
    couplings = {coupling.label: coupling for coupling in chip.couplings}
    coupling_specs = {
        spec.label: spec for spec in target_specs if isinstance(spec.label, str) and spec.kind == "cross_kerr"
    }
    metadata: dict[str, tuple[str, str | None]] = {}
    for name in names:
        owner, _, _ = name.rpartition(".")
        coupling = couplings.get(owner)
        if name in user_starts:
            metadata[name] = ("user start", "user supplied" if coupling is not None else None)
            continue
        if coupling is None:
            metadata[name] = (
                "component declaration",
                None,
            )
            continue
        spec = coupling_specs.get(owner)
        if spec is not None and not coupling.reduces_to_crosskerr:
            metadata[name] = ("target value", "target sign")
        elif spec is not None:
            metadata[name] = ("isolated-pair root solve", "positive convention")
        else:
            metadata[name] = (
                "component declaration",
                "declared sign",
            )
    return metadata


def _identifiability_receipt(
    jacobian: Any,
    names: list[str],
    parameter_scale: np.ndarray,
) -> dict[str, Any]:
    """Analyze the final residual Jacobian in the solver's scaled coordinates."""
    matrix = np.asarray(jacobian, dtype=float)
    if matrix.ndim == 1:
        matrix = matrix.reshape(1, -1)
    scaled = matrix * np.asarray(parameter_scale, dtype=float)[None, :]
    _, singular_values, right_vectors = np.linalg.svd(scaled, full_matrices=True)
    largest = float(singular_values[0]) if singular_values.size else 0.0
    tolerance = float(np.finfo(float).eps * max(scaled.shape, default=0) * largest)
    rank = int(np.count_nonzero(singular_values > tolerance))
    n_parameters = len(names)
    rank_deficient = rank < n_parameters
    if rank_deficient:
        condition_number = float("inf")
    elif singular_values.size:
        condition_number = float(singular_values[0] / singular_values[n_parameters - 1])
    else:
        condition_number = float("inf")

    weak_directions: list[dict[str, Any]] = []
    for index in range(rank, n_parameters):
        vector = right_vectors[index]
        magnitude = float(np.max(np.abs(vector)))
        relative = vector / magnitude if magnitude else vector
        weak_directions.append(
            {
                "singular_value": (float(singular_values[index]) if index < singular_values.size else 0.0),
                "relative_weights": {
                    name: float(weight)
                    for name, weight in zip(names, relative, strict=True)
                    if abs(float(weight)) >= 1e-8
                },
            }
        )

    return {
        "jacobian_rank": rank,
        "jacobian_shape": tuple(int(size) for size in scaled.shape),
        "jacobian_rank_tolerance": tolerance,
        "jacobian_singular_values": tuple(float(value) for value in singular_values),
        "jacobian_condition_number": condition_number,
        "rank_deficient": rank_deficient,
        "weak_parameter_directions": tuple(weak_directions),
        "jacobian_parameter_scale": dict(zip(names, (float(value) for value in parameter_scale), strict=True)),
    }


def _coerce_mapping(value: Mapping | None, name: str) -> dict:
    """Return *value* as a plain dict, treating ``None`` as empty and rejecting non-``Mapping`` input."""
    if value is None:
        return {}
    if isinstance(value, Mapping):
        return dict(value)
    raise TypeError(f"{name} must be None or a Mapping, got {type(value).__name__}")


def _jax_residual_functions(
    chip: Chip,
    names: list[str],
    target_specs: tuple[TargetSpec, ...],
    evaluator: str,
    targets: np.ndarray,
    scales: np.ndarray,
    max_hilbert_dim: int = 10_000,
):
    """Return JAX residual functions for a JAX-backed chip, else ``None``."""
    backend = chip.backend
    if backend.array_module is not jnp:
        return None
    targets_jax = jnp.asarray(targets)
    scales_jax = jnp.asarray(scales)

    def residual(values):
        with _backend_context(backend):
            candidate = _rebuild_candidate(chip, names, values)
            observed = jnp.stack([jnp.asarray(_evaluate_checked_spec(candidate, spec, evaluator, max_hilbert_dim))
                                  for spec in target_specs])
        return (observed - targets_jax) / scales_jax

    return jax.jit(residual), jax.jit(jax.jacrev(residual))


[docs] def fit_a_dress( chip: Chip, *, constraints: Mapping | None = None, vary: Mapping | None = None, start: Mapping | None = None, evaluator: Literal["full", "local"] = "full", max_hilbert_dim: int = 10_000, seed_strength_bounds: tuple[float, float] = (1e-6, 0.25), max_nfev: int = 1000, ) -> FitADressResult: """Find bare parameters for a numerically specified dressed chip. ``fit_a_dress(desired)`` reads component-owned target declarations without evaluating ``desired``. Common spectral devices interpret their declared frequencies and anharmonicities as dressed targets. A ``Capacitive`` edge between two non-computational modes targets the dressed ``exchange_rate``. Other ``Capacitive`` edges and ``CrossKerr`` couplings target the full ``cross_kerr = E11 - E10 - E01 + E00``. The returned :attr:`~quchip.inverse_design.types.FitADressResult.chip` is a fitted clone, and ``desired`` is never mutated. ``constraints`` adds or replaces numerical observables. ``vary`` replaces the component-owned selection of free parameters. ``start`` replaces the selected starting values. Parameters ---------- chip Desired dressed-chip specification. Component declarations supply numerical targets, and no dressed analysis runs on this object. A chip declared for simulation is not a specification. Its bare coupling strengths become ``cross_kerr`` or ``exchange_rate`` targets, and a frequency-only ``vary`` cannot meet them. State such targets explicitly with ``constraints``, and read the unmet-target line of ``summary()``. constraints Additional ``{component_or_pair: {observable: value_or_none}}`` constraints. The supported canonical observables are ``"freq"``, ``"anharmonicity"``, ``"cross_kerr"``, ``"exchange_rate"``, and ``"coupling_strength"``. The function also accepts the aliases ``"zz"``/``"static_zz"``, ``"exchange"``, and ``"g"``. An explicit value replaces the same component default. ``None`` removes it. Device pairs do not have to be direct coupling edges. vary Complete desired-chip allowlist of the bare parameters that can move: ``{component_or_label: name_collection}``. If omitted, each component's conservative inverse-design policy applies. Components that are absent from an explicit mapping are frozen. start Optional ``{"<component>.<parameter>": value}`` replacements for the optimizer's selected starting values. A key that ``vary`` does not select raises an error rather than being silently ignored. evaluator ``"full"`` evaluates each target on the complete model. ``"local"`` explicitly selects one-hop neighborhoods and omits effects of devices outside them. A neighborhood keeps the ports whose targets it holds. Target reports record the selected evaluator. Unsupported local model contributions raise, e.g. a feedline that links a kept port to a discarded one. A ``PortNetwork`` with matrix-form ``scattering`` also raises when a neighborhood keeps only some of its planes. With the mapping form ``{(output, input): amplitude}``, each neighborhood keeps the entries of its own planes. A chip with an active :class:`~quchip.cutoff.ExcitationCutoff` raises, because a neighborhood would spend the same budget on fewer devices. max_hilbert_dim Maximum dimension of each matrix that a target evaluation diagonalizes. A chip whose static model conserves the total excitation number diagonalizes only the excitation sectors that its targets read. Under an excitation cutoff, each target diagonalizes the cutoff classes that its labels occupy, and every candidate keeps the same cutoff. Other chips diagonalize the full product space. A larger matrix raises before seeding or optimization. Each rebound or traced target checks the limit again if its conservation proof changes. No different approximation is selected. Increase the value to permit a larger model. seed_strength_bounds ``(lo, hi)`` magnitude bounds for the seed root solve of the bare coupling strength (:func:`_estimate_bare_g`). This solve serves ``cross_kerr`` coupling targets. The values at both endpoints must bracket the target observable, or seeding raises :class:`ValueError` rather than silently returning a saturated endpoint. max_nfev Maximum number of residual evaluations for the SciPy Trust-Region Reflective solver. Returns ------- FitADressResult Fitted chip clone, loss, residual history, per-target :class:`ObservableReport` tuples, packed parameters, and solver metadata. Raises ------ ValueError Raised when ``seed_strength_bounds`` does not bracket a cross-Kerr seed, an automatic plan is underdetermined, ``vary`` contains unknown, duplicate or invalid parameter selections, or a resource ceiling is exceeded. Warns ----- UserWarning An explicit ``vary`` plan is underdetermined by the target count or by the final scaled-Jacobian rank. A nonconverged automatic fit also returns its candidate with a warning when the final Jacobian does not have full rank. Converged rank-deficient automatic fits raise an error. Notes ----- The fitter normalizes residuals by ``max(|target|, 1e-9)``, so every anchor contributes on an equal *relative*-error basis. A coupling's scalar-strength bounds are symmetric around zero, because the sign of a capacitive-type coupling is physical and the fitter must not constrain it. The solver's convergence tolerances (``ftol``/``xtol``/``gtol`` = ``1e-11``) are fixed fitter policy, not options. So is the ``x_scale`` floor (``1e-3``, applied per parameter as ``max(abs(x0), 1e-3)``). **Identifiability.** The count of free parameters against residuals is necessary but not sufficient. The fitter therefore computes the final SVD rank and condition number from normalized residuals, in the same scaled parameter coordinates that the solver uses. A custom :class:`~quchip.declarative.models.DeviceModel` with a discovered ``tunable_param_names`` (the derived default, not an explicit declaration) is not automatically fit-ready. An unbounded parameter still needs a :meth:`~quchip.devices.base.BaseDevice.tunable_param_bounds` rule before the optimizer can search it. **JAX traceability.** When the chip uses a JAX-native backend, the complete map from parameters to residuals and its exact Jacobian are JAX-traceable. SciPy receives their concrete values for bounded trust-region control. The optimizer itself is not differentiated. Other backends keep SciPy's numerical Jacobian. In both cases, the returned chip stays fully traceable and differentiable. """ if evaluator not in ("full", "local"): raise ValueError(f"Unknown evaluator {evaluator!r}; choose 'full' or 'local'.") if evaluator == "local": # A one-hop neighborhood would spend the global excitation budget on fewer devices. from quchip.engine.routes import require_workflow require_workflow(chip, "fit_a_dress(evaluator='local')") if isinstance(max_hilbert_dim, bool) or not isinstance(max_hilbert_dim, Integral) or max_hilbert_dim < 1: raise ValueError("max_hilbert_dim must be a positive integer.") constraint_mapping = _coerce_mapping(constraints, "constraints") if constraints is not None else None target_specs = build_dressed_target_specs(chip, constraint_mapping) for spec in target_specs: _target_model(chip, spec, evaluator, max_hilbert_dim) if vary is None: targeted_labels = {spec.label for spec in target_specs if isinstance(spec.label, str)} device_selection = { device.label: device.default_fit_parameters() for device in chip.devices if device.label in targeted_labels and device.default_fit_parameters() } coupling_selection = { coupling.label: (coupling.coupling_strength_name,) for coupling in chip.couplings if coupling.label in targeted_labels and any(spec.label == coupling.label and spec.kind != "coupling_strength" for spec in target_specs) } else: device_selection, coupling_selection = _resolve_vary(chip, _coerce_mapping(vary, "vary")) n_free_parameters = len(_selected_parameter_names(chip, device_selection, coupling_selection)) n_target_residuals = len(target_specs) automatic_desired_plan = vary is None if n_free_parameters == 0: raise ValueError( "vary selects zero free parameters; fit_a_dress has nothing to optimize. " "List at least one device parameter or coupling strength with a non-empty selection." ) underdetermined_by_count = n_free_parameters > n_target_residuals if underdetermined_by_count: message = ( f"fit_a_dress has {n_free_parameters} free parameters but only {n_target_residuals} target " f"residuals; the fit is underdetermined by count. Select fewer parameters with vary. " "Target count alone does not guarantee identifiability." ) if automatic_desired_plan: raise ValueError(message) warnings.warn(message, UserWarning, stacklevel=2) names, x0 = _pack_initial_params( chip, target_specs, seed_strength_bounds, device_selection=device_selection, coupling_selection=coupling_selection, ) user_starts: set[str] = set() if start is not None: start_mapping = _coerce_mapping(start, "start") user_starts = set(start_mapping) unknown = sorted(set(start_mapping) - set(names)) if unknown: raise ValueError( f"start contains parameters that are not selected by vary: {unknown}. Selected parameters: {names}." ) indices = {name: index for index, name in enumerate(names)} x0 = x0.copy() for name, value in start_mapping.items(): x0[indices[name]] = float(value) targets_arr = np.asarray([spec.target for spec in target_specs], dtype=float) scales = np.maximum(np.abs(targets_arr), 1e-9) lower, upper = _auto_bounds(chip, names, x0) parameter_scale = np.maximum(np.abs(x0), 1e-3) seed_metadata = _parameter_seed_metadata( chip, names, target_specs, user_starts=user_starts, ) def concrete_residuals(x: np.ndarray) -> np.ndarray: candidate = _rebuild_candidate(chip, names, x) values = np.asarray([_evaluate_checked_spec(candidate, spec, evaluator, max_hilbert_dim) for spec in target_specs], dtype=float) return (values - targets_arr) / scales jacobian_mode = "finite-difference" residuals = concrete_residuals jacobian: Any = "2-point" try: jax_functions = _jax_residual_functions( chip, names, target_specs, evaluator, targets_arr, scales, max_hilbert_dim, ) except ImportError: jax_functions = None if jax_functions is not None: residual_jax, jacobian_jax = jax_functions def residuals(x: np.ndarray) -> np.ndarray: return np.asarray(residual_jax(jnp.asarray(x)), dtype=float) def jacobian(x: np.ndarray) -> np.ndarray: return np.asarray(jacobian_jax(jnp.asarray(x)), dtype=float) jacobian_mode = "jax" untracked_residuals = residuals r0 = untracked_residuals(x0) history: list[float] = [float(r0 @ r0)] history_points: list[np.ndarray] = [np.asarray(x0, dtype=float).copy()] history_values: list[np.ndarray] = [np.array(r0, dtype=float)] def tracked_residuals(x: np.ndarray) -> np.ndarray: """Evaluate residuals and retain one record per distinct parameter vector.""" values = untracked_residuals(x) point = np.asarray(x, dtype=float) if not np.array_equal(point, history_points[-1]): history.append(float(values @ values)) history_points.append(point.copy()) history_values.append(np.array(values, dtype=float)) return values result = least_squares( tracked_residuals, x0=x0, jac=jacobian, bounds=(lower, upper), method="trf", x_scale=parameter_scale, ftol=1e-11, xtol=1e-11, gtol=1e-11, max_nfev=max_nfev, ) final_loss = float(2.0 * result.cost) final_values = untracked_residuals(result.x) if not np.array_equal(history_points[-1], np.asarray(result.x, dtype=float)): history.append(final_loss) history_points.append(np.asarray(result.x, dtype=float).copy()) history_values.append(np.array(final_values, dtype=float)) else: history[-1] = final_loss fitted_chip = _rebuild_candidate(chip, names, result.x) identifiability = _identifiability_receipt(result.jac, names, parameter_scale) if identifiability["rank_deficient"]: weak_names = sorted( { name for direction in identifiability["weak_parameter_directions"] for name in direction["relative_weights"] } ) message = ( f"fit_a_dress final Jacobian rank {identifiability['jacobian_rank']} for " f"{n_free_parameters} free parameters; the fitted bare chip is not locally identifiable. " f"Weak parameter directions involve {weak_names}." ) if automatic_desired_plan and result.status > 0: raise ValueError(message) warnings.warn(message, UserWarning, stacklevel=2) # Reconstruct observed values at start/end by inverting the residual scaling. initial_vec = r0 * scales + targets_arr final_vec = final_values * scales + targets_arr initial_reports = tuple( ObservableReport( spec.kind, spec.label, spec.target, float(initial), float(initial), evaluator, spec.source, ) for spec, initial in zip(target_specs, initial_vec, strict=True) ) final_reports = tuple( ObservableReport( spec.kind, spec.label, spec.target, float(initial), float(final), evaluator, spec.source, ) for spec, initial, final in zip(target_specs, initial_vec, final_vec, strict=True) ) parameter_reports = tuple( FitParameterReport( name=name, initial=float(initial), final=float(final), lower_bound=float(lo), upper_bound=float(hi), seed_source=seed_metadata[name][0], sign_choice=seed_metadata[name][1], ) for name, initial, final, lo, hi in zip( names, x0, result.x, lower, upper, strict=True, ) ) return FitADressResult( chip=fitted_chip, loss=final_loss, history=np.asarray(history, dtype=float), history_params=tuple(dict(zip(names, point, strict=True)) for point in history_points), history_residuals=np.asarray(history_values) * scales, initial_targets=initial_reports, final_targets=final_reports, initial_params=dict(zip(names, x0, strict=True)), final_params=dict(zip(names, result.x, strict=True)), parameter_reports=parameter_reports, solver_info={ "method": "trf", "status": int(result.status), "message": result.message, "nfev": int(result.nfev), "jacobian": jacobian_mode, "n_free_parameters": n_free_parameters, "n_target_residuals": n_target_residuals, "underdetermined_by_count": underdetermined_by_count, "history_axis": "distinct residual evaluation", "n_recorded_evaluations": len(history), "input_contract": "desired-chip", **identifiability, }, )