core/chat/register_variation.py
Shay 7f0bad3e20 feat(register): R5 — operator-visible register telemetry + tour demo
ADR-0072 ratified + implemented.  Closes the register subsystem
inside-out arc (R1 ADR-0068 → R5 ADR-0072): the presentation axis is
now operator-visible, CI-falsifiable, and audit-traceable.

Telemetry extension
  - TurnEvent + ChatResponse gain register_id + register_variant_id
    (12-char SHA-256 prefix of selected (opening, closing) pair;
    empty string for UNREGISTERED / no-decoration registers).
  - serialize_turn_event surfaces both fields in every audit JSONL
    line.  Pre-R5 callers stay byte-identical (defaults are "").

Decoration result widened
  - chat/register_variation.py: decorate_surface now returns
    DecorationResult(surface, opening, closing, variant_id).
  - decorate_surface_str alias preserves the pre-R5 string-only API
    for off-runtime callers.
  - chat/runtime.py updated at both call sites (stub + main).

Operator surface
  - core chat --register REGISTER_ID threads into
    RuntimeConfig.register_pack_id via _runtime_config_from_args.
  - Invalid id ⇒ RegisterPackError caught at cmd_chat and surfaced
    as a clean _die(...) before the REPL launches.

Narrative demo
  - evals/register_tour/run_tour.py walks 4 prompts × 3 ratified
    registers ({default_neutral_v1, terse_v1, convivial_v1}) and
    asserts three load-bearing seam claims:
      * all_grounding_sources_identical
      * all_trace_hashes_identical (ADR-0069 invariant C, falsifiable)
      * surfaces_vary_at_least_once (ADR-0071 seeded variation lift)
  - core demo register-tour exit code = 0 iff every claim holds.

Tests
  - tests/test_register_telemetry.py (6) — TurnEvent default,
    serializer keys, runtime emits register_id/variant_id for
    convivial/terse/unregistered, ChatResponse mirrors event fields.
  - tests/test_register_cli.py (7) — _runtime_config_from_args
    threading, invalid-id fail-fast, parser wires --register.
  - tests/test_register_tour_demo.py (7) — three seam claims pinned
    individually + all_claims_supported + per-cell register_id +
    variant_id discipline (empty for neutral/terse, non-empty for
    convivial).
  - tests/test_register_variation.py extended (4 new) — DecorationResult
    shape, decorate_surface_str alias, variant_id stability,
    bijection between non-trivial marker pairs and variant_ids.

Lane evidence
  - Full lane: 2632 passed / 4 skipped / 1 pre-existing failure
    (tests/test_cli_demo.py::test_all_preamble_explains_combined_run,
    unrelated to R5).
  - Cognition eval byte-identical: public 100 / 100 / 91.7 / 100.

Trust boundaries (per CLAUDE.md)
  - --register flag does not bypass ratification; loader validates the
    pack id through _find_pack and the ratify gate at load time.
  - variant_id is content-addressed; no raw markers leak into audit.
  - Telemetry stays redact-safe — register_id and variant_id are
    identifiers, not content, so include_content=False emits them
    unconditionally.
  - No new mutation surface; pack files on disk are not modified.
2026-05-19 19:03:07 -07:00

184 lines
6.2 KiB
Python

"""Seeded surface variation (ADR-0071 R4, extended ADR-0072 R5).
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.
ADR-0072 (R5) — :func:`decorate_surface` now returns a
:class:`DecorationResult` that carries the post-decoration surface plus
the chosen ``opening`` / ``closing`` strings and a 12-char
``variant_id`` digest of the selected pair. This is what
``TurnEvent`` records into the audit stream so operators can ask
"which register variant fired on turn N?" without reading content.
The legacy string-return is preserved via :func:`decorate_surface_str`.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from packs.register.loader import RegisterPack
_VARIANT_ID_LEN = 12
@dataclass(frozen=True, slots=True)
class DecorationResult:
"""Result of one seeded discourse-marker decoration.
``variant_id`` is the 12-char SHA-256 prefix of
``f"{opening}|{closing}"``. Empty string ⇒ no decoration was
applied this turn (empty buckets, or empty input surface). Two
different turns under the same register that select the same
``(opening, closing)`` pair share the same ``variant_id``.
"""
surface: str
opening: str
closing: str
variant_id: str
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 _compute_variant_id(opening: str, closing: str) -> str:
"""12-char SHA-256 prefix of the chosen ``(opening, closing)`` pair.
Empty when both markers are empty — ``""`` is the "no decoration
applied" sentinel, so ``UNREGISTERED`` / ``default_neutral_v1`` /
``terse_v1`` do not pollute the audit stream with a no-op digest.
"""
if not opening and not closing:
return ""
payload = f"{opening}|{closing}".encode("utf-8")
return hashlib.sha256(payload).hexdigest()[:_VARIANT_ID_LEN]
def decorate_surface(
surface: str,
register: RegisterPack,
*,
turn_idx: int,
seed_text: str | None = None,
) -> DecorationResult:
"""Apply seeded discourse-marker decoration to *surface*.
Returns a :class:`DecorationResult` with the post-decoration
``surface`` plus the chosen ``opening`` / ``closing`` markers and
the 12-char ``variant_id`` digest of that pair.
Empty buckets ⇒ no-op (the original surface is returned, both
marker strings empty, ``variant_id=""``). 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 DecorationResult(
surface=surface, opening="", closing="", variant_id=""
)
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 DecorationResult(
surface=out,
opening=opening,
closing=closing,
variant_id=_compute_variant_id(opening, closing),
)
def decorate_surface_str(
surface: str,
register: RegisterPack,
*,
turn_idx: int,
seed_text: str | None = None,
) -> str:
"""String-only convenience wrapper around :func:`decorate_surface`.
Preserves the pre-R5 return type for off-runtime callers (tests,
ad-hoc CLI tools) that only want the post-decoration string.
"""
return decorate_surface(
surface, register, turn_idx=turn_idx, seed_text=seed_text
).surface
__all__ = ("DecorationResult", "decorate_surface", "decorate_surface_str")