parse-stack-next 5.5.6 → 5.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8f26110cea78b4a3fc393dce2ac3aada7d73442f7d1e970e2aa71d88d3ecba2
4
- data.tar.gz: 753ead47dcc377ba89e5eca4d2e93a3f7767f5e71a168124f6f180fe7549b5a1
3
+ metadata.gz: d7ea05ce0688ca7c1df6a1077162478dfee52e33d9fac77c7480c5ca818c9eb8
4
+ data.tar.gz: 71e61f7e70da94793dae9d741a3e5d29ffc7f9cd45f2adee6c96cd98b5ac2d24
5
5
  SHA512:
6
- metadata.gz: c1261b7e8db1b2d61e69fe70a1327829afd8d4c1b0960cb1ca1b3d5b418fcbc81b21dc05921a59865d7c226108392f0c3968a982277410872585b5cc4f42760a
7
- data.tar.gz: 64f53a649209415edf4c3cecaab383f5ea01e0f106711ae867bfc7ac6d506dde592bd09b628a7956ccb621428ff4cea9c6f5970660d40465581496092b3d18ee
6
+ metadata.gz: c723fbb9589ce57c7651fab142bed386f3a2070d39fc20887fc40b50f676ccb0041e9b758611e8b75d0a9202095de318ce2bbae35e41015be519209d9d2628dd
7
+ data.tar.gz: dbbc09ee718630415da1ab329168fc1c10d5d9cf20ad09e4129b2518087231206ca84d7d5ecd5e1a27f66e2fb75d3704b856592fcd43c6d2f7711fa20c0dba1b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,230 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.6.0
4
+
5
+ #### Voyage embeddings reach the Atlas endpoint, video, and streamed media
6
+
7
+ - **NEW**: The Voyage provider now targets MongoDB's Atlas Embedding and
8
+ Reranking API in addition to Voyage's own. The two serve the same models
9
+ over an identical wire contract but do not share credentials — an Atlas
10
+ model API key returns 403 from Voyage's host and vice versa. A key carrying
11
+ the Atlas prefix routes to `https://ai.mongodb.com/v1` automatically; pass
12
+ `endpoint: :atlas` or `:voyage` to be explicit, or a `base_url:` to override
13
+ both. A named endpoint that contradicts an explicit `base_url` is rejected
14
+ rather than silently reconciled, so a credential is never sent to a host the
15
+ caller did not intend. `#endpoint` and `#atlas?` report the resolved target.
16
+ - **NEW**: Added `voyage-3.5`, `voyage-3.5-lite`, `voyage-code-2` (1536-dim),
17
+ and `voyage-multimodal-3.5`. Models the Atlas endpoint does not expose —
18
+ `voyage-3`, `voyage-3-lite`, `voyage-4-nano` — are refused at construction
19
+ when that endpoint is active, with an error naming a current replacement;
20
+ they remain valid against Voyage's own API.
21
+ - **NEW**: `Parse::Embeddings::Voyage#embed_video` embeds video through
22
+ `voyage-multimodal-3.5`, the only model that accepts it. Text, image, and
23
+ video vectors share one space, so a stored text vector is comparable against
24
+ a video vector without re-embedding. `#modalities` reports `[:text, :image,
25
+ :video]` for that model and `[:text, :image]` for `voyage-multimodal-3`.
26
+ - **NEW**: `Parse::Embeddings::MediaFile` wraps a local image or video and
27
+ streams it into the request body instead of buffering it. Serializing media
28
+ with `to_json` costs roughly 2.4x the file size resident — raw bytes, the
29
+ 1.33x base64 copy, and the serialized document — which is enough to exhaust
30
+ a small dyno on a single moderate video. `MediaFile` reads only a 16-byte
31
+ header at construction; the payload is then base64-encoded into the socket
32
+ in fixed-size chunks by `Parse::Embeddings::StreamingBody`, so peak memory
33
+ is bounded by the chunk size regardless of file size and nothing spills to
34
+ disk. `Content-Length` is computed exactly so the request avoids chunked
35
+ transfer encoding, and the body replays byte-identically on retry. Passing a
36
+ URL instead keeps the SDK out of the transfer entirely.
37
+ - **FIXED**: Corrected the model dimension table. The entire v4 family
38
+ defaults to 1024 — `voyage-4-large`'s 2048, `voyage-4-lite`'s 512, and
39
+ `voyage-4-nano`'s 256 were recorded as native widths when they are
40
+ Matryoshka options reached only by requesting them. Because the provider
41
+ validates the returned vector width against the declared one, both
42
+ `voyage-4-large` and `voyage-4-lite` raised
43
+ `Parse::Embeddings::InvalidResponseError` on every call. `voyage-4-nano` is
44
+ 1024, and `voyage-finance-2` carries a 32,000-token context rather than
45
+ 16,000.
46
+ - **CHANGED**: The coarse "Matryoshka-capable models" gate is replaced by
47
+ per-model `MODEL_SUPPORTED_DIMENSIONS`. Any width on a model's ladder is now
48
+ accepted — including one wider than its default, which the old rule rejected
49
+ as "exceeds native" — and a width off the ladder is refused with the
50
+ supported set named. `output_dimension` is sent whenever the configured
51
+ width differs from the model's default, so `voyage-4-lite` at 512 and
52
+ `voyage-4-large` at 2048 both work.
53
+ - **FIXED**: A caller-supplied URL can no longer capture a local file's bytes.
54
+ The streamed body previously marked each payload with a sentinel token and
55
+ located it by searching the serialized JSON, so a URL containing that token
56
+ matched first: the file's base64 was spliced into the URL slot and forwarded
57
+ to the provider as a URL to fetch, disclosing local file contents, while the
58
+ intended slot kept the literal token. Request bodies are now assembled
59
+ structurally — each fragment serialized independently and concatenated in
60
+ order — so payloads are placed by position and caller data is never
61
+ searched.
62
+ - **FIXED**: Mixed `image_url` / `image_base64` batches no longer violate
63
+ Voyage's request contract, which requires a single representation per
64
+ request. A mixed batch is split into one request per representation and
65
+ reassembled into the caller's original order, preserving the 1:1 alignment
66
+ between inputs and returned vectors.
67
+ - **FIXED**: Video validation no longer accepts containers the provider
68
+ rejects. MP4 is the only format Voyage supports, and WebM and QuickTime
69
+ payloads are refused by the API, so both are out of the default allowlist.
70
+ An `ftyp` box no longer implies MP4 on its own — QuickTime and the
71
+ audio-only profiles share the ISO base media container — so major brands are
72
+ matched explicitly and an unrecognized brand is refused rather than assumed.
73
+ Apple's audio-only `M4A` brand is excluded, closing a type confusion in
74
+ which an audio file passed as video.
75
+ - **NEW**: `Parse::Embeddings.max_media_bytes` caps streamed media per file,
76
+ defaulting to the 20 MB Voyage documents. Streaming already prevents an
77
+ oversized file from exhausting memory, but the provider still rejects it, so
78
+ failing locally turns a wasted upload into an immediate error. The Voyage
79
+ adapter enforces the 20 MB ceiling independently, so raising the global knob
80
+ for another provider cannot push an oversized payload onto Voyage.
81
+
82
+ #### Breaking
83
+
84
+ - **BREAKING**: The Voyage provider's default model moves from `voyage-3` to
85
+ `voyage-3.5`. `voyage-3` is retired from the Atlas endpoint, so the old
86
+ default made an Atlas key fail at construction whenever no model was named.
87
+ Vectors from the two models are not comparable. **Migration:** code relying
88
+ on the default must pin `model: "voyage-3"` explicitly to keep existing
89
+ embeddings valid, or re-embed against the new default. A `:vector` property
90
+ that declares `model:` is unaffected — the new binding audit catches the
91
+ mismatch before any request rather than letting the two mix silently.
92
+
93
+ #### Vector search no longer returns fewer results than requested
94
+
95
+ - **FIXED**: `$vectorSearch` set its `limit` to `k`, but Atlas applies that
96
+ limit before the SDK's ACL `$match`, `protectedFields` redaction,
97
+ pointer-field filtering, and any caller-supplied `filter` — so a scoped
98
+ caller who could read 2 of the top 10 documents asked for 10 and received 2,
99
+ even when hundreds of readable matches existed further down the ranking.
100
+ The search now requests a wider internal candidate window, applies every
101
+ enforcement layer, and only then trims to `k`. The window is raised only
102
+ when something can actually drop rows, so a master-key call with no filter
103
+ keeps its previous one-for-one cost. A `candidate_limit:` option on
104
+ `VectorSearch.search` and `find_similar` tunes the window for principals
105
+ whose visibility is unusually narrow. This is a mitigation, not a guarantee:
106
+ a sufficiently selective ACL can still exhaust any finite window, which is
107
+ why the attrition counts below exist. HNSW width stays anchored to `k`, so
108
+ the wider window does not widen the ANN search.
109
+ - **NEW**: `VectorSearch.search` emits a `parse.vector_search.search`
110
+ `ActiveSupport::Notifications` event carrying `candidate_limit`,
111
+ `num_candidates`, `post_filter_count`, `post_pointer_count`,
112
+ `returned_count`, `pointer_attrition`, and `underfilled`. The counts are
113
+ named for where they are measured — obtaining a true pre-`$match` count
114
+ would require a `$facet` — so an underfill is observable rather than silent.
115
+
116
+ #### Vector properties are checked against the provider actually registered
117
+
118
+ - **FIXED**: A `:vector` property's `model:` was recorded and never enforced.
119
+ Because models in the same family usually share a width (`voyage-3` and
120
+ `voyage-3.5` are both 1024), swapping the registered provider's model
121
+ silently mixed incomparable vectors into one index — no error, just
122
+ degraded recall, repairable only by re-embedding. `dimensions:` was
123
+ verified, but only against a vector the provider had already returned and
124
+ billed for. Both are now checked by `Parse::Embeddings::BindingAudit`
125
+ before any request is issued, on the managed-write path and the
126
+ query-embedding path alike. The audit runs ahead of the digest short-circuit
127
+ so an unchanged record still surfaces a drifted binding, and it fails closed:
128
+ a provider that cannot report `#model_name` or `#dimensions` is refused
129
+ rather than skipped, since a declaration that cannot be verified is not a
130
+ declaration that has been satisfied.
131
+ - **NEW**: `Parse::Embeddings::BindingAudit.audit_all!` and
132
+ `.audit_all_or_raise!` check every declared binding at once, for a boot-time
133
+ or CI gate rather than waiting for the first save that happens to touch one.
134
+ - **NEW**: `:vector` properties validate `similarity:` against the functions
135
+ Atlas accepts (`euclidean`, `cosine`, `dotProduct`) at declaration time
136
+ instead of surfacing a typo as an index error later.
137
+ - **FIXED**: Hybrid search applies the same candidate window as the plain
138
+ vector search, so opting into hybrid no longer underfills where a straight
139
+ vector search would not. Each branch's limit is applied before ACL
140
+ enforcement and was narrower than the plain path's window.
141
+ - **FIXED**: Hybrid search separates the rows each branch retains for fusion
142
+ from the rows Atlas considers before ACL. Conflating them meant the branch
143
+ limit was passed as the vector branch's `k` and then multiplied a second
144
+ time by the plain search's own window derivation — a hybrid `k: 10` asked
145
+ Atlas for 1,000 rows instead of the intended 100, an explicit
146
+ `candidate_limit: 250` became 2,500 on the client path while staying 250 on
147
+ the native path, and any `k` above 100 produced a branch `k` beyond
148
+ `VectorSearch::MAX_K` that failed outright. The fusion depth is now bounded
149
+ by `MAX_K` and the candidate window by Atlas's 10,000 ceiling, with both
150
+ paths using the same window. A `candidate_limit` outside that range is
151
+ refused rather than clamped, and `vector: { candidate_limit: }` is forwarded
152
+ through `hybrid_search`.
153
+ - **FIXED**: The native `$rankFusion` pipeline trimmed to `k` in a `$limit`
154
+ stage that runs after its ACL `$match`, reintroducing the underfill the
155
+ candidate window exists to prevent. It now limits to the candidate window
156
+ and trims to `k` once enforcement has run.
157
+ - **NEW**: A `parse.vector_search.hybrid` notification reports `method`,
158
+ `branch_depth`, `candidate_window`, `post_filter_count`, `returned_count`,
159
+ and `underfilled` for both fusion paths. `branch_depth` differs by method by
160
+ design: the client path enforces ACL inside each branch and can retain the
161
+ narrower fusion depth, while the native path enforces after `$rankFusion`
162
+ and must retain the full window. The value reported is the one that ran.
163
+ - **NEW**: A `:vector` property wider than the Atlas vectorSearch index cap is
164
+ refused unless it declares `searchable: false`. `Parse::Vector` tolerates up
165
+ to 16384 dimensions while an Atlas index caps at 8192, so such a property
166
+ was previously declarable, storable, and permanently unsearchable, with the
167
+ failure appearing only at query time. The two limits remain distinct — they
168
+ govern storage and indexing respectively — but the combination now has to be
169
+ acknowledged.
170
+ - **NEW**: `searchable: false` makes a `:vector` property genuinely
171
+ storage-only, whether it opted out to clear the index cap or by choice. It
172
+ is excluded from `find_similar` / `hybrid_search` field resolution, refused
173
+ with an explanation when named directly, and rejected by `agent_searchable`
174
+ at class load rather than at an agent's first query.
175
+
176
+ #### Provider protocol
177
+
178
+ - **NEW**: `Parse::Embeddings::Provider#embed_video` joins `#embed_image` in
179
+ the base protocol with the same `NotImplementedError` default, so video is a
180
+ declared capability rather than a Voyage-only method. `#supports_modality?`
181
+ answers the capability question without rescuing.
182
+ - **NEW**: `rake test:contract` runs live, billable provider contract tests
183
+ that pin request routing, native dimensions, the Matryoshka ladder, accepted
184
+ media, size limits, model availability, and response shape. They skip unless
185
+ `VOYAGE_CONTRACT_KEY` is set and are excluded from both `rake test` and
186
+ `rake test:unit`, so no ordinary run becomes billable because a key happens
187
+ to be exported. Probes that assert a refusal issue raw requests rather than
188
+ going through the SDK — a local guard asserted against itself proves nothing
189
+ about the contract it encodes — and distinguish a genuine refusal from an
190
+ authentication, rate-limit, or 5xx failure so infrastructure trouble cannot
191
+ read as a contract verdict. Mocked tests assert what the SDK believes the
192
+ API does and therefore cannot detect provider drift: every dimension and
193
+ media-format correction in this release was invisible to a fully green
194
+ mocked suite.
195
+
196
+ ### Behavior Notes
197
+
198
+ - Audio is not offered by any Voyage model, and neither PDF nor DOCX is
199
+ accepted as a content type. Render document pages to images and embed those;
200
+ the SDK does not perform that conversion.
201
+ - Deterministic result fill under highly selective ACLs would require the
202
+ authorization predicate to run inside the Atlas prefilter rather than after
203
+ it. The post-search `$match` remains the enforcement boundary regardless.
204
+ - `voyage-4-nano` is served by neither hosted endpoint — it is open-weight and
205
+ meant to be self-hosted. It is refused against Voyage's and Atlas's hosts
206
+ with a message pointing at a self-hosted `base_url:` or
207
+ `Parse::Embeddings::LocalHTTP`, and remains usable through either.
208
+
209
+ ### Code Example
210
+
211
+ ```ruby
212
+ # Endpoint inferred from the key prefix — no base_url needed.
213
+ provider = Parse::Embeddings::Voyage.new(
214
+ api_key: ENV.fetch("ATLAS_MODEL_API_KEY"),
215
+ model: "voyage-multimodal-3.5",
216
+ )
217
+ provider.endpoint # => :atlas
218
+ provider.modalities # => [:text, :image, :video]
219
+
220
+ # Local media streams into the request; the bytes are never held in memory.
221
+ provider.embed_image([Parse::Embeddings::MediaFile.image("page.png")])
222
+ provider.embed_video([Parse::Embeddings::MediaFile.video("demo.mp4")])
223
+
224
+ # A URL keeps the SDK out of the transfer entirely — the provider fetches it.
225
+ provider.embed_image(["https://cdn.example.com/page.png"])
226
+ ```
227
+
3
228
  ### 5.5.6
4
229
 
5
230
  #### MCP clients now receive the SSE response instead of hanging
@@ -634,6 +634,16 @@ module Parse
634
634
  "agent_searchable field: :#{field_sym} is not a declared :vector property " \
635
635
  "on #{parse_class_name} (declared: #{vector_properties.keys.inspect})."
636
636
  end
637
+ # A storage-only property is not eligible for search, so the
638
+ # tool this registers could never run. Refuse at class load
639
+ # rather than at the first agent query.
640
+ if respond_to?(:vector_properties) &&
641
+ vector_properties.dig(field_sym, :searchable) == false
642
+ raise ArgumentError,
643
+ "agent_searchable field: :#{field_sym} on #{parse_class_name} is declared " \
644
+ "`searchable: false` (storage-only, not eligible for search) and cannot " \
645
+ "back a search tool."
646
+ end
637
647
  filters = Array(filter_fields).map(&:to_sym)
638
648
  @agent_searchable_field = field_sym
639
649
  @agent_searchable_filter_fields = filters
@@ -0,0 +1,211 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module Embeddings
6
+ # Checks that a `:vector` property's declared provider binding
7
+ # still matches the provider actually registered under that name.
8
+ #
9
+ # A `:vector` property may declare `provider:`, `model:`, and
10
+ # `dimensions:`. Only `provider:` was ever enforced. `dimensions:`
11
+ # is verified — but only against the vector a provider already
12
+ # returned, i.e. after the call has been made and paid for. And
13
+ # `model:` was never checked at all, which is the dangerous one: two
14
+ # generations of the same model family usually share a width
15
+ # (`voyage-3` and `voyage-3.5` are both 1024), so swapping the
16
+ # registered provider's model silently mixes incompatible
17
+ # embeddings into one index. Nothing raises, recall just quietly
18
+ # degrades, and the damage is only repairable by re-embedding.
19
+ #
20
+ # This module closes both gaps by comparing the declaration against
21
+ # the live provider BEFORE any request is issued.
22
+ #
23
+ # Auditing cannot happen at class-definition time: providers are
24
+ # registered by name and, as {Parse::Core::EmbedManaged} documents,
25
+ # registration may legitimately happen any time before the first
26
+ # save. So the audit runs lazily on each use and is also exposed as
27
+ # {.audit_all!} for an explicit boot-time or CI check.
28
+ module BindingAudit
29
+ # Raised when a property's declared binding disagrees with the
30
+ # registered provider.
31
+ class BindingMismatch < Parse::Embeddings::Error; end
32
+
33
+ # Raised when the audit cannot enumerate the classes it is meant
34
+ # to check. Distinct from {BindingMismatch}: nothing was found to
35
+ # be wrong, but nothing was verified either.
36
+ class DiscoveryFailed < Parse::Embeddings::Error; end
37
+
38
+ class << self
39
+ # Verify one property binding against a resolved provider.
40
+ #
41
+ # Deliberately NOT memoized. The check is a pair of comparisons
42
+ # against values already in memory, so caching it saves nothing
43
+ # measurable — while any cache key cheap enough to be worth
44
+ # computing (class name, provider object id) can go stale when a
45
+ # class is unloaded and redefined with a changed declaration, or
46
+ # when object ids are recycled. A validator that silently skips
47
+ # after a reload is worse than no validator, so correctness wins
48
+ # over an optimization with no observable benefit.
49
+ #
50
+ # @param klass [Class] the Parse::Object subclass.
51
+ # @param field [Symbol] the `:vector` property name.
52
+ # @param provider [Parse::Embeddings::Provider]
53
+ # @raise [BindingMismatch]
54
+ # @return [void]
55
+ def verify!(klass, field, provider)
56
+ declared = klass.vector_properties[field.to_sym]
57
+ return if declared.nil?
58
+
59
+ check!(klass, field, provider, declared)
60
+ nil
61
+ end
62
+
63
+ # Audit every declared binding whose provider is registered.
64
+ # Intended for boot or CI: it surfaces a drifted declaration
65
+ # before a single embedding is written, rather than on the
66
+ # first save that happens to touch it.
67
+ #
68
+ # @param classes [Array<Class>, nil] defaults to every
69
+ # Parse::Object subclass carrying `:vector` properties.
70
+ # @param strict [Boolean] when true, an unregistered provider
71
+ # is itself a failure; otherwise those bindings are skipped
72
+ # (a provider may be registered later in boot).
73
+ # @return [Array<String>] human-readable problems, empty when clean.
74
+ def audit_all!(classes: nil, strict: false)
75
+ problems = []
76
+ begin
77
+ bindings = collect_bindings(classes)
78
+ rescue DiscoveryFailed => e
79
+ return [e.message]
80
+ end
81
+
82
+ bindings.each do |klass, field, declared|
83
+ provider_name = declared[:provider]
84
+ next if provider_name.nil?
85
+
86
+ begin
87
+ provider = Parse::Embeddings.provider(provider_name)
88
+ rescue Parse::Embeddings::ProviderNotRegistered => e
89
+ problems << "#{klass}##{field}: #{e.message}" if strict
90
+ next
91
+ end
92
+
93
+ begin
94
+ check!(klass, field, provider, declared)
95
+ rescue BindingMismatch => e
96
+ problems << e.message
97
+ end
98
+ end
99
+ problems
100
+ end
101
+
102
+ # {.audit_all!} that raises instead of returning problems.
103
+ #
104
+ # @raise [BindingMismatch] when any binding disagrees.
105
+ # @return [void]
106
+ def audit_all_or_raise!(classes: nil, strict: false)
107
+ problems = audit_all!(classes: classes, strict: strict)
108
+ return if problems.empty?
109
+
110
+ raise BindingMismatch,
111
+ "Parse::Embeddings binding audit failed:\n - #{problems.join("\n - ")}"
112
+ end
113
+
114
+ # Retained as a no-op for callers that invoked it when this
115
+ # module memoized verdicts.
116
+ # @return [void]
117
+ def reset!
118
+ nil
119
+ end
120
+
121
+ private
122
+
123
+ # Read a provider accessor, distinguishing "not implemented"
124
+ # from a legitimate nil. Returns `:unavailable` for the former
125
+ # so {#check!} can fail closed rather than skip the comparison.
126
+ def accessor(provider, method)
127
+ value = provider.public_send(method)
128
+ value.nil? ? :unavailable : value
129
+ rescue NotImplementedError, NoMethodError
130
+ :unavailable
131
+ end
132
+
133
+ def check!(klass, field, provider, declared)
134
+ declared_model = declared[:model]
135
+ if declared_model
136
+ actual_model = accessor(provider, :model_name)
137
+ # A declaration states a requirement. A provider that cannot
138
+ # answer what model it runs cannot satisfy it, so this fails
139
+ # closed — otherwise a custom provider without `model_name`
140
+ # would write same-width embeddings that are never checked
141
+ # against the declaration at all.
142
+ if actual_model == :unavailable
143
+ raise BindingMismatch,
144
+ "#{klass}##{field} declares model: #{declared_model.inspect} but the " \
145
+ "provider registered as #{declared[:provider].inspect} " \
146
+ "(#{provider.class}) does not report a usable #model_name, so the " \
147
+ "binding cannot be verified. Implement #model_name on the provider, " \
148
+ "or drop `model:` from the property to opt out of the check."
149
+ end
150
+ if declared_model.to_s != actual_model.to_s
151
+ raise BindingMismatch,
152
+ "#{klass}##{field} declares model: #{declared_model.inspect} but the " \
153
+ "provider registered as #{declared[:provider].inspect} is running " \
154
+ "#{actual_model.inspect}. Vectors from different models are not " \
155
+ "comparable; embedding with the current provider would corrupt this " \
156
+ "index. Update the declaration and re-embed, or register the declared " \
157
+ "model."
158
+ end
159
+ end
160
+
161
+ declared_dims = declared[:dimensions]
162
+ if declared_dims
163
+ actual_dims = accessor(provider, :dimensions)
164
+ if actual_dims == :unavailable
165
+ raise BindingMismatch,
166
+ "#{klass}##{field} declares dimensions: #{declared_dims} but the " \
167
+ "provider registered as #{declared[:provider].inspect} " \
168
+ "(#{provider.class}) does not report a usable #dimensions, so the " \
169
+ "binding cannot be verified. #dimensions is required by the provider " \
170
+ "protocol."
171
+ end
172
+ if declared_dims != actual_dims
173
+ raise BindingMismatch,
174
+ "#{klass}##{field} declares dimensions: #{declared_dims} but the provider " \
175
+ "registered as #{declared[:provider].inspect} emits #{actual_dims}-dim " \
176
+ "vectors. Fix the declaration or configure the provider's width before " \
177
+ "embedding."
178
+ end
179
+ end
180
+ nil
181
+ end
182
+
183
+ def collect_bindings(classes)
184
+ list = classes || default_classes
185
+ list.flat_map do |klass|
186
+ next [] unless klass.respond_to?(:vector_properties)
187
+ klass.vector_properties.map { |field, declared| [klass, field, declared] }
188
+ end
189
+ end
190
+
191
+ # Discovery must NOT swallow its own failure: an empty list from
192
+ # a crashed sweep is indistinguishable from a clean audit, so
193
+ # `audit_all_or_raise!` would report success having checked
194
+ # nothing. Failures propagate as {DiscoveryFailed} and
195
+ # {.audit_all!} converts them into a reported problem.
196
+ def default_classes
197
+ return [] unless defined?(Parse::Object)
198
+ ObjectSpace.each_object(Class).select do |k|
199
+ k < Parse::Object && k.respond_to?(:vector_properties) &&
200
+ !k.vector_properties.empty?
201
+ end
202
+ rescue StandardError => e
203
+ raise DiscoveryFailed,
204
+ "Parse::Embeddings::BindingAudit could not enumerate Parse::Object " \
205
+ "subclasses (#{e.class}: #{e.message}); the audit checked nothing. " \
206
+ "Pass `classes:` explicitly to audit a known set."
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,136 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module Embeddings
6
+ # A file-backed image or video input that is **streamed** into the
7
+ # request body rather than read into memory.
8
+ #
9
+ # This is the memory-safe counterpart to
10
+ # {ImageFetch::FetchedImage}, which holds raw bytes. A MediaFile
11
+ # holds only a path, a sniffed MIME type, and a byte count; the
12
+ # bytes are read and base64-encoded incrementally by
13
+ # {StreamingBody} while the request is being written to the socket.
14
+ # Peak memory stays at {StreamingBody::READ_CHUNK} no matter how
15
+ # large the file is, which is what makes video viable on a small
16
+ # dyno.
17
+ #
18
+ # Only the first 16 bytes are read at construction time, to sniff
19
+ # the container. The `Content-Type` header and the filename
20
+ # extension are never consulted.
21
+ #
22
+ # @example stream a local image
23
+ # img = Parse::Embeddings::MediaFile.image("diagram.png")
24
+ # provider.embed_image([img])
25
+ #
26
+ # @example stream a local video
27
+ # clip = Parse::Embeddings::MediaFile.video("demo.mp4")
28
+ # provider.embed_video([clip])
29
+ class MediaFile
30
+ # Voyage documents 20 MB per image and 20 MB per video.
31
+ # Overridable via {Parse::Embeddings.max_media_bytes=}.
32
+ DEFAULT_MAX_MEDIA_BYTES = 20 * 1024 * 1024
33
+
34
+ # Raised when a file exceeds {Parse::Embeddings.max_media_bytes}.
35
+ class TooLarge < Parse::Embeddings::Error; end
36
+
37
+ # @return [String] absolute path to the backing file.
38
+ attr_reader :path
39
+ # @return [String] sniffed MIME type.
40
+ attr_reader :mime_type
41
+ # @return [Integer] size in bytes, captured at construction.
42
+ attr_reader :byte_size
43
+ # @return [Symbol] `:image` or `:video`.
44
+ attr_reader :kind
45
+
46
+ class << self
47
+ # Wrap a local image file. Verifies the magic bytes against
48
+ # {Parse::Embeddings.allowed_image_types}.
49
+ #
50
+ # @param path [String]
51
+ # @return [MediaFile]
52
+ # @raise [ImageFetch::InvalidImageType]
53
+ def image(path)
54
+ header = read_header(path)
55
+ mime = ImageFetch.sniff_mime(header)
56
+ if mime.nil?
57
+ raise ImageFetch::InvalidImageType.new(:unknown_magic,
58
+ "Parse::Embeddings::MediaFile.image: #{path} matches no supported image " \
59
+ "format (JPEG/PNG/GIF/WebP).")
60
+ end
61
+ allowed = Parse::Embeddings.allowed_image_types
62
+ unless allowed.include?(mime)
63
+ raise ImageFetch::InvalidImageType.new(:type_not_allowed,
64
+ "Parse::Embeddings::MediaFile.image: sniffed type #{mime.inspect} is not in " \
65
+ "Parse::Embeddings.allowed_image_types (#{allowed.inspect}).")
66
+ end
67
+ new(path: path, mime_type: mime, kind: :image)
68
+ end
69
+
70
+ # Wrap a local video file. Verifies the magic bytes against
71
+ # {Parse::Embeddings.allowed_video_types}.
72
+ #
73
+ # @param path [String]
74
+ # @return [MediaFile]
75
+ # @raise [VideoSource::InvalidVideoType]
76
+ def video(path)
77
+ header = read_header(path)
78
+ new(path: path, mime_type: VideoSource.verify!(header), kind: :video)
79
+ end
80
+
81
+ private
82
+
83
+ # Read only enough bytes to sniff a container. Deliberately
84
+ # tiny — the whole point of this class is to never hold the
85
+ # file. A file shorter than this is passed through as-is so the
86
+ # sniffers can reject it with their own error.
87
+ def read_header(path)
88
+ unless ::File.file?(path)
89
+ raise ArgumentError,
90
+ "Parse::Embeddings::MediaFile: #{path.inspect} is not a readable file."
91
+ end
92
+ ::File.open(path, "rb") { |f| f.read(16).to_s }
93
+ end
94
+ end
95
+
96
+ def initialize(path:, mime_type:, kind:)
97
+ @path = ::File.expand_path(path)
98
+ @mime_type = mime_type
99
+ @kind = kind
100
+ @byte_size = ::File.size(@path)
101
+ if @byte_size.zero?
102
+ raise ArgumentError,
103
+ "Parse::Embeddings::MediaFile: #{path.inspect} is empty."
104
+ end
105
+ cap = Parse::Embeddings.max_media_bytes
106
+ if @byte_size > cap
107
+ raise TooLarge,
108
+ "Parse::Embeddings::MediaFile: #{path.inspect} is #{@byte_size} bytes, over " \
109
+ "the #{cap}-byte limit (Parse::Embeddings.max_media_bytes). Voyage rejects " \
110
+ "media above 20 MB — downscale or re-encode before embedding."
111
+ end
112
+ end
113
+
114
+ # The `data:` URI prefix that precedes the streamed base64 in the
115
+ # wire body. The payload itself is never concatenated here.
116
+ #
117
+ # @return [String]
118
+ def data_uri_prefix
119
+ "data:#{mime_type};base64,"
120
+ end
121
+
122
+ # Segment descriptor consumed by {StreamingBody}.
123
+ #
124
+ # @return [Hash]
125
+ def stream_segment
126
+ { path: @path, size: @byte_size }
127
+ end
128
+
129
+ def inspect
130
+ "#<Parse::Embeddings::MediaFile kind=#{@kind} mime_type=#{@mime_type.inspect} " \
131
+ "bytes=#{@byte_size} path=#{@path.inspect}>"
132
+ end
133
+ alias_method :to_s, :inspect
134
+ end
135
+ end
136
+ end
@@ -24,6 +24,7 @@ module Parse
24
24
  # Subclasses MAY override:
