Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
18 KiB
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
checkpointsdifference and none was). - Field comparisons counts
(scenario, field, host-pair)triples:fields × 3 pairsper 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)
Telegram has no single
NSTextStorage.DocumentCanvasViewis 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.inputDecthe full field set — an asymmetry between host kinds, not a regression.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
UITextViewbehavior 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.
nil == nilis agreement.IDCompareTextInputSnapshotPairdecides equality withleftValue == rightValue || [leftValue isEqual:rightValue]oversnapshot.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.- 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.
storageMutationTraceandtraitsare in 3 of the 4 families' contracts, so passingscenario.comparisonFieldsverbatim fails at snapshot 0 ontraitsand hides the rest —test_theUnfilteredFieldListFailsAtTheFirstSnapshotOnADeclinedFieldpins exactly that. Consequence for this run: the family tests call the comparator once per field, with a single-elementcomparisonFieldslist, so one diverging field cannot hide the others. Within a field, only the first diverging checkpoint is reported. MIN(left.count, right.count)bounds the snapshot walk; a count mismatch is reported afterwards as a syntheticcheckpointsdifference. (None occurred: nocheckpointsdifference was reported for any scenario, so all three arms produced the same snapshot count throughout.)diagnosticFieldsare skipped outright —behaviorCapabilitiesandbehaviorDetachedare never compared, whatever a scenario's contract says.- Geometry tolerance applies to
caretRectandselectionRectsonly, 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
IDTextInputHostKindMinimalis 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 theMinimalslot and the check becomes a real one.- 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.
annotationRangesis never requested. No scenario in the corpus names it (measured over all 68), socomparesAnnotationsis NO throughout and the recorder's annotation path is written but unexercised — a capability claim, not a result.- The
selectionaxis is MAPPED, not compared raw. The editor'sUITextInputposition axis is structural (it counts the non-renderable token slots between blocks), so it diverges from stock's character axis past every paragraph boundary.IDTelegramCharacterAxisconverts both arms onto the character axis before anything is reported, with no residue — soselectionandmarkedRangecompare exactly, and there is deliberately NO expectation entry excusing them. (There could not usefully be one:selectionis 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.) - 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
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:
find ~/Library/Developer/Xcode/DerivedData/ModuleCache.noindex \
-name 'RichTextEditorDifferentialHarness*.pcm' -delete