# 5 MB Editor Performance Report

Date: 2026-05-30

Milestone: 2.6 Large Document Profiling

Status: Investigation updated after Milestone 2.7 CRLF correctness fix; no editor optimization or redesign was performed.

## Executive Summary

The 5 MB document becomes unusable primarily because Sapling's current line tracker does not split CRLF line endings. The benchmark file has 51,481 CRLF pairs. Swift treats `"\r\n"` as one newline grapheme cluster, and `EditorActiveLineTracker.lines(from:)` searches the `String` for `Character("\n")`. The result is one giant `EditorLine` containing the entire 5.5M UTF-16-unit document.

Evidence:

- Asset profile: 51,482 physical lines, 51,481 CR characters, 51,481 LF characters.
- Swift check: `"a\r\nb".firstIndex(of: "\n") == nil`; `"a\r\nb".split(separator: "\n").count == 1`.
- Benchmark rendered-mode trace for `Docs/Benchmarks/5mb.md`: 0 rendered lines, 1 source line.
- The initial styling pass reports 1 styled line, not 51,482 styled lines.

This explains the visible source-only behavior: the editor believes the whole document is the active line. Dirty-line invalidation is technically working, but the dirty line is the entire file.

Milestone 2.7 corrected this by introducing a UTF-16 based `DocumentLineIndex` with explicit LF, CRLF, and CR handling. The corrected benchmark now reports 51,482 editor lines, 51,481 rendered lines, and one active source line.

## Benchmark Asset

The benchmark file is now stored at:

`Docs/Benchmarks/5mb.md`

Recorded characteristics:

| Property | Value |
| --- | ---: |
| File size | 5,526,168 bytes |
| Characters | 5,474,683 |
| UTF-16 units | 5,526,164 |
| Physical lines | 51,482 |
| CR characters | 51,481 |
| LF characters | 51,481 |
| Max line UTF-16 length | 658 |
| Average line UTF-16 length | 105.342 |

Markdown structure:

| Construct | Count |
| --- | ---: |
| ATX headings | 2 |
| Unordered list items | 2 |
| Ordered list items | 0 |
| Blockquotes | 0 |
| Fenced code fences | 0 |
| Lines with inline code | 0 |
| Lines with bold markers | 0 |
| Lines with italic markers | 0 |
| Inline links | 0 |
| Reference-style link markers | 51,156 |
| Images | 0 |

The document mostly consists of repeated reference-style link text. Milestone 2 rendering does not promote reference-style links, so even after correct line splitting most lines would remain visually close to source text unless future rendering adds support for that construct.

## Benchmark Harness

Reusable benchmark command:

```sh
swift run -c release SaplingEditorBenchmark
```

Single-file command:

```sh
swift run -c release SaplingEditorBenchmark Docs/Benchmarks/5mb.md
```

The harness measures:

- file read time
- document model creation
- editor state parse and line-model creation
- render-plan generation
- initial attributed-string generation
- active-line lookup
- selection update
- dirty-line invalidation for click and typing paths
- dirty render update for typing
- `NSTextStorage` updates
- `NSLayoutManager` glyph and layout generation
- viewport glyph-range lookup as a scroll proxy

Percentages below are percentages of measured benchmark stage time, not Instruments Time Profiler samples.

## Scenario Summary

Original Milestone 2.6 release benchmark run on May 30, 2026:

| Scenario | Lines | Rendered trace | Measured total |
| --- | ---: | --- | ---: |
| Sample document | 54 | 53 rendered, 1 source | 7.722 ms |
| 2,100-line prototype | 2,101 | 2,100 rendered, 1 source | 348.580 ms |
| 5 MB benchmark | 51,482 physical, 1 editor line | 0 rendered, 1 source | 4,376.271 ms |

Corrected Milestone 2.7 release benchmark run on May 30, 2026:

| Scenario | Lines | Rendered trace | Measured total |
| --- | ---: | --- | ---: |
| Sample document | 54 | 53 rendered, 1 source | 7.367 ms |
| 2,100-line prototype | 2,101 | 2,100 rendered, 1 source | 269.568 ms |
| 5 MB benchmark | 51,482 editor lines | 51,481 rendered, 1 source | 5,674.222 ms |

Memory measurements from `/usr/bin/time -l` after the CRLF fix:

| Scenario | Maximum resident set size |
| --- | ---: |
| 50-line prototype | 22,396,928 bytes |
| 2,100-line prototype | 30,998,528 bytes |
| 5 MB benchmark | 197,378,048 bytes |

These memory values are benchmark-process measurements, not full Sapling app memory measurements.

## Top 20 Operations

5 MB release benchmark before the CRLF fix:

| Rank | Operation | Category | Time | Percent | Evidence |
| ---: | --- | --- | ---: | ---: | --- |
| 1 | `attributed_string_generation_initial` | rendering | 903.494 ms | 20.645% | 1 styled line |
| 2 | `render_update_typing_dirty` | interaction | 887.213 ms | 20.273% | 1 styled line |
| 3 | `layout_generation_full_document` | layout | 766.331 ms | 17.511% | `NSLayoutManager.ensureLayout` |
| 4 | `dirty_line_invalidation_typing` | editor update | 566.852 ms | 12.953% | 1 dirty line |
| 5 | `layout_after_typing` | interaction | 490.160 ms | 11.200% | layout for inserted character |
| 6 | `glyph_generation_full_document` | layout | 157.546 ms | 3.600% | full character range |
| 7 | `typing_state_update` | interaction | 136.721 ms | 3.124% | `EditorState.updateSource` |
| 8 | `selection_update` | editor update | 136.422 ms | 3.117% | `EditorState.updateSelection` |
| 9 | `active_line_lookup` | editor update | 73.509 ms | 1.680% | midpoint cursor lookup |
| 10 | `line_model_generation` | editor update | 63.505 ms | 1.451% | `EditorActiveLineTracker.lines` |
| 11 | `document_parse_editor_state_init` | document load | 63.444 ms | 1.450% | initial `EditorState` |
| 12 | `dirty_line_invalidation_click` | editor update | 63.043 ms | 1.441% | 0 dirty lines |
| 13 | `render_plan_generation_all_lines` | rendering | 51.323 ms | 1.173% | one render plan |
| 14 | `text_storage_initial_update` | layout | 10.957 ms | 0.250% | full source replace |
| 15 | `file_read` | document load | 4.954 ms | 0.113% | disk read |
| 16 | `text_storage_typing_insert` | interaction | 0.711 ms | 0.016% | one-character insertion |
| 17 | `document_model_init` | document load | 0.044 ms | 0.001% | `MarkdownDocument` |
| 18 | `scroll_latency_middle_viewport` | interaction | 0.013 ms | 0.000% | cached glyph-range lookup |
| 19 | `scroll_latency_deep_viewport` | interaction | 0.012 ms | 0.000% | cached glyph-range lookup |
| 20 | `scroll_latency_top_viewport` | interaction | 0.011 ms | 0.000% | cached glyph-range lookup |

5 MB release benchmark after the CRLF fix:

| Rank | Operation | Category | Time | Percent | Evidence |
| ---: | --- | --- | ---: | ---: | --- |
| 1 | `layout_generation_full_document` | layout | 1,320.499 ms | 23.272% | `NSLayoutManager.ensureLayout` |
| 2 | `dirty_line_invalidation_typing` | editor update | 980.651 ms | 17.283% | 3 dirty lines |
| 3 | `attributed_string_generation_initial` | rendering | 773.474 ms | 13.631% | 51,482 styled lines |
| 4 | `render_plan_generation_all_lines` | rendering | 637.268 ms | 11.231% | 51,482 render plans |
| 5 | `selection_update` | editor update | 394.957 ms | 6.961% | `EditorState.updateSelection` |
| 6 | `typing_state_update` | interaction | 382.159 ms | 6.735% | `EditorState.updateSource` |
| 7 | `glyph_generation_full_document` | layout | 279.739 ms | 4.930% | full character range |
| 8 | `line_model_generation` | editor update | 217.457 ms | 3.832% | `EditorActiveLineTracker.lines` |
| 9 | `document_parse_editor_state_init` | document load | 208.638 ms | 3.677% | initial `EditorState` |
| 10 | `dirty_line_invalidation_click` | editor update | 204.958 ms | 3.612% | 0 dirty lines |
| 11 | `active_line_lookup` | editor update | 198.833 ms | 3.504% | midpoint cursor lookup |
| 12 | `render_update_typing_dirty` | interaction | 55.805 ms | 0.983% | 3 styled lines |
| 13 | `text_storage_initial_update` | layout | 14.411 ms | 0.254% | full source replace |
| 14 | `file_read` | document load | 3.651 ms | 0.064% | disk read |
| 15 | `layout_after_typing` | interaction | 0.903 ms | 0.016% | layout for inserted character |
| 16 | `text_storage_typing_insert` | interaction | 0.737 ms | 0.013% | one-character insertion |
| 17 | `document_model_init` | document load | 0.047 ms | 0.001% | `MarkdownDocument` |
| 18 | `scroll_latency_top_viewport` | interaction | 0.009 ms | 0.000% | cached glyph-range lookup |
| 19 | `scroll_latency_middle_viewport` | interaction | 0.009 ms | 0.000% | cached glyph-range lookup |
| 20 | `scroll_latency_deep_viewport` | interaction | 0.008 ms | 0.000% | cached glyph-range lookup |

## CRLF Fix Impact

Direct before/after comparison for the 5 MB benchmark:

