Skip to content

Commit d12bd61

Browse files
authored
docs(attribution): document UTF-16 / UTF-8 encoding boundary (#223)
Adds a contract doc in claude-notes/designs/ plus file-header / struct-doc anchors on the two load-bearing AttributionRun types. Frames the dual encoding as inherited from each side's substrate (tree-sitter + SourceInfo on the Rust side, JS string primitives + Monaco + Automerge on the JS side) rather than a stylistic choice, so it isn't "harmonized" later.
1 parent 9a368c8 commit d12bd61

3 files changed

Lines changed: 140 additions & 0 deletions

File tree

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Attribution encoding contract
2+
3+
**Status:** Active (Phase 5 of the attribution pipeline, see
4+
`claude-notes/plans/2026-05-06-attribution-pipeline.md`).
5+
**Conversion site:** `buildAttributionPayload` in
6+
`hub-client/src/hooks/useAttribution.ts`.
7+
**Key types:** `AttributionRun` (TS) in
8+
`hub-client/src/services/attribution-runs.ts`, `AttributionRun` (Rust)
9+
in `crates/quarto-core/src/attribution/types.rs`.
10+
11+
## Summary
12+
13+
Attribution intentionally uses **two coordinate spaces** for run
14+
boundaries, joined at exactly one site. The JS side speaks UTF-16
15+
code units because that is what Automerge text patches, JS string
16+
indexing, and Monaco editor offsets all natively produce. The Rust
17+
side speaks UTF-8 byte offsets because that is what `&str` indexing
18+
and `SourceInfo` ranges use. The conversion happens at the WASM
19+
wire, immediately before the JSON payload is shipped to the Rust
20+
pipeline.
21+
22+
This is not a bug to be "harmonized." Both sides are correct for
23+
their own domain; collapsing to a single encoding would force one
24+
side to fight its primitives on every offset.
25+
26+
## The two spaces
27+
28+
| Side | Space | Why |
29+
|---|---|---|
30+
| Automerge (Rust crate, compiled with `utf16-indexing`) | UTF-16 code units | Crate default; verified at `crates/quarto-hub/src/automerge_api_tests.rs::test_text_encoding_is_utf16` |
31+
| Automerge (JS via `@automerge/automerge`) | UTF-16 code units | Native to JS strings; `patch.value.length` and `patch.length` on `diff()` output are UTF-16 |
32+
| Monaco editor (presence cursors) | UTF-16 code units | Native to `model.getOffsetAt` / `model.getPositionAt`; passes through `A.getCursorPosition` unchanged |
33+
| Run-list replay (`attribution-runs.ts`) | UTF-16 code units | Direct passthrough of Automerge patches |
34+
| Rust attribution (`AttributionRun`, `query_byte_range`, `GitBlameProvider`) | UTF-8 byte offsets | Aligns with `&str` indexing and the rest of `SourceInfo` |
35+
36+
## Single conversion site
37+
38+
`buildAttributionPayload(state, sourceText, identities)` in
39+
`hub-client/src/hooks/useAttribution.ts` calls
40+
`buildCharToByteMap(text)` (iterates `text.charCodeAt(i)` with
41+
explicit surrogate-pair handling, returning a `Uint32Array` of byte
42+
offsets keyed by UTF-16 index), then `runsCharToByteOffsets(runs, map)`
43+
to translate each run's `start`/`end` from char offsets to byte
44+
offsets before `JSON.stringify`.
45+
46+
All Automerge-driven JS code upstream of `buildAttributionPayload`
47+
stays in UTF-16. All Rust code downstream of the JSON wire stays in
48+
UTF-8 bytes. The wire format itself carries bytes.
49+
50+
## Surrogate-pair handling
51+
52+
`buildCharToByteMap` walks UTF-16 code units, not code points. A
53+
surrogate pair (e.g. an emoji or non-BMP CJK character) occupies two
54+
consecutive UTF-16 indices, both of which receive a map entry: the
55+
high-surrogate index maps to the byte offset *before* the 4-byte
56+
UTF-8 sequence, the low-surrogate index to the offset *after*.
57+
Automerge does not emit splice positions that land mid-surrogate, so
58+
a run boundary on the low-surrogate index should not occur in
59+
practice; if it does, the map keeps the translation well-defined.
60+
61+
Test coverage: `hub-client/src/services/attribution-runs.test.ts`
62+
exercises ASCII (identity), 2-byte (Latin-1 supplement), 3-byte
63+
(CJK), and 4-byte (surrogate-pair) cases.
64+
65+
## Failure modes if "harmonized"
66+
67+
The encoding split is not a stylistic preference on either side.
68+
Each side's encoding is forced by the substrate immediately beneath
69+
it; collapsing to a single encoding doesn't simplify the design, it
70+
relocates the translation cost to a worse place.
71+
72+
**The encodings are inherited, not chosen.** On the Rust side, every
73+
caller of `query_byte_range` already has `start` and `end` in byte
74+
form because the layers below produce byte offsets natively:
75+
tree-sitter returns node positions as bytes (`Range.start_byte` /
76+
`end_byte`), `SourceInfo` carries those bytes through every AST
77+
transform, `&str` indexing requires bytes, and `GitBlameProvider`
78+
consumes line-based blame output which is byte-positional. On the
79+
JS side, `string[i]`, `charCodeAt(i)`, `string.length`, Automerge
80+
text splice positions, Monaco's `getOffsetAt`, and the DOM Selection
81+
API all return UTF-16 code units natively. Attribution is the
82+
*consumer* of coordinate systems chosen many layers below it on
83+
both sides — there is no UTF-16 plane to ask tree-sitter for, and
84+
no UTF-8 plane to ask Monaco for.
85+
86+
**Force UTF-8 on the JS side.** Every Automerge patch position,
87+
every Monaco cursor, and every `string[i]` would need byte
88+
translation per use. Cost moves from one conversion per payload
89+
(debounced at ~500 ms) to one conversion per editor interaction —
90+
many orders of magnitude more work, in the editor hot path rather
91+
than the producer's cold debounced path.
92+
93+
**Force UTF-16 on the Rust side, option (a) — translate at every
94+
query.** Every `query_byte_range` call becomes a
95+
`byte_range → utf16_range → query` chain. For a document with
96+
thousands of AST nodes, every render triggers thousands of
97+
conversions. Translation cost moves from O(payloads) to
98+
O(AST nodes × renders), into the rendering hot path.
99+
100+
**Force UTF-16 on the Rust side, option (b) — translate the
101+
substrate.** Rewrite tree-sitter integration, `SourceInfo`, every
102+
AST transform, citeproc, link rewriting, diagnostics, and
103+
serialization to track UTF-16 code units rather than bytes. Massive
104+
ripple for no win — `&str` still indexes by bytes, so a byte ↔
105+
code-unit map would have to travel alongside every range anyway.
106+
107+
The current design picks the WASM wire as the conversion point
108+
because it is the smallest, coldest, most auditable surface: one
109+
function, debounced, off the rendering hot path.
110+
111+
## Soft floor on async desync
112+
113+
`runsCharToByteOffsets` uses `charToByte[r.start] ?? r.start` rather
114+
than asserting in-bounds. This is deliberate: between the moment a
115+
run list is computed (via `buildRunListAttribution` /
116+
`updateRunListAttribution`) and the moment `buildAttributionPayload`
117+
reads `sourceTextRef.current`, the document can receive a remote
118+
Automerge change. A deletion-shaped race produces runs whose `end`
119+
exceeds the new `sourceText.length`. The `??` falls back to the raw
120+
UTF-16 offset for that one frame; the next debounced update heals
121+
it. Converting to a hard assertion would null the payload on benign
122+
races for no correctness gain.
123+
124+
## Related
125+
126+
- `claude-notes/plans/2026-05-06-attribution-pipeline.md` — original Phase 5 plan
127+
- `crates/quarto-hub/src/automerge_api_tests.rs` — UTF-16 feature check + emoji splice tests
128+
- `crates/quarto-core/src/attribution/types.rs::AttributionRun` — Rust-side byte-offset types
129+
- `crates/quarto-lsp-core/src/types.rs::Position` — a separate UTF-16 surface (LSP spec); not connected to attribution

crates/quarto-core/src/attribution/types.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ use crate::format::{Format, FormatIdentifier};
2121
/// A contiguous byte-range run attributed to a single author at a
2222
/// single point in time.
2323
///
24+
/// `start` and `end` are UTF-8 **byte** offsets into the source text,
25+
/// deliberately distinct from the UTF-16 code units used on the JS
26+
/// side (`hub-client/src/services/attribution-runs.ts`) and by
27+
/// Automerge text splice positions. Conversion happens once, at the
28+
/// WASM wire, inside `buildAttributionPayload`. See
29+
/// `claude-notes/designs/attribution-encoding-contract.md`.
30+
///
2431
/// `actor` is `Arc<str>` (not `String`) so the same Arc is shared
2532
/// across every run by the same author. For a doc with 5
2633
/// contributors and 1000 runs this is 5 string allocations + 1000

hub-client/src/services/attribution-runs.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
* Algorithm reference (and known-good baseline): the prototype branch
1616
* `feat/node-attribution` carries this file along with the consumer-side
1717
* surface and the `attribution-runs.test.ts` invariant suite.
18+
*
19+
* See `claude-notes/designs/attribution-encoding-contract.md` for the
20+
* full statement of the UTF-16 / UTF-8 boundary and why both sides are
21+
* correct in their own coordinate space.
1822
*/
1923

2024
import { diff } from '@automerge/automerge';

0 commit comments

Comments
 (0)