From 4e15c5e67c9a91189308d0cefb64007656287ae5 Mon Sep 17 00:00:00 2001 From: sukru tikves Date: Thu, 1 Oct 2026 10:50:11 -0700 Subject: [PATCH 1/4] Consolidate scalar sigmoid onto a shared overflow-safe helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DetectionDecoder and SegmentationPostprocessor each defined their own scalar `sigmoid(_:)`. The video decoder's branches on the sign so the exponent argument stays non-positive (overflow-safe); the segmenter's naive `1/(1+exp(-x))` underflows large-negative logits to exactly 0. Add one shared `sigmoid` in CoreAIShared/Runtime/VectorMath.swift (the sign-branched form) and route both subsystems and their tests through it, deleting the two duplicates. VectorMathTests characterizes both extremes. Intended behavior change: the segmenter path now returns a tiny positive value instead of exactly 0 for large-negative logits — negligible for any threshold > 0. WS4 PR2. --- .../SegmentationPostprocessor.swift | 6 +-- .../CoreAIShared/Runtime/VectorMath.swift | 23 ++++++++++++ .../Tracking/DetectionDecoder.swift | 11 ------ .../Tracking/FrameProcessor.swift | 2 +- .../CoreAISharedTests/VectorMathTests.swift | 37 +++++++++++++++++++ .../ImageSegmentationTests.swift | 7 ++-- .../HotstartAndMemoryTests.swift | 4 +- .../TrackingPrimitiveTests.swift | 18 ++++----- 8 files changed, 77 insertions(+), 31 deletions(-) create mode 100644 swift/Sources/CoreAIShared/Runtime/VectorMath.swift create mode 100644 swift/Tests/CoreAISharedTests/VectorMathTests.swift diff --git a/swift/Sources/CoreAIImageSegmenter/Postprocessing/SegmentationPostprocessor.swift b/swift/Sources/CoreAIImageSegmenter/Postprocessing/SegmentationPostprocessor.swift index 7e2ab99a..d86dae73 100644 --- a/swift/Sources/CoreAIImageSegmenter/Postprocessing/SegmentationPostprocessor.swift +++ b/swift/Sources/CoreAIImageSegmenter/Postprocessing/SegmentationPostprocessor.swift @@ -3,6 +3,7 @@ // Use of this source code is governed by a BSD-3-clause license that can // be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause +import CoreAIShared import CoreGraphics import Foundation @@ -227,11 +228,6 @@ public enum SegmentationPostprocessor { probabilities: probabilities, width: outputWidth, height: outputHeight) } - // MARK: - Helpers - public static func sigmoid(_ x: Float) -> Float { - 1.0 / (1.0 + exp(-x)) - } - /// Bilinear-upsample a Float grid and threshold at the destination resolution. /// /// Pre-image sampling (PIL `BILINEAR` / align-corners=False semantics): the diff --git a/swift/Sources/CoreAIShared/Runtime/VectorMath.swift b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift new file mode 100644 index 00000000..8fbe1323 --- /dev/null +++ b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift @@ -0,0 +1,23 @@ +// Copyright 2026 Apple Inc. +// +// Use of this source code is governed by a BSD-3-clause license that can +// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause + +import Foundation + +/// Numerically stable logistic sigmoid. +/// +/// Branches on the sign so the exponent argument stays non-positive. The naive +/// `1 / (1 + exp(-x))` is fine for `x >= 0`, but on a large *negative* logit `exp(-x)` +/// overflows to `+inf` and the result underflows to exactly `0`; the equivalent +/// `exp(x) / (1 + exp(x))` form stays finite there and returns a tiny positive value. +/// (The opposite naive arrangement would produce `inf / inf = NaN` on large *positive* +/// logits — this one avoids that too.) Detector/segmenter logits are unbounded, so the +/// branch matters. +public func sigmoid(_ x: Float) -> Float { + if x >= 0 { + return 1 / (1 + Foundation.exp(-x)) + } + let e = Foundation.exp(x) + return e / (1 + e) +} diff --git a/swift/Sources/CoreAIVideoSegmenter/Tracking/DetectionDecoder.swift b/swift/Sources/CoreAIVideoSegmenter/Tracking/DetectionDecoder.swift index d7b1d011..a01812a0 100644 --- a/swift/Sources/CoreAIVideoSegmenter/Tracking/DetectionDecoder.swift +++ b/swift/Sources/CoreAIVideoSegmenter/Tracking/DetectionDecoder.swift @@ -109,15 +109,4 @@ enum DetectionDecoder { // prompt's own ordering. return kept.sorted() } - - @inline(__always) - static func sigmoid(_ x: Float) -> Float { - // Branch on the sign to keep the exponent argument negative. `exp` of a large - // positive logit overflows to infinity and yields NaN. - if x >= 0 { - return 1 / (1 + Foundation.exp(-x)) - } - let e = Foundation.exp(x) - return e / (1 + e) - } } diff --git a/swift/Sources/CoreAIVideoSegmenter/Tracking/FrameProcessor.swift b/swift/Sources/CoreAIVideoSegmenter/Tracking/FrameProcessor.swift index 72cf5a3f..085095dd 100644 --- a/swift/Sources/CoreAIVideoSegmenter/Tracking/FrameProcessor.swift +++ b/swift/Sources/CoreAIVideoSegmenter/Tracking/FrameProcessor.swift @@ -416,7 +416,7 @@ final class FrameProcessor { for (objectID, score) in newScores { trackerScores[objectID] = score } for (objectID, logit) in zip(trackedObjectIDs, trackerScoreLogits) where !removed.contains(objectID) { - trackerScores[objectID] = DetectionDecoder.sigmoid(logit) + trackerScores[objectID] = sigmoid(logit) } session.trackerScoreByFrame[frameIndex] = trackerScores diff --git a/swift/Tests/CoreAISharedTests/VectorMathTests.swift b/swift/Tests/CoreAISharedTests/VectorMathTests.swift new file mode 100644 index 00000000..eebcbfae --- /dev/null +++ b/swift/Tests/CoreAISharedTests/VectorMathTests.swift @@ -0,0 +1,37 @@ +// Copyright 2026 Apple Inc. +// +// Use of this source code is governed by a BSD-3-clause license that can +// be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause + +import Foundation +import Testing + +@testable import CoreAIShared + +// MARK: - sigmoid + +/// The shared logistic sigmoid is the single home for the two segmenter copies that previously +/// lived in `DetectionDecoder` and `SegmentationPostprocessor`. Detector/segmenter logits are +/// unbounded, so these cases pin the overflow-safe behavior at both extremes. +@Suite("VectorMath") +struct VectorMathTests { + @Test("sigmoid(0) == 0.5 and is symmetric") + func knownValues() { + #expect(sigmoid(0) == 0.5) + let x: Float = 2.5 + #expect(abs(sigmoid(x) + sigmoid(-x) - 1.0) < 1e-6) + #expect(abs(sigmoid(2) - 0.880797) < 1e-5) + } + + @Test("sigmoid stays finite at both extremes") + func overflowSafe() { + // Large positive: naive `exp(x)/(1+exp(x))` would be inf/inf = NaN; this returns 1. + #expect(sigmoid(100) == 1) + #expect(sigmoid(100).isNaN == false) + // Large negative: a tiny positive value, never NaN. The naive `1/(1+exp(-x))` underflows + // to exactly 0 here; the sign-branched form keeps the denormal. + #expect(sigmoid(-100) > 0) + #expect(sigmoid(-100) < 1e-40) + #expect(sigmoid(-100).isNaN == false) + } +} diff --git a/swift/Tests/ImageSegmenterTests/ImageSegmentationTests.swift b/swift/Tests/ImageSegmenterTests/ImageSegmentationTests.swift index dc473d1b..f4a12e26 100644 --- a/swift/Tests/ImageSegmenterTests/ImageSegmentationTests.swift +++ b/swift/Tests/ImageSegmenterTests/ImageSegmentationTests.swift @@ -3,6 +3,7 @@ // Use of this source code is governed by a BSD-3-clause license that can // be found in the LICENSE file or at https://opensource.org/licenses/BSD-3-Clause +import CoreAIShared import CoreGraphics import Foundation import Testing @@ -95,7 +96,7 @@ struct ImageSegmentationTests { @Test("Decode: absent presence logits treated as 1.0") func decodeEmptyPresenceLogits() { let logit: Float = 2.0 - let expectedScore = SegmentationPostprocessor.sigmoid(logit) + let expectedScore = sigmoid(logit) let output = SegmentationOutput( predictedMasks: [Float](repeating: 0, count: 1 * 1 * 2 * 2), @@ -376,9 +377,9 @@ struct ImageSegmentationTests { @Test("sigmoid(0) == 0.5 and sigmoid(x) + sigmoid(-x) == 1") func sigmoidKnownValues() { - #expect(SegmentationPostprocessor.sigmoid(0) == 0.5) + #expect(sigmoid(0) == 0.5) let x: Float = 2.5 - #expect(abs(SegmentationPostprocessor.sigmoid(x) + SegmentationPostprocessor.sigmoid(-x) - 1.0) < 1e-6) + #expect(abs(sigmoid(x) + sigmoid(-x) - 1.0) < 1e-6) } // MARK: - Helpers diff --git a/swift/Tests/VideoSegmenterTests/HotstartAndMemoryTests.swift b/swift/Tests/VideoSegmenterTests/HotstartAndMemoryTests.swift index aa8fba6a..9ce5e04f 100644 --- a/swift/Tests/VideoSegmenterTests/HotstartAndMemoryTests.swift +++ b/swift/Tests/VideoSegmenterTests/HotstartAndMemoryTests.swift @@ -588,9 +588,9 @@ struct BuildOutputsPairingTests { trackerLogits: maskLogits(for: tracked), trackerScoreLogits: scoreLogits(for: tracked), plan: plan, newScores: [:]) - #expect(output.trackerScoreByObjectID[1] == DetectionDecoder.sigmoid(1)) + #expect(output.trackerScoreByObjectID[1] == sigmoid(1)) #expect( - output.trackerScoreByObjectID[3] == DetectionDecoder.sigmoid(3), + output.trackerScoreByObjectID[3] == sigmoid(3), "object 3 must not inherit object 2's score") } diff --git a/swift/Tests/VideoSegmenterTests/TrackingPrimitiveTests.swift b/swift/Tests/VideoSegmenterTests/TrackingPrimitiveTests.swift index 9274bbf5..664ab167 100644 --- a/swift/Tests/VideoSegmenterTests/TrackingPrimitiveTests.swift +++ b/swift/Tests/VideoSegmenterTests/TrackingPrimitiveTests.swift @@ -152,13 +152,13 @@ struct DetectionDecoderTests { func sigmoidStability() { // `1 / (1 + exp(-x))` is fine for large x but `exp(x) / (1 + exp(x))` overflows to // inf/inf = NaN. The detector's logits are unbounded, so the branch matters. - #expect(DetectionDecoder.sigmoid(0) == 0.5) - #expect(DetectionDecoder.sigmoid(100) == 1) + #expect(sigmoid(0) == 0.5) + #expect(sigmoid(100) == 1) // Not exactly zero: the negative branch keeps the denormal rather than underflowing. - #expect(DetectionDecoder.sigmoid(-100) < 1e-40) - #expect(DetectionDecoder.sigmoid(-100) >= 0) - #expect(DetectionDecoder.sigmoid(-100).isNaN == false) - #expect(abs(DetectionDecoder.sigmoid(2) - 0.880797) < 1e-5) + #expect(sigmoid(-100) < 1e-40) + #expect(sigmoid(-100) >= 0) + #expect(sigmoid(-100).isNaN == false) + #expect(abs(sigmoid(2) - 0.880797) < 1e-5) } @Test("NMS keeps the highest-scoring member of an overlapping group") @@ -235,7 +235,7 @@ struct DetectionDecoderTests { outputs(queries: [2], presence: 1, masks: [[1, 1, -1, -1]]), promptID: 0, maskSize: 2, parameters: parameters(threshold: 0.1, nms: 0)) #expect(decoded.count == 1) - let expected = DetectionDecoder.sigmoid(2) * DetectionDecoder.sigmoid(1) + let expected = sigmoid(2) * sigmoid(1) #expect(abs(decoded.scores[0] - expected) < 1e-6) #expect(abs(expected - 0.643914) < 1e-5) } @@ -289,8 +289,8 @@ struct DetectionDecoderTests { #expect(decoded.maskLogits[1] == [-1, -1, -1, 1]) #expect(decoded.masks[1].area == 1) let expected = [ - DetectionDecoder.sigmoid(3) * DetectionDecoder.sigmoid(8), - DetectionDecoder.sigmoid(1) * DetectionDecoder.sigmoid(8), + sigmoid(3) * sigmoid(8), + sigmoid(1) * sigmoid(8), ] #expect(zip(decoded.scores, expected).allSatisfy { abs($0 - $1) < 1e-6 }) } From b0559e29418f6da978b7ac14a05586ed43cc48a2 Mon Sep 17 00:00:00 2001 From: sukru tikves Date: Thu, 1 Oct 2026 11:32:21 -0700 Subject: [PATCH 2/4] Consolidate the cosineSimilarity parity helpers The image-segmenter, diffusion-runner, and speech-recognizer parity harnesses each defined their own `cosineSimilarity`. Add a shared one to CoreAIShared/Runtime/VectorMath.swift (Double accumulation) and route all three through it, deleting the copies. One zero-norm convention: empty, length-mismatched, and zero-norm inputs return 0. This changes the speech-recognizer harness's both-norms-zero case from 1 to 0; no parity fixture passes a zero-vector reference. The two `Float`-returning call sites wrap the result in `Float(...)` to keep their printed format. Adds cosineSimilarity cases to VectorMathTests. --- .../CoreAIShared/Runtime/VectorMath.swift | 19 ++++++++++++++++++ .../DiffusionRunnerMain+Parity.swift | 20 +++---------------- .../ImageSegmentationRunnerMain.swift | 16 +-------------- .../speech-recognizer/SpeechParity.swift | 13 ------------ .../CoreAISharedTests/VectorMathTests.swift | 19 ++++++++++++++++++ 5 files changed, 42 insertions(+), 45 deletions(-) diff --git a/swift/Sources/CoreAIShared/Runtime/VectorMath.swift b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift index 8fbe1323..cce52ff6 100644 --- a/swift/Sources/CoreAIShared/Runtime/VectorMath.swift +++ b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift @@ -21,3 +21,22 @@ 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`. +/// +/// Returns `0` for empty, length-mismatched, or zero-norm inputs. The parity harnesses that +/// use this report the value as a diagnostic, so the single documented convention keeps their +/// numbers comparable. +public func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Double { + guard a.count == b.count, !a.isEmpty 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() + return denom > 0 ? dot / denom : 0 +} diff --git a/swift/Sources/Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift b/swift/Sources/Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift index fd12bcaf..3886aba3 100644 --- a/swift/Sources/Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift +++ b/swift/Sources/Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift @@ -61,10 +61,10 @@ extension DiffusionRunner { if let hiddenKey = teOutputs.keys.first(where: { $0.contains("hidden") }), let actual = teOutputs[hiddenKey] { - let cosine = cosineSimilarity(actual, expectedTE.data) + let cosine = Float(cosineSimilarity(actual, expectedTE.data)) print(" Cosine similarity: \(cosine)") } else if let actual = teOutputs.values.first { - let cosine = cosineSimilarity(actual, expectedTE.data) + let cosine = Float(cosineSimilarity(actual, expectedTE.data)) print(" Cosine similarity (first output): \(cosine)") } @@ -79,7 +79,7 @@ extension DiffusionRunner { let vaeND = floatsToNDArray(vaeInput.data, asInt32: false, shape: vaeInput.shape, scalarType: vaeType) let vaeOutputs = try await vaeDecoder.predictAutoNamed(inputs: [vaeND]) if let actual = vaeOutputs.values.first { - let cosine = cosineSimilarity(actual, expectedVAE.data) + let cosine = Float(cosineSimilarity(actual, expectedVAE.data)) print(" Cosine similarity: \(cosine)") } @@ -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 9cb076a3..1f3c7454 100644 --- a/swift/Sources/Tools/image-segmenter/ImageSegmentationRunnerMain.swift +++ b/swift/Sources/Tools/image-segmenter/ImageSegmentationRunnerMain.swift @@ -598,20 +598,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. @@ -641,7 +627,7 @@ struct ImageSegmenterCLI: AsyncParsableCommand { ) } let p = psnr(actual, ref) - let c = cosineSimilarity(actual, ref) + let c = Float(cosineSimilarity(actual, ref)) let ok = (p.isInfinite || p >= psnrFloor) && c >= cosineFloor let psnrStr = p.isInfinite ? "INF" : String(format: "%.2f", p) return ParityRow( diff --git a/swift/Sources/Tools/speech-recognizer/SpeechParity.swift b/swift/Sources/Tools/speech-recognizer/SpeechParity.swift index 86b4ee6b..abde969a 100644 --- a/swift/Sources/Tools/speech-recognizer/SpeechParity.swift +++ b/swift/Sources/Tools/speech-recognizer/SpeechParity.swift @@ -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..751d729b 100644 --- a/swift/Tests/CoreAISharedTests/VectorMathTests.swift +++ b/swift/Tests/CoreAISharedTests/VectorMathTests.swift @@ -34,4 +34,23 @@ 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-9) + #expect(abs(cosineSimilarity([1, 0], [0, 1])) < 1e-9) + #expect(abs(cosineSimilarity([1, 2, 3], [-1, -2, -3]) + 1.0) < 1e-9) + } + + @Test("cosineSimilarity: degenerate inputs return 0") + func cosineDegenerate() { + // The single documented convention: empty, length-mismatched, and zero-norm inputs all + // score 0 (the old SpeechParity copy returned 1 when both norms were zero). + #expect(cosineSimilarity([], []) == 0) + #expect(cosineSimilarity([1, 2], [1, 2, 3]) == 0) + #expect(cosineSimilarity([0, 0], [0, 0]) == 0) + #expect(cosineSimilarity([0, 0], [1, 2]) == 0) + } } From 8f48cd489825d8a6087df83982b9dda42c1cf121 Mon Sep 17 00:00:00 2001 From: sukru tikves Date: Fri, 2 Oct 2026 11:50:41 -0700 Subject: [PATCH 3/4] Address review: return Float from cosineSimilarity Accumulate in Double, return Float. The image-segmenter and diffusion parity callers drop their `Float(...)` wrap; the speech parity harness casts to Double for its Double-based reporting. --- swift/Sources/CoreAIShared/Runtime/VectorMath.swift | 6 +++--- .../Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift | 6 +++--- .../Tools/image-segmenter/ImageSegmentationRunnerMain.swift | 2 +- swift/Sources/Tools/speech-recognizer/SpeechParity.swift | 2 +- swift/Tests/CoreAISharedTests/VectorMathTests.swift | 6 +++--- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/swift/Sources/CoreAIShared/Runtime/VectorMath.swift b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift index cce52ff6..c60c890a 100644 --- a/swift/Sources/CoreAIShared/Runtime/VectorMath.swift +++ b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift @@ -22,12 +22,12 @@ public func sigmoid(_ x: Float) -> Float { return e / (1 + e) } -/// Cosine similarity of two equal-length vectors, accumulated in `Double`. +/// Cosine similarity of two equal-length vectors, accumulated in `Double` and returned as `Float`. /// /// Returns `0` for empty, length-mismatched, or zero-norm inputs. The parity harnesses that /// use this report the value as a diagnostic, so the single documented convention keeps their /// numbers comparable. -public func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Double { +public func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { guard a.count == b.count, !a.isEmpty else { return 0 } var dot = 0.0 var normA = 0.0 @@ -38,5 +38,5 @@ public func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Double { normB += Double(b[i]) * Double(b[i]) } let denom = (normA * normB).squareRoot() - return denom > 0 ? dot / denom : 0 + return denom > 0 ? Float(dot / denom) : 0 } diff --git a/swift/Sources/Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift b/swift/Sources/Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift index 3886aba3..87cf7fe7 100644 --- a/swift/Sources/Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift +++ b/swift/Sources/Tools/diffusion-runner/DiffusionRunnerMain+Parity.swift @@ -61,10 +61,10 @@ extension DiffusionRunner { if let hiddenKey = teOutputs.keys.first(where: { $0.contains("hidden") }), let actual = teOutputs[hiddenKey] { - let cosine = Float(cosineSimilarity(actual, expectedTE.data)) + let cosine = cosineSimilarity(actual, expectedTE.data) print(" Cosine similarity: \(cosine)") } else if let actual = teOutputs.values.first { - let cosine = Float(cosineSimilarity(actual, expectedTE.data)) + let cosine = cosineSimilarity(actual, expectedTE.data) print(" Cosine similarity (first output): \(cosine)") } @@ -79,7 +79,7 @@ extension DiffusionRunner { let vaeND = floatsToNDArray(vaeInput.data, asInt32: false, shape: vaeInput.shape, scalarType: vaeType) let vaeOutputs = try await vaeDecoder.predictAutoNamed(inputs: [vaeND]) if let actual = vaeOutputs.values.first { - let cosine = Float(cosineSimilarity(actual, expectedVAE.data)) + let cosine = cosineSimilarity(actual, expectedVAE.data) print(" Cosine similarity: \(cosine)") } diff --git a/swift/Sources/Tools/image-segmenter/ImageSegmentationRunnerMain.swift b/swift/Sources/Tools/image-segmenter/ImageSegmentationRunnerMain.swift index 1f3c7454..5e2fe852 100644 --- a/swift/Sources/Tools/image-segmenter/ImageSegmentationRunnerMain.swift +++ b/swift/Sources/Tools/image-segmenter/ImageSegmentationRunnerMain.swift @@ -627,7 +627,7 @@ struct ImageSegmenterCLI: AsyncParsableCommand { ) } let p = psnr(actual, ref) - let c = Float(cosineSimilarity(actual, ref)) + let c = cosineSimilarity(actual, ref) let ok = (p.isInfinite || p >= psnrFloor) && c >= cosineFloor let psnrStr = p.isInfinite ? "INF" : String(format: "%.2f", p) return ParityRow( diff --git a/swift/Sources/Tools/speech-recognizer/SpeechParity.swift b/swift/Sources/Tools/speech-recognizer/SpeechParity.swift index abde969a..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) diff --git a/swift/Tests/CoreAISharedTests/VectorMathTests.swift b/swift/Tests/CoreAISharedTests/VectorMathTests.swift index 751d729b..4a1bdcb3 100644 --- a/swift/Tests/CoreAISharedTests/VectorMathTests.swift +++ b/swift/Tests/CoreAISharedTests/VectorMathTests.swift @@ -39,9 +39,9 @@ struct VectorMathTests { @Test("cosineSimilarity: identical, orthogonal, and opposite vectors") func cosineKnownValues() { - #expect(abs(cosineSimilarity([1, 2, 3], [1, 2, 3]) - 1.0) < 1e-9) - #expect(abs(cosineSimilarity([1, 0], [0, 1])) < 1e-9) - #expect(abs(cosineSimilarity([1, 2, 3], [-1, -2, -3]) + 1.0) < 1e-9) + #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: degenerate inputs return 0") From ceed53a80a99dd29784164686b09280beac4a7ac Mon Sep 17 00:00:00 2001 From: Sukru Tikves Date: Fri, 9 Oct 2026 10:44:19 -0700 Subject: [PATCH 4/4] Unify cosineSimilarity zero-norm convention with speech parity Two zero-norm (or empty) vectors are identical and now score 1; a single zero-norm vector still scores 0; a length mismatch scores 0. This restores the behavior the speech parity harness had before the helper was consolidated (both-zero -> 1), so every harness sharing cosineSimilarity agrees on the all-zero case. Addresses review feedback. --- swift/Sources/CoreAIShared/Runtime/VectorMath.swift | 11 ++++++----- swift/Tests/CoreAISharedTests/VectorMathTests.swift | 13 +++++++------ 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/swift/Sources/CoreAIShared/Runtime/VectorMath.swift b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift index 90709524..bffd8e1b 100644 --- a/swift/Sources/CoreAIShared/Runtime/VectorMath.swift +++ b/swift/Sources/CoreAIShared/Runtime/VectorMath.swift @@ -20,11 +20,11 @@ public func sigmoid(_ x: Float) -> Float { /// Cosine similarity of two equal-length vectors, accumulated in `Double` and returned as `Float`. /// -/// Returns `0` for empty, length-mismatched, or zero-norm inputs. The parity harnesses that -/// use this report the value as a diagnostic, so the single documented convention keeps their -/// numbers comparable. +/// 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, !a.isEmpty else { return 0 } + guard a.count == b.count else { return 0 } var dot = 0.0 var normA = 0.0 var normB = 0.0 @@ -34,5 +34,6 @@ public func cosineSimilarity(_ a: [Float], _ b: [Float]) -> Float { normB += Double(b[i]) * Double(b[i]) } let denom = (normA * normB).squareRoot() - return denom > 0 ? Float(dot / denom) : 0 + guard denom > 0 else { return normA == normB ? 1 : 0 } + return Float(dot / denom) } diff --git a/swift/Tests/CoreAISharedTests/VectorMathTests.swift b/swift/Tests/CoreAISharedTests/VectorMathTests.swift index 4a1bdcb3..61dc917f 100644 --- a/swift/Tests/CoreAISharedTests/VectorMathTests.swift +++ b/swift/Tests/CoreAISharedTests/VectorMathTests.swift @@ -44,13 +44,14 @@ struct VectorMathTests { #expect(abs(cosineSimilarity([1, 2, 3], [-1, -2, -3]) + 1.0) < 1e-6) } - @Test("cosineSimilarity: degenerate inputs return 0") + @Test("cosineSimilarity: zero-norm convention matches speech parity") func cosineDegenerate() { - // The single documented convention: empty, length-mismatched, and zero-norm inputs all - // score 0 (the old SpeechParity copy returned 1 when both norms were zero). - #expect(cosineSimilarity([], []) == 0) - #expect(cosineSimilarity([1, 2], [1, 2, 3]) == 0) - #expect(cosineSimilarity([0, 0], [0, 0]) == 0) + // 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) } }