carray 3.0.2 → 3.0.4

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 (247) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +568 -0
  3. data/README.md +6 -6
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_descriptor.h +27 -24
  6. data/ext/ca_axis_dispatch.c +220 -0
  7. data/ext/ca_axis_group.c +201 -123
  8. data/ext/ca_binop_dispatch.c +46 -52
  9. data/ext/ca_binop_dispatch.h +1 -1
  10. data/ext/ca_categorical_iterator.c +249 -183
  11. data/ext/ca_compare.c +7 -12
  12. data/ext/ca_for_buffer.h +21 -68
  13. data/ext/ca_group_iter.c +38 -23
  14. data/ext/ca_kernel_iterator.c +1765 -2076
  15. data/ext/ca_kernel_iterator.h +584 -566
  16. data/ext/ca_monop_dispatch.c +86 -63
  17. data/ext/ca_monop_dispatch.h +5 -0
  18. data/ext/ca_obj_array.c +223 -56
  19. data/ext/ca_obj_bincmp.c +172 -138
  20. data/ext/ca_obj_binop.c +116 -230
  21. data/ext/ca_obj_bitarray.c +40 -26
  22. data/ext/ca_obj_bitfield.c +100 -68
  23. data/ext/ca_obj_block.c +19 -9
  24. data/ext/ca_obj_byte_swap.c +45 -46
  25. data/ext/ca_obj_const_string.c +269 -44
  26. data/ext/ca_obj_face.c +612 -89
  27. data/ext/ca_obj_face.h +111 -47
  28. data/ext/ca_obj_fake.c +36 -79
  29. data/ext/ca_obj_farray.c +7 -3
  30. data/ext/ca_obj_field.c +33 -8
  31. data/ext/ca_obj_fixlen_string.c +23 -24
  32. data/ext/ca_obj_grid.c +541 -210
  33. data/ext/ca_obj_meld.c +331 -111
  34. data/ext/ca_obj_moncmp.c +80 -65
  35. data/ext/ca_obj_monop.c +177 -149
  36. data/ext/ca_obj_object.c +664 -248
  37. data/ext/ca_obj_record.c +63 -23
  38. data/ext/ca_obj_reduce.c +18 -16
  39. data/ext/ca_obj_refer.c +140 -58
  40. data/ext/ca_obj_remap.c +85 -24
  41. data/ext/ca_obj_repeat.c +24 -9
  42. data/ext/ca_obj_roll.c +121 -16
  43. data/ext/ca_obj_select.c +61 -58
  44. data/ext/ca_obj_select_axis.c +73 -107
  45. data/ext/ca_obj_shift.c +32 -102
  46. data/ext/ca_obj_stack.c +372 -122
  47. data/ext/ca_obj_stride.c +348 -140
  48. data/ext/ca_obj_string.c +88 -22
  49. data/ext/ca_obj_tile.c +38 -21
  50. data/ext/ca_obj_time.c +45 -31
  51. data/ext/ca_obj_timedelta.c +43 -29
  52. data/ext/ca_obj_transpose.c +17 -6
  53. data/ext/ca_obj_triop.c +125 -141
  54. data/ext/ca_obj_window.c +330 -44
  55. data/ext/ca_op_ipower.c +24 -11
  56. data/ext/ca_op_powi.h +57 -30
  57. data/ext/ca_pow5_table.h +656 -0
  58. data/ext/ca_sort_kernels.h +70 -0
  59. data/ext/ca_sweep_engine.c +104 -81
  60. data/ext/ca_sweep_engine.h +7 -20
  61. data/ext/ca_triop_dispatch.c +4 -1
  62. data/ext/ca_triop_dispatch.h +5 -2
  63. data/ext/caframe_csv_reader.c +542 -0
  64. data/ext/carray.h +241 -92
  65. data/ext/carray_access.c +608 -276
  66. data/ext/carray_address_basis.c +494 -97
  67. data/ext/carray_attribute.c +129 -4
  68. data/ext/carray_bincount.c +39 -7
  69. data/ext/carray_broadcast.c +56 -19
  70. data/ext/carray_build_flags.h +1 -1
  71. data/ext/carray_call_cfunc.c +108 -28
  72. data/ext/carray_cast.c +1576 -223
  73. data/ext/carray_cast_func.rb +77 -24
  74. data/ext/carray_class.c +4 -3
  75. data/ext/carray_conversion.c +208 -75
  76. data/ext/carray_copy.c +82 -24
  77. data/ext/carray_core.c +646 -162
  78. data/ext/carray_count.c +171 -88
  79. data/ext/carray_element.c +94 -60
  80. data/ext/carray_factorize.c +670 -324
  81. data/ext/carray_generate.c +260 -83
  82. data/ext/carray_histogram.c +209 -106
  83. data/ext/carray_hold.c +14 -16
  84. data/ext/carray_index_classifier.c +422 -243
  85. data/ext/carray_internal.h +692 -3
  86. data/ext/carray_kernels_bincmp.c +7 -0
  87. data/ext/carray_kernels_binop.c +487 -120
  88. data/ext/carray_kernels_init.c +653 -50
  89. data/ext/carray_kernels_moncmp.c +15 -8
  90. data/ext/carray_kernels_monop.c +99 -43
  91. data/ext/carray_kernels_reduce_aggregate.c +3925 -6112
  92. data/ext/carray_kernels_reduce_boolean.c +112 -36
  93. data/ext/carray_kernels_reduce_cumulative.c +2127 -6903
  94. data/ext/carray_kernels_reduce_extreme.c +3032 -2892
  95. data/ext/carray_kernels_reduce_variance.c +439 -500
  96. data/ext/carray_kernels_scan.c +1097 -488
  97. data/ext/carray_kernels_search.c +3787 -2176
  98. data/ext/carray_kernels_sort.c +1200 -920
  99. data/ext/carray_kernels_triop.c +479 -7
  100. data/ext/carray_key_windows.c +216 -0
  101. data/ext/carray_lazy.c +967 -153
  102. data/ext/carray_loop.c +81 -65
  103. data/ext/carray_mask.c +910 -324
  104. data/ext/carray_math_kernel.h +1 -0
  105. data/ext/carray_median_percentile.c +299 -100
  106. data/ext/carray_memory_view.c +872 -758
  107. data/ext/carray_operator.c +1171 -364
  108. data/ext/carray_order.c +505 -133
  109. data/ext/carray_partition.c +112 -65
  110. data/ext/carray_random.c +327 -239
  111. data/ext/carray_round.c +273 -0
  112. data/ext/carray_scatter.c +346 -78
  113. data/ext/carray_segment.c +257 -0
  114. data/ext/carray_slab.c +249 -86
  115. data/ext/carray_sort.c +224 -227
  116. data/ext/carray_sort_kernel.c +8 -1
  117. data/ext/carray_struct.c +118 -39
  118. data/ext/carray_test.c +193 -69
  119. data/ext/carray_time_text.c +1182 -0
  120. data/ext/carray_undef.c +4 -0
  121. data/ext/carray_utils.c +579 -113
  122. data/ext/extconf.rb +27 -2
  123. data/ext/mk_call_cfunc.rb +19 -4
  124. data/ext/mk_pow5_table.rb +35 -0
  125. data/ext/mkkernel.rb +1202 -1014
  126. data/ext/ruby_carray.c +17 -5
  127. data/ext/version.h +4 -4
  128. data/ext/version.rb +4 -2
  129. data/lib/carray/attribute.rb +160 -17
  130. data/lib/carray/autoload_carray.rb +23 -27
  131. data/lib/carray/autoload_method_extension.rb +20 -5
  132. data/lib/carray/axis_group.rb +360 -63
  133. data/lib/carray/basics.rb +162 -28
  134. data/lib/carray/bincount_nd.rb +18 -2
  135. data/lib/carray/block_iterator.rb +231 -58
  136. data/lib/carray/boolean_reduce.rb +34 -42
  137. data/lib/carray/categorical.rb +242 -52
  138. data/lib/carray/categorical_iterator.rb +437 -827
  139. data/lib/carray/complex.rb +41 -34
  140. data/lib/carray/conditional.rb +150 -14
  141. data/lib/carray/const_string.rb +59 -4
  142. data/lib/carray/construct.rb +15 -11
  143. data/lib/carray/data_type_extension.rb +1 -0
  144. data/lib/carray/distance_limit.rb +77 -0
  145. data/lib/carray/fixlen_string.rb +7 -1
  146. data/lib/carray/frame/column_verbs.rb +70 -0
  147. data/lib/carray/frame/concat.rb +157 -6
  148. data/lib/carray/frame/convert.rb +82 -7
  149. data/lib/carray/frame/csv_parser.rb +688 -87
  150. data/lib/carray/frame/describe.rb +80 -0
  151. data/lib/carray/frame/frame.rb +369 -45
  152. data/lib/carray/frame/group.rb +631 -90
  153. data/lib/carray/frame/io.rb +506 -79
  154. data/lib/carray/frame/join.rb +411 -62
  155. data/lib/carray/frame/records.rb +116 -19
  156. data/lib/carray/frame/reshape.rb +455 -0
  157. data/lib/carray/frame/sort.rb +30 -18
  158. data/lib/carray/frame/verbs.rb +868 -61
  159. data/lib/carray/frame/windows.rb +117 -0
  160. data/lib/carray/frame.rb +4 -0
  161. data/lib/carray/fuse_source.rb +118 -2
  162. data/lib/carray/fusion.rb +257 -42
  163. data/lib/carray/histogram.rb +6 -1
  164. data/lib/carray/inspect.rb +54 -13
  165. data/lib/carray/iterator.rb +99 -9
  166. data/lib/carray/key_windows.rb +620 -0
  167. data/lib/carray/lazy.rb +216 -193
  168. data/lib/carray/mask_gap_fill.rb +112 -92
  169. data/lib/carray/math.rb +32 -19
  170. data/lib/carray/meld_reduce.rb +52 -21
  171. data/lib/carray/methods/align_addr.rb +8 -4
  172. data/lib/carray/methods/bin.rb +26 -18
  173. data/lib/carray/methods/bincount.rb +18 -2
  174. data/lib/carray/methods/bit_string.rb +15 -4
  175. data/lib/carray/methods/choose.rb +16 -3
  176. data/lib/carray/methods/composition.rb +18 -10
  177. data/lib/carray/methods/factorize.rb +2 -2
  178. data/lib/carray/methods/gather_nd.rb +25 -5
  179. data/lib/carray/methods/insert_block.rb +9 -14
  180. data/lib/carray/methods/is_between.rb +59 -0
  181. data/lib/carray/methods/is_in.rb +140 -10
  182. data/lib/carray/methods/join.rb +2 -7
  183. data/lib/carray/methods/lag.rb +49 -0
  184. data/lib/carray/methods/locate_addr.rb +182 -45
  185. data/lib/carray/methods/mode.rb +18 -1
  186. data/lib/carray/methods/nunique.rb +11 -6
  187. data/lib/carray/methods/repeat.rb +1 -20
  188. data/lib/carray/methods/resize.rb +21 -23
  189. data/lib/carray/methods/snap.rb +34 -12
  190. data/lib/carray/methods/unique.rb +11 -2
  191. data/lib/carray/methods/value_counts.rb +4 -11
  192. data/lib/carray/record_order.rb +208 -0
  193. data/lib/carray/rng.rb +3 -0
  194. data/lib/carray/runtime.rb +65 -17
  195. data/lib/carray/segment_iterator.rb +1221 -0
  196. data/lib/carray/serialize.rb +214 -30
  197. data/lib/carray/slab_iterator.rb +101 -27
  198. data/lib/carray/stack.rb +10 -4
  199. data/lib/carray/string.rb +105 -5
  200. data/lib/carray/string_operation_extension.rb +12 -4
  201. data/lib/carray/struct.rb +137 -58
  202. data/lib/carray/struct_builder.rb +124 -17
  203. data/lib/carray/time.rb +1222 -186
  204. data/lib/carray/window_iterator.rb +242 -81
  205. data/lib/carray.rb +9 -2
  206. data/yard-stubs/ca_obj_array.rb +4 -4
  207. data/yard-stubs/ca_obj_bitarray.rb +7 -0
  208. data/yard-stubs/ca_obj_bitfield.rb +23 -12
  209. data/yard-stubs/ca_obj_fake.rb +1 -1
  210. data/yard-stubs/ca_obj_field.rb +5 -1
  211. data/yard-stubs/ca_obj_grid.rb +21 -10
  212. data/yard-stubs/ca_obj_refer.rb +5 -4
  213. data/yard-stubs/ca_obj_roll.rb +2 -3
  214. data/yard-stubs/ca_obj_shift.rb +5 -1
  215. data/yard-stubs/ca_obj_stride.rb +10 -7
  216. data/yard-stubs/ca_obj_tile.rb +1 -1
  217. data/yard-stubs/ca_obj_transpose.rb +1 -1
  218. data/yard-stubs/ca_obj_window.rb +9 -5
  219. data/yard-stubs/carray_access.rb +11 -5
  220. data/yard-stubs/carray_attribute.rb +0 -5
  221. data/yard-stubs/carray_broadcast.rb +1 -1
  222. data/yard-stubs/carray_cast.rb +88 -43
  223. data/yard-stubs/carray_conversion.rb +15 -2
  224. data/yard-stubs/carray_copy.rb +12 -5
  225. data/yard-stubs/carray_core.rb +3 -3
  226. data/yard-stubs/carray_count.rb +9 -0
  227. data/yard-stubs/carray_element.rb +10 -4
  228. data/yard-stubs/carray_generate.rb +21 -1
  229. data/yard-stubs/carray_lazy.rb +5 -1
  230. data/yard-stubs/carray_loop.rb +4 -1
  231. data/yard-stubs/carray_mask.rb +60 -12
  232. data/yard-stubs/carray_math.rb +52 -14
  233. data/yard-stubs/carray_median_percentile.rb +15 -8
  234. data/yard-stubs/carray_memory_view.rb +40 -23
  235. data/yard-stubs/carray_order.rb +61 -29
  236. data/yard-stubs/carray_random.rb +13 -4
  237. data/yard-stubs/carray_scatter.rb +21 -9
  238. data/yard-stubs/carray_segment.rb +72 -0
  239. data/yard-stubs/carray_slab.rb +10 -2
  240. data/yard-stubs/carray_sort.rb +49 -8
  241. data/yard-stubs/carray_test.rb +15 -3
  242. data/yard-stubs/carray_utils.rb +0 -26
  243. metadata +19 -5
  244. data/ext/ca_for_each_element.h +0 -239
  245. data/ext/carray_kernels_map.c +0 -3467
  246. data/lib/carray/arrow_tensor.rb +0 -401
  247. data/lib/carray/table.rb +0 -108
