carray 3.0.2 → 3.0.3

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 (218) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +304 -0
  3. data/README.md +3 -4
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_dispatch.c +225 -0
  6. data/ext/ca_axis_group.c +177 -99
  7. data/ext/ca_binop_dispatch.c +54 -32
  8. data/ext/ca_binop_dispatch.h +1 -0
  9. data/ext/ca_categorical_iterator.c +66 -71
  10. data/ext/ca_compare.c +8 -2
  11. data/ext/ca_for_buffer.h +21 -68
  12. data/ext/ca_group_iter.c +27 -11
  13. data/ext/ca_kernel_iterator.c +663 -214
  14. data/ext/ca_kernel_iterator.h +231 -167
  15. data/ext/ca_monop_dispatch.c +4 -4
  16. data/ext/ca_monop_dispatch.h +4 -0
  17. data/ext/ca_obj_array.c +132 -22
  18. data/ext/ca_obj_bincmp.c +152 -121
  19. data/ext/ca_obj_binop.c +107 -210
  20. data/ext/ca_obj_bitarray.c +37 -24
  21. data/ext/ca_obj_bitfield.c +95 -62
  22. data/ext/ca_obj_block.c +1 -0
  23. data/ext/ca_obj_byte_swap.c +40 -40
  24. data/ext/ca_obj_const_string.c +96 -25
  25. data/ext/ca_obj_face.c +406 -64
  26. data/ext/ca_obj_face.h +92 -47
  27. data/ext/ca_obj_fake.c +21 -50
  28. data/ext/ca_obj_farray.c +1 -0
  29. data/ext/ca_obj_field.c +16 -3
  30. data/ext/ca_obj_fixlen_string.c +21 -21
  31. data/ext/ca_obj_grid.c +181 -124
  32. data/ext/ca_obj_meld.c +189 -107
  33. data/ext/ca_obj_moncmp.c +61 -61
  34. data/ext/ca_obj_monop.c +125 -107
  35. data/ext/ca_obj_object.c +543 -233
  36. data/ext/ca_obj_record.c +51 -16
  37. data/ext/ca_obj_reduce.c +5 -8
  38. data/ext/ca_obj_refer.c +117 -33
  39. data/ext/ca_obj_remap.c +57 -17
  40. data/ext/ca_obj_repeat.c +21 -5
  41. data/ext/ca_obj_roll.c +115 -11
  42. data/ext/ca_obj_select.c +47 -49
  43. data/ext/ca_obj_select_axis.c +58 -60
  44. data/ext/ca_obj_shift.c +22 -102
  45. data/ext/ca_obj_stack.c +223 -118
  46. data/ext/ca_obj_stride.c +195 -34
  47. data/ext/ca_obj_string.c +17 -18
  48. data/ext/ca_obj_tile.c +32 -16
  49. data/ext/ca_obj_time.c +23 -25
  50. data/ext/ca_obj_timedelta.c +21 -23
  51. data/ext/ca_obj_transpose.c +12 -3
  52. data/ext/ca_obj_triop.c +110 -117
  53. data/ext/ca_obj_window.c +149 -27
  54. data/ext/ca_op_ipower.c +24 -11
  55. data/ext/ca_op_powi.h +57 -30
  56. data/ext/ca_pow5_table.h +656 -0
  57. data/ext/ca_sort_kernels.h +70 -0
  58. data/ext/ca_sweep_engine.c +102 -79
  59. data/ext/ca_sweep_engine.h +7 -20
  60. data/ext/ca_triop_dispatch.c +4 -1
  61. data/ext/ca_triop_dispatch.h +5 -2
  62. data/ext/caframe_csv_reader.c +463 -0
  63. data/ext/carray.h +70 -27
  64. data/ext/carray_access.c +455 -211
  65. data/ext/carray_address_basis.c +177 -36
  66. data/ext/carray_attribute.c +45 -3
  67. data/ext/carray_bincount.c +32 -2
  68. data/ext/carray_broadcast.c +46 -14
  69. data/ext/carray_call_cfunc.c +0 -28
  70. data/ext/carray_cast.c +1444 -209
  71. data/ext/carray_cast_func.rb +34 -21
  72. data/ext/carray_conversion.c +197 -73
  73. data/ext/carray_copy.c +32 -11
  74. data/ext/carray_core.c +514 -122
  75. data/ext/carray_count.c +140 -87
  76. data/ext/carray_element.c +18 -27
  77. data/ext/carray_factorize.c +569 -277
  78. data/ext/carray_generate.c +76 -40
  79. data/ext/carray_histogram.c +152 -94
  80. data/ext/carray_hold.c +2 -1
  81. data/ext/carray_index_classifier.c +63 -96
  82. data/ext/carray_internal.h +385 -3
  83. data/ext/carray_lazy.c +834 -79
  84. data/ext/carray_loop.c +81 -65
  85. data/ext/carray_mask.c +682 -316
  86. data/ext/carray_math_kernel.h +1 -0
  87. data/ext/carray_median_percentile.c +221 -76
  88. data/ext/carray_memory_view.c +254 -248
  89. data/ext/carray_operator.c +1089 -359
  90. data/ext/carray_order.c +133 -69
  91. data/ext/carray_partition.c +102 -56
  92. data/ext/carray_random.c +273 -239
  93. data/ext/carray_scatter.c +308 -65
  94. data/ext/carray_segment.c +257 -0
  95. data/ext/carray_slab.c +170 -70
  96. data/ext/carray_sort.c +215 -218
  97. data/ext/carray_sort_kernel.c +8 -1
  98. data/ext/carray_struct.c +99 -21
  99. data/ext/carray_test.c +167 -64
  100. data/ext/carray_time_text.c +1182 -0
  101. data/ext/carray_utils.c +363 -98
  102. data/ext/extconf.rb +27 -2
  103. data/ext/mk_call_cfunc.rb +0 -2
  104. data/ext/mk_pow5_table.rb +35 -0
  105. data/ext/mkkernel.rb +1076 -846
  106. data/ext/ruby_carray.c +7 -1
  107. data/ext/version.h +4 -4
  108. data/ext/version.rb +4 -2
  109. data/lib/carray/attribute.rb +39 -0
  110. data/lib/carray/autoload_carray.rb +19 -0
  111. data/lib/carray/autoload_method_extension.rb +6 -0
  112. data/lib/carray/axis_group.rb +174 -26
  113. data/lib/carray/basics.rb +91 -25
  114. data/lib/carray/bincount_nd.rb +6 -0
  115. data/lib/carray/block_iterator.rb +118 -48
  116. data/lib/carray/boolean_reduce.rb +21 -29
  117. data/lib/carray/categorical.rb +94 -26
  118. data/lib/carray/categorical_iterator.rb +333 -826
  119. data/lib/carray/complex.rb +36 -29
  120. data/lib/carray/conditional.rb +94 -7
  121. data/lib/carray/const_string.rb +46 -0
  122. data/lib/carray/frame/concat.rb +157 -6
  123. data/lib/carray/frame/convert.rb +13 -7
  124. data/lib/carray/frame/csv_parser.rb +589 -82
  125. data/lib/carray/frame/describe.rb +79 -0
  126. data/lib/carray/frame/frame.rb +124 -25
  127. data/lib/carray/frame/group.rb +189 -63
  128. data/lib/carray/frame/io.rb +412 -72
  129. data/lib/carray/frame/join.rb +134 -46
  130. data/lib/carray/frame/records.rb +37 -16
  131. data/lib/carray/frame/reshape.rb +448 -0
  132. data/lib/carray/frame/sort.rb +26 -2
  133. data/lib/carray/frame/verbs.rb +715 -48
  134. data/lib/carray/frame.rb +2 -0
  135. data/lib/carray/fuse_source.rb +111 -2
  136. data/lib/carray/fusion.rb +229 -38
  137. data/lib/carray/inspect.rb +29 -11
  138. data/lib/carray/iterator.rb +14 -2
  139. data/lib/carray/lazy.rb +54 -30
  140. data/lib/carray/mask_gap_fill.rb +66 -52
  141. data/lib/carray/math.rb +32 -19
  142. data/lib/carray/meld_reduce.rb +40 -7
  143. data/lib/carray/methods/align_addr.rb +8 -4
  144. data/lib/carray/methods/bincount.rb +2 -2
  145. data/lib/carray/methods/bit_string.rb +14 -3
  146. data/lib/carray/methods/composition.rb +16 -10
  147. data/lib/carray/methods/factorize.rb +2 -2
  148. data/lib/carray/methods/gather_nd.rb +13 -2
  149. data/lib/carray/methods/insert_block.rb +9 -14
  150. data/lib/carray/methods/is_in.rb +117 -4
  151. data/lib/carray/methods/join.rb +1 -1
  152. data/lib/carray/methods/locate_addr.rb +177 -36
  153. data/lib/carray/methods/mode.rb +18 -1
  154. data/lib/carray/methods/repeat.rb +1 -20
  155. data/lib/carray/methods/resize.rb +21 -23
  156. data/lib/carray/methods/snap.rb +34 -12
  157. data/lib/carray/methods/unique.rb +11 -2
  158. data/lib/carray/record_order.rb +208 -0
  159. data/lib/carray/runtime.rb +14 -11
  160. data/lib/carray/segment_iterator.rb +990 -0
  161. data/lib/carray/serialize.rb +197 -27
  162. data/lib/carray/slab_iterator.rb +51 -27
  163. data/lib/carray/stack.rb +10 -4
  164. data/lib/carray/string_operation_extension.rb +4 -3
  165. data/lib/carray/struct.rb +142 -51
  166. data/lib/carray/struct_builder.rb +110 -14
  167. data/lib/carray/table.rb +2 -2
  168. data/lib/carray/time.rb +517 -103
  169. data/lib/carray/window_iterator.rb +127 -72
  170. data/lib/carray.rb +2 -2
  171. data/yard-stubs/ca_obj_array.rb +4 -4
  172. data/yard-stubs/ca_obj_bitarray.rb +7 -0
  173. data/yard-stubs/ca_obj_bitfield.rb +23 -12
  174. data/yard-stubs/ca_obj_fake.rb +1 -1
  175. data/yard-stubs/ca_obj_field.rb +1 -1
  176. data/yard-stubs/ca_obj_refer.rb +3 -3
  177. data/yard-stubs/ca_obj_roll.rb +0 -1
  178. data/yard-stubs/ca_obj_stride.rb +10 -7
  179. data/yard-stubs/ca_obj_transpose.rb +1 -1
  180. data/yard-stubs/ca_obj_window.rb +1 -2
  181. data/yard-stubs/carray_access.rb +5 -4
  182. data/yard-stubs/carray_broadcast.rb +1 -1
  183. data/yard-stubs/carray_cast.rb +78 -43
  184. data/yard-stubs/carray_conversion.rb +13 -1
  185. data/yard-stubs/carray_copy.rb +8 -3
  186. data/yard-stubs/carray_core.rb +3 -3
  187. data/yard-stubs/carray_count.rb +9 -0
  188. data/yard-stubs/carray_lazy.rb +5 -1
  189. data/yard-stubs/carray_loop.rb +4 -1
  190. data/yard-stubs/carray_mask.rb +34 -10
  191. data/yard-stubs/carray_math.rb +25 -13
  192. data/yard-stubs/carray_median_percentile.rb +10 -6
  193. data/yard-stubs/carray_order.rb +34 -13
  194. data/yard-stubs/carray_scatter.rb +10 -3
  195. data/yard-stubs/carray_segment.rb +72 -0
  196. data/yard-stubs/carray_slab.rb +2 -1
  197. data/yard-stubs/carray_sort.rb +43 -3
  198. data/yard-stubs/carray_test.rb +10 -0
  199. data/yard-stubs/carray_utils.rb +0 -26
  200. metadata +11 -19
  201. data/ext/ca_for_each_element.h +0 -239
  202. data/ext/carray_build_flags.h +0 -3
  203. data/ext/carray_config.h +0 -28
  204. data/ext/carray_kernels_bincmp.c +0 -4446
  205. data/ext/carray_kernels_binop.c +0 -11001
  206. data/ext/carray_kernels_init.c +0 -1131
  207. data/ext/carray_kernels_map.c +0 -3467
  208. data/ext/carray_kernels_moncmp.c +0 -2097
  209. data/ext/carray_kernels_monop.c +0 -18313
  210. data/ext/carray_kernels_reduce_aggregate.c +0 -26005
  211. data/ext/carray_kernels_reduce_boolean.c +0 -330
  212. data/ext/carray_kernels_reduce_cumulative.c +0 -14862
  213. data/ext/carray_kernels_reduce_extreme.c +0 -17494
  214. data/ext/carray_kernels_reduce_variance.c +0 -3910
  215. data/ext/carray_kernels_scan.c +0 -3693
  216. data/ext/carray_kernels_search.c +0 -32218
  217. data/ext/carray_kernels_sort.c +0 -10626
  218. data/ext/carray_kernels_triop.c +0 -1392
