core/generate/semantic_templates.py
Shay dccce6a7b9 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.
2026-07-27 13:08:17 -07:00

172 lines
7.4 KiB
Python

"""Intent-aware semantic templates for the realizer.
Maps (IntentTag, relation_predicate) pairs to deterministic surface
templates that use the seed pack's relation predicates (defines, means,
grounds, supports, contrasts_with, corrects).
Design constraints:
- No LLM fallback
- 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.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] = {
IntentTag.DEFINITION: "{subject} is defined as {obj}",
IntentTag.CAUSE: "{subject} is grounded in {obj}",
IntentTag.PROCEDURE: "first, {obj}; then, {subject} follows",
IntentTag.COMPARISON: "{subject} and {secondary} are distinguished: {subject} {predicate_h} {secondary}",
IntentTag.CORRECTION: "correction: {subject} {predicate_h} {obj}",
IntentTag.RECALL: "recalling {subject}: {obj}",
IntentTag.VERIFICATION: "{subject} is verified: {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
#: 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("_", " "))
def render_semantic(
intent: IntentTag,
subject: str,
predicate: str,
obj: str,
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.
When language + root are supplied (from enriched PropositionGraph nodes
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.
"""
predicate_h = humanize_predicate(predicate)
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.
if language and root and language != "en":
if language == "he":
depth_note = f" (Hebrew root: {root})"
elif language in ("grc", "el"):
depth_note = f" (Koine Greek: {root})"
else:
depth_note = f" ({language} root: {root})"
# For definition-style intents, highlight the term itself.
# For others, qualify the object referent.
if intent in (IntentTag.DEFINITION, IntentTag.RECALL, IntentTag.VERIFICATION):
subject = f"{subject}{depth_note}"
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,
obj=obj_display,
secondary=secondary or obj_display,
)