feat(#000057): arborist+qwen enablement — multi-engine JSON-schema + per-model extras pass-through

Two surgical fixes unblock 'arborist with synthesis LLM = Qwen-on-
llama.cpp' as a viable arm in the control sweep. Pre-existing
docstring said 'Arborist×Qwen needs proof-path guided_json+extra_body
surgery — coupled follow-up'; this is that follow-up.

Fix 1 — multi-engine structured-output extras

The runner / query JSON-mode paths previously sent only vLLM's
'guided_json' key for the claim_lattice schema. llama.cpp silently
drops it, leaving Qwen un-enforced (the parse-tolerant fallback did
all the work). Helper

    claim_lattice_structured_output_extras() in arborist/qa/verify.py

now returns a dict carrying the schema under all three engine
conventions:

  - guided_json     (vLLM grammar-constrained sampling)
  - json_schema     (llama.cpp native shorthand)
  - response_format (OpenAI-spec, honoured by llama.cpp and newer vLLM)

Each engine recognises its own key and silently drops the others.
Used at both inference call sites (runner.py:740, query.py:3324).
Hermes/vLLM path is unchanged — it picks up 'guided_json' and
ignores the other two.

Fix 2 — query() accepts user-supplied extra_body, merges with defaults

query() grew a keyword-only extra_body parameter (default None).
Per-model knobs (Qwen's {'chat_template_kwargs': {'enable_thinking':
False}} toggle, future template knobs) can flow from the caller to
the synthesis chat-completion call. Schema-enforcement extras are
added inside query() and merge under user keys — common case is
disjoint namespaces, but if a caller wants to override 'guided_json'
they can.

bench/control_sweep.py now passes MODELS[arborist_ref]['extra']
through to query() in the arborist branch, so --arborist-ref
qwen-nothink runs with reasoning disabled and --arborist-ref
qwen-think runs with reasoning enabled. Phase 1's arborist arm with
--arborist-ref=hermes is unaffected (MODELS['hermes']['extra'] is
None, merges to no-op).

Tests
  + 3 new in tests/test_verify_json.py covering helper default shape,
    alternate-schema reuse, and query()'s new extra_body parameter
  220 affected tests still green (verify / claim_lattice / judge /
    runner suite)
  pytest test_verify_json: 27/27

Next: small smoke run --arborist-ref qwen-nothink against 4-8 items
to confirm end-to-end before any full sweep. Phase 2 (qwen-think solo)
still running in background, unaffected — it doesn't touch the
arborist arm.
This commit is contained in:
russell@unturf.com 2026-05-19 19:53:00 -04:00
parent 3450e8a281
commit c6621ee700
No known key found for this signature in database
5 changed files with 141 additions and 11 deletions

View file

@ -1920,6 +1920,7 @@ def query(
retrieval_keywords: str | None = None, retrieval_keywords: str | None = None,
translator: object | None = None, translator: object | None = None,
progress: Progress | None = None, progress: Progress | None = None,
extra_body: dict | None = None,
) -> dict: ) -> dict:
"""Answer `question` using the corpus. Cache to qa_db. Returns a result dict. """Answer `question` using the corpus. Cache to qa_db. Returns a result dict.
@ -3312,16 +3313,24 @@ def query(
}, },
} }
# 5. Cache miss — call LLM. JSON mode passes guided_json via # 5. Cache miss — call LLM. JSON mode emits structured-output
# extra_body so vLLM constrains output to the schema at sampling # extras under all three engine conventions (vLLM `guided_json`,
# time. Non-vLLM endpoints silently drop the field; the lenient # llama.cpp `json_schema`, OpenAI-spec `response_format`) so the
# pre-parser in the verifier handles whatever drift remains. # schema is enforced regardless of which engine is behind the
extra_body: dict | None = None # endpoint. Engines silently drop unknown keys. The user-supplied
# `extra_body` (e.g. {"chat_template_kwargs": {"enable_thinking":
# False}} for Qwen) merges on top — user keys override defaults
# if the caller set both, but the common case is disjoint
# namespaces (model-template knobs vs grammar guidance).
from arborist.qa.verify import claim_lattice_structured_output_extras
final_extra_body: dict | None = None
stop_seqs: list[str] | None = None stop_seqs: list[str] | None = None
if answer_mode == "claim_lattice" and policy.get( if answer_mode == "claim_lattice" and policy.get(
"claim_lattice_use_guided_json", True "claim_lattice_use_guided_json", True
): ):
extra_body = {"guided_json": CLAIM_LATTICE_JSON_SCHEMA} final_extra_body = claim_lattice_structured_output_extras()
if extra_body:
final_extra_body = {**(final_extra_body or {}), **extra_body}
if answer_mode == "claim_lattice": if answer_mode == "claim_lattice":
# JSON-mode token-runaway guard — see runner.py for the # JSON-mode token-runaway guard — see runner.py for the
# full rationale. Stops generation on a blank line so # full rationale. Stops generation on a blank line so
@ -3344,7 +3353,7 @@ def query(
temperature=policy["temperature"], temperature=policy["temperature"],
max_tokens=policy["max_tokens"], max_tokens=policy["max_tokens"],
top_p=policy.get("top_p", 1.0), top_p=policy.get("top_p", 1.0),
extra_body=extra_body, extra_body=final_extra_body,
stop=stop_seqs, stop=stop_seqs,
) )
llm_ms = _ms_since(t_phase) llm_ms = _ms_since(t_phase)

