namo 0.22.0 → 0.23.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: e6342f1cab3b5811a97d92be8ef7ba34a81191bd97a6ee2c606f8adc7d9a0d47
4
- data.tar.gz: ce7db23d2907e33a80d1e263b4b9987645a6c1c47979b7f3311bf519d8f9e738
3
+ metadata.gz: d07dec8cde3d61366ec26720be42067c0fa10deb6aa1fd37977bf69ba16aa802
4
+ data.tar.gz: c355e980ff842ddc90d6a3873e98bb7185fb788f44f610c18ace363059136e6f
5
5
  SHA512:
6
- metadata.gz: 5c6c26eda0a637af215eef9cab0fe369769af3e945b3a1cf980788bd34be97abeefb3cf29fb208012556c5df9e46c212c9476dc222b0f3917404c7db3003f21a
7
- data.tar.gz: f71fd3ed7b8e8068b55fbb8379cd361c91c84ca1cb4fe823ff72af9fe1c9b8e1247468fb9fd818aa865cac709aa2d22e63f2c866030cacee22bf25c5930e7758
6
+ metadata.gz: 17ac2640764b18b17521af477f71b0e5419d24d68a35ce0a7fc79acd963f5e9210331b39ca04a242780037e1a39558bd8fd7079146331b13068f80f811da6fd9
7
+ data.tar.gz: b4ad7c11375029c45e9b00f2769368c8b48082a965105c95a9b4d23c86a81a24af15107695535a08ea78fa548fc733c84a6ac0df13635a089c4da1297cee1ed2
data/CHANGELOG CHANGED
@@ -1,6 +1,17 @@
1
1
  CHANGELOG
2
2
  _________
3
3
 
4
+ 20260627
5
+ 0.23.0: + Namo::Formulary — A formulary is a reusable body of derived dimensions a Namo instance may draw upon.
6
+
7
+ 1. + lib/Namo/Formulary.rb: + Namo::Formulary, an empty marker module. A module includes it to brand itself a formulary; attach raises ArgumentError for an untagged module, so an ordinary module of helpers can't be mistaken for a body of derivations.
8
+ 2. ~ lib/Namo/Formulae.rb: + attach(modul), aliased as << — after the marker guard, extends a single memoised inert host (@host ||= Object.new) with the module and stores each of its public_instance_methods(false) as a Method bound to that host, then returns self. One host per Formulae across all attaches, so the bound Methods share a self-free receiver and a formulary method can't shadow Namo's API. No other change: derive, key?, required_parameter_count, keys, merge/dup/reject, and ==/eql?/hash are the 0.22.0 store-only methods, and they handle the formulary methods because the formulary methods are now store entries.
9
+ 3. ~ lib/namo.rb: + require of Namo::Formulary; + Namo#attach(modul), aliased as << — raises ArgumentError naming the collision if a formulary method's name collides with a data column (intent/blast-radius: you didn't type the module's names, and it could clear several columns), else forwards to @formulae.attach and returns self. + the class-include channel: initialize calls a private attach_included_formularies that scans self.class.ancestors.reverse and routes each tagged module through attach (same guard, same host; reverse so most-recent-include wins, matching MRO). Collection#initialize calls super, so it participates harmlessly. Collection defines its own <<, so its member-add is unaffected and a plain Namo's << attaches a formulary. Separately and additively, []='s formula branch widens from when Proc to value.respond_to?(:call), so a directly-assigned Method or lambda registers as a formula rather than broadcasting per-row; arrays are unaffected. derived_dimensions, ===/eql?/hash, and requires_arguments? are unchanged from 0.22.0 — formulary methods are store keys, so the existing keys-based forms already count them.
10
+ 4. + test/Namo/Formulae_test.rb: + a "formulary tier" describe — attach returns self, rejects an untagged module, and copies the public methods into the store (excluding a private helper); a single shared host across attaches (bound Methods share a receiver); << attaches as attach does; key?, required_parameter_count, and derive resolve the copied methods; most-recent-wins on re-attachment; carry-through through dup/reject/merge.
11
+ 5. ~ test/namo_test.rb: + a "formularies" describe (runtime attach) — attach (returns the Namo, rejects untagged); derived_dimensions (copied names, private helper absent, vanilla Namo empty); resolution through values/Row/selection; two-arity windowing and the missing-namo raise; a parameterised method omitted from bulk and raising bare, materialising through a row-scoped wrapper; re-attachment; an instance []= formula shadowing a formulary method; attach raising on a formulary/data-name collision; carry-through through selection, projection, and concatenation; projection materialising-and-dropping a formulary name; === against a vanilla Namo and against the same names via []=; << attaching as attach does. + a "formularies via class include" describe — included methods as derived dimensions (private absent), resolution, most-recent-include wins (MRO), raise at construction on a data collision, and the build-time-vs-after contract (an instance built before a later include sees it only via attach). ~ the #[]= polymorphic dispatch describe gains a Method and a lambda registering as formulae (the array-broadcast regression already present).
12
+ 6. ~ README.md: + a "Formularies" subsection under Formulae (attach and the marker, private-hides-helpers, the copy-into-the-store / snapshot model, the raise on a data collision, materialise-and-drop, carry-through, the bare-name/memoisation forward-note); the Polymorphic []= section now says "a callable" (proc, lambda, or Method) rather than "a proc". ~ COMPARISON.md: + a "Formularies" row. ~ ROADMAP.md: + the 0.23.0 release entry; current-state bumped to 0.23.0; the 2.x "module-based formula libraries", "Namo#=== revision", and marker-vs-implicit notes annotated as shipped/decided at 0.23.0, with bare-name bodies remaining 2.x.
13
+ 7. ~ Namo::VERSION: /0.22.0/0.23.0/
14
+
4
15
  20260626
5
16
  0.22.0: + Formulae#derive — move formula invocation out of Row#[] and into Formulae. [] keeps its meaning (the stored callable, as at 0.21.0); the new derive(name, row, namo, *arguments) resolves a named formula to its value. derive over call: the receiver is the collection, not the callable, so the verb says the collection derives a value via the named rule rather than borrowing Proc#call's "invoke the receiver"; and the result is whatever the proc returns, not necessarily numeric, which a neutral verb leaves open. Indexing a Formulae is storage access, not a Namo operation, so [] returning the callable carries no inconsistency with the dimension-value [] of Namo and Row — different domains, same spelling. Behaviour-preserving at the usage level: row[:revenue], namo[:x] = formula, values, and selections are unchanged.
6
17
 
data/README.md CHANGED
@@ -716,9 +716,65 @@ prices[:sma_close_20] = proc{|row| row[:sma, :close, 20]}
716
716
  prices[:date, :sma_close_20] # materialises per the usual projection rule
717
717
  ```
718
718
 
719
+ #### Formularies
720
+
721
+ A *formulary* is a reusable body of derived dimensions — a module whose public instance methods are formulae. A Namo attaches a formulary, and from then on those methods resolve as derived dimensions through the same interface as `[]=` formulae. The methods take `row` (and `namo`, and any further parameters) exactly as a `[]=` formula does:
722
+
723
+ ```ruby
724
+ module OrderFlow
725
+ include Namo::Formulary
726
+
727
+ def signed_volume(row)
728
+ row[:buys] - row[:sells]
729
+ end
730
+
731
+ def cum_delta(row, namo)
732
+ namo[date: ..row[:date]].values(:signed_volume).sum
733
+ end
734
+
735
+ private
736
+
737
+ def net(row)
738
+ row[:buys] + row[:sells]
739
+ end
740
+ end
741
+
742
+ flows = Namo.new([
743
+ {date: 1, buys: 60, sells: 40},
744
+ {date: 2, buys: 80, sells: 120},
745
+ {date: 3, buys: 75, sells: 75}
746
+ ])
747
+
748
+ flows.attach(OrderFlow)
749
+ flows.values(:signed_volume) # => [20, -40, 0]
750
+ flows.values(:cum_delta) # => [20, -20, -20]
751
+ flows.derived_dimensions # => [:signed_volume, :cum_delta]
752
+ ```
753
+
754
+ `<<` is the operator form of `attach`, so `flows << OrderFlow` reads the same and chains: `flows << OrderFlow << Scoring`.
755
+
756
+ A module brands itself a formulary by including `Namo::Formulary`. The marker is mandatory — `attach` raises `ArgumentError` for an untagged module, so an ordinary module of helpers can't be mistaken for a body of derivations. Within a formulary, the public methods are the derivations and `private` methods are helpers: `net` above never appears in `derived_dimensions` and never resolves as a dimension. The separation needs no per-method declaration — public or private says it.
757
+
758
+ Attaching **copies** the formulary's public methods into the Namo's formula collection — each becomes a stored formula, indistinguishable from one assigned through `[]=`. From there they are `[]=` formulae in everything but how they were defined: they resolve through the same path, carry through the same operators, and obey the same exclusivity rule.
759
+
760
+ The copy is a *snapshot* taken at the moment of attachment, and this is where a formulary departs from ordinary Ruby. A real `include` (or `extend`) is a live link: its methods resolve through the ancestor chain, so a method added to the module later, a redefinition, or a further module mixed in afterwards are all seen by objects that already include it. `attach` forgoes that — it copies the method set once, so later changes to the module are not reflected on an already-attached Namo. Re-attach to pick them up. (A live-link mechanism may come in a later release; it is not in 0.23.0.)
761
+
762
+ **Two ways to give a Namo a formulary.** `attach` (and `<<`) adds one to an individual Namo at runtime, as above. Alternatively, `include` the formulary into a `Namo` subclass — `class TradingAnalysis < Namo; include OrderFlow; end` — and every instance picks it up when it is built. The rule that separates them is simple: *the class `include` is honoured once, when each instance is constructed; everything after that is `attach`.* Both copy (snapshot), so a formulary `include`d into the class **after** an instance exists won't reach that instance — `attach` to it, or build a fresh one. A one-off plain `Namo` has no class to `include` into per-instance, so it always takes `attach`. Where both apply, ordinary last-write-wins holds: a runtime `attach` (or `[]=`) overrides a class-included formula of the same name on that instance, and the most-recently `include`d formulary wins among the class's own.
763
+
764
+ Because each method is copied into the store, the rules already governing `[]=` apply:
765
+
766
+ - **Most-recent wins.** Attaching a second formulary that defines a colliding name overwrites the first, exactly as a second `[]=` would. Equally, `[]=` and `attach` interleave as plain last-write-wins on a shared name — whichever was applied last governs.
767
+ - **Data collisions raise.** A name is data or derived, never both. If a formulary method's name collides with an existing data column, attaching raises an `ArgumentError` naming the collision rather than silently destroying the data. The reason it raises where `[]=` silently clears: `[]=` names one column at the call site, so replacing it is your explicit intent; a formulary names a whole *module*, so a data collision is a name you never typed — and possibly several columns at once — that you most likely didn't mean to destroy. The same raise guards the class-`include` channel, where it fires at construction. Resolve it explicitly first — contract the column away (`namo[-:signed_volume]`) or rename — then attach.
768
+ - **Materialise-and-drop on projection.** Naming a formulary method in a projection snapshots its values into a data column *and drops the formula*, exactly as for a `[]=` formula: `flows[:date, :signed_volume]` returns a Namo reporting `:signed_volume` as data, not derived, with all access paths agreeing on the stored snapshot.
769
+ - **Carry-through.** Selection, the set and composition operators, and a projection that *omits* the name all keep the formula live, computing against the result's own rows — the carry-through is free, because the formulae are ordinary store entries.
770
+
771
+ Two Namos that expose the same names — one via `[]=`, one via a formulary — are `===`; a Namo with a formulary attached is not `===` to a vanilla Namo of the same data dimensions.
772
+
773
+ Formulary methods take `row` explicitly and index it (`row[:buys]`), the same as `[]=` formulae. Resolving bare names inside a method body (`buys` for `row[:buys]`) and memoising derived values on a frozen Namo arrive in a later release; until then a formulary method is a plain function of its `row` (and `namo`).
774
+
719
775
  ### Polymorphic `[]=`
720
776
 
721
- `[]=` dispatches on the type of the value assigned. A proc registers a formula, as above. Anything else broadcasts the value to every row:
777
+ `[]=` dispatches on the value assigned. A callable — anything that responds to `call`, so a proc, a lambda, or a `Method` — registers a formula, as above. Anything else broadcasts the value to every row:
722
778
 
723
779
  ```ruby
724
780
  sales[:status] = 'active'
@@ -732,7 +788,7 @@ sales.values(:revenue)
732
788
 
733
789
  The two branches mirror the polymorphism `[]` already has on the selection side, where a single bracket call dispatches over exact values, arrays, ranges, procs, and regexes. Rather than introduce a separate `broadcast` or `set_all` method for the scalar case, `[]=` reads the same way for both: `sales[:status] = 'active'` says "set status to active across this Namo," and `sales[:revenue] = proc{…}` says "derive revenue from each row."
734
790
 
735
- The two branches enforce **exclusive storage**: a name is either a data dimension or a derived dimension, never both. Assigning a proc clears any data column of that name; assigning anything else clears any formula of that name. The last write wins, and there is no shadowing:
791
+ The two branches enforce **exclusive storage**: a name is either a data dimension or a derived dimension, never both. Assigning a callable clears any data column of that name; assigning anything else clears any formula of that name. The last write wins, and there is no shadowing:
736
792
 
737
793
  ```ruby
738
794
  sales[:x] = 5 # :x is a broadcast data value
@@ -744,7 +800,7 @@ sales[:x] = 5 # :x is now a broadcast data value — the f
744
800
 
745
801
  Exclusivity ties directly to the inspection vocabulary: a name assigned a scalar shows up in `data_dimensions`; a name assigned a proc shows up in `derived_dimensions`; never in both, so it appears in `dimensions` exactly once.
746
802
 
747
- Only a `Proc` takes the formula branch. An array is a value like any other, so it broadcasts as the per-row value rather than registering as a formula:
803
+ Only a callable takes the formula branch. An array doesn't respond to `call`, so it broadcasts as the per-row value rather than registering as a formula:
748
804
 
749
805
  ```ruby
