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.
This commit is contained in:
Shay 2026-07-27 13:08:17 -07:00
parent 536d6e5585
commit dccce6a7b9
11 changed files with 704 additions and 26 deletions

View file

@ -70,6 +70,15 @@ TEST_SUITES: dict[str, tuple[str, ...]] = {
# substantive override of the served surface. Cheap, and the axis is
# invisible until it breaks silently.
"tests/test_prior_surface_deduction_binding.py",
# CORE must not serve the affirmative of a proposition the user denied.
# This was live behind `realizer_grounded_authority`: "evidence does not
# support truth" and "evidence supports truth" served byte-identical
# surfaces, because the graph had no slot for a denial. It is a truth
# defect on a serving path, so it belongs on the pre-push gate, not
# under `full` — and this file is registered in the same PR that
# creates it, per the #136 finding that an unregistered pin runs
# nowhere. ~6s, mostly two real pipeline turns per denial pair.
"tests/test_negation_survives_articulation.py",
# ADR governance. An ADR that governs a default-ON flag records a
# decision already in force, so leaving it "Proposed" makes the
# governance record assert something false about the running system —

View file

@ -407,6 +407,10 @@ class CognitiveTurnPipeline:
language=getattr(res, 'language', None),
root=getattr(res, 'root', None),
morphology_id=getattr(res, 'morphology_id', None),
# Depth enrichment must not launder a denial away.
# This rebuild is on the ADR-0088 Phase B path, the
# one where the realizer's surface actually wins.
negated=n.negated,
)
new_nodes.append(enriched)
changed = True

View file

