namo 0.8.0 → 0.9.1

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: 86f572755292e2bac9808f09adb3169cec06066a5687ec2ea6bd9354f97d592a
4
- data.tar.gz: edafcc183a1a9e5fb1212bb66a8b6906b94bf8e37a7375d3f9d304e5632ddba3
3
+ metadata.gz: 9788cac0a828d1bb2dbe181bdc7a685a9d390ae452905a6a3cbec90d0af36a60
4
+ data.tar.gz: 27d4c652792e52e01c120e69268c790b8cc26bc2a5fcf884437e6320ef997723
5
5
  SHA512:
6
- metadata.gz: 2b2fad7c20e6cc9909b6d770a94627a071626de5cec9b402074ccda919295781dd18c38411604e5d0adf3d2f5e80833de830be37892a8e0a5cea5323bcef1a66
7
- data.tar.gz: d0e9d4ae5bbe7a4dfc217614f0fb1442750313e9db734b622b831b15873e1ce6a3a5d2e91b01c450ab795476f00b94e29aae12be0dea4aad3d37e46369b5628e
6
+ metadata.gz: cad1474b8d4f14cb8ffc98fc718bd0a047ea5fa9fb0c00a6da1e3c058345f6ab88d20e0a569bd7fb838ba0ad8ea43769b46f86b675e994f8d513766da76e9a94
7
+ data.tar.gz: 1974cdebe247dc73dd8b62c70297ba928c51f41954ba1913b8709cb4183bc556fc2d44c334763d4984238015f6dc2ce1c962bbe65aff9de8111a0926e13626bd
data/CHANGELOG CHANGED
@@ -1,6 +1,28 @@
1
1
  CHANGELOG
2
2
  _________
3
3
 
4
+ 20260525
5
+ 0.9.1: ~ Namo#initialize: default data to [], ~ Namo#data_dimensions to handle empty data
6
+
7
+ 1. ~ Namo#initialize: Default `data` parameter changed from `nil` to `[]`. Construction with no arguments now produces a usable empty Namo rather than one whose introspection methods crash on `nil`.
8
+ 2. ~ Namo#data_dimensions: Handle empty `@data` with `@data.first&.keys || []`. Returns `[]` for a Namo with no rows instead of raising `NoMethodError` on `nil.keys`.
9
+ 3. ~ Namo#dimensions: Refactor to delegate as `data_dimensions + derived_dimensions`. The empty-case guard now lives in one place; `dimensions` becomes a trivial composition.
10
+ 4. ~ test/namo_test.rb: + Empty-Namo tests (dimensions, data_dimensions, formula surfacing on a Namo with no data).
11
+ 5. ~ Namo::VERSION: /0.9.0/0.9.1/
12
+
13
+ 20260521
14
+ 0.9.0: + composition operators: equi-join (*), Cartesian product (**), decomposition (/)
15
+
16
+ 1. + Namo#*: Equi-join on shared data dimensions. Inner-join semantics — unmatched rows from both sides are dropped. Raises ArgumentError ("no shared dimensions, need to have shared dimensions") when operands have no overlap. Preserves duplicates multiplicatively. Formulae merge with self winning on conflict.
17
+ 2. + Namo#**: Cartesian product of two Namos with disjoint data dimensions. Raises ArgumentError ("dimensions in common, need no common dimensions") when any dimension is shared. Output has left.length * right.length rows. Formulae merge with self winning on conflict.
18
+ 3. + Namo#/: Decomposition. Removes from self the dimensions that are also in other (the intersection), then dedupes the projected rows. No precondition — total on Namo × Namo. When self and other share no dimensions, the operator is a no-op. Formulae carry through from self. (a ** b) / b == a exactly; (a * b) / b loses dimensions shared between a and b.
19
+ 4. + Namo#raise_unless_shared_data_dimensions, Namo#raise_unless_disjoint_data_dimensions: Private precondition helpers for #* and #** respectively.
20
+ 5. ~ test/namo_test.rb: + #* tests (single/multi-dimension join, inner-join symmetry, multiplicative duplicates, formulae merging, error cases). + #** tests (Cartesian product, output sizing, dimension overlap error). + #/ tests (intersection removal, dedupe of collided rows, no-op on disjoint operands, idempotence). + Composition round-trip tests for the ** case (exact identity) and the * case (lossy on shared dimensions).
21
+ 6. ~ README.md: + Composition section (*), + Cartesian product section (**), + Decomposition section (/) including the combining-vs-projecting rationale for /'s no-precondition design. Placed after Symmetric Difference and before Equality.
22
+ 7. ~ ROADMAP.md: Promote 0.9.0 from upcoming to shipped under "Current state: 0.9.0"; revise Summary to include composition in the operator vocabulary and point "next phase" at 0.10.0+.
23
+ 8. ~ COMPARISON.md: /planned (0.9.0)/shipped (0.9.0)/ for Equi-join, Cartesian product, and Decomposition. + Paragraph in the Decomposition entry on the combining-vs-projecting distinction. Date bumped to 20260521.
24
+ 9. ~ Namo::VERSION: /0.8.0/0.9.0/
25
+
4
26
  20260521
5
27
  0.8.0: + proc and regex-based selection
6
28
 
data/README.md CHANGED
@@ -319,6 +319,124 @@ set_a ^ set_b
319
319
 
320
320
  The dimensions must match; different dimensions raise an `ArgumentError`. Formulae merge from both sides; the left-hand side's formulae take precedence on conflict.
321
321
 
322
+ ### Composition
323
+
324
+ `*` is the equi-join operator. It pairs rows from two Namos where coordinates match on every shared dimension, like an inner join on the shared dimension names:
325
+
326
+ ```ruby
327
+ ohlcv = Namo.new([
328
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5},
329
+ {symbol: 'RIO', date: '2025-01-01', close: 118.3}
330
+ ])
331
+
332
+ fundamentals = Namo.new([
333
+ {symbol: 'BHP', pe: 14.5},
334
+ {symbol: 'RIO', pe: 9.2}
335
+ ])
336
+
337
+ ohlcv * fundamentals
338
+ # => #<Namo [
339
+ # {symbol: 'BHP', date: '2025-01-01', close: 42.5, pe: 14.5},
340
+ # {symbol: 'RIO', date: '2025-01-01', close: 118.3, pe: 9.2}
341
+ # ]>
342
+ ```
343
+
344
+ 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.
345
+
346
+ 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.
347
+
348
+ ### Cartesian product
349
+
350
+ `**` is the Cartesian product. Every row from the left paired with every row from the right:
351
+
352
+ ```ruby
353
+ products = Namo.new([{product: 'Widget'}, {product: 'Gadget'}])
354
+ quarters = Namo.new([{quarter: 'Q1'}, {quarter: 'Q2'}])
355
+
356
+ products ** quarters
357
+ # => #<Namo [
358
+ # {product: 'Widget', quarter: 'Q1'},
359
+ # {product: 'Widget', quarter: 'Q2'},
360
+ # {product: 'Gadget', quarter: 'Q1'},
361
+ # {product: 'Gadget', quarter: 'Q2'}
362
+ # ]>
363
+ ```
364
+
365
+ 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.
366
+
367
+ 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.
368
+
369
+ The visual relationship is intentional: `*` is the filtered version, `**` is the explosive version — more sigil, more output.
370
+
371
+ ### Decomposition
372
+
373
+ `/` removes from the left Namo the dimensions that are also in the right, then dedupes the projected rows. It's the inverse of `*` and `**`:
374
+
375
+ ```ruby
376
+ combined = Namo.new([
377
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5, pe: 14.5},
378
+ {symbol: 'RIO', date: '2025-01-01', close: 118.3, pe: 9.2}
379
+ ])
380
+
381
+ fundamentals = Namo.new([
382
+ {symbol: 'BHP', pe: 14.5},
383
+ {symbol: 'RIO', pe: 9.2}
384
+ ])
385
+
386
+ combined / fundamentals
387
+ # => #<Namo [
388
+ # {date: '2025-01-01', close: 42.5},
389
+ # {date: '2025-01-01', close: 118.3}
390
+ # ]>
391
+ ```
392
+
393
+ The intersection of dimensions — here `:symbol` and `:pe` — is removed. Everything else stays. The projected rows are deduplicated, so `/` answers "what's left when these dimensions are factored out?" rather than "what rows survive a column drop?". Formulae carry through from the left-hand side.
394
+
395
+ `/` has no precondition. When the two Namos share no dimensions, the intersection is empty, nothing is removed, and `self / other` returns a Namo equal to self:
396
+
397
+ ```ruby
398
+ shipments = Namo.new([{order_id: 1, weight: 10}])
399
+ weather = Namo.new([{date: '2025-01-01', temperature: 22}])
400
+
401
+ shipments / weather
402
+ # => #<Namo [{order_id: 1, weight: 10}]> — equal to shipments
403
+ ```
404
+
405
+ The round-trip identity holds for the `**` case exactly:
406
+
407
+ ```ruby
408
+ a = Namo.new([{symbol: 'BHP'}, {symbol: 'RIO'}])
409
+ b = Namo.new([{quarter: 'Q1'}, {quarter: 'Q2'}])
410
+
411
+ (a ** b) / b == a
412
+ # => true
413
+ ```
414
+
415
+ For `*`, the round-trip is lossy on the dimensions that were shared between the operands:
416
+
417
+ ```ruby
418
+ a = Namo.new([{symbol: 'BHP', close: 42.5}, {symbol: 'RIO', close: 118.3}])
419
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}, {symbol: 'RIO', pe: 9.2}])
420
+
421
+ (a * b) / b
422
+ # => #<Namo [{close: 42.5}, {close: 118.3}]>
423
+ # Equal to a[-:symbol]. :symbol was shared and is lost.
424
+ ```
425
+
426
+ The asymmetry is inherent: `/` operates only on the two values it receives and can't distinguish "shared dimension that belonged to both" from "exclusive dimension that belonged only to the right". Removing the intersection is the only rule expressible from the operands alone, and it gives clean recovery from `**` and well-defined (if lossy) recovery from `*`.
427
+
428
+ #### Why `/` is loose
429
+
430
+ `*` and `**` raise when their preconditions are violated — combining unrelated Namos has no natural answer, and silently producing arbitrary output would turn a logic error into a large pile of nonsense rows. `/` is different: it's a projecting operator, not a combining one, and projecting away nothing returns the original. The no-precondition rule isn't a fallback; it's the structurally correct result.
431
+
432
+ This earns `/` three properties a strict version would lose:
433
+
434
+ - **Identity test.** `combined / other == combined` exactly when the two have no shared dimensions — answers "are these Namos dimensionally independent?" without explicit introspection. Same shape as `a & b == a` answering subset from 0.6.0.
435
+ - **Idempotence.** `(c / b) / b == c / b`. Once `b`'s dimensions are removed, removing them again does nothing.
436
+ - **Pipeline composition.** A processing step that applies `/ separator` can run over any Namo regardless of whether the separator's dimensions apply. Uninvolved Namos pass through unchanged; involved Namos get stripped. The pipeline doesn't need to special-case applicability.
437
+
438
+ This is the same pattern that makes `Array#-` useful with arrays that aren't subsets: `[1, 2, 3] - [9] == [1, 2, 3]`, not an error. The no-op-on-non-applicable behaviour lets the operator compose into pipelines that don't know in advance whether the operation applies.
439
+
322
440
  ### Equality
