core/docs/decisions/ADR-0011-renderer.md

100 lines
4.1 KiB
Markdown

# ADR-0011 — Renderer Layer Contract
**Status:** Accepted
**Date:** 2026-05-13
---
## Context
The architecture pipeline terminates at `generate/stream.py`, which produces a sequence of
versor-nearest tokens. Those tokens are internal field entities — they have CGA coordinates,
provenance, and algebraic identity. Before reaching any surface (terminal, API response, UI,
audio), they must be realized into a modality-specific form.
In `core-ai`, this became `core_logos` — a full subsystem with deterministic readback, surface
realization, public trace metadata, and its own authority boundary. That was over-engineering:
it solved operational concerns (auditability, proof artifacts, API stability) before the
underlying generation was correct.
In `core`, the renderer is deliberately thin. It is not a subsystem. It is a single contract.
---
## Decision
The renderer layer is defined by one interface:
```python
class Renderer(Protocol):
"""Convert a generated token sequence into surface output.
Contract:
- Input: Iterable[VocabEntry] — the ordered token stream from generate/stream.py
- Output: str | bytes — modality-specific surface realization
- Stateless: the renderer holds no field state and modifies nothing
- Deterministic: identical token sequences produce identical surface output
"""
def render(self, tokens: Iterable["VocabEntry"]) -> str | bytes: ...
```
The default implementation (`generate/render.py`) is a plain text renderer:
tokens → their `.surface` strings joined by the language-appropriate separator.
Modality-specific renderers (markdown, Hebrew RTL, Koine Greek polytonic, audio phoneme stream)
are implementations of this same protocol, registered externally. The engine never selects a
renderer — the caller provides one.
---
## Rationale
**Why thin?**
The field knows what it means. The renderer only knows how to write it down. These are
fundamentally different concerns. Mixing them (as `core_logos` did) creates a subsystem that
must understand both the algebra and the output format — a dual responsibility that violates
Semantic Rigor.
**Why caller-provided?**
The engine has no concept of "deployment context." Whether it renders to a terminal, an API,
a mobile UI, or an audio stream is not the engine's concern. Injecting a renderer at the call
site keeps the engine's contract pure and keeps the engine testable in isolation.
**Why stateless?**
Propagation-over-mutation. The renderer receives a completed token stream. It does not
accumulate, buffer, or modify field state. If continuity across renders is needed, that is a
session-level concern, not a renderer concern.
**Why deterministic?**
Third Door: the renderer is a pure function of the token stream. Non-determinism (formatting
decisions, adaptive punctuation, "natural" variation in surface form) is a property of language
models that apply stochastic transforms at output time. CORE does not do that. The field
determines meaning; the renderer transcribes it exactly.
---
## Hebrew and Koine Greek Rendering
These are not localizations — they are depth languages with structurally different rendering
requirements:
- **Hebrew:** RTL script, prefix/suffix morphology carried as field metadata, nikud
(vowel points) rendered only when the VocabEntry carries them explicitly
- **Koine Greek:** polytonic diacritics, breathing marks, iota subscript — all carried in the
VocabEntry's `.surface` field; the renderer writes them as-is
Neither requires a special renderer *subsystem*. Both require only that the VocabEntry's
`.surface` field is correctly populated upstream (in `vocab/`), and that the text renderer
respects Unicode directionality. That is all.
---
## Consequences
- `generate/render.py` is added as the default `TextRenderer` implementation
- `generate/stream.py` does not call any renderer — it yields tokens
- No `core_logos` equivalent will be introduced
- Future modality renderers (audio, structured data) implement `Renderer` and are provided
by the caller
- The renderer is the last thing that happens before output leaves the system
- Nothing after the renderer touches the field