namo 0.16.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dcb7aafe4522ff115c12464016fef1a6909047975829523b095105f601a7ae60
4
- data.tar.gz: 81f150f5f370f836734908a30e1ecd600b856f90c0151a698fa25bd117530d92
3
+ metadata.gz: f84d86032e9397e8db4a4d7a4150474c30215cf9339ec2158a38802aad1a4d49
4
+ data.tar.gz: 830b874e04b36acd7ab848351419ed5ceb848e0108b95602184b7e6131812e01
5
5
  SHA512:
6
- metadata.gz: 6fd5a96f0fdf85e6ffb8a3cfa2d1a560d5da1d118072e95cd549af43149ff2a4c963de122b9026c29c0817e33fcfbd2ba3ece969ee1f6f5d92738c3d7f38b73a
7
- data.tar.gz: 78ba07312d7e4f4d4ac85a6ca572852964ed7055d48687361d8a86f9cb998122f421e75418ba67bc978bf78d7b0cc22315ebb800ca734e8ade07817bae886736
6
+ metadata.gz: 1b409a5004f78619ea60186404e6f156fadbc660755cc9fc3c64cfe8a028a3ffdd7bb35dd252510ece1814674f0b02af7b347b1ea42c1280390e7d7a982f0de0
7
+ data.tar.gz: 15c2c28dafa605ee5a93e5ebb3bee4e5a1f8301dca7cfb11675e1f197db62052975a0f9b74a9096638062b4d40cd85b5752ce5e52c3b0c5f39463fed421aec6c
data/CHANGELOG CHANGED
@@ -1,6 +1,55 @@
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
+
4
53
  20260612
5
54
  0.16.0: ~ data/formula exclusivity — projection drops the formulae it materialises; * and ** raise on a data/formula name collision.
6
55
 
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:
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
- 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.17.0'
6
6
  end
data/lib/namo.rb CHANGED
@@ -28,7 +28,7 @@ class Namo
28
28
 
29
29
  def values(*dims)
30
30
  if dims.empty?
31
- dimensions.each_with_object({}){|dim, hash| hash[dim] = values_for(dim)}
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
@@ -246,6 +246,19 @@ class Namo
246
246
  end
247
247
  end
248
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
+
249
262
  def raise_unless_namo(other)
250
263
  unless other.is_a?(Namo)
251
264
  raise TypeError, "can't compare Namo with #{other.class}"
@@ -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.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran