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
@@ -66,7 +66,7 @@ module Phronomy
66
66
 
67
67
  def save(agent_id, expected_revision:, root:)
68
68
  @owner.synchronize do
69
- current = load(agent_id)
69
+ current = @owner.state[:agents].fetch(agent_id.to_s) { raise NotFoundError, "agent not found: #{agent_id}" }
70
70
  unless current.agent_revision == expected_revision
71
71
  raise ConflictError,
72
72
  "agent revision conflict: expected #{expected_revision}, actual #{current.agent_revision}"
@@ -162,7 +162,7 @@ module Phronomy
162
162
 
163
163
  def save(execution_id, expected_revision:, execution:)
164
164
  @owner.synchronize do
165
- current = load(execution_id)
165
+ current = @owner.state[:executions].fetch(execution_id.to_s) { raise NotFoundError, "execution not found: #{execution_id}" }
166
166
  unless current.execution_revision == expected_revision
167
167
  raise ConflictError,
168
168
  "execution revision conflict: expected #{expected_revision}, actual #{current.execution_revision}"
@@ -209,31 +209,112 @@ module Phronomy
209
209
  end
210
210
  end
211
211
 
212
- attr_reader :state
212
+ class WorkflowStates
213
+ def initialize(owner) = @owner = owner
214
+
215
+ def load(thread_id)
216
+ @owner.synchronize do
217
+ record = @owner.workflow_state_data[thread_id.to_s]
218
+ next nil unless record
219
+
220
+ {
221
+ snapshot: @owner.deep_dup_workflow_value(record.fetch(:snapshot)),
222
+ revision: record.fetch(:revision)
223
+ }.freeze
224
+ end
225
+ end
226
+
227
+ def save(thread_id, expected_revision:, snapshot:)
228
+ @owner.synchronize do
229
+ key = thread_id.to_s
230
+ current = @owner.workflow_state_data[key]
231
+ actual_revision = current&.fetch(:revision)
232
+ unless actual_revision == expected_revision
233
+ raise ConflictError,
234
+ "workflow state revision conflict for #{key}: " \
235
+ "expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
236
+ end
237
+
238
+ next_revision = actual_revision ? actual_revision + 1 : 1
239
+ @owner.workflow_state_data[key] = {
240
+ snapshot: @owner.deep_dup_workflow_value(snapshot),
241
+ revision: next_revision
242
+ }
243
+ next_revision
244
+ end
245
+ end
246
+
247
+ def delete(thread_id, expected_revision:)
248
+ @owner.synchronize do
249
+ key = thread_id.to_s
250
+ current = @owner.workflow_state_data[key]
251
+ actual_revision = current&.fetch(:revision)
252
+ unless actual_revision == expected_revision
253
+ raise ConflictError,
254
+ "workflow state revision conflict for #{key}: " \
255
+ "expected #{expected_revision.inspect}, actual #{actual_revision.inspect}"
256
+ end
257
+ @owner.workflow_state_data.delete(key)
258
+ end
259
+ nil
260
+ end
261
+ end
262
+
263
+ attr_reader :state, :workflow_state_data
213
264
 
214
265
  def initialize
215
266
  @monitor = Monitor.new
216
267
  @state = {contents: {}, agents: {}, journals: {}, executions: {}}
268
+ @workflow_state_data = {}
217
269
  @contents = Contents.new(self)
218
270
  @agents = Agents.new(self)
219
271
  @journals = Journals.new(self)
220
272
  @executions = Executions.new(self)
221
- @activations = Phronomy::Agent::ActivationRegistry.new
222
- super(contents: @contents, agents: @agents, journals: @journals,
223
- executions: @executions, activations: @activations)
273
+ @workflow_states = WorkflowStates.new(self)
274
+ super(
275
+ contents: @contents,
276
+ agents: @agents,
277
+ journals: @journals,
278
+ executions: @executions,
279
+ workflow_states: @workflow_states
280
+ )
224
281
  end
