Files
wehub-resource-sync 5a558eb09e
Backend Tests / discover-tests (push) Waiting to run
Backend Tests / ${{ matrix.name }} (push) Blocked by required conditions
Build and Publish SDK / build-and-publish (push) Waiting to run
Build Opik Docker Images / set-version (push) Waiting to run
Build Opik Docker Images / build-backend (push) Blocked by required conditions
Build Opik Docker Images / build-sandbox-executor-python (push) Blocked by required conditions
Build Opik Docker Images / build-python-backend (push) Blocked by required conditions
Build Opik Docker Images / build-frontend (push) Blocked by required conditions
Build Opik Docker Images / create-git-tag (push) Blocked by required conditions
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Waiting to run
Docs - Publish / run (push) Waiting to run
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Waiting to run
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Blocked by required conditions
Frontend Unit Tests / Test on Node 20 (push) Waiting to run
Guardrails E2E Tests / Select Python version matrix (push) Waiting to run
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Blocked by required conditions
Guardrails E2E Tests / 📢 Slack Notification (push) Blocked by required conditions
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Waiting to run
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Blocked by required conditions
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Waiting to run
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Waiting to run
Lint Opik Helm Chart / unittest-helm-chart (push) Waiting to run
Lint Opik Helm Chart / render-equality (push) Waiting to run
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Waiting to run
Python BE E2E Tests / Python BE E2E (push) Waiting to run
Python Backend Tests / run-python-backend-tests (push) Waiting to run
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
SDK E2E Libraries Integration Tests / Check Secrets (push) Waiting to run
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Blocked by required conditions
SDK E2E Libraries Integration Tests / build-opik (push) Blocked by required conditions
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Blocked by required conditions
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Waiting to run
TypeScript SDK Build & Publish / build-and-publish (push) Waiting to run
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:25:44 +08:00

474 lines
18 KiB
Python

"""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