maglev-rb 0.1.1 → 0.2.1

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -0
  3. data/README.ja.md +618 -0
  4. data/README.md +533 -248
  5. data/README.zh-CN.md +457 -250
  6. data/lib/generators/maglev/install/install_generator.rb +20 -0
  7. data/lib/generators/maglev/upgrade_index_version/upgrade_index_version_generator.rb +27 -0
  8. data/lib/generators/maglev/upgrade_source_identity/upgrade_source_identity_generator.rb +52 -0
  9. data/lib/maglev/active_record_extension.rb +141 -24
  10. data/lib/maglev/adapters/faraday_client.rb +94 -0
  11. data/lib/maglev/adapters/faraday_embedding.rb +51 -0
  12. data/lib/maglev/adapters/faraday_generation.rb +49 -0
  13. data/lib/maglev/adapters/faraday_planner.rb +88 -0
  14. data/lib/maglev/answerer.rb +30 -12
  15. data/lib/maglev/chunker.rb +39 -4
  16. data/lib/maglev/configuration.rb +66 -1
  17. data/lib/maglev/content_source_graph.rb +17 -11
  18. data/lib/maglev/dependency_graph.rb +72 -13
  19. data/lib/maglev/embedding_adapter.rb +10 -0
  20. data/lib/maglev/hybrid_candidate_set.rb +25 -0
  21. data/lib/maglev/hybrid_coordinator.rb +112 -0
  22. data/lib/maglev/hybrid_result.rb +25 -0
  23. data/lib/maglev/index_diagnostics.rb +83 -0
  24. data/lib/maglev/index_identity.rb +70 -0
  25. data/lib/maglev/index_state.rb +9 -0
  26. data/lib/maglev/indexer.rb +185 -35
  27. data/lib/maglev/knowledge_config.rb +27 -5
  28. data/lib/maglev/knowledge_registry.rb +33 -0
  29. data/lib/maglev/planner.rb +172 -0
  30. data/lib/maglev/planner_adapter.rb +25 -0
  31. data/lib/maglev/planner_evaluation.rb +49 -0
  32. data/lib/maglev/query_compiler.rb +197 -0
  33. data/lib/maglev/query_ir.rb +143 -0
  34. data/lib/maglev/query_validator.rb +311 -0
  35. data/lib/maglev/railtie.rb +9 -0
  36. data/lib/maglev/registry.rb +72 -0
  37. data/lib/maglev/reindex_job.rb +34 -2
  38. data/lib/maglev/relation_order.rb +16 -0
  39. data/lib/maglev/request.rb +22 -0
  40. data/lib/maglev/request_executor.rb +101 -0
  41. data/lib/maglev/resource_config.rb +222 -0
  42. data/lib/maglev/response.rb +2 -2
  43. data/lib/maglev/result.rb +30 -0
  44. data/lib/maglev/retrieval_outcome.rb +52 -0
  45. data/lib/maglev/retrieval_result.rb +25 -0
  46. data/lib/maglev/retriever.rb +282 -27
  47. data/lib/maglev/router.rb +77 -0
  48. data/lib/maglev/routing_adapter.rb +25 -0
  49. data/lib/maglev/schema_compiler.rb +17 -4
  50. data/lib/maglev/schema_snapshot.rb +159 -0
  51. data/lib/maglev/search_result.rb +7 -3
  52. data/lib/maglev/snapshot.rb +21 -1
  53. data/lib/maglev/snapshot_budget.rb +57 -0
  54. data/lib/maglev/snapshot_builder.rb +89 -11
  55. data/lib/maglev/source_extractor.rb +43 -0
  56. data/lib/maglev/source_fragment.rb +9 -0
  57. data/lib/maglev/structured_answer_composer.rb +67 -0
  58. data/lib/maglev/structured_evidence_builder.rb +56 -0
  59. data/lib/maglev/structured_executor.rb +157 -0
  60. data/lib/maglev/structured_result.rb +97 -0
  61. data/lib/maglev/trace.rb +56 -0
  62. data/lib/maglev/vector_stores/base.rb +12 -0
  63. data/lib/maglev/vector_stores/document.rb +14 -5
  64. data/lib/maglev/vector_stores/document_id.rb +27 -0
  65. data/lib/maglev/vector_stores/memory.rb +68 -6
  66. data/lib/maglev/vector_stores/metadata_filter.rb +55 -0
  67. data/lib/maglev/vector_stores/pgvector.rb +94 -7
  68. data/lib/maglev/version.rb +1 -1
  69. data/lib/maglev-rb.rb +3 -0
  70. data/lib/maglev.rb +36 -3
  71. data/lib/tasks/maglev.rake +43 -0
  72. metadata +71 -11
  73. data/lib/maglev/adapters/ruby_llm_attachment_extractor.rb +0 -15
  74. data/lib/maglev/adapters/ruby_llm_embedding.rb +0 -22
  75. data/lib/maglev/adapters/ruby_llm_generation.rb +0 -22
  76. data/lib/maglev/adapters/ruby_llm_provider.rb +0 -64
