fix(generate): a proposition graph must be able to represent denial (ADR-0265)
CORE served the affirmative of propositions the user denied. Measured live on
main @ 536d6e55 with realizer_grounded_authority=True:
"evidence does not support truth" -> 'Evidence is verified: what supports truth.'
"evidence supports truth" -> 'Evidence is verified: what supports truth.'
Byte-identical.
Phase 4 (#136) pinned "render_semantic has no negated parameter" as a defect.
Sizing the exposure before acting on it -- the step §7 requires -- showed the
pin was correct and INCOMPLETE. The defect is two drops in series, and the
first is in the graph:
intent.negated parsed from the user's words, always has been
GraphNode NO FIELD FOR IT <-- first drop
ground_graph rebuilds nodes field-by-field; nothing to carry
depth enrichment rebuilds nodes field-by-field; nothing to carry
plan_articulation cannot carry what the node does not hold
realize_semantic never read step.negated
render_semantic no parameter for it <-- second drop
Five separate constructors, each silently defaulting to the affirmative.
Why every gate was green
------------------------
On the default config the ungrounded realizer emits "...", _is_useful_surface
rejects it, and the runtime echo wins the resolver -- and the echo contains the
user's own "does not". The truth path was correct BY ACCIDENT. ADR-0088 Phase B
grounds the graph first, which is exactly when the realizer's surface becomes
useful enough to win. So the defect sat behind a shipped flag, invisible to
every property-of-one-surface test, because nothing compared a denial to its
assertion.
Delivered
---------
- GraphNode.negated, threaded intent -> graph -> ground -> enrich -> step ->
surface. Serialized ONLY when True, so every pre-existing as_dict and every
trace_hash folded from one stays byte-identical -- which is why no lane pin
moves.
- Clause grammar delegated to its one owner. Four of the eight intent
"templates" were never frames; they were plain clauses. They now call
render_step. Writing a second negation implementation in semantic_templates
was the tempting fix and is rejected: Phase 2A spent a unit giving every
linguistic fact one owner, and that design rebuilds the disease one level up.
- Frames that keep a finite verb (VERIFICATION, PROCEDURE, COMPARISON) get an
explicit negated form. RECALL is a speech act with no proposition to deny, so
it falls back to the clause path rather than drop the denial (ADR-0261 §5.1).
Measured
--------
delegation on affirmatives 192/192 byte-identical
serving realizer, all corpora 85/347 -> 109/347 (+24 = the denials)
feature-bearing bucket 49/214 -> 73/214
CONTROL (nothing droppable) 33/33 -> 33/33 unchanged
multi-node (clause joining) 3/100 -> 3/100 unchanged, out of scope
lane pins 11/11 byte-identical, none edited
The control staying 33/33 and multi-node staying 3/100 is the evidence that
this moved the denials and nothing else.
The exhaustive control earned its keep immediately
--------------------------------------------------
It found FIVE more intents serving a denial as its own assertion --
TRANSITIVE_QUERY, FRAME_TRANSFER, NARRATIVE, EXAMPLE, DEDUCTION -- because an
intent with no frame fell back to the UNKNOWN *template* (which cannot say
"not") rather than the UNKNOWN *clause* (which can). The default is now the
capable path, so the next intent added inherits correctness. Found by the
control, not by inspection.
Mutation -- every link reverted individually
--------------------------------------------
baseline 19 pass
graph_from_intent drops intent.negated 8 FAIL
plan_articulation drops node.negated 6 FAIL
ground_graph drops it on rebuild 2 FAIL
realize_semantic stops passing step.negated 6 FAIL
unframed intents fall back to the template 1 FAIL
pipeline depth-enrichment drops it 1 FAIL
The last row was GREEN on the first run -- my fix there was unguarded. That is
what added the structural invariant: every GraphNode(...) built on the serving
path must NAME `negated` or be recorded in an allowlist with a reason. The
defect was five constructors; a per-site test must be written per site, and a
site added without one is invisible. Only recognition/connector.py is exempt
(an EpistemicNode has no polarity to carry).
Four Phase 4 pins are revised, not relaxed -- #136's M2 mutation ("render_semantic
GAINS a negated parameter -> FAIL") has now happened for real, and forced the
deliberate revision it was built to force.
Registered in `smoke` in the same PR that creates it, per the #136 finding that
an unregistered pin runs nowhere.
Still unexpressed, deliberately: quantifier, tense, aspect. NO PRODUCER sets
them anywhere on the serving path, so threading them would be machinery with no
caller. render_step already handles all three the moment a producer exists.
[Verification]: in-worktree on CPython 3.12.13 with `uv sync --locked` --
smoke 641 (was 621), deductive 503, lane pins 11/11 with no pin edited.
This commit is contained in:
parent
536d6e5585
commit
dccce6a7b9
11 changed files with 704 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
|
||||
# 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 —
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
169
docs/adr/ADR-0265-negation-in-the-proposition-graph.md
Normal file
169
docs/adr/ADR-0265-negation-in-the-proposition-graph.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# ADR-0265 — Negation belongs in the proposition graph, and clause grammar has one owner
|
||||
|
||||
- **Status:** Proposed — awaiting ratification by Joshua Shay. **This ADR
|
||||
changes served output.** It fixes a truth defect on a serving path; it flips
|
||||
no flag and changes no default.
|
||||
- **Date:** 2026-07-27
|
||||
- **Implemented by:** #137. Enforcement pins:
|
||||
`tests/test_negation_survives_articulation.py` (registered in the `smoke`
|
||||
pre-push gate in the same PR).
|
||||
- **Arc:** grammar-unification Phase 5
|
||||
(`docs/plans/grammar-unification-2026-07-26.md`), following Phase 4 (#135, #136).
|
||||
- **Governs:** `GraphNode.negated`, `graph_from_intent`, `ground_graph`,
|
||||
`plan_articulation` (`generate/graph_planner.py`),
|
||||
`generate/intent_bridge.py`, the depth-enrichment rebuild in
|
||||
`core/cognition/pipeline.py`, and the division of labour between
|
||||
`generate/semantic_templates.py` (discourse framing) and
|
||||
`generate/templates.py` (clause grammar).
|
||||
- **Supersedes:** the implicit claim in
|
||||
`core/cognition/surface_resolution.py` that the `realize_semantic` path,
|
||||
"granted supremacy by the Shadow Coherence Gate", was safe to grant supremacy
|
||||
*to*. It was not, on any turn carrying a denial.
|
||||
- **Builds on:** ADR-0261 §5.1 (refuse, don't drop) — this is the same failure
|
||||
in a different organ.
|
||||
|
||||
## 1. Context
|
||||
|
||||
Phase 4 (#136) recorded that `render_semantic` has no `negated` parameter and
|
||||
pinned the consequence as a defect. That pin was correct and incomplete. The
|
||||
defect is not one missing parameter; it is **two independent drops in series**,
|
||||
and the first one is in the graph.
|
||||
|
||||
`generate/intent.py` has always recovered denial from the user's text
|
||||
(`intent.negated`, set from the `does|do|did not` group of
|
||||
`_DECLARATIVE_RELATION_RE`). Downstream, every stage discarded it:
|
||||
|
||||
| stage | what it did with the denial |
|
||||
|---|---|
|
||||
| `graph_from_intent` | dropped it — `GraphNode` had **no field** for it |
|
||||
| `ground_graph` | rebuilt nodes field-by-field; nothing to carry |
|
||||
| pipeline depth enrichment | rebuilt nodes field-by-field; nothing to carry |
|
||||
| `plan_articulation` | could not carry what the node did not hold |
|
||||
| `realize_semantic` | never read `step.negated` |
|
||||
| `render_semantic` | had no parameter for it |
|
||||
|
||||
Measured on `main` @ `536d6e55`, with `realizer_grounded_authority=True`:
|
||||
|
||||
```
|
||||
"evidence does not support truth" -> 'Evidence is verified: what supports truth.'
|
||||
"evidence supports truth" -> 'Evidence is verified: what supports truth.'
|
||||
```
|
||||
|
||||
Byte-identical. **CORE affirmed what the user denied.**
|
||||
|
||||
### 1.1 Why every existing gate was green
|
||||
|
||||
On the default config the realizer runs on an ungrounded graph, emits `...`,
|
||||
and `_is_useful_surface` rejects it — so the runtime's echo wins the resolver,
|
||||
and the echo happens to contain the user's own "does not". The truth path was
|
||||
correct **by accident**. ADR-0088 Phase B removes the accident by grounding the
|
||||
graph before realizing, which is precisely when the realizer's surface becomes
|
||||
useful enough to win.
|
||||
|
||||
So the defect sat behind a shipped flag, invisible to every property-of-one-
|
||||
surface test, because no test compared a denial to its assertion. The failure
|
||||
mode is [[feedback-ask-what-if-the-thing-were-absent]] in its purest form: the
|
||||
measurement would have looked identical with the mechanism removed, because the
|
||||
mechanism was never there.
|
||||
|
||||
## 2. Decision
|
||||
|
||||
**R1 — A proposition graph must be able to represent denial.**
|
||||
`GraphNode` gains `negated: bool = False`, threaded from `intent.negated`
|
||||
through `graph_from_intent`, `ground_graph`, the pipeline's depth enrichment,
|
||||
`intent_bridge`, and into `ArticulationStep` via `plan_articulation`.
|
||||
|
||||
Serialized **only when True**, so every pre-existing `as_dict` — and every
|
||||
`trace_hash` folded from one — stays byte-identical. This is why the change
|
||||
lands without touching a lane pin.
|
||||
|
||||
**R2 — Clause grammar has exactly one owner.**
|
||||
Four of the eight intent "templates" were never frames; they were plain clauses
|
||||
(`{subject} {predicate_h} {obj}`, optionally with a pinned predicate or a
|
||||
prefix). Those now delegate to `generate.templates.render_step`, the single
|
||||
owner of English clause grammar.
|
||||
|
||||
Writing a second copy of negation into `semantic_templates.py` was the
|
||||
tempting fix and is rejected: Phase 2A spent a whole unit giving every
|
||||
linguistic fact one owner, and a second negation implementation rebuilds that
|
||||
disease one level up. The rejected design would have grown quantifier
|
||||
agreement, then tense, then aspect, until `semantic_templates.py` was a second
|
||||
grammar.
|
||||
|
||||
The delegation is **provably output-preserving**: 192 of 192 surfaces are
|
||||
byte-identical across all four delegated intents × every predicate in
|
||||
`PREDICATE_DISPLAY` (plus an unknown one) × every object sentinel. The only
|
||||
surfaces this ADR moves are ones that were wrong.
|
||||
|
||||
**R3 — A frame that cannot deny must not pretend to assert.**
|
||||
Frames retaining a finite verb (VERIFICATION, PROCEDURE, COMPARISON) get an
|
||||
explicit negated form. RECALL — a speech act, with no proposition in it to deny
|
||||
— falls back to the clause path rather than emit a frame that silently drops
|
||||
the denial. Losing the framing is the correct trade against asserting the
|
||||
opposite of the graph (ADR-0261 §5.1).
|
||||
|
||||
**R4 — An intent with no frame of its own falls back to the UNKNOWN *clause*,
|
||||
not the UNKNOWN *template*.**
|
||||
The old default sent unframed intents to a format string that cannot say "not".
|
||||
Five live intents were sitting on it — TRANSITIVE_QUERY, FRAME_TRANSFER,
|
||||
NARRATIVE, EXAMPLE, DEDUCTION — each serving a denial as its own assertion.
|
||||
They were found by the exhaustive control in R5, not by inspection. The default
|
||||
must be the capable path so the next intent added inherits correctness.
|
||||
|
||||
**R5 — The pins assert a difference, and one of them is structural.**
|
||||
- The load-bearing test compares a denial's served surface to its assertion's,
|
||||
end to end through the real pipeline under `realizer_grounded_authority`.
|
||||
Asserting a *difference* is the only shape that catches a writer collapsing a
|
||||
distinction; every property-of-one-surface test was green throughout.
|
||||
- An exhaustive control requires **every** `IntentTag` to distinguish the two.
|
||||
- A structural invariant requires every `GraphNode(...)` construction in
|
||||
serving code to name `negated` explicitly or be recorded in an allowlist with
|
||||
a reason. The defect was five separate constructors each defaulting to
|
||||
`False`; a per-site test must be written per site, and a site added without
|
||||
one is invisible. Only `recognition/connector.py` is exempt, because an
|
||||
`EpistemicNode` has no polarity to carry.
|
||||
|
||||
## 3. Consequences
|
||||
|
||||
**What changes for a user.** On turns where the realizer's surface wins the
|
||||
resolver and the input carried a denial, CORE now says "not". Previously it
|
||||
said the opposite. No other turn changes: lane pins are 11/11 byte-identical
|
||||
and smoke/deductive counts move only by the new tests.
|
||||
|
||||
**What this does not do.** `quantifier`, `tense` and `aspect` remain
|
||||
unexpressed on the serving path. That is deliberate, not an oversight: **no
|
||||
producer sets them.** `plan_articulation` has no source for them, and no parser
|
||||
populates them, so threading them now would be speculative machinery with no
|
||||
caller — the honest-placeholder discipline. They are expressible the moment a
|
||||
producer exists, because the clause owner already handles all three.
|
||||
|
||||
**What it costs.** `render_semantic` now imports `render_step`, so
|
||||
`semantic_templates` depends on `templates`. That is the correct direction:
|
||||
framing composes grammar, not the reverse.
|
||||
|
||||
**Thesis check** (`decoding, not generating`): a graph that cannot represent
|
||||
denial cannot *decode* a denial — it can only generate an affirmative that
|
||||
resembles the input. Carrying polarity through the graph makes the articulation
|
||||
a function of what was understood. This ADR strengthens the thesis rather than
|
||||
straining it.
|
||||
|
||||
## 4. Alternatives rejected
|
||||
|
||||
**Add negation handling to `semantic_templates.py` directly.** Smallest diff,
|
||||
and it rebuilds the two-grammar disease Phase 2A removed. Rejected under R2.
|
||||
|
||||
**Promote `realize_target` wholesale to the serving path** (Phase 4 option (a)
|
||||
in its raw form). Maximum hash churn, and it *loses* the intent framing and
|
||||
depth notes that `realize_semantic` genuinely has. The delegation gets the
|
||||
grammar without discarding the framing. Rejected as strictly worse.
|
||||
|
||||
**Leave it pinned as a defect** (the Phase 4 resting state). A documented truth
|
||||
defect with a green test beside it is exactly the comfortable state this arc
|
||||
exists to make uncomfortable. Rejected once the exposure was measured as live
|
||||
rather than latent.
|
||||
|
||||
**Fail closed — refuse to realize a step carrying a denial.** Consistent with
|
||||
ADR-0261, and strictly worse here: the clause owner can already *express* the
|
||||
denial correctly, so refusing would discard information CORE holds and
|
||||
understands. Refusal is right when expression is impossible, not when it is one
|
||||
delegation away.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -71,15 +71,36 @@ class GraphNode:
|
|||
root: str | None = None
|
||||
morphology_id: str | None = None
|
||||
# 3-lang depth support for PropGraph spine (comprehend/articulate/think via roots)
|
||||
negated: bool = False
|
||||
"""Whether the proposition is DENIED rather than asserted.
|
||||
|
||||
The intent parser has always recovered this from the user's text
|
||||
(``intent.negated``, ``generate/intent.py``), and the graph then dropped it
|
||||
on the floor: ``GraphNode`` had no slot for it, so ``plan_articulation``
|
||||
could not carry it and the realizer could not express it. The measured
|
||||
consequence, under ``realizer_grounded_authority``:
|
||||
|
||||
"evidence does not support truth" -> 'Evidence is verified: what supports truth.'
|
||||
"evidence supports truth" -> 'Evidence is verified: what supports truth.'
|
||||
|
||||
Byte-identical. CORE affirmed what the user denied. A proposition graph that
|
||||
cannot represent denial cannot decode a denial, which is the thesis check
|
||||
this field exists to pass.
|
||||
|
||||
Serialized only when True, so every pre-existing ``as_dict`` — and every
|
||||
``trace_hash`` folded from one — stays byte-identical.
|
||||
"""
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
d = {
|
||||
d: dict[str, object] = {
|
||||
"node_id": self.node_id,
|
||||
"subject": self.subject,
|
||||
"predicate": self.predicate,
|
||||
"object": self.obj,
|
||||
"source_intent": self.source_intent.value,
|
||||
}
|
||||
if self.negated:
|
||||
d["negated"] = True
|
||||
if self.language is not None:
|
||||
d["language"] = self.language
|
||||
if self.root is not None:
|
||||
|
|
@ -289,6 +310,7 @@ def graph_from_intent(
|
|||
obj=intent.secondary_subject or "<pending>",
|
||||
source_intent=intent.tag,
|
||||
# depth fields populated later via resolve_entry + grounding enrichment
|
||||
negated=intent.negated,
|
||||
)
|
||||
right = GraphNode(
|
||||
node_id="p1",
|
||||
|
|
@ -296,6 +318,7 @@ def graph_from_intent(
|
|||
predicate=predicate,
|
||||
obj=intent.subject,
|
||||
source_intent=intent.tag,
|
||||
negated=intent.negated,
|
||||
)
|
||||
edge = GraphEdge(source="p0", target="p1", relation=Relation.CONTRAST)
|
||||
return graph.add_node(left).add_node(right).add_edge(edge)
|
||||
|
|
@ -308,6 +331,7 @@ def graph_from_intent(
|
|||
predicate=predicate,
|
||||
obj=prior_node_id or "<prior>",
|
||||
source_intent=intent.tag,
|
||||
negated=intent.negated,
|
||||
)
|
||||
graph = graph.add_node(root)
|
||||
if prior_node_id is not None:
|
||||
|
|
@ -324,6 +348,7 @@ def graph_from_intent(
|
|||
predicate=predicate,
|
||||
obj="<pending>",
|
||||
source_intent=intent.tag,
|
||||
negated=intent.negated,
|
||||
)
|
||||
return graph.add_node(root)
|
||||
|
||||
|
|
@ -365,6 +390,12 @@ def ground_graph(
|
|||
language=lang,
|
||||
root=rt,
|
||||
morphology_id=mid,
|
||||
# Same drop-on-rebuild bug this field exists to fix: grounding
|
||||
# is the ADR-0088 Phase B path, i.e. exactly the path on which
|
||||
# the realizer's surface becomes useful enough to WIN the
|
||||
# resolver. Losing negation here would restore the defect on
|
||||
# the only turns where it reaches a user.
|
||||
negated=node.negated,
|
||||
))
|
||||
else:
|
||||
new_nodes.append(node)
|
||||
|
|
@ -400,6 +431,7 @@ def plan_articulation(graph: PropositionGraph) -> ArticulationTarget:
|
|||
move=move,
|
||||
predicate=node.predicate,
|
||||
subject=node.subject,
|
||||
negated=node.negated,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -9,12 +9,32 @@ Design constraints:
|
|||
- No random template selection
|
||||
- Deterministic: same (intent, predicate, subject, object) -> same surface
|
||||
- Uses seed pack vocabulary directly
|
||||
|
||||
Division of labour (Phase 5)
|
||||
----------------------------
|
||||
This module owns **discourse framing** — which frame an intent speaks in, and
|
||||
the 3-language depth notes. It does **not** own clause grammar.
|
||||
|
||||
Four of the eight intent templates are not frames at all: they are ordinary
|
||||
clauses (``{subject} {predicate_h} {obj}``, optionally with a fixed predicate
|
||||
or a prefix). Those are handed to ``generate.templates.render_step``, the
|
||||
single owner of English clause grammar — number, agreement, negation, tense,
|
||||
aspect. Writing a second copy of negation here is how this codebase came to
|
||||
have two grammars in the first place; Phase 2A spent a whole unit undoing that
|
||||
duplication, and the fix must not rebuild it one level up.
|
||||
|
||||
The delegation is **provably output-preserving**: across all four delegated
|
||||
intents x every predicate in ``PREDICATE_DISPLAY`` (plus an unknown one) x
|
||||
every object sentinel, 192 of 192 surfaces are byte-identical to the
|
||||
pre-delegation output. The only surfaces this moves are ones that were WRONG.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from generate.lexicon import PREDICATE_DISPLAY
|
||||
from generate.graph_planner import RhetoricalMove
|
||||
from generate.intent import IntentTag
|
||||
from generate.lexicon import PREDICATE_DISPLAY
|
||||
from generate.templates import render_step
|
||||
|
||||
|
||||
_INTENT_TEMPLATES: dict[IntentTag, str] = {
|
||||
|
|
@ -28,8 +48,44 @@ _INTENT_TEMPLATES: dict[IntentTag, str] = {
|
|||
IntentTag.UNKNOWN: "{subject} {predicate_h} {obj}",
|
||||
}
|
||||
|
||||
#: Intents whose template IS a clause, mapped to (predicate override, move).
|
||||
#:
|
||||
#: ``None`` keeps the step's own predicate. DEFINITION and CAUSE pin one,
|
||||
#: because the *intent* — not the node — decides how a definition is framed.
|
||||
#: That was already true of the format strings above; it is preserved exactly.
|
||||
_CLAUSE_INTENTS: dict[IntentTag, tuple[str | None, RhetoricalMove]] = {
|
||||
IntentTag.UNKNOWN: (None, RhetoricalMove.ASSERT),
|
||||
IntentTag.CORRECTION: (None, RhetoricalMove.CORRECT),
|
||||
IntentTag.DEFINITION: ("is_defined_as", RhetoricalMove.ASSERT),
|
||||
IntentTag.CAUSE: ("is_grounded_in", RhetoricalMove.ASSERT),
|
||||
}
|
||||
|
||||
#: Frames that are not clauses but still carry a finite verb, so a denial still
|
||||
#: has somewhere to attach. Written out rather than derived: negating a frame
|
||||
#: is a wording decision per frame, not a rule.
|
||||
_NEGATED_FRAMES: dict[IntentTag, str] = {
|
||||
IntentTag.VERIFICATION: "{subject} is not verified: {obj}",
|
||||
IntentTag.PROCEDURE: "first, {obj}; then, {subject} does not follow",
|
||||
IntentTag.COMPARISON: (
|
||||
"{subject} and {secondary} are not distinguished: "
|
||||
"{subject} does not {predicate_h} {secondary}"
|
||||
),
|
||||
}
|
||||
|
||||
#: RECALL is a speech act ("recalling X: Y"), not an assertion, so it contains
|
||||
#: no proposition to deny. Rather than emit a frame that quietly drops the
|
||||
#: denial — the defect this change exists to remove — a negated RECALL falls
|
||||
#: back to the clause path, which can say "not". Losing the framing is the
|
||||
#: right trade against asserting the opposite of the graph (ADR-0261 §5.1,
|
||||
#: refuse-don't-drop).
|
||||
_UNFRAMEABLE_WHEN_NEGATED: frozenset[IntentTag] = frozenset({IntentTag.RECALL})
|
||||
|
||||
_PREDICATE_HUMANIZE: dict[str, str] = PREDICATE_DISPLAY
|
||||
|
||||
#: Object sentinels meaning "not grounded yet". ``render_step`` knows only
|
||||
#: ``<pending>``; this module has always also mapped ``<prior>``.
|
||||
_OBJ_SENTINELS = ("<pending>", "<prior>")
|
||||
|
||||
|
||||
def humanize_predicate(predicate: str) -> str:
|
||||
return _PREDICATE_HUMANIZE.get(predicate, predicate.replace("_", " "))
|
||||
|
|
@ -43,6 +99,7 @@ def render_semantic(
|
|||
secondary: str | None = None,
|
||||
language: str | None = None,
|
||||
root: str | None = None,
|
||||
negated: bool = False,
|
||||
) -> str:
|
||||
"""Render a semantic surface from intent, subject, predicate, and object.
|
||||
|
||||
|
|
@ -50,10 +107,15 @@ def render_semantic(
|
|||
carrying 3-core-language depth), the surface incorporates etymological
|
||||
precision for Hebrew (root density) and Koine Greek (Logos precision).
|
||||
English base remains unchanged.
|
||||
|
||||
``negated`` denies the proposition. It reaches here from the user's own
|
||||
words: ``intent.negated`` -> ``GraphNode.negated`` ->
|
||||
``ArticulationStep.negated``. Before Phase 5 that chain was broken at the
|
||||
graph and this function had no parameter for it, so CORE served the
|
||||
affirmative of every denial it was given.
|
||||
"""
|
||||
template = _INTENT_TEMPLATES.get(intent, _INTENT_TEMPLATES[IntentTag.UNKNOWN])
|
||||
predicate_h = humanize_predicate(predicate)
|
||||
obj_display = obj if obj not in ("<pending>", "<prior>") else "..."
|
||||
obj_display = obj if obj not in _OBJ_SENTINELS else "..."
|
||||
|
||||
# Masterful 3-language depth framing on the articulation side.
|
||||
# Depth travels with the shared GraphNode from resolve_entry grounding.
|
||||
|
|
@ -72,6 +134,36 @@ def render_semantic(
|
|||
else:
|
||||
obj_display = f"{obj_display}{depth_note}"
|
||||
|
||||
# --- clause-shaped intents: hand the clause to its one owner ----------
|
||||
#
|
||||
# An intent with no frame of its own has always fallen back to the UNKNOWN
|
||||
# *template*; it now falls back to the UNKNOWN *clause*. That distinction
|
||||
# is the whole point: the template cannot say "not" and the clause can, so
|
||||
# the default has to be the capable path. Five live intents were sitting on
|
||||
# that fallback — TRANSITIVE_QUERY, FRAME_TRANSFER, NARRATIVE, EXAMPLE,
|
||||
# DEDUCTION — each serving a denial as its own assertion, and the next
|
||||
# intent added without a template would have joined them silently.
|
||||
clause_intent = intent
|
||||
if intent not in _INTENT_TEMPLATES:
|
||||
clause_intent = IntentTag.UNKNOWN
|
||||
elif negated and intent in _UNFRAMEABLE_WHEN_NEGATED:
|
||||
clause_intent = IntentTag.UNKNOWN
|
||||
if clause_intent in _CLAUSE_INTENTS:
|
||||
override, move = _CLAUSE_INTENTS[clause_intent]
|
||||
return render_step(
|
||||
move,
|
||||
subject,
|
||||
override or predicate,
|
||||
obj_display,
|
||||
negated=negated,
|
||||
)
|
||||
|
||||
# --- frames: keep the discourse shape, deny inside it -----------------
|
||||
if negated and intent in _NEGATED_FRAMES:
|
||||
template = _NEGATED_FRAMES[intent]
|
||||
else:
|
||||
template = _INTENT_TEMPLATES.get(intent, _INTENT_TEMPLATES[IntentTag.UNKNOWN])
|
||||
|
||||
return template.format(
|
||||
subject=subject,
|
||||
predicate_h=predicate_h,
|
||||
|
|
|
|||
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."""
|
||||
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
|
||||
|
|
|
|||
Loading…
Reference in a new issue