namo 0.13.2 → 0.14.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: 78880ca991afa35b970cc09625c4ca632db3a3ff69e7b4c69fda2fa3b536bd08
4
+ data.tar.gz: b85bfe8ef28cf45880e8ebda997d209e7a4f6d545710bad52ec4f6e5c790e30f
5
5
  SHA512:
6
- metadata.gz: 203c4416d177b726d3f8bec5bebf5225f9bb28cbf2ca3fbae1365f8f0d4062ce95018233e4342ec29b33f2bf753849acc512f1ff017f0925eec629caece73781
7
- data.tar.gz: a9ca4f20b7319825652588fe780e59601744447c38cddbfe0d2ce2c624200d249ce1ff43113a5d543737ab5789890392a48ff2fcf3dca52c3d5f25c999838bee
6
+ metadata.gz: c61419caff79be760cc24d3cd67eb008107061a65041853bd5108e51c70bf0fb100e50c899241b2ae45de59d92c9f7bcd6173eb2af279543c9057c186c3ec4ba
7
+ data.tar.gz: 5b9fe7311512c5a59f6528d70a82724f35323769267a804784d14a83351da08cb62000feb6f676e396ecd45c79397413f754cb09c599e5204f28212e95caa872
data/CHANGELOG CHANGED
@@ -1,6 +1,37 @@
1
1
  CHANGELOG
2
2
  _________
3
3
 
4
+ 20260608
5
+ 0.14.0: + blocks on the composition operators (*, **) for custom match refinement.
6
+
7
+ 1. ~ lib/namo.rb: Namo#* takes an optional block. Without a block, behaviour is unchanged.
8
+ With a block, the right rows matched on the shared data dimensions are passed as a Namo
9
+ (candidates, carrying other's formulae) alongside the left Row (carrying self's
10
+ formulae); the block returns a Namo of the rows to pair, each merged into the left row.
11
+ An empty returned Namo drops the left row (inner-join semantics). Result formulae are
12
+ other.formulae.merge(@formulae) as before — the block does not affect result formulae.
13
+ The shared-dimension precondition still applies with a block present.
14
+ 2. ~ lib/namo.rb: Namo#** takes an optional block, same contract, with candidates being all
15
+ of other's rows (no shared-dimension pre-filter). The disjoint-dimensions precondition
16
+ still applies with a block present.
17
+ 3. ~ test/namo_test.rb: + "with a block" context under #* (single-match one-row return, empty-return
18
+ drop, multi-row return, selection on other's derived dimension, reference to self's
19
+ derived dimension, result-formulae parity with no-block, derived-not-stored-but-resolves,
20
+ subclass type, preconditions still raise) and under #** (selector filtering one-to-many,
21
+ empty-return drop, full-candidates reproduces no-block, selection on other's derived
22
+ dimension, result-formulae parity, disjoint precondition still raises, subclass type).
23
+ Existing no-block #* and #** tests unchanged and green.
24
+ 4. ~ README.md: + block subsections under Composition and Cartesian product, with the
25
+ price/quarterly matching and orders/tiers worked examples and the (row, candidates) ->
26
+ Namo contract.
27
+ 5. ~ ROADMAP.md: Promote 0.14.0 to shipped (composition blocks only); Current state -> 0.14.0;
28
+ Summary folds in composition blocks; next phase -> 0.15.0. + the governing principle
29
+ (a block form is warranted iff the operation gives consideration to a dimension in
30
+ isolation), tied to the orthogonality/efficiency rationale for why set-operator/`/`
31
+ blocks were not added. The now-redundant upcoming 0.14.0 section removed.
32
+ 6. ~ COMPARISON.md: "Conditional join with block" -> shipped (0.14.0).
33
+ 7. ~ Namo::VERSION: /0.13.2/0.14.0/
34
+
4
35
  20260608
5
36
  0.13.2: Narrow the planned 0.14.0 block-form scope to the composition operators and document the rationale.
6
37
 
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 `**`:
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.14.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), 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), 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))
data/test/namo_test.rb CHANGED
@@ -1450,6 +1450,126 @@ describe Namo do
1450
1450
  b = Namo.new([{symbol: 'BHP', pe: 14.5}])
1451
1451
  _((a * b).class).must_equal subclass
1452
1452
  end
