|
@@ -1,5 +1,6 @@
|
|
|
#include <gtest/gtest.h>
|
|
#include <gtest/gtest.h>
|
|
|
#include "embeddings.hpp"
|
|
#include "embeddings.hpp"
|
|
|
|
|
+#include "embedding_cache.hpp"
|
|
|
#include "errors.hpp"
|
|
#include "errors.hpp"
|
|
|
#include <httplib.h>
|
|
#include <httplib.h>
|
|
|
#include <thread>
|
|
#include <thread>
|
|
@@ -51,3 +52,168 @@ TEST(Embeddings, ClientHonorsBasePathPrefix) {
|
|
|
EXPECT_EQ(v.size(), 2u); EXPECT_FLOAT_EQ(v[0], 9.0f);
|
|
EXPECT_EQ(v.size(), 2u); EXPECT_FLOAT_EQ(v[0], 9.0f);
|
|
|
mock.stop(); t.join();
|
|
mock.stop(); t.join();
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+// ---- EmbeddingCache tests -----------------------------------------------
|
|
|
|
|
+
|
|
|
|
|
+TEST(EmbeddingCache, ReturnsCachedVectorOnExactKey) {
|
|
|
|
|
+ EmbeddingCache cache(16, 1024*1024, 0);
|
|
|
|
|
+ EmbeddingCache::Key k{"https://api.openai.com", "text-embedding-3-small", "hello world"};
|
|
|
|
|
+ std::vector<float> vec = {0.1f, 0.2f, 0.3f};
|
|
|
|
|
+ cache.put(k, vec);
|
|
|
|
|
+ auto result = cache.get(k);
|
|
|
|
|
+ ASSERT_TRUE(result.has_value());
|
|
|
|
|
+ ASSERT_EQ(result->size(), 3u);
|
|
|
|
|
+ EXPECT_FLOAT_EQ((*result)[0], 0.1f);
|
|
|
|
|
+ EXPECT_FLOAT_EQ((*result)[1], 0.2f);
|
|
|
|
|
+ EXPECT_FLOAT_EQ((*result)[2], 0.3f);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(EmbeddingCache, MissOnAbsentKey) {
|
|
|
|
|
+ EmbeddingCache cache(16, 1024*1024, 0);
|
|
|
|
|
+ EmbeddingCache::Key k{"https://api.openai.com", "text-embedding-3-small", "not stored"};
|
|
|
|
|
+ auto result = cache.get(k);
|
|
|
|
|
+ EXPECT_FALSE(result.has_value());
|
|
|
|
|
+ auto s = cache.stats();
|
|
|
|
|
+ EXPECT_EQ(s.misses, 1u);
|
|
|
|
|
+ EXPECT_EQ(s.hits, 0u);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(EmbeddingCache, EvictsLeastRecentlyUsedWhenFull) {
|
|
|
|
|
+ // capacity=3: insert A,B,C; then get(A) to refresh it; insert D → B should be evicted
|
|
|
|
|
+ EmbeddingCache cache(3, 1024*1024, 0);
|
|
|
|
|
+ EmbeddingCache::Key kA{"base", "m", "A"};
|
|
|
|
|
+ EmbeddingCache::Key kB{"base", "m", "B"};
|
|
|
|
|
+ EmbeddingCache::Key kC{"base", "m", "C"};
|
|
|
|
|
+ EmbeddingCache::Key kD{"base", "m", "D"};
|
|
|
|
|
+ cache.put(kA, {1.0f});
|
|
|
|
|
+ cache.put(kB, {2.0f});
|
|
|
|
|
+ cache.put(kC, {3.0f});
|
|
|
|
|
+ // Touch A so it's recently used; B becomes the LRU
|
|
|
|
|
+ cache.get(kA);
|
|
|
|
|
+ // Insert D — evicts B (least recently used)
|
|
|
|
|
+ cache.put(kD, {4.0f});
|
|
|
|
|
+ EXPECT_TRUE(cache.get(kA).has_value());
|
|
|
|
|
+ EXPECT_FALSE(cache.get(kB).has_value()); // evicted
|
|
|
|
|
+ EXPECT_TRUE(cache.get(kC).has_value());
|
|
|
|
|
+ EXPECT_TRUE(cache.get(kD).has_value());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(EmbeddingCache, DistinguishesModels) {
|
|
|
|
|
+ EmbeddingCache cache(16, 1024*1024, 0);
|
|
|
|
|
+ EmbeddingCache::Key k1{"https://api.openai.com", "model-A", "hello"};
|
|
|
|
|
+ EmbeddingCache::Key k2{"https://api.openai.com", "model-B", "hello"};
|
|
|
|
|
+ cache.put(k1, {1.0f});
|
|
|
|
|
+ cache.put(k2, {2.0f});
|
|
|
|
|
+ auto r1 = cache.get(k1);
|
|
|
|
|
+ auto r2 = cache.get(k2);
|
|
|
|
|
+ ASSERT_TRUE(r1.has_value());
|
|
|
|
|
+ ASSERT_TRUE(r2.has_value());
|
|
|
|
|
+ EXPECT_FLOAT_EQ((*r1)[0], 1.0f);
|
|
|
|
|
+ EXPECT_FLOAT_EQ((*r2)[0], 2.0f);
|
|
|
|
|
+ EXPECT_EQ(cache.stats().size, 2u);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(EmbeddingCache, DistinguishesApiBase) {
|
|
|
|
|
+ EmbeddingCache cache(16, 1024*1024, 0);
|
|
|
|
|
+ EmbeddingCache::Key k1{"https://api.openai.com", "text-embedding-3-small", "hello"};
|
|
|
|
|
+ EmbeddingCache::Key k2{"https://openrouter.ai/api", "text-embedding-3-small", "hello"};
|
|
|
|
|
+ cache.put(k1, {1.0f});
|
|
|
|
|
+ cache.put(k2, {2.0f});
|
|
|
|
|
+ auto r1 = cache.get(k1);
|
|
|
|
|
+ auto r2 = cache.get(k2);
|
|
|
|
|
+ ASSERT_TRUE(r1.has_value());
|
|
|
|
|
+ ASSERT_TRUE(r2.has_value());
|
|
|
|
|
+ EXPECT_FLOAT_EQ((*r1)[0], 1.0f);
|
|
|
|
|
+ EXPECT_FLOAT_EQ((*r2)[0], 2.0f);
|
|
|
|
|
+ EXPECT_EQ(cache.stats().size, 2u);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(EmbeddingCache, ExpiresAfterTtl) {
|
|
|
|
|
+ EmbeddingCache cache(16, 1024*1024, /*ttlSec=*/60);
|
|
|
|
|
+ int64_t fakeNow = 1000;
|
|
|
|
|
+ cache.setClockForTesting([&fakeNow]{ return fakeNow; });
|
|
|
|
|
+ EmbeddingCache::Key k{"base", "m", "text"};
|
|
|
|
|
+ cache.put(k, {5.0f});
|
|
|
|
|
+ // Before expiry — hit
|
|
|
|
|
+ EXPECT_TRUE(cache.get(k).has_value());
|
|
|
|
|
+ // Advance clock past TTL
|
|
|
|
|
+ fakeNow = 1061;
|
|
|
|
|
+ auto result = cache.get(k);
|
|
|
|
|
+ EXPECT_FALSE(result.has_value()); // expired
|
|
|
|
|
+ auto s = cache.stats();
|
|
|
|
|
+ EXPECT_GE(s.misses, 1u); // at least the expired-get counted as miss
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(EmbeddingCache, EvictsToStayUnderByteBudget) {
|
|
|
|
|
+ // Each vector of 4 floats = 16 bytes. Budget = 40 bytes → max 2 full entries.
|
|
|
|
|
+ EmbeddingCache cache(/*capacity=*/100, /*maxBytes=*/40, /*ttlSec=*/0);
|
|
|
|
|
+ EmbeddingCache::Key kA{"b", "m", "A"};
|
|
|
|
|
+ EmbeddingCache::Key kB{"b", "m", "B"};
|
|
|
|
|
+ EmbeddingCache::Key kC{"b", "m", "C"};
|
|
|
|
|
+ cache.put(kA, {1.0f, 2.0f, 3.0f, 4.0f}); // 16 bytes
|
|
|
|
|
+ cache.put(kB, {5.0f, 6.0f, 7.0f, 8.0f}); // 16 bytes; total=32
|
|
|
|
|
+ // inserting C (16 bytes) would push total to 48 > 40 → evict LRU (A)
|
|
|
|
|
+ cache.put(kC, {9.0f, 10.0f, 11.0f, 12.0f});
|
|
|
|
|
+ EXPECT_LE(cache.stats().bytes, 40u);
|
|
|
|
|
+ EXPECT_FALSE(cache.get(kA).has_value()); // evicted
|
|
|
|
|
+ EXPECT_TRUE(cache.get(kB).has_value());
|
|
|
|
|
+ EXPECT_TRUE(cache.get(kC).has_value());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(EmbeddingCache, NormalizationCollapsesWhitespaceAndCaseWhenEnabled) {
|
|
|
|
|
+ // With normalize=true: "Max DC" and "max dc" should map to ONE entry
|
|
|
|
|
+ EmbeddingCache cacheOn(16, 1024*1024, 0, /*normalize=*/true);
|
|
|
|
|
+ EmbeddingCache::Key k1{"base", "m", "Max DC"};
|
|
|
|
|
+ EmbeddingCache::Key k2{"base", "m", "max dc"};
|
|
|
|
|
+ cacheOn.put(k1, {1.0f});
|
|
|
|
|
+ auto r = cacheOn.get(k2);
|
|
|
|
|
+ ASSERT_TRUE(r.has_value());
|
|
|
|
|
+ EXPECT_FLOAT_EQ((*r)[0], 1.0f);
|
|
|
|
|
+ EXPECT_EQ(cacheOn.stats().size, 1u);
|
|
|
|
|
+
|
|
|
|
|
+ // With normalize=false (default): they stay distinct
|
|
|
|
|
+ EmbeddingCache cacheOff(16, 1024*1024, 0, /*normalize=*/false);
|
|
|
|
|
+ EmbeddingCache::Key k3{"base", "m", "Max DC"};
|
|
|
|
|
+ EmbeddingCache::Key k4{"base", "m", "max dc"};
|
|
|
|
|
+ cacheOff.put(k3, {1.0f});
|
|
|
|
|
+ auto r2 = cacheOff.get(k4);
|
|
|
|
|
+ EXPECT_FALSE(r2.has_value());
|
|
|
|
|
+ EXPECT_EQ(cacheOff.stats().size, 1u);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(EmbeddingCache, StatsCountsHitsAndMisses) {
|
|
|
|
|
+ EmbeddingCache cache(16, 1024*1024, 0);
|
|
|
|
|
+ EmbeddingCache::Key k{"base", "m", "text"};
|
|
|
|
|
+ // 2 misses
|
|
|
|
|
+ cache.get(k);
|
|
|
|
|
+ cache.get(k);
|
|
|
|
|
+ // put then 3 hits
|
|
|
|
|
+ cache.put(k, {1.0f});
|
|
|
|
|
+ cache.get(k);
|
|
|
|
|
+ cache.get(k);
|
|
|
|
|
+ cache.get(k);
|
|
|
|
|
+ auto s = cache.stats();
|
|
|
|
|
+ EXPECT_EQ(s.misses, 2u);
|
|
|
|
|
+ EXPECT_EQ(s.hits, 3u);
|
|
|
|
|
+ EXPECT_EQ(s.size, 1u);
|
|
|
|
|
+ EXPECT_EQ(s.capacity, 16u);
|
|
|
|
|
+ EXPECT_EQ(s.bytes, sizeof(float) * 1u);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST(EmbeddingCache, ReconfigureEvictsToTighterBounds) {
|
|
|
|
|
+ EmbeddingCache cache(16, 1024*1024, 0);
|
|
|
|
|
+ EmbeddingCache::Key kA{"b", "m", "A"};
|
|
|
|
|
+ EmbeddingCache::Key kB{"b", "m", "B"};
|
|
|
|
|
+ EmbeddingCache::Key kC{"b", "m", "C"};
|
|
|
|
|
+ cache.put(kA, {1.0f}); // A is least-recently used
|
|
|
|
|
+ cache.put(kB, {2.0f});
|
|
|
|
|
+ cache.put(kC, {3.0f});
|
|
|
|
|
+ ASSERT_EQ(cache.stats().size, 3u);
|
|
|
|
|
+ // Shrink capacity to 1 — reconfigure must evict immediately, keeping the MRU entry.
|
|
|
|
|
+ cache.reconfigure(1, 1024*1024, 0, false);
|
|
|
|
|
+ auto s = cache.stats();
|
|
|
|
|
+ EXPECT_EQ(s.size, 1u);
|
|
|
|
|
+ EXPECT_EQ(s.capacity, 1u);
|
|
|
|
|
+ EXPECT_FALSE(cache.get(kA).has_value()); // evicted
|
|
|
|
|
+ EXPECT_TRUE(cache.get(kC).has_value()); // most-recently used survives
|
|
|
|
|
+}
|