| Measurement | Before CRLF fix | After CRLF fix | Change |
| --- | ---: | ---: | ---: |
| Editor lines | 1 | 51,482 | correctness restored |
| Rendered/source trace | 0 rendered, 1 source | 51,481 rendered, 1 source | hybrid mode restored |
| Measured total | 4,376.271 ms | 5,674.222 ms | +29.704% |
| Initial attributed styling | 903.494 ms | 773.474 ms | -14.390% |
| Dirty typing render | 887.213 ms | 55.805 ms | -93.710% |
| Full TextKit layout | 766.331 ms | 1,320.499 ms | +72.052% |
| Dirty invalidation typing | 566.852 ms | 980.651 ms | +72.997% |
| Layout after typing | 490.160 ms | 0.903 ms | -99.816% |
| Selection update | 136.422 ms | 394.957 ms | +189.516% |
| Typing state update | 136.721 ms | 382.159 ms | +179.516% |
| Render plan generation | 51.323 ms | 637.268 ms | +1,141.680% |

What improved:

- The source-only rendering failure disappeared.
- Dirty-line rendering now behaves as intended: the typing render touched 3 styled lines instead of one giant document-sized line.
- Post-typing TextKit layout dropped from 490.160 ms to 0.903 ms.
- Dirty typing render dropped from 887.213 ms to 55.805 ms.

What became more expensive:

- The benchmark now performs real per-line work for 51,482 editor lines. Render-plan generation, state updates, selection updates, and dirty invalidation are now measuring the actual line count instead of one accidental giant line.
- Full TextKit layout increased from 766.331 ms to 1,320.499 ms after the document became many real layout lines.

What bottlenecks remain:

- `EditorState.updateSelection` and `EditorState.updateSource` still rebuild the full line model.
- `EditorDirtyLineInvalidator.plan` still scans full previous/current strings and rebuilds current line boundaries.
- `MarkdownTextStyler.apply` still recomputes the full line list before applying dirty-line styling.
- Full-document render-plan generation is expensive when run across 51,482 lines.

Conclusion:

The CRLF bug caused the source-only display and pathological dirty render/layout behavior. Fixing it restored correctness and removed the giant-line typing/rendering failure. It also exposed the real remaining scalability issue: editor updates still perform document-wide line indexing and render planning work.

## What Consumes the Most CPU and Time?

Measured stage totals indicate the dominant costs are:

| Area | Representative operations | Time |
| --- | --- | ---: |
| Rendering/styling one giant line | initial attributed styling, dirty typing styling | 1,790.707 ms |
| TextKit layout | full layout, glyph generation, layout after typing | 1,414.037 ms |
| Whole-document editor scans | dirty invalidation, selection/source updates, active-line lookup, line generation | 1,103.496 ms |
| File/model load | file read, model init, state init | 68.442 ms |

Before the CRLF fix, the largest single cost was initial attributed styling of one giant line. After the fix, the largest single cost is full TextKit layout over the correctly segmented document.

After the CRLF fix, measured stage groups are:

| Area | Representative operations | Time |
| --- | --- | ---: |
| TextKit layout | full layout, glyph generation, layout after typing | 1,601.141 ms |
| Whole-document editor scans | dirty invalidation, selection/source updates, active-line lookup, line generation | 2,379.015 ms |
| Rendering/render planning | initial attributed styling, full render-plan generation, dirty typing styling | 1,466.547 ms |
| File/model load | file read, model init, state init | 212.336 ms |

## Interaction Paths

### Clicking

Observed measured path:

```mermaid
sequenceDiagram
    participant User
    participant TextView as NSTextView
    participant VM as HybridMarkdownEditorViewModel
    participant State as EditorState
    participant Dirty as EditorDirtyLineInvalidator

    User->>TextView: Click
    TextView->>VM: updateSelection
    VM->>State: updateSelection
    State->>State: lineIndex + lines over full source
    TextView->>Dirty: plan same source/active line
    Dirty-->>TextView: 0 dirty lines, render skipped
```

Measured operations for the 5 MB file after the CRLF fix:

- `selection_update`: 394.957 ms
- `dirty_line_invalidation_click`: 204.958 ms
- render update: skipped because no dirty lines were scheduled

Clicking is not dominated by Markdown rendering in this benchmark. It is dominated by whole-document source scans and line-model rebuilding.

### Typing

Observed measured path:

```mermaid
sequenceDiagram
    participant User
    participant TextStorage as NSTextStorage
    participant VM as ViewModel
    participant State as EditorState
    participant Dirty as Dirty invalidator
    participant Styler as MarkdownTextStyler
    participant Layout as NSLayoutManager

    User->>TextStorage: Insert one character
    TextStorage->>VM: updateSource
    VM->>State: updateSource
    State->>State: lineIndex + lines over full source
    VM->>Dirty: plan previous/current source
    Dirty->>Dirty: prefix/suffix diff over full source
    Dirty->>Styler: 1 dirty line
    Styler->>Styler: restyle the one giant line
    Styler->>Layout: invalidate layout
    Layout->>Layout: layout inserted character
```

