Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

277 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The differential-corpus baseline — Phase 0b, Task 9d
**Measured 2026-08-24** on this branch (`richtext/input-backend-seam`), against the **legacy**
input backend. Stage 2's Stage D re-runs this corpus and compares against exactly these numbers, so
every figure below is stated as a count, not as prose.
The oracle drives the vendored InputDec corpus through two arms — a stock `UITextView` and the
Telegram editor — and diffs their snapshots with the vendored comparator. What it is for is finding
places where the editor's `UITextInput` behaviour differs from the platform's. It found nine such
places. Eight are characterised below and named in `TelegramDifferentialExpectations`; the ninth is
an **open editor defect** and is why one of the four family tests is RED.
---
## 1. The pinned pair
| | |
| --- | --- |
| Simulator device | **`iPhone 17 Pro K1`** — `CA0A2186-0F4A-425B-B3B1-9B61E5FF01A9` (the device `Scripts/iostest.sh` defaults to) |
| Simulator runtime | **iOS 26.5, build `23F73`** |
| Runtime identifier | `com.apple.CoreSimulator.SimRuntime.iOS-26-5` — **shared with the beta `23F5069b`, which is also installed on this host** |
| Engine | both — the suite was run on TK2 and on TK1 (`TK1=1`) and reports identically |
**The identifier does not pin the build, and that is the whole problem.** `simctl list devices`
groups both 26.5 runtimes under one bare `-- iOS 26.5 --` heading, and a device's `device.plist`
records only the identifier (verified for K1). From outside the process there is no way to tell which
of the two a device will boot; the booted runtime root
(`/Library/Developer/CoreSimulator/Volumes/iOS_23F73`) is visible only from the running process's own
open file handles. A run on the beta would decode the corpus happily and report **stock UIKit's own**
behaviour changes as Telegram's.
So `LegacyDifferentialTests` asserts the build **from inside the test process**, and **fails loudly
with expected-vs-actual rather than skipping** — a skipped differential run reads as a pass in every
summary that matters. The expected build is parsed out of the corpus's own
`provenance.targetRuntime`, so a re-captured corpus moves the assertion with it.
### Reading the build from inside the simulator: three of the four obvious sources are wrong
Measured 2026-08-24, all four side by side in one process:
| Source | Value | Verdict |
| --- | --- | --- |
| `ProcessInfo.processInfo.operatingSystemVersionString` | `Version 26.5 (Build 23F73)` | **correct** — Foundation resolves the runtime's `SystemVersion.plist` under `$SIMULATOR_ROOT` |
| `$SIMULATOR_ROOT/System/Library/CoreServices/SystemVersion.plist` → `ProductBuildVersion` | `23F73` | correct — used as the independent cross-check |
| `sysctlbyname("kern.osversion")` | `25G76` | **WRONG — the host Mac's kernel build** |
| `/System/Library/CoreServices/SystemVersion.plist` → `ProductBuildVersion` | `25G76` | **WRONG — the host Mac.** An absolute path is not redirected into the runtime |
| `SIMULATOR_RUNTIME_VERSION` (env) | `26.5` | a version, never a build |
`kern.osversion` is what the plan's Step 3b suggested; it is right on a device and wrong in a
simulator, which runs on the host's kernel and in the host's filesystem namespace. `25G76` is a
perfectly plausible-looking build string — adopting either wrong source and then "fixing" the
resulting permanent failure by comparing against `25G76` would yield an assertion that passes on
**both** iOS 26.5 runtimes and therefore certifies nothing. That is the exact vacuity Step 3b exists
to prevent, reached by an entirely reasonable-looking route.
### The corpus's provenance is not one string (Task 9a's finding, re-confirmed)
`provenance.targetRuntime` has **two spellings**, split exactly on the family boundary:
* all **14** inline-formatting scenarios: `iOS 26.5 (23F73), K3`
* the other **54** scenarios: `iOS 26.5 (23F73)`
Both are pinned EXACTLY by the vendored decoder — not prefix-matched — and this is the only place the
corpus names a capture **device**. Recorded here so a future re-capture does not silently normalise
one spelling into the other. The test parses the parenthesised build, which tolerates both without
flattening either.
---
## 2. Per-family counts
Scenario and transaction counts are the corpus's own (decoded through the vendored
`IDLoadTextInputScenarios`); everything right of them was measured by this run.
| Family | Scenarios | Transactions | Checkpoints | Contract fields | Compared fields | Field comparisons | Execution failures | Differences | Explained | **Unexplained** |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| ime | 24 | 256 | 100 | 14 | 12 | 864 | 0 | 240 | 240 | **0** |
| autocorrection | 21 | 177 | 51 | 14 | 12 | 756 | 0 | 144 | 144 | **0** |
| inline-formatting | 14 | 98 | 56 | 7 | 7 | 294 | 0 | 18 | 18 | **0** |
| inline-prediction | 9 | 81 | 30 | 14 | 12 | 324 | 0 | 108 | 72 | **36** |
| **total** | **68** | **612** | **237** | — | — | **2238** | **0** | **510** | **474** | **36** |
* *Checkpoints* is the stock arm's snapshot count (all three arms produced the same count for every
scenario — a mismatch would have been reported as a `checkpoints` difference and none was).
* *Field comparisons* counts `(scenario, field, host-pair)` triples: `fields × 3 pairs` per scenario.
* *Differences* counts the same triples. The vendored comparator **returns on the first inequality**,
so at most one difference is reported per triple and only the FIRST diverging checkpoint of it —
these are counts of diverging (scenario, field, pair) triples, not of disagreeing values.
* **Every difference in the table is a stock-vs-Telegram one. The reference-vs-minimal pair agreed on
every field of every scenario** — 0 of 510. That is the determinism / execution-order result (see
the caveat below on what it does and does not mean).
### Per-difference-class counts
| Class (the `TelegramDifferentialExpectations` entry name) | ime | autocorr | inline-fmt | inline-pred | total |
| --- | ---: | ---: | ---: | ---: | ---: |
| `caret-origin-differs-because-the-arms-use-different-text-metrics` | 48 | 42 | – | 18 | 108 |
| `editor-returns-no-selection-rects-for-a-collapsed-range` | 48 | 42 | – | 18 | 108 |
| `editor-notifies-the-input-delegate-where-stock-stays-silent` | 48 | 42 | – | 18 | 108 |
| `editor-registers-no-undo-step-for-an-uncommitted-composition` | 40 | – | – | 18 | 58 |
| `editor-does-not-model-selection-affinity` | 48 | – | – | – | 48 |
| `undo-restores-the-pre-edit-caret-where-stock-re-selects-the-restored-range` | – | 18 | – | – | 18 |
| `editor-has-no-pending-caret-format` | – | – | 18 | – | 18 |
| `editor-finalizes-a-live-composition-when-it-resigns-first-responder` | 8 | – | – | – | 8 |
| **UNEXPLAINED — `canonical` + `blocks`** | – | – | – | **36** | **36** |
Each entry's full reason is at its declaration in
`Tests/RichTextEditorUIKitTests/Differential/TelegramDifferentialExpectations.swift`. **"Explained"
means the difference is understood and attributable — not that it is desirable.** Three of the eight
are flagged at their declaration as candidate defects worth a separate look:
`editor-returns-no-selection-rects-for-a-collapsed-range`,
`editor-notifies-the-input-delegate-where-stock-stays-silent`, and
`editor-registers-no-undo-step-for-an-uncommitted-composition`.
---
## 3. THE OPEN FINDING — marked-text replace over-deletes by the composition's length
**All 9 inline-prediction scenarios diverge on `canonical` and `blocks`. This is an editor defect,
not a harness artifact, and `test_inlinePredictionFamilyMatchesStock` is RED because of it.**
Minimal reproduction, isolated 2026-08-24 (both arms driven by identical calls; the harness's aim was
verified to round-trip exactly on both, `aim {33,2} → textInRange "to" → roundTrip {33,2}`):
```
document "AB \nXYZ" (caret at character 3, between the two spaces)
setMarkedText "to" selectedRange {0, 0} -> both arms: "AB to \nXYZ", markedRange {3,2}
replace {3, 2} with "to "
stock -> "AB to \nXYZ" (replaced exactly the 2 marked characters)
editor -> "AB to XYZ" (replaced 4 — it consumed 2 further characters, including the "\n")
```
The over-deletion is **exactly the marked text's length**, and it is governed by the composition
caret, not by block structure:
| Variation | Result |
| --- | --- |
| `selectedRange {0, 0}` (caret at the START of the composition) | **diverges** |
| `selectedRange {2, 0}` (caret at the END of the composition) | agrees |
| replacement `""`, `"to"`, `"to "` | diverges identically — the replacement text is irrelevant |
| range far from any paragraph boundary (`"ABCDEFGH IJ\nXYZ"`) | **diverges** — so it is not about boundaries |
| no marked text at all | agrees |
That last row is why the whole IME family passes on these fields while the whole inline-prediction
family fails: **IME compositions put the caret at the END of the marked text (`selectedRange`
`{1,0}`/`{2,0}`) and inline-prediction puts it at offset 0** — which is the correct idiom for a
suggestion shown *after* the caret. The corpus separates the two cases by construction, and that is
what made the defect visible.
Where to look: `LegacyRichTextInputBackend+Insertion.swift:64` (`replace(_:withText:)`) →
`DocumentCanvasView+UITextInput.swift:105` (`legacyReplace`) → `editing { applySelectionReplaceOutcome(…) }`.
`performEditing` opens with `finalizeMarkedText()`, so the composition is COMMITTED before the
mutation runs, and the `lo`/`hi` computed from the incoming range are consumed afterwards. A commit
that moves text by the composition's length while the caller holds pre-commit offsets fits the
measured signature exactly, but this task did not confirm the line and **fixing it is not Phase 0b's
job**. It is recorded here, and the family test stays red until it is fixed or a decision is recorded
against it.
**Do not close this by widening an expectation.** The difference is a wrong document, produced by a
sequence a real inline-prediction keyboard performs.
---
## 4. The two limitations (verbatim from the Phase 0b preamble)
> 1. **Telegram has no single `NSTextStorage`.** `DocumentCanvasView` is block-structured with
> per-leaf-region layout, so the storage-dependent comparison fields (`blocks`, `inlineRuns`) are
> declined under the legacy backend. Stage 2's storage façade gives `.inputDec` the full field set
> — an asymmetry between host kinds, not a regression.
>
> 2. **The corpus's provenance is iOS 26.5 / 23F73.** The runner re-derives stock behavior live rather
> than comparing against goldens, so it does not go stale the way a golden corpus would — but stock
> `UITextView` behavior *does* change across OS versions, and those changes surface as differences
> that are not Telegram's fault. Pin the simulator OS and record the pinned pair.
**How limitation 1 actually came out, which is better than it predicted.** Task 9b took option (a) —
`observedTextStorage` IS populated, as a validated read-only pull projection
(`IDTelegramProjectedTextStorage`) — so **neither `blocks` nor `inlineRuns` is declined**. Both are
compared for every scenario whose contract names them. What *is* declined is a different, smaller
pair, and for a different reason:
| Declined field | Why (short form; the full reason is `IDTelegramDeclinedComparisonFieldReasons()`) |
| --- | --- |
| `storageMutationTrace` | The projection's `-refresh` swaps its backing outright, so it can never post `NSTextStorageDidProcessEditingNotification`. A trace built from our own refresh calls would describe *when the recorder sampled*, not when the editor edited. |
| `traits` | The contract's census is all **ten** `UITextInputTraits` members; the canvas implements **six** (all ten are `@optional`; `UIView` implements none). The ten-key dictionary cannot be formed, and a six-key one is a different measurement wearing the same name. What can be read is published under `telegramReadableTraits`, and the construction-time apply-and-read-back census under `telegramUnappliedTraits`. |
Cost of the declines, measured: the 14 inline-formatting scenarios lose **nothing** (neither field is
in their contract); the other 54 lose **2 of 14**. All 68 still compare `canonical`, `blocks`,
`selection`, `canUndo`, `canRedo`.
Limitation 2 is what §1 is about.
---
## 5. Comparator behaviours a reader must know (catalogued by Task 9c, confirmed here)
These are properties of the **vendored** `IDTextInputDifferentialRunner.m`. None is a defect; all
four change what a green result means, and a later reader who rediscovers them as bugs will waste a
day.
1. **`nil == nil` is agreement.** `IDCompareTextInputSnapshotPair` decides equality with
`leftValue == rightValue || [leftValue isEqual:rightValue]` over `snapshot.state[field]`, so a
field absent from BOTH sides reports agreement **on something nobody measured**. This is why a
declined field must be *removed from the comparison list* (`IDTelegramComparableFields()`), not
merely omitted from the snapshot. "Absent, never defaulted" prevents a false *value*; it does not
prevent a false *agreement*.
2. **It returns on the FIRST inequality, scanning snapshot indices outermost.** One
permanently-differing field at snapshot 0 masks every later snapshot AND every later field of that
scenario. `storageMutationTrace` and `traits` are in 3 of the 4 families' contracts, so passing
`scenario.comparisonFields` verbatim fails at snapshot 0 on `traits` and hides the rest —
`test_theUnfilteredFieldListFailsAtTheFirstSnapshotOnADeclinedField` pins exactly that.
**Consequence for this run:** the family tests call the comparator **once per field**, with a
single-element `comparisonFields` list, so one diverging field cannot hide the others. Within a
field, only the first diverging checkpoint is reported.
3. **`MIN(left.count, right.count)`** bounds the snapshot walk; a count mismatch is reported
afterwards as a synthetic `checkpoints` difference. (None occurred: no `checkpoints` difference
was reported for any scenario, so all three arms produced the same snapshot count throughout.)
4. **`diagnosticFields` are skipped outright** — `behaviorCapabilities` and `behaviorDetached` are
never compared, whatever a scenario's contract says.
5. **Geometry tolerance applies to `caretRect` and `selectionRects` only**, recursively through
dictionaries and arrays, and requires identical key sets. The corpus sets it to **1.0** for ime /
autocorrection / inline-prediction and **0** for inline-formatting (which compares no geometry).
---
## 6. Caveats — what an AGREEMENT here does not mean
1. **`IDTextInputHostKindMinimal` is not a second backend.** The vendored enum has three cases and
the vendored runner executes all three; Telegram has ONE input implementation, so the factory
builds a fresh, independently constructed editor for both non-stock kinds. The reference-vs-minimal
pair is a **determinism / execution-order check** — the runner drives the two at different points
of its order, so a difference there means state leaked across instances or the result depended on
when it ran. The measured 0-of-510 is a real and useful result *as that*. Reading it as "two
backends agree" would be exactly the false comfort this phase exists to prevent. When a second
Telegram input backend exists it takes the `Minimal` slot and the check becomes a real one.
2. **The oracle is independent of the EDITOR, not of US.** Only the corpus and two of the five
Objective-C pairs are vendored; host construction, state sampling and transaction driving are
Telegram-side code (the reshaped Phase 0b's accepted trade). The corpus — 68 scenarios / 612
transactions of real IME, autocorrection, inline-prediction and inline-formatting sequences
captured against stock UIKit — is the genuinely independent asset.
3. **`annotationRanges` is never requested.** No scenario in the corpus names it (measured over all
68), so `comparesAnnotations` is NO throughout and the recorder's annotation path is written but
unexercised — a capability claim, not a result.
4. **The `selection` axis is MAPPED, not compared raw.** The editor's `UITextInput` position axis is
structural (it counts the non-renderable token slots between blocks), so it diverges from stock's
character axis past every paragraph boundary. `IDTelegramCharacterAxis` converts both arms onto the
character axis before anything is reported, with **no residue** — so `selection` and `markedRange`
compare exactly, and there is deliberately NO expectation entry excusing them. (There could not
usefully be one: `selection` is a comparison field of all 68 scenarios and 58 of the 68 touch a
paragraph boundary, so an entry would have excused ~85% of the corpus on a field every scenario
compares.)
5. **Only one execution order was run** (`Stock, Reference, Minimal`). The vendored runner also
supports the reverse; it is not part of this baseline.
---
## 7. Reproducing this
```sh
cd submodules/TelegramUI/Components/RichTextEditor
Scripts/iostest.sh RichTextEditorUIKitTests/LegacyDifferentialTests # TextKit 2
TK1=1 Scripts/iostest.sh RichTextEditorUIKitTests/LegacyDifferentialTests # TextKit 1
```
Expected today: **6 tests, 1 failure** — `test_inlinePredictionFamilyMatchesStock`, on the 36
unexplained `canonical`/`blocks` differences of §3. Identical on both engines. Every count in §2 is
printed by the run itself, one `=== DIFFERENTIAL <family>` block per family.
If a new header is added under `Tests/DifferentialSupport/include/`, Swift will report
`cannot find … in scope` while the `.m` compiles without a diagnostic — the umbrella module's
prebuilt `.pcm` is not invalidated when a header appears in the umbrella *directory*:
```sh
find ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex \
-name 'RichTextEditorDifferentialHarness*.pcm' -delete
```