namo 0.14.0 → 0.16.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: 78880ca991afa35b970cc09625c4ca632db3a3ff69e7b4c69fda2fa3b536bd08
4
- data.tar.gz: b85bfe8ef28cf45880e8ebda997d209e7a4f6d545710bad52ec4f6e5c790e30f
3
+ metadata.gz: dcb7aafe4522ff115c12464016fef1a6909047975829523b095105f601a7ae60
4
+ data.tar.gz: 81f150f5f370f836734908a30e1ecd600b856f90c0151a698fa25bd117530d92
5
5
  SHA512:
6
- metadata.gz: c61419caff79be760cc24d3cd67eb008107061a65041853bd5108e51c70bf0fb100e50c899241b2ae45de59d92c9f7bcd6173eb2af279543c9057c186c3ec4ba
7
- data.tar.gz: 5b9fe7311512c5a59f6528d70a82724f35323769267a804784d14a83351da08cb62000feb6f676e396ecd45c79397413f754cb09c599e5204f28212e95caa872
6
+ metadata.gz: 6fd5a96f0fdf85e6ffb8a3cfa2d1a560d5da1d118072e95cd549af43149ff2a4c963de122b9026c29c0817e33fcfbd2ba3ece969ee1f6f5d92738c3d7f38b73a
7
+ data.tar.gz: 78ba07312d7e4f4d4ac85a6ca572852964ed7055d48687361d8a86f9cb998122f421e75418ba67bc978bf78d7b0cc22315ebb800ca734e8ade07817bae886736
data/CHANGELOG CHANGED
@@ -1,6 +1,86 @@
1
1
  CHANGELOG
2
2
  _________
3
3
 