323
441
 
324
442
  Comparison on Namos is **multiset-theoretic on rows**: row order is ignored (it's an accident of ingestion, not data), but row multiplicities count (they *are* data). The same stance carries across the equality, pattern-match, and subset/superset operators below.
data/lib/Namo/VERSION.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # Namo::VERSION
3
3
 
4
4
  class Namo
5
- VERSION = '0.8.0'
5
+ VERSION = '0.9.1'
6
6
  end
data/lib/namo.rb CHANGED
@@ -12,11 +12,11 @@ class Namo
12
12
  attr_accessor :formulae
13
13
 
14
14
  def dimensions
15
- @data.first.keys + @formulae.keys
15
+ data_dimensions + derived_dimensions
16
16
  end
17
17
 
18
18
  def data_dimensions
19
- @data.first.keys
19
+ @data.first&.keys || []
20
20
  end
21
21
 
22
22
  def derived_dimensions
@@ -110,6 +110,42 @@ class Namo
110
110
  self.class.new((@data - other.data) + (other.data - @data), formulae: other.formulae.merge(@formulae))
111
111
  end
112
112
 
113
+ def *(other)
114
+ raise_unless_namo(other)
115
+ raise_unless_shared_data_dimensions(other)
116
+ shared = data_dimensions & other.data_dimensions
117
+ combined_data = []
118
+ @data.each do |left_row|
119
+ other.data.each do |right_row|
120
+ if shared.all?{|dim| left_row[dim] == right_row[dim]}
121
+ combined_data << left_row.merge(right_row)
122
+ end
123
+ end
124
+ end
125
+ self.class.new(combined_data, formulae: other.formulae.merge(@formulae))
126
+ end
127
+
128
+ def **(other)
129
+ raise_unless_namo(other)
130
+ raise_unless_disjoint_data_dimensions(other)
131
+ combined_data = []
132
+ @data.each do |left_row|
133
+ other.data.each do |right_row|
134
+ combined_data << left_row.merge(right_row)
135
+ end
136
+ end
137
+ self.class.new(combined_data, formulae: other.formulae.merge(@formulae))
138
+ end
139
+
140
+ def /(other)
141
+ raise_unless_namo(other)
142
+ kept = data_dimensions - other.data_dimensions
143
+ projected = @data.map do |row|
144
+ kept.each_with_object({}){|dim, hash| hash[dim] = row[dim]}
145
+ end
146
+ self.class.new(projected.uniq, formulae: @formulae.dup)
147
+ end
148
+
113
149
  def ==(other)
114
150
  return false unless other.is_a?(Namo)
115
151
  canonical_data == other.canonical_data
@@ -201,7 +237,19 @@ class Namo
201
237
  end
202
238
  end
203
239
 
204
- def initialize(data = nil, formulae: {})
240
+ def raise_unless_shared_data_dimensions(other)
241
+ if (data_dimensions & other.data_dimensions).empty?
242
+ raise ArgumentError, "no shared dimensions, need to have shared dimensions: #{data_dimensions} vs #{other.data_dimensions}"
243
+ end
244
+ end
245
+
246
+ def raise_unless_disjoint_data_dimensions(other)
247
+ if (data_dimensions & other.data_dimensions).any?
248
+ raise ArgumentError, "dimensions in common, need no common dimensions: #{data_dimensions} vs #{other.data_dimensions}"
249
+ end
250
+ end
251
+
252
+ def initialize(data = [], formulae: {})
205
253
  @data = data
206
254
  @formulae = formulae
207
255
  end
data/test/namo_test.rb CHANGED
@@ -17,6 +17,22 @@ describe Namo do
17
17
  Namo.new(sample_data)
18
18
  end
19
19
 
20
+ describe "empty Namo" do
21
+ it "has empty dimensions" do
22
+ _(Namo.new.dimensions).must_equal []
23
+ end
24
+
25
+ it "has empty data_dimensions" do
26
+ _(Namo.new.data_dimensions).must_equal []
27
+ end
28
+
29
+ it "exposes formulae even with no data" do
30
+ namo = Namo.new
31
+ namo[:x] = proc{|r| 42}
32
+ _(namo.dimensions).must_equal [:x]
33
+ end
34
+ end
35
+
20
36
  describe "#dimensions" do
21
37
  it "infers dimensions from hash keys" do
22
38
  _(sales.dimensions).must_equal [:product, :quarter, :price, :quantity]
@@ -710,6 +726,288 @@ describe Namo do
710
726
  end
711
727
  end
712
728
 
729
+ describe "#*" do
730
+ let(:ohlcv) do
731
+ Namo.new([
732
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5},
733
+ {symbol: 'RIO', date: '2025-01-01', close: 118.3}
734
+ ])
735
+ end
736
+
737
+ let(:fundamentals) do
738
+ Namo.new([
739
+ {symbol: 'BHP', pe: 14.5},
740
+ {symbol: 'RIO', pe: 9.2}
741
+ ])
742
+ end
743
+
744
+ it "joins on a single shared dimension" do
745
+ result = ohlcv * fundamentals
746
+ _(result.to_a).must_equal [
747
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5, pe: 14.5},
748
+ {symbol: 'RIO', date: '2025-01-01', close: 118.3, pe: 9.2}
749
+ ]
750
+ end
751
+
752
+ it "joins on multiple shared dimensions" do
753
+ a = Namo.new([
754
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5},
755
+ {symbol: 'BHP', date: '2025-01-02', close: 43.0}
756
+ ])
757
+ b = Namo.new([
758
+ {symbol: 'BHP', date: '2025-01-01', volume: 1000},
759
+ {symbol: 'BHP', date: '2025-01-02', volume: 1500}
760
+ ])
761
+ result = a * b
762
+ _(result.to_a).must_equal [
763
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5, volume: 1000},
764
+ {symbol: 'BHP', date: '2025-01-02', close: 43.0, volume: 1500}
765
+ ]
766
+ end
767
+
768
+ it "preserves non-shared dimensions from both sides" do
769
+ result = ohlcv * fundamentals
770
+ _(result.dimensions).must_equal [:symbol, :date, :close, :pe]
771
+ end
772
+
773
+ it "drops unmatched rows from both sides (inner-join symmetry)" do
774
+ left = Namo.new([
775
+ {symbol: 'BHP', close: 42.5},
776
+ {symbol: 'CBA', close: 100.0}
777
+ ])
778
+ right = Namo.new([
779
+ {symbol: 'BHP', pe: 14.5},
780
+ {symbol: 'RIO', pe: 9.2}
781
+ ])
782
+ result = left * right
783
+ _(result.to_a).must_equal [{symbol: 'BHP', close: 42.5, pe: 14.5}]
784
+ end
785
+
786
+ it "produces multiplicative duplicates when inputs have duplicates on shared dimensions" do
787
+ left = Namo.new([
788
+ {symbol: 'BHP', close: 42.5},
789
+ {symbol: 'BHP', close: 43.0}
790
+ ])
791
+ right = Namo.new([
792
+ {symbol: 'BHP', pe: 14.5},
793
+ {symbol: 'BHP', pe: 14.7}
794
+ ])
795
+ result = left * right
796
+ _(result.to_a.length).must_equal 4
797
+ _(result.to_a).must_equal [
798
+ {symbol: 'BHP', close: 42.5, pe: 14.5},
799
+ {symbol: 'BHP', close: 42.5, pe: 14.7},
800
+ {symbol: 'BHP', close: 43.0, pe: 14.5},
801
+ {symbol: 'BHP', close: 43.0, pe: 14.7}
802
+ ]
803
+ end
804
+
805
+ it "carries formulae through from self" do
806
+ ohlcv[:label] = proc{|r| "#{r[:symbol]}-self"}
807
+ result = ohlcv * fundamentals
808
+ _(result.map{|row| row[:label]}).must_equal ['BHP-self', 'RIO-self']
809
+ end
810
+
811
+ it "merges formulae from other" do
812
+ fundamentals[:flag] = proc{|r| "pe=#{r[:pe]}"}
813
+ result = ohlcv * fundamentals
814
+ _(result.map{|row| row[:flag]}).must_equal ['pe=14.5', 'pe=9.2']
815
+ end
816
+
817
+ it "prefers self's formulae on conflict" do
818
+ ohlcv[:label] = proc{|r| "self: #{r[:symbol]}"}
819
+ fundamentals[:label] = proc{|r| "other: #{r[:symbol]}"}
820
+ result = ohlcv * fundamentals
821
+ _(result.map{|row| row[:label]}).must_equal ['self: BHP', 'self: RIO']
822
+ end
823
+
824
+ it "raises ArgumentError when there are no shared dimensions" do
825
+ a = Namo.new([{symbol: 'BHP'}])
826
+ b = Namo.new([{quarter: 'Q1'}])
827
+ err = _ { a * b }.must_raise ArgumentError
828
+ _(err.message).must_match(/no shared dimensions, need to have shared dimensions/)
829
+ end
830
+
831
+ it "raises TypeError on a non-Namo operand" do
832
+ _ { ohlcv * [{symbol: 'BHP'}] }.must_raise TypeError
833
+ end
834
+
835
+ it "returns an instance of self's class" do
836
+ subclass = Class.new(Namo)
837
+ a = subclass.new([{symbol: 'BHP', close: 42.5}])
838
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}])
839
+ _((a * b).class).must_equal subclass
840
+ end
841
+ end
842
+
843
+ describe "#**" do
844
+ let(:products) do
845
+ Namo.new([{product: 'Widget'}, {product: 'Gadget'}])
846
+ end
847
+
848
+ let(:quarters) do
849
+ Namo.new([{quarter: 'Q1'}, {quarter: 'Q2'}])
850
+ end
851
+
852
+ it "Cartesian-products two disjoint Namos" do
853
+ result = products ** quarters
854
+ _(result.to_a).must_equal [
855
+ {product: 'Widget', quarter: 'Q1'},
856
+ {product: 'Widget', quarter: 'Q2'},
857
+ {product: 'Gadget', quarter: 'Q1'},
858
+ {product: 'Gadget', quarter: 'Q2'}
859
+ ]
860
+ end
861
+
862
+ it "has self.data.length * other.data.length rows" do
863
+ a = Namo.new([{x: 1}, {x: 2}, {x: 3}])
864
+ b = Namo.new([{y: 'a'}, {y: 'b'}])
865
+ _((a ** b).to_a.length).must_equal 6
866
+ end
867
+
868
+ it "output dimensions are self.data_dimensions + other.data_dimensions" do
869
+ result = products ** quarters
870
+ _(result.dimensions).must_equal [:product, :quarter]
871
+ end
872
+
873
+ it "preserves duplicates on either side multiplicatively" do
874
+ a = Namo.new([{x: 1}, {x: 1}])
875
+ b = Namo.new([{y: 'a'}, {y: 'a'}])
876
+ result = a ** b
877
+ _(result.to_a.length).must_equal 4
878
+ end
879
+
880
+ it "carries formulae through from self" do
881
+ products[:label] = proc{|r| "self: #{r[:product]}"}
882
+ result = products ** quarters
883
+ _(result.map{|row| row[:label]}).must_equal [
884
+ 'self: Widget', 'self: Widget', 'self: Gadget', 'self: Gadget'
885
+ ]
886
+ end
887
+
888
+ it "merges formulae from other" do
889
+ quarters[:flag] = proc{|r| "q=#{r[:quarter]}"}
890
+ result = products ** quarters
891
+ _(result.map{|row| row[:flag]}).must_equal ['q=Q1', 'q=Q2', 'q=Q1', 'q=Q2']
892
+ end
893
+
894
+ it "prefers self's formulae on conflict" do
895
+ products[:label] = proc{|r| "self: #{r[:product]}"}
896
+ quarters[:label] = proc{|r| "other: #{r[:quarter]}"}
897
+ result = products ** quarters
898
+ _(result.map{|row| row[:label]}).must_equal [
899
+ 'self: Widget', 'self: Widget', 'self: Gadget', 'self: Gadget'
900
+ ]
901
+ end
902
+
903
+ it "raises ArgumentError when any dimension is shared" do
904
+ a = Namo.new([{symbol: 'BHP', close: 42.5}])
905
+ b = Namo.new([{symbol: 'RIO', pe: 14.5}])
906
+ err = _ { a ** b }.must_raise ArgumentError
907
+ _(err.message).must_match(/dimensions in common, need no common dimensions/)
908
+ end
909
+
910
+ it "raises TypeError on a non-Namo operand" do
911
+ _ { products ** [{quarter: 'Q1'}] }.must_raise TypeError
912
+ end
913
+
914
+ it "returns an instance of self's class" do
915
+ subclass = Class.new(Namo)
916
+ a = subclass.new([{product: 'Widget'}])
917
+ b = Namo.new([{quarter: 'Q1'}])
918
+ _((a ** b).class).must_equal subclass
919
+ end
920
+ end
921
+
922
+ describe "#/" do
923
+ let(:combined) do
924
+ Namo.new([
925
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5, pe: 14.5},
926
+ {symbol: 'RIO', date: '2025-01-01', close: 118.3, pe: 9.2}
927
+ ])
928
+ end
929
+
930
+ let(:fundamentals) do
931
+ Namo.new([
932
+ {symbol: 'BHP', pe: 14.5},
933
+ {symbol: 'RIO', pe: 9.2}
934
+ ])
935
+ end
936
+
937
+ it "removes dimensions present in both self and other (the intersection)" do
938
+ result = combined / fundamentals
939
+ _(result.dimensions).must_equal [:date, :close]
940
+ end
941
+
942
+ it "preserves dimensions exclusive to self" do
943
+ result = combined / fundamentals
944
+ _(result.to_a).must_equal [
945
+ {date: '2025-01-01', close: 42.5},
946
+ {date: '2025-01-01', close: 118.3}
947
+ ]
948
+ end
949
+
950
+ it "dedupes rows that collide after projection" do
951
+ a = Namo.new([
952
+ {symbol: 'BHP', close: 42.5},
953
+ {symbol: 'RIO', close: 42.5}
954
+ ])
955
+ b = Namo.new([{symbol: 'X'}])
956
+ result = a / b
957
+ _(result.to_a).must_equal [{close: 42.5}]
958
+ end
959
+
960
+ it "carries formulae through from self" do
961
+ combined[:label] = proc{|r| "row: #{r[:close]}"}
962
+ result = combined / fundamentals
963
+ _(result.map{|row| row[:label]}).must_equal ['row: 42.5', 'row: 118.3']
964
+ end
965
+
966
+ it "is a no-op when self and other share no dimensions" do
967
+ shipments = Namo.new([{order_id: 1, weight: 10}])
968
+ weather = Namo.new([{date: '2025-01-01', temperature: 22}])
969
+ _(shipments / weather).must_equal shipments
970
+ end
971
+
972
+ it "ignores dimensions present in other but not in self" do
973
+ a = Namo.new([{symbol: 'BHP', close: 42.5}])
974
+ b = Namo.new([{symbol: 'BHP', pe: 14.5, sector: 'Mining'}])
975
+ result = a / b
976
+ _(result.dimensions).must_equal [:close]
977
+ end
978
+
979
+ it "is idempotent" do
980
+ first = combined / fundamentals
981
+ second = first / fundamentals
982
+ _(second).must_equal first
983
+ end
984
+
985
+ it "raises TypeError on a non-Namo operand" do
986
+ _ { combined / [{symbol: 'BHP'}] }.must_raise TypeError
987
+ end
988
+
989
+ it "returns an instance of self's class" do
990
+ subclass = Class.new(Namo)
991
+ a = subclass.new([{symbol: 'BHP', close: 42.5}])
992
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}])
993
+ _((a / b).class).must_equal subclass
994
+ end
995
+ end
996
+
997
+ describe "composition round-trip" do
998
+ it "satisfies (a ** b) / b == a for disjoint a and b" do
999
+ a = Namo.new([{symbol: 'BHP'}, {symbol: 'RIO'}])
1000
+ b = Namo.new([{quarter: 'Q1'}, {quarter: 'Q2'}])
1001
+ _((a ** b) / b).must_equal a
1002
+ end
1003
+
1004
+ it "satisfies (a * b) / b == a[-:shared] for a and b with shared dimensions (shared dimensions lost)" do
1005
+ a = Namo.new([{symbol: 'BHP', close: 42.5}, {symbol: 'RIO', close: 118.3}])
1006
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}, {symbol: 'RIO', pe: 9.2}])
1007
+ _((a * b) / b).must_equal Namo.new([{close: 42.5}, {close: 118.3}])
1008
+ end
1009
+ end
1010
+
713
1011
  describe "#==" do
714
1012
  it "is true for same data, same order" do
715
1013
  a = Namo.new([{x: 1}, {x: 2}])
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.8.0
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran
@@ -92,7 +92,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
92
92
  - !ruby/object:Gem::Version
93
93
  version: '0'
94
94
  requirements: []
95
- rubygems_version: 4.0.11
95
+ rubygems_version: 4.0.12
96
96
  specification_version: 4
97
97
  summary: Named dimensional data for Ruby.
98
98
  test_files: []