225
282
 
226
283
  def capabilities
227
284
  {atomic_all: true, atomic_admission: true, optimistic_revision: true}.freeze
228
285
  end
229
286
 
287
+ def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
288
+ synchronize do
289
+ stored = @state[:agents][agent_id.to_s]
290
+ unless stored
291
+ raise NotFoundError, "Agent not found: #{agent_id}"
292
+ end
293
+
294
+ if stored.agent_revision != agent_revision
295
+ raise ConflictError,
296
+ "agent revision conflict: expected #{agent_revision}, actual #{stored.agent_revision}"
297
+ end
298
+
299
+ actual_position = Array(@state[:journals][agent_id.to_s]).length
300
+ if actual_position != journal_position
301
+ raise ConflictError,
302
+ "journal position conflict: expected #{journal_position}, actual #{actual_position}"
303
+ end
304
+
305
+ true
306
+ end
307
+ end
308
+
230
309
  def transaction
231
310
  synchronize do
232
- snapshot = Marshal.load(Marshal.dump(@state))
311
+ state_snapshot = Marshal.load(Marshal.dump(@state))
312
+ workflow_snapshot = deep_dup_workflow_value(@workflow_state_data)
233
313
  begin
234
314
  yield self
235
315
  rescue
236
- @state = snapshot
316
+ @state.replace(state_snapshot)
317
+ @workflow_state_data.replace(workflow_snapshot)
237
318
  raise
238
319
  end
239
320
  end
@@ -242,6 +323,30 @@ module Phronomy
242
323
  def synchronize(&block)
243
324
  @monitor.synchronize(&block)
244
325
  end
326
+
327
+ # Workflow fields historically accepted ordinary Ruby values in the
328
+ # in-memory store. Keep that contract without forcing the Agent durable
329
+ # state Marshal snapshot to serialize arbitrary Workflow values.
330
+ def deep_dup_workflow_value(value)
331
+ case value
332
+ when Hash
333
+ value.each_with_object({}) do |(key, child), result|
334
+ result[deep_dup_workflow_value(key)] = deep_dup_workflow_value(child)
335
+ end
336
+ when Array
337
+ value.map { |child| deep_dup_workflow_value(child) }
338
+ when NilClass, Symbol, Integer, Float, TrueClass, FalseClass
339
+ value
340
+ else
341
+ return value if value.frozen?
342
+
343
+ begin
344
+ value.dup
345
+ rescue TypeError
346
+ value
347
+ end
348
+ end
349
+ end
245
350
  end
246
351
  end
247
352
  end
@@ -6,14 +6,14 @@ module Phronomy
6
6
  class NotFoundError < Phronomy::Error; end
7
7
  class UnsupportedBackendError < Phronomy::Error; end
8
8
 
9
- attr_reader :contents, :agents, :journals, :executions, :activations
9
+ attr_reader :contents, :agents, :journals, :executions, :workflow_states
10
10
 
11
- def initialize(contents:, agents:, journals:, executions:, activations:)
11
+ def initialize(contents:, agents:, journals:, executions:, workflow_states:)
12
12
  @contents = contents
13
13
  @agents = agents
14
14
  @journals = journals
15
15
  @executions = executions
16
- @activations = activations
16
+ @workflow_states = workflow_states
17
17
  validate_capabilities!
18
18
  end
19
19
 
@@ -25,6 +25,15 @@ module Phronomy
25
25
  raise UnsupportedBackendError, "#{self.class} does not provide atomic_all"
26
26
  end
27
27
 
28
+ # Verifies that a live Agent still owns the durable base it hydrated.
29
+ # Backends should implement this as a revision/position precondition check,
30
+ # not as a state reload returned to the caller.
31
+ # @api private
32
+ def assert_agent_watermark!(agent_id:, agent_revision:, journal_position:)
33
+ raise UnsupportedBackendError,
34
+ "#{self.class} does not provide Agent durable-watermark checks"
35
+ end
36
+
28
37
  private
