core/docs/decisions/ADR-0069-realizer-register-parameter.md
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

13 KiB

ADR-0069 — Realizer register parameter (Plan Phase R2)

Status: Accepted (amended 2026-05-19) Date: 2026-05-19 Ratified: 2026-05-19 Amended: 2026-05-19 — composer parameter defaults to RegisterPack.unregistered(); runtime-threading lint test replaces the "required keyword-only" rule. See Amendment section. Author: Shay Phase: Plan Phase R2 (presentation-axis wiring) Builds on: ADR-0068 (register pack class)


Context

ADR-0068 landed the register pack class — frozen dataclass, loader, ratification script, default_neutral_v1 null register, and an architectural seam test asserting that no upstream module imports packs.register. At R1 the realizer is untouched and the seam is machine-enforced.

R2 is the first wiring step. Two questions the ADR answers:

  1. Where does the register enter the runtime? At realizer call time — after intent classification, propagation, grounding-source selection, chain resolution, and articulation-target construction. Never earlier.
  2. What happens when register is consumed but the pack is null? Byte-identical output to the unregistered path. This is the load-bearing CI gate for R2.

R2 ships plumbing only. No composer changes its template choice. No discourse marker is inserted. The realizer entry points learn to accept a register parameter; the runtime learns to load and pass it; the gate asserts that doing so changes nothing. R3 is where the first non-neutral register starts consuming overrides.

This phasing matters because it lets us prove the seam against the trace-hash and grounding-source invariants before any visible behaviour change rides on top.


Decision

Widen the realizer call signature to accept a RegisterPack parameter and thread it through every surface composer entry point in chat/runtime.py. Add RuntimeConfig.register_pack_id: str | None, defaulting to None. Pin three byte-identity invariants in CI.

The three byte-identity invariants

R2 ships only if all three hold across the full cognition lane (public + holdout splits):

invariant_A:  register_pack_id=None
              ≡ pre-R2 unregistered output  (byte-for-byte)

invariant_B:  register_pack_id="default_neutral_v1"
              ≡ register_pack_id=None       (byte-for-byte)

invariant_C:  trace_hash(turn) is invariant under register_pack_id
              for every turn in the cognition lane

Invariant A proves R2 is a no-op for existing callers. Invariant B proves the null register is structurally equivalent to "no register." Invariant C proves register cannot leak into the truth path.

Files

chat/runtime.py                                              EDIT
  - RuntimeConfig gains register_pack_id: str | None = None
  - ChatRuntime.__init__ loads the register pack (or unregistered sentinel)
  - Every realizer call site passes the register through

generate/realizer.py                                         EDIT
  - Public entry points accept register: RegisterPack parameter
  - At R2, register is accepted and discarded (no-op consumption)

generate/templates.py                                        UNCHANGED
  (R3 will start dispatching on register.realizer_overrides;
   R2 leaves templates alone)

chat/pack_grounded_*.py / chat/teaching_grounding.py /       EDIT (parameter only)
chat/cross_pack_grounding.py / chat/narrative_surface.py /
chat/example_surface.py
  - Accept register parameter; do not consume it at R2

tests/test_register_pack_seam.py                             REPLACE
  - R1 version asserted no upstream module imports packs.register;
    R2 narrows the forbidden list (see "Updated seam" below)

tests/test_register_null_lift.py                             NEW
  - Pins invariants A, B, C across the cognition lane

tests/test_runtime_config_register.py                        NEW
  - RuntimeConfig field shape; load behaviour; invalid id rejection

docs/decisions/ADR-0069-realizer-register-parameter.md       NEW (this file)

Wiring shape

# RuntimeConfig (chat/runtime.py)
@dataclass(frozen=True)
class RuntimeConfig:
    ...
    register_pack_id: str | None = None  # NEW (ADR-0069)


# ChatRuntime.__init__
class ChatRuntime:
    def __init__(self, config: RuntimeConfig, ...):
        ...
        if config.register_pack_id is None:
            self._register = RegisterPack.unregistered()
        else:
            self._register = load_register_pack(config.register_pack_id)


# Realizer entry point
def realize_surface(
    target: ArticulationTarget,
    *,
    register: RegisterPack,
) -> str:
    # R2: register is accepted, validated, discarded.
    # R3: register.realizer_overrides[target.intent] selects template family.
    ...

Composer signature widening

Every existing surface composer that the runtime calls — pack-grounded, teaching-grounded, cross-pack, narrative, example, composed, correction, procedure — gains a register: RegisterPack keyword-only parameter. At R2 the parameter is required (no default) so call sites fail loudly if they forget to thread it through. The composer body is unchanged.

Default arguments are avoided deliberately: silent fall-through to RegisterPack.unregistered() would let R3 sites quietly skip the register lookup. Required keyword-only parameter forces explicit threading.

Updated seam

The R1 seam test forbade packs.register imports in every upstream module. R2 replaces it with a narrowed forbidden list:

Still forbidden at R2 (truth-path modules):
  core/cognition/trace.py
  core/cognition/pipeline.py
  core/cognition/result.py
  generate/graph_planner.py
  generate/intent.py
  field/propagate.py
  vault/store.py
  algebra/versor.py

Allowed at R2 (realizer side):
  generate/realizer.py
  generate/templates.py
  chat/runtime.py
  chat/pack_grounded_*.py
  chat/teaching_grounding.py
  chat/cross_pack_grounding.py
  chat/narrative_surface.py
  chat/example_surface.py

The structural commitment from R1 is preserved: register is consumed at realizer call time only. Trace, pipeline, intent classification, propagation, vault recall, and algebra remain free of register imports.

