carray 3.0.1 → 3.0.2

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.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +520 -0
  3. data/README.md +2 -2
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_dispatch.c +33 -4
  6. data/ext/ca_axis_group.c +202 -96
  7. data/ext/ca_categorical_iterator.c +108 -54
  8. data/ext/ca_kernel_iterator.c +317 -51
  9. data/ext/ca_kernel_iterator.h +142 -35
  10. data/ext/ca_obj_array.c +62 -20
  11. data/ext/ca_obj_block.c +4 -4
  12. data/ext/ca_obj_const_string.c +85 -26
  13. data/ext/ca_obj_face.c +24 -0
  14. data/ext/ca_obj_face.h +15 -0
  15. data/ext/ca_obj_fixlen_string.c +18 -5
  16. data/ext/ca_obj_meld.c +123 -25
  17. data/ext/ca_obj_object.c +8 -0
  18. data/ext/ca_obj_select.c +49 -34
  19. data/ext/ca_obj_stack.c +3 -8
  20. data/ext/ca_obj_stride.c +72 -1
  21. data/ext/ca_obj_string.c +8 -4
  22. data/ext/ca_obj_window.c +8 -2
  23. data/ext/ca_op_ipower.c +1 -2
  24. data/ext/ca_rng_normal.h +42 -0
  25. data/ext/ca_rng_xoshiro256pp.h +105 -0
  26. data/ext/ca_sweep_engine.c +307 -143
  27. data/ext/ca_sweep_engine.h +26 -5
  28. data/ext/carray.h +21 -2
  29. data/ext/carray_access.c +32 -20
  30. data/ext/carray_address_basis.c +590 -0
  31. data/ext/carray_broadcast.c +3 -3
  32. data/ext/carray_call_cfunc.c +667 -483
  33. data/ext/carray_cast.c +115 -41
  34. data/ext/carray_copy.c +55 -30
  35. data/ext/carray_core.c +83 -3
  36. data/ext/carray_count.c +9 -10
  37. data/ext/carray_factorize.c +46 -25
  38. data/ext/carray_internal.h +17 -0
  39. data/ext/carray_kernels_reduce_aggregate.c +168 -0
  40. data/ext/carray_kernels_reduce_cumulative.c +270 -1
  41. data/ext/carray_kernels_reduce_extreme.c +554 -8
  42. data/ext/carray_kernels_scan.c +4 -4
  43. data/ext/carray_kernels_search.c +94 -14
  44. data/ext/carray_loop.c +7 -1
  45. data/ext/carray_mask.c +23 -8
  46. data/ext/carray_median_percentile.c +55 -0
  47. data/ext/carray_operator.c +4 -4
  48. data/ext/carray_order.c +1 -1
  49. data/ext/carray_random.c +384 -40
  50. data/ext/carray_slab.c +13 -0
  51. data/ext/carray_sort.c +20 -22
  52. data/ext/mk_call_cfunc.rb +103 -116
  53. data/ext/mkkernel.rb +297 -29
  54. data/ext/ruby_carray.c +10 -1
  55. data/ext/version.h +4 -4
  56. data/lib/carray/autoload_carray.rb +5 -3
  57. data/lib/carray/autoload_method_extension.rb +12 -0
  58. data/lib/carray/axis_group.rb +77 -0
  59. data/lib/carray/basics.rb +4 -0
  60. data/lib/carray/block_iterator.rb +92 -16
  61. data/lib/carray/categorical.rb +150 -33
  62. data/lib/carray/categorical_iterator.rb +207 -80
  63. data/lib/carray/const_string.rb +131 -27
  64. data/lib/carray/construct.rb +40 -0
  65. data/lib/carray/data_type_extension.rb +3 -0
  66. data/lib/carray/data_type_limits.rb +91 -0
  67. data/lib/carray/fixlen_string.rb +1 -1
  68. data/lib/carray/frame/csv_parser.rb +11 -4
  69. data/lib/carray/frame/frame.rb +81 -10
  70. data/lib/carray/frame/group.rb +36 -3
  71. data/lib/carray/frame/io.rb +67 -15
  72. data/lib/carray/frame/records.rb +18 -4
  73. data/lib/carray/frame/verbs.rb +14 -11
  74. data/lib/carray/inspect.rb +42 -9
  75. data/lib/carray/iterator.rb +143 -0
  76. data/lib/carray/lazy.rb +0 -37
  77. data/lib/carray/mask_gap_fill.rb +3 -1
  78. data/lib/carray/methods/discovery_along.rb +74 -0
  79. data/lib/carray/methods/factorize.rb +50 -0
  80. data/lib/carray/methods/is_in.rb +13 -2
  81. data/lib/carray/methods/locate_addr.rb +75 -2
  82. data/lib/carray/methods/mask_duplicates.rb +35 -1
  83. data/lib/carray/methods/nunique.rb +22 -1
  84. data/lib/carray/methods/repeat.rb +110 -0
  85. data/lib/carray/methods/unique.rb +41 -1
  86. data/lib/carray/rng.rb +86 -0
  87. data/lib/carray/slab_iterator.rb +58 -13
  88. data/lib/carray/string_operation_extension.rb +5 -1
  89. data/lib/carray/time.rb +18 -2
  90. data/lib/carray/window_iterator.rb +142 -20
  91. data/lib/carray.rb +2 -0
  92. data/yard-stubs/ca_obj_block.rb +2 -7
  93. data/yard-stubs/ca_obj_window.rb +10 -2
  94. data/yard-stubs/carray_access.rb +1 -1
  95. data/yard-stubs/carray_broadcast.rb +1 -1
  96. data/yard-stubs/carray_core.rb +0 -80
  97. data/yard-stubs/carray_count.rb +7 -2
  98. data/yard-stubs/carray_lazy.rb +205 -0
  99. data/yard-stubs/carray_math.rb +1486 -3
  100. data/yard-stubs/carray_median_percentile.rb +16 -2
  101. data/yard-stubs/carray_order.rb +9 -69
  102. data/yard-stubs/carray_slab.rb +9 -7
  103. data/yard-stubs/carray_sort.rb +7 -5
  104. metadata +9 -1
@@ -103,7 +103,7 @@ class CACategoricalIterator < CAIterator
103
103
  # index): offsets[c] = sum of counts[0...c]. Both come off the shared
104
104
  # plan, so the counting sort is not repeated here.
105
105
  @elements = cat.category_sizes.int64
106
- nvalid = @elements.sum
106
+ nvalid = counts.sum
107
107
  @offsets = cat.reduceat_index # cached segment STARTS (int64[k])
108
108
  # Group-major source indices = the valid prefix of the cached sort_addr.
109
109
  # With no classified cell the prefix is empty (and slicing a length-0
@@ -119,25 +119,30 @@ class CACategoricalIterator < CAIterator
119
119
  # range — so build the empty grouped buffer directly.
120
120
  @grouped = nvalid > 0 ? value.reshape(value.elements)[@perm].copy
121
121
  : CArray.new(value.data_type, [0])
122
- @empty = CArray.new(@grouped.data_type, [0])
122
+ @empty = CArray.new(grouped.data_type, [0])
123
123
  else
124
124
  # Shape mismatch: only per-fiber axis: dispatch could still work. With a
125
125
  # 1-D value there is no fiber structure to broadcast into, so a mismatch
126
126
  # is unrecoverable (preserves the old strict check). For higher-rank
127
127
  # value, defer validation to reduce time — check only that cat.ndim fits
128
128
  # one of the 3 axis: cases;
129
- # any no-axis reduce called on this iterator will surface the mismatch
130
- # because @grouped stays undefined.
129
+ # A no-axis reduce has no grouped buffer to work from. The reason is
130
+ # recorded here and raised from wherever one is asked for, so what
131
+ # surfaces names the mismatch instead of being whatever NoMethodError
132
+ # the nil produced first.
133
+ mismatch = "group_by_category: value.elements (#{value.elements}) != " \
134
+ "cat.elements (#{cat.elements})"
131
135
  if value.ndim == 1 ||
132
136
  ! [1, value.ndim - 1, value.ndim].include?(cat.ndim)
133
137
  raise ArgumentError,
134
- "group_by_category: value.elements (#{value.elements}) != " \
135
- "cat.elements (#{cat.elements})" +
138
+ mismatch +
136
139
  (value.ndim == 1 ? "" :
137
140
  ". For per-fiber reduce use `.sum(axis: k)`; cat.ndim=" \
138
141
  "#{cat.ndim} must be 1 (case A), #{value.ndim} (case B), " \
139
142
  "or #{value.ndim - 1} (band-only) for h.ndim=#{value.ndim}.")
140
143
  end
144
+ @no_flat = mismatch + ". This iterator answers only the per-fiber form, " \
145
+ "`.<reduce>(axis: k)`."
141
146
  end
142
147
  self
143
148
  end
@@ -176,7 +181,12 @@ class CACategoricalIterator < CAIterator
176
181
  # (structural, mask-independent) lifted per group.
177
182
  # @return [CArray]
178
183
  def elements
179
- @elements
184
+ # A copy, like every other member that reads off a memo: the memo is the
185
+ # iterator's own state, and handing out the array itself lets a caller
186
+ # write into it and change what the iterator answers from then on. This
187
+ # one invites it -- the docs point at its prefix sum for splitting a
188
+ # column apart, which reads as scratch.
189
+ counts.copy
180
190
  end
181
191
 
182
192
  # Group-vocabulary alias for {#elements}; reads naturally next to
@@ -189,8 +199,12 @@ class CACategoricalIterator < CAIterator
189
199
  # internal grouped/value/codes buffers.
190
200
  # @return [String]
191
201
  def inspect
192
- "#<#{self.class} ngroups=#{@k} labels=#{@labels.inspect} " \
193
- "elements=#{@elements.to_a.inspect}>"
202
+ # An iterator that answers only the per-fiber form has no per-category
203
+ # counts to show. Saying so beats both raising -- inspect is what you
204
+ # reach for when something is already puzzling -- and printing an empty
205
+ # list, which reads as a grouping that classified nothing.
206
+ tail = @elements ? "elements=#{@elements.to_a.inspect}" : "per-fiber only"
207
+ "#<#{self.class} ngroups=#{@k} labels=#{@labels.inspect} #{tail}>"
194
208
  end
195
209
 
196
210
  # @overload count_not_masked
@@ -205,9 +219,9 @@ class CACategoricalIterator < CAIterator
205
219
  # @param axis [Integer]
206
220
  # @return [CArray]
207
221
  def count_not_masked(axis: nil)
208
- return axis_moments(axis)[:count] if axis
222
+ return axis_counts(axis) if axis
209
223
  m = moments
210
- m ? m[:count] : per_category(CA_INT64) { |s| s.count_not_masked }
224
+ m ? m[:count].copy : per_category(CA_INT64) { |s| s.count_not_masked }
211
225
  end
212
226
 
213
227
  # @overload count(v = <none>)
@@ -253,7 +267,7 @@ class CACategoricalIterator < CAIterator
253
267
  "call it without axis:."
254
268
  end
255
269
  m = moments
256
- m ? @elements - m[:count] : per_category(CA_INT64) { |s| s.count_masked }
270
+ m ? counts - m[:count] : per_category(CA_INT64) { |s| s.count_masked }
257
271
  end
258
272
 
259
273
  # @overload sum
@@ -306,7 +320,7 @@ class CACategoricalIterator < CAIterator
306
320
  def max(axis: nil)
307
321
  return axis_moments(axis)[:max] if axis
308
322
  m = moments
309
- m ? m[:max] : per_category(core_reduce_type(:max)) { |s| s.max }
323
+ m ? m[:max].copy : per_category(core_reduce_type(:max)) { |s| s.max }
310
324
  end
311
325
 
312
326
  # @overload min
@@ -320,7 +334,7 @@ class CACategoricalIterator < CAIterator
320
334
  def min(axis: nil)
321
335
  return axis_moments(axis)[:min] if axis
322
336
  m = moments
323
- m ? m[:min] : per_category(core_reduce_type(:min)) { |s| s.min }
337
+ m ? m[:min].copy : per_category(core_reduce_type(:min)) { |s| s.min }
324
338
  end
325
339
 
326
340
  # @overload mean
@@ -357,11 +371,11 @@ class CACategoricalIterator < CAIterator
357
371
  # @return [CArray]
358
372
  def percentile (p, axis: nil)
359
373
  axis_order_stat_defer!(:percentile) if axis
360
- unless MONOID_TYPES.include?(@grouped.data_type)
374
+ unless MONOID_TYPES.include?(grouped.data_type)
361
375
  return per_category(core_reduce_type(:percentile, p)) { |s| s.percentile(p) }
362
376
  end
363
377
  out = CArray.float64(@k)
364
- @grouped.send(:__reduceat_percentile__, @offsets, p.to_f, out)
378
+ grouped.send(:__reduceat_percentile__, @offsets, p.to_f, out)
365
379
  out
366
380
  end
367
381
 
@@ -372,11 +386,11 @@ class CACategoricalIterator < CAIterator
372
386
  # MASKED. For a single fraction q in 0..1 use `percentile(q * 100)`.
373
387
  # @return [Array<CArray>]
374
388
  def quantile
375
- unless MONOID_TYPES.include?(@grouped.data_type)
389
+ unless MONOID_TYPES.include?(grouped.data_type)
376
390
  return [0, 25, 50, 75, 100].map { |p| percentile(p) }
377
391
  end
378
392
  outs = Array.new(5) { CArray.float64(@k) }
379
- @grouped.send(:__reduceat_quantile__, @offsets, *outs)
393
+ grouped.send(:__reduceat_quantile__, @offsets, *outs)
380
394
  outs
381
395
  end
382
396
 
@@ -393,7 +407,7 @@ class CACategoricalIterator < CAIterator
393
407
  cnt = m[:count]
394
408
  means = m[:sum] / cnt.float64 # per-segment mean (garbage where count 0/1,
395
409
  out = CArray.float64(@k) # ignored by the kernel's n<2 guards)
396
- @grouped.send(:__reduceat_variance__, @offsets, means, cnt, out)
410
+ grouped.send(:__reduceat_variance__, @offsets, means, cnt, out)
397
411
  out
398
412
  end
399
413
 
