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;
searchreturns a table directly, same shape embedded or remote - Compound query — join sources, filter, and run a model over a relation (the
annotateSQL table function), in-process or over the Flight SQL lane in one round-trip; a fluentQueryBuildercomposes the same operations in Rust - Evidence provenance —
retrieved_byandannotated_bytracking 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
| Interface | Best for | Install |
|---|---|---|
| Rust library | Embedding Jammi into Rust applications | cargo add jammi-ai |
| Python package | Data science, notebooks, scripts | pip install jammi-ai |
| CLI | Shell workflows, quick queries, ops | cargo 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
| Crate | Purpose |
|---|---|
jammi-db | Query engine, configuration, catalog, source management, Parquet storage, ANN indexes |
jammi-ai | Model loading, inference execution, embedding pipeline, vector search, evidence model, fine-tuning, evaluation |
jammi-server | Arrow Flight SQL server and HTTP health endpoint |
jammi-cli | Command-line interface |
jammi-python | Python 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 surface | Domain tables (the consumer’s own entities) |
| Catalog primitives — typed status enums, append-only migrations | Domain status enums and their lifecycle meaning |
| Storage primitives — Parquet result-tables, sidecar ANN, mutable companion tables | Domain audit / recapture / gating semantics |
| Source registration and federation | Domain interop adapters (foreign format → a registered source) |
| AI primitives — embeddings, inference, search, fine-tune, eval | Domain agents / reactors and what they decide |
| Data-driven provenance channels | Domain audit columns (what “signed-by”, “owned-by” mean) |
| Trigger stream — Arrow batches, SQL-predicate filters | Domain event taxonomies and their semantics |
| Tenant session scope | Domain 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
LifecycleTablewrapper 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.
How embeddings are consumed: search
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:
| Backend | Embedded default | Production driver(s) |
|---|---|---|
| Catalog | SQLite | Postgres |
| Result-table storage | Local filesystem | S3 / GCS / R2 / Azure Blob (via the object_store crate) |
| Mutable companion tables | SQLite | Postgres |
| Trigger broker | In-memory | Kafka / NATS / Redis Streams / a cloud queue |
| Model artifact source | Local cache + HF Hub | Mirror, 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
CustomOpwith 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 SAMEjammi-serverbinary, on jammi’s own shutdown (never Ballista’s own process-levelstart_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 ownattempts/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
JammiConfig::load(None)loads config fromjammi.toml,$JAMMI_CONFIG, or defaultsInferenceSessionwraps the query engine with model loading, caching, and GPU schedulingadd_sourceregisters a file in the catalog — it survives session restartssqlruns any SQL query via DataFusion, returnsVec<RecordBatch>generate_text_embeddingsruns the model over every row, persists vectors to Parquet with a sidecar ANN indexencode_text_queryencodes a text string into the same vector spacesearchfinds the nearest neighbors, hydrates all source columns, and returns results with similarity scores
Next steps
- Query Your Data with SQL — SQL features, joins, aggregations
- Generate Embeddings — persistence, multiple models, crash recovery
- Semantic Search — SearchBuilder API, filtering, evidence provenance
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
- Install —
pip install jammi-ai - Connect — open a session against a local artifact dir
- Register a source — attach a Parquet file
- 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
| Command | Description |
|---|---|
jammi status | Report the server’s capabilities and confirm reachability |
jammi sources list | List registered data sources |
jammi sources add <NAME> --url <URL> --format <FMT> | Register a source |
jammi models list | List registered models |
jammi channels … | Manage evidence channels |
jammi mutable … | Manage mutable companion tables |
jammi trigger … | Manage trigger-stream topics |
jammi jobs list | List 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 prune | Delete terminal job rows past [jobs] retention_days |
jammi workers list | List 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
- Deploy as a Server —
jammi-server, configuration, preloading models - Configuration — full config reference
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:
| Recipe | Demonstrates |
|---|---|
mutable_tables | Create/insert/select/drop on a mutable companion table |
trigger_streams | Publish + subscribe on a topic via the in-process broker |
eval_embeddings | recall@k, MRR, nDCG against a golden set |
image_search | Image-to-image search + Recall@K / MRR eval; vision-tower LoRA fine-tune with a served-change assertion; refusal on an unmatched selector |
eval_inference | Accuracy + macro F1 against gold labels |
eval_inference_ner | Entity-level precision / recall / F1 against gold spans |
fine_tune | LoRA fine-tune end-to-end |
flight_sql | Query a remote jammi-server over Arrow Flight SQL |
audio_search | Audio-to-audio search + Recall@K / MRR eval; audio-tower LoRA fine-tune with a served-change assertion; refusal on an unmatched selector |
search_audit | Per-query provenance audit of a search |
session_lifecycle | Ephemeral 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
- Connects to a temporary artifact dir
- Creates a
notesmutable table with anint64primary key +utf8body column - Inserts three rows through DataFusion DML (
INSERT INTO ...) - Verifies count and ordering via
SELECT - Drops the table, then asserts a
SELECTafter the drop raises - 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
- Connects to a temporary artifact dir
- Registers a topic
events.demowith a typed schema and broker metadata - Confirms
list_topics()returns the new topic - Publishes a 3-row batch through
publish_topic— captures the broker-assigned offset - Subscribes from
from_offset=0and round-trips the same rows back - Drops the topic, confirms it’s gone from
list_topics() - 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 offsetDatabase.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
- Connects to a temporary artifact dir
- Registers the tiny corpus as a Parquet source
- Builds 32-dim embeddings over the
contentcolumn with the localtiny_bertfixture - Reads
cookbook/fixtures/tiny_golden.json, expands it into the(query_id, query_text, relevant_id)CSV shapeeval_embeddingsconsumes, and registers it as agoldensource - Calls
db.eval_embeddings(source="corpus", golden_source="golden.public.golden", k=5) - Asserts each aggregate metric is in
[0.0, 1.0]and the per-query records carry their golden-setquery_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:
| column | type | example |
|---|---|---|
query_id | utf8 | q1 |
query_text | utf8 | quantum computing applications |
relevant_id | utf8 | 1 (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.
Image search
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
- Load a small image corpus (inline image bytes in a Parquet source)
- Generate L2-normalized vision embeddings over the image column
- Search the index with an encoded image query (cosine ANN)
- Eval retrieval quality (Recall@K / MRR) against a held-out golden set
- 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
- Connects to a temporary artifact dir
- Reads the 20 committed 224×224 PNGs under
cookbook/fixtures/tiny_image_corpus/into a Parquetcorpussource (image_id,imagebytes) db.generate_embeddings(source="corpus", model=MODEL, columns=["image"], key="image_id", modality="image")db.encode_query(model=MODEL, query=png_bytes, modality="image")→db.search("corpus", query=vec, k=5)(returns apyarrow.Table)- Builds the image-query golden source from
tiny_image_golden.jsonand callsdb.eval_embeddings(source="corpus", golden_source="golden.public.golden", k=5) - 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.
- Builds synthetic
(anchor, positive, negative)image triplets from the corpus (positive = same shape family, negative = a different family) and callsdb.fine_tune(source="triplets", base_model=MODEL, columns=["anchor","positive","negative"], method="lora", task="image_embedding", target_modules=["in_proj","c_fc"], ...). A non-emptytarget_modulesputs LoRA inside the vision tower —in_projis the transformer block’s fused-QKV projection,c_fcthe 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-4versus the base encoding). - 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 assertsjob.wait()raisesjammi.errors.TrainingErrorwhose message echoesq_projand 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.TableDatabase.eval_embeddings(*, source, golden_source, model=None, k=10)Database.fine_tune(*, source, base_model, columns, method, task="image_embedding", target_modules=[...], ...)→TrainingJobDatabase.describe_model(model_id)→dict | None
Image triplet schema (fine-tune input)
| column | type | notes |
|---|---|---|
anchor | binary | encoded image |
positive | binary | an image the caller deems related |
negative | binary | an 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
| column | type | notes |
|---|---|---|
image_id | utf8 | per-row key |
image | binary | raw 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:
| column | type | example |
|---|---|---|
query_id | utf8 | q_circle |
query_image | binary | raw PNG bytes of the query image |
relevant_id | utf8 | img_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 underqueries/. Rendered programmatically bycookbook/fixtures/generate.py— no 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
- Connects to a temporary artifact dir
- Registers the tiny corpus as
corpus(parquet) - Registers
tiny_labels.csvasgolden(csv) —(id, label)rows - Runs
db.eval_inferencewith the localtiny_modernbert_classifierfixture against thecontentcolumn - Prints the returned aggregate
accuracy, macrof1, per-class metrics, and the count of per-record predictions - 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:
| column | type | example |
|---|---|---|
id | utf8 | "1" |
<label_column> | utf8 | physics |
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
- Connects to a temporary artifact dir
- Registers
tiny_ner_corpus.parquetascorpus(parquet) - Registers
tiny_ner_gold.csvasgolden(csv) — one row per gold entity span:(id, label, start, end) - Runs
db.eval_inferencewith the localtiny_modernbert_nerfixture against thetextcolumn,task="ner" - Prints the returned aggregate
precision,recall,f1, the per-type breakdown, and the count of per-record predictions - 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):
| column | type | example |
|---|---|---|
id | utf8 | "1" |
<label_column> | utf8 | PER |
start | i64 | 0 |
end | i64 | 13 |
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
- Connects to a temporary artifact dir
- Registers
tiny_pairs.csv(30 contrastive pairs) astraining - Calls
db.fine_tune(...)with the localtiny_bertbase, a small LoRA rank, and one epoch — kept fast for CI - Waits for terminal status via
job.wait() - Asserts the resulting
model_idstarts withjammi:fine-tuned: - 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_idDatabase.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
- Spawns
target/release/jammi-serveras a child process pointed at a tempartifact_dir - Polls the health endpoint (
http://127.0.0.1:8080/healthz) until the server is ready (5 s budget) - Opens a
pyarrow.flight.FlightClientagainstgrpc://127.0.0.1:8081 - Submits
SELECT 1 AS oneover Flight SQL and confirms the response - 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— producestarget/release/jammi-serverpip install pyarrow(already ajammi-aidependency)
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 dialectjammi-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.
Audio search
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
- Load a small audio corpus (inline audio bytes in a Parquet source)
- Generate L2-normalized audio embeddings over the audio column
- Search the index with an encoded audio query (cosine ANN)
- Eval retrieval quality (Recall@K / MRR) against a held-out golden set
- Fine-tune a projection head on audio triplets and re-eval (tuned ≠ base)
- 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
- Connects to a temporary artifact dir
- Reads the 20 committed mono WAV clips under
cookbook/fixtures/tiny_audio_corpus/into a Parquetcorpussource (clip_id,audiobytes) db.generate_embeddings(source="corpus", model=MODEL, columns=["audio"], key="clip_id", modality="audio")db.encode_query(model=MODEL, query=wav_bytes, modality="audio")→db.search("corpus", query=vec, k=5)(returns apyarrow.Table)- Builds the audio-query golden source from
tiny_audio_golden.jsonand callsdb.eval_embeddings(source="corpus", golden_source="golden.public.golden", k=5) - 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.
- Builds synthetic
(anchor, positive, negative)audio triplets from the corpus (positive = same timbre family, negative = a different family) and callsdb.fine_tune(source="triplets", base_model=MODEL, columns=["anchor","positive","negative"], method="lora", task="audio_embedding", ...). Emptytarget_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-4versus 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. - Runs the other fine-tune mode on the same triplets:
target_modules=["query", "value", "linear1"]puts LoRA inside the HTSAT-Swin tower itself —query/valueare the Swin blocks’ attention projections (indexed by stage),linear1the 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-4change. - 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 assertsjob.wait()raisesjammi.errors.TrainingErrorwhose message echoesq_projand 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.TableDatabase.eval_embeddings(*, source, golden_source, model=None, k=10)Database.fine_tune(*, source, base_model, columns, method, task="audio_embedding", target_modules=[...], ...)→TrainingJobDatabase.describe_model(model_id)→dict | None
Audio triplet schema (fine-tune input)
| column | type | notes |
|---|---|---|
anchor | binary | encoded audio clip |
positive | binary | a clip the caller deems related |
negative | binary | a 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
| column | type | notes |
|---|---|---|
clip_id | utf8 | per-row key |
audio | binary | raw 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:
| column | type | example |
|---|---|---|
query_id | utf8 | q_sine |
query_audio | binary | raw WAV bytes of the query clip |
relevant_id | utf8 | clip_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 underqueries/. Synthesised programmatically bycookbook/fixtures/generate.py— no 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 bytests/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
PerQueryAuditrecord (query id, model id/version, query lineage, top-K result ids, retrieval scores). db.audit.log([...])— the substrate injectstenant_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_lineageJSON may not exceed 8 KiB (override withJAMMI_AUDIT_MAX_LINEAGE_BYTES). Store image hashes and row IDs, not raw payloads — compliance posture is structural, not advisory. top_k_result_idsandretrieval_scoresmust be the same length. This is checked when you construct the record.- The table is reserved.
_jammi_search_auditis created implicitly on the first log; you cannot create or directlyINSERTinto 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
| Format | Rust | Python/CLI | Notes |
|---|---|---|---|
| Parquet | FileFormat::Parquet | "parquet" | Columnar, compressed, recommended for large datasets |
| CSV | FileFormat::Csv | "csv" | Auto-detected schema |
| JSON | FileFormat::Json | "json" | Line-delimited JSON |
| JSON Lines | FileFormat::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_idstrings..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
| Column | Type | Description |
|---|---|---|
_row_id | Utf8 | Key column value cast to string |
_source_id | Utf8 | Source identifier |
_model_id | Utf8 | Model identifier |
vector | FixedSizeList(Float32, N) | L2-normalized embedding vector |
_content_hash | Utf8 (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.Utf8Viewis what a plain ParquetUtf8column 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.
modelis 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.keyandtext_columnsare 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
recomputeof 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 | _error | vector |
|---|---|---|---|
| Valid text | "ok" | null | 384-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:
- Halve the batch size
- Retry (up to 3 times)
- 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_embeddings’ model,
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:
| Form | Example | Resolves to |
|---|---|---|
local: prefix | local:/models/bioclinical-modernbert-large | the directory after the prefix |
file:// URI | file:///models/bioclinical-modernbert-large | the URI’s path |
| Bare filesystem path | /models/m, ./m, ../m | that path (must start with /, ./, or ../) |
| Anything else | sentence-transformers/all-MiniLM-L6-v2, hf://owner/repo | a 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(oropen_clip_config.jsonfor OpenCLIP models). - Weights:
model.safetensors(oropen_clip_model.safetensors),model.onnx, and/ormodel.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*.gguffilename in the directory produces a typed error naming the convention. config.jsonis 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 sidecarconfig.json) is never read for this. A directory with amodel.ggufbut noconfig.jsonis 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 asf32/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.
| Field | Precedence |
|---|---|
| Cache root | hub_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) |
| Endpoint | hub_endpoint → HF_ENDPOINT → the Hub’s own default |
| Token | hub_token → HF_TOKEN → HUGGING_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) |
| Offline | offline, when explicitly set → HF_HUB_OFFLINE → TRANSFORMERS_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 ParquetUtf8/LargeUtf8/Utf8View— file paths pointing to images on disk.Utf8Viewis what a plain ParquetUtf8column 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:
- Pad to square — white canvas, image centered (preserves aspect ratio)
- Resize — bicubic interpolation to the model’s input size (224x224 for CLIP)
- 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.jsonwithmodel_cfg.vision_cfg(andmodel_cfg.text_cfgif you want cross-modal text queries)open_clip_model.safetensorswith OpenCLIP weight key naming (visual.*for vision, root-level for text)- Either a
tokenizer.jsonor the OpenCLIP-nativebpe_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:
| Column | Type | Description |
|---|---|---|
_row_id | Utf8 | Key value |
_source_id | Utf8 | Source identifier |
_model_id | Utf8 | Model identifier |
vector | FixedSizeList(Float32, N) | L2-normalized embedding vector (N = embed_dim) |
_content_hash | Utf8 (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 |
Search
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 disk | n/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.jsonmust contain a populatedmodel_cfg.text_cfg(withwidth,layers, and eitherheadsor awidththat is a multiple of 64).- The safetensors checkpoint must contain the text-tower keys:
token_embedding.weight,positional_embedding,transformer.resblocks.*,ln_final.*, andtext_projection. - A tokenizer must be available — either an HF-converted
tokenizer.jsonor the OpenCLIP-nativebpe_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:
| Column | Type | Description |
|---|---|---|
_row_id | Utf8 | Key column value |
_ordinal | UInt64 | Stream-scoped row counter (0-based, in model emission order); rows read back ordered by _row_id, _ordinal |
_source | Utf8 | Source identifier |
_model | Utf8 | Model identifier |
_status | Utf8 | "ok" or "error" |
_error | Utf8 (nullable) | Error message if failed |
_latency_ms | Float32 | Inference latency |
label | Utf8 (nullable) | Predicted class label |
confidence | Float32 (nullable) | Confidence score (0-1) |
all_scores_json | Utf8 (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.biasfrom safetensors - CLS token pooling + linear classifier + softmax
ModernBERT — uses the built-in ModernBertForSequenceClassification:
- CLS or MEAN pooling (configured via
classifier_poolingin 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 | _status | label | confidence |
|---|---|---|---|
| Valid text | "ok" | Predicted label | 0-1 score |
| Null/empty text | "error" | null | null |
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
| Column | Type | Description |
|---|---|---|
_row_id | Utf8 | Key column value |
_ordinal | UInt64 | Stream-scoped row counter (0-based, in model emission order); rows read back ordered by _row_id, _ordinal |
_source | Utf8 | Source identifier |
_model | Utf8 | Model identifier |
_status | Utf8 | "ok" or "error" |
_error | Utf8 (nullable) | Error message if failed |
_latency_ms | Float32 | Inference latency |
entities | Utf8 (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
}
| Field | Type | Description |
|---|---|---|
text | string | The entity text extracted from the input |
label | string | Entity type (PER, ORG, LOC, etc.) without B-/I- prefix |
start | integer | Character start position (inclusive) |
end | integer | Character end position (exclusive) |
confidence | float | Average 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:
| Column | Type | Classification | Regression |
|---|---|---|---|
prediction_set | Utf8 | JSON array of class ids | null |
lower | Float64 | null | interval lower bound |
upper | Float64 | null | interval upper bound |
alpha | Float64 | nominal level | nominal 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 label | Task / objective |
|---|---|
| a continuous outcome + you want a density | Regression, β-NLL or CRPS (Gaussian) |
| a continuous outcome + you want robust intervals | Regression, pinball (quantile) |
| graded similarity scores | embedding fine-tune, cosine-MSE / CoSENT |
| ordered pairs / rankings | embedding 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.5is 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:
| Tool | Mechanism | Training | Reach for it when |
|---|---|---|---|
| Conformal | distribution-free coverage wrap | none (calibrate) | you need a guarantee over any model, audit-reproducible and deterministic |
| Distributional head | learned aleatoric distribution | fine-tune a head | continuous outcomes where a density or quantiles suffice |
| In-context predictor | meta-learned posterior over a context set | episodic meta-train | few-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.
Basic search
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 fromsimilarity— cosine similarity score (1.0 = identical, 0.0 = orthogonal)retrieved_by—List<Utf8>provenance: which channels found this rowannotated_by—List<Utf8>provenance: which channels added evidence post-retrieval
Refining a search
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.
ANN vs exact search
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:
- Explicit table name (if provided)
- Latest ready embedding table for the source (by
created_at) - 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: callEncodeAudioQuery(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
searchfor the neighbours of specific rows. Usebuild_neighbor_graphonly 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:
| Column | Type | Meaning |
|---|---|---|
src | Utf8 | source node — the source key |
dst | Utf8 | neighbour node — the source key |
rank | Int32 | 1 = nearest, … k |
similarity | Float32 | 1.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 n² 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 to0.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
| Weighting | Aggregation | Use |
|---|---|---|
DegreeNormalized (default) | symmetric  = D̃^{-1/2}(A+I)D̃^{-1/2}, with the α-teleport | the safe default (SGC/APPNP) |
Uniform | random-walk mean D̃^{-1}Ã (each node = mean of itself + neighbours) | unweighted graphs, simplest smoothing |
EdgeSimilarity | edge-weighted mean Σ(w·x)/Σw | use 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
set — C = {(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, orNonefor 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 requestedvalue_columnsof 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_selfdefaults on. Setexclude_keyto 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 belowk.splitscopes 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), andf64+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;fanoutbounds the neighbours sampled per node per hop. The sample is seeded-deterministically from the target, so a gather reproduces byte-identically.Noneis 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 heterophilous — cites 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:
searchis the bounded, jammi-defined primitive: nearest-neighbor top-k with optionalfilter/select, returning a table directly. Same call, same shape, embedded or remote.- Compound query — open, caller-shaped composition (
join/filter/selectand model inference over the results) — rides SQL. In Rust the fluentQueryBuilder(returned bysession.search(...)) builds the same plan in-process; in Python and over the wire the surface isdb.sql(...), where theannotate(...)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:
| Scenario | retrieved_by | annotated_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
WHEREover 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
taskstring ('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
rankerasInt32—add_columnsrejects withJammiError::ChannelCatalog(ChannelCatalogError::ColumnConflict { … }), whose message is"channel 'scored_by': column 'ranker' was declared Utf8, cannot redeclare as Int32". From Python, the same call raisesRuntimeErrorcarrying 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_columnsrejects withJammiError::ChannelCatalog(ChannelCatalogError::ColumnAlreadyDeclared { … }), whose message is"channel 'scored_by': column 'ranker' already declared". -
Drop
rankerfrom the channel — there is nodrop_columnmethod 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
| Field | Default | Description |
|---|---|---|
lora_rank | 8 | Low-rank dimension |
lora_alpha | 16.0 | Scaling factor |
lora_dropout | 0.05 | Dropout probability |
learning_rate | 2e-4 | Base learning rate |
epochs | 3 | Training epochs |
batch_size | 8 | Micro-batch size |
max_seq_length | 512 | Max tokens per text |
gradient_accumulation_steps | 1 | Steps before optimizer update |
validation_fraction | 0.1 | Holdout fraction for early stopping |
early_stopping_patience | 3 | Epochs without improvement before stopping |
warmup_steps | 100 | Linear warmup from 0 to base LR |
lr_schedule | CosineDecay | Decay after warmup: Constant, CosineDecay, LinearDecay |
embedding_loss | auto | CoSent (pairs+scores), Triplet, MultipleNegativesRanking |
backbone_dtype | f32 | Frozen-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
- The base encoder model (BERT, ModernBERT, etc.) is loaded and frozen
- A LoRA projection layer (identity + low-rank A/B matrices) is added after pooling
- For each batch: text is encoded, projected through LoRA, and loss is computed
- Only the A/B matrices receive gradients
- The adapter is saved as
adapter.safetensorsin 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:
| Architecture | Task | Common target_modules |
|---|---|---|
| BERT / RoBERTa / CamemBERT / XLM-RoBERTa | text | ["query", "value"] (recommended) or ["query", "key", "value", "dense"] |
| DistilBERT | text | ["q_lin", "v_lin"] or ["q_lin", "k_lin", "v_lin", "out_lin"] |
| ModernBERT | text | ["Wqkv", "Wo"] (fused QKV + output) |
| OpenCLIP text tower | text_embedding | ["in_proj", "out_proj"] (attention) or ["in_proj", "out_proj", "c_fc", "c_proj"] |
| OpenCLIP vision tower | image_embedding | the same four names |
| HTSAT-CLAP audio tower | audio_embedding | ["query", "value"] or ["query", "key", "value", "attention_output", "intermediate_dense", "output_dense"]; plus ["reduction"] (patch-merging) and ["linear1", "linear2"] (projection head) |
| Any encoder | any | ["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— overridelora_rankfor 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_modulesis 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
patienceepochs, 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_secsretires 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; abf16backbone on a non-CUDA device is refused before training starts, rather than silently falling back tof32. (2) A smallerbatch_size, or trade batch size forgradient_accumulation_stepsto hold the same effective batch size while shrinking the per-step activation memory. (3) A smallermax_seq_length. - Projection head (default):
backbone_dtypedoes not apply and is omitted from the message — the message says so outright. (1) A smallerbatch_size, or trade batch size forgradient_accumulation_steps. (2) A smallermax_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
| Knob | Effect |
|---|---|
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_negatives | Structure-mined hard negatives per pair. 0 = in-batch only. |
exclude_hops | Hops of the anchor’s neighbourhood excluded from its negatives (false-negative guard). |
min_negatives | Minimum 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:
- Build two supervision graphs over the same nodes — one from declared edges, one from S9-similarity edges.
fine_tune_grapheach; hold out a golden relevance set.eval_embeddingsthe base model vs each fine-tune, with a paired significance test.- 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
| Column | Type | Required |
|---|---|---|
query_id | Utf8 | yes |
query_text | Utf8 | yes |
relevant_id | Utf8 or Int | yes |
relevance_grade | Int32 | no (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
| Metric | What it measures |
|---|---|
recall_at_k | Fraction of relevant docs found in top-k |
precision_at_k | Fraction of top-k that are relevant |
mrr | Reciprocal rank of the first relevant result |
ndcg | Normalized 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
- Baseline. Produce a plain text-embedding table with
generate_text_embeddings. - Treatment. Produce each structure-aware table (a fine-tuned model’s embeddings, a propagated table, etc.) over the same source rows.
- Compare. Run
eval_comparewith the baseline table first. Read the per-metric delta and its paired significance. - 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_upperare 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_valueis 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:
| Judgments | Metric | Why |
|---|---|---|
Graded (relevance_grade > 1) | nDCG | Discounted cumulative gain uses the grade; recall would discard the ranking signal. |
| Binary (relevant / not) | recall@k, MRR | No 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 and Compare Models — the golden-set schema, the
metric definitions, and the full
eval_compare/eval_embeddingsAPI. - Fine-Tune for Your Domain — producing a trained treatment table to evaluate with this recipe.
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
| Column | Type | Required |
|---|---|---|
record_id | Utf8 | yes |
mean | Float / Int | yes |
sd | Float / Int (positive) | yes |
outcome | Float / Int | yes |
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
| Column | Type | Required |
|---|---|---|
record_id | Utf8 | yes |
draws | Utf8 (JSON array of numbers) | yes |
outcome | Float / Int | yes |
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
- The
Publisherminted the next monotonic offset for the topic (seeded lazily fromMAX(_offset)on the backing table the first time the topic is touched). - The augmented batch — user columns plus
_offset,_row_idx, and_produced_at— was inserted into the topic’s backing mutable table inside oneCatalogBackend::transaction. On commit the offset advances; on rollback it is reused for the next attempt so no gaps appear in the log. - 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 with a SQL Predicate Filter —
open a subscription with a
WHERE-style predicate and consume the matching batches. - Replay Events from the Backing Table — run ad-hoc Flight SQL over the durable event log.
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:
| Supported | Rejected |
|---|---|
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 NULL | CASE 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
- Publish Events to a Topic — the publisher side.
- Replay Events from the Backing Table — bypass the broker entirely and read the event log via Flight SQL.
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:
| Column | Type | Purpose |
|---|---|---|
_offset | BIGINT NOT NULL | Monotonic offset; stable across rows of one publish. |
_row_idx | BIGINT NOT NULL | Position within a publish, for the composite PK. |
_produced_at | BIGINT NOT NULL (UTC microseconds) | Publisher-side timestamp, single value per offset. |
| …user cols… | per TopicDefinition.schema | Payload columns. |
tenant_id | TEXT (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
- Publish Events to a Topic — the publisher side.
- Subscribe with a SQL Predicate Filter — the live-tail surface for these same events.
- Register a Mutable Companion Table — the substrate the backing table reuses; same schema validation, same federation properties.
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:
- Bind a tenant to a session in Rust, Python, and on the CLI.
- Verify that two sessions on the same process see disjoint rows.
- Bind a tenant on a remote client via the gRPC
CatalogServiceso 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.
- 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 returnsErr(Status::unauthenticated(..))here, before any handler runs. - 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)). - The engine binds it. The async
TenantResolverLayermaps the resolved scope onto theSessionTenantrequest extension every verb handler reads, and the Flight SQL provider (TenantBoundProvider) binds the same scope fordb.sql— you write onlyresolve.
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:
- Read each other as invisible:
list_sources()returns the calling tenant’s sources plus any globally-scoped (tenant_id IS NULL) sources. - Write into different lanes: a
register_sourcefrom Alice produces a row taggedtenant_id = alice; Bob’slist_sourcesdoes not see it. - 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 injection —
TenantScopeAnalyzerRuleinjectstenant_id = $current OR tenant_id IS NULLon everyTableScanwhose 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_idcolumn for the analyzer to filter on. The tenant-gating result-table schema provider instead gates resolution on the catalog owner: over every lane that namesjammi.{table}(Flightdb.sql, gRPCsql, 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 callsTransaction::assert_tenant_matchesbefore 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_idcolumn — Jammi’s analyzer rule has no column to inject against, so those sources show every row to every tenant unless the source declaration registers atenant_columnoverride. Catalog tables and mutable companion tables always carry the column. - Cross-tenant
WHEREclauses the caller writes by hand — a query that containsWHERE 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
- The discipline test in
SPEC-03 Register a Mutable Companion Tablefor how a mutable companion table also honours the tenant binding on write
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:
- Register a federated source whose tenant discriminator is named
differently from
tenant_id. - Tell the analyzer rule which column to use.
- Verify two tenants get disjoint rows from the same physical source.
- Recognise what
set_source_tenant_columndoes 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
JammiSessiononly. 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_columnat 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 aDataFusionError::SchemaError. The override is a trust contract — the engine does not validate the column’s presence at registration time. - You cannot mix
tenant_idand a non-tenant_idcolumn on the same source. When the source’s schema already declarestenant_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 onnotes.public.noteswill 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
- Scope a Session to a Tenant — the broader session-binding recipe this one extends.
- Register a Mutable Companion Table —
for sources Jammi owns, the
tenant_idcolumn is emitted by default.
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:
| Field | Meaning |
|---|---|
artifact | The in-toto subject — SHA-256 over the Parquet object’s bytes. The thing a verifier matches by digest. |
leaves | The 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_hash | SHA-256 of how the table was produced — the descriptor plus the environment (see below). |
input_anchors | The immutable state pointer of each input, in producer order. |
produced_by | The producing-run id — provenance, never the reproducibility anchor. |
produced_at | The producing instant, RFC3339 — provenance, never the anchor. |
engine_version | The engine semantic version that produced the artifact. |
manifest_version | The 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:
ProducingDescriptor— how 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, orContextSet. 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:
- 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;
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.jsonsidecar;- flip the catalog row
building → readyby a compare-and-set naming the writer (recording thedefinition_hashand the input anchors as summary columns, and clearing the lease); - 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
| Verdict | Meaning |
|---|---|
Match | The 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. |
MissingManifest | No 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 → readyflip. The contract forbids promoting a table without an attestation, and the producing descriptor cannot be reconstructed after the fact, so the row is driven tofailedby 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
readytable whose manifest has since vanished is reaped. This isreconcile_ready_manifests, whichrecover()also runs (across every post-contractreadyrow — one whose catalogdefinition_hashis set, so it was promoted under the contract): its.materialization.jsonbeing absent is a corruption, since the attestation a verifier would read is gone. Such a row is driven tofailedby 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
- Gates. The table must be
ready, produced by an embedding pipeline, and carry_content_hash(a table imported or produced before the column existed isNotRefreshable { reason: missing_content_hash }— runrecomputeonce 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 isDefinitionDrift { table, recorded, current }— nothing is allocated, and the consumer’s remedy isrecompute. - Base version. A never-refreshed table is first published as its base
version, whose identity is the table’s existing
.materialization.jsonartifact digest, so publishing the base changes no downstream anchor. - 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 classifiedadded,changedorunchanged; a current key the source no longer has isdeleted(see the delete policy below). - Nothing to do →
outcome: no_change, no version allocated, nothing written, every downstream anchor untouched (a dependent staysFresh). - 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.
- 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. - Publish. The version’s manifest is written and one catalog transaction
flips the version
building → readyand moves the table’scurrent_versionforward. 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
| Field | Meaning |
|---|---|
version | The published version (published), or the current one (no_change) |
parent_version | The version the diff was computed against |
inferred_rows | Rows the model actually ran on (added + changed) |
added / changed / deleted / unchanged | The classification of the source’s keys |
dropped_rows | Rows asked for that the model did not realize (a per-row input failure — an empty text on the candle backend) |
live_rows | Rows a reader sees after the publish |
masked_rows | Physical rows in the version’s fragments hidden by the mask |
outcome | published 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 staysD(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
klive 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_idalways projected, soCOUNT(*),SELECT vectorandLIMIT nall 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 }, andrecomputeis the remedy. - A never-refreshed table is byte-identical to today’s: no mask, no
union,
read_vectorsreads 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 byResultStore::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
| Error | Meaning | Remedy |
|---|---|---|
InvalidKey { column, null_count } | Null keys on the source | Fix the source |
NonUniqueKey { scan, keys, total } | Duplicate keys on the source or the parent | De-duplicate; recompute a non-unique parent |
DefinitionDrift { recorded, current } | The environment would produce a different definition | recompute |
NotRefreshable { reason } | No content hash, not ready, not an embedding table, or the current version is unavailable | recompute |
VersionUnavailable { table, version } | The current version’s manifest cannot be resolved | recompute |
ParentMoved { .. } | Another refresh already published against this parent | The 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:
| Source | Feature flag |
|---|---|
| PostgreSQL | postgres |
| MySQL | mysql |
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
| Type | Description | Status |
|---|---|---|
File (file://) | Parquet, CSV, JSON, JSONL on local disk | Always available |
File (s3:// / gs:// / azure://) | Same formats over cloud object stores | Feature-gated — see Cloud Storage |
| PostgreSQL | Any PostgreSQL-compatible database | Available |
| MySQL | MySQL / MariaDB | Available |
| SQLite | SQLite databases | Not 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:
| Feature | Schemes it enables |
|---|---|
storage-s3 | s3:// (AWS S3 and S3-compatible: MinIO, LocalStack) |
storage-gcs | gs:// |
storage-azure | azure://, abfss:// |
storage-r2 | r2:// (Cloudflare R2 — the S3 driver with R2’s endpoint + region derived) |
storage-cloud | All 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 var | Used for |
|---|---|
AWS_ACCESS_KEY_ID | S3 / R2 access key id |
AWS_SECRET_ACCESS_KEY | S3 / R2 secret access key |
AWS_SESSION_TOKEN | optional STS session token (S3) |
AWS_ENDPOINT | optional S3 endpoint override |
AWS_REGION | optional 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:
| Tier | Service | Role |
|---|---|---|
event | TriggerService | topic / publish / subscribe streams |
eval | EvalService | per-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:
| Gauge | Present on | Source |
|---|---|---|
jammi_jobs_queued{kind} | worker-enabled processes | the 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 processes | the same sample |
jammi_worker_jobs_in_flight | worker-enabled processes | loop-claimed jobs running under a live lease hold (0 or 1); an inline run_now is never counted |
jammi_worker_claim_loop_up | worker-enabled processes | 1 while the claim loop task runs, 0 once it stopped, aborted or failed |
jammi_lease_heartbeat_age_seconds | every process | seconds 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
| Operation | Available over Flight SQL? | Available over typed gRPC? |
|---|---|---|
| SQL queries on source tables | Yes | — (use Flight SQL) |
| SQL queries on embedding tables | Yes | — (use Flight SQL) |
| Joins, aggregations, filters | Yes | — (use Flight SQL) |
| Generate embeddings | No — use library or Python package | Yes — EmbeddingService.GenerateEmbeddings |
| Semantic vector search | No — use library or Python package | Yes — EmbeddingService.Search |
| Inference | No — use library or Python package | Yes — InferenceService.Infer |
| Fine-tuning (and graph / context-predictor training) | No — use library or Python package | Yes — JobService.SubmitJob (core; runs where [worker] enabled) |
| Context-predictor prediction | No — use library or Python package | Yes — InferenceService.Predict |
| Evaluation | No — use library or Python package | Yes — 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
jobstable costs one attempt (attempts + 1,releasesuntouched) within one[lease] duration_secs; the linkedbuildingrow lives inresult_tables, which carries noattempts/releasescolumns 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 thatUPDATE’s own commit is what the sweep’sSomecount 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 == falsemeans precisely that such a claim is not ruled out, and a claim like that is outside the sweep’s predicate, keeping a live lease withreleasesuntouched 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
TenantResolverseam itself: one resolver, plugged intoassemble_grpc_chainonce, authenticates both the gRPC control plane and the Flightdb.sqllane.
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-server— CPU, built from a distroless base.ghcr.io/f-inverse/jammi-ai-server-cu12— CUDA, 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__SERVICES—allhere (every compiled-in service tier); see Service tiers for narrower selections.JAMMI_AUDIT_MASTER_KEY— read fromdeploy/.env(copydeploy/.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 makesjammi-serverrefuse 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_bindset, 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 leftrunningfor reclaim (an attempt spent), never run in-process on the claiming pod. - The compute tier’s
StatefulSetpods (jammi-server-compute) each host a Ballista EXECUTOR ([ballista.executor]pointed at the scheduler’s Service) alongside their own[worker] enabled = trueclaim 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’sworldis 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_bindunset — 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_bindmakes a replica an owner (and a gang admission member). Setting[server] peer_bind(JAMMI_SERVER__PEER_BIND) opens the internal listener serving bothPeerService(segment search) andGangService(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_rooton an object store every replica reads (a localartifact_diris one node’s). Placement — which replica owns which segment — is derived at query time from the live replica ring ([server] peer_advertise+ the catalog’sinstancesrows, 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 explicitStaticPlacementthroughInferenceSession::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 until3 × 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 (theinstancesrow and, if this process runs a claim loop, itsworkersrow) in one transaction.instances.result_rootrecords the configured root SPELLING verbatim, but neitherlist_gang_members(the gang-membership listing verb, contractfeat_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:
- Restore (or roll storage forward/back to) the storage snapshot first.
- 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.
- 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 reconcilewithapply=falseFIRST and review theorphans/pending/rows_failedlists by hand before ever passing--apply— the dry run is free and mutates nothing;--applyis the step that can turn a merely stale-looking object into a permanently deleted one. Once reviewed,--applyflips areadyrow whose required objects are missing tofailed, and reclaims any storage object no live row references. See Catalog Backend and Trigger Broker → Multi-writer safety for whatreconcilechecks 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
buildingrow still present in the catalog; an object written but never inserted as a row at all isreconcile’s job, not recovery’s); - before decommissioning a tenant’s data, as a dry run to confirm what a
cross-tenant
--allpass (gated behind anAdminAuthorizer— 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:
| Endpoint | Meaning | Status |
|---|---|---|
/healthz | Liveness — dependency-free 200 with the build version. The process is up and serving. | 200 |
/readyz | Readiness — pings the catalog backend the session is bound to. Use it for load-balancer admission. | 200 ready / 503 not ready |
/metrics | Prometheus 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:
| Metric | Type | Incremented by |
|---|---|---|
jammi_grpc_requests_total | counter | Any /jammi.v1.* gRPC request. |
jammi_flight_queries_total | counter | A Flight SQL DoGet query. |
jammi_eval_invocations_total | counter | An EvalService/* RPC. |
jammi_search_latency_seconds | histogram | End-to-end EmbeddingService/Search request latency. |
jammi_grpc_refused_total{reason} | counter | A request refused by [server.limits] — reason ∈ message_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} | counter | One outcome of the placed-search failure ladder on THIS replica as a coordinator — reason ∈ deadline, 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} | counter | A PeerService request served on THIS replica’s [server] peer_bind listener as an owner — rpc ∈ SegmentSearch, 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} | counter | A 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) carriesworker_id,job_id, andtenant_id.run_spec(the training run inside a claimed job) carriesjob_idandworker_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.
| Knob | Refusal | reason label | Notes |
|---|---|---|---|
max_message_bytes (default 64 MiB) | OUT_OF_RANGE | message_size | Enforced 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_EXHAUSTED | in_flight | Global, UNARY methods only. 0 = unbounded. |
max_in_flight_per_connection (default 64) | RESOURCE_EXHAUSTED | in_flight_per_connection | Per TCP connection, UNARY methods only. 0 = unbounded. |
request_timeout_secs (default unset) | DEADLINE_EXCEEDED | timeout | UNARY methods only; unset means no server-imposed timeout. |
wait_timeout_secs (default unset) | DEADLINE_EXCEEDED | timeout (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_EXHAUSTED | subscriptions | Concurrently open TriggerService.Subscribe streams; released when the stream ends or the client disconnects. 0 = unbounded. |
max_job_waits (default 1024) | RESOURCE_EXHAUSTED | job_waits | Concurrently 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
BuildingTablebetweencreate_tableandfinish. - 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
buildingresult 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
| Failure | Observed behavior | Recovery mechanism | Signal (metric/log) | Proving test |
|---|---|---|---|---|
| Storage dies mid-publish | No 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 dies | The 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 queued | A 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 dies | — | Memory-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 dies | The 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 table | No 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 table | The 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 materialization | The 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 oneexists()per post-contractreadyrow (definition_hash IS NOT NULL) — it checks only that row’s.materialization.jsonsidecar is still present, never a full object-storeLIST. 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 offTenantSegment::parse, which only recognizes{seg}/{table}.parquetandmodels/{seg}/{job}/…shapes; an older flat key never round-trips through that parser, so it is reported asunattributedand never deleted at anygrace— 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.unattributedis 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’sgracegate compares two DIFFERENT clocks. The age check (is an orphan candidate old enough to reclaim?) readslast_modifiedoff the OBJECT STORE’s own clock and compares it against the replica runningreconcile’sUtc::now()— never the catalog database’s clock the way a lease predicate does.apply=truerequiringgrace >=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 — setgraceto several multiples of the lease duration as the practical guard against both a slow writer and clock skew together.delete_modeldeletes the catalog row only — it never touches the artifact bytes atmodels/{seg}/{job_id}/…, and it refuses (JammiError::ModelReferenced) while another row still references it. A specific{model_id}:epoch_Ncheckpoint row is independent of the model’s own row: deleting one leaves the other’s row and bytes untouched. An artifact prefix nomodelsrow names any longer becomes areconcileorphan candidate on the next pass, aged againstgracelike 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 thebuildinginsert recovery later reconciles) that committed before the crash is present after it. No committed catalog state is lost. - Host power loss: Postgres commits synchronously (
fsyncper commit by default), so a committed transaction survives. SQLite runssynchronous=NORMALunder 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:
- 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.tomlunderdirectories::ProjectDirs::from("ai", "jammi", "jammi").config_dir()— e.g.~/.config/jammi/config.tomlon Linux). - Environment variables —
JAMMI_GPU__DEVICE=0,JAMMI_INFERENCE__BATCH_SIZE=64. - 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 TOML —
JAMMI_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 → Connections → Security 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 usesgcloud 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 choosingverify-fullfor it; either way, the URL must still namesslmodeexplicitly (per the rule above), so aPGSSLMODEleft 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 ofNAME(load: the process environment viastd::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
${returnsJammiError::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
| Concern | SQLite | Postgres |
|---|---|---|
| Operational footprint | One file under artifact_dir. No daemon. | Externally-managed Postgres cluster. |
| Concurrent writers | One; WAL mode lets many readers run alongside one writer. | Many. |
| Multi-process deployment | Single-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 recovery | File restore from backup. | Standard Postgres point-in-time-recovery. |
| Pool tuning | None — 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
| Concern | InMemory | Postgres | JetStream |
|---|---|---|---|
| Persistence | In-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 delivery | None — 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. |
| Auth | None. | Whatever [broker.postgres] url (or the catalog’s) already authenticates with. | Anonymous or NATS .creds file contents via credentials. |
| Operational footprint | None. | 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
| Format | On-disk file | Stability stamp | Reject semantics on load |
|---|---|---|---|
| Materialization manifest | .materialization.json | manifest_version (u32) | Exact-version — found != 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 | .rowmap | leading u32 version header | Reject-newer — found > ROWMAP_VERSION → JammiError::IncompatibleFormat { artifact: "rowmap", .. } |
| ANN sidecar manifest | .manifest.json | version (u32) | Reject-newer — found > ANN_MANIFEST_VERSION → JammiError::IncompatibleFormat { artifact: "ann-manifest", .. } |
| ANN binary threshold companion | .threshold | none embedded — required whenever the sidecar manifest’s scalar_kind is Binary, confirmed by the manifest’s binary_threshold_kind field | Fail-loud, not versioned — a missing binary_threshold_kind, a missing file, or a byte length not matching dimensions f32s → JammiError::Other |
| USearch ANN graph | .usearch | backend_version stamped in the sidecar .manifest.json | Strict — any mismatch with the linked USearch → JammiError::IncompatibleFormat { artifact: "usearch-index", .. } |
| Lexical (BM25) index | tantivy index dir | tantivy’s own format tag | Library-loud — tantivy’s Index::open fails with IncompatibleIndex, surfaced as JammiError::Lexical |
| Result-table data | .parquet | none embedded — its format-of-record version is the .materialization.json manifest_version | Schema-shape checked at read via JammiError::Schema; byte integrity caught by verify_materialization |
| Version manifest (a refreshed embedding table) | {table}__v{N}.version.json | version_format (u32) | Reject-newer — found > VERSION_FORMAT → JammiError::IncompatibleFormat { artifact: "version-manifest", .. } |
| Deletion mask | {table}__v{N}.deletes.parquet | none embedded — its format-of-record version is the version manifest’s version_format | Schema-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}.parquet | none embedded — same rule as the base Parquet | Pinned 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
.rowmapand ANN.manifest.jsonare 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 typedManifestError::Serde; the one older shape the reader names on its own (an object at the current version with noleaves) 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:
version, reject-newer. Aversiongreater thanANN_MANIFEST_VERSIONisJammiError::IncompatibleFormat { artifact: "ann-manifest", .. }.backend_version, strict. The stamped USearch version is compared for exact equality against the linkedjammi_db::index::backend_version(). Any mismatch isJammiError::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 toFixedSizeList<Float32>; a missing column, a wrong Arrow type, or a non-Float32inner type is a typedJammiError::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 byverify_materializationrecomputing 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 wholevectorcolumn of an embedding table into oneVec<f32>per row.Session::read_vector_by_key— extracts a single row’svectorby its_row_id(the resolver behindsearch_by_id’s query-by-example path).store::register_parquet_table— registers a Parquet URL as a DataFusion table underjammi.{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_VERBS | add_source, generate_embeddings, encode_query, search, sql, list_sources, describe_source, set_tenant, tenant_scope, tenant, get_server_info |
_TRAINING_VERBS | fine_tune, fine_tune_graph, train_context_predictor, predict_with_context_predictor, training_job, list_training_jobs |
_INFERENCE_VERBS | infer |
_PIPELINE_VERBS | build_neighbor_graph, propagate_embeddings, asof_join, assemble_context, recompute, verify_materialization, staleness, derives_from |
_EVAL_VERBS | eval_embeddings, eval_per_query, eval_inference, eval_compare, eval_calibration |
_CHANNEL_VERBS | register_channel, add_channel_columns, list_channels |
_NUMERIC_VERBS | conformalize, conformalize_interval, conformalize_cqr, rrf_fuse |
_MUTABLE_TOPIC_VERBS | create_mutable_table, drop_mutable_table, list_mutable_tables, register_topic, drop_topic, list_topics, publish_topic, subscribe_collect |
_LIFECYCLE_VERBS | list_models, describe_model, delete_model |
_SEGMENT_VERBS | list_index_segments |
_SEARCH_VERBS | search (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):
| Package | Surface |
|---|---|
jammi.v1.audit | provenance / audit log rpcs |
jammi.v1.catalog | sources, models, channels, tenant, server-info, mutable tables, topics |
jammi.v1.embedding | embedding generation, query encode, search |
jammi.v1.error | the typed wire-error message (no rpcs) |
jammi.v1.eval | the evaluation rpcs |
jammi.v1.gang | the 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.inference | bulk inference + predict |
jammi.v1.job | the durable job queue: submit / status / wait / list / cancel / list-workers / prune (JobService) |
jammi.v1.lifecycle | license apply / bootstrap / status / login — contract-only, answered by a platform server (the OSS engine returns UNIMPLEMENTED) |
jammi.v1.peer | the 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.pipeline | graph / context / as-of / recompute / materialization rpcs |
jammi.v1.training | the 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.trigger | topic 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:
| Format | Stamp | Current version |
|---|---|---|
Materialization manifest (.materialization.json) | MANIFEST_VERSION | 3 |
ANN row map (.rowmap) | ROWMAP_VERSION | 1 |
ANN sidecar manifest (.manifest.json) | ANN_MANIFEST_VERSION | 3 |
| Catalog schema | append-only migration ledger | see 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 removedjammi.v1.*package, is breaking — major only, or ajammi.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 (StartTraining→SubmitJob,
TrainingStatus→JobStatus, ListTrainingJobs→ListJobs, 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::ValidatedQueryis the only typejammi_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 itsquery_vectorfield), andjammi_ai::pipeline::neighbor_graph::Node’svectorfield accept for a query vector.jammi_db::index::segment::verify_query_width(a freepub fn) is removed with no replacement — its check is nowValidatedQuery::require_width/require_authority_width, methods on the type itself, not a function a caller could import. Construct aValidatedQuerywithjammi_db::index::validate_query(values, expected_width, source)(re-exported fromjammi_numerics::query, along with the newjammi_numerics::query::QueryValidationErrorerror type), wheresourceis ajammi_db::index::QuerySource::{Caller, Stored { table }}. Its inherent methods areas_slice,into_inner,source, and the two width checks below.exact_vector_searchalso gained acatalog_dimensions: Option<usize>parameter (a cross-check against the scan’s own width;Nonewhen there is none on record).jammi_db::index::peer::{SegmentSearchRequest, ExactRescoreRequest}’squeryfield is aValidatedQuery, not aVec<f32>;PeerFailureReasongained theCallerFaultvariant,PeerErrorgained amessage: Stringfield, andPEER_FAILURE_LABELSis[&str; 10]. jammi_db::catalog::result_repo::ResultTableRecord::dimensionsis a method, not a field. It returnsOption<std::num::NonZeroUsize>— a non-positive stored value and an absent one are bothNone.dimensions_raw() -> Option<i32>returns the stored column verbatim, for a caller that must round-trip it unfiltered. A caller that built aResultTableRecordfield-by-field from outside this crate now goes throughResultTableRecord::from_wire_projection, the crate’s sole cross-crate constructor.jammi_wire::peer::{phase_from_proto, precision_from_proto}return aProtoEnumDecode<T>, not anOption<T>. The wire’s explicit “not set” (ProtoEnumDecode::Unspecified) and a raw value this build’s generatedenumhas no variant for (ProtoEnumDecode::Unknown) are no longer collapsed into oneNone— 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_widthnow takes anartifact: impl Into<String>and returns a new error variant,QueryValidationError::ArtifactMismatch { artifact, expected, actual }, which carries noQuerySourceat 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, andexact_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 newValidatedQuery::require_authority_widthinstead, which keeps the OLDrequire_widthbehaviour under a name that says why it is different.QuerySource::source()onQueryValidationErrornow returnsOption<&QuerySource>(NoneforArtifactMismatch) rather than&QuerySourceunconditionally. (DIST round 6/7 shipped a thirdQuerySourcevariant,Artifact { name }, to carry this same information; round 8 removed it in favor of the dedicatedArtifactMismatcherror variant above, soQuerySourceis back to exactly two variants,CallerandStored— the states a query’s own provenance can actually be. A round-6/7 caller matching on threeQuerySourcevariants needs the arm removed, not added.)JammiError::Schemaconstructions injammi_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}, andjammi_db::store::deletes:: DeletionMask::readthat priced an ENGINE-owned artifact’s own corruption as the caller’s fault are nowJammiError::IncompatibleFormat; the twostore::vectorsfunctions changed their error type to the new, provenance-neutraljammi_db::store::vectors::VectorColumnError(?converts it toIncompatibleFormatby default;.into_caller_fault()is the explicit override the one caller-supplied read path, behindimport_embeddings, now uses). jammi_db::store::ResultStore::result_digest_anchoris removed with no replacement. It resolved a result table’s current version and then discarded the resolution, returning a bareInputAnchora caller could not get the matching content back from without a second, independent resolve — a version publish landing between the two could straddle. CallResultStore::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 wentpub→ private/pub(crate)entirely within this epic’s own unreleased commits,current_version_providerandcurrent_version_identity, never shipped aspubin 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, theapi_freezemodule). It decodesFILE_DESCRIPTOR_SETinto the live(Service, Method)rpc set and the livejammi.v1.*package set, and asserts they equal a committed frozen baseline; it also assertsMANIFEST_VERSIONequals 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.
| Defence | Mechanism | Traces to |
|---|---|---|
| Format-version reject-newer | A persisted artifact stamped with a version newer than this build knows is a typed rejection, never a silent misparse into wrong data | manifest.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 query | The 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-found | tenant_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 surfaces | Failures 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::IncompatibleFormat | the 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 job | the 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 transport | Unlike 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 verb | grpc/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
SessionIdTenantResolverreads thejammi-session-idheader and binds the tenant the caller asserts (or the explicitGlobal/unscoped scope when none is bound) — it verifies nothing about who the caller is. A deployment that needs authentication supplies its ownTenantResolverat 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-idis 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_bindlistener 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_bindand[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_bindis a jammi coordinator. The owner handler trusts the channel: the request carries no tenant, the owner binds none and reads noresult_tablesrow. 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
Searchresolved 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 byGetServerInfo; the public listener answersUNIMPLEMENTEDfor/jammi.v1.peer.PeerService/*(proven by the tenant-isolation oracle). - Binding
peer_bindon 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_bindis 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
RunRankcall 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 OWNtenant_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 onpeer_bindholding 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. Aworld_size > 1session runs its rank body (trained over the session’s own stream as a rank of the gang) and ends with that body’s oneOutcome; aworld_size == 1session has no body to run, parks, and endsNoBodyafter 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’snow()) — 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
PeerServiceoutsideassemble_grpc_chain, never wrapped by the tenant-binding layer, never advertised byGetServerInfo; the public listener answersUNIMPLEMENTEDfor/jammi.v1.gang.GangService/*(proven by the tenant-isolation oracle). - Binding
peer_bindon 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_membersnarrows to is a catalog-local read, not an RPC. A row is a member of a listing iff: it is NOT the caller itself; itsworkersrow (an INNER join — a member is a fleet worker with a claim-loop slot, not merely a live process) hasstate = 'claiming'(warming/drainingexcluded);workers.kindscontains the requested kind as a whole, comma-split, trimmed token (never a substring —fine_tunenever matchesgraph_fine_tune);instances.peer_addris non-NULL; andlast_seen_atis fresh underinstance_liveness_margin(lease)(2 × lease) on the DB clock; and itsinstances.result_root_identityEQUALS the caller’s own (unit U5b-1a-A2).peer_addr_ofis 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_rootcarriesresolved_result_root()VERBATIM (sogcs://b/pandgs://b/premain two different STRINGS in that column), andresult_root_identitycarries that root’s identity, derived byMemberRoot::resolvedfrom 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/workersrows 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, andlist_gang_members/peer_addr_ofreturn 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 (
JammiCodecwrites 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 answersUNIMPLEMENTEDforSchedulerGrpc, the same way it does forPeerServiceandGangService. - 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
tlscargo 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] tlskey, 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 disciplineJammiConfigitself carries at its top level (crates/jammi-db/src/config/mod.rs:204). A[server] tls = …stanza in a config file, andJAMMI_SERVER__TLSin the environment, are not silently ignored — each is a typedJammiError::Configstartup refusal, naming the unrecognised key. - There is no engine-side certificate to hand the
TenantResolverseam. 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--targetrefusesgrpcs://andhttps://with a typed error naming the accepted schemes (crates/jammi-cli/src/main.rs:170-187; the CHANGELOG’s “dropgrpcs://andhttps://as accepted--targetschemes” entry (#480), commit616bb6d4) rather than advertising a transport it cannot speak — put a TLS-terminating proxy in front and point--targetat it in plaintext (grpc:///http://). This is an asymmetry between the two clients: the Python SDK’sRemoteTargetlegitimately keepsgrpcs:///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.1 —
reverse_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):
- 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 returnsErr(Status::unauthenticated)here, before any handler runs. - 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)). - The engine binds it. The async tenant-binding layer maps the resolved
scope onto the
SessionTenantrequest extension every verb handler resolves, andTenantBoundProviderbinds it for Flight — the consumer writes onlyresolve.
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
| Lane | Trigger | Blocking? | Purpose |
|---|---|---|---|
ci.yml (workspace tests) | every PR | yes | Gates 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.yml | nightly schedule: + workflow_dispatch | no (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 tag | yes | The 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.
| Verb | Bench tier | Named scale | Committed baseline | Threshold | Gated quantity |
|---|---|---|---|---|---|
fine_tune | train-scale | 1 536 in-batch-negative pairs, one GradCache backward + AdamW step, Device::Cpu | 180.0 pairs/s | 30% rel. drop | throughput (pairs/s) |
fine_tune_graph | graph-train-scale | 8 communities × 64 nodes, biased-walk sampler (walk length 4, 4 walks/node) | 6 418.1 pairs/s | 30% rel. drop | sampled-pairs/s throughput (+ a portable determinism digest) |
train_context_predictor | context-predictor-scale | CNP over 8 tasks × 18 rows, 30 epochs | 21.29 episode-steps/s | 30% rel. drop | meta-training throughput (+ a same-box predict digest) |
generate_embeddings | model-inference-scale | 16 rows over a tiny 32-dim 1-layer BERT bundle, Device::Cpu | 333.6 rows/s | 30% rel. drop | coarse serving throughput (+ a same-box embed digest) |
infer (classification) | model-inference-scale | 16 rows over a tiny 32-dim 1-layer ModernBERT classifier bundle, Device::Cpu | 207.0 rows/s | 30% rel. drop | coarse serving throughput (+ a same-box infer digest) |
search + build_neighbor_graph | arxiv | 2 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:
| Property | Value |
|---|---|
| Logical CPUs | 8 |
| Total RAM | 31 720 MiB (~31 GiB) |
| Profile | release |
| Engine version when committed | 0.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.