namo 0.15.0 → 0.17.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 +94 -0
- data/README.md +46 -2
- data/Rakefile +1 -1
- data/lib/Namo/Row.rb +30 -6
- data/lib/Namo/VERSION.rb +1 -1
- data/lib/namo.rb +25 -2
- data/test/Namo/Row_test.rb +105 -0
- data/test/namo_test.rb +323 -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: f84d86032e9397e8db4a4d7a4150474c30215cf9339ec2158a38802aad1a4d49
|
|
4
|
+
data.tar.gz: 830b874e04b36acd7ab848351419ed5ceb848e0108b95602184b7e6131812e01
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1b409a5004f78619ea60186404e6f156fadbc660755cc9fc3c64cfe8a028a3ffdd7bb35dd252510ece1814674f0b02af7b347b1ea42c1280390e7d7a982f0de0
|
|
7
|
+
data.tar.gz: 15c2c28dafa605ee5a93e5ebb3bee4e5a1f8301dca7cfb11675e1f197db62052975a0f9b74a9096638062b4d40cd85b5752ce5e52c3b0c5f39463fed421aec6c
|
data/CHANGELOG
CHANGED
|
@@ -1,6 +1,100 @@
|
|
|
1
1
|
CHANGELOG
|
|
2
2
|
_________
|
|
3
3
|
|
|
4
|
+
20260613
|
|
5
|
+
0.17.0: + parameterised formulae — formulae with required parameters beyond (row, namo) receive arguments at access time through Row#[].
|
|
6
|
+
|
|
7
|
+
1. ~ lib/Namo/Row.rb: Row#[] gains a trailing splat and forwards call-site arguments. Dispatch
|
|
8
|
+
generalises from exact arity 2 to required-parameter count via the new private
|
|
9
|
+
collection_scoped? and required_parameter_count — one required parameter or none stays
|
|
10
|
+
row-scoped, two or more call formula.call(self, @namo, *arguments). Settles the 0.15.0
|
|
11
|
+
negative-arity deferral: |row, *rest| and ->(row, namo = nil){} stay row-scoped;
|
|
12
|
+
|row, namo, *fields| is collection-scoped with optional arguments.
|
|
13
|
+
2. ~ lib/Namo/Row.rb: + argument-count enforcement — the new private expected_argument_counts
|
|
14
|
+
and raise_unless_expected_arguments raise ArgumentError ("wrong number of arguments for
|
|
15
|
+
:sma (given 0, expected 2)") on the wrong count for any dimension: too few or too many for
|
|
16
|
+
a fixed-arity formula, fewer than required for a splatted one, any at all for a data
|
|
17
|
+
dimension, a row-scoped formula, or a two-arity formula. Checked before the Namo-context
|
|
18
|
+
guard, whose message generalises from "two-arity formula" to "collection-scoped formula".
|
|
19
|
+
3. ~ lib/namo.rb: the no-arg values (and so coordinates and to_h) materialise via the new
|
|
20
|
+
private materialisable_dimensions, omitting formulae that require arguments (private
|
|
21
|
+
requires_arguments? and required_parameter_count); explicit asks — values(:dim),
|
|
22
|
+
coordinates(:dim), naming the dimension in a projection, selecting on it — raise through
|
|
23
|
+
Row#[]. dimensions and derived_dimensions still list the name; the empty-Namo case returns
|
|
24
|
+
[] without invoking the formula.
|
|
25
|
+
4. ~ test/Namo/Row_test.rb: + "#[] parameterised formulae" describe — arity 3 and 4 receive
|
|
26
|
+
(row, namo, args...), trailing-splat forwarding (arity -3 and -4), one-required-parameter
|
|
27
|
+
procs stay row-scoped, a row-scoped formula calling a parameterised one with arguments,
|
|
28
|
+
missing-Namo-context raise; + "#[] argument-count enforcement" describe — the message
|
|
29
|
+
matrix for too few, too many, splat minimum, and arguments handed to data dimensions,
|
|
30
|
+
row-scoped, two-arity, and missing dimensions.
|
|
31
|
+
5. ~ test/namo_test.rb: + "#[]= parameterised formulae" describe — resolution with arguments
|
|
32
|
+
through yielded Rows, one definition serving different fields and periods, Enumerable
|
|
33
|
+
predicates, a one-arity formula referencing a parameterised one, dimension listing, bulk
|
|
34
|
+
views omitting, explicit values/coordinates/projection/selection raises, carry through
|
|
35
|
+
contraction, selection (windowing over the filtered rows), and set operators, the
|
|
36
|
+
one-arity wrapper materialisation idiom, namo-plus-splat formulae materialising in the
|
|
37
|
+
bulk views, and the empty-Namo case.
|
|
38
|
+
6. ~ README.md: + Parameterised formulae subsection under Formulae — access-time arguments,
|
|
39
|
+
the required-parameter scope rule, argument-count enforcement, materialisation behaviour,
|
|
40
|
+
and the wrapper idiom.
|
|
41
|
+
7. ~ ROADMAP.md: Promote 0.17.0 to shipped with the dispatch-rule, enforcement, and
|
|
42
|
+
materialisation rationale; Current state -> 0.17.0; Summary folds in parameterised
|
|
43
|
+
formulae; next phase -> Namo::Collection (0.18.0). Date bumped.
|
|
44
|
+
8. ~ COMPARISON.md: Parameterised formulae -> shipped (0.17.0), the entry noting access-time
|
|
45
|
+
arguments and argument-count enforcement. Date bumped.
|
|
46
|
+
9. ~ EXAMPLES.md: The finance section's composition blocks (all three Namo stages) corrected
|
|
47
|
+
from max_by, which returns a Row, to sort_by + last(1), which returns the Namo the 0.14.0
|
|
48
|
+
block contract requires — the 1.x stage, parameterised sma included, now runs end to end
|
|
49
|
+
against this release. The finance highlight notes the parameterised formula as shipped
|
|
50
|
+
(0.17.0). Date bumped.
|
|
51
|
+
10. ~ Namo::VERSION: /0.16.0/0.17.0/
|
|
52
|
+
|
|
53
|
+
20260612
|
|
54
|
+
0.16.0: ~ data/formula exclusivity — projection drops the formulae it materialises; * and ** raise on a data/formula name collision.
|
|
55
|
+
|
|
56
|
+
1. ~ lib/namo.rb: Namo#[]'s positive-projection branch carries @formulae minus the projected
|
|
57
|
+
derived names — naming a derived dimension materialises it (stored values, computed against
|
|
58
|
+
the yielding Namo, windowed over any same-call selection) and drops the formula; omitted
|
|
59
|
+
formulae carry live and recompute from the result's own rows. Contraction and selection-only
|
|
60
|
+
calls are unchanged. The projection list is the materialise/live selector.
|
|
61
|
+
2. ~ lib/namo.rb: Namo#* and Namo#** raise ArgumentError via the new private
|
|
62
|
+
raise_unless_data_formula_exclusivity when one operand's data dimension is the other's
|
|
63
|
+
derived dimension, block and no-block forms alike. Formula-vs-formula stays left-wins; the
|
|
64
|
+
set operators need no guard (matching-data-dimensions blocks the asymmetric case); the
|
|
65
|
+
constructor stays unguarded.
|
|
66
|
+
3. ~ test/namo_test.rb: + "data/formula exclusivity" describe — access-path agreement on a
|
|
67
|
+
materialised dimension, dimension-listing, dependent-formula carry, omitted-formula
|
|
68
|
+
liveness, live-without-inputs caveat, two-arity windowing at materialisation,
|
|
69
|
+
contraction/selection unchanged, subclass type, composition collision raises (both
|
|
70
|
+
directions, both operators, block forms), left-wins formula merge, contraction-first
|
|
71
|
+
resolution.
|
|
72
|
+
4. ~ test/namo_test.rb: + "range selection" context under #[] — basic range, beginless and
|
|
73
|
+
endless forms, range composed with projection, range on a formula-defined dimension
|
|
74
|
+
(Row_test holds the predicate matrix; these pin the Namo-level wiring).
|
|
75
|
+
5. ~ README.md: + Projection of derived dimensions subsection under Formulae (naming
|
|
76
|
+
materialises, omitting carries live); data/formula collision sentences under Composition
|
|
77
|
+
and Cartesian product.
|
|
78
|
+
6. ~ ROADMAP.md: Promote 0.16.0 to shipped; Current state -> 0.16.0; Summary folds in
|
|
79
|
+
exclusivity; next phase -> 0.17.0. window.length -> window.count and n.length -> n.count
|
|
80
|
+
in the remaining future-release examples (0.17.0, 2.x, 4.x) — Namo has no #length,
|
|
81
|
+
extending the 0.15.0 correction.
|
|
82
|
+
7. ~ COMPARISON.md: Repoint the pre-renumbering planned markers to what shipped — proc-based,
|
|
83
|
+
regex-based, and mixed selection -> shipped (0.8.0); Enumerable methods return Namos ->
|
|
84
|
+
shipped (0.11.0), with the entry summary's parity sentence and the Sorting entry's
|
|
85
|
+
"as of 0.14.0" corrected to 0.11.0; values and to_h -> shipped (0.7.0). Aspect classes ->
|
|
86
|
+
not planned, the entry rewritten to record 0.7.0's plain-return-types decision (Namo#===
|
|
87
|
+
and subclassing cover case dispatch; a Matcher factory can serve a finer split later).
|
|
88
|
+
Aggregation repointed from 2.x to group_by returning a Namo::Collection at 0.19.0 (gated
|
|
89
|
+
on Collection at 0.18.0), with summary/members examples; bare names stay 2.x. Parameterised
|
|
90
|
+
formulae stays planned (0.17.0), its example's window.length -> window.count.
|
|
91
|
+
8. ~ EXAMPLES.md: + Epidemiology / public health section — a cross-row (two-arity) rolling
|
|
92
|
+
weekly average in the four-stage format (Polars, then Namo 1.x/2.x/3.x), with the
|
|
93
|
+
(row, namo) window over the yielding Namo and a one-arity formula referencing the
|
|
94
|
+
two-arity one. window.length -> window.count in the finance 1.x and 2.x stages. Date
|
|
95
|
+
bumped to 20260612.
|
|
96
|
+
9. ~ Namo::VERSION: /0.15.0/0.16.0/
|
|
97
|
+
|
|
4
98
|
20260612
|
|
5
99
|
0.15.0: + two-arity formulae — procs with arity 2 receive (row, namo) for cross-row computation.
|
|
6
100
|
|
data/README.md
CHANGED
|
@@ -353,7 +353,7 @@ ohlcv * fundamentals
|
|
|
353
353
|
|
|
354
354
|
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.
|
|
355
355
|
|
|
356
|
-
The two Namos must have at least one shared data dimension. No overlap raises an `ArgumentError` — the asymmetry with `**` is deliberate, and falling through to a Cartesian product would silently turn a logic error into a large pile of nonsense rows. Formulae merge from both sides; the left-hand side wins on conflict.
|
|
356
|
+
The two Namos must have at least one shared data dimension. No overlap raises an `ArgumentError` — the asymmetry with `**` is deliberate, and falling through to a Cartesian product would silently turn a logic error into a large pile of nonsense rows. Formulae merge from both sides; the left-hand side wins on conflict. A name that is data on one side and a formula on the other also raises an `ArgumentError` — the operands disagree about what the name means, with no last-write order to appeal to — so resolve before composing: `audited[-:margin] * modelled`.
|
|
357
357
|
|
|
358
358
|
#### Conditional join
|
|
359
359
|
|
|
@@ -404,7 +404,7 @@ products ** quarters
|
|
|
404
404
|
|
|
405
405
|
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.
|
|
406
406
|
|
|
407
|
-
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
|
|
407
|
+
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, and a data/formula name collision between the operands raises, as for `*`.
|
|
408
408
|
|
|
409
409
|
The visual relationship is intentional: `*` is the filtered version, `**` is the explosive version — more sigil, more output.
|
|
410
410
|
|
|
@@ -644,6 +644,20 @@ sales[product: 'Widget'][:revenue, :quarter]
|
|
|
644
644
|
|
|
645
645
|
Formulae carry through selection — a filtered Namo instance remembers its formulae.
|
|
646
646
|
|
|
647
|
+
#### Projection of derived dimensions
|
|
648
|
+
|
|
649
|
+
Naming a derived dimension in a projection asks for its values: they are computed against the source and stored in the result's rows, and the formula is dropped — the name is a data dimension of the result. Omitting it carries the formula live, recomputing from the result's own rows on every access:
|
|
650
|
+
|
|
651
|
+
```ruby
|
|
652
|
+
sales[:price, :quantity, :revenue].derived_dimensions
|
|
653
|
+
# => [] — :revenue is stored values, a snapshot taken at projection
|
|
654
|
+
|
|
655
|
+
sales[:price, :quantity].derived_dimensions
|
|
656
|
+
# => [:revenue] — :revenue recomputes from the projected rows on every access
|
|
657
|
+
```
|
|
658
|
+
|
|
659
|
+
The projection list is the selector: name a derived dimension for a snapshot, omit it to keep it as computation. A carried formula whose inputs the projection dropped breaks on access — the same caveat as contracting away a formula's inputs.
|
|
660
|
+
|
|
647
661
|
#### Cross-row formulae
|
|
648
662
|
|
|
649
663
|
A formula's arity selects its calling convention. A proc with **one** parameter receives the row, as above. A proc with **two** parameters receives `(row, namo)`, where `namo` is the Namo the row belongs to — so the formula can reach beyond the current row to the rest of the collection. That's what cross-row computation needs: moving windows, ranks, running totals, anything whose value depends on the row's neighbours.
|
|
@@ -672,6 +686,36 @@ One-arity formulae are unchanged, and the two forms mix freely — a one-arity f
|
|
|
672
686
|
|
|
673
687
|
Resolving a two-arity formula needs a Namo to window over. A `Row` constructed directly, without one, raises an `ArgumentError` naming the formula rather than letting the missing context surface as an unrelated error.
|
|
674
688
|
|
|
689
|
+
#### Parameterised formulae
|
|
690
|
+
|
|
691
|
+
A formula can declare parameters beyond `(row, namo)`. The arguments arrive at access time, through `Row#[]`, so one definition serves every column and every setting:
|
|
692
|
+
|
|
693
|
+
```ruby
|
|
694
|
+
prices[:sma] = proc do |row, namo, field, period|
|
|
695
|
+
window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}].last(period)
|
|
696
|
+
window.sum{|r| r[field]} / window.count.to_f
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
prices.last[:sma, :close, 20] # 20-period moving average of close
|
|
700
|
+
prices.last[:sma, :volume, 50] # 50-period moving average of volume
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
The number of *required* parameters decides a formula's calling convention. One means row-scoped, two or more means collection-scoped, and everything past the second receives the arguments given at the call site. A trailing splat or optional after `(row, namo)` makes the arguments optional — `proc{|row, namo, *fields|}` accepts any number, including none. A proc whose second parameter is optional (`->(row, namo = nil){...}`) requires only one, so it stays row-scoped.
|
|
704
|
+
|
|
705
|
+
Argument counts are enforced. Asking with the wrong number — too few for the formula's parameters, too many for a fixed-arity proc, or any at all for a data dimension or an unparameterised formula — raises an `ArgumentError` stating the counts, rather than letting `nil` flow into the formula body:
|
|
706
|
+
|
|
707
|
+
```ruby
|
|
708
|
+
prices.last[:sma] # ArgumentError: wrong number of arguments for :sma (given 0, expected 2)
|
|
709
|
+
prices.last[:close, 20] # ArgumentError: wrong number of arguments for :close (given 1, expected 0)
|
|
710
|
+
```
|
|
711
|
+
|
|
712
|
+
A formula that requires arguments can't be materialised without them. `values(:sma)`, `coordinates(:sma)`, naming `:sma` in a projection, and selecting on it all raise the same `ArgumentError`; the no-argument `values`, `coordinates`, and `to_h` omit the dimension, returning everything that can be materialised. `dimensions` and `derived_dimensions` still list it — it is queryable, with arguments. To materialise particular values, bind the arguments in a one-arity wrapper and ask for that:
|
|
713
|
+
|
|
714
|
+
```ruby
|
|
715
|
+
prices[:sma_close_20] = proc{|row| row[:sma, :close, 20]}
|
|
716
|
+
prices[:date, :sma_close_20] # materialises per the usual projection rule
|
|
717
|
+
```
|
|
718
|
+
|
|
675
719
|
### Polymorphic `[]=`
|
|
676
720
|
|
|
677
721
|
`[]=` dispatches on the type of the value assigned. A proc registers a formula, as above. Anything else broadcasts the value to every row:
|
data/Rakefile
CHANGED
|
@@ -21,7 +21,7 @@ namespace :docs do
|
|
|
21
21
|
task :md2pdf => :md4print do
|
|
22
22
|
Dir.glob('docs/*.print.md').each do |f|
|
|
23
23
|
pdf = f.sub(/\.md$/, '.pdf')
|
|
24
|
-
sh "pandoc #{f} --pdf-engine=xelatex -V geometry:margin=1in -V mainfont=Charter -V monofont=Menlo -o #{pdf}"
|
|
24
|
+
sh "pandoc #{f} --pdf-engine=xelatex --include-in-header script/print.preamble.tex -V geometry:margin=1in -V mainfont=Charter -V monofont=Menlo -o #{pdf}"
|
|
25
25
|
end
|
|
26
26
|
end
|
|
27
27
|
|
data/lib/Namo/Row.rb
CHANGED
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
|
|
4
4
|
class Namo
|
|
5
5
|
class Row
|
|
6
|
-
def [](name)
|
|
6
|
+
def [](name, *arguments)
|
|
7
|
+
raise_unless_expected_arguments(name, arguments)
|
|
7
8
|
if @formulae.key?(name)
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
formula = @formulae[name]
|
|
10
|
+
if collection_scoped?(formula)
|
|
10
11
|
raise_unless_namo_context(name)
|
|
11
|
-
|
|
12
|
+
formula.call(self, @namo, *arguments)
|
|
12
13
|
else
|
|
13
|
-
|
|
14
|
+
formula.call(self)
|
|
14
15
|
end
|
|
15
16
|
else
|
|
16
17
|
@row[name]
|
|
@@ -56,9 +57,32 @@ class Namo
|
|
|
56
57
|
@namo = namo
|
|
57
58
|
end
|
|
58
59
|
|
|
60
|
+
def collection_scoped?(formula)
|
|
61
|
+
required_parameter_count(formula) >= 2
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def required_parameter_count(formula)
|
|
65
|
+
formula.arity >= 0 ? formula.arity : -formula.arity - 1
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def expected_argument_counts(name)
|
|
69
|
+
formula = @formulae[name]
|
|
70
|
+
return [0, 0] unless formula && collection_scoped?(formula)
|
|
71
|
+
minimum = required_parameter_count(formula) - 2
|
|
72
|
+
maximum = formula.arity >= 0 ? minimum : nil
|
|
73
|
+
[minimum, maximum]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def raise_unless_expected_arguments(name, arguments)
|
|
77
|
+
minimum, maximum = expected_argument_counts(name)
|
|
78
|
+
return if arguments.length >= minimum && (maximum.nil? || arguments.length <= maximum)
|
|
79
|
+
expected = maximum.nil? ? "#{minimum}+" : minimum.to_s
|
|
80
|
+
raise ArgumentError, "wrong number of arguments for #{name.inspect} (given #{arguments.length}, expected #{expected})"
|
|
81
|
+
end
|
|
82
|
+
|
|
59
83
|
def raise_unless_namo_context(name)
|
|
60
84
|
unless @namo
|
|
61
|
-
raise ArgumentError, "
|
|
85
|
+
raise ArgumentError, "collection-scoped formula #{name.inspect} requires a Namo context, but this Row has none"
|
|
62
86
|
end
|
|
63
87
|
end
|
|
64
88
|
end
|
data/lib/Namo/VERSION.rb
CHANGED
data/lib/namo.rb
CHANGED
|
@@ -28,7 +28,7 @@ class Namo
|
|
|
28
28
|
|
|
29
29
|
def values(*dims)
|
|
30
30
|
if dims.empty?
|
|
31
|
-
|
|
31
|
+
materialisable_dimensions.each_with_object({}){|dim, hash| hash[dim] = values_for(dim)}
|
|
32
32
|
elsif dims.length == 1
|
|
33
33
|
values_for(dims.first)
|
|
34
34
|
else
|
|
@@ -71,7 +71,8 @@ class Namo
|
|
|
71
71
|
rows.map(&:to_h)
|
|
72
72
|
end
|
|
73
73
|
)
|
|
74
|
-
|
|
74
|
+
carried = positive.any? ? @formulae.reject{|name, _| positive.include?(name)} : @formulae.dup
|
|
75
|
+
self.class.new(projected, formulae: carried)
|
|
75
76
|
end
|
|
76
77
|
|
|
77
78
|
def []=(name, value)
|
|
@@ -118,6 +119,7 @@ class Namo
|
|
|
118
119
|
def *(other, &block)
|
|
119
120
|
raise_unless_namo(other)
|
|
120
121
|
raise_unless_shared_data_dimensions(other)
|
|
122
|
+
raise_unless_data_formula_exclusivity(other)
|
|
121
123
|
shared = data_dimensions & other.data_dimensions
|
|
122
124
|
combined_data = []
|
|
123
125
|
@data.each do |left_row|
|
|
@@ -136,6 +138,7 @@ class Namo
|
|
|
136
138
|
def **(other, &block)
|
|
137
139
|
raise_unless_namo(other)
|
|
138
140
|
raise_unless_disjoint_data_dimensions(other)
|
|
141
|
+
raise_unless_data_formula_exclusivity(other)
|
|
139
142
|
combined_data = []
|
|
140
143
|
@data.each do |left_row|
|
|
141
144
|
if block
|
|
@@ -243,6 +246,19 @@ class Namo
|
|
|
243
246
|
end
|
|
244
247
|
end
|
|
245
248
|
|
|
249
|
+
def materialisable_dimensions
|
|
250
|
+
dimensions.reject{|dim| requires_arguments?(dim)}
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def requires_arguments?(name)
|
|
254
|
+
formula = @formulae[name]
|
|
255
|
+
!!formula && required_parameter_count(formula) > 2
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def required_parameter_count(formula)
|
|
259
|
+
formula.arity >= 0 ? formula.arity : -formula.arity - 1
|
|
260
|
+
end
|
|
261
|
+
|
|
246
262
|
def raise_unless_namo(other)
|
|
247
263
|
unless other.is_a?(Namo)
|
|
248
264
|
raise TypeError, "can't compare Namo with #{other.class}"
|
|
@@ -266,4 +282,11 @@ class Namo
|
|
|
266
282
|
raise ArgumentError, "dimensions in common, need no common dimensions: #{data_dimensions} vs #{other.data_dimensions}"
|
|
267
283
|
end
|
|
268
284
|
end
|
|
285
|
+
|
|
286
|
+
def raise_unless_data_formula_exclusivity(other)
|
|
287
|
+
collisions = (data_dimensions & other.derived_dimensions) | (derived_dimensions & other.data_dimensions)
|
|
288
|
+
if collisions.any?
|
|
289
|
+
raise ArgumentError, "name collision between data and formulae: #{collisions.inspect}"
|
|
290
|
+
end
|
|
291
|
+
end
|
|
269
292
|
end
|
data/test/Namo/Row_test.rb
CHANGED
|
@@ -94,6 +94,111 @@ describe Namo::Row do
|
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
describe "#[] parameterised formulae" do
|
|
98
|
+
let(:namo) do
|
|
99
|
+
Namo.new([row_data])
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
let(:contextual_row) do
|
|
103
|
+
Namo::Row.new(row_data, formulae, namo)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it "calls an arity-3 formula with the Row, the yielding Namo, and one argument" do
|
|
107
|
+
seen = nil
|
|
108
|
+
formulae[:scaled] = ->(r, n, factor){seen = [r, n, factor]; r[:price] * factor}
|
|
109
|
+
_(contextual_row[:scaled, 3]).must_equal 30.0
|
|
110
|
+
_(seen[0]).must_be_same_as contextual_row
|
|
111
|
+
_(seen[1].equal?(namo)).must_equal true
|
|
112
|
+
_(seen[2]).must_equal 3
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it "calls an arity-4 formula with two arguments" do
|
|
116
|
+
formulae[:metric] = ->(r, n, field, factor){r[field] * factor}
|
|
117
|
+
_(contextual_row[:metric, :quantity, 2]).must_equal 200
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "forwards a trailing splat's arguments past a required one (arity -4)" do
|
|
121
|
+
formulae[:dim] = proc{|r, n, field, *rest| [field, rest]}
|
|
122
|
+
_(contextual_row[:dim, :price]).must_equal [:price, []]
|
|
123
|
+
_(contextual_row[:dim, :price, 1, 2]).must_equal [:price, [1, 2]]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it "treats a splat directly after namo as collection-scoped taking any number of arguments (arity -3)" do
|
|
127
|
+
formulae[:dim] = proc{|r, n, *rest| rest}
|
|
128
|
+
_(contextual_row[:dim]).must_equal []
|
|
129
|
+
_(contextual_row[:dim, 1, 2, 3]).must_equal [1, 2, 3]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "keeps a one-required-parameter proc row-scoped regardless of trailing optionals" do
|
|
133
|
+
seen = :unset
|
|
134
|
+
formulae[:dim] = ->(r, n = :fallback){seen = n; 1}
|
|
135
|
+
contextual_row[:dim]
|
|
136
|
+
_(seen).must_equal :fallback
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it "lets a row-scoped formula call a parameterised formula with arguments" do
|
|
140
|
+
formulae[:metric] = ->(r, n, field, factor){r[field] * factor}
|
|
141
|
+
formulae[:double_quantity] = ->(r){r[:metric, :quantity, 2]}
|
|
142
|
+
_(contextual_row[:double_quantity]).must_equal 200
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it "raises ArgumentError naming the formula when a parameterised formula has no Namo context" do
|
|
146
|
+
formulae[:metric] = ->(r, n, field){r[field]}
|
|
147
|
+
error = _(proc{row[:metric, :price]}).must_raise ArgumentError
|
|
148
|
+
_(error.message).must_match(/metric/)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
describe "#[] argument-count enforcement" do
|
|
153
|
+
let(:namo) do
|
|
154
|
+
Namo.new([row_data])
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
let(:contextual_row) do
|
|
158
|
+
Namo::Row.new(row_data, formulae, namo)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it "raises when a parameterised formula is given too few arguments" do
|
|
162
|
+
formulae[:metric] = ->(r, n, field, period){r[field] * period}
|
|
163
|
+
error = _(proc{contextual_row[:metric, :price]}).must_raise ArgumentError
|
|
164
|
+
_(error.message).must_equal "wrong number of arguments for :metric (given 1, expected 2)"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it "raises when a fixed-arity parameterised formula is given too many arguments" do
|
|
168
|
+
formulae[:metric] = ->(r, n, field){r[field]}
|
|
169
|
+
error = _(proc{contextual_row[:metric, :price, 20]}).must_raise ArgumentError
|
|
170
|
+
_(error.message).must_equal "wrong number of arguments for :metric (given 2, expected 1)"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it "raises when a splatted parameterised formula is given fewer than its required arguments" do
|
|
174
|
+
formulae[:metric] = proc{|r, n, field, *rest| r[field]}
|
|
175
|
+
error = _(proc{contextual_row[:metric]}).must_raise ArgumentError
|
|
176
|
+
_(error.message).must_equal "wrong number of arguments for :metric (given 0, expected 1+)"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it "raises when arguments are given for a data dimension" do
|
|
180
|
+
error = _(proc{row[:price, 20]}).must_raise ArgumentError
|
|
181
|
+
_(error.message).must_equal "wrong number of arguments for :price (given 1, expected 0)"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it "raises when arguments are given for a row-scoped formula" do
|
|
185
|
+
formulae[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
186
|
+
error = _(proc{row[:revenue, 20]}).must_raise ArgumentError
|
|
187
|
+
_(error.message).must_equal "wrong number of arguments for :revenue (given 1, expected 0)"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it "raises when arguments are given for a two-arity formula" do
|
|
191
|
+
formulae[:row_count] = ->(r, n){n.count}
|
|
192
|
+
error = _(proc{contextual_row[:row_count, 1]}).must_raise ArgumentError
|
|
193
|
+
_(error.message).must_equal "wrong number of arguments for :row_count (given 1, expected 0)"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it "raises when arguments are given for a missing dimension" do
|
|
197
|
+
error = _(proc{row[:missing, 1]}).must_raise ArgumentError
|
|
198
|
+
_(error.message).must_equal "wrong number of arguments for :missing (given 1, expected 0)"
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
97
202
|
describe "#match?" do
|
|
98
203
|
it "matches a single value" do
|
|
99
204
|
_(row.match?(product: 'Widget')).must_equal true
|
data/test/namo_test.rb
CHANGED
|
@@ -491,6 +491,36 @@ describe Namo do
|
|
|
491
491
|
end
|
|
492
492
|
end
|
|
493
493
|
|
|
494
|
+
context "range selection" do
|
|
495
|
+
it "selects rows whose value falls within the range" do
|
|
496
|
+
result = sales[price: 5.0..15.0]
|
|
497
|
+
_(result.to_a.count).must_equal 2
|
|
498
|
+
_(result.to_a.map{|row| row[:product]}).must_equal ['Widget', 'Widget']
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
it "supports beginless and endless ranges" do
|
|
502
|
+
_(sales[price: ..15.0].to_a.count).must_equal 2
|
|
503
|
+
_(sales[quantity: 100..].to_a.count).must_equal 2
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
it "composes with projection in a single call" do
|
|
507
|
+
result = sales[:product, :quantity, quantity: 50..120]
|
|
508
|
+
_(result.to_a).must_equal [
|
|
509
|
+
{product: 'Widget', quantity: 100},
|
|
510
|
+
{product: 'Gadget', quantity: 60}
|
|
511
|
+
]
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
it "selects on a formula-defined dimension" do
|
|
515
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
516
|
+
result = sales[revenue: 1200.0..]
|
|
517
|
+
_(result.to_a).must_equal [
|
|
518
|
+
{product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
|
|
519
|
+
{product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
|
|
520
|
+
]
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
|
|
494
524
|
context "mixed proc and regex selection" do
|
|
495
525
|
it "combines a proc and a regex across dimensions" do
|
|
496
526
|
result = sales[product: /^W/, quantity: ->(v){v > 100}]
|
|
@@ -767,6 +797,299 @@ describe Namo do
|
|
|
767
797
|
end
|
|
768
798
|
end
|
|
769
799
|
|
|
800
|
+
describe "#[]= parameterised formulae" do
|
|
801
|
+
let(:price_data) do
|
|
802
|
+
[
|
|
803
|
+
{symbol: 'AAA', date: 1, close: 10.0, volume: 100},
|
|
804
|
+
{symbol: 'AAA', date: 2, close: 20.0, volume: 200},
|
|
805
|
+
{symbol: 'AAA', date: 3, close: 30.0, volume: 300},
|
|
806
|
+
]
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
let(:prices) do
|
|
810
|
+
Namo.new(price_data)
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
# A parameterised moving average: the field and the window length arrive at
|
|
814
|
+
# access time, so one definition serves every column and every period.
|
|
815
|
+
let(:sma) do
|
|
816
|
+
proc do |row, namo, field, period|
|
|
817
|
+
window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}].last(period)
|
|
818
|
+
window.sum{|r| r[field]} / window.count.to_f
|
|
819
|
+
end
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
it "resolves with arguments through a yielded Row" do
|
|
823
|
+
prices[:sma] = sma
|
|
824
|
+
_(prices.first[:sma, :close, 2]).must_equal 10.0
|
|
825
|
+
_(prices.last[:sma, :close, 2]).must_equal 25.0
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
it "serves different fields and periods from one definition" do
|
|
829
|
+
prices[:sma] = sma
|
|
830
|
+
_(prices.last[:sma, :close, 3]).must_equal 20.0
|
|
831
|
+
_(prices.last[:sma, :volume, 2]).must_equal 250.0
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
it "resolves inside an Enumerable predicate" do
|
|
835
|
+
prices[:sma] = sma
|
|
836
|
+
result = prices.select{|row| row[:sma, :close, 2] > 12.0}
|
|
837
|
+
_(result).must_be_kind_of Namo
|
|
838
|
+
_(result.values(:date)).must_equal [2, 3]
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
it "lets a one-arity formula reference a parameterised formula with arguments" do
|
|
842
|
+
prices[:sma] = sma
|
|
843
|
+
prices[:rising] = proc{|row| row[:sma, :close, 1] > row[:sma, :close, 3]}
|
|
844
|
+
_(prices.values(:rising)).must_equal [false, true, true]
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
it "lists a parameterised dimension in dimensions and derived_dimensions" do
|
|
848
|
+
prices[:sma] = sma
|
|
849
|
+
_(prices.dimensions).must_equal [:symbol, :date, :close, :volume, :sma]
|
|
850
|
+
_(prices.derived_dimensions).must_equal [:sma]
|
|
851
|
+
end
|
|
852
|
+
|
|
853
|
+
it "omits a parameterised dimension from the no-arg values, coordinates, and to_h" do
|
|
854
|
+
prices[:sma] = sma
|
|
855
|
+
_(prices.values.keys).must_equal [:symbol, :date, :close, :volume]
|
|
856
|
+
_(prices.coordinates.keys).must_equal [:symbol, :date, :close, :volume]
|
|
857
|
+
_(prices.to_h.keys).must_equal [:symbol, :date, :close, :volume]
|
|
858
|
+
end
|
|
859
|
+
|
|
860
|
+
it "raises when values is asked for a parameterised dimension by name" do
|
|
861
|
+
prices[:sma] = sma
|
|
862
|
+
error = _(proc{prices.values(:sma)}).must_raise ArgumentError
|
|
863
|
+
_(error.message).must_equal "wrong number of arguments for :sma (given 0, expected 2)"
|
|
864
|
+
end
|
|
865
|
+
|
|
866
|
+
it "raises when coordinates is asked for a parameterised dimension by name" do
|
|
867
|
+
prices[:sma] = sma
|
|
868
|
+
_(proc{prices.coordinates(:sma)}).must_raise ArgumentError
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
it "raises when a projection names a parameterised dimension" do
|
|
872
|
+
prices[:sma] = sma
|
|
873
|
+
_(proc{prices[:date, :sma]}).must_raise ArgumentError
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
it "raises when a selection selects on a parameterised dimension" do
|
|
877
|
+
prices[:sma] = sma
|
|
878
|
+
_(proc{prices[sma: ->(v){v > 12.0}]}).must_raise ArgumentError
|
|
879
|
+
end
|
|
880
|
+
|
|
881
|
+
it "carries a parameterised formula through contraction" do
|
|
882
|
+
prices[:sma] = sma
|
|
883
|
+
contracted = prices[-:volume]
|
|
884
|
+
_(contracted.derived_dimensions).must_equal [:sma]
|
|
885
|
+
_(contracted.first[:sma, :close, 2]).must_equal 10.0
|
|
886
|
+
end
|
|
887
|
+
|
|
888
|
+
it "carries a parameterised formula through selection, windowing over the filtered rows" do
|
|
889
|
+
prices[:sma] = sma
|
|
890
|
+
filtered = prices[date: 2..3]
|
|
891
|
+
_(filtered.first[:sma, :close, 2]).must_equal 20.0
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
it "materialises through a one-arity wrapper that binds the arguments" do
|
|
895
|
+
prices[:sma] = sma
|
|
896
|
+
prices[:sma_close_2] = proc{|row| row[:sma, :close, 2]}
|
|
897
|
+
_(prices.values(:sma_close_2)).must_equal [10.0, 15.0, 25.0]
|
|
898
|
+
_(prices[:date, :sma_close_2].values(:sma_close_2)).must_equal [10.0, 15.0, 25.0]
|
|
899
|
+
end
|
|
900
|
+
|
|
901
|
+
it "includes a namo-plus-splat formula in the bulk views, called with no extra arguments" do
|
|
902
|
+
prices[:flexible] = proc{|row, namo, *rest| rest.empty? ? namo.count : rest.sum}
|
|
903
|
+
_(prices.values.keys).must_include :flexible
|
|
904
|
+
_(prices.values(:flexible)).must_equal [3, 3, 3]
|
|
905
|
+
_(prices.first[:flexible, 1, 2, 4]).must_equal 7
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
it "carries a parameterised formula through a set-operator result" do
|
|
909
|
+
a = Namo.new(price_data.take(2))
|
|
910
|
+
b = Namo.new([price_data.last])
|
|
911
|
+
a[:sma] = sma
|
|
912
|
+
_((a + b).last[:sma, :close, 2]).must_equal 25.0
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
it "returns [] for a parameterised dimension on an empty Namo without invoking the formula" do
|
|
916
|
+
invoked = false
|
|
917
|
+
empty = Namo.new([], formulae: {sma: ->(row, namo, field, period){invoked = true; 0}})
|
|
918
|
+
_(empty.values(:sma)).must_equal []
|
|
919
|
+
_(invoked).must_equal false
|
|
920
|
+
end
|
|
921
|
+
end
|
|
922
|
+
|
|
923
|
+
describe "data/formula exclusivity" do
|
|
924
|
+
context "projection" do
|
|
925
|
+
let(:price_data) do
|
|
926
|
+
[
|
|
927
|
+
{symbol: 'AAA', date: 1, close: 10.0},
|
|
928
|
+
{symbol: 'AAA', date: 2, close: 20.0},
|
|
929
|
+
{symbol: 'AAA', date: 3, close: 30.0},
|
|
930
|
+
]
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
let(:prices) do
|
|
934
|
+
Namo.new(price_data)
|
|
935
|
+
end
|
|
936
|
+
|
|
937
|
+
let(:sma) do
|
|
938
|
+
->(row, namo){
|
|
939
|
+
window = namo[symbol: row[:symbol], date: ->(d){d <= row[:date]}]
|
|
940
|
+
window.values(:close).sum / window.count.to_f
|
|
941
|
+
}
|
|
942
|
+
end
|
|
943
|
+
|
|
944
|
+
it "agrees across all access paths on a materialised dimension" do
|
|
945
|
+
prices[:sma] = sma
|
|
946
|
+
projected = prices[:date, :sma]
|
|
947
|
+
_(projected.values(:sma)).must_equal [10.0, 15.0, 20.0]
|
|
948
|
+
_(projected.first[:sma]).must_equal projected.values(:sma).first
|
|
949
|
+
_(projected[sma: ->(v){v > 12.0}].values(:date)).must_equal [2, 3]
|
|
950
|
+
end
|
|
951
|
+
|
|
952
|
+
it "lists a materialised dimension as data, not derived, exactly once" do
|
|
953
|
+
prices[:sma] = sma
|
|
954
|
+
projected = prices[:date, :sma]
|
|
955
|
+
_(projected.data_dimensions).must_include :sma
|
|
956
|
+
_(projected.derived_dimensions).wont_include :sma
|
|
957
|
+
_(projected.dimensions.count(:sma)).must_equal 1
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
it "carries a dependent formula not named in the projection, resolving off the materialised column" do
|
|
961
|
+
prices[:sma] = sma
|
|
962
|
+
prices[:double_sma] = ->(row){row[:sma] * 2}
|
|
963
|
+
projected = prices[:date, :sma]
|
|
964
|
+
_(projected.derived_dimensions).must_equal [:double_sma]
|
|
965
|
+
_(projected.values(:double_sma)).must_equal [20.0, 30.0, 40.0]
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
it "carries an omitted formula live, recomputing from the result's own rows" do
|
|
969
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
970
|
+
projected = sales[:price, :quantity]
|
|
971
|
+
_(projected.derived_dimensions).must_equal [:revenue]
|
|
972
|
+
_(projected.values(:revenue)).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
|
|
973
|
+
projected.data.first[:quantity] = 200
|
|
974
|
+
_(projected.values(:revenue).first).must_equal 2000.0
|
|
975
|
+
end
|
|
976
|
+
|
|
977
|
+
it "breaks on access when a carried formula's inputs were dropped (caveat emptor)" do
|
|
978
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
979
|
+
projected = sales[:product]
|
|
980
|
+
_(projected.derived_dimensions).must_equal [:revenue]
|
|
981
|
+
_ { projected.values(:revenue) }.must_raise NoMethodError
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
it "materialises a two-arity formula windowed over the yielding Namo" do
|
|
985
|
+
prices[:sma] = sma
|
|
986
|
+
_(prices[:date, :sma].values(:sma)).must_equal [10.0, 15.0, 20.0]
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
it "windows a two-arity materialisation over a same-call selection" do
|
|
990
|
+
prices[:sma] = sma
|
|
991
|
+
projected = prices[:date, :sma, date: 2..3]
|
|
992
|
+
_(projected.values(:sma)).must_equal [20.0, 25.0]
|
|
993
|
+
end
|
|
994
|
+
|
|
995
|
+
it "carries all formulae through a selection-only call" do
|
|
996
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
997
|
+
result = sales[price: ..15.0]
|
|
998
|
+
_(result.derived_dimensions).must_equal [:revenue]
|
|
999
|
+
_(result.values(:revenue)).must_equal [1000.0, 1500.0]
|
|
1000
|
+
end
|
|
1001
|
+
|
|
1002
|
+
it "carries all formulae through contraction" do
|
|
1003
|
+
sales[:revenue] = proc{|r| r[:price] * r[:quantity]}
|
|
1004
|
+
result = sales[-:quarter]
|
|
1005
|
+
_(result.derived_dimensions).must_equal [:revenue]
|
|
1006
|
+
_(result.values(:revenue)).must_equal [1000.0, 1500.0, 1000.0, 1500.0]
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
it "returns pure materialised values and empty formulae when only derived names are projected" do
|
|
1010
|
+
prices[:sma] = sma
|
|
1011
|
+
projected = prices[:sma]
|
|
1012
|
+
_(projected.to_a).must_equal [{sma: 10.0}, {sma: 15.0}, {sma: 20.0}]
|
|
1013
|
+
_(projected.formulae).must_equal({})
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
it "returns an instance of self's class" do
|
|
1017
|
+
subclass = Class.new(Namo)
|
|
1018
|
+
namo = subclass.new([{x: 1}])
|
|
1019
|
+
namo[:double] = ->(row){row[:x] * 2}
|
|
1020
|
+
_(namo[:double].class).must_equal subclass
|
|
1021
|
+
end
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
context "composition" do
|
|
1025
|
+
let(:audited) do
|
|
1026
|
+
Namo.new([
|
|
1027
|
+
{symbol: 'BHP', margin: 0.3},
|
|
1028
|
+
{symbol: 'RIO', margin: 0.25}
|
|
1029
|
+
])
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
let(:modelled) do
|
|
1033
|
+
namo = Namo.new([
|
|
1034
|
+
{symbol: 'BHP', price: 10.0, cost: 6.0},
|
|
1035
|
+
{symbol: 'RIO', price: 20.0, cost: 16.0}
|
|
1036
|
+
])
|
|
1037
|
+
namo[:margin] = proc{|r| (r[:price] - r[:cost]) / r[:price]}
|
|
1038
|
+
namo
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1041
|
+
let(:audited_orders) do
|
|
1042
|
+
Namo.new([{order: 'A', margin: 0.3}])
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
let(:modelled_tiers) do
|
|
1046
|
+
namo = Namo.new([{tier: 'light', price: 10.0, cost: 6.0}])
|
|
1047
|
+
namo[:margin] = proc{|r| (r[:price] - r[:cost]) / r[:price]}
|
|
1048
|
+
namo
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
it "raises on * when self's data dimension is other's derived dimension" do
|
|
1052
|
+
_ { audited * modelled }.must_raise ArgumentError
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
it "raises on * when self's derived dimension is other's data dimension" do
|
|
1056
|
+
_ { modelled * audited }.must_raise ArgumentError
|
|
1057
|
+
end
|
|
1058
|
+
|
|
1059
|
+
it "raises on ** when self's data dimension is other's derived dimension" do
|
|
1060
|
+
_ { audited_orders ** modelled_tiers }.must_raise ArgumentError
|
|
1061
|
+
end
|
|
1062
|
+
|
|
1063
|
+
it "raises on ** when self's derived dimension is other's data dimension" do
|
|
1064
|
+
_ { modelled_tiers ** audited_orders }.must_raise ArgumentError
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
it "raises in the block forms of both operators" do
|
|
1068
|
+
_ { audited.*(modelled){|row, candidates| candidates} }.must_raise ArgumentError
|
|
1069
|
+
_ { audited_orders.**(modelled_tiers){|row, candidates| candidates} }.must_raise ArgumentError
|
|
1070
|
+
end
|
|
1071
|
+
|
|
1072
|
+
it "names the colliding dimensions in the message" do
|
|
1073
|
+
err = _ { audited * modelled }.must_raise ArgumentError
|
|
1074
|
+
_(err.message).must_match(/name collision between data and formulae/)
|
|
1075
|
+
_(err.message).must_include ':margin'
|
|
1076
|
+
end
|
|
1077
|
+
|
|
1078
|
+
it "does not raise on a formula-vs-formula collision — left wins" do
|
|
1079
|
+
left = Namo.new([{symbol: 'BHP', close: 42.5}])
|
|
1080
|
+
right = Namo.new([{symbol: 'BHP', pe: 14.5}])
|
|
1081
|
+
left[:margin] = proc{|r| :left}
|
|
1082
|
+
right[:margin] = proc{|r| :right}
|
|
1083
|
+
_((left * right).values(:margin)).must_equal [:left]
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
it "composes after explicit resolution by contraction" do
|
|
1087
|
+
result = audited[-:margin] * modelled
|
|
1088
|
+
_(result.values(:margin)).must_equal [0.4, 0.2]
|
|
1089
|
+
end
|
|
1090
|
+
end
|
|
1091
|
+
end
|
|
1092
|
+
|
|
770
1093
|
describe "#each" do
|
|
771
1094
|
it "yields Row objects" do
|
|
772
1095
|
rows = []
|