Skip to content

JMFTS — Reference

Cognition × Application

Table schema, query API, scoring parameters, and the pgvector index definitions.

Draft

Built for lookup, not narrative reading. Endpoint tables give verb, path, and purpose — not every parameter; a running instance's /openapi.json (or /docs) is the exact contract. Tables are checked against schema.sql, jmfts_core/config.py, and the @expose registry that generates the routes.

Ingestion is ahead of the release

The queued ingestion system — task_queue, document_blobs, documents.settled, the /ingest family, ingest options, and the worker fleet — is not in the public 0.1.0 release and not on master. It is 26 commits on the feat/ingest-lifecycle branch.

Everything else here — search, triples, graph, view, access — is 0.1.0. Sections covering unreleased ingestion say so in their own note. Clone the branch, not the tag, if you want to run any of it.

Defaults come from the code, not from .env.example

Settings tables quote the built-in default in jmfts_core/config.py. .env.example ships a filled-in sample for the LLM block (an Ollama URL and a model name) rather than the blank built-in — copy it and you have configured an endpoint, not accepted a default.

Data model

Table Holds
documents The tree. parent_id + a path JSONB array of ancestor ids for subtree queries; content; a 768-dim embed vector; usetype for classification; a nullable explicit sibling position; created_at/updated_at (system time) and a separate nullable event_time (domain time, for imported content whose ingest time means nothing — transcripts, backfills). (branch) adds settled (in_flight, settled or failed), the ingestion lifecycle flag the retrieval indexes are partial on — an in_flight node is invisible to search until its subtree finishes.
token_embeddings Per-token late-interaction vectors — embed_256/384/512 as halfvec, one row per selected token, tier marking which top-N% importance band it fell in. Only 256-dim is populated in a default deployment; 384/512 exist for benchmarking retention levels.
document_blobs (branch) Uploaded bytes for POST /ingest/file. One row per document (UNIQUE), holding the lob_oid of a Postgres large object plus mime_type, byte_size, content_hash. The bytes are not in the table: ON DELETE CASCADE removes the row and orphans the object, so deletion goes through BlobRepository (lo_unlink first), and pg_dump in plain format needs -b or the restore points at bytes that no longer exist.
task_queue (branch) The ingest scheduler's rows — see Ingest task queue for the column set and the lifecycle. scope_document_id + write_mode (self, children or subtree) declare what region of the tree a task reserves while it runs, and the claim query refuses a task whose region overlaps one already claimed, running or batched. Retry policy lives in TaskQueueRepository.fail(), not in SQL.
document_links Typed graph edges between documents (link_type, a score, JSONB metadata).
predicates / triples The knowledge graph. A triple is (subject_id, predicate_id, object_id), unique per combination, with a bitemporal split: valid_from/valid_until (when the fact was true) vs. created_at/recorded_at (when this store learned it). fact_type is atemporal | static | dynamic (Zep/Graphiti taxonomy). Superseding a fact sets invalidated_at/invalidated_by rather than deleting the row.
search_indexes, search_index_members, search_index_entries, search_term_postings, search_term_stats The BM25 inverted index: which subtrees belong to an index, per-document length (for normalization), term→document postings, and per-term document frequency (for IDF). search_indexes.config carries k1/b; capabilities flags whether an index supports bm25/maxsim.
search_contexts Named, reusable search-parameter presets (method, weights, usetype/parent_id filters) stored as JSONB config, referenced by name from any search endpoint's context param.
usetype_presentations Per-usetype rendering rules for /view/{id} — which renderer, how children collapse, how links render. Seeded for the built-in wiki:* taxonomy and core usetypes (markdown, chunk, conversation, raw, transcript).
principals, api_tokens, access_grants Subtree RBAC. The shared owner bearer token bypasses all checks and has no row. A grant on a document makes it an access-control root; a principal's effective right on any document is the highest grant on any ACR at or above it on its path (grants are additive; write implies read). A document under no ACR is unprotected — single-user default.

Auth and CORS

Every request needs Authorization: Bearer <token>.

Setting Default Meaning
JMFTS_API_TOKEN blank → ephemeral, printed to stdout at boot Set a value to pin a fixed token. A blank token never means "allow all."
JMFTS_CORS_ORIGINS [] JSON list of allowed browser origins. Empty = server-to-server only. "*" is rejected.

Endpoint families

All routes are generated from a service-layer @expose registry (jmfts_core/services/), not hand-written per-route: one decorated service method is both the in-process Python call and the REST route, and tests/test_api_parity.py asserts the registry and the mounted routes stay in bijection. docs/API_UNIFICATION_CONTRACT_NOTES.md, which carries the conversion history and the per-endpoint divergence notes, is one of the design documents held back from 0.1.0 (see Documents not in this release).

Family Prefix Covers
Documents /documents CRUD, tree navigation (roots/children/ancestors/siblings/subtree), embed, tokens, split, chunk, segment, raptor (+ raptor/portfolio), extract-facts, links.
Ingest /ingest Two paths under one prefix, from two different eras — see Two ingest paths. GET /ingest/pipelines and POST /ingest are the synchronous named-pipeline path and are in 0.1.0. (branch) adds the queue: POST /ingest/file (multipart upload; stores bytes, enqueues probe, returns before the work is done), file/{id}/frontier, explain, analyze.
Conversations /conversations ingest — a whole conversation into the tree in one call.
Search /search vector, fulltext, bm25, maxsim, hybrid, synthesize, auto, plus a convenience GET /search/?q=.
Search contexts /search-contexts CRUD for the named parameter presets any search endpoint's context param resolves.
Triples /triples Predicates CRUD, triple create/query/path-find, invalidate, supersede.
Templates /templates Prompt-template CRUD, render, search.
Graph /graph centrality, subtree-authority, spines, communities, neighbors, diff, stats, lint.
Indexes /indexes Index CRUD, root membership, refresh, index-document/{id}.
View /view Rendered read side: /view/{id}, expand-children, breadcrumbs, back-references.
Presentations /usetype-presentations CRUD for the per-usetype rendering rules /view applies.
Access /access Principals, their tokens, and per-document grants — the subtree RBAC surface.

A caller-facing note: several GET endpoints across these families lost their FastAPI-level numeric/pattern query-param validation during the migration to the @expose registry (a raw scalar query param carries no Query(ge=..., pattern=...) without importing FastAPI into the core layer). Examples: GET /search/'s limit no longer enforces 1–100 server-side; several /graph/* metric/scope params no longer enforce their allowed-value pattern. Request-body (BaseModel) field constraints are unaffected — SearchRequest.limit, ChunkRequest.max_tokens, LintRequest's thresholds, and similar all still validate. If you are calling a GET endpoint's scalar query params with untrusted values, validate them client-side rather than relying on a 422.

Response envelope

