phronomy 0.16.0 → 0.18.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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -4,95 +4,34 @@ require "securerandom"
4
4
 
5
5
  module Phronomy
6
6
  module MultiAgent
7
- # Implements the "Agent teams" coordination pattern (Anthropic blog, Pattern 3).
8
- #
9
- # @see https://claude.com/blog/multi-agent-coordination-patterns
10
- #
11
- # A coordinator LLM agent decomposes work into tasks and enqueues them
12
- # dynamically via built-in tools. A fixed set of worker agents processes tasks
13
- # sequentially — one task per worker per turn — carrying forward their
14
- # conversation history across assignments to accumulate domain context over time.
15
- #
16
- # Workers are selected in sequence (the worker with the fewest accumulated
17
- # messages is chosen by default). Task dispatch is synchronous; there is no
18
- # concurrent or parallel execution.
19
- #
20
- # The coordinator is an {Agent::Base} subclass that has two built-in tools:
21
- # - +enqueue_task+ — adds a task description to the queue
22
- # - +finalize+ — signals that all tasks have been enqueued
23
- #
24
- # Worker persistence is implemented by passing each worker's accumulated
25
- # +messages+ array back as a top-level +messages:+ argument on every subsequent
26
- # +invoke+ call, so the LLM retains context across multiple task assignments.
27
- #
28
- # @example Basic usage
29
- # class MigrationTeam < Phronomy::MultiAgent::TeamCoordinator
30
- # coordinator_model "claude-3-5-sonnet-20241022"
31
- # coordinator_instructions <<~INST
32
- # Analyze the request and enqueue one migration task per service.
33
- # Call enqueue_task for each service, then call finalize.
34
- # INST
35
- #
36
- # pool size: 3, agent: MigrationAgent
37
- #
38
- # aggregate do |assignments|
39
- # { reports: assignments.map { |a| { task: a[:task][:description], result: a[:result] } } }
40
- # end
41
- # end
42
- #
43
- # result = MigrationTeam.new.invoke("Migrate all services to Rails 8")
7
+ # Coordinator/worker multi-agent pattern with persistent worker Agent state.
44
8
  class TeamCoordinator
45
- # Holds per-worker context between task invocations.
46
- # Worker persistence is implemented by carrying +messages+ forward on each
47
- # successive +agent#invoke+ call as the top-level +messages:+ argument..
48
9
  WorkerState = Struct.new(
49
- :index, # Integer — 0-based worker index
50
- :agent, # Agent::Base instance
51
- :messages, # Array — accumulated conversation history
52
- :status # Symbol — :idle | :available | :done
10
+ :index,
11
+ :agent,
12
+ :transcript_size,
13
+ :status
53
14
  ) do
54
- # Returns true when this worker is ready to accept the next task.
55
- def available? = [:idle, :available].include?(status)
15
+ def available? = %i[idle available].include?(status)
56
16
  end
57
17
  private_constant :WorkerState
58
18
 
59
19
  class << self
60
- # Sets the LLM model for the coordinator agent.
61
- # Falls back to +Phronomy.configuration.default_model+ when not set.
62
- #
63
- # @param value [String, nil]
64
20
  # @api public
65
21
  def coordinator_model(value = nil)
66
22
  value ? @coordinator_model = value : @coordinator_model
67
23
  end
68
24
 
69
- # Sets the system instructions for the coordinator agent.
70
- # The prompt should direct the LLM to call +enqueue_task+ for each task
71
- # and then call +finalize+ when all tasks are enqueued.
72
- #
73
- # @param value [String, nil]
74
25
  # @api public
75
26
  def coordinator_instructions(value = nil)
76
27
  value ? @coordinator_instructions = value : @coordinator_instructions
77
28
  end
78
29
 
79
- # Sets the LLM provider for the coordinator agent.
80
- # Required when using a custom +BASE_URL+ (e.g. LM Studio, Ollama, vLLM)
81
- # so that RubyLLM does not attempt to resolve an unknown model name.
82
- # Pass the same value as +LLMConfig::PROVIDER+ in your examples.
83
- #
84
- # @param value [Symbol, nil]
85
30
  # @api public
86
31
  def coordinator_provider(value = nil)