29
38
 
30
39
  def validate_capabilities!
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ class Comparison
7
+ ComparisonPair = Data.define(:eval_case, :result_a, :result_b)
8
+
9
+ def initialize(scorer: Scorer::ExactMatch.new)
10
+ @scorer = scorer
11
+ end
12
+
13
+ def compare(dataset, callable_a, callable_b)
14
+ results_a = Runner.new(scorer: @scorer).run(dataset, callable_a)
15
+ results_b = Runner.new(scorer: @scorer).run(dataset, callable_b)
16
+ results_a.zip(results_b).map do |a, b|
17
+ ComparisonPair.new(eval_case: a.eval_case, result_a: a, result_b: b)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ class Dataset
7
+ include Enumerable
8
+
9
+ def initialize(cases = [])
10
+ @cases = cases.freeze
11
+ end
12
+
13
+ def self.from_array(pairs)
14
+ new(pairs.map { |item| EvalCase.new(**item) })
15
+ end
16
+
17
+ def each(&block)
18
+ @cases.each(&block)
19
+ end
20
+
21
+ def size
22
+ @cases.size
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ EvalCase = Data.define(:input, :expected, :metadata) do
7
+ def initialize(input:, expected:, metadata: {})
8
+ super
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ EvalResult = Data.define(:eval_case, :actual, :score, :usage, :latency_ms, :error) do
7
+ def initialize(eval_case:, actual:, score:, usage:, latency_ms:, error: nil)
8
+ super
9
+ end
10
+
11
+ def pass? = score >= 1.0
12
+ def scorer_error? = !error.nil?
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ class Metrics
7
+ def initialize(results)
8
+ @results = results
9
+ end
10
+
11
+ def pass_rate
12
+ return 0.0 if @results.empty?
13
+ @results.count(&:pass?).to_f / @results.size
14
+ end
15
+
16
+ def average_score
17
+ return 0.0 if @results.empty?
18
+ @results.sum(&:score) / @results.size
19
+ end
20
+
21
+ def total_usage
22
+ @results.map(&:usage).compact.reduce(Phronomy::TokenUsage.zero, :+)
23
+ end
24
+
25
+ def average_latency_ms
26
+ return 0.0 if @results.empty?
27
+ @results.sum(&:latency_ms).to_f / @results.size
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ total: @results.size,
33
+ pass_count: @results.count(&:pass?),
34
+ pass_rate: pass_rate,
35
+ average_score: average_score,
36
+ total_usage: total_usage.to_h,
37
+ average_latency_ms: average_latency_ms
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ # Sequential test-only evaluator. Concurrency belongs to the subsystem
7
+ # under test, not to the evaluation helper itself.
8
+ class Runner
9
+ def initialize(scorer: Scorer::ExactMatch.new)
10
+ @scorer = scorer
11
+ end
12
+
13
+ def run(dataset, callable)
14
+ dataset.to_a.map { |eval_case| run_one(eval_case, callable) }
15
+ end
16
+
17
+ private
18
+
19
+ def run_one(eval_case, callable)
20
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
21
+ result = callable.call(eval_case.input)
22
+ latency_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - started_at
23
+ actual, usage = extract(result)
24
+ score, score_error = score_safely(
25
+ @scorer,
26
+ actual: actual,
27
+ expected: eval_case.expected,
28
+ input: eval_case.input
29
+ )
30
+ EvalResult.new(
31
+ eval_case: eval_case,
32
+ actual: actual,
33
+ score: score,
34
+ usage: usage,
35
+ latency_ms: latency_ms,
36
+ error: score_error
37
+ )
38
+ end
39
+
40
+ def extract(result)
41
+ result.is_a?(Hash) ? [result[:output].to_s, result[:usage]] : [result.to_s, nil]
42
+ end
43
+
44
+ def score_safely(scorer, **kwargs)
45
+ [scorer.score(**kwargs), nil]
46
+ rescue => error
47
+ [0.0, error]
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ class Base
8
+ def score(actual:, expected:, input: nil)
9
+ raise NotImplementedError, "#{self.class}#score is not implemented"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ class ExactMatch < Base
8
+ def initialize(case_sensitive: true)
9
+ @case_sensitive = case_sensitive
10
+ end
11
+
12
+ def score(actual:, expected:, input: nil)
13
+ actual_value = actual.to_s.strip
14
+ expected_value = expected.to_s.strip
15
+ unless @case_sensitive
16
+ actual_value = actual_value.downcase
17
+ expected_value = expected_value.downcase
18
+ end
19
+ (actual_value == expected_value) ? 1.0 : 0.0
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ class IncludesScorer < Base
8
+ def initialize(case_sensitive: false)
9
+ @case_sensitive = case_sensitive
10
+ end
11
+
12
+ def score(actual:, expected:, input: nil)
13
+ actual_value = actual.to_s
14
+ expected_value = expected.to_s
15
+ unless @case_sensitive
16
+ actual_value = actual_value.downcase
17
+ expected_value = expected_value.downcase
18
+ end
19
+ actual_value.include?(expected_value) ? 1.0 : 0.0
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ class LlmJudge < Base
8
+ DEFAULT_PROMPT = <<~PROMPT
9
+ You are an impartial judge evaluating the quality of an AI assistant response.
10
+ Rate the response on a scale from 0.0 (completely wrong or unhelpful) to 1.0 (perfect).
11
+ Respond with ONLY a single decimal number between 0.0 and 1.0 — no other text.
12
+
13
+ Question: %<input>s
14
+ Expected answer: %<expected>s
15
+ Actual response: %<actual>s
16
+
17
+ Score:
18
+ PROMPT
19
+
20
+ def initialize(model:, prompt_template: DEFAULT_PROMPT, raise_on_error: false)
21
+ @model = model
22
+ @prompt_template = prompt_template
23
+ @raise_on_error = raise_on_error
24
+ end
25
+
26
+ def score(actual:, expected:, input: nil)
27
+ prompt = format(
28
+ @prompt_template,
29
+ input: input.to_s,
30
+ expected: expected.to_s,
31
+ actual: actual.to_s
32
+ )
33
+ response = Phronomy::Runtime.instance.offload.submit do
34
+ RubyLLM.chat(model: @model).ask(prompt)
35
+ end.blocking_wait
36
+ response.content.to_s.strip.scan(/-?\d+\.?\d*/).first.to_f.clamp(0.0, 1.0)
37
+ rescue => error
38
+ raise if @raise_on_error
39
+ warn "[LlmJudge] Scoring failed: #{error.message}"
40
+ 0.0
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ module Eval
6
+ module Scorer
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Testing
5
+ # Test-only evaluation helpers.
6
+ module Eval
7
+ end
8
+ end
9
+ end
@@ -2,32 +2,16 @@
2
2
 
