grx-tensor 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/grx/storage.rb CHANGED
@@ -4,46 +4,39 @@ require "fiddle"
4
4
 
5
5
  module GRX
6
6
  # ===================================================================
7
- # Storage — Buffer de memoria nativa
7
+ # Storage — Native memory buffer
8
8
  #
9
- # Cuando CAPI está cargado:
10
- # @ptr Fiddle::Pointer a un bloque de doubles alineado a 32 bytes
11
- # reservado con grx_alloc() (malloc alineado en C).
12
- # Los datos viven en el heap de C, NO en el GC de Ruby.
9
+ # When CAPI is loaded:
10
+ # @ptr -> Fiddle::Pointer to 32-byte aligned doubles block
11
+ # allocated via grx_alloc() (C posix_memalign / _aligned_malloc).
12
+ # Data lives in C heap, NOT managed by Ruby GC.
13
13
  #
14
- # Cuando CAPI NO está cargado (fallback):
15
- # @data Array de Ruby normal (lento pero siempre correcto).
16
- #
17
- # La separación entre los dos modos es transparente para Tensor.
14
+ # When CAPI is NOT loaded (fallback):
15
+ # @data -> Standard Ruby Array (slow but correct).
18
16
  # ===================================================================
19
17
  class Storage
20
18
  attr_reader :size
21
-
22
- # ptr expuesto para que CAPI pueda leerlo directamente
23
19
  attr_reader :ptr
24
20
 
25
21
  def initialize(array_plano)
26
- @size = array_plano.size
22
+ flat = array_plano.is_a?(Array) ? array_plano.flatten : Array(array_plano)
23
+ @size = flat.size
27
24
 
28
25
  if CAPI::LOADED
29
- # --- MODO RÁPIDO: memoria C alineada ---
30
- # grx_alloc devuelve un double* alineado a 32 bytes
26
+ # Fast mode: aligned C memory
31
27
  @ptr = CAPI.grx_alloc(@size)
32
- raise StorageError, "grx_alloc falló (OOM)" if @ptr.null?
28
+ raise StorageError, "grx_alloc failed (OOM)" if @ptr.null?
33
29
 
34
- # Empaquetamos el Array de Ruby en el buffer C como doubles (little-endian)
35
- # Array#pack("d*") → String binaria de IEEE 754 doubles
36
- bytes = array_plano.pack("d*")
30
+ # Pack Ruby Array into C buffer as IEEE 754 doubles
31
+ bytes = flat.map(&:to_f).pack("d*")
37
32
  @ptr[0, bytes.bytesize] = bytes
38
33
 
39
- # Registramos un finalizer para liberar la memoria C cuando el objeto
40
- # Ruby sea recolectado por el GC. Usamos ObjectSpace para evitar
41
- # que el closure capture self (lo que impediría la recolección).
34
+ # Finalizer releases C memory upon Ruby GC collection
42
35
  ptr_to_free = @ptr
43
36
  ObjectSpace.define_finalizer(self, self.class.make_finalizer(ptr_to_free))
44
37
  else
45
- # --- MODO FALLBACK: Array de Ruby ---
46
- @data = array_plano.map(&:to_f)
38
+ # Fallback mode: Ruby Array
39
+ @data = flat.map(&:to_f)
47
40
  @ptr = nil
48
41
  end
49
42
  end
@@ -53,12 +46,11 @@ module GRX
53
46
  end
54
47
 
55
48
  # ------------------------------------------------------------------
56
- # Lectura / escriturausadas solo en modo fallback y por get()
57
- # Las operaciones masivas (add, mul, etc.) van directo por @ptr en C.
49
+ # Read / Writeused in fallback mode and by item/get()
50
+ # High-performance tensor ops operate directly on @ptr in C.
58
51
  # ------------------------------------------------------------------
59
52
  def read(indice)
60
53
  if CAPI::LOADED
61
- # Leemos 8 bytes desde el offset correcto y los desempaquetamos como double
62
54
  @ptr[indice * 8, 8].unpack1("d")
63
55
  else
64
56
  @data[indice]
@@ -73,7 +65,7 @@ module GRX
73
65
  end
74
66
  end
75
67
 
76
- # Vuelca todo el buffer a un Array de Ruby (para to_a, inspect, tests)
68
+ # Dumps entire buffer to a Ruby Array
77
69
  def to_ruby_array