4
+ 20260612
5
+ 0.16.0: ~ data/formula exclusivity — projection drops the formulae it materialises; * and ** raise on a data/formula name collision.
6
+
7
+ 1. ~ lib/namo.rb: Namo#[]'s positive-projection branch carries @formulae minus the projected
8
+ derived names — naming a derived dimension materialises it (stored values, computed against
9
+ the yielding Namo, windowed over any same-call selection) and drops the formula; omitted
10
+ formulae carry live and recompute from the result's own rows. Contraction and selection-only
11
+ calls are unchanged. The projection list is the materialise/live selector.
12
+ 2. ~ lib/namo.rb: Namo#* and Namo#** raise ArgumentError via the new private
13
+ raise_unless_data_formula_exclusivity when one operand's data dimension is the other's
14
+ derived dimension, block and no-block forms alike. Formula-vs-formula stays left-wins; the
15
+ set operators need no guard (matching-data-dimensions blocks the asymmetric case); the
16
+ constructor stays unguarded.
17
+ 3. ~ test/namo_test.rb: + "data/formula exclusivity" describe — access-path agreement on a
18
+ materialised dimension, dimension-listing, dependent-formula carry, omitted-formula
19
+ liveness, live-without-inputs caveat, two-arity windowing at materialisation,
20
+ contraction/selection unchanged, subclass type, composition collision raises (both
21
+ directions, both operators, block forms), left-wins formula merge, contraction-first
22
+ resolution.
23
+ 4. ~ test/namo_test.rb: + "range selection" context under #[] — basic range, beginless and
24
+ endless forms, range composed with projection, range on a formula-defined dimension
25
+ (Row_test holds the predicate matrix; these pin the Namo-level wiring).
26
+ 5. ~ README.md: + Projection of derived dimensions subsection under Formulae (naming
27
+ materialises, omitting carries live); data/formula collision sentences under Composition
28
+ and Cartesian product.
29
+ 6. ~ ROADMAP.md: Promote 0.16.0 to shipped; Current state -> 0.16.0; Summary folds in
30
+ exclusivity; next phase -> 0.17.0. window.length -> window.count and n.length -> n.count
31
+ in the remaining future-release examples (0.17.0, 2.x, 4.x) — Namo has no #length,
32
+ extending the 0.15.0 correction.
33
+ 7. ~ COMPARISON.md: Repoint the pre-renumbering planned markers to what shipped — proc-based,
34
+ regex-based, and mixed selection -> shipped (0.8.0); Enumerable methods return Namos ->
35
+ shipped (0.11.0), with the entry summary's parity sentence and the Sorting entry's
36
+ "as of 0.14.0" corrected to 0.11.0; values and to_h -> shipped (0.7.0). Aspect classes ->
37
+ not planned, the entry rewritten to record 0.7.0's plain-return-types decision (Namo#===
38
+ and subclassing cover case dispatch; a Matcher factory can serve a finer split later).
39
+ Aggregation repointed from 2.x to group_by returning a Namo::Collection at 0.19.0 (gated
40
+ on Collection at 0.18.0), with summary/members examples; bare names stay 2.x. Parameterised
41
+ formulae stays planned (0.17.0), its example's window.length -> window.count.
42
+ 8. ~ EXAMPLES.md: + Epidemiology / public health section — a cross-row (two-arity) rolling
43
+ weekly average in the four-stage format (Polars, then Namo 1.x/2.x/3.x), with the
44
+ (row, namo) window over the yielding Namo and a one-arity formula referencing the
45
+ two-arity one. window.length -> window.count in the finance 1.x and 2.x stages. Date
46
+ bumped to 20260612.
47
+ 9. ~ Namo::VERSION: /0.15.0/0.16.0/
48
+
49
+ 20260612
50
+ 0.15.0: + two-arity formulae — procs with arity 2 receive (row, namo) for cross-row computation.
51
+
52
+ 1. ~ lib/Namo/Row.rb: Row's constructor gains an optional third parameter, namo (default nil),
53
+ stored in @namo — the Namo that yielded the Row; the two-argument form keeps working.
54
+ Row#[] dispatches on formula arity: exactly 2 calls formula.call(self, @namo), raising
55
+ ArgumentError via the new private raise_unless_namo_context when @namo is nil; every
56
+ other arity (0, 1, negative) keeps the existing formula.call(self) unchanged.
57
+ 2. ~ lib/Namo/Enumerable.rb: every Row construction (each, select and its aliases, reject,
58
+ sort_by, first, last, take_while, drop_while, uniq's block form, partition) passes self
59
+ as the Row's namo, so two-arity formulae resolve during enumeration and predicate
60
+ evaluation, with the yielding Namo as the window.
61
+ 3. ~ lib/namo.rb: values_for's derived branch and the * and ** block paths pass self as the
62
+ Row's namo — values/coordinates/to_h resolve two-arity dimensions, and composition-block
63
+ rows can resolve self's two-arity formulae (extension of the 0.14.0 block contract; the
64
+ (Row, Namo) -> Namo contract and result-formulae rule are unchanged).
65
+ 4. ~ test/Namo/Row_test.rb: + constructor third-argument and backward-compatibility tests,
66
+ arity-dispatch tests (1, 2, 0, negative), the nil-namo ArgumentError, and match? on a
67
+ two-arity derived dimension.
68
+ 5. ~ test/namo_test.rb: + "#[]= two-arity formulae" describe — SMA cross-row resolution via
69
+ values/coordinates/to_h, selection and subset-predicate resolution, first/last Rows,
70
+ yielding-Namo (filtered-window) semantics, liveness, operator carry-through, composition-
71
+ block resolution, mixed-arity chains, and the empty-Namo case.
72
+ 6. ~ README.md: + Cross-row formulae subsection under Formulae, with the SMA example, the
73
+ yielding-Namo semantic, and the no-context error.
74
+ 7. ~ ROADMAP.md: Promote 0.15.0 to shipped; Current state -> 0.15.0; Summary folds in
75
+ two-arity formulae; the example's window.length corrected to window.count (Namo has
76
+ no length); "Live computation objects" phrasing updated. + upcoming 0.16.0 section,
77
+ Data/formula exclusivity — projection drops the formulae it materialises, * and **
78
+ raise on a data/formula name collision — with parameterised formulae renumbered to
79
+ 0.17.0, Namo::Collection to 0.18.0, and group_by to 0.19.0; next phase -> 0.16.0.
80
+ 8. ~ COMPARISON.md: Two-arity formulae -> shipped (0.15.0) with the same window.count
81
+ correction; Parameterised formulae repointed to planned (0.17.0). Date bumped.
82
+ 9. ~ Namo::VERSION: /0.14.0/0.15.0/
83
+
4
84
  20260608
5
85
  0.14.0: + blocks on the composition operators (*, **) for custom match refinement.
