trifle-stats 2.4.1 → 2.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.
@@ -0,0 +1,356 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bigdecimal'
4
+
5
+ module Trifle
6
+ module Stats
7
+ class Transponder
8
+ class ExpressionEngine
9
+ LETTERS = ('a'..'z').to_a.freeze
10
+ TOKEN_OPERATORS = %w[+ - * / ( ) ,].freeze
11
+ NUMBER_PATTERN = /\A\d+(?:\.\d+)?/.freeze
12
+ IDENT_PATTERN = /\A[A-Za-z_][A-Za-z0-9_]*/.freeze
13
+
14
+ class << self
15
+ def max_vars
16
+ LETTERS.length
17
+ end
18
+
19
+ def allowed_vars(count)
20
+ LETTERS.take(count)
21
+ end
22
+
23
+ def validate(paths:, expression:)
24
+ normalized_paths = normalize_paths(paths)
25
+ parse(expression: expression, paths: normalized_paths)
26
+ true
27
+ end
28
+
29
+ def parse(expression:, paths:)
30
+ normalized_paths = normalize_paths(paths)
31
+ ensure_within_var_limit(normalized_paths)
32
+ tokens = tokenize(expression.to_s)
33
+ parser = Parser.new(tokens, allowed_vars(normalized_paths.length))
34
+ ast = parser.parse
35
+ raise ArgumentError, 'Unexpected token sequence.' unless parser.done?
36
+
37
+ ast
38
+ end
39
+
40
+ def evaluate(ast:, env:)
41
+ Evaluator.new(env).evaluate(ast)
42
+ end
43
+
44
+ private
45
+
46
+ def normalize_paths(paths)
47
+ raise ArgumentError, 'Paths must be an array.' unless paths.is_a?(Array)
48
+
49
+ cleaned = paths.map { |path| path.to_s.strip }.reject(&:empty?)
50
+ raise ArgumentError, 'At least one path is required.' if cleaned.empty?
51
+
52
+ cleaned
53
+ end
54
+
55
+ def ensure_within_var_limit(paths)
56
+ return if paths.length <= max_vars
57
+
58
+ raise ArgumentError, "Too many paths. Maximum supported is #{max_vars}."
59
+ end
60
+
61
+ def tokenize(expression)
62
+ trimmed = normalize_expression_text(expression)
63
+ tokens = []
64
+ index = 0
65
+
66
+ while index < trimmed.length
67
+ token, consumed = read_token(trimmed, index)
68
+ tokens << token unless token.nil?
69
+ index += consumed
70
+ end
71
+
72
+ tokens
73
+ end
74
+
75
+ def normalize_expression_text(expression)
76
+ trimmed = expression.to_s.strip
77
+ raise ArgumentError, 'Expression must be text.' if trimmed.empty?
78
+
79
+ trimmed
80
+ end
81
+
82
+ def read_token(source, index)
83
+ char = source[index]
84
+
85
+ return [nil, 1] if whitespace?(char)
86
+ return [char, 1] if operator?(char)
87
+ return number_token(source, index) if digit?(char)
88
+ return ident_token(source, index) if identifier_start?(char)
89
+
90
+ raise_invalid_token(index)
91
+ end
92
+
93
+ def whitespace?(char)
94
+ char.match?(/\s/)
95
+ end
96
+
97
+ def operator?(char)
98
+ TOKEN_OPERATORS.include?(char)
99
+ end
100
+
101
+ def digit?(char)
102
+ char.match?(/\d/)
103
+ end
104
+
105
+ def identifier_start?(char)
106
+ char.match?(/[A-Za-z_]/)
107
+ end
108
+
109
+ def number_token(source, index)
110
+ match = source[index..].match(NUMBER_PATTERN)
111
+ raise_invalid_token(index) unless match
112
+
113
+ [[:number, BigDecimal(match[0])], match[0].length]
114
+ end
115
+
116
+ def ident_token(source, index)
117
+ match = source[index..].match(IDENT_PATTERN)
118
+ raise_invalid_token(index) unless match
119
+
120
+ [[:ident, match[0]], match[0].length]
121
+ end
122
+
123
+ def raise_invalid_token(index)
124
+ raise ArgumentError, "Invalid token at position #{index}."
125
+ end
126
+ end
127
+
128
+ class Parser
129
+ def initialize(tokens, vars)
130
+ @tokens = tokens
131
+ @vars = vars
132
+ @index = 0
133
+ end
134
+
135
+ def parse
136
+ parse_expression
137
+ end
138
+
139
+ def done?
140
+ current.nil?
141
+ end
142
+
143
+ private
144
+
145
+ def parse_expression
146
+ node = parse_term
147
+
148
+ while %w[+ -].include?(current)
149
+ op = consume
150
+ node = [op.to_sym, node, parse_term]
151
+ end
152
+
153
+ node
154
+ end
155
+
156
+ def parse_term
157
+ node = parse_factor
158
+
159
+ while %w[* /].include?(current)
160
+ op = consume
161
+ node = [op.to_sym, node, parse_factor]
162
+ end
163
+
164
+ node
165
+ end
166
+
167
+ def parse_factor
168
+ return parse_unary_factor if %w[+ -].include?(current)
169
+ return parse_grouped_expression if current == '('
170
+
171
+ parse_primary
172
+ end
173
+
174
+ def parse_primary
175
+ token = current
176
+ raise ArgumentError, 'Unexpected end of expression.' if token.nil?
177
+
178
+ return parse_number(token) if number_token?(token)
179
+ return parse_identifier(token) if ident_token?(token)
180
+
181
+ raise ArgumentError, "Unexpected token #{token.inspect}."
182
+ end
183
+
184
+ def parse_args
185
+ return [] if current == ')'
186
+
187
+ args = [parse_expression]
188
+
189
+ while current == ','
190
+ consume
191
+ args << parse_expression
192
+ end
193
+
194
+ args
195
+ end
196
+
197
+ def current
198
+ @tokens[@index]
199
+ end
200
+
201
+ def consume
202
+ token = current
203
+ @index += 1
204
+ token
205
+ end
206
+
207
+ def parse_unary_factor
208
+ operator = consume
209
+ node = parse_factor
210
+ operator == '-' ? [:neg, node] : node
211
+ end
212
+
213
+ def parse_grouped_expression
214
+ consume
215
+ node = parse_expression
216
+ expect_token!(')', 'Missing closing parenthesis.')
217
+ consume
218
+ node
219
+ end
220
+
221
+ def parse_number(token)
222
+ consume
223
+ [:number, token[1]]
224
+ end
225
+
226
+ def parse_identifier(token)
227
+ consume
228
+ ident = token[1]
229
+ return parse_function_call(ident) if current == '('
230
+
231
+ validate_variable!(ident)
232
+ [:var, ident]
233
+ end
234
+
235
+ def parse_function_call(ident)
236
+ consume
237
+ args = parse_args
238
+ expect_token!(')', 'Missing closing parenthesis.')
239
+ consume
240
+ [:func, ident, args]
241
+ end
242
+
243
+ def validate_variable!(ident)
244
+ raise ArgumentError, "Unknown variable #{ident}." unless @vars.include?(ident)
245
+ end
246
+
247
+ def number_token?(token)
248
+ token.is_a?(Array) && token[0] == :number
249
+ end
250
+
251
+ def ident_token?(token)
252
+ token.is_a?(Array) && token[0] == :ident
253
+ end
254
+
255
+ def expect_token!(expected, message)
256
+ raise ArgumentError, message unless current == expected
257
+ end
258
+ end
259
+
260
+ class Evaluator
261
+ ZERO = BigDecimal('0')
262
+ AVERAGE = lambda do |args|
263
+ args.reduce(ZERO, :+) / BigDecimal(args.length.to_s)
264
+ end
265
+ BINARY_OPERATIONS = {
266
+ :+ => ->(left, right) { left + right },
267
+ :- => ->(left, right) { left - right },
268
+ :* => ->(left, right) { left * right },
269
+ :/ => ->(left, right) { right.zero? ? nil : left / right }
270
+ }.freeze
271
+ FUNCTIONS = {
272
+ 'sum' => ->(args) { args.reduce(ZERO, :+) },
273
+ 'mean' => AVERAGE,
274
+ 'avg' => AVERAGE,
275
+ 'min' => :min.to_proc,
276
+ 'max' => :max.to_proc
277
+ }.freeze
278
+
279
+ def initialize(env)
280
+ @env = env || {}
281
+ end
282
+
283
+ def evaluate(node)
284
+ type, *rest = node
285
+ return rest[0] if type == :number
286
+ return evaluate_variable(rest[0]) if type == :var
287
+ return evaluate_negation(rest[0]) if type == :neg
288
+ return evaluate_binary(type, rest[0], rest[1]) if BINARY_OPERATIONS.key?(type)
289
+ return evaluate_function(rest[0], rest[1]) if type == :func
290
+
291
+ raise ArgumentError, "Unknown AST node #{type.inspect}."
292
+ end
293
+
294
+ private
295
+
296
+ def normalize_value(value)
297
+ return nil if value.nil?
298
+ return value if value.is_a?(BigDecimal)
299
+ return BigDecimal(value.to_s) if value.is_a?(Numeric)
300
+
301
+ nil
302
+ rescue ArgumentError
303
+ nil
304
+ end
305
+
306
+ def evaluate_variable(name)
307
+ normalize_value(@env[name])
308
+ end
309
+
310
+ def evaluate_negation(node)
311
+ value = evaluate(node)
312
+ value.nil? ? nil : -value
313
+ end
314
+
315
+ def evaluate_binary(operator, left_node, right_node)
316
+ left = evaluate(left_node)
317
+ right = evaluate(right_node)
318
+ apply_binary(operator, left, right)
319
+ end
320
+
321
+ def evaluate_function(name, arg_nodes)
322
+ args = arg_nodes.map { |arg| evaluate(arg) }
323
+ apply_function(name, args)
324
+ end
325
+
326
+ def apply_binary(operator, left, right)
327
+ return nil if left.nil? || right.nil?
328
+
329
+ operation = BINARY_OPERATIONS[operator]
330
+ raise ArgumentError, "Unknown binary operator #{operator.inspect}." unless operation
331
+
332
+ operation.call(left, right)
333
+ end
334
+
335
+ def apply_function(name, args)
336
+ return nil if args.empty? || args.any?(&:nil?)
337
+
338
+ return apply_sqrt(args) if name == 'sqrt'
339
+
340
+ function = FUNCTIONS[name]
341
+ raise ArgumentError, "Unknown function #{name}." unless function
342
+
343
+ function.call(args)
344
+ end
345
+
346
+ def apply_sqrt(args)
347
+ raise ArgumentError, 'Function sqrt expects 1 argument.' unless args.length == 1
348
+ return nil if args[0].negative?
349
+
350
+ BigDecimal(Math.sqrt(args[0].to_f).to_s)
351
+ end
352
+ end
353
+ end
354
+ end
355
+ end
356
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Trifle
4
4
  module Stats
