namo 0.15.0 → 0.17.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d2d5b27fc7c414edd9d13ac80d87bc6f99c87b2f94a42b8f192c3e5a4f08daa
4
- data.tar.gz: 91ff974cb3c20802cf0afea77563211a1d29abda66bf9960ed18611fbaf09410
3
+ metadata.gz: f84d86032e9397e8db4a4d7a4150474c30215cf9339ec2158a38802aad1a4d49
4
+ data.tar.gz: 830b874e04b36acd7ab848351419ed5ceb848e0108b95602184b7e6131812e01
5
5
  SHA512:
6
- metadata.gz: f309aa497bde5bd05083f844f8d123257917da3e74abe07e4e18a8aff3dd362ab0e2c2b4b6df0791cb3a1b55ea53f9f7914a89eda74c2d158c0d7e5cf1e6b358
7
- data.tar.gz: 62040149299672f673de4216cc7123e78e0135902d5a5ad3c948e74e7d2906b184b9ac6a6e84238f000d85a63b3ccc0b6dc894c3cdb74a65fb6ec1d7df0d6e33
6
+ metadata.gz: 1b409a5004f78619ea60186404e6f156fadbc660755cc9fc3c64cfe8a028a3ffdd7bb35dd252510ece1814674f0b02af7b347b1ea42c1280390e7d7a982f0de0
7
+ data.tar.gz: 15c2c28dafa605ee5a93e5ebb3bee4e5a1f8301dca7cfb11675e1f197db62052975a0f9b74a9096638062b4d40cd85b5752ce5e52c3b0c5f39463fed421aec6c
data/CHANGELOG CHANGED
@@ -1,6 +1,100 @@
1
1
  CHANGELOG
2
2
  _________
3
3
 