@@ -35,8 +35,8 @@
35
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
- # variance / stddev (sample, ddof=1) -> float64, empty/all-masked = MASKED,
39
- # single value = 0.0 (n=1 contract)
38
+ # variance / stddev (sample, ddof=1) -> float64, fewer than two present
39
+ # values = MASKED
40
40
  # all / any -> boolean (boolean value data type only)
41
41
  # labels -> cat.labels
42
42
  #
@@ -60,17 +60,17 @@
60
60
  # ----------------------------------------------------------------------------
61
61
 
62
62
  require "carray"
63
-
64
- # A CAIterator over the categories of a CACategorical. CAIterator is the
65
- # family base (the built-in iterators like CAWindowIterator / CABlockIterator
66
- # are defined in C); a Ruby `Foo < CAIterator` supplies its own behaviour and
67
- # does not lean on the base machinery. Like CASlabIterator, this class defines
68
- # its own `each` (over the k categories, yielding each category's member slice),
69
- # which drives the inherited Enumerable surface; the reduction methods (sum /
70
- # mean / median / ...) aggregate the groups into length-k arrays. The kernels
71
- # are per-category slices of an eager, category-contiguous grouped copy. This
72
- # supersedes the older CAClassIterator.
73
- class CACategoricalIterator < CAIterator
63
+ require "carray/segment_iterator"
64
+
65
+ # A CAIterator over the categories of a CACategorical. It is a
66
+ # CASegmentIterator over a category-sorted copy of the value: construction
67
+ # gathers the value through the categorical's cached grouping plan, so each
68
+ # category is one contiguous segment, and every reduction without `axis:` is
69
+ # the segment reduction over that copy (one value per category, in {#labels}
70
+ # order). This class adds the construction, the labels, and the per-fiber
71
+ # form (`axis:`), which classifies each fiber along an axis and keeps the
72
+ # other axes.
73
+ class CACategoricalIterator < CASegmentIterator
74
74
 
75
75
  # value : the payload CArray to reduce, one cell per categorical cell.
76
76
  # cat : the CACategorical carrying the classification.
@@ -105,6 +105,7 @@ class CACategoricalIterator < CAIterator
105
105
  @elements = cat.category_sizes.int64
106
106
  nvalid = counts.sum
107
107
  @offsets = cat.reduceat_index # cached segment STARTS (int64[k])
108
+ @bounds = CArray.segment_offsets(lengths: @elements) # every boundary (int64[k+1])
108
109
  # Group-major source indices = the valid prefix of the cached sort_addr.
109
110
  # With no classified cell the prefix is empty (and slicing a length-0
110
111
  # sort_addr would be out of range), so take the empty permutation directly.
@@ -118,8 +119,8 @@ class CACategoricalIterator < CAIterator
118
119
  # is nothing to gather — an empty index into an empty source is out of
119
120
  # range — so build the empty grouped buffer directly.
120
121
  @grouped = nvalid > 0 ? value.reshape(value.elements)[@perm].copy
121
- : CArray.new(value.data_type, [0])
122
- @empty = CArray.new(grouped.data_type, [0])
122
+ : CArray.new(value.data_type, [0], bytes: value.bytes)
123
+ @empty = CArray.new(grouped.data_type, [0], bytes: grouped.bytes)
123
124
  else
124
125
  # Shape mismatch: only per-fiber axis: dispatch could still work. With a
125
126
  # 1-D value there is no fiber structure to broadcast into, so a mismatch
@@ -147,20 +148,6 @@ class CACategoricalIterator < CAIterator
147
148
  self
148
149
  end
149
150
 
150
- # @overload each { |members| ... }
151
- # Yields each category's members (a CArray slice of the grouped copy, in
152
- # {#labels} order; an empty category yields an empty array). Without a
153
- # block, returns an Enumerator. This is the own iteration that drives the
154
- # inherited Enumerable methods (map / count / to_a / ...); it does not use
155
- # the CAIterator base each / kernel_at_addr path.
156
- # @yieldparam members [CArray]
157
- # @return [Enumerator, self]
158
- def each
159
- return to_enum(:each) unless block_given?
160
- @k.times { |c| yield group_slice(c) }
161
- self
162
- end
163
-
164
151
  # @overload labels
165
152
  # Returns the category vocabulary the results are aligned to.
166
153
  # @return [Array]
@@ -174,21 +161,6 @@ class CACategoricalIterator < CAIterator
174
161
  @k
175
162
  end
176
163
 
177
- # @overload elements
178
- # Returns per-group cell counts (classified cells, including value-masked
179
- # ones; = `cat.category_sizes`), a length-ngroups CArray aligned to
180
- # {#labels}. The CAIterator count-family member — `CArray#elements`
181
- # (structural, mask-independent) lifted per group.
182
- # @return [CArray]
183
- def 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
190
- end
191
-
192
164
  # Group-vocabulary alias for {#elements}; reads naturally next to
193
165
  # {#ngroups} and mirrors `CACategorical#category_sizes`.
194
166
  alias group_sizes elements
@@ -207,543 +179,144 @@ class CACategoricalIterator < CAIterator
207
179
  "#<#{self.class} ngroups=#{@k} labels=#{@labels.inspect} #{tail}>"
208
180
  end
209
181
 
