carray 3.0.1 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +520 -0
  3. data/README.md +2 -2
  4. data/carray.gemspec +1 -1
  5. data/ext/ca_axis_dispatch.c +33 -4
  6. data/ext/ca_axis_group.c +202 -96
  7. data/ext/ca_categorical_iterator.c +108 -54
  8. data/ext/ca_kernel_iterator.c +317 -51
  9. data/ext/ca_kernel_iterator.h +142 -35
  10. data/ext/ca_obj_array.c +62 -20
  11. data/ext/ca_obj_block.c +4 -4
  12. data/ext/ca_obj_const_string.c +85 -26
  13. data/ext/ca_obj_face.c +24 -0
  14. data/ext/ca_obj_face.h +15 -0
  15. data/ext/ca_obj_fixlen_string.c +18 -5
  16. data/ext/ca_obj_meld.c +123 -25
  17. data/ext/ca_obj_object.c +8 -0
  18. data/ext/ca_obj_select.c +49 -34
  19. data/ext/ca_obj_stack.c +3 -8
  20. data/ext/ca_obj_stride.c +72 -1
  21. data/ext/ca_obj_string.c +8 -4
  22. data/ext/ca_obj_window.c +8 -2
  23. data/ext/ca_op_ipower.c +1 -2
  24. data/ext/ca_rng_normal.h +42 -0
  25. data/ext/ca_rng_xoshiro256pp.h +105 -0
  26. data/ext/ca_sweep_engine.c +307 -143
  27. data/ext/ca_sweep_engine.h +26 -5
  28. data/ext/carray.h +21 -2
  29. data/ext/carray_access.c +32 -20
  30. data/ext/carray_address_basis.c +590 -0
  31. data/ext/carray_broadcast.c +3 -3
  32. data/ext/carray_call_cfunc.c +667 -483
  33. data/ext/carray_cast.c +115 -41
  34. data/ext/carray_copy.c +55 -30
  35. data/ext/carray_core.c +83 -3
  36. data/ext/carray_count.c +9 -10
  37. data/ext/carray_factorize.c +46 -25
  38. data/ext/carray_internal.h +17 -0
  39. data/ext/carray_kernels_reduce_aggregate.c +168 -0
  40. data/ext/carray_kernels_reduce_cumulative.c +270 -1
  41. data/ext/carray_kernels_reduce_extreme.c +554 -8
  42. data/ext/carray_kernels_scan.c +4 -4
  43. data/ext/carray_kernels_search.c +94 -14
  44. data/ext/carray_loop.c +7 -1
  45. data/ext/carray_mask.c +23 -8
  46. data/ext/carray_median_percentile.c +55 -0
  47. data/ext/carray_operator.c +4 -4
  48. data/ext/carray_order.c +1 -1
  49. data/ext/carray_random.c +384 -40
  50. data/ext/carray_slab.c +13 -0
  51. data/ext/carray_sort.c +20 -22
  52. data/ext/mk_call_cfunc.rb +103 -116
  53. data/ext/mkkernel.rb +297 -29
  54. data/ext/ruby_carray.c +10 -1
  55. data/ext/version.h +4 -4
  56. data/lib/carray/autoload_carray.rb +5 -3
  57. data/lib/carray/autoload_method_extension.rb +12 -0
  58. data/lib/carray/axis_group.rb +77 -0
  59. data/lib/carray/basics.rb +4 -0
  60. data/lib/carray/block_iterator.rb +92 -16
  61. data/lib/carray/categorical.rb +150 -33
  62. data/lib/carray/categorical_iterator.rb +207 -80
  63. data/lib/carray/const_string.rb +131 -27
  64. data/lib/carray/construct.rb +40 -0
  65. data/lib/carray/data_type_extension.rb +3 -0
  66. data/lib/carray/data_type_limits.rb +91 -0
  67. data/lib/carray/fixlen_string.rb +1 -1
  68. data/lib/carray/frame/csv_parser.rb +11 -4
  69. data/lib/carray/frame/frame.rb +81 -10
  70. data/lib/carray/frame/group.rb +36 -3
  71. data/lib/carray/frame/io.rb +67 -15
  72. data/lib/carray/frame/records.rb +18 -4
  73. data/lib/carray/frame/verbs.rb +14 -11
  74. data/lib/carray/inspect.rb +42 -9
  75. data/lib/carray/iterator.rb +143 -0
  76. data/lib/carray/lazy.rb +0 -37
  77. data/lib/carray/mask_gap_fill.rb +3 -1
  78. data/lib/carray/methods/discovery_along.rb +74 -0
  79. data/lib/carray/methods/factorize.rb +50 -0
  80. data/lib/carray/methods/is_in.rb +13 -2
  81. data/lib/carray/methods/locate_addr.rb +75 -2
  82. data/lib/carray/methods/mask_duplicates.rb +35 -1
  83. data/lib/carray/methods/nunique.rb +22 -1
  84. data/lib/carray/methods/repeat.rb +110 -0
  85. data/lib/carray/methods/unique.rb +41 -1
  86. data/lib/carray/rng.rb +86 -0
  87. data/lib/carray/slab_iterator.rb +58 -13
  88. data/lib/carray/string_operation_extension.rb +5 -1
  89. data/lib/carray/time.rb +18 -2
  90. data/lib/carray/window_iterator.rb +142 -20
  91. data/lib/carray.rb +2 -0
  92. data/yard-stubs/ca_obj_block.rb +2 -7
  93. data/yard-stubs/ca_obj_window.rb +10 -2
  94. data/yard-stubs/carray_access.rb +1 -1
  95. data/yard-stubs/carray_broadcast.rb +1 -1
  96. data/yard-stubs/carray_core.rb +0 -80
  97. data/yard-stubs/carray_count.rb +7 -2
  98. data/yard-stubs/carray_lazy.rb +205 -0
  99. data/yard-stubs/carray_math.rb +1486 -3
  100. data/yard-stubs/carray_median_percentile.rb +16 -2
  101. data/yard-stubs/carray_order.rb +9 -69
  102. data/yard-stubs/carray_slab.rb +9 -7
  103. data/yard-stubs/carray_sort.rb +7 -5
  104. metadata +9 -1
