|
| 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 |
0 commit comments