carray 3.0.0 → 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 (202) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +2 -1
  3. data/CHANGELOG.md +845 -3
  4. data/{NEWS.md → CHANGELOG.v1.md} +1 -1
  5. data/README.md +33 -33
  6. data/carray.gemspec +12 -8
  7. data/ext/ca_axis_dispatch.c +33 -4
  8. data/ext/ca_axis_group.c +256 -114
  9. data/ext/ca_binop_dispatch.c +5 -6
  10. data/ext/ca_binop_dispatch.h +0 -7
  11. data/ext/ca_categorical_iterator.c +121 -67
  12. data/ext/ca_for_each_element.h +6 -8
  13. data/ext/ca_group_iter.c +1 -1
  14. data/ext/ca_kernel_iterator.c +386 -74
  15. data/ext/ca_kernel_iterator.h +145 -45
  16. data/ext/ca_obj_array.c +62 -20
  17. data/ext/ca_obj_bincmp.c +57 -27
  18. data/ext/ca_obj_binop.c +86 -30
  19. data/ext/ca_obj_block.c +4 -4
  20. data/ext/ca_obj_const_string.c +88 -27
  21. data/ext/ca_obj_face.c +104 -0
  22. data/ext/ca_obj_face.h +48 -1
  23. data/ext/ca_obj_fake.c +11 -0
  24. data/ext/ca_obj_fixlen_string.c +21 -6
  25. data/ext/ca_obj_grid.c +11 -3
  26. data/ext/ca_obj_meld.c +125 -22
  27. data/ext/ca_obj_moncmp.c +33 -14
  28. data/ext/ca_obj_monop.c +19 -7
  29. data/ext/ca_obj_object.c +107 -4
  30. data/ext/ca_obj_record.c +3 -1
  31. data/ext/ca_obj_refer.c +30 -6
  32. data/ext/ca_obj_roll.c +13 -5
  33. data/ext/ca_obj_select.c +49 -34
  34. data/ext/ca_obj_select_axis.c +16 -6
  35. data/ext/ca_obj_shift.c +3 -3
  36. data/ext/ca_obj_stack.c +3 -8
  37. data/ext/ca_obj_stride.c +178 -24
  38. data/ext/ca_obj_string.c +11 -5
  39. data/ext/ca_obj_tile.c +13 -5
  40. data/ext/ca_obj_time.c +3 -1
  41. data/ext/ca_obj_timedelta.c +3 -1
  42. data/ext/ca_obj_transpose.c +1 -1
  43. data/ext/ca_obj_triop.c +62 -23
  44. data/ext/ca_obj_window.c +94 -19
  45. data/ext/ca_op_cmplx64.h +123 -0
  46. data/ext/ca_op_ipower.c +1 -5
  47. data/ext/ca_rng_normal.h +42 -0
  48. data/ext/ca_rng_xoshiro256pp.h +105 -0
  49. data/ext/ca_sort_kernels.h +5 -5
  50. data/ext/ca_sweep_engine.c +362 -155
  51. data/ext/ca_sweep_engine.h +39 -9
  52. data/ext/ca_transform_common.c +7 -0
  53. data/ext/carray.h +148 -132
  54. data/ext/carray_access.c +83 -49
  55. data/ext/carray_address_basis.c +590 -0
  56. data/ext/carray_bincount.c +8 -8
  57. data/ext/carray_broadcast.c +102 -9
  58. data/ext/carray_build_flags.h +3 -0
  59. data/ext/carray_call_cfunc.c +2010 -335
  60. data/ext/carray_call_cfunc.h +153 -0
  61. data/ext/carray_cast.c +184 -72
  62. data/ext/carray_conversion.c +22 -22
  63. data/ext/carray_copy.c +67 -32
  64. data/ext/carray_core.c +140 -13
  65. data/ext/carray_count.c +10 -11
  66. data/ext/carray_element.c +7 -7
  67. data/ext/carray_factorize.c +72 -51
  68. data/ext/carray_hold.c +1 -1
  69. data/ext/carray_index_classifier.c +7 -21
  70. data/ext/carray_internal.h +36 -3
  71. data/ext/carray_kernels_bincmp.c +1 -0
  72. data/ext/carray_kernels_binop.c +320 -298
  73. data/ext/carray_kernels_init.c +1095 -0
  74. data/ext/carray_kernels_map.c +1 -0
  75. data/ext/carray_kernels_moncmp.c +1 -0
  76. data/ext/carray_kernels_monop.c +412 -411
  77. data/ext/carray_kernels_reduce_aggregate.c +256 -87
  78. data/ext/carray_kernels_reduce_boolean.c +1 -0
  79. data/ext/carray_kernels_reduce_cumulative.c +324 -54
  80. data/ext/carray_kernels_reduce_extreme.c +605 -58
  81. data/ext/carray_kernels_reduce_variance.c +1 -0
  82. data/ext/carray_kernels_scan.c +5 -4
  83. data/ext/carray_kernels_search.c +187 -106
  84. data/ext/carray_kernels_sort.c +1 -0
  85. data/ext/carray_kernels_triop.c +1 -0
  86. data/ext/carray_lazy.c +175 -5
  87. data/ext/carray_loop.c +7 -1
  88. data/ext/carray_mask.c +29 -13
  89. data/ext/carray_median_percentile.c +56 -1
  90. data/ext/carray_memory_view.c +46 -21
  91. data/ext/carray_operator.c +50 -53
  92. data/ext/carray_order.c +1 -1
  93. data/ext/carray_partition.c +4 -4
  94. data/ext/carray_random.c +391 -47
  95. data/ext/carray_scatter.c +1 -1
  96. data/ext/carray_slab.c +13 -0
  97. data/ext/carray_sort.c +22 -24
  98. data/ext/carray_sort_kernel.c +10 -10
  99. data/ext/carray_test.c +1 -1
  100. data/ext/extconf.rb +18 -0
  101. data/ext/mk_call_cfunc.rb +323 -145
  102. data/ext/mkkernel.rb +651 -129
  103. data/ext/ruby_carray.c +28 -17
  104. data/ext/version.h +4 -4
  105. data/lib/carray/autoload_carray.rb +24 -3
  106. data/lib/carray/autoload_method_extension.rb +14 -1
  107. data/lib/carray/axis_group.rb +84 -7
  108. data/lib/carray/basics.rb +63 -59
  109. data/lib/carray/bincount_nd.rb +12 -12
  110. data/lib/carray/block_iterator.rb +111 -25
  111. data/lib/carray/categorical.rb +154 -37
  112. data/lib/carray/categorical_iterator.rb +300 -151
  113. data/lib/carray/conditional.rb +14 -14
  114. data/lib/carray/const_string.rb +131 -27
  115. data/lib/carray/construct.rb +61 -0
  116. data/lib/carray/core_extensions.rb +62 -5
  117. data/lib/carray/data_type_extension.rb +21 -1
  118. data/lib/carray/data_type_limits.rb +91 -0
  119. data/lib/carray/fixlen_string.rb +1 -1
  120. data/lib/carray/frame/concat.rb +3 -3
  121. data/lib/carray/frame/convert.rb +1 -1
  122. data/lib/carray/frame/csv_parser.rb +11 -4
  123. data/lib/carray/frame/frame.rb +85 -21
  124. data/lib/carray/frame/group.rb +36 -3
  125. data/lib/carray/frame/io.rb +224 -15
  126. data/lib/carray/frame/records.rb +18 -4
  127. data/lib/carray/frame/sort.rb +1 -1
  128. data/lib/carray/frame/verbs.rb +32 -12
  129. data/lib/carray/fuse_source.rb +123 -0
  130. data/lib/carray/fusion.rb +218 -0
  131. data/lib/carray/histogram.rb +16 -16
  132. data/lib/carray/inspect.rb +43 -16
  133. data/lib/carray/iterator.rb +147 -3
  134. data/lib/carray/lazy.rb +88 -73
  135. data/lib/carray/mask_gap_fill.rb +3 -1
  136. data/lib/carray/meld_reduce.rb +2 -2
  137. data/lib/carray/methods/align_addr.rb +1 -1
  138. data/lib/carray/methods/composition.rb +1 -1
  139. data/lib/carray/methods/discovery_along.rb +74 -0
  140. data/lib/carray/methods/factorize.rb +50 -0
  141. data/lib/carray/methods/is_in.rb +25 -14
  142. data/lib/carray/methods/locate_addr.rb +81 -3
  143. data/lib/carray/methods/mask_duplicates.rb +36 -2
  144. data/lib/carray/methods/meshgrid.rb +4 -5
  145. data/lib/carray/methods/mode.rb +2 -2
  146. data/lib/carray/methods/nunique.rb +23 -2
  147. data/lib/carray/methods/repeat.rb +110 -0
  148. data/lib/carray/methods/snap.rb +7 -2
  149. data/lib/carray/methods/unique.rb +44 -4
  150. data/lib/carray/methods/value_counts.rb +2 -2
  151. data/lib/carray/rng.rb +86 -0
  152. data/lib/carray/runtime.rb +0 -19
  153. data/lib/carray/slab_iterator.rb +77 -19
  154. data/lib/carray/string_operation_extension.rb +10 -6
  155. data/lib/carray/time.rb +906 -491
  156. data/lib/carray/window_iterator.rb +421 -27
  157. data/lib/carray.rb +7 -5
  158. data/yard-stubs/ca_obj_array.rb +385 -0
  159. data/yard-stubs/ca_obj_bitarray.rb +38 -0
  160. data/yard-stubs/ca_obj_bitfield.rb +43 -0
  161. data/yard-stubs/ca_obj_block.rb +68 -0
  162. data/yard-stubs/ca_obj_byte_swap.rb +56 -0
  163. data/yard-stubs/ca_obj_fake.rb +31 -0
  164. data/yard-stubs/ca_obj_farray.rb +32 -0
  165. data/yard-stubs/ca_obj_field.rb +45 -0
  166. data/yard-stubs/ca_obj_grid.rb +35 -0
  167. data/yard-stubs/ca_obj_refer.rb +72 -0
  168. data/yard-stubs/ca_obj_roll.rb +45 -0
  169. data/yard-stubs/ca_obj_shift.rb +43 -0
  170. data/yard-stubs/ca_obj_stride.rb +181 -0
  171. data/yard-stubs/ca_obj_tile.rb +29 -0
  172. data/yard-stubs/ca_obj_transpose.rb +40 -0
  173. data/yard-stubs/ca_obj_window.rb +57 -0
  174. data/yard-stubs/carray_access.rb +131 -0
  175. data/yard-stubs/carray_attribute.rb +246 -0
  176. data/yard-stubs/carray_broadcast.rb +37 -0
  177. data/yard-stubs/carray_cast.rb +489 -0
  178. data/yard-stubs/carray_class.rb +65 -0
  179. data/yard-stubs/carray_conversion.rb +76 -0
  180. data/yard-stubs/carray_copy.rb +79 -0
  181. data/yard-stubs/carray_core.rb +34 -0
  182. data/yard-stubs/carray_count.rb +84 -0
  183. data/yard-stubs/carray_element.rb +108 -0
  184. data/yard-stubs/carray_generate.rb +66 -0
  185. data/yard-stubs/carray_lazy.rb +228 -0
  186. data/yard-stubs/carray_loop.rb +140 -0
  187. data/yard-stubs/carray_mask.rb +259 -0
  188. data/yard-stubs/carray_math.rb +1615 -0
  189. data/yard-stubs/carray_mathfunc.rb +45 -0
  190. data/yard-stubs/carray_median_percentile.rb +103 -0
  191. data/yard-stubs/carray_memory_view.rb +163 -0
  192. data/yard-stubs/carray_order.rb +252 -0
  193. data/yard-stubs/carray_random.rb +89 -0
  194. data/yard-stubs/carray_scatter.rb +106 -0
  195. data/yard-stubs/carray_slab.rb +59 -0
  196. data/yard-stubs/carray_sort.rb +165 -0
  197. data/yard-stubs/carray_test.rb +85 -0
  198. data/yard-stubs/carray_undef.rb +64 -0
  199. data/yard-stubs/carray_utils.rb +97 -0
  200. data/yard-stubs/ruby_carray.rb +193 -0
  201. metadata +67 -10
  202. data/ext/ca_obj_unbound_repeat.c +0 -496
