core/evals/prompt_diversity/runner.py
Shay 48282eef8d
feat(adr-0084): definitional layer — proposal + substrate (schema/loader/closure) (#64)
* docs(adr-0084): propose definitional layer + prompt-diversity suite

Three companion artifacts proposing the next substantive design step
after ADR-0083:

1. ADR-0084 (Proposed) — Definitional Layer for Lexicon Packs
   Optional `definition` block on pack entries: gloss,
   definitional_atoms, predicates_invited, definition_version,
   provenance.  Pack-level opt-in.  Closure rule: every word in a
   gloss must resolve to a same-pack lemma, another mounted pack's
   lemma, or a primitive in a new `packs/primitives/` pack.
   NO composer change in this ADR (sequenced for ADR-0085) —
   ratify substrate before any consumer depends on it.

2. evals/prompt_diversity/ (Proposed) — companion eval lane
   ~50 cases across question-shape × sophistication × domain,
   measuring three new metrics: response_shape_fit,
   audit_in_surface_rate (quantifies the trust-boundary leak into
   user surfaces), gloss_quote_rate (zero today; rises with future
   gloss-aware composer).  No v1 pass thresholds — the lane
   establishes a baseline distribution so future work has
   something to move.  26 seed cases authored covering all 21
   categories.

3. docs/handoff/ADR-0084-pack-content-brief.md — paste-ready brief
   for a cheaper/faster dev agent to produce the pack content in
   parallel.  Self-contained, 5 sequenced phases (primitives pack
   → extend 9 existing glosses → add to relations/anchors → write
   closure verifier → run safety lanes), explicit don't-touch list
   (no composer / runtime / algebra / Greek+Hebrew packs / schema
   parser), no-LLM-glosses discipline, per-phase acceptance.

Discovery while drafting: 9 packs already carry glosses.jsonl
under language_packs/data/ with a flat schema (78 entries in
en_core_cognition_v1 alone).  The brief reflects that — most
work is extending existing entries, not authoring from scratch.

Strategic context: ADR-0083 raised the *depth* ceiling on chain
composition; ADR-0084 raises the *fidelity* ceiling.  The φ
separation probe (memory: phi-separation-falsified) established
that semantic capability lives in chain composition, not in φ
geometry, so deepening the composer's substrate is the natural
next step.  ADR-0084 → 0085 (gloss-aware composer) → 0086
(predicate licensing at ratification) is the planned sequence.

* feat(adr-0084): substrate — schema parser, primitives loader, closure verifier

Substrate-only code-side for ADR-0084 (Definitional Layer for Lexicon Packs).
No composer touches the new fields yet; consumer integration is ADR-0085.

Schema (additive, default preserves byte-identity)
  - LanguagePackManifest.definitional_layer: bool = False
  - compiler loader propagates the flag from manifest.json

language_packs/definitions.py (new)
  - GlossEntry dataclass: lemma, gloss, pos, definitional_atoms,
    predicates_invited, definition_version, provenance_ids
  - parse_gloss_entry(payload, *, strict) — strict mode enforces ADR-0084
    §Schema validation row-by-row: required keys, typed lists, no
    unknown keys, positive definition_version; lax mode preserves the
    legacy two-field shape for back-compat
  - load_pack_glosses(pack_id, *, strict) with cache + clear hook
  - verify_definitional_closure(pack_id, *, mounted_pack_lemmas,
    primitive_lemmas, strict) returning tuple[ClosureViolation, ...];
    case-insensitive resolution; cycles permitted per ADR

packs/primitives/loader.py (new)
  - Sister loader to packs/safety/ and packs/identity/
  - PrimitivesPack frozen dataclass with .lemmas frozenset
  - Gates: checksum match, kind=='primitives', definitional_layer:true,
    never_auto_mutable:true, pack_id matches dir, primitive_count
    cross-check, duplicate-lemma rejection, path-traversal rejection,
    strict per-entry schema with allow-list
  - DEFAULT_PRIMITIVES_PACK = 'en_semantic_primitives_v1'

tests/test_adr_0084_definitional_substrate.py
  - 38 tests covering strict parser (each required key rejection, unknown
    key rejection, empty predicates_invited allowed, empty
    definitional_atoms rejected, invalid definition_version), lax
    parser back-compat, load_pack_glosses (missing/strict raise/lax
    skip/malformed JSON), closure verifier (same-pack/primitive/mounted/
    unresolved/case-insensitive), primitives loader (every gate), and
    a back-compat check that every shipped pack still ratifies with
    definitional_layer=False

Lanes: smoke 67/0, cognition 120/0/1, teaching 17/0, runtime 19/0,
packs 6/0. Cognition eval byte-identical 100/91.7/100/100.

When the content PR lands (primitives.jsonl + extended glosses.jsonl
under ADR-0084-pack-content-brief.md), the gate catches any closure-rule
violation without further code change.

* feat(evals): prompt_diversity lane runner — measurement instrument for ADR-0084+

Implements the runner against the existing contract.md + 26-case v1
public split.  Lane auto-discovered by evals.framework via the standard
contract + runner convention.

Runner (evals/prompt_diversity/runner.py)
  - run_lane(cases, *, config, workers) -> LaneReport
  - 5 metrics: intent_accuracy, versor_closure_rate (carried over from
    cognition), plus the three new lane-specific metrics —
    response_shape_fit, audit_in_surface_rate, gloss_quote_rate
  - breakdown dict groups by (question_shape, sophistication, domain)
    per contract §How to read the output
  - mirrors evals.cognition.runner's parallel worker pattern

Per-shape classifier (deliberately substring/regex-simple at v1)
  - predicate_identity, explanation, sequence, two_subject_contrast,
    narrative, honest_disclosure
  - Unknown shape => neutral pass (don't penalise new categories)

Audit-leak detector
  - trust-boundary preamble markers (teaching-grounded (, pack-grounded
    (, No session evidence yet.)
  - dotted semantic-domain tag regex (cognition.illumination, etc.)

Gloss-quote detector
  - resolves expected_terms via chat.pack_resolver.resolve_gloss
  - 4-token contiguous-window match against surface (high-confidence
    "gloss actually quoted", not "shared one common word")

Tests (tests/test_prompt_diversity_runner.py — 23)
  - shape classifier parametrized over the six expected_shape values
  - audit-leak detector parametrized over preamble + tag + clean cases
  - end-to-end on v1 public:
      * versor_closure_rate == 1.0 (only v1 pass threshold per contract)
      * every metric in [0, 1]
      * breakdown groups present with the four per-cell metrics
      * diversity gate: >=5 question shapes, >=3 domains
        (defends against future regressions that collapse the suite
         back to a cognition-shaped fixture)

v1/public baseline (26 cases)
  intent_accuracy      : 65.4%   (contract predicted 70-85%)
  versor_closure_rate  : 100.0%  (only v1 pass threshold)  PASS
  response_shape_fit   : 53.8%   (contract predicted low)
  audit_in_surface_rate: 42.3%   (contract predicted ~100%)
  gloss_quote_rate     :  7.7%   (contract predicted 0%)

Three baseline surprises worth noting in the report (NOT failures —
the v1 lane is explicitly there to establish the distribution):

  - audit_in_surface_rate at 42% (not 100%) means the chain-walk leak
    fires on ~11/26; the other 15 are honest-disclosure cases that
    emit no audit envelope.  Sharpens the future surface-vs-envelope
    ADR's actual target: grounded surfaces specifically.
  - response_shape_fit at 54% (not "low") — classifier likely has
    false positives on the ", which " cause-marker.  Worth tightening
    once we have an ADR-0085 baseline to compare against.
  - intent_accuracy at 65% (below predicted 70-85%) — classifier dips
    harder on adversarial/cross-pack than expected.  Real gap.

All five smoke/cognition/teaching/runtime/packs lanes still green;
core eval cognition byte-identical 100/91.7/100/100.

* feat(packs): ADR-0084 pack content (primitives + extend glosses + closure verifier) (#65)

* feat(packs): ADR-0084 pack content

* feat(packs): repair ADR-0084 definitional content

* test(adr-0084): adjust substrate manifest tests for post-#65 content reality

PR #65 flipped definitional_layer:true on 13 English packs (9 core +
4 relations + collapse-anchors).  The substrate's previous test
test_existing_packs_unchanged asserted that en_core_cognition_v1 +
en_core_relations_v1 still had definitional_layer:False — which was
the right pre-content invariant but is wrong post-content.

Replace it with two complementary tests that hold against real content:

  - test_non_opted_packs_default_false:
      pins that packs that DIDN'T flip the flag (en_minimal_v1,
      he_core_cognition_v1, grc_logos_cognition_v1) still surface
      definitional_layer=False through the loader.  Defends against
      a future change accidentally flipping the flag on a non-opted
      pack.

  - test_opted_packs_carry_flag:
      pins that packs that DID flip the flag (en_core_cognition_v1,
      en_core_relations_v1) surface definitional_layer=True through
      the loader.  Proves the substrate's manifest-field propagation
      works against real ratified content, not just fixture packs.

Net: +1 test, same intent (substrate ratifies the manifest field
correctly), now with real-content coverage on both sides of the gate.

All 62 ADR-0084 substrate + prompt-diversity tests pass.
2026-05-20 15:25:25 -07:00

356 lines
12 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.
_CAUSE_MARKERS: tuple[str, ...] = (
"because",
"reveals",
"grounds",
"requires",
"implies",
"depends on",
"is the result of",
", which ",
)
# 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.
Uses :func:`chat.pack_resolver.resolve_gloss` to fetch any gloss
bound to each expected term, then checks for a substantive
substring (4-token window) of the gloss in the surface.
v1 ≈ 0% by design — the composer does not consume glosses yet.
The metric exists so ADR-0085's lift is quantifiable.
"""
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
gloss_tokens = [t for t in re.findall(r"[a-z]+", gloss.lower()) if len(t) >= 4]
if not gloss_tokens:
continue
# 4-token contiguous window match — high-confidence "gloss
# actually quoted", not "shared one common word".
for i in range(len(gloss_tokens) - 3):
window = " ".join(gloss_tokens[i : i + 4])
if window 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)