Skip to content

Commit 836779e

Browse files
Adopt 0097 rerank document-echo shape rule (#213)
Adopt proposal 0097's document-echo shape rule and lift it into a shared document_echo helper in retrieval/_events.py, used by every RerankProvider (Cohere, TEI, Jina). A string echo surfaces verbatim (an empty string is present), a TextDoc object {"text": ...} unwraps to its string, a text-less object or absent/null yields null, and a non-object scalar (number / array / bool) raises provider_invalid_response rather than folding to null. The verbatim echo stays on RerankResponse.raw. This replaces three per-provider copies of the string-or-null fold; Cohere and TEI now unwrap TextDoc objects and raise on corrupt scalars, matching Jina. The conformance fixtures (019 cases C-G) ride the v0.92.0 pin bump.
1 parent 1d2cfc4 commit 836779e

5 files changed

Lines changed: 122 additions & 45 deletions

File tree

src/openarmature/retrieval/_events.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@
1919
from __future__ import annotations
2020

2121
from collections.abc import Mapping
22-
from typing import Any
22+
from typing import Any, cast
2323

2424
from openarmature.graph.events import (
2525
EmbeddingEvent,
2626
EmbeddingFailedEvent,
2727
RerankEvent,
2828
RerankFailedEvent,
2929
)
30-
from openarmature.llm.errors import LlmProviderError
30+
from openarmature.llm.errors import LlmProviderError, ProviderInvalidResponse
3131
from openarmature.observability.correlation import (
3232
current_attempt_index,
3333
current_branch_name,
@@ -108,6 +108,31 @@ def apply_client_side_prefix(
108108
return [prefix + s for s in input_strings]
109109

110110

111+
# §6 (0097): the rerank document-echo shape rule, shared by every RerankProvider
112+
# (Cohere / TEI / Jina). ScoredDocument.document carries the provider's string
113+
# echo verbatim when present, null otherwise -- never fabricated from the input
114+
# documents. Vendors echo it in several shapes: a bare string, a TextDoc object
115+
# {"text": "..."} (Jina's return_documents form), a text-less object (an
116+
# ImageDoc), or absent/null. Unwrap the string from a string or a TextDoc; a
117+
# text-less object or absent/null yields null (an empty string is present --
118+
# surfaced as "", not folded to null). A NON-object scalar (number / array /
119+
# bool -- outside the documented anyOf[string, object, null]) is wire corruption
120+
# and raises provider_invalid_response (§7), NOT folded to null. The verbatim
121+
# echo is preserved on RerankResponse.raw regardless.
122+
def document_echo(value: Any) -> str | None:
123+
"""Extract the string document echo from a rerank result's documented shapes."""
124+
if value is None:
125+
return None
126+
if isinstance(value, str):
127+
return value
128+
if isinstance(value, dict):
129+
text = cast("dict[str, Any]", value).get("text")
130+
return text if isinstance(text, str) else None
131+
raise ProviderInvalidResponse(
132+
f"rerank document echo must be a string, object, or null (got {type(value).__name__})"
133+
)
134+
135+
111136
def build_embedding_event(
112137
response: EmbeddingResponse,
113138
latency_ms: float,
@@ -334,5 +359,6 @@ def build_rerank_failed_event(
334359
"build_rerank_event",
335360
"build_rerank_failed_event",
336361
"client_side_prefix",
362+
"document_echo",
337363
"normalize_base_url",
338364
]

src/openarmature/retrieval/providers/cohere.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
)
4545
from openarmature.observability.correlation import current_dispatch
4646

47-
from .._events import build_rerank_event, build_rerank_failed_event, normalize_base_url
47+
from .._events import build_rerank_event, build_rerank_failed_event, document_echo, normalize_base_url
4848
from ..provider import validate_rerank_input, validate_rerank_response
4949
from ..response import RerankResponse, RerankRuntimeConfig, RerankUsage, ScoredDocument
5050

@@ -306,11 +306,10 @@ def _parse_response(
306306
raise ProviderInvalidResponse(
307307
"rerank response entry has a missing or non-numeric 'relevance_score'"
308308
)
309-
# Read ``document`` only when present; never auto-fill from the
310-
# input documents list (§6 -- the provider's echo and the caller's
311-
# input are two different surfaces).
312-
document_raw = entry.get("document")
313-
document = document_raw if isinstance(document_raw, str) else None
309+
# Read the document echo per the shared §6 (0097) rule; never
310+
# auto-fill from the input documents. Cohere v2 does not echo, so
311+
# this is null in practice, but a compatible gateway may return one.
312+
document = document_echo(entry.get("document"))
314313
scored.append(ScoredDocument(index=index, relevance_score=float(score), document=document))
315314
# §6: sort by relevance_score descending before validating / returning.
316315
scored.sort(key=lambda s: s.relevance_score, reverse=True)

src/openarmature/retrieval/providers/jina.py

Lines changed: 5 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
build_embedding_failed_event,
6262
build_rerank_event,
6363
build_rerank_failed_event,
64+
document_echo,
6465
normalize_base_url,
6566
)
6667
from ..provider import (
@@ -153,25 +154,6 @@ def _nonneg_int(value: Any) -> int | None:
153154
return None
154155

155156

156-
# §6: ScoredDocument.document carries the provider's string echo verbatim when
157-
# present, null otherwise -- never fabricated from the input documents. Jina
158-
# types `document` as anyOf[string, TextDoc{text}, ImageDoc, null] (its OpenAPI
159-
# schema); the text reranker returns the TextDoc OBJECT form {"text": "..."}
160-
# when return_documents=true, while a bare string is also valid. Extract the
161-
# string from either shape; any other shape (an ImageDoc, a malformed entry, an
162-
# absent echo) yields null -- the verbatim object is preserved on
163-
# RerankResponse.raw regardless.
164-
def _document_echo(value: Any) -> str | None:
165-
"""Extract the string document echo from Jina's string-or-TextDoc shape."""
166-
if isinstance(value, str):
167-
return value
168-
if isinstance(value, dict):
169-
text = cast("dict[str, Any]", value).get("text")
170-
if isinstance(text, str):
171-
return text
172-
return None
173-
174-
175157
# Absence-is-meaningful per observability §5.5.2: only caller-supplied keys
176158
# appear in the event's request_params -- "the field was not supplied for this
177159
# call", distinct from a supplied default. For Jina, input_type joins dimensions
@@ -662,12 +644,10 @@ def _parse_response(
662644
raise ProviderInvalidResponse(
663645
"rerank response entry has a missing or non-numeric 'relevance_score'"
664646
)
665-
# Read the document echo only when present; never auto-fill from the
666-
# input documents list (§6 -- the provider's echo and the caller's
667-
# input are two different surfaces). Jina echoes text as a TextDoc
668-
# object ({"text": ...}), so _document_echo unwraps either the object
669-
# or a bare-string shape onto the string field.
670-
document = _document_echo(entry.get("document"))
647+
# Read the document echo per the shared §6 (0097) rule; never
648+
# auto-fill from the input documents. Jina echoes text as a TextDoc
649+
# object ({"text": ...}), which document_echo unwraps to the string.
650+
document = document_echo(entry.get("document"))
671651
scored.append(ScoredDocument(index=index, relevance_score=float(score), document=document))
672652
# §6: sort by relevance_score descending before validating / returning.
673653
scored.sort(key=lambda s: s.relevance_score, reverse=True)

src/openarmature/retrieval/providers/tei.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
build_embedding_failed_event,
5656
build_rerank_event,
5757
build_rerank_failed_event,
58+
document_echo,
5859
)
5960
from ..provider import (
6061
validate_embedding_input,
@@ -634,11 +635,10 @@ def _parse_chunk(
634635
score = entry.get("score")
635636
if not isinstance(score, (int, float)) or isinstance(score, bool):
636637
raise ProviderInvalidResponse("rerank response entry has a missing or non-numeric 'score'")
637-
# Read ``text`` only when present; never auto-fill from the input
638-
# documents list (§6 -- the provider's echo and the caller's input
639-
# are two different surfaces).
640-
text_raw = entry.get("text")
641-
document = text_raw if isinstance(text_raw, str) else None
638+
# Read the ``text`` echo per the shared §6 (0097) rule; never
639+
# auto-fill from the input documents. TEI's return_text echo is a
640+
# bare string, so this is the identity path in practice.
641+
document = document_echo(entry.get("text"))
642642
scored.append(
643643
ScoredDocument(index=offset + index, relevance_score=float(score), document=document)
644644
)

tests/unit/test_retrieval_provider.py

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,40 @@ def handler(req: httpx.Request) -> httpx.Response:
637637
await provider.aclose()
638638

639639

640+
async def test_cohere_rerank_document_echo_shared_rule() -> None:
641+
# §6 (0097): Cohere is on the shared document_echo rule -- a TextDoc object
642+
# echo unwraps to its text; a text-less object (or absent) yields null.
643+
def handler(_req: httpx.Request) -> httpx.Response:
644+
return httpx.Response(
645+
200,
646+
json=_rerank_body(
647+
results=[
648+
{"index": 0, "relevance_score": 0.9, "document": {"text": "echoed"}},
649+
{"index": 1, "relevance_score": 0.5, "document": {"image": "..."}},
650+
]
651+
),
652+
)
653+
654+
provider = _rerank_provider(handler)
655+
response = await provider.rerank("q", ["a", "b"], config=RerankRuntimeConfig(return_documents=True))
656+
await provider.aclose()
657+
assert {r.index: r.document for r in response.results} == {0: "echoed", 1: None}
658+
659+
660+
async def test_cohere_rerank_non_object_document_echo_raises() -> None:
661+
# §6 (0097): a non-object scalar echo is wire corruption -> provider_invalid_response.
662+
def handler(_req: httpx.Request) -> httpx.Response:
663+
return httpx.Response(
664+
200,
665+
json=_rerank_body(results=[{"index": 0, "relevance_score": 0.9, "document": 123}]),
666+
)
667+
668+
provider = _rerank_provider(handler)
669+
with pytest.raises(ProviderInvalidResponse):
670+
await provider.rerank("q", ["a"], config=RerankRuntimeConfig(return_documents=True))
671+
await provider.aclose()
672+
673+
640674
# --- typed-event dispatch ---------------------------------------------------
641675

642676

@@ -1152,6 +1186,19 @@ def handler(_req: httpx.Request) -> httpx.Response:
11521186
await provider.aclose()
11531187

11541188

1189+
async def test_tei_rerank_non_object_text_echo_raises() -> None:
1190+
# §6 (0097): TEI is on the shared document_echo rule -- its `text` echo is a
1191+
# bare string in practice, but a non-object scalar (wire corruption) raises
1192+
# provider_invalid_response rather than folding to null.
1193+
def handler(_req: httpx.Request) -> httpx.Response:
1194+
return httpx.Response(200, json=[{"index": 0, "score": 0.9, "text": 123}])
1195+
1196+
provider = _tei_rerank_provider(handler)
1197+
with pytest.raises(ProviderInvalidResponse):
1198+
await provider.rerank("q", ["a"], config=RerankRuntimeConfig(return_documents=True))
1199+
await provider.aclose()
1200+
1201+
11551202
def test_tei_base_url_strips_trailing_slash() -> None:
11561203
provider = TeiEmbeddingProvider(base_url="http://tei-embed.test/", model="m")
11571204
assert provider.base_url == "http://tei-embed.test"
@@ -1335,9 +1382,10 @@ async def test_jina_rerank_document_echo_unwraps_textdoc_object() -> None:
13351382
# Jina's real /v1/rerank echoes the document as a TextDoc OBJECT
13361383
# ({"text": ...}) when return_documents=true, not a bare string -- its
13371384
# OpenAPI types `document` as anyOf[string, TextDoc, ImageDoc, null]. The
1338-
# mapping unwraps either shape onto ScoredDocument.document (§6); a shape
1339-
# with no string text (an ImageDoc, a malformed entry) yields null, and the
1340-
# verbatim object is preserved on RerankResponse.raw.
1385+
# mapping unwraps either shape onto ScoredDocument.document (§6); a text-less
1386+
# object (an ImageDoc) or an absent/null echo yields null, an empty string is
1387+
# present (surfaced as ""), and the verbatim object is preserved on
1388+
# RerankResponse.raw.
13411389
def handler(_req: httpx.Request) -> httpx.Response:
13421390
return httpx.Response(
13431391
200,
@@ -1347,23 +1395,47 @@ def handler(_req: httpx.Request) -> httpx.Response:
13471395
"results": [
13481396
{"index": 0, "relevance_score": 0.9, "document": {"text": "doc a"}},
13491397
{"index": 1, "relevance_score": 0.5, "document": "doc b"},
1350-
{"index": 2, "relevance_score": 0.1, "document": {"image": "..."}},
1398+
{"index": 2, "relevance_score": 0.4, "document": {"image": "..."}},
1399+
{"index": 3, "relevance_score": 0.3, "document": ""},
1400+
{"index": 4, "relevance_score": 0.1, "document": None},
13511401
],
13521402
},
13531403
)
13541404

13551405
provider = _jina_rerank_provider(handler)
13561406
config = RerankRuntimeConfig(return_documents=True)
1357-
response = await provider.rerank("q", ["doc a", "doc b", "doc c"], config=config)
1407+
response = await provider.rerank("q", ["a", "b", "c", "d", "e"], config=config)
13581408
await provider.aclose()
13591409

1360-
# TextDoc object -> its text; bare string -> itself; non-text shape -> null.
1361-
assert {r.index: r.document for r in response.results} == {0: "doc a", 1: "doc b", 2: None}
1410+
# TextDoc object -> its text; bare string -> itself; text-less object -> null;
1411+
# empty string -> "" (present, distinct from absent); null -> null.
1412+
echoes = {r.index: r.document for r in response.results}
1413+
assert echoes == {0: "doc a", 1: "doc b", 2: None, 3: "", 4: None}
13621414
# The verbatim TextDoc object is still reachable on raw.
13631415
assert isinstance(response.raw, dict)
13641416
assert response.raw["results"][0]["document"] == {"text": "doc a"}
13651417

13661418

1419+
async def test_jina_rerank_non_object_document_echo_raises() -> None:
1420+
# 0097: a non-object scalar echo (number / array / bool -- outside Jina's
1421+
# documented anyOf[string, TextDoc, ImageDoc, null]) is wire corruption and
1422+
# raises provider_invalid_response, NOT folded to null.
1423+
def handler(_req: httpx.Request) -> httpx.Response:
1424+
return httpx.Response(
1425+
200,
1426+
json={
1427+
"model": "jina-reranker-test",
1428+
"usage": {"total_tokens": 4},
1429+
"results": [{"index": 0, "relevance_score": 0.9, "document": 123}],
1430+
},
1431+
)
1432+
1433+
provider = _jina_rerank_provider(handler)
1434+
with pytest.raises(ProviderInvalidResponse):
1435+
await provider.rerank("q", ["doc a"], config=RerankRuntimeConfig(return_documents=True))
1436+
await provider.aclose()
1437+
1438+
13671439
async def test_jina_rerank_relevance_score_parse_sorted_and_usage() -> None:
13681440
def handler(_req: httpx.Request) -> httpx.Response:
13691441
# UNSORTED, Cohere-shaped relevance_score; Jina reports total_tokens.

0 commit comments

Comments
 (0)