6
86
 
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,48 @@ 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
+
661
+ #### Cross-row formulae
662
+
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.
664
+
665
+ A simple moving average reads the surrounding rows through `namo`:
666
+
667
+ ```ruby
668
+ prices = Namo.new([
669
+ {symbol: 'AAA', date: 1, close: 10.0},
670
+ {symbol: 'AAA', date: 2, close: 20.0},
671
+ {symbol: 'AAA', date: 3, close: 30.0}
672
+ ])
673
+
674
+ prices[:sma] = proc{|row, namo|
675
+ window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}]
676
+ window.values(:close).sum / window.count.to_f
677
+ }
678
+
679
+ prices.values(:sma)
680
+ # => [10.0, 15.0, 20.0]
681
+ ```
682
+
683
+ `namo` is the Namo that yielded the row, live — so the window always reflects the current state of the object you ask through. A filtered Namo's rows window over the filtered rows; an operator result's rows window over the result. Appending a row changes every cross-row value on the next access, with no caching.
684
+
685
+ One-arity formulae are unchanged, and the two forms mix freely — a one-arity formula can reference a two-arity one, and a two-arity formula can reference a one-arity one, by name.
686
+
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.
688
+
647
689
  ### Polymorphic `[]=`
648
690
 
649
691
  `[]=` dispatches on the type of the value assigned. A proc registers a formula, as above. Anything else broadcasts the value to every row:
@@ -7,28 +7,28 @@ class Namo
7
7
 
8
8
  def each(&block)
9
9
  return enum_for(:each) unless block_given?
10
- @data.each{|row_data| block.call(Row.new(row_data, @formulae))}
10
+ @data.each{|row_data| block.call(Row.new(row_data, @formulae, self))}
11
11
  end
12
12
 
13
13
  def select(&block)
14
- self.class.new(@data.select{|row| block.call(Row.new(row, @formulae))}, formulae: @formulae.dup)
14
+ self.class.new(@data.select{|row| block.call(Row.new(row, @formulae, self))}, formulae: @formulae.dup)
15
15
  end
16
16
  alias_method :filter, :select
17
17
  alias_method :find_all, :select
18
18
 
19
19
  def reject(&block)
20
- self.class.new(@data.reject{|row| block.call(Row.new(row, @formulae))}, formulae: @formulae.dup)
20
+ self.class.new(@data.reject{|row| block.call(Row.new(row, @formulae, self))}, formulae: @formulae.dup)
21
21
  end
22
22
 
23
23
  def sort_by(&block)
24
- self.class.new(@data.sort_by{|row| block.call(Row.new(row, @formulae))}, formulae: @formulae.dup)
24
+ self.class.new(@data.sort_by{|row| block.call(Row.new(row, @formulae, self))}, formulae: @formulae.dup)
25
25
  end
26
26
 
27
27
  def first(n = nil)
28
28
  if n
29
29
  self.class.new(@data.first(n), formulae: @formulae.dup)
30
30
  else
31
- @data.first ? Row.new(@data.first, @formulae) : nil
31
+ @data.first ? Row.new(@data.first, @formulae, self) : nil
32
32
  end
33
33
  end
34
34
 
@@ -36,7 +36,7 @@ class Namo
36
36
  if n
37
37
  self.class.new(@data.last(n), formulae: @formulae.dup)
38
38
  else
39
- @data.last ? Row.new(@data.last, @formulae) : nil
39
+ @data.last ? Row.new(@data.last, @formulae, self) : nil
40
40
  end
41
41
  end
42
42
 
@@ -49,20 +49,20 @@ class Namo
49
49
  end
50
50
 
51
51
  def take_while(&block)
52
- self.class.new(@data.take_while{|row| block.call(Row.new(row, @formulae))}, formulae: @formulae.dup)
52
+ self.class.new(@data.take_while{|row| block.call(Row.new(row, @formulae, self))}, formulae: @formulae.dup)
53
53
  end
54
54
 
55
55
  def drop_while(&block)
56
- self.class.new(@data.drop_while{|row| block.call(Row.new(row, @formulae))}, formulae: @formulae.dup)
56
+ self.class.new(@data.drop_while{|row| block.call(Row.new(row, @formulae, self))}, formulae: @formulae.dup)
57
57
  end
58
58
 
59
59
  def uniq(&block)
60
- rows = block ? @data.uniq{|row| block.call(Row.new(row, @formulae))} : @data.uniq
60
+ rows = block ? @data.uniq{|row| block.call(Row.new(row, @formulae, self))} : @data.uniq
61
61
  self.class.new(rows, formulae: @formulae.dup)
62
62
  end
63
63
 
64
64
  def partition(&block)
65
- matches, non_matches = @data.partition{|row| block.call(Row.new(row, @formulae))}
65
+ matches, non_matches = @data.partition{|row| block.call(Row.new(row, @formulae, self))}
66
66
  [
67
67
  self.class.new(matches, formulae: @formulae.dup),
68
68
  self.class.new(non_matches, formulae: @formulae.dup),
data/lib/Namo/Row.rb CHANGED
@@ -5,7 +5,13 @@ class Namo
5
5
  class Row
6
6
  def [](name)
7
7
  if @formulae.key?(name)
8
- @formulae[name].call(self)
8
+ case @formulae[name].arity
9
+ when 2
10
+ raise_unless_namo_context(name)
11
+ @formulae[name].call(self, @namo)
12
+ else
13
+ @formulae[name].call(self)
14
+ end
9
15
  else
10
16
  @row[name]
11
17
  end
@@ -44,9 +50,16 @@ class Namo
44
50
 
45
51
  private
46
52
 
47
- def initialize(row, formulae)
53
+ def initialize(row, formulae, namo = nil)
48
54
  @row = row
49
55
  @formulae = formulae
56
+ @namo = namo
57
+ end
58
+
59
+ def raise_unless_namo_context(name)
60
+ unless @namo
61
+ raise ArgumentError, "two-arity formula #{name.inspect} requires a Namo context, but this Row has none"
62
+ end
50
63
  end
51
64
  end
52
65
  end
data/lib/Namo/VERSION.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # Namo::VERSION
3
3
 
4
4
  class Namo
5
- VERSION = '0.14.0'
5
+ VERSION = '0.16.0'
6
6
  end
data/lib/namo.rb CHANGED
@@ -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,13 +119,14 @@ 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|
124
126
  matched = other.data.select{|right_row| shared.all?{|dim| left_row[dim] == right_row[dim]}}
125
127
  if block
126
128
  candidates = other.class.new(matched, formulae: other.formulae.dup)
127
- chosen = block.call(Row.new(left_row, @formulae), candidates)
129
+ chosen = block.call(Row.new(left_row, @formulae, self), candidates)
128
130
  chosen.data.each{|right_row| combined_data << left_row.merge(right_row)}
129
131
  else
130
132
  matched.each{|right_row| combined_data << left_row.merge(right_row)}
@@ -136,11 +138,12 @@ 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
142
145
  candidates = other.class.new(other.data, formulae: other.formulae.dup)
143
- chosen = block.call(Row.new(left_row, @formulae), candidates)
146
+ chosen = block.call(Row.new(left_row, @formulae, self), candidates)
144
147
  chosen.data.each{|right_row| combined_data << left_row.merge(right_row)}
145
148
  else
146
149
  other.data.each{|right_row| combined_data << left_row.merge(right_row)}
@@ -229,11 +232,17 @@ class Namo
229
232
 
230
233
  private
231
234
 
235
+ def initialize(positional_data = nil, data: [], formulae: {}, name: nil)
236
+ @data = positional_data || data
237
+ @formulae = formulae
238
+ @name = name
239
+ end
240
+
232
241
  def values_for(dim)
233
242
  if data_dimensions.include?(dim)
234
243
  @data.map{|row_data| row_data[dim]}
235
244
  else
236
- @data.map{|row_data| Row.new(row_data, @formulae)[dim]}
245
+ @data.map{|row_data| Row.new(row_data, @formulae, self)[dim]}
237
246
  end
238
247
  end
239
248
 
@@ -261,9 +270,10 @@ class Namo
261
270
  end
262
271
  end
263
272
 
264
- def initialize(positional_data = nil, data: [], formulae: {}, name: nil)
265
- @data = positional_data || data
266
- @formulae = formulae
267
- @name = name
273
+ def raise_unless_data_formula_exclusivity(other)
274
+ collisions = (data_dimensions & other.derived_dimensions) | (derived_dimensions & other.data_dimensions)
275
+ if collisions.any?
276
+ raise ArgumentError, "name collision between data and formulae: #{collisions.inspect}"
277
+ end
268
278
  end
269
279
  end
@@ -40,6 +40,60 @@ describe Namo::Row do
40
40
  end
41
41
  end
42
42
 
43
+ describe "constructor" do
44
+ it "constructs from the two-argument form (namo defaults nil)" do
45
+ _(Namo::Row.new(row_data, formulae)).must_be_kind_of Namo::Row
46
+ end
47
+
48
+ it "accepts a third namo argument" do
49
+ namo = Namo.new([row_data])
50
+ _(Namo::Row.new(row_data, formulae, namo)).must_be_kind_of Namo::Row
51
+ end
52
+ end
53
+
54
+ describe "#[] arity dispatch" do
55
+ it "calls an arity-1 formula with the Row only" do
56
+ seen = nil
57
+ formulae[:dim] = ->(r){seen = r; 1}
58
+ row[:dim]
59
+ _(seen).must_be_same_as row
60
+ end
61
+
62
+ it "calls an arity-2 formula with the Row and the yielding Namo" do
63
+ namo = Namo.new([row_data])
64
+ row = Namo::Row.new(row_data, formulae, namo)
65
+ seen_row = nil
66
+ seen_namo = nil
67
+ formulae[:dim] = ->(r, n){seen_row = r; seen_namo = n; 1}
68
+ row[:dim]
69
+ _(seen_row).must_be_same_as row
70
+ _(seen_namo.equal?(namo)).must_equal true
71
+ end
72
+
73
+ it "takes the one-arity path for an arity-0 proc" do
74
+ formulae[:dim] = proc{42}
75
+ _(row[:dim]).must_equal 42
76
+ end
77
+
78
+ it "takes the one-arity path for a negative-arity proc" do
79
+ seen_rest = nil
80
+ formulae[:dim] = proc{|r, *rest| seen_rest = rest; 1}
81
+ row[:dim]
82
+ _(seen_rest).must_equal []
83
+ end
84
+
85
+ it "raises ArgumentError naming the formula when an arity-2 formula has no Namo context" do
86
+ formulae[:sma] = ->(r, n){n.count}
87
+ error = _(proc{row[:sma]}).must_raise ArgumentError
88
+ _(error.message).must_match(/sma/)
89
+ end
90
+
91
+ it "resolves an arity-1 formula on a Row with no Namo context" do
92
+ formulae[:revenue] = ->(r){r[:price] * r[:quantity]}
93
+ _(row[:revenue]).must_equal 1000.0
94
+ end
95
+ end
96
+
43
97
  describe "#match?" do
44
98
  it "matches a single value" do
45
99
  _(row.match?(product: 'Widget')).must_equal true
@@ -61,6 +115,14 @@ describe Namo::Row do
61
115
  _(row.match?(product: 'Widget', quarter: 'Q2')).must_equal false
62
116
  end
63
117
 
118
+ it "resolves a two-arity derived dimension when the Row carries a Namo" do
119
+ namo = Namo.new([row_data])
120
+ formulae[:row_count] = ->(r, n){n.count}
121
+ row = Namo::Row.new(row_data, formulae, namo)
122
+ _(row.match?(row_count: 1)).must_equal true
123
+ _(row.match?(row_count: 2)).must_equal false
124
+ end
125
+
64
126
  describe "Proc predicates" do
65
127
  it "matches when the proc returns true" do
66
128
  _(row.match?(price: ->(v){v < 15.0})).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}]
@@ -647,6 +677,296 @@ describe Namo do
647
677
  end
648
678
  end
649
679
 
680
+ describe "#[]= two-arity formulae" do
681
+ let(:price_data) do
682
+ [
683
+ {symbol: 'AAA', date: 1, close: 10.0},
684
+ {symbol: 'AAA', date: 2, close: 20.0},
685
+ {symbol: 'AAA', date: 3, close: 30.0},
686
+ ]
687
+ end
688
+
689
+ let(:prices) do
690
+ Namo.new(price_data)
691
+ end
692
+
693
+ # A simple moving average: the mean close over the rows of the same symbol
694
+ # up to and including the current row's date, computed against the Namo the
695
+ # row belongs to.
696
+ let(:sma) do
697
+ ->(row, namo){
698
+ window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}]
699
+ window.values(:close).sum / window.count.to_f
700
+ }
701
+ end
702
+
703
+ it "resolves a cross-row SMA via values" do
704
+ prices[:sma] = sma
705
+ _(prices.values(:sma)).must_equal [10.0, 15.0, 20.0]
706
+ end
707
+
708
+ it "resolves through coordinates" do
709
+ prices[:sma] = sma
710
+ _(prices.coordinates(:sma)).must_equal [10.0, 15.0, 20.0]
711
+ end
712
+
713
+ it "resolves through the no-arg to_h" do
714
+ prices[:sma] = sma
715
+ _(prices.to_h[:sma]).must_equal [10.0, 15.0, 20.0]
716
+ end
717
+
718
+ it "selects on the two-arity dimension" do
719
+ prices[:sma] = sma
720
+ _(prices[sma: ->(v){v > 12.0}].values(:date)).must_equal [2, 3]
721
+ end
722
+
723
+ it "resolves a two-arity dimension in a subset-method predicate" do
724
+ prices[:sma] = sma
725
+ result = prices.select{|row| row[:sma] > 12.0}
726
+ _(result).must_be_kind_of Namo
727
+ _(result.values(:date)).must_equal [2, 3]
728
+ end
729
+
730
+ it "resolves the two-arity formula on the no-arg first Row" do
731
+ prices[:sma] = sma
732
+ _(prices.first[:sma]).must_equal 10.0
733
+ end
734
+
735
+ it "resolves the two-arity formula on the no-arg last Row" do
736
+ prices[:sma] = sma
737
+ _(prices.last[:sma]).must_equal 20.0
738
+ end
739
+
740
+ it "windows over the yielding Namo — a filtered Namo computes over the filtered rows only" do
741
+ prices[:sma] = sma
742
+ filtered = prices.select{|row| row[:date] >= 2}
743
+ # On the full Namo the date-2 SMA averages dates 1 and 2 (15.0); on the
744
+ # filtered Namo it sees only date 2, so the value differs.
745
+ _(prices.values(:sma)).must_equal [10.0, 15.0, 20.0]
746
+ _(filtered.values(:sma)).must_equal [20.0, 25.0]
747
+ end
748
+
749
+ it "is live: appending a row changes the two-arity result on next access" do
750
+ prices[:sma] = sma
751
+ _(prices.last[:sma]).must_equal 20.0
752
+ prices.data << {symbol: 'AAA', date: 4, close: 40.0}
753
+ _(prices.last[:sma]).must_equal 25.0
754
+ end
755
+
756
+ it "carries a two-arity formula through a set-operator result, windowing over the combined rows" do
757
+ a = Namo.new([{symbol: 'AAA', date: 1}, {symbol: 'AAA', date: 2}])
758
+ b = Namo.new([{symbol: 'AAA', date: 3}])
759
+ a[:peers] = ->(row, namo){namo.count}
760
+ _((a + b).values(:peers)).must_equal [3, 3, 3]
761
+ end
762
+
763
+ it "carries a merged two-arity formula through a composition result, windowing over the joined rows" do
764
+ left = Namo.new([{symbol: 'AAA', date: 1}, {symbol: 'AAA', date: 2}])
765
+ right = Namo.new([{symbol: 'AAA', sector: 'tech'}])
766
+ left[:peers] = ->(row, namo){namo.count}
767
+ result = left * right
768
+ _(result.values(:peers)).must_equal [2, 2]
769
+ end
770
+
771
+ it "resolves a two-arity formula of self inside a composition block" do
772
+ left = Namo.new([{symbol: 'AAA', date: 1}, {symbol: 'AAA', date: 2}])
773
+ right = Namo.new([{symbol: 'AAA', sector: 'tech'}])
774
+ left[:peers] = ->(row, namo){namo.count}
775
+ seen = nil
776
+ left.*(right){|row, candidates| seen = row[:peers]; candidates}
777
+ _(seen).must_equal 2
778
+ end
779
+
780
+ it "lets a one-arity formula reference a two-arity formula by name" do
781
+ prices[:sma] = sma
782
+ prices[:double_sma] = ->(row){row[:sma] * 2}
783
+ _(prices.values(:double_sma)).must_equal [20.0, 30.0, 40.0]
784
+ end
785
+
786
+ it "lets a two-arity formula reference a one-arity formula by name" do
787
+ prices[:tenth] = ->(row){row[:close] / 10.0}
788
+ prices[:tenth_plus_count] = ->(row, namo){row[:tenth] + namo.count}
789
+ _(prices.values(:tenth_plus_count)).must_equal [4.0, 5.0, 6.0]
790
+ end
791
+
792
+ it "returns [] for a two-arity dimension on an empty Namo without invoking the formula" do
793
+ invoked = false
794
+ empty = Namo.new([], formulae: {sma: ->(row, namo){invoked = true; 0}})
795
+ _(empty.values(:sma)).must_equal []
796
+ _(invoked).must_equal false
797
+ end
798
+ end
799
+
800
+ describe "data/formula exclusivity" do
801
+ context "projection" do
802
+ let(:price_data) do
803
+ [
804
+ {symbol: 'AAA', date: 1, close: 10.0},
805
+ {symbol: 'AAA', date: 2, close: 20.0},
806
+ {symbol: 'AAA', date: 3, close: 30.0},
807
+ ]
808
+ end
809
+
810
+ let(:prices) do
811
+ Namo.new(price_data)
812
+ end
813
+
814
+ let(:sma) do
815
+ ->(row, namo){
816
+ window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}]
817
+ window.values(:close).sum / window.count.to_f
818
+ }
819
+ end
820
+
821
+ it "agrees across all access paths on a materialised dimension" do
822
+ prices[:sma] = sma
823
+ projected = prices[:date, :sma]
824
+ _(projected.values(:sma)).must_equal [10.0, 15.0, 20.0]
825
+ _(projected.first[:sma]).must_equal projected.values(:sma).first
826
+ _(projected[sma: ->(v){v > 12.0}].values(:date)).must_equal [2, 3]
827
+ end
828
+
829
+ it "lists a materialised dimension as data, not derived, exactly once" do
830
+ prices[:sma] = sma
831
+ projected = prices[:date, :sma]
832
+ _(projected.data_dimensions).must_include :sma
833
+ _(projected.derived_dimensions).wont_include :sma
834
+ _(projected.dimensions.count(:sma)).must_equal 1
835
+ end
836
+
837
+ it "carries a dependent formula not named in the projection, resolving off the materialised column" do
838
+ prices[:sma] = sma
839
+ prices[:double_sma] = ->(row){row[:sma] * 2}
840
+ projected = prices[:date, :sma]
841
+ _(projected.derived_dimensions).must_equal [:double_sma]
842
+ _(projected.values(:double_sma)).must_equal [20.0, 30.0, 40.0]
843
+ end
844
+
845
+ it "carries an omitted formula live, recomputing from the result's own rows" do
846
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
847
+ projected = sales[:price, :quantity]
848
+ _(projected.derived_dimensions).must_equal [:revenue]
849
+ _(projected.values(:revenue)).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
850
+ projected.data.first[:quantity] = 200
851
+ _(projected.values(:revenue).first).must_equal 2000.0
852
+ end
853
+
854
+ it "breaks on access when a carried formula's inputs were dropped (caveat emptor)" do
855
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
856
+ projected = sales[:product]
857
+ _(projected.derived_dimensions).must_equal [:revenue]
858
+ _ { projected.values(:revenue) }.must_raise NoMethodError
859
+ end
860
+
861
+ it "materialises a two-arity formula windowed over the yielding Namo" do
862
+ prices[:sma] = sma
863
+ _(prices[:date, :sma].values(:sma)).must_equal [10.0, 15.0, 20.0]
864
+ end
865
+
866
+ it "windows a two-arity materialisation over a same-call selection" do
867
+ prices[:sma] = sma
868
+ projected = prices[:date, :sma, date: 2..3]
869
+ _(projected.values(:sma)).must_equal [20.0, 25.0]
870
+ end
871
+
872
+ it "carries all formulae through a selection-only call" do
873
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
874
+ result = sales[price: ..15.0]
875
+ _(result.derived_dimensions).must_equal [:revenue]
876
+ _(result.values(:revenue)).must_equal [1000.0, 1500.0]
877
+ end
878
+
879
+ it "carries all formulae through contraction" do
880
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
881
+ result = sales[-:quarter]
882
+ _(result.derived_dimensions).must_equal [:revenue]
883
+ _(result.values(:revenue)).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
884
+ end
885
+
886
+ it "returns pure materialised values and empty formulae when only derived names are projected" do
887
+ prices[:sma] = sma
888
+ projected = prices[:sma]
889
+ _(projected.to_a).must_equal [{sma: 10.0}, {sma: 15.0}, {sma: 20.0}]
890
+ _(projected.formulae).must_equal({})
891
+ end
892
+
893
+ it "returns an instance of self's class" do
894
+ subclass = Class.new(Namo)
895
+ namo = subclass.new([{x: 1}])
896
+ namo[:double] = ->(row){row[:x] * 2}
897
+ _(namo[:double].class).must_equal subclass
898
+ end
899
+ end
900
+
901
+ context "composition" do
902
+ let(:audited) do
903
+ Namo.new([
904
+ {symbol: 'BHP', margin: 0.3},
905
+ {symbol: 'RIO', margin: 0.25}
906
+ ])
907
+ end
908
+
909
+ let(:modelled) do
910
+ namo = Namo.new([
911
+ {symbol: 'BHP', price: 10.0, cost: 6.0},
912
+ {symbol: 'RIO', price: 20.0, cost: 16.0}
913
+ ])
914
+ namo[:margin] = proc{|r| (r[:price] - r[:cost]) / r[:price]}
915
+ namo
916
+ end
917
+
918
+ let(:audited_orders) do
919
+ Namo.new([{order: 'A', margin: 0.3}])
920
+ end
921
+
922
+ let(:modelled_tiers) do
923
+ namo = Namo.new([{tier: 'light', price: 10.0, cost: 6.0}])
924
+ namo[:margin] = proc{|r| (r[:price] - r[:cost]) / r[:price]}
925
+ namo
926
+ end
927
+
928
+ it "raises on * when self's data dimension is other's derived dimension" do
929
+ _ { audited * modelled }.must_raise ArgumentError
930
+ end
931
+
932
+ it "raises on * when self's derived dimension is other's data dimension" do
933
+ _ { modelled * audited }.must_raise ArgumentError
934
+ end
935
+
936
+ it "raises on ** when self's data dimension is other's derived dimension" do
937
+ _ { audited_orders ** modelled_tiers }.must_raise ArgumentError
938
+ end
939
+
940
+ it "raises on ** when self's derived dimension is other's data dimension" do
941
+ _ { modelled_tiers ** audited_orders }.must_raise ArgumentError
942
+ end
943
+
944
+ it "raises in the block forms of both operators" do
945
+ _ { audited.*(modelled){|row, candidates| candidates} }.must_raise ArgumentError
946
+ _ { audited_orders.**(modelled_tiers){|row, candidates| candidates} }.must_raise ArgumentError
947
+ end
948
+
949
+ it "names the colliding dimensions in the message" do
950
+ err = _ { audited * modelled }.must_raise ArgumentError
951
+ _(err.message).must_match(/name collision between data and formulae/)
952
+ _(err.message).must_include ':margin'
953
+ end
954
+
955
+ it "does not raise on a formula-vs-formula collision — left wins" do
956
+ left = Namo.new([{symbol: 'BHP', close: 42.5}])
957
+ right = Namo.new([{symbol: 'BHP', pe: 14.5}])
958
+ left[:margin] = proc{|r| :left}
959
+ right[:margin] = proc{|r| :right}
960
+ _((left * right).values(:margin)).must_equal [:left]
961
+ end
962
+
963
+ it "composes after explicit resolution by contraction" do
964
+ result = audited[-:margin] * modelled
965
+ _(result.values(:margin)).must_equal [0.4, 0.2]
966
+ end
967
+ end
968
+ end
969
+
650
970
  describe "#each" do
651
971
  it "yields Row objects" do
652
972
  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.14.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran
@@ -93,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
93
93
  - !ruby/object:Gem::Version
94
94
  version: '0'
95
95
  requirements: []
96
- rubygems_version: 4.0.12
96
+ rubygems_version: 4.0.14
97
97
  specification_version: 4
98
98
  summary: Named dimensional data for Ruby.
99
99
  test_files: []