Where the register pack loads

ChatRuntime.__init__ loads it once and stores it on the instance. Per-turn lookup is not needed — RuntimeConfig is frozen and the register pack is content-addressed and immutable after load. Reloading per turn would be wasted work and would obscure the "register is session-scoped" mental model.

When register_pack_id is None, the sentinel RegisterPack.unregistered() is used. This is structurally identical to default_neutral_v1 (both null registers) and the invariant-B test proves it.


Consequences

Capability unlocked at R2

None visible at runtime. R2 lands the wiring and the byte-identity gate. Capability unlocks at R3 (second ratified register pack).

Cognition lane — byte-identical

Required and CI-pinned by test_register_null_lift.py:

public:  intent 100% / surface 100% / term 91.7% / closure 100%
holdout: intent 100% / surface 100% / term 83.3% / closure 100%

must hold post-merge across all three invariants (None, default_neutral_v1, unregistered sentinel).

Test coverage

  • test_register_null_lift.py — runs the cognition lane three ways and asserts surfaces are byte-identical pairwise. Also asserts grounding_source and trace_hash are invariant across registers.
  • test_runtime_config_register.pyRuntimeConfig.register_pack_id default is None; invalid id raises RegisterPackError at runtime init; default_neutral_v1 loads cleanly.
  • test_register_pack_seam.py (replaces R1 version) — narrowed forbidden list; loader-side import constraints unchanged.

Performance

Negligible. One pack load on ChatRuntime.__init__; the loaded pack is a frozen dataclass passed by reference per turn. No new allocations in the hot path.


Trust boundaries

  • Register cannot mutate truth. Realizer is the only consumer. grounding_source, chain resolution, trace hash, and proposition graph are computed before realizer is called. Invariant C CI-enforces this.
  • Required keyword-only parameter. No composer accepts a default register; missing it raises TypeError at call time. This catches R3 wiring mistakes early.
  • Load-time validation. Invalid register_pack_id raises RegisterPackError at ChatRuntime.__init__, not at first turn. Fail-fast on session start.
  • No mutation surface. ADR-0068 doctrine preserved: register packs are operator-authored and ratified through scripts/ratify_register_packs.py. R2 adds no runtime write path to packs/register/.
  • Telemetry stays redact-safe. R2 does not extend TurnEvent. Adding register_id to telemetry is an R5 task (ADR-0072) — out of scope here.

Verification

tests/test_register_null_lift.py                             N passed
tests/test_runtime_config_register.py                        N passed
tests/test_register_pack_seam.py                             updated, N passed
Curated lanes (must remain green):
  smoke / cognition / teaching / packs / runtime / algebra
Cognition eval byte-identical across:
  register_pack_id=None
  register_pack_id="default_neutral_v1"
Full lane: zero new failures.

The invariant-A/B/C test is the load-bearing artifact. If it lands and passes, R2 has shipped its full promise.


Open questions deferred to later phases

  • How do composers consult realizer_overrides? R3 question. R2 only proves the parameter threads cleanly.
  • Where do discourse markers attach to surfaces? R4 question. Likely a post-composer wrapper that consults register.discourse_markers and the seeded selection function. Not part of R2.
  • Should RegisterPack.unregistered() be replaced with the loaded default_neutral_v1 by default? Open. Two arguments:
    • Replace: simpler mental model; one canonical null register.
    • Keep separate: sentinel doesn't require disk presence; works in minimal/test environments where packs/register/ is absent. Lean toward keep-separate; revisit if it causes confusion in R3.
  • Should the realizer signature change to accept a richer RealizerContext object (register + future fields) instead of bare RegisterPack? Probably yes by R4, but premature at R2. Deferred.

Amendment (2026-05-19)

The original ADR required composers to take register as a required keyword-only parameter, on the principle that defaults would let R3 sites silently fall through to RegisterPack.unregistered(). R2 implementation scouting found that rule lands on 167 call sites across 15 test files. Each of those sites calls a composer as a bare function (e.g. teaching_grounded_surface("light", IntentTag.CAUSE)) and would need a register keyword added.

That cost was not measured in the original ADR. Updated decision:

  • Composers accept register: RegisterPack = RegisterPack.unregistered() as a keyword-only argument with a default. Existing test call sites remain valid.
  • A new lint test, test_register_runtime_threading.py, AST-parses chat/runtime.py and asserts that every call to a composer function passes register explicitly. This is the runtime-side enforcement that R3 cannot silently drop the register lookup.
  • The default is the unregistered sentinel, not a loaded pack. A composer called without register= from a non-runtime caller (test, ad-hoc CLI) defaults to the same in-memory shape that runtime would use when register_pack_id=None. Byte-identity invariants A/B/C still hold trivially because the runtime path always threads explicitly.

The seam intent — "R3 cannot quietly skip the register lookup" — is preserved by the lint test rather than by the function signature. The trade is: signature-level rigour for blast-radius reduction, with the seam guarantee re-located one level up.

This amendment supersedes the "Composer signature widening" subsection above where it conflicts.


Future ADRs unlocked

  • ADR-0070 (Phase R3) — second ratified register pack. First non-neutral register (terse_v1 candidate). Composers start consulting realizer_overrides. New CI metric: register_invariant_grounding=True (grounding source identical across registers for every prompt; only surface wording varies).
  • ADR-0071 (Phase R4) — seeded surface variation. (trace_hash, register, turn_idx) → template_variant. Discourse markers gain bounded variants pulled by deterministic seed.
  • ADR-0072 (Phase R5) — telemetry + operator surface. TurnEvent gains register_id and template_variant_id. core chat --register <id> flag. core demo register-tour.