Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Jammi AI

Jammi is an embeddable AI engine that brings model inference into your data pipeline. Register data sources, run SQL queries, generate embeddings, search with vector similarity, fine-tune models on your domain, and evaluate results — all without leaving your application.

What Jammi does

  • Query local data with SQL — register Parquet, CSV, JSON, and JSONL files, run full SQL via DataFusion
  • Federate external databases — query PostgreSQL and MySQL alongside local files
  • Generate embeddings — load any BERT-family model from HuggingFace Hub (or local safetensors / ONNX), persist results to Parquet with sidecar ANN indexes
  • Vector search — ANN similarity search over embedding tables with automatic fallback to brute-force; search returns a table directly, same shape embedded or remote
  • Compound query — join sources, filter, and run a model over a relation (the annotate SQL table function), in-process or over the Flight SQL lane in one round-trip; a fluent QueryBuilder composes the same operations in Rust
  • Evidence provenanceretrieved_by and annotated_by tracking on the fluent query builder’s results
  • Fine-tuning — LoRA adapters with contrastive loss to improve embeddings for your domain
  • Evaluation — retrieval metrics (recall@k, precision@k, MRR, nDCG), classification (accuracy, F1), and A/B model comparison
  • Per-row error handling — null or invalid text produces error status per row, not a batch failure
  • Model caching — LRU eviction, ref-counted guards, single-flight loading
  • GPU scheduling — memory-budget admission control with RAII permits
  • Crash recovery — on restart, recovers result tables stuck in “building” state
  • Inference observability — attach observers to hook into every output batch

Three ways to use Jammi

InterfaceBest forInstall
Rust libraryEmbedding Jammi into Rust applicationscargo add jammi-ai
Python packageData science, notebooks, scriptspip install jammi-ai
CLIShell workflows, quick queries, opscargo install jammi-cli

All three interfaces share the same engine, configuration, and storage format. Embeddings generated from Python are queryable from the CLI, and vice versa.

For multi-language access or BI tool integration, the jammi-server binary starts an Arrow Flight SQL server — any Arrow client can connect and query via standard SQL. The jammi CLI is a strict gRPC client of that server.

Crates

CratePurpose
jammi-dbQuery engine, configuration, catalog, source management, Parquet storage, ANN indexes
jammi-aiModel loading, inference execution, embedding pipeline, vector search, evidence model, fine-tuning, evaluation
jammi-serverArrow Flight SQL server and HTTP health endpoint
jammi-cliCommand-line interface
jammi-pythonPython bindings via PyO3

jammi-db has no dependency on jammi-ai. You can use it standalone for SQL queries over local data without pulling in the AI layer.

Design Philosophy

Jammi is an engine of generic primitives, not a substrate-platform. This page states what that means, where the line falls, and how the engine is meant to be consumed and deployed. It is principle-level on purpose — it pins no versions, index types, or other details that move, so it stays true as the implementation evolves.

The one rule everything else follows from

Jammi names no consumer. Not in code, config, docs, tests, fixtures, or scripts. References point one way only: a consumer may depend on Jammi; Jammi depends on no consumer. A consumer’s name anywhere in the engine repo is a bug.

A real consumer need is a fine forcing function for the roadmap — but the thing that lands in the engine is the generic primitive the need pointed at, with the name filed off. Two unrelated consumers independently reaching for the same primitive is the strongest evidence it is right; being able to justify a primitive only by naming one consumer is the strongest evidence it is wrong.

The discipline test

Before any capability enters the engine:

Would a user who has never heard of any particular consumer reach for this on its own?

Justify it against unrelated, hypothetical consumers — a feature store, an ad-attribution chain, a clinical-trial data fabric, a personal-knowledge search tool. If it survives only with a real name attached, it is domain pull masquerading as a primitive, and it belongs in that consumer’s own repo, built on a published Jammi version.

Where the line falls

Stays in Jammi (engine primitives)Lives in the consumer’s repo (composition)
DataFusion SQL surfaceDomain tables (the consumer’s own entities)
Catalog primitives — typed status enums, append-only migrationsDomain status enums and their lifecycle meaning
Storage primitives — Parquet result-tables, sidecar ANN, mutable companion tablesDomain audit / recapture / gating semantics
Source registration and federationDomain interop adapters (foreign format → a registered source)
AI primitives — embeddings, inference, search, fine-tune, evalDomain agents / reactors and what they decide
Data-driven provenance channelsDomain audit columns (what “signed-by”, “owned-by” mean)
Trigger stream — Arrow batches, SQL-predicate filtersDomain event taxonomies and their semantics
Tenant session scopeDomain ownership models — registries, ownership lanes, publish/install/bind
Server surfaces (Flight SQL, gRPC)Domain operation contracts (typed verbs, signed transitions)

Left column: every cell is something a user with no knowledge of any specific consumer would still want. Right column: every cell is a composition a consumer builds in its own repo out of the left column. The substrate-platform shape — append-only typed substrate + pluggable reactors + read-back loops at decision moments — recurs across consumers, but it recurs in their domain layers, not here. If Jammi codified that shape, the next consumer, in a domain we have not met, would have to bend to fit or fork.

Leak-guards

Domain pull leaks in through “almost-generic” primitives that quietly assume a consumer’s semantics. Three guards, all the same shape — the primitive transports / persists / merges; the semantics live above it:

  • The trigger stream knows nothing about the payload. A topic is a name; a message is an Arrow batch; a subscription is a SQL predicate over batch columns. No typed-event taxonomy, no required headers (actor, timestamp, signature), no ordering guarantee beyond per-topic FIFO. Adding a “typed event” with mandatory headers is where it would break.
  • Mutable tables expose CRUD through DML, nothing more. No built-in transition log, no automatic versioning, no lifecycle-column convention. A consumer that wants append-only-with-history builds it from two table registrations. Adding a LifecycleTable wrapper is where it would break.
  • Provenance channels merge declared columns at query time. The engine never writes to a provenance column. What a channel column means — signed-by, retrieved-by, scored-by, attributed-to — is the caller’s vocabulary. Adding channel-writing helpers (record_actor(), sign_with()) is where it would break.

There is one consumption verb. The embedding producers differ only in the encoder; the moment vectors land in a result-table plus its sidecar index, consumption is identical: search(source, query, k) returns top-k ids and scores — ANN over the sidecar index, with an exact scan as the fallback when no index is present.

search is the curated path because it is where the engine adds value: the ANN index, the exact-scan fallback, and the evidence/provenance attached to results. The raw vector itself is a column in a SQL-addressable result-table, so it is reachable through the generic SQL surface (SELECT <vector_column> FROM <result_table> …) — the same as selecting any other column. That generic read path is legitimate, and making it ergonomic and documented is ordinary engine work; it is not a violation of anything here. It is the right home for the rare genuine need — exporting embeddings, a custom-metric re-rank, debugging.

What the engine does not add is a dedicated vector-retrieval verb on the embedding/search API. A consumer should not pull vectors back to reconstruct a ranking the engine already computes, or to compare across models (re-encode for that). A bespoke get_vector verb with no caller is speculative domain-convenience: it competes with search as “how you consume embeddings” and owes a per-id contract across every storage backend. So the line is precise — the capability is the SQL surface, not a verb; the verb is what fails the discipline test.

How it deploys: one binary, pluggable backends

The same engine binary serves every topology. Differences are configuration (which backend driver) and process count (1 vs N) — never a topology-specific code path or a server-only feature the library cannot do.

Four canonical shapes — points on a configuration surface, not tiers to graduate through:

  • A — Single-process embedded. Library mode, SQLite catalog, local Parquet, in-memory trigger stream, in-process model cache. Notebooks, CLI, single-machine, laptop dev.
  • B — Single-tenant server. One process, Flight SQL + gRPC trigger stream exposed, optional Postgres catalog for HA, local disk + object-store backup. Physical-isolation requirements, on-prem.
  • C — Multi-tenant server. One process (or stateless fleet), Postgres catalog, shared object store, shared trigger broker; tenant scope filters every catalog query. SaaS hosting many tenants — e.g. an edge-function deployment with the engine as a container sidecar reached over gRPC, or a managed multi-tenant service.
  • D — Disaggregated. Catalog process ↔ stateless query workers ↔ GPU-resident inference workers ↔ trigger broker, each scaling independently; the same binary in every role. Very high scale, specialized GPU pools, split compliance posture.

The five pluggable backends — the entire deployment-knob surface:

BackendEmbedded defaultProduction driver(s)
CatalogSQLitePostgres
Result-table storageLocal filesystemS3 / GCS / R2 / Azure Blob (via the object_store crate)
Mutable companion tablesSQLitePostgres
Trigger brokerIn-memoryKafka / NATS / Redis Streams / a cloud queue
Model artifact sourceLocal cache + HF HubMirror, private registry, object-store-backed store

Everything else — load balancing, ingress, TLS, secrets, IAM, observability stack, orchestration, autoscaling — is the consumer’s runtime, not the engine’s.

Three properties this preserves, and that a consumer evaluating Jammi should be able to test:

  • The library is never less capable than the server. Anything the server does, the library does in-process. No feature gated to “clustered mode.”
  • The default deployment fits on a laptop. SQLite + local filesystem + in-memory trigger stream + HF Hub cache is a complete deployment, no cloud service required.
  • Production is a configuration change, not a fork. Moving from Shape A to Shape C swaps backend drivers; the engine code, schema, catalog discipline, and trigger-stream contract are unchanged.

Extending third-party libraries at their seams

Where a capability is better bought than built, the engine extends the library at the seam it already exposes for exactly that — its own extension points, never a fork and never a vendored copy — and keeps the retry/actuator/ownership discipline (attempts, terminal writes, who is allowed to write a row) on jammi’s own side of that seam. Two examples, one pattern:

  • Fused CUDA kernels extend candle, never replace it: a jammi kernel is a candle CustomOp with a CPU reference arm and an optional fused CUDA arm, so the same tensor graph runs identically (bit-for-bit on the CPU arm, parity-tested on CUDA) whether or not the fused kernel compiles in. Candle’s own eager composition is always the fallback, never a second implementation to keep in sync.
  • The Ballista compute plane extends Ballista, never forks it: a codec (PhysicalExtensionCodec), an execution engine wrapper, and a custom task distribution policy are all extension points Ballista ships for exactly this — a scheduler/executor role hosts them inside the SAME jammi-server binary, on jammi’s own shutdown (never Ballista’s own process-level start_server/start_executor_process, which install their own signal handlers). Every operator the codec does not know still crosses the wire through Ballista’s own codec unchanged, so extending the plan surface never touches Ballista’s own node set. Retries stay off on the Ballista side entirely (task_max_failures = stage_max_failures = 0) — a task fault surfaces to jammi’s own attempts/reclaim accounting, never a second, competing retry loop.

Positioning

With these primitives the engine is, precisely: an embeddable AI engine — federated SQL, durable result-tables with ANN indexes, mutable companion tables, embeddings / inference / search / fine-tune / eval, evidence provenance on every row, and a trigger-stream event surface. That is a general-utility data-and-AI engine. It is deliberately not a substrate-platform engine — because the substrate-platform shape belongs to the consumers that are that shape, and the next consumer may not be.

Installation

Rust

Add Jammi to your Cargo.toml:

[dependencies]
jammi-db = "0.25"
jammi-ai = "0.25"
tokio = { version = "1", features = ["full"] }

CLI

The jammi CLI registers sources, runs SQL, and starts the server. There are three ways to get it.

cargo install (CPU)

Builds from source on your machine. Needs the build dependencies below.

cargo install jammi-cli

The installed binary is jammi.

Prebuilt binary (CPU)

Download a stripped, ready-to-run binary from the GitHub releases. No build toolchain required. Assets are published per release:

  • jammi-<version>-x86_64-unknown-linux-gnu.tar.gz — Linux x86-64 (built on a glibc 2.28 floor, so it runs on any newer Linux)
  • jammi-<version>-aarch64-apple-darwin.tar.gz — macOS on Apple silicon
tar -xzf jammi-0.25.0-x86_64-unknown-linux-gnu.tar.gz
./jammi --help

GPU (CUDA 12)

GPU inference ships as a container image, not a bare binary. The jammi-ai-server-cu12 image runs jammi-server as its entrypoint and also carries the jammi admin CLI; it is turnkey:

docker run --gpus all \
  -p 127.0.0.1:8080:8080 -p 127.0.0.1:8081:8081 \
  ghcr.io/f-inverse/jammi-ai-server-cu12:latest

Both ports bind to 127.0.0.1: the server performs no authentication of its own (see The identity seam), so a loopback bind keeps the unauthenticated admin surface off the host’s public network until a terminator or reverse proxy is put in front of it.

Both :latest tags are re-pointed by every v* release tag (never by a prerelease); the CPU :latest can additionally be re-pointed to the current main by a manual build-and-push-main dispatch. Pin an exact :vX.Y.Z tag for a reproducible pull.

That runs jammi-server with zero config. See Deploy as a Server for GPU configuration and persistence.

Alternatively, install the CUDA server as a pip wheel — it ships the same jammi-server binary and pulls the CUDA runtime from nvidia-*-cu12 wheels, so no system CUDA install is required (only an NVIDIA driver on the host):

pip install jammi-server-cu12
jammi-server

The jammi-ai embed wheel is CPU-only; GPU inference runs in the server, reached from Python via jammi.connect("grpc://…").

Build dependencies (Linux)

If building from source, you need a C compiler and protoc:

# Debian/Ubuntu
apt-get install protobuf-compiler gcc g++ pkg-config

# RHEL/AlmaLinux
yum install protobuf-compiler gcc gcc-c++ pkg-config

All other native libraries (lzma, zstd, zlib, sqlite) are vendored and compiled from source automatically. These tools are pre-installed in the devcontainer and CI images.

Building jammi-db with the postgres or mysql source feature additionally requires OpenSSL’s development headers: these features link a native TLS stack (native-tls -> OpenSSL) rather than rustls, and jammi-db does not vendor OpenSSL.

# Debian/Ubuntu
apt-get install libssl-dev

# RHEL/AlmaLinux
yum install openssl-devel

# macOS (Homebrew)
brew install openssl pkg-config

See Connect to PostgreSQL / MySQL.

Python

pip install jammi-ai

Requires Python 3.8+. Pre-built wheels are available for Linux, macOS, and Windows.

From source

git clone https://github.com/f-inverse/jammi-ai.git
cd jammi-ai
cargo build --release

The CLI binary is at target/release/jammi (a strict gRPC client) and the server binary at target/release/jammi-server.

For the Python package from source:

pip install maturin
maturin develop --release

Runtime requirements

Jammi has no mandatory runtime dependencies beyond the binary itself.

Optional:

  • CUDA toolkit + cuDNN for GPU inference (CPU works out of the box)
  • HuggingFace Hub access for downloading models (first run downloads ~90MB for MiniLM, cached thereafter)
  • PostgreSQL / MySQL client libraries if using federated database sources

Set HF_TOKEN for gated models, or HF_HOME to control the cache location — both are read as fallbacks when the config’s own [models] section (see Configuration) leaves hub_token/hub_cache_dir unset; a config value always wins over the environment variable.

Quickstart: Rust

This walkthrough registers a local data file, runs a SQL query, generates embeddings, and performs a semantic search — all in one program.

Full example

extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::config::JammiConfig;
use jammi_db::source::{FileFormat, SourceConnection, SourceType};
use jammi_db::store::CachePolicy;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = JammiConfig::load(None)?;
    let session = Arc::new(InferenceSession::new(config).await?);

    // 1. Register a data source
    session.add_source("patents", SourceType::File, SourceConnection {
        url: Some("file:///path/to/patents.parquet".into()),
        format: Some(FileFormat::Parquet),
        ..Default::default()
    }).await?;

    // 2. Query with SQL
    let rows = session.sql(
        "SELECT id, title, year FROM patents.public.patents WHERE year > 2020 LIMIT 5"
    ).await?;
    for batch in &rows {
        println!("{batch:?}");
    }

    // 3. Generate embeddings
    let (record, _outcome) = session.generate_text_embeddings(
        "patents",
        "sentence-transformers/all-MiniLM-L6-v2",
        &["title".to_string()],
        "id",
        CachePolicy::Bypass,
        None,
    ).await?;
    println!("Embedded {} rows", record.row_count);

    // 4. Semantic search
    let query = session.encode_text_query(
        "sentence-transformers/all-MiniLM-L6-v2",
        "quantum computing applications",
    ).await?;

    let results = session.search("patents", query, 5, None, None).await?
        .sort("similarity", true)?
        .run().await?;

    for batch in &results {
        println!("{batch:?}");
    }

    Ok(())
}

The first run downloads the model from HuggingFace Hub (~90MB). Subsequent runs load from cache.

What’s happening

  1. JammiConfig::load(None) loads config from jammi.toml, $JAMMI_CONFIG, or defaults
  2. InferenceSession wraps the query engine with model loading, caching, and GPU scheduling
  3. add_source registers a file in the catalog — it survives session restarts
  4. sql runs any SQL query via DataFusion, returns Vec<RecordBatch>
  5. generate_text_embeddings runs the model over every row, persists vectors to Parquet with a sidecar ANN index
  6. encode_text_query encodes a text string into the same vector space
  7. search finds the nearest neighbors, hydrates all source columns, and returns results with similarity scores

Next steps

Quickstart: Python

The full quickstart — install, connect, register, search — lives in the repo’s cookbook tree under cookbook/quickstart/ with a runnable quickstart.py that’s exercised end-to-end on every PR by tests/cookbook_smoke.py. This page mirrors the cookbook’s overview so the mdBook site renders a self-contained quickstart; the cookbook is the source of truth.

Goal: a fresh user goes from pip install jammi-ai to a successful vector query in five minutes. The end-to-end script lives next to this file in quickstart.py — copy-paste it, run it, then read the four step-by-step pages for the explanation.

Steps

  1. Installpip install jammi-ai
  2. Connect — open a session against a local artifact dir
  3. Register a source — attach a Parquet file
  4. Generate embeddings + search — build a vector index and run a similarity query

Run it

python cookbook/quickstart/quickstart.py

Expected output: a header row and three top-3 matches with cosine similarity scores. The script exits 0 in under 30 seconds on CPU.

Production substitution

The script uses the local cookbook/fixtures/tiny_bert/ model (32-dim, 88 KB, single-layer) so the example needs no network access. In a real workload you would swap in a Hub model — for example sentence-transformers/all-MiniLM-L6-v2 (384-dim, English) — by changing the MODEL constant. Everything else stays the same.

Quickstart: CLI

The jammi CLI is a strict gRPC client: it talks to a running jammi-server over the wire and never touches the catalog or storage in-process. Start a server (see Deploy as a Server), then point the CLI at it with --target.

Register a source

# Register a remote source (the URL is resolved server-side)
jammi --target grpc://127.0.0.1:8081 \
  sources add patents --url /path/to/patents.parquet --format parquet

# List registered sources
jammi --target grpc://127.0.0.1:8081 sources list

The CLI is a control-plane tool — SQL itself runs over the server’s Flight SQL surface through the Rust or Python client, not through a CLI verb; see Query Your Data with SQL.

The default --target is grpc://127.0.0.1:8081, so a CLI talking to a local server can omit the flag.

Check the server

# Report version, compiled features, storage backends, and mounted services.
# A successful response also confirms reachability.
jammi status

Available commands

CommandDescription
jammi statusReport the server’s capabilities and confirm reachability
jammi sources listList registered data sources
jammi sources add <NAME> --url <URL> --format <FMT>Register a source
jammi models listList registered models
jammi channels …Manage evidence channels
jammi mutable …Manage mutable companion tables
jammi trigger …Manage trigger-stream topics
jammi jobs listList jobs (lifecycle status; read-only)
jammi jobs status <JOB_ID>Read one job’s lifecycle status
jammi jobs cancel <JOB_ID>Request cancellation of a job
jammi jobs pruneDelete terminal job rows past [jobs] retention_days
jammi workers listList engine processes running the claim loop

Global options

jammi --target <ENDPOINT> <command>   # Server endpoint (default grpc://127.0.0.1:8081)
jammi --tenant <UUID> <command>       # Bind a tenant scope for the session

--target accepts grpc://host:port, http://host:port, or a bare host:port — all plaintext h2. TLS termination is the consumer’s runtime, not the CLI’s: put a TLS-terminating proxy in front and point --target at it in plaintext. --tenant binds a tenant scope before any verb runs, so every read and write is scoped to that tenant.

Next steps

Runnable Recipes

Every recipe under cookbook/recipes/ ships as a runnable example.py next to a markdown README and is wired into CI via tests/cookbook_smoke.py — a broken recipe blocks the merge. These recipes are the OSS source of truth; this page mirrors each README below.

For the long-form, measured companion — The Cookbook, a Theory↔Computation book that shows one recipe = one equation in the graph-signal-processing monograph = one line of the GNN canon, executed against committed goldens — see The Cookbook. The two are complementary: these How-To Guides are the short, dual-language (Rust + Python), compile-tested “how do I call this verb” reference; The Cookbook is the long-form, Python, executed-and-measured narrative.

The recipes shipped at MVP:

RecipeDemonstrates
mutable_tablesCreate/insert/select/drop on a mutable companion table
trigger_streamsPublish + subscribe on a topic via the in-process broker
eval_embeddingsrecall@k, MRR, nDCG against a golden set
image_searchImage-to-image search + Recall@K / MRR eval; vision-tower LoRA fine-tune with a served-change assertion; refusal on an unmatched selector
eval_inferenceAccuracy + macro F1 against gold labels
eval_inference_nerEntity-level precision / recall / F1 against gold spans
fine_tuneLoRA fine-tune end-to-end
flight_sqlQuery a remote jammi-server over Arrow Flight SQL
audio_searchAudio-to-audio search + Recall@K / MRR eval; audio-tower LoRA fine-tune with a served-change assertion; refusal on an unmatched selector
search_auditPer-query provenance audit of a search
session_lifecycleEphemeral session storage with scoped cleanup

Mutable tables

End-to-end create / insert / select / drop on a Jammi mutable table — the OSS primitive for state that needs to live alongside read-only result tables.

When to use this pattern. You need a writable table that sits in the same SQL catalog as your registered sources and embedding tables — for caching enriched rows, holding cursor state, recording user feedback, or any “small table I want to UPDATE / DELETE / INSERT from SQL” workload — without standing up an external Postgres.

What example.py does

  1. Connects to a temporary artifact dir
  2. Creates a notes mutable table with an int64 primary key + utf8 body column
  3. Inserts three rows through DataFusion DML (INSERT INTO ...)
  4. Verifies count and ordering via SELECT
  5. Drops the table, then asserts a SELECT after the drop raises
  6. Demonstrates the idempotent drop_mutable_table(..., if_exists=True)

API surface exercised

  • Database.create_mutable_table(name, *, schema, primary_key, ...)
  • Database.sql("INSERT INTO mutable.public.<name> ...")
  • Database.sql("SELECT ... FROM mutable.public.<name>")
  • Database.drop_mutable_table(name, *, if_exists=False)

The DataFusion namespace for mutable tables is always mutable.public.<name> — distinct from registered sources, which live under <source>.public.<source>.

Run it

python cookbook/recipes/mutable_tables/example.py

Exits 0 on success, prints mutable_tables: OK on the last line.


Trigger streams

End-to-end publish + subscribe on a Jammi topic, plus the registration and listing surface. Uses the embedded in-process broker — no NATS or external broker needed.

When to use this pattern. You need a low-friction event bus inside your application — for fan-out to downstream consumers, fan-in from batch jobs, or replay-from-offset semantics — without bringing up Kafka or NATS in dev/test. The same surface scales out to NATS JetStream by flipping a config flag at deploy time.

What example.py does

  1. Connects to a temporary artifact dir
  2. Registers a topic events.demo with a typed schema and broker metadata
  3. Confirms list_topics() returns the new topic
  4. Publishes a 3-row batch through publish_topic — captures the broker-assigned offset
  5. Subscribes from from_offset=0 and round-trips the same rows back
  6. Drops the topic, confirms it’s gone from list_topics()
  7. Demonstrates idempotent drop_topic(..., if_exists=True) and strict-mode failure when dropping a missing topic

API surface exercised

  • Database.register_topic(name, *, schema, broker_metadata=None)
  • Database.list_topics()
  • Database.publish_topic(name, *, batch) — returns the assigned offset
  • Database.subscribe_collect(name, *, from_offset, max_batches)
  • Database.drop_topic(name, *, if_exists=False)

The subscribe_collect path drives the replay-from-backing-table flow when from_offset=0; the live-tail flow is exercised in the broker integration suite.

Run it

python cookbook/recipes/trigger_streams/example.py

Exits 0 on success, prints trigger_streams: OK on the last line.


Evaluate retrieval quality

Measure recall@k, precision@k, MRR, and nDCG of an embedding index against a golden relevance set.

When to use this pattern. You have a corpus and a small set of (query, expected document) judgments, and you need a number that tells you “is my new encoder better than the one I shipped last month?” The same loop powers nightly regression dashboards and A/B model comparison.

What example.py does

  1. Connects to a temporary artifact dir
  2. Registers the tiny corpus as a Parquet source
  3. Builds 32-dim embeddings over the content column with the local tiny_bert fixture
  4. Reads cookbook/fixtures/tiny_golden.json, expands it into the (query_id, query_text, relevant_id) CSV shape eval_embeddings consumes, and registers it as a golden source
  5. Calls db.eval_embeddings(source="corpus", golden_source="golden.public.golden", k=5)
  6. Asserts each aggregate metric is in [0.0, 1.0] and the per-query records carry their golden-set query_id

API surface exercised

  • Database.generate_embeddings(*, source, model, columns, key, modality="text")
  • Database.eval_embeddings(*, source, golden_source, model=None, k=10)

The returned dict carries aggregate (mean across queries — recall_at_k, precision_at_k, mrr, ndcg) and per_query (one entry per query with query_id and a metrics sub-dict of the same four names, un-averaged).

Golden source shape

eval_embeddings requires a registered source with these columns:

columntypeexample
query_idutf8q1
query_textutf8quantum computing applications
relevant_idutf81 (matches corpus.id as a string)

Image queries are supported via a query_image BLOB column instead of query_text; cross-modal eval is out of scope for this recipe.

Run it

python cookbook/recipes/eval_embeddings/example.py

Exits 0 on success, prints the metrics dict + eval_embeddings: OK.


Run image-to-image semantic search over a corpus with an OpenCLIP-format vision model, measure retrieval quality, and adapt the vision tower itself on caller-supplied image triplets.

When to use this pattern. You have a corpus of images (figures, drawings, photos) and want to find the ones most similar to a query image — and a number that tells you how good the retrieval is. This is the image counterpart of the text eval_embeddings recipe.

Flow

  1. Load a small image corpus (inline image bytes in a Parquet source)
  2. Generate L2-normalized vision embeddings over the image column
  3. Search the index with an encoded image query (cosine ANN)
  4. Eval retrieval quality (Recall@K / MRR) against a held-out golden set
  5. Fine-tune LoRA adapters inside the vision tower on image triplets (adapted ≠ base), then watch a wrong selector get refused

Model

A domain-specialized CLIP checkpoint is a drop-in for the reference model when your corpus is technical drawings or diagrams rather than photographs — a generic CLIP has seen few of them, and a checkpoint tuned on that kind of imagery separates them far better. patentclip/PatentCLIP_Vit_B on the Hugging Face Hub is one such checkpoint:

JAMMI_IMAGE_MODEL=patentclip/PatentCLIP_Vit_B \
    python cookbook/recipes/image_search/example.py

patentclip/PatentCLIP_Vit_B is pulled from the Hugging Face Hub on first use and produces 512-dim L2-normalized embeddings. Any OpenCLIP-format model works the same way — OpenAI CLIP, LAION CLIP-ViT-B-32-*, EVA-CLIP, etc. — the encoder is auto-detected from the model’s open_clip_config.json.

By default (no env var) the recipe runs against the hermetic cookbook/fixtures/tiny_open_clip fixture so it runs offline in CI — about two seconds on a laptop, nearly all of it the tower-LoRA and refusal legs (the search-and-eval flow alone is a fraction of a second). That fixture has random weights, so its retrieval numbers are meaningless — it exercises the full pipeline, not model quality. Point JAMMI_IMAGE_MODEL at any real checkpoint for real numbers.

What example.py does

  1. Connects to a temporary artifact dir
  2. Reads the 20 committed 224×224 PNGs under cookbook/fixtures/tiny_image_corpus/ into a Parquet corpus source (image_id, image bytes)
  3. db.generate_embeddings(source="corpus", model=MODEL, columns=["image"], key="image_id", modality="image")
  4. db.encode_query(model=MODEL, query=png_bytes, modality="image")db.search("corpus", query=vec, k=5) (returns a pyarrow.Table)
  5. Builds the image-query golden source from tiny_image_golden.json and calls db.eval_embeddings(source="corpus", golden_source="golden.public.golden", k=5)
  6. Prints the aggregate Recall@K / precision@K / MRR / nDCG and the per-query records. It reports the metrics; it does not assert a quality bar.
  7. Builds synthetic (anchor, positive, negative) image triplets from the corpus (positive = same shape family, negative = a different family) and calls db.fine_tune(source="triplets", base_model=MODEL, columns=["anchor","positive","negative"], method="lora", task="image_embedding", target_modules=["in_proj","c_fc"], ...). A non-empty target_modules puts LoRA inside the vision towerin_proj is the transformer block’s fused-QKV projection, c_fc the MLP’s first linear — so the tower’s own representation moves. (The audio recipe runs both modes side by side: an empty list instead trains a projection head on a frozen tower — cheaper, less capacity.) It then re-encodes the same query image through the adapted model and asserts the embedding vector changed (max elementwise |Δ| > 1e-4 versus the base encoding).
  8. Submits one more job with target_modules=["q_proj"] — a real selector on plenty of decoder checkpoints and on nothing in an OpenCLIP tower — and asserts job.wait() raises jammi.errors.TrainingError whose message echoes q_proj and names this tower’s real sites (in_proj, …). It prints the message.

What each leg proves, and the honesty rule

  • The tower leg proves the adapter is trained and applied when the model is served: an adapter that trained but was silently dropped at serve time leaves the two query vectors bit-identical, and that is what the |Δ| check catches. It asserts change, not improvement — the default fixture has random weights, so the direction of the change carries no information. The vector check is also the deterministic one: asserting a top-k metric moved is flaky, because on this tiny eval set the rankings rarely flip even when the vectors do. What the tower leg canNOT check from here is the saved adapter’s kind — that it is an encoder-adapters bundle carrying the vision tower’s id. That is engine-internal and is pinned by the engine’s own integration tests; the client surface (describe_model) reports only the model’s id, backend, task and status, so the recipe asserts the task and leans on the |Δ| check for the rest.
  • The refusal leg proves a selector that matches no site fails the job rather than publishing an adapter that changes nothing — and that the message is actionable, carrying this architecture’s own site vocabulary.
  • The independently-known improvement number — tuned retrieval quality beating the base by a measured margin — is not this recipe’s to claim. It belongs to a real-checkpoint chapter (built from a committed GPU-produced cache; planned under issue #421), which does not exist yet. A recipe running a random-weight fixture on a laptop can honestly prove mechanism; it cannot prove quality.

The pairing semantics (what a “positive” means) are the caller’s training data, not the trainer’s: the trainer only minimizes the contrastive triplet loss over whatever images you pair.

Stepwise scripts

example.py runs every phase in one process (this is the version wired into tests/cookbook_smoke.py). The numbered scripts decompose the search-and-eval flow and share a persistent workdir, so run them in order:

python cookbook/recipes/image_search/01-load-corpus.py
python cookbook/recipes/image_search/02-generate-embeddings.py
python cookbook/recipes/image_search/03-search.py
python cookbook/recipes/image_search/04-eval.py

API surface exercised

  • Database.generate_embeddings(*, source, model, columns, key, modality="image")
  • Database.encode_query(*, model, query, modality="image")list[float]
  • Database.search(source, *, query, k, filter=None, select=None)pyarrow.Table
  • Database.eval_embeddings(*, source, golden_source, model=None, k=10)
  • Database.fine_tune(*, source, base_model, columns, method, task="image_embedding", target_modules=[...], ...)TrainingJob
  • Database.describe_model(model_id)dict | None

Image triplet schema (fine-tune input)

columntypenotes
anchorbinaryencoded image
positivebinaryan image the caller deems related
negativebinaryan image the caller deems unrelated

Same column shape as text and audio triplets — task="image_embedding" is what tells the loader to read the three columns as encoded images rather than text.

Vision-tower LoRA sites

target_modules names sites on this architecture. The OpenCLIP towers offer in_proj (fused QKV), out_proj, c_fc and c_proj; all-linear selects every one. A selector matches a site name exactly or as a suffix of it. A list matching nothing fails the job with a message that echoes what you submitted and lists the tower’s real names.

Input schema

columntypenotes
image_idutf8per-row key
imagebinaryraw PNG/JPEG/TIFF bytes (decoded by the encoder)

Preprocessing (pad-to-square, no center crop, normalization, L2-normalized output) is handled inside the encoder per the model’s preprocess_cfg.

Golden source shape (image mode)

eval_embeddings switches to image-query mode when the golden source carries a query_image (binary) column instead of query_text:

columntypeexample
query_idutf8q_circle
query_imagebinaryraw PNG bytes of the query image
relevant_idutf8img_circle_0 (matches image_id)

Fixtures

  • cookbook/fixtures/tiny_image_corpus/ — 20 synthetic 224×224 PNGs in 5 shape families (circle / triangle / square / hexagon / grating), 4 per family, plus a held-out query image per family under queries/. Rendered programmatically by cookbook/fixtures/generate.pyno real-world imagery (licensing).
  • cookbook/fixtures/tiny_image_golden.json — per-query → expected corpus IDs (same shape family).
  • cookbook/fixtures/tiny_open_clip/ — tiny offline OpenCLIP fixture used as the default CI model.

Run it

python cookbook/recipes/image_search/example.py

Exits 0 on success, prints the top-K and the metrics dict + image_search: OK.


Evaluate inference (classification)

Run a classifier over a registered source and score its predictions against gold labels.

When to use this pattern. You have a labelled holdout set and you want a single number — accuracy, macro F1, per-class F1 — to compare two classifiers, or to track drift over time on the same classifier.

What example.py does

  1. Connects to a temporary artifact dir
  2. Registers the tiny corpus as corpus (parquet)
  3. Registers tiny_labels.csv as golden (csv) — (id, label) rows
  4. Runs db.eval_inference with the local tiny_modernbert_classifier fixture against the content column
  5. Prints the returned aggregate accuracy, macro f1, per-class metrics, and the count of per-record predictions
  6. Asserts every reported rate is in [0.0, 1.0]

API surface exercised

  • Database.eval_inference(*, model, source, columns, task, golden_source, label_column)

The returned dict carries aggregate (tagged by "task" — currently "classification") with accuracy, f1, and per_class, plus per_record (one entry per aligned {record_id, predicted, gold}).

The task argument is the string form of the inference task — "classification" here. For NER, see ../eval_inference_ner/.

Golden source shape

eval_inference requires a registered source with these columns:

columntypeexample
idutf8"1"
<label_column>utf8physics

label_column is the kwarg you pass at call time — label in this recipe. Every id in the golden source must resolve to a row in the input source; rows without a gold label are silently dropped from the metric.

Run it

python cookbook/recipes/eval_inference/example.py

Exits 0 on success, prints the metrics dict + eval_inference: OK.


Evaluate inference (NER)

Run a token-classification model over a registered source and score its predicted entity spans against gold spans.

When to use this pattern. You have a labelled NER holdout set (one gold span per row) and you want strict entity-level precision, recall, and F1 — both overall and per entity type — to compare two NER models or to track regressions on the same one.

What example.py does

  1. Connects to a temporary artifact dir
  2. Registers tiny_ner_corpus.parquet as corpus (parquet)
  3. Registers tiny_ner_gold.csv as golden (csv) — one row per gold entity span: (id, label, start, end)
  4. Runs db.eval_inference with the local tiny_modernbert_ner fixture against the text column, task="ner"
  5. Prints the returned aggregate precision, recall, f1, the per-type breakdown, and the count of per-record predictions
  6. Asserts every reported rate is in [0.0, 1.0]

API surface exercised

  • Database.eval_inference(*, model, source, columns, task, golden_source, label_column)

The returned dict carries aggregate (tagged by "task""ner" for this recipe) with precision, recall, f1, and per_type (one breakdown per entity type the model emitted or the gold set carried), plus per_record (one entry per aligned {record_id, predicted, gold} where predicted and gold are entity-span lists, each tagged "task": "ner").

The task argument is the string form of the inference task — "ner" here. For classification, see ../eval_inference/.

Golden source shape

eval_inference with task="ner" requires a registered source with these columns — one row per entity span (multiple spans on the same id accumulate into one per-row gold set):

columntypeexample
idutf8"1"
<label_column>utf8PER
starti640
endi6413

label_column is the kwarg you pass at call time — label in this recipe. start is inclusive, end is exclusive, both byte offsets into the source row’s text column. The label set must match the shipped model’s id2label minus the B-/I- prefixes — tiny_modernbert_ner knows PER and ORG only.

Rows in the source without a matching gold id are silently dropped from the metric (same alignment rule the classification recipe uses).

Run it

python cookbook/recipes/eval_inference_ner/example.py

Exits 0 on success, prints the metrics dict + eval_inference (ner): OK.


Fine-tune an encoder

Run a LoRA fine-tune on top of an existing text encoder, poll the job to completion, and use the resulting checkpoint to encode a query.

When to use this pattern. Your domain (legal contracts, medical abstracts, patent claims, internal product docs) doesn’t match the distribution the base encoder was trained on, and you have a few hundred to a few thousand labelled or contrastive pairs. LoRA gets you ~80% of the lift of a full fine-tune at a fraction of the cost; the resulting adapter is small enough to ship as an attachment to the base model rather than a re-distributed full checkpoint.

What example.py does

  1. Connects to a temporary artifact dir
  2. Registers tiny_pairs.csv (30 contrastive pairs) as training
  3. Calls db.fine_tune(...) with the local tiny_bert base, a small LoRA rank, and one epoch — kept fast for CI
  4. Waits for terminal status via job.wait()
  5. Asserts the resulting model_id starts with jammi:fine-tuned:
  6. Encodes a query through the fine-tuned model to confirm it loads

API surface exercised

  • Database.fine_tune(*, source, base_model, columns, method, task=..., ...)
  • Job.wait()
  • Job.job_id, Job.output_model_id
  • Database.encode_query(*, model, query, modality="text")

The full keyword list on fine_tune covers LoRA rank/alpha/dropout, learning rate, epochs, batch size, max sequence length, validation fraction, early-stopping patience/metric, warmup, gradient accumulation, backbone dtype, weight decay, and gradient clipping — the recipe uses the defaults for everything except rank and epochs.

Performance note

This recipe is excluded from the per-PR smoke matrix because even at one epoch it runs ~30 seconds on CPU. The nightly cron with JAMMI_COOKBOOK_SLOW=1 includes it. Override the gate locally:

JAMMI_COOKBOOK_SLOW=1 python tests/cookbook_smoke.py

Run it

python cookbook/recipes/fine_tune/example.py

Exits 0 on success, prints job_id, model_id, and fine_tune: OK.


Connect via Flight SQL

Run a query against a remote jammi-server over Arrow Flight SQL.

When to use this pattern. You’re connecting from a non-Python client (Tableau, dbt, JDBC tools, Rust binaries), or you want to expose Jammi to multiple readers without each one holding an embedded session. The same protocol is what dbt-flightsql, the official Flight SQL JDBC driver, and BI tools speak natively.

What example.py does

  1. Spawns target/release/jammi-server as a child process pointed at a temp artifact_dir
  2. Polls the health endpoint (http://127.0.0.1:8080/healthz) until the server is ready (5 s budget)
  3. Opens a pyarrow.flight.FlightClient against grpc://127.0.0.1:8081
  4. Submits SELECT 1 AS one over Flight SQL and confirms the response
  5. Tears down the server process cleanly

This recipe is gated out of the per-PR CI matrix — it depends on the jammi-server binary being built (cargo build --release -p jammi-server), and the build cost dominates the test wall-clock. The nightly cookbook job builds the binary and runs the recipe behind JAMMI_COOKBOOK_SLOW=1.

Prerequisites

  • cargo build --release -p jammi-server — produces target/release/jammi-server
  • pip install pyarrow (already a jammi-ai dependency)

The script auto-detects JAMMI_BIN (env var) or falls back to the workspace’s target/release/jammi-server.

API surface exercised

  • pyarrow.flight.FlightClient.execute(query) over the Flight SQL command dialect
  • jammi-server — the OSS deployment-shape binary entrypoint

Run it

cargo build --release -p jammi-server      # one-time build
python cookbook/recipes/flight_sql/example.py

Exits 0 on success, prints the query result + flight_sql: OK.


Run audio-to-audio similarity search over a corpus with a CLAP-format audio model, measure retrieval quality, and domain-tune the audio embeddings on caller-supplied triplets.

When to use this pattern. You have a corpus of sounds (clips, stems, loops, recordings) and want to find the ones most similar to a query clip — and a number that tells you how good the retrieval is. This is the audio counterpart of the image eval_embeddings recipe; audio is simply the third embedding modality the engine supports alongside text and images.

Flow

  1. Load a small audio corpus (inline audio bytes in a Parquet source)
  2. Generate L2-normalized audio embeddings over the audio column
  3. Search the index with an encoded audio query (cosine ANN)
  4. Eval retrieval quality (Recall@K / MRR) against a held-out golden set
  5. Fine-tune a projection head on audio triplets and re-eval (tuned ≠ base)
  6. Fine-tune LoRA adapters inside the audio tower on the same triplets (adapted ≠ base), then watch a wrong selector get refused

Model

Any HuggingFace CLAP audio model works — its config.json declares model_type = "clap_audio_model" (or lists ClapModel / ClapAudioModelWithProjection in architectures), its checkpoint exposes the audio_model.audio_encoder.* + audio_projection.* HTSAT-Swin tower keys, and a preprocessor_config.json carries the feature-extractor geometry. The encoder is auto-detected from that config, exactly as the image recipe auto-detects OpenCLIP:

JAMMI_AUDIO_MODEL=<hf-repo-id-or-local-path> \
    python cookbook/recipes/audio_search/example.py

By default (no env var) the recipe runs against the hermetic cookbook/fixtures/htsat_clap_tiny fixture so it runs offline in CI — on the order of ten seconds on a laptop, dominated by the three training legs (the projection head, the tower LoRA, and the refused job). That fixture has random weights, so its retrieval numbers are meaningless — it exercises the full pipeline, not model quality. Point JAMMI_AUDIO_MODEL at a real CLAP checkpoint for real numbers.

What example.py does

  1. Connects to a temporary artifact dir
  2. Reads the 20 committed mono WAV clips under cookbook/fixtures/tiny_audio_corpus/ into a Parquet corpus source (clip_id, audio bytes)
  3. db.generate_embeddings(source="corpus", model=MODEL, columns=["audio"], key="clip_id", modality="audio")
  4. db.encode_query(model=MODEL, query=wav_bytes, modality="audio")db.search("corpus", query=vec, k=5) (returns a pyarrow.Table)
  5. Builds the audio-query golden source from tiny_audio_golden.json and calls db.eval_embeddings(source="corpus", golden_source="golden.public.golden", k=5)
  6. Prints the base aggregate Recall@K / precision@K / MRR / nDCG and the per-query records. It reports the metrics; it does not assert a quality bar.
  7. Builds synthetic (anchor, positive, negative) audio triplets from the corpus (positive = same timbre family, negative = a different family) and calls db.fine_tune(source="triplets", base_model=MODEL, columns=["anchor","positive","negative"], method="lora", task="audio_embedding", ...). Empty target_modules ⇒ a trainable projection head on the frozen CLAP audio tower (the cheap, low-risk lightweight mode). It then re-embeds the corpus with the tuned model, re-evals, and prints base-vs-tuned metrics for narrative. For correctness it re-encodes the same query clip through the tuned model and asserts the embedding vector changed (max elementwise |Δ| > 1e-4 versus the base encoding) — the real invariant fine-tuning guarantees, and a deterministic check. (Asserting on the coarse top-k metrics instead is flaky: on this tiny eval set the rankings rarely flip even when the vectors move.) It proves the adapter alters audio retrieval — not that it improves it; the random-weight fixture’s direction is not meaningful, real lift comes from a real checkpoint.
  8. Runs the other fine-tune mode on the same triplets: target_modules=["query", "value", "linear1"] puts LoRA inside the HTSAT-Swin tower itself — query/value are the Swin blocks’ attention projections (indexed by stage), linear1 the audio projection head’s first linear (an unindexed site). It re-encodes the same query clip through the adapted model and asserts the same |Δ| > 1e-4 change.
  9. Submits one more job with target_modules=["q_proj"] — a real selector on plenty of decoder checkpoints and on nothing in an HTSAT-Swin tower — and asserts job.wait() raises jammi.errors.TrainingError whose message echoes q_proj and names this tower’s real sites (query, linear1, …). It prints the message.

What each leg proves, and the honesty rule

Both fine-tune legs ship and both are real; they are different capabilities:

  • The projection-head leg (empty target_modules) trains a new map on top of a tower whose weights never move — cheap, low-risk, no site names needed.
  • The tower leg (non-empty target_modules) moves the tower’s own representation — more capacity for a domain the base checkpoint never saw, at more compute, and it needs the site vocabulary of this architecture.

Both prove the adapter is trained and applied when the model is served: an adapter that trained but was silently dropped at serve time leaves the two query vectors bit-identical, and that is what the |Δ| check catches. Both assert change, not improvement — the default fixture has random weights, so the direction of the change carries no information.

What neither leg can check from here is the saved adapter’s kind — that it is an encoder-adapters bundle carrying the audio tower’s id. That is engine-internal and is pinned by the engine’s own integration tests; the client surface (describe_model) reports only the model’s id, backend, task and status, so the recipe asserts the task and leans on the |Δ| check for the rest.

The refusal leg proves a selector matching no site fails the job rather than publishing an adapter that changes nothing, and that the message is actionable — it carries this architecture’s own site names.

The independently-known improvement number — tuned retrieval quality beating the base by a measured margin — is not this recipe’s to claim. It belongs to a real-checkpoint chapter (built from a committed GPU-produced cache; planned under issue #421), which does not exist yet. A recipe running a random-weight fixture on a laptop can honestly prove mechanism; it cannot prove quality.

The pairing semantics (what a “positive” means) are the caller’s training data, not the trainer’s: the trainer only minimizes the contrastive triplet loss over whatever clips you pair.

Stepwise scripts

example.py runs every phase in one process (this is the version wired into tests/cookbook_smoke.py). The numbered scripts decompose the search-and-eval flow and share a persistent workdir, so run them in order:

python cookbook/recipes/audio_search/01-load-corpus.py
python cookbook/recipes/audio_search/02-generate-embeddings.py
python cookbook/recipes/audio_search/03-search.py
python cookbook/recipes/audio_search/04-eval.py

API surface exercised

  • Database.generate_embeddings(*, source, model, columns, key, modality="audio")
  • Database.encode_query(*, model, query, modality="audio")list[float]
  • Database.search(source, *, query, k, filter=None, select=None)pyarrow.Table
  • Database.eval_embeddings(*, source, golden_source, model=None, k=10)
  • Database.fine_tune(*, source, base_model, columns, method, task="audio_embedding", target_modules=[...], ...)TrainingJob
  • Database.describe_model(model_id)dict | None

Audio triplet schema (fine-tune input)

columntypenotes
anchorbinaryencoded audio clip
positivebinarya clip the caller deems related
negativebinarya clip the caller deems unrelated

Same column shape as text triplets — task="audio_embedding" is what tells the loader to read the three columns as encoded audio rather than text.

Audio-tower LoRA sites

target_modules names sites on this architecture. An empty list means “no tower sites” and selects the projection-head mode instead. The HTSAT-Swin audio tower offers query, key, value, attention_output, intermediate_dense, output_dense, reduction, linear1 and linear2; all-linear selects every one. A selector matches a site name exactly or as a suffix of it. A non-empty list matching nothing fails the job with a message that echoes what you submitted and lists the tower’s real names.

Input schema

columntypenotes
clip_idutf8per-row key
audiobinaryraw WAV/FLAC/MP3/Ogg bytes (decoded by the encoder)

Preprocessing (decode → resample to the model’s sample rate → CLAP fusion log-mel spectrogram → HTSAT-Swin tower → L2-normalized output) is handled inside the encoder per the model’s preprocessor_config.json feature-extractor geometry. The audio column may also hold file-path strings instead of inline bytes.

Golden source shape (audio mode)

eval_embeddings switches to audio-query mode when the golden source carries a query_audio (binary) column instead of query_text / query_image:

columntypeexample
query_idutf8q_sine
query_audiobinaryraw WAV bytes of the query clip
relevant_idutf8clip_sine_0 (matches clip_id)

Fixtures

  • cookbook/fixtures/tiny_audio_corpus/ — 20 synthetic mono WAV clips in 5 timbre families (sine / harmonic / square / saw / noise), 4 per family, plus a held-out query clip per family under queries/. Synthesised programmatically by cookbook/fixtures/generate.pyno recorded audio (licensing), no tenant data.
  • cookbook/fixtures/tiny_audio_golden.json — per-query → expected corpus IDs (same timbre family).
  • cookbook/fixtures/htsat_clap_tiny/ — tiny offline HTSAT-Swin CLAP fixture used as the default CI model, generated by tests/fixtures/generate_htsat_clap.py.

Run it

python cookbook/recipes/audio_search/example.py

Exits 0 on success, prints the top-K and the metrics dict + audio_search: OK.


Per-query search audit

Record a tamper-evident audit row for every search: what was queried, with what model, what came back, and when. The substrate signs each record, stores it tenant-scoped, and publishes it to a trigger topic — so you do not hand-roll an audit schema, a signature scheme, and a stream integration in every project.

This is the primitive every audited-ML deployment in a regulated setting (finance, healthcare, legal, and the like) needs to answer “show me exactly what this model returned for this query, and prove the record hasn’t been altered.”

What this recipe shows

  • Build a PerQueryAudit record (query id, model id/version, query lineage, top-K result ids, retrieval scores).
  • db.audit.log([...]) — the substrate injects tenant_id, signs the record with a per-tenant HMAC-SHA256 key, stores it, and publishes it.
  • db.audit.fetch_by_query_id(...) / db.audit.fetch_recent(...) — typed reads, tenant-scoped.
  • record.verify() — re-derive the key and check the signature.
  • Plain SQL over mutable.public."_jammi_search_audit" — same tenant scope.
  • db.subscribe_collect("jammi.audit.search.v1", ...) — every logged record is also delivered on a trigger topic for alerting / analytics / warehouse sinks.

Run it

The audit master key is required — the substrate refuses to sign without it:

export JAMMI_AUDIT_MASTER_KEY=$(python -c "import secrets; print(secrets.token_hex(32))")
python cookbook/recipes/search_audit/example.py

The key derives a distinct signing secret per tenant via HKDF-SHA256 and is deterministic across restarts, so signatures written today verify after a redeploy. Source it from your secret manager — never hard-code it.

Key points

  • Lineage is capped. query_lineage JSON may not exceed 8 KiB (override with JAMMI_AUDIT_MAX_LINEAGE_BYTES). Store image hashes and row IDs, not raw payloads — compliance posture is structural, not advisory.
  • top_k_result_ids and retrieval_scores must be the same length. This is checked when you construct the record.
  • The table is reserved. _jammi_search_audit is created implicitly on the first log; you cannot create or directly INSERT into it (that would bypass signing). Read it freely via SQL.
  • Tenant isolation is automatic. A record logged under tenant A is invisible to tenant B, through both the typed API and raw SQL.

Ephemeral session storage

A session-scoped storage context whose tables are auto-deleted when the session ends — on explicit close(), on context-manager exit, or when the 60-second timeout scanner force-closes a session past its deadline. Every transition publishes to the jammi.audit.session_lifecycle.v1 trigger topic, giving an audit-log aggregator durable proof that the data was deleted.

Run it:

python cookbook/recipes/session_lifecycle/example.py

When to use it

Use an ephemeral session for sensitive transient data that must not outlive the request that produced it: uploaded images, derived embeddings, draft model inputs. The session is always tenant-scoped — tenant A can never see tenant B’s ephemeral tables.

When NOT to use it

Do not store long-lived data in an ephemeral session. The audit record, the persistent corpus, and anything compliance needs to read later belong in ordinary mutable tables. The pattern is: keep the throwaway working set (raw bytes, embeddings) in the ephemeral session, and write only durable lineage (hashes, ids, scores) to a persistent table — before you close the session, while the working data still exists.

API

with db.ephemeral_session(timeout_seconds=3600) as ephem:
    ephem.create_ephemeral_table("imgs", schema=schema, primary_key=["image_id"])
    ephem.insert("imgs", batch=table)
    rows = ephem.sql("imgs", "SELECT image_hash FROM {table}")
# close() runs on exit: tables dropped, `closed` event published

{table} in a sql query is replaced by the tenant-scoped reference to the named ephemeral table. The context manager is the recommended path; Drop is best-effort. Lifecycle events (opened, closed, timed_out, partial_deletion_failure) carry the session id, tenant, table count, and deleted-row count.

Query Your Data with SQL

Register data files as named sources, then query them with full SQL. Sources are persisted in the catalog and survive session restarts.

Register a source

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
use jammi_db::source::{FileFormat, SourceConnection, SourceType};

session.add_source("patents", SourceType::File, SourceConnection {
    url: Some("file:///data/patents.parquet".into()),
    format: Some(FileFormat::Parquet),
    ..Default::default()
}).await?;
Ok(()) }
}

Python

db.add_source("patents", url="/data/patents.parquet", format="parquet")

CLI

jammi sources add patents --url /data/patents.parquet --format parquet

Supported formats

FormatRustPython/CLINotes
ParquetFileFormat::Parquet"parquet"Columnar, compressed, recommended for large datasets
CSVFileFormat::Csv"csv"Auto-detected schema
JSONFileFormat::Json"json"Line-delimited JSON
JSON LinesFileFormat::JsonLines"jsonl" or "ndjson"Same line-delimited reader as JSON; .jsonl preferred, .ndjson used when no .jsonl files match. Resolved once at registration and pinned — a later directory change never flips it.

Run a SQL query

Sources are accessible via three-part SQL names: <source_id>.public.<table_name>. The table name is derived from the file name (e.g., patents.parquet becomes patents).

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
let results = session.sql(
    "SELECT id, title, year FROM patents.public.patents WHERE year > 2020 ORDER BY year"
).await?;

for batch in &results {
    println!("{batch:?}");
}
Ok(()) }
}

Python

table = db.sql("SELECT id, title, year FROM patents.public.patents WHERE year > 2020 ORDER BY year")
print(table.to_pandas())

CLI

jammi query "SELECT id, title, year FROM patents.public.patents WHERE year > 2020 ORDER BY year"

Aggregations

SELECT category, COUNT(*) as count, AVG(citation_count) as avg_citations
FROM patents.public.patents
WHERE year > 2020
GROUP BY category
ORDER BY count DESC

Joins across sources

Register multiple sources and join them in a single query:

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_db::source::{FileFormat, SourceConnection, SourceType};
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
session.add_source("companies", SourceType::File, SourceConnection {
    url: Some("file:///data/companies.csv".into()),
    format: Some(FileFormat::Csv),
    ..Default::default()
}).await?;

let results = session.sql("
    SELECT p.title, c.company_name
    FROM patents.public.patents p
    JOIN companies.public.companies c ON p.assignee_id = c.id
").await?;
Ok(()) }
}

Python

db.add_source("companies", url="/data/companies.csv", format="csv")

table = db.sql("""
    SELECT p.title, c.company_name
    FROM patents.public.patents p
    JOIN companies.public.companies c ON p.assignee_id = c.id
""")

Source lifecycle

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
// List registered sources
let sources = session.catalog().list_sources().await?;

// Remove a source
session.remove_source("patents").await?;
Ok(()) }
}

CLI

jammi sources list

Sources persist in the SQLite catalog at <artifact_dir>/catalog.db. Registering the same source ID twice returns an error — remove it first.

Execution plans

Use EXPLAIN (or the CLI explain command) to see how DataFusion will execute your query:

jammi explain "SELECT * FROM patents.public.patents WHERE year > 2020"

Generate Embeddings

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Constructing the Graph.

Generate vector embeddings by running a model over text columns from a registered source. Results are persisted to Parquet with sidecar ANN indexes for fast similarity search.

Basic usage

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_db::store::CachePolicy;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
let (record, _outcome) = session.generate_text_embeddings(
    "patents",
    "sentence-transformers/all-MiniLM-L6-v2",
    &["abstract".to_string()],
    "id",
    CachePolicy::Bypass,
    None,
).await?;

println!("Embedded {} rows, {} dimensions", record.row_count, record.dimensions().unwrap());
Ok(()) }
}

Python

db.generate_embeddings(
    source="patents",
    model="sentence-transformers/all-MiniLM-L6-v2",
    columns=["abstract"],
    key="id",
    modality="text",
)

What gets created

Each call creates a timestamped Parquet file plus a sidecar ANN index bundle:

{artifact_dir}/jammi_db/
├── patents__embedding__all-MiniLM-L6-v2__20260325T120000.parquet
├── patents__embedding__all-MiniLM-L6-v2__20260325T120000.usearch
├── patents__embedding__all-MiniLM-L6-v2__20260325T120000.rowmap
└── patents__embedding__all-MiniLM-L6-v2__20260325T120000.manifest.json
  • Parquet file — source of truth. Contains _row_id, _source_id, _model_id, vector, _content_hash. Readable by external tools (DuckDB, Polars, pandas).
  • .usearch — USearch HNSW graph for ANN search.
  • .rowmap — maps internal USearch keys to _row_id strings.
  • .manifest.json — metadata (dimensions, count, metric, backend).

The sidecar files are disposable — deleting them falls back to brute-force exact search. The Parquet file is the only thing that matters.

Embedding table schema

ColumnTypeDescription
_row_idUtf8Key column value cast to string
_source_idUtf8Source identifier
_model_idUtf8Model identifier
vectorFixedSizeList(Float32, N)L2-normalized embedding vector
_content_hashUtf8 (nullable)Hex SHA-256 over the embedded source columns, in columns order, rendered exactly as the model read them (jammi.content_hash.v1); NULL on a table no embedding pipeline produced (an import, a context set, a propagation)

Failed rows (null or empty text) are excluded — only successfully embedded rows appear in the output. Rows are written in key order: CAST(key AS Utf8) ascending, ties broken by _content_hash, so the table’s bytes are identical across engine.execution_threads.

A NULL in the key column is not a per-row failure: the whole call is refused with the typed InvalidKey { column, null_count } before the model runs (the null count is exact; zero rows are embedded and nothing is written). Every row needs a key.

_content_hash is what makes the table refreshable: after the source changes, refresh_embeddings re-embeds only the rows whose hash differs and publishes a new version of the same table — see Refresh an Embedding Table Incrementally.

Text column format

A text column can be:

  • Utf8 / LargeUtf8 / Utf8View — read directly, no conversion. Utf8View is what a plain Parquet Utf8 column becomes through a DataFusion scan under this workspace’s pinned Arrow/DataFusion versions, so an ordinary registered source’s text column takes this arm on the real, unmodified scan output.
  • Any other non-binary type (e.g. Int64, Float64) — cast to text ("42", "3.14", …). The whole call is refused if the cast would introduce a null the source column did not have.
  • Binary / LargeBinary / BinaryView / FixedSizeBinary — refused outright, naming the column’s data type. These hold raw bytes, not text (e.g. image or audio bytes submitted under a text-embedding task by mistake); the call never silently embeds an empty string for every row.

A null value in an otherwise-text column reads as the empty string (""), which the row-failure rule above then excludes from the output.

Multiple text columns

Pass multiple column names to concatenate them (space-separated) before embedding:

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_db::store::CachePolicy;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
session.generate_text_embeddings(
    "papers",
    "sentence-transformers/all-MiniLM-L6-v2",
    &["title".to_string(), "abstract".to_string()],
    "doi",
    CachePolicy::Bypass,
    None,
).await?;
Ok(()) }
}

Python

db.generate_embeddings(
    source="papers",
    model="sentence-transformers/all-MiniLM-L6-v2",
    columns=["title", "abstract"],
    key="doi",
    modality="text",
)

Multiple embedding tables

Each call creates a new table. Multiple tables can coexist for the same source (different models, different columns):

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_db::store::CachePolicy;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
session.generate_text_embeddings("patents", "all-MiniLM-L6-v2", &["abstract".into()], "id", CachePolicy::Bypass, None).await?;
session.generate_text_embeddings("patents", "bge-small-en-v1.5", &["title".into()], "id", CachePolicy::Bypass, None).await?;
Ok(()) }
}

When searching, the latest ready embedding table is used by default.

Import precomputed embeddings

When the vectors already exist — computed by an offline batch, migrated from another store, or upserted from a remote encoder — register them directly as a ready embedding table instead of re-running the model. The input is a Parquet object with a _row_id (Utf8) column and a vector (FixedSizeList<Float32> of width dimensions) column, one row per key.

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
use jammi_db::storage::StorageUrl;

let vectors = StorageUrl::parse("file:///data/precomputed.parquet")?;
let record = session.import_embeddings(
    "patents",
    "sentence-transformers/all-MiniLM-L6-v2",
    &vectors,
    "id",
    &["abstract".to_string()],
    384,
).await?;

println!("Imported {} rows, {} dimensions", record.row_count, record.dimensions().unwrap());
Ok(()) }
}

Python

db.import_embeddings(
    source="patents",
    model="sentence-transformers/all-MiniLM-L6-v2",
    vectors_url="file:///data/precomputed.parquet",
    key="id",
    text_columns=["abstract"],
    dimensions=384,
)

The result is indistinguishable from a generated table — same (_row_id, _source_id, _model_id, vector) schema, same sidecar ANN index — so search queries it exactly like any other embedding table. Three behaviours are specific to import:

  • Vectors are L2-normalized on import. Every embedding table holds unit vectors (the cosine ANN sidecar assumes it), so each incoming vector is normalized and a zero-norm vector is rejected — it cannot be cosine-searched.
  • The model is validated, not loaded. model is parsed to its canonical form and recorded as the table’s derivation provenance; import never loads the encoder or downloads weights, so it needs no GPU. key and text_columns are recorded as catalog provenance (which source column the keys came from, which content columns produced the vectors); the physical key stays _row_id.
  • The table is recompute-inert. The engine did not compute these vectors, so a recompute of an imported table is a typed refusal rather than a re-run guessed from its columns.

The input vectors are read fully into memory; a streaming variant is future work.

Supported models

Any encoder model on HuggingFace Hub with safetensors weights. Supported architectures:

BERT family — BERT, RoBERTa, DistilBERT, CamemBERT, XLM-RoBERTa:

  • sentence-transformers/all-MiniLM-L6-v2 (384-dim, fast)
  • sentence-transformers/all-mpnet-base-v2 (768-dim, higher quality)
  • BAAI/bge-small-en-v1.5, BAAI/bge-base-en-v1.5

ModernBERT — modernized encoder with rotary embeddings, 8192-token context, GeGLU:

  • answerdotai/ModernBERT-base (768-dim)
  • answerdotai/ModernBERT-large (1024-dim)

Or any local directory with config.json + model.safetensors + tokenizer.json. The architecture is detected automatically from model_type in config.json.

Use a local model:

#![allow(unused)]
fn main() {
extern crate jammi_ai;
use jammi_ai::model::ModelSource;
let model = ModelSource::local("/path/to/my-model");
}

Pooling

Pooling — how per-token hidden states collapse into one sentence vector — is model-declared, not hardcoded. On load, the engine reads the model’s 1_Pooling/config.json (the sentence-transformers convention) and pools with the strategy it declares: pooling_mode_cls_token selects CLS pooling (first token — the mode BGE, GTE, and many E5-family models require), and pooling_mode_mean_tokens (or pooling_mode_mean_sqrt_len_tokens, which is exactly equivalent after the mandatory L2 normalization) selects mean pooling. Max and weighted-mean pooling are also supported.

A model whose repository ships no 1_Pooling/ directory — many bare BERT checkpoints — falls back to mean pooling, the historical sentence-transformers default. A model whose 1_Pooling/config.json declares a mode the engine cannot represent (e.g. last-token pooling, or more than one enabled mode at once) fails to load rather than silently pooling incorrectly.

Raw inference (no persistence)

To get embeddings as RecordBatch without writing to disk:

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::store::CachePolicy;
async fn ex(session: &Arc<InferenceSession>) -> jammi_db::error::Result<()> {
use jammi_ai::model::{ModelSource, ModelTask};

let model = ModelSource::hf("sentence-transformers/all-MiniLM-L6-v2");
let (_results, _outcome) = session.infer("patents", &model, ModelTask::TextEmbedding, &["abstract".into()], "id", CachePolicy::Bypass).await?;
Ok(()) }
}

Python

results = db.infer(
    source="patents",
    model="sentence-transformers/all-MiniLM-L6-v2",
    columns=["abstract"],
    task="text_embedding",
    key="id",
)

Each RecordBatch has prefix columns (_row_id, _ordinal, _source, _model, _status, _error, _latency_ms) plus task-specific columns (e.g., vector for embeddings). _ordinal is a stream-scoped, 0-based row counter in model emission order; infer rows read back ordered by _row_id, _ordinal. The materialised embedding table generate_embeddings registers keeps only _row_id, _source_id, _model_id, vector — no _ordinal, since its _row_id is unique by construction.

Error handling

Inference never panics on bad input. _status/_error track per-row input validation, applied before the model ever runs:

Condition_status_errorvector
Valid text"ok"null384-dim float vector
Null text"error""Empty or null text input"null
Empty text"error""Empty or null text input"null

The batch continues processing even when individual rows fail this validation. A null key is the one input fault that is never per-row: infer, generate_embeddings and an incremental refresh all refuse the whole call with InvalidKey { column, null_count } before any model call — _row_id is non-nullable by contract, so a silent drop would break the per-row disclosure this table promises. A model-forward failure itself — a broken kernel, a contiguity/PTX/dtype mismatch, or a model incapable of the requested task — is always systemic (every row fails identically), never a per-row event, so it fails the whole infer/embedding call with an error rather than being served as an all-"error" relation or an empty “ready” embedding table.

Dynamic batch sizing

The runner starts with the configured inference.batch_size (default: 32). If an out-of-memory error occurs:

  1. Halve the batch size
  2. Retry (up to 3 times)
  3. If OOM persists at batch size 1, the call fails with an error

The reduced batch size is sticky for the remainder of the stream.

Crash recovery

If the process dies mid-generation, the table is left in “building” status. On the next session start, recovery runs automatically:

  • Parquet missing — mark as failed
  • Parquet corrupt — delete file, mark as failed
  • Parquet valid but stuck in “building” — promote to “ready”, rebuild ANN index

No data is lost if the Parquet file was fully written.

DataFusion integration

Result tables are automatically registered in DataFusion and queryable via SQL:

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_db::catalog::result_repo::ResultTableRecord;
async fn ex(session: &InferenceSession, record: &ResultTableRecord) -> jammi_db::error::Result<()> {
let results = session.sql(&format!(
    "SELECT _row_id, _source_id FROM \"jammi.{}\" LIMIT 10",
    record.table_name
)).await?;
Ok(()) }
}

Use a Local Model Checkpoint

Every model-accepting argument in the engine — generate_embeddingsmodel, fine-tune’s base_model, annotate()’s first argument, the Python Session API — takes a model reference string. A reference that names a filesystem location loads the checkpoint from local disk, with no Hub access and no network: this is the supported path for air-gapped hosts and for checkpoints you have already downloaded or trained elsewhere.

Reference forms

ModelSource::parse recognizes four spellings; the first three are local:

FormExampleResolves to
local: prefixlocal:/models/bioclinical-modernbert-largethe directory after the prefix
file:// URIfile:///models/bioclinical-modernbert-largethe URI’s path
Bare filesystem path/models/m, ./m, ../mthat path (must start with /, ./, or ../)
Anything elsesentence-transformers/all-MiniLM-L6-v2, hf://owner/repoa HuggingFace Hub repo id

A local path is resolved against the filesystem of the host running the engine — for a remote client that is the server, not the client machine — so the directory must exist there.

What the directory must contain

  • A config: config.json (or open_clip_config.json for OpenCLIP models).
  • Weights: model.safetensors (or open_clip_model.safetensors), model.onnx, and/or model.gguf.

When both safetensors and ONNX weights are present, the ONNX file wins and the model runs on the ORT backend; safetensors alone selects the Candle backend. An explicit backend hint overrides this choice. model.gguf is considered only when neither safetensors nor ONNX weights are present — see Quantized (GGUF) checkpoints below.

Resolution is fail-loud: a nonexistent directory, a directory with no config, and a directory with no recognized weights file each produce a typed error naming what is missing — there is no silent fallback to the Hub.

Quantized (GGUF) checkpoints

A directory with no model.safetensors/model.onnx but a model.gguf loads on the Candle backend as a quantized checkpoint — matmul-site weights stored at a k-quant format (q4_0 through q6k) stay resident in that compressed form; everything else (embeddings, norms, classifier/NER heads, and any matmul-site weight that happens to be stored densely) is dequantized to the model’s compute dtype at load.

Requirements and limits:

  • The weights file must be named exactly model.gguf — any other *.gguf filename in the directory produces a typed error naming the convention.
  • config.json is still required, and still the source of the model’s architecture and layer count — a GGUF file’s own embedded metadata (the convention some GGUF exporters use in place of a sidecar config.json) is never read for this. A directory with a model.gguf but no config.json is not a supported checkpoint shape.
  • Supported architectures: BERT and its variants (RoBERTa, CamemBERT, XLM-RoBERTa), DistilBERT, and ModernBERT. Any other architecture (OpenCLIP, CLAP) is a typed refusal, not a best-effort load.
  • Every tensor in the file must be a supported k-quant format (q4_0, q4_1, q5_0, q5_1, q8_0, q2k, q3k, q4k, q5k, q6k) or stored densely as f32/f16/bf16 — any other GGML dtype is a typed refusal.

base_model in fine-tuning accepts a model.gguf checkpoint the same way: an encoder-adapters LoRA job trains its low-rank adapters over the frozen quantized backbone automatically when the resolved base is GGUF — there is no separate QLoRA flag or config field.

Examples

Embeddings over a local checkpoint (Python):

db.generate_embeddings(
    source="patents",
    model="local:/models/all-MiniLM-L6-v2",
    columns=["abstract"],
    key="id",
    modality="text",
)

Fine-tune from a local base model (see Fine-Tune for Your Domain):

db.fine_tune(
    base_model="local:/models/bioclinical-modernbert-large",
    ...
)

SQL, over Flight SQL (see Compound Retrieval and Inference):

SELECT * FROM annotate('local:/models/all-MiniLM-L6-v2', 'text_embedding',
                       ARRAY['abstract'])

Model registration — a durable catalog entry with an id, stage transitions, and evidence — is a platform concern and lives outside the OSS engine; the engine consumes local checkpoints directly through the reference forms above.

Configuring the Hub cache, endpoint, and token

Every other spelling (a bare repo id, hf://owner/repo) resolves against the Hugging Face Hub through one client built once from [models]:

[models]
hub_endpoint = "https://huggingface.co"
hub_cache_dir = "/var/cache/jammi"
hub_token = { file = "/run/secrets/hf-token" }
offline = false

Every field is also settable through the standard JAMMI_MODELS__HUB_* / JAMMI_MODELS__OFFLINE env-override layer (e.g. JAMMI_MODELS__HUB_CACHE_DIR, JAMMI_MODELS__OFFLINE=true) — that tier sits ABOVE the HF_* fallbacks below: a JAMMI_* override behaves exactly like the equivalent TOML key, one precedence step above HF_HUB_CACHE/HF_HOME/HF_ENDPOINT/HF_TOKEN/ HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE, not alongside them.

FieldPrecedence
Cache roothub_cache_dir (hub/ appended) → HF_HUB_CACHE (used AS the cache root directly, nothing appended, matching huggingface_hub’s own convention) → HF_HOME (hub/ appended) → the platform home directory’s .cache/huggingface (hub/ appended)
Endpointhub_endpointHF_ENDPOINT → the Hub’s own default
Tokenhub_tokenHF_TOKENHUGGING_FACE_HUB_TOKEN (huggingface_hub’s own live legacy alias) → the token FILE (HF_TOKEN_PATH, naming the file directly, else <HF_HOME>/token, huggingface-cli login’s file — resolved independently of whichever tier won the cache-root precedence above, never derived from the cache root itself)
Offlineoffline, when explicitly set → HF_HUB_OFFLINETRANSFORMERS_OFFLINE (only when HF_HUB_OFFLINE is itself unset or present-but-empty — huggingface_hub’s own alias) → false; truthy for any of "1", "on", "yes", "true" (case-insensitive, whitespace trimmed — huggingface_hub’s own ENV_VARS_TRUE_VALUES)

Empty values are absent, and every value is trimmed: every HF_* environment variable above (HF_HUB_CACHE, HF_HOME, HF_ENDPOINT, HF_TOKEN, HUGGING_FACE_HUB_TOKEN, HF_TOKEN_PATH, HF_HUB_OFFLINE, TRANSFORMERS_OFFLINE) is treated as unset when it is present but empty (after trimming whitespace) — the shape a Compose/Kubernetes env block produces for a variable named with no value (HF_HUB_OFFLINE:), or a shell export HF_TOKEN=. Without this rule, an empty HF_HUB_OFFLINE would shadow the TRANSFORMERS_OFFLINE alias and silently resolve online. The value used downstream is also the TRIMMED string, not the raw one — HF_HOME=" /data/hf" resolves to the cache root /data/hf/hub, never a current-working-directory-relative root the padded, untrimmed value would otherwise produce. One case is a genuine divergence, not merely a stricter reading of upstream, and it fails in opposite directions: a whitespace-only HF_HUB_OFFLINE=" " falls through to TRANSFORMERS_OFFLINE here (failing toward offline, the safe direction), where huggingface_hub itself stops at the whitespace-only value and resolves online.

No home directory, no HF_HUB_CACHE, no HF_HOME, and no hub_cache_dir is a typed JammiError::Config at session construction — never a panic. A token that resolves from none of the four tiers sends no Authorization header, the same as an anonymous huggingface-cli session. The token FILE is HF_TOKEN_PATH, when set, naming the file directly; otherwise <HF_HOME>/token, even when HF_HUB_CACHE wins the cache-root precedence above — HF_HUB_CACHE names the cache directory directly, with no hub path component to derive HF_HOME back out of, so the token lookup reads HF_TOKEN_PATH/HF_HOME on their own rather than working backwards from the cache root.

offline = true refuses every Hub network fetch: a HuggingFace-sourced model loads only when it resolves against local: or an already-populated catalog row — a warm, on-disk Hub cache directory with no matching catalog row is still a miss, because the catalog (not the cache) is offline’s source of truth. It does not reach the fine-tune worker’s adapter fetch for an already-trained model, which always reads the adapter bundle from the artifact store, offline or not. An explicit offline = false wins over HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE in the environment (and vice versa for offline = true) — only an OMITTED offline key falls back to either environment variable at all.

Every HF_*/HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE fallback above is read from the server process’s own environment, at session construction — never from a remote client’s environment (a gRPC/Flight SQL/Python client connecting to a running jammi-server has no way to influence these; only that server process’s own [models]/env decides resolution).

A configured hub_endpoint/mirror is not part of a resolved model’s identity: two endpoints can serve different bytes for the same repo id (a stale or divergent mirror), so once a model resolves, its catalog artifact_path pins the actual bytes fetched — re-resolving under a different endpoint later never silently swaps them out from under an already-registered model.

Generate Image Embeddings

Generate vector embeddings from images using an OpenCLIP-compatible vision model. Results are persisted to Parquet with sidecar ANN indexes, identical to text embeddings — the same search(), evaluation, and SQL tools work on both.

The OpenCLIP family is cross-modal: the vision tower and the text tower in the same checkpoint produce embeddings in a shared latent space, so a text query encoded with the same model can search image embeddings directly. See Search Text Against Images (Cross-Modal) for the full text-to-image recipe.

Basic usage

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_db::store::CachePolicy;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
let (record, _outcome) = session.generate_image_embeddings(
    "figures",
    "laion/CLIP-ViT-B-32-laion2B-s34B-b79K",
    "image",       // column containing image data
    "figure_id",   // key column
    CachePolicy::Bypass,
    None,
).await?;

println!("Embedded {} images, {} dimensions", record.row_count, record.dimensions().unwrap());
Ok(()) }
}

Python

db.generate_embeddings(
    source="figures",
    model="laion/CLIP-ViT-B-32-laion2B-s34B-b79K",
    columns=["image"],
    key="figure_id",
    modality="image",
)

Image column format

The image column can be either:

  • Binary / LargeBinary / BinaryView — inline image bytes (PNG, JPEG, TIFF) stored directly in Parquet
  • Utf8 / LargeUtf8 / Utf8View — file paths pointing to images on disk. Utf8View is what a plain Parquet Utf8 column becomes through a DataFusion scan under this workspace’s pinned Arrow/DataFusion versions, so an ordinary registered source’s path column takes this arm on the real, unmodified scan output — not just a hand-built array (arrow_to_images, crates/jammi-ai/src/inference/mod.rs:210).

Image preprocessing

Each image is automatically preprocessed before embedding:

  1. Pad to square — white canvas, image centered (preserves aspect ratio)
  2. Resize — bicubic interpolation to the model’s input size (224x224 for CLIP)
  3. Normalize — per-channel normalization using constants from the model’s config

Preprocessing parameters (mean, std, image size) are model-driven — parsed from the model’s config file, not hardcoded.

Encode a single image

To embed one image without persistence (e.g., for a query):

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> Result<(), Box<dyn std::error::Error>> {
let image_bytes = std::fs::read("query.png")?;
let vector = session
    .encode_image_query("laion/CLIP-ViT-B-32-laion2B-s34B-b79K", &image_bytes)
    .await?;
// vector: Vec<f32>, L2-normalized, dimensionality = model's embed_dim
Ok(()) }
}

Python

with open("query.png", "rb") as f:
    image_bytes = f.read()

vector = db.encode_query(model="laion/CLIP-ViT-B-32-laion2B-s34B-b79K", query=image_bytes, modality="image")

Supported models

OpenCLIP-compatible models with safetensors weights. The repo must carry:

  • open_clip_config.json with model_cfg.vision_cfg (and model_cfg.text_cfg if you want cross-modal text queries)
  • open_clip_model.safetensors with OpenCLIP weight key naming (visual.* for vision, root-level for text)
  • Either a tokenizer.json or the OpenCLIP-native bpe_simple_vocab_16e6.txt.gz (only required for text-side queries)

The architecture (ViT width, layers, heads, patch size, pooling strategy), the shared latent dimensionality (embed_dim), and the preprocessing config (mean, std, image size) are detected automatically from the config — no per-model code path.

Known-working models include patentclip/PatentCLIP_Vit_B (512-dim, tuned for technical drawings or diagrams rather than photographs — uses global average pooling), OpenAI CLIP, and the LAION CLIP-ViT-B-32-* variants. For an end-to-end image-to-image search + retrieval-eval walkthrough, see the runnable image_search recipe.

Output schema

Same as text embeddings:

ColumnTypeDescription
_row_idUtf8Key value
_source_idUtf8Source identifier
_model_idUtf8Model identifier
vectorFixedSizeList(Float32, N)L2-normalized embedding vector (N = embed_dim)
_content_hashUtf8 (nullable)Hex SHA-256 over the embedded source columns as the model read them (the image bytes, or the path string for a path-valued column — the file behind a path is not hashed); NULL on an imported table

Image embeddings work with the same search() API as text embeddings:

vector = db.encode_query(model="laion/CLIP-ViT-B-32-laion2B-s34B-b79K", query=query_bytes, modality="image")
results = db.search("figures", query=vector, k=10)  # pyarrow.Table

search returns a pyarrow.Table directly; for compound retrieval (join / annotate(...)) use db.sql(...).

Error handling

Condition_status_error
Valid image"ok"null
Null image"error""Null or missing image input"
Corrupt image"error""Failed to decode image at row N: ..." (path-valued rows append (path '...') after the cause)
Path-valued row whose file cannot be read from diskn/a — whole-call Err, not a per-row status"Failed to read image file '<path>': <os error>"; the whole infer/embedding call fails, no rows are served

Search Text Against Images (Cross-Modal)

OpenCLIP-family models carry both a vision tower and a text tower in the same checkpoint, with both towers projecting into a shared latent space. That means a text query embedded with the text tower lives in the same vector space as image embeddings produced by the vision tower — vector search against an image corpus accepts a text query directly, no separate text encoder, no projection bridge.

This recipe shows the full path: index images with the vision tower, embed a text query with the text tower, run search().

1. Index the image corpus with the vision tower

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_db::store::CachePolicy;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
session.generate_image_embeddings(
    "figures",
    "laion/CLIP-ViT-B-32-laion2B-s34B-b79K",
    "image",       // column containing image data
    "figure_id",   // key column
    CachePolicy::Bypass,
    None,
).await?;
Ok(()) }
}

Python

db.generate_embeddings(
    source="figures",
    model="laion/CLIP-ViT-B-32-laion2B-s34B-b79K",
    columns=["image"],
    key="figure_id",
    modality="image",
)

2. Embed a text query with the same model’s text tower

encode_query dispatches to the OpenCLIP text tower when the model ID resolves to an OpenCLIP checkpoint. The output vector dimensionality matches embed_dim — the same dim the image embeddings carry.

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
let query_vec = session
    .encode_text_query(
        "laion/CLIP-ViT-B-32-laion2B-s34B-b79K",
        "a red circle on a white background",
    )
    .await?;
// query_vec: Vec<f32>, L2-normalized, same length as the image embedding vector
Ok(()) }
}

Python

query_vec = db.encode_query(model="laion/CLIP-ViT-B-32-laion2B-s34B-b79K", query="a red circle on a white background",)

3. Search image embeddings with the text vector

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
async fn ex(session: Arc<InferenceSession>, query_vec: Vec<f32>) -> jammi_db::error::Result<()> {
let results = session.search("figures", query_vec, 10, None, None).await?.run().await?;
Ok(()) }
}

Python

results = db.search("figures", query=query_vec, k=10)  # pyarrow.Table

search returns a pyarrow.Table directly, carrying your source’s columns (figure_id) alongside the similarity score; pass filter= / select= to refine. For compound retrieval — joining sources or running a model over the results with annotate(...) — use db.sql(...) (see Compound Retrieval and Inference over Flight SQL); it composes identically for cross-modal search.

Why this works

Both towers in an OpenCLIP checkpoint emit vectors of size embed_dim (the shared latent dimensionality declared at the top of open_clip_config.json). The vision tower applies a visual.proj matrix after pooling its patch tokens; the text tower applies a text_projection matrix after pooling at the <|endoftext|> token. The two projections are jointly trained so the cosine similarity between a text vector and an image vector reflects semantic alignment.

If you embed text and images with separate models (e.g. a BERT encoder + a vision model that wasn’t jointly trained with it), the resulting vectors don’t share a latent space and the similarities are meaningless. Cross-modal search only works when both modalities are projected by the same CLIP-style joint training.

Model requirements

Same as Generate Image Embeddings, plus:

  • open_clip_config.json must contain a populated model_cfg.text_cfg (with width, layers, and either heads or a width that is a multiple of 64).
  • The safetensors checkpoint must contain the text-tower keys: token_embedding.weight, positional_embedding, transformer.resblocks.*, ln_final.*, and text_projection.
  • A tokenizer must be available — either an HF-converted tokenizer.json or the OpenCLIP-native bpe_simple_vocab_16e6.txt.gz.

Classify Text

Run a classification model over text columns to assign labels and confidence scores. Any HuggingFace model with id2label in its config works out of the box.

Basic usage

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::store::CachePolicy;
async fn ex(session: &Arc<InferenceSession>) -> jammi_db::error::Result<()> {
use jammi_ai::model::{ModelSource, ModelTask};

let model = ModelSource::hf("answerdotai/ModernBERT-base-classification");
let (_results, _outcome) = session.infer(
    "patents",
    &model,
    ModelTask::Classification,
    &["abstract".to_string()],
    "id",
    CachePolicy::Bypass,
).await?;
Ok(()) }
}

Python

results = db.infer(
    source="patents",
    model="answerdotai/ModernBERT-base-classification",
    columns=["abstract"],
    task="classification",
    key="id",
)

Output schema

Each RecordBatch has prefix columns plus classification-specific columns:

ColumnTypeDescription
_row_idUtf8Key column value
_ordinalUInt64Stream-scoped row counter (0-based, in model emission order); rows read back ordered by _row_id, _ordinal
_sourceUtf8Source identifier
_modelUtf8Model identifier
_statusUtf8"ok" or "error"
_errorUtf8 (nullable)Error message if failed
_latency_msFloat32Inference latency
labelUtf8 (nullable)Predicted class label
confidenceFloat32 (nullable)Confidence score (0-1)
all_scores_jsonUtf8 (nullable)JSON with all class scores

Supported model architectures

Classification models must have id2label in their config.json. Supported architectures:

BERT family — BERT, RoBERTa, DistilBERT, CamemBERT, XLM-RoBERTa:

  • Loads classifier.weight + classifier.bias from safetensors
  • CLS token pooling + linear classifier + softmax

ModernBERT — uses the built-in ModernBertForSequenceClassification:

  • CLS or MEAN pooling (configured via classifier_pooling in config)
  • Head (dense + GELU + LayerNorm) + classifier + softmax

Fine-tuning for classification

Train a LoRA adapter with a classification head on your labeled data:

Prepare training data

text,label
"quantum error correction","physics"
"CRISPR gene editing","biology"

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
use jammi_ai::fine_tune::FineTuneMethod;
use jammi_db::ModelTask;

let job = session.fine_tune(
    "training",
    "sentence-transformers/all-MiniLM-L6-v2",
    &["text".into(), "label".into()],
    FineTuneMethod::Lora,
    ModelTask::Classification,
    None,
).await?;

job.wait().await?;
Ok(()) }
}

Python

job = db.fine_tune(
    source="training",
    base_model="sentence-transformers/all-MiniLM-L6-v2",
    columns=["text", "label"],
    method="lora",
    task="classification",
)
job.wait()

The fine-tuned model trains a LoRA projection plus a linear classification head using cross-entropy loss. Both are saved to adapter.safetensors.

Error handling

Same per-row error tracking as embeddings:

Condition_statuslabelconfidence
Valid text"ok"Predicted label0-1 score
Null/empty text"error"nullnull

Extract Entities (NER)

Run a Named Entity Recognition model over text columns to extract person names, organizations, locations, and other entities. Results are returned as JSON arrays of entity spans with character positions and confidence scores.

Basic usage

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::store::CachePolicy;
async fn ex(session: &Arc<InferenceSession>) -> jammi_db::error::Result<()> {
use jammi_ai::model::{ModelSource, ModelTask};

let model = ModelSource::hf("dslim/bert-base-NER");
let (_results, _outcome) = session.infer(
    "patents",
    &model,
    ModelTask::Ner,
    &["abstract".to_string()],
    "id",
    CachePolicy::Bypass,
).await?;
Ok(()) }
}

Python

results = db.infer(
    source="patents",
    model="dslim/bert-base-NER",
    columns=["abstract"],
    task="ner",
    key="id",
)

Output schema

ColumnTypeDescription
_row_idUtf8Key column value
_ordinalUInt64Stream-scoped row counter (0-based, in model emission order); rows read back ordered by _row_id, _ordinal
_sourceUtf8Source identifier
_modelUtf8Model identifier
_statusUtf8"ok" or "error"
_errorUtf8 (nullable)Error message if failed
_latency_msFloat32Inference latency
entitiesUtf8 (nullable)JSON array of entity spans

Entity span format

Each entity in the JSON array has:

{
  "text": "Google",
  "label": "ORG",
  "start": 15,
  "end": 21,
  "confidence": 0.97
}
FieldTypeDescription
textstringThe entity text extracted from the input
labelstringEntity type (PER, ORG, LOC, etc.) without B-/I- prefix
startintegerCharacter start position (inclusive)
endintegerCharacter end position (exclusive)
confidencefloatAverage softmax confidence across entity tokens

Supported models

NER models must have id2label with BIO-tagged labels (e.g., B-PER, I-PER, O) in their config.json.

BERT family — loads classifier.weight + classifier.bias on top of the encoder:

  • dslim/bert-base-NER (English, 4 entity types)
  • dbmdz/bert-large-cased-finetuned-conll03-english

ModernBERT — same pattern, modern encoder architecture.

How it works

text → tokenize (with character offsets)
     → encoder forward → hidden states [batch, seq_len, hidden]
     → Linear(hidden, num_labels) per token → logits
     → softmax → argmax → BIO tag per token
     → merge consecutive B-/I- tags into entity spans
     → map character offsets back to original text

The BIO decoding handles:

  • B-TYPE: starts a new entity of that type
  • I-TYPE: continues the current entity (must match type)
  • O: outside any entity
  • Special tokens ([CLS], [SEP], padding) are automatically skipped

Conformal Prediction: Distribution-Free Coverage

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Conformal Prediction.

Conformal prediction wraps any existing predictor and turns a point output into a prediction set (classification) or interval (regression) carrying a finite-sample, distribution-free coverage guarantee. Given a held-out calibration set, the marginal coverage of the emitted sets is at least 1 − alpha under exchangeability — for any underlying model, any data distribution, and any sample size. No retraining: a calibration pass and an empirical quantile. Deterministic given the calibration set, which is the audit property.

The serving primitive lives in the open engine because a calibrated set is a serving output — it must work with no license. The operationalization of the guarantee — rolling coverage monitoring, coverage-SLA gating, and managed recalibration under drift — is a governed concern provided by the Jammi platform, built on this same primitive.

The one assumption

The guarantee holds if and only if the calibration and serving data are exchangeable. Under distribution drift it degrades silently. Two levers correct for known structure:

  • Weighted conformal applies importance weights for a known covariate shift.
  • Mondrian conformal keeps a per-cohort quantile keyed on a group column, the principled approximation to conditional coverage (full per-input coverage is provably impossible distribution-free).

The primitive applies the weights and the grouping; detecting drift and choosing the cohorts is governance, not a serving output.

The three-way split

Reusing test points to calibrate inflates coverage. The calibration set must be disjoint from both the training set and the test/serving data. The calibration source is a distinct argument throughout the API.

Classification: prediction sets

The classification scores read the per-class softmax mass the classifier already emits.

  • LAC — nonconformity 1 − p_y; the smallest sets at the nominal level, but non-adaptive.
  • APS (default) — the cumulative mass of classes ranked most- to least-probable up to the true class; set size adapts to input difficulty.
  • RAPS — APS plus a tail-rank penalty that shrinks sets on easy inputs.
#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
fn ex() -> jammi_db::error::Result<()> {
use jammi_ai::predict::{ClassScore, ConformalModel};

// Held-out calibration: per-class probabilities + the realised class index.
let calibration: Vec<Vec<f64>> = vec![
    vec![0.7, 0.2, 0.1],
    vec![0.1, 0.8, 0.1],
    // ... one row per calibration example
];
let true_labels: Vec<usize> = vec![0, 1 /* ... */];

// Calibrate at 90% nominal coverage with the adaptive APS score.
let model = ConformalModel::classification(&calibration, &true_labels, ClassScore::Aps, 0.1)?;

// Serving: emit the prediction set for a new row of class probabilities.
let probabilities = vec![0.45, 0.4, 0.15];
let prediction_set = model.predict_set(&probabilities, None)?; // e.g. [0, 1]
let _ = prediction_set;
Ok(()) }
}

Python

sets = db.conformalize(
    calibration=[[0.7, 0.2, 0.1], [0.1, 0.8, 0.1]],  # per-class probabilities
    true_labels=[0, 1],                               # realised class indices
    test=[[0.45, 0.4, 0.15]],                         # rows to predict
    alpha=0.1,
    score="aps",                                      # "lac" | "aps" | "raps"
)
# sets -> [[0, 1]]  (one list of admitted class indices per test row)

Regression: prediction intervals

  • Absolute residual — nonconformity |y − ŷ|; a constant-width interval [ŷ − q̂, ŷ + q̂]. Distribution-free but uninformative under heteroscedasticity.
  • CQR (Conformalized Quantile Regression) — nonconformity max(q_lo − y, y − q_hi) over a predictor’s lower/upper quantile estimates; an adaptive-width interval whose width tracks local uncertainty.
#![allow(unused)]
fn main() {
extern crate jammi_ai;
extern crate jammi_db;
fn ex() -> jammi_db::error::Result<()> {
use jammi_ai::predict::{ConformalModel, IntervalScore};

// Absolute-residual conformal over held-out (prediction, observation) pairs.
let predictions = vec![1.0, 2.0, 3.0 /* ... */];
let observed = vec![1.2, 1.7, 3.1 /* ... */];
let model = ConformalModel::regression(
    &predictions,
    &[],   // lower quantiles (CQR only)
    &[],   // upper quantiles (CQR only)
    &observed,
    IntervalScore::AbsoluteResidual,
    0.1,
)?;

// Serving: a 90% interval around a new point estimate.
let (lower, upper) = model.predict_interval(2.5, 0.0, 0.0, None)?;
let _ = (lower, upper);
Ok(()) }
}

Python

# Constant-width absolute-residual intervals.
intervals = db.conformalize_interval(
    predictions=[1.0, 2.0, 3.0],   # calibration point estimates
    observed=[1.2, 1.7, 3.1],      # calibration targets
    test_predictions=[2.5],        # point estimates to bound
    alpha=0.1,
)
# intervals -> [(lower, upper)]

# Adaptive-width CQR intervals from quantile estimates.
intervals = db.conformalize_cqr(
    lower=[0.5, 1.5, 2.5],         # calibration lower-quantile estimates
    upper=[1.5, 2.5, 3.5],         # calibration upper-quantile estimates
    observed=[1.2, 1.7, 3.1],
    test_lower=[2.0],
    test_upper=[3.0],
    alpha=0.1,
)

The finite-sample quantile

The conformal threshold is the ⌈(n+1)(1 − alpha)⌉-th smallest calibration score, not the naive ⌈n(1 − alpha)⌉ order statistic. The (n + 1) correction is what makes the guarantee exact rather than merely asymptotic; the naive quantile leaves a ~1/n coverage gap and under-covers. When the calibration set is too small for the requested level — fewer than ⌈1/alpha⌉ − 1 points — the threshold is +∞: the honest, conservative answer is “every label”. A full set is a real signal that the input is hard or the base model is miscalibrated, not a bug.

The conformal evidence channel

Conformal outputs ride the evidence substrate exactly as vector and inference do — one channel, four declared columns, no new provenance machinery:

ColumnTypeClassificationRegression
prediction_setUtf8JSON array of class idsnull
lowerFloat64nullinterval lower bound
upperFloat64nullinterval upper bound
alphaFloat64nominal levelnominal level

Register the channel once, then attach a contribution per result batch:

#![allow(unused)]
fn main() {
extern crate jammi_ai;
extern crate jammi_db;
async fn ex(catalog: &jammi_db::catalog::Catalog) -> jammi_db::error::Result<()> {
use jammi_ai::evidence::conformal::{channel_spec, contribution, ConformalOutput};

catalog.channels().register(&channel_spec()?).await?;

let _contrib = contribution(&[
    ConformalOutput::Set { classes: vec![0, 2], alpha: 0.1 },
    ConformalOutput::Interval { lower: -1.0, upper: 1.0, alpha: 0.1 },
])?;
// `_contrib` merges into result batches via `merge_channels`.
Ok(()) }
}

Verifying coverage

The realised coverage and mean set size of a labelled batch are pure functions in jammi-numerics — the same functions the platform’s coverage monitor calls on a rolling window:

#![allow(unused)]
fn main() {
extern crate jammi_numerics;
fn ex() -> Result<(), jammi_numerics::error::NumericsError> {
use jammi_numerics::calibration::{coverage, mean_set_size};

let hits = [true, true, false, true];     // did each set contain the true label?
let sizes = [2usize, 1, 3, 2];            // cardinality of each set
let realised = coverage(&hits)?;          // ~ 1 - alpha when calibrated
let efficiency = mean_set_size(&sizes)?;  // smaller is sharper
let _ = (realised, efficiency);
Ok(()) }
}

What lives in the platform, not here

This is the serving primitive only. The governed layer — a rolling realised-vs-nominal coverage monitor with drift detection and online adaptation, a coverage-SLA gate, and managed recalibration under shift — is provided by the Jammi platform. It consumes this primitive and the OSS coverage function; it is not part of the open engine.

Distributional Inference: Predict a Distribution, Not a Point

A ModelTask::Regression head returns a predictive distribution per row — a Gaussian (predicted_mean, predicted_std) or a set of quantiles — instead of a single number. Where conformal prediction wraps any predictor with a distribution-free interval, a regression head is trained to emit calibrated uncertainty directly, with proper-scoring objectives that make that uncertainty honest.

Two output forms, both standard:

  • Parametric Gaussian — the head predicts μ and a raw scale; serving maps the scale to a positive σ = floor + softplus(raw). Smooth, cheap, a closed- form density. The default.
  • Quantile — the head predicts a set of levels (e.g. 0.05, 0.5, 0.95) directly. Distribution-free in shape, robust to non-Gaussian outcomes, and the input to conformal CQR. The serving adapter sorts each row’s quantiles so they never cross.

Choose the objective by your label

Your labelTask / objective
a continuous outcome + you want a densityRegression, β-NLL or CRPS (Gaussian)
a continuous outcome + you want robust intervalsRegression, pinball (quantile)
graded similarity scoresembedding fine-tune, cosine-MSE / CoSENT
ordered pairs / rankingsembedding fine-tune, MNRL / triplet

The four regression objectives are all proper scores — minimising them rewards a calibrated distribution, not merely an accurate mean. (MSE on the predicted mean is not proper for a distribution and is only a secondary point- accuracy diagnostic.)

  • β-NLL (default) — Seitzer’s variance-weighted Gaussian NLL. The plain joint μ,σ² NLL has a well-documented pathology: it down-weights high-error points by inflating their variance, starving the mean’s gradient and collapsing to overconfidence elsewhere. β-NLL re-weights each row’s NLL by a detached σ^{2β}, restoring the mean’s gradient and removing the collapse. β = 0.5 is the recommended default.
  • CRPS — the closed-form Gaussian continuous ranked probability score, the other collapse-resistant choice: strictly proper, in the outcome’s units, and far more stable under joint μ,σ² training than NLL.
  • Gaussian NLL — the classic mean-variance objective, provided for completeness and as the pathology baseline. Prefer β-NLL or CRPS.
  • Pinball — the quantile objective; trains each quantile to its level, with a non-crossing penalty that discourages crossing during training.

The same CRPS / NLL math headlines the calibration eval — one source of truth for the score, used as both the training loss and the eval metric.

Calibrated, not merely accurate

A regression head is not done until its coverage is verified. Two models can share a mean (identical MSE) yet one be badly miscalibrated. The calibration eval is the gate: the central interval should cover at ≈ its nominal level, and the head’s proper score (CRPS/NLL) should beat a constant-variance baseline. Verify coverage; never ship on NLL alone.

Aleatoric, not epistemic

A parametric Gaussian head models aleatoric (irreducible data) noise only. It does not know what it has not seen: off-distribution it can be confidently wrong. For uncertainty about the unseen, reach for the rest of the spectrum:

  • distribution-free coverage with no model assumption → conformal prediction;
  • amortized epistemic posteriors → a future neural-process head.

Do not read this head’s σ as epistemic.

The uncertainty evidence channel

A served distribution rides the uncertainty evidence channel (predicted_mean, predicted_std, quantiles, context_ref) — the same additive substrate as vector, inference, and conformal. When the prediction was conditioned on an assembled context set, the channel records which rows informed it in context_ref — data-driven provenance applied to prediction. Register it like any custom channel (see Declare a Custom Provenance Channel); the distribution columns then merge into the result and are SQL-reachable.

Train an In-Context Predictor (Amortized, Adapts Without Retraining)

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Context-Conditioned Prediction.

An in-context predictor meta-learns to turn a context set — a target’s retrieved neighbours and their outcomes — into a predictive distribution, in one forward pass with no gradient update at inference. Trained once over many tasks, it adapts to a new target’s neighbourhood the way a prior-fitted network does: condition on the context, emit the posterior, move on. It is the learned-aggregation point of the uncertainty spectrum, above the cheaper distribution-free and parametric options.

Three curated architectures, selected by config (never authored as tensor ops):

  • CNP — a DeepSets encoder that mean-pools the context, then a decoder MLP. The baseline; the learned twin of fixed pooling.
  • Attentive CNP (attncnp) — attention pooling over the context, so the target query weights the neighbours that matter. The payoff over fixed pooling, and the member that widens its uncertainty when its context is thin or unfamiliar (epistemic uncertainty).
  • TNP — a transformer over the (context ∪ target) token set; the strongest member, the prior-fitted-network point.

When to reach for it — the spectrum

The substrate offers three honest-uncertainty tools. Pick the cheapest one that covers your need:

ToolMechanismTrainingReach for it when
Conformaldistribution-free coverage wrapnone (calibrate)you need a guarantee over any model, audit-reproducible and deterministic
Distributional headlearned aleatoric distributionfine-tune a headcontinuous outcomes where a density or quantiles suffice
In-context predictormeta-learned posterior over a context setepisodic meta-trainfew-shot / adapt-per-target without retraining; you want epistemic uncertainty

The in-context predictor is the heaviest and most expressive. It does not replace the other two — an amortized posterior is sharp but not automatically calibrated, so it is wrapped by conformal for a coverage guarantee (below). Conformal remains the deterministic, audit-reproducible option; this predictor is what a continual, adapt-per-target setting reaches for.

Train

Training is episodic: each task (the distinct values of a task column — a cohort, a time window, a source partition) is split into a context set and held-out targets, and the target’s outcome is scored under a proper objective. Tasks — not points — are partitioned into train/test, so generalisation is measured on held-out tasks. The target is never in its own context (self-exclusion plus a same-task split), and a meta-dataset with too few tasks is rejected rather than meta-trained into memorisation.

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
extern crate jammi_encoders;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_ai::pipeline::context_predictor::{
    ContextArchitecture, ContextPredictorTrainConfig, GaussianObjective, PredictiveHead,
};
async fn ex(session: &Arc<InferenceSession>) -> jammi_db::error::Result<()> {
let spec = ContextPredictorTrainConfig {
    model_id: "patents-context-predictor".into(),
    architecture: ContextArchitecture::AttnCnp, // CNP | AttnCnp | TNP, by config
    key_column: "_row_id".into(),               // the per-row identity
    task_column: "cohort".into(),               // distinct values = the tasks
    value_column: "outcome".into(),             // the scalar y to regress
    context_k: 32,                              // retrieval / context size
    hidden_dim: 64,
    num_heads: 4,
    num_layers: 2,
    head: PredictiveHead::Gaussian {            // an S18 head + proper score
        objective: GaussianObjective::Crps,
    },
    epochs: 100,
    learning_rate: 0.005,
    grad_clip: 1.0,
    test_task_fraction: 0.2,                    // tasks held out for eval
    min_task_count: 4,                          // the meta-overfitting guard
    seed: 0,
};
// Training is a durable, lease-claimed job: `train_context_predictor` submits a
// queued job and returns a handle immediately; a worker claims it, re-samples
// the episodic meta-dataset from the spec, trains it, and registers the model.
let job = session.train_context_predictor("patents", &spec).await?;
job.wait().await?; // block until a worker drives the job to completion
let model_id = job.model_id(); // the spec's `model_id`, now registered
let _ = model_id;
Ok(()) }
}

The objective is one of the proper scores the distributional head uses — no new loss code. A PredictiveHead::Gaussian serves (mean, std); a PredictiveHead::Quantile serves a non-crossing set of quantile levels.

Predict — adapt to a new target, no retraining

Predicting assembles the target’s live context (the serving corpus, with the target excluded) and runs one in-context forward. There is no optimizer and no weight update — the adaptation lives entirely in the forward.

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_ai::pipeline::context_predictor::{ContextServeOptions, PredictedDistribution};
async fn ex(session: &Arc<InferenceSession>) -> jammi_db::error::Result<()> {
// Reload the trained predictor for inference, serving over a corpus.
// `ContextServeOptions::default()` is embedding-similarity (ANN) context with no
// serving split — pass an edge-bearing source to condition on declared edges.
let served = session
    .load_context_predictor(
        "patents-context-predictor",
        "patents",
        ContextServeOptions::default(),
    )
    .await?;

// One forward over the target's live context — no gradient update.
let dist = session
    .predict_with_context_predictor(&served, "US-7654321")
    .await?;
match dist {
    PredictedDistribution::Gaussian { mean, std } => {
        let _ = (mean, std);
    }
    PredictedDistribution::Quantile { levels } => {
        let _ = levels; // ascending (level, value) pairs
    }
}
Ok(()) }
}

The serving source need not be the training source — a predictor meta-trained on one corpus serves a target’s neighbourhood in another of the same shape (the inductive prior-fitted-network property). An optional split predicate scopes the serving context.

Wrap with conformal for a coverage guarantee

An amortized posterior is sharp but can be overconfident off its training tasks — its raw interval under-covers. Calibrate a conformal wrap on a held-out calibration set (tasks disjoint from training) and the served interval recovers its nominal coverage. A Gaussian head wraps with absolute-residual conformal over its mean; a quantile head wraps with CQR over its lower/upper quantiles.

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_ai::pipeline::context_predictor::{ConformalLevers, ServedContextPredictor};
async fn ex(
    session: &Arc<InferenceSession>,
    served: &ServedContextPredictor,
    held_out: &[(String, f64)], // (target_key, observed y), tasks disjoint from training
) -> jammi_db::error::Result<()> {
// Calibrate at 90% nominal coverage on the held-out set. `Marginal` is plain
// split-conformal; governance may instead supply a Mondrian cohort or weights.
let wrap = session
    .calibrate_context_predictor_conformal(served, held_out, 0.1, ConformalLevers::Marginal)
    .await?;

// Serving: turn a prediction into a coverage-guaranteed interval. The optional
// group is the test point's Mondrian cohort (`None` for a marginal wrap).
let dist = session
    .predict_with_context_predictor(served, "US-7654321")
    .await?;
let (lower, upper) = wrap.interval(&dist, None)?;
let _ = (lower, upper);
Ok(()) }
}

Epistemic uncertainty — and its honest caveat

The attentive members (attncnp, tnp) widen their predicted uncertainty when a target’s context is sparse or unfamiliar — the property a fixed distributional head lacks. This is primarily an attention property: a plain CNP’s mean-pool barely widens its σ as the context thins (it conditions on the context size rather than reweighting members), so reach for attncnp or tnp when epistemic widening matters. If you only need aleatoric noise on a continuous outcome, the cheaper distributional head is the right tool.

From Python

# Submits a durable training job and returns its handle; an embedded worker
# runs it. Block on `.wait()`, then read `.model_id` for the registered model.
job = db.train_context_predictor(
    "patents",
    key_column="_row_id",
    task_column="cohort",
    value_column="outcome",
    architecture="attncnp",   # "cnp" | "attncnp" | "tnp"
    output="gaussian",        # or "quantile" with levels=[0.1, 0.5, 0.9]
    objective="crps",         # "crps" | "nll" | "betanll"
    context_k=32,
)
job.wait()
model_id = job.model_id

dist = db.predict_with_context_predictor(
    model_id, source="patents", target_key="US-7654321"
)
# {"kind": "gaussian", "mean": ..., "std": ...}

Semantic Search

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Retrieval.

Perform ANN vector similarity search over embedding tables. Results include all original source columns, similarity scores, and evidence provenance.

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_db::config::JammiConfig;
async fn ex(config: JammiConfig) -> jammi_db::error::Result<()> {
use std::sync::Arc;

let session = Arc::new(InferenceSession::new(config).await?);

// Encode a query
let query = session.encode_text_query(
    "sentence-transformers/all-MiniLM-L6-v2",
    "quantum computing applications",
).await?;

// Search — returns top 10 results
let results = session.search("patents", query, 10, None, None).await?
    .run().await?;
Ok(()) }
}

Python

query_vec = db.encode_query(model="sentence-transformers/all-MiniLM-L6-v2", query="quantum computing applications")

results = db.search("patents", query=query_vec, k=10)  # pyarrow.Table
print(results.to_pandas())

What search returns

Results are RecordBatch / pyarrow.Table with:

  • All original source columns (e.g., id, title, abstract, year)
  • _row_id — the source key
  • _source_id — which source the row came from
  • similarity — cosine similarity score (1.0 = identical, 0.0 = orthogonal)
  • retrieved_byList<Utf8> provenance: which channels found this row
  • annotated_byList<Utf8> provenance: which channels added evidence post-retrieval

search carries the two knobs the bounded primitive owns directly: a SQL filter predicate over the hydrated results and a select column projection. In Python they are keyword arguments and search returns the table; in Rust they are methods on the fluent QueryBuilder (session.search(...) returns the builder, which also carries sort / limit / join / annotate and a .run()).

Filter and select

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &std::sync::Arc<InferenceSession>, query: Vec<f32>) -> jammi_db::error::Result<()> {
session.search("patents", query, 20, None, None).await?
    .filter("year > 2020")?
    .sort("similarity", true)?  // descending
    .limit(5)
    .select(&["_row_id".into(), "title".into(), "similarity".into()])?
    .run().await?;
Ok(()) }
}

Python

results = db.search(
    "patents", query=query_vec, k=20,
    filter="year > 2020",
    select=["_row_id", "title", "similarity"],
)  # pyarrow.Table

Compound query (join, annotate)

Joining other sources and running a model over the results is open composition, so in Python and over the wire it is SQL — db.sql(...), with the annotate(...) table function for inference. In Rust the same operations compose on the fluent builder:

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &std::sync::Arc<InferenceSession>, query: Vec<f32>) -> jammi_db::error::Result<()> {
let results = session.search("patents", query, 100, None, None).await?
    .filter("year > 2020")?
    .sort("similarity", true)?
    .limit(10)
    .select(&["title".into(), "similarity".into()])?
    .run().await?;
Ok(()) }
}

Python

results = db.sql("""
    SELECT title, vector
    FROM annotate('local:/models/all-MiniLM-L6-v2', 'text_embedding',
                  'patents.public.patents', 'id', 'abstract') AS a
    JOIN patents.public.patents AS p ON a._row_id = arrow_cast(p.id, 'Utf8')
    WHERE p.year > 2020
    LIMIT 10
""")

See Compound Retrieval and Inference over Flight SQL for the full compound surface — it runs the same SQL in-process or against a remote engine over Flight SQL.

Search automatically selects the best path:

  • ANN (fast) — when sidecar index files (.usearch + .rowmap + .manifest.json) exist and load successfully
  • Exact (brute-force) — fallback when sidecar files are missing or corrupt

The caller never knows the difference. Deleting sidecar files degrades performance but not correctness.

Embedding table resolution

When multiple embedding tables exist for a source, search uses the most recently created “ready” table. The resolution order:

  1. Explicit table name (if provided)
  2. Latest ready embedding table for the source (by created_at)
  3. Error if no embedding table exists

Search over gRPC (edge runtimes)

EmbeddingService exposes Search on the typed gRPC surface, so a process that reaches the engine over gRPC-web — an edge function that cannot speak Flight SQL’s bidirectional HTTP/2 — can run the same similarity search it already uses for AddSource, GenerateAudioEmbeddings, and EncodeAudioQuery. It is the same engine capability on an additional transport, not a second search path.

A SearchRequest carries the source, a k, an optional SQL filter (predicate pushdown), and an optional select column list. The query is a oneof:

  • query_vector — a precomputed vector. The usual flow is encode-then-search: call EncodeAudioQuery (or any client-side encoder) to get the vector, then feed it back as the query.
  • row_key — query-by-example. The engine resolves that row’s stored vector internally and ranks by it (“rows like this row”). The vector never crosses the wire.
// encode-then-search
embedding = EncodeAudioQuery{ model_id, audio_bytes }.embedding
hits      = Search{ source_id, query_vector: { values: embedding }, k: 10 }.hits

// query-by-example (no re-encode round-trip; vector stays in the engine)
hits      = Search{ source_id, row_key: "clip_1", k: 10 }.hits

Each SearchHit carries the key (the matched row’s key-column value), the score (similarity), and a columns map. columns is empty unless select is non-empty, in which case it holds the requested columns stringified — the engine always projects the key and score alongside them so a hit is fully formed. Heavy clients that want Arrow batches keep using Flight SQL; Search returns lightweight structured rows so an edge bundle needs no Arrow reader.

Building a similarity graph

build_neighbor_graph materializes the k-nearest-neighbour graph of an existing embedding table: for every row it finds the k most similar rows within the same table and writes one directed edge per pair as a queryable edge relation.

When to reach for it — and when not to

Use search for the neighbours of specific rows. Use build_neighbor_graph only when you need the whole edge set at once.

If you want “rows like this row”, call search (or search_by_id). It loads the index once per query and hydrates results on demand — that is the per-query path, and build_neighbor_graph is not for it.

build_neighbor_graph exists for global-structure work, where you consume all edges as a durable artifact:

  • near-duplicate detection / semantic dedup,
  • clustering and connected components,
  • entity resolution,
  • generating training pairs for graph-aware fine-tuning.

For those, looping search over every row would reopen the index per row, pay an n× hydration round-trip, and leave you with n detached result sets instead of one catalogued, tenant-scoped, queryable table. The edge table this verb writes closes that gap — and only for the global case.

The edge relation

The result is an ordinary result_table you can query, join, and federate like any other. One row per directed edge:

ColumnTypeMeaning
srcUtf8source node — the source key
dstUtf8neighbour node — the source key
rankInt321 = nearest, … k
similarityFloat321.0 - cosine_distance

src and dst are the embedding table’s keys, so the edge table joins directly to your source data — no detour through the embedding table.

Approximate by default; exact on demand

The default driver is index-assisted (HNSW). It is fast (n · log n) but its output is:

  • approximate — HNSW recall is below 100%, so some true neighbours are missed, and
  • non-deterministic — two builds can differ in the long tail of weak edges.

For dedup and clustering this is exactly what you want. When you need reproducible, auditable edges, pass exact = true: a brute-force, deterministic, complete pass (gated by a row-count ceiling, so it refuses to run on very large tables).

Example: near-duplicate detection

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::config::JammiConfig;
use jammi_ai::pipeline::neighbor_graph::BuildNeighborGraph;
use jammi_db::store::CachePolicy;
async fn ex(config: JammiConfig, model_id: &str) -> jammi_db::error::Result<()> {
let session = Arc::new(InferenceSession::new(config).await?);

// Embed the corpus first (any embedding model).
session
    .generate_text_embeddings("patents", model_id, &["abstract".into()], "id", CachePolicy::Bypass, None)
    .await?;

// Materialize the kNN graph, keeping only strong, reciprocal edges.
let (edges, _outcome) = session
    .build_neighbor_graph(
        "patents",
        None, // resolve the latest embedding table
        &BuildNeighborGraph {
            k: 5,
            min_similarity: Some(0.9), // near-duplicate threshold
            mutual: true,              // both rows agree they are neighbours
            ..Default::default()
        },
        CachePolicy::Bypass,
    )
    .await?;

// The edge table is now an ordinary relation. Group by src to list each
// row's near-duplicates, joined directly to the source on the key.
let dupes = session
    .sql(&format!(
        "SELECT e.src, e.dst, e.similarity, p.title \
         FROM \"jammi.{}\" e \
         JOIN patents.public.patents p ON p.id = e.src \
         ORDER BY e.similarity DESC",
        edges.table_name
    ))
    .await?;
Ok(()) }
}

Example: graph traversal stays in SQL

build_neighbor_graph transports adjacency and weight — it never walks the graph. Two-hop expansion, paths, and reachability are plain SQL over the edge relation, on every transport:

-- Two-hop neighbours via a self-join on the edge table.
SELECT a.src AS origin, b.dst AS two_hops_away
FROM "jammi.<edge_table>" a
JOIN "jammi.<edge_table>" b ON b.src = a.dst
WHERE a.src = '<some_key>';

For deeper walks, use a WITH RECURSIVE CTE. There is no traversal operator and no graph DSL — the edge table is just a relation.

Example: training-data prep for graph-aware fine-tuning

The edge table is the raw material for neighbour-contrastive embedding fine-tuning. Turn edges into (anchor, positive, negative) triplets — a neighbour is a positive, a non-neighbour a negative — and feed them to the existing triplet/contrastive fine-tune path. The walk policy, negative sampling, and objective are yours; Jammi supplies the edges and the loss.

Propagate Embeddings over a Graph (Decoupled GNN)

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Graph Signal Processing.

propagate_embeddings is the forward pass of a graph convolution, run as a data-plane operation. For every row of an embedding table it replaces the row’s vector with an aggregate of its k-hop neighbourhood — ÂᵏX — and writes the result as a new, ordinary embedding table (searchable, joinable, re-graphable).

This is the propagate half of a decoupled GNN. SGC showed the nonlinearities between graph-conv layers are removable: precompute the propagated features, then learn a simple head on top. APPNP added the teleport restart that keeps deep propagation from collapsing. Neither needs autograd, an architecture, or message-passing code — ÂᵏX is a graph join plus a grouped vector average, and that is all this verb is.

It composes with anything that consumes an embedding table: search the propagated vectors, evaluate them, build a neighbour graph over them, or fine-tune a head on them (the SGC/APPNP order — propagate first, then fine-tune).

When propagation helps — measure homophily first

Smoothing helps only when neighbours share signal.

Averaging a node with its neighbours denoises it when the graph is homophilous — neighbours tend to be the same kind of thing (papers cite papers on the same topic; co-purchased items share a category; KG entities of one type link to one type). Then the propagated vectors cluster tighter and downstream search / classification improves.

On a heterophilous graph — neighbours tend to differ — propagation mixes in opposing signal and is beaten by ignoring the graph entirely. This is not a silent failure mode to discover in production: measure it first. The per-edge-type homophily diagnostic reports, for each edge type, how often its endpoints share a label. Propagate over the homophilous types; for genuinely heterophilous structure the answer is learned attention (a later spec), not fixed averaging.

The default is over-smoothing-safe

Iterated averaging is exactly the operation that collapses every node into one indistinguishable point as the hop count grows (rank collapse). Three defaults keep that in check:

  • PageRank-decay weighting (DegreeNormalized + an α-teleport restart). Each hop re-mixes a share α of every node’s original embedding back in, so a node stays anchored to itself however deep you go (the APPNP fix). α defaults to 0.1.
  • Two hops by default, capped at three. Beyond that, more hops add collapse, not signal.
  • Self-loops (Ã = A + I). Every node aggregates over itself, so an isolated node propagates to its own embedding rather than vanishing, and the symmetric normalisation has no oscillating eigenmode.

Weightings

WeightingAggregationUse
DegreeNormalized (default)symmetric  = D̃^{-1/2}(A+I)D̃^{-1/2}, with the α-teleportthe safe default (SGC/APPNP)
Uniformrandom-walk mean D̃^{-1}Ã (each node = mean of itself + neighbours)unweighted graphs, simplest smoothing
EdgeSimilarityedge-weighted mean Σ(w·x)/Σwuse the edge weight as fixed attention (e.g. an S9 similarity edge); negative weights clamp to zero

Output: final block, or Jumping Knowledge

By default the output is the final propagated block X⁽ᴷ⁾, a d-dimensional embedding table in the input’s vector space.

PropagationOutput::JumpingKnowledge instead concatenates the per-hop blocks [X⁽⁰⁾ ‖ … ‖ X⁽ᴷ⁾], each L2-normalised before concat so the raw block does not dominate cosine search. This lets a downstream head pick the right receptive depth per node, but the output is (K+1)·d-dimensional and indexes in its own space — do not search it against the original d-dimensional vectors.

Example: propagate over a citation graph

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::config::JammiConfig;
use jammi_ai::pipeline::graph_neighbourhood::{EdgeDirection, EdgeSourceRef};
use jammi_ai::pipeline::graph_propagation::{PropagateRequest, PropagationWeighting};
use jammi_db::store::CachePolicy;
async fn ex(config: JammiConfig, model_id: &str) -> jammi_db::error::Result<()> {
let session = Arc::new(InferenceSession::new(config).await?);

// Embed the documents first (any embedding model).
session
    .generate_text_embeddings("papers", model_id, &["abstract".into()], "id", CachePolicy::Bypass, None)
    .await?;

// Propagate over a declared citation edge source (src/dst are the paper ids,
// which are the embedding keys). Citations are undirected for smoothing.
let (propagated, _outcome) = session
    .propagate_embeddings(
        &PropagateRequest::new(
            "papers",
            EdgeSourceRef::Registered {
                source_id: "citations".into(),
                src_column: "citing".into(),
                dst_column: "cited".into(),
                type_column: None,
                weight_column: None,
                as_of_column: None,
            },
        )
        .with_direction(EdgeDirection::Undirected)
        .with_weighting(PropagationWeighting::DegreeNormalized)
        .with_hops(2),
        CachePolicy::Bypass,
    )
    .await?;

// The result is an ordinary embedding table: search it, evaluate it, or graph
// it like any other.
let neighbours = session
    .sql(&format!(
        "SELECT _row_id FROM \"jammi.{}\" LIMIT 5",
        propagated.table_name
    ))
    .await?;
let _ = neighbours;
Ok(())
}
}

Propagating over an S9 similarity graph

You can also propagate over the similarity graph Jammi itself builds — pass its table name as the edge source:

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::config::JammiConfig;
use jammi_ai::pipeline::graph_neighbourhood::EdgeSourceRef;
use jammi_ai::pipeline::graph_propagation::PropagateRequest;
use jammi_db::store::CachePolicy;
async fn ex(config: JammiConfig, graph_table: &str) -> jammi_db::error::Result<()> {
let session = Arc::new(InferenceSession::new(config).await?);
let (_propagated, _outcome) = session
    .propagate_embeddings(
        &PropagateRequest::new(
            "items",
            EdgeSourceRef::NeighborGraph {
                table_name: graph_table.into(),
            },
        ),
        CachePolicy::Bypass,
    )
    .await?;
Ok(())
}
}

But note the caveat from graph-supervised fine-tuning: a similarity graph is k-NN under the base metric, so propagating over it mostly re-averages things the model already thinks are close. Declared edges (a citation network, a co-purchase log, a knowledge graph’s typed relations) carry structure the base metric does not already encode — that is where propagation adds signal.

Determinism

Propagation is deterministic: every fold, teleport, and weighted sum runs in f64 over a fixed (node, neighbour) order, so the output is byte-identical regardless of how many threads the engine runs, on a machine. It is the reproducible point on the structure-aware spectrum — fixed averaging, no learned parameters.

Bounds

The edge set is loaded under a row ceiling (PropagateRequest::max_rows); a graph larger than that is refused loudly rather than risking an out-of-memory pass. Whole-graph propagation beyond memory (chunking by the join) is future work.

Hybrid Retrieval: Lexical (BM25) + Reciprocal-Rank Fusion

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Retrieval.

Dense vector search finds rows that mean the same thing as your query; lexical (BM25) search finds rows that contain the same words. Each misses what the other catches — dense search fumbles rare identifiers and exact phrases, lexical search misses paraphrase. Hybrid retrieval runs both and fuses their rankings, and it is the standard production recipe because it reliably beats either alone.

Jammi ships two pieces for this:

  • A lexical sidecar (LexicalIndex) — a tantivy BM25 inverted index that rides beside a result table’s Parquet object, the lexical peer of the USearch ANN sidecar.
  • Reciprocal-rank fusion (rrf_fuse) — merges any number of ranked lists by rank, not score.

Fusing by rank is the whole point: BM25 scores and cosine similarities live on incompatible scales, so averaging them is meaningless. RRF never looks at a raw score — it sums 1 / (k_rrf + rank) across the lists a row appears in, so the fused order depends only on where a row landed in each list. The default k_rrf is 60 (Cormack et al., SIGIR 2009; robust across 40–80).

Build a lexical index

A LexicalIndex is built over (row_id, text) pairs — the text is whatever text columns of the row you want searchable, joined by the caller. The analyzer is configurable; English (lowercase + Porter stemming) is the default, and Raw (lowercase, no stemming) is the escape hatch for text the English stemmer would mangle (codes, identifiers, non-English).

#![allow(unused)]
fn main() {
extern crate jammi_ai;
extern crate jammi_db;
fn ex() -> jammi_db::error::Result<()> {
use jammi_ai::index::{Analyzer, LexicalIndex};

let rows = vec![
    ("doc-1", "a method for reducing turbine blade vibration"),
    ("doc-2", "an apparatus for cooling turbine engine blades"),
    ("doc-3", "a recipe for baking sourdough bread"),
];

let lexical = LexicalIndex::build(rows, Analyzer::English)?;
let hits = lexical.search("turbine engine", 10)?;
for hit in &hits {
    println!("{} bm25={:.3} rank={}", hit.row_id, hit.bm25_score, hit.rank);
}
Ok(()) }
}

Each LexicalHit carries the row_id, its raw bm25_score, and its 0-based rank — the rank is what fusion consumes.

Lifecycle and scope

The lexical sidecar’s lifecycle equals the ANN sidecar’s: it is built (and rebuilt) with the table. An immutable result table that is rebuilt produces a fresh sidecar; for a mutable-table source, re-ingesting the changed rows into a new index is the caller’s mode. Search applies no row-level filter — isolation is table-level, exactly as the ANN search path: resolve the table through the tenant-scoped catalog and hand the index only that table’s rows.

Fuse dense and lexical rankings

rrf_fuse takes a slice of ranked lists — each a best-first list of _row_ids — and returns one fused ranking. The dense list is the ANN search result; the lexical list is the LexicalIndex result. A third list (e.g. a graph-retrieval channel) fuses identically, with no special-casing.

#![allow(unused)]
fn main() {
extern crate jammi_ai;
use jammi_ai::query::{rrf_fuse, DEFAULT_K_RRF};

// Best-first row-id lists from each retriever.
let dense = vec!["doc-2", "doc-1", "doc-5"];   // ANN cosine order
let lexical = vec!["doc-1", "doc-2", "doc-9"]; // BM25 order

let fused = rrf_fuse(&[dense, lexical], DEFAULT_K_RRF);
for hit in &fused {
    println!("{} rrf={:.4}", hit.row_id, hit.rrf_score);
}
}

Rows that both retrievers surface rise to the top — cross-list agreement is exactly what RRF rewards. The output is fully deterministic: it is sorted by fused score descending, ties broken ascending by row_id, and it does not depend on the order you pass the lists in. A row repeated within a single list counts only once, at its best rank.

k_rrf is exposed, not forced. Larger values flatten the gap between adjacent ranks (a deep-but-agreed-upon row matters more); smaller values sharpen the reward for top-of-list placement. DEFAULT_K_RRF (60) is the recommended start.

Record the evidence

BM25 contributions ride the built-in bm25 evidence channel, the lexical peer of vector’s similarity. It declares two columns — bm25_score (Float32) and bm25_rank (Int64) — and a contribution is supplied to merge_channels exactly as the vector channel’s is, so a fused result carries both its dense and its lexical provenance side by side. See Declare a Custom Provenance Channel for the contribution mechanics; bm25 needs no registration — it is seeded with the catalog.

Assemble a Context Set for Conditioned Prediction

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Retrieval.

A prediction is often best made conditioned on a neighbourhood: not “what is the label of this row” in the abstract, but “given the k most similar labelled rows, what is the label of this row.” That neighbourhood is a context setC = {(xᵢ, yᵢ)} — and in a database it is not an abstraction to invent. It is a search joined to its labels.

assemble_context makes that first-class. It retrieves a target’s k nearest neighbours, pairs them with their outcome columns, and pools the neighbour vectors — permutation-invariantly — into one fixed-width context vector a predictor can condition on. It is the encode-and-aggregate half of a Neural Process; the decode half (a learned predictor over the representation) composes on top.

When this is the right tool

Reach for assemble_context when you want a reusable set representation of a target’s retrieved neighbourhood — a conditioning vector, a prototype/centroid, a bag-of-evidence summary. If you only need to aggregate one specific, already known set of rows once, that is a SQL GROUP BY you already have; this is the operator that turns any target’s retrieval into a representation, reproducibly, with the leakage guards a prediction context needs.

Assemble and encode

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_ai::pipeline::context_set::{ContextRequest, SetAggregator};
async fn ex(session: &std::sync::Arc<InferenceSession>, query: Vec<f32>) -> jammi_db::error::Result<()> {
let mut request = ContextRequest::new("patents", query, 10);
request.value_columns = vec!["category".into()];     // the labels carried per context row
request.aggregator = SetAggregator::Mean;             // mean | sum | max pooling

let context = session.assemble_context(&request).await?;

if let Some(vector) = &context.context_vector {
    // condition a predictor on `vector` + `context.context_size`
    let _ = (vector, context.context_size);
}
Ok(()) }
}

The result carries:

  • context_vector — the pooled ρ(Σ φ(xᵢ)) representation, or None for an empty context (no neighbour survived the guards below — treat as low-confidence / fall back to the prior, never as a one-element average).
  • context_size — the number of neighbours that entered the pool, carried separately so a decoder can use the count signal without it corrupting the pooled vector.
  • context_keys — the context members’ keys, in retrieval (descending similarity) order.
  • value_rows — the requested value_columns of each member, in the same order.

The leakage guards (on by default)

A target that retrieves itself as its own context trivially leaks the answer when a value column is the prediction target. So:

  • exclude_self defaults on. Set exclude_key to the target’s own row key (when the query vector belongs to a stored row) and that same-key neighbour is dropped before pooling; the retrieval over-fetches by one so a self-hit never shrinks the context below k.
  • split scopes the context to a train split. When the context feeds a training or evaluation target, pass e.g. split = Some("split = 'train'".into()) so the target’s own outcome stays held out — the same train/target line the evaluation harness enforces.
#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
use jammi_ai::pipeline::context_set::ContextRequest;
fn ex(query: Vec<f32>) {
let mut request = ContextRequest::new("patents", query, 10);
request.exclude_key = Some("US-1234567".into());   // drop the target's own row
request.split = Some("split = 'train'".into());     // context from the train split only
let _ = request;
}
}

Pooling: fixed, permutation-invariant, deterministic

The encoder pools through the engine’s vector-aggregation functions (vector_mean / vector_sum / vector_max) — the same element-wise aggregation the engine ships for grouped vector reduction. The pool is:

  • permutation-invariant in value — shuffling the context rows yields the same vector value, but not necessarily byte-identical bits: the pool runs through the engine’s vector-aggregation SQL UDAF (vector_mean/vector_sum/ vector_max), and f64 + is non-associative, so a different query partitioning can move the last bits even though the aggregate is commutative and associative at the value level;
  • stable run-to-run under a fixed execution plan on one host — the pooled vector is reproducible across runs, not asserted byte-identical across an arbitrary re-partitioning or a different host.

mean discards set size; sum encodes it; max is robust but lossy. None is universally right, which is why the aggregator is a knob and context_size is always carried alongside.

This is fixed pooling — the DeepSets / Conditional-Neural-Process expressiveness ceiling. It cannot model which context element matters; learned attention pooling (the AttnCNP point on the spectrum) is a separate, downstream capability, not a silent extension of this one.

Materialise for batch workflows

For batch pipelines, pool every target once and land the results as a normal embedding-shaped result table — searchable and joinable like any other embedding table, with its own sidecar ANN index:

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_ai::pipeline::context_set::{ContextRequest, MaterializedContext};
use jammi_db::store::CachePolicy;
async fn ex(session: &std::sync::Arc<InferenceSession>, rows: Vec<(String, Vec<f32>)>) -> jammi_db::error::Result<()> {
// The recipe carries *how* every row was pooled — the source, candidate set,
// value columns, aggregator, self-exclusion, and split — and names the source.
let recipe = ContextRequest::new("patents", vec![0.0_f32; 32], 5);
let (table, _outcome) = session
    .materialize_context(
        MaterializedContext {
            rows: &rows,
            dimensions: 32,
            recipe: &recipe,
            // These targets are `patents` rows, so their keys are `patents.id`.
            key_column: Some("id"),
        },
        CachePolicy::Bypass,
    )
    .await?;
// `table` is a normal embedding result table: search it, join it, index it.
println!("materialised context table: {}", table.table_name);
Ok(()) }
}

Each target’s key becomes the table’s _row_id; the pooled context vector becomes its vector. A materialised context set is a first-class member of the same table family every embedding table belongs to. The recipe materialize_context takes is the batch’s shared assembly definition (the source comes from it); the per-target query vectors are the inputs the recipe ran over, which is why they are the rows, not part of the recipe.

key_column names which column of the source those target keys came from, and is recorded as the table’s provenance: it is what lets a reader join patents.id = <context table>._row_id to get back to the rows the targets are. You declare it because the targets are yours — materialize_context receives only (key, vector) pairs and cannot see where the keys came from — but it does check the half it can: a column the source does not have is rejected rather than recorded. Pass None when the targets are free vectors that correspond to no stored row; the table is still searchable, but nothing joins it back to a source.

Condition a Prediction on Declared-Edge Context (Bring Your Own Graph)

assemble_context builds a target’s context set from its embedding-similar neighbours — search(target, k). That is the right neighbourhood when similarity is the relationship you want to condition on. But often the relationship that matters is one only your domain declares: the papers a paper cites, the products co-purchased with this one, the concepts an entity is an is-a of. Those edges carry structure an embedding metric does not reconstruct — and they are exactly the context a prediction is most defensible conditioned on.

A context set is a search. It is also a walk — and the walk you care about is the one only you can declare.

S16-G makes that first-class: a second context source for the same assemble_context. You register an edge relation, and a target’s context becomes its bounded, target-anchored declared-edge neighbourhood — pooled through the same permutation-invariant set encoder, under the same leakage and tenancy contracts, decoded by the same calibrated predictor. The engine transports adjacency; it never learns what an edge means.

When this is the right tool

Reach for declared-edge context when a target’s most informative neighbours are the ones your graph names, not the ones the base metric happens to place nearby: a citation classifier, a co-purchase recommender, a knowledge-graph-backed labeller, a transaction-graph scorer. Use ANN context when similarity is the signal; use Hybrid (below) when declared edges are the signal but the graph is sparse and you want similarity to densify it.

It is not a general graph-traversal verb. The gather is target-anchored and depth-/fan-out-bounded — never a free walk — which is what keeps it inside the tenant-scope guarantee.

The edge source

Any relation with two key columns is an edge source. Register it like any other source, then point the gather at it:

  • a similarity graph you already built (neighbor_graph), or
  • an external edge table you register (two key columns, optionally a type and a weight column).

The edge endpoints are row keys: a neighbour joins to its stored vector and its outcome columns by key, exactly as an ANN neighbour does.

Assemble declared-edge context

Rust

#![allow(unused)]
fn main() {
extern crate jammi_ai;
extern crate jammi_db;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_ai::pipeline::context_set::{ContextRequest, ContextSource};
use jammi_ai::pipeline::graph_neighbourhood::{EdgeGather, EdgeSourceRef};

async fn demo(session: Arc<InferenceSession>, target_vector: Vec<f32>) -> jammi_db::error::Result<()> {
// A registered edge relation: a node's declared neighbours.
let gather = EdgeGather::new(EdgeSourceRef::Registered {
    source_id: "citations".into(),
    src_column: "from_id".into(),
    dst_column: "to_id".into(),
    type_column: None,
    weight_column: None,
    as_of_column: None,
});

let mut request = ContextRequest::new("papers", target_vector, 0);
request.source = ContextSource::Edges(gather);
// The target's own row key is the gather anchor (and is excluded from its
// own context — the leakage guard).
request.exclude_key = Some("paper-42".into());
request.value_columns = vec!["topic".into()];

let context = session.assemble_context(&request).await?;
println!("conditioned on {} declared-edge neighbours", context.context_size);
Ok(())
}
}

Python

out = db.predict_with_context_predictor(
    model_id,
    source="papers",
    target_key="paper-42",
    edge_source="citations",     # a registered edge relation
    edge_src_column="from_id",
    edge_dst_column="to_id",
    edge_hops=1,                 # bounded depth (default 1)
    edge_fanout=25,              # sample at most 25 neighbours per node per hop
)
# The prediction carries how its context was assembled and which rows it used:
assert out["source"] == "edges"
print(out["context_ref"])        # the declared-edge member keys

The bounds — and why they are caps, not knobs to crank

  • hops (default 1, hard-capped). Beyond ~2–3 hops, neighbour pooling is Laplacian over-smoothing — the pooled vector washes out and loses signal. Depth is a precision/recall trade-off, not “more context is better.”
  • fanout (sample, don’t enumerate). A high-degree node’s neighbourhood is intractable to enumerate; fanout bounds the neighbours sampled per node per hop. The sample is seeded-deterministically from the target, so a gather reproduces byte-identically. None is exact (enumerate all) and uses no randomness; a truncated neighbourhood is reported, never silently dropped.
  • edge_types / min_weight. Filter which edges the walk follows. Types are a filter, never learned aggregation — a consumer wanting learned multi-relational message passing runs it in a graph library and registers the resulting node embeddings back as a source.

Hybrid: declared edges ∪ similarity

When the graph is sparsely connected, union the declared-edge neighbours with the ANN neighbours and pool once — declared edges as the signal, similarity as the densifier:

#![allow(unused)]
fn main() {
extern crate jammi_ai;
use jammi_ai::pipeline::context_set::{ContextSource, HybridMerge};
use jammi_ai::pipeline::graph_neighbourhood::{EdgeGather, EdgeSourceRef};
fn demo(gather: EdgeGather) -> ContextSource {
ContextSource::Hybrid {
    ann_k: 10,
    edges: gather,
    merge: HybridMerge::Union,
}
}
}

Check homophily before you trust it

A declared edge can be heterophilouscites may connect dissimilar items — and pooling over a heterophilous edge type degrades a prediction rather than helping it. Declared-edge context is an option on the spectrum, never unconditionally better than similarity. Before you rely on a type, read its homophily:

#![allow(unused)]
fn main() {
extern crate jammi_ai;
extern crate jammi_db;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_ai::pipeline::graph_neighbourhood::EdgeGather;
async fn demo(session: Arc<InferenceSession>, gather: &EdgeGather) -> jammi_db::error::Result<()> {
// Per-edge-type label-agreement over a labelled set: a type near the label's
// chance rate is heterophilous — pooling over it is unlikely to help.
let homophily = session
    .homophily_by_edge_type(gather, "papers", "id", "topic")
    .await?;
for (edge_type, agreement) in &homophily {
    println!("{edge_type}: {agreement:.2} label agreement");
}
Ok(())
}
}

The decoder also receives the target’s own (ego) features alongside the pooled neighbour vector, so it can down-weight an unhelpful neighbourhood rather than being forced to trust it.

Coverage over graph context

A graph-conditioned prediction is decoded and conformally wrapped exactly like an ANN-conditioned one, and it always serves. But graph correlation can break the exchangeability that marginal split-conformal coverage assumes — so the served prediction carries the assembly source fact and its member keys, and the coverage claim is attributed, never silently presented as a guarantee. Choosing whether to apply a group-conditional (Mondrian) or importance-weighted lever, and which cohort or weights to use, is a governance decision the serving layer applies but never makes. The engine surfaces the fact; governance chooses the lever.

Tenancy

An edge source is tenant-scoped like every other source. The gather runs inside the session’s tenant scope, so an edge whose endpoint belongs to another tenant is filtered before it is ever materialised — a declared edge cannot leak one tenant’s rows into another’s context.

Point-in-time joins: matching facts to the instant they were known

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Point-in-Time Correctness.

An as-of join matches each row of a spine relation to the at-most-one row of a facts relation that was valid as of the spine row’s instant, within the same group. It is the relational primitive for point-in-time correctness.

The problem it solves is leakage. If you assemble a table by joining each spine row to any fact in its group, you import facts stamped after the spine instant — information that was not yet known. A forward join imports the future. The as-of join takes only the fact valid at or before each instant, so every attached value reflects what was knowable then, and nothing later.

The engine exposes this as one verb, asof_join, over two registered relations. It carries only what every time-aware caller needs — an equality grouping, a temporal ordering key, a match direction, boundary inclusivity, an optional look-back tolerance, and a deterministic tie-break — and writes a result table that carries the same materialization manifest every other producer does.

The call

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::config::JammiConfig;
use jammi_ai::pipeline::asof::{
    AsofJoinSpecBuilder, AsofKey, Boundary, MatchDirection, TieBreak, Tolerance,
};
async fn ex(config: JammiConfig) -> jammi_db::error::Result<()> {
let session = Arc::new(InferenceSession::new(config).await?);

// `events` is the spine (its `t` column is the as-of instant); `facts` carries
// the values that were valid over time (its `vt` column is their validity time).
// `key` groups the match so a fact only matches an event in the same group.
let spec = AsofJoinSpecBuilder::new(
        AsofKey { by: vec!["key".into()], time: "t".into() },  // spine
        AsofKey { by: vec!["key".into()], time: "vt".into() }, // facts
    )
    .direction(MatchDirection::Backward)  // most recent at or before
    .boundary(Boundary::Inclusive)        // a fact stamped exactly at t matches
    .tolerance(Some(Tolerance::Duration(5_000_000))) // ignore facts >5s stale
    .tie_break(TieBreak::ByColumnDesc("seq".into())) // newest seq wins a tie
    .project(vec!["value".into()])        // attach this fact column
    .build();

let table = session.asof_join("events", "facts", &spec).await?;

// The result is an ordinary relation: every spine row, with the matched fact's
// `value` attached (null where nothing matched within the rules). Read it via SQL.
let _rows = session
    .sql(&format!(
        "SELECT t, value FROM \"{name}\".public.\"{name}\" ORDER BY t",
        name = table.table_name,
    ))
    .await?;
Ok(())
}
}

The spine is always fully preserved — an unmatched spine row keeps its columns and carries nulls for the fact columns. Dropping unmatched rows would silently shrink the result; a caller who wants inner semantics filters on a non-null fact column themselves.

The four pinned knobs

Each knob is the choice every engine gets subtly different. The engine pins them once, on the spec, never inferred — and each one changes the result.

Direction

MatchDirection::Backward (the default) takes the most recent fact at or before the instant — the only leakage-safe choice when the spine instant is a point you must not see past. Forward takes the first fact at or after (e.g. “the next scheduled event after each reading”). Nearest takes the smallest absolute distance, resolving equidistant candidates toward the past, and requires a numeric temporal key.

Boundary

Boundary::Inclusive (the default, <=) lets a fact stamped exactly at the instant match; Boundary::Exclusive (<) excludes it. Over identical inputs, the two differ exactly on the rows that have a fact coincident with the instant.

Tolerance

None (the default) looks back arbitrarily far. Some(Tolerance::Duration(µs)) for a temporal key, or Some(Tolerance::Steps(n)) for an integer key, discards a candidate farther than the limit — the spine row goes unmatched rather than matching a stale fact. The limit is measured relative to each spine instant.

Tie-break

When two facts share the matched instant within a group, the match is ambiguous. TieBreak::ByColumnDesc("seq") disambiguates by a secondary column, the maximal value winning (the transaction-time column). TieBreak::Error makes a true duplicate a loud AsofError::AmbiguousMatch rather than a silent, non-deterministic pick. With a tie-break in force the output is bit-reproducible.

The temporal key must be totally ordered

The temporal key on each side must be a totally-ordered Arrow type — any Timestamp(..), Date32/Date64, or a signed/unsigned integer. A float key is rejected: NaN has no total order, so “most recent at or before” would be undefined. The two sides’ temporal keys must share a type, and a null temporal value is never ordered — a null-time spine row is preserved with null facts, and a null-time fact is never a candidate.

One verb, many shapes

The same asof_join assembles a leakage-free labelled set keyed on past instants, matches each transaction to the value in effect when it occurred, and pairs a measurement with the reading valid at the time it was taken. The engine provides the as-of relational primitive and the determinism contract; what a caller assembles on top of it is theirs.

Enrich Results with Joins and Annotations

Search results can be enriched by joining with other data sources and annotating with additional model inference.

There are two surfaces for this, and they are deliberately different:

  • search is the bounded, jammi-defined primitive: nearest-neighbor top-k with optional filter/select, returning a table directly. Same call, same shape, embedded or remote.
  • Compound query — open, caller-shaped composition (join / filter / select and model inference over the results) — rides SQL. In Rust the fluent QueryBuilder (returned by session.search(...)) builds the same plan in-process; in Python and over the wire the surface is db.sql(...), where the annotate(...) table function runs a model over a relation. Both descend through the one inference operator, so an in-process query and a Flight-SQL query run the same plan node.

The fluent Rust builder tracks every enrichment step in the evidence provenance columns (retrieved_by / annotated_by).

Join with another source

Join search results with a registered source to add context columns (e.g., company name, category labels):

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::source::{FileFormat, SourceConnection, SourceType};
async fn ex(session: &Arc<InferenceSession>, query: Vec<f32>) -> jammi_db::error::Result<()> {
session.add_source("assignees", SourceType::File, SourceConnection {
    url: Some("file:///data/assignees.csv".into()),
    format: Some(FileFormat::Csv),
    ..Default::default()
}).await?;

let results = session.search("patents", query, 10, None, None).await?
    .join("assignees", "assignee_id=id", None).await?  // left join by default
    .run().await?;
// Results now include company_name, country from assignees
Ok(()) }
}

Python

In Python the compound query is SQL. search returns a pyarrow.Table directly; to join, run SQL that the engine plans (in-process for the embed wheel, over Flight SQL for a remote engine — same SQL either way):

db.add_source("assignees", path="/data/assignees.csv", format="csv")

results = db.sql("""
    SELECT p.title, a.company_name, a.country
    FROM patents.public.patents AS p
    JOIN assignees.public.assignees AS a ON p.assignee_id = a.id
""")
# Results now include company_name, country from assignees

In Rust, the fluent builder’s on parameter is "left_col=right_col" and the optional join type is "inner" or "left" (default).

Annotate with model inference

Run a model over search results to add new columns:

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::ModelTask;
async fn ex(session: &Arc<InferenceSession>, query: Vec<f32>) -> jammi_db::error::Result<()> {
let results = session.search("patents", query, 10, None, None).await?
    .annotate(
        "sentence-transformers/all-MiniLM-L6-v2",
        ModelTask::TextEmbedding,
        &["abstract".to_string()],
    ).await?
    .run().await?;
Ok(()) }
}

Python

annotate(model, task, relation, key_column, content_column, …) is a SQL table function: it runs the model over the named relation’s columns and returns the inference output (_row_id keyed from key_column, plus the task’s columns — e.g. vector). Join it back to the source on _row_id to enrich:

results = db.sql("""
    SELECT p.title, a.vector
    FROM annotate('sentence-transformers/all-MiniLM-L6-v2', 'text_embedding',
                  'patents.public.patents', 'id', 'abstract') AS a
    JOIN patents.public.patents AS p ON a._row_id = arrow_cast(p.id, 'Utf8')
""")

The same SQL — the same annotate function — runs in-process (embed wheel) or over the Flight SQL lane (remote engine, jammi-client), so compound retrieval + inference is one round-trip.

Evidence provenance

Every search result carries provenance tracking that records how each row was found and enriched:

Scenarioretrieved_byannotated_by
Plain search["vector"][]
Search + annotate["vector"]["inference"]

These are List<Utf8> columns — each row has its own list of contributing channels.

Composing everything

All operations compose freely:

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::ModelTask;
async fn ex(session: &Arc<InferenceSession>, query: Vec<f32>) -> jammi_db::error::Result<()> {
let results = session.search("patents", query, 100, None, None).await?
    .join("assignees", "assignee_id=id", None).await?
    .annotate("all-MiniLM-L6-v2", ModelTask::TextEmbedding, &["abstract".into()]).await?
    .filter("country = 'US'")?
    .sort("similarity", true)?
    .limit(10)
    .select(&["title".into(), "company_name".into(), "similarity".into()])?
    .run().await?;
Ok(()) }
}

Python

The compound query is SQL, so everything composes as JOIN / WHERE / ORDER BY / LIMIT / projection over annotate(...) and registered sources:

results = db.sql("""
    SELECT p.title, a.company_name, ann.vector
    FROM annotate('all-MiniLM-L6-v2', 'text_embedding',
                  'patents.public.patents', 'id', 'abstract') AS ann
    JOIN patents.public.patents  AS p ON ann._row_id = arrow_cast(p.id, 'Utf8')
    JOIN assignees.public.assignees AS a ON p.assignee_id = a.id
    WHERE a.country = 'US'
    LIMIT 10
""")

Each surface plans a DataFusion execution plan under the hood. No data is processed until the result is read.

Compound Retrieval and Inference over Flight SQL

search is the bounded primitive — nearest-neighbor top-k, returning a table directly. Compound query — joining sources, filtering, and running a model over a relation — is open, caller-shaped composition, so it rides SQL. The same SQL runs in-process on the embedded engine and over the Flight SQL lane against a remote engine; the annotate(...) table function makes model inference available inside that SQL on both.

This is what lets a remote caller do search → join → annotate in one round-trip, with the model running inside the engine — no per-row RPC, no bespoke compound-search verb.

The annotate table function

annotate(model, task, relation, key_column, content_column [, content_column…])

It runs model (a local:<path>, an HF repo id, or a fine-tuned id) for task over the named relation’s content_column(s), and returns the inference output: the prefix _row_id / _ordinal / _source / _model / _status / _error / _latency_ms — with _row_id carried from key_column and _ordinal a 0-based row counter in model emission order (so ORDER BY _row_id, _ordinal is deterministic even when a key repeats) — followed by the task’s columns (e.g. a vector FixedSizeList for an embedding task). Join it back to the source on _row_id to place inference columns alongside source columns.

Remote: jammi over Flight SQL

import jammi

db = jammi.connect("grpc://engine.internal:8081")  # behind your TLS terminator or on a trusted network

# Compound retrieval + inference in one Flight SQL round-trip:
table = db.sql("""
    SELECT p.title, a.vector
    FROM annotate('sentence-transformers/all-MiniLM-L6-v2', 'text_embedding',
                  'patents.public.patents', 'id', 'abstract') AS a
    JOIN patents.public.patents AS p ON a._row_id = arrow_cast(p.id, 'Utf8')
    WHERE p.year >= 2020
""")
# table is a pyarrow.Table

db.sql carries the connection’s tenant scope (the same jammi-session-id the typed gRPC verbs use), so SQL reads observe the same tenant as db.search.

Embedded: the same SQL, in-process

The embed wheel runs the identical SQL against its in-process DataFusion engine — the annotate function is registered on the same context:

import jammi

db = jammi.connect("file:///var/lib/jammi")
table = db.sql("""
    SELECT a._row_id, a.vector
    FROM annotate('local:/models/all-MiniLM-L6-v2', 'text_embedding',
                  'patents.public.patents', 'id', 'abstract') AS a
""")

Productionising from the embed wheel to the remote client changes only the target (file://grpc://) — the import jammi and the sql call are unchanged.

In-process Rust: the fluent builder

In Rust, session.search(source_id, vec, k, embedding_table, oversample) returns a QueryBuilder that composes the same operations as a fluent chain (the annotate node it builds is the very plan node the SQL table function builds):

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::ModelTask;
async fn ex(session: &Arc<InferenceSession>, query: Vec<f32>) -> jammi_db::error::Result<()> {
let results = session.search("patents", query, 10, None, None).await?
    .annotate("local:/models/all-MiniLM-L6-v2", ModelTask::TextEmbedding, &["abstract".into()]).await?
    .run().await?;
Ok(()) }
}

Notes

  • A WHERE over the annotated output runs above the inference node — the table function declares inference non-pushdown, since a model runs row-wise and a predicate can’t push below it. Filter the source (inside the relation a join scans) when you want to shrink the input the model sees.
  • The output schema is fixed at planning time; the embedding dimension is read by loading the model, which is then warm for execution.
  • Classification and NER ride the same prefix + task-column shape; pass their task string ('classification', 'ner') and the content column.

Declare a Custom Provenance Channel

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Provenance Channels.

Every row that flows through Jammi carries provenance — retrieved_by and annotated_by lists that record how the row was found and what was added after retrieval. Jammi ships two built-in channels — vector (declares similarity) and inference (declares inference_model, inference_task, inference_confidence) — but the catalog accepts any channel a consumer wants to register. Each channel declares the columns it contributes; the engine merges those columns into every result RecordBatch at query time.

This recipe walks through registering a third channel, scored_by, for a multi-stage retrieval pipeline where a federated reranker rescores the vector hits. The same shape applies to any non-built-in provenance signal: a citation graph, an attribution chain, a quality-grading pass.

Setup

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate arrow;
extern crate tokio;
use jammi_db::config::JammiConfig;
async fn ex(config: JammiConfig) -> jammi_db::error::Result<()> {
use std::sync::Arc;
use arrow::array::{ArrayRef, Float32Array, StringArray};
use jammi_ai::evidence::{merge_channels, ChannelContribution};
use jammi_ai::session::InferenceSession;
use jammi_db::catalog::channel_repo::{ChannelColumn, ChannelColumnType, ChannelSpec};
use jammi_db::ChannelId;
use jammi_db::source::{FileFormat, SourceConnection, SourceType};

let session = Arc::new(InferenceSession::new(config).await?);
session.add_source("patents", SourceType::File, SourceConnection {
    url: Some("file:///data/patents.parquet".into()),
    format: Some(FileFormat::Parquet),
    ..Default::default()
}).await?;
Ok(()) }
}

Python

import jammi

db = jammi.connect("file:///var/lib/jammi")
db.add_source("patents", path="/data/patents.parquet", format="parquet")

Declare the channel

Channel declarations are catalog rows. Each declared column has a name, an Arrow type, and an ordinal. The set is append-only — once ranker: Utf8 is declared, the engine refuses to redeclare it as Int32 or drop it.

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::catalog::channel_repo::{ChannelColumn, ChannelColumnType, ChannelSpec};
use jammi_db::ChannelId;
async fn ex(session: &Arc<InferenceSession>) -> jammi_db::error::Result<()> {
session.catalog().channels().register(&ChannelSpec {
    id: ChannelId::new("scored_by")?,
    priority: 3,
    columns: vec![
        ChannelColumn {
            name: "ranker".into(),
            data_type: ChannelColumnType::Utf8,
        },
        ChannelColumn {
            name: "rank_score".into(),
            data_type: ChannelColumnType::Float32,
        },
    ],
}).await?;
Ok(()) }
}

Python

db.register_channel(
    "scored_by",
    priority=3,
    columns=[("ranker", "Utf8"), ("rank_score", "Float32")],
)

priority controls the order columns appear in the merged output — vector (1) and inference (2) come first, then scored_by (3).

To add more columns to an already-registered channel:

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::catalog::channel_repo::{ChannelColumn, ChannelColumnType};
use jammi_db::ChannelId;
async fn ex(session: &Arc<InferenceSession>) -> jammi_db::error::Result<()> {
session.catalog().channels().add_columns(
    &ChannelId::new("scored_by")?,
    &[ChannelColumn { name: "scored_at".into(), data_type: ChannelColumnType::Utf8 }],
).await?;
Ok(()) }
}
db.add_channel_columns("scored_by", columns=[("scored_at", "Utf8")])

add_columns is append-only by construction. Trying to redeclare ranker with the same or a different type returns JammiError::ChannelCatalog(_).

Use the channel

Build a ChannelContribution for each batch your reranker produces. The arrays must align 1:1 with the channel’s declared columns (ranker first, rank_score second) and have the same length as the batch’s row count.

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate arrow;
extern crate tokio;
use std::sync::Arc;
use arrow::array::{ArrayRef, Float32Array, RecordBatch, StringArray};
use jammi_ai::evidence::{merge_channels, ChannelContribution};
use jammi_ai::session::InferenceSession;
use jammi_db::ChannelId;
fn rerank_scores(_batch: &RecordBatch) -> Vec<f32> { vec![] }
async fn ex(session: &Arc<InferenceSession>, batches: Vec<RecordBatch>) -> jammi_db::error::Result<()> {
let scored_by = ChannelId::new("scored_by")?;
let vector = ChannelId::new("vector")?;

let mut contributions = Vec::with_capacity(batches.len());
for batch in &batches {
    let n = batch.num_rows();
    let ranker: ArrayRef = Arc::new(StringArray::from(vec!["bm25"; n]));
    let rank_score: ArrayRef = Arc::new(Float32Array::from(rerank_scores(batch)));
    contributions.push(vec![ChannelContribution {
        channel: scored_by.clone(),
        columns: vec![ranker, rank_score],
    }]);
}

let merged = merge_channels(
    session.catalog(),
    &batches,
    &[vector.clone(), scored_by.clone()],
    &[vector, scored_by],   // retrieved_by
    &[],                     // annotated_by
    &contributions,
).await?;
Ok(()) }
}

Verify

The merged output schema includes the declared columns. Rows where the channel did not supply a value carry NULL.

Rust

#![allow(unused)]
fn main() {
extern crate arrow;
use arrow::array::RecordBatch;
fn ex(merged: Vec<RecordBatch>) {
let schema = merged[0].schema();
assert!(schema.field_with_name("ranker").is_ok());
assert!(schema.field_with_name("rank_score").is_ok());

for batch in &merged {
    let ranker = batch.column_by_name("ranker").unwrap();
    println!("first ranker: {:?}", ranker);
}
}
}

Python

From the SQL surface, the declared columns show up in any query that touches the result table — Python sees them as plain Arrow columns:

table = db.sql(
    "SELECT _row_id, similarity, ranker, rank_score FROM results LIMIT 3"
)
for row in table.to_pylist():
    print(row["ranker"], row["rank_score"])

What you cannot do

The channel declaration is append-only. Once scored_by ships with ranker: Utf8, you cannot:

  • Redeclare ranker as Int32add_columns rejects with JammiError::ChannelCatalog(ChannelCatalogError::ColumnConflict { … }), whose message is "channel 'scored_by': column 'ranker' was declared Utf8, cannot redeclare as Int32". From Python, the same call raises RuntimeError carrying the identical message:

    db.add_channel_columns("scored_by", columns=[("ranker", "Int32")])
    # RuntimeError: channel 'scored_by': column 'ranker' was declared Utf8, cannot redeclare as Int32
    
  • Add a second column with the same name — add_columns rejects with JammiError::ChannelCatalog(ChannelCatalogError::ColumnAlreadyDeclared { … }), whose message is "channel 'scored_by': column 'ranker' already declared".

  • Drop ranker from the channel — there is no drop_column method by design.

If a column needs to change shape, declare a new column under a new name and migrate consumers. This preserves byte-for-byte readability of any backing table or downstream artifact that already references the original column.

Fine-Tune for Your Domain

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Fine-Tuning Methods.

Train LoRA adapters on your data to improve embedding quality for your domain. The base model stays frozen — only a small projection layer is trained and saved.

Prepare training data

Create contrastive pairs with a similarity score:

text_a,text_b,score
"quantum error correction","superconducting qubit stabilization",0.88
"quantum error correction","medieval poetry analysis",0.08

High scores mean similar; low scores mean dissimilar.

Register the training data as a source:

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_db::source::{FileFormat, SourceConnection, SourceType};
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
session.add_source("training", SourceType::File, SourceConnection {
    url: Some("file:///data/training_pairs.csv".into()),
    format: Some(FileFormat::Csv),
    ..Default::default()
}).await?;
Ok(()) }
}

Python

db.add_source("training", path="/data/training_pairs.csv", format="csv")

Start a fine-tuning job

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
use jammi_ai::fine_tune::FineTuneMethod;
use jammi_db::ModelTask;

let job = session.fine_tune(
    "training",
    "sentence-transformers/all-MiniLM-L6-v2",
    &["text_a".into(), "text_b".into(), "score".into()],
    FineTuneMethod::Lora,
    ModelTask::TextEmbedding,
    None,  // default config
).await?;

println!("Job: {}", job.job_id);
job.wait().await?;
println!("Model: {}", job.model_id());
Ok(()) }
}

Python

job = db.fine_tune(
    source="training",
    base_model="sentence-transformers/all-MiniLM-L6-v2",
    columns=["text_a", "text_b", "score"],
    method="lora",
    task="embedding",
)

job.wait()
print(f"Model: {job.model_id}")

base_model accepts any model reference form, including a local checkpoint (local:/path, file:///path, or a bare filesystem path) — see Use a Local Model Checkpoint.

Custom configuration

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_ai::fine_tune::{FineTuneMethod, LrSchedule};
async fn ex(session: &InferenceSession, model: &str, columns: Vec<String>) -> jammi_db::error::Result<()> {
use jammi_ai::fine_tune::FineTuneConfig;
use jammi_db::ModelTask;

let config = FineTuneConfig {
    lora_rank: 4,
    learning_rate: 5e-4,
    epochs: 5,
    batch_size: 4,
    warmup_steps: 10,
    lr_schedule: LrSchedule::CosineDecay,
    early_stopping_patience: 2,
    validation_fraction: 0.2,
    gradient_accumulation_steps: 4,  // effective batch = 4 x 4 = 16
    ..Default::default()
};

let job = session.fine_tune(
    "training", model, &columns, FineTuneMethod::Lora, ModelTask::TextEmbedding, Some(config),
).await?;
Ok(()) }
}

Configuration reference

FieldDefaultDescription
lora_rank8Low-rank dimension
lora_alpha16.0Scaling factor
lora_dropout0.05Dropout probability
learning_rate2e-4Base learning rate
epochs3Training epochs
batch_size8Micro-batch size
max_seq_length512Max tokens per text
gradient_accumulation_steps1Steps before optimizer update
validation_fraction0.1Holdout fraction for early stopping
early_stopping_patience3Epochs without improvement before stopping
warmup_steps100Linear warmup from 0 to base LR
lr_scheduleCosineDecayDecay after warmup: Constant, CosineDecay, LinearDecay
embedding_lossautoCoSent (pairs+scores), Triplet, MultipleNegativesRanking
backbone_dtypef32Frozen-backbone dtype: f32, f16, or bf16 (bf16 requires CUDA). Applies only when target_modules is non-empty (encoder-adapters) — see Memory

Use the fine-tuned model

The fine-tuned model is automatically registered and can be used anywhere a model ID is accepted:

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_ai::fine_tune::training_job::TrainingJob;
use jammi_db::store::CachePolicy;
async fn ex(session: &InferenceSession, job: &TrainingJob) -> jammi_db::error::Result<()> {
let model_id = job.model_id();

let embedding = session.encode_text_query(model_id, "quantum computing").await?;
println!("query embedding has {} dims", embedding.len());
session.generate_text_embeddings("patents", model_id, &["abstract".into()], "id", CachePolicy::Bypass, None).await?;
Ok(()) }
}

Python

model_id = job.model_id

query_vec = db.encode_query(model=model_id, query="quantum computing")
db.generate_embeddings(source="patents", model=model_id, columns=["abstract"], key="id", modality="text")

Run metrics

job.metrics() (Python) returns the run summary recorded on the job — a dict with final_loss (the best value seen, on whichever metric early_stopping_metric monitored), early_stopping_metric ("train_loss" or "val_loss"), total_steps, and started_at/completed_at timestamps. It returns {} for a job that has not recorded anything yet (still queued, or running before its first stamp), and carries error_message instead when the job failed.

job.wait()
metrics = job.metrics()
print(f"final loss: {metrics['final_loss']} ({metrics['early_stopping_metric']})")

This is a run summary, not a per-epoch curve: the trainer computes and logs avg_train_loss/avg_val_loss at every epoch boundary but does not retain them past that boundary, so no per-epoch trajectory is available through this surface today.

How it works

text -> encoder (frozen) -> base embedding -> LoRA projection (trained) -> output
  1. The base encoder model (BERT, ModernBERT, etc.) is loaded and frozen
  2. A LoRA projection layer (identity + low-rank A/B matrices) is added after pooling
  3. For each batch: text is encoded, projected through LoRA, and loss is computed
  4. Only the A/B matrices receive gradients
  5. The adapter is saved as adapter.safetensors in the artifact directory

The training set a run reads

A fine-tune trains from a committed snapshot, not from your live source relation. It materialises its training set once as an immutable result table of kind TrainingSet — a Parquet artifact carrying a definition hash and a materialization manifest attestation — and reads that table back in one canonical full-tuple order (every projected column, in declared order, is part of the sort key, so identical tuples are identical rows and the read order is the committed order).

The definition hash folds the source query, the projected columns, the model task, the training format and the order rule — and nothing about how a run consumes the rows, so a run’s world size, batch size or validation split never enters the table’s identity.

Sharing one table across jobs takes more than a matching definition. A ready training set is reused only when its definition hash and every recorded input anchor match the request exactly, and an input anchored unpinned-at-an-instant never matches: an instant is not a reproducible id, so equal anchors would not prove equal rows. That is the engine’s standing reuse rule — the same (definition, input anchors) key the embedding and as-of producers are probed on — with no training-set exception. Which path ran is reported on the returned table rather than left to be inferred.

A registered source relation exposes no version or digest surface to pin, so a fine-tune anchors it unpinned-at-an-instant and materialises its own training set on every run: two runs over the same query and columns leave two tables. Changing the source’s rows therefore never serves a stale training set — the earlier table cannot be served at all. Sharing rests on the rule’s other arm, an input pinned by content digest, as a result table is; a fine-tune source does not reach that arm, because a source resolves as a registered relation and a result table does not resolve as one. The manifest keeps the anchors either way, so a staleness check over the table answers the same honest Undecidable it gives for every unpinned input.

A projection that yields no rows is refused with a typed EmptyTrainingSet error before any catalog row or byte exists, so a run never trains on an empty set in silence.

A graph fine-tune does not go through this table: its sampled pairs are sampled in memory and trained on directly. Giving the graph arm a TrainingSet table of its own is tracked at https://github.com/f-inverse/jammi-ai/issues/538.

Model-level cache reuse (cache = Use) is not yet supported

fine_tune (the column-source kind) and fine_tune_graph both accept the same opt-in cache dial the compute verbs (generate_embeddings, infer, …) carry, but neither honors cache="use". It is refused, typed (jammi.errors.InvalidArgument on both transports): model-level cache reuse — binding a fine-tune job to an earlier run’s already-published model instead of training — is not yet supported (https://github.com/f-inverse/jammi-ai/issues/562). cache="bypass" (the default, or omitting cache entirely) is unaffected on either kind: a fine-tune job always trains.

Python

job = db.fine_tune(
    source="training",
    base_model="sentence-transformers/all-MiniLM-L6-v2",
    columns=["text_a", "text_b", "score"],
    method="lora",
    task="embedding",
)
result = job.wait()
print(result["cache_outcome"])  # always "computed"
print(f"Model: {result['model_id']}")

Rust

The embedded surface’s cache dial lives on jammi_wire::request::FineTuneRequest (submitted through InferenceSession::submit_fine_tune), not on the loose InferenceSession::fine_tune/fine_tune_graph methods shown above, which always train (cache: CachePolicy::Bypass, unconditionally). The remote jammi_client::DataClient::submit_fine_tune carries the same field, and Use is refused there too. Neither Rust surface returns cache_outcome from TrainingJob::wait() — only model_id() is exposed there.

A stray cache key found under graph_fine_tune in a persisted jobs.spec row (the row is engine-written from an already-decoded spec, so this only arises from a hand-edited row) is silently dropped at deserialize rather than refused, since the type has nowhere to put it; making an unexpected key a hard error across the persisted-row format is a separate reshape (https://github.com/f-inverse/jammi-ai/issues/548).

Encoder-adapters fine-tuning (PEFT-style adapter injection)

The default flow above trains a single low-rank projection head sitting outside the frozen encoder. For higher capacity at the same parameter budget, Jammi also supports encoder adapters — LoRA injected into named linear layers inside the encoder stack, matching the PEFT convention.

Switch to encoder adapters by populating target_modules on FineTuneConfig:

#![allow(unused)]
fn main() {
extern crate jammi_ai;
use jammi_ai::fine_tune::FineTuneConfig;
fn make() -> FineTuneConfig {
let config = FineTuneConfig {
    lora_rank: 8,
    lora_alpha: 16.0,
    // Inject LoRA into BERT's attention query and value projections.
    target_modules: vec!["query".to_string(), "value".to_string()],
    ..Default::default()
};
config }
}
job = db.fine_tune(
    source="training",
    base_model="sentence-transformers/all-MiniLM-L6-v2",
    columns=["text_a", "text_b", "score"],
    method="lora",
    task="text_embedding",
    target_modules=["query", "value"],
)

Target-module conventions

Pick target_modules per the architecture you’re fine-tuning:

ArchitectureTaskCommon target_modules
BERT / RoBERTa / CamemBERT / XLM-RoBERTatext["query", "value"] (recommended) or ["query", "key", "value", "dense"]
DistilBERTtext["q_lin", "v_lin"] or ["q_lin", "k_lin", "v_lin", "out_lin"]
ModernBERTtext["Wqkv", "Wo"] (fused QKV + output)
OpenCLIP text towertext_embedding["in_proj", "out_proj"] (attention) or ["in_proj", "out_proj", "c_fc", "c_proj"]
OpenCLIP vision towerimage_embeddingthe same four names
HTSAT-CLAP audio toweraudio_embedding["query", "value"] or ["query", "key", "value", "attention_output", "intermediate_dense", "output_dense"]; plus ["reduction"] (patch-merging) and ["linear1", "linear2"] (projection head)
Any encoderany["all-linear"] — every linear layer gets an adapter (largest capacity)

Names match the trailing module-name segment in the HuggingFace weight layout. Suffix matching is the rule, so "query" matches "attention.self.query".

in_proj on the two OpenCLIP towers is the fused QKV projection — one site covering query, key and value, the way Wqkv does on ModernBERT.

A target_modules list that matches nothing on the selected tower fails the job with an error naming that tower’s real site names, rather than training an adapter with zero parameters.

Fine-tuning an image or audio tower

The tower is selected by the job’s task, not by a separate flag: an image_embedding job on an OpenCLIP checkpoint fine-tunes its vision tower, a text_embedding job on the same checkpoint fine-tunes its text tower, and an audio_embedding job on an HF-CLAP checkpoint fine-tunes its HTSAT audio tower. A task the base checkpoint has no tower for is refused before training starts, with a message naming the towers it does have.

Media jobs read triplets of encoded bytes — three binary columns anchor, positive, negative holding whole files (PNG/JPEG/… for images, WAV/FLAC/ MP3/Ogg for audio). The modality comes from the declared task and is never sniffed from the bytes, so passing an image corpus to an audio_embedding job is a decoding error, not a silently mis-encoded run. The three groups are encoded as one joined forward pass and then split.

job = db.fine_tune(
    source="image_triplets",
    base_model="local:/models/open_clip_vit_b32",
    columns=["anchor", "positive", "negative"],
    method="lora",
    task="image_embedding",
    target_modules=["in_proj", "out_proj"],
)

What makes a blob a “positive” — an augmentation of the anchor, a co-occurring item — is your data’s concern; the triplet loss only separates whatever pairs you supply.

Layer ranges and per-module ranks

Two optional refinements:

  • layers_to_transform — restrict injection to specific 0-based layer indices. None (default) applies to every layer.
  • rank_pattern — override lora_rank for individual modules. Keys are substring matches against the module name; values are the override rank.

layers_to_transform indexes the first numbered segment of the weight name, matching PEFT’s own rule. On the BERT family and the two OpenCLIP towers that is the transformer layer. On the HTSAT audio tower, whose blocks are named layers.{stage}.blocks.{block}, it is the stage index. A site that sits in no numbered unit at all — the CLAP audio projection head’s linear1/linear2 — is excluded whenever layers_to_transform is set, again matching PEFT: a restriction to specific layers cannot be satisfied by a module that belongs to no layer.

#![allow(unused)]
fn main() {
extern crate jammi_ai;
use jammi_ai::fine_tune::FineTuneConfig;
fn make() -> FineTuneConfig {
let mut rank_pattern = std::collections::HashMap::new();
rank_pattern.insert("query".to_string(), 16);  // higher capacity on Q
rank_pattern.insert("value".to_string(), 4);   // lower on V

let config = FineTuneConfig {
    lora_rank: 8,                                     // default rank
    target_modules: vec!["query".into(), "value".into()],
    layers_to_transform: Some(vec![6, 7, 8, 9, 10, 11]), // top half only
    rank_pattern,
    ..Default::default()
};
config }
}

On-disk artifact

Every fine-tuned model writes adapter.safetensors plus an adapter_config.json whose adapter_type tag discriminates between the two adapter shapes Jammi produces.

Encoder-adapters example:

{
  "adapter_type": "encoder_adapters",
  "model_type": "bert",
  "lora_rank": 8,
  "lora_alpha": 16.0,
  "use_rslora": false,
  "target_modules": ["query", "value"],
  "layers_to_transform": [6, 7, 8, 9, 10, 11],
  "rank_pattern": {"query": 16, "value": 4},
  "backbone_dtype": "f32"
}

Projection-head example:

{
  "adapter_type": "projection_head",
  "lora_rank": 8,
  "lora_alpha": 16.0,
  "head_layers": ["projection"]
}

model_type records the base architecture the adapter was trained on — one of bert, distilbert, modernbert, open_clip (an OpenCLIP checkpoint, which ships no model_type field of its own) or clap_audio_model.

A checkpoint that holds more than one tower carries one extra key, tower, naming which one the adapter installs on:

{
  "adapter_type": "encoder_adapters",
  "model_type": "open_clip",
  "lora_rank": 8,
  "lora_alpha": 16.0,
  "use_rslora": false,
  "target_modules": ["in_proj", "out_proj"],
  "layers_to_transform": null,
  "rank_pattern": {},
  "backbone_dtype": "f32",
  "tower": "vision"
}

The key is written only when it applies: a single-tower adapter’s adapter_config.json carries no tower key at all.

The Candle inference backend reads adapter_config.json on model load and dispatches on adapter_type: encoder_adapters rebuilds the encoder with frozen backbone weights plus the LoRA A/B from adapter.safetensors; projection_head loads the saved projection weights as a LoraLinear applied after pooling.

Before any of that, the backend checks the adapter and the base agree on architecture family: an open_clip adapter on a CLAP base, a clap_audio_model adapter on a BERT base, or a tower the base checkpoint does not have, is refused with a typed error rather than loaded onto whatever the base happens to be. On an OpenCLIP base the adapted tower is rebuilt with the adapter’s weights and the sibling tower is rebuilt frozen at the same backbone precision — a fine-tuned model has one identity and one precision.

When to use each

  • Projection head — fastest training, smallest artifact, lowest memory. The default when target_modules is empty. Best for adapting embedding direction without changing per-token attention behaviour.
  • Encoder adapters — higher representational ceiling per adapter parameter; required if the task needs to reshape attention behaviour (e.g. a domain where the base attention pattern mismatches the query distribution). Costs a slightly slower forward pass since the LoRA path runs per layer.

QLoRA (encoder adapters over a quantized base)

base_model accepts a GGUF checkpoint (see Quantized (GGUF) checkpoints) the same way it accepts a safetensors checkpoint. When the resolved base is model.gguf, an encoder-adapters job trains its LoRA A/B matrices over the frozen quantized backbone automatically — the base artifact selects this, not a separate flag or config field. The quantized weights themselves are never trained (LoRA never updates a frozen base, quantized or dense); only the low-rank adapters receive gradients, exactly as with a dense base.

Training safety

  • Divergence detection: if loss is NaN or >100 for 3 consecutive batches, the job fails with a clear error
  • Early stopping: training stops when validation loss doesn’t improve for patience epochs, best checkpoint weights are restored
  • Checkpoints: saved at ~10% intervals for crash recovery
  • Multi-host runs (world_size > 1): a rank that aborts, drops its stream, or stays silent past [worker] rank_timeout_secs retires the whole attempt — no partial model is ever published and nothing terminal is written; the job is requeued from its last epoch checkpoint and the retry costs one attempt, unless the rank’s host was draining (a rolling restart), which costs none. A job whose ranks keep failing fails once its attempts are exhausted, never retries forever.

Memory

Training memory is dominated by the frozen backbone’s weights and activations, scaled by batch_size and max_seq_length. backbone_dtype defaults to f32 for numerical conservatism — every job runs the backbone at full precision unless you opt into a lower-precision dtype; the trained LoRA A/B matrices always stay f32 regardless of backbone_dtype, for numerical stability.

backbone_dtype only takes effect on the encoder-adapters arm (target_modules non-empty) — the projection-head arm (the default, empty target_modules) never re-dtypes the frozen backbone, so bf16 is not an available remedy there.

This guidance applies to the fine-tune training kinds (embedding and classification training), and only when the failure’s own error text carries a recognized out-of-memory spelling. Two cases it does NOT cover: a host OOM-kill that terminates the worker process outright leaves no error message at all to classify — the job is picked up by lease reclaim instead, not this guidance; and a ContextPredictor training run carries no OOM guidance at all (it doesn’t route through this classifier).

When it applies, the job’s terminal error is rewritten to name the exact batch_size, max_seq_length, and (on the encoder-adapters arm) backbone_dtype it ran with, and suggests remedies in the order they’re cheapest to try:

  • Encoder adapters: (1) backbone_dtype: bf16 — substantially reduces memory on this arm. Requires a CUDA device; a bf16 backbone on a non-CUDA device is refused before training starts, rather than silently falling back to f32. (2) A smaller batch_size, or trade batch size for gradient_accumulation_steps to hold the same effective batch size while shrinking the per-step activation memory. (3) A smaller max_seq_length.
  • Projection head (default): backbone_dtype does not apply and is omitted from the message — the message says so outright. (1) A smaller batch_size, or trade batch size for gradient_accumulation_steps. (2) A smaller max_seq_length.

For a fine-tune job whose failure was classified this way, jammi jobs status (and the Python job.status()) surfaces the rewritten message directly, so you don’t need to read raw driver output to find the fix.

Fine-Tune from a Graph (Graph-Supervised)

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Representation Learning on Graphs.

Fine-tune embeddings so that graph-neighbours are close in embedding space. This is node2vec / DeepWalk realised as Jammi config: it samples a graph into contrastive (anchor, positive, [hard_negative]) pairs and feeds them through the existing fine-tune trainer. It authors no GNN — no message passing, no new loss — it is a new training-data shape (TrainingFormat::Graph) that drives the same in-batch-negative (MNRL) / triplet objective as Fine-Tune for Your Domain.

Use it when your supervision is a graph rather than hand-built pairs: a hierarchy, a crosswalk, a citation network, a set of coder-confirmed matches, or the neighbour graph Jammi itself builds.

The load-bearing caveat: where the signal comes from

Declared edges teach; similarity edges echo.

If you train on S9-similarity edges (the neighbour graph, which is k-NN under the base embedding metric), the walk-positives are mostly “things the model already thinks are close” — so fine-tuning largely re-learns the base metric. That is a degenerate feedback loop with little new signal.

Genuine gain comes from declared / external edges — structure the base metric does not already encode:

  • a hierarchy (parent/child categories),
  • a crosswalk (version-A code ↔ version-B code),
  • a citation / reference network,
  • coder-confirmed pairs.

Tag your edges with their provenance. Similarity edges are an acceptable weak bootstrap (e.g. to expand a sparse declared graph), but never the sole supervision. The sampler tracks provenance and can report whether any declared edge is present.

Prepare the graph

Two sources: node text (what the encoder embeds) and edges.

nodes.csv — every node must be text-bearing (the encoder needs text; pure-vector nodes are out of scope here):

id,text
c01,"acute myocardial infarction, initial"
c02,"acute myocardial infarction, subsequent"
c03,"benign essential hypertension"

edges.csv — directed edges; endpoints join to id:

src,dst
c01,c02
c02,c01

Register both as sources:

Python

db.add_source("nodes", path="/data/nodes.csv", format="csv")
db.add_source("edges", path="/data/edges.csv", format="csv")

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
use jammi_ai::session::InferenceSession;
use jammi_db::source::{FileFormat, SourceConnection, SourceType};
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
for name in ["nodes", "edges"] {
    session.add_source(name, SourceType::File, SourceConnection {
        url: Some(format!("file:///data/{name}.csv")),
        format: Some(FileFormat::Csv),
        ..Default::default()
    }).await?;
}
Ok(()) }
}

Run the graph fine-tune

The sampler runs biased random walks (node2vec) over the edges: from each node it walks walk_length (L) steps, biased by the return parameter p and the in-out parameter q, and treats co-walked nodes as positives. L > 1 captures higher-order / community structure — L = 1 is the degenerate 1-hop case. Negatives are in-batch (every other pair’s positive) plus structure-mined hard negatives drawn from outside the anchor’s exclude_hops-hop neighbourhood (the false-negative guard — a node inside that radius is likely a missing edge, i.e. a true positive).

Python

job = db.fine_tune_graph(
    node_source="nodes", id_column="id", text_column="text",
    edge_source="edges", src_column="src", dst_column="dst",
    base_model="local:/models/tiny_bert",
    edge_provenance="declared",   # "declared" teaches; "similarity" echoes
    walk_length=4, walks_per_node=2, return_p=1.0, in_out_q=1.0,
    graph_hard_negatives=1, exclude_hops=1, min_negatives=1,
    embedding_loss="mnrl",        # in-batch negatives (default); or "triplet"
    epochs=3, batch_size=8,
)
job.wait()

Rust

#![allow(unused)]
fn main() {
extern crate jammi_ai;
extern crate jammi_db;
use jammi_ai::session::InferenceSession;
use jammi_ai::fine_tune::FineTuneConfig;
use jammi_ai::fine_tune::graph_sampler::{EdgeProvenance, GraphFineTuneSources, GraphSampleConfig};
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
let sources = GraphFineTuneSources {
    node_source: "nodes".into(), id_column: "id".into(), text_column: "text".into(),
    edge_source: "edges".into(), src_column: "src".into(), dst_column: "dst".into(),
    // Declared edges carry signal the base metric does not already encode.
    provenance: EdgeProvenance::Declared,
};
let sample = GraphSampleConfig {
    walk_length: 4, walks_per_node: 2, return_p: 1.0, in_out_q: 1.0,
    hard_negatives: 1, exclude_hops: 1, min_negatives: 1, seed: 0,
};
let job = session
    .fine_tune_graph(&sources, "local:/models/tiny_bert", sample, Some(FineTuneConfig::default()))
    .await?;
job.wait().await?;
Ok(()) }
}

The output is a fine-tuned model; regenerate embeddings with it and they encode the graph’s structure (build_neighbor_graph, search, and propagation all benefit).

Tuning knobs

KnobEffect
walk_length (L)How far a positive can be. 1 = 1-hop only; >1 = community structure.
return_p (p)Large p discourages backtracking.
in_out_q (q)q < 1 explores outward (DFS-like); q > 1 stays local (BFS-like).
graph_hard_negativesStructure-mined hard negatives per pair. 0 = in-batch only.
exclude_hopsHops of the anchor’s neighbourhood excluded from its negatives (false-negative guard).
min_negativesMinimum negative pool — guards against contrastive collapse on a tiny graph.

Compose with propagation

Both graph fine-tune and embedding propagation encode homophily; stacking them naively double-counts the same smoothing. The recommended order is propagate first, then fine-tune the head (the SGC/APPNP decoupling) — not two independent smoothing passes.

Did it work? The circularity check

To confirm declared edges actually helped (and that you did not just re-learn the base metric), evaluate on a held-out golden set — see Did Structure Help? A Graph-ML Evaluation Recipe:

  1. Build two supervision graphs over the same nodes — one from declared edges, one from S9-similarity edges.
  2. fine_tune_graph each; hold out a golden relevance set.
  3. eval_embeddings the base model vs each fine-tune, with a paired significance test.
  4. Expect the declared-edge model to beat the base significantly, and the similarity-edge model’s gain to be near-zero — the degenerate feedback loop, measured rather than assumed.

Evaluate and Compare Models

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Evaluation & Provenance Channels.

Measure embedding quality and classification accuracy against golden datasets. Results are recorded in the catalog for tracking over time.

Prepare a golden dataset

A golden dataset is any registered source with the right columns. No special format required.

Retrieval golden set

query_id,query_text,relevant_id
q1,quantum computing applications,1
q1,quantum computing applications,4
q2,machine learning for science,2
ColumnTypeRequired
query_idUtf8yes
query_textUtf8yes
relevant_idUtf8 or Intyes
relevance_gradeInt32no (default: 1 = binary)

Register it as a source:

db.add_source("golden", path="/data/golden_relevance.csv", format="csv")

Evaluate embedding quality

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
let report = session.eval_embeddings(
    "patents",
    None,                                // use latest embedding table
    "golden.public.golden_relevance",    // golden dataset
    10,                                  // k for recall@k, precision@k
    &std::collections::HashMap::new(),   // no cohort tags
).await?;

println!("recall@10:    {}", report.aggregate.recall_at_k);
println!("precision@10: {}", report.aggregate.precision_at_k);
println!("MRR:          {}", report.aggregate.mrr);
println!("nDCG:         {}", report.aggregate.ndcg);
Ok(()) }
}

Python

metrics = db.eval_embeddings(
    source="patents",
    golden_source="golden.public.golden_relevance",
    k=10,
)

agg = metrics["aggregate"]
print(f"recall@10:    {agg['recall_at_k']:.3f}")
print(f"precision@10: {agg['precision_at_k']:.3f}")
print(f"MRR:          {agg['mrr']:.3f}")
print(f"nDCG:         {agg['ndcg']:.3f}")

Per-query drill-down

The report also carries a per_query array — one record per golden-set query, in golden order. This is what sample-based statistical rules (Welch’s t, Mann-Whitney U) consume at gate time.

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
let report = session.eval_embeddings("patents", None, "golden.public.golden_relevance", 10, &std::collections::HashMap::new()).await?;
for record in &report.per_query {
    println!("{}: recall={:.3} ndcg={:.3}",
        record.query_id, record.metrics.recall, record.metrics.ndcg);
}
Ok(()) }
}
for record in metrics["per_query"]:
    m = record["metrics"]
    print(f"{record['query_id']}: recall={m['recall']:.3f} ndcg={m['ndcg']:.3f}")

Retrieval metrics

MetricWhat it measures
recall_at_kFraction of relevant docs found in top-k
precision_at_kFraction of top-k that are relevant
mrrReciprocal rank of the first relevant result
ndcgNormalized discounted cumulative gain (uses graded relevance if provided)

All metrics are in [0, 1]. Higher is better.

Compare models (A/B)

Compare a base model against a fine-tuned model:

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession, base_table: String, finetuned_table: String) -> jammi_db::error::Result<()> {
let comparison = session.eval_compare(
    &[base_table.clone(), finetuned_table.clone()],
    "patents",
    "golden.public.golden_relevance",
    10,
).await?;

// The first entry is the baseline (`delta: None`); every subsequent entry
// carries a delta against it.
for entry in comparison.per_table.iter().skip(1) {
    let delta = entry.delta.as_ref().expect("non-baseline entries carry a delta");
    println!(
        "{}: recall@10 delta {:+.3} ({:+.1}%)",
        entry.table_name,
        delta.recall_at_k.absolute,
        delta.recall_at_k.relative * 100.0,
    );
}
Ok(()) }
}

Python

comparison = db.eval_compare(
    embedding_tables=[base_table, finetuned_table],
    source="patents",
    golden_source="golden.public.golden_relevance",
    k=10,
)
# `per_table[0]` is the baseline (`delta` is None); subsequent entries
# carry a `delta` dict keyed by metric name (recall_at_k, precision_at_k,
# mrr, ndcg) with `absolute` and `relative` sub-keys.
for entry in comparison["per_table"][1:]:
    d = entry["delta"]["recall_at_k"]
    print(f"{entry['table_name']}: recall@10 delta {d['absolute']:+.3f} ({d['relative']*100:+.1f}%)")

The first table is the baseline. Deltas (absolute and relative) are computed for all subsequent tables.

Evaluate classification

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
use jammi_ai::eval::{EvalTask, InferenceAggregate};

let report = session.eval_inference(
    "facebook/bart-large-mnli",
    "test_data",
    &["text".into()],
    EvalTask::Classification,
    "golden.public.labels",
    "category",
).await?;

match &report.aggregate {
    InferenceAggregate::Classification(c) => {
        println!("Accuracy: {}", c.accuracy);
        println!("Macro F1: {}", c.f1);
    }
    InferenceAggregate::Ner(n) => {
        println!("NER F1: {}", n.f1);
    }
}
println!("per_record predictions: {}", report.per_record.len());
Ok(()) }
}

Python

metrics = db.eval_inference(
    model="facebook/bart-large-mnli",
    source="test_data",
    columns=["text"],
    task="classification",
    golden_source="golden.public.labels",
    label_column="category",
)

# `aggregate` is tagged by `task`; for classification it carries
# `accuracy`, `f1`, and `per_class`.
agg = metrics["aggregate"]
print(f"Accuracy: {agg['accuracy']:.3f}")
print(f"Macro F1: {agg['f1']:.3f}")
# `per_record` is one entry per aligned predicted/gold pair.
print(f"per_record predictions: {len(metrics['per_record'])}")

Eval runs in the catalog

Every evaluation is recorded automatically:

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
let runs = session.catalog().list_eval_runs().await?;
for run in &runs {
    println!("{}: {} on {} (k={:?})", run.eval_run_id, run.eval_type, run.golden_source, run.k);
}
Ok(()) }
}

Schema validation

Golden datasets are validated before evaluation starts. Missing or wrong-type columns produce clear errors:

Eval error: Golden dataset missing required column 'query_text'
Eval error: Golden dataset column 'query_id' has type Boolean, expected Utf8

Integer ID columns (Int32, Int64) are accepted where Utf8 is expected.

Did Structure Help? A Graph-ML Evaluation Recipe

When you produce a structure-aware embedding table — a fine-tuned model, a propagated table, or any treatment that folds graph context into the representation — the only question that matters is whether it beats plain text embeddings on a held-out retrieval task. This recipe is the discipline around eval_compare that turns a delta into a defensible conclusion.

Nothing here is new engine surface. eval_compare already computes recall@k / precision@k / MRR / nDCG per table, the per-metric delta against a baseline, and — paired by query_id over the per-query records — a distribution-free significance result for each metric delta. The recipe is the protocol: a clean split, the judgment-matched metric, multiple seeds for trained treatments, and cohort slicing.

The four steps

  1. Baseline. Produce a plain text-embedding table with generate_text_embeddings.
  2. Treatment. Produce each structure-aware table (a fine-tuned model’s embeddings, a propagated table, etc.) over the same source rows.
  3. Compare. Run eval_compare with the baseline table first. Read the per-metric delta and its paired significance.
  4. Conclude and slice. Declare a win only when the judgment-matched metric improves with a significant paired test; then slice by cohort to see where structure helped.

1 & 2 — produce baseline and treatment tables

The baseline is a plain text-embedding table over your corpus. Each treatment table must be built over the same rows so the comparison is apples-to-apples.

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_db::store::CachePolicy;
async fn ex(session: &InferenceSession, baseline_model: &str, treatment_model: &str) -> jammi_db::error::Result<()> {
// Baseline: plain text embeddings.
let (baseline, _) = session
    .generate_text_embeddings("patents", baseline_model, &["abstract".into()], "id", CachePolicy::Bypass, None)
    .await?;

// Treatment: embeddings from a structure-aware model (e.g. a fine-tuned
// checkpoint), over the same source and key column.
let (treatment, _) = session
    .generate_text_embeddings("patents", treatment_model, &["abstract".into()], "id", CachePolicy::Bypass, None)
    .await?;

let baseline_table = baseline.table_name;
let treatment_table = treatment.table_name;
let _ = (baseline_table, treatment_table);
Ok(()) }
}
baseline = db.generate_embeddings(
    source="patents", model=baseline_model,
    columns=["abstract"], key="id", modality="text",
)
treatment = db.generate_embeddings(
    source="patents", model=treatment_model,
    columns=["abstract"], key="id", modality="text",
)

Leakage contract (read before you build anything). The graph and any graph-supervised training must use the train split only. The golden set — the (query, judgments) pairs you evaluate against — is held out and must never feed graph construction or training-pair selection. A structure-aware representation that has seen the eval rows will “win” by memorizing them, and the verdict is worthless. Split first, then build.

3 — compare on a held-out golden set

Run eval_compare with the baseline table first; every subsequent table carries its delta against that baseline. The golden set is a registered source of (query_id, query_text, relevant_id[, relevance_grade]) rows — see Evaluate and Compare Models for its schema.

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession, baseline_table: String, treatment_table: String) -> jammi_db::error::Result<()> {
let comparison = session
    .eval_compare(
        &[baseline_table, treatment_table], // baseline FIRST
        "patents",
        "golden.public.golden_relevance",   // held-out golden set
        10,                                 // k for recall@k / precision@k
    )
    .await?;

for entry in comparison.per_table.iter().skip(1) {
    let delta = entry.delta.as_ref().expect("non-baseline entries carry a delta");
    println!(
        "{}: nDCG {:+.3} ({:+.1}%)",
        entry.table_name,
        delta.ndcg.absolute,
        delta.ndcg.relative * 100.0,
    );

    // The paired significance of each metric delta. `None` only when the two
    // runs share no `query_id` (nothing to pair).
    if let Some(sig) = delta.significance.as_ref() {
        let s = &sig.ndcg;
        println!(
            "  nDCG p={:.4}  95% CI [{:+.3}, {:+.3}]",
            s.p_value, s.ci_lower, s.ci_upper,
        );
    }
    let _ = entry;
}
Ok(()) }
}
comparison = db.eval_compare(
    embedding_tables=[baseline_table, treatment_table],  # baseline FIRST
    source="patents",
    golden_source="golden.public.golden_relevance",
    k=10,
)
for entry in comparison["per_table"][1:]:
    delta = entry["delta"]
    d = delta["ndcg"]
    print(f"{entry['table_name']}: nDCG {d['absolute']:+.3f} ({d['relative']*100:+.1f}%)")
    sig = delta.get("significance")
    if sig is not None:
        s = sig["ndcg"]
        print(f"  nDCG p={s['p_value']:.4f}  95% CI [{s['ci_lower']:+.3f}, {s['ci_upper']:+.3f}]")

Reading the significance

For each metric, eval_compare attaches a MetricSignificance carrying a p_value and a [ci_lower, ci_upper] interval:

  • ci_lower / ci_upper are a percentile bootstrap confidence interval on the mean paired difference (treatment − baseline), at the 95% level. A CI that lies entirely above zero is the resampling analogue of “the delta is real, not noise.” A CI that brackets zero means you cannot distinguish the treatment from the baseline on this metric.
  • p_value is the two-tailed Mann–Whitney U p-value comparing the baseline and treatment per-query distributions — distribution-free, and robust to the bounded, tie-heavy shape retrieval metrics have. Smaller is stronger evidence.

Both are deterministic: the bootstrap runs under a pinned seed and a fixed iteration count, so the same inputs always yield the same interval. Two identical runs collapse to a [0, 0] CI with p ≈ 1.

A delta of +0.02 is a headline, not a conclusion. Report it only as +0.02, p=0.003, CI [+0.008, +0.031] — the delta with its significance.

Discipline contracts

These are the contracts a “did structure help?” claim must satisfy. State each one explicitly when you report a result.

Strict held-out split (the leakage contract)

The graph and any graph-supervised training use the train split only; the golden set is held out and never feeds construction. This is the single most important contract — restated here because it is the one that silently invalidates a result. If you cannot point to the split boundary, you do not have a clean number.

Judgment-matched metric

Pick the metric from the judgment type, not by habit:

JudgmentsMetricWhy
Graded (relevance_grade > 1)nDCGDiscounted cumulative gain uses the grade; recall would discard the ranking signal.
Binary (relevant / not)recall@k, MRRNo grade to exploit; presence and first-hit rank are the right targets.

eval_compare always computes all four metrics, but read the one that matches your golden set. Using recall on graded judgments throws away signal you paid a human to produce.

≥3 seeds for trained treatments

A trained treatment (a fine-tuned checkpoint, or any treatment whose construction samples) varies by seed. One lucky seed can fake a win. Run the treatment under ≥3 seeds, compare each against the same baseline, and report the mean ± variance of the delta plus the significance across seeds — not a single run.

A deterministic treatment (e.g. a pure propagation with no sampling) does not vary by seed, so it needs only the leakage and significance discipline — a quiet advantage worth stating when it applies.

Cohort slicing

The most useful output is where structure helped — by source family, period, or any segment you care about. Tag each query with cohort labels at eval time, then group the persisted per-query records.

Cohort tags are supplied per query to eval_embeddings (the per-table entry point); eval_compare itself does not surface cohort tagging, so to slice a comparison you run each table through eval_embeddings with the same cohort map. Every per-query record — its metrics and its cohort tags — is persisted to _jammi_eval_per_query, keyed by the run’s eval_run_id, and read back with eval_per_query.

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::collections::{BTreeMap, HashMap};
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession, treatment_table: &str) -> jammi_db::error::Result<()> {
// Tag each query with its cohort(s) at eval time.
let mut cohorts: HashMap<String, BTreeMap<String, String>> = HashMap::new();
cohorts.insert(
    "q1".into(),
    BTreeMap::from([("family".into(), "A".into())]),
);
// ... one entry per query_id ...

let report = session
    .eval_embeddings(
        "patents",
        Some(treatment_table),
        "golden.public.golden_relevance",
        10,
        &cohorts,
    )
    .await?;

// Read the persisted per-query rows back by run id; each row carries its
// metrics and cohort tags as JSON, ready to group by cohort.
let rows = session.eval_per_query(&report.eval_run_id).await?;
for row in &rows {
    println!("{}: cohorts={} metrics={}", row.query_id, row.cohorts_json, row.metrics_json);
}
Ok(()) }
}
cohorts = {"q1": {"family": "A"}, "q2": {"family": "B"}}  # one entry per query_id
report = db.eval_embeddings(
    source="patents",
    embedding_table=treatment_table,
    golden_source="golden.public.golden_relevance",
    k=10,
    cohorts=cohorts,
)
rows = db.eval_per_query(report["eval_run_id"])
# Group `rows` by their cohort tags and aggregate per-cohort metrics.

Then group by cohort and report per-cohort n and a confidence interval — a 12-query cohort has a wide CI, and a swing inside it is not a finding. Small cohorts go noisy; report n so a reader does not over-read them.

What this recipe does not cover

  • New metrics. Recall / precision / MRR / nDCG suffice; this recipe does not add others.
  • Online drift monitoring. This is an offline held-out harness, not a production-drift monitor.
  • The golden set itself. Constructing relevance judgments is the expensive human step — budget for it. The eval is cheap; the labels are not.

See also

Evaluate Uncertainty and Calibration

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Calibration & Uncertainty.

eval_embeddings and eval_inference answer “is the prediction accurate?”. eval_calibration answers the orthogonal question — “does the prediction know what it doesn’t know?”. The two are independent: a model can be accurate and badly calibrated, or perfectly calibrated and useless. When a predictor emits a distribution or an interval, a point-accuracy metric cannot tell you whether that uncertainty is honest. This harness can.

What it reports

Every calibration eval reports three things together — reporting any one alone is a trap:

  • A proper score (the headline). CRPS (continuous ranked probability score) and NLL (negative log-likelihood). Strictly proper scores are uniquely minimised by the true distribution, so they reward calibration and sharpness jointly — the only safe headline metric.
  • A calibration diagnostic. The adaptive, debiased PIT-calibration error: under calibration the probability-integral-transform of the outcomes is uniform, and this scores its departure from uniform. It is a diagnostic, never the verdict — reporting it alone admits the marginal-predictor degenerate (a model that predicts the global average is perfectly calibrated and worthless).
  • Sharpness and coverage. The mean width of the nominal 90% interval and how often it actually contains the outcome. Sharper is better only at fixed coverage.

The held-out, three-way-split contract

Calibration is measured on a held-out test set that is disjoint from both the training data and any calibration set used to fit the predictor. Re-using calibration points to also test inflates coverage — it is the single most common conformal/calibration bug. The harness measures exactly the predictions you give it; the split discipline is yours.

Prepare a calibration golden set

A calibration golden set pairs a held-out predictive distribution with its realised outcome. Two predictor shapes are supported, each reading different columns.

Parametric (Gaussian) predictor

For a predictor that emits a predictive Normal(mean, sd) per record:

record_id,mean,sd,outcome
r1,4.2,0.5,4.0
r2,1.1,0.9,2.3
r3,7.8,0.3,7.7
ColumnTypeRequired
record_idUtf8yes
meanFloat / Intyes
sdFloat / Int (positive)yes
outcomeFloat / Intyes

Ensemble (Sample) predictor

For a predictor that emits an ensemble of predictive draws per record, store the draws as a JSON array in a draws column:

record_id,draws,outcome
r1,"[3.9, 4.1, 4.3, 4.0]",4.0
r2,"[0.8, 1.4, 1.0, 2.1]",2.3
ColumnTypeRequired
record_idUtf8yes
drawsUtf8 (JSON array of numbers)yes
outcomeFloat / Intyes

Register either as a source:

db.add_source("calib", path="/data/calibration_holdout.csv", format="csv")

Run the eval

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
use jammi_ai::session::InferenceSession;
use jammi_ai::eval::EvalCalibrationShape;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
let report = session.eval_calibration(
    "patents",                          // source under test
    "calib.public.calibration_holdout", // held-out predictions + outcomes
    EvalCalibrationShape::Gaussian,     // or ::Sample for an ensemble
    &std::collections::HashMap::new(),  // no cohort tags
).await?;

// The proper score is the headline; the diagnostics explain it.
println!("CRPS (headline):  {}", report.aggregate.crps);
println!("NLL:              {}", report.aggregate.nll);
println!("PIT-calibration:  {}", report.aggregate.adaptive_ece);
println!("sharpness (90%):  {}", report.aggregate.sharpness);
println!("coverage (90%):   {}", report.aggregate.coverage);
Ok(())
}
}

Python

report = db.eval_calibration(
    "patents",
    "calib.public.calibration_holdout",
    shape="gaussian",   # or "sample"
)
print("CRPS:", report["aggregate"]["crps"])
print("coverage:", report["aggregate"]["coverage"])

Slice by cohort

Marginal coverage hides conditional miscoverage: a predictor can hit 90% coverage globally while systematically under-covering a subgroup. Tag records with opaque cohort segments — keyed by record_id — and the report slices coverage and CRPS per cohort, each with its sample size n and a bootstrap confidence interval on the proper score, so a small cohort is not over-read.

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
use std::collections::{BTreeMap, HashMap};
use jammi_ai::session::InferenceSession;
use jammi_ai::eval::EvalCalibrationShape;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
let mut cohorts: HashMap<String, BTreeMap<String, String>> = HashMap::new();
cohorts.insert(
    "r1".to_string(),
    BTreeMap::from([("region".to_string(), "emea".to_string())]),
);

let report = session
    .eval_calibration(
        "patents",
        "calib.public.calibration_holdout",
        EvalCalibrationShape::Gaussian,
        &cohorts,
    )
    .await?;

for cohort in &report.per_cohort {
    println!(
        "{}={}: n={} coverage={} crps={}",
        cohort.key, cohort.value, cohort.n, cohort.coverage, cohort.crps
    );
}
Ok(())
}
}

Compare two predictors with a p-value

The per-record scores are persisted to _jammi_eval_per_query keyed by the run id, exactly like the embedding eval. Pairing the per-record CRPS of two runs by record_id and running the same distribution-free paired significance test the retrieval comparison uses turns “B is better-calibrated than A” into a CRPS delta with a confidence interval and a p-value — not a vibe.

Determinism

Given the same inputs on the same host the report is bit-for-bit reproducible: every scoring function is deterministic and the only randomness — the cohort confidence-interval bootstrap — runs under a pinned seed. The scoring folds are f32/f64 reductions, so they are a same-host guarantee, not a cross-host one — a different CPU host can produce the same score at the value level while differing in the last float bits.

Register a Mutable Companion Table

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Feature Store.

A mutable companion table lives in the same backend database as the Jammi catalog (SQLite by default, Postgres in shared deployments), supports transactional INSERT / UPDATE / DELETE through DataFusion DML, and federates with Parquet result tables and external sources in one SQL surface. Reach for it when a tenant needs a relation it can edit row by row — a feature-store slowly-changing dimension table, a per-user state table, a config-driven lookup — that still has to participate in the same JOINs as your immutable result tables.

The primitive carries only what every consumer needs: a schema, a primary key, optional tenant scope, optional secondary indexes, optional ordering column. No history semantics, no lifecycle vocabulary, no audit columns.

Goal

This recipe walks through registering one mutable companion table for a neutral third-party use case (a feature-store team called Polaris Features maintaining slowly-changing dimensions for their recommender) and shows the equivalent Rust / Python / CLI surface.

Setup

Assumes a working JammiSession. The session opens the catalog at the configured artifact directory; nothing else is needed.

Define the schema

Polaris keeps one row per (item_id, valid_from, valid_to) interval:

#![allow(unused)]
fn main() {
extern crate arrow_schema;
fn make() {
use std::sync::Arc;
use arrow_schema::{DataType, Field, Schema};

let schema = Arc::new(Schema::new(vec![
    Field::new("item_id",      DataType::Utf8,    false),
    Field::new("price_tier",   DataType::Utf8,    false),
    Field::new("availability", DataType::Utf8,    false),
    Field::new("valid_from",   DataType::Int64,   false),  // epoch milliseconds
    Field::new("valid_to",     DataType::Int64,   true),   // epoch milliseconds; NULL = open
]));
}
}

The catalog encoder accepts the closed primitive subset enforced by every MutableBackend impl — Boolean, the integer family, Float32 / Float64, Utf8, Binary. Wider types (e.g. Timestamp, Decimal) round-trip via their natural numeric encoding (Int64 epoch milliseconds, scaled Int64) so the schema stays narrow and the rule stays one-line at the boundary.

The engine reserves tenant_id and any column whose name starts with _ — the schema builder rejects them at build time, per the tenant-identifier discipline (Design Philosophy). (The tenant_id column is always present on the storage table; the engine appends it implicitly.)

Build the definition

MutableTableDefinitionBuilder chains the field validations:

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate arrow_schema;
use std::sync::Arc;
use arrow_schema::Schema;
use jammi_db::store::mutable::definition::{
    MutableIndexDef, MutableTableDefinitionBuilder, MutableTableId,
};

fn make(schema: Arc<Schema>) -> jammi_db::store::mutable::definition::MutableTableDefinition {
let def = MutableTableDefinitionBuilder::new(
        MutableTableId::new("item_dimensions").unwrap(),
        schema,
    )
    .primary_key(vec!["item_id".into(), "valid_from".into()])
    .index(MutableIndexDef {
        name: "idx_item_dim_active".into(),
        columns: vec!["item_id".into(), "valid_to".into()],
        unique: false,
    })
    .build()
    .unwrap();
def
}
}

The primary key must be a non-empty subset of the schema; secondary indexes are optional but persisted on the storage table so the backend can use them for WHERE clauses.

Register

The registration is atomic: catalog row + storage CREATE TABLE + every secondary CREATE INDEX commit together. If any step fails, nothing lands.

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate tokio;
use jammi_db::store::mutable::definition::MutableTableDefinition;
use jammi_db::session::JammiSession;
async fn ex(session: &JammiSession, def: MutableTableDefinition) -> jammi_db::error::Result<()> {
let id = session.create_mutable_table(def).await?;
// The table is now queryable as `mutable.public.item_dimensions` in the
// same SQL surface that federates result tables and external sources.
Ok(())
}
}

Python

import pyarrow as pa
import jammi

db = jammi.connect("file:///var/lib/jammi")
# The Python wrapper exposes mutable-table registration through the
# `create_mutable_table` accessor (see `jammi.mutable`). The recipe below
# is illustrative; consult the API reference for the binding shape your
# version ships.

CLI

The jammi CLI exposes mutable-table registration through the lower-level sources surface for now; programmatic clients should use the Rust or Python APIs.

Verify

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate tokio;
async fn ex(session: &jammi_db::session::JammiSession) -> jammi_db::error::Result<()> {
let zero_rows = session
    .sql("SELECT * FROM mutable.public.item_dimensions LIMIT 0")
    .await?;
assert_eq!(zero_rows[0].schema().fields().len(), 5);
Ok(())
}
}

The query returns a zero-row batch with the declared schema — confirmation that the table is registered and DataFusion can route mutable.public.<id> correctly.

Federation tease

The mutable table now JOINs with your existing result tables and sources:

SELECT  d.item_id, d.price_tier, e.embedding
FROM    mutable.public.item_dimensions d
JOIN    itemembs.public.item_embeddings e ON e.item_id = d.item_id
WHERE   d.valid_to IS NULL
  AND   d.price_tier = 'premium'
LIMIT 10;

See the Run Transactional Updates on a Mutable Table recipe for INSERT / UPDATE / DELETE round-trips and the SCD Type 2 close-and-open pattern.

Run Transactional Updates on a Mutable Table

Once a mutable companion table is registered (see Register a Mutable Companion Table), you update its rows with the same SQL surface that runs your read queries. Every INSERT / UPDATE / DELETE lands in one backend transaction — either every row commits or none does — and federates with your immutable result tables in subsequent SELECTs.

Goal

Walk through the three DML verbs against the item_dimensions table from the previous recipe, then demonstrate the Slowly-Changing Dimension Type 2 close-and-open pattern Polaris uses to record a price-tier change.

Insert

INSERT INTO mutable.public.item_dimensions
    (item_id, price_tier, availability, valid_from)
VALUES
    ('sku-1842', 'standard', 'in_stock', '2026-04-01T00:00:00Z'),
    ('sku-2901', 'premium',  'in_stock', '2026-04-01T00:00:00Z'),
    ('sku-3457', 'standard', 'out_of_stock', '2026-04-01T00:00:00Z');

The RecordBatch returned by session.sql(...) carries a single-row UInt64 column called count per DataFusion’s TableProvider::insert_into contract. Three rows landed; the JOIN-against-result-tables query from the previous recipe now returns three rows.

Update

UPDATE mutable.public.item_dimensions
   SET availability = 'low_stock'
 WHERE item_id = 'sku-2901';

Predicate columns that participate in an index pushdown become a backend WHERE clause; the rest filter above the scan node. The update commits in one transaction; if the predicate matches zero rows, the call succeeds with rows_affected = 0.

Delete

DELETE FROM mutable.public.item_dimensions
 WHERE item_id = 'sku-3457';

DELETE follows the same shape. Row-level cascades are SQLite’s job (the foreign-key declarations on the storage table); the engine does not model cascades above the backend.

SCD Type 2 — close-and-open

Polaris records a price-tier change by closing the active row’s valid_to and inserting a new row with the new tier. Both statements must land atomically; today the supported pattern is to issue them as a single multi-statement SQL string through session.sql, which DataFusion plans as one DML batch under one transaction:

-- Single sql() call so both statements land in one transaction.
UPDATE mutable.public.item_dimensions
   SET valid_to = '2026-05-15T12:00:00Z'
 WHERE item_id = 'sku-1842' AND valid_to IS NULL;

INSERT INTO mutable.public.item_dimensions
    (item_id, price_tier, availability, valid_from)
VALUES
    ('sku-1842', 'premium', 'in_stock', '2026-05-15T12:00:00Z');

A future JammiSession::transaction(|tx| async { … }) API will make multi-statement DML atomicity explicit; today the multi-statement SQL string is the supported surface.

Federation join

The mutable table now joins with the embedding table to surface recommender candidates filtered by current tier:

SELECT  d.item_id, d.price_tier, e.embedding
  FROM  mutable.public.item_dimensions d
  JOIN  itemembs.public.item_embeddings e ON e.item_id = d.item_id
 WHERE  d.valid_to IS NULL
   AND  d.price_tier = 'premium'
 LIMIT 10;

The federation is the engine’s existing FederationOptimizerRule work — no special integration needed; mutable tables register under the same SessionContext as your Parquet result tables and external sources.

Crash recovery

If the process dies mid-write, no partial commit is visible on restart. SQLite’s WAL mode (documentation) and Postgres’s MVCC each guarantee that an open transaction either commits as a whole or is rolled back on connection loss. The engine inherits that guarantee through the CatalogBackend::transaction closure shape: when the closure returns Err(_), the backend rolls back; when the process is killed mid-execution, the backend rolls back the in-flight transaction.

Direct-access append + replay (Phase 4 trigger streams)

Two lower-level methods bypass DataFusion’s planner for high-throughput event paths:

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate arrow;
extern crate tokio;
async fn ex(
    session: &jammi_db::session::JammiSession,
    batch: arrow::array::RecordBatch,
) -> jammi_db::error::Result<()> {
use jammi_db::store::mutable::definition::MutableTableId;
use jammi_db::catalog::backend::TxOptions;

let id = MutableTableId::new("events").unwrap();
let registry = session.mutable_tables_arc();
let backend = session.catalog().backend_arc();

// Direct INSERT via insert_batch — caller owns the transaction.
backend
    .transaction(TxOptions::default(), move |tx| {
        let registry = registry.clone();
        let id = id.clone();
        let batch = batch.clone();
        Box::pin(async move {
            registry
                .insert_batch(tx, &id, &batch)
                .await
                .map_err(|e| jammi_db::BackendError::Execution(e.to_string()))?;
            Ok::<(), jammi_db::BackendError>(())
        })
    })
    .await?;
Ok(())
}
}
#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate futures;
extern crate tokio;
use futures::StreamExt;
async fn ex(
    session: &jammi_db::session::JammiSession,
) -> jammi_db::error::Result<()> {
use jammi_db::store::mutable::definition::MutableTableId;

let id = MutableTableId::new("events").unwrap();
// Stream rows where the registered `order_column` value > 100.
let mut stream = session
    .mutable_tables()
    .scan_after(&id, 100)
    .await
    .map_err(|e| jammi_db::error::JammiError::Catalog(e.to_string()))?;
while let Some(batch) = stream.next().await {
    let _batch = batch
        .map_err(|e| jammi_db::error::JammiError::Catalog(e.to_string()))?;
    // …
}
Ok(())
}
}

These are the surface Phase 4’s trigger broker uses to publish events into a backing table and replay subscribers; general consumers should prefer the SQL surface.

Publish Events to a Topic

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Change Data Capture.

A trigger-stream topic is a catalog-registered Arrow schema plus a backing mutable table. Publishers append RecordBatches; subscribers filter and receive them. The engine owns the offset counter and the durable event log; the broker (in-memory by default, NATS JetStream in clustered deployments) fans live deliveries out to attached subscribers.

Reach for the trigger stream when a tenant needs event semantics — a CDC pipeline, a feature-store update bus, a job-completion notification fan-out — that has to coexist with the SQL surface the rest of the platform already uses. Every published event lands as a row in the topic’s backing mutable table; that table is queryable with the same Flight SQL surface as any other mutable companion table, so ad-hoc analytics on the event log come for free.

Goal

Walk through registering one topic for a neutral third-tenant use case (a small CDC pipeline pulling Postgres change events into a downstream search index) and publish a batch of events from Rust.

Setup

Assumes a JammiSession whose JammiConfig.trigger_broker is left at its default — the embedded InMemoryBroker. Production deployments swap in JetStreamBroker via configuration; the publisher API does not change.

Define the topic schema

#![allow(unused)]
fn main() {
extern crate arrow_schema;
fn make() {
use std::sync::Arc;
use arrow_schema::{DataType, Field, Schema};

let schema = Arc::new(Schema::new(vec![
    Field::new("op",         DataType::Utf8,  false),
    Field::new("ts_ms",      DataType::Int64, false),
    Field::new("key",        DataType::Utf8,  false),
    Field::new("after",      DataType::Utf8,  true),
]));
}
}

The schema is the contract every published batch must satisfy. The engine reserves the _offset, _row_idx, and _produced_at column names (all leading-underscore names are reserved); user schemas must not include them.

Register the topic

Topic registration is a typed lifecycle verb, not a SQL statement: build a TopicDefinition and register it. The Session::register_topic surface (and the gRPC CatalogService.RegisterTopic verb it rides) does this in one call.

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate arrow_schema;
use std::collections::BTreeMap;
use std::sync::Arc;
use arrow_schema::SchemaRef;
use jammi_db::trigger::{TopicDefinition, TopicId};

fn make(schema: SchemaRef) -> TopicDefinition {
let topic = TopicDefinition {
    id: TopicId::new(),
    name: "cdc.orders".into(),
    schema,
    tenant: None,                          // None = global; Some(t) scopes to t
    broker_metadata: BTreeMap::new(),      // driver-specific opts (e.g. retention)
};
topic
}
}

The CLI exposes the same shape via jammi trigger register --name … --schema ….

import jammi
import pyarrow as pa

db = jammi.connect("file:///var/lib/jammi")
db.register_topic(
    "cdc.orders",
    schema=pa.schema([
        ("op", pa.string()),
        ("ts_ms", pa.int64()),
        ("key", pa.string()),
        ("after", pa.string()),
    ]),
    broker_metadata={"retention_seconds": "604800"},
)

The id is a UUIDv7 minted at construction — time-ordered so the catalog index keeps insert locality. The name is opaque to the engine beyond catalog lookup; pick a hierarchical namespace that suits your platform (e.g. cdc.orders, feature_store.user_features).

Registration is atomic: the topics row, the backing mutable table, and any broker-side state commit together; nothing lands on failure.

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate tokio;
use std::sync::Arc;
use jammi_db::trigger::{TopicDefinition, TriggerBroker};
async fn ex(
    topic_repo: &jammi_db::catalog::topic_repo::TopicRepo,
    broker: Arc<dyn TriggerBroker>,
    topic: &TopicDefinition,
) -> Result<(), jammi_db::trigger::TriggerError> {
broker.register_topic(topic).await?;
topic_repo.register_topic(topic).await?;
Ok(())
}
}

Publish a batch

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate arrow;
extern crate arrow_schema;
extern crate tokio;
use std::sync::Arc;
use arrow::array::{Int64Array, RecordBatch, StringArray};
use arrow_schema::SchemaRef;
use jammi_db::trigger::{Publisher, TopicDefinition};
use jammi_db::TenantId;
async fn ex(
    publisher: &Publisher,
    topic: &TopicDefinition,
    schema: SchemaRef,
    tenant: Option<TenantId>,
) -> Result<(), jammi_db::trigger::TriggerError> {
let batch = RecordBatch::try_new(
    schema,
    vec![
        Arc::new(StringArray::from(vec!["c", "u", "d"])),
        Arc::new(Int64Array::from(vec![1700_000_000_000, 1700_000_000_100, 1700_000_000_200])),
        Arc::new(StringArray::from(vec!["order-1", "order-2", "order-3"])),
        Arc::new(StringArray::from(vec![Some("{...}"), Some("{...}"), None])),
    ],
)
.unwrap();
let offset = publisher.publish_scoped(topic, tenant, batch).await?;
println!("published offset = {}", offset.value());
Ok(())
}
}

publish_scoped tags every row’s tenant_id column from the explicit tenant: Option<TenantId> argument — no silent dependency on session state at publish time. Pass None for global topics; pass the session’s current tenant (session.tenant()) for tenant-scoped publishes.

Python equivalent — publish_topic accepts a pyarrow.Table via the Arrow C Stream Interface so the conversion is zero-copy:

import pyarrow as pa

table = pa.table({
    "op":    ["c", "u", "d"],
    "ts_ms": [1700_000_000_000, 1700_000_000_100, 1700_000_000_200],
    "key":   ["order-1", "order-2", "order-3"],
    "after": ["{...}", "{...}", None],
})
offset = db.publish_topic("cdc.orders", batch=table)
print(f"published offset = {offset}")

publish_scoped validates the batch schema against the topic schema before opening a transaction. A mismatch returns BatchSchemaMismatch and nothing lands in the backing table. If the topic is tenant-pinned (TopicDefinition::tenant = Some(t)) and the tenant argument doesn’t match, the publish is rejected up front with PublishTenantMismatch.

What just happened

  1. The Publisher minted the next monotonic offset for the topic (seeded lazily from MAX(_offset) on the backing table the first time the topic is touched).
  2. The augmented batch — user columns plus _offset, _row_idx, and _produced_at — was inserted into the topic’s backing mutable table inside one CatalogBackend::transaction. On commit the offset advances; on rollback it is reused for the next attempt so no gaps appear in the log.
  3. The broker received the batch for best-effort fan-out to any live subscribers. A broker fan-out failure after commit is logged but does not fail the publish — subscribers replay missed offsets from the backing table on next reconnect.

See also

Subscribe to a Topic with a SQL Predicate Filter

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Change Data Capture.

A subscription tails a topic and yields only the batches whose rows satisfy a SQL WHERE-clause predicate. The predicate is parsed once through DataFusion at subscribe time; the broker delivers each batch, the engine evaluates the predicate against it, and rows that match flow to the consumer.

Reach for predicate-filtered subscriptions when different downstream consumers need different selectivity on the same event stream — a search-index that only cares about op = 'c', an audit log that wants every event, a cache invalidator that wants op IN ('u', 'd').

Goal

Open a subscription on the cdc.orders topic with a predicate that matches only deletes, and consume the stream from Rust.

Setup

Assumes the topic was registered (see Publish Events to a Topic) and you have a Subscriber constructed against the same broker the publisher uses.

Open the subscription

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate arrow;
extern crate arrow_schema;
extern crate datafusion;
extern crate futures;
extern crate tokio;
use std::sync::Arc;
use arrow_schema::SchemaRef;
use datafusion::execution::context::SessionContext;
use futures::StreamExt;
use jammi_db::trigger::{Predicate, Subscriber, TopicDefinition};
async fn ex(
    subscriber: &Subscriber,
    session: &SessionContext,
    topic: &TopicDefinition,
) -> Result<(), jammi_db::trigger::TriggerError> {
let predicate = Predicate::from_sql(session, Arc::clone(&topic.schema), "op = 'd'")?;

let mut stream = subscriber
    .subscribe(topic, predicate, None /* from_offset: None = live tail */)
    .await?;

while let Some(delivered) = stream.next().await {
    let batch = delivered?;
    handle_deletes(batch.batch);
}
Ok(())
}
fn handle_deletes(_: arrow::array::RecordBatch) {}
}

from_offset = None starts the subscription at the broker’s live tail (no replay). Some(0) starts from the earliest retained event; the engine joins backing-table replay with the live broker stream so the client sees one continuous sequence of DeliveredBatch.

Predicate dialect

Predicates are a subset of DataFusion SQL. The whitelist:

SupportedRejected
Column references (col)Subqueries (SELECT …)
Literal scalars (1, 'foo', true)Aggregates (SUM, COUNT, …)
Comparison ops (=, <, >, <=, >=, !=)Window functions
Boolean ops (AND, OR, NOT)Joins
IS NULL, IS NOT NULLCASE WHEN
IN (literal, literal, …)Functions outside the whitelist
LIKE, BETWEEN
Whitelisted string functions

The string-function whitelist is lower, upper, length, starts_with, ends_with. Anything outside this list returns PredicateUnsupported at subscribe time — the stream never opens. An unparseable predicate returns PredicateParse for the same reason.

Reconnection and replay

If your consumer disconnects and reconnects, pass the last-seen offset as from_offset to resume without missing events:

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate chrono;
extern crate tokio;
use chrono::Utc;
use std::sync::Arc;
use jammi_db::trigger::{Offset, Predicate, Subscriber, TopicDefinition};
async fn ex(
    subscriber: &Subscriber,
    topic: &TopicDefinition,
    last_seen: u64,
) -> Result<(), jammi_db::trigger::TriggerError> {
let resume_from = Offset::new(last_seen + 1, Utc::now());
let _stream = subscriber
    .subscribe(topic, Predicate::match_all(), Some(resume_from))
    .await?;
Ok(())
}
}

The engine reads the backing table for offsets >= resume_from, then attaches the broker live stream starting strictly above the last replayed offset — the two halves never deliver the same offset twice.

Backpressure

A slow consumer slows the producer; events are not dropped. The chain is: the broker tail backs up onto a bounded mpsc::channel, the channel’s send() future awaits, the broker poll loop pauses, publishers awaiting the broker fan-out experience matching back- pressure. The backing table — the authoritative log — is still written without delay, so a consumer that disconnects under load can always catch up via replay.

See also

Replay Events from the Backing Table

Every topic’s event log is a Phase-2 mutable companion table named __topic_<topic_id>. The double-underscore prefix is reserved for engine-controlled tables; consumers do not register tables under that namespace. Flight SQL queries against the backing table compose with the same federation surface the rest of Jammi exposes — joins with result tables, predicate pushdown, aggregates over event history.

Reach for direct replay when a tenant needs ad-hoc analytics on the event log that would be awkward through the subscribe surface — counting events per key, computing per-day rollups, joining the event stream against a Parquet result table.

Goal

Run an ad-hoc query that returns the count of op = 'd' events per hour over the durable log for cdc.orders.

Backing table naming

Every registered topic has a backing table whose name is __topic_<topic_id> where <topic_id> is the hyphenated lowercase TopicId::Display. To find the name, query topics:

SELECT topic_id, name, backing_table FROM topics WHERE name = 'cdc.orders';

Schema

The backing table’s columns are the topic’s user schema with three engine-controlled columns prepended:

ColumnTypePurpose
_offsetBIGINT NOT NULLMonotonic offset; stable across rows of one publish.
_row_idxBIGINT NOT NULLPosition within a publish, for the composite PK.
_produced_atBIGINT NOT NULL (UTC microseconds)Publisher-side timestamp, single value per offset.
…user cols…per TopicDefinition.schemaPayload columns.
tenant_idTEXT (nullable, added by Phase 2)Tenant scope per the tenant-identifier discipline.

The primary key is (_offset, _row_idx); _offset is the order column so scan_after and ORDER BY _offset agree.

Query

SELECT
    DATE_TRUNC('hour', TIMESTAMP_MICROS(_produced_at))      AS hour,
    COUNT(*)                                                 AS deletes
FROM    mutable.public.__topic_019088da_1234_7890_abcd_ef1234567890
WHERE   op = 'd'
GROUP BY hour
ORDER BY hour;

Substitute your topic’s backing_table (looked up from the topics catalog row) for the literal name in the example. The query runs through Flight SQL like any other federated query — predicate pushdown applies, joins compose, aggregates run.

Tenant scoping

The backing table carries the tenant_id column added by the Phase-2 mutable backend. Sessions bound to a tenant see only rows whose tenant_id matches or is NULL, per Phase 3’s predicate-injection analyzer rule — the same guard that scopes the rest of the catalog.

See also

Scope a Session to a Tenant

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Tenancy.

When more than one logical tenant shares a Jammi engine — a SaaS feature store serving two ML teams, a research workbench shared across three labs, a notebook product hosting one project per student — every catalog read and write needs to belong to the right tenant. Jammi’s session-scoped tenant binding does this without the caller having to spell a WHERE tenant_id = … clause on every query.

Goal

After this recipe you can:

  1. Bind a tenant to a session in Rust, Python, and on the CLI.
  2. Verify that two sessions on the same process see disjoint rows.
  3. Bind a tenant on a remote client via the gRPC CatalogService so subsequent Flight SQL queries from the same connection observe the tenant.

Setup

Every example below assumes a configured JammiConfig (defaults are fine for the recipe). The tenant identifier is a UUID v4 or v7 string — the engine refuses the nil UUID (00000000-…) at the TenantId newtype boundary.

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate tokio;
use std::str::FromStr;
use jammi_db::TenantId;
use jammi_db::session::JammiSession;
use jammi_db::config::JammiConfig;

async fn ex() -> jammi_db::error::Result<()> {
let config = JammiConfig::default();
let alice = TenantId::from_str("018f5a0e-c4c8-7e10-9c4f-3b6f7c5a8e9a")?;

let session = JammiSession::new(config).await?.with_tenant(alice);
// Every catalog read and write on `session` now scopes to Alice.
Ok(())
}
}

with_tenant is a builder that consumes self and returns Self, so it chains naturally. If you hold a session behind Arc, use bind_tenant(&t) to update the binding in place — the session shares one TenantBinding across all references.

Python

import jammi

db = jammi.connect("file:///tmp/jammi")
db.set_tenant("018f5a0e-c4c8-7e10-9c4f-3b6f7c5a8e9a")

# Subsequent calls observe Alice's tenant scope.
db.add_source("inbox", path="/data/alice/inbox.parquet", format="parquet")
db.sql("SELECT * FROM inbox.public.inbox")

set_tenant is a sticky setter — it mutates the connection in place and stays in effect until the next set_tenant. Pass an empty string to clear: db.set_tenant("").

For a binding scoped to a single block — the prior tenant restored on exit, and nesting handled — use tenant_scope as a context manager:

with db.tenant_scope("018f5a0e-c4c8-7e10-9c4f-3b6f7c5a8e9a"):
    # Reads here observe Alice's tenant scope.
    db.sql("SELECT * FROM inbox.public.inbox")
# Prior scope restored here.

The same surface is available on a remote connection (jammi.RemoteDatabase), where the prior tenant is captured client-side and rebound on exit.

CLI

The --tenant flag is global; it applies to every subcommand.

jammi --tenant 018f5a0e-c4c8-7e10-9c4f-3b6f7c5a8e9a sources list
jammi --tenant 018f5a0e-c4c8-7e10-9c4f-3b6f7c5a8e9b models list

Remote clients (gRPC + Flight SQL)

A programmatic client (Python, Go, Java) binds the tenant once per connection via the jammi.v1.catalog.CatalogService.SetTenant RPC. The server records the tenant against the jammi-session-id request metadata header; every Flight SQL query the same connection issues afterwards inherits the binding through the same resolver — the engine-default SessionIdTenantResolver — applied by the single async tenant-binding layer (TenantResolverLayer) that fronts both the CatalogService and the Flight SQL provider. Browser clients reach the same CatalogService over HTTP/1.1 via the gRPC-Web shim (application/grpc-web+proto) — no separate REST surface, same jammi-session-id header semantics.

import grpc
from jammi.v1 import catalog_pb2, catalog_pb2_grpc

channel = grpc.insecure_channel("jammi.example.com:50051")
metadata = [("jammi-session-id", "my-client-uuid")]

client = catalog_pb2_grpc.CatalogServiceStub(channel)
client.SetTenant(
    catalog_pb2.SetTenantRequest(
        tenant=catalog_pb2.Tenant(id="018f5a0e-c4c8-7e10-9c4f-3b6f7c5a8e9a")
    ),
    metadata=metadata,
)
# Subsequent Flight SQL queries on the same channel + jammi-session-id
# observe Alice's tenant scope.

This flow assumes a trusted network. The jammi-session-id header is a client-minted, opaque transport correlation id — it identifies a connection, not a principal. The server does not authenticate it: anyone who presents another session’s id assumes that session’s tenant. SetTenant writes a tenant the caller asserts; nothing verifies the caller is entitled to it. That is the right trade-off when every client is inside your trust boundary (a private VPC, a sidecar mesh, a single-process notebook), and the wrong one the moment an untrusted caller can reach the port. Do not treat jammi-session-id as an authentication or authorization boundary.

Bring your own auth

Jammi authenticates nothing on its own — it is a substrate, and identity is a consumer’s vocabulary. To put a tenant boundary in front of untrusted callers, you supply the authentication and authorization yourself by implementing a TenantResolver and passing it to GrpcChain.tenant_resolver when you assemble the chain via assemble_grpc_chain. One resolver, plugged in once, binds every engine gRPC verb AND the Flight SQL db.sql lane — the same async tenant-binding layer (TenantResolverLayer) applies the resolved scope to both transports, so there is nothing separate to wire up for Flight.

  1. Authenticate the principal. In resolve, read the caller’s credential — a bearer token, a session cookie your gateway exchanges, a service-to-service token — and verify it. A missing or invalid credential returns Err(Status::unauthenticated(..)) here, before any handler runs.
  2. Authorize the tenant from the verified claim. Derive the tenant from the verified claim — never from a header the caller controls. This is where your policy lives: which tenant this principal may act as. Return Ok(TenantScope::Tenant(t)).
  3. The engine binds it. The async TenantResolverLayer maps the resolved scope onto the SessionTenant request extension every verb handler reads, and the Flight SQL provider (TenantBoundProvider) binds the same scope for db.sql — you write only resolve.

Because resolve runs in front of every handler, the tenant the engine acts on is the one the credential proves, not one the caller asserts. The jammi-session-id header plays no part in this path. Reject, don’t default: an authenticating resolver returns Tenant/Err and NEVER TenantScope::Global — returning Global on a failed check runs the request unscoped, which for a tenant_id IS NULL-bearing catalog is a global read, so a rejected caller must fail the request. TenantScope::Global is the explicit unscoped choice the engine-default resolver (SessionIdTenantResolver) returns when no tenant is bound — never a value a rejection falls through to.

use tonic::{Status, metadata::MetadataMap};
use jammi_db::TenantId;
use jammi_server::grpc::session::{TenantResolver, TenantScope};

/// A consumer's authenticating resolver. `verify_credential` is the
/// consumer's own identity logic — it authenticates the caller and returns the
/// tenant the verified claim authorizes, or `None` to reject the request.
struct AuthResolver;

#[tonic::async_trait]
impl TenantResolver for AuthResolver {
    async fn resolve(&self, metadata: &MetadataMap) -> Result<TenantScope, Status> {
        // 1. Authenticate: pull the credential the caller presented.
        let credential = metadata
            .get("authorization")
            .and_then(|v| v.to_str().ok())
            .ok_or_else(|| Status::unauthenticated("missing credential"))?;

        // 2. Authorize: derive the tenant from the *verified* claim. A failed
        //    check rejects the request — it never falls through to an unscoped
        //    read that could surface another tenant's rows.
        let tenant: TenantId = verify_credential(credential)
            .ok_or_else(|| Status::unauthenticated("invalid credential"))?;

        // 3. Bind: return the resolved scope. The engine's tenant-binding layer
        //    applies it to every gRPC verb and to Flight SQL.
        Ok(TenantScope::Tenant(tenant))
    }
}
fn verify_credential(_c: &str) -> Option<TenantId> { None }

Plug it in at assembly time, in place of the engine default:

use std::sync::Arc;
use jammi_server::runtime::{assemble_grpc_chain, GrpcChain};

let chain = GrpcChain {
    // .. addr, flight_ctx, flight_binding, store, trigger, engine, tiers, metrics ..
    tenant_resolver: Arc::new(AuthResolver),
    ..chain_defaults
};
let assembled = assemble_grpc_chain(chain)?;

The seam types are TenantResolver (the trait you implement), TenantScope (Tenant/Global), and SessionTenant (the per-request binding every verb reads, which the engine sets for you). This one resolver replaces the engine-default SessionIdTenantResolver for the whole chain — the gRPC verbs and the Flight db.sql lane both read the scope it resolves, closing the cross-transport gap where a boundary authenticated the gRPC plane but Flight still bound from the unauthenticated jammi-session-id header.

Gating the cross-tenant admin pass: AdminAuthorizer

TenantResolver above binds which tenant an ordinary request acts as. A separate, narrower seam gates a different question: whether a caller may run CatalogService.Reconcile’s cross-tenant all = true admin pass at all — the one verb that deliberately crosses every tenant’s data in a single call. This is AdminAuthorizer, a synchronous capability check (&MetadataMap -> Result<(), Status>) supplied at GrpcChain.admin_authorizer:

use jammi_server::grpc::catalog::AdminAuthorizer;
use tonic::{Status, metadata::MetadataMap};

/// A consumer's admin-capability check — a signed capability header, an
/// mTLS peer identity the transport already verified, or any other
/// consumer-owned policy. `Ok(())` permits the `all = true` pass; `Err`
/// is returned to the caller verbatim, so this decides its own status code.
struct AdminHeaderCheck;

impl AdminAuthorizer for AdminHeaderCheck {
    fn authorize(&self, metadata: &MetadataMap) -> Result<(), Status> {
        match metadata.get("x-admin-capability") {
            Some(v) if v == "granted" => Ok(()),
            _ => Err(Status::permission_denied("admin capability required")),
        }
    }
}
use std::sync::Arc;
use jammi_server::runtime::{assemble_grpc_chain, GrpcChain};

let chain = GrpcChain {
    admin_authorizer: Some(Arc::new(AdminHeaderCheck)),
    ..chain_defaults
};

The shipped default is admin_authorizer: None, which refuses EVERY all = true request with PERMISSION_DENIED — a deployment that never wires one cannot accidentally expose the cross-tenant sweep. A tenant-scoped Reconcile (all = false) never consults this seam at all; it runs under the caller’s own TenantResolver-resolved scope like every other verb. The seam is gRPC-only by construction (Reconcile has no Flight SQL analogue), unlike TenantResolver, which binds both transports from one grant — see Security Posture for the full comparison.

Disjoint views — what to expect

Two sessions on the same process, bound to different tenants, will:

  1. Read each other as invisible: list_sources() returns the calling tenant’s sources plus any globally-scoped (tenant_id IS NULL) sources.
  2. Write into different lanes: a register_source from Alice produces a row tagged tenant_id = alice; Bob’s list_sources does not see it.
  3. Share globally-scoped rows: an unscoped (tenant_id IS NULL) registration — typically a public reference dataset — is visible to every tenant.

The engine enforces the binding at four layers (the SPEC-03 defence-in-depth discipline):

  • Read-side predicate injectionTenantScopeAnalyzerRule injects tenant_id = $current OR tenant_id IS NULL on every TableScan whose schema declares the column.
  • Result-table resolution gate — a Jammi-owned result table is wholly owned by one tenant (or GLOBAL), so its Parquet carries no tenant_id column for the analyzer to filter on. The tenant-gating result-table schema provider instead gates resolution on the catalog owner: over every lane that names jammi.{table} (Flight db.sql, gRPC sql, search), a correctly-bound tenant resolves only its own and GLOBAL result tables, and a peer’s private table resolves not-found (and is absent from the schema’s table enumeration) — the same (tenant_id = $current OR tenant_id IS NULL) visibility the catalog read API applies.
  • Write-side guard — every catalog register_* and the mutable-table sink calls Transaction::assert_tenant_matches before INSERT.
  • Storage-side filter — catalog repo reads also pass the predicate to the backend SQL layer, so the wrong tenant’s rows never leave the database.

A buggy caller that constructs a row with the wrong tenant_id gets BackendError::TenantMismatch from the guard layer.

When the binding doesn’t apply

  • External federated sources without a tenant_id column — Jammi’s analyzer rule has no column to inject against, so those sources show every row to every tenant unless the source declaration registers a tenant_column override. Catalog tables and mutable companion tables always carry the column.
  • Cross-tenant WHERE clauses the caller writes by hand — a query that contains WHERE tenant_id = 'other-tenant' runs against the injected predicate plus the user’s clause; the analyzer rule does not remove user-written predicates.
  • Single-tenant deployments — bind nothing and every row is global; no predicate is injected beyond tenant_id IS NULL.

See also

Scope a Federated Source by Tenant

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Tenancy.

The session-scoped tenant binding (multi-tenant.md) relies on every table the engine reads carrying a tenant_id column. That works for mutable companion tables and Parquet result tables Jammi produced itself — both emit the column per the tenant-identifier discipline (Design Philosophy). But a federated source — a remote Postgres warehouse, a S3 Parquet lake, a CSV from someone else’s pipeline — usually doesn’t. It may carry a customer_id, an organization, a workspace column, or no tenant discriminator at all.

This recipe shows how to tell Jammi which column on a federated source plays the role of the tenant discriminator, so the predicate-injection analyzer rule scopes scans against that column instead of looking for the engine’s built-in tenant_id name.

Goal

After this recipe you can:

  1. Register a federated source whose tenant discriminator is named differently from tenant_id.
  2. Tell the analyzer rule which column to use.
  3. Verify two tenants get disjoint rows from the same physical source.
  4. Recognise what set_source_tenant_column does not do.

Setup

The recipe assumes you have a Parquet file (or any other federated source) whose schema includes a column that already carries the tenant identifier — for example a customer_id column populated with the UUID of the customer who owns each row. The column’s value must be the same canonical hyphenated lowercase form TenantId::Display emits; the analyzer rule does a string comparison after coercing the column to Utf8.

Register a federated source

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate tokio;
use jammi_db::session::JammiSession;
use jammi_db::source::{FileFormat, SourceConnection, SourceType};

async fn ex(session: &JammiSession) -> jammi_db::error::Result<()> {
session
    .add_source(
        "notes",
        SourceType::File,
        SourceConnection {
            url: Some("file:///data/notes.parquet".into()),
            format: Some(FileFormat::Parquet),
            ..Default::default()
        },
    )
    .await?;
Ok(())
}
}

Python

db.add_source("notes", path="/data/notes.parquet", format="parquet")

Declare its tenant column

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
use jammi_db::session::JammiSession;
fn ex(session: &JammiSession) {
session.set_source_tenant_column("notes", Some("customer_id".into()));
}
}

set_source_tenant_column registers the override on the session’s SourceTenantColumns map. The next time the analyzer rule sees a scan against notes.public.notes, it discovers the override and injects WHERE CAST(customer_id AS Utf8) = $current_tenant OR CAST(customer_id AS Utf8) IS NULL (or IS NULL only when the session is unscoped).

The Python and CLI surfaces do not expose this method today — it lives on Rust JammiSession only. If you embed Jammi as a library this is the right hook; if you reach Jammi over Flight SQL / gRPC, the source registration and tenant-column declaration happen on the server side before the server starts accepting client connections.

Schema column tenant_id always wins. Only call set_source_tenant_column when your federated source carries the discriminator under a different name, or when it carries the discriminator at all — sources without any tenant column remain globally visible.

Verify the predicate

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate tokio;
use std::str::FromStr;
use jammi_db::TenantId;
use jammi_db::session::JammiSession;
use jammi_db::config::JammiConfig;
async fn ex() -> jammi_db::error::Result<()> {
let alice = TenantId::from_str("018f5a0e-c4c8-7e10-9c4f-3b6f7c5a8e9a")?;
let bob = TenantId::from_str("018f5a0e-c4c8-7e10-9c4f-3b6f7c5a8e9b")?;

let session_a = JammiSession::new(JammiConfig::default()).await?.with_tenant(alice);
session_a.set_source_tenant_column("notes", Some("customer_id".into()));

let session_b = JammiSession::new(JammiConfig::default()).await?.with_tenant(bob);
session_b.set_source_tenant_column("notes", Some("customer_id".into()));

let count_a = session_a.sql("SELECT COUNT(*) FROM notes.public.notes").await?;
let count_b = session_b.sql("SELECT COUNT(*) FROM notes.public.notes").await?;

// Each session sees only its own rows — `count_a` and `count_b` are
// disjoint subsets of the on-disk file.
Ok(())
}
}

For a file with 10 rows split 6 (customer_id = alice) + 4 (customer_id = bob), the two sessions get 6 and 4 respectively.

What you cannot do

  • You cannot point set_source_tenant_column at a column that doesn’t exist on the source. The analyzer rule emits a column reference that DataFusion later fails to resolve at execution time, surfacing as a DataFusionError::SchemaError. The override is a trust contract — the engine does not validate the column’s presence at registration time.
  • You cannot mix tenant_id and a non-tenant_id column on the same source. When the source’s schema already declares tenant_id, the built-in column wins and the override is ignored.
  • You cannot remove the discriminator at runtime once tenants are actively querying. Call set_source_tenant_column("notes", None) to drop the override; subsequent queries on notes.public.notes will not be tenant-scoped at all.

If the federated source you are wrapping carries no tenant discriminator, two options are open: (1) re-shape upstream so each tenant lands in its own table, registered as a separate source, or (2) accept that the source is globally visible to every session and gate access at a higher layer (Flight SQL session interceptor, gRPC auth middleware). The engine itself does not authenticate; Design Philosophy § the engine does not invent tenants applies.

See also

The Materialization Contract: Verifiable Result-Table Identity

Measured companion: for the long-form, executed-and-measured Python treatment, see The Cookbook → Incremental Recompute.

Every result table Jammi publishes carries a verifiable identity: a sidecar attestation that lets a later reader assert “this artifact is the output of definition D over input-state S” — without trusting a name, a path, or an out-of-band convention. This recipe is the operator’s view of that contract: what the attestation contains, how it is written, how to check a table against it, and how recovery reconciles it after a crash. Everything below describes the system as it ships today.

What a materialization manifest is

A result table is published as an immutable Parquet object (plus, for embedding tables, an ANN-index sidecar bundle). Alongside it the engine writes a separate .materialization.json sidecar — for every result table, not only embedding tables — carrying an in-toto-shaped attestation that binds three things to the artifact’s content digest:

  • a definition hash of how the table was produced;
  • the as-of anchors of every input the producer read; and
  • the producing-run identity and instant.

The on-disk shape is MaterializationManifest:

FieldMeaning
artifactThe in-toto subject — SHA-256 over the Parquet object’s bytes. The thing a verifier matches by digest.
leavesThe keyed inventory of the artifact’s parts, beside the subject, never in place of it: one leaf per Parquet row group (its index and byte range as the footer locates it, and the SHA-256 of exactly that range), or one per model-bundle file by name. A peer verifies ONE partition against its leaf without reading the rest (ResultStore::verify_partitions names the first divergent row group); bytes outside every row group — the footer, page indexes, bloom filters — belong to no leaf and are the whole-object artifact digest’s to catch.
definition_hashSHA-256 of how the table was produced — the descriptor plus the environment (see below).
input_anchorsThe immutable state pointer of each input, in producer order.
produced_byThe producing-run id — provenance, never the reproducibility anchor.
produced_atThe producing instant, RFC3339 — provenance, never the anchor.
engine_versionThe engine semantic version that produced the artifact.
manifest_versionThe manifest format version, so a future format change is a typed error rather than a silent misparse.

The artifact digest deliberately covers the Parquet data, never the ANN index sidecar: the index is a derived accelerator reconstructible from the data, so a verdict attests the data-of-record, not the search structure. The two sidecars never collide — .manifest.json describes the ANN accelerator; .materialization.json attests the Parquet data.

The two halves of identity: descriptor and environment

The definition_hash is not a hash of a logical plan. Result-table producers in this engine are hand-built physical pipelines, so there is no single plan to canonicalise. Instead the hash folds two typed, deterministically-serialisable values:

  • ProducingDescriptorhow the table was computed: the verb plus its typed parameters. Each producer fills in exactly one variant from its own parameters — Inference, Embedding, NeighborGraph, GraphPropagation, or ContextSet. A stable, sorted-key JSON encoding yields canonical bytes.
  • MaterializationEnv — the output-affecting environment that is not part of the description itself: the engine semantic version, the compute device (Cpu / Cuda { ordinal } / Metal { ordinal }), and the identity + backend kind of every model the producer invoked.

The device is part of the environment for a concrete reason: a model produces different float outputs on CPU versus an accelerator while carrying the same model identity, so a hash that omitted the device would yield a false “match” when only the device changed. The two halves are length-prefixed and domain-separated before hashing, so a descriptor field can never alias an environment field. Two runs of the same producer, with the same parameters, over the same inputs, in the same environment, hash identically; any output-affecting change to any of the three changes the hash.

The hash folds every declared parameter of the descriptor and the environment — it does not fold the producing host’s CPU microarchitecture. Two CPU hosts of a different ISA (or even two same-ISA hosts with different runtime vector-dispatch decisions) can produce the same definition_hash for tables whose last float bits differ, because a CPU float reduction is a same-box guarantee only (measured directly: bits_snapshot.rs pins CPU-float bit output per box, not per (target_arch, target_os)). A definition_hash match therefore asserts which definition produced the table, not that its bytes are reproducible byte-for-byte on a different CPU host — identity across hosts is the catalog row plus the definition_hash, never the raw bytes. This CPU-variant gap in the hash is a known, open limitation, not a defect this contract claims to close.

The input anchors are recorded but are deliberately not part of the definition hash: the definition is how a table is produced, the anchors are over what. A consumer that wants a combined “code + data” identity composes the two itself.

BuildingTable::finish is the sole building→ready transition

There is no manifest-free finalize. ResultStore::create_table returns a lease-owned BuildingTable handle, and its finish is the single building → ready path every producer goes through, so no table reaches ready without an attestation. It performs the steps in a crash-safe order:

  1. renew the writer’s lease by compare-and-set — a writer whose lease was claimed by recovery learns it here, before it attests bytes it no longer owns;
  2. ResultStore::write_attestation: read the Parquet bytes and compute the artifact digest, compute the manifest from the descriptor, environment, and resolved inputs, and write the .materialization.json sidecar;
  3. flip the catalog row building → ready by a compare-and-set naming the writer (recording the definition_hash and the input anchors as summary columns, and clearing the lease);
  4. register the table in DataFusion under the row’s own catalog owner.

Because the bytes and the sidecar are durable before the status flip, a crash in the window leaves a building row — never a queryable ready table missing its manifest — and the row’s lease then expires, so the next recovery sweep promotes it from that very sidecar or reaps it. Every producer that materialises an embedding table — graph propagation and context-set pooling — routes through this funnel too: ResultStore::materialize_embedding_table writes the table and then calls finish with the producer’s Materialization (descriptor, environment, and resolved input anchors).

How to verify a table

verify_materialization is the read-only verb that recomputes a ready table’s artifact digest and checks it — and, optionally, an expected definition hash — against the table’s manifest. It returns a MatchVerdict; it never acts on one. What a reader does with a mismatch (refuse, alarm, fall back) is the reader’s policy, not the engine’s.

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use std::sync::Arc;
use jammi_ai::session::InferenceSession;
use jammi_db::store::manifest::{DefinitionHash, MatchVerdict};
async fn ex(session: &Arc<InferenceSession>, table: &str) -> jammi_db::error::Result<()> {
let record = session
    .catalog()
    .get_result_table(table)
    .await?
    .expect("result table exists");

// No expectation: just assert the bytes still match the attestation.
let verdict = session
    .result_store()
    .verify_materialization(&record, None)
    .await?;

match verdict {
    MatchVerdict::Match => { /* artifact is the attested output */ }
    MatchVerdict::MatchWithUnpinnedInputs { unpinned } => {
        // Verified, but at least one input was anchored only to a read
        // instant, so reproducibility cannot be fully asserted. Honest,
        // not silent — downgrade confidence accordingly.
        let _ = unpinned;
    }
    MatchVerdict::Mismatch { expected, found } => {
        // The served artifact is not the output of the expected definition.
        let _ = (expected, found);
    }
    MatchVerdict::MissingManifest => {
        // No sidecar — a pre-contract table. A truthful unknown, never a
        // fabricated match.
    }
}

// Pin an expected definition hash to assert *which* definition produced it.
let expected = DefinitionHash("…".into());
let _ = session
    .result_store()
    .verify_materialization(&record, Some(&expected))
    .await?;
Ok(()) }
}

Python

verify_materialization takes the table name and an optional expected definition hash, and returns the verdict as a dict tagged by verdict:

verdict = db.verify_materialization("results__text_embedding__…")

if verdict["verdict"] == "match":
    pass  # artifact is the attested output
elif verdict["verdict"] == "match_with_unpinned_inputs":
    unpinned = verdict["unpinned"]      # sources anchored only to an instant
elif verdict["verdict"] == "mismatch":
    expected, found = verdict["expected"], verdict["found"]
elif verdict["verdict"] == "missing_manifest":
    pass  # pre-contract table — a truthful unknown

# Pin the definition you expect produced the table:
db.verify_materialization("results__…", expected_definition="<hex definition hash>")

Reading the verdict

VerdictMeaning
MatchThe recomputed artifact digest equals the manifest’s, and (if supplied) the expected definition hash equals the manifest’s. The artifact is the output of the expected definition.
MatchWithUnpinnedInputs { unpinned }The artifact verifies, but at least one input was anchored only to a read instant (an external source with no version surface), so reproducibility cannot be fully asserted. The named sources are honest about not being reproducibly pinned.
Mismatch { expected, found }The digest or the definition hash differs — the served artifact is not the output of the expected definition. Both sides are returned for the caller.
MissingManifestNo manifest sidecar exists — a table created before the contract landed. A truthful “unknown”, never a fabricated match.

An anchor is “pinned” when it points at an immutable id: a result table’s content digest, a mutable companion table’s monotonic version, or an external source’s as-of/version value (an Iceberg snapshot id, a Delta version, an LSN, a watermark). It is “unpinned” only when the source exposes no version surface, in which case the anchor is the read instant and the verdict says so.

How recovery reconciles manifest sidecars

ResultStore::recover() runs at startup and restores the crash-consistency invariant of the catalog↔storage boundary across every tenant (it runs under an admin scope — see Catalog Backend and Trigger Broker → Multi-writer safety for the lease primitive this section assumes). Its domain is deliberately narrow: it enumerates only building rows whose lease is absent or expired (Catalog::list_expired_building_tables). A building row under a live lease belongs to a writer that is still producing it — recovery does not even look at its Parquet or manifest state, on any tenant, from any session. For each expired-lease row it finds, recovery first claims it by a one-row compare-and-set naming the recoverer the row’s new owner (the recoverer becomes a writer, heartbeating a fresh lease of its own) — only after that claim succeeds does it inspect bytes or delete anything, so a writer that renews mid-sweep, or a peer recoverer that claims first, is never raced. For the materialization contract specifically, the claimed row’s Parquet and manifest state decide the terminal CAS:

  • A claimed row with valid Parquet but no manifest is reaped. The write was torn in the window between the Parquet landing and the manifest being written — before the building → ready flip. The contract forbids promoting a table without an attestation, and the producing descriptor cannot be reconstructed after the fact, so the row is driven to failed by CAS and its bytes are deleted only after that CAS affects exactly one row — never promoted manifest-less.
  • A claimed row that does have its manifest is promoted, backfilling the summary columns (definition_hash, input anchors, the true Parquet footer row count — never the count the writer intended before it crashed) the live path records, and rebuilding the ANN sidecar from the Parquet for an embedding table so it self-heals even if its segment set never landed.
  • A ready table whose manifest has since vanished is reaped. This is reconcile_ready_manifests, which recover() also runs (across every post-contract ready row — one whose catalog definition_hash is set, so it was promoted under the contract): its .materialization.json being absent is a corruption, since the attestation a verifier would read is gone. Such a row is driven to failed by a status-guarded CAS and its bytes reaped, rather than left queryable with a silently missing manifest.

Every deletion in both reaping arms above follows its own one-row CAS — the row’s new owner (the recoverer, having just claimed it) deletes what it now owns; a failed delete is logged and left for a later jammi reconcile pass, never silently swallowed. This is two of the engine’s three deletion arms for a building row’s bytes; the third is a writer’s own abort() after its own CAS. See Operability for the full crash-mid-publish failure-mode entry, and Backup and Restore for what a catalog/storage restore that lands outside this lease-driven window (an object whose row was never written at all, or vice versa) requires — jammi reconcile, not recover().

Pre-contract tables report honestly rather than being penalised. A row created before migration 021 carries definition_hash IS NULL in the catalog and legitimately has no sidecar; recovery leaves it untouched, and verify_materialization returns MissingManifest for it — a truthful unknown. This is the distinction the contract draws: a bug (post-contract, no sidecar) is reaped; a legitimate historical table is preserved.

Versioned tables: the identity chain

An embedding table that has been refreshed (see Refresh an Embedding Table Incrementally) carries a chain of versions beside its base manifest. The base version’s identity is the .materialization.json artifact digest — publishing the base changes no anchor. Every later version N records, in {table}__v{N}.version.json, its parent, the definition hash, the delta descriptor (EmbeddingDelta or EmbeddingCompaction, which name the parent version and its identity), the digest of every fragment it serves and the digest of its deletion mask; its identity is a domain-separated SHA-256 over exactly those inputs. Counts, the ANN segments and produced_by/produced_at are outputs, not inputs.

verify_materialization on a versioned table runs the base check unchanged, then recomputes every fragment digest and the mask digest from the bytes, recomputes the chain identity from the parent’s recorded identity, and compares it with both the version manifest and the catalog row. A Mismatch names the artifact that diverged. The version’s input anchors are the source at the instant of the refresh (unpinned), so the verdict is MatchWithUnpinnedInputs naming the source, exactly as for the base embed.

staleness and every derives_from anchor use the current version’s identity, so a refresh that changed content advances dependents to Stale { InputAdvanced } and a no_change refresh leaves them Fresh. recompute of a versioned table starts a new chain in a new table.

Why this identity matters

The materialization manifest gives every result table a content-addressed, verifiable identity that is independent of its name or path. That identity is the nucleus a future freshness-and-caching layer builds on: once a table’s output is bound to the definition that produced it and the as-of state of its inputs, an incremental-recompute layer can decide whether a cached artifact is still valid by comparing definition hashes and input anchors — rather than re-running the producer blind. The contract ships that identity and the verify primitive today; it ships no policy. What a reader does with a verdict, and when a downstream layer chooses to recompute, are decisions left to the consumer.

Refresh an Embedding Table Incrementally

An embedding table produced by generate_embeddings is a function of its source: D(S). When the source changes, refresh_embeddings re-embeds only the rows whose content changed and publishes the result as a new version of the same table — the table keeps its name, its ANN index and every downstream anchor, and a reader sees exactly one version at a time. “Publish” here is the storage-commit sense — the catalog’s publish_version CAS that makes a building version the table’s current one — distinct from the domain-lifecycle “publish/install/bind” a consumer builds on top of Jammi’s primitives (see Philosophy).

db.generate_embeddings("docs", model="sentence-transformers/all-MiniLM-L6-v2",
                       columns=["title", "body"], key_column="id")
# ... rows are edited, added, or deleted in the source ...
report = db.refresh_embeddings("docs__embedding__all-MiniLM-L6-v2__...")
# {'table': ..., 'version': 1, 'parent_version': 0, 'inferred_rows': 3,
#  'added': 1, 'changed': 2, 'deleted': 1, 'unchanged': 9996, 'dropped_rows': 0,
#  'live_rows': 9999, 'masked_rows': 3, 'outcome': 'published'}
#![allow(unused)]
fn main() {
extern crate jammi_ai;
extern crate jammi_db;
extern crate tokio;
use jammi_ai::Session;
use jammi_ai::pipeline::embedding_refresh::RefreshOptions;
async fn ex(session: &Session, table: &str) -> jammi_db::error::Result<()> {
let report = session.refresh_embeddings(table, RefreshOptions::default()).await?;
let _ = report;
Ok(()) }
}

Every transport exposes the same three verbs with the same report: Session::{refresh_embeddings, compact_embeddings, expire_versions} in Rust, Database.refresh_embeddings / compact_embeddings / expire_versions in Python (embedded and remote alike), and EmbeddingService.RefreshEmbeddings / CompactEmbeddings / ExpireVersions on gRPC. A refresh run through a local session and the same refresh run through the data-plane client produce the same version identity, fragment digests and counts (K4).

What a refresh does

  1. Gates. The table must be ready, produced by an embedding pipeline, and carry _content_hash (a table imported or produced before the column existed is NotRefreshable { reason: missing_content_hash } — run recompute once to get a refreshable table). The model is loaded and the table’s recorded definition is compared with the definition the current environment would produce; a different model, backend, precision or quantization is DefinitionDrift { table, recorded, current } — nothing is allocated, and the consumer’s remedy is recompute.
  2. Base version. A never-refreshed table is first published as its base version, whose identity is the table’s existing .materialization.json artifact digest, so publishing the base changes no downstream anchor.
  3. Diff. The current version’s (_row_id, _content_hash) pairs are read through the masked view and the source is scanned once, projecting the same content hash. Each source key is classified added, changed or unchanged; a current key the source no longer has is deleted (see the delete policy below).
  4. Nothing to dooutcome: no_change, no version allocated, nothing written, every downstream anchor untouched (a dependent stays Fresh).
  5. Infer the delta. A new version number is allocated (monotonic, never reused) and only the added and changed rows are run through the model — the plan is the source scan joined to the delta keys, sorted in the same key order the base uses, so the fragment’s bytes are deterministic. The rows land in one Parquet fragment and one ANN segment, both stamped with the new version.
  6. Deletion mask. Every superseded or deleted key is added to the version’s cumulative mask with the horizon N − 1: the key is dead in every fragment and segment stamped at or below that horizon and live in the new fragment.
  7. Publish. The version’s manifest is written and one catalog transaction flips the version building → ready and moves the table’s current_version forward. Two refreshes of one parent both allocate a number; the second to publish a BASE version finds its parent already moved (ParentMoved) and absorbs it, proceeding with the concurrent winner’s version rather than failing. The previous version stays live until the swap, so a refused refresh never changes what a reader sees.

RefreshReport

FieldMeaning
versionThe published version (published), or the current one (no_change)
parent_versionThe version the diff was computed against
inferred_rowsRows the model actually ran on (added + changed)
added / changed / deleted / unchangedThe classification of the source’s keys
dropped_rowsRows asked for that the model did not realize (a per-row input failure — an empty text on the candle backend)
live_rowsRows a reader sees after the publish
masked_rowsPhysical rows in the version’s fragments hidden by the mask
outcomepublished or no_change

live_rows and masked_rows are the inputs to a compaction decision; the engine ships no threshold.

Delete policy

deletes is tombstone (default) or retain:

  • tombstone — a key the source no longer has is masked out of every prior fragment and segment; the table stays D(S).
  • retain — the key’s current row is kept (a rolling-window source that drops old rows the table should still serve).

The policy is recorded in the version’s descriptor, so two versions produced under different policies have different identities.

Uniqueness

The initial generate_embeddings tolerates duplicate source keys (every row is written; the ANN index keeps the last one). A refresh does not: a duplicated key on the source scan is NonUniqueKey { table, scan: source, keys, total } after the complete scan (up to ten keys with their exact counts, plus the total), and a parent version that already holds two physical rows under one _row_id is NonUniqueKey { scan: parent }. Neither allocates a new version; the remedy is to de-duplicate the source and, for a non-unique parent, to recompute once.

Null keys

A NULL in the key column is never a per-row event on any path — generate_embeddings, a refresh, and infer all refuse the whole call with InvalidKey { column, null_count } before the model runs. The count is exact, zero rows are embedded, and nothing is written (a base version published by a first refresh is retained; it is downstream-invisible).

Empty texts

Per-row validity is the backend’s: the candle backend marks an empty or null text a per-row error (dropped_rows); the HTTP backend forwards texts verbatim, so validity is the remote’s. A delta whose every row is dropped is still a legitimate publish — a mask-only version with no fragment and no segment, so an edit-to-empty removes the row from the table.

Reading a versioned table

A reader — search, search_by_id, every SQL SELECT, read_vectors, verify_materialization, staleness — resolves the table’s current_version and sees only that version’s live rows:

  • ANN. The version’s segments are merged under its mask; a masked candidate is dropped and the search widens until k live hits are found, and the exact rescore reads the segment that owns the hit.
  • SQL. The version’s fragments are unioned under the mask with _row_id always projected, so COUNT(*), SELECT vector and LIMIT n all see live rows only.
  • Snapshots. A publish between a reader’s table-row read and its scan serves the older version consistently; a read in flight when its version expires completes from memory. A table whose current version’s manifest cannot be resolved (the object is gone) is bound as a placeholder: planning succeeds, every scan is the typed VersionUnavailable { table, version }, and recompute is the remedy.
  • A never-refreshed table is byte-identical to today’s: no mask, no union, read_vectors reads the raw file.

Restarting the engine re-binds the current version; search results and SELECT output are identical before and after.

Compaction and expiry

compact_embeddings(table) rewrites the current version’s live rows as one fragment and one segment — no inference, vectors carried byte-for-byte — and publishes it as a new version with the same ranking and an identity that folds the parent’s. expire_versions(table, before) deletes every non-current version numbered below before and reaps its manifest, mask and every fragment and segment the current version does not still reference; {table}.parquet, its .materialization.json and the version allocator are never touched.

db.compact_embeddings(table)
db.expire_versions(table, before=db.refresh_embeddings(table)["version"])

Identity and verification

The base version’s identity is the table’s artifact digest. Every later version’s identity is a hash chain over its parent’s identity, the definition hash, the delta descriptor, the fragment digests and the mask digest — counts and the ANN index are outputs, never inputs. verify_materialization on a versioned table recomputes every fragment and mask digest and the chain and names the artifact that diverged; staleness on a dependent uses the current version’s identity, so a refresh that changed content advances the dependent to Stale { InputAdvanced } and a no_change refresh leaves it Fresh. recompute of a versioned table produces a new table with a fresh chain root.

Pinned reads for a persisting producer

Any producer that persists a durable artifact whose provenance NAMES this table as an input — an embedding delta/compaction, a context set, a propagated table, a neighbor graph — must resolve the table’s anchor and every row it reads from the SAME catalog resolution, never two independent reads of current_version: a version publish landing between them would persist an artifact whose provenance names one version while its content came from another. ResultStore::pin_current_version is that one resolution; PinnedSource::input_anchor (infallible, no second catalog read) and ResultStore::pinned_provider both derive from it.

Disclosed residual — candidate SELECTION is not pinned. Pinning closes “the artifact’s anchor and the rows it reads agree on one version” for a producer that already holds its candidate key set. It does not make “every row read anywhere in the producer’s pipeline came from this one version” true end-to-end for every producer:

  • The three context producers (context_set, context_predictor, recompute) pin the POOLED VECTOR read; the candidate SET is chosen upstream by ResultStore::search_vectors, which serves ANN from the catalog’s live segment set (or, on the exact-search fallback, this session’s own registration) — neither leg is pinned.
  • The neighbor-graph producer persists an artifact whose provenance carries the pinned anchor, while its edge candidates come from an unpinned segment set (resolve_search_mode_local).

Closing this means threading a pin into the search/candidate-selection path — out of scope for the seam above; tracked as a residual, not silently absorbed into “reads through pin_current_version” claims elsewhere.

Errors

ErrorMeaningRemedy
InvalidKey { column, null_count }Null keys on the sourceFix the source
NonUniqueKey { scan, keys, total }Duplicate keys on the source or the parentDe-duplicate; recompute a non-unique parent
DefinitionDrift { recorded, current }The environment would produce a different definitionrecompute
NotRefreshable { reason }No content hash, not ready, not an embedding table, or the current version is unavailablerecompute
VersionUnavailable { table, version }The current version’s manifest cannot be resolvedrecompute
ParentMoved { .. }Another refresh already published against this parentThe base-publish arm absorbs it and proceeds with the concurrent winner’s version — no retry needed

A storage object that vanishes under a running scan is the typed Storage(StorageError::Io { source: object_store::Error::NotFound }), never a partial answer; every other DataFusion error keeps its source() chain under JammiError::DataFusion.

Scope

A refresh diffs the whole source by content hash (Tier 1), which covers file, federated and mutable sources alike. A table-level monotonic source version is a freshness surface the manifest already anticipates and may be built later (Tier 2); row-level change tracking on mutable tables is a transition log and is never built. Refresh applies to tables produced by an embedding pipeline — not to imported, propagated, context-set or inference tables.

Connect to PostgreSQL / MySQL

Jammi federates external databases alongside local files. Register a database as a source and query it with the same SQL interface — joins across local files and databases work seamlessly.

PostgreSQL

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
use jammi_db::source::{SourceConnection, SourceType};

session.add_source("pg_data", SourceType::Postgres, SourceConnection {
    url: Some("postgresql://user:pass@localhost:5432/mydb".into()),
    ..Default::default()
}).await?;

let results = session.sql(
    "SELECT id, title FROM pg_data.public.articles WHERE published = true LIMIT 10"
).await?;
Ok(()) }
}

MySQL

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_db::source::{SourceConnection, SourceType};
async fn ex(session: &InferenceSession) -> jammi_db::error::Result<()> {
session.add_source("mysql_data", SourceType::Mysql, SourceConnection {
    url: Some("mysql://user:pass@localhost:3306/mydb".into()),
    ..Default::default()
}).await?;
Ok(()) }
}

Cross-source joins

Once registered, external databases are queryable with the same three-part naming convention and can be joined with local files:

SELECT p.title, a.author_name
FROM local_data.public.papers p
JOIN pg_data.public.authors a ON p.author_id = a.id
WHERE a.institution = 'MIT'

Generate embeddings from external sources

External databases work as sources for embedding generation:

# Note: external databases must be registered through the Rust API,
# which exposes the typed SourceType::Postgres / SourceType::Mysql variants.
# The Python `add_source(url=…, format=…)` surface is for file-shaped sources.

db.generate_embeddings(
    source="pg_articles",
    model="sentence-transformers/all-MiniLM-L6-v2",
    columns=["title", "abstract"],
    key="id",
    modality="text",
)

Feature flags

External source support requires feature flags when building from source:

SourceFeature flag
PostgreSQLpostgres
MySQLmysql

These are enabled by default in published crates and pre-built binaries. Building either feature from source links a native TLS stack (native-tls -> OpenSSL) rather than rustls, so it additionally requires OpenSSL’s development headers on the build host — libssl-dev on Debian/Ubuntu, openssl-devel on RHEL/AlmaLinux, or openssl + pkg-config on macOS via Homebrew. See Build dependencies (Linux).

Supported source types

TypeDescriptionStatus
File (file://)Parquet, CSV, JSON, JSONL on local diskAlways available
File (s3:// / gs:// / azure://)Same formats over cloud object storesFeature-gated — see Cloud Storage
PostgreSQLAny PostgreSQL-compatible databaseAvailable
MySQLMySQL / MariaDBAvailable
SQLiteSQLite databasesNot supported (rusqlite version conflict)

Store Sources and Results in Cloud Object Storage

Jammi treats local disk, S3, GCS, Azure Blob, and Cloudflare R2 as interchangeable backends. Any place the engine accepts a local file path it also accepts a storage URL — file://, s3://, gs://, azure://, or r2:// — including registered file-shaped sources and the result-table Parquet that embedding and inference jobs write.

Build with the cloud features you need

The default build ships only file:// and the in-memory test driver. Cloud schemes are opt-in per provider so a deployment that only uses S3 does not pull in the GCS and Azure SDK chains:

FeatureSchemes it enables
storage-s3s3:// (AWS S3 and S3-compatible: MinIO, LocalStack)
storage-gcsgs://
storage-azureazure://, abfss://
storage-r2r2:// (Cloudflare R2 — the S3 driver with R2’s endpoint + region derived)
storage-cloudAll four (umbrella)
[dependencies]
jammi-db = { version = "0.5", features = ["storage-s3", "storage-gcs"] }

Live integration tests live behind matching live-s3-tests, live-gcs-tests, live-azure-tests features so the hermetic cargo test lane never reaches the network.

Register an S3-backed source

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate tokio;
use jammi_ai::session::InferenceSession;
async fn ex(session: &InferenceSession) -> Result<(), Box<dyn std::error::Error>> {
use jammi_db::source::{FileFormat, SourceConnection, SourceType};
use jammi_db::storage::{CloudConfig, S3Config, StorageUrl};

let url = StorageUrl::parse("s3://benchmarks/snapshots/2026/papers.parquet")?;

let conn = SourceConnection {
    url: Some(url.to_string()),
    format: Some(FileFormat::Parquet),
    cloud: Some(CloudConfig::S3(S3Config {
        region: Some("us-east-1".into()),
        ..Default::default()
    })),
    ..Default::default()
};

session.add_source("papers", SourceType::File, conn).await?;

let rows = session
    .sql("SELECT id, title FROM papers.public.papers LIMIT 10")
    .await?;
Ok(()) }
}

If the cloud field is None and the URL is a cloud scheme, the driver falls back to the SDK’s ambient credential chain — env vars, instance profile, IRSA, ADC, Managed Identity.

Python

import jammi

db = jammi.connect("file:///var/lib/jammi")
db.add_source("papers", url="s3://benchmarks/snapshots/2026/papers.parquet", format="parquet")
db.sql("SELECT id, title FROM papers.public.papers LIMIT 10")

The Python binding accepts the same URL forms as the Rust API; per-source cloud credentials are read from process environment.

CLI

jammi sources add papers \
    --url s3://benchmarks/snapshots/2026/papers.parquet \
    --format parquet

GCS and Azure

The pattern is identical — only the URL prefix and the CloudConfig variant change:

#![allow(unused)]
fn main() {
extern crate jammi_db;
use jammi_db::source::{FileFormat, SourceConnection};
fn make() -> SourceConnection {
use jammi_db::storage::{CloudConfig, GcsConfig};

let conn = SourceConnection {
    url: Some("gs://archives/2026/jan.parquet".into()),
    format: Some(FileFormat::Parquet),
    cloud: Some(CloudConfig::Gcs(GcsConfig {
        service_account_path: Some("/etc/jammi/sa.json".into()),
        ..Default::default()
    })),
    ..Default::default()
};
conn }
}
#![allow(unused)]
fn main() {
extern crate jammi_db;
use jammi_db::source::{FileFormat, SourceConnection};
fn make() -> Result<SourceConnection, Box<dyn std::error::Error>> {
use jammi_db::storage::{AzureConfig, CloudConfig};

let conn = SourceConnection {
    url: Some("azure://snapshots/model_outputs.parquet".into()),
    format: Some(FileFormat::Parquet),
    cloud: Some(CloudConfig::Azure(AzureConfig {
        account_name: Some("mystorage".into()),
        sas_token: Some(std::env::var("AZURE_SAS_TOKEN")?.into()),
        ..Default::default()
    })),
    ..Default::default()
};
Ok(conn) }
}

Cloudflare R2

R2 speaks the S3 API, so it rides the same driver — but r2:// is a first-class scheme so you supply only the R2-shaped inputs and the engine derives the two quirks R2 imposes: the account-scoped endpoint https://<account_id>.r2.cloudflarestorage.com and region = "auto". Mint an S3-style access key pair in the R2 dashboard (or via the API) and give Jammi the account id:

#![allow(unused)]
fn main() {
extern crate jammi_db;
use jammi_db::source::{FileFormat, SourceConnection};
fn make() -> Result<SourceConnection, Box<dyn std::error::Error>> {
use jammi_db::storage::{CloudConfig, R2Config};

let conn = SourceConnection {
    url: Some("r2://archives/snapshots/2026.parquet".into()),
    format: Some(FileFormat::Parquet),
    cloud: Some(CloudConfig::R2(R2Config {
        account_id: Some(std::env::var("R2_ACCOUNT_ID")?),
        access_key_id: Some(std::env::var("R2_ACCESS_KEY_ID")?),
        secret_access_key: Some(std::env::var("R2_SECRET_ACCESS_KEY")?.into()),
        ..Default::default()
    })),
    ..Default::default()
};
Ok(conn) }
}

Set endpoint instead of account_id to point at an R2 custom domain. Result tables and their sidecar ANN indexes persist to r2:// exactly as to any other cloud backend.

Persist result tables to the cloud

ResultStore accepts a [StorageUrl] root, so embedding and inference outputs land in the same bucket as the source data:

#![allow(unused)]
fn main() {
extern crate jammi_db;
use std::sync::Arc;
use jammi_db::catalog::Catalog;
fn ex(catalog: Arc<Catalog>) -> jammi_db::error::Result<()> {
use jammi_db::config::AnnIndexConfig;
use jammi_db::storage::{StorageRegistry, StorageUrl};
use jammi_db::store::ResultStore;
use std::sync::Arc;

let root = StorageUrl::parse("s3://benchmarks/jammi_db")?;
let registry = StorageRegistry::new();
// `AnnIndexConfig` tunes the HNSW sidecar index every embedding table carries;
// the default reproduces the index backend's built-in defaults. The last
// argument, `local_cache_dir`, is the PARENT of the two LOCAL caches the store
// derives: `{local_cache_dir}/index` (each remote ANN segment materialised
// before USearch opens it — a `file://` root loads its segments in place, so
// it is unused there) and `{local_cache_dir}/artifact` (the model-artifact
// fetch cache the store's own internal `ArtifactStore`, rooted at
// `{root}/models`, uses). Both are always local paths, even when `root` is a
// cloud scheme, since USearch and the model loader both read the local
// filesystem.
let local_cache_dir = std::path::PathBuf::from("/var/lib/jammi/cache");
let result_store = Arc::new(ResultStore::with_root(
    root,
    registry,
    catalog,
    AnnIndexConfig::default(),
    local_cache_dir,
)?);
Ok(()) }
}

Every result table the session creates writes its Parquet and sidecar ANN index under a tenant-attributed prefix of that root ({root}/_global/{table}.parquet for a GLOBAL table, {root}/{tenant-uuid}/{table}.parquet for a tenant’s); delete_table_files and the crash-recovery pass operate against the same backend. Model artifacts (ResultStore::artifact_store) share the root’s models/ sub-prefix, attributed the same way (models/{seg}/{job_id}/…) — one storage knob serves both result tables and trained models.

Config-driven result storage

A deployment usually does not hand-build the ResultStore — it sets a [storage] section in the config file and lets the session do it. result_root is the storage URL result tables are rooted at; cloud carries the driver credentials, and is the default cloud config the session threads to every driver it builds — both for the result root and for cloud data sources whose add_source call carries no inline credentials.

[storage]
result_root = "r2://jammi-results/prod"

[storage.cloud.r2]
account_id = "abc123def456"
# access_key_id / secret_access_key are read from the environment — see below.

Both fields are optional. With result_root unset, result tables stay on local disk under {artifact_dir}/jammi_db/. The catalog backend is independent of this setting (configure it under [catalog]); [storage] governs only result-table and source object storage.

[storage.cloud] is an externally tagged section — the provider name (s3, r2, gcs, azure) is its own table, and the remaining keys mirror the matching config-side section:

# AWS S3 (region in TOML, secrets from env)
[storage.cloud.s3]
region = "us-east-1"
# Google Cloud Storage — `service_account` accepts inline JSON or `{ file = "…" }`
[storage.cloud.gcs]
service_account = { file = "/etc/jammi/sa.json" }
# Azure Blob
[storage.cloud.azure]
account_name = "mystorage"

Credentials come from the environment

Secrets are deploy secrets, not config-file values. The S3 and R2 drivers build on object_store’s AmazonS3Builder::from_env(), which reads:

Env varUsed for
AWS_ACCESS_KEY_IDS3 / R2 access key id
AWS_SECRET_ACCESS_KEYS3 / R2 secret access key
AWS_SESSION_TOKENoptional STS session token (S3)
AWS_ENDPOINToptional S3 endpoint override
AWS_REGIONoptional S3 region

GCS reads GOOGLE_APPLICATION_CREDENTIALS (or Workload Identity); Azure reads the standard AZURE_* chain. So the R2 example above needs only account_id in the TOML — AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in the container’s environment supply the rest. Any field you do set in [storage.cloud] overrides the value the env chain produced. A half-set credential pair (an access_key_id with no secret_access_key, or vice-versa) is rejected at config-load time rather than on the first request.

How the layout maps onto buckets

For a result table named papers__text_embedding__bge-m3__20260520T120000Z_abc12345, the engine writes the Parquet plus one ANN segment bundle per index segment. A table built in a single embedding pass has one segment, seg0:

s3://benchmarks/jammi_db/papers__text_embedding__bge-m3__….parquet
s3://benchmarks/jammi_db/papers__text_embedding__bge-m3__…__seg0.usearch
s3://benchmarks/jammi_db/papers__text_embedding__bge-m3__…__seg0.rowmap
s3://benchmarks/jammi_db/papers__text_embedding__bge-m3__…__seg0.manifest.json

A table’s ANN index is a set of segments: appending a batch of new rows writes a new bundle (…__seg1.*, …__seg2.*, …) beside the existing ones and leaves them untouched, and a search merges every segment. A quantized-precision segment adds a …__seg{N}.rawf32 rescore companion; a Binary one adds a …__seg{N}.threshold companion too.

The segment layout is the same on every backend; the only difference is the driver under the hood. USearch’s path-based FFI is bridged through a tempfile for cloud schemes so its save / load calls work unchanged, and each remote segment is materialised once into a content-addressed local cache before it is opened.

Deploy as a Server

Jammi can run as an Arrow Flight SQL server, making all registered sources and embedding tables queryable from any Arrow-compatible client. Use this when multiple services, BI tools, or non-Rust/Python consumers need to query Jammi’s data.

The workflow

The server is a read path. Deploy the server, then set up your data through it — with the jammi CLI (a strict gRPC client) or the library — so other systems can query it:

# 1. Start the server
jammi-server

# 2. Register sources against the running server with the CLI
jammi --target grpc://127.0.0.1:8081 \
  sources add patents --url /data/patents.parquet --format parquet

# 3. Generate embeddings (library or Python — not available over Flight SQL)
python3 -c '
import jammi
db = jammi.connect("file:///var/lib/jammi")
db.generate_embeddings(source="patents", model="sentence-transformers/all-MiniLM-L6-v2", columns=["abstract"], key="id", modality="text")
'

Connecting with Arrow Flight SQL

Python (pyarrow)

from pyarrow.flight import FlightClient, FlightDescriptor

client = FlightClient("grpc://localhost:8081")

# Run a SQL query
info = client.get_flight_info(
    FlightDescriptor.for_command(b"SELECT id, title, year FROM patents.public.patents WHERE year > 2020")
)
reader = client.do_get(info.endpoints[0].ticket)
table = reader.read_all()
print(table.to_pandas())

Query embedding tables

Embedding tables are registered in DataFusion and queryable via SQL:

# List all embedding tables
info = client.get_flight_info(
    FlightDescriptor.for_command(b"SELECT table_name FROM information_schema.tables WHERE table_schema = 'jammi'")
)

# Query vectors directly
info = client.get_flight_info(
    FlightDescriptor.for_command(b"SELECT _row_id, _model_id FROM \"jammi.patents__embedding__all-MiniLM-L6-v2__20260325\" LIMIT 10")
)

JDBC

Flight SQL is compatible with JDBC drivers that support the Arrow Flight SQL protocol, enabling access from Java applications, BI tools (Superset, DBeaver, Tableau), and SQL editors.

Server configuration

[server]
flight_listen = "0.0.0.0:8081"
preload_models = ["sentence-transformers/all-MiniLM-L6-v2"]

[logging]
level = "info"
format = "json"    # structured logging for production

Preloading models

Models listed in preload_models are loaded into the cache at startup, BEFORE /readyz reports ready — it answers 503 {"status":"not_ready", "detail":"preloading i/n"} meanwhile, /healthz stays 200 (no startupProbe needed), and this process’s claim loop waits at its gate with its workers.state row reading warming, so no job is claimed by a cold process. An entry is a bare id, whose task comes from the catalog’s models row, or { id, task } naming the task explicitly — required for a local: path, which has no row. A listed model that cannot load, a bare id with no models row, or an unknown task token is a startup error: the server exits non-zero instead of serving. A shutdown signal during the preload exits 0 without ever serving.

[server]
preload_models = [
    "sentence-transformers/all-MiniLM-L6-v2",
    { id = "local:/models/bge-small", task = "text_embedding" },
]

Service tiers

One server binary scales to many deployment shapes by mounting only the gRPC service tiers a deployment needs — no per-shape rebuild. The core tier is always mounted: CatalogService (the control plane — tenant binding, the GetServerInfo handshake, and source / model / channel / mutable-table / topic administration), EmbeddingService, InferenceService, PipelineService, AuditService, JobService (durable job submission — every deployment accepts a job and reports its status), and the Flight SQL surface. Two optional tiers are runtime-selectable via [server] services:

TierServiceRole
eventTriggerServicetopic / publish / subscribe streams
evalEvalServiceper-query evaluation arrays
[server]
services = "all"             # all-in-one: every tier (the default)
# services = ["event"]       # serve + event box
# services = []              # serve-only: core tier only

Running jobs is not a tier. Whether a process claims and executes the jobs it accepted is [worker] enabled (see Configuration): a request node runs [worker] enabled = false and still accepts every submission; a compute node runs services = [] with [worker] enabled = true, kinds = [...] and works what the request nodes queued.

A deployment advertises exactly the tiers it mounted over the wire, so a client can negotiate capability before calling a verb:

info = db.get_server_info()
# {"version": "...", "features": [...], "storage_backends": [...],
#  "services": ["core", "eval", "event"]}
if "eval" in info["services"]:
    db.eval_per_query(...)

Reaching a verb whose tier was not mounted returns a truthful Unimplemented (“not enabled on this deployment”) rather than a misleading success — the service-mount analog of the client’s build-by-capability connect(target).

Runtime config, no compile features. Every tier compiles into every build — no cargo feature gates a tier, so there is no compile ceiling for the selection to hit; a token naming no tier is a startup error, not a silent drop. Override the selection with JAMMI_SERVER__SERVICES (all, or a comma-separated token list — empty for serve-only).

GPU configuration

For GPU-accelerated inference in production:

[gpu]
device = 0            # CUDA device index
memory_limit = "auto"
memory_fraction = 0.9
require_gpu = false   # fail fast if the GPU is unavailable instead of CPU fallback

[inference]
batch_size = 64
max_loaded_models = 3

Set gpu.device = -1 for CPU-only deployment. On a GPU build, an unavailable device degrades to CPU with a warning by default; set gpu.require_gpu = true to fail fast instead.

Environment variable overrides

Every config field can be overridden with environment variables, useful for containerized deployments: JAMMI_<PATH>, path segments joined by __, works for every field in the tree — not a hand-picked subset — and a bare JAMMI_<FIELD> (no __) works for a top-level one. See Configuration for the full rule.

JAMMI_SERVER__FLIGHT_LISTEN=0.0.0.0:9081 \
JAMMI_GPU__DEVICE=-1 \
JAMMI_LOGGING__FORMAT=json \
jammi-server

A production shape, entirely from the environment

A Postgres catalog, a JetStream broker, an S3 result root, and a file-backed audit signing key — Shape C’s stack — need no TOML file at all: every field resolves from JAMMI_* variables through the same layered loader.

export JAMMI_CATALOG__POSTGRES__URL="postgres://jammi:${POSTGRES_PASSWORD}@postgres.internal:5432/jammi?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca-certificates.crt"
export JAMMI_CATALOG__POSTGRES__POOL_SIZE=16
export JAMMI_BROKER__JET_STREAM__URL="nats://nats.internal:4222"
export JAMMI_BROKER__JET_STREAM__CREDENTIALS__FILE=/run/secrets/nats.creds
export JAMMI_STORAGE__RESULT_ROOT="s3://jammi-results/prod"
export JAMMI_STORAGE__CLOUD__S3__REGION=us-east-1
export JAMMI_SIGNING_KEY__FILE__PATH=/run/secrets/jammi-audit-master-key
export JAMMI_MODELS__HUB_TOKEN__FILE=/run/secrets/hf-token
export JAMMI_SERVER__SERVICES=all
jammi-server

The equivalent TOML file — the two are interchangeable, and either one overrides the other’s fields when both are present:

[catalog.postgres]
url = "postgres://jammi:${POSTGRES_PASSWORD}@postgres.internal:5432/jammi?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca-certificates.crt"
pool_size = 16

[broker.jet_stream]
url = "nats://nats.internal:4222"
credentials = { file = "/run/secrets/nats.creds" }

[storage]
result_root = "s3://jammi-results/prod"

[storage.cloud.s3]
region = "us-east-1"

[signing_key.file]
path = "/run/secrets/jammi-audit-master-key"

[models]
hub_token = { file = "/run/secrets/hf-token" }

[server]
services = "all"

Health, readiness, and metrics

The server exposes three HTTP side-channel endpoints on port 8080:

curl http://localhost:8080/healthz
# {"status":"ok","version":"0.8.0"}

curl http://localhost:8080/readyz
# {"status":"ready"}

curl http://localhost:8080/metrics
# jammi_grpc_requests_total 0
# jammi_flight_queries_total 0
# jammi_eval_invocations_total 0
# jammi_search_latency_seconds_bucket{...} 0

/healthz is a liveness probe — 200 while the process can keep its leases and its claim loop alive; 503 {"status":"unhealthy","lease_keeper": false|true,"claim_loop":"…"} when the lease keeper thread is dead (every lease this process holds is lost) or the claim loop task panicked (claim_loop: "failed"). A stopped or aborted loop, a process with no loop, and a DRAIN in progress are all 200 — liveness decides restarts, never routing, and there is no slow-step detection (the runtime owns “how long is too long”). /readyz is a readiness probe — 200 means the catalog backend responded; 503 means it didn’t, or the server is draining ("detail": "draining"), and traffic should be drained from this instance. Point your load balancer at /readyz.

/metrics exposes a small, substrate-level set of Prometheus counters (gRPC requests, Flight SQL queries, eval invocations, refusals at the [server.limits] edge) plus a search-latency histogram, and five gauges:

GaugePresent onSource
jammi_jobs_queued{kind}worker-enabled processesthe catalog, sampled every [worker] metrics_sample_secs (default 5) by a dedicated task — one GROUP BY kind, status per tick, never on a scrape; a held (claimable = false) row counts as queued
jammi_jobs_running{kind}worker-enabled processesthe same sample
jammi_worker_jobs_in_flightworker-enabled processesloop-claimed jobs running under a live lease hold (0 or 1); an inline run_now is never counted
jammi_worker_claim_loop_upworker-enabled processes1 while the claim loop task runs, 0 once it stopped, aborted or failed
jammi_lease_heartbeat_age_secondsevery processseconds since the lease keeper last completed a renewal pass

A process without a claim loop omits the worker families (absent, never 0). No gauge carries a tenant label. jammi_jobs_queued{kind} is the autoscaling input for a compute tier; a scrape issues no catalog statement.

What the server can and cannot do

OperationAvailable over Flight SQL?Available over typed gRPC?
SQL queries on source tablesYes— (use Flight SQL)
SQL queries on embedding tablesYes— (use Flight SQL)
Joins, aggregations, filtersYes— (use Flight SQL)
Generate embeddingsNo — use library or Python packageYes — EmbeddingService.GenerateEmbeddings
Semantic vector searchNo — use library or Python packageYes — EmbeddingService.Search
InferenceNo — use library or Python packageYes — InferenceService.Infer
Fine-tuning (and graph / context-predictor training)No — use library or Python packageYes — JobService.SubmitJob (core; runs where [worker] enabled)
Context-predictor predictionNo — use library or Python packageYes — InferenceService.Predict
EvaluationNo — use library or Python packageYes — EvalService (eval tier)

The Flight SQL surface is a query interface (read path); the ML operations are not SQL, so they ride the typed gRPC surface instead. Set up your data and run training/inference through the Rust library, the jammi-ai / jammi-client Python package, or — for a remote engine — those same verbs over gRPC, then query the results over Flight SQL. The CLI is a strict gRPC client that registers sources and drives the admin surfaces against a running server; it carries no ML verbs and does not run the engine in-process.

The typed gRPC surface is what an edge runtime speaks (it has no HTTP/2 client for Flight SQL’s bidirectional streaming). EmbeddingService serves AddSource, GenerateEmbeddings, EncodeQuery, and Search over plain gRPC — and, since tonic-web is mounted, over gRPC-web — so an edge function running the engine as a sidecar can ingest, encode, and search without the library. Search accepts a precomputed vector or an existing row_key (query-by-example, with the vector resolved inside the engine); see Semantic Search. JobService (core, always mounted) serves all three training kinds over gRPC and InferenceService.Predict serves a trained context predictor — so a client can offload training and prediction to a GPU server with the same verb surface the embedded engine exposes.

Shutdown: DRAIN and RELEASE

The server has two shutdown modes, PostgreSQL’s mapping: SIGTERM = DRAIN (smart) and SIGINT = RELEASE (fast); any signal received while draining is a RELEASE. There is no engine-side drain timeout — the runtime’s grace period (terminationGracePeriodSeconds, stop_grace_period) bounds a DRAIN, then SIGKILL.

DRAIN (kill -TERM, docker stop, a Kubernetes pod deletion): /readyz flips to 503 {"status":"not_ready","detail":"draining"}; the listener closes and in-flight requests finish; every idle WaitJob / Subscribe stream is ended with UNAVAILABLE “server draining” (counted under jammi_grpc_refused_total{reason="draining"}); the embedded worker finishes the job it is running — its lease keeps renewing, every epoch bundle lands, the job reaches completed under the same attempt — and claims no more. Then the catalog is released and the process exits 0. An in-flight unary (an inline run_now such as GenerateEmbeddings) is bounded only by the grace period. A DRAIN that outlives the grace is SIGKILLed: the running job’s lease then expires after one [lease] duration_secs, a successor requeues it (resuming from its last epoch bundle) and it consumes one attempt. A gang RANK this process holds for another host’s training job ([server] peer_advertise) ends at once on DRAIN with the Drain reason: the coordinator hands that job’s lease back (releases + 1), so a rolling restart of the peer tier costs the job no attempt — the next attempt lands within one idle poll over the members still listed. Every other way a rank is lost mid-run — the process SIGKILLed, its stream dropped, or silent past [worker] rank_timeout_secs — retires the coordinator’s attempt with no terminal write: its lease is left to expire, a successor requeues the job and the retry consumes one attempt.

RELEASE (kill -INT, Ctrl+C, jammi-server release, or a second SIGTERM): connections are severed at once; every job lease this process holds is handed back — the row stays running under this instance with lease_expires_at = NULL and releases + 1, and a compute job’s linked building-table lease is NULLed with it — the loop is stopped (cooperatively while no job is under a hold, by abort while one is), the workers row is deleted, the catalog released, and the process exits 0 at once, or exit code 3 when its own evidence does not confirm every lease was handed back (see below). A released row is claimable by any other worker within one [worker] idle_poll_secs, never one lease window; the reclaim cap counts attempts - releases, so a rollout storm of releases never burns the three attempts a genuine crash does. The abandoned training thread never finalizes: its lease is gone and its next epoch boundary bails without a bundle, so the _resume manifest epoch never advances past the last landed one PROVIDED the RELEASE confirmed (exit 0) — under a degraded RELEASE (exit 3) one further epoch bundle may still land before the affected hold’s lost flag flips at its next renewal. Three named exceptions (§3.5 of the design): a claim caught between its COMMIT and its hold registration past one heartbeat keeps its live lease and is recovered by the expiry path (one lease window, attempts + 1, never failed); a compute job whose linked building sweep errored while the jobs sweep succeeded makes the successor back off once (one lease window) before it re-materializes; and a RELEASE whose own sweep statement for a given lease’s table itself errored (that statement’s own Err, read back as None) — that lease was never written and falls to the expiry path, so a successor reclaims it within one [lease] duration_secs rather than one idle poll.

Exit code 3 (DEGRADED) does not by itself say which lease, if any, is still live — it means at least one determinant of a confirmed release is missing, and WHICH determinant is missing decides what is and is not established; there is no single consequence for “degraded”:

  • The RELEASE call itself returned an error: nothing further is established — whether any lease this instance held was handed back is unknown.
  • A sweep statement for a given lease’s own table itself failed (the exception above): that is the one case with a definite, table-specific cost. The jobs table costs one attempt (attempts + 1, releases untouched) within one [lease] duration_secs; the linked building row lives in result_tables, which carries no attempts/releases columns at all, so its cost is the “backs off once” recovery documented above, never an attempt.
  • The keeper’s per-hold pass could not be confirmed to run (Unobserved), or reported a per-hold failure, while both sweep statements still confirm: every row the sweep itself matched — queued, not under an active hold — is confirmed released, since that UPDATE’s own commit is what the sweep’s Some count reports. Nothing is established about a job under an active hold at that moment: the sweep predicate never matches a held row, and the pass whose job it was to release that hold is exactly the one whose evidence is missing.
  • The loop’s terminal state was not genuinely witnessed (stop_witnessed == false) while both sweep statements still confirm: every row the sweep matched by the time it ran is confirmed released. Nothing is established about a claim that commits AFTER the sweep runs — stop_witnessed == false means precisely that such a claim is not ruled out, and a claim like that is outside the sweep’s predicate, keeping a live lease with releases untouched and falling to the expiry path, the same shape as the exception above.

The process still exits at once either way (exit code 3, never a hang).

jammi-server release [--pid N] sends SIGINT to N (default 1, the container entrypoint) and exits 0 when the signal was sent — that is ALL its own exit code means; it signals a process that is not its child, so it cannot wait on the RELEASE it triggered and never reports that outcome. It is the uniform RELEASE actuator for a preStop hook, since the distroless images carry no shell for kill, and it knows nothing about jobs. The RELEASE outcome itself (confirmed or degraded) is read from the serving process’s own exit code (0 or 3) via the supervisor (lastState.terminated.exitCode, docker inspect --format='{{.State.ExitCode}}') — under restartPolicy: Always (or equivalent), exit code 3 restarts identically to 0. The library reaches the same mechanism through EmbeddedWorker::release_and_stop and Python’s close(release=True); there the process survives, so a thread that reaches finalize before a successor claims may still land completed — the one documented divergence from the server, which exits.

ListWorkers / jammi workers show each claim loop’s state: warming (the process is preloading; nothing claimed yet), claiming, or draining.

The identity seam

The server performs no authentication on its own — treat it as trusted-network and put access control in front of it, or supply your own TenantResolver at the seam the engine ships for exactly this. The contract of record for that seam is on the security page; the sketch below is only the minimal shape a caller wires in. See:

  • Security Posture for the full threat model — what the engine defends, what it explicitly does not, and the trusted-network assumption every deployment inherits; and
  • Bring your own auth for the TenantResolver seam itself: one resolver, plugged into assemble_grpc_chain once, authenticates both the gRPC control plane and the Flight db.sql lane.

Sketch: an authenticating proxy in front. The engine does not invent tenants (the one rule everything else follows from — Design Philosophy); a proxy that already verified the caller injects the fact, and the resolver only reads it:

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_server;
extern crate tonic;
extern crate uuid;
use jammi_db::TenantId;
use jammi_server::grpc::session::{TenantResolver, TenantScope};
use tonic::{metadata::MetadataMap, Status};
use uuid::Uuid;

struct ProxyHeaderResolver;

#[tonic::async_trait]
impl TenantResolver for ProxyHeaderResolver {
    // The proxy verified the caller upstream and sets this header itself —
    // never a client-controlled one. Read ONLY the proxy-set value and
    // reject when it is absent: no header, no fallback tenant.
    async fn resolve(&self, metadata: &MetadataMap) -> Result<TenantScope, Status> {
        let raw = metadata
            .get("x-jammi-verified-tenant")
            .and_then(|v| v.to_str().ok())
            .ok_or_else(|| Status::unauthenticated("no verified tenant"))?;
        let uuid = Uuid::parse_str(raw).map_err(|_| Status::unauthenticated("bad tenant"))?;
        let tenant = TenantId::from_uuid(uuid).map_err(|_| Status::unauthenticated("bad tenant"))?;
        Ok(TenantScope::Tenant(tenant))
    }
}
}

That is the whole pattern: the proxy authenticates and sets one header the client cannot forge (metadata stripped from the inbound request and re-added by the proxy itself); the resolver trusts only its own header and fails closed when it is missing.

Transport encryption is a separate decision from the identity seam above — see Security Posture for why the engine ships no TLS code path and how a deployer’s runtime terminates it in front.

Run the server where only trusted clients can reach it (a private network / VPC with the gRPC + health ports, 8081 / 8080, closed to the public internet; network policy or a firewall; or an authenticating reverse proxy) — or wire an authenticating TenantResolver in front — before exposing it beyond a trusted caller.

The peer listener (I-PEER / I-GANG). [server] peer_bind — unset by default — opens a THIRD listener that serves jammi.v1.peer.PeerService to other replicas of the same deployment (segment search for the segments this replica owns; see Beyond one node) and jammi.v1.gang.GangService (RunRank — a coordinator admitting this replica into a multi-host training run; see Security Posture). Both are deliberately outside the identity seam: their routes are built outside assemble_grpc_chain, are never wrapped by the TenantResolverLayer, never advertised by GetServerInfo, and the public listener answers UNIMPLEMENTED for their paths. The peer owner binds no tenant — the request carries none — because tenant scope was already enforced by the coordinator (the replica that received the Search), which resolved the table through its own tenant-scoped catalog read before fanning out; the owner enforces only that every requested segment belongs to the named table. The gang member likewise binds no caller-supplied tenant: a RunRank call names job coordinates only, the member derives the tenant from the verified jobs row and resolves the job’s training set under that tenant alone (never another tenant’s table, never a global one), so a coordinator on this listener holding a job’s own coordinates is admitted for that job and can read nothing beyond what that job’s row names. The invariant every deployment inherits: every client of peer_bind is a jammi coordinator. Bind it on a private interface behind network policy and, where the runtime provides it, mTLS; on a routable interface without them it exposes cross-tenant segment reads (I-PEER) and ungated gang admission (I-GANG) to anyone who can reach the port.

[server] peer_advertise makes a replica a gang MEMBER, distinct from peer_bind merely opening the listener: it is the host:port OTHER replicas dial, and setting it writes this process’s instances.peer_addr/ result_root columns (requires peer_bind to be set too, refused naming both keys otherwise). The row records the configured root SPELLING verbatim, but the gang-membership predicate does NOT consult it in this unit — root identity across spellings, and any membership predicate built on it, is a separate, not-yet-built unit. Root-string equality was never more than NECESSARY, never SUFFICIENT, for shared storage: two byte-identical roots on two filesystems are indistinguishable to a string comparison; the attestation VERIFY (a later unit) is what establishes sufficiency. The prune window a stale member’s row survives before deletion is strictly beyond the liveness margin used to judge freshness (3 × lease vs. 2 × lease), so a pruned-but-still-live process rejoins its gang on its very next heartbeat with no restart — the lease keeper’s reregister re-upserts the whole membership tuple (instances + its workers row, if any) in one transaction.

Deploying as a container

The OSS server ships as two public Docker images on GHCR:

  • ghcr.io/f-inverse/jammi-ai-serverCPU, built from a distroless base.
  • ghcr.io/f-inverse/jammi-ai-server-cu12CUDA, for GPU-accelerated inference (see GPU serving).

The generic CPU tags (:latest, :vX.Y.Z, :vX.Y, and their sha-<sha> equivalents) are multi-arch image indexes: linux/amd64 and linux/arm64, so docker pull/docker run resolves the right member for the host’s architecture automatically. The self-contained CPU tags (:selfcontained, :selfcontained-sha-<sha>) and the CUDA (-cu12) tags are linux/amd64 only, pushed under the same CPU image name in the self-contained case.

Both run as the nonroot user (uid 65532), expose the same 8080 / 8081 ports the local binary listens on, and share the same tag scheme (:latest, :vX.Y.Z, :vX.Y). Both :latest tags are re-pointed by every v* release tag (never by a prerelease); the CPU :latest can additionally be re-pointed to the current main by a manual build-and-push-main dispatch. The image entrypoint is jammi-server, so docker run <image> brings up the server with zero config — a local SQLite catalog, the in-memory broker, and every service tier, no TOML required. The jammi admin CLI also ships in the image for running verbs against the server. The examples below use the CPU image, and bind both published ports to 127.0.0.1: the server itself performs no authentication (see The identity seam), so publishing to every interface would expose an unauthenticated admin surface to the host’s whole network — a terminator or reverse proxy that itself binds a public interface is what a deployment fronts these loopback-bound ports with.

# Turnkey: zero config, no TOML.
docker run --rm \
  -p 127.0.0.1:8080:8080 -p 127.0.0.1:8081:8081 \
  -v jammi_data:/var/lib/jammi \
  ghcr.io/f-inverse/jammi-ai-server:latest

To supply your own config, pass --config to the jammi-server entrypoint:

docker run --rm \
  -p 127.0.0.1:8080:8080 -p 127.0.0.1:8081:8081 \
  -v jammi_data:/var/lib/jammi \
  -v $(pwd)/jammi.toml:/etc/jammi/jammi.toml:ro \
  ghcr.io/f-inverse/jammi-ai-server:latest --config /etc/jammi/jammi.toml

A tested Compose stack (server + Postgres catalog + JetStream broker) lives at deploy/docker-compose.yml, exercised end to end by the compose-smoke workflow — see Reference Topologies: Shape B for the full file, its environment variables, and what the smoke proves.

Persistence

/var/lib/jammi holds the catalog DB, model weights, and indices. Zero-config jammi-server writes its SQLite catalog there (the image sets JAMMI_ARTIFACT_DIR=/var/lib/jammi) and its Hugging Face Hub cache at /var/lib/jammi/hf (the image sets HF_HOME=/var/lib/jammi/hf, the fallback [models] hub_cache_dir reads when unset). On the jammi-ai-server-cu12 image the same volume also holds the CUDA PTX-JIT compute cache at /var/lib/jammi/.nv-cache (see GPU serving) — mounting the volume is what makes both caches durable across container restarts. The Dockerfile declares /var/lib/jammi as a VOLUME owned by uid 65532 — a named Docker volume or no mount at all just works; a bind mount must have the host directory writable by uid 65532:

# Bind mount on the host.
sudo chown -R 65532:65532 /opt/jammi/data
docker run -v /opt/jammi/data:/var/lib/jammi ...

A named Docker volume (the compose default) sidesteps that step because Docker provisions ownership for the container’s user automatically.

Configuration

The image needs no config — it boots zero-config, running jammi-server serve (the implicit default subcommand) via the entrypoint. To override defaults under docker run, pass --config as shown above, or set JAMMI_* env vars — the [gpu], [server], and services knobs documented above all apply. Under Compose, the same two options carry over unchanged (command: appends to the entrypoint the same way docker run <image> --config ... does; environment: sets the same JAMMI_* vars) — see Reference Topologies: Shape B for a complete, tested Compose file doing exactly this against a Postgres catalog and JetStream broker.

GPU serving

The jammi-ai-server-cu12 image builds with candle’s CUDA backend on an NVIDIA CUDA 12.6 runtime base, so libcudart and the rest of the CUDA runtime libraries are present in the image. It carries the same turnkey jammi CLI as the CPU image. Run it on a host with the NVIDIA Container Toolkit and pass --gpus all. Both :latest tags are re-pointed by every v* release tag (never by a prerelease); the CPU :latest can additionally be re-pointed to the current main by a manual build-and-push-main dispatch — pin an exact :vX.Y.Z tag for a reproducible GPU-node deploy:

# Turnkey: zero config, GPU inference.
docker run --rm --gpus all \
  -p 127.0.0.1:8080:8080 -p 127.0.0.1:8081:8081 \
  -v jammi_data:/var/lib/jammi \
  ghcr.io/f-inverse/jammi-ai-server-cu12:latest

With no TOML the server selects GPU device 0 by default. To override the device or any other knob, pass a config with --config (or bind-mount it straight at /etc/jammi/jammi.toml, one of the resolution order’s own default locations — see Configuration — and drop the flag entirely):

docker run --rm --gpus all \
  -p 127.0.0.1:8080:8080 -p 127.0.0.1:8081:8081 \
  -v jammi_data:/var/lib/jammi \
  -v $(pwd)/jammi.toml:/etc/jammi/jammi.toml:ro \
  ghcr.io/f-inverse/jammi-ai-server-cu12:latest --config /etc/jammi/jammi.toml

Set gpu.device = 0 in jammi.toml (or JAMMI_GPU__DEVICE=0) to select the CUDA device; see GPU configuration. The image is compiled for compute capability 8.0 (Ampere) and runs on 8.0 and every newer datacenter GPU — A10/A6000 (8.6), L40S (8.9), H100 (9.0) — via PTX forward-compatibility. Turing GPUs (e.g. Tesla T4, 7.5) are not supported.

Minimum NVIDIA driver: the image is built against the CUDA 12.6 toolkit and ships single-architecture PTX, so on any GPU newer than 8.0 the driver JIT-compiles that PTX at first model load. This requires a driver new enough for the CUDA 12.6 runtime — Linux: r560 or later (≥ 560.28.03). An older driver (for example 550.x, which tops out at the CUDA 12.4 PTX ISA) can reject the image’s newer PTX at load with CUDA_ERROR_UNSUPPORTED_PTX_VERSION / CUDA_ERROR_INVALID_PTX, even on a supported architecture. nvidia-smi reports the installed driver and its max CUDA version.

JIT cache persistence: the image sets CUDA_CACHE_PATH=/var/lib/jammi/.nv-cache, so the driver’s compiled PTX→SASS cache lands on the /var/lib/jammi volume rather than the container’s ephemeral filesystem. With the volume mounted, the JIT cost above is paid once — a subsequent cold start on the same host reuses the cached SASS instead of re-JIT-ing every model load. Without the volume mounted, each container restart starts with an empty cache and re-pays the JIT. CUDA_CACHE_MAXSIZE (bytes) caps the cache size if the default cap is too small for the set of models you serve.

The CPU image ignores GPU config and runs inference on the CPU.

Building from source

The Dockerfile lives at the workspace root and uses BuildKit cache mounts for the cargo registry and target directory:

# CPU image (default).
DOCKER_BUILDKIT=1 docker build -t jammi-ai-server:dev -f Dockerfile .

# CUDA image — selected by the RUNTIME_VARIANT build-arg.
DOCKER_BUILDKIT=1 docker build -t jammi-ai-server-cu12:dev \
  --build-arg RUNTIME_VARIANT=runtime-cuda -f Dockerfile .

Cold builds take ~30 minutes (the workspace is large); warm builds with cache hits land at ~3 minutes. The CUDA build additionally compiles candle’s CUDA kernels, so its cold build is longer.

Supply chain: SBOM, provenance, attestations

Every image server-image.yml pushes to GHCR — the CPU :latest / :vX.Y.Z tags, the CUDA -cu12 tags, and the dispatch-only :selfcontained build — carries a docker/build-push-action SPDX SBOM and mode=max build provenance attached to the image manifest, plus a Sigstore-signed actions/attest-build-provenance attestation published to the repository’s attestation store for the exact digest that job pushed — never a mutable tag, which a concurrent run could re-point. Verify an image you pulled against that digest:

gh attestation verify oci://ghcr.io/f-inverse/jammi-ai-server@sha256:<digest> \
  --repo f-inverse/jammi-ai

CI checks both of these, in two separate steps, on every push job: ci/scripts/assert_image_attestations.sh asserts (via docker buildx imagetools inspect) that the pushed digest’s own OCI index carries BOTH a non-empty SBOM and a non-empty provenance attestation manifest; a positively empty accessor fails the job immediately rather than falling back to a looser check. Which SHAPE each attestation must then match is decided by the index’s own platform count, read off the raw OCI index, never by the attestation’s own key spelling: a multi-platform index (the merged CPU manifest list, linux/amd64 + linux/arm64) requires the per-platform map, its key set checked for exact equality against the index’s platform set, so an attestation covering only one of the merged legs — the other landed unattested — fails the job by name, rather than passing because some platform was attested. A single-platform push (the CUDA -cu12 tags, the dispatch-only :selfcontained build, and each per-arch sha-<sha>-<arch> leg before it is merged) instead requires the FLAT predicate object buildx actually emits for a single platform — {"SLSA":{...}} / {"SPDX":{...}} — since that flat object and the per-platform map are structurally indistinguishable by key inspection alone (both are “a non-empty object of non-empty objects”); the index’s platform count is what breaks the tie. A separate gh attestation verify oci://... --bundle-from-oci step then verifies the Sigstore-signed bundle attest-build-provenance published as an OCI referrer — the check above never touches that bundle. The compose-smoke workflow’s own build (load: true, loaded into the runner’s daemon, never pushed) carries neither: sbom and provenance are explicitly false there, since the stock Docker exporter a load build uses cannot carry attestations.

Reference Topologies

The same engine binary — and the same Rust crate / Python wheel for the embedded case — serves every deployment shape below. Nothing here is a different code path: each shape is a point on the backend-driver configuration surface (catalog, trigger broker, object storage) plus a process count. This page pins each shape to the concrete artifact that realises it: a config, a tested deploy/ Compose file, or a kustomize manifest tree.

Shape A — single-process embedded

Artifact: the jammi-db / jammi-ai Rust crates, or the jammi-ai Python wheel. No server process.

The workspace defaults ARE Shape A: SQLite catalog under artifact_dir, result tables on local disk, the in-memory trigger broker, an in-process model cache. No config file is required.

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
use jammi_db::config::JammiConfig;
use jammi_ai::session::InferenceSession;

async fn run() -> Result<(), Box<dyn std::error::Error>> {
let config = JammiConfig::load(None)?; // SQLite + local fs + in-memory broker
let session = InferenceSession::new(config).await?;
let _ = session;
Ok(())
}
}
import jammi

# `file://` resolves to the compiled in-process engine (the `jammi-ai[embedded]`
# extra), constructed through the same `JammiConfig` resolution chain as
# `jammi-server` — via the engine's own `jammi_native.open_local`.
db = jammi.connect("file:///var/lib/jammi")

Notebooks, the jammi CLI against a local directory, single-machine batch jobs, and laptop development all run this shape unmodified — see Quickstart: Rust and Quickstart: Python.

Shape B — single-tenant server

Artifact: deploy/docker-compose.yml — one jammi-server process, a Postgres catalog, and a JetStream broker, tested end to end by the compose-smoke workflow on every push to main and nightly.

# Shape B: `jammi-server` against Postgres (catalog) + JetStream (broker) —
# a worked example of a *configuration* change, not orchestration policy
# (orchestration is the deployer's runtime; see `docs/guide/src/philosophy.md`).
# Every knob below is one this compose file only sets, never invents: the
# image's env-only configuration layer (`crates/jammi-db/src/config/`)
# resolves `JAMMI_CATALOG__POSTGRES__URL`, `JAMMI_BROKER__JET_STREAM__URL`,
# and `JAMMI_SERVER__SERVICES` exactly as documented in
# `docs/guide/src/reference-topologies.md`.
#
# `jammi-server`'s healthcheck is exec-form `jammi-server probe` — the
# runtime image is distroless (no shell, no curl/wget), so the readiness
# probe is a generic CLI subcommand the binary itself ships (see
# `crates/jammi-server/src/probe.rs`), the same shape as Postgres's own
# `pg_isready`.
#
# `secrets` live in a git-ignored `.env` file next to this compose file;
# `deploy/.env.example` documents the one required key.
#
# This file has no compute-tier service: Kubernetes runs the compute tier as
# a `StatefulSet` (`deploy/kubernetes/overlays/shape-d`) because a rank's
# peer address must survive a pod restart; Compose has no ordinal-stable
# identity primitive, so a multi-rank Compose deployment lists each rank as
# its own named service instead (`deploy/kubernetes/README.md`).

services:
  jammi-server:
    image: ${JAMMI_IMAGE:-ghcr.io/f-inverse/jammi-ai-server:latest}
    # For local developers only: `docker compose build` compiles this repo's
    # own Dockerfile (CPU variant) instead of pulling the published image.
    # The CI smoke override (`docker-compose.ci.yml`) drops this block and
    # points `JAMMI_IMAGE` at the image it already built + loaded.
    build:
      context: ..
      dockerfile: Dockerfile
    environment:
      JAMMI_CATALOG__POSTGRES__URL: postgres://jammi:jammi@postgres:5432/jammi
      JAMMI_BROKER__JET_STREAM__URL: nats://nats:4222
      JAMMI_SERVER__SERVICES: all
      JAMMI_AUDIT_MASTER_KEY: ${JAMMI_AUDIT_MASTER_KEY:?set it in .env}
    ports:
      # Loopback-bound: the deployer publishes these for a
      # TLS-terminating proxy running on the same host to reach (see
      # `docs/guide/src/security.md`'s transport-encryption section), never
      # for direct exposure to an untrusted network. TLS termination is the
      # deployer's runtime, not this compose file's job.
      - "127.0.0.1:8081:8081" # gRPC + Flight SQL
      - "127.0.0.1:8080:8080" # HTTP side-channel (/healthz, /readyz, /metrics)
    volumes:
      # Artifacts (indices, materialized Parquet, `index_cache`) and the
      # Hugging Face Hub cache (`HF_HOME`, set by the image) — a named
      # volume so docker provisions ownership for distroless's nonroot
      # uid (65532); a bind mount would need host-side `chown` first.
      - jammi-data:/var/lib/jammi
    depends_on:
      postgres:
        condition: service_healthy
      nats:
        condition: service_healthy
    restart: unless-stopped
    # `docker stop` / `docker compose down` send SIGTERM (DRAIN: the in-flight
    # job finishes) and SIGKILL after this grace; 120 s covers a small epoch.
    # SIGINT is RELEASE — the job's lease is handed back at once and the next
    # process to claim it resumes it. `docker compose kill -s SIGINT` delivers
    # that signal but the daemon records a kill as a MANUAL stop, and a manually
    # stopped container is exempt from `restart: unless-stopped`: nothing comes
    # back on its own. To release AND have this policy restart the container,
    # signal the process from the host (`kill -INT $(docker inspect -f
    # '{{.State.Pid}}' <container>)`, what tests/compose/shape_b_release.py
    # does), or follow a compose kill with `docker compose start jammi-server`.
    stop_grace_period: 120s
    healthcheck:
      test: ["CMD", "/usr/local/bin/jammi-server", "probe"]
      interval: 10s
      timeout: 5s
      retries: 12
      start_period: 10s

  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: jammi
      POSTGRES_PASSWORD: jammi
      POSTGRES_DB: jammi
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "-U", "jammi"]
      interval: 5s
      timeout: 5s
      retries: 12

  nats:
    image: nats:2.10-alpine
    command: ["-js", "-m", "8222"]
    healthcheck:
      # The image ships no shell — `wget` is the one HTTP-capable binary
      # nats:2.10-alpine's base carries; `nats-server --help` (no shell
      # dependency either) would prove only that the binary exists, not
      # that JetStream came up.
      test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8222/healthz"]
      interval: 5s
      timeout: 5s
      retries: 12

volumes:
  jammi-data:
  postgres-data:

The jammi-server service’s configuration is entirely environment-driven, through the same JAMMI_<PATH> layer every deployment shape uses:

  • JAMMI_CATALOG__POSTGRES__URL — the Postgres catalog connection.
  • JAMMI_BROKER__JET_STREAM__URL — the JetStream broker connection.
  • JAMMI_SERVER__SERVICESall here (every compiled-in service tier); see Service tiers for narrower selections.
  • JAMMI_AUDIT_MASTER_KEY — read from deploy/.env (copy deploy/.env.example); an absent key makes Compose itself refuse to come up (${JAMMI_AUDIT_MASTER_KEY:?…}) before the container ever starts. Signing is not a “disabled” mode: it stays configured and, unset, only fails at the first audit write; a present-but-malformed key instead makes jammi-server refuse to start.

The published ports (8081, 8080) are bound to 127.0.0.1, not 0.0.0.0: the compose file publishes them for a TLS-terminating proxy running on the same host to reach, never for direct exposure to an untrusted network (see Security Posture).

The healthcheck is exec-form jammi-server probe (see jammi-server probe below) — the runtime image is distroless and ships no shell, so a curl/wget-based HEALTHCHECK is not an option; probe is the same generic-CLI shape as Postgres’s own pg_isready.

What the smoke proves. tests/compose/remote_smoke.py is the shared smoke oracle; shape_b_remote.py (this Compose shape) and shape_c_kube_remote.py (the Kubernetes shape below) are its two drivers, differing only in how they restart the server and in what they assert afterwards — durability on the Compose volume here, the shared catalog on the emptyDir pods there. The oracle asserts get_server_info().broker == "jet_stream" — the RUNTIME driver kind, which fails if JAMMI_BROKER__JET_STREAM__URL were ever dropped from the compose file, unlike the compile-time features list alone — registers the bundled patents.parquet fixture, generates embeddings with the bundled tiny_bert fixture, searches for the stored vector’s own nearest neighbor (an exact self-hit), then restarts the jammi-server container and repeats the same search. It asserts durability across the restart: identical result ids and scores within 1e-6, and an unchanged list_index_segments — the segment bundle on the Postgres-backed catalog and the jammi-data volume survives a container restart, not merely that the server answers again afterward. The workflow itself queries the postgres container directly afterward for the sources table’s row count — the matching runtime oracle on the catalog side, failing if JAMMI_CATALOG__POSTGRES__URL were ever dropped.

Shape C — multi-tenant server

Artifact: N jammi-server replicas behind a load balancer, a shared Postgres catalog, a shared object store, and a shared JetStream (or Postgres-as-)broker. Every replica runs the identical config; only the process count differs from Shape B.

artifact_dir = "/var/lib/jammi"

[catalog.postgres]
url = "${POSTGRES_URL}?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca-certificates.crt"
pool_size = 16
max_lifetime_secs = 1800

[broker.jet_stream]
url = "nats://${NATS_HOST}:4222"
retention_seconds = 604800
credentials = { file = "/var/run/secrets/nats.creds" }

[lease]
duration_secs = 30
heartbeat_secs = 10

[server]
services = ["core", "event", "eval"]

[worker]
enabled = false

The env-only equivalent (see Deploy as a Server: a production shape, entirely from the environment for the fuller walkthrough, including the object-store result root and a file-backed audit signing key):

export JAMMI_CATALOG__POSTGRES__URL="postgres://jammi:${POSTGRES_PASSWORD}@postgres.internal:5432/jammi?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca-certificates.crt"
export JAMMI_CATALOG__POSTGRES__POOL_SIZE=16
export JAMMI_BROKER__JET_STREAM__URL="nats://nats.internal:4222"
export JAMMI_BROKER__JET_STREAM__CREDENTIALS__FILE=/run/secrets/nats.creds
export JAMMI_LEASE__DURATION_SECS=30
export JAMMI_LEASE__HEARTBEAT_SECS=10
export JAMMI_SERVER__SERVICES=all
jammi-server

The guarantee. With every replica pointed at the same Postgres catalog, the one lease primitive documented in Catalog Backend and Trigger Broker: Multi-writer safety holds across the whole fleet: concurrent writers never corrupt or reap each other’s building tables; a crashed replica’s rows are reclaimed at the next session boot or by jammi reconcile; migrations are serialised by an advisory lock. [lease] (duration_secs / heartbeat_secs, JAMMI_LEASE__*) is the one timing knob every leased catalog row shares across the fleet — see Configuration for its full field reference.

Kubernetes (deploy/kubernetes)

Orchestration — which scheduler, how replicas are placed, ingress, TLS termination, autoscaling — is the deployer’s runtime, not the engine’s (see “How it deploys”). deploy/kubernetes/base is the whole shape the engine cares about — a Deployment with readinessProbe against /readyz and runAsNonRoot, a Service, a ConfigMap of the non-secret knobs, an emptyDir for scratch; ingress, TLS, autoscaling, network policy and each cloud’s managed-service annotations are the deployer’s overlay, and the seam is a kustomize patch on the Deployment/Service metadata, never a change to the engine’s knobs. Every PR validates kustomize build + kubeconform --strict --kubernetes-version 1.34.11 over every kustomization in the tree, in ci.yml’s Guard (kubernetes manifests); the kube-smoke workflow additionally stands the ci overlay up on a real kind cluster on push to main, nightly, on manual dispatch, and on any pull request that touches deploy/kubernetes/** or tests/compose/**.

# Shape C's query tier -- the artifact the guide's Kubernetes section
# (docs/guide/src/reference-topologies.md) includes directly. See
# `deploy/kubernetes/README.md` for the boundary this file does and does not
# cover.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: jammi-server
spec:
  # Every replica runs the identical config against the same Postgres catalog
  # and JetStream broker; the one lease + advisory-lock mechanism
  # (docs/guide/src/reference-topologies.md#shape-c--multi-tenant-server,
  # docs/guide/src/catalog-and-broker.md#multi-writer-safety; #479 closed)
  # is what makes concurrent replicas safe. Raise or lower freely.
  replicas: 3
  selector:
    matchLabels: { app: jammi-server }
  template:
    metadata:
      labels: { app: jammi-server }
    spec:
      # Termination grace is the Kubernetes default (30 s): SIGTERM drains —
      # in-flight requests finish, an idle WaitJob/Subscribe stream ends with
      # UNAVAILABLE "server draining" — then SIGKILL. A training job still in
      # flight past the grace resumes from its last epoch bundle after one
      # lease window and consumes one attempt; raise the grace to the compute
      # overlay's value (overlays/shape-d) when this base runs training in
      # earnest.
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        # The scratch emptyDir below must be writable by uid 65532 without
        # relying on kubelet's default 0777 volume permissions.
        fsGroup: 65532
      containers:
        - name: jammi-server
          # Pin-advice: `:latest` is re-pointed by every `v*` release tag.
          # Pin an exact `:vX.Y.Z` tag for reproducible deploys.
          image: ghcr.io/f-inverse/jammi-ai-server:latest
          ports:
            - { containerPort: 8081, name: flight }
            - { containerPort: 8080, name: health }
          readinessProbe:
            httpGet: { path: /readyz, port: 8080 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
            periodSeconds: 10
          envFrom:
            # JAMMI_AUDIT_MASTER_KEY, JAMMI_CATALOG__POSTGRES__URL,
            # JAMMI_BROKER__JET_STREAM__URL -- no Secret manifest ships in
            # git; create it out-of-band (see the README).
            - secretRef: { name: jammi-server-secrets, optional: false }
          volumeMounts:
            - { name: config, mountPath: /etc/jammi, readOnly: true }
            - { name: scratch, mountPath: /var/lib/jammi }
      volumes:
        - name: config
          configMap: { name: jammi-server-config } # the non-secret knobs only
        - name: scratch
          emptyDir: {} # local scratch only -- the catalog/broker/result-root carry the durable state
apiVersion: v1
kind: Service
metadata:
  name: jammi-server
spec:
  # No `type:` -> ClusterIP (the default): cluster-internal only, reachable
  # from other pods/Services on this cluster, not from outside it -- pair
  # with an Ingress/Gateway and a TLS terminator to reach it externally.
  selector: { app: jammi-server }
  ports:
    - { name: flight, port: 8081, targetPort: 8081 }
    - { name: health, port: 8080, targetPort: 8080 }
# env wins over this file (the JAMMI_<PATH> layer resolves after it); secrets
# never here -- JAMMI_AUDIT_MASTER_KEY, JAMMI_CATALOG__POSTGRES__URL and
# JAMMI_BROKER__JET_STREAM__URL come from the Secret this Deployment's
# envFrom names, never from this ConfigMap.

[storage]
result_root = "s3://jammi-results/prod"

[storage.cloud.s3]
region = "us-east-1"

[lease]
duration_secs = 30
heartbeat_secs = 10

[distributed]
# Read at the submit edge only (this query tier is where jobs are
# enqueued; a compute pod never reads it): admits a `world_size` up to the
# compute pod's two devices. A cross-pod `Peer` gang needs this bound
# raised to the target world size here, and at least that many compute
# pods able to hold a rank.
max_world_size = 2

[server]
services = ["core", "event", "eval"]

[worker]
enabled = false

jammi-server-secrets is a Secret carrying the same env keys the Compose and bare-env forms above use — JAMMI_CATALOG__POSTGRES__URL, JAMMI_BROKER__JET_STREAM__CREDENTIALS__FILE (or __URL), JAMMI_AUDIT_MASTER_KEY — mounted as env vars, never baked into the ConfigMap. No Secret manifest ships in git; create it out-of-band, once per cluster namespace:

kubectl -n <namespace> create secret generic jammi-server-secrets \
  --from-literal=JAMMI_AUDIT_MASTER_KEY=<...> \
  --from-literal=JAMMI_CATALOG__POSTGRES__URL=<...> \
  --from-literal=JAMMI_BROKER__JET_STREAM__URL=<...>

never in git.

What kube-smoke proves. Against the ci overlay on a real kind cluster: readiness (kubectl rollout status), get_server_info().broker == "jet_stream", the Postgres sources table’s row count via psql against the postgres StatefulSet, one-hop image identity — every pod’s containerStatuses[].imageID traces back to the image kind loaded, never a registry pull — and a rollout restart after which the new pod still sees the source the pod it replaced registered. What it does NOT prove: durability across that restart — the scratch volume is an emptyDir, so the Postgres catalog and the JetStream broker, not the pod’s local disk, carry the state a fresh pod recovers.

Shape D — disaggregated

Artifact: the query tier above (Shape C’s Deployment) unchanged, plus a GPU-scheduled StatefulSet compute tier and a single-replica CPU scheduler Deployment, both running the SAME image family, scheduled separately.

Running jobs is not a service tier (see Service tiers): whether a process claims and executes the jobs it accepted is [worker] enabled. Every query-tier replica runs [worker] enabled = false (JAMMI_WORKER__ENABLED=false) — it still mounts core/event/eval and accepts every submission — and both compute-tier roles below run [worker] enabled = true (JAMMI_WORKER__ENABLED=true) so only they run the job worker’s claim loop against the shared catalog. Their kinds differ: the compute StatefulSet’s pods claim ["fine_tune", "graph_fine_tune", "context_predictor"], the scheduler Deployment claims ["fine_tune", "graph_fine_tune"] only — context_predictor has no placed arm, so listing it on the CPU scheduler pod would train it there instead of on a device. [server] services = [] on both — a pure compute/scheduler node serves no query-tier gRPC.

Two compute-tier roles, one config knob. Whether a process hosts a Ballista scheduler or executor (or neither) is [ballista] (scheduler_bind / executor, see Configuration) — a process with neither role runs exactly as it always has:

  • The scheduler is ONE dedicated single-replica Deployment (jammi-server-scheduler): [ballista] scheduler_bind set, no [ballista.executor], CPU image. It claims a training job and PLACES it — as one Ballista task — on a registered compute-pod executor; when no executor is registered yet it claims and runs the job in-process instead (byte-identical either way, per device kind), since it is also a plain worker-enabled fleet member. A third arm: when a live registered executor exists but none of its own devices lists the plan’s device kind (a row a dead executor left behind is not live and never counts), the submission is refused typed BEFORE it ever reaches the scheduler — the row is left running for reclaim (an attempt spent), never run in-process on the claiming pod.
  • The compute tier’s StatefulSet pods (jammi-server-compute) each host a Ballista EXECUTOR ([ballista.executor] pointed at the scheduler’s Service) alongside their own [worker] enabled = true claim loop: a pod claims and runs a job in-process exactly like the scheduler can, or accepts a gang the scheduler placed on it. GangExec’s world is informational only — topology is decided on the pod that actually runs the body, from its OWN [worker] local_ranks, never from the submitter’s.

Placement is decided BEFORE topology, for every fine_tune/ graph_fine_tune attempt a scheduler-role process claims (any process whose HostAdmission exposes a placement submitter): it always attempts to place the whole job as one Ballista task on a registered compute-pod executor, regardless of W versus [worker] local_ranks. Only the pod that ends up running the job’s coordinator body — the placed executor, or the claiming process itself when no submitter seam exists or no other executor is registered — decides Single/Local/Peer from its OWN local_ranks: this overlay admits single-pod gangs (W ≤ 2, Local on one pod’s two devices); a cross-pod Peer gang of world W needs W > local_ranks, max_world_size ≥ W on the submit edge (base/jammi.toml — the key is read only where jobs are enqueued), and at least W compute pods able to hold a rank (the coordinator’s included) — the submitting host moves to an Awaiting holder state for the whole placement (it runs no compute meanwhile, but can still serve a RunRank session). Placement always excludes a task’s own submitter: a claimant’s host is never bound its own gang, so a lone compute pod placing its own claim would deadlock against itself — this is why the scheduler is a SEPARATE role rather than “whichever compute pod claims first places its own siblings.”

Each jammi-server-compute pod’s peer_advertise is its own stable DNS name under the headless Service (<pod>.jammi-server-compute.<namespace>.svc.cluster.local, publishNotReadyAddresses: true so a rank can dial a sibling that is still warming), the property a plain Deployment’s churning pod names cannot hold; its Ballista advertise_host is the SAME per-pod name, since the scheduler must dial the executor back on the identical stable address.

Both compute-tier roles carry terminationGracePeriodSeconds: 600 — SIGTERM drains (the in-flight training job finishes, every epoch bundle lands) and SIGKILL follows the grace; SIGINT, or jammi-server release from a preStop hook, RELEASES — on a CONFIRMED release (exit 0), the job’s lease is handed back at once and any other replica claims it within one idle poll at no attempt cost; a DEGRADED release (exit 3) does not cost an attempt universally, and its per-lease outcome depends on which determinant degraded (see the RELEASE breakdown in deploy/kubernetes/README.md and deploy-server.md below — never assume the CONFIRMED cost here for a degraded exit). The grace must cover one epoch’s wall time; on spot capacity use RELEASE. DRAIN on an executor pod additionally stops Ballista task admission at once — the executor reports Terminating to the scheduler the instant DRAIN begins, before the in-flight worker job is joined, and a terminating executor is never bound; a gang the pod is still dialled with inside its grace is refused before any claim transfer — but waits for any in-flight placed gang before the process itself stops — only RELEASE tears the executor down immediately. The operative rule, the rollout arithmetic and both preStop recipes are in deploy/kubernetes/README.md (“Shutdown: DRAIN and RELEASE”); the modes themselves are in Shutdown.

The compute tier is a StatefulSet — each rank’s peer address (and Ballista advertise_host) must stay stable across a pod restart, which a Deployment’s churning pod names cannot hold. This overlay is validated by kubeconform only — CI has no GPU node.

# jammi-server-compute ships as a StatefulSet, not a Deployment: each rank's
# peer address must stay stable across a pod restart for `instances.peer_addr`
# gang membership (`[server] peer_advertise`, DESIGN.md §7) -- a Deployment's
# pod names churn on every reschedule and cannot serve as that identity. The
# headless Service (service-compute-headless.yaml) is the DNS half of the
# same fact: ranks dial each other directly on the peer listener
# (`peer_advertise`), never through a load balancer or a virtual IP.
#
# `podManagementPolicy: Parallel`: ranks assemble by catalog membership
# (`instances` rows, DESIGN.md §7), never by ordinal start order, so no rank
# waits on a lower-ordinal sibling to become Ready before it starts.
#
# `nvidia.com/gpu: 2` matches `[worker] local_ranks = 2` in
# jammi-compute.toml: one device per local rank (`[gpu] devices = [0, 1]`).
#
# Each pod ALSO registers as a Ballista executor with the scheduler
# Deployment (deployment-scheduler.yaml): `ballista-flt`/`ballista-grpc`
# below are the shuffle/task listeners `[ballista.executor]`
# (jammi-compute.toml) binds, advertised at this same pod's stable DNS name
# (`JAMMI_BALLISTA__EXECUTOR__ADVERTISE_HOST` below) -- a training job
# claimed by the scheduler pod is PLACED onto whichever of these pods the
# scheduler's device-aware policy picks; a training job claimed by one of
# these pods itself runs here in-process.
#
# kubeconform-validated only -- no GPU node is available in CI, so this
# StatefulSet never runs a real pod in the kind smoke.
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: jammi-server-compute
spec:
  serviceName: jammi-server-compute
  replicas: 2
  podManagementPolicy: Parallel
  updateStrategy:
    type: RollingUpdate
  selector:
    matchLabels: { app: jammi-server-compute }
  template:
    metadata:
      labels: { app: jammi-server-compute }
    spec:
      # DRAIN on SIGTERM finishes the in-flight job; RELEASE (SIGINT /
      # `jammi-server release` via preStop) hands it to a successor at once.
      # 600 s = cluster-autoscaler `--max-graceful-termination-sec` default; a
      # larger value is honoured only by rollouts/`kubectl delete` — raise both
      # together. The operative rule and the rollout arithmetic are in the
      # README ("Shutdown: DRAIN and RELEASE").
      terminationGracePeriodSeconds: 600
      nodeSelector:
        gpu-node-pool: "true" # your cluster's own GPU node label
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        fsGroup: 65532
      containers:
        - name: jammi-server
          # amd64 only. Pin-advice: `:latest` is re-pointed by every `v*`
          # release tag; pin an exact `:vX.Y.Z` tag for reproducible
          # GPU-node deploys.
          image: ghcr.io/f-inverse/jammi-ai-server-cu12:latest
          resources:
            limits: { nvidia.com/gpu: 2 }
          ports:
            - { containerPort: 8081, name: flight }
            - { containerPort: 8080, name: health }
            - { containerPort: 9000, name: peer }
            - { containerPort: 50051, name: ballista-flt }
            - { containerPort: 50052, name: ballista-grpc }
          env:
            # The downward API's pod-field env source
            # (EnvVarSource.fieldRef; `metadata.name` / `metadata.namespace`
            # resolve to THIS pod's own values, not a template string).
            - name: POD_NAME
              valueFrom: { fieldRef: { fieldPath: metadata.name } }
            - name: POD_NAMESPACE
              valueFrom: { fieldRef: { fieldPath: metadata.namespace } }
            # Kubernetes expands `$(VAR)` in an `env[].value` string only for
            # a variable name declared EARLIER in this same list (the
            # `EnvVar.value` field doc: "Variable references $(VAR_NAME) are
            # expanded using the previously defined environment variables in
            # the container and any service environment variables" --
            # https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.34/#envvar-v1-core)
            # -- POD_NAME/POD_NAMESPACE above must stay ordered before this
            # key. This is the pod's stable identity for gang membership: the
            # ordinal-indexed DNS name the headless Service publishes
            # (`<pod>.jammi-server-compute.<namespace>.svc.cluster.local`),
            # never a virtual IP. `[server] peer_bind` (the listen side, the
            # same on every replica) lives in jammi-compute.toml instead --
            # one non-secret knob file for the shared value, env only for the
            # per-pod identity that file cannot express.
            - name: JAMMI_SERVER__PEER_ADVERTISE
              value: "$(POD_NAME).jammi-server-compute.$(POD_NAMESPACE).svc.cluster.local:9000"
            # The SAME per-pod stable DNS name, host only (no port -- this
            # key is `[ballista.executor] advertise_host`, jammi-compute.toml,
            # a bare host the scheduler dials back on the flight/grpc ports
            # THAT config table already names).
            - name: JAMMI_BALLISTA__EXECUTOR__ADVERTISE_HOST
              value: "$(POD_NAME).jammi-server-compute.$(POD_NAMESPACE).svc.cluster.local"
          readinessProbe:
            httpGet: { path: /readyz, port: 8080 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
            periodSeconds: 10
          envFrom:
            # Same catalog/broker Secret as the query tier.
            - secretRef: { name: jammi-server-secrets, optional: false }
          volumeMounts:
            - { name: config, mountPath: /etc/jammi, readOnly: true }
            - { name: scratch, mountPath: /var/lib/jammi }
      volumes:
        - name: config
          configMap: { name: jammi-compute-config }
        - name: scratch
          # The cu12 image also keeps CUDA_CACHE_PATH under /var/lib/jammi
          # alongside HF_HOME -- local scratch only, no durable state here.
          emptyDir: {}
# Headless (clusterIP: None): ranks resolve each other by pod DNS name
# (`<pod>.jammi-server-compute.<namespace>.svc.cluster.local`, the value
# `[server] peer_advertise` is set to per-pod in statefulset-compute.yaml's
# `env`) and dial that name directly on the peer port -- never a virtual IP,
# never through this Service acting as a load balancer.
#
# `publishNotReadyAddresses: true`: the peer/gang listener is bound and
# already serving `PeerService`/`GangService` connections as soon as
# `OssServer::bind` returns (`crates/jammi-server/src/runtime.rs`), which
# happens BEFORE the process's own `/readyz` reports ready --
# `/readyz` gates the catalog ping and, when `[server] preload_models` is
# set, model preload (`docs/guide/src/deploy-server.md`'s "Preloading
# models": the claim loop "waits at its gate with its `workers.state` row
# reading `warming`" until then). A coordinator only dials a peer AFTER it
# claims a job, i.e. after its OWN `/readyz` is ready, but the PEER it
# dials may still be warming. Without this flag a not-yet-ready pod has no
# DNS record on a headless Service (the default EndpointSlice/DNS behaviour
# publishes only Ready pod addresses), so the coordinator would get NXDOMAIN
# resolving a peer whose `GangService` is already live and reachable.
apiVersion: v1
kind: Service
metadata:
  name: jammi-server-compute
spec:
  clusterIP: None
  publishNotReadyAddresses: true
  selector: { app: jammi-server-compute }
  ports:
    - { name: flight, port: 8081, targetPort: 8081 }
    - { name: health, port: 8080, targetPort: 8080 }
    - { name: peer, port: 9000, targetPort: 9000 }
    - { name: ballista-flt, port: 50051, targetPort: 50051 }
    - { name: ballista-grpc, port: 50052, targetPort: 50052 }
# env wins over this file (the JAMMI_<PATH> layer resolves after it); secrets
# never here -- see base/jammi.toml's header.

[storage]
result_root = "s3://jammi-results/prod"

[storage.cloud.s3]
region = "us-east-1"

[lease]
duration_secs = 30
heartbeat_secs = 10

[gpu]
# The ordered device list this pod's ranks place on: rank i on devices[i].
# devices[0] must equal `device` (unset here, default 0), so no separate
# `device` key is needed. Two entries for the shape's two local ranks below.
devices = [0, 1]

# This pod also hosts a Ballista EXECUTOR -- it registers with the
# scheduler Deployment's Service and accepts a PLACED training job (one
# `GangExec` task) alongside the jobs it claims and runs on its own,
# in-process. One task slot: a placed gang and this pod's own in-process
# claim both take the SAME `HostAdmission` job slot (this pod coordinates
# one placed task at a time), so a second bound task is refused typed at
# the slot rather than left to contend for `[gpu] devices` capacity.
[ballista.executor]
scheduler_address = "jammi-server-scheduler:50050"
bind = "0.0.0.0:50051"
grpc_bind = "0.0.0.0:50052"
# The per-pod stable DNS name (statefulset-compute.yaml's downward-API
# `JAMMI_BALLISTA__EXECUTOR__ADVERTISE_HOST`, same construction as
# `peer_advertise` below) -- never set here, since every replica shares this
# one ConfigMap and the value differs per pod.
task_slots = 1

# Pure compute node: mounts no query-tier gRPC service, only claims and runs
# queued jobs (in-process or as a placed Ballista task).
[server]
services = []
# This pod's own peer/gang listener, identical on every replica of this
# StatefulSet: "0.0.0.0:PORT" accepts a connection on any interface. The
# per-pod dial-in address other replicas use is `peer_advertise`, set from
# the downward API in statefulset-compute.yaml's `env` -- never here, since
# every replica shares this one ConfigMap and `peer_advertise` differs per
# pod.
peer_bind = "0.0.0.0:9000"

[worker]
enabled = true
# The training kinds; "all" would also claim every other compiled kind.
kinds = ["fine_tune", "graph_fine_tune", "context_predictor"]
# One rank per [gpu] devices entry: a claimed job with world_size <= 2 runs
# both ranks in this pod over a Local gang (no peer dial); a wider job makes
# this pod rank 0 of a Peer gang whose other rank is the StatefulSet's other
# replica.
local_ranks = 2
# The Ballista scheduler role, ONE dedicated single-replica Deployment: it
# hosts `[ballista] scheduler_bind` (claims and PLACES a training job onto
# a registered compute-pod executor) and `[worker] enabled = true` for the
# training kinds
# (so it can also claim and run one in-process, K4, when placement is not
# available -- e.g. no compute executor is registered yet). CPU image: this
# pod never itself trains on a device, it only decodes/re-encodes plans and
# assembles gangs.
#
# A plain (non-headless) Service in front of it is enough: a single replica
# behind a stable Service DNS name (`jammi-server-scheduler`) survives a pod
# restart exactly as well as a StatefulSet's ordinal identity would, since
# there is only ever one ordinal.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: jammi-server-scheduler
spec:
  replicas: 1
  selector:
    matchLabels: { app: jammi-server-scheduler }
  template:
    metadata:
      labels: { app: jammi-server-scheduler }
    spec:
      # Same DRAIN/RELEASE shutdown contract as every other role (README
      # "Shutdown: DRAIN and RELEASE"); this pod holds no GPU, so its own
      # grace only needs to cover an in-flight in-process claim, never a
      # placed gang's device work (that grace is the compute overlay's).
      terminationGracePeriodSeconds: 600
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        fsGroup: 65532
      containers:
        - name: jammi-server
          # CPU image: the scheduler role decodes/encodes plans and places
          # tasks, it never itself runs a device kernel.
          image: ghcr.io/f-inverse/jammi-ai-server:latest
          ports:
            - { containerPort: 8081, name: flight }
            - { containerPort: 8080, name: health }
            - { containerPort: 9000, name: peer }
            - { containerPort: 50050, name: ballista-sched }
          readinessProbe:
            httpGet: { path: /readyz, port: 8080 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /healthz, port: 8080 }
            periodSeconds: 10
          envFrom:
            # Same catalog/broker Secret as every other role -- one shared
            # cluster, one catalog.
            - secretRef: { name: jammi-server-secrets, optional: false }
          volumeMounts:
            - { name: config, mountPath: /etc/jammi, readOnly: true }
            - { name: scratch, mountPath: /var/lib/jammi }
      volumes:
        - name: config
          configMap: { name: jammi-scheduler-config }
        - name: scratch
          emptyDir: {}
# No `type:` -> ClusterIP: reachable from the compute StatefulSet's pods
# (they dial `jammi-server-scheduler:50050` as their `[ballista.executor]
# scheduler_address`, and this Service's stable DNS name doubles as this
# pod's `[server] peer_advertise`, jammi-scheduler.toml), never externally.
apiVersion: v1
kind: Service
metadata:
  name: jammi-server-scheduler
spec:
  selector: { app: jammi-server-scheduler }
  ports:
    - { name: flight, port: 8081, targetPort: 8081 }
    - { name: health, port: 8080, targetPort: 8080 }
    - { name: peer, port: 9000, targetPort: 9000 }
    - { name: ballista-sched, port: 50050, targetPort: 50050 }
# env wins over this file (the JAMMI_<PATH> layer resolves after it); secrets
# never here -- see base/jammi.toml's header.

[storage]
result_root = "s3://jammi-results/prod"

[storage.cloud.s3]
region = "us-east-1"

[lease]
duration_secs = 30
heartbeat_secs = 10

[ballista]
# This pod hosts the ONE Ballista scheduler: it claims a training job and
# PLACES it on a registered compute-pod executor (configuration.md's
# semantics: this table's presence makes a process a scheduler). No
# `[ballista.executor]` here -- this pod never itself registers as an
# executor, so a task it places can never be bound back to it (a task's own
# submitter is excluded from placement in any case).
scheduler_bind = "0.0.0.0:50050"

# This pod claims the training kinds a placed run handles (`fine_tune`,
# `graph_fine_tune`) and places each claimed job on a compute executor; while
# no compute executor is registered it runs the job in-process, exactly like
# any other worker-enabled process (the bytes are identical either way).
# `context_predictor` has no placed arm, so it is left to the compute pods:
# listing it here would train it on this CPU pod.
[worker]
enabled = true
kinds = ["fine_tune", "graph_fine_tune"]

# This pod's own peer/gang listener -- needed because a training job this
# pod claims and runs in-process (no placement available) may still assemble
# a multi-host gang across the compute StatefulSet's pods.
[server]
peer_bind = "0.0.0.0:9000"
# The stable Service DNS name this pod is always reachable at (a single
# replica behind a normal ClusterIP Service never needs a downward-API pod
# name the way the compute StatefulSet's per-pod `peer_advertise` does).
peer_advertise = "jammi-server-scheduler:9000"
# Pure scheduler node: mounts no query-tier gRPC service.
services = []

Both :latest tags are re-pointed by every v* release tag (never by a prerelease); the CPU :latest can additionally be re-pointed to the current main by a manual build-and-push-main dispatch. Pin an exact :vX.Y.Z tag for reproducible GPU-node deploys.

Very high scale, specialized GPU pools, and a split compliance posture (query tier vs. training tier on separate node pools / network policies) are the shapes this topology serves.

Beyond-one-node retrieval

A query-tier replica answers Search over a table whose ANN index segments it does not all hold by fanning the query out to the replicas that own them: each owner searches its segments and returns (row_id, distance) hits — never vectors — and the coordinator merges them under the same total order a single node merges its own segments with (the distributed data plane; see Security Posture for the I-PEER invariant and Operability for the failure ladder). Three facts fix the shape:

  • The default is AllLocal. With [server] peer_bind unset — every shape above — every segment is this replica’s, there is no third listener, and the search is exactly the single-node search (the same kernels, the same bytes, the same exact-read count at every segment count). Nothing on this page changes until a deployment opts in.
  • peer_bind makes a replica an owner (and a gang admission member). Setting [server] peer_bind (JAMMI_SERVER__PEER_BIND) opens the internal listener serving both PeerService (segment search) and GangService (multi-host training-run admission — see Security Posture) on that replica. Bind it on a private interface behind network policy / mTLS from the runtime — its clients are other jammi coordinators and it authenticates nothing itself.
  • Precondition: a shared, replica-readable result_root. A segment an owner serves must be a bundle every replica can reach: [storage] result_root on an object store every replica reads (a local artifact_dir is one node’s). Placement — which replica owns which segment — is derived at query time from the live replica ring ([server] peer_advertise + the catalog’s instances rows, rendezvous- hashed over (table, segment)), never declared; the membership half of that ring is the compute-tier substrate’s (peer_advertise, instances.peer_addr), and until it lands a library process supplies an explicit StaticPlacement through InferenceSession::open_with_placement. Batch builders (the neighbor graph, eval) never fan out: they load the whole table’s segment set on the building replica.
  • Membership is judged fresh under a margin, pruned only well beyond it. A row missing its heartbeat for 2 × lease (instance_liveness_margin) is no longer a fresh member; it is not actually deleted until 3 × lease (instance_prune_window) — strictly beyond the margin, so a merely-stale member still has its row when the lease keeper’s own reregister lands. A process whose row WAS pruned during a transient outage (an outage longer than the window) rejoins the ring on its very next successful heartbeat, with no restart: the keeper’s reregister re-upserts the whole membership tuple (the instances row and, if this process runs a claim loop, its workers row) in one transaction. instances.result_root records the configured root SPELLING verbatim, but neither list_gang_members (the gang-membership listing verb, contract feat_500-C-U5b-1a §12) nor this ring consults it in this unit — root identity across spellings, and any membership predicate built on it, is a separate, not-yet-built unit. Were root-string equality ever made part of a membership predicate here, it would still be necessary, never sufficient, for shared storage — the attestation VERIFY a later unit owns is what establishes sufficiency.

A REFRESHED table’s Mixed arm is not version-aware. The single-node (AllLocal, every segment this replica’s own) search path always resolves a versioned table’s CURRENT version before searching it. The multi-node Mixed arm — reached only when peer_bind is set and this table’s segments span more than one replica — does not: it plans off list_index_segments’ flat, unversioned segment set, the same limitation the single-node path carried before its own version-aware resolution was added. If a Shape D deployment places a table refresh_embeddings has since published a new version of across more than one owning replica, a Search served through peers can surface rows from a version older than the table’s current one; keep a refreshed table’s segments on a single owning replica (or force-local it) until this closes.

The jammi-server probe subcommand

Every shape above that runs jammi-server — B, C, D — uses the same readiness mechanism: jammi-server probe [--url URL] [--config PATH] [--timeout-secs N] GETs /readyz once and exits 0 on HTTP 200, 1 otherwise (never following a redirect), printing the failure status and body to stderr. Without --url, the target derives from the resolved [server] health_listen through the identical config-resolution chain serve uses. It is a generic CLI a supervisor drives from a subprocess exit code — Compose’s HEALTHCHECK, systemd’s ExecStartPost=, Nomad’s check { type = "script" } — the same shape as Postgres’s pg_isready. Kubernetes needs none of this: its readinessProbe.httpGet already speaks HTTP directly against /readyz.

What the published images can and cannot do

The published CPU image (ghcr.io/f-inverse/jammi-ai-server) is built with cargo build --features jammi-server/jetstream-broker,jammi-server/storage-cloud — nothing else. GetServerInfo.features on that image therefore reports ["jetstream-broker"], and storage_backends reports the schemes storage-cloud pulls in (s3, r2, gs, azure, alongside the always-on file/memory).

A Postgres catalog ([catalog.postgres], Shapes B/C/D above) works on this image unconditionally: sqlx’s Postgres driver is not gated behind any cargo feature, so it is not a features entry at all — it is simply always present.

The separate postgres cargo feature (datafusion-table-providers/postgres) is a different capability — querying a remote Postgres database as a federated SQL source, not the catalog backend — and is not compiled into the published image. It would appear in features as "postgres" only on a custom build that opts into it explicitly; do not read this image’s Postgres-catalog support as evidence that source federation is available.

Node architecture. The CPU image’s generic tags (ghcr.io/f-inverse/jammi-ai-server:latest/:vX.Y.Z/:vX.Y and their sha-<sha> equivalents) are a multi-arch index — linux/amd64 and linux/arm64 — so Shapes B/C’s query tier and Shape D’s disaggregated query tier can schedule onto either an amd64 or an arm64 node pool without a per-arch tag; docker pull/Kubernetes resolve the right member automatically. Shape D’s GPU compute tier is unaffected by this: the CUDA image (-cu12) is linux/amd64 only, so the GPU node pool stays amd64. The same CPU image name’s self-contained tags (:selfcontained, :selfcontained-sha-<sha>) are also linux/amd64 only — never schedule those onto an arm64 node pool.

Backup and Restore

How to back up and restore a Jammi deployment safely, for each of the deployment shapes the engine ships. The catalog (models, sources, eval runs, mutable companion tables, result-table rows) and the object store (result-table Parquet, ANN sidecars, model artifacts) are two independently-backed-up systems that must stay consistent with each other; everything below is about preserving that consistency across a backup/restore cycle. Everything described here ships today.

cache/ is excludable from every backup

Both the local ANN-segment cache and the model-artifact fetch cache ({local_cache_dir}/index and {local_cache_dir}/artifact — see Store Sources and Results in Cloud Object Storage for with_root’s local_cache_dir parameter; for the default embedded shape they sit under {artifact_dir}/cache/) are content-addressed derived data: every object in them is rebuilt on demand from the object store, and never the sole copy of anything. It is always safe to:

  • exclude cache/ from a backup entirely;
  • restore a backup whose cache/ directory is stale relative to the restored catalog and storage — the store simply misses cache and refetches;
  • restore a backup with no cache/ directory at all — the store creates it on first use.

Shape A / B: SQLite catalog

The SQLite catalog and the local (or object-store) result-table root are backed up together, and the catalog file must be captured through a consistent snapshot — a bare cp of a live catalog.db while its WAL is being written is not that. Close first:

#![allow(unused)]
fn main() {
extern crate jammi_db;
use jammi_db::session::JammiSession;
async fn ex(session: JammiSession) {
// Stop accepting new work on this session first, at the application layer;
// close() itself only waits for outstanding checkouts, not new admissions.
session.close().await;
}
}
db.close()

close() (crates/jammi-db/src/session.rs, JammiSession::close) awaits every outstanding pool checkout and, for SQLite, releases the process-scoped unix-excl file lock and lets the -wal sidecar quiesce. Only once it returns is the on-disk image safe to copy:

# 1. Stop the process (or await close()) so the WAL is checkpointed and no
#    writer holds the file.
# 2. Copy the catalog file, its WAL, and the result-table root together —
#    they must be from the SAME instant, since a result-table row and its
#    bytes are two halves of one fact.
cp catalog.db catalog.db-wal /backup/<date>/               # -wal may be absent
                                                             # if fully checkpointed
cp -r jammi_db/ /backup/<date>/jammi_db/                    # exclude jammi_db/cache/

Restoring is the reverse: with no jammi-server process holding the directory, replace catalog.db (+ -wal) and the result-table root from the same backup instant, then reopen:

#![allow(unused)]
fn main() {
extern crate jammi_ai;
extern crate jammi_db;
use jammi_ai::Jammi;
use jammi_db::config::JammiConfig;
async fn ex() -> jammi_db::error::Result<()> {
let config = JammiConfig::load(None)?;
let session = Jammi::open(jammi_ai::Target::Local(config)).await?;
// ResultStore::recover() runs automatically at open — see "Ordering" below.
let _ = session; Ok(()) }
}

A hot copy is unsafe under the unix-excl VFS contract described in Catalog Backend and Trigger Broker: copying the file while the engine still holds it can capture a torn WAL image, and handing the directory to a second reader before close() returns races the first engine’s in-memory WAL index. Always stop-or-close before copying.

Shape B / C: Postgres catalog

Use Postgres’s own backup primitives for the catalog — pg_dump for a point-in-time logical snapshot, or continuous archiving / PITR for a production deployment — and your object-store provider’s own snapshot or versioning feature (S3 versioning, GCS object versioning, a bucket-level snapshot) for the result-table root. These are two independent systems with independent backup tooling; nothing about restoring one requires stopping jammi-server the way SQLite’s file-level copy does, since Postgres and the remote object store both serve concurrent readers/writers safely through their own transaction/consistency models.

Ordering rule

Restore storage to a point no later than the catalog — and always review a reconcile dry run before --apply. A catalog row is only meaningful if the bytes it names exist; restoring the catalog to a point after the storage snapshot can leave rows pointing at objects the storage restore does not have. The reverse ordering is NOT symmetrically safe: restoring the catalog to a point before the storage snapshot can leave storage objects with no referencing row in the restored catalog even though those objects WERE legitimately queryable data under the catalog state the storage snapshot was actually taken against (a table materialized, then the catalog rolled back past that materialization). jammi reconcile cannot tell “an object nothing ever referenced” from “an object a row referenced before the catalog was rolled back” — both look identical to it: row-less, and (past grace) an orphan candidate --apply reclaims. Treating that reclaim as harmless because “a row-less object was never queryable” is the wrong mental model; it can genuinely delete data a client read moments before the restore. Concretely:

  1. Restore (or roll storage forward/back to) the storage snapshot first.
  2. Restore the catalog to a snapshot from the same or a later instant — never earlier — so every row the restored catalog carries is covered by the storage snapshot, and the storage snapshot carries nothing the catalog does not already know to be superseded.
  3. If the two snapshots cannot be instant-matched exactly (a Postgres PITR target a few seconds off a bucket versioning rollback point, say), run jammi reconcile with apply=false FIRST and review the orphans / pending / rows_failed lists by hand before ever passing --apply — the dry run is free and mutates nothing; --apply is the step that can turn a merely stale-looking object into a permanently deleted one. Once reviewed, --apply flips a ready row whose required objects are missing to failed, and reclaims any storage object no live row references. See Catalog Backend and Trigger Broker → Multi-writer safety for what reconcile checks and its deletion arms, and the maintainer guide (docs/maintainer/MAINTAINER-GUIDE.md) for the full allowlist and referenced/required-object rules.

When to run reconcile

Beyond a mismatched restore, run jammi reconcile (dry run first — --apply defaults to false):

  • after any restore where the catalog and storage snapshots were not taken atomically together;
  • as a periodic housekeeping pass in a long-lived deployment, to reclaim orphaned objects left by a writer that crashed and was never reaped by a later session’s startup recovery sweep (recovery only reaps a building row still present in the catalog; an object written but never inserted as a row at all is reconcile’s job, not recovery’s);
  • before decommissioning a tenant’s data, as a dry run to confirm what a cross-tenant --all pass (gated behind an AdminAuthorizer — see Security Posture) would report.

reconcile never mutates a training job and never reclaims a pending (too-young) orphan, so a dry run is always safe to run against a live, healthy deployment.

Monitor Inference

Attach an observer to inspect every output batch during inference. Use this for logging, metrics collection, quality checks, or progress tracking.

Attach an observer

Rust

#![allow(unused)]
fn main() {
extern crate jammi_db;
extern crate jammi_ai;
extern crate arrow;
extern crate tokio;
use jammi_ai::session::InferenceSession;
use jammi_db::config::JammiConfig;
async fn ex(config: JammiConfig) -> jammi_db::error::Result<()> {
use jammi_ai::inference::observer::InferenceObserver;
use std::sync::Arc;

struct MetricsCollector;

impl InferenceObserver for MetricsCollector {
    fn on_batch(
        &self,
        batch: &arrow::record_batch::RecordBatch,
        model_id: &str,
        latency: std::time::Duration,
    ) {
        println!(
            "Batch: {} rows from {model_id} in {latency:?}",
            batch.num_rows()
        );
    }
}

let session = InferenceSession::with_observer(
    config,
    Some(Arc::new(MetricsCollector) as Arc<dyn InferenceObserver>),
).await?;
Ok(()) }
}

The observer is called once per output batch. When no observer is attached, the overhead is a single Option branch — effectively zero.

Use cases

Progress logging

#![allow(unused)]
fn main() {
extern crate jammi_ai;
extern crate arrow;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use arrow::record_batch::RecordBatch;
use jammi_ai::inference::observer::InferenceObserver;
struct ProgressLogger { total: AtomicUsize }

impl InferenceObserver for ProgressLogger {
    fn on_batch(&self, batch: &RecordBatch, _model_id: &str, _latency: Duration) {
        let count = self.total.fetch_add(batch.num_rows(), Ordering::Relaxed) + batch.num_rows();
        eprintln!("Processed {count} rows...");
    }
}
}

Quality checks

#![allow(unused)]
fn main() {
extern crate jammi_ai;
extern crate arrow;
use std::time::Duration;
use arrow::array::StringArray;
use arrow::record_batch::RecordBatch;
use jammi_ai::inference::observer::InferenceObserver;
struct QualityChecker;

impl InferenceObserver for QualityChecker {
    fn on_batch(&self, batch: &RecordBatch, model_id: &str, latency: Duration) {
        // Check for high error rates
        let status = batch.column_by_name("_status").unwrap();
        let errors = status.as_any().downcast_ref::<StringArray>().unwrap()
            .iter().filter(|s| s == &Some("error")).count();

        if errors > batch.num_rows() / 2 {
            eprintln!("WARNING: {model_id} batch has {errors}/{} errors", batch.num_rows());
        }
    }
}
}

Latency tracking

#![allow(unused)]
fn main() {
extern crate jammi_ai;
extern crate arrow;
use std::time::Duration;
use arrow::record_batch::RecordBatch;
use jammi_ai::inference::observer::InferenceObserver;
struct LatencyTracker { slow_threshold: Duration }

impl InferenceObserver for LatencyTracker {
    fn on_batch(&self, batch: &RecordBatch, model_id: &str, latency: Duration) {
        if latency > self.slow_threshold {
            eprintln!(
                "SLOW: {model_id} took {latency:?} for {} rows ({:?}/row)",
                batch.num_rows(),
                latency / batch.num_rows() as u32,
            );
        }
    }
}
}

Pipeline architecture

Source (Parquet/CSV/DB)
    |
    v  DataFusion scan
    |
InferenceExec operator
    |-- Loads model (or cache hit)
    |-- Bounded channel (capacity=2, backpressure)
    |-- InferenceRunner (async task)
    |     |-- Reads input batches
    |     |-- Extracts text from content columns
    |     |-- Tokenizes with model's tokenizer
    |     |-- BERT forward pass
    |     |-- Mean pooling + L2 normalization
    |     |-- Constructs prefix + vector columns
    |     |-- ** Observer called here **
    |     '-- Sends to output channel
    |
    v  RecordBatch stream
    |
Results

Model caching

Models are loaded once and cached with LRU eviction:

  • First load: downloads from HF Hub (or reads from local path), loads weights into memory
  • Subsequent calls: cache hit, returns immediately
  • Ref counting: model stays in memory while any inference is running
  • Eviction: when the LRU limit is reached, the least-recently-used model with no active references is evicted

Operability

How to run a Jammi server in production: what it exposes for observability, how it shuts down cleanly, the resource limits it enforces, and how it behaves when a dependency fails. Everything below describes the system as it ships today.

Observability surface

The server exposes three HTTP side-channel endpoints, independent of the gRPC and Flight SQL data paths:

EndpointMeaningStatus
/healthzLiveness — dependency-free 200 with the build version. The process is up and serving.200
/readyzReadiness — pings the catalog backend the session is bound to. Use it for load-balancer admission.200 ready / 503 not ready
/metricsPrometheus text-format snapshot of the substrate metric registry.200

/healthz answers without touching any dependency, so an orchestrator uses it to decide whether to restart the container. /readyz goes one step further and pings the catalog — a transient catalog outage returns 503 so the load balancer removes the instance from rotation rather than restarting it.

$ curl -s localhost:8080/healthz
{"status":"ok","version":"0.29.0"}

$ curl -s localhost:8080/readyz          # catalog reachable
{"status":"ready"}

$ curl -s localhost:8080/readyz          # catalog unreachable → 503
{"status":"not_ready","detail":"catalog ping failed: connection refused"}

Metrics

/metrics emits these substrate-level metrics that the gRPC services, Flight SQL layer, and request-bounds layer stack feed:

MetricTypeIncremented by
jammi_grpc_requests_totalcounterAny /jammi.v1.* gRPC request.
jammi_flight_queries_totalcounterA Flight SQL DoGet query.
jammi_eval_invocations_totalcounterAn EvalService/* RPC.
jammi_search_latency_secondshistogramEnd-to-end EmbeddingService/Search request latency.
jammi_grpc_refused_total{reason}counterA request refused by [server.limits]reasonmessage_size, in_flight, in_flight_per_connection, subscriptions, job_waits, timeout, draining (a WaitJob/Subscribe stream ended by the server’s DRAIN — see Shutdown). See Request bounds.
jammi_peer_search_failures_total{reason}counterOne outcome of the placed-search failure ladder on THIS replica as a coordinator — reasondeadline, unreachable, refused, torn, transport, malformed, caller_fault (one failed peer call each — malformed is an answer that does not reconcile with its request: an unrequested / missing / duplicated segment unit or rescore row, or a non-finite distance, from a non-conforming or version-skewed peer; caller_fault is an owner refusing the REQUEST as invalid, which is TERMINAL — a coordinator that missed its own query check, never retried, never loaded locally, never unavailable), retry_ok (the second rendezvous candidate served), local_load (the segment was loaded locally under [server] peer_local_load_bytes), unavailable (the ladder was exhausted; the query failed UNAVAILABLE). Read at scrape from the result store’s counters.
jammi_peer_requests_total{rpc}counterA PeerService request served on THIS replica’s [server] peer_bind listener as an owner — rpcSegmentSearch, ExactRescore. Counted by the same whole-server metrics layer as jammi_grpc_requests_total (which also counts them, as /jammi.v1.* requests).
jammi_gang_requests_total{rpc}counterA GangService request served on THIS replica’s [server] peer_bind listener as a gang member — rpc = RunRank. Counted by the same whole-server metrics layer as jammi_grpc_requests_total, regardless of how the call is decided.
# HELP jammi_grpc_requests_total Total number of gRPC requests served across all jammi.v1 services.
# TYPE jammi_grpc_requests_total counter
jammi_grpc_requests_total 1432
# HELP jammi_search_latency_seconds Vector-search request latency, in seconds.
# TYPE jammi_search_latency_seconds histogram
jammi_search_latency_seconds_bucket{le="0.05"} 311
jammi_search_latency_seconds_bucket{le="0.1"} 402
jammi_search_latency_seconds_sum 27.41
jammi_search_latency_seconds_count 418

Tracing

The server installs a global tracing subscriber (a Registry layered with the fmt formatter and, when configured, an OTLP export layer). Spans carry the correlation fields that let you follow a request across the gRPC surface and the worker fleet:

  • gRPC handler spans carry tenant_id — recorded once the handler has resolved the request’s tenant scope.
  • run_claimed_job (the worker dispatching a claimed job) carries worker_id, job_id, and tenant_id.
  • run_spec (the training run inside a claimed job) carries job_id and worker_id.

Logs are emitted as structured records, JSON or human-readable text per logging.format (LogFormat). The filter comes from logging.level, with RUST_LOG as an optional override. Output always goes to stdout — a server runs non-interactively by design — and ANSI colour is enabled only when stdout is a terminal.

{"timestamp":"2026-06-15T04:17:33.114Z","level":"INFO","fields":{"message":"job completed"},"target":"jammi_ai::fine_tune::worker","span":{"job_id":"job-7af3","worker_id":"worker-2","tenant_id":"acme","name":"run_claimed_job"}}

OTLP trace export

Setting [observability] otlp_endpoint sends spans to any vendor-neutral OTLP collector over gRPC (opentelemetry-otlp, tonic transport). Every span from this process carries the service.name resource attribute (default "jammi"), and the parent-based ratio sampler keeps sample_ratio (default 1.0, i.e. everything) of the traces this process ROOTS — a span whose parent was already sampled by the caller is always kept, regardless of the local ratio. Request headers a collector requires (an auth token, a tenant header) go under [observability.otlp_headers]; each value is a secret — inline or { file = "…" } — and is never logged (see Configuration).

Leaving otlp_endpoint unset installs no exporter and opens no network connection for tracing at all — the zero-egress default. The exporter lives behind the telemetry-otlp cargo feature (on by default in every published jammi-server build and in the jammi-python embed wheel); a build compiled WITHOUT the feature refuses to start with a typed configuration error if otlp_endpoint is set, rather than silently dropping every span.

A whole-server tower layer extracts the incoming W3C traceparent (and tracestate) from every gRPC/Flight request’s HTTP headers and continues that trace: the span this process opens for the request shares the CALLER’s trace id, so a request that entered through an edge proxy or gateway stays one trace end-to-end across process boundaries. A request with no traceparent header starts a fresh, unparented trace, exactly as today.

Graceful shutdown

run_with_shutdown drives both the HTTP side-channel and the gRPC surface and drains them in parallel: the call returns once both have stopped accepting new connections and finished serving in-flight requests. The standalone binary wires both SIGINT (Ctrl+C) and SIGTERM, so docker stop — which sends SIGTERM — triggers the same clean drain as an interactive Ctrl+C.

Backpressure and resource limits

The engine enforces these limits. There is no in-memory worker-queue-depth bound — the work queue is durable, not buffered (see below) — but the combined gRPC + Flight SQL surface (and, separately, the request-bounds knobs below) DOES enforce a configured inbound message-size cap, request-concurrency bounds, and two long-lived-stream budgets.

Request bounds ([server.limits])

Every inbound request to the combined gRPC + Flight SQL listener (and, for message size, the Flight-only listener too) is checked against [server.limits] before any tenant-scoped catalog read runs — a refused request never reaches a handler, so a refusal leaks nothing about cross-tenant existence. A request that would exceed any of these is refused at the edge with a typed gRPC status and a jammi_grpc_refused_total{reason} counter increment (see Metrics below); see Configuration for the full [server.limits] reference and every default.

KnobRefusalreason labelNotes
max_message_bytes (default 64 MiB)OUT_OF_RANGEmessage_sizeEnforced by tonic’s own per-service codec (max_decoding_message_size), below the refusal-counting layer stack — verified against the vendored tonic 0.14.5 source; this is NOT RESOURCE_EXHAUSTED. Applied on every listener: the public gRPC + Flight SQL chain and the internal peer_bind listener’s PeerService and GangService; a gang round’s chunks are sized to it, and a coordinator’s client bounds its own inbound decode at the same value. A message of exactly this many encoded bytes decodes; one more byte is refused naming the configured value. No outbound cap: a large result set is never truncated.
max_in_flight (default 256)RESOURCE_EXHAUSTEDin_flightGlobal, UNARY methods only. 0 = unbounded.
max_in_flight_per_connection (default 64)RESOURCE_EXHAUSTEDin_flight_per_connectionPer TCP connection, UNARY methods only. 0 = unbounded.
request_timeout_secs (default unset)DEADLINE_EXCEEDEDtimeoutUNARY methods only; unset means no server-imposed timeout.
wait_timeout_secs (default unset)DEADLINE_EXCEEDEDtimeout (edge refusal only)Bounds a TriggerService.Subscribe / JobService.WaitJob stream: a grpc-timeout ABOVE the budget is refused at the edge (before the stream opens, counted under timeout); a header WITHIN the budget is ENFORCED by the server itself, ending the stream with DEADLINE_EXCEEDED at the caller’s own declared deadline (uncounted — fires mid-stream, after the edge; tonic’s own GrpcTimeout never bounds a streaming response body already returned, so this is deliberate, not merely honoured as-is); NO header at all is NOT refused — the budget itself becomes the stream’s deadline, ending it with DEADLINE_EXCEEDED once elapsed (also uncounted, same reason). Unset means no cap.
max_subscriptions (default 256)RESOURCE_EXHAUSTEDsubscriptionsConcurrently open TriggerService.Subscribe streams; released when the stream ends or the client disconnects. 0 = unbounded.
max_job_waits (default 1024)RESOURCE_EXHAUSTEDjob_waitsConcurrently open JobService.WaitJob streams; same release rule. 0 = unbounded.

Lease and worker timing

One lease primitive ([lease], crates/jammi-db/src/catalog/lease.rs) owns every leased catalog row — a claimed job and a building result table alike — defaulting to a 30 s lease renewed every 10 s; the job worker adds a 1 s idle-poll ([worker]):

  • Lease (30 s default) — how long a claimed job, or a result table being written, is exclusively owned by its holder before it becomes reclaimable.
  • Heartbeat (10 s default) — renews the lease well inside the window, from the process’s shared lease-keeper thread: one registration per running job and one per BuildingTable between create_table and finish.
  • Idle-poll (1 s default) — how often an idle worker checks for new work; reclaim runs on each idle tick, so a dead worker’s job is recovered within roughly one poll plus one lease. A dead writer’s building result table is reclaimed by the next session’s startup recovery sweep once its lease has expired — never before, so a replica restarting beside a live writer leaves that writer’s table alone.

The config layer enforces the invariant heartbeat × 2 < lease (and rejects a zero lease, zero heartbeat, or zero idle-poll). This guarantees a live holder renews at least twice per lease, so a single missed beat still leaves one in-window renewal that lands strictly before expiry — never coincident with it, which would race a reclaim. Bad values are rejected at config time, never silently clamped.

Job attempts cap

A job is retried at most 3 times. After the third attempt the expired-lease reclaim path fails the job for good rather than re-queueing it indefinitely.

GPU admission — a memory budget

GPU admission is a memory budget, not a max-concurrent-job count. The scheduler admits work against a budget of total_gpu_memory × (1 − headroom_fraction): a reservation is admitted by a compare-and-swap against the reserved total, and released via RAII when the permit drops. Many small jobs can run concurrently while one large job is admitted only when its memory fits the remaining budget.

Work queue

The work queue is the durable jobs table (migration 029) — one kind-agnostic table for every training AND compute kind, distinguishing a queued row a [worker] claim loop may pick up from an inline row a submitting call claims once, by id, in its own task. The queued path is drained with a SELECT … FOR UPDATE SKIP LOCKED claim (Postgres; SQLite’s single serialised writer) so concurrent workers each lock a distinct row. It is bounded by the lease plus the attempts cap, not by an in-memory buffer — there is no in-process queue-depth limit to overflow, and a worker crash leaves the row claimable again after the lease expires.

Close() and the successor handoff

Closing a session (InferenceSession::close / PyDatabase.close) shuts the process’s dedicated lease-keeper thread down and joins it — bounded by a 30s window — before closing the shared catalog pool. This ordering matters specifically for the SQLite backend: the keeper holds its own catalog connection on its own OS thread, independent of the shared pool, so closing only the shared pool while the keeper’s thread stays up leaves the unix-excl VFS’s process-scoped exclusive lock held and a successor process opening the same directory refused within its busy timeout, even after every other handle has let go. A keeper that does not exit within the shutdown window is logged and does not block the shared-pool close that follows — close() must never hang, even at the cost of leaving that one connection’s fate unresolved in the (unexpected) case the keeper’s thread is wedged.

Failure-mode matrix

FailureObserved behaviorRecovery mechanismSignal (metric/log)Proving test
Storage dies mid-publishNo half-written committed artifact. The crashed worker’s per-attempt prefix is orphaned because its finalize CAS never ran.Winner-only commit: each attempt writes a unique {job}/{worker}/{attempt} prefix; the served artifact_path is written solely by the finalize CAS, so the committed pointer roots under the winner’s prefix.Final jobs row’s artifact_path (nested in its result payload) resolves to the winner’s prefix; reload returns the winner’s bytes.Proven by tests/distributed/artifact_crash_window.rs.
Worker diesThe claimed job is reclaimed by a different worker after the lease expires and completes exactly once.Lease expiry + idle-tick reclaim; the FOR UPDATE SKIP LOCKED claim guarantees a single new owner.The finalized row’s claimed_by is a different instance id; reclaim runs each idle tick (worker log); the process’s own workers row is deleted once its claim loop stops.Proven by tests/distributed/kill9_reclaim.rs (plus exactly_one_claim.rs for the N-worker claim race and cross_tenant_isolation.rs for tenant scope).
Compute tier down: jobs stay queuedA deployment where the request-facing node runs [worker] enabled = false and only a separate compute-tier node runs [worker] enabled = true submits jobs normally even while every compute node is down: SubmitJob only inserts the queued row, it never requires a live claimant. JobStatus/WaitJob report the row queued indefinitely — never a fabricated failure — until a compute node’s claim loop comes back and claims it.No special recovery needed: reclaim_expired_jobs’s arms match only running-status rows past their lease — a queued row that has never been claimed has no lease to expire, so it is simply the row a [worker] claim loop resumes from once any process with a matching kinds entry is running again.ListWorkers shows zero rows for the down kind; the job’s row stays queued (never running, never failed) for the outage’s whole duration.The reclaim arms’ running-only scope is asserted by crates/jammi-db/tests/it/jobs_queue.rs’s reclaim_leaves_live_leases_untouched and the claim-ordering tests (a job the claim loop has not reached yet is untouched by reclaim); [worker] enabled gating is asserted by crates/jammi-db/src/config/tests.rs’s worker_config_toml_enabled_false_parses_to_false. No single test submits a job with zero live workers and asserts it stays queued indefinitely end to end — an honest gap, tracked as a follow-up.
GPU diesMemory-budget admission releases the permit via RAII on the failing path, but in-flight GPU-fault recovery is not yet validated end-to-end.Honest gap: not yet proven (1.0-deferred). The distributed lane is CPU-only, so no chaos test exercises a GPU fault.
Broker diesThe trigger stream is a separate subsystem from the training worker fleet — claim and lease are pure Postgres, with no broker coupling — so a broker outage does not stall training. A broker fan-out failure is best-effort: the publisher has already committed the augmented event (with its engine _offset) to the durable backing table before fanning out, so the event is never lost — subscribers replay it from the backing table on reconnect. The Postgres broker driver carries no data of its own (it is a LISTEN/NOTIFY wake-up transport over the same backing table every driver replays), so its own connection dying is doubly harmless: a lost NOTIFY (a listener disconnect, a dropped notification) is bounded by the idle_poll_secs tick, which wakes every topic and triggers a replay regardless — nothing is lost, only delayed up to one poll interval.At-least-once + replay-completeness: the backing table is the authoritative log; a subscriber attaches at an engine _offset, replays [from..last_replayed] from the table, then joins the live broker tail with overlap and dedups by engine _offset so no committed offset is ever skipped across the replay/live seam. The seam is keyed on the engine _offset alone — never on a broker-native sequence (JetStream’s stream sequence is an independent counter that skews permanently after any post-commit fan-out failure).Replayed offsets are contiguous from from_offset; the live tail resumes with no gap (broker integration test + in-mem property test); for the Postgres driver, every offset still arrives via replay within idle_poll_secs of the listener connection being killed or a NOTIFY being silently dropped.At-least-once + replay-completeness PROVEN. In-memory + crash-mid-publish: jammi-db/tests/it/trigger.rs (crash_mid_publish_replays_committed_offsets_with_no_loss, live_tail_resumes_with_no_loss_after_post_commit_fan_out_failure, at_least_once_no_skip_property_over_randomized_states). Live JetStream consumer-recreate resume: jammi-db/tests/it/trigger_jetstream.rs (consumer_recreate_resumes_engine_offsets_with_no_loss, gated live-broker-tests). Postgres listener-kill and lost-NOTIFY recovery: jammi-db/tests/it/broker_parity.rs (postgres_listener_killed_recovers_via_replay, postgres_suppressed_notify_recovers_via_idle_tick, runtime-skip on JAMMI_TEST_PG_URL, run in the test-pg CI job). Exactly-once is NOT provided by any backend — dedup downstream by the (_offset, _row_idx) composite key. At-least-once is bounded by the backing log’s durability (see below): process-crash-durable always; full on Postgres; on SQLite a host power-loss can lose the last committed backing-table row(s) since the previous checkpoint.
Crash mid-publish of a result tableNo half-written result table is ever queryable. On restart every table left building by a dead writer — its lease absent or expired — is reconciled to exactly one terminal state — ready if its Parquet is a fully-valid closed file whose manifest sidecar landed (promoted with the true footer row count, and the ANN sidecar rebuilt from the Parquet so an embedding table self-heals), failed otherwise (missing or torn bytes, or a valid Parquet with no manifest; the objects are reaped only after the row’s building → failed compare-and-set). A building row under a live lease belongs to a writer that is still producing it and is left alone.Crash-consistent eventual reconciliation over lease-owned rows: a writer’s BuildingTable stamps its writer_id and heartbeats a lease; every transition on the row is a compare-and-set naming the owner; the startup sweep enumerates only expired-lease rows, claims a promotable row (becoming its writer) before it rebuilds, and fails a reapable row before it deletes. The sweep runs cross-tenant (the one implicit-admin pass) — it deletes expired-lease bytes across every tenant, even from a tenant-bound session — and each row keeps its own tenant_id.Recovery: … WARN logs name each reconciled table and its disposition; no expired-lease row remains building.Proven by jammi-db/tests/it/recovery.rs — each torn state (missing bytes, truncated Parquet, valid-but-unfinalized Parquet, finalize-ordering window, ready-but-missing-bytes, two tenants’ orphans) is constructed directly as a dead writer’s, then the real recover() + load_existing_tables asserts invariants I1–I6; the two-writer oracles (live_writer_survives_peer_recover_w1/_w2, live_writer_survives_peer_reconcile_apply_u2, feature test-hooks) park a live writer while a peer session sweeps or reconciles beside it and prove its row, bytes, and segments survive and the table completes with the true count.
Crash mid-refresh of a versioned embedding tableThe table stays ready and keeps serving its current version — a refresh writes only artifacts stamped with the new version number and flips current_version in the publish transaction. A crashed refresh leaves a building version row under an expired lease plus its stamped objects.Startup recovery (and reconcile(apply=true)) claims the expired-lease version row, fails it by CAS, and reaps only the artifacts stamped with that number (__v{N}.parquet, __v{N}.deletes.parquet, __v{N}.version.json, the version = N segments); the version number is never reused. A version is never promoted from its sidecar — a delta is cheap to redo. result_tables.status, writer_id and current_version are untouched.Recovery: … version … failed WARN logs; no expired-lease version row remains building.Proven by crates/jammi-ai/tests/it/refresh.rs::expired_version_lease_is_reaped_and_the_table_stays_ready and concurrent_refreshes_on_one_parent_publish_exactly_once.
Current version manifest vanished (storage restored to an earlier point past a refresh)The table binds as a placeholder: planning succeeds, every SELECT/search/read_vectors over it is the typed VersionUnavailable { table, version } — never a resurrected older version, never a partial answer. A peer tenant still resolves not-found.recompute(table) produces a new table with a fresh identity chain; reconcile reports the missing objects. Only a definitive exists() miss fails the version row; a storage error propagates as a storage error.VersionUnavailable on every read; reconcile’s rows_failed.Proven by refresh.rs::current_manifest_loss_is_typed_unavailable_and_recomputable and crates/jammi-db/tests/it/masked_read.rs.
Object vanished mid-scan (a fragment deleted under a running query)The query fails with the typed Storage(StorageError::Io { source: object_store::Error::NotFound }) — the structural classifier walks the DataFusion error’s source() chain — never a truncated result. A read that already holds its version’s segment set in memory completes.Nothing to recover: versions are immutable and the deletion happened outside the engine; reconcile names the missing object.The typed Storage error at the caller.Proven by masked_read.rs::mid_scan_object_vanish_is_a_typed_not_found (feature test-hooks).
Replica restarts during another replica’s materializationThe restarting replica’s recovery sweep skips the live writer’s building row (its lease is live); the writer finishes normally. If the writer instead stalls past its lease, the sweep claims the row (the writer’s next heartbeat reports LeaseLost, it stops and deletes nothing) and either promotes it from the writer’s own sidecar or reaps it. Lease time is the catalog database’s clock (now() on Postgres); replica clock skew does not matter — see “Multi-writer safety” above.Lease ownership + compare-and-set on (table_name, writer_id, status = 'building'); the deletion arms are a writer’s own abort() after its one-row CAS, the reaper’s claim/fail CAS (from both recover()’s startup sweep and reconcile(apply=true)’s expired-lease pass), and delete_result_tables_for_source/remove_source’s atomic delete-with-live-guard.A writer that lost its lease surfaces LeaseLost / CasFailed typed errors; Recovery: … skipped WARN logs name a row a sweep declined.Proven by recovery.rs::live_writer_survives_peer_recover_* / live_writer_survives_peer_reconcile_apply_u2, the zero-row outcome tests (RowGone / TenantMismatch / CasFailed / LeaseLost, none deletes), expired_lease_building_row_is_claimed_before_reconcile_reaps_it_u2b, and two_recoverers_race_on_one_expired_row (exactly one recoverer promotes).
Client floods the port (oversize messages, unbounded concurrency, or a long-held stream budget)Every excess request is refused at the edge — before a tenant-scoped catalog read runs — rather than exhausting memory, file descriptors, or the catalog connection pool. A well-behaved caller elsewhere is unaffected: the per-connection bound isolates one noisy peer, and the two stream budgets (Subscribe/WaitJob) are independent of each other and of the unary bounds.[server.limits]’s refusal-at-the-edge design (see Request bounds) — no queueing, no silent degrade; a caller that needs more headroom raises the config knob.jammi_grpc_refused_total{reason} — non-zero for a specific reason names exactly which knob a caller is hitting.Proven by crates/jammi-server/src/limits.rs’s own tests module (the concurrency/timeout/budget refusal paths, deterministically) and crates/jammi-server/tests/it/grpc_limits.rs (the oversize-message, stream-budget-drop-releases-the-permit, and wait-timeout-at-the-edge cases, live over the wire — including the Flight SQL parity case).
A segment owner is unreachable (placed retrieval)A Search whose table has a segment owned by another replica walks a bounded ladder per remote segment: the owner (2 s deadline) → one retry at the next rendezvous candidate → a local load of the segment through the content-addressed cache, admitted only under [server] peer_local_load_bytes and only when the table records its dimensions → the query fails UNAVAILABLE naming segment {table}/{id} and the last reason. A peer outage is visible, never masked by a silent full scan of a larger-than-memory table; an all-local table keeps its whole-table exact fallback on a torn bundle. Readiness is untouched (/readyz stays the catalog ping), so a peer outage never ejects the coordinator from the load balancer.Bounded per request — no background loop; placement is re-read at every resolve. Raise the budget (or unset it) to let queries ride out an outage on local loads; restore the owner to stop paying them.jammi_peer_search_failures_total{reason} (unreachable / deadline per failed call, retry_ok, local_load, unavailable), a WARN per rung naming table / segment / owner / reason, and the typed UNAVAILABLE (JammiError::Unavailable, wire detail unavailable) on the query.Proven by crates/jammi-server/tests/it/peer_placement.rs (two engine instances in one process over one catalog: byte parity with the all-local merge, the retry rung, the budget-refused and dimensions = None UNAVAILABLE arms, the admitted local load, readiness 200 throughout).
Catalog restored to an earlier point (storage unchanged)Startup recovery only reconciles building rows — a ready row whose objects the storage side has since moved past (or a ready row the restored catalog no longer has any record of writing) is invisible to it.jammi reconcile catches what startup recovery does not: a ready row whose required objects are missing is flipped to failed (by CAS, after a live exists() per object); any storage object the restored catalog no longer references is reported as an orphan (or unattributed if its key predates the tenant-layout convention). See Backup and Restore for the restore-ordering rule this failure mode motivates.reconcile’s report (rows_failed, orphans, unattributed, bytes_reclaimed).Proven by reconcile.rs::missing_object_fails_the_row_and_is_reaped_past_grace and apply_false_never_mutates (a dry run reports without touching anything, so re-running reconcile against a mismatched restore is always safe to inspect first).
Storage restored to an earlier point (catalog unchanged)Symmetric to the row above: a ready row’s catalog entry survives but its Parquet or a sidecar the row requires is gone from the rolled-back storage snapshot.Same reconcile pass, same row→object completeness check — the missing-object arm does not care which side of the pair moved.Same as above.Proven by the same reconcile.rs suite; unattributed_key_never_deleted_regardless_of_grace additionally guards against a rolled-back storage snapshot’s pre-layout keys being mistaken for reclaimable orphans (see below).

reconcile boot cost, pre-layout keys, and delete_model

  • reconcile_ready_manifests’s boot cost is one exists() per post-contract ready row (definition_hash IS NOT NULL) — it checks only that row’s .materialization.json sidecar is still present, never a full object-store LIST. A pre-contract row (definition_hash IS NULL) is skipped entirely; it legitimately has no sidecar to check.
  • A key written before the tenant-prefixed layout landed is permanently unattributed. reconcile’s row→object and object→row checks both key off TenantSegment::parse, which only recognizes {seg}/{table}.parquet and models/{seg}/{job}/… shapes; an older flat key never round-trips through that parser, so it is reported as unattributed and never deleted at any grace — the allowlist is a safety property, not a migration path. Such an object stays reachable only by direct URL, exactly as it was before the tenant-prefixed layout existed. unattributed is reported only by the admin cross-tenant pass (reconcile --all); a tenant-scoped pass never lists another tenant’s — or nobody’s — stray keys.
  • reconcile’s grace gate compares two DIFFERENT clocks. The age check (is an orphan candidate old enough to reclaim?) reads last_modified off the OBJECT STORE’s own clock and compares it against the replica running reconcile’s Utc::now() — never the catalog database’s clock the way a lease predicate does. apply=true requiring grace >= the configured lease duration is exact only when the object store’s clock and the replica’s clock agree; ordinary NTP-level skew erodes the safety margin, it does not remove the mechanism — set grace to several multiples of the lease duration as the practical guard against both a slow writer and clock skew together.
  • delete_model deletes the catalog row only — it never touches the artifact bytes at models/{seg}/{job_id}/…, and it refuses (JammiError::ModelReferenced) while another row still references it. A specific {model_id}:epoch_N checkpoint row is independent of the model’s own row: deleting one leaves the other’s row and bytes untouched. An artifact prefix no models row names any longer becomes a reconcile orphan candidate on the next pass, aged against grace like any other.

Catalog durability under crash vs. power loss

Result-table crash-consistency reconciles whatever the catalog durably retained against the bytes on disk, so the catalog’s own durability setting bounds the guarantee:

  • Process crash (the engine dies, the host survives): both backends replay their write-ahead log on restart, so a building → ready (or the building insert recovery later reconciles) that committed before the crash is present after it. No committed catalog state is lost.
  • Host power loss: Postgres commits synchronously (fsync per commit by default), so a committed transaction survives. SQLite runs synchronous=NORMAL under WAL — it fsyncs at checkpoint, not on every commit — so a power loss can lose the last committed transaction(s) since the previous checkpoint. A row that was not durably retained simply isn’t seen by recovery; the bytes it would have pointed at are reaped as an orphan on a later sweep. This is a property of the catalog backend’s durability configuration, not of the reconciliation.

The trigger stream’s at-least-once guarantee inherits exactly this bound, because its durable log is the same kind of backing table written through the same backend transaction. Under a process crash the at-least-once guarantee is unconditional — the committed backing-table rows replay on reconnect. Under a host power loss the durable log itself is power-loss-bounded: on Postgres a committed publish survives; on SQLite (synchronous=NORMAL under WAL) the last committed backing-table row(s) since the previous checkpoint can be lost, and an offset whose row was not durably retained will not replay. At-least-once is therefore full on Postgres and power-loss-bounded on SQLite — never weaker than the backing log’s own durability.

Configuration

Jammi loads configuration by layering three sources — the file layer is deep merged with the environment layer (an environment value wins field-by-field; see Environment variable overrides), falling back to defaults for anything neither layer sets:

  1. Config file (TOML) — resolved, first match wins: an explicit path, $JAMMI_CONFIG, ./jammi.toml, /etc/jammi/jammi.toml, then the platform per-user config directory (config.toml under directories::ProjectDirs::from("ai", "jammi", "jammi").config_dir() — e.g. ~/.config/jammi/config.toml on Linux).
  2. Environment variablesJAMMI_GPU__DEVICE=0, JAMMI_INFERENCE__BATCH_SIZE=64.
  3. Defaults — sensible defaults for every field.
#![allow(unused)]
fn main() {
extern crate jammi_db;
use std::path::Path;
use jammi_db::config::JammiConfig;
fn ex() -> jammi_db::error::Result<()> {
// Load with defaults
let config = JammiConfig::load(None)?;

// Load from a specific file
let config = JammiConfig::load(Some(Path::new("/path/to/jammi.toml")))?;
Ok(()) }
}

JammiConfig::load is JammiConfig::load_from against the real process environment; load_from(file, env) takes an explicit env map instead (used by every hermetic config test in the workspace — and by the doc example below — so nothing here ever touches a real environment variable). JammiConfig::parse_from(toml_src, env) is the parse-only core underneath both: it runs ${VAR} interpolation, the layering, and deserialization, but skips the post-load validation (storage.cloud.validate(), the worker timing invariants) load_from runs afterward.

Full reference

# Where Jammi stores artifacts (catalog DB, model cache, embeddings)
# Default: platform-specific data directory (~/.local/share/jammi on Linux)
artifact_dir = "/path/to/artifacts"

[engine]
# Number of DataFusion execution threads. Default: number of CPUs.
execution_threads = 8
# Memory limit for the query engine's DataFusion session: this becomes the
# byte size of a `GreedyMemoryPool` every plan and every engine-side memory
# reservation (a training-set stream's chunk, an eager read's collected
# batches) is bounded by. Three forms:
#   - "<n>%"      -- that percentage (1-100) of the HOST's total physical
#                    memory (a Linux cgroup ceiling is honoured when it is
#                    lower than the host total and readable), resolved once
#                    at session build.
#   - "<n>GB"/"<n>MB"/"<n>KB" -- n binary (1024-based) units.
#   - "<n>"       -- n bytes, unadorned.
# Anything else, or a resolved value below the 64 MiB floor, is refused at
# load, naming the key and (for the floor) the floor. A query or engine-side
# reservation that would grow the pool past this limit fails with a typed
# `ResourcesExhausted` error rather than silently exceeding it. Default: "75%".
memory_limit = "75%"
# Maximum rows per DataFusion batch. Default: 8192.
# Deployment rule: a single batch larger than `memory_limit` above cannot be
# sorted (a spilling external sort still needs one batch resident), which
# matters most for a training-set materialization's full-tuple sort over wide
# rows — size this down (and `memory_limit` up) when a row is large.
batch_size = 8192

[gpu]
# GPU device index. -1 for CPU only. Default: 0.
device = -1
# GPU memory limit. Default: "auto".
memory_limit = "auto"
# Fraction of GPU memory Jammi may use. Default: 0.9.
memory_fraction = 0.9
# Fail fast if the requested GPU is unavailable instead of falling back to CPU.
# Default: false (degrade to CPU with a warning).
require_gpu = false
# Default inference compute precision: "f32" or "f16". A model may override
# this with its own "compute_precision" in config.json; the per-model value
# wins. "bf16" is a valid value for fine-tune's frozen-backbone dtype but is
# rejected at inference load time (not yet supported). Default: "f32".
compute_precision = "f32"

[inference]
# Default backend selection strategy. Default: "auto".
default_backend = "auto"
# Maximum rows per inference batch. Default: 32.
batch_size = 32
# Timeout for batch accumulation in server mode (seconds). Default: 300.
batch_timeout_secs = 300
# Maximum models kept loaded simultaneously. 0 = unlimited. Default: 0.
max_loaded_models = 0

[inference.http]
# HTTP request timeout (seconds). Default: 60.
timeout_secs = 60
# Custom headers for HTTP model endpoints.
[inference.http.headers]
# Authorization = "Bearer sk-..."

[embedding]
# Distance metric for vector indices. Default: "cosine".
default_distance_metric = "cosine"
# Index type for vector storage. Default: "ivf_hnsw_sq".
default_index_type = "ivf_hnsw_sq"
# Rows between embedding index checkpoints. Default: 1000.
checkpoint_interval = 1000

[fine_tuning]
# LoRA rank for fine-tuning. Default: 8.
default_lora_rank = 8
# Learning rate. Default: 0.0002.
default_learning_rate = 0.0002
# Training epochs. Default: 3.
default_epochs = 3
# Training batch size. Default: 8.
default_batch_size = 8
# Checkpoint every N fraction of training. Default: 0.1.
checkpoint_fraction = 0.1

[lease]
# The one lease timing every leased catalog row shares: a claimed training
# job and a `building` result table are both owned under a lease their holder
# heartbeats, and both are reclaimed by a sweep once it expires.
# How long a claim owns its row before it is reclaimable. Default: 30.
duration_secs = 30
# How often the holder renews the lease. Must leave a real margin under the
# lease (heartbeat_secs * 2 < duration_secs), so a single missed beat does
# not drop a live holder's lease. Default: 10.
heartbeat_secs = 10

[worker]
# Whether THIS process runs the job claim loop. Default: true.
# true  - the process claims queued jobs (of `kinds`, below), renews the
#         lease while they run, and reclaims leases that expired under a
#         dead claimant.
# false - the process still mounts and serves the submission surface and
#         still accepts submissions, but never claims. Submitted jobs stay
#         queued until some process with enabled = true opens the catalog.
#         The SQLite catalog is single-process, so this process must close
#         the catalog before that one can open it; a Postgres catalog is
#         multi-process and can run both at once.
enabled = true
# Which job kinds this worker claims. "all" (the default) claims every kind
# compiled into the binary; a comma list or array claims only those named.
kinds = "all"
# How often an idle worker polls for a queued job (and reclaims expired
# leases). Must be > 0 - a zero poll is a busy-loop. Default: 1.
# The lease a claim is held under is `[lease]` above; a `[worker]` section
# naming the former `lease_duration_secs` / `heartbeat_interval_secs`
# keys is refused at load (no alias), never silently defaulted.
idle_poll_secs = 1
# How often a worker-enabled process samples the queue-depth gauges
# (`jammi_jobs_queued{kind}` / `jammi_jobs_running{kind}`) from the catalog:
# one grouped count per tick on a dedicated task, never on a `/metrics`
# scrape and never on the claim loop. Must be >= 1. Default: 5.
metrics_sample_secs = 5
# How many ranks THIS HOST places on its own `[gpu] devices` for a
# distributed training job it runs entirely in-process - one rank per
# device, rank `i` on `[gpu] devices[i]`. Must be >= 1 (the default, 1, is
# the single-rank deployment: no gang, no collective) and never more than
# the configured device count. Orthogonal to a submitted job's own
# `world_size` (a separate, per-job knob) and to `[distributed]
# max_world_size` (the fleet-wide bound on a `Peer` gang across hosts) -
# the three knobs load independently, with no cross-check between any
# pair. A claimed job whose `world_size` is within `local_ranks` runs every
# rank in this process over a `Local` gang; one wider than `local_ranks`
# makes this process rank 0 of a `Peer` gang whose other ranks are fleet
# members it assembles and dials.
local_ranks = 1
# Which collective a multi-rank worker reduces gradients over. Default:
# "auto" (the best collective this process can actually reach: NCCL on a
# CUDA build, the host CPU reduction otherwise). "nccl" on a build without
# the `cuda` feature is refused at session open. Configuration, not a build
# feature.
collective = "auto"
# How long a rank waits on its peers at a gang boundary before the wait is a
# failure: on the coordinator, a member silent this long retires the attempt
# (requeued from its checkpoint, one attempt spent). Must be > 0 - a zero
# deadline expires before any peer can answer, turning every gang into an
# immediate failure. Default: 120.
rank_timeout_secs = 120

[distributed]
# The widest `Peer` gang any coordinator on this deployment may admit,
# bounding a job's own `world_size` ACROSS FLEET MEMBERS: a submitted
# `world_size` past it is refused at submit, from configuration alone; one
# within it submits even when it is wider than this host's own `[gpu]
# devices`, and is decided by assembly on the claiming coordinator. Loads
# independently of `[worker]`'s own per-host rank count -- the two knobs
# are checked against each other by nothing. Must be >= 1 (1, the default,
# admits no fleet gang at all).
max_world_size = 1

[jobs]
# How many days a terminal (completed/failed) job row survives before the
# retention sweep may delete it, and before it stops blocking `delete_model`
# on the model(s) it references. A non-terminal job blocks indefinitely,
# regardless of age. Default: 30.
retention_days = 30

[cache]
# Enable ANN query cache. Default: true.
ann_cache_enabled = true
# Max cached ANN queries. Default: 10000.
ann_cache_max_entries = 10000
# Enable embedding cache. Default: true.
embedding_cache_enabled = true
# Embedding cache size. Default: "1GB".
embedding_cache_size = "1GB"

[server]
# Health probe listen address. Default: "0.0.0.0:8080".
health_listen = "0.0.0.0:8080"
# Arrow Flight SQL listen address. Default: "0.0.0.0:8081".
flight_listen = "0.0.0.0:8081"
# Models to load into the cache before /readyz reports ready and before this
# process's claim loop claims anything. A bare id takes its task from the
# catalog's `models` row; `{ id, task }` names it (required for a `local:`
# path). A model that cannot load, a bare id with no row, or an unknown task
# token is a startup error (the server exits non-zero). Default: [].
preload_models = [
    "sentence-transformers/all-MiniLM-L6-v2",
    { id = "local:/models/bge-small", task = "text_embedding" },
]
# The INTERNAL peer listener for beyond-one-node retrieval and multi-host
# gang admission: the address this replica serves `jammi.v1.peer.PeerService`
# (segment search for the segments it owns) AND `jammi.v1.gang.GangService`
# (RunRank -- a coordinator admitting this replica into a multi-host training
# run) on, to OTHER replicas/coordinators of the same deployment. Unset (the
# default) = no third listener = single node = no gang admission surface.
# A replica is a segment owner and a gang admission member iff this is set.
# Must differ from health_listen and flight_listen at a fixed port (`:0`
# never collides). I-PEER / I-GANG: every client of this listener is a jammi
# coordinator -- the owner/member trusts the channel; the peer side binds no
# tenant and enforces only that each requested segment belongs to the named
# table (the coordinator resolved that table through its own tenant-scoped
# catalog read before fanning out); the gang side never reads the caller's
# tenant either -- a rank's tenant is derived from the verified `jobs` row,
# and its training set is resolved under that tenant alone. Bind it on a
# private interface behind
# network policy / mTLS from the runtime: on a routable interface without
# them it exposes cross-tenant reads. See security.md "The peer listener"
# and "The gang listener".
# peer_bind = "10.0.0.5:8082"
# The address OTHER replicas dial THIS process's `peer_bind` listener at
# (`peer_bind` is commonly `0.0.0.0:PORT`, unusable as a dial target).
# Unset (the default) = this process never advertises a gang-membership row:
# its `instances.peer_addr`/`result_root` columns stay NULL regardless of
# whether `peer_bind` is set. Setting it means: this process ADVERTISES
# itself as a gang member. Requires `peer_bind` to be set too -- refused,
# naming both keys, by `InstanceRegistration::from_config`, called once by
# every session construction path (and, for this early-failure check alone,
# by `JammiConfig::load_from` at config load time too).
# The membership root rule: `instances.result_root` carries the VERBATIM,
# byte-for-byte output of `resolved_result_root()` -- the exact same string
# the result store is rooted at ({artifact_dir}/jammi_db when [storage]
# result_root is unset, else result_root itself) -- and
# `instances.result_root_identity` carries that root's IDENTITY across
# spellings, computed once by this process at registration from the same
# config the store reads: scheme aliases folded by the store's own URL
# parser (gcs://=gs://, abfss://=azure://), the bucket as spelled and
# the key normalised by the store's own key parser, the location determinants
# (the endpoint/account/base URL the driver dials) read back from the very
# builder the store constructs -- environment first, [storage.cloud] on top,
# every spelling object_store accepts -- a local root CREATED
# (as the store creates it at open) and canonicalised on this host's
# filesystem (symlinks, ./.., the filesystem's own spelling). Only members
# whose identity equals this process's are its gang members. A memory://
# root is refused here: it lives in this process alone and can never be
# shared with a peer. See "The gang listener (I-GANG)" in security.md.
# peer_advertise = "10.0.4.7:9000"
# MARGINAL-LOAD ADMISSION per query, in bytes (a plain integer): the maximum
# estimated bytes ONE query may load locally for segments it does not own,
# when their owners are unreachable -- the last rung of the placed-search
# failure ladder (see "Beyond one node" in reference-topologies.md). Unset
# (the default) = unbounded, today's behaviour. It is NOT a memory cap: the
# segment cache never evicts, earlier queries' loads are invisible to the
# check (each query loads afresh and frees on completion; the on-disk copy of
# a remote bundle persists), distinct remote segments accumulate on disk, and
# concurrent queries admit independently, so peak heap is
# concurrency x budget. The estimate per segment is
# row_count x (dimensions x bytes(precision) + 32 + 64) -- 4 (F32) / 2 (F16)
# / 1 (Int8) / ceil(d/8)/d (Binary) bytes per component, 32 bytes of row-id
# strings and 64 bytes of graph link overhead per row -- a LOWER bound for
# the quantized precisions: the rawf32 companion is excluded (it is a
# positioned read, never resident), but usearch's level-0 links and the
# row-id HashMap are unmodelled, so the true resident size exceeds it.
# Prescribe headroom: set the budget to at most half the memory you are
# willing to give one query's fallback loads. 0 is refused. Read by the
# result store; a library embedder sets it through the same config.
# peer_local_load_bytes = 268435456

[server.limits]
# Request-bounds and refusal policy for the combined gRPC + Flight SQL
# surface (also applied to the Flight-only listener). A request exceeding
# any of these is refused at the edge -- before any tenant-scoped catalog
# read runs, so a refusal never leaks cross-tenant existence -- with a typed
# gRPC status and a jammi_grpc_refused_total{reason} counter increment.
# Maximum inbound message size, in bytes, on EVERY listener: the public
# chain and the internal `peer_bind` listener (a gang round's chunks are
# sized to it). Must be > 0. Default: 67108864 (64 MiB). There is no
# outbound cap.
max_message_bytes = 67108864
# Global cap on unary requests in flight across every connection.
# 0 = unbounded. Default: 256.
max_in_flight = 256
# Cap on unary requests in flight on a SINGLE connection. 0 = unbounded;
# when both this and max_in_flight are non-zero (bounded), this must be
# <= max_in_flight. Default: 64.
max_in_flight_per_connection = 64
# Maximum duration a unary request may run before this server cancels it
# with DEADLINE_EXCEEDED. Unset (the default) means no server-imposed
# timeout. Unary methods only -- Subscribe/WaitJob use wait_timeout_secs
# and the stream budgets below instead.
# request_timeout_secs = 30
# Bounds a TriggerService.Subscribe or JobService.WaitJob stream. The
# server budget bounds the stream; the client imposes no deadline of its
# own by default (jammi-client's wait_job/subscribe send no grpc-timeout
# header). Three arms:
#   * a grpc-timeout header ABOVE this budget is refused at the edge,
#     before the stream ever opens (DEADLINE_EXCEEDED).
#   * a grpc-timeout header WITHIN this budget is ENFORCED by the server
#     itself, at the caller's own declared deadline -- the stream ends
#     with DEADLINE_EXCEEDED once that (shorter) duration elapses, not
#     the wider budget. This is deliberate: nothing else bounds a
#     streaming response body already returned, so a caller that declares
#     a deadline and then ignores it would otherwise hold the stream open
#     (and its permit held) past its own declared timeout.
#   * NO grpc-timeout header at all (the default for jammi-client, and for
#     any header-less caller) is NOT refused -- this budget itself becomes
#     the stream's own deadline, ending it with DEADLINE_EXCEEDED once it
#     elapses, wherever the stream then stands.
# Unset (the default) means no cap -- a stream runs until terminal
# (WaitJob) or indefinitely (Subscribe).
# wait_timeout_secs = 300
# Cap on concurrently open TriggerService.Subscribe streams. 0 = unbounded.
# Default: 256.
max_subscriptions = 256
# Cap on concurrently open JobService.WaitJob streams. 0 = unbounded.
# Default: 1024.
max_job_waits = 1024

# [ballista]
# A process hosts a Ballista scheduler iff `scheduler_bind` is set, and an
# executor iff `[ballista.executor]` is present. Unset (the default, the
# whole `[ballista]` table absent) means neither role -- the process runs
# exactly as it always has, byte-for-byte. Both roles on one process is the
# single-node cluster, with one refinement: that process's own executor is
# excluded from its own placement decisions, so a claimant on it places
# onto a DIFFERENT registered executor when one exists, and runs in-process
# otherwise (there is no other role combination to configure -- placement
# is a property of the cluster view, not a third knob).
# Trust class: every listener this table opens (the scheduler's gRPC below,
# the executor's task gRPC and Flight shuffle in `[ballista.executor]`) is
# the peer listener's class, I-PEER -- unauthenticated, every client a jammi
# role, tenant scope enforced at the submitting session (see the security
# guide, "The Ballista listeners"). Bind them on the cluster-internal
# network and owe them the same network policy as `[server] peer_bind`.
# This process hosts a Ballista scheduler bound here iff set.
# scheduler_bind = "0.0.0.0:50050"

# [ballista.executor]
# This process hosts a Ballista executor iff this table is present. Unset
# (the default, table absent) means no executor role.
# The scheduler this executor registers with and takes tasks from,
# `host:port` -- a `SocketAddr` literal or a DNS name and port (the
# Kubernetes case). Required whenever `[ballista.executor]` is present.
# scheduler_address = "10.0.4.7:50050"
# This executor's Arrow Flight (shuffle) listener. Default: "0.0.0.0:50051".
# bind = "0.0.0.0:50051"
# This executor's gRPC (task) listener. Default: "0.0.0.0:50052".
# grpc_bind = "0.0.0.0:50052"
# The host other executors/the scheduler dial to reach this executor.
# REQUIRED when `bind`'s host is unspecified (`0.0.0.0`/`::`) -- the
# scheduler dials this address back to register the executor and push
# tasks, and an unspecified host never resolves on the scheduler's side of
# that connection. Unset (the default) means the `bind` host, valid only
# when `bind` already names a real interface.
# advertise_host = "10.0.4.8"
# Local directory Ballista's shuffle writer stages files under. Unset (the
# default) means a fresh temporary directory per process (no object-store
# shuffle in v1).
# work_dir = "/var/lib/jammi/shuffle"
# Concurrent task slots this executor offers the scheduler. Must be >= 1.
# Default: 1.
# task_slots = 1
#
# `scheduler_bind`, `executor.bind`, `executor.grpc_bind`,
# `[server] health_listen`/`flight_listen`/`peer_bind` (configuration.md's
# `[server]` block) may never share a fixed port -- a collision is refused
# at load time naming both keys. Two addresses collide iff their ports are
# equal and non-zero AND their hosts are equal or either host is
# unspecified (`0.0.0.0`/`::` overlaps every interface, including
# `127.0.0.1`); an ephemeral `:0` never collides with anything.

[logging]
# Log level: "trace", "debug", "info", "warn", "error". Default: "info".
level = "info"
# Log format: "text" or "json". Default: "text".
format = "text"

[observability]
# OTLP/gRPC collector endpoint spans export to. Unset (the default) means:
# build no exporter and open no network connection at all -- a process with
# no configured endpoint attempts zero egress for tracing, whether or not
# the `telemetry-otlp` cargo feature is compiled in.
# otlp_endpoint = "http://localhost:4317"
# `service.name` resource attribute stamped on every exported span.
# Default: "jammi".
service_name = "jammi"
# Fraction of traces kept by the parent-based ratio sampler, in [0.0, 1.0].
# Default: 1.0 (sample everything).
sample_ratio = 1.0

# [observability.otlp_headers]
# Request headers the exporter attaches to every export call (e.g. a
# collector auth token). Each value is a secret -- a plain string inline, or
# `{ file = "/run/secrets/otlp-token" }` -- and is never logged. Default:
# empty.
# x-api-key = { file = "/run/secrets/otlp-token" }

JobService.SubmitJob’s idempotency_key is bounded to 256 bytes (MAX_IDEMPOTENCY_KEY_BYTES, jammi_db::catalog::jobs_repo) — a fixed engine bound, not a [server.limits] key. A longer key is refused with INVALID_ARGUMENT naming the bound, never the key’s own value. This closes a real backend divergence: Postgres’s btree index has a hard row-size ceiling an oversize key can exceed (index row size ... exceeds btree version 4 maximum ...), while SQLite silently accepts a key of any size — without the bound, the same idempotency_key would be accepted on one backend and refused on the other.

Catalog, broker, signing key, storage, and model source

Five sections select a backend rather than tune a fixed set of knobs, so each is an externally tagged enum: the variant name is its own TOML table (or a bare string for a variant with no required fields), never a kind = key inside one shared table — [catalog.postgres], not [catalog] with kind = "postgres". Selecting an unrecognised variant, or naming a key that does not belong to the selected one, is a load-time error naming the offending name; two variants of the same section both present (in the same layer) is a load-time error too.

catalog — the models/sources/eval-runs/mutable-table backend. Default: SQLite under artifact_dir.

[catalog.sqlite]
# path = "/var/lib/jammi/catalog.db"   # optional override
[catalog.postgres]
url = "${POSTGRES_URL}?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca-certificates.crt"
pool_size = 16
max_lifetime_secs = 1800

broker — the trigger/provenance-channel backend. Default: the in-process broker.

broker = "in_memory"
[broker.jet_stream]
url = "nats://${NATS_HOST}:4222"
retention_seconds = 604800
credentials = { file = "/var/run/secrets/nats.creds" }

[broker.jet_stream] requires the jetstream-broker cargo feature on jammi-db; selecting it without the feature is a load-time JammiError::Config, never a panic at session construction.

[broker.postgres]
# url = "postgres://user:pass@host:5432/jammi?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca-certificates.crt"
#                                                 # optional; defaults to
#                                                 # `catalog.postgres.url`
idle_poll_secs = 5

[broker.postgres] is a LISTEN/NOTIFY wake-up transport over the topic’s own mutable backing table — it carries no cargo feature, no bytes, and no separate log: url defaults to catalog.postgres.url and MUST name the SAME Postgres database on every replica (NOTIFY is scoped to one instance; a replica pointed elsewhere silently degrades to idle_poll-only delivery, never data loss). A SQLite catalog with no explicit url here is a load-time JammiError::Config naming both keys. idle_poll_secs (default 5, must be >= 1) bounds how long a lost NOTIFY can go undetected before the next poll wakes every topic. The broker itself opens up to three dedicated Postgres connections (one PgListener, up to two for NOTIFY) — never the catalog’s own pool — but every trigger-stream replay (a tail’s own driver-triggered replay, a lagging subscriber’s own catch-up, and a fresh subscriber’s subscribe-time drain) runs one STEP at a time, and each step borrows one CATALOG-pool connection only for its own duration: the permit is released between steps, so a long multi-step catch-up never monopolises a connection. Concurrent replay STEPS across the process are bounded at pool_size − 2 (minimum 1), one connection per step, leaving two connections for publishers; size catalog.postgres.pool_size for the number of (topic, tenant) tails you expect to be replaying at the same moment plus ordinary writers, independent of this broker’s fixed three-connection budget. See Catalog Backend and Trigger Broker for the full trade-off discussion, the health probe, and the SQLite single-process contract.

signing_key — where the audit HMAC master key comes from. Default: env (JAMMI_AUDIT_MASTER_KEY, a runtime knob outside this config layer’s own JAMMI_* namespace — see below).

signing_key = "env"
[signing_key.file]
path = "/run/secrets/jammi-audit-master-key"

The file form is read at each signing request (not at config load), so a rotated mount is picked up without a restart.

storage — the object-storage root for result tables, and the default cloud driver credentials for both the result root and any cloud data source whose registration carries no inline credentials. Default: unset (result tables live on local disk under artifact_dir; cloud sources fall back to the SDK’s own ambient credential chain).

[storage]
result_root = "s3://jammi-results/prod"

[storage.cloud.s3]
region = "us-east-1"

[storage.cloud] is itself externally tagged over s3 / r2 / gcs / azure; a bare storage.cloud = "s3" also selects a variant with its per-field defaults. See Store Sources and Results in Cloud Object Storage for every provider’s fields, the credential precedence, and the segment layout on disk.

models — the Hugging Face Hub cache root, endpoint, token, and offline switch. Default: every field unset (cache root falls back to a non-empty HF_HUB_CACHE — used directly as the cache root, nothing appended — then a non-empty HF_HOME, then the platform home directory; endpoint falls back to a non-empty HF_ENDPOINT, then the Hub’s own default; token falls back to a non-empty HF_TOKEN, then a non-empty HUGGING_FACE_HUB_TOKEN (huggingface_hub’s own live legacy alias), then the token FILE (HF_TOKEN_PATH, naming the file directly, else the <HF_HOME>/token file — resolved independently of whichever tier won the cache-root fallback, never derived from the cache root itself); offline falls back to a non-empty HF_HUB_OFFLINE, then TRANSFORMERS_OFFLINE when HF_HUB_OFFLINE is itself unset or present-but-empty, then false). A present-but-empty HF_* value — the shape a Compose/Kubernetes env block or a shell export FOO= produces — is treated identically to an unset one at every one of these tiers, never as a literal empty value; the value used is also always the TRIMMED string, never the raw one (a padded HF_HOME must not silently resolve to a current-working-directory-relative root). One exception is a genuine divergence from huggingface_hub, not merely a stricter reading of it: a whitespace-only HF_HUB_OFFLINE falls through to TRANSFORMERS_OFFLINE here, failing toward offline, where huggingface_hub itself stops at the whitespace-only value and resolves online.

[models]
hub_endpoint = "https://huggingface.co"
hub_cache_dir = "/var/cache/jammi"
hub_token = { file = "/run/secrets/hf-token" }
offline = false

offline = true refuses every Hub network fetch: a model loads only from a local: reference or an already-resolved catalog row (a warm, on-disk Hub cache with no catalog row is still a miss). It does not reach the fine-tune worker’s adapter fetch, which always reads from the artifact store, offline or not. HF_HUB_OFFLINE/TRANSFORMERS_OFFLINE are truthy for any of huggingface_hub’s own ENV_VARS_TRUE_VALUES"1", "on", "yes", "true", case-insensitively, surrounding whitespace trimmed. See Use a Local Model Checkpoint.

Environment variable overrides

Every field in the config tree is overridable — not a hand-enumerated subset — through one namespace rule, deep-merged over the file layer (an environment value wins field-by-field; see the layering order above and JammiConfig::parse_from/load_from).

Namespace. JAMMI_<X>__<path> (segments joined by __) is always config: an unknown X — one that does not name a top-level JammiConfig field (artifact_dir, engine, gpu, inference, embedding, fine_tuning, lease, worker, jobs, cache, server, logging, observability, catalog, broker, signing_key, storage, models) — is a load-time error naming the variable, never a silent no-op (JAMMI_CATALOG__KIND=postgres, a typo one segment short of JAMMI_CATALOG__POSTGRES__URL, refuses rather than quietly running SQLite with nothing to explain why). A bare JAMMI_<X> with no __ is config iff X exactly names one of those same top-level fields (JAMMI_ARTIFACT_DIR, JAMMI_CATALOG=sqlite, …); every other JAMMI_* name is a runtime knob outside this layer’s namespace and is silently ignored here — JAMMI_AUDIT_MASTER_KEY, JAMMI_CONFIG (which names the config file to load, not a field override), JAMMI_WORKER_ID (below), JAMMI_TEST_PG_URL, and similar single-purpose variables all pass through untouched.

JAMMI_WORKER_ID is a label. Every process mints its own instances.instance_id (a UUID) at session construction — that id is what jobs.claimed_by records and what the lease/liveness machinery keys on. JAMMI_WORKER_ID, when set and non-empty (trimmed), is only the instances.label shown beside that id by ListWorkers / jammi workers and in logs: an operator-chosen, non-unique name (a node, a replica slot). Two processes given the same label are two instances, so a replacement process never inherits — or keeps alive — a dead namesake’s claims.

Path segments and TOML syntax. Everything after the first segment is lowercased on the way in, matching every config struct’s snake_case field names — JAMMI_STORAGE__CLOUD__S3__REGION reaches storage.cloud.s3.region regardless of case. A leaf value is parsed as the field’s own type; a value naming a list or map field is parsed as TOMLJAMMI_SERVER__PRELOAD_MODELS='["a", "b"]', JAMMI_INFERENCE__HTTP__HEADERS='{ X-Api-Key = "v" }' — so a map value’s own keys keep the case written in the TOML (only the path segments that route to the map are lowercased). services is the one field with its own grammar instead of TOML syntax — see below.

Refusals name the variable. An unknown section, an unknown key, or a value outside a field’s domain is a load-time error naming the offending JAMMI_* variable — never a silent drop and never a fall-back to the file’s value or the default. JAMMI_WORKER__ENABLED (boolean) accepts true, false, 1, 0, case-insensitively and with surrounding whitespace trimmed; any other value — including an empty one — is refused by name: a yes/no question about what the process will do has no safe direction to guess in.

services. JAMMI_SERVER__SERVICES takes the same grammar the TOML field does: exactly all (case-sensitive — ALL is a one-token tier list, rejected as an unknown tier name) selects all-in-one; a comma-separated list (event,eval, empty tokens filtered, so "" means serve-only) selects exactly those tiers. See Service tiers.

Secrets. A Secret-typed field (catalog.postgres.url, broker.jet_stream.credentials, broker.postgres.url, inference.http.headers values, the cloud credential fields, models.hub_token) takes the value inline (JAMMI_CATALOG__POSTGRES__URL=…) or as a file reference via the __FILE suffix (JAMMI_CATALOG__POSTGRES__URL__FILE=/run/secrets/pg-url) — the TOML-side mirror of url = { file = "…" }. Both spellings at the same path is a collision error.

No ${VAR} interpolation in environment values. ${VAR} substitution (see the loading order above) runs once, over the TOML file text, before that layer is parsed — an environment variable’s own value is taken verbatim, never re-interpolated.

Resolution order (for the config file itself, not the override layer): an explicit path, JAMMI_CONFIG, ./jammi.toml, /etc/jammi/jammi.toml, then the platform per-user config directory. First existing path wins; when none exists the config is defaults-plus-environment-overrides only.

Catalog Backend and Trigger Broker

Jammi’s catalog (models, sources, eval runs, mutable companion tables) and trigger broker (provenance channels, evidence streams) are selected through two fields on JammiConfig: catalog and broker. The dev-laptop default is SQLite + an in-process broker; production deployments swap one or both for Postgres (catalog and/or broker) or NATS JetStream (broker only).

TOML schema

The catalog stanza is an externally tagged enum: the variant name is its own TOML table (or a bare string for a variant with no required fields):

[catalog.sqlite]
# path = "/var/lib/jammi/catalog.db"   # optional; defaults to {artifact_dir}/catalog.db
[catalog.postgres]
url = "postgres://user:pass@host:5432/jammi?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca-certificates.crt"
pool_size = 16
max_lifetime_secs = 1800

url should carry ?sslmode=verify-full for any connection that leaves a trusted network: sslmode=require upgrades the connection to TLS but never verifies the server’s certificate (it defeats a MITM only when the network path is already trusted), and sqlx verifies against the webpki root store rather than the OS trust store, so a private CA needs its own sslrootcert= path even on a host that already trusts it system-wide.

The engine hands this URL to the Postgres driver unchanged (backend_postgres.rs::open_with_options) — but “unchanged” only means the driver’s own URL parser sees every key you wrote. sqlx’s parser (PgConnectOptions::parse_from_url) starts from options already populated from the process environment (PGSSLMODE, PGSSLROOTCERT, and the rest of the PGSSL*/PGPASSWORD/… family) and then overrides only the keys the URL names. A URL that omits sslmode still inherits whatever PGSSLMODE is set in the environment — including a stray PGSSLMODE=disable left over from a shell profile or a shared base image. Naming sslmode=verify-full explicitly in the URL, as the example above does, is what makes the connection immune to that: an override the URL states always wins over the environment default, but a key the URL is silent on does not. The broker’s own Postgres pool (postgres.rs::connect, in src/trigger/) parses its url through the same PgConnectOptions path, so the same rule — name sslmode (and sslrootcert, for a private CA) in the URL, don’t rely on PGSSL* being unset — applies to [broker.postgres] url below too.

Where sslrootcert comes from for a managed provider. Each of three common managed Postgres offerings documents its own root differently:

  • Google Cloud SQL — in the console: Cloud SQL Instances → the instance’s Overview → ConnectionsSecurity tab. From the CLI, a per-instance CA: gcloud sql ssl server-ca-certs list --format="value(cert)" --instance=INSTANCE > server-ca.pem; an instance on the shared-CA model instead uses gcloud sql ssl server-certs list --format="value(ca_cert.cert)" --instance=INSTANCE > server-ca.pem. See Cloud SQL’s “Configure SSL/TLS certificates” docs for which model an instance uses.
  • Amazon RDS — one fixed global bundle covers every region and instance, at https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem; see the RDS User Guide’s “Using SSL/TLS to encrypt a connection to a DB instance” page.
  • Fly Postgres — the connection docs give only the private-network URL form (postgres://user:pass@host.flycast:5432/db) and publish no downloadable server CA. Confirm with the operator which product is in front — Fly’s Managed Postgres and an unmanaged Postgres app on Fly carry different TLS postures — before choosing verify-full for it; either way, the URL must still name sslmode explicitly (per the rule above), so a PGSSLMODE left set in the environment cannot downgrade a connection that is otherwise reached only over Fly’s private network.

Download the certificate once, mount it read-only into the container, and point sslrootcert= at that path.

The broker stanza follows the same shape:

broker = "in_memory"
[broker.jet_stream]
url = "nats://nats.svc:4222"
retention_seconds = 604800
credentials = { file = "/var/run/secrets/nats.creds" }

[broker.jet_stream] requires the jetstream-broker cargo feature on jammi-db; selecting it without the feature returns JammiError::Config rather than panicking at session construction time.

[broker.postgres]
# url = "postgres://user:pass@host:5432/jammi?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca-certificates.crt"
#                                                 # optional; defaults to
#                                                 # `catalog.postgres.url`
idle_poll_secs = 5

[broker.postgres] carries no cargo feature: sqlx’s postgres feature is unconditional in the workspace, so this variant always compiles in. It is a LISTEN/NOTIFY wake-up transport, not a second log — the topic’s own mutable backing table is the durable, authoritative log, and this driver only tells a subscriber “topic T may have advanced; go check”. Every replica MUST point url at the SAME Postgres database — NOTIFY is scoped to one instance, and a replica listening elsewhere is not detectable by config; it silently degrades to idle_poll-only delivery (never data loss, since the backing table is still replayed on the next tick). url defaults to catalog.postgres.url when unset and the catalog itself is Postgres; a SQLite catalog with no explicit url here is a load-time JammiError::Config naming both keys. idle_poll_secs (default 5) must be >= 1.

Environment variable interpolation

JammiConfig::load substitutes ${NAME} patterns from the process environment before TOML parsing (load_from/parse_from take the same lookup as an explicit map instead — see Configuration — so a test never touches real process env). The rules:

  • ${NAME} is replaced by the looked-up value of NAME (load: the process environment via std::env::var).
  • A missing variable is an error. The loader never silently substitutes an empty string — that is a common source of “deployed config has an empty Postgres URL” outages.
  • $$ escapes a literal $.
  • A bare $ not followed by $ or { is preserved verbatim, so passwords containing a single $ slip through unchanged.
  • An unterminated ${ returns JammiError::Config.
  • Interpolation is one-pass and not recursive: ${X}’s value is not re-scanned.

Combined with the externally tagged shape:

artifact_dir = "/var/lib/jammi"

[catalog.postgres]
url = "${POSTGRES_URL}?sslmode=verify-full&sslrootcert=/etc/ssl/certs/ca-certificates.crt"
pool_size = 16
max_lifetime_secs = 1800

[broker.jet_stream]
url = "nats://${NATS_HOST}:4222"
retention_seconds = 604800
credentials = { file = "/var/run/secrets/nats.creds" }

A working copy of this file ships at crates/jammi-db/examples/sample-postgres.toml.

SQLite vs Postgres trade-offs

ConcernSQLitePostgres
Operational footprintOne file under artifact_dir. No daemon.Externally-managed Postgres cluster.
Concurrent writersOne; WAL mode lets many readers run alongside one writer.Many.
Multi-process deploymentSingle-process only, and enforced on unix: the catalog opens through SQLite’s unix-excl VFS, which holds a process-scoped exclusive lock on the file. A second process opening the same artifact_dir is refused with a typed backend unavailable error naming this contract after the 5 s busy timeout — it never corrupts the WAL and never hangs. Handing the directory to another process is an awaited event (Catalog::close().await / JammiSession::close().await), not a drop.Multi-replica safe — see Multi-writer safety below.
Failure recoveryFile restore from backup.Standard Postgres point-in-time-recovery.
Pool tuningNone — opens one pool of 8 connections.pool_size + max_lifetime_secs honour sqlx::PgPool knobs.

The single-process guarantee stops at the process boundary. A second SQLite library instance inside the same process — the shape a Python caller reaches by using the stdlib sqlite3 module against a live engine’s catalog — shares this process’s fcntl locks and so cannot be arbitrated: it can read a stale image or corrupt the file. Close the engine first (close()), then touch the file. On Windows the contract is documentation-only: unix-excl does not exist there.

One operator knob exists and is diagnostic only: JAMMI_SQLITE_VFS=default restores the platform default VFS on every target, re-arming exactly the corruption the seam removes, so that the fix can be falsified by re-running the escape’s oracle against it and observing the RED; engaging it logs a WARN, and it is never set in production.

For laptop / single-tenant deployments, SQLite is the right answer; the trade-off table tilts to Postgres the moment a second jammi-server replica enters the picture.

Multi-writer safety

Postgres’s multi-replica safety rests on three mechanisms, all under one lease primitive (crates/jammi-db/src/catalog/lease.rs, [lease] in Configuration):

Lease-owned building tables. A result table under construction is not a bare row a crash can leave ambiguous — ResultStore::create_table stamps it with writer_id = "writer-{uuid}" (one per ResultStore instance, so two sessions in one process are distinct writers) and a lease_expires_at deadline, then returns a BuildingTable handle whose background heartbeat renews that lease every heartbeat seconds. Every transition on the row — checkpoint, segment insert, promote to ready, fail — is a compare-and-set naming (table_name, writer_id, status = 'building'), so a stalled or crashed writer can never be mistaken for a live one: its lease simply expires, and only THEN is the row reclaimable.

Lease time is the catalog database’s clock — replica clock skew does not matter. On Postgres every lease stamp and comparison is rendered as SQL that reads and writes now(): a renew sets lease_expires_at = (now() + make_interval(secs => $n))::text ($n binds the lease WINDOW, never a precomputed deadline), and every expiry check compares lease_expires_at::timestamptz < now(). No replica ever binds its own wall-clock reading into a lease predicate, so two replicas whose system clocks disagree — even by minutes — can never reap each other’s live writer or extend a lease past what the DATABASE considers “now” plus the configured window. (SQLite is a single embedded process — there is no peer replica to skew against — so it keeps the simpler application-clock stamp, through the one shared helper in catalog::lease.)

The advisory lock around migrations. On Postgres, catalog::migrations::run takes a transaction-scoped advisory lock (SELECT pg_advisory_xact_lock($1), keyed by JAMMI_MIGRATION_LOCK_KEY) as the very first statement, before it reads the applied_migrations ledger or runs any (non-idempotent) schema DDL. Two replicas booting against one fresh database serialise on this lock instead of racing the ledger read against each other’s DDL; the lock is released on commit or rollback, so it stays correct under PgBouncer transaction pooling. SQLite needs no equivalent: its BEGIN IMMEDIATE write transaction already serialises the one process that may hold the file.

What recovery does at session construction. Every session construction runs ResultStore::recover() under an admin scope — the one place a session bypasses its own tenant binding — because a dead writer’s orphaned row can belong to any tenant, not only the one this session is bound to. Recovery enumerates ONLY building rows whose lease is absent or expired (lease_expired_clause); a row under a live lease belongs to a writer that is still working and is left completely alone, wherever it runs. For each expired-lease row, recovery deletes bytes only after a one-row compare-and-set names it the new owner (claiming a promotable row before it rebuilds the ANN sidecar and promotes it, or flipping a reapable row to failed before it deletes) — never a bare “looks abandoned” heuristic, and never before that CAS. This sweep runs across every tenant, even from a tenant-bound embedded session, and each reconciled row keeps its own tenant_id.

jammi reconcile. The lease sweep above only ever looks at rows still carrying a live catalog entry; it says nothing about an object that was written but never got a row (or a row’s objects that outlived the row). The jammi reconcile [--apply] [--grace-secs N] [--all] CLI (backed by ResultStore::reconcile/reconcile_all) cross-checks the catalog against a live object-store listing in both directions — see Backup and Restore for when to run it, and the maintainer guide (docs/maintainer/MAINTAINER-GUIDE.md) for the allowlist and deletion-arm detail.

In-memory vs Postgres vs JetStream broker

ConcernInMemoryPostgresJetStream
PersistenceIn-process only; lost on restart.None of its own — the topic’s mutable backing table (already durable) is the log; the driver carries no bytes.NATS server retains streams per retention_seconds.
Cross-process deliveryNone — a publish in process A is invisible to a subscriber in process B.All subscribers (any process, any host) see a wake within idle_poll_secs of a publish; the actual rows come from a replay of the shared backing table.All subscribers (any process, any host) see every published batch within the retention window.
AuthNone.Whatever [broker.postgres] url (or the catalog’s) already authenticates with.Anonymous or NATS .creds file contents via credentials.
Operational footprintNone.None beyond the catalog — up to three extra connections to the SAME Postgres instance the catalog already uses (or points at); no extra service.One NATS server (or cluster).

In-memory is fine for tests, local development, and single-process server deployments where every consumer lives in the same jammi-server process. For any deployment that wants replay across restarts or fan-out across multiple jammi-server replicas (Shapes B and C), Postgres is the recommended broker whenever the catalog is already Postgres: it adds no extra service, only a handful of connections to the database already in the topology. Reach for JetStream when the deployment wants a dedicated, broker-scoped retention window independent of the catalog’s own lifecycle, or when the catalog itself stays SQLite (single-process) while the broker still needs to fan out beyond one process — a shape Postgres-as-broker cannot serve without a Postgres catalog to default its url from.

Health probe

CatalogBackend::ping runs SELECT 1 against the underlying pool and classifies pool failures as BackendError::Unavailable. The /readyz endpoint on jammi-server (when wired) reaches this via session.catalog().ping().await. The primitive is cheap — microseconds against a warm pool — and never opens a transaction.

Format Stability

Jammi persists several on-disk formats. Each one is stamped with a version the writer records and the reader checks, so a file written by a newer build — or by a backend whose serialized layout changed — is rejected as a typed error rather than silently misparsed into wrong data. This page is the operator’s reference for every persisted format the engine owns, what its stability stamp is, and how a reader reacts to a stamp it cannot honour.

The single principle: a reader never guesses. When a stamp is unreadable the load fails loud with a typed error; the upgrade path is to re-emit the artifact from its definition. There is no back-compat reader, no silent downgrade, no default-to-version-1. (Re-emitting is cheap and exact on the producing host: a result table is the deterministic output of its producing definition over its pinned input anchors — see The Materialization Contract. Across hosts a CPU-produced result table’s identity is the catalog row plus its definition_hash, not its bytes: a float reduction can move by an ULP between hosts of different CPU microarchitecture even though the definition and inputs are pinned identically.)

The per-format table

FormatOn-disk fileStability stampReject semantics on load
Materialization manifest.materialization.jsonmanifest_version (u32)Exact-versionfound != MANIFEST_VERSION, older or newer, → ManifestError::UnsupportedManifestVersion (an older version names a superseded determinant set, so it is never read as a hit). One older SHAPE is named on its own: an object at the current version with no leaves inventory (written before the inventory existed) is ManifestError::PreLeavesSidecar, which ResultStore::read_materialization_manifest reads as absent (re-materialise) — never a hit, and never how a newer version or a corrupt body is read
ANN row map.rowmapleading u32 version headerReject-newerfound > ROWMAP_VERSIONJammiError::IncompatibleFormat { artifact: "rowmap", .. }
ANN sidecar manifest.manifest.jsonversion (u32)Reject-newerfound > ANN_MANIFEST_VERSIONJammiError::IncompatibleFormat { artifact: "ann-manifest", .. }
ANN binary threshold companion.thresholdnone embedded — required whenever the sidecar manifest’s scalar_kind is Binary, confirmed by the manifest’s binary_threshold_kind fieldFail-loud, not versioned — a missing binary_threshold_kind, a missing file, or a byte length not matching dimensions f32s → JammiError::Other
USearch ANN graph.usearchbackend_version stamped in the sidecar .manifest.jsonStrict — any mismatch with the linked USearch → JammiError::IncompatibleFormat { artifact: "usearch-index", .. }
Lexical (BM25) indextantivy index dirtantivy’s own format tagLibrary-loud — tantivy’s Index::open fails with IncompatibleIndex, surfaced as JammiError::Lexical
Result-table data.parquetnone embedded — its format-of-record version is the .materialization.json manifest_versionSchema-shape checked at read via JammiError::Schema; byte integrity caught by verify_materialization
Version manifest (a refreshed embedding table){table}__v{N}.version.jsonversion_format (u32)Reject-newerfound > VERSION_FORMATJammiError::IncompatibleFormat { artifact: "version-manifest", .. }
Deletion mask{table}__v{N}.deletes.parquetnone embedded — its format-of-record version is the version manifest’s version_formatSchema-shape checked at read (_row_id Utf8 NOT NULL, _dead_through_version Int64 NOT NULL) via JammiError::Schema; byte integrity caught by verify_materialization (its digest folds into the version identity)
Version fragment{table}__v{N}.parquetnone embedded — same rule as the base ParquetPinned to the base fragment’s schema at bind; a divergent file is a typed JammiError::Schema; digest verified by verify_materialization

Two distinct kinds of stamp appear above, and the difference is deliberate:

  • Reject-newer for formats that carry a compatibility ordering: only a newer version carries a layout this build does not know, so only a newer version is refused on the stamp. The .rowmap and ANN .manifest.json are this kind (found > ROWMAP_VERSION, found > ANN_MANIFEST_VERSION).
  • Exact-version for the materialization manifest (MaterializationManifest::from_json_bytes), whose version names a determinant set rather than a layout: an older version is a superseded determinant set, so a body that still decodes is refused on any inequality (found != MANIFEST_VERSION), never trusted as a hit. The decode runs before the stamp is inspected, so a body that lacks a field the current shape requires — a producer variant that grew a required determinant at the same version, as the fine-tune descriptor’s topology fields did — is the typed ManifestError::Serde; the one older shape the reader names on its own (an object at the current version with no leaves) is read as absent.
  • Strict for the USearch backend_version, because the USearch serialized graph format carries no compatibility ordering between releases. A version that differs at all may mis-deserialise the graph and return wrong neighbours, so any inequality is incompatible — there is no “older is fine” here.

Materialization manifest — exact-version

.materialization.json carries manifest_version (MANIFEST_VERSION). The reader rejects any other version, older or newer, as the typed ManifestError::UnsupportedManifestVersion { found, supported } — the version names the determinant set the hash was computed over, and a superseded set is never a hit. The version is checked after the decode and before the parsed manifest is handed back. Every other stamped format is modeled on this fail-loud shape (the ordered ones relax it to reject-newer); the full contract is in The Materialization Contract. Its error lives in its own domain (ManifestError) and is intentionally not folded into the shared IncompatibleFormat variant — it carries the manifest-specific recovery semantics the contract describes.

ANN row map (.rowmap) — reject-newer

The .rowmap is the engine-owned mapping from a USearch internal id to the Jammi _row_id string. It is a small binary file: a leading u32 version header, then length-prefixed UTF-8 entries. On load the reader checks the header and rejects a version greater than ROWMAP_VERSION as JammiError::IncompatibleFormat { artifact: "rowmap", found, supported }.

ANN sidecar manifest (.manifest.json) — reject-newer + strict backend

The ANN sidecar’s .manifest.json records the index metadata: version, dimensions, backend, backend_version, scalar_kind, count, the file names, and the creation instant. On load it is deserialised as a typed struct (mirroring MaterializationManifest::from_json_bytes), never by field-by-key serde_json::Value lookups. The determinants of a safe load — version, dimensions, backend_version, and scalar_kind — are all required: a manifest missing any of them is a hard decode error, never silently defaulted to a guess. A fifth field, binary_threshold_kind, is conditionally required: legitimately absent for every non-Binary scalar_kind, but its absence on a Binary manifest with at least one row is itself a hard error — a torn or pre-threshold-fix bundle, not a legitimate empty state.

Two checks run on the deserialised manifest:

  1. version, reject-newer. A version greater than ANN_MANIFEST_VERSION is JammiError::IncompatibleFormat { artifact: "ann-manifest", .. }.
  2. backend_version, strict. The stamped USearch version is compared for exact equality against the linked jammi_db::index::backend_version(). Any mismatch is JammiError::IncompatibleFormat { artifact: "usearch-index", .. }.

A Binary scalar_kind additionally loads the .threshold companion beside the bundle — the per-dimension threshold τ the sidecar’s sign-packing was fit against — validated against dimensions and the manifest’s binary_threshold_kind rather than a stamp of its own (see the per-format table above).

USearch ANN graph (.usearch) — strict backend version

The .usearch file is USearch’s own serialized HNSW graph. Its serialized header carries only the major version and gives no cross-release compatibility guarantee, so a USearch upgrade can change the on-disk layout in a way that deserialises into a structurally-valid-but-wrong graph — returning incorrect nearest neighbours with no error. To close that silent-corruption path, the engine stamps the full linked USearch version (backend_version) into the sidecar .manifest.json at save and strict-compares it on load. The graph itself is never trusted across a backend version change; the only safe action is to re-emit the embedding table (which rebuilds the sidecar).

Lexical (BM25) index — library-loud

The lexical retrieval sidecar is a tantivy index directory. Tantivy stamps its own format version and refuses to open an index written by an incompatible release: Index::open returns IncompatibleIndex, which the engine surfaces as JammiError::Lexical. The engine adds no stamp of its own here — the library is already loud, so a second stamp would be redundant machinery. The recovery is the same: re-emit (re-index) the table.

Result-table Parquet — no embedded stamp, by design

The result-table Parquet object carries no embedded format version. Its format-of-record version is the manifest_version of the .materialization.json sidecar written beside it: the manifest is the artifact’s identity, and the Parquet bytes are its subject. A reader does not need a second, in-band version because:

  • Shape safety is enforced at read time by the typed Arrow downcast. Every vector read goes through store::vectors::extend_with_fixed_size_list_f32, the single place in the engine that downcasts a vector column to FixedSizeList<Float32>; a missing column, a wrong Arrow type, or a non-Float32 inner type is a typed JammiError::Schema, not a panic. Schema shape is checked from the data itself, so it needs no stamp.
  • Byte integrity is content-addressed. The Parquet object is immutable and identified by its ArtifactDigest (SHA-256 over the bytes) recorded in the manifest, so any out-of-band byte change is caught by verify_materialization recomputing the digest — see The Materialization Contract.

The manifest-bypass Parquet read paths

Three engine read paths open a result-table Parquet object directly, without first reading the .materialization.json manifest:

  • Session::read_vectors — streams the whole vector column of an embedding table into one Vec<f32> per row.
  • Session::read_vector_by_key — extracts a single row’s vector by its _row_id (the resolver behind search_by_id’s query-by-example path).
  • store::register_parquet_table — registers a Parquet URL as a DataFusion table under jammi.{name} for SQL scans.

These paths do not consult a format stamp, and that is correct: their safety rests entirely on the typed JammiError::Schema downcast in store::vectors::extend_with_fixed_size_list_f32, which validates the on-disk Arrow schema shape directly from the data. A Parquet object whose schema does not match what the read expects produces a typed Schema error, regardless of how it was written. Out-of-band byte tampering on these immutable, content-addressed objects is the verify_materialization digest check’s concern, not a per-read stamp’s.

Upgrade path: re-emit

For every stamped format above, the recovery from an incompatible stamp is the same — re-emit the artifact from its definition. The engine ships no back-compat reader and no in-place migrator: an ANN sidecar is rebuilt by re-running the embedding producer, a tantivy index by re-indexing, a result table by re-running its producing definition over its input anchors. A result table’s identity after re-emission is its catalog row and definition_hash, which are exact and not lossy; on the producing host the bytes are exact too, but a re-emission on a different CPU host is not asserted byte-identical to the original (see The Materialization Contract). The typed rejection is the signal to re-emit; it is never something to paper over with a default.

API Stability

Jammi exposes a deliberate, frozen public surface. This page is the operator’s reference for what is stable, what semver promise covers it, and how the freeze is enforced — not as prose anyone can let drift, but as a CI guard that reds the moment a stable surface changes shape.

The single principle: a stable surface does not change under you without a major. A verb is not renamed, an rpc is not dropped, a wire package is not removed, and a persisted-format version is not reinterpreted, across any release that does not bump the major version. The surfaces below are the ones that promise holds for; everything not listed here is internal and may move.

The frozen stable surfaces

Three surfaces are frozen. Each is machine-checked against a committed baseline, so the freeze is enforceable rather than aspirational (see Enforcement).

1. The verb set — the call surface

The public verb vocabulary a caller invokes — identical name-for-name and signature-for-signature across the embedded (jammi.EmbeddedBackend) and remote (jammi.RemoteDatabase) transports. It is pinned, set-by-set, in crates/jammi-python/tests/test_conformance.py; those sets are the frozen verb list:

Verb set (conformance constant)Verbs
_REMOTE_VERBSadd_source, generate_embeddings, encode_query, search, sql, list_sources, describe_source, set_tenant, tenant_scope, tenant, get_server_info
_TRAINING_VERBSfine_tune, fine_tune_graph, train_context_predictor, predict_with_context_predictor, training_job, list_training_jobs
_INFERENCE_VERBSinfer
_PIPELINE_VERBSbuild_neighbor_graph, propagate_embeddings, asof_join, assemble_context, recompute, verify_materialization, staleness, derives_from
_EVAL_VERBSeval_embeddings, eval_per_query, eval_inference, eval_compare, eval_calibration
_CHANNEL_VERBSregister_channel, add_channel_columns, list_channels
_NUMERIC_VERBSconformalize, conformalize_interval, conformalize_cqr, rrf_fuse
_MUTABLE_TOPIC_VERBScreate_mutable_table, drop_mutable_table, list_mutable_tables, register_topic, drop_topic, list_topics, publish_topic, subscribe_collect
_LIFECYCLE_VERBSlist_models, describe_model, delete_model
_SEGMENT_VERBSlist_index_segments
_SEARCH_VERBSsearch (pinned separately for the embedding_table= selector)

The conformance suite is the enforced annotation: removing or renaming a verb, or changing its signature on either transport, reds the suite. Jammi does not carry a per-pub-item #[stable] rustdoc attribute — Rust has no such attribute, and history-bearing version markers in rustdoc are explicitly disallowed — so the conformance sets carry the freeze that a #[stable] pass would carry elsewhere.

2. The wire contract — package jammi.v1.*

The gRPC/Flight SQL wire surface is the thirteen jammi.v1.* proto packages (ten served on the public listener; two, jammi.v1.peer and jammi.v1.gang, served only on the internal [server] peer_bind listener — the public listener answers UNIMPLEMENTED for their rpcs; jammi.v1.lifecycle is a contract-only surface — defined in the wire descriptor so the candle-free client can call a platform server that implements it, but answered by no OSS handler):

PackageSurface
jammi.v1.auditprovenance / audit log rpcs
jammi.v1.catalogsources, models, channels, tenant, server-info, mutable tables, topics
jammi.v1.embeddingembedding generation, query encode, search
jammi.v1.errorthe typed wire-error message (no rpcs)
jammi.v1.evalthe evaluation rpcs
jammi.v1.gangthe coordinator-to-member gang-admission seam for a multi-host training run (GangService.RunRank) — served only on peer_bind, never on the public listener; the caller’s tenant is never read — the member derives it from the verified jobs row and resolves a multi-host job’s training set under it alone (see Security Posture)
jammi.v1.inferencebulk inference + predict
jammi.v1.jobthe durable job queue: submit / status / wait / list / cancel / list-workers / prune (JobService)
jammi.v1.lifecyclelicense apply / bootstrap / status / login — contract-only, answered by a platform server (the OSS engine returns UNIMPLEMENTED)
jammi.v1.peerthe engine-internal segment-search seam between replicas (PeerService.SegmentSearch / ExactRescore) — served only on peer_bind, never on the public listener; deliberately tenant-free (the coordinator enforces tenant scope; see Security Posture)
jammi.v1.pipelinegraph / context / as-of / recompute / materialization rpcs
jammi.v1.trainingthe training spec message vocabulary JobService.SubmitJob’s oneof carries (FineTuneSpec/GraphFineTuneSpec/ContextPredictorSpec/FineTuneConfig/…) — no rpcs of its own since TrainingService folded into JobService
jammi.v1.triggertopic publish + subscribe

The contract is the full set of (Service, Method) rpc paths these packages define — decoded from the compiled FILE_DESCRIPTOR_SET, the authoritative machine-readable description of the frozen wire surface, which may exceed what a given build mounts (jammi.v1.lifecycle is defined here yet served by no OSS handler), not a hand-maintained list. The v1 in the package path is the wire-stability stamp: a breaking change to a message or an rpc shape requires a jammi.v2.* package, not an in-place edit of v1.

3. The persisted-format versions

The on-disk format-of-record versions, each a writer-stamped, reader-checked version with reject-newer (or strict) semantics — the full contract is on the Format Stability page:

FormatStampCurrent version
Materialization manifest (.materialization.json)MANIFEST_VERSION3
ANN row map (.rowmap)ROWMAP_VERSION1
ANN sidecar manifest (.manifest.json)ANN_MANIFEST_VERSION3
Catalog schemaappend-only migration ledgersee crates/jammi-db/src/catalog/migrations.rs

The catalog migration ledger is append-only: a migration is never edited or removed once shipped, only a new numbered migration is appended. The other three stamps follow the reject-newer idiom — a newer stamp than this build knows is a typed rejection, never a silent misparse.

The semver commitment

This release is the terminal 0.x engineering bar: the three surfaces above are frozen, and a breaking change to any of them — a renamed/removed verb, a dropped rpc, a removed jammi.v1.* package, an incompatible reinterpretation of a persisted-format version — does not ship without a major version bump. New additive surface (a new verb, a new rpc, a new appended migration) may land in a minor; it does not break a caller written against the frozen set, because it only grows the surface.

Concretely:

  • A new verb is added to a conformance set in the same PR that adds the verb, on both transports — additive, minor-compatible.
  • A removed or renamed verb is a breaking change — major only.
  • A new rpc is a new (Service, Method) path appended to the wire baseline — additive. A removed/renamed rpc, or a removed jammi.v1.* package, is breaking — major only, or a jammi.v2.* package for a message-shape break.
  • A persisted-format version is bumped only when the layout changes; the reject-newer guard then makes an old reader fail loud rather than misparse, and the recovery is to re-emit (see Format Stability).

Pre-1.0 amendment (#485). Until the 1.0 release, the jammi.v1 wire contract may still change — including a genuinely breaking rpc rename or removal — when BOTH of the following hold in the same PR: the frozen baseline (crates/jammi-server/tests/it/api_freeze_baseline.txt) is updated to match the new live surface, and the CHANGELOG carries an explicit Breaking entry describing the change and its migration. The freeze-guard test still enforces that the live surface and the committed baseline agree exactly — the amendment relaxes only WHICH edits to the baseline are allowed pre-1.0 (an announced breaking edit, not a silent one), never the mechanism that catches an unannounced divergence. The following release then bumps the minor version rather than the major — the terminal-0.x window is itself the “stabilizing” period the eventual 1.0 major bump closes. TrainingService folding into JobService (StartTrainingSubmitJob, TrainingStatusJobStatus, ListTrainingJobsListJobs, plus new WaitJob/CancelJob/ListWorkers/PruneJobs) is the first change to ship under this amendment.

Experimental surfaces

There are none. Every public verb in the conformance sets, every jammi.v1.* rpc, and every persisted-format version above is frozen — none is marked provisional or experimental, and none ships behind an “unstable” flag. A surface that is not yet ready to freeze does not appear on the public client at all; it stays internal until it is ready to enter the frozen set. The freeze is total across the published surface, which is what the terminal-0.x bar requires.

Published-crate Rust APIs

Twelve workspace crates lack publish = false and are therefore published Rust crates: jammi-admin, jammi-ai, jammi-ballista, jammi-cli, jammi-client, jammi-db, jammi-encoders, jammi-kernels, jammi-lora, jammi-numerics, jammi-server, and jammi-wire. Their public items (types, functions, trait signatures, struct field visibility) are a real compile-time surface for any consumer outside this workspace, distinct from the three CI-enforced surfaces above. This surface carries no CI freeze guard — there is no descriptor to decode or conformance set to pin a bare Rust signature against — so a breaking change here is caught only by review, and is recorded as a BREAKING entry in the CHANGELOG the same way every other breaking change in this workspace is, naming the item, what changed, and what the caller does instead.

Deriving this section. Do not hand-maintain this list from memory — a prior round of this same section missed at least seven breaking changes, including a pub fn removed with no entry anywhere, and its own scope sentence named 2 of these 11 crates. Enumerate every public item whose signature, visibility, or existence changed across the range instead. The range is a set of commits, not a contiguous <base>..<head> span: this unit’s commits are interleaved on the branch with other units’ commits (a plain git diff <base> <head> between the oldest and newest of this unit’s own commits also picks up whatever any OTHER unit changed in between — that is how a sibling unit’s own unannounced removal was mistaken for this section’s gap in a prior round). Derive the exact commit set from the commit-message tag every round of this unit’s own history carries, and diff the UNION of those commits, never a span:

# Every commit belonging to THIS unit, oldest first. NOT a literal
# '#482 DIST' substring match: this unit's own history also carries the tag
# as 'DIST-1' and 'DELTA/DIST' (a hyphen or a slash immediately after
# 'DIST', never a space) — a plain `--grep='#482 DIST'` silently drops those
# and under-ranges the set (measured, DIST round 8: it returns 3 commits and
# misses 3 more, including the one that introduced the sites a round-8 fix
# corrected). `-E --grep='#482.*DIST'` matches all three spellings because
# it does not require a space between the issue number and the tag:
commits=$(git log --oneline --reverse -E --grep='#482.*DIST' | cut -d' ' -f1)
for c in $commits; do
  git diff --name-only "$c"^.."$c" | grep '^crates/.*/src/.*\.rs$'
  # for each changed file, diff its `pub fn|struct|enum|trait|type|const|
  # static|use` items between "$c"^ and "$c" — a struct/enum/trait's full
  # brace-balanced body, a fn/const/static/type/use's signature up to its
  # body or `;` — and treat a normalized-text change as added-old +
  # added-new (catches a removal with no replacement, not just a same-line
  # diff hunk).
done
# Cross-check the crate list above against every `crates/*/Cargo.toml`
# lacking `publish = false`.

Nine commits are current state as of this release (the range this section has covered started as three, was five, was six, was seven, was eight as of DELTA round 6’s own fold, and is nine as of DIST round 7’s own fold — state the true count rather than repeating a stale one). The corrected recipe above resolves to SIX commits (3a696a65, cbd427b4, 320b73ee, f37cb743, b1665c14, a2dfcb1f) — three more than the three the old, narrower grep found — but the three added (320b73ee’s re-apply, cbd427b4’s formatting-only fmt, 3a696a65’s masked-load reuse with no new pub item) introduce no public-surface change beyond what the bullets below already list; checked by diffing each for an added/removed/changed pub item against the crate list above, not assumed.

  • The vector-search API takes a validated query type, not a bare slice. jammi_numerics::query::ValidatedQuery is the only type jammi_numerics::distance::{cosine_distance, cosine_similarity}, jammi_db::index::VectorIndex::search, jammi_db::index::segment::{search_unit, rescore}, jammi_db::index::segment::SegmentedIndex::{search, search_final}, jammi_db::index::exact::exact_vector_search, jammi_db::index::placed::PlacedIndex::search_final_placed, jammi_db::store::ResultStore::{search_vectors, search_vectors_local}, jammi_ai::operator::ann_search_exec::AnnSearchExec::new (and its query_vector field), and jammi_ai::pipeline::neighbor_graph::Node’s vector field accept for a query vector. jammi_db::index::segment::verify_query_width (a free pub fn) is removed with no replacement — its check is now ValidatedQuery::require_width/require_authority_width, methods on the type itself, not a function a caller could import. Construct a ValidatedQuery with jammi_db::index::validate_query(values, expected_width, source) (re-exported from jammi_numerics::query, along with the new jammi_numerics::query::QueryValidationError error type), where source is a jammi_db::index::QuerySource::{Caller, Stored { table }}. Its inherent methods are as_slice, into_inner, source, and the two width checks below. exact_vector_search also gained a catalog_dimensions: Option<usize> parameter (a cross-check against the scan’s own width; None when there is none on record). jammi_db::index::peer::{SegmentSearchRequest, ExactRescoreRequest}’s query field is a ValidatedQuery, not a Vec<f32>; PeerFailureReason gained the CallerFault variant, PeerError gained a message: String field, and PEER_FAILURE_LABELS is [&str; 10].
  • jammi_db::catalog::result_repo::ResultTableRecord::dimensions is a method, not a field. It returns Option<std::num::NonZeroUsize> — a non-positive stored value and an absent one are both None. dimensions_raw() -> Option<i32> returns the stored column verbatim, for a caller that must round-trip it unfiltered. A caller that built a ResultTableRecord field-by-field from outside this crate now goes through ResultTableRecord::from_wire_projection, the crate’s sole cross-crate constructor.
  • jammi_wire::peer::{phase_from_proto, precision_from_proto} return a ProtoEnumDecode<T>, not an Option<T>. The wire’s explicit “not set” (ProtoEnumDecode::Unspecified) and a raw value this build’s generated enum has no variant for (ProtoEnumDecode::Unknown) are no longer collapsed into one None — a caller that only needs “did this decode” calls the new .known() -> Option<T> to get the old behaviour back.
  • Whose-fault a downstream width check assigns no longer depends on the query’s own provenance. ValidatedQuery::require_width now takes an artifact: impl Into<String> and returns a new error variant, QueryValidationError::ArtifactMismatch { artifact, expected, actual }, which carries no QuerySource at all — a mismatch it finds is always attributed to the named artifact, never the caller, because by the time a query reaches any consumer an entry has already checked it once against an authority it had in hand. THREE call sites still attribute by the query’s own provenance (the placement entry’s all-remote shape, the placement entry’s all-local shape against the set’s own first segment, and exact_vector_search’s no-catalog-width fallback — round 8 closed the all-local gap, where the same deferral the other two already performed had never run), each checking a query with no width in hand against the only authority available to that call; all three use the new ValidatedQuery::require_authority_width instead, which keeps the OLD require_width behaviour under a name that says why it is different. QuerySource::source() on QueryValidationError now returns Option<&QuerySource> (None for ArtifactMismatch) rather than &QuerySource unconditionally. (DIST round 6/7 shipped a third QuerySource variant, Artifact { name }, to carry this same information; round 8 removed it in favor of the dedicated ArtifactMismatch error variant above, so QuerySource is back to exactly two variants, Caller and Stored — the states a query’s own provenance can actually be. A round-6/7 caller matching on three QuerySource variants needs the arm removed, not added.) JammiError::Schema constructions in jammi_db::index::exact::exact_vector_search, jammi_db::index::placed::PlacedIndex::search_mixed, jammi_db::store::vectors::{extend_with_fixed_size_list_f32, extend_with_keyed_fixed_size_list_f32}, and jammi_db::store::deletes:: DeletionMask::read that priced an ENGINE-owned artifact’s own corruption as the caller’s fault are now JammiError::IncompatibleFormat; the two store::vectors functions changed their error type to the new, provenance-neutral jammi_db::store::vectors::VectorColumnError (? converts it to IncompatibleFormat by default; .into_caller_fault() is the explicit override the one caller-supplied read path, behind import_embeddings, now uses).
  • jammi_db::store::ResultStore::result_digest_anchor is removed with no replacement. It resolved a result table’s current version and then discarded the resolution, returning a bare InputAnchor a caller could not get the matching content back from without a second, independent resolve — a version publish landing between the two could straddle. Call ResultStore::pin_current_version(record).await?.input_anchor() instead; the versioned arm already delegated to exactly that internally, so the returned value is unchanged. (This item predates the compute-tier substrate epic — it shipped in an earlier release — so its removal here is a genuine breaking change to an already-released surface, not internal churn within this epic’s own unreleased history; the two DELTA-round functions that went pub → private/pub(crate) entirely within this epic’s own unreleased commits, current_version_provider and current_version_identity, never shipped as pub in any release and so carry no such entry.)

Enforcement: the freeze-guard

The freeze is a CI guard, not a promise in prose. Two checks run on every PR:

  • The wire contract + manifest version are pinned in a Rust integration test (crates/jammi-server/tests/it, the api_freeze module). It decodes FILE_DESCRIPTOR_SET into the live (Service, Method) rpc set and the live jammi.v1.* package set, and asserts they equal a committed frozen baseline; it also asserts MANIFEST_VERSION equals its frozen value. The test derives the live surface from the compiled descriptor — the same source the server actually serves — so a divergence between the served surface and the baseline cannot hide.
  • The verb set is pinned in the conformance suite (crates/jammi-python/tests/test_conformance.py), which asserts every verb in every set is callable with an identical signature across both transports.

Removing or renaming a stable rpc reds the Rust guard: the live (Service, Method) set decoded from the descriptor no longer equals the committed baseline, and the assertion fails naming the rpc that disappeared (or the one that appeared without a baseline update). Removing or renaming a verb reds the conformance suite the same way. The freeze has teeth because the baseline is a committed artifact a change must explicitly and visibly edit — and editing it to drop a stable surface is exactly the breaking change the semver commitment forbids outside a major.

Security Posture

Jammi is an engine on a trusted network, not a security boundary. This page is the published threat model: precisely what the engine defends, what it explicitly does not, the trusted-network assumption every deployment inherits, and the consumer’s responsibilities for the boundary the engine deliberately does not own. Every “defends” line below traces to a real test or code path; every “does not” line is the honest absence of a guarantee the engine never claims.

The single principle, stated once and not softened: Jammi authenticates nothing. Identity, authorization, and the network perimeter are a consumer’s vocabulary; the engine ships the seam a consumer plugs them into, never the policy. A deployment that exposes the engine’s port to an untrusted caller has removed the boundary the engine assumes is there.

What the engine defends

Each line names the mechanism and the test or code path that proves it.

DefenceMechanismTraces to
Format-version reject-newerA persisted artifact stamped with a version newer than this build knows is a typed rejection, never a silent misparse into wrong datamanifest.rs (UnsupportedManifestVersion, the read-path guard) + test newer_manifest_version_is_rejected; sidecar.rs (IncompatibleFormat) + test newer_rowmap_version_is_rejected
Tenant-scope filtering on every catalog queryThe read-side analyzer injects tenant_id = $current OR tenant_id IS NULL on every scan; every register_* and the mutable-table sink calls assert_tenant_matches before INSERT; the backend SQL layer also carries the predicate. A Jammi-owned result table carries no tenant_id column (it is wholly owned by one tenant, or GLOBAL), so a tenant-gating result-table schema provider gates resolution on the catalog owner instead — over every lane (Flight db.sql, gRPC sql, search) a correctly-bound tenant resolves only its own and GLOBAL result tables; a peer’s private table resolves not-foundtenant_scope.rs (TenantScopeAnalyzerRule) + store::result_schema (ResultTableSchemaProvider) + the catalog repos’ assert_tenant_matches; proven across the verb surface by tenant_isolation_oracle.rs::every_case_isolation_holds (every wire rpc covered, asserted by every_rpc_is_covered)
Typed error surfacesFailures are typed variants with stable wire status, not opaque strings: a wrong on-disk shape is JammiError::Schema, a stale tenant write is BackendError::TenantMismatch, an incompatible format is JammiError::IncompatibleFormatthe typed-error definitions per crate; the Format Stability reject paths; the tenant write-guard
The BYO-auth resolver seam (covers every transport)Tenant binding is uniformly resolver-driven: a consumer composing the engine via assemble_grpc_chain supplies its own TenantResolver (async: request metadata → TenantScope), which the single async tenant-binding tower layer applies to every engine gRPC verb AND the Flight SQL db.sql lane (TenantBoundProvider drives the same resolver). One authenticating resolver, plugged in once, authenticates both transports — closing the cross-transport gap where the gRPC plane was authenticated but Flight bound from the unauthenticated jammi-session-id header (#220, now closed). The seam is the same one downstreams compose with; the BYO-auth seam and the composability seam are one seam. The engine still authenticates nothing on its own — the default resolver (SessionIdTenantResolver) binds the tenant a caller asserts via jammi-session-id; supplying an authenticating resolver is the consumer’s jobthe seam proof composability_seam.rs::resolver_seam_scopes_both_transports_and_rejects_missing_credential (gRPC + Flight isolation and UNAUTHENTICATED-on-missing across both transports) and the mirror grpc_byo_auth.rs::resolver_seam_binds_the_engine_and_rejects_missing_credential; the lower-level custom-Interceptor-in-front pattern (for fronting your own single service) is pinned by the four guarantees below
The AdminAuthorizer capability gate — gRPC-only, one grant, one transportUnlike the resolver seam above (one grant that covers both transports), CatalogService.Reconcile’s cross-tenant all = true admin pass is a SEPARATE, narrower gate: a synchronous AdminAuthorizer (fn authorize(&self, metadata: &MetadataMap) -> Result<(), Status>) a deployment supplies at GrpcChain.admin_authorizer. It is gRPC-only by construction, not by omission — Reconcile has no Flight SQL analogue to gate. The shipped default is admin_authorizer: None, which refuses EVERY all = true request with PERMISSION_DENIED naming this page; a tenant-scoped Reconcile (all = false) never consults it at all and runs under the caller’s own resolved tenant scope like any other verbgrpc/catalog.rs (AdminAuthorizer, CatalogServer::admin_authorizer); default-deny proven by grpc_remote_session.rs::remote_reconcile_all_is_denied_by_default_without_an_authorizer; an authorized pass by grpc_remote_session.rs::remote_reconcile_reports_like_local and the cross-tenant isolation oracle tenant_isolation_oracle.rs::assert_reconcile_isolated, both via the test-only AllowAllAdmin (tests/it/common/grpc.rs); a worked example implementation is in Scope a Session to a Tenant → Bring your own auth

The BYO-auth seam’s contract is pinned by grpc_byo_auth.rs as a worked example. The resolver-seam tests above prove it through the engine’s own composability seam across both transports; the custom-interceptor-in-front form (a consumer fronting its own single service) additionally pins four guarantees, each its own test:

  • Missing credential → unauthenticated. No token fails the request before any handler runs, so the caller reads nothing — it does not fall through to an unscoped read (missing_credential_is_rejected_not_run_unscoped).
  • Forged claim → unauthenticated. A token whose signature does not cover its tenant claim is rejected; a forged tenant buys nothing because the signature covers the claim (invalid_credential_is_rejected).
  • A rejected caller does not fall through. The interceptor fails the request rather than binding None — there is no path by which an unauthenticated caller silently reads another tenant’s rows (the same test proves a valid token for the very tenant the forgery claimed does resolve, so the rejection was the signature, not a tenant blocklist).
  • Per-tenant isolation through the seam. Two callers presenting valid tokens for two distinct tenants each see only their own tenant’s sources, end to end through the authenticating interceptor (two_authenticated_tenants_see_isolated_sources).

What the engine explicitly does NOT defend

These are honest absences. The engine never claims them; a deployment that needs them supplies them above the engine.

  • It authenticates nothing. There is no built-in credential check on any verb. The default SessionIdTenantResolver reads the jammi-session-id header and binds the tenant the caller asserts (or the explicit Global/unscoped scope when none is bound) — it verifies nothing about who the caller is. A deployment that needs authentication supplies its own TenantResolver at the seam.
  • It ships no authz / RBAC / SSO. There is no role model, no permission check, no policy engine, no identity-provider integration. Authorization is a consumer’s vocabulary and lives above the seam.
  • jammi-session-id is a correlation id, NOT a credential or principal. It is a client-minted, opaque transport correlation id identifying a connection, not a person. Anyone who presents another session’s id assumes that session’s tenant. It is never an authentication or authorization boundary.
  • No TLS / secrets / IAM. Transport encryption, secret management, key rotation, and cloud IAM are the consumer’s runtime, not the engine’s — the same line the Design Philosophy draws around load balancing, ingress, and orchestration.
  • The peer listener authenticates nothing either (I-PEER). [server] peer_bind (unset by default) serves the engine-internal segment-search seam to other replicas and trusts the channel — see The peer listener below.
  • The gang listener authenticates nothing either (I-GANG). The same [server] peer_bind listener also serves the coordinator-to-member gang admission seam for a multi-host training run and trusts the channel — see The gang listener below.
  • The Ballista listeners authenticate nothing either (I-PEER). [ballista] scheduler_bind and [ballista.executor] bind/grpc_bind (all unset by default) open the compute plane’s scheduler gRPC, executor task gRPC and Arrow Flight shuffle ports and trust the channel — see The Ballista listeners below.

The peer listener (I-PEER)

[server] peer_bind opens a separate internal listener serving jammi.v1.peer.PeerService — the seam a coordinator replica fans a search out through to the replicas that own a table’s segments (see Beyond one node). Its threat model is stated as one invariant, I-PEER:

  • Every client of peer_bind is a jammi coordinator. The owner handler trusts the channel: the request carries no tenant, the owner binds none and reads no result_tables row. It enforces exactly one thing at its input edge — every requested segment id belongs to the named table (else the whole request is refused) and the bundle’s stamped precision matches.
  • Tenant scope is enforced once, at the coordinator. The coordinator’s Search resolved the table through its own tenant-scoped catalog read (tenant_id = $current OR tenant_id IS NULL) before any fan-out, so a coordinator bound to tenant B cannot name tenant A’s table — it fails before a single peer call. The owner is the second half of that one predicate, not a second predicate.
  • The public listener never reaches it. The peer routes are built outside assemble_grpc_chain, never wrapped by the tenant-binding layer, never advertised by GetServerInfo; the public listener answers UNIMPLEMENTED for /jammi.v1.peer.PeerService/* (proven by the tenant-isolation oracle).
  • Binding peer_bind on a routable interface without network policy / mTLS exposes cross-tenant segment reads to anyone who can reach the port. The listener speaks plaintext gRPC like every other engine port; encryption and peer authentication are the runtime’s (a mesh, a network policy, mTLS at a sidecar), exactly as for the public listener. Default unset = no listener.

The gang listener (I-GANG)

The same [server] peer_bind listener also serves jammi.v1.gang.GangService (RunRank) — the seam a coordinator uses to admit a member into a multi-host training run. Its threat model is stated as one invariant, I-GANG:

  • Every client of peer_bind is a jammi coordinator, the same trust I-PEER states — no separate authentication for the gang seam.
  • Tenant is derived from the job row, never accepted from the caller. A RunRank call carries only job coordinates (job_id, attempt, rank, world, coordinator_instance_id); caller-supplied tenant metadata is never read, and ambient admin scope is refused before any row is read (and again at the training-set resolution site). The admission row the member reads (Catalog::get_job_for_rank, primary key only) carries the row’s OWN tenant_id, and a multi-host job’s training set is resolved under that tenant alone through the strict tenant-pinned resolver (Catalog::get_result_table_for_tenant) — another tenant’s table of the same name, or a global (NULL-tenant) one, never resolves. The job’s coordinates are the capability: a coordinator on peer_bind holding them is admitted for that job — bounded by I-PEER’s own trust statement above — and can read nothing beyond what that job’s row names.
  • Non-disclosure on refusal; reasons only after admission. Every admission determinant — job not found, not running, wrong claimant, wrong attempt, lease not live or undecodable, world size undecodable or not the caller’s, the training-set pair missing, the tenant undecodable, the training set unresolved under the job’s tenant / not ready / its sidecar absent / its digest mismatching / this host’s store faulting, coordinator not fresh — collapses to the SAME status (FAILED_PRECONDITION) with a fixed message, as the call’s own result: the listener discloses neither a job’s existence, its claimant, its attempt, its tenant, nor another tenant’s table; tests distinguish determinants through a test-only seam, never response text. Only an ADMITTED session — the caller already holds the job’s own coordinates — ends with a named reason in the stream (Aborted{Drain | Refuted | Cancelled | NoBody | StoreUnavailable | Unavailable}).
  • Admit-and-hold. An admitted rank holds this host’s single job slot (a peer never claims while it holds a rank, never admits a rank while it runs a job; a busy slot refuses UNAVAILABLE, transient) and is re-verified every heartbeat against the same determinants; a DRAIN or RELEASE of the host ends every held rank at once. A world_size > 1 session runs its rank body (trained over the session’s own stream as a rank of the gang) and ends with that body’s one Outcome; a world_size == 1 session has no body to run, parks, and ends NoBody after one lease window. The member writes nothing to the job row and nothing into the job’s artifact prefix on behalf of a rank: only the lease holder writes.
  • Multi-host gang admission is a Postgres-only deployment shape — a SQLite deployment is a single embedded process; there is no second host to admit at all, so this seam is never exercised there. The admission read’s lease/freshness facts (lease_expires_at, instances.last_seen_at) are decoded CLIENT-SIDE from the row’s raw stored text (jammi_db::catalog::lease::{decode_lease_expires_at, last_seen_at_is_fresh}), never a SQL-side cast, so a malformed value is a row fact the handler refuses on EITHER backend, never a read fault on one and a live-row fact on the other (https://github.com/f-inverse/jammi-ai/issues/574); the CLAIM and RECLAIM predicates (Catalog::claim_next, Catalog::reclaim_expired_jobs) are the ones that still require a shared, single-writer clock (Postgres’s now()) — those WRITE, and a wrongly-reaped live claimant is destructive in a way a stale admission read (self-correcting at the next heartbeat) is not.
  • The public listener never reaches it. The gang routes are mounted beside PeerService outside assemble_grpc_chain, never wrapped by the tenant-binding layer, never advertised by GetServerInfo; the public listener answers UNIMPLEMENTED for /jammi.v1.gang.GangService/* (proven by the tenant-isolation oracle).
  • Binding peer_bind on a routable interface without network policy / mTLS exposes the same risk I-PEER states — the listener speaks plaintext gRPC; encryption and peer authentication are the runtime’s. Default unset = no listener = no gang admission surface.
  • The membership predicate list_gang_members narrows to is a catalog-local read, not an RPC. A row is a member of a listing iff: it is NOT the caller itself; its workers row (an INNER join — a member is a fleet worker with a claim-loop slot, not merely a live process) has state = 'claiming' (warming/draining excluded); workers.kinds contains the requested kind as a whole, comma-split, trimmed token (never a substring — fine_tune never matches graph_fine_tune); instances.peer_addr is non-NULL; and last_seen_at is fresh under instance_liveness_margin(lease) (2 × lease) on the DB clock; and its instances.result_root_identity EQUALS the caller’s own (unit U5b-1a-A2). peer_addr_of is the by-id analogue, with no kind/self/root filter (any other member may resolve any other by id). The root is compared by identity, never by spelling: instances.result_root carries resolved_result_root() VERBATIM (so gcs://b/p and gs://b/p remain two different STRINGS in that column), and result_root_identity carries that root’s identity, derived by MemberRoot::resolved from the same config the store roots itself from: the store’s own URL parser folds the scheme aliases (gcs://gs://, abfss://azure://), the bucket is kept as spelled (the driver dials it as spelled) and the key goes through the store’s own key parser (one leading / stripped, a trailing one dropped, an empty segment refused — keys keep their case), the location determinants are read back from the very builder the store constructs for that root (storage::location_determinants: the process environment first, [storage.cloud] on top — for S3/R2 the bucket endpoint the driver dials (endpoint spelling, virtual-hosted or path style, S3 Express, region), the Azure account, endpoint, Azurite host in emulator mode, Fabric switch, the GCS base URL; the identity spells exactly the variables the driver spells and reads their values as the driver reads them, so nothing the driver honours can be missed), a local root is created (as the store creates it at open) and canonicalised on the owning host’s filesystem (symlinks, ./.., the filesystem’s own spelling), and an in-memory root is refused at registration as unshareable. Two members whose spellings name one location ARE gang members of each other; two rooted at different locations are not; a row with a NULL identity (pre-036, or a process with no membership) never matches. Root identity equality is NECESSARY, never SUFFICIENT, for shared storage: two identical local roots on two unshared filesystems are indistinguishable to this predicate — sufficiency is the attestation VERIFY’s (U5a-1’s admission-time sidecar, U5b-0’s leaf inventory), not this listing’s.
  • B5 (this listing is deliberately tenant-free). instances/workers rows are deployment infrastructure (which processes exist, what they claim, where they are reachable), never tenant data — there is no tenant column on either table, so there is no tenant predicate to drop or keep, and list_gang_members/peer_addr_of return the identical answer under any tenant scope and under none. Neither verb is reachable from any RPC at all today (no gang/peer handler calls either) — a vacuous truth, not yet a tested boundary; the first RPC that calls one of them owes the enumerating unreachability-or-scoping oracle this listener’s own precedent (GANG_LISTENER_ALLOWLIST/PEER_LISTENER_ALLOWLIST) already sets.

The Ballista listeners (I-PEER)

A process with a [ballista] role opens up to three more internal listeners, none of them on the public tenant layer: the scheduler’s gRPC ([ballista] scheduler_bind, Ballista’s SchedulerGrpc), the executor’s task gRPC ([ballista.executor] grpc_bind, ExecutorGrpc) and the executor’s Arrow Flight shuffle port ([ballista.executor] bind). They serve Ballista’s own wire package, not jammi.v1, and their threat model is the peer listener’s, I-PEER:

  • Every client of these ports is a jammi role. A scheduler is dialled by the executors it places on and by the scheduler-role process’s own submitter; an executor is dialled by its scheduler and by sibling executors for shuffle reads. The listeners carry no tenant of their own and bind none.
  • Tenant scope is enforced once, at the submitting session. The plan a scheduler places carries the submitter’s tenant inside the operator descriptor (JammiCodec writes it; a plan the codec did not encode is refused by magic). An executor rebuilding a result-table read resolves the table through the strict tenant-pinned catalog read under exactly that tenant — never the executor process’s ambient scope, which a scheduler or executor process does not have. A descriptor naming another tenant’s table is refused with the same non-disclosing not-found the public layer answers.
  • The public listener never serves them. No Ballista service is mounted on assemble_grpc_chain’s routes; the tenant-isolation oracle proves the public listener answers UNIMPLEMENTED for SchedulerGrpc, the same way it does for PeerService and GangService.
  • Binding them on a routable interface without network policy / mTLS exposes the compute plane — task submission, shuffle reads, executor registration — to anyone who can reach the ports, exactly as for peer_bind. The shape-d overlay binds them on the pod network and the deployer owes the network policy that keeps them cluster-internal; encryption and peer authentication are the runtime’s (a mesh, mTLS at a sidecar). Default unset = no listener.

Transport encryption is the deployer’s runtime, not the engine’s

The engine speaks plaintext gRPC and Flight SQL and ships no TLS code path; transport encryption is the deployer’s runtime. It follows from the same primitives this page and the Design Philosophy already state:

  • B4 (“one binary, every topology”) constrains what the engine forks on, not what fronts it. Terminating TLS is supplied by the runtime the engine deploys into — a proxy, a mesh sidecar, a load balancer — and that termination is not a topology-specific code path the engine would need to special-case per shape. A tls cargo feature would itself be the kind of server-only gate B4 refuses: a build-time fork between “the engine” and “the engine, but for a server.”
  • Passing the discipline test is necessary, not sufficient. A user who has never heard of any consumer does want the wire encrypted — TLS passes the discipline test on its own. But the boundary table and the paragraph that follows it (docs/guide/src/philosophy.md:116-125) are the second gate: TLS, secrets, IAM, ingress, and load balancing all pass the discipline test and are still placed in the consumer’s runtime, not the engine, because the table asks a second question the discipline test does not — does owning this turn the engine into infrastructure it isn’t. TLS termination answers yes.
  • A [server] tls key, in the file or the environment, is a typed refusal, not a silent no-op. ServerConfig — what [server] deserializes into — is a #[serde(default, deny_unknown_fields)] struct (crates/jammi-db/src/config/mod.rs:1123), the same discipline JammiConfig itself carries at its top level (crates/jammi-db/src/config/mod.rs:204). A [server] tls = … stanza in a config file, and JAMMI_SERVER__TLS in the environment, are not silently ignored — each is a typed JammiError::Config startup refusal, naming the unrecognised key.
  • There is no engine-side certificate to hand the TenantResolver seam. Because termination happens outside the engine, the engine never sees a peer certificate to map onto a tenant — that mapping, if a deployment wants one, lives in the terminator or the proxy in front, not at the seam described under The identity seam. The CLI’s --target refuses grpcs:// and https:// with a typed error naming the accepted schemes (crates/jammi-cli/src/main.rs:170-187; the CHANGELOG’s “drop grpcs:// and https:// as accepted --target schemes” entry (#480), commit 616bb6d4) rather than advertising a transport it cannot speak — put a TLS-terminating proxy in front and point --target at it in plaintext (grpc:///http://). This is an asymmetry between the two clients: the Python SDK’s RemoteTarget legitimately keeps grpcs:///https:// in its own scheme table (clients/python/jammi/_target.py:51-54) because it is a general client library reaching whatever endpoint a deployment publishes (including a TLS-terminating proxy), while the CLI is the engine’s own admin surface and names only the schemes the engine itself speaks.

Shape B with no mesh — an on-prem single-tenant deployment that has no ingress or mesh to terminate TLS for it — still gets encryption: put a terminator in front of the engine’s plaintext listeners. A minimal, consumer-neutral example with Caddy:

# Caddyfile — terminates TLS and forwards plaintext to the engine's
# loopback-bound listeners (see docker-compose.yml).
#
# `tls internal` issues Caddy's own locally-trusted certificate: a private
# DNS name (no public record) has no ACME challenge path to a public CA,
# so automatic Let's Encrypt/ZeroSSL issuance is not an option here.
jammi.example.com {
    tls internal
    reverse_proxy h2c://127.0.0.1:8081  # gRPC + Flight SQL
}
health.jammi.example.com {
    tls internal
    reverse_proxy 127.0.0.1:8080        # /healthz, /readyz, /metrics
}

The reference deploy/docker-compose.yml binds its published ports to 127.0.0.1 for exactly this shape: the compose stack publishes the engine’s ports for a terminator running on the same host to reach, not for direct exposure to an untrusted network. A terminator running as a container on the same Compose network instead reaches the engine by its service name rather than 127.0.0.1reverse_proxy h2c://jammi-server:8081 — since two containers on the same Compose network share that network, not the host’s loopback interface.

The trusted-network assumption

Every Jammi deployment that uses the default SessionIdTenantResolver assumes a trusted network: a private VPC, a sidecar mesh, or a single-process embedding where every caller is already inside the trust boundary. On that network, binding the tenant a caller asserts via jammi-session-id is the right, low-friction trade-off. The moment an untrusted caller can reach the port, that trade-off is wrong — and closing it is the consumer’s job, via an authenticating TenantResolver at the seam, not a flag the engine flips.

Tenant scope is an organizational mechanism, not an access-control boundary

This distinction is load-bearing. Tenant-scope filtering (above) is an organizational mechanism: it keeps one tenant’s catalog rows from appearing in another tenant’s correctly-bound reads, so a multi-tenant deployment stays tidy and a buggy caller that writes the wrong tenant_id is refused by assert_tenant_matches. It is not an access-control boundary: it does not decide which tenant a caller is entitled to act as. Nothing in the engine prevents an unauthenticated caller from asserting any tenant it likes via jammi-session-id and reading that tenant’s rows. Access control — proving a caller may act as the tenant it claims — is exactly what the BYO-auth seam adds in front of the scope mechanism. Treating tenant scope as if it were authorization is the misuse this page exists to forestall.

The consumer’s responsibilities

To put a real tenant boundary in front of untrusted callers, a consumer supplies the authentication and authorization the engine deliberately omits by implementing a TenantResolver and passing it to assemble_grpc_chain — one plug that binds every engine gRPC verb and the Flight db.sql lane through the single tenant-binding mechanism (the tenant recipe and the worked example in grpc_byo_auth.rs):

  1. Authenticate the principal. In resolve, read and verify the caller’s credential (a bearer token, an exchanged session cookie, a service-to-service token). A missing or invalid credential returns Err(Status::unauthenticated) here, before any handler runs.
  2. Authorize the tenant from the verified claim. Derive the tenant from the verified claim — never from a header the caller controls. This is where the consumer’s policy lives: which tenant this principal may act as. Return Ok(TenantScope::Tenant(t)).
  3. The engine binds it. The async tenant-binding layer maps the resolved scope onto the SessionTenant request extension every verb handler resolves, and TenantBoundProvider binds it for Flight — the consumer writes only resolve.

Because resolve runs in front of every handler, the tenant the engine acts on is the one the credential proves, not one the caller asserts. Reject, don’t default: an authenticating resolver returns Tenant/Err and NEVER TenantScope::Global — returning Global (or, in the lower-level interceptor-in-front form, binding None) on a failed check runs the request unscoped, which for a tenant_id IS NULL-bearing catalog is a global read, so a rejected caller must fail the request. TenantScope::Global is the explicit unscoped choice the default (OSS-cooperative) resolver returns when no tenant is bound — never a value a rejection falls through to. That framing rule is the defect grpc_byo_auth.rs’s missing_credential_is_rejected_not_run_unscoped and the seam mirror resolver_seam_binds_the_engine_and_rejects_missing_credential guard against.

Dependency-advisory posture

The engine’s dependency tree is gated in CI by cargo deny against the RustSec advisory database, plus a license allowlist and the source/ban guards that formalize the engine’s one-way dependency direction (no proprietary or non-crates.io crate in the OSS closure). The advisory lane runs the live RustSec DB on every PR; a documented exception in deny.toml records any advisory the release knowingly carries, with a written rationale, rather than destabilizing the freeze with a risky bump. The config is deny.toml at the repo root.

Performance SLOs

Jammi’s performance contract is throughput and coverage, gated against committed baselines — not latency. Each scale-relevant engine verb commits a measured rate (or, for the recall tier, a recall fraction gated against a committed floor) on a named reference box, and a regression gate fails when a fresh run falls more than a fixed fraction below it. This page is the operator’s reference for every gated target: the verb, the named scale it is measured at, the committed baseline, the relative-drop threshold, and the box the baseline was emitted on.

How the gate works

A measured rate must not fall more than the relative-drop threshold below its committed baseline. The threshold derives an absolute floor from the baseline — floor = baseline · (1 − threshold) — and the gate is a >= against that floor (measured >= floor), never an equality and never a bit-compare. The single threshold is 30% (DEFAULT_REGRESSION_THRESHOLD), defined once in the harness. It is generous on purpose: the load-bearing failure this gate exists to catch is a structural regression — an algorithm that went quadratic, a lock that serialized a parallel path, a dropped fast path — which collapses throughput by far more than a third. A tighter threshold would trade that real signal for false alarms on runner noise.

The gate fails closed: a non-finite or non-positive baseline cannot anchor a relative gate, so it fails (it never vacuously passes against a meaningless baseline). Each *-scale bench subcommand maps its verdict to its process exit code — a regression exits non-zero — which is what the CI lanes assert.

Where the gate runs

LaneTriggerBlocking?Purpose
ci.yml (workspace tests)every PRyesGates a property of the mechanism: committed_baseline_gates_with_teeth proves the committed baseline is a well-formed, generously-thresholded gate that can fail. It does not re-measure the rate on the contended PR runner.
perf.ymlnightly schedule: + workflow_dispatchno (early-warning)Runs every *-scale tier’s measured-rate gate on a real box, so a structural regression surfaces between releases. Non-blocking because the 30% band was sized for a same-box manual emit, not a contended shared runner — a required per-PR rate gate would flap and rot.
crates.yml (perf-gate)v* release tagyesThe authoritative same-box-ish gate: publish depends on it, so a structural perf regression on the release tag blocks the crates.io publish and the GitHub release.

The gated targets

Each row is one gated verb at one named scale. The rates are same-box throughputs; the recall row is a fraction gated by an inequality — the measured >= floor check is meaningful on any box, but the fraction itself is bit-for-bit only on the same box (see the same-box caveat). Every committed number is a real, re-derivable fold — a rebuild-* bench subcommand reproduces it on the emit box.

VerbBench tierNamed scaleCommitted baselineThresholdGated quantity
fine_tunetrain-scale1 536 in-batch-negative pairs, one GradCache backward + AdamW step, Device::Cpu180.0 pairs/s30% rel. dropthroughput (pairs/s)
fine_tune_graphgraph-train-scale8 communities × 64 nodes, biased-walk sampler (walk length 4, 4 walks/node)6 418.1 pairs/s30% rel. dropsampled-pairs/s throughput (+ a portable determinism digest)
train_context_predictorcontext-predictor-scaleCNP over 8 tasks × 18 rows, 30 epochs21.29 episode-steps/s30% rel. dropmeta-training throughput (+ a same-box predict digest)
generate_embeddingsmodel-inference-scale16 rows over a tiny 32-dim 1-layer BERT bundle, Device::Cpu333.6 rows/s30% rel. dropcoarse serving throughput (+ a same-box embed digest)
infer (classification)model-inference-scale16 rows over a tiny 32-dim 1-layer ModernBERT classifier bundle, Device::Cpu207.0 rows/s30% rel. dropcoarse serving throughput (+ a same-box infer digest)
search + build_neighbor_grapharxiv2 000-row corpus slice, 100 held-out 768-dim queries (frozen sidecar)recall@{1,10,100} = {1.0, 1.0, 0.997}floor = measured − 0.04 (absolute margin)recall fraction (not a rate) — measured >= floor, an inequality gate whose absolute margin absorbs cross-box float drift; the fraction is bit-for-bit only on the same box

The reference box

The committed rate baselines were emitted on this box, in the release profile, with RAYON_NUM_THREADS=1:

PropertyValue
Logical CPUs8
Total RAM31 720 MiB (~31 GiB)
Profilerelease
Engine version when committed0.30.0

A baseline is refreshed by hand (via the tier’s rebuild-* subcommand) when the emit box changes; the version-stamped report lets a downstream gate reject a cross-version comparison.

The same-box caveat

A committed rate is not a portable floor. Stated verbatim from the gate’s own definition:

A rate (throughput, QPS, pairs/s) is not portable the way the recall fraction is — it is a property of the box that produced it, so a committed rate baseline is a same-box reference, refreshed by hand when the emit box changes, not a number a different machine can re-derive.

What stays portable is the shape of the gate (a measured rate must not fall more than a fixed fraction below the committed baseline; a measured recall must not fall below the committed floor) — that is the sense of “portable” in the quote above: the floor travels to another box, not the bits. Of the digests above, only the fine_tune_graph sampled-pair-set checksum is portable bit-for-bit: the pair selection is a seeded integer stream (its scalar f64 roulette arithmetic is neither contracted nor reordered by Rust) and the checksum is an FNV-1a fold over the selected node-text bytes, so any box re-derives it exactly. The recall fraction is not in that class — it is scoped like the float digests. Recall-set membership is decided by an f32 cosine reduction (the exact oracle’s cosine_distance, a sequential f32 accumulation over the dot product and norms), so the fraction is bit-for-bit only on the same box; across boxes or architectures a near-tie can move a neighbour in or out of the top-k, and the recall SLO is an inequality gate (measured >= floor) whose absolute margin (0.04) absorbs that small float drift — never a bit-for-bit equality. The predict/embed/infer digests fold an f32 forward, and an f32 reduction is NOT bit-identical across CPUs (SIMD/FMA contraction and BLAS reduction order differ by machine), so those three are a same-box property: each is re-derived on the box that ran it, not asserted equal across boxes. So the rate rows above are meaningful only against the reference box; do not read them as a throughput your hardware must hit. The release-tag gate is the authoritative reading because it runs on a same-box-ish runner; the nightly lane is early-warning, not a portable promise.

Why no latency SLOs

The contract is throughput and coverage, not latency. A latency SLO on a shared CI runner flaps — tail latency on a contended box is dominated by co-tenant load, not by the engine’s code path — so a latency gate would either flap (set tight) or never bite (set loose), exactly the failure mode the relative-drop rate threshold is designed around. Latency is therefore out of scope here. The representative full-scale serving numbers (the GPU-model rates that latency would ride on) are captured off-box in the cookbook’s A/B split, not gated in CI.