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
@@ -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
@@ -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
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Eval
5
- # Namespace module for scorer implementations.
6
- module Scorer
7
- end
8
- end
9
- end
data/lib/phronomy/eval.rb DELETED
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Namespace module for the evaluation framework.
5
- module Eval
6
- end
7
- end
@@ -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