@@ -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,19 @@
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: the value is
67
+ # gathered through the categorical's cached grouping plan, so each category
68
+ # is one contiguous segment, and every reduction without `axis:` is the
69
+ # segment reduction over that copy (one value per category, in {#labels}
70
+ # order). With few categories and a numeric value, the count / sum / min /
71
+ # max are read from the values by code instead, and the copy is gathered
72
+ # only for the reductions that need it. This class adds the construction, the labels, and the per-fiber
73
+ # form (`axis:`), which classifies each fiber along an axis and keeps the
74
+ # other axes.
75
+ class CACategoricalIterator < CASegmentIterator
74
76
 
75
77
  # value : the payload CArray to reduce, one cell per categorical cell.
76
78
  # cat : the CACategorical carrying the classification.
@@ -105,10 +107,10 @@ class CACategoricalIterator < CAIterator
105
107
  @elements = cat.category_sizes.int64
106
108
  nvalid = counts.sum
107
109
  @offsets = cat.reduceat_index # cached segment STARTS (int64[k])
108
- # Group-major source indices = the valid prefix of the cached sort_addr.
109
- # With no classified cell the prefix is empty (and slicing a length-0
110
- # sort_addr would be out of range), so take the empty permutation directly.
111
- @perm = nvalid > 0 ? cat.sort_addr[0...nvalid] : CArray.int64(0)
110
+ @bounds = CArray.segment_offsets(lengths: @elements) # every boundary (int64[k+1])
111
+ # The group-major source indices (#perm) are taken from the cached
112
+ # sort_addr when first asked for.
113
+ @nvalid = nvalid
112
114
  @codes = cat.codes.reshape(cat.elements) # flat codes view (map re-walk / weights)