@@ -26,18 +26,18 @@
26
26
  # the mask contract (empty / all-masked -> identity for sum/prod, UNDEF for
27
27
  # ratios) carries through unchanged. Results are length-k CArrays aligned to
28
28
  # `cat.labels`; undefined slots are MASKED cells (never magic floats). Output
29
- # dtype and the empty / all-masked answer per method:
29
+ # data type and the empty / all-masked answer per method:
30
30
  #
31
31
  # elements -> int64, classified cells (incl. masked)
32
32
  # count / count_not_masked / count_masked / count(v) -> int64
33
- # sum -> value dtype, empty/all-masked = 0 (identity)
33
+ # sum -> value data type, empty/all-masked = 0 (identity)
34
34
  # prod -> float64, empty/all-masked = 1 (identity)
35
- # max / min -> value dtype, empty/all-masked = MASKED
35
+ # max / min -> value data type, empty/all-masked = MASKED
36
36
  # mean -> float64, empty/all-masked = MASKED
37
37
  # median / percentile -> float64, empty/all-masked = MASKED
38
38
  # variance / stddev (sample, ddof=1) -> float64, empty/all-masked = MASKED,
39
39
  # single value = 0.0 (n=1 contract)
40
- # all / any -> boolean (boolean value dtype only)
40
+ # all / any -> boolean (boolean value data type only)
41
41
  # labels -> cat.labels