750
806
  sales[:weights] = [1, 2, 3]
data/lib/Namo/Formulae.rb CHANGED
@@ -13,6 +13,18 @@ class Namo
13
13
  @store[name] = callable
14
14
  end
15
15
 
16
+ def attach(modul)
17
+ unless modul.include?(Namo::Formulary)
18
+ raise ArgumentError, "not a Namo::Formulary: #{modul}"
19
+ end
20
+ host.extend(modul)
21
+ modul.public_instance_methods(false).each do |name|
22
+ @store[name] = host.method(name)
23
+ end
24
+ self
25
+ end
26
+ alias_method :<<, :attach
27
+
16
28
  def derive(name, row, namo, *arguments)
17
29
  formula = self[name]
18
30
  if collection_scoped?(name)
@@ -83,6 +95,10 @@ class Namo
83
95
  @store = store
84
96
  end
85
97
 
98
+ def host
99
+ @host ||= Object.new
100
+ end
101
+
86
102
  def collection_scoped?(name)
87
103
  required_parameter_count(name) >= 2
88
104
  end
@@ -0,0 +1,6 @@
1
+ # Namo/Formulary.rb
2
+ # Namo::Formulary
3
+
4
+ class Namo
5
+ module Formulary; end
6
+ end
data/lib/Namo/VERSION.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # Namo::VERSION
3
3
 
4
4
  class Namo
5
- VERSION = '0.22.0'
5
+ VERSION = '0.23.0'
6
6
  end
data/lib/namo.rb CHANGED
@@ -2,6 +2,7 @@
2
2
  # Namo
3
3
 
4
4
  require_relative './Namo/NegatedDimension'
5
+ require_relative './Namo/Formulary'
5
6
  require_relative './Namo/Formulae'
6
7
  require_relative './Namo/Row'
7
8
  require_relative './Namo/Collection'
@@ -78,8 +79,7 @@ class Namo
78
79
  end
79
80
 
80
81
  def []=(name, value)
81
- case value
82
- when Proc
82
+ if value.respond_to?(:call)
83
83
  @data.each{|row| row.delete(name)} if @data.first&.key?(name)
84
84
  @formulae[name] = value
85
85
  else
@@ -88,6 +88,17 @@ class Namo
88
88
  end
89
89
  end
90
90
 
91
+ def attach(modul)
92
+ collisions = modul.public_instance_methods(false) & data_dimensions
93
+ unless collisions.empty?
94
+ raise ArgumentError, "formulary methods collide with data dimensions: #{collisions.inspect}"
95
+ end
96
+ @formulae.attach(modul)
97
+ self
98
+ end
99
+
100
+ alias_method :<<, :attach
101
+
91
102
  def +(other)
92
103
  raise_unless_namo(other)
93
104
  raise_unless_matching_data_dimensions(other)
@@ -238,6 +249,14 @@ class Namo
238
249
  @data = positional_data || data
239
250
  @formulae = formulae.is_a?(Formulae) ? formulae : Formulae.new(formulae)
240
251
  @name = name