5
- VERSION = '2.4.1'
5
+ VERSION = '2.6.0'
6
6
  end
7
7
  end
data/lib/trifle/stats.rb CHANGED
@@ -28,16 +28,8 @@ require 'trifle/stats/operations/timeseries/set'
28
28
  require 'trifle/stats/operations/timeseries/values'
29
29
  require 'trifle/stats/operations/status/beam'
30
30
  require 'trifle/stats/operations/status/scan'
31
- require 'trifle/stats/transponder/add'
32
- require 'trifle/stats/transponder/divide'
33
- require 'trifle/stats/transponder/max'
34
- require 'trifle/stats/transponder/mean'
35
- require 'trifle/stats/transponder/min'
36
- require 'trifle/stats/transponder/multiply'
37
- require 'trifle/stats/transponder/ratio'
38
- require 'trifle/stats/transponder/standard_deviation'
39
- require 'trifle/stats/transponder/subtract'
40
- require 'trifle/stats/transponder/sum'
31
+ require 'trifle/stats/transponder/expression_engine'
32
+ require 'trifle/stats/transponder/expression'
41
33
  require 'trifle/stats/version'
42
34
 
43
35
  module Trifle
@@ -107,3 +99,5 @@ module Trifle
107
99
  end
108
100
  end
109
101
  end
