diff --git a/core/cli_test.py b/core/cli_test.py index c01fe3be..fdb57901 100644 --- a/core/cli_test.py +++ b/core/cli_test.py @@ -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 — diff --git a/core/cognition/pipeline.py b/core/cognition/pipeline.py index 02a5a1cd..a38ac504 100644 --- a/core/cognition/pipeline.py +++ b/core/cognition/pipeline.py @@ -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 diff --git a/docs/adr/ADR-0265-negation-in-the-proposition-graph.md b/docs/adr/ADR-0265-negation-in-the-proposition-graph.md new file mode 100644 index 00000000..e8669453 --- /dev/null +++ b/docs/adr/ADR-0265-negation-in-the-proposition-graph.md @@ -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. diff --git a/docs/adr/INDEX-by-domain.md b/docs/adr/INDEX-by-domain.md index 847b8210..7b435fae 100644 --- a/docs/adr/INDEX-by-domain.md +++ b/docs/adr/INDEX-by-domain.md @@ -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 diff --git a/docs/plans/grammar-unification-2026-07-26.md b/docs/plans/grammar-unification-2026-07-26.md index 001892e1..d5db0b38 100644 --- a/docs/plans/grammar-unification-2026-07-26.md +++ b/docs/plans/grammar-unification-2026-07-26.md @@ -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 1–3 above) remains open and is now correctly +scoped by §6's RESULT: the frontier is **construction-inventory overlap**. + --- ## 5. What is deliberately not estimated diff --git a/generate/graph_planner.py b/generate/graph_planner.py index ad435a5a..c36ccfec 100644 --- a/generate/graph_planner.py +++ b/generate/graph_planner.py @@ -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 "", 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 "", 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="", 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, ) ) diff --git a/generate/intent_bridge.py b/generate/intent_bridge.py index 698dc90d..0e4975bd 100644 --- a/generate/intent_bridge.py +++ b/generate/intent_bridge.py @@ -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) diff --git a/generate/realizer.py b/generate/realizer.py index 7b429c24..1505b1b9 100644 --- a/generate/realizer.py +++ b/generate/realizer.py @@ -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: diff --git a/generate/semantic_templates.py b/generate/semantic_templates.py index 8328a0ce..8435750f 100644 --- a/generate/semantic_templates.py +++ b/generate/semantic_templates.py @@ -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 +#: ````; this module has always also mapped ````. +_OBJ_SENTINELS = ("", "") + 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 ("", "") 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, diff --git a/tests/test_negation_survives_articulation.py b/tests/test_negation_survives_articulation.py new file mode 100644 index 00000000..ce6f1eb5 --- /dev/null +++ b/tests/test_negation_survives_articulation.py @@ -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", "", ""): + expected = render_step( + move, + "evidence", + override or predicate, + obj if obj not in ("", "") 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}" diff --git a/tests/test_phase4_realizer_resolution.py b/tests/test_phase4_realizer_resolution.py index 1dd5a460..1bedf607 100644 --- a/tests/test_phase4_realizer_resolution.py +++ b/tests/test_phase4_realizer_resolution.py @@ -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