carray 3.0.1 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (229) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +824 -0
  3. data/README.md +5 -6
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_dispatch.c +258 -4
  6. data/ext/ca_axis_group.c +368 -184
  7. data/ext/ca_binop_dispatch.c +54 -32
  8. data/ext/ca_binop_dispatch.h +1 -0
  9. data/ext/ca_categorical_iterator.c +90 -41
  10. data/ext/ca_compare.c +8 -2
  11. data/ext/ca_for_buffer.h +21 -68
  12. data/ext/ca_group_iter.c +27 -11
  13. data/ext/ca_kernel_iterator.c +960 -245
  14. data/ext/ca_kernel_iterator.h +352 -181
  15. data/ext/ca_monop_dispatch.c +4 -4
  16. data/ext/ca_monop_dispatch.h +4 -0
  17. data/ext/ca_obj_array.c +194 -42
  18. data/ext/ca_obj_bincmp.c +152 -121
  19. data/ext/ca_obj_binop.c +107 -210
  20. data/ext/ca_obj_bitarray.c +37 -24
  21. data/ext/ca_obj_bitfield.c +95 -62
  22. data/ext/ca_obj_block.c +5 -4
  23. data/ext/ca_obj_byte_swap.c +40 -40
  24. data/ext/ca_obj_const_string.c +180 -50
  25. data/ext/ca_obj_face.c +428 -62
  26. data/ext/ca_obj_face.h +107 -47
  27. data/ext/ca_obj_fake.c +21 -50
  28. data/ext/ca_obj_farray.c +1 -0
  29. data/ext/ca_obj_field.c +16 -3
  30. data/ext/ca_obj_fixlen_string.c +39 -26
  31. data/ext/ca_obj_grid.c +181 -124
  32. data/ext/ca_obj_meld.c +235 -55
  33. data/ext/ca_obj_moncmp.c +61 -61
  34. data/ext/ca_obj_monop.c +125 -107
  35. data/ext/ca_obj_object.c +551 -233
  36. data/ext/ca_obj_record.c +51 -16
  37. data/ext/ca_obj_reduce.c +5 -8
  38. data/ext/ca_obj_refer.c +117 -33
  39. data/ext/ca_obj_remap.c +57 -17
  40. data/ext/ca_obj_repeat.c +21 -5
  41. data/ext/ca_obj_roll.c +115 -11
  42. data/ext/ca_obj_select.c +88 -75
  43. data/ext/ca_obj_select_axis.c +58 -60
  44. data/ext/ca_obj_shift.c +22 -102
  45. data/ext/ca_obj_stack.c +223 -123
  46. data/ext/ca_obj_stride.c +267 -35
  47. data/ext/ca_obj_string.c +25 -22
  48. data/ext/ca_obj_tile.c +32 -16
  49. data/ext/ca_obj_time.c +23 -25
  50. data/ext/ca_obj_timedelta.c +21 -23
  51. data/ext/ca_obj_transpose.c +12 -3
  52. data/ext/ca_obj_triop.c +110 -117
  53. data/ext/ca_obj_window.c +157 -29
  54. data/ext/ca_op_ipower.c +25 -13
  55. data/ext/ca_op_powi.h +57 -30
  56. data/ext/ca_pow5_table.h +656 -0
  57. data/ext/ca_rng_normal.h +42 -0
  58. data/ext/ca_rng_xoshiro256pp.h +105 -0
  59. data/ext/ca_sort_kernels.h +70 -0
  60. data/ext/ca_sweep_engine.c +385 -198
  61. data/ext/ca_sweep_engine.h +31 -23
  62. data/ext/ca_triop_dispatch.c +4 -1
  63. data/ext/ca_triop_dispatch.h +5 -2
  64. data/ext/caframe_csv_reader.c +463 -0
  65. data/ext/carray.h +90 -28
  66. data/ext/carray_access.c +487 -231
  67. data/ext/carray_address_basis.c +731 -0
  68. data/ext/carray_attribute.c +45 -3
  69. data/ext/carray_bincount.c +32 -2
  70. data/ext/carray_broadcast.c +49 -17
  71. data/ext/carray_call_cfunc.c +667 -511
  72. data/ext/carray_cast.c +1556 -247
  73. data/ext/carray_cast_func.rb +34 -21
  74. data/ext/carray_conversion.c +197 -73
  75. data/ext/carray_copy.c +84 -38
  76. data/ext/carray_core.c +591 -119
  77. data/ext/carray_count.c +148 -96
  78. data/ext/carray_element.c +18 -27
  79. data/ext/carray_factorize.c +604 -291
  80. data/ext/carray_generate.c +76 -40
  81. data/ext/carray_histogram.c +152 -94
  82. data/ext/carray_hold.c +2 -1
  83. data/ext/carray_index_classifier.c +63 -96
  84. data/ext/carray_internal.h +400 -1
  85. data/ext/carray_lazy.c +834 -79
  86. data/ext/carray_loop.c +88 -66
  87. data/ext/carray_mask.c +705 -324
  88. data/ext/carray_math_kernel.h +1 -0
  89. data/ext/carray_median_percentile.c +263 -63
  90. data/ext/carray_memory_view.c +254 -248
  91. data/ext/carray_operator.c +1093 -363
  92. data/ext/carray_order.c +133 -69
  93. data/ext/carray_partition.c +102 -56
  94. data/ext/carray_random.c +582 -204
  95. data/ext/carray_scatter.c +308 -65
  96. data/ext/carray_segment.c +257 -0
  97. data/ext/carray_slab.c +183 -70
  98. data/ext/carray_sort.c +235 -240
  99. data/ext/carray_sort_kernel.c +8 -1
  100. data/ext/carray_struct.c +99 -21
  101. data/ext/carray_test.c +167 -64
  102. data/ext/carray_time_text.c +1182 -0
  103. data/ext/carray_utils.c +363 -98
  104. data/ext/extconf.rb +27 -2
  105. data/ext/mk_call_cfunc.rb +103 -118
  106. data/ext/mk_pow5_table.rb +35 -0
  107. data/ext/mkkernel.rb +1349 -851
  108. data/ext/ruby_carray.c +17 -2
  109. data/ext/version.h +4 -4
  110. data/ext/version.rb +4 -2
  111. data/lib/carray/attribute.rb +39 -0
  112. data/lib/carray/autoload_carray.rb +24 -3
  113. data/lib/carray/autoload_method_extension.rb +18 -0
  114. data/lib/carray/axis_group.rb +245 -20
  115. data/lib/carray/basics.rb +95 -25
  116. data/lib/carray/bincount_nd.rb +6 -0
  117. data/lib/carray/block_iterator.rb +200 -54
  118. data/lib/carray/boolean_reduce.rb +21 -29
  119. data/lib/carray/categorical.rb +244 -59
  120. data/lib/carray/categorical_iterator.rb +391 -757
  121. data/lib/carray/complex.rb +36 -29
  122. data/lib/carray/conditional.rb +94 -7
  123. data/lib/carray/const_string.rb +177 -27
  124. data/lib/carray/construct.rb +40 -0
  125. data/lib/carray/data_type_extension.rb +3 -0
  126. data/lib/carray/data_type_limits.rb +91 -0
  127. data/lib/carray/fixlen_string.rb +1 -1
  128. data/lib/carray/frame/concat.rb +157 -6
  129. data/lib/carray/frame/convert.rb +13 -7
  130. data/lib/carray/frame/csv_parser.rb +590 -76
  131. data/lib/carray/frame/describe.rb +79 -0
  132. data/lib/carray/frame/frame.rb +205 -35
  133. data/lib/carray/frame/group.rb +199 -40
  134. data/lib/carray/frame/io.rb +452 -60
  135. data/lib/carray/frame/join.rb +134 -46
  136. data/lib/carray/frame/records.rb +54 -19
  137. data/lib/carray/frame/reshape.rb +448 -0
  138. data/lib/carray/frame/sort.rb +26 -2
  139. data/lib/carray/frame/verbs.rb +726 -56
  140. data/lib/carray/frame.rb +2 -0
  141. data/lib/carray/fuse_source.rb +111 -2
  142. data/lib/carray/fusion.rb +229 -38
  143. data/lib/carray/inspect.rb +71 -20
  144. data/lib/carray/iterator.rb +157 -2
  145. data/lib/carray/lazy.rb +54 -67
  146. data/lib/carray/mask_gap_fill.rb +66 -50
  147. data/lib/carray/math.rb +32 -19
  148. data/lib/carray/meld_reduce.rb +40 -7
  149. data/lib/carray/methods/align_addr.rb +8 -4
  150. data/lib/carray/methods/bincount.rb +2 -2
  151. data/lib/carray/methods/bit_string.rb +14 -3
  152. data/lib/carray/methods/composition.rb +16 -10
  153. data/lib/carray/methods/discovery_along.rb +74 -0
  154. data/lib/carray/methods/factorize.rb +50 -0
  155. data/lib/carray/methods/gather_nd.rb +13 -2
  156. data/lib/carray/methods/insert_block.rb +9 -14
  157. data/lib/carray/methods/is_in.rb +130 -6
  158. data/lib/carray/methods/join.rb +1 -1
  159. data/lib/carray/methods/locate_addr.rb +235 -21
  160. data/lib/carray/methods/mask_duplicates.rb +35 -1
  161. data/lib/carray/methods/mode.rb +18 -1
  162. data/lib/carray/methods/nunique.rb +22 -1
  163. data/lib/carray/methods/repeat.rb +91 -0
  164. data/lib/carray/methods/resize.rb +21 -23
  165. data/lib/carray/methods/snap.rb +34 -12
  166. data/lib/carray/methods/unique.rb +52 -3
  167. data/lib/carray/record_order.rb +208 -0
  168. data/lib/carray/rng.rb +86 -0
  169. data/lib/carray/runtime.rb +14 -11
  170. data/lib/carray/segment_iterator.rb +990 -0
  171. data/lib/carray/serialize.rb +197 -27
  172. data/lib/carray/slab_iterator.rb +109 -40
  173. data/lib/carray/stack.rb +10 -4
  174. data/lib/carray/string_operation_extension.rb +9 -4
  175. data/lib/carray/struct.rb +142 -51
  176. data/lib/carray/struct_builder.rb +110 -14
  177. data/lib/carray/table.rb +2 -2
  178. data/lib/carray/time.rb +535 -105
  179. data/lib/carray/window_iterator.rb +259 -82
  180. data/lib/carray.rb +4 -2
  181. data/yard-stubs/ca_obj_array.rb +4 -4
  182. data/yard-stubs/ca_obj_bitarray.rb +7 -0
  183. data/yard-stubs/ca_obj_bitfield.rb +23 -12
  184. data/yard-stubs/ca_obj_block.rb +2 -7
  185. data/yard-stubs/ca_obj_fake.rb +1 -1
  186. data/yard-stubs/ca_obj_field.rb +1 -1
  187. data/yard-stubs/ca_obj_refer.rb +3 -3
  188. data/yard-stubs/ca_obj_roll.rb +0 -1
  189. data/yard-stubs/ca_obj_stride.rb +10 -7
  190. data/yard-stubs/ca_obj_transpose.rb +1 -1
  191. data/yard-stubs/ca_obj_window.rb +11 -4
  192. data/yard-stubs/carray_access.rb +6 -5
  193. data/yard-stubs/carray_broadcast.rb +2 -2
  194. data/yard-stubs/carray_cast.rb +78 -43
  195. data/yard-stubs/carray_conversion.rb +13 -1
  196. data/yard-stubs/carray_copy.rb +8 -3
  197. data/yard-stubs/carray_core.rb +3 -83
  198. data/yard-stubs/carray_count.rb +16 -2
  199. data/yard-stubs/carray_lazy.rb +210 -1
  200. data/yard-stubs/carray_loop.rb +4 -1
  201. data/yard-stubs/carray_mask.rb +34 -10
  202. data/yard-stubs/carray_math.rb +1499 -4
  203. data/yard-stubs/carray_median_percentile.rb +26 -8
  204. data/yard-stubs/carray_order.rb +43 -82
  205. data/yard-stubs/carray_scatter.rb +10 -3
  206. data/yard-stubs/carray_segment.rb +72 -0
  207. data/yard-stubs/carray_slab.rb +11 -8
  208. data/yard-stubs/carray_sort.rb +50 -8
  209. data/yard-stubs/carray_test.rb +10 -0
  210. data/yard-stubs/carray_utils.rb +0 -26
  211. metadata +19 -19
  212. data/ext/ca_for_each_element.h +0 -239
  213. data/ext/carray_build_flags.h +0 -3
  214. data/ext/carray_config.h +0 -28
  215. data/ext/carray_kernels_bincmp.c +0 -4446
  216. data/ext/carray_kernels_binop.c +0 -11001
  217. data/ext/carray_kernels_init.c +0 -1131
  218. data/ext/carray_kernels_map.c +0 -3467
  219. data/ext/carray_kernels_moncmp.c +0 -2097
  220. data/ext/carray_kernels_monop.c +0 -18313
  221. data/ext/carray_kernels_reduce_aggregate.c +0 -25837
  222. data/ext/carray_kernels_reduce_boolean.c +0 -330
  223. data/ext/carray_kernels_reduce_cumulative.c +0 -14593
  224. data/ext/carray_kernels_reduce_extreme.c +0 -16948
  225. data/ext/carray_kernels_reduce_variance.c +0 -3910
  226. data/ext/carray_kernels_scan.c +0 -3693
  227. data/ext/carray_kernels_search.c +0 -32138
  228. data/ext/carray_kernels_sort.c +0 -10626
  229. data/ext/carray_kernels_triop.c +0 -1392
