core/evals/discourse_paragraph/runner.py
Shay b5d6ad6510 feat(compositionality): compose_relations operator lifts lane 68.8% → 100%
Closes the residual `novel_pair_under_seen_relation` pattern that
neither `transitive_walk` nor `multi_relation_walk` could synthesise.

- new `compose_relations(triples, head, frame, relation)` operator —
  pure lookup, returns both `R(head, ?)` and `R(frame, ?)` tails
- new `FRAME_TRANSFER` intent + `_FRAME_TRANSFER_RE` regex tried
  before generic TRANSITIVE_QUERY so "in Y" isn't truncated; handles
  "X belong to in Y" → belongs_to normalisation
- pipeline wiring: `_maybe_compose_relations`, `_fold_compose_into_surface`,
  `_serialize_compose` (folded into operator_invocation so trace_hash
  stays bit-identical across replay)
- regression: inference_closure, multi_step_reasoning,
  cross_domain_transfer all still 100% on public + holdouts

discourse_paragraph v2:
- per-sentence grammar rubric (length, capitalization, subject
  alignment) gated on `require_per_sentence_grammar`
- scaling cases at 10 / 20 / 50 sentences — 3/3 pass, 100% per-sentence
- 3 runtime round-trip cases (`mode: runtime_roundtrip`) that prime
  vault, ask question, verify bit-identical across two fresh runtimes
- new `per_sentence_grammar_pass_rate` lane metric

Long-form replay benchmark (benchmarks/replay_vs_llm.py):
- `replay_determinism_report(prompts, runs, priming)` — CORE-only
- `compare_to_llm(prompts, llm_callable)` — BYO API client, no
  provider lock-in; reports per-prompt determinism on both sides
- ships with default cognition-pack prompts; 100% bit-identical at runs=3

Lanes green: cognition 121/121, runtime 19/19, teaching 17/17,
packs 6/6, compositionality 16/16 + 10/10, inference_closure 20/20 +
12/12, multi_step_reasoning 15/15 + 10/10, cross_domain_transfer
10/10 + 8/8, discourse_paragraph v1 12/12 + v2 6/6.
2026-05-16 22:44:06 -07:00

313 lines
11 KiB
Python

