Skip to content

Commit 550b3d4

Browse files
committed
fix: HttpCatalogClient.resolve_span_text service-mode parity + structured cloud-skip reason (nexus-p8nd5)
Two halves. (1) The span-text stub returned None unconditionally, so the majority topology never saw the ib6uy distinguishability contract. Root cause was ONE seam: the shared resolver's chash branch read t3._client — an attribute HttpVectorClient deliberately lacks (pinned) — so service mode AttributeError'd into the broad except and masked every chash span to None. Fixed with getattr(t3, '_client', t3) (the service stub serves the same get(where=...) shape); HttpCatalogClient.resolve_span_text is now a faithful mirror of the local Catalog method (resolve -> shared resolver with catalog=self; get_manifest parity already existed). Degraded service raises VectorServiceError through to the boundaries; missing chunk / unknown tumbler stay None. 6 parity tests incl. the seam pin. (2) dv708 structured residual: DetectionReport + DryRunPreview gain cloud_leg_skipped_reason, threaded from open_read_legs' dead-creds skip site via a skipped_out out-param (established pattern) at all report- building call sites (migrate_cmd x2, driver, guided_upgrade — tolerant of injected legacy doubles); the dry-run renderer prints the skip loudly. Non-stderr consumers can now distinguish skipped-unreadable from never-configured. Both migration-contract surface pins updated with the new names. 1707 tests green across catalog/migration/upgrade.
1 parent 702bf6d commit 550b3d4

12 files changed

Lines changed: 266 additions & 21 deletions

src/nexus/catalog/catalog_spans.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,8 +502,14 @@ def resolve_span_text_for_entry(
502502
try:
503503
from nexus.db import make_t3 # noqa: PLC0415 — function-local to avoid a circular import (nexus.db imports catalog)
504504
t3 = make_t3()
505+
# nexus-p8nd5: reach the collection surface through whichever shape
506+
# this handle has. T3Database wraps a chroma client at ``_client``;
507+
# the service-mode HttpVectorClient deliberately has NO ``_client``
508+
# (pinned) but exposes ``get_collection`` itself — the old
509+
# unconditional ``t3._client`` read AttributeError'd into the broad
510+
# except below and masked every service-mode chash span to None.
505511
result = resolve_span_in_t3(
506-
span, entry.physical_collection, t3._client,
512+
span, entry.physical_collection, getattr(t3, "_client", t3),
507513
)
508514
return result["chunk_text"] if result else None
509515
except Exception as exc: # noqa: BLE001 — span resolution is best-effort; failure is logged at WARNING and degrades to None, must not crash caller

src/nexus/catalog/http_catalog_client.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1274,7 +1274,29 @@ def resolve_chunk(self, tumbler: Tumbler | str) -> dict | None:
12741274
}
12751275

12761276
def resolve_span_text(self, tumbler: Tumbler | str, span: str) -> str | None:
1277-
return None # not supported in initial service-mode implementation
1277+
"""Resolve a span to text content — service-mode parity (nexus-p8nd5).
1278+
1279+
Faithful mirror of :meth:`nexus.catalog.catalog.Catalog.resolve_span_text`
1280+
(the canonical contract, ``catalog_protocol``): ``None`` when the span
1281+
is genuinely unresolvable (unknown tumbler, missing chunk); RAISES
1282+
:class:`~nexus.db.http_vector_client.VectorServiceError` when the
1283+
vector service is DEGRADED (nexus-ib6uy — unreachable is never
1284+
collapsed into not-found; the CLI boundaries render the distinction).
1285+
Composition over the existing service surfaces: entry via
1286+
:meth:`resolve`, chunk reads via the service store routes (the shared
1287+
resolver's T3 reads are client-shape-agnostic since the p8nd5 seam
1288+
fix), manifest via :meth:`get_manifest` for chunk:char spans.
1289+
"""
1290+
if not span:
1291+
return None
1292+
entry = self.resolve(tumbler)
1293+
if entry is None:
1294+
return None
1295+
from nexus.catalog import catalog_spans # noqa: PLC0415 — circular-dep avoidance
1296+
1297+
return catalog_spans.resolve_span_text_for_entry(
1298+
entry, span, catalog=self,
1299+
)
12781300