102
+
103
+ require 'trifle/stats/driver/api'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: trifle-stats
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.1
4
+ version: 2.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jozef Vaclavik
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-02-25 00:00:00.000000000 Z
11
+ date: 2026-07-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -225,6 +225,7 @@ files:
225
225
  - lib/trifle/stats/designator/geometric.rb
226
226
  - lib/trifle/stats/designator/linear.rb
227
227
  - lib/trifle/stats/driver/README.md
228
+ - lib/trifle/stats/driver/api.rb
228
229
  - lib/trifle/stats/driver/mongo.rb
229
230
  - lib/trifle/stats/driver/mysql.rb
230
231
  - lib/trifle/stats/driver/postgres.rb
@@ -238,23 +239,14 @@ files:
238
239
  - lib/trifle/stats/nocturnal.rb
239
240
  - lib/trifle/stats/nocturnal/key.rb
240
241
  - lib/trifle/stats/nocturnal/parser.rb
241
- - lib/trifle/stats/nocturnal_legacy.rb
242
242
  - lib/trifle/stats/operations/status/beam.rb
243
243
  - lib/trifle/stats/operations/status/scan.rb
244
244
  - lib/trifle/stats/operations/timeseries/increment.rb
245
245
  - lib/trifle/stats/operations/timeseries/set.rb