25
25
  #
26
26
  # * {#embed_image} — v5.1 (multimodal); default `NotImplementedError`
27
+ # * {#embed_video} — v5.6 (multimodal); default `NotImplementedError`
27
28
  # * {#embed_batch_size} — provider-recommended batch size hint
28
29
  # * {#max_input_tokens} — chunker hint
29
30
  # * {#normalize?} — whether output is unit-normalized
@@ -76,6 +77,44 @@ module Parse
76
77
  raise NotImplementedError, "#{self.class} does not support image embedding"
77
78
  end
78
79
 
80
+ # Embed video sources. Same contract and default posture as
81
+ # {#embed_image}: providers that do not offer video leave this
82
+ # raising, and callers discover support through {#modalities}
83
+ # rather than by rescuing.
84
+ #
85
+ # Video payloads are large enough that holding one in memory is a
86
+ # real operational risk, so the two source forms concrete
87
+ # providers should accept are:
88
+ #
89
+ # * a URL String, forwarded for provider-side fetch after
90
+ # {Parse::Embeddings.validate_image_url!} screens it (the SDK
91
+ # never downloads the video), and
92
+ # * a {Parse::Embeddings::MediaFile}, streamed into the request
93
+ # body by {Parse::Embeddings::StreamingBody} without ever being
94
+ # fully resident.
95
+ #
96
+ # Concrete overrides must accept `allow_insecure:` explicitly or
97
+ # absorb it via `**opts` — see {#embed_image} for why.
98
+ #
99
+ # @param sources [Array<String, Parse::Embeddings::MediaFile>]
100
+ # @param input_type [Symbol] `:search_query` or `:search_document`.
101
+ # @param allow_insecure [Boolean] forwarded to the URL validator.
102
+ # @param opts [Hash] provider-specific options.
103
+ # @return [Array<Array<Float>>] vectors aligned 1:1 with `sources`.
104
+ # @raise [NotImplementedError] unless the provider offers video.
105
+ def embed_video(sources, input_type: :search_document, allow_insecure: false, **opts)
106
+ raise NotImplementedError, "#{self.class} does not support video embedding"
107
+ end
108
+
109
+ # @return [Boolean] whether this provider accepts `modality`.
110
+ # Prefer this over rescuing {NotImplementedError} — it answers
111
+ # the question without issuing a call.
112
+ #
113
+ # @param modality [Symbol] one of `:text`, `:image`, `:video`.
114
+ def supports_modality?(modality)
115
+ modalities.include?(modality.to_sym)
116
+ end
117
+
79
118
  # Batched text embedding. Splits `strings` into chunks of size
80
119
  # {#embed_batch_size} (or returns a single-shot call when nil) and
81
120
  # concatenates results. Concrete providers should override only