phronomy 0.7.0 → 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 (134) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +170 -47
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_context_assembler.rb +2 -2
  8. data/benchmark/bench_regression.rb +6 -5
  9. data/benchmark/bench_token_estimator.rb +5 -5
  10. data/benchmark/bench_tool_schema.rb +1 -1
  11. data/benchmark/bench_vector_store.rb +1 -1
  12. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  13. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  14. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  15. data/lib/phronomy/agent/base.rb +285 -137
  16. data/lib/phronomy/agent/checkpoint.rb +118 -0
  17. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  18. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  19. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  20. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  21. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  23. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  24. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  25. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  26. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  27. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  28. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  29. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  30. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  31. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  32. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  33. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  34. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  35. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  36. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  37. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  38. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  39. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  40. data/lib/phronomy/agent/fsm.rb +42 -65
  41. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  42. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  43. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  44. data/lib/phronomy/agent/react_agent.rb +27 -14
  45. data/lib/phronomy/agent/runner.rb +2 -2
  46. data/lib/phronomy/agent/tool_executor.rb +108 -0
  47. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  48. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  49. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  50. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  51. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  52. data/lib/phronomy/concurrency/deadline.rb +65 -0
  53. data/lib/phronomy/concurrency/gate_registry.rb +52 -0
  54. data/lib/phronomy/concurrency/pool_registry.rb +57 -0
  55. data/lib/phronomy/configuration.rb +142 -0
  56. data/lib/phronomy/context.rb +2 -8
  57. data/lib/phronomy/diagnostics.rb +62 -0
  58. data/lib/phronomy/embeddings.rb +2 -2
  59. data/lib/phronomy/eval/runner.rb +13 -9
  60. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  61. data/lib/phronomy/event_loop.rb +184 -46
  62. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  63. data/lib/phronomy/invocation_context.rb +152 -0
  64. data/lib/phronomy/knowledge_source.rb +0 -5
  65. data/lib/phronomy/llm_adapter/base.rb +104 -0
  66. data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
  67. data/lib/phronomy/llm_adapter.rb +20 -0
  68. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  69. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  70. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  71. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  72. data/lib/phronomy/loader.rb +4 -4
  73. data/lib/phronomy/metrics.rb +38 -0
  74. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  75. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
  76. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
  77. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  78. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  79. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  80. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  81. data/lib/phronomy/runtime/scheduler.rb +98 -0
  82. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  83. data/lib/phronomy/runtime/task_registry.rb +48 -0
  84. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  85. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  86. data/lib/phronomy/runtime/timer_service.rb +42 -0
  87. data/lib/phronomy/runtime.rb +389 -0
  88. data/lib/phronomy/splitter.rb +3 -3
  89. data/lib/phronomy/task/backend.rb +80 -0
  90. data/lib/phronomy/task/fiber_backend.rb +157 -0
  91. data/lib/phronomy/task/immediate_backend.rb +89 -0
  92. data/lib/phronomy/task/thread_backend.rb +84 -0
  93. data/lib/phronomy/task.rb +275 -0
  94. data/lib/phronomy/task_group.rb +265 -0
  95. data/lib/phronomy/testing/fake_clock.rb +109 -0
  96. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  97. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  98. data/lib/phronomy/testing.rb +12 -0
  99. data/lib/phronomy/tool/base.rb +156 -7
  100. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  101. data/lib/phronomy/tool/scope_policy.rb +50 -0
  102. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  103. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  104. data/lib/phronomy/vector_store.rb +2 -2
  105. data/lib/phronomy/version.rb +1 -1
  106. data/lib/phronomy/workflow.rb +52 -5
  107. data/lib/phronomy/workflow_context.rb +37 -2
  108. data/lib/phronomy/workflow_runner.rb +28 -77
  109. data/lib/phronomy.rb +43 -0
  110. metadata +73 -33
  111. data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
  112. data/lib/phronomy/cancellation_token.rb +0 -92
  113. data/lib/phronomy/context/compaction_context.rb +0 -111
  114. data/lib/phronomy/context/trigger_context.rb +0 -39
  115. data/lib/phronomy/context/trim_context.rb +0 -75
  116. data/lib/phronomy/embeddings/base.rb +0 -22
  117. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  118. data/lib/phronomy/fsm_session.rb +0 -201
  119. data/lib/phronomy/knowledge_source/base.rb +0 -36
  120. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  121. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  122. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  123. data/lib/phronomy/loader/base.rb +0 -25
  124. data/lib/phronomy/loader/csv_loader.rb +0 -56
  125. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  126. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  127. data/lib/phronomy/prompt_template.rb +0 -96
  128. data/lib/phronomy/splitter/base.rb +0 -47
  129. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  130. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  131. data/lib/phronomy/vector_store/base.rb +0 -82
  132. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  133. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  134. 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,82 +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
- class Base
10
- # Add a document with its vector embedding.
11
- #
12
- # @param id [String] unique document identifier
13
- # @param embedding [Array<Float>] vector embedding
14
- # @param metadata [Hash] arbitrary metadata (e.g. the original message object)
15
- # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
16
- # @api public
17
- def add(id:, embedding:, metadata: {}, cancellation_token: nil)
18
- cancellation_token&.raise_if_cancelled!
19
- raise NotImplementedError, "#{self.class}#add is not implemented"
20
- end
21
-
22
- # Return the k most similar documents to the query embedding.
23
- #
24
- # @param query_embedding [Array<Float>]
25
- # @param k [Integer] number of results
26
- # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
27
- # @return [Array<Hash>] each element: { id:, score:, metadata: }
28
- # @api public
29
- def search(query_embedding:, k: 5, cancellation_token: nil)
30
- cancellation_token&.raise_if_cancelled!
31
- raise NotImplementedError, "#{self.class}#search is not implemented"
32
- end
33
-
34
- # Remove a single document by id.
35
- #
36
- # @param id [String] document identifier
37
- # @api public
38
- def remove(id:)
39
- raise NotImplementedError, "#{self.class}#remove is not implemented"
40
- end
41
-
42
- # Remove all documents.
43
- def clear
44
- raise NotImplementedError, "#{self.class}#clear is not implemented"
45
- end
46
-
47
- # Return the number of documents stored.
48
- #
49
- # @return [Integer]
50
- # @api public
51
- def size
52
- raise NotImplementedError, "#{self.class}#size is not implemented"
53
- end
54
-
55
- private
56
-
57
- # Validates that embedding has the expected dimension.
58
- # Raises ArgumentError if sizes differ.
59
- # A nil expected_dimension is a no-op (dimension not yet established).
60
- def validate_embedding_dimension!(embedding, expected_dimension)
61
- return unless expected_dimension
62
-
63
- actual = embedding.size
64
- return if actual == expected_dimension
65
-
66
- raise ArgumentError,
67
- "Embedding dimension mismatch: expected #{expected_dimension}, got #{actual}"
68
- end
69
-
70
- # Validates that k is a positive integer.
71
- # Accepts any value accepted by Integer() (e.g. "5"), but raises
72
- # ArgumentError for non-integer strings, zero, and negative values.
73
- def validate_k!(k)
74
- int_k = Integer(k)
75
- raise ArgumentError, "k must be a positive integer, got #{int_k}" unless int_k >= 1
76
- int_k
77
- rescue ArgumentError => e
78
- raise ArgumentError, "k must be a positive integer: #{e.message}"
79
- end
80
- end
81
- end
82
- end
@@ -1,93 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module VectorStore
5
- # Pure-Ruby in-memory vector store using cosine similarity.
6
- #
7
- # Intended for tests, short-lived agents, and Retrieval::Semantic scenarios where
8
- # the message count is small enough that a linear scan is fast enough.
9
- #
10
- # @example
11
- # store = Phronomy::VectorStore::InMemory.new
12
- # store.add(id: "1", embedding: [0.1, 0.9], metadata: { message: msg })
13
- # results = store.search(query_embedding: [0.1, 0.8], k: 3)
14
- class InMemory < Base
15
- # @param dimension [Integer, nil] expected embedding dimension.
16
- # When nil, the dimension is inferred from the first call to #add.
17
- # For multi-threaded use, pass dimension: explicitly; concurrent first
18
- # adds are not guaranteed to be race-free.
19
- # @api public
20
- def initialize(dimension: nil)
21
- @documents = {}
22
- @expected_dimension = dimension
23
- end
24
-
25
- # @param id [String]
26
- # @param embedding [Array<Float>]
27
- # @param metadata [Hash]
28
- # @param cancellation_token [Phronomy::CancellationToken, nil]
29
- # @api public
30
- def add(id:, embedding:, metadata: {}, cancellation_token: nil)
31
- cancellation_token&.raise_if_cancelled!
32
- # Establish expected dimension on first add, then validate.
33
- @expected_dimension ||= embedding.size
34
- validate_embedding_dimension!(embedding, @expected_dimension)
35
- @documents[id] = {embedding: embedding, metadata: metadata}
36
- self
37
- end
38
-
39
- # @param query_embedding [Array<Float>]
40
- # @param k [Integer]
41
- # @param cancellation_token [Phronomy::CancellationToken, nil]
42
- # @return [Array<Hash>] sorted by descending score
43
- # @api public
44
- def search(query_embedding:, k: 5, cancellation_token: nil)
45
- cancellation_token&.raise_if_cancelled!
46
- k = validate_k!(k)
47
- # search never establishes dimension; validate only when dimension is known.
48
- validate_embedding_dimension!(query_embedding, @expected_dimension)
49
- # Take an atomic snapshot before iterating. Hash#dup is a C-level
50
- # call that completes without releasing the GVL, so it is atomic with
51
- # respect to any other Ruby thread. Iterating the copy instead of
52
- # @documents directly prevents "can't add a new key into hash during
53
- # iteration" when a concurrent thread calls #add.
54
- snapshot = @documents.dup
55
- results = snapshot.map do |id, doc|
56
- score = cosine_similarity(query_embedding, doc[:embedding])
57
- {id: id, score: score, metadata: doc[:metadata]}
58
- end
59
- results.sort_by { |r| -r[:score] }.first(k)
60
- end
61
-
62
- def remove(id:)
63
- @documents.delete(id)
64
- self
65
- end
66
-
67
- def clear
68
- @documents.clear
69
- self
70
- end
71
-
72
- # @return [Integer] number of documents stored
73
- # @api public
74
- def size
75
- @documents.size
76
- end
77
-
78
- private
79
-
80
- def cosine_similarity(a, b)
81
- return 0.0 if a.empty? || b.empty?
82
-
83
- dot = a.zip(b).sum { |x, y| x * y }
84
- norm_a = Math.sqrt(a.sum { |x| x**2 })
85
- norm_b = Math.sqrt(b.sum { |x| x**2 })
86
-
87
- return 0.0 if norm_a.zero? || norm_b.zero?
88
-
89
- dot / (norm_a * norm_b)
90
- end
91
- end
92
- end
93
- end
@@ -1,127 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
-
5
- module Phronomy
6
- module VectorStore
7
- # PostgreSQL-backed vector store using the pgvector extension.
8
- #
9
- # Requires:
10
- # - The +pgvector+ gem (add to your Gemfile)
11
- # - An ActiveRecord model class with the following columns:
12
- # id (string / uuid)
13
- # embedding (vector — from the pgvector column type)
14
- # metadata (text or jsonb — stores arbitrary metadata as JSON)
15
- #
16
- # @example Usage
17
- # store = Phronomy::VectorStore::Pgvector.new(model_class: VectorDocument)
18
- # store.add(id: "doc1", embedding: [0.1, 0.9], metadata: {text: "hello"})
19
- # results = store.search(query_embedding: [0.1, 0.8], k: 5)
20
- class Pgvector < Base
21
- # @param model_class [Class] ActiveRecord model with id/embedding/metadata columns
22
- # @param dimension [Integer, nil] expected embedding dimension for Phronomy-side
23
- # pre-validation. When nil, dimension enforcement is delegated to the
24
- # database schema; no pre-validation is performed by Phronomy.
25
- # @api public
26
- def initialize(model_class:, dimension: nil)
27
- begin
28
- require "pgvector"
29
- rescue LoadError
30
- raise LoadError,
31
- "pgvector gem is required for Phronomy::VectorStore::Pgvector. " \
32
- "Add `gem 'pgvector'` to your Gemfile."
33
- end
34
- @model_class = model_class
35
- @dimension = dimension
36
- end
37
-
38
- # @param id [String]
39
- # @param embedding [Array<Float>]
40
- # @param metadata [Hash]
41
- # @param cancellation_token [Phronomy::CancellationToken, nil]
42
- # @api public
43
- def add(id:, embedding:, metadata: {}, cancellation_token: nil)
44
- cancellation_token&.raise_if_cancelled!
45
- validate_embedding_dimension!(embedding, @dimension)
46
- @model_class.upsert(
47
- {id: id, embedding: safe_vector(embedding), metadata: metadata.to_json},
48
- unique_by: :id
49
- )
50
- self
51
- end
52
-
53
- # @param query_embedding [Array<Float>]
54
- # @param k [Integer]
55
- # @param cancellation_token [Phronomy::CancellationToken, nil]
56
- # @return [Array<Hash>] sorted by descending similarity score
57
- # @api public
58
- def search(query_embedding:, k: 5, cancellation_token: nil)
59
- cancellation_token&.raise_if_cancelled!
60
- k_safe = validate_k!(k)
61
- validate_embedding_dimension!(query_embedding, @dimension)
62
- vec = safe_vector_literal(query_embedding)
63
- conn = @model_class.connection
64
- quoted_vec = "#{conn.quote(vec)}::vector"
65
-
66
- @model_class
67
- .select("id, metadata, 1 - (embedding <=> #{quoted_vec}) AS score")
68
- .order("embedding <=> #{quoted_vec}")
69
- .limit(k_safe)
70
- .map do |r|
71
- {
72
- id: r.id.to_s,
73
- score: r.score.to_f,
74
- metadata: parse_metadata(r.metadata)
75
- }
76
- end
77
- end
78
-
79
- def remove(id:)
80
- @model_class.where(id: id).delete_all
81
- self
82
- end
83
-
84
- def clear
85
- @model_class.delete_all
86
- self
87
- end
88
-
89
- # Returns the number of documents in the backing table.
90
- def size
91
- @model_class.count
92
- end
93
-
94
- private
95
-
96
- # Parses a metadata value returned by the pg driver.
97
- # Handles NULL (nil), already-parsed Hash, and JSON string forms.
98
- def parse_metadata(raw)
99
- return {} if raw.nil?
100
- return symbolize_hash_keys(raw) if raw.is_a?(Hash)
101
-
102
- parsed = JSON.parse(raw.to_s, symbolize_names: true)
103
- parsed.is_a?(Hash) ? parsed : {}
104
- rescue JSON::ParserError
105
- {}
106
- end
107
-
108
- # Recursively symbolizes keys for an already-parsed Hash.
109
- def symbolize_hash_keys(hash)
110
- hash.each_with_object({}) do |(k, v), h|
111
- h[k.to_sym] = v.is_a?(Hash) ? symbolize_hash_keys(v) : v
112
- end
113
- end
114
-
115
- # Validates that all elements are numeric and converts to a pgvector-
116
- # compatible literal string (e.g. "[1.0,0.5,-0.3]").
117
- def safe_vector_literal(embedding)
118
- "[#{embedding.map { |v| Float(v) }.join(",")}]"
119
- end
120
-
121
- # Returns a validated vector for the upsert call.
122
- def safe_vector(embedding)
123
- safe_vector_literal(embedding)
124
- end
125
- end
126
- end
127
- end