12791301
# ══════════════════════════════════════════════════════════════════════════
12801302
# LINKS

src/nexus/commands/migrate_cmd.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,14 @@ def migrate_to_service_cmd(
142142

143143

144144
def _run_dry_run(local_path: str | None) -> None:
145-
local, cloud = open_read_legs(local_path)
145+
_skipped: dict = {}
146+
local, cloud = open_read_legs(local_path, skipped_out=_skipped)
146147
try:
147148
report = classify_collections(
148149
local_client=local,
149150
cloud_client=cloud,
150151
voyage_key_present=voyage_key_available(),
152+
cloud_leg_skipped_reason=_skipped.get("cloud"),
151153
)
152154
# The real run this previews is run_guided_upgrade -> land-then-
153155
# transform, which rehashes ids server-side. The preview must answer
@@ -254,13 +256,15 @@ def _run_migration(
254256
# gate the billed run behind an explicit confirmation. A migration that
255257
# bills nothing (byte-for-byte copy / local ONNX re-embed) proceeds silently.
256258
# Runs AFTER the version-floor gate above (see the nexus-b6qlf comment).
257-
local_read, cloud_read = open_read_legs(local_path)
259+
_skipped2: dict = {}
260+
local_read, cloud_read = open_read_legs(local_path, skipped_out=_skipped2)
258261
try:
259262
cost_preview = build_dry_run_preview(
260263
classify_collections(
261264
local_client=local_read,
262265
cloud_client=cloud_read,
263266
voyage_key_present=voyage_key_available(),
267+
cloud_leg_skipped_reason=_skipped2.get("cloud"),
264268
),
265269
# Same path as the run below (land-then-transform).
266270
rehashes_ids=True,

src/nexus/migration/detection.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,13 @@ class DetectionReport:
536536
#: cloud mode, bge-768 in local. Defaults False (local) for the many test
537537
#: doubles that construct a report without a live mode signal.
538538
voyage_key_present: bool = False
539+
#: nexus-p8nd5 dv708 fold: why the CONFIGURED cloud read leg was skipped
540+
#: (dead creds / unreachable host — the nexus-dv708 degrade), or ``None``
541+
#: when the leg was read or was never configured. Lets non-stderr
542+
#: consumers (dry-run preview JSON, doctor) distinguish
543+
#: "skipped-unreadable" from "never configured" — previously only a WARN
544+
#: log carried the distinction.
545+
cloud_leg_skipped_reason: str | None = None
539546

540547
@property
541548
def legs_with_data(self) -> frozenset[str]:
@@ -679,6 +686,7 @@ def classify_collections(
679686
local_client: Any | None = None,
680687
cloud_client: Any | None = None,
681688
voyage_key_present: bool,
689+
cloud_leg_skipped_reason: str | None = None,
682690
) -> DetectionReport:
683691
"""Classify the Chroma footprint per collection across both source legs.
684692
@@ -703,6 +711,7 @@ def classify_collections(
703711
report = DetectionReport(
704712
classifications=tuple(classifications),
705713
voyage_key_present=voyage_key_present,
714+
cloud_leg_skipped_reason=cloud_leg_skipped_reason,
706715
)
707716
_log.info(
708717
"migration_detect_classified",
@@ -764,6 +773,7 @@ def resolve_default_local_leg() -> Path:
764773

765774
def open_read_legs(
766775
local_path: str | Path | None = None,
776+
skipped_out: dict | None = None,
767777
) -> tuple[Any | None, Any | None]:
768778
"""Open whichever Chroma read legs are present, returning ``(local, cloud)``.
769779
@@ -825,6 +835,10 @@ def open_read_legs(
825835
guidance="cloud migration-source leg skipped — fix or remove the "
826836
"CHROMA_API_KEY/tenant config if this source still matters",
827837
)
838+
if skipped_out is not None:
839+
# dv708 structured residual (nexus-p8nd5): carry the skip reason
840+
# to DetectionReport so non-stderr consumers see it.
841+
skipped_out["cloud"] = f"{type(exc).__name__}: {exc}"
828842
cloud = None
829843

830844
return local, cloud
@@ -928,6 +942,10 @@ class DryRunPreview:
928942
#: lacking a stored vector re-embeds that batch (and bills). Surfaced as a
929943
#: caveat so the ``$0`` estimate is honest about that fallback (review).
930944
passthrough_voyage_tokens: int = 0
945+
#: dv708 structured residual (nexus-p8nd5): a CONFIGURED cloud leg that
946+
#: was skipped-unreadable (dead creds / unreachable) — the preview must
947+
#: say so, or "no cloud collections" silently means two different things.
948+
cloud_leg_skipped_reason: str | None = None
931949

932950

933951
def _throughput_for_support(support: Support) -> float:
@@ -1048,6 +1066,7 @@ def _cross_target(c: CollectionClassification) -> str | None:
10481066
billed_voyage_tokens=billed_voyage_tokens,
10491067
est_voyage_cost_usd=billed_voyage_tokens / 1_000_000 * _VOYAGE_COST_USD_PER_1M_TOKENS,
10501068
passthrough_voyage_tokens=passthrough_voyage_tokens,
1069+
cloud_leg_skipped_reason=report.cloud_leg_skipped_reason,
10511070
)
10521071

10531072

@@ -1058,6 +1077,12 @@ def render_dry_run_preview(preview: DryRunPreview) -> str:
10581077
``click.echo`` over it.
10591078
"""
10601079
lines: list[str] = []
1080+
if preview.cloud_leg_skipped_reason:
1081+
lines.append(
1082+
f" ⚠ cloud migration-source leg SKIPPED (unreadable, not absent): "
1083+
f"{preview.cloud_leg_skipped_reason} — fix or remove the cloud "
1084+
f"config if that source still matters"
1085+
)
10611086
lines.append("Chroma -> service migration — DRY RUN (no data will be moved)")
10621087
lines.append("")
10631088
if not preview.groups:

src/nexus/migration/driver.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -479,12 +479,14 @@ def run_guided_upgrade(
479479
# 1. DETECT — open read legs, classify, then CLOSE before any landing (the
480480
# local leg is a WAL single-opener; the landing reopen must be the sole
481481
# opener).
482-
local, cloud = open_read_legs(local_path)
482+
_skipped: dict = {}
483+
local, cloud = open_read_legs(local_path, skipped_out=_skipped)
483484
try:
484485
detection = classify_collections(
485486
local_client=local,
486487
cloud_client=cloud,
487488
voyage_key_present=key_present,
489+
cloud_leg_skipped_reason=_skipped.get("cloud"),
488490
)
489491
finally:
490492
for client in (local, cloud):

src/nexus/migration/guided_upgrade.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,12 +262,19 @@ def detect_pending_migration(
262262
_open = open_legs if open_legs is not None else open_read_legs
263263
_close = close_leg if close_leg is not None else close_read_client
264264

265-
local, cloud = _open(local_path)
265+
_skipped: dict = {}
266+
try:
267+
local, cloud = _open(local_path, skipped_out=_skipped)
268+
except TypeError:
269+
# Injected open_legs doubles predate the skipped_out kwarg — the
270+
# structured skip note is best-effort for them.
271+
local, cloud = _open(local_path)
266272
try:
267273
report = classify_collections(
268274
local_client=local,
269275
cloud_client=cloud,
270276
voyage_key_present=key_present,
277+
cloud_leg_skipped_reason=_skipped.get("cloud"),
271278
)
272279
finally:
273280
# Close only the legs that were actually opened — an absent leg is
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# SPDX-License-Identifier: AGPL-3.0-or-later
2+
# Copyright (c) 2026 Hal Hildebrand. All rights reserved.
3+
"""nexus-p8nd5 — HttpCatalogClient.resolve_span_text service-mode parity.
4+
5+
The stub returned ``None`` unconditionally, so the majority topology
6+
(service mode) never saw the ib6uy distinguishability contract the local
7+
``Catalog.resolve_span_text`` honours: genuinely-unresolvable → ``None``,
8+
DEGRADED vector service → :class:`VectorServiceError` raised for the
9+
boundary to render. These tests pin the parity, including the underlying
10+
``t3._client`` seam fix in ``catalog_spans`` (HttpVectorClient deliberately
11+
has no ``_client`` attribute — the old code AttributeError'd into the
12+
broad except and masked every service-mode chash span to ``None``).
13+
"""
14+
from __future__ import annotations
15+
16+
import hashlib
17+
from unittest.mock import patch
18+
19+
import pytest
20+
21+
from nexus.catalog.catalog import CatalogEntry
22+
from nexus.catalog.tumbler import Tumbler
23+
from nexus.catalog.http_catalog_client import HttpCatalogClient
24+
from nexus.db.http_vector_client import VectorServiceError
25+
26+
_CHASH = hashlib.sha256(b"span target text").hexdigest()
27+
28+
29+
def _entry(**over) -> CatalogEntry:
30+
kw = dict(
31+
tumbler=Tumbler.parse("1.2.3"),
32+
title="Doc",
33+
author="",
34+
year=0,
35+
content_type="knowledge",
36+
file_path="",
37+
corpus="knowledge",
38+
physical_collection="knowledge__t__voyage-context-3__v1",
39+
chunk_count=1,
40+
head_hash="",
41+
indexed_at="",
42+
)
43+
kw.update(over)
44+
return CatalogEntry(**kw)
45+
46+
47+
class _ServiceT3:
48+
"""HttpVectorClient-shaped double: get_collection but NO _client attr."""
49+
50+
def __init__(self, docs=None, error: Exception | None = None):
51+
self._docs = docs or []
52+
self._error = error
53+
self.where_seen: list[dict] = []
54+
55+
def get_collection(self, name):
56+
outer = self
57+
58+
class _Col:
59+
def get(self, *, ids=None, where=None, include=None, **kw):
60+
if outer._error is not None:
61+
raise outer._error
62+
outer.where_seen.append(where or {"ids": ids})
63+
if outer._docs:
64+
return {"ids": ["x"], "documents": list(outer._docs),
65+
"metadatas": [{}] * len(outer._docs)}
66+
return {"ids": [], "documents": [], "metadatas": []}
67+
68+
return _Col()
69+
70+
# deliberate: no _client attribute (pinned HttpVectorClient shape)
71+
72+
73+
def _client() -> HttpCatalogClient:
74+
return HttpCatalogClient(base_url="http://127.0.0.1:1", tenant="t", _token="test-token")
75+
76+
77+
def test_chash_span_resolves_through_the_service_shape():
78+
c = _client()
79+
t3 = _ServiceT3(docs=["span target text"])
80+
with patch.object(HttpCatalogClient, "resolve", return_value=_entry()), \
81+
patch("nexus.db.make_t3", return_value=t3):
82+
out = c.resolve_span_text("1.2.3", f"chash:{_CHASH}")
83+
assert out == "span target text"
84+
assert t3.where_seen and t3.where_seen[0] == {"chunk_text_hash": _CHASH}
85+
86+
87+
def test_degraded_service_raises_never_masks_to_none():
88+
"""ib6uy: unreachable is never collapsed into not-found."""
89+
c = _client()
90+
t3 = _ServiceT3(error=VectorServiceError("service unreachable", code=503))
91+
with patch.object(HttpCatalogClient, "resolve", return_value=_entry()), \
92+
patch("nexus.db.make_t3", return_value=t3):
93+
with pytest.raises(VectorServiceError):
94+
c.resolve_span_text("1.2.3", f"chash:{_CHASH}")
95+
96+
97+
def test_unknown_tumbler_is_none():
98+
c = _client()
99+
with patch.object(HttpCatalogClient, "resolve", return_value=None):
100+
assert c.resolve_span_text("9.9.9", f"chash:{_CHASH}") is None
101+
102+
103+
def test_empty_span_is_none_without_resolving():
104+
c = _client()
105+
with patch.object(HttpCatalogClient, "resolve") as res:
106+
assert c.resolve_span_text("1.2.3", "") is None
107+
res.assert_not_called()
108+
109+
110+
def test_missing_chunk_is_none_not_error():
111+
c = _client()
112+
t3 = _ServiceT3(docs=[])
113+
with patch.object(HttpCatalogClient, "resolve", return_value=_entry()), \
114+
patch("nexus.db.make_t3", return_value=t3):
115+
assert c.resolve_span_text("1.2.3", f"chash:{_CHASH}") is None
116+
117+
118+
def test_shared_resolver_uses_handle_when_no_client_attr():
119+
"""The seam fix itself: resolve_span_text_for_entry must reach the T3
120+
read through the HANDLE when ``_client`` is absent (service shape) —
121+
the old ``t3._client`` read AttributeError'd into the broad except and
122+
masked every service-mode chash span to None."""
123+
from nexus.catalog.catalog_spans import resolve_span_text_for_entry
124+
125+
t3 = _ServiceT3(docs=["via handle"])
126+
with patch("nexus.db.make_t3", return_value=t3):
127+
out = resolve_span_text_for_entry(_entry(), f"chash:{_CHASH}")
128+
assert out == "via handle"

tests/commands/test_migrate_cost_guardrail.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ def _wire(self, monkeypatch, *, cost: float, tokens: int = 1000):
103103

104104
ran: list[str] = []
105105
monkeypatch.setenv("NX_SERVICE_TOKEN", "tok")
106-
monkeypatch.setattr(mc, "open_read_legs", lambda p: (None, None))
106+
monkeypatch.setattr(mc, "open_read_legs", lambda p, **kw: (None, None))
107107
monkeypatch.setattr(mc, "classify_collections", lambda **k: object())
108108
monkeypatch.setattr(mc, "voyage_key_available", lambda: True)
109109
monkeypatch.setattr(
@@ -179,7 +179,7 @@ def _wire_cloud(self, monkeypatch, *, cost: float = 0.0, tokens: int = 0):
179179

180180
ran: list[str] = []
181181
monkeypatch.setenv("NX_SERVICE_TOKEN", "tok")
182-
monkeypatch.setattr(mc, "open_read_legs", lambda p: (None, None))
182+
monkeypatch.setattr(mc, "open_read_legs", lambda p, **kw: (None, None))
183183
monkeypatch.setattr(mc, "classify_collections", lambda **k: object())
184184
monkeypatch.setattr(mc, "voyage_key_available", lambda: True)
185185
monkeypatch.setattr(
@@ -251,7 +251,7 @@ def _wire(self, monkeypatch, *, cost: float = 0.0, tokens: int = 0):
251251
import nexus.commands.migrate_cmd as mc
252252

253253
monkeypatch.setenv("NX_SERVICE_TOKEN", "tok")
254-
monkeypatch.setattr(mc, "open_read_legs", lambda p: (None, None))
254+
monkeypatch.setattr(mc, "open_read_legs", lambda p, **kw: (None, None))
255255
monkeypatch.setattr(mc, "classify_collections", lambda **k: object())
256256
monkeypatch.setattr(mc, "voyage_key_available", lambda: True)
257257
monkeypatch.setattr(
@@ -407,7 +407,7 @@ def test_validation_setup_filenotfound_wrapped_end_to_end(
407407
)
408408

409409
monkeypatch.setattr(
410-
driver, "open_read_legs", lambda local_path=None: (object(), object())
410+
driver, "open_read_legs", lambda local_path=None, **kw: (object(), object())
411411
)
412412
monkeypatch.setattr(driver, "classify_collections", lambda **_k: detection)
413413
# nexus-jxizy.10.7: the guided driver now runs the land-then-transform

0 commit comments

Comments
 (0)