210
- # @overload count_not_masked
211
- # Returns the per-category count of present (non-masked) values as int64
212
- # — the denominator the value reductions actually divide by. Equals
213
- # {#elements} unless the value carries a mask. A count is always defined,
214
- # so an empty category is `0` (never masked).
215
- # @return [CArray]
216
- # @overload count_not_masked(axis:)
217
- # Per-fiber per-category count of present (non-masked) values along `axis`
218
- # (int64, shape [K, ...band]). Empty cells are `0`.
219
- # @param axis [Integer]
220
- # @return [CArray]
221
- def count_not_masked(axis: nil)
222
- return axis_counts(axis) if axis
223
- m = moments
224
- m ? m[:count].copy : per_category(CA_INT64) { |s| s.count_not_masked }
225
- end
226
-
227
- # @overload count(v = <none>)
228
- # Per-category count, mirroring `CArray#count` per group. No argument
229
- # returns {#count_not_masked} (present cells); `count(UNDEF)` returns
230
- # {#count_masked}; `count(v)` counts cells whose value equals `v`.
231
- # @return [CArray] length-k int64, aligned to {#labels}
232
- # @overload count(axis:)
233
- # No-arg + axis: = per-fiber per-category count_not_masked (shape [K, ...band]).
234
- # `count(v, axis:)` (value equality) and `count(UNDEF, axis:)` are not
235
- # implemented; use them without `axis:`.
236
- # @param axis [Integer]
237
- # @return [CArray]
238
- def count (*args, axis: nil)
239
- if axis
240
- return count_not_masked(axis: axis) if args.empty?
241
- raise NotImplementedError,
242
- "CACategoricalIterator#count(v, axis:) is not implemented — " \
243
- "value-equality count is available without axis:."
244
- end
245
- return count_not_masked if args.empty?
246
- # Delegate per group to CArray#count (handles count(UNDEF) -> masked count and
247
- # count(v) alike, with core's exact data type equality). The group slice is a
248
- # CABlock, whose own #count is the block geometry accessor, so dispatch
249
- # CArray#count explicitly. (Not fused: a value-equality reduceat would have
250
- # to reproduce core's cross-type / out-of-range equality exactly.)
251
- cnt = CArray.instance_method(:count)
252
- per_category(CA_INT64) { |s| cnt.bind_call(s, *args) }
253
- end
254
-
255
- # @overload count_masked
256
- # Returns the per-category count of masked (missing) values as int64.
257
- # Empty categories are `0`.
258
- # @return [CArray]
259
- # @overload count_masked(axis:)
260
- # Not implemented; call it without `axis:`.
261
- # @param axis [Integer]
262
- # @return [CArray]
263
- def count_masked(axis: nil)
264
- if axis
265
- raise NotImplementedError,
266
- "CACategoricalIterator#count_masked(axis:) is not implemented — " \
267
- "call it without axis:."
268
- end
269
- m = moments
270
- m ? counts - m[:count] : per_category(CA_INT64) { |s| s.count_masked }
271
- end
272
-
273
- # @overload sum
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.
279
- # @return [CArray]
280
- # @overload sum(axis:)
281
- # Returns per-category sums per fiber along `axis`. Cat may be 1-D (case
282
- # A, broadcasts across band axes), same rank as source (case B, per-fiber
283
- # independent classifier), or one rank less (band-only, constant along
284
- # reduce axis). Output shape = `[K, ...source.shape without axis]`.
285
- # @param axis [Integer] reduce axis of the source value.
286
- # @return [CArray]
287
- def sum(axis: nil)
288
- return axis_sum(axis) if axis
289
- m = moments
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 }
310
- end
311
-
312
- # @overload max
313
- # Returns per-category maxima in the value data type. Empty categories are
314
- # MASKED.
315
- # @return [CArray]
316
- # @overload max(axis:)
317
- # Per-fiber per-category maxima along `axis` (h's data type, masked where empty).
318
- # @param axis [Integer]
319
- # @return [CArray]
320
- def max(axis: nil)
321
- return axis_moments(axis)[:max] if axis
322
- m = moments
323
- m ? m[:max].copy : per_category(core_reduce_type(:max)) { |s| s.max }
324
- end
325
-
326
- # @overload min
327
- # Returns per-category minima in the value data type. Empty categories are
328
- # MASKED.
329
- # @return [CArray]
330
- # @overload min(axis:)
331
- # Per-fiber per-category minima along `axis` (h's data type, masked where empty).
332
- # @param axis [Integer]
333
- # @return [CArray]
334
- def min(axis: nil)
335
- return axis_moments(axis)[:min] if axis
336
- m = moments
337
- m ? m[:min].copy : per_category(core_reduce_type(:min)) { |s| s.min }
338
- end
339
-
340
- # @overload mean
341
- # Returns per-category means as float64. Empty categories are MASKED.
342
- # @return [CArray]
343
- # @overload mean(axis:)
344
- # Per-fiber per-category means (float64, empty group cells MASKED).
345
- # @param axis [Integer]
346
- # @return [CArray]
347
- def mean(axis: nil)
348
- return axis_mean(axis) if axis
349
- m = moments
350
- return per_category(core_reduce_type(:mean)) { |s| s.mean } unless m
351
- cnt = m[:count]
352
- out = m[:sum] / cnt.float64 # count 0 -> NaN, masked next
353
- out[cnt.eq(0)] = UNDEF # empty / all-masked category -> MASKED
354
- out
355
- end
356
-
357
- # @overload median
358
- # Returns per-category medians as float64. Empty categories are MASKED.
359
- # @return [CArray]
360
- def median(axis: nil)
361
- axis_order_stat_defer!(:median) if axis
362
- percentile(50.0)
363
- end
364
-
365
- # @overload percentile(p)
366
- # Returns the per-category `p`-th percentile as float64 (`p` in 0..100,
367
- # `:linear` interpolation, matching `CArray#percentile`). Empty categories
368
- # are MASKED. Order statistics need every value of a group held together —
369
- # this is the reduceat that only the eager grouped copy can serve.
370
- # @param p [Numeric] percentile in 0..100.
371
- # @return [CArray]
372
- def percentile (p, axis: nil)
373
- axis_order_stat_defer!(:percentile) if axis
374
- unless MONOID_TYPES.include?(grouped.data_type)
375
- return per_category(core_reduce_type(:percentile, p)) { |s| s.percentile(p) }
376
- end
377
- out = CArray.float64(@k)
378
- grouped.send(:__reduceat_percentile__, @offsets, p.to_f, out)
379
- out
380
- end
381
-
382
- # @overload quantile
383
- # Returns the per-category five-number summary `[min, Q1, median, Q3, max]`
384
- # as five length-k float64 CArrays (matching `CArray#quantile`): the
385
- # percentiles at 0 / 25 / 50 / 75 / 100. Empty / all-masked categories are
386
- # MASKED. For a single fraction q in 0..1 use `percentile(q * 100)`.
387
- # @return [Array<CArray>]
388
- def quantile
389
- unless MONOID_TYPES.include?(grouped.data_type)
390
- return [0, 25, 50, 75, 100].map { |p| percentile(p) }
391
- end
392
- outs = Array.new(5) { CArray.float64(@k) }
393
- grouped.send(:__reduceat_quantile__, @offsets, *outs)
394
- outs
395
- end
396
-
397
- # @overload variance
398
- # Returns per-category SAMPLE variance (ddof=1) as float64. Matches
399
- # `CArray#variance` per group: an empty or fully-masked category is MASKED,
400
- # a single-value category is `0.0` (CArray's n=1 contract), n>=2 is the
401
- # sample variance.
402
- # @return [CArray]
403
- def variance(axis: nil)
404
- return axis_by_masked_copy(axis, :variance) if axis
405
- m = moments
406
- return per_category(core_reduce_type(:variance)) { |s| s.variance } unless m
407
- cnt = m[:count]
408
- means = m[:sum] / cnt.float64 # per-segment mean (garbage where count 0/1,
409
- out = CArray.float64(@k) # ignored by the kernel's n<2 guards)
410
- grouped.send(:__reduceat_variance__, @offsets, means, cnt, out)
411
- out
412
- end
413
-
414
- # @overload stddev
415
- # Returns per-category SAMPLE standard deviation (ddof=1) as float64.
416
- # Matches `CArray#stddev` per group (empty / all-masked MASKED,
417
- # single-value `0.0`).
418
- # @return [CArray]
419
- def stddev(axis: nil)
420
- return axis_by_masked_copy(axis, :stddev) if axis
421
- m = moments
422
- return per_category(core_reduce_type(:stddev)) { |s| s.stddev } unless m
423
- variance.sqrt # sqrt propagates the n=0 mask
424
- end
425
-
426
- # @overload prod
427
- # Returns per-category products as float64 (matching `CArray#prod`). An
428
- # empty / fully-masked category is `1.0` (the multiplicative identity).
429
- # Single-pass reduceat for numeric values; per-group fallback otherwise.
430
- # @return [CArray]
431
- # @overload prod(axis:)
432
- # Per-fiber per-category products (float64, shape [K, ...band]). Empty
433
- # group cells `1.0` (identity).
434
- # @param axis [Integer]
435
- # @return [CArray]
436
- def prod(axis: nil)
437
- return axis_prod(axis) if axis
438
- return per_category(core_reduce_type(:prod)) { |s| s.prod } unless MONOID_TYPES.include?(grouped.data_type)
439
- out = CArray.float64(@k)
440
- grouped.send(:__reduceat_prod__, @offsets, out)
441
- out
442
- end
443
-
444
- # @overload all
445
- # Returns the per-category `all` as boolean (matching `CArray#all`): true
446
- # iff every present value is truthy (empty category -> true, vacuously).
447
- # The value data type must be boolean, as for `CArray#all`.
448
- # @return [CArray]
449
- def all
450
- aa = all_any
451
- aa ? aa[:all] : per_category(CA_BOOLEAN) { |s| s.all }
452
- end
453
-
454
- # @overload any
455
- # Returns the per-category `any` as boolean (matching `CArray#any`): true
456
- # iff some present value is truthy (empty category -> false). The value
457
- # data type must be boolean, as for `CArray#any`.
458
- # @return [CArray]
459
- def any
460
- aa = all_any
461
- aa ? aa[:any] : per_category(CA_BOOLEAN) { |s| s.any }
462
- end
463
-
464
- # ---- tier 2 (fused / population / position) ------------------------------
465
-
466
- # @overload minmax
467
- # Returns the per-category `[min, max]` pair (each a length-k CArray in the
468
- # value data type; empty categories MASKED), matching `CArray#minmax`. Both come
469
- # from one moments pass.
470
- # @return [Array<CArray>]
471
- # @overload minmax(axis:)
472
- # Per-fiber `[min_ca, max_ca]` along `axis` (each shape [K, ...band], h's data type,
473
- # empty group cells MASKED). Ruby Array of two CArrays, not stacked.
474
- # Both come from one kernel run.
475
- # @param axis [Integer]
476
- # @return [Array<CArray>]
477
- def minmax(axis: nil)
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
485
- [min, max]
486
- end
487
-
488
- # @overload variancep
489
- # Per-category POPULATION variance (ddof=0) as float64, matching
490
- # `CArray#variancep`: empty / all-masked -> MASKED, single value -> 0.0.
491
- # Derived from the sample variance (variancep = variance * (n-1) / n), so it
492
- # reuses the centred two-pass kernel with no extra walk.
493
- # @return [CArray]
494
- def variancep(axis: nil)
495
- return axis_by_masked_copy(axis, :variancep) if axis
496
- m = moments
497
- return per_category(core_reduce_type(:variancep)) { |s| s.variancep } unless m
498
- cnt = m[:count]
499
- vp = variance * (cnt - 1).float64 / cnt.float64
500
- vp[cnt.eq(0)] = UNDEF # empty / all-masked stays masked
501
- vp
502
- end
503
-
504
- # @overload stddevp
505
- # Per-category POPULATION standard deviation (ddof=0) as float64.
506
- # @return [CArray]
507
- # @overload stddevp(axis:)
508
- # Per-fiber per-category population stddev (float64, empty group cells MASKED).
509
- # @param axis [Integer]
510
- # @return [CArray]
511
- def stddevp(axis: nil)
512
- return axis_by_masked_copy(axis, :stddevp) if axis
513
- m = moments
514
- return per_category(core_reduce_type(:stddevp)) { |s| s.stddevp } unless m
515
- variancep.sqrt
516
- end
517
-
518
- # @overload min_index
519
- # Per-category group-local index of the minimum — the position within the
520
- # category's members (source order) — matching `CArray#min_index` per group.
521
- # Empty / all-masked categories are MASKED. Single-pass fused reduceat for
522
- # numeric values; per-group fallback otherwise.
523
- # @return [CArray] length-k int64
524
- def min_index
525
- am = arg_minmax
526
- am ? am[:min].copy : per_category(CA_INT64) { |s| s.min_index }
527
- end
528
-
529
- # @overload max_index
530
- # Per-category group-local index of the maximum. See {#min_index}.
531
- # @return [CArray] length-k int64
532
- def max_index
533
- am = arg_minmax
534
- am ? am[:max].copy : per_category(CA_INT64) { |s| s.max_index }
535
- end
536
-
537
- # @overload min_addr
538
- # Per-category flat source address of the minimum — which cell of the source
539
- # value holds it, matching `CArray#min_addr` per group. Unlike {#min_index}
540
- # (the group-local rank) this indexes back into the original array
541
- # (`value.reshape(value.elements)[grp.min_addr]`). Empty categories MASKED.
542
- # @return [CArray] length-k int64
543
- def min_addr
544
- group_addr(min_index)
545
- end
546
-
547
- # @overload max_addr
548
- # Per-category flat source address of the maximum. See {#min_addr}.
549
- # @return [CArray] length-k int64
550
- def max_addr
551
- group_addr(max_index)
552
- end
553
-
554
- # @overload sort_addr
555
- # Per-category sort by flat source address. Returns a length-nvalid
556
- # (= `elements.sum`) int64 CArray of the flat SOURCE addresses that sort each
557
- # category's members, in group-major order: segment `c` holds category `c`'s
558
- # source addresses in ascending-value order, segments concatenated in
559
- # {#labels} order. So `value.reshape(value.elements)[grp.sort_addr]` yields
560
- # the values grouped and sorted within each group, and splitting by the
561
- # {#elements} prefix sum gives per-group. Excluded cells (in no category) are
562
- # omitted. A masked value sorts to the tail of its segment (as `CArray#sort`
563
- # sends masked cells to the end), so with a mask the first address is the
564
- # minimum but the last is the masked cell, not the maximum.
182
+ # ---- the per-fiber form ---------------------------------------------------
565
183
  #
