phronomy 0.7.1 → 0.8.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -16
  3. data/benchmark/bench_context_assembler.rb +2 -2
  4. data/benchmark/bench_regression.rb +5 -5
  5. data/benchmark/bench_token_estimator.rb +5 -5
  6. data/benchmark/bench_tool_schema.rb +1 -1
  7. data/benchmark/bench_vector_store.rb +1 -1
  8. data/lib/phronomy/agent/base.rb +86 -123
  9. data/lib/phronomy/agent/checkpoint.rb +118 -0
  10. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  11. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  12. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  13. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  14. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  15. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  16. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  17. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  18. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  19. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  20. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  21. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  23. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  24. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  25. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  26. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  27. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  28. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  29. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  30. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  31. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  32. data/lib/phronomy/agent/fsm.rb +1 -1
  33. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  34. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  35. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  36. data/lib/phronomy/agent/react_agent.rb +19 -14
  37. data/lib/phronomy/agent/runner.rb +2 -2
  38. data/lib/phronomy/agent/tool_executor.rb +108 -0
  39. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  40. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  41. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  42. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  43. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  44. data/lib/phronomy/concurrency/deadline.rb +65 -0
  45. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -1
  46. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  47. data/lib/phronomy/context.rb +2 -8
  48. data/lib/phronomy/embeddings.rb +2 -2
  49. data/lib/phronomy/eval/runner.rb +4 -0
  50. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  51. data/lib/phronomy/event_loop.rb +7 -7
  52. data/lib/phronomy/invocation_context.rb +3 -3
  53. data/lib/phronomy/knowledge_source.rb +0 -5
  54. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  55. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  56. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  57. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  58. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  59. data/lib/phronomy/loader.rb +4 -4
  60. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  61. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
  62. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  63. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  64. data/lib/phronomy/runtime.rb +19 -4
  65. data/lib/phronomy/splitter.rb +3 -3
  66. data/lib/phronomy/task_group.rb +1 -1
  67. data/lib/phronomy/tool/base.rb +50 -9
  68. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  69. data/lib/phronomy/vector_store.rb +2 -2
  70. data/lib/phronomy/version.rb +1 -1
  71. data/lib/phronomy/workflow_context.rb +8 -0
  72. data/lib/phronomy/workflow_runner.rb +11 -131
  73. data/lib/phronomy.rb +1 -0
  74. metadata +44 -42
  75. data/lib/phronomy/async_queue.rb +0 -155
  76. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  77. data/lib/phronomy/cancellation_scope.rb +0 -123
  78. data/lib/phronomy/cancellation_token.rb +0 -133
  79. data/lib/phronomy/concurrency_gate.rb +0 -155
  80. data/lib/phronomy/context/compaction_context.rb +0 -111
  81. data/lib/phronomy/context/trigger_context.rb +0 -39
  82. data/lib/phronomy/context/trim_context.rb +0 -75
  83. data/lib/phronomy/deadline.rb +0 -63
  84. data/lib/phronomy/embeddings/base.rb +0 -39
  85. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  86. data/lib/phronomy/fsm_session.rb +0 -247
  87. data/lib/phronomy/knowledge_source/base.rb +0 -54
  88. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  89. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  90. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  91. data/lib/phronomy/loader/base.rb +0 -25
  92. data/lib/phronomy/loader/csv_loader.rb +0 -56
  93. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  94. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  95. data/lib/phronomy/prompt_template.rb +0 -96
  96. data/lib/phronomy/splitter/base.rb +0 -47
  97. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  98. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  99. data/lib/phronomy/tool_executor.rb +0 -106
  100. data/lib/phronomy/vector_store/async_backend.rb +0 -110
  101. data/lib/phronomy/vector_store/base.rb +0 -89
  102. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  103. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  104. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -1,96 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # A prompt template that substitutes {{variable}} placeholders in a string.