113
115
  # Gather value into category-contiguous order via the cached permutation
114
116
  # and materialise (the reduceat kernels read the grouped buffer's raw ptr,
@@ -117,9 +119,21 @@ class CACategoricalIterator < CAIterator
117
119
  # rebuilt per column. With no classified cell (empty / all-excluded) there
118
120
  # is nothing to gather — an empty index into an empty source is out of
119
121
  # range — so build the empty grouped buffer directly.
120
- @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
+ #
123
+ # With few categories, a numeric value is instead copied as it lies:
124
+ # the count / sum / min / max read it in one pass by code (scattering
125
+ # into one slot per category beats gathering every cell first while
126
+ # the slots stay in cache), and the grouped copy is gathered from it
127
+ # only when a reduction needs the categories contiguous. Either way
128
+ # the iterator holds the values as they were when it was built.
129
+ flat = value.reshape(value.elements)
130
+ if @k <= SCATTER_CATEGORIES_MAX && kernel_reads?(value.data_type)
131
+ @as_built = flat.copy
132
+ @empty = @as_built[0...0]
133
+ else
134
+ @grouped = nvalid > 0 ? flat[perm].copy : flat[0...0].copy
135
+ @empty = @grouped[0...0] # an empty category is the values' own kind
136
+ end
123
137
  else
124
138
  # Shape mismatch: only per-fiber axis: dispatch could still work. With a
125
139
  # 1-D value there is no fiber structure to broadcast into, so a mismatch
@@ -147,19 +161,9 @@ class CACategoricalIterator < CAIterator
147
161
  self
148
162
  end
149
163
 
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
164
+ # The most categories for which the count / sum / min / max are read by
165
+ # scattering the values by code rather than through the grouped copy.
166
+ SCATTER_CATEGORIES_MAX = 4096
163
167
 
164
168
  # @overload labels
165
169
  # Returns the category vocabulary the results are aligned to.
@@ -174,21 +178,6 @@ class CACategoricalIterator < CAIterator
174
178
  @k
175
179
  end
176
180
 
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
181
  # Group-vocabulary alias for {#elements}; reads naturally next to
193
182
  # {#ngroups} and mirrors `CACategorical#category_sizes`.
194
183
  alias group_sizes elements
@@ -207,543 +196,178 @@ class CACategoricalIterator < CAIterator
207
196
  "#<#{self.class} ngroups=#{@k} labels=#{@labels.inspect} #{tail}>"
208
197
  end
209
198
 
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.
199
+ # ---- the per-fiber form ---------------------------------------------------
565
200
  #
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
201
+ # These reductions also take `axis:` on a categorical. Without it each is
202
+ # the CASegmentIterator reduction of the same name, over the category-sorted
203
+ # copy: one value per category, in {#labels} order.
204
+ #
205
+ # @!method accumulate(axis: nil)
206
+ # @overload accumulate
207
+ # The per-category form: {CASegmentIterator#accumulate}, one value per category.
208
+ # @overload accumulate(axis:)
209
+ # Per-fiber per-category in-type sums along `axis`. Output shape =
210
+ # `[K, ...source.shape without axis]`.
211
+ # @param axis [Integer] reduce axis of the source value.
212
+ # @return [CArray]
213
+ # @!method count(*args, axis: nil)
214
+ # @overload count
215
+ # The per-category form: {CASegmentIterator#count}, one value per category.
216
+ # @overload count(axis:)
217
+ # No-arg + axis: = per-fiber per-category count_not_masked (shape [K, ...band]).
218
+ # `count(v, axis:)` (value equality) and `count(UNDEF, axis:)` are not
219
+ # implemented; use them without `axis:`.
220
+ # @param axis [Integer]
221
+ # @return [CArray]
222
+ # @!method count_masked(axis: nil)
223
+ # @overload count_masked
224
+ # The per-category form: {CASegmentIterator#count_masked}, one value per category.
225
+ # @overload count_masked(axis:)
226
+ # Not implemented; call it without `axis:`.
227
+ # @param axis [Integer]
228
+ # @return [CArray]
229
+ # @!method count_not_masked(axis: nil)
230
+ # @overload count_not_masked
231
+ # The per-category form: {CASegmentIterator#count_not_masked}, one value per category.
232
+ # @overload count_not_masked(axis:)
233
+ # Per-fiber per-category count of present (non-masked) values along `axis`
234
+ # (int64, shape [K, ...band]). Empty cells are `0`.
235
+ # @param axis [Integer]
236
+ # @return [CArray]
237
+ # @!method max(axis: nil)
238
+ # @overload max
239
+ # The per-category form: {CASegmentIterator#max}, one value per category.
240
+ # @overload max(axis:)
241
+ # Per-fiber per-category maxima along `axis` (h's data type, masked where empty).
242
+ # @param axis [Integer]
243
+ # @return [CArray]
244
+ # @!method mean(axis: nil)
245
+ # @overload mean
246
+ # The per-category form: {CASegmentIterator#mean}, one value per category.
247
+ # @overload mean(axis:)
248
+ # Per-fiber per-category means (float64, empty group cells MASKED).
249
+ # @param axis [Integer]
250
+ # @return [CArray]
251
+ # @!method min(axis: nil)
252
+ # @overload min
253
+ # The per-category form: {CASegmentIterator#min}, one value per category.
254
+ # @overload min(axis:)
255
+ # Per-fiber per-category minima along `axis` (h's data type, masked where empty).
256
+ # @param axis [Integer]
257
+ # @return [CArray]
258
+ # @!method minmax(axis: nil)
259
+ # @overload minmax
260
+ # The per-category form: {CASegmentIterator#minmax}, one value per category.
261
+ # @overload minmax(axis:)
262
+ # Per-fiber `[min_ca, max_ca]` along `axis` (each shape [K, ...band], h's data type,
263
+ # empty group cells MASKED). Ruby Array of two CArrays, not stacked.
264
+ # Both come from one kernel run.
265
+ # @param axis [Integer]
266
+ # @return [Array<CArray>]
267
+ # @!method prod(axis: nil)
268
+ # @overload prod
269
+ # The per-category form: {CASegmentIterator#prod}, one value per category.
270
+ # @overload prod(axis:)
271
+ # Per-fiber per-category products (float64, shape [K, ...band]). Empty
272
+ # group cells `1.0` (identity).
273
+ # @param axis [Integer]
274
+ # @return [CArray]
275
+ # @!method stddevp(axis: nil)
276
+ # @overload stddevp
277
+ # The per-category form: {CASegmentIterator#stddevp}, one value per category.
278
+ # @overload stddevp(axis:)
279
+ # Per-fiber per-category population stddev (float64, empty group cells MASKED).
280
+ # @param axis [Integer]
281
+ # @return [CArray]
282
+ # @!method sum(axis: nil)
283
+ # @overload sum
284
+ # The per-category form: {CASegmentIterator#sum}, one value per category.
285
+ # @overload sum(axis:)
286
+ # Returns per-category sums per fiber along `axis`. Cat may be 1-D (case
287
+ # A, broadcasts across band axes), same rank as source (case B, per-fiber
288
+ # independent classifier), or one rank less (band-only, constant along
289
+ # reduce axis). Output shape = `[K, ...source.shape without axis]`.
290
+ # @param axis [Integer] reduce axis of the source value.
291
+ # @return [CArray]
292
+ # @!method wmean(weights, axis: nil)
293
+ # @overload wmean
294
+ # The per-category form: {CASegmentIterator#wmean}, one value per category.
295
+ # @overload wmean(weights, axis:)
296
+ # Per-fiber per-category weighted mean along `axis`. Same weights-shape
297
+ # contract as {#wsum} (weights.shape == source.shape). Empty cell → MASKED;
298
+ # a present cell whose weights sum to zero → NaN (0/0 core contract).
299
+ # @param weights [CArray]
300
+ # @param axis [Integer]
301
+ # @return [CArray]
302
+ # @!method wsum(weights, axis: nil)
303
+ # @overload wsum
304
+ # The per-category form: {CASegmentIterator#wsum}, one value per category.
305
+ # @overload wsum(weights, axis:)
306
+ # Per-fiber per-category weighted sum along `axis`. `weights` must have
307
+ # shape == source.shape (rev3 requires explicit broadcast; wrap 1-D or
308
+ # band-shape weights via `.broadcast_to(*source.shape)` at the call site).
309
+ # Empty group cell → `0.0` (identity). Mask contract: cell contributes iff
310
+ # value AND weight are present.
311
+ # @param weights [CArray]
312
+ # @param axis [Integer]
313
+ # @return [CArray]
607
314
 
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
315
+ private
627
316
 
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
- }
317
+ # Both are built with the grouping plan, and only on the flat path; the
318
+ # per-fiber form has neither and must not build a segment's.
319
+ # Group-major source indices = the valid prefix of the cached sort_addr.
320
+ # With no classified cell the prefix is empty (and slicing a length-0
321
+ # sort_addr would be out of range), so take the empty permutation directly.
322
+ def perm
323
+ if @nvalid && ! @perm
324
+ @perm = @nvalid > 0 ? @cat.sort_addr[0...@nvalid] : CArray.int64(0)
651
325
  end
326
+ @perm
652
327
  end
653
328
 
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
329
+ def codes
330
+ @codes
680
331
  end
681
332
 
682
- # ---- segment scan: within-category running statistics ------------------
683
- #
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) }
333
+ # With few categories the grouped copy is gathered from the values as
334
+ # built, the first time a reduction asks for it.
335
+ def grouped
336
+ if @as_built && ! @grouped
337
+ @grouped = @nvalid > 0 ? @as_built[perm].copy : @as_built[0...0].copy
338
+ end
339
+ super
717
340
  end
718
341
 
719
- private
720
-
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)
342
+ def kernel_reads? (data_type = (@as_built || grouped).data_type)
343
+ super(data_type)
727
344
  end
728
345
 
729
- # Per-category cell counts, alongside #grouped and unavailable for the same
730
- # reason.
731
- def counts
732
- @elements || raise(ArgumentError, @no_flat)
346
+ # With few categories the count / sum / min / max come from one pass over
347
+ # the values as built, each cell added to the slot of its code; a cell
348
+ # whose value or code is masked, or whose code names no category, is in
349
+ # no slot, as it is in no segment of the grouped copy.
350
+ def moments
351
+ return @moments if defined?(@moments)
352
+ return super unless @as_built && kernel_reads?
353
+ counts = CArray.int64(@k)
354
+ sums = CArray.float64(@k)
355
+ mins = CArray.new(@as_built.data_type, [@k])
356
+ maxs = CArray.new(@as_built.data_type, [@k])
357
+ @as_built.__send__(:__fiber_scatter_moments__, @codes, 0, @k, counts, sums, mins, maxs)
358
+ @moments = { count: counts, sum: sums, min: mins, max: maxs }
733
359
  end
734
360
 
735
361
  # 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)
362
+ # group does not look at what is in them. An integer or float payload goes
363
+ # through the fused moments kernel, which counts in the same pass that
364
+ # sums. Any other payload -- complex, boolean, object, a Face -- the kernel
365
+ # refuses, for an answer that never depended on the payload, so it is
366
+ # counted from the codes and the value's mask, one group at a time.
367
+ def axis_counts (axis, op)
741
368
  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
369
+ return axis_moments(axis, op)[:count] if kernel_reads?(h.data_type)
370
+ axis = checked_axis(axis, op)
747
371
  full_c = resolve_axis_codes(@cat.codes, h.shape, axis)
748
372
  band = h.shape.dup; band.delete_at(axis)
749
373
  out = CArray.int64(*([@k] + band))
@@ -755,7 +379,8 @@ class CACategoricalIterator < CAIterator
755
379
  belongs = belongs.strip_mask(false) if belongs.has_mask?
756
380
  belongs = belongs & present if present
757
381
  slot[0] = c
758
- out[*slot] = belongs.int64.sum(axis: axis)
382
+ # a boolean sums to a count without widening the whole array first
383
+ out[*slot] = belongs.sum(axis: axis)
759
384
  end
760
385
  out
761
386
  end
@@ -774,35 +399,80 @@ class CACategoricalIterator < CAIterator
774
399
  # data can produce. The axis: path materialises nothing else, so holding
775
400
  # this one thing was the odd choice; a caller who wants a fused kernel's
776
401
  # four answers shares them by keeping the result.
777
- def axis_moments (axis)
402
+ def axis_moments (axis, op)
403
+ return axis_moments_by_core(axis, op) unless kernel_reads?(@value.data_type)
778
404
  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
405
+ axis = checked_axis(axis, op)
406
+ band = h.shape.dup; band.delete_at(axis)
407
+ return band_only_moments(h, axis, band, op) if band_only?(@cat.codes, h.shape, axis)
784
408
  codes_h_shape = resolve_axis_codes(@cat.codes, h.shape, axis)
785
- band = h.shape.dup; band.delete_at(axis)
786
409
  out_shape = [@k] + band
787
410
  counts = CArray.int64(*out_shape)
788
411
  sums = CArray.float64(*out_shape)
789
- mins = CArray.new(h.data_type, out_shape)
790
- maxs = CArray.new(h.data_type, out_shape)
412
+ mins = CArray.new(h.data_type, out_shape, bytes: h.bytes)
413
+ maxs = CArray.new(h.data_type, out_shape, bytes: h.bytes)
791
414
  h.__send__(:__fiber_scatter_moments__, codes_h_shape, axis, @k,
792
415
  counts, sums, mins, maxs)
793
416
  {count: counts, sum: sums, min: mins, max: maxs}
794
417
  end
795
418
 
419
+ # The classifier is band-only, as resolve_axis_codes decides it (case A
420
+ # wins when both shapes fit).
421
+ def band_only? (codes, h_shape, axis)
422
+ band = h_shape.dup; band.delete_at(axis)
423
+ !band.empty? && codes.shape == band && codes.shape != [h_shape[axis]]
424
+ end
425
+
426
+ # Moments for a band-only classifier. Every cell of a fiber has the same
427
+ # code, so a fiber falls into one group whole: the core's own reductions
428
+ # along the axis answer for it, and each answer is placed in its code's
429
+ # row. A fiber whose code is masked belongs to no group. A group no fiber
430
+ # falls in, or one whose fibers are all masked, has count 0, sum 0.0 and
431
+ # masked min and max, as the fused kernel leaves it. Only the answers the
432
+ # caller reads are computed; each is a pass of its own here, where the
433
+ # kernel had all four from one.
434
+ BAND_ONLY_KEYS = { sum: [:sum], mean: [:sum, :count], min: [:min],
435
+ max: [:max], minmax: [:min, :max] }.freeze
436
+ Ractor.make_shareable(BAND_ONLY_KEYS)
437
+ private_constant :BAND_ONLY_KEYS
438
+
439
+ def band_only_moments (h, axis, band, op)
440
+ nb = band.inject(1, :*)
441
+ shape = [@k] + band
442
+ codes = @cat.codes
443
+ valid = codes.lt(@k).flatten
444
+ valid = valid.strip_mask(false) if valid.has_mask?
445
+ addr = (codes.value.int64.flatten * nb + CArray.int64(nb).seq)[valid]
446
+ out = {}
447
+ BAND_ONLY_KEYS.fetch(op, [:count]).each do |key|
448
+ res = case key
449
+ when :count then CArray.int64(*shape)
450
+ when :sum then CArray.float64(*shape)
451
+ else CArray.new(h.data_type, shape, bytes: h.bytes).tap { |a| a[] = UNDEF }
452
+ end
453
+ per_fiber = case key
454
+ when :count then h.count_not_masked(axis: axis)
455
+ when :sum then h.sum(axis: axis)
456
+ when :min then h.min(axis: axis)
457
+ when :max then h.max(axis: axis)
458
+ end
459
+ res.reshape(@k * nb)[addr] = per_fiber.flatten[valid]
460
+ out[key] = res
461
+ end
462
+ out
463
+ end
464
+
796
465
  # Axis-aware sum: the moments sum is already the core fold in the core's own
797
466
  # type, so it is handed back as is (an empty group cell carries identity 0.0).
798
467
  def axis_sum (axis)
799
- axis_moments(axis)[:sum]
468
+ axis_moments(axis, :sum)[:sum]
800
469
  end
801
470
 
802
471
  # Axis-aware mean: sums / counts (float64); empty group cells (count=0) MASKED.
803
472
  # Matches flat #mean per fiber.
804
473
  def axis_mean (axis)
805
- m = axis_moments(axis)
474
+ return axis_by_core(axis, :mean) unless kernel_reads?(@value.data_type)
475
+ m = axis_moments(axis, :mean)
806
476
  cnt = m[:count]
807
477
  out = m[:sum] / cnt.float64 # count 0 -> NaN
808
478
  out[cnt.eq(0)] = UNDEF # empty / all-masked -> MASKED
@@ -821,52 +491,185 @@ class CACategoricalIterator < CAIterator
821
491
  # Cost: K axis-reductions over an h-shaped local (most cells masked away for
822
492
  # each c) — bounded by K, typically small. A fused per-fiber variance
823
493
  # kernel is a natural follow-on if bench demands it.
824
- def axis_by_masked_copy (axis, op, out_data_type = CA_FLOAT64)
494
+ def axis_by_masked_copy (axis, op, out_data_type = core_reduce_type(op))
825
495
  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
496
+ axis = checked_axis(axis, op)
831
497
  full_c = resolve_axis_codes(@cat.codes, h.shape, axis)
832
498
  band = h.shape.dup; band.delete_at(axis)
833
499
  out = CArray.new(out_data_type, [@k] + band)
834
500
  slot_idx = [nil] + [nil] * band.size # placeholder; c fills slot 0
835
501
  codes_bad = full_c.has_mask? ? full_c.is_masked : nil
836
502
  @k.times do |c|
837
- h_local = h.copy
838
503
  # Boolean of cells that DO belong to group c (with codes present). On
839
504
  # any masked codes cell the codes.eq(c) result carries UNDEF, which
840
505
  # naturally reads as "not in group c" for our exclusion purpose.
841
506
  in_c = full_c.eq(c)
842
507
  exclude = in_c.not
843
508
  exclude = exclude | codes_bad if codes_bad
844
- h_local[exclude] = UNDEF
845
- slice = h_local.__send__(op, axis: axis) # float64, band shape, mask carries n<contract
509
+ h_local = h.mask_where(exclude)
510
+ slice = h_local.__send__(op, axis: axis) # band shape, mask carries n<contract
846
511
  slot_idx[0] = c
847
512
  out[*slot_idx] = slice
848
513
  end
849
514
  out
850
515
  end
851
516
 
517
+ # The axis: reductions for a value the fused kernels cannot read (boolean,
518
+ # object, a Face): each group's cells are reduced by the core's own
519
+ # reduction along the axis and the answers stacked, so the data type, the
520
+ # refusals and the empty-group answer are the core's -- a time column's mean
521
+ # is a time, its sum is refused, an empty group's sum is the identity.
522
+ # Classified along the axis (case A), a group is its positions on the axis,
523
+ # taken by index; otherwise its cells are kept by masking the rest of a copy.
524
+ # A read-only Face's answers (a const string's records index a pool of
525
+ # their own) cannot be put together, so they are collected as the surface
526
+ # values they are, as the flat form does. Over a 1-D value each group
527
+ # reduces to one cell, kept as a 1-element array so the answers stack.
528
+ def axis_by_core (axis, op, *args)
529
+ h = @value
530
+ axis = checked_axis(axis, op)
531
+ flat = (h.ndim == 1)
532
+ groups = (0...[@k, 1].max).map { |c| group_cells(h, axis, c, @k.zero?) }
533
+ answers = groups.map do |g|
534
+ flat ? g.__send__(op, *args, axis: axis, keep_axis: true)
535
+ : g.__send__(op, *args, axis: axis)
536
+ end
537
+ if answers.first.face? && answers.first.read_only?
538
+ answers = answers.map { |a| CArray.object(*a.shape) { a.to_a } }
539
+ end
540
+ out = flat ? CArray.concatenate(answers) : CArray.stack(answers, axis: 0).copy
541
+ @k.zero? ? out[0...0, *([nil] * (out.ndim - 1))].copy : out
542
+ end
543
+
544
+ def axis_moments_by_core (axis, op)
545
+ BAND_ONLY_KEYS.fetch(op, [:count]).to_h do |key|
546
+ [key, axis_by_core(axis, key == :count ? :count_not_masked : key)]
547
+ end
548
+ end
549
+
550
+ def group_cells (h, axis, c, none)
551
+ codes = @cat.codes
552
+ # Classified along the axis, a group is taken by index; the general way,
553
+ # masking the rest of a copy, gives the same cells.
554
+ if codes.shape == [h.shape[axis]] && CArray.__fast_paths_enabled__
555
+ sel = none ? CArray.boolean(codes.elements) { false } : codes.eq(c)
556
+ sel = sel.strip_mask(false) if sel.has_mask?
557
+ idx = [nil] * h.ndim
558
+ idx[axis] = sel.where
559
+ h[*idx]
560
+ else
561
+ full_c = resolve_axis_codes(codes, h.shape, axis)
562
+ exclude = none ? CArray.boolean(*h.shape) { true } : full_c.eq(c).not
563
+ exclude = exclude | full_c.is_masked if full_c.has_mask?
564
+ # a copy with the rest masked, which a read-only Face also gives
565
+ h.mask_where(exclude)
566
+ end
567
+ end
568
+ private :axis_by_core, :axis_moments_by_core, :group_cells
569
+
570
+ # A running statistic along an axis, for a categorical that classifies the
571
+ # positions along it (case A): each cell holds its group's running value up
572
+ # to it along the axis, per fiber. The answer is the core's, as the flat
573
+ # form's is; the kernel runs when it answers in the core's data type, and
574
+ # otherwise each group's positions are scanned by the core along the axis.
575
+ # Read when called, as the other axis: members are.
576
+ def axis_scan (op, axis)
577
+ h = @value
578
+ axis = checked_axis(axis, op)
579
+ classified_along!(op, axis)
580
+ answer = core_scan_answer(op)
581
+ if kernel_scans?(op, answer)
582
+ bundle = @k > 0 ? [@cat.codes, @k, [axis]]
583
+ : [CArray.int64(h.shape[axis]).fill(-1), 1, [axis]]
584
+ return h.__scan_by_axis_groups__([axis], [bundle], op)
585
+ end
586
+ each_group_along(axis, ->(shape) { blank_like(answer, shape) }) do |g|
587
+ g.public_send(op, axis: axis)
588
+ end
589
+ end
590
+
591
+ # #lag along an axis (case A): each group's positions along the axis, every
592
+ # other axis kept, lagged along it, so a lag never reaches another group.
593
+ def axis_lag (n, axis)
594
+ axis = checked_axis(axis, :lag)
595
+ classified_along!(:lag, axis)
596
+ each_group_along(axis, ->(shape) { blank_like(@value, shape) }) do |g|
597
+ g.lag(n, axis: axis)
598
+ end
599
+ end
600
+
601
+ # #strip_mask(method:) along an axis (case A): each group's positions along
602
+ # the axis filled along it, fiber by fiber.
603
+ def axis_hold (method, axis)
604
+ axis = checked_axis(axis, :strip_mask)
605
+ classified_along!(:strip_mask, axis)
606
+ each_group_along(axis, ->(shape) { blank_like(@value, shape) }) do |g|
607
+ g.strip_mask(method: method, axis: axis)
608
+ end
609
+ end
610
+
611
+ # #order along an axis (case A): each group's positions along the axis
612
+ # ranked along it, fiber by fiber.
613
+ def axis_order (axis, **kw)
614
+ axis = checked_axis(axis, :order)
615
+ classified_along!(:order, axis)
616
+ each_group_along(axis, ->(shape) { CArray.int64(*shape) }) do |g|
617
+ g.order(axis: axis, **kw)
618
+ end
619
+ end
620
+
621
+ # #map along an axis (case A): the block receives each group's positions
622
+ # along the axis, every other axis kept, and its answer goes back to them.
623
+ def axis_map (axis, data_type, &block)
624
+ axis = checked_axis(axis, :map)
625
+ classified_along!(:map, axis)
626
+ each_group_along(axis, ->(shape) { blank_output(shape, data_type) }, &block)
627
+ end
628
+
629
+ # Yields each group's positions along +axis+ as a view of the value and
630
+ # writes the block's answer back to them; a position in no group is UNDEF.
631
+ def each_group_along (axis, blank)
632
+ h = @value
633
+ out = blank.call(h.shape)
634
+ out[] = UNDEF
635
+ @k.times do |c|
636
+ sel = @cat.codes.eq(c)
637
+ sel = sel.strip_mask(false) if sel.has_mask?
638
+ next if sel.count(true).zero?
639
+ idx = [nil] * h.ndim
640
+ idx[axis] = sel.where
641
+ out[*idx] = surface_for(out, yield(h[*idx]))
642
+ end
643
+ out
644
+ end
645
+
646
+ # A running value or a transform along an axis follows the positions along
647
+ # it, so the categorical has to classify those positions.
648
+ def classified_along! (op, axis)
649
+ n = @value.shape[axis]
650
+ return if @cat.codes.shape == [n]
651
+ raise ArgumentError,
652
+ "#{op}(axis: #{axis}): needs a categorical that classifies the " \
653
+ "positions along axis #{axis} (shape [#{n}]); got shape " \
654
+ "#{@cat.codes.shape.inspect}"
655
+ end
656
+ private :axis_scan, :axis_lag, :axis_order, :axis_hold, :axis_map, :each_group_along, :classified_along!
657
+
852
658
  # Axis-aware wsum + wmean fused (single kernel call, both outputs). Returns
