namo 0.22.0 → 0.24.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: 9e1ecaf29846a7876a59dae0d82c6c9d4e1fddd84b0c6a101941214ed6544517
4
+ data.tar.gz: 4245b13c3485a504ab5c365803de282cb508579b2ca9aec0799df0f1ce653d68
5
5
  SHA512:
6
- metadata.gz: 5c6c26eda0a637af215eef9cab0fe369769af3e945b3a1cf980788bd34be97abeefb3cf29fb208012556c5df9e46c212c9476dc222b0f3917404c7db3003f21a
7
- data.tar.gz: f71fd3ed7b8e8068b55fbb8379cd361c91c84ca1cb4fe823ff72af9fe1c9b8e1247468fb9fd818aa865cac709aa2d22e63f2c866030cacee22bf25c5930e7758
6
+ metadata.gz: 41156dc96b98f59f8ff0dcf0d78f3b80ed895f402e11af9a53ba2e6cdb13d8bf39f1a38f48f2457bfe8bcf9082d146b73dafa4663e068816b2339d6888fd25af
7
+ data.tar.gz: e9c1f033e17fd2b10ffab984ea6349b7641de8abf0f04f23f85c893793242abd9e795847244855ad18b8c3f74b5876cce16dd212b2d1d2a6d327998657c9a4dc
data/CHANGELOG CHANGED
@@ -1,6 +1,30 @@
1
1
  CHANGELOG
2
2
  _________
3
3
 
4
+ 20260629
5
+ 0.24.0: ~ Namo#<<, Namo::Collection#<< — widen from a formulary-only alias into a polymorphic "append a constituent" operator.
6
+
7
+ A base Namo's constituents are formularies and rows: << dispatches a Module to attach (0.23.0 behaviour, unchanged), a Hash to a data-row append, and a Row to a row append of its underlying hash. It does not take a bare callable (formula registration stays with []=) or a whole Namo (combining is +'s job) — both raise. Collection#<< is reconciled to the same verb over its own constituents: a Namo is a member, a Module attaches, and a loose Hash/Row raises (a Collection's rows are derived from its members, so a loose row has no durable home). This Hash/Row divergence — a base Namo appends the row, a Collection refuses it — is deliberate, not an oversight.
8
+
9
+ 1. ~ lib/namo.rb: ~ Namo#<< — replace the alias_method :<<, :attach with a dispatching << that routes Module→attach, Row→add_row(row.to_h), Hash→add_row(row), else→TypeError. attach stays Module-only and unchanged, so the 0.23.0 collision-guard and formulary semantics carry over verbatim and a Module's << still reaches it. + private add_row(row), appending to @data and returning self (so << chains). A whole Namo falls to the else and raises (it is not a Module/Row/Hash) — combining is +. Row-append is a data mutation that does not pass through []=, so it does not trigger formula-eviction; a row carrying a formula-named key surfaces live through the formula on access, per existing precedence (not guarded — the exclusivity invariant is []='s).
10
+ 2. ~ lib/Namo/Collection.rb: ~ Collection#<< — keep the splat + flatten (a member or an array of them), but dispatch each constituent: Namo→add_member, Module→attach (inherited from Namo — a Collection carries formulae over its detail view), Hash/Row→ArgumentError redirecting to member-add, else→TypeError. + private add_member(member), the prior reject-by-name + append body extracted verbatim. The @data = detail.data rebuild after the loop is preserved, so member-adds re-materialise detail as before and a Module-only << rebuilds harmlessly. Module routes to the inherited Namo#attach; member-add behaviour is byte-identical to 0.23.0 for the Namo/array cases.
11
+ 3. ~ test/namo_test.rb: ~ the formularies "#<<" describe gains the row arms — a Hash appends as a data row (and returns the Namo), a Row appends its underlying hash, a whole Namo raises TypeError (+'s job), a bare callable raises TypeError ([]='s job), and a mixed chain interleaves rows and a formulary. The two 0.23.0 Module-arm tests are unchanged.
12
+ 4. ~ test/Namo/Collection_test.rb: ~ the "#<<" describe gains a formulary attach whose row-scoped method resolves over the detail rows, a collection-scoped method (the namo_collection convention) reaching members, a loose Hash and a loose Row each raising ArgumentError with the member-redirect message, and a chain mixing member-adds with a formulary attach. The 0.23.0 member-add tests are unchanged.
13
+ 5. ~ README.md: ~ the Formularies << note — << now appends a constituent (Module, Hash, or Row) to a base Namo, chains, and raises on a bare callable or a whole Namo; the bare-Hash-is-always-a-row and row-append-does-not-evict notes added. ~ the Collection "<< and unnamed members" section — Collection#<< dispatches member/Module/raise, with the deliberate Hash/Row divergence spelled out. + a "Collection formulae" subsection — the namo_collection convention (an ordinary two-arity formulary method reaching a collection view in its body), no marker and no second store.
14
+ 6. ~ ROADMAP.md: + the 0.24.0 release entry; current-state bumped to 0.24.0; pivot/reshaping recorded as a decision (long-form aggregation is in-grain future work, widening-to-a-grid is presentational and belongs at an output edge, not the algebra), and collection formulae noted as needing no new mechanism.
15
+ 7. ~ Namo::VERSION: /0.23.0/0.24.0/
16
+
17
+ 20260627
18
+ 0.23.0: + Namo::Formulary — A formulary is a reusable body of derived dimensions a Namo instance may draw upon.
19
+
20
+ 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.
21
+ 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.
22
+ 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.
23
+ 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.
24
+ 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).
25
+ 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.
26
+ 7. ~ Namo::VERSION: /0.22.0/0.23.0/
27
+
4
28
  20260626
5
29
  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
30
 
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
+ `<<` appends a *constituent*. For a base Namo the constituents are formularies and rows: a **module** attaches (the operator form of `attach`, so `flows << OrderFlow` reads the same as `flows.attach(OrderFlow)`); a **Hash** appends as a data row; a **Row** (one drawn from another Namo) appends its underlying hash as a row. It returns the Namo, so the arms chain freely: `flows << OrderFlow << {date: 4, buys: 10, sells: 5} << Scoring`. Two things `<<` deliberately does not take: a bare callable (formula *registration* stays with `[]=`, which dispatches callable-vs-scalar and enforces data/formula exclusivity) and a whole Namo (combining collections is `+`'s job) — both raise. A bare Hash is therefore always a row, never a body of formulae. Appending a row is a data mutation that does not pass through `[]=`, so it does not trigger formula-eviction; a row carrying a formula-named key is your data, surfacing live through the formula on access per the usual precedence.
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]
@@ -1017,6 +1073,8 @@ gt << SubAssembly.new(name: :powertrain, data: [...]) # replaces the existing
1017
1073
  gt << [front_suspension, rear_suspension] # adds each
1018
1074
  ```
1019
1075
 
1076
+ On a Collection `<<` dispatches to the *constituent* appropriate to a collection: a **member** (a Namo, or an array of them) is added as above; a **module** attaches a formulary (a Collection is a Namo, so it carries formulae over its detail view — see below); a loose **Hash or Row raises** an `ArgumentError` that redirects to member-add. This is the one place `Namo#<<` and `Collection#<<` deliberately diverge: a base Namo appends a loose row, a Collection refuses it — a Collection's rows are *derived* from its members, so a loose row has no durable home (the next member-add rebuilds the data view and would erase it). The honest response is to refuse and point at member-add. A whole Namo arriving at a Collection is unambiguously a member, never a row-merge, because `<<` has no whole-Namo arm (that is `+`).
1077
+
1020
1078
  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
1079
 
1022
1080
  ```ruby
@@ -1026,6 +1084,23 @@ gt.find(:missing) # => nil
1026
1084
 
1027
1085
  (`find(name)` is member lookup; it shadows `Enumerable#find` on Collections. Predicate search over rows remains available as `detect`.)
1028
1086
 
1087
+ #### Collection formulae
1088
+
1089
+ A formulary attached to a Collection resolves over its detail rows like any other — a Collection is a Namo, and its data view is the materialised detail. A *collection formula* needs no new mechanism: it is an ordinary two-arity formulary method whose `namo` argument is the Collection, reaching for a collection view (`summary`, `detail`, `members`) in its body. It resolves through the same `derive` path as every other formula; member-awareness lives in the method body, not in any marker or second store. By convention, name that argument `namo_collection` to signal it expects a Collection — a plain Namo lacking `members`/`summary` will `NoMethodError`, which is honest self-enforcement, no guard needed.
1090
+
1091
+ ```ruby
1092
+ module FleetMetrics
1093
+ include Namo::Formulary
1094
+
1095
+ def member_count(row, namo_collection)
1096
+ namo_collection.members.size
1097
+ end
1098
+ end
1099
+
1100
+ gt << FleetMetrics
1101
+ gt.values(:member_count) # => one entry per detail row, each the member count
1102
+ ```
1103
+
1029
1104
  #### View lifetime and liveness
1030
1105
 
1031
1106
  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.
@@ -5,10 +5,15 @@ class Namo
5
5
  class Collection < Namo
6
6
  attr_reader :members
7
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
8
+ def <<(*constituents)
9
+ constituents.flatten.each do |constituent|
10
+ case constituent
11
+ when Namo then add_member(constituent)
12
+ when Module then attach(constituent)
13
+ when Hash, Row
14
+ raise ArgumentError, "a Collection's rows come from its members; add a member (a named Namo), not a loose row"
15
+ else raise TypeError, "can't append #{constituent.class} to a Collection"
16
+ end
12
17
  end
13
18
  @data = detail.data
14
19
  self
@@ -48,5 +53,10 @@ class Namo
48
53
  @members = []
49
54
  super
50
55
  end
56
+
57
+ def add_member(member)
58
+ @members.reject!{|existing| existing.name == member.name} unless member.name.nil?
59
+ @members << member
60
+ end
51
61
  end
52
62
  end
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.24.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,24 @@ 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
+ def <<(constituent)
101
+ case constituent
102
+ when Module then attach(constituent)
103
+ when Row then add_row(constituent.to_h)
104
+ when Hash then add_row(constituent)
105
+ else raise TypeError, "can't append #{constituent.class} to a Namo; expected a Module (formulary), a Hash, or a Row"
106
+ end
107
+ end
108
+
91
109
  def +(other)
92
110
  raise_unless_namo(other)
93
111
  raise_unless_matching_data_dimensions(other)
@@ -238,6 +256,19 @@ class Namo
238
256
  @data = positional_data || data
239
257
  @formulae = formulae.is_a?(Formulae) ? formulae : Formulae.new(formulae)
240
258
  @name = name
259
+ attach_included_formularies
260
+ end
261
+
262
+ def add_row(row)
263
+ @data << row
264
+ self
265
+ end
266
+
267
+ def attach_included_formularies
268
+ self.class.ancestors.reverse.each do |modul|
269
+ next if modul.is_a?(Class) || !modul.include?(Namo::Formulary)
270
+ attach(modul)
271
+ end
241
272
  end
242
273
 
243
274
  def values_for(dim)
@@ -45,6 +45,20 @@ describe Namo::Collection do
45
45
  Namo::Collection.new.tap{|c| c << [powertrain, chassis, body]}
46
46
  end
47
47
 
