core/evals/discourse_paragraph/runner.py
Shay 257a27c105 feat(benchmarks): discourse_paragraph lane + pipeline profiler + word-selection tracer
Closes the user-flagged scope gap: every previous fluency lane (Phase
5.1 + 5.4-5.7 + grammatical_coverage) operates on 3-word SVO probes.
These three pieces stress paragraph-scale generation, give per-stage
latency visibility, and expose the realizer's word-choice geometry —
all on top of the existing deterministic infrastructure.

# discourse_paragraph lane (paragraph-scale fluency)

Forces the realizer to emit multi-sentence paragraphs from a
multi-step ArticulationTarget with rhetorical moves (ASSERT, SEQUENCE,
ELABORATE, CONTRAST).  Same realizer, much richer input — every case
is 3-5 sentences with deterministic discourse markers.

Public 12 cases / holdouts 5 / dev 1 across 12 + 5 topic chains
(epistemic, scientific method, creation arc, logical dependency,
ethical grounding, linguistic layers, mathematical chain, narrative,
biology, physics, two contrast-shaped, musical, social, computational,
psychological, economic).

Sub-metrics per case:
  - sentence count (within min..max window)
  - subject coverage rate
  - discourse marker presence (next / furthermore / in contrast)
  - sentence-initial capitalization
  - replay determinism (run twice, surfaces match)

Result: 12/12 public + 5/5 holdouts at 100%, replay rate 100%, mean
sentence count 4.

# Realizer capitalization (G4, addresses user-flagged concern)

generate/realizer.py gains `_capitalize_sentence` + `_join_as_paragraph`
helpers.  Sentence-initial alphabetic characters are now uppercased
(skipping leading whitespace/punctuation).  Surfaces went from
"wisdom grounds knowledge. next, knowledge requires evidence."
to
"Wisdom grounds knowledge. Next, knowledge requires evidence."

The discourse_paragraph runner ships a strict per-sentence
capitalization check so future regressions get caught.

# Pipeline-stage profiler (benchmarks/pipeline_profiler.py)

External monkey-patch wrapper around CognitiveTurnPipeline.run() that
records per-stage ns budgets without editing any pipeline source.
Stages: intent, graph_planner, realize_semantic, runtime_chat,
maybe_transitive_walk, fold_walk_into_surface, run_teaching,
trace_hash.

API: `profile_turn(pipeline, text) -> ProfileReport` with
`.stages: dict`, `.total_ns: int`, `.as_dict()`.

Empirical: runtime_chat dominates >99% on the runtime hot path (which
is correct — that's where ingest + propagate + recall + articulate
all happen).  Future optimisation work has a clear per-stage signal.

# Word-selection tracer (benchmarks/word_selection_tracer.py)

External wrapper around generate.articulation._resolve_slot that
records every nearest-neighbor lookup as a WordSelectionStep:
  - slot (subject/predicate/object)
  - input versor (32-d copy)
  - top-K candidate words by CGA inner product
  - chosen word + morphology
  - output language

Top-K scoring uses the diagonal Cl(4,1) metric kernel from
algebra.backend (same vectorised path vault_recall uses), not a
per-word Python loop over cga_inner.  No approximation, exact
deterministic ranking, bit-identical to a scalar scan.

API: `trace_realization(pipeline, text) -> RealizationTrace` with
`.steps`, `.realization_steps`, `.surface`, `.as_dict()`.

# CLI lane registration

Cognition suite now sweeps the benchmark profiler/tracer tests
(test_benchmarks_profiler.py) so any future regression in the
instrumentation surfaces immediately.

# Constraints honoured

- Zero edits to core/, chat/, vault/, teaching/, language_packs/, or
  the algebra hot path.  All instrumentation is external monkey-patch
  with originals restored in finally.
- discourse_paragraph runner bypasses ChatRuntime grounding (named v2
  gap) so paragraph capability is isolated to the realizer.
- No semantic changes; no hidden normalisation; no approximate
  recall.

# Lane health

smoke 55, runtime 19, teaching 17, packs 6, cognition 105 (was 103),
algebra 132.  All Phase 5 fluency lanes still 100% with the
capitalised surfaces (rubric is case-insensitive).  discourse_paragraph
100%.

# What ships next (named v2)

- Round-trip: discourse_paragraph through ChatRuntime end-to-end,
  not just realize_target.
- Per-sentence grammatical_coverage rubric on each emitted sentence.
- Longer chains (10/20/50 sentences) with per-sentence determinism
  scaling curves.
- compose_relations operator to lift compositionality recall from
  68.8% toward 100%.
2026-05-16 21:53:46 -07:00

174 lines
5.7 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
def _score_case(case: dict[str, Any]) -> dict[str, Any]:
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")
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,
"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
),
},
case_details=details,
)