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
@@ -11,13 +11,38 @@
11
11
  # storage, and +copy+ is the way to an independent frame.
12
12
 
13
13
  class CAFrame
14
+ # Raised when the contents of a column cannot be read as asked: a cell
15
+ # that does not read as the type a +cast+ (or +from_csv+'s +types:+) asks
16
+ # for under on_error: :raise, a cell +parse_to_time+ cannot read, a column
17
+ # whose time format +infer_time_format+ / parse_to_time(name, :infer)
18
+ # cannot find, or a float column +to_time+ cannot read as whole counts.
19
+ # The message names the column and the cell. The arguments were fine; the
20
+ # column's contents are what did not read.
21
+ class UnreadableColumn < StandardError; end
22
+
23
+ # Raised when +infer_time_format+ / parse_to_time(name, :infer) finds more
24
+ # than one format every cell fits ("01/02/2024" and "03/04/2024" are both
25
+ # day-first and month-first). The text reads in each of them, so it is
26
+ # not an UnreadableColumn; +formats+ gives the candidates, to choose one and
27
+ # pass it as the format.
28
+ class AmbiguousTimeFormat < StandardError
29
+ attr_reader :formats
30
+
31
+ def initialize(message = nil, formats: [])
32
+ @formats = formats
33
+ super(message)
34
+ end
35
+ end
36
+
14
37
  # Integer data_types that select rows positionally when used as a df[] key.
15
38
  INTEGER_TYPES = [:int8, :int16, :int32, :int64,
16
39
  :uint8, :uint16, :uint32, :uint64].freeze
40
+ Ractor.make_shareable(INTEGER_TYPES)
17
41
  private_constant :INTEGER_TYPES
18
42
 
19
43
  # @!visibility private
20
44
  DEFAULT_AXIS_NAME = "row"
45
+ Ractor.make_shareable(DEFAULT_AXIS_NAME)
21
46
 
22
47
  # Build a frame from a Hash of +name => column+. Columns may be CArrays or
23
48
  # anything that answers +to_ca+ (Ruby Array, lazy view). All columns must
@@ -28,22 +53,34 @@ class CAFrame
28
53
  # y)+). +:axis_name+ / +:index+ are control options; any remaining
29
54
  # (string-keyed) options are treated as columns.
30
55
  #
31
- # Column names are normalized to Strings, so a Symbol key in an explicit
32
- # column hash is stringified. The Symbol rejection below applies only to the
33
- # keyword channel, which doubles as the +:axis_name+ / +:index+ control-option
34
- # channel: a stray Symbol there is a mistyped control option, not a column.
56
+ # Column names are Strings, in an explicit column hash as in inline pairs.
57
+ # A stray Symbol among the keywords is a mistyped control option
58
+ # (+:axis_name+ / +:index+), not a column, and is named as such.
59
+ #
60
+ # +types:+ casts the built frame as +from_records+ does: it takes anything
61
+ # +cast+ takes as a map (a name => type map, :infer, or a map with
62
+ # +default:+), with +on_error:+ as for +cast+. A cast column is a new array,
63
+ # so a CArray given for it is no longer shared with the frame.
35
64
  def initialize(columns = {}, **opts)
36
65
  axis_name = opts.delete(:axis_name)
37
66
  index = opts.delete(:index)
38
- stray = opts.keys.reject { |k| k.is_a?(String) }
39
- unless stray.empty?
40
- raise ArgumentError,
41
- "column keys must be Strings (Symbols are reserved); got #{stray.inspect}"
67
+ attrs = opts.delete(:attrs)
68
+ types = opts.delete(:types)
69
+ on_error = opts.key?(:on_error) ? opts.delete(:on_error) : :mask
70
+ opts.each_key do |k|
71
+ CAFrame.__send__(:column_key, k, "CAFrame.new",
72
+ hint: "axis_name:, index:, attrs:, types: and on_error: are the options")
73
+ end
74
+ @attrs = nil
75
+ unless attrs.nil?
76
+ raise TypeError, "CAFrame.new: attrs: takes a Hash (got #{attrs.class})" unless attrs.is_a?(Hash)
77
+ set_attrs(attrs)
42
78
  end
43
79
  columns = columns.merge(opts) unless opts.empty?
44
80
 
45
81
  @columns = {}
46
- @axis_name = axis_name || DEFAULT_AXIS_NAME
82
+ @axis_name = axis_name.nil? ? DEFAULT_AXIS_NAME :
83
+ CAFrame.__send__(:column_key, axis_name, "CAFrame.new", "axis_name:")
47
84
  @index = nil
48
85
  # The row axis name this frame had before set_index promoted a column over
49
86
  # it, so reset_index can put it back. A frame built with an index never
@@ -54,7 +91,8 @@ class CAFrame
54
91
  n = nil
55
92
  n_from = nil
56
93
  columns.each do |name, col|
57
- key = name.to_s
94
+ key = column_key(name, "CAFrame.new")
95
+ raise ArgumentError, "CAFrame.new: a column name can not be empty" if key.empty?
58
96
  ca = coerce_column(col)
59
97
  len = ca.shape[0]
60
98
  if n.nil?
@@ -92,6 +130,8 @@ class CAFrame
92
130
  raise ArgumentError,
93
131
  "axis_name #{@axis_name.inspect} collides with a column of the same name"
94
132
  end
133
+
134
+ cast(types, on_error: on_error, option_name: :types) unless types.nil?
95
135
  end
96
136
 
97
137
  # --- df[...] : key type decides the axis (memo §13.2) ------------------
@@ -112,7 +152,7 @@ class CAFrame
112
152
  if keys.size > 1
113
153
  unless keys.all? { |k| k.is_a?(String) }
114
154
  raise ArgumentError,
115
- "multi-key df[...] escapes columns; every key must be a String " \
155
+ "df[...] with several keys returns columns; every key must be a String " \
116
156
  "(use df.select(...) for a subset frame)"
117
157
  end
118
158
  return keys.map { |k| @columns.fetch(k) { raise KeyError, "no column #{k.inspect}" } }
@@ -142,7 +182,7 @@ class CAFrame
142
182
  "CArray df[] key must be boolean or integer (got #{key.data_type})"
143
183
  end
144
184
  when Symbol
145
- raise NotImplementedError, "df[symbol] is reserved for predicate keys"
185
+ raise ArgumentError, "df[symbol] is reserved for predicate keys"
146
186
  else
147
187
  raise ArgumentError, "unsupported df[] key: #{key.class}"
148
188
  end
@@ -197,6 +237,7 @@ class CAFrame
197
237
  # write path always propagates to derived views (unchanged from
198
238
  # previous behaviour).
199
239
  def []=(*keys, value)
240
+ refuse_if_frozen
200
241
  if keys.size != 1
201
242
  raise ArgumentError,
202
243
  "df[...] = takes one key (a column name or a row selector); " \
@@ -222,6 +263,56 @@ class CAFrame
222
263
  value
223
264
  end
224
265
 
266
+ # Freezing a frame fixes its columns, rows and index; the columns' cells are
267
+ # their own arrays' to guard, as an Array's elements are. The check comes
268
+ # first so a refused edit leaves the frame as it was: the edits change the
269
+ # column table before any instance variable.
270
+ private def refuse_if_frozen
271
+ return unless frozen?
272
+ # The size, not the table: inspect prints every row.
273
+ size = "#{@nrow} row#{@nrow == 1 ? "" : "s"}, #{ncol} column#{ncol == 1 ? "" : "s"}"
274
+ raise FrozenError.new("can't modify frozen CAFrame (#{size})",
275
+ receiver: self)
276
+ end
277
+
278
+ # Make this frame read-only and return it. It is +freeze+ extended to the
279
+ # cells: the column set, the rows and the index are fixed as by +freeze+,
280
+ # and the cells and masks of every column and of the index refuse writes
281
+ # made through this frame.
282
+ #
283
+ # Each column (and the index) is replaced by a read-only view of itself, so
284
+ # the arrays the frame was built from are left as they were: whoever holds
285
+ # them can still write, and the writes show through this frame. Frames
286
+ # derived from a protected frame by views (+select+, +filter+, a row slice)
287
+ # read through those views and refuse cell writes too, but are not frozen.
288
+ # +copy+ gives a frame that can be written again.
289
+ #
290
+ # Calling it on a protected frame returns the frame unchanged. A frame
291
+ # frozen with +freeze+ cannot be protected afterwards, because its columns
292
+ # can no longer be replaced: raises +FrozenError+.
293
+ def protect
294
+ return self if protected?
295
+ refuse_if_frozen
296
+ @columns = @columns.transform_values { |col| read_only_view(col) }
297
+ @index = read_only_view(@index) if @index
298
+ @protected = true
299
+ freeze
300
+ end
301
+
302
+ # True if this frame was made read-only by +protect+. A +dup+ is not frozen
303
+ # and so not protected, though its columns stay the read-only views it
304
+ # shares; a +clone+ keeps both.
305
+ def protected?
306
+ frozen? && @protected == true
307
+ end
308
+
309
+ private def read_only_view(col)
310
+ return col if col.read_only?
311
+ view = col.refer
312
+ view.set_read_only_flag
313
+ view
314
+ end
315
+
225
316
  # df["name"] = ... : the column forms of []=. Unlike the verbs, +[]=+ can
226
317
  # only ever mutate the receiver -- Ruby hands the right-hand side back as
227
318
  # the value of an assignment, so there is no way to return a new frame.
@@ -238,7 +329,7 @@ class CAFrame
238
329
  mask_column(key)
239
330
  elsif value.is_a?(CAFrame)
240
331
  raise ArgumentError,
241
- "df[name] = takes a column, not a CAFrame; escape one with " \
332
+ "df[name] = takes a column, not a CAFrame; take one with " \
242
333
  "other[\"name\"], or splice rows with df[rows] = other"
243
334
  else
244
335
  rebind_column(key, value)
@@ -255,6 +346,7 @@ class CAFrame
255
346
  # leaves the old one alone (the same rule +cast+ follows, for the physical
256
347
  # reason that a type change cannot reuse the buffer).
257
348
  private def rebind_column(key, column)
349
+ raise ArgumentError, "df[name] =: a column name can not be empty" if key.empty?
258
350
  if @index && key == @axis_name
259
351
  raise ArgumentError,
260
352
  "#{key.inspect} names the index, not a column; " \
@@ -299,8 +391,7 @@ class CAFrame
299
391
  def select(*names)
300
392
  raise ArgumentError, "select requires at least one column name" if names.empty?
301
393
  cols = {}
302
- names.each do |name|
303
- key = name.to_s
394
+ column_keys(names, "select").each do |key|
304
395
  raise KeyError, "no column #{key.inspect}" unless @columns.key?(key)
305
396
  cols[key] = @columns[key]
306
397
  end
@@ -329,20 +420,78 @@ class CAFrame
329
420
  mask = yield(self)
330
421
  unless mask.is_a?(CArray) && mask.data_type == :boolean
331
422
  raise ArgumentError,
332
- "filter block must return a boolean CArray (got #{mask.class})"
423
+ "filter block must return a boolean CArray (got #{mask.is_a?(CArray) ? "a #{CArray::Inspector.type_label(mask)} CArray" : mask.class})"
333
424
  end
334
- unless mask.shape[0] == @nrow
425
+ unless mask.shape == [@nrow]
335
426
  raise ArgumentError,
336
- "filter mask has axis-0 length #{mask.shape[0]}, expected nrow #{@nrow}"
427
+ "filter mask has shape #{mask.shape.inspect}, expected [#{@nrow}] (one value per row)"
337
428
  end
338
429
  select_rows(mask, keep_masked: keep_masked)
339
430
  end
340
431
 
432
+ # Judge every row against named rules, and return the verdicts as a frame:
433
+ #
434
+ # report = df.verify(
435
+ # "age is not negative" => ->(f) { f["age"] >= 0 },
436
+ # "end after start" => ->(f) { f["end"] > f["start"] },
437
+ # )
438
+ # df.filter { |f| report["end after start"].not } # the rows that break it
439
+ #
440
+ # A rule is called with the frame, as a +filter+ block is, and returns a
441
+ # boolean CArray with one entry per row on its first axis; true means the
442
+ # rule holds. A rule over an N-D column may answer per cell, in the
443
+ # column's own shape, so the cells that break it can be masked directly.
444
+ # A cell the rule could not judge -- it read a masked value -- stays
445
+ # UNDEF rather than becoming false.
446
+ #
447
+ # The result has one column per rule, named after it, and this frame's
448
+ # index and row axis name.
449
+ #
450
+ # @param rules [Hash{String => #call}] the rules by name.
451
+ # @return [CAFrame]
452
+ # @raise [ArgumentError] when there are no rules, a name is empty, or a
453
+ # rule does not return a boolean CArray of this frame's length.
454
+ # @raise [TypeError] when a name is not a String.
455
+ def verify(rules)
456
+ unless rules.is_a?(Hash)
457
+ raise ArgumentError, "verify: the rules are a Hash of name => rule (got #{rules.class})"
458
+ end
459
+ raise ArgumentError, "verify: there are no rules" if rules.empty?
460
+ verdicts = {}
461
+ rules.each do |name, rule|
462
+ key = column_name_arg(name, "verify")
463
+ if @index && key == @axis_name
464
+ raise ArgumentError,
465
+ "verify: rule #{key.inspect} has the name of the row axis, " \
466
+ "which the index has in the result; name the rule otherwise"
467
+ end
468
+ unless rule.respond_to?(:call)
469
+ raise ArgumentError, "verify: rule #{key.inspect} is not callable (got #{rule.class})"
470
+ end
471
+ verdict = rule.call(self)
472
+ unless verdict.is_a?(CArray) && verdict.data_type == :boolean
473
+ raise ArgumentError,
474
+ "verify: rule #{key.inspect} must return a boolean CArray (got " \
475
+ "#{verdict.is_a?(CArray) ? "a #{verdict.data_type} CArray" : verdict.class})"
476
+ end
477
+ unless verdict.shape[0] == @nrow
478
+ raise ArgumentError,
479
+ "verify: rule #{key.inspect} returned shape #{verdict.shape.inspect}, " \
480
+ "expected #{@nrow} rows on the first axis"
481
+ end
482
+ verdicts[key] = verdict
483
+ end
484
+ # The verdicts are a table about the rows, not a part of this one, so the
485
+ # frame's attributes are not carried.
486
+ CAFrame.new(verdicts, axis_name: @axis_name, index: @index)
487
+ end
488
+
341
489
  # Move a column to the index and name the row axis after it (memo §3).
342
490
  # An index-role change: the data is unchanged, so this mutates self and
343
491
  # returns it (memo §3.8). Any existing index is replaced.
344
492
  def set_index(name)
345
- key = name.to_s
493
+ refuse_if_frozen
494
+ key = column_key(name, "set_index")
346
495
  raise KeyError, "no column #{key.inspect}" unless @columns.key?(key)
347
496
  idx = @columns[key]
348
497
  unless idx.ndim == 1
@@ -370,6 +519,7 @@ class CAFrame
370
519
  # Also an index-role change, so it mutates self (memo §3.8). The former
371
520
  # index becomes the first column, named after the row axis.
372
521
  def reset_index
522
+ refuse_if_frozen
373
523
  return self unless @index
374
524
  @columns = { @axis_name => @index }.merge(@columns)
375
525
  @index = nil
@@ -394,7 +544,11 @@ class CAFrame
394
544
 
395
545
  # The single row whose index label equals +label+, as a Ruby Hash (memo
396
546
  # §13.2b). +label+ is matched exactly against the index (+index.eq+), so any
397
- # orderable / object / datetime / categorical index works. The return type is
547
+ # orderable / object / datetime / categorical index works, and +label+ is
548
+ # whatever +index.eq+ takes -- for a time index, a Time, Date or a String
549
+ # such as "2024-01-02". A NaN label finds the NaN label, as join and
550
+ # group_by match NaN keys. A numeric index takes a number; a String or
551
+ # true / false raises. The return type is
398
552
  # a row Hash and stays that way: zero matches raise KeyError, and duplicate
399
553
  # labels raise (go through +filter { |f| f.index.eq(label) }+ for the
400
554
  # multi-row, frame-returning path). Positional access is +df[i]+.
@@ -411,16 +565,29 @@ class CAFrame
411
565
  "at(UNDEF): an undefined label identifies no row; " \
412
566
  "use filter { |f| f.index.is_masked } for the rows with no label"
413
567
  end
414
- pos = @index.eq(label).where
568
+ # A NaN label is the NaN value, as join and group_by take it: eq answers
569
+ # false for NaN against anything, itself included.
570
+ # A numeric index is looked up with a number: a String or true/false would
571
+ # be read differently by each numeric type, or not at all.
572
+ if !@index.face? && (@index.integer? || @index.float? || @index.complex?) &&
573
+ !label.is_a?(Numeric)
574
+ raise ArgumentError,
575
+ "at: the index is #{@index.data_type}, so the label must be a number " \
576
+ "(got #{label.inspect}); convert it first, e.g. at(Float(s)) or at(Integer(s))"
577
+ end
578
+ nan = label.is_a?(Float) && label.nan?
579
+ hit = nan ? @index.is_in([label]) : @index.eq(label)
580
+ pos = hit.where
415
581
  case pos.elements
416
582
  when 0
417
583
  raise KeyError, "no row with index label #{label.inspect}"
418
584
  when 1
419
585
  row(pos[0])
420
586
  else
587
+ test = nan ? "f.index.is_nan" : "f.index.eq(label)"
421
588
  raise ArgumentError,
422
589
  "index label #{label.inspect} matches #{pos.elements} rows; " \
423
- "use filter { |f| f.index.eq(label) } for duplicate labels"
590
+ "use filter { |f| #{test} } for duplicate labels"
424
591
  end
425
592
  end
426
593
 
@@ -428,7 +595,8 @@ class CAFrame
428
595
  def copy
429
596
  cols = {}
430
597
  @columns.each { |k, v| cols[k] = v.copy }
431
- CAFrame.new(cols, axis_name: @axis_name, index: @index && @index.copy)
598
+ CAFrame.new(cols, axis_name: @axis_name, index: @index && @index.copy,
599
+ attrs: @attrs)
432
600
  end
433
601
 
434
602
  # +dup+ / +clone+ share the column data -- that is the CArray dup contract
@@ -439,25 +607,103 @@ class CAFrame
439
607
  private def initialize_copy(other)
440
608
  super
441
609
  @columns = @columns.dup