87
32
  value ? @coordinator_provider = value : @coordinator_provider
88
33
  end
89
34
 
90
- # Configures the set of workers.
91
- #
92
- # @param size [Integer] number of persistent worker instances (tasks are assigned sequentially)
93
- # @param agent [Class] Agent::Base subclass used for all workers
94
- # @param on_error [Symbol] +:raise+ (default) propagates worker exceptions;
95
- # +:skip+ records the failure and continues with remaining tasks
96
35
  # @api public
97
36
  def pool(size:, agent:, on_error: :raise)
98
37
  @pool_size = Integer(size)
@@ -100,57 +39,31 @@ module Phronomy
100
39
  @on_error = on_error
101
40
  end
102
41
 
103
- # Customises the worker selection algorithm.
104
- # The block receives an Array of available WorkerState objects and must
105
- # return the one to assign the next task to.
106
- # Default: worker with the fewest accumulated messages (round-robin-like).
107
- #
108
- # @yield [Array<WorkerState>] available workers
109
- # @yieldreturn [WorkerState] the chosen worker
110
42
  # @api public
111
43
  def schedule(&block)
112
44
  @scheduler = block
113
45
  end
114
46
 
115
- # Defines how task assignments are merged into the final return value.
116
- # The block receives an Array of assignment Hashes:
117
- # { task: Hash, result: String|nil, worker: Integer, error: Exception|nil }
118
- # When omitted, the raw assignments array is returned.
119
- #
120
- # @yield [Array<Hash>] all completed (and skipped) task assignments
121
47
  # @api public
122
48
  def aggregate(&block)
123
49
  @aggregator = block
124
50
  end
125
51
 
126
- # @!visibility private
127
52
  def _coordinator_model = @coordinator_model
128
- # @!visibility private
129
53
  def _coordinator_instructions = @coordinator_instructions
130
- # @!visibility private
131
54
  def _coordinator_provider = @coordinator_provider
132
- # @!visibility private
133
55
  def _pool_size = @pool_size || 1
134
- # @!visibility private
135
56
  def _worker_agent = @worker_agent
136
- # @!visibility private
137
57
  def _on_error = @on_error || :raise
138
- # @!visibility private
139
58
  def _scheduler = @scheduler
140
- # @!visibility private
141
59
  def _aggregator = @aggregator
142
60
  end
143
61
 
144
- # Runs the full team coordination: coordinator generates tasks, workers
145
- # process them sequentially, and the aggregate block merges the results.
146
- #
147
- # @param team_input [String, Hash] the high-level objective given to the coordinator
148
- # @param config [Hash] reserved for future use
149
- # @return [Object] the return value of the aggregate block, or the raw assignments Array
150
- # @raise [ArgumentError] when +pool :agent+ has not been configured
151
62
  # @api public
152
63
  def invoke(team_input, config: {})
153
- raise ArgumentError, "pool :agent must be configured before invoking" unless self.class._worker_agent
64
+ unless self.class._worker_agent
65
+ raise ArgumentError, "pool :agent must be configured before invoking"
66
+ end
154
67
 
155
68
  task_queue = []
156
69
  run_coordinator(team_input, task_queue)
@@ -158,26 +71,13 @@ module Phronomy
158
71
  finalize_result(assignments)
159
72
  end
160
73
 
161
- # Streaming version of +invoke+. Yields a Hash event for each completed or
162
- # failed task assignment.
163
- #
164
- # Yielded Hash keys:
165
- # :type — +:task_completed+ or +:task_failed+
166
- # :worker — worker index (Integer)
167
- # :task — the task Hash from the queue ({ id:, description:, metadata:, enqueued_at: })
168
- # :result — output string, or +nil+ on failure
169
- # :error — Exception, or +nil+ on success
170
- #
171
- # @param team_input [String, Hash]
172
- # @param config [Hash]
173
- # @yield [Hash] one event per completed/failed task
174
- # @return [Object] same as +invoke+
175
- # @raise [ArgumentError] when +pool :agent+ has not been configured
176
74
  # @api public
177
75
  def stream(team_input, config: {}, &block)
178
76
  return invoke(team_input, config: config) unless block
179
77
 
