phronomy 0.17.0 → 0.19.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +122 -1134
- data/README.md +70 -1280
- data/benchmark/bench_regression.rb +25 -5
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/009-state-store-abstraction.md +1 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
- data/docs/decisions/014-unified-persistence-durable-state.md +268 -0
- data/docs/features.md +93 -0
- data/docs/getting-started.md +387 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/migrations/0.19.md +148 -0
- data/docs/runtime-and-concurrency.md +349 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +116 -55
- data/lib/phronomy/agent/context/capability/base.rb +13 -3
- data/lib/phronomy/agent/context_assembler.rb +13 -3
- data/lib/phronomy/agent/execution_coordinator.rb +423 -252
- data/lib/phronomy/agent/journal_projection.rb +5 -1
- data/lib/phronomy/agent/shared_state.rb +2 -0
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +97 -47
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/configuration.rb +7 -30
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +170 -193
- data/lib/phronomy/engine/fsm_session.rb +6 -4
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +52 -158
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
- data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
- data/lib/phronomy/persistence/in_memory.rb +113 -8
- data/lib/phronomy/persistence.rb +12 -3
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +10 -9
- data/lib/phronomy/workflow_runner.rb +363 -99
- data/lib/phronomy.rb +10 -4
- data/scripts/api_snapshot.rb +4 -4
- metadata +25 -33
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -193
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- data/lib/phronomy/state_store/base.rb +0 -48
- data/lib/phronomy/state_store/in_memory.rb +0 -62
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
|
@@ -66,7 +66,7 @@ module Phronomy
|
|
|
66
66
|
|
|
67
67
|
def save(agent_id, expected_revision:, root:)
|
|
68
68
|
@owner.synchronize do
|
|
69
|
-
current =
|
|
69
|
+
current = @owner.state[:agents].fetch(agent_id.to_s) { raise NotFoundError, "agent not found: #{agent_id}" }
|
|
70
70
|
unless current.agent_revision == expected_revision
|
|
71
71
|
raise ConflictError,
|
|
72
72
|
"agent revision conflict: expected #{expected_revision}, actual #{current.agent_revision}"
|
|
@@ -162,7 +162,7 @@ module Phronomy
|
|
|
162
162
|
|
|
163
163
|
def save(execution_id, expected_revision:, execution:)
|
|
164
164
|
@owner.synchronize do
|
|
165
|
-
current =
|
|
165
|
+
current = @owner.state[:executions].fetch(execution_id.to_s) { raise NotFoundError, "execution not found: #{execution_id}" }
|
|
166
166
|
unless current.execution_revision == expected_revision
|
|
167
167
|
raise ConflictError,
|
|
168
168
|
"execution revision conflict: expected #{expected_revision}, actual #{current.execution_revision}"
|
|
@@ -209,31 +209,112 @@ module Phronomy
|
|
|
209
209
|
end
|
|
210
210
|
end
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
class WorkflowStates
|
|
213
|
+
def initialize(owner) = @owner = owner
|
|
214
|
+
|
|
215
|
+
def load(thread_id)
|
|
216
|
+
@owner.synchronize do
|
|
217
|
+
record = @owner.workflow_state_data[thread_id.to_s]
|
|
218
|
+
next nil unless record
|
|
219
|
+
|
|
220
|
+
{
|
|
221
|
+
snapshot: @owner.deep_dup_workflow_value(record.fetch(:snapshot)),
|
|
222
|
+
revision: record.fetch(:revision)
|
|
223
|
+
}.freeze
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def save(thread_id, expected_revision:, snapshot:)
|
|
228
|
+
@owner.synchronize do
|
|
229
|
+
key = thread_id.to_s
|
|
230
|
+
current = @owner.workflow_state_data[key]
|
|
231
|
+
actual_revision = current&.fetch(:revision)
|
|
232
|
+
unless actual_revision == expected_revision
|
|
233
|
+
raise ConflictError,
|
|
234
|
+
"workflow state revision conflict for #{key}: " \
|
|
235
|
+
"expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
next_revision = actual_revision ? actual_revision + 1 : 1
|
|
239
|
+
@owner.workflow_state_data[key] = {
|
|
240
|
+
snapshot: @owner.deep_dup_workflow_value(snapshot),
|
|
241
|
+
revision: next_revision
|
|
242
|
+
}
|
|
243
|
+
next_revision
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def delete(thread_id, expected_revision:)
|
|
248
|
+
@owner.synchronize do
|
|
249
|
+
key = thread_id.to_s
|
|
250
|
+
current = @owner.workflow_state_data[key]
|
|
251
|
+
actual_revision = current&.fetch(:revision)
|
|
252
|
+
unless actual_revision == expected_revision
|
|
253
|
+
raise ConflictError,
|
|
254
|
+
"workflow state revision conflict for #{key}: " \
|
|
255
|
+
"expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
|
|
256
|
+
end
|
|
257
|
+
@owner.workflow_state_data.delete(key)
|
|
258
|
+
end
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
attr_reader :state, :workflow_state_data
|
|
213
264
|
|
|
214
265
|
def initialize
|
|
215
266
|
@monitor = Monitor.new
|
|
216
267
|
@state = {contents: {}, agents: {}, journals: {}, executions: {}}
|
|
268
|
+
@workflow_state_data = {}
|
|
217
269
|
@contents = Contents.new(self)
|
|
218
270
|
@agents = Agents.new(self)
|
|
219
271
|
@journals = Journals.new(self)
|
|
220
272
|
@executions = Executions.new(self)
|
|
221
|
-
@
|
|
222
|
-
super(
|
|
223
|
-
|
|
273
|
+
@workflow_states = WorkflowStates.new(self)
|
|
274
|
+
super(
|
|
275
|
+
contents: @contents,
|
|
276
|
+
agents: @agents,
|
|
277
|
+
journals: @journals,
|
|
278
|
+
executions: @executions,
|
|
279
|
+
workflow_states: @workflow_states
|
|
280
|
+
)
|
|
224
281
|
end
|
|
225
282
|
|
|
226
283
|
def capabilities
|
|
227
284
|
{atomic_all: true, atomic_admission: true, optimistic_revision: true}.freeze
|
|
228
285
|
end
|
|
229
286
|
|
|
287
|
+
def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
|
|
288
|
+
synchronize do
|
|
289
|
+
stored = @state[:agents][agent_id.to_s]
|
|
290
|
+
unless stored
|
|
291
|
+
raise NotFoundError, "Agent not found: #{agent_id}"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
if stored.agent_revision != agent_revision
|
|
295
|
+
raise ConflictError,
|
|
296
|
+
"agent revision conflict: expected #{agent_revision}, actual #{stored.agent_revision}"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
actual_position = Array(@state[:journals][agent_id.to_s]).length
|
|
300
|
+
if actual_position != journal_position
|
|
301
|
+
raise ConflictError,
|
|
302
|
+
"journal position conflict: expected #{journal_position}, actual #{actual_position}"
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
true
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
230
309
|
def transaction
|
|
231
310
|
synchronize do
|
|
232
|
-
|
|
311
|
+
state_snapshot = Marshal.load(Marshal.dump(@state))
|
|
312
|
+
workflow_snapshot = deep_dup_workflow_value(@workflow_state_data)
|
|
233
313
|
begin
|
|
234
314
|
yield self
|
|
235
315
|
rescue
|
|
236
|
-
@state
|
|
316
|
+
@state.replace(state_snapshot)
|
|
317
|
+
@workflow_state_data.replace(workflow_snapshot)
|
|
237
318
|
raise
|
|
238
319
|
end
|
|
239
320
|
end
|
|
@@ -242,6 +323,30 @@ module Phronomy
|
|
|
242
323
|
def synchronize(&block)
|
|
243
324
|
@monitor.synchronize(&block)
|
|
244
325
|
end
|
|
326
|
+
|
|
327
|
+
# Workflow fields historically accepted ordinary Ruby values in the
|
|
328
|
+
# in-memory store. Keep that contract without forcing the Agent durable
|
|
329
|
+
# state Marshal snapshot to serialize arbitrary Workflow values.
|
|
330
|
+
def deep_dup_workflow_value(value)
|
|
331
|
+
case value
|
|
332
|
+
when Hash
|
|
333
|
+
value.each_with_object({}) do |(key, child), result|
|
|
334
|
+
result[deep_dup_workflow_value(key)] = deep_dup_workflow_value(child)
|
|
335
|
+
end
|
|
336
|
+
when Array
|
|
337
|
+
value.map { |child| deep_dup_workflow_value(child) }
|
|
338
|
+
when NilClass, Symbol, Integer, Float, TrueClass, FalseClass
|
|
339
|
+
value
|
|
340
|
+
else
|
|
341
|
+
return value if value.frozen?
|
|
342
|
+
|
|
343
|
+
begin
|
|
344
|
+
value.dup
|
|
345
|
+
rescue TypeError
|
|
346
|
+
value
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
245
350
|
end
|
|
246
351
|
end
|
|
247
352
|
end
|
data/lib/phronomy/persistence.rb
CHANGED
|
@@ -6,14 +6,14 @@ module Phronomy
|
|
|
6
6
|
class NotFoundError < Phronomy::Error; end
|
|
7
7
|
class UnsupportedBackendError < Phronomy::Error; end
|
|
8
8
|
|
|
9
|
-
attr_reader :contents, :agents, :journals, :executions, :
|
|
9
|
+
attr_reader :contents, :agents, :journals, :executions, :workflow_states
|
|
10
10
|
|
|
11
|
-
def initialize(contents:, agents:, journals:, executions:,
|
|
11
|
+
def initialize(contents:, agents:, journals:, executions:, workflow_states:)
|
|
12
12
|
@contents = contents
|
|
13
13
|
@agents = agents
|
|
14
14
|
@journals = journals
|
|
15
15
|
@executions = executions
|
|
16
|
-
@
|
|
16
|
+
@workflow_states = workflow_states
|
|
17
17
|
validate_capabilities!
|
|
18
18
|
end
|
|
19
19
|
|
|
@@ -25,6 +25,15 @@ module Phronomy
|
|
|
25
25
|
raise UnsupportedBackendError, "#{self.class} does not provide atomic_all"
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Verifies that a live Agent still owns the durable base it hydrated.
|
|
29
|
+
# Backends should implement this as a revision/position precondition check,
|
|
30
|
+
# not as a state reload returned to the caller.
|
|
31
|
+
# @api private
|
|
32
|
+
def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
|
|
33
|
+
raise UnsupportedBackendError,
|
|
34
|
+
"#{self.class} does not provide Agent durable-watermark checks"
|
|
35
|
+
end
|
|
36
|
+
|
|
28
37
|
private
|
|
29
38
|
|
|
30
39
|
def validate_capabilities!
|
|
@@ -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,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
|
|
@@ -2,32 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Testing
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# Replaces real +Process.clock_gettime+ calls so that time-sensitive code
|
|
8
|
-
# can be tested without relying on wall-clock sleeps.
|
|
9
|
-
#
|
|
10
|
-
# @example
|
|
11
|
-
# clock = Phronomy::Testing::FakeClock.new
|
|
12
|
-
# clock.now # => 0.0
|
|
13
|
-
# clock.advance(5) # advance by 5 seconds
|
|
14
|
-
# clock.now # => 5.0
|
|
5
|
+
# Deterministic manually-advanced clock for tests.
|
|
15
6
|
class FakeClock
|
|
16
|
-
# @return [Float] the current logical time in seconds since the epoch (t=0)
|
|
17
7
|
attr_reader :now
|
|
18
8
|
|
|
19
9
|
def initialize
|
|
20
10
|
@now = 0.0
|
|
21
|
-
@callbacks = []
|
|
11
|
+
@callbacks = []
|
|
22
12
|
@mutex = Mutex.new
|
|
23
13
|
end
|
|
24
14
|
|
|
25
|
-
# Advance the clock by +seconds+ and fire any registered callbacks whose
|
|
26
|
-
# deadline has passed.
|
|
27
|
-
#
|
|
28
|
-
# @param seconds [Numeric]
|
|
29
|
-
# @return [self]
|
|
30
|
-
# @api private
|
|
31
15
|
def advance(seconds)
|
|
32
16
|
@mutex.synchronize do
|
|
33
17
|
@now += seconds.to_f
|
|
@@ -36,65 +20,34 @@ module Phronomy
|
|
|
36
20
|
self
|
|
37
21
|
end
|
|
38
22
|
|
|
39
|
-
# Register a one-shot callback that fires when the clock reaches +at+.
|
|
40
|
-
#
|
|
41
|
-
# @param at [Numeric] logical time to fire
|
|
42
|
-
# @yield called with no arguments when the clock reaches +at+
|
|
43
|
-
# @return [self]
|
|
44
|
-
# @api private
|
|
45
23
|
def at(at, &block)
|
|
46
24
|
@mutex.synchronize { @callbacks << [at.to_f, block] }
|
|
47
25
|
self
|
|
48
26
|
end
|
|
49
27
|
|
|
50
|
-
# Schedule a one-shot callback to fire after +seconds+ from the current
|
|
51
|
-
# logical time. This is the same interface as {Runtime::TimerQueue#schedule}
|
|
52
|
-
# so that a +FakeClock+ can be passed as a +timer_queue:+ argument in tests.
|
|
53
|
-
#
|
|
54
|
-
# @param seconds [Numeric] delay in logical seconds
|
|
55
|
-
# @yield called when the clock reaches the scheduled time
|
|
56
|
-
# @return [self]
|
|
57
|
-
# @api private
|
|
58
28
|
def schedule(seconds:, &block)
|
|
59
29
|
at(@now + seconds.to_f, &block)
|
|
60
30
|
end
|
|
61
31
|
|
|
62
|
-
# Returns the number of pending (un-fired) callbacks.
|
|
63
|
-
# @return [Integer]
|
|
64
|
-
# @api private
|
|
65
32
|
def pending_callbacks
|
|
66
33
|
@mutex.synchronize { @callbacks.size }
|
|
67
34
|
end
|
|
68
35
|
|
|
69
|
-
# Returns the logical time of the next pending callback, or +nil+ if
|
|
70
|
-
# there are no pending callbacks.
|
|
71
|
-
#
|
|
72
|
-
# @return [Float, nil]
|
|
73
|
-
# @api private
|
|
74
36
|
def next_timer_at
|
|
75
37
|
@mutex.synchronize { @callbacks.min_by { |(t, _)| t }&.first }
|
|
76
38
|
end
|
|
77
39
|
|
|
78
|
-
# Advance the clock exactly to the next pending callback and fire it.
|
|
79
|
-
# Raises +RuntimeError+ when there are no pending callbacks.
|
|
80
|
-
#
|
|
81
|
-
# @return [self]
|
|
82
|
-
# @api private
|
|
83
40
|
def advance_to_next_timer
|
|
84
41
|
target = next_timer_at
|
|
85
42
|
raise "No pending timers to advance to" unless target
|
|
86
|
-
|
|
87
43
|
advance(target - @now)
|
|
88
44
|
end
|
|
89
45
|
|
|
90
|
-
# Returns descriptive entries for all pending callbacks.
|
|
91
|
-
# Used by {Phronomy::Runtime::FakeScheduler#pending_timers}.
|
|
92
|
-
#
|
|
93
|
-
# @return [Array<Hash>] each entry: +{ fire_at:, description: nil }+
|
|
94
|
-
# @api private
|
|
95
46
|
def pending_timer_entries
|
|
96
47
|
@mutex.synchronize do
|
|
97
|
-
@callbacks.sort_by { |(t, _)| t }.map
|
|
48
|
+
@callbacks.sort_by { |(t, _)| t }.map do |(time, _)|
|
|
49
|
+
{fire_at: time, description: nil}
|
|
50
|
+
end
|
|
98
51
|
end
|
|
99
52
|
end
|
|
100
53
|
|
|
@@ -102,7 +55,7 @@ module Phronomy
|
|
|
102
55
|
|
|
103
56
|
def fire_expired_callbacks!
|
|
104
57
|
fired, @callbacks = @callbacks.partition { |(t, _)| t <= @now }
|
|
105
|
-
fired.sort_by { |(t, _)| t }.each { |(_,
|
|
58
|
+
fired.sort_by { |(t, _)| t }.each { |(_, callback)| callback.call }
|
|
106
59
|
end
|
|
107
60
|
end
|
|
108
61
|
end
|
data/lib/phronomy/testing.rb
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
# Test helpers
|
|
5
|
-
#
|
|
6
|
-
# @example
|
|
7
|
-
# require "phronomy/testing"
|
|
8
|
-
# clock = Phronomy::Testing::FakeClock.new
|
|
9
|
-
# scheduler = Phronomy::Testing::FakeScheduler.new
|
|
4
|
+
# Test-only helpers. Nothing under this namespace participates in the
|
|
5
|
+
# production Runtime control plane.
|
|
10
6
|
module Testing
|
|
11
7
|
end
|
|
12
8
|
end
|