Repository navigation
Expand file tree
/
Copy pathNDExpr.cs
More file actions
3176 lines (2862 loc) · 177 KB
/
Copy pathNDExpr.cs
File metadata and controls
3176 lines (2862 loc) · 177 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Reflection.Emit;
using System.Text;
using NumSharp.Backends.Kernels;
// =============================================================================
// NDExpr.cs — Expression DSL (Tier 3C of the custom-op API)
// =============================================================================
//
// A small algebraic AST over NDIter operands. Compiles to an
// NDInnerLoopFunc by emitting (scalarBody, vectorBody) pairs that
// DirectILKernelGenerator.CompileInnerLoop wraps in the standard 4× unroll shell.
//
// TYPE DISCIPLINE — TWO MODES
// ---------------------------
// Legacy mode (Compile): all intermediate computation happens in the output
// dtype. Input loads auto-promote to output dtype; constants are pushed as
// output dtype. Simple, but does NOT match NumPy for mixed-dtype trees
// (NumPy computes each ufunc node at its own result_type — int32*int32
// wraps in int32 even when the final result is float64).
//
// NumPy mode (CompileNumPy, used by np.evaluate): a typing pass resolves
// every node to its NumPy 2.x result_type (NEP50, incl. weak python-scalar
// semantics for constants); emission computes each node at its resolved
// dtype and converts at node edges. See NDExpr.Typing.cs.
//
// For fine-grained type control, use ExecuteElementWise directly (Tier 3B).
//
// SIMD
// ----
// The vector path is enabled iff every input type equals the output type
// (in NumPy mode: the whole tree resolves homogeneous) AND every node's op
// supports SIMD. Otherwise the compiled kernel carries a scalar-only body;
// the factory's strided fallback handles all cases.
//
// =============================================================================
namespace NumSharp.Backends.Iteration
{
/// <summary>
/// Abstract expression node. Subclasses describe computations over
/// NDIter operands; Compile() produces an NDInnerLoopFunc.
/// </summary>
public abstract partial class NDExpr
{
// ----- Contract (internal API used by the compiler) -----
/// <summary>
/// Emit scalar code. On exit, the evaluation stack must have exactly
/// one value of dtype <c>ctx.OutputType</c>.
/// </summary>
public abstract void EmitScalar(ILGenerator il, NDExprCompileContext ctx);
/// <summary>
/// Emit vector code. On exit, the evaluation stack must have exactly
/// one <c>Vector{W}<T></c> of element type <c>ctx.OutputType</c>.
/// Called only when <see cref="SupportsSimd"/> is true and all input
/// types equal the output type.
/// </summary>
public abstract void EmitVector(ILGenerator il, NDExprCompileContext ctx);
/// <summary>
/// True if this node and its entire sub-tree have a SIMD emit path. Structural and
/// type-independent — prefer <see cref="SupportsSimdAt"/> for the compile decision, since
/// some ops only vectorize at certain element types/runtimes.
/// </summary>
public abstract bool SupportsSimd { get; }
/// <summary>
/// Whether this node and its sub-tree have a SIMD emit path WHEN every operand and the
/// output share element type <paramref name="t"/> (the compiler only vectorizes a tree when
/// all its types are equal — see <see cref="Compile"/>). Defaults to the structural
/// <see cref="SupportsSimd"/>; nodes whose SIMD-ability is type- or runtime-dependent
/// override this. The key case: rounding ops (Floor/Ceil/Round/Truncate) bind a per-type
/// <c>Vector{N}</c> BCL method that exists only for float/double on a capable runtime, so
/// vectorizing them at an integer type (or on a runtime without the method) would hit the
/// emitter's "Could not find ..." throw — this gate routes those to scalar instead.
/// </summary>
public virtual bool SupportsSimdAt(NPTypeCode t) => SupportsSimd;
/// <summary>
/// Stable structural signature. Used to derive a cache key when the
/// user doesn't supply one.
/// </summary>
public abstract void AppendSignature(StringBuilder sb);
// ----- Compilation -----
/// <summary>
/// Compile the tree to an <see cref="NDInnerLoopFunc"/>.
/// </summary>
public NDInnerLoopFunc Compile(
NPTypeCode[] inputTypes, NPTypeCode outputType, string? cacheKey)
{
if (inputTypes is null) throw new ArgumentNullException(nameof(inputTypes));
string key = cacheKey ?? DeriveCacheKey(inputTypes, outputType);
int nIn = inputTypes.Length;
// Type-aware: a tree only vectorizes when every operand and the output share a type
// (AllEqual), so SupportsSimdAt is evaluated at that single shared type. This catches
// ops that vectorize only at certain element types/runtimes (e.g. rounding ops, which
// have no Vector{N} method for integers or on pre-.NET-9 runtimes).
bool wantSimd = AllEqual(inputTypes, outputType) && SupportsSimdAt(outputType);
Action<ILGenerator> scalarBody = il =>
{
// Shell delivers N inputs on stack: stack[bottom]=in0, stack[top]=inN-1.
// Stash each into a local (reverse order since we pop top first).
var scalarLocals = new LocalBuilder[nIn];
for (int i = nIn - 1; i >= 0; i--)
{
scalarLocals[i] = il.DeclareLocal(DirectILKernelGenerator.GetClrType(inputTypes[i]));
il.Emit(OpCodes.Stloc, scalarLocals[i]);
}
var ctx = new NDExprCompileContext(inputTypes, outputType, scalarLocals, vectorMode: false);
EmitScalar(il, ctx);
// Stack now: [result : outputType] — factory stores it.
};
Action<ILGenerator>? vectorBody = null;
if (wantSimd)
{
vectorBody = il =>
{
var vectorLocals = new LocalBuilder[nIn];
// Vector locals take the SIMD LANE type: a bool operand rides the byte lanes
// (Vector{N}<bool> is not a type — declaring it made the loads type-confused).
var vecType = DirectILKernelGenerator.GetVectorType(DirectILKernelGenerator.GetSimdLaneType(inputTypes[0]));
for (int i = nIn - 1; i >= 0; i--)
{
vectorLocals[i] = il.DeclareLocal(vecType);
il.Emit(OpCodes.Stloc, vectorLocals[i]);
}
var ctx = new NDExprCompileContext(inputTypes, outputType, vectorLocals, vectorMode: true);
EmitVector(il, ctx);
};
}
var operandTypes = new NPTypeCode[nIn + 1];
Array.Copy(inputTypes, operandTypes, nIn);
operandTypes[nIn] = outputType;
return DirectILKernelGenerator.CompileInnerLoop(operandTypes, scalarBody, vectorBody, key);
}
private protected string DeriveCacheKey(NPTypeCode[] inputTypes, NPTypeCode outputType)
{
var sb = new StringBuilder("NDExpr:");
AppendSignature(sb);
sb.Append(":in=");
for (int i = 0; i < inputTypes.Length; i++)
{
if (i > 0) sb.Append(',');
sb.Append(inputTypes[i]);
}
sb.Append(":out=").Append(outputType);
return sb.ToString();
}
private static bool AllEqual(NPTypeCode[] inputs, NPTypeCode output)
{
foreach (var t in inputs) if (t != output) return false;
return true;
}
/// <summary>
/// A sub-32-bit integer result lives on the IL stack as an int32 that the arithmetic did
/// NOT wrap (byte 200 + 100 is 300 there; a ROOT node is truncated for free by its stind.i1
/// store). A PARENT node converts the intermediate to a wider dtype, so the wrap must
/// happen at the node or <c>sqrt(add(u8, u8))</c> reads 300 where NumPy reads 44 — the
/// uint8/int8/int16 cells of the evaluate oracle tier. One conv.* opcode; no-op elsewhere.
/// </summary>
internal static void EmitNormalizeNarrowInt(ILGenerator il, NPTypeCode t)
{
switch (t)
{
case NPTypeCode.Byte: il.Emit(OpCodes.Conv_U1); break;
case NPTypeCode.SByte: il.Emit(OpCodes.Conv_I1); break;
case NPTypeCode.Int16: il.Emit(OpCodes.Conv_I2); break;
case NPTypeCode.UInt16:
case NPTypeCode.Char: il.Emit(OpCodes.Conv_U2); break;
}
}
// ===================================================================
// Leaf factories
// ===================================================================
/// <summary>Reference the i-th operand of the iterator (0-based input index).</summary>
public static NDExpr Input(int index) => new InputNode(index);
/// <summary>
/// A literal. NEP50 kind follows the CLR type (see <see cref="NDExprLiteralKind"/>): the
/// numeric primitives, <c>bool</c> and <c>Complex</c> are WEAK Python literals that adopt the
/// dtype of the array they meet (<c>i4 + 2 → i4</c>, <c>f2 + 2.5 → f2</c>, <c>u8 + 2^64-1 → u8</c>);
/// <c>Half</c>, <c>decimal</c> and <c>char</c> are STRONG and promote like a 0-d array of their dtype.
/// </summary>
public static NDExpr Const(double value) => new ConstNode(value);
public static NDExpr Const(float value) => new ConstNode(value);
public static NDExpr Const(long value) => new ConstNode(value);
public static NDExpr Const(int value) => new ConstNode(value);
public static NDExpr Const(uint value) => new ConstNode(value);
public static NDExpr Const(ulong value) => new ConstNode(value);
public static NDExpr Const(bool value) => new ConstNode(value);
public static NDExpr Const(System.Numerics.Complex value) => new ConstNode(value);
public static NDExpr Const(Half value) => new ConstNode(value);
public static NDExpr Const(decimal value) => new ConstNode(value);
public static NDExpr Const(char value) => new ConstNode(value);
// ===================================================================
// Binary factories
// ===================================================================
// Arithmetic
public static NDExpr Add(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.Add, a, b);
public static NDExpr Subtract(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.Subtract, a, b);
public static NDExpr Multiply(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.Multiply, a, b);
public static NDExpr Divide(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.Divide, a, b);
public static NDExpr Mod(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.Mod, a, b);
public static NDExpr Power(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.Power, a, b);
public static NDExpr FloorDivide(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.FloorDivide, a, b);
public static NDExpr ATan2(NDExpr y, NDExpr x) => new BinaryNode(BinaryOp.ATan2, y, x);
// Bitwise
public static NDExpr BitwiseAnd(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.BitwiseAnd, a, b);
public static NDExpr BitwiseOr(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.BitwiseOr, a, b);
public static NDExpr BitwiseXor(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.BitwiseXor, a, b);
// Scalar-branchy combinators compiled to IL
public static NDExpr Min(NDExpr a, NDExpr b) => new MinMaxNode(isMin: true, a, b);
public static NDExpr Max(NDExpr a, NDExpr b) => new MinMaxNode(isMin: false, a, b);
public static NDExpr Clamp(NDExpr x, NDExpr lo, NDExpr hi) => Min(Max(x, lo), hi);
public static NDExpr Where(NDExpr cond, NDExpr a, NDExpr b) => new WhereNode(cond, a, b);
// ===================================================================
// Logical / N-ary selection / clip (Phase 4.3)
//
// LogicalAnd/Or/Xor are a DEDICATED node (LogicalNode), not the 4.2
// mechanical BinaryOp route: their result is always Boolean via a
// NONZERO-TEST of each operand at its OWN dtype (`(a != 0) op (b != 0)`),
// which no engine BinaryOp expresses. Select and Clip are pure LOWERINGS
// over the existing WhereNode / MinMaxNode kernels — no new node.
// ===================================================================
/// <summary>
/// Element-wise logical AND (np.logical_and): the result is ALWAYS Boolean, formed by
/// nonzero-testing each operand at its OWN dtype — <c>(a != 0) & (b != 0)</c> — so it is
/// NOT the bitwise <see cref="BitwiseAnd"/> (<c>&</c>). Every dtype is accepted, complex
/// included (a complex is truthy iff either component is nonzero); a NaN is truthy
/// (<c>NaN != 0</c>), and ±0 is falsy. Vectorizes on a SIMD lane (each operand becomes a
/// truthiness mask, combined by a vector AND).
/// </summary>
/// <param name="a">First operand (nonzero-tested).</param>
/// <param name="b">Second operand (nonzero-tested).</param>
/// <returns>A Boolean-typed expression node computing the logical AND.</returns>
public static NDExpr LogicalAnd(NDExpr a, NDExpr b) => new LogicalNode(LogicalOp.And, a, b);
/// <summary>
/// Element-wise logical OR (np.logical_or) — <c>(a != 0) | (b != 0)</c>, always Boolean. See
/// <see cref="LogicalAnd"/> for the nonzero-test / dtype / NaN semantics; NOT the bitwise
/// <see cref="BitwiseOr"/>.
/// </summary>
/// <param name="a">First operand (nonzero-tested).</param>
/// <param name="b">Second operand (nonzero-tested).</param>
/// <returns>A Boolean-typed expression node computing the logical OR.</returns>
public static NDExpr LogicalOr(NDExpr a, NDExpr b) => new LogicalNode(LogicalOp.Or, a, b);
/// <summary>
/// Element-wise logical XOR (np.logical_xor) — <c>(a != 0) ^ (b != 0)</c>, always Boolean. See
/// <see cref="LogicalAnd"/>; NOT the bitwise <see cref="BitwiseXor"/>.
/// </summary>
/// <param name="a">First operand (nonzero-tested).</param>
/// <param name="b">Second operand (nonzero-tested).</param>
/// <returns>A Boolean-typed expression node computing the logical XOR.</returns>
public static NDExpr LogicalXor(NDExpr a, NDExpr b) => new LogicalNode(LogicalOp.Xor, a, b);
/// <summary>
/// Piecewise selection (np.select): each output element is drawn from the choice whose condition
/// is true, the FIRST matching condition winning; positions where every condition is false take
/// <paramref name="default"/>. Lowered to a reverse <see cref="Where"/> chain
/// (<c>where(c0, v0, where(c1, v1, … where(cN, vN, default)))</c>), so the result dtype is
/// <c>result_type(*choices, default)</c> (the nested where nodes compose the same promotion) and
/// the conditions are nonzero-tested at their own dtype exactly like <see cref="Where"/>'s
/// condition — in practice each is a Boolean comparison, matching np.select's bool condlist.
/// </summary>
/// <param name="condlist">The conditions, outermost (highest priority) first.</param>
/// <param name="choicelist">The choices, aligned with <paramref name="condlist"/>.</param>
/// <param name="default">The fill where no condition is true; <c>null</c> ≙ NumPy's default weak-int <c>0</c>.</param>
/// <returns>An expression node selecting per element from the first true condition's choice.</returns>
/// <exception cref="ArgumentNullException"><paramref name="condlist"/> or <paramref name="choicelist"/> (or an element) is null.</exception>
/// <exception cref="ArgumentException">The lists differ in length (NumPy "list of cases must be same length as list of conditions"), or <paramref name="condlist"/> is empty (NumPy "select with an empty condition list is not possible").</exception>
public static NDExpr Select(NDExpr[] condlist, NDExpr[] choicelist, NDExpr @default = null)
{
if (condlist is null) throw new ArgumentNullException(nameof(condlist));
if (choicelist is null) throw new ArgumentNullException(nameof(choicelist));
// NumPy validation ORDER + verbatim text: length mismatch before emptiness.
if (condlist.Length != choicelist.Length)
throw new ArgumentException("list of cases must be same length as list of conditions");
if (condlist.Length == 0)
throw new ArgumentException("select with an empty condition list is not possible");
// First-true-wins ⇒ fold from the LAST pair inward so condlist[0] is the OUTERMOST where.
NDExpr result = @default ?? Const(0);
for (int i = condlist.Length - 1; i >= 0; i--)
{
var cond = condlist[i] ?? throw new ArgumentNullException(nameof(condlist));
var choice = choicelist[i] ?? throw new ArgumentNullException(nameof(choicelist));
result = new WhereNode(cond, choice, result);
}
return result;
}
/// <summary>
/// Clip (limit) the values of <paramref name="x"/> to a range (np.clip). Either bound may be
/// <c>null</c> (NumPy's <c>None</c>): a one-sided clip lowers to the NaN-propagating
/// <see cref="Max"/>/<see cref="Min"/> (<c>np.clip(x, lo, None) ≡ np.maximum(x, lo)</c>,
/// <c>np.clip(x, None, hi) ≡ np.minimum(x, hi)</c>), and a two-sided clip to the exact general clip ufunc
/// <c>_NPY_MIN(_NPY_MAX(x, lo), hi)</c>: two min/max nodes flagged as clip halves, which equal
/// <see cref="Clamp"/> (<c>Min(Max(x, lo), hi)</c>) for every dtype but complex128, where clip's selection is
/// strict (a tie takes the bound) and tests only the running value for NaN. Result dtype is
/// <c>result_type(x, lo, hi)</c>.
/// </summary>
/// <param name="x">The values to clip.</param>
/// <param name="lo">Lower bound, or <c>null</c> for no lower bound.</param>
/// <param name="hi">Upper bound, or <c>null</c> for no upper bound.</param>
/// <returns>An expression node clipping <paramref name="x"/> to <c>[lo, hi]</c>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="x"/> is null.</exception>
/// <exception cref="ArgumentException">Both bounds are null (NumPy "One of max or min must be given").</exception>
/// <remarks>
/// Bit-exact with NumPy 2.4.2 for one-sided clips and for two-sided clips with ARRAY bounds. A
/// two-sided clip with SCALAR (0-d) bounds differs from NumPy ONLY in the pathological
/// signed-zero-exactly-at-a-finite-bound corner (e.g. <c>clip(-0.0, -inf, +0.0)</c>): NumPy's
/// const-scalar fast path returns <c>-0.0</c> while the general min(max) path — which NumSharp
/// always takes — returns <c>+0.0</c>; NaN-sign-of-bound corners are float-NaN differences the
/// oracle tokenizes.
/// </remarks>
public static NDExpr Clip(NDExpr x, NDExpr lo, NDExpr hi)
{
if (x is null) throw new ArgumentNullException(nameof(x));
if (lo is null && hi is null)
throw new ArgumentException("One of max or min must be given");
if (lo is null) return Min(x, hi); // np.clip(x, None, hi) ≡ np.minimum(x, hi)
if (hi is null) return Max(x, lo); // np.clip(x, lo, None) ≡ np.maximum(x, lo)
// _NPY_MIN(_NPY_MAX(x, lo), hi): um.clip's own halves, not the maximum / minimum ufuncs (they differ for
// complex128: clip.cpp's strict CGT / CLT against loops.c.src's CGE / CLE).
return new MinMaxNode(isMin: true,
new MinMaxNode(isMin: false, x, lo, clipSelection: true),
hi, clipSelection: true);
}
// ===================================================================
// Elementwise binary ufunc family (Phase 4 coverage) — every node here
// rides the shared BinaryNode kernel; the scalar emit is the SAME per-op
// emitter the engine's own ufuncs use (DirectILKernelGenerator.
// EmitScalarOperation), so results are byte-for-byte the unfused chain.
// ===================================================================
/// <summary>
/// NaN-PROPAGATING element-wise maximum (np.maximum) — an exact alias of <see cref="Max"/>.
/// A NaN operand WINS (the result is NaN if either input is NaN); contrast <see cref="FMax"/>,
/// which ignores NaN. Provided so <c>np.maximum</c> ports verbatim; identical node, cost and
/// dtype to <see cref="Max"/> (plain result_type promotion, complex compares lexicographically).
/// </summary>
/// <param name="a">First operand.</param>
/// <param name="b">Second operand.</param>
/// <returns>An expression node computing the NaN-propagating maximum of <paramref name="a"/> and <paramref name="b"/>.</returns>
public static NDExpr Maximum(NDExpr a, NDExpr b) => Max(a, b);
/// <summary>
/// NaN-PROPAGATING element-wise minimum (np.minimum) — an exact alias of <see cref="Min"/>.
/// A NaN operand WINS; contrast <see cref="FMin"/>. See <see cref="Maximum"/>.
/// </summary>
/// <param name="a">First operand.</param>
/// <param name="b">Second operand.</param>
/// <returns>An expression node computing the NaN-propagating minimum of <paramref name="a"/> and <paramref name="b"/>.</returns>
public static NDExpr Minimum(NDExpr a, NDExpr b) => Min(a, b);
/// <summary>
/// NaN-IGNORING element-wise maximum (np.fmax): when exactly one operand is NaN the OTHER
/// (the number) is returned — the opposite of <see cref="Maximum"/>/<see cref="Max"/>, which
/// propagate NaN. Dtype-preserving (plain result_type promotion, like maximum). Scalar-only in
/// the fused kernel (no SIMD lane), so a tree containing it runs scalar end-to-end.
/// </summary>
/// <param name="a">First operand.</param>
/// <param name="b">Second operand.</param>
/// <returns>An expression node computing the NaN-ignoring maximum.</returns>
public static NDExpr FMax(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.FMax, a, b);
/// <summary>
/// NaN-IGNORING element-wise minimum (np.fmin): the non-NaN operand wins. See <see cref="FMax"/>.
/// </summary>
/// <param name="a">First operand.</param>
/// <param name="b">Second operand.</param>
/// <returns>An expression node computing the NaN-ignoring minimum.</returns>
public static NDExpr FMin(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.FMin, a, b);
/// <summary>
/// C-style floating remainder (np.fmod): the result takes the sign of the DIVIDEND
/// (<paramref name="a"/>), unlike <see cref="Mod"/> which is floored and takes the divisor's
/// sign — so <c>Fmod(-7,3) == -1</c> where <c>Mod(-7,3) == 2</c>. Promotion follows Mod:
/// integer stays integer, a bool pair falls to the int8 loop; there is NO complex loop, so a
/// complex operand throws a no-loop <see cref="NotSupportedException"/> at typing time.
/// </summary>
/// <param name="a">Dividend (its sign is the result's sign).</param>
/// <param name="b">Divisor.</param>
/// <returns>An expression node computing the truncated remainder.</returns>
public static NDExpr Fmod(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.Fmod, a, b);
/// <summary>
/// Magnitude of <paramref name="a"/> with the sign of <paramref name="b"/> (np.copysign) — an
/// EXACT sign-bit copy, so <c>CopySign(3, -0.0)</c> is <c>-3</c> and the sign of ±0/±NaN is
/// honoured bit-for-bit. Float-only ufunc: bool/int operands PROMOTE to their tier float
/// (bool/i8/u8→f16, i16/u16→f32, i32+→f64, chosen per input like arctan2), and a complex
/// operand has no loop (throws at typing time).
/// </summary>
/// <param name="a">The value whose magnitude is kept.</param>
/// <param name="b">The value whose sign is copied.</param>
/// <returns>An expression node computing the copysign.</returns>
public static NDExpr CopySign(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.CopySign, a, b);
/// <summary>
/// The next representable value after <paramref name="a"/> toward <paramref name="b"/>
/// (np.nextafter) — an EXACT one-ULP bit step (bit-exact with NumPy). Same float-tier promotion
/// as <see cref="CopySign"/>; complex has no loop (throws at typing time).
/// </summary>
/// <param name="a">Start value.</param>
/// <param name="b">Direction to step toward.</param>
/// <returns>An expression node computing nextafter.</returns>
public static NDExpr NextAfter(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.NextAfter, a, b);
/// <summary>
/// Numerically stable <c>log(exp(a)+exp(b))</c> (np.logaddexp). Same float-tier promotion as
/// <see cref="CopySign"/> (complex has no loop). NOT bit-exact with NumPy — the managed fdlibm
/// <c>log1p</c> it composes over differs ≤2 ULP from NumPy's closed UCRT <c>log1p</c> (the same
/// documented ~ULP envelope as the unary transcendentals), so it is gated within 2 ULP, not
/// byte-for-byte.
/// </summary>
/// <param name="a">First log-domain value.</param>
/// <param name="b">Second log-domain value.</param>
/// <returns>An expression node computing logaddexp.</returns>
public static NDExpr LogAddExp(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.LogAddExp, a, b);
/// <summary>
/// Base-2 stable log-sum-exp <c>log2(2**a + 2**b)</c> (np.logaddexp2). See
/// <see cref="LogAddExp"/> — ≤2 ULP vs NumPy (the extra LOG2E product), not bit-exact.
/// </summary>
/// <param name="a">First value.</param>
/// <param name="b">Second value.</param>
/// <returns>An expression node computing logaddexp2.</returns>
public static NDExpr LogAddExp2(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.LogAddExp2, a, b);
/// <summary>
/// <c>sqrt(a**2 + b**2)</c> without spurious overflow/underflow (np.hypot). Same float-tier
/// promotion as <see cref="CopySign"/> (complex has no loop). Bit-exact with NumPy at every float
/// dtype: the node runs np.hypot's own kernel, UCRT's faithfully rounded <c>sqrt(a*a + b*b)</c> with
/// its power-of-two rescale (parity QA-25), so float64 matches NumPy byte for byte too.
/// </summary>
/// <param name="a">First leg.</param>
/// <param name="b">Second leg.</param>
/// <returns>An expression node computing the hypotenuse.</returns>
public static NDExpr Hypot(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.Hypot, a, b);
/// <summary>
/// The Heaviside step function (np.heaviside): <c>0</c> if <paramref name="x"/><0,
/// <paramref name="h0"/> if <paramref name="x"/>==0, <c>1</c> if <paramref name="x"/>>0, and
/// the positive canonical NaN if <paramref name="x"/> is NaN. NOT commutative — <paramref name="x"/>
/// selects the branch and <paramref name="h0"/> is ONLY the exact-zero fill (its bits, NaN sign
/// included, pass through). Same float-tier promotion as <see cref="CopySign"/>; complex has no
/// loop. Bit-exact with NumPy at every supported dtype.
/// </summary>
/// <param name="x">The value whose sign selects the step branch.</param>
/// <param name="h0">The fill returned where <paramref name="x"/> is exactly zero.</param>
/// <returns>An expression node computing the Heaviside step.</returns>
public static NDExpr Heaviside(NDExpr x, NDExpr h0) => new BinaryNode(BinaryOp.Heaviside, x, h0);
/// <summary>
/// Greatest common divisor of |a| and |b| (np.gcd). INTEGER-ONLY: valid only when the two
/// operands promote to a real integer dtype (a bool paired with an integer is fine, but a
/// bool/bool pair, any float/complex/decimal, and the uint64+signed pair — which promotes to
/// float64 — all have NO loop and throw a "did not contain a loop" <see cref="NotSupportedException"/>
/// at typing time). The result can be NEGATIVE where the magnitude wraps the signed range
/// (<c>Gcd(int8 -128, -128) == -128</c>); <c>Gcd(0,0) == 0</c>. Scalar-only in the fused kernel.
/// </summary>
/// <param name="a">First operand.</param>
/// <param name="b">Second operand.</param>
/// <returns>An expression node computing the GCD.</returns>
public static NDExpr Gcd(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.Gcd, a, b);
/// <summary>
/// Lowest common multiple of |a| and |b| (np.lcm): 0 if either is 0, else <c>|a|/gcd*|b|</c>
/// (divide-before-multiply, so the product WRAPS the dtype on overflow, matching NumPy).
/// Same INTEGER-ONLY loop coverage / no-loop errors as <see cref="Gcd"/>.
/// </summary>
/// <param name="a">First operand.</param>
/// <param name="b">Second operand.</param>
/// <returns>An expression node computing the LCM.</returns>
public static NDExpr Lcm(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.Lcm, a, b);
/// <summary>
/// Bit shift left (np.left_shift). Integer loops only: a bool pair falls to the int8 loop, an
/// integer pair keeps its promoted integer dtype, and float/complex — plus the uint64+signed
/// pair (which promotes to float64) — have NO loop and throw a "not supported for the input
/// types" <see cref="NotSupportedException"/> at typing time. A shift count outside
/// <c>[0, bitwidth)</c> follows NumPy's overflow rule at the result width. Scalar-only.
/// </summary>
/// <param name="a">The value to shift.</param>
/// <param name="b">The shift count.</param>
/// <returns>An expression node computing the left shift.</returns>
public static NDExpr LeftShift(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.LeftShift, a, b);
/// <summary>
/// Bit shift right (np.right_shift) — arithmetic for signed, logical for unsigned. Same integer
/// loop coverage / no-loop errors and overflow rule as <see cref="LeftShift"/>.
/// </summary>
/// <param name="a">The value to shift.</param>
/// <param name="b">The shift count.</param>
/// <returns>An expression node computing the right shift.</returns>
public static NDExpr RightShift(NDExpr a, NDExpr b) => new BinaryNode(BinaryOp.RightShift, a, b);
// ===================================================================
// Unary factories
// ===================================================================
// Core arithmetic
public static NDExpr Sqrt(NDExpr x) => new UnaryNode(UnaryOp.Sqrt, x);
public static NDExpr Abs(NDExpr x) => new UnaryNode(UnaryOp.Abs, x);
public static NDExpr Negate(NDExpr x) => new UnaryNode(UnaryOp.Negate, x);
public static NDExpr Square(NDExpr x) => new UnaryNode(UnaryOp.Square, x);
public static NDExpr Reciprocal(NDExpr x) => new UnaryNode(UnaryOp.Reciprocal, x);
public static NDExpr Sign(NDExpr x) => new UnaryNode(UnaryOp.Sign, x);
public static NDExpr Cbrt(NDExpr x) => new UnaryNode(UnaryOp.Cbrt, x);
// Exp / Log family
public static NDExpr Exp(NDExpr x) => new UnaryNode(UnaryOp.Exp, x);
public static NDExpr Exp2(NDExpr x) => new UnaryNode(UnaryOp.Exp2, x);
public static NDExpr Expm1(NDExpr x) => new UnaryNode(UnaryOp.Expm1, x);
public static NDExpr Log(NDExpr x) => new UnaryNode(UnaryOp.Log, x);
public static NDExpr Log2(NDExpr x) => new UnaryNode(UnaryOp.Log2, x);
public static NDExpr Log10(NDExpr x) => new UnaryNode(UnaryOp.Log10, x);
public static NDExpr Log1p(NDExpr x) => new UnaryNode(UnaryOp.Log1p, x);
// Trigonometric
public static NDExpr Sin(NDExpr x) => new UnaryNode(UnaryOp.Sin, x);
public static NDExpr Cos(NDExpr x) => new UnaryNode(UnaryOp.Cos, x);
public static NDExpr Tan(NDExpr x) => new UnaryNode(UnaryOp.Tan, x);
public static NDExpr Sinh(NDExpr x) => new UnaryNode(UnaryOp.Sinh, x);
public static NDExpr Cosh(NDExpr x) => new UnaryNode(UnaryOp.Cosh, x);
public static NDExpr Tanh(NDExpr x) => new UnaryNode(UnaryOp.Tanh, x);
public static NDExpr ASin(NDExpr x) => new UnaryNode(UnaryOp.ASin, x);
public static NDExpr ACos(NDExpr x) => new UnaryNode(UnaryOp.ACos, x);
public static NDExpr ATan(NDExpr x) => new UnaryNode(UnaryOp.ATan, x);
public static NDExpr Asinh(NDExpr x) => new UnaryNode(UnaryOp.Asinh, x);
public static NDExpr Acosh(NDExpr x) => new UnaryNode(UnaryOp.Acosh, x);
public static NDExpr Atanh(NDExpr x) => new UnaryNode(UnaryOp.Atanh, x);
public static NDExpr Deg2Rad(NDExpr x) => new UnaryNode(UnaryOp.Deg2Rad, x);
public static NDExpr Rad2Deg(NDExpr x) => new UnaryNode(UnaryOp.Rad2Deg, x);
// Rounding
public static NDExpr Floor(NDExpr x) => new UnaryNode(UnaryOp.Floor, x);
public static NDExpr Ceil(NDExpr x) => new UnaryNode(UnaryOp.Ceil, x);
public static NDExpr Round(NDExpr x) => new UnaryNode(UnaryOp.Round, x);
/// <summary>
/// Round to <paramref name="decimals"/> decimal places (np.round(x, decimals) / np.around) — a
/// DTYPE-PRESERVING port of NumPy's <c>PyArray_Round</c>. For <c>decimals == 0</c> this is exactly
/// <see cref="Round(NDExpr)"/> (banker's rint); for <c>decimals != 0</c> it composes
/// <c>op2(rint(op1(x, 10^|decimals|)), 10^|decimals|)</c> (mul→div for positive, div→mul for
/// negative). Every dtype behaves as NumPy 2.4.2 does:
/// <list type="bullet">
/// <item>float / complex: computed at the input's own float dtype (complex rounds each lane), dtype PRESERVED;</item>
/// <item>INTEGER, <c>decimals ≥ 0</c>: the IDENTITY (an integer has no fractional part — no float round-trip, so a huge int64 is untouched);</item>
/// <item>INTEGER, <c>decimals < 0</c>: the round runs in float64 and is CAST BACK to the integer dtype, so an out-of-range result WRAPS (e.g. <c>round(uint8 255, -1)</c> → 4);</item>
/// <item>BOOL with <c>decimals != 0</c>: THROWS NumPy's <c>UFuncTypeError</c> "Cannot cast ufunc 'multiply'/'divide' output from dtype('float64') to dtype('bool')…" (the multiply/divide output cannot cast back to bool).</item>
/// </list>
/// Scalar-only in the fused kernel (the multi-step composition's SIMD form is deferred).
/// </summary>
/// <param name="x">The operand to round.</param>
/// <param name="decimals">The number of decimal places (negative rounds to tens/hundreds/…).</param>
/// <returns>An expression node computing <paramref name="x"/> rounded to <paramref name="decimals"/> places, dtype preserved.</returns>
public static NDExpr Round(NDExpr x, int decimals)
=> decimals == 0 ? new UnaryNode(UnaryOp.Round, x) : new RoundNode(x, decimals);
/// <summary>
/// Round to the nearest integer, half-to-even (np.rint) — the TRUE ufunc form of round-half-to-even.
/// The VALUE is identical to <see cref="Round"/> (both are banker's rounding, and Rint aliases Round
/// at every kernel emit site), but the DTYPE differs: where <see cref="Round"/> PRESERVES the input
/// dtype (an integer array rounds to itself unchanged), <c>Rint</c> PROMOTES to a float tier
/// (bool/int8/uint8→float16, int16/uint16→float32, int32+→float64; float/complex/decimal preserved) —
/// so <c>Rint(int32)</c> is a float64 while <c>Round(int32)</c> is the int32 identity. Complex rounds
/// the real and imaginary parts separately. Vectorizes at float32/float64 on a capable runtime, exactly
/// like <see cref="Round"/>.
/// </summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node computing <paramref name="x"/> rounded half-to-even, at the promoted float tier.</returns>
public static NDExpr Rint(NDExpr x) => new UnaryNode(UnaryOp.Rint, x);
public static NDExpr Truncate(NDExpr x) => new UnaryNode(UnaryOp.Truncate, x);
// Bitwise / logical
public static NDExpr BitwiseNot(NDExpr x) => new UnaryNode(UnaryOp.BitwiseNot, x);
public static NDExpr LogicalNot(NDExpr x) => new UnaryNode(UnaryOp.LogicalNot, x);
// Predicates (returns numeric 0/1 at output dtype — NumPy-compatible)
public static NDExpr IsNaN(NDExpr x) => new UnaryNode(UnaryOp.IsNan, x);
public static NDExpr IsFinite(NDExpr x) => new UnaryNode(UnaryOp.IsFinite, x);
public static NDExpr IsInf(NDExpr x) => new UnaryNode(UnaryOp.IsInf, x);
// ===================================================================
// Elementwise unary ufunc family (Phase 4 coverage) — every node here
// rides the shared UnaryNode kernel; the scalar/vector emit is the SAME
// per-op emitter the engine's own ufuncs use (DirectILKernelGenerator.
// EmitUnary{Scalar,Vector}Operation), so a fused unary node is
// byte-for-byte the unfused chain. Real/Imag/Angle/Rint/Round(decimals)/
// Cast (the dtype-CHANGING unary set) are a separate follow-up.
// ===================================================================
/// <summary>
/// Identity at every numeric dtype (np.positive). The loaded value IS the result — a fused
/// tree wrapping an operand in <see cref="Positive"/> compiles to a pure copy (and vectorizes
/// as one). Its only observable effect is dtype: it is dtype-PRESERVING, so unlike a bare
/// operand it forces the result to that operand's dtype. **BOOL has no loop** — NumPy rejects
/// <c>np.positive</c> on a boolean array (there is no <c>dtype=</c> escape in the fused tier),
/// so a bool child throws at typing.
/// </summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node evaluating to <paramref name="x"/> unchanged (dtype preserved).</returns>
public static NDExpr Positive(NDExpr x) => new UnaryNode(UnaryOp.Positive, x);
/// <summary>
/// Complex conjugate (np.conjugate). For Complex it flips the sign of the imaginary part; for
/// every REAL float/int dtype it is the identity (dtype preserved) — with ONE NumPy quirk: a
/// <b>bool</b> child promotes to <b>int8</b> (probed 2.4.2: <c>np.conjugate(bool_).dtype ==
/// int8</c>), it does not stay bool. Scalar-only in the fused kernel (the complex path has no
/// SIMD lane), so a tree containing it runs scalar end-to-end.
/// </summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node computing the element-wise complex conjugate of <paramref name="x"/>.</returns>
public static NDExpr Conjugate(NDExpr x) => new UnaryNode(UnaryOp.Conjugate, x);
/// <summary>NumPy alias of <see cref="Conjugate"/> (np.conj). Identical node, cost and dtype.</summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node computing the element-wise complex conjugate of <paramref name="x"/>.</returns>
public static NDExpr Conj(NDExpr x) => new UnaryNode(UnaryOp.Conjugate, x);
/// <summary>
/// Float-only absolute value (np.fabs). Differs from <see cref="Abs"/> in two dtype-level ways:
/// it PROMOTES bool/int to the tier float (bool/int8/uint8→float16, int16/uint16→float32,
/// int32+→float64; floats/decimal preserved) — so <c>Fabs(int)</c> is always a float where
/// <c>Abs(int)</c> preserves int — and it has NO COMPLEX loop (a complex child throws at typing
/// with NumPy's coercion TypeError). The operation itself is <see cref="Abs"/>'s (clear the IEEE
/// sign bit), applied AFTER the int→float promotion, so <c>Fabs(int.MinValue)</c> is the exact
/// float magnitude, never the wrapped integer abs.
/// </summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node computing the float-valued absolute value of <paramref name="x"/>.</returns>
public static NDExpr Fabs(NDExpr x) => new UnaryNode(UnaryOp.Fabs, x);
/// <summary>
/// Distance to the adjacent representable value away from zero — one ULP (np.spacing). A
/// float-only ufunc: it PROMOTES bool/int to the tier float (like <see cref="Fabs"/>), preserves
/// floats/decimal, and has NO COMPLEX loop (a complex child throws at typing). float32/float64
/// are SIGNED (carry the sign of x); float16 is NumPy's separate always-positive
/// <c>npy_half_spacing</c>. Pure bit-arithmetic (no transcendental), so it is IEEE-exact and
/// portable — no host-libm dependence.
/// </summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node computing the one-ULP spacing at each element of <paramref name="x"/>.</returns>
public static NDExpr Spacing(NDExpr x) => new UnaryNode(UnaryOp.Spacing, x);
/// <summary>
/// IEEE sign-bit predicate (np.signbit) — result is always <b>bool</b>. NOT <c>x < 0</c>: it
/// reads the raw sign bit, so <c>-0.0</c> and a NEGATIVE NaN are True while <c>+0.0</c>, <c>+inf</c>
/// and a positive NaN are False. Signed integers use the two's-complement MSB (<c>x < 0</c>);
/// unsigned/bool/char are always False; COMPLEX has no loop (a complex child throws at typing).
/// Scalar-only in the fused kernel (bool result, no vector body here), so a tree containing it
/// runs scalar end-to-end.
/// </summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node evaluating True where <paramref name="x"/>'s sign bit is set.</returns>
public static NDExpr SignBit(NDExpr x) => new UnaryNode(UnaryOp.SignBit, x);
/// <summary>
/// Test for positive infinity (np.isposinf) — result is <b>bool</b>, True only where <c>x == +inf</c>.
/// Integer/bool inputs are all-False (no infinity). A COMPLEX child throws at typing with NumPy's
/// ambiguity TypeError ("…not supported for complex128 values because it would be ambiguous.") —
/// a DIFFERENT message from the ufunc no-loop family, because isposinf is a FUNCTION, not a ufunc.
/// Scalar-only in the fused kernel.
/// </summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node evaluating True where <paramref name="x"/> is positive infinity.</returns>
public static NDExpr IsPosInf(NDExpr x) => new UnaryNode(UnaryOp.IsPosInf, x);
/// <summary>
/// Test for negative infinity (np.isneginf) — result is <b>bool</b>, True only where <c>x == -inf</c>.
/// See <see cref="IsPosInf"/> for the integer/bool (all-False) and complex (ambiguity TypeError)
/// behavior. Scalar-only in the fused kernel.
/// </summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node evaluating True where <paramref name="x"/> is negative infinity.</returns>
public static NDExpr IsNegInf(NDExpr x) => new UnaryNode(UnaryOp.IsNegInf, x);
/// <summary>
/// Population count of the absolute value — the number of set bits in <c>|x|</c> (np.bitwise_count).
/// INTEGER/BOOL ONLY: every integer/bool/char dtype maps to a <b>uint8</b> result, while
/// float/float16/complex/decimal have NO loop and throw at typing. It CONSUMES the input dtype
/// (a wide value is counted at its own width, never truncated to the uint8 output first) and a
/// signed negative counts the magnitude (<c>bitwise_count(-1) == 1</c>, not 8). Scalar-only in
/// the fused kernel.
/// </summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node computing the per-element set-bit count of <c>|x|</c> as uint8.</returns>
public static NDExpr BitwiseCount(NDExpr x) => new UnaryNode(UnaryOp.BitwiseCount, x);
/// <summary>
/// Real part (np.real) — the complex→real component extractor. A COMPLEX child yields its real
/// lane as <b>float64</b>; every REAL child is the IDENTITY with its dtype PRESERVED (the real
/// part of a real number is itself), so <c>Real(int32)</c> is int32, not a float. Unlike the
/// standalone <c>np.real</c> (which returns a writeable VIEW onto a complex array's real lane),
/// the fused node produces a fresh value stream — a read, not an alias. Scalar-only in the fused
/// kernel (the complex path has no SIMD lane), so a tree containing it runs scalar end-to-end.
/// </summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node computing the element-wise real part of <paramref name="x"/>.</returns>
public static NDExpr Real(NDExpr x) => new UnaryNode(UnaryOp.Real, x);
/// <summary>
/// Imaginary part (np.imag) — the complex→real component extractor. A COMPLEX child yields its
/// imaginary lane as <b>float64</b>; every REAL child yields <b>zero</b> with its dtype PRESERVED
/// (the imaginary part of a real number is zero, so <c>Imag(int32)</c> is int32 zeros) — and the
/// child's VALUE is not read on that path (the result does not depend on it), matching np.imag's
/// <c>zeros_like</c>. Scalar-only in the fused kernel.
/// </summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node computing the element-wise imaginary part of <paramref name="x"/>.</returns>
public static NDExpr Imag(NDExpr x) => new UnaryNode(UnaryOp.Imag, x);
/// <summary>
/// Phase angle in radians (np.angle) — the counterclockwise angle from the positive real axis in
/// <c>(-pi, pi]</c>. A COMPLEX child yields <c>atan2(imag, real)</c> as <b>float64</b>; a REAL
/// child yields <c>atan2(0, x)</c> — <c>0</c> for x ≥ 0, <c>pi</c> for x < 0, <c>NaN</c> for
/// NaN — at NumPy's per-dtype float tier (bool/int32+/f64→f64, int8/uint8/f16→f16,
/// int16/uint16/char/f32→f32). <b>Radians only</b>: the fused primitive has no <c>deg=</c> — a
/// caller wanting degrees composes <c>Angle(x) * (180/pi)</c>. Uses the host <c>atan2</c>, so it
/// is bit-exact vs NumPy only within the host-pinned evaluate tier (win-amd64 shares MSVC
/// <c>ucrtbase</c>). Scalar-only in the fused kernel.
/// </summary>
/// <param name="x">The operand.</param>
/// <returns>An expression node computing the element-wise phase angle of <paramref name="x"/> in radians.</returns>
public static NDExpr Angle(NDExpr x) => new UnaryNode(UnaryOp.Angle, x);
/// <summary>
/// Convert to <paramref name="dtype"/> (np.ndarray.astype with the default <c>casting='unsafe'</c>) —
/// the fused analog of <c>expr.astype(dtype)</c>. Computes <paramref name="x"/> at its own dtype,
/// then converts each element to <paramref name="dtype"/> with the SAME per-element conversion the
/// engine's own casts use, so the RESULT dtype is exactly <paramref name="dtype"/> regardless of the
/// child. Every conversion is allowed (float→int TRUNCATES toward zero, over/underflow WRAPS, a
/// complex→real cast drops the imaginary part), matching NumPy's default-unsafe astype. Cast is the
/// building block for <c>Round(decimals)</c> (which must cast a float round back to an integer dtype).
/// </summary>
/// <remarks>
/// <para>
/// One overload takes every dtype spelling through <see cref="DType"/>'s conversions: an
/// <see cref="NPTypeCode"/> (<c>NDExpr.Cast(x, NPTypeCode.Single)</c>), a <see cref="System.Type"/>
/// (<c>NDExpr.Cast(x, typeof(float))</c>), a dtype string (<c>"float32"</c>, <c>"U5"</c>, <c>"S3"</c>) or a
/// <see cref="DType"/> itself (<c>np.dtypes.StringDType()</c>). A TEXT target is a whole descriptor — the class
/// (<c>S</c>, <c>U</c>, StringDType) and the width or NA object — which the storage lane
/// <see cref="NPTypeCode.String"/> alone does not carry, so <c>NPTypeCode.String</c> is refused by the conversion
/// itself (<see cref="NotSupportedException"/>) and a text cast names its descriptor.
/// </para>
/// <para>
/// A cast whose operand or target is TEXT never enters the fused kernel: it is NumPy's own
/// <c>x.astype(dtype)</c> run as a stage (docs/plans/evaluate-text.md) — a number formatted as text
/// (<c>int64 → <U21</c>, a narrower width cuts it), text parsed into a number (NumPy's <c>int(str)</c> /
/// <c>float(str)</c>, its errors included) or text recast between classes and widths.
/// </para>
/// <para>
/// <b>Scalar-only in the fused kernel</b> for now: a tree containing a numeric <c>Cast</c> runs scalar
/// end-to-end (the SIMD-widening cast lanes are Phase 5). <b>Parity envelope:</b> bit-exact vs
/// NumPy's <c>astype</c> for IN-RANGE, non-NaN conversions; the C-undefined edges (NaN/±inf/
/// out-of-range → integer, complex→real) inherit the engine's own documented cast behaviour
/// (host-dependent, the <c>astype_full</c> class), not a distinct guarantee.
/// </para>
/// </remarks>
/// <param name="x">The operand to convert.</param>
/// <param name="dtype">The target dtype: any of the 15 NumSharp types, or a text descriptor (<c>S</c> / <c>U</c> of a
/// width, or StringDType with its NA object).</param>
/// <returns>An expression node evaluating <paramref name="x"/> converted to <paramref name="dtype"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="x"/> or <paramref name="dtype"/> is null.</exception>
/// <exception cref="NotSupportedException">The dtype was spelled as <see cref="NPTypeCode.String"/>, a storage lane
/// without a text class or width (raised by the conversion to <see cref="DType"/>).</exception>
public static NDExpr Cast(NDExpr x, DType dtype)
=> new CastNode(x, dtype ?? throw new ArgumentNullException(nameof(dtype)));
// ===================================================================
// Comparison factories (produce 0/1 at output dtype)
// ===================================================================
public static NDExpr Equal(NDExpr a, NDExpr b) => new ComparisonNode(ComparisonOp.Equal, a, b);
public static NDExpr NotEqual(NDExpr a, NDExpr b) => new ComparisonNode(ComparisonOp.NotEqual, a, b);
public static NDExpr Less(NDExpr a, NDExpr b) => new ComparisonNode(ComparisonOp.Less, a, b);
public static NDExpr LessEqual(NDExpr a, NDExpr b) => new ComparisonNode(ComparisonOp.LessEqual, a, b);
public static NDExpr Greater(NDExpr a, NDExpr b) => new ComparisonNode(ComparisonOp.Greater, a, b);
public static NDExpr GreaterEqual(NDExpr a, NDExpr b) => new ComparisonNode(ComparisonOp.GreaterEqual, a, b);
// ===================================================================
// Call — invoke an arbitrary .NET delegate or MethodInfo per element.
// ===================================================================
//
// Three entry points:
// (a) Typed Func<...> overloads — allow passing method groups
// (e.g. `Math.Sqrt`, `Math.Pow`) without an explicit cast.
// C# overload resolution picks these when the compiler can infer
// the delegate signature from the method group.
//
// (b) `Call(Delegate func, params NDExpr[] args)` — catch-all for
// any pre-constructed delegate. Method groups will NOT bind to
// this directly (the C# compiler needs a specific delegate
// target type). Cast or use a typed Func<...> overload.
//
// (c) `Call(MethodInfo, ...)` and `Call(MethodInfo, object target, ...)`
// — bypass the delegate layer entirely. Static and instance methods
// respectively. Useful when reflecting over types at runtime.
//
// Implementation notes:
// * Static methods with no target are emitted as a direct `call`
// opcode to the underlying `MethodInfo` — no indirection.
// * Instance methods or delegates with captured state are stored in a
// process-wide slot dictionary (`DelegateSlots`). The emitted IL
// loads the delegate via an integer ID and invokes it through
// `Delegate.Invoke` (callvirt).
// * SIMD is always disabled for trees containing a CallNode.
// * Argument values are auto-converted from `ctx.OutputType` to each
// parameter's dtype; the return value is converted back to
// `ctx.OutputType` before leaving the node.
/// <summary>Invoke a static method (no target).</summary>
public static NDExpr Call(System.Reflection.MethodInfo method, params NDExpr[] args)
=> new CallNode(method, target: null, args);
/// <summary>Invoke an instance method on a target object.</summary>
public static NDExpr Call(System.Reflection.MethodInfo method, object target, params NDExpr[] args)
=> new CallNode(method, target, args);
/// <summary>Invoke any delegate. Method-group arguments need a typed Func overload; use a cast or the typed overloads below.</summary>
public static NDExpr Call(Delegate func, params NDExpr[] args)
=> new CallNode(func, args);
// Typed Func<...> overloads — enable `NDExpr.Call(Math.Sqrt, x)` without cast.
public static NDExpr Call<TR>(Func<TR> func)
=> new CallNode(func, Array.Empty<NDExpr>());
public static NDExpr Call<T1, TR>(Func<T1, TR> func, NDExpr a1)
=> new CallNode(func, new[] { a1 });
public static NDExpr Call<T1, T2, TR>(Func<T1, T2, TR> func, NDExpr a1, NDExpr a2)
=> new CallNode(func, new[] { a1, a2 });
public static NDExpr Call<T1, T2, T3, TR>(Func<T1, T2, T3, TR> func, NDExpr a1, NDExpr a2, NDExpr a3)
=> new CallNode(func, new[] { a1, a2, a3 });
public static NDExpr Call<T1, T2, T3, T4, TR>(Func<T1, T2, T3, T4, TR> func, NDExpr a1, NDExpr a2, NDExpr a3, NDExpr a4)
=> new CallNode(func, new[] { a1, a2, a3, a4 });
// ===================================================================
// Operator overloads (syntactic sugar)
// ===================================================================
public static NDExpr operator +(NDExpr a, NDExpr b) => Add(a, b);
public static NDExpr operator -(NDExpr a, NDExpr b) => Subtract(a, b);
public static NDExpr operator *(NDExpr a, NDExpr b) => Multiply(a, b);
public static NDExpr operator /(NDExpr a, NDExpr b) => Divide(a, b);
public static NDExpr operator %(NDExpr a, NDExpr b) => Mod(a, b);
public static NDExpr operator &(NDExpr a, NDExpr b) => BitwiseAnd(a, b);
public static NDExpr operator |(NDExpr a, NDExpr b) => BitwiseOr(a, b);
public static NDExpr operator ^(NDExpr a, NDExpr b) => BitwiseXor(a, b);
public static NDExpr operator -(NDExpr a) => Negate(a);
public static NDExpr operator ~(NDExpr a) => BitwiseNot(a);
public static NDExpr operator !(NDExpr a) => LogicalNot(a);
// Comparison operators (Phase 4.4). These build a Boolean-typed ComparisonNode and RETURN an
// NDExpr (not a bool) — the numexpr / expression-builder convention, so `a < b` composes into a
// larger tree. C# requires `<`/`>` and `<=`/`>=` in pairs. `==`/`!=` are deliberately NOT
// overloaded (they would hijack `expr == null` reference checks); use Equal/NotEqual instead.
// An NDArray or scalar operand converts implicitly to NDExpr, so `expr < 0.5` and `arr < expr`
// both bind here.
public static NDExpr operator <(NDExpr a, NDExpr b) => Less(a, b);
public static NDExpr operator >(NDExpr a, NDExpr b) => Greater(a, b);
public static NDExpr operator <=(NDExpr a, NDExpr b) => LessEqual(a, b);
public static NDExpr operator >=(NDExpr a, NDExpr b) => GreaterEqual(a, b);
/// <summary>Unary plus (np's <c>+a</c>): the identity copy <see cref="Positive"/> — dtype-preserving, no bool loop.</summary>
public static NDExpr operator +(NDExpr a) => Positive(a);
}
// =========================================================================
// Compile-time context shared with each node
// =========================================================================
public sealed class NDExprCompileContext
{
public NPTypeCode[] InputTypes { get; }
public NPTypeCode OutputType { get; }
public LocalBuilder[] InputLocals { get; }
public bool VectorMode { get; }
/// <summary>
/// Per-node resolved dtypes (NumPy result_type mode, produced by the
/// typing pass for <see cref="NDExpr.CompileNumPy"/>). Null in legacy
/// mode, where every node computes at <see cref="OutputType"/>.
/// Keyed by node reference.
/// </summary>
public IReadOnlyDictionary<NDExpr, NPTypeCode>? NodeTypes { get; }
/// <summary>
/// Vector mode: the kernel's compute lane dtype W. Every node typed W emits a
/// <c>Vector<W></c>; every Boolean-typed node emits a <c>Vector<W></c> LANE MASK
/// (all-ones / zero lanes). <see cref="NPTypeCode.Boolean"/> is "byte mode": every operand is
/// bool and nodes carry canonical 0/1 bytes. Equals <see cref="OutputType"/> in legacy mode.
/// </summary>
public NPTypeCode VectorLaneType { get; }
internal bool ByteMode => VectorLaneType == NPTypeCode.Boolean;
/// <summary>
/// P5.2 mixed-width vector mode (ndexpr-evaluate.md): nodes compute at their OWN dtype in a
/// container of <c>elemCount·sizeof(dtype)</c> bytes — <c>Vector128</c> for a half-lane
/// dtype, the full <c>Vector256</c> for a lane-sized one — and each node edge widens exactly
/// (<see cref="NDExprVec.EmitWidenEdge"/>). False = the uniform v2 contract, where every node
/// emits one shared <c>Vector<W></c>. Only meaningful when <see cref="VectorMode"/>.
/// </summary>
internal bool MixedWidth { get; }
/// <summary>
/// The vector container width (bits) a node of dtype <paramref name="t"/> emits at: the
/// full <see cref="DirectILKernelGenerator.VectorBits"/> in uniform mode, or — in mixed-width
/// mode — the lane's element count times <paramref name="t"/>'s size (always 128 or 256; the
/// plan's container rule admits nothing else).
/// </summary>
/// <param name="t">The node's dtype (never Boolean/Half in mixed mode — the plan excludes them).</param>
/// <returns>The container width in bits.</returns>
internal int ContainerBits(NPTypeCode t)
=> MixedWidth
? DirectILKernelGenerator.VectorBits * DirectILKernelGenerator.GetTypeSize(t)
/ DirectILKernelGenerator.GetTypeSize(VectorLaneType)
: DirectILKernelGenerator.VectorBits;
/// <summary>
/// Per input index: the position of that input among the kernel's ITERATOR operands (an
/// index into <see cref="InputLocals"/>), or -1 when the input is a PARAMETER — a 0-d array
/// np.evaluate hoists into the kernel's aux block once per call instead of streaming it
/// through the iterator (<see cref="ParamLocals"/>). Null = every input is an operand at its
/// own index (the legacy / positional-handle contract).
/// </summary>
internal int[]? InputSlots { get; }
/// <summary>Per input index: its index into <see cref="ParamLocals"/>, or -1 for an iterator operand. Null when there are no parameters.</summary>
internal int[]? InputParams { get; }
/// <summary>
/// The parameters' locals, loaded ONCE by the kernel prologue: the scalar value in scalar
/// mode, its broadcast <c>Vector<lane(W)></c> (a lane mask for a bool parameter in
/// W-mode) in vector mode — so a parameter costs nothing per element, exactly like a literal.
/// </summary>
internal LocalBuilder[]? ParamLocals { get; }
public NDExprCompileContext(
NPTypeCode[] inputTypes, NPTypeCode outputType,
LocalBuilder[] inputLocals, bool vectorMode)
: this(inputTypes, outputType, inputLocals, vectorMode, null)
{
}
public NDExprCompileContext(
NPTypeCode[] inputTypes, NPTypeCode outputType,
LocalBuilder[] inputLocals, bool vectorMode,
IReadOnlyDictionary<NDExpr, NPTypeCode>? nodeTypes,
NPTypeCode vectorLaneType = NPTypeCode.Empty)
: this(inputTypes, outputType, inputLocals, vectorMode, nodeTypes, vectorLaneType, null, null, null)
{
}
/// <summary>
/// The parameter-aware context np.evaluate compiles with: <paramref name="inputSlots"/> /
/// <paramref name="inputParams"/> route each <see cref="InputNode"/> to an iterator local or a
/// parameter local (see <see cref="InputSlots"/>).
/// </summary>
/// <param name="inputTypes">Every input's dtype, parameters included, in input order.</param>
/// <param name="outputType">The kernel's output dtype.</param>
/// <param name="inputLocals">The iterator operands' locals, in operand order.</param>
/// <param name="vectorMode">True when emitting the vector body.</param>
/// <param name="nodeTypes">The typing pass's node→dtype table (NumPy mode), or null (legacy).</param>
/// <param name="vectorLaneType">The vector body's lane dtype W (vector mode).</param>
/// <param name="inputSlots">Input → operand slot map, or null for the identity map.</param>
/// <param name="inputParams">Input → parameter index map, or null when there are no parameters.</param>
/// <param name="paramLocals">The parameters' prologue-loaded locals, or null.</param>
/// <param name="mixedWidth">P5.2 mixed-width vector mode (see <see cref="MixedWidth"/>); false = uniform v2.</param>
internal NDExprCompileContext(
NPTypeCode[] inputTypes, NPTypeCode outputType,
LocalBuilder[] inputLocals, bool vectorMode,
IReadOnlyDictionary<NDExpr, NPTypeCode>? nodeTypes,