@@ -421,9 +435,9 @@ class CACategoricalIterator < CAIterator
421
435
  # @return [CArray]
422
436
  def prod(axis: nil)
423
437
  return axis_prod(axis) if axis
424
- return per_category(core_reduce_type(:prod)) { |s| s.prod } unless MONOID_TYPES.include?(@grouped.data_type)
438
+ return per_category(core_reduce_type(:prod)) { |s| s.prod } unless MONOID_TYPES.include?(grouped.data_type)
425
439
  out = CArray.float64(@k)
426
- @grouped.send(:__reduceat_prod__, @offsets, out)
440
+ grouped.send(:__reduceat_prod__, @offsets, out)
427
441
  out
428
442
  end
429
443
 
@@ -452,15 +466,22 @@ class CACategoricalIterator < CAIterator
452
466
  # @overload minmax
453
467
  # Returns the per-category `[min, max]` pair (each a length-k CArray in the
454
468
  # value data type; empty categories MASKED), matching `CArray#minmax`. Both come
455
- # from the single cached moments pass.
469
+ # from one moments pass.
456
470
  # @return [Array<CArray>]
457
471
  # @overload minmax(axis:)
458
472
  # Per-fiber `[min_ca, max_ca]` along `axis` (each shape [K, ...band], h's data type,
459
473
  # empty group cells MASKED). Ruby Array of two CArrays, not stacked.
474
+ # Both come from one kernel run.
460
475
  # @param axis [Integer]
461
476
  # @return [Array<CArray>]
462
477
  def minmax(axis: nil)
463
- return [min(axis: axis), max(axis: axis)] if axis
478
+ if axis
479
+ # take both off one pass rather than asking min and max separately,
480
+ # which would run the kernel twice now that nothing is kept between
481
+ # calls -- this is the "keep the result" the axis: family expects
482
+ m = axis_moments(axis)
483
+ return [m[:min], m[:max]]
484
+ end
464
485
  [min, max]
465
486
  end
466
487
 
@@ -502,7 +523,7 @@ class CACategoricalIterator < CAIterator
502
523
  # @return [CArray] length-k int64
503
524
  def min_index
504
525
  am = arg_minmax
505
- am ? am[:min] : per_category(CA_INT64) { |s| s.min_index }
526
+ am ? am[:min].copy : per_category(CA_INT64) { |s| s.min_index }
506
527
  end
507
528
 
508
529
  # @overload max_index
@@ -510,7 +531,7 @@ class CACategoricalIterator < CAIterator
510
531
  # @return [CArray] length-k int64
511
532
  def max_index
512
533
  am = arg_minmax
513
- am ? am[:max] : per_category(CA_INT64) { |s| s.max_index }
534
+ am ? am[:max].copy : per_category(CA_INT64) { |s| s.max_index }
514
535
  end
515
536
 
516
537
  # @overload min_addr
@@ -549,14 +570,14 @@ class CACategoricalIterator < CAIterator
549
570
  # {#min_addr} vs the skipped group-local min_index-into-source.
550
571
  # @return [CArray] length-nvalid int64
551
572
  def sort_addr
552
- out = CArray.int64(@grouped.elements)
573
+ out = CArray.int64(grouped.elements)
553
574
  @k.times do |c|
554
575
  lo = @offsets[c]
555
- hi = (c + 1 < @k) ? @offsets[c + 1] : @grouped.elements
576
+ hi = (c + 1 < @k) ? @offsets[c + 1] : grouped.elements
556
577
  next unless hi > lo
557
578
  # View-local sort order of the segment (0..size-1), lifted to grouped
558
579
  # slots, then mapped back to source addresses via perm.
559
- out[lo...hi] = perm[@grouped[lo...hi].sort_addr + lo]
580
+ out[lo...hi] = perm[grouped[lo...hi].sort_addr + lo]
560
581
  end
561
582
  out
562
583
  end
@@ -580,7 +601,7 @@ class CACategoricalIterator < CAIterator
580
601
  def wsum (weights, axis: nil)
581
602
  return axis_wsum_wmean(weights, axis)[0] if axis
582
603
  wg = scatter_weights(weights)
583
- return kernel_weighted(wg)[0] if MONOID_TYPES.include?(@grouped.data_type)
604
+ return kernel_weighted(wg)[0] if MONOID_TYPES.include?(grouped.data_type)
584
605
  fold_weighted(wg, 0.0) { |v, ws| v.wsum(ws) }
585
606
  end
586
607
 
@@ -600,7 +621,7 @@ class CACategoricalIterator < CAIterator
600
621
  def wmean (weights, axis: nil)
601
622
  return axis_wsum_wmean(weights, axis)[1] if axis
602
623
  wg = scatter_weights(weights)
603
- return kernel_weighted(wg)[1] if MONOID_TYPES.include?(@grouped.data_type)
624
+ return kernel_weighted(wg)[1] if MONOID_TYPES.include?(grouped.data_type)
604
625
  fold_weighted(wg, UNDEF) { |v, ws| v.wmean(ws) }
605
626
  end
606
627
 
@@ -641,14 +662,14 @@ class CACategoricalIterator < CAIterator
641
662
  # @return [CArray] shaped like the source value
642
663
  def map (data_type: nil)
643
664
  raise LocalJumpError, "no block given (yield)" unless block_given?
644
- dt = data_type || @grouped.data_type
665
+ dt = data_type || grouped.data_type
645
666
  # Apply the block per category, assembled in grouped (category-contiguous)
646
667
  # order: a same-length result scatters cell for cell, a scalar broadcasts.
647
- transformed = CArray.new(dt, [@grouped.elements])
668
+ transformed = CArray.new(dt, [grouped.elements])
648
669
  @k.times do |c|
649
670
  lo = @offsets[c]
650
- hi = (c + 1 < @k) ? @offsets[c + 1] : @grouped.elements
651
- transformed[lo...hi] = yield(@grouped[lo...hi]) if hi > lo
671
+ hi = (c + 1 < @k) ? @offsets[c + 1] : grouped.elements
672
+ transformed[lo...hi] = yield(grouped[lo...hi]) if hi > lo
652
673
  end
653
674
  # Scatter back to source positions via the permutation (grouped-order source
654
675
  # indices). Excluded cells are absent from perm and stay UNDEF.
@@ -676,19 +697,19 @@ class CACategoricalIterator < CAIterator
676
697
  # preserve the value data type, cumcount -> int64 (1-based within-category
677
698
  # ordinal); an object value data type is carried by the kernel's object branch.
678
699
 
679
- # @overload cumsum
700
+ # @!method cumsum
680
701
  # Per-category inclusive running sum (float64), source-shaped.
681
702
  # @return [CArray]
682
- # @overload cumprod
703
+ # @!method cumprod
683
704
  # Per-category inclusive running product (float64), source-shaped.
684
705
  # @return [CArray]
685
- # @overload cummax
706
+ # @!method cummax
686
707
  # Per-category inclusive running maximum (value data type), source-shaped.
687
708
  # @return [CArray]
688
- # @overload cummin
709
+ # @!method cummin
689
710
  # Per-category inclusive running minimum (value data type), source-shaped.
690
711
  # @return [CArray]
691
- # @overload cumcount
712
+ # @!method cumcount
692
713
  # Per-category 1-based within-category ordinal (int64), source-shaped.
693
714
  # @return [CArray]
694
715
  [:cumsum, :cumprod, :cummax, :cummin, :cumcount].each do |op|
@@ -697,16 +718,63 @@ class CACategoricalIterator < CAIterator
697
718
 
698
719
  private
699
720
 
700
- # Axis-aware moments (count / sum / min / max) — computed once per axis via
701
- # the fused per-fiber scatter-reduce C kernel and cached (matches the flat
702
- # #moments caching in spirit: pay one kernel per {iterator, axis} pair, share
703
- # across sum / mean / min / max / minmax / count* consumers). Returns
721
+ # The category-major copy every no-axis reduction works from. It does not
722
+ # exist when the classifier does not line up cell-for-cell with the value;
723
+ # such an iterator answers the per-fiber form only, and says so here rather
724
+ # than letting a nil surface as whatever NoMethodError it reaches first.
725
+ def grouped
726
+ @grouped || raise(ArgumentError, @no_flat)
727
+ end
728
+
729
+ # Per-category cell counts, alongside #grouped and unavailable for the same
730
+ # reason.
731
+ def counts
732
+ @elements || raise(ArgumentError, @no_flat)
733
+ end
734
+
735
+ # Axis-aware count of present cells. Counting how many cells fall in a
736
+ # group does not look at what is in them, so it is taken from the codes and
737
+ # the value's mask rather than from the fused moments kernel, which is
738
+ # numeric-only and refused a complex, boolean or object payload for an
739
+ # answer that never depended on the payload.
740
+ def axis_counts (axis)
741
+ h = @value
742
+ unless axis.is_a?(Integer) && axis >= 0 && axis < h.ndim
743
+ raise ArgumentError,
744
+ "group_by_category.count(axis: #{axis.inspect}): axis must be an " \
745
+ "Integer in [0, #{h.ndim}) for source h with shape #{h.shape}"
746
+ end
747
+ full_c = resolve_axis_codes(@cat.codes, h.shape, axis)
748
+ band = h.shape.dup; band.delete_at(axis)
749
+ out = CArray.int64(*([@k] + band))
750
+ present = h.has_mask? ? h.is_not_masked : nil
751
+ slot = [nil] * (band.size + 1)
752
+ @k.times do |c|
753
+ # a masked code belongs to no group, and eq yields UNDEF there
754
+ belongs = full_c.eq(c)
755
+ belongs = belongs.strip_mask(false) if belongs.has_mask?
756
+ belongs = belongs & present if present
757
+ slot[0] = c
758
+ out[*slot] = belongs.int64.sum(axis: axis)
759
+ end
760
+ out
761
+ end
762
+
763
+ # Axis-aware moments (count / sum / min / max) via the fused per-fiber
764
+ # scatter-reduce C kernel. Returns
704
765
  # `{count: <int64>, sum: <float64>, min: <h's type, masked>, max: <h's type, masked>}`,