@@ -0,0 +1,79 @@
1
+ # CAFrame#describe: one row of statistics per column, for a first look at a
2
+ # table that has just been read.
3
+
4
+ class CAFrame
5
+ # Summarize the columns, one row each, as a new frame indexed by column
6
+ # name:
7
+ #
8
+ # puts df.describe.to_table
9
+ #
10
+ # column type count masked unique min max mean stddev
11
+ # ------- ------- ----- ------ ------ ---------- ---------- ---------- --------
12
+ # station object 3 0 2 _ _ _ _
13
+ # temp float64 2 1 2 19.0 22.1 20.55 2.192031
14
+ # time CATime 3 0 3 2024-01-01 2024-01-03 2024-01-02 1D
15
+ #
16
+ # +type+ is the data type, or the Face's class for a Face column, with the
17
+ # trailing shape of an N-D column in brackets (+float64[3]+). +count+ and
18
+ # +masked+ count cells, so an N-D column counts every cell of every row;
19
+ # +unique+ is the number of distinct values among the present cells.
20
+ #
21
+ # The other statistics apply only where the column's kind gives them a
22
+ # meaning, and are UNDEF elsewhere:
23
+ #
24
+ # real numbers, boolean min max mean stddev
25
+ # complex mean stddev (no order)
26
+ # CATime, CATimedelta min max mean stddev (as times / durations)
27
+ # anything else (text, categorical, records ...) unique only
28
+ #
29
+ # A boolean column counts true as 1, so its mean is the share of trues. A
30
+ # column with no present cell has UNDEF statistics. The index is not a
31
+ # column and is not summarized.
32
+ #
33
+ # @param names [Array<String>] the columns to summarize; default all.
34
+ # @return [CAFrame] a new frame with one row per column.
35
+ def describe(*names)
36
+ names = names.empty? ? column_names : names.map(&:to_s)
37
+ rows = names.map { |name| describe_column(self[name]) }
38
+ stats = %w[type count masked unique min max mean stddev]
39
+ cols = stats.to_h do |stat|
40
+ values = rows.map { |row| row[stat] }
41
+ column =
42
+ case stat
43
+ when "count", "masked", "unique" then CArray.int64(values.size)
44
+ else CArray.object(values.size)
45
+ end
46
+ column[] = UNDEF
47
+ values.each_with_index { |v, i| column[i] = v unless UNDEF.equal?(v) }
48
+ [stat, column]
49
+ end
50
+ CAFrame.new(cols, axis_name: "column", index: CA_OBJECT(names))
51
+ end
52
+
53
+ DESCRIBE_TIME_FACES = %w[CATime CATimedelta].freeze
54
+ private_constant :DESCRIBE_TIME_FACES
55
+
56
+ private def describe_column(col)
57
+ kind =
58
+ if col.face?
59
+ DESCRIBE_TIME_FACES.include?(col.class.name) ? :ordered : :other
60
+ elsif col.complex?
61
+ :complex
62
+ elsif col.numeric? || col.boolean?
63
+ :ordered
64
+ else
65
+ :other
66
+ end
67
+ type = col.face? ? col.class.name : col.data_type_name
68
+ type += col.shape[1..].inspect if col.ndim > 1
69
+ row = { "type" => type,
70
+ "count" => col.count_not_masked, "masked" => col.count_masked,
71
+ "unique" => col.nunique }
72
+ ordered = kind == :ordered
73
+ row["min"] = ordered ? col.min : UNDEF
74
+ row["max"] = ordered ? col.max : UNDEF
75
+ row["mean"] = kind == :other ? UNDEF : col.mean
76
+ row["stddev"] = kind == :other ? UNDEF : col.stddev
77
+ row
78
+ end
79
+ end
@@ -11,6 +11,29 @@
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
@@ -45,14 +68,21 @@ class CAFrame
45
68
  @columns = {}
46
69
  @axis_name = axis_name || DEFAULT_AXIS_NAME
47
70
  @index = nil
71
+ # The row axis name this frame had before set_index promoted a column over
72
+ # it, so reset_index can put it back. A frame built with an index never
73
+ # had one, and neither does a frame derived from an indexed one: both are
74
+ # born indexed, so there is nothing to restore and the default stands.
75
+ @axis_name_before_index = nil
48
76
 
49
77
  n = nil
78
+ n_from = nil
50
79
  columns.each do |name, col|
51
80
  key = name.to_s
52
81
  ca = coerce_column(col)
53
82
  len = ca.shape[0]
54
83
  if n.nil?
55
- n = len
84
+ n = len
85
+ n_from = key
56
86
  elsif len != n
57
87
  raise ArgumentError,
58
88
  "column #{key.inspect} has axis-0 length #{len}, expected #{n}"
@@ -67,8 +97,12 @@ class CAFrame
67
97
  raise ArgumentError, "index must be a 1-D column (got ndim #{idx.ndim})"
68
98
  end
69
99
  if n && idx.shape[0] != n
100
+ # Name the column as well as the index: when the index is the frame's
101
+ # existing one and a column is the new arrival, blaming the index alone
102
+ # points at the side the caller cannot change.
70
103
  raise ArgumentError,
71
- "index length #{idx.shape[0]} does not match nrow #{n}"
104
+ "column #{n_from.inspect} has axis-0 length #{n}, " \
105
+ "but the index has length #{idx.shape[0]}"
72
106
  end
73
107
  @index = idx
74
108
  @nrow = idx.shape[0] if n.nil?
@@ -101,7 +135,7 @@ class CAFrame
101
135
  if keys.size > 1
102
136
  unless keys.all? { |k| k.is_a?(String) }
103
137
  raise ArgumentError,
104
- "multi-key df[...] escapes columns; every key must be a String " \
138
+ "df[...] with several keys returns columns; every key must be a String " \
105
139
  "(use df.select(...) for a subset frame)"
106
140
  end
107
141
  return keys.map { |k| @columns.fetch(k) { raise KeyError, "no column #{k.inspect}" } }
@@ -131,7 +165,7 @@ class CAFrame
131
165
  "CArray df[] key must be boolean or integer (got #{key.data_type})"
132
166
  end
133
167
  when Symbol
134
- raise NotImplementedError, "df[symbol] is reserved for predicate keys"
168
+ raise ArgumentError, "df[symbol] is reserved for predicate keys"
135
169
  else
136
170
  raise ArgumentError, "unsupported df[] key: #{key.class}"
137
171
  end
@@ -186,6 +220,7 @@ class CAFrame
186
220
  # write path always propagates to derived views (unchanged from
187
221
  # previous behaviour).
188
222
  def []=(*keys, value)
223
+ refuse_if_frozen
189
224
  if keys.size != 1
190
225
  raise ArgumentError,
191
226
  "df[...] = takes one key (a column name or a row selector); " \
@@ -211,6 +246,52 @@ class CAFrame
211
246
  value
212
247
  end
213
248
 
249
+ # Freezing a frame fixes its columns, rows and index; the columns' cells are
250
+ # their own arrays' to guard, as an Array's elements are. The check comes
251
+ # first so a refused edit leaves the frame as it was: the edits change the
252
+ # column table before any instance variable.
253
+ private def refuse_if_frozen
254
+ raise FrozenError.new("can't modify frozen CAFrame: #{inspect}", receiver: self) if frozen?
255
+ end
256
+
257
+ # Make this frame read-only and return it. It is +freeze+ extended to the
258
+ # cells: the column set, the rows and the index are fixed as by +freeze+,
259
+ # and the cells and masks of every column and of the index refuse writes
260
+ # made through this frame.
261
+ #
262
+ # Each column (and the index) is replaced by a read-only view of itself, so
263
+ # the arrays the frame was built from are left as they were: whoever holds
264
+ # them can still write, and the writes show through this frame. Frames
265
+ # derived from a protected frame by views (+select+, +filter+, a row slice)
266
+ # read through those views and refuse cell writes too, but are not frozen.
267
+ # +copy+ gives a frame that can be written again.
268
+ #
269
+ # Calling it on a protected frame returns the frame unchanged. A frame
270
+ # frozen with +freeze+ cannot be protected afterwards, because its columns
271
+ # can no longer be replaced: raises +FrozenError+.
272
+ def protect
273
+ return self if protected?
274
+ refuse_if_frozen
275
+ @columns = @columns.transform_values { |col| read_only_view(col) }
276
+ @index = read_only_view(@index) if @index
277
+ @protected = true
278
+ freeze
279
+ end
280
+
281
+ # True if this frame was made read-only by +protect+. A +dup+ is not frozen
282
+ # and so not protected, though its columns stay the read-only views it
283
+ # shares; a +clone+ keeps both.
284
+ def protected?
285
+ frozen? && @protected == true
286
+ end
287
+
288
+ private def read_only_view(col)
289
+ return col if col.read_only?
290
+ view = col.refer
291
+ view.set_read_only_flag
292
+ view
293
+ end
294
+
214
295
  # df["name"] = ... : the column forms of []=. Unlike the verbs, +[]=+ can
215
296
  # only ever mutate the receiver -- Ruby hands the right-hand side back as
216
297
  # the value of an assignment, so there is no way to return a new frame.
@@ -227,16 +308,17 @@ class CAFrame
227
308
  mask_column(key)
228
309
  elsif value.is_a?(CAFrame)
229
310
  raise ArgumentError,
230
- "df[name] = takes a column, not a CAFrame; escape one with " \
311
+ "df[name] = takes a column, not a CAFrame; take one with " \
231
312
  "other[\"name\"], or splice rows with df[rows] = other"
232
313
  else
233
314
  rebind_column(key, value)
234
315
  end
235
316
  end
236
317
 
237
- # Bind +key+ to +column+, adding the name when it is new. This is the one
238
- # place a column enters an existing frame, so it is where the axis-0 length
239
- # invariant is enforced (§12-A). It is a replacement rather than an edit,
318
+ # Bind +key+ to +column+, adding the name when it is new. This is where a
319
+ # column arriving from outside the frame has its axis-0 length checked
320
+ # (§12-A); the conversions in verbs.rb rebind +@columns+ directly and rely on
321
+ # being length-preserving instead. It is a replacement rather than an edit,
240
322
  # which is what sets it apart from the rest: +fill+ / +mask_eq+ /
241
323
  # +df[rows] = UNDEF+ write to the shared column and are therefore visible
242
324
  # wherever it is held, while this binds the name to a different column and
@@ -307,15 +389,21 @@ class CAFrame
307
389
  # value kept, so the row stays identifiable), leaving a later, better-informed
308
390
  # pass to re-judge them. Definitely-true rows carry their values through
309
391
  # unchanged in both modes.
392
+ #
393
+ # The default is a view-frame. +keep_masked: true+ returns a **materialized**
394
+ # frame instead -- carrying the UNDEF forward means writing it into the
395
+ # result, which a view cannot do without masking the parent's rows. That holds
396
+ # whether or not the selector actually carries a masked cell, so the same call
397
+ # site does not switch between sharing and copying with the data.
310
398
  def filter(keep_masked: false)
311
399
  mask = yield(self)
312
400
  unless mask.is_a?(CArray) && mask.data_type == :boolean
313
401
  raise ArgumentError,
314
402
  "filter block must return a boolean CArray (got #{mask.class})"
315
403
  end
316
- unless mask.shape[0] == @nrow
404
+ unless mask.shape == [@nrow]
317
405
  raise ArgumentError,
318
- "filter mask has axis-0 length #{mask.shape[0]}, expected nrow #{@nrow}"
406
+ "filter mask has shape #{mask.shape.inspect}, expected [#{@nrow}] (one value per row)"
319
407
  end
320
408
  select_rows(mask, keep_masked: keep_masked)
321
409
  end
@@ -324,13 +412,25 @@ class CAFrame
324
412
  # An index-role change: the data is unchanged, so this mutates self and
325
413
  # returns it (memo §3.8). Any existing index is replaced.
326
414
  def set_index(name)
415
+ refuse_if_frozen
327
416
  key = name.to_s
328
417
  raise KeyError, "no column #{key.inspect}" unless @columns.key?(key)
329
418
  idx = @columns[key]
330
419
  unless idx.ndim == 1
331
420
  raise ArgumentError, "index must be a 1-D column (got ndim #{idx.ndim})"
332
421
  end
422
+ # Only the name held before the frame had an index: a second set_index must
423
+ # not overwrite it with the first index's name.
424
+ @axis_name_before_index = @axis_name if @index.nil?
333
425
  @columns.delete(key)
426
+ if @index
427
+ # Re-indexing replaces the index, so the one being replaced goes back to
428
+ # being a column -- the same demotion reset_index performs, in the same
429
+ # position, which makes set_index over an existing index equivalent to
430
+ # reset_index followed by set_index. Dropping it would lose the column it
431
+ # was made from.
432
+ @columns = { @axis_name => @index }.merge(@columns)
433
+ end
334
434
  @index = idx
335
435
  @axis_name = key
336
436
  @nrow = idx.shape[0]
@@ -341,10 +441,12 @@ class CAFrame
341
441
  # Also an index-role change, so it mutates self (memo §3.8). The former
342
442
  # index becomes the first column, named after the row axis.
343
443
  def reset_index
444
+ refuse_if_frozen
344
445
  return self unless @index
345
446
  @columns = { @axis_name => @index }.merge(@columns)
346
447
  @index = nil
347
- @axis_name = DEFAULT_AXIS_NAME
448
+ @axis_name = @axis_name_before_index || DEFAULT_AXIS_NAME
449
+ @axis_name_before_index = nil
348
450
  self
349
451
  end
350
452
 
@@ -364,22 +466,50 @@ class CAFrame
364
466
 
365
467
  # The single row whose index label equals +label+, as a Ruby Hash (memo
366
468
  # §13.2b). +label+ is matched exactly against the index (+index.eq+), so any
367
- # orderable / object / datetime / categorical index works. The return type is
469
+ # orderable / object / datetime / categorical index works, and +label+ is
470
+ # whatever +index.eq+ takes -- for a time index, a Time, Date or a String
471
+ # such as "2024-01-02". A NaN label finds the NaN label, as join and
472
+ # group_by match NaN keys. A numeric index takes a number; a String or
473
+ # true / false raises. The return type is
368
474
  # a row Hash and stays that way: zero matches raise KeyError, and duplicate
369
475
  # labels raise (go through +filter { |f| f.index.eq(label) }+ for the
370
476
  # multi-row, frame-returning path). Positional access is +df[i]+.
371
477
  def at(label)
372
478
  raise ArgumentError, "at requires an index (set one with set_index)" unless @index
373
- pos = @index.eq(label).where
479
+ # An index may hold a masked cell -- an outer join and align both build one
480
+ # -- but an undefined label identifies no row, and two undefined labels are
481
+ # not the same label. The addressing primitives already say this (a masked
482
+ # key matches nothing, not another masked key), so at says it too rather
483
+ # than answering through eq(UNDEF), which asks about the mask and not about
484
+ # the label at all.
485
+ if UNDEF.equal?(label)
486
+ raise ArgumentError,
487
+ "at(UNDEF): an undefined label identifies no row; " \
488
+ "use filter { |f| f.index.is_masked } for the rows with no label"
489
+ end
490
+ # A NaN label is the NaN value, as join and group_by take it: eq answers
491
+ # false for NaN against anything, itself included.
492
+ # A numeric index is looked up with a number: a String or true/false would
493
+ # be read differently by each numeric type, or not at all.
494
+ if !@index.face? && (@index.integer? || @index.float? || @index.complex?) &&
495
+ !label.is_a?(Numeric)
496
+ raise ArgumentError,
497
+ "at: the index is #{@index.data_type}, so the label must be a number " \
498
+ "(got #{label.inspect}); convert it first, e.g. at(Float(s)) or at(Integer(s))"
499
+ end
500
+ nan = label.is_a?(Float) && label.nan?
501
+ hit = nan ? @index.is_in([label]) : @index.eq(label)
502
+ pos = hit.where
374
503
  case pos.elements
375
504
  when 0
376
505
  raise KeyError, "no row with index label #{label.inspect}"
377
506
  when 1
378
507
  row(pos[0])
379
508
  else
509
+ test = nan ? "f.index.is_nan" : "f.index.eq(label)"
380
510
  raise ArgumentError,
381
511
  "index label #{label.inspect} matches #{pos.elements} rows; " \
382
- "use filter { |f| f.index.eq(label) } for duplicate labels"
512
+ "use filter { |f| #{test} } for duplicate labels"
383
513
  end
384
514
  end
385
515
 
@@ -403,20 +533,20 @@ class CAFrame
403
533
  # --- metadata readers (memo §13.2) ------------------------------------
404
534
  # Each returns a fresh object; the live columns Hash is never exposed.
405
535
 
406
- # Variable (column) names in column order. Returns +Array<String>+.
407
- def variable_names
536
+ # Column names in column order. Returns +Array<String>+.
537
+ def column_names
408
538
  @columns.keys
409
539
  end
410
540
 
411
- # Variables (raw column CArrays) in column order. Returns
412
- # +Array<CArray>+. Equivalent to +df[*variable_names]+ but built
413
- # directly from the columns Hash.
414
- def variables
541
+ # The columns (raw CArrays) in column order. Returns +Array<CArray>+.
542
+ # Equivalent to +df[*column_names]+ but built directly from the columns
543
+ # Hash.
544
+ def columns
415
545
  @columns.values
416
546
  end
417
547
 
418
- # Number of variables (columns).
419
- def nvar
548
+ # Number of columns.
549
+ def ncol
420
550
  @columns.size
421
551
  end
422
552
 
@@ -444,7 +574,7 @@ class CAFrame
444
574
  if col.is_a?(CAFrame)
445
575
  raise ArgumentError,
446
576
  "a CAFrame cannot be a column (it answers to_ca as a 2-D matrix); " \
447
- "escape a column with df[\"name\"] or df.to_ca first"
577
+ "take a column with df[\"name\"] or df.to_ca first"
448
578
  end
449
579
  unless col.respond_to?(:to_ca)
450
580
  raise ArgumentError, "column must be a CArray or Array (got #{col.class})"
@@ -457,10 +587,11 @@ class CAFrame
457
587
  end
458
588
 
459
589
  private def row(i)
460
- i += @nrow if i < 0
461
- unless i >= 0 && i < @nrow
590
+ pos = i < 0 ? i + @nrow : i
591
+ unless pos >= 0 && pos < @nrow
462
592
  raise IndexError, "row index #{i} out of range (nrow=#{@nrow})"
463
593
  end
594
+ i = pos
464
595
  h = {}
465
596
  h[@axis_name] = elem_at(@index, i) if @index
466
597
  @columns.each { |name, col| h[name] = elem_at(col, i) }
@@ -483,6 +614,19 @@ class CAFrame
483
614
  # and index are untouched; the selected cells of every column go to UNDEF.
484
615
  # The selector is forwarded to the column indexer, which classifies it.
485
616
  private def mask_rows(selector)
617
+ # Decide before changing anything. A read-only column (a categorical, whose
618
+ # codes are read-only) refuses the write, and refusing half way through
619
+ # would leave the frame masked in whichever columns happened to come first.
620
+ refusing = @columns.find { |_, col| col.read_only? }
621
+ if refusing
622
+ raise "can not modify read-only array: column #{refusing[0].inspect} " \
623
+ "refuses the write, so no column was masked"
624
+ end
625
+ # The selector is classified by the column indexer, which is also what
626
+ # bound-checks it -- so with no column to forward it to, nothing would.
627
+ # Build the row mask purely to have the frame's own row axis refuse an
628
+ # out-of-range row, as every other row form does.
629
+ selected_row_mask(selector) if @columns.empty?
486
630
  @columns.each_value do |col|
487
631
  col[selector, *([nil] * (col.ndim - 1))] = UNDEF
488
632
  end
@@ -508,10 +652,10 @@ class CAFrame
508
652
  # promote and the row count shifts by other.nrow - span.
509
653
  private def splice_rows(selector, other)
510
654
  lo, hi = contiguous_span(selector)
511
- unless other.variable_names.sort == variable_names.sort
655
+ unless other.column_names.sort == column_names.sort
512
656
  raise ArgumentError,
513
- "splice frame has columns #{other.variable_names.inspect}, " \
514
- "expected the same set as #{variable_names.inspect}"
657
+ "splice frame has columns #{other.column_names.inspect}, " \
658
+ "expected the same set as #{column_names.inspect}"
515
659
  end
516
660
  new_cols = {}
517
661
  @columns.each do |name, col|
@@ -540,7 +684,16 @@ class CAFrame
540
684
  new_index = splice_index(other, lo, hi)
541
685
  @columns = new_cols
542
686
  @index = new_index
543
- @nrow = @nrow - (hi - lo) + other.nrow
687
+ # Read the new count off something the frame now holds, rather than
688
+ # computing it. For every column the pieces welded above are head (lo) +
689
+ # other's rows + tail (nrow - hi), so the arithmetic and the objects agree
690
+ # -- except when there is nothing to weld: a frame with no columns and no
691
+ # index would otherwise come away claiming rows that nothing backs.
692
+ @nrow = if (witness = new_cols.each_value.first || new_index)
693
+ witness.shape[0]
694
+ else
695
+ 0
696
+ end
544
697
  self
545
698
  end
546
699
 
@@ -609,8 +762,11 @@ class CAFrame
609
762
  end
610
763
 
611
764
  private def select_rows(selector, keep_masked: false)
612
- if keep_masked && selector.is_a?(CArray) &&
613
- selector.data_type == :boolean && selector.has_mask?
765
+ if keep_masked && selector.is_a?(CArray) && selector.data_type == :boolean
766
+ # Not gated on selector.has_mask?: gating there would make the same call
767
+ # site return a view-frame or a materialized one depending on whether that
768
+ # run's data happened to produce an undetermined cell (the reason splice
769
+ # always snapshots -- see CAFrame.md section 3).
614
770
  return select_rows_keep_masked(selector)
615
771
  end
616
772
  cols = {}
@@ -625,13 +781,27 @@ class CAFrame
625
781
  keep = selector.strip_mask(1) # true where selected OR undetermined
626
782
  undet_kept = selector.is_masked[keep] # among kept rows, which were undetermined
627
783
  cols = {}
784
+ any_undet = undet_kept.any
628
785
  @columns.each do |name, col|
629
786
  tail = [nil] * (col.ndim - 1)
630
- g = col[keep, *tail].copy
631
- g[undet_kept, *tail] = UNDEF
632
- cols[name] = g
787
+ g = col[keep, *tail]
788
+ if any_undet
789
+ # mask_where copies and masks in one step, so a read-only Face
790
+ # (categorical, CAConstString) takes the mask as any column does.
791
+ cells = CArray.boolean(*g.shape)
792
+ cells[] = false
793
+ cells[undet_kept, *tail] = true
794
+ cols[name] = g.mask_where(cells)
795
+ else
796
+ cols[name] = g.copy
797
+ end
633
798
  end
634
- new_index = @index ? @index[keep] : nil
799
+ # The index is copied along with the columns. Its values are carried over
800
+ # unmasked (an undetermined row keeps its label, as mask_rows leaves the
801
+ # index alone), but a materialized frame has to be materialized whole: an
802
+ # aliased index would write through to the parent from a frame whose columns
803
+ # do not.
804
+ new_index = @index ? @index[keep].copy : nil
635
805
  CAFrame.new(cols, axis_name: @axis_name, index: new_index)
636
806
  end
637
807