core/generate/surface.py
Shay 0fa498e98b
fix(surface): add empty-slot guard — fallback when subject+predicate both empty
Add fallback mechanism for empty subject and predicate in surface generation.
2026-05-14 13:44:09 -07:00

305 lines
9.6 KiB
Python

"""generate/surface.py — SentenceAssembler (ADR-0012)
Bridges ArticulationPlan + GenerationResult.tokens into a fully coherent
surface sentence. This is the final realisation stage: every prior layer
(field walk, proposition formation, articulation) feeds here.
Contract:
Input: ArticulationPlan — subject / predicate / object slots
Sequence[str] — ordered token walk from generate()
DialogueRole — assert | elaborate | question | refute
output_language: str — BCP-47 language tag (default "en")
Output: str — a single grammatical sentence
Determinism guarantee:
Identical (plan, tokens, role, language) → identical output string.
No randomness, no model calls, no state mutation.
Sentence templates by DialogueRole (English):
assert → "{Subject} {predicate} {object}."
elaborate → "{Subject} {predicate} {object}{elaboration}."
question → "Does {subject} {predicate} {object}?"
refute → "{Subject} does not {predicate} {object}."
Elaboration material is drawn from the walk tokens that survive
_STOP_SURFACES filtering, deduplicated, capped at _MAX_ELAB_TOKENS, and
joined with commas. This keeps elaboration grounded in the actual
manifold walk rather than invented content.
Language routing:
Hebrew (he) — VSO order following articulation.py convention
Greek (grc) — SOV order following articulation.py convention
All others — SVO (English default)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Sequence
from generate.articulation import ArticulationPlan
from generate.dialogue import DialogueRole
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
# Tokens that carry no propositional content; excluded from elaboration.
_STOP_SURFACES: frozenset[str] = frozenset({
"it", "to", "a", "an", "the", "and", "or", "but", "in", "on",
"at", "of", "for", "with", "by", "from", "is", "are", "was",
"were", "be", "been", "being", "have", "has", "had", "do",
"does", "did", "will", "would", "could", "should", "may",
"might", "shall", "can", "word", "what", "who", "how",
"why", "when", "which", "that", "this", "these", "those",
})
_MAX_ELAB_TOKENS: int = 4 # max walk tokens woven into elaboration
# ---------------------------------------------------------------------------
# SentencePlan — immutable record of the assembled sentence components
# ---------------------------------------------------------------------------
@dataclass(frozen=True, slots=True)
class SentencePlan:
"""Fully resolved sentence before punctuation is appended."""
subject: str
predicate_phrase: str
object_phrase: str | None
elaboration: str | None # None unless role == "elaborate"
dialogue_role: str
output_language: str
surface: str # the final rendered sentence
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _cap(word: str) -> str:
"""Capitalise the first codepoint; leave the rest as-is."""
if not word:
return word
return word[0].upper() + word[1:]
def _elaboration_tokens(
tokens: Sequence[str],
already_used: frozenset[str],
) -> list[str]:
"""Pick walk tokens that add propositional content beyond the slots."""
seen: set[str] = set()
result: list[str] = []
for tok in tokens:
low = tok.lower()
if (
low in _STOP_SURFACES
or low in already_used
or low in seen
or not tok.isalpha()
):
continue
seen.add(low)
result.append(tok)
if len(result) >= _MAX_ELAB_TOKENS:
break
return result
def _assemble_en(
subject: str,
predicate: str,
object_: str | None,
elab_tokens: list[str],
role: str,
) -> str:
"""English SVO sentence assembly."""
subj = _cap(subject)
obj = object_ or ""
if role == "assert":
parts = [subj, predicate]
if obj:
parts.append(obj)
return " ".join(parts) + "."
if role == "elaborate":
parts = [subj, predicate]
if obj:
parts.append(obj)
base = " ".join(parts)
if elab_tokens:
elab = ", ".join(elab_tokens)
return f"{base}{elab}."
return base + "."
if role == "question":
# "Does subject predicate object?"
parts = ["Does", subject, predicate]
if obj:
parts.append(obj)
return " ".join(parts) + "?"
if role == "refute":
# "Subject does not predicate object."
parts = [subj, "does not", predicate]
if obj:
parts.append(obj)
return " ".join(parts) + "."
# fallback — treat as assert
parts = [subj, predicate]
if obj:
parts.append(obj)
return " ".join(parts) + "."
def _assemble_he(
subject: str,
predicate: str,
object_: str | None,
elab_tokens: list[str],
role: str,
) -> str:
"""Hebrew VSO — verb precedes subject, object follows."""
obj = object_ or ""
if role == "question":
parts = ["\u05d4\u05d0\u05dd", predicate, subject]
if obj:
parts.append(obj)
return " ".join(parts) + "?"
if role == "refute":
parts = ["\u05dc\u05d0", predicate, subject]
if obj:
parts.append(obj)
return " ".join(parts) + "."
parts = [predicate, subject]
if obj:
parts.append(obj)
base = " ".join(parts)
if role == "elaborate" and elab_tokens:
elab = ", ".join(elab_tokens)
return f"{base} \u2014 {elab}."
return base + "."
def _assemble_grc(
subject: str,
predicate: str,
object_: str | None,
elab_tokens: list[str],
role: str,
) -> str:
"""Ancient Greek SOV — subject, object, verb."""
subj = _cap(subject)
obj = object_ or ""
if role == "question":
parts = [subj]
if obj:
parts.append(obj)
parts.append(predicate)
return " ".join(parts) + ";"
if role == "refute":
neg = "\u03bf\u1f50" # ou
parts = [subj, neg]
if obj:
parts.append(obj)
parts.append(predicate)
return " ".join(parts) + "."
parts = [subj]
if obj:
parts.append(obj)
parts.append(predicate)
base = " ".join(parts)
if role == "elaborate" and elab_tokens:
elab = ", ".join(elab_tokens)
return f"{base} \u2014 {elab}."
return base + "."
# ---------------------------------------------------------------------------
# SentenceAssembler — public API
# ---------------------------------------------------------------------------
class SentenceAssembler:
"""Stateless assembler: maps (plan, tokens, role) to a surface sentence.
No field state is held. All inputs are consumed read-only.
The same instance may be shared across sessions.
"""
def assemble(
self,
plan: ArticulationPlan,
tokens: Sequence[str],
role: DialogueRole = "assert",
) -> SentencePlan:
"""Produce a SentencePlan (with .surface) from an ArticulationPlan.
Steps:
1. Extract slot strings from the plan.
2. Filter elaboration tokens from the walk.
3. Route to language-specific assembler.
4. Return an immutable SentencePlan.
"""
subject = plan.subject or ""
predicate = plan.predicate or ""
object_ = plan.object or None
lang = plan.output_language or "en"
role_str = str(role)
# Collect slots already present so elaboration doesn't repeat them.
used_slots: frozenset[str] = frozenset(
w.lower()
for w in [subject, predicate, object_]
if w
)
elab_tokens = _elaboration_tokens(tokens, used_slots)
# Empty-slot guard: if slot versors were missing, both subject and
# predicate will be empty. Fall back to the raw articulation surface
# rather than emitting a bare ".".
if not subject and not predicate:
fallback = plan.surface or " ".join(t for t in tokens if t)
return SentencePlan(
subject="",
predicate_phrase="",
object_phrase=object_,
elaboration=None,
dialogue_role=role_str,
output_language=lang,
surface=fallback,
)
# Language dispatch
if lang == "he":
surface = _assemble_he(subject, predicate, object_, elab_tokens, role_str)
elif lang == "grc":
surface = _assemble_grc(subject, predicate, object_, elab_tokens, role_str)
else:
surface = _assemble_en(subject, predicate, object_, elab_tokens, role_str)
return SentencePlan(
subject=subject,
predicate_phrase=predicate,
object_phrase=object_,
elaboration=", ".join(elab_tokens) if elab_tokens else None,
dialogue_role=role_str,
output_language=lang,
surface=surface,
)
# Module-level default instance (stateless — safe to share)
default_assembler = SentenceAssembler()
def assemble(
plan: ArticulationPlan,
tokens: Sequence[str],
role: DialogueRole = "assert",
) -> str:
"""Convenience wrapper returning only the surface string."""
return default_assembler.assemble(plan, tokens, role).surface