705
766
  # all shape [K, ...band].
767
+ #
768
+ # Read fresh on every call, deliberately. It used to be kept per axis, and
769
+ # since the rest of the axis: family (prod, the variance family, wsum /
770
+ # wmean) reads the source when asked, half of the family answered about the
771
+ # values as they were and half about the values as they are. Writing
772
+ # through another view of the source between two calls got you a mean of 2.0
773
+ # beside a variance of 4704.5 for the same cell, which is not a pair any
774
+ # data can produce. The axis: path materialises nothing else, so holding
775
+ # this one thing was the odd choice; a caller who wants a fused kernel's
776
+ # four answers shares them by keeping the result.
706
777
  def axis_moments (axis)
707
- @axis_moments_cache ||= {}
708
- cached = @axis_moments_cache[axis]
709
- return cached if cached
710
778
  h = @value
711
779
  unless axis.is_a?(Integer) && axis >= 0 && axis < h.ndim
712
780
  raise ArgumentError,
@@ -722,13 +790,13 @@ class CACategoricalIterator < CAIterator
722
790
  maxs = CArray.new(h.data_type, out_shape)
723
791
  h.__send__(:__fiber_scatter_moments__, codes_h_shape, axis, @k,
724
792
  counts, sums, mins, maxs)
725
- @axis_moments_cache[axis] = {count: counts, sum: sums, min: mins, max: maxs}
793
+ {count: counts, sum: sums, min: mins, max: maxs}
726
794
  end
727
795
 
728
796
  # Axis-aware sum: the moments sum is already the core fold in the core's own
729
797
  # type, so it is handed back as is (an empty group cell carries identity 0.0).
730
798
  def axis_sum (axis)
731
- axis_moments(axis)[:sum].copy
799
+ axis_moments(axis)[:sum]
732
800
  end
733
801
 
734
802
  # Axis-aware mean: sums / counts (float64); empty group cells (count=0) MASKED.
@@ -831,22 +899,20 @@ class CACategoricalIterator < CAIterator
831
899
  def resolve_axis_codes (codes, h_shape, axis)
832
900
  ndim = h_shape.size
833
901
  band = h_shape.dup; band.delete_at(axis)
834
- case codes.ndim
835
- when 1
836
- unless codes.shape == [h_shape[axis]]
837
- axis_shape_mismatch!(codes.shape, h_shape, axis, band)
838
- end
902
+ # Chosen by shape, not by rank. For a 2-D source the case A shape and the
903
+ # band-only shape are both rank 1, so choosing by rank took case A every
904
+ # time and band-only could never be reached there -- while the refusal
905
+ # went on to list the very shape it was refusing among the ones it
906
+ # accepts. When both fit, which a square source makes possible, case A
907
+ # wins: classifying along the reduce axis is the reading that holds at
908
+ # every rank.
909
+ case
910
+ when codes.shape == [h_shape[axis]] # case A
839
911
  view_shape = Array.new(ndim, 1); view_shape[axis] = h_shape[axis]
840
912
  codes.reshape(*view_shape).broadcast_to(*h_shape)
841
- when ndim
842
- unless codes.shape == h_shape
843
- axis_shape_mismatch!(codes.shape, h_shape, axis, band)
844
- end
913
+ when codes.shape == h_shape # case B
845
914
  codes
846
- when ndim - 1
847
- unless codes.shape == band
848
- axis_shape_mismatch!(codes.shape, h_shape, axis, band)
849
- end
915
+ when codes.shape == band # band-only
850
916
  view_shape = h_shape.dup; view_shape[axis] = 1
851
917
  codes.reshape(*view_shape).broadcast_to(*h_shape)
852
918
  else
@@ -855,8 +921,11 @@ class CACategoricalIterator < CAIterator
855
921
  end
856
922
 
857
923
  def axis_shape_mismatch! (cat_shape, h_shape, axis, band)
924
+ # No method name: the one place that resolves this serves sum, mean, min,
925
+ # max, count and the rest alike, and naming one of them would be wrong for
926
+ # the others. The backtrace says which was called.
858
927
  raise ArgumentError,
859
- "group_by_category.sum(axis: #{axis}): cat.shape=#{cat_shape.inspect} " \
928
+ "group_by_category (axis: #{axis}): cat.shape=#{cat_shape.inspect} " \
860
929
  "does not fit any of the 3 accepted forms for h.shape=#{h_shape.inspect}: " \