180
- raise ArgumentError, "pool :agent must be configured before invoking" unless self.class._worker_agent
78
+ unless self.class._worker_agent
79
+ raise ArgumentError, "pool :agent must be configured before invoking"
80
+ end
181
81
 
182
82
  task_queue = []
183
83
  run_coordinator(team_input, task_queue)
@@ -187,23 +87,25 @@ module Phronomy
187
87
 
188
88
  private
189
89
 
190
- # Phase 1: Run the coordinator LLM agent to populate task_queue.
191
90
  def run_coordinator(team_input, task_queue)
192
91
  coordinator = build_coordinator_agent(task_queue)
193
92
  input = team_input.is_a?(String) ? team_input : team_input.to_s
194
93
  coordinator.invoke(input)
195
94
  end
196
95
 
197
- # Phase 2: Process tasks from the queue using the worker pool.
198
- # Workers accumulate message history across assignments.
199
96
  def run_workers(task_queue, &event_block)
200
97
  pool_size = self.class._pool_size
201
98
  agent_class = self.class._worker_agent
202
99
  on_error = self.class._on_error
203
100
  scheduler = self.class._scheduler
204
101
 
205
- workers = Array.new(pool_size) do |i|
206
- WorkerState.new(index: i, agent: agent_class.new, messages: [], status: :idle)
102
+ workers = Array.new(pool_size) do |index|
103
+ WorkerState.new(
104
+ index: index,
105
+ agent: agent_class.new,
106
+ transcript_size: 0,
107
+ status: :idle
108
+ )
207
109
  end
208
110
 
209
111
  assignments = []
@@ -214,40 +116,45 @@ module Phronomy
214
116
  worker = scheduler ? scheduler.call(available) : default_scheduler(available)
215
117
 
216
118
  begin
217
- # Worker agent retains conversation history in its Journal.
218
119
  result = worker.agent.invoke(task[:description])
219
- worker.messages = Array(result[:messages])
120
+ worker.transcript_size = worker.agent.transcript.length
220
121
  worker.status = :available
221
- entry = {task: task, result: result[:output], worker: worker.index, error: nil}
122
+ entry = {
123
+ task: task,
124
+ result: result[:output],
125
+ worker: worker.index,
126
+ error: nil
127
+ }
222
128
  assignments << entry
223
129
  event_block&.call(entry.merge(type: :task_completed))
224
- rescue => e
130
+ rescue => error
225
131
  worker.status = :available
226
132
  raise unless on_error == :skip
227
133
 
228
- entry = {task: task, result: nil, worker: worker.index, error: e}
134
+ entry = {
135
+ task: task,
136
+ result: nil,
137
+ worker: worker.index,
138
+ error: error
139
+ }
229
140
  assignments << entry
230
141
  event_block&.call(entry.merge(type: :task_failed))
231
142
  end
232
143
  end
233
144
 
234
- workers.each { |w| w.status = :done }
145
+ workers.each { |worker| worker.status = :done }
235
146
  assignments
236
147
  end
237
148
 
238
- # Phase 3: Apply the aggregate block (or return raw assignments).
239
149
  def finalize_result(assignments)
240
150
  aggregator = self.class._aggregator
241
151
  aggregator ? aggregator.call(assignments) : assignments
242
152
  end
243
153
 
244
- # Default scheduler: assign to the worker with the fewest accumulated
245
- # messages (promotes round-robin-like distribution across the pool).
246
154
  def default_scheduler(available_workers)
247
- available_workers.min_by { |w| w.messages.size }
155
+ available_workers.min_by(&:transcript_size)
248
156
  end
249
157
 
250
- # Build an anonymous coordinator Agent::Base with the two built-in tools.
251
158
  def build_coordinator_agent(task_queue)
252
159
  coordinator_model_val = self.class._coordinator_model
253
160
  coordinator_instructions_val = self.class._coordinator_instructions
@@ -260,34 +167,38 @@ module Phronomy
260
167
  model coordinator_model_val
261
168
  provider coordinator_provider_val if coordinator_provider_val
262
169
  instructions coordinator_instructions_val
263
- tools enqueue_tool, finalize_tool
170
+ tools(enqueue_tool => nil, finalize_tool => nil)
264
171
  end
265
172
 
266
173
  coordinator_class.new
267
174
  end
268
175
 
269
- # Builds the +enqueue_task+ tool. Each call appends a task Hash to task_queue.
270
176
  def build_enqueue_tool(task_queue)
271
177
  Class.new(Phronomy::Agent::Context::Capability::Base) do
272
178
  tool_name "enqueue_task"
273
179
  description "Add a task to the worker queue."
180
+ execution_mode :cooperative
274
181
  param :description, type: :string, desc: "What the worker agent should do"
275
182
  param :metadata, type: :string, desc: "Optional metadata", required: false
276
183
 
277
184
  define_method(:execute) do |description:, metadata: nil|
278
- task = {id: task_queue.size + 1, description: description, metadata: metadata, enqueued_at: Time.now}
185
+ task = {
186
+ id: task_queue.size + 1,
187
+ description: description,
188
+ metadata: metadata,
189
+ enqueued_at: Time.now
190
+ }
279
191
  task_queue << task
280
192
  "Task ##{task[:id]} enqueued: #{description}"
281
193
  end
282
194
  end
283
195
  end
284
196
 
285
- # Builds the +finalize+ tool. Signals to the coordinator LLM that all tasks
286
- # have been enqueued; returns a confirmation string.
287
197
  def build_finalize_tool(task_queue)
288
198
  Class.new(Phronomy::Agent::Context::Capability::Base) do
289
199
  tool_name "finalize"
290
200
  description "Signal that task generation is complete. Call this after all tasks have been enqueued."
201
+ execution_mode :cooperative
291
202
  param :summary, type: :string, desc: "Brief summary of what was enqueued", required: false
292
203
 
293
204
  define_method(:execute) do |summary: ""|
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ class Comparison
7
+ ComparisonPair = Data.define(:eval_case, :result_a, :result_b)
8
+
9
+ def initialize(scorer: Scorer::ExactMatch.new)
10
+ @scorer = scorer
11
+ end
12
+
13
+ def compare(dataset, callable_a, callable_b)
14
+ results_a = Runner.new(scorer: @scorer).run(dataset, callable_a)
15
+ results_b = Runner.new(scorer: @scorer).run(dataset, callable_b)
16
+ results_a.zip(results_b).map do |a, b|
17
+ ComparisonPair.new(eval_case: a.eval_case, result_a: a, result_b: b)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ class Dataset
7
+ include Enumerable
8
+
9
+ def initialize(cases = [])
10
+ @cases = cases.freeze
11
+ end
12
+
13
+ def self.from_array(pairs)
14
+ new(pairs.map { |item| EvalCase.new(**item) })
15
+ end
16
+
17
+ def each(&block)
18
+ @cases.each(&block)
19
+ end
20
+
21
+ def size
22
+ @cases.size
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ EvalCase = Data.define(:input, :expected, :metadata) do
7
+ def initialize(input:, expected:, metadata: {})
8
+ super
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ EvalResult = Data.define(:eval_case, :actual, :score, :usage, :latency_ms, :error) do
7
+ def initialize(eval_case:, actual:, score:, usage:, latency_ms:, error: nil)
8
+ super
9
+ end
10
+
11
+ def pass? = score >= 1.0
12
+ def scorer_error? = !error.nil?
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ class Metrics
7
+ def initialize(results)
8
+ @results = results
9
+ end
10
+
11
+ def pass_rate
12
+ return 0.0 if @results.empty?
13
+ @results.count(&:pass?).to_f / @results.size
14
+ end
15
+
16
+ def average_score
17
+ return 0.0 if @results.empty?
18
+ @results.sum(&:score) / @results.size
19
+ end
20
+
21
+ def total_usage
22
+ @results.map(&:usage).compact.reduce(Phronomy::TokenUsage.zero, :+)
23
+ end
24
+
25
+ def average_latency_ms
26
+ return 0.0 if @results.empty?
27
+ @results.sum(&:latency_ms).to_f / @results.size
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ total: @results.size,
33
+ pass_count: @results.count(&:pass?),
34
+ pass_rate: pass_rate,
35
+ average_score: average_score,
36
+ total_usage: total_usage.to_h,
37
+ average_latency_ms: average_latency_ms
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ # Sequential test-only evaluator. Concurrency belongs to the subsystem
7
+ # under test, not to the evaluation helper itself.
8
+ class Runner
9
+ def initialize(scorer: Scorer::ExactMatch.new)
10
+ @scorer = scorer
11
+ end
12
+
13
+ def run(dataset, callable)
14
+ dataset.to_a.map { |eval_case| run_one(eval_case, callable) }
15
+ end
16
+
17
+ private
18
+
19
+ def run_one(eval_case, callable)
20
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
21
+ result = callable.call(eval_case.input)
22
+ latency_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - started_at
23
+ actual, usage = extract(result)
24
+ score, score_error = score_safely(
25
+ @scorer,
26
+ actual: actual,
27
+ expected: eval_case.expected,
28
+ input: eval_case.input
29
+ )
30
+ EvalResult.new(
31
+ eval_case: eval_case,
32
+ actual: actual,
33
+ score: score,
34
+ usage: usage,
35
+ latency_ms: latency_ms,
36
+ error: score_error
37
+ )
38
+ end
39
+
40
+ def extract(result)
41
+ result.is_a?(Hash) ? [result[:output].to_s, result[:usage]] : [result.to_s, nil]
42
+ end
43
+
44
+ def score_safely(scorer, **kwargs)
45
+ [scorer.score(**kwargs), nil]
46
+ rescue => error
47
+ [0.0, error]
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ class Base
8
+ def score(actual:, expected:, input: nil)
9
+ raise NotImplementedError, "#{self.class}#score is not implemented"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ class ExactMatch < Base
8
+ def initialize(case_sensitive: true)
9
+ @case_sensitive = case_sensitive
10
+ end
11
+
12
+ def score(actual:, expected:, input: nil)
13
+ actual_value = actual.to_s.strip
14
+ expected_value = expected.to_s.strip
15
+ unless @case_sensitive
16
+ actual_value = actual_value.downcase
17
+ expected_value = expected_value.downcase
18
+ end
19
+ (actual_value == expected_value) ? 1.0 : 0.0
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ class IncludesScorer < Base
8
+ def initialize(case_sensitive: false)
9
+ @case_sensitive = case_sensitive
10
+ end
11
+
12
+ def score(actual:, expected:, input: nil)
13
+ actual_value = actual.to_s
14
+ expected_value = expected.to_s
15
+ unless @case_sensitive
16
+ actual_value = actual_value.downcase
17
+ expected_value = expected_value.downcase
18
+ end
19
+ actual_value.include?(expected_value) ? 1.0 : 0.0
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ class LlmJudge < Base
8
+ DEFAULT_PROMPT = <<~PROMPT
9
+ You are an impartial judge evaluating the quality of an AI assistant response.
10
+ Rate the response on a scale from 0.0 (completely wrong or unhelpful) to 1.0 (perfect).
11
+ Respond with ONLY a single decimal number between 0.0 and 1.0 — no other text.
12
+
13
+ Question: %<input>s
14
+ Expected answer: %<expected>s
15
+ Actual response: %<actual>s
16
+
17
+ Score:
18
+ PROMPT
19
+
20
+ def initialize(model:, prompt_template: DEFAULT_PROMPT, raise_on_error: false)
21
+ @model = model
22
+ @prompt_template = prompt_template
23
+ @raise_on_error = raise_on_error
24
+ end
25
+
26
+ def score(actual:, expected:, input: nil)
27
+ prompt = format(
28
+ @prompt_template,
29
+ input: input.to_s,
30
+ expected: expected.to_s,
31
+ actual: actual.to_s
32
+ )
33
+ response = Phronomy::Runtime.instance.offload.submit do
34
+ RubyLLM.chat(model: @model).ask(prompt)
35
+ end.blocking_wait
36
+ response.content.to_s.strip.scan(/-?\d+\.?\d*/).first.to_f.clamp(0.0, 1.0)
37
+ rescue => error
38
+ raise if @raise_on_error
39
+ warn "[LlmJudge] Scoring failed: #{error.message}"
40
+ 0.0
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ # Test-only evaluation helpers.
6
+ module Eval
7
+ end
8
+ end
9
+ end