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.
184 lines
6.2 KiB
Python
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")
|