252
+ attach_included_formularies
253
+ end
254
+
255
+ def attach_included_formularies
256
+ self.class.ancestors.reverse.each do |modul|
257
+ next if modul.is_a?(Class) || !modul.include?(Namo::Formulary)
258
+ attach(modul)
259
+ end
241
260
  end
242
261
 
243
262
  def values_for(dim)
@@ -208,4 +208,146 @@ describe Namo::Formulae do
208
208
  _(Namo::Formulae.new.keys).must_equal []
209
209
  end
210
210
  end
211
+
212
+ describe "formulary tier" do
213
+ let(:delta) do
214
+ Module.new do
215
+ include Namo::Formulary
216
+ def signed_volume(row); row[:buys] - row[:sells]; end
217
+ def echo_namo(row, namo); namo; end
218
+ def scaled(row, namo, factor); row[:buys] * factor; end
219
+ def windowed(row, namo, *rest); rest; end
220
+ private
221
+ def helper(row); row[:buys]; end
222
+ end
223
+ end
224
+
225
+ let(:untagged) do
226
+ Module.new do
227
+ def signed_volume(row); 0; end
228
+ end
229
+ end
230
+
231
+ let(:attached) do
232
+ Namo::Formulae.new.attach(delta)
233
+ end
234
+
235
+ describe "#attach" do
236
+ it "returns self" do
237
+ formulae = Namo::Formulae.new
238
+ _(formulae.attach(delta)).must_be_same_as formulae
239
+ end
240
+
241
+ it "raises ArgumentError for a module not tagged Namo::Formulary" do
242
+ _(proc{Namo::Formulae.new.attach(untagged)}).must_raise ArgumentError
243
+ end
244
+
245
+ it "copies the formulary's public methods into the callable store" do
246
+ _(attached.keys.sort).must_equal [:echo_namo, :scaled, :signed_volume, :windowed]
247
+ end
248
+
249
+ it "excludes a private helper" do
250
+ _(attached.keys).wont_include :helper
251
+ end
252
+
253
+ it "binds every attached formulary to a single shared host" do
254
+ other = Module.new do
255
+ include Namo::Formulary
256
+ def momentum(row); row[:buys]; end
257
+ end
258
+ formulae = Namo::Formulae.new.attach(delta).attach(other)
259
+ _(formulae[:signed_volume].receiver).must_be_same_as formulae[:momentum].receiver
260
+ end
261
+ end
262
+
263
+ describe "#<<" do
264
+ it "attaches a formulary, the same as #attach" do
265
+ formulae = Namo::Formulae.new
266
+ formulae << delta
267
+ _(formulae.keys).must_include :signed_volume
268
+ end
269
+
270
+ it "returns self for chaining" do
271
+ formulae = Namo::Formulae.new
272
+ _(formulae << delta).must_be_same_as formulae
273
+ end
274
+ end
275
+
276
+ describe "#key?" do
277
+ it "is true for a formulary method name" do
278
+ _(attached.key?(:signed_volume)).must_equal true
279
+ end
280
+
281
+ it "is false for a private helper" do
282
+ _(attached.key?(:helper)).must_equal false
283
+ end
284
+
285
+ it "is false for an absent name" do
286
+ _(attached.key?(:missing)).must_equal false
287
+ end
288
+ end
289
+
290
+ describe "#required_parameter_count" do
291
+ it "counts a row-scoped formulary method" do
292
+ _(attached.required_parameter_count(:signed_volume)).must_equal 1
293
+ end
294
+
295
+ it "counts a two-arity formulary method" do
296
+ _(attached.required_parameter_count(:echo_namo)).must_equal 2
297
+ end
298
+
299
+ it "counts a parameterised formulary method" do
300
+ _(attached.required_parameter_count(:scaled)).must_equal 3
301
+ end
302
+
303
+ it "excludes the splat for a variadic formulary method" do
304
+ _(attached.required_parameter_count(:windowed)).must_equal 2
305
+ end
306
+ end
307
+
308
+ describe "#derive" do
309
+ it "resolves a row-scoped formulary method to its value" do
310
+ _(attached.derive(:signed_volume, {buys: 60, sells: 40}, nil)).must_equal 20
311
+ end
312
+
313
+ it "resolves a two-arity formulary method with the namo context" do
314
+ namo = Object.new
315
+ _(attached.derive(:echo_namo, {buys: 60, sells: 40}, namo)).must_be_same_as namo
316
+ end
317
+
318
+ it "raises when a two-arity formulary method has no namo context" do
319
+ error = _(proc{attached.derive(:echo_namo, {buys: 60, sells: 40}, nil)}).must_raise ArgumentError
320
+ _(error.message).must_match(/echo_namo/)
321
+ end
322
+
323
+ it "resolves the most-recently attached formulary on a name collision" do
324
+ alt = Module.new do
325
+ include Namo::Formulary
326
+ def signed_volume(row); 999; end
327
+ end
328
+ formulae = Namo::Formulae.new.attach(delta).attach(alt)
329
+ _(formulae.derive(:signed_volume, {buys: 60, sells: 40}, nil)).must_equal 999
330
+ end
331
+ end
332
+
333
+ describe "carry-through" do
334
+ it "preserves attached formularies through #dup" do
335
+ _(attached.dup.keys).must_include :signed_volume
336
+ end
337
+
338
+ it "preserves attached formularies through #reject" do
339
+ _(attached.reject{|_name, _| false}.keys).must_include :signed_volume
340
+ end
341
+
342
+ it "carries both sides' formularies through #merge" do
343
+ other_delta = Module.new do
344
+ include Namo::Formulary
345
+ def momentum(row); row[:buys]; end
346
+ end
347
+ merged = attached.merge(Namo::Formulae.new.attach(other_delta))
348
+ _(merged.keys).must_include :momentum
349
+ _(merged.keys).must_include :signed_volume
350
+ end
351
+ end
352
+ end
211
353
  end
