Files
vh 2111b1e824 fix(diagnostics): fresh-session + quote-fold in fiction_wing_probe
Two bugs R42 (brokkr-smithy-dev) surfaced on first live-index contact:

1. Session-reuse degradation. run_yardstick/run_term reused one mimir
   session across terms; mimir returns EMPTY search_library results after
   a session's first query (Worldtree #391), silently scoring every later
   term a false-MISS. Fixed by making search_library and reference_knowledge
   self-session (fresh session per call) so no caller can re-hoist it. Live
   yardstick now reproduces all four anchors HIT top-10. Fresh-session-per-
   query is the pinned arm-2 protocol; folded into the conventions docstring.

2. Curly-vs-ASCII apostrophe. _on_target substring-matched raw ASCII while
   the b170 extraction stores U+2019, so possessive-named subjects
   false-MISSed. _on_target now NFKC-normalizes + quote-folds both sides
   (NFKC alone does not fold U+2019, so the explicit fold is load-bearing).

Adds tests/test_fiction_wing_probe.py covering the apostrophe fold both
directions with a negative control.
2026-08-04 17:00:52 -07:00

199 lines
9.0 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Fiction-wing retrieval probe harness — the recipe R42 (brokkr-smithy-dev) builds against
and the re-runnable #389 acceptance gate.
Two retrieval paths, kept SEPARATE because they have different noise properties:
* search_library (raw, RANKING-clean): drive the `mimir` foundational agent (all-wing
librarian) with a FIXED query string. Deterministic against a fixed index — use it for
ranking baselines (R42 arm-2). No LLM in the query loop.
* reference_knowledge (the Tier-3 consumer path): drive `ratatoskr:donut`; her reasoning
REFORMULATES the query each turn, so this path carries QUERY-FORMULATION variance
(the arm-4 signal), attributable via the captured tool_start query. NOT for ranking numbers.
Scoring conventions (identical across both paths and all R42 arms):
* confidence BUCKET vs WT's RRF thresholds: high >= 0.030, medium >= 0.016, low < 0.016.
* ON-TARGET (load-bearing): a returned row is on-target iff its excerpt actually NAMES or
describes the queried subject (keyword match on the subject's distinctive tokens). The
failure signature "10 hits / MEDIUM / 0 on-target" = present-by-topic, subject absent —
the split that separated #384 (packaging) / #387 (coverage) / #389 (ranking).
* MISS = no on-target row in the returned top-k.
Noise floor: freeze the generation (pin the b-tag) to remove extraction variance; fixed-string
search_library is deterministic (no CI needed); reference_knowledge variance is query-
formulation, not floor noise. Residual = bucket-boundary sensitivity at 0.016/0.030 — so probe
N>=3-5 times per term and report the bucket DISTRIBUTION, never a single-run point label.
Session protocol: ONE fresh session per query. A reused mimir session returns EMPTY
search_library results after its first turn (Worldtree #391), silently scoring later terms
false-MISS; the retrieval helpers self-session to enforce it. Never hoist the session out.
Config from env (source ratatoskr's env.sh): WORLDTREE_API_URL, WORLDTREE_API_KEY,
RATATOSKR_END_USER_ID. No secrets are stored here.
Usage:
uv run python docs/diagnostics/fiction_wing_probe.py # run the artifact yardstick
uv run python docs/diagnostics/fiction_wing_probe.py --runs 5 # N repeats -> bucket distribution
uv run python docs/diagnostics/fiction_wing_probe.py --term "Enhanced Pet Biscuit" --keywords biscuit
"""
from __future__ import annotations
import argparse
import json
import os
import unicodedata
from collections import Counter
import httpx
HIGH, MEDIUM = 0.030, 0.016 # WT RRF confidence thresholds
def _cfg() -> tuple[str, dict, str]:
base = os.environ.get("WORLDTREE_API_URL", "http://10.250.50.152:8081")
key = os.environ.get("WORLDTREE_API_KEY")
if not key:
raise SystemExit("WORLDTREE_API_KEY unset — source env.sh first.")
end_user = os.environ.get("RATATOSKR_END_USER_ID", "ratatoskr-tui")
return base, {"Authorization": f"Bearer {key}"}, end_user
def _bucket(score: float | None) -> str:
if score is None:
return "none"
return "high" if score >= HIGH else "medium" if score >= MEDIUM else "low"
# Curly punctuation the b170 extraction emits (U+2019 etc.) folded to ASCII so a
# possessive-named subject ("Darcy's letter") matches regardless of quote style.
_QUOTE_FOLD = str.maketrans({
"": "'", "": "'", # noqa: RUF001 - single curly quotes / apostrophe
"": '"', "": '"', # double curly quotes
"": "'", "": '"', # noqa: RUF001 - primes
})
def _fold(s: str) -> str:
"""NFKC-normalize, fold curly quotes/apostrophes to ASCII, lowercase.
NFKC alone does NOT fold U+2019, so the explicit quote-fold is load-bearing."""
return unicodedata.normalize("NFKC", s or "").translate(_QUOTE_FOLD).lower()
def _on_target(excerpt: str, keywords: list[str]) -> bool:
ex = _fold(excerpt)
return any(_fold(k) in ex for k in keywords)
def _session(base: str, headers: dict, agent_id: str, end_user: str) -> str:
r = httpx.post(f"{base}/sessions", json={"agent_id": agent_id, "end_user_id": end_user},
headers=headers, timeout=30)
return r.json()["session_id"]
def _drive(base: str, headers: dict, sid: str, content: str) -> tuple[str | None, dict]:
"""POST a turn, return (tool_query, tool_result_dict). tool_result is the first tool packet."""
tool_query, result = None, {}
with httpx.stream("POST", f"{base}/sessions/{sid}/messages", json={"content": content},
headers=headers, timeout=120) as r:
for line in r.iter_lines():
if not line.startswith("data: "):
continue
ev = json.loads(line[6:])
t = ev.get("type")
if t == "tool_start" and tool_query is None:
tool_query = (ev.get("arguments") or {}).get("query")
elif t == "tool_result" and not result:
result = ev.get("result") if isinstance(ev.get("result"), dict) else {}
elif t == "done":
break
return tool_query, result
def search_library(base, headers, end_user, term, keywords):
"""RANKING-clean path: fixed-string search over the mimir librarian. Deterministic.
Opens a FRESH mimir session per call — REQUIRED. mimir stops returning
search_library results after the first turn on a reused session (Worldtree #391),
silently scoring every later term a false-MISS; do not hoist the session to the caller.
"""
msid = _session(base, headers, "mimir", end_user)
_, res = _drive(base, headers, msid, f"Use search_library to find: {term}")
rows = res.get("results", []) if isinstance(res, dict) else []
on = [h for h in rows if isinstance(h, dict) and _on_target(h.get("excerpt", ""), keywords)]
top = on[0] if on else None
score = round(top["score"], 4) if top else None
return {"n": len(rows), "on_target": len(on), "hit": bool(on),
"score": score, "bucket": _bucket(score),
"excerpt": (top.get("excerpt", "")[:140] if top else None)}
def reference_knowledge(base, headers, end_user, question, keywords):
"""Consumer path: Donut reformulates -> capture her tool_query. NOT for ranking numbers.
Fresh donut session per call (same reuse-degradation guard as search_library, and
it keeps each run an independent first-turn sample rather than a growing conversation).
"""
dsid = _session(base, headers, "ratatoskr:donut", end_user)
q, res = _drive(base, headers, dsid, question)
hits = res.get("hits", []) if isinstance(res, dict) else []
on = [h for h in hits if isinstance(h, dict) and _on_target(h.get("excerpt", ""), keywords)]
return {"tool_query": q, "n": len(hits), "on_target": len(on),
"confidence": res.get("confidence") if isinstance(res, dict) else None}
# Artifact yardstick — worldtree-dev grep-confirmed in DCC book-1. Frozen arm-2 baseline.
YARDSTICK = [
("Enchanted Crown of the Sepsis Whore", "Crown of the Sepsis Whore", ["sepsis", "crown"]),
("Enhanced Pet Biscuit", "Pet Biscuit", ["biscuit"]),
("Enchanted BigBoi Boxers", "BigBoi Boxers", ["boxers", "bigboi"]),
("Enchanted Toe Ring of the Splatter Skunk", "Toe Ring of the Splatter Skunk",
["toe ring", "splatter", "skunk"]),
]
def run_yardstick(runs: int) -> None:
base, headers, end_user = _cfg()
print(f"# Fiction-wing ranking yardstick (search_library, {runs} run(s) per name)\n")
for full, partial, kw in YARDSTICK:
for label, term in (("full ", full), ("part ", partial)):
buckets, hits = Counter(), 0
for _ in range(runs):
r = search_library(base, headers, end_user, term, kw)
buckets[r["bucket"]] += 1
hits += r["hit"]
dist = " ".join(f"{b}:{c}" for b, c in buckets.most_common())
print(f" [{label}] {term:<42} hit {hits}/{runs} buckets({dist})")
print()
def run_term(term: str, keywords: list[str], runs: int) -> None:
base, headers, end_user = _cfg()
print(f"# Probe: {term!r} ({runs} run(s))\n")
sl_buckets, sl_hits = Counter(), 0
for _ in range(runs):
r = search_library(base, headers, end_user, term, keywords)
sl_buckets[r["bucket"]] += 1
sl_hits += r["hit"]
print(f" search_library : hit {sl_hits}/{runs} buckets({dict(sl_buckets)})")
for _ in range(runs):
rk = reference_knowledge(base, headers, end_user, f"Tell me about the {term}.", keywords)
print(f" reference_knowledge: conf={rk['confidence']} on_target={rk['on_target']}"
f" (donut query: {rk['tool_query']!r})")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--runs", type=int, default=1, help="repeats per term (>=3-5 near the floor)")
ap.add_argument("--term", help="probe a single term instead of the yardstick")
ap.add_argument("--keywords", nargs="*", default=[], help="on-target keywords for --term")
ns = ap.parse_args()
if ns.term:
run_term(ns.term, ns.keywords or [ns.term.split()[-1]], ns.runs)
else:
run_yardstick(ns.runs)
if __name__ == "__main__":
main()