namo 0.4.0 → 0.6.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: 0dbfe699c8bbb334e0f72bbfd1859b0eee910be2c72eaab988da633b58b438e6
4
- data.tar.gz: 40238ef4593d7820bddfb6cb708823fa9c269a51def947e89cdb43e4b69cbae0
3
+ metadata.gz: 9d29843b2d9895ba401fa013ea83753f548458bc09bcd8e61218400f81950e33
4
+ data.tar.gz: 6b8772e13cd773d41ae4cabddb019bddb533cbc9a24ece7729977543e462a30c
5
5
  SHA512:
6
- metadata.gz: 9e6dfd0bcf4b8d370373a43e507a4d208e4ccb4478e044eb138bf5f2bfa61612cbb4f10735be691c852d2f8bf672eca70720cc6a5c35cd5fb02fae224388e010
7
- data.tar.gz: 2b147c6e7faf7b9f92e8e2457fcd657734a35946c326857e3b7c09177ceaa7f3ce4f0892bdcbb68ec251d673e1b298c1ed06416fe43918656734a58bd0c639ea
6
+ metadata.gz: 22c4c03943617b1ec56e45ace4722019e6d54bb12f9b30fb3e2b85eebce132477a380ccde5529ea65782be03f060abd7507ebbfbe8b06d3c8ad10d7b90319061
7
+ data.tar.gz: 17e0163a3353024bec9826d5fc95773893d8195dbb7efe87ff7e814da7c74ca1391f168ca10025a6deb9fcd44e7ecae8fc3a10c44f29f9ef1e2018a0de96aa97
data/CHANGELOG CHANGED
@@ -1,6 +1,31 @@
1
1
  CHANGELOG
2
2
  _________
3
3
 
4
+ 20260511
5
+ 0.6.0: + equality, pattern-match, and subset/superset operators
6
+
7
+ 1. + Namo#==: Multiset equality on row data, ignoring class and formulae.
8
+ 2. + Namo#===: Analytical identity match — true iff other has the same dimensions and same formula names as self, ignoring rows and proc bodies. Returns false for non-Namo operands.
9
+ 3. + Namo#eql?: Strict equality requiring class match, multiset-equal data, and formula name match.
10
+ 4. + Namo#hash: Content-based hash, consistent with eql?.
11
+ 5. + Namo#<, #<=, #>, #>=: Multiset subset/superset relations on rows. Raise ArgumentError on mismatched dimensions, TypeError on non-Namo operand.
12
+ 6. ~ Namo#+, #-, #&, #|, #^: Error message on dimension mismatch updated to "dimensions don't match: X vs Y". Non-Namo operand now raises TypeError ("can't compare Namo with X") instead of NoMethodError.
13
+ 7. ~ lib/namo.rb, namo.gemspec: Minor cleanup (./-prefixed requires; gemspec whitespace).
14
+ 8. ~ test/namo_test.rb: Add tests for ==, ===, eql?, hash, <, <=, >, >=, equal?, and the new error message.
15
+ 9. ~ README.md: + Equality section, + Subset and superset section, + design-philosophy paragraph in the opening and one-line principle callouts in the dimensions, formulae, set-operator, and equality sections.
16
+ 10. + script/md4print, ~ Rakefile: + rake docs:print, docs:pdf, docs:all for regenerating docs/*.print.md and docs/*.print.pdf.
17
+ 11. ~ Namo::VERSION: /0.5.0/0.6.0/
18
+
19
+ 20260416
20
+ 0.5.0: + row-axis set operations: intersection (&), union (|), symmetric difference (^)
21
+
22
+ 1. + Namo#&: Intersection. Returns rows present in both Namo objects. Requires matching dimensions.
23
+ 2. + Namo#|: Union. Returns all rows from both sides, deduplicated. Requires matching dimensions.
24
+ 3. + Namo#^: Symmetric difference. Returns rows in one side but not both. Requires matching dimensions.
25
+ 4. ~ Namo_test.rb: Add tests for #&, #|, and #^.
26
+ 5. ~ README.md: + Intersection section, + Union section, + Symmetric difference section.
27
+ 6. ~ Namo::VERSION: /0.4.0/0.5.0/
28
+
4
29
  20260415
5
30
  0.4.0: + concatenation (+) and row removal (-)
6
31
 
data/README.md CHANGED
@@ -4,6 +4,8 @@ Named dimensional data for Ruby.
4
4
 
5
5
  Namo is a Ruby library for working with multi-dimensional data using named dimensions. It infers dimensions and coordinates from plain arrays of hashes — the same shape you get from databases, CSV files, JSON, and YAML — so there's no reshaping step.
6
6
 
7
+ The design rests on a few stances: every hash key is a dimension and none is privileged; formulae attach to a Namo alongside stored data and re-evaluate on each access; the operators that combine Namos all take Namos and return Namos, so analytical pipelines close; and the formula mechanism is type-agnostic — strings, dates, booleans, and arbitrary Ruby objects work as readily as numbers.
8
+
7
9
  ## Installation
8
10
 
9
11
  ```
@@ -44,6 +46,8 @@ sales.coordinates[:quarter]
44
46
  # => ['Q1', 'Q2']
45
47
  ```
46
48
 
49
+ Every key is a dimension; every value is a coordinate. There's no schema declaration and no choosing which column is "the index" — `price` and `quantity` are no less first-class than `product` and `quarter`.
50
+
47
51
  ### Selection
48
52
 
49
53
  Select by named dimension using keyword arguments:
@@ -155,6 +159,8 @@ Selection, projection, and contraction always return a new Namo instance, so eve
155
159
 
156
160
  ### Concatenation
157
161
 
162
+ `+` is the first of Namo's binary operators: it takes a Namo on each side and returns a Namo. The same shape holds for `-`, `&`, `|`, `^`, `==`, `===`, `<`, `<=`, `>`, `>=` and (later) the composition operators — Namo in, Namo (or boolean) out — so analytical pipelines stay queryable end-to-end.
163
+
158
164
  `+` combines two Namo objects that share the same dimensions by appending the rows of the second to the first:
159
165
 
160
166
  ```ruby
@@ -205,6 +211,162 @@ sales - discontinued
205
211
 
206
212
  Removal is exact — every dimension, every value must match. The dimensions must match; different dimensions raise an `ArgumentError`. Formulae carry through from the left-hand side.
207
213
 
214
+ ### Intersection
215
+
216
+ `&` returns the rows present in both Namo objects, like `Array#&`:
217
+
218
+ ```ruby
219
+ sales = Namo.new([
220
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
221
+ {product: 'Widget', quarter: 'Q2', price: 10.0, quantity: 150},
222
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
223
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
224
+ ])
225
+
226
+ confirmed = Namo.new([
227
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
228
+ {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
229
+ ])
230
+
231
+ sales & confirmed
232
+ # => #<Namo [
233
+ # {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
234
+ # {product: 'Gadget', quarter: 'Q2', price: 25.0, quantity: 60}
235
+ # ]>
236
+ ```
237
+
238
+ The dimensions must match; different dimensions raise an `ArgumentError`. Formulae carry through from the left-hand side.
239
+
240
+ ### Union
241
+
242
+ `|` returns all rows from both sides, deduplicated, like `Array#|`:
243
+
244
+ ```ruby
245
+ q1_sales = Namo.new([
246
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
247
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40}
248
+ ])
249
+
250
+ all_sales = Namo.new([
251
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
252
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
253
+ ])
254
+
255
+ q1_sales | all_sales
256
+ # => #<Namo [
257
+ # {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
258
+ # {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
259
+ # {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
260
+ # ]>
261
+ ```
262
+
263
+ The dimensions must match; different dimensions raise an `ArgumentError`. Formulae merge from both sides; the left-hand side's formulae take precedence on conflict.
264
+
265
+ ### Symmetric Difference
266
+
267
+ `^` returns rows that appear in one side but not both:
268
+
269
+ ```ruby
270
+ set_a = Namo.new([
271
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
272
+ {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40}
273
+ ])
274
+
275
+ set_b = Namo.new([
276
+ {product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100},
277
+ {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
278
+ ])
279
+
280
+ set_a ^ set_b
281
+ # => #<Namo [
282
+ # {product: 'Gadget', quarter: 'Q1', price: 25.0, quantity: 40},
283
+ # {product: 'Thingo', quarter: 'Q3', price: 5.0, quantity: 10}
284
+ # ]>
285
+ ```
286
+
287
+ The dimensions must match; different dimensions raise an `ArgumentError`. Formulae merge from both sides; the left-hand side's formulae take precedence on conflict.
288
+
289
+ ### Equality
290
+
291
+ Comparison on Namos is **multiset-theoretic on rows**: row order is ignored (it's an accident of ingestion, not data), but row multiplicities count (they *are* data). The same stance carries across the equality, pattern-match, and subset/superset operators below.
292
+
293
+ `==` is multiset equality on rows. Class and formulae are ignored; row order is ignored; row multiplicities are not.
294
+
295
+ ```ruby
296
+ a = Namo.new([{x: 1}, {x: 2}])
297
+ b = Namo.new([{x: 2}, {x: 1}])
298
+
299
+ a == b
300
+ # => true
301
+
302
+ a == Namo.new([{x: 1}, {x: 1}, {x: 2}])
303
+ # => false
304
+ ```
305
+
306
+ `eql?` is stricter: it also requires the class to match and the formula names to match. Like `===`, it ignores proc bodies — proc identity isn't a meaningful equivalence in Ruby (`proc{...} == proc{...}` is false), so neither `===` nor `eql?` uses it.
307
+
308
+ `hash` is consistent with `eql?` and is content-based, so equal Namos hash equally and can be used as Hash keys:
309
+
310
+ ```ruby
311
+ h = {a => 'first'}
312
+ h[b]
313
+ # => 'first'
314
+ ```
315
+
316
+ `equal?` is unchanged from Ruby's default — it tests object identity.
317
+
318
+ `===` answers a different question: does the candidate have the same dimensions and the same formula names? Row data is ignored, and so are the proc bodies themselves — only the names matter. This is the `===` semantics that case statements use, so Namos can serve as templates for analytical shape:
319
+
320
+ ```ruby
321
+ sales_shape = Namo.new([{product: 'X', quarter: 'Q1', price: 0.0, quantity: 0}])
322
+ sales_shape[:revenue] = proc{|row| row[:price] * row[:quantity]}
323
+
324
+ q1 = Namo.new([{product: 'Widget', quarter: 'Q1', price: 10.0, quantity: 100}])
325
+ q1[:revenue] = proc{|row| row[:price] * row[:quantity]}
326
+
327
+ sales_shape === q1
328
+ # => true (same dimensions, same formula name)
329
+
330
+ sales_shape == q1
331
+ # => false (different rows)
332
+ ```
333
+
334
+ The two `:revenue` procs are independently-written and not the same object — `proc{...} == proc{...}` is false in Ruby. But `===` doesn't compare proc identity; it asks "do these Namos have the same analytical shape?" and the shape is the set of dimensions plus the set of formula names.
335
+
336
+ Each comparison operator answers a distinct question: `eql?` is strictest (class + data + formula names); `==` is data identity; `===` is analytical identity; the subset operators are data containment.
337
+
338
+ ### Subset and Superset
339
+
340
+ `<`, `<=`, `>`, `>=` are multiset subset and superset relations on rows.
341
+
342
+ ```ruby
343
+ small = Namo.new([{x: 1}, {x: 2}])
344
+ large = Namo.new([{x: 1}, {x: 2}, {x: 3}])
345
+
346
+ small <= large
347
+ # => true
348
+
349
+ small < large
350
+ # => true
351
+
352
+ large > small
353
+ # => true
354
+ ```
355
+
356
+ Equal sets are `<=` and `>=` each other, but neither `<` nor `>`. Disjoint sets are none of the above — unless one side is empty, in which case it is a subset of (and disjoint with) the other.
357
+
358
+ Multiplicity matters: a single `{x: 1}` is a proper subset of two `{x: 1}`s.
359
+
360
+ ```ruby
361
+ one = Namo.new([{x: 1}])
362
+ two = Namo.new([{x: 1}, {x: 1}])
363
+
364
+ one < two
365
+ # => true
366
+ ```
367
+
368
+ The dimensions must match; different dimensions raise an `ArgumentError`. Comparing against a non-Namo raises a `TypeError`.
369
+
208
370
  ### Formulae
209
371
 
210
372
  Define computed dimensions using `[]=`:
@@ -221,6 +383,8 @@ sales[:product, :quarter, :revenue]
221
383
  # ]>
222
384
  ```
223
385
 
386
+ Formulae aren't materialised into stored columns — they re-evaluate on every access. A `:revenue` value reflects the current `:price` and `:quantity` at the moment you ask for it, so derived values stay in sync with whatever the underlying data is doing.
387
+
224
388
  Formulae compose:
225
389
 
226
390
  ```ruby
data/Rakefile CHANGED
@@ -6,4 +6,37 @@ Rake::TestTask.new(:test) do |t|
6
6
  t.test_files = FileList['test/**/*_test.rb']