610
+ @attrs = @attrs.dup if @attrs
611
+ end
612
+
613
+ # Marshal writes the instance variables as they are. Loading stores the
614
+ # attribute values again as set_attr does, as frozen copies: Marshal
615
+ # brings back a value thawed.
616
+ private def marshal_dump
617
+ instance_variables.to_h { |name| [name, instance_variable_get(name)] }
618
+ end
619
+
620
+ private def marshal_load(ivars)
621
+ ivars.each { |name, value| instance_variable_set(name, value) }
622
+ if @attrs
623
+ @attrs = @attrs.transform_values { |v| CArray.__send__(:attr_coerce_value, v) }
624
+ end
625
+ end
626
+
627
+ # --- attributes of the frame ------------------------------------------
628
+ #
629
+ # Key/value metadata about the table as a whole (a title, a source), apart
630
+ # from each column's own attributes. Keys and values follow the rules of
631
+ # CArray#set_attr: a Symbol or String key, stored as a String, and a
632
+ # JSON-compatible value, stored as a frozen copy.
633
+ #
634
+ # A frame made from this one takes its attributes when it holds the same
635
+ # table: a selection of rows (df[rows], filter, head, tail, sort_by,
636
+ # join(how: :semi / :anti)), a change of columns (select, append, drop,
637
+ # rename, ...), copy and dup. A frame computed from it -- a summary, a
638
+ # join, a reshape -- does not; ask the frame it came from. Each frame has
639
+ # its own Hash: setting an attribute on a frame made from this one leaves
640
+ # this one unchanged.
641
+
642
+ # The value of the attribute +key+, or nil when the frame has none.
643
+ def attr(key)
644
+ @attrs ? @attrs[CArray.__send__(:attr_normalize_key, key)] : nil
645
+ end
646
+
647
+ # Set the attribute +key+ to +value+ and return the stored value.
648
+ def set_attr(key, value)
649
+ refuse_if_frozen
650
+ CArray.__send__(:attr_validate_value, value)
651
+ (@attrs ||= {})[CArray.__send__(:attr_normalize_key, key)] =
652
+ CArray.__send__(:attr_coerce_value, value)
653
+ end
654
+
655
+ # Set every key of +hash+, as set_attr sets one, and return self. Every
656
+ # value is checked before any is written.
657
+ def set_attrs(hash)
658
+ refuse_if_frozen
659
+ raise TypeError, "set_attrs takes a Hash (got #{hash.class})" unless hash.is_a?(Hash)
660
+ entries = hash.map do |key, value|
661
+ CArray.__send__(:attr_validate_value, value)
662
+ [CArray.__send__(:attr_normalize_key, key), CArray.__send__(:attr_coerce_value, value)]
663
+ end
664
+ unless entries.empty?
665
+ h = (@attrs ||= {})
666
+ entries.each { |k, v| h[k] = v }
667
+ end
668
+ self
669
+ end
670
+
671
+ # Remove the attribute +key+ and return its value, or nil.
672
+ def delete_attr(key)
673
+ k = CArray.__send__(:attr_normalize_key, key)
674
+ return nil unless @attrs&.key?(k)
675
+ refuse_if_frozen
676
+ @attrs.delete(k)
677
+ end
678
+
679
+ # A frozen Hash of the frame's attributes.
680
+ def attrs
681
+ (@attrs ? @attrs.dup : {}).freeze
682
+ end
683
+
684
+ # Whether the frame has any attribute, or the attribute +key+.
685
+ def has_attr?(key = nil)
686
+ return !(@attrs.nil? || @attrs.empty?) if key.nil?
687
+ !!@attrs&.key?(CArray.__send__(:attr_normalize_key, key))
442
688
  end
443
689
 
444
690
  # --- metadata readers (memo §13.2) ------------------------------------
445
691
  # Each returns a fresh object; the live columns Hash is never exposed.
446
692
 
447
- # Variable (column) names in column order. Returns +Array<String>+.
448
- def variable_names
693
+ # Column names in column order. Returns +Array<String>+.
694
+ def column_names
449
695
  @columns.keys
450
696
  end
451
697
 
452
- # Variables (raw column CArrays) in column order. Returns
453
- # +Array<CArray>+. Equivalent to +df[*variable_names]+ but built
454
- # directly from the columns Hash.
455
- def variables
698
+ # The columns (raw CArrays) in column order. Returns +Array<CArray>+.
699
+ # Equivalent to +df[*column_names]+ but built directly from the columns
700
+ # Hash.
701
+ def columns
456
702
  @columns.values
457
703
  end
458
704
 
459
- # Number of variables (columns).
460
- def nvar
705
+ # Number of columns.
706
+ def ncol
461
707
  @columns.size
462
708
  end
463
709
 
@@ -470,22 +716,32 @@ class CAFrame
470
716
  # Index column (CArray) or nil.
471
717
  attr_reader :index
472
718
 
473
- # name => data_type, freshly derived.
719
+ # name => data_type, freshly derived; a Face column gives its class
720
+ # (CATime, CAString, ...), since a Face's data_type is the surface its
721
+ # kernels dispatch on, not what the column holds.
474
722
  def data_types
475
723
  h = {}
476
- @columns.each { |k, v| h[k] = v.data_type }
724
+ @columns.each { |k, v| h[k] = v.face? ? v.class : v.data_type } # type-label: data_types returns the class itself, a value
477
725
  h
478
726
  end
479
727
 
728
+ # A frame of the same table with a different set of columns: it takes this
729
+ # frame's attributes.
480
730
  private def rebuild(cols)