4
+ 20260613
5
+ 0.17.0: + parameterised formulae — formulae with required parameters beyond (row, namo) receive arguments at access time through Row#[].
6
+
7
+ 1. ~ lib/Namo/Row.rb: Row#[] gains a trailing splat and forwards call-site arguments. Dispatch
8
+ generalises from exact arity 2 to required-parameter count via the new private
9
+ collection_scoped? and required_parameter_count — one required parameter or none stays
10
+ row-scoped, two or more call formula.call(self, @namo, *arguments). Settles the 0.15.0
11
+ negative-arity deferral: |row, *rest| and ->(row, namo = nil){} stay row-scoped;
12
+ |row, namo, *fields| is collection-scoped with optional arguments.
13
+ 2. ~ lib/Namo/Row.rb: + argument-count enforcement — the new private expected_argument_counts
14
+ and raise_unless_expected_arguments raise ArgumentError ("wrong number of arguments for
15
+ :sma (given 0, expected 2)") on the wrong count for any dimension: too few or too many for
16
+ a fixed-arity formula, fewer than required for a splatted one, any at all for a data
17
+ dimension, a row-scoped formula, or a two-arity formula. Checked before the Namo-context
18
+ guard, whose message generalises from "two-arity formula" to "collection-scoped formula".
19
+ 3. ~ lib/namo.rb: the no-arg values (and so coordinates and to_h) materialise via the new
20
+ private materialisable_dimensions, omitting formulae that require arguments (private
21
+ requires_arguments? and required_parameter_count); explicit asks — values(:dim),
22
+ coordinates(:dim), naming the dimension in a projection, selecting on it — raise through
23
+ Row#[]. dimensions and derived_dimensions still list the name; the empty-Namo case returns
24
+ [] without invoking the formula.
25
+ 4. ~ test/Namo/Row_test.rb: + "#[] parameterised formulae" describe — arity 3 and 4 receive
26
+ (row, namo, args...), trailing-splat forwarding (arity -3 and -4), one-required-parameter
27
+ procs stay row-scoped, a row-scoped formula calling a parameterised one with arguments,
28
+ missing-Namo-context raise; + "#[] argument-count enforcement" describe — the message
29
+ matrix for too few, too many, splat minimum, and arguments handed to data dimensions,
30
+ row-scoped, two-arity, and missing dimensions.
31
+ 5. ~ test/namo_test.rb: + "#[]= parameterised formulae" describe — resolution with arguments
32
+ through yielded Rows, one definition serving different fields and periods, Enumerable
33
+ predicates, a one-arity formula referencing a parameterised one, dimension listing, bulk
34
+ views omitting, explicit values/coordinates/projection/selection raises, carry through
35
+ contraction, selection (windowing over the filtered rows), and set operators, the
36
+ one-arity wrapper materialisation idiom, namo-plus-splat formulae materialising in the
37
+ bulk views, and the empty-Namo case.
38
+ 6. ~ README.md: + Parameterised formulae subsection under Formulae — access-time arguments,
39
+ the required-parameter scope rule, argument-count enforcement, materialisation behaviour,
40
+ and the wrapper idiom.
41
+ 7. ~ ROADMAP.md: Promote 0.17.0 to shipped with the dispatch-rule, enforcement, and
42
+ materialisation rationale; Current state -> 0.17.0; Summary folds in parameterised
43
+ formulae; next phase -> Namo::Collection (0.18.0). Date bumped.
44
+ 8. ~ COMPARISON.md: Parameterised formulae -> shipped (0.17.0), the entry noting access-time
45
+ arguments and argument-count enforcement. Date bumped.
46
+ 9. ~ EXAMPLES.md: The finance section's composition blocks (all three Namo stages) corrected
47
+ from max_by, which returns a Row, to sort_by + last(1), which returns the Namo the 0.14.0
48
+ block contract requires — the 1.x stage, parameterised sma included, now runs end to end
49
+ against this release. The finance highlight notes the parameterised formula as shipped
50
+ (0.17.0). Date bumped.
51
+ 10. ~ Namo::VERSION: /0.16.0/0.17.0/
52
+
53
+ 20260612
54
+ 0.16.0: ~ data/formula exclusivity — projection drops the formulae it materialises; * and ** raise on a data/formula name collision.
55
+
56
+ 1. ~ lib/namo.rb: Namo#[]'s positive-projection branch carries @formulae minus the projected
57
+ derived names — naming a derived dimension materialises it (stored values, computed against
58
+ the yielding Namo, windowed over any same-call selection) and drops the formula; omitted
59
+ formulae carry live and recompute from the result's own rows. Contraction and selection-only
60
+ calls are unchanged. The projection list is the materialise/live selector.
61
+ 2. ~ lib/namo.rb: Namo#* and Namo#** raise ArgumentError via the new private
62
+ raise_unless_data_formula_exclusivity when one operand's data dimension is the other's
63
+ derived dimension, block and no-block forms alike. Formula-vs-formula stays left-wins; the
64
+ set operators need no guard (matching-data-dimensions blocks the asymmetric case); the
65
+ constructor stays unguarded.
66
+ 3. ~ test/namo_test.rb: + "data/formula exclusivity" describe — access-path agreement on a
67
+ materialised dimension, dimension-listing, dependent-formula carry, omitted-formula
68
+ liveness, live-without-inputs caveat, two-arity windowing at materialisation,
69
+ contraction/selection unchanged, subclass type, composition collision raises (both
70
+ directions, both operators, block forms), left-wins formula merge, contraction-first
71
+ resolution.
72
+ 4. ~ test/namo_test.rb: + "range selection" context under #[] — basic range, beginless and
73
+ endless forms, range composed with projection, range on a formula-defined dimension
74
+ (Row_test holds the predicate matrix; these pin the Namo-level wiring).
75
+ 5. ~ README.md: + Projection of derived dimensions subsection under Formulae (naming
76
+ materialises, omitting carries live); data/formula collision sentences under Composition
77
+ and Cartesian product.
78
+ 6. ~ ROADMAP.md: Promote 0.16.0 to shipped; Current state -> 0.16.0; Summary folds in
79
+ exclusivity; next phase -> 0.17.0. window.length -> window.count and n.length -> n.count
80
+ in the remaining future-release examples (0.17.0, 2.x, 4.x) — Namo has no #length,
81
+ extending the 0.15.0 correction.
82
+ 7. ~ COMPARISON.md: Repoint the pre-renumbering planned markers to what shipped — proc-based,
83
+ regex-based, and mixed selection -> shipped (0.8.0); Enumerable methods return Namos ->
84
+ shipped (0.11.0), with the entry summary's parity sentence and the Sorting entry's
85
+ "as of 0.14.0" corrected to 0.11.0; values and to_h -> shipped (0.7.0). Aspect classes ->
86
+ not planned, the entry rewritten to record 0.7.0's plain-return-types decision (Namo#===
87
+ and subclassing cover case dispatch; a Matcher factory can serve a finer split later).
88
+ Aggregation repointed from 2.x to group_by returning a Namo::Collection at 0.19.0 (gated
89
+ on Collection at 0.18.0), with summary/members examples; bare names stay 2.x. Parameterised
90
+ formulae stays planned (0.17.0), its example's window.length -> window.count.
91
+ 8. ~ EXAMPLES.md: + Epidemiology / public health section — a cross-row (two-arity) rolling
92
+ weekly average in the four-stage format (Polars, then Namo 1.x/2.x/3.x), with the
93
+ (row, namo) window over the yielding Namo and a one-arity formula referencing the
94
+ two-arity one. window.length -> window.count in the finance 1.x and 2.x stages. Date
95
+ bumped to 20260612.
96
+ 9. ~ Namo::VERSION: /0.15.0/0.16.0/
97
+
4
98
  20260612
5
99
  0.15.0: + two-arity formulae — procs with arity 2 receive (row, namo) for cross-row computation.
6
100
 
data/README.md CHANGED
@@ -353,7 +353,7 @@ ohlcv * fundamentals
353
353
 
354
354
  Inner-join semantics: unmatched rows from either side are dropped. Output dimensions are `self.data_dimensions` followed by `other.data_dimensions` exclusive to other. Duplicates on shared coordinates are preserved multiplicatively — output multiplicity is the product of input multiplicities on each matching key.
355
355
 
356
- The two Namos must have at least one shared data dimension. No overlap raises an `ArgumentError` — the asymmetry with `**` is deliberate, and falling through to a Cartesian product would silently turn a logic error into a large pile of nonsense rows. Formulae merge from both sides; the left-hand side wins on conflict.
356
+ The two Namos must have at least one shared data dimension. No overlap raises an `ArgumentError` — the asymmetry with `**` is deliberate, and falling through to a Cartesian product would silently turn a logic error into a large pile of nonsense rows. Formulae merge from both sides; the left-hand side wins on conflict. A name that is data on one side and a formula on the other also raises an `ArgumentError` — the operands disagree about what the name means, with no last-write order to appeal to — so resolve before composing: `audited[-:margin] * modelled`.
357
357
 
