From 7f0bad3e203ef64a9ad7bf8957ede1f9e532ca03 Mon Sep 17 00:00:00 2001 From: Shay Date: Tue, 19 May 2026 19:03:07 -0700 Subject: [PATCH] =?UTF-8?q?feat(register):=20R5=20=E2=80=94=20operator-vis?= =?UTF-8?q?ible=20register=20telemetry=20+=20tour=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- chat/register_variation.py | 91 ++++++- chat/runtime.py | 29 ++- chat/telemetry.py | 5 + core/cli.py | 35 ++- core/physics/identity.py | 10 + ...072-register-telemetry-operator-surface.md | 3 +- evals/register_tour/__init__.py | 5 + evals/register_tour/run_tour.py | 227 ++++++++++++++++++ tests/test_register_cli.py | 93 +++++++ tests/test_register_telemetry.py | 113 +++++++++ tests/test_register_tour_demo.py | 72 ++++++ tests/test_register_variation.py | 84 ++++++- 12 files changed, 744 insertions(+), 23 deletions(-) create mode 100644 evals/register_tour/__init__.py create mode 100644 evals/register_tour/run_tour.py create mode 100644 tests/test_register_cli.py create mode 100644 tests/test_register_telemetry.py create mode 100644 tests/test_register_tour_demo.py diff --git a/chat/register_variation.py b/chat/register_variation.py index 6ef93798..0d4691ea 100644 --- a/chat/register_variation.py +++ b/chat/register_variation.py @@ -1,4 +1,4 @@ -"""Seeded surface variation (ADR-0071, Plan Phase R4). +"""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)``. @@ -28,15 +28,44 @@ 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, ...], *, @@ -61,20 +90,38 @@ def _select_bucket_entry( 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, -) -> str: +) -> DecorationResult: """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"). + 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. @@ -84,7 +131,9 @@ def decorate_surface( they sit until a later phase that owns clause-boundary detection. """ if not surface: - return surface + return DecorationResult( + surface=surface, opening="", closing="", variant_id="" + ) if seed_text is None: seed_text = surface markers = register.discourse_markers @@ -107,7 +156,29 @@ def decorate_surface( out = f"{opening} {out}" if closing: out = f"{out}{closing}" - return out + return DecorationResult( + surface=out, + opening=opening, + closing=closing, + variant_id=_compute_variant_id(opening, closing), + ) -__all__ = ("decorate_surface",) +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") diff --git a/chat/runtime.py b/chat/runtime.py index af8106ef..16a207be 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -303,6 +303,13 @@ class ChatResponse: # the truth path (ADR-0069 invariant C). Empty string ⇒ identical # to ``surface`` (no decoration applied this turn). pre_decoration_surface: str = "" + # ADR-0072 (R5) — operator-visible register identity per turn. + # Mirrors the TurnEvent fields so callers (CLI, demos, tests) can + # read the register state from ChatResponse without re-parsing the + # telemetry JSONL. ``""`` defaults preserve pre-R5 byte-identity + # for callers that construct ChatResponse without these fields. + register_id: str = "" + register_variant_id: str = "" class ChatRuntime: @@ -933,11 +940,16 @@ class ChatRuntime: # the truth-path surface and trace_hash stays invariant under # register (ADR-0069 invariant C). pre_decoration_surface_stub = response_surface - response_surface = decorate_surface( + decoration_stub = decorate_surface( response_surface, self.register_pack, turn_idx=len(self.turn_log), ) + response_surface = decoration_stub.surface + register_id_stub = ( + "" if self.register_pack.is_unregistered() + else self.register_pack.register_id + ) verdicts_bundle = TurnVerdicts( identity_score=None, safety_verdict=safety_verdict, @@ -963,6 +975,8 @@ class ChatRuntime: ethics_verdict=ethics_verdict, verdicts=verdicts_bundle, grounding_source=grounding_source, + register_id=register_id_stub, + register_variant_id=decoration_stub.variant_id, ) self.turn_log.append(stub_event) self._emit_turn_event(stub_event) @@ -1007,6 +1021,8 @@ class ChatRuntime: verdicts=verdicts_bundle, grounding_source=grounding_source, pre_decoration_surface=pre_decoration_surface_stub, + register_id=register_id_stub, + register_variant_id=decoration_stub.variant_id, ) def chat(self, text: str, max_tokens: int | None = None) -> ChatResponse: @@ -1277,11 +1293,16 @@ class ChatRuntime: # cognition pipeline can hash the truth-path surface and # trace_hash stays invariant under register (ADR-0069 inv C). pre_decoration_surface_main = response_surface - response_surface = decorate_surface( + decoration_main = decorate_surface( response_surface, self.register_pack, turn_idx=len(self.turn_log), ) + response_surface = decoration_main.surface + register_id_main = ( + "" if self.register_pack.is_unregistered() + else self.register_pack.register_id + ) verdicts_bundle = TurnVerdicts( identity_score=identity_score, safety_verdict=safety_verdict, @@ -1306,6 +1327,8 @@ class ChatRuntime: ethics_verdict=ethics_verdict, verdicts=verdicts_bundle, grounding_source=warm_grounding_source or "vault", + register_id=register_id_main, + register_variant_id=decoration_main.variant_id, ) self.turn_log.append(turn_event) self._emit_turn_event(turn_event) @@ -1339,6 +1362,8 @@ class ChatRuntime: verdicts=verdicts_bundle, grounding_source=warm_grounding_source or "vault", pre_decoration_surface=pre_decoration_surface_main, + register_id=register_id_main, + register_variant_id=decoration_main.variant_id, ) def _unknown_domain_response(self, field_state: FieldState, filtered: list[str]) -> ChatResponse: diff --git a/chat/telemetry.py b/chat/telemetry.py index 1cc70014..23df1331 100644 --- a/chat/telemetry.py +++ b/chat/telemetry.py @@ -69,6 +69,11 @@ def serialize_turn_event( "flagged": bool(getattr(event, "flagged", False)), "stub_path": getattr(event, "walk_surface", "") == _UNKNOWN_DOMAIN_SURFACE, "dialogue_role": str(getattr(event, "dialogue_role", "")), + # ADR-0072 (R5) — operator-visible register identity per turn. + # Empty strings on pre-R5 events / UNREGISTERED runtimes / empty + # marker buckets, so the wire format degrades cleanly. + "register_id": str(getattr(event, "register_id", "") or ""), + "register_variant_id": str(getattr(event, "register_variant_id", "") or ""), } safety = getattr(event, "safety_verdict", None) if safety is not None: diff --git a/core/cli.py b/core/cli.py index ba673fcc..a4af6753 100644 --- a/core/cli.py +++ b/core/cli.py @@ -23,7 +23,7 @@ _CORE_RS_DIR = _REPO_ROOT / "core-rs" _CORE_RS_MANIFEST = _CORE_RS_DIR / "Cargo.toml" DESCRIPTION = "CORE versor engine command suite." -EPILOG = "Examples:\n core chat\n core pulse \"What is truth?\"\n core pulse --no-glove --json \"Compare knowledge and wisdom\"\n core bench\n core bench --suite all\n core bench --suite all --json --report bench_all.json\n core bench --suite determinism --runs 50\n core bench --suite speedup --json\n core trace \"word beginning truth\"\n core trace --output-language grc --frame-pack grc --json \"logos\"\n core rust status\n core rust build\n core oov covenant\n core pack list\n core pack verify en_minimal_v1\n core teaching audit\n core teaching audit --json\n core teaching gaps --top 10\n core teaching queue --threshold 3\n core teaching propose \n core teaching proposals --state pending\n core teaching review --accept --review-date 2026-05-18\n core teaching supersede cause_light_reveals_truth --subject light --intent cause --connective grounds --object truth --review-date 2026-05-18\n core teaching supersessions\n core teaching supersessions --json\n core test --suite fast -q\n core test --suite pulse -q\n core test --suite proof -q\n core test --suite cognition -q\n core test -- tests/test_alignment_graph.py -q\n core demo audit-tour\n core demo pack-measurements\n core demo long-context-comparison\n core demo anti-regression\n core demo learning-loop\n core demo articulation\n core demo conversation\n core demo conversation --no-stream\n core demo all\n core demo adr-0024-chain\n core eval --list\n core eval cognition\n core eval cognition --json --save\n core eval cognition --split dev --version v1\n core eval cognition --split holdout" +EPILOG = "Examples:\n core chat\n core pulse \"What is truth?\"\n core pulse --no-glove --json \"Compare knowledge and wisdom\"\n core bench\n core bench --suite all\n core bench --suite all --json --report bench_all.json\n core bench --suite determinism --runs 50\n core bench --suite speedup --json\n core trace \"word beginning truth\"\n core trace --output-language grc --frame-pack grc --json \"logos\"\n core rust status\n core rust build\n core oov covenant\n core pack list\n core pack verify en_minimal_v1\n core teaching audit\n core teaching audit --json\n core teaching gaps --top 10\n core teaching queue --threshold 3\n core teaching propose \n core teaching proposals --state pending\n core teaching review --accept --review-date 2026-05-18\n core teaching supersede cause_light_reveals_truth --subject light --intent cause --connective grounds --object truth --review-date 2026-05-18\n core teaching supersessions\n core teaching supersessions --json\n core test --suite fast -q\n core test --suite pulse -q\n core test --suite proof -q\n core test --suite cognition -q\n core test -- tests/test_alignment_graph.py -q\n core demo audit-tour\n core demo register-tour\n core demo pack-measurements\n core demo long-context-comparison\n core demo anti-regression\n core demo learning-loop\n core demo articulation\n core demo conversation\n core demo conversation --no-stream\n core demo all\n core demo adr-0024-chain\n core eval --list\n core eval cognition\n core eval cognition --json --save\n core eval cognition --split dev --version v1\n core eval cognition --split holdout" _TEST_SUITES: dict[str, tuple[str, ...]] = { "fast": ( @@ -172,6 +172,7 @@ def _runtime_config_from_args(args: argparse.Namespace): inner_loop_admissibility=getattr(args, "inner_loop_admissibility", False), admissibility_threshold=getattr(args, "admissibility_threshold", 0.0), identity_pack=getattr(args, "identity", "") or "", + register_pack_id=(getattr(args, "register", None) or None), ) @@ -213,7 +214,13 @@ def cmd_chat(args: argparse.Namespace) -> int: except Exception as exc: # pragma: no cover - exercised by CLI in broken envs _print_runtime_import_hint(exc) - runtime = ChatRuntime(config=_runtime_config_from_args(args)) + try: + runtime = ChatRuntime(config=_runtime_config_from_args(args)) + except Exception as exc: # noqa: BLE001 — surface RegisterPackError / pack-load errors + from packs.register.loader import RegisterPackError + if isinstance(exc, RegisterPackError): + _die(f"invalid --register pack id: {exc}", code=2) + raise show_verdicts = bool(getattr(args, "show_verdicts", False)) while True: try: @@ -1954,6 +1961,14 @@ def cmd_demo(args: argparse.Namespace) -> int: print(json.dumps(result, indent=2, sort_keys=True, default=str)) return 0 + if target == "register-tour": + from evals.register_tour.run_tour import run_tour as run_register_tour + + result = run_register_tour(emit_json=args.json) + if args.json: + print(json.dumps(result, indent=2, sort_keys=True, default=str)) + return 0 if result.get("all_claims_supported", False) else 1 + if target == "pack-measurements": from scripts.publish_pack_measurements import ( build_combined_report, @@ -2494,6 +2509,18 @@ def build_parser() -> argparse.ArgumentParser: "stderr (ADR-0041 operator-facing audit readout)" ), ) + chat.add_argument( + "--register", + metavar="REGISTER_ID", + default=None, + help=( + "optional register pack id (ADR-0068+); default: no " + "register (unregistered sentinel, byte-identical to " + "default_neutral_v1). Examples: default_neutral_v1, " + "terse_v1, convivial_v1. Invalid ids fail-fast at " + "runtime init before the REPL starts." + ), + ) chat.set_defaults(func=cmd_chat) test = subparsers.add_parser("test", help="run pytest with curated suite aliases or direct passthrough") @@ -2823,6 +2850,7 @@ def build_parser() -> argparse.ArgumentParser: "phase6", "adr-0024-chain", "audit-tour", + "register-tour", "pack-measurements", "long-context-comparison", "anti-regression", @@ -2840,6 +2868,9 @@ def build_parser() -> argparse.ArgumentParser: "consolidated PASS/FAIL table; exits non-zero if any demo fails. " "audit-tour: ADR-0027..0041 pack-layer architecture in four " "scenes (identity / safety / ethics / replay). " + "register-tour: ADR-0068..0072 presentation-axis seam — same " + "prompts × three registers; surface varies, grounding_source " + "and trace_hash byte-identical. " "pack-measurements: ADR-0043 — pack-layer claims → CI-enforced " "numbers across the three ratified identity packs. " "long-context-comparison: ADR-0045 — CORE exact recall NIAH at " diff --git a/core/physics/identity.py b/core/physics/identity.py index 7d455c80..5aa0c0e2 100644 --- a/core/physics/identity.py +++ b/core/physics/identity.py @@ -291,3 +291,13 @@ class TurnEvent: # Preserved verbatim through the TurnEvent telemetry stream for # downstream audit consumers. grounding_source: str = "none" + # ADR-0072 (R5) — operator-visible register identity per turn. + # ``register_id`` is the loaded pack id (e.g. ``"convivial_v1"``), + # or ``""`` for the in-memory UNREGISTERED sentinel. + # ``register_variant_id`` is the 12-char SHA-256 prefix of the + # selected ``(opening, closing)`` discourse-marker pair, or ``""`` + # when no decoration was applied this turn (empty buckets, or empty + # surface). Both default to ``""`` so pre-R5 callers stay + # byte-identical. + register_id: str = "" + register_variant_id: str = "" diff --git a/docs/decisions/ADR-0072-register-telemetry-operator-surface.md b/docs/decisions/ADR-0072-register-telemetry-operator-surface.md index 4f2382c5..a6b24168 100644 --- a/docs/decisions/ADR-0072-register-telemetry-operator-surface.md +++ b/docs/decisions/ADR-0072-register-telemetry-operator-surface.md @@ -1,7 +1,8 @@ # ADR-0072 — Register telemetry + operator surface (Plan Phase R5) -**Status:** Proposed +**Status:** Accepted **Date:** 2026-05-19 +**Ratified:** 2026-05-19 **Author:** Shay **Phase:** Plan Phase R5 (operator-visible register) **Builds on:** ADR-0068 (register pack class), ADR-0069 (realizer diff --git a/evals/register_tour/__init__.py b/evals/register_tour/__init__.py new file mode 100644 index 00000000..8cfff1be --- /dev/null +++ b/evals/register_tour/__init__.py @@ -0,0 +1,5 @@ +"""Register tour — narrative walkthrough demonstrating CORE's +presentation-axis seam: switching register packs varies the surface +string while leaving ``grounding_source`` and ``trace_hash`` +byte-identical. See ``run_tour.py`` for the entry point. +""" diff --git a/evals/register_tour/run_tour.py b/evals/register_tour/run_tour.py new file mode 100644 index 00000000..aa43f2f2 --- /dev/null +++ b/evals/register_tour/run_tour.py @@ -0,0 +1,227 @@ +"""Register tour — narrative walkthrough demonstrating the +presentation-axis seam (ADR-0068 → ADR-0072). + +Walks a fixed four-prompt sequence under three ratified registers +({default_neutral_v1, terse_v1, convivial_v1}) and prints a grid of +per-cell ``(surface, grounding_source, trace_hash, register_id, +register_variant_id)``. + +Load-bearing claim asserted before exit (R5 invariant): + +* ``all_grounding_sources_identical`` — for every prompt, the + grounding_source is byte-identical across registers. +* ``all_trace_hashes_identical`` — for every prompt, the trace_hash is + byte-identical across registers. +* ``surfaces_vary_at_least_once`` — at least one prompt produces a + visibly different surface under convivial vs neutral (the variation + is real, not stubbed). + +Exit code 0 iff every claim holds. Designed for ``core demo +register-tour`` and the corresponding ``tests/test_register_tour_demo.py`` +gate. +""" + +from __future__ import annotations + +import json +from typing import Any + +from chat.runtime import ChatRuntime +from core.cognition.pipeline import CognitiveTurnPipeline +from core.config import RuntimeConfig + + +# Three ratified registers cover the three structural points of the +# R1–R4 architecture: unregistered-equivalent neutral baseline, the +# realizer-override variant (terse_v1), and the seeded-variation +# variant (convivial_v1). +_REGISTERS = ( + "default_neutral_v1", + "terse_v1", + "convivial_v1", +) + +# A fixed, deterministic prompt sequence. Each prompt is chosen to +# exercise a different grounding source on the cold path so the +# register-invariance claim holds across the breadth of the cognition +# lane, not just one intent shape. +_PROMPTS = ( + "What is light?", + "Define knowledge.", + "What is truth?", + "Light reveals truth, right?", +) + + +_VERBOSE = True + + +def _say(*args: Any, **kwargs: Any) -> None: + if _VERBOSE: + print(*args, **kwargs) + + +def _print_header() -> None: + _say() + _say("=" * 72) + _say(" CORE Register Tour — presentation-axis seam in three registers") + _say("=" * 72) + _say( + " Same prompt sequence run under three ratified register packs.\n" + " Claim: switching register varies SURFACE only — grounding_source\n" + " and trace_hash stay byte-identical (ADR-0069 invariant C,\n" + " ADR-0070 register_invariant_grounding, ADR-0071 seeded variation\n" + " replay equivalence, ADR-0072 operator-visible audit)." + ) + _say() + + +def _run_one_register(register_id: str) -> list[dict[str, Any]]: + """Run the prompt sequence under ``register_id`` and return per-cell records.""" + runtime = ChatRuntime(config=RuntimeConfig(register_pack_id=register_id)) + pipeline = CognitiveTurnPipeline(runtime=runtime) + cells: list[dict[str, Any]] = [] + for prompt in _PROMPTS: + # ``pipeline.run(prompt)`` computes trace_hash on the pre- + # decoration surface (ADR-0069 invariant C). We want the + # *post-decoration* surface (the user-facing string) for the + # surface-varies claim, which lives on TurnEvent.surface after + # ChatRuntime applied seeded variation. + result = pipeline.run(prompt) + turn_event = runtime.turn_log[-1] + cells.append( + { + "prompt": prompt, + "surface": turn_event.surface, + "grounding_source": getattr(turn_event, "grounding_source", ""), + "trace_hash": result.trace_hash, + "register_id": getattr(turn_event, "register_id", ""), + "register_variant_id": getattr(turn_event, "register_variant_id", ""), + } + ) + return cells + + +def _print_grid(grid: dict[str, list[dict[str, Any]]]) -> None: + for prompt_idx, prompt in enumerate(_PROMPTS): + _say(f" P{prompt_idx + 1}: {prompt!r}") + for register_id in _REGISTERS: + cell = grid[register_id][prompt_idx] + _say( + f" {register_id:24s} surface = {cell['surface']!r}" + ) + _say( + f" {'':24s} grounding = {cell['grounding_source']:<10s}" + f" trace_hash = {cell['trace_hash'][:12]}…" + f" variant_id = {cell['register_variant_id'] or '(none)'}" + ) + _say() + + +def _check_claims( + grid: dict[str, list[dict[str, Any]]], +) -> dict[str, Any]: + """Return the three load-bearing seam-claim booleans + supporting evidence.""" + + all_grounding_identical = True + all_trace_hashes_identical = True + surfaces_vary_at_least_once = False + + per_prompt_evidence: list[dict[str, Any]] = [] + for prompt_idx, prompt in enumerate(_PROMPTS): + cells = [grid[r][prompt_idx] for r in _REGISTERS] + grounding_set = {c["grounding_source"] for c in cells} + trace_set = {c["trace_hash"] for c in cells} + surface_set = {c["surface"] for c in cells} + if len(grounding_set) > 1: + all_grounding_identical = False + if len(trace_set) > 1: + all_trace_hashes_identical = False + if len(surface_set) > 1: + surfaces_vary_at_least_once = True + per_prompt_evidence.append( + { + "prompt": prompt, + "distinct_grounding_sources": sorted(grounding_set), + "distinct_trace_hashes": sorted(trace_set), + "distinct_surfaces_count": len(surface_set), + } + ) + + return { + "all_grounding_sources_identical": all_grounding_identical, + "all_trace_hashes_identical": all_trace_hashes_identical, + "surfaces_vary_at_least_once": surfaces_vary_at_least_once, + "per_prompt_evidence": per_prompt_evidence, + } + + +def run_tour(*, emit_json: bool = False) -> dict[str, Any]: + """Run the register tour end-to-end and return a structured report. + + When ``emit_json`` is True the human narration is suppressed and + only the result dict is returned (caller prints it). Otherwise + narration is printed and the dict is returned for index callers. + """ + global _VERBOSE + _VERBOSE = not emit_json + + if not emit_json: + _print_header() + + grid: dict[str, list[dict[str, Any]]] = {} + for register_id in _REGISTERS: + if not emit_json: + _say(f" Running register: {register_id}") + grid[register_id] = _run_one_register(register_id) + if not emit_json: + _say() + _say("-" * 72) + _say(" Register × prompt grid") + _say("-" * 72) + _print_grid(grid) + + claims = _check_claims(grid) + all_supported = ( + claims["all_grounding_sources_identical"] + and claims["all_trace_hashes_identical"] + and claims["surfaces_vary_at_least_once"] + ) + + if not emit_json: + _say("=" * 72) + _say(" Load-bearing seam claims (R5 invariant)") + _say("=" * 72) + _say( + f" all_grounding_sources_identical : " + f"{claims['all_grounding_sources_identical']}" + ) + _say( + f" all_trace_hashes_identical : " + f"{claims['all_trace_hashes_identical']}" + ) + _say( + f" surfaces_vary_at_least_once : " + f"{claims['surfaces_vary_at_least_once']}" + ) + _say() + _say(f" all_claims_supported : {all_supported}") + _say() + + return { + "registers": list(_REGISTERS), + "prompts": list(_PROMPTS), + "grid": grid, + "claims": claims, + "all_claims_supported": all_supported, + } + + +if __name__ == "__main__": # pragma: no cover + import sys + + emit_json = "--json" in sys.argv + report = run_tour(emit_json=emit_json) + if emit_json: + print(json.dumps(report, indent=2, sort_keys=True, default=str)) + sys.exit(0 if report["all_claims_supported"] else 1) diff --git a/tests/test_register_cli.py b/tests/test_register_cli.py new file mode 100644 index 00000000..aa4b9d47 --- /dev/null +++ b/tests/test_register_cli.py @@ -0,0 +1,93 @@ +"""Register CLI — ``core chat --register `` flag wiring +(ADR-0072 / Plan Phase R5). +""" + +from __future__ import annotations + +import argparse + +import pytest + +from core.cli import _runtime_config_from_args +from core.config import DEFAULT_CONFIG + + +def _namespace(**overrides) -> argparse.Namespace: + """Build a Namespace shaped like ``cmd_chat``'s args, allowing + targeted overrides for register / identity.""" + defaults = dict( + output_language=DEFAULT_CONFIG.output_language, + frame_pack=None, + pack=None, + max_tokens=DEFAULT_CONFIG.max_tokens, + no_cross_language_recall=False, + allow_cross_language_generation=False, + vault_reproject_interval=DEFAULT_CONFIG.vault_reproject_interval, + no_salience=False, + salience_top_k=DEFAULT_CONFIG.salience_top_k, + inhibition_threshold=DEFAULT_CONFIG.inhibition_threshold, + inner_loop_admissibility=False, + admissibility_threshold=0.0, + identity="", + register=None, + ) + defaults.update(overrides) + return argparse.Namespace(**defaults) + + +def test_runtime_config_default_register_is_none(): + """No --register on CLI ⇒ register_pack_id is None ⇒ unregistered.""" + cfg = _runtime_config_from_args(_namespace()) + assert cfg.register_pack_id is None + + +def test_runtime_config_threads_register_flag(): + """--register convivial_v1 ⇒ RuntimeConfig.register_pack_id set.""" + cfg = _runtime_config_from_args(_namespace(register="convivial_v1")) + assert cfg.register_pack_id == "convivial_v1" + + +def test_runtime_config_empty_register_treated_as_none(): + """Empty-string --register (treated as unset) ⇒ None.""" + cfg = _runtime_config_from_args(_namespace(register="")) + assert cfg.register_pack_id is None + + +def test_runtime_config_register_pack_id_threads_into_runtime(): + """Loading the runtime with a ratified register id wires + ChatRuntime.register_pack to a non-unregistered pack.""" + from chat.runtime import ChatRuntime + + cfg = _runtime_config_from_args(_namespace(register="convivial_v1")) + rt = ChatRuntime(config=cfg) + assert rt.register_pack_id == "convivial_v1" + assert rt.register_pack.register_id == "convivial_v1" + assert not rt.register_pack.is_unregistered() + + +def test_runtime_config_unratified_register_id_fails_fast(): + """An unratified / non-existent register id ⇒ RegisterPackError at + ChatRuntime init, not at first turn.""" + from chat.runtime import ChatRuntime + from packs.register.loader import RegisterPackError + + cfg = _runtime_config_from_args(_namespace(register="bogus_v999")) + with pytest.raises(RegisterPackError): + ChatRuntime(config=cfg) + + +def test_chat_parser_accepts_register_flag(): + """``core chat --register convivial_v1`` parses cleanly.""" + from core.cli import build_parser + + parser = build_parser() + ns = parser.parse_args(["chat", "--register", "convivial_v1"]) + assert ns.register == "convivial_v1" + + +def test_chat_parser_register_defaults_none(): + from core.cli import build_parser + + parser = build_parser() + ns = parser.parse_args(["chat"]) + assert ns.register is None diff --git a/tests/test_register_telemetry.py b/tests/test_register_telemetry.py new file mode 100644 index 00000000..fbf0c962 --- /dev/null +++ b/tests/test_register_telemetry.py @@ -0,0 +1,113 @@ +"""Register telemetry — TurnEvent + serialize_turn_event carry +register_id and register_variant_id (ADR-0072 / Plan Phase R5). +""" + +from __future__ import annotations + +import json + +from chat.runtime import ChatRuntime +from chat.telemetry import JsonlBufferSink, serialize_turn_event +from core.config import RuntimeConfig +from core.physics.identity import TurnEvent + + +def _decode(line: str) -> dict: + return json.loads(line) + + +def test_turn_event_defaults_register_fields_empty(): + """Pre-R5 callers constructing TurnEvent without the new fields + see empty strings — preserves byte-identity.""" + event = TurnEvent( + turn=0, + input_tokens=(), + surface="", + walk_surface="", + articulation_surface="", + dialogue_role="assert", + identity_score=None, + cycle_cost_total=0.0, + vault_hits=0, + versor_condition=0.0, + flagged=False, + ) + assert event.register_id == "" + assert event.register_variant_id == "" + + +def test_serialize_turn_event_emits_register_fields(): + """The JSONL audit line carries register_id / register_variant_id.""" + event = TurnEvent( + turn=0, + input_tokens=(), + surface="", + walk_surface="", + articulation_surface="", + dialogue_role="assert", + identity_score=None, + cycle_cost_total=0.0, + vault_hits=0, + versor_condition=0.0, + flagged=False, + register_id="convivial_v1", + register_variant_id="abcdef012345", + ) + payload = serialize_turn_event(event) + assert payload["register_id"] == "convivial_v1" + assert payload["register_variant_id"] == "abcdef012345" + + +def test_runtime_chat_emits_register_id_when_set(): + """Convivial-loaded runtime ⇒ every TurnEvent carries register_id.""" + runtime = ChatRuntime( + config=RuntimeConfig(register_pack_id="convivial_v1") + ) + sink = JsonlBufferSink() + runtime.attach_telemetry_sink(sink) + runtime.chat("What is light?") + assert sink.lines, "expected at least one telemetry line" + last = _decode(sink.lines[-1]) + assert last["register_id"] == "convivial_v1" + # convivial_v1 always emits a non-empty opening on non-empty + # surfaces, so variant_id is a 12-char hex. + variant_id = last["register_variant_id"] + assert variant_id != "" + assert len(variant_id) == 12 + int(variant_id, 16) + + +def test_runtime_chat_empty_register_id_for_unregistered(): + """Default runtime (no --register) ⇒ empty register_id / variant_id.""" + runtime = ChatRuntime(config=RuntimeConfig()) + sink = JsonlBufferSink() + runtime.attach_telemetry_sink(sink) + runtime.chat("What is light?") + last = _decode(sink.lines[-1]) + assert last["register_id"] == "" + assert last["register_variant_id"] == "" + + +def test_runtime_chat_empty_variant_id_for_terse(): + """terse_v1 has empty marker buckets ⇒ variant_id stays empty even + though register_id is set.""" + runtime = ChatRuntime( + config=RuntimeConfig(register_pack_id="terse_v1") + ) + sink = JsonlBufferSink() + runtime.attach_telemetry_sink(sink) + runtime.chat("What is light?") + last = _decode(sink.lines[-1]) + assert last["register_id"] == "terse_v1" + assert last["register_variant_id"] == "" + + +def test_chat_response_mirrors_register_fields(): + """ChatResponse exposes the same register fields as TurnEvent.""" + runtime = ChatRuntime( + config=RuntimeConfig(register_pack_id="convivial_v1") + ) + response = runtime.chat("What is light?") + event = runtime.turn_log[-1] + assert response.register_id == event.register_id == "convivial_v1" + assert response.register_variant_id == event.register_variant_id diff --git a/tests/test_register_tour_demo.py b/tests/test_register_tour_demo.py new file mode 100644 index 00000000..4c33ebe8 --- /dev/null +++ b/tests/test_register_tour_demo.py @@ -0,0 +1,72 @@ +"""Register tour demo — load-bearing seam claim (ADR-0072 / R5). + +Pins the three seam claims: + +* grounding_source identical across {neutral, terse, convivial} per prompt +* trace_hash identical across {neutral, terse, convivial} per prompt +* surface differs at least once (convivial vs neutral) + +Any one of those failing is the R5 architectural-regression signal. +""" + +from __future__ import annotations + +from evals.register_tour.run_tour import _PROMPTS, _REGISTERS, run_tour + + +def test_tour_returns_structured_report(): + report = run_tour(emit_json=True) + assert set(report) >= { + "registers", "prompts", "grid", "claims", "all_claims_supported", + } + assert list(report["registers"]) == list(_REGISTERS) + assert list(report["prompts"]) == list(_PROMPTS) + + +def test_tour_grounding_sources_identical_across_registers(): + report = run_tour(emit_json=True) + assert report["claims"]["all_grounding_sources_identical"] is True + + +def test_tour_trace_hashes_identical_across_registers(): + """ADR-0069 invariant C, restated as a falsifiable demo claim.""" + report = run_tour(emit_json=True) + assert report["claims"]["all_trace_hashes_identical"] is True + + +def test_tour_surfaces_vary_at_least_once(): + """ADR-0071 seeded variation: convivial vs neutral must differ + somewhere across the four-prompt sequence.""" + report = run_tour(emit_json=True) + assert report["claims"]["surfaces_vary_at_least_once"] is True + + +def test_tour_all_claims_supported(): + """Canonical R5 gate — every claim must hold or exit non-zero.""" + report = run_tour(emit_json=True) + assert report["all_claims_supported"] is True + + +def test_tour_grid_carries_register_id_per_cell(): + """Each grid cell records the register_id that produced it.""" + report = run_tour(emit_json=True) + for register_id in _REGISTERS: + cells = report["grid"][register_id] + assert len(cells) == len(_PROMPTS) + for cell in cells: + assert cell["register_id"] == register_id + + +def test_tour_grid_variant_id_empty_when_no_decoration(): + """terse_v1 + default_neutral_v1 emit empty variant_ids; convivial_v1 + emits non-empty variant_ids on non-empty surfaces.""" + report = run_tour(emit_json=True) + for cell in report["grid"]["default_neutral_v1"]: + assert cell["register_variant_id"] == "" + for cell in report["grid"]["terse_v1"]: + assert cell["register_variant_id"] == "" + convivial_non_empty = [ + c for c in report["grid"]["convivial_v1"] + if c["surface"] + ] + assert any(c["register_variant_id"] != "" for c in convivial_non_empty) diff --git a/tests/test_register_variation.py b/tests/test_register_variation.py index d8ad0040..f4807247 100644 --- a/tests/test_register_variation.py +++ b/tests/test_register_variation.py @@ -4,7 +4,12 @@ from __future__ import annotations -from chat.register_variation import _select_bucket_entry, decorate_surface +from chat.register_variation import ( + DecorationResult, + _select_bucket_entry, + decorate_surface, + decorate_surface_str, +) from packs.register.loader import UNREGISTERED, load_register_pack @@ -120,19 +125,25 @@ def test_decorate_surface_empty_buckets_noop(): """UNREGISTERED / neutral / terse all have empty buckets.""" surface = "Light is illumination. Pack-grounded (en_core_cognition_v1)." out = decorate_surface(surface, UNREGISTERED, turn_idx=0) - assert out == surface + assert isinstance(out, DecorationResult) + assert out.surface == surface + assert out.variant_id == "" neutral = load_register_pack("default_neutral_v1") - assert decorate_surface(surface, neutral, turn_idx=0) == surface + assert decorate_surface(surface, neutral, turn_idx=0).surface == surface + assert decorate_surface(surface, neutral, turn_idx=0).variant_id == "" terse = load_register_pack("terse_v1") - assert decorate_surface(surface, terse, turn_idx=0) == surface + assert decorate_surface(surface, terse, turn_idx=0).surface == surface + assert decorate_surface(surface, terse, turn_idx=0).variant_id == "" def test_decorate_surface_empty_input_noop(): """Empty input ⇒ empty output regardless of register.""" convivial = load_register_pack("convivial_v1") - assert decorate_surface("", convivial, turn_idx=0) == "" + out = decorate_surface("", convivial, turn_idx=0) + assert out.surface == "" + assert out.variant_id == "" def test_decorate_surface_convivial_attaches_markers(): @@ -143,8 +154,11 @@ def test_decorate_surface_convivial_attaches_markers(): out = decorate_surface(surface, convivial, turn_idx=0) # The opening (3 entries, no empty) is always non-empty. # Therefore the surface must change. - assert out != surface - assert surface in out + assert out.surface != surface + assert surface in out.surface + # ADR-0072 (R5) — non-empty markers produce a non-empty variant_id. + assert out.variant_id != "" + assert len(out.variant_id) == 12 def test_decorate_surface_is_deterministic(): @@ -160,7 +174,61 @@ def test_decorate_surface_turn_idx_varies_output(): surface = "Light is illumination." seen: set[str] = set() for t in range(15): - seen.add(decorate_surface(surface, convivial, turn_idx=t)) + seen.add(decorate_surface(surface, convivial, turn_idx=t).surface) # 15 turns × 3 openings × 3 closings = 9 possible outputs; # should see at least 4 distinct. assert len(seen) >= 4, f"only {len(seen)} distinct decorations across 15 turns" + + +# ADR-0072 (R5) — DecorationResult + variant_id + str alias. + + +def test_decorate_surface_str_alias_returns_string(): + """decorate_surface_str preserves the pre-R5 string-only API.""" + convivial = load_register_pack("convivial_v1") + surface = "Light is illumination." + out = decorate_surface_str(surface, convivial, turn_idx=0) + assert isinstance(out, str) + full = decorate_surface(surface, convivial, turn_idx=0) + assert out == full.surface + + +def test_variant_id_stable_across_invocations(): + """Same (opening, closing) pair ⇒ identical 12-char variant_id.""" + convivial = load_register_pack("convivial_v1") + surface = "Light is illumination." + a = decorate_surface(surface, convivial, turn_idx=0) + b = decorate_surface(surface, convivial, turn_idx=0) + assert a.variant_id == b.variant_id + # Stable hex prefix of 12 chars. + assert len(a.variant_id) == 12 + int(a.variant_id, 16) # raises if not hex + + +def test_variant_id_distinguishes_distinct_marker_pairs(): + """Different marker selections ⇒ different variant_ids.""" + convivial = load_register_pack("convivial_v1") + surface = "Light is illumination." + seen_pairs: set[tuple[str, str]] = set() + seen_variant_ids: set[str] = set() + for t in range(40): + result = decorate_surface(surface, convivial, turn_idx=t) + seen_pairs.add((result.opening, result.closing)) + if result.variant_id: + seen_variant_ids.add(result.variant_id) + # Bijection between non-trivial pairs and variant_ids: + # the number of distinct variant_ids equals the number of + # distinct non-empty pairs (collisions on 48 bits ≈ 2^-48 ⇒ none). + non_trivial_pairs = {p for p in seen_pairs if p != ("", "")} + assert len(seen_variant_ids) == len(non_trivial_pairs) + + +def test_variant_id_empty_for_no_decoration(): + """UNREGISTERED / neutral / terse always emit ``variant_id=""``.""" + surface = "Light is illumination." + for pack_id in ("default_neutral_v1", "terse_v1"): + pack = load_register_pack(pack_id) + for t in range(5): + assert decorate_surface(surface, pack, turn_idx=t).variant_id == "" + for t in range(5): + assert decorate_surface(surface, UNREGISTERED, turn_idx=t).variant_id == ""