Commit graph

169 commits

Author SHA1 Message Date
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
5674107c06
user_payload_layout: opt-in policy knob for question placement
format_user_payload helper in arborist/qa/prompts.py becomes the single
source of truth for the user-turn payload. Three layouts:
  tail (default)  evidence first, question at end (prior behavior)
  bookend         question repeated before AND after evidence — counters
                  lost-in-the-middle on small models (≤8B)
  per_chunk       bookend + a one-line [for: <q>] reminder before each
                  evidence block; for list/extraction queries

USER_PAYLOAD_LAYOUTS constant exported; unknown layout raises ValueError.
The six _user_payload closures in query.py (3) and runner.py (3) all
delegate to format_user_payload. Quote-mode passes per_chunk_marker=None
to fall back to bookend on flat document/sources context.

Wired through both DEFAULT_QUERY_POLICY and DEFAULT_POLICY. Folds into
governance_policy_hash (the layout changes the user-turn content the
model sees, so the policy hash partitions cleanly per layout); does NOT
fold into verifier_policy_hash (verifier rules unchanged).

Makefile gets LAYOUT_DEFAULT ?= tail and LAYOUT ?= $(LAYOUT_DEFAULT) so
operators can flip per-call (LAYOUT=bookend make query Q="...") or
session-wide (LAYOUT_DEFAULT=bookend make query Q="..."). Recommendation
matrix in the Makefile comment block above the query target encodes the
2026-05-27 bench finding.

Motivating case: the Veronica-Ballestrini "songs by" failure. Hermes-3-
8B under tail layout returned "specific songs by her are not mentioned
in the provided evidence" when evidence E2 literally contained the song
names. Same query under bookend recovered the answer (with conflation
between Veronica Ballestrini and The Veronicas); under per_chunk
recovered AND disambiguated three entities. Qwen-27B unaffected by
layout. The Ballestrini case is added to bench/qa_questions.txt as a
regression fixture under "entity list", with a 4-line comment pointing
to docs/user-payload-layout.md.

2026-05-27 bench (n=3 × 75q, claim_lattice mode, Hermes-3-8B):
  tail        STRICT 94/225 (0.418)  — control
  bookend     STRICT 95/225 (0.422)  — +0.44pp (noise, 5pp floor)
  per_chunk   STRICT 72/225 (0.320)  — -9.78pp (significant regression)

Verdict: tail stays default (cache-preserving and bench-confirmed
neutral). Bookend/per_chunk available as opt-in operator knobs. Per_chunk
regresses in aggregate because the per-chunk reminder over-anchors the
model on every chunk (TOO_MANY_EVIDENCE_IDS violations rose from 20 →
54; mean answer chars in 32-64KB bucket doubled from 720 → 1660). The
Ballestrini-class failure is real but rare across the curated set; a
layout fix that helps the rare case at the cost of 10pp aggregate is a
bad default trade. Documented in full in docs/user-payload-layout.md
along with the Dav1d 2026-05-27 review framing (GO for opt-in, NO-GO
for default promotion, ADD companion missed-answer guard).

CLI changes (--user-payload-layout flag on `query` and `ask`) landed
separately in commit e5ee283 alongside the #54 busy_timeout fix.
2026-05-27 10:13:59 -04:00
2c11435f7f
#000067 phase 2: 3rd "fts" pack kind for skip-rebuild hydrate
Each producer shard now optionally emits a THIRD pack alongside its
metadata and chunks packs: an "fts" pack containing the FTS5 shadow
tables (chunks_fts_data, chunks_fts_idx, chunks_fts_docsize,
chunks_fts_config + the documents_fts_* counterparts) packed as a
fresh SQLite file inside the tar so BLOB columns round-trip natively.

Consumer detects fts_pack_hashes in the metadata pack's manifest,
pulls each fts pack, ATTACHes the embedded sqlite, INSERTs every
shadow-table row into its target's empty shadow tables, and SKIPS
the local FTS rebuild entirely.

Producer side:
  arborist/cold_object.py
    + PACK_KIND_FTS = "fts"
    + FTS_SHADOW_TABLES tuple (8 shadow tables)
    + build_fts_pack(src_db_path, ...)
        creates a temp sqlite, applies SCHEMA_SQL (so destination
        has FTS virtual tables → shadow tables auto-created), copies
        every shadow-table row from src via cursor iteration, packs
        the sqlite file into tar.zst
    + ParsedManifest.fts_pack_hashes
    + parse_manifest reads _fts_pack_hashes records
    + build_metadata_pack accepts fts_pack_hashes parameter and
      writes the new manifest record
  arborist/evict.py:push_pack
    + include_fts: bool = True parameter (CLI --no-fts opts out)
    + Phase B.5 emits the fts pack BEFORE Phase C (metadata pack)
      so its hash can be referenced in the metadata manifest

Consumer side:
  arborist/evict.py
    + _pull_fts_pack_into_targets() — pulls fts pack body, extracts
      embedded sqlite, ATTACHes into each target, INSERT OR IGNORE
      every shadow-table row. INSERT OR IGNORE protects against
      rowid collisions on other targets that don't own these chunks.
    + hydrate_from_metadata_pack_routed iterates fts_pack_hashes in
      full mode, calls _pull_fts_pack_into_targets per pack
    + _pull_pack_inner_routed returns fts_pack_hashes_referenced in
      its result dict (mirrors chunk_pack_hashes_referenced)

CLI / Makefile:
  arborist cold pack --no-fts                     (opt-out)
  make cold-hydrate                                (auto-detects: if
                                                   chunks_fts_data is
                                                   already populated
                                                   on shard 000 after
                                                   unpack, skip the
                                                   rebuild post-pass)
  make cold-hydrate HYDRATE_REBUILD_FTS=1          (force rebuild)
  make cold-hydrate HYDRATE_REBUILD_FTS=0          (skip rebuild)

Schema:
  cold_pending.kind CHECK extended to include 'fts'
  pack_key() accepts kind="fts" → packs/<hash>.fts.tar.zst

Expected wall-time impact on the 3090 genesis bench:
  with fts in packs:  no rebuild step → ~5-10 min total wall
  without fts:        rebuild post-pass needed → ~15-20 min

Trade-off: ~30-50% larger bucket (FTS shadow data per shard) for
~70-90% faster consumer hydrate. Producer flips the trade via
--no-fts. The fts pack is optional in the manifest (empty list →
consumer falls back to rebuild) so old bucket data without fts
packs continues to work unchanged.

34 cold-unpack-routed + migrate + planner tests pass.
2026-05-26 19:13:01 -04:00
00eda4aed1
bench: cold-pack producer/consumer roundtrip recorder (#000061 + #46)
Self-contained benchmark for the SPV-wallet validation. Records:
  PRODUCER  bucket state + pack count + compressed bytes
  CONSUMER  wall time, exit status, post-hydrate shard sizes,
            per-shard documents/chunks/edges counts

Driver runs against the real DO Spaces bucket + the real fresh peer
on 3090-ai.foxhop.net. Writes one JSON artifact per run to
bench/results/cold-pack-roundtrip-<ISO>.json so a future operator
can diff hydrate times across pack-format changes (#000061 v3 →
graft mode #000066 → mesh-pull future).

Consumer command uses /usr/bin/time -v wrapped around
`arborist cold unpack --hydrate-shards-dir … --hydrate-M 4 --full`.
Hydrates into ~/.arborist/shards-genesis-test/ so it doesn't
clobber anything on the 3090.

No new ticket — this is task #45/#46 instrumentation. Existing
tests untouched.
2026-05-26 16:19:54 -04:00
3aae8119a6
#000065 step 1: routing helper + meta field + pre-migration snapshot
Three pieces, all read-only or additive — no shard mutation, no
schema-version bump:

1. shard_for_document(document_root, M) in arborist/document.py.
   Pure function: int(document_root[:8], 16) % M. 22 tests cover
   determinism, range-bounds, near-uniform distribution (±5pp at
   N=20k), and seven lock-in fixtures so peers will disagree
   loudly if anyone changes the formula.

2. corpus_shard_count meta field + get/set helpers in store.py.
   Lives in the existing key/value meta table; SCHEMA_VERSION
   stays at v9.8.0 (the DDL doesn't change and source_root is
   layout-independent, so cache records survive a reshard).
   Legacy shards (without the field) return None; reshard tool
   populates it on every target shard at migration time.

3. Pre-migration snapshot captured to
   bench/results/pre-migration-snapshot.json:
     docs        3,468,392  (3,468,226 globally unique)
     chunks      6,235,764
     edges      90,593,537
     audit       3,468,403
   This is the reference set post-reshard row counts must match.

4. Audit-event extraction script writes all 3.47M events from
   all 4 shards to /tmp/audit-events.ndjson (2.0 GB) for the
   Option-A canonical-chain consolidation step. Verifies chain
   integrity on extract — all 4 source chains report 0 breaks.

5. Fixed a wrong chunk count in docs/corpus-history.md
   (had ~3.54M/shard; actual is ~1.56M/shard) and added the
   edge-count column (~22.6M/shard, 90.6M total). 6.24M chunks
   total, not 14.12M.

Tests: 29 new pass (22 routing + 7 meta). No existing tests
touched.
2026-05-26 13:13:44 -04:00
967fedbbe0
#000065: pin M=4 + bench script + SQLite-alternative decision tree
Pinned the canonical shard count at M = 4 based on real-Wikipedia
ingest + query benchmark (bench/shard_count_sweep.py). Captured the
"when does SQLite stop being the right substrate" decision tree so
future operators know what bench would justify a fork or replacement.

Bench numbers (Wikipedia 2003 cur dump, 2000 docs, 4 cells of
M ∈ {1, 2, 4, 8}, 50 FTS queries per cell):

     M   chunks/s    q_p50_ms    q_p99_ms   attach_ms
     1    3,648        0.05        0.20        1.61
     2    5,649        0.03        0.19        3.24
     4    6,405        0.07        0.30        9.00
     8    6,959        0.03        0.28        9.65

Key observations:
- M=1→M=2 is the biggest ingest win (+55%). Most gain happens there.
- M=2→M=4 is +13%. M=4→M=8 is only +9% — diminishing returns.
- Real wikitext canonicalization is per-worker Python CPU bound, not
  SQLite-writer-lock bound. More shards don't unlock more CPU.
- Query p50/p99 is flat across M within noise (50 queries small).
- ATTACH cost grows linearly: 1.6 / 3.2 / 9.0 / 9.7 ms.

Why M=4 specifically:
- Captures 92% of peak ingest throughput (6,405 / 6,959).
- 6 ATTACH slots free under SQLite's 10 ceiling for aux DBs
  (qa.db, snapshots.db, selfmodel-chain.db, crawl_*.db, future
  mesh_*.db) — comfortable headroom. M=8 leaves only 2 slots.
- Mobile-tolerable: phone NAND attach is 5-10x slower than NVMe;
  M=4 = 45-90 ms cold start (instant), M=8 = 50-100 ms (sluggish
  with no headroom).
- Matches fox's current 4-shard layout = cheapest migration.

Decision tree for when SQLite stops being right (full text in
ticket §"When the SQLite-default substrate stops being right"):

A. ATTACH ceiling pressure (auxiliary DBs grow past 5) → bench
   forked SQLite with SQLITE_MAX_ATTACHED=125, M ∈ {16, 32, 64};
   if attach cost stays linear past M=10, fork viable but pays
   permanent "no longer stock sqlite3" tax.

B. Ingest hits >10k chunks/s sustained ceiling → first tune
   page_size / WAL checkpoint / mmap_size / synchronous. If
   tuning gets 2-5x, stay on SQLite. If still ceiling-limited,
   candidates: DuckDB (columnar, MVCC, FTS), libmdbx (B+tree no
   FTS; we'd build it). In-house DB rejected without specific
   failure of those.

C. Federation needs multi-writer-same-shard → SQLite writer-lock
   serializes peers, becomes federation bottleneck. First try
   leader-election (single-writer-per-shard with WAL replication
   to followers). If true multi-writer required, SQLite is wrong;
   candidates: FoundationDB, CRDT-on-KV-store. DuckDB does NOT
   solve this — its MVCC is single-process.

Honest verdict: for current arborist workload (single-writer-per-
shard, read-mostly federation), stock python3 sqlite3 is the right
substrate. None of A/B/C are close to firing. The bench discipline
exists to know what to measure when something changes.

bench/results/shard-count-sweep-2026-05-26T16-20-48Z.csv (synthetic
baseline) + 2026-05-26T16-31-34Z.csv (real Wikipedia) committed as
the load-bearing measurement for the M=4 choice.
2026-05-26 12:40:48 -04:00
b6bb31a836
docs+code: ground §12 judge pipeline in the actual judge_code.py
The §12.1 pipeline I added was second-hand from benchmark-matrix.md
and got several things wrong against the code:
- listed 4 verdicts; actual is 5 (missing FABRICATED — the
  fabrication-vs-WRONG split that energy-cogs §5.5 leans on for the
  qwen-fabricates / hermes-abstains finding)
- "θ=0.85" was right by accident — but it's the code-judge-pinned
  _CODE_JUDGE_THETA_CONTRA constant, raised from the manifest 0.5
  default after measuring 114 FPs in the 0.5-0.75 band
- omitted the short-answer entity-grounding fast path (which runs
  BEFORE NLI per the 2026-05-19 Poland-Tusk smoke)
- omitted the HYBRID rescue ladder (NLI entail / entity rescue /
  2026-05-21 verbatim-quote-on-topic rescue)
- conflated WRONG and FABRICATED (the subject-in-gold split is what
  distinguishes "source has the topic but a different value" from
  "source silent on the topic")

Rewrote §12.1 grounded in `bench/judge_code.py:judge()` (its own
docstring at line 501-528 is the truth on rule order), with file:line
citations and the verdict-mapping in full.

Also fixed a real artifact-vs-doc drift INSIDE the judge: the
module-top docstring still claimed θ_contra default 0.5 and omitted
the short-path and the WRONG/FABRICATED split. Updated to match the
authoritative judge() docstring + current code.

No behavior change — docstring + benchmarks doc only.
2026-05-22 19:17:28 -04:00
2d3186669f
feat(#000057): stronger code judge — resolve HYBRID with verified quote + on-topic
The code judge bailed to JUDGE_ERROR on 40% of in-corpus answers: HYBRID
(partial grounding) with low NLI entail, where the entity-grounding
rescue needs ZERO unsourced specifics. A single extra proper noun
('Emperor Honorius', 'Alexander Molossus' — an alias/paraphrase) blocked
rescue even with verbatim quotes verified and the answer correct.

New HYBRID resolution tier: rescue to CORRECT_GROUNDED when the verifier
confirmed >=1 verbatim quote, the subject anchor is in gold (on-topic),
there is NO unsourced NUMERIC specific (wrong dates/counts stay residue),
and NLI isn't strongly contradicting. Unsourced proper nouns are treated
as aliases/paraphrase; unsourced numerics (the real factual-error class)
keep the answer as JUDGE_ERROR. Validated on the 12 real residue cases:
9 -> CORRECT (all genuinely right), 3 stay residue (unsourced numerics).
JUDGE_ERROR 40% -> ~10%. self-test 4/4; 2 new tier tests; suite 2549.
2026-05-21 13:10:01 -04:00
2fd3523777
fix(#000057): control_ab header prints actual model/answer_mode/judge
Was a stale hardcoded 'same Hermes; judge=Opus hermetic' label that
misreported any run with --model/--judge overrides (e.g. qwen + code
judge). Now reflects the real config — honest header for the artifact.
2026-05-21 12:37:24 -04:00
892d9ed037
feat(#000057): bench/watt_calibrate.py — separate prefill vs decode energy
fox 2026-05-21: account wattage for input and output separately. Prefill
(process all prompt tokens, parallel/compute-bound) and decode (generate
output, autoregressive/bandwidth-bound) are different GPU ops with
different J/token — a single per-token number can't represent both.

Slope calibration (no sub-request power alignment): sweep prompt length
at tiny max_tokens -> prefill J/input-tok (fixed overhead cancels in the
slope); fix a tiny prompt and sweep forced output length (ignore_eos) ->
decode J/output-tok. Prefill kept COLD (unique filler so cached_tokens=0).
Reuses watt_bench probes. Bad points (context overflow) skip, not abort.

Measured qwen-nothink/4090 @$0.33/kWh: prefill 0.175 J/tok
($0.016/M-input-tok), decode 6.16 J/tok ($0.564/M-output-tok) — decode
35x dearer per token. Predicts measured substrate J/q within ~5%. 14
tests (+ slope). Validated live.
2026-05-21 11:48:36 -04:00
459060c774
fix(#000057): real token usage + cost per input/output separately
fox 2026-05-21: (1) use REAL API token usage, not len//4; (2) the
substrate prefills a large retrieved CONTEXT as INPUT while solo feeds
~nothing, so per-completion-token over-charges the substrate — and per-
TOTAL-token UNDER-charges it (its mix is ~98% cheap prefill tokens).
Measured n=30 qwen-nothink/4090: substrate prefills ~6.6k input tok/query
(claim_lattice) vs solo ~52 — ~127x. Neither single per-token denominator
is honest; prefill (parallel, cheap/tok) and decode (autoregressive,
dear/tok) must be costed separately.

- OpenAICompatibleClient stashes data['usage'] as .last_usage (non-
  invasive; return type unchanged).
- watt_bench captures real prompt_tokens + completion_tokens per call
  (both arms), aggregates per cell, and energy_cogs reports gross +
  marginal per BOTH 1k-total-tok and 1k-completion-tok plus the context
  size. Prints the prompt/completion split.
- 12 tests incl. the prompt-context artifact (per-total cheap, per-
  completion dear). Full suite 2540 passed.

The clean per-input-tok / per-output-tok split rides bench/watt_calibrate
(slope calibration; separate commit once validated live).
2026-05-21 11:41:01 -04:00
32aeb37086
fix(#000057): GPU COGS = generation only — retrieval/verify don't touch the card
fox 2026-05-21: 'we dont touch card during retrieval'. Retrieval + verify
are CPU/SQLite on the orchestrator; the GPU is idle (at the shared, always-
on model-resident floor) during them. So the substrate does NOT 'hold the
card' through its wall-clock — the gross window integral over-counts by
charging that always-on floor for the seconds we spend retrieving (energy
that exists regardless of the query; the card serves other traffic then).

Correct attribution: the GPU cost of a query is its GENERATION energy only
(the marginal — burst above the serving floor). Relabel: marginal is the
headline GPU COGS; gross is demoted to a reference 'window total, not
query-attributable'. Print + energy_cogs docstring + stock-v1-config doc
updated. No math change (marginal was already right) — this corrects the
narration. Measured qwen-nothink/4090: substrate GPU COGS is LOW
(claim_lattice $0.21/M-tok, quote $0.83) — its real overhead is latency
(CPU retrieval), not GPU watts.
2026-05-21 11:07:37 -04:00
5b1cbeed80
fix(#000057): measure power STATES, not a duty-cycle blend; guarantee cache miss
fox 2026-05-21: 'gen 200W' was a bug — joules/window blends the ~400W
generation bursts with the sub-100W gaps (retrieval/verify/network) into
a power state the card never sits at. A card occupies DISTINCT states
(idle / middle-idle = resident-between-requests / generation), differing
per card×model×server.

watt_probe.classify_power_bands(): largest-gap split of the window
samples into a low band (serving floor) and high band (generation draw)
+ duty cycle. Data-derived, never hardcoded — tested at two scales. The
worker emits the decomposition + raw samples; RemoteProbe/LocalProbe
expose band_stats() uniformly.

energy_cogs: marginal now taken against the measured SERVING FLOOR (the
standing cost of being ready), not deep idle; the blend is kept but
labelled window_mean_w. Reports idle/serving-floor/gen-draw/duty.

Cache-miss certainty (fox's question): the arborist arm runs
burn_existing=True (force-deletes any live providence row before
inference) and asserts cache_hits==0 with a loud warning + real_inference
flag — so we time real generation, never a SQLite lookup. Solo has no
cache path. 11 tests (energy math + band split). Validated live on the
isolated 4090: solo gen 308W/70%-duty vs substrate 396W/8.6%-duty —
substrate marginal/tok is LOWER, gross/tok higher (it holds the card
longer for retrieval).
2026-05-21 10:58:56 -04:00
1aff09f021
feat(#000057): energy-COGS layer for watt_bench — marginal vs gross $/1k-tok
fox 2026-05-21: compute cost-of-goods-sold by kWh vs tokens, with the
three power states (idle / warm-idle / generation) MEASURED per
card×model×server — never hardcoded (his 40/127/380 W were illustrative
of one 3090). The only operator input is --price-per-kwh (default 0.33
USD/kWh, a configurable site rate).

energy_cogs() (pure, unit-tested) decomposes measured generation energy
against the measured warm-idle baseline:
  * gross    — all measured joules over the window (all-in, includes the
               warm-idle cost of keeping the model hot, amortized).
  * marginal — joules ABOVE warm-idle: what one more request's burst
               actually costs (clamped >=0).
kWh = J/3.6e6; $/1k-tok is the unit that compares to API pricing. Both
surface per cell + a COGS print line.

watt_bench's arborist arm now loads the frozen bench.stock_v1 policy
(--answer-mode, drift-guarded on non-reasoning) so cost is measured for
the SAME substrate the campaign grades. Cells record
window_start/end_unix so a post-hoc load_monitor queue-depth cross-ref
can flag organic-traffic contamination on the non-isolated single-slot
endpoints. 6 COGS tests; full suite 2534 passed.
2026-05-21 10:19:41 -04:00
1a7f8eb4ea
feat: STOCK V.1 two-mode config family + wire treatment arms to the pin
fox 2026-05-21: characterize substrate-ON under BOTH answer shapes, so
answer_mode is a swept axis, not a single pinned value.

stock_v1.py now exposes STOCK_V1_POLICIES{quote,claim_lattice} +
STOCK_V1_GOVERNANCE_HASHES (quote 5b6ca4c5..., claim_lattice 036a4c79...),
policy_for(mode), and assert_not_drifted(mode). Shared pins (crosslang
OFF, repair OFF, quantifier dry-run, metacognition label-only,
soft-preflight OFF, claim cap 12, v2-acronym-aware) are frozen
identically across modes.

Wire the treatment arms to the pin (the consumer-side step that makes
the freeze real):
  * control_ab    --answer-mode {quote,claim_lattice}
  * control_sweep --arborist-answer-mode {quote,claim_lattice}
Both default claim_lattice (prior behavior), call assert_not_drifted on
non-reasoning runs (halts the sweep if DEFAULT_QUERY_POLICY drifts), and
load the frozen policy_for(mode) instead of an inline
dict(DEFAULT_QUERY_POLICY, ...). Reasoning refs (phase 3) keep their
documented JSON overrides and skip the assert by design (different hash).

jaggedness is left standalone — it is a mode-agnostic retrieval
instrument, coupling it to the answer-policy freeze adds friction with no
correctness gain. Full suite 2528 passed.
2026-05-21 10:15:26 -04:00
e227bbc32a
feat: pin STOCK V.1 substrate — frozen substrate-ON config for the GPU campaign
Before the multi-day campaign (hermes 3090/4090 -> qwen 3090/4090 ->
reasoning variants) the substrate-ON treatment arm must NOT drift. It
previously inherited DEFAULT_QUERY_POLICY implicitly, so any mid-run
edit would silently change what 'substrate-ON' means.

bench/stock_v1.py snapshots DEFAULT_QUERY_POLICY + re-asserts the
load-bearing pins (answer_mode=quote, crosslang OFF, repair OFF,
quantifier caps dry-run, metacognition label-only, soft-preflight OFF,
claim ceiling 12, v2-acronym-aware), then hashes the whole effective
dict. assert_not_drifted() fails loudly if that hash ever changes —
re-pinning is a deliberate fox-gated V.2 bump, never silent. The whole
campaign is identified by one governance_policy_hash
(5b6ca4c5...aade4e). Non-reasoning + non-distributed are harness axes
(reasoning -> phase 3, mesh -> later fork), not policy fields.

docs/stock-v1-config.md documents V.1, substrate-OFF (control_ab arm A),
the campaign matrix, and the energy-COGS companion (#000057) — whose
power states (idle / warm-idle / generation) are MEASURED per
card+model+inference-server at runtime, never hardcoded; only $/kWh is
an operator flag.
2026-05-21 09:43:17 -04:00
b5cc970a86
feat: bench/load_monitor.py — stdlib request-load monitor for single-slot endpoints
Answers fox's 2026-05-21 question — 'are we being swamped because we're
open to internet?' — before the multi-day GPU bench, where uncontrolled
internet traffic on the single-slot hermes (3090/vLLM) and qwen
(4090/llama.cpp) endpoints would contaminate wattage + throughput.

Three stdlib subcommands (urllib + sqlite3 + hand-rolled SVG, nothing to
install):
  poll   — scrape each endpoint's Prometheus /metrics on an interval into
           SQLite; queue depth (num_requests_waiting / requests_deferred)
           is the swamp signal a single GPU slot exposes. Prunes past
           --retention-days each cycle (bounded store, no cancer growth).
  graph  — multi-panel SVG: queue depth, running, req/s, tok/s, e2e latency.
  access — parse Caddy/nginx access log for the real client IPs the backend
           can't see behind the proxy hop; top talkers + per-IP rate SVG.

Backend /metrics = HOW MUCH; proxy log = WHO. make monitor-poll /
monitor-graph / monitor-access.
2026-05-21 09:20:53 -04:00
9d0015b4d4
feat(#000060): bench/jaggedness.py — deterministic retrieval jaggedness instrument
v1 of the same-model substrate-delta harness's non-jagged metric.
For one corpus title, surface-perturb its question (numeral / accent /
hyphen / honorific / amp / brit) preserving the referent, then ask
whether retrieval surfaces the SAME target for canonical vs perturbed
phrasing. J_norm = XOR disagreement rate @k (lower = less jagged);
graded mean |Δrank| catches rank instability the binary metric misses.

Pure query --dry-run: no LLM, no verifier, no judge, no n=3 noise, no
5pp floor — the recall_at_k discipline. Reuses recall_at_k.probe +
mine_questions._surface_variant. Feeds #000012 ForkScore
ΔJaggednessReduction. A-vs-C answer-quality arm already exists under
#000057 (control_ab/control_sweep) — not rebuilt. Curvature + LLM-arm
jaggedness delta remain open (ticket §8).

make bench-jaggedness JAGGED_LIMIT=40 JAGGED_K=8
2026-05-21 08:38:07 -04:00
c6f8e991f8
feat(#000057): watt_bench --remote-gpu-host — laptop-driver / worker-reporter mode
Implements fox's 2026-05-20 architecture: don't ship shards to the
tight-on-disk GPU boxes; run the benchmark FROM the laptop (local
shards + retrieval + judge + workload loop, driving the worker's LLM
endpoint over the network) and have the worker boxes REPORT their own
power.

Adds a probe abstraction with two implementations behind one
start/stop/gpu_stats/cpu_stats interface:
  - LocalProbe: wraps PowerSampler + CpuSampler (watt_bench runs ON the
    GPU box — the original mode)
  - RemoteProbe: orchestrates bench/watt_probe.py on a remote worker
    over SSH (scp the stdlib probe once, launch --until-file detached,
    touch the stop-file after the workload, fetch the JSON). The worker
    needs no shards / arborist / venv.

--remote-gpu-host HOST selects RemoteProbe; --endpoint points at the
worker's endpoint. Idle baseline now records gpu_util_mean_pct and
flags ">5% ⇒ contaminated by live traffic" so the 3090-style
contamination (live hermes traffic polluting the baseline) is visible
in the output, not silent.

Verified end-to-end: laptop-driven n=2 solo qwen, power sampled on the
4090 over SSH — idle gpu 20.2W @ 0% util (clean) + cpu 86W; load
populated; report tagged mode=remote. The on-box LocalProbe path is
unchanged (omit --remote-gpu-host).

Report schema gains mode / remote_gpu_host / gpu_available /
cpu_energy_available. Per-cell records gpu+cpu+total joules/question.
2026-05-20 16:33:14 -04:00
88d90ad8a2
feat(#000057): bench/watt_probe.py — stdlib-only remote power probe (worker side)
Splits the power-sampling half out for the laptop-driver / worker-
reporter architecture (fox 2026-05-20): don't ship shards to the tight-
on-disk GPU boxes; instead drive the benchmark FROM the laptop (which
holds the shards + judge + workload loop) and have the worker boxes
just serve the model and REPORT their own power.

Power sampling must run on the box (nvidia-smi + RAPL are host-local),
but nothing else does. watt_probe.py is stdlib-only — python3 +
nvidia-smi + readable RAPL, no venv, no arborist, no shards. Copy it to
the worker and run; it samples GPU power (nvidia-smi, trapezoid-
integrated to joules) + CPU package energy (RAPL energy-diff) over a
window and emits the same energy schema watt_bench's local samplers
produce. Two window modes: --duration (fixed) or --until-file (laptop
touches a stop-file — the workload-correlated mode). Records
gpu_util_mean_pct so live-traffic contamination can be detected.

Takes only the top-level RAPL package (intel-rapl:N, single colon),
not the :N:M core/uncore subdomains, so no double-count.

Verified on the 4090: scp'd to /tmp, ran with no install, real readings
(GPU 20.2W idle @ 0% util, CPU 82W, joules computed, 6 samples / 3.2s).

Next: laptop-side orchestration in watt_bench (--remote-gpu-host) that
SSH-starts this probe for each cell's window while the driver runs
retrieval + judge locally and drives the worker's LLM endpoint.
2026-05-20 16:30:12 -04:00
ab8df76792
feat(#000057): CPU wattage (RAPL) in watt_bench + expanded cost/quality matrix doc
Toward fox's next goal: score the full serving stack on quality AND
cost — {qwen, hermes} × {llama.cpp, vLLM} × {3090, 4090} × {solo,
arborist}, measuring CG% + GPU watts + CPU watts + joules/answer per
cell.

watt_bench.py — adds CpuSampler (Intel RAPL package energy via
/sys/class/powercap/intel-rapl:*/energy_uj). RAPL exposes a cumulative
microjoule counter, so energy-over-window is an end-minus-start diff
(handles wrap) — more accurate than integrating instantaneous power.
Sums multi-package. energy_uj is root-only by default (PLATYPUS /
CVE-2020-8694), so it degrades to available=False when locked;
--cpu-energy-cmd 'sudo cat {path}' supplies a privileged reader when a
sudo rule exists. Each cell now reports gpu/cpu/total joules-per-
question + gpu joules-per-token; the report records cpu_rapl_available.
Verified: graceful degradation when locked; RAPL diff math (1->4 MJ uJ
= 3.0 J, exact).

benchmark-matrix.md — expands the cost section to the full 16-cell
(model × engine × GPU × arm) design, the per-cell metric set (quality +
GPU + CPU energy), the serving-stack inventory from 2026-05-20 recon
(4090=qwen/llama.cpp, 3090=hermes/vLLM — each box has one engine + one
model today), and the buildout gap (vLLM+qwen, llama.cpp+hermes, cross-
GPU models). Notes idle-floor asymmetry (hermes/3090 ~127W vs
qwen/4090 ~20W) as a real optimizer input.

Harness is ready; the serving-config buildout + RAPL perm grant are the
remaining (ops, fox-directed) prerequisites to run the full matrix.
2026-05-20 14:45:49 -04:00
5260161e6f
feat(#000057): benchmark matrix doc (for David) + GPU wattage harness
Two deliverables for the cost/energy axis of the constraint optimizer.

docs/benchmark-matrix.md — shareable spec of the control experiment:
the question, fixture (386 office-holder Qs with corpus-vintage gold),
the 3-model × 3-framing × 2-arm matrix (18 cells), the verdict
vocabulary + two reads (accuracy vs grounding-fidelity), the
deterministic code judge + its Opus calibration, the results-so-far
table, and the NEW cost dimension (tokens / latency / GPU watts /
joules-per-answer measured per GPU tier). Self-contained — readable
cold by David.

bench/watt_bench.py — GPU wattage harness. Samples nvidia-smi
power.draw on the inference GPU while driving a small representative
subset, reports mean/peak watts, trapezoid-integrated joules,
joules-per-question, and joules-per-token. Tags the GPU
(--gpu-label 3090|4090) so the optimizer can compare hardware tiers.
Idle-baseline sampling separates load draw from idle. Does NOT grade
(energy is independent of correctness); saves answers + per-question
timing to JSONL for a later quality-per-joule pass via
score_with_code_judge.

Designed to run ON the GPU box (the orchestrator has no GPU; the
3090/4090 live on the inference boxes). Degrades gracefully when
nvidia-smi is absent (energy fields null) so it is testable anywhere.
Verified: PowerSampler graceful degradation + trapezoid integration
(synthetic 100->200->200W over 2s = 350 J, exact).

The headline cost finding the optimizer must weight: qwen-think
reasoning = 1300-3300 tokens/answer vs qwen-nothink ~50-100 (20-50x),
for a workload where arborist+qwen-nothink already lands 82% CG. The
energy numbers will quantify whether reasoning's premium is ever
justified — grounding-fidelity per joule, not per answer.
2026-05-20 12:33:58 -04:00
14cc9c891a
fix(#000057): raise reasoning-ref max_tokens to 8192 — the real empty-output cause
fox was right ("or you have defects still"): the arborist+qwen-think
empties were NOT an inherent reasoning limitation, they were
max_tokens set too low. Diagnosed with finish_reason + token usage on
a realistic large arborist-style context:

  max_tokens=1024 → 4/4 empty, ALL finish_reason='length', all hit
                    exactly 1024 tokens
  max_tokens=4096 → 0/4 empty, ALL finish_reason='stop', used
                    1339-3295 completion tokens

qwen-think spends 1300-3300 tokens on its (internal) reasoning trace
BEFORE emitting the tiny claim-lattice JSON. The arborist arm's budget
comes from DEFAULT_QUERY_POLICY["max_tokens"]=512 (correct for non-
reasoning single-line JSON), so reasoning refs hit finish='length'
mid-trace and return EMPTY. Fix: arb_policy["max_tokens"]=8192 for
reasoning refs (generous headroom over the 3295 observed ceiling).

This also CORRECTS a wrong claim in commit aa9d9c8's message: the
json-schema grammar does NOT suppress the reasoning trace. The
reasoning happens (it burns those 1300-3300 completion tokens); the
grammar only shapes the final emitted answer into schema-valid JSON.
So arborist+qwen-think is a genuinely distinct config, not a clone of
arborist+qwen-nothink. The earlier "grammar suppresses reasoning"
read was an artefact of toy-prompt diagnostics where reasoning fit
under 1024 tokens.

The empty-retry from aa9d9c8 stays as belt-and-suspenders for any
residual model-side empties, but with an adequate budget it should
rarely fire.

Cost note (fox's standing point, now quantified): reasoning = 1300-
3300 tokens/answer vs qwen-nothink's ~50-100 = 20-50x token cost. For
a workload where arborist+qwen-nothink already lands 82% CG, that cost
multiplier is the open question — not whether the cell is measurable
(it now is), but whether the marginal lift justifies 20-50x spend.
2026-05-20 12:03:39 -04:00
aa9d9c8277
fix(#000057): arborist+reasoning-model — clear JSON stop-seq + empty-output retry
Two defects blocked the arborist+qwen-think cell, both diagnosed
2026-05-20:

Defect 1 — stop sequence truncates to empty. The claim_lattice path
sets stop=['\n\n'] (runaway guard tuned for single-line Hermes JSON).
A reasoning model's output trips it immediately → 100% empty answers
→ 100% ABSTAINED (measured on the first 23 items of the killed run).
Fix: MODELS gains an explicit  flag; the arborist arm
clears claim_lattice_json_stop_sequences for reasoning refs. Direct
A/B confirmed: stop=['\n\n'] → ''; stop=None → valid JSON.

Defect 2 — intermittent empty completions. Even with the stop cleared,
qwen-think under json-schema grammar emits an empty completion ~1/3 of
calls (a llama.cpp reasoning+grammar artefact; qwen-nothink phase 3
had ~0 spurious empties). Fix: empty-output self-heal — reasoning refs
retry up to 3 attempts, burning the cached empty each retry. Never
fabricates: a still-empty answer after retries is recorded as empty.
6-item smoke: 0/6 (broken) → 5/6 valid JSON; residual ~1/6 are
questions that reliably break (4 consecutive empties), a documented
artefact.

Structural finding (to fold into Addendum 8): json-schema grammar
enforcement SUPPRESSES the reasoning trace — output is pure single-
line JSON, no <think> block. So arborist+qwen-think is structurally
≈ arborist+qwen-nothink; the thinking lever that moved the solo arm
is neutralized by the claim_lattice grammar. The re-run will confirm
empirically.

Non-reasoning refs (hermes, qwen-nothink) unchanged: single pass,
stop sequence intact.
2026-05-20 08:46:09 -04:00
5863559445
feat(#000057): --skip-solo flag — arborist-only sweeps without redundant solo data
fox 2026-05-19: 'we don't need to redo anything'. The arborist+qwen-
nothink sweep would re-run qwen-nothink solo on 1158 records that
phase 1 already produced (control_sweep_2026-05-19T21-52-56Z.jsonl,
calibrated rescore on disk). Wasteful — qwen-nothink solo behaviour
doesn't depend on retrieval being on, so phase 1's numbers stand.

--skip-solo runs ONLY the arborist arm. Implementation: skip the
per-variant model loop in _process_item when the flag is set; the
arborist arm below it still runs if arborist_on. The spend banner
zeroes the solo-call count so the operator sees the actual LLM
budget for the arborist-only run.

Example: a full arborist+qwen-nothink sweep against the 386-item
fixture goes from 2316 LLM calls (1158 solo + 1158 arborist) to
1158 calls (arborist only). Halves wall-clock on single-worker
llama.cpp.

pytest 27/27, imports clean, --help shows the flag.
2026-05-19 20:07:24 -04:00
866e67f43d
fix(#000057): code judge — short-entity-grounded fast path runs before NLI contradiction
Reorder rule 3 (short-answer entity grounding) above rule 4 (NLI
contradiction) so positive lexical evidence cannot be overridden by
NLI clause-level noise. Surfaced by the 2026-05-19 arborist+qwen-
nothink smoke:

  i=3 · who is the prime minister of Poland?
  ans: 'Donald Tusk is listed as the Prime Minister of Poland.'
  gold: ...lists Tusk + Marcinkiewicz + Belka + Kaczynski + Kopacz...
  NLI contradiction p=0.892  (above 0.85 threshold)
  NLI entailment   p=0.744   (also high on the correct clause)

Tusk WAS PM in 2010 (served 2007-2014); answer is correct against
the corpus-vintage gold. The NLI contradiction signal came from
clause-level candidate selection picking a NON-Tusk PM the source
also mentions; entailment was high on the Tusk clause. Mixed signal
that the WRONG rule then over-confidently resolved.

The fix is a rule reorder, not a threshold change — the fast path's
positive-evidence combination (specifics-in-gold AND subject-in-gold)
is a strictly stronger signal than NLI's clause-level max
contradiction, so when it fires it should win. The combination
discriminates Poland-Tusk (Tusk ∈ gold, Poland ∈ gold → CG) from
Anthony-Albanese (Albanese ∉ gold → fast path declines → falls
through to UNGROUNDED-subject-in-gold → WRONG, unchanged).

Self-test 4/4 INSTRUMENT TRUSTWORTHY unchanged. pytest 27/27.
Poland-Tusk regression smoke: now CG via short_entity_grounded ✓.

No regression risk on the existing reconciliation cells:
- Iceland CG: short_entity_grounded was already winning (was rule
  4, now rule 3 — same outcome, earlier exit)
- WWII-1812 WRONG: '1812' ∉ gold → fast path declines, NLI fires ✓
- Higgs-cafe FABRICATED: 'Higgs' ∉ gold → fast path declines ✓
- Anthony Albanese WRONG: 'Albanese' ∉ gold → fast path declines ✓
- Abstention phrases: rule 2 still fires first ✓
2026-05-19 20:03:21 -04:00
c6621ee700
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.
2026-05-19 19:53:00 -04:00
3450e8a281
fix(#000057): code judge unwraps Arborist claim-lattice JSON envelopes
The Arborist arm runs answer_mode='claim_lattice' (per control_sweep.py
:179, control_ab.py:155) so its answers arrive as the JSON envelope
  {"claims":[{"text":"...","evidence_ids":["E1"]},...]}.
_descaffold strips the [E1] evidence-pointer markup but the JSON
braces + key syntax remain. The verifier's strategy-2 (span) and
strategy-3 (proper-noun) extractors see brace noise instead of the
inner claim prose — every Arborist record degraded to UNGROUNDED.

The 2026-05-19T17-01-17Z sweep, re-graded with the freshly calibrated
judge (5a17f61), surfaced this: Arborist arm reported 0 CG across all
three variants in the live phase 1 output (the live run was pre-
calibration), and 29/120 CG (24%) under the calibrated rescore — clear
improvement just from theta_contra=0.85, but the JSON envelope was
still hobbling the verifier paths.

Fix: _unwrap_claim_lattice_json runs BEFORE all downstream rules.
Detection is conservative (three independent signals: starts-with-
brace AND "claims" key AND "text" key) so plain-prose answers
pass through unchanged. Multi-claim envelopes concatenate as discrete
sentences (extract_claim_spans treats each as its own span).
Malformed JSON falls back to the original answer — no silent
rewriting on broken input.

Smoke result on the Iceland Arborist case
  ans:  {"claims":[{"text":"The current president of Iceland is
         Ólafur Ragnar Grímsson.","evidence_ids":["E1"]}]}
  gold: {{Infobox Political post |post = President |body = Iceland
         |incumbent = [[Ólafur Ragnar Grímsson]] ...}}
  before: UNGROUNDED → FABRICATED (then WRONG after calibration)
  after:  short_entity_grounded → CORRECT_GROUNDED

pytest: 27/27 (added 7 unwrap-coverage tests covering single-claim
envelopes, multi-claim concatenation, plain-prose passthrough,
malformed-JSON tolerance, unrelated-JSON passthrough, and the
end-to-end Arborist-envelope CG flow). Self-test 4/4 unchanged.

Re-rescores of 17:01 sweep + phase 1 sweep run after this commit
to measure final Arborist scorecard improvement.
2026-05-19 18:53:17 -04:00
5a17f617e2
feat(#000057): reconcile code judge against Opus — 4 calibrated rules
Opus is trusted; this commit closes the systematic gaps surfaced by
B's rescore on the 17:01 sweep (2289 records, of which 468 had real
Opus verdicts). Off-diagonal cells in descending size and the
root-cause fix for each:

  cell n=114  code:WRONG  · opus:CORRECT_GROUNDED
    Root cause: NLI fires contradiction p in [0.5, 0.75] on factual
    answers like 'Ólafur Ragnar Grímsson is president of Iceland'
    against wikitext-shaped infobox gold — clause-level candidate
    selection picks up co-mentioned earlier office-holders, reads
    temporal-frame mismatch as contradiction. The TRUE contradiction
    signal (WW2 1812 self-test fixture) measures p=0.985 — clean
    margin above noise.
    Fix: raise theta_contra 0.5 → 0.85 (code-judge override of the
    NLI manifest's 0.5 contradiction_veto).

  cell n=60   code:FABRICATED  · opus:WRONG
    Root cause: 'Anthony Albanese' answer vs Julia Gillard gold gets
    FABRICATED (specifics-not-in-gold) but Opus correctly distinguishes
    WRONG (source contradicts by naming someone else) from
    FABRICATED (source silent on topic).
    Fix: when verifier UNGROUNDED + specifics not in gold AND the
    question's subject anchor IS in gold, demote FABRICATED → WRONG.
    Subject anchor uses proper-noun-shaped terms from the question
    (Iceland / Australia / Higgs) — not the last-content-token
    heuristic, which mis-fires on coincidental matches like 'cafe'
    appearing in a 'gold does not mention any cafe' denial.

  cell n=18   code:WRONG  · opus:ABSTAINED
    Root cause: abstention patterns missed Hermes's most common
    refusal phrasings — 'I do not have accurate information', 'I do
    not have access to a reference knowledge base', 'I lack access
    to'. Original patterns required determine/know/tell verbs right
    after 'do not'.
    Fix: three new patterns for the 'do not have / lack ...
    information / access / knowledge' family.

  cell n=13   code:ABSTAINED  · opus:CORRECT_GROUNDED
    Root cause: verifier's strategy-2 needs prose shape; terse-name
    answers ('Pratibha Patil', 'Jalal Talabani') fall to
    UNGROUNDED-no-specifics → ABSTAINED, missing valid CG.
    Fix: short-answer entity-grounding fast path. When answer is
    short (≤15 tokens) AND every specific asserted is present in
    gold (no unsourced) AND at least one specific WAS asserted AND
    the question's subject anchor is in gold → CG. Guards against
    'wrong topic, right name' false-positives via the subject check.

Structural reorder: NLI contradiction now runs AFTER the
abstention check and short-answer fast path (instead of preempting
the verifier), so the verifier's STRICT/HYBRID positive signal
isn't overridden by NLI noise. NLI still leads the path on truly
unbounded answers — verifier UNGROUNDED + NLI ≥ 0.85 contradiction
keeps the WRONG label.

Self-test 4/4 INSTRUMENT TRUSTWORTHY. pytest contract 18/18.
v2 rescore on the same 17:01 sweep runs in the background to
measure agreement-matrix improvement empirically.

Added bench/analyze_judge_disagreement.py — the harness that drove
this calibration (reads B's rescore JSONL, bucketises off-diagonal
cells, dumps configurable samples per cell with question / answer /
gold / both rationales). Reusable for the next calibration round.
2026-05-19 18:14:38 -04:00
7913012001
feat(#000057): bench/score_with_code_judge.py — code-judge rescore tool
Reads an existing sweep JSONL (Opus verdicts already recorded), re-fetches
gold per record via _gold(), runs the code judge on (question_asked,
answer, gold), and emits:

  - markdown scorecard: agreement matrix (code × original judge), per-arm
    / per-model / per-variant code-judge tallies, and a residue table of
    the JUDGE_ERROR records (the natural input to a later LLM-batch
    needle-haystack pass — Opus or Grok);
  - JSONL with one row per sweep record (code_verdict + code_rationale
    + code_decision), joinable on (i, arm, model, variant) to the
    source sweep.

Zero LLM calls. Reads sweep JSONL + shards read-only. Pairs with the
new --judge switch (a2e9b49): the switch decides what NEW data uses;
this script decides what the ALREADY-COLLECTED data looks like under
the deterministic judge.

Usage (parameter default matches control_sweep.py default fixture):
  python -m bench.score_with_code_judge --in <sweep>.jsonl

Currently running against control_sweep_2026-05-19T17-01-17Z.jsonl
(the 2289-record sweep that ran on the prior huge-N pass before the
Opus quota burned out). Output will land at
bench/qa_results/control_sweep_2026-05-19T17-01-17Z_code_judge.{md,jsonl}.
2026-05-19 17:46:52 -04:00
a2e9b49c0a
feat(#000057): --judge {code,opus} switch in control sweep/AB, default=code
Wire bench/judge_code.py into the sweep harnesses as the default judge.
Both control_sweep.py and control_ab.py grow a --judge {code,opus} CLI
arg; both share the same Verdict shape so the dispatch is a pointer
assignment + threading the judge_fn through _process_item.

Behaviour:
- DEFAULT = code: zero LLM, zero quota, deterministic. Self-test gate
  is the code judge's 4-fixture contract.
- --judge opus: original gated Opus path; needs ARBORIST_JUDGE_ENABLE=1
  set per 1cabfe6's fail-closed gate, otherwise every record returns
  JUDGE_ERROR with rationale 'disabled — set ARBORIST_JUDGE_ENABLE=1'
  and the sweep records that label honestly.

Reporting:
- Header line now records which judge ran ('Judge = code (...)' or
  'Judge = opus (...)') so partial-reports & resumes don't lie about
  provenance.
- Spend banner shows '0 LLM calls' for the code path so the no-burn
  property is visible in the operator output.

Test surface: pytest sweep across tests/ still 136/136 (no regressions);
new --judge flag visible in --help on both harnesses.

Next: bench/score_with_code_judge.py to re-grade existing sweep JSONLs
(written under the gated-Opus run) with the code judge; agreement
matrix surfaces residue size for the eventual LLM-batch needle-haystack.
2026-05-19 17:42:49 -04:00
f6a822ed8a
feat(#000057): code-only judge — deterministic, no LLM, no quota
bench/judge_code.py — drop-in alternative to bench/judge.py with the
same Verdict shape & closed verdict vocabulary (CG/W/F/A/JE) but zero
quota cost: composes verifier + NLI + abstention + specificity into a
fixed-order pipeline. fox 2026-05-19: 'data first, judging later' —
this is the data-collection arm; LLM-based judging (Opus batched
needle-haystack, or Grok credit-card) is a separate downstream
concern that operates on the residue this judge cannot classify
deterministically.

Pipeline (first hit decides):
  1. empty / no-gold guards
  2. explicit abstention phrases (lexical regex)
  3. NLI contradiction (arborist.qa.nli.shadow_check) — strongest
     signal: gold contradicts the claim → WRONG
  4. lexical verifier (arborist.qa.verify.verify_quotes) →
       STRICT                                  → CORRECT_GROUNDED
       HYBRID + NLI entail >= 0.55             → CORRECT_GROUNDED
       UNGROUNDED + specifics-not-in-gold      → FABRICATED
       UNGROUNDED + no specifics               → ABSTAINED
       HYBRID without NLI corroboration        → JUDGE_ERROR (residue
                                                  for an LLM judge)

Threshold note: _CODE_JUDGE_THETA_ENTAIL_CORROBORATE=0.55 is distinct
from the NLI manifest's entailment_block_veto=0.9. The manifest's
threshold is calibrated for OVERRIDING a STRICT lexical signal with
negative evidence — high bar. The corroboration use here is the
opposite direction: additive positive evidence on an already-positive
anchor — moderate bar appropriate. Self-test case 1 measures NLI
entail=0.769 (clearly entailed, clear margin above 0.55).

Specificity for FABRICATED layers three scanners:
  - verifier's multi-word proper-noun extractor (Higgs Boson, ...)
  - local single-word capitalised-token scanner (Napoleon, Mars, ...)
    deliberately separate because the verifier's gate is conservative
    by design (multi-word only)
  - numerics (years, dates, large counts, money)

Self-test: same 4 fixtures as bench/judge.py:self_test() so the two
instruments can be cross-checked when fox re-fires the Opus judge on
the residue later. Result: 4/4 INSTRUMENT TRUSTWORTHY.

tests/test_judge_code.py — pulls the contract into make test
(18 cases): module identifiers pinned, dataclass shape parity,
empty / no-gold guards, parametrised abstention phrases, specificity
layer behaviour, the canonical 4-case self-test, batch helper, and
graceful NLI-unavailable degradation. 18/18 pass.

Pre-existing known limitation, documented in the docstring: terse
correct answers ('In 1945.' against gold containing '1945') route to
ABSTAINED because the verifier's span extractor needs prose shape;
NLI sees no clause-level overlap at very short claims. The conservative
ABSTAINED label is correct deferral; tuning this is a calibration
question for real bench data, not the instrument's contract.

No callers touched yet — control_sweep.py & control_ab.py still
import the disabled Opus judge. Wiring this in is a separate ticket
move per fox's data-first sequencing.
2026-05-19 17:35:09 -04:00
1cabfe6850
feat(#000057): fail-closed Opus judge gate — ARBORIST_JUDGE_ENABLE=1 to run
2026-05-19: huge-N #000057 control sweep (f63b00d → 9dc02e4) burned
our Opus quota. Disable judge.py by default so a stray re-run can't
re-burn — every call short-circuits to JUDGE_ERROR with rationale
'disabled — set ARBORIST_JUDGE_ENABLE=1 ...' and zero subprocess
spawn (0ms in the disabled path, smoke-tested).

Why a gate, not a model swap:
- judge.py uses Opus deliberately as EXTERNAL SOTA outside both arms;
  swapping the judge to Hermes/Qwen would corrupt the experiment
  (Hermes is itself an arm under test). The hygiene comment at
  judge.py:26-29 already names same-family-judging as the live threat
  to validity at Opus level; downgrading further changes the science.
- Gating instead preserves the science when fox re-enables, and gives
  us the data-first workflow he asked for: deterministic tool
  pre-filters (verifier / NLI / recall@k) up front, judge only on
  residue worth Opus tokens, with explicit go.

Behaviour:
- control_sweep.py + control_ab.py already treat JUDGE_ERROR
  non-fatally (counted as JE in _bucket); disabled runs degrade to
  100% JE in the tally and surface the disable reason in rationale —
  the loudest possible 'judge did not run here' signal.
- Re-enable per-run: ARBORIST_JUDGE_ENABLE=1 python -m bench.control_sweep ...
- self_test() will report 4× JUDGE_ERROR when gated — intentional;
  if the instrument is off, the self-test must NOT silently pass.

Smoke-test (without flag): label='JUDGE_ERROR'  rationale='disabled — ...'
dt=0.0ms · no claude subprocess spawned.

Cross-referenced from CLAUDE.md '## Live endpoints' /
'Budget discipline' subsection added in 2365bd1.
2026-05-19 17:26:06 -04:00
9dc02e4a0b
feat(#000057): sweep --resume (skip-complete + last-wins dedupe)
fox: 'not 11 hours it shouldn't take that long'. Probed endpoints —
Qwen-27B absorbs 8 concurrent with 0 errors (0.5->2.1s); the
bottleneck is the serialized claude -p Opus judge, so more workers
≈ near-linear speedup. control_sweep.py gains --resume PATH: appends
to an existing JSONL, skips items already COMPLETE (full
models×variants for solo + variants for arborist if i<=arborist-n),
re-runs partial items; _aggregate now dedupes (i,arm,model,variant)
last-wins so a killed-mid-unit restart never double-counts, and
_load_recs tolerates a truncated trailing line from the kill.
Makefile control-sweep gains CONTROL_SWEEP_WORKERS / _RESUME / _ARB_N
so make stays the interface. 6-worker run killed cleanly (specific
pids, no pkill), relaunched resume @ 12 workers — 12 done items
preserved, 374 to run, ~5h -> ~2-2.5h.
2026-05-19 13:13:27 -04:00
12bb6dbb6f
feat(#000057): parallel + incremental + huge-N control sweep
fox: 'make the n huge huge, check in every ~7 turns'.

- stale fixture re-mined to the FULL pool: 386 questions (180 pres /
  119 PM / 34 premier / 20 gov-gen / 17 chancellor / 15 CM / 1 FM),
  deterministic.
- control_sweep.py rebuilt: ThreadPoolExecutor work-unit-per-item
  (per-unit qa_db -> zero SQLite write contention; shards read-only
  safe under concurrent readers), incremental flushed JSONL, and a
  --report-only PATH mode that aggregates a PARTIAL file with ZERO
  LLM/judge spend (the interim check-in path).
- huge N goes on the CONTROL (solo x3 models x3 framings) where the
  open statistical question lives; Arborist A/B = fixed --arborist-n
  (default 40), not re-measured 386x (power belongs on the control,
  and query() over ~40GB shards is the heaviest call).
- bench-maxing doctrine applied: independent hermetic judge calls
  fanned out, 'serial-by-caution is halting in disguise'.

N=3 (prior run) already shows the coherent, review-relevant story:
plain -> all 3 models confidently assert (0% abstain); source_relative
-> abstain jumps (hermes 33 / qwen-think 67 / qwen-nothink 100%);
as_of_corpus -> ~100% correct all models. The gap is largely
framing+snapshot, not a universal capability deficit.
2026-05-19 13:00:45 -04:00
f63b00d58e
feat(#000057): control-arm characterization sweep — model × framing
fox ruling: 'we both do not know which framing is right, measure all
benchmarks and bring results forward for review'; 'we have qwen with
and without reasoning to use'.

bench/control_sweep.py: sweeps the CONTROL (solo) arm across
{Hermes-3-8B, Qwen3.6-27B reasoning, Qwen3.6-27B no-reasoning} ×
{plain, source-relative, as-of-corpus-era} questions, judged vs the
fixed corpus-vintage gold; Arborist-Hermes treatment reference run
alongside at the same N. Presents the SAME judge verdicts under both
the accuracy framing (the naive read fox flagged as unfair-as-truth,
shown for contrast) and the grounding-fidelity framing (the
defensible read), plus an explicit note on the faithfulness-ablation
framing + Arborist×Qwen — both deliberately NOT run (proof-path
surgery / different instrument, not a relabelling).

Answers fox's open question empirically: does a 27B *reasoning*
control honestly ABSTAIN where the 8B fabricates (gap = weak-small-
model artefact) or still confidently assert post-corpus (gap = real,
scale-independent)? as_of_corpus separates 'can't recall the era'
from 'won't constrain to a source'.

Qwen toggles probed live: reasoning answer in message.content (CoT in
separate reasoning_content, not surfaced — logged limitation);
no-reasoning via chat_template_kwargs{enable_thinking:false}. Gate =
in-script judge self_test (aborts on fail) — stronger than a make
edge, no double self-test spend.
2026-05-19 12:45:22 -04:00
a4f3e126f7
feat(#000049 §7 #28): tinygrad NLI backend + deterministic engine-agreement A/B; ONNX-immunity rationale
Opt-in third NLI inference backend (ARBORIST_NLI_BACKEND=tinygrad) in
qa/nli/shadow.py, parallel to torch/onnx-int8, behind the #000049
cage: shadow-only, never an audit_mode input, never auto-preempts the
proven path (guarded so it cannot regress torch/onnx). Loads the ONNX
export through tinygrad's frontend wrapped to the existing
model(**enc).logits contract so _nli_batch is byte-unchanged.

bench/scripts/nli_backend_ab.py + make bench-nli-backends: deterministic
A/B, gate is numerical agreement with the torch reference first,
latency second (a divergent engine = a different shadow signal = a
different nli_policy_hash). Instrument is honest — reports a requested
backend as unavailable rather than relabelling a fallback's numbers.

First CPU-smoke run already quantified that the deployed §7 #22 int8
export diverges Δmax≈0.42 from torch — the immunity property made
measurable, not a defect. Real tinygrad numbers pending a producer-box
run (tinygrad not an arborist dep; frontend op-coverage for the large
MNLI checkpoints unverified by design).

docs/onnx-vendor-capture-immunity.md: why the model-in-proof-path cage
makes the inference engine an interchangeable sidecar, never a trust
dependency — public-domain positioning capital. Indexed in CLAUDE.md.

Full suite 2498 passed (identical to baseline); 24/24 NLI tests green.
2026-05-19 12:34:04 -04:00
031634c69c
fix(#000057): solo arm never ran — chat_completion(model=) was omitted
control_ab.py called client.chat_completion(messages) but model is a
keyword-only REQUIRED arg → TypeError every call → solo answer was
always '[solo-error: TypeError]', which the hermetic judge correctly
classified ABSTAINED. Every prior 'Hermes-solo abstained' smoke
(incl. the n=2 the ticket cites as a case against the treatment) was
a broken-control-arm artefact, not a measurement. Fix: pass
model=a.model (symmetry with the Arborist arm). Error token now
carries the exception message so the next failure self-describes.

Also adds the #000057 'stale' fabrication-bait mining class
(time-varying executive offices; corpus = ~2010-2011 Wikipedia
VERIFIED from the artifact, NOT the 2003 dump CLAUDE.md names —
flagged) and restores the numeral fixture deterministically.
2026-05-19 11:49:30 -04:00
e713c70dec
fix(#000057): restore numeral fixture clobbered by b573c59 (40 entries, not 5)
b573c59's accent-fold work removed 210 lines from
qa_questions_numeral_map.json (40 -> 5 entries) — collateral of an
earlier `mine_questions.py --cls numeral --limit 5` regression-check
that got committed. The control-ab run scored n=5 (not the --n 12
passed) because the fixture itself only had 5 rows. Re-mined at the
default --limit 40; deterministic mine yields a byte-identical
restoration of the a3ac653 original. Caught by reading the artifact,
not assuming.
2026-05-19 11:12:12 -04:00
1356459091
feat(#000057): control experiment harness — Hermes-solo vs Arborist, blinded Opus judge (smoke-verified)
bench/control_ab.py + `make control-ab` (gated on judge-self-test
as a make dependency — instrument gate cannot be skipped). Same
model both arms; gold = target-article text by target_root; Arborist
[E…] scaffolding stripped (blinding — format can't betray the arm);
Arborist UNGROUNDED credited as honest abstention; hermetic Opus
judge; deterministic aggregate; self-auditing JSONL; threats-to-
validity printed in the report.

N=2 smoke: clean end-to-end, 0 JUDGE_ERROR — and already surfaced a
case AGAINST the treatment (solo correctly ABSTAINED; Arborist
HYBRID-WRONG). The instrument can falsify the Arborist value claim;
that is the point. n=2 proves nothing (report says so) — verdict
needs a real N.
2026-05-19 08:58:38 -04:00
65fd9fad5d
feat(#000057): hermetic external judge instrument — built + verified 4/4 (make judge-self-test)
fox ruled judge = Opus via `claude -p`. bench/judge.py:
hermetic (`env -u CLAUDECODE claude -p`, fresh process, context =
only (Q, answer, gold) — no arm label, no Arborist context, no
session), blinded-by-caller, reference-grounded against the fixed
gold (ignore parametric knowledge), structured via FINAL_VERDICT=
sentinel parsed LAST-match.

Instrument-before-experiment gate worked: first cut parsed
first-match over the model's chain-of-thought → 0/3 self-test. The
judge REASONED correctly; the parser was the defect (+ two bad test
fixtures, my error). Hardened (sentinel contract + fixed fixtures),
re-verified: `make judge-self-test` = 4/4 on known-verdict triples
via real claude -p. The make target is the precondition gate; no
control run trusts the judge until it passes.

Threat to validity recorded, not hidden: same model family judging;
mitigated (blind + no-stake + reference-grounded) not eliminated —
different-family SOTA cross-check is the only full removal.

Next: bench/control_ab.py + `make control-ab` (Hermes-solo vs
Arborist, gold=target-article text, blinded, judged) — NOT yet
built; no broken make target shipped for it.
2026-05-19 08:44:47 -04:00
b573c592d8
feat(retrieval): accent-fold (+30pp recall@1) + fold-search factory hardening
Second MEASURED fold-search win, and the instrument correcting my own
premature call. accent-fold ON vs OFF on the mined accent fixture:
recall@1 55% -> 85% (+30pp), rank-1 22/40 -> 34/40. recall@8 was
flat (95->98) — a too-lenient k nearly got a real lever wrongly
reverted; @1/@3 is the resolution that drives primary-source
selection. _accent_fold_variants: ASCII-fold then re-tokenise so a
diacritic title ("Béla Bartók", which _TITLE_TOKEN_RE otherwise
fragments to junk) matches the ASCII form a user types. Additive+
symmetric, no-op on pure-ASCII (zero effect on non-accent
queries/titles), mirrors _hyphen_fold_variants (#000007).

Also fixes a defect I shipped in a3ac653: an orphaned duplicate
body left as dead code after `return base` in _title_query_tokens
(unreachable — numeral-fold behaviour/measurement were valid — but
cruft; removed).

Fold-search factory, fanned out across the full survey backlog
(deterministic recall, no LLM, parallel — serial-by-caution was
halting in disguise):
- recall_at_k.py: returns rank -> recall@1/@3/@k from one retrieval
  (verified offline). A coarse k hides rank-only lifts.
- mine_questions.py: numeral/accent/hyphen/honorific/amp/brit
  ground-truth classes; fixtures committed.
- Measured @1 headroom verdicts: accent SHIP (this commit);
  honorific 45% / brit 50% = real headroom (build next); hyphen
  90% = existing #000007 already delivers, NOTHING to build (the
  measure-the-unmeasured-thing check pays off); amp 82% = no fold
  needed (prevalence-overranked, instrument kills it cheaply).

CLAUDE.md bench-maxing: two measured lessons codified — report
recall@1/@3/@k (a lenient k hides rank lifts; prevalence != miss-
rate), and fan out independent measurements (serial-by-caution is
halting). Full suite 2488 passed, 0 regressions (accent-fold is
hot-path in _title_query_tokens); real-path test (FakeSource->
ingest->query()->real _Hit).
2026-05-18 19:23:22 -04:00
a3ac6539c1
feat(retrieval): numeral-fold (ordinal-word <-> Roman) + mined ground-truth eval instrument
The first MEASURED, above-noise retrieval win this thread. The 75-q
n=3 audit_mode bench couldn't resolve any single lever (every failure
class <=3-5 q, sub the 5pp floor — four hypotheses died there). Fix
the instrument, not just the lever:

- bench/mine_questions.py + bench/recall_at_k.py: mine questions from
  corpus titles (ground-truth target known by construction), grade by
  deterministic retrieval recall@k via `query --dry-run` — no LLM, no
  verifier, no n=3 noise, scalable to the 22K-deep numeral pool. The
  curated qa_questions.txt stays the separate verifier-honesty/trap
  gate; mined fixtures measure the answerable long tail per class.

- _numeral_fold_variants in query.py: ordinal-word ("Alexander the
  second") <-> multi-char Roman ("Alexander II"), additive+symmetric,
  unioned into _title_query_tokens exactly like _hyphen_fold_variants
  (#000007). Strict 2..40 Roman set → no English-word collision;
  single-char Romans (I/V/X) intentionally out of scope (universal
  len>1 token filter — stated before building, ~4 of 10 residual
  misses).

Measured on the mined numeral fixture: recall@8 22/40 (55%) -> 30/40
(75%), +20pp; 20 hits now rank-1. Discipline applied end to end:
measured-first, mirrored precedent, full-suite regression run (2482
passed, 0 regressions — numeral-fold is hot-path in
_title_query_tokens), real-path test (FakeSource->ingest->query()->
real _Hit, not a hand-built object), measured-after on a noise-free
instrument. The ~6 multi-char residual misses are a different
downstream cause the instrument now exposes for future iteration.
2026-05-18 15:16:10 -04:00
2c98fc964e
feat: cross-language Q&A (Operation Sandwich) + Windows quickstart — all default-OFF
Three workstreams, full suite 2482 passed, experimental paths default-OFF.

#000055 — Windows quickstart without make
  tasks.py (pure-stdlib runner) + make.bat shim + .gitattributes;
  README Windows section rewritten. Quickstart needs only Python
  3.10+ (no make/bzip2/curl/bash). Mirrors the Makefile quickstart
  subset; drift-pinned by tests/test_tasks_runner.py.

#000001 §7 Phase 0 — deterministic cross-language guard
  arborist/qa/crosslang.py: non-English signal (¿/¡/non-ASCII) + an
  es function-word stoppack. Fail-closed to UNGROUNDED before
  retrieval/LLM (mirrors the quantifier reject-DAG) when no content
  token survives, else strips es stopwords from the retrieval query
  only. English path byte-identical by construction. Default OFF
  (crosslang_guard_enabled). Measured: the anarcocapitalismo field
  case 10.4s -> 1.6s.

#000056 — Operation Sandwich (cross-language grounding)
  arborist/qa/mt/: opus-mt es/fr/ru<->en, lazy per-pair memoised
  singleton (fixes the 88%-engine-error concurrency defect),
  manifest-pinned, [mt] extra; entity_mask wrapper. Sandwich =
  translate query in (retrieval + LLM prompt) -> English answer ->
  UNTOUCHED verifier grounds English-vs-English -> translate the
  verified answer out as display-only (banner-labelled, zero
  grounding). question_hash + verifier_policy_hash invariant; MT
  engine identity binds into RetrievalPlan, not governance. CLI
  --crosslang-translate / make XLANG_MT=1. Default OFF; entity_mask
  default OFF (measured net-negative at bench scale). Fan-out bench
  (bench/*.py): Spanish ~0% -> 71% grounded vs the real no-support
  baseline; the round-trip predictor was tried and refuted; the
  entity-mask lever failed at scale (corpus-title anchoring untried).

CLAUDE.md: cross-language bright-line convention + module map.
Pre-existing modified diagram files are intentionally excluded.
2026-05-18 12:12:23 -04:00
7910440e50
#000052 §3.2.2 step 3 part C: multi-signal combination experiment — does NOT lift precision/recall on §3.2's failure shapes; clean architectural finding that lexical sidecars + relevance reranker cover NON-OVERLAPPING failure modes
Ran diagnose_deflection and diagnose_coherence on the 20+20 NEG
fixtures from step 3 parts A+B, plus all 808 STRICT.

Result: 0/40 NEG fire on diagnose_deflection (because the fixtures
are token-overlap-correct by construction — the question's subject
appears in the answer; that's exactly the failure mode #000052 was
built to catch beyond lexical). 0/40 on diagnose_coherence (the
fixtures are well-formed sentences).

So:
  UNION (relevance OR deflection) = relevance alone (no lift)
  INTERSECT (relevance AND deflection) = 0/40

Multi-signal combination doesn't help on these failure shapes.
But the architectural finding is positive in a different way: the
sidecars and the relevance reranker cover NON-OVERLAPPING failure
shapes cleanly:
  - diagnose_coherence: structural breakage (word-salad, vacuous,
    phrase-component-reuse). Owns the 'incoherent answer' slice.
  - diagnose_deflection: token-overlap mismatch (subject anchor not
    in answer). Owns the 'wholesale topic drift' slice.
  - bge-large relevance: semantic aboutness mismatch despite shared
    tokens. Owns the 'topic-collision / mis-cite / on-topic-but-
    not-answering' slice — what §3.2 was built for.

Each signal owns its own slice; combining is redundant on these
cases. That's the architectural validation of the §3.1 + §3.2 +
existing-lexical-sidecars split as orthogonal, not overlapping.

Manifest runtime_viability.as_multi_signal_factor updated from
'viable' to 'TESTED — does not lift; sidecars are complementary
not combinatorial on these shapes'. Cleaned up stale
nli-shadow-grid-n1-minilm.json.

Also cleaned up a stale nli-shadow-grid JSON.

The §3.2 arc is now complete:
  step 1 candidate-bench (overclaim, contrived data)
  step 2 real STRICT FP (over-pessimistic 'NOT VIABLE')
  step 3A real-context deflection (positive reversal, 55% at fp=0)
  step 3B real-context mis-cite (100% at fp=0, motivating failure covered)
  step 3C multi-signal combination (no lift; clean architectural split)
The recommended operating point holds: bge-reranker-large + cleaned
+ θ ≤ -2.42 → 100% mis-cite, 55% deflect, 0% STRICT FP.
2026-05-13 15:30:39 -04:00
902e676d4d
#000052 §3.2.2 step 3 part B: real-context MIS-CITE fixtures (n=20) — bge-large catches 100% of mis-cite at strictly 0% real-STRICT FP (the Zionist failure mode is fully covered with margin)
Built bench/fixtures/5f/relevance-miscite-realcontext-v1.jsonl —
20 hand-crafted (claim, source) mis-cite pairs: claim about X, source
about Y, X≠Y but shared tokens. Each pair survives the lexical
title-relevance + verifier sidecars by construction. This is the
Zionist-entity failure mode (claim about a different entity than the
cited source, both lexically related).

Examples:
  - 'Mercury is the smallest planet' / Roman-god Mercury source
  - 'Java is a programming language' / Java-the-island source
  - 'Apple Inc. was co-founded by Steve Jobs' / apple-the-fruit source
  - 'The Eiffel Tower is in Paris' / Gustave-Eiffel-person source
  - 'Mozart composed The Magic Flute' / Mozart-effect-theory source

Headline (bge-reranker-large + cleaned + θ ≤ -2.42):
  Mis-cite catch:    20/20 = 100%   ← FULL COVERAGE of motivating shape
  Deflection catch:  11/20 =  55%
  Combined NEG:      31/40 =  78%
  Real-STRICT FP:    0/808 = 0%     ← strictly safe

bge-large mis-cite scores: -9.37 to -3.81 (max). STRICT min: -2.42.
Mis-cite is STRICTLY SEPARABLE from real STRICT — there's a 1.4-pt
gap with no overlap. (Deflection harder; some overlap with weak STRICT.)

MiniLM-L-6 cost-pick (5× smaller, cleaned, θ=+3):
  100% mis-cite + 65% deflect + 0.4% STRICT FP

Mis-cite is structurally MUCH easier than deflection — both models
hit 95-100% mis-cite catch at modest θ; deflection is harder because
'answer doesn't quite address question' can look like a weak STRICT.
That's appropriate: mis-cite is 'wrong topic entirely'; deflection
is 'right topic, not answering'.

Manifest:
  - runtime_viability flipped (step 2 → step 3): NOT VIABLE → VIABLE
    at the bge-large fp=0 operating point.
  - demote_below_score still null pending fox+dav1d sign-off (setting
    it folds relevance_policy_hash into governance_policy_hash per
    #000049 §7 #2).
  - PRIMARY RECOMMENDATION: bge-reranker-large + cleaned +
    θ ≤ -2.42 → 100% mis-cite, 55% deflect, 0% STRICT FP.

Production verifier unchanged; still SHADOW. The §3.2 arc went:
candidate-bench (overclaim) → real STRICT step 2 (over-pessimistic
NOT VIABLE) → real-context deflection step 3 (positive reversal,
55% at fp=0) → real-context mis-cite step 3 part B (full closure
at fp=0 for the motivating failure mode). Eight meta-lesson
instances over the §000049 + §000052 arc, with the sharpest one
yet: clean candidate-bench can mis-predict in BOTH directions —
real-data fixtures on BOTH precision and recall axes are the
only load-bearing measurement.
2026-05-13 15:27:58 -04:00
7b1a57ade9
#000052 §3.2.2 step 3: real-context deflection fixtures (n=20) — verdict REVERSES: bge-large catches 55% of real deflections at strictly 0% real-STRICT FP, MiniLM-L-6 catches 65% at 0.4% FP. Runtime soft-veto IS viable.
Built bench/fixtures/5f/relevance-deflection-realcontext-v1.jsonl —
20 hand-crafted deflection answers against real bench-qa questions
(each: coherent well-formed answer using context-tokens, but NOT
addressing the question — the kind of failure §3.2 was built for).

Real-context deflection scores (cleaned, MiniLM-L-6):
  min -6.71, median +1.56, max +6.34
Real-context deflection scores (cleaned, bge-reranker-large):
  min -5.57, median -3.02, max +5.01

vs STRICT (cleaned):
  MiniLM-L-6: min -5.29, p10 +6.90, median +9.32
  bge-large:  min -2.42, p10 +3.56, median +6.48

Distributions are CLEAN-separable on real data — 0% of MiniLM
deflections score above p10(STRICT); bge-large median is -3.02 vs
STRICT median +6.48.

Pareto frontier (MiniLM-L-6, cleaned):
  θ=-5.29: catch 10% / fp 0%      (fp=0 floor, low signal but safe)
  θ=+3:    catch 65% / fp 0.4%    ← strong runtime soft-veto
  θ=+5:    catch 90% / fp 1.9%    ← aggressive soft-veto
  θ=+7:    catch 100% / fp 10.4%  (too FP for runtime)

bge-reranker-large:
  θ=-2.42: catch 55% / fp 0%      ← VIABLE runtime soft-veto at strict fp=0

The step-2 'NOT VIABLE' verdict was an artifact of using the
candidate-bench NEG distribution (tight contrived band, overlapped
real STRICT) as the recall denominator. Real-context deflections sit
in a much lower band (median around -3 for bge-large) than real
STRICT, so absolute thresholds DO separate them cleanly. The
relevance reranker IS a viable runtime soft-veto on this design —
the candidate-bench-only step 2 measurement misled us.

Manifest's runtime_viability.as_runtime_demotion_veto flipped from
'NOT VIABLE' to 'VIABLE at low-to-moderate FP', with both per-model
Pareto frontiers recorded. demote_below_score stays null pending
fox+dav1d sign-off; setting it folds relevance_policy_hash into
governance_policy_hash per #000049 §7 #2 discipline.

Still SHADOW; production verifier unchanged; no audit_mode effect.
Eighth instance of the meta-lesson, with the lesson sharpening AGAIN:
clean candidate-bench can MIS-PREDICT in BOTH directions — over-
optimistic on threshold (step 2) AND over-pessimistic on viability
(step 3 reversal). Real-data fixtures are the only load-bearing
denominators on either axis.
2026-05-13 15:11:27 -04:00
e4cc3293b5
#000052 §3.2.2 refinement: claim-lattice metadata cleaning — STRICT FP drops universally (-0.5 to -5.6 pts), L-2 down to 1.5%
Hand-inspection of the bottom-15 STRICT-fires from the raw §3.2.2 step 2
sweep showed claim-lattice overlay markup ([E\d+ | title | hash: '…'])
depressing scores on correct concise answers (the 6× Henry-VIII case),
while true-positive deflections (broad-question / narrow-answer like
'winners of all major sports?' → just-one-sport) remained correctly
low-scored. So the noise FP class is the bracket metadata; cleaning it
should reduce FP without losing true-positive signal.

Built clean_for_relevance() in arborist/qa/relevance/shadow.py — strips
[E\d+ | ... ] blocks + trailing '...']' tails. Baked into
ShadowRelevance.check_question_answer / check_claim_source by default
(opt out with clean_input=False). relevance_shadow_sweep.py applies it
to inputs before _score_batch (opt out with --no-clean).

Re-ran the full 6-model sweep on the 808-cell pooled STRICT with
cleaning:
  bge-reranker-large   21.8% → 18.6%  (-3.2)
  MiniLM-L-4-v2        15.0% →  9.4%  (-5.6 pts, -37% rel)
  MiniLM-L-6-v2        11.6% →  9.0%  (-2.6)
  MiniLM-L-12-v2       11.0% →  8.0%  (-3.0)
  bge-reranker-base     9.5% →  9.0%  (-0.5)
  MiniLM-L-2-v2         4.5% →  1.5%  (-3.0 pts, -67% rel)

Universal improvement, every model better. Big surprise: MiniLM-L-2-v2
— the model that FAILED the candidate-bench separability (margin
-1.97, declared 'capacity floor') — has the LOWEST real-traffic FP
rate at its own cb θ (1.5%). Because L-2's compressed score range
gives it a low cb θ which few real STRICT pairs score below.
SEVENTH instance of 'candidate-bench doesn't predict real-traffic'.

Runtime-veto verdict UNCHANGED — still not viable; smallest fp=0 θ on
real STRICT is below the cb NEG max for every model, so at any
runtime-safe θ the catch on cb NEG is 0/12. But cleaning is now FREE
improvement for any soft-signal / advisory / contrastive use of the
relevance score. Hand-inspected bottom-10 post-cleaning confirms true-
positive deflection signal preserved.
2026-05-13 15:01:50 -04:00
9fc951592d
#000052 §3.2.2 step 2: relevance shadow sweep on 808 pooled bench-qa STRICT — universal walk-back, runtime veto NOT VIABLE on this design
All 6 swept rerankers (bge-large/base, MiniLM-L-2/L-4/L-6/L-12)
false-fire on 4.5–21.8% of real bench-qa STRICT at their
candidate-bench fp=0 θ. The smallest θ that yields fp=0 on real
STRICT is BELOW the candidate-bench NEG max for every model —
meaning at the runtime-safe θ, catch on the 12 candidate-bench NEG
= 0/12 across the board.

Structural reason: real bench-qa STRICT answers have a much wider
score distribution (bge-large STRICT: min -2.20, p10 +2.99, p50
+5.80, p90 +7.54) than the tight contrived candidate-bench POS
band. The bottom 10% of legitimate STRICT score below where the
candidate-bench NEG cases sat. Distributions overlap heavily; no
threshold separates them.

This is the §3.2 mirror of #000049 §7 #27's recall-side walk-back —
clean candidate-bench → fails real-pipeline gate. Same diagnosis:
lexical-candidate selection + cross-encoder scoring + hard threshold
doesn't survive real-pipeline heterogeneity.

Verdict: relevance reranker CANNOT be promoted to a runtime
demotion-only veto on this design. demote_below_score stays null;
manifest gains runtime_viability block documenting the negative
result + the still-viable advisory soft-signal uses (render-tail,
multi-signal advisory, contrastive Q→A vs claim→source delta).

bench/scripts/relevance_shadow_sweep.py + bench/results/
relevance-shadow-sweep-pooled808.json committed. Production
verifier unchanged; advisory only; no audit_mode effect.
2026-05-13 14:17:38 -04:00
8fd74a2da3
#000052 §3.2.2 step 2: bench/scripts/relevance_shadow_sweep.py — score every pooled bench-qa STRICT (question, answer) pair with every manifest reranker; report score distribution + FP rate at the candidate-bench θ + smallest θ that yields fp=0 on the pool (the real-traffic-calibrated threshold) 2026-05-13 13:33:21 -04:00