diff --git a/src/NumSharp.Core/Logic/np.find_common_type.cs b/src/NumSharp.Core/Logic/np.find_common_type.cs index ebd3f380..64ff6b4c 100644 --- a/src/NumSharp.Core/Logic/np.find_common_type.cs +++ b/src/NumSharp.Core/Logic/np.find_common_type.cs @@ -8,6 +8,82 @@ namespace NumSharp { + // ================================================================================ + // TYPE PROMOTION SYSTEM + // ================================================================================ + // + // This file implements NumPy-compatible type promotion for arithmetic operations. + // When two arrays (or an array and a scalar) are combined, this system determines + // the result dtype. + // + // ARCHITECTURE + // ============ + // + // Four lookup tables are used (two pairs for Type and NPTypeCode access): + // + // _typemap_arr_arr / _nptypemap_arr_arr - Array + Array promotion + // _typemap_arr_scalar / _nptypemap_arr_scalar - Array + Scalar promotion + // + // The tables are FrozenDictionary<(T1, T2), TResult> for O(1) lookup. + // + // WHEN EACH TABLE IS USED + // ======================= + // + // The _FindCommonType(NDArray, NDArray) method decides which table to use: + // + // if (both are non-scalar arrays) → _typemap_arr_arr + // if (both are scalar arrays) → _FindCommonScalarType (uses arr_arr rules) + // if (one is array, one is scalar) → _typemap_arr_scalar + // + // This matters because scalar promotion follows different rules than array promotion. + // + // KIND HIERARCHY + // ============== + // + // Types are grouped into "kinds" with a promotion hierarchy: + // + // boolean < integer < floating-point < complex + // + // When operands are of different kinds, the result promotes to the higher kind: + // + // int32 + float32 → float64 (int promotes to float) + // float32 + complex → complex (float promotes to complex) + // + // WITHIN-KIND PROMOTION + // ===================== + // + // When operands are the same kind, promotion depends on the operation type: + // + // Array + Array (both non-scalar): + // - Result is the "larger" type that can hold both ranges + // - uint8 + int16 → int16 (int16 can hold uint8 range + negatives) + // - uint32 + int32 → int64 (need 64-bit to hold both ranges) + // - uint64 + int64 → float64 (no integer type can hold both!) + // + // Array + Scalar (NEP 50 behavior): + // - Array dtype wins when scalar is same-kind (e.g., both integers) + // - uint8_array + int32_scalar → uint8 (array wins) + // - float32_array + int32_scalar → float32 (array wins, same effective kind) + // + // EXAMPLES + // ======== + // + // var a = np.array(new byte[] {1, 2, 3}); // uint8 + // var b = np.array(new int[] {4, 5, 6}); // int32 + // + // (a + b).dtype == np.int32 // arr+arr: promotes to int32 + // (a + 5).dtype == np.uint8 // arr+scalar: array wins (NEP 50) + // (a + 5.0).dtype == np.float64 // cross-kind: float wins + // + // REFERENCES + // ========== + // + // - NumPy type promotion: https://numpy.org/doc/stable/reference/ufuncs.html#type-casting-rules + // - NEP 50 (scalar promotion): https://numpy.org/neps/nep-0050-scalar-promotion.html + // - Array API type promotion: https://data-apis.org/array-api/latest/API_specification/type_promotion.html + // + // ================================================================================ + [SuppressMessage("ReSharper", "StaticMemberInitializerReferesToMemberBelow")] public static partial class np { @@ -50,6 +126,39 @@ static np() { #region arr_arr + // ============================================================================ + // ARRAY-ARRAY TYPE PROMOTION TABLE + // ============================================================================ + // + // This table defines type promotion when TWO ARRAYS are combined. + // The key is (LeftArrayType, RightArrayType), the value is the result type. + // + // PROMOTION RULES: + // + // 1. Same type: result is that type + // int32 + int32 → int32 + // + // 2. Same kind, different size: result is larger type + // int16 + int32 → int32 + // float32 + float64 → float64 + // + // 3. Signed + Unsigned (same size): result is next-larger signed type + // int16 + uint16 → int32 (need more bits for both ranges) + // int32 + uint32 → int64 + // int64 + uint64 → float64 (no larger integer exists!) + // + // 4. Cross-kind: result is the higher kind + // int32 + float32 → float64 (int32 needs float64 precision) + // uint8 + float32 → float32 (uint8 fits in float32) + // + // 5. Complex: absorbs everything + // float32 + complex64 → complex64 + // int32 + complex64 → complex128 (int32 needs float64 precision) + // + // This table matches NumPy 2.x arr+arr behavior exactly. + // + // ============================================================================ + var typemap_arr_arr = new Dictionary<(Type, Type), Type>(180); typemap_arr_arr.Add((np.@bool, np.@bool), np.@bool); typemap_arr_arr.Add((np.@bool, np.uint8), np.uint8); @@ -243,6 +352,45 @@ static np() #region arr_scalar + // ============================================================================ + // ARRAY-SCALAR TYPE PROMOTION TABLE + // ============================================================================ + // + // This table defines type promotion when an array operates with a scalar value. + // The key is (ArrayType, ScalarType), the value is the result type. + // + // NUMSHARP DESIGN DECISION: + // C# primitive scalars (int, short, long, etc.) are treated as "weakly typed" + // like Python scalars in NumPy 2.x, NOT like NumPy scalars (np.int32, etc.). + // + // This means: np.array(new byte[]{1,2,3}) + 5 → uint8 result (not int32) + // + // WHY: This matches the natural Python/NumPy user experience where `arr + 5` + // preserves the array's dtype when both are integers. This is consistent with + // NumPy 2.x behavior under NEP 50 for Python scalar operands. + // + // NEP 50 (NumPy Enhancement Proposal 50): + // https://numpy.org/neps/nep-0050-scalar-promotion.html + // + // Key rule: When an array operates with a scalar of the same "kind" (e.g., both + // are integers), the array dtype wins. Cross-kind operations (int + float) still + // promote to the higher kind (float). + // + // AFFECTED ENTRIES (12 total - all unsigned array + signed scalar): + // + // | Array Type | Scalar Types | NumPy 1.x Result | NumPy 2.x Result | + // |------------|-------------------|------------------|------------------| + // | uint8 | int16/int32/int64 | int16/int32/int64| uint8 | + // | uint16 | int16/int32/int64 | int32/int32/int64| uint16 | + // | uint32 | int16/int32/int64 | int64/int64/int64| uint32 | + // | uint64 | int16/int32/int64 | float64 (!) | uint64 | + // + // Verified against NumPy 2.4.2: + // >>> (np.array([1,2,3], np.uint8) + 5).dtype + // dtype('uint8') + // + // ============================================================================ + var typemap_arr_scalar = new Dictionary<(Type, Type), Type>(); typemap_arr_scalar.Add((np.@bool, np.@bool), np.@bool); typemap_arr_scalar.Add((np.@bool, np.uint8), np.uint8); @@ -259,11 +407,11 @@ static np() typemap_arr_scalar.Add((np.uint8, np.@bool), np.uint8); typemap_arr_scalar.Add((np.uint8, np.uint8), np.uint8); typemap_arr_scalar.Add((np.uint8, np.@char), np.uint8); - typemap_arr_scalar.Add((np.uint8, np.int16), np.int16); + typemap_arr_scalar.Add((np.uint8, np.int16), np.uint8); typemap_arr_scalar.Add((np.uint8, np.uint16), np.uint8); - typemap_arr_scalar.Add((np.uint8, np.int32), np.int32); + typemap_arr_scalar.Add((np.uint8, np.int32), np.uint8); typemap_arr_scalar.Add((np.uint8, np.uint32), np.uint8); - typemap_arr_scalar.Add((np.uint8, np.int64), np.int64); + typemap_arr_scalar.Add((np.uint8, np.int64), np.uint8); typemap_arr_scalar.Add((np.uint8, np.uint64), np.uint8); typemap_arr_scalar.Add((np.uint8, np.float32), np.float32); typemap_arr_scalar.Add((np.uint8, np.float64), np.float64); @@ -298,11 +446,11 @@ static np() typemap_arr_scalar.Add((np.uint16, np.@bool), np.uint16); typemap_arr_scalar.Add((np.uint16, np.uint8), np.uint16); typemap_arr_scalar.Add((np.uint16, np.@char), np.uint16); - typemap_arr_scalar.Add((np.uint16, np.int16), np.int32); + typemap_arr_scalar.Add((np.uint16, np.int16), np.uint16); typemap_arr_scalar.Add((np.uint16, np.uint16), np.uint16); - typemap_arr_scalar.Add((np.uint16, np.int32), np.int32); + typemap_arr_scalar.Add((np.uint16, np.int32), np.uint16); typemap_arr_scalar.Add((np.uint16, np.uint32), np.uint16); - typemap_arr_scalar.Add((np.uint16, np.int64), np.int64); + typemap_arr_scalar.Add((np.uint16, np.int64), np.uint16); typemap_arr_scalar.Add((np.uint16, np.uint64), np.uint16); typemap_arr_scalar.Add((np.uint16, np.float32), np.float32); typemap_arr_scalar.Add((np.uint16, np.float64), np.float64); @@ -324,11 +472,11 @@ static np() typemap_arr_scalar.Add((np.uint32, np.@bool), np.uint32); typemap_arr_scalar.Add((np.uint32, np.uint8), np.uint32); typemap_arr_scalar.Add((np.uint32, np.@char), np.uint32); - typemap_arr_scalar.Add((np.uint32, np.int16), np.int64); + typemap_arr_scalar.Add((np.uint32, np.int16), np.uint32); typemap_arr_scalar.Add((np.uint32, np.uint16), np.uint32); - typemap_arr_scalar.Add((np.uint32, np.int32), np.int64); + typemap_arr_scalar.Add((np.uint32, np.int32), np.uint32); typemap_arr_scalar.Add((np.uint32, np.uint32), np.uint32); - typemap_arr_scalar.Add((np.uint32, np.int64), np.int64); + typemap_arr_scalar.Add((np.uint32, np.int64), np.uint32); typemap_arr_scalar.Add((np.uint32, np.uint64), np.uint32); typemap_arr_scalar.Add((np.uint32, np.float32), np.float64); typemap_arr_scalar.Add((np.uint32, np.float64), np.float64); @@ -350,11 +498,11 @@ static np() typemap_arr_scalar.Add((np.uint64, np.@bool), np.uint64); typemap_arr_scalar.Add((np.uint64, np.uint8), np.uint64); typemap_arr_scalar.Add((np.uint64, np.@char), np.uint64); - typemap_arr_scalar.Add((np.uint64, np.int16), np.float64); + typemap_arr_scalar.Add((np.uint64, np.int16), np.uint64); typemap_arr_scalar.Add((np.uint64, np.uint16), np.uint64); - typemap_arr_scalar.Add((np.uint64, np.int32), np.float64); + typemap_arr_scalar.Add((np.uint64, np.int32), np.uint64); typemap_arr_scalar.Add((np.uint64, np.uint32), np.uint64); - typemap_arr_scalar.Add((np.uint64, np.int64), np.float64); + typemap_arr_scalar.Add((np.uint64, np.int64), np.uint64); typemap_arr_scalar.Add((np.uint64, np.uint64), np.uint64); typemap_arr_scalar.Add((np.uint64, np.float32), np.float64); typemap_arr_scalar.Add((np.uint64, np.float64), np.float64); diff --git a/test/NumSharp.UnitTest/Logic/NEP50.cs b/test/NumSharp.UnitTest/Logic/NEP50.cs new file mode 100644 index 00000000..3f46decd --- /dev/null +++ b/test/NumSharp.UnitTest/Logic/NEP50.cs @@ -0,0 +1,964 @@ +using System; +using AwesomeAssertions; +using NumSharp.Backends; + +namespace NumSharp.UnitTest.Logic; + +/// +/// NEP 50 Type Promotion Tests +/// +/// NumPy 2.0 introduced NEP 50 (https://numpy.org/neps/nep-0050-scalar-promotion.html) +/// which changed scalar-array type promotion rules. +/// +/// KEY CONCEPT: Python scalars (int, float, complex) are "weakly typed" - they adopt +/// the array's dtype when combined. NumPy scalars (np.int32, np.float64) are "strongly +/// typed" - their dtype is honored in promotion. +/// +/// NUMSHARP DESIGN DECISION: C# primitive scalars (int, double, etc.) are treated as +/// "weakly typed" like Python scalars, not like NumPy scalars. This gives users the +/// natural Python-like experience: +/// +/// np.array(new byte[]{1,2,3}) + 5 → uint8 result (not int32) +/// +/// This matches how Python users expect `arr + 5` to behave in NumPy 2.x. +/// +/// Each test is verified against NumPy 2.4.2 output. +/// +public class NEP50_TypePromotion +{ + #region 1. Unsigned Array + Python Int (array dtype wins) + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.uint8) + 5).dtype)" + /// Output: uint8 + /// + [Test] + public void UInt8Array_Plus_PythonInt_Returns_UInt8() + { + var arr = np.array(new byte[] { 1, 2, 3 }); + var result = arr + 5; + + result.dtype.Should().Be(np.uint8); + result.GetAtIndex(0).Should().Be(6); + result.GetAtIndex(1).Should().Be(7); + result.GetAtIndex(2).Should().Be(8); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.uint16) + 5).dtype)" + /// Output: uint16 + /// + [Test] + public void UInt16Array_Plus_PythonInt_Returns_UInt16() + { + var arr = np.array(new ushort[] { 1, 2, 3 }); + var result = arr + 5; + + result.dtype.Should().Be(np.uint16); + result.GetAtIndex(0).Should().Be(6); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.uint32) + 5).dtype)" + /// Output: uint32 + /// + [Test] + public void UInt32Array_Plus_PythonInt_Returns_UInt32() + { + var arr = np.array(new uint[] { 1, 2, 3 }); + var result = arr + 5; + + result.dtype.Should().Be(np.uint32); + result.GetAtIndex(0).Should().Be(6); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.uint64) + 5).dtype)" + /// Output: uint64 + /// + [Test] + public void UInt64Array_Plus_PythonInt_Returns_UInt64() + { + var arr = np.array(new ulong[] { 1, 2, 3 }); + var result = arr + 5; + + result.dtype.Should().Be(np.uint64); + result.GetAtIndex(0).Should().Be(6); + } + + #endregion + + #region 2. Signed Array + Python Int (array dtype wins) + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.int16) + 5).dtype)" + /// Output: int16 + /// + [Test] + public void Int16Array_Plus_PythonInt_Returns_Int16() + { + var arr = np.array(new short[] { 1, 2, 3 }); + var result = arr + 5; + + result.dtype.Should().Be(np.int16); + result.GetAtIndex(0).Should().Be(6); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.int32) + 5).dtype)" + /// Output: int32 + /// + [Test] + public void Int32Array_Plus_PythonInt_Returns_Int32() + { + var arr = np.array(new int[] { 1, 2, 3 }); + var result = arr + 5; + + result.dtype.Should().Be(np.int32); + result.GetAtIndex(0).Should().Be(6); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.int64) + 5).dtype)" + /// Output: int64 + /// + [Test] + public void Int64Array_Plus_PythonInt_Returns_Int64() + { + var arr = np.array(new long[] { 1, 2, 3 }); + var result = arr + 5; + + result.dtype.Should().Be(np.int64); + result.GetAtIndex(0).Should().Be(6); + } + + #endregion + + #region 3. Float Array + Python Int (int adopts float kind) + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.float32) + 5).dtype)" + /// Output: float32 + /// + [Test] + public void Float32Array_Plus_PythonInt_Returns_Float32() + { + var arr = np.array(new float[] { 1f, 2f, 3f }); + var result = arr + 5; + + result.dtype.Should().Be(np.float32); + result.GetAtIndex(0).Should().Be(6f); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.float64) + 5).dtype)" + /// Output: float64 + /// + [Test] + public void Float64Array_Plus_PythonInt_Returns_Float64() + { + var arr = np.array(new double[] { 1.0, 2.0, 3.0 }); + var result = arr + 5; + + result.dtype.Should().Be(np.float64); + result.GetAtIndex(0).Should().Be(6.0); + } + + #endregion + + #region 4. Float Array + Python Float (scalar adopts array dtype) + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.float32) + 5.0).dtype)" + /// Output: float32 + /// + [Test] + public void Float32Array_Plus_PythonFloat_Returns_Float32() + { + var arr = np.array(new float[] { 1f, 2f, 3f }); + var result = arr + 5.0; + + result.dtype.Should().Be(np.float32); + result.GetAtIndex(0).Should().Be(6f); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.float64) + 5.0).dtype)" + /// Output: float64 + /// + [Test] + public void Float64Array_Plus_PythonFloat_Returns_Float64() + { + var arr = np.array(new double[] { 1.0, 2.0, 3.0 }); + var result = arr + 5.0; + + result.dtype.Should().Be(np.float64); + result.GetAtIndex(0).Should().Be(6.0); + } + + #endregion + + #region 5. Int Array + Python Float (promotes to float64 - cross-kind) + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.int32) + 5.0).dtype)" + /// Output: float64 + /// + [Test] + public void Int32Array_Plus_PythonFloat_Returns_Float64() + { + var arr = np.array(new int[] { 1, 2, 3 }); + var result = arr + 5.0; + + result.dtype.Should().Be(np.float64); + result.GetAtIndex(0).Should().Be(6.0); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.uint8) + 5.0).dtype)" + /// Output: float64 + /// + [Test] + public void UInt8Array_Plus_PythonFloat_Returns_Float64() + { + var arr = np.array(new byte[] { 1, 2, 3 }); + var result = arr + 5.0; + + result.dtype.Should().Be(np.float64); + result.GetAtIndex(0).Should().Be(6.0); + } + + #endregion + + #region 6. Subtraction (same rules apply) + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([10,20,30], np.uint8) - 5).dtype)" + /// Output: uint8 + /// + [Test] + public void UInt8Array_Minus_PythonInt_Returns_UInt8() + { + var arr = np.array(new byte[] { 10, 20, 30 }); + var result = arr - 5; + + result.dtype.Should().Be(np.uint8); + result.GetAtIndex(0).Should().Be(5); + result.GetAtIndex(1).Should().Be(15); + result.GetAtIndex(2).Should().Be(25); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([10,20,30], np.uint16) - 5).dtype)" + /// Output: uint16 + /// + [Test] + public void UInt16Array_Minus_PythonInt_Returns_UInt16() + { + var arr = np.array(new ushort[] { 10, 20, 30 }); + var result = arr - 5; + + result.dtype.Should().Be(np.uint16); + result.GetAtIndex(0).Should().Be(5); + } + + #endregion + + #region 7. Multiplication (same rules apply) + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.uint8) * 5).dtype)" + /// Output: uint8 + /// + [Test] + public void UInt8Array_Times_PythonInt_Returns_UInt8() + { + var arr = np.array(new byte[] { 1, 2, 3 }); + var result = arr * 5; + + result.dtype.Should().Be(np.uint8); + result.GetAtIndex(0).Should().Be(5); + result.GetAtIndex(1).Should().Be(10); + result.GetAtIndex(2).Should().Be(15); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.uint32) * 5).dtype)" + /// Output: uint32 + /// + [Test] + public void UInt32Array_Times_PythonInt_Returns_UInt32() + { + var arr = np.array(new uint[] { 1, 2, 3 }); + var result = arr * 5; + + result.dtype.Should().Be(np.uint32); + result.GetAtIndex(0).Should().Be(5); + } + + #endregion + + #region 8. Division + + /// + /// NumPy: python3 -c "import numpy as np; print((np.array([10,20,30], np.uint8) / 5).dtype)" + /// Output: float64 + /// + /// NumSharp DIFFERENCE: Uses integer division (like C#'s / operator for integers). + /// NumSharp returns array dtype, not float64. + /// + [Test] + [Misaligned] + public void UInt8Array_Divide_PythonInt_Returns_UInt8_NumSharpBehavior() + { + var arr = np.array(new byte[] { 10, 20, 30 }); + var result = arr / 5; + + // NumSharp uses integer division, preserving array dtype + // NumPy would return float64 + result.dtype.Should().Be(np.uint8, "NumSharp uses integer division"); + result.GetAtIndex(0).Should().Be(2); // 10 / 5 = 2 + } + + /// + /// NumPy: python3 -c "import numpy as np; print((np.array([10,20,30], np.int32) / 5).dtype)" + /// Output: float64 + /// + /// NumSharp DIFFERENCE: Uses integer division. + /// + [Test] + [Misaligned] + public void Int32Array_Divide_PythonInt_Returns_Int32_NumSharpBehavior() + { + var arr = np.array(new int[] { 10, 20, 30 }); + var result = arr / 5; + + // NumSharp uses integer division, preserving array dtype + result.dtype.Should().Be(np.int32, "NumSharp uses integer division"); + result.GetAtIndex(0).Should().Be(2); // 10 / 5 = 2 + } + + /// + /// Float division works correctly. + /// Verified: python3 -c "import numpy as np; print((np.array([10,20,30], np.float64) / 5).dtype)" + /// Output: float64 + /// + [Test] + public void Float64Array_Divide_PythonInt_Returns_Float64() + { + var arr = np.array(new double[] { 10.0, 20.0, 30.0 }); + var result = arr / 5; + + result.dtype.Should().Be(np.float64); + result.GetAtIndex(0).Should().Be(2.0); + } + + #endregion + + #region 9. Modulo + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([10,20,30], np.uint8) % 7).dtype)" + /// Output: uint8 + /// + [Test] + public void UInt8Array_Mod_PythonInt_Returns_UInt8() + { + var arr = np.array(new byte[] { 10, 20, 30 }); + var result = arr % 7; + + result.dtype.Should().Be(np.uint8); + result.GetAtIndex(0).Should().Be(3); // 10 % 7 = 3 + result.GetAtIndex(1).Should().Be(6); // 20 % 7 = 6 + result.GetAtIndex(2).Should().Be(2); // 30 % 7 = 2 + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([10,20,30], np.int32) % 7).dtype)" + /// Output: int32 + /// + [Test] + public void Int32Array_Mod_PythonInt_Returns_Int32() + { + var arr = np.array(new int[] { 10, 20, 30 }); + var result = arr % 7; + + result.dtype.Should().Be(np.int32); + result.GetAtIndex(0).Should().Be(3); + } + + #endregion + + #region 10. Scalar-First Operations (commutative - same result) + + /// + /// Verified: python3 -c "import numpy as np; print((5 + np.array([1,2,3], np.uint8)).dtype)" + /// Output: uint8 + /// + [Test] + public void PythonInt_Plus_UInt8Array_Returns_UInt8() + { + var arr = np.array(new byte[] { 1, 2, 3 }); + var result = 5 + arr; + + result.dtype.Should().Be(np.uint8); + result.GetAtIndex(0).Should().Be(6); + } + + /// + /// Verified: python3 -c "import numpy as np; print((5 - np.array([1,2,3], np.uint8)).dtype)" + /// Output: uint8 + /// + [Test] + public void PythonInt_Minus_UInt8Array_Returns_UInt8() + { + var arr = np.array(new byte[] { 1, 2, 3 }); + var result = 5 - arr; + + result.dtype.Should().Be(np.uint8); + result.GetAtIndex(0).Should().Be(4); // 5 - 1 = 4 + result.GetAtIndex(1).Should().Be(3); // 5 - 2 = 3 + result.GetAtIndex(2).Should().Be(2); // 5 - 3 = 2 + } + + /// + /// Verified: python3 -c "import numpy as np; print((5 * np.array([1,2,3], np.uint8)).dtype)" + /// Output: uint8 + /// + [Test] + public void PythonInt_Times_UInt8Array_Returns_UInt8() + { + var arr = np.array(new byte[] { 1, 2, 3 }); + var result = 5 * arr; + + result.dtype.Should().Be(np.uint8); + result.GetAtIndex(0).Should().Be(5); + } + + #endregion + + #region 11. _FindCommonArrayScalarType Direct Tests + + /// + /// Direct verification of the type promotion table for NEP 50 changes. + /// These are the 12 entries that changed from NumPy 1.x to 2.x behavior. + /// + [Test] + public void ArrayScalarType_UInt8_SignedScalars() + { + // NEP 50: uint8 array + signed scalar → uint8 (array wins) + np._FindCommonArrayScalarType(NPTypeCode.Byte, NPTypeCode.Int16).Should().Be(NPTypeCode.Byte); + np._FindCommonArrayScalarType(NPTypeCode.Byte, NPTypeCode.Int32).Should().Be(NPTypeCode.Byte); + np._FindCommonArrayScalarType(NPTypeCode.Byte, NPTypeCode.Int64).Should().Be(NPTypeCode.Byte); + } + + [Test] + public void ArrayScalarType_UInt16_SignedScalars() + { + // NEP 50: uint16 array + signed scalar → uint16 (array wins) + np._FindCommonArrayScalarType(NPTypeCode.UInt16, NPTypeCode.Int16).Should().Be(NPTypeCode.UInt16); + np._FindCommonArrayScalarType(NPTypeCode.UInt16, NPTypeCode.Int32).Should().Be(NPTypeCode.UInt16); + np._FindCommonArrayScalarType(NPTypeCode.UInt16, NPTypeCode.Int64).Should().Be(NPTypeCode.UInt16); + } + + [Test] + public void ArrayScalarType_UInt32_SignedScalars() + { + // NEP 50: uint32 array + signed scalar → uint32 (array wins) + np._FindCommonArrayScalarType(NPTypeCode.UInt32, NPTypeCode.Int16).Should().Be(NPTypeCode.UInt32); + np._FindCommonArrayScalarType(NPTypeCode.UInt32, NPTypeCode.Int32).Should().Be(NPTypeCode.UInt32); + np._FindCommonArrayScalarType(NPTypeCode.UInt32, NPTypeCode.Int64).Should().Be(NPTypeCode.UInt32); + } + + [Test] + public void ArrayScalarType_UInt64_SignedScalars() + { + // NEP 50: uint64 array + signed scalar → uint64 (array wins) + np._FindCommonArrayScalarType(NPTypeCode.UInt64, NPTypeCode.Int16).Should().Be(NPTypeCode.UInt64); + np._FindCommonArrayScalarType(NPTypeCode.UInt64, NPTypeCode.Int32).Should().Be(NPTypeCode.UInt64); + np._FindCommonArrayScalarType(NPTypeCode.UInt64, NPTypeCode.Int64).Should().Be(NPTypeCode.UInt64); + } + + #endregion + + #region 12. Array-Array Operations (unchanged - uses arr_arr table) + + /// + /// Array-array operations use _typemap_arr_arr, not _typemap_arr_scalar. + /// These should NOT be affected by NEP 50 changes to arr_scalar table. + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1], np.uint8) + np.array([5], np.int32)).dtype)" + /// Output: int32 + /// + [Test] + public void ArrayArray_UInt8_Plus_Int32_Returns_Int32() + { + var arr1 = np.array(new byte[] { 1, 2, 3 }); + var arr2 = np.array(new int[] { 5, 6, 7 }); + var result = arr1 + arr2; + + result.dtype.Should().Be(np.int32); + result.GetAtIndex(0).Should().Be(6); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1], np.uint16) + np.array([5], np.int32)).dtype)" + /// Output: int32 + /// + [Test] + public void ArrayArray_UInt16_Plus_Int32_Returns_Int32() + { + var arr1 = np.array(new ushort[] { 1, 2, 3 }); + var arr2 = np.array(new int[] { 5, 6, 7 }); + var result = arr1 + arr2; + + result.dtype.Should().Be(np.int32); + } + + #endregion + + #region 13. Unchanged Behaviors (sanity checks) + + /// + /// Same-type operations remain unchanged. + /// + [Test] + public void SameType_UInt8_Plus_UInt8_Returns_UInt8() + { + var arr1 = np.array(new byte[] { 1, 2, 3 }); + var arr2 = np.array(new byte[] { 4, 5, 6 }); + var result = arr1 + arr2; + + result.dtype.Should().Be(np.uint8); + result.GetAtIndex(0).Should().Be(5); + } + + /// + /// Unsigned array + larger unsigned scalar → array dtype (unchanged). + /// + [Test] + public void UInt8Array_Plus_UInt32Scalar_Returns_UInt8() + { + // This was already "array wins" in NumPy 1.x for same-kind + np._FindCommonArrayScalarType(NPTypeCode.Byte, NPTypeCode.UInt32).Should().Be(NPTypeCode.Byte); + } + + /// + /// Float operations with int scalars → float (array kind wins). + /// + [Test] + public void Float32Array_Operations_PreserveFloat() + { + var arr = np.array(new float[] { 1f, 2f, 3f }); + + (arr + 5).dtype.Should().Be(np.float32); + (arr - 5).dtype.Should().Be(np.float32); + (arr * 5).dtype.Should().Be(np.float32); + (5 + arr).dtype.Should().Be(np.float32); + } + + #endregion + + #region 14. Edge Cases + + /// + /// Empty array operations should preserve dtype. + /// + [Test] + public void EmptyArray_Operations_PreserveDtype() + { + var arr = np.array(Array.Empty()); + var result = arr + 5; + + result.dtype.Should().Be(np.uint8); + result.size.Should().Be(0); + } + + /// + /// 1D array operations. + /// + [Test] + public void OneDimensional_UInt8_Operations() + { + var arr = np.arange(10).astype(np.uint8); + var result = arr + 100; + + result.dtype.Should().Be(np.uint8); + result.shape.Should().BeEquivalentTo(new[] { 10 }); + } + + /// + /// Multi-dimensional array operations. + /// + [Test] + public void MultiDimensional_UInt8_Operations() + { + var arr = np.arange(12).astype(np.uint8).reshape(3, 4); + var result = arr + 5; + + result.dtype.Should().Be(np.uint8); + result.shape.Should().BeEquivalentTo(new[] { 3, 4 }); + } + + /// + /// Scalar array (0-d) operations use SCALAR-SCALAR promotion, not ARR-SCALAR. + /// + /// When both operands are scalars, NumSharp uses _FindCommonScalarType, + /// which follows different rules than _FindCommonArrayScalarType. + /// + /// NumPy: python3 -c "import numpy as np; print((np.uint8(10) + 5).dtype)" + /// Output: uint8 (NEP 50: weak scalar adopts stronger scalar dtype) + /// + /// NumSharp uses scalar-scalar table which may differ. + /// + [Test] + [Misaligned] + public void ScalarArray_Operations_UsesScalarScalarPromotion() + { + var arr = NDArray.Scalar((byte)10); + var result = arr + 5; + + // Note: Scalar + scalar uses _FindCommonScalarType, not _FindCommonArrayScalarType + // Current behavior returns int32 (matches C# semantics) + result.dtype.Should().Be(np.int32, + "Scalar-scalar promotion follows different rules than arr-scalar"); + } + + #endregion + + #region 15. Power Operation + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.uint8) ** 2).dtype)" + /// Output: uint8 + /// + [Test] + public void UInt8Array_Power_PythonInt_Returns_UInt8() + { + var arr = np.array(new byte[] { 1, 2, 3 }); + var result = np.power(arr, 2); + + result.dtype.Should().Be(np.uint8); + result.GetAtIndex(0).Should().Be(1); + result.GetAtIndex(1).Should().Be(4); + result.GetAtIndex(2).Should().Be(9); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.int32) ** 2).dtype)" + /// Output: int32 + /// + [Test] + public void Int32Array_Power_PythonInt_Returns_Int32() + { + var arr = np.array(new int[] { 1, 2, 3 }); + var result = np.power(arr, 2); + + result.dtype.Should().Be(np.int32); + result.GetAtIndex(0).Should().Be(1); + result.GetAtIndex(1).Should().Be(4); + result.GetAtIndex(2).Should().Be(9); + } + + #endregion + + #region 16. Comparison with NumPy 1.x Behavior (Documentation) + + /// + /// Documents what CHANGED from NumPy 1.x to 2.x behavior. + /// + /// NumPy 1.x: uint8([1,2,3]) + 5 → int64 (scalar widened to int64) + /// NumPy 2.x: uint8([1,2,3]) + 5 → uint8 (array dtype wins) + /// + /// NumSharp now follows NumPy 2.x behavior. + /// + [Test] + public void Documentation_NEP50_BreakingChange() + { + // This is the key behavioral change from NumPy 1.x to 2.x + var arr = np.array(new byte[] { 1, 2, 3 }); + var result = arr + 5; + + // NumPy 1.x would return int64 + // NumPy 2.x (and NumSharp) returns uint8 + result.dtype.Should().Be(np.uint8, + "NEP 50: array dtype wins when scalar is same-kind (integer)"); + } + + #endregion + + #region 17. Cross-Kind Promotion (float wins over int) + + /// + /// When kinds differ (int vs float), the higher kind wins. + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.int16) + 5.0).dtype)" + /// Output: float64 + /// + /// Note: NumSharp doesn't support int8 (sbyte), so we use int16. + /// + [Test] + public void CrossKind_IntArray_Plus_Float_Returns_Float64() + { + var intArr = np.array(new short[] { 1, 2, 3 }); // int16 + var result = intArr + 5.0; + + result.dtype.Should().Be(np.float64, "Cross-kind: float wins over int"); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.uint8) + 5.0).dtype)" + /// Output: float64 + /// + [Test] + public void CrossKind_UInt8Array_Plus_Float_Returns_Float64() + { + var arr = np.array(new byte[] { 1, 2, 3 }); + var result = arr + 5.0; + + result.dtype.Should().Be(np.float64, "Cross-kind: float wins over uint"); + } + + /// + /// Verified: python3 -c "import numpy as np; print((np.array([1,2,3], np.int32) + 5.0).dtype)" + /// Output: float64 + /// + [Test] + public void CrossKind_Int32Array_Plus_Float_Returns_Float64() + { + var arr = np.array(new int[] { 1, 2, 3 }); + var result = arr + 5.0; + + result.dtype.Should().Be(np.float64, "Cross-kind: float wins over int"); + } + + #endregion + + #region 18. Complete NEP50 Operation Matrix (12 combinations × 4 operations) + + // ============================================================================ + // UINT8 + SIGNED SCALARS (3 combinations) + // ============================================================================ + + /// + /// Verified: python3 -c "import numpy as np; a=np.array([1,2,3], np.uint8); print((a + np.int16(5)).dtype)" + /// NumPy 2.x with numpy scalar: int16 (strongly typed) + /// NumSharp with C# short: treats as weakly typed → uint8 + /// + [Test] + public void NEP50_UInt8_Plus_Short_AllOps() + { + var arr = np.array(new byte[] { 10, 20, 30 }); + short scalar = 5; + + (arr + scalar).dtype.Should().Be(np.uint8, "uint8 + int16 → uint8"); + (arr - scalar).dtype.Should().Be(np.uint8, "uint8 - int16 → uint8"); + (arr * scalar).dtype.Should().Be(np.uint8, "uint8 * int16 → uint8"); + (arr % scalar).dtype.Should().Be(np.uint8, "uint8 % int16 → uint8"); + } + + [Test] + public void NEP50_UInt8_Plus_Int_AllOps() + { + var arr = np.array(new byte[] { 10, 20, 30 }); + int scalar = 5; + + (arr + scalar).dtype.Should().Be(np.uint8, "uint8 + int32 → uint8"); + (arr - scalar).dtype.Should().Be(np.uint8, "uint8 - int32 → uint8"); + (arr * scalar).dtype.Should().Be(np.uint8, "uint8 * int32 → uint8"); + (arr % scalar).dtype.Should().Be(np.uint8, "uint8 % int32 → uint8"); + } + + [Test] + public void NEP50_UInt8_Plus_Long_AllOps() + { + var arr = np.array(new byte[] { 10, 20, 30 }); + long scalar = 5; + + (arr + scalar).dtype.Should().Be(np.uint8, "uint8 + int64 → uint8"); + (arr - scalar).dtype.Should().Be(np.uint8, "uint8 - int64 → uint8"); + (arr * scalar).dtype.Should().Be(np.uint8, "uint8 * int64 → uint8"); + (arr % scalar).dtype.Should().Be(np.uint8, "uint8 % int64 → uint8"); + } + + // ============================================================================ + // UINT16 + SIGNED SCALARS (3 combinations) + // ============================================================================ + + [Test] + public void NEP50_UInt16_Plus_Short_AllOps() + { + var arr = np.array(new ushort[] { 100, 200, 300 }); + short scalar = 5; + + (arr + scalar).dtype.Should().Be(np.uint16, "uint16 + int16 → uint16"); + (arr - scalar).dtype.Should().Be(np.uint16, "uint16 - int16 → uint16"); + (arr * scalar).dtype.Should().Be(np.uint16, "uint16 * int16 → uint16"); + (arr % scalar).dtype.Should().Be(np.uint16, "uint16 % int16 → uint16"); + } + + [Test] + public void NEP50_UInt16_Plus_Int_AllOps() + { + var arr = np.array(new ushort[] { 100, 200, 300 }); + int scalar = 5; + + (arr + scalar).dtype.Should().Be(np.uint16, "uint16 + int32 → uint16"); + (arr - scalar).dtype.Should().Be(np.uint16, "uint16 - int32 → uint16"); + (arr * scalar).dtype.Should().Be(np.uint16, "uint16 * int32 → uint16"); + (arr % scalar).dtype.Should().Be(np.uint16, "uint16 % int32 → uint16"); + } + + [Test] + public void NEP50_UInt16_Plus_Long_AllOps() + { + var arr = np.array(new ushort[] { 100, 200, 300 }); + long scalar = 5; + + (arr + scalar).dtype.Should().Be(np.uint16, "uint16 + int64 → uint16"); + (arr - scalar).dtype.Should().Be(np.uint16, "uint16 - int64 → uint16"); + (arr * scalar).dtype.Should().Be(np.uint16, "uint16 * int64 → uint16"); + (arr % scalar).dtype.Should().Be(np.uint16, "uint16 % int64 → uint16"); + } + + // ============================================================================ + // UINT32 + SIGNED SCALARS (3 combinations) + // ============================================================================ + + [Test] + public void NEP50_UInt32_Plus_Short_AllOps() + { + var arr = np.array(new uint[] { 1000, 2000, 3000 }); + short scalar = 5; + + (arr + scalar).dtype.Should().Be(np.uint32, "uint32 + int16 → uint32"); + (arr - scalar).dtype.Should().Be(np.uint32, "uint32 - int16 → uint32"); + (arr * scalar).dtype.Should().Be(np.uint32, "uint32 * int16 → uint32"); + (arr % scalar).dtype.Should().Be(np.uint32, "uint32 % int16 → uint32"); + } + + [Test] + public void NEP50_UInt32_Plus_Int_AllOps() + { + var arr = np.array(new uint[] { 1000, 2000, 3000 }); + int scalar = 5; + + (arr + scalar).dtype.Should().Be(np.uint32, "uint32 + int32 → uint32"); + (arr - scalar).dtype.Should().Be(np.uint32, "uint32 - int32 → uint32"); + (arr * scalar).dtype.Should().Be(np.uint32, "uint32 * int32 → uint32"); + (arr % scalar).dtype.Should().Be(np.uint32, "uint32 % int32 → uint32"); + } + + [Test] + public void NEP50_UInt32_Plus_Long_AllOps() + { + var arr = np.array(new uint[] { 1000, 2000, 3000 }); + long scalar = 5; + + (arr + scalar).dtype.Should().Be(np.uint32, "uint32 + int64 → uint32"); + (arr - scalar).dtype.Should().Be(np.uint32, "uint32 - int64 → uint32"); + (arr * scalar).dtype.Should().Be(np.uint32, "uint32 * int64 → uint32"); + (arr % scalar).dtype.Should().Be(np.uint32, "uint32 % int64 → uint32"); + } + + // ============================================================================ + // UINT64 + SIGNED SCALARS (3 combinations) + // ============================================================================ + + [Test] + public void NEP50_UInt64_Plus_Short_AllOps() + { + var arr = np.array(new ulong[] { 10000, 20000, 30000 }); + short scalar = 5; + + (arr + scalar).dtype.Should().Be(np.uint64, "uint64 + int16 → uint64"); + (arr - scalar).dtype.Should().Be(np.uint64, "uint64 - int16 → uint64"); + (arr * scalar).dtype.Should().Be(np.uint64, "uint64 * int16 → uint64"); + (arr % scalar).dtype.Should().Be(np.uint64, "uint64 % int16 → uint64"); + } + + [Test] + public void NEP50_UInt64_Plus_Int_AllOps() + { + var arr = np.array(new ulong[] { 10000, 20000, 30000 }); + int scalar = 5; + + (arr + scalar).dtype.Should().Be(np.uint64, "uint64 + int32 → uint64"); + (arr - scalar).dtype.Should().Be(np.uint64, "uint64 - int32 → uint64"); + (arr * scalar).dtype.Should().Be(np.uint64, "uint64 * int32 → uint64"); + (arr % scalar).dtype.Should().Be(np.uint64, "uint64 % int32 → uint64"); + } + + [Test] + public void NEP50_UInt64_Plus_Long_AllOps() + { + var arr = np.array(new ulong[] { 10000, 20000, 30000 }); + long scalar = 5; + + (arr + scalar).dtype.Should().Be(np.uint64, "uint64 + int64 → uint64"); + (arr - scalar).dtype.Should().Be(np.uint64, "uint64 - int64 → uint64"); + (arr * scalar).dtype.Should().Be(np.uint64, "uint64 * int64 → uint64"); + (arr % scalar).dtype.Should().Be(np.uint64, "uint64 % int64 → uint64"); + } + + #endregion + + #region 19. Value Correctness Tests + + /// + /// Verify actual computed values are correct, not just dtypes. + /// + [Test] + public void NEP50_Values_UInt8_Operations() + { + var arr = np.array(new byte[] { 10, 20, 30 }); + + var add = arr + 5; + add.GetAtIndex(0).Should().Be(15); + add.GetAtIndex(1).Should().Be(25); + add.GetAtIndex(2).Should().Be(35); + + var sub = arr - 5; + sub.GetAtIndex(0).Should().Be(5); + sub.GetAtIndex(1).Should().Be(15); + sub.GetAtIndex(2).Should().Be(25); + + var mul = arr * 2; + mul.GetAtIndex(0).Should().Be(20); + mul.GetAtIndex(1).Should().Be(40); + mul.GetAtIndex(2).Should().Be(60); + + var mod = arr % 7; + mod.GetAtIndex(0).Should().Be(3); // 10 % 7 + mod.GetAtIndex(1).Should().Be(6); // 20 % 7 + mod.GetAtIndex(2).Should().Be(2); // 30 % 7 + } + + [Test] + public void NEP50_Values_UInt32_Operations() + { + var arr = np.array(new uint[] { 1000, 2000, 3000 }); + + var add = arr + 500; + add.GetAtIndex(0).Should().Be(1500); + add.GetAtIndex(1).Should().Be(2500); + add.GetAtIndex(2).Should().Be(3500); + + var sub = arr - 500; + sub.GetAtIndex(0).Should().Be(500); + sub.GetAtIndex(1).Should().Be(1500); + sub.GetAtIndex(2).Should().Be(2500); + } + + [Test] + public void NEP50_Values_UInt64_Operations() + { + var arr = np.array(new ulong[] { 10000, 20000, 30000 }); + + var add = arr + 5000L; + add.GetAtIndex(0).Should().Be(15000); + add.GetAtIndex(1).Should().Be(25000); + add.GetAtIndex(2).Should().Be(35000); + } + + #endregion +} diff --git a/test/NumSharp.UnitTest/Logic/np.find_common_type.Test.cs b/test/NumSharp.UnitTest/Logic/np.find_common_type.Test.cs index 27b7e303..693304b3 100644 --- a/test/NumSharp.UnitTest/Logic/np.find_common_type.Test.cs +++ b/test/NumSharp.UnitTest/Logic/np.find_common_type.Test.cs @@ -162,6 +162,35 @@ public void Case23() r.Should().Be(NPTypeCode.Int16); } + /// + /// NEP 50: When an unsigned integer array operates with a signed integer scalar, + /// the array dtype wins (no type widening). This matches NumPy 2.x behavior. + /// See: https://numpy.org/neps/nep-0050-scalar-promotion.html + /// + [Test] + public void NEP50_UnsignedArray_SignedScalar_ArrayWins() + { + // uint8 array + signed scalar → uint8 + np._FindCommonArrayScalarType(NPTypeCode.Byte, NPTypeCode.Int16).Should().Be(NPTypeCode.Byte); + np._FindCommonArrayScalarType(NPTypeCode.Byte, NPTypeCode.Int32).Should().Be(NPTypeCode.Byte); + np._FindCommonArrayScalarType(NPTypeCode.Byte, NPTypeCode.Int64).Should().Be(NPTypeCode.Byte); + + // uint16 array + signed scalar → uint16 + np._FindCommonArrayScalarType(NPTypeCode.UInt16, NPTypeCode.Int16).Should().Be(NPTypeCode.UInt16); + np._FindCommonArrayScalarType(NPTypeCode.UInt16, NPTypeCode.Int32).Should().Be(NPTypeCode.UInt16); + np._FindCommonArrayScalarType(NPTypeCode.UInt16, NPTypeCode.Int64).Should().Be(NPTypeCode.UInt16); + + // uint32 array + signed scalar → uint32 + np._FindCommonArrayScalarType(NPTypeCode.UInt32, NPTypeCode.Int16).Should().Be(NPTypeCode.UInt32); + np._FindCommonArrayScalarType(NPTypeCode.UInt32, NPTypeCode.Int32).Should().Be(NPTypeCode.UInt32); + np._FindCommonArrayScalarType(NPTypeCode.UInt32, NPTypeCode.Int64).Should().Be(NPTypeCode.UInt32); + + // uint64 array + signed scalar → uint64 + np._FindCommonArrayScalarType(NPTypeCode.UInt64, NPTypeCode.Int16).Should().Be(NPTypeCode.UInt64); + np._FindCommonArrayScalarType(NPTypeCode.UInt64, NPTypeCode.Int32).Should().Be(NPTypeCode.UInt64); + np._FindCommonArrayScalarType(NPTypeCode.UInt64, NPTypeCode.Int64).Should().Be(NPTypeCode.UInt64); + } + [Test, Skip("Ignored")] public void gen_typecode_map() {