diff --git a/swift/Sources/CoreAIShared/Runtime/VectorMath.swift b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift index 18b40110..bffd8e1b 100644 --- a/swift/Sources/CoreAIShared/Runtime/VectorMath.swift +++ b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift @@ -17,3 +17,23 @@ public func sigmoid(_ x: Float) -> Float { let e = Foundation.exp(x) return e / (1 + e) } + +/// Cosine similarity of two equal-length vectors, accumulated in `Double` and returned as `Float`. +/// +/// Length-mismatched inputs score `0`. For zero-norm inputs the convention matches the parity +/// harnesses: two zero-norm (or empty) vectors are identical and score `1`, while a single +/// zero-norm vector has no direction to compare and scores `0`. +public func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { + guard a.count == b.count else { return 0 } + var dot = 0.0 + var normA = 0.0 + var normB = 0.0 + for i in a.indices { + dot += Double(a[i]) * Double(b[i]) + normA += Double(a[i]) * Double(a[i]) + normB += Double(b[i]) * Double(b[i]) + } + let denom = (normA * normB).squareRoot() + guard denom > 0 else { return normA == normB ? 1 : 0 } + return Float(dot / denom) +} diff --git a/swift/Sources/Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift b/swift/Sources/Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift index fd12bcaf..87cf7fe7 100644 --- a/swift/Sources/Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift +++ b/swift/Sources/Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift @@ -102,20 +102,6 @@ extension DiffusionRunner { // MARK: - Helpers - private func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { - guard a.count == b.count, !a.isEmpty else { return 0 } - var dot: Float = 0 - var normA: Float = 0 - var normB: Float = 0 - for i in 0.. 0 ? dot / denom : 0 - } - /// Widen a `[Float]` buffer into an `NDArray` of the requested scalar type. /// /// Not diffusion- or model-specific. Float targets defer to diff --git a/swift/Sources/Tools/image-segmenter/ImageSegmentationRunnerMain.swift b/swift/Sources/Tools/image-segmenter/ImageSegmentationRunnerMain.swift index 9c4ba51e..7fdb0a19 100644 --- a/swift/Sources/Tools/image-segmenter/ImageSegmentationRunnerMain.swift +++ b/swift/Sources/Tools/image-segmenter/ImageSegmentationRunnerMain.swift @@ -603,20 +603,6 @@ struct ImageSegmenterCLI: AsyncParsableCommand { return Float(10.0 * log10((peak * peak) / mse)) } - private func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { - guard a.count == b.count, !a.isEmpty else { return 0 } - var dot: Double = 0 - var normA: Double = 0 - var normB: Double = 0 - for i in 0.. 0 ? Float(dot / denom) : 0 - } - /// Return the leading slice of `ids` up to and including the first EOT /// (token id 49407) after position 0. Trailing pad tokens are excluded. /// If no EOT is found, returns the full sequence. diff --git a/swift/Sources/Tools/speech-recognizer/SpeechParity.swift b/swift/Sources/Tools/speech-recognizer/SpeechParity.swift index 86b4ee6b..255cc960 100644 --- a/swift/Sources/Tools/speech-recognizer/SpeechParity.swift +++ b/swift/Sources/Tools/speech-recognizer/SpeechParity.swift @@ -467,7 +467,7 @@ private func metricRow( ok: false) } let p = psnr(actual, ref) - let c = cosineSimilarity(actual, ref) + let c = Double(cosineSimilarity(actual, ref)) let maxAbs = zip(actual, ref).reduce(Float(0)) { max($0, abs($1.0 - $1.1)) } let ok = (p.isInfinite || p >= psnrFloor) && c >= cosineFloor let psnrStr = p.isInfinite ? "INF" : String(format: "%.2f", p) @@ -491,16 +491,3 @@ private func psnr(_ a: [Float], _ b: [Float]) -> Double { if peak == 0 { peak = 1 } return 10 * log10(peak * peak / mse) } - -private func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Double { - var dot = 0.0 - var na = 0.0 - var nb = 0.0 - for i in a.indices { - dot += Double(a[i]) * Double(b[i]) - na += Double(a[i]) * Double(a[i]) - nb += Double(b[i]) * Double(b[i]) - } - guard na > 0 && nb > 0 else { return na == nb ? 1 : 0 } - return dot / (na.squareRoot() * nb.squareRoot()) -} diff --git a/swift/Tests/CoreAISharedTests/VectorMathTests.swift b/swift/Tests/CoreAISharedTests/VectorMathTests.swift index eebcbfae..61dc917f 100644 --- a/swift/Tests/CoreAISharedTests/VectorMathTests.swift +++ b/swift/Tests/CoreAISharedTests/VectorMathTests.swift @@ -34,4 +34,24 @@ struct VectorMathTests { #expect(sigmoid(-100) < 1e-40) #expect(sigmoid(-100).isNaN == false) } + + // MARK: - cosineSimilarity + + @Test("cosineSimilarity: identical, orthogonal, and opposite vectors") + func cosineKnownValues() { + #expect(abs(cosineSimilarity([1, 2, 3], [1, 2, 3]) - 1.0) < 1e-6) + #expect(abs(cosineSimilarity([1, 0], [0, 1])) < 1e-6) + #expect(abs(cosineSimilarity([1, 2, 3], [-1, -2, -3]) + 1.0) < 1e-6) + } + + @Test("cosineSimilarity: zero-norm convention matches speech parity") + func cosineDegenerate() { + // Two zero-norm (or empty) vectors are identical and score 1; a single zero-norm vector + // has no direction to compare and scores 0; a length mismatch scores 0. This matches the + // speech parity convention so every harness that shares this helper agrees. + #expect(cosineSimilarity([], []) == 1) + #expect(cosineSimilarity([0, 0], [0, 0]) == 1) + #expect(cosineSimilarity([0, 0], [1, 2]) == 0) + #expect(cosineSimilarity([1, 2], [1, 2, 3]) == 0) + } }