kestrel.markets
Version:
A typed, token-efficient language + runtime for agentic trading: agents author bounded plans, the runtime fires them at the tick. CLI + typed library + MCP server.
101 lines (90 loc) • 6.17 kB
Markdown
# OSS-ADR-0052 — The Rendering seam: one kernel/tape walk, format adapters, and the human chart
Status: accepted (2026-07-18, owner grill — 2026-07-17 depth review, G1)
## Context
The Frame has two renderers kept in sync by hand: `src/frame/render.ts` (canonical)
and `src/frame/render-arm.ts` (the token-optimal ARM) each independently walk the
8-section SAFETY/CONTROL kernel (`KERNEL_SECTION_LABELS`) and re-materialize the
40-column tape geometry, with near-verbatim copies of `restingState`, `sizingLine`,
`ENGINE_LOG_BUCKETS`, and the action-id helpers. The only bridge is the
after-the-fact information-parity gate, and the renderer epoch
(`renderer-revision.ts`) is a hand-bumped constant whose own docstring has already
drifted from the value (prose `/5`, constant `/8`) — the behaviour↔epoch link is
prose, not a check.
Meanwhile `RequestedFormat` admits `json` and `html` that nothing produces: the
format seam has exactly one adapter (text). The cost surfaced concretely — the
generated stock/options education videos rendered the *agent's* vertical tape,
not a human candlestick chart, because the agent tape is the only Rendering that
exists. CONTEXT.md's Rendering entry already promises the other half ("the
agent's ASCII screen and the human's HTML chart are two Renderings of one
Frame"); Caller doctrine says a human gets the human Rendering. There was no
human Rendering to serve.
## Decision
1. **One walk.** The kernel/tape model is materialized once, in one deep module.
Canonical text, the per-tokenizer ARM texts (the `TOKEN_OPTIMAL_ARMS`
registry), and `json` are **format adapters** at the Rendering seam. The
information-parity gate becomes a property of the seam, not a bridge between
hand-kept copies. **Proof obligation: the canonical adapter reproduces
today's canonical bytes exactly** (golden + parity fixtures) — canonical is
the frozen leaderboard control; the refactor lands byte-identical or not at
all.
2. **The `json` Rendering is materialized.** Frame → typed JSON carrying Fields
whole (`value` + Attribution + SourceWatermark). A Rendering never invents or
changes a value; `null` Fields cross the wire as explicit UNKNOWN.
3. **The human visualization layer is a Rendering adapter package, shipped from
this repo** as a subpath export of the published package
(`kestrel.markets/react`). **Isolation is a requirement, not a hope**: the
subpath is its own `exports` entry (the CLI/agent entry never resolves it),
and `react` is a peer dependency marked optional
(`peerDependenciesMeta.react.optional: true`), so the `npx` funnel installs
nothing new and loads nothing new — a consumer needs React only if they
import the subpath. Two component families:
- the **honest chart** — standard human candlesticks consuming the Frame
`json` Rendering: UNKNOWN renders as a gap (never an interpolated candle),
`MODEL` Fields are badged with their receipt, provenance is inspectable.
No generic-OHLC entry point: a chart that can't prove it invented nothing
defeats the point.
- the **terminal view** — displays an *existing text Rendering* (canonical
or an ARM chosen by a tokenizer/model prop). It renders bytes the engine
already emits; it is a shell, never a fourth renderer.
4. **React hosts Renderings; it never produces them.** This is the invariant
uniting the web components above with the already-decided Ink oversight TUI
(human-oversight PRD 2026-07-14, build decision 11; kestrel-jvr4.4): both
are shells that *display* engine output — the web terminal-view shows text
Renderings the engine already emitted; the Ink TUI is a strict view over
`OversightFrame` that invents no value, while every one-shot verb keeps its
byte-stable `text`/`json`/`ascii` Rendering untouched ("strip the SGR
wrappers and the exact text bytes remain"). No React reconciler — DOM or
Ink — ever sits inside a Rendering: renderers stay pure `Frame → bytes`
functions on the deterministic path. The earlier spike recommendation
against Ink (kestrel-5kt, 2026-07-12: over Ink — install weight,
raw-mode-on-pipes) is superseded on the *choice* by decision 11, but its
concerns survive as constraints: the TUI rides the same isolation
discipline as `/react` — its own entrypoint, lazily loaded, never a hard
dependency the `npx` funnel pays for, and never attached to a piped/
non-interactive stream (raw-mode fails open on pipes; the Caller check
already routes agents to machine Renderings).
5. **The renderer epoch is derived, not hand-typed.** The epoch that stamps
session identity is computed from the renderer's observable behaviour (
mechanism chosen at implementation: e.g. a hash over the golden-fixture
render corpus), so a byte-changing edit cannot ship without the epoch
moving.
## Considered and rejected
- **Formalize a `FrameRenderer` interface but keep both implementations** — the
seam gets a type, the copy-paste cost stays forever.
- **Freeze canonical untouched; dedupe only ARM + json** — zero byte-risk, but
two walks remain and every kernel-section change still lands twice.
- **Platform-side visualizer** — the dashboard gets components but OSS
docs/blog/videos cannot import them, and outsiders cannot render Frames the
way we do; the trust substrate should ship its own legible visualization.
- **Generic OHLC props on the chart** — maximum reuse, but the chart loses
Field provenance and with it the honest-chart claim.
## Consequences
- Caller doctrine becomes servable at the content layer: human-facing content
(dashboard, docs, blog, videos) defaults to the human Rendering; the agent
screen appears only when the subject *is* the agent's perception.
- Kernel-section and tape-geometry bugs concentrate in one module; the
duplicated helpers are deleted.
- `TOKEN_OPTIMAL_ARMS` becomes the seam's adapter registry — the terminal
view's model-selector prop reads it directly.
- The parity gate survives as the seam's regression harness, and doubles as
the byte-identity check for the refactor itself.