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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +151 -1134
- data/CONTRIBUTING.md +28 -16
- data/README.md +69 -1249
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +4 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +26 -6
- data/benchmark/bench_tool_schema.rb +2 -35
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- 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.rb +2 -36
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
- data/lib/phronomy/agent/agent_root.rb +1 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +170 -333
- data/lib/phronomy/agent/context/capability/base.rb +177 -298
- data/lib/phronomy/agent/context_assembler.rb +65 -29
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
- data/lib/phronomy/agent/context_plan_validator.rb +0 -33
- data/lib/phronomy/agent/execution_coordinator.rb +6 -7
- data/lib/phronomy/agent/journal_projection.rb +28 -2
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
- data/lib/phronomy/agent/shared_state.rb +48 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +180 -336
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/agent.rb +6 -10
- data/lib/phronomy/configuration.rb +4 -171
- 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 +51 -79
- 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 +49 -377
- 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/llm_context_window/token_budget.rb +8 -79
- 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 +252 -256
- data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
- 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/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +2 -4
- data/lib/phronomy.rb +7 -121
- data/scripts/api_snapshot.rb +4 -15
- metadata +24 -38
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
- 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 -265
- 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/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Eval
|
|
5
|
-
# Runs the same Dataset against two callables (configuration A and B) and
|
|
6
|
-
# packages the paired results for side-by-side comparison.
|
|
7
|
-
#
|
|
8
|
-
# @example
|
|
9
|
-
# cmp = Comparison.new(scorer: Scorer::ExactMatch.new)
|
|
10
|
-
# pairs = cmp.compare(dataset, callable_a, callable_b)
|
|
11
|
-
# pairs.each do |pair|
|
|
12
|
-
# puts "A=#{pair.result_a.score} B=#{pair.result_b.score}"
|
|
13
|
-
# end
|
|
14
|
-
#
|
|
15
|
-
# # Aggregate each side independently
|
|
16
|
-
# Metrics.new(pairs.map(&:result_a)).to_h
|
|
17
|
-
class Comparison
|
|
18
|
-
# Holds the two EvalResults for a single EvalCase.
|
|
19
|
-
ComparisonPair = Data.define(:eval_case, :result_a, :result_b)
|
|
20
|
-
|
|
21
|
-
# @param scorer [Scorer::Base]
|
|
22
|
-
# @api public
|
|
23
|
-
def initialize(scorer: Scorer::ExactMatch.new)
|
|
24
|
-
@scorer = scorer
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Evaluates both callables on every case in the dataset.
|
|
28
|
-
#
|
|
29
|
-
# @param dataset [Dataset]
|
|
30
|
-
# @param callable_a [#call]
|
|
31
|
-
# @param callable_b [#call]
|
|
32
|
-
# @return [Array<ComparisonPair>]
|
|
33
|
-
# @api public
|
|
34
|
-
def compare(dataset, callable_a, callable_b)
|
|
35
|
-
runner_a = Runner.new(scorer: @scorer)
|
|
36
|
-
runner_b = Runner.new(scorer: @scorer)
|
|
37
|
-
|
|
38
|
-
results_a = runner_a.run(dataset, callable_a)
|
|
39
|
-
results_b = runner_b.run(dataset, callable_b)
|
|
40
|
-
|
|
41
|
-
results_a.zip(results_b).map do |a, b|
|
|
42
|
-
ComparisonPair.new(eval_case: a.eval_case, result_a: a, result_b: b)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Eval
|
|
5
|
-
# An ordered collection of EvalCase objects.
|
|
6
|
-
#
|
|
7
|
-
# @example Build from a plain array of hashes
|
|
8
|
-
# dataset = Dataset.from_array([
|
|
9
|
-
# { input: "What is 2+2?", expected: "4" },
|
|
10
|
-
# { input: "Capital of France?", expected: "Paris" }
|
|
11
|
-
# ])
|
|
12
|
-
class Dataset
|
|
13
|
-
include Enumerable
|
|
14
|
-
|
|
15
|
-
# @param cases [Array<EvalCase>]
|
|
16
|
-
# @api public
|
|
17
|
-
def initialize(cases = [])
|
|
18
|
-
@cases = cases.freeze
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# Constructs a Dataset from an Array of Hash-like objects.
|
|
22
|
-
# Each hash must have at least +:input+ and +:expected+ keys.
|
|
23
|
-
# An optional +:metadata+ key is forwarded as-is.
|
|
24
|
-
#
|
|
25
|
-
# @param pairs [Array<Hash>]
|
|
26
|
-
# @return [Dataset]
|
|
27
|
-
# @api public
|
|
28
|
-
def self.from_array(pairs)
|
|
29
|
-
new(pairs.map { |h| EvalCase.new(**h) })
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# @yield [EvalCase]
|
|
33
|
-
# @api public
|
|
34
|
-
def each(&block)
|
|
35
|
-
@cases.each(&block)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# @return [Integer]
|
|
39
|
-
# @api public
|
|
40
|
-
def size
|
|
41
|
-
@cases.size
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
@@ -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,12 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# KnowledgeSource provides the interface for supplying context region 3 (Knowledge)
|
|
5
|
-
# to the Context::Assembler.
|
|
6
|
-
#
|
|
7
|
-
# Each implementation returns an array of knowledge chunks via #fetch(query:).
|
|
8
|
-
# Each chunk is a Hash with :content (String) and :type (Symbol) keys.
|
|
9
|
-
# The Assembler wraps each chunk in an XML context tag before injecting it.
|
|
10
|
-
module KnowledgeSource
|
|
11
|
-
end
|
|
12
|
-
end
|