Tightens the multi_sentence_response lane predicates so OOV
invitations and refusal disclosures can no longer be counted as
articulate capability. Three new metrics partition the case space:
articulate_sentence_rate - >=2 sentences AND grounded in
{pack, teaching}. Real capability.
disclosure_sentence_rate - >=2 sentences AND grounded in
{oov, refusal, none}. Structural
multi-sentence from disclosure templates.
unarticulate_rate - <2 sentences regardless of source.
The three sum to 1.0 (modulo rounding) by construction. The
doctrine-correct headline is now ``articulate_sentence_rate``;
``multi_sentence_rate`` is kept as a continuity metric only.
2 new tests pin: (a) the three-way partition is total and disjoint
(articulate + disclosure + unarticulate == 1.0); (b) OOV/refusal
disclosure surfaces contribute to disclosure_sentence_rate but
never to articulate_sentence_rate.
Live A/B on 21 cases under the new partition:
flag off: articulate=0.0952, disclosure=0.0476, unarticulate=0.8571
flag on : articulate=0.8571, disclosure=0.0476, unarticulate=0.0952
Planner lift is +76pp on articulate. Disclosure stays flat across
the flag (the planner gate correctly leaves disclosure surfaces
alone). The remaining 9.5pp unarticulate flag-on is the genuine
miss list (walkthrough + compound prompts) that the next two
landings will target.
contract.md updated to make articulate_sentence_rate the headline
and to document the partition explicitly.
cognition eval byte-identical: public 100/100/91.7/100.
smoke suite 67/67.
235 lines
8.4 KiB
Python
235 lines
8.4 KiB
Python
"""Multi-sentence response eval lane runner.
|
|
|
|
Measures whether ``ChatRuntime`` emits more than one sentence when
|
|
the prompt structurally calls for elaboration. Strips the trailing
|
|
provenance tag (``pack-grounded (...).``) before counting sentences
|
|
so the metric reflects substantive content.
|
|
|
|
Framework contract: ``run_lane(cases, config=None) -> LaneReport``.
|
|
|
|
Case schema:
|
|
{
|
|
"id": "...",
|
|
"category": "...",
|
|
"prompt": "Tell me about truth.",
|
|
"subject_lemma": "truth",
|
|
"expects_connective": true,
|
|
"priming_prompts": ["What is truth?"] # optional
|
|
}
|
|
|
|
``priming_prompts`` is an optional list run before the scored prompt
|
|
on the same ``ChatRuntime`` instance. Their responses are discarded;
|
|
only ``prompt`` is scored. Priming exists because the discourse
|
|
planner currently hooks the warm pack/teaching-grounded path (post-
|
|
vault), so a one-shot cold-start case cannot exercise it. Cases
|
|
remain backward-compatible — missing or empty ``priming_prompts``
|
|
yields the original cold-start behavior.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from chat.runtime import ChatRuntime
|
|
|
|
|
|
_PROVENANCE_TAIL_RE = re.compile(
|
|
r"\s*(pack-grounded|teaching-grounded)\s*\([^)]+\)\.?\s*$"
|
|
)
|
|
_TRUST_DISCLOSURE_TAIL_RE = re.compile(
|
|
r"\s*No session evidence yet\.?\s*$"
|
|
)
|
|
|
|
_CONNECTIVES = (
|
|
"and", "because", "therefore", "which", "since", "also",
|
|
"furthermore", "however", "consequently", "thus", "so", "while",
|
|
"whereas", "moreover", "in turn",
|
|
)
|
|
|
|
|
|
def _strip_provenance(surface: str) -> str:
|
|
stripped = _PROVENANCE_TAIL_RE.sub("", surface).strip()
|
|
return _TRUST_DISCLOSURE_TAIL_RE.sub("", stripped).strip()
|
|
|
|
|
|
def _split_sentences(text: str) -> list[str]:
|
|
"""Split substantive sentences without treating domain dots as stops.
|
|
|
|
Pack and teaching surfaces often contain semantic-domain atoms such as
|
|
``cognition.truth`` or ``logos.core``. A raw ``period + whitespace``
|
|
splitter over-counts those atoms as sentence boundaries, especially in
|
|
older structured disclosures like ``logos.core. truth grounds ...``.
|
|
|
|
Treat a stop as sentence-final only when it is followed by whitespace and
|
|
an uppercase/digit opener, or by the end of the text. This keeps
|
|
``cognition.truth. In turn, ...`` as two sentences while preventing
|
|
lowercase domain continuations from inflating the metric.
|
|
"""
|
|
stripped = text.strip()
|
|
if not stripped:
|
|
return []
|
|
parts = re.split(r"(?<=[.!?])\s+(?=[A-Z0-9])", stripped)
|
|
return [p.strip() for p in parts if p.strip()]
|
|
|
|
|
|
def _alpha_tokens(text: str) -> int:
|
|
return len(re.findall(r"[A-Za-z]+", text))
|
|
|
|
|
|
def _has_connective(text: str) -> bool:
|
|
low = text.lower()
|
|
return any(re.search(rf"\b{re.escape(c)}\b", low) for c in _CONNECTIVES)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class CaseResult:
|
|
case_id: str
|
|
category: str
|
|
prompt: str
|
|
surface: str
|
|
grounding_source: str
|
|
sentence_count: int
|
|
each_sentence_long_enough: bool
|
|
connective_present: bool
|
|
grounded: bool
|
|
subject_named: bool
|
|
expects_connective: bool
|
|
primed: bool
|
|
|
|
|
|
@dataclass
|
|
class LaneReport:
|
|
metrics: dict[str, Any] = field(default_factory=dict)
|
|
case_details: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
|
|
def _run_case(case: dict[str, Any], config: Any = None) -> CaseResult:
|
|
rt = ChatRuntime(config=config) if config is not None else ChatRuntime()
|
|
|
|
# Run optional priming turns on the same runtime so the scored
|
|
# prompt executes on the warm pack/teaching path. Responses are
|
|
# discarded; only the scored prompt's response is measured.
|
|
priming = case.get("priming_prompts") or ()
|
|
primed = False
|
|
for prime in priming:
|
|
if not isinstance(prime, str) or not prime.strip():
|
|
continue
|
|
rt.chat(prime)
|
|
primed = True
|
|
|
|
resp = rt.chat(case["prompt"])
|
|
surface = resp.surface
|
|
grounding = resp.grounding_source or "none"
|
|
|
|
stripped = _strip_provenance(surface)
|
|
sentences = _split_sentences(stripped)
|
|
each_long = all(_alpha_tokens(s) >= 4 for s in sentences) if sentences else False
|
|
|
|
subj = case.get("subject_lemma", "").lower()
|
|
subj_named = (subj in surface.lower()) if subj else True
|
|
|
|
return CaseResult(
|
|
case_id=case["id"],
|
|
category=case.get("category", "uncategorised"),
|
|
prompt=case["prompt"],
|
|
surface=surface,
|
|
grounding_source=grounding,
|
|
sentence_count=len(sentences),
|
|
each_sentence_long_enough=each_long,
|
|
connective_present=_has_connective(stripped),
|
|
grounded=(grounding in {"pack", "teaching"}),
|
|
subject_named=subj_named,
|
|
expects_connective=bool(case.get("expects_connective", False)),
|
|
primed=primed,
|
|
)
|
|
|
|
|
|
def run_lane(cases: list[dict[str, Any]], config: Any = None) -> LaneReport:
|
|
if not cases:
|
|
return LaneReport(metrics={}, case_details=[])
|
|
|
|
results = [_run_case(c, config=config) for c in cases]
|
|
total = len(results)
|
|
|
|
multi = sum(1 for r in results if r.sentence_count >= 2)
|
|
non_frag = sum(1 for r in results if r.each_sentence_long_enough)
|
|
grounded = sum(1 for r in results if r.grounded)
|
|
named = sum(1 for r in results if r.subject_named)
|
|
|
|
conn_expected = [r for r in results if r.expects_connective]
|
|
conn_rate = (
|
|
round(sum(1 for r in conn_expected if r.connective_present) / len(conn_expected), 4)
|
|
if conn_expected else 1.0
|
|
)
|
|
|
|
# ``multi_sentence_rate`` historically counted any case with ≥ 2
|
|
# sentences regardless of grounding source. That admitted OOV
|
|
# teaching invitations and refusal disclosures into the headline
|
|
# capability number — fixed here by splitting into three honest
|
|
# buckets:
|
|
#
|
|
# articulate_sentence_rate — ≥2 sentences AND grounded in pack
|
|
# or teaching (real capability).
|
|
# disclosure_sentence_rate — ≥2 sentences but grounded in oov,
|
|
# refusal, none, etc. (structural
|
|
# multi-sentence from disclosure
|
|
# templates, not articulation).
|
|
# unarticulate_rate — <2 sentences regardless of source.
|
|
#
|
|
# ``multi_sentence_rate`` is retained as a continuity metric. The
|
|
# doctrine-correct headline is ``articulate_sentence_rate``.
|
|
_DISCLOSURE_SOURCES = {"oov", "refusal", "none"}
|
|
articulate = sum(
|
|
1 for r in results
|
|
if r.sentence_count >= 2
|
|
and r.grounding_source in {"pack", "teaching"}
|
|
)
|
|
disclosure = sum(
|
|
1 for r in results
|
|
if r.sentence_count >= 2
|
|
and r.grounding_source in _DISCLOSURE_SOURCES
|
|
)
|
|
unarticulate = sum(1 for r in results if r.sentence_count < 2)
|
|
|
|
metrics: dict[str, Any] = {
|
|
"cases": total,
|
|
"multi_sentence_rate": round(multi / total, 4) if total else 0.0,
|
|
"articulate_sentence_rate": round(articulate / total, 4) if total else 0.0,
|
|
"disclosure_sentence_rate": round(disclosure / total, 4) if total else 0.0,
|
|
"unarticulate_rate": round(unarticulate / total, 4) if total else 0.0,
|
|
"non_fragment_rate": round(non_frag / total, 4) if total else 0.0,
|
|
"grounded_rate": round(grounded / total, 4) if total else 0.0,
|
|
"subject_named_rate": round(named / total, 4) if total else 0.0,
|
|
"connective_present_rate": conn_rate,
|
|
}
|
|
|
|
primed_results = [r for r in results if r.primed]
|
|
metrics["primed_cases"] = len(primed_results)
|
|
if primed_results:
|
|
multi_primed = sum(1 for r in primed_results if r.sentence_count >= 2)
|
|
metrics["primed_multi_sentence_rate"] = round(
|
|
multi_primed / len(primed_results), 4
|
|
)
|
|
else:
|
|
metrics["primed_multi_sentence_rate"] = 0.0
|
|
|
|
case_details = [
|
|
{
|
|
"case_id": r.case_id,
|
|
"category": r.category,
|
|
"prompt": r.prompt,
|
|
"surface": r.surface,
|
|
"grounding_source": r.grounding_source,
|
|
"sentence_count": r.sentence_count,
|
|
"each_sentence_long_enough": r.each_sentence_long_enough,
|
|
"connective_present": r.connective_present,
|
|
"grounded": r.grounded,
|
|
"subject_named": r.subject_named,
|
|
"expects_connective": r.expects_connective,
|
|
"primed": r.primed,
|
|
}
|
|
for r in results
|
|
]
|
|
|
|
return LaneReport(metrics=metrics, case_details=case_details)
|