358
358
  #### Conditional join
359
359
 
@@ -404,7 +404,7 @@ products ** quarters
404
404
 
405
405
  Output has `self.data.length * other.data.length` rows. Output dimensions are `self.data_dimensions + other.data_dimensions`, in operand order. Duplicates are preserved multiplicatively.
406
406
 
407
- The two Namos must have **no** shared data dimensions — the precondition is the mirror image of `*`. Any overlap raises an `ArgumentError`; allowing it would produce rows with the same dimension named twice. Formulae merge from both sides; the left-hand side wins on conflict.
407
+ The two Namos must have **no** shared data dimensions — the precondition is the mirror image of `*`. Any overlap raises an `ArgumentError`; allowing it would produce rows with the same dimension named twice. Formulae merge from both sides; the left-hand side wins on conflict, and a data/formula name collision between the operands raises, as for `*`.
408
408
 
409
409
  The visual relationship is intentional: `*` is the filtered version, `**` is the explosive version — more sigil, more output.
410
410
 
@@ -644,6 +644,20 @@ sales[product: 'Widget'][:revenue, :quarter]
644
644
 
645
645
  Formulae carry through selection — a filtered Namo instance remembers its formulae.
646
646
 
647
+ #### Projection of derived dimensions
648
+
649
+ Naming a derived dimension in a projection asks for its values: they are computed against the source and stored in the result's rows, and the formula is dropped — the name is a data dimension of the result. Omitting it carries the formula live, recomputing from the result's own rows on every access:
650
+
651
+ ```ruby
652
+ sales[:price, :quantity, :revenue].derived_dimensions
653
+ # => [] — :revenue is stored values, a snapshot taken at projection
654
+
655
+ sales[:price, :quantity].derived_dimensions
656
+ # => [:revenue] — :revenue recomputes from the projected rows on every access
657
+ ```
658
+
659
+ The projection list is the selector: name a derived dimension for a snapshot, omit it to keep it as computation. A carried formula whose inputs the projection dropped breaks on access — the same caveat as contracting away a formula's inputs.
660
+
647
661
  #### Cross-row formulae
648
662
 
649
663
  A formula's arity selects its calling convention. A proc with **one** parameter receives the row, as above. A proc with **two** parameters receives `(row, namo)`, where `namo` is the Namo the row belongs to — so the formula can reach beyond the current row to the rest of the collection. That's what cross-row computation needs: moving windows, ranks, running totals, anything whose value depends on the row's neighbours.
@@ -672,6 +686,36 @@ One-arity formulae are unchanged, and the two forms mix freely — a one-arity f
672
686
 
673
687
  Resolving a two-arity formula needs a Namo to window over. A `Row` constructed directly, without one, raises an `ArgumentError` naming the formula rather than letting the missing context surface as an unrelated error.
674
688
 
689
+ #### Parameterised formulae
690
+
691
+ A formula can declare parameters beyond `(row, namo)`. The arguments arrive at access time, through `Row#[]`, so one definition serves every column and every setting:
692
+
693
+ ```ruby
694
+ prices[:sma] = proc do |row, namo, field, period|
695
+ window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}].last(period)
696
+ window.sum{|r| r[field]} / window.count.to_f
697
+ end
698
+
699
+ prices.last[:sma, :close, 20] # 20-period moving average of close
700
+ prices.last[:sma, :volume, 50] # 50-period moving average of volume
701
+ ```
702
+
703
+ The number of *required* parameters decides a formula's calling convention. One means row-scoped, two or more means collection-scoped, and everything past the second receives the arguments given at the call site. A trailing splat or optional after `(row, namo)` makes the arguments optional — `proc{|row, namo, *fields|}` accepts any number, including none. A proc whose second parameter is optional (`->(row, namo = nil){...}`) requires only one, so it stays row-scoped.
704
+
705
+ Argument counts are enforced. Asking with the wrong number — too few for the formula's parameters, too many for a fixed-arity proc, or any at all for a data dimension or an unparameterised formula — raises an `ArgumentError` stating the counts, rather than letting `nil` flow into the formula body:
706
+
707
+ ```ruby
708
+ prices.last[:sma] # ArgumentError: wrong number of arguments for :sma (given 0, expected 2)
709
+ prices.last[:close, 20] # ArgumentError: wrong number of arguments for :close (given 1, expected 0)
710
+ ```
711
+
712
+ A formula that requires arguments can't be materialised without them. `values(:sma)`, `coordinates(:sma)`, naming `:sma` in a projection, and selecting on it all raise the same `ArgumentError`; the no-argument `values`, `coordinates`, and `to_h` omit the dimension, returning everything that can be materialised. `dimensions` and `derived_dimensions` still list it — it is queryable, with arguments. To materialise particular values, bind the arguments in a one-arity wrapper and ask for that:
713
+
714
+ ```ruby
715
+ prices[:sma_close_20] = proc{|row| row[:sma, :close, 20]}
716
+ prices[:date, :sma_close_20] # materialises per the usual projection rule
717
+ ```
718
+
675
719
  ### Polymorphic `[]=`
676
720
 
677
721
  `[]=` dispatches on the type of the value assigned. A proc registers a formula, as above. Anything else broadcasts the value to every row:
