> ## Documentation Index
> Fetch the complete documentation index at: https://developer.neurofit.app/llms.txt
> Use this file to discover all available pages before exploring further.

# API reference (iOS)

> Every public type in the NeurofitVitals Swift module, version 1.0.0, as declared in the SDK sources.

Module `NeurofitVitals`, iOS 15 or later, Swift 5 language mode, SwiftPM tools 5.9, one dynamic library product.
The module depends on the `PpgCore` engine (path dependency `../../ports/swift`) through `internal import`, so no
engine type appears in the public interface; that import form needs **Xcode 16 or newer** to build the SDK from
source (the binary xcframework has no such requirement). Every public value type is `Sendable` and `Equatable`.
Events and delegate callbacks are delivered on the main thread. Engine version 1.3.0.

This page is hand-maintained from the SDK sources and updated with every SDK change; the
[Android page](/api-reference-android) documents the mirrored API.

## VitalsSDK

The static entry point. There is no instance.

```swift theme={null}
public enum VitalsSDK {
  public static let sdkVersion: String        // "1.0.0"
  public static let engineVersion: String     // "1.3.0" (PpgCoreVersion.string)
  public static let buildDate: String         // "2026-09-30"; release scripts stamp it

  /// Verifies the key offline against the public key compiled into the SDK and records the status.
  /// Throws VitalsError.licenseInvalid, .licenseNotValidForApp(appId:), .licenseUpdatesExpired(updatesUntil:).
  @discardableResult
  public static func activate(licenseKey: String) throws -> LicenseStatus

  /// The status from the last successful activate(licenseKey:), or nil.
  public static var licenseStatus: LicenseStatus? { get }

  /// Rear camera, torch and the highest reachable frame-rate tier. Does not open the camera.
  public static func deviceSupport() -> DeviceSupport

  /// Current camera authorization, without prompting.
  public static var cameraAuthorization: CameraAuthorization { get }

  /// Prompts for camera access when not yet determined. True when access is granted.
  public static func requestCameraAccess() async -> Bool

  /// Debug logging to os.Logger (subsystem "com.neurofit.vitals"). Off by default.
  public static var isLoggingEnabled: Bool { get set }
}
```

Notes:

* `activate` compares the key's `apps` with `Bundle.main.bundleIdentifier`. A process without a bundle identifier
  fails closed with `.licenseNotValidForApp(appId: "")` in Release builds; a Debug build of the SDK sources accepts
  it with the usual relaxed-app-id warning in `LicenseStatus.warnings`.
* The relaxed app-id check (a warning instead of `.licenseNotValidForApp`) is compiled in with `#if DEBUG` **in
  the SDK module itself**. With the SwiftPM source integration that is your build configuration; the binary
  xcframework is built Release and never relaxes the check. See [License keys](/license-keys#debug-builds).
* `deviceSupport()` probes with the default `fpsTiers` (`[60, 30, 20]`) and the same format selection the camera
  configures with, so a reported tier is one the camera reaches.
* `isLoggingEnabled` logs state transitions and capture decisions only: never metric values, frames or license
  contents.

## LicenseStatus and LicenseType

```swift theme={null}
public struct LicenseStatus: Sendable, Equatable {
  public let licensee: String
  public let apps: [String]          // bundle identifiers and application ids the key covers
  public let features: [String]      // ["vitals"] in 1.0.0
  public let updatesUntil: String    // "YYYY-MM-DD": last SDK build date this key accepts
  public let type: LicenseType
  public let warnings: [String]      // non-fatal notes, e.g. the relaxed app-id check in debug builds
  public init(licensee: String, apps: [String], features: [String], updatesUntil: String,
              type: LicenseType, warnings: [String])
}

public enum LicenseType: String, Sendable, Codable {
  case binary
  case source
}
```

## DeviceSupport

Exactly five fields.

```swift theme={null}
public struct DeviceSupport: Sendable, Equatable {
  public let isSupported: Bool
  public let hasCamera: Bool         // a rear wide camera is present
  public let hasTorch: Bool          // the rear camera has a torch (required)
  public let fpsTier: Int?           // highest reachable tier: 60, 30 or 20; nil when none
  public let reason: String?         // why isSupported is false; nil when supported
  public init(isSupported: Bool, hasCamera: Bool, hasTorch: Bool, fpsTier: Int?, reason: String?)
}
```

`reason` is one of: `"No rear wide camera"`, `"The iOS Simulator has no rear camera"`, `"The rear camera has no
torch"`, or `"No capture format reaches 20 fps at 640 px"` (the lowest tier and the extractor's minimum width).

## CameraAuthorization

```swift theme={null}
public enum CameraAuthorization: String, Sendable {
  case notDetermined
  case denied
  case restricted
  case authorized
}
```

## VitalsConfiguration

```swift theme={null}
public struct VitalsConfiguration: Sendable, Equatable {
  public var readingDuration: TimeInterval = 60     // seconds (Double); the validated duration
  public var autoRestartAfterCancel: Bool = true    // after a cancel: positioning (true) or idle (false)
  public var motionBreathing: Bool = true           // accelerometer as a breathing-rate channel
  public var advanced: AdvancedConfiguration = AdvancedConfiguration()
  public init()
}
```

### AdvancedConfiguration

Capture and gating parameters with the iOS production defaults. Changing them is unvalidated. The
[Reading lifecycle](/reading-lifecycle#production-defaults) page lists both platforms side by side.

```swift theme={null}
public struct AdvancedConfiguration: Sendable, Equatable {
  // Contact and signal
  public var minAmplitude: Double = 0.5
  public var contactEnterFrac: Double = 0.5
  public var contactExitFrac: Double = 0.5
  // Start gate
  public var startWindowSeconds: Double = 3.0
  public var startRequiredFraction: Double = 1.0
  public var startQualityFloor: Double = 0.5
  public var requireExposureConverged: Bool = false
  public var startMaxWaitSeconds: Double = 15.0
  // Cancel gates
  public var cancelWindowSeconds: Double = 12.0
  public var cancelMinSamples: Int = 8
  public var cancelBadFraction: Double = 0.6
  public var liveSqCancelConsecutive: Int = 100      // observe only
  public var contactLossPreviews: Int = 3
  public var liveDiscardGateSeconds: Int = 35
  public var liveDiscardGateThreshold: Double = 0.5
  // Exposure (iOS runs auto-exposure; these are carried for parity and inert while the tuner is off)
  public var exposureTunerEnabled: Bool = false
  public var exposureTargetDc: Double = 150
  public var exposureDcMin: Double = 120
  public var exposureDcMax: Double = 210
  public var exposureTuneFramesPerStep: Int = 15
  public var exposureTuneMaxSteps: Int = 8
  public var exposureTuneGamma: Double = 1.0
  public var exposureTunerMaxCancels: Int = 2
  // Engine finalize
  public var consistencyGate: Bool = true
  public var mergedIntervalFix: Bool = true
  public var androidEstimator: Bool = false          // Android-only estimator; no effect on iOS
  // Frame-rate tiers tried in order; the session drops a tier on sustained frame drop
  public var fpsTiers: [Int] = [60, 30, 20]
  public init()
}
```

### Configuration validation

`VitalsSession.init` validates the configuration and throws `VitalsError.internalError("Invalid configuration: <detail>")` for a value the capture layer cannot run with, before any camera work:

| Rule | Detail text |
| - | - |
| `readingDuration`, `minAmplitude`, `contactEnterFrac`, `contactExitFrac`, `startWindowSeconds`, `startRequiredFraction`, `startQualityFloor`, `startMaxWaitSeconds`, `cancelWindowSeconds`, `cancelBadFraction`, `liveDiscardGateThreshold`, `exposureTargetDc`, `exposureDcMin`, `exposureDcMax`, `exposureTuneGamma` must be finite | `<name> must be a finite number` |
| `readingDuration` in (0, 3600] | `readingDuration must be greater than 0 and at most 3600 seconds` |
| `startWindowSeconds`, `startMaxWaitSeconds`, `cancelWindowSeconds`, `liveDiscardGateSeconds` in \[0, 3600] | `<name> must be in 0...3600 seconds` |
| `fpsTiers` not empty | `fpsTiers must list at least one tier` |
| every `fpsTiers` entry in 1...240 | `fpsTiers entries must be in 1...240 fps (got <tier>)` |

## VitalsSession

One session runs one reading. Create a new session for each reading screen. The class is `final` and
`@unchecked Sendable`; own it from one place.

```swift theme={null}
public final class VitalsSession {
  /// Throws .notActivated (activate has not succeeded), .unsupportedDevice(reason:) (deviceSupport().isSupported
  /// is false) or .internalError("Invalid configuration: ...") (see Configuration validation).
  public convenience init(configuration: VitalsConfiguration = VitalsConfiguration()) throws

  /// Optional camera preview for the host to place. Available after init.
  public var previewLayer: AVCaptureVideoPreviewLayer? { get }

  /// Every event, in order, on the main thread. One consumer per session. The stream ends after the
  /// terminal event (completed or failed) and on stop().
  public let events: AsyncStream<VitalsEvent>

  /// Callback alternative to events. Called on the main thread.
  public weak var delegate: VitalsSessionDelegate? { get set }

  /// The current state. Safe from any thread.
  public var state: VitalsSessionState { get }

  /// Turns on the camera and torch and enters positioning.
  public func start() async throws

  /// Cancels the current attempt; the session returns to positioning (autoRestartAfterCancel) or idle.
  /// No-op unless measuring. Any thread.
  public func cancelReading(reason: String = "host")

  /// Tears down the camera and turns the torch off. Idempotent. Any thread.
  public func stop()
}

public protocol VitalsSessionDelegate: AnyObject {
  func vitalsSession(_ session: VitalsSession, didEmit event: VitalsEvent)
}
```

`start()` rechecks activation and the device, requests camera access when it is `notDetermined`, opens the camera
at the tier `deviceSupport().fpsTier` reports (or the next lower tier that has a format), turns the torch on and
arms the reading. It throws:

| Error | When |
| - | - |
| `.alreadyRunning` | The session is starting or running. |
| `.internalError("The session has finished; create a new session")` | A `completed` or `failed` session. |
| `.internalError("The session was stopped; create a new session")` | `stop()` was called (also when it landed while `start()` was suspended). |
| `.notActivated` | Activation was lost since `init`. |
| `.cameraDenied`, `.cameraRestricted` | Camera authorization; `.cameraDenied` also when the prompt is declined. |
| `.unsupportedDevice(reason:)` | `deviceSupport()` reports unsupported, or no format reaches the requested tier. |
| `.cameraUnavailable` | The rear camera could not be configured or started (no device, input or output refused). |

A failed `start()` leaves the session idle, so the host can try again (after granting camera access, say). On iOS a
camera that cannot be opened is reported synchronously by `start()`; on Android the same condition arrives
asynchronously as `Failed(CameraUnavailable)`.

Other behaviours:

* **Measuring ticks.** While measuring, `state` is updated to `.measuring(elapsed:)` about once per second
  without a `.state` event; the `.preview` event carries the same `elapsed`.
* **Cancel without auto-restart.** With `autoRestartAfterCancel = false` a cancel emits `.cancelled`, then
  `.state(.idle)`, turns the torch off and stops the camera; `start()` may be called again on the same session.
* **Backgrounding.** `UIApplication.didEnterBackgroundNotification`, an `AVCaptureSession` interruption or a
  media-services reset while positioning or measuring fails the session with `.interrupted(reason:)` (for
  backgrounding the reason is `"The app moved to the background"`). An interruption that arrives while finalizing
  is ignored and the result is delivered. Lock the reading screen's orientation so a rotation cannot interrupt a
  reading.
* **stop().** While positioning, measuring or finalizing, `stop()` sets `state` to `.idle` and emits
  `.state(.idle)`; a terminal state is kept. The events stream finishes. The session cannot be restarted after
  `stop()`. `deinit` calls `stop()`.
* The configuration passed to `init` is not exposed as a public property on iOS (Android exposes
  `VitalsSession.config`).

## VitalsSessionState

```swift theme={null}
public enum VitalsSessionState: Sendable, Equatable {
  case idle
  case positioning                     // camera and torch on; waiting for a stable fingertip signal
  case measuring(elapsed: Double)      // reading in progress; seconds since the reading started
  case finalizing
  case completed(VitalsResult)
  case failed(VitalsError)
}
```

## VitalsEvent

```swift theme={null}
public enum VitalsEvent: Sendable, Equatable {
  case state(VitalsSessionState)                        // every transition (not the measuring ticks)
  case guidance(Guidance, startProgress: Double)        // about 1 Hz while positioning; startProgress 0...1
  case preview(LivePreview)                             // about 1 Hz while measuring
  case heartbeat(ibiMs: Double?)                        // one per detected beat while measuring
  case started                                          // the start gate opened; a reading began
  case cancelled(CancelReason, CancelContext)
  case reconfiguringCamera(lowPowerMode: Bool)          // dropping to a lower fps tier in place
  case recoverableError(VitalsRecoverableError)         // one automatic recovery per session
  case measurementComplete                              // the full duration was captured; result follows
  case completed(VitalsResult)
  case failed(VitalsError)
}
```

`completed` and `failed` arrive twice: as their own event and inside the following `.state(...)` event.

## Guidance

```swift theme={null}
public enum Guidance: String, Sendable, CaseIterable {
  case noContact      // no fingertip detected
  case lowQuality     // contact, signal not yet stable
  case weakSignal     // contact, pulse amplitude below minAmplitude
  case compliant      // eligible; the start gate is filling
  case frameDrop      // camera below 70% of its target frame rate
}
```

Evaluation order per preview: `noContact`, then `frameDrop`, then `weakSignal`, then `lowQuality`, else
`compliant`. The SDK ships no strings; see [Reading lifecycle](/reading-lifecycle#guidance-values-and-suggested-copy)
for the NEUROFIT app's copy.

## LivePreview

```swift theme={null}
public struct LivePreview: Sendable, Equatable {
  public let elapsed: Double                 // seconds since the reading started
  public let progress: Double                // elapsed / readingDuration, clamped to 0...1
  public let hrBpm: Double?
  public let rmssdMs: Double?                // provisional HRV (RMSSD), from about 20 s; nil before
  public let signalLevel: SignalLevel
  public let breathingRate: Double?          // breaths per minute; nil until the estimate is ready (about 30 s)
  public let abortRisk: AbortRisk
  public init(elapsed: Double, progress: Double, hrBpm: Double?, rmssdMs: Double?,
              signalLevel: SignalLevel, breathingRate: Double?, abortRisk: AbortRisk)
}

public enum SignalLevel: String, Sendable, CaseIterable {
  case strong, good, fair, weak              // quality score >= 0.80 / >= 0.65 / >= 0.50 / else
}

public enum AbortRisk: String, Sendable, CaseIterable {
  case none, warn, imminent                  // distance to the no-low-discard-estimate cancel
}
```

`rmssdMs` is sticky: once shown it keeps the last good value until the attempt ends. `abortRisk` is `warn` from
preview 21 of the 35 s discard deadline (60%, rounded up), `imminent` from preview 30 (the last 5 s), and `none` as
soon as a live estimate clears the gate.

## CancelReason and CancelContext

```swift theme={null}
public enum CancelReason: Sendable, Equatable {
  case contactLost                 // no contact for contactLossPreviews consecutive previews
  case lowQuality                  // cancel gate: too many failed quality checks in the window
  case lowLiveSignalQuality        // liveSqCancelConsecutive withheld live estimates (observe only)
  case noLowDiscardEstimate        // no live estimate below the threshold by liveDiscardGateSeconds
  case host(String)                // cancelReading(reason:)

  /// The snake_case token the NEUROFIT app logs ("contact_lost", "low_quality", "low_live_signal_quality",
  /// "no_low_discard_estimate"), or the host's own string for .host.
  public var token: String { get }
}

public struct CancelContext: Sendable, Equatable {
  public let elapsedSeconds: Double?
  public let qualityScore: Double?
  public let effectiveFps: Int
  public let deviceModel: String             // hardware identifier, e.g. "iPhone15,2"
  public let redDc: Double?                  // red mean of the last measuring frame
  public let exposureIso: Double?            // the camera's current ISO
  public let exposureTuneSteps: Int?         // always nil on iOS (no exposure tuner)
  public let exposureInBand: Bool?           // redDc within exposureDcMin...exposureDcMax
  public func toDictionary() -> [String: Any]
}
```

`toDictionary()` always contains these ten keys: `reason` (the `CancelReason.token`), `quality_score`,
`elapsed_s`, `engine` (`"neurofit_vitals"`), `refine_mode` (a per-platform engine constant), `effective_fps`,
`device_model`, `red_dc`, `exposure_iso`, `exposure_tune_steps`. Absent numbers are `-1` (`-1.0` for doubles,
`-1` for `exposure_tune_steps`). `exposure_in_band` is added only when `exposureInBand` is known. The initializer
is internal; the SDK builds the context.

## VitalsRecoverableError

```swift theme={null}
public enum VitalsRecoverableError: Sendable, Equatable {
  case frameRateUnsustainable(fps: Double)
  case cameraStalled
}
```

The SDK recovers from the first recoverable error in a session (the attempt is abandoned, the camera rebound, the
session returns to positioning) and fails on the second, whatever its kind: `cameraStalled` fails with
`.cameraUnavailable`, `frameRateUnsustainable` with `.unsupportedDevice(reason:)`. In 1.0.0 iOS emits only
`.cameraStalled` (for an `AVCaptureSession` runtime error); sustained frame drop uses the tier ladder and
`.reconfiguringCamera` instead.

## VitalsError

```swift theme={null}
public enum VitalsError: Error, Sendable, Equatable {
  case notActivated                                  // activate(licenseKey:) has not succeeded
  case licenseInvalid                                // malformed key or bad signature
  case licenseNotValidForApp(appId: String)          // the key does not list this bundle identifier
  case licenseUpdatesExpired(updatesUntil: String)   // this build is dated after the key's window
  case cameraDenied
  case cameraRestricted
  case cameraUnavailable
  case unsupportedDevice(reason: String)
  case interrupted(reason: String)                   // backgrounding, another client, media services reset
  case noHeartRate                                   // the reading finished without a heart rate
  case alreadyRunning
  case internalError(String)

  /// The case name as a stable string ("notActivated", "licenseInvalid", ...), for hosts and wrappers.
  public var code: String { get }
}

extension VitalsError: LocalizedError {
  public var errorDescription: String? { get }       // one English sentence per case, with the associated value
}
```

## VitalsResult

```swift theme={null}
public struct VitalsResult: Sendable, Codable, Equatable {
  public let id: UUID
  public let capturedAt: Date
  public let durationSeconds: Double
  public let heartRateBpm: Double?
  public let rmssdMs: Double?                   // HRV (RMSSD), ms
  public let sdnnMs: Double?
  public let baevskyStressIndex: Double?
  public let sd2sd1: Double?                    // Poincaré SD2:SD1
  public let breathingRateBrpm: Double?         // returned at any confidence
  public let breathingRateConfidence: Double?   // nil exactly when breathingRateBrpm is nil; 0...1
  public let confidence: Double                 // HRV confidence, else heart-rate confidence, else 0
  public let quality: SignalQuality
  public let flags: [String]
  public let diagnostics: VitalsDiagnostics
  public let engineVersion: String              // "1.3.0"
  public let sdkVersion: String                 // "1.0.0"
}

public enum SignalQuality: Int, Sendable, Codable, CaseIterable {
  case clean = 3
  case usable = 2
  case withheld = 1
}
```

`Codable` encodes camelCase keys; `diagnostics` encodes as its snake\_case dictionary. Field meanings and units
are on the [Metrics](/metrics) page. The members have no public initializer; results come from the session.

## VitalsDiagnostics

Every member is a `public let`. `Codable` and `toDictionary()` use the snake\_case keys shown; the optionals are
absent (not `-1`) when the step that produces them did not run.

```swift theme={null}
public struct VitalsDiagnostics: Sendable, Codable, Equatable {
  // Provenance
  public let engine: String                 // engine: "neurofit_vitals"
  public let engineVersion: String          // engine_version
  public let hrSource: String               // hr_source
  public let refineMode: String             // refine_mode: a per-platform engine constant. Opaque.
  public let effectiveFps: Int              // effective_fps
  // Quality
  public let signalQuality: Int             // signal_quality
  public let qualityPassedFraction: Double  // quality_passed_fraction
  public let discardRatio: Double           // discard_ratio
  public let saturationFrac: Double         // saturation_frac
  public let measuredFps: Double            // measured_fps
  public let frameIntervalCv: Double        // frame_interval_cv
  public let frameDropComplianceErrorCount: Int // frame_drop_compliance_error_count
  public let amplitude: Double              // amplitude
  public let spectralConcentration: Double  // spectral_concentration
  public let snrDb: Double                  // snr_db
  public let skewness: Double               // skewness
  // Capture hardware
  public let torchActive: Bool              // torch_active
  public let torchLevel: Double             // torch_level
  public let exposureIso: Double            // exposure_iso
  public let exposureDurationMs: Double     // exposure_duration_ms
  public let exposureLocked: Bool           // exposure_locked
  public let exposureTunerEnabled: Bool     // exposure_tuner_enabled
  public let exposureTuneSteps: Int         // exposure_tune_steps
  public let redDcMean: Double              // red_dc_mean
  public let activeFormatWidth: Int         // active_format_width
  public let activeFormatHeight: Int        // active_format_height
  public let isVideoBinned: Bool            // is_video_binned
  public let videoFieldOfView: Double       // video_field_of_view
  public let lensPosition: Double           // lens_position
  public let minimumFocusDistance: Int      // minimum_focus_distance
  // Quality trajectory
  public let liveSqAt15s: Double            // live_sq_at_15s
  public let liveSqAt30s: Double            // live_sq_at_30s
  public let earlyMeanQualityScore: Double  // early_mean_quality_score
  public let earlyMinQualityScore: Double   // early_min_quality_score
  public let earlyMeanAmplitude: Double     // early_mean_amplitude
  public let liveSqMaxWithheldRun: Int      // live_sq_max_withheld_run
  public let liveSqWithheldEstimates: Int   // live_sq_withheld_estimates
  public let liveSqTotalEstimates: Int      // live_sq_total_estimates
  public let liveSqWithheldSeq: String      // live_sq_withheld_seq
  public let earlyMeanDiscardRatio: Double  // early_mean_discard_ratio
  public let earlyMaxDiscardRatio: Double   // early_max_discard_ratio
  // Breathing
  public let motionSamples: Int             // motion_samples
  public let rrMotionBrpm: Double?          // rr_motion_brpm
  public let rrMotionConc: Double?          // rr_motion_conc
  // Engine (consistency gate and estimator); optionals absent when the gate did not run
  public let gateEnabled: Bool              // gate_enabled
  public let gateMergedFixEnabled: Bool     // gate_merged_fix_enabled
  public let gateRejected: Bool?            // gate_rejected
  public let gateVeto: Bool?                // gate_veto
  public let gateNPairs: Int?               // gate_n_pairs
  public let gateNMerged: Int?              // gate_n_merged
  public let gateDiscardRaw: Double?        // gate_discard_raw
  public let gateMaxJump: Double?           // gate_max_jump
  public let gateHfShare: Double?           // gate_hf_share
  public let gateCompress: Double?          // gate_compress
  public let gateRPs: Double?               // gate_r_ps
  public let estColOverProd: Double?        // est_col_over_prod
  public let estRmssdParabolic: Double?     // est_rmssd_parabolic
  public let estRmssdFixed: Double?         // est_rmssd_fixed
  public let estGmApplied: Bool?            // est_gm_applied
  public let estArm2Fired: Bool?            // est_arm2_fired

  /// The snake_case dictionary, key for key what Codable encodes (optionals absent when nil).
  public func toDictionary() -> [String: Any]
}
```

See [Diagnostics](/diagnostics) for every key's meaning. The Android record differs in a few keys
(`est_android_estimator_enabled` and `est_gm_ratio` exist only there; the iOS-only camera-format keys do not).

## VitalsChange

Provisional helper for pre/post comparisons; see
[Outcomes best practices](/outcomes-best-practices#is-a-change-real).

```swift theme={null}
public enum VitalsChange {
  /// Thresholds: heart rate 5 bpm, HRV (RMSSD) 10 ms, breathing rate 2 brpm (not exposed as public constants).
  public static func compare(baseline: VitalsResult, followUp: VitalsResult) -> ChangeAssessment
}

public struct ChangeAssessment: Sendable, Equatable {
  public let heartRateBpm: MetricChange?        // threshold 5 bpm
  public let rmssdMs: MetricChange?             // threshold 10 ms
  public let breathingRateBrpm: MetricChange?   // threshold 2 brpm
  public let sdnnMs: MetricChange?              // no threshold; beyondTypicalVariation is nil
  public let baevskyStressIndex: MetricChange?  // no threshold
  public let sd2sd1: MetricChange?              // no threshold
}

public struct MetricChange: Sendable, Equatable {
  public let baseline: Double
  public let followUp: Double
  public let delta: Double                       // followUp - baseline
  public let beyondTypicalVariation: Bool?       // |delta| > threshold; nil when the metric has no threshold
}
```

A metric's entry is `nil` when either reading lacks the metric.

## Threading

* `VitalsSDK.activate`, `licenseStatus`, `deviceSupport()`, `cameraAuthorization`, `isLoggingEnabled`,
  `VitalsSession.init`, `state`, `delegate`, `cancelReading` and `stop` are safe from any thread. The activation
  record is lock-guarded; the SDK has no other mutable statics.
* `start()` is `async` and may be awaited from any context; it suspends through the camera prompt and the camera's
  own start.
* Every event reaches the `events` stream and the delegate on the main thread, in order, never synchronously from
  the caller's thread.
* Frame extraction runs on the SDK's capture queue, the reading logic on its processing queue, the engine's
  finalize on a separate queue. Nothing blocks the main thread.
* No `DispatchQueue.main.sync` anywhere in the SDK.

## Logging

`VitalsSDK.isLoggingEnabled` (default `false`) turns on `os.Logger` debug messages under subsystem
`com.neurofit.vitals`, category `session`: state transitions and capture decisions only. Nothing is formatted or
emitted while it is off. No metric values, frames or license contents are ever logged.

## Package

`Package.swift`: tools version 5.9, `platforms: [.iOS(.v15)]`, product `.library(name: "NeurofitVitals",
type: .dynamic)`, dependency `PpgCore` at `../../ports/swift`, `swiftLanguageVersions: [.v5]`. The library bundles
`PrivacyInfo.xcprivacy` (no tracking, no collected data types, no required-reason APIs) and no other resources.
Dependencies beyond Apple frameworks: none.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.