arborist/bench/qa_sweep.py
russell@unturf.com ec55db513c
#000068 Phase 2+3: bench + opt-in demote flag for missed-answer guard
Phase 2 — bench instrumentation + measurement run

bench/qa_sweep.py picks up the answerability sidecar projection per row
(answerability_fired, answerability_confidence, answerability_denial_
pattern, answerability_answer_type, answerability_candidate_count) and
aggregates per-mode (answerability_fires + S/M/W confidence breakdown)
into a new column in the markdown summary table.

Measurement run on bench/qa_results/phase2-sidecar-on/2026-05-27T14-
16-22Z (76 questions × n=3 × claim_lattice × Hermes-3-8B × tail layout,
228 runs). Headline:

  sidecar fires        2/228 (0.88%)
  confidence dist      2 strong / 0 medium / 0 weak
  precision            100% (2/2 fires were the Ballestrini fixture)
  recall on Ballestrini 2/3 across n=3 (third run model extracted
                                       correctly -> sidecar silent,
                                       correct behavior)
  false positives      0/226 non-Ballestrini runs
  verifier verdict     both fires labeled STRICT by the binary
                       verifier (the verifier-blind class, exactly
                       as predicted)

Detection rule's three-clause conjunction (denial + extraction-shape +
candidate proximity near cleaned subject tokens) is operating at the
precision floor. The strong-confidence-only firing pattern is what
calibrates Phase 3's demote threshold.

Phase 3 — opt-in demote flag (default OFF per Dav1d Phase 4 NO-GO)

arborist/qa/keys.py: answerability_demote_enabled added to
_VERIFIER_POLICY_FIELDS so flipping the flag partitions cache via
verifier_policy_hash. Justification: when on, the rendered audit_mode
changes (EVIDENCE-WARRANTED -> EVIDENCE-MISSED-PARTIAL), which IS a
verifier-output property; verifier hash must move accordingly. The
other answerability_* fields stay governance-only (sidecar
diagnostic, no audit_mode mutation).

arborist/cli.py:_render_audit_label extended with answerability +
demote_enabled kwargs. Logic:

  demote_triggers = (
      demote_enabled
      and answerability["answerability_warning"] is True
      and answerability["confidence_class"] in ("strong", "medium")
  )

  lattice modes:
    EVIDENCE-WARRANTED -> EVIDENCE-MISSED-PARTIAL    (rung transition)
    POINTER-LINKED / ANCHOR-WARRANTED -> "rung · missed-answer"
                                          (tail tag; rung itself already
                                          signals degradation)

  non-lattice modes (quote/span/entity/paraphrase):
    audit_mode token unchanged + "· missed-answer" tail tag

  weak confidence: NEVER demotes (Phase 2 saw zero weak fires on real
  failures; reserved for future expanded detection ladder)

CLI flag --demote-on-missed-answer on both `arborist query` and
`arborist ask`, default OFF. Flows into call_policy[
"answerability_demote_enabled"] and through to result[
"answerability_demote_enabled"] so the renderer reads it without
needing the policy dict.

End-to-end verified live: 4 fresh Hermes-3-8B runs with --demote-on-
missed-answer on `songs by veronica ballestrini`, all 4 rendered
EVIDENCE-MISSED-PARTIAL · via claim_lattice (Hermes hit the failure
mode in all 4, sidecar fired strong, demote logic transformed the
label).

Phase 4 (default flip to demote-on) — NO-GO per Dav1d 2026-05-27 §3.4:
"a false sidecar warning is tolerable; a false audit-label demotion
can damage trust in correct abstentions." Phase 2 precision is 100%
but n=2 fires is too few samples to claim precision floor empirically.
Default flip blocks on wider bench + human spot-check of the warnings.

Tests: 47 total (36 Phase 1 + 11 new Phase 3 covering hash partitioning
discipline + render-label projection across all four rung/confidence
matrices). Full suite 2794 passed (delta +22 from prior 2772).

Bench output (bench/qa_results/phase2-sidecar-on/) intentionally not
committed — bench/qa_results/ is gitignored per existing convention;
the ticket carries the headline numbers + path for re-inspection.
2026-05-27 10:40:35 -04:00