data/Rakefile CHANGED
@@ -21,7 +21,7 @@ namespace :docs do
21
21
  task :md2pdf => :md4print do
22
22
  Dir.glob('docs/*.print.md').each do |f|
23
23
  pdf = f.sub(/\.md$/, '.pdf')
24
- sh "pandoc #{f} --pdf-engine=xelatex -V geometry:margin=1in -V mainfont=Charter -V monofont=Menlo -o #{pdf}"
24
+ sh "pandoc #{f} --pdf-engine=xelatex --include-in-header script/print.preamble.tex -V geometry:margin=1in -V mainfont=Charter -V monofont=Menlo -o #{pdf}"
25
25
  end
26
26
  end
27
27
 
data/lib/Namo/Row.rb CHANGED
@@ -3,14 +3,15 @@
3
3
 
4
4
  class Namo
5
5
  class Row
6
- def [](name)
6
+ def [](name, *arguments)
7
+ raise_unless_expected_arguments(name, arguments)
7
8
  if @formulae.key?(name)
8
- case @formulae[name].arity
9
- when 2
9
+ formula = @formulae[name]
10
+ if collection_scoped?(formula)
10
11
  raise_unless_namo_context(name)
11
- @formulae[name].call(self, @namo)
12
+ formula.call(self, @namo, *arguments)
12
13
  else
13
- @formulae[name].call(self)
14
+ formula.call(self)
14
15
  end
15
16
  else
16
17
  @row[name]
@@ -56,9 +57,32 @@ class Namo
56
57
  @namo = namo
57
58
  end
58
59
 
60
+ def collection_scoped?(formula)
61
+ required_parameter_count(formula) >= 2
62
+ end
63
+
64
+ def required_parameter_count(formula)
65
+ formula.arity >= 0 ? formula.arity : -formula.arity - 1
66
+ end
67
+
68
+ def expected_argument_counts(name)
69
+ formula = @formulae[name]
70
+ return [0, 0] unless formula && collection_scoped?(formula)
71
+ minimum = required_parameter_count(formula) - 2
72
+ maximum = formula.arity >= 0 ? minimum : nil
73
+ [minimum, maximum]
74
+ end
75
+
76
+ def raise_unless_expected_arguments(name, arguments)
77
+ minimum, maximum = expected_argument_counts(name)
78
+ return if arguments.length >= minimum && (maximum.nil? || arguments.length <= maximum)
79
+ expected = maximum.nil? ? "#{minimum}+" : minimum.to_s
80
+ raise ArgumentError, "wrong number of arguments for #{name.inspect} (given #{arguments.length}, expected #{expected})"
81
+ end
82
+
59
83
  def raise_unless_namo_context(name)
60
84
  unless @namo
61
- raise ArgumentError, "two-arity formula #{name.inspect} requires a Namo context, but this Row has none"
85
+ raise ArgumentError, "collection-scoped formula #{name.inspect} requires a Namo context, but this Row has none"
62
86
  end
63
87
  end
64
88
  end
data/lib/Namo/VERSION.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # Namo::VERSION
3
3
 
4
4
  class Namo
5
- VERSION = '0.15.0'
5
+ VERSION = '0.17.0'
6
6
  end
data/lib/namo.rb CHANGED
@@ -28,7 +28,7 @@ class Namo
28
28
 
29
29
  def values(*dims)
30
30
  if dims.empty?
31
- dimensions.each_with_object({}){|dim, hash| hash[dim] = values_for(dim)}
31
+ materialisable_dimensions.each_with_object({}){|dim, hash| hash[dim] = values_for(dim)}
32
32
  elsif dims.length == 1
33
33
  values_for(dims.first)
34
34
  else
@@ -71,7 +71,8 @@ class Namo
71
71
  rows.map(&:to_h)
72
72
  end
73
73
  )
74
- self.class.new(projected, formulae: @formulae.dup)
74
+ carried = positive.any? ? @formulae.reject{|name, _| positive.include?(name)} : @formulae.dup
75
+ self.class.new(projected, formulae: carried)
75
76
  end
76
77
 
77
78
  def []=(name, value)
@@ -118,6 +119,7 @@ class Namo
118
119
  def *(other, &block)
119
120
  raise_unless_namo(other)
120
121
  raise_unless_shared_data_dimensions(other)
122
+ raise_unless_data_formula_exclusivity(other)
121
123
  shared = data_dimensions & other.data_dimensions
122
124
  combined_data = []
123
125
  @data.each do |left_row|
@@ -136,6 +138,7 @@ class Namo
136
138
  def **(other, &block)
137
139
  raise_unless_namo(other)
138
140
  raise_unless_disjoint_data_dimensions(other)
141
+ raise_unless_data_formula_exclusivity(other)
139
142
  combined_data = []
140
143
  @data.each do |left_row|
141
144
  if block
@@ -243,6 +246,19 @@ class Namo
243
246
  end
244
247
  end
245
248
 
249
+ def materialisable_dimensions
250
+ dimensions.reject{|dim| requires_arguments?(dim)}
251
+ end
252
+
253
+ def requires_arguments?(name)
254
+ formula = @formulae[name]
255
+ !!formula && required_parameter_count(formula) > 2
256
+ end
257
+
258
+ def required_parameter_count(formula)
259
+ formula.arity >= 0 ? formula.arity : -formula.arity - 1
260
+ end
261
+
246
262
  def raise_unless_namo(other)