1453
+
1454
+ context "with a block" do
1455
+ let(:prices) do
1456
+ Namo.new([
1457
+ {symbol: 'BHP', date: '2025-02-15', close: 42.5},
1458
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0}
1459
+ ])
1460
+ end
1461
+
1462
+ let(:quarterly) do
1463
+ Namo.new([
1464
+ {symbol: 'BHP', quarter_end: '2024-12-31', eps: 1.0},
1465
+ {symbol: 'BHP', quarter_end: '2025-03-31', eps: 1.2}
1466
+ ])
1467
+ end
1468
+
1469
+ let(:most_recent_quarter) do
1470
+ proc do |row, candidates|
1471
+ candidates[quarter_end: ->(qe){qe <= row[:date]}].sort_by{|f| f[:quarter_end]}.last(1)
1472
+ end
1473
+ end
1474
+
1475
+ it "pairs each left row with the single match the block returns" do
1476
+ result = prices.*(quarterly, &most_recent_quarter)
1477
+ _(result.to_a).must_equal [
1478
+ {symbol: 'BHP', date: '2025-02-15', close: 42.5, quarter_end: '2024-12-31', eps: 1.0},
1479
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2}
1480
+ ]
1481
+ end
1482
+
1483
+ it "drops a left row whose block returns an empty Namo" do
1484
+ early = Namo.new([
1485
+ {symbol: 'BHP', date: '2024-06-01', close: 40.0},
1486
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0}
1487
+ ])
1488
+ result = early.*(quarterly, &most_recent_quarter)
1489
+ _(result.to_a).must_equal [
1490
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2}
1491
+ ]
1492
+ end
1493
+
1494
+ it "pairs each row when the block returns a multi-row Namo (selector, not reducer)" do
1495
+ left = Namo.new([{symbol: 'BHP', date: '2025-05-20', close: 44.0}])
1496
+ result = left.*(quarterly){|row, candidates| candidates}
1497
+ _(result.to_a).must_equal [
1498
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2024-12-31', eps: 1.0},
1499
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2}
1500
+ ]
1501
+ end
1502
+
1503
+ it "selects on a derived dimension of other inside the block" do
1504
+ prices = Namo.new([
1505
+ {symbol: 'BHP', close: 42.5},
1506
+ {symbol: 'RIO', close: 118.3}
1507
+ ])
1508
+ funds = Namo.new([
1509
+ {symbol: 'BHP', price: 42.5, eps: 3.0},
1510
+ {symbol: 'RIO', price: 118.3, eps: 13.0}
1511
+ ])
1512
+ funds[:pe] = proc{|f| f[:price] / f[:eps]}
1513
+ result = prices.*(funds){|row, candidates| candidates[pe: ->(v){v && v < 10}]}
1514
+ _(result.values(:symbol)).must_equal ['RIO']
1515
+ _(result.values(:close)).must_equal [118.3]
1516
+ end
1517
+
1518
+ it "resolves a derived dimension of self referenced in the block" do
1519
+ dated = Namo.new([{symbol: 'BHP', date: '2025-05-20', close: 44.0}])
1520
+ dated[:cutoff] = proc{|r| r[:date]}
1521
+ result = dated.*(quarterly){|row, candidates| candidates[quarter_end: ->(qe){qe <= row[:cutoff]}].sort_by{|f| f[:quarter_end]}.last(1)}
1522
+ _(result.to_a).must_equal [
1523
+ {symbol: 'BHP', date: '2025-05-20', close: 44.0, quarter_end: '2025-03-31', eps: 1.2}
1524
+ ]
1525
+ end
1526
+
1527
+ it "produces the same result formulae as the no-block form" do
1528
+ ohlcv = Namo.new([
1529
+ {symbol: 'BHP', close: 42.5},
1530
+ {symbol: 'RIO', close: 118.3}
1531
+ ])
1532
+ fundamentals = Namo.new([
1533
+ {symbol: 'BHP', pe: 14.5},
1534
+ {symbol: 'RIO', pe: 9.2}
1535
+ ])
1536
+ ohlcv[:label] = proc{|r| "#{r[:symbol]}-self"}
1537
+ fundamentals[:flag] = proc{|r| "pe=#{r[:pe]}"}
1538
+ blocked = ohlcv.*(fundamentals){|row, candidates| candidates}
1539
+ plain = ohlcv * fundamentals
1540
+ _(blocked.derived_dimensions).must_equal plain.derived_dimensions
1541
+ _(blocked.values(:label)).must_equal plain.values(:label)
1542
+ _(blocked.values(:flag)).must_equal plain.values(:flag)
1543
+ end
1544
+
1545
+ it "leaves other's derived dimension unstored but resolvable on the result" do
1546
+ prices = Namo.new([{symbol: 'BHP', close: 42.5}])
1547
+ funds = Namo.new([{symbol: 'BHP', price: 42.5, eps: 3.0}])
1548
+ funds[:pe] = proc{|f| f[:price] / f[:eps]}
1549
+ result = prices.*(funds){|row, candidates| candidates}
1550
+ _(result.data_dimensions).wont_include :pe
1551
+ _(result.derived_dimensions).must_include :pe
1552
+ _(result.values(:pe)).must_equal [42.5 / 3.0]
1553
+ end
1554
+
1555
+ it "returns an instance of self's class" do
1556
+ subclass = Class.new(Namo)
1557
+ a = subclass.new([{symbol: 'BHP', close: 42.5}])
1558
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}])
1559
+ result = a.*(b){|row, candidates| candidates}
1560
+ _(result.class).must_equal subclass
1561
+ end
1562
+
1563
+ it "still raises ArgumentError on no shared dimensions" do
1564
+ a = Namo.new([{symbol: 'BHP'}])
1565
+ b = Namo.new([{quarter: 'Q1'}])
1566
+ _ { a.*(b){|row, candidates| candidates} }.must_raise ArgumentError
1567
+ end
1568
+
1569
+ it "still raises TypeError on a non-Namo operand" do
1570
+ _ { ohlcv.*([{symbol: 'BHP'}]){|row, candidates| candidates} }.must_raise TypeError
1571
+ end
1572
+ end
1453
1573
  end
1454
1574
 
1455
1575
  describe "#**" do
@@ -1529,6 +1649,90 @@ describe Namo do
1529
1649
  b = Namo.new([{quarter: 'Q1'}])
1530
1650
  _((a ** b).class).must_equal subclass
1531
1651
  end
1652
+
1653
+ context "with a block" do
1654
+ let(:orders) do
1655
+ Namo.new([
1656
+ {order: 'A', weight: 5},
1657
+ {order: 'B', weight: 15}
1658
+ ])
1659
+ end
1660
+
1661
+ let(:tiers) do
1662
+ Namo.new([
1663
+ {tier: 'light', max_weight: 10},
1664
+ {tier: 'heavy', max_weight: 20}
1665
+ ])
1666
+ end
1667
+
1668
+ it "filters pairings by the selector block (one-to-many)" do
1669
+ result = orders.**(tiers){|row, candidates| candidates[max_weight: ->(w){w >= row[:weight]}]}
1670
+ _(result.to_a).must_equal [
1671
+ {order: 'A', weight: 5, tier: 'light', max_weight: 10},
1672
+ {order: 'A', weight: 5, tier: 'heavy', max_weight: 20},
1673
+ {order: 'B', weight: 15, tier: 'heavy', max_weight: 20}
1674
+ ]
1675
+ end
1676
+
1677
+ it "drops a left row whose block returns an empty Namo" do
1678
+ with_unservable = Namo.new([
1679
+ {order: 'C', weight: 50},
1680
+ {order: 'A', weight: 5}
1681
+ ])
1682
+ result = with_unservable.**(tiers){|row, candidates| candidates[max_weight: ->(w){w >= row[:weight]}]}
1683
+ _(result.to_a).must_equal [
1684
+ {order: 'A', weight: 5, tier: 'light', max_weight: 10},
1685
+ {order: 'A', weight: 5, tier: 'heavy', max_weight: 20}
1686
+ ]
1687
+ end
1688
+
1689
+ it "reproduces the no-block product when the block returns all candidates" do
1690
+ blocked = products.**(quarters){|row, candidates| candidates}
1691
+ plain = products ** quarters
1692
+ _(blocked.to_a).must_equal plain.to_a
1693
+ end
1694
+
1695
+ it "selects on a derived dimension of other inside the block" do
1696
+ weighted_tiers = Namo.new([
1697
+ {tier: 'light', max_weight: 10},
1698
+ {tier: 'heavy', max_weight: 20}
1699
+ ])
1700
+ weighted_tiers[:premium] = proc{|t| t[:max_weight] > 15}
1701
+ result = orders.**(weighted_tiers){|row, candidates| candidates[premium: ->(v){v}]}
1702
+ _(result.to_a).must_equal [
1703
+ {order: 'A', weight: 5, tier: 'heavy', max_weight: 20},
1704
+ {order: 'B', weight: 15, tier: 'heavy', max_weight: 20}
1705
+ ]
1706
+ end
1707
+
1708
+ it "produces the same result formulae as the no-block form" do
1709
+ products[:plabel] = proc{|r| "p=#{r[:product]}"}
1710
+ quarters[:qlabel] = proc{|r| "q=#{r[:quarter]}"}
1711
+ blocked = products.**(quarters){|row, candidates| candidates}
1712
+ plain = products ** quarters
1713
+ _(blocked.derived_dimensions).must_equal plain.derived_dimensions
1714
+ _(blocked.values(:plabel)).must_equal plain.values(:plabel)
1715
+ _(blocked.values(:qlabel)).must_equal plain.values(:qlabel)
1716
+ end
1717
+
1718
+ it "returns an instance of self's class" do
1719
+ subclass = Class.new(Namo)
1720
+ a = subclass.new([{product: 'Widget'}])
1721
+ b = Namo.new([{quarter: 'Q1'}])
1722
+ result = a.**(b){|row, candidates| candidates}
1723
+ _(result.class).must_equal subclass
1724
+ end
1725
+
1726
+ it "still raises ArgumentError when a dimension is shared" do
1727
+ a = Namo.new([{symbol: 'BHP', close: 42.5}])
1728
+ b = Namo.new([{symbol: 'RIO', pe: 14.5}])
1729
+ _ { a.**(b){|row, candidates| candidates} }.must_raise ArgumentError
1730
+ end
1731
+
1732
+ it "still raises TypeError on a non-Namo operand" do
1733
+ _ { products.**([{quarter: 'Q1'}]){|row, candidates| candidates} }.must_raise TypeError
1734
+ end
1735
+ end
1532
1736
  end
1533
1737
 
1534
1738
  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.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran