# stapel-search 0.2.1

A materialized search index with one swappable engine seam: full-text with per-language configs and a typo-tolerant second arm, drill-down facet counts with a benchmark-calibrated candidate cap and an approximate fallback, indexed range filters over a numeric side table, geo radius and bounding-box search including the antimeridian, five sorts with an opaque keyset cursor whose envelope matches AnchorPagination, a registry-generated P2B Art. 5 ranking disclosure with a mandatory promoted marker on every result, and an event-to-pull indexer carrying the four Projection guarantees (idempotency, ordering, rebuild, drift_check). Ships three engines behind the same conformance suite: Postgres FTS (default, zero new infrastructure), Meilisearch (the [meili] extra) and a declared naive backend for SQLite demos.

Contract: axes 8 · surface 39 · extension points 6 · operations 5 · error codes 54.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- ACCEPT_FEATURES_SEARCH [bool, default true] — Accept the source's reduced attribute projection
  Whether a source that will not hand over full attribute values may feed the index with its flattened projection instead. Lossy — no ranges, no colour axis, no unit context — and the loss is reported to the caller rather than absorbed.
- BACKEND [enum, default "stapel_search.backends.postgres.PostgresSearchBackend"] — Search engine
  Which engine answers. Postgres FTS needs no new infrastructure and is honest about its limits (estimated totals, facet counts sampled above the cap). Meilisearch adds typo tolerance, exact totals and exact facet counts at any corpus size. The switch is this key plus a rebuild.
- FACET_CANDIDATE_CAP [enum, default 15000] — Exact facet-count ceiling
  Above this many candidates the counts come from a sample and the answer says approximate: true. Calibrated by benchmark, not by feel — under eight concurrent clients, 20000 candidates already breach the 200ms target, so the default is 15000.
- MAX_FACET_FIELDS [enum, default 12] — Facets counted per query
  Drill-down semantics mean N active facet filters cost N+1 candidate scans; this is the ceiling that stops a wide category page becoming a dozen sequential ones. Plan slugs past it are reported in facet_meta.skipped, never silently dropped.
- MAX_RESULT_WINDOW [enum, default 1000] — Deepest reachable result
  Paging past it is refused with an error rather than truncated. One explicit shared limit is what makes the engines interchangeable instead of interchangeable-until-page-forty.
- SORTS [list, default ["relevance", "newest", "price_asc", "price_desc", "distance"]] — Offered sort orders
  Which orderings a user may pick. `popular` is absent by default because no popularity signal exists in the fleet, and a sort that orders every result by zero is a promise with nothing behind it.
- SOURCES [enum, default {}] — Searchable corpora
  What can be found at all. Empty means installed and indexing nothing, which raises search.W001 rather than pretending.
- TRANSLITERATE [enum, default {"ru": true}] — Cross-script matching
  Whether Cyrillic and Latin spellings of the same word find each other without a curated entry per brand. On for Russian by default; a curated synonym group always wins over the algorithm, because algorithmic transliteration is noisy.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### predicate
- build_facets — stapel_search.services.build_facets
  instead of: reading the source's features_search projection, which loses ranges, the colour axis and unit context
  Turn a document's attribute DAOs into (facets, facet_terms, numbers) — the function that decides what is countable, filterable and range-able.
- build_index_document — stapel_search.services.build_index_document
  SearchDocumentInput -> the flattened, backend-facing document, so every engine sees identically normalized values.
- defaulted_type_slugs — stapel_search.registry.defaulted_type_slugs
  Attribute types that took the unreviewed generic branch this process — what search.W002 names.
- drift_check — stapel_search.services.drift_check
  Compare the index against the owner's snapshot and report, WITHOUT repairing — a job that silently fixes drift also silently hides that drift keeps happening.
- get_dictionary_sources — stapel_search.registry.get_dictionary_sources
  The extra dictionary sources configured for a language, settings layer then runtime.
- get_facet_mapping — stapel_search.registry.get_facet_mapping
  The index semantics for one attribute-type slug, falling back to the generic term branch and recording that it did so.
- get_facet_mappings — stapel_search.registry.get_facet_mappings
  The whole effective mapping table, memoized on stapel-attributes' own registry_version so a host's new type is picked up rather than cached away.
- get_scorers — stapel_search.registry.get_scorers
  The effective ranking registry — the same object the disclosure endpoint renders.
- get_source — stapel_search.registry.get_source
  The SourceSpec for one doc_type, or a named SourceNotRegistered rather than a KeyError three frames deeper.
- get_sources — stapel_search.registry.get_sources
  instead of: reading STAPEL_SEARCH['SOURCES'] directly, which misses the runtime layer
  Every effective source: builtins, then the settings overlay, then runtime registrations. The answer to "what is searchable here".
- health — stapel_search.services.health
  Engine reachability, its declared capabilities, and how far behind the index is — the numbers an operator pages on.
- pull_documents — stapel_search.services.pull_documents
  The raw keyed batch from a source's content Function, before mapping — useful when diagnosing whether a gap is the source's or ours.
- search — stapel_search.services.search
  instead of: calling a backend's query() directly, which returns keys and no card, promoted flag or degradation report
  Run one query end to end: parse, plan the facets, ask the engine, hydrate cards, and shape the envelope with degraded[] filled in. The whole read path, HTTP-free.
- suggest — stapel_search.services.suggest
  Title prefixes from the index. No query log exists to suggest from, by decision.
### factory
- apply_settings — stapel_search.services.apply_settings
  Push the engine-side schema and the dictionary halves it owns. A no-op on Postgres, and what makes synonyms take effect on Meilisearch.
- apply_signal — stapel_search.services.apply_signal
  instead of: setting SearchDocument.boost directly
  Apply one boost/popularity/promotion signal with its audit row, idempotently. Writes columns disjoint from the source document, so re-indexing never undoes a promotion.
- corpus — stapel_search.testing.corpus
  The fixed conformance corpus, small enough to hold in your head while reading a failure.
- expire_signals — stapel_search.services.expire_signals
  Drop promotions past their expiry, so a paid slot really ends.
- get_search_beat_schedule — stapel_search.tasks.get_search_beat_schedule
  instead of: hand-writing crontab entries that drift from the settings
  The three beat entries on their configured cadences, to splat into a host's CELERY_BEAT_SCHEDULE. Without it, search.W003 says the catch-up is a promise rather than a mechanism.
- harness — stapel_search.testing.harness
  Context manager that registers the conformance source, applies engine settings, loads the fixed corpus and cleans up — the entry point a third-party backend uses to run this package's suite against itself.
- index_documents — stapel_search.services.index_documents
  instead of: SearchDocument.objects.create, calling backend.upsert yourself
  Upsert documents into the table and the engine in ONE transaction, with the idempotency and ordering guards applied. The only supported write path.
- ingest — stapel_search.services.ingest
  Pull specific keys through the source's content Function and index them; keys the source no longer serves are removed. What a signal handler and a targeted reindex both call.
- purge_tombstones — stapel_search.services.purge_tombstones
  Delete rows invisible longer than the retention window.
- rebuild — stapel_search.services.rebuild
  Rebuild a whole doc_type from the owner's cursor snapshot and drop what the source no longer has. The backfill, the recovery path, and the engine-switch procedure.
- register_dictionary — stapel_search.registry.register_dictionary
  instead of: editing the packaged dictionaries in site-packages
  Add synonym groups, query rewrites or stopwords for a language at runtime, on top of the shipped JSON.
- register_facet_mapping — stapel_search.registry.register_facet_mapping
  Say how a stapel-attributes type lands in the index — term, range, path or skip — when a host registers a field type this module has never seen. Without it the type indexes by a generic default and raises search.W002.
- register_scorer — stapel_search.registry.register_scorer
  instead of: post-processing a result page to reorder it
  Add or replace a ranking parameter. It takes effect in scoring AND in the generated P2B disclosure at once, which is the point: the two cannot drift.
- register_source — stapel_search.registry.register_source
  instead of: writing an @on_action subscriber and your own upsert loop
  Declare a corpus this index should carry: the invalidation signals, the two comm Function names the documents are pulled through, and the mapper. Called from a composite's app config, because a mapper is cross-domain glue.
- reindex_stale — stapel_search.services.reindex_stale
  Re-pull the documents the source has moved on from. The safety net for a lost event; on a healthy deployment it should find nothing.
- remove_documents — stapel_search.services.remove_documents
  instead of: SearchDocument.objects.delete
  Tombstone documents: invisible immediately, purged by the beat later, so a late event cannot resurrect a stale version.
- reset_defaulted_type_slugs — stapel_search.registry.reset_defaulted_type_slugs
  Forget the generic-branch record between tests.
- run_all — stapel_search.testing.run_all
  Run every conformance scenario outside pytest and return {name: ok|skip:<capability>} — a smoke check for a deployment pipeline.
- search_expire_signals — stapel_search.tasks.search_expire_signals
  Beat task: end expired promotions.
- search_purge_tombstones — stapel_search.tasks.search_purge_tombstones
  Beat task: delete tombstones past retention.
- search_reindex_stale — stapel_search.tasks.search_reindex_stale
  Beat task: catch every registered type up after a lost event.
- unregister_dictionaries — stapel_search.registry.unregister_dictionaries
  Drop runtime dictionary sources (tests).
