mathpix 0.1.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.
Files changed (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +52 -0
  3. data/LICENSE +21 -0
  4. data/README.md +171 -0
  5. data/SECURITY.md +137 -0
  6. data/lib/mathpix/balanced_ternary.rb +86 -0
  7. data/lib/mathpix/batch.rb +155 -0
  8. data/lib/mathpix/capture_builder.rb +142 -0
  9. data/lib/mathpix/chemistry.rb +69 -0
  10. data/lib/mathpix/client.rb +439 -0
  11. data/lib/mathpix/configuration.rb +187 -0
  12. data/lib/mathpix/configuration.rb.backup +125 -0
  13. data/lib/mathpix/conversion.rb +257 -0
  14. data/lib/mathpix/document.rb +320 -0
  15. data/lib/mathpix/errors.rb +78 -0
  16. data/lib/mathpix/mcp/auth/oauth_provider.rb +346 -0
  17. data/lib/mathpix/mcp/auth/token_manager.rb +31 -0
  18. data/lib/mathpix/mcp/auth.rb +18 -0
  19. data/lib/mathpix/mcp/base_tool.rb +117 -0
  20. data/lib/mathpix/mcp/elicitations/ambiguity_elicitation.rb +162 -0
  21. data/lib/mathpix/mcp/elicitations/base_elicitation.rb +141 -0
  22. data/lib/mathpix/mcp/elicitations/confidence_elicitation.rb +162 -0
  23. data/lib/mathpix/mcp/elicitations.rb +78 -0
  24. data/lib/mathpix/mcp/middleware/cors_middleware.rb +94 -0
  25. data/lib/mathpix/mcp/middleware/oauth_middleware.rb +72 -0
  26. data/lib/mathpix/mcp/middleware/rate_limiting_middleware.rb +140 -0
  27. data/lib/mathpix/mcp/middleware.rb +13 -0
  28. data/lib/mathpix/mcp/resources/formats_list_resource.rb +113 -0
  29. data/lib/mathpix/mcp/resources/hierarchical_router.rb +237 -0
  30. data/lib/mathpix/mcp/resources/latest_snip_resource.rb +60 -0
  31. data/lib/mathpix/mcp/resources/recent_snips_resource.rb +75 -0
  32. data/lib/mathpix/mcp/resources/snip_stats_resource.rb +78 -0
  33. data/lib/mathpix/mcp/resources.rb +15 -0
  34. data/lib/mathpix/mcp/server.rb +174 -0
  35. data/lib/mathpix/mcp/tools/batch_convert_tool.rb +106 -0
  36. data/lib/mathpix/mcp/tools/check_document_status_tool.rb +66 -0
  37. data/lib/mathpix/mcp/tools/convert_document_tool.rb +90 -0
  38. data/lib/mathpix/mcp/tools/convert_image_tool.rb +91 -0
  39. data/lib/mathpix/mcp/tools/convert_strokes_tool.rb +82 -0
  40. data/lib/mathpix/mcp/tools/get_account_info_tool.rb +57 -0
  41. data/lib/mathpix/mcp/tools/get_usage_tool.rb +62 -0
  42. data/lib/mathpix/mcp/tools/list_formats_tool.rb +81 -0
  43. data/lib/mathpix/mcp/tools/search_results_tool.rb +111 -0
  44. data/lib/mathpix/mcp/transports/http_streaming_transport.rb +622 -0
  45. data/lib/mathpix/mcp/transports/sse_stream_handler.rb +236 -0
  46. data/lib/mathpix/mcp/transports.rb +12 -0
  47. data/lib/mathpix/mcp.rb +52 -0
  48. data/lib/mathpix/result.rb +364 -0
  49. data/lib/mathpix/version.rb +22 -0
  50. data/lib/mathpix.rb +229 -0
  51. metadata +283 -0
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'json'
5
+ require 'thread' # For Queue
6
+
7
+ module Mathpix
8
+ module MCP
9
+ module Transports
10
+ # Handles Server-Sent Events (SSE) streaming for MCP protocol
11
+ class SSEStreamHandler
12
+ attr_reader :mcp_server, :session_id
13
+ attr_accessor :stop_flag
14
+
15
+ HEARTBEAT_INTERVAL = 10 # seconds
16
+ TEST_HEARTBEAT_INTERVAL = 0.1 # 100ms for tests (fast disconnect detection)
17
+ TIMEOUT = 300 # 5 minutes
18
+ TEST_TIMEOUT = 30 # 30 seconds for tests
19
+ EVENT_LOOP_CHECK_INTERVAL = 0.05 # 50ms for production
20
+ TEST_EVENT_LOOP_CHECK_INTERVAL = 0.005 # 5ms for tests (fast stop flag response)
21
+
22
+ def initialize(mcp_server, env, test_mode: false, transport: nil)
23
+ @mcp_server = mcp_server
24
+ @transport = transport # Transport for connection tracking
25
+ @session_id = extract_session_id(env)
26
+ @event_id = 0
27
+ @last_activity = Time.now
28
+ @heartbeat_thread = nil
29
+ @test_mode = test_mode
30
+ @stop_flag = false
31
+ @event_queue = Queue.new # Thread-safe event queue
32
+ end
33
+
34
+ def handle
35
+ headers = {
36
+ 'Content-Type' => 'text/event-stream',
37
+ 'Cache-Control' => 'no-cache',
38
+ 'Connection' => 'keep-alive',
39
+ 'X-Accel-Buffering' => 'no' # Disable nginx buffering
40
+ }
41
+
42
+ stream = proc do |out|
43
+ @stream = out # Store stream for external access
44
+ @closed = false
45
+ register_connection
46
+
47
+ begin
48
+ # Send connection event
49
+ send_connection_event(out)
50
+
51
+ # Start heartbeat
52
+ start_heartbeat(out)
53
+
54
+ # Event loop: process queued events until timeout or close
55
+ timeout = @test_mode ? TEST_TIMEOUT : TIMEOUT
56
+ check_interval = @test_mode ? TEST_EVENT_LOOP_CHECK_INTERVAL : EVENT_LOOP_CHECK_INTERVAL
57
+ start_time = Time.now
58
+ $stderr.puts "[SSE] Event loop started (timeout: #{timeout}s, check_interval: #{check_interval * 1000}ms)"
59
+
60
+ loop do
61
+ # Check for timeout
62
+ if Time.now - start_time > timeout
63
+ $stderr.puts "[SSE] Timeout reached, sending timeout event"
64
+ send_event(out, 'timeout', {
65
+ message: 'Connection timed out due to inactivity'
66
+ })
67
+ break
68
+ end
69
+
70
+ # Check for stop signal
71
+ if @stop_flag
72
+ $stderr.puts "[SSE] Stop flag set, exiting loop"
73
+ break
74
+ end
75
+
76
+ # Process queued events (non-blocking with short timeout)
77
+ begin
78
+ event = @event_queue.pop(true) # non_block = true
79
+ $stderr.puts "[SSE] Sending queued event: #{event[:type]}"
80
+ send_event(out, event[:type], event[:data])
81
+ rescue ThreadError
82
+ # Queue empty - that's ok, sleep briefly and check again
83
+ sleep check_interval
84
+ end
85
+ end
86
+ $stderr.puts "[SSE] Event loop exited"
87
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
88
+ # Connection closed by client - exit cleanly
89
+ @closed = true
90
+ rescue StandardError => e
91
+ # Unexpected error - log and exit
92
+ @closed = true
93
+ ensure
94
+ stop_heartbeat
95
+ unregister_connection
96
+ @stream = nil
97
+ @closed = true
98
+ end
99
+ end
100
+
101
+ [200, headers, stream]
102
+ end
103
+
104
+ # Public methods for sending events (used by transport layer)
105
+ # Events are queued and processed by the event loop
106
+ def send_tool_result_event(request_id, tool_response)
107
+ return if @closed
108
+
109
+ content = tool_response.content.first
110
+ result = JSON.parse(content[:text])
111
+
112
+ @event_queue << {
113
+ type: 'tool_result',
114
+ data: {
115
+ request_id: request_id,
116
+ success: !tool_response.is_error,
117
+ result: result
118
+ }
119
+ }
120
+ end
121
+
122
+ def send_tool_error_event(request_id, error)
123
+ return if @closed
124
+
125
+ @event_queue << {
126
+ type: 'tool_error',
127
+ data: {
128
+ request_id: request_id,
129
+ error: 'tool_execution_error',
130
+ error_description: error.message
131
+ }
132
+ }
133
+ end
134
+
135
+ def send_custom_event(event_type, data)
136
+ return if @closed
137
+
138
+ $stderr.puts "[SSE] Queueing event: #{event_type}"
139
+ @event_queue << {
140
+ type: event_type,
141
+ data: data
142
+ }
143
+ $stderr.puts "[SSE] Event queued, queue size: #{@event_queue.size}"
144
+ end
145
+
146
+ def close
147
+ @closed = true
148
+ @stop_flag = true
149
+ @stream&.close rescue nil
150
+ end
151
+
152
+ private
153
+
154
+ def extract_session_id(env)
155
+ env.dig('rack.session', :session_id) || SecureRandom.uuid
156
+ end
157
+
158
+ def send_event(stream, event_type, data)
159
+ return if @closed # Don't send if already closed
160
+
161
+ @event_id += 1
162
+
163
+ stream.write("event: #{event_type}\n")
164
+ stream.write("id: #{@event_id}\n")
165
+ stream.write("data: #{JSON.generate(data)}\n")
166
+ stream.write("\n")
167
+
168
+ @last_activity = Time.now
169
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
170
+ # Connection closed by client
171
+ @closed = true
172
+ nil # Return nil instead of raising
173
+ end
174
+
175
+ def send_connection_event(stream)
176
+ send_event(stream, 'connection', {
177
+ session_id: session_id,
178
+ transport: 'http_sse'
179
+ })
180
+ end
181
+
182
+ def send_heartbeat(stream)
183
+ send_event(stream, 'heartbeat', {
184
+ timestamp: Time.now.iso8601
185
+ })
186
+ end
187
+
188
+
189
+ def start_heartbeat(stream)
190
+ interval = @test_mode ? TEST_HEARTBEAT_INTERVAL : HEARTBEAT_INTERVAL
191
+ @heartbeat_thread = Thread.new do
192
+ loop do
193
+ sleep interval
194
+ send_heartbeat(stream)
195
+ end
196
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET => e
197
+ # Client disconnected - signal main event loop to exit
198
+ $stderr.puts "[HEARTBEAT] Client disconnect detected: #{e.class}"
199
+ @stop_flag = true
200
+ rescue StandardError => e
201
+ # Unexpected error - signal main loop to exit
202
+ $stderr.puts "[HEARTBEAT] Unexpected error: #{e.class}: #{e.message}"
203
+ @stop_flag = true
204
+ end
205
+ end
206
+
207
+ def stop_heartbeat
208
+ @heartbeat_thread&.kill
209
+ @heartbeat_thread = nil
210
+ end
211
+
212
+ def register_connection
213
+ # Connection registered in transport when handler is created
214
+ end
215
+
216
+ def unregister_connection
217
+ # Remove connection from transport's connection tracking
218
+ $stderr.puts "[SSE] Unregistering connection: #{@session_id}, transport present: #{!@transport.nil?}"
219
+ if @transport
220
+ $stderr.puts "[SSE] About to call transport.unregister_connection"
221
+ begin
222
+ @transport.unregister_connection(@session_id)
223
+ $stderr.puts "[SSE] Called transport.unregister_connection"
224
+ rescue => e
225
+ $stderr.puts "[SSE] ERROR calling unregister: #{e.class}: #{e.message}"
226
+ $stderr.puts "[SSE] Backtrace: #{e.backtrace.first(3).join('\n')}"
227
+ end
228
+ else
229
+ $stderr.puts "[SSE] WARNING: No transport to unregister from!"
230
+ end
231
+ $stderr.puts "[SSE] Unregistration complete"
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mathpix
4
+ module MCP
5
+ module Transports
6
+ # Transports will be autoloaded
7
+ end
8
+ end
9
+ end
10
+
11
+ require_relative 'transports/http_streaming_transport'
12
+ require_relative 'transports/sse_stream_handler'
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # MCP (Model Context Protocol) integration for Mathpix
4
+ #
5
+ # This module provides MCP server functionality using the official Ruby MCP SDK.
6
+ # All 9 tools are thin delegates to the core Mathpix::Client.
7
+ #
8
+ # Usage:
9
+ # require 'mathpix/mcp'
10
+ #
11
+ # Mathpix.configure do |config|
12
+ # config.app_id = ENV['MATHPIX_APP_ID']
13
+ # config.app_key = ENV['MATHPIX_APP_KEY']
14
+ # end
15
+ #
16
+ # Mathpix::MCP::Server.run
17
+
18
+ # Load MCP components
19
+ require_relative 'mcp/server'
20
+ require_relative 'mcp/base_tool'
21
+
22
+ # Load transports (HTTP/SSE)
23
+ require_relative 'mcp/transports'
24
+
25
+ # Load middleware (CORS, OAuth)
26
+ require_relative 'mcp/middleware'
27
+
28
+ # Load authentication
29
+ require_relative 'mcp/auth'
30
+
31
+ # Load elicitations (interactive prompts for low-confidence OCR)
32
+ require_relative 'mcp/elicitations'
33
+
34
+ # Load hierarchical resources
35
+ require_relative 'mcp/resources'
36
+
37
+ # Load all 9 tools
38
+ require_relative 'mcp/tools/convert_image_tool'
39
+ require_relative 'mcp/tools/convert_document_tool'
40
+ require_relative 'mcp/tools/convert_strokes_tool'
41
+ require_relative 'mcp/tools/batch_convert_tool'
42
+ require_relative 'mcp/tools/check_document_status_tool'
43
+ require_relative 'mcp/tools/search_results_tool'
44
+ require_relative 'mcp/tools/get_usage_tool'
45
+ require_relative 'mcp/tools/get_account_info_tool'
46
+ require_relative 'mcp/tools/list_formats_tool'
47
+
48
+ # Load all 4 resource handlers
49
+ require_relative 'mcp/resources/latest_snip_resource'
50
+ require_relative 'mcp/resources/recent_snips_resource'
51
+ require_relative 'mcp/resources/snip_stats_resource'
52
+ require_relative 'mcp/resources/formats_list_resource'
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mathpix
4
+ # OCR Result object
5
+ # The geodesic path: rich, intuitive, method-based access
6
+ class Result
7
+ attr_reader :data, :source_path
8
+
9
+ def initialize(data, source_path = nil)
10
+ @data = data
11
+ @source_path = source_path
12
+ end
13
+
14
+ # Get source URL if image was processed from URL (feature parity with mpxpy)
15
+ #
16
+ # @return [String, nil] URL if source was remote, nil otherwise
17
+ def source_url
18
+ return nil unless @source_path.is_a?(String)
19
+ @source_path.start_with?('http://', 'https://') ? @source_path : nil
20
+ end
21
+
22
+ # Text result
23
+ # @return [String, nil]
24
+ def text
25
+ data['text']
26
+ end
27
+
28
+ # LaTeX styled output
29
+ # @return [String, nil]
30
+ def latex
31
+ data['latex_styled'] || data['latex']
32
+ end
33
+
34
+ alias latex_styled latex
35
+
36
+ # Simplified LaTeX
37
+ # @return [String, nil]
38
+ def latex_simplified
39
+ data['latex_simplified']
40
+ end
41
+
42
+ # MathML output
43
+ # @return [String, nil]
44
+ def mathml
45
+ data['mathml']
46
+ end
47
+
48
+ # AsciiMath output
49
+ # @return [String, nil]
50
+ def asciimath
51
+ data['asciimath']
52
+ end
53
+
54
+ # HTML output (may contain SVG)
55
+ # @return [String, nil]
56
+ def html
57
+ data['html']
58
+ end
59
+
60
+ # Confidence score
61
+ # @return [Float] 0.0-1.0
62
+ def confidence
63
+ data['confidence'] || 0.0
64
+ end
65
+
66
+ # Confidence rate (alternative)
67
+ # @return [Float] 0.0-1.0
68
+ def confidence_rate
69
+ data['confidence_rate'] || confidence
70
+ end
71
+
72
+ # Created timestamp
73
+ # @return [Time, nil]
74
+ def created_at
75
+ Time.parse(data['created']) if data['created']
76
+ rescue ArgumentError
77
+ nil
78
+ end
79
+
80
+ # Request ID
81
+ # @return [String, nil]
82
+ def request_id
83
+ data['request_id']
84
+ end
85
+
86
+ # Processing time in milliseconds
87
+ # @return [Integer, nil]
88
+ def processing_time_ms
89
+ data['processing_time_ms']
90
+ end
91
+
92
+ # Is content printed (vs handwritten)?
93
+ # @return [Boolean]
94
+ def printed?
95
+ data['is_printed'] == true
96
+ end
97
+
98
+ # Is content handwritten?
99
+ # @return [Boolean]
100
+ def handwritten?
101
+ data['is_handwritten'] == true
102
+ end
103
+
104
+ # Contains chart?
105
+ # @return [Boolean]
106
+ def chart?
107
+ data['contains_chart'] == true
108
+ end
109
+
110
+ # Contains diagram?
111
+ # @return [Boolean]
112
+ def diagram?
113
+ data['contains_diagram'] == true
114
+ end
115
+
116
+ # Contains table?
117
+ # @return [Boolean]
118
+ def table?
119
+ data['contains_table'] == true
120
+ end
121
+
122
+ # Get metadata
123
+ # @return [Hash]
124
+ def metadata
125
+ data['metadata'] || {}
126
+ end
127
+
128
+ # Get tags
129
+ # @return [Array<String>]
130
+ def tags
131
+ data['tags'] || []
132
+ end
133
+
134
+ # --- Line-by-line data (feature parity with mpxpy) ---
135
+
136
+ # Get line-by-line data with bounding boxes
137
+ #
138
+ # Requires snap(..., include_line_data: true)
139
+ # Feature parity with Python mpxpy's lines_json() method
140
+ #
141
+ # @return [Array<Line>] array of line objects
142
+ def lines
143
+ return [] unless data['line_data']
144
+
145
+ @lines ||= data['line_data'].map { |line_data| Line.new(line_data) }
146
+ end
147
+
148
+ # Get lines as JSON array (raw data)
149
+ # @return [Array<Hash>]
150
+ def lines_json
151
+ data['line_data'] || []
152
+ end
153
+
154
+ # Line data structure for bounding boxes and confidence
155
+ class Line
156
+ attr_reader :data
157
+
158
+ def initialize(data)
159
+ @data = data
160
+ end
161
+
162
+ # Line text content
163
+ # @return [String]
164
+ def text
165
+ data['text'] || ''
166
+ end
167
+
168
+ # Line confidence
169
+ # @return [Float]
170
+ def confidence
171
+ data['confidence'] || 0.0
172
+ end
173
+
174
+ # Bounding box coordinates [x, y, width, height]
175
+ # @return [Array<Integer>, nil]
176
+ def bbox
177
+ data['bbox']
178
+ end
179
+
180
+ alias bounding_box bbox
181
+
182
+ # Is this line handwritten?
183
+ # @return [Boolean]
184
+ def handwritten?
185
+ data['type'] == 'handwriting'
186
+ end
187
+
188
+ # Is this line printed?
189
+ # @return [Boolean]
190
+ def printed?
191
+ data['type'] == 'printed' || data['type'] == 'print'
192
+ end
193
+
194
+ # LaTeX for this line
195
+ # @return [String, nil]
196
+ def latex
197
+ data['latex']
198
+ end
199
+
200
+ # MathML for this line
201
+ # @return [String, nil]
202
+ def mathml
203
+ data['mathml']
204
+ end
205
+
206
+ # Word-level data (if available)
207
+ # @return [Array<Word>]
208
+ def words
209
+ return [] unless data['words']
210
+
211
+ @words ||= data['words'].map { |word_data| Word.new(word_data) }
212
+ end
213
+
214
+ # Convert to hash
215
+ # @return [Hash]
216
+ def to_h
217
+ data
218
+ end
219
+
220
+ # Inspect
221
+ # @return [String]
222
+ def inspect
223
+ "#<Mathpix::Result::Line text=\"#{text&.[](0..30)}\" confidence=#{confidence}>"
224
+ end
225
+ end
226
+
227
+ # Word data structure for fine-grained bounding boxes
228
+ class Word
229
+ attr_reader :data
230
+
231
+ def initialize(data)
232
+ @data = data
233
+ end
234
+
235
+ # Word text
236
+ # @return [String]
237
+ def text
238
+ data['text'] || ''
239
+ end
240
+
241
+ # Word confidence
242
+ # @return [Float]
243
+ def confidence
244
+ data['confidence'] || 0.0
245
+ end
246
+
247
+ # Word bounding box
248
+ # @return [Array<Integer>, nil]
249
+ def bbox
250
+ data['bbox']
251
+ end
252
+
253
+ # Convert to hash
254
+ # @return [Hash]
255
+ def to_h
256
+ data
257
+ end
258
+
259
+ # Inspect
260
+ # @return [String]
261
+ def inspect
262
+ "#<Mathpix::Result::Word text=\"#{text}\" confidence=#{confidence}>"
263
+ end
264
+ end
265
+
266
+ # Convert to hash
267
+ # @return [Hash]
268
+ def to_h
269
+ data
270
+ end
271
+
272
+ # Convert to JSON
273
+ # @return [String]
274
+ def to_json(*args)
275
+ data.to_json(*args)
276
+ end
277
+
278
+ # Inspect
279
+ # @return [String]
280
+ def inspect
281
+ "#<Mathpix::Result confidence=#{confidence} text=#{text&.[](0..50)}>"
282
+ end
283
+
284
+ # --- Chemistry-specific methods ---
285
+
286
+ # SMILES notation (chemistry)
287
+ # @return [String, nil]
288
+ def smiles
289
+ text if chemistry?
290
+ end
291
+
292
+ # InChI notation (chemistry)
293
+ # @return [String, nil]
294
+ def inchi
295
+ data['inchi']
296
+ end
297
+
298
+ # InChI Key (chemistry)
299
+ # @return [String, nil]
300
+ def inchikey
301
+ data['inchikey']
302
+ end
303
+
304
+ # Molecular formula
305
+ # @return [String, nil]
306
+ def molecular_formula
307
+ data['molecular_formula']
308
+ end
309
+
310
+ # Molecular name
311
+ # @return [String, nil]
312
+ def molecular_name
313
+ data['molecular_name']
314
+ end
315
+
316
+ # Molecular weight
317
+ # @return [Float, nil]
318
+ def molecular_weight
319
+ data['molecular_weight']
320
+ end
321
+
322
+ # Has stereochemistry?
323
+ # @return [Boolean]
324
+ def stereochemistry?
325
+ data['has_stereochemistry'] == true || smiles&.include?('@')
326
+ end
327
+
328
+ # Is this a chemistry result?
329
+ # @return [Boolean]
330
+ def chemistry?
331
+ molecular_formula || inchi || smiles&.match?(/^[A-Za-z0-9@\[\]\(\)=#\+\-\\\/\.]+$/)
332
+ end
333
+
334
+ # --- Success/Failure ---
335
+
336
+ # Was capture successful?
337
+ # @return [Boolean]
338
+ def success?
339
+ !text.nil? && confidence >= 0.5
340
+ end
341
+
342
+ # Was capture a failure?
343
+ # @return [Boolean]
344
+ def failure?
345
+ !success?
346
+ end
347
+
348
+ # Execute block if successful
349
+ # @yield [self]
350
+ # @return [self]
351
+ def on_success
352
+ yield self if success?
353
+ self
354
+ end
355
+
356
+ # Execute block if failed
357
+ # @yield [self]
358
+ # @return [self]
359
+ def on_failure
360
+ yield self if failure?
361
+ self
362
+ end
363
+ end
364
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mathpix
4
+ # Gem version following semantic versioning
5
+ VERSION = '0.1.0'
6
+
7
+ # Seed for deterministic behavior
8
+ SEED = 1069
9
+
10
+ # Balanced ternary pattern
11
+ BALANCED_TERNARY = [+1, -1, -1, +1, +1, +1, +1].freeze
12
+
13
+ # Version info
14
+ def self.version_info
15
+ {
16
+ version: VERSION,
17
+ seed: SEED,
18
+ balanced_ternary: BALANCED_TERNARY,
19
+ ruby_version: RUBY_VERSION
20
+ }
21
+ end
22
+ end