Measured operations for the 5 MB file after the CRLF fix:

- `typing_state_update`: 382.159 ms
- `dirty_line_invalidation_typing`: 980.651 ms
- `render_update_typing_dirty`: 55.805 ms
- `text_storage_typing_insert`: 0.737 ms
- `layout_after_typing`: 0.903 ms

Typing is no longer dominated by dirty rendering of one giant line. After the fix, typing is dominated by dirty invalidation and state updates that still scan/rebuild full-document line data.

### Scrolling

After the CRLF fix, the harness measured cached viewport glyph-range lookup after full layout:

- top viewport: 0.009 ms
- middle viewport: 0.009 ms
- deep viewport: 0.008 ms

This does not reproduce the user's slow visible scrolling by itself. The measured expensive TextKit operations are:

- `layout_generation_full_document`: 1,320.499 ms
- `glyph_generation_full_document`: 279.739 ms
- `layout_after_typing`: 0.903 ms

Evidence supports this conclusion: cached glyph-range lookup is cheap after layout, but full layout generation remains expensive. A visible AppKit scroll-wheel trace is still needed to prove whether slow scrolling comes from repeated layout invalidation, drawing, or accessibility/selection work during live scrolling.

## Rendering Investigation

Rendered mode appeared absent before Milestone 2.7 because the line tracker emitted one editor line for the entire CRLF document.

Actual execution path:

```mermaid
flowchart TD
    File["5mb.md with CRLF endings"] --> SwiftString["Swift String"]
    SwiftString --> Tracker["EditorActiveLineTracker.lines"]
    Tracker --> Search["firstIndex(of: Character newline)"]
    Search --> OneLine["No Character(\\n) match inside CRLF grapheme clusters"]
    OneLine --> State["EditorState.lines = 1"]
    State --> Active["activeLineIndex = 0"]
    Active --> Modes["1 source line, 0 rendered lines"]
    Modes --> Styler["MarkdownTextStyler styles active source line"]
```

Evidence:

```text
CR=51481 LF=51481
"a\r\nb".firstIndex(of: "\n") == nil
"a\r\nb".split(separator: "\n").count == 1
(NSString("a\r\nb")).components(separatedBy: "\n").count == 2
```

There is no evidence of an explicit safety fallback, disabled rendering threshold, performance guard, or render-plan failure. The rendered mode was absent because the editor's line model contained no inactive lines.

After Milestone 2.7:

- `DocumentLineIndex` scans UTF-16 code units and treats LF, CRLF, and CR as explicit line endings.
- `EditorActiveLineTracker.lines(from:)` returns 51,482 editor lines for the benchmark document.
- The corrected rendered-mode trace is 51,481 rendered lines and 1 source line.

## Scalability Audit

| Operation | Trigger | Frequency | Complexity | Evidence / justification |
| --- | --- | ---: | --- | --- |
| `String(contentsOf:)` | document open | once | O(n) bytes | 4.954 ms for 5.5 MB |
| `EditorState.init` | document open | once | O(n) | builds `EditorState.lines`; 208.638 ms after fix |
| `EditorActiveLineTracker.lines` | open, source update, selection update, dirty plan, styler | repeated | O(n) | scans source to create ranges; 217.457 ms standalone after fix |
| `EditorActiveLineTracker.lineIndex` | cursor/selection/source updates | repeated | O(n) | scans line boundaries; 198.833 ms midpoint lookup after fix |
| `EditorState.updateSelection` | click/cursor movement | every selection change | O(n) | rebuilds `lines`; 394.957 ms after fix |
| `EditorState.updateSource` | typing | every source edit | O(n) | rebuilds `lines`; 382.159 ms after fix |
| Dirty diff prefix/suffix scan | source edit | every source edit | O(n) | `dirty_line_invalidation_typing` 980.651 ms after fix |
| Dirty line selection | source edit/click | every dirty plan | O(n) today | dirty plan first rebuilds current lines |
| Full render-plan generation | benchmark / future full render | on demand | O(line count + inline regex work) | 637.268 ms after fix |
| Initial attributed styling | initial render | once per view text load | O(n + line count) | 773.474 ms after fix |
| Dirty attributed styling | typing/active line change | per dirty render | O(n + dirty content) today | 55.805 ms after fix because styler still rebuilds line list before styling 3 lines |
| `NSTextStorage.replaceCharacters` full | text load | once | O(n) | 10.957 ms |
| `NSTextStorage.replaceCharacters` insert | typing | every edit | local TextKit edit | 0.711 ms |
| `NSLayoutManager.ensureGlyphs` full | initial layout | once / after invalidation | O(n) | 279.739 ms after fix |
| `NSLayoutManager.ensureLayout` full | initial layout | once / after invalidation | O(n layout fragments) | 1,320.499 ms after fix |
| `ensureLayout` for inserted char | typing | every edit | TextKit-dependent | 0.903 ms after fix |
| Viewport glyph range | scroll proxy after layout | per query | near O(1) after cached layout | 0.008-0.009 ms in harness |

No O(n^2) operation was proven by this benchmark. After the CRLF fix, the measured failure is multiple O(n) scans over 51,482 real editor lines plus full TextKit layout.

## Complexity Analysis

| Workflow | Current complexity | Evidence |
| --- | --- | --- |
| Document opening | O(n) read + O(n) editor line model + O(n) initial styling + O(n) layout | 3.651 ms read, 208.638 ms state init, 773.474 ms styling, 1,320.499 ms layout |
| Typing | O(n) state update + O(n) dirty diff + O(n) dirty styling setup + local TextKit layout | 382.159 ms + 980.651 ms + 55.805 ms + 0.903 ms |
| Clicking / cursor movement | O(n) selection update + O(n) dirty plan | 394.957 ms + 204.958 ms |
| Active-line changes | O(n) line lookup + O(n) line model rebuild + dirty render | measured through selection and dirty planning |
| Scrolling after cached layout | not proven O(n) by harness | viewport glyph-range lookup was near zero after full layout |
| Scrolling during invalidated layout | likely TextKit layout-bound, not isolated by this harness | full layout and post-typing layout are expensive; live scroll trace still required |

Any operation that calls `EditorActiveLineTracker.lines`, `EditorActiveLineTracker.lineIndex`, or `EditorDirtyLineInvalidator.plan` currently grows with total document size.

## Viewport Analysis

Sapling currently operates on the entire document for editor state and rendering inputs. It does not operate on visible lines plus a buffer.

```mermaid
flowchart TD
    Source["Full Markdown source"] --> State["EditorState.lines for full source"]
    Source --> Dirty["Dirty invalidator scans full previous/current source"]
    Source --> Styler["MarkdownTextStyler recomputes full line list"]
    Styler --> TextStorage["NSTextStorage dirty attributes"]
    TextStorage --> Layout["NSLayoutManager layout/glyph cache"]
    Layout --> Viewport["Visible viewport"]
```

| Subsystem | Entire document or viewport? | Evidence |
| --- | --- | --- |
| Active-line update | Entire document | `lineIndex(containing:in:)` scans line index; 198.833 ms |
| Selection update | Entire document | `updateSelection` rebuilds `lines`; 394.957 ms |
| Source update | Entire document | `updateSource` rebuilds `lines`; 382.159 ms |
| Dirty invalidation | Entire document | compares previous/current strings and rebuilds current lines; 980.651 ms |
| Render planning | Entire document in full benchmark; dirty line only for actual render plans after valid dirty plan | full render-plan generation measured separately |
| Attributed styling | Dirty line set, but line list recomputed from entire document | 55.805 ms for 3 styled lines |
| TextKit layout | TextKit-managed full layout/glyph cache | full layout 1,320.499 ms |
| Scroll lookup after layout | Viewport query against cached layout | 0.008-0.009 ms |

## Bottleneck Assessment

The bottleneck is not one subsystem alone.

Evidence ranks after the CRLF fix:

1. TextKit full layout is the largest single measured stage: 1,320.499 ms.
2. Dirty-line invalidation still does document-wide work: 980.651 ms.
3. Initial attributed styling across 51,482 lines remains expensive: 773.474 ms.
4. Full render-plan generation across 51,482 lines is expensive: 637.268 ms.
5. Active-line, selection, source update, and dirty invalidation still perform whole-document scans.

File I/O is not the bottleneck. `file_read` was 3.651 ms in the corrected benchmark.

## Milestone 2.8 Scalability Update

Milestone 2.8 added TextKit probes for cold viewport layout and line-fragment calculation. The corrected 5 MB benchmark still segments into 51,482 editor lines and schedules 51,481 rendered lines plus one active source line.

Release benchmark run on May 30, 2026:

