arborist/aborist/qa
russell@unturf.com 41d1d9b71e
qa(client): scrub lone UTF-16 surrogates before httpx encode
Six bench runs aborted with:

  UnicodeEncodeError: 'utf-8' codec can't encode characters
  in position N-M: surrogates not allowed

The error fires inside httpx's json-encode path: when the
request body's JSON contains lone surrogates (from Wikipedia
chunks ingested with invalid-UTF-8 source bytes), httpx's
.encode('utf-8') raises before the request even leaves the
client.

Earlier surrogate fixes (3b91223) hardened the OUTPUT side —
sha256 hashers now use errors='surrogatepass' so the run-DAG
roots survive surrogate-bearing model output. But the INPUT
side (corpus text injected into the prompt) was still
vulnerable: the LLM never sees the surrogate but the HTTP
client tries to send it.

Fix: scrub message content via WTF-8 → UTF-8-with-replace
roundtrip in OpenAICompatibleClient.chat_completion. Lone
surrogates become U+FFFD (REPLACEMENT CHARACTER); the prompt
serializes cleanly. Verified: 'tell me about the roman empire'
under claim_lattice mode now classifies HYBRID 5/7 instead of
erroring out (this question was 6/6 lattice runs failing on
the 2026-05-02 c=4 bench).

The scrub lives in the client because the hot path needs to
guarantee the outbound HTTP body is valid UTF-8, regardless of
what upstream code injected. Defense-in-depth: ingest-time
sanitization would be cleaner but the existing corpus already
has surrogates baked in, and re-ingest would invalidate every
document_root in 6 GB of shards.

Tests: 751/34 still pass clean in 11s with pytest -n auto.
2026-05-02 11:58:25 -04:00
..
__init__.py multi-source corpus query: pose a question, the tree pulls related cached docs 2026-04-27 11:43:57 -04:00
client.py qa(client): scrub lone UTF-16 surrogates before httpx encode 2026-05-02 11:58:25 -04:00
concepts.py qa(concepts): corpus-derived concept_relations table replaces frozensets 2026-05-01 21:14:53 -04:00
dag.py speed: pytest-xdist, bench smoke, concurrency default; UTF surrogate fix 2026-05-02 09:29:40 -04:00
evidence.py speed: pytest-xdist, bench smoke, concurrency default; UTF surrogate fix 2026-05-02 09:29:40 -04:00
frame.py qa(frame): #000002 land — reference-frame polarity contract (D3 → ✓) 2026-05-01 20:18:13 -04:00
inspect.py qa/inspect: title-relevance sidecar — flag retrieval-driven hallucinations 2026-05-01 10:19:44 -04:00
keys.py speed: pytest-xdist, bench smoke, concurrency default; UTF surrogate fix 2026-05-02 09:29:40 -04:00
parse_claims.py qa: G0 claim-lattice-pointer answer mode — CTI quote-by-pointer 2026-04-29 21:19:19 -04:00
prompts.py qa(prompts): collapse lattice prompts to single source of truth 2026-05-02 09:58:45 -04:00
query.py qa(prompts): collapse lattice prompts to single source of truth 2026-05-02 09:58:45 -04:00
repair.py qa: chain-segment failure localization + re-prompt repair tier 2026-04-29 19:27:33 -04:00
retrieval_plan.py qa(provenance): #000001 land — retrieval_plan_hash binds D4 2026-05-01 19:26:01 -04:00
runner.py qa(prompts): collapse lattice prompts to single source of truth 2026-05-02 09:58:45 -04:00
verify.py qa(verifier): promote deflection sidecar to soft-demote violation 2026-05-01 23:44:32 -04:00
warrant.py qa(warrant): #000003 land — anchor-class generalization (D6 → ✓) 2026-05-01 19:16:06 -04:00