Closes the 'identity is load-bearing but not visibly differentiated'
gap noted at the end of ADR-0027. Pack swap now produces visibly
different surfaces on identical trajectories at the same alignment.
Schema bump — packs gain an optional 'surface_preferences' block:
hedge_threshold_strong, hedge_threshold_soft → band entries
preferred_hedge_strong, preferred_hedge_soft → phrases per band
claim_strength → balanced|qualified|affirmative
qualified_band_high, preferred_qualifier → marginal-band shaping
Loader enforces threshold ordering (strong <= soft <= qual_high),
phrase length bounds, and the enum-of-three for claim_strength.
Missing block resolves to defaults that reproduce pre-ADR behavior
byte-for-byte; existing tests pass unchanged.
Algorithm (deterministic, surface-only, no sampling/repair/normalize):
alignment < strong → preferred_hedge_strong + lower-cased surface
alignment < soft → preferred_hedge_soft + lower-cased surface
soft <= alignment < qual_high
and claim_strength=qualified → preferred_qualifier + lower-cased surface
otherwise → bare surface
Three v1 pack profiles:
default_general_v1 balanced; 0.40 / 0.50 / 0.75 ; 'It seems that' / 'Perhaps'
precision_first_v1 qualified; 0.55 / 0.70 / 0.85 ; 'Arguably,' / 'In some cases,' / 'Under certain conditions,'
generosity_first_v1 affirmative; 0.20 / 0.30 / 0.50 ; default hedge phrases
Re-ratified. New MasteryReport SHAs (superseding Phase-5):
default_general_v1 → ddc1ba127231272660e6a435e177227558461b0278572a95635b416c3e1dec5a
precision_first_v1 → cb5fb2323214a26afda33f2a67e22f38fe49f4763829d48ef67fd41241aba33c
generosity_first_v1 → 94f2f49e1b16c7498fb52b8f9864eecc198618933dc8381a01b809c146826db7
Files touched:
* core/physics/identity.py — new SurfacePreferences dataclass;
IdentityManifold gains 'surface_preferences' field with defaults.
* packs/identity/loader.py — _build_surface_preferences() parses,
bounds-checks (threshold ordering, claim_strength enum, phrase
length, threshold ranges); SurfacePreferences round-trips.
* generate/surface.py — SurfaceContext gains 7 new fields with defaults
matching the pre-ADR module-level HEDGE_STRONG_THRESHOLD /
HEDGE_SOFT_THRESHOLD; _apply_hedge takes the full context and
implements the four-band algorithm; module-level constants retained
for back-compat.
* chat/runtime.py — _build_surface_context lifts manifold.surface_preferences
into SurfaceContext.
* packs/identity/*.json — three v1 packs gain surface_preferences blocks
tuned to their roles; re-ratified via scripts/ratify_identity_packs.py
(idempotent).
* tests/test_identity_surface_divergence.py — 15 tests covering hedge
bands, claim_strength bands, pack-swap divergence proof, and runtime
context wiring.
Suite status: cognition 121, teaching 17, runtime 19, formation 182,
smoke 67 — all green. test_identity_packs.py 23/23, new
test_identity_surface_divergence.py 15/15.
Docs: ADR-0028 (Accepted) records the decision and verification; ADR-0027
status updated to point to ADR-0028 for deep realizer wiring; README
§Identity Packs notes the visible divergence; docs/identity_packs.md
gains a §Surface preferences section and closes the known-limit #1
about invisible surface differentiation.
293 lines
10 KiB
Python
293 lines
10 KiB
Python
"""generate/surface.py — deterministic sentence assembly."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Sequence
|
|
|
|
from generate.articulation import ArticulationPlan
|
|
from generate.dialogue import DialogueRole
|
|
|
|
_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
|
|
# Legacy default thresholds — used when SurfaceContext is constructed
|
|
# without pack-supplied identity surface preferences. Identical to the
|
|
# pre-ADR-0028 hardcoded values; do not change without bumping every
|
|
# downstream test that asserts on these constants.
|
|
HEDGE_STRONG_THRESHOLD: float = 0.4
|
|
HEDGE_SOFT_THRESHOLD: float = 0.5
|
|
_DEFAULT_HEDGE_STRONG_PHRASE: str = "It seems that"
|
|
_DEFAULT_HEDGE_SOFT_PHRASE: str = "Perhaps"
|
|
_DEFAULT_QUALIFIER_PHRASE: str = "In some cases,"
|
|
_DEFAULT_QUALIFIED_BAND_HIGH: float = 0.75
|
|
CONTRAST_THRESHOLD: float = 0.3
|
|
_SLOT_PRONOUN: dict[str, str] = {
|
|
"neut_sg": "it",
|
|
"plural": "they",
|
|
"masc_sg": "he",
|
|
"fem_sg": "she",
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SurfaceContext:
|
|
active_referent_surface: str = ""
|
|
active_referent_slot: str = "neut_sg"
|
|
identity_alignment: float = 1.0
|
|
valence_delta: float = 0.0
|
|
elab_conjunction: str = ""
|
|
# ADR-0028 — pack-supplied identity surface preferences. Defaults
|
|
# preserve the pre-ADR ``_apply_hedge`` behavior byte-for-byte when
|
|
# the chat runtime is constructed without an identity-pack manifold
|
|
# (no test should ever exercise that path post-ADR-0027, but the
|
|
# defaults keep ``SurfaceContext()`` legal and harmless).
|
|
hedge_threshold_strong: float = HEDGE_STRONG_THRESHOLD
|
|
hedge_threshold_soft: float = HEDGE_SOFT_THRESHOLD
|
|
preferred_hedge_strong: str = _DEFAULT_HEDGE_STRONG_PHRASE
|
|
preferred_hedge_soft: str = _DEFAULT_HEDGE_SOFT_PHRASE
|
|
claim_strength: str = "balanced"
|
|
qualified_band_high: float = _DEFAULT_QUALIFIED_BAND_HIGH
|
|
preferred_qualifier: str = _DEFAULT_QUALIFIER_PHRASE
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class SentencePlan:
|
|
subject: str
|
|
predicate_phrase: str
|
|
object_phrase: str | None
|
|
elaboration: str | None
|
|
dialogue_role: str
|
|
output_language: str
|
|
surface: str
|
|
|
|
|
|
def _cap(word: str) -> str:
|
|
return word[0].upper() + word[1:] if word else word
|
|
|
|
|
|
def _elaboration_tokens(tokens: Sequence[str], already_used: frozenset[str]) -> list[str]:
|
|
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 _join_elab(elab_tokens: list[str], conjunction: str) -> str:
|
|
if not elab_tokens:
|
|
return ""
|
|
if len(elab_tokens) == 1:
|
|
return elab_tokens[0]
|
|
return f"{', '.join(elab_tokens[:-1])} {conjunction} {elab_tokens[-1]}"
|
|
|
|
|
|
def _pick_conjunction(valence_delta: float, override: str) -> str:
|
|
return override or ("but" if valence_delta < 0 else "and")
|
|
|
|
|
|
def _elaboration_string(elab_tokens: list[str], ctx: SurfaceContext | None) -> str:
|
|
return _join_elab(
|
|
elab_tokens,
|
|
_pick_conjunction(
|
|
ctx.valence_delta if ctx else 0.0,
|
|
ctx.elab_conjunction if ctx else "",
|
|
),
|
|
)
|
|
|
|
|
|
def _coref_subject(subject: str, ctx: SurfaceContext | None) -> str:
|
|
if ctx is None or not ctx.active_referent_surface:
|
|
return subject
|
|
if subject.casefold() == ctx.active_referent_surface.casefold():
|
|
return _SLOT_PRONOUN.get(ctx.active_referent_slot, "it")
|
|
return subject
|
|
|
|
|
|
def _lower_first(surface: str) -> str:
|
|
return surface[0].lower() + surface[1:] if surface else surface
|
|
|
|
|
|
def _apply_hedge(surface: str, ctx: SurfaceContext) -> str:
|
|
"""Apply identity-pack-supplied hedge and claim-strength shaping.
|
|
|
|
Bands, in descending hedge strength:
|
|
|
|
1. ``alignment < hedge_threshold_strong`` → strong hedge phrase.
|
|
2. ``alignment < hedge_threshold_soft`` → soft hedge phrase.
|
|
3. Otherwise, in the *marginal* band
|
|
``[hedge_threshold_soft, qualified_band_high)``:
|
|
- ``claim_strength == "qualified"`` → prepend ``preferred_qualifier``.
|
|
- ``claim_strength == "affirmative"`` → leave assertion bare.
|
|
- ``claim_strength == "balanced"`` → leave assertion bare.
|
|
4. Above ``qualified_band_high`` → leave assertion bare.
|
|
"""
|
|
alignment = ctx.identity_alignment
|
|
if alignment < ctx.hedge_threshold_strong:
|
|
return f"{ctx.preferred_hedge_strong} {_lower_first(surface)}"
|
|
if alignment < ctx.hedge_threshold_soft:
|
|
return f"{ctx.preferred_hedge_soft} {_lower_first(surface)}"
|
|
if (
|
|
ctx.claim_strength == "qualified"
|
|
and alignment < ctx.qualified_band_high
|
|
):
|
|
return f"{ctx.preferred_qualifier} {_lower_first(surface)}"
|
|
return surface
|
|
|
|
|
|
def _apply_contrast(surface: str, valence_delta: float) -> str:
|
|
if valence_delta < -CONTRAST_THRESHOLD:
|
|
return f"However, {_lower_first(surface)}"
|
|
return surface
|
|
|
|
|
|
def _apply_subordination(surface: str, role: str, ctx: SurfaceContext | None, lang: str) -> str:
|
|
if lang != "en" or role != "question" or ctx is None or not ctx.active_referent_surface:
|
|
return surface
|
|
return f"Given that {ctx.active_referent_surface}, {_lower_first(surface)}"
|
|
|
|
|
|
def _assemble_en(
|
|
subject: str,
|
|
predicate: str,
|
|
object_: str | None,
|
|
elaboration: str,
|
|
role: str,
|
|
ctx: SurfaceContext | None,
|
|
) -> str:
|
|
subj_out = _coref_subject(subject, ctx)
|
|
subj = _cap(subj_out)
|
|
obj = object_ or ""
|
|
if role == "assert":
|
|
parts = [subj, predicate]
|
|
if obj:
|
|
parts.append(obj)
|
|
surface = " ".join(parts) + "."
|
|
elif role == "elaborate":
|
|
parts = [subj, predicate]
|
|
if obj:
|
|
parts.append(obj)
|
|
base = " ".join(parts)
|
|
surface = f"{base} — {elaboration}." if elaboration else base + "."
|
|
elif role == "question":
|
|
parts = ["Does", subj_out, predicate]
|
|
if obj:
|
|
parts.append(obj)
|
|
surface = " ".join(parts) + "?"
|
|
elif role == "refute":
|
|
parts = [subj, "does not", predicate]
|
|
if obj:
|
|
parts.append(obj)
|
|
surface = " ".join(parts) + "."
|
|
else:
|
|
parts = [subj, predicate]
|
|
if obj:
|
|
parts.append(obj)
|
|
surface = " ".join(parts) + "."
|
|
surface = _apply_subordination(surface, role, ctx, lang="en")
|
|
if ctx is not None:
|
|
surface = _apply_contrast(surface, ctx.valence_delta)
|
|
surface = _apply_hedge(surface, ctx)
|
|
return surface
|
|
|
|
|
|
def _assemble_he(subject: str, predicate: str, object_: str | None, elaboration: str, role: str) -> str:
|
|
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)
|
|
return f"{base} \u2014 {elaboration}." if role == "elaborate" and elaboration else base + "."
|
|
|
|
|
|
def _assemble_grc(subject: str, predicate: str, object_: str | None, elaboration: str, role: str) -> str:
|
|
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":
|
|
parts = [subj, "\u03bf\u1f50"]
|
|
if obj:
|
|
parts.append(obj)
|
|
parts.append(predicate)
|
|
return " ".join(parts) + "."
|
|
parts = [subj]
|
|
if obj:
|
|
parts.append(obj)
|
|
parts.append(predicate)
|
|
base = " ".join(parts)
|
|
return f"{base} \u2014 {elaboration}." if role == "elaborate" and elaboration else base + "."
|
|
|
|
|
|
class SentenceAssembler:
|
|
def assemble(
|
|
self,
|
|
plan: ArticulationPlan,
|
|
tokens: Sequence[str],
|
|
role: DialogueRole = "assert",
|
|
context: SurfaceContext | None = None,
|
|
) -> SentencePlan:
|
|
subject = plan.subject or ""
|
|
predicate = plan.predicate or ""
|
|
object_ = plan.object or None
|
|
lang = plan.output_language or "en"
|
|
role_str = str(role)
|
|
used_slots = frozenset(w.lower() for w in [subject, predicate, object_] if w)
|
|
elab_tokens = _elaboration_tokens(tokens, used_slots)
|
|
elaboration = _elaboration_string(elab_tokens, context) if elab_tokens else ""
|
|
if not subject and not predicate:
|
|
fallback = plan.surface or " ".join(t for t in tokens if t)
|
|
return SentencePlan("", "", object_, None, role_str, lang, fallback)
|
|
if lang == "he":
|
|
surface = _assemble_he(subject, predicate, object_, elaboration, role_str)
|
|
elif lang == "grc":
|
|
surface = _assemble_grc(subject, predicate, object_, elaboration, role_str)
|
|
else:
|
|
surface = _assemble_en(subject, predicate, object_, elaboration, role_str, context)
|
|
return SentencePlan(
|
|
subject=subject,
|
|
predicate_phrase=predicate,
|
|
object_phrase=object_,
|
|
elaboration=elaboration or None,
|
|
dialogue_role=role_str,
|
|
output_language=lang,
|
|
surface=surface,
|
|
)
|
|
|
|
|
|
default_assembler = SentenceAssembler()
|
|
|
|
|
|
def assemble(
|
|
plan: ArticulationPlan,
|
|
tokens: Sequence[str],
|
|
role: DialogueRole = "assert",
|
|
context: SurfaceContext | None = None,
|
|
) -> str:
|
|
return default_assembler.assemble(plan, tokens, role, context).surface
|