Skip to main content

submilli_engine/stdlib/embedding/
mod.rs

1//! `submilli:embedding` — remote text embeddings from inside a Submilli program.
2//!
3//! Rust host functions registered directly under the package name. Each op runs
4//! `check_security_call` before anything leaves the process; the one gated
5//! capability is `embedding.embed`, cataloged in [`crate::stdlib::capabilities`]
6//! — keep it in sync when adding or removing a gate (see AGENTS.md).
7//!
8//! Dispatch is the embedder's: [`StoreData::embedding_provider`] holds the
9//! provider, and a runtime with none refuses every op rather than inventing a
10//! vector — the rule `submilli:llm` follows.
11//!
12//! **One capability, per-alias filtering.** `embed` and `models()` share the
13//! grant, with `input_count` in the filter context. `models()` filters each
14//! candidate through the same `model` filter that gates `embed`, so a listing
15//! never offers an alias the caller would be denied at call time.
16//!
17//! **No input text or vector crosses this boundary in metadata.** Not in the
18//! filter context, not in an error, not in a log. The context carries `model`
19//! and `input_count`.
20//!
21//! **Vectors stay compact, in the GC heap.** A result is a backing struct whose
22//! hidden field holds the vectors as a packed `i8` array: little-endian `f32`
23//! bytes, row-major, `count × dimensions × 4` bytes — the storage a `Uint8Array`
24//! uses. The array is allocated through the GC limiter, which collects inside
25//! host calls, so a discarded result is reclaimed before the heap grows: the
26//! vectors live in the GC heap, never in host memory the collector cannot see.
27//! The field has no getter and its type is unreachable from the guest, so only
28//! `vector(i)` and `bytes(i)` read it, copying one row out on demand; a
29//! plain `number[][]` would cost about 60 bytes per number.
30
31pub mod declaration;
32
33use crate::runtime::effects::{self, NativeError};
34use std::sync::Arc;
35
36use wasmtime::{FuncType, HeapType, Linker, RefType, StructType, Val, ValType};
37
38use crate::runtime::StoreData;
39use crate::runtime::call_log::{ModelUsage, Payload, Side, record_payload, record_usage};
40use crate::runtime::decision::CallTicket;
41use crate::runtime::embedding::{
42    EmbeddingBatch, EmbeddingBoundKind, EmbeddingError, EmbeddingLimits, EmbeddingMalformedReason,
43    EmbeddingModel, EmbeddingProvider, EmbeddingTokenBudget, Purpose, SubBatchSettlement,
44};
45use crate::runtime::fuel;
46use crate::runtime::host::{
47    abi_arg, abi_result, fatal_host_error, quota_exceeded_error, range_error, read_string_arg,
48    register_host_fn, register_host_value_async, type_error, write_boxed_number_struct,
49    write_submilli_array_struct_precharged, write_submilli_string_struct,
50    write_submilli_uint8array_struct, write_uint8_array_precharged,
51};
52use crate::runtime::intrinsic_types::build_intrinsic_types;
53use crate::stdlib::abi::{
54    self, HostView, backing_struct, f64_field, install_field_getters, nullable_object_field,
55    raw_bytes_field, string_field,
56};
57use crate::stdlib::shared::{
58    audit_quota_denial, check_security_call, filters_candidate, mark_filtered, optional_number,
59    preflight_models, sanitize_description,
60};
61
62pub use crate::runtime::EMBEDDING_MODULE_NAME as MODULE_NAME;
63pub use declaration::package_declaration;
64
65/// The single capability gating every op in this package.
66pub const CAPABILITY: &str = "embedding.embed";
67
68// `$EmbeddingsBacking` field indices (0 is the vtable).
69const E_VECTORS: usize = 1;
70const E_COUNT: usize = 2;
71const E_DIMENSIONS: usize = 3;
72const E_IDENTITY: usize = 4;
73const E_MODEL: usize = 5;
74const E_INPUT_TOKENS: usize = 6;
75
76// `$EmbeddingModelBacking` field indices (0 is the vtable).
77const M_NAME: usize = 1;
78const M_DESCRIPTION: usize = 2;
79const M_DIMENSIONS: usize = 3;
80const M_MAX_INPUT_TOKENS: usize = 4;
81const M_MAX_INPUT_BYTES: usize = 5;
82const M_IDENTITY: usize = 6;
83
84#[derive(serde::Serialize)]
85struct EmbedRequest {
86    model: String,
87    texts: Vec<String>,
88    purpose: Purpose,
89}
90
91pub fn install(linker: &mut Linker<StoreData>) -> wasmtime::Result<()> {
92    let engine = linker.engine().clone();
93    let intr = build_intrinsic_types(&engine)?;
94    let string = ValType::Ref(RefType::new(
95        false,
96        HeapType::ConcreteStruct(intr.string.clone()),
97    ));
98    let array = ValType::Ref(RefType::new(
99        false,
100        HeapType::ConcreteStruct(intr.array.clone()),
101    ));
102    let uint8 = ValType::Ref(RefType::new(
103        false,
104        HeapType::ConcreteStruct(intr.uint8_array.clone()),
105    ));
106    // `Embeddings` crosses the boundary as the universal `(ref null $Object)`
107    // lowering; a `Direct` receiver is the non-null `(ref $Object)`.
108    let nullable_object = ValType::Ref(RefType::new(
109        true,
110        HeapType::ConcreteStruct(intr.object.clone()),
111    ));
112    let receiver = ValType::Ref(RefType::new(
113        false,
114        HeapType::ConcreteStruct(intr.object.clone()),
115    ));
116
117    register_host_value_async(
118        linker,
119        MODULE_NAME,
120        crate::mangle::package_symbol(MODULE_NAME, "embed"),
121        FuncType::new(
122            &engine,
123            [string.clone(), array.clone(), string.clone()],
124            [nullable_object.clone()],
125        ),
126        |caller, params| {
127            Box::pin(async move {
128                let model =
129                    read_string_arg(&mut *caller, abi_arg(params, 0)?, "embedding.embed (model)")?;
130                let purpose = read_string_arg(
131                    &mut *caller,
132                    abi_arg(params, 2)?,
133                    "embedding.embed (purpose)",
134                )?;
135                embed(caller, &model, abi_arg(params, 1)?, &purpose).await
136            })
137        },
138    )?;
139
140    register_host_value_async(
141        linker,
142        MODULE_NAME,
143        crate::mangle::package_symbol(MODULE_NAME, "models"),
144        FuncType::new(&engine, [], [array.clone()]),
145        |caller, _params| Box::pin(async move { models(caller).await }),
146    )?;
147
148    for view in HOST_VIEWS {
149        install_field_getters(linker, &engine, &receiver, view)?;
150    }
151    install_methods(linker, &engine, receiver, array, uint8)
152}
153
154const EMBEDDINGS_VIEW: HostView = HostView {
155    module: MODULE_NAME,
156    interface: "Embeddings",
157    backing: embeddings_backing_struct,
158    properties: &[
159        ("count", E_COUNT),
160        ("dimensions", E_DIMENSIONS),
161        ("identity", E_IDENTITY),
162        ("model", E_MODEL),
163        ("inputTokens", E_INPUT_TOKENS),
164    ],
165};
166
167const EMBEDDING_MODEL_VIEW: HostView = HostView {
168    module: MODULE_NAME,
169    interface: "EmbeddingModel",
170    backing: model_backing_struct,
171    properties: &[
172        ("name", M_NAME),
173        ("description", M_DESCRIPTION),
174        ("dimensions", M_DIMENSIONS),
175        ("maxInputTokens", M_MAX_INPUT_TOKENS),
176        ("maxInputBytes", M_MAX_INPUT_BYTES),
177        ("identity", M_IDENTITY),
178    ],
179};
180
181/// The host-backed interfaces this package's values are.
182pub(crate) const HOST_VIEWS: &[&HostView] = &[&EMBEDDINGS_VIEW, &EMBEDDING_MODEL_VIEW];
183
184fn install_methods(
185    linker: &mut Linker<StoreData>,
186    engine: &wasmtime::Engine,
187    receiver: ValType,
188    array: ValType,
189    uint8: ValType,
190) -> wasmtime::Result<()> {
191    let embeddings_key = crate::mangle::package_symbol(MODULE_NAME, "Embeddings");
192
193    register_host_fn(
194        linker,
195        MODULE_NAME,
196        crate::mangle::extend(&embeddings_key, "vector"),
197        FuncType::new(engine, [receiver.clone(), ValType::F64], [array]),
198        |caller, params, results| {
199            let row = read_row(
200                caller,
201                abi_arg(params, 0)?,
202                abi_arg(params, 1)?,
203                "embedding.vector",
204            )?;
205            *abi_result(results, 0)? = build_number_array(caller, &row)?;
206            Ok(())
207        },
208    )?;
209
210    register_host_fn(
211        linker,
212        MODULE_NAME,
213        crate::mangle::extend(&embeddings_key, "bytes"),
214        FuncType::new(engine, [receiver, ValType::F64], [uint8]),
215        |caller, params, results| {
216            let row = read_row(
217                caller,
218                abi_arg(params, 0)?,
219                abi_arg(params, 1)?,
220                "embedding.bytes",
221            )?;
222            *abi_result(results, 0)? = Val::AnyRef(Some(
223                write_submilli_uint8array_struct(caller, &row)?.to_anyref(),
224            ));
225            Ok(())
226        },
227    )
228}
229
230/// One `embed`: gate, bound, resolve, reserve, dispatch, settle.
231///
232/// The ordering is the contract and is load-bearing at every step.
233///
234/// 1. **Gate before anything leaves.** A denial must cost nothing and reveal
235///    nothing, so the capability check runs ahead of the provider — which is
236///    what knows whether an alias exists.
237/// 2. **Per-call caps, then alias, then per-input limit.** Each refusal happens
238///    before the budget is touched, so a refused call charges nothing and the
239///    provider is never called.
240/// 3. **Reserve before dispatching.** The whole estimate is reserved up front.
241/// 4. **Settle exactly once** on success and on failure, from the settlements the
242///    provider recorded; a call that never sent releases its whole reservation.
243async fn embed(
244    caller: &mut wasmtime::Caller<'_, StoreData>,
245    model: &str,
246    texts: &Val,
247    purpose: &str,
248) -> wasmtime::Result<Val> {
249    let elements = crate::runtime::prelude::collection::read_array_vals(caller, texts)?;
250    let ticket = gate(caller, model, elements.len())?;
251
252    let budget = execution_budget(caller);
253    let limits = budget.limits();
254    let texts = read_texts(caller, model, &elements, &limits)?;
255    // The texts are first in hand once the per-call bounds pass, so the request is
256    // recorded here: a call refused for size has no copy to keep.
257    record_payload(&*caller, ticket, Side::Request, || {
258        let body = serde_json::to_vec(&texts).unwrap_or_default();
259        let bytes: usize = texts.iter().map(String::len).sum();
260        Payload::meta(serde_json::json!({
261            "op": "embed",
262            "model": model,
263            "purpose": purpose,
264            "count": texts.len(),
265        }))
266        .with_owned_body(body)
267        .with_size(bytes as u64)
268    });
269    let purpose = parse_purpose(model, purpose)?;
270
271    let request = EmbedRequest {
272        model: model.to_owned(),
273        texts,
274        purpose,
275    };
276    let mut response = effects::execute(
277        caller,
278        "embedding.embed",
279        request,
280        move |caller, _, request| Box::pin(call_provider(caller, ticket, request)),
281    )
282    .await?;
283    // Charge even a partially failed batch before exposing its result or error.
284    if let Some(settlement) = &mut response.settlement {
285        settlement.charge();
286    } else {
287        budget.restore_spend(response.used, response.held, response.requests);
288    }
289    let batch = response.result.map_err(NativeError::into_error)?;
290    build_embeddings(caller, batch)
291}
292
293#[derive(serde::Serialize, serde::Deserialize)]
294struct EmbeddingResponse {
295    result: Result<EmbeddingBatch, NativeError>,
296    used: u64,
297    held: u64,
298    requests: u64,
299    #[serde(skip)]
300    settlement: Option<EmbeddingSettlement>,
301}
302
303/// Retain live reservation cleanup if storing the outcome fails. Replay has no
304/// live reservation and restores only the recorded per-run counters.
305struct EmbeddingSettlement {
306    budget: Option<Arc<EmbeddingTokenBudget>>,
307    reserved: u64,
308    settlements: Vec<SubBatchSettlement>,
309}
310
311impl EmbeddingSettlement {
312    fn charge(&mut self) {
313        if let Some(budget) = self.budget.take() {
314            budget.settle(self.reserved, &self.settlements);
315        }
316    }
317}
318
319impl Drop for EmbeddingSettlement {
320    fn drop(&mut self) {
321        self.charge();
322    }
323}
324
325async fn call_provider(
326    caller: &mut wasmtime::Caller<'_, StoreData>,
327    ticket: Option<CallTicket>,
328    request: EmbedRequest,
329) -> wasmtime::Result<EmbeddingResponse> {
330    let model = request.model.as_str();
331    let texts = request.texts;
332    let purpose = request.purpose;
333    let budget = execution_budget(caller);
334    let provider = provider(caller, model)?;
335    check_inputs(caller, &*provider, model, &texts).await?;
336
337    let sent: usize = texts.iter().map(String::len).sum();
338    fuel::charge(&mut *caller, fuel::IO, sent as u64)?;
339
340    let estimate = provider.estimate_tokens(model, &texts);
341    budget
342        .reserve(model, estimate)
343        .map_err(|error| quota_throw(caller, ticket, model, error))?;
344    let requests_before = budget.requests();
345    let dispatched = provider.embed(model, &texts, purpose, &budget).await;
346    let requests = budget.requests().saturating_sub(requests_before);
347    let settlements = match &dispatched {
348        Ok(batch) => batch.settlements(),
349        Err(error) => error.settlements(),
350    };
351    let (used, held) = settlements.iter().fold((0u64, 0u64), |(used, held), part| {
352        (
353            used.saturating_add(part.reported)
354                .saturating_add(part.indeterminate),
355            held.saturating_add(part.indeterminate),
356        )
357    });
358    let settlement = EmbeddingSettlement {
359        budget: Some(budget),
360        reserved: estimate,
361        settlements: settlements.to_vec(),
362    };
363    let result = match dispatched {
364        Ok(batch) if batch.count() != texts.len() => Err(throw(EmbeddingError::Malformed {
365            alias: model.to_string(),
366            reason: EmbeddingMalformedReason::CountMismatch,
367            settlements: Vec::new(),
368        })),
369        Ok(batch) => {
370            let received = (batch.values().len() as u64).saturating_mul(4);
371            record_payload(&*caller, ticket, Side::Response, || {
372                Payload::meta(serde_json::json!({
373                    "count": batch.count(),
374                    "dimensions": batch.dimensions(),
375                    "identity": batch.identity(),
376                    "model": model,
377                    "inputTokens": batch.input_tokens(),
378                }))
379                .with_size(received)
380            });
381            record_usage(
382                &*caller,
383                ticket,
384                ModelUsage {
385                    input_tokens: batch.input_tokens(),
386                    output_tokens: None,
387                },
388            );
389            fuel::settle(&mut *caller, fuel::IO, received)?;
390            Ok(batch)
391        }
392        Err(error) => {
393            // Some sub-batches may have completed before the provider failed.
394            let reported = error
395                .settlements()
396                .iter()
397                .fold(0u64, |sum, part| sum.saturating_add(part.reported));
398            if reported > 0 {
399                record_usage(
400                    &*caller,
401                    ticket,
402                    ModelUsage {
403                        input_tokens: Some(reported),
404                        output_tokens: None,
405                    },
406                );
407            }
408            Err(quota_throw(caller, ticket, model, error))
409        }
410    };
411    let result = match result {
412        Err(error) if crate::runtime::host::ends_the_run(&error) => return Err(error),
413        result => result.map_err(|error| NativeError::capture(&error)),
414    };
415    Ok(EmbeddingResponse {
416        result,
417        used,
418        held,
419        requests,
420        settlement: Some(settlement),
421    })
422}
423
424/// The capability check, run before any bytes leave the process. The context is
425/// exactly `model` and `input_count`; input text is never in it.
426fn gate(
427    caller: &mut wasmtime::Caller<'_, StoreData>,
428    model: &str,
429    input_count: usize,
430) -> wasmtime::Result<Option<CallTicket>> {
431    check_security_call(
432        caller,
433        CAPABILITY,
434        serde_json::json!({ "model": model, "input_count": input_count }),
435    )
436}
437
438/// Bound the call by count and total bytes, then read the texts. The count is
439/// checked on the element list, before any string is copied out of the guest.
440fn read_texts(
441    caller: &mut wasmtime::Caller<'_, StoreData>,
442    model: &str,
443    elements: &[Val],
444    limits: &EmbeddingLimits,
445) -> wasmtime::Result<Vec<String>> {
446    let count = elements.len() as u64;
447    if count > limits.max_texts_per_call {
448        return Err(throw(EmbeddingError::BoundsExceeded {
449            alias: model.to_string(),
450            kind: EmbeddingBoundKind::TextCount,
451            actual: count,
452            limit: limits.max_texts_per_call,
453        }));
454    }
455    if elements.is_empty() {
456        return Err(range_error(format!(
457            "embedding.embed(\"{model}\"): at least one text is required — pass a non-empty array"
458        )));
459    }
460    let mut texts = Vec::with_capacity(elements.len());
461    let mut total = 0u64;
462    for element in elements {
463        let text = read_string_arg(caller, element, "embedding.embed (texts)")?;
464        total = total.saturating_add(text.len() as u64);
465        if total > limits.max_bytes_per_call {
466            return Err(throw(EmbeddingError::BoundsExceeded {
467                alias: model.to_string(),
468                kind: EmbeddingBoundKind::TotalBytes,
469                actual: total,
470                limit: limits.max_bytes_per_call,
471            }));
472        }
473        texts.push(text);
474    }
475    Ok(texts)
476}
477
478fn parse_purpose(model: &str, purpose: &str) -> wasmtime::Result<Purpose> {
479    match purpose {
480        "query" => Ok(Purpose::Query),
481        "document" => Ok(Purpose::Document),
482        _ => Err(range_error(format!(
483            "embedding.embed(\"{model}\"): purpose must be \"query\" or \"document\""
484        ))),
485    }
486}
487
488/// Resolve the alias and refuse any input over its byte limit, before the budget
489/// is touched. Numbering is from 0, like the vectors.
490async fn check_inputs(
491    caller: &mut wasmtime::Caller<'_, StoreData>,
492    provider: &dyn EmbeddingProvider,
493    model: &str,
494    texts: &[String],
495) -> wasmtime::Result<()> {
496    let Some(limit) = provider.max_input_bytes(model) else {
497        let available = available_aliases(caller, provider).await?;
498        return Err(throw(EmbeddingError::UnknownModel {
499            alias: model.to_string(),
500            available,
501        }));
502    };
503    // The provider repeats this check. Doing it here too keeps the refusal ahead
504    // of the budget reservation (see the ordering in [`embed`]) and does not
505    // depend on every provider implementing it.
506    let too_long = texts.iter().position(|text| text.len() as u64 > limit);
507    if let Some(index) = too_long {
508        return Err(throw(EmbeddingError::InputTooLong {
509            alias: model.to_string(),
510            index: Some(index),
511            limit: Some(limit),
512            settlements: Vec::new(),
513        }));
514    }
515    Ok(())
516}
517
518/// The aliases this caller may use, for an unknown-alias error. Filtered through
519/// the capability like `models()`, so the error cannot enumerate aliases the
520/// policy hides.
521async fn available_aliases(
522    caller: &mut wasmtime::Caller<'_, StoreData>,
523    provider: &dyn EmbeddingProvider,
524) -> wasmtime::Result<Vec<String>> {
525    let candidates = provider.models().await.map_err(throw)?;
526    let mut names = Vec::new();
527    for candidate in candidates {
528        if may_embed(caller, &candidate.name)? {
529            names.push(candidate.name);
530        }
531    }
532    Ok(names)
533}
534
535/// `models()`: check runtime invariants, then gate each candidate with the same
536/// `model` filter that gates `embed`.
537///
538/// Filtering acts **only** on a policy denial. An invariant denial means the
539/// check itself could not be made, and swallowing it would turn a runtime
540/// refusal into a silently short listing.
541async fn models(caller: &mut wasmtime::Caller<'_, StoreData>) -> wasmtime::Result<Val> {
542    preflight_models(caller, CAPABILITY, "input_count")?;
543    let candidates = effects::execute(caller, "embedding.models", (), |caller, _, ()| {
544        Box::pin(async move { provider(caller, "")?.models().await.map_err(throw) })
545    })
546    .await?;
547    let mut built = Vec::with_capacity(candidates.len());
548    for candidate in candidates {
549        if may_embed(caller, &candidate.name)? {
550            built.push(build_model(caller, candidate)?);
551        }
552    }
553    abi::new_array(caller, &built)
554}
555
556/// The per-candidate gate. A denial omits the alias rather than failing the
557/// call: a listing that threw on the first forbidden alias would itself disclose
558/// that the operator configured it.
559fn may_embed(caller: &mut wasmtime::Caller<'_, StoreData>, model: &str) -> wasmtime::Result<bool> {
560    let keeps = filters_candidate(gate(caller, model, 0).map(|_| ()))?;
561    if !keeps {
562        mark_filtered(&*caller);
563    }
564    Ok(keeps)
565}
566
567/// Clone the provider out of the store before any `await`. Runs *after* the
568/// capability check, deliberately: see [`embed`].
569fn provider(
570    caller: &wasmtime::Caller<'_, StoreData>,
571    model: &str,
572) -> wasmtime::Result<Arc<dyn EmbeddingProvider>> {
573    caller.data().embedding_provider.clone().ok_or_else(|| {
574        throw(EmbeddingError::NotConfigured {
575            alias: model.to_string(),
576        })
577    })
578}
579
580/// The execution's budget, or an unmetered one in the pure-interpreter path. The
581/// per-call caps in its limits still apply there.
582fn execution_budget(caller: &mut wasmtime::Caller<'_, StoreData>) -> Arc<EmbeddingTokenBudget> {
583    caller
584        .data_mut()
585        .embedding_budget
586        .get_or_insert_with(|| Arc::new(EmbeddingTokenBudget::unmetered()))
587        .clone()
588}
589
590/// [`throw`] plus an audit record when the failure is a budget refusal.
591fn quota_throw(
592    caller: &wasmtime::Caller<'_, StoreData>,
593    ticket: Option<CallTicket>,
594    model: &str,
595    error: EmbeddingError,
596) -> wasmtime::Error {
597    if !error.is_budget_exceeded() {
598        return throw(error);
599    }
600    if let Err(denial) = audit_quota_denial(
601        caller,
602        ticket,
603        CAPABILITY,
604        model,
605        "embedding-token budget exceeded",
606    ) {
607        return denial;
608    }
609    throw(error)
610}
611
612/// Every failure but an internal one reaches the guest as a catchable error.
613/// The `Display` impls exclude input text and vectors, so the message passes
614/// through whole. An [`EmbeddingError::Internal`] is a host invariant failure
615/// and ends the run, like every other internal host failure.
616///
617/// Budget refusals are quota errors, count/byte/length bounds are argument range
618/// errors, a malformed provider response is a type error, and the rest keep the
619/// base error type.
620fn throw(error: EmbeddingError) -> wasmtime::Error {
621    let message = error.to_string();
622    match error {
623        EmbeddingError::BudgetExceeded { .. } => quota_exceeded_error(message),
624        EmbeddingError::BoundsExceeded { .. } | EmbeddingError::InputTooLong { .. } => {
625            range_error(message)
626        }
627        EmbeddingError::Malformed { .. } => type_error(message),
628        EmbeddingError::Internal { .. } => fatal_host_error(message),
629        EmbeddingError::NotConfigured { .. }
630        | EmbeddingError::UnknownModel { .. }
631        | EmbeddingError::Unauthorized { .. }
632        | EmbeddingError::Provider { .. } => wasmtime::Error::msg(message),
633    }
634}
635
636fn embeddings_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
637    let intr = build_intrinsic_types(engine)?;
638    backing_struct(
639        engine,
640        &intr,
641        vec![
642            raw_bytes_field(&intr),       // vectors (hidden: no getter)
643            f64_field(),                  // count
644            f64_field(),                  // dimensions
645            string_field(&intr),          // identity
646            string_field(&intr),          // model
647            nullable_object_field(&intr), // inputTokens
648        ],
649    )
650}
651
652fn model_backing_struct(engine: &wasmtime::Engine) -> wasmtime::Result<StructType> {
653    let intr = build_intrinsic_types(engine)?;
654    backing_struct(
655        engine,
656        &intr,
657        vec![
658            string_field(&intr),          // name
659            nullable_object_field(&intr), // description
660            f64_field(),                  // dimensions
661            nullable_object_field(&intr), // maxInputTokens
662            f64_field(),                  // maxInputBytes
663            string_field(&intr),          // identity
664        ],
665    )
666}
667
668/// Seal `batch`: copy its vectors into a packed byte array in the GC heap and
669/// hand the guest a backing struct that holds it in a hidden field.
670///
671/// Charge `count × dimensions × 4` bytes once, before encoding. A recorded
672/// native batch can be replayed if this conversion runs out of fuel.
673/// At most two host copies of the vectors are live at once (the encoded bytes
674/// and the engine's internal copy); the batch's own floats are gone before the
675/// array is allocated.
676fn build_embeddings(
677    caller: &mut wasmtime::Caller<'_, StoreData>,
678    batch: EmbeddingBatch,
679) -> wasmtime::Result<Val> {
680    let byte_count = batch
681        .values()
682        .len()
683        .checked_mul(4)
684        .ok_or_else(|| fatal_host_error("embedding.embed: result size overflows"))?;
685    fuel::charge(&mut *caller, fuel::COPY, byte_count as u64)?;
686    let mut bytes = Vec::new();
687    bytes
688        .try_reserve_exact(byte_count)
689        .map_err(|error| fatal_host_error(format!("embedding.embed: {error}")))?;
690    for value in batch.values() {
691        bytes.extend_from_slice(&value.to_le_bytes());
692    }
693    let count = batch.count() as f64;
694    let dimensions = batch.dimensions() as f64;
695    let identity = batch.identity().to_string();
696    let model = batch.model().to_string();
697    let input_tokens = batch.input_tokens().map(|n| n as f64);
698    drop(batch);
699
700    // Allocated through the GC limiter, which collects before the heap grows.
701    let array_ty = build_intrinsic_types(caller.engine())?.raw_uint8_array;
702    let vectors = write_uint8_array_precharged(&mut *caller, array_ty, &bytes)?;
703    drop(bytes);
704
705    let identity = string_val(caller, &identity)?;
706    let model = string_val(caller, &model)?;
707    let input_tokens = optional_number(caller, input_tokens)?;
708
709    let ty = embeddings_backing_struct(caller.engine())?;
710    abi::new_backing(
711        caller,
712        ty,
713        &[
714            Val::AnyRef(Some(vectors.to_anyref())),
715            Val::F64(count.to_bits()),
716            Val::F64(dimensions.to_bits()),
717            identity,
718            model,
719            input_tokens,
720        ],
721    )
722}
723
724fn build_model(
725    caller: &mut wasmtime::Caller<'_, StoreData>,
726    model: EmbeddingModel,
727) -> wasmtime::Result<Val> {
728    let name = string_val(caller, &model.name)?;
729    let description = match model.description.as_deref().and_then(sanitize_description) {
730        Some(text) => string_val(caller, &text)?,
731        None => crate::runtime::prelude::undefined::value(caller)?,
732    };
733    let max_input_tokens = optional_number(caller, model.max_input_tokens.map(|n| n as f64))?;
734    let identity = string_val(caller, &model.identity)?;
735    let ty = model_backing_struct(caller.engine())?;
736    abi::new_backing(
737        caller,
738        ty,
739        &[
740            name,
741            description,
742            Val::F64((model.dimensions as f64).to_bits()),
743            max_input_tokens,
744            Val::F64((model.max_input_bytes as f64).to_bits()),
745            identity,
746        ],
747    )
748}
749
750fn string_val(caller: &mut wasmtime::Caller<'_, StoreData>, text: &str) -> wasmtime::Result<Val> {
751    Ok(Val::AnyRef(Some(
752        write_submilli_string_struct(caller, text)?.to_anyref(),
753    )))
754}
755
756/// Copy row `index` out of the sealed result. The index must be an integer below
757/// `count`; anything else is the program's mistake, a `RangeError`.
758fn read_row(
759    caller: &mut wasmtime::Caller<'_, StoreData>,
760    receiver: &Val,
761    index: &Val,
762    ctx: &str,
763) -> wasmtime::Result<Vec<u8>> {
764    let Val::F64(bits) = index else {
765        return Err(fatal_host_error(format!("{ctx}: index is not a number")));
766    };
767    let index = f64::from_bits(*bits);
768
769    let st = abi::backing_receiver(caller, receiver)?;
770    let Val::AnyRef(Some(vectors)) = st.field(&mut *caller, E_VECTORS)? else {
771        return Err(fatal_host_error(format!(
772            "{ctx}: result vectors are missing"
773        )));
774    };
775    let vectors = vectors
776        .as_array(&mut *caller)?
777        .ok_or_else(|| fatal_host_error(format!("{ctx}: result vectors are not an array")))?;
778    let (Val::F64(count), Val::F64(dimensions)) = (
779        st.field(&mut *caller, E_COUNT)?,
780        st.field(&mut *caller, E_DIMENSIONS)?,
781    ) else {
782        return Err(fatal_host_error(format!("{ctx}: result shape is missing")));
783    };
784    let (count, dimensions) = (f64::from_bits(count), f64::from_bits(dimensions));
785    let in_range = index.is_finite() && index.fract() == 0.0 && index >= 0.0 && index < count;
786    if !in_range {
787        return Err(range_error(format!(
788            "{ctx}: index {index} is out of range for {count} vectors — use an integer from 0 to \
789             count - 1"
790        )));
791    }
792    let row_bytes = (dimensions as usize)
793        .checked_mul(4)
794        .ok_or_else(|| fatal_host_error(format!("{ctx}: row size overflows")))?;
795    let offset = (index as usize)
796        .checked_mul(row_bytes)
797        .and_then(|offset| u32::try_from(offset).ok())
798        .ok_or_else(|| fatal_host_error(format!("{ctx}: row offset overflows")))?;
799    // The row copy-out is charged by size, before it is made.
800    fuel::charge(&mut *caller, fuel::COPY, row_bytes as u64)?;
801    let mut row = Vec::new();
802    row.try_reserve_exact(row_bytes)
803        .map_err(|error| fatal_host_error(format!("{ctx}: {error}")))?;
804    row.resize(row_bytes, 0);
805    vectors
806        .read_i8(&mut *caller, offset, &mut row)
807        .map_err(|error| fatal_host_error(format!("{ctx}: {error}")))?;
808    Ok(row)
809}
810
811/// A `number[]` of the row's components, widened from `f32`.
812fn build_number_array(
813    caller: &mut wasmtime::Caller<'_, StoreData>,
814    row: &[u8],
815) -> wasmtime::Result<Val> {
816    // Admit the whole array before boxing any element.
817    fuel::charge(&mut *caller, fuel::ELEM, (row.len() / 4) as u64)?;
818    let mut boxed = Vec::with_capacity(row.len() / 4);
819    for chunk in row.as_chunks::<4>().0 {
820        let value = f32::from_le_bytes(*chunk);
821        boxed.push(Val::AnyRef(Some(
822            write_boxed_number_struct(caller, f64::from(value))?.to_anyref(),
823        )));
824    }
825    Ok(Val::AnyRef(Some(
826        write_submilli_array_struct_precharged(caller, &boxed)?.to_anyref(),
827    )))
828}
829
830#[cfg(test)]
831mod tests {
832    use std::pin::Pin;
833    use std::sync::atomic::{AtomicUsize, Ordering};
834    use std::sync::{Arc, Mutex};
835
836    use crate::runtime::{
837        CheckOutcome, EmbeddingBatch, EmbeddingError, EmbeddingFailureReason, EmbeddingLimits,
838        EmbeddingModel, EmbeddingProvider, EmbeddingTokenBudget, Purpose, RuntimeConfig,
839        SecurityCheck, SharedTokenBudget, StoreData, SubBatchSettlement, Vfs, dispatch_main_async,
840        install_runtime_async,
841    };
842
843    type BoxFuture<'a, T> = Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
844
845    /// What the mock answers a send with.
846    #[derive(Clone, Copy)]
847    enum Outcome {
848        /// Succeeds and reports usage equal to the estimate.
849        Reported,
850        /// Fails after sending; the estimate stays held.
851        TransportFailure,
852        /// Sends, then reports an internal bookkeeping failure.
853        Internal,
854    }
855
856    /// Counts `embed` calls and answers deterministically. The `embed` count is
857    /// what proves "the provider was not called".
858    struct MockProvider {
859        dimensions: usize,
860        max_input_bytes: u64,
861        outcome: Outcome,
862        embed_calls: AtomicUsize,
863    }
864
865    impl MockProvider {
866        fn new(dimensions: usize, max_input_bytes: u64, outcome: Outcome) -> Arc<Self> {
867            Arc::new(Self {
868                dimensions,
869                max_input_bytes,
870                outcome,
871                embed_calls: AtomicUsize::new(0),
872            })
873        }
874
875        fn calls(&self) -> usize {
876            self.embed_calls.load(Ordering::Relaxed)
877        }
878    }
879
880    impl EmbeddingProvider for MockProvider {
881        fn embed<'a>(
882            &'a self,
883            alias: &'a str,
884            texts: &'a [String],
885            _purpose: Purpose,
886            budget: &'a EmbeddingTokenBudget,
887        ) -> BoxFuture<'a, Result<EmbeddingBatch, EmbeddingError>> {
888            self.embed_calls.fetch_add(1, Ordering::Relaxed);
889            Box::pin(async move {
890                let estimate = self.estimate_tokens(alias, texts);
891                budget.mark_sent(alias, estimate)?;
892                if matches!(self.outcome, Outcome::Internal) {
893                    return Err(EmbeddingError::Internal {
894                        alias: alias.to_string(),
895                        settlements: vec![SubBatchSettlement {
896                            estimate,
897                            reported: 0,
898                            indeterminate: estimate,
899                        }],
900                    });
901                }
902                if matches!(self.outcome, Outcome::TransportFailure) {
903                    return Err(EmbeddingError::Provider {
904                        alias: alias.to_string(),
905                        reason: EmbeddingFailureReason::Transport,
906                        settlements: vec![SubBatchSettlement {
907                            estimate,
908                            reported: 0,
909                            indeterminate: estimate,
910                        }],
911                    });
912                }
913                let values = vec![0.5f32; texts.len() * self.dimensions];
914                EmbeddingBatch::new(values, texts.len(), self.dimensions, "emb1:mock", alias)
915                    .map(|batch| {
916                        batch.with_settlements(vec![SubBatchSettlement {
917                            estimate,
918                            reported: estimate,
919                            indeterminate: 0,
920                        }])
921                    })
922                    .map_err(|_| EmbeddingError::Malformed {
923                        alias: alias.to_string(),
924                        reason: crate::runtime::EmbeddingMalformedReason::InvalidBody,
925                        settlements: Vec::new(),
926                    })
927            })
928        }
929
930        fn models<'a>(&'a self) -> BoxFuture<'a, Result<Vec<EmbeddingModel>, EmbeddingError>> {
931            let model = EmbeddingModel {
932                name: "mock".to_string(),
933                description: None,
934                dimensions: self.dimensions as u64,
935                max_input_tokens: None,
936                max_input_bytes: self.max_input_bytes,
937                identity: "emb1:mock".to_string(),
938            };
939            Box::pin(async move { Ok(vec![model]) })
940        }
941
942        fn max_input_bytes(&self, alias: &str) -> Option<u64> {
943            (alias == "mock").then_some(self.max_input_bytes)
944        }
945    }
946
947    struct RecordingPolicy(Arc<Mutex<Vec<serde_json::Value>>>);
948
949    impl SecurityCheck for RecordingPolicy {
950        fn check(
951            &self,
952            _caller: &str,
953            _capability: &str,
954            context: &serde_json::Value,
955        ) -> CheckOutcome {
956            if let Ok(mut contexts) = self.0.lock() {
957                contexts.push(context.clone());
958            }
959            CheckOutcome::Allow { rule: None }
960        }
961    }
962
963    /// Runs `source` against the provider and returns what the host observed:
964    /// the `main` result and the host-attached bytes still held at the end.
965    struct Run {
966        result: wasmtime::Result<String>,
967        host_attached_bytes: u64,
968        /// GC heap growth the limiter has observed for the whole run.
969        observed_bytes: u64,
970    }
971
972    async fn run(
973        source: &str,
974        provider: Option<Arc<MockProvider>>,
975        budget: Option<Arc<EmbeddingTokenBudget>>,
976        policy: Option<Arc<dyn SecurityCheck>>,
977    ) -> Run {
978        let compiled = crate::compile_script(source, "test.ts", crate::FileId(0), &[], &[])
979            .expect("compile clean");
980        let cfg = RuntimeConfig::default();
981        let engine = cfg.engine().expect("engine");
982        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
983        data.install_type_info(compiled.type_info.clone());
984        data.embedding_provider = provider.map(|p| p as Arc<dyn EmbeddingProvider>);
985        data.embedding_budget = budget;
986        if let Some(policy) = policy {
987            data.security_check = policy;
988        }
989        let mut store = cfg.store_async(&engine, data).expect("store");
990        crate::runtime::install_tenant_limits(&mut store);
991        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
992        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
993        install_runtime_async(&mut linker, &mut store)
994            .await
995            .expect("install");
996        let inst = linker
997            .instantiate_async(&mut store, &module)
998            .await
999            .expect("instantiate");
1000        let result = dispatch_main_async(&mut store, &inst)
1001            .await
1002            .map(Option::unwrap_or_default);
1003        let host_attached_bytes = store.data().tenant_limits.host_attached_bytes();
1004        let observed_bytes = store.data().tenant_limits.observed_bytes();
1005        Run {
1006            result,
1007            host_attached_bytes,
1008            observed_bytes,
1009        }
1010    }
1011
1012    fn budget(limits: EmbeddingLimits) -> Arc<EmbeddingTokenBudget> {
1013        Arc::new(EmbeddingTokenBudget::new(
1014            limits,
1015            SharedTokenBudget::new(u64::MAX),
1016        ))
1017    }
1018
1019    const ROUND_TRIP: &str = r#"import embedding from "submilli:embedding";
1020        function main(): void {
1021          const r = embedding.embed("mock", ["a", "b", "c"], "document");
1022          assert(r.count === 3, "three vectors");
1023        }"#;
1024
1025    /// `Embeddings` and `EmbeddingModel` stringify through their properties;
1026    /// the vectors, read through methods, are not among them.
1027    #[tokio::test]
1028    async fn embeddings_and_models_stringify_their_properties() {
1029        let provider = MockProvider::new(4, 10, Outcome::Reported);
1030        let run = run(
1031            r#"import embedding from "submilli:embedding";
1032               function main(): string {
1033                 const r = embedding.embed("mock", ["a"], "document");
1034                 return JSON.stringify(r)! + "|" + JSON.stringify(embedding.models())!;
1035               }"#,
1036            Some(provider),
1037            None,
1038            None,
1039        )
1040        .await;
1041        let out = run.result.expect("program completes");
1042        let (embeddings, models) = out.split_once('|').expect("two values");
1043        let embeddings: serde_json::Value = serde_json::from_str(embeddings).expect("JSON");
1044        assert_eq!(embeddings["count"], 1, "{embeddings}");
1045        assert_eq!(embeddings["dimensions"], 4, "{embeddings}");
1046        assert_eq!(embeddings["model"], "mock", "{embeddings}");
1047        assert!(embeddings.get("vectors").is_none(), "{embeddings}");
1048        assert_eq!(
1049            models,
1050            r#"[{"name":"mock","dimensions":4,"maxInputBytes":10,"identity":"emb1:mock"}]"#
1051        );
1052    }
1053
1054    /// AE1: an over-length input is refused before the provider is called, and
1055    /// the budget is unchanged.
1056    #[tokio::test]
1057    async fn ae1_over_length_input_is_refused_before_the_provider_and_the_budget() {
1058        let provider = MockProvider::new(4, 10, Outcome::Reported);
1059        let budget = budget(EmbeddingLimits::default());
1060        let run = run(
1061            r#"import embedding from "submilli:embedding";
1062               function main(): void {
1063                 let message = "";
1064                 try {
1065                   embedding.embed("mock", ["ok", "ok", "this one is too long"], "document");
1066                 } catch (e: RangeError) {
1067                   message = e.message;
1068                 }
1069                 assert(message.indexOf("text 2") >= 0, "names index 2: " + message);
1070               }"#,
1071            Some(Arc::clone(&provider)),
1072            Some(Arc::clone(&budget)),
1073            None,
1074        )
1075        .await;
1076        run.result.expect("program completes");
1077        assert_eq!(provider.calls(), 0, "the provider is never called");
1078        assert_eq!(budget.used(), 0, "nothing is charged");
1079        assert_eq!(budget.held(), 0, "nothing is held");
1080        assert_eq!(budget.requests(), 0, "nothing counts as sent");
1081    }
1082
1083    /// AE6: an exhausted per-run budget is a `QuotaExceededError` and nothing is
1084    /// sent.
1085    #[tokio::test]
1086    async fn ae6_exhausted_budget_refuses_without_calling_the_provider() {
1087        let provider = MockProvider::new(4, 1_000, Outcome::Reported);
1088        let budget = budget(EmbeddingLimits {
1089            per_execution_tokens: 1,
1090            ..EmbeddingLimits::default()
1091        });
1092        let run = run(
1093            r#"import embedding from "submilli:embedding";
1094               function main(): void {
1095                 let quota = false;
1096                 try {
1097                   embedding.embed("mock", ["more than one token of text"], "document");
1098                 } catch (e: QuotaExceededError) {
1099                   quota = true;
1100                 }
1101                 assert(quota, "the call is a QuotaExceededError");
1102               }"#,
1103            Some(Arc::clone(&provider)),
1104            Some(Arc::clone(&budget)),
1105            None,
1106        )
1107        .await;
1108        run.result.expect("program completes");
1109        assert_eq!(provider.calls(), 0, "the provider is never called");
1110        assert_eq!(budget.used(), 0, "the refusal charged nothing");
1111        assert_eq!(budget.requests(), 0);
1112    }
1113
1114    /// Settlement is applied once on success: the reported usage is committed and
1115    /// nothing stays held.
1116    #[tokio::test]
1117    async fn success_settles_reported_usage() {
1118        let provider = MockProvider::new(4, 1_000, Outcome::Reported);
1119        let budget = budget(EmbeddingLimits::default());
1120        let run = run(ROUND_TRIP, Some(provider), Some(Arc::clone(&budget)), None).await;
1121        run.result.expect("program completes");
1122        assert_eq!(
1123            budget.used(),
1124            3,
1125            "three one-byte texts estimate one token each"
1126        );
1127        assert_eq!(budget.held(), 0, "reported usage leaves nothing held");
1128        assert_eq!(budget.requests(), 1);
1129    }
1130
1131    /// A send that fails after leaving keeps its estimate held (unknown spend is
1132    /// indeterminate, not free), and the program sees a catchable error.
1133    #[tokio::test]
1134    async fn failure_after_send_settles_the_estimate_as_held() {
1135        let provider = MockProvider::new(4, 1_000, Outcome::TransportFailure);
1136        let budget = budget(EmbeddingLimits::default());
1137        let run = run(
1138            r#"import embedding from "submilli:embedding";
1139               function main(): void {
1140                 let message = "";
1141                 try {
1142                   embedding.embed("mock", ["secret text"], "document");
1143                 } catch (e: Error) {
1144                   message = e.message;
1145                 }
1146                 assert(message.indexOf("transport") >= 0, message);
1147                 assert(message.indexOf("secret text") < 0, "no input text in the error");
1148               }"#,
1149            Some(provider),
1150            Some(Arc::clone(&budget)),
1151            None,
1152        )
1153        .await;
1154        run.result.expect("program completes");
1155        assert_eq!(budget.held(), 4, "ceil(11 / 3) tokens stay held");
1156        assert_eq!(budget.used(), 4, "and count against the run");
1157    }
1158
1159    /// AE8: a 128 x 3,072 result is held at 4 bytes per number in the GC heap —
1160    /// not the ~60 bytes a plain array costs — and reading one vector or its
1161    /// bytes works.
1162    #[tokio::test]
1163    async fn ae8_results_are_held_at_four_bytes_per_number() {
1164        const COUNT: u64 = 128;
1165        const DIMENSIONS: u64 = 3072;
1166        let source = |texts: u32| {
1167            format!(
1168                r#"import embedding from "submilli:embedding";
1169               function main(): void {{
1170                 const texts: string[] = [];
1171                 for (let i = 0; i < {texts}; i = i + 1) {{
1172                   texts.push("t");
1173                 }}
1174                 const r = embedding.embed("mock", texts, "document");
1175                 assert(r.count === {texts} && r.dimensions === 3072, "shape");
1176                 assert(r.vector(0).length === 3072, "reading vector 0 returns 3,072 numbers");
1177                 assert(r.bytes(0).length === 12288, "exporting it returns 12,288 bytes");
1178                 if ({texts} > 5) {{
1179                   assert(r.vector(5).length === 3072, "reading vector 5 returns 3,072 numbers");
1180                   assert(r.bytes(5).length === 12288, "exporting it returns 12,288 bytes");
1181                 }}
1182                 let range = false;
1183                 try {{
1184                   r.vector({texts});
1185                 }} catch (e: RangeError) {{
1186                   range = true;
1187                 }}
1188                 assert(range, "reading vector {texts} is a RangeError");
1189               }}"#
1190            )
1191        };
1192        let provider = |_| MockProvider::new(DIMENSIONS as usize, 1_000, Outcome::Reported);
1193        let one = run(&source(1), Some(provider(())), None, None).await;
1194        one.result.expect("one-vector program completes");
1195        let many = run(&source(COUNT as u32), Some(provider(())), None, None).await;
1196        many.result.expect("program completes");
1197
1198        // The two runs differ only in the result: 127 more vectors. The GC heap
1199        // growth the limiter observed grows by that many vectors at 4 bytes per
1200        // number (within 10%: the heap grows in steps, and 127 one-character texts ride along).
1201        let expected = (COUNT - 1) * DIMENSIONS * 4;
1202        let grown = many.observed_bytes.saturating_sub(one.observed_bytes);
1203        assert!(
1204            grown >= expected - expected / 10 && grown <= expected + expected / 10,
1205            "the result costs about count x dimensions x 4 bytes of GC heap: grew {grown}, \
1206             expected about {expected}"
1207        );
1208        assert_eq!(
1209            many.host_attached_bytes, 0,
1210            "the vectors are not host-attached bytes"
1211        );
1212    }
1213
1214    /// Discarded results are reclaimed by collection: 100 results of 128 x 3,072
1215    /// numbers (1.5 MiB each, 150 MiB in all) fit the default 50 MB store when
1216    /// each loop pass drops the last one. The texts are built once so the loop
1217    /// allocates almost nothing else and only the results press on the heap.
1218    #[tokio::test]
1219    async fn discarded_results_are_collected_under_the_default_cap() {
1220        let provider = MockProvider::new(3072, 1_000, Outcome::Reported);
1221        let run = run(
1222            r#"import embedding from "submilli:embedding";
1223               function main(): void {
1224                 const texts: string[] = [];
1225                 for (let i = 0; i < 128; i = i + 1) {
1226                   texts.push("t");
1227                 }
1228                 let total = 0;
1229                 for (let round = 0; round < 100; round = round + 1) {
1230                   const r = embedding.embed("mock", texts, "document");
1231                   total = total + r.count;
1232                 }
1233                 assert(total === 12800, "every round returned its vectors");
1234               }"#,
1235            Some(provider),
1236            Some(budget(EmbeddingLimits::default())),
1237            None,
1238        )
1239        .await;
1240        run.result
1241            .expect("program completes without memory exhaustion");
1242    }
1243
1244    /// The hidden vectors array is sealed: no declared member reaches it, and a
1245    /// cast to `Uint8Array` is refused at run time rather than aliasing it.
1246    #[tokio::test]
1247    async fn the_hidden_vectors_are_not_reachable_from_the_guest() {
1248        for member in ["vectors", "handle", "data", "buffer"] {
1249            let source = format!(
1250                r#"import embedding from "submilli:embedding";
1251                   function main(): void {{
1252                     const r = embedding.embed("mock", ["a"], "document");
1253                     const hidden = r.{member};
1254                   }}"#
1255            );
1256            assert!(
1257                crate::compile_script(&source, "test.ts", crate::FileId(0), &[], &[]).is_err(),
1258                "`Embeddings.{member}` must not type-check"
1259            );
1260        }
1261        let provider = MockProvider::new(4, 1_000, Outcome::Reported);
1262        let cast = run(
1263            r#"import embedding from "submilli:embedding";
1264               function main(): void {
1265                 const r = embedding.embed("mock", ["a"], "document");
1266                 const u = r as unknown as Uint8Array;
1267                 u[0] = 255;
1268               }"#,
1269            Some(provider),
1270            None,
1271            None,
1272        )
1273        .await;
1274        assert!(
1275            cast.result.is_err(),
1276            "casting the result to a Uint8Array must fail, not expose the vectors"
1277        );
1278
1279        // Hashing the result as bytes must never read the vectors. The cast to
1280        // `string | Uint8Array` refuses at run time (the value is neither); the
1281        // host-level refusal behind it is pinned by
1282        // `a_host_read_of_the_result_as_a_uint8array_is_refused`.
1283        for call in [
1284            "crypto.sha256(x)",
1285            "crypto.hmacSha256(crypto.randomBytes(4), x)",
1286        ] {
1287            let provider = MockProvider::new(4, 1_000, Outcome::Reported);
1288            let source = format!(
1289                r#"import embedding from "submilli:embedding";
1290                   import crypto from "submilli:crypto";
1291                   function main(): void {{
1292                     const r = embedding.embed("mock", ["a"], "document");
1293                     const x = r as unknown as (string | Uint8Array);
1294                     crypto.sha256(crypto.randomBytes(1));
1295                     {call};
1296                   }}"#
1297            );
1298            let outcome = run(&source, Some(provider), None, None).await;
1299            assert!(
1300                outcome.result.is_err(),
1301                "`{call}` on the result must fail, not hash the vectors"
1302            );
1303        }
1304    }
1305
1306    /// The host-side seal behind every `Uint8Array` argument: a backing struct
1307    /// with a hidden byte field is not a `$Uint8Array`, so reading it as one is
1308    /// refused even if a cast were bypassed.
1309    #[tokio::test]
1310    async fn a_host_read_of_the_result_as_a_uint8array_is_refused() {
1311        let compiled = crate::compile_script(
1312            "function main(): void {}",
1313            "test.ts",
1314            crate::FileId(0),
1315            &[],
1316            &[],
1317        )
1318        .expect("compile clean");
1319        let cfg = RuntimeConfig::default();
1320        let engine = cfg.engine().expect("engine");
1321        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1322        data.install_type_info(compiled.type_info.clone());
1323        let mut store = cfg.store_async(&engine, data).expect("store");
1324        crate::runtime::install_tenant_limits(&mut store);
1325        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1326        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1327        install_runtime_async(&mut linker, &mut store)
1328            .await
1329            .expect("install");
1330        linker
1331            .instantiate_async(&mut store, &module)
1332            .await
1333            .expect("instantiate");
1334
1335        let probe = wasmtime::Func::new(
1336            &mut store,
1337            wasmtime::FuncType::new(&engine, [], []),
1338            |mut caller, _, _| {
1339                let batch = EmbeddingBatch::new(vec![1.0, 2.0], 1, 2, "id", "mock")
1340                    .map_err(wasmtime::Error::new)?;
1341                let sealed = super::build_embeddings(&mut caller, batch)?;
1342                let refused =
1343                    crate::runtime::host::read_uint8_array_arg(&mut caller, &sealed, "probe");
1344                match refused {
1345                    Err(error) if error.to_string().contains("expects a Uint8Array") => Ok(()),
1346                    other => Err(wasmtime::Error::msg(format!(
1347                        "the sealed result was not refused: {other:?}"
1348                    ))),
1349                }
1350            },
1351        );
1352        probe
1353            .call_async(&mut store, &[], &mut [])
1354            .await
1355            .expect("the host refuses to read the sealed result as bytes");
1356    }
1357
1358    /// The vectors count against the run's memory cap like any GC object: a store
1359    /// too small for the result ends the run.
1360    #[tokio::test]
1361    async fn the_result_charge_counts_against_the_run_memory_cap() {
1362        let provider = MockProvider::new(3072, 1_000, Outcome::Reported);
1363        let compiled = crate::compile_script(
1364            r#"import embedding from "submilli:embedding";
1365               function main(): void {
1366                 const texts: string[] = [];
1367                 for (let i = 0; i < 128; i = i + 1) {
1368                   texts.push("t");
1369                 }
1370                 embedding.embed("mock", texts, "document");
1371               }"#,
1372            "test.ts",
1373            crate::FileId(0),
1374            &[],
1375            &[],
1376        )
1377        .expect("compile clean");
1378        let cfg = RuntimeConfig::default();
1379        let engine = cfg.engine().expect("engine");
1380        let mut data = StoreData::with_vfs("test-run".into(), Vfs::tempdir().expect("tempdir"));
1381        data.install_type_info(compiled.type_info.clone());
1382        data.embedding_provider = Some(provider);
1383        let budget = budget(EmbeddingLimits::default());
1384        data.embedding_budget = Some(Arc::clone(&budget));
1385        let mut store = cfg.store_async(&engine, data).expect("store");
1386        crate::runtime::install_tenant_limits(&mut store);
1387        let module = wasmtime::Module::new(&engine, &compiled.wasm).expect("module");
1388        let mut linker = wasmtime::Linker::<StoreData>::new(&engine);
1389        install_runtime_async(&mut linker, &mut store)
1390            .await
1391            .expect("install");
1392        let inst = linker
1393            .instantiate_async(&mut store, &module)
1394            .await
1395            .expect("instantiate");
1396        // Leave the program less than the 128 x 3,072 x 4 = 1.5 MiB of
1397        // vectors it is about to ask for.
1398        let cap = store.data().tenant_limits.observed_bytes() + 100_000;
1399        store.data_mut().tenant_limits.max_total_bytes = cap;
1400        let result = dispatch_main_async(&mut store, &inst).await;
1401        let error = result.expect_err("the cap refuses the result");
1402        assert!(
1403            crate::runtime::is_memory_exhausted(&error),
1404            "a memory refusal ends the run: {error:?}"
1405        );
1406        // The result is built after settlement: the provider's spend is
1407        // committed even though the guest never sees it.
1408        assert_eq!(budget.used(), 128, "the reported usage is settled");
1409        assert_eq!(budget.held(), 0, "nothing stays held");
1410    }
1411
1412    /// An internal provider failure ends the run rather than becoming a catchable
1413    /// error, and the sent sub-batch stays held.
1414    #[tokio::test]
1415    async fn an_internal_provider_failure_ends_the_run_and_settles_the_budget() {
1416        let provider = MockProvider::new(4, 1_000, Outcome::Internal);
1417        let budget = budget(EmbeddingLimits::default());
1418        let run = run(
1419            r#"import embedding from "submilli:embedding";
1420               function main(): void {
1421                 try {
1422                   embedding.embed("mock", ["secret text"], "document");
1423                 } catch (e: Error) {
1424                   assert(false, "an internal failure must not be catchable");
1425                 }
1426               }"#,
1427            Some(provider),
1428            Some(Arc::clone(&budget)),
1429            None,
1430        )
1431        .await;
1432        let error = run.result.expect_err("the run ends");
1433        assert!(
1434            crate::runtime::host::ends_the_run(&error),
1435            "an internal failure is fatal: {error:?}"
1436        );
1437        assert_eq!(budget.held(), 4, "ceil(11 / 3) tokens stay held");
1438        assert_eq!(budget.used(), 4);
1439    }
1440
1441    /// The filter context is exactly `model` and `input_count`; input text never
1442    /// reaches policy, and `models()` asks with `input_count` 0.
1443    #[tokio::test]
1444    async fn the_filter_context_carries_the_numbers_and_never_the_text() {
1445        const SECRET: &str = "the patient's diagnosis is confidential";
1446        let provider = MockProvider::new(4, 1_000, Outcome::Reported);
1447        let contexts = Arc::new(Mutex::new(Vec::new()));
1448        let policy: Arc<dyn SecurityCheck> = Arc::new(RecordingPolicy(Arc::clone(&contexts)));
1449        let run = run(
1450            &format!(
1451                r#"import embedding from "submilli:embedding";
1452                   function main(): void {{
1453                     embedding.embed("mock", ["{SECRET}", "b"], "query");
1454                     embedding.models();
1455                   }}"#
1456            ),
1457            Some(provider),
1458            None,
1459            Some(policy),
1460        )
1461        .await;
1462        run.result.expect("program completes");
1463
1464        let contexts = contexts.lock().expect("contexts");
1465        assert_eq!(contexts.len(), 2, "one check per embed, one per candidate");
1466        for context in contexts.iter() {
1467            let mut keys: Vec<&str> = context
1468                .as_object()
1469                .expect("an object context")
1470                .keys()
1471                .map(String::as_str)
1472                .collect();
1473            keys.sort_unstable();
1474            assert_eq!(keys, ["input_count", "model"]);
1475            assert!(!context.to_string().contains("patient"), "{context}");
1476        }
1477        assert_eq!(contexts[0]["input_count"], 2, "embed: the text count");
1478        assert_eq!(contexts[1]["input_count"], 0, "models: zero");
1479    }
1480
1481    /// An unknown alias is refused after the gate and before the budget, naming
1482    /// the aliases that exist.
1483    #[tokio::test]
1484    async fn an_unknown_alias_names_the_available_ones_and_charges_nothing() {
1485        let provider = MockProvider::new(4, 1_000, Outcome::Reported);
1486        let budget = budget(EmbeddingLimits::default());
1487        let run = run(
1488            r#"import embedding from "submilli:embedding";
1489               function main(): void {
1490                 let message = "";
1491                 try {
1492                   embedding.embed("ghost", ["a"], "document");
1493                 } catch (e: Error) {
1494                   message = e.message;
1495                 }
1496                 assert(message.indexOf("mock") >= 0, message);
1497               }"#,
1498            Some(Arc::clone(&provider)),
1499            Some(Arc::clone(&budget)),
1500            None,
1501        )
1502        .await;
1503        run.result.expect("program completes");
1504        assert_eq!(provider.calls(), 0);
1505        assert_eq!(budget.used(), 0);
1506    }
1507}