@@ -5,12 +5,15 @@
5
5
  # CAConstString high-level construction + conversion surface.
6
6
  #
7
7
  # CAConstString itself (the Face + tail buffer + fetch decode + numeric gate) lives
8
- # in ext/ca_obj_const_string.c. This file provides the ergonomic builders that pack
9
- # Ruby Strings into the internal length-prefix buffer + int64 offset entity
10
- # and wrap them via the CAConstString.wrap C primitive.
8
+ # in ext/ca_obj_const_string.c. This file provides the ergonomic builders that
9
+ # pack Ruby Strings into the shared buffer + (start,end) pair entity, via the
10
+ # CAConstString.__build__ C primitive.
11
11
  #
12
- # Internal buffer format: concatenation of `[int32 length][bytes...]` records
13
- # (self-delimiting → one offset per element, permutation-safe view).
12
+ # Internal buffer format: a pure concatenation of the element bytes, with no
13
+ # per-record length prefix (= the Arrow values buffer). Each element carries
14
+ # its own `(start, end)` byte range in the storage, so it is self-describing
15
+ # and a gather / sort / select view over the pairs decodes correctly with no
16
+ # buffer copy.
14
17
  #
15
18
  # ----------------------------------------------------------------------------
16
19
 
@@ -89,21 +92,118 @@ class CAConstString
89
92
  # override the L1 generics where present.
90
93
  include CArray::StringOperationMixin
91
94
 
92
- # @overload sort
93
- # Returns a byte-order sorted view built by gathering the
94
- # offsets with `sort_index`. Neither bytes nor offsets are
95
- # copied.
95
+ # ---- ordering family ---------------------------------------------------
96
+ #
97
+ # A storage cell is a `(start, end)` byte range into the shared buffer, so
98
+ # storage order is the order the strings were packed in, not the order they
99
+ # compare in. Without these overrides the family sorted the offsets: the
100
+ # answers came back well-formed and wrong -- `%w[pear apple].sort_addr` gave
101
+ # the identity, and `partition_copy` gave NUL bytes.
102
+ #
103
+ # Every member therefore reads the bytes. The flat forms do it natively
104
+ # (a byte-memcmp scan over the packed buffer, an order of magnitude faster
105
+ # than decoding a Ruby String per cell), the per-axis forms through
106
+ # {#to_string}, where a cell is the string and CArray's own kernels apply.
107
+ # Arguments are forwarded verbatim, so `kind:` / `masked_position:` /
108
+ # `keep_axis:` mean here what they mean on CArray.
109
+ #
110
+ # Anything whose cells are strings comes back a CAConstString, gathered
111
+ # over the same buffer where it can be (sort / sort_copy) and rebuilt where
112
+ # it cannot. Indices, addresses and counts need no conversion.
113
+
114
+ # @overload sort(axis: nil, kind: :quick, masked_position: :last)
115
+ # Returns a sorted {CAConstString} view, gathered over the same buffer
116
+ # and offsets. With no `axis:` the array is flattened first, as
117
+ # `CArray#sort` does.
96
118
  # @return [CAConstString]
97
- def sort
98
- self[sort_index]
119
+ def sort (*args, **kw)
120
+ addr = sort_addr(*args, **kw)
121
+ kw[:axis].nil? ? self[addr.flatten] : self[addr]
99
122
  end
100
123
 
101
- # @overload sort_copy
102
- # Returns an owned sorted {CAConstString}; the materialised
103
- # counterpart to {#sort}.
124
+ # @overload sort_copy(axis: nil, kind: :quick, masked_position: :last)
125
+ # Returns an owned sorted {CAConstString}; the materialised counterpart
126
+ # to {#sort}.
104
127
  # @return [CAConstString]
