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.
This page describes 0.2.0. The current release is 0.3.0.
Two releases have landed since this page was written, and it has not moved to either: 0.2.1 added the spreadsheet task types, the Turtle surface and two extras, and 0.3.0 replaced the ingest planner. The Changelog says what each one changed, and 0.3.0 — the job system runs the changes rather than describing them.
Two sections here have been corrected in place because they described designs that no longer exist: One ingest path and the .xlsx row under Office formats. Everything else is 0.2.0's surface, which 0.3.0 is a superset of except for the two breaks the changelog names.
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). 0.1.1 added 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 |
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 |
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 the public tree (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 |
One path, two doors onto it — see One ingest path. POST /ingest/file is a multipart upload that stores bytes, enqueues probe and returns before the work is done; POST /ingest takes content plus a named entry point and drains that document's tasks before returning. GET /ingest/pipelines lists the entry points, file/{id}/frontier reports what is still queued, and explain / analyze answer what a document will run before it runs. |
| Runner | /runner |
Vectors for text, behind JMFTS_RUNNER_KEY rather than the API token. Owns no documents. This is what a worker set to JMFTS_RUNNER_URL calls instead of loading the model. |
| 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. |
Calling them from Python¶
Do not write a client against /openapi.json. One already exists, and it is generated from the same route table.
pip install jmfts-client
from jmfts_client import RemoteJmftsClient
from jmfts_client.contracts import DocumentCreate, HybridSearchRequest
with RemoteJmftsClient("http://localhost:8100", token="...") as jmfts:
jmfts.create_document(DocumentCreate(title="Ada", content="Ada Lovelace"))
hits = jmfts.hybrid_search(HybridSearchRequest(query="Ada", limit=10))
jmfts-client is a second distribution shipped from the same repository. It carries httpx and pydantic and nothing else — no SQLAlchemy, no numpy, no torch — so calling an appliance does not mean installing one.
jmfts |
jmfts-client |
|
|---|---|---|
| Holds | the appliance | the wire contracts plus RemoteJmftsClient |
| Dependencies | the full stack | httpx, pydantic |
| Depends on | jmfts-client==<same version> |
nothing in this repository |
The two release in lockstep: one number, two wheels, one tag. _verbs.py is generated from the server's resolved route table, so a client built from a different release was generated from a different surface — and that mismatch does not fail at install time. It fails at call time, as a verb that is missing or whose parameters moved. The == pin is what makes the pair one product.
The contracts are the single definition of every request and response shape. Both the in-process LocalJmftsClient and RemoteJmftsClient validate against the same classes, which matters when one process holds both — the JMFTS_RUNNER_URL case, where two copies equal by value and unequal by isinstance would bite.
So one @expose-decorated service method becomes four things: a REST route, an entry in the OpenAPI document, a LocalJmftsClient method, and a RemoteJmftsClient method. Nobody hand-writes the last three.
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.
One ingest path¶
Corrected for 0.3.0
Through 0.2.1 this section described two unrelated designs under one prefix, and said POST /ingest was being retired. It was not retired — it was moved onto the queue, and the design it belonged to was deleted. execute_pipeline and the PipelineDefinition registry are gone.
/ingest has one implementation and two doors onto it. Both create a file node with stored bytes and run queue tasks against it; they differ in who waits and in whether the format is named or measured.
POST /ingest |
POST /ingest/file |
|
|---|---|---|
| Takes | content plus a named entry point | bytes; the format is detected |
| Names | markdown, raw, conversation, transcript, wiki:url, wiki:arxiv, wiki:pdf |
text, pdf, docx, pptx, xlsx, zip, … from detect_format |
| Returns | a finished tree, in the request | an in_flight node and a queued probe |
| Who drains the queue | the request, for that document only | a jmfts-worker process |
| Since | 0.1.0, on the queue since 0.3.0 | 0.1.1 |
Same tasks, same handlers, same settling walk. An entry point is not a stage list. GET /ingest/pipelines names the seven and reports, for each, where its content comes from and which options it resolves to — two facts probe cannot supply. Which tasks a document actually runs is POST /ingest/explain's answer, decided from what was measured.
pipeline_config was the deleted design's stage vocabulary and is now refused with a 400 naming options instead. There is no translation between the two: that design's summarize stage was RAPTOR clustering, and the queue's is what gives a container node its vectors.
The two name sets overlap in exactly the way that causes trouble: markdown is an entry point 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.
Office formats¶
.docx and .pptx are read as text. .xlsx is read as records, by a different route.
That difference is not a gap in the documentation — it is the state of the appliance, and POST /ingest/explain reports it per format. Ask before you load a corpus.
Corrected for 0.2.1: .xlsx is read
The table below is 0.2.0's, where a workbook was detected and probed and nothing more. 0.2.1 added three task types that read one — structure:sheets makes a child node per worksheet, profile:sheet measures a worksheet, and extract:sheet turns each row into a record node. None of them is extract:text, and TEXT_EXTRACTORS is still ("pdf", "text", "docx", "pptx"): a spreadsheet is not prose, and rendering one as a wall of markdown would index something nobody wrote. So the xlsx rows below stay correct about text and understate what the format does. See the Changelog.
What is read, and what only measured¶
| Format | Detected | Probed | Text extracted |
|---|---|---|---|
docx |
yes | yes | yes, with jmfts[office] |
pptx |
yes | yes | yes, with jmfts[office] |
xlsx |
yes | yes — has_sheets, sheet_count, has_comments |
no |
ole2 |
yes | yes — separates an encrypted package from a legacy binary | no |
detect_format identifies docx, pptx and xlsx from the ZIP member list — word/document.xml, ppt/presentation.xml, xl/workbook.xml — and reports detected_by: "zip_manifest", so the evidence is in the record rather than inferred from the filename. That needs zipfile and nothing else, which is why it is in every install.
PROBERS_AVAILABLE is ("pdf", "text", "docx", "pptx", "xlsx", "ole2"), and TEXT_EXTRACTORS is ("pdf", "text", "docx", "pptx"). The two lists are not the same list, and the second is the one that decides whether an upload produces anything to search.
How the scheduler decides, per format¶
Nothing in the Part 4 table was changed to turn office ingestion on. extract:text is gated on has_text_layer, and the work was to measure that pattern rather than to add a row:
| Format | has_text_layer |
structure:declared gated on |
|---|---|---|
docx |
measured by the docx prober | has_heading_styles |
pptx |
measured by the pptx prober | has_slides |
xlsx |
not measured | has_sheets |
For docx and pptx that makes every downstream row conditional when you ask without bytes, and decided once probe has run — the same shape pdf has always had. For xlsx the pattern is never reported, so extract:text comes back not_applicable with the reason patterns.has_text_layer was not measured, and every row that depends on it follows.
DECLARED_STRUCTURE_PATTERN already mapped all three. In 0.2.0 the xlsx entry was a decision waiting on a reader; 0.2.1 supplied one, and has_sheets now gates structure:sheets as well as structure:declared.
What the markdown looks like¶
Both readers convert to markdown, because markdown is the intermediate format every text-bearing input converges on. Nothing downstream needed to learn what a .docx is.
.docx — a paragraph whose style resolves to Heading 1–Heading 9 becomes the matching ATX heading, and Title becomes #. Body order is preserved by walking the body element rather than Document.paragraphs, which skips tables entirely. Lists become -. Tables become GFM.
The heading styles the reader writes from are the same ones the prober counted for has_heading_styles, so the pattern that lets structure:declared run and the structure it splits on are two readings of one fact.
.pptx — one # section per slide, body text frames, tables, and speaker notes under a ## Notes subheading. A chunk is a slide.
Slide titles are promoted when there is no title placeholder. Across 22 real decks (220 slides), 80 had no title placeholder because the deck was built from a blank layout with the title typed into an ordinary text box. Falling through to # Slide N discarded the heading that becomes a search result's section title. The reader now promotes the slide's first line of text instead, and the attempt log carries promoted_title_count so you can see how many headings the reader chose rather than read.
The promoted line is moved, not copied — the rest of that shape stays in the body.
Deliberately not covered¶
A wrong rendering is worse than a missing one, so these are named rather than attempted:
- Ordered vs unordered lists. A numbered list becomes
-like any other. Telling them apart means resolvingw:numIdthroughword/numbering.xml, and a guess would renumber the author's list. - Images, charts, SmartArt.
probereportshas_imagesandhas_smartart; reading them isextract:images, a different task. - Comments, footnotes, tracked changes.
probereports each. Merging an unaccepted insertion into the prose would put text into the index that the document does not say.w:delTextandw:instrTextare excluded fromhas_text_layerfor the same reason. - Legacy
.doc/.ppt/.xls. Not a ZIP at all. Tier 3.
A corrupt package fails once, not three times¶
_open_package guards the open of a ZIP, but a member can still fail to inflate after the archive opens. The raw zipfile.BadZipFile derives straight from Exception, so classify_exception graded it RETRYABLE and probe attempted the same bytes three times. A local header does not repair itself between attempts.
probe_patterns now converts it to a ValueError naming the format and the member failure, which grades PERMANENT.
The three dependency tiers¶
Office support splits by what the dependency actually is. The split is the operator's concern as much as the developer's, so it is described in full in the DevOps Manual.
| Tier | What | Where it lives | Status |
|---|---|---|---|
| 1 | zipfile, xml.etree, olefile |
base install | detection and all four probers ship |
| 2 | python-docx, python-pptx, openpyxl |
jmfts[office] extra |
the docx and pptx readers call it; openpyxl has no caller yet |
| 3 | LibreOffice, driven by unoserver |
a badged worker image; jmfts[convert] is the client only |
nothing yet |
jmfts_core.office is the only module permitted to import a tier-2 library, and only through its require_docx() / require_pptx() / require_openpyxl() guards, at the point of use and never at module scope. tests/test_office_packaging.py fails if one reaches the application's import path, and scripts/check_base_install.sh asserts the same thing against a real base virtualenv.
A base install asked to read a .docx raises OfficeStackNotInstalled, which subclasses ImportError and therefore classifies PERMANENT.
is_encrypted is why olefile is in the base install¶
An encrypted OOXML package is not a ZIP. It is an OLE2 compound file, so it carries the same eight magic bytes as a legacy .doc, and detect_format reports both as ole2 with nothing to separate them. The two go opposite ways — a legacy binary gets converted, an encrypted package must fail with a named reason — so probe has to tell them apart, and one directory listing does it.
msoffcrypto-tool is deliberately not carried. It decrypts a password-protected package, and there is no path by which a caller could supply a password. Detecting encryption is the requirement; decrypting is not.
What is planned, and in what order¶
docs/OFFICE_SPEC.md in the repository is the plan, and it covers more than ingestion — rendering a page, addressing a region, editing a document in the tree, and generating one from a template.
| Step | What | Tier | Status |
|---|---|---|---|
| 0 | the extras, the office seam, the import guards |
— | shipped |
| 1–3 | advisory task types and the PDF citation path | — | shipped |
| 4 | office probers; is_encrypted fails, is_legacy_binary schedules convert:ooxml |
1 | probers shipped |
| 5 | extract:text for docx and pptx |
2 | shipped |
| 6 | profile:sheet, extract:sheet, GET /documents/{id}/cells |
2 | open |
| 7 | render:pdf and convert:ooxml; the LibreOffice worker |
3 | open |
Step 6 is what makes .xlsx readable, and it is a different job from steps 5: a spreadsheet is not prose, so extracting it as a wall of markdown would index something nobody wrote. convert:ooxml in step 7 is also what makes legacy .doc / .ppt / .xls reachable, by converting them to OOXML for the tier-2 readers.
Ingest task queue¶
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:llm — per-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.