core/docs/decisions/ADR-0071-seeded-surface-variation.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

463 lines
18 KiB
Markdown

# ADR-0071 — Seeded surface variation + discourse markers (Plan Phase R4)
**Status:** Accepted
**Date:** 2026-05-19
**Ratified:** 2026-05-19
**Author:** Shay
**Phase:** Plan Phase R4 (deterministic surface variation)
**Builds on:** ADR-0068 (register pack class), ADR-0069 (realizer
register parameter), ADR-0070 (`terse_v1` + `realizer_overrides`)
---
## Context
ADR-0070 proved a non-neutral register can vary surface text while
grounding source and trace hash stay invariant. That register
(`terse_v1`) varies *depth* — one knob, one composer site, one
deterministic change per turn given the same input.
R4 is where the system gains genuine *output variation across turns*
without losing replay equivalence. The load-bearing question is:
> Can a single register produce different surfaces on repeat invocations
> while remaining bit-for-bit reproducible from
> `(input, pack_set, register_pack_id, turn_idx)`?
The original-question framing (the deterministic-AI "canned response"
critique) bottoms out here. A user who asks the same question twice
should not get verbatim-identical text — but the system must still be
fully reproducible. The mechanism is *seeded* variation: the variation
is a deterministic function of inputs the user does not control but the
system commits to.
R4 ships three things:
1. A deterministic seed derived from `(trace_hash, register_id,
turn_idx)` that selects from bounded discourse-marker buckets.
2. A second non-neutral register pack — `convivial_v1` — that exercises
the marker buckets end-to-end. (`terse_v1` keeps its empty markers;
the R3 invariants over it still hold.)
3. A schema widening for `realizer_overrides` to support a `per_intent`
nested key, so future R5+ work can dispatch per intent without
another schema change.
---
## Decision
Introduce `chat/register_variation.py` as the single deterministic-seed
surface to which the runtime delegates post-composer decoration. Widen
the ratify gate to allow non-empty discourse markers and `per_intent`
overrides. Add the `convivial_v1` register pack. Pin a new
load-bearing invariant — `seeded_variation_replay_equivalence`
across the cognition lane.
### The seed
```python
# chat/register_variation.py
def _select_bucket_entry(
bucket: tuple[str, ...],
*,
trace_hash: str,
register_id: str,
turn_idx: int,
bucket_name: str,
) -> str:
"""Deterministically pick one entry from *bucket*, or '' if empty.
Seed is one-way: it READS trace_hash but NEVER feeds back into it.
Pinned by ADR-0069 invariant C and ADR-0070 register_invariant_grounding.
"""
if not bucket:
return ""
seed_bytes = (
f"{trace_hash}|{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]
```
The choice of inputs is load-bearing:
- `trace_hash` — couples variation to the truth path so two semantically
different turns under the same register never collide on the same
marker by accident. **One-way coupling**: trace_hash is read here but
*cannot* feed back into itself (it has already been computed and
sealed by the time the realizer is called).
- `register_id` — different registers under the same trace_hash pick
different markers. Switching registers mid-session produces visibly
different surfaces even on byte-identical truth content.
- `turn_idx` — same prompt twice in one session does NOT produce
byte-identical surface, because turn_idx increments. Replay against
the same `(input_sequence, register, packs)` does, because turn_idx
is part of the inputs.
- `bucket_name``"openings"`, `"transitions"`, `"closings"` use
different seeds so e.g. opening and closing don't always correlate.
### Decoration shape
```python
def decorate_surface(
surface: str,
register: RegisterPack,
*,
trace_hash: str,
turn_idx: int,
) -> str:
"""Apply seeded discourse-marker decoration to *surface*.
Always returns at minimum the original surface (empty buckets ⇒
no-op). Order is opening + ' ' + surface + closing, with the
closing concatenated directly so a marker like ' — does that help?'
can attach without a separator.
"""
opening = _select_bucket_entry(
register.discourse_markers.openings,
trace_hash=trace_hash, register_id=register.register_id,
turn_idx=turn_idx, bucket_name="openings",
)
closing = _select_bucket_entry(
register.discourse_markers.closings,
trace_hash=trace_hash, 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
```
Transitions are **deferred to a later phase**. Clause-boundary
detection is realizer-internal and tangling it into R4 widens scope
without commensurate evidence. The schema accepts a `transitions`
bucket; R4 reads it and validates it; nothing consumes it yet.
### Per-intent override schema
Open question from ADR-0070 closed: `realizer_overrides` gains an
optional `per_intent` nested key. Flat keys remain valid and apply to
all intents; `per_intent[intent_tag]` overrides them per-intent.
```json
{
"realizer_overrides": {
"disclosure_domain_count": 3,
"per_intent": {
"DEFINITION": {"disclosure_domain_count": 1},
"COMPARISON": {"disclosure_domain_count": 2}
}
}
}
```
The composer resolves overrides as `per_intent[current_intent] >> flat
>> default`. R4 wires this through `_resolve_disclosure_domain_count`
in `chat/pack_grounding.py` (already R3-ready) so the resolution is a
single dispatch.
Per-intent IS NOT exercised by `convivial_v1` (which leans on discourse
markers, not depth). It's wired so R5 packs can opt in without another
schema change.
### The new pack — `convivial_v1`
```json
{
"register_id": "convivial_v1",
"version": "1.0.0",
"description": "Warm, conversational register. Seeded openings and closings draw from small bounded buckets; depth follows the standard (3-domain) default. Variation is deterministic across (trace_hash, register_id, turn_idx). See ADR-0071.",
"schema_version": "1.0.0",
"mastery_report_sha256": "",
"display_name": "Convivial",
"depth_preference": "standard",
"realizer_overrides": {},
"discourse_markers": {
"openings": ["So,", "Right —", "OK,"],
"transitions": [],
"closings": ["", " — does that help?", " — make sense?"]
}
}
```
The empty-string entry in `closings` is intentional: ~1/3 of turns get
no closing, so the register does not feel mechanically marker-stuffed.
The seed enforces uniformity across the bucket, not a frequency curve.
That is acceptable for R4 — frequency shaping is an R5+ design space.
### Ratify gate widening
`scripts/ratify_register_packs.py`:
1. Known-key allow-list adds `per_intent` (validated as a dict whose
keys are valid `IntentTag` names and whose values are dicts of
known flat keys — same allow-list, one nesting level).
2. Discourse markers may now be non-empty. Buckets are validated by
the loader's existing bounds checks (`_MAX_MARKER_LEN`,
`_MAX_MARKERS_PER_BUCKET`). The gate adds:
- At least one of `openings`/`closings` must be non-empty for a pack
to claim a non-`null` register status (defensive: an "empty"
non-null pack is just `default_neutral_v1` with extra ratification
overhead — refuse with a clear message).
- `transitions` accepted but reported in evidence as
`transitions_reserved=true`. R4 does not consume them.
3. Ratification method label for marker-using packs:
`seeded_variation_replay_equivalence`.
### Runtime threading
```python
# chat/runtime.py
class ChatRuntime:
def chat(self, text: str, ...) -> ChatResponse:
...
# Compute the trace hash (truth path; unchanged from R3).
result = self._pipeline.run(text, ...)
# Realize the surface (composers consume register).
surface = self._maybe_pack_grounded_surface(...) # or fallback
# Apply seeded decoration AFTER trace_hash is sealed.
turn_idx = len(self.turn_log)
surface = decorate_surface(
surface,
self.register_pack,
trace_hash=result.trace_hash,
turn_idx=turn_idx,
)
...
```
The decoration call is unconditional but is a no-op when buckets are
empty (sentinel, neutral, terse — every R1/R2/R3 register). The
existing register-aware composer call sites are untouched.
### Files
```
packs/register/convivial_v1.json NEW
packs/register/convivial_v1.mastery_report.json NEW (generated)
scripts/ratify_register_packs.py EDIT
- REGISTER_IDS gains "convivial_v1"
- Known-key allow-list adds "per_intent" (dict validator)
- Discourse markers may be non-empty
- Ratification method label "seeded_variation_replay_equivalence"
for marker-using packs
- Evidence dict gains: marker_bucket_sizes,
per_intent_keys, transitions_reserved
chat/register_variation.py NEW
- _select_bucket_entry (deterministic seeded selector)
- decorate_surface (post-composer wrapper)
chat/runtime.py EDIT
- Import decorate_surface from chat.register_variation
- Call decorate_surface AFTER pack-grounded composers and AFTER
trace_hash is sealed, AFTER turn_event construction
- turn_idx = len(self.turn_log) at call time
chat/pack_grounding.py EDIT
- _resolve_disclosure_domain_count(register, intent=None)
consults register.realizer_overrides["per_intent"][intent.name]
before the flat key
- Existing callers passing no intent stay safe (flat key only)
- build_pack_surface_candidate gains optional intent: IntentTag | None
packs/register/loader.py EDIT
- _validate_overrides accepts a `per_intent` nested dict
(whitelist key, value-shape validated recursively)
tests/test_register_variation.py NEW
- _select_bucket_entry determinism (same inputs → same output)
- _select_bucket_entry distribution (across 1000 fake trace hashes,
every bucket entry is selected at least once → uniform sanity)
- decorate_surface empty buckets ⇒ no-op
- decorate_surface neutral + sentinel ⇒ surface unchanged
tests/test_register_pack_convivial_v1.py NEW
- convivial_v1 loads, self-seal verifies
- Discourse markers populated as declared
- Repeated ChatRuntime.chat(prompt) across separate sessions ⇒
identical surfaces (replay equivalence)
- Same prompt, three turns in one session ⇒ at least one surface
differs (turn_idx variation is observable)
tests/test_seeded_variation_replay_equivalence.py NEW
- Run cognition lane under convivial_v1 in two fresh runtimes
- Assert per-case surfaces are byte-identical between runs (replay)
- Assert grounding_source byte-identical between runs
- Assert trace_hash byte-identical between runs
- Assert at least one case under convivial differs from neutral
(variation is actually visible somewhere in the lane)
tests/test_register_invariant_grounding.py EDIT
- Extend to include convivial_v1 alongside terse_v1
- grounding_source + trace_hash + aggregate metrics still invariant
tests/test_register_runtime_threading.py UNCHANGED
- decorate_surface is post-composer; not on the threaded list
docs/decisions/ADR-0071-seeded-surface-variation.md NEW (this file)
```
### Invariants pinned in CI at R4
```
invariant_A (ADR-0069): register_pack_id=None ≡ pre-R2 unregistered
invariant_B (ADR-0069): None ≡ default_neutral_v1 (byte-identical)
invariant_C (ADR-0069): trace_hash invariant under register
invariant_register_grounding (ADR-0070):
grounding_source identical across {None, neutral, terse, convivial}
invariant_seeded_variation_replay (ADR-0071): NEW
Two fresh ChatRuntime sessions under the same register, given the
same input sequence and pack set, produce byte-identical surfaces.
invariant_seeded_variation_turn_distinct (ADR-0071): NEW
Same prompt, multiple turns in one session, under a marker-using
register: at least one turn's surface differs from the others
(proves the seed actually moves with turn_idx).
```
The two new invariants are the load-bearing R4 artifacts. If they pass,
the deterministic-AI critique is structurally answered.
---
## Consequences
### Capability unlocked at R4
A non-neutral register that produces *visibly different* surfaces
across turns while remaining bit-for-bit reproducible from
`(input_sequence, register_pack_id, pack_set)`. The first end-to-end
demonstration of "deterministic without canned."
### Cognition lane — split expectation
```
None : unchanged from R3 baseline
neutral : unchanged from R3 baseline (invariants A, B)
terse : unchanged from R3 baseline (empty markers, decoration
is no-op; invariant_register_grounding still holds)
convivial : surfaces may differ from neutral on most cases;
grounding_source / trace_hash / versor_closures /
intent_correct match neutral byte-for-byte; aggregate
term_capture_rate and surface_groundedness MAY differ
(lane assertions are substring-permissive — markers
don't disrupt the provenance substrings)
```
If the cognition lane's surface predicates fail under `convivial_v1`
because a marker collides with a substring assertion, the ADR-0070
debugging signal applies: the lane fixture has a latent dependency,
not an architectural bug. R4 expectation: lane substring predicates
match through marker decoration because markers attach as prefix/suffix
and the provenance markers (`pack-grounded (X)`) sit mid-surface.
### Performance
One SHA-256 per bucket per turn. Two buckets currently consulted
(`openings`, `closings`). Two `int.from_bytes` + one modulo. Negligible
in the hot path.
### Test coverage
- `test_register_variation.py` — determinism + bucket distribution.
- `test_register_pack_convivial_v1.py` — pack-level integration.
- `test_seeded_variation_replay_equivalence.py` — load-bearing R4
artifact (replay equivalence across separate sessions).
- `test_register_invariant_grounding.py` — extended to four registers.
### Trust boundaries
- **Seed is one-way.** `trace_hash → marker_selection`; never the
reverse. The seam test (ADR-0068, narrowed at R2) continues to refuse
imports of `packs.register` from truth-path modules. Add
`chat/register_variation.py` to the *allowed-realizer-side* list.
- **Markers are bounded.** Loader bounds (`_MAX_MARKER_LEN`,
`_MAX_MARKERS_PER_BUCKET`) already enforced at R1. Ratify gate adds
the additional check that at least one marker bucket be populated
for a marker-using ratification.
- **Per-intent dispatch is allow-listed.** `per_intent` keys must be
valid `IntentTag` names (whitelist), values must be dicts of known
flat keys. Unknown intent names are refused with a clear error.
- **Transitions reserved.** Schema accepts the bucket but R4 does not
consume it. Operators can ship transition entries; they sit until a
later phase consumes them. Loader validates bounds; ratify reports
`transitions_reserved=true` in evidence.
- **No new mutation surface.** No runtime write path to
`packs/register/`.
- **Replay determinism preserved.** Two runtimes started from the same
config and given the same input sequence produce identical
`turn_log`. Pinned by `invariant_seeded_variation_replay`.
### Backwards compatibility
R3-and-earlier registers (`UNREGISTERED`, `default_neutral_v1`,
`terse_v1`) have empty marker buckets, so `decorate_surface` is a no-op
for them. ADR-0069 invariants A/B/C remain byte-identical. ADR-0070
`register_invariant_grounding` continues to hold and is extended to
include `convivial_v1`.
---
## Verification
```
tests/test_register_variation.py N passed
tests/test_register_pack_convivial_v1.py N passed
tests/test_seeded_variation_replay_equivalence.py N passed
tests/test_register_invariant_grounding.py N passed (extended)
Curated lanes (must remain green):
smoke / cognition / teaching / packs / runtime / algebra
Cognition eval:
register_pack_id=None == R3 baseline (byte-identical)
register_pack_id="default_neutral_v1" == None (invariant B)
register_pack_id="terse_v1" == R3 baseline (empty markers)
register_pack_id="convivial_v1" grounding_source / trace_hash /
versor_closures byte-identical
against neutral; surface differs
on marker-decorated cases
Replay-equivalence test:
Two fresh ChatRuntime sessions under convivial_v1, same prompt
sequence ⇒ byte-identical surfaces.
```
---
## Open questions deferred to later phases
- **Transition markers.** Schema accepted; not consumed. Needs clause
boundary detection — realizer-internal — deferred until a concrete
use case forces the wiring.
- **Frequency shaping.** Current selector is uniform over the bucket.
Real conversational variation has a frequency curve (closings less
often than openings, for example). One option: bucket entries weight
themselves with explicit `count` multipliers. Deferred to R5+ once
operator-author feedback is available.
- **Multi-pack discourse-marker composition.** A future "personality"
pack alongside a "register" pack might want to compose markers. Out
of scope.
- **Should `decorate_surface` short-circuit for non-pack-grounded
surfaces?** Currently no — it decorates every surface uniformly.
Open question whether vault-grounded recall surfaces should also be
decorated. R4 keeps the uniform path; revisit if it causes
surprising decoration on rote recall.
---
## Future ADRs unlocked
- **ADR-0072 (Phase R5)** — telemetry + operator surface. `TurnEvent`
gains `register_id` and `template_variant_id` (or the marker-pair
selected by the seed). `core chat --register <id>` CLI flag.
`core demo register-tour` walks neutral → terse → convivial on the
same prompt sequence so the variation is operator-visible.
- **ADR-0073+ (post-R5)** — anchor-lens / Greek-Hebrew substantive
variation. The presentation axis is now solid enough to compose
against a true content-axis variation. See
`[[greek-hebrew-pack-scout-2026-05-19]]`.