42
42
  #
43
43
  # Generic iteration (the escape hatch for statistics not in the named surface),
@@ -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
- # one of the 3 axis: cases (§2.2 of PROPOSAL_CATEGORICAL_REDUCE_AXIS);
129
- # any no-axis reduce called on this iterator will surface the mismatch
130
- # because @grouped stays undefined.
128
+ # one of the 3 axis: cases;
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>)
@@ -217,21 +231,20 @@ class CACategoricalIterator < CAIterator
217
231
  # @return [CArray] length-k int64, aligned to {#labels}
218
232
  # @overload count(axis:)
219
233
  # No-arg + axis: = per-fiber per-category count_not_masked (shape [K, ...band]).
220
- # `count(v, axis:)` (value equality) and `count(UNDEF, axis:)` are deferred
221
- # to Phase 3 of PROPOSAL_CATEGORICAL_REDUCE_AXIS.
234
+ # `count(v, axis:)` (value equality) and `count(UNDEF, axis:)` are not
235
+ # implemented; use them without `axis:`.
222
236
  # @param axis [Integer]
223
237
  # @return [CArray]
224
238
  def count (*args, axis: nil)
225
239
  if axis
226
240
  return count_not_masked(axis: axis) if args.empty?
227
241
  raise NotImplementedError,
228
- "CACategoricalIterator#count(v, axis:) not yet implemented — " \
229
- "value-equality count with axis: deferred to Phase 3 of " \
230
- "PROPOSAL_CATEGORICAL_REDUCE_AXIS."
242
+ "CACategoricalIterator#count(v, axis:) is not implemented — " \
243
+ "value-equality count is available without axis:."
231
244
  end
232
245
  return count_not_masked if args.empty?
233
246
  # Delegate per group to CArray#count (handles count(UNDEF) -> masked count and
234
- # count(v) alike, with core's exact dtype equality). The group slice is a
247
+ # count(v) alike, with core's exact data type equality). The group slice is a
235
248
  # CABlock, whose own #count is the block geometry accessor, so dispatch
236
249
  # CArray#count explicitly. (Not fused: a value-equality reduceat would have
237
250
  # to reproduce core's cross-type / out-of-range equality exactly.)
@@ -244,70 +257,84 @@ class CACategoricalIterator < CAIterator
244
257
  # Empty categories are `0`.
245
258
  # @return [CArray]
246
259
  # @overload count_masked(axis:)
247
- # Deferred to Phase 3 of PROPOSAL_CATEGORICAL_REDUCE_AXIS (needs a separate
248
- # "assigned count" scatter that counts cells regardless of value mask).
260
+ # Not implemented; call it without `axis:`.
249
261
  # @param axis [Integer]
250
262
  # @return [CArray]
251
263
  def count_masked(axis: nil)
252
264
  if axis
253
265
  raise NotImplementedError,
254
- "CACategoricalIterator#count_masked(axis:) not yet implemented — " \
255
- "deferred to Phase 3 of PROPOSAL_CATEGORICAL_REDUCE_AXIS " \
256
- "(needs a separate value-mask-only scatter kernel)."
266
+ "CACategoricalIterator#count_masked(axis:) is not implemented — " \
267
+ "call it without axis:."
257
268
  end
258
269
  m = moments
259
- 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 }
260
271
  end
261
272
 
262
273
  # @overload sum
263
- # Returns per-category sums in the value dtype. An empty or fully-masked
264
- # category sums the empty set, which is the additive identity `0`
265
- # (unmasked) — the same contract as `CArray#sum` on an empty / all-masked
266
- # array.
274
+ # Returns per-category sums in the data type `CArray#sum` promotes the value
275
+ # to (float64 for an integer value). `accumulate` is the same fold kept in
276
+ # the value's own type. An empty or fully-masked category sums the empty
277
+ # set, which is the additive identity `0` (unmasked) — the same contract as
278
+ # `CArray#sum` on an empty / all-masked array.
267
279
  # @return [CArray]
268
280
  # @overload sum(axis:)
269
281
  # Returns per-category sums per fiber along `axis`. Cat may be 1-D (case
270
282
  # A, broadcasts across band axes), same rank as source (case B, per-fiber
271
283
  # independent classifier), or one rank less (band-only, constant along
272
- # reduce axis). Output shape = `[K, ...source.shape without axis]`. See
273
- # PROPOSAL_CATEGORICAL_REDUCE_AXIS.
284
+ # reduce axis). Output shape = `[K, ...source.shape without axis]`.
274
285
  # @param axis [Integer] reduce axis of the source value.
275
286
  # @return [CArray]
276
287
  def sum(axis: nil)
277
288
  return axis_sum(axis) if axis
278
289
  m = moments