566
- # Unlike {#min_index} / {#max_index} (group-local rank), this indexes back
567
- # into the original array. There is no group-local sort surface: a
568
- # group-local rank order is weak (the grouped copy is already
569
- # category-contiguous), so only the source-address form is offered, mirroring
570
- # {#min_addr} vs the skipped group-local min_index-into-source.
571
- # @return [CArray] length-nvalid int64
572
- def sort_addr
573
- out = CArray.int64(grouped.elements)
574
- @k.times do |c|
575
- lo = @offsets[c]
576
- hi = (c + 1 < @k) ? @offsets[c + 1] : grouped.elements
577
- next unless hi > lo
578
- # View-local sort order of the segment (0..size-1), lifted to grouped
579
- # slots, then mapped back to source addresses via perm.
580
- out[lo...hi] = perm[grouped[lo...hi].sort_addr + lo]
581
- end
582
- out
583
- end
584
-
585
- # @overload wsum(weights)
586
- # Per-category weighted sum as float64, matching `CArray#wsum`. `weights` is
587
- # a per-cell weight CArray in the source order (same elements as the value).
588
- # Empty / all-masked category -> 0.0 (the additive identity). A cell is
589
- # skipped iff its value OR its weight is masked (core's contract).
590
- # @param weights [CArray]
591
- # @return [CArray]
592
- # @overload wsum(weights, axis:)
593
- # Per-fiber per-category weighted sum along `axis`. `weights` must have
594
- # shape == source.shape (rev3 requires explicit broadcast; wrap 1-D or
595
- # band-shape weights via `.broadcast_to(*source.shape)` at the call site).
596
- # Empty group cell → `0.0` (identity). Mask contract: cell contributes iff
597
- # value AND weight are present.
598
- # @param weights [CArray]
599
- # @param axis [Integer]
600
- # @return [CArray]
601
- def wsum (weights, axis: nil)
602
- return axis_wsum_wmean(weights, axis)[0] if axis
603
- wg = scatter_weights(weights)
604
- return kernel_weighted(wg)[0] if MONOID_TYPES.include?(grouped.data_type)
605
- fold_weighted(wg, 0.0) { |v, ws| v.wsum(ws) }
606
- end
607
-
608
- # @overload wmean(weights)
609
- # Per-category weighted mean as float64, matching `CArray#wmean`. Empty
610
- # category -> MASKED; a present category whose weights sum to zero -> NaN
611
- # (core's 0/0 contract).
612
- # @param weights [CArray]
613
- # @return [CArray]
614
- # @overload wmean(weights, axis:)
615
- # Per-fiber per-category weighted mean along `axis`. Same weights-shape
616
- # contract as {#wsum} (weights.shape == source.shape). Empty cell → MASKED;
617
- # a present cell whose weights sum to zero → NaN (0/0 core contract).
618
- # @param weights [CArray]
619
- # @param axis [Integer]
620
- # @return [CArray]
621
- def wmean (weights, axis: nil)
622
- return axis_wsum_wmean(weights, axis)[1] if axis
623
- wg = scatter_weights(weights)
624
- return kernel_weighted(wg)[1] if MONOID_TYPES.include?(grouped.data_type)
625
- fold_weighted(wg, UNDEF) { |v, ws| v.wmean(ws) }
626
- end
627
-
628
- # @overload reduce { |members| ... }
629
- # Custom per-category reduction (the escape hatch for statistics not in the
630
- # named surface), mirroring `CArray#reduce_slab`. The block receives each
631
- # category's members (a CArray) and returns one value per category.
632
- # @yieldparam members [CArray]
633
- # @return [CArray] length-k, aligned to {#labels}
634
- # @overload reduce(init) { |acc, elem| ... }
635
- # Per-category fiber fold: each category's members are folded element by
636
- # element starting from `init`.
637
- # @param init [Object] initial accumulator.
638
- # @return [CArray] length-k
639
- def reduce (*args, data_type: nil, &blk)
640
- raise LocalJumpError, "no block given (yield)" unless blk
641
- dt = data_type || CA_OBJECT
642
- if args.empty?
643
- per_category(dt) { |s| blk.call(s) }
644
- else
645
- init = args[0]
646
- per_category(dt) { |s|
647
- acc = init
648
- s.each { |e| acc = blk.call(acc, e) }
649
- acc
650
- }
651
- end
652
- end
653
-
654
- # @overload map(data_type: nil) { |members| ... }
655
- # Group-wise element-wise transform, mirroring `CArray#map_slab`. The block
656
- # receives each category's members and returns either a same-length CArray
657
- # (scattered back cell for cell) or a scalar (broadcast over the group's
658
- # cells). Returns a NEW CArray shaped like the source `value`; the original
659
- # is not modified (`value[] = grp.map { ... }` for in-place). Excluded cells
660
- # (in no category) are UNDEF in the result.
661
- # @yieldparam members [CArray]
662
- # @return [CArray] shaped like the source value
663
- def map (data_type: nil)
664
- raise LocalJumpError, "no block given (yield)" unless block_given?
665
- dt = data_type || grouped.data_type
666
- # Apply the block per category, assembled in grouped (category-contiguous)
667
- # order: a same-length result scatters cell for cell, a scalar broadcasts.
668
- transformed = CArray.new(dt, [grouped.elements])
669
- @k.times do |c|
670
- lo = @offsets[c]
671
- hi = (c + 1 < @k) ? @offsets[c + 1] : grouped.elements
672
- transformed[lo...hi] = yield(grouped[lo...hi]) if hi > lo
673
- end
674
- # Scatter back to source positions via the permutation (grouped-order source
675
- # indices). Excluded cells are absent from perm and stay UNDEF.
676
- out = CArray.new(dt, @src_shape)
677
- out[] = UNDEF
678
- out.reshape(@codes.elements)[perm] = transformed
679
- out
680
- end
681
-
682
- # ---- segment scan: within-category running statistics ------------------
184
+ # These reductions also take `axis:` on a categorical. Without it each is
185
+ # the CASegmentIterator reduction of the same name, over the category-sorted
186
+ # copy: one value per category, in {#labels} order.
683
187
  #