data/test/namo_test.rb CHANGED
@@ -639,6 +639,24 @@ describe Namo do
639
639
  _(namo.values(:weights)).must_equal [[1, 2, 3], [1, 2, 3]]
640
640
  end
641
641
 
642
+ it "registers a formula when assigned a Method (responds to call)" do
643
+ calculator = Object.new
644
+ def calculator.revenue(row); row[:price] * row[:quantity]; end
645
+ namo = Namo.new([{price: 10.0, quantity: 100}, {price: 5.0, quantity: 200}])
646
+ namo[:revenue] = calculator.method(:revenue)
647
+ _(namo.derived_dimensions).must_include :revenue
648
+ _(namo.data_dimensions).wont_include :revenue
649
+ _(namo.values(:revenue)).must_equal [1000.0, 1000.0]
650
+ end
651
+
652
+ it "registers a formula when assigned a lambda (responds to call)" do
653
+ namo = Namo.new([{x: 1}, {x: 2}])
654
+ namo[:doubled] = ->(r){r[:x] * 2}
655
+ _(namo.derived_dimensions).must_include :doubled
656
+ _(namo.data_dimensions).wont_include :doubled
657
+ _(namo.values(:doubled)).must_equal [2, 4]
658
+ end
659
+
642
660
  it "is last-write-wins: scalar then proc leaves a formula only" do
643
661
  namo = Namo.new([{y: 7}])
644
662
  namo[:x] = 5
@@ -688,6 +706,247 @@ describe Namo do
688
706
  end
689
707
  end
690
708
 