1028 lines
46 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""QA-quality benchmark sweep.
Runs a fixed question set through ``arborist.qa.query.query`` under each
answer mode and tabulates STRICT / HYBRID / UNGROUNDED counts, the
``n_verified / n_quotes`` ratio, latency, and lazy-anchor signals.
Why this exists: G0 / claim-lattice-pointer mode landed in 2337b77, and
four hardenings stacked on top (smell sidecar, positive-form prompts,
source-role boost, title-purity rerank, CITATION_MISMATCH check). Every
new heuristic flies blind until we can show "this commit moved STRICT
from N to M". This is that scaffolding.
Outputs:
bench/qa_results/<utc-iso>.jsonl one row per (mode, question)
bench/qa_results/<utc-iso>.md markdown summary table
The JSONL is the durable artifact. The markdown is the thing fox reads.
Wire via ``make bench-qa``. Honors ``BURN=1`` to force fresh inference
under the current ``governance_policy_hash`` — useful after a prompt
change that doesn't bump any version field.
"""
from __future__ import annotations
import argparse
import datetime as _dt
import json
import os
import random
import re
import sys
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from threading import Lock
# Defer arborist imports until argparse runs so `--help` works without
# the package installed.
ANSWER_MODES = ("quote", "claim_lattice_pointer", "claim_lattice")
# Pointer-tag regex for bench-side bracket counting on raw model
# output. Mirrors the verifier's FORMAT_COLLAPSED detector
# (verify.py:1650). Module-level so the sweep loop doesn't recompile
# it per row.
_BRACKET_RE = re.compile(r"\[E\d+")
# Whole bracket-tag region (one or more `[E\d+]`-shaped tokens
# possibly separated by `,`/whitespace, optionally followed by `]`).
# Used to estimate `answer_chars_with_brackets` — the share of the
# raw answer that is bracket-tagged citation rather than prose.
_BRACKET_REGION_RE = re.compile(r"\[E\d+(?:\s*,\s*E\d+)*\s*\]")
# Distinct pointer-id extractor — counts unique `[E\d+]` ids in raw
# output regardless of whether they appear bare or in a comma list.
_POINTER_ID_RE = re.compile(r"\bE\d+\b")
def _read_questions(path: Path) -> list[str]:
out: list[str] = []
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
out.append(line)
return out
def _utc_iso_compact() -> str:
# YYYY-MM-DDTHH-MM-SSZ — colon-free so it's filesystem-safe.
return _dt.datetime.now(tz=_dt.timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")
def _ratio(verdict: dict) -> float:
nq = verdict.get("n_quotes") or 0
nv = verdict.get("n_verified") or 0
return (nv / nq) if nq else 0.0
def _bracket_diagnostics(raw_answer: str) -> tuple[int, int, int, int]:
"""Compute bracket-tag diagnostics from a model's raw output.
Returns a 4-tuple:
``(bracket_count, distinct_pointer_count, chars_with_brackets,
raw_meaningful_line_count)``.
- ``bracket_count`` count of `[E\\d+` occurrences (mirrors
the verifier's FORMAT_COLLAPSED gate).
- ``distinct_pointer`` unique `E\\d+` ids — answers with
`[E1, E2]` show 2 ids vs 2 brackets,
same denominator either way.
- ``chars_with_brackets`` total characters inside `[E\\d+,...]`
regions; pairs with answer_chars to
compute "% of answer that is citation"
at aggregate scale.
- ``raw_meaningful_line_count``
lines >20 chars in the raw output; the
FORMAT_COLLAPSED denominator. Mirrors
the verifier's threshold so bench-side
and verifier-side numbers line up.
All four return 0 on empty input. Module-level regexes do the
heavy lifting; this function exists to bundle the calls in one
place for the bench row builder.
"""
if not raw_answer:
return 0, 0, 0, 0
bracket_count = len(_BRACKET_RE.findall(raw_answer))
distinct = len(set(_POINTER_ID_RE.findall(raw_answer)))
chars = sum(len(m) for m in _BRACKET_REGION_RE.findall(raw_answer))
n_meaningful = sum(
1 for line in raw_answer.splitlines() if len(line.strip()) > 20
)
return bracket_count, distinct, chars, n_meaningful
def _run_one(
*,
question: str,
answer_mode: str,
shards_dir: Path | None,
qa_db: Path | None,
top_k: int,
burn: bool,
endpoint: str,
model: str,
policy_overrides: dict | None = None,
) -> dict:
"""Run one (question, mode) — returns a flat record for the JSONL.
``policy_overrides`` is a dict of policy-field overrides applied
after ``DEFAULT_QUERY_POLICY`` and ``answer_mode`` so that
``bench/qa_sweep.py --policy quantifier_reminder_enabled=true``
can flip individual fields for an A/B cycle without touching
the policy defaults.
"""
from arborist.qa.client import OpenAICompatibleClient
from arborist.qa.query import DEFAULT_QUERY_POLICY, query
policy = dict(DEFAULT_QUERY_POLICY)
policy["answer_mode"] = answer_mode
if policy_overrides:
policy.update(policy_overrides)
api_key = os.environ.get("ARBORIST_LLM_API_KEY")
client = OpenAICompatibleClient(base_url=endpoint, api_key=api_key)
if qa_db is None:
qa_db = (shards_dir / "qa.db") if shards_dir else (Path.home() / ".arborist" / "qa.db")
t0 = time.monotonic()
err: str | None = None
result: dict = {}
try:
result = query(
question=question,
qa_db=qa_db,
chat_client=client,
model_id=model,
shards_dir=shards_dir,
top_k=top_k,
policy=policy,
burn_existing=burn,
)
except Exception as e: # noqa: BLE001 — bench captures all
err = f"{type(e).__name__}: {e}"
elapsed_s = round(time.monotonic() - t0, 2)
# Deflection sidecar — sidecar-only signal, never feeds the
# verifier. Surfaces the Mars-BDFL pattern (STRICT but answer
# never mentions the question's subject) at bench-aggregate
# scale so a creeping "model deflects rather than refuses"
# regression is legible across runs.
from arborist.qa.inspect import diagnose_deflection, diagnose_metaphor_deflection
deflection = diagnose_deflection(question, result.get("answer_text") or "")
metaphor = diagnose_metaphor_deflection(
question, result.get("answer_text") or ""
)
audit_mode = result.get("audit_mode")
# Bracket diagnostics from the model's RAW output (before the
# renderer interpolates literal spans). Lattice modes only;
# non-lattice rows record zeros. Pairs with format_collapsed for
# ticket #000008 bench-side diagnostics: a low ratio + zero
# brackets + many lines tells us format collapse, not graceful
# per-claim refusal.
raw_answer = result.get("raw_answer") or ""
(
answer_brackets,
answer_pointer_count,
answer_chars_with_brackets,
raw_meaningful_line_count,
) = _bracket_diagnostics(raw_answer)
# Violation kind summary — full violation dicts are kept on the
# result for the renderer but bench rows only need the kinds for
# aggregate counting. Keeps row size bounded.
violation_kinds = sorted({
v.get("kind") for v in (result.get("violations") or [])
if v.get("kind")
})
# Quantifier preflight (Phase 0.x slot — populated by the Phase 1
# classifier once it lands; keep the keys present so the bench
# JSONL schema is stable across the rollout). model_profile_id
# captures the configured model id verbatim — Phase 2 cap lookup
# uses model_profile_hash derived from this string.
quantifier_intensity = result.get("quantifier_intensity")
quantifier_matched_token = result.get("quantifier_matched_token")
scope_bound_hint = result.get("scope_bound_hint")
claim_cap_applied = result.get("claim_cap_applied")
# Ticket #000068 Phase 1 — missed-answer guard sidecar projection.
# `result["answerability"]` is None when the guard didn't fire,
# else a structured dict. Persist a small projection on the bench
# row so the aggregator can count fires + confidence breakdown
# without re-running the sidecar. Full dict (with candidate
# spans + offsets) stays on the result for human inspection.
answerability = result.get("answerability") or {}
answerability_fired = bool(answerability.get("answerability_warning"))
answerability_confidence = answerability.get("confidence_class")
answerability_denial_pattern = answerability.get("denial_pattern_matched")
answerability_answer_type = answerability.get("answer_type")
answerability_candidate_count = answerability.get("candidate_count")
# Ticket #000010 — meta-cognition QuestionState. Persist a
# bounded-size projection: logical_statuses, question_shape,
# preflight_result, temporal_sensitivity, and the kind of any
# detector hits. Full QuestionState (including
# contradiction_pairs / false_premise_hints) stays on the
# result dict for CLI render but is NOT persisted in bench
# rows to keep row size bounded across long sweeps.
qs = result.get("question_state") or {}
preflight_logical_statuses = list(qs.get("logical_statuses") or [])
preflight_question_shape = qs.get("question_shape")
preflight_result = qs.get("preflight_result")
preflight_temporal_sensitivity = qs.get("temporal_sensitivity")
preflight_has_false_premise = bool(qs.get("false_premise_hints"))
preflight_has_contradiction = bool(qs.get("contradiction_pairs"))
preflight_corpus_requirement = qs.get("corpus_requirement")
# Ticket #000009 §7.2 — preflight stage hash 12-char prefix.
# Same truncation as cache_key. Two cache rows that differ
# only in their preflight policy state now have different
# 12-char prefixes — operator can grep / SQL-filter the bench
# JSONL for cross-row policy comparison.
preflight_hash = (result.get("preflight_hash") or "")[:12]
row = {
"question": question,
"answer_mode": answer_mode,
"status": result.get("status") or ("error" if err else "missing"),
"audit_mode": audit_mode,
"n_quotes": result.get("n_quotes"),
"n_verified": result.get("n_verified"),
"ratio": round(_ratio(result), 3),
"verifier_method": result.get("verifier_method"),
"lookup_path": result.get("lookup_path"),
"failure_stage": result.get("failure_stage"),
"lazy_anchor_ratio": result.get("lazy_anchor_ratio"),
"pointer_id_distribution": result.get("pointer_id_distribution"),
# Format-collapse signal (pointer-mode only — None elsewhere).
# See verify.py and ticket #000008.
"format_collapsed": result.get("format_collapsed"),
# Sorted unique kinds list for aggregate counting; full
# violation payloads stay off the bench row to keep size
# bounded (5+ violations × dict ~= bloat across 10K-row sweeps).
"violation_kinds": violation_kinds,
# Bracket count in the model's raw output. Lattice-mode-only
# diagnostic; quote/span/entity/paraphrase rows always 0.
"answer_brackets": answer_brackets,
# Distinct pointer ids in raw output (`[E1, E2]` → 2 ids).
# Different from answer_brackets when the model bundles
# multiple ids inside one bracket region.
"answer_pointer_count": answer_pointer_count,
# Chars inside `[E\d+,...]` regions — divides into answer_chars
# to give "fraction of answer that is bracket-tag, not prose"
# at aggregate scale.
"answer_chars_with_brackets": answer_chars_with_brackets,
# Meaningful raw-output line count (>20 chars). The
# FORMAT_COLLAPSED denominator; surfaces how dense the raw
# output was so we can chart claim-shape vs prose-shape over
# bench cycles.
"raw_meaningful_line_count": raw_meaningful_line_count,
# Ticket #000008 quantifier preflight — Phase 0.x slot.
# Populated by the Phase 1 classifier; remain None until
# Phase 1 wires it. Keeping the keys present here makes the
# JSONL schema stable across the rollout so post-Phase-1
# bench markdown can be re-rendered against pre-Phase-1
# rows without column-misalignment.
"quantifier_intensity": quantifier_intensity,
"quantifier_matched_token": quantifier_matched_token,
"scope_bound_hint": scope_bound_hint,
# Cap that was actually applied in this run. Phase 2 wires
# this; until then it's whatever the policy default is
# (typically 12 — the claim_lattice_max_claims_per_answer
# default). None means "not applicable" (non-lattice modes).
"claim_cap_applied": claim_cap_applied,
# Ticket #000068 Phase 1 — missed-answer guard sidecar bench
# projection. Five small fields surface aggregate fire-rate +
# confidence breakdown without bloating the row with candidate
# spans / offsets (full dict on the result, recomputable from
# question + answer + evidence + policy).
"answerability_fired": answerability_fired,
"answerability_confidence": answerability_confidence,
"answerability_denial_pattern": answerability_denial_pattern,
"answerability_answer_type": answerability_answer_type,
"answerability_candidate_count": answerability_candidate_count,
# Configured model id; serves as the model_profile_id key
# for Phase 2 per-model cap lookup. Stored verbatim so a
# bench archive remains interpretable when model_profiles.py
# changes.
"model_profile_id": model,
# Ticket #000010 — meta-cognition preflight bench fields.
# Bounded-size projection of QuestionState. Full state
# (contradiction_pairs / false_premise_hints) lives on the
# result dict for CLI render; bench rows keep just the
# kinds + shape + result so 10K-row sweeps stay
# human-greppable.
"preflight_logical_statuses": preflight_logical_statuses,
"preflight_question_shape": preflight_question_shape,
"preflight_result": preflight_result,
"preflight_temporal_sensitivity": preflight_temporal_sensitivity,
"preflight_has_false_premise": preflight_has_false_premise,
"preflight_has_contradiction": preflight_has_contradiction,
"preflight_corpus_requirement": preflight_corpus_requirement,
# 12-char preflight stage hash prefix (#000009 §7.2).
# Matches cache_key truncation pattern.
"preflight_hash": preflight_hash,
"cache_key": (result.get("cache_key") or "")[:12],
"n_sources": len(result.get("sources") or []),
"elapsed_s": elapsed_s,
"deflection_kind": deflection["kind"],
"subject_anchor": deflection["subject_anchor"],
"subject_in_answer": deflection["subject_in_answer"],
"metaphor_deflection_kind": metaphor["kind"],
"metaphor_cue_count": metaphor["cue_count"],
"metaphor_overlap_count": metaphor["answer_overlap_count"],
# 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),
# Seven-point program directive compliance — see
# docs/seven-point-program.md. Per-row booleans where the
# directive is observable from the result; aggregate
# coverage shows up in the markdown summary's
# "directive coverage" section. Directives that are global
# properties of the substrate (D1, D5, D8) don't appear
# per-row.
"directive_compliance": _directive_compliance(answer_mode, result, err),
"error": err,
}
# #000049 Phase 2 — when ARBORIST_NLI_SHADOW is set, query() surfaces
# the verifier-input text; carry it (+ the answer) into the row so a
# downstream `nli_shadow_sweep.py --input <this.jsonl>` can measure
# the would-demote rate on bench-qa traffic (§7 #12 gate item 4). Off
# by default — these two fields can be large; never persisted
# otherwise. Scrub lone surrogates first: real Wikipedia context
# occasionally carries U+D800U+DFFF code points (mangled source
# encoding) that `json.dumps(..., ensure_ascii=False)` then refuses
# to UTF-8-encode — a measurement field, U+FFFD is fine.
vit = result.get("verifier_input_text")
if vit is not None:
row["answer_text"] = _scrub_surrogates(result.get("answer_text"))
row["context"] = _scrub_surrogates(vit)
return row
def _scrub_surrogates(s):
if not isinstance(s, str):
return s
return s.encode("utf-8", "replace").decode("utf-8")
def _directive_compliance(
answer_mode: str, result: dict, err: str | None
) -> dict:
"""Compute per-row pass/fail for the seven-point directives whose
pinning is observable from a single bench row.
Returns a dict mapping directive id -> bool (True = pass, False =
fail/pending). Some directives are system-global (D1 verifier
never calls LLM, D5 verifier_method enum, D8 discipline) and don't
appear per-row — track those once in the summary header.
"""
if err or result.get("status") == "error":
return {}
audit = result.get("audit_mode")
method = result.get("verifier_method") or ""
is_lattice = answer_mode in ("claim_lattice_pointer", "claim_lattice")
return {
# D2: lattice modes emit pointer clauses (claim_lattice_pointer
# or claim_lattice JSON variant). Quote-mode rows count as
# n/a for D2 — they predate the directive.
"D2_pointer_clauses": is_lattice,
# D3: build CTI internally — proxy is "phrase route had a
# chance to fire" (lattice mode + run_dag_root populated).
# Module L (the answer-side multi-frame compilation) lands
# via ticket #000002; until then this is a structural
# readiness check, not full coverage.
"D3_cti_substrate_ready": (
is_lattice and bool(result.get("run_dag_root"))
),
# D4: evidence_map_root + run_dag_root present in the
# 9-stage CTI run-DAG. Retrieval-plan-hash binding pending
# via ticket #000001.
"D4_evidence_map_bound": bool(result.get("run_dag_root")),
# D6: warrant ran (relation/date anchor classes — today).
# Generalization to entity-list / count / why-cause shapes
# pending via ticket #000003. Mark True only when the
# verifier verdict shows the warrant was checked (today,
# any lattice-mode row gets the warrant pass; future
# per-shape gating sharpens this).
"D6_warrant_fired": is_lattice,
# D7: schema audit_mode is in the canonical enum. Renderer
# then maps lattice-mode STRICT/HYBRID → EVIDENCE-LINKED at
# display time (pinned by tests/test_cli_render.py). Per-row
# signal here just confirms the row's audit token is valid —
# the renderer side is a deterministic transformation tested
# separately, not something each bench row can re-verify.
"D7_honest_label": audit in ("STRICT", "HYBRID", "UNGROUNDED"),
}
def _summarize(rows: list[dict]) -> dict:
"""Group rows by mode → STRICT/HYBRID/UNGROUNDED totals + means."""
by_mode: dict[str, dict] = {}
for r in rows:
m = r["answer_mode"]
b = by_mode.setdefault(m, {
"n": 0,
"STRICT": 0,
"HYBRID": 0,
"UNGROUNDED": 0,
"errors": 0,
"ratio_sum": 0.0,
"latency_sum": 0.0,
"deflections": 0,
# Format-collapse rate (pointer-mode only — None elsewhere
# so non-lattice rows count as 0). Surfaced in markdown
# alongside deflection rate as a per-mode collapse signal.
"format_collapses": 0,
# Ticket #000068 Phase 1 — missed-answer guard aggregate
# counters. answerability_fires counts every row where the
# sidecar fired; the per-confidence breakdown surfaces how
# many of those were strong/medium/weak. Pairs with
# violation_kind_counts and format_collapses as the third
# sidecar-class signal at aggregate scale.
"answerability_fires": 0,
"answerability_strong": 0,
"answerability_medium": 0,
"answerability_weak": 0,
# Per-violation-kind counts. Open-ended dict — fills as
# kinds are encountered. Empty when no violations fire.
"violation_kind_counts": defaultdict(int),
# Bracket-count distribution for lattice rows. Surfaces
# whether the model is following the pointer protocol at
# all; FORMAT_COLLAPSED is the bracket=0 corner.
"answer_brackets_sum": 0,
"answer_brackets_n": 0,
# Per-directive pass counts (seven-point program). Init
# all known directive ids so absent rows report 0/N
# rather than missing-key.
"directive_pass": {
"D2_pointer_clauses": 0,
"D3_cti_substrate_ready": 0,
"D4_evidence_map_bound": 0,
"D6_warrant_fired": 0,
"D7_honest_label": 0,
},
})
b["n"] += 1
if r["error"] or r["status"] == "error":
b["errors"] += 1
elif r["audit_mode"] in ("STRICT", "HYBRID", "UNGROUNDED"):
b[r["audit_mode"]] += 1
b["ratio_sum"] += r["ratio"] or 0.0
b["latency_sum"] += r["elapsed_s"] or 0.0
# Deflection rate: STRICT/HYBRID with subject-anchor missing
# from answer. UNGROUNDED + deflection isn't interesting (no
# answer to deflect with), and no_question_tokens is vacuous.
if r.get("deflection_kind") == "deflection" and r["audit_mode"] in (
"STRICT", "HYBRID"
):
b["deflections"] += 1
# Format-collapse: pointer-mode rows report bool, others
# report None. Treat None as not-collapsed (the check didn't
# apply); only count explicit True.
if r.get("format_collapsed") is True:
b["format_collapses"] += 1
# Missed-answer guard fires + per-confidence breakdown.
if r.get("answerability_fired"):
b["answerability_fires"] += 1
conf = r.get("answerability_confidence")
if conf == "strong":
b["answerability_strong"] += 1
elif conf == "medium":
b["answerability_medium"] += 1
elif conf == "weak":
b["answerability_weak"] += 1
# Violation-kind tallies — each kind counts once per row even
# if the same kind fires on multiple claims. The bench is
# asking "did this kind fire on this run?", not "how many
# times within the run".
for kind in (r.get("violation_kinds") or []):
b["violation_kind_counts"][kind] += 1
# Bracket-count aggregates. Lattice rows only — quote and
# other modes record 0 so they'd skew the mean if averaged
# globally. Track per-mode sum + count; renderer can compute
# mean only for lattice modes.
ab = r.get("answer_brackets")
if ab is not None and m in ("claim_lattice_pointer", "claim_lattice"):
b["answer_brackets_sum"] += ab
b["answer_brackets_n"] += 1
# Directive compliance — sum the per-row booleans into
# per-mode pass counts.
for did, ok in (r.get("directive_compliance") or {}).items():
if did in b["directive_pass"] and ok:
b["directive_pass"][did] += 1
return by_mode
def _per_question_summary(rows: list[dict]) -> dict:
"""Group rows by (question, mode) → per-cell vote counts + medians."""
cells: dict[tuple[str, str], dict] = {}
for r in rows:
key = (r["question"], r["answer_mode"])
c = cells.setdefault(key, {
"STRICT": 0, "HYBRID": 0, "UNGROUNDED": 0, "errors": 0,
"ratios": [], "latencies": [],
})
if r["error"] or r["status"] == "error":
c["errors"] += 1
elif r["audit_mode"] in ("STRICT", "HYBRID", "UNGROUNDED"):
c[r["audit_mode"]] += 1
c["ratios"].append(r["ratio"] or 0.0)
c["latencies"].append(r["elapsed_s"] or 0.0)
return cells
def _median(xs: list[float]) -> float:
if not xs:
return 0.0
s = sorted(xs)
mid = len(s) // 2
return s[mid] if len(s) % 2 else (s[mid - 1] + s[mid]) / 2
def _render_markdown(
rows: list[dict],
summary: dict,
started_utc: str,
modes: list[str],
questions: list[str],
n_samples: int,
) -> str:
lines: list[str] = []
n_runs = len(rows)
lines.append(f"# arborist QA-quality benchmark — {started_utc}")
lines.append("")
lines.append(
f"questions: {len(questions)} · modes: {len(modes)} · samples per cell: "
f"{n_samples} · runs: {n_runs}"
)
lines.append("")
lines.append("## summary")
lines.append("")
lines.append("| mode | runs | STRICT | HYBRID | UNGROUNDED | err | strict-rate | mean ratio | mean latency | deflections | #68 fires (S/M/W) |")
lines.append("|------|------|--------|--------|------------|-----|-------------|-----------|--------------|-------------|-------------------|")
for mode in modes:
b = summary.get(mode)
if not b:
continue
n = b["n"] or 1
mean_ratio = b["ratio_sum"] / n
mean_lat = b["latency_sum"] / n
strict_rate = b["STRICT"] / n
deflections = b.get("deflections", 0)
ans_fires = b.get("answerability_fires", 0)
ans_s = b.get("answerability_strong", 0)
ans_m = b.get("answerability_medium", 0)
ans_w = b.get("answerability_weak", 0)
lines.append(
f"| {mode} | {b['n']} | {b['STRICT']} | {b['HYBRID']} | "
f"{b['UNGROUNDED']} | {b['errors']} | "
f"{strict_rate:.2f} | {mean_ratio:.3f} | {mean_lat:.1f}s | "
f"{deflections}/{b['n']} | "
f"{ans_fires}/{b['n']} ({ans_s}/{ans_m}/{ans_w}) |"
)
lines.append("")
lines.append("## format-collapse + violation kinds")
lines.append("")
lines.append(
"Per-mode count of FORMAT_COLLAPSED firings (pointer-mode signal — "
"model emitted ≥5 meaningful prose lines with zero `[E\\d+]` tags) "
"and per-violation-kind tallies. Each kind counts once per row "
"even if it fires on multiple claims within the run. See "
"`docs/tickets/ticket-000008-broad-quantifier-preflight-guard.md` "
"for why this matters: format collapse separates 'tried to ground "
"& failed' from 'abandoned the protocol entirely' at aggregate "
"scale."
)
lines.append("")
# Build the union of violation kinds observed across all modes for
# the table header — keeps the column set bench-wide rather than
# per-mode (so quote-mode rows show 0/N for kinds that only fire
# in lattice modes, instead of the column being absent).
all_kinds = sorted({
k for b in summary.values()
for k in (b.get("violation_kind_counts") or {}).keys()
})
if all_kinds:
header = (
"| mode | format-collapse | mean brackets (raw) | "
+ " | ".join(all_kinds)
+ " |"
)
sep = (
"|------|-----------------|---------------------|"
+ "|".join("-" * (len(k) + 2) for k in all_kinds)
+ "|"
)
else:
header = "| mode | format-collapse | mean brackets (raw) |"
sep = "|------|-----------------|---------------------|"
lines.append(header)
lines.append(sep)
for mode in modes:
b = summary.get(mode)
if not b:
continue
n = b["n"] or 1
fc = b.get("format_collapses", 0)
mean_brackets = (
b["answer_brackets_sum"] / b["answer_brackets_n"]
if b.get("answer_brackets_n")
else 0.0
)
kind_cells = [
str(b["violation_kind_counts"].get(k, 0))
for k in all_kinds
]
cells_str = " | ".join(kind_cells)
prefix = f"| {mode} | {fc}/{b['n']} | {mean_brackets:.1f} |"
if all_kinds:
lines.append(f"{prefix} {cells_str} |")
else:
lines.append(prefix)
lines.append("")
lines.append("## directive coverage (seven-point program)")
lines.append("")
lines.append(
"Per-mode pass-rate for the directives whose pinning is observable "
"from a single bench row. See `docs/seven-point-program.md` for "
"the full directive list. D1 (no LLM in verifier), D5 (verifier_method "
"enum), and D8 (test-pinning discipline) are global properties of "
"the substrate and don't appear per-row. ½ in the doc means some "
"scope is implemented; bench numbers reflect the implemented portion."
)
lines.append("")
lines.append("| mode | D2 pointer | D3 cti-ready | D4 ev-map bound | D6 warrant | D7 honest label |")
lines.append("|------|-----------|--------------|-----------------|-----------|----------------|")
for mode in modes:
b = summary.get(mode)
if not b:
continue
n = b["n"] or 1
dp = b["directive_pass"]
cells = []
for did in (
"D2_pointer_clauses",
"D3_cti_substrate_ready",
"D4_evidence_map_bound",
"D6_warrant_fired",
"D7_honest_label",
):
count = dp.get(did, 0)
cells.append(f"{count}/{n} ({count / n:.0%})")
lines.append(f"| {mode} | " + " | ".join(cells) + " |")
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. Buckets log-scale "
"from 8 KB to >= 1 MB so the same harness covers Hermes-3-8B "
"(82K context) through 1M-context models without code change."
)
lines.append("")
# Log-scale buckets — covers the entire model spectrum from
# 8B-class models (Hermes 82K context, max useful prompt ~32-64KB)
# through giant-context models (Gemini 1.5 / Claude 1M, useful
# prompt potentially 100KB-800KB). Models simply don't populate
# buckets beyond their context window; the substrate stays
# universal. Adding new boundaries doesn't require a code change
# for existing data — empty buckets get skipped at render.
buckets = [
("<8KB", 0, 8000),
("8-16KB", 8000, 16000),
("16-32KB", 16000, 32000),
("32-64KB", 32000, 64000),
("64-128KB", 64000, 128000),
("128-256KB", 128000, 256000),
("256-512KB", 256000, 512000),
("512K-1M", 512000, 1048576),
(">=1M", 1048576, 10**12),
]
lines.append("| mode | bucket | runs | STRICT | strict-rate | mean evidence chars | mean answer chars |")
lines.append("|------|--------|------|--------|-------------|---------------------|-------------------|")
# Track per-mode (label, strict-rate, n) tuples for the
# "recommended context budget" recommendation below.
per_mode_buckets: dict[str, list[tuple[str, float, int]]] = {}
for mode in modes:
per_mode_buckets[mode] = []
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
strict_rate = n_strict / n_b
per_mode_buckets[mode].append((label, strict_rate, n_b))
lines.append(
f"| {mode} | {label} | {n_b} | {n_strict} | "
f"{strict_rate:.2f} | {int(mean_ev)} | {int(mean_ans)} |"
)
lines.append("")
lines.append("## recommended context budget (learned from this bench)")
lines.append("")
lines.append(
"Per-mode peak strict-rate bucket from the table above. Bench "
"observations feed back into a recommended `max_context_chars` "
"default. Operator-driven landing per the five-step algorithm "
"step 5 — surfaced here, not auto-applied. Minimum bucket size "
"of 5 runs to be considered (smaller samples are noise). The "
"log-scale buckets cover everything from 8B-class models "
"through 1M-context models; a model whose context window stops "
"at 82K simply never populates the giant buckets."
)
lines.append("")
lines.append("| mode | peak bucket | strict-rate | n |")
lines.append("|------|-------------|-------------|---|")
for mode in modes:
candidates = [t for t in per_mode_buckets.get(mode, []) if t[2] >= 5]
if not candidates:
lines.append(
f"| {mode} | (insufficient samples; ≥5 runs/bucket "
f"required) | n/a | n/a |"
)
continue
# Peak strict-rate; tie-break on smaller bucket (less context
# is cheaper at equivalent strict-rate).
peak_label, peak_rate, peak_n = max(
candidates, key=lambda t: (t[1], -buckets.index(
next(b for b in buckets if b[0] == t[0])
))
)
lines.append(
f"| {mode} | {peak_label} | {peak_rate:.2f} | {peak_n} |"
)
lines.append("")
lines.append("## per question")
lines.append("")
lines.append(
"Cell shows verdict counts across N samples (S=STRICT, H=HYBRID, U=UNGROUNDED, e=err). "
"median ratio = median n_verified/n_quotes."
)
lines.append("")
cells = _per_question_summary(rows)
lines.append("| question | mode | verdicts (N=" + str(n_samples) + ") | median ratio | median latency |")
lines.append("|----------|------|---------------------|--------------|----------------|")
for q in questions:
for mode in modes:
c = cells.get((q, mode))
if not c:
continue
verdicts = (
f"S:{c['STRICT']} H:{c['HYBRID']} U:{c['UNGROUNDED']}"
+ (f" e:{c['errors']}" if c["errors"] else "")
)
mr = _median(c["ratios"])
ml = _median(c["latencies"])
qd = q if len(q) <= 50 else q[:47] + "..."
lines.append(f"| {qd} | {mode} | {verdicts} | {mr:.3f} | {ml:.1f}s |")
lines.append("")
return "\n".join(lines)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--questions", type=Path, default=Path("bench/qa_questions.txt"))
ap.add_argument("--shards-dir", type=Path, default=Path.home() / ".arborist" / "shards")
ap.add_argument("--qa-db", type=Path, default=None)
ap.add_argument("--out-dir", type=Path, default=Path("bench/qa_results"))
ap.add_argument("--top-k", type=int, default=8)
ap.add_argument("--modes", default=",".join(ANSWER_MODES),
help="comma-separated subset of ANSWER_MODES to sweep")
ap.add_argument("--n", dest="n_samples", type=int, default=3,
help="samples per (question, mode); each sample burns the cached "
"record so Hermes nondeterminism becomes the variance source")
ap.add_argument("--limit", type=int, default=0,
help="truncate question list to N; 0 = all")
ap.add_argument("--concurrency", type=int, default=1,
help="parallel sample-level tasks. Each (question, "
"mode, sample_idx) is one task; tasks are shuffled "
"via --seed and dispatched concurrently. Per-cell "
"Lock serializes burn+insert on the shared "
"cache_key so two samples of the same cell never "
"race. vLLM handles concurrent requests well; "
"4-8 is a safe starting point.")
ap.add_argument("--seed", type=int, default=0,
help="deterministic shuffle seed for sample-level "
"task ordering. Same seed = same task order = "
"reproducible bench. Random ordering is the "
"design — uncorrelated samples give true i.i.d. "
"variance estimation and feed vLLM's continuous "
"batcher a diverse request stream.")
ap.add_argument("--resume", type=Path, default=None,
help="resume an interrupted bench by appending to "
"an existing JSONL. Already-completed (question, "
"mode, sample_idx) tasks are skipped; remaining "
"tasks run in the original shuffled order (use "
"the same --seed). The same .md path is "
"re-rendered from the union of pre-existing + "
"new rows. Stop/start-able bench.")
ap.add_argument("--endpoint", default=os.environ.get(
"ARBORIST_LLM_ENDPOINT", "https://hermes.ai.unturf.com/v1"))
ap.add_argument("--model", default=os.environ.get(
"ARBORIST_LLM_MODEL", "adamo1139/Hermes-3-Llama-3.1-8B-FP8-Dynamic"))
ap.add_argument(
"--policy", action="append", default=None, metavar="KEY=VALUE",
help=(
"policy-field override applied on top of DEFAULT_QUERY_POLICY. "
"Repeat for multiple fields. VALUE is JSON-decoded; bare strings "
"fall back to literal string. Example: "
"--policy quantifier_reminder_enabled=true "
"--policy quantifier_guard_apply_caps=true. Useful for A/B "
"cycles per ticket #000008 §10.8 decision tree."
),
)
args = ap.parse_args(argv)
questions = _read_questions(args.questions)
if args.limit:
questions = questions[: args.limit]
if not questions:
print(f"no questions in {args.questions}", file=sys.stderr)
return 2
# Parse --policy KEY=VALUE overrides into a dict. Values run through
# json.loads so booleans, ints, lists, and quoted strings work as
# expected; bare unquoted strings ("hermes-3") fall back to the
# literal string.
policy_overrides: dict | None = None
if args.policy:
policy_overrides = {}
for entry in args.policy:
if "=" not in entry:
print(f"--policy needs KEY=VALUE, got {entry!r}", file=sys.stderr)
return 2
key, val = entry.split("=", 1)
try:
policy_overrides[key] = json.loads(val)
except json.JSONDecodeError:
policy_overrides[key] = val
modes = [m.strip() for m in args.modes.split(",") if m.strip()]
bad = [m for m in modes if m not in ANSWER_MODES]
if bad:
print(f"unknown mode(s): {bad}; expected subset of {list(ANSWER_MODES)}", file=sys.stderr)
return 2
args.out_dir.mkdir(parents=True, exist_ok=True)
# Resume support: when --resume <existing.jsonl> is given, reuse
# that file's stamp and rebuild done_tasks set so already-completed
# samples skip on this run. Markdown rolls up the union of
# pre-existing + freshly-written rows.
existing_rows: list[dict] = []
done_tasks: set[tuple[str, str, int]] = set()
if args.resume:
resume_path = Path(args.resume)
if not resume_path.exists():
print(f"--resume target does not exist: {resume_path}", file=sys.stderr)
return 2
with resume_path.open(encoding="utf-8") as rf:
for line in rf:
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue # skip a partial trailing line
key = (row["question"], row["answer_mode"], int(row.get("sample_idx", 0)))
done_tasks.add(key)
existing_rows.append(row)
stamp = resume_path.stem # reuse the original "YYYY-MM-DDTHH-MM-SSZ"
jsonl_path = resume_path
md_path = resume_path.with_suffix(".md")
else:
stamp = _utc_iso_compact()
jsonl_path = args.out_dir / f"{stamp}.jsonl"
md_path = args.out_dir / f"{stamp}.md"
if args.n_samples < 1:
print(f"--n must be >= 1, got {args.n_samples}", file=sys.stderr)
return 2
n_runs_total = len(questions) * len(modes) * args.n_samples
print(
f"[bench] {len(questions)} question(s) × {len(modes)} mode(s) × "
f"{args.n_samples} sample(s) = {n_runs_total} run(s)"
)
print(f"[bench] shards_dir={args.shards_dir} top_k={args.top_k} "
f"burn=always (per-sample, forces fresh inference)")
print(f"[bench] concurrency={args.concurrency} seed={args.seed} "
f"(sample-level tasks; per-cell Lock serializes cache_key writes)")
if args.resume:
print(f"[bench] resuming from {jsonl_path}: "
f"{len(existing_rows)} task(s) already done; skipping")
else:
print(f"[bench] writing → {jsonl_path}")
print()
# Build sample-level task list. Each task is one (question, mode,
# sample_idx); shuffle so samples for the same cell get spread
# across time. Two effects:
# - True i.i.d. n-sample variance: consecutive samples of the
# same cell don't share vLLM batch composition or KV-cache
# locality, so what looks like model nondeterminism actually
# IS model nondeterminism.
# - vLLM's continuous batcher gets a diverse request stream;
# under the prior cell-grouped scheduling, batches were
# correlated and the engine couldn't fill efficiently.
tasks = [
(q, mode, sample_idx)
for q in questions
for mode in modes
for sample_idx in range(args.n_samples)
]
rng = random.Random(args.seed)
rng.shuffle(tasks)
total_tasks = len(tasks)
# Resume: filter out tasks already in the JSONL. Order preserved.
if done_tasks:
tasks = [t for t in tasks if t not in done_tasks]
print(f"[bench] {len(tasks)} task(s) remaining ({total_tasks} total)")
print()
# Per-cell lock — burn+insert against a shared cache_key must
# serialize. Two samples of the SAME (q, mode) ending up in
# adjacent worker slots could race; the lock makes that path
# safe without bottlenecking unrelated cells. Lock contention is
# rare under shuffle (samples spread out), so the throughput cost
# is near-zero while correctness is preserved.
cell_locks: dict[tuple[str, str], Lock] = defaultdict(Lock)
rows: list[dict] = list(existing_rows) # preserve resumed rows for the rollup
write_lock = Lock()
print_lock = Lock()
done = [len(existing_rows)] # countdown reflects total, not just-this-session
file_mode = "a" if args.resume else "w"
with jsonl_path.open(file_mode, encoding="utf-8") as f:
def _process(task: tuple[str, str, int]) -> None:
q, mode, sample_idx = task
with cell_locks[(q, mode)]:
row = _run_one(
question=q,
answer_mode=mode,
shards_dir=args.shards_dir,
qa_db=args.qa_db,
top_k=args.top_k,
burn=True,
endpoint=args.endpoint,
model=args.model,
policy_overrides=policy_overrides,
)
row["sample_idx"] = sample_idx
with write_lock:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
rows.append(row)
f.flush()
with print_lock:
done[0] += 1
tag = (
f"err: {row['error']}" if row["error"]
else f"{row['audit_mode']} {row['n_verified']}/{row['n_quotes']} "
f"{row['elapsed_s']:.1f}s"
)
print(
f" [{done[0]:>3}/{total_tasks}] {mode:<22} "
f"#{sample_idx + 1}/{args.n_samples} "
f"{q[:42]:<42}{tag}",
flush=True,
)
if args.concurrency <= 1:
for task in tasks:
_process(task)
else:
with ThreadPoolExecutor(max_workers=args.concurrency) as pool:
futures = [pool.submit(_process, t) for t in tasks]
for fut in as_completed(futures):
fut.result() # surfaces any exception
summary = _summarize(rows)
md = _render_markdown(rows, summary, stamp, modes, questions, args.n_samples)
md_path.write_text(md, encoding="utf-8")
print()
print(md)
print(f"\n[bench] results: {jsonl_path}\n[bench] summary: {md_path}")
return 0
if __name__ == "__main__":
sys.exit(main())