481
- CAFrame.new(cols, axis_name: @axis_name, index: @index)
731
+ CAFrame.new(cols, axis_name: @axis_name, index: @index, attrs: @attrs)
482
732
  end
483
733
 
734
+ # A column given to the frame: a CArray as it is; a Ruby Array typed by its
735
+ # values as from_records types a record's (build_record_column); anything
736
+ # else through its to_ca.
484
737
  private def coerce_column(col)
738
+ if col.is_a?(Array)
739
+ return CAFrame.__send__(:build_record_column, col, col.size)
740
+ end
485
741
  if col.is_a?(CAFrame)
486
742
  raise ArgumentError,
487
743
  "a CAFrame cannot be a column (it answers to_ca as a 2-D matrix); " \
488
- "escape a column with df[\"name\"] or df.to_ca first"
744
+ "take a column with df[\"name\"] or df.to_ca first"
489
745
  end
490
746
  unless col.respond_to?(:to_ca)
491
747
  raise ArgumentError, "column must be a CArray or Array (got #{col.class})"
@@ -498,10 +754,11 @@ class CAFrame
498
754
  end
499
755
 
500
756
  private def row(i)
501
- i += @nrow if i < 0
502
- unless i >= 0 && i < @nrow
757
+ pos = i < 0 ? i + @nrow : i
758
+ unless pos >= 0 && pos < @nrow
503
759
  raise IndexError, "row index #{i} out of range (nrow=#{@nrow})"
504
760
  end
761
+ i = pos
505
762
  h = {}
506
763
  h[@axis_name] = elem_at(@index, i) if @index
507
764
  @columns.each { |name, col| h[name] = elem_at(col, i) }
@@ -562,10 +819,10 @@ class CAFrame
562
819
  # promote and the row count shifts by other.nrow - span.
563
820
  private def splice_rows(selector, other)
564
821
  lo, hi = contiguous_span(selector)
565
- unless other.variable_names.sort == variable_names.sort
822
+ unless other.column_names.sort == column_names.sort
566
823
  raise ArgumentError,
567
- "splice frame has columns #{other.variable_names.inspect}, " \
568
- "expected the same set as #{variable_names.inspect}"
824
+ "splice frame has columns #{other.column_names.inspect}, " \
825
+ "expected the same set as #{column_names.inspect}"
569
826
  end
570
827
  new_cols = {}
571
828
  @columns.each do |name, col|
@@ -590,8 +847,10 @@ class CAFrame
590
847
  # column beforehand — silent
591
848
  # promotion in a splice would
592
849
  # hide schema drift.
850
+ splice_keep_attrs(new_cols[name], col)
593
851
  end
594
852
  new_index = splice_index(other, lo, hi)
853
+ splice_keep_attrs(new_index, @index) if new_index
595
854
  @columns = new_cols
596
855
  @index = new_index
597
856
  # Read the new count off something the frame now holds, rather than
@@ -607,6 +866,13 @@ class CAFrame
607
866
  self
608
867
  end
609
868
 
869
+ # A splice edits this frame's table, so a spliced column keeps the
870
+ # attributes this frame's column had, whatever the rows put in carry.
871
+ private def splice_keep_attrs(result, own)
872
+ (result.attrs.keys - own.attrs.keys).each { |k| result.delete_attr(k) }
873
+ result.set_attrs(own.attrs)
874
+ end
875
+
610
876
  # The index for a splice: head + other's index + tail. When this frame has
611
877
  # an index, the spliced-in frame must have one too (its rows need labels).
612
878
  private def splice_index(other, lo, hi)
@@ -684,18 +950,27 @@ class CAFrame
684
950
  cols[name] = col[selector, *([nil] * (col.ndim - 1))]
685
951
  end
686
952
  new_index = @index ? @index[selector] : nil
687
- CAFrame.new(cols, axis_name: @axis_name, index: new_index)
953
+ CAFrame.new(cols, axis_name: @axis_name, index: new_index, attrs: @attrs)
688
954
  end
689
955
 
690
956
  private def select_rows_keep_masked(selector)
691
957
  keep = selector.strip_mask(1) # true where selected OR undetermined
692
958
  undet_kept = selector.is_masked[keep] # among kept rows, which were undetermined
