@dvai-bridge/ios 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DVAIBridge.podspec +120 -0
- package/LICENSE +51 -0
- package/Package.swift +104 -0
- package/README.md +199 -0
- package/ios/Sources/DVAIBridge/BackendKind.swift +23 -0
- package/ios/Sources/DVAIBridge/BoundServer.swift +46 -0
- package/ios/Sources/DVAIBridge/Capability/CapabilityCache.swift +85 -0
- package/ios/Sources/DVAIBridge/Capability/CapabilityPrecheck.swift +193 -0
- package/ios/Sources/DVAIBridge/Capability/CapabilityScore.swift +51 -0
- package/ios/Sources/DVAIBridge/Capability/DeviceID.swift +70 -0
- package/ios/Sources/DVAIBridge/Capability/HardwareAssessment.swift +41 -0
- package/ios/Sources/DVAIBridge/DVAIBridge.swift +658 -0
- package/ios/Sources/DVAIBridge/DVAIBridgeConfig.swift +86 -0
- package/ios/Sources/DVAIBridge/DVAIBridgeError.swift +33 -0
- package/ios/Sources/DVAIBridge/Discovery/MDNSPeer.swift +64 -0
- package/ios/Sources/DVAIBridge/Discovery/NWAdvertiser.swift +103 -0
- package/ios/Sources/DVAIBridge/Discovery/NWBrowserDiscovery.swift +212 -0
- package/ios/Sources/DVAIBridge/Internal/BackendSelector.swift +59 -0
- package/ios/Sources/DVAIBridge/Internal/ProgressBroadcaster.swift +84 -0
- package/ios/Sources/DVAIBridge/License/Audience.swift +133 -0
- package/ios/Sources/DVAIBridge/License/Discovery.swift +164 -0
- package/ios/Sources/DVAIBridge/License/LicenseValidator.swift +392 -0
- package/ios/Sources/DVAIBridge/License/PublicKeys.swift +114 -0
- package/ios/Sources/DVAIBridge/License/Types.swift +195 -0
- package/ios/Sources/DVAIBridge/Offload/OffloadConfig.swift +118 -0
- package/ios/Sources/DVAIBridge/Offload/OffloadProxy.swift +604 -0
- package/ios/Sources/DVAIBridge/Offload/OffloadRuntime.swift +98 -0
- package/ios/Sources/DVAIBridge/Pairing/Pairing.swift +125 -0
- package/ios/Sources/DVAIBridge/Pairing/PairingHandshake.swift +141 -0
- package/ios/Sources/DVAIBridge/Pairing/PairingPolicy.swift +162 -0
- package/ios/Sources/DVAIBridge/Pairing/PairingStore.swift +65 -0
- package/ios/Sources/DVAIBridge/ProgressEvent.swift +34 -0
- package/ios/Sources/DVAIBridge/ReactiveState.swift +149 -0
- package/ios/Sources/DVAICoreMLCore/.gitkeep +0 -0
- package/ios/Sources/DVAICoreMLCore/CoreMLBackendError.swift +19 -0
- package/ios/Sources/DVAICoreMLCore/CoreMLHandlers.swift +123 -0
- package/ios/Sources/DVAICoreMLCore/CoreMLPluginState.swift +130 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLEngine.swift +137 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLGenerator.swift +108 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLSampler.swift +96 -0
- package/ios/Sources/DVAICoreMLCore/Internal/CoreMLTokenizer.swift +69 -0
- package/ios/Tests/DVAIBridgeTests/BackendSelectorTests.swift +53 -0
- package/ios/Tests/DVAIBridgeTests/CapabilityPrecheckTests.swift +108 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLEngineTests.swift +18 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLGeneratorShapeTests.swift +11 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLHandlersTests.swift +32 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLPluginStateTests.swift +41 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLSamplerTests.swift +40 -0
- package/ios/Tests/DVAIBridgeTests/CoreMLTokenizerTests.swift +19 -0
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeAPIShapeTests.swift +37 -0
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeConfigTests.swift +52 -0
- package/ios/Tests/DVAIBridgeTests/DVAIBridgeErrorTests.swift +33 -0
- package/ios/Tests/DVAIBridgeTests/LicenseValidatorTests.swift +658 -0
- package/ios/Tests/DVAIBridgeTests/OffloadProxyDecisionTests.swift +156 -0
- package/ios/Tests/DVAIBridgeTests/OffloadTests.swift +339 -0
- package/ios/Tests/DVAIBridgeTests/ProgressBroadcasterTests.swift +69 -0
- package/ios/Tests/DVAIBridgeTests/ProgressEventTests.swift +25 -0
- package/ios/Tests/DVAIBridgeTests/ReactiveStateTests.swift +45 -0
- package/ios/Tests/DVAIBridgeTests/RealModelIntegrationTest.swift +359 -0
- package/package.json +19 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import CoreML
|
|
3
|
+
|
|
4
|
+
/// Sampling strategies for autoregressive decoding.
|
|
5
|
+
///
|
|
6
|
+
/// Note on `seed:` — `SystemRandomNumberGenerator` cannot be seeded; for
|
|
7
|
+
/// reproducible sampling a custom PRNG (e.g. Mulberry32) would be needed.
|
|
8
|
+
/// Per the plan, we drop `seed:` from the public-facing init entirely rather
|
|
9
|
+
/// than silently ignoring it. Apple-managed entropy is fine for production LLM
|
|
10
|
+
/// sampling.
|
|
11
|
+
internal struct CoreMLSampler {
|
|
12
|
+
let temperature: Float
|
|
13
|
+
let topP: Float
|
|
14
|
+
let topK: Int // 0 = disabled
|
|
15
|
+
|
|
16
|
+
/// Sample a token id from a logits vector.
|
|
17
|
+
/// - Parameter logits: 1-D MLMultiArray<Float32> of length vocab_size.
|
|
18
|
+
func sample(logits: MLMultiArray) -> Int {
|
|
19
|
+
let count = logits.count
|
|
20
|
+
let ptr = UnsafeMutablePointer<Float32>(OpaquePointer(logits.dataPointer))
|
|
21
|
+
|
|
22
|
+
// 1. Greedy fast-path
|
|
23
|
+
if temperature <= 0 {
|
|
24
|
+
return argmax(ptr, count: count)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// 2. Apply temperature
|
|
28
|
+
var scaled = [Float](repeating: 0, count: count)
|
|
29
|
+
for i in 0 ..< count { scaled[i] = ptr[i] / temperature }
|
|
30
|
+
|
|
31
|
+
// 3. Optional top-K filter
|
|
32
|
+
if topK > 0 && topK < count {
|
|
33
|
+
applyTopK(&scaled, k: topK)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 4. Softmax → probabilities
|
|
37
|
+
let probs = softmax(scaled)
|
|
38
|
+
|
|
39
|
+
// 5. Optional nucleus (top-p) filter
|
|
40
|
+
let final = topP < 1.0 ? applyTopP(probs, p: topP) : probs
|
|
41
|
+
|
|
42
|
+
// 6. Categorical draw
|
|
43
|
+
return categoricalSample(final)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// MARK: - Helpers
|
|
47
|
+
|
|
48
|
+
private func argmax(_ ptr: UnsafeMutablePointer<Float32>, count: Int) -> Int {
|
|
49
|
+
var bestIdx = 0
|
|
50
|
+
var bestVal = ptr[0]
|
|
51
|
+
for i in 1 ..< count {
|
|
52
|
+
if ptr[i] > bestVal { bestVal = ptr[i]; bestIdx = i }
|
|
53
|
+
}
|
|
54
|
+
return bestIdx
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private func softmax(_ logits: [Float]) -> [Float] {
|
|
58
|
+
let maxVal = logits.max() ?? 0
|
|
59
|
+
var exps = logits.map { Float(exp(Double($0 - maxVal))) }
|
|
60
|
+
let sum = exps.reduce(0, +)
|
|
61
|
+
if sum > 0 { for i in 0 ..< exps.count { exps[i] /= sum } }
|
|
62
|
+
return exps
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private func applyTopK(_ logits: inout [Float], k: Int) {
|
|
66
|
+
let kth = logits.sorted(by: >).prefix(k).last ?? -.greatestFiniteMagnitude
|
|
67
|
+
for i in 0 ..< logits.count where logits[i] < kth { logits[i] = -.greatestFiniteMagnitude }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private func applyTopP(_ probs: [Float], p: Float) -> [Float] {
|
|
71
|
+
let sorted = probs.enumerated().sorted { $0.element > $1.element }
|
|
72
|
+
var cum: Float = 0
|
|
73
|
+
var keep = Set<Int>()
|
|
74
|
+
for (idx, prob) in sorted {
|
|
75
|
+
keep.insert(idx)
|
|
76
|
+
cum += prob
|
|
77
|
+
if cum >= p { break }
|
|
78
|
+
}
|
|
79
|
+
var result = probs
|
|
80
|
+
for i in 0 ..< result.count where !keep.contains(i) { result[i] = 0 }
|
|
81
|
+
let sum = result.reduce(0, +)
|
|
82
|
+
if sum > 0 { for i in 0 ..< result.count { result[i] /= sum } }
|
|
83
|
+
return result
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private func categoricalSample(_ probs: [Float]) -> Int {
|
|
87
|
+
var rng = SystemRandomNumberGenerator()
|
|
88
|
+
let r = Float.random(in: 0 ..< 1, using: &rng)
|
|
89
|
+
var cum: Float = 0
|
|
90
|
+
for i in 0 ..< probs.count {
|
|
91
|
+
cum += probs[i]
|
|
92
|
+
if r < cum { return i }
|
|
93
|
+
}
|
|
94
|
+
return probs.count - 1
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
#if !COCOAPODS
|
|
3
|
+
import Tokenizers
|
|
4
|
+
#endif
|
|
5
|
+
|
|
6
|
+
/// Loads a HuggingFace-style tokenizer.json + tokenizer_config.json from a
|
|
7
|
+
/// local directory. Provides chat-template application, encode, and decode.
|
|
8
|
+
///
|
|
9
|
+
/// swift-transformers 1.3.0 API notes:
|
|
10
|
+
/// - `AutoTokenizer.from(modelFolder:hubApi:strict:)` — hubApi and strict
|
|
11
|
+
/// have default values so the two-arg form `from(modelFolder:)` is NOT
|
|
12
|
+
/// available; must pass at minimum `modelFolder:`.
|
|
13
|
+
/// - `Message` is `typealias Message = [String: any Sendable]` so we convert
|
|
14
|
+
/// `[[String: String]]` → `[[String: any Sendable]]` before passing.
|
|
15
|
+
/// - `applyChatTemplate(messages:)` has all other params defaulted.
|
|
16
|
+
/// - `eosTokenId` is `Int?` (optional) — we fall back to 0 if absent.
|
|
17
|
+
internal struct CoreMLTokenizer: @unchecked Sendable {
|
|
18
|
+
private let inner: any Tokenizer
|
|
19
|
+
|
|
20
|
+
init(tokenizerDir: URL) async throws {
|
|
21
|
+
do {
|
|
22
|
+
// `from(modelFolder:)` resolves to `from(modelFolder:hubApi:strict:)`
|
|
23
|
+
// with default HubApi() and strict: true.
|
|
24
|
+
self.inner = try await AutoTokenizer.from(modelFolder: tokenizerDir)
|
|
25
|
+
} catch {
|
|
26
|
+
throw CoreMLBackendError.tokenizerLoadFailed(reason: "\(error)")
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/// Apply the model's chat template to convert messages into token IDs.
|
|
31
|
+
/// - Parameter messages: Array of {"role": ..., "content": ...} dicts.
|
|
32
|
+
/// - Parameter addGenerationPrompt: Append the generation-start marker.
|
|
33
|
+
func applyChatTemplate(
|
|
34
|
+
messages: [[String: String]],
|
|
35
|
+
addGenerationPrompt: Bool = true
|
|
36
|
+
) throws -> [Int] {
|
|
37
|
+
// Convert [[String: String]] → [[String: any Sendable]] (Tokenizers.Message)
|
|
38
|
+
let normalized: [Message] = messages.map { dict in
|
|
39
|
+
var m: Message = [:]
|
|
40
|
+
for (k, v) in dict { m[k] = v }
|
|
41
|
+
return m
|
|
42
|
+
}
|
|
43
|
+
do {
|
|
44
|
+
// swift-transformers 1.x's applyChatTemplate signature drops the
|
|
45
|
+
// addGenerationPrompt parameter (defaulted to true server-side).
|
|
46
|
+
// The `addGenerationPrompt` knob in our wrapper is preserved for
|
|
47
|
+
// future API symmetry but currently passed through implicitly.
|
|
48
|
+
_ = addGenerationPrompt
|
|
49
|
+
return try inner.applyChatTemplate(messages: normalized)
|
|
50
|
+
} catch {
|
|
51
|
+
throw CoreMLBackendError.generationFailed(reason: "applyChatTemplate failed: \(error)")
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func encode(text: String) -> [Int] {
|
|
56
|
+
inner.encode(text: text)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func decode(tokens: [Int]) -> String {
|
|
60
|
+
inner.decode(tokens: tokens, skipSpecialTokens: true)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
func decode(token: Int) -> String {
|
|
64
|
+
decode(tokens: [token])
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/// EOS token id. Falls back to 0 if the tokenizer config doesn't specify one.
|
|
68
|
+
var eosTokenId: Int { inner.eosTokenId ?? 0 }
|
|
69
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAIBridge
|
|
3
|
+
|
|
4
|
+
final class BackendSelectorTests: XCTestCase {
|
|
5
|
+
func testExplicitChoicePassesThrough() throws {
|
|
6
|
+
for kind in [BackendKind.llama, .foundation, .coreml] {
|
|
7
|
+
let resolved = try BackendSelector.resolve(kind, config: DVAIBridgeConfig())
|
|
8
|
+
XCTAssertEqual(resolved, kind)
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
func testAutoWithGGUFResolvesToLlama() throws {
|
|
13
|
+
let cfg = DVAIBridgeConfig(modelPath: "/path/to/model.gguf")
|
|
14
|
+
XCTAssertEqual(try BackendSelector.resolve(.auto, config: cfg), .llama)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func testAutoWithMlmodelcResolvesToCoreML() throws {
|
|
18
|
+
let cfg = DVAIBridgeConfig(modelPath: "/path/to/model.mlmodelc")
|
|
19
|
+
XCTAssertEqual(try BackendSelector.resolve(.auto, config: cfg), .coreml)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
func testAutoWithMlpackageResolvesToCoreML() throws {
|
|
23
|
+
let cfg = DVAIBridgeConfig(modelPath: "/path/to/model.mlpackage")
|
|
24
|
+
XCTAssertEqual(try BackendSelector.resolve(.auto, config: cfg), .coreml)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func testAutoWithTaskFileThrows() {
|
|
28
|
+
let cfg = DVAIBridgeConfig(modelPath: "/path/to/model.task")
|
|
29
|
+
XCTAssertThrowsError(try BackendSelector.resolve(.auto, config: cfg)) { err in
|
|
30
|
+
guard case let DVAIBridgeError.configurationInvalid(reason) = err else {
|
|
31
|
+
return XCTFail("wrong error type")
|
|
32
|
+
}
|
|
33
|
+
XCTAssertTrue(reason.contains("Android"))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func testAutoWithUnknownExtensionThrows() {
|
|
38
|
+
let cfg = DVAIBridgeConfig(modelPath: "/path/to/something.unknown")
|
|
39
|
+
XCTAssertThrowsError(try BackendSelector.resolve(.auto, config: cfg))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func testAutoWithNoModelPathOnIOS26ResolvesToFoundation() throws {
|
|
43
|
+
// This test only meaningfully runs on iOS 26+. On older simulators
|
|
44
|
+
// the no-modelPath branch throws. Both outcomes are well-defined;
|
|
45
|
+
// assert the right one based on availability.
|
|
46
|
+
let cfg = DVAIBridgeConfig(modelPath: nil)
|
|
47
|
+
if #available(iOS 26.0, macOS 26.0, *) {
|
|
48
|
+
XCTAssertEqual(try BackendSelector.resolve(.auto, config: cfg), .foundation)
|
|
49
|
+
} else {
|
|
50
|
+
XCTAssertThrowsError(try BackendSelector.resolve(.auto, config: cfg))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAIBridge
|
|
3
|
+
|
|
4
|
+
/// v3.2 — pre-init capability gate (Swift parallel to Android's
|
|
5
|
+
/// CapabilityPrecheckTest.kt and TS precheck.test.ts). Same hint
|
|
6
|
+
/// shapes, same expected modes — guarantees that iOS, Android, and
|
|
7
|
+
/// the TS core agree on what's a "too-weak" or "offload-only" device
|
|
8
|
+
/// for any given hardware profile.
|
|
9
|
+
@available(iOS 14.0, macOS 11.0, *)
|
|
10
|
+
final class CapabilityPrecheckTests: XCTestCase {
|
|
11
|
+
|
|
12
|
+
private let highEndDesktop = DeviceCapabilityHints(
|
|
13
|
+
hasNpu: false, ramGb: 32, gpuClass: .discrete, cpuClass: .high
|
|
14
|
+
)
|
|
15
|
+
private let appleSiliconLaptop = DeviceCapabilityHints(
|
|
16
|
+
hasNpu: true, ramGb: 16, gpuClass: .appleSilicon, cpuClass: .high
|
|
17
|
+
)
|
|
18
|
+
private let midRangeLaptop = DeviceCapabilityHints(
|
|
19
|
+
hasNpu: false, ramGb: 8, gpuClass: .integrated, cpuClass: .mid
|
|
20
|
+
)
|
|
21
|
+
private let lowEndLaptop = DeviceCapabilityHints(
|
|
22
|
+
hasNpu: false, ramGb: 4, gpuClass: .integrated, cpuClass: .low
|
|
23
|
+
)
|
|
24
|
+
private let veryWeakDevice = DeviceCapabilityHints(
|
|
25
|
+
hasNpu: false, ramGb: 2, gpuClass: .none, cpuClass: .low
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
func testHighEndDesktopClassifiesAsOk() {
|
|
29
|
+
let result = CapabilityPrecheck.assess(hints: highEndDesktop)
|
|
30
|
+
XCTAssertEqual(result.mode, .ok)
|
|
31
|
+
XCTAssertGreaterThan(result.tokPerSec, 10.0)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func testAppleSiliconClassifiesAsOk() {
|
|
35
|
+
let result = CapabilityPrecheck.assess(hints: appleSiliconLaptop)
|
|
36
|
+
XCTAssertEqual(result.mode, .ok)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func testMidRangeLaptopClassifiesAsOffloadOnly() {
|
|
40
|
+
// 8 (integrated) * 1.0 (mid CPU) * 1.0 (8 GB RAM) * 1.0 (no NPU) = 8 tok/s
|
|
41
|
+
// Above hardwareMinimum (3), below minLocalCapability (10) → offload-only.
|
|
42
|
+
let result = CapabilityPrecheck.assess(hints: midRangeLaptop)
|
|
43
|
+
XCTAssertEqual(result.mode, .offloadOnly)
|
|
44
|
+
XCTAssertEqual(result.tokPerSec, 8.0, accuracy: 0.01)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
func testLowEndLaptopClassifiesAsOffloadOnly() {
|
|
48
|
+
// 8 * 0.6 * 0.7 = 3.4 tok/s → above floor (3), below comfort (10).
|
|
49
|
+
let result = CapabilityPrecheck.assess(hints: lowEndLaptop)
|
|
50
|
+
XCTAssertEqual(result.mode, .offloadOnly)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
func testVeryWeakDeviceClassifiesAsTooWeak() {
|
|
54
|
+
// 3 (no GPU) * 0.6 (low CPU) * 0.3 (RAM < 4) = 0.5 tok/s → too-weak.
|
|
55
|
+
let result = CapabilityPrecheck.assess(hints: veryWeakDevice)
|
|
56
|
+
XCTAssertEqual(result.mode, .tooWeak)
|
|
57
|
+
XCTAssertLessThan(result.tokPerSec, 3.0)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func testCustomHardwareMinimumIsHonored() {
|
|
61
|
+
// Mid-range gets 8 tok/s. Raise the floor above that → too-weak.
|
|
62
|
+
let result = CapabilityPrecheck.assess(
|
|
63
|
+
thresholds: CapabilityPrecheck.Thresholds(hardwareMinimum: 12.0),
|
|
64
|
+
hints: midRangeLaptop
|
|
65
|
+
)
|
|
66
|
+
XCTAssertEqual(result.mode, .tooWeak)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
func testCustomMinLocalCapabilityIsHonored() {
|
|
70
|
+
// Mid-range gets 8 tok/s. Lower the comfort threshold to 5 → ok.
|
|
71
|
+
let result = CapabilityPrecheck.assess(
|
|
72
|
+
thresholds: CapabilityPrecheck.Thresholds(minLocalCapability: 5.0),
|
|
73
|
+
hints: midRangeLaptop
|
|
74
|
+
)
|
|
75
|
+
XCTAssertEqual(result.mode, .ok)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
func testReasonContainsTokPerSec() {
|
|
79
|
+
let result = CapabilityPrecheck.assess(hints: veryWeakDevice)
|
|
80
|
+
XCTAssertTrue(
|
|
81
|
+
result.reason.contains("tok/s"),
|
|
82
|
+
"reason should mention tok/s — got: \(result.reason)"
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func testHardwareAssessmentIsCodableRoundTrip() throws {
|
|
87
|
+
let result = CapabilityPrecheck.assess(hints: veryWeakDevice)
|
|
88
|
+
let assessment = HardwareAssessment(from: result)
|
|
89
|
+
let encoded = try JSONEncoder().encode(assessment)
|
|
90
|
+
let decoded = try JSONDecoder().decode(HardwareAssessment.self, from: encoded)
|
|
91
|
+
XCTAssertEqual(decoded.mode, .tooWeak)
|
|
92
|
+
XCTAssertLessThan(decoded.tokPerSec, 3.0)
|
|
93
|
+
XCTAssertEqual(decoded.hints, veryWeakDevice)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/// Wire-format strings should be stable across SDK versions for
|
|
97
|
+
/// cross-platform parity; rendering a JSON with this exact shape
|
|
98
|
+
/// is the contract every other SDK reads / writes.
|
|
99
|
+
func testWireFormatMatchesCrossPlatform() throws {
|
|
100
|
+
let result = CapabilityPrecheck.assess(hints: midRangeLaptop)
|
|
101
|
+
let assessment = HardwareAssessment(from: result)
|
|
102
|
+
let encoded = try JSONEncoder().encode(assessment)
|
|
103
|
+
let json = String(decoding: encoded, as: UTF8.self)
|
|
104
|
+
XCTAssertTrue(json.contains("\"offload-only\""), "expected kebab-case mode in JSON: \(json)")
|
|
105
|
+
XCTAssertTrue(json.contains("\"integrated\""), "expected kebab-case gpuClass in JSON: \(json)")
|
|
106
|
+
XCTAssertTrue(json.contains("\"mid\""), "expected lower-case cpuClass in JSON: \(json)")
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAICoreMLCore
|
|
3
|
+
|
|
4
|
+
@available(iOS 18.0, macOS 15.0, *)
|
|
5
|
+
final class CoreMLEngineTests: XCTestCase {
|
|
6
|
+
func testLoadFailsForMissingFile() {
|
|
7
|
+
let bogusURL = URL(fileURLWithPath: "/tmp/definitely-does-not-exist.mlmodelc")
|
|
8
|
+
XCTAssertThrowsError(try CoreMLEngine(modelURL: bogusURL, eosTokenId: 0)) { err in
|
|
9
|
+
guard case let CoreMLBackendError.modelLoadFailed(reason) = err else {
|
|
10
|
+
return XCTFail("wrong error: \(err)")
|
|
11
|
+
}
|
|
12
|
+
XCTAssertTrue(
|
|
13
|
+
reason.contains("error") || reason.contains("Error") || reason.contains("file"),
|
|
14
|
+
"reason should mention error details, got: \(reason)"
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAICoreMLCore
|
|
3
|
+
|
|
4
|
+
@available(iOS 18.0, macOS 15.0, *)
|
|
5
|
+
final class CoreMLGeneratorShapeTests: XCTestCase {
|
|
6
|
+
func testTypesCompile() {
|
|
7
|
+
// Ensures the public-internal API compiles correctly at the type level.
|
|
8
|
+
// Real generation is tested end-to-end in RealModelIntegrationTest (Task 18).
|
|
9
|
+
let _: AsyncThrowingStream<String, Error>.Type = AsyncThrowingStream<String, Error>.self
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import DVAISharedCore
|
|
3
|
+
@testable import DVAICoreMLCore
|
|
4
|
+
|
|
5
|
+
@available(iOS 18.0, macOS 15.0, *)
|
|
6
|
+
final class CoreMLHandlersTests: XCTestCase {
|
|
7
|
+
func testHandleEmbeddingsReturns501() async throws {
|
|
8
|
+
// The embeddings endpoint short-circuits without invoking the generator.
|
|
9
|
+
// We verify the response shape without needing a real MLModel.
|
|
10
|
+
let response: HandlerResponse = .error(501, "embeddings not yet supported by the CoreML backend")
|
|
11
|
+
if case let .error(status, msg) = response {
|
|
12
|
+
XCTAssertEqual(status, 501)
|
|
13
|
+
XCTAssertTrue(msg.contains("embeddings"), "expected 'embeddings' in '\(msg)'")
|
|
14
|
+
} else {
|
|
15
|
+
XCTFail("expected error response")
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func testHandleModelsReturnsConfiguredModel() async throws {
|
|
20
|
+
let response: HandlerResponse = .json(200, [
|
|
21
|
+
"object": "list",
|
|
22
|
+
"data": [["id": "test-model", "object": "model", "owned_by": "dvai-bridge"]]
|
|
23
|
+
])
|
|
24
|
+
if case let .json(status, body) = response {
|
|
25
|
+
XCTAssertEqual(status, 200)
|
|
26
|
+
let dict = body as? [String: Any]
|
|
27
|
+
XCTAssertEqual(dict?["object"] as? String, "list")
|
|
28
|
+
} else {
|
|
29
|
+
XCTFail("expected json response")
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAICoreMLCore
|
|
3
|
+
|
|
4
|
+
@available(iOS 18.0, macOS 15.0, *)
|
|
5
|
+
final class CoreMLPluginStateTests: XCTestCase {
|
|
6
|
+
func testStartFailsWithoutModelPath() async {
|
|
7
|
+
let state = CoreMLPluginState()
|
|
8
|
+
do {
|
|
9
|
+
_ = try await state.start(opts: [:])
|
|
10
|
+
XCTFail("Expected throw")
|
|
11
|
+
} catch let err as CoreMLBackendError {
|
|
12
|
+
guard case .modelLoadFailed = err else { return XCTFail("wrong error: \(err)") }
|
|
13
|
+
// Pass — empty opts correctly throws modelLoadFailed
|
|
14
|
+
} catch {
|
|
15
|
+
XCTFail("wrong error type: \(error)")
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func testStartFailsWithoutTokenizerPath() async {
|
|
20
|
+
let state = CoreMLPluginState()
|
|
21
|
+
do {
|
|
22
|
+
_ = try await state.start(opts: ["modelPath": "/tmp/x.mlmodelc"])
|
|
23
|
+
XCTFail("Expected throw")
|
|
24
|
+
} catch let err as CoreMLBackendError {
|
|
25
|
+
guard case .tokenizerLoadFailed = err else { return XCTFail("wrong error: \(err)") }
|
|
26
|
+
// Pass — missing tokenizerPath correctly throws tokenizerLoadFailed
|
|
27
|
+
} catch {
|
|
28
|
+
XCTFail("wrong error type: \(error)")
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func testStopWhenNotStartedIsIdempotent() async throws {
|
|
33
|
+
try await CoreMLPluginState().stop()
|
|
34
|
+
// Doesn't throw — idempotent stop is required by the API contract
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func testStatusInfoReportsNotRunning() async {
|
|
38
|
+
let info = await CoreMLPluginState().statusInfo()
|
|
39
|
+
XCTAssertEqual(info["running"] as? Bool, false)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import CoreML
|
|
3
|
+
@testable import DVAICoreMLCore
|
|
4
|
+
|
|
5
|
+
final class CoreMLSamplerTests: XCTestCase {
|
|
6
|
+
func makeLogits(_ vals: [Float]) -> MLMultiArray {
|
|
7
|
+
let arr = try! MLMultiArray(shape: [NSNumber(value: vals.count)], dataType: .float32)
|
|
8
|
+
for (i, v) in vals.enumerated() { arr[i] = NSNumber(value: v) }
|
|
9
|
+
return arr
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
func testGreedyReturnsArgmax() {
|
|
13
|
+
let s = CoreMLSampler(temperature: 0, topP: 1.0, topK: 0)
|
|
14
|
+
let logits = makeLogits([1.0, 5.0, 2.0, 4.0])
|
|
15
|
+
XCTAssertEqual(s.sample(logits: logits), 1) // argmax index
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func testTemperatureSamplingNeverThrows() {
|
|
19
|
+
let s = CoreMLSampler(temperature: 1.0, topP: 1.0, topK: 0)
|
|
20
|
+
let logits = makeLogits([1.0, 2.0, 3.0, 4.0])
|
|
21
|
+
for _ in 0 ..< 100 {
|
|
22
|
+
let token = s.sample(logits: logits)
|
|
23
|
+
XCTAssertGreaterThanOrEqual(token, 0)
|
|
24
|
+
XCTAssertLessThan(token, 4)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func testTopPTruncationFavorsHighProb() {
|
|
29
|
+
// With top_p = 0.5 and a sharply skewed distribution, only the top
|
|
30
|
+
// few tokens should ever be selected.
|
|
31
|
+
let s = CoreMLSampler(temperature: 1.0, topP: 0.5, topK: 0)
|
|
32
|
+
// Logits chosen so that softmax(logits) ≈ [0.0, 0.0, 0.05, 0.95]
|
|
33
|
+
let logits = makeLogits([-100.0, -100.0, 1.0, 4.0])
|
|
34
|
+
var counts = [0, 0, 0, 0]
|
|
35
|
+
for _ in 0 ..< 1000 { counts[s.sample(logits: logits)] += 1 }
|
|
36
|
+
XCTAssertEqual(counts[0], 0)
|
|
37
|
+
XCTAssertEqual(counts[1], 0)
|
|
38
|
+
XCTAssertGreaterThan(counts[3], counts[2]) // 4.0 picked far more often than 1.0
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAICoreMLCore
|
|
3
|
+
|
|
4
|
+
final class CoreMLTokenizerTests: XCTestCase {
|
|
5
|
+
func testInitFailsForMissingDir() async {
|
|
6
|
+
let bogus = URL(fileURLWithPath: "/tmp/no-such-tokenizer-dir-xyz")
|
|
7
|
+
do {
|
|
8
|
+
_ = try await CoreMLTokenizer(tokenizerDir: bogus)
|
|
9
|
+
XCTFail("Expected throw for missing tokenizer dir")
|
|
10
|
+
} catch let err as CoreMLBackendError {
|
|
11
|
+
guard case .tokenizerLoadFailed = err else {
|
|
12
|
+
return XCTFail("wrong error type: \(err)")
|
|
13
|
+
}
|
|
14
|
+
// Pass — missing dir correctly converts to tokenizerLoadFailed
|
|
15
|
+
} catch {
|
|
16
|
+
XCTFail("wrong error type: \(error)")
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAIBridge
|
|
3
|
+
|
|
4
|
+
final class DVAIBridgeAPIShapeTests: XCTestCase {
|
|
5
|
+
func testSingletonExists() {
|
|
6
|
+
let bridge: DVAIBridge = DVAIBridge.shared
|
|
7
|
+
XCTAssertNotNil(bridge)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
func testStatusBeforeStartReportsNotRunning() async {
|
|
11
|
+
let bridge = DVAIBridge() // fresh instance for test isolation
|
|
12
|
+
let info = await bridge.status()
|
|
13
|
+
XCTAssertFalse(info.running)
|
|
14
|
+
XCTAssertNil(info.backend)
|
|
15
|
+
XCTAssertNil(info.baseUrl)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func testStopWhenNotStartedIsIdempotent() async throws {
|
|
19
|
+
let bridge = DVAIBridge()
|
|
20
|
+
try await bridge.stop()
|
|
21
|
+
try await bridge.stop() // no throw
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func testStartCoreMLThrowsBackendUnavailable() async {
|
|
25
|
+
let bridge = DVAIBridge()
|
|
26
|
+
do {
|
|
27
|
+
_ = try await bridge.start(.init(backend: .coreml))
|
|
28
|
+
XCTFail("Expected throw")
|
|
29
|
+
} catch let err as DVAIBridgeError {
|
|
30
|
+
if case .backendUnavailable(.coreml, _) = err { /* expected */ } else {
|
|
31
|
+
XCTFail("wrong error: \(err)")
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
XCTFail("wrong error type: \(error)")
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAIBridge
|
|
3
|
+
|
|
4
|
+
final class DVAIBridgeConfigTests: XCTestCase {
|
|
5
|
+
func testDefaultsMatchSpec() {
|
|
6
|
+
let c = DVAIBridgeConfig()
|
|
7
|
+
XCTAssertEqual(c.backend, .auto)
|
|
8
|
+
XCTAssertNil(c.modelPath)
|
|
9
|
+
XCTAssertEqual(c.gpuLayers, 99)
|
|
10
|
+
XCTAssertEqual(c.contextSize, 2048)
|
|
11
|
+
XCTAssertEqual(c.threads, 4)
|
|
12
|
+
XCTAssertFalse(c.embeddingMode)
|
|
13
|
+
XCTAssertEqual(c.httpBasePort, 38883)
|
|
14
|
+
XCTAssertEqual(c.httpMaxPortAttempts, 16)
|
|
15
|
+
XCTAssertFalse(c.autoUnloadOnLowMemory)
|
|
16
|
+
XCTAssertEqual(c.logLevel, "info")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func testToCoreOptsWildcardCors() {
|
|
20
|
+
let c = DVAIBridgeConfig(modelPath: "/x.gguf")
|
|
21
|
+
let opts = c.toCoreOpts()
|
|
22
|
+
XCTAssertEqual(opts["modelPath"] as? String, "/x.gguf")
|
|
23
|
+
XCTAssertEqual(opts["corsOrigin"] as? String, "*")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func testToCoreOptsExactCors() {
|
|
27
|
+
let c = DVAIBridgeConfig(corsOrigin: .exact("https://example.com"))
|
|
28
|
+
XCTAssertEqual(c.toCoreOpts()["corsOrigin"] as? String, "https://example.com")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func testToCoreOptsAllowlistCors() {
|
|
32
|
+
let c = DVAIBridgeConfig(corsOrigin: .allowlist(["https://a.com", "https://b.com"]))
|
|
33
|
+
XCTAssertEqual(c.toCoreOpts()["corsOrigin"] as? [String], ["https://a.com", "https://b.com"])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func testBoundServerInitFromCoreResult() throws {
|
|
37
|
+
let result: [String: Any] = [
|
|
38
|
+
"baseUrl": "http://127.0.0.1:38883/v1",
|
|
39
|
+
"port": 38883,
|
|
40
|
+
"modelId": "test-model"
|
|
41
|
+
]
|
|
42
|
+
let server = try BoundServer(coreResult: result, backend: .llama)
|
|
43
|
+
XCTAssertEqual(server.baseUrl, "http://127.0.0.1:38883/v1")
|
|
44
|
+
XCTAssertEqual(server.port, 38883)
|
|
45
|
+
XCTAssertEqual(server.backend, .llama)
|
|
46
|
+
XCTAssertEqual(server.modelId, "test-model")
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
func testBoundServerInitMalformedResult() {
|
|
50
|
+
XCTAssertThrowsError(try BoundServer(coreResult: ["baseUrl": "x"], backend: .llama))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import DVAIBridge
|
|
3
|
+
|
|
4
|
+
final class DVAIBridgeErrorTests: XCTestCase {
|
|
5
|
+
func testErrorDescriptionsAreUserFacing() {
|
|
6
|
+
let cases: [(DVAIBridgeError, String)] = [
|
|
7
|
+
(.notStarted, "has not been started"),
|
|
8
|
+
(.alreadyStarted(currentBackend: .llama, baseUrl: "http://127.0.0.1:38883/v1"), "already running"),
|
|
9
|
+
(.configurationInvalid(reason: "x"), "invalid"),
|
|
10
|
+
(.backendUnavailable(.foundation, reason: "iOS 26+ required"), "unavailable"),
|
|
11
|
+
(.modelLoadFailed(reason: "x"), "Model load failed"),
|
|
12
|
+
(.downloadFailed(reason: "x"), "Download failed"),
|
|
13
|
+
(.checksumMismatch, "SHA-256"),
|
|
14
|
+
(.backendError(underlying: "x"), "Backend error"),
|
|
15
|
+
]
|
|
16
|
+
for (err, expectedFragment) in cases {
|
|
17
|
+
XCTAssertNotNil(err.errorDescription, "error has no description: \(err)")
|
|
18
|
+
XCTAssertTrue(
|
|
19
|
+
err.errorDescription!.contains(expectedFragment),
|
|
20
|
+
"expected '\(expectedFragment)' in '\(err.errorDescription!)'"
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func testBackendKindAllCases() {
|
|
26
|
+
XCTAssertEqual(BackendKind.allCases.count, 5)
|
|
27
|
+
XCTAssertTrue(BackendKind.allCases.contains(.auto))
|
|
28
|
+
XCTAssertTrue(BackendKind.allCases.contains(.llama))
|
|
29
|
+
XCTAssertTrue(BackendKind.allCases.contains(.foundation))
|
|
30
|
+
XCTAssertTrue(BackendKind.allCases.contains(.coreml))
|
|
31
|
+
XCTAssertTrue(BackendKind.allCases.contains(.mlx))
|
|
32
|
+
}
|
|
33
|
+
}
|