3
3
  module Phronomy
4
4
  module Testing
5
- # A deterministic, manually-advanced clock for use in tests.
6
- #
7
- # Replaces real +Process.clock_gettime+ calls so that time-sensitive code
8
- # can be tested without relying on wall-clock sleeps.
9
- #
10
- # @example
11
- # clock = Phronomy::Testing::FakeClock.new
12
- # clock.now # => 0.0
13
- # clock.advance(5) # advance by 5 seconds
14
- # clock.now # => 5.0
5
+ # Deterministic manually-advanced clock for tests.
15
6
  class FakeClock
16
- # @return [Float] the current logical time in seconds since the epoch (t=0)
17
7
  attr_reader :now
18
8
 
19
9
  def initialize
20
10
  @now = 0.0
21
- @callbacks = [] # [[fire_at, block], ...]
11
+ @callbacks = []
22
12
  @mutex = Mutex.new
23
13
  end
24
14
 
25
- # Advance the clock by +seconds+ and fire any registered callbacks whose
26
- # deadline has passed.
27
- #
28
- # @param seconds [Numeric]
29
- # @return [self]
30
- # @api private
31
15
  def advance(seconds)
32
16
  @mutex.synchronize do
33
17
  @now += seconds.to_f
@@ -36,65 +20,34 @@ module Phronomy
36
20
  self