246
246
  - lib/trifle/stats/operations/timeseries/values.rb
247
247
  - lib/trifle/stats/series.rb
248
- - lib/trifle/stats/transponder/add.rb
249
- - lib/trifle/stats/transponder/divide.rb
250
- - lib/trifle/stats/transponder/max.rb
251
- - lib/trifle/stats/transponder/mean.rb
252
- - lib/trifle/stats/transponder/min.rb
253
- - lib/trifle/stats/transponder/multiply.rb
254
- - lib/trifle/stats/transponder/ratio.rb
255
- - lib/trifle/stats/transponder/standard_deviation.rb
256
- - lib/trifle/stats/transponder/subtract.rb
257
- - lib/trifle/stats/transponder/sum.rb
248
+ - lib/trifle/stats/transponder/expression.rb
249
+ - lib/trifle/stats/transponder/expression_engine.rb
258
250
  - lib/trifle/stats/version.rb
259
251
  - trifle-stats.gemspec
260
252
  homepage: https://trifle.io
@@ -1,161 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Trifle
4
- module Stats
5
- class Nocturnal # rubocop:disable Metrics/ClassLength
6
- UNIT_MAP = {
7
- 's' => :second,
8
- 'm' => :minute,
9
- 'h' => :hour,
10
- 'd' => :day,
11
- 'w' => :week,
12
- 'mo' => :month,
13
- 'q' => :quarter,
14
- 'y' => :year
15
- }.freeze
16
-
17
- DAYS_INTO_WEEK = {
18
- sunday: 0, monday: 1, tuesday: 2, wednesday: 3,
19
- thursday: 4, friday: 5, saturday: 6
20
- }.freeze
21
-
22
- def self.timeline(from:, to:, granularity:, config: nil)
23
- list = []
24
- from = new(from, config: config).send(granularity)
25
- to = new(to, config: config).send(granularity)
26
- item = from.dup
27
- while item <= to
28
- list << item
29
- item = Nocturnal.new(item, config: config).send("next_#{granularity}")
30
- end
31
- list
32
- end
33
-
34
- def initialize(at, config: nil)
35
- @at = at
36
- @config = config
37
- end
38
-
39
- def config
40
- @config || Trifle::Stats.default
41
- end
42
-
43
- def change(**fractions)
44
- Time.new(
45
- fractions.fetch(:year, @at.year),
46
- fractions.fetch(:month, @at.month),
47
- fractions.fetch(:day, @at.day),
48
- fractions.fetch(:hour, @at.hour),
49
- fractions.fetch(:minute, @at.min),
50
- fractions.fetch(:second, 0),
51
- config.tz.utc_offset
52
- )
53
- end
54
-
55
- def second
56
- change(second: @at.sec)
57
- end
58
-
59
- def next_second
60
- Nocturnal.new(
61
- second + 1,
62
- config: config
63
- ).second
64
- end
65
-
66
- def minute
67
- change(second: 0)
68
- end
69
-
70
- def next_minute
71
- Nocturnal.new(
72
- minute + 60,
73
- config: config
74
- ).minute
75
- end
76
-
77
- def hour
78
- change(minute: 0)
79
- end
80
-
81
- def next_hour
82
- Nocturnal.new(
83
- hour + 60 * 60,
84
- config: config
85
- ).hour
86
- end
87
-
88
- def day
89
- change(hour: 0, minute: 0)
90
- end
91
-
92
- def next_day
93
- Nocturnal.new(
94
- day + 60 * 60 * 24,
95
- config: config
96
- ).day
97
- end
98
-
99
- def week
100
- today = day
101
-
102
- (today.to_date - days_to_week_start).to_time
103
- end
104
-
105
- def next_week
106
- Nocturnal.new(
107
- week + 60 * 60 * 24 * 7,
108
- config: config
109
- ).week
110
- end
111
-
112
- def days_to_week_start
113
- start_day_number = DAYS_INTO_WEEK.fetch(
114
- config.beginning_of_week
115
- )
116
-
117
- (@at.wday - start_day_number) % 7
118
- end
119
-
120
- def month
121
- change(day: 1, hour: 0, minute: 0)
122
- end
123
-
124
- def next_month
125
- Nocturnal.new(
126
- month + 60 * 60 * 24 * 31,
127
- config: config
128
- ).month
129
- end
130
-
131
- def quarter
132
- first_quarter_month = @at.month - (2 + @at.month) % 3
133
-
134
- change(
135
- month: first_quarter_month,
136
- day: 1,
137
- hour: 0,
138
- minute: 0
139
- )
140
- end
141
-
142
- def next_quarter
143
- Nocturnal.new(
144
- quarter + 60 * 60 * 24 * 31 * 3,
145
- config: config
146
- ).quarter
147
- end
148
-
149
- def year
150
- change(month: 1, day: 1, hour: 0, minute: 0)
151
- end
152
-
153
- def next_year
154
- Nocturnal.new(
155
- year + 60 * 60 * 24 * 31 * 12,
156
- config: config
157
- ).year
158
- end
159
- end
160
- end
161
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Trifle
4
- module Stats
5
- class Transponder
6
- class Add
7
- include Trifle::Stats::Mixins::Packer
8
- Trifle::Stats::Series.register_transponder(:add, self)
9
-
10
- def transpond(series:, left:, right:, response: 'add') # rubocop:disable Metrics/MethodLength
11
- left_keys = left.to_s.split('.')
12
- right_keys = right.to_s.split('.')
13
-
14
- series[:values] = series[:values].map do |data|
15
- dleft = data.dig(*left_keys)
16
- dright = data.dig(*right_keys)
17
- next data unless dleft && dright
18
-
19
- dres = dleft + dright
20
- signal = {
21
- response => dres
22
- }
23
- self.class.deep_merge(data, self.class.unpack(hash: signal))
24
- end
25
- series
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Trifle
4
- module Stats
5
- class Transponder
6
- class Divide
7
- include Trifle::Stats::Mixins::Packer
8
- Trifle::Stats::Series.register_transponder(:divide, self)
9
-
10
- def transpond(series:, left:, right:, response: 'divide') # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
11
- left_keys = left.to_s.split('.')
12
- right_keys = right.to_s.split('.')
13
-
14
- series[:values] = series[:values].map do |data|
15
- dleft = data.dig(*left_keys)
16
- dright = data.dig(*right_keys)
17
- next data unless dleft && dright
18
-
19
- dres = (dleft / dright)
20
- signal = {
21
- response => dres.nan? ? BigDecimal(0) : dres
22
- }
23
- self.class.deep_merge(data, self.class.unpack(hash: signal))
24
- end
25
- series
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Trifle
4
- module Stats
5
- class Transponder
6
- class Max
7
- include Trifle::Stats::Mixins::Packer
8
- Trifle::Stats::Series.register_transponder(:max, self)
9
-
10
- def transpond(series:, paths:, response: 'max') # rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
11
- path_keys = paths.map { |path| path.to_s.split('.') }
12
-
13
- series[:values] = series[:values].map do |data|
14
- dvalues = path_keys.map { |path_key| data.dig(*path_key) }
15
- next data if dvalues.any?(&:nil?)
16
-
17
- numeric_values = dvalues.select { |v| v.is_a?(Numeric) }
18
- next data if numeric_values.empty?
19
-
20
- dres = numeric_values.max
21
- signal = {
22
- response => dres
23
- }
24
- self.class.deep_merge(data, self.class.unpack(hash: signal))
25
- end
26
- series
27
- end
28
- end
29
- end
30
- end
31
- end