namo 0.13.2 → 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: 480baeb4c1da195bc5e9800df21267467dd784a4ef6b4fc41cd1997925c36432
4
- data.tar.gz: d1794682a82a35578e87734569808b61171ceb95f2d0548e761b42052ea5223b
3
+ metadata.gz: 7d2d5b27fc7c414edd9d13ac80d87bc6f99c87b2f94a42b8f192c3e5a4f08daa
4
+ data.tar.gz: 91ff974cb3c20802cf0afea77563211a1d29abda66bf9960ed18611fbaf09410
5
5
  SHA512:
6
- metadata.gz: 203c4416d177b726d3f8bec5bebf5225f9bb28cbf2ca3fbae1365f8f0d4062ce95018233e4342ec29b33f2bf753849acc512f1ff017f0925eec629caece73781
7
- data.tar.gz: a9ca4f20b7319825652588fe780e59601744447c38cddbfe0d2ce2c624200d249ce1ff43113a5d543737ab5789890392a48ff2fcf3dca52c3d5f25c999838bee
6
+ metadata.gz: f309aa497bde5bd05083f844f8d123257917da3e74abe07e4e18a8aff3dd362ab0e2c2b4b6df0791cb3a1b55ea53f9f7914a89eda74c2d158c0d7e5cf1e6b358
7
+ data.tar.gz: 62040149299672f673de4216cc7123e78e0135902d5a5ad3c948e74e7d2906b184b9ac6a6e84238f000d85a63b3ccc0b6dc894c3cdb74a65fb6ec1d7df0d6e33
data/CHANGELOG CHANGED
@@ -1,6 +1,72 @@
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
+
39
+ 20260608
40
+ 0.14.0: + blocks on the composition operators (*, **) for custom match refinement.
41
+
42
+ 1. ~ lib/namo.rb: Namo#* takes an optional block. Without a block, behaviour is unchanged.
43
+ With a block, the right rows matched on the shared data dimensions are passed as a Namo
44
+ (candidates, carrying other's formulae) alongside the left Row (carrying self's
45
+ formulae); the block returns a Namo of the rows to pair, each merged into the left row.
46
+ An empty returned Namo drops the left row (inner-join semantics). Result formulae are
47
+ other.formulae.merge(@formulae) as before — the block does not affect result formulae.
48
+ The shared-dimension precondition still applies with a block present.
49
+ 2. ~ lib/namo.rb: Namo#** takes an optional block, same contract, with candidates being all
50
+ of other's rows (no shared-dimension pre-filter). The disjoint-dimensions precondition
51
+ still applies with a block present.
52
+ 3. ~ test/namo_test.rb: + "with a block" context under #* (single-match one-row return, empty-return
53
+ drop, multi-row return, selection on other's derived dimension, reference to self's
54
+ derived dimension, result-formulae parity with no-block, derived-not-stored-but-resolves,
55
+ subclass type, preconditions still raise) and under #** (selector filtering one-to-many,
56
+ empty-return drop, full-candidates reproduces no-block, selection on other's derived
57
+ dimension, result-formulae parity, disjoint precondition still raises, subclass type).
58
+ Existing no-block #* and #** tests unchanged and green.
59
+ 4. ~ README.md: + block subsections under Composition and Cartesian product, with the
60
+ price/quarterly matching and orders/tiers worked examples and the (row, candidates) ->
61
+ Namo contract.
62
+ 5. ~ ROADMAP.md: Promote 0.14.0 to shipped (composition blocks only); Current state -> 0.14.0;
63
+ Summary folds in composition blocks; next phase -> 0.15.0. + the governing principle
64
+ (a block form is warranted iff the operation gives consideration to a dimension in
65
+ isolation), tied to the orthogonality/efficiency rationale for why set-operator/`/`
66
+ blocks were not added. The now-redundant upcoming 0.14.0 section removed.
67
+ 6. ~ COMPARISON.md: "Conditional join with block" -> shipped (0.14.0).
68
+ 7. ~ Namo::VERSION: /0.13.2/0.14.0/
69
+
4
70
  20260608
5
71
  0.13.2: Narrow the planned 0.14.0 block-form scope to the composition operators and document the rationale.
6
72
 
data/README.md CHANGED
@@ -355,6 +355,36 @@ Inner-join semantics: unmatched rows from either side are dropped. Output dimens
355
355
 
356
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.
357
357
 
358
+ #### Conditional join
359
+
360
+ `*` takes an optional block that decides which matched rows to pair with each left row. Without a block, every shared-dimension match pairs, as above. With one, the block is handed the current left row and the right rows already matched on the shared dimensions, and returns the subset to pair — the refinement plain `*` can't express, because it pairs every match.
361
+
362
+ The canonical case is matching each daily price to a single quarterly report — the most recent one dated on or before it. Plain `*` pairs *every* matching quarter; the block narrows that to the one the matching rule picks.
363
+
364
+ ```ruby
365
+ prices = Namo.new([
366
+ {symbol: 'BHP', date: '2025-02-15', close: 42.5},
367
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0}
368
+ ])
369
+
370
+ quarterly = Namo.new([
371
+ {symbol: 'BHP', quarter_end: '2024-12-31', eps: 1.0},
372
+ {symbol: 'BHP', quarter_end: '2025-03-31', eps: 1.2}
373
+ ])
374
+
375
+ prices.*(quarterly) do |row, candidates|
376
+ candidates[quarter_end: ->(qe){qe <= row[:date]}].sort_by{|f| f[:quarter_end]}.last(1)
377
+ end
378
+ # => #<Namo [
379
+ # {symbol: 'BHP', date: '2025-02-15', close: 42.5, quarter_end: '2024-12-31', eps: 1.0},
380
+ # {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2}
381
+ # ]>
382
+ ```
383
+
384
+ `row` is the `Row` for the current left row, carrying self's formulae, so `row[:date]` and any self formula resolve inside the block. `candidates` is a Namo of the shared-dimension matches, carrying other's formulae, so the block can select on other's derived dimensions too. The block returns a Namo of the rows to pair: one row for the single-match rule above, though it may return zero, one, or many — it's a selector, not a reducer. An empty returned Namo pairs nothing, so that left row is dropped, preserving inner-join semantics. The block can also be passed as a named proc, `prices.*(quarterly, &most_recent_quarter)`.
385
+
386
+ The block changes only which rows pair. Formulae carry through exactly as in the no-block form — other's merged under self's, self winning on conflict — and the rows the block returns contribute data only.
387
+
358
388
  ### Cartesian product
359
389
 
360
390
  `**` is the Cartesian product. Every row from the left paired with every row from the right:
@@ -378,6 +408,35 @@ The two Namos must have **no** shared data dimensions — the precondition is th
378
408
 
379
409
  The visual relationship is intentional: `*` is the filtered version, `**` is the explosive version — more sigil, more output.
380
410
 
411
+ #### Conditional product
412
+
413
+ `**` takes an optional block on the same contract. Where `*`'s block receives the rows pre-matched on the shared dimensions, `**`'s receives *all* of other's rows — there are no shared dimensions to match on — and returns the subset to pair with each left row.
414
+
415
+ This expresses a conditional product: pair each order with only the shipping tiers that can carry it.
416
+
417
+ ```ruby
418
+ orders = Namo.new([
419
+ {order: 'A', weight: 5},
420
+ {order: 'B', weight: 15}
421
+ ])
422
+
423
+ tiers = Namo.new([
424
+ {tier: 'light', max_weight: 10},
425
+ {tier: 'heavy', max_weight: 20}
426
+ ])
427
+
428
+ orders.**(tiers) do |row, candidates|
429
+ candidates[max_weight: ->(w){w >= row[:weight]}]
430
+ end
431
+ # => #<Namo [
432
+ # {order: 'A', weight: 5, tier: 'light', max_weight: 10},
433
+ # {order: 'A', weight: 5, tier: 'heavy', max_weight: 20},
434
+ # {order: 'B', weight: 15, tier: 'heavy', max_weight: 20}
435
+ # ]>
436
+ ```
437
+
438
+ The contract matches `*`'s: `row` carries self's formulae, `candidates` carries other's, the block returns a Namo of rows to pair, and an empty return drops the left row. A block that returns its `candidates` unchanged reproduces the no-block product exactly — `**` is its own block form with the identity selector, just as `*` is `**` with the shared-dimension match applied first.
439
+
381
440
  ### Decomposition
382
441
 
383
442
  `/` removes from the left Namo the dimensions that are also in the right, then dedupes the projected rows. It's the inverse of `*` and `**`:
@@ -585,6 +644,34 @@ sales[product: 'Widget'][:revenue, :quarter]
585
644
 
586
645
  Formulae carry through selection — a filtered Namo instance remembers its formulae.
587
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
+
588
675
  ### Polymorphic `[]=`
589
676
 
590
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.13.2'
5
+ VERSION = '0.15.0'
6
6
  end
data/lib/namo.rb CHANGED
@@ -115,28 +115,35 @@ class Namo
115
115
  self.class.new((@data - other.data) + (other.data - @data), formulae: other.formulae.merge(@formulae))