247
263
  unless other.is_a?(Namo)
248
264
  raise TypeError, "can't compare Namo with #{other.class}"
@@ -266,4 +282,11 @@ class Namo
266
282
  raise ArgumentError, "dimensions in common, need no common dimensions: #{data_dimensions} vs #{other.data_dimensions}"
267
283
  end
268
284
  end
285
+
286
+ def raise_unless_data_formula_exclusivity(other)
287
+ collisions = (data_dimensions & other.derived_dimensions) | (derived_dimensions & other.data_dimensions)
288
+ if collisions.any?
289
+ raise ArgumentError, "name collision between data and formulae: #{collisions.inspect}"
290
+ end
291
+ end
269
292
  end
@@ -94,6 +94,111 @@ describe Namo::Row do
94
94
  end
95
95
  end
96
96
 
97
+ describe "#[] parameterised formulae" do
98
+ let(:namo) do
99
+ Namo.new([row_data])
100
+ end
101
+
102
+ let(:contextual_row) do
103
+ Namo::Row.new(row_data, formulae, namo)
104
+ end
105
+
106
+ it "calls an arity-3 formula with the Row, the yielding Namo, and one argument" do
107
+ seen = nil
108
+ formulae[:scaled] = ->(r, n, factor){seen = [r, n, factor]; r[:price] * factor}
109
+ _(contextual_row[:scaled, 3]).must_equal 30.0
110
+ _(seen[0]).must_be_same_as contextual_row
111
+ _(seen[1].equal?(namo)).must_equal true
112
+ _(seen[2]).must_equal 3
113
+ end
114
+
115
+ it "calls an arity-4 formula with two arguments" do
116
+ formulae[:metric] = ->(r, n, field, factor){r[field] * factor}
117
+ _(contextual_row[:metric, :quantity, 2]).must_equal 200
118
+ end
119
+
120
+ it "forwards a trailing splat's arguments past a required one (arity -4)" do
121
+ formulae[:dim] = proc{|r, n, field, *rest| [field, rest]}
122
+ _(contextual_row[:dim, :price]).must_equal [:price, []]
123
+ _(contextual_row[:dim, :price, 1, 2]).must_equal [:price, [1, 2]]
124
+ end
125
+
126
+ it "treats a splat directly after namo as collection-scoped taking any number of arguments (arity -3)" do
127
+ formulae[:dim] = proc{|r, n, *rest| rest}
128
+ _(contextual_row[:dim]).must_equal []
129
+ _(contextual_row[:dim, 1, 2, 3]).must_equal [1, 2, 3]
130
+ end
131
+
132
+ it "keeps a one-required-parameter proc row-scoped regardless of trailing optionals" do
133
+ seen = :unset
134
+ formulae[:dim] = ->(r, n = :fallback){seen = n; 1}
135
+ contextual_row[:dim]
136
+ _(seen).must_equal :fallback
137
+ end
138
+
139
+ it "lets a row-scoped formula call a parameterised formula with arguments" do
140
+ formulae[:metric] = ->(r, n, field, factor){r[field] * factor}
141
+ formulae[:double_quantity] = ->(r){r[:metric, :quantity, 2]}
142
+ _(contextual_row[:double_quantity]).must_equal 200
143
+ end
144
+
145
+ it "raises ArgumentError naming the formula when a parameterised formula has no Namo context" do
146
+ formulae[:metric] = ->(r, n, field){r[field]}
147
+ error = _(proc{row[:metric, :price]}).must_raise ArgumentError
148
+ _(error.message).must_match(/metric/)
149
+ end
150
+ end
151
+
152
+ describe "#[] argument-count enforcement" do
153
+ let(:namo) do
154
+ Namo.new([row_data])
155
+ end
156
+
157
+ let(:contextual_row) do
158
+ Namo::Row.new(row_data, formulae, namo)
159
+ end
160
+
161
+ it "raises when a parameterised formula is given too few arguments" do
162
+ formulae[:metric] = ->(r, n, field, period){r[field] * period}
163
+ error = _(proc{contextual_row[:metric, :price]}).must_raise ArgumentError
164
+ _(error.message).must_equal "wrong number of arguments for :metric (given 1, expected 2)"
165
+ end
166
+
167
+ it "raises when a fixed-arity parameterised formula is given too many arguments" do
168
+ formulae[:metric] = ->(r, n, field){r[field]}
169
+ error = _(proc{contextual_row[:metric, :price, 20]}).must_raise ArgumentError
170
+ _(error.message).must_equal "wrong number of arguments for :metric (given 2, expected 1)"
171
+ end
172
+
173
+ it "raises when a splatted parameterised formula is given fewer than its required arguments" do
174
+ formulae[:metric] = proc{|r, n, field, *rest| r[field]}
175
+ error = _(proc{contextual_row[:metric]}).must_raise ArgumentError
176
+ _(error.message).must_equal "wrong number of arguments for :metric (given 0, expected 1+)"
177
+ end
178
+
179
+ it "raises when arguments are given for a data dimension" do
180
+ error = _(proc{row[:price, 20]}).must_raise ArgumentError
181
+ _(error.message).must_equal "wrong number of arguments for :price (given 1, expected 0)"
182
+ end
183
+
184
+ it "raises when arguments are given for a row-scoped formula" do
185
+ formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
186
+ error = _(proc{row[:revenue, 20]}).must_raise ArgumentError
187
+ _(error.message).must_equal "wrong number of arguments for :revenue (given 1, expected 0)"
188
+ end
189
+
190
+ it "raises when arguments are given for a two-arity formula" do
191
+ formulae[:row_count] = ->(r, n){n.count}
192
+ error = _(proc{contextual_row[:row_count, 1]}).must_raise ArgumentError
193
+ _(error.message).must_equal "wrong number of arguments for :row_count (given 1, expected 0)"
194
+ end
195
+
196
+ it "raises when arguments are given for a missing dimension" do
197
+ error = _(proc{row[:missing, 1]}).must_raise ArgumentError
198
+ _(error.message).must_equal "wrong number of arguments for :missing (given 1, expected 0)"
199
+ end
200
+ end
201
+
97
202
  describe "#match?" do
98
203
  it "matches a single value" do
99
204
  _(row.match?(product: 'Widget')).must_equal true
data/test/namo_test.rb CHANGED
@@ -491,6 +491,36 @@ describe Namo do
491
491
  end
492
492
  end
493
493
 
494
+ context "range selection" do
495
+ it "selects rows whose value falls within the range" do
496
+ result = sales[price: 5.0..15.0]
497
+ _(result.to_a.count).must_equal 2
498
+ _(result.to_a.map{|row| row[:product]}).must_equal ['Widget', 'Widget']
499
+ end
500
+
501
+ it "supports beginless and endless ranges" do
502
+ _(sales[price: ..15.0].to_a.count).must_equal 2
503
+ _(sales[quantity: 100..].to_a.count).must_equal 2
504
+ end
505
+
506
+ it "composes with projection in a single call" do
507
+ result = sales[:product, :quantity, quantity: 50..120]
508
+ _(result.to_a).must_equal [
509
+ {product: 'Widget', quantity: 100},
510
+ {product: 'Gadget', quantity: 60}
511
+ ]
512
+ end
513
+
514
+ it "selects on a formula-defined dimension" do
515
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
516
+ result = sales[revenue: 1200.0..]
517
+ _(result.to_a).must_equal [
518
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
519
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
520
+ ]
521
+ end
522
+ end
523
+
494
524
  context "mixed proc and regex selection" do
495
525
  it "combines a proc and a regex across dimensions" do
496
526
  result = sales[product: /^W/, quantity: ->(v){v > 100}]
@@ -767,6 +797,299 @@ describe Namo do
767
797
  end
768
798
  end
769
799
 