"""discourse_paragraph eval lane runner.
Exercises paragraph-scale realization: given a multi-step
ArticulationTarget, the deterministic realizer should produce a
multi-sentence surface with discourse markers (next, furthermore,
in contrast) and full subject coverage.
Bypasses ChatRuntime grounding so the paragraph claim is isolated
to the realizer. Runtime round-tripping is named as a v2 gap.
Conforms to the framework interface: run_lane(cases, config=None) -> report.
"""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
from generate.graph_planner import (
ArticulationStep,
ArticulationTarget,
GraphEdge,
GraphNode,
PropositionGraph,
Relation,
RhetoricalMove,
)
from generate.intent import IntentTag
from generate.realizer import realize_target
@dataclass(slots=True)
class LaneReport:
metrics: dict[str, Any] = field(default_factory=dict)
case_details: list[dict[str, Any]] = field(default_factory=list)
_SENTENCE_SPLIT_RE = re.compile(r"[.!?]\s+|[.!?]$")
def _sentence_count(surface: str) -> int:
if not surface.strip():
return 0
parts = [p for p in _SENTENCE_SPLIT_RE.split(surface) if p.strip()]
return len(parts)
def _build_target_from_case(case: dict[str, Any]) -> tuple[ArticulationTarget, PropositionGraph]:
nodes_data = case["graph"]["nodes"]
edges_data = case["graph"].get("edges", [])
nodes = tuple(
GraphNode(
node_id=nd["node_id"],
subject=nd["subject"],
predicate=nd["predicate"],
obj=nd["obj"],
source_intent=IntentTag.UNKNOWN,
)
for nd in nodes_data
)
edges = tuple(
GraphEdge(
source=e["source"],
target=e["target"],
relation=Relation[e.get("relation", "SEQUENCE").upper()],
)
for e in edges_data
)
graph = PropositionGraph(nodes=nodes, edges=edges)
by_id = {n.node_id: n for n in nodes}
steps = tuple(
ArticulationStep(
node_id=s["node_id"],
subject=by_id[s["node_id"]].subject,
predicate=by_id[s["node_id"]].predicate,
move=RhetoricalMove[s["move"].upper()],
)
for s in case["steps"]
)
target = ArticulationTarget(steps=steps, source_intent=IntentTag.UNKNOWN)
return target, graph
_MIN_WORDS_PER_SENTENCE = 3
def _check_per_sentence_grammar(
sentences: list[str],
expected_steps: list[dict[str, Any]] | None,
) -> list[str]:
"""Per-sentence grammaticality rubric (v2).
For each emitted sentence, verifies:
- non-empty after strip
- at least ``_MIN_WORDS_PER_SENTENCE`` whitespace tokens
- starts with an uppercase alphabetic character (sentence-initial cap)
- if expected_steps is supplied, the subject of the aligned step
appears somewhere in the sentence (case-insensitive)
Returns a list of failure strings; empty if every sentence passes.
"""
failures: list[str] = []
for idx, sent in enumerate(sentences):
stripped = sent.strip()
if not stripped:
failures.append(f"sentence[{idx}] empty")
continue
words = stripped.split()
if len(words) < _MIN_WORDS_PER_SENTENCE:
failures.append(
f"sentence[{idx}] too short ({len(words)} words): {stripped[:40]!r}"
)
first_alpha = next((c for c in stripped if c.isalpha()), None)
if first_alpha is not None and not first_alpha.isupper():
failures.append(
f"sentence[{idx}] not capitalized: {stripped[:40]!r}"
)
if expected_steps is not None and idx < len(expected_steps):
subj = expected_steps[idx].get("subject", "").lower()
if subj and subj not in stripped.lower():
failures.append(
f"sentence[{idx}] missing aligned subject {subj!r}: {stripped[:40]!r}"
)
return failures
def _score_runtime_roundtrip_case(case: dict[str, Any]) -> dict[str, Any]:
"""Score a runtime round-trip case: prime vault, ask a question,
check the runtime's articulation surface is well-formed and
replay-deterministic.
Builds two fresh ``ChatRuntime`` instances, primes each with the
same sequence, and runs the same question — both surfaces must
match byte-identically.
This is a weaker structural claim than the realizer-direct
cases: the runtime/planner typically produces a single sentence
per turn, so we do not assert paragraph length here. Multi-
sentence-from-runtime is a v3 gap (requires planner extension).
"""
from chat.runtime import ChatRuntime
priming: list[str] = list(case.get("priming", []))
question: str = case["question"]
failures: list[str] = []
def run_once() -> tuple[str, int]:
rt = ChatRuntime()
for p in priming:
rt.chat(p)
resp = rt.chat(question)
surface = resp.articulation_surface or resp.surface or ""
return surface, int(getattr(resp, "vault_hits", 0))
surface_1, hits_1 = run_once()
surface_2, _ = run_once()
surface = surface_1.strip()
if not surface:
failures.append("empty runtime surface")
min_hits = int(case.get("min_vault_hits", 1))
if hits_1 < min_hits:
failures.append(f"vault_hits {hits_1} < min {min_hits} (gate likely fired)")
if surface_1 != surface_2:
failures.append(
f"runtime replay non-deterministic: {surface_1!r} != {surface_2!r}"
)
# Sentence-initial capitalization on the runtime surface too.
if surface:
first_alpha = next((c for c in surface if c.isalpha()), None)
if first_alpha is not None and not first_alpha.isupper():
failures.append(f"runtime surface not capitalized: {surface[:40]!r}")
must_contain = case.get("must_contain", [])
for token in must_contain:
if token.lower() not in surface.lower():
failures.append(f"missing required token {token!r} in {surface[:60]!r}")
sent_count = _sentence_count(surface)
return {
"id": case["id"],
"topic": case.get("topic", "runtime_roundtrip"),
"passed": not failures,
"surface": surface,
"sentence_count": sent_count,
"subject_coverage": 1.0 if not failures else 0.0,
"discourse_markers_found": [],
"replay_match": surface_1 == surface_2,
"per_sentence_failures": [],
"vault_hits": hits_1,
"failure_reasons": failures,
}
def _score_case(case: dict[str, Any]) -> dict[str, Any]:
if case.get("mode") == "runtime_roundtrip":
return _score_runtime_roundtrip_case(case)
target, graph = _build_target_from_case(case)
plan_1 = realize_target(target, graph)
plan_2 = realize_target(target, graph)
surface = plan_1.surface
surface_lower = surface.lower()
failures: list[str] = []
sent_count = _sentence_count(surface)
min_sentences = int(case["min_sentences"])
max_sentences = int(case.get("max_sentences", min_sentences + 2))
if sent_count < min_sentences:
failures.append(f"sentence_count {sent_count} < min {min_sentences}")
if sent_count > max_sentences:
failures.append(f"sentence_count {sent_count} > max {max_sentences}")
must_contain = case.get("must_contain_subjects", [])
present = [s for s in must_contain if s.lower() in surface_lower]
coverage = len(present) / max(1, len(must_contain))
if coverage < 0.75:
missing = [s for s in must_contain if s.lower() not in surface_lower]
failures.append(f"subject_coverage {coverage:.2f} < 0.75; missing={missing}")
expected_markers = case.get("discourse_markers", [])
if expected_markers:
found = [m for m in expected_markers if m.lower() in surface_lower]
if not found:
failures.append(
f"no discourse marker present; expected one of {expected_markers}"
)
else:
found = []
# Sentence-initial capitalization (G4): every sentence-leading
# alphabetic character must be uppercase. This is the gate that
# turned "wisdom grounds knowledge." into "Wisdom grounds
# knowledge." — addresses the open scope item.
sentences = [p.strip() for p in _SENTENCE_SPLIT_RE.split(surface) if p.strip()]
badly_cased: list[str] = []
for sent in sentences:
for ch in sent:
if ch.isalpha():
if not ch.isupper():
badly_cased.append(sent[:30])
break
if badly_cased:
failures.append(
f"sentence-initial capitalization missing in {len(badly_cased)} "
f"sentence(s): {badly_cased}"
)
replay_match = plan_1.surface == plan_2.surface
if not replay_match:
failures.append("replay determinism broken: surfaces differ")
per_sentence_failures: list[str] = []
if case.get("require_per_sentence_grammar"):
# v2: align emitted sentences to the case steps (one sentence per
# step in non-folded cases) and run the per-sentence rubric.
expected_steps_aligned: list[dict[str, Any]] | None = (
case.get("steps") if case.get("align_steps_to_sentences") else None
)
per_sentence_failures = _check_per_sentence_grammar(
sentences, expected_steps_aligned
)
if per_sentence_failures:
failures.extend(per_sentence_failures)
passed = not failures
return {
"id": case["id"],
"topic": case.get("topic", ""),
"passed": passed,
"surface": surface,
"sentence_count": sent_count,
"subject_coverage": coverage,
"discourse_markers_found": found,
"replay_match": replay_match,
"per_sentence_failures": per_sentence_failures,
"failure_reasons": failures,
}
def run_lane(cases: list[dict[str, Any]], *, config: Any = None) -> LaneReport:
details = [_score_case(c) for c in cases]
total = len(details)
passed = sum(1 for d in details if d["passed"])
return LaneReport(
metrics={
"total": total,
"passed": passed,
"accuracy": round(passed / total, 4) if total else 0.0,
"mean_sentence_count": round(
sum(d["sentence_count"] for d in details) / max(1, total), 3
),
"mean_subject_coverage": round(
sum(d["subject_coverage"] for d in details) / max(1, total), 4
),
"replay_determinism_rate": round(
sum(1 for d in details if d["replay_match"]) / max(1, total), 4
),
"per_sentence_grammar_pass_rate": round(
sum(
1
for d in details
if not d.get("per_sentence_failures")
)
/ max(1, total),
4,
),
},
case_details=details,
)