116
116
  end
117
117
 
118
- def *(other)
118
+ def *(other, &block)
119
119
  raise_unless_namo(other)
120
120
  raise_unless_shared_data_dimensions(other)
121
121
  shared = data_dimensions & other.data_dimensions
122
122
  combined_data = []
123
123
  @data.each do |left_row|
124
- other.data.each do |right_row|
125
- if shared.all?{|dim| left_row[dim] == right_row[dim]}
126
- combined_data << left_row.merge(right_row)
127
- end
124
+ matched = other.data.select{|right_row| shared.all?{|dim| left_row[dim] == right_row[dim]}}
125
+ if block
126
+ candidates = other.class.new(matched, formulae: other.formulae.dup)
127
+ chosen = block.call(Row.new(left_row, @formulae, self), candidates)
128
+ chosen.data.each{|right_row| combined_data << left_row.merge(right_row)}
129
+ else
130
+ matched.each{|right_row| combined_data << left_row.merge(right_row)}
128
131
  end
129
132
  end
130
133
  self.class.new(combined_data, formulae: other.formulae.merge(@formulae))
131
134
  end
132
135
 
133
- def **(other)
136
+ def **(other, &block)
134
137
  raise_unless_namo(other)
135
138
  raise_unless_disjoint_data_dimensions(other)
136
139
  combined_data = []
137
140
  @data.each do |left_row|
138
- other.data.each do |right_row|
139
- combined_data << left_row.merge(right_row)
141
+ if block
142
+ candidates = other.class.new(other.data, formulae: other.formulae.dup)
143
+ chosen = block.call(Row.new(left_row, @formulae, self), candidates)
144
+ chosen.data.each{|right_row| combined_data << left_row.merge(right_row)}
145
+ else
146
+ other.data.each{|right_row| combined_data << left_row.merge(right_row)}
140
147
  end
141
148
  end
142
149
  self.class.new(combined_data, formulae: other.formulae.merge(@formulae))
@@ -222,11 +229,17 @@ class Namo
222
229
 
223
230
  private
224
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
+
225
238
  def values_for(dim)
226
239
  if data_dimensions.include?(dim)
227
240
  @data.map{|row_data| row_data[dim]}
228
241
  else
229
- @data.map{|row_data| Row.new(row_data, @formulae)[dim]}
242
+ @data.map{|row_data| Row.new(row_data, @formulae, self)[dim]}
230
243
  end
231
244
  end
232
245
 
@@ -253,10 +266,4 @@ class Namo
253
266
  raise ArgumentError, "dimensions in common, need no common dimensions: #{data_dimensions} vs #{other.data_dimensions}"
254
267
  end
255
268
  end
256
-
257
- def initialize(positional_data = nil, data: [], formulae: {}, name: nil)
258
- @data = positional_data || data
259
- @formulae = formulae
260
- @name = name
261
- end
262
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 = []
@@ -1450,6 +1570,126 @@ describe Namo do
1450
1570
  b = Namo.new([{symbol: 'BHP', pe: 14.5}])
1451
1571
  _((a * b).class).must_equal subclass
1452
1572
  end
1573
+
1574
+ context "with a block" do
1575
+ let(:prices) do
1576
+ Namo.new([
1577
+ {symbol: 'BHP', date: '2025-02-15', close: 42.5},
1578
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0}
1579
+ ])
1580
+ end
1581
+
1582
+ let(:quarterly) do
1583
+ Namo.new([
1584
+ {symbol: 'BHP', quarter_end: '2024-12-31', eps: 1.0},
1585
+ {symbol: 'BHP', quarter_end: '2025-03-31', eps: 1.2}
1586
+ ])
1587
+ end
1588
+
1589
+ let(:most_recent_quarter) do
1590
+ proc do |row, candidates|
1591
+ candidates[quarter_end: ->(qe){qe <= row[:date]}].sort_by{|f| f[:quarter_end]}.last(1)
1592
+ end
1593
+ end
1594
+
1595
+ it "pairs each left row with the single match the block returns" do
1596
+ result = prices.*(quarterly, &most_recent_quarter)
1597
+ _(result.to_a).must_equal [
1598
+ {symbol: 'BHP', date: '2025-02-15', close: 42.5, quarter_end: '2024-12-31', eps: 1.0},
1599
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2}
1600
+ ]
1601
+ end
1602
+
1603
+ it "drops a left row whose block returns an empty Namo" do
1604
+ early = Namo.new([
1605
+ {symbol: 'BHP', date: '2024-06-01', close: 40.0},
1606
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0}
1607
+ ])
1608
+ result = early.*(quarterly, &most_recent_quarter)
1609
+ _(result.to_a).must_equal [
1610
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2}
1611
+ ]
1612
+ end
1613
+
1614
+ it "pairs each row when the block returns a multi-row Namo (selector, not reducer)" do
1615
+ left = Namo.new([{symbol: 'BHP', date: '2025-05-20', close: 44.0}])
1616
+ result = left.*(quarterly){|row, candidates| candidates}
1617
+ _(result.to_a).must_equal [
1618
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2024-12-31', eps: 1.0},
1619
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2}
1620
+ ]
1621
+ end
1622
+
1623
+ it "selects on a derived dimension of other inside the block" do
1624
+ prices = Namo.new([
1625
+ {symbol: 'BHP', close: 42.5},
1626
+ {symbol: 'RIO', close: 118.3}
1627
+ ])
1628
+ funds = Namo.new([
1629
+ {symbol: 'BHP', price: 42.5, eps: 3.0},
1630
+ {symbol: 'RIO', price: 118.3, eps: 13.0}
1631
+ ])
1632
+ funds[:pe] = proc{|f| f[:price] / f[:eps]}
1633
+ result = prices.*(funds){|row, candidates| candidates[pe: ->(v){v && v < 10}]}
1634
+ _(result.values(:symbol)).must_equal ['RIO']
1635
+ _(result.values(:close)).must_equal [118.3]
1636
+ end
1637
+
1638
+ it "resolves a derived dimension of self referenced in the block" do
1639
+ dated = Namo.new([{symbol: 'BHP', date: '2025-05-20', close: 44.0}])
1640
+ dated[:cutoff] = proc{|r| r[:date]}
1641
+ result = dated.*(quarterly){|row, candidates| candidates[quarter_end: ->(qe){qe <= row[:cutoff]}].sort_by{|f| f[:quarter_end]}.last(1)}
1642
+ _(result.to_a).must_equal [
1643
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2}
1644
+ ]
1645
+ end
1646
+
1647
+ it "produces the same result formulae as the no-block form" do
1648
+ ohlcv = Namo.new([
1649
+ {symbol: 'BHP', close: 42.5},
1650
+ {symbol: 'RIO', close: 118.3}
1651
+ ])
1652
+ fundamentals = Namo.new([
1653
+ {symbol: 'BHP', pe: 14.5},
1654
+ {symbol: 'RIO', pe: 9.2}
1655
+ ])
1656
+ ohlcv[:label] = proc{|r| "#{r[:symbol]}-self"}
1657
+ fundamentals[:flag] = proc{|r| "pe=#{r[:pe]}"}
1658
+ blocked = ohlcv.*(fundamentals){|row, candidates| candidates}
1659
+ plain = ohlcv * fundamentals
1660
+ _(blocked.derived_dimensions).must_equal plain.derived_dimensions
1661
+ _(blocked.values(:label)).must_equal plain.values(:label)
1662
+ _(blocked.values(:flag)).must_equal plain.values(:flag)
1663
+ end
1664
+
1665
+ it "leaves other's derived dimension unstored but resolvable on the result" do
1666
+ prices = Namo.new([{symbol: 'BHP', close: 42.5}])
1667
+ funds = Namo.new([{symbol: 'BHP', price: 42.5, eps: 3.0}])
1668
+ funds[:pe] = proc{|f| f[:price] / f[:eps]}
1669
+ result = prices.*(funds){|row, candidates| candidates}
1670
+ _(result.data_dimensions).wont_include :pe
1671
+ _(result.derived_dimensions).must_include :pe
1672
+ _(result.values(:pe)).must_equal [42.5 / 3.0]
1673
+ end
1674
+
1675
+ it "returns an instance of self's class" do
1676
+ subclass = Class.new(Namo)
1677
+ a = subclass.new([{symbol: 'BHP', close: 42.5}])
1678
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}])
1679
+ result = a.*(b){|row, candidates| candidates}
1680
+ _(result.class).must_equal subclass
1681
+ end
1682
+
1683
+ it "still raises ArgumentError on no shared dimensions" do
1684
+ a = Namo.new([{symbol: 'BHP'}])
1685
+ b = Namo.new([{quarter: 'Q1'}])
1686
+ _ { a.*(b){|row, candidates| candidates} }.must_raise ArgumentError
1687
+ end
1688
+
1689
+ it "still raises TypeError on a non-Namo operand" do
1690
+ _ { ohlcv.*([{symbol: 'BHP'}]){|row, candidates| candidates} }.must_raise TypeError
1691
+ end
1692
+ end
1453
1693
  end
1454
1694
 
1455
1695
  describe "#**" do