78
70
  if CAPI::LOADED
79
71
  @ptr[0, @size * 8].unpack("d#{@size}")
data/lib/grx/tensor.rb CHANGED
@@ -40,7 +40,7 @@ module GRX
40
40
  ones(t.shape, requires_grad: requires_grad)
41
41
  end
42
42
 
43
- # Inicialización Xavier uniform (para capas lineales con tanh/sigmoid)
43
+ # Xavier uniform initialization (optimal for linear layers with tanh/sigmoid)
44
44
  def self.xavier_uniform(shape, requires_grad: false)
45
45
  fan_in, fan_out = shape[-2] || 1, shape[-1] || 1
46
46
  n = shape.reduce(1, :*)
@@ -49,9 +49,9 @@ module GRX
49
49
  new(s, shape, requires_grad: requires_grad)
50
50
  end
51
51
 
52
- # Inicialización He normal (para capas con ReLU)
52
+ # He normal initialization (optimal for layers with ReLU)
53
53
  def self.he_normal(shape, requires_grad: false)
54
- # fan_in = número de entradas = último dim o penúltimo si es 2D
54
+ # fan_in = number of inputs = last dim or penultimate if 2D
55
55
  fan_in = shape.size >= 2 ? shape[-1] : shape[0]
56
56
  n = shape.reduce(1, :*)
57
57
  s = _alloc_raw(n)
@@ -60,13 +60,13 @@ module GRX
60
60
  end
61
61
 
62
62
  # ----------------------------------------------------------------
63
- # OPERACIONES ARITMÉTICAS (con autograd)
63
+ # ARITHMETIC OPERATIONS (with autograd)
64
64
  # ----------------------------------------------------------------
65
65
 
66
66
  def +(other)
67
67
  case other
68
68
  when Tensor
69
- raise ShapeError, "Shapes incompatibles: #{@shape} vs #{other.shape}" if @shape != other.shape
69
+ raise ShapeError, "Incompatible shapes: #{@shape} vs #{other.shape}" if @shape != other.shape
70
70
  r = Tensor.new(_binop(:grx_add, other), @shape)
71
71
  if requires_grad || other.requires_grad
72
72
  r.requires_grad = true
@@ -80,14 +80,14 @@ module GRX
80
80
  when Numeric
81
81
  add_scalar(other.to_f)
82
82
  else
83
- raise TypeError, "No se puede sumar Tensor con #{other.class}"
83
+ raise TypeError, "Cannot add Tensor with #{other.class}"
84
84
  end
85
85
  end
86
86
 
87
87
  def -(other)
88
88
  case other
89
89
  when Tensor
90
- raise ShapeError, "Shapes incompatibles: #{@shape} vs #{other.shape}" if @shape != other.shape
90
+ raise ShapeError, "Incompatible shapes: #{@shape} vs #{other.shape}" if @shape != other.shape
91
91
  r = Tensor.new(_binop(:grx_sub, other), @shape)
92
92
  if requires_grad || other.requires_grad
93
93
  r.requires_grad = true
@@ -101,14 +101,14 @@ module GRX
101
101
  when Numeric
102
102
  add_scalar(-other.to_f)
103
103
  else
104
- raise TypeError, "No se puede restar Tensor con #{other.class}"
104
+ raise TypeError, "Cannot subtract Tensor with #{other.class}"
105
105
  end
106
106
  end
107
107
 
108
108
  def *(other)
109
109
  case other
110
110
  when Tensor
111
- raise ShapeError, "Shapes incompatibles: #{@shape} vs #{other.shape}" if @shape != other.shape
111
+ raise ShapeError, "Incompatible shapes: #{@shape} vs #{other.shape}" if @shape != other.shape
112
112
  r = Tensor.new(_binop(:grx_mul, other), @shape)
113
113
  if requires_grad || other.requires_grad
114
114
  r.requires_grad = true
@@ -123,14 +123,14 @@ module GRX
123
123
  when Numeric
124
124
  scale(other.to_f)
125
125
  else
126
- raise TypeError, "No se puede multiplicar Tensor con #{other.class}"
126
+ raise TypeError, "Cannot multiply Tensor with #{other.class}"
127
127
  end
128
128
  end
129
129
 
130
130
  def /(other)
131
131
  case other
132
132
  when Tensor
133
- raise ShapeError, "Shapes incompatibles: #{@shape} vs #{other.shape}" if @shape != other.shape
133
+ raise ShapeError, "Incompatible shapes: #{@shape} vs #{other.shape}" if @shape != other.shape
134
134
  r = Tensor.new(_binop(:grx_div, other), @shape)
135
135
  if requires_grad || other.requires_grad
136
136
  r.requires_grad = true
@@ -146,7 +146,7 @@ module GRX
146
146
  when Numeric
147
147
  scale(1.0 / other.to_f)
148
148
  else
149
- raise TypeError, "No se puede dividir Tensor con #{other.class}"
149
+ raise TypeError, "Cannot divide Tensor with #{other.class}"
150
150
  end
151
151
  end
152
152
 
@@ -155,23 +155,51 @@ module GRX
155
155
  end
156
156
 
157
157
  # ----------------------------------------------------------------
158
- # OPERACIONES ESCALARES
158
+ # SCALAR OPERATIONS
159
159
  # ----------------------------------------------------------------
160
160
 
161
+ def coerce(other)
162
+ case other
163
+ when Numeric
164
+ # Returns reversed [self, other] wrapper to enable 2.0 * tensor
165
+ [Tensor.new(Storage.new(Array.new(numel, other.to_f)), @shape), self]
166
+ else
167
+ raise TypeError, "#{self.class} cannot be coerced with #{other.class}"
168
+ end
169
+ end
170
+
161
171
  def scale(s)
162
- _unary_c(:grx_scale, s) { |v| v * s }
172
+ r = _unary_c(:grx_scale, s) { |v| v * s }
173
+ if requires_grad
174
+ r.requires_grad = true; r._grafo_hijos << self
175
+ src = self; factor = s.to_f
176
+ r.backward_fn = ->(g) { src.agregar_gradiente(g.scale(factor)) }
177
+ end
178
+ r
163
179
  end
164
180
 
165
181
  def add_scalar(s)
166
- _unary_c(:grx_add_scalar, s) { |v| v + s }
182
+ r = _unary_c(:grx_add_scalar, s) { |v| v + s }
183
+ if requires_grad
184
+ r.requires_grad = true; r._grafo_hijos << self
185
+ src = self
186
+ r.backward_fn = ->(g) { src.agregar_gradiente(g) }
187
+ end
188
+ r
167
189
  end
168
190
 
169
191
  def negate
170
- _unary_c(:grx_negate) { |v| -v }
192
+ r = _unary_c(:grx_negate) { |v| -v }
193
+ if requires_grad
194
+ r.requires_grad = true; r._grafo_hijos << self
195
+ src = self
196
+ r.backward_fn = ->(g) { src.agregar_gradiente(g.negate) }
197
+ end
198
+ r
171
199
  end
172
200
 
173
201
  # ----------------------------------------------------------------
174
- # MATEMÁTICAS ELEMENT-WISE (con autograd)
202
+ # ELEMENT-WISE MATH (with autograd)
175
203
  # ----------------------------------------------------------------
176
204
 
177
205
  def abs
@@ -249,29 +277,56 @@ module GRX
249
277
  CAPI.grx_clip(@storage.ptr, lo.to_f, hi.to_f, out.ptr, numel)
250
278
  else
251
279
  data = to_a.map { |v| v < lo ? lo : (v > hi ? hi : v) }
252
- return Tensor.create(data, @shape)
280
+ return Tensor.create(data, @shape, requires_grad: @requires_grad)
253
281
  end
254
- Tensor.new(out, @shape)
282
+ r = Tensor.new(out, @shape)
283
+ if @requires_grad
284
+ r.requires_grad = true; r._grafo_hijos << self
285
+ src = self; l = lo.to_f; h = hi.to_f
286
+ r.backward_fn = ->(g) {
287
+ mask = Tensor.create(src.to_a.map { |v| (v >= l && v <= h) ? 1.0 : 0.0 }, src.shape)
288
+ src.agregar_gradiente(g * mask)
289
+ }
290
+ end
291
+ r
255
292
  end
256
293
 
257
294
  # ----------------------------------------------------------------
258
- # REDUCCIONES (retornan Float o Tensor escalar)
295
+ # REDUCTIONS (return differentiable scalar Tensor with autograd)
259
296
  # ----------------------------------------------------------------
260
297
 
261
298
  def sum
