namo 0.16.0 → 0.18.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: dcb7aafe4522ff115c12464016fef1a6909047975829523b095105f601a7ae60
4
- data.tar.gz: 81f150f5f370f836734908a30e1ecd600b856f90c0151a698fa25bd117530d92
3
+ metadata.gz: 1ed38a77d14075cbe3c28ff53097d539c2fefe4a91ff515e165e02c280311fd3
4
+ data.tar.gz: ba009835a59174f2e60327f3583753d2e6c2b0e81b06f23de4a15dd792981526
5
5
  SHA512:
6
- metadata.gz: 6fd5a96f0fdf85e6ffb8a3cfa2d1a560d5da1d118072e95cd549af43149ff2a4c963de122b9026c29c0817e33fcfbd2ba3ece969ee1f6f5d92738c3d7f38b73a
7
- data.tar.gz: 78ba07312d7e4f4d4ac85a6ca572852964ed7055d48687361d8a86f9cb998122f421e75418ba67bc978bf78d7b0cc22315ebb800ca734e8ade07817bae886736
6
+ metadata.gz: 7328696a6b4032561fa1b68c0eb4d3e7d3582ee4d5fc24926779112413867f1713100d9c21a3aa2f18c299e50802c866b5c95e874e569f4ef41f40e7b3924f58
7
+ data.tar.gz: 8028776be37f1f183ca2cefc3c9de7229d6918cf37d05b5f925675dd2ffd7d05603cc21ba53665f74094991b0c8d08f22479de2f50ee35e36ba772777bdbfb6e
data/CHANGELOG CHANGED
@@ -1,6 +1,95 @@
1
1
  CHANGELOG
2
2
  _________
3
3
 
4
+ 20260613
5
+ 0.18.0: + Namo::Collection — hierarchical aggregate of named Namos with summary/detail views.
6
+
7
+ 1. + lib/Namo/Collection.rb: Namo::Collection < Namo, the first family member beyond Namo
8
+ itself. Holds @members (an Array of named Namos, attr_reader :members) whose substance is
9
+ the collection; the inherited @data is a derived view rebuilt from the members. << accepts a
10
+ member or an array (via flatten), replaces by name (last-write-wins, unless member.name.nil?
11
+ so nil-named members always append), rebuilds @data = detail.data, and returns self. find(name)
12
+ returns the named member or nil (find(nil) is nil, and an unnamed member never matches),
13
+ shadowing Enumerable#find on Collections. summary(dimension, by: :member, reducer: :sum)
14
+ reduces each member to a {by => member.name, dimension => reduced} row; detail(by: :member)
15
+ unions the members' rows under the inject-iff-absent conditional
16
+ (row.key?(by) ? row : row.merge(by => member.name)); both return a fresh Namo (non-mutating).
17
+ as_summary(...) and as_detail(by = :member) set @data to the chosen view and return self
18
+ (mutating); the view persists until the next << re-materialises detail. Private initialize
19
+ sets @members = [] before super.
20
+ 2. ~ lib/namo.rb: + require_relative './Namo/Collection'.
21
+ 3. + test/Namo/Collection_test.rb: empty members; lazy detail materialisation and selection;
22
+ pure-live reflection of <<; <<' add/replace-by-name/array/unnamed/returns-self; find by name,
23
+ absent, nil, and unnamed; summary's labelled rows, custom by:, :mean reducer (local Array#mean),
24
+ and non-mutation; detail's plain-Namo return, inject-iff-absent both ways, and non-mutation;
25
+ live recomputation after <<; as_summary/as_detail mutate and return self and surface the
26
+ summary's columns in dimensions; as_* view persistence until the next <<; assembly round-trip
27
+ (as_detail(:assembly) injects and is retained, removed only by contraction); subclass default
28
+ by: via a Car < Namo::Collection overriding summary/detail.
29
+ 4. ~ README.md: + Collections section after Named Namos — the GT-budget example (Car/SubAssembly,
30
+ <<, summary, detail), lazy detail materialisation, the four view methods and the
31
+ non-mutating/mutating split, the inject-iff-absent rule, << replace-by-name with use-site
32
+ handling of unnamed members, the rebuild-on-<< view lifetime, and forward-notes to group_by
33
+ (0.19.0) and freeze-gated memoisation (2.x).
34
+ 5. ~ ROADMAP.md: Promote 0.18.0 to shipped, recording the resolved materialisation model
35
+ (rebuild-on-<<, as_* persists until the next <<, diverging from the drafted pure-live-per-access
36
+ / transient wording) and the find/Enumerable#find shadowing; Current state -> 0.18.0; Summary
37
+ folds in Namo::Collection; next phase -> group_by (0.19.0). The upcoming 0.18.0 design block
38
+ folded into the shipped entry.
39
+ 6. ~ COMPARISON.md: Aggregation entry repointed — Namo::Collection shipped (0.18.0), group_by
40
+ constructor still planned (0.19.0); persistent-named-collection retained as Namo's novelty
41
+ versus the other tools' transient group-by intermediates.
42
+ 7. ~ Namo::VERSION: /0.17.0/0.18.0/
43
+
44
+ 20260613
45
+ 0.17.0: + parameterised formulae — formulae with required parameters beyond (row, namo) receive arguments at access time through Row#[].
46
+
47
+ 1. ~ lib/Namo/Row.rb: Row#[] gains a trailing splat and forwards call-site arguments. Dispatch
48
+ generalises from exact arity 2 to required-parameter count via the new private
49
+ collection_scoped? and required_parameter_count — one required parameter or none stays
50
+ row-scoped, two or more call formula.call(self, @namo, *arguments). Settles the 0.15.0
51
+ negative-arity deferral: |row, *rest| and ->(row, namo = nil){} stay row-scoped;
52
+ |row, namo, *fields| is collection-scoped with optional arguments.
53
+ 2. ~ lib/Namo/Row.rb: + argument-count enforcement — the new private expected_argument_counts
54
+ and raise_unless_expected_arguments raise ArgumentError ("wrong number of arguments for
55
+ :sma (given 0, expected 2)") on the wrong count for any dimension: too few or too many for
56
+ a fixed-arity formula, fewer than required for a splatted one, any at all for a data
57
+ dimension, a row-scoped formula, or a two-arity formula. Checked before the Namo-context
58
+ guard, whose message generalises from "two-arity formula" to "collection-scoped formula".
59
+ 3. ~ lib/namo.rb: the no-arg values (and so coordinates and to_h) materialise via the new
60
+ private materialisable_dimensions, omitting formulae that require arguments (private
61
+ requires_arguments? and required_parameter_count); explicit asks — values(:dim),
62
+ coordinates(:dim), naming the dimension in a projection, selecting on it — raise through
63
+ Row#[]. dimensions and derived_dimensions still list the name; the empty-Namo case returns
64
+ [] without invoking the formula.
65
+ 4. ~ test/Namo/Row_test.rb: + "#[] parameterised formulae" describe — arity 3 and 4 receive
66
+ (row, namo, args...), trailing-splat forwarding (arity -3 and -4), one-required-parameter
67
+ procs stay row-scoped, a row-scoped formula calling a parameterised one with arguments,
68
+ missing-Namo-context raise; + "#[] argument-count enforcement" describe — the message
69
+ matrix for too few, too many, splat minimum, and arguments handed to data dimensions,
70
+ row-scoped, two-arity, and missing dimensions.
71
+ 5. ~ test/namo_test.rb: + "#[]= parameterised formulae" describe — resolution with arguments
72
+ through yielded Rows, one definition serving different fields and periods, Enumerable
73
+ predicates, a one-arity formula referencing a parameterised one, dimension listing, bulk
74
+ views omitting, explicit values/coordinates/projection/selection raises, carry through
75
+ contraction, selection (windowing over the filtered rows), and set operators, the
76
+ one-arity wrapper materialisation idiom, namo-plus-splat formulae materialising in the
77
+ bulk views, and the empty-Namo case.
78
+ 6. ~ README.md: + Parameterised formulae subsection under Formulae — access-time arguments,
79
+ the required-parameter scope rule, argument-count enforcement, materialisation behaviour,
80
+ and the wrapper idiom.
81
+ 7. ~ ROADMAP.md: Promote 0.17.0 to shipped with the dispatch-rule, enforcement, and
82
+ materialisation rationale; Current state -> 0.17.0; Summary folds in parameterised
83
+ formulae; next phase -> Namo::Collection (0.18.0). Date bumped.
84
+ 8. ~ COMPARISON.md: Parameterised formulae -> shipped (0.17.0), the entry noting access-time
85
+ arguments and argument-count enforcement. Date bumped.
86
+ 9. ~ EXAMPLES.md: The finance section's composition blocks (all three Namo stages) corrected
87
+ from max_by, which returns a Row, to sort_by + last(1), which returns the Namo the 0.14.0
88
+ block contract requires — the 1.x stage, parameterised sma included, now runs end to end
89
+ against this release. The finance highlight notes the parameterised formula as shipped
90
+ (0.17.0). Date bumped.
91
+ 10. ~ Namo::VERSION: /0.16.0/0.17.0/
92
+
4
93
  20260612
5
94
  0.16.0: ~ data/formula exclusivity — projection drops the formulae it materialises; * and ** raise on a data/formula name collision.
6
95
 
data/README.md CHANGED
@@ -686,6 +686,36 @@ One-arity formulae are unchanged, and the two forms mix freely — a one-arity f
686
686
 
687
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.
688
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
+
689
719
  ### Polymorphic `[]=`
690
720
 
691
721
  `[]=` dispatches on the type of the value assigned. A proc registers a formula, as above. Anything else broadcasts the value to every row:
@@ -895,6 +925,121 @@ end
895
925
 
896
926
  `super` with no parentheses forwards every argument — positional and keyword — to `Namo#initialize` unchanged. The `return unless name` guard means a subclass need not override every operator to stop the result of `*` or `select` from re-running its construction side effects: it guards on `name` instead.
897
927
 
928
+ ### Collections
929
+
930
+ `Namo::Collection` is a hierarchical aggregate — a Namo that holds an Array of named Namos (its `members`) and exposes summary and detail views across them. It is the first member of the Namo family beyond `Namo` itself.
931
+
932
+ The motivating case is a hierarchical budget. Each sub-assembly of a car (`powertrain`, `chassis`, `body`, ...) is a Namo with shared columns; the whole car is a `Collection` of those sub-assemblies, queryable both at summary level ("weight by assembly") and detail level ("every line item across all assemblies"):
933
+
934
+ ```ruby
935
+ class Car < Namo::Collection
936
+ def summary(dimension, by: :assembly, reducer: :sum)
937
+ super
938
+ end
939
+
940
+ def detail(by: :assembly)
941
+ super
942
+ end
943
+ end
944
+
945
+ class SubAssembly < Namo; end
946
+
947
+ powertrain = SubAssembly.new(name: :powertrain, data: [
948
+ {component: 'engine', weight: 200, cost: 50000},
949
+ {component: 'gearbox', weight: 80, cost: 20000}
950
+ ])
951
+ chassis = SubAssembly.new(name: :chassis, data: [{component: 'frame', weight: 150, cost: 30000}])
952
+ body = SubAssembly.new(name: :body, data: [{component: 'panels', weight: 60, cost: 15000}])
953
+
954
+ gt = Car.new
955
+ gt << [powertrain, chassis, body]
956
+
957
+ gt.summary(:weight).to_a
958
+ # => [
959
+ # {assembly: :powertrain, weight: 280},
960
+ # {assembly: :chassis, weight: 150},
961
+ # {assembly: :body, weight: 60}
962
+ # ]
963
+
964
+ gt.summary(:weight).values(:weight).sum # total weight by summing the assembly summaries
965
+ # => 490
966
+
967
+ gt.detail.values(:weight).sum # total weight by summing every line item
968
+ # => 490
969
+ ```
970
+
971
+ `Car` overrides `summary`/`detail` only to set `by: :assembly` as the per-class default and then calls `super`. A bare `Namo::Collection.new` works equally well, defaulting `by:` to `:member` and taking it at the call site.
972
+
973
+ #### Lazy detail, behaving as its line items
974
+
975
+ A Collection's substance is its `members`; the inherited `@data` is a *derived view* of them. Any inherited row-operation — selection, projection, `each`, `values`, the set and composition operators — reads that view, so a Collection transparently behaves as its **detail** (the lossless union of its members' rows). Nothing has to be called first:
976
+
977
+ ```ruby
978
+ gt.values(:weight)
979
+ # => [200, 80, 150, 60]
980
+
981
+ gt[component: 'engine'].values(:cost)
982
+ # => [50000]
983
+ ```
984
+
985
+ Detail is the lazy view because a Collection's rows simply *are* its members' rows; a summary is a reduction you pose against them, so it is never reached by accident — only through `summary` or `as_summary`.
986
+
987
+ #### Four view methods
988
+
989
+ The views come in a non-mutating pair and a mutating pair:
990
+
991
+ - `summary(dimension, by:, reducer:)` and `detail(by:)` are **non-mutating** — each returns a fresh `Namo` derived from the members, leaving the Collection untouched. Use these when you want a view to keep: assign the result to a variable and operate on it independently.
992
+ - `as_summary(dimension, by:, reducer:)` and `as_detail(by)` are **mutating** — each sets the Collection's data to the chosen view and returns `self`, for a fluent step. (`as_detail` carries no `dimension`, so its label argument is positional: `as_detail(:assembly)`.)
993
+
994
+ ```ruby
995
+ gt.summary(:cost, reducer: :mean) # a fresh Namo; gt is unchanged
996
+ gt.as_summary(:weight) # gt's data becomes the summary; returns gt
997
+ gt.as_detail(:assembly) # gt's data becomes the detail; returns gt
998
+ ```
999
+
1000
+ `reducer:` is any method the member's column responds to — `:sum` (the default) and `:mean` are typical (`:mean` via a statistics gem that adds `Array#mean`).
1001
+
1002
+ #### Inject-iff-absent
1003
+
1004
+ `detail(by:)` unions the members' rows and labels each with its origin, but only when that label isn't already present:
1005
+
1006
+ - If `by` is **already a dimension** in a member's rows, the row passes through untouched — the dimension is intrinsic.
1007
+ - If `by` is **not** present, `detail` injects it (`row.merge(by => member.name)`), promoting the member's name into a dimension.
1008
+
1009
+ This single conditional is where assembly (`<<`, members named extrinsically) and partition (`group_by`, members named by an intrinsic value — 0.19.0) meet. For an assembled Collection, `as_detail(:assembly)` is the dimension-creating step: it promotes the member name into real data and **retains** it. From then on the structure is intrinsic and round-trips are exact; the promoted dimension is removed only by explicit contraction (`gt[-:assembly]`), never automatically.
1010
+
1011
+ #### `<<` and unnamed members
1012
+
1013
+ `<<` accepts a single member or an array of them. A member whose `name` collides with an existing member's **replaces** it (last-write-wins), making the name → member mapping a dictionary rather than a multimap:
1014
+
1015
+ ```ruby
1016
+ gt << SubAssembly.new(name: :powertrain, data: [...]) # replaces the existing :powertrain
1017
+ gt << [front_suspension, rear_suspension] # adds each
1018
+ ```
1019
+
1020
+ There is no insertion-time guard against unnamed members. An unnamed member is simply appended (no name to collide on) and is unfindable by `find` — the honest consequence of having no name, not an error. `find(name)` returns the member with that name, or `nil`:
1021
+
1022
+ ```ruby
1023
+ gt.find(:chassis) # => the chassis SubAssembly
1024
+ gt.find(:missing) # => nil
1025
+ ```
1026
+
1027
+ (`find(name)` is member lookup; it shadows `Enumerable#find` on Collections. Predicate search over rows remains available as `detect`.)
1028
+
1029
+ #### View lifetime and liveness
1030
+
1031
+ Materialisation is pure-live: the Collection rebuilds its data view from the current members on every `<<`, with no memoisation. So a mutation is reflected immediately — add a member, then summarise or detail, and the new member is included.
1032
+
1033
+ A mutating `as_summary`/`as_detail` view **persists until the next `<<`**, which re-materialises detail. So `as_summary` is for "be the summary for this immediate chain":
1034
+
1035
+ ```ruby
1036
+ gt.as_summary(:weight).values(:weight) # => [280, 150, 60] (the summary)
1037
+ gt << front_suspension # re-materialises detail
1038
+ gt.values(:weight) # => [200, 80, 150, 60, ...] (line items again)
1039
+ ```
1040
+
1041
+ Freeze-gated memoisation is a 2.x optimisation — opt-in via `freeze`, transparent, and never changing this observable behaviour. `group_by` (0.19.0) is the partition-side constructor for the same type: it splits a Namo into a `Collection`, the mirror of assembling one with `<<`.
1042
+
898
1043
  ## Why?
899
1044
 
900
1045
  Every other multi-dimensional array library requires you to pre-shape your data before you can work with it. Namo takes it in the form it likely already comes in.
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
 
@@ -0,0 +1,52 @@
1
+ # Namo/Collection.rb
2
+ # Namo::Collection
3
+
4
+ class Namo
5
+ class Collection < Namo
6
+ attr_reader :members
7
+
8
+ def <<(*members)
9
+ members.flatten.each do |member|
10
+ @members.reject!{|existing| existing.name == member.name} unless member.name.nil?
11
+ @members << member
12
+ end
13
+ @data = detail.data
14
+ self
15
+ end
16
+
17
+ def find(name)
18
+ @members.find{|member| member.name == name} unless name.nil?
19
+ end
20
+
21
+ def summary(dimension, by: :member, reducer: :sum)
22
+ rows = @members.map do |member|
23
+ {by => member.name, dimension => member.values(dimension).send(reducer)}
24
+ end
25
+ Namo.new(rows)
26
+ end
27
+
28
+ def detail(by: :member)
29
+ rows = @members.flat_map do |member|
30
+ member.data.map{|row| row.key?(by) ? row : row.merge(by => member.name)}
31
+ end
32
+ Namo.new(rows)
33
+ end
34
+
35
+ def as_summary(dimension, by: :member, reducer: :sum)
36
+ @data = summary(dimension, by: by, reducer: reducer).data
37
+ self
38
+ end
39
+
40
+ def as_detail(by = :member)
41
+ @data = detail(by: by).data
42
+ self
43
+ end
44
+
45
+ private
46
+
47
+ def initialize(positional_data = nil, data: [], formulae: {}, name: nil)
48
+ @members = []
49
+ super
50
+ end
51
+ end
52
+ end
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
- case @formulae[name].arity
9
- when 2
9
+ formula = @formulae[name]
10
+ if collection_scoped?(formula)
10
11
  raise_unless_namo_context(name)
11
- @formulae[name].call(self, @namo)
12
+ formula.call(self, @namo, *arguments)
12
13
  else
13
- @formulae[name].call(self)
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, "two-arity formula #{name.inspect} requires a Namo context, but this Row has none"
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
@@ -2,5 +2,5 @@
2
2
  # Namo::VERSION
3
3
 
4
4
  class Namo
5
- VERSION = '0.16.0'
5
+ VERSION = '0.18.0'
6
6
  end
data/lib/namo.rb CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  require_relative './Namo/NegatedDimension'
5
5
  require_relative './Namo/Row'
6
+ require_relative './Namo/Collection'
6
7
  require_relative './Namo/Enumerable'
7
8
  require_relative './Namo/VERSION'
8
9
  require_relative './Symbol'
@@ -28,7 +29,7 @@ class Namo
28
29
 
29
30
  def values(*dims)
30
31
  if dims.empty?
31
- dimensions.each_with_object({}){|dim, hash| hash[dim] = values_for(dim)}
32
+ materialisable_dimensions.each_with_object({}){|dim, hash| hash[dim] = values_for(dim)}
32
33
  elsif dims.length == 1
33
34
  values_for(dims.first)
34
35
  else
@@ -246,6 +247,19 @@ class Namo
246
247
  end
247
248
  end
248
249
 
250
+ def materialisable_dimensions
251
+ dimensions.reject{|dim| requires_arguments?(dim)}
252
+ end
253
+
254
+ def requires_arguments?(name)
255
+ formula = @formulae[name]
256
+ !!formula && required_parameter_count(formula) > 2
257
+ end
258
+
259
+ def required_parameter_count(formula)
260
+ formula.arity >= 0 ? formula.arity : -formula.arity - 1
261
+ end
262
+
249
263
  def raise_unless_namo(other)
250
264
  unless other.is_a?(Namo)
251
265
  raise TypeError, "can't compare Namo with #{other.class}"
@@ -0,0 +1,250 @@
1
+ require 'minitest/autorun'
2
+ require 'minitest-spec-context'
3
+
4
+ require_relative '../../lib/namo'
5
+
6
+ class Array
7
+ def mean
8
+ sum.to_f / size
9
+ end
10
+ end unless [].respond_to?(:mean)
11
+
12
+ class SubAssembly < Namo; end
13
+
14
+ class Car < Namo::Collection
15
+ def summary(dimension, by: :assembly, reducer: :sum)
16
+ super
17
+ end
18
+
19
+ def detail(by: :assembly)
20
+ super
21
+ end
22
+ end
23
+
24
+ describe Namo::Collection do
25
+ let(:powertrain) do
26
+ SubAssembly.new(name: :powertrain, data: [
27
+ {component: 'engine', weight: 200, cost: 50000},
28
+ {component: 'gearbox', weight: 80, cost: 20000},
29
+ ])
30
+ end
31
+
32
+ let(:chassis) do
33
+ SubAssembly.new(name: :chassis, data: [{component: 'frame', weight: 150, cost: 30000}])
34
+ end
35
+
36
+ let(:body) do
37
+ SubAssembly.new(name: :body, data: [{component: 'panels', weight: 60, cost: 15000}])
38
+ end
39
+
40
+ let(:wheels) do
41
+ SubAssembly.new(name: :wheels, data: [{component: 'tyres', weight: 40, cost: 8000}])
42
+ end
43
+
44
+ let(:collection) do
45
+ Namo::Collection.new.tap{|c| c << [powertrain, chassis, body]}
46
+ end
47
+
48
+ describe "construction" do
49
+ it "starts with empty members" do
50
+ _(Namo::Collection.new.members).must_equal []
51
+ end
52
+ end
53
+
54
+ describe "lazy detail materialisation" do
55
+ it "materialises detail on a bare row-operation without a prior as_detail" do
56
+ _(collection.values(:weight)).must_equal [200, 80, 150, 60]
57
+ end
58
+
59
+ it "supports selection against the materialised detail" do
60
+ _(collection[component: 'engine'].values(:component)).must_equal ['engine']
61
+ end
62
+
63
+ it "reflects a newly added member on the next operation" do
64
+ before = collection.values(:weight)
65
+ collection << wheels
66
+ _(collection.values(:weight)).must_equal before + [40]
67
+ end
68
+ end
69
+
70
+ describe "#<<" do
71
+ it "adds a member" do
72
+ collection = Namo::Collection.new
73
+ collection << powertrain
74
+ _(collection.members.size).must_equal 1
75
+ _(collection.find(:powertrain)).must_be_same_as powertrain
76
+ end
77
+
78
+ it "replaces a member with a colliding name (last-write-wins)" do
79
+ collection = Namo::Collection.new
80
+ collection << powertrain
81
+ replacement = SubAssembly.new(name: :powertrain, data: [{component: 'hybrid', weight: 250, cost: 70000}])
82
+ collection << replacement
83
+ _(collection.members.size).must_equal 1
84
+ _(collection.find(:powertrain)).must_be_same_as replacement
85
+ end
86
+
87
+ it "adds each member from an array" do
88
+ collection = Namo::Collection.new
89
+ collection << [powertrain, chassis]
90
+ _(collection.members.size).must_equal 2
91
+ end
92
+
93
+ it "appends an unnamed member, which is unfindable by name" do
94
+ collection = Namo::Collection.new
95
+ collection << SubAssembly.new(data: [{component: 'misc', weight: 5, cost: 100}])
96
+ collection << SubAssembly.new(data: [{component: 'other', weight: 6, cost: 200}])
97
+ _(collection.members.size).must_equal 2
98
+ end
99
+
100
+ it "returns self" do
101
+ collection = Namo::Collection.new
102
+ _(collection << powertrain).must_be_same_as collection
103
+ end
104
+ end
105
+
106
+ describe "#find" do
107
+ it "returns the member with the given name" do
108
+ _(collection.find(:chassis)).must_be_same_as chassis
109
+ end
110
+
111
+ it "returns nil for an absent name" do
112
+ _(collection.find(:engine)).must_be_nil
113
+ end
114
+
115
+ it "returns nil for find(nil)" do
116
+ _(collection.find(nil)).must_be_nil
117
+ end
118
+
119
+ it "never matches an unnamed member" do
120
+ collection = Namo::Collection.new
121
+ collection << SubAssembly.new(data: [{component: 'misc', weight: 5, cost: 100}])
122
+ _(collection.find(nil)).must_be_nil
123
+ end
124
+ end
125
+
126
+ describe "#summary" do
127
+ it "reduces each member to a labelled row" do
128
+ summary = collection.summary(:weight)
129
+ _(summary.values(:member)).must_equal [:powertrain, :chassis, :body]
130
+ _(summary.values(:weight)).must_equal [280, 150, 60]
131
+ end
132
+
133
+ it "labels with a custom by dimension" do
134
+ summary = collection.summary(:weight, by: :assembly)
135
+ _(summary.values(:assembly)).must_equal [:powertrain, :chassis, :body]
136
+ end
137
+
138
+ it "reduces with a custom reducer" do
139
+ summary = collection.summary(:weight, reducer: :mean)
140
+ _(summary.values(:weight)).must_equal [140.0, 150.0, 60.0]
141
+ end
142
+
143
+ it "is non-mutating — leaves the collection's data untouched" do
144
+ collection.summary(:weight)
145
+ _(collection.values(:weight)).must_equal [200, 80, 150, 60]
146
+ end
147
+ end
148
+
149
+ describe "#detail" do
150
+ it "returns a plain Namo" do
151
+ _(collection.detail).must_be_instance_of Namo
152
+ end
153
+
154
+ it "unions the members' rows, injecting the by dimension when absent" do
155
+ detail = collection.detail
156
+ _(detail.values(:member)).must_equal [:powertrain, :powertrain, :chassis, :body]
157
+ _(detail.values(:weight)).must_equal [200, 80, 150, 60]
158
+ end
159
+
160
+ it "does not inject when the by dimension is already present" do
161
+ collection = Namo::Collection.new
162
+ collection << SubAssembly.new(name: :ignored, data: [{member: :preexisting, weight: 5}])
163
+ _(collection.detail.values(:member)).must_equal [:preexisting]
164
+ end
165
+
166
+ it "is non-mutating — leaves the collection's data untouched" do
167
+ collection.detail(by: :assembly)
168
+ _(collection.dimensions).wont_include :assembly
169
+ end
170
+ end
171
+
172
+ describe "live recomputation (no memoisation in 1.x)" do
173
+ it "reflects a mutation on the next detail call" do
174
+ before = collection.detail.values(:weight).size
175
+ collection << wheels
176
+ _(collection.detail.values(:weight).size).must_equal before + 1
177
+ end
178
+
179
+ it "reflects a mutation on the next summary call" do
180
+ collection << wheels
181
+ _(collection.summary(:weight).values(:member)).must_equal [:powertrain, :chassis, :body, :wheels]
182
+ end
183
+ end
184
+
185
+ describe "#as_summary / #as_detail" do
186
+ it "as_summary sets the data to the summary view and returns self" do
187
+ result = collection.as_summary(:weight)
188
+ _(result).must_be_same_as collection
189
+ _(collection.values(:weight)).must_equal [280, 150, 60]
190
+ end
191
+
192
+ it "as_detail sets the data to the detail view and returns self" do
193
+ collection.as_summary(:weight)
194
+ result = collection.as_detail
195
+ _(result).must_be_same_as collection
196
+ _(collection.values(:weight)).must_equal [200, 80, 150, 60]
197
+ end
198
+
199
+ it "exposes the summary's columns in dimensions immediately after as_summary" do
200
+ collection.as_summary(:weight)
201
+ _(collection.dimensions.sort).must_equal [:member, :weight].sort
202
+ end
203
+ end
204
+
205
+ describe "as_* view lifetime (rebuild-on-<<: persists until the next <<)" do
206
+ it "keeps the summary view across a bare row-operation" do
207
+ collection.as_summary(:weight)
208
+ _(collection.values(:weight)).must_equal [280, 150, 60]
209
+ _(collection[member: :powertrain].values(:weight)).must_equal [280]
210
+ end
211
+
212
+ it "re-materialises detail on the next <<" do
213
+ collection.as_summary(:weight)
214
+ collection << wheels
215
+ _(collection.values(:weight)).must_equal [200, 80, 150, 60, 40]
216
+ end
217
+ end
218
+
219
+ describe "assembly round-trip" do
220
+ it "injects :assembly via as_detail and retains it through a later << and as_detail" do
221
+ collection.as_detail(:assembly)
222
+ _(collection.dimensions).must_include :assembly
223
+ _(collection.coordinates(:assembly)).must_equal [:powertrain, :chassis, :body]
224
+ collection << wheels
225
+ collection.as_detail(:assembly)
226
+ _(collection.dimensions).must_include :assembly
227
+ _(collection.coordinates(:assembly)).must_equal [:powertrain, :chassis, :body, :wheels]
228
+ end
229
+
230
+ it "removes :assembly only by explicit contraction" do
231
+ collection.as_detail(:assembly)
232
+ contracted = collection[-:assembly]
233
+ _(contracted.dimensions).wont_include :assembly
234
+ end
235
+ end
236
+
237
+ describe "subclass with a default by:" do
238
+ let(:car) do
239
+ Car.new.tap{|c| c << [powertrain, chassis, body]}
240
+ end
241
+
242
+ it "uses the subclass default :assembly for summary" do
243
+ _(car.summary(:weight).values(:assembly)).must_equal [:powertrain, :chassis, :body]
244
+ end
245
+
246
+ it "uses the subclass default :assembly for detail" do
247
+ _(car.detail.values(:assembly)).must_equal [:powertrain, :powertrain, :chassis, :body]
248
+ end
249
+ end
250
+ end
@@ -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
@@ -797,6 +797,129 @@ describe Namo do
797
797
  end
798
798
  end
799
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
+
800
923
  describe "data/formula exclusivity" do
801
924
  context "projection" do
802
925
  let(:price_data) do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: namo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.0
4
+ version: 0.18.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran
@@ -64,6 +64,7 @@ files:
64
64
  - LICENSE
65
65
  - README.md
66
66
  - Rakefile
67
+ - lib/Namo/Collection.rb
67
68
  - lib/Namo/Enumerable.rb
68
69
  - lib/Namo/NegatedDimension.rb
69
70
  - lib/Namo/Row.rb
@@ -71,6 +72,7 @@ files:
71
72
  - lib/Symbol.rb
72
73
  - lib/namo.rb
73
74
  - namo.gemspec
75
+ - test/Namo/Collection_test.rb
74
76
  - test/Namo/NegatedDimension_test.rb
75
77
  - test/Namo/Row_test.rb
76
78
  - test/Symbol_test.rb