- unregister_facet_mapping — stapel_search.registry.unregister_facet_mapping
  Drop a runtime facet mapping (tests).
- unregister_scorer — stapel_search.registry.unregister_scorer
  Drop a runtime scorer (tests).
- unregister_source — stapel_search.registry.unregister_source
  Drop a runtime source registration — tests, and hosts that mount a corpus conditionally.

## Extension points — what a product replaces, fork-free
- BACKEND [swap_key]
  One dotted path selects the engine. Postgres by default; Meilisearch is one settings key and a search_rebuild away, with no module code changed — which is the test of whether the seam is real, and it is an e2e assertion, not a claim. A third-party engine implements nine verbs and must pass stapel_search.testing.backend_conformance; differences are legitimate only through capabilities(), and they travel to the caller in degraded[].
- DICTIONARIES [merge_registry]
  Synonym groups, one-way query rewrites and stopwords per language, shipped as JSON contract data and extended by settings or at runtime. Stopwords are removed from the query only, never from the index: a corpus indexed without them can only be repaired by reindexing everything.
- FACET_MAPPINGS [merge_registry]
  How each stapel-attributes type lands in the index (term, range, path or skip), keyed by type slug. A host registering an eleventh field type indexes by a generic default and raises search.W002 until it declares — or explicitly confirms — the mapping, because a silent default is exactly the disease the index contract exists to prevent.
- SCORERS [merge_registry]
  The ranking parameters. Weights and descriptions live here, and GET /search/api/v1/ranking plus docs/ranking.json are rendered from this registry — so the P2B disclosure cannot drift away from the behaviour the way a paragraph copied into terms of service always does. A scorer declares which sorts it applies to, which is how 'an explicit sort receives no promotional boost' is a structural property rather than a check repeated in three places.
- SOURCES [merge_registry]
  Which corpora are indexed. Empty by design: this module knows nothing about listings, chats or profiles. A SourceSpec names the invalidation signals, the keyed-batch content Function, the cursor snapshot Function and the mapper — and it is declared by the composite, because a mapper is cross-domain glue and belongs where knowing both sides is allowed.
- search.signal [action]
  The single inbound door for boost, popularity and promotion, with an audit row per signal. Who may open it is a product decision (a billing entitlement, an editorial tool) deliberately left outside this module: v1 owns the field, not the policy.

## Fits with — fleet dependencies
- stapel-attributes (required) — facet semantics are keyed by attribute type slug and the mapping cache follows its registry_version(); the DAO shapes this module reads are its output. An L1 library — imported, never installed as an app
- stapel-categories (optional) — the facet plan comes from the categories.features comm Function, and category ancestry from categories.path — both by name, no import. Any provider satisfies the contract; without categories.features nothing is counted, and without categories.path the category path degrades to one segment (search.W006, and degraded: ['category_rollup'] in every answer)
- stapel-core (required) — comm bus (the source's content/export Functions, search.signal, category.changed, user.deleted), AppSettings config layer, unified error envelope, access declarations, JWT authentication
- stapel-geo (required) — geohash encoding and haversine distance are imported from stapel_geo.geohash rather than copied (architect verdict §18.10). The module is pure arithmetic (pygeohash, no Django) and stapel_geo is never added to INSTALLED_APPS
- stapel-listings (optional) — the first corpus. Not a dependency of this module at all: a composite registers the `listing` source with a mapper and the two Function names, because the composite is the one place allowed to know both sides

## HTTP operations (5) — call by operationId, never by a typed path
Paths are relative to `/search/api/v1/`.
### search
- GET /health — search_api_v1_health_retrieve
- GET /query — search_api_v1_query_retrieve
- GET /ranking — search_api_v1_ranking_retrieve
- POST /reindex — search_api_v1_reindex_create
- GET /suggest — search_api_v1_suggest_retrieve

## Error codes (54) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.search_bad_cursor [400] fix_input
- error.400.search_bad_geo [400] fix_input {reason}
- error.400.search_bad_range [400] fix_input {slug,reason}
- error.400.search_query_too_long [400] fix_input
- error.400.search_sort_needs_center [400] fix_input
- error.400.search_too_many_facets [400] fix_input {limit}
- error.400.search_too_many_ranges [400] fix_input {limit}
- error.400.search_unknown_doc_type [400] fix_input {doc_type}
- error.400.search_unknown_sort [400] fix_input {sort}
- error.400.search_window_exceeded [400] fix_input {window}
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.network_blocked [403] contact_support
- error.403.search_forbidden [403] retry
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.not_found [404] retry
- error.404.verification_challenge_not_found [404] verify
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.mandate_unavailable [503] retry
- error.503.search_backend_unavailable [503] retry
