core/evals/prompt_diversity/runner.py
Shay 4b9404a88e
feat(adr-0085): gloss-aware CAUSE composer — explanation frame from glosses (#70)
The original "Why does light exist?" complaint that motivated ADR-0084
was specifically about CAUSE-intent surfaces. ADR-0084 (substrate) +
PR #65 (content) already moved DEFINITION/RECALL to gloss-grounded
surfaces ("Light is visible medium that reveal truth."). But CAUSE
still dispatched through the chain-walk path:

  Before: light — teaching-grounded (cognition_chains_v1):
            cognition.illumination; logos.core.
            light reveals truth (cognition.truth).
            No session evidence yet.

  After:  Light exists as visible medium that reveal truth.
          pack-grounded (en_core_cognition_v1).

The chain-walk is structurally correct but the wrong SHAPE for a why-
question — it's a graph traversal, not an explanation. ADR-0085 fixes
the shape using the same gloss material that DEFINITION/RECALL already
consume, with no new content authoring.

Additive composer
  chat/pack_grounding.py:gloss_aware_cause_surface()
  - Resolves gloss via lexicon-residency-checked resolve_gloss().
  - Frames POS-aware:
      NOUN -> "{Lemma} exists as {gloss}."
      VERB -> "To {lemma} is to {gloss}."
      ADJ  -> "To be {lemma} is to {gloss}."
      *    -> falls back to _frame_gloss (predicate-identity).
  - Threads anchor lens via the existing helper (ADR-0073c parity).
  - Returns None when no gloss exists — runtime falls through to the
    existing chain-walk path. Additive: no CAUSE case loses its surface.

Runtime dispatch
  chat/runtime.py — IntentTag.CAUSE tries gloss path FIRST under the
  flag; falls through to teaching_grounded_surface* on None.
  Unconditional fallback — never silent.

Opt-in flag
  core/config.py — RuntimeConfig.gloss_aware_cause: bool = False
  Default off preserves pre-ADR-0085 chain-walk surfaces byte-
  identically (null-drop invariant, CI-pinned).

Prompt-diversity classifier update
  evals/prompt_diversity/runner.py — _CAUSE_MARKERS widened with the
  explanation-frame markers ("exists as", "is to", "to be", "is for",
  "purpose of") plus bare-form predicates ("reveal" alongside
  "reveals"). Neither composer path is penalised on shape_fit just on
  inflection grounds.

v1/public lift (flag OFF vs ON, 26 cases)
  intent_accuracy        : 65.4% -> 65.4%   ( — )
  versor_closure_rate    : 100.0% -> 100.0% ( — )
  response_shape_fit     : 57.7% -> 57.7%   ( — , both frames recognized)
  audit_in_surface_rate  : 42.3% -> 42.3%   ( — , envelope ADR's job)
  gloss_quote_rate       : 11.5% -> 23.1%   (+11.5pp, structural lift)

Tests (15)
  - 5 pure composer (NOUN/VERB frame, unknown/empty None, no chain-
    walk artifacts in surface)
  - 5 runtime dispatch (flag-off chain-walk, flag-on gloss, parametrized
    across glossed subjects, VERIFICATION unchanged under flag, no-
    gloss fallback engages)
  - 5 cognition lane invariance (aggregate metrics byte-identical
    under both flag states; surfaces deliberately shift on the 2 CAUSE
    cases with glossed subjects — the structural-change-vs-metric-
    invariance both-sides invariant)

Lanes
  smoke 67/0, cognition 120/0/1 skipped, packs 6/0, teaching 17/0,
  runtime 19/0. core eval cognition byte-identical 100/91.7/100/100
  under both flag states.

Scope limits (per ADR §Scope limits)
  - CAUSE only; VERIFICATION still chain-walks (different shape).
  - English pilot only; Greek/Hebrew packs not opted into definitional
    layer yet (ADR-0084 scope limit).
  - Single-lemma subjects; compound/anaphoric fall through.
  - Opt-in until cognition holdout confirms the lift transfers off-
    fixture. Future PR flips default on.

Out of scope
  - Surface-vs-envelope cleanup ("pack-grounded (...)" still leaks).
  - Predicate licensing (ADR-0086).
  - Content style pass (bare lemma forms in glosses — separate brief).
2026-05-20 15:55:08 -07:00

370 lines
13 KiB
Python

"""Prompt-diversity eval lane runner.
Companion to ``evals/prompt_diversity/contract.md`` (ADR-0084 sibling).
Measures how surface quality and grounding generalize across question
types — not just on the cognition-lane's chain-walk fixture. Beyond the
cognition lane's ``intent_accuracy`` + ``versor_closure_rate``, this
runner adds three new metrics specific to this lane:
- ``response_shape_fit`` — does the surface's structural shape match
the question shape? Uses a small per-shape classifier driven by the
case's ``expected_shape`` field.
- ``audit_in_surface_rate`` — fraction of surfaces leaking audit
metadata (trust-boundary text, semantic-domain tags, "No session
evidence yet."). **Lower is better.** v1 is the baseline a future
surface-vs-envelope ADR will move down.
- ``gloss_quote_rate`` — fraction of surfaces visibly drawing from a
pack ``glosses.jsonl`` entry rather than only from
``semantic_domains`` tags. v1 ≈ 0% by design — the composer is
unchanged in ADR-0084. Rises with ADR-0085.
v1 has NO pass thresholds beyond ``versor_closure_rate == 1.00``. The
lane's v1 job is to establish a baseline distribution across the
matrix. Pass thresholds get set in v2 after ADR-0084 → 0085 → 0086 has
run and we know which axes are actually moveable.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from functools import partial
from typing import Any, Callable
from chat.runtime import ChatRuntime
from core.cognition.pipeline import CognitiveTurnPipeline
from core.config import RuntimeConfig
from evals._parallel import run_cases_parallel
from generate.intent import IntentTag
# Substring markers that indicate audit-tier metadata leaked into the
# user-facing surface (the leak the surface-vs-envelope ADR will close).
# Pinned to the actual strings today's composers emit so the metric is
# falsifiable rather than wishful.
_AUDIT_MARKERS: tuple[str, ...] = (
"teaching-grounded (",
"pack-grounded (",
"No session evidence yet.",
"No prior turn in this session to correct yet.",
)
# Semantic-domain tag pattern — e.g. ``cognition.illumination``,
# ``logos.core``, ``relations.kinship.parent``. A dotted lower-case
# token with at least two segments is almost always a domain leak.
_DOMAIN_TAG_RE = re.compile(r"\b[a-z][a-z_]*(?:\.[a-z][a-z_]*)+\b")
# Honest-disclosure markers used by today's runtime for non-grounded
# answers. Not audit text — these *are* legitimate surfaces.
_HONEST_DISCLOSURE_MARKERS: tuple[str, ...] = (
"i don't know",
"no session evidence yet",
"no prior turn",
"i can't",
"i cannot",
"unknown",
"not in my vocabulary",
)
# Procedure-shape markers.
_PROCEDURE_MARKERS: tuple[str, ...] = (
"first,",
"then,",
"finally,",
"step ",
"1.",
"2.",
"",
)
# Comparison-shape markers.
_COMPARISON_MARKERS: tuple[str, ...] = (
"contrasts with",
"differs from",
"while",
"whereas",
"vs.",
" versus ",
)
# Cause/why-shape markers. Both inflected (``reveals``, from the
# chain-walk surface ``light reveals truth``) and bare (``reveal``,
# from the ADR-0085 gloss surface ``Light exists as visible medium
# that reveal truth``) forms are listed so neither composer path
# under-reports explanation-shape fit just on inflection grounds.
_CAUSE_MARKERS: tuple[str, ...] = (
"because",
"reveals", "reveal",
"grounds", "ground",
"requires", "require",
"implies", "imply",
"depends on",
"is the result of",
", which ",
# ADR-0085 — existential explanation frame.
"exists as", "exists to",
" is for ",
"purpose of",
# ADR-0085 — verb/adjective explanation frames.
" is to ", " to be ",
)
# Predicate-identity markers (definition + verification).
_PREDICATE_MARKERS: tuple[str, ...] = (
" is ",
" are ",
" means ",
" refers to ",
" denotes ",
" requires ",
"yes,",
"no,",
)
@dataclass(frozen=True, slots=True)
class CaseResult:
case_id: str
category: str
question_shape: str
sophistication: str
domain: str
prompt: str
intent_correct: bool
versor_closure: bool
versor_condition: float
response_shape_fit: bool
audit_in_surface: bool
gloss_quoted: bool
surface: str
trace_hash: str
@dataclass(slots=True)
class LaneReport:
metrics: dict[str, Any] = field(default_factory=dict)
case_details: list[dict[str, Any]] = field(default_factory=list)
def _surface_has_any(surface: str, markers: tuple[str, ...]) -> bool:
lowered = surface.lower()
return any(marker.lower() in lowered for marker in markers)
def _classify_response_shape(surface: str, expected_shape: str) -> bool:
"""Heuristic: does *surface* match the structural *expected_shape*?
Deliberately simple substring/regex classifier — the lane's job at
v1 is to *measure* shape mismatch, not to fix it. False positives
are fine; what matters is that the metric moves when ADR-0085 lands.
"""
lowered = surface.strip().lower()
if not lowered:
return False
if expected_shape == "honest_disclosure":
return _surface_has_any(surface, _HONEST_DISCLOSURE_MARKERS)
if expected_shape == "predicate_identity":
return any(marker in lowered for marker in _PREDICATE_MARKERS)
if expected_shape == "explanation":
return any(marker in lowered for marker in (m.lower() for m in _CAUSE_MARKERS))
if expected_shape == "sequence":
return any(marker in lowered for marker in _PROCEDURE_MARKERS)
if expected_shape == "two_subject_contrast":
return any(marker in lowered for marker in _COMPARISON_MARKERS)
if expected_shape == "narrative":
# Multi-clause aggregated content — at least two clauses joined
# by commas or "and"/"which".
return lowered.count(",") >= 2 or " which " in lowered
# Unknown expected_shape — neutral pass to avoid penalising new
# categories during expansion.
return True
def _surface_has_audit_leak(surface: str) -> bool:
"""Return True iff the surface contains audit-tier metadata.
Two leak families:
1. Trust-boundary preamble (``teaching-grounded (...)``,
``pack-grounded (...)``, ``No session evidence yet.``).
2. Semantic-domain tags as bare tokens (``cognition.illumination``,
``logos.core``).
"""
if _surface_has_any(surface, _AUDIT_MARKERS):
return True
return bool(_DOMAIN_TAG_RE.search(surface))
def _surface_quotes_gloss(surface: str, expected_terms: tuple[str, ...]) -> bool:
"""Return True iff the surface visibly draws from a pack gloss.
Resolves each expected term via
:func:`chat.pack_resolver.resolve_gloss`, then asks: does the
surface contain the gloss text verbatim? The pack-grounded
composer emits the gloss without paraphrasing
(``"{Lemma} is {gloss}."``), so substring match is an exact and
high-confidence "gloss actually quoted" signal — no fuzzy windows,
no false-positives from one shared content word.
Note on the v1 prediction: the contract predicted ``≈ 0%`` here,
on the assumption that the composer would not consume glosses
until ADR-0085 landed. In fact the pack-grounded composer at
``chat/pack_grounding.py:398-434`` was *already* gloss-aware
pre-ADR-0084 but had no glosses to consume. Once PR #65's content
landed, the composer immediately started emitting glosses on
DEFINITION/RECALL. This metric now reflects that reality.
"""
if not expected_terms:
return False
from chat.pack_resolver import resolve_gloss
surface_lower = surface.lower()
for term in expected_terms:
resolved = resolve_gloss(term)
if resolved is None:
continue
_pack_id, _pos, gloss = resolved
if not gloss.strip():
continue
if gloss.lower().strip() in surface_lower:
return True
return False
def _run_case(case: dict[str, Any], pipeline: CognitiveTurnPipeline) -> CaseResult:
prompt = case["prompt"]
expected_intent = case["expected_intent"]
expected_shape = case.get("expected_shape", "")
expected_terms = tuple(case.get("expected_terms", []))
result = pipeline.run(prompt, max_tokens=8)
surface = result.surface
actual_intent = result.intent.tag if result.intent else IntentTag.UNKNOWN
intent_correct = actual_intent.value == expected_intent
versor_ok = result.versor_condition < 1e-6
return CaseResult(
case_id=case["id"],
category=case.get("category", "unknown"),
question_shape=case.get("question_shape", "unknown"),
sophistication=case.get("sophistication", "unknown"),
domain=case.get("domain", "unknown"),
prompt=prompt,
intent_correct=intent_correct,
versor_closure=versor_ok,
versor_condition=result.versor_condition,
response_shape_fit=_classify_response_shape(surface, expected_shape),
audit_in_surface=_surface_has_audit_leak(surface),
gloss_quoted=_surface_quotes_gloss(surface, expected_terms),
surface=surface,
trace_hash=result.trace_hash,
)
def _build_case_runner(
config: RuntimeConfig | None = None,
) -> Callable[[dict[str, Any]], CaseResult]:
"""Warm worker-local caches once, then return a per-case scorer.
Mirrors :mod:`evals.cognition.runner` so the parallel-worker pool's
cache-warming pattern is consistent across lanes.
"""
if config is None:
ChatRuntime()
else:
ChatRuntime(config=config)
def _run(case: dict[str, Any]) -> CaseResult:
runtime = ChatRuntime(config=config) if config else ChatRuntime()
pipeline = CognitiveTurnPipeline(runtime)
return _run_case(case, pipeline)
return _run
def _aggregate_breakdown(
results: list[CaseResult],
) -> dict[str, dict[str, dict[str, float]]]:
"""Group results by (question_shape, sophistication, domain) and
compute per-cell counts + the four moveable metrics.
The contract calls for per-cell breakdowns so we can see which axes
move when ADR-0085 lands. Aggregating in the runner (vs. the CLI)
keeps the contract-shaped JSON stable across consumers.
"""
cells: dict[tuple[str, str, str], list[CaseResult]] = {}
for cr in results:
key = (cr.question_shape, cr.sophistication, cr.domain)
cells.setdefault(key, []).append(cr)
out: dict[str, dict[str, dict[str, float]]] = {}
for (shape, soph, domain), members in sorted(cells.items()):
n = len(members)
cell = {
"n": n,
"intent_accuracy": round(sum(1 for m in members if m.intent_correct) / n, 4),
"response_shape_fit": round(sum(1 for m in members if m.response_shape_fit) / n, 4),
"audit_in_surface_rate": round(sum(1 for m in members if m.audit_in_surface) / n, 4),
"gloss_quote_rate": round(sum(1 for m in members if m.gloss_quoted) / n, 4),
}
out.setdefault(shape, {}).setdefault(soph, {})[domain] = cell # type: ignore[assignment]
return out
def run_lane(
cases: list[dict[str, Any]],
*,
config: RuntimeConfig | None = None,
workers: int | None = None,
) -> LaneReport:
"""Run all cases and return baseline-distribution metrics + per-case detail."""
if not cases:
return LaneReport(metrics={"total": 0}, case_details=[])
case_runner_builder = partial(_build_case_runner, config=config)
case_results: list[CaseResult] = run_cases_parallel(
cases,
case_runner_builder,
n_workers=workers if workers is not None else 4,
)
total = len(case_results)
intent_correct = sum(1 for cr in case_results if cr.intent_correct)
versor_closures = sum(1 for cr in case_results if cr.versor_closure)
shape_fits = sum(1 for cr in case_results if cr.response_shape_fit)
audit_leaks = sum(1 for cr in case_results if cr.audit_in_surface)
gloss_quotes = sum(1 for cr in case_results if cr.gloss_quoted)
metrics: dict[str, Any] = {
"total": total,
"intent_accuracy": round(intent_correct / total, 4),
"versor_closure_rate": round(versor_closures / total, 4),
"response_shape_fit": round(shape_fits / total, 4),
"audit_in_surface_rate": round(audit_leaks / total, 4),
"gloss_quote_rate": round(gloss_quotes / total, 4),
"breakdown": _aggregate_breakdown(case_results),
}
case_details: list[dict[str, Any]] = [
{
"case_id": cr.case_id,
"category": cr.category,
"question_shape": cr.question_shape,
"sophistication": cr.sophistication,
"domain": cr.domain,
"intent_correct": cr.intent_correct,
"versor_closure": cr.versor_closure,
"versor_condition": round(cr.versor_condition, 9),
"response_shape_fit": cr.response_shape_fit,
"audit_in_surface": cr.audit_in_surface,
"gloss_quoted": cr.gloss_quoted,
"trace_hash": cr.trace_hash,
"surface": cr.surface,
}
for cr in case_results
]
return LaneReport(metrics=metrics, case_details=case_details)