Taguru
reference · evidence assembly

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).

SurfaceCall
HTTPPOST /contexts/{name}/evidence
MCPassemble_evidence — one ordinary routed tool, not the retrieve-style client-composed loop
Python SDKContext.assemble_evidence(origins, **options) (sync and async)
TypeScript SDKcontext.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.

FieldDefaultCeilingMeaning
max_items401000Admitted items count.
max_bytes65536 (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_tokens4000none beyond max_bytes/max_itemsAn 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+0000U+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 on object, or opposite-signed same-triple candidates, form one contradiction group — admitted or omitted as one atomic unit, never split, with contradicts naming every disagreeing candidate_id. This grouping is purely structural: an ordinary multi-valued relation (several genuinely true objects 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.reasonMeaning
not_configuredNo TAGURU_RERANK_URL/_MODEL on this server.
model_mismatchThe request's rerank.model does not match the configured provider's own model — the provider is never called.
empty_poolFewer than two survivors — nothing to usefully reorder.
invalid_permutationThe provider's response was not a strict permutation of the pool it was handed.
circuit_openThe provider's own circuit breaker is open from recent failures.
timeoutThe attempt did not finish inside TAGURU_RERANK_TIMEOUT_SECS (default 5) or the call's own deadline.
provider_errorAn 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:

MetricWhat it answers
citations.recall / .locator_validityUnchanged ADR 0004 §8 definitions, computed over whichever mode's admitted evidence actually served the request.
recall.recall_at_k / .mrr / .ndcgComputed 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_rateWhat each case's own budget actually spent and dropped, in both modes.
latency.evidence_msWall-clock round trip of the --assembly lane's own call.
rerank.ranShare 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.