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 +4 -4
- data/CHANGELOG +31 -2
- data/README.md +151 -0
- data/lib/Namo/Row.rb +4 -0
- data/lib/Namo/VERSION.rb +1 -1
- data/lib/namo.rb +48 -0
- data/test/Namo/Row_test.rb +149 -0
- data/test/namo_test.rb +386 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7adbe8192367d3c4207f7b27ff0b6a8a13f5243b42a4e4f18a6fbc35ef6439be
|
|
4
|
+
data.tar.gz: cda1e8b3d8fc042b4457efc0bc7846d5a416a5eda670d140824a4f8ad193a0f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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. ~
|
|
18
|
-
12. ~
|
|
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
data/lib/Namo/VERSION.rb
CHANGED
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
|
data/test/Namo/Row_test.rb
CHANGED
|
@@ -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}])
|