684
- # The per-element-emit siblings of the reductions: unlike a reduction (which
685
- # collapses each category to one value) a scan preserves the source shape,
686
- # each cell holding its category's running statistic up to and including that
687
- # cell, in source (row-major) order. A category is a partition (each cell is
688
- # in exactly one category), so the running value is single-valued. The flat
689
- # categorical grouping is the one-band case of the axis-group scan, so each
690
- # routes straight through the fused C kernel __axis_group_scan__ (the same one
691
- # CAGroupIterator drives) with the whole source as a single grouped axis and
692
- # the categorical's codes as the single bundle -- which yields SOURCE-ORDER
693
- # output directly, so no counting-sort inverse permutation is needed.
694
- # Excluded (out-of-vocabulary / masked-code) and source-masked cells join no
695
- # running total and are UNDEF. Mirroring the reductions (sum / mean), a scan
696
- # takes no axis argument. cumsum / cumprod -> float64, cummax / cummin
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.
699
-
700
- # @!method cumsum
701
- # Per-category inclusive running sum (float64), source-shaped.
702
- # @return [CArray]
703
- # @!method cumprod
704
- # Per-category inclusive running product (float64), source-shaped.
705
- # @return [CArray]
706
- # @!method cummax
707
- # Per-category inclusive running maximum (value data type), source-shaped.
708
- # @return [CArray]
709
- # @!method cummin
710
- # Per-category inclusive running minimum (value data type), source-shaped.
711
- # @return [CArray]
712
- # @!method cumcount
713
- # Per-category 1-based within-category ordinal (int64), source-shaped.
714
- # @return [CArray]
715
- [:cumsum, :cumprod, :cummax, :cummin, :cumcount].each do |op|
716
- define_method(op) { scan(op) }
717
- end
188
+ # @!method accumulate(axis: nil)
189
+ # @overload accumulate
190
+ # The per-category form: {CASegmentIterator#accumulate}, one value per category.
191
+ # @overload accumulate(axis:)
192
+ # Per-fiber per-category in-type sums along `axis`. Output shape =
193
+ # `[K, ...source.shape without axis]`.
194
+ # @param axis [Integer] reduce axis of the source value.
195
+ # @return [CArray]
196
+ # @!method count(*args, axis: nil)
197
+ # @overload count
198
+ # The per-category form: {CASegmentIterator#count}, one value per category.
199
+ # @overload count(axis:)
200
+ # No-arg + axis: = per-fiber per-category count_not_masked (shape [K, ...band]).
201
+ # `count(v, axis:)` (value equality) and `count(UNDEF, axis:)` are not
202
+ # implemented; use them without `axis:`.
203
+ # @param axis [Integer]
204
+ # @return [CArray]
205
+ # @!method count_masked(axis: nil)
206
+ # @overload count_masked
207
+ # The per-category form: {CASegmentIterator#count_masked}, one value per category.
208
+ # @overload count_masked(axis:)
209
+ # Not implemented; call it without `axis:`.
210
+ # @param axis [Integer]
211
+ # @return [CArray]
212
+ # @!method count_not_masked(axis: nil)
213
+ # @overload count_not_masked
214
+ # The per-category form: {CASegmentIterator#count_not_masked}, one value per category.
215
+ # @overload count_not_masked(axis:)
216
+ # Per-fiber per-category count of present (non-masked) values along `axis`
217
+ # (int64, shape [K, ...band]). Empty cells are `0`.
218
+ # @param axis [Integer]
219
+ # @return [CArray]
220
+ # @!method max(axis: nil)
221
+ # @overload max
222
+ # The per-category form: {CASegmentIterator#max}, one value per category.
223
+ # @overload max(axis:)
224
+ # Per-fiber per-category maxima along `axis` (h's data type, masked where empty).
225
+ # @param axis [Integer]
226
+ # @return [CArray]
227
+ # @!method mean(axis: nil)
228
+ # @overload mean
229
+ # The per-category form: {CASegmentIterator#mean}, one value per category.
230
+ # @overload mean(axis:)
231
+ # Per-fiber per-category means (float64, empty group cells MASKED).
232
+ # @param axis [Integer]
233
+ # @return [CArray]
234
+ # @!method min(axis: nil)
235
+ # @overload min
236
+ # The per-category form: {CASegmentIterator#min}, one value per category.
237
+ # @overload min(axis:)
238
+ # Per-fiber per-category minima along `axis` (h's data type, masked where empty).
239
+ # @param axis [Integer]
240
+ # @return [CArray]
241
+ # @!method minmax(axis: nil)
242
+ # @overload minmax
243
+ # The per-category form: {CASegmentIterator#minmax}, one value per category.
244
+ # @overload minmax(axis:)
245
+ # Per-fiber `[min_ca, max_ca]` along `axis` (each shape [K, ...band], h's data type,
246
+ # empty group cells MASKED). Ruby Array of two CArrays, not stacked.
247
+ # Both come from one kernel run.
248
+ # @param axis [Integer]
249
+ # @return [Array<CArray>]
250
+ # @!method prod(axis: nil)
251
+ # @overload prod
252
+ # The per-category form: {CASegmentIterator#prod}, one value per category.
253
+ # @overload prod(axis:)
254
+ # Per-fiber per-category products (float64, shape [K, ...band]). Empty
255
+ # group cells `1.0` (identity).
256
+ # @param axis [Integer]
257
+ # @return [CArray]
258
+ # @!method stddevp(axis: nil)
259
+ # @overload stddevp
260
+ # The per-category form: {CASegmentIterator#stddevp}, one value per category.
261
+ # @overload stddevp(axis:)
262
+ # Per-fiber per-category population stddev (float64, empty group cells MASKED).
263
+ # @param axis [Integer]
264
+ # @return [CArray]
265
+ # @!method sum(axis: nil)
266
+ # @overload sum
267
+ # The per-category form: {CASegmentIterator#sum}, one value per category.
268
+ # @overload sum(axis:)
269
+ # Returns per-category sums per fiber along `axis`. Cat may be 1-D (case
270
+ # A, broadcasts across band axes), same rank as source (case B, per-fiber
271
+ # independent classifier), or one rank less (band-only, constant along
272
+ # reduce axis). Output shape = `[K, ...source.shape without axis]`.
273
+ # @param axis [Integer] reduce axis of the source value.
274
+ # @return [CArray]
275
+ # @!method wmean(weights, axis: nil)
276
+ # @overload wmean
277
+ # The per-category form: {CASegmentIterator#wmean}, one value per category.
278
+ # @overload wmean(weights, axis:)
279
+ # Per-fiber per-category weighted mean along `axis`. Same weights-shape
280
+ # contract as {#wsum} (weights.shape == source.shape). Empty cell → MASKED;
281
+ # a present cell whose weights sum to zero → NaN (0/0 core contract).
282
+ # @param weights [CArray]
283
+ # @param axis [Integer]
284
+ # @return [CArray]
285
+ # @!method wsum(weights, axis: nil)
286
+ # @overload wsum
287
+ # The per-category form: {CASegmentIterator#wsum}, one value per category.
288
+ # @overload wsum(weights, axis:)
289
+ # Per-fiber per-category weighted sum along `axis`. `weights` must have
290
+ # shape == source.shape (rev3 requires explicit broadcast; wrap 1-D or
291
+ # band-shape weights via `.broadcast_to(*source.shape)` at the call site).
292
+ # Empty group cell → `0.0` (identity). Mask contract: cell contributes iff
293
+ # value AND weight are present.
294
+ # @param weights [CArray]
295
+ # @param axis [Integer]
296
+ # @return [CArray]
718
297
 
719
298
  private
720
299
 
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)
300
+ # Both are built with the grouping plan, and only on the flat path; the
301
+ # per-fiber form has neither and must not build a segment's.
302
+ def perm
303
+ @perm
727
304
  end
728
305
 
729
- # Per-category cell counts, alongside #grouped and unavailable for the same
730
- # reason.
731
- def counts
732
- @elements || raise(ArgumentError, @no_flat)
306
+ def codes
307
+ @codes
733
308
  end
734
309
 
735
310
  # 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)
311
+ # group does not look at what is in them. An integer or float payload goes
312
+ # through the fused moments kernel, which counts in the same pass that
313
+ # sums. Any other payload -- complex, boolean, object, a Face -- the kernel
314
+ # refuses, for an answer that never depended on the payload, so it is
315
+ # counted from the codes and the value's mask, one group at a time.
316
+ def axis_counts (axis, op)
741
317
  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
318
+ return axis_moments(axis, op)[:count] if h.integer? || h.float?
319
+ axis = checked_axis(axis, op)
747
320
  full_c = resolve_axis_codes(@cat.codes, h.shape, axis)
748
321
  band = h.shape.dup; band.delete_at(axis)
749
322
  out = CArray.int64(*([@k] + band))
@@ -755,7 +328,8 @@ class CACategoricalIterator < CAIterator
755
328
  belongs = belongs.strip_mask(false) if belongs.has_mask?
756
329
  belongs = belongs & present if present
757
330
  slot[0] = c
758
- out[*slot] = belongs.int64.sum(axis: axis)
331
+ # a boolean sums to a count without widening the whole array first
332
+ out[*slot] = belongs.sum(axis: axis)
759
333
  end
760
334
  out
761
335
  end
@@ -774,35 +348,79 @@ class CACategoricalIterator < CAIterator
774
348
  # data can produce. The axis: path materialises nothing else, so holding
775
349
  # this one thing was the odd choice; a caller who wants a fused kernel's
776
350
  # four answers shares them by keeping the result.
777
- def axis_moments (axis)
351
+ def axis_moments (axis, op)
352
+ return axis_moments_by_core(axis, op) unless MONOID_TYPES.include?(@value.data_type)
778
353
  h = @value
779
- unless axis.is_a?(Integer) && axis >= 0 && axis < h.ndim
780
- raise ArgumentError,
781
- "group_by_category.<reduce>(axis: #{axis.inspect}): axis must be an " \
782
- "Integer in [0, #{h.ndim}) for source h with shape #{h.shape}"
783
- end
354
+ axis = checked_axis(axis, op)
355
+ band = h.shape.dup; band.delete_at(axis)
356
+ return band_only_moments(h, axis, band, op) if band_only?(@cat.codes, h.shape, axis)
784
357
  codes_h_shape = resolve_axis_codes(@cat.codes, h.shape, axis)
785
- band = h.shape.dup; band.delete_at(axis)
786
358
  out_shape = [@k] + band
787
359
  counts = CArray.int64(*out_shape)
788
360
  sums = CArray.float64(*out_shape)
789
- mins = CArray.new(h.data_type, out_shape)
790
- maxs = CArray.new(h.data_type, out_shape)
361
+ mins = CArray.new(h.data_type, out_shape, bytes: h.bytes)
362
+ maxs = CArray.new(h.data_type, out_shape, bytes: h.bytes)
791
363
  h.__send__(:__fiber_scatter_moments__, codes_h_shape, axis, @k,
792
364
  counts, sums, mins, maxs)
793
365
  {count: counts, sum: sums, min: mins, max: maxs}
794
366
  end
795
367
 
368
+ # The classifier is band-only, as resolve_axis_codes decides it (case A
369
+ # wins when both shapes fit).
370
+ def band_only? (codes, h_shape, axis)
371
+ band = h_shape.dup; band.delete_at(axis)
372
+ !band.empty? && codes.shape == band && codes.shape != [h_shape[axis]]
373
+ end
374
+
375
+ # Moments for a band-only classifier. Every cell of a fiber has the same
376
+ # code, so a fiber falls into one group whole: the core's own reductions
377
+ # along the axis answer for it, and each answer is placed in its code's
378
+ # row. A fiber whose code is masked belongs to no group. A group no fiber
379
+ # falls in, or one whose fibers are all masked, has count 0, sum 0.0 and
380
+ # masked min and max, as the fused kernel leaves it. Only the answers the
381
+ # caller reads are computed; each is a pass of its own here, where the
382
+ # kernel had all four from one.
383
+ BAND_ONLY_KEYS = { sum: [:sum], mean: [:sum, :count], min: [:min],
384
+ max: [:max], minmax: [:min, :max] }.freeze
385
+ private_constant :BAND_ONLY_KEYS
386
+
387
+ def band_only_moments (h, axis, band, op)
388
+ nb = band.inject(1, :*)
389
+ shape = [@k] + band
390
+ codes = @cat.codes
391
+ valid = codes.lt(@k).flatten
392
+ valid = valid.strip_mask(false) if valid.has_mask?
393
+ addr = (codes.value.int64.flatten * nb + CArray.int64(nb).seq)[valid]
394
+ out = {}
395
+ BAND_ONLY_KEYS.fetch(op, [:count]).each do |key|
396
+ res = case key
397
+ when :count then CArray.int64(*shape)
398
+ when :sum then CArray.float64(*shape)
399
+ else CArray.new(h.data_type, shape, bytes: h.bytes).tap { |a| a[] = UNDEF }
400
+ end
401
+ per_fiber = case key
402
+ when :count then h.count_not_masked(axis: axis)
403
+ when :sum then h.sum(axis: axis)
404
+ when :min then h.min(axis: axis)
405
+ when :max then h.max(axis: axis)
406
+ end
407
+ res.reshape(@k * nb)[addr] = per_fiber.flatten[valid]
408
+ out[key] = res
409
+ end
410
+ out
411
+ end
412
+
796
413
  # Axis-aware sum: the moments sum is already the core fold in the core's own
797
414
  # type, so it is handed back as is (an empty group cell carries identity 0.0).
798
415
  def axis_sum (axis)
799
- axis_moments(axis)[:sum]
416
+ axis_moments(axis, :sum)[:sum]
800
417
  end
801
418
 
802
419
  # Axis-aware mean: sums / counts (float64); empty group cells (count=0) MASKED.
803
420
  # Matches flat #mean per fiber.
804
421
  def axis_mean (axis)
805
- m = axis_moments(axis)
422
+ return axis_by_core(axis, :mean) unless MONOID_TYPES.include?(@value.data_type)
423
+ m = axis_moments(axis, :mean)
806
424
  cnt = m[:count]
807
425
  out = m[:sum] / cnt.float64 # count 0 -> NaN
808
426
  out[cnt.eq(0)] = UNDEF # empty / all-masked -> MASKED
@@ -821,13 +439,9 @@ class CACategoricalIterator < CAIterator
821
439
  # Cost: K axis-reductions over an h-shaped local (most cells masked away for
822
440
  # each c) — bounded by K, typically small. A fused per-fiber variance
823
441
  # kernel is a natural follow-on if bench demands it.
824
- def axis_by_masked_copy (axis, op, out_data_type = CA_FLOAT64)
442
+ def axis_by_masked_copy (axis, op, out_data_type = core_reduce_type(op))
825
443
  h = @value
826
- unless axis.is_a?(Integer) && axis >= 0 && axis < h.ndim
827
- raise ArgumentError,
828
- "group_by_category.#{op}(axis: #{axis.inspect}): axis must be an " \
829
- "Integer in [0, #{h.ndim}) for source h with shape #{h.shape}"
830
- end
444
+ axis = checked_axis(axis, op)
831
445
  full_c = resolve_axis_codes(@cat.codes, h.shape, axis)
832
446
  band = h.shape.dup; band.delete_at(axis)
833
447
  out = CArray.new(out_data_type, [@k] + band)
@@ -842,27 +456,128 @@ class CACategoricalIterator < CAIterator
842
456
  exclude = in_c.not
843
457
  exclude = exclude | codes_bad if codes_bad
844
458
  h_local[exclude] = UNDEF
