--- phase: 08-pagination-quality-cache-performance plan: 03 type: execute wave: 2 depends_on: - 08-01 - 08-02 files_modified: - Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift - Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift autonomous: true requirements: - QUAL-01 - QUAL-04 must_haves: truths: - "Builder records per-chapter render time and paginate time" - "Builder records total build time" - "Builder records cache hit/miss per chapter" - "Performance samples are accessible via lastBuildPerformanceSamples" - "Cache integration: builder accepts optional cache parameter and skips build on hit" - "Cache miss triggers full build and stores result" - "Console logs show [PERF] summary with timing data" artifacts: - path: "Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift" provides: "RDEPUBTextPerformanceSample struct and RDEPUBTextPerformanceSampler class" contains: "struct RDEPUBTextPerformanceSample" - path: "Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift" provides: "Cache integration and performance instrumentation in build()" contains: "lastBuildPerformanceSamples" key_links: - from: "RDEPUBTextBookBuilder.swift" to: "RDEPUBTextBookCache.swift" via: "cache.load(key:) / cache.save(_:key:)" pattern: "cache\\.load\\|cache\\.save" - from: "RDEPUBTextBookBuilder.swift" to: "RDEPUBTextPerformanceSampler.swift" via: "sampler.record(sample)" pattern: "sampler\\.record" --- Create performance sampling infrastructure and integrate cache + performance instrumentation into RDEPUBTextBookBuilder.build(). This adds timing measurements for render/paginate/build operations and wires the cache from Plan 01 so that repeated builds with the same parameters skip the full pipeline. Purpose: QUAL-04 requires stable diagnostic/sampling data to ensure no degradation. QUAL-01 requires cache integration to avoid redundant pagination. This plan brings both into the builder. Output: New RDEPUBTextPerformanceSampler.swift; modified RDEPUBTextBookBuilder.swift with cache integration and timing instrumentation. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/08-pagination-quality-cache-performance/08-CONTEXT.md @.planning/phases/08-pagination-quality-cache-performance/08-RESEARCH.md @.planning/phases/08-pagination-quality-cache-performance/08-PATTERNS.md @.planning/phases/08-pagination-quality-cache-performance/08-01-SUMMARY.md @.planning/phases/08-pagination-quality-cache-performance/08-02-SUMMARY.md @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift @Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift ```swift public init(renderer: RDEPUBTextRenderer) { self.renderer = renderer } public convenience init() { self.init(renderer: RDEPUBDTCoreTextRenderer()) } ``` ```swift public private(set) var lastBuildResourceDiagnostics: [RDEPUBTextResourceReferenceDiagnostic] = [] public private(set) var lastBuildPaginationDiagnostics: [RDEPUBTextChapterPaginationDiagnostic] = [] ``` ```swift public func build( parser: RDEPUBParser, publication: RDEPUBPublication, pageSize: CGSize, style: RDEPUBTextRenderStyle ) throws -> RDEPUBTextBook ``` ```swift public final class RDEPUBTextBookCache { public init(subdirectory: String = "RDEPUBTextBookCache") public func cacheKey(bookID: String, fontSize: CGFloat, lineHeightMultiple: CGFloat, contentInsets: UIEdgeInsets, pageSize: CGSize) -> String public func load(key: String) -> RDEPUBTextBook? public func save(_ book: RDEPUBTextBook, key: String) public func invalidateAll() public var schemaVersion: Int } ``` ```swift let start = CFAbsoluteTimeGetCurrent() // ... operation ... let duration = CFAbsoluteTimeGetCurrent() - start ``` ```swift public struct RDEPUBTextResourceReferenceDiagnostic: Equatable { public var kind: RDEPUBTextResourceReferenceKind public var chapterHref: String // ... public init(kind: ..., chapterHref: ..., ...) { ... } } ``` ```swift extension NSAttributedString { func rd_paginatedFrames( size: CGSize, fragmentOffsets: [String: Int] = [:], config: RDEPUBTextLayoutConfig = .default ) -> [RDEPUBTextLayoutFrame] } ``` Task 1: Create RDEPUBTextPerformanceSampler with timing sample struct and recording API Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift Create a new file `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift`. **RDEPUBTextPerformanceSample struct** (per QUAL-04, D-04): ```swift public struct RDEPUBTextPerformanceSample: Equatable { public var chapterHref: String public var renderDuration: TimeInterval // DTCoreText render time public var paginateDuration: TimeInterval // CoreText pagination time public var pageCount: Int public var attributedStringLength: Int public var cacheHit: Bool public init( chapterHref: String, renderDuration: TimeInterval, paginateDuration: TimeInterval, pageCount: Int, attributedStringLength: Int, cacheHit: Bool ) { ... } } ``` Follow the exact pattern from `RDEPUBTextResourceReferenceDiagnostic` at RDEPUBTextRenderer.swift lines 90-113: public struct, Equatable, explicit public init with all properties. **RDEPUBTextPerformanceSampler class**: ```swift public final class RDEPUBTextPerformanceSampler { public private(set) var samples: [RDEPUBTextPerformanceSample] = [] public private(set) var totalBuildDuration: TimeInterval = 0 public init() {} public func record(_ sample: RDEPUBTextPerformanceSample) { samples.append(sample) print("[PERF] \(sample.chapterHref): render=\(formatMS(sample.renderDuration)) paginate=\(formatMS(sample.paginateDuration)) pages=\(sample.pageCount) cache=\(sample.cacheHit ? "HIT" : "MISS")") } public func summary() -> String { let totalRender = samples.reduce(0) { $0 + $1.renderDuration } let totalPaginate = samples.reduce(0) { $0 + $1.paginateDuration } let hitCount = samples.filter(\.cacheHit).count return "[PERF] chapters=\(samples.count) render=\(formatMS(totalRender)) paginate=\(formatMS(totalPaginate)) total=\(formatMS(totalBuildDuration)) cacheHits=\(hitCount)/\(samples.count)" } public func reset() { samples.removeAll() totalBuildDuration = 0 } private func formatMS(_ duration: TimeInterval) -> String { String(format: "%.0fms", duration * 1000) } } ``` **Console output pattern** (follows existing `[EPUB]` style in the codebase): - Each `record()` call prints a per-chapter `[PERF]` line - `summary()` returns a formatted string (caller prints it) - Use milliseconds (multiply TimeInterval by 1000) for human-readable output xcodebuild build -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 17' 2>&1 | tail -5 - File `Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift` exists - `RDEPUBTextPerformanceSample` is a public struct with Equatable conformance - Properties: `chapterHref`, `renderDuration`, `paginateDuration`, `pageCount`, `attributedStringLength`, `cacheHit` - `RDEPUBTextPerformanceSampler` is a public final class - `record(_:)` method appends sample and prints `[PERF]` line - `summary()` returns formatted string with totals and cache hit rate - `reset()` clears samples and totalBuildDuration - `totalBuildDuration` is a public property - Build succeeds RDEPUBTextPerformanceSampler.swift contains performance sample struct and sampler class with record/summary/reset API and [PERF] console logging. Build succeeds. Task 2: Integrate cache and performance sampling into RDEPUBTextBookBuilder.build() Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookBuilder.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextPerformanceSampler.swift Sources/RDReaderView/EPUBTextRendering/RDEPUBTextRenderer.swift Sources/RDReaderView/EPUBUI/RDEPUBReaderController.swift Modify `RDEPUBTextBookBuilder.swift` to integrate the cache (from Plan 01) and performance sampling (Task 1). **Step 1: Add new stored properties** (per D-01, D-04) After the existing `lastBuildPaginationDiagnostics` property (line 99), add: ```swift public private(set) var lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample] = [] public private(set) var lastBuildCacheStats: (hits: Int, misses: Int) = (0, 0) ``` **Step 2: Add cache and sampler to init** (per D-01) Update the initializer to accept optional cache: ```swift private let cache: RDEPUBTextBookCache? private let sampler: RDEPUBTextPerformanceSampler public init(renderer: RDEPUBTextRenderer, cache: RDEPUBTextBookCache? = nil) { self.renderer = renderer self.cache = cache self.sampler = RDEPUBTextPerformanceSampler() } public convenience init() { self.init(renderer: RDEPUBDTCoreTextRenderer()) } ``` The `cache` parameter defaults to nil for backward compatibility. The sampler is always created (lightweight, no cost until samples recorded). **Step 3: Add cache key helper** Add a private helper method to generate the cache key from build parameters: ```swift private func makeCacheKey( bookID: String, pageSize: CGSize, style: RDEPUBTextRenderStyle ) -> String? { guard let cache else { return nil } return cache.cacheKey( bookID: bookID, fontSize: style.font.pointSize, lineHeightMultiple: style.lineSpacing, contentInsets: .zero, // or derive from pageSize insets if available pageSize: pageSize ) } ``` Note: The `bookID` needs to come from the publication. Use `publication.metadata.identifier ?? publication.metadata.title ?? "unknown"` as the bookID. If no identifier is available, return nil to skip caching. **Step 4: Instrument the build() method** (per QUAL-04, QUAL-01) At the start of `build()`, after the existing `lastBuildResourceDiagnostics = []` / `lastBuildPaginationDiagnostics = []` lines: 1. **Reset sampler**: `sampler.reset()`, `lastBuildCacheStats = (0, 0)` 2. **Start build timer**: `let buildStart = CFAbsoluteTimeGetCurrent()` 3. **Cache lookup**: If cache is available, generate key and try `cache.load(key:)`. If hit, set `lastBuildCacheStats.hits += 1`, print `[Cache] HIT`, and return the cached book immediately. 4. **Cache miss**: If cache miss, proceed with existing build loop. Inside the for loop, around each chapter's render + paginate: 1. **Render timing**: Wrap `renderer.renderChapter(request:)` (line 158) with `CFAbsoluteTimeGetCurrent()` before and after 2. **Paginate timing**: Wrap `content.rd_paginatedFrames(size:)` (line 196) with `CFAbsoluteTimeGetCurrent()` before and after 3. **Record sample**: After getting layoutFrames, record: ```swift sampler.record(RDEPUBTextPerformanceSample( chapterHref: item.href, renderDuration: renderDuration, paginateDuration: paginateDuration, pageCount: effectiveFrames.count, attributedStringLength: content.length, cacheHit: false )) ``` 4. **Miss counter**: `lastBuildCacheStats.misses += 1` After the loop, before returning: 1. **Set total build duration**: `sampler.totalBuildDuration = CFAbsoluteTimeGetCurrent() - buildStart` 2. **Save to cache**: If cache available and key was generated, `cache.save(book, key: cacheKey)` 3. **Print summary**: `print(sampler.summary())` 4. **Store samples**: `lastBuildPerformanceSamples = sampler.samples` **Step 5: Update build() method signature** — keep the existing signature unchanged. The cache is accessed via the stored property, not a method parameter. **Important implementation notes**: - All timing uses `CFAbsoluteTimeGetCurrent()` (no external deps, per D-04) - Timing happens on the same queue as the build (DispatchQueue.global(qos: .userInitiated)), not dispatched to main - The `publication.metadata.identifier` or equivalent needs to be accessible — check the `RDEPUBPublication` type for an identifier field - If publication has no identifier, skip cache (return nil from makeCacheKey) xcodebuild build -scheme ReadViewDemo -destination 'platform=iOS Simulator,name=iPhone 17' 2>&1 | tail -5 - `RDEPUBTextBookBuilder` has `lastBuildPerformanceSamples: [RDEPUBTextPerformanceSample]` property - `RDEPUBTextBookBuilder` has `lastBuildCacheStats: (hits: Int, misses: Int)` property - `RDEPUBTextBookBuilder` init accepts optional `cache: RDEPUBTextBookCache?` parameter (defaults to nil) - `build()` method contains `CFAbsoluteTimeGetCurrent()` timing calls for render and paginate - `build()` method calls `cache.load(key:)` before the build loop when cache is available - `build()` method calls `cache.save(_:key:)` after the build loop when cache is available - `build()` method calls `sampler.record()` for each chapter with timing data - `build()` method prints `sampler.summary()` at the end - Existing `convenience init()` still works (cache defaults to nil) - Build succeeds with no regressions RDEPUBTextBookBuilder.build() is instrumented with per-chapter render/paginate timing, cache hit/miss logging, and performance sample recording. Cache integration skips build on hit and stores result on miss. Build succeeds. ## Trust Boundaries | Boundary | Description | |----------|-------------| | builder → cache | Builder writes to cache after successful build; corrupted cache could return invalid RDEPUBTextBook on next load | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation | |-----------|----------|-----------|-------------|------------| | T-08-04 | Tampering | Cache integration in builder | accept | Cache is optional (nil default); NSKeyedArchiver deserialization fails safely returning nil | | T-08-SC | Tampering | npm/pip/cargo installs | mitigate | No external packages installed in this phase | - `xcodebuild build -scheme ReadViewDemo` succeeds - Builder init accepts optional cache parameter - build() method has CFAbsoluteTimeGetCurrent timing instrumentation - Cache load/save calls are present in build() flow - Performance samples accessible via lastBuildPerformanceSamples - Console shows [PERF] summary and [Cache] HIT/MISS logs - Per-chapter render and paginate durations are recorded - Total build time is captured - Cache hit skips the entire build loop - Cache miss runs full build and stores result - Performance summary printed to console after each build - No functional regression: book opens, paginates, and displays correctly Create `.planning/phases/08-pagination-quality-cache-performance/08-03-SUMMARY.md` when done