Budgeted evidence assembly — POST /contexts/{name}/evidence
An opt-in step after retrieval and before an external answer model: ranks graph
associations, graph activations, passages, and (opt-in) community summaries into one
deduplicated, citation-complete package that fits an explicit byte/token/item budget.
Taguru still returns evidence, not prose — this prepares the context window an external
LLM reads, and works with no reranker provider configured. The design is fixed by
ADR 0006;
this page documents the shape it produces and how taguru evaluate --assembly
proves it helps at equal budget.
The surface: HTTP, MCP, Python, TypeScript
One opt-in endpoint, reached four ways — none of them change retrieve's or any
direct endpoint's own behavior, and http_contract/mcp_contract
stay 1 (a purely additive endpoint and MCP tool).
| Surface | Call |
|---|---|
| HTTP | POST /contexts/{name}/evidence |
| MCP | assemble_evidence — one ordinary routed tool, not the retrieve-style client-composed loop |
| Python SDK | Context.assemble_evidence(origins, **options) (sync and async) |
| TypeScript SDK | context.assembleEvidence(origins, options), returning a named EvidencePackage |
POST /contexts/sake/evidence
{
"origins": ["青嶺酒造"],
"labels": ["杜氏"],
"text_fallback_query": "青嶺酒造の杜氏は誰か",
"search_limit": 5,
"include_communities": false,
"budget": {"max_items": 40, "max_bytes": 65536, "max_tokens": 4000},
"rerank": {"model": "bge-reranker-v2-m3"}
}
Every field but origins is optional. origins/labels
accept a bare string or an array, the same contract retrieve's own cue list
uses, including its MAX_ORIGIN_CUES ceiling. The composed fan-out is the same
one retrieve already runs — resolve each origin, query only when
labels pins the facets, activate always, search_passages,
an opt-in community-summary search, and cite_passage for every located
citation — but server-side, in one call, then normalized (reciprocal-rank fusion, never
comparing raw BM25/cosine/graph-weight/community scores against each other), deduplicated,
and budget-selected into one package. text_fallback_query is the passage/
community lanes' own search text and, when a reranker is configured, exactly what it sees
too — omitted, it defaults to origins joined with "; " in request
order, so a caller who never names one still gets a consistent query across every lane.
search_limit (default 5) bounds both the passage and community lanes and has
its own ceiling, 200 — deliberately lower than the 1000 ceiling most other list-limit fields
in this API share, since every admitted passage candidate is compared pairwise against every
other one during near-duplicate suppression (an O(n²) step by construction); 200 keeps that
worst case two orders of magnitude smaller while staying well above the default.
The response shape
{
"items": [
{"candidate_id": "association\u0000青嶺酒造\u0000杜氏\u0000高瀬",
"kind": "association", "fused_rank": 1,
"lane_ranks": [{"lane": "graph_activate", "rank": 1}],
"citation_refs": [{"source": "corpus/kura.md", "paragraph": 0}],
"corroboration": {"sources": ["corpus/kura.md"],
"attributions": [{"source": "corpus/kura.md", "paragraph": 0}]},
"bytes": 210, "estimated_tokens": 54,
"association": { "...": "the existing AssociationOut shape, embedded verbatim" }},
{"candidate_id": "passage\u0000sake\u0000corpus/kura.md\u00000",
"kind": "passage", "fused_rank": 2,
"lane_ranks": [{"lane": "passage_bm25", "rank": 1}],
"citation_refs": [], "bytes": 340, "estimated_tokens": 96,
"passage": { "...": "the existing PassageHit shape, embedded verbatim" }}
],
"citations": [{"source": "corpus/kura.md", "paragraph": 0,
"citation": {"text": "...", "source": "corpus/kura.md", "section": null,
"locator": null}}],
"budget": {"items_used": 2, "bytes_used": 612, "tokens_used": 158,
"limits": {"max_items": 40, "max_bytes": 65536, "max_tokens": 4000}},
"omitted": [], "omitted_total": 0, "omitted_by_reason": {},
"plan": {
"lanes": {"resolve": {"ran": true}, "query": {"ran": false, "reason": "no 'labels' given"},
"activate": {"ran": true}, "passages": {"ran": true},
"communities": {"ran": false, "reason": "include_communities was false"},
"citations": {"ran": true}},
"selection": {"dedup_dropped": 0, "contradiction_groups": 0, "diversity_tier_width": 10},
"reranker": {"configured": true, "ran": true, "model": "bge-reranker-v2-m3"}
}
}
items[] embeds the existing wire types
(AssociationOut/PassageHit/CommunityHit) verbatim as
the kind-specific payload — no parallel type is minted, and exactly one of
association/passage/community is present, selected by
kind (an open string, not a closed enum — a future kind is an ordinary additive
value). citation_refs is locators only; the citation text itself lives
exactly once, in the top-level citations[] array — an item's own
bytes/estimated_tokens exclude those two fields themselves (see
below). corroboration is present only for an association
item and names every independent source its fact traces to — never silently collapsed to a
count. contradicts (omitted above when empty) lists the candidate_ids
of every item this one disagrees with.
omitted[] is capped the same way Issue lists are (20 entries) —
bounding response size for a caller that mainly wants to know whether truncation
happened — but omitted_total and omitted_by_reason are
never capped, so the itemized list's own truncation is itself always observable.
plan.lanes reuses the same {ran, reason?, floor?} shape
sources/search's own plan.contexts[].lanes already uses.
plan.lanes.citations reports ran: true whenever the call reached
that step at all — unlike the other lanes, citation resolution has no precondition to skip
on, so it reports ran: true even when zero locators needed resolving (no
association candidates carried any). When the query lane does run, the number
of associations it can return is bounded by the same default match limit
/query itself falls back to with no explicit limit — there is no
separate request field to raise it.
Budget semantics
Three independent hard ceilings — none a priority over another. Whichever is reached
first is binding, and reaching any one of them stops admitting further candidates; an
over-budget candidate is skipped, not a call-ending refusal — even
max_items: 0 answers 200 with an empty package and every candidate
named under omitted/counted in omitted_total/omitted_by_reason.
Zero and near-zero budgets are ordinary, valid input, never an error.
| Field | Default | Ceiling | Meaning |
|---|---|---|---|
max_items | 40 | 1000 | Admitted items count. |
max_bytes | 65536 (64 KiB) | 1048576 (1 MiB) | Compact-JSON byte length of exactly the items array plus the citations array — never the envelope, plan, or an item's own bytes/estimated_tokens fields. |
max_tokens | 4000 | none beyond max_bytes/max_items | An estimate, not a real tokenizer count — this codebase deliberately carries no tokenizer dependency. |
The token estimator is fixed by ADR 0006 and is itself part of the wire contract — changing
it changes what budget.tokens_used means without changing its type, which is
the worst kind of breaking change. For each Unicode scalar in the counted text: 0.25
tokens for a Basic Latin scalar (U+0000–U+007F), 1.0
token for anything else (CJK ideographs, kana, hangul, everything else) — the estimated
total is the ceiling of the sum. This is deliberately biased toward overestimating
for non-Latin scripts, since this project's own corpora and fixtures are Japanese-heavy and
a bytes/4-style heuristic tuned for English would undercount them badly enough to make the
budget meaningless.
budget.bytes_used/.tokens_used carry a small fixed floor even when
zero candidates are admitted — the compact-JSON "[]" for each of the (always
present) items and citations arrays costs 4 bytes together, so an
empty package reports bytes_used: 4 (and a similarly tiny tokens_used)
rather than 0, and a max_bytes/max_tokens set below that floor still
answers 200 with every candidate named under omitted — the "hard
ceiling" guarantee bounds what selection admits, not this unavoidable two-empty-array
floor. Similarly, an item's own estimated_tokens is rounded up independently per
item, while budget.tokens_used rounds up once over the combined total — so
summing every item's own estimated_tokens can read slightly higher than
budget.tokens_used itself; the two are not required to add up exactly.
Deduplication, diversity, corroboration, contradiction
Fixed rules the server documents once — no request-level tuning knob, the same posture reciprocal-rank fusion's own constant takes.
- Exact-key dedup. Association candidates dedup on
(subject, label, object)after alias resolution; passage/community candidates dedup on(context, source, paragraph). - Near-duplicate suppression. A fixed character-bigram Dice-coefficient threshold over passage text, staged after ranking so "keep the higher-ranked candidate" is well-defined; no raw coefficient ever reaches the wire.
- Corroboration. A fact several sources assert keeps every source named in
corroboration.sources— never collapsed to a count, and never counted as independent corroboration more than once per distinct source. - Contradiction. Candidates sharing
(subject, label)but disagreeing onobject, or opposite-signed same-triple candidates, form one contradiction group — admitted or omitted as one atomic unit, never split, withcontradictsnaming every disagreeingcandidate_id. This grouping is purely structural: an ordinary multi-valued relation (several genuinely trueobjects under one(subject, label), e.g. several rice varieties under one brewery's "uses rice" fact) groups the same way a real contradiction does, since both share the same wire shape. A relation with many values is therefore admitted or omitted together, and a small budget can drop the whole group at once rather than partially. - Diversity. Tier-based round-robin admission (
plan.selection.diversity_tier_width) — within a tier, a candidate whose primary source hasn't appeared yet is preferred over a repeat source. This reorders admission inside a tier only; it never changes relevance rank.
The optional reranker
Absent rerank, or no TAGURU_RERANK_URL/TAGURU_RERANK_MODEL
configured on the server (TAGURU_RERANK_API_KEY optionally rides the calls as a
bearer token for hosted providers), selection is fully deterministic (the reciprocal-rank-fusion order)
at no network or credential cost — plan.reranker = {"configured": false, "ran": false}.
A reranker may only reorder the pool it is handed, after dedup/contradiction-grouping/
near-duplicate-suppression — it can never add, drop, or edit a candidate, and every degrade
falls back to that same deterministic order, still answered 200, never a
call-ending refusal.
plan.reranker.reason | Meaning |
|---|---|
not_configured | No TAGURU_RERANK_URL/_MODEL on this server. |
model_mismatch | The request's rerank.model does not match the configured provider's own model — the provider is never called. |
empty_pool | Fewer than two survivors — nothing to usefully reorder. |
invalid_permutation | The provider's response was not a strict permutation of the pool it was handed. |
circuit_open | The provider's own circuit breaker is open from recent failures. |
timeout | The attempt did not finish inside TAGURU_RERANK_TIMEOUT_SECS (default 5) or the call's own deadline. |
provider_error | An unreachable host, a non-2xx response, or an unparsable body. |
Candidate text reaches a configured reranker provider and nowhere else — never a log line,
an error message, or a metric label; only the model identity string reaches the response and
the Prometheus taguru_rerank_* families.
Proving it helps: taguru evaluate --assembly
#216's own acceptance criteria reject a subjective demo — evidence assembly has to beat
fixed-limit retrieval on labeled quality at the same context-window budget, not just
look plausible on one example. taguru evaluate (see the
retrieval quality gate page for the harness itself) grows three
flags for exactly this (ADR 0006 §14):
--assembly swap the passage lane for POST /contexts/{name}/evidence;
the structural lane (resolve -> query) never changes, so
coverage/lane-cross stay comparable across a run pair
--max-items N
--max-bytes N the same three ceilings apply to BOTH modes: --assembly
--max-tokens N sends them as the request's own budget; the baseline
passage lane truncates client-side with the identical
accounting crate::api::evidence::budget uses server-side
--rerank MODEL opts an --assembly run into a configured reranker;
usage error without --assembly
# 1. baseline: today's fixed-limit sources/search, no assembly step
taguru evaluate --eval eval.jsonl --context sake \
--max-items 40 --max-bytes 65536 --max-tokens 4000 --out baseline.json
# 2. deterministic assembly: no reranker configured
taguru evaluate --eval eval.jsonl --context sake --assembly \
--max-items 40 --max-bytes 65536 --max-tokens 4000 \
--thresholds thresholds.json --out assembly.json
# 3. configured reranker, where available (opt-in, never required for the gate)
taguru evaluate --eval eval.jsonl --context sake --assembly \
--max-items 40 --max-bytes 65536 --max-tokens 4000 --rerank bge-reranker-v2-m3 \
--out assembly-reranked.json
taguru evaluate compare baseline.json assembly.json
Without any --max-* flag, baseline mode is byte-for-byte the same
as before this feature existed — no truncation, no budget block in the
artifact. --assembly mode always sends some budget to the endpoint (it
has no unbudgeted mode) — the server's own defaults when no flag was given, the caller's own
ceilings otherwise — so inputs.budget is always populated in that mode.
Metrics, computed per the existing catalog plus one this ADR adds:
| Metric | What it answers |
|---|---|
citations.recall / .locator_validity | Unchanged ADR 0004 §8 definitions, computed over whichever mode's admitted evidence actually served the request. |
recall.recall_at_k / .mrr / .ndcg | Computed over the admitted package in fused_rank order, exactly like the passage lane's own hits. |
diversity.sources (new) | Distinct source locators among a case's admitted evidence — the one metric #216 names explicitly ("source diversity at equal evidence budget"). |
budget.items_used / .bytes_used / .tokens_used / .omitted_rate | What each case's own budget actually spent and dropped, in both modes. |
latency.evidence_ms | Wall-clock round trip of the --assembly lane's own call. |
rerank.ran | Share of --rerank cases whose configured reranker actually reordered the pool; the complement is the degrade rate. |
Regression thresholds for these live in ADR 0004 §9.3's existing thresholds-file
format — no new format. evaluate compare additionally warns (never refuses,
matching every other mismatch it already checks) when the two runs' inputs.budget
differ, including one side having no budget flag at all — comparing citation recall or
source diversity across different budgets is exactly the dishonest comparison this feature
exists to catch.
The default repository gate stays offline, deterministic, and provider-free — configuration 3 above (a configured reranker) is opt-in exactly the way ADR 0004's own embedding-provider suites already are, never required for the gate to pass.
What this is not
Assembly composes the same lanes retrieve already fans out to — it does not
replace sources/search, communities/search, or retrieve
itself, all of which are unchanged and keep working exactly as before. Requesting
include_communities: true with no derived-communities artifact is a
degrade here (plan.lanes.communities.ran: false), never the refusal
communities/search itself gives, since community evidence is one opt-in input
among several rather than the entire point of the call.
Most importantly: Taguru still does not generate the final answer. Every field in
the response — items, citations, budget,
omitted, plan — is evidence prepared for an external answer
model to read, never a verdict, a summary, or generated prose. The intended client is an
LLM; everything that needs language understanding is the client's job. No answer-generation
LLM appears anywhere on this endpoint's own path, and none is required for either
configuration 1 or 2 of the equal-budget comparison above.
Taguru