800
+ describe "#[]= parameterised formulae" do
801
+ let(:price_data) do
802
+ [
803
+ {symbol: 'AAA', date: 1, close: 10.0, volume: 100},
804
+ {symbol: 'AAA', date: 2, close: 20.0, volume: 200},
805
+ {symbol: 'AAA', date: 3, close: 30.0, volume: 300},
806
+ ]
807
+ end
808
+
809
+ let(:prices) do
810
+ Namo.new(price_data)
811
+ end
812
+
813
+ # A parameterised moving average: the field and the window length arrive at
814
+ # access time, so one definition serves every column and every period.
815
+ let(:sma) do
816
+ proc do |row, namo, field, period|
817
+ window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}].last(period)
818
+ window.sum{|r| r[field]} / window.count.to_f
819
+ end
820
+ end
821
+
822
+ it "resolves with arguments through a yielded Row" do
823
+ prices[:sma] = sma
824
+ _(prices.first[:sma, :close, 2]).must_equal 10.0
825
+ _(prices.last[:sma, :close, 2]).must_equal 25.0
826
+ end
827
+
828
+ it "serves different fields and periods from one definition" do
829
+ prices[:sma] = sma
830
+ _(prices.last[:sma, :close, 3]).must_equal 20.0
831
+ _(prices.last[:sma, :volume, 2]).must_equal 250.0
832
+ end
833
+
834
+ it "resolves inside an Enumerable predicate" do
835
+ prices[:sma] = sma
836
+ result = prices.select{|row| row[:sma, :close, 2] > 12.0}
837
+ _(result).must_be_kind_of Namo
838
+ _(result.values(:date)).must_equal [2, 3]
839
+ end
840
+
841
+ it "lets a one-arity formula reference a parameterised formula with arguments" do
842
+ prices[:sma] = sma
843
+ prices[:rising] = proc{|row| row[:sma, :close, 1] > row[:sma, :close, 3]}
844
+ _(prices.values(:rising)).must_equal [false, true, true]
845
+ end
846
+
847
+ it "lists a parameterised dimension in dimensions and derived_dimensions" do
848
+ prices[:sma] = sma
849
+ _(prices.dimensions).must_equal [:symbol, :date, :close, :volume, :sma]
850
+ _(prices.derived_dimensions).must_equal [:sma]
851
+ end
852
+
853
+ it "omits a parameterised dimension from the no-arg values, coordinates, and to_h" do
854
+ prices[:sma] = sma
855
+ _(prices.values.keys).must_equal [:symbol, :date, :close, :volume]
856
+ _(prices.coordinates.keys).must_equal [:symbol, :date, :close, :volume]
857
+ _(prices.to_h.keys).must_equal [:symbol, :date, :close, :volume]
858
+ end
859
+
860
+ it "raises when values is asked for a parameterised dimension by name" do
861
+ prices[:sma] = sma
862
+ error = _(proc{prices.values(:sma)}).must_raise ArgumentError
863
+ _(error.message).must_equal "wrong number of arguments for :sma (given 0, expected 2)"
864
+ end
865
+
866
+ it "raises when coordinates is asked for a parameterised dimension by name" do
867
+ prices[:sma] = sma
868
+ _(proc{prices.coordinates(:sma)}).must_raise ArgumentError
869
+ end
870
+
871
+ it "raises when a projection names a parameterised dimension" do
872
+ prices[:sma] = sma
873
+ _(proc{prices[:date, :sma]}).must_raise ArgumentError
874
+ end
875
+
876
+ it "raises when a selection selects on a parameterised dimension" do
877
+ prices[:sma] = sma
878
+ _(proc{prices[sma: ->(v){v > 12.0}]}).must_raise ArgumentError
879
+ end
880
+
881
+ it "carries a parameterised formula through contraction" do
882
+ prices[:sma] = sma
883
+ contracted = prices[-:volume]
884
+ _(contracted.derived_dimensions).must_equal [:sma]
885
+ _(contracted.first[:sma, :close, 2]).must_equal 10.0
886
+ end
887
+
888
+ it "carries a parameterised formula through selection, windowing over the filtered rows" do
889
+ prices[:sma] = sma
890
+ filtered = prices[date: 2..3]
891
+ _(filtered.first[:sma, :close, 2]).must_equal 20.0
892
+ end
893
+
894
+ it "materialises through a one-arity wrapper that binds the arguments" do
895
+ prices[:sma] = sma
896
+ prices[:sma_close_2] = proc{|row| row[:sma, :close, 2]}
897
+ _(prices.values(:sma_close_2)).must_equal [10.0, 15.0, 25.0]
898
+ _(prices[:date, :sma_close_2].values(:sma_close_2)).must_equal [10.0, 15.0, 25.0]
899
+ end
900
+
901
+ it "includes a namo-plus-splat formula in the bulk views, called with no extra arguments" do
902
+ prices[:flexible] = proc{|row, namo, *rest| rest.empty? ? namo.count : rest.sum}
903
+ _(prices.values.keys).must_include :flexible
904
+ _(prices.values(:flexible)).must_equal [3, 3, 3]
905
+ _(prices.first[:flexible, 1, 2, 4]).must_equal 7
906
+ end
907
+
908
+ it "carries a parameterised formula through a set-operator result" do
909
+ a = Namo.new(price_data.take(2))
910
+ b = Namo.new([price_data.last])
911
+ a[:sma] = sma
912
+ _((a + b).last[:sma, :close, 2]).must_equal 25.0
913
+ end
914
+
915
+ it "returns [] for a parameterised dimension on an empty Namo without invoking the formula" do
916
+ invoked = false
917
+ empty = Namo.new([], formulae: {sma: ->(row, namo, field, period){invoked = true; 0}})
918
+ _(empty.values(:sma)).must_equal []
919
+ _(invoked).must_equal false
920
+ end
921
+ end
922
+
923
+ describe "data/formula exclusivity" do
924
+ context "projection" do
925
+ let(:price_data) do
926
+ [
927
+ {symbol: 'AAA', date: 1, close: 10.0},
928
+ {symbol: 'AAA', date: 2, close: 20.0},
929
+ {symbol: 'AAA', date: 3, close: 30.0},
930
+ ]
931
+ end
932
+
933
+ let(:prices) do
934
+ Namo.new(price_data)
935
+ end
936
+
937
+ let(:sma) do
938
+ ->(row, namo){
939
+ window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}]
940
+ window.values(:close).sum / window.count.to_f
941
+ }
942
+ end
943
+
944
+ it "agrees across all access paths on a materialised dimension" do
945
+ prices[:sma] = sma
946
+ projected = prices[:date, :sma]
947
+ _(projected.values(:sma)).must_equal [10.0, 15.0, 20.0]
948
+ _(projected.first[:sma]).must_equal projected.values(:sma).first
949
+ _(projected[sma: ->(v){v > 12.0}].values(:date)).must_equal [2, 3]
950
+ end
951
+
952
+ it "lists a materialised dimension as data, not derived, exactly once" do
953
+ prices[:sma] = sma
954
+ projected = prices[:date, :sma]
955
+ _(projected.data_dimensions).must_include :sma
956
+ _(projected.derived_dimensions).wont_include :sma
957
+ _(projected.dimensions.count(:sma)).must_equal 1
958
+ end
959
+
960
+ it "carries a dependent formula not named in the projection, resolving off the materialised column" do
961
+ prices[:sma] = sma
962
+ prices[:double_sma] = ->(row){row[:sma] * 2}
963
+ projected = prices[:date, :sma]
964
+ _(projected.derived_dimensions).must_equal [:double_sma]
965
+ _(projected.values(:double_sma)).must_equal [20.0, 30.0, 40.0]
966
+ end
967
+
968
+ it "carries an omitted formula live, recomputing from the result's own rows" do
969
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
970
+ projected = sales[:price, :quantity]
971
+ _(projected.derived_dimensions).must_equal [:revenue]
972
+ _(projected.values(:revenue)).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
973
+ projected.data.first[:quantity] = 200
974
+ _(projected.values(:revenue).first).must_equal 2000.0
975
+ end
976
+
977
+ it "breaks on access when a carried formula's inputs were dropped (caveat emptor)" do
978
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
979
+ projected = sales[:product]
980
+ _(projected.derived_dimensions).must_equal [:revenue]
981
+ _ { projected.values(:revenue) }.must_raise NoMethodError
982
+ end
983
+
984
+ it "materialises a two-arity formula windowed over the yielding Namo" do
985
+ prices[:sma] = sma
986
+ _(prices[:date, :sma].values(:sma)).must_equal [10.0, 15.0, 20.0]
987
+ end
988
+
989
+ it "windows a two-arity materialisation over a same-call selection" do
990
+ prices[:sma] = sma
991
+ projected = prices[:date, :sma, date: 2..3]
992
+ _(projected.values(:sma)).must_equal [20.0, 25.0]
993
+ end
994
+
995
+ it "carries all formulae through a selection-only call" do
996
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
997
+ result = sales[price: ..15.0]
998
+ _(result.derived_dimensions).must_equal [:revenue]
999
+ _(result.values(:revenue)).must_equal [1000.0, 1500.0]
1000
+ end
1001
+
1002
+ it "carries all formulae through contraction" do
1003
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
1004
+ result = sales[-:quarter]
1005
+ _(result.derived_dimensions).must_equal [:revenue]
1006
+ _(result.values(:revenue)).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
1007
+ end
1008
+
1009
+ it "returns pure materialised values and empty formulae when only derived names are projected" do
1010
+ prices[:sma] = sma
1011
+ projected = prices[:sma]
1012
+ _(projected.to_a).must_equal [{sma: 10.0}, {sma: 15.0}, {sma: 20.0}]
1013
+ _(projected.formulae).must_equal({})
1014
+ end
1015
+
1016
+ it "returns an instance of self's class" do
1017
+ subclass = Class.new(Namo)
1018
+ namo = subclass.new([{x: 1}])
1019
+ namo[:double] = ->(row){row[:x] * 2}
1020
+ _(namo[:double].class).must_equal subclass
1021
+ end
1022
+ end
1023
+
1024
+ context "composition" do
1025
+ let(:audited) do
1026
+ Namo.new([
1027
+ {symbol: 'BHP', margin: 0.3},
1028
+ {symbol: 'RIO', margin: 0.25}
1029
+ ])
1030
+ end
1031
+
1032
+ let(:modelled) do
1033
+ namo = Namo.new([
1034
+ {symbol: 'BHP', price: 10.0, cost: 6.0},
1035
+ {symbol: 'RIO', price: 20.0, cost: 16.0}
1036
+ ])
1037
+ namo[:margin] = proc{|r| (r[:price] - r[:cost]) / r[:price]}
1038
+ namo
1039
+ end
1040
+
1041
+ let(:audited_orders) do
1042
+ Namo.new([{order: 'A', margin: 0.3}])
1043
+ end
1044
+
1045
+ let(:modelled_tiers) do
1046
+ namo = Namo.new([{tier: 'light', price: 10.0, cost: 6.0}])
1047
+ namo[:margin] = proc{|r| (r[:price] - r[:cost]) / r[:price]}
1048
+ namo
1049
+ end
1050
+
1051
+ it "raises on * when self's data dimension is other's derived dimension" do
1052
+ _ { audited * modelled }.must_raise ArgumentError
1053
+ end
1054
+
1055
+ it "raises on * when self's derived dimension is other's data dimension" do
1056
+ _ { modelled * audited }.must_raise ArgumentError
1057
+ end
1058
+
1059
+ it "raises on ** when self's data dimension is other's derived dimension" do
1060
+ _ { audited_orders ** modelled_tiers }.must_raise ArgumentError
1061
+ end
1062
+
1063
+ it "raises on ** when self's derived dimension is other's data dimension" do
1064
+ _ { modelled_tiers ** audited_orders }.must_raise ArgumentError
1065
+ end
1066
+
1067
+ it "raises in the block forms of both operators" do
1068
+ _ { audited.*(modelled){|row, candidates| candidates} }.must_raise ArgumentError
1069
+ _ { audited_orders.**(modelled_tiers){|row, candidates| candidates} }.must_raise ArgumentError
1070
+ end
1071
+
1072
+ it "names the colliding dimensions in the message" do
1073
+ err = _ { audited * modelled }.must_raise ArgumentError
1074
+ _(err.message).must_match(/name collision between data and formulae/)
1075
+ _(err.message).must_include ':margin'
1076
+ end
1077
+
1078
+ it "does not raise on a formula-vs-formula collision — left wins" do
1079
+ left = Namo.new([{symbol: 'BHP', close: 42.5}])
1080
+ right = Namo.new([{symbol: 'BHP', pe: 14.5}])
1081
+ left[:margin] = proc{|r| :left}
1082
+ right[:margin] = proc{|r| :right}
1083
+ _((left * right).values(:margin)).must_equal [:left]
1084
+ end
1085
+
1086
+ it "composes after explicit resolution by contraction" do
1087
+ result = audited[-:margin] * modelled
1088
+ _(result.values(:margin)).must_equal [0.4, 0.2]
1089
+ end
1090
+ end
1091
+ end
1092
+
770
1093
  describe "#each" do
771
1094
  it "yields Row objects" do
772
1095
  rows = []
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: namo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran