Two coupled changes that wire the §13 Step 11 proposal stream from in-memory-and-discarded to persisted-and-harvestable. (A) emit_controller_events now writes a 4th event kind controller_falsification_proposal, one row per decision.falsification_proposals entry. Body carries branch_id + witness_divergence + reason; label column carries the reason for terminal-table inspection. Deterministic ordering by (branch_id, witness_divergence) so canonical-body hashes are stable. Idempotent under the existing UNIQUE (event_kind, body_hash) constraint. The audit-chain semantics remain unchanged (still a sibling table; no event_hash preimage entry). The CLI inspector's --kind choices gain the new event kind so operators can filter for it directly. (B) bench/scripts/harvest_falsification_proposals.py gains a third source bucket CONTROLLER_PROPOSAL alongside the existing HYBRID + UNGROUNDED providence_cache buckets. Reads controller_falsification_proposal rows from controller_events, extracts the 16-char cache_key prefix from branch_id (qa:<prefix> pattern from the QA-runner advisory), joins back to providence_cache for fixture enrichment (answer_text + audit_mode + verifier_method), and tags _harvest_meta.harvested_from = "controller_events" so the two source paths stay distinguishable in the fixture pack. Dedup against the providence_cache buckets by fixture id. Today this typically yields 0 new fixtures because (i) no live QA has fired since Phase 2 wiring landed, and (ii) the QA-runner single-branch advisory's proposals overlap providence_cache content the harvester already finds. Real net value comes from Phase 3 (#000045) sweep emissions, which will produce multi-branch chunk proposals that providence_cache rows can't predict. Tests: 3 new in tests/test_prometheus_audit.py (proposal-row emission, no-proposal no-row, idempotency); 1 new in tests/test_bench_batteries.py (synthetic qa.db with both providence_cache + controller_events rows; asserts the controller_events bucket surfaces a fixture invisible to the divergence-thresholded providence_cache buckets). The pre-existing harvested-pack-runs-clean test relaxes its harvested_from pin from "providence_cache"-only to {"providence_cache", "controller_events"}.
376 lines
14 KiB
Python
376 lines
14 KiB
Python
"""Harvest the §13-Step-11 falsification-fixture proposals from a live
|
|
shard into a deterministic 5F fixture pack.
|
|
|
|
Closes the Phase 1 → 5F battery loop (#000037 Phase 1 produces
|
|
:class:`FalsificationFixtureProposal` records; #000025 Phase 1e named
|
|
the documented motif set). The dry-run simulator
|
|
(``bench/scripts/prometheus_sigma_sweep_dryrun.py``) showed 576
|
|
high-divergence rows in ``~/.arborist/shards/qa.db`` at τ_qa=1d.
|
|
This script picks a representative deterministic sub-sample of those
|
|
rows and writes embedded fixture entries to a NEW pack at
|
|
``bench/fixtures/5f/falsification-harvested-v1.jsonl``.
|
|
|
|
The harvest is intentionally separate from
|
|
``falsification-v1.jsonl`` so hand-curated motif fixtures and
|
|
corpus-derived regression fixtures live in distinct packs with
|
|
distinct attribution. The harness test asserts the harvested pack
|
|
runs clean independently.
|
|
|
|
Sample policy (`SAMPLE_PER_BUCKET = 20`):
|
|
|
|
- HYBRID rows with ``witness_divergence ≥ 0.5``: top 20 by
|
|
``cache_key`` (lex-sorted for determinism).
|
|
- UNGROUNDED rows with ``witness_divergence ≥ 0.5``: top 20 by
|
|
``cache_key``.
|
|
- CONTROLLER_PROPOSAL — rows pulled from the live
|
|
``controller_events`` stream (event_kind =
|
|
``controller_falsification_proposal``, persisted by Phase 2 audit
|
|
writes). Today's QA-runner advisory writes a single-branch
|
|
decision per QA cycle whose proposals overlap providence_cache;
|
|
Phase 3 sweep emissions will grow this bucket independently.
|
|
Top 20 by branch_id, joined back to providence_cache by 16-char
|
|
cache_key prefix for fixture enrichment. Empty bucket (zero
|
|
rows) is the default state until live QA accumulates proposals.
|
|
|
|
The first two buckets produce 40 corpus-derived fixtures by default;
|
|
the third grows the pack as the controller_events stream fills.
|
|
Idempotency on re-harvest is the caller's job (the runner
|
|
truncates+rewrites by default).
|
|
|
|
Mapping cache row → fixture:
|
|
|
|
- ``id``: ``5f-fal-harvested-<first-16-of-cache_key>``
|
|
- ``answer_text``: preserved verbatim from the row (helps human
|
|
readability when debugging regressions).
|
|
- ``observed_violations``: derived from audit_mode + unverified-
|
|
quote count.
|
|
- ``expected_reason``: the audit_mode itself (UNGROUNDED /
|
|
HYBRID_<method>).
|
|
- ``verifier_method_root``: from the row's ``verifier_method``
|
|
column (falls back to ``"warrant-v1"`` if absent).
|
|
- ``expected``: ``"pass"``.
|
|
|
|
The fixtures route through the existing embedded-mode runner
|
|
(``arborist.qa.verify.verify_quotes`` is NOT called — see the
|
|
``"answer_text" in task and "context" in task`` branch in
|
|
``bench/batteries/b_5f.py:run_falsification``). The runner sees
|
|
``observed_violations`` directly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sqlite3
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
DEFAULT_QA_DB = Path.home() / ".arborist" / "shards" / "qa.db"
|
|
DEFAULT_OUT = (
|
|
Path(__file__).resolve().parents[2]
|
|
/ "bench"
|
|
/ "fixtures"
|
|
/ "5f"
|
|
/ "falsification-harvested-v1.jsonl"
|
|
)
|
|
DEFAULT_DIVERGENCE_THRESHOLD = 0.5
|
|
SAMPLE_PER_BUCKET = 20
|
|
ANSWER_TEXT_TRUNCATE = 512
|
|
|
|
|
|
def _row_divergence(row: sqlite3.Row) -> float:
|
|
n_q = int(row["n_quotes"] or 0)
|
|
uv_raw = row["unverified_quotes"]
|
|
n_uv = 0
|
|
if uv_raw:
|
|
try:
|
|
n_uv = len(json.loads(uv_raw))
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
n_uv = 0
|
|
return (n_uv / n_q) if n_q > 0 else 0.0
|
|
|
|
|
|
def _build_fixture(row: sqlite3.Row, divergence: float) -> dict:
|
|
"""Map a providence_cache row to an embedded 5F falsification fixture."""
|
|
cache_key = row["cache_key"]
|
|
audit_mode = (row["audit_mode"] or "UNKNOWN").upper()
|
|
verifier_method = (row["verifier_method"] or "warrant-v1").lower()
|
|
|
|
# Observed violations honest to the actual row data:
|
|
# - UNGROUNDED audit_mode → "UNGROUNDED"
|
|
# - HYBRID audit_mode → "HYBRID_<METHOD>" (matches the live
|
|
# runner's emission shape — _live_falsification_violations
|
|
# emits HYBRID_PARAPHRASE / HYBRID_QUOTE etc.).
|
|
# - Any unverified quotes → also "UNVERIFIED_QUOTE".
|
|
observed: list[str] = []
|
|
if audit_mode == "UNGROUNDED":
|
|
expected_reason = "UNGROUNDED"
|
|
observed.append("UNGROUNDED")
|
|
elif audit_mode == "HYBRID":
|
|
expected_reason = f"HYBRID_{verifier_method.upper()}"
|
|
observed.append(expected_reason)
|
|
else:
|
|
# Should not happen given the stratification filter (we only
|
|
# harvest HYBRID + UNGROUNDED), but stay honest if it does.
|
|
expected_reason = audit_mode
|
|
observed.append(audit_mode)
|
|
|
|
if (row["unverified_quotes"] or "") not in ("", "[]"):
|
|
observed.append("UNVERIFIED_QUOTE")
|
|
|
|
answer_text = row["answer_text"] or ""
|
|
if len(answer_text) > ANSWER_TEXT_TRUNCATE:
|
|
answer_text = answer_text[: ANSWER_TEXT_TRUNCATE - 3] + "..."
|
|
|
|
return {
|
|
"id": f"5f-fal-harvested-{cache_key[:16]}",
|
|
"battery": "5f",
|
|
"sub_battery": "falsification",
|
|
"version": "v1",
|
|
"carrier": "providence_record",
|
|
"domain": "claim_lattice",
|
|
"pi_star_ref": "claim-lattice@v1",
|
|
"answer_text": answer_text,
|
|
"observed_violations": observed,
|
|
"expected_reason": expected_reason,
|
|
"verifier_method_root": verifier_method,
|
|
"expected": "pass",
|
|
"_harvest_meta": {
|
|
"cache_key": cache_key,
|
|
"witness_divergence": round(divergence, 4),
|
|
"audit_mode_at_harvest": audit_mode,
|
|
"harvested_from": "providence_cache",
|
|
"harvest_threshold": DEFAULT_DIVERGENCE_THRESHOLD,
|
|
"source_ticket": "#000037 §13 step 11",
|
|
},
|
|
}
|
|
|
|
|
|
def _iter_controller_proposal_keys(
|
|
conn: sqlite3.Connection,
|
|
) -> list[tuple[str, float, str]]:
|
|
"""Pull (cache_key_prefix, witness_divergence, organism_root) tuples
|
|
from the ``controller_falsification_proposal`` stream.
|
|
|
|
Returns rows where ``branch_id`` matches the QA-runner-advisory
|
|
pattern ``qa:<cache_key[:16]>``. Sorted by branch_id for
|
|
deterministic harvest output. Empty list if the table is missing
|
|
or no proposal rows exist.
|
|
"""
|
|
has_table = conn.execute(
|
|
"SELECT name FROM sqlite_master "
|
|
"WHERE type='table' AND name='controller_events'"
|
|
).fetchone()
|
|
if not has_table:
|
|
return []
|
|
out: list[tuple[str, float, str]] = []
|
|
for row in conn.execute(
|
|
"SELECT branch_id, body_blob, organism_root "
|
|
"FROM controller_events "
|
|
"WHERE event_kind = 'controller_falsification_proposal' "
|
|
"ORDER BY branch_id"
|
|
):
|
|
bid = row["branch_id"] or ""
|
|
if not bid.startswith("qa:"):
|
|
continue
|
|
prefix = bid[3:]
|
|
try:
|
|
body = json.loads(row["body_blob"])
|
|
except (TypeError, ValueError, json.JSONDecodeError):
|
|
continue
|
|
div = float(body.get("witness_divergence", 0.0))
|
|
out.append((prefix, div, row["organism_root"] or ""))
|
|
return out
|
|
|
|
|
|
def _lookup_providence_by_prefix(
|
|
conn: sqlite3.Connection, cache_key_prefix: str
|
|
) -> sqlite3.Row | None:
|
|
"""Return the providence_cache row whose cache_key starts with
|
|
``cache_key_prefix`` (16-char branch_id suffix). None if no match."""
|
|
return conn.execute(
|
|
"SELECT cache_key, audit_mode, n_quotes, n_verified, "
|
|
" unverified_quotes, verifier_method, falsification_state, "
|
|
" answer_text "
|
|
"FROM providence_cache "
|
|
"WHERE cache_key LIKE ? "
|
|
"ORDER BY cache_key LIMIT 1",
|
|
(cache_key_prefix + "%",),
|
|
).fetchone()
|
|
|
|
|
|
def harvest(
|
|
qa_db: Path,
|
|
threshold: float = DEFAULT_DIVERGENCE_THRESHOLD,
|
|
sample_per_bucket: int = SAMPLE_PER_BUCKET,
|
|
) -> list[dict]:
|
|
"""Return a list of fixture dicts harvested from qa_db.
|
|
|
|
Three buckets:
|
|
- ``HYBRID`` — providence_cache rows w/ divergence ≥ threshold
|
|
- ``UNGROUNDED`` — providence_cache rows w/ divergence ≥ threshold
|
|
- ``CONTROLLER_PROPOSAL`` — live controller_events stream
|
|
(event_kind = ``controller_falsification_proposal``), joined
|
|
back to providence_cache for fixture enrichment.
|
|
|
|
Each bucket is independently capped at ``sample_per_bucket`` rows
|
|
sorted deterministically (cache_key for the first two, branch_id
|
|
for the third).
|
|
"""
|
|
if not qa_db.exists():
|
|
raise FileNotFoundError(f"qa.db not found: {qa_db}")
|
|
|
|
conn = sqlite3.connect(f"file:{qa_db}?mode=ro", uri=True)
|
|
conn.row_factory = sqlite3.Row
|
|
|
|
buckets: dict[str, list[tuple[float, sqlite3.Row]]] = {
|
|
"HYBRID": [],
|
|
"UNGROUNDED": [],
|
|
}
|
|
|
|
for row in conn.execute(
|
|
"SELECT cache_key, audit_mode, n_quotes, n_verified, "
|
|
" unverified_quotes, verifier_method, falsification_state, "
|
|
" answer_text "
|
|
"FROM providence_cache "
|
|
"WHERE falsification_state = 'live' "
|
|
"ORDER BY cache_key"
|
|
):
|
|
div = _row_divergence(row)
|
|
if div < threshold:
|
|
continue
|
|
audit_mode = (row["audit_mode"] or "UNKNOWN").upper()
|
|
if audit_mode in buckets:
|
|
buckets[audit_mode].append((div, row))
|
|
|
|
fixtures: list[dict] = []
|
|
for mode in sorted(buckets):
|
|
rows = buckets[mode][:sample_per_bucket]
|
|
for div, row in rows:
|
|
fixtures.append(_build_fixture(row, div))
|
|
|
|
# Third bucket — live controller_events proposal stream. Today
|
|
# this typically yields 0 rows (Phase 2 wiring landed but live QA
|
|
# may not have accumulated proposals yet); Phase 3 sweep emissions
|
|
# grow it. Dedup against the providence_cache buckets above by
|
|
# fixture id (cache_key prefix collision → same id → set membership).
|
|
seen_ids = {fx["id"] for fx in fixtures}
|
|
proposals = _iter_controller_proposal_keys(conn)
|
|
cp_taken = 0
|
|
for prefix, div, organism_root in proposals:
|
|
if cp_taken >= sample_per_bucket:
|
|
break
|
|
prov_row = _lookup_providence_by_prefix(conn, prefix)
|
|
if prov_row is None:
|
|
continue
|
|
fx = _build_fixture(prov_row, div)
|
|
# Tag the fixture's _harvest_meta to surface the controller-
|
|
# stream provenance — distinguishes it from a pure providence-
|
|
# cache harvest of the same row.
|
|
fx["_harvest_meta"]["harvested_from"] = "controller_events"
|
|
fx["_harvest_meta"]["controller_organism_root"] = organism_root
|
|
if fx["id"] in seen_ids:
|
|
continue
|
|
seen_ids.add(fx["id"])
|
|
fixtures.append(fx)
|
|
cp_taken += 1
|
|
|
|
conn.close()
|
|
return fixtures
|
|
|
|
|
|
def write_pack(fixtures: list[dict], out_path: Path) -> None:
|
|
"""Write the fixture pack with a _meta header line."""
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
meta = {
|
|
"_meta": {
|
|
"battery": "5f",
|
|
"sub_battery": "falsification",
|
|
"version": "v1",
|
|
"task_count": len(fixtures),
|
|
"notes": (
|
|
"Corpus-derived 5F falsification fixtures harvested "
|
|
"from two sources: (i) #000037 Phase 1 divergence-"
|
|
"based providence_cache rows (HYBRID + UNGROUNDED "
|
|
"audit modes, witness_divergence >= "
|
|
f"{DEFAULT_DIVERGENCE_THRESHOLD}, top-"
|
|
f"{SAMPLE_PER_BUCKET}-by-cache_key per bucket); (ii) "
|
|
"live controller_events stream (event_kind = "
|
|
"controller_falsification_proposal, persisted by "
|
|
"Phase 2 audit writes), branch_id-keyed with 16-char "
|
|
"cache_key prefix join back to providence_cache for "
|
|
"fixture enrichment. The third source typically yields "
|
|
"0 rows until Phase 3 sweep emissions populate it. "
|
|
"STRICT and CANONICAL_PROJECTION rows have divergence "
|
|
"~0 by construction and are excluded. Embedded runner "
|
|
"path: `observed_violations` is the verifier-shape "
|
|
"signal; `answer_text` preserved verbatim for "
|
|
"debugging. Regenerate via `make bench-5f-harvest`."
|
|
),
|
|
}
|
|
}
|
|
|
|
with out_path.open("w", encoding="utf-8") as f:
|
|
f.write(json.dumps(meta) + "\n")
|
|
for fx in fixtures:
|
|
f.write(json.dumps(fx) + "\n")
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
p = argparse.ArgumentParser(
|
|
description="Harvest #000037 Phase 1 falsification proposals "
|
|
"into a 5F fixture pack."
|
|
)
|
|
p.add_argument(
|
|
"--qa-db",
|
|
type=Path,
|
|
default=DEFAULT_QA_DB,
|
|
help=f"path to qa.db shard (default: {DEFAULT_QA_DB})",
|
|
)
|
|
p.add_argument(
|
|
"--threshold",
|
|
type=float,
|
|
default=DEFAULT_DIVERGENCE_THRESHOLD,
|
|
help="witness_divergence threshold for inclusion (default: 0.5)",
|
|
)
|
|
p.add_argument(
|
|
"--sample-per-bucket",
|
|
type=int,
|
|
default=SAMPLE_PER_BUCKET,
|
|
help="how many rows per audit_mode bucket (default: 20)",
|
|
)
|
|
p.add_argument(
|
|
"--out",
|
|
type=Path,
|
|
default=DEFAULT_OUT,
|
|
help=f"output fixture pack (default: {DEFAULT_OUT})",
|
|
)
|
|
args = p.parse_args(argv)
|
|
|
|
fixtures = harvest(
|
|
args.qa_db,
|
|
threshold=args.threshold,
|
|
sample_per_bucket=args.sample_per_bucket,
|
|
)
|
|
write_pack(fixtures, args.out)
|
|
|
|
sys.stderr.write(
|
|
f"Wrote {len(fixtures)} fixtures to {args.out}\n"
|
|
)
|
|
by_mode: dict[str, int] = {}
|
|
by_source: dict[str, int] = {}
|
|
for fx in fixtures:
|
|
m = fx["_harvest_meta"]["audit_mode_at_harvest"]
|
|
by_mode[m] = by_mode.get(m, 0) + 1
|
|
src = fx["_harvest_meta"].get("harvested_from", "providence_cache")
|
|
by_source[src] = by_source.get(src, 0) + 1
|
|
for mode, n in sorted(by_mode.items()):
|
|
sys.stderr.write(f" {mode}: {n}\n")
|
|
sys.stderr.write("by source:\n")
|
|
for src, n in sorted(by_source.items()):
|
|
sys.stderr.write(f" {src}: {n}\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|