37
21
  end
38
22
 
39
- # Register a one-shot callback that fires when the clock reaches +at+.
40
- #
41
- # @param at [Numeric] logical time to fire
42
- # @yield called with no arguments when the clock reaches +at+
43
- # @return [self]
44
- # @api private
45
23
  def at(at, &block)
46
24
  @mutex.synchronize { @callbacks << [at.to_f, block] }
47
25
  self
48
26
  end
49
27
 
50
- # Schedule a one-shot callback to fire after +seconds+ from the current
51
- # logical time. This is the same interface as {Runtime::TimerQueue#schedule}
52
- # so that a +FakeClock+ can be passed as a +timer_queue:+ argument in tests.
53
- #
54
- # @param seconds [Numeric] delay in logical seconds
55
- # @yield called when the clock reaches the scheduled time
56
- # @return [self]
57
- # @api private
58
28
  def schedule(seconds:, &block)
59
29
  at(@now + seconds.to_f, &block)
60
30
  end
61
31
 
62
- # Returns the number of pending (un-fired) callbacks.
63
- # @return [Integer]
64
- # @api private
65
32
  def pending_callbacks
66
33
  @mutex.synchronize { @callbacks.size }
67
34
  end
68
35
 
69
- # Returns the logical time of the next pending callback, or +nil+ if
70
- # there are no pending callbacks.
71
- #
72
- # @return [Float, nil]
73
- # @api private
74
36
  def next_timer_at
75
37
  @mutex.synchronize { @callbacks.min_by { |(t, _)| t }&.first }
76
38
  end
77
39
 
78
- # Advance the clock exactly to the next pending callback and fire it.
79
- # Raises +RuntimeError+ when there are no pending callbacks.
80
- #
81
- # @return [self]
82
- # @api private
83
40
  def advance_to_next_timer
84
41
  target = next_timer_at
85
42
  raise "No pending timers to advance to" unless target
86
-
87
43
  advance(target - @now)
88
44
  end
89
45
 
90
- # Returns descriptive entries for all pending callbacks.
91
- # Used by {Phronomy::Runtime::FakeScheduler#pending_timers}.
92
- #
93
- # @return [Array<Hash>] each entry: +{ fire_at:, description: nil }+
94
- # @api private
95
46
  def pending_timer_entries
96
47
  @mutex.synchronize do
97
- @callbacks.sort_by { |(t, _)| t }.map { |(t, _)| {fire_at: t, description: nil} }
48
+ @callbacks.sort_by { |(t, _)| t }.map do |(time, _)|
49
+ {fire_at: time, description: nil}
50
+ end
98
51
  end
99
52
  end
100
53
 
@@ -102,7 +55,7 @@ module Phronomy
102
55
 
103
56
  def fire_expired_callbacks!
104
57
  fired, @callbacks = @callbacks.partition { |(t, _)| t <= @now }
105
- fired.sort_by { |(t, _)| t }.each { |(_, cb)| cb.call }
58
+ fired.sort_by { |(t, _)| t }.each { |(_, callback)| callback.call }
106
59
  end
107
60
  end
108
61
  end
@@ -1,12 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- # Test helpers for deterministic, timer-independent testing.
5
- #
6
- # @example
7
- # require "phronomy/testing"
8
- # clock = Phronomy::Testing::FakeClock.new
9
- # scheduler = Phronomy::Testing::FakeScheduler.new
4
+ # Test-only helpers. Nothing under this namespace participates in the
5
+ # production Runtime control plane.
10
6
  module Testing
11
7
  end
12
8
  end