709
+ describe "formularies" do
710
+ let(:flow_data) do
711
+ [
712
+ {date: 1, buys: 60, sells: 40},
713
+ {date: 2, buys: 80, sells: 120},
714
+ {date: 3, buys: 75, sells: 75},
715
+ ]
716
+ end
717
+
718
+ let(:delta) do
719
+ Module.new do
720
+ include Namo::Formulary
721
+ def signed_volume(row); row[:buys] - row[:sells]; end
722
+ def cum_delta(row, namo); namo[date: ..row[:date]].values(:signed_volume).sum; end
723
+ def scaled_volume(row, namo, factor); (row[:buys] + row[:sells]) * factor; end
724
+ private
725
+ def helper(row); row[:buys]; end
726
+ end
727
+ end
728
+
729
+ let(:untagged) do
730
+ Module.new do
731
+ def signed_volume(row); 0; end
732
+ end
733
+ end
734
+
735
+ let(:flows) do
736
+ Namo.new(flow_data).attach(delta)
737
+ end
738
+
739
+ describe "#attach" do
740
+ it "returns the Namo" do
741
+ namo = Namo.new(flow_data)
742
+ _(namo.attach(delta)).must_be_same_as namo
743
+ end
744
+
745
+ it "raises ArgumentError for a module not tagged Namo::Formulary" do
746
+ _(proc{Namo.new(flow_data).attach(untagged)}).must_raise ArgumentError
747
+ end
748
+ end
749
+
750
+ describe "#<<" do
751
+ it "attaches a formulary, the same as #attach" do
752
+ namo = Namo.new(flow_data)
753
+ namo << delta
754
+ _(namo.values(:signed_volume)).must_equal [20, -40, 0]
755
+ end
756
+
757
+ it "returns the Namo for chaining" do
758
+ namo = Namo.new(flow_data)
759
+ _(namo << delta).must_be_same_as namo
760
+ end
761
+ end
762
+
763
+ describe "#derived_dimensions" do
764
+ it "lists the formulary's public methods alongside store keys, unique" do
765
+ _(flows.derived_dimensions.sort).must_equal [:cum_delta, :scaled_volume, :signed_volume]
766
+ end
767
+
768
+ it "does not surface a private helper" do
769
+ _(flows.derived_dimensions).wont_include :helper
770
+ end
771
+
772
+ it "is empty for a vanilla Namo — no Namo public API leaks as a dimension" do
773
+ _(Namo.new(flow_data).derived_dimensions).must_equal []
774
+ end
775
+ end
776
+
777
+ describe "resolution" do
778
+ it "resolves a row-scoped formulary method through values" do
779
+ _(flows.values(:signed_volume)).must_equal [20, -40, 0]
780
+ end
781
+
782
+ it "resolves a row-scoped formulary method through a Row" do
783
+ _(flows.entries.first[:signed_volume]).must_equal 20
784
+ end
785
+
786
+ it "selects on a formulary-defined dimension" do
787
+ _(flows[signed_volume: 20].values(:date)).must_equal [1]
788
+ end
789
+
790
+ it "windows a two-arity formulary method over the yielding Namo" do
791
+ _(flows.values(:cum_delta)).must_equal [20, -20, -20]
792
+ end
793
+
794
+ it "raises the collection-scoped-context error on a Row built without a Namo" do
795
+ row = Namo::Row.new(flow_data.first, flows.formulae)
796
+ _(proc{row[:cum_delta]}).must_raise ArgumentError
797
+ end
798
+ end
799
+
800
+ describe "parameterised formulary method" do
801
+ it "is omitted from no-arg bulk views" do
802
+ _(flows.values.keys).wont_include :scaled_volume
803
+ end
804
+
805
+ it "raises when named directly with no argument" do
806
+ _(proc{flows.values(:scaled_volume)}).must_raise ArgumentError
807
+ end
808
+
809
+ it "materialises through a row-scoped wrapper that supplies the argument" do
810
+ flows[:double_flow] = ->(r){r[:scaled_volume, 2]}
811
+ _(flows.values(:double_flow)).must_equal [200, 400, 300]
812
+ end
813
+ end
814
+
815
+ describe "live re-attachment" do
816
+ it "resolves to the most-recently attached formulary on a name collision" do
817
+ alt = Module.new do
818
+ include Namo::Formulary
819
+ def signed_volume(row); row[:buys]; end
820
+ end
821
+ namo = Namo.new(flow_data).attach(delta).attach(alt)
822
+ _(namo.values(:signed_volume)).must_equal [60, 80, 75]
823
+ end
824
+ end
825
+
826
+ describe "precedence" do
827
+ it "lets an instance []= formula shadow a formulary method of the same name" do
828
+ flows[:signed_volume] = ->(r){0}
829
+ _(flows.values(:signed_volume)).must_equal [0, 0, 0]
830
+ end
831
+ end
832
+
833
+ describe "carry-through" do
834
+ it "carries the formulary live through selection" do
835
+ _(flows.select{|row| row[:date] >= 2}.values(:signed_volume)).must_equal [-40, 0]
836
+ end
837
+
838
+ it "carries the formulary live through projection by selection" do
839
+ _(flows[date: 1..2].values(:signed_volume)).must_equal [20, -40]
840
+ end
841
+
842
+ it "carries the formulary through concatenation" do
843
+ _((flows + Namo.new(flow_data)).derived_dimensions).must_include :signed_volume
844
+ end
845
+ end
846
+
847
+ describe "materialising a formulary dimension by projection" do
848
+ it "lists the name once, as data, not derived" do
849
+ proj = flows[:date, :signed_volume]
850
+ _(proj.data_dimensions).must_include :signed_volume
851
+ _(proj.derived_dimensions).wont_include :signed_volume
852
+ _(proj.dimensions.count(:signed_volume)).must_equal 1
853
+ end
854
+
855
+ it "reads the snapshot, not a recomputation, on per-Row access" do
856
+ proj = flows[:date, :signed_volume]
857
+ _(proj.entries.first[:signed_volume]).must_equal 20
858
+ _(proj.values(:signed_volume)).must_equal [20, -40, 0]
859
+ end
860
+
861
+ it "raises when a formulary method collides with a data dimension" do
862
+ widened = Namo.new(flow_data.map{|row| row.merge(signed_volume: 999)})
863
+ error = _(proc{widened.attach(delta)}).must_raise ArgumentError
864
+ _(error.message).must_match(/signed_volume/)
865
+ end
866
+ end
867
+
868
+ describe "#===" do
869
+ it "is not === to a vanilla Namo of the same data dimensions" do
870
+ _(flows === Namo.new(flow_data)).must_equal false
871
+ end
872
+
873
+ it "is === to a Namo exposing the same names via []=" do
874
+ other = Namo.new(flow_data)
875
+ other[:signed_volume] = ->(r){0}
876
+ other[:cum_delta] = ->(r, n){0}
877
+ other[:scaled_volume] = ->(r, n, f){0}
878
+ _(flows === other).must_equal true
879
+ end
880
+ end
881
+ end
882
+
883
+ describe "formularies via class include" do
884
+ let(:flow_data) do
885
+ [{buys: 60, sells: 40}]
886
+ end
887
+
888
+ let(:indicators) do
889
+ Module.new do
890
+ include Namo::Formulary
891
+ def signed_volume(row); row[:buys] - row[:sells]; end
892
+ def label(row); "indicators"; end
893
+ private
894
+ def helper(row); row[:buys]; end
895
+ end
896
+ end
897
+
898
+ let(:scoring) do
899
+ Module.new do
900
+ include Namo::Formulary
901
+ def momentum(row); row[:buys] + row[:sells]; end
902
+ def label(row); "scoring"; end
903
+ end
904
+ end
905
+
906
+ let(:analysis_class) do
907
+ klass = Class.new(Namo)
908
+ klass.include(indicators)
909
+ klass.include(scoring)
910
+ klass
911
+ end
912
+
913
+ it "lists an included formulary's public methods as derived dimensions" do
914
+ _(analysis_class.new(flow_data).derived_dimensions.sort).must_equal [:label, :momentum, :signed_volume]
915
+ end
916
+
917
+ it "excludes a private helper" do
918
+ _(analysis_class.new(flow_data).derived_dimensions).wont_include :helper
919
+ end
920
+
921
+ it "resolves an included formulary method" do
922
+ _(analysis_class.new(flow_data).values(:signed_volume)).must_equal [20]
923
+ end
924
+
925
+ it "lets the most-recently included formulary win on a name collision" do
926
+ _(analysis_class.new(flow_data).values(:label)).must_equal ["scoring"]
927
+ end
928
+
929
+ it "raises at construction when an included formulary collides with a data dimension" do
930
+ clash = Module.new do
931
+ include Namo::Formulary
932
+ def buys(row); 0; end
933
+ end
934
+ klass = Class.new(Namo)
935
+ klass.include(clash)
936
+ _(proc{klass.new([{buys: 5}])}).must_raise ArgumentError
937
+ end
938
+
939
+ it "reaches an instance built before a later include only through attach" do
940
+ klass = Class.new(Namo)
941
+ instance = klass.new(flow_data)
942
+ klass.include(scoring)
943
+ _(instance.derived_dimensions).wont_include :momentum
944
+ _(klass.new(flow_data).derived_dimensions).must_include :momentum
945
+ instance.attach(scoring)
946
+ _(instance.derived_dimensions).must_include :momentum
947
+ end
948
+ end
949
+
691
950
  describe "#[]= two-arity formulae" do
692
951
  let(:price_data) do
693
952
  [
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.22.0
4
+ version: 0.23.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - thoran
@@ -67,6 +67,7 @@ files:
67
67
  - lib/Namo/Collection.rb
68
68
  - lib/Namo/Enumerable.rb
69
69
  - lib/Namo/Formulae.rb
70
+ - lib/Namo/Formulary.rb
70
71
  - lib/Namo/NegatedDimension.rb
71
72
  - lib/Namo/Row.rb
72
73
  - lib/Namo/VERSION.rb