robot_lab-ractor 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.
@@ -0,0 +1,258 @@
1
+ # Ractor Integration Design
2
+
3
+ **Date:** 2026-04-14
4
+ **Status:** Approved
5
+ **Gems:** `ractor_queue`, `ractor-wrapper`
6
+
7
+ ## Goals
8
+
9
+ 1. True CPU parallelism (GIL-bypassing) for CPU-bound tool execution
10
+ 2. True CPU parallelism for parallel robot execution in Networks
11
+ 3. Use `ractor_queue` as the queue backbone for both tracks
12
+ 4. Use `ractor-wrapper` to expose shared `Memory` to Ractor workers
13
+ 5. Deliver both tracks as independent, composable layers
14
+
15
+ ## Non-Goals
16
+
17
+ - Making `ruby_llm` or the `async` gem Ractor-safe
18
+ - Replacing the existing `:async` concurrency model (it remains the default)
19
+ - Ractor-isolating `Robot` instances that are long-lived across multiple tasks
20
+
21
+ ---
22
+
23
+ ## Architecture Overview
24
+
25
+ Two parallel tracks share a frozen-message convention and `ractor_queue` as the communication backbone.
26
+
27
+ ```
28
+ ┌─────────────────────────────────────────────────────────────────┐
29
+ │ Thread/Fiber World │
30
+ │ Robot (ruby_llm, async) ──▶ Tool.call() ──▶ RobotResult │
31
+ │ │ │ │
32
+ │ BusPoller ractor_safe? │
33
+ │ (ractor_queue) │ │ │
34
+ └────────────────────────────────│────────│────────────────────────┘
35
+ │ yes │ no
36
+ ┌───────────────────┘ └──► Thread executor
37
+
38
+ ┌─────────────────────────────────────────────────────────────────┐
39
+ │ Ractor World │
40
+ │ RactorWorkerPool ◀──ractor_queue── frozen RactorJob │
41
+ │ (N Ractor workers) │
42
+ │ │ │
43
+ │ RactorMemoryProxy (ractor-wrapper around Memory) │
44
+ │ ◀── get/set via Ractor messages ──▶ │
45
+ └─────────────────────────────────────────────────────────────────┘
46
+ ```
47
+
48
+ **Key constraint:** only frozen, `Ractor.shareable?` objects cross Ractor boundaries. A `RactorJob` is a `Data.define` struct (shareable by design) carrying a frozen payload and a per-job reply `ractor_queue`.
49
+
50
+ ---
51
+
52
+ ## Shared Infrastructure
53
+
54
+ ### `RactorJob`
55
+
56
+ ```ruby
57
+ RactorJob = Data.define(:id, :type, :payload, :reply_queue)
58
+ ```
59
+
60
+ Single cross-boundary carrier for both tracks. `payload` must be frozen by the caller before submission. `reply_queue` is a `ractor_queue` instance (Ractor-safe).
61
+
62
+ ### `RactorJobError`
63
+
64
+ ```ruby
65
+ RactorJobError = Data.define(:message, :backtrace)
66
+ ```
67
+
68
+ Frozen error representation for exceptions that occur inside a Ractor worker. Serialized at the Ractor boundary, re-raised on the thread side.
69
+
70
+ ### `RobotSpec`
71
+
72
+ ```ruby
73
+ RobotSpec = Data.define(:name, :template, :system_prompt, :config_hash)
74
+ ```
75
+
76
+ Carries everything needed to reconstruct a `Robot` inside a Ractor. All fields must be frozen strings/hashes.
77
+
78
+ ### `RactorBoundary`
79
+
80
+ A utility module with a `freeze_deep(obj)` method that recursively freezes nested `Hash`/`Array` structures before they cross a Ractor boundary. Similar in spirit to the existing `deep_dup` in `Utils`. Raises `RobotLab::RactorBoundaryError` (a subclass of `RobotLab::Error`) if a value cannot be made shareable (e.g., a live IO or Proc).
81
+
82
+ ```ruby
83
+ module RactorBoundary
84
+ def self.freeze_deep(obj)
85
+ case obj
86
+ when Hash then obj.transform_values { freeze_deep(_1) }.freeze
87
+ when Array then obj.map { freeze_deep(_1) }.freeze
88
+ else obj.frozen? ? obj : obj.dup.freeze
89
+ end
90
+ rescue TypeError => e
91
+ raise RobotLab::RactorBoundaryError, "Cannot make value Ractor-shareable: #{e.message}"
92
+ end
93
+ end
94
+ ```
95
+
96
+ ---
97
+
98
+ ## Track 1: RactorWorkerPool (Tool CPU Parallelism)
99
+
100
+ ### Tool opt-in
101
+
102
+ `RobotLab::Tool` gets a `ractor_safe` class macro (default `false`). Ractor-safe tools must be stateless — no captured mutable closures, no non-shareable constants.
103
+
104
+ ```ruby
105
+ class EmbeddingTool < RobotLab::Tool
106
+ ractor_safe true
107
+
108
+ def execute(text:)
109
+ # CPU-bound embedding work — runs inside a Ractor worker
110
+ end
111
+ end
112
+ ```
113
+
114
+ The framework raises `RobotLab::ConfigurationError` at class-definition time if a declared-safe tool captures unshareable state (detected via `Ractor.shareable?` check on the class object).
115
+
116
+ ### `RactorWorkerPool`
117
+
118
+ A pool of N Ractor workers (configurable via `RunConfig#ractor_pool_size`, default `Etc.nprocessors`). Each worker runs:
119
+
120
+ ```ruby
121
+ loop do
122
+ job = work_queue.pop # blocks on ractor_queue
123
+ result = dispatch(job) # instantiates tool class, calls execute
124
+ job.reply_queue.push(result) # frozen result back to caller
125
+ rescue => e
126
+ job.reply_queue.push(RactorJobError.new(message: e.message, backtrace: e.backtrace))
127
+ end
128
+ ```
129
+
130
+ The pool is lazily initialized on first use and shared across robots in a Network via the existing `RunConfig` hierarchy. It lives for the lifetime of the process (or the `RunConfig` that owns it). `RactorWorkerPool#shutdown` drains in-flight jobs, then closes the work `ractor_queue` so all workers exit their loops cleanly. `RunConfig` calls `shutdown` on `ObjectSpace` finalizer or explicit `RobotLab.shutdown` call.
131
+
132
+ If a worker Ractor crashes (unhandled exception kills the Ractor), the pool detects the dead Ractor via `Ractor#take` and spawns a replacement. The failed job's reply queue receives a `RactorJobError`.
133
+
134
+ ### Submission path (inside `Robot#call_tool`)
135
+
136
+ 1. Look up `tool_class` from `ToolManifest`
137
+ 2. Check `tool_class.ractor_safe?`
138
+ 3. **If yes:** `RactorBoundary.freeze_deep(args)`, build `RactorJob`, push to pool's work `ractor_queue`, block on reply queue
139
+ 4. **If no:** run in current thread/fiber as today
140
+ 5. On reply: if result is `RactorJobError`, re-raise as `RobotLab::ToolError` in the calling thread
141
+
142
+ ### `RunConfig` additions
143
+
144
+ ```ruby
145
+ ractor_pool_size: :auto # :auto = Etc.nprocessors, or an Integer
146
+ ```
147
+
148
+ ---
149
+
150
+ ## Track 2: RactorMemoryProxy + RactorNetworkScheduler (Robot Parallelism)
151
+
152
+ ### `RactorMemoryProxy`
153
+
154
+ Wraps the existing `Memory` instance via `ractor-wrapper`. The wrapper Ractor acts as a method-dispatch server: it receives frozen messages and replies with frozen results.
155
+
156
+ Supported operations proxied across the Ractor boundary:
157
+
158
+ | Message | Reply |
159
+ |---------|-------|
160
+ | `[:get, key]` | frozen value or `nil` |
161
+ | `[:set, key, frozen_value]` | `:ok` |
162
+ | `[:keys]` | frozen array of keys |
163
+
164
+ Subscriptions (callbacks) are **not** proxied — closures are not Ractor-safe. Robots that need reactive subscriptions use the thread-side `Memory` directly. `RactorMemoryProxy` is for Ractor workers that need read/write access to shared state.
165
+
166
+ No changes to `Memory` itself.
167
+
168
+ ### `RactorNetworkScheduler`
169
+
170
+ Replaces `SimpleFlow::Pipeline#call_parallel` for Networks with `parallel_mode: :ractor`. Distributes frozen task descriptions to worker Ractors, collects frozen results.
171
+
172
+ `depends_on` ordering is preserved: the scheduler reads the pipeline's existing dependency graph (from `SimpleFlow::Pipeline`) and uses it to determine which tasks are ready to dispatch. A task is submitted to the `ractor_queue` only once all its dependencies have resolved. This mirrors how `call_parallel` works today — the scheduler wraps the same topological resolution logic.
173
+
174
+ ```
175
+ Scheduler ──► ractor_queue (frozen RobotSpec + task payload)
176
+
177
+
178
+ Worker Ractor
179
+ (constructs fresh Robot from RobotSpec,
180
+ runs task, freezes RobotResult,
181
+ pushes to reply ractor_queue)
182
+
183
+ Scheduler ◀── ractor_queue (frozen results)
184
+ ```
185
+
186
+ Each worker Ractor constructs its own `Robot` instance from a `RobotSpec`. The LLM call happens inside the Ractor. This is safe because `ruby_llm` HTTP calls use no shared mutable state between instances — the Ractor constraint is about *shared* non-shareable objects, not fresh instances created inside a Ractor.
187
+
188
+ Results are collected via a reply `ractor_queue` and assembled into the pipeline's `SimpleFlow::Result` context on the thread side.
189
+
190
+ ### `BusPoller` queue upgrade
191
+
192
+ `BusPoller#@robot_queues` changes from `Hash<String, Array>` to `Hash<String, ractor_queue>`. Delivery mechanics (mutex-guarded drain, `process_and_drain`) are unchanged — only the backing store is swapped. This makes `BusPoller` capable of receiving deliveries from Ractor workers.
193
+
194
+ ### Network opt-in
195
+
196
+ ```ruby
197
+ network = RobotLab.create_network(name: "analysis", parallel_mode: :ractor) do
198
+ task :sentiment, sentiment_robot, depends_on: :none
199
+ task :entities, entity_robot, depends_on: :none
200
+ task :summarize, summary_robot, depends_on: [:sentiment, :entities]
201
+ end
202
+ ```
203
+
204
+ `parallel_mode: :async` remains the default and is unchanged.
205
+
206
+ ---
207
+
208
+ ## Error Handling
209
+
210
+ | Scenario | Mechanism |
211
+ |----------|-----------|
212
+ | Tool raises inside Ractor worker | Serialized as `RactorJobError`, re-raised as `RobotLab::ToolError` in calling thread |
213
+ | Robot raises inside `RactorNetworkScheduler` | Serialized as `RactorJobError`, surfaced as failed step in `SimpleFlow::Result` |
214
+ | Worker Ractor crashes (unhandled exception) | Pool detects dead Ractor, spawns replacement, failed job gets `RactorJobError` on reply queue |
215
+ | Non-shareable value submitted to pool | `RobotLab::RactorBoundaryError` raised before the Ractor boundary |
216
+
217
+ ---
218
+
219
+ ## Testing
220
+
221
+ - `RactorWorkerPool` is testable standalone — no Robot or Network required
222
+ - `RactorMemoryProxy` is testable standalone — wrap a `Memory`, call proxy methods from a test Ractor
223
+ - Tools that declare `ractor_safe true` should pass `assert_ractor_safe(tool_class)` — a test helper that spins up a single-worker pool and round-trips a frozen payload
224
+ - `RactorNetworkScheduler` tests use a minimal two-robot network with `parallel_mode: :ractor`
225
+ - All existing tests are unaffected — `:async` remains the default; no existing class is modified in a breaking way
226
+
227
+ ---
228
+
229
+ ## New Files
230
+
231
+ | File | Purpose |
232
+ |------|---------|
233
+ | `lib/robot_lab/ractor_job.rb` | `RactorJob`, `RactorJobError`, `RobotSpec` data classes |
234
+ | `lib/robot_lab/ractor_boundary.rb` | `RactorBoundary.freeze_deep` utility |
235
+ | `lib/robot_lab/ractor_worker_pool.rb` | `RactorWorkerPool` — N Ractor workers fed by `ractor_queue` |
236
+ | `lib/robot_lab/ractor_memory_proxy.rb` | `RactorMemoryProxy` — `ractor-wrapper` around `Memory` |
237
+ | `lib/robot_lab/ractor_network_scheduler.rb` | `RactorNetworkScheduler` — distributes robot tasks to Ractor workers |
238
+
239
+ ## Modified Files
240
+
241
+ | File | Change |
242
+ |------|--------|
243
+ | `lib/robot_lab/tool.rb` | Add `ractor_safe` class macro |
244
+ | `lib/robot_lab/robot.rb` | Check `ractor_safe?` in `call_tool`, submit to pool if true |
245
+ | `lib/robot_lab/run_config.rb` | Add `ractor_pool_size:` field |
246
+ | `lib/robot_lab/bus_poller.rb` | Swap `Array` queues for `ractor_queue` instances |
247
+ | `lib/robot_lab/network.rb` | Add `parallel_mode:` option, delegate to `RactorNetworkScheduler` |
248
+ | `lib/robot_lab/error.rb` | Add `RobotLab::RactorBoundaryError` subclass |
249
+ | `lib/robot_lab.rb` | Require new files |
250
+
251
+ ---
252
+
253
+ ## Dependencies to Add
254
+
255
+ ```ruby
256
+ gem "ractor_queue"
257
+ gem "ractor-wrapper"
258
+ ```
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 29: Ractor-Safe CPU Tools
5
+ #
6
+ # Demonstrates Track 1 of RobotLab's Ractor parallelism: CPU-bound tools
7
+ # that bypass Ruby's GVL by running inside a pool of Ractor workers.
8
+ #
9
+ # Concepts covered:
10
+ # - ractor_safe true — opt a tool class into Ractor execution
11
+ # - Inheritance — subclasses inherit ractor_safe automatically
12
+ # - RactorBoundary.freeze_deep — deep-freeze nested data before
13
+ # crossing the Ractor boundary
14
+ # - RactorBoundaryError — raised for non-shareable values (Procs, IOs…)
15
+ # - RactorWorkerPool — global pool, submit jobs, read frozen results
16
+ # - ToolError — propagated when a tool raises inside a Ractor
17
+ # - Parallel batch — many threads submitting concurrently vs sequentially
18
+ #
19
+ # Usage (no LLM API key required):
20
+ # bundle exec ruby examples/29_ractor_tools.rb
21
+ #
22
+ # Good to know:
23
+ # Ractor dispatch has a fixed overhead of ~20 ms per job (reply-queue
24
+ # Ractor creation + Ractor.make_shareable). Computation must dominate
25
+ # that overhead for parallelism to win. HeavyDigestTool uses 500 000
26
+ # SHA-256 rounds (~320 ms on modern hardware) so the 4-6× speedup is
27
+ # clearly visible on a 6-core machine.
28
+
29
+ require "robot_lab"
30
+ require "robot_lab/ractor"
31
+ require "digest"
32
+
33
+ # Always shut down the pool when the process exits.
34
+ at_exit { RobotLab.shutdown_ractor_pool }
35
+
36
+ # =============================================================================
37
+ # Tool definitions
38
+ # =============================================================================
39
+
40
+ # Base class — declare ractor_safe once; all subclasses inherit it.
41
+ class TextTool < RobotLab::Tool
42
+ ractor_safe true
43
+ end
44
+
45
+ # Word and sentence statistics — pure computation, no shared state.
46
+ class WordStatsTool < TextTool
47
+ description "Count words, sentences, and average word length"
48
+
49
+ param :text, type: :string, desc: "Text to analyze"
50
+
51
+ def execute(text:)
52
+ words = text.scan(/\b\w+\b/)
53
+ sentences = [text.scan(/[.!?]+/).length, 1].max
54
+ avg_len = words.empty? ? 0.0 : (words.sum(&:length).to_f / words.length).round(2)
55
+
56
+ { words: words.length, sentences: sentences, avg_word_len: avg_len }.freeze
57
+ end
58
+ end
59
+
60
+ # Words-per-sentence and long-word density (a simple readability proxy).
61
+ class ReadabilityTool < TextTool
62
+ description "Estimate words-per-sentence and long-word density"
63
+
64
+ param :text, type: :string, desc: "Text to analyze"
65
+
66
+ def execute(text:)
67
+ words = text.scan(/\b\w+\b/)
68
+ sentences = [text.scan(/[.!?]+/).length, 1].max
69
+ long_words = words.count { |w| w.length > 6 }
70
+ long_pct = words.empty? ? 0 : (long_words * 100 / words.length)
71
+
72
+ {
73
+ words: words.length,
74
+ sentences: sentences,
75
+ words_per_sentence: (words.length.to_f / sentences).round(1),
76
+ long_word_pct: long_pct
77
+ }.freeze
78
+ end
79
+ end
80
+
81
+ # CPU-intensive: 500 000 SHA-256 rounds (~320 ms per job on modern hardware).
82
+ # The overhead of reply-queue Ractor creation + make_shareable is ~20 ms per job
83
+ # (constant), so computation must dwarf it to show a real speedup.
84
+ class HeavyDigestTool < TextTool
85
+ description "SHA-256 chain (500 000 rounds) — CPU-intensive, Ractor-safe"
86
+
87
+ ROUNDS = 500_000
88
+
89
+ param :text, type: :string, desc: "Seed text"
90
+
91
+ def execute(text:)
92
+ digest = text
93
+ ROUNDS.times { digest = Digest::SHA256.hexdigest(digest) }
94
+ digest[0..15].freeze
95
+ end
96
+ end
97
+
98
+ # NOT Ractor-safe: @@hits is mutable class-level state.
99
+ # Shown here purely as a contrast — do not submit this to the pool.
100
+ class RequestCounterTool < RobotLab::Tool
101
+ description "Word count plus a mutable global call counter (not Ractor-safe)"
102
+
103
+ @@hits = 0 # mutable class variable — Ractor workers cannot access this
104
+
105
+ param :text, type: :string, desc: "Text to count"
106
+
107
+ def execute(text:)
108
+ @@hits += 1
109
+ "#{text.scan(/\b\w+\b/).length} words (total calls: #{@@hits})"
110
+ end
111
+ end
112
+
113
+ # =============================================================================
114
+ # Demo
115
+ # =============================================================================
116
+
117
+ puts "=" * 62
118
+ puts "Example 29: Ractor-Safe CPU Tools"
119
+ puts "=" * 62
120
+ puts
121
+
122
+ DIVIDER = ("─" * 54).freeze
123
+
124
+ SAMPLE_TEXTS = [
125
+ "Ruby makes programmer happiness a first-class concern in language design.",
126
+ "Ractors enable true CPU parallelism by isolating mutable state between actors.",
127
+ "Every distributed system eventually becomes a consistency problem.",
128
+ "The quick brown fox jumps over the lazy dog — a pangram.",
129
+ "Machine learning transforms raw observations into actionable insight.",
130
+ "Simplicity is the ultimate sophistication in software architecture."
131
+ ].freeze
132
+
133
+ # ── 1. ractor_safe? flags ─────────────────────────────────────
134
+
135
+ puts "1. ractor_safe? flags"
136
+ puts " #{DIVIDER}"
137
+
138
+ {
139
+ "WordStatsTool" => WordStatsTool,
140
+ "ReadabilityTool" => ReadabilityTool,
141
+ "HeavyDigestTool" => HeavyDigestTool,
142
+ "RequestCounterTool" => RequestCounterTool
143
+ }.each do |name, klass|
144
+ mark = klass.ractor_safe ? "✓ ractor_safe (inherits from TextTool)" : "✗ NOT ractor_safe"
145
+ puts " #{name.ljust(22)} #{mark}"
146
+ end
147
+ puts
148
+
149
+ # ── 2. RactorBoundary.freeze_deep ─────────────────────────────
150
+
151
+ puts "2. RactorBoundary.freeze_deep"
152
+ puts " #{DIVIDER}"
153
+
154
+ nested = { tags: ["ruby", "ractor"], meta: { version: 2 } }
155
+ frozen = RobotLab::RactorBoundary.freeze_deep(nested)
156
+
157
+ puts " Input frozen? #{nested.frozen?}"
158
+ puts " Output frozen? #{frozen.frozen?}"
159
+ puts " Inner :tags array frozen? #{frozen[:tags].frozen?}"
160
+ puts " Inner :meta hash frozen? #{frozen[:meta].frozen?}"
161
+ puts
162
+
163
+ # A Proc cannot cross a Ractor boundary — freeze_deep raises immediately.
164
+ require "stringio"
165
+ begin
166
+ RobotLab::RactorBoundary.freeze_deep(StringIO.new("I cannot be frozen"))
167
+ rescue RobotLab::RactorBoundaryError => e
168
+ puts " RactorBoundaryError on StringIO:"
169
+ puts " #{e.message.split('.').first}."
170
+ end
171
+ puts
172
+
173
+ # ── 3. Single pool submissions ─────────────────────────────────
174
+
175
+ pool = RobotLab.ractor_pool
176
+ puts "3. Worker pool (#{pool.size} Ractors — one per CPU core)"
177
+ puts " #{DIVIDER}"
178
+
179
+ sample = SAMPLE_TEXTS.first
180
+
181
+ r = pool.submit("WordStatsTool", { text: sample })
182
+ puts " WordStatsTool: #{r.inspect}"
183
+
184
+ r = pool.submit("ReadabilityTool", { text: sample })
185
+ puts " ReadabilityTool: words_per_sentence=#{r[:words_per_sentence]} long_word_pct=#{r[:long_word_pct]}%"
186
+ puts
187
+
188
+ # ── 4. ToolError propagation ───────────────────────────────────
189
+
190
+ puts "4. ToolError propagation"
191
+ puts " #{DIVIDER}"
192
+ puts " Submitting nil as :text (WordStatsTool will call nil.scan — NoMethodError)"
193
+ puts
194
+
195
+ begin
196
+ pool.submit("WordStatsTool", { text: nil })
197
+ rescue RobotLab::ToolError => e
198
+ puts " RobotLab::ToolError caught:"
199
+ puts " #{e.message}"
200
+ end
201
+ puts
202
+
203
+ # ── 5. Parallel batch vs sequential ───────────────────────────
204
+
205
+ puts "5. Parallel batch — #{SAMPLE_TEXTS.length} jobs, each doing #{HeavyDigestTool::ROUNDS.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1_').reverse} SHA-256 rounds"
206
+ puts " #{DIVIDER}"
207
+
208
+ # Parallel: one Thread per job, all submitted simultaneously.
209
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
210
+ threads = SAMPLE_TEXTS.map { |text| Thread.new { pool.submit("HeavyDigestTool", { text: text }) } }
211
+ parallel_results = threads.map(&:value)
212
+ parallel_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
213
+
214
+ # Sequential: jobs submitted one-at-a-time from the main thread.
215
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
216
+ SAMPLE_TEXTS.each { |text| pool.submit("HeavyDigestTool", { text: text }) }
217
+ seq_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
218
+
219
+ puts " Parallel (#{SAMPLE_TEXTS.length} threads → #{pool.size} Ractors): #{"%.3f" % parallel_time}s"
220
+ puts " Sequential (1 thread → #{pool.size} Ractors): #{"%.3f" % seq_time}s"
221
+
222
+ if seq_time > parallel_time * 1.2
223
+ puts " Speedup: #{(seq_time / parallel_time).round(1)}×"
224
+ else
225
+ puts " Note: overhead (~20 ms/job for reply-queue Ractor + make_shareable)"
226
+ puts " dominated this run. Increase ROUNDS further to widen the gap."
227
+ end
228
+
229
+ puts
230
+ puts " First result (truncated digest): #{parallel_results.first}"
231
+ puts
232
+
233
+ # ── 6. Shutdown ────────────────────────────────────────────────
234
+
235
+ puts "6. Shutdown"
236
+ puts " #{DIVIDER}"
237
+ RobotLab.shutdown_ractor_pool
238
+ puts " Pool shut down cleanly (poison-pill × #{pool.size} workers)."
239
+ puts
240
+ puts "=" * 62
241
+ puts "Example 29 complete."
242
+ puts "=" * 62