core/tests/test_negation_survives_articulation.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

299 lines
13 KiB
Python

"""CORE must not serve the affirmative of a proposition the user denied.
The defect
----------
The intent parser has always recovered negation from the user's text
(``intent.negated``, ``generate/intent.py``). Everything downstream threw it
away: ``GraphNode`` had no slot for it, so ``plan_articulation`` could not
carry it, so ``realize_semantic`` never saw it — and ``render_semantic`` had no
parameter for it in any case. Two independent drops in series.
Measured 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. CORE affirmed what the user denied.
Why it was invisible
--------------------
On the default config the realizer's ungrounded surface contains ``...``,
``_is_useful_surface`` rejects it, and the runtime's echo wins the resolver —
which happens to preserve the user's own "does not". The truth path was
correct **by accident**, and grounding the graph (ADR-0088 Phase B) removes the
accident. So every pre-existing test passed while the defect sat live behind a
shipped flag.
That is why the load-bearing test here is
``test_a_denial_and_its_assertion_do_not_serve_the_same_surface``: it asserts a
DIFFERENCE between two turns rather than a property of one, which is the only
shape that can catch a writer collapsing a distinction.
"""
from __future__ import annotations
import pytest
from chat.runtime import ChatRuntime
from core.cognition import CognitiveTurnPipeline
from core.config import RuntimeConfig
from generate.graph_planner import (
GraphNode,
RhetoricalMove,
graph_from_intent,
ground_graph,
plan_articulation,
)
from generate.intent import IntentTag, classify_intent
from generate.lexicon import PREDICATE_DISPLAY
from generate.realizer import realize_semantic
from generate.semantic_templates import render_semantic
from generate.templates import render_step
#: Inputs the declarative parser recognises as denials. It requires do-support
#: plus one of its relation verbs ("X does not support Y"), which is why these
#: are phrased the way they are rather than "X is not Y".
_DENIAL_PAIRS = [
("evidence does not support truth", "evidence supports truth"),
("wisdom does not require knowledge", "wisdom requires knowledge"),
("knowledge does not ground belief", "knowledge grounds belief"),
]
# --------------------------------------------------------------------------- #
# THE CONTROL — a denial must not serve as its own assertion
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(("denial", "assertion"), _DENIAL_PAIRS)
def test_a_denial_and_its_assertion_do_not_serve_the_same_surface(
denial: str, assertion: str
) -> None:
"""The regression test for the defect itself, end to end through the real
pipeline under the flag that makes the realizer's surface authoritative.
Asserting a *difference* is the point. Every property-of-one-surface test
passed while these two were byte-identical.
"""
served = []
for text in (denial, assertion):
runtime = ChatRuntime(config=RuntimeConfig(realizer_grounded_authority=True))
served.append(CognitiveTurnPipeline(runtime=runtime).run(text, max_tokens=6).surface)
assert served[0] != served[1], (
f"CORE served the same surface for {denial!r} and {assertion!r}: {served[0]!r}"
)
assert " not " in served[0], f"the denial lost its negation: {served[0]!r}"
assert " not " not in served[1], f"the assertion gained one: {served[1]!r}"
@pytest.mark.parametrize(("denial", "assertion"), _DENIAL_PAIRS)
def test_the_whole_chain_carries_the_denial(denial: str, assertion: str) -> None:
"""intent.negated -> GraphNode.negated -> ArticulationStep.negated.
Pinned link by link: before Phase 5 the first arrow existed and the second
did not, so a green ``intent.negated`` proved nothing about what shipped.
"""
intent = classify_intent(denial)
assert intent.negated is True, "the parser stopped recognising this denial"
graph = graph_from_intent(intent)
assert graph.nodes[0].negated is True, "the graph dropped the denial"
target = plan_articulation(graph)
assert target.steps[0].negated is True, "the articulation target dropped the denial"
assert "not" in realize_semantic(target, graph).surface
# And the affirmative stays clean at every link.
pos = classify_intent(assertion)
assert pos.negated is False
assert graph_from_intent(pos).nodes[0].negated is False
def test_grounding_does_not_launder_the_denial_away() -> None:
"""``ground_graph`` rebuilds every node field-by-field, and grounding is
precisely the path (ADR-0088 Phase B) on which the realizer's surface stops
being rejected and starts reaching users. Dropping the flag here would
restore the defect on the only turns where it is visible."""
intent = classify_intent("evidence does not support truth")
graph = graph_from_intent(intent)
grounded = ground_graph(graph, ("reality",))
assert grounded.nodes[0].obj == "reality", "fixture no longer grounds anything"
assert grounded.nodes[0].negated is True
# --------------------------------------------------------------------------- #
# The delegation is output-preserving — the churn budget
# --------------------------------------------------------------------------- #
_DELEGATED = {
IntentTag.UNKNOWN: (None, RhetoricalMove.ASSERT),
IntentTag.CORRECTION: (None, RhetoricalMove.CORRECT),
IntentTag.DEFINITION: ("is_defined_as", RhetoricalMove.ASSERT),
IntentTag.CAUSE: ("is_grounded_in", RhetoricalMove.ASSERT),
}
@pytest.mark.parametrize("intent", sorted(_DELEGATED, key=lambda i: i.name))
def test_delegating_the_clause_changes_no_affirmative_surface(intent: IntentTag) -> None:
"""Four intent templates were never frames — they were plain clauses. They
now route through ``render_step``, the single owner of clause grammar,
rather than a second copy of it here.
This pins that the move is byte-identical on every affirmative input, so
the only surfaces the change moves are ones that were WRONG. If this goes
red, the unification has started rewriting correct output and the diff
needs re-reading, not re-pinning.
"""
override, move = _DELEGATED[intent]
predicates = [*sorted(PREDICATE_DISPLAY)[:14], "is_verified_as", "unknown_pred_xyz"]
for predicate in predicates:
for obj in ("truth", "<pending>", "<prior>"):
expected = render_step(
move,
"evidence",
override or predicate,
obj if obj not in ("<pending>", "<prior>") else "...",
)
assert render_semantic(intent, "evidence", predicate, obj) == expected
def test_serialisation_is_byte_identical_while_the_flag_is_false() -> None:
"""``negated`` is emitted only when True, so every pre-existing
``as_dict`` — and every ``trace_hash`` folded from one — is unchanged.
This is what let the change land without touching a single lane pin."""
node = GraphNode("p0", "evidence", "supports", "truth", IntentTag.UNKNOWN)
assert "negated" not in node.as_dict()
denied = GraphNode("p0", "evidence", "supports", "truth", IntentTag.UNKNOWN, negated=True)
assert denied.as_dict()["negated"] is True
# --------------------------------------------------------------------------- #
# Frames deny inside themselves; the one that cannot, falls back
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
("intent", "expected"),
[
(IntentTag.VERIFICATION, "evidence is not verified: truth"),
(IntentTag.DEFINITION, "evidence is not defined as truth"),
(IntentTag.CAUSE, "evidence is not grounded in truth"),
],
)
def test_a_frame_with_a_finite_verb_denies_inside_itself(
intent: IntentTag, expected: str
) -> None:
assert render_semantic(intent, "evidence", "supports", "truth", negated=True) == expected
def test_recall_has_no_proposition_to_deny_so_it_falls_back_to_a_clause() -> None:
""""recalling X: Y" is a speech act, not an assertion — there is nothing in
it to negate. Emitting the frame anyway would silently drop the denial,
which is the defect. Losing the framing is the correct trade
(ADR-0261 §5.1, refuse-don't-drop)."""
affirmative = render_semantic(IntentTag.RECALL, "evidence", "supports", "truth")
assert affirmative == "recalling evidence: truth"
denied = render_semantic(IntentTag.RECALL, "evidence", "supports", "truth", negated=True)
assert "not" in denied, "a negated RECALL silently dropped the denial"
assert denied != affirmative
def test_every_intent_distinguishes_denial_from_assertion() -> None:
"""The exhaustive form of the control: no IntentTag may render a denial and
its assertion identically. A new intent added without a negated form fails
here rather than shipping the defect again."""
collisions = []
for intent in IntentTag:
affirmative = render_semantic(intent, "evidence", "supports", "truth")
denied = render_semantic(intent, "evidence", "supports", "truth", negated=True)
if affirmative == denied:
collisions.append((intent.name, affirmative))
assert not collisions, f"intents that serve a denial as its assertion: {collisions}"
# --------------------------------------------------------------------------- #
# STRUCTURAL INVARIANT — every GraphNode built on the serving path decides
# --------------------------------------------------------------------------- #
#
# The defect was not one bad line. It was FIVE separate constructors that each
# rebuilt a GraphNode field-by-field and each silently defaulted `negated` back
# to False: graph_from_intent (x3), ground_graph, the pipeline's depth
# enrichment, and intent_bridge (x3). A per-site test has to be written per
# site, and a site added without one is invisible — which is exactly how this
# survived in the first place.
#
# So the pin is structural, in the same shape as the tail-preservation
# invariant in #135: every GraphNode(...) construction in SERVING code must
# name `negated` explicitly, or be recorded below with a reason. Sites nobody
# has written yet are covered.
#: Serving-path constructors allowed to leave `negated` at its default, with
#: the reason. Adding a row is a decision with a diff, not a side effect.
_MAY_DEFAULT_NEGATED: dict[str, str] = {
# An EpistemicNode is a recognition artefact — a measured observation with
# no polarity to carry. There is no denial to lose here.
"recognition/connector.py": "EpistemicNode has no negation concept",
}
_SERVING_ROOTS = ("generate", "core", "chat", "recognition")
def _graphnode_constructions() -> list[tuple[str, int, bool]]:
"""(relative path, line, names_negated) for every GraphNode(...) call in
serving code. AST-based, so a call spanning lines or reordered kwargs is
matched the same way."""
import ast
from pathlib import Path
repo = Path(__file__).resolve().parents[1]
found: list[tuple[str, int, bool]] = []
for root in _SERVING_ROOTS:
for path in sorted((repo / root).rglob("*.py")):
rel = path.relative_to(repo).as_posix()
try:
tree = ast.parse(path.read_text())
except SyntaxError: # pragma: no cover - not our problem here
continue
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn = node.func
name = fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", None)
if name != "GraphNode":
continue
names_negated = any(kw.arg == "negated" for kw in node.keywords)
found.append((rel, node.lineno, names_negated))
return found
def test_every_serving_graphnode_decides_about_negation() -> None:
"""A GraphNode built without naming `negated` asserts the affirmative by
default. On the serving path that default is a claim about truth, so it
has to be made deliberately."""
constructions = _graphnode_constructions()
assert constructions, "the AST scan found no GraphNode constructions — it is broken"
silent = [
(path, line)
for path, line, names_negated in constructions
if not names_negated and path not in _MAY_DEFAULT_NEGATED
]
assert not silent, (
"GraphNode built on the serving path without deciding about negation "
f"(add `negated=` or record it in _MAY_DEFAULT_NEGATED): {silent}"
)
def test_the_recorded_exemptions_still_exist() -> None:
"""An allowlist that outlives its file silently widens. Same discipline as
RECORDED_CONSUMERS in test_lexicon_single_source.py."""
paths = {path for path, _, _ in _graphnode_constructions()}
stale = sorted(set(_MAY_DEFAULT_NEGATED) - paths)
assert not stale, f"recorded exemptions no longer construct a GraphNode: {stale}"