48
+ let(:component_pricing) do
49
+ Module.new do
50
+ include Namo::Formulary
51
+ def cost_per_kg(row); row[:cost] / row[:weight]; end
52
+ end
53
+ end
54
+
55
+ let(:fleet_metrics) do
56
+ Module.new do
57
+ include Namo::Formulary
58
+ def member_count(row, namo_collection); namo_collection.members.size; end
59
+ end
60
+ end
61
+
48
62
  describe "construction" do
49
63
  it "starts with empty members" do
50
64
  _(Namo::Collection.new.members).must_equal []
@@ -101,6 +115,34 @@ describe Namo::Collection do
101
115
  collection = Namo::Collection.new
102
116
  _(collection << powertrain).must_be_same_as collection
103
117
  end
118
+
119
+ it "attaches a formulary whose row-scoped method resolves over the detail rows" do
120
+ collection << component_pricing
121
+ _(collection.values(:cost_per_kg)).must_equal [250, 250, 200, 250]
122
+ end
123
+
124
+ it "attaches a formulary whose collection-scoped method reaches members" do
125
+ collection << fleet_metrics
126
+ _(collection.values(:member_count)).must_equal [3, 3, 3, 3]
127
+ end
128
+
129
+ it "raises an ArgumentError on a loose Hash, redirecting to member-add" do
130
+ error = _(proc{collection << {component: 'bolt', weight: 1, cost: 5}}).must_raise ArgumentError
131
+ _(error.message).must_match(/member/)
132
+ end
133
+
134
+ it "raises an ArgumentError on a loose Row, redirecting to member-add" do
135
+ row = Namo.new([{component: 'bolt', weight: 1, cost: 5}]).entries.first
136
+ error = _(proc{collection << row}).must_raise ArgumentError
137
+ _(error.message).must_match(/member/)
138
+ end
139
+
140
+ it "chains member-adds and a formulary attach" do
141
+ collection = Namo::Collection.new
142
+ collection << powertrain << chassis << component_pricing
143
+ _(collection.members.size).must_equal 2
144
+ _(collection.values(:cost_per_kg)).must_equal [250, 250, 200]
145
+ end
104
146
  end
105
147
 
106
148
  describe "#find" do
