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
data/README.md CHANGED
@@ -1,48 +1,108 @@
1
1
  # Maglev
2
2
 
3
- [English](README.md) | [简体中文](README.zh-CN.md)
3
+ [English](README.md) | [简体中文](README.zh-CN.md) | [日本語](README.ja.md)
4
4
 
5
5
  [![CI](https://github.com/benjis/maglev/actions/workflows/ci.yml/badge.svg)](https://github.com/benjis/maglev/actions/workflows/ci.yml)
6
- [![Ruby](https://img.shields.io/badge/Ruby-3.2%2B-CC342D.svg)](https://www.ruby-lang.org/)
6
+ [![Ruby](https://img.shields.io/badge/Ruby-3.3%2B-CC342D.svg)](https://www.ruby-lang.org/)
7
7
  [![Rails](https://img.shields.io/badge/Rails-7.1%20%7C%208.0-D30001.svg)](https://rubyonrails.org/)
8
8
  [![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.txt)
9
9
 
10
- **Give your Rails models semantic memory, without building a second application beside Rails.**
10
+ Maglev 0.2 is a Rails-native, read-only knowledge and query layer for
11
+ ActiveRecord applications. It lets an application answer natural-language
12
+ questions through three explicit routes:
11
13
 
12
- Maglev is a Rails-native semantic knowledge layer for ActiveRecord object graphs. Declare which parts of your domain are safe and useful to understand, and Maglev turns records, relationships, attachments, and rich text into searchable knowledge. It keeps that knowledge fresh through normal Rails lifecycle hooks and gives you semantic search and grounded answers through familiar model APIs.
14
+ - **Structured:** question validated Query IR composable
15
+ `ActiveRecord::Relation` or bounded aggregate.
16
+ - **RAG:** question → authorized semantic retrieval → optional grounded answer.
17
+ - **Hybrid:** one of two fixed workflows combining structured filtering and RAG
18
+ evidence.
19
+
20
+ Models expose only an application-defined allowlist. Structured compilation
21
+ starts from the caller's base relation and can only narrow it. RAG retrieval and
22
+ answer generation remain separable.
13
23
 
14
24
  ```ruby
15
- Product.search("laptops with battery or usability complaints")
25
+ # Exact database question
26
+ resource_authorizer = ->(_entry, user) { user.account_id == current_account.id }
27
+ result = current_account.invoices.maglev_request(
28
+ "Open invoices over $500",
29
+ mode: :structured,
30
+ planner_adapter: planner,
31
+ authorizer: resource_authorizer,
32
+ user: current_user
33
+ )
16
34
 
17
- response = Product.ask("What recurring product issues should we investigate?", user: current_user)
18
- response.text
19
- response.sources # the ActiveRecord records and chunks behind the answer
35
+ # Inspectable semantic evidence, without generation
36
+ retrieval = SupportTicket.retrieve("customers blocked during cancellation", user: current_user)
37
+
38
+ # Grounded answer
39
+ answer = SupportTicket.ask("What cancellation problems recur?", user: current_user)
20
40
  ```
21
41
 
22
- Maglev is deliberately focused on retrieval-augmented generation (RAG). ActiveRecord remains the source of truth for exact filters, joins, reporting, and aggregation; Maglev handles questions expressed in human language.
42
+ ## Start here: choose the data path
43
+
44
+ You do not need to understand Maglev's whole architecture before using it. Start
45
+ with the kind of answer your application needs:
46
+
47
+ | User question | Data needed | Declare | Call |
48
+ | --- | --- | --- | --- |
49
+ | “How many invoices are overdue?” | Exact columns, filters, counts | `queryable` | `maglev_request(..., mode: :structured)` |
50
+ | “What problems do customers describe when cancelling?” | Free text, comments, attachments | `knowledge` | `retrieve` or `ask` |
51
+ | “Which open tickets mention being charged twice?” | Exact status plus semantic text | Both | `maglev_request(..., mode: :hybrid)` |
52
+
53
+ The mental model is deliberately small:
54
+
55
+ 1. `maglev_resource :support_tickets` gives a model a stable name in Maglev.
56
+ 2. `queryable` allowlists what a planner may ask ActiveRecord to filter, sort,
57
+ join, or aggregate.
58
+ 3. `knowledge` selects what becomes searchable semantic evidence.
59
+ 4. The caller still supplies authorization and, for structured work, the base
60
+ `ActiveRecord::Relation`. Maglev never grants itself broader access.
23
61
 
24
62
  ## Why Maglev?
25
63
 
26
- - **Rails-native:** a gem and Railtie, not a separate service, engine, or API.
27
- - **Model-driven:** declare knowledge next to the ActiveRecord model that owns it.
28
- - **Graph-aware:** traverse direct, `has_many :through`, and polymorphic associations with explicit depth and record limits.
29
- - **Fresh by default:** reindex owners after declared records, direct associations, attachments, or Action Text content change.
30
- - **Grounded:** answers are generated only from retrieved context and include their sources.
31
- - **Production-minded:** authorization hooks, content limits, sanitization, retries, instrumentation, and idempotent reindexing are built in.
32
- - **Extensible:** use the default PostgreSQL/pgvector store or implement the small vector-store contract.
64
+ - A normal Ruby gem with `Maglev::Railtie`, not a Rails Engine or separate API.
65
+ - ActiveRecord-first structured queries; no unrestricted SQL or Ruby generation.
66
+ - Explicit registry for queryable fields, associations, scopes, aggregates, and
67
+ knowledge sources.
68
+ - Tenant and authorization constraints carried by application-owned relations.
69
+ - Source-aware RAG with pgvector, normalized similarity, deterministic budgets,
70
+ and inspectable evidence.
71
+ - Immutable plans/results and redacted trace metadata.
72
+ - Deterministic fake adapters for tests; no live provider calls in the default suite.
33
73
 
34
- ## Quick Start
74
+ ## Architecture
35
75
 
36
- ### 1. Install prerequisites
76
+ ```mermaid
77
+ flowchart TD
78
+ Q["Question + registered scope"] --> R["Router"]
79
+ R --> S["Structured planner"]
80
+ R --> G["RAG retrieval"]
81
+ R --> H["Fixed hybrid coordinator"]
82
+ S --> I["Untrusted Query IR"]
83
+ I --> V["Deterministic validator"]
84
+ V --> C["Compiler on supplied base relation"]
85
+ C --> E["Read-only bounded execution"]
86
+ G --> X["Authorized semantic evidence"]
87
+ H --> E
88
+ H --> X
89
+ E --> O["Evidence + Result"]
90
+ X --> O
91
+ O --> A["Optional grounded answer"]
92
+ Q --> T["Redacted trace"]
93
+ ```
94
+
95
+ The registry is an authority boundary. A schema snapshot contains only the
96
+ registered resources authorized for one request, never record values. Provider
97
+ output is untrusted until deterministic validation succeeds.
37
98
 
38
- Maglev requires Ruby 3.2+, Rails 7.1 or 8.0, PostgreSQL, and the
39
- [`pgvector`](https://github.com/pgvector/pgvector) extension.
99
+ ## Installation
40
100
 
41
- Add Maglev to your application:
101
+ Maglev requires Ruby 3.3+, Rails 7.1 or 8.0, PostgreSQL, and pgvector.
42
102
 
43
103
  ```ruby
44
104
  # Gemfile
45
- gem "maglev-rb"
105
+ gem "maglev-rb", "~> 0.2.1"
46
106
  ```
47
107
 
48
108
  ```bash
@@ -51,358 +111,583 @@ bin/rails generate maglev:install --embedding-dimensions=1536
51
111
  bin/rails db:migrate
52
112
  ```
53
113
 
54
- The generator writes `--embedding-dimensions` to both `config/initializers/maglev.rb` and the `maglev_chunks` vector column, and includes a cosine HNSW index.
114
+ The generator creates an initializer, the `maglev_chunks` table, source and
115
+ tenant metadata, an HNSW cosine index, and `maglev_index_states` diagnostics.
116
+ Review generated migrations when owner primary keys are UUIDs.
117
+
118
+ ## Configuration
55
119
 
56
- ### 2. Configure your provider
120
+ The built-in embedding and generation clients use OpenAI-compatible HTTP
121
+ endpoints. The planner defaults to the OpenAI `json_schema` response format;
122
+ providers without that capability may use the less constrained `json_object`
123
+ format. Embedding and generation may use different providers.
57
124
 
58
125
  ```ruby
59
- # config/initializers/maglev.rb
60
126
  Maglev.configure do |config|
61
127
  config.embedding_provider do |provider|
62
- provider.url = "http://localhost:11434/v1"
63
- provider.api_key = ENV["LOCAL_EMBEDDING_API_KEY"]
64
- provider.model = "Qwen3-Embedding-0.6B-8bit"
65
- provider.dimensions = 1024
128
+ provider.url = ENV.fetch("MAGLEV_EMBEDDING_URL", "https://api.openai.com/v1")
129
+ provider.api_key = ENV["MAGLEV_EMBEDDING_API_KEY"]
130
+ provider.model = "text-embedding-3-small"
131
+ provider.dimensions = 1536
66
132
  end
67
133
 
68
134
  config.generation_provider do |provider|
69
- provider.url = "https://api.deepseek.com/v1"
70
- provider.api_key = Rails.application.credentials.dig(:deepseek_api_key)
71
- provider.model = "deepseek-chat"
135
+ provider.url = ENV.fetch("MAGLEV_GENERATION_URL", "https://api.openai.com/v1")
136
+ provider.api_key = ENV["MAGLEV_GENERATION_API_KEY"]
137
+ provider.model = "gpt-4.1-mini"
72
138
  end
73
139
 
140
+ config.planner_adapter = Maglev::Adapters::FaradayPlanner.new
141
+ # For providers without json_schema support:
142
+ # config.planner_adapter = Maglev::Adapters::FaradayPlanner.new(response_format: :json_object)
143
+ config.routing_adapter = MyRoutingAdapter.new # required only for mode: :auto
144
+
74
145
  config.chunk_size = 1000
146
+ config.minimum_similarity = nil
147
+ config.retrieval_max_candidates = 1000
148
+ config.context_max_characters = 4000
149
+ config.context_per_owner_characters = 1200
150
+
151
+ config.snapshot_attribute_max_characters = 20_000
152
+ config.snapshot_related_record_max_characters = 50_000
153
+ config.snapshot_max_characters = 100_000
154
+ config.snapshot_max_chunks = 100
155
+
156
+ config.structured_query_timeout = 5
157
+ config.structured_evidence_max_rows = 100
158
+ config.structured_evidence_max_bytes = 32_768
75
159
  end
76
160
  ```
77
161
 
78
- Embedding and generation endpoints are independent and may use different URLs, API keys, and models. The default provider bridge expects OpenAI-compatible HTTP endpoints. Applications can still inject custom Maglev adapters for other protocols.
162
+ Use custom `embedding_adapter`, `generation_adapter`, `planner_adapter`,
163
+ `routing_adapter`, `attachment_extractor`, or `authorization_adapter` objects
164
+ when the built-in protocols do not fit.
165
+
166
+ ## Registering a resource
79
167
 
80
- For an existing installation, change the configured dimensions and the database vector column together. Maglev checks their consistency before requesting an embedding.
168
+ `maglev_resource` is the primary v0.2 DSL. Structured and knowledge capabilities
169
+ are independent and may be declared together or separately.
81
170
 
82
- ### 3. Declare model knowledge
171
+ ### A fully annotated structured resource
83
172
 
84
173
  ```ruby
85
- class Product < ApplicationRecord
86
- has_many :reviews, inverse_of: :product
87
- has_many :product_categories, inverse_of: :product
88
- has_many :categories, through: :product_categories
89
- has_many_attached :images
90
- has_rich_text :description
91
-
92
- has_knowledge do
93
- expose :name, :sku, :price, :status
94
- tags :product
95
-
96
- include_related :reviews, depth: 1, limit: 10
97
- include_related :categories, depth: 1, limit: 10, inverse: :products
98
-
99
- expose_attached :images
100
- expose_rich_text :description
174
+ class Invoice < ApplicationRecord
175
+ # Normal Rails declarations remain the source of truth.
176
+ belongs_to :account
177
+ scope :due_before, ->(date) { where(due_on: ..date) }
178
+
179
+ # :invoices is the stable identifier used by Maglev plans and requests.
180
+ maglev_resource :invoices do
181
+ # Help the planner understand what this resource represents.
182
+ description "Invoices belonging to an authorized account"
183
+ synonyms "bills"
184
+
185
+ # This block controls structured ActiveRecord queries only.
186
+ queryable do
187
+ # Allow exact filters/sorts on these real database columns.
188
+ # enum also tells the planner which status values are valid.
189
+ field :status, enum: %w[draft open paid void]
190
+ field :amount, description: "Invoice total in the account currency"
191
+ field :due_on, synonyms: ["deadline"]
192
+ field :paid_at
193
+
194
+ # Explicitly deny sensitive columns, even if a planner asks for them.
195
+ prohibit :number, :internal_note
196
+
197
+ # Permit a registered join to the separately registered :accounts resource.
198
+ association :account, resource: :accounts
199
+
200
+ # Permit this existing Rails scope and describe its typed argument.
201
+ scope :due_before,
202
+ parameters: {date: {type: :date, required: true}}
203
+
204
+ # Allow only these aggregate functions/columns.
205
+ aggregates count: true, sum: [:amount], average: [:amount]
206
+
207
+ # Per-resource ceilings applied in addition to global/request limits.
208
+ limits rows: 50, operations: 8, joins: 1
209
+
210
+ # The caller must authorize this resource for every structured plan.
211
+ authorization :required
212
+ end
213
+
214
+ # This separate block controls semantic indexing and RAG.
215
+ # Repeating a field here does not broaden structured query permissions.
216
+ knowledge do
217
+ expose :status, :amount, :due_on, :paid_at
218
+ end
101
219
  end
102
220
  end
221
+ ```
103
222
 
104
- class Review < ApplicationRecord
105
- belongs_to :product, inverse_of: :reviews
223
+ Nothing is exposed implicitly. `authorization :required` means the resource is
224
+ omitted from a request schema snapshot unless the caller authorizes it.
225
+ `allow_unscoped_model_queries` is opt-in and should be reserved for genuinely
226
+ public data.
106
227
 
107
- has_knowledge do
108
- expose :rating, :title, :body
109
- end
110
- end
111
- ```
228
+ `queryable` defines only the constrained ActiveRecord contract. `knowledge`
229
+ defines only RAG indexing and retrieval sources. `maglev_resource` is the
230
+ unified resource declaration and may contain either block or both. Models that
231
+ do not declare `knowledge` cannot use `search`, `retrieve`, `ask`, snapshots, or
232
+ indexing callbacks.
112
233
 
113
- Only explicitly exposed fields and content sources enter Maglev's knowledge snapshot. Relation `limit` bounds the number of records per association. Relation `depth` bounds association hops: `depth: 1` includes the directly related record but does not expand that record's relations. `config.max_relation_depth` is the hard root-to-leaf ceiling for every snapshot. Related models define their own exposed knowledge, so sensitive join-model fields are not flattened accidentally.
234
+ Read the declaration as an allowlist, not as a schema dump. Columns that are not
235
+ listed in `field` cannot appear in Query IR. Values that are not listed in
236
+ `expose` (or another knowledge source) do not enter the semantic snapshot.
114
237
 
115
- ### 4. Index existing records
238
+ ### Why a field may appear in both blocks
116
239
 
117
- New and updated records enqueue `Maglev::ReindexJob` automatically. Backfill existing data once after installation:
240
+ The same column can serve two different jobs:
118
241
 
119
- ```bash
120
- bin/rails maglev:reindex[Product]
121
- # or every model that declares has_knowledge
122
- bin/rails maglev:reindex_all
123
- ```
242
+ - `field :status` lets structured queries apply an exact condition such as
243
+ `status = "open"`.
244
+ - `expose :status` writes `status: open` into the indexed snapshot so retrieved
245
+ evidence keeps useful context.
124
246
 
125
- Make sure your Active Job backend is running in production.
247
+ Declaring one never implies the other. Put identifiers, dates, enums, and money
248
+ in `queryable` when exactness matters. Put prose, descriptions, comments,
249
+ resolution notes, and attachment text in `knowledge` when meaning matters. Use
250
+ both for contextual fields such as status, priority, or product area.
126
251
 
127
- ### 5. Search and ask
252
+ ### A realistic RAG resource
128
253
 
129
- ```ruby
130
- results = Product.search(
131
- "laptops with battery or usability complaints",
132
- limit: 10,
133
- user: current_user
134
- )
254
+ RAG is most useful when the answer is present in human language rather than a
255
+ single database column. In this example, structured queries can find open/high
256
+ priority tickets, while RAG can understand phrases such as “charged twice” in
257
+ ticket bodies, comments, resolution notes, and attached logs.
135
258
 
136
- results.each do |result|
137
- result.owner # => Product
138
- result.content # retrieved snapshot chunk
139
- result.source # => "snapshot"
140
- result.distance # cosine distance; lower is closer
141
- result.similarity # normalized convenience score
259
+ ```ruby
260
+ class SupportTicket < ApplicationRecord
261
+ belongs_to :account
262
+ has_many :comments
263
+ has_many_attached :files
264
+ has_rich_text :resolution
265
+
266
+ maglev_resource :support_tickets do
267
+ description "Customer support requests and their investigation evidence"
268
+
269
+ queryable do
270
+ # Good structured fields: exact, typed, and useful for filtering.
271
+ field :status, enum: %w[open pending resolved closed]
272
+ field :priority, enum: %w[low normal high urgent]
273
+ field :product_area
274
+ field :created_at
275
+ prohibit :requester_email, :internal_notes
276
+ limits rows: 100, operations: 8, joins: 1
277
+ authorization :required
278
+ end
279
+
280
+ knowledge do
281
+ # Prose carries the semantic meaning that exact filters cannot capture.
282
+ expose :subject, :body
283
+
284
+ # These overlap with queryable fields to preserve context in evidence.
285
+ expose :status, :priority, :product_area
286
+
287
+ # Include a bounded, deterministic slice of related conversation.
288
+ include_related :comments, depth: 1, limit: 20,
289
+ order: {created_at: :desc}
290
+
291
+ # Extract supported attachment text and Action Text content.
292
+ expose_attached :files
293
+ expose_rich_text :resolution
294
+ end
295
+ end
142
296
  end
143
297
  ```
144
298
 
145
- Generate an answer grounded in the retrieved records:
299
+ Related models used by `include_related` must declare their own
300
+ `maglev_resource ... knowledge` block. To create a knowledge-only resource,
301
+ simply omit `queryable`; to create a structured-only resource, omit `knowledge`.
302
+
303
+ Once records are indexed, these calls answer different questions:
146
304
 
147
305
  ```ruby
148
- response = Product.ask(
149
- "What recurring product issues should the merchandising team investigate?",
306
+ # Semantic matches only. No generation call is made.
307
+ evidence = SupportTicket.retrieve(
308
+ "customers say they were charged twice after cancellation",
309
+ limit: 10,
310
+ user: current_user
311
+ )
312
+
313
+ # A prose answer grounded in the selected ticket evidence.
314
+ answer = SupportTicket.ask(
315
+ "What double-charge patterns recur after cancellation?",
150
316
  limit: 5,
151
317
  user: current_user
152
318
  )
153
319
 
154
- response.text
155
- response.sources # owner, chunk, distance, and the exact retrieved content
156
- response.metadata
320
+ # Exact database filtering followed by semantic retrieval inside that set.
321
+ result = current_account.support_tickets.maglev_request(
322
+ "Open urgent tickets where customers describe being charged twice",
323
+ mode: :hybrid,
324
+ hybrid_plan: :structured_first,
325
+ planner_adapter: planner,
326
+ authorizer: resource_authorizer,
327
+ user: current_user
328
+ )
157
329
  ```
158
330
 
159
- For example, an answer might summarize a loud fan, intermittent trackpad responsiveness, and lower-than-advertised battery life from retrieved review content. Treat it as a summary of the retrieved context—not an aggregate over every product—and use `response.sources` to show the evidence behind each conclusion.
331
+ The default attachment extractor supports plain text, Markdown, HTML, and XHTML.
332
+ PDF, Office, OCR, image, audio, and video parsing require an application-owned
333
+ extractor. All snapshots, relations, attachments, and chunks have hard budgets.
160
334
 
161
- Instance-level questions stay scoped to one owner:
335
+ Inspect exposure without calling a provider:
162
336
 
163
337
  ```ruby
164
- product.ask("Summarize the reported strengths and weaknesses of this product.", user: current_user)
338
+ SupportTicket.maglev_schema
339
+ ticket.maglev_snapshot
340
+ ticket.maglev_context_preview(question: "Why is this unresolved?")
341
+ ticket.maglev_index_status
165
342
  ```
166
343
 
167
- Maglev also follows declared relationships when Rails data changes. Moving a review queues reindexing for both the previous and current product after commit, so their searchable knowledge stays current:
344
+ ### DSL reference
168
345
 
169
- ```ruby
170
- review.update!(product: replacement_product)
171
- # Maglev::ReindexJob is queued for both affected products.
172
- ```
346
+ Resource-level methods:
173
347
 
174
- When retrieval yields no usable context, Maglev returns a deterministic insufficient-context response instead of asking the model to improvise.
348
+ | DSL | Purpose |
349
+ | --- | --- |
350
+ | `maglev_resource :identifier` | Registers one stable resource identifier for the model. |
351
+ | `description "..."` | Human-readable planner context; never contains record values. |
352
+ | `synonyms "...", "..."` | Alternate names the planner may use for the resource. |
353
+ | `queryable { ... }` | Declares the structured ActiveRecord capability. May appear once. |
354
+ | `knowledge { ... }` | Declares the RAG/indexing capability. May appear once. |
175
355
 
176
- ## How It Works
356
+ `queryable` methods:
177
357
 
178
- ```mermaid
179
- flowchart LR
180
- A["ActiveRecord object graph"] --> B["Explicit knowledge schema"]
181
- B --> C["Bounded, sanitized snapshot"]
182
- C --> D["Chunking + embeddings"]
183
- D --> E["Vector store<br/>pgvector by default"]
184
- Q["search / ask"] --> F["Semantic retrieval"]
185
- E --> F
186
- F --> G["Context assembly"]
187
- G --> H["Grounded generation"]
188
- H --> I["Response + sources"]
189
- J["Rails commits and content changes"] --> K["Active Job reindex"]
190
- K --> C
191
- ```
358
+ | DSL | Purpose and options |
359
+ | --- | --- |
360
+ | `field :name` | Allowlists a real column. Options: `description:`, `synonyms:`, `enum:`, and `sensitive:`. A sensitive field is excluded from the planner schema. |
361
+ | `prohibit :a, :b` | Explicitly denies real columns. A field cannot be both allowed and prohibited. |
362
+ | `association :account, resource: :accounts` | Allows a registered ActiveRecord association path. Options: `description:` and `synonyms:`. The target resource must also be registered. |
363
+ | `scope :due_before, parameters: {...}` | Allows one existing model scope. Parameter metadata supports `type`, `required`, `nullable`, `enum_values`, `minimum`, and `maximum`. Arbitrary scopes are never callable. |
364
+ | `aggregates count: true, sum: [:amount]` | Allows `count`, `sum`, `average`, `minimum`, or `maximum`; field lists restrict numeric/value aggregates. |
365
+ | `limits rows:, operations:, joins:` | Adds positive per-resource ceilings. Effective limits are the strictest configured limits. |
366
+ | `authorization :required` | Default. The resource enters a schema snapshot only when `authorizer` approves it. |
367
+ | `authorization :public` | Marks the resource schema public; record access must still respect the supplied relation. |
368
+ | `allow_unscoped_model_queries true` | Allows structured requests without a supplied base relation. Off by default; use only for genuinely public data. |
369
+
370
+ Scope parameter `type` accepts `:string`, `:integer`, `:float`, `:decimal`,
371
+ `:boolean`, `:date`, `:datetime`, `:timestamp`, or `:time`. Unsupported types
372
+ are rejected when the resource is registered.
192
373
 
193
- 1. `has_knowledge` compiles an explicit schema for a model and its declared relationships.
194
- 2. Maglev builds a deterministic text snapshot from allowed attributes, related records, attachments, rich text, and tags.
195
- 3. The snapshot is split into bounded chunks and embedded through the configured adapter.
196
- 4. Chunks are upserted into a vector store. The default store persists them in PostgreSQL using pgvector.
197
- 5. `search` embeds the query and performs cosine nearest-neighbor retrieval.
198
- 6. `ask` assembles retrieved chunks within context budgets, builds a grounded prompt, and returns the answer with source metadata.
199
- 7. Rails callbacks propagate declared record changes through the graph and enqueue reindexing for affected owners.
374
+ `knowledge` methods:
200
375
 
201
- Maglev does not duplicate your relational model. Vector documents point back to their ActiveRecord owners; your application remains responsible for transactions, business rules, and structured queries.
376
+ | DSL | Purpose and options |
377
+ | --- | --- |
378
+ | `expose :subject, :body` | Adds selected non-nil model attributes to the searchable snapshot. |
379
+ | `hide :internal_notes` | Explicitly documents attributes that must not be exposed; an attribute cannot be both exposed and hidden. |
380
+ | `tags :support, :customer` | Adds fixed classification labels to every snapshot for this model. |
381
+ | `include_related :comments, depth:, limit:` | Adds bounded related-record snapshots. Optional `inverse:` resolves non-obvious reverse associations; `order:` accepts a column or `{column: :asc/:desc}`. |
382
+ | `expose_attached :files` | Adds text extracted from named Active Storage attachments. |
383
+ | `expose_rich_text :resolution` | Adds plain text from named Action Text attributes. |
202
384
 
203
- ## Knowledge Sources
385
+ Nothing is inferred from database visibility. DSL declarations are validated at
386
+ registration time, and unknown fields, associations, scopes, or attachments
387
+ raise `Maglev::ConfigurationError`.
204
388
 
205
- ### ActiveRecord graphs
389
+ ## Structured queries
206
390
 
207
- `include_related` supports bounded traversal across ordinary associations, `has_many :through`, and polymorphic relationships. Use `inverse:` when Maglev cannot infer how changes on a related model should find their owning knowledge record.
391
+ Planning and execution are deliberately separate.
208
392
 
209
393
  ```ruby
210
- has_knowledge do
211
- include_related :tickets, depth: 2, limit: 25
212
- include_related :events, depth: 1, limit: 20, inverse: :eventable
213
- end
394
+ base = current_account.invoices.where(archived: false)
395
+
396
+ plan = Maglev.plan(
397
+ "Open invoices over $500 due this month",
398
+ resource: :invoices,
399
+ base_relation: base,
400
+ authorizer: ->(entry, user) { user.account_id == current_account.id },
401
+ user: current_user,
402
+ constraints: {rows: 25, operations: 8, joins: 1},
403
+ adapter: planner
404
+ )
405
+
406
+ plan.status # :ready, :clarification_required, :unsupported, :invalid
407
+ plan.ir # immutable Maglev::QueryIR::Query when ready
408
+ plan.explanation
409
+ plan.policy_limits # effective rows/operations/joins/complexity limits
410
+ plan.evidence_requirements
411
+ plan.trace_id
412
+
413
+ result = Maglev.execute(plan)
414
+ result.status # :succeeded
415
+ result.kind # :relation or :aggregate
416
+ result.value # protected ActiveRecord::Relation or bounded scalar
417
+ result.evidence # bounded Maglev::StructuredEvidence
418
+ result.render
214
419
  ```
215
420
 
216
- When a related record moves between owners, Maglev reindexes both the previous and current owner.
421
+ Record relations stay lazy and composable until read. Reads execute in a
422
+ read-only PostgreSQL transaction with `statement_timeout`; returned records are
423
+ read-only and bulk writes are rejected.
424
+
425
+ Query IR v1 supports registered scopes; `eq`, `not_eq`, `gt`, `gte`, `lt`,
426
+ `lte`, `in`, `not_in`, `is_null`, `is_not_null`, and `between`; joins up to two
427
+ levels; sort, distinct, limit; and count, sum, average, minimum, and maximum.
428
+ It cannot contain SQL, Arel, Ruby, arbitrary methods, writes, locks, nested
429
+ boolean groups, windows, subqueries, `HAVING`, or planner-defined tools.
217
430
 
218
- Creating, deleting, or reassigning a join row changes a `has_many :through` relationship without changing the related record itself. After such join-model changes, explicitly enqueue or run owner reindexing from your application.
431
+ ## RAG: search, retrieve, and ask
219
432
 
220
- ### Active Storage and Action Text
433
+ These APIs remain directly available on knowledge-enabled models.
221
434
 
222
435
  ```ruby
223
- has_knowledge do
224
- expose_attached :contracts, :brief
225
- expose_rich_text :notes
226
- end
436
+ matches = SupportTicket.search(
437
+ "cancellation failures",
438
+ limit: 10,
439
+ minimum_similarity: 0.65,
440
+ user: current_user
441
+ )
442
+
443
+ matches.first.owner
444
+ matches.first.source_identity
445
+ matches.first.source_type
446
+ matches.first.similarity
227
447
  ```
228
448
 
229
- HTML and Action Text content are sanitized before indexing. Attachments are constrained by content type, byte size, and extracted character count. Changes to declared attachments and rich text trigger owner reindexing.
449
+ `search` returns at most one `SearchResult` per owner. For full retrieval
450
+ diagnostics without generation, use `retrieve`:
451
+
452
+ ```ruby
453
+ retrieval = SupportTicket.retrieve(
454
+ "cancellation failures",
455
+ limit: 10,
456
+ chunks_per_owner: 2,
457
+ user: current_user
458
+ )
230
459
 
231
- ### Inspect before indexing
460
+ retrieval.considered
461
+ retrieval.selected
462
+ retrieval.rejected
463
+ retrieval.context
464
+ retrieval.budgets
465
+ retrieval.reasons
466
+ retrieval.timings
467
+ retrieval.trace_id
468
+ ```
232
469
 
233
- The developer experience APIs let you inspect exactly what a model exposes without calling an embedding or generation provider:
470
+ Generate only when a prose answer is needed:
234
471
 
235
472
  ```ruby
236
- Customer.maglev_schema
237
- customer.maglev_snapshot
238
-
239
- preview = customer.maglev_context_preview(
240
- question: "Why is this customer at risk?"
473
+ answer = SupportTicket.ask(
474
+ "Which cancellation failures recur?",
475
+ limit: 5,
476
+ chunks_per_owner: 2,
477
+ minimum_similarity: 0.65,
478
+ user: current_user
241
479
  )
242
- preview.text
243
- preview.metadata # includes provider_calls: 0
480
+
481
+ answer.text
482
+ answer.sources
483
+ answer.metadata
244
484
  ```
245
485
 
246
- ## Authorization
486
+ If no evidence survives authorization, similarity, or context budgets, `ask`
487
+ returns deterministic insufficient context without calling generation.
247
488
 
248
- Maglev is policy-library agnostic. Configure a small adapter to apply your application's authorization rules during retrieval and answering:
489
+ ## Unified requests and routing
249
490
 
250
- ```ruby
251
- class MaglevAuthorization
252
- def scope(model:, user:)
253
- model.accessible_by(user)
254
- end
491
+ Use `Maglev.request`, `Model.maglev_request`, or
492
+ `relation.maglev_request` when one result envelope and route selection are useful.
255
493
 
256
- def authorize(record:, user:)
257
- record.account_id == user.account_id
258
- end
259
- end
494
+ ```ruby
495
+ result = current_account.invoices.maglev_request(
496
+ "How many open invoices are overdue?",
497
+ mode: :structured,
498
+ planner_adapter: planner,
499
+ authorizer: resource_authorizer,
500
+ user: current_user
501
+ )
260
502
 
261
- Maglev.configure do |config|
262
- config.authorization_adapter = MaglevAuthorization.new
263
- end
503
+ result.status
504
+ result.route
505
+ result.kind
506
+ result.value
507
+ result.evidence
508
+ result.warnings
509
+ result.trace_id
510
+ result.confidence
511
+ result.reasons
512
+ result.metadata
264
513
  ```
265
514
 
266
- The adapter must implement:
515
+ Modes are `:structured`, `:rag`, `:hybrid`, and `:auto`. Explicit modes never
516
+ call the routing classifier. Automatic routing requires `routing_adapter` and
517
+ receives only bounded capability summaries—not record values or source text.
518
+ Every application-level request must name resources/models or supply a base
519
+ relation; Maglev never scans all application models.
267
520
 
268
- - `scope(model:, user:)`, returning the records visible to the user.
269
- - `authorize(record:, user:)`, returning `false` to deny a record.
521
+ A routing adapter implements `classify(question:, capabilities:)` and returns
522
+ `{"route" => "structured", "confidence" => 0.9, "reasons" => ["exact fields"]}`
523
+ or another valid route. Confidence is advisory and never grants access.
270
524
 
271
- Without an adapter, all records are allowed. Pass `user:` consistently anywhere retrieval must be scoped.
525
+ For a generated RAG answer through the unified API, pass `answer: true`.
526
+ Otherwise the RAG route returns `kind: :semantic_matches`.
272
527
 
273
- `customer.explain` is a convenience API for applications without user-scoped authorization; use `customer.ask(Maglev.configuration.explain_question, user: current_user)` when a user context is required. Authorization scopes apply to the default pgvector retrieval path. See [Vector Stores](#vector-stores) before combining direct `search` calls with a custom store.
528
+ ## Hybrid workflows
274
529
 
275
- ## Configuration
530
+ Hybrid mode supports exactly two fixed shapes and requires one registered
531
+ resource with both queryable and knowledge capabilities.
276
532
 
277
533
  ```ruby
278
- Maglev.configure do |config|
279
- config.embedding_provider do |provider|
280
- provider.url = ENV.fetch("MAGLEV_EMBEDDING_URL", "https://api.openai.com/v1")
281
- provider.api_key = ENV["MAGLEV_EMBEDDING_API_KEY"]
282
- provider.model = "text-embedding-3-small"
283
- provider.dimensions = 1536
284
- end
285
-
286
- config.generation_provider do |provider|
287
- provider.url = ENV.fetch("MAGLEV_GENERATION_URL", "https://api.openai.com/v1")
288
- provider.api_key = ENV["MAGLEV_GENERATION_API_KEY"]
289
- provider.model = "gpt-4.1-mini"
290
- end
291
-
292
- config.chunk_size = 1000
534
+ result = current_account.support_tickets.maglev_request(
535
+ "Open tickets that mention cancellation",
536
+ mode: :hybrid,
537
+ hybrid_plan: :structured_first, # or :rag_first
538
+ planner_adapter: planner,
539
+ authorizer: resource_authorizer,
540
+ candidate_limit: 100,
541
+ user: current_user
542
+ )
293
543
 
294
- config.context_max_characters = 4000
295
- config.context_per_owner_characters = 1200
296
- config.max_relation_depth = 3
297
-
298
- config.attachment_allowed_content_types = [
299
- "text/plain",
300
- "text/markdown",
301
- "text/html"
302
- ]
303
- config.attachment_max_bytes = 5 * 1024 * 1024
304
- config.attachment_max_characters = 20_000
305
-
306
- config.provider_max_attempts = 2
307
- config.provider_timeout = 30
308
- config.logger = Rails.logger
309
- end
544
+ result.kind # :hybrid_answer
545
+ result.value.records
546
+ result.evidence # structured/RAG provenance envelopes
547
+ result.metadata[:plan_shape]
548
+ result.metadata[:operations]
310
549
  ```
311
550
 
312
- `provider_timeout` applies to each provider attempt. Timed-out attempts are retryable and count toward `provider_max_attempts`.
313
-
314
- Inject custom `embedding_adapter`, `generation_adapter`, `attachment_extractor`, `authorization_adapter`, or `source_redactor` objects when your application needs different provider or policy behavior. Tests can use deterministic adapters without making network calls.
551
+ Structured-first filters records before retrieval. RAG-first retrieves candidate
552
+ owners and then verifies them through the authorized relation. Candidate handoff
553
+ uses typed primary keys, is bounded, and reapplies registration, tenant, base
554
+ relation, and authorization constraints at every stage. There are no loops or
555
+ autonomous tool calls.
315
556
 
316
- ## Vector Stores
557
+ ## Authorization and tenancy
317
558
 
318
- PostgreSQL with pgvector is the default production path. Maglev also exposes a compact backend contract for applications that need another store:
559
+ The base relation is the structured and hybrid authority boundary:
319
560
 
320
561
  ```ruby
321
- class MyVectorStore < Maglev::VectorStores::Base
322
- def upsert(documents:)
323
- # Persist or replace documents by document.id
324
- end
562
+ # Plain tenant scope
563
+ current_account.invoices.maglev_request(
564
+ question, mode: :structured, authorizer: resource_authorizer, user: current_user
565
+ )
325
566
 
326
- def search(vector:, filters:, limit:)
327
- # Return nearest Maglev::VectorStores::Document objects
328
- end
567
+ # Pundit
568
+ policy_scope(Invoice).maglev_request(
569
+ question, mode: :structured, authorizer: resource_authorizer, user: current_user
570
+ )
329
571
 
330
- def delete(ids:)
331
- # Delete documents with these stable IDs
332
- end
572
+ # CanCanCan
573
+ Invoice.accessible_by(current_ability).maglev_request(
574
+ question, mode: :structured, authorizer: resource_authorizer, user: current_user
575
+ )
576
+ ```
333
577
 
334
- def delete_by_owner(owner_type:, owner_id:)
335
- # Delete every document belonging to this owner
336
- end
578
+ RAG authorization uses an optional adapter:
337
579
 
338
- def healthcheck = :ok
339
- def capabilities = {metadata_filtering: true}
580
+ ```ruby
581
+ class MaglevAuthorization
582
+ def scope(model:, user:) = model.where(account_id: user.account_id)
583
+ def authorize(record:, user:) = record.account_id == user.account_id
340
584
  end
341
585
 
342
586
  Maglev.configure do |config|
343
- config.vector_store = MyVectorStore.new
587
+ config.authorization_adapter = MaglevAuthorization.new
588
+ config.tenant_id_resolver = lambda do |record: nil, user: nil|
589
+ (record || user)&.account_id&.to_s
590
+ end
344
591
  end
345
592
  ```
346
593
 
347
- `Maglev::VectorStores::Memory` is useful for tests and local experiments. Custom stores should preserve document metadata filtering and stable document identity semantics.
594
+ Without an RAG authorization adapter, records are allowed by default. Configure
595
+ one and pass `user:` for every user-scoped retrieval.
348
596
 
349
- Custom vector stores currently receive model and owner metadata filters, but not the configured authorization scope. `ask` still authorizes retrieved owners individually; direct `search(..., user:)` with a custom store must not be treated as authorization-filtered. Apply tenant or policy filtering inside the custom store, or use the default pgvector path when authorization-scoped search is required.
597
+ Authorization filters are pushed into storage when supported and every hydrated
598
+ record is rechecked. Authorization scopes exceeding 1,000 owner IDs fail closed.
350
599
 
351
- ## Operations and Observability
600
+ ## Indexing, upgrades, and operations
352
601
 
353
602
  ```bash
354
603
  bin/rails maglev:status
355
- bin/rails maglev:reindex[Customer]
604
+ bin/rails maglev:reindex[SupportTicket]
356
605
  bin/rails maglev:reindex_all
606
+ bin/rails maglev:evaluate_planner
357
607
  ```
358
608
 
359
- Reindexing is safe to repeat: unchanged chunks are reused and obsolete chunks are removed. Maglev emits ActiveSupport notifications for indexing start/success/failure, retrieval, generation, and provider retries:
609
+ Callbacks enqueue `Maglev::ReindexJob` after relevant commits. Indexing is
610
+ idempotent, reuses unchanged chunks, atomically replaces one owner's searchable
611
+ generation, and records safe status/failure diagnostics.
360
612
 
361
- ```ruby
362
- ActiveSupport::Notifications.subscribe(/\Amaglev\./) do |name, start, finish, id, payload|
363
- Rails.logger.info(
364
- event: name,
365
- duration_ms: ((finish - start) * 1000).round(1),
366
- **payload
367
- )
368
- end
613
+ Applications upgrading from 0.1.x should follow [CHANGELOG.md](CHANGELOG.md):
614
+
615
+ ```bash
616
+ bin/rails generate maglev:upgrade_index_version
617
+ bin/rails generate maglev:upgrade_source_identity
618
+ bin/rails db:migrate
619
+ bin/rails maglev:reindex_all
369
620
  ```
370
621
 
371
- ## Security and Boundaries
622
+ Review generated migrations, migrate the vector column separately when embedding
623
+ dimensions change, rebuild the HNSW index, and complete a full reindex. Old rows
624
+ remain unavailable until they carry the current index identity.
372
625
 
373
- - Treat extracted content as untrusted context. Maglev sanitizes supported HTML sources, but authorization and model exposure remain application responsibilities.
374
- - Expose only fields and content sources that are appropriate to send to your configured providers.
375
- - Default attachment limits are 5 MiB and 20,000 extracted characters; the default allowlist covers plain text, Markdown, HTML, and XHTML.
376
- - The generated v1 migration uses `bigint` owner IDs. UUID-backed owners require a customized migration.
377
- - Maglev is RAG-only. It does not generate SQL, answer aggregate questions through database computation, expose REST endpoints, provide an admin UI, run agents, or manage conversation memory.
626
+ ### Index identity and safe replacement
378
627
 
379
- ## Compatibility
628
+ Every chunk stores an `index_version`. Fingerprint format version 1 uses the
629
+ `maglev-index` namespace and covers the embedding model/dimensions, adapter ID
630
+ and version, chunking algorithm/size, and `application_index_version`. Custom
631
+ embedding adapters implement `maglev_adapter_id` and `maglev_adapter_version`,
632
+ or configure `embedding_adapter_id` and `embedding_adapter_version`.
380
633
 
381
- The CI matrix tests:
634
+ The `upgrade_index_version` migration intentionally adds a nullable
635
+ `index_version`. Legacy rows are unavailable until a full reindex gives them the
636
+ current identity. Migrate the vector dimension before reindexing. A failed owner
637
+ replacement preserves the previous complete generation.
382
638
 
383
- | | Supported |
384
- |---|---|
385
- | Ruby | 3.2, 3.3 |
386
- | Rails | 7.1, 8.0 |
387
- | Database | PostgreSQL with pgvector |
639
+ ## Vector-store contract
388
640
 
389
- Versions outside this matrix may work but are not currently guaranteed.
641
+ PostgreSQL/pgvector is the production default; `Maglev::VectorStores::Memory` is
642
+ for tests and local experiments. A custom store implements `fetch(ids:)`,
643
+ `upsert(documents:)`, `search(vector:, filters:, limit:)`, `delete(ids:)`,
644
+ `delete_by_owner(owner_type:, owner_id:)`, atomic
645
+ `replace_owner(owner_type:, owner_id:, documents:)`, `healthcheck`, and
646
+ `capabilities`.
390
647
 
391
- ## Development
648
+ `delete_by_owner` followed by `upsert` is not an atomic replacement. Concurrent
649
+ replacement and deletion for one owner must be linearizable, and failed
650
+ replacement must preserve the previous complete generation.
392
651
 
393
- ```bash
394
- bundle install
652
+ ## Traces, evidence, and safety boundaries
395
653
 
396
- # PostgreSQL + pgvector integration tests
397
- MAGLEV_REQUIRE_POSTGRESQL=true MAGLEV_DATABASE=maglev_test bundle exec rspec
654
+ Maglev results carry bounded evidence and a trace ID. Traces record identifiers,
655
+ decisions, operation names, limits, safe timings, warnings, and error classes.
656
+ They exclude record values, source text, prompts, secrets, and raw provider
657
+ payloads by default. The host application owns audit persistence and retention.
398
658
 
659
+ Maglev 0.2 does **not** provide:
660
+
661
+ - natural-language writes or mutations;
662
+ - unrestricted SQL, Ruby, Arel, scopes, or code execution;
663
+ - autonomous/iterative agents;
664
+ - a Rails Engine, REST API, admin UI, or mandatory frontend;
665
+ - built-in PDF/Office/OCR/audio parsing;
666
+ - streaming or conversation memory;
667
+ - Qdrant or another required external vector service.
668
+
669
+ Retrieved documents are evidence, never instructions that can change route,
670
+ permissions, Query IR, or execution policy.
671
+
672
+ ## Runtime support and development
673
+
674
+ | Component | Supported |
675
+ | --- | --- |
676
+ | Ruby | 3.3, 4.0 |
677
+ | Rails | 7.1, 8.0 |
678
+ | Database | PostgreSQL with pgvector |
679
+
680
+ ```bash
681
+ bundle exec rspec
399
682
  bundle exec standardrb
400
683
  bundle exec rubocop
401
684
  bundle exec rake build
685
+ bundle exec rake maglev:release_audit
402
686
  ```
403
687
 
404
- The default test suite uses deterministic fake adapters and never calls a live LLM or embedding provider.
688
+ The default suite uses deterministic fakes and never calls a live LLM or
689
+ embedding provider.
405
690
 
406
691
  ## License
407
692
 
408
- Maglev is available as open source under the [MIT License](LICENSE.txt).
693
+ Maglev is available under the [MIT License](LICENSE.txt).