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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions changelog.d/15.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
UEC-first architecture with strict adapter contract enforcement:
- `parametric_hash` and `executed_parametric_hash` in `ProgramSnapshot` for VQE support
- `canonicalize_bitstrings()` for consistent bit order normalization to `cbit0_right`
- `ProgramMatchStatus` enum for detailed comparison reporting
- `MissingEnvelopeError` when adapter run missing envelope (use `strict=False` for migration)
212 changes: 171 additions & 41 deletions packages/devqubit-engine/src/devqubit_engine/compare/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,8 @@
from typing import Any, Iterator

from devqubit_engine.artifacts import (
find_artifact,
get_artifact_digests,
get_counts,
load_json_artifact,
)
from devqubit_engine.bundle.reader import Bundle, is_bundle_path
from devqubit_engine.circuit.extractors import extract_circuit
Expand All @@ -48,6 +46,12 @@
from devqubit_engine.storage.protocols import ObjectStoreProtocol, RegistryProtocol
from devqubit_engine.uec.calibration import DeviceCalibration
from devqubit_engine.uec.device import DeviceSnapshot
from devqubit_engine.uec.envelope import ExecutionEnvelope
from devqubit_engine.uec.resolver import (
get_program_hash_from_envelope,
resolve_envelope,
)
from devqubit_engine.uec.result import canonicalize_bitstrings
from devqubit_engine.uec.types import ArtifactRef
from devqubit_engine.utils.distributions import (
compute_noise_context,
Expand Down Expand Up @@ -177,56 +181,112 @@ def _extract_metrics(record: RunRecord) -> dict[str, Any]:
def _extract_counts(
record: RunRecord,
store: ObjectStoreProtocol,
envelope: ExecutionEnvelope | None = None,
*,
canonicalize: bool = True,
) -> dict[str, int] | None:
"""Extract counts from run record using artifacts module."""
"""
Extract counts from envelope or Run artifacts.

Uses UEC-first strategy: extracts counts from ExecutionEnvelope,
falling back to Run counts artifact if envelope has no counts.
Always canonicalizes bitstrings to cbit0_right format for comparison.

Parameters
----------
record : RunRecord
Run record.
store : ObjectStoreProtocol
Object store.
envelope : ExecutionEnvelope, optional
Pre-resolved envelope. If None, will be resolved internally.
canonicalize : bool, default=True
Whether to canonicalize bitstrings to cbit0_right format.

Returns
-------
dict or None
Counts as {bitstring: count} in canonical format, or None if not available.
"""
# Use provided envelope or resolve
if envelope is None:
envelope = resolve_envelope(record, store, strict=False)

# Try to get counts from envelope (UEC-first)
if envelope.result.items:
item = envelope.result.items[0]
if item.counts:
raw_counts = item.counts.get("counts")
if isinstance(raw_counts, dict):
if canonicalize:
format_info = item.counts.get("format", {})
bit_order = format_info.get("bit_order", "cbit0_right")
transformed = format_info.get("transformed", False)
canonical = canonicalize_bitstrings(
raw_counts,
bit_order=bit_order,
transformed=transformed,
)
return {k: int(v) for k, v in canonical.items()}
else:
return {str(k): int(v) for k, v in raw_counts.items()}

# Fallback: Run counts artifact
counts_info = get_counts(record, store)
if counts_info is None:
return None

# Canonicalize fallback counts (assume cbit0_right if not specified)
if canonicalize:
return canonicalize_bitstrings(
counts_info.counts,
bit_order="cbit0_right",
transformed=False,
)
return counts_info.counts


def _load_device_snapshot(
record: RunRecord,
store: ObjectStoreProtocol,
envelope: ExecutionEnvelope | None = None,
) -> DeviceSnapshot | None:
"""
Load device snapshot from run record.
Load device snapshot from envelope or run record.

Loads DeviceSnapshot from ExecutionEnvelope artifact, falling back
to record metadata if envelope is not available.
Uses UEC-first strategy: extracts device from ExecutionEnvelope,
falling back to Run record metadata if envelope has no device.

Parameters
----------
record : RunRecord
Run record to extract device snapshot from.
store : ObjectStoreProtocol
Object store for loading artifacts.
envelope : ExecutionEnvelope, optional
Pre-resolved envelope. If None, will be resolved internally.

Returns
-------
DeviceSnapshot or None
Device snapshot if available, None otherwise.
"""
# Load from ExecutionEnvelope
envelope_artifact = find_artifact(record, kind_contains="envelope")
if envelope_artifact:
try:
envelope_data = load_json_artifact(envelope_artifact, store)
if isinstance(envelope_data, dict) and "device" in envelope_data:
device_data = envelope_data["device"]
if isinstance(device_data, dict):
return DeviceSnapshot.from_dict(device_data)
except Exception as e:
logger.debug("Failed to load device from envelope: %s", e)
# Use provided envelope or resolve
if envelope is None:
envelope = resolve_envelope(record, store)

# Get device from envelope (UEC-first)
if envelope.device is not None:
return envelope.device

# Fallback: construct from record metadata
# Fallback: construct from record metadata (Run compatibility)
backend = record.record.get("backend") or {}
if not isinstance(backend, dict):
return None

snapshot_summary = record.record.get("device_snapshot") or {}
if not isinstance(snapshot_summary, dict):
return None
snapshot_summary = {}

calibration = None
cal_data = snapshot_summary.get("calibration")
Expand Down Expand Up @@ -265,9 +325,15 @@ def _extract_circuit_summary(
return None


def _extract_circuit_hash(record: RunRecord) -> str | None:
def _extract_circuit_hash(
record: RunRecord,
envelope: ExecutionEnvelope | None = None,
) -> str | None:
"""
Extract circuit_hash from run record or artifact metadata.
Extract circuit_hash from envelope or run record.

Uses UEC-first strategy: extracts program_hash from envelope,
falling back to Run metadata if not in envelope.

The circuit_hash is a structural hash that ignores parameter values,
making it suitable for comparing parameterized circuits that were
Expand All @@ -277,18 +343,26 @@ def _extract_circuit_hash(record: RunRecord) -> str | None:
----------
record : RunRecord
Run record to extract from.
envelope : ExecutionEnvelope, optional
Pre-resolved envelope.

Returns
-------
str or None
Circuit hash if available.
"""
# Try execute metadata
# Try envelope first (UEC-first)
if envelope is not None:
program_hash = get_program_hash_from_envelope(envelope)
if program_hash:
return program_hash

# Fallback: try execute metadata
execute = record.record.get("execute", {})
if isinstance(execute, dict) and execute.get("circuit_hash"):
return str(execute["circuit_hash"])

# Try artifacts metadata
# Fallback: try artifacts metadata
for artifact in record.artifacts:
meta = artifact.meta or {}
if meta.get("circuit_hash"):
Expand All @@ -300,56 +374,97 @@ def _extract_circuit_hash(record: RunRecord) -> str | None:
def _compare_programs(
run_a: RunRecord,
run_b: RunRecord,
envelope_a: ExecutionEnvelope | None = None,
envelope_b: ExecutionEnvelope | None = None,
) -> ProgramComparison:
"""
Compare program artifacts between two runs.

Computes both exact (digest) and structural (structural/circuit_hash)
matching to support different verification policies.
Uses UEC-first strategy for structural and parametric hash comparison.
Computes exact (digest), structural (program_hash), and parametric
(parametric_hash) matching.

Parameters
----------
run_a : RunRecord
Baseline run.
run_b : RunRecord
Candidate run.
envelope_a : ExecutionEnvelope, optional
Pre-resolved envelope for run_a.
envelope_b : ExecutionEnvelope, optional
Pre-resolved envelope for run_b.

Returns
-------
ProgramComparison
Detailed comparison with exact_match, structural_match, and metadata.
Detailed comparison with status, exact_match, structural_match,
parametric_match, and hash availability.
"""
digests_a = get_artifact_digests(run_a, role="program")
digests_b = get_artifact_digests(run_b, role="program")

# Exact match on content digests
exact_match = digests_a == digests_b

# Extract circuit hashes for structural comparison
hash_a = _extract_circuit_hash(run_a)
hash_b = _extract_circuit_hash(run_b)

# Template match: same circuit structure (ignores parameter values)
# Extract hashes from UEC (program_hash and parametric_hash)
hash_a = None
hash_b = None
param_hash_a = None
param_hash_b = None
hash_available = True

if envelope_a and envelope_a.program:
hash_a = envelope_a.program.program_hash
param_hash_a = envelope_a.program.parametric_hash
if envelope_b and envelope_b.program:
hash_b = envelope_b.program.program_hash
param_hash_b = envelope_b.program.parametric_hash

# Check if hashes are available (manual runs won't have them)
if (envelope_a and envelope_a.metadata.get("manual_run")) or (
envelope_b and envelope_b.metadata.get("manual_run")
):
# At least one is manual run
if not hash_a or not hash_b:
hash_available = False

# Structural match: same circuit structure (ignores parameter values)
structural_match = False
if hash_a and hash_b:
structural_match = hash_a == hash_b
elif exact_match:
# If exact match, structural also matches
structural_match = True

if structural_match and not exact_match:
# Parametric match: same structure AND same parameter values
parametric_match = False
if param_hash_a and param_hash_b:
parametric_match = param_hash_a == param_hash_b
elif exact_match:
# If exact match, parametric also matches
parametric_match = True

if structural_match and not parametric_match:
logger.debug(
"Programs differ in content but match in structure (circuit_hash=%s)",
"Programs match in structure but differ in params "
"(program_hash=%s, parametric_hash_a=%s, parametric_hash_b=%s)",
hash_a,
param_hash_a,
param_hash_b,
)

return ProgramComparison(
exact_match=exact_match,
structural_match=structural_match,
parametric_match=parametric_match,
digests_a=digests_a,
digests_b=digests_b,
circuit_hash_a=hash_a,
circuit_hash_b=hash_b,
parametric_hash_a=param_hash_a,
parametric_hash_b=param_hash_b,
hash_available=hash_available,
)


Expand All @@ -366,6 +481,11 @@ def diff_runs(
"""
Compare two run records comprehensively.

Uses UEC-first strategy: resolves ExecutionEnvelope for each run
and performs comparisons through the unified envelope structure.
This ensures consistent behavior whether runs were created with
adapters or manually.

Performs multi-dimensional comparison including metadata, parameters,
metrics, program artifacts, device calibration drift, and result
distributions.
Expand Down Expand Up @@ -397,6 +517,10 @@ def diff_runs(

logger.info("Comparing runs: %s vs %s", run_a.run_id, run_b.run_id)

# Resolve envelopes (UEC-first strategy, non-strict for backward compat)
envelope_a = resolve_envelope(run_a, store_a, strict=False)
envelope_b = resolve_envelope(run_b, store_b, strict=False)

result = ComparisonResult(
run_id_a=run_a.run_id,
run_id_b=run_b.run_id,
Expand All @@ -412,6 +536,12 @@ def diff_runs(
"project_b": run_b.project,
"backend_a": run_a.backend_name,
"backend_b": run_b.backend_name,
"envelope_a_synthesized": envelope_a.metadata.get(
"synthesized_from_run", False
),
"envelope_b_synthesized": envelope_b.metadata.get(
"synthesized_from_run", False
),
}

# Parameter comparison
Expand All @@ -424,8 +554,8 @@ def diff_runs(
metrics_b = _extract_metrics(run_b)
result.metrics = _diff_dict(metrics_a, metrics_b)

# Program comparison (both exact and structural)
result.program = _compare_programs(run_a, run_b)
# Program comparison (both exact and structural) - uses envelope
result.program = _compare_programs(run_a, run_b, envelope_a, envelope_b)

if result.program.structural_only_match:
result.warnings.append(
Expand All @@ -441,9 +571,9 @@ def diff_runs(
result.program.structural_match,
)

# Device drift analysis
snapshot_a = _load_device_snapshot(run_a, store_a)
snapshot_b = _load_device_snapshot(run_b, store_b)
# Device drift analysis - uses envelope
snapshot_a = _load_device_snapshot(run_a, store_a, envelope_a)
snapshot_b = _load_device_snapshot(run_b, store_b, envelope_b)

if snapshot_a and snapshot_b:
result.device_drift = compute_drift(snapshot_a, snapshot_b, thresholds)
Expand All @@ -453,9 +583,9 @@ def diff_runs(
"Results may not be directly comparable."
)

# Results comparison (TVD)
result.counts_a = _extract_counts(run_a, store_a)
result.counts_b = _extract_counts(run_b, store_b)
# Results comparison (TVD) - uses envelope
result.counts_a = _extract_counts(run_a, store_a, envelope_a)
result.counts_b = _extract_counts(run_b, store_b, envelope_b)

if result.counts_a is not None and result.counts_b is not None:
probs_a = normalize_counts(result.counts_a)
Expand Down
Loading