@@ -2,21 +2,25 @@
2
2
 
3
3
  module Maglev
4
4
  class SearchResult
5
- attr_reader :owner, :content, :source, :distance, :chunk_index
5
+ attr_reader :owner, :content, :source, :distance, :chunk_index, :source_identity, :source_type, :score
6
6
 
7
- def initialize(owner:, content:, source:, distance:, chunk_index: nil)
7
+ def initialize(owner:, content:, source:, distance:, chunk_index: nil, source_identity: nil, source_type: nil, score: nil)
8
8
  @owner = owner
9
9
  @content = content
10
10
  @source = source
11
11
  @distance = distance
12
12
  @chunk_index = chunk_index
13
+ @source_identity = source_identity || source
14
+ @source_type = (source_type || :snapshot).to_sym
15
+ @score = score
13
16
  freeze
14
17
  end
15
18
 
16
19
  def similarity
20
+ return score.clamp(0.0, 1.0) unless score.nil?
17
21
  return nil if distance.nil?
18
22
 
19
- 1.0 - distance.to_f
23
+ (1.0 - distance.to_f).clamp(0.0, 1.0)
20
24
  end
21
25
  end
22
26
  end
@@ -2,13 +2,33 @@
2
2
 
3
3
  module Maglev
4
4
  class Snapshot
5
- def initialize(lines)
5
+ attr_reader :metadata
6
+
7
+ def initialize(lines, metadata: {})
6
8
  @text = lines.join("\n").freeze
9
+ @metadata = deep_freeze(metadata)
7
10
  freeze
8
11
  end
9
12
 
10
13
  def to_s
11
14
  @text
12
15
  end
16
+
17
+ def truncated?
18
+ @metadata[:truncated] == true
19
+ end
20
+
21
+ private
22
+
23
+ def deep_freeze(value)
24
+ case value
25
+ when Hash
26
+ value.to_h { |key, item| [key, deep_freeze(item)] }.freeze
27
+ when Array
28
+ value.map { |item| deep_freeze(item) }.freeze
29
+ else
30
+ value.freeze
31
+ end
32
+ end
13
33
  end
14
34
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maglev
4
+ class SnapshotBudget
5
+ LIMIT_METHODS = {
6
+ attribute: :snapshot_attribute_max_characters,
7
+ rich_text: :snapshot_attribute_max_characters,
8
+ attachment: :snapshot_attribute_max_characters,
9
+ related_record: :snapshot_related_record_max_characters,
10
+ whole_snapshot: :snapshot_max_characters
11
+ }.freeze
12
+
13
+ def initialize(configuration: Maglev.configuration)
14
+ @limits = {
15
+ attribute_characters: configuration.snapshot_attribute_max_characters,
16
+ related_record_characters: configuration.snapshot_related_record_max_characters,
17
+ snapshot_characters: configuration.snapshot_max_characters,
18
+ chunks: configuration.snapshot_max_chunks
19
+ }.freeze
20
+ @configuration = configuration
21
+ @sources = []
22
+ end
23
+
24
+ def truncate(text, kind:, path:)
25
+ value = text.to_s
26
+ limit = @configuration.public_send(LIMIT_METHODS.fetch(kind))
27
+ return value if value.length <= limit
28
+
29
+ @sources << {
30
+ kind: kind,
31
+ path: path,
32
+ original_characters: value.length,
33
+ retained_characters: limit
34
+ }.freeze
35
+ value[0, limit]
36
+ end
37
+
38
+ def record_chunk_truncation(original:, retained:, path: "snapshot.chunks")
39
+ return if original <= retained
40
+
41
+ @sources << {
42
+ kind: :chunks,
43
+ path: path,
44
+ original_chunks: original,
45
+ retained_chunks: retained
46
+ }.freeze
47
+ end
48
+
49
+ def metadata
50
+ {
51
+ truncated: @sources.any?,
52
+ limits: @limits,
53
+ sources: @sources.dup.freeze
54
+ }.freeze
55
+ end
56
+ end
57
+ end
@@ -3,24 +3,30 @@
3
3
  require "cgi"
4
4
 
5
5
  require_relative "attachment_extractor"
6
+ require_relative "relation_order"
6
7
  require_relative "snapshot"
8
+ require_relative "snapshot_budget"
7
9
 
8
10
  module Maglev
9
11
  class SnapshotBuilder
10
- def initialize(record, config, path: nil, visited: nil, attachment_extractor: nil, remaining_depth: Maglev.configuration.max_relation_depth)
12
+ def initialize(record, config, path: nil, visited: nil, attachment_extractor: nil, remaining_depth: Maglev.configuration.max_relation_depth, budget: nil)
11
13
  @record = record
12
14
  @config = config
13
15
  @path = path
14
16
  @visited = visited || {}
15
17
  @attachment_extractor = attachment_extractor || Maglev.configuration.attachment_extractor || AttachmentExtractor.new
16
18
  @remaining_depth = remaining_depth
19
+ @budget = budget || SnapshotBudget.new
17
20
  end
18
21
 
19
22
  def build
20
23
  return Snapshot.new([]) if visited?
21
24
 
22
25
  mark_visited
23
- Snapshot.new(lines)
26
+ raw_lines = lines
27
+ text = raw_lines.join("\n")
28
+ text = @budget.truncate(text, kind: :whole_snapshot, path: "snapshot") unless @path
29
+ Snapshot.new(text.lines(chomp: true), metadata: @budget.metadata)
24
30
  end
25
31
 
26
32
  private
@@ -49,7 +55,10 @@ module Maglev
49
55
  def attribute_lines
50
56
  @config.exposed_attributes.filter_map do |attribute|
51
57
  value = @record.public_send(attribute)
52
- "#{attribute_path(attribute)}: #{value}" unless value.nil?
58
+ next if value.nil?
59
+
60
+ text = @budget.truncate(value, kind: :attribute, path: attribute_path(attribute))
61
+ "#{attribute_path(attribute)}: #{text}"
53
62
  end
54
63
  end
55
64
 
@@ -67,14 +76,16 @@ module Maglev
67
76
  relation_records, collection = relation_records(relation)
68
77
  relation_records.each_with_index.flat_map do |related_record, index|
69
78
  relation_path = path_for(relation, index, collection: collection)
70
- SnapshotBuilder.new(
79
+ child = SnapshotBuilder.new(
71
80
  related_record,
72
81
  related_record.class.maglev_config,
73
82
  path: relation_path,
74
83
  visited: @visited,
75
84
  attachment_extractor: @attachment_extractor,
76
- remaining_depth: remaining_depth
77
- ).build.to_s.split("\n")
85
+ remaining_depth: remaining_depth,
86
+ budget: @budget
87
+ ).build.to_s
88
+ @budget.truncate(child, kind: :related_record, path: relation_path).split("\n")
78
89
  end
79
90
  end
80
91
  end
@@ -84,7 +95,9 @@ module Maglev
84
95
  attachment_blobs(source.name).flat_map do |blob|
85
96
  document = extract_attachment(source.name, blob)
86
97
  if document.extracted?
87
- ["#{document.source_identifier}.text: #{document.text}"]
98
+ path = document.source_identifier
99
+ text = @budget.truncate(document.text, kind: :attachment, path: path)
100
+ ["#{path}.text: #{text}"]
88
101
  else
89
102
  ["#{document.source_identifier}.skipped: #{document.metadata[:reason]}"]
90
103
  end
@@ -125,7 +138,9 @@ module Maglev
125
138
  def rich_text_lines
126
139
  @config.rich_text_sources.filter_map do |source|
127
140
  text = rich_text_value(source.name)
128
- "#{rich_text_identifier(source.name)}.text: #{text}" unless text.nil? || text.empty?
141
+ path = rich_text_identifier(source.name)
142
+ text = @budget.truncate(text, kind: :rich_text, path: path) unless text.nil?
143
+ "#{path}.text: #{text}" unless text.nil? || text.empty?
129
144
  end
