2111b1e824
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.
199 lines
9.0 KiB
Python
199 lines
9.0 KiB
Python
#!/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()
|