7
7
  end
8
8
 
9
+ namespace :docs do
10
+ SOURCE_DOCS = %w{COMPARISON EXAMPLES README ROADMAP}
11
+
12
+ desc "Strip syntax highlighting from code blocks for printing"
13
+ task :md4print do
14
+ SOURCE_DOCS.each do |name|
15
+ sh "script/md4print #{name}.md"
16
+ sh "mv #{name}.print.md docs/"
17
+ end
18
+ end
19
+
20
+ desc "Render print-ready markdown to PDF"
21
+ task :md2pdf => :md4print do
22
+ Dir.glob('docs/*.print.md').each do |f|
23
+ pdf = f.sub(/\.md$/, '.pdf')
24
+ sh "pandoc #{f} --pdf-engine=xelatex -V geometry:margin=1in -o #{pdf}"
25
+ end
26
+ end
27
+
28
+ desc "Remove intermediate .print.md files"
29
+ task :clean do
30
+ rm_f Dir.glob('docs/*.print.md')
31
+ end
32
+
33
+ desc "Remove all generated docs (intermediates and PDFs)"
34
+ task :clobber => :clean do
35
+ rm_f Dir.glob('docs/*.print.pdf')
36
+ end
37
+
38
+ desc "Regenerate all derived docs"
39
+ task :gen => [:md2pdf, :clean]
40
+ end
41
+
9
42
  task default: :test
