prism branch T-1966/bugfix-…-quadratic-scans commits 2 (PR #343) files 7 touched (1 production) lines +813 / -51 tests run 111 targeted + 4 000-sample corpus, 0 failed lint 0 violations / 513 files

Pre-push review: T-1966 — quadratic source-map scans in InlineHTMLRenderer

Memoised absence proofs in InlineHTMLRenderer.Walker.locate turn the entity/escape family from O(N²) into O(N). Worst measured shape 12.4 s → 57 ms, with output equivalence pinned by a committed 4 000-sample digest corpus and exact pre-fix run coordinates.

At a glance

  • One production file changed. InlineHTMLRenderer.swift gains two exact rejections in locate plus an at-cursor fast path; everything else in the diff is tests and docs.
  • Soundness independently verified. All four cursor write sites are monotone-forward; the memo's precondition is now enforced by locate's signature (no from: parameter) rather than by a comment.
  • Equivalence is empirical, not argued. 4 000 seeded samples digested over html + every run coordinate + badgeSourceStarts, pinned to pre-fix values; verified here across all 8 chunks.
  • O7's goldens hand-check out. I re-derived all three fixtures' (sourceStart, length) pairs from the source layout by hand and they match exactly, badges included.
  • One recorded gap. The 256-entry cap lets a crafted paragraph (256 distinct decoy absent texts, then a repeated one) bypass the memo and stay quadratic — same class as the accepted T-2034 residual, but a shape neither the CHANGELOG nor the ticket names.

Verdict

Ready to push

The change is correct and the evidence behind it is unusually strong for a performance fix. I re-derived the soundness argument independently rather than taking the commit message's word for it: provedAbsent is only sound if cursor is monotonically non-decreasing, and I checked every one of the four sites that writes cursor (appendVisible, claimOccurrences, appendFootnoteBadge, advanceCursorPastNewline) — all forward, with the badge site forward only via the pre-badge searchLimit bound, exactly as the doc comment now states. Both rejections are exact, so no output can move; the at-cursor fast path is trivially equivalent to the old loop's first iteration. Index safety for the new matches(_:at:) follows from the retained limit >= cursor guard, which also subsumes the removed from <= count guard.

I independently hand-derived all three O7 golden fixtures from the source layout and they are exactly right, including the non-zero-length runs that a collapsed source map would destroy. The full 8-chunk corpus passes (0.61 s vs 0.10 s for one chunk, confirming TEST_RUNNER_PRISM_INLINE_CORPUS_FULL=1 really reaches the runner). 111 targeted tests pass, both platform builds succeed, lint is clean.

One finding is worth recording before push, but it is a docs/ticket accuracy gap rather than a defect: the 256-entry cap on provedAbsent re-opens the fixed quadratic for a crafted input that neither the CHANGELOG nor T-2034 currently describes. That should go on T-2034 as a comment; it does not need to hold the push.

Review findings

6 raised · 0 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What changed

When Prism shows a markdown file, it has to remember which part of the file each word on screen came from. That is what lets you select some text and pin a note to it. It works by scanning forward through the file looking for the words it just drew.

Some words are not written in the file the way they appear. &#65; in the file shows up as A on screen. So when the app went looking for that A, it scanned the whole rest of the paragraph, found nothing, gave up — and then did the exact same fruitless scan again for the next word, and again for the one after that.

Why it matters

A paragraph with 3 200 such words took 12.4 seconds to render. Because Prism can open a document from a URL, someone else's file could keep your device busy for a long time. It now takes 57 milliseconds.

The idea

Remember the failures. If a full scan of the file proves the word A is not in there anywhere from here on, write that down — the next time the same word comes up, answer from the note instead of scanning again. A second, cheaper note records which individual characters the file contains at all: a word containing a character the file never uses cannot possibly be in the file, so it is rejected instantly.

Key concepts

  • Memoisation — caching the answer to a question you already answered.
  • Quadratic vs linear — doubling the input made this four times slower, not twice. That is what makes it a denial-of-service shape rather than merely a slow path.
  • Nothing on screen changes. The fix only skips repeated work; the text, the footnote badges, and where notes attach are all identical.

Architecture

InlineHTMLRenderer.Walker walks swift-markdown's inline AST, emitting HTML while tracking a UTF-16 cursor into the block's source. Every mapped text node calls locate(units), a naive forward substring scan, to find where the rendered text sits in the source; the match becomes a DocumentSourceMap.Run that selection-anchored notes resolve against.

Two earlier fixes bounded specific calls: PR #326 bounds the segment immediately before a footnote badge by the occurrence's start, and T-1992 bounds claimOccurrences by the next owed occurrence. appendVisible's general case cannot be bounded — text legitimately sits far ahead of the cursor whenever the walk skips source it does not account for (a long image src, a raw-HTML span). So it stayed unbounded and unmemoised.

The pattern

locate is a pure function of (source, units, cursor, bound). A failed unbounded scan therefore proves something permanent: units occurs nowhere in [cursor, sourceEnd). Because the cursor is monotonically non-decreasing, every later search runs over a subrange of that already-cleared range — so the proof still holds. Two records capture it:

  • provedAbsent: Set<[UInt16]> — texts proved absent, capped at 256 entries.
  • sourceUnits: UnitSet? — a lazily built 1 024-word bitset over the 16-bit UTF-16 unit domain. A text containing a unit the source lacks cannot occur at any position. This is what makes the whole entity family O(text) instead of O(block).

Both are written only after a scan that reached the true end of the source (end == sourceUTF16.count), so a miss caused by a too-small window records nothing — otherwise a bounded miss would silently suppress a later unbounded match, which is a wrong answer rather than a slow one.

Trade-offs

The team rejected four alternatives: advancing the cursor on failure (does not help — cost is set by where a scan ends), a fixed forward window (drops legitimate far-ahead mappings), a per-block scan budget (changes output), and a suffix automaton / k-gram index (O(block) build cost on every block of every render to close a residual an attacker has to work at). The chosen approach changes no output at all, which is what makes it safe to land beside T-1992 and T-1941.

Testing

A committed 4 000-sample seeded corpus (InlineRenderCorpusEquivalenceTests) digests html + every run's (runID, sourceStart, length) + badgeSourceStarts, pinned to values taken from the branch point before the fix. GrowthRatioGuard is a shared ratio assertion (N vs 4N, 8× ceiling) hoisted out of FootnoteBadgeGrowthTests as T-1951 recommended.

Soundness, verified independently

I did not accept the commit message's monotonicity claim on trust. cursor has exactly four write sites and I checked each:

  • appendVisible (L259): cursor = start + units.count, and locate returns an index ≥ cursor. Forward.
  • claimOccurrences (L546): cursor = start + units.count with start ≥ cursor, units non-empty. Strictly forward.
  • advanceCursorPastNewline (L646-655): local index starts at cursor and only increments.
  • appendFootnoteBadge (L574): cursor = occurrence.sourceStart + occurrence.length. This is the only non-obvious one, and it is forward via a two-step argument: liveOccurrence(atOrAfter: cursor) (L415) floors sourceStart ≥ cursor, and the intervening appendVisible(_, searchLimit: occurrence.sourceStart) (L416-419) makes end = min(occurrence.sourceStart, count) so the post-appendVisible cursor is ≤ occurrence.sourceStart. Weakening the pre-badge bound would break the memo, not merely performance — correctly called out in both the doc comment and the agent-note.

The signature change (locate no longer takes from:, reads self.cursor) is the right structural move: it makes the precondition unviolatable by a caller rather than documented.

Index safety and equivalence

The removed from <= sourceUTF16.count guard is subsumed by the retained limit >= cursor, since limit = end - units.count ≤ count - units.count. That also discharges matches(_:at:)'s stated precondition at both the fast-path call (index = cursor) and the loop call (index ≤ limit). The at-cursor fast path is exactly the old loop's first iteration hoisted out, with the loop restarting at cursor + 1 — bit-for-bit equivalent.

Recording is keyed on actual coverage (end == sourceUTF16.count), not on whether a bound was passed — so claimOccurrences' bound of owed.sourceStart + units.count, which frequently exceeds the source length, still records correctly. That is the subtle right call.

Edge cases I checked

  • Memo scope. Walker is a struct instantiated per render call; MarkupWalker dispatch is all mutating on self, so no copy can fork a memo away from its cursor. Per-block, correct.
  • Coordinate space. Both sourceUTF16 and every units argument are in marked source coordinates (PUA-substituted), since locate is called with text.string and literal restoration happens only on the HTML side. No mismatch.
  • Memory. Capped at 256 entries; total retained bytes are bounded by the sum of distinct text-node lengths, i.e. O(block). UnitSet is 8 KB, allocated at most once per Walker and only after a failure.
  • Empty-Set probe cost. I benchmarked Set.contains against an empty Set<[UInt16]> with a 200-unit key: 0.33 ns per call, i.e. the stdlib short-circuits and does not hash. So the unconditional provedAbsent.contains probe costs nothing on blocks that never record, and no isEmpty guard is needed. (Incidentally sourceUnits == nil ⇔ provedAbsent.isEmpty, since both are written in the same branch — so gating both probes on one nil check would be equivalent, but is not worth the churn given the measurement.)

Evidence quality

O7 is the load-bearing test and it earns its place: O1-O6 genuinely cannot fail on this change (locate's return never reaches the HTML; the ordering invariants hold vacuously for a map collapsed to zero-length runs at the cursor), which the file says out loud. I hand-derived all three O7 fixtures from the source layout — including the mixed one *&#65;* two [^1] three \*x\* fixture at 99 UTF-16 units, whose ten runs and three badge offsets [16, 49, 82] I reproduced exactly. The non-zero (0,4)/(11,5)/(44,5)/(77,5) lengths are precisely what a memo that suppressed a real match would destroy.

O8's mutation-testing story is the honest kind: the reviewer-suggested fixture &#65;&#66;[^1]AB stays green under the mutation because the post-badge AB sits exactly at the cursor and the fast path answers first. The author kept it (it pins the shape) and added ![x](yyyy.png) to push the correct match 14 units ahead, out of the fast path's reach — I verified that the image contributes no run and does not move the cursor (its alt and src carry no reserved marker, so claimOccurrences returns early), so the mutated code would indeed collapse (28, 2) to (14, 0).

The one gap

The 256-entry cap is a bypass of the fix, not only a memory bound. Construct 256 distinct escape-split absent texts (*a\*b1* *a\*b2* …) to fill the cap, then repeat a 257th: sourceUnits cannot reject it (all units present), provedAbsent refuses to record it, and every occurrence pays a full scan again. Quadratic returns. It is the same complexity class as the accepted T-2034 residual, so the security posture is unchanged — but it is a distinct shape, and unbounded memoisation would have closed it at a cost of O(block) memory, which is the same order as sourceUTF16 that the Walker already holds. Worth a comment on T-2034.

Important changes — detailed

locate: two exact absence memos plus an at-cursor fast path

InlineHTMLRenderer.swift

Why it matters. The whole fix. A failed unbounded scan now records what it proved instead of being re-derived once per text node. Correctness rests entirely on cursor monotonicity, which I verified across all four write sites.

What to look at. InlineHTMLRenderer.swift:334-372 (locate), :376-381 (matches)

Takeaway. When a pure predicate's negative result is permanently valid under a monotone precondition, memoising it is an exact optimisation — provable by differential comparison rather than by argument. Record only after the scan that actually establishes the proof (here: end == sourceUTF16.count), never after a bounded miss.
Rationale. Bounding the search was impossible in the general case (text legitimately sits far ahead of the cursor when the walk skips source it does not account for), and the three bounded alternatives all changed output on inputs that legitimately need long scans. Memoising negative results changes no output at all, which is what makes it safe to land beside T-1992 and T-1941.

locate drops its from: parameter and reads self.cursor

InlineHTMLRenderer.swift

Why it matters. Turns the memo's monotone-cursor precondition from a comment into a signature. Both call sites already passed cursor, so this costs nothing and removes the only way a future caller could break the memo silently.

What to look at. InlineHTMLRenderer.swift:334; call sites at :255 and :532-534

Takeaway. If an optimisation depends on a caller-supplied value always equalling a piece of internal state, stop accepting the value. The strongest form of a precondition is one the type system will not let you state wrongly.
Rationale. Directly requested by the PR #343 review; the author took the structural form rather than adding another comment.

sourceUnits as a 1 024-word bitset over the UTF-16 unit domain

InlineHTMLRenderer.swift

Why it matters. This is the record that makes the entity/escape family O(text) rather than O(block) — a text holding a unit the source lacks cannot occur at any position, no cursor reasoning required. Built lazily, so blocks whose text all locates verbatim never pay the 8 KB.

What to look at. InlineHTMLRenderer.swift:105-133 (UnitSet), :195-205 (sourceUnits), probed at :348-350

Takeaway. When a set's domain is small and fixed (16 bits = 8 KB), a bitset makes membership an index and a mask with no hashing. The doc comment is candid that this was chosen structurally and NOT on a measurement, because Debug builds cannot separate the two forms — which is the right way to record an unmeasured choice.
Rationale. Replaced a Set<UInt16> after the PR #343 review; the probe runs once per unit of every text located after the first failure, so hashing dominated it.

provedAbsent capped at 256 entries, stop-at-cap rather than evict-oldest

InlineHTMLRenderer.swift

Why it matters. Bounds what an attacker-supplied paragraph can retain. It is also the one place the fix can be bypassed: fill the cap with distinct decoys and the memo stops recording, so a subsequently repeated absent text is quadratic again.

What to look at. InlineHTMLRenderer.swift:181-189 (provedAbsent, provedAbsentCapacity), :356-368 (the gate)

Takeaway. A cap on a memo is not free: it converts an unconditional optimisation into a conditional one, and the condition is attacker-influenced. Where the memo defends against a DoS shape, say explicitly which inputs the cap re-admits.
Rationale. Stop-at-cap over evict-oldest because on the T-2034 input class nothing is ever read back (so an eviction order would be pure overhead), while in the cases the memo is for the same few texts recur, making the earliest entries the most likely to be hit again. Sound reasoning as far as it goes — but see the open question about what the cap re-admits.

Committed 4 000-sample seeded differential corpus

InlineRenderCorpusEquivalenceTests.swift

Why it matters. Converts the equivalence claim from a scratch probe described in a commit message into a reproducible artifact. Digests cover html AND every run coordinate AND badgeSourceStarts — the coordinates being exactly what the memo could break and what the HTML cannot show.

What to look at. InlineRenderCorpusEquivalenceTests.swift:1-239, digests at :164-167

Takeaway. Seeded SplitMix64 over a fixed fragment alphabet drawn WITH replacement — the repetition is what makes samples reach the memo at all, and E2 pins that property so a future alphabet edit cannot hollow out E1. Pinning goldens taken from the PRE-fix commit is what makes them evidence of equivalence rather than a snapshot of today.
Rationale. The PR #343 review's central verdict was that the memo was sound but the committed evidence did not guard the property it could break. This is the response.

O7 golden coordinates and O8's mutation-verified recording gate

InlineSourceMapScanGrowthTests.swift

Why it matters. O1-O6 cannot fail on this change and the file admits it. O7 pins exact (sourceStart, length) pairs on memo-HIT fixtures; O8 pins the one gate whose failure direction is a wrong answer rather than a slow one.

What to look at. InlineSourceMapScanGrowthTests.swift:209-265 (O7), :267-322 (O8)

Takeaway. Mutation-test your regression test before claiming it guards anything. The reviewer's own suggested fixture stayed GREEN under the mutation — the at-cursor fast path answered first — and the author reports that honestly and adds the fixture that actually goes red, instead of quietly keeping the one that looked sufficient.
Rationale. Explicitly recorded in the commit message: forcing end == sourceUTF16.count to true left the plain fixture green; inserting ![x](yyyy.png) moves the correct match 14 units ahead of the cursor, out of the fast path's reach.

GrowthRatioGuard hoisted out of FootnoteBadgeGrowthTests

GrowthRatioGuard.swift

Why it matters. Both growth suites now ask 'linear or quadratic?' the same way, and the methodology rationale lives in one place. Also adds input validation that turns two caller mistakes from silent passes into recorded issues.

What to look at. GrowthRatioGuard.swift:1-98; FootnoteBadgeGrowthTests.swift now delegates

Takeaway. A ratio at N vs 4N, fastest-of-samples with warm-ups at the base size, is the assertion that survives CI contention — contention scales both measurements and cancels out of the quotient, where an absolute budget just goes flaky. Guard the guard: multiplier < 2 or baseCount <= 0 produce a meaningless ratio that would otherwise pass silently.
Rationale. Recommended by T-1951; no such shared helper existed. Both suites are now .serialized so base and 4x measurements meet the same contention.

Key decisions

Memoise negative results rather than bound or budget the search.

Four alternatives were rejected with reasons that hold up: advancing the cursor on failure does not help because a scan's cost is set by where it ends; a fixed forward window silently drops legitimate far-ahead mappings; a per-block scan budget changes output on inputs that need long scans; a suffix automaton or k-gram index pays O(block) build cost on every block of every render to close a residual an attacker has to work at. Memoisation is the only option that changes no output, which is what makes byte-for-byte equivalence a demonstrable claim rather than an argued one.

Record only after a scan that reached the true end of the source.

end == sourceUTF16.count gates both records. A miss caused by a too-small window proves nothing about the source, and recording it would suppress a later unbounded match — a wrong answer, not a slow one. Note the gate is keyed on actual coverage, not on whether a bound was passed, so claimOccurrences' frequently-oversized bound still records correctly. This is the subtle right call and it is what O8 guards.

Stop-at-cap rather than evict-oldest for provedAbsent.

On the T-2034 input class nothing is ever read back, so an eviction policy would maintain an order for entries that never pay off; and in the cases the memo is for, the same few texts recur, so the earliest entries are the most likely to be hit again. Reasoning is sound for the stated inputs. What is not stated is that a cap of any kind re-admits a crafted shape the uncapped memo would have closed — see the open question below.

Bitset over Set<UInt16>, chosen structurally rather than on a measurement.

The doc comment is explicit that Debug builds cannot separate the two forms and that what was measured is only that neither the bitset nor the at-cursor fast path regresses the common case (80.1 ms vs 80.3 ms over 8 000 text nodes). Labelling an unmeasured choice as unmeasured is the correct discipline here, and the structural argument (1 024 words, index-and-mask, no hashing, allocated only after a failure) stands on its own.

The bugfix report lives in the commit message, not specs/bugfixes/.

The commit notes the harness blocked writing specs/bugfixes/<name>/report.md. I checked the last five bugfix commits on main (T-1541/T-1983, T-1716/T-1945, T-1980, T-1827/T-1828, T-1877) and none of them added a specs/bugfixes/ entry either, so this matches current practice rather than diverging from it. Not a finding.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorInlineHTMLRenderer.swift:181-189, 356-368 + CHANGELOG residual wordingThe 256-entry cap on provedAbsent is a bypass of the fix, not only a memory bound. An attacker can fill the cap with 256 distinct escape-split absent texts (*a\*b1* *a\*b2* … — every unit present in the source, so sourceUnits cannot reject them) and then repeat a 257th: it is never recorded, so every occurrence pays a full scan and the behaviour is quadratic again. This is the same complexity class as the accepted T-2034 residual, so the security posture does not change — but it is a DISTINCT shape, and neither the CHANGELOG's residual sentence ('where every repetition writes a different word') nor T-2034's stated scope covers it. Uncapped, the memo's memory is bounded by the sum of distinct text-node lengths, i.e. O(block) — the same order as the sourceUTF16 array the Walker already holds — so the cap buys roughly one block-size of memory in exchange for re-admitting the shape the fix exists to close.Report-only: no production change made. Recommended follow-up (does not need to block the push): add a comment to T-2034 recording this second uncovered shape, and consider whether the cap should be raised, or dropped in favour of the natural O(block) bound. If the cap stays, one sentence in the CHANGELOG residual and in the locate doc comment naming the decoy-fill shape would keep the documentation honest.
nitInlineRenderCorpusEquivalenceTests.swift:41-55 (re-blessing protocol)The header says a digest mismatch 'means inline rendering changed for some sample in that chunk'. The corpus is generated with Int.random(in:using:), whose mapping from generator output to values is a stdlib implementation detail — a Swift toolchain change to it would move every digest without inline rendering having changed at all. The protocol's step 1 (bisect and diff html/runs/badgeSourceStarts) would eventually surface that, but the reader is pointed at the wrong first hypothesis.Report-only. One clause in the header ('or the stdlib's random-in-range mapping changed') would close it. Not worth blocking on.
nitdocs/agent-notes/webview-rendering-status.mdThe agent-note cites PRISM_INLINE_CORPUS_FULL=1 without the TEST_RUNNER_ prefix xcodebuild needs to forward it to the test process. Both spellings are correct in their own context (the code reads the unprefixed name; the CLI needs the prefix), and the test file header explains it — but a reader who only sees the agent-note will set the wrong variable and get a silently truncated one-chunk run.Report-only. Verified empirically: TEST_RUNNER_PRISM_INLINE_CORPUS_FULL=1 gives 0.614 s (8 chunks) vs 0.096 s unset (1 chunk), so the mechanism works as documented in the test file.
infoInlineHTMLRenderer.swift:252-256 (searchStart local)Considered whether `let searchStart = cursor` is redundant now that locate never writes cursor. It is not redundant in the useful sense: locate is `mutating`, so a reader can no longer assume cursor survives the call, and capturing it makes the fallback explicit. Leaving it alone.No change needed.
infoInlineHTMLRenderer.swift:347 (unconditional provedAbsent.contains)Considered flagging the unconditional Set lookup as a per-node O(text) hash on the common path. Benchmarked it instead: Set.contains against an empty Set<[UInt16]> with a 200-unit key costs 0.33 ns, i.e. the stdlib short-circuits on empty and does not hash. No isEmpty guard needed. (Separately, sourceUnits == nil <=> provedAbsent.isEmpty holds structurally, since both are written in the same branch — so a single nil check could gate both probes, but the measurement says there is nothing to win.)Withdrawn after measurement. No change needed.
infobuild warningsBoth make build-ios and make build-macos emit one warning: main actor-isolated conformance of 'ImageDimension' to 'Equatable' cannot be used in nonisolated context. ImageDimension is not touched by this branch, so the warning is pre-existing on main and out of scope here.Out of scope for this PR; worth a separate ticket if not already tracked.

Per-file diffs

Click to expand.

prism/Services/WebRendering/InlineHTMLRenderer.swift Modified +134 / -21
diff --git a/prism/Services/WebRendering/InlineHTMLRenderer.swift b/prism/Services/WebRendering/InlineHTMLRenderer.swiftindex 3abe1a5..9896536 100644--- a/prism/Services/WebRendering/InlineHTMLRenderer.swift+++ b/prism/Services/WebRendering/InlineHTMLRenderer.swift@@ -102,6 +102,36 @@ nonisolated struct InlineHTMLRenderer {             .badgeSourceStarts     } +    // MARK: - Unit membership++    /// Membership over the UTF-16 unit domain, as a bitset.+    ///+    /// A plain `Set<UInt16>` does the same job, but this probe runs once per unit of every+    /// text located after the first failed whole-source scan, and the domain is only 16+    /// bits: 65 536 bits is 1 024 words — 8 KB — so membership becomes an index and a mask+    /// with no hashing at all (PR #343 review). Allocated only when a whole-source scan has+    /// already failed, so the common block never pays for it.+    ///+    /// Chosen for being structurally cheaper per probe, NOT on a measurement: the two forms+    /// are not separable in a Debug build, where unoptimised generics make both far slower+    /// than either is in Release. What was measured is that neither this nor the at-cursor+    /// fast path regresses the common case — a block that records nothing renders in the+    /// same time as before (80.1 ms vs 80.3 ms over 8 000 text nodes).+    private struct UnitSet {+        private var words: [UInt64]++        init(_ units: [UInt16]) {+            words = [UInt64](repeating: 0, count: 1_024)+            for unit in units {+                words[Int(unit) >> 6] |= UInt64(1) << UInt64(unit & 63)+            }+        }++        func contains(_ unit: UInt16) -> Bool {+            words[Int(unit) >> 6] & (UInt64(1) << UInt64(unit & 63)) != 0+        }+    }+     // MARK: - Walker      /// Walks the inline AST, emitting HTML and tracking the source cursor in UTF-16 units.@@ -141,6 +171,40 @@ nonisolated struct InlineHTMLRenderer {         /// the same treatment a code span or a link destination already gets.         private var linkDepth: Int = 0 +        /// Texts an UNBOUNDED search has already proved absent from the source at or after+        /// the position it searched from.+        ///+        /// Sound because `cursor` only ever moves forward and every search starts from it:+        /// a later search for the same text runs over a subrange of the range this one+        /// already cleared, so it can only reach the same answer. Recording it is what+        /// stops the walk re-deriving one failed scan per node — the quadratic (T-1966).+        ///+        /// Capped: see `provedAbsentCapacity` and the recording site in `locate`.+        private var provedAbsent: Set<[UInt16]> = []++        /// How many distinct absent texts one block may retain. Bounds the memo's memory on+        /// input that only ever writes to it (T-2034), where it would otherwise grow with+        /// the block — the threat model here is documents opened from a URL.+        private static let provedAbsentCapacity = 256++        /// The distinct UTF-16 units the source contains. A text holding a unit the source+        /// does not hold cannot occur in it at ANY position, so this rejects a search in+        /// O(text) with no scan — which is the whole entity/escape family, whose rendered+        /// characters are exactly the ones the source spells some other way (`A` written+        /// `&#65;`).+        ///+        /// Built lazily, on the first search that scanned the whole source and failed: a+        /// block whose every text locates verbatim — the overwhelming majority — never+        /// pays for it.+        ///+        /// Between them the two records leave one residual, recorded rather than closed:+        /// a text absent from the source whose every unit is nonetheless present, and that+        /// is DISTINCT at each occurrence, fires neither rejection and still costs a full+        /// scan apiece. Closing that needs an index over the source (a suffix automaton or+        /// a k-gram table), whose build cost every real document would pay to defend+        /// against input no real document produces — tracked as **T-2034**.+        private var sourceUnits: UnitSet?+         /// Buffer for the currently-open run: source start + accumulated escaped HTML.         private var openRunStart: Int?         private var openRunSourceLength: Int = 0@@ -185,8 +249,11 @@ nonisolated struct InlineHTMLRenderer {             // to its own contiguous source span.             closeRun()             let units = Array(text.utf16)-            let start = locate(units, from: cursor, limit: searchLimit)-            openRunStart = start ?? cursor+            // `locate` reads the cursor itself and never moves it, so this local is the+            // search start either way — kept only for the not-located fallback below.+            let searchStart = cursor+            let start = locate(units, limit: searchLimit)+            openRunStart = start ?? searchStart             if let start {                 openRunSourceLength = units.count                 cursor = start + units.count@@ -232,27 +299,87 @@ nonisolated struct InlineHTMLRenderer {             html += markup         } -        /// Finds `units` in the source starting at or after `from`, returning the UTF-16+        /// Finds `units` in the source starting at or after the CURSOR, returning the UTF-16         /// start index of the first match, or nil if absent. `bound`, when given, is an         /// exclusive upper bound on the match's END: no match may extend past it.-        private func locate(_ units: [UInt16], from: Int, limit bound: Int? = nil) -> Int? {-            guard !units.isEmpty, from <= sourceUTF16.count else { return nil }+        ///+        /// The scan is naive and forward, which is right for the common case: the text+        /// almost always sits at or just past the cursor, and the search exists only to+        /// step over markup the walk does not otherwise account for. What it must not do+        /// is scan for the same absent text once per node, which is quadratic in the block+        /// (T-1966). `appendVisible`'s search cannot be BOUNDED the way the pre-badge+        /// segment and `claimOccurrences` are — text legitimately sits far ahead of the+        /// cursor whenever the walk skipped source it does not account for (a long image+        /// `src`, a long raw-HTML span) — so instead a scan that ran the whole source and+        /// failed records what it proved, and later searches read the proof off the record.+        ///+        /// **Precondition: the cursor never moves backwards.** The search start is read+        /// from `cursor` here rather than taken as a parameter precisely so no caller can+        /// violate that — a signature is a stronger guarantee than this comment (PR #343+        /// review). Every mutation of `cursor` moves it forward:+        ///+        /// - `locate` returns an index at or after the cursor, and `appendVisible` steps to+        ///   the end of that match.+        /// - `claimOccurrences` steps to the end of a span located at or after the cursor.+        /// - `advanceCursorPastNewline` only increments.+        /// - `appendFootnoteBadge` steps to its occurrence's END, which is forward only+        ///   because of a two-step argument worth stating: `liveOccurrence(atOrAfter:)`+        ///   floors the occurrence to `sourceStart >= cursor`, AND the `appendVisible` that+        ///   runs between them is bounded by `searchLimit: occurrence.sourceStart`, so it+        ///   cannot push the cursor past that start. **Weakening or dropping that pre-badge+        ///   bound would break the precondition** — it is not merely an optimisation.+        ///+        /// `provedAbsent` rests on the whole of that: a text absent from `[cursor, end)`+        /// stays absent from every later, narrower window.+        private mutating func locate(_ units: [UInt16], limit bound: Int? = nil) -> Int? {+            guard !units.isEmpty else { return nil }             let end = min(bound ?? sourceUTF16.count, sourceUTF16.count)             let limit = end - units.count-            guard limit >= from else { return nil }-            var index = from+            // A window too small to hold `units` proves nothing about the source — record+            // nothing, or a bounded miss would suppress a later unbounded match.+            guard limit >= cursor else { return nil }+            // Free, because the scan below would have tried the cursor as its first+            // position anyway — it now starts one past it. When the text does sit exactly+            // at the cursor, this skips BOTH memo probes (hashing the whole text for+            // `provedAbsent`, then one membership test per unit) for a search that was+            // about to succeed immediately (PR #343 review).+            if matches(units, at: cursor) { return cursor }+            if provedAbsent.contains(units) { return nil }+            if let sourceUnits, units.contains(where: { !sourceUnits.contains($0) }) {+                return nil+            }+            var index = cursor + 1             while index <= limit {-                var matched = true-                for offset in 0..<units.count where sourceUTF16[index + offset] != units[offset] {-                    matched = false-                    break-                }-                if matched { return index }+                if matches(units, at: index) { return index }                 index += 1             }+            if end == sourceUTF16.count {+                // Past the cap, stop recording rather than evicting. On the input class the+                // cap is for — T-2034, where every failing text is DISTINCT — nothing is+                // ever read back, so an eviction policy would spend work maintaining an+                // order for entries that never pay off; and in the cases the memo is+                // actually for, the same few texts recur, so the earliest entries are the+                // ones most likely to be hit again. A real document has a handful of+                // distinct un-locatable texts (the entity/escape spellings its author+                // repeats), so the cap is far above any legitimate use while bounding what+                // an attacker-supplied paragraph can retain to a constant (PR #343 review).+                if provedAbsent.count < Self.provedAbsentCapacity {+                    provedAbsent.insert(units)+                }+                if sourceUnits == nil { sourceUnits = UnitSet(sourceUTF16) }+            }             return nil         } +        /// Whether the source holds `units` verbatim starting at `index`. The caller+        /// guarantees `index + units.count <= sourceUTF16.count`.+        private func matches(_ units: [UInt16], at index: Int) -> Bool {+            for offset in 0..<units.count where sourceUTF16[index + offset] != units[offset] {+                return false+            }+            return true+        }+         // MARK: Inline leaf nodes          /// A footnote marker becomes a badge in text position that is NOT inside an element@@ -403,7 +530,7 @@ nonisolated struct InlineHTMLRenderer {             guard occurrenceIndex < occurrences.count else { return }             let owed = occurrences[occurrenceIndex]             guard let start = locate(-                units, from: cursor, limit: owed.sourceStart + units.count+                units, limit: owed.sourceStart + units.count             ) else { return }             let end = start + units.count             // Structurally implied by the bound above (`start <= owed.sourceStart`); kept
prismTests/WebRendering/InlineSourceMapScanGrowthTests.swift Added +323 / -0
diff --git a/prismTests/WebRendering/InlineSourceMapScanGrowthTests.swift b/prismTests/WebRendering/InlineSourceMapScanGrowthTests.swiftnew file mode 100644index 0000000..8972f7b--- /dev/null+++ b/prismTests/WebRendering/InlineSourceMapScanGrowthTests.swift@@ -0,0 +1,323 @@+//+//  InlineSourceMapScanGrowthTests.swift+//  prismTests+//+//  Growth + equivalence guards for `InlineHTMLRenderer`'s source-mapping scan (T-1966).+//+//  The scan is `Walker.locate`, reached from `appendVisible` (every mapped text node) and+//  from `claimOccurrences` (T-1992). `appendVisible`'s search is UNBOUNDED except for the+//  one segment that sits immediately before a badge, so a text node whose rendered text+//  does not appear verbatim in the source — the whole entity/escape family, whose rendered+//  characters are exactly the ones the source spells some other way — scans to the end of+//  the block and finds nothing. The cursor does not move on that outcome, so the next such+//  node re-derives the same failed scan: N nodes x O(block) = quadratic.+//+//  The three inputs T-1966 was filed for (`\* [^1] `, `\\`, `\[\^1\]&#65;`) had already+//  stopped being quadratic by the time it was picked up — PR #326's pre-badge `searchLimit`+//  and T-1992's `owed.sourceStart + units.count` bound closed them incidentally — so they+//  are pinned here at their measured sizes rather than dropped, and the shapes that were+//  still quadratic on the same commit are pinned beside them.+//+//  Three kinds of assertion, all needed:+//+//  - GROWTH (G1-G8): ratio at N vs 4N, never an absolute budget. See `GrowthRatioGuard`.+//  - ORDER (O1-O6): the RENDERED TEXT IN ORDER, plus the run invariants. Counts and totals+//    are not enough — the correctness defect PR #326 found in round 4 swapped a live and an+//    escaped reference while keeping every total identical.+//  - COORDINATES (O7-O8): exact `(sourceStart, length)` goldens. O1-O6 are invariant under+//    the memo by construction — `locate`'s answer never reaches the HTML, and the O6+//    invariants hold for a source map that collapsed entirely — so they guard the T-1716+//    failure class, not this one. O7 pins pre-fix coordinates on memo-HIT fixtures; O8 pins+//    the recording gate, whose failure direction is a wrong answer rather than a slow one.+//    The corresponding sweep over a generated corpus is+//    `InlineRenderCorpusEquivalenceTests` (PR #343 review).+//+//  The suite is `.serialized`: eight of its tests are timing measurements, and running them+//  concurrently with each other puts the base and the 4x measurement under different+//  contention, which is exactly the noise the ratio is meant to cancel.+//++import Foundation+import Testing+@testable import prism++@Suite("Inline source-map scan — growth and order (T-1966)", .serialized)+struct InlineSourceMapScanGrowthTests {++    private static let footnotes = FootnoteRenderProbe.footnotes(["1"])++    private static func render(_ source: String) -> InlineHTMLRenderer.Result {+        var allocator = 0+        return InlineHTMLRenderer.render(+            source: source, footnotes: footnotes, runIDAllocator: &allocator+        )+    }++    private func expectLinear(_ unit: String, shape: String, baseCount: Int = 800) {+        GrowthRatioGuard.expectLinearGrowth(shape: shape, baseCount: baseCount) { count in+            _ = Self.render(String(repeating: unit, count: count))+        }+    }++    // MARK: Growth — the shapes that were quadratic when T-1966 was picked up++    @Test("G1: entity-decoded text inside emphasis scales linearly")+    func entityInEmphasisScalesLinearly() {+        // 453ms -> 7068ms (15.6x) before the fix: `A` is nowhere in `*&#65;* `, so every+        // one of the N emphasis text nodes scanned the whole remaining block and failed.+        expectLinear("*&#65;* ", shape: "entity-in-emphasis")+    }++    @Test("G2: entity-decoded text inside strong scales linearly")+    func entityInStrongScalesLinearly() {+        expectLinear("**&#65;** ", shape: "entity-in-strong")+    }++    @Test("G3: entity-decoded link text scales linearly")+    func entityInLinkTextScalesLinearly() {+        expectLinear("[&#65;](e) ", shape: "entity-in-link-text")+    }++    @Test("G4: entity-decoded text after a badge scales linearly")+    func entityAfterBadgeScalesLinearly() {+        // The segment BEFORE a badge is bounded by the occurrence (PR #326); the segment+        // after the last badge in a text node is not, which is the path this reaches.+        expectLinear("*[^1]&#65;* ", shape: "entity-after-badge-in-emphasis")+    }++    @Test("G5: escape-split text inside emphasis scales linearly")+    func escapeSplitTextInEmphasisScalesLinearly() {+        // The other half of the family, and the one an escape can reach: every UNIT of the+        // rendered `a*b` occurs in `*a\*b* `, but the substring does not (the source spells+        // it `a\*b`). So a filter on units alone cannot reject it — only not re-deriving+        // the same failed scan can.+        expectLinear(#"*a\*b* "#, shape: "escape-split-text-in-emphasis")+    }++    // MARK: Growth — the three inputs the ticket was filed for++    @Test("G6: the ticket's escaped-star + live-reference run scales linearly")+    func ticketEscapedStarRunScalesLinearly() {+        expectLinear(#"\* [^1] "#, shape: "ticket escaped-star + live reference",+                     baseCount: 3_200)+    }++    @Test("G7: the ticket's double-backslash run scales linearly")+    func ticketDoubleBackslashRunScalesLinearly() {+        expectLinear(#"\\"#, shape: "ticket double backslash", baseCount: 16_000)+    }++    @Test("G8: the ticket's escaped-reference + entity run scales linearly")+    func ticketEscapedReferenceEntityRunScalesLinearly() {+        expectLinear(#"\[\^1\]&#65;"#, shape: "ticket escaped reference + entity",+                     baseCount: 1_600)+    }++    // MARK: Order — rendered text, in order++    /// Renders `unit` repeated `count` times through the full emitter and asserts the+    /// document's rendered text is `expectedUnit` repeated the same number of times.+    ///+    /// The expectation drops trailing spaces because cmark strips a paragraph's trailing+    /// whitespace; nothing else about the sequence is normalised.+    private func expectRenderedText(+        _ unit: String, repeated count: Int, is expectedUnit: String,+        sourceLocation: SourceLocation = #_sourceLocation+    ) {+        let html = FootnoteRenderProbe.emit(String(repeating: unit, count: count))+        let expected = String(+            String(repeating: expectedUnit, count: count).reversed()+                .drop(while: { $0 == " " }).reversed()+        )+        #expect(FootnoteRenderProbe.text(html) == expected, sourceLocation: sourceLocation)+    }++    @Test("O1: entity-decoded emphasis renders its text in order")+    func entityInEmphasisRendersInOrder() {+        expectRenderedText("*&#65;* ", repeated: 64, is: "A ")+    }++    @Test("O2: entity-decoded link text renders its text in order")+    func entityInLinkTextRendersInOrder() {+        expectRenderedText("[&#65;](e) ", repeated: 64, is: "A ")+    }++    @Test("O3: a badge followed by entity-decoded text renders both, in order")+    func badgeThenEntityRendersInOrder() {+        expectRenderedText("*[^1]&#65;* ", repeated: 64, is: "{1}A ")+    }++    @Test("O4: live and escaped references alternating with entities keep their order")+    func alternatingReferencesKeepOrder() {+        // The shape a swapped live/escaped pair hides in: identical badge and literal+        // counts either way, different sequences.+        expectRenderedText(#"*&#65;* [^1] \[\^1\] "#, repeated: 64, is: "A {1} [^1] ")+    }++    @Test("O5: the ticket's escaped-star + live-reference run keeps its order")+    func ticketEscapedStarKeepsOrder() {+        expectRenderedText(#"\* [^1] "#, repeated: 64, is: "* {1} ")+    }++    // MARK: Order — run invariants over the same shapes++    @Test("O6: runs stay ordered, non-overlapping and inside the source",+          arguments: [+            "*&#65;* ", "**&#65;** ", "[&#65;](e) ", "*[^1]&#65;* ", #"*a\*b* "#,+            #"\* [^1] "#, #"\\"#, #"\[\^1\]&#65;"#, #"*&#65;* [^1] \[\^1\] "#+          ])+    func runsStayOrderedAndInBounds(unit: String) {+        let source = String(repeating: unit, count: 64)+        let result = Self.render(source)+        let limit = source.utf16.count+        var previousEnd = 0+        for (index, run) in result.runs.enumerated() {+            #expect(run.length >= 0, "\(unit): run \(index) has negative length")+            #expect(+                run.sourceStart >= previousEnd,+                Comment(rawValue: "\(unit): run \(index) starts at \(run.sourceStart) before"+                    + " previous end \(previousEnd) — overlap or out of order")+            )+            #expect(+                run.sourceStart + run.length <= limit,+                "\(unit): run \(index) ends past the source (\(limit) UTF-16 units)"+            )+            previousEnd = run.sourceStart + run.length+        }+        // Badge offsets come out ascending too, and each has to name a real source+        // position — `SearchStateFeeder` matches them against its own occurrence scan.+        #expect(result.badgeSourceStarts == result.badgeSourceStarts.sorted())+        #expect(result.badgeSourceStarts.allSatisfy { $0 >= 0 && $0 < limit })+    }++    // MARK: Golden coordinates — the property only the source map can lose++    /// O1-O6 are invariant under this change by construction: `locate`'s return value never+    /// reaches the HTML (`appendVisible` sets `openRunHTML` from `visibleText(text)` alone),+    /// and O6's ordering/bounds hold vacuously for a source map that collapsed every run to+    /// zero length at the cursor. So neither is evidence for the memo (PR #343 review).+    ///+    /// These are. Every `(sourceStart, length)` pair is pinned exactly, on fixtures chosen+    /// so `provedAbsent` actually fires — the same absent text is searched for once per+    /// repeat — and the values were taken from `origin/main` at 09cd828, i.e. from the code+    /// WITHOUT the memo. They are the pre-fix coordinates, so they falsify any change to+    /// what `locate` answers, not merely a change from today's output.+    ///+    /// Re-blessing: same protocol as the corpus digests+    /// (`InlineRenderCorpusEquivalenceTests`) — establish why a coordinate moved, prove the+    /// new one is intended by a named behavioural test, and only then edit the number.+    @Test("O7: memo-hit fixtures keep their exact run coordinates", arguments: [+        // `A` is spelled `&#65;`, so the emphasis text occurs NOWHERE in the source: the+        // first scan records it and builds `sourceUnits` (the source holds no `A` at all),+        // and the three later ones are rejected without scanning. The `(n, 1)` runs are the+        // single spaces between the emphases, which do locate — a collapsed map would zero+        // them.+        GoldenFixture(+            name: "entity-in-emphasis x4", unit: "*&#65;* ", repeats: 4, sourceLength: 32,+            runs: [(0, 0), (7, 1), (8, 0), (15, 1), (16, 0), (23, 1), (24, 0)],+            badges: []+        ),+        // The other half of the family, and the one `sourceUnits` CANNOT reject: every unit+        // of the rendered `a*b` is in the source, the substring is not (`a\*b`). Only+        // `provedAbsent` rejects it, so this fixture is the memo's own regression guard.+        GoldenFixture(+            name: "escape-split-in-emphasis x4", unit: #"*a\*b* "#, repeats: 4,+            sourceLength: 28,+            runs: [(0, 0), (6, 1), (7, 0), (13, 1), (14, 0), (20, 1), (21, 0)],+            badges: []+        ),+        // Mixed: prose that locates (lengths 4 and 5), a live badge, and two memo hits per+        // repeat. The nonzero lengths are the point — they are what a memo that quietly+        // suppressed a real match would destroy while still satisfying O6.+        GoldenFixture(+            name: "prose + badge + memo hits x3",+            unit: #"one *&#65;* two [^1] three \*x\* "#, repeats: 3, sourceLength: 99,+            runs: [(0, 4), (4, 0), (11, 5), (20, 0), (20, 0), (44, 5), (53, 0), (53, 0),+                   (77, 5), (86, 0)],+            badges: [16, 49, 82]+        )+    ])+    func memoHitFixturesKeepGoldenCoordinates(fixture: GoldenFixture) {+        let source = String(repeating: fixture.unit, count: fixture.repeats)+        #expect(source.utf16.count == fixture.sourceLength, "\(fixture.name): fixture drifted")+        let result = Self.render(source)+        let actual = result.runs.map { ($0.sourceStart, $0.length) }+        #expect(+            actual.map { [$0.0, $0.1] } == fixture.runs.map { [$0.0, $0.1] },+            Comment(rawValue: "\(fixture.name): run coordinates moved — got \(actual),"+                + " expected \(fixture.runs)")+        )+        #expect(+            result.badgeSourceStarts == fixture.badges,+            "\(fixture.name): badge offsets moved"+        )+    }++    struct GoldenFixture: CustomStringConvertible, Sendable {+        let name: String+        let unit: String+        let repeats: Int+        let sourceLength: Int+        let runs: [(Int, Int)]+        let badges: [Int]++        var description: String { name }+    }++    // MARK: The recording gate — a bounded miss must record NOTHING++    /// `locate` records a failure only when the scan ran to the true end of the source+    /// (`end == sourceUTF16.count`). A scan that failed merely because its window was too+    /// small proved nothing, and recording it would suppress a LATER search that would have+    /// matched — a wrong answer, not a slow one, which makes this the one gate whose+    /// failure direction is silent corruption.+    ///+    /// `&#65;&#66;[^1]AB` is the shape (PR #343 review). Layout in UTF-16:+    ///+    ///     0        5        10   14+    ///     &#65;    &#66;    [^1] AB+    ///+    /// The text before the badge renders `AB` and is searched BOUNDED to the occurrence at+    /// 10 — `AB` is not spelled anywhere in `[0, 10)`, so that search fails, and must not+    /// record. The identical `AB` after the badge is then searched from 14, where it does+    /// occur verbatim.+    ///+    /// That fixture alone is NOT a guard, which was found by mutation-testing it: forcing+    /// the gate to record on bounded misses leaves it green, because `locate` tries a match+    /// AT the cursor before consulting either memo and the post-badge `AB` sits exactly+    /// there. Both cases are kept — the plain one because it is the shape the review named+    /// and it pins the coordinates, and the second because it is the one that actually goes+    /// red. `![x](yyyy.png)` between the badge and the second `AB` is source the walk+    /// renders without moving the cursor (an image contributes no run, and its alt and+    /// `src` carry no reference to claim), so the correct match is 14 units AHEAD of the+    /// cursor and the fast path cannot reach it. A recorded bounded miss then rejects the+    /// search off the record and the run collapses from `(28, 2)` to `(28 -> 14, 0)`.+    @Test("O8: a miss inside a bound records nothing, so the same text still locates after")+    func boundedMissDoesNotSuppressALaterMatch() {+        let atCursor = Self.render("&#65;&#66;[^1]AB")+        #expect(atCursor.badgeSourceStarts == [10], "badge did not land on the reference")+        // Pre-badge: bounded miss -> zero-length at the cursor. Post-badge: the same text,+        // located verbatim. The second pair is the assertion; the first pins the shape that+        // makes it a bounded miss rather than an unbounded one.+        #expect(+            atCursor.runs.map { [$0.sourceStart, $0.length] } == [[0, 0], [14, 2]],+            Comment(rawValue: "expected [[0, 0], [14, 2]], got"+                + " \(atCursor.runs.map { [$0.sourceStart, $0.length] })")+        )++        // The mutation-verified case: the same bounded miss, but the later match is out of+        // the fast path's reach.+        //+        //     0        5        10   14             28+        //     &#65;    &#66;    [^1] ![x](yyyy.png)  AB+        let ahead = Self.render("&#65;&#66;[^1]![x](yyyy.png)AB")+        #expect(ahead.badgeSourceStarts == [10], "badge did not land on the reference")+        let coordinates = ahead.runs.map { [$0.sourceStart, $0.length] }+        #expect(+            coordinates == [[0, 0], [28, 2]],+            Comment(rawValue: "expected [[0, 0], [28, 2]] — a bounded miss recorded as"+                + " proved-absent would collapse the post-image run to [14, 0]."+                + " Got \(coordinates)")+        )+    }+}
prismTests/WebRendering/InlineRenderCorpusEquivalenceTests.swift Added +239 / -0
diff --git a/prismTests/WebRendering/InlineRenderCorpusEquivalenceTests.swift b/prismTests/WebRendering/InlineRenderCorpusEquivalenceTests.swiftnew file mode 100644index 0000000..6d1fac3--- /dev/null+++ b/prismTests/WebRendering/InlineRenderCorpusEquivalenceTests.swift@@ -0,0 +1,239 @@+//+//  InlineRenderCorpusEquivalenceTests.swift+//  prismTests+//+//  The differential corpus behind T-1966, committed rather than described.+//+//  WHY THIS EXISTS. T-1966 made `InlineHTMLRenderer.Walker.locate` memoise its negative+//  results (`provedAbsent`, `sourceUnits`) and short-circuit a match at the cursor. Every+//  one of those is claimed to be EXACT: they change which work is done, never which answer+//  comes out. That claim is not provable by reading — the failure this project has actually+//  hit (PR #326 round 4) was a hand-trace concluding "correct" over wrong code — so it was+//  established empirically, by rendering a generated corpus before and after the change and+//  comparing the whole result. The original probe was scratch and unreproducible, which is+//  what this file fixes (PR #343 review).+//+//  WHAT IS COMPARED. Everything `render` returns: the HTML byte-for-byte, every run's+//  `(runID, sourceStart, length)`, and `badgeSourceStarts`. Reduced to an FNV-1a digest per+//  500-sample chunk so a mismatch names the chunk. A digest over the HTML alone would miss+//  the coordinates, which is precisely what the memo could break (see `O7` in+//  `InlineSourceMapScanGrowthTests`: `locate`'s return value cannot reach the HTML at all).+//+//  THE CORPUS. 4 000 samples, each 1-8 fragments drawn with replacement from a fixed+//  alphabet covering text position, emphasis/strong/strikethrough, link text, link+//  destination and title, autolinks, image alt/`src`/title, inline code, raw inline HTML,+//  HTML comments, escapes, entities, hard and soft breaks, list markers, and+//  live/escaped/unresolvable footnote references. Drawing WITH REPLACEMENT from a small+//  alphabet is what makes the corpus reach the memo paths at all: the same failing text+//  (`A` spelled `&#65;`, `a*b` spelled `a\*b`) recurs within a sample, which is the only+//  shape that hits `provedAbsent` — pinned explicitly by `memoHitSamples` below so a future+//  edit to the alphabet cannot quietly stop exercising it.+//+//  DETERMINISM. A seeded SplitMix64, never `SystemRandomNumberGenerator` or a clock, so the+//  corpus is the same corpus on every machine and every run. Nothing here measures time.+//+//  ORIGIN OF THE GOLDENS. `chunkDigests` was produced by running this exact generator+//  against `origin/main` at 09cd828 (the branch point, i.e. WITHOUT the T-1966 change) and+//  against the fix, and confirming the two agreed on all eight chunks and all 16 239 runs.+//  So the pinned values are not merely "what the code does today" — they are the pre-fix+//  values, which is what makes them evidence of equivalence rather than a snapshot.+//+//  RE-BLESSING PROTOCOL. A digest mismatch means inline rendering changed for some sample+//  in that chunk. That is not automatically a bug — a deliberate change to the emitter, to+//  the walker's badge rules, or a swift-markdown upgrade will all move it. It is, however,+//  ALWAYS deliberate. To re-bless:+//+//    1. Establish WHY it moved. Re-run with `PRISM_INLINE_CORPUS_FULL=1` to see which+//       chunks moved; bisect a chunk by rendering its samples individually against the+//       previous commit and diffing `html` / `runs` / `badgeSourceStarts`.+//    2. Confirm the new output is the intended one for those samples, in a test that+//       asserts the behaviour by name — never by digest alone.+//    3. Only then update `chunkDigests`, and say in the commit message what changed and+//       which samples moved. A commit that updates these values with no such explanation+//       is a review blocker.+//+//  Never "fix" a mismatch by regenerating the numbers.+//++import Foundation+import Testing+@testable import prism++// MARK: - Deterministic generator++/// SplitMix64 — a seeded PRNG, so the corpus is a fixed corpus rather than a fresh one per+/// run. `SystemRandomNumberGenerator` would make the digests meaningless.+struct SeededSplitMix64: RandomNumberGenerator {+    private var state: UInt64++    init(seed: UInt64) { self.state = seed }++    mutating func next() -> UInt64 {+        state &+= 0x9E37_79B9_7F4A_7C15+        var z = state+        z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9+        z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB+        return z ^ (z >> 31)+    }+}++/// The corpus generator. Changing ANY of `fragments`, `sample`, `seed` or `digest` changes+/// every pinned digest — see the re-blessing protocol in the file header.+enum InlineRenderCorpus {++    static let seed: UInt64 = 0x5EED_1966+    static let chunkSize = 500+    static let chunkCount = 8++    /// Fragments whose rendered text does NOT occur verbatim in their own source, so a+    /// sample repeating one of them is a `provedAbsent` hit rather than merely a miss.+    static let memoHitFragments: Set<String> = [+        "*&#65;* ", "*a\\*b* ", "**&#65;** ", "~~&#65;~~ ", "[&#65;](e) ",+        "*[^1]&#65;* ", #"a\*b "#+    ]++    static let fragments: [String] = [+        "plain ", "A ", "AB ", "text ",+        "&#65; ", "&#66; ", "&#65;&#66; ", "&amp; ", "&lt;tag&gt; ",+        #"\* "#, #"\\ "#, #"\[ "#, #"\] "#, #"a\*b "#, #"\[\^1\] "#,+        "*&#65;* ", "*a\\*b* ", "**&#65;** ", "~~&#65;~~ ", "*plain* ", "**AB** ",+        "[&#65;](e) ", "[text](http://x/a) ", #"[t](http://x/[^1]) "#,+        #"[t](u "ti [^2]") "#, "[see [^1]](u) ", "[](http://x/[^1]) ",+        "<http://x/a> ", #"<http://x/[^1]> "#,+        "`code` ", "`[^1]` ", "`&#65;` ",+        "![alt](img.png) ", "![[^1]](img.png) ", #"![*a*[^1]](img.png) "#,+        #"![c](c.png "t [^2]") "#, "![&#65;](i.png) ",+        "<span title=\"[^1]\">x</span> ", "<em>raw</em> ", "<!-- note [^1] --> ",+        "[^1] ", "[^2] ", "[^9] ", "[^1][^2] ",+        "*[^1]&#65;* ", "**[^2]AB** ",+        "line  \nnext ", "soft\nbreak ", "- item ", "1. item ", "3) item ",+        "  ", "word ", "the ", "&#65;the "+    ]++    /// One sample: 1-8 fragments drawn WITH replacement (see the header — repetition is+    /// what reaches the memo).+    static func sample(_ generator: inout SeededSplitMix64) -> [String] {+        let count = Int.random(in: 1...8, using: &generator)+        return (0..<count).map { _ in+            fragments[Int.random(in: 0..<fragments.count, using: &generator)]+        }+    }++    static func samples(count: Int) -> [[String]] {+        var generator = SeededSplitMix64(seed: seed)+        return (0..<count).map { _ in sample(&generator) }+    }++    /// `[^9]` is deliberately undefined, so unresolvable references are in the corpus too.+    static let footnotes = FootnoteRenderProbe.footnotes(["1", "2"])++    /// FNV-1a over the WHOLE result of every sample in the slice: html, then every run's+    /// id/start/length, then the badge offsets.+    static func digest(_ slice: ArraySlice<[String]>) -> (digest: String, runs: Int) {+        var hash: UInt64 = 0xCBF2_9CE4_8422_2325+        var runCount = 0+        func feed(_ string: String) {+            for byte in string.utf8 {+                hash ^= UInt64(byte)+                hash = hash &* 0x0000_0100_0000_01B3+            }+        }+        for fragments in slice {+            var allocator = 0+            let result = InlineHTMLRenderer.render(+                source: fragments.joined(), footnotes: footnotes, runIDAllocator: &allocator+            )+            feed(result.html)+            for run in result.runs {+                feed("\(run.runID),\(run.sourceStart),\(run.length);")+                runCount += 1+            }+            feed("|\(result.badgeSourceStarts.map(String.init).joined(separator: ","))|")+        }+        return (String(format: "%016llX", hash), runCount)+    }+}++// MARK: - Tests++@Suite("Inline render — seeded corpus equivalence (T-1966)")+struct InlineRenderCorpusEquivalenceTests {++    /// Digests of the 500-sample chunks, in order. Produced on `origin/main` at 09cd828+    /// (pre-fix) and confirmed identical post-fix — see "Origin of the goldens" above.+    private static let chunkDigests = [+        "1AA6609847865380", "A58BB549EB90BD4B", "180EB38E91B6FCED", "CD5CA1D59960BEF0",+        "E09974A38DFDF7DC", "E1C924C82F5F43FE", "E9CD6F4D8571A83E", "4309C87D69DFA0FE"+    ]++    /// Only the first chunk runs by default (~0.1 s); the whole 4 000 is opt-in, because+    /// the point of the corpus is reproducibility, not sweeping every build.+    ///+    /// From the command line the variable has to be forwarded to the test process with+    /// xcodebuild's `TEST_RUNNER_` prefix — the runner does not inherit xcodebuild's own+    /// environment:+    ///+    ///     TEST_RUNNER_PRISM_INLINE_CORPUS_FULL=1 xcodebuild test … \+    ///       -only-testing:prismTests/InlineRenderCorpusEquivalenceTests+    private static var chunksToRun: Int {+        ProcessInfo.processInfo.environment["PRISM_INLINE_CORPUS_FULL"] == "1"+            ? InlineRenderCorpus.chunkCount : 1+    }++    @Test("E1: the seeded corpus renders to its pinned digests (html + every run + badges)")+    func corpusMatchesPinnedDigests() {+        let chunks = Self.chunksToRun+        let samples = InlineRenderCorpus.samples(+            count: chunks * InlineRenderCorpus.chunkSize+        )+        for chunk in 0..<chunks {+            let lower = chunk * InlineRenderCorpus.chunkSize+            let slice = samples[lower..<(lower + InlineRenderCorpus.chunkSize)]+            let (digest, runs) = InlineRenderCorpus.digest(slice)+            #expect(+                runs > 0,+                Comment(rawValue: "chunk \(chunk + 1) recorded no runs at all — the corpus"+                    + " rendered nothing, so its digest proves nothing")+            )+            #expect(+                digest == Self.chunkDigests[chunk],+                Comment(rawValue: "chunk \(chunk + 1) digest \(digest) !="+                    + " \(Self.chunkDigests[chunk]) — inline rendering changed for some"+                    + " sample in this chunk. Read the RE-BLESSING PROTOCOL at the top of"+                    + " this file before touching the pinned values.")+            )+        }+    }++    @Test("E2: the corpus actually reaches the memo — samples repeat a failing fragment")+    func corpusReachesTheMemoPaths() {+        // `provedAbsent` is only READ when the same absent text is searched for twice, so a+        // corpus of samples that each use a failing fragment at most once would exercise+        // the recording and never the rejection. Pinning that the generator produces+        // repeats is what stops a future alphabet edit silently hollowing out E1.+        //+        // 93 of the 4 000 samples repeat a memo-hit fragment VERBATIM for this seed and+        // alphabet, and that is a LOWER bound on the samples that actually hit the memo:+        // `*&#65;* `, `**&#65;** `, `~~&#65;~~ `, `[&#65;](e) ` and `*[^1]&#65;* ` are five+        // distinct fragments whose text nodes all render the same absent text `A`, so a+        // sample mixing any two of them hits `provedAbsent` without repeating a fragment.+        // Asserting the exactly-countable lower bound keeps the test free of assumptions+        // about how cmark splits text nodes.+        let samples = InlineRenderCorpus.samples(+            count: InlineRenderCorpus.chunkCount * InlineRenderCorpus.chunkSize+        )+        let repeating = samples.count { sample in+            var seen: Set<String> = []+            return sample.contains { fragment in+                InlineRenderCorpus.memoHitFragments.contains(fragment)+                    && !seen.insert(fragment).inserted+            }+        }+        #expect(+            repeating >= 90,+            Comment(rawValue: "only \(repeating) of \(samples.count) samples repeat a"+                + " memo-hit fragment (93 expected) — the corpus no longer exercises"+                + " provedAbsent, so E1's digests stopped covering the memo")+        )+    }+}
prismTests/WebRendering/GrowthRatioGuard.swift Added +98 / -0
diff --git a/prismTests/WebRendering/GrowthRatioGuard.swift b/prismTests/WebRendering/GrowthRatioGuard.swiftnew file mode 100644index 0000000..fc32eaf--- /dev/null+++ b/prismTests/WebRendering/GrowthRatioGuard.swift@@ -0,0 +1,98 @@+//+//  GrowthRatioGuard.swift+//  prismTests+//+//  The shared growth-ratio assertion for the "is this pass linear or quadratic?"+//  question, hoisted out of `FootnoteBadgeGrowthTests` so every suite that asks it+//  asks it the same way (recommended by T-1951, applied by T-1966).+//+//  Why a RATIO and not an absolute budget. One timing at one input size cannot+//  separate a linear scan from a quadratic one — it only says "this machine took+//  this long today" (the naming lesson from T-1655). Absolute budgets in this+//  project are also documented as flaky under concurrent `xcodebuild` load (T-1541),+//  while a ratio taken from two sizes in the same process is not: contention scales+//  both measurements together and cancels out of the quotient.+//+//  Quadrupling the input costs ~4x when the pass is linear and ~16x when it is+//  quadratic, so the default 8x ceiling leaves 2x headroom above linear and sits 2x+//  below quadratic.+//++import Foundation+import Testing++enum GrowthRatioGuard {++    /// Runs `work` at `baseCount` and at `baseCount * multiplier` and asserts the cost+    /// grew like N, not like N².+    ///+    /// Each measurement is the FASTEST of `samples` runs, which is the right statistic+    /// here: the quantity being estimated is the work the algorithm does, and every+    /// source of noise (scheduler pre-emption, page faults, a concurrent build) only+    /// ever adds time. Warm-up runs at the base size come first so first-call costs —+    /// lazy globals, allocator growth — land outside the smaller measurement, where+    /// they would otherwise inflate the denominator and hide a real quadratic.+    ///+    /// - Parameters:+    ///   - shape: what is being repeated, for the failure message.+    ///   - baseCount: N.+    ///   - multiplier: the factor between the two sizes.+    ///   - ceiling: the largest acceptable cost ratio.+    ///   - work: performs the pass at a given repeat count.+    static func expectLinearGrowth(+        shape: String,+        baseCount: Int,+        multiplier: Int = 4,+        ceiling: Double = 8.0,+        warmups: Int = 3,+        samples: Int = 3,+        sourceLocation: SourceLocation = #_sourceLocation,+        work: (Int) -> Void+    ) {+        // A ratio taken at a multiplier below 2 does not separate linear from quadratic —+        // at 1 the two sizes are the same size and the "ratio" is pure noise — and a base+        // count of 0 makes the denominator meaningless. Both are caller mistakes that would+        // otherwise show up as a mysteriously passing guard (PR #343 review).+        guard multiplier >= 2, baseCount > 0 else {+            Issue.record(+                Comment(rawValue: "\(shape): growth guard needs baseCount > 0 and"+                    + " multiplier >= 2 to mean anything — got \(baseCount) and"+                    + " \(multiplier)"),+                sourceLocation: sourceLocation+            )+            return+        }+        for _ in 0..<warmups { work(baseCount) }+        let largeCount = baseCount * multiplier+        let smallMs = fastestMilliseconds(runs: samples) { work(baseCount) }+        let largeMs = fastestMilliseconds(runs: samples) { work(largeCount) }+        let ratio = largeMs / smallMs+        let detail = String(+            format: "%@ — %d: %.2fms, %d: %.2fms, ratio %.2fx",+            shape, baseCount, smallMs, largeCount, largeMs, ratio+        )++        #expect(+            smallMs > 0,+            "baseline must be resolvable for the ratio to mean anything — \(detail)",+            sourceLocation: sourceLocation+        )+        #expect(+            ratio < ceiling,+            Comment(rawValue: "multiplying \(shape) by \(multiplier) should cost roughly"+                + " \(multiplier)x, not ~\(multiplier * multiplier)x — \(detail)"),+            sourceLocation: sourceLocation+        )+    }++    /// The fastest of `runs` executions of `body`, in milliseconds.+    static func fastestMilliseconds(runs: Int, of body: () -> Void) -> Double {+        let fastest = (0..<max(1, runs)).map { _ -> Duration in+            let start = ContinuousClock.now+            body()+            return ContinuousClock.now - start+        }.min()!+        return Double(fastest.components.seconds) * 1000.0+            + Double(fastest.components.attoseconds) / 1_000_000_000_000_000.0+    }+}
prismTests/WebRendering/FootnoteBadgeGrowthTests.swift Modified +18 / -29
diff --git a/prismTests/WebRendering/FootnoteBadgeGrowthTests.swift b/prismTests/WebRendering/FootnoteBadgeGrowthTests.swiftindex 6d6f2d8..e88c2c6 100644--- a/prismTests/WebRendering/FootnoteBadgeGrowthTests.swift+++ b/prismTests/WebRendering/FootnoteBadgeGrowthTests.swift@@ -5,11 +5,9 @@ //  Growth-ratio guards for footnote badge substitution (T-1716/T-1945 redesign). // //  These are ratio assertions, not absolute budgets, and they are named for what they-//  assert. One budget at one input size cannot separate a linear scan from a quadratic-//  one (the naming lesson from T-1655), and absolute timing budgets in this project are-//  documented as flaky (T-1541) while ratio guards are not. Quadrupling the reference-//  count costs ~4x when the pass is linear and ~16x when it is quadratic, so the 8x-//  ceiling leaves 2x headroom above linear and sits 2x below quadratic.+//  assert. The methodology — and the reasoning behind it — now lives in the shared+//  `GrowthRatioGuard`, hoisted out of this suite by T-1966 so every suite asking+//  "linear or quadratic?" asks it the same way. // //  Four shapes, because each stresses a different part of the pipeline: //   - ESCAPED: no token is substituted, so nothing splits and the parsed text does not@@ -28,11 +26,13 @@ import Foundation import Testing @testable import prism -@Suite("Footnote badge substitution — growth")+// `.serialized`: every test here is a timing measurement, and running them concurrently+// puts the base and the 4x measurement under different contention — the noise the ratio+// exists to cancel (PR #343 review).+@Suite("Footnote badge substitution — growth", .serialized) struct FootnoteBadgeGrowthTests {      private static let baseCount = 800-    private static let largeCount = 3_200      private func emit(_ markdown: String) {         _ = BlockHTMLEmitter.emit(@@ -42,38 +42,11 @@ struct FootnoteBadgeGrowthTests {         )     } -    private func fastestMilliseconds(runs: Int, of body: () -> Void) -> Double {-        let fastest = (0..<runs).map { _ -> Duration in-            let start = ContinuousClock.now-            body()-            return ContinuousClock.now - start-        }.min()!-        return Double(fastest.components.seconds) * 1000.0-            + Double(fastest.components.attoseconds) / 1_000_000_000_000_000.0-    }-     /// Measures `unit` repeated at N and 4N and asserts the cost grew like N, not N².     private func expectLinearGrowth(of unit: String, shape: String) {-        let small = String(repeating: unit, count: Self.baseCount)-        let large = String(repeating: unit, count: Self.largeCount)--        // Warm up so first-call costs land outside the smaller measurement, which would-        // otherwise deflate the ratio.-        for _ in 0..<3 { emit(small) }--        let smallMs = fastestMilliseconds(runs: 3) { emit(small) }-        let largeMs = fastestMilliseconds(runs: 3) { emit(large) }-        let ratio = largeMs / smallMs-        let ratioText = String(format: "%.2f", ratio)-        print("\(shape) — \(Self.baseCount): \(smallMs)ms, \(Self.largeCount): \(largeMs)ms,"-              + " ratio: \(ratioText)")--        #expect(smallMs > 0, "baseline must be resolvable for the ratio to mean anything")-        #expect(-            ratio < 8.0,-            Comment(rawValue: "quadrupling \(shape) references should cost roughly 4x, not ~16x —"-                + " got \(ratioText)x (\(smallMs)ms -> \(largeMs)ms)")-        )+        GrowthRatioGuard.expectLinearGrowth(shape: shape, baseCount: Self.baseCount) { count in+            emit(String(repeating: unit, count: count))+        }     }      @Test("E1: a long run of escaped references scales linearly")
docs/agent-notes/webview-rendering-status.md Modified +1 / -0
diff --git a/docs/agent-notes/webview-rendering-status.md b/docs/agent-notes/webview-rendering-status.mdindex 9fe9356..c2727a5 100644--- a/docs/agent-notes/webview-rendering-status.md+++ b/docs/agent-notes/webview-rendering-status.md@@ -159,6 +159,7 @@ source rather than shapes seen today. - **`.fileImporter` / pickers present reliably at LAYOUT level, not from the nested DocumentScrollContent** — drive folder pickers from the layout (coordinator-flag) or from a sheet's own context. - **CSS `:has()` does not reliably re-evaluate on a `td` when a child is inserted live** (e.g. a note dot added during the session) — for "hide X when sibling Y appears" driven by live DOM mutation, suppress explicitly in JS, not via `:has()`. - **`InlineHTMLRenderer` run offsets are always relative to the string it is handed** (its source cursor restarts at 0 on every call), and the runs are consumed against `MarkdownBlock.textContent`, which JOINS the sub-spans (cells with `" | "`, rows/items with `"\n"`). A sub-span caller that appended its runs unrebased mis-anchored every selection notes made past the first cell/item — invisible in the emitted HTML, visible only when run offsets are compared against `textContent`. Closed structurally in **T-1941**: `BlockHTMLEmitter.renderInline` takes a **required** `InlineSpan` (`.wholeBlock` / `.subspan(offset:)` / `.unmapped`), is the only place that appends to `context.blockRuns`, and does the rebase itself — so a new caller cannot omit it, only state it wrongly. Offsets come from `MarkdownBlock.tableCellTextOffsets` / `listItemTextOffsets`, which live beside `textContent` and use the same separator constants, so the two sides cannot drift. Anything absent from `textContent` (nested list items, continuation paragraphs, nested blocks, a `<details>` child list, a nested `<details>` summary, the child-less blockquote fallback) is `.unmapped` → selection declined (Decision 8), never mis-anchored; mapping the nested cases would need `textContent` widened to contain them, tracked as **T-2032** — it is a scoped-out limitation, not a defect. `EmittedDocument.badgeSourceStarts` is deliberately NOT rebased: it is keyed by the inline source string and matched against that string's own occurrence scan in `SearchStateFeeder` (T-1853). Regression guard: `WebStructuredSourceMapInvariantTests.parityCorpusRunsMonotonic` sweeps the whole parity fixture corpus asserting runs are monotonic, non-overlapping AND within their block's `textContent` UTF-16 length — the upper bound is what catches a Character-count offset, which stays monotonic and would otherwise pass.+- **`InlineHTMLRenderer.Walker.locate` is a naive forward scan, and it is only affordable because failed scans are recorded** (T-1966). The search cannot be BOUNDED in the general case — text legitimately sits far ahead of the cursor whenever the walk skipped source it does not account for (a long image `src`, a long raw-HTML span), so only the pre-badge segment (PR #326) and `claimOccurrences` (T-1992) get bounds. Without a record, a text node whose rendered text does not occur verbatim in the source — the entity/escape family, `A` spelled `&#65;` — scanned to the end of the block, failed, left the cursor where it was, and the next such node re-derived the same scan: `*&#65;* ` x 3200 took 12s. Two exact rejections fix it: `provedAbsent` (a text an unbounded scan proved absent stays absent, capped at 256 entries so the T-2034 class cannot grow it with the block) and a lazily-built `sourceUnits` bitset (a text holding a unit the source does not hold cannot occur in it anywhere; a bitset over the 16-bit domain rather than a `Set`, because the probe runs once per unit of every later text and hashing dominated it). A match at the cursor is tested BEFORE either memo, so the common case — text sitting exactly where the walk expects it — pays for neither. **`provedAbsent` rests on `cursor` never moving backwards**, which is why `locate` takes NO `from:` parameter and reads `cursor` itself: the precondition is structural, not documented. Every mutation of `cursor` is forward, but one of them is only forward *because of the pre-badge bound* — `appendFootnoteBadge` steps to the occurrence's end, and the `appendVisible` before it must stay bounded by `occurrence.sourceStart` or the cursor could overshoot; weakening that bound breaks the memo, not just performance. No rejection changes any output: digests over a 4000-sample generated corpus (html + every run + `badgeSourceStarts`) are byte-identical to `origin/main` at 09cd828, and that corpus is now COMMITTED (`InlineRenderCorpusEquivalenceTests`, seeded SplitMix64, chunk digests pinned, re-blessing protocol in the file header; `PRISM_INLINE_CORPUS_FULL=1` for all 4000). Residual, deliberately open: a distinct-per-node text absent from the source whose every unit is present still costs a scan each — **T-2034**, needs a source index to close. Guards: `InlineSourceMapScanGrowthTests` (G1-G8 growth over the shared `GrowthRatioGuard`, O1-O6 rendered-text-in-order + run invariants, O7 exact pre-fix run coordinates on memo-HIT fixtures, O8 the recording gate — a bounded miss must record nothing or a later unbounded match is silently suppressed). - **`FootnotePopoverWebPage.reset()` must reload** to actually clear the live page (updating the served-HTML box alone leaves the prior content in the WebContent process). - **Live-WebPage test harness wedges intermittently** (launchservicesd / XPC / "Sandbox restriction"). Stale `prism.app`/`xctest`/`xcodebuild`/`testmanagerd` processes are a cause — `pkill -9` before a run. `livePresentAndReplace` passing while another live test fails means the harness is fine and it's a real assertion. - **`xcodebuild test` hangs** in post-test xcresult finalization (it builds prismUITests). Use `build-for-testing` then `test-without-building` with `NSUnbufferedIO=YES`; the process **exit code is authoritative**. Run targeted classes with `-only-testing:prismTests/<Class>` to avoid the hang.
CHANGELOG.md Modified +1 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex df07a19..c95e42b 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0  ### Fixed +- Opening a document that writes characters as HTML entities or backslash escapes inside emphasis, bold, or link text is no longer slow enough to matter (T-1966). Text written `*&#65;*` shows an `A`, but the file spells it `&#65;` — so while working out which part of the file each word on screen came from, which is what lets you select text and attach a note to it, the app searched the rest of the paragraph for an `A`, found none, and then searched the same stretch again for the next word, and again for the one after. A paragraph of 3,200 such words took 12.4 seconds to render; it now takes 57 milliseconds, and the cost grows in step with the length of the document rather than with its square. Nothing about the result changes — the same text, the same footnote badges, in the same order, with notes anchoring exactly where they did before, which was checked by rendering four thousand generated samples before and after and comparing every character and every anchor position. Because a document can be opened from a URL, a file written this way could previously have kept a device busy for a long time on someone else's behalf. The three examples originally reported for this fault had already stopped being slow through earlier fixes, and are now pinned by growth guards so they cannot come back. One deliberately-constructed shape is not covered: where every repetition writes a *different* word the file spells some other way, the cost still grows with the square of the document's length, as this fault did. That is tracked separately (T-2034). - Adding a note to text in a paragraph that also mentions a footnote reference inside an image, a link address, an image tooltip, or raw HTML now works, as long as that reference is written out plainly (T-1992). In a paragraph like `![[^1]](cat.png) choose [^1] after` — or the same with the reference written inside a link address such as `[link](http://example.com/[^1])`, inside an image's tooltip text such as `![cat](cat.png "see [^1]")`, or inside raw HTML — the badge still appeared in the right place, but the app matched it to the reference-shaped text inside the image, address or tooltip rather than the real one. Selecting the words in between and choosing **Add Note** was then declined, or saved a note quoting the wrong text and pointing at the image or link syntax, which the note carried into relocation and inline-note export. Stepping search onto such a footnote could also mark the wrong badge. Text that merely looks like a reference in those positions is now accounted for, so the words either side of a badge map to what you actually selected. Two spellings are not covered yet and still behave as they did before: an image whose alt text mixes the reference with formatting, as in `![*a*[^1]](cat.png) choose [^1] after`, and a link address that writes a character as an HTML entity, as in `[link](x&amp;/[^1]) choose [^1] after`. In both the app cannot line the text up with your document and deliberately leaves it alone, so a selection over the words before the badge is still declined — tracked under T-2033. This is separate from the earlier fix for selecting after a badge (T-1876); footnotes inside list items and table cells are still tracked separately. - Adding a note to text selected inside a table cell or a list item now quotes the text you actually selected (T-1941). Selecting a word in the second cell of a row, in any row after the first, or in any list item after the first quoted text from the start of the table or list instead — and saving stored a wrong source range, which the note then carried into relocation and inline-note export. Only the very first cell and the very first list item behaved correctly. The rendered document's text-to-source map now records every cell and every item at its real position within the block's text, so a note anchors where you put it. A few places where the map used to record an anchor that could only ever be wrong now record none at all: a nested list's items, a list nested inside a quote or another list item, a list inside a collapsible `<details>` section, the summary of a `<details>` nested inside another, and the rare quote the parser cannot break into parts. Selecting text in one of those and reaching for **Add note** now declines quietly instead of quoting text from elsewhere in the block — the block's own **+** button still adds a note, as does the **+** beside each item of a nested list. Anchoring a selection in those places is tracked separately (T-2032). This was the same fault as the footnote-selection fix below (T-1876) on a different path; every place in the renderer that draws part of a block must now state where that part sits, so the next one cannot repeat it. - Footnote popovers now use the same text settings as the document (T-1978). A footnote's content ignored the **Body Font** you chose, the in-app **Text Size** slider, and the system text size (iOS Larger Text / macOS Text Size), always rendering in the system font at the default reading size — so footnote text could be noticeably smaller than the document it belongs to, and at accessibility text sizes it stayed small while everything around it grew. Footnote content now follows the same font, scale, and system text size as document text. A font that is no longer installed still falls back to the system font, and a font name is applied as text only, so it cannot alter the popover's styling. The system **Increase Contrast** setting had been missed in the same way and now reaches popovers too, though the only footnote content that looks different for it today is an HTML comment shown inline, which kept the standard low-contrast grey. A popover already open when you change these settings updates in place, as of the fix below (T-1979).

Things to double-check

The pre-badge bound is now load-bearing for correctness, not just speed.

appendFootnoteBadge's forward step depends on appendVisible being bounded by occurrence.sourceStart. If a future change relaxes or removes that searchLimit — for a plausible-sounding reason like 'the bound loses a mapping' — the cursor can overshoot the occurrence, cursor monotonicity breaks, and provedAbsent starts returning stale nils that collapse runs. The failure is silent: no crash, no test in O1-O6 fires, only note anchoring quietly degrades. Both the locate doc comment and the agent-note say this, which is the right mitigation — but it is worth knowing it is the single sharpest edge this change introduces.

Ten timing-based tests now run in the default plan.

G1-G8 plus the four pre-existing footnote growth tests add roughly 2.4 s and, more importantly, twelve ratio assertions that can in principle flake under heavy CI contention. Both suites are .serialized, which only serialises within a suite — other suites still run concurrently in-process. The 8x ceiling with 2x headroom above linear and the fastest-of-3 statistic are the right defences, and this matches the precedent FootnoteBadgeGrowthTests already set. Worth watching for flakes over the next few CI runs rather than acting on now.

Golden digests will need re-blessing on a swift-markdown bump.

The corpus digests are over emitter output, so any deliberate change to BlockHTMLEmitter, to the walker's badge rules, or a cmark/swift-markdown upgrade will move them. The re-blessing protocol in the file header is explicit and even declares that an unexplained digest update is a review blocker — good — but reviewers of future PRs need to actually apply it rather than waving through a numbers-only diff.