279
- return per_category(@grouped.data_type) { |s| s.sum } unless m
280
- out = CArray.new(@grouped.data_type, [@k])
281
- out[] = m[:sum] # cast float64 sums -> value dtype (empty -> 0)
282
- out
290
+ return per_category(core_reduce_type(:sum)) { |s| s.sum } unless m
291
+ m[:sum].copy # the moments sum IS the core fold (empty -> 0.0)
292
+ end
293
+
294
+ # @overload accumulate
295
+ # Returns per-category sums folded in the value's own data type, wrapping at
296
+ # its width, as the core `accumulate` does. This is the exact in-type fold:
297
+ # `sum` reads its answer off a float64 moment and casts back, so it loses
298
+ # the low bits of a wide integer payload and does not wrap. An empty or
299
+ # fully-masked category accumulates the empty set, the additive identity `0`
300
+ # (unmasked).
301
+ # @return [CArray]
302
+ # @overload accumulate(axis:)
303
+ # Per-fiber per-category in-type sums along `axis`. Output shape =
304
+ # `[K, ...source.shape without axis]`.
305
+ # @param axis [Integer] reduce axis of the source value.
306
+ # @return [CArray]
307
+ def accumulate(axis: nil)
308
+ return axis_by_masked_copy(axis, :accumulate, core_reduce_type(:accumulate)) if axis
309
+ per_category(core_reduce_type(:accumulate)) { |s| s.accumulate }
283
310
  end
284
311
 
285
312
  # @overload max
286
- # Returns per-category maxima in the value dtype. Empty categories are
313
+ # Returns per-category maxima in the value data type. Empty categories are
287
314
  # MASKED.
288
315
  # @return [CArray]
289
316
  # @overload max(axis:)
290
- # Per-fiber per-category maxima along `axis` (h dtype, masked where empty).
317
+ # Per-fiber per-category maxima along `axis` (h's data type, masked where empty).
291
318
  # @param axis [Integer]
292
319
  # @return [CArray]
293
320
  def max(axis: nil)
294
321
  return axis_moments(axis)[:max] if axis
295
322
  m = moments
296
- m ? m[:max] : per_category(@grouped.data_type) { |s| s.max }
323
+ m ? m[:max].copy : per_category(core_reduce_type(:max)) { |s| s.max }
297
324
  end
298
325
 
299
326
  # @overload min
300
- # Returns per-category minima in the value dtype. Empty categories are
327
+ # Returns per-category minima in the value data type. Empty categories are
301
328
  # MASKED.
302
329
  # @return [CArray]
303
330
  # @overload min(axis:)
304
- # Per-fiber per-category minima along `axis` (h dtype, masked where empty).
331
+ # Per-fiber per-category minima along `axis` (h's data type, masked where empty).
305
332
  # @param axis [Integer]
306
333
  # @return [CArray]
307
334
  def min(axis: nil)
308
335
  return axis_moments(axis)[:min] if axis
309
336
  m = moments
310
- m ? m[:min] : per_category(@grouped.data_type) { |s| s.min }
337
+ m ? m[:min].copy : per_category(core_reduce_type(:min)) { |s| s.min }
311
338
  end
312
339
 
313
340
  # @overload mean
@@ -320,7 +347,7 @@ class CACategoricalIterator < CAIterator
320
347
  def mean(axis: nil)
321
348
  return axis_mean(axis) if axis
322
349
  m = moments
323
- return per_category(CA_FLOAT64) { |s| s.mean } unless m
350
+ return per_category(core_reduce_type(:mean)) { |s| s.mean } unless m
324
351
  cnt = m[:count]
325
352
  out = m[:sum] / cnt.float64 # count 0 -> NaN, masked next
326
353
  out[cnt.eq(0)] = UNDEF # empty / all-masked category -> MASKED
@@ -344,11 +371,11 @@ class CACategoricalIterator < CAIterator
344
371
  # @return [CArray]
345
372
  def percentile (p, axis: nil)
346
373
  axis_order_stat_defer!(:percentile) if axis
347
- unless MONOID_TYPES.include?(@grouped.data_type)
348
- return per_category(CA_FLOAT64) { |s| s.percentile(p) }
374
+ unless MONOID_TYPES.include?(grouped.data_type)
375
+ return per_category(core_reduce_type(:percentile, p)) { |s| s.percentile(p) }
349
376
  end
350
377
  out = CArray.float64(@k)
351
- @grouped.send(:__reduceat_percentile__, @offsets, p.to_f, out)
378
+ grouped.send(:__reduceat_percentile__, @offsets, p.to_f, out)
352
379
  out
353
380
  end
354
381
 
@@ -359,11 +386,11 @@ class CACategoricalIterator < CAIterator
359
386
  # MASKED. For a single fraction q in 0..1 use `percentile(q * 100)`.
360
387
  # @return [Array<CArray>]
361
388
  def quantile
362
- unless MONOID_TYPES.include?(@grouped.data_type)
389
+ unless MONOID_TYPES.include?(grouped.data_type)
363
390
  return [0, 25, 50, 75, 100].map { |p| percentile(p) }
364
391
  end
365
392
  outs = Array.new(5) { CArray.float64(@k) }
366
- @grouped.send(:__reduceat_quantile__, @offsets, *outs)
393
+ grouped.send(:__reduceat_quantile__, @offsets, *outs)
367
394
  outs
368
395
  end
369
396
 
@@ -374,13 +401,13 @@ class CACategoricalIterator < CAIterator
374
401
  # sample variance.
375
402
  # @return [CArray]
376
403
  def variance(axis: nil)
377
- return axis_variance_family(axis, :variance) if axis
404
+ return axis_by_masked_copy(axis, :variance) if axis
378
405
  m = moments
379
- return per_category(CA_FLOAT64) { |s| s.variance } unless m
406
+ return per_category(core_reduce_type(:variance)) { |s| s.variance } unless m
380
407
  cnt = m[:count]
381
408
  means = m[:sum] / cnt.float64 # per-segment mean (garbage where count 0/1,
382
409
  out = CArray.float64(@k) # ignored by the kernel's n<2 guards)
383
- @grouped.send(:__reduceat_variance__, @offsets, means, cnt, out)
410
+ grouped.send(:__reduceat_variance__, @offsets, means, cnt, out)
384
411
  out
