×ばつ cliff, hit by expanding cards, searching, and switching days. Replaced with a true LRU on a logical clock, evicting the coldest quarter so the sort amortizes. Cap raised 2000 → 6000 (first paint alone touches ~935 uniques, ~443 KB of source text). Correction to an earlier reading: the cap was not the cause of slow first load — the first-paint working set fits under the old cap. It's a latency cliff on interaction, not on load. Testing 530 tests in 97 suites pass, including ParserContractTests (three-repo corpus untouched). New: SplitSubjectBodyTests (curated + differential + cost-curve), InlineMarkdownCacheTests (LRU retention, boundedness, coldest-first eviction, hit==miss), and hash-digit fast-path tests. The scaling tests assert the cost curve, not a wall-clock budget — a hard millisecond bound would be flaky, so they check that doubling the input doesn't quadruple the time. They fail on the old implementation (ratio 3.26; 16k line at 197 ms) and pass on the new one. Caveats All figures are Debug. Release couldn't be measured in-app (@testable needs testability, and forcing it on Release fails because Run.make is #if DEBUG). The parse figures were cross-checked standalone at -O and are Release-credible. DebouncedFileEventsTests.coalescesBurstIntoFewTrailingEvents failed once during this work and passed on 4 subsequent runs. It's a timing/debounce test and nothing here touches file events — flagging it as observed pre-existing flakiness, not investigated. 🤖 Generated with Claude Code">
Skip to content

Navigation Menu

Sign in
Sign up

perf(action-items): de-quadratic the parse, guard the linkifier, LRU the markdown cache #100

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
jordanrburger wants to merge 1 commit into main
base: main
Choose a base branch
Loading
from claude/action-items-perf-hot-paths
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 69 additions & 44 deletions Scout/ActionItems/ActionItemsParser.swift
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -778,65 +778,90 @@ extension ActionItemsParser {
/// and ``[label](url)`` tokens. Falls back to ``": "`` separator. Mirrors
/// ``action-items/render.py`` ``_split_subject``.
static func splitSubjectBody(_ rest: String) -> (String, String) {
let separators = [" — ", " – ", " - "]
if let idx = firstSeparatorOutsideTokens(in: rest, separators: separators) {
for sep in separators {
let sepLen = sep.count
if rest.distance(from: idx, to: rest.endIndex) >= sepLen,
rest[idx ..< rest.index(idx, offsetBy: sepLen)] == sep {
return (
String(rest[..<idx]).trimmingCharacters(in: .whitespaces),
String(rest[rest.index(idx, offsetBy: sepLen)...]).trimmingCharacters(in: .whitespaces)
)
}
}
// Materialize the grapheme clusters once. Every index below is then an
// `Int` into this array, which is what keeps the scan O(n): the previous
// version walked `String.Index` and asked `text.distance(from:to:)` —
// itself O(n) — three times per character, making the whole function
// quadratic in line length. Task lines run to ~9,700 characters, so that
// cost 465 ms at `-O` across one day's file, 68% of the entire parse.
let chars = Array(rest)

if let hit = firstSeparatorOutsideTokens(in: chars, separators: dashSeparators) {
return split(chars, at: hit)
}
if let idx = firstSeparatorOutsideTokens(in: rest, separators: [": "]) {
let sepLen = 2
return (
String(rest[..<idx]).trimmingCharacters(in: .whitespaces),
String(rest[rest.index(idx, offsetBy: sepLen)...]).trimmingCharacters(in: .whitespaces)
)
if let hit = firstSeparatorOutsideTokens(in: chars, separators: colonSeparator) {
return split(chars, at: hit)
}
return (rest, "")
}

private static func firstSeparatorOutsideTokens(in text: String, separators: [String]) -> String.Index? {
/// ` — ` / ` – ` / ` - `, in the order the scanner tries them at each
/// position. Hoisted so the arrays aren't rebuilt per task line.
private static let dashSeparators: [[Character]] = [
Array(" — "), Array(" – "), Array(" - "),
]
private static let colonSeparator: [[Character]] = [Array(": ")]

private static func split(
_ chars: [Character],
at hit: (index: Int, length: Int)
) -> (String, String) {
(
String(chars[..<hit.index]).trimmingCharacters(in: .whitespaces),
String(chars[(hit.index + hit.length)...]).trimmingCharacters(in: .whitespaces)
)
}

/// First separator lying outside `**bold**`, `~~strike~~`, `` `code` ``,
/// `[[wikilink]]` and `[label](url)` tokens, as a `(offset, length)` pair.
///
/// Returning the matched length as well as the offset lets the caller slice
/// directly. The original re-tested each separator at the returned index to
/// recover its length; because the scan already tries them in order at every
/// position, the first one that matched there is the same one, so this is
/// equivalent — and the `SplitSubjectBodyTests` differential pins it.
private static func firstSeparatorOutsideTokens(
in chars: [Character],
separators: [[Character]]
) -> (index: Int, length: Int)? {
var inBold = false, inStrike = false, inCode = false
var bracketDepth = 0, parenDepth = 0
var i = text.startIndex
while i < text.endIndex {
let rem = text[i...]
let two = rem.prefix(2)
let ch = text[i]
if ch == "`" && !inBold && !inStrike { inCode.toggle(); i = text.index(after: i); continue }
if inCode { i = text.index(after: i); continue }
if two == "**" { inBold.toggle(); i = text.index(i, offsetBy: 2); continue }
if two == "~~" { inStrike.toggle(); i = text.index(i, offsetBy: 2); continue }
if two == "[[" { bracketDepth += 1; i = text.index(i, offsetBy: 2); continue }
if two == "]]" && bracketDepth > 0 { bracketDepth -= 1; i = text.index(i, offsetBy: 2); continue }
if ch == "[" && bracketDepth == 0 { bracketDepth = 1; i = text.index(after: i); continue }
if ch == "]" && bracketDepth > 0 && two != "]]" {
var i = 0
let n = chars.count
while i < n {
let ch = chars[i]
let next: Character? = i + 1 < n ? chars[i + 1] : nil
if ch == "`" && !inBold && !inStrike { inCode.toggle(); i += 1; continue }
if inCode { i += 1; continue }
if ch == "*" && next == "*" { inBold.toggle(); i += 2; continue }
if ch == "~" && next == "~" { inStrike.toggle(); i += 2; continue }
if ch == "[" && next == "[" { bracketDepth += 1; i += 2; continue }
if ch == "]" && next == "]" && bracketDepth > 0 { bracketDepth -= 1; i += 2; continue }
if ch == "[" && bracketDepth == 0 { bracketDepth = 1; i += 1; continue }
if ch == "]" && bracketDepth > 0 && next != "]" {
bracketDepth = 0
let next = text.index(after: i)
if next < text.endIndex && text[next] == "(" {
if next == "(" {
parenDepth = 1
i = text.index(i, offsetBy: 2); continue
i += 2; continue
}
i = text.index(after: i); continue
i += 1; continue
}
if ch == ")" && parenDepth > 0 { parenDepth -= 1; i = text.index(after: i); continue }
if ch == ")" && parenDepth > 0 { parenDepth -= 1; i += 1; continue }
if !inBold && !inStrike && bracketDepth == 0 && parenDepth == 0 {
for sep in separators {
let sepLen = sep.count
if text.distance(from: i, to: text.endIndex) >= sepLen,
text[i ..< text.index(i, offsetBy: sepLen)] == sep {
return i
}
for sep in separators where matches(chars, at: i, sep) {
return (i, sep.count)
}
}
i = text.index(after: i)
i += 1
}
return nil
}

private static func matches(_ chars: [Character], at i: Int, _ sep: [Character]) -> Bool {
guard i + sep.count <= chars.count else { return false }
for k in 0 ..< sep.count where chars[i + k] != sep[k] {
return false
}
return true
}
}
27 changes: 27 additions & 0 deletions Scout/ActionItems/Views/GitHubRefLinkifier.swift
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ enum GitHubRefLinkifier {
pattern: #"github\.com/([A-Za-z0-9][\w.-]*/[A-Za-z0-9][\w.-]*)"#
)

/// Whether `s` holds a `#` immediately followed by an ASCII digit — the
/// necessary condition for `refRe` to match anything. One byte pass, no
/// allocation, no grapheme breaking.
static func containsHashDigit(_ s: String) -> Bool {
var previousWasHash = false
for byte in s.utf8 {
if previousWasHash, byte >= UInt8(ascii: "0"), byte <= UInt8(ascii: "9") {
return true
}
previousWasHash = byte == UInt8(ascii: "#")
}
return false
}

/// Spans that must not be rewritten: markdown links, wikilinks, inline code.
private static let protectedRes: [NSRegularExpression] = [
#"\[\[[^\]]*\]\]"#, // [[wikilink]] / [[target|alias]]
Expand All @@ -56,6 +70,19 @@ enum GitHubRefLinkifier {
].map { try! NSRegularExpression(pattern: 0ドル) }

static func linkify(_ s: String) -> String {
// Both branches of `refRe` need a `#` immediately followed by a digit
// (`owner/repo#123` or a bare `#123`), so anything else cannot produce
// a single rewrite — yet the scans below (three protected-range regexes
// plus two repo-inference regexes) ran over every string regardless.
// This sits on each `InlineMarkdownText` cache miss, i.e. the
// scroll-visible path.
//
// Testing for a bare `#` is not enough here: Scout vaults are dense
// with `[#TAG]` mnemonics and `#channel` names, so 1,089 of 2,307
// rendered strings on a real day contain a `#` and only a handful carry
// an issue ref. Requiring the digit takes the skip rate from 53% to 96%
// and this function from 129 ms to 12 ms over that set.
guard Self.containsHashDigit(s) else { return s }
let ns = s as NSString
let full = NSRange(location: 0, length: ns.length)

Expand Down
67 changes: 55 additions & 12 deletions Scout/ActionItems/Views/InlineMarkdownText.swift
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,32 @@ struct InlineMarkdownText: View {
/// that rebuilding it per body evaluation visibly stalls scrolling through
/// a full day of cards. Keys are the raw subject/body strings, which are
/// stable across parses for the same task text.
private static var cache: [String: AttributedString] = [:]
private static let cacheCap = 2000
private struct CacheEntry {
let value: AttributedString
/// Logical clock tick of the last read or write — the LRU ordering.
var lastUsed: UInt64
}

private static var cache: [String: CacheEntry] = [:]
private static var clock: UInt64 = 0

/// Upper bound on cached renderings.
///
/// A real day's first paint touches ~935 unique strings (~443 KB of source
/// text), and expanding cards, searching, or switching days pushes the
/// working set several thousand higher. `var` only so tests can shrink it;
/// nothing in the app mutates it. MainActor-isolated like the cache itself.
static var cacheCap = 6000

/// Internal rather than private so tests can assert on the rendered runs —
/// that a tag survives the markdown parse as a `scout-tag://` link and
/// carries the chip attributes. Pure function; no other caller.
static func attributedString(for raw: String) -> AttributedString {
if let hit = cache[raw] { return hit }
clock &+= 1
if let hit = cache[raw] {
cache[raw]?.lastUsed = clock
return hit.value
}
// Tags first: once a tag is a `[label](scout-tag://...)` link, the GitHub
// linkifier's protected ranges cover it. The two can't collide on the
// same token anyway — a tag needs a letter, a GitHub ref is all digits
Expand All @@ -100,18 +118,43 @@ struct InlineMarkdownText: View {
var computed = (try? AttributedString(markdown: rewritten, options: options))
?? AttributedString(rewritten)
styleTagChips(&computed)
if cache.count >= cacheCap {
// Evict an arbitrary half rather than flushing everything: a full
// clear at the cap means the very next render pass re-parses every
// visible string — the stall this cache exists to prevent.
for key in Array(cache.keys.prefix(cacheCap / 2)) {
cache.removeValue(forKey: key)
}
}
cache[raw] = computed
if cache.count >= cacheCap { evictColdest() }
cache[raw] = CacheEntry(value: computed, lastUsed: clock)
return computed
}

/// Drop the coldest quarter, oldest first.
///
/// The previous policy evicted `cache.keys.prefix(cap/2)` — *arbitrary*
/// dictionary order — so the strings currently on screen were as likely to
/// be thrown out as anything else. Past the cap that turned into a re-parse
/// treadmill: a warm pass over a 2,398-string working set measured ~320 ms
/// against 12 ms uncapped, a ×ばつ cliff hit by expanding cards, searching,
/// and switching days. Evicting by last use keeps the visible set resident,
/// so crossing the cap degrades gradually instead of falling off an edge.
///
/// A quarter at a time so the sort amortizes across many inserts rather
/// than running on every one past the cap.
private static func evictColdest() {
let excess = cache.count - (cacheCap * 3 / 4)
guard excess > 0 else { return }
let coldest = cache
.sorted { 0ドル.value.lastUsed < 1ドル.value.lastUsed }
.prefix(excess)
for (key, _) in coldest {
cache.removeValue(forKey: key)
}
}

// MARK: - Test seams

static var cacheCountForTesting: Int { cache.count }
static func cacheContainsForTesting(_ raw: String) -> Bool { cache[raw] != nil }
static func resetCacheForTesting() {
cache.removeAll()
clock = 0
}

/// Give every `scout-tag://` run the chip treatment: accent ink on an
/// accent wash, in a slightly smaller monospace face so a tag reads as an
/// object rather than prose.
Expand Down
73 changes: 73 additions & 0 deletions ScoutTests/ActionItems/GitHubRefLinkifierTests.swift
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,76 @@ struct GitHubRefLinkifierTests {
#expect(GitHubRefLinkifier.linkify(input) == input)
}
}

/// The fast-path guard added for the render hot path. Both branches of `refRe`
/// need a `#` immediately followed by a digit, so anything else must come back
/// byte-identical — if that ever stops holding, the guard would silently drop
/// rewrites rather than fail loudly, so pin the invariant it rests on.
@Suite("GitHubRefLinkifier — hash-digit fast path")
struct GitHubRefLinkifierFastPathTests {
@Test("Strings with no #digit are returned unchanged")
func noHashDigitRoundTrips() {
for s in [
"",
"Plain prose with no refs at all",
"example-org/scout is the repo",
"github.com/example-org/scout without a ref",
"[[people/alex]] and `code` and [label](https://example.com/a)",
"PROJ-1234 — Priya merged it",
"Numbers 1234 and 5678 but no hash",
"🚧 emoji and — an em dash",
// The case that makes the digit requirement worth having: Scout
// vaults are full of these, and none is a GitHub ref.
"[#PRGREISS] Prague reissue is still open",
"Discussed in #tmp-cuesta-star with [[people/priya]]",
"[#AI3026] and [#RSM] in one line",
"A trailing hash # and a lone #",
// Digit-leading tags DO clear the guard — `#5` is a hash followed
// by a digit — so they still pay for the regex scan. They must
// nonetheless come back untouched: `[#5864M]` is not `#5864` (the
// trailing `M` defeats refRe's `\b`), and it is bracket-protected.
"[#5864M] the reissue coupon",
] {
#expect(GitHubRefLinkifier.linkify(s) == s, "should be untouched: \(s)")
}
}

@Test("Alpha-leading tags and channel names skip the regex scan entirely")
func guardSkipsTagsAndChannels() {
for s in [
"[#PRGREISS] Prague reissue is still open",
"Discussed in #tmp-cuesta-star with [[people/priya]]",
"[#AI3026] and [#RSM] in one line",
"A trailing hash # and a lone #",
"no hash at all",
] {
#expect(!GitHubRefLinkifier.containsHashDigit(s), "guard should skip: \(s)")
}
}

@Test("The guard admits every shape refRe can match")
func guardAdmitsRealRefs() {
for s in [
"#42",
"see #42 in example-org/scout",
"example-org/scout#42",
"trailing ref example-org/scout#7",
"[#TAG] alongside a real #99 ref",
] {
#expect(GitHubRefLinkifier.containsHashDigit(s), "guard must not skip: \(s)")
}
}

@Test("Strings with a # still linkify")
func hashStillLinkifies() {
// Qualified ref — carries its own repo.
#expect(GitHubRefLinkifier.linkify("example-org/scout#42")
.contains("https://github.com/example-org/scout/issues/42"))
// Bare ref — only linkifies once a single repo can be inferred from the
// same string, so give it one. (Without a repo it stays plain; that's
// the existing `leavesBareRefsAloneWithoutARepo` case, not a fast-path
// regression.)
#expect(GitHubRefLinkifier.linkify("see #42 in example-org/scout")
.contains("https://github.com/example-org/scout/issues/42"))
}
}
Loading

AltStyle によって変換されたページ (->オリジナル) /