262
- if CAPI::LOADED
299
+ val = if CAPI::LOADED
263
300
  CAPI.grx_sum(@storage.ptr, numel)
264
301
  else
265
302
  to_a.sum
266
303
  end
304
+ r = Tensor.create([val], [1], requires_grad: @requires_grad)
305
+ if @requires_grad
306
+ r._grafo_hijos << self
307
+ src = self
308
+ r.backward_fn = ->(g) {
309
+ src.agregar_gradiente(Tensor.create(Array.new(src.numel, g.item), src.shape))
310
+ }
311
+ end
312
+ r
267
313
  end
268
314
 
269
315
  def mean
270
- if CAPI::LOADED
316
+ val = if CAPI::LOADED
271
317
  CAPI.grx_mean(@storage.ptr, numel)
272
318
  else
273
319
  to_a.sum.to_f / numel
274
320
  end
321
+ r = Tensor.create([val], [1], requires_grad: @requires_grad)
322
+ if @requires_grad
323
+ r._grafo_hijos << self
324
+ src = self; n = numel.to_f
325
+ r.backward_fn = ->(g) {
326
+ src.agregar_gradiente(Tensor.create(Array.new(src.numel, g.item / n), src.shape))
327
+ }
328
+ end
329
+ r
275
330
  end
276
331
 
277
332
  def max
@@ -290,12 +345,40 @@ module GRX
290
345
  end
291
346
  end
292
347
 
348
+ def argmax
349
+ arr = to_a
350
+ return 0 if arr.empty?
351
+ max_idx = 0
352
+ max_val = arr[0]
353
+ (1...arr.size).each do |i|
354
+ if arr[i] > max_val
355
+ max_val = arr[i]
356
+ max_idx = i
357
+ end
358
+ end
359
+ max_idx
360
+ end
361
+
362
+ def argmin
363
+ arr = to_a
364
+ return 0 if arr.empty?
365
+ min_idx = 0
366
+ min_val = arr[0]
367
+ (1...arr.size).each do |i|
368
+ if arr[i] < min_val
369
+ min_val = arr[i]
370
+ min_idx = i
371
+ end
372
+ end
373
+ min_idx
374
+ end
375
+
293
376
  # ----------------------------------------------------------------
294
- # ÁLGEBRA LINEAL
377
+ # LINEAR ALGEBRA
295
378
  # ----------------------------------------------------------------
296
379
 
297
380
  def dot(other)
298
- raise ShapeError, "dot requiere mismo shape" if @shape != other.shape
381
+ raise ShapeError, "dot requires matching shape" if @shape != other.shape
299
382
  if CAPI::LOADED
300
383
  CAPI.grx_dot(@storage.ptr, other.storage.ptr, numel)
301
384
  else
@@ -304,9 +387,9 @@ module GRX
304
387
  end
305
388
 
306
389
  def matmul(other)
307
- raise DimensionError, "matmul requiere tensores 2D" unless @shape.size == 2 && other.shape.size == 2
390
+ raise DimensionError, "matmul requires 2D tensors" unless @shape.size == 2 && other.shape.size == 2
308
391
  m, k = @shape; k2, n = other.shape
309
- raise ShapeError, "Dimensiones incompatibles: #{@shape} × #{other.shape}" if k != k2
392
+ raise ShapeError, "Incompatible dimensions: #{@shape} × #{other.shape}" if k != k2
310
393
  out = _alloc_storage(m * n)
311
394
  if CAPI::LOADED
312
395
  CAPI.grx_matmul(@storage.ptr, other.storage.ptr, out.ptr, m, k, n)
@@ -323,7 +406,7 @@ module GRX
323
406
  r._grafo_hijos.push(a, b)
324
407
  r.backward_fn = ->(g) {
325
408
  # dL/dA = dL/dC × B^T, dL/dB = A^T × dL/dC
326
- # Usamos _matmul_no_grad y _transpose_view para no crear nodos en el grafo
409
+ # Uses _matmul_no_grad and _transpose_view to avoid graph recursion
327
410
  a.agregar_gradiente(g._matmul_no_grad(b._transpose_view)) if a.requires_grad
328
411
  b.agregar_gradiente(a._transpose_view._matmul_no_grad(g)) if b.requires_grad
329
412
  }
@@ -332,7 +415,7 @@ module GRX
332
415
  end