861
930
  "case A cat.shape=[#{h_shape[axis]}], " \
862
931
  "case B cat.shape=#{h_shape.inspect}, " \
@@ -875,9 +944,28 @@ class CACategoricalIterator < CAIterator
875
944
  # one grouped axis, the flat codes as the single bundle. The kernel emits in
876
945
  # source order, so the flat result reshapes straight back to the source shape.
877
946
  def scan (op)
878
- @value.reshape(@value.elements)
879
- .__axis_group_scan__([0], [[@codes, @k, [0]]], op)
880
- .reshape(*@src_shape)
947
+ scan_source.reshape(@value.elements)
948
+ .__axis_group_scan__([0], [[@codes, @k, [0]]], op)
949
+ .reshape(*@src_shape)
950
+ end
951
+
952
+ # The values as they were when the iterator was built, in source order.
953
+ # Every no-axis reduction works from the category-major copy taken then; the
954
+ # scans read @value, so a write through the source between two calls used to
955
+ # be visible to a cumsum and not to a sum, off one iterator.
956
+ #
957
+ # Rebuilt rather than copied a second time: @perm is exactly the classified
958
+ # cells and @grouped holds their values and their masks, which is everything
959
+ # a scan reads -- a cell classified by nothing is skipped on its code, before
960
+ # its value is looked at. So this costs nothing until a scan asks for it, and
961
+ # nothing at all for an iterator that never scans.
962
+ def scan_source
963
+ @scan_source ||=
964
+ begin
965
+ snap = @value.template
966
+ snap.reshape(snap.elements)[perm] = grouped
967
+ snap
968
+ end
881
969
  end
882
970
 
883
971
 
@@ -900,7 +988,7 @@ class CACategoricalIterator < CAIterator
900
988
  "value.elements (#{@codes.elements})"
901
989
  end
902
990
  wf = weights.float64
903
- wg = CArray.float64(@grouped.elements)
991
+ wg = CArray.float64(grouped.elements)
904
992
  @codes.send(:__categorical_scatter__, wf.reshape(wf.elements),
905
993
  @offsets.copy, wg, @k)
906
994
  wg
@@ -925,7 +1013,7 @@ class CACategoricalIterator < CAIterator
925
1013
  def kernel_weighted (wg)
926
1014
  ws = CArray.float64(@k)
927
1015
  wm = CArray.float64(@k)
928
- @grouped.send(:__reduceat_wsum_wmean__, @offsets, wg, ws, wm)
1016
+ grouped.send(:__reduceat_wsum_wmean__, @offsets, wg, ws, wm)
929
1017
  [ws, wm]
930
1018
  end
931
1019
 
@@ -935,8 +1023,8 @@ class CACategoricalIterator < CAIterator
935
1023
  out = CArray.float64(@k)
936
1024
  @k.times do |c|
937
1025
  lo = @offsets[c]
938
- hi = (c + 1 < @k) ? @offsets[c + 1] : @grouped.elements
939
- out[c] = hi > lo ? yield(@grouped[lo...hi], wg[lo...hi]) : empty
1026
+ hi = (c + 1 < @k) ? @offsets[c + 1] : grouped.elements
1027
+ out[c] = hi > lo ? yield(grouped[lo...hi], wg[lo...hi]) : empty
940
1028
  end
941
1029
  out
942
1030
  end
@@ -947,8 +1035,8 @@ class CACategoricalIterator < CAIterator
947
1035
  # contract we want (identity for sum, UNDEF for ratios).
948
1036
  def group_slice (c)
949
1037
  lo = @offsets[c]
950
- hi = (c + 1 < @k) ? @offsets[c + 1] : @grouped.elements
951
- hi > lo ? @grouped[lo...hi] : @empty
1038
+ hi = (c + 1 < @k) ? @offsets[c + 1] : grouped.elements
1039
+ hi > lo ? grouped[lo...hi] : @empty
952
1040
  end
953
1041
 
954
1042
  # Single-pass reduceat moments (count / sum / min / max per category), computed
@@ -964,13 +1052,13 @@ class CACategoricalIterator < CAIterator
964
1052
  def moments
965
1053
  return @moments if defined?(@moments)
966
1054
  @moments =
967
- if MONOID_TYPES.include?(@grouped.data_type)
968
- dt = @grouped.data_type
1055
+ if MONOID_TYPES.include?(grouped.data_type)
1056
+ dt = grouped.data_type
969
1057
  counts = CArray.int64(@k)
970
1058
  sums = CArray.float64(@k)
971
1059
  mins = CArray.new(dt, [@k])
972
1060
  maxs = CArray.new(dt, [@k])
973
- @grouped.send(:__reduceat_moments__, @offsets, counts, sums, mins, maxs)
1061
+ grouped.send(:__reduceat_moments__, @offsets, counts, sums, mins, maxs)
974
1062
  { count: counts, sum: sums, min: mins, max: maxs }
975
1063
  end
976
1064
  end
@@ -980,10 +1068,10 @@ class CACategoricalIterator < CAIterator
980
1068
  def arg_minmax
981
1069
  return @arg_minmax if defined?(@arg_minmax)
982
1070
  @arg_minmax =
983
- if MONOID_TYPES.include?(@grouped.data_type)
1071
+ if MONOID_TYPES.include?(grouped.data_type)
984
1072
  mn = CArray.int64(@k)
985
1073
  mx = CArray.int64(@k)
986
- @grouped.send(:__reduceat_argminmax__, @offsets, mn, mx)
1074
+ grouped.send(:__reduceat_argminmax__, @offsets, mn, mx)
987
1075
  { min: mn, max: mx }
988
1076
  end
989
1077
  end
@@ -994,10 +1082,10 @@ class CACategoricalIterator < CAIterator
994
1082
  def all_any
995
1083
  return @all_any if defined?(@all_any)
996
1084
  @all_any =
997
- if @grouped.data_type == CA_BOOLEAN
1085
+ if grouped.data_type == CA_BOOLEAN
998
1086
  a = CArray.boolean(@k)
999
1087
  o = CArray.boolean(@k)
1000
- @grouped.send(:__reduceat_all_any__, @offsets, a, o)
1088
+ grouped.send(:__reduceat_all_any__, @offsets, a, o)
1001
1089
  { all: a, any: o }
1002
1090
  end
1003
1091
  end
@@ -1015,13 +1103,52 @@ class CACategoricalIterator < CAIterator
1015
1103
  # (`sum` on an integer promotes, `accumulate` stays, `min` / `max` keep the
1016
1104
  # type but a boolean widens, `prod` on an object stays an object). A payload
1017
1105
  # the core refuses to fold this way raises here, with the core's own error.
1106
+ # Probes the core with a one-cell array of the value's data type and takes
1107
+ # the answer's. Asks @value rather than the grouped copy, which has the same
1108
+ # data type but does not exist on an iterator that answers only the
1109
+ # per-fiber form -- and `accumulate(axis:)`, the one axis: member that needs
1110
+ # this, is exactly the case that would have found it missing.
1018
1111
  def core_reduce_type (op, *args)
1019
1112
  (@core_reduce_type ||= {})[[op, args]] ||=
1020
- CArray.new(@grouped.data_type, [1, 1]).public_send(op, *args, axis: 1).data_type
1113
+ @value.face? && @value.elements.zero? ?
1114
+ CA_OBJECT : core_probe.public_send(op, *args, axis: 1).data_type
1115
+ end
1116
+
1117
+ # A one-cell array of the same kind as the values, so the core answers about
1118
+ # the same thing the group slices will hand back. For a Face that is a view
1119
+ # of the values themselves rather than a blank: a Face cannot be allocated
1120
+ # from its surface data type alone, and a blank storage array is not a valid
1121
+ # Face for every one of them -- a const string's record indexes a shared
1122
+ # pool, so a zeroed record points nowhere.
1123
+ def core_probe
1124
+ return CArray.new(@value.data_type, [1, 1]) unless @value.face?
1125
+ @value.reshape(@value.elements)[[0]].reshape(1, 1)
1126
+ end
1127
+
1128
+ # Whether an output can be built by lifting one. A Face is filled by writing
1129
+ # surface values into storage, so this needs a Face that can be written
1130
+ # into; a read-only one -- a const string's records index a shared pool, a
1131
+ # categorical's codes index a vocabulary -- has no blank form to fill.
1132
+ def face_output?
1133
+ @value.face? && ! @value.read_only?
1021
1134
  end
1022
1135
 
1023
1136
  def per_category (data_type)
1024
- out = CArray.new(data_type, [@k])
1137
+ out = if ! @value.face? || data_type != @value.data_type
1138
+ CArray.new(data_type, [@k])
1139
+ elsif face_output?
1140
+ # the core answered in the values' own Face, so the output is one
1141
+ # too: CATime#min hands back a CATime::Element, which only a
1142
+ # CATime has anywhere to put
1143
+ CArray.new(@value.parent.data_type, [@k],
1144
+ bytes: @value.parent.bytes).face_lift(@value)
1145
+ else
1146
+ # a read-only Face cannot be filled, so its answers are collected
1147
+ # as the surface objects they already are
1148
+ CArray.new(CA_OBJECT, [@k])
1149
+ end
1150
+ # A member the core refuses for this Face still refuses: the group slice is
1151
+ # the Face, so the refusal comes from there, in the core's own words.
1025
1152
  @k.times { |c| out[c] = yield(group_slice(c)) }
1026
1153
  out
1027
1154
  end