core/tests/test_phase4_realizer_resolution.py
Shay dccce6a7b9 fix(generate): a proposition graph must be able to represent denial (ADR-0265)
CORE served the affirmative of propositions the user denied. Measured live on
main @ 536d6e55 with realizer_grounded_authority=True:

    "evidence does not support truth" -> 'Evidence is verified: what supports truth.'
    "evidence supports truth"         -> 'Evidence is verified: what supports truth.'

Byte-identical.

Phase 4 (#136) pinned "render_semantic has no negated parameter" as a defect.
Sizing the exposure before acting on it -- the step §7 requires -- showed the
pin was correct and INCOMPLETE. The defect is two drops in series, and the
first is in the graph:

  intent.negated      parsed from the user's words, always has been
  GraphNode           NO FIELD FOR IT  <-- first drop
  ground_graph        rebuilds nodes field-by-field; nothing to carry
  depth enrichment    rebuilds nodes field-by-field; nothing to carry
  plan_articulation   cannot carry what the node does not hold
  realize_semantic    never read step.negated
  render_semantic     no parameter for it                <-- second drop

Five separate constructors, each silently defaulting to the affirmative.

Why every gate was green
------------------------
On the default config the ungrounded realizer emits "...", _is_useful_surface
rejects it, and the runtime echo wins the resolver -- and the echo contains the
user's own "does not". The truth path was correct BY ACCIDENT. ADR-0088 Phase B
grounds the graph first, which is exactly when the realizer's surface becomes
useful enough to win. So the defect sat behind a shipped flag, invisible to
every property-of-one-surface test, because nothing compared a denial to its
assertion.

Delivered
---------
- GraphNode.negated, threaded intent -> graph -> ground -> enrich -> step ->
  surface. Serialized ONLY when True, so every pre-existing as_dict and every
  trace_hash folded from one stays byte-identical -- which is why no lane pin
  moves.
- Clause grammar delegated to its one owner. Four of the eight intent
  "templates" were never frames; they were plain clauses. They now call
  render_step. Writing a second negation implementation in semantic_templates
  was the tempting fix and is rejected: Phase 2A spent a unit giving every
  linguistic fact one owner, and that design rebuilds the disease one level up.
- Frames that keep a finite verb (VERIFICATION, PROCEDURE, COMPARISON) get an
  explicit negated form. RECALL is a speech act with no proposition to deny, so
  it falls back to the clause path rather than drop the denial (ADR-0261 §5.1).

Measured
--------
    delegation on affirmatives          192/192 byte-identical
    serving realizer, all corpora       85/347 -> 109/347   (+24 = the denials)
    feature-bearing bucket              49/214 -> 73/214
    CONTROL (nothing droppable)         33/33  -> 33/33     unchanged
    multi-node (clause joining)         3/100  -> 3/100     unchanged, out of scope
    lane pins                           11/11 byte-identical, none edited

The control staying 33/33 and multi-node staying 3/100 is the evidence that
this moved the denials and nothing else.

The exhaustive control earned its keep immediately
--------------------------------------------------
It found FIVE more intents serving a denial as its own assertion --
TRANSITIVE_QUERY, FRAME_TRANSFER, NARRATIVE, EXAMPLE, DEDUCTION -- because an
intent with no frame fell back to the UNKNOWN *template* (which cannot say
"not") rather than the UNKNOWN *clause* (which can). The default is now the
capable path, so the next intent added inherits correctness. Found by the
control, not by inspection.

Mutation -- every link reverted individually
--------------------------------------------
    baseline                                        19 pass
    graph_from_intent drops intent.negated           8 FAIL
    plan_articulation drops node.negated             6 FAIL
    ground_graph drops it on rebuild                 2 FAIL
    realize_semantic stops passing step.negated      6 FAIL
    unframed intents fall back to the template       1 FAIL
    pipeline depth-enrichment drops it               1 FAIL

The last row was GREEN on the first run -- my fix there was unguarded. That is
what added the structural invariant: every GraphNode(...) built on the serving
path must NAME `negated` or be recorded in an allowlist with a reason. The
defect was five constructors; a per-site test must be written per site, and a
site added without one is invisible. Only recognition/connector.py is exempt
(an EpistemicNode has no polarity to carry).

Four Phase 4 pins are revised, not relaxed -- #136's M2 mutation ("render_semantic
GAINS a negated parameter -> FAIL") has now happened for real, and forced the
deliberate revision it was built to force.

Registered in `smoke` in the same PR that creates it, per the #136 finding that
an unregistered pin runs nowhere.

Still unexpressed, deliberately: quantifier, tense, aspect. NO PRODUCER sets
them anywhere on the serving path, so threading them would be machinery with no
caller. render_step already handles all three the moment a producer exists.

[Verification]: in-worktree on CPython 3.12.13 with `uv sync --locked` --
smoke 641 (was 621), deductive 503, lane pins 11/11 with no pin edited.
2026-07-27 13:08:17 -07:00

291 lines
12 KiB
Python

"""Phase 4 — which realizer serves, and what the other one's score means.
The arc opened with a fact that had gone unremarked for its whole life:
``english_fluency_ood`` reported 117/117 + 39/39 for ``realize_target``, and
``core/cognition/pipeline.py`` **never calls** ``realize_target``. It calls
``realize_semantic``. 149 green cases were scoring a function that does not
speak, and the lane said nothing about the one that does.
Phase 4 resolves that by option (b) of the plan: the lanes now report the
serving writer's score alongside, and the claim "realizer fluency is
mechanistic" is restated as a claim about **eval-only code**. Nothing here
changes a served byte — promoting ``realize_target`` to the serving path is
option (a), it moves surface hashes, and it is Shay's call, not this file's.
What these tests are for
------------------------
Every number below was measured, and each one is pinned so that it cannot
quietly stop being true. The load-bearing ones are the *controls*: without
them the headline gap could be dismissed as an artifact of the corpora, and
without the negation pin the most serious finding here would live only in a
commit message.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from evals.grammatical_coverage.runner import run_lane
from generate.graph_planner import (
ArticulationStep,
ArticulationTarget,
GraphNode,
PropositionGraph,
RhetoricalMove,
)
from generate.intent import IntentTag
from generate.realizer import realize_semantic, realize_target
_EVALS = Path(__file__).resolve().parents[1] / "evals"
#: Every corpus scored through ``realize_target`` by a lane.
_CORPORA = (
"grammatical_coverage/public/v1",
"grammatical_coverage/public/v2",
"grammatical_coverage/dev",
"grammatical_coverage/holdouts/v1",
"english_fluency_ood/public/v1",
"english_fluency_ood/holdouts/v1",
"english_fluency_ood/dev",
)
#: Content an ``ArticulationStep`` carries and ``render_semantic`` has no
#: parameter for.
_UNEXPRESSIBLE = ("quantifier", "negated", "tense", "aspect")
def _load(name: str) -> list[dict]:
path = _EVALS / name / "cases.jsonl"
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
def _all_cases() -> list[dict]:
return [case for name in _CORPORA for case in _load(name)]
def _carries_unexpressible(case: dict) -> bool:
return any(
node.get(feature) not in (None, False, "")
for node in case.get("proposition_graph", {}).get("nodes", [])
for feature in _UNEXPRESSIBLE
)
def _is_multi_node(case: dict) -> bool:
return len(case.get("proposition_graph", {}).get("nodes", [])) > 1
def _one_step(**step_kwargs) -> tuple[ArticulationTarget, PropositionGraph]:
node = GraphNode(
node_id="n1", subject="knowledge", predicate="is_grounded_in",
obj="opinion", source_intent=IntentTag.DEFINITION,
)
step = ArticulationStep(
node_id="n1", subject="knowledge", predicate="is_grounded_in",
move=RhetoricalMove.ASSERT, **step_kwargs,
)
return (
ArticulationTarget(steps=(step,), source_intent=IntentTag.DEFINITION),
PropositionGraph(nodes=(node,), edges=()),
)
# --------------------------------------------------------------------------- #
# The headline: the lane now reports what ships
# --------------------------------------------------------------------------- #
def test_the_lane_reports_the_serving_writer_too() -> None:
"""Before Phase 4 a lane could report 1.00 while the writer that actually
speaks scored 0.23 on the identical cases, and nothing surfaced it."""
metrics = run_lane(_load("english_fluency_ood/public/v1")).metrics
assert metrics["passed"] == 117
assert metrics["serving_passed"] == 36 # was 27 before ADR-0265
assert "serving_accuracy" in metrics
def test_the_measured_gap_across_every_scored_corpus() -> None:
"""340/347 for the realizer the lanes score; 109/347 for the one that ships.
Was 85 before ADR-0265. The +24 is precisely the negation-bearing cases:
the serving writer can now say "not". The remaining gap is quantifier,
tense, aspect and clause joining — none of which has a producer on the
serving path (ADR-0265 §3), so it is a capability gap and not a live
defect.
"""
cases = _all_cases()
assert len(cases) == 347
metrics = run_lane(cases).metrics
assert metrics["passed"] == 340
assert metrics["serving_passed"] == 109
# --------------------------------------------------------------------------- #
# CONTROL — the gap is the missing features, not the corpora's IntentTag
# --------------------------------------------------------------------------- #
def test_the_two_realizers_agree_exactly_where_nothing_is_dropped() -> None:
"""THE CONTROL, and the most important test in this file.
Every corpus here hardcodes ``IntentTag.UNKNOWN``, so "the serving realizer
scores badly" could be an artifact of never giving it a real intent. It is
not. On the 33 cases that carry no unexpressible feature and have a single
node, the two realizers score **identically** — which is only possible if
the gap on the other 314 is the dropped content and the clause joining.
If this ever goes red, the gap measured above has stopped meaning what the
Phase 4 ruling says it means, and the ruling needs revisiting.
"""
control = [c for c in _all_cases()
if not _carries_unexpressible(c) and not _is_multi_node(c)]
assert len(control) == 33, "the control bucket changed size"
metrics = run_lane(control).metrics
assert metrics["passed"] == 33
assert metrics["serving_passed"] == 33
@pytest.mark.parametrize(
("bucket", "expected_n", "expected_eval", "expected_serving"),
[
("features", 214, 207, 73), # 49 before ADR-0265
("multi_node", 100, 100, 3),
],
)
def test_the_gap_decomposes_into_dropped_features_and_clause_joining(
bucket: str, expected_n: int, expected_eval: int, expected_serving: int
) -> None:
"""Two separate causes, measured separately: content the serving writer has
no parameter for, and clauses it can only join with a full stop."""
if bucket == "features":
cases = [c for c in _all_cases()
if _carries_unexpressible(c) and not _is_multi_node(c)]
else:
cases = [c for c in _all_cases()
if not _carries_unexpressible(c) and _is_multi_node(c)]
assert len(cases) == expected_n
metrics = run_lane(cases).metrics
assert metrics["passed"] == expected_eval
assert metrics["serving_passed"] == expected_serving
# --------------------------------------------------------------------------- #
# DEFECT PIN — the serving writer cannot express negation
# --------------------------------------------------------------------------- #
def test_the_serving_realizer_now_distinguishes_a_denial_from_its_assertion() -> None:
"""WAS A DEFECT PIN. **Fixed by ADR-0265** — revised deliberately, as the
pin required, rather than relaxed.
The recorded defect was::
negated=False -> 'Knowledge is defined as opinion.'
negated=True -> 'Knowledge is defined as opinion.'
Byte-identical: the serving writer had no ``negated`` parameter and
``realize_semantic`` never read ``step.negated``. Sizing the exposure
showed it was **live**, not latent — under ``realizer_grounded_authority``
a real user turn served the affirmative of its own denial — and that it was
two drops in series, the first being a ``GraphNode`` with no field for a
denial at all.
ADR-0265 threads the flag end to end and delegates the clause to
``render_step``. The end-to-end regression lives in
``tests/test_negation_survives_articulation.py``; this pin keeps the
unit-level guarantee that the two surfaces may never collapse again.
"""
affirmative = realize_semantic(*_one_step(negated=False)).surface
negated = realize_semantic(*_one_step(negated=True)).surface
assert affirmative != negated, "the serving realizer collapsed a denial again"
assert "not" in negated
# The eval-only realizer distinguishes them too.
assert realize_target(*_one_step(negated=True)).surface != (
realize_target(*_one_step(negated=False)).surface
)
assert "not" in realize_target(*_one_step(negated=True)).surface
@pytest.mark.parametrize("feature", sorted(set(_UNEXPRESSIBLE) - {"negated"}))
def test_render_semantic_still_has_no_parameter_for_these(feature: str) -> None:
"""Derived from the signature, so it cannot rot into a stale comment.
``negated`` left this list in ADR-0265. The other three stay, and stay
deliberately: **no producer sets them** anywhere on the serving path, so
threading them would be machinery with no caller. They become expressible
the moment a producer exists, because ``render_step`` already handles all
three.
"""
import inspect
from generate.semantic_templates import render_semantic
assert feature not in inspect.signature(render_semantic).parameters
def test_how_much_of_the_corpus_carries_feature_bearing_content() -> None:
"""214 of 347. The scale of the gap, independent of any rubric.
Post-ADR-0265 the serving writer expresses the *negation* subset of these;
quantifier, tense and aspect remain unexpressed (and unproduced).
"""
cases = _all_cases()
carrying = [c for c in cases if _carries_unexpressible(c)]
assert len(carrying) == 214
assert len(cases) == 347
# --------------------------------------------------------------------------- #
# §6 evidence — the writer is not what round-trip is blocked on
# --------------------------------------------------------------------------- #
def test_the_serving_writer_round_trips_nothing_at_all() -> None:
"""§6 of the plan forks on ``read_rate`` after unification, and this is the
measurement that says which fork.
Two numbers, and they say different things:
* **292 of 293** cases refuse with ``no_template_match`` under *either*
writer. For those the writer is irrelevant — the reader has no
SUBJ-VERB-OBJ template at all, so nothing the writer does can help.
* The **one** case that reads is writer-sensitive: it reads through
``realize_target`` (0.003413) and refuses through ``realize_semantic``
(0.0), because predicate-nominal object agreement lives in
``render_step`` and the serving writer has no equivalent.
So the serving writer round-trips **nothing**, and the eval writer
round-trips one case — and the gap between 1 and 293 is reader construction
coverage, not a graph-model mismatch. That is why §6's pre-committed
reading (§1.8 ⇒ next step is an ADR) is not what the evidence supports; see
``test_grammar_roundtrip.py::test_the_remaining_blockers_are_reader_construction_coverage``
for the full refusal census.
"""
import evals.grammar_roundtrip.runner as rt
baseline = rt.run_lane().metrics["g_read_rate"]
original = rt.realize_target
rt.realize_target = realize_semantic
try:
swapped = rt.run_lane().metrics["g_read_rate"]
finally:
rt.realize_target = original
assert baseline == 0.003413, "the eval writer reads back exactly one case"
assert swapped == 0.0, "the serving writer reads back none"
# Sentinel: prove the swap is actually reaching the lane, so that an
# equality between two identical runs can never be mistaken for a result.
def _boom(*_args, **_kwargs): # pragma: no cover - must raise
raise RuntimeError("sentinel")
rt.realize_target = _boom
try:
with pytest.raises(RuntimeError, match="sentinel"):
rt.run_lane()
finally:
rt.realize_target = original