845
- slice = h_local.__send__(op, axis: axis) # float64, band shape, mask carries n<contract
459
+ slice = h_local.__send__(op, axis: axis) # band shape, mask carries n<contract
846
460
  slot_idx[0] = c
847
461
  out[*slot_idx] = slice
848
462
  end
849
463
  out
850
464
  end
851
465
 
466
+ # The axis: reductions for a value the fused kernels cannot read (boolean,
467
+ # object, a Face): each group's cells are reduced by the core's own
468
+ # reduction along the axis and the answers stacked, so the data type, the
469
+ # refusals and the empty-group answer are the core's -- a time column's mean
470
+ # is a time, its sum is refused, an empty group's sum is the identity.
471
+ # Classified along the axis (case A), a group is its positions on the axis,
472
+ # taken by index; otherwise its cells are kept by masking the rest of a copy.
473
+ # A read-only Face's answers (a const string's records index a pool of
474
+ # their own) cannot be put together, so they are collected as the surface
475
+ # values they are, as the flat form does.
476
+ def axis_by_core (axis, op, *args)
477
+ h = @value
478
+ axis = checked_axis(axis, op)
479
+ groups = (0...[@k, 1].max).map { |c| group_cells(h, axis, c, @k.zero?) }
480
+ answers = groups.map { |g| g.__send__(op, *args, axis: axis) }
481
+ if answers.first.face? && answers.first.read_only?
482
+ answers = answers.map { |a| CArray.object(*a.shape) { a.to_a } }
483
+ end
484
+ out = CArray.stack(answers, axis: 0).copy
485
+ @k.zero? ? out[0...0, *([nil] * (out.ndim - 1))].copy : out
486
+ end
487
+
488
+ def axis_moments_by_core (axis, op)
489
+ BAND_ONLY_KEYS.fetch(op, [:count]).to_h do |key|
490
+ [key, axis_by_core(axis, key == :count ? :count_not_masked : key)]
491
+ end
492
+ end
493
+
494
+ def group_cells (h, axis, c, none)
495
+ codes = @cat.codes
496
+ if codes.shape == [h.shape[axis]]
497
+ sel = none ? CArray.boolean(codes.elements) { false } : codes.eq(c)
498
+ sel = sel.strip_mask(false) if sel.has_mask?
499
+ idx = [nil] * h.ndim
500
+ idx[axis] = sel.where
501
+ h[*idx]
502
+ else
503
+ full_c = resolve_axis_codes(codes, h.shape, axis)
504
+ exclude = none ? CArray.boolean(*h.shape) { true } : full_c.eq(c).not
505
+ exclude = exclude | full_c.is_masked if full_c.has_mask?
506
+ local = h.copy
507
+ local[exclude] = UNDEF
508
+ local
509
+ end
510
+ end
511
+ private :axis_by_core, :axis_moments_by_core, :group_cells
512
+
513
+ # A running statistic along an axis, for a categorical that classifies the
514
+ # positions along it (case A): each cell holds its group's running value up
515
+ # to it along the axis, per fiber. The answer is the core's, as the flat
516
+ # form's is; the kernel runs when it answers in the core's data type, and
517
+ # otherwise each group's positions are scanned by the core along the axis.
518
+ # Read when called, as the other axis: members are.
519
+ def axis_scan (op, axis)
520
+ h = @value
521
+ axis = checked_axis(axis, op)
522
+ classified_along!(op, axis)
523
+ answer = core_scan_answer(op)
524
+ if kernel_scans?(op, answer)
525
+ bundle = @k > 0 ? [@cat.codes, @k, [axis]]
526
+ : [CArray.int64(h.shape[axis]).fill(-1), 1, [axis]]
527
+ return h.__axis_group_scan__([axis], [bundle], op)
528
+ end
529
+ each_group_along(axis, ->(shape) { blank_like(answer, shape) }) do |g|
530
+ g.public_send(op, axis: axis)
531
+ end
532
+ end
533
+
534
+ # #map along an axis (case A): the block receives each group's positions
535
+ # along the axis, every other axis kept, and its answer goes back to them.
536
+ def axis_map (axis, data_type, &block)
537
+ axis = checked_axis(axis, :map)
538
+ classified_along!(:map, axis)
539
+ each_group_along(axis, ->(shape) { blank_output(shape, data_type) }, &block)
540
+ end
541
+
542
+ # Yields each group's positions along +axis+ as a view of the value and
543
+ # writes the block's answer back to them; a position in no group is UNDEF.
544
+ def each_group_along (axis, blank)
545
+ h = @value
546
+ out = blank.call(h.shape)
547
+ out[] = UNDEF
548
+ @k.times do |c|
549
+ sel = @cat.codes.eq(c)
550
+ sel = sel.strip_mask(false) if sel.has_mask?
551
+ next if sel.count(true).zero?
552
+ idx = [nil] * h.ndim
553
+ idx[axis] = sel.where
554
+ out[*idx] = surface_for(out, yield(h[*idx]))
555
+ end
556
+ out
557
+ end
558
+
559
+ # A running value or a transform along an axis follows the positions along
560
+ # it, so the categorical has to classify those positions.
561
+ def classified_along! (op, axis)
562
+ n = @value.shape[axis]
563
+ return if @cat.codes.shape == [n]
564
+ raise ArgumentError,
565
+ "#{op}(axis: #{axis}): needs a categorical that classifies the " \
566
+ "positions along axis #{axis} (shape [#{n}]); got shape " \
567
+ "#{@cat.codes.shape.inspect}"
568
+ end
569
+ private :axis_scan, :axis_map, :each_group_along, :classified_along!
570
+
852
571
  # Axis-aware wsum + wmean fused (single kernel call, both outputs). Returns
853
572
  # [wsum_ca, wmean_ca]. Weights must match source shape exactly (explicit
854
573
  # broadcast on the call site for 1-D or band-shape weights). A cell
855
574
  # contributes iff its value AND its weight are present.
856
- def axis_wsum_wmean (weights, axis)
575
+ def axis_wsum_wmean (weights, axis, op)
857
576
  h = @value
858
- unless axis.is_a?(Integer) && axis >= 0 && axis < h.ndim
859
- raise ArgumentError,
860
- "group_by_category.wsum/wmean(axis: #{axis.inspect}): axis must " \
861
- "be an Integer in [0, #{h.ndim}) for source h with shape #{h.shape}"
862
- end
577
+ axis = checked_axis(axis, op)
863
578
  unless weights.is_a?(CArray) && weights.shape == h.shape
864
579
  raise ArgumentError,
865
- "group_by_category.wsum/wmean(axis: #{axis}): weights.shape " \
580
+ "#{op}(axis: #{axis}): weights.shape " \
866
581
  "#{weights.respond_to?(:shape) ? weights.shape.inspect : weights.class} " \
867
582
  "must equal source.shape #{h.shape.inspect}. Wrap 1-D / band-shape " \
868
583
  "weights via `.broadcast_to(*source.shape)` before passing."
@@ -880,12 +595,9 @@ class CACategoricalIterator < CAIterator
880
595
  # Axis-aware prod: dedicated kernel (identity 1.0, separate from moments to
881
596
  # avoid conflating with sum's zero-identity memset).
882
597
  def axis_prod (axis)
598
+ return axis_by_core(axis, :prod) unless MONOID_TYPES.include?(@value.data_type)
883
599
  h = @value
884
- unless axis.is_a?(Integer) && axis >= 0 && axis < h.ndim
885
- raise ArgumentError,
886
- "group_by_category.prod(axis: #{axis.inspect}): axis must be an " \
887
- "Integer in [0, #{h.ndim}) for source h with shape #{h.shape}"
888
- end
600
+ axis = checked_axis(axis, :prod)
889
601
  codes_h_shape = resolve_axis_codes(@cat.codes, h.shape, axis)
890
602
  band = h.shape.dup; band.delete_at(axis)
891
603
  out = CArray.float64(*([@k] + band))
@@ -896,6 +608,12 @@ class CACategoricalIterator < CAIterator
896
608
  # Broadcast `codes` to `h_shape` per PROPOSAL §2.2 3-case positional rule.
897
609
  # Returns a broadcast view of codes at h_shape (or codes itself for case B).
898
610
  # Raises ArgumentError with a message that enumerates all 3 accepted shapes.
611
+ # The axis of an axis: reduction, normalised against the source: a
612
+ # negative axis counts from the end, as everywhere else.
613
+ def checked_axis (axis, op)
614
+ @value.normalize_axis(axis, op.to_s)
615
+ end
616
+
899
617
  def resolve_axis_codes (codes, h_shape, axis)
900
618
  ndim = h_shape.size
901
619
  band = h_shape.dup; band.delete_at(axis)
@@ -932,225 +650,10 @@ class CACategoricalIterator < CAIterator
932
650
  "band-only cat.shape=#{band.inspect}."
933
651
  end
934
652
 
935
- # Order-stat axis: is deferred to Phase 4 (per-fiber counting-sort C kernel).
936
- # Called from median / percentile / variance / stddev when axis: is given.
937
- def axis_order_stat_defer! (op)
938
- raise NotImplementedError,
939
- "CACategoricalIterator##{op}(axis:) is not implemented — order " \
940
- "statistics are available without axis:."
941
- end
942
-
943
- # Drive a segment scan through the axis-group scan kernel: the whole value as
944
- # one grouped axis, the flat codes as the single bundle. The kernel emits in
945
- # source order, so the flat result reshapes straight back to the source shape.
946
- def scan (op)
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
969
- end
970
-
971
-
972
- # Permutation: perm[slot] = the source index whose value sits at that grouped
973
- # slot. This is the valid prefix of the categorical's cached sort_addr, sliced
974
- # at construction (the same counting sort that lays out @grouped), so #map /
975
- # #sort_addr / the *_addr reductions read it for free.
976
- def perm
977
- @perm
978
- end
979
-
980
- # Lay a per-cell weight array out in category-contiguous order (same layout as
981
- # @grouped), so wsum / wmean can pair each group's values with its weights.
982
- # Weights are coerced to float64; the same counting-sort scatter propagates
983
- # the weight mask and skips excluded cells, so wg lines up with @grouped.
984
- def scatter_weights (weights)
985
- unless weights.elements == @codes.elements
986
- raise ArgumentError,
987
- "wsum/wmean: weights.elements (#{weights.elements}) != " \
988
- "value.elements (#{@codes.elements})"
989
- end
990
- wf = weights.float64
991
- wg = CArray.float64(grouped.elements)
992
- @codes.send(:__categorical_scatter__, wf.reshape(wf.elements),
993
- @offsets.copy, wg, @k)
994
- wg
995
- end
996
-
997
- # Map a per-category group-local index to the flat source address via the
998
- # permutation (grouped slot -> source index). The min/max sits at grouped slot
999
- # offsets[c] + local_index[c]; perm carries it back to the source. Empty
1000
- # categories (masked local index) stay masked.
1001
- def group_addr (local_index)
1002
- out = CArray.int64(@k)
1003
- @k.times do |c|
1004
- out[c] = local_index.is_masked[c] ? UNDEF
1005
- : perm[@offsets[c] + local_index[c]]
1006
- end
1007
- out
1008
- end
1009
-
1010
- # Fused per-segment weighted sum + weighted mean (one C pass over the grouped
1011
- # copy, weights in group order). Returns [wsum, wmean]; wmean is masked where a
1012
- # segment has no present (value AND weight) pair. Numeric value data types only.
1013
- def kernel_weighted (wg)
1014
- ws = CArray.float64(@k)
1015
- wm = CArray.float64(@k)
1016
- grouped.send(:__reduceat_wsum_wmean__, @offsets, wg, ws, wm)
1017
- [ws, wm]
1018
- end
1019
-
1020
- # Per-group weighted fallback for non-numeric value data types (complex): delegate
1021
- # each group to CArray#wsum / #wmean. Empty segments take the given identity.
1022
- def fold_weighted (wg, empty)
1023
- out = CArray.float64(@k)
1024
- @k.times do |c|
1025
- lo = @offsets[c]
1026
- hi = (c + 1 < @k) ? @offsets[c + 1] : grouped.elements
1027
- out[c] = hi > lo ? yield(grouped[lo...hi], wg[lo...hi]) : empty
1028
- end
1029
- out
1030
- end
1031
-
1032
- # The members of category `c` as a CArray slice of the grouped copy. An empty
1033
- # category (zero-width segment) yields the shared empty array — a zero-length
1034
- # slice cannot be taken directly, and an empty array carries the same reduction
1035
- # contract we want (identity for sum, UNDEF for ratios).
1036
- def group_slice (c)
1037
- lo = @offsets[c]
1038
- hi = (c + 1 < @k) ? @offsets[c + 1] : grouped.elements
1039
- hi > lo ? grouped[lo...hi] : @empty
1040
- end
1041
-
1042
- # Single-pass reduceat moments (count / sum / min / max per category), computed
1043
- # once over the grouped copy and cached — the whole point of the eager copy is
1044
- # that one scatter is followed by cheap single-pass reductions with no
1045
- # per-segment views. Nil for a non-numeric value data type (complex / object /
1046
- # bool), where the monoid reductions fall back to per_category.
1047
- # numeric value data types the C moments kernel handles (int8..float64); bool /
1048
- # complex / object fall back to per_category.
1049
- MONOID_TYPES = %i[int8 uint8 int16 uint16 int32 uint32
1050
- int64 uint64 float32 float64].freeze
1051
-
1052
- def moments
1053
- return @moments if defined?(@moments)
1054
- @moments =
1055
- if MONOID_TYPES.include?(grouped.data_type)
1056
- dt = grouped.data_type
1057
- counts = CArray.int64(@k)
1058
- sums = CArray.float64(@k)
1059
- mins = CArray.new(dt, [@k])
1060
- maxs = CArray.new(dt, [@k])
1061
- grouped.send(:__reduceat_moments__, @offsets, counts, sums, mins, maxs)
1062
- { count: counts, sum: sums, min: mins, max: maxs }
1063
- end
1064
- end
1065
-
1066
- # Single-pass fused group-local argmin / argmax (min_index / max_index),
1067
- # cached. Nil for a non-numeric value data type (fall back to per_category).
1068
- def arg_minmax
1069
- return @arg_minmax if defined?(@arg_minmax)
1070
- @arg_minmax =
1071
- if MONOID_TYPES.include?(grouped.data_type)
1072
- mn = CArray.int64(@k)
1073
- mx = CArray.int64(@k)
1074
- grouped.send(:__reduceat_argminmax__, @offsets, mn, mx)
1075
- { min: mn, max: mx }
1076
- end
1077
- end
1078
-
1079
- # Single-pass fused per-category boolean all / any, cached. Nil unless the
1080
- # value data type is boolean (fall back to per_category, which raises like
1081
- # CArray#all on a non-boolean).
1082
- def all_any
1083
- return @all_any if defined?(@all_any)
1084
- @all_any =
1085
- if grouped.data_type == CA_BOOLEAN
1086
- a = CArray.boolean(@k)
1087
- o = CArray.boolean(@k)
1088
- grouped.send(:__reduceat_all_any__, @offsets, a, o)
1089
- { all: a, any: o }
1090
- end
1091
- end
1092
-
1093
- # Build a length-k typed output by folding each category's members with the
1094
- # given reduction block. Fallback path (order statistics, and monoids on a
1095
- # non-numeric value data type): each group is delegated to the same CArray
1096
- # reduction, so the per-group result matches `CArray#<reduction>` over that
1097
- # group's members — the mask carries the "insufficient present data" contract
1098
- # for free (an all-masked group reduces like an empty one; identity-bearing
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
-
1136
- def per_category (data_type)
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.
1152
- @k.times { |c| out[c] = yield(group_slice(c)) }
1153
- out
653
+ # median / percentile along an axis: the core's own order statistic of each
654
+ # group's cells, stacked -- the axis_by_core path, one core call per group.
655
+ def axis_order_stat (axis, op, *args)
656
+ axis_by_core(axis, op, *args)
1154
657
  end
1155
658
  end
1156
659
 
@@ -1161,8 +664,14 @@ class CArray
1161
664
  # per category of `cat`. Requires `self.elements == cat.elements`.
1162
665
  # @param cat [CACategorical] the classifier.
1163
666
  # @return [CACategoricalIterator]
667
+ # @raise [TypeError] when `cat` is not a CACategorical.
1164
668
  # @raise [ArgumentError] when element counts differ.
1165
669
  def group_by_category (cat)
670
+ unless cat.is_a?(CACategorical)
671
+ raise TypeError,
672
+ "group_by_category: expects a CACategorical (got #{cat.class}); " \
673
+ "classify the values first with .categorize"
674
+ end
1166
675
  CACategoricalIterator.new(self, cat)
1167
676
  end
1168
677
 
@@ -1187,18 +696,16 @@ class CArray
1187
696
  # `grp.labels` is `[0, 1, ...]` in position order.
1188
697
  #
1189
698
  # @return [CACategoricalIterator] one category per run, in order.
1190
- # @raise [RuntimeError] when `self` is not 1-D.
699
+ # @raise [ArgumentError] when `self` is not 1-D.
1191
700
  def group_by_run
1192
- raise "group_by_run: 1-D only (got #{ndim}-D)" unless ndim == 1
701
+ raise ArgumentError, "group_by_run: 1-D only (got #{ndim}-D)" unless ndim == 1
1193
702
  if elements == 0
1194
703
  code = CArray.int64(0)
1195
704
  else
1196
705
  present = is_not_masked
1197
706
  edge = present & present.shift(1).not # rising edge = run start
1198
- # feed cumsum via a zero-copy int8 reinterpret of the 1-byte booleans
1199
- # rather than widening to int64; cumsum promotes to float64, so the
1200
- # running count never overflows int8.
1201
- code = edge.refer(:int8).cumsum.int64 - 1 # 0-based run index per cell
707
+ # a boolean cumsum counts in uint64, exactly
708
+ code = edge.cumsum.int64 - 1 # 0-based run index per cell
1202
709
  code[present.not] = UNDEF # masked cells join no run
1203
710
  end
1204
711
  # categorize turns the dense run indices into the run categories: it derives