robot_lab 0.0.9 → 0.0.11

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +80 -1
  4. data/Rakefile +2 -1
  5. data/docs/api/core/robot.md +182 -0
  6. data/docs/guides/creating-networks.md +21 -0
  7. data/docs/guides/index.md +10 -0
  8. data/docs/guides/knowledge.md +182 -0
  9. data/docs/guides/mcp-integration.md +106 -0
  10. data/docs/guides/memory.md +2 -0
  11. data/docs/guides/observability.md +486 -0
  12. data/docs/guides/ractor-parallelism.md +364 -0
  13. data/docs/superpowers/plans/2026-04-14-ractor-integration.md +1538 -0
  14. data/docs/superpowers/specs/2026-04-14-ractor-integration-design.md +258 -0
  15. data/examples/19_token_tracking.rb +128 -0
  16. data/examples/20_circuit_breaker.rb +153 -0
  17. data/examples/21_learning_loop.rb +164 -0
  18. data/examples/22_context_compression.rb +179 -0
  19. data/examples/23_convergence.rb +137 -0
  20. data/examples/24_structured_delegation.rb +150 -0
  21. data/examples/25_history_search/conversation.jsonl +30 -0
  22. data/examples/25_history_search.rb +136 -0
  23. data/examples/26_document_store/api_versioning_adr.md +52 -0
  24. data/examples/26_document_store/incident_postmortem.md +46 -0
  25. data/examples/26_document_store/postgres_runbook.md +49 -0
  26. data/examples/26_document_store/redis_caching_guide.md +48 -0
  27. data/examples/26_document_store/sidekiq_guide.md +51 -0
  28. data/examples/26_document_store.rb +147 -0
  29. data/examples/27_incident_response/incident_response.rb +244 -0
  30. data/examples/28_mcp_discovery.rb +112 -0
  31. data/examples/29_ractor_tools.rb +243 -0
  32. data/examples/30_ractor_network.rb +256 -0
  33. data/examples/README.md +136 -0
  34. data/examples/prompts/skill_with_mcp_test.md +9 -0
  35. data/examples/prompts/skill_with_robot_name_test.md +5 -0
  36. data/examples/prompts/skill_with_tools_test.md +6 -0
  37. data/lib/robot_lab/bus_poller.rb +149 -0
  38. data/lib/robot_lab/convergence.rb +69 -0
  39. data/lib/robot_lab/delegation_future.rb +93 -0
  40. data/lib/robot_lab/document_store.rb +155 -0
  41. data/lib/robot_lab/error.rb +25 -0
  42. data/lib/robot_lab/history_compressor.rb +205 -0
  43. data/lib/robot_lab/mcp/client.rb +17 -5
  44. data/lib/robot_lab/mcp/connection_poller.rb +187 -0
  45. data/lib/robot_lab/mcp/server.rb +7 -2
  46. data/lib/robot_lab/mcp/server_discovery.rb +110 -0
  47. data/lib/robot_lab/mcp/transports/stdio.rb +6 -0
  48. data/lib/robot_lab/memory.rb +103 -6
  49. data/lib/robot_lab/network.rb +44 -9
  50. data/lib/robot_lab/ractor_boundary.rb +42 -0
  51. data/lib/robot_lab/ractor_job.rb +37 -0
  52. data/lib/robot_lab/ractor_memory_proxy.rb +85 -0
  53. data/lib/robot_lab/ractor_network_scheduler.rb +154 -0
  54. data/lib/robot_lab/ractor_worker_pool.rb +117 -0
  55. data/lib/robot_lab/robot/bus_messaging.rb +43 -65
  56. data/lib/robot_lab/robot/history_search.rb +69 -0
  57. data/lib/robot_lab/robot.rb +228 -11
  58. data/lib/robot_lab/robot_result.rb +24 -5
  59. data/lib/robot_lab/run_config.rb +1 -1
  60. data/lib/robot_lab/text_analysis.rb +103 -0
  61. data/lib/robot_lab/tool.rb +42 -3
  62. data/lib/robot_lab/tool_config.rb +1 -1
  63. data/lib/robot_lab/version.rb +1 -1
  64. data/lib/robot_lab/waiter.rb +49 -29
  65. data/lib/robot_lab.rb +25 -0
  66. data/mkdocs.yml +1 -0
  67. metadata +70 -2
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 27: Production Incident War Room
5
+ #
6
+ # A payment-service outage is in progress. Three SRE scouts investigate
7
+ # different layers in parallel — database, network, and application. Each
8
+ # scout writes findings to shared reactive memory and broadcasts a status
9
+ # update to the war-room coordinator via TypedBus.
10
+ #
11
+ # Phase 5 infrastructure features demonstrated:
12
+ #
13
+ # REACTIVE MEMORY (IO.pipe Waiter — #13)
14
+ # ──────────────────────────────────────────────────────────────
15
+ # db_scout ─┐ memory[:db_finding] = "..." ─┐
16
+ # net_scout ─┼ memory[:net_finding] = "..." ─┼→ IO.pipe signal
17
+ # app_scout ─┘ memory[:app_finding] = "..." ─┘
18
+ # ↓
19
+ # commander.get(:db_finding, :net_finding, :app_finding, wait: 60)
20
+ # wakes via IO.select on the pipe — no busy-wait, Async-safe
21
+ #
22
+ # BUS POLLER (serialized delivery — #14)
23
+ # ──────────────────────────────────────────────────────────────
24
+ # db_scout, net_scout, app_scout each send a bus message to :war_room.
25
+ # If two scouts finish simultaneously, their deliveries queue in BusPoller
26
+ # so war_room processes them one at a time — no re-entrancy, no dropped
27
+ # messages, arrival order preserved.
28
+ #
29
+ # POLLER GROUPS (#15)
30
+ # ──────────────────────────────────────────────────────────────
31
+ # task :db_scout, poller_group: :investigation (fast, no LLM in final stage)
32
+ # task :net_scout, poller_group: :investigation
33
+ # task :app_scout, poller_group: :investigation
34
+ # task :commander, poller_group: :command (expensive synthesis call)
35
+ #
36
+ # Usage:
37
+ # bundle exec ruby examples/27_incident_response/incident_response.rb
38
+
39
+ ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "../prompts")
40
+
41
+ require_relative "../../lib/robot_lab"
42
+ require "fileutils"
43
+
44
+ RubyLLM.configure { |c| c.logger = Logger.new(File::NULL) }
45
+
46
+ OUTPUT_DIR = File.join(__dir__, "output")
47
+ FileUtils.mkdir_p(OUTPUT_DIR)
48
+
49
+ # ── Scout ──────────────────────────────────────────────────────────────────
50
+ #
51
+ # Investigates one subsystem, stores a terse finding in shared memory,
52
+ # and broadcasts a status line to the war-room via bus.
53
+ #
54
+ # NOTE: we bypass extract_run_context's delete() pattern and hold a direct
55
+ # reference to shared memory — the same technique used in example 15 —
56
+ # because parallel pipeline steps would otherwise lose the memory reference
57
+ # after the first step runs.
58
+
59
+ class SREScout < RobotLab::Robot
60
+ attr_writer :shared_memory
61
+
62
+ def initialize(name:, subsystem:, memory_key:, bus: nil)
63
+ super(
64
+ name: name,
65
+ system_prompt: "You are a senior SRE responding to a production outage. " \
66
+ "Diagnose one infrastructure layer in 2 sentences: " \
67
+ "first sentence is root cause, second is customer impact.",
68
+ bus: bus
69
+ )
70
+ @subsystem = subsystem
71
+ @memory_key = memory_key
72
+ end
73
+
74
+ def call(result)
75
+ incident = extract_message(result)
76
+
77
+ finding = run("Investigate the #{@subsystem} layer. #{incident}").reply.strip
78
+
79
+ # Write to reactive memory — wakes the IO.pipe waiter in the commander
80
+ if @shared_memory
81
+ @shared_memory.current_writer = name
82
+ @shared_memory.set(@memory_key, finding)
83
+ end
84
+
85
+ puts " [#{name}] #{finding[0..100]}#{"..." if finding.length > 100}"
86
+
87
+ # Notify war room — BusPoller queues this if war_room is already processing
88
+ send_message(to: :war_room, content: "[#{@subsystem}] #{finding}") if bus
89
+
90
+ result.with_context(name.to_sym, finding).continue(finding)
91
+ end
92
+
93
+ private
94
+
95
+ def extract_message(result)
96
+ case result.value
97
+ when Hash then result.value[:message].to_s
98
+ when RobotLab::RobotResult then result.value.reply.to_s
99
+ else result.value.to_s
100
+ end
101
+ end
102
+ end
103
+
104
+ # ── War Room ───────────────────────────────────────────────────────────────
105
+ #
106
+ # Receives scout status updates via TypedBus.
107
+ # BusPoller serializes delivery: even if all three scouts finish at the
108
+ # same instant their messages are processed one at a time and none is
109
+ # dropped. Delivery order is preserved by the queue.
110
+
111
+ class WarRoom < RobotLab::Robot
112
+ attr_reader :updates
113
+
114
+ def initialize(bus:)
115
+ super(name: "war_room", system_prompt: "SRE war-room coordinator.", bus: bus)
116
+ @updates = []
117
+ @delivery_mutex = Mutex.new # only for reading @updates outside Async
118
+
119
+ on_message do |msg|
120
+ # BusPoller ensures this block is never re-entered for the same robot.
121
+ # Simulate brief processing work so a second delivery would have to queue.
122
+ @updates << msg.content
123
+ puts " [war_room] Update ##{@updates.size} processed: #{msg.content[0..70]}..."
124
+ end
125
+ end
126
+ end
127
+
128
+ # ── Incident Commander ─────────────────────────────────────────────────────
129
+ #
130
+ # Waits until all three scout findings land in shared memory, then calls
131
+ # the LLM once to synthesize an action plan.
132
+ #
133
+ # memory.get(:db_finding, :net_finding, :app_finding, wait: 60)
134
+ # → internally each key uses an IO.pipe-backed Waiter
135
+ # → IO.select wakes the call as soon as all three keys are written
136
+ # → no busy-wait, no mutex spin, works cleanly with Async
137
+
138
+ class IncidentCommander < RobotLab::Robot
139
+ attr_writer :shared_memory
140
+
141
+ def call(result)
142
+ puts " [commander] Blocking on reactive memory — waiting for all scout findings..."
143
+
144
+ findings = @shared_memory.get(:db_finding, :net_finding, :app_finding, wait: 60)
145
+
146
+ if findings.values.include?(:timeout)
147
+ timed_out = findings.select { |_k, v| v == :timeout }.keys
148
+ puts " [commander] WARNING: scouts timed out: #{timed_out.join(", ")}"
149
+ end
150
+
151
+ prompt = <<~PROMPT
152
+ Three SRE scouts have reported their findings for a payment-service outage.
153
+ Issue a concise incident action plan (3–5 bullet points) covering immediate
154
+ mitigation, next investigation steps, and stakeholder communication.
155
+
156
+ Database layer: #{findings[:db_finding] || "no data"}
157
+ Network layer: #{findings[:net_finding] || "no data"}
158
+ Application: #{findings[:app_finding] || "no data"}
159
+ PROMPT
160
+
161
+ report = run(prompt).reply.strip
162
+
163
+ @shared_memory[:incident_report] = report
164
+
165
+ path = File.join(OUTPUT_DIR, "incident_report.md")
166
+ File.write(path, "# Incident Action Plan\n\n#{report}\n")
167
+ puts " [commander] Action plan written to #{path}"
168
+
169
+ result.with_context(:commander, report).continue(report)
170
+ end
171
+ end
172
+
173
+ # ── Wire up ────────────────────────────────────────────────────────────────
174
+
175
+ bus = TypedBus::MessageBus.new
176
+
177
+ db_scout = SREScout.new(name: "db_scout", subsystem: "database", memory_key: :db_finding, bus: bus)
178
+ net_scout = SREScout.new(name: "net_scout", subsystem: "network", memory_key: :net_finding, bus: bus)
179
+ app_scout = SREScout.new(name: "app_scout", subsystem: "application", memory_key: :app_finding, bus: bus)
180
+ war_room = WarRoom.new(bus: bus)
181
+ commander = IncidentCommander.new(name: "commander", system_prompt: "SRE incident commander.")
182
+
183
+ # Build the investigation network
184
+ # poller_group: labels are registered on the network's shared BusPoller.
185
+ network = RobotLab.create_network(name: "incident_response") do
186
+ task :db_scout, db_scout, depends_on: :none, poller_group: :investigation
187
+ task :net_scout, net_scout, depends_on: :none, poller_group: :investigation
188
+ task :app_scout, app_scout, depends_on: :none, poller_group: :investigation
189
+ task :commander, commander, depends_on: [:db_scout, :net_scout, :app_scout], poller_group: :command
190
+ end
191
+
192
+ # Assign direct shared-memory references (avoids the extract_run_context
193
+ # mutation problem with parallel pipeline steps — see example 15).
194
+ shared_memory = network.memory
195
+ [db_scout, net_scout, app_scout, commander].each do |r|
196
+ r.shared_memory = shared_memory
197
+ end
198
+
199
+ # Subscribe to memory changes — fires as each scout writes its finding.
200
+ # The callback runs asynchronously; the IO.pipe waiter in commander wakes
201
+ # independently when the key is set.
202
+ network.memory.subscribe(:db_finding, :net_finding, :app_finding) do |change|
203
+ puts " [memory] :#{change.key} written by #{change.writer}"
204
+ end
205
+
206
+ # ── Run ────────────────────────────────────────────────────────────────────
207
+
208
+ puts "=" * 65
209
+ puts "Example 27: Production Incident War Room"
210
+ puts " Phase 5 — BusPoller · Reactive Memory · Poller Groups"
211
+ puts "=" * 65
212
+ puts
213
+ puts network.visualize
214
+ puts
215
+
216
+ puts "Poller groups registered on network BusPoller:"
217
+ puts " #{network.instance_variable_get(:@bus_poller).groups.inspect}"
218
+ puts
219
+
220
+ puts "INCIDENT: Payment service degraded — elevated error rates and timeouts"
221
+ puts "-" * 65
222
+ puts
223
+
224
+ result = network.run(message: "Payment service is degraded: elevated HTTP 500 " \
225
+ "error rates (~12%) and p99 latency spiked to 8s. " \
226
+ "Investigate your assigned infrastructure layer.")
227
+
228
+ puts
229
+ puts "-" * 65
230
+ puts "War-room updates received (BusPoller order):"
231
+ war_room.updates.each.with_index(1) { |u, i| puts " #{i}. #{u.gsub(/\s+/, " ")[0..90]}" }
232
+ puts
233
+ puts "Reactive memory keys written by scouts:"
234
+ %i[db_finding net_finding app_finding].each do |key|
235
+ val = network.memory[key]
236
+ puts " :#{key.to_s.ljust(14)} #{val&.gsub(/\s+/, " ")&.slice(0, 80)}..."
237
+ end
238
+ puts
239
+ puts "Incident action plan (from #{OUTPUT_DIR}/incident_report.md):"
240
+ puts "-" * 65
241
+ report = network.memory[:incident_report].to_s
242
+ puts report
243
+ puts
244
+ puts "=" * 65
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Example 28: MCP Server Discovery
5
+ #
6
+ # When a robot has many MCP servers configured, connecting to all of them
7
+ # upfront is wasteful — some servers may be irrelevant to a particular query.
8
+ #
9
+ # MCP Server Discovery uses TF cosine similarity to select only the servers
10
+ # semantically relevant to the user's query, then connects only those.
11
+ #
12
+ # == Key config
13
+ #
14
+ # robot = RobotLab.build(
15
+ # mcp_discovery: true, # ← enables semantic filtering
16
+ # mcp: [ ... ] # ← candidate servers, each with :description
17
+ # )
18
+ #
19
+ # == Fallback behaviour
20
+ #
21
+ # All servers are connected unchanged when:
22
+ # - No server has a :description field
23
+ # - The classifier gem is unavailable
24
+ # - The query is blank or nil
25
+ # - No server scores at or above the threshold (0.05 by default)
26
+ #
27
+ # This demo exercises MCP::ServerDiscovery directly — no LLM calls needed.
28
+ #
29
+ # Usage:
30
+ # bundle exec ruby examples/28_mcp_discovery.rb
31
+
32
+ require_relative "../lib/robot_lab"
33
+
34
+ # Three representative MCP server configurations
35
+ SERVERS = [
36
+ {
37
+ name: "filesystem",
38
+ description: "Read, write, and search local files and directories",
39
+ transport: { type: "stdio", command: "mcp-server-filesystem" }
40
+ },
41
+ {
42
+ name: "github",
43
+ description: "GitHub repos, issues, pull requests, code search",
44
+ transport: { type: "stdio", command: "mcp-server-github" }
45
+ },
46
+ {
47
+ name: "brew",
48
+ description: "Install, update, and manage macOS packages via Homebrew",
49
+ transport: { type: "stdio", command: "mcp-server-brew" }
50
+ }
51
+ ].freeze
52
+
53
+ def show_query(label, query)
54
+ selected = RobotLab::MCP::ServerDiscovery.select(query, from: SERVERS)
55
+ names = selected.map { |s| s[:name] }
56
+
57
+ puts " Query : #{query.inspect}"
58
+ puts " Match : #{names.inspect}"
59
+ puts
60
+ end
61
+
62
+ puts "=" * 60
63
+ puts "Example 28: MCP Server Discovery"
64
+ puts " Semantic server selection via TF cosine similarity"
65
+ puts "=" * 60
66
+ puts
67
+ puts "Candidate servers:"
68
+ SERVERS.each do |s|
69
+ puts " #{s[:name].ljust(12)} #{s[:description]}"
70
+ end
71
+ puts
72
+
73
+ puts "Discovery queries:"
74
+ puts "-" * 60
75
+ show_query("File ops", "read my config file")
76
+ show_query("Package mgmt", "install imagemagick via homebrew")
77
+ show_query("Code review", "list open pull requests on my repo")
78
+
79
+ puts "Fallback cases:"
80
+ puts "-" * 60
81
+
82
+ # No description → all servers returned
83
+ no_desc_servers = SERVERS.map { |s| s.except(:description) }
84
+ result = RobotLab::MCP::ServerDiscovery.select("install imagemagick", from: no_desc_servers)
85
+ puts " No descriptions : returns all (#{result.size} servers)"
86
+
87
+ # Blank query → all servers returned
88
+ result = RobotLab::MCP::ServerDiscovery.select("", from: SERVERS)
89
+ puts " Blank query : returns all (#{result.size} servers)"
90
+
91
+ # Very high threshold → no match → fallback to all
92
+ result = RobotLab::MCP::ServerDiscovery.select("install imagemagick", from: SERVERS, threshold: 1.0)
93
+ puts " High threshold : returns all (#{result.size} servers) — no match above 1.0"
94
+
95
+ puts
96
+ puts "mcp_discovery: true on a Robot"
97
+ puts "-" * 60
98
+ puts <<~NOTE
99
+ RobotLab.build(
100
+ name: "assistant",
101
+ mcp_discovery: true,
102
+ mcp: [
103
+ { name: "filesystem", description: "Read, write...", transport: { ... } },
104
+ { name: "github", description: "GitHub repos...", transport: { ... } },
105
+ { name: "brew", description: "Install packages...", transport: { ... } }
106
+ ]
107
+ )
108
+
109
+ # Only the :brew server is connected for this message:
110
+ robot.run("install imagemagick")
111
+ NOTE
112
+ puts "=" * 60
@@ -0,0 +1,243 @@
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
+ ENV["ROBOT_LAB_TEMPLATE_PATH"] ||= File.join(__dir__, "prompts")
30
+
31
+ require_relative "../lib/robot_lab"
32
+ require "digest"
33
+
34
+ # Always shut down the pool when the process exits.
35
+ at_exit { RobotLab.shutdown_ractor_pool }
36
+
37
+ # =============================================================================
38
+ # Tool definitions
39
+ # =============================================================================
40
+
41
+ # Base class — declare ractor_safe once; all subclasses inherit it.
42
+ class TextTool < RobotLab::Tool
43
+ ractor_safe true
44
+ end
45
+
46
+ # Word and sentence statistics — pure computation, no shared state.
47
+ class WordStatsTool < TextTool
48
+ description "Count words, sentences, and average word length"
49
+
50
+ param :text, type: :string, desc: "Text to analyze"
51
+
52
+ def execute(text:)
53
+ words = text.scan(/\b\w+\b/)
54
+ sentences = [text.scan(/[.!?]+/).length, 1].max
55
+ avg_len = words.empty? ? 0.0 : (words.sum(&:length).to_f / words.length).round(2)
56
+
57
+ { words: words.length, sentences: sentences, avg_word_len: avg_len }.freeze
58
+ end
59
+ end
60
+
61
+ # Words-per-sentence and long-word density (a simple readability proxy).
62
+ class ReadabilityTool < TextTool
63
+ description "Estimate words-per-sentence and long-word density"
64
+
65
+ param :text, type: :string, desc: "Text to analyze"
66
+
67
+ def execute(text:)
68
+ words = text.scan(/\b\w+\b/)
69
+ sentences = [text.scan(/[.!?]+/).length, 1].max
70
+ long_words = words.count { |w| w.length > 6 }
71
+ long_pct = words.empty? ? 0 : (long_words * 100 / words.length)
72
+
73
+ {
74
+ words: words.length,
75
+ sentences: sentences,
76
+ words_per_sentence: (words.length.to_f / sentences).round(1),
77
+ long_word_pct: long_pct
78
+ }.freeze
79
+ end
80
+ end
81
+
82
+ # CPU-intensive: 500 000 SHA-256 rounds (~320 ms per job on modern hardware).
83
+ # The overhead of reply-queue Ractor creation + make_shareable is ~20 ms per job
84
+ # (constant), so computation must dwarf it to show a real speedup.
85
+ class HeavyDigestTool < TextTool
86
+ description "SHA-256 chain (500 000 rounds) — CPU-intensive, Ractor-safe"
87
+
88
+ ROUNDS = 500_000
89
+
90
+ param :text, type: :string, desc: "Seed text"
91
+
92
+ def execute(text:)
93
+ digest = text
94
+ ROUNDS.times { digest = Digest::SHA256.hexdigest(digest) }
95
+ digest[0..15].freeze
96
+ end
97
+ end
98
+
99
+ # NOT Ractor-safe: @@hits is mutable class-level state.
100
+ # Shown here purely as a contrast — do not submit this to the pool.
101
+ class RequestCounterTool < RobotLab::Tool
102
+ description "Word count plus a mutable global call counter (not Ractor-safe)"
103
+
104
+ @@hits = 0 # mutable class variable — Ractor workers cannot access this
105
+
106
+ param :text, type: :string, desc: "Text to count"
107
+
108
+ def execute(text:)
109
+ @@hits += 1
110
+ "#{text.scan(/\b\w+\b/).length} words (total calls: #{@@hits})"
111
+ end
112
+ end
113
+
114
+ # =============================================================================
115
+ # Demo
116
+ # =============================================================================
117
+
118
+ puts "=" * 62
119
+ puts "Example 29: Ractor-Safe CPU Tools"
120
+ puts "=" * 62
121
+ puts
122
+
123
+ DIVIDER = ("─" * 54).freeze
124
+
125
+ SAMPLE_TEXTS = [
126
+ "Ruby makes programmer happiness a first-class concern in language design.",
127
+ "Ractors enable true CPU parallelism by isolating mutable state between actors.",
128
+ "Every distributed system eventually becomes a consistency problem.",
129
+ "The quick brown fox jumps over the lazy dog — a pangram.",
130
+ "Machine learning transforms raw observations into actionable insight.",
131
+ "Simplicity is the ultimate sophistication in software architecture."
132
+ ].freeze
133
+
134
+ # ── 1. ractor_safe? flags ─────────────────────────────────────
135
+
136
+ puts "1. ractor_safe? flags"
137
+ puts " #{DIVIDER}"
138
+
139
+ {
140
+ "WordStatsTool" => WordStatsTool,
141
+ "ReadabilityTool" => ReadabilityTool,
142
+ "HeavyDigestTool" => HeavyDigestTool,
143
+ "RequestCounterTool" => RequestCounterTool
144
+ }.each do |name, klass|
145
+ mark = klass.ractor_safe ? "✓ ractor_safe (inherits from TextTool)" : "✗ NOT ractor_safe"
146
+ puts " #{name.ljust(22)} #{mark}"
147
+ end
148
+ puts
149
+
150
+ # ── 2. RactorBoundary.freeze_deep ─────────────────────────────
151
+
152
+ puts "2. RactorBoundary.freeze_deep"
153
+ puts " #{DIVIDER}"
154
+
155
+ nested = { tags: ["ruby", "ractor"], meta: { version: 2 } }
156
+ frozen = RobotLab::RactorBoundary.freeze_deep(nested)
157
+
158
+ puts " Input frozen? #{nested.frozen?}"
159
+ puts " Output frozen? #{frozen.frozen?}"
160
+ puts " Inner :tags array frozen? #{frozen[:tags].frozen?}"
161
+ puts " Inner :meta hash frozen? #{frozen[:meta].frozen?}"
162
+ puts
163
+
164
+ # A Proc cannot cross a Ractor boundary — freeze_deep raises immediately.
165
+ require "stringio"
166
+ begin
167
+ RobotLab::RactorBoundary.freeze_deep(StringIO.new("I cannot be frozen"))
168
+ rescue RobotLab::RactorBoundaryError => e
169
+ puts " RactorBoundaryError on StringIO:"
170
+ puts " #{e.message.split('.').first}."
171
+ end
172
+ puts
173
+
174
+ # ── 3. Single pool submissions ─────────────────────────────────
175
+
176
+ pool = RobotLab.ractor_pool
177
+ puts "3. Worker pool (#{pool.size} Ractors — one per CPU core)"
178
+ puts " #{DIVIDER}"
179
+
180
+ sample = SAMPLE_TEXTS.first
181
+
182
+ r = pool.submit("WordStatsTool", { text: sample })
183
+ puts " WordStatsTool: #{r.inspect}"
184
+
185
+ r = pool.submit("ReadabilityTool", { text: sample })
186
+ puts " ReadabilityTool: words_per_sentence=#{r[:words_per_sentence]} long_word_pct=#{r[:long_word_pct]}%"
187
+ puts
188
+
189
+ # ── 4. ToolError propagation ───────────────────────────────────
190
+
191
+ puts "4. ToolError propagation"
192
+ puts " #{DIVIDER}"
193
+ puts " Submitting nil as :text (WordStatsTool will call nil.scan — NoMethodError)"
194
+ puts
195
+
196
+ begin
197
+ pool.submit("WordStatsTool", { text: nil })
198
+ rescue RobotLab::ToolError => e
199
+ puts " RobotLab::ToolError caught:"
200
+ puts " #{e.message}"
201
+ end
202
+ puts
203
+
204
+ # ── 5. Parallel batch vs sequential ───────────────────────────
205
+
206
+ puts "5. Parallel batch — #{SAMPLE_TEXTS.length} jobs, each doing #{HeavyDigestTool::ROUNDS.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1_').reverse} SHA-256 rounds"
207
+ puts " #{DIVIDER}"
208
+
209
+ # Parallel: one Thread per job, all submitted simultaneously.
210
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
211
+ threads = SAMPLE_TEXTS.map { |text| Thread.new { pool.submit("HeavyDigestTool", { text: text }) } }
212
+ parallel_results = threads.map(&:value)
213
+ parallel_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
214
+
215
+ # Sequential: jobs submitted one-at-a-time from the main thread.
216
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
217
+ SAMPLE_TEXTS.each { |text| pool.submit("HeavyDigestTool", { text: text }) }
218
+ seq_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
219
+
220
+ puts " Parallel (#{SAMPLE_TEXTS.length} threads → #{pool.size} Ractors): #{"%.3f" % parallel_time}s"
221
+ puts " Sequential (1 thread → #{pool.size} Ractors): #{"%.3f" % seq_time}s"
222
+
223
+ if seq_time > parallel_time * 1.2
224
+ puts " Speedup: #{(seq_time / parallel_time).round(1)}×"
225
+ else
226
+ puts " Note: overhead (~20 ms/job for reply-queue Ractor + make_shareable)"
227
+ puts " dominated this run. Increase ROUNDS further to widen the gap."
228
+ end
229
+
230
+ puts
231
+ puts " First result (truncated digest): #{parallel_results.first}"
232
+ puts
233
+
234
+ # ── 6. Shutdown ────────────────────────────────────────────────
235
+
236
+ puts "6. Shutdown"
237
+ puts " #{DIVIDER}"
238
+ RobotLab.shutdown_ractor_pool
239
+ puts " Pool shut down cleanly (poison-pill × #{pool.size} workers)."
240
+ puts
241
+ puts "=" * 62
242
+ puts "Example 29 complete."
243
+ puts "=" * 62