chore: import upstream snapshot with attribution
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
TypeScript SDK Library Integration Tests / Check Secrets (push) Has been cancelled
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Has been cancelled
SDK Library Integration Tests Runner / Check Secrets (push) Has been cancelled
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Has been cancelled
SDK Library Integration Tests Runner / Build (push) Has been cancelled
SDK Library Integration Tests Runner / openai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_tests (push) Has been cancelled
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / llama_index_tests (push) Has been cancelled
SDK Library Integration Tests Runner / anthropic_tests (push) Has been cancelled
SDK Library Integration Tests Runner / mistral_tests (push) Has been cancelled
SDK Library Integration Tests Runner / groq_tests (push) Has been cancelled
SDK Library Integration Tests Runner / aisuite_tests (push) Has been cancelled
SDK Library Integration Tests Runner / haystack_tests (push) Has been cancelled
SDK Library Integration Tests Runner / dspy_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / crewai_v1_tests (push) Has been cancelled
SDK Library Integration Tests Runner / genai_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_tests (push) Has been cancelled
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Has been cancelled
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Has been cancelled
SDK Library Integration Tests Runner / bedrock_tests (push) Has been cancelled
SDK Library Integration Tests Runner / litellm_tests (push) Has been cancelled
SDK Library Integration Tests Runner / harbor_tests (push) Has been cancelled
SDK Library Integration Tests Runner / Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / render-equality (push) Has been cancelled
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Has been cancelled
Python BE E2E Tests / Python BE E2E (push) Has been cancelled
Python Backend Tests / run-python-backend-tests (push) Has been cancelled
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
SDK E2E Libraries Integration Tests / Check Secrets (push) Has been cancelled
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Has been cancelled
SDK E2E Libraries Integration Tests / build-opik (push) Has been cancelled
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Has been cancelled
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Has been cancelled
TypeScript SDK Build & Publish / build-and-publish (push) Has been cancelled
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Has been cancelled
Backend Tests / discover-tests (push) Has been cancelled
Backend Tests / ${{ matrix.name }} (push) Has been cancelled
Build and Publish SDK / build-and-publish (push) Has been cancelled
Build Opik Docker Images / set-version (push) Has been cancelled
Build Opik Docker Images / build-backend (push) Has been cancelled
Build Opik Docker Images / build-sandbox-executor-python (push) Has been cancelled
Build Opik Docker Images / build-python-backend (push) Has been cancelled
Build Opik Docker Images / build-frontend (push) Has been cancelled
Build Opik Docker Images / create-git-tag (push) Has been cancelled
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Has been cancelled
Docs - Publish / run (push) Has been cancelled
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Has been cancelled
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Has been cancelled
Frontend Unit Tests / Test on Node 20 (push) Has been cancelled
Guardrails E2E Tests / Select Python version matrix (push) Has been cancelled
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Guardrails E2E Tests / 📢 Slack Notification (push) Has been cancelled
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Has been cancelled
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Has been cancelled
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Has been cancelled
Lint Opik Helm Chart / unittest-helm-chart (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:25:44 +08:00
commit 5a558eb09e
11579 changed files with 1795921 additions and 0 deletions
@@ -0,0 +1,473 @@
"""Deep-equal helpers for ``opik migrate dataset`` cascade e2e tests.
The cascade copies four kinds of entities -- experiment + experiment items
+ traces + spans -- with FK fields remapped to the destination. Counts
alone aren't enough; we also need to verify that the content (input,
output, tags, metadata, feedback scores, assertion results, span tree
shape) round-trips byte-for-byte modulo the remapped IDs.
This module provides ``compare_cascade(source_state, destination_state, rest_client)``
that recursively diff-walks both sides and raises ``AssertionError`` with
a precise message on any mismatch.
What's compared
---------------
Experiment level:
- name, type, evaluation_method, tags, metadata
- prompt_versions must be None on destination (epic decision: strip)
Experiment items (paired via source/dest item ordinal, which corresponds
to the source/dest dataset_item_id pairing the cascade builds):
- assertion_results compared as a set keyed by (value, passed, reason)
- feedback_scores compared as a set keyed by (name, value, reason, source)
- status NOT compared -- BE computes it from assertion_results
Traces (paired via cascade's trace_id_remap):
- name, input, output, metadata, tags, start_time, end_time,
thread_id, error_info, ttft, environment
- feedback_scores compared as a set keyed by (name, value, reason, source)
Spans (tree-aware):
- both sides sorted topologically (parent before child)
- parent_span_id remap verified by reconstructing each side's tree and
walking in lockstep
- per-span: name, type, input, output, metadata, model, provider,
tags, usage, start_time, end_time, error_info, ttft,
total_estimated_cost, environment
- feedback_scores on spans compared as a set
What's NOT compared (intentional)
---------------------------------
- any id field (id, project_id, experiment_id, dataset_id,
dataset_version_id, dataset_item_id, trace_id, span_id,
parent_span_id, optimization_id) -- they all change during cascade
- audit fields (created_at, last_updated_at, created_by, last_updated_by)
- BE-computed aggregates on traces/items (trace_count,
total_estimated_cost, duration, usage, span_count, llm_span_count,
has_tool_spans, providers, span_feedback_scores)
- ``project_name`` on experiment metadata (Slice 3 stamps it on the
destination as part of recreate_experiment; differs intentionally)
- ``prompt_versions`` (stripped on destination per epic decision)
- ``optimization_id`` (stripped on destination -- Slice 4's territory)
Trace ``input`` / ``output`` JSON that embeds source-side IDs (e.g.
``{'item': '<src-dataset-item-id>'}``) round-trips verbatim. The cascade
deliberately does not recursively remap arbitrary JSON content. Tests
that seed embedded IDs in trace I/O and care about post-migration
freshness need their own narrower assertion; this module compares the
JSON shape verbatim because that IS the cascade's contract.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from opik.rest_api import OpikApi
# ---------------------------------------------------------------------------
# Top-level entrypoint
# ---------------------------------------------------------------------------
def compare_cascade(
*,
rest_client: OpikApi,
source_experiment: Any,
destination_experiment: Any,
source_item_ids: List[str],
destination_item_ids: List[str],
source_trace_ids: List[str],
destination_trace_ids: List[str],
source_items_compare: List[Any],
destination_items_compare: List[Any],
) -> None:
"""Deep-equal the experiment + items + traces + spans between source and
destination, modulo remapped IDs.
Raises ``AssertionError`` with a focused message on any divergence.
The trace pairing is positional: ``source_trace_ids[i]`` must correspond
to ``destination_trace_ids[i]`` (callers maintain this ordering when
they seed + read). Same for items.
"""
_compare_experiment(source_experiment, destination_experiment)
if len(source_items_compare) != len(destination_items_compare):
raise AssertionError(
f"item count diverged: source={len(source_items_compare)}, "
f"destination={len(destination_items_compare)}"
)
if len(source_trace_ids) != len(destination_trace_ids):
raise AssertionError(
f"trace count diverged: source={len(source_trace_ids)}, "
f"destination={len(destination_trace_ids)}"
)
# Items are typically returned in BE-imposed order (e.g. by created_at
# desc). Pair by dataset_item_id round-trip: source item with source
# dataset_item_id S maps to destination item with destination
# dataset_item_id D where D = item_id_remap[S]. The callers pass the
# already-paired ordered lists, so positional zip works.
for src_item, dst_item in zip(source_items_compare, destination_items_compare):
_compare_experiment_item(src_item, dst_item)
# Traces compared in pairs.
for src_tid, dst_tid in zip(source_trace_ids, destination_trace_ids):
src_trace = rest_client.traces.get_trace_by_id(id=src_tid)
dst_trace = rest_client.traces.get_trace_by_id(id=dst_tid)
_compare_trace(src_trace, dst_trace)
# Spans for this trace. ``project_id`` lives on the trace's read
# shape and scopes the spans query correctly without needing the
# caller to plumb project_name everywhere.
src_spans = _fetch_spans_for_trace(
rest_client, trace_id=src_tid, project_id=src_trace.project_id
)
dst_spans = _fetch_spans_for_trace(
rest_client, trace_id=dst_tid, project_id=dst_trace.project_id
)
_compare_span_trees(src_spans, dst_spans)
# ---------------------------------------------------------------------------
# Experiment-level
# ---------------------------------------------------------------------------
def _compare_experiment(src: Any, dst: Any) -> None:
if src.name != dst.name:
raise AssertionError(
f"experiment.name diverged: source={src.name!r}, destination={dst.name!r}"
)
if src.type != dst.type:
raise AssertionError(
f"experiment.type diverged: source={src.type!r}, destination={dst.type!r}"
)
if src.evaluation_method != dst.evaluation_method:
raise AssertionError(
f"experiment.evaluation_method diverged: source={src.evaluation_method!r}, "
f"destination={dst.evaluation_method!r}"
)
if (src.tags or None) != (dst.tags or None):
raise AssertionError(
f"experiment.tags diverged: source={src.tags!r}, destination={dst.tags!r}"
)
# Metadata: compare modulo Slice 3's injections.
# - ``project_name`` is stamped on the destination by recreate_experiment
# (kept as a forward-import hint); on source it depends on how the
# experiment was created. Strip from both for comparison.
# - ``prompt_versions`` is stripped on the destination by design.
src_meta = dict(src.metadata or {})
dst_meta = dict(dst.metadata or {})
src_meta.pop("project_name", None)
dst_meta.pop("project_name", None)
src_meta.pop("prompt_versions", None)
dst_meta.pop("prompt_versions", None)
if src_meta != dst_meta:
raise AssertionError(
f"experiment.metadata diverged (after stripping project_name + "
f"prompt_versions): source={src_meta!r}, destination={dst_meta!r}"
)
# Per epic decision, destination must have prompt_versions stripped.
if dst.prompt_versions:
raise AssertionError(
f"experiment.prompt_versions should be stripped on destination "
f"(epic decision); got {dst.prompt_versions!r}"
)
# Per epic decision, destination must have optimization_id stripped.
if dst.optimization_id:
raise AssertionError(
f"experiment.optimization_id should be stripped on destination "
f"(Slice 4 cascades the optimization entity); "
f"got {dst.optimization_id!r}"
)
# ---------------------------------------------------------------------------
# Experiment item (Compare view)
# ---------------------------------------------------------------------------
def _compare_experiment_item(src: Any, dst: Any) -> None:
src_ars = _normalize_assertions(src.assertion_results)
dst_ars = _normalize_assertions(dst.assertion_results)
if src_ars != dst_ars:
raise AssertionError(
f"experiment item assertion_results diverged: "
f"source={src_ars}, destination={dst_ars}"
)
src_fs = _normalize_feedback_scores(src.feedback_scores)
dst_fs = _normalize_feedback_scores(dst.feedback_scores)
if src_fs != dst_fs:
raise AssertionError(
f"experiment item feedback_scores diverged: "
f"source={src_fs}, destination={dst_fs}"
)
# ---------------------------------------------------------------------------
# Trace
# ---------------------------------------------------------------------------
_TRACE_DIRECT_FIELDS: Tuple[str, ...] = (
"name",
"input",
"output",
"metadata",
"tags",
"thread_id",
"ttft",
"environment",
)
def _compare_trace(src: Any, dst: Any) -> None:
for field in _TRACE_DIRECT_FIELDS:
s = getattr(src, field, None)
d = getattr(dst, field, None)
if (s or None) != (d or None):
raise AssertionError(
f"trace.{field} diverged: source={s!r}, destination={d!r}"
)
# ``error_info`` model_dump for content comparison; the read shape is
# ErrorInfoPublic on both sides so dicts should be equal.
s_err = _safe_dump(src.error_info)
d_err = _safe_dump(dst.error_info)
if s_err != d_err:
raise AssertionError(
f"trace.error_info diverged: source={s_err}, destination={d_err}"
)
# start_time / end_time round-trip as-is; the cascade copies them
# verbatim from the source trace. ms precision differences would
# surface here.
if src.start_time != dst.start_time:
raise AssertionError(
f"trace.start_time diverged: source={src.start_time}, "
f"destination={dst.start_time}"
)
if (src.end_time or None) != (dst.end_time or None):
raise AssertionError(
f"trace.end_time diverged: source={src.end_time}, "
f"destination={dst.end_time}"
)
# Feedback scores compared as a set keyed by name+value+reason+source.
src_fs = _normalize_feedback_scores(src.feedback_scores)
dst_fs = _normalize_feedback_scores(dst.feedback_scores)
if src_fs != dst_fs:
raise AssertionError(
f"trace.feedback_scores diverged: source={src_fs}, destination={dst_fs}"
)
# ---------------------------------------------------------------------------
# Span tree
# ---------------------------------------------------------------------------
_SPAN_DIRECT_FIELDS: Tuple[str, ...] = (
"name",
"type",
"input",
"output",
"metadata",
"model",
"provider",
"tags",
"usage",
"total_estimated_cost",
"ttft",
"environment",
)
def _compare_span_trees(src_spans: List[Any], dst_spans: List[Any]) -> None:
"""Walk both span trees in parallel, comparing per-node fields and
verifying parent_span_id remap (children's new parent must be the
remapped new root, etc.).
Pairs spans across the two sides by tree position: both lists are
sorted topologically (parents first) and within a parent's children
by (name, start_time). The cascade preserves source order via
``sort_spans_topologically`` so a stable sort makes this
deterministic.
"""
if len(src_spans) != len(dst_spans):
raise AssertionError(
f"span count diverged: source={len(src_spans)}, "
f"destination={len(dst_spans)}"
)
src_sorted = _topo_sort_for_compare(src_spans)
dst_sorted = _topo_sort_for_compare(dst_spans)
src_to_dst_span_id: Dict[Optional[str], Optional[str]] = {None: None}
for src_span, dst_span in zip(src_sorted, dst_sorted):
src_to_dst_span_id[src_span.id] = dst_span.id
for field in _SPAN_DIRECT_FIELDS:
s = getattr(src_span, field, None)
d = getattr(dst_span, field, None)
if (s or None) != (d or None):
raise AssertionError(
f"span.{field} diverged (source span id={src_span.id!r}, "
f"dest span id={dst_span.id!r}): source={s!r}, destination={d!r}"
)
# Timestamps verbatim.
if src_span.start_time != dst_span.start_time:
raise AssertionError(
f"span.start_time diverged (source span id={src_span.id!r}): "
f"source={src_span.start_time}, destination={dst_span.start_time}"
)
if (src_span.end_time or None) != (dst_span.end_time or None):
raise AssertionError(
f"span.end_time diverged (source span id={src_span.id!r}): "
f"source={src_span.end_time}, destination={dst_span.end_time}"
)
# Error info.
s_err = _safe_dump(getattr(src_span, "error_info", None))
d_err = _safe_dump(getattr(dst_span, "error_info", None))
if s_err != d_err:
raise AssertionError(
f"span.error_info diverged (source span id={src_span.id!r}): "
f"source={s_err}, destination={d_err}"
)
# Feedback scores compared as a set.
s_fs = _normalize_feedback_scores(getattr(src_span, "feedback_scores", None))
d_fs = _normalize_feedback_scores(getattr(dst_span, "feedback_scores", None))
if s_fs != d_fs:
raise AssertionError(
f"span.feedback_scores diverged (source span id={src_span.id!r}): "
f"source={s_fs}, destination={d_fs}"
)
# parent_span_id remap correctness: the destination span's
# parent_span_id must be the destination id of the source span's
# parent (or None for root).
expected_dst_parent = src_to_dst_span_id.get(src_span.parent_span_id)
if dst_span.parent_span_id != expected_dst_parent:
raise AssertionError(
f"span.parent_span_id remap incorrect "
f"(source span id={src_span.id!r}, source parent={src_span.parent_span_id!r}): "
f"expected destination parent={expected_dst_parent!r}, "
f"got destination parent={dst_span.parent_span_id!r}"
)
# ---------------------------------------------------------------------------
# Normalisation helpers
# ---------------------------------------------------------------------------
def _normalize_assertions(items: Optional[List[Any]]) -> List[Tuple[Any, Any, Any]]:
"""Set-equality-friendly tuples keyed by the AssertionResult identity:
(value, passed, reason). Sorted so list-equality also works."""
if not items:
return []
return sorted(
((a.value, a.passed, a.reason) for a in items),
key=lambda t: (str(t[0]), bool(t[1]), str(t[2] or "")),
)
def _normalize_feedback_scores(
items: Optional[List[Any]],
) -> List[Tuple[Any, ...]]:
"""Set-equality-friendly tuples keyed by (name, value, reason, source).
Source vs destination scores might come back in different orders; the
sort makes the comparison stable."""
if not items:
return []
return sorted(
(
(
getattr(f, "name", None),
getattr(f, "value", None),
getattr(f, "category_name", None),
getattr(f, "reason", None),
getattr(f, "source", None),
)
for f in items
),
key=lambda t: tuple(str(x) for x in t),
)
def _safe_dump(obj: Any) -> Optional[Dict[str, Any]]:
if obj is None:
return None
if hasattr(obj, "model_dump"):
return obj.model_dump()
if isinstance(obj, dict):
return obj
return {"_raw": str(obj)}
def _topo_sort_for_compare(spans: List[Any]) -> List[Any]:
"""Topological sort that's also stable on (name, start_time).
The cascade re-emits spans in source topological order. The BE may
return them in a different ordering on read; this helper produces a
deterministic order on both sides so paired comparison works.
"""
by_id: Dict[Optional[str], Any] = {s.id: s for s in spans}
children: Dict[Optional[str], List[Any]] = {None: []}
for s in spans:
children.setdefault(s.parent_span_id, []).append(s)
# Sort each parent's children deterministically.
for parent_id, kids in children.items():
kids.sort(key=lambda s: (s.name or "", str(s.start_time)))
out: List[Any] = []
def _walk(parent_id: Optional[str]) -> None:
for s in children.get(parent_id, []):
out.append(s)
_walk(s.id)
_walk(None)
# Defensive: catch orphans (spans whose parent isn't in the same tree).
if len(out) != len(spans):
# Append orphans at the end in deterministic order.
seen = {s.id for s in out}
orphans = [s for s in spans if s.id not in seen]
orphans.sort(key=lambda s: (s.name or "", str(s.start_time)))
out.extend(orphans)
_ = by_id # by_id retained for clarity / potential future use
return out
def _fetch_spans_for_trace(
rest_client: OpikApi, *, trace_id: str, project_id: Optional[str]
) -> List[Any]:
"""Pull all spans for one trace from the BE.
Scopes by ``project_id`` (off the trace's read shape), required by
the BE.
"""
collected: List[Any] = []
page = 1
while True:
resp = rest_client.spans.get_spans_by_project(
project_id=project_id,
trace_id=trace_id,
page=page,
size=200,
)
page_content = resp.content or []
collected.extend(page_content)
if len(page_content) < 200:
break
page += 1
return collected
+701
View File
@@ -0,0 +1,701 @@
"""Shared fixtures + helpers for ``opik migrate`` e2e tests.
These tests drive ``opik migrate dataset`` against a real backend
(localhost during dev, the CI-provisioned Opik in CI). They verify
per-version fidelity end-to-end: items, item-level fields (data,
description, tags, evaluators, execution_policy, source), version-level
fields (suite evaluators, execution_policy, user tags, metadata), and
display order.
Helpers live here so individual test files stay focused on the scenarios
they exercise. Wire-type item reads (via ``rest_stream_parser`` directly)
mirror what ``cli/migrate/datasets/version_replay.py`` does in production
— the SDK dataclass strips per-item ``tags`` during reconstruction, so
asserting tag fidelity requires the wire type.
"""
from __future__ import annotations
import hashlib
import json
import subprocess
import os
import sys
from typing import Any, Dict, Iterator, List, Optional, Set
import pytest
import opik
from opik.api_objects import rest_stream_parser
from opik.rest_api import OpikApi
from opik.rest_api.core.api_error import ApiError
from opik.rest_api.types import dataset_item_public, dataset_version_public
from ...conftest import random_chars
# ---------------------------------------------------------------------------
# Project fixtures — ephemeral source + target, deleted on teardown
# ---------------------------------------------------------------------------
@pytest.fixture
def source_project_name(opik_client: opik.Opik) -> Iterator[str]:
"""Create an ephemeral source project for the migration test.
Deleted on teardown (best-effort — tolerates already-deleted state).
Each test gets its own project so parallel runs don't collide on
dataset-name uniqueness (datasets are workspace-scoped, not
project-scoped, in Opik's BE — see Slice 1's collision pre-flight).
"""
name = f"e2e-cli-migrate-source-{random_chars()}"
opik_client.rest_client.projects.create_project(name=name)
yield name
_best_effort_delete_project(opik_client.rest_client, name)
@pytest.fixture
def target_project_name(opik_client: opik.Opik) -> Iterator[str]:
"""Create an ephemeral target project for the migration test."""
name = f"e2e-cli-migrate-target-{random_chars()}"
opik_client.rest_client.projects.create_project(name=name)
yield name
_best_effort_delete_project(opik_client.rest_client, name)
def _best_effort_delete_project(rest_client: OpikApi, name: str) -> None:
try:
project_id = rest_client.projects.retrieve_project(name=name).id
rest_client.projects.delete_project_by_id(project_id)
except ApiError:
# Already gone (404) or insufficient permissions — either way
# cleanup is non-blocking; leave the project for the next run to
# garbage-collect or for a maintenance task to clean up.
pass
# ---------------------------------------------------------------------------
# CLI invocation
# ---------------------------------------------------------------------------
def run_migrate_cli(
args: List[str],
audit_log_path: Optional[str] = None,
extra_env: Optional[Dict[str, str]] = None,
) -> subprocess.CompletedProcess:
"""Invoke ``opik migrate`` via the installed CLI entrypoint.
Uses subprocess (not Click's ``CliRunner``) so the test exercises the
same code path real users hit — module import, Click group setup,
config-chain resolution, exit-code handling, stderr routing. Returns
the completed process so the caller can assert on ``returncode``,
``stdout``, ``stderr``.
``--audit-log`` is appended when provided. Tests typically write to a
tmp_path so the JSON can be re-read and asserted on.
``extra_env`` is merged into the child process environment — the resume
E2E test uses it to put a test-only ``sitecustomize.py`` seam on
``PYTHONPATH`` that injects a deterministic mid-cascade crash (the child
``os._exit``s, so ``returncode`` is the hard-exit code, not a clean CLI
exit) and redirects the checkpoint dir into a tmp path.
"""
cmd = [sys.executable, "-m", "opik.cli", "migrate"] + args
if audit_log_path is not None:
cmd.extend(["--audit-log", audit_log_path])
env = None
if extra_env is not None:
env = {**os.environ, **extra_env}
return subprocess.run(cmd, capture_output=True, text=True, env=env)
# ---------------------------------------------------------------------------
# Multi-version source seeding
# ---------------------------------------------------------------------------
def create_dataset_shell(
rest_client: OpikApi,
name: str,
project_name: str,
*,
type: Optional[str] = None,
) -> str:
"""Create an empty dataset (or test suite) and return its id.
``type='evaluation_suite'`` produces a test suite (carries version-
level evaluators + execution_policy); omit for a plain dataset.
Caller is responsible for seeding versions via ``apply_changes``.
"""
kwargs: Dict[str, Any] = {"name": name, "project_name": project_name}
if type is not None:
kwargs["type"] = type
rest_client.datasets.create_dataset(**kwargs)
ds = rest_client.datasets.get_dataset_by_identifier(
dataset_name=name, project_name=project_name
)
return ds.id
def apply_changes(
rest_client: OpikApi,
dataset_id: str,
*,
base_version_id: Optional[str],
added_items: Optional[List[Dict[str, Any]]] = None,
edited_items: Optional[List[Dict[str, Any]]] = None,
deleted_ids: Optional[List[str]] = None,
change_description: Optional[str] = None,
suite_evaluators: Optional[List[Dict[str, Any]]] = None,
suite_execution_policy: Optional[Dict[str, int]] = None,
metadata: Optional[Dict[str, str]] = None,
user_tags: Optional[List[str]] = None,
override: bool = False,
) -> str:
"""Send ``apply_dataset_item_changes`` and return the new version id.
Thin wrapper over the raw REST endpoint that mirrors the BE schema's
field names. Used to seed multi-version source datasets for migration
tests. ``override=True`` is required for the first version (when
``base_version_id=None``); see the BE validation in
``DatasetItemService.applyDeltaChanges``.
"""
request: Dict[str, Any] = {}
if change_description is not None:
request["change_description"] = change_description
if base_version_id is not None:
request["base_version"] = base_version_id
if added_items:
request["added_items"] = added_items
if edited_items:
request["edited_items"] = edited_items
if deleted_ids:
request["deleted_ids"] = deleted_ids
if suite_evaluators is not None:
request["evaluators"] = suite_evaluators
if suite_execution_policy is not None:
request["execution_policy"] = suite_execution_policy
if metadata is not None:
request["metadata"] = metadata
if user_tags is not None:
request["tags"] = user_tags
new_version = rest_client.datasets.apply_dataset_item_changes(
id=dataset_id, request=request, override=override
)
return new_version.id
# ---------------------------------------------------------------------------
# Verification helpers (read side)
# ---------------------------------------------------------------------------
def chronological_versions(
rest_client: OpikApi, dataset_id: str
) -> List[dataset_version_public.DatasetVersionPublic]:
"""Return every version of ``dataset_id`` oldest-first.
The REST endpoint returns newest-first; we paginate to exhaustion and
reverse so tests can iterate alongside source-version order for per-
version comparisons.
"""
out: List[dataset_version_public.DatasetVersionPublic] = []
page = 1
while True:
resp = rest_client.datasets.list_dataset_versions(
id=dataset_id, page=page, size=100
)
if not resp.content:
break
out.extend(resp.content)
if len(resp.content) < 100:
break
page += 1
out.reverse()
return out
def stream_items_wire(
rest_client: OpikApi,
*,
dataset_name: str,
project_name: Optional[str],
version_hash: Optional[str],
) -> List[dataset_item_public.DatasetItemPublic]:
"""Read items at ``version_hash`` via the raw REST stream + wire type.
The SDK helper ``rest_operations.stream_dataset_items`` drops per-item
``tags`` during dataclass reconstruction, so tests that assert tag
fidelity must go through the wire type directly. Mirrors the same
approach used by ``cli/migrate/datasets/version_replay.py`` in
production.
"""
raw_stream = rest_client.datasets.stream_dataset_items(
dataset_name=dataset_name,
project_name=project_name,
dataset_version=version_hash,
)
return rest_stream_parser.read_and_parse_stream(
stream=raw_stream,
item_class=dataset_item_public.DatasetItemPublic,
)
def item_content_hash(item: dataset_item_public.DatasetItemPublic) -> str:
"""Full-fidelity per-item hash covering every persisted user field.
Mirrors the production hash in ``cli/migrate/datasets/version_replay._content_hash_for``
so source-version vs target-version set-equality checks behave the
same way the migration code does internally (i.e. any field change
is treated as a content change).
"""
content: Dict[str, Any] = {"data": dict(item.data) if item.data else {}}
if item.description is not None:
content["description"] = item.description
if item.tags is not None:
content["tags"] = sorted(item.tags)
if item.evaluators is not None:
content["evaluators"] = [
{"name": e.name, "type": e.type, "config": e.config}
for e in item.evaluators
]
if item.execution_policy is not None:
content["execution_policy"] = {
"runs_per_item": item.execution_policy.runs_per_item,
"pass_threshold": item.execution_policy.pass_threshold,
}
if item.source is not None:
content["source"] = item.source
return hashlib.sha256(
json.dumps(content, sort_keys=True, default=str).encode()
).hexdigest()
def item_hashes(items: List[dataset_item_public.DatasetItemPublic]) -> Set[str]:
return {item_content_hash(it) for it in items}
def display_order(
items: List[dataset_item_public.DatasetItemPublic], key: str = "q"
) -> List[Optional[Any]]:
"""Extract one ``data`` field per item in stream order (newest-first).
The stream's order *is* the UI's display order, so two versions' lists
of (e.g.) ``q`` values match iff the visible order matches.
"""
return [(item.data.get(key) if item.data else None) for item in items]
def normalize_evaluators(evals: Optional[List[Any]]) -> List[Dict[str, Any]]:
"""Compare-friendly form of a suite evaluator list.
Strips wire-type wrapping and sorts by name so identical
configurations hash equal regardless of how the BE happened to
serialise them.
"""
if not evals:
return []
return sorted(
({"name": e.name, "type": e.type, "config": e.config} for e in evals),
key=lambda d: d["name"],
)
def normalize_policy(pol: Any) -> Optional[Dict[str, int]]:
"""Compare-friendly form of an execution_policy."""
if pol is None:
return None
return {
"runs_per_item": pol.runs_per_item,
"pass_threshold": pol.pass_threshold,
}
def strip_be_managed_version_tags(
tags: Optional[List[str]],
) -> List[str]:
"""Drop the BE-managed ``'latest'`` marker so source/target tag lists compare equal.
The BE auto-injects ``'latest'`` on the newest version of any dataset
on read; the migration code filters it out before forwarding to avoid
409 conflicts. Tests strip it on both sides for the same reason.
"""
return sorted(t for t in (tags or []) if t != "latest")
# ---------------------------------------------------------------------------
# Cascade seeding (Slice 3: experiment + traces + spans)
#
# Tests seed an experiment + its trace data directly via REST. We do this
# rather than going through ``opik.evaluate`` because the BE-side wire
# shapes are what the cascade reads from, and we want full control over
# trace ids, span tree topology, and feedback score payloads.
# ---------------------------------------------------------------------------
def seed_experiment_with_trace_tree(
rest_client: OpikApi,
*,
experiment_name: str,
dataset_name: str,
dataset_id: str,
dataset_version_id: Optional[str],
project_name: str,
item_ids: List[str],
experiment_config: Optional[Dict[str, Any]] = None,
experiment_type: str = "regular",
evaluation_method: str = "dataset",
experiment_tags: Optional[List[str]] = None,
spans_per_trace: int = 2,
feedback_scores_per_trace: Optional[List[Dict[str, Any]]] = None,
per_item_extras: Optional[List[Dict[str, Any]]] = None,
optimization_id: Optional[str] = None,
trace_environment: Optional[str] = None,
span_environment: Optional[str] = None,
thread_id: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a source experiment + one trace per ``item_id`` + a small span
tree per trace, then attach everything via ``create_experiment_items``.
Returns a dict the cascade tests assert against:
{
"experiment_id": str,
"trace_ids": [str, ...], # one per item_id, same order
"span_ids_by_trace": {trace_id: [root_span_id, child_span_id, ...]},
"feedback_scores_by_trace": {trace_id: [score_dicts...]},
}
``spans_per_trace`` controls the tree size; ``spans_per_trace >= 2``
produces a root + child(ren) layout so the cascade has to remap
parent_span_id. We deliberately do NOT use ``opik.evaluate`` here -- we
want the wire shape, deterministic ids, and to assert on it without
flush/streamer timing concerns.
``trace_environment`` / ``span_environment`` stamp the ClickHouse
``environment`` column on every seeded trace / span; ``thread_id``
groups the traces into one thread so the cascade's env preservation
(OPIK-6695) can be round-trip asserted on traces, spans, and the
BE-materialized thread row.
"""
import datetime as dt
import opik.id_helpers as id_helpers_module
from opik.rest_api.types.experiment_item import ExperimentItem
from opik.rest_api.types.feedback_score_batch_item import (
FeedbackScoreBatchItem,
)
from opik.rest_api.types.span_write import SpanWrite
from opik.rest_api.types.trace_write import TraceWrite
if spans_per_trace < 1:
raise ValueError("spans_per_trace must be >= 1")
now = dt.datetime.now(dt.timezone.utc)
trace_ids: List[str] = []
span_ids_by_trace: Dict[str, List[str]] = {}
feedback_scores_by_trace: Dict[str, List[Dict[str, Any]]] = {}
trace_writes: List[TraceWrite] = []
span_writes: List[SpanWrite] = []
feedback_batch: List[FeedbackScoreBatchItem] = []
for index, item_id in enumerate(item_ids):
trace_id = id_helpers_module.generate_id()
trace_ids.append(trace_id)
trace_writes.append(
TraceWrite(
id=trace_id,
project_name=project_name,
name=f"task-{index}",
start_time=now,
end_time=now + dt.timedelta(milliseconds=10),
input={"item": item_id},
output={"answer": f"output-{index}"},
metadata={"item_id": item_id},
tags=["e2e-cascade"],
thread_id=thread_id,
environment=trace_environment,
)
)
# Span tree: root + (spans_per_trace - 1) children of the root.
# Children all parent on the root so the cascade has to remap
# parent_span_id at least once.
root_span_id = id_helpers_module.generate_id()
span_ids_by_trace[trace_id] = [root_span_id]
span_writes.append(
SpanWrite(
id=root_span_id,
project_name=project_name,
trace_id=trace_id,
parent_span_id=None,
name=f"root-{index}",
type="general",
start_time=now,
end_time=now + dt.timedelta(milliseconds=10),
input={"item": item_id},
output={"answer": f"output-{index}"},
environment=span_environment,
)
)
for child_index in range(spans_per_trace - 1):
child_span_id = id_helpers_module.generate_id()
span_ids_by_trace[trace_id].append(child_span_id)
span_writes.append(
SpanWrite(
id=child_span_id,
project_name=project_name,
trace_id=trace_id,
parent_span_id=root_span_id,
name=f"llm-call-{index}-{child_index}",
type="llm",
start_time=now + dt.timedelta(milliseconds=1),
end_time=now + dt.timedelta(milliseconds=9),
input={"prompt": "..."},
output={"completion": f"output-{index}"},
model="gpt-mock",
provider="mock",
usage={"prompt_tokens": 5, "completion_tokens": 10},
environment=span_environment,
)
)
# Optional: attach feedback scores to the trace.
if feedback_scores_per_trace:
scores_for_this_trace: List[Dict[str, Any]] = []
for score in feedback_scores_per_trace:
feedback_batch.append(
FeedbackScoreBatchItem(
id=trace_id,
project_name=project_name,
name=score["name"],
value=score["value"],
reason=score.get("reason"),
source="sdk",
)
)
scores_for_this_trace.append(score)
feedback_scores_by_trace[trace_id] = scores_for_this_trace
rest_client.traces.create_traces(traces=trace_writes)
rest_client.spans.create_spans(spans=span_writes)
if feedback_batch:
rest_client.traces.score_batch_of_traces(scores=feedback_batch)
# Create the experiment, then attach experiment items wiring item_id
# to trace_id 1:1.
import opik.id_helpers as _id_helpers
new_experiment_id = _id_helpers.generate_id()
create_experiment_kwargs: Dict[str, Any] = {
"id": new_experiment_id,
"name": experiment_name,
"dataset_name": dataset_name,
"type": experiment_type,
"evaluation_method": evaluation_method,
"tags": experiment_tags,
"metadata": experiment_config,
"dataset_version_id": dataset_version_id,
"project_name": project_name,
}
if optimization_id is not None:
create_experiment_kwargs["optimization_id"] = optimization_id
rest_client.experiments.create_experiment(**create_experiment_kwargs)
extras_list = per_item_extras or [{} for _ in item_ids]
if len(extras_list) != len(item_ids):
raise ValueError("per_item_extras must have the same length as item_ids")
# ``assertion_results`` are persisted via the dedicated
# ``assertion_results.store_assertions_batch(entity_type='TRACE', ...)``
# endpoint -- the ``ExperimentItem.assertion_results`` field is dropped
# silently on the BE Write view (it's READ-ONLY on the Compare view,
# computed from the underlying assertion-results entity table). Same
# for the other per-item fidelity fields like input/output -- those
# are BE-computed read aggregates.
#
# The seed builds a separate assertion-batch from each item's extras
# before constructing the ExperimentItem write (which only carries the
# FK fields). This mirrors how the cascade itself writes assertions.
from opik.rest_api.types.assertion_result_batch_item import (
AssertionResultBatchItem,
)
assertion_batch: List[AssertionResultBatchItem] = []
assertion_results_by_trace: Dict[str, List[Dict[str, Any]]] = {}
experiment_items_to_create: List[ExperimentItem] = []
for item_id, trace_id, extras in zip(item_ids, trace_ids, extras_list):
per_item_assertions = extras.get("assertion_results") or []
for ar in per_item_assertions:
value = (
ar.get("value") if isinstance(ar, dict) else getattr(ar, "value", None)
)
passed = (
ar.get("passed")
if isinstance(ar, dict)
else getattr(ar, "passed", None)
)
reason = (
ar.get("reason")
if isinstance(ar, dict)
else getattr(ar, "reason", None)
)
if value is None or passed is None:
continue
assertion_batch.append(
AssertionResultBatchItem(
entity_id=trace_id,
project_name=project_name,
name=value,
status="passed" if passed else "failed",
reason=reason,
source="sdk",
)
)
assertion_results_by_trace.setdefault(trace_id, []).append(
{"value": value, "passed": passed, "reason": reason}
)
# The remaining extras are READ-ONLY on the BE; we don't write
# them. Forwarding them on the ExperimentItem create payload would
# be silently dropped (BE Write view doesn't include them).
experiment_items_to_create.append(
ExperimentItem(
id=_id_helpers.generate_id(),
experiment_id=new_experiment_id,
dataset_item_id=item_id,
trace_id=trace_id,
)
)
rest_client.experiments.create_experiment_items(
experiment_items=experiment_items_to_create
)
if assertion_batch:
rest_client.assertion_results.store_assertions_batch(
entity_type="TRACE",
assertion_results=assertion_batch,
)
return {
"experiment_id": new_experiment_id,
"trace_ids": trace_ids,
"span_ids_by_trace": span_ids_by_trace,
"feedback_scores_by_trace": feedback_scores_by_trace,
"assertion_results_by_trace": assertion_results_by_trace,
}
def find_destination_experiment(
rest_client: OpikApi,
*,
destination_dataset_id: str,
experiment_name: str,
) -> Any:
"""Locate the cascaded experiment at the destination by name + dataset.
Returns the ``ExperimentPublic``. Raises if zero or multiple match;
the cascade is supposed to recreate one experiment per source
experiment, so neither outcome is silently acceptable.
"""
page = rest_client.experiments.find_experiments(
dataset_id=destination_dataset_id,
page=1,
size=100,
name=experiment_name,
)
matched = [e for e in (page.content or []) if e.name == experiment_name]
if len(matched) != 1:
raise AssertionError(
f"expected exactly one destination experiment named "
f"{experiment_name!r} under dataset {destination_dataset_id}, "
f"got {len(matched)}"
)
return matched[0]
def destination_experiment_items(
rest_client: OpikApi,
*,
experiment_id: str,
dataset_id: str,
) -> List[Any]:
"""Materialise the destination experiment's items via the Compare view.
The cascade's source-side read uses
``datasets.find_dataset_items_with_experiment_items`` because only the
Compare view surfaces ``assertion_results`` / ``feedback_scores`` /
``input`` / ``output``. We use the same endpoint for destination
verification so tests can assert on those fields directly (the slim
``stream_experiment_items`` Public view drops them).
Returns a flat list of ``ExperimentItemCompare`` -- one per source
experiment item.
"""
experiment_ids_filter = json.dumps([experiment_id])
collected: List[Any] = []
page = 1
while True:
resp = rest_client.datasets.find_dataset_items_with_experiment_items(
id=dataset_id,
experiment_ids=experiment_ids_filter,
page=page,
size=100,
)
content = resp.content or []
if not content:
break
for ds_item in content:
for ei in ds_item.experiment_items or []:
if ei.experiment_id == experiment_id:
collected.append(ei)
if len(content) < 100:
break
page += 1
return collected
def destination_spans_for_trace(
rest_client: OpikApi, *, trace_id: str, project_name: str
) -> List[Any]:
"""Read all destination spans for one destination trace, paginating.
``get_spans_by_project`` requires ``project_name`` (or ``project_id``)
on the request; without it the BE 400s. We pass the destination
project name explicitly.
"""
out: List[Any] = []
page = 1
while True:
resp = rest_client.spans.get_spans_by_project(
project_name=project_name,
trace_id=trace_id,
page=page,
size=100,
)
if not resp.content:
break
out.extend(resp.content)
if len(resp.content) < 100:
break
page += 1
return out
def destination_feedback_scores_for_trace(
rest_client: OpikApi, *, trace_id: str
) -> List[Any]:
"""Read feedback scores on a destination trace.
The trace's ``feedback_scores`` field on read is the authoritative
source -- the cascade copies them implicitly because trace metadata
isn't the only place they live (per-trace ``feedback_scores`` table).
Today's cascade does NOT explicitly re-emit feedback scores; this
helper lets a test assert that as an explicit known-gap or as
"preserved if and only if the cascade adds the copy".
"""
trace = rest_client.traces.get_trace_by_id(id=trace_id)
return list(trace.feedback_scores or [])
@@ -0,0 +1,318 @@
"""End-to-end test for ``opik migrate dataset`` with a cross-project experiment.
``opik.evaluate`` co-locates an experiment's traces in one project, but the
BE permits ``experiment_items`` rows that point at traces living in a
*different* project from the experiment's own project. The BE populates
``experiment_items.project_id`` from ``traces.project_id`` at write time
(``ExperimentItemService.populateProjectIdFromTraces``), so the rows are
the authoritative source of truth for "which projects do this experiment's
traces actually live in".
This test pins the cascade's cross-project contract: regardless of how
many distinct source projects the experiment's traces are spread across,
the migration funnels every one of them into the single ``--to-project``
destination.
Shape (three distinct source projects per experiment, plus the target):
1. Source dataset lives in project A (``source_project_name``)
2. ``seed_experiment_with_trace_tree`` seeds the experiment + 2 traces in A
3. We mint a trace + span in project B (``cross_project_name_b``) and
another in project C (``cross_project_name_c``), then append two
``experiment_items`` rows pointing the same experiment at them. The
experiment now has traces spread across A, B, and C — the user only
"sees" A (the experiment's own project) but the cascade must reach
into B and C as well.
4. Run ``opik migrate dataset ... --to-project=<destination>``
5. Assert: destination experiment has all 4 items, every destination
trace lives under the destination project, and the source trace ids
(across all three source projects) are not reused.
"""
from __future__ import annotations
import datetime as dt
from pathlib import Path
from typing import Any, Iterator, List
import pytest
import opik
import opik.id_helpers as id_helpers_module
from opik.rest_api.core.api_error import ApiError
from opik.rest_api.types.experiment_item import ExperimentItem
from opik.rest_api.types.span_write import SpanWrite
from opik.rest_api.types.trace_write import TraceWrite
from ...conftest import random_chars
from ...testlib import generate_project_name
from .. import verifiers
from .conftest import (
create_dataset_shell,
destination_experiment_items,
destination_spans_for_trace,
find_destination_experiment,
run_migrate_cli,
seed_experiment_with_trace_tree,
stream_items_wire,
)
PROJECT_NAME = generate_project_name("e2e", __name__)
def _make_cross_project(opik_client: opik.Opik, label: str) -> Iterator[str]:
name = f"e2e-cli-migrate-cross-{label}-{random_chars()}"
rest = opik_client.rest_client
rest.projects.create_project(name=name)
yield name
try:
project_id = rest.projects.retrieve_project(name=name).id
rest.projects.delete_project_by_id(project_id)
except ApiError:
pass
@pytest.fixture
def cross_project_name_b(opik_client: opik.Opik) -> Iterator[str]:
yield from _make_cross_project(opik_client, "b")
@pytest.fixture
def cross_project_name_c(opik_client: opik.Opik) -> Iterator[str]:
yield from _make_cross_project(opik_client, "c")
@pytest.fixture
def dataset_name() -> Iterator[str]:
yield f"e2e-migrate-cross-{random_chars()}"
def _mint_cross_project_trace(
rest: Any,
*,
project_name: str,
label: str,
) -> str:
"""Create one trace + one root span in ``project_name`` and return the trace id."""
now = dt.datetime.now(dt.timezone.utc)
trace_id = id_helpers_module.generate_id()
span_id = id_helpers_module.generate_id()
rest.traces.create_traces(
traces=[
TraceWrite(
id=trace_id,
project_name=project_name,
name=f"task-cross-{label}",
start_time=now,
end_time=now + dt.timedelta(milliseconds=10),
input={"q": f"Q-cross-{label}"},
output={"answer": f"from-{label}"},
tags=["e2e-cross"],
)
]
)
rest.spans.create_spans(
spans=[
SpanWrite(
id=span_id,
project_name=project_name,
trace_id=trace_id,
parent_span_id=None,
name=f"root-{label}",
type="general",
start_time=now,
end_time=now + dt.timedelta(milliseconds=10),
input={"q": f"Q-cross-{label}"},
output={"answer": f"from-{label}"},
)
]
)
return trace_id
def test_migrate_dataset__cross_project_experiment__all_traces_land_in_target(
opik_client: opik.Opik,
source_project_name: str,
target_project_name: str,
cross_project_name_b: str,
cross_project_name_c: str,
dataset_name: str,
tmp_path: Path,
) -> None:
rest = opik_client.rest_client
# ── Seed the source dataset in project A ──
# All items (same-project + both cross-project) are added to a single
# v1 so the experiment's items all resolve under the same dataset
# version -- mixing experiment_items rows against different dataset
# versions causes the Compare endpoint to surface incomplete rows
# (missing ``source``). That's a known corner case unrelated to the
# cross-project plumbing under test, so we sidestep it by seeding a
# single version.
from opik import id_helpers
from opik.rest_api.types.dataset_item_write import DatasetItemWrite
source_id = create_dataset_shell(rest, dataset_name, source_project_name)
rest.datasets.create_or_update_dataset_items(
dataset_id=source_id,
items=[
DatasetItemWrite(source="manual", data={"q": "Q1", "a": "A1"}),
DatasetItemWrite(source="manual", data={"q": "Q2", "a": "A2"}),
DatasetItemWrite(source="manual", data={"q": "Q-cross-b", "a": "A-b"}),
DatasetItemWrite(source="manual", data={"q": "Q-cross-c", "a": "A-c"}),
],
batch_group_id=id_helpers.generate_id(),
)
v1 = rest.datasets.list_dataset_versions(id=source_id, page=1, size=1).content[0]
v1_items = stream_items_wire(
rest,
dataset_name=dataset_name,
project_name=source_project_name,
version_hash=v1.version_hash,
)
by_q = {(it.data or {}).get("q"): it.id for it in v1_items}
same_project_item_ids = [by_q["Q1"], by_q["Q2"]]
cross_b_item_id = by_q["Q-cross-b"]
cross_c_item_id = by_q["Q-cross-c"]
assert all(same_project_item_ids) and cross_b_item_id and cross_c_item_id
# ── Seed the experiment with same-project traces in A ──
experiment_name = f"e2e-cross-{random_chars()}"
cascade_seed = seed_experiment_with_trace_tree(
rest,
experiment_name=experiment_name,
dataset_name=dataset_name,
dataset_id=source_id,
dataset_version_id=v1.id,
project_name=source_project_name,
item_ids=same_project_item_ids,
spans_per_trace=2,
)
source_experiment_id = cascade_seed["experiment_id"]
same_project_trace_ids = cascade_seed["trace_ids"]
# ── Mint cross-project traces in two distinct projects (B and C) ──
# The experiment now has traces in 3 projects total: A (same-project,
# 2 traces), B (1 trace), and C (1 trace). The user only "sees" A as
# the experiment's project; the cascade has to discover B and C via
# ``streamExperimentItems``' per-item ``project_id`` field and issue a
# ``search_traces`` / ``search_spans`` against each.
cross_b_trace_id = _mint_cross_project_trace(
rest, project_name=cross_project_name_b, label="b"
)
cross_c_trace_id = _mint_cross_project_trace(
rest, project_name=cross_project_name_c, label="c"
)
rest.experiments.create_experiment_items(
experiment_items=[
ExperimentItem(
id=id_helpers_module.generate_id(),
experiment_id=source_experiment_id,
dataset_item_id=cross_b_item_id,
trace_id=cross_b_trace_id,
),
ExperimentItem(
id=id_helpers_module.generate_id(),
experiment_id=source_experiment_id,
dataset_item_id=cross_c_item_id,
trace_id=cross_c_trace_id,
),
]
)
# ── Run the migration to the destination project ──
audit_path = tmp_path / "audit.json"
result = run_migrate_cli(
[
"dataset",
dataset_name,
"--to-project",
target_project_name,
],
audit_log_path=str(audit_path),
)
assert result.returncode == 0, result.stdout + result.stderr
# ── Verify destination ─────────────────────────────────────────────
dest_dataset = rest.datasets.get_dataset_by_identifier(
dataset_name=dataset_name, project_name=target_project_name
)
dest_exp = find_destination_experiment(
rest,
destination_dataset_id=dest_dataset.id,
experiment_name=experiment_name,
)
# All 4 source experiment items should round-trip (2 same-project +
# 1 from project B + 1 from project C).
expected_count = len(same_project_trace_ids) + 2
dest_items = destination_experiment_items(
rest, experiment_id=dest_exp.id, dataset_id=dest_dataset.id
)
assert len(dest_items) == expected_count, (
f"expected {expected_count} destination experiment items "
f"(same-project + 2 cross-project), got {len(dest_items)}"
)
# Every destination trace must live in the target project — including
# the ones that originally lived in project B and project C. This is
# the contract the cross-project plumbing protects: without
# ``_discover_trace_projects``, the search_traces call would scope to
# the experiment's own project (A) only and both cross-project traces
# would be silently dropped, leaving 2 fewer items at the destination.
dest_trace_ids: List[str] = []
for it in dest_items:
assert it.trace_id is not None, "destination experiment item missing trace_id"
dest_trace_ids.append(it.trace_id)
verifiers.verify_trace(
opik_client=opik_client,
trace_id=it.trace_id,
project_name=target_project_name,
)
# Per-span fidelity per source project: spans live under the same project
# as their parent trace and are discovered+read by the cascade in the
# same per-project loop as traces. So the cross-project safety we just
# verified for traces only holds if the per-project ``search_spans``
# loop also fired for B and C; if it didn't, the cross-project traces
# would land at the destination but with zero spans attached.
#
# Trace ``name`` is the stable handle for classifying which source
# project a destination trace came from:
# * ``task-N`` -> same-project (A), seeded with root + 1 LLM child
# * ``task-cross-b`` -> cross-project from B, seeded with 1 root span
# * ``task-cross-c`` -> cross-project from C, seeded with 1 root span
expected_spans_by_trace_name = {"task-cross-b": 1, "task-cross-c": 1}
for i in range(len(same_project_trace_ids)):
expected_spans_by_trace_name[f"task-{i}"] = 2 # root + 1 LLM child
for it in dest_items:
dest_trace = rest.traces.get_trace_by_id(id=it.trace_id)
assert dest_trace.name in expected_spans_by_trace_name, (
f"unexpected destination trace name {dest_trace.name!r} -- the "
f"cross-project trace naming contract changed and this test "
f"doesn't know which source project to attribute it to"
)
dest_spans = destination_spans_for_trace(
rest, trace_id=it.trace_id, project_name=target_project_name
)
expected = expected_spans_by_trace_name[dest_trace.name]
assert len(dest_spans) == expected, (
f"destination trace {dest_trace.name!r} should have {expected} "
f"span(s) at the destination, got {len(dest_spans)} -- if 0 for "
f"a cross-project trace, the per-project search_spans loop didn't "
f"fire for that source project"
)
# Fresh destination ids — migrate is copy-not-move. No source trace id
# (from any of the 3 source projects) may be reused at the destination.
source_trace_ids = set(same_project_trace_ids) | {
cross_b_trace_id,
cross_c_trace_id,
}
overlap = source_trace_ids & set(dest_trace_ids)
assert not overlap, (
f"destination trace ids must be fresh, but found source ids reused: {overlap}"
)
@@ -0,0 +1,483 @@
"""End-to-end tests for ``opik migrate dataset`` against a real Opik backend.
Covers the plain-dataset path: full version replay, plus the experiment +
trace + span cascade that rides along with the dataset. The test-suite
path lives in ``test_migrate_test_suite_e2e.py``.
Each test:
1. Seeds a multi-version source dataset directly via the REST API (so we
control every per-version delta the migration has to replay) and,
where the test calls for it, an experiment + traces + spans attached
to one of the source versions
2. Runs ``opik migrate dataset`` as a subprocess so the actual CLI
entrypoint, Click group, and exit-code handling are exercised
3. Reads back the target via the raw REST stream + wire type (the SDK
helper drops per-item tags) and asserts per-version content +
display-order fidelity, plus -- where relevant -- destination
experiment + trace + span fidelity and FK remapping
Shared helpers live in ``conftest.py``.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Iterator
import pytest
import opik
from opik import synchronization
from ...conftest import random_chars
from ...testlib import generate_project_name
from ._cascade_comparison import compare_cascade
from .conftest import (
apply_changes,
chronological_versions,
create_dataset_shell,
destination_experiment_items,
destination_feedback_scores_for_trace,
destination_spans_for_trace,
display_order,
find_destination_experiment,
item_hashes,
run_migrate_cli,
seed_experiment_with_trace_tree,
stream_items_wire,
)
# Per ``sdks/python/AGENTS.md``: every e2e module sources PROJECT_NAME from
# ``generate_project_name("e2e", __name__)`` so backend project names are
# isolated per test module + the autouse ``configure_e2e_tests_env`` fixture
# can patch ``OPIK_PROJECT_NAME`` to match.
PROJECT_NAME = generate_project_name("e2e", __name__)
@pytest.fixture
def dataset_name() -> Iterator[str]:
yield f"e2e-migrate-{random_chars()}"
class TestMigrateDatasetVersionReplay:
"""Default ``opik migrate dataset`` flow: full version-history replay.
Pin the slice 2 contract: target version count == source version
count, per-version content set-equal under hash, display order
preserved at every version.
"""
def test_three_version_dataset_with_mixed_deltas_round_trips(
self,
opik_client: opik.Opik,
source_project_name: str,
target_project_name: str,
dataset_name: str,
tmp_path: Path,
) -> None:
rest = opik_client.rest_client
# Seed: 3 versions exercising every delta kind.
# v1: adds Q1, Q2, Q3.
# v2: edits Q1 (data change), adds Q4.
# v3: edits Q3 (data change), deletes Q2, adds Q5.
source_id = create_dataset_shell(rest, dataset_name, source_project_name)
# v1 of a plain dataset is created via the REST insert path (mirrors
# what ``Dataset.insert`` does at higher level). Use create_or_update
# so we get exactly one BE version with all three items.
from opik import id_helpers
from opik.rest_api.types.dataset_item_write import DatasetItemWrite
rest.datasets.create_or_update_dataset_items(
dataset_id=source_id,
items=[
DatasetItemWrite(source="manual", data={"q": "Q1", "a": "A1"}),
DatasetItemWrite(source="manual", data={"q": "Q2", "a": "A2"}),
DatasetItemWrite(source="manual", data={"q": "Q3", "a": "A3"}),
],
batch_group_id=id_helpers.generate_id(),
)
v1 = rest.datasets.list_dataset_versions(id=source_id, page=1, size=1).content[
0
]
v1_items = stream_items_wire(
rest,
dataset_name=dataset_name,
project_name=source_project_name,
version_hash=v1.version_hash,
)
by_q = {item.data["q"]: item for item in v1_items if item.data}
# v2: edit Q1's answer + add Q4.
v2_id = apply_changes(
rest,
source_id,
base_version_id=v1.id,
edited_items=[
{"id": by_q["Q1"].id, "data": {"q": "Q1", "a": "A1-EDITED"}},
],
added_items=[{"data": {"q": "Q4", "a": "A4"}, "source": "manual"}],
change_description="v2 — edit Q1, add Q4",
)
# v3: edit Q3, delete Q2, add Q5.
apply_changes(
rest,
source_id,
base_version_id=v2_id,
edited_items=[
{"id": by_q["Q3"].id, "data": {"q": "Q3", "a": "A3-EDITED"}},
],
deleted_ids=[by_q["Q2"].id],
added_items=[{"data": {"q": "Q5", "a": "A5"}, "source": "manual"}],
change_description="v3 — delete Q2, edit Q3, add Q5",
)
# Snapshot source expectations per version.
src_versions = chronological_versions(rest, source_id)
assert len(src_versions) == 3
expected_hashes = []
expected_orders = []
for v in src_versions:
items = stream_items_wire(
rest,
dataset_name=dataset_name,
project_name=source_project_name,
version_hash=v.version_hash,
)
expected_hashes.append(item_hashes(items))
expected_orders.append(display_order(items))
# ── Seed an experiment on v1 items so the cascade has something to
# round-trip. Regular-dataset experiments carry per-trace feedback
# scores (test suites carry assertion_results -- covered in
# test_migrate_test_suite_e2e.py). Each item gets one trace with a
# root + 1 LLM child span and a feedback score on the trace. ──
experiment_name = f"e2e-exp-{random_chars()}"
v1_item_ids = [by_q["Q1"].id, by_q["Q2"].id, by_q["Q3"].id]
cascade_seed = seed_experiment_with_trace_tree(
rest,
experiment_name=experiment_name,
dataset_name=dataset_name,
dataset_id=source_id,
dataset_version_id=v1.id,
project_name=source_project_name,
item_ids=v1_item_ids,
experiment_config={"runner": "e2e-cascade-test"},
experiment_tags=["e2e", "cascade"],
spans_per_trace=2, # root + 1 LLM child -> exercises parent_span_id remap
feedback_scores_per_trace=[
{"name": "correctness", "value": 0.9, "reason": "matches reference"},
{"name": "latency_p95", "value": 230.5},
],
)
# Run the migration.
audit_path = tmp_path / "audit.json"
result = run_migrate_cli(
[
"dataset",
dataset_name,
"--to-project",
target_project_name,
],
audit_log_path=str(audit_path),
)
assert result.returncode == 0, result.stdout + result.stderr
# Verify target: same version count, per-version content set-equal
# under hash, display order matches at every version.
target = rest.datasets.get_dataset_by_identifier(
dataset_name=dataset_name, project_name=target_project_name
)
tgt_versions = chronological_versions(rest, target.id)
assert len(tgt_versions) == len(src_versions), (
f"target version count {len(tgt_versions)} != source {len(src_versions)} "
"— Slice 2 contract requires N=N"
)
for src_v, tgt_v, exp_hashes, exp_order in zip(
src_versions, tgt_versions, expected_hashes, expected_orders
):
items = stream_items_wire(
rest,
dataset_name=dataset_name,
project_name=target_project_name,
version_hash=tgt_v.version_hash,
)
actual_hashes = item_hashes(items)
actual_order = display_order(items)
assert actual_hashes == exp_hashes, (
f"version {tgt_v.version_name}: target items don't match source. "
f"Missing on target: {exp_hashes - actual_hashes}; "
f"extra on target: {actual_hashes - exp_hashes}"
)
assert actual_order == exp_order, (
f"version {tgt_v.version_name}: display order diverged "
f"(source: {exp_order}, target: {actual_order})"
)
# Audit log records one per-version entry per replayed source version.
audit = json.loads(audit_path.read_text())
per_version_records = [
a for a in audit["actions"] if a["type"] == "replay_dataset_version"
]
assert len(per_version_records) == len(src_versions)
# Per-version deltas: v1=(3 adds), v2=(1 add, 1 mod), v3=(1 add, 1 mod, 1 del).
assert (
per_version_records[0]["items_added"],
per_version_records[0]["items_modified"],
per_version_records[0]["items_deleted"],
) == (3, 0, 0)
assert (
per_version_records[1]["items_added"],
per_version_records[1]["items_modified"],
per_version_records[1]["items_deleted"],
) == (1, 1, 0)
assert (
per_version_records[2]["items_added"],
per_version_records[2]["items_modified"],
per_version_records[2]["items_deleted"],
) == (1, 1, 1)
# ── Cascade fidelity ──
# The destination project should now have a copy of the source
# experiment with: a remapped dataset_version_id, fresh item ids
# carrying remapped trace ids, traces+spans landing under the
# destination project, feedback scores re-emitted on the destination
# traces, and per-item write-side fidelity (input/output) preserved.
dest_exp = find_destination_experiment(
rest,
destination_dataset_id=target.id,
experiment_name=experiment_name,
)
# FKs remapped.
assert dest_exp.id != cascade_seed["experiment_id"]
assert dest_exp.dataset_id == target.id
# The destination experiment must reference one of the target
# versions (the cascade picks the remap of v1).
target_version_ids = {v.id for v in tgt_versions}
assert dest_exp.dataset_version_id in target_version_ids
# Items: one per source item, with FRESH trace ids (disjoint from
# source). Per-item input/output/usage/cost are READ-ONLY on the BE
# (computed/aggregated from the underlying trace + span entities);
# we assert the trace + span fidelity below instead.
dest_items = destination_experiment_items(
rest,
experiment_id=dest_exp.id,
dataset_id=target.id,
)
assert len(dest_items) == len(v1_item_ids)
dest_trace_ids = {it.trace_id for it in dest_items}
assert dest_trace_ids.isdisjoint(set(cascade_seed["trace_ids"])), (
"destination experiment items should reference new trace ids, "
"not the source's"
)
# Each destination trace exists under the target project and has the
# same span shape as the source (root + 1 child = 2 spans).
for new_trace_id in dest_trace_ids:
dest_spans = destination_spans_for_trace(
rest,
trace_id=new_trace_id,
project_name=target_project_name,
)
assert len(dest_spans) == 2, (
f"trace {new_trace_id} should have 2 spans (root + child), "
f"got {len(dest_spans)}"
)
# Topological remap: exactly one root (parent_span_id=None),
# the other span points at the root via parent_span_id.
roots = [s for s in dest_spans if s.parent_span_id is None]
assert len(roots) == 1, f"trace {new_trace_id} should have one root span"
children = [s for s in dest_spans if s.parent_span_id is not None]
assert all(c.parent_span_id == roots[0].id for c in children), (
f"trace {new_trace_id} child spans should remap parent_span_id "
"to the new root id"
)
# Trace-level feedback scores re-emitted on the destination trace.
dest_scores = destination_feedback_scores_for_trace(
rest, trace_id=new_trace_id
)
score_names = {s.name for s in dest_scores}
assert score_names == {"correctness", "latency_p95"}, (
f"trace {new_trace_id}: expected feedback score names "
f"{{'correctness', 'latency_p95'}}, got {score_names}"
)
# ── Deep-equal source vs. destination ──
# Verify field-by-field that experiment + items + traces + spans
# round-trip the cascade modulo remapped IDs. Pairing strategy:
# both sides sorted by trace ``name`` (assigned by the seed as
# "task-0", "task-1", "task-2" and carried verbatim through the
# cascade), guaranteeing stable positional correspondence.
src_exp = find_destination_experiment(
rest,
destination_dataset_id=source_id,
experiment_name=experiment_name,
)
src_items_compare = destination_experiment_items(
rest,
experiment_id=cascade_seed["experiment_id"],
dataset_id=source_id,
)
# Sort both sides by trace name for stable pairing. Build a
# trace_id -> name map by reading each trace once.
src_trace_names = {
it.trace_id: rest.traces.get_trace_by_id(id=it.trace_id).name
for it in src_items_compare
}
dst_trace_names = {
it.trace_id: rest.traces.get_trace_by_id(id=it.trace_id).name
for it in dest_items
}
src_items_compare.sort(key=lambda it: src_trace_names[it.trace_id])
dest_items_sorted = sorted(
dest_items, key=lambda it: dst_trace_names[it.trace_id]
)
src_trace_ids_sorted = [it.trace_id for it in src_items_compare]
dst_trace_ids_sorted = [it.trace_id for it in dest_items_sorted]
compare_cascade(
rest_client=rest,
source_experiment=src_exp,
destination_experiment=dest_exp,
source_item_ids=v1_item_ids,
destination_item_ids=[it.dataset_item_id for it in dest_items_sorted],
source_trace_ids=src_trace_ids_sorted,
destination_trace_ids=dst_trace_ids_sorted,
source_items_compare=src_items_compare,
destination_items_compare=dest_items_sorted,
)
class TestMigrateDatasetEnvironmentPreservation:
"""OPIK-6695: the cascade must preserve the ``environment`` column on
traces, spans, and the BE-materialized ``trace_threads`` row.
Pre-2026-05-07 rows default to ``''`` in ClickHouse and replay
trivially; the regression this guards is the post-migration reset of a
non-empty ``environment`` to ``''`` because the re-emit payload didn't
carry the field.
"""
def test_environment_round_trips_on_traces_spans_and_threads(
self,
opik_client: opik.Opik,
source_project_name: str,
target_project_name: str,
dataset_name: str,
tmp_path: Path,
) -> None:
from opik import id_helpers
from opik.rest_api.types.dataset_item_write import DatasetItemWrite
rest = opik_client.rest_client
# Single-version dataset with two items -> two cascaded traces.
source_id = create_dataset_shell(rest, dataset_name, source_project_name)
rest.datasets.create_or_update_dataset_items(
dataset_id=source_id,
items=[
DatasetItemWrite(source="manual", data={"q": "Q1", "a": "A1"}),
DatasetItemWrite(source="manual", data={"q": "Q2", "a": "A2"}),
],
batch_group_id=id_helpers.generate_id(),
)
v1 = rest.datasets.list_dataset_versions(id=source_id, page=1, size=1).content[
0
]
v1_items = stream_items_wire(
rest,
dataset_name=dataset_name,
project_name=source_project_name,
version_hash=v1.version_hash,
)
item_ids = [it.id for it in v1_items]
# Seed: traces tagged environment="production" + grouped into one
# thread; spans tagged environment="staging". The trace env and
# span env differ deliberately so a single shared value couldn't
# mask a per-entity bug, and the thread inherits the trace env.
experiment_name = f"e2e-env-{random_chars()}"
thread_id = f"env-thread-{random_chars()}"
seed_experiment_with_trace_tree(
rest,
experiment_name=experiment_name,
dataset_name=dataset_name,
dataset_id=source_id,
dataset_version_id=v1.id,
project_name=source_project_name,
item_ids=item_ids,
spans_per_trace=2,
trace_environment="production",
span_environment="staging",
thread_id=thread_id,
)
audit_path = tmp_path / "audit.json"
result = run_migrate_cli(
["dataset", dataset_name, "--to-project", target_project_name],
audit_log_path=str(audit_path),
)
assert result.returncode == 0, result.stdout + result.stderr
target = rest.datasets.get_dataset_by_identifier(
dataset_name=dataset_name, project_name=target_project_name
)
dest_exp = find_destination_experiment(
rest,
destination_dataset_id=target.id,
experiment_name=experiment_name,
)
dest_items = destination_experiment_items(
rest,
experiment_id=dest_exp.id,
dataset_id=target.id,
)
assert len(dest_items) == len(item_ids)
# (a) traces and (b) spans keep their source environment verbatim.
for dest_item in dest_items:
dest_trace = rest.traces.get_trace_by_id(id=dest_item.trace_id)
assert dest_trace.environment == "production", (
f"trace {dest_item.trace_id} lost environment: "
f"got {dest_trace.environment!r}"
)
dest_spans = destination_spans_for_trace(
rest,
trace_id=dest_item.trace_id,
project_name=target_project_name,
)
assert dest_spans, f"trace {dest_item.trace_id} has no destination spans"
assert all(span.environment == "staging" for span in dest_spans), (
"destination spans lost environment: "
f"{[span.environment for span in dest_spans]}"
)
# (c) the destination thread row -- materialized by the BE from the
# cascaded traces -- inherits the same environment. Polls because
# thread materialization is eventually consistent.
assert synchronization.until(
lambda: bool(
opik_client.search_threads(
project_name=target_project_name,
filter_string=f'id = "{thread_id}"',
)
),
max_try_seconds=30,
), f"destination thread {thread_id!r} never materialized"
threads = opik_client.search_threads(
project_name=target_project_name,
filter_string=f'id = "{thread_id}"',
)
assert len(threads) == 1
assert threads[0].environment == "production", (
f"destination thread {thread_id!r} lost environment: "
f"got {threads[0].environment!r}"
)
@@ -0,0 +1,467 @@
"""End-to-end test for ``opik migrate dataset`` using the natural
``opik.evaluate(...)`` flow as seeding (rather than direct REST writes).
Complements ``test_migrate_dataset_e2e.py`` (precise per-version delta
coverage via REST seeding) by exercising the realistic shape that real
users produce:
1. Create a dataset, ``dataset.insert(...)`` a first batch of items
2. Run ``opik.evaluate(...)`` (experiment E1) under the dataset's project
3. Insert more items
4. Run ``opik.evaluate(...)`` (experiment E2) -- E1 saw 2 items, E2 sees 3
5. ``opik migrate dataset ... --to-project=<dest>``
6. Verify both experiments + items + traces + spans + feedback scores
round-trip with fresh destination ids
In Opik V2 ``opik.evaluate(...)`` always lands its traces in the dataset's
project (the per-evaluate ``project_name`` override is deprecated). The
per-trace project_id scoping that ``_copy_traces_and_spans`` does is
defense-in-depth for BE shapes the SDK doesn't produce today; that
contract is covered by the unit test
``test_span_read_uses_trace_project_not_experiment_project``.
"""
from __future__ import annotations
import time
from pathlib import Path
from typing import Any, Callable, Dict, Iterator
from unittest import mock
import pytest
import opik
from opik.evaluation.metrics import score_result
from ...testlib import generate_project_name
from .. import verifiers
from .conftest import (
destination_experiment_items,
find_destination_experiment,
run_migrate_cli,
)
PROJECT_NAME = generate_project_name("e2e", __name__)
# ``dataset_name`` and ``experiment_name`` fixtures come from
# ``tests/e2e/conftest.py`` (shared per AGENTS.md). For the second
# experiment we derive a sibling name off the standard fixture rather
# than introducing a parallel per-test fixture.
@pytest.fixture
def experiment_name_two(experiment_name: str) -> Iterator[str]:
yield f"{experiment_name}-second"
def _llm_task(item: Dict[str, Any]) -> Dict[str, Any]:
"""Deterministic stand-in for an LLM call.
Returns ``"Paris"`` regardless of input so the equality scorer
produces a deterministic 1.0/0.0 split based on the dataset items'
expected outputs.
"""
return {"output": "Paris"}
def _equals_score(
dataset_item: Dict[str, Any], task_outputs: Dict[str, Any]
) -> score_result.ScoreResult:
reference = dataset_item.get("expected", {}).get("output")
prediction = task_outputs["output"]
value = 1.0 if reference == prediction else 0.0
return score_result.ScoreResult(
name="equals_scoring_function",
value=value,
reason="match" if value == 1.0 else "mismatch",
)
def _wait_until(
predicate: Callable[[], Any],
timeout_seconds: int = 30,
poll_seconds: float = 1.0,
) -> Any:
"""Poll ``predicate()`` until it returns truthy or the timeout elapses.
The streamer flushes traces / spans / feedback scores asynchronously,
so we poll the BE for the artifacts we care about rather than racing
a hardcoded ``sleep()``. Returns the predicate's final value so the
caller can capture e.g. the experiment items in one go.
"""
deadline = time.time() + timeout_seconds
last: Any = None
while time.time() < deadline:
last = predicate()
if last:
return last
time.sleep(poll_seconds)
return last
def test_migrate_dataset__evaluate_shape__round_trips(
opik_client: opik.Opik,
source_project_name: str,
target_project_name: str,
dataset_name: str,
experiment_name: str,
experiment_name_two: str,
tmp_path: Path,
) -> None:
# ── Seed via the natural opik.evaluate(...) flow ──
# Dataset and both evaluate() runs land in source_project_name. We
# add items between runs so E1 sees 2 items and E2 sees 3 -- this
# exercises the "experiments reference different dataset version
# snapshots" case during migration.
dataset = opik_client.create_dataset(dataset_name, project_name=source_project_name)
dataset.insert(
[
{
"input": {"question": "Capital of France?"},
"expected": {"output": "Paris"},
},
{
"input": {"question": "Capital of Italy?"},
"expected": {"output": "Rome"},
},
]
)
# Experiment 1 -- 2 items.
eval1 = opik.evaluate(
dataset=dataset,
task=_llm_task,
scoring_functions=[_equals_score],
experiment_name=experiment_name,
experiment_config={"phase": "first-eval"},
)
# Add a third item between runs.
dataset.insert(
[
{
"input": {"question": "Capital of Spain?"},
"expected": {"output": "Madrid"},
},
]
)
# Experiment 2 -- 3 items.
eval2 = opik.evaluate(
dataset=dataset,
task=_llm_task,
scoring_functions=[_equals_score],
experiment_name=experiment_name_two,
experiment_config={"phase": "second-eval"},
)
# Flush the SDK streamer so all traces / spans / feedback scores land
# on the BE before the migration reads them.
opik.flush_tracker()
# Belt-and-suspenders: poll until both experiments are visible on the
# BE (the streamer's flush returns once the local queue drains, but
# the BE's eventual-consistency window can lag a beat).
rest = opik_client.rest_client
def _experiments_ready() -> bool:
e1 = rest.experiments.get_experiment_by_id(id=eval1.experiment_id)
e2 = rest.experiments.get_experiment_by_id(id=eval2.experiment_id)
return e1 is not None and e2 is not None
assert _wait_until(_experiments_ready), (
"experiments not visible on BE after flush; streamer may have failed"
)
# ── Run the migration ──
audit_path = tmp_path / "audit.json"
result = run_migrate_cli(
[
"dataset",
dataset_name,
"--to-project",
target_project_name,
],
audit_log_path=str(audit_path),
)
assert result.returncode == 0, result.stdout + result.stderr
# ── Verify the destination via the shared verifier layer ─────────────
# ``verifiers.verify_experiment`` / ``verify_trace`` have built-in
# ``synchronization.until`` retry loops for the BE's eventual-consistency
# window right after the migrate completes -- spurious failures from
# not-yet-readable rows are eliminated.
#
# Two name->id lookups remain: the destination dataset id (cascade keeps
# the source name at the destination after the rename) and each
# destination experiment id (cascade is name-preserving on experiments,
# so we resolve by name). Everything else routes through verifiers.
dest_dataset = rest.datasets.get_dataset_by_identifier(
dataset_name=dataset_name, project_name=target_project_name
)
dest_dataset_id = dest_dataset.id
dest_e1 = find_destination_experiment(
rest,
destination_dataset_id=dest_dataset_id,
experiment_name=experiment_name,
)
dest_e2 = find_destination_experiment(
rest,
destination_dataset_id=dest_dataset_id,
experiment_name=experiment_name_two,
)
# Fresh destination ids -- migrate is copy-not-move; new experiments
# must have new ids.
assert dest_e1.id != eval1.experiment_id, (
"destination experiment 1 must have a fresh id, not the source's"
)
assert dest_e2.id != eval2.experiment_id, (
"destination experiment 2 must have a fresh id, not the source's"
)
# Experiment-level shape via the shared verifier (with eventual-
# consistency retry baked in). ``recreate_experiment`` injects
# ``project_name`` into the destination experiment's metadata
# (intentional -- it's recorded so future imports/migrations can
# re-derive the project context). The verifier's metadata check is
# exact-equality, so include it in the expected shape.
verifiers.verify_experiment(
opik_client=opik_client,
id=dest_e1.id,
experiment_name=experiment_name,
experiment_metadata={
"phase": "first-eval",
"project_name": target_project_name,
},
feedback_scores_amount=1, # the equals scorer emits one aggregate
traces_amount=2,
project_name=target_project_name,
)
verifiers.verify_experiment(
opik_client=opik_client,
id=dest_e2.id,
experiment_name=experiment_name_two,
experiment_metadata={
"phase": "second-eval",
"project_name": target_project_name,
},
feedback_scores_amount=1,
traces_amount=3,
project_name=target_project_name,
)
# Per-item shape: every destination experiment item has a fresh
# trace_id + dataset_item_id, and the trace exists under the
# destination project (verified via ``verify_trace`` for its built-in
# eventual-consistency retry). The trace's feedback_scores value /
# reason varies per item (the equals scorer's mismatch produces 0.0
# for some items and 1.0 for others), so per-trace feedback scores
# aren't asserted by exact value here -- ``verify_experiment(
# feedback_scores_amount=1)`` above already pins that the cascade
# re-emitted the equals_scoring_function at the experiment-aggregate
# level.
dest_e1_items = destination_experiment_items(
rest, experiment_id=dest_e1.id, dataset_id=dest_dataset_id
)
dest_e2_items = destination_experiment_items(
rest, experiment_id=dest_e2.id, dataset_id=dest_dataset_id
)
for it in dest_e1_items + dest_e2_items:
assert it.trace_id is not None, "destination experiment item missing trace_id"
assert it.dataset_item_id is not None, (
"destination item missing dataset_item_id"
)
verifiers.verify_trace(
opik_client=opik_client,
trace_id=it.trace_id,
project_name=target_project_name,
)
def test_migrate_dataset__cascade_trace_and_span_comments__round_trip(
opik_client: opik.Opik,
source_project_name: str,
target_project_name: str,
dataset_name: str,
experiment_name: str,
tmp_path: Path,
) -> None:
"""Slice 4 (OPIK-6417): comments on traces + spans survive the cascade.
Seeds an evaluate() run, POSTs a handful of comments on one of the
resulting traces + one of its spans, runs the migrate, and asserts
the destination trace + span carry the same comments in the same
order on read-back. ``add_trace_comment`` / ``add_span_comment`` are
the only write surface (comments are ``READ_ONLY`` on the trace/span
Write payload); the cascade re-emits them via those endpoints after
the destination trace/span lands.
"""
rest = opik_client.rest_client
# ── Seed ──
dataset = opik_client.create_dataset(dataset_name, project_name=source_project_name)
dataset.insert(
[
{
"input": {"question": "Capital of France?"},
"expected": {"output": "Paris"},
},
]
)
eval_result = opik.evaluate(
dataset=dataset,
task=_llm_task,
scoring_functions=[_equals_score],
experiment_name=experiment_name,
experiment_config={"phase": "comments-cascade"},
)
opik.flush_tracker()
def _experiment_ready() -> bool:
return (
rest.experiments.get_experiment_by_id(id=eval_result.experiment_id)
is not None
)
assert _wait_until(_experiment_ready), (
"experiment not visible on BE after flush; streamer may have failed"
)
# Pick one source trace + one of its spans to attach comments to. The
# cascade reads ``comments`` off the bulk trace/span read, so the
# specific id we attach to doesn't matter -- any trace/span in the
# experiment is fine.
source_traces = _wait_until(
lambda: list(
opik_client.search_traces(
project_name=source_project_name,
filter_string=f'experiment_id = "{eval_result.experiment_id}"',
max_results=10,
truncate=False,
)
)
)
assert source_traces, "no source traces visible to seed comments on"
target_trace = source_traces[0]
source_spans = _wait_until(
lambda: list(
opik_client.search_spans(
project_name=source_project_name,
trace_id=target_trace.id,
max_results=10,
truncate=False,
)
)
)
assert source_spans, "no source spans visible to seed comments on"
target_span = source_spans[0]
# POST trace + span comments in a deterministic order so we can assert
# round-trip ordering at the destination.
trace_comment_texts = [
"qa-note: golden path verified",
"pm-note: tracked in OPS-1234",
"debug-note: see screenshot in #incidents",
]
span_comment_texts = [
"first span observation",
"second span observation",
]
for text in trace_comment_texts:
rest.traces.add_trace_comment(id_=target_trace.id, text=text)
for text in span_comment_texts:
rest.spans.add_span_comment(id_=target_span.id, text=text)
# ── Run the migration ──
audit_path = tmp_path / "audit.json"
result = run_migrate_cli(
[
"dataset",
dataset_name,
"--to-project",
target_project_name,
],
audit_log_path=str(audit_path),
)
assert result.returncode == 0, result.stdout + result.stderr
# ── Verify the destination trace + span round-trip the comments ──
dest_dataset = rest.datasets.get_dataset_by_identifier(
dataset_name=dataset_name, project_name=target_project_name
)
dest_experiment = find_destination_experiment(
rest,
destination_dataset_id=dest_dataset.id,
experiment_name=experiment_name,
)
dest_items = _wait_until(
lambda: destination_experiment_items(
rest,
experiment_id=dest_experiment.id,
dataset_id=dest_dataset.id,
)
)
assert dest_items, "no destination experiment items after migrate"
dest_trace_id = dest_items[0].trace_id
assert dest_trace_id is not None
# Verify the destination trace's comments via the shared verifier
# (it polls through ``_retry_until_assertions_pass`` for BE eventual
# consistency, so we don't need a separate hand-rolled wait).
verifiers.verify_trace(
opik_client=opik_client,
trace_id=dest_trace_id,
project_name=target_project_name,
comments=trace_comment_texts,
)
# Span comments: the cascade mints fresh span ids, so we resolve the
# destination span via name match on the source ``target_span``
# before delegating to the shared verifier for the content check.
dest_spans = _wait_until(
lambda: list(
opik_client.search_spans(
project_name=target_project_name,
trace_id=dest_trace_id,
max_results=50,
truncate=False,
)
)
)
assert dest_spans, "no destination spans after migrate"
matching = [s for s in dest_spans if s.name == target_span.name]
assert matching, (
f"destination trace has no span matching source name {target_span.name!r}"
)
# If multiple destination spans share the source span's name (rare;
# the seeded experiment has one root + a few children with distinct
# names), pick the one whose comment count matches before delegating
# to verify_span -- the verifier's exact-equality assert would fail
# against an unrelated namesake otherwise.
candidate_ids = [s.id for s in matching]
if len(candidate_ids) > 1:
chosen = next(
(
s.id
for s in matching
if rest.spans.get_span_by_id(id=s.id).comments
and len(rest.spans.get_span_by_id(id=s.id).comments)
== len(span_comment_texts)
),
candidate_ids[0],
)
else:
chosen = candidate_ids[0]
verifiers.verify_span(
opik_client=opik_client,
span_id=chosen,
trace_id=dest_trace_id,
parent_span_id=mock.ANY,
project_name=target_project_name,
comments=span_comment_texts,
)
@@ -0,0 +1,269 @@
"""End-to-end test for ``opik migrate dataset`` Slice 5 — optimization cascade.
Optimization is a real BE entity, not a logical group-by: it has its own
UUID, its own row in ``OptimizationDAO``, and its own ``dataset_id`` /
``project_id`` FKs. The Optimization Studio "group" view in the UI is
computed by the BE at read time from each experiment's ``optimization_id``
FK plus the optimization's aggregated stats (``numTrials``, etc.); the
persisted shape is one Optimization row + N Experiment rows where
``Experiment.optimization_id == Optimization.id``.
Slice 5's contract: when ``opik migrate dataset`` runs, every optimization
referencing the source dataset is recreated under the destination project
with a fresh id, and every experiment carrying that ``optimization_id``
is recreated at the destination with its FK re-pointed at the new
destination optimization id. The destination's Optimization Studio shows
the same rows + trial groupings as the source.
Shape pinned by this test:
1. Source dataset in project A with 3 items
2. One Optimization (``opt``) on the source dataset
3. Two TRIAL experiments seeded as ``opt``'s trials (each has 1 item)
4. One CONTROL ``regular`` experiment with no optimization_id (1 item)
5. Run ``opik migrate dataset ... --to-project=<destination>``
6. Assert:
a. Destination has exactly one optimization tied to the
destination dataset, with a fresh id (NOT the source id).
b. Both trial experiments are recreated under the destination
project with their ``optimization_id`` pointing at the
destination optimization (NOT the source optimization id).
c. The control experiment has no ``optimization_id`` (untouched).
d. The destination optimization's fidelity fields
(``name`` / ``objective_name`` / ``status``) match the source.
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterator
import pytest
import opik
import opik.id_helpers as id_helpers_module
from opik.rest_api import OpikApi
from ...conftest import random_chars
from ...testlib import generate_project_name
from .conftest import (
create_dataset_shell,
find_destination_experiment,
run_migrate_cli,
seed_experiment_with_trace_tree,
stream_items_wire,
)
PROJECT_NAME = generate_project_name("e2e", __name__)
@pytest.fixture
def dataset_name() -> Iterator[str]:
yield f"e2e-migrate-opt-{random_chars()}"
def _create_source_optimization(
rest: OpikApi,
*,
dataset_name: str,
project_name: str,
optimization_name: str,
) -> str:
"""Create an Optimization tied to ``dataset_name`` in ``project_name``
and return its id. The cascade will discover this via
``find_optimizations(dataset_id=...)``.
"""
optimization_id = id_helpers_module.generate_id()
rest.optimizations.create_optimization(
id=optimization_id,
name=optimization_name,
dataset_name=dataset_name,
project_name=project_name,
objective_name="accuracy",
status="completed",
)
return optimization_id
def test_migrate_dataset__optimization_cascade__trials_grouped_at_destination(
opik_client: opik.Opik,
source_project_name: str,
target_project_name: str,
dataset_name: str,
tmp_path: Path,
) -> None:
rest = opik_client.rest_client
# ── Seed the source dataset with 3 items (2 for trials, 1 for control) ──
from opik import id_helpers
from opik.rest_api.types.dataset_item_write import DatasetItemWrite
source_dataset_id = create_dataset_shell(rest, dataset_name, source_project_name)
rest.datasets.create_or_update_dataset_items(
dataset_id=source_dataset_id,
items=[
DatasetItemWrite(source="manual", data={"q": "trial-a", "a": "A"}),
DatasetItemWrite(source="manual", data={"q": "trial-b", "a": "B"}),
DatasetItemWrite(source="manual", data={"q": "control", "a": "C"}),
],
batch_group_id=id_helpers.generate_id(),
)
v1 = rest.datasets.list_dataset_versions(
id=source_dataset_id, page=1, size=1
).content[0]
v1_items = stream_items_wire(
rest,
dataset_name=dataset_name,
project_name=source_project_name,
version_hash=v1.version_hash,
)
by_q = {(it.data or {}).get("q"): it.id for it in v1_items}
trial_a_item_id = by_q["trial-a"]
trial_b_item_id = by_q["trial-b"]
control_item_id = by_q["control"]
assert trial_a_item_id and trial_b_item_id and control_item_id
# ── Create the source optimization ──
optimization_name = f"e2e-opt-{random_chars()}"
source_optimization_id = _create_source_optimization(
rest,
dataset_name=dataset_name,
project_name=source_project_name,
optimization_name=optimization_name,
)
# ── Seed two trial experiments pointing at the optimization ──
# ``experiment_type="trial"`` is what the Optimization Studio UI
# filters on for the trial sub-list under an optimization row.
trial_a_name = f"trial-a-{random_chars()}"
trial_b_name = f"trial-b-{random_chars()}"
seed_experiment_with_trace_tree(
rest,
experiment_name=trial_a_name,
dataset_name=dataset_name,
dataset_id=source_dataset_id,
dataset_version_id=v1.id,
project_name=source_project_name,
item_ids=[trial_a_item_id],
experiment_type="trial",
optimization_id=source_optimization_id,
)
seed_experiment_with_trace_tree(
rest,
experiment_name=trial_b_name,
dataset_name=dataset_name,
dataset_id=source_dataset_id,
dataset_version_id=v1.id,
project_name=source_project_name,
item_ids=[trial_b_item_id],
experiment_type="trial",
optimization_id=source_optimization_id,
)
# ── Seed a control regular experiment with no optimization_id ──
# Pins that the cascade only touches experiments that actually had
# an optimization_id; the control experiment must round-trip with
# no optimization_id at the destination.
control_name = f"control-{random_chars()}"
seed_experiment_with_trace_tree(
rest,
experiment_name=control_name,
dataset_name=dataset_name,
dataset_id=source_dataset_id,
dataset_version_id=v1.id,
project_name=source_project_name,
item_ids=[control_item_id],
experiment_type="regular",
optimization_id=None,
)
# ── Run the migration ──
audit_path = tmp_path / "audit.json"
result = run_migrate_cli(
[
"dataset",
dataset_name,
"--from-project",
source_project_name,
"--to-project",
target_project_name,
],
audit_log_path=str(audit_path),
)
assert result.returncode == 0, result.stdout + result.stderr
# ── Verify destination optimization fidelity ──
dest_dataset = rest.datasets.get_dataset_by_identifier(
dataset_name=dataset_name, project_name=target_project_name
)
dest_optimizations_page = rest.optimizations.find_optimizations(
dataset_id=dest_dataset.id, page=1, size=10
)
dest_optimizations = list(dest_optimizations_page.content or [])
assert len(dest_optimizations) == 1, (
f"expected exactly one destination optimization tied to dataset "
f"{dest_dataset.id}, got {len(dest_optimizations)}"
)
dest_optimization = dest_optimizations[0]
# Fresh destination id -- migrate is copy-not-move; source id must
# not be reused.
assert dest_optimization.id != source_optimization_id, (
"destination optimization id must be fresh; source id reused"
)
# Fidelity fields round-trip verbatim.
assert dest_optimization.name == optimization_name
assert dest_optimization.objective_name == "accuracy"
assert dest_optimization.status == "completed"
assert dest_optimization.dataset_id == dest_dataset.id
# ── Verify trial experiments are re-pointed at the destination optimization ──
dest_trial_a = find_destination_experiment(
rest,
destination_dataset_id=dest_dataset.id,
experiment_name=trial_a_name,
)
dest_trial_b = find_destination_experiment(
rest,
destination_dataset_id=dest_dataset.id,
experiment_name=trial_b_name,
)
assert dest_trial_a.optimization_id == dest_optimization.id, (
f"trial a should FK to destination optimization {dest_optimization.id}, "
f"got {dest_trial_a.optimization_id}"
)
assert dest_trial_b.optimization_id == dest_optimization.id, (
f"trial b should FK to destination optimization {dest_optimization.id}, "
f"got {dest_trial_b.optimization_id}"
)
# Defence-in-depth: neither trial should still point at the source id.
for dest_trial, label in ((dest_trial_a, "a"), (dest_trial_b, "b")):
assert dest_trial.optimization_id != source_optimization_id, (
f"trial {label}'s destination optimization_id must NOT be the "
f"source id (cascade failed to remap)"
)
# ── Verify control experiment is untouched (no optimization_id) ──
dest_control = find_destination_experiment(
rest,
destination_dataset_id=dest_dataset.id,
experiment_name=control_name,
)
assert not dest_control.optimization_id, (
f"control experiment must not have an optimization_id at the "
f"destination, got {dest_control.optimization_id!r}"
)
# ── Audit log carries per-optimization records ──
import json
audit = json.loads(audit_path.read_text())
migrate_optimization_records = [
a for a in audit["actions"] if a["type"] == "migrate_optimization"
]
assert len(migrate_optimization_records) == 1
record = migrate_optimization_records[0]
assert record["status"] == "ok"
assert record["source_id"] == source_optimization_id
assert record["destination_id"] == dest_optimization.id
@@ -0,0 +1,273 @@
"""End-to-end test for ``opik migrate dataset`` checkpoint/resume (OPIK-7168).
The unit tests prove the resume *logic* against a stand-in client. This test
proves the thing that actually matters for the feature: a **real process
death** mid-cascade leaves a checkpoint on disk that a **real re-run** picks
up, against a **real backend** whose trace-delete cascades spans — ending with
a duplicate-free destination.
Shape:
1. Seed a source dataset (one version) and N experiments, each with its own
trace + span tree, all referencing that dataset.
2. Run ``opik migrate dataset`` with a test-only ``sitecustomize.py`` seam on
``PYTHONPATH`` (nothing test-only lives in the product) that ``os._exit(137)``s
partway through one experiment — after its destination traces are written
and recorded on the checkpoint but before the experiment row is recreated.
This is the uncatchable-kill (OOM-like) interruption the feature exists for;
a clean ``except`` never runs, so only the incrementally-flushed checkpoint
survives. The seam also redirects the checkpoint dir into tmp_path.
3. Assert the run died hard and the checkpoint on disk shows partial progress
(some experiments done, one in flight with recorded dest trace ids).
4. Re-run the *same* command with no crash env. Assert it exits 0, resumes
(skips the already-done experiments, cleans up the interrupted one's
partial traces, re-migrates it), and the destination ends with exactly one
experiment per source experiment — no duplicates — and the checkpoint file
is gone.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Iterator, List
import pytest
import opik
from opik import id_helpers, synchronization
from opik.rest_api.types.dataset_item_write import DatasetItemWrite
from ...conftest import random_chars
from .conftest import (
create_dataset_shell,
destination_experiment_items,
find_destination_experiment,
run_migrate_cli,
seed_experiment_with_trace_tree,
stream_items_wire,
)
# Number of source experiments and the count of experiments completed before
# the first run hard-exits. Crashing after 2 completed experiments (mid the 3rd)
# leaves a non-trivial done-set to skip AND an in-flight experiment to clean up
# + re-migrate on resume.
_NUM_EXPERIMENTS = 5
_CRASH_AFTER_N_EXPERIMENTS = 2
@pytest.fixture
def dataset_name() -> Iterator[str]:
yield f"e2e-migrate-resume-{random_chars()}"
def _write_test_seam(
seam_dir: Path, *, crash_after_n: int, checkpoint_dir: Path
) -> None:
"""Write a test-only ``sitecustomize.py`` the migrate subprocess auto-imports.
Python imports ``sitecustomize`` at interpreter startup, so dropping it on
the subprocess's ``PYTHONPATH`` lets the test inject behaviour **without any
test-only logic living in the product**. Two patches, both test-owned:
* Redirect the checkpoint dir to a tmp path (via ``checkpoint_dir``) so the
test can find/assert the checkpoint without touching the developer's real
``~/.opik`` and without repointing ``HOME`` (which would break the SDK's
backend-config resolution the E2E run relies on).
* Wrap ``_copy_traces_and_spans`` so the real one runs first (destination
traces written + recorded on the checkpoint), then ``os._exit(137)`` after
the Nth experiment — reproducing the exact "traces written, experiment row
not yet created" partial state an OOM leaves mid-cascade. ``crash_after_n``
< 0 disables the crash (used for the resume run).
"""
seam_dir.mkdir(parents=True, exist_ok=True)
(seam_dir / "sitecustomize.py").write_text(
f"""
import os
from pathlib import Path
from opik.cli.migrate import checkpoint as _cp
from opik.cli.migrate.datasets import experiments as _exp
_cp.checkpoint_dir = lambda: Path({str(checkpoint_dir)!r})
_crash_after = {crash_after_n}
if _crash_after >= 0:
_seen = {{"n": 0}}
_real_copy = _exp._copy_traces_and_spans
def _copy_then_maybe_crash(*args, **kwargs):
result = _real_copy(*args, **kwargs)
_seen["n"] += 1
if _seen["n"] > _crash_after:
os._exit(137) # 128 + SIGKILL(9): uncatchable, mimics an OOM kill
return result
_exp._copy_traces_and_spans = _copy_then_maybe_crash
"""
)
def test_migrate_dataset__crash_mid_cascade__resumes_without_duplicates(
opik_client: opik.Opik,
source_project_name: str,
target_project_name: str,
dataset_name: str,
tmp_path: Path,
) -> None:
rest = opik_client.rest_client
# ── Seed the source dataset (single version, one item per experiment) ──
source_id = create_dataset_shell(rest, dataset_name, source_project_name)
rest.datasets.create_or_update_dataset_items(
dataset_id=source_id,
items=[
DatasetItemWrite(source="manual", data={"q": f"Q{i}", "a": f"A{i}"})
for i in range(_NUM_EXPERIMENTS)
],
batch_group_id=id_helpers.generate_id(),
)
v1 = rest.datasets.list_dataset_versions(id=source_id, page=1, size=1).content[0]
v1_items = stream_items_wire(
rest,
dataset_name=dataset_name,
project_name=source_project_name,
version_hash=v1.version_hash,
)
item_id_by_q = {(it.data or {}).get("q"): it.id for it in v1_items}
# ── Seed N experiments, each referencing one dataset item ──
# Names derive from the (already-random) ``dataset_name`` fixture so they're
# unique per run yet reproducible from the fixture, per sdks/python/AGENTS.md.
experiment_names: List[str] = []
for i in range(_NUM_EXPERIMENTS):
name = f"{dataset_name}-exp-{i}"
experiment_names.append(name)
seed_experiment_with_trace_tree(
rest,
experiment_name=name,
dataset_name=dataset_name,
dataset_id=source_id,
dataset_version_id=v1.id,
project_name=source_project_name,
item_ids=[item_id_by_q[f"Q{i}"]],
spans_per_trace=2,
)
audit_path = tmp_path / "audit.json"
migrate_args = ["dataset", dataset_name, "--to-project", target_project_name]
# Test seam: a sitecustomize.py the migrate subprocess auto-imports. It
# redirects the checkpoint dir into tmp_path (hermetic; no real ~/.opik) and
# injects the deterministic mid-cascade crash — all test-owned, nothing in
# the product. PYTHONPATH must keep the SDK's own src on it (the E2E run
# imports opik from source), so the seam dir is prepended.
checkpoint_dir = tmp_path / "checkpoints"
seam_dir = tmp_path / "seam"
existing_pythonpath = os.environ.get("PYTHONPATH", "")
def _seam_env(crash_after_n: int) -> dict:
_write_test_seam(
seam_dir, crash_after_n=crash_after_n, checkpoint_dir=checkpoint_dir
)
return {
"PYTHONPATH": os.pathsep.join(
[str(seam_dir)] + ([existing_pythonpath] if existing_pythonpath else [])
)
}
# ── Run 1: crash deterministically mid-cascade ──
crashed = run_migrate_cli(
migrate_args,
audit_log_path=str(audit_path),
extra_env=_seam_env(_CRASH_AFTER_N_EXPERIMENTS),
)
# os._exit(137) is a hard, uncatchable exit — not a clean CLI exit code.
assert crashed.returncode == 137, (
f"expected hard-exit 137, got {crashed.returncode}\n"
f"stdout={crashed.stdout}\nstderr={crashed.stderr}"
)
# The incrementally-flushed checkpoint must have survived the kill and show
# partial progress: the experiments before the crash completed, and the one
# at the crash is in flight with recorded destination trace ids (its traces
# were written to the backend before the crash).
checkpoints = list(checkpoint_dir.glob("opik-migrate-checkpoint-*.json"))
assert len(checkpoints) == 1, f"expected one checkpoint file, got {checkpoints}"
checkpoint_data = json.loads(checkpoints[0].read_text())
assert (
len(checkpoint_data["completed_experiment_ids"]) == _CRASH_AFTER_N_EXPERIMENTS
)
# The dataset phase (create-temp/replay/optimizations) finished before the
# cascade started, so resume must skip it and reconstruct the remaps from
# the temp destination rather than re-running (and duplicating) it. Under the
# OPIK-7162 ordering the source keeps its original name until the run
# succeeds, and the destination is still under the temp name at crash time —
# the checkpoint records both so resume can re-resolve them.
assert checkpoint_data["dataset_phase_done"] is True
assert checkpoint_data["source_name"] == dataset_name
assert checkpoint_data["temp_dest_name"] == f"{dataset_name}__migrating"
in_flight = checkpoint_data["in_flight"]
assert in_flight is not None, "interrupted experiment must be recorded in flight"
assert in_flight["dest_trace_ids"], (
"the in-flight experiment's destination trace ids must be recorded "
"before the crash so resume can delete them"
)
# Crash landed before the experiment row was created.
assert in_flight["dest_experiment_id"] is None
# ── Run 2: resume to completion (same seam, crash disabled) ──
resumed = run_migrate_cli(
migrate_args, audit_log_path=str(audit_path), extra_env=_seam_env(-1)
)
assert resumed.returncode == 0, resumed.stdout + resumed.stderr
assert "Resuming migration" in resumed.stdout, (
"resumed run should announce it is resuming"
)
# ── Verify destination: exactly one experiment per source, no duplicates ──
dest_dataset = rest.datasets.get_dataset_by_identifier(
dataset_name=dataset_name, project_name=target_project_name
)
# Migrated experiments become readable asynchronously, so poll until the
# destination shows the full set before asserting — otherwise the read can
# race ahead of eventual consistency and flake. ``until`` returns False on
# timeout; the count assertion below then fails with the observed number.
def _all_experiments_present() -> bool:
page = rest.experiments.find_experiments(
dataset_id=dest_dataset.id, page=1, size=100
)
return len(page.content or []) == _NUM_EXPERIMENTS
synchronization.until(_all_experiments_present, max_try_seconds=30)
for name in experiment_names:
# find_destination_experiment RAISES if zero or >1 match — so this is
# the duplicate check: the interrupted experiment must not have been
# migrated twice (once by the crashed run, once by the resume).
dest_exp = find_destination_experiment(
rest,
destination_dataset_id=dest_dataset.id,
experiment_name=name,
)
dest_items = destination_experiment_items(
rest, experiment_id=dest_exp.id, dataset_id=dest_dataset.id
)
assert len(dest_items) == 1, (
f"experiment {name!r} should have exactly 1 item at the "
f"destination, got {len(dest_items)}"
)
# Total destination experiments equals the source count — no orphaned
# partial experiment left over from the crashed run.
all_dest = rest.experiments.find_experiments(
dataset_id=dest_dataset.id, page=1, size=100
)
assert len(all_dest.content or []) == _NUM_EXPERIMENTS, (
f"expected {_NUM_EXPERIMENTS} destination experiments, "
f"got {len(all_dest.content or [])}"
)
# Checkpoint deleted on successful completion.
assert list(checkpoint_dir.glob("opik-migrate-checkpoint-*.json")) == []
@@ -0,0 +1,229 @@
"""End-to-end test for ``opik migrate prompt`` against a real Opik backend.
Seeds a multi-version source prompt directly via the REST API, runs
``opik migrate prompt`` as a subprocess (exercising the actual Click
entrypoint + exit-code handling), then reads back the destination prompt
and asserts:
- Destination has the same number of versions as the source
- Each destination version carries the source commit hash verbatim
(the architectural promise this slice exists to deliver)
- Source has been renamed to ``<name>_v1``
- Audit log is finalised to ``ok`` with one ``replay_prompt_version``
record per source version
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Iterator, List
import pytest
import opik
from opik.rest_api import OpikApi
from opik.rest_api.types.prompt_version_detail import PromptVersionDetail
from ...conftest import random_chars
from ...testlib import generate_project_name
from .conftest import run_migrate_cli
PROJECT_NAME = generate_project_name("e2e", __name__)
@pytest.fixture
def prompt_name() -> Iterator[str]:
yield f"e2e-migrate-prompt-{random_chars()}"
def _seed_source_prompt_with_versions(
rest_client: OpikApi,
*,
name: str,
project_name: str,
version_specs: List[dict],
) -> List[str]:
"""Create a source prompt container plus N versions.
First call to ``create_prompt`` carries the v1 template so the BE
mints v1 with its own commit hash (we don't control v1's commit at
seed time — that's fine for the test, we just need a multi-version
history). Subsequent versions are minted via
``create_prompt_version`` so we control their commit + payload.
Returns the list of source version commits in chronological order.
"""
rest_client.prompts.create_prompt(
name=name,
project_name=project_name,
description="e2e source description",
tags=["e2e", "source"],
template=version_specs[0]["template"],
type=version_specs[0].get("type"),
change_description=version_specs[0].get("change_description"),
)
versions_page = rest_client.prompts.get_prompts(name=name, size=10)
source_row = versions_page.content[0]
versions = rest_client.prompts.get_prompt_versions(
id=source_row.id, page=1, size=100
)
v1_commit = versions.content[0].commit
commits = [v1_commit]
for spec in version_specs[1:]:
version_payload = PromptVersionDetail(
template=spec["template"],
type=spec.get("type"),
change_description=spec.get("change_description"),
tags=spec.get("tags"),
environments=spec.get("environments"),
)
created = rest_client.prompts.create_prompt_version(
name=name,
version=version_payload,
project_name=project_name,
)
commits.append(created.commit)
return commits
class TestMigratePromptE2E:
def test_three_version_prompt_round_trips_with_commits_verbatim(
self,
opik_client: opik.Opik,
source_project_name: str,
target_project_name: str,
prompt_name: str,
tmp_path: Path,
) -> None:
rest = opik_client.rest_client
source_commits = _seed_source_prompt_with_versions(
rest,
name=prompt_name,
project_name=source_project_name,
version_specs=[
{"template": "hi {{name}}", "type": "mustache"},
{
"template": "hello {{name}}",
"type": "mustache",
"change_description": "be friendlier",
},
{
"template": "greetings {{name}}",
"type": "mustache",
"tags": ["polished"],
},
],
)
audit_log_path = tmp_path / "audit.json"
result = run_migrate_cli(
["prompt", prompt_name, "--to-project", target_project_name],
audit_log_path=str(audit_log_path),
)
assert result.returncode == 0, (
f"migrate prompt failed: stdout={result.stdout!r} stderr={result.stderr!r}"
)
# Source must have been renamed (workspace-unique name freed for
# the destination to claim).
renamed_page = rest.prompts.get_prompts(name=f"{prompt_name}_v1", size=10)
assert renamed_page.content, (
f"source prompt was not renamed to {prompt_name}_v1"
)
# Destination claims the original name.
dest_page = rest.prompts.get_prompts(name=prompt_name, size=10)
assert dest_page.content, "destination prompt not found at original name"
dest_prompt = dest_page.content[0]
# Every source version is replayed onto the destination with the
# commit hash preserved verbatim. BE orders newest-first; reverse
# to chronological for comparison against source_commits.
dest_versions_page = rest.prompts.get_prompt_versions(
id=dest_prompt.id, page=1, size=100
)
dest_commits = [v.commit for v in reversed(dest_versions_page.content or [])]
assert dest_commits == source_commits
# Audit log finalised to "ok" with one replay record per version.
audit = json.loads(audit_log_path.read_text())
assert audit["status"] == "ok"
replay_records = [
a for a in audit["actions"] if a["type"] == "replay_prompt_version"
]
assert len(replay_records) == len(source_commits)
assert all(a["status"] == "ok" for a in replay_records)
def test_environment_ownership_round_trips(
self,
opik_client: opik.Opik,
source_project_name: str,
target_project_name: str,
prompt_name: str,
tmp_path: Path,
) -> None:
# A version that owns an environment on the source must own the
# same environment on the destination after migration. The BE
# accepts ``environments`` inline on create_prompt_version and
# auto-registers unknown names in the workspace registry, so the
# replay carries the set verbatim without a follow-up PATCH.
rest = opik_client.rest_client
environment_name = f"prod-{random_chars()}"
source_commits = _seed_source_prompt_with_versions(
rest,
name=prompt_name,
project_name=source_project_name,
version_specs=[
{"template": "hi {{name}}", "type": "mustache"},
{"template": "hello {{name}}", "type": "mustache"},
{
"template": "greetings {{name}}",
"type": "mustache",
"environments": [environment_name],
},
],
)
audit_log_path = tmp_path / "audit.json"
result = run_migrate_cli(
["prompt", prompt_name, "--to-project", target_project_name],
audit_log_path=str(audit_log_path),
)
assert result.returncode == 0, (
f"migrate prompt failed: stdout={result.stdout!r} stderr={result.stderr!r}"
)
dest_page = rest.prompts.get_prompts(name=prompt_name, size=10)
assert dest_page.content, "destination prompt not found at original name"
dest_prompt = dest_page.content[0]
dest_versions_page = rest.prompts.get_prompt_versions(
id=dest_prompt.id, page=1, size=100
)
dest_versions = list(reversed(dest_versions_page.content or []))
dest_commits = [v.commit for v in dest_versions]
assert dest_commits == source_commits
# Exactly one destination version owns the environment, and it is
# the last one (the source owner), proving ownership round-tripped
# without leaking onto the env-less versions.
owners = [
v.commit
for v in dest_versions
if environment_name in (v.environments or [])
]
assert owners == [source_commits[-1]]
audit = json.loads(audit_log_path.read_text())
env_records = [
a
for a in audit["actions"]
if a["type"] == "replay_prompt_version" and a.get("source_environments")
]
assert len(env_records) == 1
assert env_records[0]["source_environments"] == [environment_name]
assert env_records[0]["target_environments"] == [environment_name]
@@ -0,0 +1,488 @@
"""End-to-end test for ``opik migrate dataset`` against a test suite.
Test suites have an extra dimension over plain datasets: each version
carries suite-level ``evaluators`` + ``execution_policy`` that the
migration must replay per-version. Plus the BE-natural test-suite shape
is "config-only v1 + content versions v2+" (the SDK's
``create_test_suite(global_assertions=...)`` produces a v1 with zero
items but the suite config attached).
This test covers the full surface:
- 4 versions: config-only v1 + 3 content versions with adds/edits/deletes
- Per-version suite-level evaluators that change across versions
- Per-version suite-level execution_policy that changes across versions
- Per-version user tags + metadata
- Per-item evaluators + execution_policy + tags on individual items
- Clear transitions: items that drop their per-item overrides between versions
Verifies all of the above round-trips at every replayed version on the
target.
"""
from __future__ import annotations
from pathlib import Path
from typing import Iterator
import pytest
import opik
from ...conftest import random_chars
from ...testlib import generate_project_name
from ._cascade_comparison import compare_cascade
from .conftest import (
apply_changes,
chronological_versions,
create_dataset_shell,
destination_experiment_items,
destination_spans_for_trace,
display_order,
find_destination_experiment,
item_hashes,
normalize_evaluators,
normalize_policy,
run_migrate_cli,
seed_experiment_with_trace_tree,
strip_be_managed_version_tags,
stream_items_wire,
)
# Per ``sdks/python/AGENTS.md``: every e2e module sources PROJECT_NAME from
# ``generate_project_name("e2e", __name__)`` so backend project names are
# isolated per test module + the autouse ``configure_e2e_tests_env`` fixture
# can patch ``OPIK_PROJECT_NAME`` to match.
PROJECT_NAME = generate_project_name("e2e", __name__)
@pytest.fixture
def suite_name() -> Iterator[str]:
yield f"e2e-migrate-suite-{random_chars()}"
# Suite-level evaluators per source version. Each entry is what gets
# attached to that version (changes across versions to exercise replay).
SUITE_EVALUATORS_PER_VERSION = [
# v1: one suite-level assertion
[{"name": "v1-judge", "type": "llm_judge", "config": {"model": "haiku"}}],
# v2: still v1's evaluator (no suite-level change)
[{"name": "v1-judge", "type": "llm_judge", "config": {"model": "haiku"}}],
# v3: add a second suite-level assertion + bump policy
[
{"name": "v1-judge", "type": "llm_judge", "config": {"model": "haiku"}},
{"name": "v2-judge", "type": "llm_judge", "config": {"model": "sonnet"}},
],
# v4: replace evaluators entirely
[{"name": "v3-only-judge", "type": "llm_judge", "config": {"model": "opus"}}],
]
SUITE_EXEC_POLICY_PER_VERSION = [
{"runs_per_item": 1, "pass_threshold": 1},
{"runs_per_item": 1, "pass_threshold": 1},
{"runs_per_item": 3, "pass_threshold": 2},
{"runs_per_item": 5, "pass_threshold": 3},
]
def test_test_suite_full_fidelity_round_trip(
opik_client: opik.Opik,
source_project_name: str,
target_project_name: str,
suite_name: str,
tmp_path: Path,
) -> None:
"""Migrate a 4-version test suite end-to-end and verify per-version
fidelity across items, item-level overrides, suite-level config,
metadata, and user tags.
Exercises every fidelity dimension Slice 2 supports in one test
because the BE round-trip is the expensive part; multiple smaller
tests would each pay the same setup cost. If any dimension regresses
this single test pins it.
"""
rest = opik_client.rest_client
# ── Seed v1: config-only (no items, just suite-level evaluators+policy) ──
suite_id = create_dataset_shell(
rest, suite_name, source_project_name, type="evaluation_suite"
)
apply_changes(
rest,
suite_id,
base_version_id=None,
change_description="v1 — initial config-only version",
suite_evaluators=SUITE_EVALUATORS_PER_VERSION[0],
suite_execution_policy=SUITE_EXEC_POLICY_PER_VERSION[0],
override=True, # required when base_version=None
)
v1 = chronological_versions(rest, suite_id)[0]
# ── Seed v2: add 3 items (Q1, Q2 with per-item evaluator, Q3 with
# per-item exec policy). Suite config unchanged. ──
v2_items = [
{"data": {"q": "Q1", "a": "A1"}, "source": "manual"},
{
"data": {"q": "Q2", "a": "A2"},
"source": "manual",
"evaluators": [
{
"name": "q2-item-judge",
"type": "llm_judge",
"config": {"model": "haiku-item"},
}
],
"tags": ["smoke"],
},
{
"data": {"q": "Q3", "a": "A3"},
"source": "manual",
"execution_policy": {"runs_per_item": 7, "pass_threshold": 4},
},
]
v2_id = apply_changes(
rest,
suite_id,
base_version_id=v1.id,
added_items=v2_items,
change_description="v2 — initial items with per-item overrides",
suite_evaluators=SUITE_EVALUATORS_PER_VERSION[1],
suite_execution_policy=SUITE_EXEC_POLICY_PER_VERSION[1],
)
v2_streamed = stream_items_wire(
rest,
dataset_name=suite_name,
project_name=source_project_name,
version_hash=None, # latest
)
by_q = {it.data["q"]: it for it in v2_streamed if it.data}
# ── Seed v3: edit Q1 (add per-item policy + extend tags), edit Q2
# (clear tags), add Q4. Bump suite evaluators to v3 config, bump policy. ──
v3_id = apply_changes(
rest,
suite_id,
base_version_id=v2_id,
edited_items=[
{
"id": by_q["Q1"].id,
"data": {"q": "Q1", "a": "A1"},
"execution_policy": {"runs_per_item": 9, "pass_threshold": 5},
"tags": ["regression", "v3-touched"],
},
{
"id": by_q["Q2"].id,
"data": {"q": "Q2", "a": "A2"},
# Clear Q2's tags by passing empty list.
"tags": [],
},
],
added_items=[{"data": {"q": "Q4", "a": "A4"}, "source": "manual"}],
change_description="v3 — edit Q1, clear Q2 tags, add Q4",
suite_evaluators=SUITE_EVALUATORS_PER_VERSION[2],
suite_execution_policy=SUITE_EXEC_POLICY_PER_VERSION[2],
metadata={"phase": "experimentation", "owner": "ada"},
user_tags=["baseline"],
)
# ── Seed v4: delete Q2, replace Q3's per-item evaluator, add Q5 with a
# per-item evaluator. Replace suite config entirely + bump policy. ──
apply_changes(
rest,
suite_id,
base_version_id=v3_id,
edited_items=[
{
"id": by_q["Q3"].id,
"data": {"q": "Q3", "a": "A3"},
"evaluators": [
{
"name": "q3-item-judge-NEW",
"type": "llm_judge",
"config": {"model": "sonnet-item"},
}
],
}
],
deleted_ids=[by_q["Q2"].id],
added_items=[
{
"data": {"q": "Q5", "a": "A5"},
"source": "manual",
"evaluators": [
{
"name": "q5-item-judge",
"type": "llm_judge",
"config": {"model": "opus-item"},
}
],
}
],
change_description="v4 — delete Q2, replace Q3 evaluator, add Q5",
suite_evaluators=SUITE_EVALUATORS_PER_VERSION[3],
suite_execution_policy=SUITE_EXEC_POLICY_PER_VERSION[3],
metadata={"phase": "production", "owner": "ada"},
user_tags=["v1.0", "release-candidate"],
)
# ── Snapshot source expectations per version ──
src_versions = chronological_versions(rest, suite_id)
assert len(src_versions) == 4
expected = []
for v in src_versions:
items = stream_items_wire(
rest,
dataset_name=suite_name,
project_name=source_project_name,
version_hash=v.version_hash,
)
expected.append(
{
"hashes": item_hashes(items),
"order": display_order(items),
"suite_evals": normalize_evaluators(v.evaluators),
"suite_pol": normalize_policy(v.execution_policy),
"metadata": v.metadata or None,
"user_tags": strip_be_managed_version_tags(v.tags),
}
)
# ── Seed a suite-driven experiment on v2 items so the cascade has
# something to round-trip. Test-suite experiments carry per-trace
# assertion_results (regular-dataset experiments carry feedback_scores
# instead -- covered in test_migrate_dataset_e2e.py). Each item gets
# one trace, one span, and per-item assertion_results that the seed
# helper writes via store_assertions_batch(entity_type='TRACE', ...).
# The cascade re-emits them scoped to the new destination trace ids
# via the same endpoint. ──
experiment_name = f"e2e-suite-exp-{random_chars()}"
v2_item_ids = [by_q["Q1"].id, by_q["Q2"].id, by_q["Q3"].id]
cascade_seed = seed_experiment_with_trace_tree(
rest,
experiment_name=experiment_name,
dataset_name=suite_name,
dataset_id=suite_id,
dataset_version_id=v2_id,
project_name=source_project_name,
item_ids=v2_item_ids,
# ``type="regular"`` + ``evaluation_method="evaluation_suite"`` is
# the canonical shape for a non-optimizer test-suite run, mirroring
# what ``opik.evaluate(...)`` produces against a test suite. The
# BE only updates ``last_created_experiment_at`` on the dataset for
# ``type="regular"`` experiments, so ``with_experiments_only=true``
# (the UI's project-page filter) correctly surfaces this one.
# Optimizer-driven trials use ``type="trial"`` + ``optimization_id``;
# those are Slice 4's cascade scope.
experiment_type="regular",
evaluation_method="evaluation_suite",
experiment_config={"runner": "e2e-suite-cascade-test"},
experiment_tags=["e2e", "test-suite", "cascade"],
spans_per_trace=2,
# Per-item runtime assertion results are seeded with the SAME name
# as the v2 suite-level evaluator (``v1-judge``). That's what a real
# test-suite run produces: for each item, one assertion result per
# evaluator that ran against it. Q2 also has a per-item override
# evaluator ``q2-item-judge``; we seed a second result there to
# mirror what an actual evaluator run would produce.
per_item_extras=[
{
"assertion_results": [
{
"value": "v1-judge", # matches v2's suite-level evaluator
"passed": i % 2 == 0,
"reason": f"v1-judge ran on Q{i + 1}",
}
]
# Q2 has a per-item override evaluator ``q2-item-judge``; a
# real run would also produce a result for that. We tack it
# on for the second item.
+ (
[
{
"value": "q2-item-judge",
"passed": True,
"reason": "q2-item-judge ran on Q2",
}
]
if i == 1
else []
),
}
for i in range(len(v2_item_ids))
],
)
# ── Run the migration ──
audit_path = tmp_path / "audit.json"
result = run_migrate_cli(
[
"dataset",
suite_name,
"--to-project",
target_project_name,
],
audit_log_path=str(audit_path),
)
assert result.returncode == 0, result.stdout + result.stderr
# ── Verify target ──
target = rest.datasets.get_dataset_by_identifier(
dataset_name=suite_name, project_name=target_project_name
)
assert target.type == "evaluation_suite", (
f"target should be a test suite, got type={target.type!r}"
)
tgt_versions = chronological_versions(rest, target.id)
assert len(tgt_versions) == len(src_versions), (
f"target version count {len(tgt_versions)} != source {len(src_versions)} "
"— Slice 2 contract requires N=N for test suites too"
)
for src_v, tgt_v, exp in zip(src_versions, tgt_versions, expected):
items = stream_items_wire(
rest,
dataset_name=suite_name,
project_name=target_project_name,
version_hash=tgt_v.version_hash,
)
# Items: set-equal under content hash + display order matches.
actual_hashes = item_hashes(items)
assert actual_hashes == exp["hashes"], (
f"{tgt_v.version_name}: items diverged from {src_v.version_name}. "
f"Missing on target: {exp['hashes'] - actual_hashes}; "
f"extra on target: {actual_hashes - exp['hashes']}"
)
assert display_order(items) == exp["order"], (
f"{tgt_v.version_name}: display order diverged"
)
# Suite-level config matches.
assert normalize_evaluators(tgt_v.evaluators) == exp["suite_evals"], (
f"{tgt_v.version_name}: suite evaluators diverged"
)
assert normalize_policy(tgt_v.execution_policy) == exp["suite_pol"], (
f"{tgt_v.version_name}: suite execution_policy diverged"
)
# Version-level metadata + user tags match.
assert (tgt_v.metadata or None) == exp["metadata"], (
f"{tgt_v.version_name}: metadata diverged"
)
assert strip_be_managed_version_tags(tgt_v.tags) == exp["user_tags"], (
f"{tgt_v.version_name}: user version tags diverged"
)
# ── Cascade fidelity (test-suite-specific) ──
# The destination project should now have a copy of the source
# suite-driven experiment: type + evaluation_method preserved,
# per-item assertion_results + execution_policy round-trip, FKs
# remapped, traces+spans land under the destination project.
dest_exp = find_destination_experiment(
rest,
destination_dataset_id=target.id,
experiment_name=experiment_name,
)
assert dest_exp.id != cascade_seed["experiment_id"]
assert dest_exp.dataset_id == target.id
# Type + evaluation_method preserved (test-suite-specific fields).
assert dest_exp.type == "regular", (
f"destination experiment type should be 'regular', got {dest_exp.type!r}"
)
assert dest_exp.evaluation_method == "evaluation_suite", (
f"destination experiment evaluation_method should be 'evaluation_suite', "
f"got {dest_exp.evaluation_method!r}"
)
target_version_ids = {v.id for v in tgt_versions}
assert dest_exp.dataset_version_id in target_version_ids
# Per-item assertion_results survive on the destination via the
# Compare view (the BE persists them through the dedicated
# ``assertion_results.store_assertions_batch`` endpoint scoped to the
# destination trace ids; the Compare view aggregates them onto each
# ExperimentItemCompare for read).
dest_items = destination_experiment_items(
rest,
experiment_id=dest_exp.id,
dataset_id=target.id,
)
assert len(dest_items) == len(v2_item_ids)
dest_trace_ids = {it.trace_id for it in dest_items}
assert dest_trace_ids.isdisjoint(set(cascade_seed["trace_ids"]))
# Each destination item has at least one assertion result named
# ``v1-judge`` (the v2 suite-level evaluator's name), matching what a
# real test-suite run would produce. The Q2 item additionally has a
# ``q2-item-judge`` result because of the per-item evaluator override.
items_with_q2_override = 0
for dest_item in dest_items:
ars = dest_item.assertion_results
assert ars, "destination experiment item should have assertion_results"
names = {a.value for a in ars}
assert "v1-judge" in names, (
f"destination item should carry a 'v1-judge' assertion result "
f"(matches the v2 suite-level evaluator); got names={names}"
)
if "q2-item-judge" in names:
items_with_q2_override += 1
# Find the q2-item-judge result and verify its reason text.
q2_ar = next(a for a in ars if a.value == "q2-item-judge")
assert q2_ar.passed is True
assert "Q2" in (q2_ar.reason or "")
assert items_with_q2_override == 1, (
"exactly one item (Q2) should carry the per-item q2-item-judge "
f"assertion result; got {items_with_q2_override}"
)
# Each destination trace exists under the target project with the
# span tree shape preserved (root + 1 child, parent_span_id remapped).
for new_trace_id in dest_trace_ids:
dest_spans = destination_spans_for_trace(
rest,
trace_id=new_trace_id,
project_name=target_project_name,
)
assert len(dest_spans) == 2
roots = [s for s in dest_spans if s.parent_span_id is None]
assert len(roots) == 1
children = [s for s in dest_spans if s.parent_span_id is not None]
assert all(c.parent_span_id == roots[0].id for c in children)
# ── Deep-equal source vs. destination ──
# Pair source/destination items by trace ``name`` (assigned by the
# seed as "task-0", "task-1", "task-2" and carried verbatim through
# the cascade), then run a field-by-field comparison of experiment +
# items (assertion_results + feedback_scores) + traces + spans
# modulo remapped IDs.
src_exp = find_destination_experiment(
rest,
destination_dataset_id=suite_id,
experiment_name=experiment_name,
)
src_items_compare = destination_experiment_items(
rest,
experiment_id=cascade_seed["experiment_id"],
dataset_id=suite_id,
)
src_trace_names = {
it.trace_id: rest.traces.get_trace_by_id(id=it.trace_id).name
for it in src_items_compare
}
dst_trace_names = {
it.trace_id: rest.traces.get_trace_by_id(id=it.trace_id).name
for it in dest_items
}
src_items_compare.sort(key=lambda it: src_trace_names[it.trace_id])
dest_items_sorted = sorted(dest_items, key=lambda it: dst_trace_names[it.trace_id])
src_trace_ids_sorted = [it.trace_id for it in src_items_compare]
dst_trace_ids_sorted = [it.trace_id for it in dest_items_sorted]
compare_cascade(
rest_client=rest,
source_experiment=src_exp,
destination_experiment=dest_exp,
source_item_ids=v2_item_ids,
destination_item_ids=[it.dataset_item_id for it in dest_items_sorted],
source_trace_ids=src_trace_ids_sorted,
destination_trace_ids=dst_trace_ids_sorted,
source_items_compare=src_items_compare,
destination_items_compare=dest_items_sorted,
)