data/lib/Namo/VERSION.rb CHANGED
@@ -2,5 +2,5 @@
2
2
  # Namo::VERSION
3
3
 
4
4
  class Namo
5
- VERSION = '0.4.0'
5
+ VERSION = '0.6.0'
6
6
  end
data/lib/namo.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  # namo.rb
2
2
  # Namo
3
3
 
4
- require_relative 'Namo/NegatedDimension'
5
- require_relative 'Namo/Row'
6
- require_relative 'Symbol'
4
+ require_relative './Namo/NegatedDimension'
5
+ require_relative './Namo/Row'
6
+ require_relative './Symbol'
7
7
 
8
8
  class Namo
9
9
  include Enumerable
@@ -57,19 +57,80 @@ class Namo
57
57
  end
58
58
 
59
59
  def +(other)
60
- unless dimensions == other.dimensions
61
- raise ArgumentError, "dimensions do not match"
62
- end
60
+ raise_unless_namo(other)
61
+ raise_unless_matching_dimensions(other)
63
62
  self.class.new(@data + other.data, formulae: other.formulae.merge(@formulae))
64
63
  end
65
64
 
66
65
  def -(other)
67
- unless dimensions == other.dimensions
68
- raise ArgumentError, "dimensions do not match"
69
- end
66
+ raise_unless_namo(other)
67
+ raise_unless_matching_dimensions(other)
70
68
  self.class.new(@data - other.data, formulae: @formulae.dup)
71
69
  end
72
70
 
71
+ def &(other)
72
+ raise_unless_namo(other)
73
+ raise_unless_matching_dimensions(other)
74
+ self.class.new(@data & other.data, formulae: @formulae.dup)
75
+ end
76
+
77
+ def |(other)
78
+ raise_unless_namo(other)
79
+ raise_unless_matching_dimensions(other)
80
+ self.class.new((@data | other.data), formulae: other.formulae.merge(@formulae))
81
+ end
82
+
83
+ def ^(other)
84
+ raise_unless_namo(other)
85
+ raise_unless_matching_dimensions(other)
86
+ self.class.new((@data - other.data) + (other.data - @data), formulae: other.formulae.merge(@formulae))
87
+ end
88
+
89
+ def ==(other)
90
+ return false unless other.is_a?(Namo)
91
+ canonical_data == other.canonical_data
92
+ end
93
+
94
+ def ===(other)
95
+ return false unless other.is_a?(Namo)
96
+ dimensions.sort == other.dimensions.sort &&
97
+ @formulae.keys.sort == other.formulae.keys.sort
98
+ end
99
+
100
+ def eql?(other)
101
+ self.class == other.class &&
102
+ canonical_data == other.canonical_data &&
103
+ @formulae.keys.sort == other.formulae.keys.sort
104
+ end
105
+
106
+ def hash
107
+ [self.class, canonical_data, @formulae.keys.sort].hash
108
+ end
109
+
110
+ def <(other)
111
+ raise_unless_namo(other)
112
+ raise_unless_matching_dimensions(other)
113
+ proper_subset_of_rows?(other)
114
+ end
115
+
116
+ def <=(other)
117
+ raise_unless_namo(other)
118
+ raise_unless_matching_dimensions(other)
119
+ subset_of_rows?(other)
120
+ end
121
+
122
+ def >(other)
123
+ raise_unless_namo(other)
124
+ raise_unless_matching_dimensions(other)
125
+ other.proper_subset_of_rows?(self)
126
+ end
127
+
128
+ def >=(other)
129
+ raise_unless_namo(other)
130
+ raise_unless_matching_dimensions(other)
131
+ other.subset_of_rows?(self)
132
+ end
133
+
73
134
  def to_a
74
135
  @data.map do |row|
75
136
  row.keys.each_with_object({}) do |key, hash|
@@ -78,8 +139,36 @@ class Namo
78
139
  end
79
140
  end
80
141
 
142
+ protected
143
+
144
+ def canonical_data
145
+ @data.sort_by{|row| row.values_at(*dimensions.sort)}
146
+ end
147
+
148
+ def subset_of_rows?(other)
149
+ self_counts = canonical_data.tally
150
+ other_counts = other.canonical_data.tally
151
+ self_counts.all?{|row, count| (other_counts[row] || 0) >= count}
152
+ end
153
+
154
+ def proper_subset_of_rows?(other)
155
+ subset_of_rows?(other) && self != other
156
+ end
157
+
81
158
  private
82
159
 
160
+ def raise_unless_namo(other)
161
+ unless other.is_a?(Namo)
162
+ raise TypeError, "can't compare Namo with #{other.class}"
163
+ end
164
+ end
165
+
166
+ def raise_unless_matching_dimensions(other)
167
+ unless dimensions == other.dimensions
168
+ raise ArgumentError, "dimensions don't match: #{dimensions} vs #{other.dimensions}"
169
+ end
170
+ end
171
+
83
172
  def initialize(data = nil, formulae: {})
84
173
  @data = data
85
174
  @formulae = formulae
data/namo.gemspec CHANGED
@@ -19,7 +19,6 @@ Gem::Specification.new do |spec|
19
19
  spec.license = 'MIT'
20
20
 
21
21
  spec.required_ruby_version = '>= 2.7'
22
-
23
22
  spec.require_paths = ['lib']
24
23
 
25
24
  spec.files = [