From 492f92079a1022f747a54c18851f1c18e2508db3 Mon Sep 17 00:00:00 2001 From: Jason Jobe Date: Sat, 8 Aug 2026 10:14:14 -0400 Subject: [PATCH 1/2] Assert span-density cost by counting work, not by timing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wall-clock ratio these assertions used was not portable, and gating CI on it turned main red: the same parser reads 5.3x on an idle laptop and 10.9x on a contended runner, which is above the 11.3x pre-rewrite floor, so no threshold separates the two parsers. 350b2d3 made them opt-in, which left the rewrite with no CI-visible cost guard. InlineParser.parse can now report an InlineParseCost — claimed-range probes and containment tests — and the assertions use that. Both quantities are pure functions of the input, so they read identically everywhere, and the separation is decisive rather than marginal: 6.0x for 6x the spans against 33.9x with the pre-rewrite pairwise containment restored, verified by reintroducing it. The bound stays at 8, now inside a deterministic gap. The counters are plain Ints beside comparisons the loops already do, threaded through rather than kept in a global, because swift-testing runs suites in parallel and a global would be raced by every other suite that parses. ClaimedIndex owns its own probe count; the scans take it inout so the caller can read it back. The timed assertions stay, still opt-in, for absolute numbers. What the counted form does not catch is a new scan bypassing ClaimedIndex and buildTree entirely — it holds the existing structures to linear rather than proving nothing quadratic exists anywhere. Corpus fingerprint unchanged, so the instrumentation changed no parse. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 ++ .../MarkdownEngine/Parser/InlineParser.swift | 107 ++++++++++++++---- .../InlineSpanDensityTests.swift | 100 ++++++++++++++-- 3 files changed, 182 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f78d4b6a..f3155fcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 another presentation. ### Changed +- The span-density regression tests assert on counted work instead of elapsed + time, so they run on CI again. `InlineParser.parse` can report an + `InlineParseCost` — claimed-range probes and containment tests — which is a + pure function of the input and therefore reads the same on a laptop and on a + contended runner. Linear measures 6.0x for 6x the spans; the pre-rewrite + pairwise containment measures 33.9x. The wall-clock assertions stay for + absolute numbers, still opt-in via `MDE_PERF=1`. - An ordered list's painted number no longer reverts to the source digit under the caret or a selection. The number is positional, so in a run written `1./1./1.` a click inside a marker — or a select-all — flipped every number diff --git a/Sources/MarkdownEngine/Parser/InlineParser.swift b/Sources/MarkdownEngine/Parser/InlineParser.swift index 24b033b0..2f281172 100644 --- a/Sources/MarkdownEngine/Parser/InlineParser.swift +++ b/Sources/MarkdownEngine/Parser/InlineParser.swift @@ -41,6 +41,19 @@ import Foundation enum EmphasisKind: Equatable { case italic, bold, boldItalic } +/// What the claimed-range and containment scans did during one parse. +/// +/// Both quantities were quadratic in the spans per region before the ordered +/// walk, and both are a pure function of the input — which is the point. The +/// span-density tests assert on these instead of on elapsed time, so they mean +/// the same thing on a laptop and on a loaded CI runner. +struct InlineParseCost: Equatable { + /// Claimed ranges inspected by `ClaimedIndex` across every pass. + var claimedProbes = 0 + /// Span-in-region containment tests performed while building the tree. + var containmentTests = 0 +} + /// A node in the inline AST. indirect enum InlineNode: Equatable { case text(NSRange) @@ -94,15 +107,38 @@ enum InlineParser { // MARK: - Entry point static func parse(_ text: String, registry: ExtensionRegistry = .empty) -> [InlineNode] { + var cost = InlineParseCost() + return parse(text, registry: registry, cost: &cost) + } + + /// Parse, reporting the work the claimed-range and containment scans did. + /// + /// The counts are what the span-density tests assert on. A wall-clock ratio + /// looked like the natural measure and isn't portable — the same parser + /// reads 5.3x on an idle laptop and 10.9x on a contended CI runner, which + /// is above the pre-rewrite floor, so no threshold separates them. These + /// counts are a pure function of the input: identical everywhere, and + /// quadratic vs. linear differ by orders of magnitude rather than by 1.4x. + static func parse(_ text: String, registry: ExtensionRegistry = .empty, + cost: inout InlineParseCost) -> [InlineNode] { let ns = text as NSString let len = ns.length guard len > 0 else { return [] } var claimed = scanCodeSpans(ns, len: len) - claimed += scanEscapes(ns, len: len, claimed: ClaimedIndex(claimed)) - claimed += scanLinkFamily(ns, len: len, claimed: ClaimedIndex(claimed), registry: registry) - let emphasis = resolveEmphasis(ns, len: len, claimed: ClaimedIndex(claimed)) - return buildTree(region: NSRange(location: 0, length: len), spans: claimed + emphasis, ns: ns, registry: registry) + + var escapeIndex = ClaimedIndex(claimed) + claimed += scanEscapes(ns, len: len, claimed: &escapeIndex) + + var linkIndex = ClaimedIndex(claimed) + claimed += scanLinkFamily(ns, len: len, claimed: &linkIndex, registry: registry) + + var emphasisIndex = ClaimedIndex(claimed) + let emphasis = resolveEmphasis(ns, len: len, claimed: &emphasisIndex) + + cost.claimedProbes += escapeIndex.probes + linkIndex.probes + emphasisIndex.probes + return buildTree(region: NSRange(location: 0, length: len), spans: claimed + emphasis, + ns: ns, registry: registry, cost: &cost) } /// Parse the inline content of `range` within `ns`, returning nodes in absolute document coordinates. @@ -154,23 +190,37 @@ enum InlineParser { private let ranges: [NSRange] private var cursor = 0 + /// How many claimed ranges the queries have inspected. The scans that + /// used to be quadratic all ran through here, so this is the number + /// `InlineSpanDensityTests` holds to a linear budget. An `Int` bumped + /// beside comparisons the loop already does — cheap enough to leave in + /// release, where the alternative is a global the tests race on. + private(set) var probes = 0 + init(_ spans: [Span]) { ranges = spans.map(\.fullRange).sorted { $0.location < $1.location } } /// Discard ranges that end at or before `idx`. `idx` must not move backwards. private mutating func advance(to idx: Int) { - while cursor < ranges.count, NSMaxRange(ranges[cursor]) <= idx { cursor += 1 } + while cursor < ranges.count, NSMaxRange(ranges[cursor]) <= idx { + cursor += 1 + probes += 1 + } } mutating func contains(_ idx: Int) -> Bool { advance(to: idx) - return cursor < ranges.count && NSLocationInRange(idx, ranges[cursor]) + guard cursor < ranges.count else { return false } + probes += 1 + return NSLocationInRange(idx, ranges[cursor]) } mutating func overlaps(_ range: NSRange) -> Bool { advance(to: range.location) - return cursor < ranges.count && ranges[cursor].location < NSMaxRange(range) + guard cursor < ranges.count else { return false } + probes += 1 + return ranges[cursor].location < NSMaxRange(range) } /// Every claimed range overlapping `range`. Peeks forward from the @@ -183,6 +233,7 @@ enum InlineParser { while k < ranges.count, ranges[k].location < NSMaxRange(range) { if NSIntersectionRange(ranges[k], range).length > 0 { out.append(ranges[k]) } k += 1 + probes += 1 } return out } @@ -236,8 +287,7 @@ enum InlineParser { // MARK: - 2. Backslash escapes (claimed → escaped chars are inert everywhere) - private static func scanEscapes(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [Span] { - var claimed = claimed + private static func scanEscapes(_ ns: NSString, len: Int, claimed: inout ClaimedIndex) -> [Span] { var spans: [Span] = [] var i = 0 while i < len - 1 { @@ -257,8 +307,7 @@ enum InlineParser { // MARK: - 3. Link family / inline LaTeX / extension spans - private static func scanLinkFamily(_ ns: NSString, len: Int, claimed: ClaimedIndex, registry: ExtensionRegistry) -> [Span] { - var claimed = claimed + private static func scanLinkFamily(_ ns: NSString, len: Int, claimed: inout ClaimedIndex, registry: ExtensionRegistry) -> [Span] { // A candidate overlapping a claimed span is rejected, except for spans // wholly nested inside a Markdown link's label (#118). Only that case // needs the full overlap list; everything else short-circuits on the @@ -594,8 +643,8 @@ enum InlineParser { var remaining: Int { rightEdge - leftEdge } } - private static func resolveEmphasis(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [Span] { - var runs = collectDelimiterRuns(ns, len: len, claimed: claimed) + private static func resolveEmphasis(_ ns: NSString, len: Int, claimed: inout ClaimedIndex) -> [Span] { + var runs = collectDelimiterRuns(ns, len: len, claimed: &claimed) guard !runs.isEmpty else { return [] } var stack: [Int] = [] var spans: [Span] = [] @@ -610,8 +659,7 @@ enum InlineParser { return spans } - private static func collectDelimiterRuns(_ ns: NSString, len: Int, claimed: ClaimedIndex) -> [DelimRun] { - var claimed = claimed + private static func collectDelimiterRuns(_ ns: NSString, len: Int, claimed: inout ClaimedIndex) -> [DelimRun] { var runs: [DelimRun] = [] var lineIdx = 0 var i = 0 @@ -689,12 +737,14 @@ enum InlineParser { // MARK: - 5. Containment tree - private static func buildTree(region: NSRange, spans: [Span], ns: NSString, registry: ExtensionRegistry) -> [InlineNode] { + private static func buildTree(region: NSRange, spans: [Span], ns: NSString, + registry: ExtensionRegistry, cost: inout InlineParseCost) -> [InlineNode] { // Spans are non-overlapping or properly nested (each pass claims only // inside regions no earlier pass took), so ordering by start ascending // and length descending puts every span immediately after the one that // contains it. Containment then falls out of a single ordered walk, // instead of testing each span against every other span. + cost.containmentTests += spans.count let ordered = spans .filter { rangeContains(region, $0.fullRange) } .sorted { a, b in @@ -702,13 +752,15 @@ enum InlineParser { return x.location == y.location ? x.length > y.length : x.location < y.location } var cursor = 0 - return buildTree(region: region, ordered: ordered, cursor: &cursor, ns: ns, registry: registry) + return buildTree(region: region, ordered: ordered, cursor: &cursor, + ns: ns, registry: registry, cost: &cost) } /// Consumes spans from `cursor` for as long as they fall inside `region`, /// leaving `cursor` on the first span that doesn't. private static func buildTree( - region: NSRange, ordered: [Span], cursor: inout Int, ns: NSString, registry: ExtensionRegistry + region: NSRange, ordered: [Span], cursor: inout Int, ns: NSString, + registry: ExtensionRegistry, cost: inout InlineParseCost ) -> [InlineNode] { var result: [InlineNode] = [] var textStart = region.location @@ -716,6 +768,7 @@ enum InlineParser { while cursor < ordered.count { let span = ordered[cursor] let fr = span.fullRange + cost.containmentTests += 1 guard rangeContains(region, fr) else { break } cursor += 1 @@ -729,10 +782,11 @@ enum InlineParser { let content = NSRange(location: NSMaxRange(open), length: close.location - NSMaxRange(open)) result.append(.emphasis(kind, range: range, markers: [open, close], children: buildTree(region: content, ordered: ordered, - cursor: &cursor, ns: ns, registry: registry))) + cursor: &cursor, ns: ns, + registry: registry, cost: &cost))) case .link(let range, let textRange, let url, let markers): result.append(.link(range: range, textRange: textRange, url: url, markers: markers, - children: reparse(textRange, ns: ns, registry: registry))) + children: reparse(textRange, ns: ns, registry: registry, cost: &cost))) case .image(let range, let alt, let url, let markers): result.append(.image(range: range, alt: alt, url: url, markers: markers)) case .wikiLink(let range, let name, let id, let markers): @@ -746,13 +800,17 @@ enum InlineParser { case .ext(let id, let range, let contentRange, let markers, let parsesContent): result.append(.ext(ExtensionInlineNode( extensionID: id, range: range, contentRange: contentRange, markers: markers, - children: parsesContent ? reparse(contentRange, ns: ns, registry: registry) : [] + children: parsesContent ? reparse(contentRange, ns: ns, registry: registry, cost: &cost) : [] ))) } // Every span but emphasis is opaque, so nothing should remain // inside one. Skipping keeps the walk well-formed if that ever // changes, rather than emitting a node past the cursor. - while cursor < ordered.count, rangeContains(fr, ordered[cursor].fullRange) { cursor += 1 } + while cursor < ordered.count, rangeContains(fr, ordered[cursor].fullRange) { + cursor += 1 + cost.containmentTests += 1 + } + cost.containmentTests += 1 textStart = NSMaxRange(fr) } if textStart < NSMaxRange(region) { @@ -762,8 +820,9 @@ enum InlineParser { } /// Recursively parse a sub-range's content, offset back to absolute coordinates. - private static func reparse(_ range: NSRange, ns: NSString, registry: ExtensionRegistry) -> [InlineNode] { - offsetNodes(parse(ns.substring(with: range), registry: registry), by: range.location) + private static func reparse(_ range: NSRange, ns: NSString, registry: ExtensionRegistry, + cost: inout InlineParseCost) -> [InlineNode] { + offsetNodes(parse(ns.substring(with: range), registry: registry, cost: &cost), by: range.location) } // MARK: - Helpers diff --git a/Tests/MarkdownEngineTests/InlineSpanDensityTests.swift b/Tests/MarkdownEngineTests/InlineSpanDensityTests.swift index 63b94806..0587e4f4 100644 --- a/Tests/MarkdownEngineTests/InlineSpanDensityTests.swift +++ b/Tests/MarkdownEngineTests/InlineSpanDensityTests.swift @@ -16,15 +16,28 @@ // diff `String(describing:)` per input against the old parser to see what moved. // // The second half asserts the cost curve is linear in spans rather than -// quadratic, so the scans can't quietly come back. It is OPT-IN via -// `MDE_PERF=1 swift test` — see `perfGateEnabled`. +// quadratic, so the scans can't quietly come back. It exists twice over: +// +// - COUNTED (`expectLinearWork`) — asserts on `InlineParseCost`, the number +// of claimed-range probes and containment tests a parse performs. Pure +// functions of the input, so they read the same on any machine and run on +// CI. Linear measures 6.0x; restoring the pre-rewrite pairwise +// containment measures 33.9x. +// - TIMED (`expectLinearInSpans`) — the original wall-clock ratio, kept for +// absolute numbers and OPT-IN via `MDE_PERF=1 swift test`, because a ratio +// of durations is not portable. See `perfGateEnabled`. +// +// What the counted form does NOT catch is a brand-new scan that bypasses +// `ClaimedIndex` and `buildTree` entirely; it holds the existing structures to +// linear rather than proving nothing quadratic exists anywhere. // import Foundation import Testing @testable import MarkdownEngine -/// The cost-curve assertions run only under `MDE_PERF=1 swift test`. +/// The TIMED cost-curve assertions run only under `MDE_PERF=1 swift test`. +/// The counted ones next to them always run. /// /// A wall-clock RATIO is not portable, which is easy to miss because it looks /// like it should be: the same parser measures 5.3x on an M-series laptop and @@ -33,8 +46,8 @@ import Testing /// that number buys flakiness, not safety — and no bound fixes it, since the /// pre-rewrite floor (11.3x here) sits below the post-rewrite CI reading. /// -/// `corpusFingerprint` is the regression net that DOES hold everywhere, and it -/// stays on by default. +/// `corpusFingerprint` and the counted assertions are the regression nets that +/// DO hold everywhere, and they stay on by default. private let perfGateEnabled = ProcessInfo.processInfo.environment["MDE_PERF"] != nil @Suite("Inline parse cost vs. span density") @@ -92,10 +105,81 @@ struct InlineSpanDensityTests { #expect(fingerprint(corpus(4000), registry: registry) == "b74649ffbbbe237a") } - // MARK: - Cost curve + // MARK: - Cost curve, counted + + /// The work a parse actually does, as a count rather than a duration. + /// + /// `claimedProbes` covers the claimed-range queries every pass makes; + /// `containmentTests` covers `buildTree`. Both were quadratic in the spans + /// per region before the ordered walk. Summing them is deliberate — a + /// paragraph of links makes no claimed-range queries at all (nothing is + /// claimed before the link pass, and there are no `*` or `\\` characters to + /// ask about), so `containmentTests` carries the signal there and + /// `claimedProbes` carries it for code spans. + private func cost(_ text: String, registry: ExtensionRegistry) -> Int { + var cost = InlineParseCost() + _ = InlineParser.parse(text, registry: registry, cost: &cost) + return cost.claimedProbes + cost.containmentTests + } + + /// 6x the spans must cost ~6x the work, not ~34x. + /// + /// Measured: 6.0x for every construct below. Restoring the pre-rewrite + /// pairwise containment takes it to 33.9x. The bound sits in that gap, and + /// unlike the wall-clock version it means the same thing everywhere — + /// these are integers derived from the input, not timings. + private func expectLinearWork(_ label: String, _ make: (Int) -> String) { + let registry = MarkdownEditorConfiguration(extensions: [HighlightExtension()]).extensionRegistry + let small = cost(paragraph(40, make), registry: registry) + let large = cost(paragraph(240, make), registry: registry) + + // Guards against the assertion passing because nothing was measured. + #expect(small > 0, "\(label): no work counted at all") + + let growth = Double(large) / Double(max(small, 1)) + #expect(growth < 8, "\(label): 6x spans cost \(String(format: "%.1f", growth))x work (\(small) -> \(large))") + } + + @Test("code spans: parse WORK is linear in spans per paragraph") + func codeSpanWork() { expectLinearWork("code") { "`word\($0)`" } } + + @Test("links: parse WORK is linear in spans per paragraph") + func linkWork() { expectLinearWork("links") { "[word\($0)](https://e.com/\($0))" } } + + @Test("emphasis: parse WORK is linear in spans per paragraph") + func emphasisWork() { expectLinearWork("emphasis") { "*word\($0)*" } } + + @Test("highlights: parse WORK is linear in spans per paragraph") + func highlightWork() { expectLinearWork("highlight") { "==word\($0)==" } } + + @Test("a paragraph mixing claimed-span kinds does linear work") + func mixedWork() { + expectLinearWork("mixed") { i in + switch i % 4 { + case 0: return "`code\(i)`" + case 1: return "\\*lit\(i)\\*" + case 2: return "*em\(i)*" + default: return "[l\(i)](u\(i))" + } + } + } + + /// Nesting a claimed span inside a link label (#118) must not make the + /// link pass rescan — `overlapping` peeks from the cursor rather than + /// walking the array. + @Test("code spans inside link labels stay linear") + func nestedLabelWork() { + expectLinearWork("nested labels") { "[`c\($0)` t](u\($0))" } + } + + // MARK: - Cost curve, timed - /// Minimum of several runs: scheduler noise only ever adds time, so the - /// floor is the stable statistic. Means would make this a flake. + /// Minimum of several runs. That makes the ABSOLUTE number about as stable + /// as a timing gets — noise only ever adds time — but it does not rescue + /// the RATIO these tests assert on, which is why they are opt-in. Under + /// sustained contention there is no quiet run to find a floor in, and the + /// smaller measurement inflates proportionally more, so the ratio drifts + /// up. The counted assertions above are the portable form. private func msPerParse(_ text: String, registry: ExtensionRegistry) -> Double { var best = Double.infinity for _ in 0..<7 { From d9680f23636b0592f895562b554d6960d7c422f8 Mon Sep 17 00:00:00 2001 From: Jason Jobe Date: Tue, 18 Aug 2026 12:10:03 -0400 Subject: [PATCH 2/2] Cover the directive path in the counted density tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto current main puts this branch on top of the directive seam (#120), which added a claimed-span producer that did not exist when these tests were written. Two counted cases now cover it: 6.0x work for 6x the spans, on both the self-contained and container forms. Each shape pairs the directive with a code span deliberately. A paragraph of bare `@mk` claims nothing in passes 1-2, so `ClaimedIndex` is built EMPTY and a pairwise scan over it costs nothing — the assertion then passes no matter what the cursor does, and only `buildTree` is under test. That is exactly what the first version of these two tests did, and it looked fine: 6.0x, green. Verified the other way round, by restoring the pre-rewrite pairwise containment. With the code span both fail at 37.6x; without it both still pass. `expectLinearWork` takes a registry now, defaulted to the extensions one, so the existing call sites are unchanged. 455 tests pass. Co-Authored-By: Claude Opus 5 --- .../InlineSpanDensityTests.swift | 43 ++++++++++++++++++- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/Tests/MarkdownEngineTests/InlineSpanDensityTests.swift b/Tests/MarkdownEngineTests/InlineSpanDensityTests.swift index 0587e4f4..1cd9d3c7 100644 --- a/Tests/MarkdownEngineTests/InlineSpanDensityTests.swift +++ b/Tests/MarkdownEngineTests/InlineSpanDensityTests.swift @@ -128,8 +128,13 @@ struct InlineSpanDensityTests { /// pairwise containment takes it to 33.9x. The bound sits in that gap, and /// unlike the wall-clock version it means the same thing everywhere — /// these are integers derived from the input, not timings. - private func expectLinearWork(_ label: String, _ make: (Int) -> String) { - let registry = MarkdownEditorConfiguration(extensions: [HighlightExtension()]).extensionRegistry + private func expectLinearWork( + _ label: String, + registry: ExtensionRegistry = MarkdownEditorConfiguration( + extensions: [HighlightExtension()] + ).extensionRegistry, + _ make: (Int) -> String + ) { let small = cost(paragraph(40, make), registry: registry) let large = cost(paragraph(240, make), registry: registry) @@ -152,6 +157,40 @@ struct InlineSpanDensityTests { @Test("highlights: parse WORK is linear in spans per paragraph") func highlightWork() { expectLinearWork("highlight") { "==word\($0)==" } } + // MARK: - Directives + + /// Directives landed after this suite was written (#120), and they are a + /// claimed-span producer like any other. + /// + /// Each shape pairs the directive with a CODE SPAN deliberately. A + /// paragraph of bare `@mk` claims nothing in passes 1-2, so `ClaimedIndex` + /// is built empty and a pairwise scan over it is free — the assertion then + /// holds no matter what the cursor does, and only `buildTree` is really + /// under test. The code span gives the index something to scan, so these + /// cover BOTH structures. Verified by restoring the pre-rewrite pairwise + /// containment: with the code span they fail, without it they pass. + private struct CountedMarker: MarkdownDirective { + var syntax: DirectiveSyntax { DirectiveSyntax(name: "mk", form: .selfContained) } + } + + private struct CountedBox: MarkdownDirective { + var syntax: DirectiveSyntax { DirectiveSyntax(name: "bx", form: .container) } + } + + private var directiveRegistry: ExtensionRegistry { + MarkdownEditorConfiguration(directives: [CountedMarker(), CountedBox()]).extensionRegistry + } + + @Test("self-contained directives: parse WORK is linear in spans per paragraph") + func selfContainedDirectiveWork() { + expectLinearWork("directive/self-contained", registry: directiveRegistry) { "`c\($0)` @mk" } + } + + @Test("container directives: parse WORK is linear in spans per paragraph") + func containerDirectiveWork() { + expectLinearWork("directive/container", registry: directiveRegistry) { "`c\($0)` @bx{w\($0)}" } + } + @Test("a paragraph mixing claimed-span kinds does linear work") func mixedWork() { expectLinearWork("mixed") { i in