@@ -162,3 +162,200 @@ def test_convert_float32_to_float16(self):
162162 # Verify values round-trip through float16
163163 arr = np .frombuffer (converted ["doc:0" ]["embedding" ], dtype = np .float16 )
164164 np .testing .assert_allclose (arr , [1.0 , 2.0 , 3.0 , 4.0 ], rtol = 1e-3 )
165+
166+ def test_convert_float64_to_float32 (self ):
167+ """Float64 to float32 should preserve values with minor precision loss."""
168+ import numpy as np
169+
170+ from redisvl .migration .quantize import convert_vectors
171+
172+ dims = 4
173+ source = np .array ([1.0 , - 2.5 , 3.14159265358979 , 0.0 ], dtype = np .float64 )
174+ originals = {"doc:0" : {"embedding" : source .tobytes ()}}
175+ changes = {
176+ "embedding" : {"source" : "float64" , "target" : "float32" , "dims" : dims }
177+ }
178+
179+ converted = convert_vectors (originals , changes )
180+
181+ # float64 = 8 bytes/dim, float32 = 4 bytes/dim
182+ assert len (converted ["doc:0" ]["embedding" ]) == dims * 4
183+ arr = np .frombuffer (converted ["doc:0" ]["embedding" ], dtype = np .float32 )
184+ np .testing .assert_allclose (arr , source , rtol = 1e-6 )
185+
186+ def test_convert_float64_to_float16 (self ):
187+ """Float64 to float16 should preserve values with larger precision loss."""
188+ import numpy as np
189+
190+ from redisvl .migration .quantize import convert_vectors
191+
192+ dims = 4
193+ source = np .array ([1.0 , - 2.5 , 0.333 , 0.0 ], dtype = np .float64 )
194+ originals = {"doc:0" : {"embedding" : source .tobytes ()}}
195+ changes = {
196+ "embedding" : {"source" : "float64" , "target" : "float16" , "dims" : dims }
197+ }
198+
199+ converted = convert_vectors (originals , changes )
200+
201+ # float64 = 8 bytes/dim, float16 = 2 bytes/dim
202+ assert len (converted ["doc:0" ]["embedding" ]) == dims * 2
203+ arr = np .frombuffer (converted ["doc:0" ]["embedding" ], dtype = np .float16 )
204+ np .testing .assert_allclose (arr , source , rtol = 1e-2 )
205+
206+ def test_convert_float64_to_int8_scales_correctly (self ):
207+ """Float64 to int8 should apply scaling, not truncate."""
208+ import numpy as np
209+
210+ from redisvl .migration .quantize import convert_vectors
211+
212+ source = np .array ([- 0.8 , - 0.2 , 0.3 , 0.9 ], dtype = np .float64 )
213+ originals = {"doc:0" : {"embedding" : source .tobytes ()}}
214+ changes = {"embedding" : {"source" : "float64" , "target" : "int8" , "dims" : 4 }}
215+
216+ converted = convert_vectors (originals , changes )
217+ result = np .frombuffer (converted ["doc:0" ]["embedding" ], dtype = np .int8 )
218+
219+ assert len (converted ["doc:0" ]["embedding" ]) == 4
220+ assert result .min () == - 128
221+ assert result .max () == 127
222+ assert not np .all (result == 0 )
223+
224+ def test_convert_float32_to_int8_scales_correctly (self ):
225+ """Float-to-int8 should scale values to [-128, 127], not truncate."""
226+ import numpy as np
227+
228+ from redisvl .migration .quantize import convert_vectors
229+
230+ # Typical embedding values in [-1, 1] — would all become 0 without scaling.
231+ dims = 4
232+ source = np .array ([- 1.0 , - 0.5 , 0.0 , 1.0 ], dtype = np .float32 )
233+ originals = {"doc:0" : {"embedding" : source .tobytes ()}}
234+ changes = {"embedding" : {"source" : "float32" , "target" : "int8" , "dims" : dims }}
235+
236+ converted = convert_vectors (originals , changes )
237+ result = np .frombuffer (converted ["doc:0" ]["embedding" ], dtype = np .int8 )
238+
239+ # 1 byte per dim
240+ assert len (converted ["doc:0" ]["embedding" ]) == dims * 1
241+ # Min should map to -128, max to 127
242+ assert result [0 ] == - 128 # min value
243+ assert result [3 ] == 127 # max value
244+ # Values should span the full int8 range, NOT be all zeros
245+ assert result .min () == - 128
246+ assert result .max () == 127
247+ # Middle values should be proportionally scaled
248+ # -0.5 → (-0.5 - (-1)) / 2 * 255 + (-128) = 63.75 - 128 = -64.25 → -64
249+ assert result [1 ] == - 64
250+ # 0.0 → (0 - (-1)) / 2 * 255 + (-128) = 127.5 - 128 = -0.5 → 0
251+ assert result [2 ] == 0
252+
253+ def test_convert_float16_to_int8_scales_correctly (self ):
254+ """Float16-to-int8 should also scale properly (the benchmark bug path)."""
255+ import numpy as np
256+
257+ from redisvl .migration .quantize import convert_vectors
258+
259+ # Simulate what the benchmark did: random [0, 1] float16 vectors
260+ source = np .array ([0.1 , 0.3 , 0.7 , 0.9 ], dtype = np .float16 )
261+ originals = {"doc:0" : {"embedding" : source .tobytes ()}}
262+ changes = {"embedding" : {"source" : "float16" , "target" : "int8" , "dims" : 4 }}
263+
264+ converted = convert_vectors (originals , changes )
265+ result = np .frombuffer (converted ["doc:0" ]["embedding" ], dtype = np .int8 )
266+
267+ # Should NOT be all zeros (the original bug)
268+ assert not np .all (
269+ result == 0
270+ ), "INT8 conversion produced all zeros — scaling is not being applied"
271+ # Should use the full range
272+ assert result .min () == - 128
273+ assert result .max () == 127
274+
275+ def test_convert_float32_to_uint8_scales_correctly (self ):
276+ """Float-to-uint8 should scale values to [0, 255]."""
277+ import numpy as np
278+
279+ from redisvl .migration .quantize import convert_vectors
280+
281+ source = np .array ([- 1.0 , 0.0 , 0.5 , 1.0 ], dtype = np .float32 )
282+ originals = {"doc:0" : {"embedding" : source .tobytes ()}}
283+ changes = {"embedding" : {"source" : "float32" , "target" : "uint8" , "dims" : 4 }}
284+
285+ converted = convert_vectors (originals , changes )
286+ result = np .frombuffer (converted ["doc:0" ]["embedding" ], dtype = np .uint8 )
287+
288+ assert len (converted ["doc:0" ]["embedding" ]) == 4 * 1
289+ assert result [0 ] == 0 # min maps to 0
290+ assert result [3 ] == 255 # max maps to 255
291+ assert result .min () == 0
292+ assert result .max () == 255
293+
294+ def test_convert_constant_vector_to_int8 (self ):
295+ """A constant vector (all same value) should not divide by zero."""
296+ import numpy as np
297+
298+ from redisvl .migration .quantize import convert_vectors
299+
300+ source = np .array ([0.5 , 0.5 , 0.5 , 0.5 ], dtype = np .float32 )
301+ originals = {"doc:0" : {"embedding" : source .tobytes ()}}
302+ changes = {"embedding" : {"source" : "float32" , "target" : "int8" , "dims" : 4 }}
303+
304+ converted = convert_vectors (originals , changes )
305+ result = np .frombuffer (converted ["doc:0" ]["embedding" ], dtype = np .int8 )
306+
307+ # Should not raise and should produce a valid int8 vector
308+ assert len (result ) == 4
309+ # All values should be identical (mapped to midpoint)
310+ assert np .all (result == result [0 ])
311+
312+ def test_convert_preserves_relative_ordering (self ):
313+ """Scaled int8 values should maintain the same ordering as the source."""
314+ import numpy as np
315+
316+ from redisvl .migration .quantize import convert_vectors
317+
318+ source = np .array ([0.1 , 0.9 , 0.5 , 0.3 , 0.7 ], dtype = np .float32 )
319+ originals = {"doc:0" : {"embedding" : source .tobytes ()}}
320+ changes = {"embedding" : {"source" : "float32" , "target" : "int8" , "dims" : 5 }}
321+
322+ converted = convert_vectors (originals , changes )
323+ result = np .frombuffer (converted ["doc:0" ]["embedding" ], dtype = np .int8 )
324+
325+ # The sorted order of indices should be preserved
326+ assert list (np .argsort (source )) == list (np .argsort (result .astype (float )))
327+
328+ def test_convert_skips_unknown_fields (self ):
329+ """Fields not in datatype_changes should be skipped."""
330+ from redisvl .migration .quantize import convert_vectors
331+
332+ originals = {"doc:0" : {"other_field" : b"\x00 \x01 " }}
333+ changes = {"embedding" : {"source" : "float32" , "target" : "float16" , "dims" : 4 }}
334+
335+ converted = convert_vectors (originals , changes )
336+ assert converted ["doc:0" ] == {}
337+
338+ def test_convert_multiple_keys (self ):
339+ """Conversion should work across multiple keys in a batch."""
340+ import numpy as np
341+
342+ from redisvl .migration .quantize import convert_vectors
343+
344+ v1 = np .array ([0.0 , 0.5 , 1.0 ], dtype = np .float32 )
345+ v2 = np .array ([- 1.0 , 0.0 , 1.0 ], dtype = np .float32 )
346+ originals = {
347+ "doc:0" : {"embedding" : v1 .tobytes ()},
348+ "doc:1" : {"embedding" : v2 .tobytes ()},
349+ }
350+ changes = {"embedding" : {"source" : "float32" , "target" : "int8" , "dims" : 3 }}
351+
352+ converted = convert_vectors (originals , changes )
353+
354+ r1 = np .frombuffer (converted ["doc:0" ]["embedding" ], dtype = np .int8 )
355+ r2 = np .frombuffer (converted ["doc:1" ]["embedding" ], dtype = np .int8 )
356+
357+ # Each vector is scaled independently (per-vector min-max)
358+ assert r1 .min () == - 128
359+ assert r1 .max () == 127
360+ assert r2 .min () == - 128
361+ assert r2 .max () == 127
0 commit comments