qa: capacity metrics — prompt-char accounting for strict-rate vs input-size analysis

Per-call accounting of prompt capacity so we can answer 'does
context size affect strict-rate?' at aggregate scale.

Per-call result dict gains:

  "prompt_chars": {
    "system_prompt":      <int>,  # system message chars
    "grounding_reminder": <int>,  # restated rule message (if any)
    "user_question":      <int>,  # the original question text
    "evidence_or_context":<int>,  # rendered evidence map / wikitext context
    "messages_total":     <int>,  # sum of all messages[].content
  }
  "answer_chars": <int>            # rendered answer length

Char-level for now: model-agnostic, fast, and a workable proxy
for prompt tokens (~4 chars/token English prose, ~2.5 for JSON-
evidence-block heavy contexts). Token-exact accounting can layer
on later if a tokenizer is cheap enough to wire in per-call.

Surfaces in three places:

(1) result["prompt_chars"] — for programmatic callers. Cache hits
    return prompt_chars too (computed before lookup).
(2) `aborist query` human render — one-liner showing the breakdown
    and the answer length, so an operator can tell at a glance
    whether STRICT came from a 5KB tight prompt or a 60KB stuffed
    context.
(3) bench/qa_sweep.py — per-row prompt_chars_{total,evidence,system,
    question} + answer_chars columns in the JSONL, plus a new
    "strict-rate by prompt size" section in the markdown summary
    bucketing runs by capacity (<8KB, 8-16KB, 16-32KB, 32-64KB,
    >=64KB) so the bench reveals whether input size correlates
    with verdict quality across modes.

Never enters cache_key — runtime measurements, not policy.
This commit is contained in:
russell@unturf.com 2026-05-01 10:34:25 -04:00
parent 2d6a86b991
commit f927298353
No known key found for this signature in database
3 changed files with 83 additions and 0 deletions

View file

@ -581,6 +581,19 @@ def _render_query_human(result: dict, question: str) -> str:
)
lines.append("")
pc = result.get("prompt_chars") or {}
if pc:
# Compact one-liner — operator at a glance: did STRICT come
# from a tight prompt or a context-stuffed one?
lines.append(
f"capacity: prompt {pc.get('messages_total', 0):,} chars "
f"(sys {pc.get('system_prompt', 0):,} + "
f"reminder {pc.get('grounding_reminder', 0):,} + "
f"evidence {pc.get('evidence_or_context', 0):,} + "
f"question {pc.get('user_question', 0):,}) → "
f"answer {result.get('answer_chars', 0):,} chars"
)
cache_key = (result.get("cache_key") or "")[:8]
lines.append(f"cache_key: {cache_key}… <run with --json for full record>")
return "\n".join(lines)

View file

@ -1435,6 +1435,26 @@ def query(
messages.append({"role": "user", "content": grounding_reminder})
messages.append({"role": "user", "content": _user_payload(question)})
# Capacity metrics. Char-level for now — a fast model-agnostic proxy
# for prompt size (rule of thumb: ~4 chars/token for English prose,
# ~2.5 for JSON-evidence-block heavy contexts). Surfaced in the
# result dict so the bench can correlate strict-rate with input
# size and the operator can tell at a glance whether a STRICT
# verdict came from a tight 5KB prompt or a 50KB context-stuffed
# one. Never enters cache_key — these are runtime measurements,
# not policy.
if answer_mode in ("claim_lattice_pointer", "claim_lattice"):
evidence_or_context_chars = len(rendered_evidence)
else:
evidence_or_context_chars = len(context)
prompt_chars = {
"system_prompt": len(sys_prompt or ""),
"grounding_reminder": len(grounding_reminder or ""),
"user_question": len(question or ""),
"evidence_or_context": evidence_or_context_chars,
"messages_total": sum(len(m["content"]) for m in messages),
}
context_root = _context_root([h.document_root for h in chosen])
mhash = model_profile_hash(model_id, revision, quantization)
@ -1580,6 +1600,8 @@ def query(
# writes populate it correctly. Acceptable degradation
# since governance_policy_hash invalidated prior records.
"partially_verified_quotes": [],
"prompt_chars": prompt_chars,
"answer_chars": len(cached["answer_text"] or ""),
"timings": {
"search_ms": search_ms,
"context_ms": context_ms,
@ -1943,6 +1965,8 @@ def query(
# never threaded into run_dag_root.
"pointer_id_distribution": verdict.get("pointer_id_distribution"),
"lazy_anchor_ratio": verdict.get("lazy_anchor_ratio"),
"prompt_chars": prompt_chars,
"answer_chars": len(answer_text or ""),
"timings": {
"search_ms": search_ms,
"context_ms": context_ms,

View file

@ -128,6 +128,15 @@ def _run_one(
"deflection_kind": deflection["kind"],
"subject_anchor": deflection["subject_anchor"],
"subject_in_answer": deflection["subject_in_answer"],
# Capacity metrics — char-level proxy for prompt-token budget.
# Surfaces "did STRICT come from a tight 5KB prompt or a 50KB
# context-stuffed one?" at aggregate scale. Lets the bench
# bucket strict-rate by input-size band.
"prompt_chars_total": (result.get("prompt_chars") or {}).get("messages_total", 0),
"prompt_chars_evidence": (result.get("prompt_chars") or {}).get("evidence_or_context", 0),
"prompt_chars_system": (result.get("prompt_chars") or {}).get("system_prompt", 0),
"prompt_chars_question": (result.get("prompt_chars") or {}).get("user_question", 0),
"answer_chars": result.get("answer_chars", 0),
"error": err,
}
@ -227,6 +236,43 @@ def _render_markdown(
f"{deflections}/{b['n']} |"
)
lines.append("")
lines.append("## strict-rate by prompt size")
lines.append("")
lines.append(
"Buckets capacity in `messages_total` chars (system + reminder + "
"evidence/context + question). ~4 chars/token English prose; "
"~2.5 chars/token JSON-evidence-heavy. Tells you whether the "
"model strict-rate degrades with input size."
)
lines.append("")
buckets = [
("<8KB", 0, 8000),
("8-16KB", 8000, 16000),
("16-32KB", 16000, 32000),
("32-64KB", 32000, 64000),
(">=64KB", 64000, 10**9),
]
lines.append("| mode | bucket | runs | STRICT | strict-rate | mean evidence chars | mean answer chars |")
lines.append("|------|--------|------|--------|-------------|---------------------|-------------------|")
for mode in modes:
for label, lo, hi in buckets:
sub = [
r for r in rows
if r["answer_mode"] == mode
and lo <= (r.get("prompt_chars_total") or 0) < hi
and not r["error"] and r["status"] != "error"
]
if not sub:
continue
n_b = len(sub)
n_strict = sum(1 for r in sub if r["audit_mode"] == "STRICT")
mean_ev = sum((r.get("prompt_chars_evidence") or 0) for r in sub) / n_b
mean_ans = sum((r.get("answer_chars") or 0) for r in sub) / n_b
lines.append(
f"| {mode} | {label} | {n_b} | {n_strict} | "
f"{n_strict / n_b:.2f} | {int(mean_ev)} | {int(mean_ans)} |"
)
lines.append("")
lines.append("## per question")
lines.append("")
lines.append(