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.
18 KiB
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:
- A deterministic seed derived from
(trace_hash, register_id, turn_idx)that selects from bounded discourse-marker buckets. - A second non-neutral register pack —
convivial_v1— that exercises the marker buckets end-to-end. (terse_v1keeps its empty markers; the R3 invariants over it still hold.) - A schema widening for
realizer_overridesto support aper_intentnested 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
# 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
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.
{
"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_countinchat/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
{
"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:
- Known-key allow-list adds
per_intent(validated as a dict whose keys are validIntentTagnames and whose values are dicts of known flat keys — same allow-list, one nesting level). - 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/closingsmust be non-empty for a pack to claim a non-nullregister status (defensive: an "empty" non-null pack is justdefault_neutral_v1with extra ratification overhead — refuse with a clear message). transitionsaccepted but reported in evidence astransitions_reserved=true. R4 does not consume them.
- At least one of
- Ratification method label for marker-using packs:
seeded_variation_replay_equivalence.
Runtime threading
# 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 ofpacks.registerfrom truth-path modules. Addchat/register_variation.pyto 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_intentkeys must be validIntentTagnames (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=truein 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 byinvariant_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
countmultipliers. 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_surfaceshort-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.
TurnEventgainsregister_idandtemplate_variant_id(or the marker-pair selected by the seed).core chat --register <id>CLI flag.core demo register-tourwalks 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]].