namo 0.7.0 → 0.9.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: c0aa48a727f9cc67fae8eea94dcfa8227c66a34aa8918ac65e30b33b7685c82b
4
- data.tar.gz: c8f634b5c41f35337fb843d286edf80cbdabef951b076dd36533f5a2531c3715
3
+ metadata.gz: 7adbe8192367d3c4207f7b27ff0b6a8a13f5243b42a4e4f18a6fbc35ef6439be
4
+ data.tar.gz: cda1e8b3d8fc042b4457efc0bc7846d5a416a5eda670d140824a4f8ad193a0f2
5
5
  SHA512:
6
- metadata.gz: 9b196446a0d02dc61790a25ea416bfebdd591020cfd6ef89430f5e8b9d8bd1239cc265bca83f0cd6dde4d42ce3c3927074ac69e1d4e79ae88db667b95caeadf3
7
- data.tar.gz: 3680f44daa8e1e78921d1d286c7f436947b6e436c9308d62038c17a1f9658ae4ccc23196afb2eb44e6b3efb41398ab1b39d8cf3bf813f87c24df12c7cb975586
6
+ metadata.gz: 60d639f243fc7bf306576b69ac646be388571b343330e5636f7c862519169cbaca3a36beb1cfccf16462b7e65b4735ca1b38e948db693c094c3636507531ebd4
7
+ data.tar.gz: 5898c9f8b7d9196481a964826cee97a8f1a962916f818604fa4ea647c1b59d5b05e17ea0ac5a578c6cd2a7d499100d95a10344029e7b6abd5bce70a41efc9b1f
data/CHANGELOG CHANGED
@@ -1,6 +1,31 @@
1
1
  CHANGELOG
2
2
  _________
3
3
 
4
+ 20260521
5
+ 0.9.0: + composition operators: equi-join (*), Cartesian product (**), decomposition (/)
6
+
7
+ 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.
8
+ 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.
9
+ 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.
10
+ 4. + Namo#raise_unless_shared_data_dimensions, Namo#raise_unless_disjoint_data_dimensions: Private precondition helpers for #* and #** respectively.
11
+ 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).
12
+ 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.
13
+ 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+.
14
+ 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.
15
+ 9. ~ Namo::VERSION: /0.8.0/0.9.0/
16
+
17
+ 20260521
18
+ 0.8.0: + proc and regex-based selection
19
+
20
+ 1. ~ Namo::Row#match?: + `when Proc` branch — calls the proc with the dimension value; truthy result selects the row. Predicate receives nil for missing or nil-valued dimensions and decides.
21
+ 2. ~ Namo::Row#match?: + `when Regexp` branch — matches against row[dimension].to_s. nil becomes "" (matches //, not /./); Integer/Float/Date/Symbol coerce via to_s.
22
+ 3. ~ test/Namo/Row_test.rb: + Proc-predicate tests (true/false/nil/truthy non-boolean returns, nil dimension values, composition with exact/array/range/regex, multiple proc predicates across dimensions, carry-through to formula-defined dimensions). + Regexp-predicate tests (match/no-match/case-insensitive on Strings, to_s coercion of Integer/Float/Date/Symbol/nil, composition with exact/array/range/proc, multiple regex predicates across dimensions, carry-through to formula-defined dimensions).
23
+ 4. ~ test/namo_test.rb: + End-to-end tests for proc selection, regex selection, and mixed proc/regex selection, including composition with projection and contraction in a single call and selection on formula-defined dimensions.
24
+ 5. ~ README.md: + Proc and regex examples in the Selection section; + paragraphs on proc semantics (truthy/nil-aware, composes with everything) and regex semantics (.to_s coercion across nil/String/Symbol/Integer/Float/Date).
25
+ 6. ~ ROADMAP.md: Promote 0.8.0 from upcoming to shipped under "Current state: 0.8.0"; revise Summary to include proc/regex in the selection vocabulary and point "next phase" at 0.9.0+.
26
+ 7. ~ CHANGELOG: Update with 0.8.0's changes and retroactively log the 0.7.0 changes to README (+ Coordinates and values section), COMPARISON.md, and EXAMPLES.md (2 sections relocated from EXAMPLES.md to COMPARISON.md).
27
+ 8. ~ Namo::VERSION: /0.7.0/0.8.0/
28
+
4
29
  20260520