5
- #
6
- # @example Simple human template
7
- # t = Phronomy::PromptTemplate.new(template: "Translate to {{lang}}: {{text}}")
8
- # t.format(lang: "French", text: "Hello")
9
- # # => "Translate to French: Hello"
10
- #
11
- # @example With a system template
12
- # t = Phronomy::PromptTemplate.new(
13
- # template: "{{question}}",
14
- # system_template: "You are a {{role}} assistant."
15
- # )
16
- # t.format_system(role: "helpful")
17
- # # => "You are a helpful assistant."
18
- #
19
- # As a Runnable, #invoke accepts a Hash of variables and returns a Hash
20
- # with :prompt (and optionally :system) keys.
21
- class PromptTemplate
22
- include Phronomy::Runnable
23
-
24
- PLACEHOLDER = /\{\{(\w+)\}\}/
25
-
26
- attr_reader :template, :system_template
27
-
28
- # @param template [String] human message template with {{var}} placeholders
29
- # @param system_template [String, nil] optional system message template
30
- # @api public
31
- def initialize(template:, system_template: nil)
32
- @template = template
33
- @system_template = system_template
34
- end
35
-
36
- # Substitute all {{var}} placeholders in the human template.
37
- #
38
- # @param variables [Hash{Symbol => String}]
39
- # @return [String]
40
- # @api public
41
- def format(**variables)
42
- substitute(@template, variables)
43
- end
44
-
45
- # Substitute all {{var}} placeholders in the system template.
46
- # Returns nil when no system template was set.
47
- #
48
- # @param variables [Hash{Symbol => String}]
49
- # @return [String, nil]
50
- # @api public
51
- def format_system(**variables)
52
- @system_template && substitute(@system_template, variables)
53
- end
54
-
55
- # Runnable interface: accepts a Hash of variable values.
56
- # Returns { prompt: String, system: String|nil }.
57
- #
58
- # @param input [Hash{Symbol => String}]
59
- # @return [Hash]
60
- # @api public
61
- def invoke(input, config: {})
62
- vars = normalize_input(input)
63
- result = {prompt: format(**vars)}
64
- sys = format_system(**vars)
65
- result[:system] = sys if sys
66
- result
67
- end
68
-
69
- # Returns the list of placeholder names found in both templates.
70
- #
71
- # @return [Array<Symbol>]
72
- # @api public
73
- def variables
74
- names = @template.scan(PLACEHOLDER).flatten
75
- names += @system_template.scan(PLACEHOLDER).flatten if @system_template
76
- names.map(&:to_sym).uniq
77
- end
78
-
79
- private
80
-
81
- def substitute(text, variables)
82
- text.gsub(PLACEHOLDER) do |match|
83
- key = Regexp.last_match(1).to_sym
84
- variables.fetch(key) { raise KeyError, "Missing variable: {{#{key}}}" }
85
- end
86
- end
87
-
88
- def normalize_input(input)
89
- case input
90
- when Hash then input
91
- when String then {input: input}
92
- else raise ArgumentError, "PromptTemplate#invoke expects a Hash of variables, got #{input.class}"
93
- end
94
- end
95
- end
96
- end
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Splitter
5
- # Abstract base class for text splitters.
6
- #
7
- # A splitter takes a single document hash (or plain text) and returns an
8
- # array of smaller chunk documents:
9
- #
10
- # [{ text: String, metadata: Hash }, ...]
11
- #
12
- # Subclasses must implement {#split}.
13
- class Base
14
- # Split +document+ into an array of chunk documents.
15
- #
16
- # @param document [Hash, String]
17
- # Either a document hash (<tt>{ text: String, metadata: Hash }</tt>)
18
- # returned by a Loader, or a plain String.
19
- # @return [Array<Hash>] array of <tt>{ text: String, metadata: Hash }</tt>
20
- # @raise [NotImplementedError] when not overridden by a subclass
21
- # @api public
22
- def split(document)
23
- raise NotImplementedError, "#{self.class}#split is not implemented"
24
- end
25
-
26
- # Convenience method: split an array of documents.
27
- #
28
- # @param documents [Array<Hash, String>]
29
- # @return [Array<Hash>]
30
- # @api public
31
- def split_all(documents)
32
- documents.flat_map { |doc| split(doc) }
33
- end
34
-
35
- private
36
-
37
- # Normalise a document-or-string argument into {text:, metadata:}.
38
- def normalise(document)
39
- case document
40
- when Hash then {text: document[:text].to_s, metadata: document.fetch(:metadata, {})}
41
- when String then {text: document, metadata: {}}
42
- else raise ArgumentError, "document must be a Hash or String, got #{document.class}"
43
- end
44
- end
45
- end
46
- end
47
- end
@@ -1,51 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Splitter
5
- # Splits text into fixed-size character chunks with optional overlap.
6
- #
7
- # @example
8
- # splitter = Phronomy::Splitter::FixedSizeSplitter.new(chunk_size: 200, chunk_overlap: 20)
9
- # chunks = splitter.split({ text: long_text, metadata: { source: "doc.txt" } })
10
- # # => [
11
- # # { text: "...(200 chars)...", metadata: { source: "doc.txt", chunk: 0 } },
12
- # # { text: "...(200 chars, 20-char overlap)...", metadata: { source: "doc.txt", chunk: 1 } },
13
- # # ]
14
- class FixedSizeSplitter < Base
15
- # @param chunk_size [Integer] maximum characters per chunk (default: 1000)
16
- # @param chunk_overlap [Integer] characters to repeat at the start of each
17
- # subsequent chunk (default: 200); must be less than chunk_size
18
- # @api public
19
- def initialize(chunk_size: 1000, chunk_overlap: 200)
20
- raise ArgumentError, "chunk_overlap must be less than chunk_size" if chunk_overlap >= chunk_size
21
-
22
- @chunk_size = chunk_size
23
- @chunk_overlap = chunk_overlap
24
- end
25
-
26
- # @param document [Hash, String]
27
- # @return [Array<Hash>]
28
- # @api public
29
- def split(document)
30
- doc = normalise(document)
31
- text = doc[:text]
32
- base_metadata = doc[:metadata]
33
-
34
- chunks = []
35
- start = 0
36
- index = 0
37
-
38
- while start < text.length
39
- chunk_text = text[start, @chunk_size]
40
- chunks << {text: chunk_text, metadata: base_metadata.merge(chunk: index)}
41
- break if start + @chunk_size >= text.length
42
-
43
- start += @chunk_size - @chunk_overlap
44
- index += 1
45
- end
46
-
47
- chunks
48
- end
49
- end
50
- end
51
- end
@@ -1,105 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Splitter
5
- # Splits text recursively using a prioritised list of separator strings.
6
- #
7
- # The splitter tries each separator in order. When a separator produces
8
- # chunks that are still larger than +chunk_size+, it recurses with the
9
- # next separator in the list. This mirrors LangChain's
10
- # RecursiveCharacterTextSplitter behaviour.
11
- #
12
- # Default separators (in priority order):
13
- # 1. "\n\n" — paragraph breaks
14
- # 2. "\n" — line breaks
15
- # 3. ". " — sentence boundaries
16
- # 4. " " — word boundaries
17
- # 5. "" — character-level fallback
18
- #
19
- # @example
20
- # splitter = Phronomy::Splitter::RecursiveSplitter.new(chunk_size: 300, chunk_overlap: 30)
21
- # chunks = splitter.split({ text: long_markdown, metadata: { source: "guide.md" } })
22
- class RecursiveSplitter < Base
23
- DEFAULT_SEPARATORS = ["\n\n", "\n", ". ", " ", ""].freeze
24
-
25
- # @param chunk_size [Integer] maximum characters per chunk (default: 1000)
26
- # @param chunk_overlap [Integer] overlap characters (default: 200)
27
- # @param separators [Array<String>] separator list in priority order
28
- # @api public
29
- def initialize(chunk_size: 1000, chunk_overlap: 200, separators: DEFAULT_SEPARATORS)
30
- raise ArgumentError, "chunk_overlap must be less than chunk_size" if chunk_overlap >= chunk_size
31
-
32
- @chunk_size = chunk_size
33
- @chunk_overlap = chunk_overlap
34
- @separators = separators
35
- end
36
-
37
- # @param document [Hash, String]
38
- # @return [Array<Hash>]
39
- # @api public
40
- def split(document)
41
- doc = normalise(document)
42
- texts = recursive_split(doc[:text], @separators)
43
- merge_with_overlap(texts).each_with_index.map do |text, idx|
44
- {text: text, metadata: doc[:metadata].merge(chunk: idx)}
45
- end
46
- end
47
-
48
- private
49
-
50
- # Split +text+ using the first separator that yields non-trivial pieces,
51
- # then recurse on any piece that is still too large.
52
- def recursive_split(text, separators)
53
- return [text] if text.length <= @chunk_size || separators.empty?
54
-
55
- sep, *rest_seps = separators
56
-
57
- # Character-level fallback: just slice
58
- if sep == ""
59
- return FixedSizeSplitter
60
- .new(chunk_size: @chunk_size, chunk_overlap: @chunk_overlap)
61
- .split(text)
62
- .map { |c| c[:text] }
63
- end
64
-
65
- parts = text.split(sep)
66
-
67
- # If this separator doesn't split, try the next
68
- return recursive_split(text, rest_seps) if parts.length <= 1
69
-
70
- # Re-attach the separator to each part except the last so context is preserved
71
- parts_with_sep = parts.each_with_index.map do |part, i|
72
- (i < parts.length - 1) ? part + sep : part
73
- end
74
-
75
- parts_with_sep.flat_map do |part|
76
- if part.length > @chunk_size
77
- recursive_split(part, rest_seps)
78
- else
79
- [part]
80
- end
81
- end.reject { |t| t.strip.empty? }
82
- end
83
-
84
- # Merge small adjacent pieces and apply overlap between chunks.
85
- def merge_with_overlap(texts)
86
- merged = []
87
- current = +""
88
-
89
- texts.each do |text|
90
- if current.length + text.length <= @chunk_size
91
- current << text
92
- else
93
- merged << current.strip unless current.strip.empty?
94
- # Start next chunk with overlap from the end of current
95
- overlap_text = (current.length > @chunk_overlap) ? current[-@chunk_overlap..] : current
96
- current = overlap_text + text
97
- end
98
- end
99
-
100
- merged << current.strip unless current.strip.empty?
101
- merged
102
- end
103
- end
104
- end
105
- end
@@ -1,106 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Centralises tool execution routing based on {Tool::Base.execution_mode}.
5
- #
6
- # This is the single place in the framework that decides *how* a tool call is
7
- # dispatched:
8
- #
9
- # - +:cooperative+ — dispatched via +Runtime#spawn+ through the configured
10
- # scheduler. Under the +:fiber+ backend this avoids an
11
- # extra OS thread; under the +:thread+ backend it is
12
- # backed by +ThreadScheduler+ (one thread per task).
13
- # - +:blocking_io+ — submitted to +BlockingAdapterPool+ when the runtime
14
- # provides a pool; falls back to +Runtime#spawn+ otherwise.
15
- # - +:cpu_bound+ — emits a deprecation-style warning then falls back to
16
- # +:blocking_io+ routing (no process pool available yet).
17
- # - +:external_process+ — falls back to +:blocking_io+ routing (no process
18
- # manager available yet).
19
- #
20
- # All paths return an object that responds to +#await+ (+Phronomy::Task+ or
21
- # +BlockingAdapterPool::PendingOperation+), so callers can collect results
22
- # uniformly.
23
- #
24
- # @note Non-goals
25
- # ToolExecutor deliberately does NOT provide:
26
- # - A CPU-bound process pool. CPU-intensive tool work must be handled at the
27
- # application layer (e.g., fork, Sidekiq, separate OS processes). The
28
- # framework will not add a +ProcessPoolExecutor+ equivalent.
29
- # - An external process manager. Spawning or supervising subprocesses is
30
- # out of scope for this module.
31
- # - Additional core execution routes beyond scheduler-backed cooperative
32
- # execution and BlockingAdapterPool-backed blocking I/O isolation.
33
- # The +:cpu_bound+ and +:external_process+ modes are accepted for
34
- # compatibility but both fall back to +:blocking_io+ routing with a
35
- # one-time warning. If a genuinely new core execution route is needed,
36
- # a new ADR is required.
37
- # These non-goals follow from the cooperative-first, non-preemptive
38
- # concurrency model (ADR-010): framework components must not assume the
39
- # caller's concurrency model, and CPU/process management belongs to the
40
- # application layer.
41
- #
42
- # @api private
43
- module ToolExecutor
44
- # Tracks tool classes that have already emitted an execution_mode warning so
45
- # that the same warning is only logged once per process lifetime.
46
- WARNED_MODES = Set.new
47
- WARNED_MODES_MUTEX = Mutex.new
48
- private_constant :WARNED_MODES, :WARNED_MODES_MUTEX
49
-
50
- # Dispatches a single tool call asynchronously according to its
51
- # +execution_mode+ and returns an awaitable.
52
- #
53
- # @param tool [Phronomy::Tool::Base] the tool instance to invoke
54
- # @param args [Hash] argument hash to pass to {Tool::Base#call}
55
- # @param cancellation_token [Phronomy::CancellationToken, nil]
56
- # @param runtime [Phronomy::Runtime] runtime to use for spawning
57
- # (defaults to {Runtime.instance}; injectable for tests)
58
- # @return [#await] a {Phronomy::Task} or {BlockingAdapterPool::PendingOperation}
59
- # @api private
60
- def self.call_async(tool:, args:, cancellation_token: nil, runtime: Phronomy::Runtime.instance)
61
- ct = cancellation_token
62
- mode = tool.class.execution_mode
63
-
64
- # Warn and normalise unsupported modes to :blocking_io.
65
- # Each (tool class, mode) pair emits the warning at most once per process
66
- # lifetime to avoid log flooding in high-throughput scenarios.
67
- if mode == :cpu_bound || mode == :external_process
68
- warn_key = [tool.class.name, mode]
69
- newly_warned = WARNED_MODES_MUTEX.synchronize { WARNED_MODES.add?(warn_key) }
70
- if newly_warned
71
- msg = if mode == :cpu_bound
72
- "[Phronomy] Tool #{tool.class.name} declares execution_mode :cpu_bound, " \
73
- "which has no dedicated executor. " \
74
- "Falling back to blocking_io (BlockingAdapterPool). " \
75
- "Use :blocking_io explicitly to suppress this warning."
76
- else
77
- "[Phronomy] Tool #{tool.class.name} declares execution_mode :external_process, " \
78
- "which has no dedicated process manager. " \
79
- "Falling back to blocking_io (BlockingAdapterPool)."
80
- end
81
- if Phronomy.configuration.logger
82
- Phronomy.configuration.logger.warn(msg)
83
- else
84
- warn msg
85
- end
86
- end
87
- mode = :blocking_io
88
- end
89
-
90
- pool = begin
91
- runtime&.blocking_io
92
- rescue
93
- nil
94
- end
95
-
96
- if mode == :cooperative || pool.nil?
97
- runtime.spawn(name: "tool-#{tool.class.name.to_s.split("::").last}") do
98
- tool.call(args, cancellation_token: ct)
99
- end
100
- else
101
- # Submit directly to pool — no wrapping Task thread required.
102
- pool.submit(cancellation_token: ct) { tool.call(args, cancellation_token: ct) }
103
- end
104
- end
105
- end
106
- end
@@ -1,110 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module VectorStore
5
- # Mixin that defines the async interface for VectorStore backends.
6
- #
7
- # Mixing this module into a VectorStore class provides three choices:
8
- #
9
- # 1. **Do nothing** — inherits default implementations from {VectorStore::Base}
10
- # that route through {BlockingAdapterPool} (the previous behaviour).
11
- #
12
- # 2. **Override selectively** — override only the async methods where the
13
- # backend has a native async driver, while the remaining methods fall back
14
- # to the pool.
15
- #
16
- # 3. **Implement all natively** — override all async methods to avoid pool
17
- # allocation entirely.
18
- #
19
- # @example Native async search (no pool worker thread allocated)
20
- # class MyFastStore < Phronomy::VectorStore::Base
21
- # include Phronomy::VectorStore::AsyncBackend
22
- #
23
- # def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
24
- # # Returns a PendingOperation backed by a native async driver.
25
- # native_async_search(query_embedding, k)
26
- # end
27
- # end
28
- #
29
- # @api public
30
- module AsyncBackend
31
- # Async variant of {VectorStore::Base#add}.
32
- #
33
- # Submits the add call to {BlockingAdapterPool} by default.
34
- # Override to use a native async driver.
35
- #
36
- # @param id [String]
37
- # @param embedding [Array<Float>]
38
- # @param metadata [Hash]
39
- # @param cancellation_token [Phronomy::CancellationToken, nil]
40
- # @param timeout [Numeric, nil]
41
- # @return [BlockingAdapterPool::PendingOperation]
42
- # @api public
43
- def add_async(id:, embedding:, metadata: {}, cancellation_token: nil, timeout: nil)
44
- Phronomy::Runtime.instance.blocking_io.submit(
45
- timeout: timeout,
46
- cancellation_token: cancellation_token
47
- ) do
48
- add(id: id, embedding: embedding, metadata: metadata, cancellation_token: cancellation_token)
49
- end
50
- end
51
-
52
- # Async variant of {VectorStore::Base#search}.
53
- #
54
- # Submits the search call to {BlockingAdapterPool} by default.
55
- # Override to use a native async driver.
56
- #
57
- # @param query_embedding [Array<Float>]
58
- # @param k [Integer]
59
- # @param cancellation_token [Phronomy::CancellationToken, nil]
60
- # @param timeout [Numeric, nil]
61
- # @return [BlockingAdapterPool::PendingOperation]
62
- # @api public
63
- def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
64
- Phronomy::Runtime.instance.blocking_io.submit(
65
- timeout: timeout,
66
- cancellation_token: cancellation_token
67
- ) do
68
- search(query_embedding: query_embedding, k: k, cancellation_token: cancellation_token)
69
- end
70
- end
71
-
72
- # Async variant of {VectorStore::Base#remove}.
73
- #
74
- # Submits the remove call to {BlockingAdapterPool} by default.
75
- # Override to use a native async driver.
76
- #
77
- # @param id [String]
78
- # @param cancellation_token [Phronomy::CancellationToken, nil]
79
- # @param timeout [Numeric, nil]
80
- # @return [BlockingAdapterPool::PendingOperation]
81
- # @api public
82
- def remove_async(id:, cancellation_token: nil, timeout: nil)
83
- Phronomy::Runtime.instance.blocking_io.submit(
84
- timeout: timeout,
85
- cancellation_token: cancellation_token
86
- ) do
87
- remove(id: id)
88
- end
89
- end
90
-
91
- # Async variant of {VectorStore::Base#clear}.
92
- #
93
- # Submits the clear call to {BlockingAdapterPool} by default.
94
- # Override to use a native async driver.
95
- #
96
- # @param cancellation_token [Phronomy::CancellationToken, nil]
97
- # @param timeout [Numeric, nil]
98
- # @return [BlockingAdapterPool::PendingOperation]
99
- # @api public
100
- def clear_async(cancellation_token: nil, timeout: nil)
101
- Phronomy::Runtime.instance.blocking_io.submit(
102
- timeout: timeout,
103
- cancellation_token: cancellation_token
104
- ) do
105
- clear
106
- end
107
- end
108
- end
109
- end
110
- end
@@ -1,89 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module VectorStore
5
- # Abstract interface for vector stores.
6
- #
7
- # Implementations manage a collection of (embedding, metadata) pairs and
8
- # support similarity search.
9
- #
10
- # Async methods (`search_async`, `add_async`, `remove_async`, `clear_async`)
11
- # are provided by the {AsyncBackend} mixin which defaults to routing calls
12
- # through {BlockingAdapterPool}. Backends with native async drivers may
13
- # override individual async methods without touching the pool at all.
14
- class Base
15
- include AsyncBackend
16
-
17
- # Add a document with its vector embedding.
18
- #
19
- # @param id [String] unique document identifier
20
- # @param embedding [Array<Float>] vector embedding
21
- # @param metadata [Hash] arbitrary metadata (e.g. the original message object)
22
- # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
23
- # @api public
24
- def add(id:, embedding:, metadata: {}, cancellation_token: nil)
25
- cancellation_token&.raise_if_cancelled!
26
- raise NotImplementedError, "#{self.class}#add is not implemented"
27
- end
28
-
29
- # Return the k most similar documents to the query embedding.
30
- #
31
- # @param query_embedding [Array<Float>]
32
- # @param k [Integer] number of results
33
- # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
34
- # @return [Array<Hash>] each element: { id:, score:, metadata: }
35
- # @api public
36
- def search(query_embedding:, k: 5, cancellation_token: nil)
37
- cancellation_token&.raise_if_cancelled!
38
- raise NotImplementedError, "#{self.class}#search is not implemented"
39
- end
40
-
41
- # Remove a single document by id.
42
- #
43
- # @param id [String] document identifier
44
- # @api public
45
- def remove(id:)
46
- raise NotImplementedError, "#{self.class}#remove is not implemented"
47
- end
48
-
49
- # Remove all documents.
50
- def clear
51
- raise NotImplementedError, "#{self.class}#clear is not implemented"
52
- end
53
-
54
- # Return the number of documents stored.
55
- #
56
- # @return [Integer]
57
- # @api public
58
- def size
59
- raise NotImplementedError, "#{self.class}#size is not implemented"
60
- end
61
-
62
- private
63
-
64
- # Validates that embedding has the expected dimension.
65
- # Raises ArgumentError if sizes differ.
66
- # A nil expected_dimension is a no-op (dimension not yet established).
67
- def validate_embedding_dimension!(embedding, expected_dimension)
68
- return unless expected_dimension
69
-
70
- actual = embedding.size
71
- return if actual == expected_dimension
72
-
73
- raise ArgumentError,
74
- "Embedding dimension mismatch: expected #{expected_dimension}, got #{actual}"
75
- end
76
-
77
- # Validates that k is a positive integer.
78
- # Accepts any value accepted by Integer() (e.g. "5"), but raises
79
- # ArgumentError for non-integer strings, zero, and negative values.
80
- def validate_k!(k)
81
- int_k = Integer(k)
82
- raise ArgumentError, "k must be a positive integer, got #{int_k}" unless int_k >= 1
83
- int_k
84
- rescue ArgumentError => e
85
- raise ArgumentError, "k must be a positive integer: #{e.message}"
86
- end
87
- end
88
- end
89
- end