View file

@ -729,15 +729,21 @@ def ask(
} }
t_llm = time.monotonic() t_llm = time.monotonic()
# JSON mode: pass guided_json schema so vLLM constrains output at # JSON mode: pass structured-output extras under all three engine
# sampling time. Endpoints without guided-decoding silently drop the # conventions (vLLM `guided_json`, llama.cpp `json_schema`, OpenAI-
# field; the lenient pre-parser handles whatever drift remains. # spec `response_format`) so the same call site enforces the schema
# whether the model is on vLLM, llama.cpp, or OpenAI-spec. Engines
# silently drop unknown keys. Pre-2026-05-19 this was vLLM-only
# (`guided_json` alone); llama.cpp Qwen got NO enforcement and
# relied on the lenient pre-parser to clean drift — the parse-
# tolerant fallback still handles whatever drift remains.
from arborist.qa.verify import claim_lattice_structured_output_extras
extra_body: dict | None = None extra_body: dict | None = None
stop_seqs: list[str] | None = None stop_seqs: list[str] | None = None
if answer_mode == "claim_lattice" and policy.get( if answer_mode == "claim_lattice" and policy.get(
"claim_lattice_use_guided_json", True "claim_lattice_use_guided_json", True
): ):
extra_body = {"guided_json": CLAIM_LATTICE_JSON_SCHEMA} extra_body = claim_lattice_structured_output_extras()
if answer_mode == "claim_lattice": if answer_mode == "claim_lattice":
# JSON-mode token-runaway guard. On broad-descriptive / # JSON-mode token-runaway guard. On broad-descriptive /
# comparison questions Hermes-3-8B sometimes spams whitespace # comparison questions Hermes-3-8B sometimes spams whitespace

View file

@ -1847,6 +1847,49 @@ CLAIM_LATTICE_JSON_SCHEMA = {
} }
def claim_lattice_structured_output_extras(
schema: dict | None = None,
*,
name: str = "claim_lattice",
) -> dict:
"""Multi-engine ``extra_body`` for JSON-schema enforcement on
chat completions. Each inference engine recognises its own key
and silently drops the others, so sending all three lets the
same call site work across vLLM, llama.cpp, and OpenAI-spec
endpoints without per-endpoint branching:
* ``guided_json`` — vLLM grammar-constrained sampling
* ``json_schema`` — llama.cpp native shorthand
* ``response_format``— OpenAI-spec ``{type: json_schema, …}``
(honoured by llama.cpp and newer vLLM)
Returns a dict you splat into
``client.chat_completion(extra_body=…)``. Defaults to the claim-
lattice schema; pass an alternate schema to reuse the helper for
other structured-output features. The ``name`` is required by
OpenAI-spec ``response_format`` and is the user-visible label
for the schema in some engines' error messages.
Added 2026-05-19 to enable the Arborist arm to run with Qwen on
llama.cpp (the old single-key ``guided_json`` was silently dropped
on llama.cpp, leaving Qwen un-enforced and the parse-tolerant
fallback doing all the work). Hermes/vLLM path is unchanged —
it still picks up ``guided_json`` and ignores the other two."""
s = schema if schema is not None else CLAIM_LATTICE_JSON_SCHEMA
return {
"guided_json": s,
"json_schema": s,
"response_format": {
"type": "json_schema",
"json_schema": {
"name": name,
"schema": s,
"strict": True,
},
},
}
def verify_claim_lattice_json( def verify_claim_lattice_json(
answer_json_text: str, answer_json_text: str,
evidence_map, evidence_map,

View file

@ -189,10 +189,21 @@ def _process_item(idx: int, it: dict, variants: list[str],
"judge_raw": (v.raw or "")[:700]}) "judge_raw": (v.raw or "")[:700]})
if arborist_on: if arborist_on:
try: try:
# Pass through MODELS[arborist_ref]["extra"] so
# per-model knobs (e.g. Qwen's enable_thinking
# toggle under chat_template_kwargs) reach the
# synthesis call in the proof-path. Without this,
# an arborist-ref=qwen-nothink sweep would still
# emit reasoning tokens because the toggle never
# crosses query()'s boundary. JSON-schema extras
# are added inside query() via
# claim_lattice_structured_output_extras() and
# merge with this per-model extras dict.
r = query(question=q_asked, qa_db=qa_db, r = query(question=q_asked, qa_db=qa_db,
chat_client=cl(arborist_ref), chat_client=cl(arborist_ref),
model_id=MODELS[arborist_ref]["model"], model_id=MODELS[arborist_ref]["model"],
shards_dir=shards_dir, shards_dir=shards_dir,
extra_body=MODELS[arborist_ref]["extra"],
policy=dict(DEFAULT_QUERY_POLICY, policy=dict(DEFAULT_QUERY_POLICY,
answer_mode="claim_lattice")) answer_mode="claim_lattice"))
arb_raw = (r.get("raw_answer") arb_raw = (r.get("raw_answer")

View file

@ -523,3 +523,64 @@ def test_verify_json_subject_tokens_absent_below_threshold_passes():
f"expected STRICT (below threshold); got {v['audit_mode']} " f"expected STRICT (below threshold); got {v['audit_mode']} "
f"with violations {[vio['kind'] for vio in v['violations']]}" f"with violations {[vio['kind'] for vio in v['violations']]}"
) )
# --- multi-engine structured-output extras (claim_lattice JSON mode) -------
def test_claim_lattice_structured_output_extras_default_shape():
"""The helper returns extras under all three engine conventions so
the same call site enforces the JSON schema across vLLM, llama.cpp,
and OpenAI-spec endpoints. Engines silently drop unknown keys, so
sending all three is safe."""
from arborist.qa.verify import (
CLAIM_LATTICE_JSON_SCHEMA,
claim_lattice_structured_output_extras,
)
e = claim_lattice_structured_output_extras()
assert set(e.keys()) == {"guided_json", "json_schema", "response_format"}
# vLLM convention — the schema directly under `guided_json`.
assert e["guided_json"] is CLAIM_LATTICE_JSON_SCHEMA
# llama.cpp shorthand — the schema directly under `json_schema`.
assert e["json_schema"] is CLAIM_LATTICE_JSON_SCHEMA
# OpenAI-spec — wrapped under response_format with the type tag.
rf = e["response_format"]
assert rf["type"] == "json_schema"
inner = rf["json_schema"]
assert inner["name"] == "claim_lattice"
assert inner["schema"] is CLAIM_LATTICE_JSON_SCHEMA
assert inner["strict"] is True
def test_claim_lattice_structured_output_extras_accepts_alternate_schema():
"""The helper isn't claim-lattice-specific in mechanism — pass a
different schema and an alternate name to reuse the multi-engine
bridge for any structured-output feature."""
from arborist.qa.verify import claim_lattice_structured_output_extras
other = {"type": "object", "properties": {"x": {"type": "string"}},
"required": ["x"], "additionalProperties": False}
e = claim_lattice_structured_output_extras(other, name="my_thing")
assert e["guided_json"] is other
assert e["json_schema"] is other
assert e["response_format"]["json_schema"]["name"] == "my_thing"
assert e["response_format"]["json_schema"]["schema"] is other
def test_query_accepts_extra_body_parameter():
"""query() grew an extra_body keyword so per-model knobs (Qwen's
enable_thinking, etc.) can flow through to the synthesis call.
Schema-enforcement extras are added inside query() and merge with
the user-supplied dict (user keys override defaults on collision —
common case is disjoint namespaces)."""
import inspect
from arborist.qa.query import query
sig = inspect.signature(query)
assert "extra_body" in sig.parameters
p = sig.parameters["extra_body"]
# keyword-only with default None — accepts any caller, optional.
assert p.default is None