5
30
  0.7.0: + derived-dimension surfacing, lazy single-column access, live views
6
31
 
@@ -14,8 +39,12 @@ _________
14
39
  8. ~ Namo#canonical_data: Sorts by data_dimensions to preserve 0.6.0 row-equality semantics under the broader dimensions definition.
15
40
  9. /raise_unless_matching_dimensions/raise_unless_matching_data_dimensions/: Private helper renamed to reflect what it actually compares.
16
41
  10. ~ test/namo_test.rb: + Tests for #data_dimensions, #derived_dimensions, the no-arg/single-arg/multi-arg forms of #values and #coordinates, derived-dimension surfacing in #dimensions, #to_h, the coordinates(dim) == values(dim).uniq consistency property, and live-view semantics (added rows / formulae reflected on next call).
17
- 11. ~ Rakefile: + -V mainfont=Charter -V monofont=Menlo on pandoc invocation in docs:md2pdf, for a cleaner serif body font and so code spans containing Unicode math glyphs (e.g. ∅) render correctly under xelatex.
18
- 12. ~ Namo::VERSION: /0.6.0/0.7.0/
42
+ 11. ~ README.md: + Coordinates and values section covering #values, #coordinates(*dims), #data_dimensions, #derived_dimensions, and #to_h.
43
+ 12. ~ COMPARISON.md: + "Worked example: comparing yesterday's screen to today's" under the set-operators section; + "Schema dispatch on incoming data feeds" under the equality/=== section. Date bumped to 20260520.
44
+ 13. ~ EXAMPLES.md: - "Schema dispatch on incoming data feeds", - "Comparing yesterday's screen to today's" — both relocated to COMPARISON.md as worked examples under the matching feature sections. Date bumped to 20260520.
45
+ 14. ~ ROADMAP.md: Promote 0.7.0 from upcoming to shipped under "Current state: 0.7.0"; point "next phase" at 0.8.0+.
46
+ 15. ~ Rakefile: + -V mainfont=Charter -V monofont=Menlo on pandoc invocation in docs:md2pdf, for a cleaner serif body font and so code spans containing Unicode math glyphs (e.g. ∅) render correctly under xelatex.
47
+ 16. ~ Namo::VERSION: /0.6.0/0.7.0/
19
48
 
20
49
  20260511
21
50
  0.6.0: + equality, pattern-match, and subset/superset operators
data/README.md CHANGED
@@ -79,8 +79,41 @@ sales[quarter: ['Q1']]
79
79
  # {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
80
80
  # {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40}
81
81
  # ]>
82
+
83
+ # Proc predicate
84
+ sales[price: ->(v){v < 20.0}]
85
+ # => #<Namo [
86
+ # {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
87
+ # {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
88
+ # ]>
89
+
90
+ # Regex predicate
91
+ sales[product: /^W/]
92
+ # => #<Namo [
93
+ # {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
94
+ # {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
95
+ # ]>
96
+ ```
97
+
98
+ Procs receive the dimension value and select the row when they return truthy. They handle arbitrary predicates — multi-condition tests, nil-aware checks, anything Ruby can express — and compose with everything else:
99
+
100
+ ```ruby
101
+ sales[price: ->(v){v < 20.0}, quantity: ->(v){v > 100}]
102
+ # => #<Namo [
103
+ # {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
104
+ # ]>
105
+ ```
106
+
107
+ Regexes match against the dimension value coerced with `to_s`, so they work against strings, symbols, numbers, dates, or anything else with a sensible string form. `nil` becomes `""` — `//` matches it, `/./` doesn't.
108
+
109
+ ```ruby
110
+ sales[product: /widget/i] # case-insensitive
111
+ sales[product: /Widget|Gadget/] # alternation
112
+ sales[product: /^W/, quarter: 'Q1'] # mixed with exact
82
113
  ```
83
114
 
115
+ Procs and regexes mix freely with exact values, arrays, ranges, projection, and contraction in the same `[]` call.
116
+
84
117
  ### Projection
85
118
 
86
119
  Project to specific dimensions:
@@ -286,6 +319,124 @@ set_a ^ set_b
286
319
 