385
412
  end
386
413
 
@@ -390,9 +417,9 @@ class CACategoricalIterator < CAIterator
390
417
  # single-value `0.0`).
391
418
  # @return [CArray]
392
419
  def stddev(axis: nil)
393
- return axis_variance_family(axis, :stddev) if axis
420
+ return axis_by_masked_copy(axis, :stddev) if axis
394
421
  m = moments
395
- return per_category(CA_FLOAT64) { |s| s.stddev } unless m
422
+ return per_category(core_reduce_type(:stddev)) { |s| s.stddev } unless m
396
423
  variance.sqrt # sqrt propagates the n=0 mask
397
424
  end
398
425
 
@@ -408,16 +435,16 @@ class CACategoricalIterator < CAIterator
408
435
  # @return [CArray]
409
436
  def prod(axis: nil)
410
437
  return axis_prod(axis) if axis
411
- return per_category(CA_FLOAT64) { |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)
412
439
  out = CArray.float64(@k)
413
- @grouped.send(:__reduceat_prod__, @offsets, out)
440
+ grouped.send(:__reduceat_prod__, @offsets, out)
414
441
  out
415
442
  end
416
443
 
417
444
  # @overload all
418
445
  # Returns the per-category `all` as boolean (matching `CArray#all`): true
419
446
  # iff every present value is truthy (empty category -> true, vacuously).
420
- # The value dtype must be boolean, as for `CArray#all`.
447
+ # The value data type must be boolean, as for `CArray#all`.
421
448
  # @return [CArray]
422
449
  def all
423
450
  aa = all_any
@@ -427,7 +454,7 @@ class CACategoricalIterator < CAIterator
427
454
  # @overload any
428
455
  # Returns the per-category `any` as boolean (matching `CArray#any`): true
429
456
  # iff some present value is truthy (empty category -> false). The value
430
- # dtype must be boolean, as for `CArray#any`.
457
+ # data type must be boolean, as for `CArray#any`.
431
458
  # @return [CArray]
432
459
  def any
433
460
  aa = all_any
@@ -438,16 +465,23 @@ class CACategoricalIterator < CAIterator
438
465
 
439
466
  # @overload minmax
440
467
  # Returns the per-category `[min, max]` pair (each a length-k CArray in the
441
- # value dtype; empty categories MASKED), matching `CArray#minmax`. Both come
442
- # from the single cached moments pass.
468
+ # value data type; empty categories MASKED), matching `CArray#minmax`. Both come
469
+ # from one moments pass.
443
470
  # @return [Array<CArray>]
444
471
  # @overload minmax(axis:)
445
- # Per-fiber `[min_ca, max_ca]` along `axis` (each shape [K, ...band], h dtype,
472
+ # Per-fiber `[min_ca, max_ca]` along `axis` (each shape [K, ...band], h's data type,
446
473
  # empty group cells MASKED). Ruby Array of two CArrays, not stacked.
474
+ # Both come from one kernel run.
447
475
  # @param axis [Integer]
448
476
  # @return [Array<CArray>]
449
477
  def minmax(axis: nil)
450
- 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
451
485
  [min, max]
452
486
  end
453
487
 
@@ -458,9 +492,9 @@ class CACategoricalIterator < CAIterator
458
492
  # reuses the centred two-pass kernel with no extra walk.
459
493
  # @return [CArray]
460
494
  def variancep(axis: nil)
461
- return axis_variance_family(axis, :variancep) if axis
495
+ return axis_by_masked_copy(axis, :variancep) if axis
462
496
  m = moments
463
- return per_category(CA_FLOAT64) { |s| s.variancep } unless m
497
+ return per_category(core_reduce_type(:variancep)) { |s| s.variancep } unless m
464
498
  cnt = m[:count]
465
499
  vp = variance * (cnt - 1).float64 / cnt.float64
466
500
  vp[cnt.eq(0)] = UNDEF # empty / all-masked stays masked
@@ -475,9 +509,9 @@ class CACategoricalIterator < CAIterator
475
509
  # @param axis [Integer]
476
510
  # @return [CArray]
477
511
  def stddevp(axis: nil)
478
- return axis_variance_family(axis, :stddevp) if axis
512
+ return axis_by_masked_copy(axis, :stddevp) if axis
479
513
  m = moments
480
- return per_category(CA_FLOAT64) { |s| s.stddevp } unless m
514
+ return per_category(core_reduce_type(:stddevp)) { |s| s.stddevp } unless m
481
515
  variancep.sqrt
482
516
  end
483
517
 
@@ -489,7 +523,7 @@ class CACategoricalIterator < CAIterator
489
523
  # @return [CArray] length-k int64
490
524
  def min_index
491
525
  am = arg_minmax
492
- am ? am[:min] : per_category(CA_INT64) { |s| s.min_index }
526
+ am ? am[:min].copy : per_category(CA_INT64) { |s| s.min_index }
493
527
  end
494
528
 
495
529
  # @overload max_index
@@ -497,7 +531,7 @@ class CACategoricalIterator < CAIterator
497
531
  # @return [CArray] length-k int64
498
532
  def max_index
499
533
  am = arg_minmax
500
- am ? am[:max] : per_category(CA_INT64) { |s| s.max_index }
534
+ am ? am[:max].copy : per_category(CA_INT64) { |s| s.max_index }
501
535
  end
502
536
 
503
537
  # @overload min_addr
@@ -536,14 +570,14 @@ class CACategoricalIterator < CAIterator
536
570
  # {#min_addr} vs the skipped group-local min_index-into-source.
537
571
  # @return [CArray] length-nvalid int64
538
572
  def sort_addr
539
- out = CArray.int64(@grouped.elements)
573
+ out = CArray.int64(grouped.elements)
540
574
  @k.times do |c|
541
575
  lo = @offsets[c]
542
- hi = (c + 1 < @k) ? @offsets[c + 1] : @grouped.elements
576
+ hi = (c + 1 < @k) ? @offsets[c + 1] : grouped.elements
543
577
  next unless hi > lo
544
578
  # View-local sort order of the segment (0..size-1), lifted to grouped
545
579
  # slots, then mapped back to source addresses via perm.
