trifle-stats 2.4.1 → 2.5.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,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trifle
4
+ module Stats
5
+ class Transponder
6
+ class Expression
7
+ include Trifle::Stats::Mixins::Packer
8
+ Trifle::Stats::Series.register_transponder(:expression, self)
9
+
10
+ def transpond(series:, paths:, expression:, response:)
11
+ normalized_paths, normalized_response = normalized_config(paths, response)
12
+ ast = ExpressionEngine.parse(expression: expression, paths: normalized_paths)
13
+ series[:values] = transformed_values(series[:values], normalized_paths, normalized_response, ast)
14
+ series
15
+ end
16
+
17
+ def validate(paths:, expression:, response:)
18
+ normalized_paths, = normalized_config(paths, response)
19
+ ExpressionEngine.validate(paths: normalized_paths, expression: expression)
20
+ end
21
+
22
+ private
23
+
24
+ def normalized_config(paths, response)
25
+ normalized_paths = normalize_paths(paths)
26
+ normalized_response = normalize_response(response)
27
+
28
+ ensure_no_wildcards!(normalized_paths, normalized_response)
29
+ [normalized_paths, normalized_response]
30
+ end
31
+
32
+ def normalize_paths(paths)
33
+ raise ArgumentError, 'Paths must be an array.' unless paths.is_a?(Array)
34
+
35
+ cleaned = paths.map { |path| path.to_s.strip }.reject(&:empty?)
36
+ raise ArgumentError, 'At least one path is required.' if cleaned.empty?
37
+
38
+ cleaned
39
+ end
40
+
41
+ def normalize_response(response)
42
+ normalized_response = response.to_s.strip
43
+ ensure_response!(normalized_response)
44
+ normalized_response
45
+ end
46
+
47
+ def ensure_response!(response)
48
+ raise ArgumentError, 'Response path is required.' if response.empty?
49
+ end
50
+
51
+ def ensure_no_wildcards!(paths, response)
52
+ wildcard = paths.any? { |path| path.include?('*') } || response.include?('*')
53
+ raise ArgumentError, 'Wildcard paths are not supported yet.' if wildcard
54
+ end
55
+
56
+ def build_env(data, paths)
57
+ ExpressionEngine.allowed_vars(paths.length).zip(paths).to_h do |var, path|
58
+ [var, data.dig(*path.split('.'))]
59
+ end
60
+ end
61
+
62
+ def transformed_values(values, paths, response, ast)
63
+ values.map { |data| transform_row(data, paths, response, ast) }
64
+ end
65
+
66
+ def transform_row(data, paths, response, ast)
67
+ env = build_env(data, paths)
68
+ value = ExpressionEngine.evaluate(ast: ast, env: env)
69
+ put_response(data, response, value)
70
+ end
71
+
72
+ def put_response(data, response, value)
73
+ keys = response.split('.')
74
+ raise ArgumentError, "Cannot write to response path #{response}." unless can_create_path?(data, keys)
75
+
76
+ updated = deep_dup(data)
77
+ target = updated
78
+
79
+ keys[0..-2].each do |key|
80
+ target[key] ||= {}
81
+ target = target[key]
82
+ end
83
+
84
+ target[keys[-1]] = value
85
+ updated
86
+ end
87
+
88
+ def can_create_path?(data, keys)
89
+ return true if keys.length <= 1
90
+
91
+ current = data
92
+
93
+ keys[0..-2].each do |key|
94
+ value = current[key]
95
+ return true if value.nil?
96
+ return false unless value.is_a?(Hash)
97
+
98
+ current = value
99
+ end
100
+
101
+ true
102
+ end
103
+
104
+ def deep_dup(value)
105
+ case value
106
+ when Hash
107
+ value.transform_values { |inner| deep_dup(inner) }
108
+ when Array
109
+ value.map { |inner| deep_dup(inner) }
110
+ else
111
+ value
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -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.5.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
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.5.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-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -238,23 +238,14 @@ files:
238
238
  - lib/trifle/stats/nocturnal.rb
239
239
  - lib/trifle/stats/nocturnal/key.rb
240
240
  - lib/trifle/stats/nocturnal/parser.rb
241
- - lib/trifle/stats/nocturnal_legacy.rb
242
241
  - lib/trifle/stats/operations/status/beam.rb
243
242
  - lib/trifle/stats/operations/status/scan.rb
244
243
  - lib/trifle/stats/operations/timeseries/increment.rb
245
244
  - lib/trifle/stats/operations/timeseries/set.rb
246
245
  - lib/trifle/stats/operations/timeseries/values.rb
247
246
  - 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
247
+ - lib/trifle/stats/transponder/expression.rb
248
+ - lib/trifle/stats/transponder/expression_engine.rb
258
249
  - lib/trifle/stats/version.rb
259
250
  - trifle-stats.gemspec
260
251
  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