287
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.
288
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
+
289
440
  ### Equality
290
441
 
291
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/Row.rb CHANGED
@@ -16,6 +16,10 @@ class Namo
16
16
  case coordinate
17
17
  when Array, Range
18
18
  coordinate.include?(self[dimension])
19
+ when Proc
20
+ coordinate.call(self[dimension])
21
+ when Regexp
22
+ coordinate.match?(self[dimension].to_s)
19
23
  else
20
24
  self[dimension] == coordinate
21
25
  end
data/lib/Namo/VERSION.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # Namo::VERSION
3
3
 
4
4
  class Namo
5
- VERSION = '0.7.0'
5
+ VERSION = '0.9.0'
6
6
  end
data/lib/namo.rb CHANGED
@@ -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,6 +237,18 @@ class Namo
201
237
  end
202
238
  end
203
239
 
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
+
204
252
  def initialize(data = nil, formulae: {})
205
253
  @data = data
206
254
  @formulae = formulae
@@ -1,3 +1,4 @@
1
+ require 'date'
1
2
  require 'minitest/autorun'
2
3
  require 'minitest-spec-context'
3
4
 
@@ -59,6 +60,154 @@ describe Namo::Row do
59
60
  _(row.match?(product: 'Widget', quarter: 'Q1')).must_equal true
60
61
  _(row.match?(product: 'Widget', quarter: 'Q2')).must_equal false
61
62
  end