@@ -1529,6 +1769,90 @@ describe Namo do
1529
1769
  b = Namo.new([{quarter: 'Q1'}])
1530
1770
  _((a ** b).class).must_equal subclass
1531
1771
  end
1772
+
1773
+ context "with a block" do
1774
+ let(:orders) do
1775
+ Namo.new([
1776
+ {order: 'A', weight: 5},
1777
+ {order: 'B', weight: 15}
1778
+ ])
1779
+ end
1780
+
1781
+ let(:tiers) do
1782
+ Namo.new([
1783
+ {tier: 'light', max_weight: 10},
1784
+ {tier: 'heavy', max_weight: 20}
1785
+ ])
1786
+ end
1787
+
1788
+ it "filters pairings by the selector block (one-to-many)" do
1789
+ result = orders.**(tiers){|row, candidates| candidates[max_weight: ->(w){w >= row[:weight]}]}
1790
+ _(result.to_a).must_equal [
1791
+ {order: 'A', weight: 5, tier: 'light', max_weight: 10},
1792
+ {order: 'A', weight: 5, tier: 'heavy', max_weight: 20},
1793
+ {order: 'B', weight: 15, tier: 'heavy', max_weight: 20}
1794
+ ]
1795
+ end
1796
+
1797
+ it "drops a left row whose block returns an empty Namo" do
1798
+ with_unservable = Namo.new([
1799
+ {order: 'C', weight: 50},
1800
+ {order: 'A', weight: 5}
1801
+ ])
1802
+ result = with_unservable.**(tiers){|row, candidates| candidates[max_weight: ->(w){w >= row[:weight]}]}
1803
+ _(result.to_a).must_equal [
1804
+ {order: 'A', weight: 5, tier: 'light', max_weight: 10},
1805
+ {order: 'A', weight: 5, tier: 'heavy', max_weight: 20}
1806
+ ]
1807
+ end
1808
+
1809
+ it "reproduces the no-block product when the block returns all candidates" do
1810
+ blocked = products.**(quarters){|row, candidates| candidates}
1811
+ plain = products ** quarters
1812
+ _(blocked.to_a).must_equal plain.to_a
1813
+ end
1814
+
1815
+ it "selects on a derived dimension of other inside the block" do
1816
+ weighted_tiers = Namo.new([
1817
+ {tier: 'light', max_weight: 10},
1818
+ {tier: 'heavy', max_weight: 20}
1819
+ ])
1820
+ weighted_tiers[:premium] = proc{|t| t[:max_weight] > 15}
1821
+ result = orders.**(weighted_tiers){|row, candidates| candidates[premium: ->(v){v}]}
1822
+ _(result.to_a).must_equal [
1823
+ {order: 'A', weight: 5, tier: 'heavy', max_weight: 20},
1824
+ {order: 'B', weight: 15, tier: 'heavy', max_weight: 20}
1825
+ ]
1826
+ end
1827
+
1828
+ it "produces the same result formulae as the no-block form" do
1829
+ products[:plabel] = proc{|r| "p=#{r[:product]}"}
1830
+ quarters[:qlabel] = proc{|r| "q=#{r[:quarter]}"}
1831
+ blocked = products.**(quarters){|row, candidates| candidates}
1832
+ plain = products ** quarters
1833
+ _(blocked.derived_dimensions).must_equal plain.derived_dimensions
1834
+ _(blocked.values(:plabel)).must_equal plain.values(:plabel)
1835
+ _(blocked.values(:qlabel)).must_equal plain.values(:qlabel)
1836
+ end
1837
+
1838
+ it "returns an instance of self's class" do
1839
+ subclass = Class.new(Namo)
1840
+ a = subclass.new([{product: 'Widget'}])
1841
+ b = Namo.new([{quarter: 'Q1'}])
1842
+ result = a.**(b){|row, candidates| candidates}
1843
+ _(result.class).must_equal subclass
1844
+ end
1845
+
1846
+ it "still raises ArgumentError when a dimension is shared" do
1847
+ a = Namo.new([{symbol: 'BHP', close: 42.5}])
1848
+ b = Namo.new([{symbol: 'RIO', pe: 14.5}])
1849
+ _ { a.**(b){|row, candidates| candidates} }.must_raise ArgumentError
1850
+ end
1851
+
1852
+ it "still raises TypeError on a non-Namo operand" do
1853
+ _ { products.**([{quarter: 'Q1'}]){|row, candidates| candidates} }.must_raise TypeError
1854
+ end
1855
+ end
1532
1856
  end
1533
1857
 
1534
1858
  describe "#/" do
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.13.2
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: []