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.
299 lines
13 KiB
Python
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}"
|