This is the multi-page printable view of this section. Click here to print.

Return to the regular view of this page.

RawCull Architecture

Architecture overview and guided documentation catalog for the RawCull macOS application.

RawCull — Architecture Overview & Documentation Index

RawCull is a macOS app for culling RAW photos: scanning a folder of camera RAW files (Sony ARW today), showing fast previews, letting the photographer rate/reject/flag images, grouping near-duplicate “burst” shots with on-device AI, and finally exporting the keepers to a destination folder.

This document is the entry point and index for the whole Docs/ catalog. Read it first, then follow the links below — in the “Reading order” section, and repeated here for quick reference — for deep dives into each subsystem. All docs assume you already know Swift and SwiftUI, but nothing about this codebase.

Documentation catalog

#DocumentCovers
00Overview (this file)App summary, source map, composition root, glossary
01Concurrency-Architecture.mdActors, TaskGroup backpressure, Sendable rules, coalescing
02Image-Pipeline-and-Caching.mdScan → decode → thumbnail → cache pipeline
03Culling-and-Persistence.mdRatings model, debounced save, catalog switching
04Export-Copy-Pipeline.mdThe rsync-backed export/copy step
05Intelligence-AI-Subsystem.mdOn-device AI: similarity, semantic search, deep review
Intelligence RuntimeDeep dive: how the Intelligence runtime is built, reconfigured, and extended
06SwiftUI-View-Layer.mdNavigation, state-management idioms, the grid view
07Settings-and-Configuration.mdUser preferences, AI settings, memory monitor
Features and RoadmapRawCull vs. other culling apps, and the post-3.2.0 roadmap
Known IssuesCode-review findings, with severities

Naming note: the project (and this worktree’s branch) carries the name “rsyncosx” because it evolved from the author’s earlier RsyncOSX rsync GUI. RawCull is not a two-way sync tool — it’s a one-way RAW photo culling/export app that happens to reuse rsync (and several helper packages) from that project for its final copy step. See Export and Copy Pipeline for the full story and a table of legacy names you’ll bump into while reading the code.

Where the source lives

RawCull/
├── Main/          RawCullApp (the @main entry point), RawCullMainView, FileItem typealias
├── Actors/         The file-scanning / thumbnail / disk-cache pipeline (all `actor`s)
├── Model/
│   ├── ViewModels/      RawCullViewModel (central state) + its feature extensions,
│   │                    CullingModel, SettingsViewModel, MemoryViewModel, GridThumbnailViewModel
│   ├── Cache/           Cache config/keys shared by the Actors layer
│   ├── Handlers/        Small callback-bundle structs passed into background workers
│   ├── ParametersRsync/ Copy/export configuration + rsync argument building
│   └── JSON/            Codable read/write helpers for on-disk persistence
├── Intelligence/    The on-device AI subsystem (similarity, semantic search,
│                    burst analysis, deep review, model management) — see its own doc
├── Views/           SwiftUI view layer, one folder per feature area
└── Extensions/      Small Foundation/Thread extensions

RawCullCore (types like RawCullFileItem/FileItem, RawCullSourceCatalog, ExifMetadata, burst grouping/ranking algorithms) and the AI-facing PhotoAIContracts/PhotoAnalysisKit packages, plus RawParserKit (RAW decoding) and the rsync helper packages (RsyncArguments, RsyncProcessStreaming, ParseRsyncOutput, DecodeEncodeGeneric), are all separate Swift packages pulled in via SPM from the rsyncOSX GitHub org. They are out of scope for this documentation — treat them as RawCull’s external boundary and consult their own repos if you need their internals.

The composition root

The app has exactly one place where its two long-lived object graphs are built: RawCullApplicationState.live() in RawCull/Intelligence/Composition/RawCullIntelligenceRuntime.swift.

@MainActor
struct RawCullApplicationState {
    let intelligenceRuntime: RawCullIntelligenceRuntime
    let viewModel: RawCullViewModel

    static func live() -> RawCullApplicationState { make(integration: RawCullAIIntegration()) }
    static func make(integration:, similarityArtifactStore:, userDefaults:, ...) -> RawCullApplicationState { ... }
}

RawCullApp.init() calls RawCullApplicationState.live() once and stores viewModel and intelligenceRuntime in @State. Everything downstream (views, feature controllers, background workers) either receives these by parameter or reads them via .environment(...). Nothing else in the app constructs a second RawCullViewModel or a second AI runtime — this matters because several assertions in make(...) (assert(viewModel.similarityFeature === intelligenceRuntime.similarityFeature), etc.) exist specifically to catch an accidental second instance during refactors.

RawCullApp (RawCull/Main/RawCullApp.swift) declares three Scenes:

  • "main-window" — hosts RawCullMainView, the whole photo-browsing UI.
  • Settings — hosts SettingsView, bound to intelligenceRuntime.settingsModel.
  • "about-window" — a simple about box.

An NSApplicationDelegateAdaptor-backed AppDelegate intercepts app termination to flush pending culling-state writes (viewModel.cullingModel.flushPersistence()) before actually quitting, and to release any active security-scoped folder access.

The central view model

RawCullViewModel (Model/ViewModels/RawCullViewModel.swift) is a @MainActor @Observable final class — the single source of truth for almost all UI-visible state: the current file list, selection, view mode (loupe / grid / similarity grid / rated grid / comparison grid), zoom-overlay state, rating filter, and references to the “stable feature boundaries” it doesn’t own outright: similarityFeature, semanticSearchFeature, deepAIReviewController, burstAnalysisCoordinator, plus its own cullingModel and sharpnessModel.

It’s split across one main file and several RawCullViewModel+*.swift extensions by concern (+Culling, +Catalog, +Sharpness, +Similarity, +Thumbnails, +BurstGrouping) — a common Swift pattern for keeping a large @Observable model’s storage in one place while spreading its behavior across topic-focused files.

Reading order

  1. Concurrency Architecture — actors, TaskGroup, cancellation, Sendable rules. Read this early; almost every other subsystem leans on these patterns.
  2. Image Pipeline and Caching — scan → decode → thumbnail → cache, from folder selection to pixels on screen.
  3. Culling and Persistence — how ratings/rejects are modeled, filtered, and saved.
  4. Export and Copy Pipeline — how the “keeper” files get copied out via rsync.
  5. Intelligence and AI Subsystem — the on-device ML features: burst grouping, semantic search, deep review. Optionally follow with Intelligence Runtime for a deep dive on how the runtime is built, reconfigured live, and safely extended.
  6. SwiftUI View Layer — the view hierarchy and SwiftUI idioms used throughout.
  7. Settings and Configuration — user preferences, AI model management/licensing, memory monitoring.
  8. Features and Roadmap — how RawCull compares to other culling apps (Photo Mechanic, Aftershoot, Narrative Select, Lightroom Classic, and others), and the principles guiding how it should evolve after 3.2.0. Product-level, not implementation detail — a good read once you understand the architecture and want the “why this app, why this shape” context.
  9. Known Issues — known issues found during a code-review pass, with severities. Read after the above once you have the architectural context to evaluate them.

Glossary

TermMeaning
Catalog / ARWSourceCatalogA user-selected source folder of RAW files, plus its bookmark/metadata. Culling decisions are keyed per catalog.
CullingThe act of rating (-1 reject, 0 keeper, 2–5 stars) or flagging RAW files so a subset can be exported.
BurstA group of near-duplicate frames (e.g. continuous shooting) detected via similarity scoring, presented together so the user picks the best one.
Deep ReviewAn optional, heavier AI pass over a burst group (subject segmentation + focus scoring) that recommends a winner.
LoupeThe single-photo detail view mode (as opposed to a grid of thumbnails).
FileItemtypealias for RawCullCore.RawCullFileItem — the value type representing one scanned RAW file (name, URL, metadata).

1 - Concurrency Architecture

RawCull actor isolation, structured concurrency, backpressure, cancellation, and request coalescing.

Concurrency Architecture

RawCull is a Swift 6 / strict-concurrency codebase. Almost every piece of mutable shared state — caches, in-flight request tables, coordinators, view models — is isolated to either an actor or @MainActor, and cross-boundary data is required to be Sendable. This document is an inventory of that system and the patterns it relies on, so you can extend it safely.

The two isolation domains

RawCull’s concurrency model boils down to two kinds of isolated types:

  1. @MainActor @Observable view/feature models — own UI-visible state and are read/written from SwiftUI views. Examples: RawCullViewModel, SettingsViewModel, GridThumbnailViewModel, MemoryViewModel, SimilarityScoringModel, BurstAnalysisCoordinator, DeepAIReviewController, RawCullIntelligenceRuntime.
  2. actor workers — own I/O-bound or CPU-bound background work (disk caches, file scanning, RAW/JPEG extraction, request coalescing). They are invoked with await from a Task started on the main actor, and they report progress back via @MainActor @Sendable callback closures rather than by mutating view-model state directly.

Actor inventory

ActorFileResponsibility
DiskCacheManagerActors/DiskCacheManager.swiftGrid-thumbnail JPEG disk cache, keyed by a hash of the source file.
FullSizeJPGDiskCacheActors/FullSizeJPGDiskCache.swiftFull-resolution JPEG disk cache for zoom/loupe.
SharedMemoryCacheActors/SharedMemoryCache.swiftIn-memory NSCache-backed image cache with kernel memory-pressure response.
RequestThumbnailActors/RequestThumbnail.swiftDe-duplicates concurrent thumbnail requests for the same file.
ScanFilesActors/ScanFiles.swiftDiscovers and reads metadata for RAW files in a folder.
ScanAndCreateThumbnailsActors/ScanAndCreateThumbnails.swiftProactively preloads grid thumbnails after a scan.
ScanAndExtractJPGsActors/ScanAndExtractJPGs.swiftExtracts full-size preview JPEGs in bulk.
ExtractAndSaveJPGsActors/ExtractAndSaveJPGs.swiftUser-triggered “export embedded/demosaiced JPEGs” batch job.
SaveJPGImageActors/SaveJPGImage.swiftJPEG encode + file write.
ThumbnailLoaderActors/ThumbnailLoader.swiftRate-limits concurrent grid-image decode (max ~6 in flight).
ThumbnailPreloadGateActors/ThumbnailPreloadGate.swiftDe-duplicates grid decode requests that race with preload.
SettingsFileWriter (private)Model/ViewModels/SettingsViewModel.swiftAtomic settings JSON writes.
WriteSavedFilesJSONModel/JSON/WriteSavedFilesJSON.swiftAtomic culling-state JSON writes.
BurstAnalysisCacheIntelligence/Persistence/BurstAnalysisCache.swiftPersists burst-analysis snapshots per catalog.
PerFileAnalysisArtifactStoreIntelligence/Persistence/PerFileAnalysisArtifactStore.swiftPersists similarity embeddings per file/backend.
RawCullAIModelResourceManagerIntelligence/ModelManagement/RawCullAIModelResourceManager.swiftValidates AI model bundles, constructs providers.
RawCullAIModelDownloadCoordinator / ...DownloadServiceIntelligence/ModelManagement/RawCullAIModelDownloadService.swiftBackground Assets model downloads.

RawImageLoadingConcurrency (Model/RawImageLoadingConcurrency.swift) is a tiny nonisolated enum that centralizes the concurrency limits everything above uses:

nonisolated enum RawImageLoadingConcurrency {
    static var batchExtractionLimit: Int {
        max(1, ProcessInfo.processInfo.activeProcessorCount * 2)
    }
    static var thumbnailPreloadLimit: Int {
        min(4, max(1, ProcessInfo.processInfo.activeProcessorCount / 2))
    }
}

Preloading is deliberately capped lower than explicit export, because it’s lower priority than interactive grid browsing and AI inference, which compete for the same CPU/disk.

Structured concurrency: TaskGroup with manual backpressure

Every batch operation (thumbnail preload, JPEG extraction) uses the same shape: a withTaskGroup loop that calls await group.next() once the number of started tasks reaches the configured limit, so at most N tasks are ever in flight — no unbounded queue of pending work:

return await withTaskGroup(of: Void.self) { group in
    let maxConcurrent = RawImageLoadingConcurrency.thumbnailPreloadLimit
    for (index, url) in urls.enumerated() {
        if Task.isCancelled { group.cancelAll(); break }
        if index >= maxConcurrent { await group.next() }   // backpressure
        group.addTask { await self.processSingleFile(url, ...) }
    }
    await group.waitForAll()
}

(ScanAndCreateThumbnails.swift)

Cancellation is checked at multiple points (before the loop, at task entry, after each await boundary, and again before committing results), so a cancelled batch unwinds quickly instead of finishing wasted work.

Request coalescing via CheckedContinuation

RequestThumbnail avoids decoding the same image twice when several UI elements ask for it concurrently (e.g. scroll-fling revisits a cell that’s already loading). The pattern:

private struct InFlightRequest {
    let generation: UUID
    let task: Task<Void, Never>
    var waiters: [UUID: CheckedContinuation<CGImage?, Never>]
}
  • The first caller for a given cache key creates a generation UUID, spawns the decode Task, and registers its own continuation as a waiter.
  • Subsequent callers for the same key just add their continuation to waiters and return — no new work is started.
  • When the task finishes, every waiter’s continuation is resumed with the same result, validated against the stored generation so a resumed-but- stale finish can’t clobber a newer request.
  • withTaskCancellationHandler removes a waiter’s continuation if its own Task is cancelled while waiting, so continuations can’t leak.

ThumbnailLoader and ThumbnailPreloadGate use the same continuation/queue idiom to rate-limit and de-duplicate grid decodes.

Sendable rules at actor boundaries

CGImage and NSImage are not Sendable and must never cross an actor boundary directly. The codebase’s consistent rule: encode to Data (or otherwise convert to a value type) before crossing.

/// Accepts pre-encoded JPEG `Data` so callers never need to send a `CGImage`
/// across an actor/task boundary. Encode with `DiskCacheManager.jpegData(from:)`
/// inside the actor that owns the image, then pass the resulting `Data` here.
func save(_ jpegData: Data, for key: ThumbnailCacheKey) async

Callers follow the same discipline explicitly, capturing only the actor reference (never self) before hopping to a detached task:

let dcache = diskCache                       // capture the actor-isolated let
Task.detached(priority: .background) {
    await dcache.save(jpegData, for: cacheKey)   // no implicit self capture
}

Everything else that legitimately crosses isolation is either a primitive (Int, Bool), a Sendable value type (URL, Date, CGPoint), or an explicitly-marked nonisolated struct ...: Sendable (e.g. RawImageFileMetadata, JPGExportResult, similarity/burst artifacts used by the Intelligence layer). Callback closures passed into actors are typed as @MainActor @Sendable (Int) -> Void and only ever move primitive progress counts back to the main actor.

Custom synchronization primitives

SharedMemoryCache needs synchronous, lock-free reads of counters that NSCache doesn’t expose (its cost/count), so it uses OSAllocatedUnfairLock instead of actor hops for those specific fields:

private let _memCost = OSAllocatedUnfairLock(initialState: 0)
_memCost.withLock { $0 = max(0, $0 - existing.cost) }

It also listens for kernel memory-pressure transitions with DispatchSource.makeMemoryPressureSource, bridging the callback (which fires on a background dispatch queue) back into actor isolation with a plain Task { await self.handleMemoryPressureEvent() }. No DispatchQueue, NSLock, or semaphore is used anywhere else — everything else is structured concurrency plus these two primitives.

Memory pressure response is graduated, not all-or-nothing:

  • Normal → restore full cache size limits from settings.
  • Warning → shrink both cache limits to 60% of current in place (existing entries are evicted lazily by NSCache, not flushed), to avoid a cascade of re-fetches if the pressure spike is transient.
  • Critical → flush all cache entries and clamp to a 50 MiB floor.

A deliberate cache-admission invariant

ScanAndCreateThumbnails (background preload) intentionally never admits images into SharedMemoryCache’s full-size memory cache — only RequestThumbnail (driven by actual UI requests) does. The code comments explain why: admitting during scan would immediately evict ~180 items in LRU order before the user ever looks at them, guaranteeing a near-100% cache-miss (“boomerang”) rate on first browse. Preload still writes to the disk JPEG cache, so RequestThumbnail’s disk-cache branch can serve the first UI request cheaply without a cold RAW decode — it just doesn’t warm the memory cache. If you’re debugging “why doesn’t this show up after preload”, this is usually why: it’s working as designed.

Generation counters prevent stale results

Several long-running coordinators guard against a slow background computation completing after the user has already moved on (switched catalogs, started a new burst analysis, etc.) by stamping a generation value at the start of a run and checking it before publishing results:

  • RequestThumbnail.InFlightRequest.generation
  • BurstAnalysisCoordinator.generation
  • SimilarityScoringModel’s indexing/grouping generation tracking

The pattern is always: capture the generation before the await, do the work, then only apply results if the generation (or an isCurrent() callback) still matches on return.

Cancellation checkpoints

Long batch jobs (ExtractAndSaveJPGs, ScanAndExtractJPGs) check Task.isCancelled at several points, not just once:

  1. Before starting the next item in the loop.
  2. On entry to each per-item task.
  3. After any await boundary (RAW decode, disk I/O).
  4. Before committing results/counters.

This keeps a cancelled operation (e.g. the user closes the zoom window or switches catalogs mid-extraction) from doing meaningfully more work than necessary, without needing cooperative cancellation to be checked on every single line.

Why this matters when you extend the app

  • New background work should be a new actor, not a class with manual locking — follow the existing actors as templates.
  • New batch operations should follow the TaskGroup + group.next() backpressure shape, sized from RawImageLoadingConcurrency (or a sibling limit you add there) rather than an ad hoc constant.
  • Never pass CGImage/NSImage across an actor boundary — encode to Data first, exactly like DiskCacheManager.save(_:for:).
  • If a computation can outlive its relevance (catalog switch, cancel, settings change), add a generation/identity check before applying its result, following BurstAnalysisCoordinator’s or RequestThumbnail’s lead.

2 - Image Pipeline and Caching

The RawCull scan, decode, thumbnail, preview, memory-cache, and disk-cache pipeline.

Image Pipeline and Caching

This is the path a RAW file takes from “user picked a folder” to “sharp preview pixels on screen”, and the multi-layer cache system that keeps that fast on repeat visits. Read Concurrency Architecture first — this document assumes you know the actor/TaskGroup/Sendable vocabulary from there.

End-to-end flow

User selects folder
        │
        ▼
DiscoverFiles (actor)  ──►  finds candidate RAW file URLs
        │
        ▼
ScanFiles (actor)      ──►  reads EXIF/metadata per file → [FileItem]
        │                    (feeds RawCullViewModel.files)
        ▼
ScanAndCreateThumbnails (actor, background, low priority)
        │                    proactively decodes + caches grid thumbnails
        ▼
   ┌─────────────────────────────┐
   │   SharedMemoryCache (RAM)   │   ← only RequestThumbnail admits here
   │   DiskCacheManager (disk)   │   ← both scan-preload and requests write here
   └─────────────────────────────┘
        ▲
        │  (user scrolls the grid / opens loupe)
        │
RequestThumbnail (actor)  ──►  coalesces duplicate concurrent requests,
        │                       checks memory cache → disk cache → decode
        ▼
   CGImage/NSImage rendered by a SwiftUI Image/ThumbnailComponents view

For full-size zoom/export, a parallel path exists through ScanAndExtractJPGs / ExtractAndSaveJPGs / SaveJPGImage, backed by FullSizeJPGDiskCache instead of DiskCacheManager.

Discovery and metadata scanning

  • DiscoverFiles walks the selected folder (non-recursive) and returns candidate RAW file URLs.
  • ScanFiles turns URLs into FileItems, reading EXIF metadata, capture date, and focus-point data (via RawParserKit/exiftool-backed helpers). This populates RawCullViewModel.files, the root array everything else — filtering, rating lookup, grid rendering — derives from.

Both run as actors so metadata extraction happens off the main actor while still reporting progress (count, ETA) back to the UI via @MainActor @Sendable callback closures defined in Model/Handlers/CreateFileHandlers.swift.

Two cache tiers, two purposes

RawCull deliberately keeps two separate NSCache-backed stores, because the grid and the loupe/zoom view have very different quality/size needs:

TierActorContentTypical size
GridSharedMemoryCache (grid cache) + DiskCacheManager~200px JPEG thumbnails, quality ~0.7Hundreds of items, small footprint
PreviewSharedMemoryCache (preview cache) + FullSizeJPGDiskCacheFull-size decoded previews, quality ~0.85Dozens of items, large footprint

Don’t confuse the two when debugging a “wrong image size” issue — a file can be present in one tier and absent from the other.

Cache sizing

CacheConfig / CacheRecommendationPolicy (Model/Cache/CacheConfig.swift) compute memory-cache byte limits from the machine’s physical RAM tier (≥64 GB / ≥32 GB / below), with an adaptive mode that grows the limits further when a large fraction of RAM is currently free, and shrinks them under memory pressure (see the pressure handling in Concurrency Architecture). The user can also set explicit MB caps in Settings, which act as a hard ceiling on top of whatever the adaptive policy recommends (CacheSettingsLimits.memoryMinMB/MaxMB, gridMinMB/MaxMB).

RequestThumbnail: the single front door for grid images

Actors/RequestThumbnail.swift is the only actor that admits images into SharedMemoryCache’s memory cache. Its requestThumbnail(for:targetSize:purpose:) does, in order:

  1. Coalesce with any in-flight request for the same ThumbnailCacheKey (see the CheckedContinuation pattern in the concurrency doc) — if a decode for this exact file/size/purpose is already running, just wait for it.
  2. Check SharedMemoryCache — a hit returns immediately with no disk I/O.
  3. Check DiskCacheManager’s on-disk JPEG cache — a hit decodes the cached JPEG (cheaper than a fresh RAW decode) and then admits it to memory.
  4. Fall back to a full RAW decode via rawLoader (RawParserKitImageLoader, backed by the external RawParserKit package), encode a JPEG, save it to disk in a detached background task, and admit the decoded image to memory.

The scan-never-admits invariant

ScanAndCreateThumbnails (the proactive preloader that runs right after a folder scan) explicitly does not call into SharedMemoryCache’s admission path — it only writes to the disk cache. Only RequestThumbnail, driven by genuine UI requests (scrolling, opening the loupe), admits to the memory cache. This is a deliberate LRU-ordering decision: if preload admitted eagerly, it would evict already-cached UI-relevant items in scan order, and the user’s first real browse would see a near-100% cache miss rate. Preloading still pays off because the disk cache is warm, so RequestThumbnail’s disk-hit branch is cheap even on a cold memory cache.

RAW decoding

RawImageLoading (Model/RawImageLoading.swift) defines the RawImageLoading protocol RawCull decodes RAW files through, implemented by RawParserKitImageLoader (backed by the external RawParserKit package). Two extraction modes exist, selectable per-export (RawCull/Model/ViewModels / ExtractJPGExportMode):

  • embeddedJPG — pull the small preview JPEG most RAW files already embed; fast, but limited resolution/quality. Wired to ExtractAndSaveJPGs.exportFailureMessage(for:) and SaveJPGImage.
  • demosaicedRAW — fully demosaic the RAW sensor data into a full-size JPEG (SonyRawFormat.createFullSizeJPEG); slower but full resolution.

RawImageLoadingConcurrency.batchExtractionLimit governs how many of these decodes run concurrently in a batch export (scaled to ProcessInfo.activeProcessorCount).

Cache keys and orientation

ThumbnailCacheKey (Model/Cache/ThumbnailCacheKey.swift) identifies a cached image by source file + requested pixel size + purpose (grid vs. preview), so the same file can have independent grid- and preview-tier cache entries without collision. FullSizeJPGDiskCache normalizes EXIF orientation during load (not encode), via an OrientationNormalizedImageLoader — if you see an image rotated wrong, check that path first.

Memory pressure adaptation (recap)

SharedMemoryCache listens for kernel memory-pressure events and adjusts both cache tiers’ limits live: 60% shrink in place on .warning, full flush plus a 50 MiB floor on .critical, restore to the computed adaptive limit on .normal. MemoryViewModel (Model/ViewModels/MemoryViewModel.swift) surfaces SharedMemoryCache.shared.currentPressureLevel plus system/app memory stats (read via mach/vm_statistics64 calls, moved off the main actor with Task.detached) for the Settings memory-usage UI.

Where to look when extending this

  • Adding a new derived-image size/purpose → extend ThumbnailCacheKey.Purpose and thread it through RequestThumbnail, not a new ad hoc cache.
  • Adding a new batch background job → model it as an actor with a TaskGroup + RawImageLoadingConcurrency-style limit, following ScanAndCreateThumbnails/ScanAndExtractJPGs.
  • Changing cache size defaults → CacheConfig/CacheRecommendationPolicy, not scattered literals.

3 - Culling and Persistence

RawCull ratings, filters, catalog switching, and debounced persistent storage.

Culling and Persistence

“Culling” is RawCull’s core job: attach a rating/status to each RAW file so the photographer can filter down to keepers and export just those. This document covers the model that owns that decision, how it’s filtered for display, and how it’s saved to disk.

The model: CullingModel

CullingModel (Model/ViewModels/CullingModel.swift) is a @MainActor @Observable class, constructed exactly once by RawCullViewModel (var cullingModel = CullingModel() — the comment above it says explicitly “This is the only place CullingModel is initialised”). It holds:

private(set) var savedFiles = [SavedFiles]()
private(set) var persistenceError: String?
private(set) var persistenceLoadFailure: SavedFilesReadFailure?
private(set) var hasUnsavedChanges = false

SavedFiles (Model/JSON/SavedFiles.swift) is a Codable struct keyed by catalog: one entry per source folder the user has ever culled, each holding an array of FileRecords (filename, rating, tagged date, and related metadata). Because the model is keyed by catalog URL rather than a single flat list, switching between catalogs never loses another catalog’s ratings — they simply live at a different index in savedFiles.

Dependency injection for testability

CullingModel.init takes its save delay, save handler, and load handler as injectable parameters with production defaults:

init(
    saveDelayNanoseconds: UInt64 = 350_000_000,
    saveHandler: @escaping @Sendable ([SavedFiles]) async throws -> Void
        = { try await WriteSavedFilesJSON.write($0) },
    loadHandler: @escaping @MainActor () -> SavedFilesReadResult
        = { ReadSavedFilesJSON().read() },
)

Tests can substitute an in-memory handler or a different delay without touching disk — this pattern (protocol/closure injection with a live default) recurs throughout the codebase (ScanAndExtractJPGs’s rawLoader, ExecuteCopyFiles’s fileManager, etc.).

Rating flow

  1. UI action (star key, menu command, batch rating) calls into RawCullViewModel+Culling.updateRating(for:rating:).
  2. That calls CullingModel.updateRating(fileName:rating:in:)updateRatings(fileNames:rating:in:), which:
    • Resolves (or creates) the catalog’s entry in savedFiles via ensureCatalog(_:dateStart:).
    • Upserts a FileRecord per filename (upsertRecord), updating rating + tagged date if a record already exists, appending a new one otherwise.
    • Calls scheduleSave().
  3. refreshCullingDerivedState() back on RawCullViewModel+Culling rebuilds the O(1) ratingCache: [String: Int] (filename → rating) and taggedNamesCache: Set<String>, then re-applies the active RatingFilter to recompute filteredFiles.

ratingCache exists purely for performance: without it, checking a file’s rating while rendering hundreds of grid cells would be an O(n) linear scan of savedFiles per cell. It’s rebuilt lazily, only after a mutation, not on every filter/render pass.

Debounced, revisioned persistence

scheduleSave() doesn’t write to disk synchronously — rapid rating changes (rating ten photos in a few seconds) would otherwise cause ten redundant disk writes. Instead:

private func scheduleSave() {
    persistenceRevision &+= 1
    let snapshot = savedFiles          // capture value-type snapshot now
    let revision = persistenceRevision
    saveTask?.cancel()                 // supersede any pending save
    hasUnsavedChanges = true
    saveTask = Task {
        try? await Task.sleep(nanoseconds: saveDelayNanoseconds)   // 350ms
        guard !Task.isCancelled else { return }
        _ = await persist(snapshot, revision: revision)
    }
}

Every mutation cancels the previous pending save and schedules a new one, so only the last snapshot within a 350ms debounce window actually reaches disk. persist(_:revision:) only clears hasUnsavedChanges if its revision still matches the model’s current persistenceRevision — if a newer save has already been scheduled by the time an older one’s write completes, the older write doesn’t incorrectly mark state as “saved”.

persist delegates the actual write to saveHandler, whose production implementation is WriteSavedFilesJSON.write(_:) (Model/JSON/WriteSavedFilesJSON.swift), an actor that:

  1. Encodes [SavedFiles] to JSON.
  2. Backs up the existing file to savedfiles.backup.json.
  3. Writes atomically (Data.write(options: .atomic)) to ~/Library/Application Support/RawCull/savedfiles.json.

Failure handling and recovery

CullingModel surfaces both load and save failures as @Observable state rather than throwing past its callers, so the UI can react:

  • Load failurepersistenceLoadFailure: SavedFilesReadFailure? (a corrupted or unreadable savedfiles.json). The UI offers to archive the broken file (renamed with a timestamp, e.g. savedfiles-corrupt-<timestamp>.json) and start over with an empty rating set, rather than crashing or silently discarding user data.
  • Save failurepersistenceError: String?, with hasUnsavedChanges left true so the user isn’t falsely told their ratings are safe; the next successful save clears both.

Filtering: RatingFilter and filteredFiles

RatingFilter (defined in Main/RawCullFileItem.swift) is a small enum:

enum RatingFilter: Hashable {
    case all
    case rejected   // rating == -1
    case keepers    // rating == 0
    case stars(Int) // rating in 2...5
}

RawCullViewModel.filteredFiles is the projection actually rendered by grid views; it’s recomputed from files (all scanned RAW files) through catalogDisplayCandidates (name-sorted/search-filtered) and then applyFilters(to:), which consults ratingCache/taggedNamesCache for the active ratingFilter. Grid views never filter savedFiles directly — they always go through this derived, cached projection.

Catalog model and switching

ARWSourceCatalog (typealias for RawCullCore.RawCullSourceCatalog, an external-package type) represents one source folder: its URL, display name, and a macOS security-scoped bookmark so RawCull can re-open it across launches without re-prompting through the file picker (App Sandbox requirement). RawCullViewModel tracks which catalog currently has an active startAccessingSecurityScopedResource() grant (activeSecurityScopedURL) and guarantees it’s released (stopActiveSecurityScopedAccess()) both when switching catalogs and in isolated deinit.

Switching selectedSource triggers a rescan (DiscoverFiles/ScanFiles, see Image Pipeline and Caching) and reloads that catalog’s ratings from the already-in-memory savedFiles (rebuildRatingCache()) — no new disk read is needed unless the app just launched, since all catalogs’ records live in the one JSON file.

Where to look when extending this

  • New per-file culling attribute (e.g. a new flag) → add a field to FileRecord, thread it through upsertRecord, and expose a cache similar to ratingCache if it needs O(1) lookup during rendering.
  • New filter → extend RatingFilter and applyFilters(to:), not a parallel ad hoc filtering path.
  • Persistence format changesSavedFiles/FileRecord are plain Codable; bump/handle schema compatibility in DecodeSavedFiles.swift and ReadSavedFilesJSON.swift the same way the Intelligence subsystem versions its caches (see Intelligence and AI Subsystem).

4 - Export and Copy Pipeline

The one-way rsync-backed pipeline RawCull uses to export selected photographs.

Export / Copy Pipeline (rsync)

Once files are rated, RawCull needs to get the keepers out to another folder (a backup drive, a delivery folder, etc.). It does this by shelling out to the system’s /usr/bin/rsync, rather than using FileManager copy APIs directly. This document explains that pipeline and, importantly, why the code around it uses vocabulary (“synchronize”, “offsite server”, “SSH parameters”) that has nothing to do with what RawCull actually does.

RawCull is not a sync tool

RawCullViewModel/CullingModel/the export UI only ever perform a one-way copy: selected source files → a destination folder, on the same Mac. There is no two-way sync, no remote host, no SSH. The rsync-flavored naming survives because RawCull reuses configuration types and helper packages (RsyncArguments, RsyncProcessStreaming, ParseRsyncOutput, DecodeEncodeGeneric) from the author’s earlier RsyncOSX project, which did support real two-way remote sync. Recognizing this up front will save you time — several fields exist purely because a shared type requires them, not because RawCull uses them:

Name you’ll seeWhat it actually means in RawCull
SynchronizeConfiguration.task = "synchronize"Hardcoded; RawCull only ever runs one “task” kind (copy).
localCatalog / offsiteCatalogLocal source folder / local destination folder — never remote.
offsiteServerAlways empty/"localhost"; unused.
offsiteUsername, sshport, sshkeypathandidentityfileUnused SSH fields from the shared config struct.
RemoteDataNumbers / PrepareOutputFromRsyncParses local rsync stdout; “remote” refers to rsync’s own terminology for the far side of a transfer, not a network host.
ParametersRsync/ folder nameHouses copy-operation config, not persistent sync profiles.

Configuration

SynchronizeConfiguration (Model/ParametersRsync/SynchronizeConfiguration.swift) is a plain struct created fresh per copy operation — not a saved profile. The fields RawCull actually uses are localCatalog (source), offsiteCatalog (destination), and rsyncVersion3 (false by default, since macOS ships openrsync, a BSD rewrite, as /usr/bin/rsync; set it true only if the user has installed real GNU rsync 3, which changes how RemoteDataNumbers parses the summary output). parameter8parameter14 are optional passthrough rsync flags defined by the shared RsyncArguments package but not currently exposed in RawCull’s UI — a hook for future power users.

Params.swift and ArgumentsSynchronize.swift translate a SynchronizeConfiguration plus the export options (dry-run, minimum rating, tagged-only) into the actual argument array passed to rsync, e.g.:

["-avc", "--itemize-changes", "--update", "--from0",
 "--files-from=/var/tmp/copyfilelist-<uuid>.list0",
 "<source folder>/", "<destination folder>/"]

--files-from is the key flag: rather than copying an entire folder, RawCull writes a null-terminated list of just the rated/tagged filenames the user selected and tells rsync to copy only those.

Running the copy: ExecuteCopyFiles

ExecuteCopyFiles (Model/ParametersRsync/ExecuteCopyFiles.swift) is a @MainActor @Observable class that orchestrates one copy run:

  1. Resolve the file listviewModel.extractRatedfilenames(minimumRating:) (or the tagged-files equivalent) returns the filenames to copy.
  2. Write the include list — filenames are null-separated and written to a temp file under /var/tmp/copyfilelist-<UUID>.list0; the temp file is deleted in cleanup() once the run finishes.
  3. Resolve security-scoped access for both source and destination folders (they may be different security-scoped bookmarks than the currently active catalog), tracked as sourceAccessedURL/ destAccessedURL and released in cleanup().
  4. Build arguments and launch — via the external RsyncProcessStreaming package’s process wrapper, configured through CreateStreamingHandlers.createHandlers(fileHandler:processTermination:) (Model/Handlers/CreateStreamingHandlers.swift), which hardcodes rsyncPath: "/usr/bin/rsync".
  5. Stream progressfileHandler is invoked with a running count as rsync emits itemized-change lines; processTermination receives the full output array plus exit status when the process exits.
  6. Report a typed failure if any step fails, via the CopyStartupFailure enum (rsyncArgumentsUnavailable, noMatchingFiles, sourceAccessFailed, processLaunchFailed, …), so the UI can show a specific alert rather than a generic error.

Any startup or teardown failure is surfaced through viewModel.operationFailurePresentation, driving the alert already wired up in RawCullMainView.

isolated deinit { cleanup() } guarantees the temp include-file and security-scoped access are released even if ExecuteCopyFiles is deallocated mid-flight (e.g. the sheet is dismissed early).

Parsing rsync’s output

rsync’s own stdout is itemized-change text plus a trailing statistics block. RawCull turns that into UI-friendly data in three steps:

  • CreateOutputforView wraps each raw output line in an RsyncOutputData (adds a stable UUID id for SwiftUI List/Table).
  • RemoteDataNumbers (despite the “remote” in its name — just parses local rsync output) drives PrepareOutputFromRsync (trims to the last ~20 lines, i.e. the summary block) and the external ParseRsyncOutput package’s getstats() to extract numeric fields: files transferred, total bytes, elapsed time — populating filestransferredInt, totaltransferredfilessizeInt, and a formatted stats string for display.
  • ItemizedOutput parses individual itemized-change lines (e.g. >f+++++++ DSC0001.ARW) into a change-kind + filename pair so ItemizedOutputRow can color-code new/updated/deleted entries in the detailed output table (Views/OutputViews).

User-facing flow

  1. User opens the copy sheet (Views/CopyFiles/CopyFilesView.swift), picks a minimum star rating (or “tagged files only”) and a destination, and optionally toggles dry-run.
  2. CopyFilesView constructs and drives ExecuteCopyFiles.
  3. On completion, a summary (“3 files · 45.2 MB”) is shown with a button to open the detailed itemized output table (Views/OutputViews/DetailsView.swift), backed by RemoteDataNumbers.
  4. Views/SavedFiles provides a separate, persistent browser over previously-saved culling records (CullingModel.savedFiles) independent of any specific copy run.

Where to look when extending this

  • Changing which rsync flags are usedArgumentsSynchronize.swift and Params.swift; don’t hand-build argument arrays elsewhere.
  • Exposing the currently-unused optional parameters (8–14) → add UI bindings in Views/CopyFiles/Views/Settings and thread through Params.swift; the plumbing to RsyncArguments already exists.
  • New failure conditions → extend CopyStartupFailure, not a bare Error, so the UI keeps getting a specific, localized message.
  • If you’re tracing a bug and a type name mentions “remote”, “offsite”, or “SSH”, first double-check whether it’s a real code path or one of the unused legacy fields listed above.

5 - Intelligence and AI Subsystem

RawCull’s on-device similarity, semantic search, burst analysis, and deep-review architecture.

Intelligence / AI Subsystem

RawCull/Intelligence/ is where RawCull’s on-device machine-learning features live: grouping near-duplicate “burst” shots, ranking them by sharpness/subject focus, letting the user search photos by natural-language description, and (optionally) running a heavier “Deep Review” AI pass that recommends a winner within a burst. All of it runs locally — no photo data or embeddings leave the machine, and every optional model requires an explicit license acceptance and download before RawCull will use it.

This subsystem depends on sibling packages that own the actual model execution, and the CLIP/segmentation models themselves are run through Apple’s CoreAI framework (import CoreAI / CoreAIImageSegmenter, from Apple’s coreai-models package, macOS 27+) — RawCull does not ship a hand-rolled CoreML pipeline for these features. The package boundaries are:

  • PhotoAIContracts — shared value types/protocols (SimilarityArtifact, SimilarityBackendDescriptor, ModelIdentity, etc.) with no model code of their own.
  • PhotoAIKit — the package that actually wraps Apple’s CoreAI framework, split into several library products RawCull links individually: CoreAICLIPBackend (CoreAICLIPProvider, CLIP/SigLIP2 image+text embeddings via import CoreAI/CoreAIImageSegmenter), CoreAISAM3Backend and CoreAIEfficientSAMBackend (subject segmentation, same CoreAI foundation, two swappable model families), and VisionFeaturePrintBackend (a CoreAI-independent fallback similarity provider built on Apple’s Vision framework, always available with no download). PhotoAIWorkflows/PhotoAIStorage provide shared indexing/storage helpers used by these backends.
  • PhotoAnalysisKit — a separate, unrelated package used only for sharpness/focus scoring (the Laplacian-based focus-mask and subject-focus algorithms described in Settings and Configuration and the Deep Review section below). It’s built on Vision/CoreImage/Accelerate, not CoreAI, and has no CLIP or segmentation model code.

RawCull’s Intelligence/ folder is the integration and orchestration layer on top of all of these — it owns configuration, caching, lifecycle, and how results reach the SwiftUI layer, not the models themselves. It never calls CoreAI/CoreAIImageSegmenter directly — only through the CoreAI*Backend provider types PhotoAIKit exposes.

Folder map

FolderRole
Contracts/RawCullAIModels.swift — the shared data types every other Intelligence file depends on (capability status enums, paths, model IDs).
Composition/RawCullIntelligenceRuntime + RawCullAIIntegration — the subsystem’s own composition root and configuration-application logic.
Similarity/Groups near-duplicate images into bursts and ranks/scores their visual similarity.
SemanticSearch/Natural-language photo search via CLIP text/image embeddings.
BurstAnalysis/Orchestrates sharpness scoring + similarity grouping into one cached “burst analysis” pass per catalog.
DeepReview/Optional, heavier AI pass: subject segmentation + focus scoring to recommend a burst winner.
ModelManagement/Downloading, licensing, and validating optional model bundles (CLIP, SAM3/EfficientSAM).
Persistence/Actor-isolated disk caches for burst-analysis snapshots and per-file similarity artifacts.
Presentation/Small UI-facing presentation helpers (e.g. semantic search result formatting).

Contracts: the shared vocabulary

Intelligence/Contracts/RawCullAIModels.swift defines the types every other file in this subsystem builds on:

  • RawCullCLIPModel (.dataComp / .openAI) and RawCullSegmentationModel (.sam3 / .efficientSAM) — the mutually exclusive model choices for semantic search and Deep Review, respectively. RawCullAIModelInclusion is a compile-time switch table deciding which of these are actually offered in this build (currently: DataComp CLIP + SAM3).
  • RawCullAIPaths — computes every on-disk location the subsystem uses, all rooted under RawCull’s existing Application Support/Caches directories rather than a separate namespace (models under Application Support/RawCull/Models/<ModelName>/, subject masks under Caches/no.blogspot.RawCull/SAM3Masks/, burst analysis under Application Support/RawCull/BurstAnalysis/).
  • RawCullAICapabilityStatus / RawCullSemanticSearchCapabilityStatus — structured “is this feature usable right now” enums (checking/available/missing/invalid/unavailable, plus a semantic-search-specific ready(location:backend:)). Semantic search has its own status type because Vision alone is a valid similarity backend but text search specifically requires a validated CLIP provider.
  • RawCullAICapabilities — the full readiness snapshot (per segmentation model, per CLIP model, per semantic-search variant, plus Vision feature-print and subject-mask storage) that Settings UI reads.
  • RawCullSavedBurstEvidenceScanner — a read-only, cancellable @concurrent scan of the existing burst cache directory that counts how many CLIP vs. Vision embeddings are already saved, purely for a diagnostics panel in Settings; it never mutates the cache.

Composition root: RawCullIntelligenceRuntime + RawCullAIIntegration

Intelligence/Composition/RawCullIntelligenceRuntime.swift is this subsystem’s own composition root, nested inside the app-wide one described in Architecture Overview. RawCullApplicationState.make(...) builds, in order: RawCullAIModelManagementModelRawCullAISettingsModelSimilarityScoringModelRawCullSimilarityFeatureRawCullSemanticSearchFeatureDeepAIReviewControllerRawCullViewModelRawCullIntelligenceRuntime, then wires bidirectional references (similarityFeature.bindApplicationContext(viewModel), settingsModel.bindConfigurationConsumer(intelligenceRuntime)) and asserts identity equality everywhere a shared instance is expected (assert(viewModel.similarityFeature === intelligenceRuntime.similarityFeature)), specifically to catch an accidental duplicate instance during future refactors.

RawCullIntelligenceRuntime.apply(configuration:) is the single choke point through which settings changes reach running features. It compares an incoming RawCullIntelligenceConfiguration’s identity (backend descriptors + capability statuses, all Sendable, computed via configuration.identity) against the last-applied one, using a monotonic revision: UInt64 to reject stale/out-of-order configuration pushes, and only calls similarityFeature.replaceSimilarityService(...) / replaceSemanticSearchConfiguration(...) for the parts that actually changed:

func apply(configuration: RawCullIntelligenceConfiguration) -> RawCullAICapabilities {
    let incomingIdentity = configuration.identity
    guard configuration.revision > lastAcceptedConfigurationRevision ?? 0 else {
        return integration.capabilities()   // stale push, ignore
    }
    guard previousIdentity != incomingIdentity else {
        lastAcceptedConfigurationRevision = configuration.revision
        return integration.capabilities()   // no-op, nothing actually changed
    }
    // ... replace only the changed sub-configuration ...
}

Note the deliberate Sendable/non-Sendable split: identity is Sendable value data that’s safe to compare and log, while the actual service: any RawCullSimilarityServicing (the concrete Vision/CLIP provider) stays @MainActor-only and never crosses an isolation boundary — only its backendDescriptor (a Sendable value) does.

Deep dive: see Intelligence Runtime for the full lifecycle — construction order and identity assertions, exactly how a settings change or a completed model download reaches apply(configuration:), what each per-field diff actually swaps downstream, and how to safely add a new runtime-configurable setting or AI feature.

Similarity and burst grouping

SimilarityScoringModel (Similarity/SimilarityScoringModel.swift) is the @MainActor @Observable state machine behind both similarity grouping and semantic search indexing. It talks to a pluggable backend behind RawCullSimilarityServicing — currently either Vision’s on-device feature print (RawCullVisionSimilarityService, built on PhotoAIKit’s VisionFeaturePrintBackend, always available, no download required) or CLIP embeddings (RawCullSemanticSearchService, backed by PhotoAIKit’s CoreAICLIPBackend — i.e. Apple’s CoreAI framework — and requires a downloaded model), selected via Settings (RawCullAISettingsModel.useCLIPForSimilarity). It also owns indexing, i.e. computing and persisting embeddings for a catalog’s files, tracked through phases (idlegeneratingsaving) so the UI can show progress and so a stale indexing run can be safely discarded if the catalog changes mid-flight.

RawCullSimilarityFeature is the thin @MainActor @Observable boundary the rest of the app (views, RawCullViewModel) actually talks to — it exposes burst-group lookups and grouping actions without leaking SimilarityScoringModel’s internal indexing machinery.

Burst analysis: tying sharpness + similarity together

A “burst analysis” run combines two independently-computed signals — sharpness score (per file) and similarity distance (between files) — into grouped bursts with a per-group ranking. BurstAnalysisCoordinator (BurstAnalysis/BurstAnalysisCoordinator.swift, @MainActor @Observable) owns this orchestration, kept deliberately separate from RawCullViewModel (which only supplies burst-analysis inputs — the ordered file list, catalog identity — and applies the result back into its own burstAnalysisResults/burstReviewStates once accepted).

run(request:initialReviewStates:fullCatalogFileIDs:callbacks:) is the pipeline entry point:

  1. callbacks.isCurrent() — bail out immediately if the caller already knows this run is stale (catalog switched, generation advanced).
  2. Load cacheprepareCache(for:...) checks BurstAnalysisCacheRepository for a compatible snapshot (see cache versioning below); a full cache hit skips straight to applying results.
  3. Score sharpness for files that don’t have a cached score.
  4. Score similarity for file pairs that don’t have a cached embedding.
  5. Group into bursts via similarityModel.groupBursts(files:configuration:).
  6. Restore or seed review states — if a compatible legacy cache snapshot exists, prior “reviewed”/“deferred” per-group states are migrated onto the newly computed groups; otherwise the caller’s initialReviewStates (typically empty) are used.
  7. Publish through callbacks.applyResult(...), which itself re-validates currency before mutating RawCullViewModel state.

Every step checks callbacks.isCurrent() again before proceeding — identical in spirit to the generation-check pattern from Concurrency Architecture.

Cache versioning

BurstAnalysisCacheSnapshot (Persistence/BurstAnalysisCache.swift) is the Codable structure written to Application Support/RawCull/BurstAnalysis/<catalog>.json. It’s explicitly versioned on three independent axes so a stale or incompatible cache is never blindly trusted:

  • schemaVersion — the on-disk JSON shape.
  • algorithmVersion — the burst-grouping/ranking algorithm itself.
  • BurstSimilaritySignature.artifactSchemaVersion / embeddingPipelineVersion — the similarity backend and how its embeddings were produced.

If any of these mismatch, RawCull treats the cache as stale for that part of the pipeline and recomputes just the affected signal (e.g. a CLIP model upgrade recomputes similarity but not sharpness), rather than discarding everything.

RawCullSemanticSearchFeature (SemanticSearch/RawCullSemanticSearchFeature.swift) is the UI-facing surface for natural-language photo search, backed by the same SimilarityScoringModel used for burst grouping (asserted via sharesSimilarityModelIdentity(with:) in RawCullViewModel.init) so a photo’s embedding is computed once and reused for both features. RawCullSemanticSearchService performs CLIP text-vs-image cosine scoring via PhotoAIKit’s CoreAICLIPProvider (Apple’s CoreAI framework); if no CLIP model is downloaded/valid, semantic search reports .unavailable/.failed capability rather than silently falling back — unlike plain similarity grouping, which can fall back to the CoreAI-free Vision feature-print backend. Results are modeled as an explicit state machine (RawCullSemanticSearchState: .idle / .searching / .results / .emptyIndex / .failed) so the UI never has to infer “are we loading or just have zero matches” from ambiguous nil/empty checks.

Deep Review

Deep Review is an optional, user-triggered, heavier analysis of a single burst group: it segments the subject in each frame using PhotoAIKit’s CoreAI-backed segmentation (SAM3 or EfficientSAM, whichever the user selected — both are CoreAI/CoreAIImageSegmenter model families exposed via CoreAISAM3Backend/CoreAIEfficientSAMBackend) and scores focus specifically on that subject region (SubjectMaskFocusScorer, from PhotoAnalysisKit, edge-energy based — not a CoreAI model), then recommends which frame in the group is sharpest on the subject, which can differ from a naive whole-frame sharpness ranking (e.g. a background-blurred portrait where the subject is what matters).

DeepAIReviewController (@MainActor @Observable) is the runtime-owned boundary views talk to; it delegates actual scoring/state work to DeepAIReviewFeature and adapts RawCullViewModel’s burst data into a DeepAIReviewGroupContext through the small DeepAIReviewApplicationContext protocol RawCullViewModel conforms to — another instance of the “protocol-shaped seam between the central view model and a feature controller” pattern used throughout this codebase. Progress is modeled as DeepAIReviewPresentationState (checkingready/unavailablepreparingrunningcompletingcompleted/failed/cancelled), and the UI shows an inline spinner via deepAIReviewController.isRunning(groupID:) while a specific group is being analyzed.

Model management and licensing

RawCullAIModelManagementModel and RawCullAISettingsModel (ModelManagement/) handle the optional model bundles (CLIP variants, SAM variants) that aren’t bundled with the app — they’re multi-hundred-MB to multi-GB downloads the user opts into:

  • RawCullAIModelDownloadCatalog — the production catalog of downloadable model IDs (RawCullAIModelDownloadID: clipDataComp, clipOpenAI, efficientSAM, sam3), gated by the RawCullAIModelInclusion compile-time flags mentioned above.
  • RawCullAIModelDownloadService — an actor-based coordinator integrating with Apple’s Background Assets framework so large downloads survive app relaunch/backgrounding.
  • RawCullAIModelLicenceAcceptance — persists which model licenses the user has explicitly accepted (with RawCull version + timestamp) to ModelLicenceAcceptances.json; a model isn’t used until its license is accepted, even if already downloaded.
  • RawCullAIModelResourceManager — a generic actor that validates a downloaded bundle and constructs the concrete provider for it, caching the validated provider so re-validation doesn’t happen on every use.
  • RawCullAISettingsModel — the @Observable @MainActor model the Settings UI actually binds to. Per its own doc comment, it “intentionally does not expose PhotoAIKit providers, repositories, or the composition root” — it only exposes capability statuses and simple preference toggles (useCLIPForSimilarity, selectedCLIPModel, selectedSegmentationModel), persisted to UserDefaults, and forwards changes to the runtime via bindConfigurationConsumer.

Persistence layer

Two actors persist Intelligence results to disk, independent of CullingModel’s rating persistence:

  • PerFileAnalysisArtifactStore — stores each file’s similarity embedding (SimilarityArtifact, Sendable Codable) keyed by file ID and backend, so re-opening a catalog doesn’t require re-embedding every photo.
  • BurstAnalysisCache — stores the full BurstAnalysisCacheSnapshot per catalog described above.

Both use atomic writes and are safe to interrupt mid-write (a cancelled indexing run can’t corrupt an already-persisted artifact for a different file).

Where to look when extending this

  • Adding a new similarity/segmentation backend → implement the relevant protocol (RawCullSimilarityServicing, etc.) in PhotoAIKit (not PhotoAnalysisKit, which is sharpness-only), add a case to the relevant enum in RawCullAIModels.swift, and thread it through RawCullAIModelDownloadCatalog + RawCullAISettingsModel — don’t bypass RawCullIntelligenceRuntime.apply(configuration:).
  • Changing cache-invalidation behavior → bump the relevant version field in BurstSimilaritySignature/BurstAnalysisCacheSnapshot, don’t add a parallel invalidation check.
  • New Deep-Review-style heavy analysis → follow DeepAIReviewController/DeepAIReviewFeature’s split (runtime-owned controller + protocol-based application context) rather than adding logic directly to RawCullViewModel.

6 - The Intelligence Runtime

How RawCull constructs, refreshes, and safely extends its long-lived intelligence runtime.

The Intelligence Runtime

This document is a deep dive into RawCullIntelligenceRuntime and its composition partner RawCullAIIntegration — together “the runtime” — which is only summarized in Intelligence and AI Subsystem. Read that doc first for the subsystem’s overall shape (folders, backends, CoreAI attribution); this document explains how the runtime object itself is built, how it reacts when a part of its configuration changes, how to extend it safely, and why it’s built this way.

What “the runtime” actually is

Two collaborating types, both @MainActor, with a strict division of responsibility:

TypeRoleLives in
RawCullAIIntegrationOwns every concrete provider (CoreAI CLIP/SAM providers, the Vision fallback, model-resource managers, the subject-mask repository) and knows how to (re)validate and (re)build them. Never seen by views or RawCullViewModel.Intelligence/Composition/RawCullAIIntegration.swift
RawCullIntelligenceRuntimeThe stable, long-lived object RawCullApp actually holds. Owns the feature-facing models (similarityFeature, semanticSearchFeature, deepAIReviewController, settingsModel, modelManagementModel) and the single entry point through which a settings change is turned into “swap this one service, leave everything else alone.”Intelligence/Composition/RawCullIntelligenceRuntime.swift

Neither type is a singleton (no static let shared) — instead, exactly one of each is constructed by RawCullApplicationState, once, at app launch, and threaded everywhere else by reference. This is deliberate: see “Why this design is good” below.

Creating the runtime

RawCullApp.init() calls RawCullApplicationState.live(), which forwards to RawCullApplicationState.make(integration:...) (RawCullIntelligenceRuntime.swift, lines ~151-224). make(...) takes every external dependency as a parameter with a production default (RawCullAIIntegration(), PerFileAnalysisArtifactStore.shared, UserDefaults.standard, RawCullAIModelDownloadCatalog.production, …) — this is what lets RawCullTests construct a fully wired runtime against fakes without touching the real filesystem or downloading real models.

Construction happens in a fixed, dependency-respecting order:

1. modelManagementModel = RawCullAIModelManagementModel(...)      // download/licence lifecycle
2. settingsModel = RawCullAISettingsModel(integration:, modelManagementModel:)
3. initialConfiguration = settingsModel.configurationSnapshot()   // revision 0, synchronous, no I/O
4. similarityModel = SimilarityScoringModel(similarityService: initialConfiguration.similarity.service, ...)
5. similarityFeature = RawCullSimilarityFeature(similarityModel:)
6. semanticSearchFeature = RawCullSemanticSearchFeature(similarityModel:, similarityFeature:)
7. deepAIReviewController = DeepAIReviewController(feature: integration.deepAIReviewFeature)
8. viewModel = RawCullViewModel(similarityModel:, similarityFeature:, semanticSearchFeature:, deepAIReviewController:)
9. intelligenceRuntime = RawCullIntelligenceRuntime(integration:, similarityFeature:, semanticSearchFeature:,
                                                     deepAIReviewController:, settingsModel:,
                                                     applicationContext: viewModel)
10. semanticSearchFeature.bindApplicationTarget(viewModel)
11. settingsModel.bindConfigurationConsumer(intelligenceRuntime)

Two things are worth noticing about this order:

  • Step 3 reads settingsModel’s synchronous snapshot (whatever UserDefaults says the user last chose, paired with whatever integration.capabilities() already knows at construction time — before any async model validation has run). This is intentionally cheap and synchronous so the app can finish building its object graph and show a UI immediately; it does not wait for model files to be validated on disk. The real, validated capabilities arrive later (see “The first refresh” below) and flow through the exact same apply(configuration:) path as any other runtime settings change — there is no separate “initial capability” code path.
  • bindConfigurationConsumer is called last, and it is itself what triggers the first apply(configuration:) call (see RawCullAISettingsModel.bindConfigurationConsumer, which calls publishConfiguration() immediately after storing the weak reference). So even though step 3’s snapshot was never explicitly “applied,” its equivalent — freshly recomputed at bind time — is applied as revision 1 once the whole graph exists and can safely receive it.

Post-construction identity assertions

make(...) ends with a block of assert(...) calls (viewModel.similarityFeature === intelligenceRuntime.similarityFeature, semanticSearchFeature.sharesSimilarityFeatureIdentity(with:), settingsModel.modelManagementModel === intelligenceRuntime.modelManagementModel, etc.) — debug-only checks (compiled out in Release via assert) that fail loudly if a future refactor accidentally constructs a second instance of any of these objects instead of reusing the one built here. Because nothing else in the app is allowed to call these initializers directly (by convention, not by access control), these assertions are effectively a regression test for “the composition root is still the only place that builds these.”

The first refresh after launch

RawCullMainView’s .task { await intelligenceRuntime.settingsModel.refresh() } (RawCullApp.swift) is the very first thing that happens once the main window’s view tree appears. This is what actually validates model bundles on disk and reconciles the runtime with reality — see the full flow in the next section, since it’s the same machinery used for every later settings/model change, not a special-cased startup routine.

How a change flows through the runtime

There are exactly two kinds of events that can change what the runtime’s features are backed by, and both end up going through the same funnel:

  1. The user changes an AI settingRawCullAISettingsModel.setUseCLIPForSimilarity(_:), setSelectedCLIPModel(_:), or setSelectedSegmentationModel(_:), each bound to a SwiftUI control in Views/Settings/AISettingsTab.swift.
  2. Model availability changes — a model finishes downloading, a licence is accepted, a model is removed, or the app performs its first post-launch validation pass. All of these go through RawCullAIModelManagementModel.refresh() (called directly, or as the tail end of startModelDownload/acceptModelLicence/removeManagedModel).

Both paths converge on RawCullAISettingsModel.publishConfiguration():

private func publishConfiguration() {
    guard let configurationConsumer else { return }
    configurationRevision &+= 1
    capabilities = configurationConsumer.apply(
        configuration: configurationSnapshot(revision: configurationRevision),
    )
}

Full call graph for path 2 (the more involved one):

RawCullAIModelManagementModel.refresh()
  → coordinator.snapshot()                          // Background Assets download states
  → apply(snapshot:)                                 // updates `presentations` for Settings UI
  → locationsConsumer.applyManagedModelLocations(_:) // = RawCullAISettingsModel
      → integration.setManagedModelLocations(_:)     // tell each resource manager the on-disk URL
      → integration.refreshCapabilities()  ─┐ concurrently
      → evidenceScan()                     ─┘
          // refreshCapabilities() re-validates every model bundle off the
          // main actor via `async let`, rebuilds `clipSimilarityProviders`/
          // `segmentationProviders`, and returns a fresh `RawCullAICapabilities`
      → self.capabilities = <fresh capabilities>
      → publishConfiguration()
          → configurationConsumer.apply(configuration:)  // = RawCullIntelligenceRuntime
              → compares identity, replaces only what changed (see below)
              → returns integration.capabilities()
      → self.capabilities = <returned capabilities>      // published a second time, now via apply()'s return value

Path 1 (user flips a setting) is the same tail: set...(_:) mutates the one @Observable field, persists it to UserDefaults, and calls publishConfiguration() directly — no re-validation needed since the model files on disk haven’t changed, only which validated provider the user wants used.

RawCullIntelligenceConfiguration: the message passed at each step

Every apply(configuration:) call receives one of these (RawCullIntelligenceRuntime.swift, lines 27-43):

@MainActor
struct RawCullIntelligenceConfiguration {
    let revision: UInt64
    let similarity: RawCullSimilarityConfiguration       // wraps `any RawCullSimilarityServicing`
    let semanticSearch: RawCullSemanticSearchConfiguration // capability + optional `any RawCullSemanticSearchServicing`
    let segmentationModel: RawCullSegmentationModel        // .sam3 / .efficientSAM

    var identity: RawCullIntelligenceConfigurationIdentity { ... }
}

Note the @MainActor isolation and the concrete any RawCullSimilarityServicing provider reference inside it — this whole struct, and the CoreAI-backed providers it can carry, never crosses an actor/task boundary. Only its computed .identity does real comparison work, and identity is a separate, Sendable, value-only struct:

nonisolated struct RawCullIntelligenceConfigurationIdentity: Equatable, Sendable {
    let similarityBackend: SimilarityBackendDescriptor
    let similarityArtifactBackends: [SimilarityBackendDescriptor]
    let semanticSearchCapability: RawCullSemanticSearchCapabilityStatus
    let semanticSearchBackend: SimilarityBackendDescriptor?
    let segmentationModel: RawCullSegmentationModel
}

SimilarityBackendDescriptor (from PhotoAIContracts) is a small value type (backend name, model fingerprint, preprocessing/normalization versions) — it describes a provider well enough to detect “did the effective backend actually change” without needing the provider itself to be Sendable or Equatable. This is the same “encode identity as a small Sendable value, keep the real object @MainActor-only” pattern the disk-cache/thumbnail pipeline uses for CGImage/Data (see Concurrency Architecture) — here applied to service instances rather than pixel buffers.

apply(configuration:): revision gate, then identity diff

func apply(configuration: RawCullIntelligenceConfiguration) -> RawCullAICapabilities {
    let incomingIdentity = configuration.identity

    if let lastAcceptedConfigurationRevision {
        guard configuration.revision > lastAcceptedConfigurationRevision else {
            assert(
                configuration.revision < lastAcceptedConfigurationRevision
                    || incomingIdentity == lastAppliedConfigurationIdentity,
                "One configuration revision described multiple identities.",
            )
            return integration.capabilities()
        }
    }

    let previousIdentity = lastAppliedConfigurationIdentity
    guard previousIdentity != incomingIdentity else {
        lastAcceptedConfigurationRevision = configuration.revision
        return integration.capabilities()
    }

    if previousIdentity?.segmentationModel != incomingIdentity.segmentationModel {
        integration.setSelectedSegmentationModel(configuration.segmentationModel)
    }
    if previousIdentity?.similarityBackend != incomingIdentity.similarityBackend
        || previousIdentity?.similarityArtifactBackends != incomingIdentity.similarityArtifactBackends {
        similarityFeature.replaceSimilarityService(configuration.similarity.service)
    }
    if previousIdentity?.semanticSearchCapability != incomingIdentity.semanticSearchCapability
        || previousIdentity?.semanticSearchBackend != incomingIdentity.semanticSearchBackend {
        similarityFeature.replaceSemanticSearchConfiguration(
            capability: configuration.semanticSearch.capability,
            service: configuration.semanticSearch.service,
        )
    }

    lastAppliedConfigurationIdentity = incomingIdentity
    lastAcceptedConfigurationRevision = configuration.revision
    return integration.capabilities()
}

Three distinct guards, in order:

  1. Staleness/ordering guard (configuration.revision > lastAcceptedConfigurationRevision). configurationRevision on RawCullAISettingsModel only ever increments, and every publishConfiguration() call is synchronous on the main actor, so in practice revisions cannot actually arrive out of order today — but the guard exists so that if a future change ever made publishConfiguration() async (e.g. awaiting something before calling apply), an old, slow-to-arrive configuration could never clobber a newer one. This is the same generation/revision-counter idiom used throughout the codebase (RequestThumbnail.generation, BurstAnalysisCoordinator.generation, CullingModel.persistenceRevision — see Concurrency Architecture and Culling and Persistence). The nested assert is a correctness canary, not a runtime safeguard: it would only fire if a caller reused the same revision number for two genuinely different configurations, which would indicate a bug in the revision-incrementing logic itself.
  2. No-op guard (previousIdentity != incomingIdentity). A configuration can have a strictly newer revision but describe the same effective backends — e.g. the user toggled a segmentation model preference that happens to already be selected, or refreshCapabilities() ran but every provider it found was identical to what was already active. In that case the revision bookkeeping still advances (so a later out-of-order push is still rejected correctly), but no feature is touched.
  3. Per-field diff — the three if previousIdentity?.field != incomingIdentity.field checks are the heart of the design: each compares only the slice of identity relevant to one feature and calls the one corresponding mutator. A CLIP model swap only ever calls similarityFeature.replaceSimilarityService(...) (and, if semantic search’s capability/backend also changed, replaceSemanticSearchConfiguration(...)) — it never touches segmentation, and vice versa.

What “replacing a service” actually does downstream

  • Segmentation: integration.setSelectedSegmentationModel(_:) updates capabilitySnapshot, then calls activateSelectedSegmentationProvider(availability:), which looks up the already-validated provider for the newly selected model in segmentationProviders[selectedSegmentationModel] (populated by the most recent refreshCapabilities() — swapping the selection never re-validates a model from disk; that only happens during a refresh() cycle) and calls installSegmentationProviderIfNeeded(_:). That method compares provider.modelIdentity against the currently active one and, only if it actually differs, rebuilds the small dependent object graph (SubjectMaskRepositoryConfigurationSubjectMaskRepositorySegmentationServiceSubjectMaskSelector) and reinstalls the Deep Review pipeline (deepAIReviewFeature.install(service:maskLoader:availability:)). If no validated provider exists yet for the selection, an UnavailableSegmentationProvider is installed instead, which throws a clear SubjectSegmentationError.providerFailure if Deep Review is invoked — there’s no silent no-op mode.
  • Similarity: similarityFeature.replaceSimilarityService(_:) swaps the backend SimilarityScoringModel uses for grouping/indexing. Because the configuration always carries a concrete, already-constructed service (Vision fallback or a validated CoreAI CLIP provider — decided by RawCullAIIntegration.similarityService(prefersCLIP:clipModel:), which itself falls back to Vision and logs a warning if the requested CLIP provider isn’t validated/available), the swap is just a reference assignment plus whatever bookkeeping SimilarityScoringModel does internally to invalidate in-flight indexing — it is not a re-index of every photo from scratch.
  • Semantic search: replaceSemanticSearchConfiguration(capability:service:) updates the feature’s reported capability status and, if a validated CLIP provider exists, wires up RawCullCLIPSemanticSearchService; otherwise it reports .unavailable/.failed per Intelligence and AI Subsystem.

Why this design is good

  • Single choke point, no scattered mutation. Nothing outside RawCullIntelligenceRuntime.apply(configuration:) is allowed to call similarityFeature.replaceSimilarityService(...) or integration.setSelectedSegmentationModel(...) directly — every settings change and every model-download completion funnels through the exact same function. A new engineer adding a new toggle only has to make sure it ends up inside a RawCullIntelligenceConfiguration and bumps a revision; they don’t need to learn a second wiring path.
  • Cheap no-ops by construction. Because identity comparison happens before any mutation, a spurious refresh() (the download-progress callback fires often, and refresh() is also called unconditionally at launch) costs one struct comparison, not a service teardown/rebuild — this matters because refreshCapabilities() runs on every model download completion and could otherwise cascade into unnecessary re-indexing.
  • Minimal blast radius per change. The three independent if guards mean a segmentation-model change can never accidentally also swap the similarity backend, and vice versa — each feature is only ever told to update when its own piece of identity actually changed, which is only possible because identity is modeled as one struct with independently comparable fields rather than one opaque hash.
  • Sendability is enforced by the type system, not convention. RawCullIntelligenceConfigurationIdentity is the only thing that’s Sendable/comparable across isolation boundaries; the actual providers stay @MainActor-confined inside RawCullIntelligenceConfiguration. A future contributor cannot accidentally make a CoreAI provider cross an actor boundary through this path — the compiler would reject it, since RawCullIntelligenceConfiguration itself is explicitly @MainActor and not Sendable.
  • Ordering safety without complexity. The revision counter handles the theoretical async-reordering case cheaply (one integer comparison) instead of requiring apply to be reentrant-safe or serialized behind a task queue — appropriate given today’s call sites are all synchronous, but it costs nothing to keep it correct if that ever changes.
  • Composition root discipline is self-enforcing. The assert(x === y) calls in RawCullApplicationState.make(...) turn “don’t build a second instance of this” from a code-review convention into something that fails a debug build immediately if violated.
  • Dependency injection for free. Every collaborator RawCullAIIntegration and RawCullApplicationState.make(...) need is a constructor parameter with a production default, so tests can substitute fakes (similarityArtifactStore:, evidenceScan:, modelDownloadCoordinator:, userDefaults:) without touching real models, real downloads, or real UserDefaults.

How to update or extend the runtime

  • Add a new user-facing AI setting that changes runtime behavior (e.g. a new backend choice): add the stored property + persistence to RawCullAISettingsModel next to selectedCLIPModel/selectedSegmentationModel, add the corresponding field to RawCullIntelligenceConfiguration and RawCullIntelligenceConfigurationIdentity, populate it in configurationSnapshot(), and add one more if previousIdentity?.field != incomingIdentity.field branch in apply(configuration:) that calls the one method responsible for swapping that feature’s backend. Do not have the setting call a feature model directly — always go through publishConfiguration().
  • Add a new downloadable model type: extend RawCullAIModelDownloadCatalog/RawCullAIModelDownloadID, add a RawCullAIModelResourceManager<YourProvider> to RawCullAIIntegration (mirroring clipDataCompModelResourceManager), include it in the async let group inside refreshCapabilities(), and fold its status into RawCullAICapabilities. The settings/model-management flow (refresh()applyManagedModelLocations(_:)refreshCapabilities()) needs no changes — it’s already generic over “however many resource managers exist.”
  • Add a brand-new AI feature (beyond similarity/semantic search/deep review): give it its own feature model analogous to RawCullSimilarityFeature, construct it in RawCullApplicationState.make(...) alongside the existing features, hold it on RawCullIntelligenceRuntime, and — only if its backend can actually change at runtime — add it to RawCullIntelligenceConfiguration/Identity and a diff branch in apply(configuration:). If it has no swappable backend (e.g. it’s a pure consumer of an already-configured feature, like Deep Review is a consumer of the segmentation provider RawCullAIIntegration already manages), it doesn’t need its own configuration field at all.
  • Never construct a second RawCullAIIntegration, RawCullIntelligenceRuntime, or SimilarityScoringModel outside RawCullApplicationState.make(...) — if a test needs an isolated runtime, call make(...) again with fresh fakes rather than reaching into an existing one, and add an assert(x === y) identity check alongside the existing ones if you introduce a new “these two references must be the same object” invariant.

Where this fits in the documentation catalog

This document sits underneath Intelligence and AI Subsystem as an optional deep dive — read 05 first for the subsystem’s overall shape, then come here when you need to actually change how settings propagate, add a new AI backend, or understand why a particular apply(configuration:) guard exists. It assumes the actor/Sendable vocabulary from Concurrency Architecture and the generation-counter pattern also used in Culling and Persistence.

7 - SwiftUI View Layer

RawCull windows, navigation, state flow, grid composition, and view-layer conventions.

SwiftUI View Layer

RawCull/Views/ (87 files across 16 subfolders, ~15k lines) is the presentation layer. This doc covers the navigation architecture, the grid/inspection tools that make up most of the screen time, and the SwiftUI state-management idioms used consistently across the whole layer, so you can match the existing style when adding a view.

RawCullApp (see Architecture Overview) hosts three Window scenes; the main one wraps RawCullMainView (Main/RawCullMainView.swift), which is the root of everything a user actually browses photos through.

RawCullMainView is a single ZStack-based router over five mutually exclusive view modes (RawCullViewModel.mainViewMode: MainViewMode):

switch viewModel.mainViewMode {
case .loupe:          loupeSplit
case .grid:           gridSplit
case .similarityGrid: similarityGridSplit
case .ratedGrid:      ratedGridSplit
case .comparisonGrid: comparisonGridSplit
}

Each *Split computed property is a NavigationSplitView (sidebar + detail), all sharing the same RawCullSidebarMainView sidebar (Views/RawCullSidebarMainView/) so switching modes never rebuilds catalog navigation. Two overlays sit above this switch in the ZStack, shown/hidden independently of the mode: the in-window zoom overlay (ZoomOverlayView, zIndex(10)) and a bottom progress overlay for JPEG extraction (ProgressCount, zIndex(12)).

Switching modes has a side effect worth knowing about: onChange(of: viewModel.mainViewMode) opens/closes GridThumbnailViewModel (gridthumbnailviewmodel.open(...)/.close()) — entering a grid mode hands it the current culling model, source, and filtered files so its thumbnail loader can start; leaving cancels everything so background decode work doesn’t run for a screen the user can’t see.

Global menu commands (Extract JPGs, Abort Task, Add Catalog, Copy Tagged Files, Show Saved Files — defined in Views/Tools/MenuCommands.swift and wired in RawCullApp’s .commands { ... }) don’t reach through the view hierarchy as parameters. Instead, RawCullMainView publishes bindings via .focusedSceneValue(\.extractJPGs, $viewModel.focusExtractJPGs) and mirrors menu-triggered boolean flips back into concrete actions with onChange(of: viewModel.focusExtractJPGs) { ... }. This decouples the menu bar (scene-level) from wherever in the view tree the acting view model happens to be — a menu command can trigger main-view behavior without threading a callback through several intermediate views.

State management idioms

The codebase favors explicit dependency injection over global singletons for its @Observable models:

  • @Bindable var viewModel: RawCullViewModel — the dominant pattern (28+ occurrences) for any view that needs to both read and write central app state. Passed explicitly down the tree as a parameter, not read from @Environment, so different windows/hierarchies can (in principle) bind different instances and tests can substitute a fixture view model.
  • @Environment(...) — used for the few models that are genuinely global-for-the-window-scene: GridThumbnailViewModel (injected once at RawCullApp’s root) and RawCullViewModel itself is also placed in the environment for convenience, but most views still take it as an explicit @Bindable parameter for clarity/testability.
  • @State (local) — transient, view-local interaction state: hover, animation flags, collapsed/expanded sets, in-flight Task handles. Used 120+ times; this is the majority of state in the view layer by count.
  • Dictionary-keyed per-item @State — e.g. ComparisonGridView’s imageStates: [FileItem.ID: ComparisonImageState]. Needed because SwiftUI recycles cells in scrolling containers, so a plain @State declared inside a ForEach row is not reliable storage across scroll — keying by stable ID and hoisting the dictionary to the parent preserves per-item state (zoom/pan position, etc.) across scroll.

The main grid: CullingGridView

Views/CullingGrid/CullingGridView.swift is the canonical implementation other grid modes are built from (rated grid, similarity grid, comparison grid all reuse or mirror its structure). Notable patterns:

Render cache to avoid O(n²) recompute

Every hover, selection change, or animation frame would otherwise force SwiftUI to recompute the full filtered/sorted/grouped file list. The view instead computes an equatable cache key from just the inputs that actually affect layout…

private var gridCacheKey: CullingGridRenderCacheKey {
    CullingGridRenderCacheKey(
        burstGroups: reviewFilteredBurstGroups, files: files,
        ratingFilter: ratingFilter, reviewQueueFilter: viewModel.burstReviewQueueFilter,
        scoresCount: viewModel.sharpnessModel.scores.count,
        scoreRevision: viewModel.sharpnessModel.scoreRevision,
        maxScore: viewModel.sharpnessModel.maxScore,
        burstAnalysisResults: viewModel.burstAnalysisResults,
    )
}

…and only rebuilds the derived visibleBurstGroups cache (.onChange(of: gridCacheKey, initial: true)) when that key actually changes. This is a manual, explicit alternative to relying on SwiftUI’s automatic dependency tracking, chosen because it gives fine-grained control over when an expensive O(n) filter/sort/group pass reruns.

Burst group collapse/expand

Burst groups can be shown “clean” (top-3 frames only) or fully expanded, and the view tracks two independent Set<Int> of group IDs (expandedBurstGroupIDs, collapsedBurstGroupIDs) so toggling a group’s visibility behaves correctly whichever display mode is active, without losing the user’s per-group choices when they switch modes.

Selection: a stateless coordinator

Multi-select (click, ⌘-click, badge-based batch select) is implemented by a stateless helper, CullingGridSelectionCoordinator, rather than logic embedded in the view:

private func handleToggleSelection(for file: FileItem) {
    let next = CullingGridSelectionCoordinator.toggleSelection(
        fileID: file.id, state: selectionState, visibleIDs: visibleSelectionIDs,
        modifier: CullingGridSelectionModifier(flags: NSEvent.modifierFlags),
    )
    applySelectionState(next)
}

The view reads current selection into a value-type CullingGridSelectionState, passes it plus modifier-key flags into a pure function, and writes the returned new state back onto viewModel. This makes selection logic independently testable without a live view hierarchy — the same shape used for badge-based batch rating (CullingGridSelectionCoordinator.matchingIDs/ .badgeSelectionItems, which cross-reference burst rank, saliency label, and sharpness score to find “all files like this one”).

Deep Review presentation

Burst group headers show a “Deep Review” button whose label/state is driven directly from deepAIReviewController.isRunning(groupID:) and .isActionUnavailable; tapping it presents DeepAIReviewSheetView as a .sheet(item:), and its onApply callback routes the accepted recommendation back through viewModel.applyDeepAIReviewRecommendation(_:to:) — the view never talks to the AI backend directly, only through the controller (see Intelligence and AI Subsystem).

Photo inspection tools

  • Views/ZoomViews — the in-window zoom overlay (ZoomOverlayView) replacing what used to be separate OS windows; supports pinch/keyboard zoom, pan, and a source toggle (thumbnail vs. full decode).
  • Views/FocusPeek, Views/FocusPoints — focus-peaking rendering and an overlay of autofocus points parsed from EXIF/maker-notes data (FocusPointsModel/FocusPoint, surfaced by RawCullViewModel.getFocusPoints()).
  • Views/HistogramHistogramView downsamples the source CGImage to a bounded 512px-max dimension before computing histogram buckets (context.interpolationQuality = .none to preserve representative pixel values), turning an O(width·height) computation into a bounded O(512²) one regardless of the source RAW’s native resolution.
  • Subject-mask regeneration in ZoomOverlayView debounces expensive recomputation behind Task.sleep + cancellation, exactly like the async debouncing pattern below.

A recurring async debounce pattern

Several views need to react to fast-changing input (scroll position, focus config changes) without recomputing on every intermediate value:

.onChange(of: focusConfig) { _, _ in
    maskTask?.cancel()
    maskTask = Task {
        try? await Task.sleep(for: .milliseconds(400))
        guard !Task.isCancelled else { return }
        await regenerateMask()
    }
}

ComparisonGridView’s scroll-position debounce (120ms) and CullingModel.scheduleSave()’s 350ms save debounce (see Culling and Persistence) follow the identical shape: cancel the previous Task, sleep, check Task.isCancelled, then act.

Reusable components

  • Views/ThumbnailComponents — the actual image tile view, rating controls, keyboard navigation modifiers, and hover/selection overlays shared by every grid mode.
  • Views/Modifiers — custom ViewModifiers/button styles used across the app for consistent look and behavior.
  • Views/FileViews — small file-metadata display components.
  • Views/Progress — the shared progress-bar/overlay components used for scanning, thumbnail generation, and JPEG extraction.

Settings, Saved Files, Tools

  • Views/Settings (11 files) maps directly onto SettingsViewModel (cache sizes, thumbnail sizes, scoring parameters) and RawCullAISettingsModel (AI model selection/downloads) — see Settings and Configuration.
  • Views/SavedFiles is a standalone browser over CullingModel.savedFiles (per-catalog rating history), independent of the currently-open catalog.
  • Views/Tools holds the About window and MenuCommands.

Accessibility

Grid tiles, focus-point controls, and other interactive elements set explicit accessibility label/value/hint (e.g. ImageItemView, FocusPointControllerView) rather than relying on SwiftUI’s default inference — follow this pattern for any new interactive photo-browsing control.

Where to look when extending this

  • New grid variant → start from CullingGridView’s structure (render-cache key, selection coordinator, burst header) rather than writing bespoke filtering/selection logic.
  • New expensive per-frame computation (a new overlay, a new visual analysis) → downsample defensively (see the histogram pattern) and gate behind the debounce-task pattern if it’s triggered by fast-changing input.
  • New global action (menu item, keyboard shortcut) → route it through focusedSceneValue, following the existing extractJPGs/aborttask bindings, rather than passing a new callback down through the view tree.

8 - Settings and Configuration

RawCull preferences, AI configuration, settings persistence, and memory monitoring.

Settings and Configuration

RawCull’s user-configurable state splits into two independently-persisted models: general app preferences (SettingsViewModel) and AI-feature preferences (RawCullAISettingsModel, covered in depth in Intelligence and AI Subsystem). This doc covers the general settings model, its persistence, and the memory monitor shown alongside it.

SettingsViewModel

Model/ViewModels/SettingsViewModel.swift is an @MainActor @Observable class exposing a flat list of plain stored properties for every tunable in the app — no nested config struct, so SwiftUI settings controls can bind to each property directly (Bindable(settingsViewModel).thumbnailSizeGrid, etc.). It groups into four areas via // MARK: comments:

GroupExamples
Memory cachememoryCacheSizeMB, gridCacheSizeMB — user-chosen ceilings on top of CacheRecommendationPolicy’s adaptive defaults (see Image Pipeline and Caching).
Thumbnail sizethumbnailSizeGrid (200px default), thumbnailSizePreview (1616px), thumbnailSizeFullSize (8700px), plus an optional CIRAWFilter-based sharpening pipeline (enableThumbnailSharpening, thumbnailSharpenAmount) for the zoom preview.
Sharpness scoringParameters feeding the sharpness-scoring algorithm used for burst ranking: border inset fraction, subject-classification toggle, salient-region weight, a SharpnessPhotoType preset (.auto, portrait, landscape, etc.), a speed/quality trade-off (SharpnessScoringQuality), and which image SharpnessScoringSource to score from (default: the embedded camera preview, cheaper than a full RAW decode).
Focus maskLaplacian-based focus-peaking tuning: pre-blur radius, threshold, energy multiplier, erosion/dilation/feather radii — these directly parameterize the focus-mask rendering seen in the zoom overlay.

CacheSettingsLimits (memoryMinMB/MaxMB = 1000–8000, gridMinMB/MaxMB = 400–2000) bounds what the user is allowed to enter for the two cache sliders, independent of the adaptive recommendation logic in CacheRecommendationPolicy.

Persistence

Settings are stored as JSON at ~/Library/Application Support/RawCull/settings.json, written through a private nested actor SettingsFileWriter (atomic write, directory created on demand if missing) — the same “small dedicated actor for atomic JSON writes” pattern used by WriteSavedFilesJSON for culling data (see Culling and Persistence).

SettingsViewModel uses a documented two-phase initialization to satisfy Swift’s strict-concurrency initialization rules while still kicking off an async load from init:

init(settingsFileURL: URL? = nil, loadOnInit: Bool = true) {
    // Phase 1: all stored properties must be set before self can be captured.
    self.settingsFileURL = settingsFileURL
    loadTask = nil
    // Phase 2: self is now fully initialized — safe to capture in the Task closure.
    if loadOnInit {
        loadTask = Task { await self.loadSettings() }
    }
}

Callers that need settings to be loaded before proceeding (e.g. before computing cache sizes at launch) call await ensureLoaded(), which simply awaits the stored loadTask — safe to call multiple times, and a no-op once the load has already completed. SettingsViewModel.shared is the app’s one instance (a rare intentional singleton here, unlike RawCullViewModel/AI runtime which are explicitly constructed once by the composition root and threaded through).

AI Settings

RawCullAISettingsModel (Intelligence/ModelManagement/RawCullAISettingsModel.swift) is the Settings-facing surface for the Intelligence subsystem: CLIP model choice, segmentation model choice, “use CLIP for similarity” toggle, model download/license status, and a diagnostics scan of already-saved burst embeddings. It deliberately does not expose the underlying PhotoAIKit/PhotoAnalysisKit providers or the composition root — only capability statuses and simple preferences, persisted to UserDefaults under its own preference keys (RawCullAI.useCLIPForSimilarity, etc.) Full detail is in Intelligence and AI Subsystem.

Memory monitor

MemoryViewModel (Model/ViewModels/MemoryViewModel.swift) is a small @MainActor @Observable class that powers the memory-usage UI in Settings. updateMemoryStats() reads system/app memory via low-level mach calls (host_statistics64, task_info) — deliberately moved off the main actor with Task.detached since these are blocking syscalls, then republished on the main actor:

func updateMemoryStats() async {
    let (total, used, app, threshold) = await Task.detached {
        (ProcessInfo.processInfo.physicalMemory, self.getUsedSystemMemory(),
         self.getAppMemory(), self.calculateMemoryPressureThreshold(total: total))
    }.value
    await MainActor.run {
        self.totalMemory = total; self.usedMemory = used
        self.appMemory = app; self.memoryPressureThreshold = threshold
    }
}

It exposes convenience percentages (usedMemoryPercentage, appMemoryPercentage, memoryPressurePercentage) for progress-bar style UI, and reads SharedMemoryCache.shared.currentPressureLevel directly rather than running a second DispatchSourceMemoryPressure listener — a comment on the property spells this out (“no second DispatchSource needed”), reinforcing that SharedMemoryCache is the single owner of pressure-level state (see Concurrency Architecture).

Where to look when extending this

  • New general preference → add a stored property to SettingsViewModel next to its logical group; it’s automatically part of the JSON blob via Codable synthesis, no manual encode/decode wiring needed.
  • New cache-related preference → also consider whether CacheRecommendationPolicy/CacheSettingsLimits need a matching bound.
  • New AI preference → goes on RawCullAISettingsModel, not SettingsViewModel, and must flow through RawCullIntelligenceRuntime.apply(configuration:) rather than being read directly by a feature.

9 - RawCull Features and Roadmap

RawCull’s product position, design principles, and post-3.2.0 roadmap.

RawCull vs. Other Culling Apps, and Where RawCull Goes After 3.2.0

This document is deliberately different from the rest of the Docs/ catalog: docs 00–07 and runtime.md explain how the code works. This one is a product-positioning and roadmap document — it compares RawCull (current version 3.2.0) against other well-known photo-culling tools on macOS, and lays out a set of principles and concrete ideas for how the app should evolve afterward. It’s written for the same reader as the rest of the catalog (Swift/SwiftUI-literate, new to this codebase), but the goal here is product understanding, not implementation detail.

Disclaimer: competitor feature descriptions below are based on public marketing material, documentation, and third-party reviews as of the research done while writing this document (early 2026), not on inspecting their source code — RawCull is the only app in this comparison whose internals this doc catalog can actually verify. Competitors update frequently; treat specifics (exact AI models, pricing) as approximate and re-verify before quoting them externally.

What RawCull is, in one paragraph

RawCull is a 100% dedicated RAW-photo culling app for macOS. It scans a folder of RAW files (Sony ARW today — see Image Pipeline and Caching), decodes fast previews, lets the photographer rate/reject/flag images, optionally groups near-duplicate “burst” shots and recommends a sharpest-on-subject winner using on-device AI (see Intelligence and AI Subsystem), and copies the keepers out to a destination folder via rsync (see Export and Copy Pipeline). It does not edit photos, does not manage a permanent asset library, and does not touch the network for anything photo-related — everything AI-driven runs locally via Apple’s CoreAI framework.

That single-purpose scope is not an accident or a limitation to be “fixed” — it’s RawCull’s defining design constraint, and it’s central to how this document evaluates competitors and proposes future work.

The competitive landscape

The table below focuses on macOS-available culling tools that photographers commonly compare against. It’s ordered roughly from “manual, no AI” to “heavily AI-automated.”

AppPlatformAI cullingRuns locally?Auto-applies ratings?Pricing modelPrimary audience
RawCull 3.2.0macOS onlyYes (similarity/burst grouping, semantic search, Deep Review recommendation)100% on-device (Apple CoreAI)Never — advisory only(project-specific)RAW shooters who want a fast, private, single-purpose culler
Photo MechanicmacOS, WindowsNoN/ANoOne-time licenseSports/press/agency — pure speed and metadata, no AI at all
FastRawViewermacOS, Windows, LinuxNoN/ANoOne-time licenseTechnical pixel-level RAW inspection (true histogram, focus peaking)
Narrative SelectmacOS, WindowsYes (face/expression detection, scene/angle grouping)Mixed (has cloud-connected features)No — AI scores, user approves every keeperFree tier + paid plansPortrait/wedding photographers who want AI guidance with full manual override
AftershootmacOS, WindowsYes (blink/blur/focus detection, full auto-select mode, AI editing)Mostly local processing, cloud account requiredYes, by default in its automated mode (user can review after)SubscriptionHigh-volume wedding/event shooters who want maximum automation
Adobe Lightroom Classic (“Assisted Culling”)macOS, WindowsYes (subject/eye sharpness, eyes-open detection)On-device processing, no upload requiredAuto-sorts into Selects/Rejects bins, user reviews before committingSubscription (Creative Cloud)Existing Lightroom users who want AI-assisted first-pass sorting inside their normal catalog workflow
Capture One / ON1 Photo RAW / Photo Supreme (honorable mentions)macOS, WindowsLimited or none (mostly manual flag/rating tools bundled into a much larger RAW-editing/DAM product)LocalNoOne-time or subscriptionPhotographers who want culling as one small feature of an all-in-one editor/DAM

Where RawCull sits

  • Vs. Photo Mechanic / FastRawViewer — RawCull adds AI-assisted burst grouping and a Deep Review recommendation that these tools simply don’t attempt. It trades some of Photo Mechanic’s legendary raw ingest speed and metadata/captioning depth for AI-assisted grouping and recommendation features neither manual tool offers.
  • Vs. Narrative Select — closest philosophical match: both treat AI as an advisor, not a decision-maker, and both let the user override every AI suggestion. The difference is architectural, not philosophical: RawCull’s AI stack is 100% local (Apple CoreAI models bundled/downloaded per-device, see 05), while Narrative Select’s feature set includes cloud-connected components. RawCull is also a narrower, single-purpose tool; Narrative Select bundles a broader editing and Lightroom-sync workflow around its culling core.
  • Vs. Aftershoot — the sharpest philosophical contrast in this table. Aftershoot’s default mode is designed to auto-select and auto-reject on the user’s behalf at scale; RawCull’s Deep Review pipeline is deliberately built so that segmentation/focus scoring only ever recommends a burst winner (see Intelligence and AI Subsystem — the ranking is surfaced as a suggestion in the UI, no rating is written automatically). RawCull will never adopt Aftershoot’s “commit an automatic cull pass” model — see the non-goals below.
  • Vs. Lightroom Classic’s Assisted Culling — Adobe’s newest feature validates RawCull’s core bet (on-device AI-assisted culling with a human-reviewed Selects/Rejects step is where the industry is heading), but it’s one feature bolted onto a large, general-purpose RAW editor/DAM/cloud product with a subscription tied to the whole Creative Cloud suite. RawCull’s entire surface area — UI, persistence model, export pipeline — is built around culling and nothing else.
  • Vs. Capture One / ON1 / Photo Supreme — these are RAW editors or digital asset managers first, with basic rating/flagging tools as one feature among many (develop modules, layers, catalogs, cloud sync, plugin ecosystems). RawCull deliberately doesn’t compete on any of that; it does one job — get from “folder of RAW files” to “folder of keepers” as fast and as advisedly as possible — and stops there.

RawCull’s genuine current gaps

Being honest about the comparison also means naming where RawCull is behind:

  • RAW format coverage. Today RawCull decodes Sony ARW only (via RawParserKit — see Image Pipeline and Caching). Every competitor above supports far more camera makes out of the box.
  • Metadata/captioning depth. Photo Mechanic’s IPTC/caption/renaming toolset has no equivalent in RawCull; RawCull’s persistence model (03-Culling-and-Persistence.md) is rating/status-only, not general metadata editing.
  • Face/expression-specific signals. Narrative Select’s and Lightroom’s eyes-open/expression detection is more specialized than RawCull’s current sharpness-on-subject Deep Review scoring — there’s no dedicated “eyes closed” or “best expression” signal yet.
  • Multi-platform reach. RawCull is macOS-only by design (it’s a native SwiftUI app, see SwiftUI View Layer); several competitors also ship a Windows build.

None of these are reasons to broaden RawCull’s mission — see below.

RawCull’s non-negotiable principles

Everything in the roadmap section that follows is filtered through three rules that define what RawCull is and will keep being, even as it grows:

  1. RawCull is a 100% dedicated culling app. Every future enhancement must serve the act of culling — deciding what to keep, reject, or export. RawCull is not going to grow into a RAW editor, a DAM, a print/proofing tool, or a cloud photo library. If a feature idea doesn’t make culling faster, clearer, or more confident, it doesn’t belong in RawCull, no matter how popular it is in adjacent tools.
  2. AI enhancements are local-first and culling-relevant only. New AI-assisted features may use Apple’s CoreAI framework (the same stack documented in 05 and Intelligence Runtime) — but only when the function is directly relevant to deciding what to keep. AI is not a checkbox to add for its own sake, and it never leaves the device: no photo, embedding, or model input is uploaded anywhere. If Apple ships a CoreAI capability that doesn’t help culling decisions (e.g. generative editing), it’s out of scope for RawCull regardless of how easy it would be to bolt on.
  3. RawCull never culls automatically. It may advise. The user decides. This is the line that separates RawCull from tools like Aftershoot’s auto-select mode. Every AI signal in RawCull — burst grouping, semantic search, Deep Review’s recommended winner, and any future signal — is surfaced as a suggestion the user can see, inspect, and override. No code path may write a rating, reject, or flag without a direct user action. This isn’t just a UX preference; it’s an architectural invariant that should be enforced the same way apply(configuration:)’s guards are enforced in the Intelligence runtime (see Intelligence Runtime) — any PR that lets an AI pipeline call CullingModel.updateRating/updateRatings directly, without going through explicit user interaction, should be treated as a design regression, not a feature.

How RawCull should evolve after 3.2.0

The ideas below are grouped by theme. All of them respect the three principles above; none of them turn RawCull into something it isn’t.

1. Broaden RAW format support (highest-leverage, lowest AI-relevance)

The single biggest gap versus every competitor in the table is format coverage. RawParserKit decoding more camera RAW formats (Canon CR2/CR3, Nikon NEF, Fujifilm RAF, generic DNG) would make RawCull usable by a much larger share of photographers without touching the culling model, the Intelligence subsystem, or the export pipeline at all — this is a pure “widen the front door” investment, not a culling-feature investment, but it’s a prerequisite for RawCull to be a realistic choice for most shooters.

2. Deepen the advisory signals Deep Review can offer

Today’s Deep Review recommends a burst winner using subject segmentation + focus scoring. Candidate CoreAI-relevant extensions, all surfaced as additional advisory signal, never as an automatic rating:

  • Eyes-open / blink detection as a per-face badge on thumbnails in portrait/group bursts — closing the specific gap versus Narrative Select and Lightroom’s Assisted Culling, without adopting their auto-sort step. Shown next to the existing sharpness recommendation, not replacing the user’s decision.
  • Composition/subject-framing hints (e.g. “subject partially out-of-frame”) using the same segmentation masks Deep Review already computes — no new model download, just a new interpretation of existing CoreAI output.
  • Confidence-scored “why this one” explanations — instead of only a ranked winner, show the actual signal that drove the ranking (sharper eye region vs. rest of frame, better subject-to-background separation), so the photographer can quickly sanity-check the suggestion rather than trust it blindly. This directly reinforces principle 3: showing why keeps the human in the loop instead of turning the recommendation into a black box.

3. Let the AI learn review order, never review outcome

A high-value, principle-safe idea borrowed from the industry’s direction (Lightroom’s and Aftershoot’s models learn from user behavior) without crossing into auto-culling: use on-device signals to re-order which photos/bursts are surfaced for review first (e.g. surface likely-reject technical failures — badly out of focus, badly clipped — early, so a photographer can dismiss the “easy no’s” fast and spend attention on hard calls). This changes presentation order, never a rating value — it’s squarely on the “advisory” side of principle 3, and it’s a natural extension of the existing RatingFilter/filteredFiles machinery documented in Culling and Persistence.

4. Expand semantic search into a culling-time filter, not a library feature

The existing CLIP-backed semantic search (see Intelligence and AI Subsystem) is a natural fit for scaling up: “find all the photos where the subject is looking at the camera,” “find the photos with a clean background,” etc., scoped strictly to helping decide what to keep during this culling session — not evolving into a permanent searchable library/DAM feature that persists independent of an active culling pass. The distinction matters: it keeps semantic search a culling tool, not the seed of an asset-management product.

5. Scale the pipeline for larger shoots

As format support broadens, session sizes will grow (multi-card, multi-day event imports). The concurrency architecture (Concurrency Architecture) and cache layer (02) were built with backpressure and coalescing in mind already; future work here is about validating and tuning those mechanisms at 10k+ file scale (memory-mapped decode limits, disk cache eviction policy, TaskGroup concurrency caps) rather than adding new AI. This is infrastructure work that makes every other item in this list viable at real-world event volumes.

6. Undo, audit, and confidence — trust infrastructure, not automation

Because RawCull’s promise is “you decide, we assist,” the tooling around that promise should get stronger over time:

  • A visible undo/history of rating changes (leaning on the existing debounced/revisioned persistence in Culling and Persistence) so a photographer can always see and reverse what they (not the AI) did. Ideally, that history should also make it easy to audit that no rating was ever written by anything other than a direct user action — a debug/test hook that would let RawCullTests assert principle 3 mechanically, not just by code review.
  • Clearer, persistent surfacing of why the AI grouped or ranked something a certain way (see item 2’s “explanations” idea) — trust in an advisor comes from transparency, not just accuracy.

7. Model management stays user-controlled

As new CoreAI-backed capabilities are added, they should go through the same model-management/licensing flow already documented in Settings and Configuration and Intelligence Runtime — explicit download, explicit license acceptance, visible storage footprint, and an explicit way to disable or remove a model. No future AI feature should be silently bundled or silently auto-enabled; the same configuration/consent discipline that today’s segmentation and similarity models follow should extend to every future model, keeping AI capability itself an opt-in, inspectable part of the app rather than a black box that “just runs.”

Explicit non-goals

To keep the roadmap honest and prevent scope creep, these are deliberately out of scope for RawCull, regardless of competitive pressure:

  • No automatic rating, rejecting, or flagging of any photo, ever, no matter how confident an AI signal is. This is principle 3, restated as a hard boundary, not a soft default that a “power user mode” could disable.
  • No RAW development/editing features (exposure, color grading, layers, presets). RawCull decides what to keep, never how it should look.
  • No cloud photo storage or cloud-based AI processing. All AI stays on-device via CoreAI; RawCull will not add a server-side component for photo analysis, even if it would unlock more powerful models.
  • No general-purpose digital asset management (permanent searchable library independent of an active culling session, print/proofing/client gallery features, multi-user collaboration). RawCull’s data model is scoped to “which of these files, in this catalog, do I keep” — not a library of everything a photographer has ever shot.
  • No expansion of the export step beyond “copy the keepers out.” The rsync-backed export pipeline (04) may grow more copy/rename options, but turning it into a publishing/delivery platform (client galleries, watermarking, web delivery) is out of scope — that’s a job for the next tool in the photographer’s chain, not RawCull.

Where this fits in the documentation catalog

This document is a product-level companion to the technical docs, not a technical deep dive itself — treat 00–07, runtime.md, and issues.md as the source of truth for how the code currently works, and treat this document as the source of truth for why RawCull looks the way it does compared to the rest of the market, and what problem the next feature should solve. When proposing a new feature, check it against the three principles above before writing any code; when documenting it afterward, it belongs in the relevant subsystem doc (01–07/runtime.md), not here.

10 - Known Issues and Findings

Prioritized RawCull code-review findings and recommended remediations.

Known Issues and Findings

This is a code-review pass over the main RawCull app target (excluding RawCullTests and the external SPM packages). Overall the codebase is unusually clean for its size: no force-unwraps (!), no try!, no force-casts (as!), no unguarded array [0] accesses, no DispatchSemaphore or Timer/NotificationCenter leaks, and every startAccessingSecurityScopedResource() call has a matching stopAccessingSecurityScopedResource() on all paths, including cancellation and deinit. The findings below are the exceptions to that otherwise solid baseline. Severities: High (real user-facing breakage or data-loss risk), Medium (real bug/gap, narrow trigger conditions or degraded UX), Low (code-quality / robustness / maintainability).


1. No escape hatch when a quit-time save fails — Medium/High

Main/RawCullApp.swift, AppDelegate.applicationShouldTerminate:

terminationTask = Task {
    let didSave = await viewModel.cullingModel.flushPersistence()
    if didSave {
        viewModel.stopActiveSecurityScopedAccess()
    }
    terminationTask = nil
    sender.reply(toApplicationShouldTerminate: didSave)
}
return .terminateLater

If flushPersistence() fails (e.g. the catalog’s volume was ejected, disk is full, or permissions changed), didSave is false and the app replies false to NSApplicationthe app refuses to quit. RawCullMainView does show an alert in this situation (persistenceErrorIsPresented, driven by cullingModel.persistenceError), but that alert only offers “Retry” (and “Archive Damaged File” for load failures, not save failures). There is no “Quit Without Saving” / “Force Quit” option, so a user whose destination volume is genuinely gone has no in-app way to exit cleanly — they must force-quit via the Dock or Activity Monitor, which is a worse experience than a clearly-labeled data-loss confirmation.

Suggested fix: add a destructive “Quit Without Saving” button to the persistence-failure alert, and have it call sender.reply(toApplicationShouldTerminate: true) directly instead of looping through flushPersistence() again.

2. Security-scope fallback path is likely dead code in the sandboxed build — Medium

Model/ParametersRsync/ExecuteCopyFiles.swift, tryFallbackPath(_:key:):

private func tryFallbackPath(_ fallbackPath: String, key: String) -> URL? {
    let fallbackURL = URL(fileURLWithPath: fallbackPath)
    guard fallbackURL.startAccessingSecurityScopedResource() else { ... }
    ...
}

This calls startAccessingSecurityScopedResource() on a plain file:// URL built from a string path, not on a URL resolved from a security-scoped bookmark or an NSOpenPanel selection. RawCull.entitlements confirms the app runs under com.apple.security.app-sandbox = true. In a sandboxed process, startAccessingSecurityScopedResource() only succeeds for URLs that were actually granted through Powerbox (an open panel) or a resolved security-scoped bookmark — calling it on an arbitrary path outside the container will almost always return false. This makes the fallback branch effectively unreachable/useless in production, while its companion success-path log line (“Successfully accessed fallback path for …”) would be misleading if it were ever hit. Recommend removing the fallback (relying solely on bookmark resolution) or replacing it with a clear “please reselect this folder” prompt routed through NSOpenPanel.

3. Entitlements may be missing a required file-access capability — Medium (needs verification)

RawCull.entitlements declares only:

<key>com.apple.security.application-groups</key> ... 
<key>com.apple.security.app-sandbox</key><true/>

There is no com.apple.security.files.bookmarks.app-scope or com.apple.security.files.user-selected.read-write entry, despite the app’s core workflow depending on persisting security-scoped bookmarks (UserDefaults.standard.data(forKey: "sourceBookmark") / "destBookmark", see RawCullViewModel.swift and ExecuteCopyFiles.getAccessedURL) to regain access to user-chosen source and destination folders across app relaunches. Document-scoped bookmarks created directly from an NSOpenPanel grant typically don’t require an extra entitlement, but this is worth explicitly re-verifying against a real signed build — if bookmark resolution silently fails after an update to sandbox rules, users would lose access to previously configured catalogs with only a generic “could not access folder” alert and no diagnostic path back to the root cause.

4. Scan failures are completely silent (no log, no UI) — Low

Actors/ScanFiles.swift, scanFiles(url:onProgress:):

} catch {
    // Logger.process.warning("Scan Error: \(error)")
    return []
}

Any failure enumerating the catalog directory (permission denied, missing folder, I/O error) is swallowed to an empty file list with the log line commented out — there is no OSLog entry and no user-facing alert, so a scan failure is indistinguishable from “this folder is genuinely empty.” This makes field diagnosis of access problems unnecessarily hard. Uncomment/restore the log line at minimum; consider surfacing a non-blocking banner for repeated scan failures the way persistence failures already are.

5. Minor duplicate-decode race in thumbnail request coalescing — Low

Actors/RequestThumbnail.swift, cancelWaiter/enqueue: when the last waiter for a coalesced request cancels, the in-flight entry is removed from inFlightRequests and task.cancel() is called, but cancellation is cooperative — if the underlying decode is mid-flight inside rawLoader.thumbnailCGImage (which doesn’t check Task.isCancelled internally) and a new request for the same file arrives before the old task notices cancellation, a second concurrent decode of the same file will start. This wastes CPU/disk I/O but is not a correctness bug: the generation check in finishRequest ensures the stale result is discarded safely and no continuation is double-resumed. Consider making the old task’s completion check-and-reuse an already-superseding in-flight entry instead of always starting a fresh decode.

6. Commented-out Logger calls scattered through the codebase — Low

grep -rn '^\s*// Logger\.' finds 13 debug log lines left commented out (e.g. in RequestThumbnail.swift, ScanFiles.swift). Harmless, but they’re dead debugging leftovers that should either be deleted or restored/gated behind a debug flag — as-is they add noise for future readers wondering if they’re intentionally disabled instrumentation or accidental omissions.


What was checked and found clean

  • Force-unwraps / try! / as!: none in the app target.
  • Security-scoped resource pairing: every start... has a matching stop... on every return path, including error paths, task cancellation, and isolated deinit (RawCullViewModel, ExecuteCopyFiles, ScanFiles, OpencatalogView).
  • Debounced-save correctness (CullingModel.scheduleSave): revision counters correctly prevent a stale debounced write from clobbering newer in-memory state; flushPersistence/retryPersistence correctly cancel any pending debounce and persist the current snapshot, not a stale captured one.
  • Array indexing: every array[0]-style access found is either guarded by a preceding !isEmpty check or targets an Apple-guaranteed non-empty system directory list.
  • Swift 6 concurrency: SWIFT_VERSION = 6.0 is set for all targets, which enables complete concurrency checking at the language-mode level regardless of the separate (Swift 5-only) SWIFT_STRICT_CONCURRENCY build setting — the apparent inconsistency in project.pbxproj (only the test target sets SWIFT_STRICT_CONCURRENCY = complete explicitly) is not a functional gap.