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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -1134
  3. data/README.md +68 -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/010-cooperative-first-concurrency.md +155 -235
  8. data/docs/features.md +87 -0
  9. data/docs/getting-started.md +351 -0
  10. data/docs/migrations/0.15.md +35 -0
  11. data/docs/migrations/0.16.md +43 -0
  12. data/docs/runtime-and-concurrency.md +258 -0
  13. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  14. data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
  15. data/lib/phronomy/agent/async_event_api.rb +3 -3
  16. data/lib/phronomy/agent/base.rb +35 -19
  17. data/lib/phronomy/agent/context/capability/base.rb +13 -3
  18. data/lib/phronomy/agent/execution_coordinator.rb +6 -6
  19. data/lib/phronomy/agent/shared_state.rb +2 -0
  20. data/lib/phronomy/agent/tool_executor.rb +29 -71
  21. data/lib/phronomy/agent/tool_invocation.rb +97 -47
  22. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  23. data/lib/phronomy/configuration.rb +5 -29
  24. data/lib/phronomy/diagnostics.rb +12 -41
  25. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  26. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  27. data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
  28. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  29. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  31. data/lib/phronomy/engine/event_loop.rb +89 -190
  32. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  33. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  34. data/lib/phronomy/engine/runtime.rb +45 -158
  35. data/lib/phronomy/engine/task.rb +136 -277
  36. data/lib/phronomy/llm_adapter/base.rb +14 -14
  37. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  38. data/lib/phronomy/llm_adapter.rb +2 -2
  39. data/lib/phronomy/metrics.rb +15 -30
  40. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  41. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  42. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  43. data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
  44. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
  45. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  46. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  47. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  48. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  49. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  50. data/lib/phronomy/testing/eval/runner.rb +52 -0
  51. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  52. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  53. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  54. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  55. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  56. data/lib/phronomy/testing/eval.rb +9 -0
  57. data/lib/phronomy/testing/fake_clock.rb +6 -53
  58. data/lib/phronomy/testing.rb +2 -6
  59. data/lib/phronomy/tools/agent.rb +141 -6
  60. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  61. data/lib/phronomy/vector_store/base.rb +2 -2
  62. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  63. data/lib/phronomy/version.rb +1 -1
  64. data/lib/phronomy/workflow_runner.rb +2 -4
  65. data/lib/phronomy.rb +7 -4
  66. data/scripts/api_snapshot.rb +4 -4
  67. metadata +23 -31
  68. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  69. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  70. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  71. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  72. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  73. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  74. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  75. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  76. data/lib/phronomy/engine/task/backend.rb +0 -80
  77. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  78. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  79. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  80. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  81. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  82. data/lib/phronomy/engine/task_group.rb +0 -193
  83. data/lib/phronomy/eval/comparison.rb +0 -47
  84. data/lib/phronomy/eval/dataset.rb +0 -45
  85. data/lib/phronomy/eval/eval_case.rb +0 -17
  86. data/lib/phronomy/eval/eval_result.rb +0 -29
  87. data/lib/phronomy/eval/metrics.rb +0 -66
  88. data/lib/phronomy/eval/runner.rb +0 -94
  89. data/lib/phronomy/eval/scorer/base.rb +0 -22
  90. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  91. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  92. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  93. data/lib/phronomy/eval/scorer.rb +0 -9
  94. data/lib/phronomy/eval.rb +0 -7
  95. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  96. 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
@@ -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,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