546
- out[lo...hi] = perm[@grouped[lo...hi].sort_addr + lo]
580
+ out[lo...hi] = perm[grouped[lo...hi].sort_addr + lo]
547
581
  end
548
582
  out
549
583
  end
@@ -567,7 +601,7 @@ class CACategoricalIterator < CAIterator
567
601
  def wsum (weights, axis: nil)
568
602
  return axis_wsum_wmean(weights, axis)[0] if axis
569
603
  wg = scatter_weights(weights)
570
- 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)
571
605
  fold_weighted(wg, 0.0) { |v, ws| v.wsum(ws) }
572
606
  end
573
607
 
@@ -587,7 +621,7 @@ class CACategoricalIterator < CAIterator
587
621
  def wmean (weights, axis: nil)
588
622
  return axis_wsum_wmean(weights, axis)[1] if axis
589
623
  wg = scatter_weights(weights)
590
- 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)
591
625
  fold_weighted(wg, UNDEF) { |v, ws| v.wmean(ws) }
592
626
  end
593
627
 
@@ -628,14 +662,14 @@ class CACategoricalIterator < CAIterator
628
662
  # @return [CArray] shaped like the source value
629
663
  def map (data_type: nil)
630
664
  raise LocalJumpError, "no block given (yield)" unless block_given?
631
- dt = data_type || @grouped.data_type
665
+ dt = data_type || grouped.data_type
632
666
  # Apply the block per category, assembled in grouped (category-contiguous)
633
667
  # order: a same-length result scatters cell for cell, a scalar broadcasts.
634
- transformed = CArray.new(dt, [@grouped.elements])
668
+ transformed = CArray.new(dt, [grouped.elements])
635
669
  @k.times do |c|
636
670
  lo = @offsets[c]
637
- hi = (c + 1 < @k) ? @offsets[c + 1] : @grouped.elements
638
- 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
639
673
  end
640
674
  # Scatter back to source positions via the permutation (grouped-order source
641
675
  # indices). Excluded cells are absent from perm and stay UNDEF.
@@ -660,22 +694,22 @@ class CACategoricalIterator < CAIterator
660
694
  # Excluded (out-of-vocabulary / masked-code) and source-masked cells join no
661
695
  # running total and are UNDEF. Mirroring the reductions (sum / mean), a scan
662
696
  # takes no axis argument. cumsum / cumprod -> float64, cummax / cummin
663
- # preserve the value dtype, cumcount -> int64 (1-based within-category
664
- # ordinal); an object value dtype is carried by the kernel's object branch.
697
+ # preserve the value data type, cumcount -> int64 (1-based within-category
698
+ # ordinal); an object value data type is carried by the kernel's object branch.
665
699
 
666
- # @overload cumsum
700
+ # @!method cumsum
667
701
  # Per-category inclusive running sum (float64), source-shaped.
668
702
  # @return [CArray]
669
- # @overload cumprod
703
+ # @!method cumprod
670
704
  # Per-category inclusive running product (float64), source-shaped.
671
705
  # @return [CArray]
672
- # @overload cummax
673
- # Per-category inclusive running maximum (value dtype), source-shaped.
706
+ # @!method cummax
707
+ # Per-category inclusive running maximum (value data type), source-shaped.
674
708
  # @return [CArray]
675
- # @overload cummin
676
- # Per-category inclusive running minimum (value dtype), source-shaped.
709
+ # @!method cummin
710
+ # Per-category inclusive running minimum (value data type), source-shaped.
677
711
  # @return [CArray]
678
- # @overload cumcount
712
+ # @!method cumcount
679
713
  # Per-category 1-based within-category ordinal (int64), source-shaped.
680
714
  # @return [CArray]
681
715
  [:cumsum, :cumprod, :cummax, :cummin, :cumcount].each do |op|
@@ -684,16 +718,63 @@ class CACategoricalIterator < CAIterator
684
718
 
685
719
  private
686
720
 
687
- # Axis-aware moments (count / sum / min / max) — computed once per axis via
688
- # the fused per-fiber scatter-reduce C kernel and cached (matches the flat
689
- # #moments caching in spirit: pay one kernel per {iterator, axis} pair, share
690
- # across sum / mean / min / max / minmax / count* consumers). Returns
691
- # `{count: <int64>, sum: <float64>, min: <h dtype masked>, max: <h dtype masked>}`,
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
765
+ # `{count: <int64>, sum: <float64>, min: <h's type, masked>, max: <h's type, masked>}`,
692
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.
693
777
  def axis_moments (axis)
694
- @axis_moments_cache ||= {}
695
- cached = @axis_moments_cache[axis]
696
- return cached if cached
697
778
  h = @value
698
779
  unless axis.is_a?(Integer) && axis >= 0 && axis < h.ndim
699
780
  raise ArgumentError,
@@ -709,16 +790,13 @@ class CACategoricalIterator < CAIterator
709
790
  maxs = CArray.new(h.data_type, out_shape)
710
791
  h.__send__(:__fiber_scatter_moments__, codes_h_shape, axis, @k,
711
792
  counts, sums, mins, maxs)
712
- @axis_moments_cache[axis] = {count: counts, sum: sums, min: mins, max: maxs}
793
+ {count: counts, sum: sums, min: mins, max: maxs}
713
794
  end
714
795
 
715
- # Axis-aware sum: from moments, cast float64 sums to h dtype so empty-group
716
- # identity 0 rides (matching flat #sum).
796
+ # Axis-aware sum: the moments sum is already the core fold in the core's own
797
+ # type, so it is handed back as is (an empty group cell carries identity 0.0).
717
798
  def axis_sum (axis)
718
- m = axis_moments(axis)
719
- out = CArray.new(@value.data_type, m[:sum].shape)
720
- out[] = m[:sum]
721
- out
799
+ axis_moments(axis)[:sum]
722
800
  end
723
801
 
724
802
  # Axis-aware mean: sums / counts (float64); empty group cells (count=0) MASKED.
@@ -731,17 +809,19 @@ class CACategoricalIterator < CAIterator
731
809
  out
732
810
  end
733
811
 
734
- # Axis-aware variance / stddev / variancep / stddevp — Ruby-level per-c mask
735
- # then delegate to the source's own axis-aware kernel. Order (median /
736
- # percentile / quantile) is genuinely order-statistical (needs sort per
737
- # group), and remains deferred; the variance family is only a centred
738
- # two-pass numeric aggregate, so this loop hits the same ε-close two-pass
739
- # kernel per (group, axis) that CArray#variance uses, no new C needed.
812
+ # Axis-aware reduction by masked copy — Ruby-level per-c mask, then delegate
813
+ # to the source's own axis-aware kernel, so the core contract for `op` rides
814
+ # unchanged. Used by the variance family (a centred two-pass numeric
815
+ # aggregate, hitting the same ε-close kernel per (group, axis) that
816
+ # CArray#variance uses) and by `accumulate` (whose in-type wrapping fold has
817
+ # no float64 moment to read it off). Order (median / percentile / quantile)
818
+ # is genuinely order-statistical (needs a sort per group) and remains
819
+ # deferred.
740
820
  #
741
821
  # Cost: K axis-reductions over an h-shaped local (most cells masked away for
742
822
  # each c) — bounded by K, typically small. A fused per-fiber variance
743
823
  # kernel is a natural follow-on if bench demands it.
744
- def axis_variance_family (axis, op)
824
+ def axis_by_masked_copy (axis, op, out_data_type = CA_FLOAT64)
745
825
  h = @value
746
826
  unless axis.is_a?(Integer) && axis >= 0 && axis < h.ndim
747
827
  raise ArgumentError,
@@ -750,7 +830,7 @@ class CACategoricalIterator < CAIterator
750
830
  end
751
831
  full_c = resolve_axis_codes(@cat.codes, h.shape, axis)
752
832
  band = h.shape.dup; band.delete_at(axis)
753
- out = CArray.float64(*([@k] + band))
833
+ out = CArray.new(out_data_type, [@k] + band)
754
834
  slot_idx = [nil] + [nil] * band.size # placeholder; c fills slot 0
755
835
  codes_bad = full_c.has_mask? ? full_c.is_masked : nil
756
836
  @k.times do |c|
@@ -819,22 +899,20 @@ class CACategoricalIterator < CAIterator
819
899
  def resolve_axis_codes (codes, h_shape, axis)
820
900
  ndim = h_shape.size
821
901
  band = h_shape.dup; band.delete_at(axis)
822
- case codes.ndim
823
- when 1
824
- unless codes.shape == [h_shape[axis]]
825
- axis_shape_mismatch!(codes.shape, h_shape, axis, band)
826
- 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
827
911
  view_shape = Array.new(ndim, 1); view_shape[axis] = h_shape[axis]
828
912
  codes.reshape(*view_shape).broadcast_to(*h_shape)
829
- when ndim
830
- unless codes.shape == h_shape
831
- axis_shape_mismatch!(codes.shape, h_shape, axis, band)
832
- end
913
+ when codes.shape == h_shape # case B
833
914
  codes
834
- when ndim - 1
835
- unless codes.shape == band
836
- axis_shape_mismatch!(codes.shape, h_shape, axis, band)
837
- end
915
+ when codes.shape == band # band-only
838
916
  view_shape = h_shape.dup; view_shape[axis] = 1
839
917
  codes.reshape(*view_shape).broadcast_to(*h_shape)
840
918
  else
@@ -843,8 +921,11 @@ class CACategoricalIterator < CAIterator
843
921
  end
844
922
 
845
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.
846
927
  raise ArgumentError,
847
- "group_by_category.sum(axis: #{axis}): cat.shape=#{cat_shape.inspect} " \
928
+ "group_by_category (axis: #{axis}): cat.shape=#{cat_shape.inspect} " \
848
929
  "does not fit any of the 3 accepted forms for h.shape=#{h_shape.inspect}: " \
849
930
  "case A cat.shape=[#{h_shape[axis]}], " \
850
931
  "case B cat.shape=#{h_shape.inspect}, " \
@@ -855,18 +936,36 @@ class CACategoricalIterator < CAIterator
855
936
  # Called from median / percentile / variance / stddev when axis: is given.
856
937
  def axis_order_stat_defer! (op)
857
938
  raise NotImplementedError,
858
- "CACategoricalIterator##{op}(axis:) not yet implemented — order " \
859
- "statistics deferred to Phase 4 of PROPOSAL_CATEGORICAL_REDUCE_AXIS " \
860
- "(needs per-fiber counting-sort C kernel, tracked separately)."
939
+ "CACategoricalIterator##{op}(axis:) is not implemented — order " \
940
+ "statistics are available without axis:."
861
941
  end
862
942
 
863
943
  # Drive a segment scan through the axis-group scan kernel: the whole value as
864
944
  # one grouped axis, the flat codes as the single bundle. The kernel emits in
865
945
  # source order, so the flat result reshapes straight back to the source shape.
866
946
  def scan (op)
867
- @value.reshape(@value.elements)
868
- .__axis_group_scan__([0], [[@codes, @k, [0]]], op)
869
- .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
870
969
  end
871
970
 
872
971
 
@@ -889,7 +988,7 @@ class CACategoricalIterator < CAIterator
889
988
  "value.elements (#{@codes.elements})"
890
989
  end
891
990
  wf = weights.float64
892
- wg = CArray.float64(@grouped.elements)
991
+ wg = CArray.float64(grouped.elements)
893
992
  @codes.send(:__categorical_scatter__, wf.reshape(wf.elements),
894
993
  @offsets.copy, wg, @k)
895
994
  wg
@@ -910,22 +1009,22 @@ class CACategoricalIterator < CAIterator
910
1009
 
911
1010
  # Fused per-segment weighted sum + weighted mean (one C pass over the grouped
912
1011
  # copy, weights in group order). Returns [wsum, wmean]; wmean is masked where a
913
- # segment has no present (value AND weight) pair. Numeric value dtypes only.
1012
+ # segment has no present (value AND weight) pair. Numeric value data types only.
914
1013
  def kernel_weighted (wg)
915
1014
  ws = CArray.float64(@k)
916
1015
  wm = CArray.float64(@k)
917
- @grouped.send(:__reduceat_wsum_wmean__, @offsets, wg, ws, wm)
1016
+ grouped.send(:__reduceat_wsum_wmean__, @offsets, wg, ws, wm)
918
1017
  [ws, wm]
919
1018
  end
920
1019
 
921
- # Per-group weighted fallback for non-numeric value dtypes (complex): delegate
1020
+ # Per-group weighted fallback for non-numeric value data types (complex): delegate
922
1021
  # each group to CArray#wsum / #wmean. Empty segments take the given identity.
923
1022
  def fold_weighted (wg, empty)
924
1023
  out = CArray.float64(@k)
925
1024
  @k.times do |c|
926
1025
  lo = @offsets[c]
927
- hi = (c + 1 < @k) ? @offsets[c + 1] : @grouped.elements
928
- 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
929
1028
  end
930
1029
  out
931
1030
  end
@@ -936,16 +1035,16 @@ class CACategoricalIterator < CAIterator
936
1035
  # contract we want (identity for sum, UNDEF for ratios).
937
1036
  def group_slice (c)
938
1037
  lo = @offsets[c]
939
- hi = (c + 1 < @k) ? @offsets[c + 1] : @grouped.elements
940
- hi > lo ? @grouped[lo...hi] : @empty
1038
+ hi = (c + 1 < @k) ? @offsets[c + 1] : grouped.elements
1039
+ hi > lo ? grouped[lo...hi] : @empty
941
1040
  end
942
1041
 
943
1042
  # Single-pass reduceat moments (count / sum / min / max per category), computed
944
1043
  # once over the grouped copy and cached — the whole point of the eager copy is
945
1044
  # that one scatter is followed by cheap single-pass reductions with no
946
- # per-segment views. Nil for a non-numeric value dtype (complex / object /
1045
+ # per-segment views. Nil for a non-numeric value data type (complex / object /
947
1046
  # bool), where the monoid reductions fall back to per_category.
948
- # numeric value dtypes the C moments kernel handles (int8..float64); bool /
1047
+ # numeric value data types the C moments kernel handles (int8..float64); bool /
949
1048
  # complex / object fall back to per_category.
950
1049
  MONOID_TYPES = %i[int8 uint8 int16 uint16 int32 uint32
951
1050
  int64 uint64 float32 float64].freeze
@@ -953,53 +1052,103 @@ class CACategoricalIterator < CAIterator
953
1052
  def moments
954
1053
  return @moments if defined?(@moments)
955
1054
  @moments =
956
- if MONOID_TYPES.include?(@grouped.data_type)
957
- dt = @grouped.data_type
1055
+ if MONOID_TYPES.include?(grouped.data_type)
1056
+ dt = grouped.data_type
958
1057
  counts = CArray.int64(@k)
959
1058
  sums = CArray.float64(@k)
960
1059
  mins = CArray.new(dt, [@k])
961
1060
  maxs = CArray.new(dt, [@k])
962
- @grouped.send(:__reduceat_moments__, @offsets, counts, sums, mins, maxs)
1061
+ grouped.send(:__reduceat_moments__, @offsets, counts, sums, mins, maxs)
963
1062
  { count: counts, sum: sums, min: mins, max: maxs }
964
1063
  end
965
1064
  end
966
1065
 
967
1066
  # Single-pass fused group-local argmin / argmax (min_index / max_index),
968
- # cached. Nil for a non-numeric value dtype (fall back to per_category).
1067
+ # cached. Nil for a non-numeric value data type (fall back to per_category).
969
1068
  def arg_minmax
970
1069
  return @arg_minmax if defined?(@arg_minmax)
971
1070
  @arg_minmax =
972
- if MONOID_TYPES.include?(@grouped.data_type)
1071
+ if MONOID_TYPES.include?(grouped.data_type)
973
1072
  mn = CArray.int64(@k)
974
1073
  mx = CArray.int64(@k)
975
- @grouped.send(:__reduceat_argminmax__, @offsets, mn, mx)
1074
+ grouped.send(:__reduceat_argminmax__, @offsets, mn, mx)
976
1075
  { min: mn, max: mx }
977
1076
  end
978
1077
  end
979
1078
 
980
1079
  # Single-pass fused per-category boolean all / any, cached. Nil unless the
981
- # value dtype is boolean (fall back to per_category, which raises like
1080
+ # value data type is boolean (fall back to per_category, which raises like
982
1081
  # CArray#all on a non-boolean).
983
1082
  def all_any
984
1083
  return @all_any if defined?(@all_any)
985
1084
  @all_any =
986
- if @grouped.data_type == CA_BOOLEAN
1085
+ if grouped.data_type == CA_BOOLEAN
987
1086
  a = CArray.boolean(@k)
988
1087
  o = CArray.boolean(@k)
989
- @grouped.send(:__reduceat_all_any__, @offsets, a, o)
1088
+ grouped.send(:__reduceat_all_any__, @offsets, a, o)
990
1089
  { all: a, any: o }
991
1090
  end
992
1091
  end
993
1092
 
994
1093
  # Build a length-k typed output by folding each category's members with the
995
1094
  # given reduction block. Fallback path (order statistics, and monoids on a
996
- # non-numeric value dtype): each group is delegated to the same CArray
1095
+ # non-numeric value data type): each group is delegated to the same CArray
997
1096
  # reduction, so the per-group result matches `CArray#<reduction>` over that
998
1097
  # group's members — the mask carries the "insufficient present data" contract
999
1098
  # for free (an all-masked group reduces like an empty one; identity-bearing
1000
1099
  # reductions return their identity, ratios return UNDEF; see ext ERI).
1100
+ # The data type the core reduction `op` promotes this value to. Asked of the
1101
+ # core itself -- a one-cell reduction of the value's type -- rather than
1102
+ # restated here, so a per-category answer cannot drift from `CArray#<op>`
1103
+ # (`sum` on an integer promotes, `accumulate` stays, `min` / `max` keep the
1104
+ # type but a boolean widens, `prod` on an object stays an object). A payload
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.
1111
+ def core_reduce_type (op, *args)
1112
+ (@core_reduce_type ||= {})[[op, args]] ||=
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?
1134
+ end
1135
+
1001
1136
  def per_category (data_type)
1002
- 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.
1003
1152
  @k.times { |c| out[c] = yield(group_slice(c)) }
1004
1153
  out
1005
1154
  end