namo 0.14.0 → 0.15.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: 7d2d5b27fc7c414edd9d13ac80d87bc6f99c87b2f94a42b8f192c3e5a4f08daa
4
+ data.tar.gz: 91ff974cb3c20802cf0afea77563211a1d29abda66bf9960ed18611fbaf09410
5
5
  SHA512:
6
- metadata.gz: c61419caff79be760cc24d3cd67eb008107061a65041853bd5108e51c70bf0fb100e50c899241b2ae45de59d92c9f7bcd6173eb2af279543c9057c186c3ec4ba
7
- data.tar.gz: 5b9fe7311512c5a59f6528d70a82724f35323769267a804784d14a83351da08cb62000feb6f676e396ecd45c79397413f754cb09c599e5204f28212e95caa872
6
+ metadata.gz: f309aa497bde5bd05083f844f8d123257917da3e74abe07e4e18a8aff3dd362ab0e2c2b4b6df0791cb3a1b55ea53f9f7914a89eda74c2d158c0d7e5cf1e6b358
7
+ data.tar.gz: 62040149299672f673de4216cc7123e78e0135902d5a5ad3c948e74e7d2906b184b9ac6a6e84238f000d85a63b3ccc0b6dc894c3cdb74a65fb6ec1d7df0d6e33
data/CHANGELOG CHANGED
@@ -1,6 +1,41 @@
1
1
  CHANGELOG
2
2
  _________
3
3
 
4
+ 20260612
5
+ 0.15.0: + two-arity formulae — procs with arity 2 receive (row, namo) for cross-row computation.
6
+
7
+ 1. ~ lib/Namo/Row.rb: Row's constructor gains an optional third parameter, namo (default nil),
8
+ stored in @namo — the Namo that yielded the Row; the two-argument form keeps working.
9
+ Row#[] dispatches on formula arity: exactly 2 calls formula.call(self, @namo), raising
10
+ ArgumentError via the new private raise_unless_namo_context when @namo is nil; every
11
+ other arity (0, 1, negative) keeps the existing formula.call(self) unchanged.
12
+ 2. ~ lib/Namo/Enumerable.rb: every Row construction (each, select and its aliases, reject,
13
+ sort_by, first, last, take_while, drop_while, uniq's block form, partition) passes self
14
+ as the Row's namo, so two-arity formulae resolve during enumeration and predicate
15
+ evaluation, with the yielding Namo as the window.
16
+ 3. ~ lib/namo.rb: values_for's derived branch and the * and ** block paths pass self as the
17
+ Row's namo — values/coordinates/to_h resolve two-arity dimensions, and composition-block
18
+ rows can resolve self's two-arity formulae (extension of the 0.14.0 block contract; the
19
+ (Row, Namo) -> Namo contract and result-formulae rule are unchanged).
20
+ 4. ~ test/Namo/Row_test.rb: + constructor third-argument and backward-compatibility tests,
21
+ arity-dispatch tests (1, 2, 0, negative), the nil-namo ArgumentError, and match? on a
22
+ two-arity derived dimension.
23
+ 5. ~ test/namo_test.rb: + "#[]= two-arity formulae" describe — SMA cross-row resolution via
24
+ values/coordinates/to_h, selection and subset-predicate resolution, first/last Rows,
25
+ yielding-Namo (filtered-window) semantics, liveness, operator carry-through, composition-
26
+ block resolution, mixed-arity chains, and the empty-Namo case.
27
+ 6. ~ README.md: + Cross-row formulae subsection under Formulae, with the SMA example, the
28
+ yielding-Namo semantic, and the no-context error.
29
+ 7. ~ ROADMAP.md: Promote 0.15.0 to shipped; Current state -> 0.15.0; Summary folds in
30
+ two-arity formulae; the example's window.length corrected to window.count (Namo has
31
+ no length); "Live computation objects" phrasing updated. + upcoming 0.16.0 section,
32
+ Data/formula exclusivity — projection drops the formulae it materialises, * and **
33
+ raise on a data/formula name collision — with parameterised formulae renumbered to
34
+ 0.17.0, Namo::Collection to 0.18.0, and group_by to 0.19.0; next phase -> 0.16.0.
35
+ 8. ~ COMPARISON.md: Two-arity formulae -> shipped (0.15.0) with the same window.count
36
+ correction; Parameterised formulae repointed to planned (0.17.0). Date bumped.
37
+ 9. ~ Namo::VERSION: /0.14.0/0.15.0/
38
+
4
39
  20260608
5
40
  0.14.0: + blocks on the composition operators (*, **) for custom match refinement.
6
41
 
data/README.md CHANGED
@@ -644,6 +644,34 @@ sales[product: 'Widget'][:revenue, :quarter]
644
644
 
645
645
  Formulae carry through selection — a filtered Namo instance remembers its formulae.
646
646
 
647
+ #### Cross-row formulae
648
+
649
+ 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.
650
+
651
+ A simple moving average reads the surrounding rows through `namo`:
652
+
653
+ ```ruby
654
+ prices = Namo.new([
655
+ {symbol: 'AAA', date: 1, close: 10.0},
656
+ {symbol: 'AAA', date: 2, close: 20.0},
657
+ {symbol: 'AAA', date: 3, close: 30.0}
658
+ ])
659
+
660
+ prices[:sma] = proc{|row, namo|
661
+ window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}]
662
+ window.values(:close).sum / window.count.to_f
663
+ }
664
+
665
+ prices.values(:sma)
666
+ # => [10.0, 15.0, 20.0]
667
+ ```
668
+
669
+ `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.
670
+
671
+ 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.
672
+
673
+ 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
+
647
675
  ### Polymorphic `[]=`
648
676
 
649
677
  `[]=` 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.15.0'
6
6
  end
data/lib/namo.rb CHANGED
@@ -124,7 +124,7 @@ class Namo
124
124
  matched = other.data.select{|right_row| shared.all?{|dim| left_row[dim] == right_row[dim]}}
125
125
  if block
126
126
  candidates = other.class.new(matched, formulae: other.formulae.dup)
127
- chosen = block.call(Row.new(left_row, @formulae), candidates)
127
+ chosen = block.call(Row.new(left_row, @formulae, self), candidates)
128
128
  chosen.data.each{|right_row| combined_data << left_row.merge(right_row)}
129
129
  else
130
130
  matched.each{|right_row| combined_data << left_row.merge(right_row)}
@@ -140,7 +140,7 @@ class Namo
140
140
  @data.each do |left_row|
141
141
  if block
142
142
  candidates = other.class.new(other.data, formulae: other.formulae.dup)
143
- chosen = block.call(Row.new(left_row, @formulae), candidates)
143
+ chosen = block.call(Row.new(left_row, @formulae, self), candidates)
144
144
  chosen.data.each{|right_row| combined_data << left_row.merge(right_row)}
145
145
  else
146
146
  other.data.each{|right_row| combined_data << left_row.merge(right_row)}
@@ -229,11 +229,17 @@ class Namo
229
229
 
230
230
  private
231
231
 
232
+ def initialize(positional_data = nil, data: [], formulae: {}, name: nil)
233
+ @data = positional_data || data
234
+ @formulae = formulae
235
+ @name = name
236
+ end
237
+
232
238
  def values_for(dim)
233
239
  if data_dimensions.include?(dim)
234
240
  @data.map{|row_data| row_data[dim]}
235
241
  else
236
- @data.map{|row_data| Row.new(row_data, @formulae)[dim]}
242
+ @data.map{|row_data| Row.new(row_data, @formulae, self)[dim]}
237
243
  end
238
244
  end
239
245
 
@@ -260,10 +266,4 @@ class Namo
260
266
  raise ArgumentError, "dimensions in common, need no common dimensions: #{data_dimensions} vs #{other.data_dimensions}"
261
267
  end
262
268
  end
263
-
264
- def initialize(positional_data = nil, data: [], formulae: {}, name: nil)
265
- @data = positional_data || data
266
- @formulae = formulae
267
- @name = name
268
- end
269
269
  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
@@ -647,6 +647,126 @@ describe Namo do
647
647
  end
648
648
  end
649
649
 
650
+ describe "#[]= two-arity formulae" do
651
+ let(:price_data) do
652
+ [
653
+ {symbol: 'AAA', date: 1, close: 10.0},
654
+ {symbol: 'AAA', date: 2, close: 20.0},
655
+ {symbol: 'AAA', date: 3, close: 30.0},
656
+ ]
657
+ end
658
+
659
+ let(:prices) do
660
+ Namo.new(price_data)
661
+ end
662
+
663
+ # A simple moving average: the mean close over the rows of the same symbol
664
+ # up to and including the current row's date, computed against the Namo the
665
+ # row belongs to.
666
+ let(:sma) do
667
+ ->(row, namo){
668
+ window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}]
669
+ window.values(:close).sum / window.count.to_f
670
+ }
671
+ end
672
+
673
+ it "resolves a cross-row SMA via values" do
674
+ prices[:sma] = sma
675
+ _(prices.values(:sma)).must_equal [10.0, 15.0, 20.0]
676
+ end
677
+
678
+ it "resolves through coordinates" do
679
+ prices[:sma] = sma
680
+ _(prices.coordinates(:sma)).must_equal [10.0, 15.0, 20.0]
681
+ end
682
+
683
+ it "resolves through the no-arg to_h" do
684
+ prices[:sma] = sma
685
+ _(prices.to_h[:sma]).must_equal [10.0, 15.0, 20.0]
686
+ end
687
+
688
+ it "selects on the two-arity dimension" do
689
+ prices[:sma] = sma
690
+ _(prices[sma: ->(v){v > 12.0}].values(:date)).must_equal [2, 3]
691
+ end
692
+
693
+ it "resolves a two-arity dimension in a subset-method predicate" do
694
+ prices[:sma] = sma
695
+ result = prices.select{|row| row[:sma] > 12.0}
696
+ _(result).must_be_kind_of Namo
697
+ _(result.values(:date)).must_equal [2, 3]
698
+ end
699
+
700
+ it "resolves the two-arity formula on the no-arg first Row" do
701
+ prices[:sma] = sma
702
+ _(prices.first[:sma]).must_equal 10.0
703
+ end
704
+
705
+ it "resolves the two-arity formula on the no-arg last Row" do
706
+ prices[:sma] = sma
707
+ _(prices.last[:sma]).must_equal 20.0
708
+ end
709
+
710
+ it "windows over the yielding Namo — a filtered Namo computes over the filtered rows only" do
711
+ prices[:sma] = sma
712
+ filtered = prices.select{|row| row[:date] >= 2}
713
+ # On the full Namo the date-2 SMA averages dates 1 and 2 (15.0); on the
714
+ # filtered Namo it sees only date 2, so the value differs.
715
+ _(prices.values(:sma)).must_equal [10.0, 15.0, 20.0]
716
+ _(filtered.values(:sma)).must_equal [20.0, 25.0]
717
+ end
718
+
719
+ it "is live: appending a row changes the two-arity result on next access" do
720
+ prices[:sma] = sma
721
+ _(prices.last[:sma]).must_equal 20.0
722
+ prices.data << {symbol: 'AAA', date: 4, close: 40.0}
723
+ _(prices.last[:sma]).must_equal 25.0
724
+ end
725
+
726
+ it "carries a two-arity formula through a set-operator result, windowing over the combined rows" do
727
+ a = Namo.new([{symbol: 'AAA', date: 1}, {symbol: 'AAA', date: 2}])
728
+ b = Namo.new([{symbol: 'AAA', date: 3}])
729
+ a[:peers] = ->(row, namo){namo.count}
730
+ _((a + b).values(:peers)).must_equal [3, 3, 3]
731
+ end
732
+
733
+ it "carries a merged two-arity formula through a composition result, windowing over the joined rows" do
734
+ left = Namo.new([{symbol: 'AAA', date: 1}, {symbol: 'AAA', date: 2}])
735
+ right = Namo.new([{symbol: 'AAA', sector: 'tech'}])
736
+ left[:peers] = ->(row, namo){namo.count}
737
+ result = left * right
738
+ _(result.values(:peers)).must_equal [2, 2]
739
+ end
740
+
741
+ it "resolves a two-arity formula of self inside a composition block" do
742
+ left = Namo.new([{symbol: 'AAA', date: 1}, {symbol: 'AAA', date: 2}])
743
+ right = Namo.new([{symbol: 'AAA', sector: 'tech'}])
744
+ left[:peers] = ->(row, namo){namo.count}
745
+ seen = nil
746
+ left.*(right){|row, candidates| seen = row[:peers]; candidates}
747
+ _(seen).must_equal 2
748
+ end
749
+
750
+ it "lets a one-arity formula reference a two-arity formula by name" do
751
+ prices[:sma] = sma
752
+ prices[:double_sma] = ->(row){row[:sma] * 2}
753
+ _(prices.values(:double_sma)).must_equal [20.0, 30.0, 40.0]
754
+ end
755
+
756
+ it "lets a two-arity formula reference a one-arity formula by name" do
757
+ prices[:tenth] = ->(row){row[:close] / 10.0}
758
+ prices[:tenth_plus_count] = ->(row, namo){row[:tenth] + namo.count}
759
+ _(prices.values(:tenth_plus_count)).must_equal [4.0, 5.0, 6.0]
760
+ end
761
+
762
+ it "returns [] for a two-arity dimension on an empty Namo without invoking the formula" do
763
+ invoked = false
764
+ empty = Namo.new([], formulae: {sma: ->(row, namo){invoked = true; 0}})
765
+ _(empty.values(:sma)).must_equal []
766
+ _(invoked).must_equal false
767
+ end
768
+ end
769
+
650
770
  describe "#each" do
651
771
  it "yields Row objects" do
652
772
  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.15.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: []