@@ -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,283 @@ 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
+
762
+ it "appends a Hash as a data row" do
763
+ namo = Namo.new(flow_data)
764
+ namo << {date: 4, buys: 10, sells: 5}
765
+ _(namo.values(:date)).must_equal [1, 2, 3, 4]
766
+ _(namo.values(:buys)).must_equal [60, 80, 75, 10]
767
+ end
768
+
769
+ it "returns the Namo when appending a row" do
770
+ namo = Namo.new(flow_data)
771
+ _(namo << {date: 4, buys: 10, sells: 5}).must_be_same_as namo
772
+ end
773
+
774
+ it "appends a Row's underlying hash as a data row" do
775
+ namo = Namo.new(flow_data)
776
+ row = Namo.new([{date: 4, buys: 10, sells: 5}]).entries.first
777
+ namo << row
778
+ _(namo.values(:date)).must_equal [1, 2, 3, 4]
779
+ end
780
+
781
+ it "raises a TypeError on a whole Namo, which is +'s job" do
782
+ namo = Namo.new(flow_data)
783
+ _(proc{namo << Namo.new(flow_data)}).must_raise TypeError
784
+ end
785
+
786
+ it "raises a TypeError on a bare callable, which is []='s job" do
787
+ namo = Namo.new(flow_data)
788
+ _(proc{namo << ->(row){row[:buys]}}).must_raise TypeError
789
+ end
790
+
791
+ it "chains mixed arms: rows and a formulary" do
792
+ namo = Namo.new(flow_data)
793
+ namo << {date: 4, buys: 10, sells: 5} << delta << {date: 5, buys: 0, sells: 0}
794
+ _(namo.values(:date)).must_equal [1, 2, 3, 4, 5]
795
+ _(namo.values(:signed_volume)).must_equal [20, -40, 0, 5, 0]
796
+ end
797
+ end
798
+
799
+ describe "#derived_dimensions" do
800
+ it "lists the formulary's public methods alongside store keys, unique" do
801
+ _(flows.derived_dimensions.sort).must_equal [:cum_delta, :scaled_volume, :signed_volume]
802
+ end
803
+
804
+ it "does not surface a private helper" do
805
+ _(flows.derived_dimensions).wont_include :helper
806
+ end
807
+
808
+ it "is empty for a vanilla Namo — no Namo public API leaks as a dimension" do
809
+ _(Namo.new(flow_data).derived_dimensions).must_equal []
810
+ end
811
+ end
812
+
813
+ describe "resolution" do
814
+ it "resolves a row-scoped formulary method through values" do
815
+ _(flows.values(:signed_volume)).must_equal [20, -40, 0]
816
+ end
817
+
818
+ it "resolves a row-scoped formulary method through a Row" do
819
+ _(flows.entries.first[:signed_volume]).must_equal 20
820
+ end
821
+
822
+ it "selects on a formulary-defined dimension" do
823
+ _(flows[signed_volume: 20].values(:date)).must_equal [1]
824
+ end
825
+
826
+ it "windows a two-arity formulary method over the yielding Namo" do
827
+ _(flows.values(:cum_delta)).must_equal [20, -20, -20]
828
+ end
829
+
830
+ it "raises the collection-scoped-context error on a Row built without a Namo" do
831
+ row = Namo::Row.new(flow_data.first, flows.formulae)
832
+ _(proc{row[:cum_delta]}).must_raise ArgumentError
833
+ end
834
+ end
835
+
836
+ describe "parameterised formulary method" do
837
+ it "is omitted from no-arg bulk views" do
838
+ _(flows.values.keys).wont_include :scaled_volume
839
+ end
840
+
841
+ it "raises when named directly with no argument" do
842
+ _(proc{flows.values(:scaled_volume)}).must_raise ArgumentError
843
+ end
844
+
845
+ it "materialises through a row-scoped wrapper that supplies the argument" do
846
+ flows[:double_flow] = ->(r){r[:scaled_volume, 2]}
847
+ _(flows.values(:double_flow)).must_equal [200, 400, 300]
848
+ end
849
+ end
850
+
851
+ describe "live re-attachment" do
852
+ it "resolves to the most-recently attached formulary on a name collision" do
853
+ alt = Module.new do
854
+ include Namo::Formulary
855
+ def signed_volume(row); row[:buys]; end
856
+ end
857
+ namo = Namo.new(flow_data).attach(delta).attach(alt)
858
+ _(namo.values(:signed_volume)).must_equal [60, 80, 75]
859
+ end
860
+ end
861
+
862
+ describe "precedence" do
863
+ it "lets an instance []= formula shadow a formulary method of the same name" do
864
+ flows[:signed_volume] = ->(r){0}
865
+ _(flows.values(:signed_volume)).must_equal [0, 0, 0]
866
+ end
867
+ end
868
+
869
+ describe "carry-through" do
870
+ it "carries the formulary live through selection" do
871
+ _(flows.select{|row| row[:date] >= 2}.values(:signed_volume)).must_equal [-40, 0]
872
+ end
873
+
874
+ it "carries the formulary live through projection by selection" do
875
+ _(flows[date: 1..2].values(:signed_volume)).must_equal [20, -40]
876
+ end
877
+
878
+ it "carries the formulary through concatenation" do
879
+ _((flows + Namo.new(flow_data)).derived_dimensions).must_include :signed_volume
880
+ end
881
+ end
882
+
883
+ describe "materialising a formulary dimension by projection" do
884
+ it "lists the name once, as data, not derived" do
885
+ proj = flows[:date, :signed_volume]
886
+ _(proj.data_dimensions).must_include :signed_volume
887
+ _(proj.derived_dimensions).wont_include :signed_volume
888
+ _(proj.dimensions.count(:signed_volume)).must_equal 1
889
+ end
890
+
891
+ it "reads the snapshot, not a recomputation, on per-Row access" do
892
+ proj = flows[:date, :signed_volume]
893
+ _(proj.entries.first[:signed_volume]).must_equal 20
894
+ _(proj.values(:signed_volume)).must_equal [20, -40, 0]
895
+ end
896
+
897
+ it "raises when a formulary method collides with a data dimension" do
898
+ widened = Namo.new(flow_data.map{|row| row.merge(signed_volume: 999)})
899
+ error = _(proc{widened.attach(delta)}).must_raise ArgumentError
900
+ _(error.message).must_match(/signed_volume/)
901
+ end
902
+ end
903
+
904
+ describe "#===" do
905
+ it "is not === to a vanilla Namo of the same data dimensions" do
906
+ _(flows === Namo.new(flow_data)).must_equal false
907
+ end
908
+
909
+ it "is === to a Namo exposing the same names via []=" do
910
+ other = Namo.new(flow_data)
911
+ other[:signed_volume] = ->(r){0}
912
+ other[:cum_delta] = ->(r, n){0}
913
+ other[:scaled_volume] = ->(r, n, f){0}
914
+ _(flows === other).must_equal true
915
+ end
916
+ end
917
+ end
918
+
919
+ describe "formularies via class include" do
920
+ let(:flow_data) do
921
+ [{buys: 60, sells: 40}]
922
+ end
923
+
924
+ let(:indicators) do
925
+ Module.new do
926
+ include Namo::Formulary
927
+ def signed_volume(row); row[:buys] - row[:sells]; end
928
+ def label(row); "indicators"; end
929
+ private
930
+ def helper(row); row[:buys]; end
931
+ end
932
+ end
933
+
934
+ let(:scoring) do
935
+ Module.new do
936
+ include Namo::Formulary
937
+ def momentum(row); row[:buys] + row[:sells]; end
938
+ def label(row); "scoring"; end
939
+ end
940
+ end
941
+
942
+ let(:analysis_class) do
943
+ klass = Class.new(Namo)
944
+ klass.include(indicators)
945
+ klass.include(scoring)
946
+ klass
947
+ end
948
+
949
+ it "lists an included formulary's public methods as derived dimensions" do
950
+ _(analysis_class.new(flow_data).derived_dimensions.sort).must_equal [:label, :momentum, :signed_volume]
951
+ end
952
+
953
+ it "excludes a private helper" do
954
+ _(analysis_class.new(flow_data).derived_dimensions).wont_include :helper
955
+ end
956
+
957
+ it "resolves an included formulary method" do
958
+ _(analysis_class.new(flow_data).values(:signed_volume)).must_equal [20]
959
+ end
960
+
961
+ it "lets the most-recently included formulary win on a name collision" do
962
+ _(analysis_class.new(flow_data).values(:label)).must_equal ["scoring"]
963
+ end
964
+
965
+ it "raises at construction when an included formulary collides with a data dimension" do
966
+ clash = Module.new do
967
+ include Namo::Formulary
968
+ def buys(row); 0; end
969
+ end
970
+ klass = Class.new(Namo)
971
+ klass.include(clash)
972
+ _(proc{klass.new([{buys: 5}])}).must_raise ArgumentError
973
+ end
974
+
975
+ it "reaches an instance built before a later include only through attach" do
976
+ klass = Class.new(Namo)
977
+ instance = klass.new(flow_data)
978
+ klass.include(scoring)
979
+ _(instance.derived_dimensions).wont_include :momentum
980
+ _(klass.new(flow_data).derived_dimensions).must_include :momentum
981
+ instance.attach(scoring)
982
+ _(instance.derived_dimensions).must_include :momentum
983
+ end
984
+ end
985
+
691
986
  describe "#[]= two-arity formulae" do
692
987
  let(:price_data) do
693
988
  [
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.24.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