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
6 changes: 3 additions & 3 deletions Sources/BCICore/Composition/HypnagogicDialecticLoop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ public actor HypnagogicDialecticLoop {
}
}()
let selfSimilarity: Float? = spokenEmb.flatMap { emb in
replyCentroid.map { DialecticalDynamics.normalized(emb.cosineSimilarity(to: $0)) }
replyCentroid.map { DialecticalDynamics.normalizedSimilarity(emb, $0) }
}
var witnessFinding: String?
var witnessDistance: Float?
Expand All @@ -294,7 +294,7 @@ public actor HypnagogicDialecticLoop {
if !finding.isEmpty {
witnessFinding = finding
if let spokenEmb, let findingEmb = try? await embedder.encode([finding]).first {
witnessDistance = 1 - DialecticalDynamics.normalized(findingEmb.cosineSimilarity(to: spokenEmb))
witnessDistance = 1 - DialecticalDynamics.normalizedSimilarity(findingEmb, spokenEmb)
}
}
} catch is CancellationError {
Expand Down Expand Up @@ -374,7 +374,7 @@ public actor HypnagogicDialecticLoop {
var worst: Float = -1
for i in 0..<embeddings.count {
for j in (i + 1)..<embeddings.count {
let d = 1 - DialecticalDynamics.normalized(embeddings[i].cosineSimilarity(to: embeddings[j]))
let d = 1 - DialecticalDynamics.normalizedSimilarity(embeddings[i], embeddings[j])
if d > worst { worst = d; best = (i, j) }
}
}
Expand Down
31 changes: 22 additions & 9 deletions Sources/BCICore/Dialectic/DialecticalDynamics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ public enum DialecticalDynamics {
@inlinable
public static func normalized(_ cosine: Float) -> Float { (cosine + 1) / 2 }

/// `normalized` over a similarity that must exist. Traps on incomparable
/// operands instead of scoring them: one embedder per session makes that a
/// defect, not a state the loop should absorb as `0.5`.
// ponytail: traps; propagate Optional the day a second embedder is held at once.
public static func normalizedSimilarity(_ a: Embedding, _ b: Embedding) -> Float {
guard let s = a.cosineSimilarity(to: b) else {
preconditionFailure("incomparable embedding spaces: \(a.modelID)/\(a.dimension) vs \(b.modelID)/\(b.dimension)")
}
return normalized(s)
}

/// Scores one candidate against the turn context. Missing centroids (early
/// turns, before any history) score a neutral `0.5` rather than biasing the
/// competition toward either pole.
Expand All @@ -122,10 +133,10 @@ public enum DialecticalDynamics {
historyCentroid: Embedding?,
replyCentroid: Embedding?
) -> DialecticalEnergy {
let coherence = normalized(candidate.cosineSimilarity(to: heard))
let resonance = historyCentroid.map { normalized(candidate.cosineSimilarity(to: $0)) } ?? 0.5
let coherence = normalizedSimilarity(candidate, heard)
let resonance = historyCentroid.map { normalizedSimilarity(candidate, $0) } ?? 0.5
// novelty = distance from what we've said; 1 − similarity, on the [0,1] scale.
let novelty = replyCentroid.map { 1 - normalized(candidate.cosineSimilarity(to: $0)) } ?? 0.5
let novelty = replyCentroid.map { 1 - normalizedSimilarity(candidate, $0) } ?? 0.5
return DialecticalEnergy(coherence: coherence, resonance: resonance, novelty: novelty)
}

Expand All @@ -137,7 +148,7 @@ public enum DialecticalDynamics {
var pairs = 0
for i in 0..<embeddings.count {
for j in (i + 1)..<embeddings.count {
acc += 1 - normalized(embeddings[i].cosineSimilarity(to: embeddings[j]))
acc += 1 - normalizedSimilarity(embeddings[i], embeddings[j])
pairs += 1
}
}
Expand Down Expand Up @@ -201,8 +212,8 @@ public enum DialecticalDynamics {
public static func synthesisScore(candidate c: Embedding,
thesis: Embedding,
antithesis: Embedding) -> Float {
let toThesis = normalized(c.cosineSimilarity(to: thesis))
let toAnti = normalized(c.cosineSimilarity(to: antithesis))
let toThesis = normalizedSimilarity(c, thesis)
let toAnti = normalizedSimilarity(c, antithesis)
return min(toThesis, toAnti)
}

Expand Down Expand Up @@ -259,13 +270,15 @@ public enum DialecticalDynamics {

/// L2-normalized mean of a set of embeddings — the "direction the dialogue
/// has been travelling." Provenance (`modelID`/`version`/`dimension`) is
/// taken from the first element; embeddings of a different dimension are
/// skipped rather than trapping. Returns `nil` for an empty set.
/// taken from the first element. Returns `nil` for an empty set, and `nil`
/// if any member is not comparable with the first: a centroid over an
/// unannounced subset is a plausible vector that means nothing.
public static func centroid(of embeddings: [Embedding]) -> Embedding? {
guard let first = embeddings.first else { return nil }
guard embeddings.allSatisfy({ $0.isComparable(with: first) }) else { return nil }
let dim = first.values.count
var sum = [Float](repeating: 0, count: dim)
for e in embeddings where e.values.count == dim {
for e in embeddings {
for i in 0..<dim { sum[i] += e.values[i] }
}
let norm = sqrtf(sum.reduce(0) { $0 + $1 * $1 })
Expand Down
3 changes: 1 addition & 2 deletions Sources/BCICore/Dialectic/DialecticalMemory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,7 @@ public struct DialecticalMemory: Sendable {
guard replyEmbeddings.count >= 2 else { return 0 }
var acc: Float = 0
for i in 1..<replyEmbeddings.count {
acc += 1 - DialecticalDynamics.normalized(
replyEmbeddings[i].cosineSimilarity(to: replyEmbeddings[i - 1]))
acc += 1 - DialecticalDynamics.normalizedSimilarity(replyEmbeddings[i], replyEmbeddings[i - 1])
}
return acc / Float(replyEmbeddings.count - 1)
}
Expand Down
4 changes: 2 additions & 2 deletions Sources/BCICore/Dialectic/SemanticGraph.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public struct SemanticGraph: Sendable {
turnIndex: turnIndex, kind: kind)
nextID += 1
for existing in nodes {
let w = DialecticalDynamics.normalized(embedding.cosineSimilarity(to: existing.embedding))
let w = DialecticalDynamics.normalizedSimilarity(embedding, existing.embedding)
if w >= edgeThreshold {
edges.append(Edge(a: existing.id, b: node.id, weight: w))
}
Expand All @@ -76,7 +76,7 @@ public struct SemanticGraph: Sendable {
public func nearestPriorNodes(to query: Embedding, limit: Int,
minSimilarity: Float = 0) -> [Node] {
nodes
.map { ($0, DialecticalDynamics.normalized(query.cosineSimilarity(to: $0.embedding))) }
.map { ($0, DialecticalDynamics.normalizedSimilarity(query, $0.embedding)) }
.filter { $0.1 >= minSimilarity }
.sorted { $0.1 > $1.1 }
.prefix(limit)
Expand Down
20 changes: 16 additions & 4 deletions Sources/BCICore/Models/Embedding.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,24 @@ public struct Embedding: Sendable, Equatable {
self.seed = seed
}

/// Whether a similarity between `self` and `other` exists to compute: same
/// `modelID`, same dimension, non-empty. Mirrors the Rust port
/// (`neuralcompose-hypnagogic/src/embedding.rs`). `seed` and `version` are
/// deliberately not checked: `DialecticalDynamics.centroid(of:)` builds
/// centroids with `seed: 0`, and a centroid must stay comparable to the
/// vectors it was averaged from.
public func isComparable(with other: Embedding) -> Bool {
modelID == other.modelID && values.count == other.values.count && !values.isEmpty
}

/// Cosine similarity to `other`. Because both operands are L2-normalized
/// by the `values` invariant, this is just the dot product — no magnitude
/// division. Returns 0 when the dimensions differ (incomparable spaces)
/// rather than trapping.
public func cosineSimilarity(to other: Embedding) -> Float {
guard values.count == other.values.count else { return 0 }
/// division. Returns `nil` when the two are not comparable (different
/// space or dimension). `nil` is not a low score and not a failed
/// computation: no similarity exists to compute. `0` means orthogonal and
/// comparable, which is a different statement.
public func cosineSimilarity(to other: Embedding) -> Float? {
guard isComparable(with: other) else { return nil }
var acc: Float = 0
for i in 0..<values.count { acc += values[i] * other.values[i] }
return acc
Expand Down
2 changes: 1 addition & 1 deletion Sources/GenerationEval/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func meaningPreservationCosine(source: String, output: String) async -> Double?
guard let embeddings = try? await embedder.encode([source, output]), embeddings.count == 2 else {
return nil
}
return Double(embeddings[0].cosineSimilarity(to: embeddings[1]))
return Double(embeddings[0].cosineSimilarity(to: embeddings[1])!)
}

// MARK: - Evaluate each candidate
Expand Down
2 changes: 1 addition & 1 deletion Sources/SemanticEval/NearestNeighbors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ enum NearestNeighbors {
k: Int
) -> [Neighbor] {
let scored = zip(corpusTexts, corpusEmbeddings).map { text, embedding in
Neighbor(text: text, score: query.cosineSimilarity(to: embedding))
Neighbor(text: text, score: query.cosineSimilarity(to: embedding)!)
}
return Array(scored.sorted { $0.score > $1.score }.prefix(k))
}
Expand Down
10 changes: 5 additions & 5 deletions Sources/SemanticEval/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func fail(_ message: String) -> Never {
func pairScores(_ pairs: [[String]], embeddingByText: [String: Embedding]) -> [SemanticEvalResult.PairScore] {
pairs.map { pair in
let a = pair[0], b = pair[1]
let score = embeddingByText[a]!.cosineSimilarity(to: embeddingByText[b]!)
let score = embeddingByText[a]!.cosineSimilarity(to: embeddingByText[b]!)!
return .init(a: a, b: b, score: score)
}
}
Expand All @@ -42,7 +42,7 @@ func meanPairwiseSimilarity(_ texts: [String], embeddingByText: [String: Embeddi
var count = 0
for i in 0..<texts.count {
for j in (i + 1)..<texts.count {
total += embeddingByText[texts[i]]!.cosineSimilarity(to: embeddingByText[texts[j]]!)
total += embeddingByText[texts[i]]!.cosineSimilarity(to: embeddingByText[texts[j]]!)!
count += 1
}
}
Expand Down Expand Up @@ -112,7 +112,7 @@ var cosineMatrixValues = Array(
)
for i in 0..<config.corpus.count {
for j in 0..<config.corpus.count {
cosineMatrixValues[i][j] = corpusEmbeddings[i].cosineSimilarity(to: corpusEmbeddings[j])
cosineMatrixValues[i][j] = corpusEmbeddings[i].cosineSimilarity(to: corpusEmbeddings[j])!
}
}

Expand Down Expand Up @@ -154,7 +154,7 @@ for gi in 0..<groupNames.count {
let phrasesB = config.commandGroups[groupNames[gj]]!
for a in phrasesA {
for b in phrasesB {
crossTotal += embeddingByText[a]!.cosineSimilarity(to: embeddingByText[b]!)
crossTotal += embeddingByText[a]!.cosineSimilarity(to: embeddingByText[b]!)!
crossCount += 1
}
}
Expand All @@ -169,7 +169,7 @@ for trajectory in config.trajectories {
let phraseEmbeddings = trajectory.phrases.map { embeddingByText[$0]! }
var stepSimilarities: [Float] = []
for i in 0..<(phraseEmbeddings.count - 1) {
stepSimilarities.append(phraseEmbeddings[i].cosineSimilarity(to: phraseEmbeddings[i + 1]))
stepSimilarities.append(phraseEmbeddings[i].cosineSimilarity(to: phraseEmbeddings[i + 1])!)
}
trajectoryResults[trajectory.name] = .init(
phrases: trajectory.phrases,
Expand Down
13 changes: 13 additions & 0 deletions Tests/BCICoreTests/DialecticalDynamicsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ final class DialecticalDynamicsTests: XCTestCase {
)
}

// MARK: - Comparability

/// Swift twin of the Rust port's `Some(0.0) != None` pin: orthogonal-and-
/// comparable is `0`; incomparable is `nil`, never a score.
func testIncomparableIsNilNotZero() throws {
let a = emb([1, 0], id: "a"), orthogonal = emb([0, 1], id: "a")
XCTAssertEqual(try XCTUnwrap(a.cosineSimilarity(to: orthogonal)), 0)
XCTAssertNil(a.cosineSimilarity(to: emb([1, 0], id: "b")), "different modelID")
XCTAssertNil(a.cosineSimilarity(to: emb([1, 0, 0], id: "a")), "different dimension")
XCTAssertNil(DialecticalDynamics.centroid(of: [a, emb([1, 0], id: "b")]), "mixed spaces")
XCTAssertNotNil(DialecticalDynamics.centroid(of: [a, orthogonal]))
}

// MARK: - Energy

func testCoherenceIsHighWhenCandidateMatchesHeard() {
Expand Down
6 changes: 3 additions & 3 deletions Tests/BCICoreTests/SemanticBGEReplayRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ final class SemanticBGEReplayRegressionTests: XCTestCase {
let deepSleep = try await embedder.encode("deep sleep")
let banana = try await embedder.encode("banana")

let shared = sleep.cosineSimilarity(to: deepSleep)
let unrelated = sleep.cosineSimilarity(to: banana)
let shared = try XCTUnwrap(sleep.cosineSimilarity(to: deepSleep))
let unrelated = try XCTUnwrap(sleep.cosineSimilarity(to: banana))
XCTAssertGreaterThan(
shared, unrelated,
"compositional cluster broken: cos(sleep, deep sleep)=\(shared) should exceed cos(sleep, banana)=\(unrelated)"
Expand All @@ -201,7 +201,7 @@ final class SemanticBGEReplayRegressionTests: XCTestCase {
var matrix = Array(repeating: Array(repeating: Float(0), count: n), count: n)
for i in 0..<n {
for k in 0..<n {
matrix[i][k] = result[i].cosineSimilarity(to: result[k])
matrix[i][k] = try XCTUnwrap(result[i].cosineSimilarity(to: result[k]))
}
}

Expand Down
6 changes: 3 additions & 3 deletions Tests/BCICoreTests/SemanticReplayRegressionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,8 @@ final class SemanticReplayRegressionTests: XCTestCase {
let deepSleep = try await embedder.encode("deep sleep")
let banana = try await embedder.encode("banana")

let shared = sleep.cosineSimilarity(to: deepSleep)
let unrelated = sleep.cosineSimilarity(to: banana)
let shared = try XCTUnwrap(sleep.cosineSimilarity(to: deepSleep))
let unrelated = try XCTUnwrap(sleep.cosineSimilarity(to: banana))
XCTAssertGreaterThan(shared, unrelated,
"compositional cluster broken: cos(sleep, deep sleep)=\(shared) should exceed cos(sleep, banana)=\(unrelated)")
}
Expand Down Expand Up @@ -245,7 +245,7 @@ final class SemanticReplayRegressionTests: XCTestCase {
var matrix = Array(repeating: Array(repeating: Float(0), count: n), count: n)
for i in 0..<n {
for k in 0..<n {
matrix[i][k] = result[i].cosineSimilarity(to: result[k])
matrix[i][k] = try XCTUnwrap(result[i].cosineSimilarity(to: result[k]))
}
}

Expand Down
6 changes: 3 additions & 3 deletions Tests/BCICoreTests/SentenceEmbedderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ final class SentenceEmbedderTests: XCTestCase {
func testDistinctInputsDiffer() async throws {
let a = try await embed("jaw clench")
let b = try await embed("rest")
XCTAssertLessThan(a.cosineSimilarity(to: b), 0.999, "distinct inputs should not be identical")
XCTAssertLessThan(try XCTUnwrap(a.cosineSimilarity(to: b)), 0.999, "distinct inputs should not be identical")
}

func testSharedTokensAreCloserThanUnrelated() async throws {
Expand All @@ -67,8 +67,8 @@ final class SentenceEmbedderTests: XCTestCase {
let deepSleep = try await embed("deep sleep")
let banana = try await embed("banana")
XCTAssertGreaterThan(
sleep.cosineSimilarity(to: deepSleep),
sleep.cosineSimilarity(to: banana),
try XCTUnwrap(sleep.cosineSimilarity(to: deepSleep)),
try XCTUnwrap(sleep.cosineSimilarity(to: banana)),
"'sleep' should be closer to 'deep sleep' than to 'banana'"
)
}
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/embedding_contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ An `Embedding` value **must** satisfy:
| 2.4 | `modelID` and `version` are non-empty and match the producer's `modelID`/`version` | `Embedding.swift:26-38`, test `testProvenanceIsPopulated` at `SentenceEmbedderTests.swift:48` |
| 2.5 | `cosineSimilarity(to:)` returns a value in `[-1, 1]` (within float error) and is a plain dot product (the L2-normalization invariant is what makes it one) | `Embedding.swift:54-63` |
| 2.6 | `cosineSimilarity(to:)` returns `0` when dimensions differ, rather than trapping — different spaces are incomparable, not broken | `Embedding.swift:58-59` |
| 2.7 | Two embeddings with different `modelID`s are not comparable, even if their `dimension` matches | `Embedding.swift:27-28` doc comment |
| 2.7 | Two embeddings with different `modelID`s are not comparable, even if their `dimension` matches | `Embedding.isComparable(with:)`; `cosineSimilarity(to:)` returns `nil`; `DialecticalDynamicsTests.testIncomparableIsNilNotZero` |

The struct carries *cheap* provenance (`modelID`/`version`/`seed`/
`dimension`) by design. Heavier provenance — model SHA256, tokenizer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Embedding comparability is documented but unenforced (2026-08-12)

**Status:** open defect, not yet fixed. Found while porting `DialecticalDynamics`
**Status:** fixed on `fix/embedding-comparability` (2026-08-22), unverified on Linux (no Swift toolchain on the host); `swift test` on macOS pending. Originally: open defect, not yet fixed. Found while porting `DialecticalDynamics`
to Rust (`neuralcompose-client-native`, `crates/neuralcompose-hypnagogic`).

## The defect
Expand Down
Loading