693
959
  cols = {}
960
+ any_undet = undet_kept.any
694
961
  @columns.each do |name, col|
695
962
  tail = [nil] * (col.ndim - 1)
696
- g = col[keep, *tail].copy
697
- g[undet_kept, *tail] = UNDEF
698
- cols[name] = g
963
+ g = col[keep, *tail]
964
+ if any_undet
965
+ # mask_where copies and masks in one step, so a read-only Face
966
+ # (categorical, CAConstString) takes the mask as any column does.
967
+ cells = CArray.boolean(*g.shape)
968
+ cells[] = false
969
+ cells[undet_kept, *tail] = true
970
+ cols[name] = g.mask_where(cells)
971
+ else
972
+ cols[name] = g.copy
973
+ end
699
974
  end
700
975
  # The index is copied along with the columns. Its values are carried over
701
976
  # unmasked (an undetermined row keeps its label, as mask_rows leaves the
@@ -703,8 +978,57 @@ class CAFrame
703
978
  # aliased index would write through to the parent from a frame whose columns
704
979
  # do not.
705
980
  new_index = @index ? @index[keep].copy : nil
706
- CAFrame.new(cols, axis_name: @axis_name, index: new_index)
981
+ CAFrame.new(cols, axis_name: @axis_name, index: new_index, attrs: @attrs)
982
+ end
983
+
984
+ # A column name given as an argument: a String, as the frame's column
985
+ # keys are. Symbols are the frame's options and types, never names.
986
+ private def column_key(name, verb)
987
+ CAFrame.__send__(:column_key, name, verb)
988
+ end
989
+
990
+ # A name a verb is given for a column -- or, with +what+, for the row
991
+ # axis -- is a String: a Symbol is a frame's option or type, never a
992
+ # name. Anything else is refused with TypeError "<verb>: <what> is a
993
+ # String (got :x)", +hint+ appended. A +verb+ that is an option, as
994
+ # "types:", already ends in its colon.
995
+ def self.column_key(name, verb, what = "a column name", hint: nil)
996
+ return name if name.is_a?(String)
997
+ message = "#{verb.end_with?(":") ? verb : "#{verb}:"} #{what} is a String (got #{name.inspect})"
998
+ message += "; #{hint}" if hint
999
+ raise TypeError, message
1000
+ end
1001
+ private_class_method :column_key
1002
+
1003
+ # A list of column names as a verb takes them: each a String, and none
1004
+ # twice.
1005
+ private def column_keys(names, verb)
1006
+ CAFrame.__send__(:refuse_repeated_names, names.map { |n| column_key(n, verb) }, verb)
1007
+ end
1008
+
1009
+ # Column names a verb makes or takes, none of them twice. A frame holds
1010
+ # one column per name, so a repeated name among new columns would keep
1011
+ # only the last of them, and among the columns a verb takes would be
1012
+ # taken once or twice depending on the verb. Refused with ArgumentError
1013
+ # "<verb>: <what> <name> more than once", +hint+ appended. df[...] alone
1014
+ # takes a name twice, as Hash#values_at does.
1015
+ def self.refuse_repeated_names(names, verb, what = "names", hint: nil)
1016
+ dup = names.tally.find { |_, count| count > 1 }
1017
+ return names unless dup
1018
+ message = "#{verb}: #{what} #{dup.first.inspect} more than once"
1019
+ message += "; #{hint}" if hint
1020
+ raise ArgumentError, message
1021
+ end
1022
+ private_class_method :refuse_repeated_names
1023
+
1024
+ # The labels an index is made of from a key -- a group's first values,
1025
+ # the resample bins, the sorted pivot keys -- as the key column shows
1026
+ # them: +labels+, already of the key's data type, Face and unit, given
1027
+ # the key's attributes. Returns +labels+.
1028
+ def self.key_labels(key, labels)
1029
+ key.__send__(:carry_attrs, labels)
707
1030
  end
1031
+ private_class_method :key_labels
708
1032
 
709
1033
  private def positional_range?(range)
710
1034
  (range.begin.nil? || range.begin.is_a?(Integer)) &&