853
659
  # [wsum_ca, wmean_ca]. Weights must match source shape exactly (explicit
854
660
  # broadcast on the call site for 1-D or band-shape weights). A cell
855
661
  # contributes iff its value AND its weight are present.
856
- def axis_wsum_wmean (weights, axis)
662
+ def axis_wsum_wmean (weights, axis, op)
857
663
  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
664
+ axis = checked_axis(axis, op)
863
665
  unless weights.is_a?(CArray) && weights.shape == h.shape
864
666
  raise ArgumentError,
865
- "group_by_category.wsum/wmean(axis: #{axis}): weights.shape " \
667
+ "#{op}(axis: #{axis}): weights.shape " \
866
668
  "#{weights.respond_to?(:shape) ? weights.shape.inspect : weights.class} " \
867
669
  "must equal source.shape #{h.shape.inspect}. Wrap 1-D / band-shape " \
868
670
  "weights via `.broadcast_to(*source.shape)` before passing."
869
671
  end
672
+ return weighted_by_core(weights, axis) unless kernel_reads?(h.data_type)
870
673
  codes_h_shape = resolve_axis_codes(@cat.codes, h.shape, axis)
871
674
  weights_f64 = weights.data_type == CA_FLOAT64 ? weights : weights.float64
872
675
  band = h.shape.dup; band.delete_at(axis)
@@ -877,15 +680,27 @@ class CACategoricalIterator < CAIterator
877
680
  [ws_out, wm_out]
878
681
  end
879
682
 
683
+ # wsum and wmean of each group by the core: the group's cells of the value
684
+ # and of the weights, each other cell masked, reduced along the axis.
685
+ def weighted_by_core (weights, axis)
686
+ h = @value
687
+ %i[wsum wmean].map do |op|
688
+ answers = (0...[@k, 1].max).map do |c|
689
+ group_cells(h, axis, c, @k.zero?)
690
+ .public_send(op, group_cells(weights, axis, c, @k.zero?), axis: axis)
691
+ end
692
+ out = CArray.stack(answers, axis: 0).copy
693
+ @k.zero? ? out[0...0, *([nil] * (out.ndim - 1))].copy : out
694
+ end
695
+ end
696
+ private :weighted_by_core
697
+
880
698
  # Axis-aware prod: dedicated kernel (identity 1.0, separate from moments to
881
699
  # avoid conflating with sum's zero-identity memset).
882
700
  def axis_prod (axis)
701
+ return axis_by_core(axis, :prod) unless kernel_reads?(@value.data_type)
883
702
  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
703
+ axis = checked_axis(axis, :prod)
889
704
  codes_h_shape = resolve_axis_codes(@cat.codes, h.shape, axis)
890
705
  band = h.shape.dup; band.delete_at(axis)
891
706
  out = CArray.float64(*([@k] + band))
@@ -896,6 +711,12 @@ class CACategoricalIterator < CAIterator
896
711
  # Broadcast `codes` to `h_shape` per PROPOSAL §2.2 3-case positional rule.
897
712
  # Returns a broadcast view of codes at h_shape (or codes itself for case B).
898
713
  # Raises ArgumentError with a message that enumerates all 3 accepted shapes.
714
+ # The axis of an axis: reduction, normalised against the source: a
715
+ # negative axis counts from the end, as everywhere else.
716
+ def checked_axis (axis, op)
717
+ @value.normalize_axis(axis, op.to_s)
718
+ end
719
+
899
720
  def resolve_axis_codes (codes, h_shape, axis)
900
721
  ndim = h_shape.size
901
722
  band = h_shape.dup; band.delete_at(axis)
@@ -932,225 +753,10 @@ class CACategoricalIterator < CAIterator
932
753
  "band-only cat.shape=#{band.inspect}."
933
754
  end
934
755
 
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
756
+ # median / percentile along an axis: the core's own order statistic of each
757
+ # group's cells, stacked -- the axis_by_core path, one core call per group.
758
+ def axis_order_stat (axis, op, *args)
759
+ axis_by_core(axis, op, *args)
1154
760
  end
1155
761
  end
1156
762
 
@@ -1161,8 +767,14 @@ class CArray
1161
767
  # per category of `cat`. Requires `self.elements == cat.elements`.
1162
768
  # @param cat [CACategorical] the classifier.
1163
769
  # @return [CACategoricalIterator]
770
+ # @raise [TypeError] when `cat` is not a CACategorical.
1164
771
  # @raise [ArgumentError] when element counts differ.
1165
772
  def group_by_category (cat)
773
+ unless cat.is_a?(CACategorical)
774
+ raise TypeError,
775
+ "group_by_category: expects a CACategorical (got #{cat.class}); " \
776
+ "classify the values first with .categorize"
777
+ end
1166
778
  CACategoricalIterator.new(self, cat)
1167
779
  end
1168
780
 
@@ -1187,18 +799,16 @@ class CArray
1187
799
  # `grp.labels` is `[0, 1, ...]` in position order.
1188
800
  #
1189
801
  # @return [CACategoricalIterator] one category per run, in order.
1190
- # @raise [RuntimeError] when `self` is not 1-D.
802
+ # @raise [ArgumentError] when `self` is not 1-D.
1191
803
  def group_by_run
1192
- raise "group_by_run: 1-D only (got #{ndim}-D)" unless ndim == 1
804
+ raise ArgumentError, "group_by_run: 1-D only (got #{ndim}-D)" unless ndim == 1
1193
805
  if elements == 0
1194
806
  code = CArray.int64(0)
1195
807
  else
1196
808
  present = is_not_masked
1197
809
  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
810
+ # a boolean cumsum counts in uint64, exactly
811
+ code = edge.cumsum.int64 - 1 # 0-based run index per cell
1202
812
  code[present.not] = UNDEF # masked cells join no run
1203
813
  end
1204
814
  # categorize turns the dense run indices into the run categories: it derives