| Rank | Operation | Category | Time | Percent | Evidence |
| ---: | --- | --- | ---: | ---: | --- |
| 1 | `layout_generation_full_document` | layout | 1,314.100 ms | 20.102% | `NSLayoutManager.ensureLayout` for the full text container |
| 2 | `dirty_line_invalidation_typing` | editor update | 1,019.380 ms | 15.593% | 3 dirty lines returned after full-document work |
| 3 | `cold_line_fragment_calculation_midpoint` | layout | 838.968 ms | 12.834% | midpoint `lineFragmentRect` before explicit full layout |
| 4 | `attributed_string_generation_initial` | rendering | 787.685 ms | 12.049% | 51,482 styled lines |
| 5 | `render_plan_generation_all_lines` | rendering | 611.781 ms | 9.358% | 51,482 render plans |
| 6 | `typing_state_update` | interaction | 416.540 ms | 6.372% | `EditorState.updateSource` after one-character insertion |
| 7 | `selection_update` | editor update | 398.262 ms | 6.092% | `EditorState.updateSelection` rebuilds line model |
| 8 | `dirty_line_invalidation_click` | editor update | 224.418 ms | 3.433% | 0 dirty lines, but current lines are rebuilt first |
| 9 | `document_parse_editor_state_init` | document load | 217.066 ms | 3.320% | initial `EditorState` |
| 10 | `line_model_generation` | editor update | 205.885 ms | 3.149% | `EditorActiveLineTracker.lines` over full source |
| 11 | `glyph_generation_full_document` | layout | 200.528 ms | 3.067% | full character range |
| 12 | `active_line_lookup` | editor update | 191.751 ms | 2.933% | midpoint cursor lookup |
| 13 | `render_update_typing_dirty` | interaction | 53.512 ms | 0.819% | 3 styled lines |
| 14 | `cold_viewport_glyph_range_middle` | layout | 38.597 ms | 0.590% | viewport query before explicit full layout |
| 15 | `text_storage_initial_update` | layout | 14.469 ms | 0.221% | full source replace |
| 16 | `file_read` | document load | 2.525 ms | 0.039% | disk read |
| 17 | `layout_after_typing` | interaction | 0.916 ms | 0.014% | layout for inserted character |
| 18 | `text_storage_typing_insert` | interaction | 0.779 ms | 0.012% | one-character insertion |
| 19 | `document_model_init` | document load | 0.035 ms | 0.001% | `MarkdownDocument` |
| 20 | `layout_invalidation_full_document` | layout | 0.013 ms | 0.000% | explicit full layout invalidation call |

Measured total: 6,537.243 ms.

The measured total increased relative to the Milestone 2.7 table because Milestone 2.8 added two cold TextKit probes. It should not be compared as an optimization regression.

### Scalability Map

| Subsystem | Trigger | Frequency | Complexity | Document dependency | Viewport dependency | Evidence |
| --- | --- | ---: | --- | --- | --- | --- |
| File read | document open | once | O(bytes) | yes | no | 2.525 ms |
| `DocumentLineIndex` construction | open, state update, dirty planning, styling | repeated | O(UTF-16 units + lines) | yes | no | `line_model_generation` 205.885 ms |
| Active-line lookup | cursor/source/selection changes | every relevant interaction | O(UTF-16 units + lines) today | yes | no | 191.751 ms |
| `EditorState.updateSelection` | click, cursor movement, selection changes | every selection change | O(document) | yes | no | 398.262 ms |
| `EditorState.updateSource` | typing | every source edit | O(document) | yes | no | 416.540 ms |
| Dirty invalidation | typing, active-line changes, view updates | every styling attempt | O(document) | yes | no | 1,019.380 ms typing; 224.418 ms click |
| Full render planning | initial/full render | on full plan | O(lines + inline parse work) | yes | no | 611.781 ms |
| Dirty render planning | dirty render | per dirty line after plan | O(dirty lines) after line lookup | partly | dirty set, not viewport | 3 styled lines, but 53.512 ms due full line-list setup |
| Initial attributed styling | initial render | once per load | O(document + lines) | yes | no | 787.685 ms |
| `NSTextStorage` full replace | text load | once | O(document) | yes | no | 14.469 ms |
| `NSTextStorage` edit | typing | every edit | local edit | no in benchmark | no | 0.779 ms |
| Full glyph generation | initial/full layout | on full layout | O(document) | yes | no | 200.528 ms |
| Full layout generation | initial/full layout | on full layout | O(layout fragments) | yes | no | 1,314.100 ms |
| Cold viewport glyph range | scroll/query before layout cache exists | as needed | O(layout to queried rect) | yes, by position | yes | 38.597 ms |
| Cold line fragment lookup | selection/layout query before layout cache exists | as needed | O(layout to queried character) | yes, by position | no | 838.968 ms |
| Cached viewport queries | scroll after layout cache exists | per query | near O(visible query) | no proven dependency | yes | below top-20 threshold for 5 MB |

### Performance Budget

| Interaction | Target | Current measured path | Result |
| --- | ---: | ---: | --- |
| Typing | <16 ms | 1,019.380 ms dirty invalidation + 416.540 ms source update + 53.512 ms dirty styling | fails |
| Cursor movement | <16 ms | 398.262 ms selection update + 224.418 ms dirty plan + 191.751 ms active-line lookup proxy | fails |
| Selection changes | <16 ms | same path as cursor movement | fails |
| Cached scroll query | <16 ms | below top-20 threshold after layout cache | passes in harness |
| Cold/deep layout query | <16 ms | 838.968 ms midpoint line fragment; 38.597 ms cold viewport | fails |
| Document open | documented, not 16 ms | full styling 787.685 ms + full layout 1,314.100 ms + full render planning 611.781 ms | multi-second |

### Document-Bound Operations

The following operations still scale with total document size:

| Operation | Why it exists | Necessary today? | Could become viewport-bound? |
| --- | --- | --- | --- |
| `EditorState.updateSelection` full line rebuild | Keeps active line and status bar line state coherent | yes with current state shape | yes, by caching line index and deriving active line without rebuilding all `EditorLine` values |
| `EditorState.updateSource` full line rebuild | Recomputes line ranges and active/source modes after edits | yes with current state shape | partly, by incrementally updating line index and only materializing affected lines |
| Dirty invalidator full prefix/suffix diff | Finds changed range without native edited range input | yes with current API | yes, by passing TextKit edited ranges into invalidation |
| Dirty invalidator current-line rebuild | Maps changed range to dirty line indexes | yes with current API | yes, by querying cached line boundaries |
| `MarkdownTextStyler.apply` full line-list setup | Converts text storage string to `EditorLine` values before applying dirty attributes | yes with current API | yes, by resolving only dirty line ranges from cached line index |
| Full render-plan generation | Creates a render plan for every line in full render scenarios | yes for current initial full render | yes for interaction paths; initial full render may remain document-bound unless viewport rendering is introduced |
| Initial attributed styling | Applies base attributes and hybrid styling to all lines | yes today | yes, with viewport-bounded styling plus background/full-export handling |
| Full TextKit layout | Calculates layout fragments across the text container | not always necessary for interaction | partly, by avoiding forced full layout and validating viewport/deep-scroll behavior |

### TextKit Findings

TextKit is a real bottleneck for full-document and cold/deep layout work:

- `layout_generation_full_document`: 1,314.100 ms
- `cold_line_fragment_calculation_midpoint`: 838.968 ms
- `glyph_generation_full_document`: 200.528 ms
- `cold_viewport_glyph_range_middle`: 38.597 ms

TextKit is not the measured bottleneck for local typing once layout is cached:

- `text_storage_typing_insert`: 0.779 ms
- `layout_after_typing`: 0.916 ms

Conclusion: TextKit dominates opening and cold layout-to-position work. Sapling's own full-document scans dominate typing, cursor movement, and selection changes.

### Viewport Assessment

Sapling is not currently viewport-bound for editor state, dirty invalidation, render-plan setup, or styling setup. It becomes viewport-like only after TextKit has a layout cache and the benchmark asks for a visible glyph range.

```mermaid
flowchart TD
    Text["Full source"] --> State["EditorState lines"]
    Text --> Dirty["Dirty invalidator"]
    Text --> Styler["MarkdownTextStyler line setup"]
    State --> Render["Render planning"]
    Styler --> TextKit["TextStorage attributes"]
    TextKit --> Layout["TextKit layout cache"]
    Layout --> Viewport["Cached viewport glyph query"]
```

The core scalability question has this answer:

- Typing, cursor movement, selection changes, active-line lookup, dirty invalidation, initial styling, and full render planning scale with total document size.
- Cached viewport glyph lookup scales with the viewport after layout exists.
- Cold/deep TextKit layout queries scale with layout work required to reach the queried position.

## Incremental Line Index Results

Milestone 2.9 implemented a persistent incremental `DocumentLineIndex` and migrated active-line lookup, selection updates, dirty invalidation, and dirty styling setup onto it. The native text adapters now capture edited ranges and replacement text, then pass `DocumentLineIndexEdit` into editor state and dirty invalidation.

This was an optimization milestone only. It did not replace NSTextView, redesign the editor, or add Markdown rendering features.

### Architecture

```mermaid
flowchart TD
    Edit["Native edited range + replacement"] --> Index["Persistent DocumentLineIndex"]
    Index --> Active["Active-line lookup"]
    Index --> Selection["Selection mapping"]
    Index --> Dirty["Dirty-line invalidation"]
    Dirty --> Styling["Dirty-line styling"]
    Styling --> TextStorage["NSTextStorage attributes"]
```

The incremental update strategy is:

1. Capture the native edit range and replacement string.
2. Expand the affected scan window to adjacent line boundaries so CRLF, LF, CR, and mixed endings remain correct.
3. Rescan only the local affected window.
4. Replace that boundary slice in the cached index.
5. Use the cached index for active-line lookup, dirty-line lookup, and dirty styling.

Programmatic full-source replacement still rebuilds the index. User text edits use the incremental path.

### Benchmark Run

Release benchmark command:

```sh
swift run -c release SaplingEditorBenchmark
```

Run date: May 30, 2026.

| Scenario | Lines | Measured total |
| --- | ---: | ---: |
| Sample document | 54 | 9.451 ms |
| 2,100-line prototype | 2,101 | 290.723 ms |
| 5 MB benchmark | 51,482 | 3,817.507 ms |

Tracked interaction metrics after Milestone 2.9:

