phronomy 0.17.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -1134
- data/README.md +68 -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/010-cooperative-first-concurrency.md +155 -235
- data/docs/features.md +87 -0
- data/docs/getting-started.md +351 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/runtime-and-concurrency.md +258 -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 +35 -19
- data/lib/phronomy/agent/context/capability/base.rb +13 -3
- data/lib/phronomy/agent/execution_coordinator.rb +6 -6
- 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 +5 -29
- 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 +89 -190
- 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 +45 -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/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_runner.rb +2 -4
- data/lib/phronomy.rb +7 -4
- data/scripts/api_snapshot.rb +4 -4
- metadata +23 -31
- 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/testing/fake_scheduler.rb +0 -104
- data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Eval
|
|
5
|
-
# Represents a single evaluation sample with an input, an expected output,
|
|
6
|
-
# and optional freeform metadata.
|
|
7
|
-
#
|
|
8
|
-
# @example
|
|
9
|
-
# EvalCase.new(input: "What is 2+2?", expected: "4")
|
|
10
|
-
# EvalCase.new(input: "Hello", expected: "Hi", metadata: { difficulty: :easy })
|
|
11
|
-
EvalCase = Data.define(:input, :expected, :metadata) do
|
|
12
|
-
def initialize(input:, expected:, metadata: {})
|
|
13
|
-
super
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Eval
|
|
5
|
-
# An immutable record holding the outcome of evaluating one EvalCase.
|
|
6
|
-
#
|
|
7
|
-
# @!attribute eval_case [EvalCase] the original sample
|
|
8
|
-
# @!attribute actual [String] the callable's output
|
|
9
|
-
# @!attribute score [Float] scorer-assigned value in [0.0, 1.0]
|
|
10
|
-
# @!attribute usage [Phronomy::TokenUsage, nil]
|
|
11
|
-
# @!attribute latency_ms [Integer] wall-clock time of the callable in ms
|
|
12
|
-
# @!attribute error [Exception, nil] set when the scorer raised an exception
|
|
13
|
-
EvalResult = Data.define(:eval_case, :actual, :score, :usage, :latency_ms, :error) do
|
|
14
|
-
def initialize(eval_case:, actual:, score:, usage:, latency_ms:, error: nil)
|
|
15
|
-
super
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
# Returns true when the scorer assigned a perfect score of 1.0.
|
|
19
|
-
def pass?
|
|
20
|
-
score >= 1.0
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Returns true when the scorer raised an exception.
|
|
24
|
-
def scorer_error?
|
|
25
|
-
!error.nil?
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Eval
|
|
5
|
-
# Aggregates a collection of EvalResult objects into summary statistics.
|
|
6
|
-
#
|
|
7
|
-
# @example
|
|
8
|
-
# metrics = Metrics.new(results)
|
|
9
|
-
# puts metrics.pass_rate # => 0.8
|
|
10
|
-
# puts metrics.average_score # => 0.9
|
|
11
|
-
# puts metrics.to_h
|
|
12
|
-
class Metrics
|
|
13
|
-
# @param results [Array<EvalResult>]
|
|
14
|
-
# @api public
|
|
15
|
-
def initialize(results)
|
|
16
|
-
@results = results
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# Fraction of results that passed (score == 1.0).
|
|
20
|
-
# @return [Float] in [0.0, 1.0]
|
|
21
|
-
# @api public
|
|
22
|
-
def pass_rate
|
|
23
|
-
return 0.0 if @results.empty?
|
|
24
|
-
@results.count(&:pass?).to_f / @results.size
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Arithmetic mean of all scores.
|
|
28
|
-
# @return [Float]
|
|
29
|
-
# @api public
|
|
30
|
-
def average_score
|
|
31
|
-
return 0.0 if @results.empty?
|
|
32
|
-
@results.sum(&:score) / @results.size
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Sum of all TokenUsage objects present in the results.
|
|
36
|
-
# Results without usage are skipped.
|
|
37
|
-
# @return [Phronomy::TokenUsage]
|
|
38
|
-
# @api public
|
|
39
|
-
def total_usage
|
|
40
|
-
@results.map(&:usage).compact.reduce(TokenUsage.zero, :+)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Arithmetic mean of latency_ms across all results.
|
|
44
|
-
# @return [Float]
|
|
45
|
-
# @api public
|
|
46
|
-
def average_latency_ms
|
|
47
|
-
return 0.0 if @results.empty?
|
|
48
|
-
@results.sum(&:latency_ms).to_f / @results.size
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Returns a plain Hash summary suitable for logging or serialisation.
|
|
52
|
-
# @return [Hash]
|
|
53
|
-
# @api public
|
|
54
|
-
def to_h
|
|
55
|
-
{
|
|
56
|
-
total: @results.size,
|
|
57
|
-
pass_count: @results.count(&:pass?),
|
|
58
|
-
pass_rate: pass_rate,
|
|
59
|
-
average_score: average_score,
|
|
60
|
-
total_usage: total_usage.to_h,
|
|
61
|
-
average_latency_ms: average_latency_ms
|
|
62
|
-
}
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
data/lib/phronomy/eval/runner.rb
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Eval
|
|
5
|
-
# Runs a Dataset through a callable and collects EvalResult objects.
|
|
6
|
-
#
|
|
7
|
-
# The callable must respond to +#call(input)+ and may return either:
|
|
8
|
-
# * a plain +String+ — treated as the output; usage is nil
|
|
9
|
-
# * a +Hash+ with +:output+ and optional +:usage+ (TokenUsage) keys
|
|
10
|
-
#
|
|
11
|
-
# @example With a simple proc
|
|
12
|
-
# runner = Runner.new(scorer: Scorer::ExactMatch.new)
|
|
13
|
-
# dataset = Dataset.from_array([{ input: "2+2", expected: "4" }])
|
|
14
|
-
# results = runner.run(dataset, ->(input) { "4" })
|
|
15
|
-
#
|
|
16
|
-
# @example With a Phronomy agent
|
|
17
|
-
# agent = MyAgent.new
|
|
18
|
-
# results = runner.run(dataset, ->(input) { agent.invoke(input) })
|
|
19
|
-
class Runner
|
|
20
|
-
# @param scorer [Scorer::Base] scorer used to evaluate each result
|
|
21
|
-
# @api public
|
|
22
|
-
def initialize(scorer: Scorer::ExactMatch.new)
|
|
23
|
-
@scorer = scorer
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# @param dataset [Dataset] collection of EvalCase objects
|
|
27
|
-
# @param callable [#call] accepts a single String argument
|
|
28
|
-
# @param concurrency [Integer] number of parallel threads (default: 1, sequential)
|
|
29
|
-
# @return [Array<EvalResult>]
|
|
30
|
-
# @api public
|
|
31
|
-
# mutant:disable - concurrency default value mutations (0/2) are genuine equivalent because sequential and concurrent paths produce identical results; if concurrency<=1 boundary mutations (==1 / <1 / <=0 / .eql? / .equal? / false / nil / <=2) are genuine equivalent because the concurrent path with concurrency=1 still produces the same Array<EvalResult> via each_slice(1); spawn name: mutations are genuine equivalent (name is only used for logging)
|
|
32
|
-
def run(dataset, callable, concurrency: 1)
|
|
33
|
-
cases = dataset.to_a
|
|
34
|
-
return cases.map { |eval_case| run_one(eval_case, callable) } if concurrency <= 1
|
|
35
|
-
|
|
36
|
-
# Run cases in slices of +concurrency+ tasks. Each slice is joined
|
|
37
|
-
# before the next starts, bounding peak task count to +concurrency+.
|
|
38
|
-
# Writing to pre-allocated slots (one per task) is safe because each
|
|
39
|
-
# task writes to a unique index and all tasks in a slice are joined
|
|
40
|
-
# before the next slice begins.
|
|
41
|
-
# Exceptions in worker tasks are collected and re-raised after all
|
|
42
|
-
# tasks in the slice are joined, preventing orphaned tasks.
|
|
43
|
-
results = Array.new(cases.length)
|
|
44
|
-
cases.each_with_index.each_slice(concurrency) do |batch|
|
|
45
|
-
errors = []
|
|
46
|
-
errors_mu = Mutex.new
|
|
47
|
-
tasks = batch.map do |eval_case, i|
|
|
48
|
-
Phronomy::Runtime.instance.spawn(name: "eval-case-#{i}") do
|
|
49
|
-
results[i] = run_one(eval_case, callable)
|
|
50
|
-
rescue => e
|
|
51
|
-
errors_mu.synchronize { errors << e }
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
tasks.each(&:join)
|
|
55
|
-
raise errors.first if errors.any?
|
|
56
|
-
end
|
|
57
|
-
results
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
private
|
|
61
|
-
|
|
62
|
-
# Evaluate a single EvalCase with the given callable and return an EvalResult.
|
|
63
|
-
# mutant:disable - multiple genuine equivalent mutations: latency_ms=+t0 or =t0 are genuine because :millisecond makes all values Integer so be_a(Integer) passes; (actual,usage)=result is genuine because Ruby multi-assign of a String yields usage=nil identical to extract(); score_safely input: nil/eval_case/absent are genuine because ExactMatch and IncludesScorer ignore the :input kwarg; EvalResult error: nil/absent and usage: nil are genuine because on a successful score run score_error and usage are already nil
|
|
64
|
-
def run_one(eval_case, callable)
|
|
65
|
-
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
66
|
-
result = callable.call(eval_case.input)
|
|
67
|
-
latency_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - t0
|
|
68
|
-
|
|
69
|
-
actual, usage = extract(result)
|
|
70
|
-
score, score_error = score_safely(@scorer, actual: actual, expected: eval_case.expected, input: eval_case.input)
|
|
71
|
-
|
|
72
|
-
EvalResult.new(eval_case: eval_case, actual: actual, score: score, usage: usage, latency_ms: latency_ms, error: score_error)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Normalises the callable's return value into [actual_string, usage_or_nil].
|
|
76
|
-
# mutant:disable - multiple genuine equivalent mutations: is_a?(Hash) vs instance_of?(Hash) (no Hash subclass in practice); to_s vs to_str (String only); result[:output]/[:usage] vs .fetch(:output)/[:usage] (keys always present when is_a?(Hash)); [result.to_s, nil] vs [result.to_s] because actual,usage=[val] → usage=nil via Ruby multi-assign; result.to_s vs result.to_str for String-only values
|
|
77
|
-
def extract(result)
|
|
78
|
-
if result.is_a?(Hash)
|
|
79
|
-
[result[:output].to_s, result[:usage]]
|
|
80
|
-
else
|
|
81
|
-
[result.to_s, nil]
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# Calls the scorer and returns [score, error]. On failure, returns [0.0, exception].
|
|
86
|
-
# mutant:disable - [scorer.score(**kwargs), nil] vs [scorer.score(**kwargs)]: because score,error=[val] → error=nil via Ruby multi-assign; both produce the same destructuring in the caller
|
|
87
|
-
def score_safely(scorer, **kwargs)
|
|
88
|
-
[scorer.score(**kwargs), nil]
|
|
89
|
-
rescue => e
|
|
90
|
-
[0.0, e]
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Eval
|
|
5
|
-
module Scorer
|
|
6
|
-
# Abstract base class for all scorers.
|
|
7
|
-
# Subclasses must implement {#score}.
|
|
8
|
-
class Base
|
|
9
|
-
# Scores an actual output against the expected output.
|
|
10
|
-
#
|
|
11
|
-
# @param actual [String] the callable's output
|
|
12
|
-
# @param expected [String] the ground-truth value from the EvalCase
|
|
13
|
-
# @param input [String, nil] the original input (used by LLM scorers)
|
|
14
|
-
# @return [Float] a value in [0.0, 1.0]
|
|
15
|
-
# @api public
|
|
16
|
-
def score(actual:, expected:, input: nil)
|
|
17
|
-
raise NotImplementedError, "#{self.class}#score is not implemented"
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Eval
|
|
5
|
-
module Scorer
|
|
6
|
-
# Scorer that returns 1.0 when the actual output exactly matches the
|
|
7
|
-
# expected output (after stripping leading/trailing whitespace).
|
|
8
|
-
# Comparison is case-sensitive by default.
|
|
9
|
-
#
|
|
10
|
-
# @example
|
|
11
|
-
# ExactMatch.new.score(actual: "Paris", expected: "Paris") # => 1.0
|
|
12
|
-
# ExactMatch.new.score(actual: "paris", expected: "Paris") # => 0.0
|
|
13
|
-
class ExactMatch < Base
|
|
14
|
-
# @param case_sensitive [Boolean] default true
|
|
15
|
-
# @api public
|
|
16
|
-
def initialize(case_sensitive: true)
|
|
17
|
-
@case_sensitive = case_sensitive
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# @return [Float] 1.0 on match, 0.0 otherwise
|
|
21
|
-
# @api public
|
|
22
|
-
def score(actual:, expected:, input: nil)
|
|
23
|
-
a = actual.to_s.strip
|
|
24
|
-
e = expected.to_s.strip
|
|
25
|
-
a = a.downcase and e = e.downcase unless @case_sensitive
|
|
26
|
-
(a == e) ? 1.0 : 0.0
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Eval
|
|
5
|
-
module Scorer
|
|
6
|
-
# Scorer that returns 1.0 when the actual output contains the expected
|
|
7
|
-
# substring (case-insensitive by default).
|
|
8
|
-
#
|
|
9
|
-
# Useful for open-ended outputs where the exact wording may vary but a
|
|
10
|
-
# key term or phrase must be present.
|
|
11
|
-
#
|
|
12
|
-
# @example
|
|
13
|
-
# IncludesScorer.new.score(actual: "The answer is 42.", expected: "42") # => 1.0
|
|
14
|
-
class IncludesScorer < Base
|
|
15
|
-
# @param case_sensitive [Boolean] default false
|
|
16
|
-
# @api public
|
|
17
|
-
def initialize(case_sensitive: false)
|
|
18
|
-
@case_sensitive = case_sensitive
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# @return [Float] 1.0 if actual contains expected, 0.0 otherwise
|
|
22
|
-
# @api public
|
|
23
|
-
def score(actual:, expected:, input: nil)
|
|
24
|
-
a = actual.to_s
|
|
25
|
-
e = expected.to_s
|
|
26
|
-
a = a.downcase and e = e.downcase unless @case_sensitive
|
|
27
|
-
a.include?(e) ? 1.0 : 0.0
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Eval
|
|
5
|
-
module Scorer
|
|
6
|
-
# LLM-as-a-Judge scorer.
|
|
7
|
-
# Sends a structured prompt to an LLM and interprets its numeric reply
|
|
8
|
-
# as a quality score in [0.0, 1.0].
|
|
9
|
-
#
|
|
10
|
-
# The prompt template accepts three named placeholders:
|
|
11
|
-
# %<input>s — the original input question
|
|
12
|
-
# %<expected>s — the ground-truth / reference answer
|
|
13
|
-
# %<actual>s — the output being evaluated
|
|
14
|
-
#
|
|
15
|
-
# The LLM is expected to reply with a single decimal number; any extra
|
|
16
|
-
# text is stripped and the value is clamped to [0.0, 1.0].
|
|
17
|
-
# If parsing fails the scorer returns 0.0 rather than raising.
|
|
18
|
-
#
|
|
19
|
-
# @example
|
|
20
|
-
# judge = LlmJudge.new(model: "gpt-4o-mini")
|
|
21
|
-
# judge.score(actual: "Paris", expected: "Paris", input: "Capital of France?")
|
|
22
|
-
class LlmJudge < Base
|
|
23
|
-
DEFAULT_PROMPT = <<~PROMPT
|
|
24
|
-
You are an impartial judge evaluating the quality of an AI assistant response.
|
|
25
|
-
Rate the response on a scale from 0.0 (completely wrong or unhelpful) to 1.0 (perfect).
|
|
26
|
-
Respond with ONLY a single decimal number between 0.0 and 1.0 — no other text.
|
|
27
|
-
|
|
28
|
-
Question: %<input>s
|
|
29
|
-
Expected answer: %<expected>s
|
|
30
|
-
Actual response: %<actual>s
|
|
31
|
-
|
|
32
|
-
Score:
|
|
33
|
-
PROMPT
|
|
34
|
-
|
|
35
|
-
# @param model [String] RubyLLM model identifier
|
|
36
|
-
# @param prompt_template [String] format string with %<input>s, %<expected>s, %<actual>s
|
|
37
|
-
# @param raise_on_error [Boolean] when true, re-raises scoring exceptions instead of
|
|
38
|
-
# returning 0.0. Use this in batch eval pipelines where silent failures are unacceptable.
|
|
39
|
-
# @api public
|
|
40
|
-
def initialize(model:, prompt_template: DEFAULT_PROMPT, raise_on_error: false)
|
|
41
|
-
@model = model
|
|
42
|
-
@prompt_template = prompt_template
|
|
43
|
-
@raise_on_error = raise_on_error
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# @return [Float] score in [0.0, 1.0]; 0.0 on error when raise_on_error is false
|
|
47
|
-
# @api public
|
|
48
|
-
# mutant:disable - multiple genuine equivalent mutations:
|
|
49
|
-
# actual.to_str / actual: (shorthand) are genuine (callers pass String);
|
|
50
|
-
# expected.to_str / expected: are genuine (String);
|
|
51
|
-
# response.content.strip (no to_s) is genuine (content is String);
|
|
52
|
-
# lstrip/rstrip/no-strip are genuine (whitespace doesn't affect number scanning);
|
|
53
|
-
# scan(/-?\d\.?\d*/) is genuine (for [0,1] range responses, single-digit-before-decimal
|
|
54
|
-
# matches are the same after clamp);
|
|
55
|
-
# response.content.to_str.strip is genuine (String);
|
|
56
|
-
# all warn variations (warn no-arg, warn(nil), warn(e), warn(nil literal),
|
|
57
|
-
# nil-replacing-warn, warn-deletion) are genuine because the rescue block
|
|
58
|
-
# still returns 0.0 — warn is a side-effect not tested by value assertions
|
|
59
|
-
def score(actual:, expected:, input: nil)
|
|
60
|
-
prompt = format(@prompt_template, input: input.to_s, expected: expected.to_s, actual: actual.to_s)
|
|
61
|
-
response = Phronomy::Runtime.instance.blocking_io.submit { RubyLLM.chat(model: @model).ask(prompt) }.blocking_wait
|
|
62
|
-
response.content.to_s.strip.scan(/-?\d+\.?\d*/).first.to_f.clamp(0.0, 1.0)
|
|
63
|
-
rescue => e
|
|
64
|
-
raise if @raise_on_error
|
|
65
|
-
|
|
66
|
-
warn "[LlmJudge] Scoring failed: #{e.message}"
|
|
67
|
-
0.0
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
data/lib/phronomy/eval/scorer.rb
DELETED
data/lib/phronomy/eval.rb
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Testing
|
|
5
|
-
# A deterministic event dispatcher for use in tests.
|
|
6
|
-
#
|
|
7
|
-
# Wraps a {Thread::Queue} and dispatches events one at a time via {#tick}
|
|
8
|
-
# or drains all pending events via {#tick_until_idle}. Tests can inspect
|
|
9
|
-
# queue depth and verify event ordering without wall-clock sleeps.
|
|
10
|
-
#
|
|
11
|
-
# @example
|
|
12
|
-
# scheduler = Phronomy::Testing::FakeScheduler.new
|
|
13
|
-
# scheduler.post(:a)
|
|
14
|
-
# scheduler.post(:b)
|
|
15
|
-
# scheduler.queue_depth # => 2
|
|
16
|
-
# scheduler.tick # dispatches :a
|
|
17
|
-
# scheduler.queue_depth # => 1
|
|
18
|
-
# scheduler.tick_until_idle
|
|
19
|
-
# scheduler.dispatched # => [:a, :b]
|
|
20
|
-
class FakeScheduler
|
|
21
|
-
# @return [Array] all events dispatched so far (in order)
|
|
22
|
-
attr_reader :dispatched
|
|
23
|
-
|
|
24
|
-
def initialize
|
|
25
|
-
@queue = Thread::Queue.new
|
|
26
|
-
@dispatched = []
|
|
27
|
-
@handlers = {}
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Enqueue an event for later dispatch.
|
|
31
|
-
#
|
|
32
|
-
# @param event [Object]
|
|
33
|
-
# @return [self]
|
|
34
|
-
# @api private
|
|
35
|
-
def post(event)
|
|
36
|
-
@queue.push(event)
|
|
37
|
-
self
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Dispatch the next queued event.
|
|
41
|
-
# Calls the registered handler (if any) and records the event.
|
|
42
|
-
# Returns the dispatched event, or +nil+ if the queue is empty.
|
|
43
|
-
#
|
|
44
|
-
# @return [Object, nil]
|
|
45
|
-
# @api private
|
|
46
|
-
def tick
|
|
47
|
-
return nil if @queue.empty?
|
|
48
|
-
|
|
49
|
-
event = begin
|
|
50
|
-
@queue.pop(true)
|
|
51
|
-
rescue
|
|
52
|
-
nil
|
|
53
|
-
end
|
|
54
|
-
return nil unless event
|
|
55
|
-
|
|
56
|
-
@dispatched << event
|
|
57
|
-
handler = @handlers[event.class] || @handlers[:any]
|
|
58
|
-
handler&.call(event)
|
|
59
|
-
event
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Dispatch events until the queue is empty.
|
|
63
|
-
# Bounded by +max_ticks+ to prevent infinite loops.
|
|
64
|
-
#
|
|
65
|
-
# @param max_ticks [Integer]
|
|
66
|
-
# @return [Integer] number of events dispatched
|
|
67
|
-
# @api private
|
|
68
|
-
def tick_until_idle(max_ticks: 1000)
|
|
69
|
-
count = 0
|
|
70
|
-
while !@queue.empty? && count < max_ticks
|
|
71
|
-
tick
|
|
72
|
-
count += 1
|
|
73
|
-
end
|
|
74
|
-
count
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Returns the number of events waiting to be dispatched.
|
|
78
|
-
# @return [Integer]
|
|
79
|
-
# @api private
|
|
80
|
-
def queue_depth
|
|
81
|
-
@queue.size
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Register a handler block for events of the given class.
|
|
85
|
-
# Use +:any+ to handle all event types.
|
|
86
|
-
#
|
|
87
|
-
# @param klass [Class, :any]
|
|
88
|
-
# @yield [event]
|
|
89
|
-
# @return [self]
|
|
90
|
-
# @api private
|
|
91
|
-
def on(klass, &block)
|
|
92
|
-
@handlers[klass] = block
|
|
93
|
-
self
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
# Returns true when the queue is empty.
|
|
97
|
-
# @return [Boolean]
|
|
98
|
-
# @api private
|
|
99
|
-
def idle?
|
|
100
|
-
@queue.empty?
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Testing
|
|
5
|
-
# RSpec helper module that provides a deterministic {Runtime} backed by
|
|
6
|
-
# {Phronomy::Runtime::FakeScheduler}.
|
|
7
|
-
#
|
|
8
|
-
# Include this module in your RSpec describe/context blocks and call
|
|
9
|
-
# {#with_fake_scheduler} to run a block of code inside a fully
|
|
10
|
-
# synchronous, event-logged runtime.
|
|
11
|
-
#
|
|
12
|
-
# @example Basic usage (no clock)
|
|
13
|
-
# include Phronomy::Testing::SchedulerHelpers
|
|
14
|
-
#
|
|
15
|
-
# it "records completed events" do
|
|
16
|
-
# with_fake_scheduler do |sched|
|
|
17
|
-
# Phronomy::Runtime.instance.spawn(name: "my-task") { 42 }
|
|
18
|
-
# expect(sched.event_log.map { |e| e[:type] }).to include(:completed)
|
|
19
|
-
# end
|
|
20
|
-
# end
|
|
21
|
-
#
|
|
22
|
-
# @example With a FakeClock
|
|
23
|
-
# include Phronomy::Testing::SchedulerHelpers
|
|
24
|
-
#
|
|
25
|
-
# it "surfaces pending timers" do
|
|
26
|
-
# clock = Phronomy::Testing::FakeClock.new
|
|
27
|
-
# with_fake_scheduler(clock: clock) do |sched|
|
|
28
|
-
# clock.schedule(seconds: 5) { :fired }
|
|
29
|
-
# expect(sched.pending_timers.first[:fire_at]).to eq(5.0)
|
|
30
|
-
# end
|
|
31
|
-
# end
|
|
32
|
-
module SchedulerHelpers
|
|
33
|
-
# Run +block+ with a {Phronomy::Runtime} that uses
|
|
34
|
-
# {Phronomy::Runtime::FakeScheduler}.
|
|
35
|
-
#
|
|
36
|
-
# The global runtime is replaced for the duration of the block and
|
|
37
|
-
# restored afterwards, whether the block raises or not.
|
|
38
|
-
#
|
|
39
|
-
# @param clock [Phronomy::Testing::FakeClock, nil]
|
|
40
|
-
# Optional fake clock to inject into the scheduler for timer support
|
|
41
|
-
# and event timestamping.
|
|
42
|
-
# @yield [scheduler, clock] the {Runtime::FakeScheduler} and the clock
|
|
43
|
-
# @return [Object] the return value of the block
|
|
44
|
-
# @api private
|
|
45
|
-
def with_fake_scheduler(clock: nil)
|
|
46
|
-
scheduler = Phronomy::Runtime::FakeScheduler.new
|
|
47
|
-
scheduler.clock = clock if clock
|
|
48
|
-
runtime = Phronomy::Runtime.new(scheduler: scheduler)
|
|
49
|
-
original = Phronomy::Runtime.default_if_initialized_for_test
|
|
50
|
-
Phronomy::Runtime.replace_default_for_test(runtime)
|
|
51
|
-
begin
|
|
52
|
-
yield scheduler, clock
|
|
53
|
-
ensure
|
|
54
|
-
result = nil
|
|
55
|
-
begin
|
|
56
|
-
result = runtime.shutdown
|
|
57
|
-
ensure
|
|
58
|
-
Phronomy::Runtime.restore_default_for_test(original)
|
|
59
|
-
end
|
|
60
|
-
unless result&.cleanup_complete?
|
|
61
|
-
raise Phronomy::RuntimeShutdownError,
|
|
62
|
-
"Temporary test Runtime did not shut down completely"
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|