105
- def sort_copy
106
- self[sort_index].copy
128
+ def sort_copy (*args, **kw)
129
+ sort(*args, **kw).copy
130
+ end
131
+
132
+ # @overload sort_addr(axis: nil, kind: :quick, masked_position: :last)
133
+ # Returns the view-flat addresses that index a sort by string order.
134
+ # @return [CArray] `:int64` addresses.
135
+ def sort_addr (*args, **kw)
136
+ # The native scan has no notion of an incomparable sentinel, so a masked
137
+ # column goes the same way a per-axis one does.
138
+ return __sort_addr_bytes__ if args.empty? && kw.empty? && ! has_mask?
139
+ to_string.sort_addr(*args, **kw)
140
+ end
141
+
142
+ # @overload sort_index(axis: nil, kind: :quick, masked_position: :last)
143
+ # Returns the per-fiber indices that index a sort by string order.
144
+ # @return [CArray] `:int64` indices.
145
+ def sort_index (*args, **kw)
146
+ to_string.sort_index(*args, **kw)
147
+ end
148
+
149
+ # @overload rank_index(axis: nil)
150
+ # @return [CArray] each cell's rank in string order.
151
+ def rank_index (*args, **kw)
152
+ to_string.rank_index(*args, **kw)
153
+ end
154
+
155
+ # @overload order(*args)
156
+ # @return [CArray] the ordering of the cells by string order.
157
+ def order (*args, **kw)
158
+ to_string.order(*args, **kw)
159
+ end
160
+
161
+ # @overload min(axis: nil, keep_axis: false)
162
+ # Returns the byte-smallest string, skipping masked cells; UNDEF when
163
+ # every cell is masked or the array is empty.
164
+ # @return [String, CAConstString]
165
+ def min (*args, **kw)
166
+ return __min_bytes__ || UNDEF if args.empty? && kw.empty?
167
+ const_string_lift(to_string.min(*args, **kw))
168
+ end
169
+
170
+ # @overload max(axis: nil, keep_axis: false)
171
+ # Byte-largest counterpart of {#min}.
172
+ # @return [String, CAConstString]
173
+ def max (*args, **kw)
174
+ return __max_bytes__ || UNDEF if args.empty? && kw.empty?
175
+ const_string_lift(to_string.max(*args, **kw))
176
+ end
177
+
178
+ # @overload minmax(axis: nil, keep_axis: false)
179
+ # @return [Array] `[min, max]`.
180
+ def minmax (*args, **kw)
181
+ return [min, max] if args.empty? && kw.empty?
182
+ to_string.minmax(*args, **kw).map { |r| const_string_lift(r) }
183
+ end
184
+
185
+ # @overload min_index(axis: nil)
186
+ # @return [Integer, CArray] where the byte-smallest string sits.
187
+ def min_index (*args, **kw)
188
+ to_string.min_index(*args, **kw)
189
+ end
190
+
191
+ # @overload max_index(axis: nil)
192
+ # @return [Integer, CArray] where the byte-largest string sits.
193
+ def max_index (*args, **kw)
194
+ to_string.max_index(*args, **kw)
195
+ end
196
+
197
+ # @overload partition_copy(kth, axis: nil)
198
+ # @return [CAConstString] partitioned about the `kth` string in order.
199
+ def partition_copy (*args, **kw)
200
+ const_string_lift(to_string.partition_copy(*args, **kw))
201
+ end
202
+
203
+ # @overload partition_index(kth, axis: nil)
204
+ # @return [CArray] the indices that partition about the `kth` string.
205
+ def partition_index (*args, **kw)
206
+ to_string.partition_index(*args, **kw)
107
207
  end
108
208
 
109
209
  # CAConstString is the one Face with three separate entities -- storage
@@ -144,14 +244,14 @@ class CAConstString
144
244
  # @overload unique(sort: false)
145
245
  # @return [CAConstString] the distinct strings.
146
246
  def unique (sort: false)
147
- to_string.unique(sort: sort).to_const_string
247
+ const_string_lift(to_string.unique(sort: sort))
148
248
  end
149
249
 
150
250
  # @overload value_counts(sort: false)
151
251
  # @return [Array(CAConstString, CArray)] `[values, counts]`.
152
252
  def value_counts (sort: false)
153
253
  values, counts = to_string.value_counts(sort: sort)
154
- [values.to_const_string, counts]
254
+ [const_string_lift(values), counts]
155
255
  end
156
256
 
157
257
  # @overload nunique(axis: nil, keep_axis: false)
@@ -164,11 +264,7 @@ class CAConstString
164
264
  # @return [CAConstString, Array] the most frequent string(s).
165
265
  def mode (axis: nil)
166
266
  r = to_string.mode(axis: axis)
167
- case r
168
- when CArray then r.to_const_string
169
- when Array then r.map { |c| c.is_a?(CArray) ? c.to_const_string : c }
170
- else r
171
- end
267
+ r.is_a?(Array) ? r.map { |c| const_string_lift(c) } : const_string_lift(r)
172
268
  end
173
269
 
174
270
  # @overload is_mode(axis: nil)
@@ -180,7 +276,7 @@ class CAConstString
180
276
  # @overload mask_duplicates(axis: nil)
181
277
  # @return [CAConstString] a copy with every repeat occurrence masked.
182
278
  def mask_duplicates (axis: nil)
183
- to_string.mask_duplicates(axis: axis).to_const_string
279
+ const_string_lift(to_string.mask_duplicates(axis: axis))
184
280
  end
185
281
 
186
282
  # @overload is_in(values)
@@ -192,19 +288,19 @@ class CAConstString
192
288
  # @overload intersection(other, sort: false)
193
289
  # @return [CAConstString] the distinct strings present in both.
194
290
  def intersection (other, sort: false)
195
- to_string.intersection(string_operand(other), sort: sort).to_const_string
291
+ const_string_lift(to_string.intersection(string_operand(other), sort: sort))
196
292
  end
197
293
 
198
294
  # @overload difference(other, sort: false)
199
295
  # @return [CAConstString] the distinct strings only `self` has.
200
296
  def difference (other, sort: false)
201
- to_string.difference(string_operand(other), sort: sort).to_const_string
297
+ const_string_lift(to_string.difference(string_operand(other), sort: sort))
202
298
  end
203
299
 
204
300
  # @overload union(other, sort: false)
205
301
  # @return [CAConstString] the distinct strings of either side.
206
302
  def union (other, sort: false)
207
- to_string.union(string_operand(other), sort: sort).to_const_string
303
+ const_string_lift(to_string.union(string_operand(other), sort: sort))
208
304
  end
209
305
 
210
306
  # @overload locate_addr(ref)
@@ -225,4 +321,12 @@ class CAConstString
225
321
  other.is_a?(CAConstString) ? other.to_string : other
226
322
  end
227
323
 
324
+ # Pack a string-bearing result back into a column of this one's encoding.
325
+ # The encoding has to be carried: the builder checks each element against
326
+ # the column's, so a Shift_JIS column rebuilt as the UTF-8 default raised
327
+ # rather than coming back.
328
+ private def const_string_lift (x)
329
+ x.is_a?(CArray) ? CArray.const_string(x, encoding: encoding) : x
330
+ end
331
+
228
332
  end
@@ -256,6 +256,46 @@ class CArray
256
256
  TypeSymbol = nil
257
257
  # @!visibility private
258
258
  DataType = nil
259
+
260
+ # @overload empty(data_type, dim, bytes: nil)
261
+ # Returns a new CArray of `data_type` with the shape `dim`, whose
262
+ # contents are **undefined**. This is {CArray.new} without the fill:
263
+ # the arguments are read the same way, and the cost of zeroing the
264
+ # buffer is not paid. Use it when every cell is written before it is
265
+ # read; otherwise use `CArray.new`, which zero-fills.
266
+ #
267
+ # `CA_OBJECT` is the exception: its cells are zero-initialised
268
+ # anyway, because the garbage collector walks them.
269
+ #
270
+ # @param data_type [Symbol, String, Integer] element type.
271
+ # @param dim [Array<Integer>] shape of the new CArray.
272
+ # @param bytes [Integer, nil] element width, for `:fixlen` only.
273
+ # @return [CArray]
274
+ # @raise [ArgumentError] if a block is given -- filling an array is
275
+ # what `CArray.new` is for.
276
+ # @overload empty(*shape)
277
+ # (Numo / NumPy compatibility) Returns an uninitialised CArray of
278
+ # the given shape. The element type comes from the receiver, so
279
+ # `CArray` itself gives `CA_FLOAT64`; see
280
+ # {DataTypeExtension#empty}.
281
+ # @param shape [Array<Integer>, Array<Array<Integer>>] shape.
282
+ # @return [CArray]
283
+ def self.empty (*args, **options, &block)
284
+ if block
285
+ raise ArgumentError,
286
+ "CArray.empty does not take a block " \
287
+ "(its contents are left undefined); use CArray.new to fill."
288
+ end
289
+ if args.size == 2 and args[1].is_a?(Array)
290
+ return __empty__(*args, **options)
291
+ end
292
+ unless options.empty?
293
+ raise ArgumentError,
294
+ "CArray.empty(*shape) takes no options; bytes: belongs to " \
295
+ "CArray.empty(data_type, dim)"
296
+ end
297
+ super(*args)
298
+ end
259
299
  end
260
300
 
261
301
  class CArray
@@ -237,6 +237,9 @@ class CArray
237
237
  # **uninitialised**. The caller must overwrite the array
238
238
  # before reading from it. `CA_OBJECT` silently falls back to
239
239
  # a zero-VALUE init required for GC safety.
240
+ #
241
+ # carray's own spelling is `CArray.empty(data_type, dim)`, which
242
+ # names the element type instead of taking it from the receiver.
240
243
  # @param shape [Array<Integer>, Array<Array<Integer>>]
241
244
  # @return [CArray]
242
245
  def empty (*args)
@@ -0,0 +1,91 @@
1
+ # What each numeric data type can hold, as constants on the typed
2
+ # classes.
3
+ #
4
+ # CArray::Int32::MAX # => 2147483647
5
+ # CArray::Float32::EPSILON # => 1.1920928955078125e-07
6
+ #
7
+ # The names follow NumPy's iinfo / finfo rather than Ruby's Float, so
8
+ # that MIN and MAX bracket the range for every numeric type alike:
9
+ #
10
+ # CArray::Float64::MIN # => -1.7976931348623157e+308
11
+ # Float::MIN # => 2.2250738585072014e-308
12
+ #
13
+ # Those are not the same number and not the same question. Ruby's
14
+ # Float::MIN is the smallest positive normal, which is TINY here; MIN
15
+ # is the bottom of the range, which for an integer type is the only
16
+ # thing MIN could mean. Code that has to bracket a type without
17
+ # knowing whether it is integer or float reads MIN and MAX and is
18
+ # right either way, and that is the reason for the choice.
19
+ #
20
+ # Not every data type has limits. boolean, fixlen and object have no
21
+ # numeric range, so they carry none of these constants -- asking gives
22
+ # a NameError rather than an answer that would have to be invented.
23
+
24
+ class CArray
25
+
26
+ # The integer widths come from the extension rather than from a table
27
+ # written here, so a platform where a type is not the usual width is
28
+ # described correctly instead of confidently mis-described.
29
+
30
+ {
31
+ Int8 => true, Int16 => true, Int32 => true, Int64 => true,
32
+ UInt8 => false, UInt16 => false, UInt32 => false, UInt64 => false,
33
+ }.each do |klass, signed|
34
+ bits = 8 * CArray.sizeof(klass::TypeSymbol)
35
+ if signed
36
+ klass.const_set(:MIN, -(2 ** (bits - 1)))
37
+ klass.const_set(:MAX, 2 ** (bits - 1) - 1)
38
+ else
39
+ klass.const_set(:MIN, 0)
40
+ klass.const_set(:MAX, 2 ** bits - 1)
41
+ end
42
+ end
43
+
44
+ # float32 and float64 are IEEE-754 binary32 and binary64, and the four
45
+ # values below follow from the format: with `p` significand bits (the
46
+ # implicit one included) and `emax` the exponent of the largest finite
47
+ # value,
48
+ #
49
+ # EPSILON = 2 ** (1 - p) the step from 1.0 to the next float
50
+ # MAX = (2 - EPSILON) * 2 ** emax
51
+ # MIN = -MAX every format here is symmetric
52
+ # TINY = 2 ** (1 - emax) the smallest positive normal
53
+ #
54
+ # All four are exactly representable as a Ruby Float, binary32
55
+ # included, so nothing is rounded on the way in. The width is
56
+ # checked rather than assumed: on a platform where C float or double
57
+ # is not one of these formats the arithmetic below would be wrong,
58
+ # and a wrong limit is worse than a missing one.
59
+
60
+ {
61
+ Float32 => [4, 24, 127],
62
+ Float64 => [8, 53, 1023],
63
+ }.each do |klass, (bytes, precision, max_exponent)|
64
+ actual = CArray.sizeof(klass::TypeSymbol)
65
+ unless actual == bytes
66
+ raise "#{klass} is #{actual} bytes wide, not the #{bytes * 8}-bit IEEE-754 " \
67
+ "format its limits are derived from"
68
+ end
69
+ epsilon = 2.0 ** (1 - precision)
70
+ max = (2.0 - epsilon) * 2.0 ** max_exponent
71
+ klass.const_set(:EPSILON, epsilon)
72
+ klass.const_set(:MAX, max)
73
+ klass.const_set(:MIN, -max)
74
+ klass.const_set(:TINY, 2.0 ** (1 - max_exponent))
75
+ end
76
+
77
+ # A complex type is a pair of floats, so its limits are its
78
+ # component's -- MIN and MAX bound the real and the imaginary part
79
+ # separately, not any magnitude of the pair. This is what
80
+ # np.finfo(np.complex64) answers too.
81
+
82
+ {
83
+ Complex64 => Float32,
84
+ Complex128 => Float64,
85
+ }.each do |klass, component|
86
+ [:MIN, :MAX, :TINY, :EPSILON].each do |name|
87
+ klass.const_set(name, component.const_get(name))
88
+ end
89
+ end
90
+
91
+ end
@@ -3,7 +3,7 @@
3
3
  # CAFixlenString high-level construction surface.
4
4
  #
5
5
  # CAFixlenString itself (the string interpretation of CA_FIXLEN storage) lives
6
- # in ext/ca_obj_string.c. This file provides the ergonomic builder that packs
6
+ # in ext/ca_obj_fixlen_string.c. This file provides the ergonomic builder that packs
7
7
  # Ruby Strings into a fixed-width CA_FIXLEN entity and wraps it via
8
8
  # CAFixlenString.wrap.
9
9
  #
@@ -91,13 +91,19 @@ class CAFrame
91
91
  @recno = 0
92
92
  end
93
93
 
94
- # Fields of the next non-blank record, or nil at EOF.
95
- def read(io)
94
+ # Fields of the next record, or nil at EOF. A blank line carries no
95
+ # separator, so it cannot be a row of a file with more than one column
96
+ # and is skipped as noise between records. In a single-column file it
97
+ # is the only spelling a missing single field has -- which is what
98
+ # to_csv writes for a masked cell -- so the caller passes
99
+ # +blank_is_row: true+ once the column count is known to be one, and
100
+ # the empty record becomes a row of no fields for build_frame to pad.
101
+ def read(io, blank_is_row: false)
96
102
  loop do
97
103
  rec = CSVParser.read_record(io, @quote)
98
104
  return nil if rec.nil?
99
105
  @recno += 1
100
- next if rec.empty?
106
+ next if rec.empty? && !blank_is_row
101
107
  return rec.count(@quote).zero? ? simple(rec) : scan(rec)
102
108
  end
103
109
  end
@@ -195,7 +201,8 @@ class CAFrame
195
201
  # Consume the remaining records as data rows.
196
202
  def body
197
203
  rows = []
198
- while (fields = @tok.read(@io))
204
+ blank_is_row = @names && @names.size == 1
205
+ while (fields = @tok.read(@io, blank_is_row: blank_is_row))
199
206
  rows << fields
200
207
  end
201
208
  @rows = rows
@@ -45,14 +45,21 @@ class CAFrame
45
45
  @columns = {}
46
46
  @axis_name = axis_name || DEFAULT_AXIS_NAME
47
47
  @index = nil
48
+ # The row axis name this frame had before set_index promoted a column over
49
+ # it, so reset_index can put it back. A frame built with an index never
50
+ # had one, and neither does a frame derived from an indexed one: both are
51
+ # born indexed, so there is nothing to restore and the default stands.
52
+ @axis_name_before_index = nil
48
53
 
49
54
  n = nil
55
+ n_from = nil
50
56
  columns.each do |name, col|
51
57
  key = name.to_s
52
58
  ca = coerce_column(col)
53
59
  len = ca.shape[0]
54
60
  if n.nil?
55
- n = len
61
+ n = len
62
+ n_from = key
56
63
  elsif len != n
57
64
  raise ArgumentError,
58
65
  "column #{key.inspect} has axis-0 length #{len}, expected #{n}"
@@ -67,8 +74,12 @@ class CAFrame
67
74
  raise ArgumentError, "index must be a 1-D column (got ndim #{idx.ndim})"
68
75
  end
69
76
  if n && idx.shape[0] != n
77
+ # Name the column as well as the index: when the index is the frame's
78
+ # existing one and a column is the new arrival, blaming the index alone
79
+ # points at the side the caller cannot change.
70
80
  raise ArgumentError,
71
- "index length #{idx.shape[0]} does not match nrow #{n}"
81
+ "column #{n_from.inspect} has axis-0 length #{n}, " \
82
+ "but the index has length #{idx.shape[0]}"
72
83
  end
73
84
  @index = idx
74
85
  @nrow = idx.shape[0] if n.nil?
@@ -234,9 +245,10 @@ class CAFrame
234
245
  end
235
246
  end
236
247
 
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,
248
+ # Bind +key+ to +column+, adding the name when it is new. This is where a
249
+ # column arriving from outside the frame has its axis-0 length checked
250
+ # (§12-A); the conversions in verbs.rb rebind +@columns+ directly and rely on
251
+ # being length-preserving instead. It is a replacement rather than an edit,
240
252
  # which is what sets it apart from the rest: +fill+ / +mask_eq+ /
241
253
  # +df[rows] = UNDEF+ write to the shared column and are therefore visible
242
254
  # wherever it is held, while this binds the name to a different column and
@@ -307,6 +319,12 @@ class CAFrame
307
319
  # value kept, so the row stays identifiable), leaving a later, better-informed
308
320
  # pass to re-judge them. Definitely-true rows carry their values through
309
321
  # unchanged in both modes.
322
+ #
323
+ # The default is a view-frame. +keep_masked: true+ returns a **materialized**
324
+ # frame instead -- carrying the UNDEF forward means writing it into the
325
+ # result, which a view cannot do without masking the parent's rows. That holds
326
+ # whether or not the selector actually carries a masked cell, so the same call
327
+ # site does not switch between sharing and copying with the data.
310
328
  def filter(keep_masked: false)
311
329
  mask = yield(self)
312
330
  unless mask.is_a?(CArray) && mask.data_type == :boolean
@@ -330,7 +348,18 @@ class CAFrame
330
348
  unless idx.ndim == 1
331
349
  raise ArgumentError, "index must be a 1-D column (got ndim #{idx.ndim})"
332
350
  end
351
+ # Only the name held before the frame had an index: a second set_index must
352
+ # not overwrite it with the first index's name.
353
+ @axis_name_before_index = @axis_name if @index.nil?
333
354
  @columns.delete(key)
355
+ if @index
356
+ # Re-indexing replaces the index, so the one being replaced goes back to
357
+ # being a column -- the same demotion reset_index performs, in the same
358
+ # position, which makes set_index over an existing index equivalent to
359
+ # reset_index followed by set_index. Dropping it would lose the column it
360
+ # was made from.
361
+ @columns = { @axis_name => @index }.merge(@columns)
362
+ end
334
363
  @index = idx
335
364
  @axis_name = key
336
365
  @nrow = idx.shape[0]
@@ -344,7 +373,8 @@ class CAFrame
344
373
  return self unless @index
345
374
  @columns = { @axis_name => @index }.merge(@columns)
346
375
  @index = nil
347
- @axis_name = DEFAULT_AXIS_NAME
376
+ @axis_name = @axis_name_before_index || DEFAULT_AXIS_NAME
377
+ @axis_name_before_index = nil
348
378
  self
349
379
  end
350
380
 
@@ -370,6 +400,17 @@ class CAFrame
370
400
  # multi-row, frame-returning path). Positional access is +df[i]+.
371
401
  def at(label)
372
402
  raise ArgumentError, "at requires an index (set one with set_index)" unless @index
403
+ # An index may hold a masked cell -- an outer join and align both build one
404
+ # -- but an undefined label identifies no row, and two undefined labels are
405
+ # not the same label. The addressing primitives already say this (a masked
406
+ # key matches nothing, not another masked key), so at says it too rather
407
+ # than answering through eq(UNDEF), which asks about the mask and not about
408
+ # the label at all.
409
+ if UNDEF.equal?(label)
410
+ raise ArgumentError,
411
+ "at(UNDEF): an undefined label identifies no row; " \
412
+ "use filter { |f| f.index.is_masked } for the rows with no label"
413
+ end
373
414
  pos = @index.eq(label).where
374
415
  case pos.elements
375
416
  when 0
@@ -483,6 +524,19 @@ class CAFrame
483
524
  # and index are untouched; the selected cells of every column go to UNDEF.
484
525
  # The selector is forwarded to the column indexer, which classifies it.
485
526
  private def mask_rows(selector)
527
+ # Decide before changing anything. A read-only column (a categorical, whose
528
+ # codes are read-only) refuses the write, and refusing half way through
529
+ # would leave the frame masked in whichever columns happened to come first.
530
+ refusing = @columns.find { |_, col| col.read_only? }
531
+ if refusing
532
+ raise "can not modify read-only array: column #{refusing[0].inspect} " \
533
+ "refuses the write, so no column was masked"
534
+ end
535
+ # The selector is classified by the column indexer, which is also what
536
+ # bound-checks it -- so with no column to forward it to, nothing would.
537
+ # Build the row mask purely to have the frame's own row axis refuse an
538
+ # out-of-range row, as every other row form does.
539
+ selected_row_mask(selector) if @columns.empty?
486
540
  @columns.each_value do |col|
487
541
  col[selector, *([nil] * (col.ndim - 1))] = UNDEF
488
542
  end
@@ -540,7 +594,16 @@ class CAFrame
540
594
  new_index = splice_index(other, lo, hi)
541
595
  @columns = new_cols
542
596
  @index = new_index
543
- @nrow = @nrow - (hi - lo) + other.nrow
597
+ # Read the new count off something the frame now holds, rather than
598
+ # computing it. For every column the pieces welded above are head (lo) +
599
+ # other's rows + tail (nrow - hi), so the arithmetic and the objects agree
600
+ # -- except when there is nothing to weld: a frame with no columns and no
601
+ # index would otherwise come away claiming rows that nothing backs.
602
+ @nrow = if (witness = new_cols.each_value.first || new_index)
603
+ witness.shape[0]
604
+ else
605
+ 0
606
+ end
544
607
  self
545
608
  end
546
609
 
@@ -609,8 +672,11 @@ class CAFrame
609
672
  end
610
673
 
611
674
  private def select_rows(selector, keep_masked: false)
612
- if keep_masked && selector.is_a?(CArray) &&
613
- selector.data_type == :boolean && selector.has_mask?
675
+ if keep_masked && selector.is_a?(CArray) && selector.data_type == :boolean
676
+ # Not gated on selector.has_mask?: gating there would make the same call
677
+ # site return a view-frame or a materialized one depending on whether that
678
+ # run's data happened to produce an undetermined cell (the reason splice
679
+ # always snapshots -- see CAFrame.md section 3).
614
680
  return select_rows_keep_masked(selector)
615
681
  end
616
682
  cols = {}
@@ -631,7 +697,12 @@ class CAFrame
631
697
  g[undet_kept, *tail] = UNDEF
632
698
  cols[name] = g
633
699
  end
634
- new_index = @index ? @index[keep] : nil
700
+ # The index is copied along with the columns. Its values are carried over
701
+ # unmasked (an undetermined row keeps its label, as mask_rows leaves the
702
+ # index alone), but a materialized frame has to be materialized whole: an
703
+ # aliased index would write through to the parent from a frame whose columns
704
+ # do not.
705
+ new_index = @index ? @index[keep].copy : nil
635
706
  CAFrame.new(cols, axis_name: @axis_name, index: new_index)
636
707
  end
637
708
 
@@ -13,7 +13,7 @@ class CAFrame
13
13
  raise ArgumentError, "group_by needs at least one key" if keys.empty?
14
14
  cat = grouping_categorical(keys)
15
15
  axis = keys.size == 1 && keys.first.is_a?(String) ? keys.first : "group"
16
- GroupedFrame.new(self, cat, axis)
16
+ GroupedFrame.new(self, cat, axis, keys.grep(String))
17
17
  end
18
18
 
19
19
  # Number of rows currently selected — used by group per-group view-frames
@@ -28,7 +28,14 @@ class CAFrame
28
28
  # values, categorized by content (the codes are composed from the
29
29
  # per-column keys).
30
30
  n = nrow
31
- CArray.object(n) { |i| cols.map { |c| c[i] } }.categorize
31
+ key = CArray.object(n) { |i| cols.map { |c| c[i] } }
32
+ # One undetermined component makes the whole tuple undetermined, the same
33
+ # answer a single masked key cell gets. Left as a value, the UNDEF inside
34
+ # the tuple would intern as an ordinary distinct key and the row would
35
+ # form a group of its own.
36
+ undetermined = CArray.boolean(n) { |i| key[i].any? { |v| UNDEF.equal?(v) } }
37
+ key[undetermined] = UNDEF if undetermined.any
38
+ key.categorize
32
39
  end
33
40
  end
34
41
 
@@ -56,10 +63,14 @@ end
56
63
  # aggregate -> declarative per-column reductions into a new frame
57
64
  # table { |g| }-> cross-column Ruby escape, g is a per-group view-frame
58
65
  class GroupedFrame
59
- def initialize(frame, cat, axis_name)
66
+ # +key_names+ are the frame columns the grouping was keyed on. They become
67
+ # the result's index, so the reduction shortcuts must not also return them as
68
+ # reduced columns; an external CArray key contributes no name.
69
+ def initialize(frame, cat, axis_name, key_names = [])
60
70
  @frame = frame
61
71
  @cat = cat
62
72
  @axis_name = axis_name
73
+ @key_names = key_names
63
74
  @labels = cat.labels # group values, in code order
64
75
  end
65
76
 
@@ -124,6 +135,23 @@ class GroupedFrame
124
135
 
125
136
  # Convenience reductions over every numeric scalar column (memo §6-4
126
137
  # "grp.mean"). Non-numeric / N-D columns are skipped.
138
+ #
139
+ # @!method sum
140
+ # Returns a frame of the per-group sum of every numeric one-dimensional
141
+ # column. Non-numeric and multi-dimensional columns are left out.
142
+ # @return [CAFrame] one row per group, indexed by the group labels.
143
+ # @!method mean
144
+ # Returns a frame of the per-group arithmetic mean of every numeric
145
+ # one-dimensional column, as {#sum} does.
146
+ # @return [CAFrame] one row per group.
147
+ # @!method min
148
+ # Returns a frame of the per-group minimum of every numeric
149
+ # one-dimensional column, as {#sum} does.
150
+ # @return [CAFrame] one row per group.
151
+ # @!method max
152
+ # Returns a frame of the per-group maximum of every numeric
153
+ # one-dimensional column, as {#sum} does.
154
+ # @return [CAFrame] one row per group.
127
155
  [:sum, :mean, :min, :max].each do |red|
128
156
  define_method(red) { reduce_numeric(red) }
129
157
  end
@@ -143,6 +171,11 @@ class GroupedFrame
143
171
  private def reduce_numeric(reduction)
144
172
  cols = {}
145
173
  @frame.variable_names.each do |name|
174
+ # A key column is the index here, not a result column. Filtering it out
175
+ # by name rather than by data type is what makes a numeric key behave
176
+ # like a string one: NON_NUMERIC is about which columns a reduction can
177
+ # apply to, which happened to cover string keys and nothing else.
178
+ next if @key_names.include?(name)
146
179
  col = @frame[name]
147
180
  next unless col.ndim == 1 && !NON_NUMERIC.include?(col.data_type)
148
181
  cols[name] = col.group_by_category(@cat).public_send(reduction)