@ -0,0 +1,169 @@
# ADR-0265 — Negation belongs in the proposition graph, and clause grammar has one owner
- **Status:** Proposed — awaiting ratification by Joshua Shay. **This ADR
changes served output.** It fixes a truth defect on a serving path; it flips
no flag and changes no default.
- **Date:** 2026-07-27
- **Implemented by:** #137. Enforcement pins:
`tests/test_negation_survives_articulation.py` (registered in the `smoke`
pre-push gate in the same PR).
- **Arc:** grammar-unification Phase 5
(`docs/plans/grammar-unification-2026-07-26.md`), following Phase 4 (#135, #136).
- **Governs:** `GraphNode.negated`, `graph_from_intent`, `ground_graph`,
`plan_articulation` (`generate/graph_planner.py`),
`generate/intent_bridge.py`, the depth-enrichment rebuild in
`core/cognition/pipeline.py`, and the division of labour between
`generate/semantic_templates.py` (discourse framing) and
`generate/templates.py` (clause grammar).
- **Supersedes:** the implicit claim in
`core/cognition/surface_resolution.py` that the `realize_semantic` path,
"granted supremacy by the Shadow Coherence Gate", was safe to grant supremacy
*to*. It was not, on any turn carrying a denial.
- **Builds on:** ADR-0261 §5.1 (refuse, don't drop) — this is the same failure
in a different organ.
## 1. Context
Phase 4 (#136) recorded that `render_semantic` has no `negated` parameter and
pinned the consequence as a defect. That pin was correct and incomplete. The
defect is not one missing parameter; it is **two independent drops in series**,
and the first one is in the graph.
`generate/intent.py` has always recovered denial from the user's text
(`intent.negated`, set from the `does|do|did not` group of
`_DECLARATIVE_RELATION_RE`). Downstream, every stage discarded it:
| stage | what it did with the denial |
|---|---|
| `graph_from_intent` | dropped it — `GraphNode` had **no field** for it |
| `ground_graph` | rebuilt nodes field-by-field; nothing to carry |
| pipeline depth enrichment | rebuilt nodes field-by-field; nothing to carry |
| `plan_articulation` | could not carry what the node did not hold |
| `realize_semantic` | never read `step.negated` |
| `render_semantic` | had no parameter for it |
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.**
### 1.1 Why every existing gate was green
On the default config the realizer runs on an ungrounded graph, emits `...`,
and `_is_useful_surface` rejects it — so the runtime's echo wins the resolver,
and the echo happens to contain the user's own "does not". The truth path was
correct **by accident**. ADR-0088 Phase B removes the accident by grounding the
graph before realizing, which is precisely 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 no test compared a denial to its assertion. The failure
mode is [[feedback-ask-what-if-the-thing-were-absent]] in its purest form: the
measurement would have looked identical with the mechanism removed, because the
mechanism was never there.
## 2. Decision
**R1 — A proposition graph must be able to represent denial.**
`GraphNode` gains `negated: bool = False`, threaded from `intent.negated`
through `graph_from_intent`, `ground_graph`, the pipeline's depth enrichment,
`intent_bridge`, and into `ArticulationStep` via `plan_articulation`.
Serialized **only when True**, so every pre-existing `as_dict` — and every
`trace_hash` folded from one — stays byte-identical. This is why the change
lands without touching a lane pin.
**R2 — Clause grammar has exactly one owner.**
Four of the eight intent "templates" were never frames; they were plain clauses
(`{subject} {predicate_h} {obj}`, optionally with a pinned predicate or a
prefix). Those now delegate to `generate.templates.render_step`, the single
owner of English clause grammar.
Writing a second copy of negation into `semantic_templates.py` was the
tempting fix and is rejected: Phase 2A spent a whole unit giving every
linguistic fact one owner, and a second negation implementation rebuilds that
disease one level up. The rejected design would have grown quantifier
agreement, then tense, then aspect, until `semantic_templates.py` was a second
grammar.
The delegation is **provably output-preserving**: 192 of 192 surfaces are
byte-identical across all four delegated intents × every predicate in
`PREDICATE_DISPLAY` (plus an unknown one) × every object sentinel. The only
surfaces this ADR moves are ones that were wrong.
**R3 — A frame that cannot deny must not pretend to assert.**
Frames retaining a finite verb (VERIFICATION, PROCEDURE, COMPARISON) get an
explicit negated form. RECALL — a speech act, with no proposition in it to deny
— falls back to the clause path rather than emit a frame that silently drops
the denial. Losing the framing is the correct trade against asserting the
opposite of the graph (ADR-0261 §5.1).
**R4 — An intent with no frame of its own falls back to the UNKNOWN *clause*,
not the UNKNOWN *template*.**
The old default sent unframed intents to a format string that cannot say "not".
Five live intents were sitting on it — TRANSITIVE_QUERY, FRAME_TRANSFER,
NARRATIVE, EXAMPLE, DEDUCTION — each serving a denial as its own assertion.
They were found by the exhaustive control in R5, not by inspection. The default
must be the capable path so the next intent added inherits correctness.
**R5 — The pins assert a difference, and one of them is structural.**
- The load-bearing test compares a denial's served surface to its assertion's,
end to end through the real pipeline under `realizer_grounded_authority`.
Asserting a *difference* is the only shape that catches a writer collapsing a
distinction; every property-of-one-surface test was green throughout.
- An exhaustive control requires **every** `IntentTag` to distinguish the two.
- A structural invariant requires every `GraphNode(...)` construction in
serving code to name `negated` explicitly or be recorded in an allowlist with
a reason. The defect was five separate constructors each defaulting to
`False`; a per-site test must be written per site, and a site added without
one is invisible. Only `recognition/connector.py` is exempt, because an
`EpistemicNode` has no polarity to carry.
## 3. Consequences
**What changes for a user.** On turns where the realizer's surface wins the
resolver and the input carried a denial, CORE now says "not". Previously it
said the opposite. No other turn changes: lane pins are 11/11 byte-identical
and smoke/deductive counts move only by the new tests.
**What this does not do.** `quantifier`, `tense` and `aspect` remain
unexpressed on the serving path. That is deliberate, not an oversight: **no
producer sets them.** `plan_articulation` has no source for them, and no parser
populates them, so threading them now would be speculative machinery with no
caller — the honest-placeholder discipline. They are expressible the moment a
producer exists, because the clause owner already handles all three.
**What it costs.** `render_semantic` now imports `render_step`, so
`semantic_templates` depends on `templates`. That is the correct direction:
framing composes grammar, not the reverse.
**Thesis check** (`decoding, not generating`): a graph that cannot represent
denial cannot *decode* a denial — it can only generate an affirmative that
resembles the input. Carrying polarity through the graph makes the articulation
a function of what was understood. This ADR strengthens the thesis rather than
straining it.
## 4. Alternatives rejected
**Add negation handling to `semantic_templates.py` directly.** Smallest diff,
and it rebuilds the two-grammar disease Phase 2A removed. Rejected under R2.
**Promote `realize_target` wholesale to the serving path** (Phase 4 option (a)
in its raw form). Maximum hash churn, and it *loses* the intent framing and
depth notes that `realize_semantic` genuinely has. The delegation gets the
grammar without discarding the framing. Rejected as strictly worse.
**Leave it pinned as a defect** (the Phase 4 resting state). A documented truth
defect with a green test beside it is exactly the comfortable state this arc
exists to make uncomfortable. Rejected once the exposure was measured as live
rather than latent.
**Fail closed — refuse to realize a step carrying a denial.** Consistent with
ADR-0261, and strictly worse here: the clause owner can already *express* the
denial correctly, so refusing would discard information CORE holds and
understands. Refusal is right when expression is impossible, not when it is one
delegation away.

View file

@ -45,6 +45,7 @@ check). Surfaces: `generate/proof_chain/render.py`, `chat/deduction_surface.py`.
|---|---|---|
| [0262](./ADR-0262-curriculum-grounded-serving.md) | Exams answered from the ratified curriculum, read OPEN-world; §5 records that curriculum **volume** is the binding constraint (~25× gap) — **conclusion amended by 0264 §4.1** | `chat/curriculum_surface.py`, `chat/curriculum_serve_license.py`, `evals/curriculum_serve/runner.py` |
| [0264](./ADR-0264-negative-curriculum-and-premise-scope.md) | Negatives via a row-level `polarity` field reusing the affirmative connective; premise compilation is query-scoped (superset of the query-atom rows ⇒ verdict-identical). §4.1: the 16-premise cap holds a band to ≤16 entailed cases, so **no curriculum band can earn SERVE** until scoping lands — the blocker is engineering, not content | `teaching/curriculum_premises.py`, `teaching/ratification.py`, `evals/curriculum_serve/oracle.py`, `teaching/domain_chains/*.jsonl` |
| [0265](./ADR-0265-negation-in-the-proposition-graph.md) | A proposition graph must be able to represent **denial**: `GraphNode.negated` threaded intent → graph → step → surface, and clause grammar delegated to its one owner (`render_step`). Fixes a live truth defect — under `realizer_grounded_authority`, "evidence does not support truth" and "evidence supports truth" served **byte-identical** surfaces. Serialized only when True, so no `trace_hash` and no lane pin moves | `generate/graph_planner.py`, `generate/semantic_templates.py`, `generate/realizer.py`, `generate/intent_bridge.py`, `core/cognition/pipeline.py` |
## Reliability licensing and the ledger bridge

View file

@ -872,6 +872,48 @@ Deliver:
| diversity is safe | 100% of emitted variants round-trip to the source graph — **zero exceptions** |
| truth path untouched | `wrong=0` holds; variant selection provably cannot alter a verdict |
**RESULT (first unit) — the realizer question resolved, and it was a truth bug.**
See **ADR-0265**. Phase 4 ruled option (b) and pinned "`render_semantic` cannot
express negation" as a defect. Sizing the exposure before acting on it — the
step this plan's own §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` has always been parsed from the user's words. `GraphNode` had
no field for it, so `graph_from_intent`, `ground_graph`, the pipeline's depth
enrichment, `intent_bridge` and `plan_articulation` each discarded it — five
constructors, each silently defaulting to the affirmative. Measured live under
`realizer_grounded_authority`:
```
"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.** Live, not latent.
Default config was safe only by accident: the ungrounded realizer emits `...`,
`_is_useful_surface` rejects it, and the runtime echo (which contains the
user's own "does not") wins. Grounding removes the accident.
Delivered: `negated` threaded end to end, and the four intent "templates" that
were never frames delegated to `render_step`, the one owner of clause grammar.
Delegation measured **192/192 byte-identical** on affirmatives, so the only
surfaces that moved are ones that were wrong. `negated` serializes only when
True ⇒ no `trace_hash` and **no lane pin** moves.
The exhaustive control found five further intents — TRANSITIVE_QUERY,
FRAME_TRANSFER, NARRATIVE, EXAMPLE, DEDUCTION — each serving a denial as its
own assertion, because an intent without a frame fell back to the UNKNOWN
*template* rather than the UNKNOWN *clause*. Found by the control, not by
inspection.
**Deliberately still unexpressed:** `quantifier`, `tense`, `aspect`. No
producer sets them anywhere on the serving path, so threading them now would be
machinery with no caller. The clause owner already handles all three the moment
a producer exists.
Phase 5's *diversity* work (items 13 above) remains open and is now correctly
scoped by §6's RESULT: the frontier is **construction-inventory overlap**.
---
## 5. What is deliberately not estimated

View file

@ -71,15 +71,36 @@ class GraphNode:
root: str | None = None
morphology_id: str | None = None
# 3-lang depth support for PropGraph spine (comprehend/articulate/think via roots)
negated: bool = False
"""Whether the proposition is DENIED rather than asserted.
The intent parser has always recovered this from the user's text
(``intent.negated``, ``generate/intent.py``), and the graph then dropped it
on the floor: ``GraphNode`` had no slot for it, so ``plan_articulation``
could not carry it and the realizer could not express it. The measured
consequence, under ``realizer_grounded_authority``:
"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. A proposition graph that
cannot represent denial cannot decode a denial, which is the thesis check
this field exists to pass.
Serialized only when True, so every pre-existing ``as_dict`` and every
``trace_hash`` folded from one stays byte-identical.
"""
def as_dict(self) -> dict[str, object]:
d = {
d: dict[str, object] = {
"node_id": self.node_id,
"subject": self.subject,
"predicate": self.predicate,
"object": self.obj,
"source_intent": self.source_intent.value,
}
if self.negated:
d["negated"] = True
if self.language is not None:
d["language"] = self.language
if self.root is not None:
@ -289,6 +310,7 @@ def graph_from_intent(
obj=intent.secondary_subject or "<pending>",
source_intent=intent.tag,
# depth fields populated later via resolve_entry + grounding enrichment
negated=intent.negated,
)
right = GraphNode(
node_id="p1",
@ -296,6 +318,7 @@ def graph_from_intent(
predicate=predicate,
obj=intent.subject,
source_intent=intent.tag,
negated=intent.negated,
)
edge = GraphEdge(source="p0", target="p1", relation=Relation.CONTRAST)
return graph.add_node(left).add_node(right).add_edge(edge)
@ -308,6 +331,7 @@ def graph_from_intent(
predicate=predicate,
obj=prior_node_id or "<prior>",
source_intent=intent.tag,
negated=intent.negated,
)
graph = graph.add_node(root)
if prior_node_id is not None:
@ -324,6 +348,7 @@ def graph_from_intent(
predicate=predicate,
obj="<pending>",
source_intent=intent.tag,
negated=intent.negated,
)
return graph.add_node(root)
@ -365,6 +390,12 @@ def ground_graph(
language=lang,
root=rt,
morphology_id=mid,
# Same drop-on-rebuild bug this field exists to fix: grounding
# is the ADR-0088 Phase B path, i.e. exactly the path on which
# the realizer's surface becomes useful enough to WIN the
# resolver. Losing negation here would restore the defect on
# the only turns where it reaches a user.
negated=node.negated,
))
else:
new_nodes.append(node)
@ -400,6 +431,7 @@ def plan_articulation(graph: PropositionGraph) -> ArticulationTarget:
move=move,
predicate=node.predicate,
subject=node.subject,
negated=node.negated,
)
)

View file

@ -232,6 +232,7 @@ def _build_graph_from_intent(intent: DialogueIntent, plan: ArticulationPlan) ->
predicate=predicate,
obj=secondary,
source_intent=intent.tag,
negated=intent.negated,
)
right = GraphNode(
node_id="p1",
@ -239,6 +240,7 @@ def _build_graph_from_intent(intent: DialogueIntent, plan: ArticulationPlan) ->
predicate=predicate,
obj=subject,
source_intent=intent.tag,
negated=intent.negated,
)
edge = GraphEdge(source="p0", target="p1", relation=Relation.CONTRAST)
return graph.add_node(left).add_node(right).add_edge(edge)
@ -249,6 +251,7 @@ def _build_graph_from_intent(intent: DialogueIntent, plan: ArticulationPlan) ->
predicate=predicate,
obj=obj,
source_intent=intent.tag,
negated=intent.negated,
)
return graph.add_node(root)

View file

@ -150,6 +150,7 @@ def realize_semantic(
secondary=secondary,
language=lang_a,
root=root_a,
negated=step_a.negated,
)
fragments.append(RealizedFragment(
node_id=step_a.node_id,
@ -167,6 +168,9 @@ def realize_semantic(
obj=obj,
language=lang,
root=rt,
# The step has carried this since the graph learned to hold a
# denial; the realizer discarded it silently until Phase 5.
negated=step.negated,
)
move = step.move
if move is RhetoricalMove.ASSERT and intent is IntentTag.CORRECTION:

View file

@ -9,12 +9,32 @@ Design constraints:
- No random template selection
- Deterministic: same (intent, predicate, subject, object) -> same surface
- Uses seed pack vocabulary directly
Division of labour (Phase 5)
----------------------------
This module owns **discourse framing** which frame an intent speaks in, and
the 3-language depth notes. It does **not** own clause grammar.
Four of the eight intent templates are not frames at all: they are ordinary
clauses (``{subject} {predicate_h} {obj}``, optionally with a fixed predicate
or a prefix). Those are handed to ``generate.templates.render_step``, the
single owner of English clause grammar number, agreement, negation, tense,
aspect. Writing a second copy of negation here is how this codebase came to
have two grammars in the first place; Phase 2A spent a whole unit undoing that
duplication, and the fix must not rebuild it one level up.
The delegation is **provably output-preserving**: across all four delegated
intents x every predicate in ``PREDICATE_DISPLAY`` (plus an unknown one) x
every object sentinel, 192 of 192 surfaces are byte-identical to the
pre-delegation output. The only surfaces this moves are ones that were WRONG.
"""
from __future__ import annotations
from generate.lexicon import PREDICATE_DISPLAY
from generate.graph_planner import RhetoricalMove
from generate.intent import IntentTag
from generate.lexicon import PREDICATE_DISPLAY
from generate.templates import render_step
_INTENT_TEMPLATES: dict[IntentTag, str] = {
@ -28,8 +48,44 @@ _INTENT_TEMPLATES: dict[IntentTag, str] = {
IntentTag.UNKNOWN: "{subject} {predicate_h} {obj}",
}
#: Intents whose template IS a clause, mapped to (predicate override, move).
#:
#: ``None`` keeps the step's own predicate. DEFINITION and CAUSE pin one,
#: because the *intent* — not the node — decides how a definition is framed.
#: That was already true of the format strings above; it is preserved exactly.
_CLAUSE_INTENTS: dict[IntentTag, tuple[str | None, RhetoricalMove]] = {
IntentTag.UNKNOWN: (None, RhetoricalMove.ASSERT),
IntentTag.CORRECTION: (None, RhetoricalMove.CORRECT),
IntentTag.DEFINITION: ("is_defined_as", RhetoricalMove.ASSERT),
IntentTag.CAUSE: ("is_grounded_in", RhetoricalMove.ASSERT),
}
#: Frames that are not clauses but still carry a finite verb, so a denial still
#: has somewhere to attach. Written out rather than derived: negating a frame
#: is a wording decision per frame, not a rule.
_NEGATED_FRAMES: dict[IntentTag, str] = {
IntentTag.VERIFICATION: "{subject} is not verified: {obj}",
IntentTag.PROCEDURE: "first, {obj}; then, {subject} does not follow",
IntentTag.COMPARISON: (
"{subject} and {secondary} are not distinguished: "
"{subject} does not {predicate_h} {secondary}"
),
}
#: RECALL is a speech act ("recalling X: Y"), not an assertion, so it contains
#: no proposition to deny. Rather than emit a frame that quietly drops the
#: denial — the defect this change exists to remove — a negated RECALL falls
#: back to the clause path, which can say "not". Losing the framing is the
#: right trade against asserting the opposite of the graph (ADR-0261 §5.1,
#: refuse-don't-drop).
_UNFRAMEABLE_WHEN_NEGATED: frozenset[IntentTag] = frozenset({IntentTag.RECALL})
_PREDICATE_HUMANIZE: dict[str, str] = PREDICATE_DISPLAY
#: Object sentinels meaning "not grounded yet". ``render_step`` knows only
#: ``<pending>``; this module has always also mapped ``<prior>``.
_OBJ_SENTINELS = ("<pending>", "<prior>")
def humanize_predicate(predicate: str) -> str:
return _PREDICATE_HUMANIZE.get(predicate, predicate.replace("_", " "))
@ -43,6 +99,7 @@ def render_semantic(
secondary: str | None = None,
language: str | None = None,
root: str | None = None,
negated: bool = False,
) -> str:
"""Render a semantic surface from intent, subject, predicate, and object.
@ -50,10 +107,15 @@ def render_semantic(
carrying 3-core-language depth), the surface incorporates etymological
precision for Hebrew (root density) and Koine Greek (Logos precision).
English base remains unchanged.
``negated`` denies the proposition. It reaches here from the user's own
words: ``intent.negated`` -> ``GraphNode.negated`` ->
``ArticulationStep.negated``. Before Phase 5 that chain was broken at the
graph and this function had no parameter for it, so CORE served the
affirmative of every denial it was given.
"""
template = _INTENT_TEMPLATES.get(intent, _INTENT_TEMPLATES[IntentTag.UNKNOWN])
predicate_h = humanize_predicate(predicate)
obj_display = obj if obj not in ("<pending>", "<prior>") else "..."
obj_display = obj if obj not in _OBJ_SENTINELS else "..."
# Masterful 3-language depth framing on the articulation side.
# Depth travels with the shared GraphNode from resolve_entry grounding.
@ -72,6 +134,36 @@ def render_semantic(
else:
obj_display = f"{obj_display}{depth_note}"
# --- clause-shaped intents: hand the clause to its one owner ----------
#
# An intent with no frame of its own has always fallen back to the UNKNOWN
# *template*; it now falls back to the UNKNOWN *clause*. That distinction
# is the whole point: the template cannot say "not" and the clause can, so
# the default has to be the capable path. Five live intents were sitting on
# that fallback — TRANSITIVE_QUERY, FRAME_TRANSFER, NARRATIVE, EXAMPLE,
# DEDUCTION — each serving a denial as its own assertion, and the next
# intent added without a template would have joined them silently.
clause_intent = intent
if intent not in _INTENT_TEMPLATES:
clause_intent = IntentTag.UNKNOWN
elif negated and intent in _UNFRAMEABLE_WHEN_NEGATED:
clause_intent = IntentTag.UNKNOWN
if clause_intent in _CLAUSE_INTENTS:
override, move = _CLAUSE_INTENTS[clause_intent]
return render_step(
move,
subject,
override or predicate,
obj_display,
negated=negated,
)
# --- frames: keep the discourse shape, deny inside it -----------------
if negated and intent in _NEGATED_FRAMES:
template = _NEGATED_FRAMES[intent]
else:
template = _INTENT_TEMPLATES.get(intent, _INTENT_TEMPLATES[IntentTag.UNKNOWN])
return template.format(
subject=subject,
predicate_h=predicate_h,

View file

@ -0,0 +1,299 @@
"""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}"

View file

@ -104,17 +104,24 @@ def test_the_lane_reports_the_serving_writer_too() -> None:
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"] == 27
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; 85/347 for the one that ships."""
"""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"] == 85
assert metrics["serving_passed"] == 109
# --------------------------------------------------------------------------- #
@ -145,7 +152,7 @@ def test_the_two_realizers_agree_exactly_where_nothing_is_dropped() -> None:
@pytest.mark.parametrize(
("bucket", "expected_n", "expected_eval", "expected_serving"),
[
("features", 214, 207, 49),
("features", 214, 207, 73), # 49 before ADR-0265
("multi_node", 100, 100, 3),
],
)
@ -171,37 +178,49 @@ def test_the_gap_decomposes_into_dropped_features_and_clause_joining(
# --------------------------------------------------------------------------- #
def test_the_serving_realizer_emits_the_same_surface_negated_or_not() -> None:
"""DEFECT PIN, not a goal. Revise when fixed; never relax.
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.
``render_semantic``'s signature is ``(intent, subject, predicate, obj,
secondary, language, root)``. There is no ``negated`` parameter, and
``realize_semantic`` never reads ``step.negated``. So a negated
proposition is served as its **affirmative**:
The recorded defect was::
negated=False -> 'Knowledge is defined as opinion.'
negated=True -> 'Knowledge is defined as opinion.'
This is not a fluency defect. It is the same family as ADR-0261 §5.1
refuse-don't-drop: v1b served WRONG by dropping premises it could not
express. Fixing it changes served output, so it is authorization-gated;
pinning it here is what stops it from being forgotten.
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 defect this pin records has changed shape"
assert affirmative != negated, "the serving realizer collapsed a denial again"
assert "not" in negated
# The eval-only realizer distinguishes them, which is how we know the
# information reaches the realizer boundary intact.
# 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", _UNEXPRESSIBLE)
def test_render_semantic_has_no_parameter_for_the_content_it_drops(feature: str) -> None:
"""Derived from the signature, so it cannot rot into a stale comment."""
@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
@ -209,8 +228,12 @@ def test_render_semantic_has_no_parameter_for_the_content_it_drops(feature: str)
assert feature not in inspect.signature(render_semantic).parameters
def test_how_much_of_the_corpus_carries_content_the_serving_writer_drops() -> None:
"""214 of 347. The scale of the gap, independent of any rubric."""
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