| Scenario | Active-line lookup | Selection update | Dirty click invalidation | Typing state update | Dirty typing invalidation | Dirty typing render |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| Sample document | 0.000 ms | 0.000 ms | 0.000 ms | 0.002 ms | 0.004 ms | 0.119 ms |
| 2,100-line prototype | 0.000 ms | 0.001 ms | 0.001 ms | 0.012 ms | 0.003 ms | 0.211 ms |
| 5 MB benchmark | 0.001 ms | 0.002 ms | 0.001 ms | 0.102 ms | 0.003 ms | 0.796 ms |

### 5 MB Before / After

Before values are the Milestone 2.8 corrected-CRLF baseline. After values are the Milestone 2.9 release benchmark.

| Measurement | Before | After | Improvement |
| --- | ---: | ---: | ---: |
| `active_line_lookup` | 191.751 ms | 0.001 ms | 99.999% |
| `selection_update` | 398.262 ms | 0.002 ms | 99.999% |
| `dirty_line_invalidation_click` | 224.418 ms | 0.001 ms | 99.999% |
| `typing_state_update` | 416.540 ms | 0.102 ms | 99.976% |
| `dirty_line_invalidation_typing` | 1,019.380 ms | 0.003 ms | 99.999% |
| `render_update_typing_dirty` | 53.512 ms | 0.796 ms | 98.512% |
| Measured total | 6,537.243 ms | 3,817.507 ms | 41.601% |

The measured total includes synthetic full-layout and cold-layout probes. The interaction-specific improvement is larger than the total improvement because full-document open/layout/render probes still dominate the benchmark.

### Complexity Review

| Subsystem | Before | After | Evidence |
| --- | --- | --- | --- |
| Active-line lookup | O(document) scan | O(log line count) binary lookup | 191.751 ms -> 0.001 ms |
| Selection mapping | O(document) line rebuild | O(log line count) lookup | 398.262 ms -> 0.002 ms |
| Dirty invalidation click | O(document) current-line rebuild | O(1) when source and active line are unchanged | 224.418 ms -> 0.001 ms |
| Dirty invalidation typing | O(document) prefix/suffix diff + line rebuild | O(log line count + affected lines) | 1,019.380 ms -> 0.003 ms |
| Dirty styling setup | O(document) line-list rebuild before styling dirty lines | O(dirty lines) | 53.512 ms -> 0.796 ms |
| Programmatic full source replacement | O(document) | O(document) fallback | unchanged by design |
| Initial render planning | O(line count) | O(line count) | still 578.083 ms |
| Initial attributed styling | O(line count) | O(line count) | still 689.766 ms |
| TextKit full layout | O(layout fragments) | O(layout fragments) | still 1,201.918 ms |

### Remaining Bottlenecks

After Milestone 2.9, the top 5 measured operations for the 5 MB benchmark are:

| Rank | Operation | Time |
| ---: | --- | ---: |
| 1 | `layout_generation_full_document` | 1,201.918 ms |
| 2 | `cold_line_fragment_calculation_midpoint` | 745.530 ms |
| 3 | `attributed_string_generation_initial` | 689.766 ms |
| 4 | `render_plan_generation_all_lines` | 578.083 ms |
| 5 | `line_model_generation` | 190.920 ms |

Validation answer: TextKit is now the dominant measured bottleneck for full-document layout and cold layout-to-position work. It is not the only remaining bottleneck: initial attributed styling and full render-plan generation are still substantial Sapling-side document-wide costs during open/full render paths. For core typing, cursor movement, selection updates, and dirty invalidation, the previous Sapling-side document-wide bottlenecks are no longer dominant in the benchmark.

## Recommendations For Future Work

These are recommendations from evidence, not implemented changes:

1. Cache and incrementally update `DocumentLineIndex` inside editor state.
2. Change dirty invalidation to use native edited ranges instead of prefix/suffix diffing full strings on every edit.
3. Change `MarkdownTextStyler.apply` so dirty rendering does not recompute the full line list before styling dirty ranges.
4. Cache render plans by line identity and invalidate only affected lines.
5. Add a visible AppKit scroll benchmark or Time Profiler trace to isolate live scroll-wheel latency after correct CRLF handling.
6. Defer viewport-only styling and TextKit layout strategy changes until document-bound editor-side scans are removed.
7. Keep `DocumentLineIndex` as the single source for editor line segmentation; future editor systems should not search raw Swift `String` values for newline characters.

## Reproduction Commands

```sh
perl -0ne '$cr = tr/\r//; $lf = tr/\n//; print "CR=$cr LF=$lf\n"' Docs/Benchmarks/5mb.md
swift -e 'import Foundation; let s = "a\r\nb"; print(s.firstIndex(of: "\n") as Any); print(s.split(separator: "\n", omittingEmptySubsequences: false).count); print((s as NSString).components(separatedBy: "\n").count)'
swift run -c release SaplingEditorBenchmark Docs/Benchmarks/5mb.md
```
