Merge pull request 'fix(generate): a proposition graph must be able to represent denial (ADR-0265) — CORE was serving the affirmative of what users denied' (#137) from feat/realizer-unification into main
This commit is contained in:
commit
8927c5632f
11 changed files with 706 additions and 26 deletions
|
|
@ -70,6 +70,15 @@ TEST_SUITES: dict[str, tuple[str, ...]] = {
|
||||||
# substantive override of the served surface. Cheap, and the axis is
|
# substantive override of the served surface. Cheap, and the axis is
|
||||||
# invisible until it breaks silently.
|
# invisible until it breaks silently.
|
||||||
"tests/test_prior_surface_deduction_binding.py",
|
"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
|
# ADR governance. An ADR that governs a default-ON flag records a
|
||||||
# decision already in force, so leaving it "Proposed" makes the
|
# decision already in force, so leaving it "Proposed" makes the
|
||||||
# governance record assert something false about the running system —
|
# governance record assert something false about the running system —
|
||||||
|
|
|
||||||
|
|
@ -407,6 +407,10 @@ class CognitiveTurnPipeline:
|
||||||
language=getattr(res, 'language', None),
|
language=getattr(res, 'language', None),
|
||||||
root=getattr(res, 'root', None),
|
root=getattr(res, 'root', None),
|
||||||
morphology_id=getattr(res, 'morphology_id', 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)
|
new_nodes.append(enriched)
|
||||||
changed = True
|
changed = True
|
||||||
|
|
|
||||||
171
docs/adr/ADR-0265-negation-in-the-proposition-graph.md
Normal file
171
docs/adr/ADR-0265-negation-in-the-proposition-graph.md
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
# ADR-0265 — Negation belongs in the proposition graph, and clause grammar has one owner
|
||||||
|
|
||||||
|
- **Status:** Accepted — ratified by Joshua Shay 2026-07-27. **This ADR changes
|
||||||
|
served output.** It fixes a truth defect on a serving path; it flips no flag
|
||||||
|
and changes no default. Implemented and merged to `main` in the same unit
|
||||||
|
that proposed it, because the defect it removes was live behind a shipped
|
||||||
|
flag rather than latent.
|
||||||
|
- **Date:** 2026-07-27 · **Ratified:** 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.
|
||||||
|
|
@ -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` |
|
| [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` |
|
| [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
|
## Reliability licensing and the ledger bridge
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -872,6 +872,48 @@ Deliver:
|
||||||
| diversity is safe | 100% of emitted variants round-trip to the source graph — **zero exceptions** |
|
| 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 |
|
| 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
|
## 5. What is deliberately not estimated
|
||||||
|
|
|
||||||
|
|
@ -71,15 +71,36 @@ class GraphNode:
|
||||||
root: str | None = None
|
root: str | None = None
|
||||||
morphology_id: str | None = None
|
morphology_id: str | None = None
|
||||||
# 3-lang depth support for PropGraph spine (comprehend/articulate/think via roots)
|
# 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]:
|
def as_dict(self) -> dict[str, object]:
|
||||||
d = {
|
d: dict[str, object] = {
|
||||||
"node_id": self.node_id,
|
"node_id": self.node_id,
|
||||||
"subject": self.subject,
|
"subject": self.subject,
|
||||||
"predicate": self.predicate,
|
"predicate": self.predicate,
|
||||||
"object": self.obj,
|
"object": self.obj,
|
||||||
"source_intent": self.source_intent.value,
|
"source_intent": self.source_intent.value,
|
||||||
}
|
}
|
||||||
|
if self.negated:
|
||||||
|
d["negated"] = True
|
||||||
if self.language is not None:
|
if self.language is not None:
|
||||||
d["language"] = self.language
|
d["language"] = self.language
|
||||||
if self.root is not None:
|
if self.root is not None:
|
||||||
|
|
@ -289,6 +310,7 @@ def graph_from_intent(
|
||||||
obj=intent.secondary_subject or "<pending>",
|
obj=intent.secondary_subject or "<pending>",
|
||||||
source_intent=intent.tag,
|
source_intent=intent.tag,
|
||||||
# depth fields populated later via resolve_entry + grounding enrichment
|
# depth fields populated later via resolve_entry + grounding enrichment
|
||||||
|
negated=intent.negated,
|
||||||
)
|
)
|
||||||
right = GraphNode(
|
right = GraphNode(
|
||||||
node_id="p1",
|
node_id="p1",
|
||||||
|
|
@ -296,6 +318,7 @@ def graph_from_intent(
|
||||||
predicate=predicate,
|
predicate=predicate,
|
||||||
obj=intent.subject,
|
obj=intent.subject,
|
||||||
source_intent=intent.tag,
|
source_intent=intent.tag,
|
||||||
|
negated=intent.negated,
|
||||||
)
|
)
|
||||||
edge = GraphEdge(source="p0", target="p1", relation=Relation.CONTRAST)
|
edge = GraphEdge(source="p0", target="p1", relation=Relation.CONTRAST)
|
||||||
return graph.add_node(left).add_node(right).add_edge(edge)
|
return graph.add_node(left).add_node(right).add_edge(edge)
|
||||||
|
|
@ -308,6 +331,7 @@ def graph_from_intent(
|
||||||
predicate=predicate,
|
predicate=predicate,
|
||||||
obj=prior_node_id or "<prior>",
|
obj=prior_node_id or "<prior>",
|
||||||
source_intent=intent.tag,
|
source_intent=intent.tag,
|
||||||
|
negated=intent.negated,
|
||||||
)
|
)
|
||||||
graph = graph.add_node(root)
|
graph = graph.add_node(root)
|
||||||
if prior_node_id is not None:
|
if prior_node_id is not None:
|
||||||
|
|
@ -324,6 +348,7 @@ def graph_from_intent(
|
||||||
predicate=predicate,
|
predicate=predicate,
|
||||||
obj="<pending>",
|
obj="<pending>",
|
||||||
source_intent=intent.tag,
|
source_intent=intent.tag,
|
||||||
|
negated=intent.negated,
|
||||||
)
|
)
|
||||||
return graph.add_node(root)
|
return graph.add_node(root)
|
||||||
|
|
||||||
|
|
@ -365,6 +390,12 @@ def ground_graph(
|
||||||
language=lang,
|
language=lang,
|
||||||
root=rt,
|
root=rt,
|
||||||
morphology_id=mid,
|
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:
|
else:
|
||||||
new_nodes.append(node)
|
new_nodes.append(node)
|
||||||
|
|
@ -400,6 +431,7 @@ def plan_articulation(graph: PropositionGraph) -> ArticulationTarget:
|
||||||
move=move,
|
move=move,
|
||||||
predicate=node.predicate,
|
predicate=node.predicate,
|
||||||
subject=node.subject,
|
subject=node.subject,
|
||||||
|
negated=node.negated,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,7 @@ def _build_graph_from_intent(intent: DialogueIntent, plan: ArticulationPlan) ->
|
||||||
predicate=predicate,
|
predicate=predicate,
|
||||||
obj=secondary,
|
obj=secondary,
|
||||||
source_intent=intent.tag,
|
source_intent=intent.tag,
|
||||||
|
negated=intent.negated,
|
||||||
)
|
)
|
||||||
right = GraphNode(
|
right = GraphNode(
|
||||||
node_id="p1",
|
node_id="p1",
|
||||||
|
|
@ -239,6 +240,7 @@ def _build_graph_from_intent(intent: DialogueIntent, plan: ArticulationPlan) ->
|
||||||
predicate=predicate,
|
predicate=predicate,
|
||||||
obj=subject,
|
obj=subject,
|
||||||
source_intent=intent.tag,
|
source_intent=intent.tag,
|
||||||
|
negated=intent.negated,
|
||||||
)
|
)
|
||||||
edge = GraphEdge(source="p0", target="p1", relation=Relation.CONTRAST)
|
edge = GraphEdge(source="p0", target="p1", relation=Relation.CONTRAST)
|
||||||
return graph.add_node(left).add_node(right).add_edge(edge)
|
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,
|
predicate=predicate,
|
||||||
obj=obj,
|
obj=obj,
|
||||||
source_intent=intent.tag,
|
source_intent=intent.tag,
|
||||||
|
negated=intent.negated,
|
||||||
)
|
)
|
||||||
return graph.add_node(root)
|
return graph.add_node(root)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -150,6 +150,7 @@ def realize_semantic(
|
||||||
secondary=secondary,
|
secondary=secondary,
|
||||||
language=lang_a,
|
language=lang_a,
|
||||||
root=root_a,
|
root=root_a,
|
||||||
|
negated=step_a.negated,
|
||||||
)
|
)
|
||||||
fragments.append(RealizedFragment(
|
fragments.append(RealizedFragment(
|
||||||
node_id=step_a.node_id,
|
node_id=step_a.node_id,
|
||||||
|
|
@ -167,6 +168,9 @@ def realize_semantic(
|
||||||
obj=obj,
|
obj=obj,
|
||||||
language=lang,
|
language=lang,
|
||||||
root=rt,
|
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
|
move = step.move
|
||||||
if move is RhetoricalMove.ASSERT and intent is IntentTag.CORRECTION:
|
if move is RhetoricalMove.ASSERT and intent is IntentTag.CORRECTION:
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,32 @@ Design constraints:
|
||||||
- No random template selection
|
- No random template selection
|
||||||
- Deterministic: same (intent, predicate, subject, object) -> same surface
|
- Deterministic: same (intent, predicate, subject, object) -> same surface
|
||||||
- Uses seed pack vocabulary directly
|
- 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 __future__ import annotations
|
||||||
|
|
||||||
from generate.lexicon import PREDICATE_DISPLAY
|
from generate.graph_planner import RhetoricalMove
|
||||||
from generate.intent import IntentTag
|
from generate.intent import IntentTag
|
||||||
|
from generate.lexicon import PREDICATE_DISPLAY
|
||||||
|
from generate.templates import render_step
|
||||||
|
|
||||||
|
|
||||||
_INTENT_TEMPLATES: dict[IntentTag, str] = {
|
_INTENT_TEMPLATES: dict[IntentTag, str] = {
|
||||||
|
|
@ -28,8 +48,44 @@ _INTENT_TEMPLATES: dict[IntentTag, str] = {
|
||||||
IntentTag.UNKNOWN: "{subject} {predicate_h} {obj}",
|
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
|
_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:
|
def humanize_predicate(predicate: str) -> str:
|
||||||
return _PREDICATE_HUMANIZE.get(predicate, predicate.replace("_", " "))
|
return _PREDICATE_HUMANIZE.get(predicate, predicate.replace("_", " "))
|
||||||
|
|
@ -43,6 +99,7 @@ def render_semantic(
|
||||||
secondary: str | None = None,
|
secondary: str | None = None,
|
||||||
language: str | None = None,
|
language: str | None = None,
|
||||||
root: str | None = None,
|
root: str | None = None,
|
||||||
|
negated: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render a semantic surface from intent, subject, predicate, and object.
|
"""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
|
carrying 3-core-language depth), the surface incorporates etymological
|
||||||
precision for Hebrew (root density) and Koine Greek (Logos precision).
|
precision for Hebrew (root density) and Koine Greek (Logos precision).
|
||||||
English base remains unchanged.
|
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)
|
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.
|
# Masterful 3-language depth framing on the articulation side.
|
||||||
# Depth travels with the shared GraphNode from resolve_entry grounding.
|
# Depth travels with the shared GraphNode from resolve_entry grounding.
|
||||||
|
|
@ -72,6 +134,36 @@ def render_semantic(
|
||||||
else:
|
else:
|
||||||
obj_display = f"{obj_display}{depth_note}"
|
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(
|
return template.format(
|
||||||
subject=subject,
|
subject=subject,
|
||||||
predicate_h=predicate_h,
|
predicate_h=predicate_h,
|
||||||
|
|
|
||||||
299
tests/test_negation_survives_articulation.py
Normal file
299
tests/test_negation_survives_articulation.py
Normal 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}"
|
||||||
|
|
@ -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."""
|
speaks scored 0.23 on the identical cases, and nothing surfaced it."""
|
||||||
metrics = run_lane(_load("english_fluency_ood/public/v1")).metrics
|
metrics = run_lane(_load("english_fluency_ood/public/v1")).metrics
|
||||||
assert metrics["passed"] == 117
|
assert metrics["passed"] == 117
|
||||||
assert metrics["serving_passed"] == 27
|
assert metrics["serving_passed"] == 36 # was 27 before ADR-0265
|
||||||
assert "serving_accuracy" in metrics
|
assert "serving_accuracy" in metrics
|
||||||
|
|
||||||
|
|
||||||
def test_the_measured_gap_across_every_scored_corpus() -> None:
|
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()
|
cases = _all_cases()
|
||||||
assert len(cases) == 347
|
assert len(cases) == 347
|
||||||
metrics = run_lane(cases).metrics
|
metrics = run_lane(cases).metrics
|
||||||
assert metrics["passed"] == 340
|
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(
|
@pytest.mark.parametrize(
|
||||||
("bucket", "expected_n", "expected_eval", "expected_serving"),
|
("bucket", "expected_n", "expected_eval", "expected_serving"),
|
||||||
[
|
[
|
||||||
("features", 214, 207, 49),
|
("features", 214, 207, 73), # 49 before ADR-0265
|
||||||
("multi_node", 100, 100, 3),
|
("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:
|
def test_the_serving_realizer_now_distinguishes_a_denial_from_its_assertion() -> None:
|
||||||
"""DEFECT PIN, not a goal. Revise when fixed; never relax.
|
"""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,
|
The recorded defect was::
|
||||||
secondary, language, root)``. There is no ``negated`` parameter, and
|
|
||||||
``realize_semantic`` never reads ``step.negated``. So a negated
|
|
||||||
proposition is served as its **affirmative**:
|
|
||||||
|
|
||||||
negated=False -> 'Knowledge is defined as opinion.'
|
negated=False -> 'Knowledge is defined as opinion.'
|
||||||
negated=True -> '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
|
Byte-identical: the serving writer had no ``negated`` parameter and
|
||||||
refuse-don't-drop: v1b served WRONG by dropping premises it could not
|
``realize_semantic`` never read ``step.negated``. Sizing the exposure
|
||||||
express. Fixing it changes served output, so it is authorization-gated;
|
showed it was **live**, not latent — under ``realizer_grounded_authority``
|
||||||
pinning it here is what stops it from being forgotten.
|
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
|
affirmative = realize_semantic(*_one_step(negated=False)).surface
|
||||||
negated = realize_semantic(*_one_step(negated=True)).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
|
# The eval-only realizer distinguishes them too.
|
||||||
# information reaches the realizer boundary intact.
|
|
||||||
assert realize_target(*_one_step(negated=True)).surface != (
|
assert realize_target(*_one_step(negated=True)).surface != (
|
||||||
realize_target(*_one_step(negated=False)).surface
|
realize_target(*_one_step(negated=False)).surface
|
||||||
)
|
)
|
||||||
assert "not" in realize_target(*_one_step(negated=True)).surface
|
assert "not" in realize_target(*_one_step(negated=True)).surface
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("feature", _UNEXPRESSIBLE)
|
@pytest.mark.parametrize("feature", sorted(set(_UNEXPRESSIBLE) - {"negated"}))
|
||||||
def test_render_semantic_has_no_parameter_for_the_content_it_drops(feature: str) -> None:
|
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."""
|
"""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
|
import inspect
|
||||||
|
|
||||||
from generate.semantic_templates import render_semantic
|
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
|
assert feature not in inspect.signature(render_semantic).parameters
|
||||||
|
|
||||||
|
|
||||||
def test_how_much_of_the_corpus_carries_content_the_serving_writer_drops() -> None:
|
def test_how_much_of_the_corpus_carries_feature_bearing_content() -> None:
|
||||||
"""214 of 347. The scale of the gap, independent of any rubric."""
|
"""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()
|
cases = _all_cases()
|
||||||
carrying = [c for c in cases if _carries_unexpressible(c)]
|
carrying = [c for c in cases if _carries_unexpressible(c)]
|
||||||
assert len(carrying) == 214
|
assert len(carrying) == 214
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue