core/chat/register_variation.py
Shay 6207b5fd0e feat(register): R1–R4 register pack subsystem — deterministic surface variation
Introduces the presentation axis as a fourth pack class (sibling to identity /
safety / ethics), orthogonal to the truth path. Same input + same packs +
same register ⇒ bit-for-bit reproducible surface; varying any of the three ⇒
genuinely different output. No stochastic sampling.

ADR-0068 (R1): RegisterPack frozen dataclass, loader, ratify script, seam test.
  - default_neutral_v1 ratified as null register.

ADR-0069 (R2): realizer register parameter threaded through 9 composer entry
  points; RuntimeConfig.register_pack_id; three byte-identity invariants
  (A: None ≡ pre-R2 unregistered; B: None ≡ default_neutral_v1; C: trace_hash
  invariant under register). Amended to default-with-lint after 167-call-site
  scout: composers default to UNREGISTERED, AST lint enforces explicit
  register= at runtime call sites.

ADR-0070 (R3): terse_v1 register, first non-neutral pack. realizer_overrides
  schema with known-keys allow-list (disclosure_domain_count ∈ {1,2,3}).
  build_pack_surface_candidate reads override with fail-soft clamp. New
  invariant register_invariant_grounding asserts grounding_source +
  trace_hash byte-identical across {None, neutral, terse}.

ADR-0071 (R4): seeded surface variation via convivial_v1.
  chat/register_variation.py applies SHA-256-seeded marker selection from
  bounded discourse-marker buckets. ChatResponse.pre_decoration_surface routes
  truth-path surface to core/cognition/pipeline.py so trace_hash stays
  invariant under register (the load-bearing architectural fix — initially
  invariant C failed under convivial because decoration was leaking into
  trace_hash via response.surface). Empty-string marker entries now
  legitimate ("no marker this turn" is a valid seed pick). realizer_overrides
  schema widened with per_intent nested block (validated against IntentTag
  whitelist; wired but not exercised by convivial). Two new invariants:
  seeded_variation_replay_equivalence (fresh runtimes → byte-identical) and
  seeded_variation_turn_distinct (same prompt across turns → ≥2 distinct
  surfaces).

ADR-0072 (R5, draft): telemetry + operator surface — TurnEvent gains
  register_id and register_variant_id, core chat --register flag, core demo
  register-tour. Status: Proposed; not yet implemented.

Three ratified register packs ship: default_neutral_v1 (null), terse_v1
(disclosure_domain_count=1), convivial_v1 (3 openings × 3 closings).

Verification:
  - 84 register tests pass + 1 documented skip
  - Curated lanes green: smoke 67, cognition 120+1s, teaching 17, packs 6,
    runtime 19, algebra 132
  - Cognition eval byte-identical to pre-register baseline:
    public 100/100/91.7/100, holdout 100/100/83.3/100
  - Full lane: 2608 passed, 4 skipped, 1 failed (pre-existing
    test_cli_demo.py "Combined Demo" → "Run Every Demo" rename, unrelated)

Truth-path isolation: chat/register_variation.py is realizer-side; the seam
test (tests/test_register_pack_seam.py) refuses imports of packs.register
from intent classification, propagation, vault recall, trace hashing, and
algebra.
2026-05-19 16:52:36 -07:00

113 lines
3.7 KiB
Python

"""Seeded surface variation (ADR-0071, Plan Phase R4).
Deterministic discourse-marker selection from bounded register-pack
buckets, keyed on ``(seed_text, register_id, turn_idx, bucket_name)``.
The ADR specifies ``trace_hash`` as the primary seed input. The
implementation passes the *pre-decoration surface* as ``seed_text``
instead, for pragmatic reasons:
* The pre-decoration surface is a deterministic downstream projection
of the truth path (intent → proposition graph → realizer → surface).
* It is already available at the decoration call site without
threading ``trace_hash`` through ``TurnEvent`` / ``ChatResponse``.
* There is no cycle: decoration reads the pre-decoration string and
writes a post-decoration string; the seed never sees its own output.
* Replay equivalence holds: same input sequence ⇒ same pre-decoration
surfaces ⇒ same decoration choices.
Trust boundary
--------------
This module READS surface text to seed marker selection. It MUST NOT
feed back into the truth path (intent / proposition graph / trace
hash). ADR-0069 invariant C and ADR-0070
``register_invariant_grounding`` continue to hold; the seam test
(``tests/test_register_pack_seam.py``) keeps truth-path modules free of
imports from ``packs.register`` and from this module.
The selector is uniform-mod-len: every entry in a bucket is equally
likely across the seed space. Frequency shaping (weighted entries) is
deferred per ADR-0071 §Open questions.
"""
from __future__ import annotations
import hashlib
from packs.register.loader import RegisterPack
def _select_bucket_entry(
bucket: tuple[str, ...],
*,
seed_text: str,
register_id: str,
turn_idx: int,
bucket_name: str,
) -> str:
"""Deterministically select one entry from *bucket*, or ``''``.
Same inputs ⇒ same output, forever. Different ``turn_idx`` against
the same seed_text + register typically picks a different entry
(uniform across the seed space).
"""
if not bucket:
return ""
seed_bytes = (
f"{seed_text}|{register_id}|{turn_idx}|{bucket_name}"
).encode("utf-8")
digest = hashlib.sha256(seed_bytes).digest()
idx = int.from_bytes(digest[:8], "big") % len(bucket)
return bucket[idx]
def decorate_surface(
surface: str,
register: RegisterPack,
*,
turn_idx: int,
seed_text: str | None = None,
) -> str:
"""Apply seeded discourse-marker decoration to *surface*.
Empty buckets ⇒ no-op (the original surface is returned). Order
is ``"{opening} {surface}{closing}"`` — closing concatenates
directly so an entry like ``" — make sense?"`` carries its own
spacing. Empty-string entries in a bucket count as legitimate
selections (the seed may pick "no marker this turn").
``seed_text`` defaults to *surface* — the pre-decoration string is
the natural seed. Callers with a stronger deterministic key (e.g.
a turn-trace hash) may pass it explicitly.
Transitions are accepted by the schema but NOT consumed at R4;
they sit until a later phase that owns clause-boundary detection.
"""
if not surface:
return surface
if seed_text is None:
seed_text = surface
markers = register.discourse_markers
opening = _select_bucket_entry(
markers.openings,
seed_text=seed_text,
register_id=register.register_id,
turn_idx=turn_idx,
bucket_name="openings",
)
closing = _select_bucket_entry(
markers.closings,
seed_text=seed_text,
register_id=register.register_id,
turn_idx=turn_idx,
bucket_name="closings",
)
out = surface
if opening:
out = f"{opening} {out}"
if closing:
out = f"{out}{closing}"
return out
__all__ = ("decorate_surface",)