semanticembed 0.7.0__tar.gz → 0.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. {semanticembed-0.7.0 → semanticembed-0.7.1}/CHANGELOG.md +10 -0
  2. {semanticembed-0.7.0 → semanticembed-0.7.1}/PKG-INFO +1 -1
  3. {semanticembed-0.7.0 → semanticembed-0.7.1}/pyproject.toml +1 -1
  4. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/__init__.py +9 -2
  5. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/client.py +167 -0
  6. semanticembed-0.7.1/tests/test_aclient.py +170 -0
  7. {semanticembed-0.7.0 → semanticembed-0.7.1}/.claude/skills/analyze/SKILL.md +0 -0
  8. {semanticembed-0.7.0 → semanticembed-0.7.1}/.github/workflows/publish.yml +0 -0
  9. {semanticembed-0.7.0 → semanticembed-0.7.1}/.gitignore +0 -0
  10. {semanticembed-0.7.0 → semanticembed-0.7.1}/LICENSE +0 -0
  11. {semanticembed-0.7.0 → semanticembed-0.7.1}/README.md +0 -0
  12. {semanticembed-0.7.0 → semanticembed-0.7.1}/docs/api_reference.md +0 -0
  13. {semanticembed-0.7.0 → semanticembed-0.7.1}/docs/dimensions.md +0 -0
  14. {semanticembed-0.7.0 → semanticembed-0.7.1}/docs/getting_started.md +0 -0
  15. {semanticembed-0.7.0 → semanticembed-0.7.1}/docs/input_format.md +0 -0
  16. {semanticembed-0.7.0 → semanticembed-0.7.1}/docs/license_keys.md +0 -0
  17. {semanticembed-0.7.0 → semanticembed-0.7.1}/docs/output_format.md +0 -0
  18. {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/ai_agent_pipeline.json +0 -0
  19. {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/cicd_pipeline.json +0 -0
  20. {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/google_online_boutique.json +0 -0
  21. {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/react/README.md +0 -0
  22. {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/react/RadarChart.tsx +0 -0
  23. {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/react/RiskTable.tsx +0 -0
  24. {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/react/TopologySummary.tsx +0 -0
  25. {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/react/useSemanticEmbed.ts +0 -0
  26. {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/sample_pipeline.json +0 -0
  27. {semanticembed-0.7.0 → semanticembed-0.7.1}/examples/weaveworks_sock_shop.json +0 -0
  28. {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/01_quickstart.ipynb +0 -0
  29. {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/02_dimensions.ipynb +0 -0
  30. {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/03_drift_detection.ipynb +0 -0
  31. {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/04_bring_your_own.ipynb +0 -0
  32. {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/05_ai_agent_pipelines.ipynb +0 -0
  33. {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/06_cicd_pipelines.ipynb +0 -0
  34. {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/07_opentelemetry.ipynb +0 -0
  35. {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/08_qwen_compression.ipynb +0 -0
  36. {semanticembed-0.7.0 → semanticembed-0.7.1}/notebooks/qwen_compression_cookbook.py +0 -0
  37. {semanticembed-0.7.0 → semanticembed-0.7.1}/skill/SETUP.md +0 -0
  38. {semanticembed-0.7.0 → semanticembed-0.7.1}/skill/SKILL.md +0 -0
  39. {semanticembed-0.7.0 → semanticembed-0.7.1}/skill/analyze.py +0 -0
  40. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/agent/README.md +0 -0
  41. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/agent/__init__.py +0 -0
  42. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/agent/__main__.py +0 -0
  43. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/agent/agent.py +0 -0
  44. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/agent/gemini_agent.py +0 -0
  45. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/dedupe.py +0 -0
  46. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/exceptions.py +0 -0
  47. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/explain.py +0 -0
  48. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/extract.py +0 -0
  49. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/find_edges.py +0 -0
  50. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/live.py +0 -0
  51. {semanticembed-0.7.0 → semanticembed-0.7.1}/src/semanticembed/models.py +0 -0
  52. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/__init__.py +0 -0
  53. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/autogen_app.py +0 -0
  54. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/cdk_app.py +0 -0
  55. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/crewai_app.py +0 -0
  56. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/docker-compose.yml +0 -0
  57. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/langgraph_app.py +0 -0
  58. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/pulumi_app.py +0 -0
  59. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/stack.yaml +0 -0
  60. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/trace_jaeger.json +0 -0
  61. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/trace_otlp.json +0 -0
  62. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/fixtures/trace_zipkin.json +0 -0
  63. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_client.py +0 -0
  64. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_dedupe.py +0 -0
  65. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_extract.py +0 -0
  66. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_extract_ai.py +0 -0
  67. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_extract_iac.py +0 -0
  68. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_extract_otel.py +0 -0
  69. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_live.py +0 -0
  70. {semanticembed-0.7.0 → semanticembed-0.7.1}/tests/test_skill.py +0 -0
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to the SemanticEmbed SDK.
4
4
 
5
+ ## 0.7.1 — Async surface
6
+
7
+ - New: `aencode`, `aencode_file`, `aencode_diff` — async siblings of the
8
+ sync versions. Same preflight node-count guard, retry-once-on-5xx, and
9
+ optional in-process cache.
10
+ - Cache is **shared** across sync + async: a sync `encode(edges, cache=True)`
11
+ followed by `await aencode(edges, cache=True)` hits the same entry.
12
+ - `aencode_diff` issues both encodes in parallel via `asyncio.gather`.
13
+ - Useful for FastAPI / Celery / event-loop integrations.
14
+
5
15
  ## 0.7.0 — Honeycomb + Datadog live connectors
6
16
 
7
17
  - New: `semanticembed.live.from_honeycomb(dataset, api_key)` — runs a
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: semanticembed
3
- Version: 0.7.0
3
+ Version: 0.7.1
4
4
  Summary: 6D structural intelligence for directed graphs. Six numbers per node. Sub-millisecond.
5
5
  Project-URL: Homepage, https://github.com/jmurray10/semanticembed-sdk
6
6
  Project-URL: Documentation, https://github.com/jmurray10/semanticembed-sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "semanticembed"
7
- version = "0.7.0"
7
+ version = "0.7.1"
8
8
  description = "6D structural intelligence for directed graphs. Six numbers per node. Sub-millisecond."
9
9
  readme = "README.md"
10
10
  license = {text = "Proprietary"}
@@ -2,7 +2,11 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from .client import encode, encode_file, report, drift, encode_diff, clear_encode_cache
5
+ from .client import (
6
+ encode, encode_file, report, drift, encode_diff,
7
+ aencode, aencode_file, aencode_diff,
8
+ clear_encode_cache,
9
+ )
6
10
  from .models import SemanticResult, RiskReport, RiskEntry, DIMENSION_NAMES
7
11
  from .exceptions import (
8
12
  SemanticEmbedError,
@@ -17,7 +21,7 @@ from .dedupe import dedupe_edges
17
21
  from .explain import explain, ask
18
22
  from .find_edges import find_edges
19
23
 
20
- __version__ = "0.7.0"
24
+ __version__ = "0.7.1"
21
25
 
22
26
  # Set this to your license key to unlock unlimited nodes:
23
27
  # import semanticembed
@@ -28,6 +32,9 @@ __all__ = [
28
32
  "encode",
29
33
  "encode_file",
30
34
  "encode_diff",
35
+ "aencode",
36
+ "aencode_file",
37
+ "aencode_diff",
31
38
  "report",
32
39
  "drift",
33
40
  "explain",
@@ -373,3 +373,170 @@ def encode_diff(
373
373
  result_after = encode(edges_after, license_key=license_key,
374
374
  api_url=api_url, timeout=timeout)
375
375
  return drift(result_before, result_after, detail=detail)
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Async surface
380
+ # ---------------------------------------------------------------------------
381
+ #
382
+ # `aencode` / `aencode_file` / `aencode_diff` mirror the sync versions:
383
+ # same preflight node-count guard, same retry-once-on-5xx logic, same
384
+ # in-process LRU cache (shared with the sync side -- a sync encode and a
385
+ # subsequent async aencode of the same edges hit the same cache entry).
386
+
387
+
388
+ async def _apost_with_retry(
389
+ url: str,
390
+ headers: dict[str, str],
391
+ payload: dict,
392
+ timeout: float,
393
+ ) -> httpx.Response:
394
+ """Async sibling of _post_with_retry. One retry on 502/503/504/ConnectError/ReadTimeout."""
395
+ import asyncio
396
+
397
+ last_exc: Exception | None = None
398
+ for attempt in range(2):
399
+ try:
400
+ async with httpx.AsyncClient(timeout=timeout) as client:
401
+ resp = await client.post(
402
+ f"{url}/api/v1/encode", headers=headers, json=payload
403
+ )
404
+ if resp.status_code in _RETRYABLE_STATUS and attempt == 0:
405
+ await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
406
+ continue
407
+ return resp
408
+ except (httpx.ConnectError, httpx.ReadTimeout) as e:
409
+ last_exc = e
410
+ if attempt == 0:
411
+ await asyncio.sleep(_RETRY_BACKOFF_SECONDS)
412
+ continue
413
+ raise SemanticConnectionError(
414
+ f"Could not connect to SemanticEmbed API at {url} after retry: {e}"
415
+ ) from e
416
+ if last_exc is not None:
417
+ raise SemanticConnectionError(
418
+ f"Could not connect to SemanticEmbed API at {url}: {last_exc}"
419
+ ) from last_exc
420
+ return resp # last 5xx response
421
+
422
+
423
+ async def aencode(
424
+ edges: list,
425
+ *,
426
+ license_key: str | None = None,
427
+ api_url: str | None = None,
428
+ timeout: float = 60.0,
429
+ cache: bool = False,
430
+ ) -> SemanticResult:
431
+ """Async version of :func:`encode`.
432
+
433
+ Identical semantics — preflight node guard, retry-on-5xx, optional
434
+ cache (shared with the sync ``encode``). Useful inside FastAPI
435
+ handlers, asyncio task groups, or any event-loop context.
436
+
437
+ Example::
438
+
439
+ import asyncio, semanticembed as se
440
+
441
+ async def main():
442
+ result = await se.aencode([("a", "b"), ("b", "c")], cache=True)
443
+ print(result.table)
444
+
445
+ asyncio.run(main())
446
+ """
447
+ normalized = _normalize_edges(edges)
448
+ if len(normalized) < 2:
449
+ raise ValueError("Graph must have at least 2 edges.")
450
+
451
+ if cache:
452
+ cache_key = _encode_cache_key(normalized)
453
+ hit = _cache_get(cache_key)
454
+ if hit is not None:
455
+ return hit
456
+
457
+ key = _resolve_key(license_key)
458
+ url = (api_url or os.environ.get("SEMANTICEMBED_API_URL") or DEFAULT_API_URL).rstrip("/")
459
+
460
+ headers: dict[str, str] = {"Content-Type": "application/json"}
461
+ if key:
462
+ headers["X-API-Key"] = key
463
+
464
+ node_set: set[str] = set()
465
+ for e in normalized:
466
+ node_set.add(e[0])
467
+ node_set.add(e[1])
468
+ n_nodes = len(node_set)
469
+ if not key and n_nodes > FREE_TIER_LIMIT:
470
+ raise NodeLimitError(n_nodes, FREE_TIER_LIMIT)
471
+ if not key and n_nodes > FREE_TIER_LIMIT * 0.8:
472
+ import warnings
473
+ warnings.warn(
474
+ f"Graph has {n_nodes}/{FREE_TIER_LIMIT} free tier nodes. "
475
+ f"Set a license key to avoid hitting the limit.",
476
+ stacklevel=2,
477
+ )
478
+
479
+ payload = {"edges": normalized}
480
+ start = time.perf_counter()
481
+ resp = await _apost_with_retry(url, headers, payload, timeout)
482
+ elapsed_ms = (time.perf_counter() - start) * 1000
483
+
484
+ if resp.status_code == 401:
485
+ raise AuthenticationError(
486
+ "Invalid API key. Check your license key or contact jeffmurr@seas.upenn.edu"
487
+ )
488
+ if resp.status_code == 403:
489
+ import re as _re
490
+ detail = resp.text[:300]
491
+ match = _re.search(r"(\d+) nodes.*limit.*?(\d+)", detail)
492
+ if match:
493
+ raise NodeLimitError(int(match.group(1)), int(match.group(2)))
494
+ raise NodeLimitError(n_nodes, FREE_TIER_LIMIT)
495
+ if resp.status_code >= 400:
496
+ raise APIError(resp.status_code, resp.text[:200])
497
+
498
+ result = _parse_response(resp.json(), elapsed_ms)
499
+ if cache:
500
+ _cache_put(cache_key, result)
501
+ return result
502
+
503
+
504
+ async def aencode_file(
505
+ path: str,
506
+ *,
507
+ license_key: str | None = None,
508
+ api_url: str | None = None,
509
+ timeout: float = 60.0,
510
+ cache: bool = False,
511
+ ) -> SemanticResult:
512
+ """Async version of :func:`encode_file`."""
513
+ import json
514
+
515
+ with open(path) as f:
516
+ data = json.load(f)
517
+ edges = data.get("edges", data) if isinstance(data, dict) else data
518
+ return await aencode(
519
+ edges, license_key=license_key, api_url=api_url, timeout=timeout, cache=cache
520
+ )
521
+
522
+
523
+ async def aencode_diff(
524
+ edges_before: list,
525
+ edges_after: list,
526
+ *,
527
+ detail: bool = True,
528
+ license_key: str | None = None,
529
+ api_url: str | None = None,
530
+ timeout: float = 60.0,
531
+ cache: bool = False,
532
+ ) -> dict[str, dict[str, dict[str, float]]]:
533
+ """Async version of :func:`encode_diff`. Issues both encodes in parallel."""
534
+ import asyncio
535
+
536
+ before_t, after_t = await asyncio.gather(
537
+ aencode(edges_before, license_key=license_key, api_url=api_url,
538
+ timeout=timeout, cache=cache),
539
+ aencode(edges_after, license_key=license_key, api_url=api_url,
540
+ timeout=timeout, cache=cache),
541
+ )
542
+ return drift(before_t, after_t, detail=detail)
@@ -0,0 +1,170 @@
1
+ """Tests for the async surface: aencode, aencode_file, aencode_diff.
2
+
3
+ Mirrors the sync test cases — preflight, retry-once, cache, error paths.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ import json
10
+
11
+ import httpx
12
+ import pytest
13
+ import respx
14
+
15
+ import semanticembed as se
16
+ from semanticembed.client import DEFAULT_API_URL, FREE_TIER_LIMIT
17
+ from semanticembed.exceptions import (
18
+ AuthenticationError,
19
+ NodeLimitError,
20
+ SemanticConnectionError,
21
+ )
22
+
23
+
24
+ VALID_BODY = {
25
+ "embeddings": {
26
+ "a": {"depth": 0.0, "independence": 0.5, "hierarchy": 0.5,
27
+ "throughput": 0.5, "criticality": 0.5, "fanout": 1.0},
28
+ "b": {"depth": 0.5, "independence": 0.5, "hierarchy": 0.5,
29
+ "throughput": 0.5, "criticality": 0.5, "fanout": 0.5},
30
+ "c": {"depth": 1.0, "independence": 0.5, "hierarchy": 0.5,
31
+ "throughput": 0.5, "criticality": 0.5, "fanout": 0.0},
32
+ },
33
+ "risks": [],
34
+ "fingerprint": {},
35
+ "metadata": {"n_nodes": 3, "n_edges": 2, "max_depth": 2},
36
+ }
37
+
38
+ SAMPLE_EDGES = [["a", "b"], ["b", "c"]]
39
+
40
+
41
+ @pytest.fixture(autouse=True)
42
+ def _reset(monkeypatch):
43
+ se.license_key = None
44
+ se.clear_encode_cache()
45
+ monkeypatch.delenv("SEMANTICEMBED_LICENSE_KEY", raising=False)
46
+ monkeypatch.delenv("SEMANTICEMBED_API_KEY", raising=False)
47
+ monkeypatch.delenv("SEMANTICEMBED_API_URL", raising=False)
48
+ yield
49
+ se.license_key = None
50
+ se.clear_encode_cache()
51
+
52
+
53
+ def _run(coro):
54
+ return asyncio.run(coro)
55
+
56
+
57
+ class TestAencodeHappyPath:
58
+ @respx.mock
59
+ def test_returns_result(self):
60
+ respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
61
+ return_value=httpx.Response(200, json=VALID_BODY)
62
+ )
63
+ result = _run(se.aencode(SAMPLE_EDGES))
64
+ assert set(result.vectors.keys()) == {"a", "b", "c"}
65
+ assert result.graph_info["nodes"] == 3
66
+
67
+
68
+ class TestAencodePreflight:
69
+ def test_free_tier_preflight_skips_http(self):
70
+ edges = [[f"n{i}", f"n{i+1}"] for i in range(59)] # 60 nodes
71
+ with respx.mock:
72
+ route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
73
+ return_value=httpx.Response(200, json=VALID_BODY)
74
+ )
75
+ with pytest.raises(NodeLimitError) as ei:
76
+ _run(se.aencode(edges))
77
+ assert route.call_count == 0
78
+ assert ei.value.n_nodes == 60
79
+
80
+
81
+ class TestAencodeRetry:
82
+ @respx.mock
83
+ def test_retries_once_on_503(self):
84
+ route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
85
+ side_effect=[
86
+ httpx.Response(503, text="upstream"),
87
+ httpx.Response(200, json=VALID_BODY),
88
+ ]
89
+ )
90
+ result = _run(se.aencode(SAMPLE_EDGES))
91
+ assert route.call_count == 2
92
+ assert result is not None
93
+
94
+ @respx.mock
95
+ def test_retries_once_on_connect_error(self):
96
+ route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
97
+ side_effect=[
98
+ httpx.ConnectError("dns fail"),
99
+ httpx.Response(200, json=VALID_BODY),
100
+ ]
101
+ )
102
+ result = _run(se.aencode(SAMPLE_EDGES))
103
+ assert route.call_count == 2
104
+ assert result is not None
105
+
106
+ @respx.mock
107
+ def test_does_not_retry_on_401(self):
108
+ route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
109
+ return_value=httpx.Response(401, text="invalid")
110
+ )
111
+ with pytest.raises(AuthenticationError):
112
+ _run(se.aencode(SAMPLE_EDGES, license_key="bad"))
113
+ assert route.call_count == 1
114
+
115
+
116
+ class TestAencodeCache:
117
+ @respx.mock
118
+ def test_cache_hit_skips_http(self):
119
+ route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
120
+ return_value=httpx.Response(200, json=VALID_BODY)
121
+ )
122
+ r1 = _run(se.aencode(SAMPLE_EDGES, cache=True))
123
+ r2 = _run(se.aencode(SAMPLE_EDGES, cache=True))
124
+ assert route.call_count == 1
125
+ assert r1 is r2
126
+
127
+ @respx.mock
128
+ def test_async_and_sync_share_cache(self):
129
+ # Cache hit between sync encode -> async aencode (same edges).
130
+ route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
131
+ return_value=httpx.Response(200, json=VALID_BODY)
132
+ )
133
+ r1 = se.encode(SAMPLE_EDGES, cache=True)
134
+ r2 = _run(se.aencode(SAMPLE_EDGES, cache=True))
135
+ assert route.call_count == 1
136
+ assert r1 is r2
137
+
138
+
139
+ class TestAencodeFile:
140
+ @respx.mock
141
+ def test_loads_from_json_file(self, tmp_path):
142
+ f = tmp_path / "g.json"
143
+ f.write_text(json.dumps({"edges": [["a", "b"], ["b", "c"]]}))
144
+ respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
145
+ return_value=httpx.Response(200, json=VALID_BODY)
146
+ )
147
+ result = _run(se.aencode_file(str(f)))
148
+ assert set(result.vectors.keys()) == {"a", "b", "c"}
149
+
150
+
151
+ class TestAencodeDiff:
152
+ @respx.mock
153
+ def test_runs_two_encodes_in_parallel_and_returns_drift(self):
154
+ route = respx.post(f"{DEFAULT_API_URL}/api/v1/encode").mock(
155
+ return_value=httpx.Response(200, json=VALID_BODY)
156
+ )
157
+ result = _run(se.aencode_diff(
158
+ [["a", "b"], ["b", "c"]],
159
+ [["a", "b"], ["b", "c"], ["a", "c"]],
160
+ ))
161
+ # Both encodes were issued.
162
+ assert route.call_count == 2
163
+ # Drift returns a dict (may be empty if identical embeddings, that's fine).
164
+ assert isinstance(result, dict)
165
+
166
+
167
+ class TestAencodeRequestValidation:
168
+ def test_too_few_edges_raises(self):
169
+ with pytest.raises(ValueError, match="at least 2 edges"):
170
+ _run(se.aencode([["a", "b"]]))
File without changes
File without changes
File without changes