63
+
64
+ describe "Proc predicates" do
65
+ it "matches when the proc returns true" do
66
+ _(row.match?(price: ->(v){v < 15.0})).must_equal true
67
+ end
68
+
69
+ it "doesn't match when the proc returns false" do
70
+ _(row.match?(price: ->(v){v > 100.0})).must_equal false
71
+ end
72
+
73
+ it "doesn't match when the proc returns nil" do
74
+ _(row.match?(price: ->(v){nil})).must_equal false
75
+ end
76
+
77
+ it "matches when the proc returns a truthy non-boolean" do
78
+ _(row.match?(price: ->(v){"truthy"})).must_equal true
79
+ end
80
+
81
+ it "passes nil to the proc when the dimension is missing" do
82
+ seen = nil
83
+ row.match?(missing: ->(v){seen = v; true})
84
+ _(seen).must_be_nil
85
+ end
86
+
87
+ it "lets the proc decide what to do with a nil value" do
88
+ _(row.match?(missing: ->(v){v.nil?})).must_equal true
89
+ _(row.match?(missing: ->(v){!v.nil?})).must_equal false
90
+ end
91
+
92
+ it "composes with an exact value on another dimension" do
93
+ _(row.match?(price: ->(v){v < 15.0}, product: 'Widget')).must_equal true
94
+ _(row.match?(price: ->(v){v < 15.0}, product: 'Gadget')).must_equal false
95
+ end
96
+
97
+ it "composes with an array on another dimension" do
98
+ _(row.match?(price: ->(v){v < 15.0}, product: ['Widget', 'Gadget'])).must_equal true
99
+ _(row.match?(price: ->(v){v < 15.0}, product: ['Gadget'])).must_equal false
100
+ end
101
+
102
+ it "composes with a range on another dimension" do
103
+ _(row.match?(price: ->(v){v < 15.0}, quantity: 50..150)).must_equal true
104
+ _(row.match?(price: ->(v){v < 15.0}, quantity: 200..300)).must_equal false
105
+ end
106
+
107
+ it "composes with a regex on another dimension" do
108
+ _(row.match?(price: ->(v){v < 15.0}, product: /^W/)).must_equal true
109
+ _(row.match?(price: ->(v){v < 15.0}, product: /^G/)).must_equal false
110
+ end
111
+
112
+ it "composes multiple proc predicates across dimensions" do
113
+ _(row.match?(
114
+ price: ->(v){v < 15.0},
115
+ quantity: ->(v){v >= 100}
116
+ )).must_equal true
117
+ _(row.match?(
118
+ price: ->(v){v < 15.0},
119
+ quantity: ->(v){v >= 200}
120
+ )).must_equal false
121
+ end
122
+
123
+ it "carries through to a formula-defined dimension" do
124
+ formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
125
+ _(row.match?(revenue: ->(v){v == 1000.0})).must_equal true
126
+ _(row.match?(revenue: ->(v){v > 5000.0})).must_equal false
127
+ end
128
+ end
129
+
130
+ describe "Regexp predicates" do
131
+ it "matches against a String value" do
132
+ _(row.match?(product: /Widget/)).must_equal true
133
+ end
134
+
135
+ it "doesn't match when the regex doesn't apply" do
136
+ _(row.match?(product: /Gadget/)).must_equal false
137
+ end
138
+
139
+ it "supports case-insensitive matching" do
140
+ _(row.match?(product: /widget/i)).must_equal true
141
+ _(row.match?(product: /widget/)).must_equal false
142
+ end
143
+
144
+ it "supports anchored patterns" do
145
+ _(row.match?(product: /^Wid/)).must_equal true
146
+ _(row.match?(product: /^Gad/)).must_equal false
147
+ end
148
+
149
+ it "coerces Integer values via to_s" do
150
+ _(row.match?(quantity: /100/)).must_equal true
151
+ _(row.match?(quantity: /^1/)).must_equal true
152
+ _(row.match?(quantity: /^9/)).must_equal false
153
+ end
154
+
155
+ it "coerces Float values via to_s" do
156
+ _(row.match?(price: /^10\./)).must_equal true
157
+ _(row.match?(price: /\.0$/)).must_equal true
158
+ _(row.match?(price: /^99/)).must_equal false
159
+ end
160
+
161
+ it "coerces Date values via to_s" do
162
+ row_data[:date] = Date.new(2026, 5, 21)
163
+ _(row.match?(date: /^2026/)).must_equal true
164
+ _(row.match?(date: /-05-/)).must_equal true
165
+ _(row.match?(date: /^2025/)).must_equal false
166
+ end
167
+
168
+ it "coerces Symbol values via to_s" do
169
+ row_data[:tag] = :priority
170
+ _(row.match?(tag: /priority/)).must_equal true
171
+ _(row.match?(tag: /^pri/)).must_equal true
172
+ _(row.match?(tag: /xyz/)).must_equal false
173
+ end
174
+
175
+ it "coerces nil to an empty string" do
176
+ _(row.match?(missing: //)).must_equal true
177
+ _(row.match?(missing: /./)).must_equal false
178
+ end
179
+
180
+ it "composes with an exact value on another dimension" do
181
+ _(row.match?(product: /^W/, quarter: 'Q1')).must_equal true
182
+ _(row.match?(product: /^W/, quarter: 'Q2')).must_equal false
183
+ end
184
+
185
+ it "composes with an array on another dimension" do
186
+ _(row.match?(product: /^W/, quarter: ['Q1', 'Q2'])).must_equal true
187
+ _(row.match?(product: /^W/, quarter: ['Q3'])).must_equal false
188
+ end
189
+
190
+ it "composes with a range on another dimension" do
191
+ _(row.match?(product: /^W/, price: 5.0..15.0)).must_equal true
192
+ _(row.match?(product: /^W/, price: 20.0..30.0)).must_equal false
193
+ end
194
+
195
+ it "composes with a proc on another dimension" do
196
+ _(row.match?(product: /^W/, quantity: ->(v){v >= 100})).must_equal true
197
+ _(row.match?(product: /^W/, quantity: ->(v){v >= 200})).must_equal false
198
+ end
199
+
200
+ it "composes multiple regex predicates across dimensions" do
201
+ _(row.match?(product: /^W/, quarter: /^Q/)).must_equal true
202
+ _(row.match?(product: /^W/, quarter: /^X/)).must_equal false
203
+ end
204
+
205
+ it "carries through to a formula-defined dimension" do
206
+ formulae[:label] = proc{|r| "#{r[:product]}-#{r[:quarter]}"}
207
+ _(row.match?(label: /Widget-Q1/)).must_equal true
208
+ _(row.match?(label: /Gadget/)).must_equal false
209
+ end
210
+ end
62
211
  end
63
212
 
64
213
  describe "#to_h" do
data/test/namo_test.rb CHANGED
@@ -272,6 +272,110 @@ describe Namo do
272
272
  ]
273
273
  end
274
274
  end
275
+
276
+ context "proc selection" do
277
+ it "selects rows where the proc returns truthy" do
278
+ result = sales[price: ->(v){v < 15.0}]
279
+ _(result.to_a.count).must_equal 2
280
+ _(result.to_a.map{|row| row[:product]}).must_equal ['Widget', 'Widget']
281
+ end
282
+
283
+ it "selects on multiple proc dimensions" do
284
+ result = sales[price: ->(v){v < 30.0}, quantity: ->(v){v > 50}]
285
+ _(result.to_a).must_equal [
286
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
287
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
288
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
289
+ ]
290
+ end
291
+
292
+ it "composes with projection in a single call" do
293
+ result = sales[:product, :price, price: ->(v){v < 15.0}]
294
+ _(result.to_a).must_equal [
295
+ {product: 'Widget', price: 10.0},
296
+ {product: 'Widget', price: 10.0}
297
+ ]
298
+ end
299
+
300
+ it "composes with contraction in a single call" do
301
+ result = sales[-:quantity, price: ->(v){v < 15.0}]
302
+ _(result.to_a).must_equal [
303
+ {product: 'Widget', quarter: 'Q1', price: 10.0},
304
+ {product: 'Widget', quarter: 'Q2', price: 10.0}
305
+ ]
306
+ end
307
+
308
+ it "selects on a formula-defined dimension" do
309
+ sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
310
+ result = sales[revenue: ->(v){v >= 1500.0}]
311
+ _(result.to_a).must_equal [
312
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
313
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
314
+ ]
315
+ end
316
+ end
317
+
318
+ context "regex selection" do
319
+ it "selects by regex against String values" do
320
+ result = sales[product: /^W/]
321
+ _(result.to_a.count).must_equal 2
322
+ _(result.to_a.map{|row| row[:product]}).must_equal ['Widget', 'Widget']
323
+ end
324
+
325
+ it "supports case-insensitive matching" do
326
+ result = sales[product: /widget/i]
327
+ _(result.to_a.count).must_equal 2
328
+ end
329
+
330
+ it "supports alternation" do
331
+ result = sales[product: /Widget|Gadget/]
332
+ _(result.to_a.count).must_equal 4
333
+ end
334
+
335
+ it "coerces non-String values via to_s" do
336
+ result = sales[quantity: /^1/]
337
+ _(result.to_a.map{|row| row[:quantity]}).must_equal [100, 150]
338
+ end
339
+
340
+ it "composes with an exact value on another dimension" do
341
+ result = sales[product: /^W/, quarter: 'Q1']
342
+ _(result.to_a).must_equal [
343
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100}
344
+ ]
345
+ end
346
+
347
+ it "composes with projection in a single call" do
348
+ result = sales[:product, :quarter, product: /^W/]
349
+ _(result.to_a).must_equal [
350
+ {product: 'Widget', quarter: 'Q1'},
351
+ {product: 'Widget', quarter: 'Q2'}
352
+ ]
353
+ end
354
+
355
+ it "composes with contraction in a single call" do
356
+ result = sales[-:price, -:quantity, product: /^W/]
357
+ _(result.to_a).must_equal [
358
+ {product: 'Widget', quarter: 'Q1'},
359
+ {product: 'Widget', quarter: 'Q2'}
360
+ ]
361
+ end
362
+
363
+ it "selects on a formula-defined dimension" do
364
+ sales[:label] = proc{|r| "#{r[:product]}-#{r[:quarter]}"}
365
+ result = sales[label: /Widget/]
366
+ _(result.to_a.count).must_equal 2
367
+ _(result.map{|row| row[:label]}).must_equal ['Widget-Q1', 'Widget-Q2']
368
+ end
369
+ end
370
+
371
+ context "mixed proc and regex selection" do
372
+ it "combines a proc and a regex across dimensions" do
373
+ result = sales[product: /^W/, quantity: ->(v){v > 100}]
374
+ _(result.to_a).must_equal [
375
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150}
376
+ ]
377
+ end
378
+ end
275
379
  end
276
380
 
277
381
  describe "#[]= formulae" do
@@ -606,6 +710,288 @@ describe Namo do
606
710
  end
607
711
  end
608
712
 
713
+ describe "#*" do
714
+ let(:ohlcv) do
715
+ Namo.new([
716
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5},
717
+ {symbol: 'RIO', date: '2025-01-01', close: 118.3}
718
+ ])
719
+ end
720
+
721
+ let(:fundamentals) do
722
+ Namo.new([
723
+ {symbol: 'BHP', pe: 14.5},
724
+ {symbol: 'RIO', pe: 9.2}
725
+ ])
726
+ end
727
+
728
+ it "joins on a single shared dimension" do
729
+ result = ohlcv * fundamentals
730
+ _(result.to_a).must_equal [
731
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5, pe: 14.5},
732
+ {symbol: 'RIO', date: '2025-01-01', close: 118.3, pe: 9.2}
733
+ ]
734
+ end
735
+
736
+ it "joins on multiple shared dimensions" do
737
+ a = Namo.new([
738
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5},
739
+ {symbol: 'BHP', date: '2025-01-02', close: 43.0}
740
+ ])
741
+ b = Namo.new([
742
+ {symbol: 'BHP', date: '2025-01-01', volume: 1000},
743
+ {symbol: 'BHP', date: '2025-01-02', volume: 1500}
744
+ ])
745
+ result = a * b
746
+ _(result.to_a).must_equal [
747
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5, volume: 1000},
748
+ {symbol: 'BHP', date: '2025-01-02', close: 43.0, volume: 1500}
749
+ ]
750
+ end
751
+
752
+ it "preserves non-shared dimensions from both sides" do
753
+ result = ohlcv * fundamentals
754
+ _(result.dimensions).must_equal [:symbol, :date, :close, :pe]
755
+ end
756
+
757
+ it "drops unmatched rows from both sides (inner-join symmetry)" do
758
+ left = Namo.new([
759
+ {symbol: 'BHP', close: 42.5},
760
+ {symbol: 'CBA', close: 100.0}
761
+ ])
762
+ right = Namo.new([
763
+ {symbol: 'BHP', pe: 14.5},
764
+ {symbol: 'RIO', pe: 9.2}
765
+ ])
766
+ result = left * right
767
+ _(result.to_a).must_equal [{symbol: 'BHP', close: 42.5, pe: 14.5}]
768
+ end
769
+
770
+ it "produces multiplicative duplicates when inputs have duplicates on shared dimensions" do
771
+ left = Namo.new([
772
+ {symbol: 'BHP', close: 42.5},
773
+ {symbol: 'BHP', close: 43.0}
774
+ ])
775
+ right = Namo.new([
776
+ {symbol: 'BHP', pe: 14.5},
777
+ {symbol: 'BHP', pe: 14.7}
778
+ ])
779
+ result = left * right
780
+ _(result.to_a.length).must_equal 4
781
+ _(result.to_a).must_equal [
782
+ {symbol: 'BHP', close: 42.5, pe: 14.5},
783
+ {symbol: 'BHP', close: 42.5, pe: 14.7},
784
+ {symbol: 'BHP', close: 43.0, pe: 14.5},
785
+ {symbol: 'BHP', close: 43.0, pe: 14.7}
786
+ ]
787
+ end
788
+
789
+ it "carries formulae through from self" do
790
+ ohlcv[:label] = proc{|r| "#{r[:symbol]}-self"}
791
+ result = ohlcv * fundamentals
792
+ _(result.map{|row| row[:label]}).must_equal ['BHP-self', 'RIO-self']
793
+ end
794
+
795
+ it "merges formulae from other" do
796
+ fundamentals[:flag] = proc{|r| "pe=#{r[:pe]}"}
797
+ result = ohlcv * fundamentals
798
+ _(result.map{|row| row[:flag]}).must_equal ['pe=14.5', 'pe=9.2']
799
+ end
800
+
801
+ it "prefers self's formulae on conflict" do
802
+ ohlcv[:label] = proc{|r| "self: #{r[:symbol]}"}
803
+ fundamentals[:label] = proc{|r| "other: #{r[:symbol]}"}
804
+ result = ohlcv * fundamentals
805
+ _(result.map{|row| row[:label]}).must_equal ['self: BHP', 'self: RIO']
806
+ end
807
+
808
+ it "raises ArgumentError when there are no shared dimensions" do
809
+ a = Namo.new([{symbol: 'BHP'}])
810
+ b = Namo.new([{quarter: 'Q1'}])
811
+ err = _ { a * b }.must_raise ArgumentError
812
+ _(err.message).must_match(/no shared dimensions, need to have shared dimensions/)
813
+ end
814
+
815
+ it "raises TypeError on a non-Namo operand" do
816
+ _ { ohlcv * [{symbol: 'BHP'}] }.must_raise TypeError
817
+ end
818
+
819
+ it "returns an instance of self's class" do
820
+ subclass = Class.new(Namo)
821
+ a = subclass.new([{symbol: 'BHP', close: 42.5}])
822
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}])
823
+ _((a * b).class).must_equal subclass
824
+ end
825
+ end
826
+
827
+ describe "#**" do
828
+ let(:products) do
829
+ Namo.new([{product: 'Widget'}, {product: 'Gadget'}])
830
+ end
831
+
832
+ let(:quarters) do
833
+ Namo.new([{quarter: 'Q1'}, {quarter: 'Q2'}])
834
+ end
835
+
836
+ it "Cartesian-products two disjoint Namos" do
837
+ result = products ** quarters
838
+ _(result.to_a).must_equal [
839
+ {product: 'Widget', quarter: 'Q1'},
840
+ {product: 'Widget', quarter: 'Q2'},
841
+ {product: 'Gadget', quarter: 'Q1'},
842
+ {product: 'Gadget', quarter: 'Q2'}
843
+ ]
844
+ end
845
+
846
+ it "has self.data.length * other.data.length rows" do
847
+ a = Namo.new([{x: 1}, {x: 2}, {x: 3}])
848
+ b = Namo.new([{y: 'a'}, {y: 'b'}])
849
+ _((a ** b).to_a.length).must_equal 6
850
+ end
851
+
852
+ it "output dimensions are self.data_dimensions + other.data_dimensions" do
853
+ result = products ** quarters
854
+ _(result.dimensions).must_equal [:product, :quarter]
855
+ end
856
+
857
+ it "preserves duplicates on either side multiplicatively" do
858
+ a = Namo.new([{x: 1}, {x: 1}])
859
+ b = Namo.new([{y: 'a'}, {y: 'a'}])
860
+ result = a ** b
861
+ _(result.to_a.length).must_equal 4
862
+ end
863
+
864
+ it "carries formulae through from self" do
865
+ products[:label] = proc{|r| "self: #{r[:product]}"}
866
+ result = products ** quarters
867
+ _(result.map{|row| row[:label]}).must_equal [
868
+ 'self: Widget', 'self: Widget', 'self: Gadget', 'self: Gadget'
869
+ ]
870
+ end
871
+
872
+ it "merges formulae from other" do
873
+ quarters[:flag] = proc{|r| "q=#{r[:quarter]}"}
874
+ result = products ** quarters
875
+ _(result.map{|row| row[:flag]}).must_equal ['q=Q1', 'q=Q2', 'q=Q1', 'q=Q2']
876
+ end
877
+
878
+ it "prefers self's formulae on conflict" do
879
+ products[:label] = proc{|r| "self: #{r[:product]}"}
880
+ quarters[:label] = proc{|r| "other: #{r[:quarter]}"}
881
+ result = products ** quarters
882
+ _(result.map{|row| row[:label]}).must_equal [
883
+ 'self: Widget', 'self: Widget', 'self: Gadget', 'self: Gadget'
884
+ ]
885
+ end
886
+
887
+ it "raises ArgumentError when any dimension is shared" do
888
+ a = Namo.new([{symbol: 'BHP', close: 42.5}])
889
+ b = Namo.new([{symbol: 'RIO', pe: 14.5}])
890
+ err = _ { a ** b }.must_raise ArgumentError
891
+ _(err.message).must_match(/dimensions in common, need no common dimensions/)
892
+ end
893
+
894
+ it "raises TypeError on a non-Namo operand" do
895
+ _ { products ** [{quarter: 'Q1'}] }.must_raise TypeError
896
+ end
897
+
898
+ it "returns an instance of self's class" do
899
+ subclass = Class.new(Namo)
900
+ a = subclass.new([{product: 'Widget'}])
901
+ b = Namo.new([{quarter: 'Q1'}])
902
+ _((a ** b).class).must_equal subclass
903
+ end
904
+ end
905
+
906
+ describe "#/" do
907
+ let(:combined) do
908
+ Namo.new([
909
+ {symbol: 'BHP', date: '2025-01-01', close: 42.5, pe: 14.5},
910
+ {symbol: 'RIO', date: '2025-01-01', close: 118.3, pe: 9.2}
911
+ ])
912
+ end
913
+
914
+ let(:fundamentals) do
915
+ Namo.new([
916
+ {symbol: 'BHP', pe: 14.5},
917
+ {symbol: 'RIO', pe: 9.2}
918
+ ])
919
+ end
920
+
921
+ it "removes dimensions present in both self and other (the intersection)" do
922
+ result = combined / fundamentals
923
+ _(result.dimensions).must_equal [:date, :close]
924
+ end
925
+
926
+ it "preserves dimensions exclusive to self" do
927
+ result = combined / fundamentals
928
+ _(result.to_a).must_equal [
929
+ {date: '2025-01-01', close: 42.5},
930
+ {date: '2025-01-01', close: 118.3}
931
+ ]
932
+ end
933
+
934
+ it "dedupes rows that collide after projection" do
935
+ a = Namo.new([
936
+ {symbol: 'BHP', close: 42.5},
937
+ {symbol: 'RIO', close: 42.5}
938
+ ])
939
+ b = Namo.new([{symbol: 'X'}])
940
+ result = a / b
941
+ _(result.to_a).must_equal [{close: 42.5}]
942
+ end
943
+
944
+ it "carries formulae through from self" do
945
+ combined[:label] = proc{|r| "row: #{r[:close]}"}
946
+ result = combined / fundamentals
947
+ _(result.map{|row| row[:label]}).must_equal ['row: 42.5', 'row: 118.3']
948
+ end
949
+
950
+ it "is a no-op when self and other share no dimensions" do
951
+ shipments = Namo.new([{order_id: 1, weight: 10}])
952
+ weather = Namo.new([{date: '2025-01-01', temperature: 22}])
953
+ _(shipments / weather).must_equal shipments
954
+ end
955
+
956
+ it "ignores dimensions present in other but not in self" do
957
+ a = Namo.new([{symbol: 'BHP', close: 42.5}])
958
+ b = Namo.new([{symbol: 'BHP', pe: 14.5, sector: 'Mining'}])
959
+ result = a / b
960
+ _(result.dimensions).must_equal [:close]
961
+ end
962
+
963
+ it "is idempotent" do
964
+ first = combined / fundamentals
965
+ second = first / fundamentals
966
+ _(second).must_equal first
967
+ end
968
+
969
+ it "raises TypeError on a non-Namo operand" do
970
+ _ { combined / [{symbol: 'BHP'}] }.must_raise TypeError
971
+ end
972
+
973
+ it "returns an instance of self's class" do
974
+ subclass = Class.new(Namo)
975
+ a = subclass.new([{symbol: 'BHP', close: 42.5}])
976
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}])
977
+ _((a / b).class).must_equal subclass
978
+ end
979
+ end
980
+
981
+ describe "composition round-trip" do
982
+ it "satisfies (a ** b) / b == a for disjoint a and b" do
983
+ a = Namo.new([{symbol: 'BHP'}, {symbol: 'RIO'}])
984
+ b = Namo.new([{quarter: 'Q1'}, {quarter: 'Q2'}])
985
+ _((a ** b) / b).must_equal a
986
+ end
987
+
988
+ it "satisfies (a * b) / b == a[-:shared] for a and b with shared dimensions (shared dimensions lost)" do
989
+ a = Namo.new([{symbol: 'BHP', close: 42.5}, {symbol: 'RIO', close: 118.3}])
990
+ b = Namo.new([{symbol: 'BHP', pe: 14.5}, {symbol: 'RIO', pe: 9.2}])
991
+ _((a * b) / b).must_equal Namo.new([{close: 42.5}, {close: 118.3}])
992
+ end
993
+ end
994
+
609
995
  describe "#==" do
610
996
  it "is true for same data, same order" do
611
997
  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.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran