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
| # | Document | Covers |
|---|
| 00 | Overview (this file) | App summary, source map, composition root, glossary |
| 01 | Concurrency-Architecture.md | Actors, TaskGroup backpressure, Sendable rules, coalescing |
| 02 | Image-Pipeline-and-Caching.md | Scan → decode → thumbnail → cache pipeline |
| 03 | Culling-and-Persistence.md | Ratings model, debounced save, catalog switching |
| 04 | Export-Copy-Pipeline.md | The rsync-backed export/copy step |
| 05 | Intelligence-AI-Subsystem.md | On-device AI: similarity, semantic search, deep review |
| — | Intelligence Runtime | Deep dive: how the Intelligence runtime is built, reconfigured, and extended |
| 06 | SwiftUI-View-Layer.md | Navigation, state-management idioms, the grid view |
| 07 | Settings-and-Configuration.md | User preferences, AI settings, memory monitor |
| — | Features and Roadmap | RawCull vs. other culling apps, and the post-3.2.0 roadmap |
| — | Known Issues | Code-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
- Concurrency Architecture — actors,
TaskGroup, cancellation, Sendable rules. Read this early; almost every
other subsystem leans on these patterns. - Image Pipeline and Caching — scan
→ decode → thumbnail → cache, from folder selection to pixels on screen.
- Culling and Persistence — how
ratings/rejects are modeled, filtered, and saved.
- Export and Copy Pipeline — how the “keeper”
files get copied out via rsync.
- 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.
- SwiftUI View Layer — the view hierarchy
and SwiftUI idioms used throughout.
- Settings and Configuration — user
preferences, AI model management/licensing, memory monitoring.
- 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.
- 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
| Term | Meaning |
|---|
Catalog / ARWSourceCatalog | A user-selected source folder of RAW files, plus its bookmark/metadata. Culling decisions are keyed per catalog. |
| Culling | The act of rating (-1 reject, 0 keeper, 2–5 stars) or flagging RAW files so a subset can be exported. |
| Burst | A group of near-duplicate frames (e.g. continuous shooting) detected via similarity scoring, presented together so the user picks the best one. |
| Deep Review | An optional, heavier AI pass over a burst group (subject segmentation + focus scoring) that recommends a winner. |
| Loupe | The single-photo detail view mode (as opposed to a grid of thumbnails). |
| FileItem | typealias 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:
@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.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
| Actor | File | Responsibility |
|---|
DiskCacheManager | Actors/DiskCacheManager.swift | Grid-thumbnail JPEG disk cache, keyed by a hash of the source file. |
FullSizeJPGDiskCache | Actors/FullSizeJPGDiskCache.swift | Full-resolution JPEG disk cache for zoom/loupe. |
SharedMemoryCache | Actors/SharedMemoryCache.swift | In-memory NSCache-backed image cache with kernel memory-pressure response. |
RequestThumbnail | Actors/RequestThumbnail.swift | De-duplicates concurrent thumbnail requests for the same file. |
ScanFiles | Actors/ScanFiles.swift | Discovers and reads metadata for RAW files in a folder. |
ScanAndCreateThumbnails | Actors/ScanAndCreateThumbnails.swift | Proactively preloads grid thumbnails after a scan. |
ScanAndExtractJPGs | Actors/ScanAndExtractJPGs.swift | Extracts full-size preview JPEGs in bulk. |
ExtractAndSaveJPGs | Actors/ExtractAndSaveJPGs.swift | User-triggered “export embedded/demosaiced JPEGs” batch job. |
SaveJPGImage | Actors/SaveJPGImage.swift | JPEG encode + file write. |
ThumbnailLoader | Actors/ThumbnailLoader.swift | Rate-limits concurrent grid-image decode (max ~6 in flight). |
ThumbnailPreloadGate | Actors/ThumbnailPreloadGate.swift | De-duplicates grid decode requests that race with preload. |
SettingsFileWriter (private) | Model/ViewModels/SettingsViewModel.swift | Atomic settings JSON writes. |
WriteSavedFilesJSON | Model/JSON/WriteSavedFilesJSON.swift | Atomic culling-state JSON writes. |
BurstAnalysisCache | Intelligence/Persistence/BurstAnalysisCache.swift | Persists burst-analysis snapshots per catalog. |
PerFileAnalysisArtifactStore | Intelligence/Persistence/PerFileAnalysisArtifactStore.swift | Persists similarity embeddings per file/backend. |
RawCullAIModelResourceManager | Intelligence/ModelManagement/RawCullAIModelResourceManager.swift | Validates AI model bundles, constructs providers. |
RawCullAIModelDownloadCoordinator / ...DownloadService | Intelligence/ModelManagement/RawCullAIModelDownloadService.swift | Background 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.generationBurstAnalysisCoordinator.generationSimilarityScoringModel’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:
- Before starting the next item in the loop.
- On entry to each per-item task.
- After any
await boundary (RAW decode, disk I/O). - 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.
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:
| Tier | Actor | Content | Typical size |
|---|
| Grid | SharedMemoryCache (grid cache) + DiskCacheManager | ~200px JPEG thumbnails, quality ~0.7 | Hundreds of items, small footprint |
| Preview | SharedMemoryCache (preview cache) + FullSizeJPGDiskCache | Full-size decoded previews, quality ~0.85 | Dozens 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:
- 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. - Check
SharedMemoryCache — a hit returns immediately with no disk I/O. - 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. - 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
- UI action (star key, menu command, batch rating) calls into
RawCullViewModel+Culling.updateRating(for:rating:). - 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().
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:
- Encodes
[SavedFiles] to JSON. - Backs up the existing file to
savedfiles.backup.json. - 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 failure —
persistenceLoadFailure: 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 failure —
persistenceError: 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 changes →
SavedFiles/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.
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 see | What it actually means in RawCull |
|---|
SynchronizeConfiguration.task = "synchronize" | Hardcoded; RawCull only ever runs one “task” kind (copy). |
localCatalog / offsiteCatalog | Local source folder / local destination folder — never remote. |
offsiteServer | Always empty/"localhost"; unused. |
offsiteUsername, sshport, sshkeypathandidentityfile | Unused SSH fields from the shared config struct. |
RemoteDataNumbers / PrepareOutputFromRsync | Parses local rsync stdout; “remote” refers to rsync’s own terminology for the far side of a transfer, not a network host. |
ParametersRsync/ folder name | Houses 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). parameter8–parameter14
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:
- Resolve the file list —
viewModel.extractRatedfilenames(minimumRating:)
(or the tagged-files equivalent) returns the filenames to copy. - 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. - 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(). - 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". - Stream progress —
fileHandler 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. - 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
- 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. CopyFilesView constructs and drives ExecuteCopyFiles.- 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. 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 used →
ArgumentsSynchronize.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
| Folder | Role |
|---|
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: RawCullAIModelManagementModel → RawCullAISettingsModel →
SimilarityScoringModel → RawCullSimilarityFeature →
RawCullSemanticSearchFeature → DeepAIReviewController →
RawCullViewModel → RawCullIntelligenceRuntime, 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 (idle → generating → saving) 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:
callbacks.isCurrent() — bail out immediately if the caller already
knows this run is stale (catalog switched, generation advanced).- Load cache —
prepareCache(for:...) checks
BurstAnalysisCacheRepository for a compatible snapshot (see cache
versioning below); a full cache hit skips straight to applying results. - Score sharpness for files that don’t have a cached score.
- Score similarity for file pairs that don’t have a cached embedding.
- Group into bursts via
similarityModel.groupBursts(files:configuration:). - 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. - 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.
Semantic search
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 (checking →
ready/unavailable → preparing → running → completing →
completed/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:
| Type | Role | Lives in |
|---|
RawCullAIIntegration | Owns 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 |
RawCullIntelligenceRuntime | The 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:
- The user changes an AI setting —
RawCullAISettingsModel.setUseCLIPForSimilarity(_:),
setSelectedCLIPModel(_:), or setSelectedSegmentationModel(_:), each
bound to a SwiftUI control in Views/Settings/AISettingsTab.swift. - 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:
- 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. - 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. - 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
(SubjectMaskRepositoryConfiguration → SubjectMaskRepository →
SegmentationService → SubjectMaskSelector) 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.
Navigation architecture
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.
Menu / keyboard commands via focusedSceneValue
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).
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/Histogram — HistogramView 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.
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:
| Group | Examples |
|---|
| Memory cache | memoryCacheSizeMB, gridCacheSizeMB — user-chosen ceilings on top of CacheRecommendationPolicy’s adaptive defaults (see Image Pipeline and Caching). |
| Thumbnail size | thumbnailSizeGrid (200px default), thumbnailSizePreview (1616px), thumbnailSizeFullSize (8700px), plus an optional CIRAWFilter-based sharpening pipeline (enableThumbnailSharpening, thumbnailSharpenAmount) for the zoom preview. |
| Sharpness scoring | Parameters 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 mask | Laplacian-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.”
| App | Platform | AI culling | Runs locally? | Auto-applies ratings? | Pricing model | Primary audience |
|---|
| RawCull 3.2.0 | macOS only | Yes (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 Mechanic | macOS, Windows | No | N/A | No | One-time license | Sports/press/agency — pure speed and metadata, no AI at all |
| FastRawViewer | macOS, Windows, Linux | No | N/A | No | One-time license | Technical pixel-level RAW inspection (true histogram, focus peaking) |
| Narrative Select | macOS, Windows | Yes (face/expression detection, scene/angle grouping) | Mixed (has cloud-connected features) | No — AI scores, user approves every keeper | Free tier + paid plans | Portrait/wedding photographers who want AI guidance with full manual override |
| Aftershoot | macOS, Windows | Yes (blink/blur/focus detection, full auto-select mode, AI editing) | Mostly local processing, cloud account required | Yes, by default in its automated mode (user can review after) | Subscription | High-volume wedding/event shooters who want maximum automation |
| Adobe Lightroom Classic (“Assisted Culling”) | macOS, Windows | Yes (subject/eye sharpness, eyes-open detection) | On-device processing, no upload required | Auto-sorts into Selects/Rejects bins, user reviews before committing | Subscription (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, Windows | Limited or none (mostly manual flag/rating tools bundled into a much larger RAW-editing/DAM product) | Local | No | One-time or subscription | Photographers 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:
- 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.
- 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.
- 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.
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 NSApplication — the 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.
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.