333
416
 
334
417
  # ----------------------------------------------------------------
335
- # ACTIVACIONES (con autograd)
418
+ # ACTIVATIONS (with autograd)
336
419
  # ----------------------------------------------------------------
337
420
 
338
421
  def relu
@@ -388,10 +471,39 @@ module GRX
388
471
  end
389
472
 
390
473
  def softmax
391
- r = _unary_c(:grx_softmax) do
392
- vals = to_a; max_v = vals.max
393
- exps = vals.map { |v| Math.exp(v - max_v) }; s = exps.sum
394
- exps.map { |e| e / s }
474
+ dim = @shape[-1]
475
+ batch = numel / dim
476
+ raw = to_a
477
+ out_vals = Array.new(numel)
478
+
479
+ batch.times do |b|
480
+ slice = raw.slice(b * dim, dim)
481
+ max_v = slice.max
482
+ exps = slice.map { |v| Math.exp(v - max_v) }
483
+ sum_e = exps.sum
484
+ dim.times { |j| out_vals[b * dim + j] = exps[j] / sum_e }
485
+ end
486
+
487
+ r = Tensor.create(out_vals, @shape, requires_grad: @requires_grad)
488
+ if @requires_grad
489
+ r._grafo_hijos << self
490
+ res = r; src = self
491
+ r.backward_fn = ->(g) {
492
+ s_data = res.to_a
493
+ g_data = g.to_a
494
+ grad_x = Array.new(src.numel, 0.0)
495
+
496
+ batch.times do |b|
497
+ s_row = s_data.slice(b * dim, dim)
498
+ g_row = g_data.slice(b * dim, dim)
499
+ dot = s_row.zip(g_row).sum { |s_val, g_val| s_val * g_val }
500
+ dim.times do |j|
501
+ grad_x[b * dim + j] = s_row[j] * (g_row[j] - dot)
502
+ end
503
+ end
504
+
505
+ src.agregar_gradiente(Tensor.create(grad_x, src.shape))
506
+ }
395
507
  end
396
508
  r
397
509
  end
@@ -411,7 +523,7 @@ module GRX
411
523
  agregar_gradiente(grad_inicial)
412
524
  end
413
525
 
414
- # Orden topológico via DFS iterativo post-order (evita stack overflow en grafos profundos)
526
+ # Topological sorting via iterative post-order DFS (prevents stack overflow on deep graphs)
415
527
  orden = []
416
528
  visitados = {}
417
529
  stack = [[self, false]]
@@ -428,7 +540,7 @@ module GRX
428
540
  end
429
541
  end
430
542
 
431
- # orden ya está en post-order reverse = raíz primero, hojas al final
543
+ # Topological order in post-order: reverse traverses root first down to leaves
432
544
  orden.reverse_each do |nodo|
433
545
  next unless nodo.grad && nodo.backward_fn
434
546
  nodo.backward_fn.call(nodo.grad)
@@ -447,20 +559,41 @@ module GRX
447
559
  end
448
560
 
449
561
  # ----------------------------------------------------------------
450
- # GEOMETRÍA (zero-copy)
562
+ # GEOMETRY (zero-copy)
451
563
  # ----------------------------------------------------------------
452
564
 
453
565
  def get(*coords)
454
566
  @storage.read(_calc_flat_index(coords))
455
567
  end
456
568
 
569
+ def set(*coords, val)
570
+ @storage.write(_calc_flat_index(coords), val.to_f)
571
+ end
572
+
573
+ def contiguous
574
+ return self if _contiguous?
575
+ c = Tensor.create(to_a, @shape, requires_grad: @requires_grad)
576
+ if @requires_grad
577
+ c._grafo_hijos << self
578
+ src = self
579
+ c.backward_fn = ->(g) { src.agregar_gradiente(g) }
580
+ end
581
+ c
582
+ end
583
+
457
584
  def reshape(nueva_forma)
458
- raise ArgumentError, "Reshape incompatible" if numel != nueva_forma.reduce(1,:*)
459
- Tensor.new(@storage, nueva_forma, offset: @offset, requires_grad: @requires_grad)
585
+ raise ArgumentError, "Incompatible reshape" if numel != nueva_forma.reduce(1,:*)
586
+ r = Tensor.new(@storage, nueva_forma, offset: @offset, requires_grad: @requires_grad)
587
+ if @requires_grad
588
+ r._grafo_hijos << self
589
+ src = self; orig_shape = @shape
590
+ r.backward_fn = ->(g) { src.agregar_gradiente(g.reshape(orig_shape)) }
591
+ end
592
+ r
460
593
  end