DocumentResponse: id, parent_id, title, content, structured_content, path, usetype, position, event_time, created_at, updated_at, content_hash. embed is opt-in (?include_embed=true) and omitted otherwise — every route that returns documents serializes through this one converter, so the field set is identical across /documents, /search/*, /triples/*, and /templates/*.

SearchResponse: results[] (each a scored DocumentResponse), total, latency_ms.

Scoring parameters

Setting Default Notes
JMFTS_BM25_K1 1.2 Term-frequency saturation.
JMFTS_BM25_B 0.75 Length normalization strength.
JMFTS_TOKEN_TOP_PERCENT 0.50 Fraction of a document's tokens kept for late interaction, selected by MMR + attention variance + stopword penalties. Half is a deliberately generous default — it keeps the tiered tier<=X bands usable for benchmarking rather than sizing the index for production.
JMFTS_TOKEN_EMBED_DIMS [256] Matryoshka dims actually populated. The schema reserves embed_384/embed_512 columns and indexes them, but nothing writes them unless a deployment opts in here.
JMFTS_BM25_EXCLUDE_USETYPES ["entity", "summary"] Usetypes held out of the BM25 index — derived nodes, not source text.
JMFTS_SEARCH_EXCLUDE_USETYPES ["entity", "summary"] Usetypes held out of search results.
Hybrid vector/BM25 weight corpus-dependent Not a single global default — see the Cookbook for measured weightings and why the right one varies by dataset.
JMFTS_RERANKER_MODEL cross-encoder/ms-marco-MiniLM-L-6-v2 Second-stage model for ?rerank=true&rerank_method=cross_encoder. Any Hub cross-encoder.
JMFTS_RERANKER_DEVICE blank Blank follows JMFTS_EMBEDDING_DEVICE. Pin only to split the two across devices.
JMFTS_RERANKER_MAX_LENGTH 512 Tokens per (query, document) pair.
JMFTS_RERANKER_BATCH_SIZE 32 Pairs scored per forward pass.

Embedding and LLM configuration

Setting Default Notes
JMFTS_EMBEDDING_MODEL nomic-ai/modernbert-embed-base Produces the 768-dim document vector and the token-level embeddings.
JMFTS_EMBEDDING_DEVICE cpu Set cuda for GPU-accelerated bulk ingestion — see the DevOps Manual for why this matters.
JMFTS_EMBEDDING_BATCH_SIZE 32 Texts per forward pass on the document path; JMFTS_TOKEN_BATCH_SIZE (also 32) is the token path's.
JMFTS_EMBEDDING_TOKEN_WINDOW 512 The token/MaxSim path's cap. It is a memory budget, not a model limit: token embedding runs the transformer with output_attentions=True, which materializes every layer's attention matrix (~277 MB at 512, ~4.4 GB at 2048). Text over the window is refused, not truncated.
JMFTS_EMBEDDING_DOC_WINDOW 8192 The document-vector path's cap — the model's real limit.
JMFTS_CHUNK_MAX_CHARS 1800 Hard cap every chunking strategy is held to, sized to land inside the token window (~5 chars per subword token, plus prefix headroom).
JMFTS_LLM_BASE_URL / JMFTS_LLM_MODEL / JMFTS_LLM_TIMEOUT blank / blank / 0 OpenAI-compatible endpoint for RAPTOR summarization, fact extraction, and read-side synthesis. Each blank field falls back to its JMFTS_ENSONET_* counterpart, so what a stock instance actually dials is the fallback, not nothing. JMFTS does not host its own model — see the DevOps Manual.
JMFTS_ENSONET_URL / _MODEL / _TIMEOUT http://localhost:8853 / THUDM_GLM4_32b / 180.0 The fallback the blanks above resolve to — the maintainer's own model orchestrator. Point JMFTS_LLM_* at your endpoint and these stop being reachable.

There is no separate extraction endpoint. Fact extraction shares the LLM settings above; JMFTS_EXTRACTION_* tunes the extraction request (MAX_FACTS 5, CONFIDENCE_THRESHOLD 0.5, ENTITY_SIMILARITY_THRESHOLD 0.8, TEMPERATURE 0.1, MAX_TOKENS 4096), not where it is sent.

Two ingest paths

/ingest carries two unrelated designs, and the names in them do not interchange.

Synchronous pipelines The queue (branch)
Endpoints GET /ingest/pipelines, POST /ingest POST /ingest/file, file/{id}/frontier, explain, analyze
Takes text plus a pipeline name bytes; the format is detected
Names markdown, raw, conversation, transcript, wiki:url, wiki:arxiv, wiki:pdf text, pdf, docx, pptx, zip, … from detect_format
Returns a finished tree, in the request an in_flight node and a queued probe
In 0.1.0 yes no

The name sets overlap in exactly the way that causes trouble: markdown is a pipeline and is not a format. Nothing in the bytes distinguishes authored markdown from a .txt file starting with #, so detect_format reports both as text and one Part 4 entry covers them. POST /ingest/explain accepts {"format": "markdown"} — an unknown format is a legal question — and answers with every row impossible or not_applicable, which reads like a verdict on markdown and is a verdict on the name.

POST /ingest is being retired in favour of the queue; it keeps its synchronous behaviour unchanged in the meantime.

Ingest task queue

Branch only

Everything from here to Vector indexes is on feat/ingest-lifecycle, not in 0.1.0.

POST /ingest/file stores the bytes, enqueues one probe task and returns. Everything after that is a worker draining task_queue. A task is one unit of work scoped to one node.

Task types

Ten declared, seven with handlers today. A task type with no handler is enqueued only if something schedules it; nothing does.

task_type Handler in Does
probe ingest_tasks.py Reads the stored bytes, detects the format, measures patterns, and schedules the rest from Part 4's table.
extract:text structure_tasks.py Pulls text out of the file — the entry point every format converges on.
structure:declared structure_tasks.py Builds the tree the document itself declares (headings, outline, page breaks).
structure:inferred structure_tasks.py Builds a tree for a document that declares none, by chunking.
structure:semantic rollup_tasks.py PELT changepoint segmentation over a node's children. Runs on the way back up, once the children have embeddings.
summarize rollup_tasks.py Gives a container node a text embedding from its children. Concatenates while that fits the window.
summarize:llm rollup_tasks.py The same job when concatenation does not fit. Split out so it can carry its own badge and its own pool.
ocr Declared, no handler.
extract:tables Declared, no handler.
extract:images Declared, no handler.

summarize does not call an LLM. It measures, and if the concatenated children are over the embedding window it enqueues summarize:llm and completes. That deferral is deliberate: whether a node needs a model is a fact about its children right now, and the only way to learn it is to concatenate and tokenize.

The ten split into two groups by who schedules them, which is why only seven ever appear in a plan:

Types Scheduled by In explain/analyze
Downward pass probe, extract:text, extract:tables, extract:images, ocr, structure:declared, structure:inferred plan_after_probe, a pure function of (format, patterns, options) yes, all seven, in table order
Rollup structure:semantic, summarize, summarize:llm the settling walk, which re-reads the tree no

The rollup rungs cannot be forecast from a format name: they are decided by children that do not exist when probe runs. A plan that listed them would be describing work it cannot have evaluated.

Row lifecycle

status Meaning Claimable Holds a reservation
pending queued yes no
claimed a worker took it, has not started no yes
running a worker is executing it no yes
batched submitted to an external batch provider no yes
completed terminal no no
failed terminal, or retryable with backoff if retryable and under cap no

pending reserves nothing, which is what lets thousands of pending tasks sit over one subtree without blocking each other. claimed and running are separate so a row stuck in claimed reads as "the worker died before it began" rather than "died halfway through".

Columns beyond the obvious

Column Notes
write_mode self, children, or subtree. What the task reserves, checked at claim time. A self task conflicts with another self on the same node; a subtree task conflicts with anything at or under its node, in either direction.
dependencies Integer array of task ids that must be completed first. Ordering within one node only. Cross-level ordering is not expressed here — a planned list goes stale the moment a child is added, so the settling walk re-reads the tree instead.
params / param_fingerprint What the task was asked to do, and a hash of it. The re-run diff is keyed on (task_type, param_fingerprint), so a task is not offered twice with the same parameters.
service_badge Which pool should run this. Set from the routing policy at enqueue. NULL means anyone.
claimed_by Worker identity, from --worker-id.
heartbeat_at Liveness while the task is held. Measures "the worker is still alive", not "the task has run this long".
batch_id / batched_at Where a batched task is parked and since when. Kept after completion as part of the record of how the answer was obtained.
retry_count / max_retries / retryable / retry_after Backoff is exponential with jitter. retryable is derived from error_type on failure.

error_type is one of retryable, permanent, timeout, dependency. retryable and timeout schedule another attempt; permanent and dependency do not, and a task that will not run again puts its node into settled = 'failed' so the settle walk stops waiting on it.

No endpoint reads the queue

task_queue has no REST surface. Nothing lists rows, shows which task failed, retries one, or cancels one — the frontier's counts are the whole API-level view of a running ingestion. Operationally that leaves SQL against the appliance, or scripts/e2e_ingest_corpus.py report, which lists every failed task with its error type alongside the tree shape and per-task-type wall clock.

Re-ingest is in the same state. Reprocessing a document under changed options is specified — diff (task_type, param_fingerprint) against the node's attempt log and enqueue only the difference, no re-parse and no duplicate tree — and is not built. Delete and re-upload is the available path.

Upload semantics

POST /ingest/file takes the bytes as a multipart part; parent_id and private are query parameters and options is a form field. That split is FastAPI's inference rather than a choice: the bytes make the body a form, so scalars publish as query parameters and only the structured parameter becomes a part.

Uploads deduplicate on sha256 of the bytes, restricted to file nodes this caller may read. Two principals in isolated access zones each get their own node, because neither one's lookup can see the other's.

Response field Says
document_id The file node — the root of the tree ingestion builds.
settled in_flight at creation.
content_hash / blob_ref sha256:<hex>, and lob:<oid> for the Postgres large object holding the bytes.
detected_mime / detected_by / declared_mime What the bytes are, how that was decided (magic_bytes, zip_manifest, content_sniff), and what the client claimed. Nulls are recorded rather than guessed.
attempts The node's attempt log as it stands, newest last.
was_existing These bytes were already here; document_id names a node this request found. No document, no blob, nothing enqueued.
linked_into_parent The found node was attached to parent_id by a contains link rather than by parentage.

Three consequences of deduplication, each of which is a refusal or a surprise rather than a convenience:

Conflicting options are a 400. The existing node records the resolved options it was ingested under. An upload of the same bytes asking for anything else fails, naming both sets. The two honest answers are "do the work" and "it was not done"; returning the node as though the new options applied is neither, and the re-run diff that would make the first one possible is not built.

Placement is a link, not a copy and not a reparent. With a parent_id, an already-stored node gains a contains graph edge to that parent and keeps its own parent_id. So GET /documents/{parent}/subtree does not reach it and GET /documents/{parent}/links does — a subtree walk is path/parent_id containment and this edge is in neither. Reparenting instead was rejected: it would change what another principal's document sits under and invalidate the rollups above the new parent, silently, in a request that said nothing about moving anything.

private=True narrows the lookup. It restricts matching to nodes the caller holds a grant on. Without that, a private upload of bytes already present as a shared node would resolve to the shared node and the caller would not be private at all.

private and the ungoverned-node gap

Access is open by default: a token means the shared knowledgebase, and an upload lands readable by every principal. That is the design, not an oversight — "private until shared" is the opposite assumption and produces bugs of ignorance wherever the stated one holds.

The gap it leaves is narrow and not repairable after the fact. Subtree RBAC resolves strictly along the tree path (R = D.id OR R ∈ D.path), so a file uploaded with no parent_id has no ancestor, no access-control root above it, and nothing governing it — and a grant made later has no path to travel down. The two fixes are both upload-time: a parent_id inside an already-governed subtree, or private=true, which makes the new node its own access-control root with the uploader as its only grantee at write.

Plan inspection

POST /ingest/explain and POST /ingest/analyze answer the same question from different amounts of evidence, and both store nothing. Both resolve options through the resolver the upload uses, so an unknown option is a 400 in all three places.

Call patterns_source Rows decided
explain {format} unknown — nobody said mostly conditional
explain {format} for a format with no prober no_prober — the empty pattern set is a measured fact about this appliance all
explain {format, patterns} supplied — the caller's hypothesis all
analyze {file} probed — measured from the bytes all

patterns_source is load-bearing rather than metadata: an answer that did not say where its patterns came from would be indistinguishable from one that invented them. patterns_known is the boolean form of the right-hand column. patterns_ignored names supplied keys no condition consults for this format — reported, not rejected, so a real matched.patterns block can be pasted in whole and a misspelled pattern is visible instead of silently planned as false.

Per-task rows carry outcome, and the vocabulary is finer than it looks:

outcome Means
enqueued The condition holds; a queue row would be written.
skipped Recorded as never-attempted, with the reason.
deferred The condition holds (or could) and no handler is registered, so nothing is queued.
not_applicable The condition is false for these patterns.
impossible The condition cannot hold for this format, whatever the bytes are.
conditional Undecided; if_condition_holds says which of the first three it would become.

Alongside: requires and forbids (patterns, resolved for this format — the declared-structure sentinel replaced by the pattern that format uses, or dropped, which is what makes a row impossible), after, write_mode, and params — the resolved options the queue row would carry.

analyze adds three blocks explain cannot produce. Check probe_failed first: exactly one of it and plan is set, and a null plan means these bytes have no schedule rather than no work. It carries the exception and the same error_type classification the real task would get. file reports what the bytes are beside what the client declared, with mime_agrees null — not false — when one side is unknown. already_stored reports that an upload would deduplicate to a named node and run no plan at all, and carries that node's recorded options, which is the only way to predict the conflicting-options 400 in advance.

Ingest frontier

GET /ingest/file/{id}/frontier counts the root node and everything under it by path.

Field Notes
settled The root node's own state: in_flight, settled or failed.
nodes_total / nodes_settled / nodes_in_flight / nodes_failed Node counts across the subtree.
tasks_unfinished Queued tasks under this root still owing work — pending, claimed, running, or failed-but-retryable.

There is no percentage, deliberately: a node's children are only created when the node is processed, so the denominator is unknown while the run happens and any estimate of it moves backward as work is discovered.

tasks_unfinished at zero while nodes_in_flight is above zero is the one reading with a definite meaning — the frontier is waiting on something that is not in the queue, and nothing is coming to finish those nodes. A non-zero value says work is queued and nothing about whether a worker is claiming it: tasks stalled pending behind an unanswered badge are counted identically to tasks being drained. Two readings over time separate them; one reading cannot.

Ingest options

Three layers, merged by jmfts_core.ingest_options.resolve_options:

TASK_PARAM_DEFAULTS[group]        the task's parameters — valid wherever it runs
  <- INGEST_PROFILES[fmt][group]  this format deviates  (empty today)
    <- caller overrides           this request deviates

Options are namespaced by group, where a group names the parameters one kind of task takes. A parameter belongs to the task that reads it, not to the format that fed it.

Group Key Default Read by
structure chunk_strategy sentence_packed structure:declared, structure:inferred
structure max_tokens 120 both structure rungs
structure min_chunk_length 20 both structure rungs
rollup max_children 16 the rollup planner — over this, segment instead of summarize
rollup penalty 1.0 PELT changepoint penalty
rollup min_segment 3 minimum children per segment
rollup llm_model "" summarize:llmper-request model selection, empty falls back to JMFTS_LLM_MODEL

Every group resolves for every format, so no document can reach a task whose parameters nobody set.

An unknown option is a 400, not a shrug

Writing max_token instead of max_tokens fails the request naming the key. It is not ignored. A run that does something other than what was asked and reports success is the swallowed failure the project's Fail Early rule exists to prevent — and it is what /ingest/explain rests on, since a plan that reports options the run would reject is a wrong answer rather than a partial one.

Workers and routing

The same IngestWorker runs three ways: as a thread inside the API process, as jmfts-worker (its own process, possibly on another host), and as a k3s Deployment.

Setting Default Notes
JMFTS_INGEST_WORKER_ENABLED true Whether the API process runs a worker thread. An appliance that accepts uploads and never processes them is not a useful default.
JMFTS_INGEST_WORKER_POLL_SECONDS 1.0 How long to wait after finding the queue empty.
JMFTS_WORKER_HEARTBEAT_SECONDS 10.0 How often a worker reports liveness while holding a task.
JMFTS_WORKER_LEASE_SECONDS 90.0 How long a worker may go without beating before another requeues its task. Must be at least 3× the heartbeat; below that a single missed beat costs a running task, and the constructor refuses.
JMFTS_WORKER_REAP_SECONDS 30.0 How often to try the fleet-wide expired-claim sweep.
JMFTS_TASK_BADGES {} JSON object mapping task_type → badge. Empty by default, deliberately — see the warning below.
JMFTS_WORKER_BADGE unset Comma-separated badges for jmfts-worker. Equivalent to repeating --badge.
JMFTS_WORKER_ID host-pid Written to task_queue.claimed_by.
JMFTS_LLM_API_KEY "" Bearer token for the LLM endpoint. Sent only when set, so one worker image serves both a LAN llama-server and a metered API.

The lease is not a task timeout

It bounds how long a live worker may go without reporting in — a property of the loop. It does not bound how long a task may run. Task durations here vary by orders of magnitude with input, so a lease keyed on elapsed runtime would reap work that is still running and then run it a second time, concurrently, on another host.

Badges filter, they do not order

claim_next orders by priority DESC, created_at ASC. Badges only decide what is in the candidate set. A worker listing [urgent, bulk] takes whatever is oldest and highest priority among both — so an idle expensive worker will start a bulk task a second before an urgent one arrives. priority on the row is the lever for that.

An un-badged worker claims everything, and an un-badged task is claimable by anyone.

A badge with no worker stalls silently and permanently

JMFTS_TASK_BADGES is empty by default and that is deliberate. Tasks carrying a badge no running worker answers to sit pending forever, and nothing reports it. The DevOps Manual covers the failure shape and what to monitor.

jmfts-worker flags: --badge (repeatable), --worker-id, --poll-seconds, --heartbeat-seconds, --lease-seconds, --reap-seconds, --drain, --max-tasks, --log-level.

Batch processing

batch_worker/ is a reference implementation, shipped beside the appliance rather than inside it. It consumes summarize:llm through an external batch provider at roughly half the synchronous price. Nothing in jmfts_core imports it.

Provider Submit Status field Batch-level failure Cap
mock 1 call, directory-backed cancel only configurable
openai 2 calls (upload file, create batch) status failed / expired / cancelled 50,000 / 200 MB
anthropic 1 call, requests inline processing_status none — always ended 100,000 / 256 MB

custom_id is the only mapping and is set to task-{id}, which satisfies Anthropic's ^[a-zA-Z0-9_-]{1,64}$. A result whose custom_id this appliance did not write is skipped, never applied.

jmfts-batch-worker subcommands: run, status, stalled, finalize (mock only), cancel. stalled exits non-zero so a cron or probe can act on it.

A batched row is invisible to every other recovery mechanism

The claim query will not take it, the lease will not reap it — nothing beats for a parked row, because the work is at the provider — and the conflict predicate counts it as a live reservation, so nothing else can work that node either. batched_at and jmfts-batch-worker stalled are the only stall signal there is. Both providers expire a batch at 24 hours; the default stall threshold is 26.

Vector indexes

Column Index Notes
documents.embed (768-dim vector) HNSW, m=16, ef_construction=64, cosine Document-level similarity.
token_embeddings.embed_256 (halfvec) IVFFlat, lists=1024, cosine The active late-interaction column in a default deployment.
token_embeddings.embed_384 / embed_512 (halfvec) HNSW, m=16, ef_construction=64, cosine Present in schema, unpopulated unless a deployment opts into a higher retention tier.

pg_trgm is also enabled, for fuzzy/trigram fallback in full-text search.