@@ -14,7 +14,6 @@ use vortex_array::arrays::ScalarFnArray;
1414use vortex_array:: arrays:: scalar_fn:: ScalarFnArrayView ;
1515use vortex_array:: arrays:: scalar_fn:: plugin:: ScalarFnArrayParts ;
1616use vortex_array:: arrays:: scalar_fn:: plugin:: ScalarFnArrayVTable ;
17- use vortex_array:: builtins:: ArrayBuiltins ;
1817use vortex_array:: dtype:: DType ;
1918use vortex_array:: dtype:: Nullability ;
2019use vortex_array:: expr:: Expression ;
@@ -28,7 +27,6 @@ use vortex_array::scalar_fn::ScalarFnId;
2827use vortex_array:: scalar_fn:: ScalarFnVTable ;
2928use vortex_array:: scalar_fn:: TypedScalarFnInstance ;
3029use vortex_array:: serde:: ArrayChildren ;
31- use vortex_array:: validity:: Validity ;
3230use vortex_buffer:: Buffer ;
3331use vortex_error:: VortexResult ;
3432use vortex_session:: VortexSession ;
@@ -144,7 +142,7 @@ impl ScalarFnVTable for CosineSimilarity {
144142 // Take any L2Denorm-wrapped fast path that applies.
145143 match DenormOrientation :: classify ( & lhs_ref, & rhs_ref) {
146144 DenormOrientation :: Both { lhs, rhs } => {
147- return self . execute_both_denorm ( lhs, rhs, len) ;
145+ return self . execute_both_denorm ( lhs, rhs, len, ctx ) ;
148146 }
149147 DenormOrientation :: One { denorm, plain } => {
150148 return self . execute_one_denorm ( denorm, plain, len, ctx) ;
@@ -244,22 +242,39 @@ impl CosineSimilarity {
244242 lhs_ref : & ArrayRef ,
245243 rhs_ref : & ArrayRef ,
246244 len : usize ,
245+ ctx : & mut ExecutionCtx ,
247246 ) -> VortexResult < ArrayRef > {
248247 let validity = lhs_ref. validity ( ) ?. and ( rhs_ref. validity ( ) ?) ?;
249248
250- let ( normalized_l, _ ) = extract_l2_denorm_children ( lhs_ref) ;
251- let ( normalized_r, _ ) = extract_l2_denorm_children ( rhs_ref) ;
249+ let ( normalized_l, norms_l ) = extract_l2_denorm_children ( lhs_ref) ;
250+ let ( normalized_r, norms_r ) = extract_l2_denorm_children ( rhs_ref) ;
252251
253252 // `L2Denorm` makes the normalized children authoritative, so their dot product is the
254- // cosine similarity even for lossy storage wrappers.
255- let dot = InnerProduct :: try_new_array ( normalized_l, normalized_r, len) ?. into_array ( ) ;
256-
257- if !matches ! ( validity, Validity :: NonNullable ) {
258- // Masking always changes the nullability to nullable.
259- dot. mask ( validity. to_array ( len) )
260- } else {
261- Ok ( dot)
262- }
253+ // cosine similarity even for lossy storage wrappers, except that a zero stored norm still
254+ // represents a zero vector.
255+ let dot: PrimitiveArray = InnerProduct :: try_new_array ( normalized_l, normalized_r, len) ?
256+ . into_array ( )
257+ . execute ( ctx) ?;
258+ let norms_l: PrimitiveArray = norms_l. execute ( ctx) ?;
259+ let norms_r: PrimitiveArray = norms_r. execute ( ctx) ?;
260+
261+ match_each_float_ptype ! ( dot. ptype( ) , |T | {
262+ let dots = dot. as_slice:: <T >( ) ;
263+ let norms_l = norms_l. as_slice:: <T >( ) ;
264+ let norms_r = norms_r. as_slice:: <T >( ) ;
265+ let buffer: Buffer <T > = ( 0 ..len)
266+ . map( |i| {
267+ if norms_l[ i] == T :: zero( ) || norms_r[ i] == T :: zero( ) {
268+ T :: zero( )
269+ } else {
270+ dots[ i]
271+ }
272+ } )
273+ . collect( ) ;
274+
275+ // SAFETY: The buffer length equals `len`, which matches the source validity length.
276+ Ok ( unsafe { PrimitiveArray :: new_unchecked( buffer, validity) } . into_array( ) )
277+ } )
263278 }
264279
265280 /// One side is `L2Denorm`: treat the normalized child as authoritative, so
@@ -275,25 +290,28 @@ impl CosineSimilarity {
275290 ) -> VortexResult < ArrayRef > {
276291 let validity = denorm_ref. validity ( ) ?. and ( plain_ref. validity ( ) ?) ?;
277292
278- let ( normalized, _ ) = extract_l2_denorm_children ( denorm_ref) ;
293+ let ( normalized, denorm_norms ) = extract_l2_denorm_children ( denorm_ref) ;
279294
280295 let dot_arr = InnerProduct :: try_new_array ( normalized, plain_ref. clone ( ) , len) ?;
281296 let dot: PrimitiveArray = dot_arr. into_array ( ) . execute ( ctx) ?;
282297
298+ let denorm_norms: PrimitiveArray = denorm_norms. execute ( ctx) ?;
299+
283300 let norm_arr = L2Norm :: try_new_array ( plain_ref. clone ( ) , len) ?;
284301 let plain_norm: PrimitiveArray = norm_arr. into_array ( ) . execute ( ctx) ?;
285302
286303 // TODO(connor): Ideally we would have a `SafeDiv` binary numeric operation.
287304 // TODO(connor): This can be written in a more SIMD-friendly manner.
288305 match_each_float_ptype ! ( dot. ptype( ) , |T | {
289306 let dots = dot. as_slice:: <T >( ) ;
290- let norms = plain_norm. as_slice:: <T >( ) ;
307+ let denorm_norms = denorm_norms. as_slice:: <T >( ) ;
308+ let plain_norms = plain_norm. as_slice:: <T >( ) ;
291309 let buffer: Buffer <T > = ( 0 ..len)
292310 . map( |i| {
293- if norms [ i] == T :: zero( ) {
311+ if denorm_norms [ i ] == T :: zero ( ) || plain_norms [ i] == T :: zero( ) {
294312 T :: zero( )
295313 } else {
296- dots[ i] / norms [ i]
314+ dots[ i] / plain_norms [ i]
297315 }
298316 } )
299317 . collect( ) ;
@@ -596,6 +614,55 @@ mod tests {
596614 Ok ( ( ) )
597615 }
598616
617+ #[ test]
618+ fn both_denorm_lossy_zero_stored_norm_returns_zero ( ) -> VortexResult < ( ) > {
619+ // Mimics a lossy encoding (e.g. TurboQuant) where the stored norm is authoritative but
620+ // the decoded normalized child is physically nonzero. With a stored norm of `0.0`, cosine
621+ // similarity for that row must be `0.0` even though the dot product of the normalized
622+ // children is nonzero.
623+ let normalized_l = tensor_array ( & [ 2 ] , & [ 0.6 , 0.8 ] ) ?;
624+ let norms_l = PrimitiveArray :: from_iter ( [ 0.0f64 ] ) . into_array ( ) ;
625+ // SAFETY: This is a focused test that intentionally violates the unit-norm invariant by
626+ // pairing a nonzero normalized row with a stored norm of `0.0`, mimicking lossy storage.
627+ let lhs = unsafe { L2Denorm :: new_array_unchecked ( normalized_l, norms_l, 1 ) ? } . into_array ( ) ;
628+
629+ let normalized_r = tensor_array ( & [ 2 ] , & [ 0.6 , 0.8 ] ) ?;
630+ let norms_r = PrimitiveArray :: from_iter ( [ 0.0f64 ] ) . into_array ( ) ;
631+ // SAFETY: Same as above for the rhs operand.
632+ let rhs = unsafe { L2Denorm :: new_array_unchecked ( normalized_r, norms_r, 1 ) ? } . into_array ( ) ;
633+
634+ // `dot(normalized_l, normalized_r) = 1.0`, but the authoritative stored norms are both
635+ // `0.0`, so cosine similarity must be `0.0`.
636+ assert_close ( & eval_cosine_similarity ( lhs, rhs, 1 ) ?, & [ 0.0 ] ) ;
637+ Ok ( ( ) )
638+ }
639+
640+ #[ test]
641+ fn one_side_denorm_lossy_zero_stored_norm_returns_zero ( ) -> VortexResult < ( ) > {
642+ // Mimics a lossy encoding (e.g. TurboQuant) where the stored norm is authoritative but
643+ // the decoded normalized child is physically nonzero. The plain side is a normal nonzero
644+ // tensor with positive norm. cosine similarity must still be `0.0` because the
645+ // authoritative stored norm on the denorm side is `0.0`.
646+ let normalized = tensor_array ( & [ 2 ] , & [ 0.6 , 0.8 ] ) ?;
647+ let norms = PrimitiveArray :: from_iter ( [ 0.0f64 ] ) . into_array ( ) ;
648+ // SAFETY: This is a focused test that intentionally pairs a nonzero normalized row with a
649+ // stored norm of `0.0`, mimicking lossy storage where the stored norm is authoritative.
650+ let denorm = unsafe { L2Denorm :: new_array_unchecked ( normalized, norms, 1 ) ? } . into_array ( ) ;
651+
652+ let plain = tensor_array ( & [ 2 ] , & [ 1.0 , 0.0 ] ) ?;
653+
654+ // Denorm on the lhs: `One { denorm: lhs, plain: rhs }`.
655+ assert_close (
656+ & eval_cosine_similarity ( denorm. clone ( ) , plain. clone ( ) , 1 ) ?,
657+ & [ 0.0 ] ,
658+ ) ;
659+
660+ // Denorm on the rhs: `One { denorm: rhs, plain: lhs }`. The same zero-norm guard must
661+ // fire regardless of operand order.
662+ assert_close ( & eval_cosine_similarity ( plain, denorm, 1 ) ?, & [ 0.0 ] ) ;
663+ Ok ( ( ) )
664+ }
665+
599666 #[ test]
600667 fn constant_lhs_matches_plain_tensor ( ) -> VortexResult < ( ) > {
601668 // The constant query `[1, 2, 2]` has norm 3, so its normalized form is `[1/3, 2/3, 2/3]`.
0 commit comments