461
594
 
462
595
  def transpose
463
- raise DimensionError, "transpose solo soporta 2D" if @shape.size != 2
596
+ raise DimensionError, "transpose only supports 2D tensors" if @shape.size != 2
464
597
  t = Tensor.new(@storage, [@shape[1], @shape[0]],
465
598
  strides: [@strides[1], @strides[0]],
466
599
  offset: @offset, requires_grad: @requires_grad)
@@ -468,32 +601,34 @@ module GRX
468
601
  t._grafo_hijos << self
469
602
  src = self
470
603
  t.backward_fn = ->(g) {
471
- src.agregar_gradiente(g._transpose_view)
604
+ src.agregar_gradiente(g.transpose)
472
605
  }
473
606
  end
474
607
  t
475
608
  end
476
609
 
477
- # Transpose sin autograd — solo para uso interno en backward
610
+ # Transpose view without autograd — for internal backward pass
478
611
  def _transpose_view
479
- raise DimensionError, "transpose solo soporta 2D" if @shape.size != 2
612
+ raise DimensionError, "transpose only supports 2D tensors" if @shape.size != 2
480
613
  Tensor.new(@storage, [@shape[1], @shape[0]],
481
614
  strides: [@strides[1], @strides[0]],
482
615
  offset: @offset, requires_grad: false)
483
616
  end
484
617
 
485
- # Matmul sin autograd — para uso interno en backward_fn
618
+ # Matmul without autograd — for internal backward_fn usage
486
619
  def _matmul_no_grad(other)
487
- raise DimensionError, "matmul requiere tensores 2D" unless @shape.size == 2 && other.shape.size == 2
620
+ raise DimensionError, "matmul requires 2D tensors" unless @shape.size == 2 && other.shape.size == 2
488
621
  m, k = @shape; k2, n = other.shape
489
- raise ShapeError, "Dimensiones incompatibles" if k != k2
622
+ raise ShapeError, "Incompatible dimensions" if k != k2
623
+ a_c = _contiguous? ? self : contiguous
624
+ b_c = other._contiguous? ? other : other.contiguous
490
625
  out = _alloc_storage(m * n)
491
626
  if CAPI::LOADED
492
- CAPI.grx_matmul(@storage.ptr, other.storage.ptr, out.ptr, m, k, n)
627
+ CAPI.grx_matmul(a_c.storage.ptr, b_c.storage.ptr, out.ptr, m, k, n)
493
628
  else
494
629
  result = Array.new(m * n, 0.0)
495
- m.times { |i| k.times { |kk| aik = @storage.read(i*k+kk)
496
- n.times { |j| result[i*n+j] += aik * other.storage.read(kk*n+j) } } }
630
+ m.times { |i| k.times { |kk| aik = a_c.storage.read(i*k+kk)
631
+ n.times { |j| result[i*n+j] += aik * b_c.storage.read(kk*n+j) } } }
497
632
  return Tensor.new(Storage.new(result), [m, n])
498
633
  end
499
634
  Tensor.new(out, [m, n])
@@ -504,16 +639,20 @@ module GRX
504
639
  end
505
640
 
506
641
  # ----------------------------------------------------------------
507
- # UTILIDADES
642
+ # UTILITIES
508
643
  # ----------------------------------------------------------------
509
644
 
510
645
  def numel
511
646
  @shape.reduce(1, :*)
512
647
  end
513
648
 
649
+ def rank
650
+ @shape.size
651
+ end
652
+
514
653
  def to_a
515
- # Si los strides son contiguos (tensor normal, reshape), leemos el buffer directo.
516
- # Si no (transpose, vistas con strides custom), recorremos con strides.
654
+ # If strides are contiguous (normal tensor, reshape), read buffer directly.
655
+ # Otherwise (transpose, strided views), traverse with custom strides.
517
656
  if _contiguous?
518
657
  @storage.to_ruby_array
519
658
  else
@@ -521,13 +660,14 @@ module GRX
521
660
  end
522
661
  end
523
662
 
