Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/workflows/swift-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,87 @@ jobs:
-scheme OpenMedKit \
-destination 'generic/platform=iOS Simulator' \
-skipPackagePluginValidation

build-watchos:
name: Build & Test watchOS Simulator
runs-on: macos-latest

steps:
- uses: actions/checkout@v7

- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer

- name: Select available watchOS simulator
id: destination
shell: bash
run: |
set -euo pipefail
udid="$(
xcrun simctl list devices available --json |
jq -r '[
.devices | to_entries[] |
select(.key | contains("watchOS")) |
.value[] |
select(.isAvailable == true) |
.udid
][0] // empty'
)"
test -n "$udid"
echo "udid=$udid" >> "$GITHUB_OUTPUT"

- name: Build and run watchOS parity tests
working-directory: swift/OpenMedKit
run: |
xcodebuild test \
-scheme OpenMedKit \
-destination 'platform=watchOS Simulator,id=${{ steps.destination.outputs.udid }}' \
-only-testing:OpenMedKitTests/WatchVisionParityTests \
-skipPackagePluginValidation

build-visionos:
name: Build & Test visionOS Simulator
runs-on: macos-latest

steps:
- uses: actions/checkout@v7

- name: Select Xcode version
run: sudo xcode-select -s /Applications/Xcode.app/Contents/Developer

- name: Build native visionOS simulator target
working-directory: swift/OpenMedKit
shell: bash
run: |
set -euo pipefail
sdk_path="$(xcrun --sdk xrsimulator --show-sdk-path)"
swift build \
--triple arm64-apple-xros1.0-simulator \
--sdk "$sdk_path"

- name: Select available visionOS simulator
id: destination
shell: bash
run: |
set -euo pipefail
udid="$(
xcrun simctl list devices available --json |
jq -r '[
.devices | to_entries[] |
select(.key | contains("xrOS")) |
.value[] |
select(.isAvailable == true) |
.udid
][0] // empty'
)"
test -n "$udid"
echo "udid=$udid" >> "$GITHUB_OUTPUT"

- name: Build and run visionOS parity tests
working-directory: swift/OpenMedKit
run: |
xcodebuild test \
-scheme OpenMedKit \
-destination 'platform=visionOS Simulator,id=${{ steps.destination.outputs.udid }}' \
-only-testing:OpenMedKitTests/WatchVisionParityTests \
-skipPackagePluginValidation
26 changes: 22 additions & 4 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ let package = Package(
platforms: [
.iOS(.v17),
.macOS(.v14),
.watchOS(.v10),
.visionOS(.v1),
],
products: [
.library(
Expand All @@ -32,10 +34,26 @@ let package = Package(
.target(
name: "OpenMedKit",
dependencies: [
.product(name: "Transformers", package: "swift-transformers"),
.product(name: "MLX", package: "mlx-swift"),
.product(name: "MLXNN", package: "mlx-swift"),
.product(name: "ZIPFoundation", package: "ZIPFoundation"),
.product(
name: "Transformers",
package: "swift-transformers",
condition: .when(platforms: [.iOS, .macOS])
),
.product(
name: "MLX",
package: "mlx-swift",
condition: .when(platforms: [.iOS, .macOS])
),
.product(
name: "MLXNN",
package: "mlx-swift",
condition: .when(platforms: [.iOS, .macOS])
),
.product(
name: "ZIPFoundation",
package: "ZIPFoundation",
condition: .when(platforms: [.iOS, .macOS])
),
],
path: "swift/OpenMedKit/Sources/OpenMedKit",
resources: [
Expand Down
99 changes: 99 additions & 0 deletions docs/runtimes/apple-platforms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# OpenMedKit Apple Platform Support

OpenMedKit supports macOS 14+, iOS 17+, watchOS 10+, and visionOS 1+.
The runtime surface depends on the platform so constrained devices do not link
backends that exceed their deployment or memory envelope.

| Platform | Supported backend | Default model ceiling | Resident RAM ceiling | Maximum sequence |
|---|---|---:|---:|---:|
| macOS 14+ | MLX or CoreML | Base | 900 MB | 512 tokens |
| iOS 17+ / iPadOS 17+ | MLX on a physical device, or CoreML | Tiny | 350 MB | 512 tokens |
| watchOS 10+ | CoreML only | Nano, INT8 | 150 MB | 256 tokens |
| visionOS 1+ | CoreML only | Nano, INT8 | 150 MB | 256 tokens |

The watchOS and visionOS limits use OpenMed's canonical Nano sub-tier: 10–30M
parameters, at most 150 MB resident memory, and INT8 CoreML artifacts. These
limits are enforced from model metadata before `MLModel` is opened. A Tiny,
Base, over-budget, or non-INT8 model fails closed instead of being loaded.

## Selecting and loading a constrained CoreML model

Describe the bundled model candidates and let `PlatformModel` select the
highest-capacity candidate that fits the current target:

```swift
import OpenMedKit

let nano = PlatformModelDescriptor(
identifier: "OpenMed-PII-Nano-INT8",
modelURL: Bundle.main.url(
forResource: "OpenMed-PII-Nano-INT8",
withExtension: "mlmodelc"
)!,
id2labelURL: Bundle.main.url(
forResource: "id2label",
withExtension: "json"
)!,
tier: .nano,
parameterCount: 24_000_000,
estimatedResidentMemoryMB: 128,
isINT8: true
)

let model = try PlatformModel(candidates: [nano])
```

watchOS and visionOS intentionally omit the full MLX and
`swift-transformers` graph. Apps tokenize with the assets bundled beside their
Nano model and pass bounded token IDs, attention masks, and character offsets
to `PlatformModel.predict(...)`. This keeps model and tokenizer access local;
OpenMedKit does not add a cloud fallback.

## Minimal redaction surface

`PlatformModel.redact(...)` applies mask or removal redaction to detected
`EntityPrediction` spans without loading another backend:

```swift
let note = "Patient Ada Lovelace, MRN TEST-123."
let spans = [
EntityPrediction(
label: "full_name",
text: "Ada Lovelace",
confidence: 0.99,
start: 8,
end: 20
),
EntityPrediction(
label: "medical_record_number",
text: "TEST-123",
confidence: 0.99,
start: 26,
end: 34
),
]

let result = PlatformModel.redact(note, entities: spans)
// Patient [FULL_NAME], MRN [MEDICAL_RECORD_NUMBER].
```

Offsets remain character offsets into the original note. The watchOS and
visionOS simulator tests use the same synthetic note and iOS reference spans,
with a one-character tolerance at each boundary.

## Build and validation

The Swift workflow performs the normal macOS tests, the iOS simulator build,
and focused parity tests on available watchOS and visionOS simulators. Local
checks use the same package scheme:

```bash
cd swift/OpenMedKit
xcodebuild build -scheme OpenMedKit \
-destination 'generic/platform=watchOS Simulator'
xcodebuild build -scheme OpenMedKit \
-destination 'generic/platform=visionOS Simulator'
```

Use only synthetic notes in committed tests and fixtures. OpenMedKit keeps
inference on device and does not log input text or detected span text.
13 changes: 10 additions & 3 deletions docs/swift-openmedkit.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# OpenMedKit (Swift Package)

OpenMedKit is the Swift package for running OpenMed models in **macOS**, **iOS**, and **iPadOS** apps.
OpenMedKit is the Swift package for running OpenMed models in **macOS**,
**iOS**, **iPadOS**, **watchOS**, and **visionOS** apps.

OpenMedKit currently supports two Apple backends:

- **MLX** for Apple Silicon Macs and real iPhone/iPad devices
- **CoreML** for bundled Apple model packages
- **CoreML** for bundled Apple model packages, including constrained watchOS
and visionOS Nano artifacts

Swift MLX supports the first OpenMed artifact families used by the public Apple demos:

Expand All @@ -22,7 +24,7 @@ ModernBERT, Longformer, EuroBERT, Qwen3, and additional architecture families ar

## Requirements

- iOS 17+ / macOS 14+
- iOS 17+ / macOS 14+ / watchOS 10+ / visionOS 1+
- Xcode 15+
- For MLX:
- Apple Silicon Mac, or
Expand All @@ -31,6 +33,9 @@ ModernBERT, Longformer, EuroBERT, Qwen3, and additional architecture families ar
- a compatible `.mlpackage` or `.mlmodelc` bundle plus `id2label.json`

iOS Simulator is **not** a Swift MLX validation target.
watchOS and visionOS use the CoreML-only `PlatformModel` surface and require an
INT8 Nano-tier artifact. See [Apple Platform Support](./runtimes/apple-platforms.md)
for selection limits and simulator validation.

## Apple Platform Matrix

Expand All @@ -40,6 +45,8 @@ iOS Simulator is **not** a Swift MLX validation target.
| Swift app on Apple Silicon macOS | `OpenMedKit` + MLX or CoreML |
| Swift app on real iPhone/iPad | `OpenMedKit` + MLX or CoreML |
| Swift app on iOS Simulator | CoreML only |
| Swift app on Apple Watch | `PlatformModel` + Nano INT8 CoreML |
| Swift app on Apple Vision Pro | `PlatformModel` + Nano INT8 CoreML |
| Older Apple OS support | CoreML |

## Install OpenMedKit
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ nav:
- On-device Segmenters: on-device-segmenters.md
- CoreML Packaging: coreml-export.md
- Swift Package (OpenMedKit): swift-openmedkit.md
- OpenMedKit Apple Platforms: runtimes/apple-platforms.md
- Swift-Kotlin API Parity: swift-kotlin-parity.md
- Project:
- FAQ: faq.md
Expand Down
26 changes: 22 additions & 4 deletions swift/OpenMedKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ let package = Package(
platforms: [
.iOS(.v17),
.macOS(.v14),
.watchOS(.v10),
.visionOS(.v1),
],
products: [
.library(
Expand All @@ -25,10 +27,26 @@ let package = Package(
.target(
name: "OpenMedKit",
dependencies: [
.product(name: "Transformers", package: "swift-transformers"),
.product(name: "MLX", package: "mlx-swift"),
.product(name: "MLXNN", package: "mlx-swift"),
.product(name: "ZIPFoundation", package: "ZIPFoundation"),
.product(
name: "Transformers",
package: "swift-transformers",
condition: .when(platforms: [.iOS, .macOS])
),
.product(
name: "MLX",
package: "mlx-swift",
condition: .when(platforms: [.iOS, .macOS])
),
.product(
name: "MLXNN",
package: "mlx-swift",
condition: .when(platforms: [.iOS, .macOS])
),
.product(
name: "ZIPFoundation",
package: "ZIPFoundation",
condition: .when(platforms: [.iOS, .macOS])
),
],
resources: [
.process("Resources")
Expand Down
45 changes: 42 additions & 3 deletions swift/OpenMedKit/Sources/OpenMedKit/NERPipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,40 @@ public class NERPipeline {
/// - modelURL: Path to the `.mlmodelc` or `.mlpackage` file.
/// - id2labelURL: Path to the `id2label.json` file mapping label IDs to names.
/// - maxSeqLength: Maximum input sequence length the model supports.
public init(modelURL: URL, id2labelURL: URL, maxSeqLength: Int = 512) throws {
self.model = try MLModel(contentsOf: try Self.resolveModelURL(modelURL))
@available(watchOS, unavailable, message: "Use PlatformModel for Nano budget enforcement.")
@available(visionOS, unavailable, message: "Use PlatformModel for Nano budget enforcement.")
public convenience init(
modelURL: URL,
id2labelURL: URL,
maxSeqLength: Int = 512
) throws {
try self.init(
resolvedModelURL: Self.resolveModelURL(modelURL),
id2labelURL: id2labelURL,
maxSeqLength: maxSeqLength
)
}

convenience init(
validatedDescriptor descriptor: PlatformModelDescriptor,
configuration: PlatformModelConfiguration
) throws {
guard configuration.allows(descriptor) else {
throw PlatformModelError.noCompatibleModel(configuration.platform)
}
try self.init(
resolvedModelURL: Self.resolveModelURL(descriptor.modelURL),
id2labelURL: descriptor.id2labelURL,
maxSeqLength: configuration.maximumSequenceLength
)
}

private init(
resolvedModelURL: URL,
id2labelURL: URL,
maxSeqLength: Int
) throws {
self.model = try MLModel(contentsOf: resolvedModelURL)
self.maxSeqLength = maxSeqLength

let data = try Data(contentsOf: id2labelURL)
Expand All @@ -32,7 +64,11 @@ public class NERPipeline {
private static func resolveModelURL(_ modelURL: URL) throws -> URL {
switch modelURL.pathExtension.lowercased() {
case "mlpackage", "mlmodel":
return try MLModel.compileModel(at: modelURL)
#if os(watchOS) || os(visionOS)
throw NERPipelineError.uncompiledModelUnsupported(modelURL)
#else
return try MLModel.compileModel(at: modelURL)
#endif
default:
return modelURL
}
Expand Down Expand Up @@ -139,11 +175,14 @@ public class NERPipeline {
/// Errors thrown by the NER pipeline.
public enum NERPipelineError: Error, LocalizedError {
case missingOutput(String)
case uncompiledModelUnsupported(URL)

public var errorDescription: String? {
switch self {
case .missingOutput(let name):
return "CoreML model output '\(name)' not found"
case .uncompiledModelUnsupported(let url):
return "\(url.lastPathComponent) must be compiled to .mlmodelc before bundling on watchOS or visionOS"
}
}
}
Loading