130
145
  end
131
146
 
@@ -169,10 +184,12 @@ module Maglev
169
184
  value = @record.public_send(relation.name)
170
185
  records, collection = if value.nil?
171
186
  [[], false]
187
+ elsif loaded_collection_proxy?(value)
188
+ [relation.order ? ordered_array(value.target, relation) : ordered_loaded_records(value), true]
189
+ elsif active_record_relation?(value)
190
+ [ordered_relation(value, relation).limit(relation.limit).to_a, true]
172
191
  elsif value.respond_to?(:to_ary)
173
- [value.to_ary, true]
174
- elsif value.respond_to?(:limit)
175
- [value.limit(relation.limit).to_a, true]
192
+ [ordered_array(value.to_ary, relation), true]
176
193
  else
177
194
  [[value], false]
178
195
  end
@@ -180,6 +197,67 @@ module Maglev
180
197
  [records.first(relation.limit), collection]
181
198
  end
182
199
 
200
+ def active_record_relation?(value)
201
+ (defined?(ActiveRecord::Relation) && value.is_a?(ActiveRecord::Relation)) ||
202
+ (defined?(ActiveRecord::Associations::CollectionProxy) && value.is_a?(ActiveRecord::Associations::CollectionProxy))
203
+ end
204
+
205
+ def loaded_collection_proxy?(value)
206
+ defined?(ActiveRecord::Associations::CollectionProxy) &&
207
+ value.is_a?(ActiveRecord::Associations::CollectionProxy) && value.loaded?
208
+ end
209
+
210
+ def ordered_relation(value, relation)
211
+ if relation.order
212
+ order = effective_order(relation.order, value.klass)
213
+ return value.reorder(order)
214
+ end
215
+ return value unless value.order_values.empty?
216
+
217
+ primary_key = value.klass.primary_key
218
+ primary_key ? value.order(primary_key => :asc) : value
219
+ end
220
+
221
+ def effective_order(order, klass)
222
+ RelationOrder.with_primary_key(order, klass)
223
+ end
224
+
225
+ def ordered_array(records, relation)
226
+ return records unless relation.order
227
+
228
+ order = relation.order
229
+ records.sort do |left, right|
230
+ comparison = order.lazy.map do |attribute, direction|
231
+ value = compare_values(left.public_send(attribute), right.public_send(attribute))
232
+ (direction == :desc) ? -value : value
233
+ end.find { |value| !value.zero? } || 0
234
+ comparison.zero? ? compare_values(record_id_for(left), record_id_for(right)) : comparison
235
+ end
236
+ end
237
+
238
+ def compare_values(left, right)
239
+ return 0 if left == right
240
+ return -1 if left.nil?
241
+ return 1 if right.nil?
242
+
243
+ left <=> right
244
+ end
245
+
246
+ def record_id_for(record)
247
+ record.respond_to?(:id) ? record.id : record.object_id
248
+ end
249
+
250
+ def ordered_loaded_records(value)
251
+ records = value.to_ary
252
+ return records unless value.order_values.empty?
253
+
254
+ primary_key = value.klass.primary_key
255
+ return records unless primary_key
256
+
257
+ persisted, unsaved = records.partition(&:persisted?)
258
+ persisted.sort_by { |record| record.public_send(primary_key) } + unsaved
259
+ end
260
+
183
261
  def path_for(relation, index, collection:)
184
262
  segment = collection ? "#{relation.name}[#{index}]" : relation.name
185
263
  @path ? "#{@path}.#{segment}" : segment
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "source_fragment"
4
+
5
+ module Maglev
6
+ class SourceExtractor
7
+ def call(snapshot)
8
+ related = {}
9
+ snapshot.to_s.lines(chomp: true).drop(1).filter_map do |line|
10
+ if (match = line.match(/\A(.+\[\d+\]) ([A-Za-z0-9_:]+)#([^ ]+)\z/))
11
+ related[match[1]] = [match[2], match[3]]
12
+ next
13
+ end
14
+ next unless line.include?(": ")
15
+
16
+ path = line.split(": ", 2).first
17
+ identity = related_identity(path, related) || path
18
+ next if identity.nil? || identity.empty?
19
+
20
+ SourceFragment.new(identity: identity, type: source_type(identity), content: line)
21
+ end.freeze
22
+ end
23
+
24
+ private
25
+
26
+ def related_identity(path, related)
27
+ prefix, record = related.find { |candidate, _identity| path.start_with?("#{candidate}.") }
28
+ return unless record
29
+
30
+ model, id = record
31
+ "related:#{model}:#{id}:#{path.delete_prefix("#{prefix}.")}"
32
+ end
33
+
34
+ def source_type(identity)
35
+ return :attachment if identity.include?("[blob:")
36
+ return :rich_text if identity.start_with?("rich_text.")
37
+ return :related_record if identity.start_with?("related:") || identity.include?("[") || identity.include?(".")
38
+ return :tag if identity == "tags"
39
+
40
+ :attribute
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Maglev
4
+ SourceFragment = Data.define(:identity, :type, :content) do
5
+ def initialize(identity:, type:, content:)
6
+ super(identity: identity.to_s.freeze, type: type.to_sym, content: content.to_s.freeze)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "trace"
5
+
6
+ module Maglev
7
+ class GroundingError < Error
8
+ end
9
+
10
+ class StructuredAnswerComposer
11
+ def initialize(generation_adapter: nil)
12
+ @generation_adapter = generation_adapter
13
+ end
14
+
15
+ def compose(result)
16
+ unless result.is_a?(StructuredResult)
17
+ raise GroundingError, "A structured result is required"
18
+ end
19
+
20
+ Trace.instrument(:composition, trace_id: result.trace_id,
21
+ resource: result.plan.resource, operation: result.plan.ir&.operation) do
22
+ (@generation_adapter && result.status == :succeeded) ? generate(result) : result.render
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def generate(result)
29
+ evidence = {
30
+ "records" => result.evidence.records,
31
+ "scalar" => result.evidence.scalar,
32
+ "filters" => result.evidence.filters,
33
+ "date_ranges" => result.evidence.date_ranges,
34
+ "count" => result.evidence.count,
35
+ "truncated" => result.evidence.truncated
36
+ }.freeze
37
+ output = @generation_adapter.generate(JSON.generate(evidence))
38
+ unless output.is_a?(Hash) && output.keys == ["claim_paths"] && output["claim_paths"].is_a?(Array) &&
39
+ output["claim_paths"].any?
40
+ raise GroundingError, "Structured generation must return claim paths"
41
+ end
42
+
43
+ output.fetch("claim_paths").map { |path| render_claim(evidence, path) }.join(" ").freeze
44
+ end
45
+
46
+ def render_claim(evidence, path)
47
+ valid_segments = path.is_a?(Array) && path.all? { |segment| [String, Integer].any? { |type| segment.is_a?(type) } }
48
+ unless valid_segments && path.length.between?(1, 3)
49
+ raise GroundingError, "Generated claim is absent from structured evidence"
50
+ end
51
+
52
+ value = path.reduce(evidence) do |current, segment|
53
+ if current.is_a?(Hash) && segment.is_a?(String) && current.key?(segment)
54
+ current.fetch(segment)
55
+ elsif current.is_a?(Array) && segment.is_a?(Integer) && segment.between?(0, current.length - 1)
56
+ current.fetch(segment)
57
+ else
58
+ raise GroundingError, "Generated claim is absent from structured evidence"
59
+ end
60
+ end
61
+ raise GroundingError, "Generated claim must identify one scalar evidence value" if value.is_a?(Hash) || value.is_a?(Array)
62
+
63
+ label = path.last.to_s.tr("_", " ").capitalize
64
+ "#{label}: #{value}."
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Maglev
6
+ class StructuredEvidenceBuilder
7
+ def initialize(plan:, relation:, resource:, rows:, bytes:)
8
+ raise ArgumentError, "evidence rows must be positive" unless rows.is_a?(Integer) && rows.positive?
9
+ raise ArgumentError, "evidence bytes must be positive" unless bytes.is_a?(Integer) && bytes.positive?
10
+
11
+ @plan = plan
12
+ @relation = relation
13
+ @resource = resource
14
+ @rows = rows
15
+ @bytes = bytes
16
+ end
17
+
18
+ def build
19
+ @filters = @plan.ir.filters.map(&:to_h)
20
+ @date_ranges = @filters.select { |filter| filter["operator"] == "between" }
21
+ StructuredEvidence.new(filters: @filters,
22
+ date_ranges: @date_ranges,
23
+ loader: method(:load_records))
24
+ end
25
+
26
+ private
27
+
28
+ def load_records
29
+ Trace.instrument(:execution, trace_id: @plan.trace_id, resource: @plan.resource,
30
+ operation: @plan.ir.operation) do |payload|
31
+ query_limit = [@relation.limit_value || @rows + 1, @rows + 1].min
32
+ fields = @resource.fields.map(&:name)
33
+ projected = @relation.limit(query_limit).to_a.map do |record|
34
+ fields.to_h { |field| [field, record.public_send(field)] }
35
+ end
36
+ records = []
37
+ projected.first(@rows).each do |record|
38
+ candidate = [*records, record]
39
+ break if serialized_size(candidate, truncated: projected.length > candidate.length) > @bytes
40
+
41
+ records << record
42
+ end
43
+ truncated = projected.length > records.length
44
+ used_bytes = serialized_size(records, truncated: truncated)
45
+ payload[:row_count] = records.length
46
+ payload[:evidence_bytes] = used_bytes
47
+ [records, records.length, truncated]
48
+ end
49
+ end
50
+
51
+ def serialized_size(records, truncated:)
52
+ JSON.generate("records" => records, "scalar" => nil, "filters" => @filters,
53
+ "date_ranges" => @date_ranges, "count" => records.length, "truncated" => truncated).bytesize
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "query_compiler"
4
+ require_relative "structured_result"
5
+ require_relative "trace"
6
+ require_relative "structured_evidence_builder"
7
+ require "bigdecimal"
8
+
9
+ module Maglev
10
+ class StructuredExecutionError < Error
11
+ end
12
+
13
+ class StructuredExecutor
14
+ DEFAULT_TIMEOUT = 5
15
+
16
+ def initialize(timeout: DEFAULT_TIMEOUT, role: nil, wrapper: nil)
17
+ @timeout = timeout
18
+ @role = role
19
+ @wrapper = wrapper
20
+ raise ArgumentError, "timeout must be positive" unless @timeout.respond_to?(:positive?) && @timeout.positive?
21
+ end
22
+
23
+ def execute(plan)
24
+ raise StructuredExecutionError, "A structured plan is required" unless plan.is_a?(StructuredPlan)
25
+ return protected_relation(plan.relation) if plan.records?
26
+
27
+ execute_with_policy(plan.relation) { execute_aggregate(plan) }
28
+ rescue StructuredExecutionError
29
+ raise
30
+ rescue ActiveRecord::ActiveRecordError => error
31
+ raise StructuredExecutionError, "Structured query execution failed: #{error.class.name}"
32
+ end
33
+
34
+ private
35
+
36
+ def protected_relation(relation)
37
+ executor = self
38
+ policy = Module.new do
39
+ define_method(:exec_queries) do |*arguments|
40
+ executor.send(:execute_with_policy, self) { super(*arguments) }
41
+ end
42
+ %i[calculate pluck pick exists? ids].each do |method_name|
43
+ define_method(method_name) do |*arguments|
44
+ executor.send(:execute_with_policy, self) { super(*arguments) }
45
+ end
46
+ end
47
+
48
+ define_method(:load_async) { load }
49
+ end
50
+ relation.extending(policy)
51
+ end
52
+
53
+ def execute_with_policy(relation, &query)
54
+ operation = proc { execute_read_only(relation, &query) }
55
+ if @wrapper
56
+ unwrapped_operation = operation
57
+ operation = proc { @wrapper.call(&unwrapped_operation) }
58
+ end
59
+ return ActiveRecord::Base.connected_to(role: @role, prevent_writes: true, &operation) if @role
60
+
61
+ ActiveRecord::Base.while_preventing_writes(&operation)
62
+ rescue StructuredExecutionError
63
+ raise
64
+ rescue ActiveRecord::ActiveRecordError => error
65
+ raise StructuredExecutionError, "Structured query execution failed: #{error.class.name}"
66
+ end
67
+
68
+ def execute_read_only(relation)
69
+ connection = relation.connection
70
+ connection.transaction(requires_new: true) do
71
+ apply_database_policy(connection)
72
+ yield
73
+ end
74
+ end
75
+
76
+ def apply_database_policy(connection)
77
+ unless connection.adapter_name.casecmp?("PostgreSQL")
78
+ raise StructuredExecutionError, "Structured execution requires an adapter with enforced statement timeouts"
79
+ end
80
+
81
+ milliseconds = (@timeout.to_f * 1000).ceil
82
+ connection.execute("SET LOCAL statement_timeout = #{milliseconds}")
83
+ connection.execute("SET TRANSACTION READ ONLY")
84
+ end
85
+
86
+ def execute_aggregate(plan)
87
+ aggregate = plan.aggregate
88
+ values = if aggregate.function == :count
89
+ plan.relation.pluck(plan.relation.klass.arel_table[plan.relation.klass.primary_key])
90
+ else
91
+ plan.relation.pluck(plan.aggregate_column)
92
+ end
93
+
94
+ case aggregate.function
95
+ when :count then values.length
96
+ when :sum then values.sum
97
+ when :average then values.empty? ? nil : BigDecimal(values.sum.to_s) / values.length
98
+ when :minimum then values.min
99
+ when :maximum then values.max
100
+ else raise StructuredExecutionError, "The aggregate is unavailable"
101
+ end
102
+ end
103
+ end
104
+
105
+ def self.structured_executor(configuration: Maglev.configuration)
106
+ StructuredExecutor.new(
107
+ timeout: configuration.structured_query_timeout,
108
+ role: configuration.structured_query_role,
109
+ wrapper: configuration.structured_query_executor_wrapper
110
+ )
111
+ end
112
+
113
+ def self.execute(plan, executor: structured_executor,
114
+ evidence_rows: Maglev.configuration.structured_evidence_max_rows,
115
+ evidence_bytes: Maglev.configuration.structured_evidence_max_bytes)
116
+ unless plan.is_a?(Planner::Plan)
117
+ raise StructuredExecutionError, "A structured planner result is required"
118
+ end
119
+
120
+ unless plan.ready?
121
+ status = (plan.status == :invalid) ? :failed : plan.status
122
+ return StructuredResult.new(status: status, kind: :none, warnings: plan.warnings,
123
+ plan: plan, trace_id: plan.trace_id)
124
+ end
125
+ unless plan.base_relation
126
+ raise StructuredExecutionError, "The structured plan has no authorized base relation"
127
+ end
128
+
129
+ compiled = Trace.instrument(:compilation, trace_id: plan.trace_id, resource: plan.resource,
130
+ operation: plan.ir.operation) do
131
+ QueryCompiler.new(snapshot: plan.validation.snapshot).compile(
132
+ validation: plan.validation, base_relation: plan.base_relation
133
+ )
134
+ end
135
+ resource = plan.validation.snapshot.resources.find { |candidate| candidate.identifier == plan.resource }
136
+ filters = plan.ir.filters.map(&:to_h)
137
+ date_ranges = filters.select { |filter| filter["operator"] == "between" }
138
+
139
+ if compiled.records?
140
+ value = executor.execute(compiled)
141
+ evidence = StructuredEvidenceBuilder.new(plan: plan, relation: value, resource: resource,
142
+ rows: evidence_rows, bytes: evidence_bytes).build
143
+ StructuredResult.new(status: :succeeded, kind: :relation, value: value, evidence: evidence,
144
+ interpretation: plan.explanation, warnings: plan.warnings, plan: plan, trace_id: plan.trace_id)
145
+ else
146
+ value = Trace.instrument(:execution, trace_id: plan.trace_id, resource: plan.resource,
147
+ operation: plan.ir.operation) { executor.execute(compiled) }
148
+ scalar_size = JSON.generate("records" => [], "scalar" => value, "filters" => filters,
149
+ "date_ranges" => date_ranges, "count" => 1, "truncated" => false).bytesize
150
+ bounded_scalar = scalar_size <= evidence_bytes
151
+ evidence = StructuredEvidence.new(scalar: bounded_scalar ? value : nil, filters: filters,
152
+ date_ranges: date_ranges, count: bounded_scalar ? 1 : 0, truncated: !bounded_scalar)
153
+ StructuredResult.new(status: :succeeded, kind: :aggregate, value: value, evidence: evidence,
154
+ interpretation: plan.explanation, warnings: plan.warnings, plan: plan, trace_id: plan.trace_id)
155
+ end
156
+ end
157
+ end