524
- private
525
-
526
- # Un tensor es contiguo si sus strides coinciden con los strides row-major estándar
527
- def _contiguous?
663
+ # A tensor is contiguous if its strides match standard row-major order
664
+ def contiguous?
528
665
  expected = _calc_strides(@shape)
529
666
  @strides == expected && @offset == 0
530
667
  end
668
+ alias _contiguous? contiguous?
669
+
670
+ private
531
671
 
532
672
  def _collect_elements(shape, strides, offset)
533
673
  if shape.size == 1
@@ -541,18 +681,46 @@ module GRX
541
681
 
542
682
  public
543
683
 
684
+ include Comparable
685
+
686
+ def <=>(other)
687
+ case other
688
+ when Tensor
689
+ (numel == 1 && other.numel == 1) ? item <=> other.item : nil
690
+ when Numeric
691
+ numel == 1 ? item <=> other.to_f : nil
692
+ else
693
+ nil
694
+ end
695
+ end
696
+
544
697
  def item
545
- raise "item() solo para tensores de 1 elemento" if numel != 1
698
+ raise "item() only supported for 1-element tensors" if numel != 1
546
699
  to_a[0]
547
700
  end
548
701
 
702
+ def to_f
703
+ raise "to_f only supported for 1-element tensors" if numel != 1
704
+ to_a[0]
705
+ end
706
+
707
+ def to_i
708
+ raise "to_i only supported for 1-element tensors" if numel != 1
709
+ to_a[0].to_i
710
+ end
711
+
712
+ def nan?
713
+ raise "nan? only supported for 1-element tensors" if numel != 1
714
+ to_a[0].nan?
715
+ end
716
+
549
717
  def to_s
550
718
  "#<GRX::Tensor shape=#{@shape} data=#{to_a}>"
551
719
  end
552
720
  alias inspect to_s
553
721
 
554
722
  # ----------------------------------------------------------------
555
- # PRIVADO
723
+ # PRIVATE
556
724
  # ----------------------------------------------------------------
557
725
 
558
726
  private
@@ -575,33 +743,34 @@ module GRX
575
743
  end
576
744
  end
577
745
 
578
- # Operación binaria element-wise: llama a CAPI o fallback Ruby
746
+ # Element-wise binary op: delegates to CAPI or Ruby fallback
579
747
  def _binop(op, other)
748
+ a_c = _contiguous? ? self : contiguous
749
+ b_c = other._contiguous? ? other : other.contiguous
580
750
  out = _alloc_storage(numel)
581
751
  if CAPI::LOADED
582
- CAPI.public_send(op, @storage.ptr, other.storage.ptr, out.ptr, numel)
752
+ CAPI.public_send(op, a_c.storage.ptr, b_c.storage.ptr, out.ptr, numel)
583
753
  else
584
754
  rb = { grx_add: :+, grx_sub: :-, grx_mul: :*, grx_div: :/ }[op]
585
755
  data = (0...numel).map { |i|
586
- @storage.read(@offset + i).public_send(rb, other.storage.read(other.offset + i))
756
+ a_c.storage.read(a_c.offset + i).public_send(rb, b_c.storage.read(b_c.offset + i))
587
757
  }
588
758
  return Storage.new(data)
589
759
  end
590
760
  out
591
761
  end
592
762
 
593
- # Operación unaria: llama a CAPI con args opcionales o fallback con bloque
594
- # Si el bloque acepta un elemento (arity == 1) → map element-wise
595
- # Si el bloque no acepta argumentos (arity == 0) → lo llama una vez (para softmax, etc.)
763
+ # Unary op: delegates to CAPI with optional args or Ruby fallback block
596
764
  def _unary_c(op, *args, &fallback)
765
+ a_c = _contiguous? ? self : contiguous
597
766
  out = _alloc_storage(numel)
598
767
  if CAPI::LOADED
599
- CAPI.public_send(op, @storage.ptr, *args, out.ptr, numel)
768
+ CAPI.public_send(op, a_c.storage.ptr, *args, out.ptr, numel)
600
769
  else
601
770
  vals = if fallback
602
- fallback.arity == 0 ? fallback.call : to_a.map(&fallback)
771
+ fallback.arity == 0 ? fallback.call : a_c.to_a.map(&fallback)
603
772
  else
604
- to_a
773
+ a_c.to_a
605
774
  end
606
775
  return Tensor.create(vals, @shape)
607
776
  end