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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -1134
  3. data/README.md +70 -1280
  4. data/benchmark/bench_regression.rb +25 -5
  5. data/docs/changelog/0.14-and-earlier.md +1137 -0
  6. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  7. data/docs/decisions/009-state-store-abstraction.md +1 -1
  8. data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
  9. data/docs/decisions/014-unified-persistence-durable-state.md +268 -0
  10. data/docs/features.md +93 -0
  11. data/docs/getting-started.md +387 -0
  12. data/docs/migrations/0.15.md +35 -0
  13. data/docs/migrations/0.16.md +43 -0
  14. data/docs/migrations/0.19.md +148 -0
  15. data/docs/runtime-and-concurrency.md +349 -0
  16. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  17. data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
  18. data/lib/phronomy/agent/async_event_api.rb +3 -3
  19. data/lib/phronomy/agent/base.rb +116 -55
  20. data/lib/phronomy/agent/context/capability/base.rb +13 -3
  21. data/lib/phronomy/agent/context_assembler.rb +13 -3
  22. data/lib/phronomy/agent/execution_coordinator.rb +423 -252
  23. data/lib/phronomy/agent/journal_projection.rb +5 -1
  24. data/lib/phronomy/agent/shared_state.rb +2 -0
  25. data/lib/phronomy/agent/tool_executor.rb +29 -71
  26. data/lib/phronomy/agent/tool_invocation.rb +97 -47
  27. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  28. data/lib/phronomy/configuration.rb +7 -30
  29. data/lib/phronomy/diagnostics.rb +12 -41
  30. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  31. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  32. data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
  33. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  34. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  35. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  36. data/lib/phronomy/engine/event_loop.rb +170 -193
  37. data/lib/phronomy/engine/fsm_session.rb +6 -4
  38. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  39. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  40. data/lib/phronomy/engine/runtime.rb +52 -158
  41. data/lib/phronomy/engine/task.rb +136 -277
  42. data/lib/phronomy/llm_adapter/base.rb +14 -14
  43. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  44. data/lib/phronomy/llm_adapter.rb +2 -2
  45. data/lib/phronomy/metrics.rb +15 -30
  46. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  47. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  48. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  49. data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
  50. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
  51. data/lib/phronomy/persistence/in_memory.rb +113 -8
  52. data/lib/phronomy/persistence.rb +12 -3
  53. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  54. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  55. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  56. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  57. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  58. data/lib/phronomy/testing/eval/runner.rb +52 -0
  59. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  60. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  61. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  62. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  63. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  64. data/lib/phronomy/testing/eval.rb +9 -0
  65. data/lib/phronomy/testing/fake_clock.rb +6 -53
  66. data/lib/phronomy/testing.rb +2 -6
  67. data/lib/phronomy/tools/agent.rb +141 -6
  68. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  69. data/lib/phronomy/vector_store/base.rb +2 -2
  70. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  71. data/lib/phronomy/version.rb +1 -1
  72. data/lib/phronomy/workflow.rb +10 -9
  73. data/lib/phronomy/workflow_runner.rb +363 -99
  74. data/lib/phronomy.rb +10 -4
  75. data/scripts/api_snapshot.rb +4 -4
  76. metadata +25 -33
  77. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  78. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  79. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  80. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  81. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  82. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  83. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  84. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  85. data/lib/phronomy/engine/task/backend.rb +0 -80
  86. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  87. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  88. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  89. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  90. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  91. data/lib/phronomy/engine/task_group.rb +0 -193
  92. data/lib/phronomy/eval/comparison.rb +0 -47
  93. data/lib/phronomy/eval/dataset.rb +0 -45
  94. data/lib/phronomy/eval/eval_case.rb +0 -17
  95. data/lib/phronomy/eval/eval_result.rb +0 -29
  96. data/lib/phronomy/eval/metrics.rb +0 -66
  97. data/lib/phronomy/eval/runner.rb +0 -94
  98. data/lib/phronomy/eval/scorer/base.rb +0 -22
  99. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  100. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  101. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  102. data/lib/phronomy/eval/scorer.rb +0 -9
  103. data/lib/phronomy/eval.rb +0 -7
  104. data/lib/phronomy/state_store/base.rb +0 -48
  105. data/lib/phronomy/state_store/in_memory.rb +0 -62
  106. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  107. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -1,157 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- class Task
5
- # Cooperative task backend using Ruby Fibers.
6
- #
7
- # Unlike {ImmediateBackend} (which runs the block to completion on the
8
- # calling thread) or {ThreadBackend} (which runs the block on a new OS
9
- # thread), +FiberBackend+ wraps the block in a +Fiber+ that is NOT started
10
- # immediately. The owning scheduler calls {#step} to advance execution one
11
- # cooperative step at a time.
12
- #
13
- # This backend is used exclusively by {Runtime::DeterministicScheduler} to
14
- # enable deterministic, wall-clock-free testing of concurrent logic.
15
- #
16
- # Thread-local key under which the currently active {DeterministicScheduler}
17
- # is stored so that {#await} can suspend cooperatively.
18
- SCHEDULER_KEY = :phronomy_deterministic_scheduler
19
-
20
- # @api private
21
- class FiberBackend < Backend
22
- def initialize(task:, &block)
23
- super
24
- @value = nil
25
- @error = nil
26
- @cancel_error = nil
27
- @cancel_requested = false
28
- @started = false
29
- @cooperative_suspend = false
30
-
31
- # Capture `self` (the FiberBackend instance) in the closure so that
32
- # instance-variable writes from inside the Fiber update this object.
33
- @fiber = Fiber.new do
34
- task.transition!(:running)
35
- begin
36
- # If cancel! was called before the first step, raise immediately.
37
- raise @cancel_error if @cancel_error
38
-
39
- @value = block.call
40
- task.transition!(:completed, value: @value)
41
- rescue CancellationError => e
42
- task.transition!(:cancelled, error: e)
43
- @error = e
44
- rescue => e
45
- task.transition!(:failed, error: e)
46
- @error = e
47
- ensure
48
- task.transition!(:cancelled) unless task.done?
49
- end
50
- end
51
- end
52
-
53
- # Advances execution by one scheduler step.
54
- # Resumes the Fiber until it yields (via +Fiber.yield+) or finishes.
55
- # Cooperative cancellation is checked at the start of each step: if
56
- # +cancel!+ has been called, +CancellationError+ is raised inside the
57
- # Fiber at this controlled checkpoint rather than injected at an
58
- # arbitrary suspension point via +Fiber#raise+.
59
- # @return [self]
60
- # @api private
61
- def step
62
- return self unless @fiber.alive?
63
-
64
- @started = true
65
- # Deliver pending cancellation at this scheduler checkpoint rather than
66
- # injecting it mid-Fiber via Fiber#raise (which would be preemptive).
67
- if @cancel_requested && @cancel_error
68
- begin
69
- @fiber.raise(@cancel_error)
70
- rescue FiberError
71
- nil # Fiber completed between the check and raise — safe to ignore.
72
- end
73
- @cancel_requested = false
74
- return self
75
- end
76
- yield_value = @fiber.resume
77
- # A yield value of :cooperative_suspend signals that the Fiber deliberately
78
- # suspended itself (e.g. inside CoopSignal#wait) and must NOT be
79
- # re-enqueued by step_callable — it will be resumed by an explicit signal.
80
- @cooperative_suspend = (yield_value == :cooperative_suspend)
81
- self
82
- end
83
-
84
- # Returns +true+ if the Fiber yielded cooperatively (via a signal wait)
85
- # and should not be automatically re-enqueued by the scheduler.
86
- # @return [Boolean]
87
- # @api private
88
- def cooperative_suspend?
89
- @cooperative_suspend
90
- end
91
-
92
- # Blocks until the task completes.
93
- #
94
- # When called from within a {DeterministicScheduler}-managed Fiber,
95
- # suspends the current Fiber cooperatively and schedules it to resume
96
- # when this task completes. When called from outside a managed Fiber
97
- # (e.g. the main fiber or a regular thread), drives execution by calling
98
- # {#step} in a loop.
99
- #
100
- # @return [Object]
101
- # @raise [Exception]
102
- # @api private
103
- def wait_result
104
- unless @fiber.alive?
105
- raise @error if @error
106
- return @value
107
- end
108
-
109
- scheduler = Thread.current.thread_variable_get(SCHEDULER_KEY)
110
- # Fiber.main was added in Ruby 3.2.4+; fall back to true (assume we are
111
- # inside a managed Fiber whenever a scheduler is active).
112
- in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
113
- if scheduler && in_managed_fiber
114
- # Cooperative context: suspend current Fiber until task is done.
115
- waiting_fiber = Fiber.current
116
- @task.on_complete { scheduler.enqueue_fiber(-> { waiting_fiber.resume }) }
117
- Fiber.yield(:cooperative_suspend)
118
- else
119
- # Non-cooperative context: drive the fiber to completion.
120
- step while @fiber.alive?
121
- end
122
-
123
- raise @error if @error
124
- @value
125
- end
126
-
127
- # @return [Boolean] +true+ while the Fiber has not yet finished
128
- # @api private
129
- def alive?
130
- @fiber.alive?
131
- end
132
-
133
- # Requests cancellation using a cooperative checkpoint mechanism.
134
- # Sets a cancellation flag; the error is raised inside the Fiber at the
135
- # next +step+ call (i.e. when the scheduler next dispatches this task),
136
- # not injected at an arbitrary suspension point via +Fiber#raise+.
137
- # If the Fiber has not yet started, the error is recorded so it is raised
138
- # on the first {#step}.
139
- # @return [self]
140
- # @api private
141
- def cancel!
142
- @cancel_error = CancellationError.new("Task cancelled")
143
- @cancel_requested = true
144
- self
145
- end
146
-
147
- # Joins execution by stepping until the Fiber is no longer alive.
148
- # @param limit [Numeric, nil] ignored
149
- # @return [self]
150
- # @api private
151
- def join(_limit = nil)
152
- step while @fiber.alive?
153
- self
154
- end
155
- end
156
- end
157
- end
@@ -1,89 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- class Task
5
- # Synchronous task backend that executes the block on the calling thread.
6
- #
7
- # Used by {Runtime::FakeScheduler} to allow tests to verify cooperative
8
- # scheduling behaviour without spawning additional Threads. The block
9
- # runs to completion before {#initialize} returns, so {#await} and {#join}
10
- # always return immediately.
11
- #
12
- # Thread count invariant: +ImmediateBackend+ never creates a new Thread.
13
- class ImmediateBackend < Backend
14
- # Executes +block+ synchronously on the calling thread.
15
- # Saves and restores +Task.current+ so nested ImmediateBackend tasks
16
- # compose correctly.
17
- #
18
- # @param task [Task]
19
- # @yieldreturn [Object]
20
- # @api private
21
- def initialize(task:, &block)
22
- super
23
- @value = nil
24
- @error = nil
25
- previous_task = Thread.current[:phronomy_current_task]
26
- Thread.current[:phronomy_current_task] = task
27
- task.transition!(:running)
28
- begin
29
- @value = block.call
30
- task.transition!(:completed, value: @value)
31
- rescue CancellationError => e
32
- task.transition!(:cancelled, error: e)
33
- @error = e
34
- rescue => e
35
- task.transition!(:failed, error: e)
36
- @error = e
37
- ensure
38
- task.transition!(:cancelled) unless task.done?
39
- Thread.current[:phronomy_current_task] = previous_task
40
- end
41
- end
42
-
43
- # Returns the block's return value, or re-raises its exception.
44
- # @return [Object]
45
- # @raise [Exception]
46
- # @api private
47
- def wait_result
48
- raise @error if @error
49
-
50
- @value
51
- end
52
-
53
- # @return [Object, nil]
54
- # @api private
55
- def completed_value
56
- @value
57
- end
58
-
59
- # @return [Exception, nil]
60
- # @api private
61
- def completed_error
62
- @error
63
- end
64
-
65
- # Always +false+ — block has already completed by the time the task
66
- # is visible to callers.
67
- # @return [Boolean]
68
- # @api private
69
- def alive?
70
- false
71
- end
72
-
73
- # No-op: the block has already completed.
74
- # @return [self]
75
- # @api private
76
- def cancel!
77
- self
78
- end
79
-
80
- # Returns immediately — nothing to wait for.
81
- # @param limit [Numeric, nil] ignored
82
- # @return [self]
83
- # @api private
84
- def join(_limit = nil)
85
- self
86
- end
87
- end
88
- end
89
- end
@@ -1,90 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "timeout"
4
-
5
- module Phronomy
6
- class Task
7
- # Backend for Tasks created by {Task#map}.
8
- #
9
- # A mapped task's lifecycle is driven entirely by the +on_complete+
10
- # callback of its source task — it never spawns a thread of its own.
11
- # +MappedBackend+ transitions the owning task to +:running+ immediately
12
- # on initialization so that +FSMSession+ treats it as an in-progress
13
- # async action. Completion (or failure) is triggered externally via
14
- # {Task#transition!} from the +on_complete+ callback registered by
15
- # {Task#map}.
16
- #
17
- # +await+ and +join+ block until {#unblock} is called, which {Task#map}
18
- # arranges by registering a second +on_complete+ callback on the *mapped*
19
- # task itself after the transform callback has been registered.
20
- #
21
- # @api private
22
- class MappedBackend < Backend
23
- def initialize(task:, &)
24
- super
25
- @done_queue = Queue.new
26
- task.transition!(:running)
27
- end
28
-
29
- # Unblocks +await+ / +join+. Called by {Task#map} after the mapped task
30
- # reaches a terminal state.
31
- # @api private
32
- def unblock(value, error)
33
- @done_queue.push([value, error])
34
- end
35
-
36
- # Blocks until the mapped task reaches a terminal state.
37
- # @return [Object] the mapped value
38
- # @raise [Exception] if the source task or the map block raised an error
39
- # @api private
40
- def wait_result
41
- scheduler = Thread.current.thread_variable_get(Task::SCHEDULER_KEY)
42
- in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
43
- if scheduler && in_managed_fiber && !@task.done?
44
- scheduler.track_blocking_await
45
- waiting_fiber = Fiber.current
46
- @task.on_complete do |_value, _error|
47
- scheduler.complete_blocking_await
48
- scheduler.enqueue_fiber(-> { waiting_fiber.resume })
49
- end
50
- Fiber.yield(:cooperative_suspend)
51
- end
52
-
53
- value, error = @done_queue.pop
54
- raise error if error
55
-
56
- value
57
- end
58
-
59
- # Returns +false+ — a mapped task has no independent thread to kill.
60
- # @return [Boolean]
61
- # @api private
62
- def alive?
63
- false
64
- end
65
-
66
- # No-op — mapped tasks carry no independent thread to cancel.
67
- # @return [self]
68
- # @api private
69
- def cancel!
70
- self
71
- end
72
-
73
- # Blocks until the mapped task completes, with an optional timeout.
74
- # @param limit [Numeric, nil]
75
- # @return [Object, nil] +nil+ on timeout
76
- # @api private
77
- def join(limit = nil)
78
- if limit.nil?
79
- wait_result
80
- else
81
- begin
82
- Timeout.timeout(limit) { wait_result }
83
- rescue Timeout::Error
84
- nil
85
- end
86
- end
87
- end
88
- end
89
- end
90
- end
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- class Task
5
- # Thread-based Task backend (default).
6
- #
7
- # Each task runs on its own OS thread. Cancellation is delivered via
8
- # +Thread#raise(CancellationError)+, which cooperates with +rescue+ clauses
9
- # inside the block. This backend is always available and requires no
10
- # external dependencies.
11
- #
12
- # When the cooperative scheduler backend is introduced, this backend will
13
- # remain available as the fallback for blocking I/O operations that must
14
- # run outside the scheduler (e.g. inside {BlockingAdapterPool}).
15
- class ThreadBackend < Backend
16
- def initialize(task:, &block)
17
- super
18
- @value = nil
19
- @error = nil
20
- @thread = Thread.new do
21
- Thread.current.name = task.name if task.name
22
- Thread.current[:phronomy_current_task] = task
23
- Thread.current[:phronomy_task_cpu_slice_start_ms] =
24
- Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
25
- task.transition!(:running)
26
- @value = block.call
27
- task.transition!(:completed, value: @value)
28
- rescue CancellationError => e
29
- task.transition!(:cancelled, error: e)
30
- @error = e
31
- rescue => e
32
- task.transition!(:failed, error: e)
33
- @error = e
34
- ensure
35
- # Guard against Thread#raise firing before the rescue handler has a
36
- # chance to run (e.g. when cancel! is called immediately after spawn).
37
- task.transition!(:cancelled) unless task.done?
38
- end
39
- end
40
-
41
- # @return [Object]
42
- # @raise [Exception]
43
- # @api private
44
- def wait_result
45
- @thread.join
46
- raise @error if @error
47
-
48
- @value
49
- end
50
-
51
- # @return [Boolean]
52
- # @api private
53
- def alive?
54
- @thread.alive?
55
- end
56
-
57
- # @return [self]
58
- # @api private
59
- def cancel!
60
- @thread.raise(CancellationError, "Task cancelled") if @thread.alive?
61
- self
62
- end
63
-
64
- # @param limit [Numeric, nil]
65
- # @return [Thread, nil]
66
- # @api private
67
- def join(limit = nil)
68
- @thread.join(limit)
69
- end
70
-
71
- # @return [Object, nil]
72
- # @api private
73
- def completed_value
74
- @value
75
- end
76
-
77
- # @return [Exception, nil]
78
- # @api private
79
- def completed_error
80
- @error
81
- end
82
- end
83
- end
84
- end
@@ -1,193 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Manages a bounded set of concurrent {Task}s with structured concurrency.
5
- class TaskGroup
6
- FAILURE_POLICIES = %i[fail_fast collect_all skip_failed].freeze
7
-
8
- # @param limit [Integer, Float::INFINITY]
9
- # @param failure_policy [Symbol]
10
- # @param runtime [Runtime] runtime authority used to spawn every child Task
11
- # @api private
12
- def initialize(runtime:, limit: Float::INFINITY, failure_policy: :fail_fast)
13
- unless FAILURE_POLICIES.include?(failure_policy)
14
- raise ArgumentError, "unknown failure_policy: #{failure_policy}"
15
- end
16
- unless runtime
17
- raise ArgumentError, "runtime is required"
18
- end
19
-
20
- @limit = limit
21
- @failure_policy = failure_policy
22
- @runtime = runtime
23
- @tasks = []
24
- @mutex = Mutex.new
25
- @cond = ConditionVariable.new
26
- @active = 0
27
- end
28
-
29
- # @api private
30
- def spawn(&block)
31
- wait_for_slot!
32
-
33
- task = @runtime.spawn(name: "task-group-worker") do
34
- block.call
35
- ensure
36
- release_slot!
37
- end
38
-
39
- @mutex.synchronize { @tasks << task }
40
- task
41
- end
42
-
43
- # @api private
44
- def await_all
45
- tasks = @mutex.synchronize { @tasks.dup }
46
- return [] if tasks.empty?
47
-
48
- if Phronomy::Runtime::Scheduler.current
49
- _await_all_cooperative(tasks)
50
- else
51
- _await_all_threaded(tasks)
52
- end
53
- end
54
-
55
- private
56
-
57
- def _await_all_cooperative(tasks)
58
- completion_q = Phronomy::Concurrency::AsyncQueue.new
59
- tasks.each_with_index do |task, idx|
60
- task.on_complete do |value, error|
61
- completion_q.push({index: idx, value: value, error: error})
62
- end
63
- end
64
-
65
- entries = Array.new(tasks.length)
66
- cancelled = false
67
- fail_fast_error = nil
68
-
69
- tasks.length.times do
70
- entry = completion_q.pop
71
- entries[entry[:index]] = entry
72
-
73
- if entry[:error] && @failure_policy == :fail_fast && !cancelled
74
- cancelled = true
75
- fail_fast_error = entry[:error]
76
- tasks.each { |task| task.cancel! unless task.done? }
77
- end
78
- end
79
-
80
- case @failure_policy
81
- when :fail_fast
82
- raise fail_fast_error if fail_fast_error
83
- entries.map { |entry| entry[:value] }
84
- when :skip_failed
85
- entries.filter_map { |entry| entry[:value] unless entry[:error] }
86
- else
87
- errors = entries.filter_map { |entry| entry[:error] }
88
- raise errors.first if errors.any?
89
- entries.map { |entry| entry[:value] }
90
- end
91
- end
92
-
93
- def _await_all_threaded(tasks)
94
- completion_q = Queue.new
95
- tasks.each_with_index do |task, idx|
96
- task.on_complete do |value, error|
97
- completion_q.push({index: idx, value: value, error: error})
98
- end
99
- end
100
-
101
- entries = Array.new(tasks.length)
102
- cancelled = false
103
- fail_fast_error = nil
104
-
105
- tasks.length.times do
106
- entry = completion_q.pop
107
- entries[entry[:index]] = entry
108
-
109
- if entry[:error] && @failure_policy == :fail_fast && !cancelled
110
- cancelled = true
111
- fail_fast_error = entry[:error]
112
- tasks.each { |task| task.cancel! unless task.done? }
113
- end
114
- end
115
-
116
- case @failure_policy
117
- when :fail_fast
118
- raise fail_fast_error if fail_fast_error
119
- entries.map { |entry| entry[:value] }
120
- when :skip_failed
121
- entries.filter_map { |entry| entry[:value] unless entry[:error] }
122
- else
123
- errors = entries.filter_map { |entry| entry[:error] }
124
- raise errors.first if errors.any?
125
- entries.map { |entry| entry[:value] }
126
- end
127
- end
128
-
129
- public
130
-
131
- # @api private
132
- def cancel_all!
133
- tasks = @mutex.synchronize { @tasks.dup }
134
- tasks.each(&:cancel!)
135
- tasks.each do |task|
136
- task.join
137
- rescue
138
- nil
139
- end
140
-
141
- scheduler = Phronomy::Runtime::Scheduler.current
142
- if scheduler && @coop_signal
143
- @active = 0
144
- scheduler.raise_signal_all(@coop_signal)
145
- else
146
- @mutex.synchronize do
147
- @active = 0
148
- @cond.broadcast
149
- end
150
- end
151
- self
152
- end
153
-
154
- # @api private
155
- def active_task_count
156
- @mutex.synchronize { @active }
157
- end
158
-
159
- private
160
-
161
- def wait_for_slot!
162
- scheduler = Phronomy::Runtime::Scheduler.current
163
- if scheduler
164
- @coop_signal ||= scheduler.new_signal
165
- loop do
166
- if @active < @limit
167
- @active += 1
168
- return
169
- end
170
- scheduler.wait_for_signal(@coop_signal)
171
- end
172
- else
173
- @mutex.synchronize do
174
- @cond.wait(@mutex) while @active >= @limit
175
- @active += 1
176
- end
177
- end
178
- end
179
-
180
- def release_slot!
181
- scheduler = Phronomy::Runtime::Scheduler.current
182
- if scheduler && @coop_signal
183
- @active -= 1
184
- scheduler.raise_signal(@coop_signal)
185
- else
186
- @mutex.synchronize do
187
- @active -= 1
188
- @cond.signal
189
- end
190
- end
191
- end
192
- end
193
- end
@@ -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