
Deterministic, tenant-scoped resolution of company jargon to canonical entities.
Quickstart • Usage • Docs • Contributing
Every tenant calls the same thing something different. A tenant writes a lexicon
mapping their private word — flooff — to one of your canonical entities:
product. lexiqr loads that lexicon and turns free-form prompts into identified
matches, each carrying the character span it covers, its score tier, and any typo
it corrected.
Teams usually solve this by hardcoding synonym tables, retraining embeddings, or letting an LLM guess. lexiqr is a pip-installable resolution layer instead: deterministic, explainable, and scoped to one tenant.
Requires Python 3.10+. The wheel is pure Python with a single runtime dependency (RapidFuzz):
pip install lexiqr
or, with uv:
uv add lexiqr
A lexicon maps one tenant's private jargon to canonical entities. Here a German
tenant maps flooff to the product entity — this is the file the examples
below run against:
{
"schemaVersion": "1",
"defaultLocale": "de-DE",
"entities": {
"product": {
"locales": {
"de-DE": { "preferred": { "singular": "flooff" } }
}
}
}
}
Now resolve a prompt. Typo tolerance is on by default, so floof still resolves
and the match names what was typed:
from lexiqr import EntityResolver
resolver = EntityResolver.from_file("lexicon.json")
# "flooff" resolves to the product entity, with its character span and tier.
match = resolver.transform("wo ist flooff", locale="de-DE").matches[0]
print(
f"{match.canonical_id} <- {match.surface_form!r} at {match.span}, tier {match.score_tier.value}"
)
# The typo "floof" still resolves; the match names what was typed.
typo = resolver.transform("wo ist floof", locale="de-DE").matches[0]
print(f"corrected {typo.correction!r} -> {typo.surface_form!r}")
product <- 'flooff' at (7, 13), tier preferred
corrected 'floof' -> 'flooff'
Lexicon authors don't need Python. The same lexicon checks and runs from the
command line, so lexiqr validate confirms the file is well-formed —
lexiqr validate lexicon.json
lexicon.json: valid lexicon.
— and lexiqr try resolves a prompt against it, showing the same match the
developer sees:
lexiqr try lexicon.json --locale de-DE "wo ist flooff"
prompt: "wo ist [flooff]"
resolved via: de-DE
1 match:
[1] product ← "flooff"
tier: preferred locale: de-DE text: "flooff"
[!NOTE] Those blocks are the test suite. CI extracts them from this file, runs them, and compares the output to what you just read, so the quickstart cannot drift from the shipped API.
fuzzy=False.py.typed and strict-mypy clean, a round-tripping
report serialization, documented input limits, and a CI-enforced performance
envelope.lexiqr validate and lexiqr try work without writing Python, with exit
codes a script can branch on.The quickstart above is the five-minute story. This is the long one: a single command that resolves a realistic tenant lexicon and prints twelve narrated sections, each stating a claim, showing what lexiqr produced, and asserting it.
uv run python examples/demo.py
It exits non-zero, naming the section that failed, so it is a verification and
not a brochure. It reads examples/medien.lexicon.json,
a German media tenant. The run itself — examples/demo.py —
is one flat file you can read top to bottom and copy a section out of, importing
nothing but lexiqr and the standard library.
product, each reporting its own entry ID and filter (C19)fuzzy=False does not (C5)lexiqr validate and lexiqr try, with the exit codes a script reads (C14, C15)A transcript this long should not be read as the whole guarantee. Three claims it deliberately does not make, each owned by a gate of its own:
uv run pytest -m perf). See Performance envelope below.scripts/report_equality.golden.json.Below is an abridged excerpt of the real output — sections 5 and 6, with the
other ten elided. The full transcript is committed as
examples/demo.golden.txt, and the test suite
compares the command's output to it:
lexiqr — a sample run: every claim printed, every claim asserted.
lexicon: examples/medien.lexicon.json
--- 5. Two entries resolve to one entity, each with its own filter [C19] ---
match "zeig mir die filme" → product ← "filme" span=(13, 18) tier=preferred
locale=de-DE entry=movie filter={genre=drama|thriller, productType=Movie}
match "zeig mir die serien" → product ← "serien" span=(13, 19) tier=preferred
locale=de-DE entry=series filter={episodic=true, productType=Series}
--- 6. A typo resolves and carries its correction; with fuzzy off it does not [C5] ---
prompt "zeig mir die flme"
tolerant product ← "filme" span=(13, 17) tier=preferred locale=de-DE entry=movie
filter={genre=drama|thriller, productType=Movie} correction="flme"
exact EntityResolver.from_file(..., fuzzy=False) → 0 matches, resolved via de-DE
OK: every section held.
The quickstart resolves one word. These are the pieces you reach for next, in the order they usually come up.
Validation is construction. Lexicon.from_file (and from_dict) either
returns a lexicon lexiqr can trust or raises ValidationError naming the entity,
locale, and field at fault. So you can check a tenant's file on the way in — at
deploy time, in an upload handler, in your own tests — without building a
throwaway resolver to find out:
from lexiqr import Lexicon, ValidationError
try:
lexicon = Lexicon.from_file("lexicon.json")
except ValidationError as invalid:
print(f"rejected: {invalid}")
else:
print(f"valid: {sorted(lexicon.entries)} in {lexicon.default_locale}")
valid: ['product'] in de-DE
A Lexicon you already hold goes straight into a resolver —
EntityResolver(lexicon) — so nothing is parsed or validated twice.
A file that is not JSON at all raises MalformedDocumentError. It is a
ValidationError, so the except above already covers it. Catch it by name only
to tell "that file is not a lexicon document" apart from "that lexicon says the
wrong thing" — the distinction the CLI turns into its two exit codes.
EntityResolver(...), from_file and from_dict all accept a fuzzy keyword,
defaulting to True. Pass fuzzy=False for exact-only behaviour. The keyword is
public, semver-governed API.
transform() accepts a prompt of at most 10,000 characters (Unicode code
points), exported as MAX_PROMPT_LENGTH. A longer prompt raises
ValidationError before any matching work happens, so a pasted document is
rejected cheaply instead of taking a request thread with it. Reject or truncate
upstream if your callers can paste arbitrary text.
A single surface form is bounded too: at most 128 characters, exported as
MAX_SURFACE_FORM_LENGTH. That one is enforced when the lexicon loads rather
than when a prompt is matched — see
docs/lexicon-semantic-checks.md. Code that
generates labels should size them against the constant, not against a copy of the
number.
Both limits are fixed parts of the contract, not per-call arguments or configuration knobs. Changing either is a semver-visible change.
serialize_report(report) turns a MatchReport into a canonical string:
sorted keys, no insignificant whitespace, pure ASCII, and the match list in the
report's own order. Two byte-equal serializations mean two equal reports and
nothing else. So you can snapshot a result in your test suite, diff two snapshots
to see real behaviour change, or store one and compare it months later.
deserialize_report(text) is its inverse — the form round-trips.
from lexiqr import serialize_report, deserialize_report
snapshot = serialize_report(resolver.transform("wo ist flooff", locale="de-DE"))
# ... store `snapshot`, compare it later, or check it into your tests
Both functions are public, semver-governed API. The serialized shape can only change on a major release, so a patch or minor upgrade never silently invalidates a stored snapshot.
lexiqr resolves one tenant's lexicon per resolver and deliberately ships no tenant registry. Mapping tenants to resolvers is your composition, not lexiqr's, which keeps it a thin layer you control. The recipe is a cache of resolvers keyed by tenant, each built once:
# Illustrative recipe — not run in CI. Adapt the loader and cache to your stack.
from functools import lru_cache
from pathlib import Path
from lexiqr import EntityResolver, MatchReport
@lru_cache(maxsize=None)
def resolver_for(tenant_id: str) -> EntityResolver:
"""One resolver per tenant, built once and reused across requests."""
lexicon = Path("lexicons") / f"{tenant_id}.lexicon.json"
return EntityResolver.from_file(lexicon)
def resolve(tenant_id: str, prompt: str, locale: str) -> MatchReport:
return resolver_for(tenant_id).transform(prompt, locale)
A resolver is built once and then only read, so one instance per tenant is safe
to share across requests. Size the cache to your tenant count, or swap
lru_cache for whatever eviction your deployment already uses.
lexiqr is built to sit in a request path, so its performance is a stated, CI-enforced contract. Both numbers are measured against the seeded 1,000-surface-form benchmark lexicon:
transform() p95 < 10 msHow it is measured, so you can reproduce it: initialization is timed cold —
one resolver built once, nothing warmed. For transform(), a fixed set of
warm-up calls is discarded, then p95 is taken over a fixed number of timed
iterations. A long-but-under-limit prompt is measured too, so the
10,000-character limit is the only performance cliff rather than a hidden one
before it.
The gate is not the guarantee. The numbers above are the guarantee. The CI perf gate asserts that envelope times a 3× headroom factor (p95 < 30 ms, init < 3 s) on a single fixed runner. Shared CI runners are noisy, and the headroom turns that noise into a re-run rather than a false failure. Matching has to get roughly an order of magnitude slower to trip the gate, so catching subtle drift is not its job. That is why the raw timings are also recorded, un-gated, on every run.
lexiqr runs on Python 3.10, 3.11, 3.12, and 3.13 and follows Semantic Versioning. A version constraint is only as trustworthy as the surface the promise covers, so that surface is named explicitly. Semver governs:
lexiqr package:
EntityResolver and its from_file / from_dict / transform methods,
including the fuzzy keyword.Lexicon, the type EntityResolver takes, with its
validating from_file / from_dict constructors. Under it sits Entry, the
named set of surface forms an entity is keyed by, carrying the entity it
resolves to and the filter it holds. Then SurfaceForms, the shape an entry
holds per locale, and Metadata / MetadataValue, that filter and the values
it may hold.ValidationError and its coordinates
(canonical_id, locale, field), which the CLI renders verbatim, plus
MalformedDocumentError, the subclass raised when a file is not JSON at all.MatchReport, EntityMatch, and ScoreTier,
and the fields a caller reads off them: span, tier, correction, the entry that
answered, and its metadata.serialize_report and consumed by deserialize_report.MAX_PROMPT_LENGTH and
MAX_SURFACE_FORM_LENGTH, whose values are part of the contract.A breaking change to any of these is a major-version change. Everything else — internal modules, private helpers, log wording — can change in a patch. Read the CHANGELOG before upgrading; every release documents what changed.
This is a single-repo project: both the meta-repo (vision and blueprint) and the product repo, with all four C4 containers shipping from here as one wheel.
| Path | Container | What it is |
|---|---|---|
src/lexiqr/ (excl. cli/) | core | The deterministic resolution engine and public typed API |
src/lexiqr/cli/ | cli | lexiqr validate / lexiqr try for lexicon authors |
schema/ | schema | The versioned JSON Schema for lexicon files, plus the shared fixture corpus |
.github/workflows/, pyproject.toml | delivery | CI gates and tag→PyPI trusted publishing |
Guides for using lexiqr:
lexiqr validate / lexiqr try CLI, and its scriptable exit-code contractThe project's cross-container truth:
Two steps, no setup document to drift out of date — uv does the rest:
git clone https://github.com/bmeunier1974/lexiqr.git && cd lexiqr
uv sync # creates the venv and installs lexiqr plus its dev tools
uv run pytest # the same suite CI runs on every push and pull request
CONTRIBUTING.md describes the pull-request gate: lint, strict type-check, and tests on every supported Python. The release process, including the one-time PyPI trusted-publisher registration, lives in RELEASING.md. To report a security issue, see SECURITY.md.
MIT — see LICENSE.
120 commits
3 commits
Python
100.0%

Deterministic, tenant-scoped resolution of company jargon to canonical entities.
Quickstart • Usage • Docs • Contributing
Every tenant calls the same thing something different. A tenant writes a lexicon
mapping their private word — flooff — to one of your canonical entities:
product. lexiqr loads that lexicon and turns free-form prompts into identified
matches, each carrying the character span it covers, its score tier, and any typo
it corrected.
Teams usually solve this by hardcoding synonym tables, retraining embeddings, or letting an LLM guess. lexiqr is a pip-installable resolution layer instead: deterministic, explainable, and scoped to one tenant.
Requires Python 3.10+. The wheel is pure Python with a single runtime dependency (RapidFuzz):
pip install lexiqr
or, with uv:
uv add lexiqr
A lexicon maps one tenant's private jargon to canonical entities. Here a German
tenant maps flooff to the product entity — this is the file the examples
below run against:
{
"schemaVersion": "1",
"defaultLocale": "de-DE",
"entities": {
"product": {
"locales": {
"de-DE": { "preferred": { "singular": "flooff" } }
}
}
}
}
Now resolve a prompt. Typo tolerance is on by default, so floof still resolves
and the match names what was typed:
from lexiqr import EntityResolver
resolver = EntityResolver.from_file("lexicon.json")
# "flooff" resolves to the product entity, with its character span and tier.
match = resolver.transform("wo ist flooff", locale="de-DE").matches[0]
print(
f"{match.canonical_id} <- {match.surface_form!r} at {match.span}, tier {match.score_tier.value}"
)
# The typo "floof" still resolves; the match names what was typed.
typo = resolver.transform("wo ist floof", locale="de-DE").matches[0]
print(f"corrected {typo.correction!r} -> {typo.surface_form!r}")
product <- 'flooff' at (7, 13), tier preferred
corrected 'floof' -> 'flooff'
Lexicon authors don't need Python. The same lexicon checks and runs from the
command line, so lexiqr validate confirms the file is well-formed —
lexiqr validate lexicon.json
lexicon.json: valid lexicon.
— and lexiqr try resolves a prompt against it, showing the same match the
developer sees:
lexiqr try lexicon.json --locale de-DE "wo ist flooff"
prompt: "wo ist [flooff]"
resolved via: de-DE
1 match:
[1] product ← "flooff"
tier: preferred locale: de-DE text: "flooff"
[!NOTE] Those blocks are the test suite. CI extracts them from this file, runs them, and compares the output to what you just read, so the quickstart cannot drift from the shipped API.
fuzzy=False.py.typed and strict-mypy clean, a round-tripping
report serialization, documented input limits, and a CI-enforced performance
envelope.lexiqr validate and lexiqr try work without writing Python, with exit
codes a script can branch on.The quickstart above is the five-minute story. This is the long one: a single command that resolves a realistic tenant lexicon and prints twelve narrated sections, each stating a claim, showing what lexiqr produced, and asserting it.
uv run python examples/demo.py
It exits non-zero, naming the section that failed, so it is a verification and
not a brochure. It reads examples/medien.lexicon.json,
a German media tenant. The run itself — examples/demo.py —
is one flat file you can read top to bottom and copy a section out of, importing
nothing but lexiqr and the standard library.
product, each reporting its own entry ID and filter (C19)fuzzy=False does not (C5)lexiqr validate and lexiqr try, with the exit codes a script reads (C14, C15)A transcript this long should not be read as the whole guarantee. Three claims it deliberately does not make, each owned by a gate of its own:
uv run pytest -m perf). See Performance envelope below.scripts/report_equality.golden.json.Below is an abridged excerpt of the real output — sections 5 and 6, with the
other ten elided. The full transcript is committed as
examples/demo.golden.txt, and the test suite
compares the command's output to it:
lexiqr — a sample run: every claim printed, every claim asserted.
lexicon: examples/medien.lexicon.json
--- 5. Two entries resolve to one entity, each with its own filter [C19] ---
match "zeig mir die filme" → product ← "filme" span=(13, 18) tier=preferred
locale=de-DE entry=movie filter={genre=drama|thriller, productType=Movie}
match "zeig mir die serien" → product ← "serien" span=(13, 19) tier=preferred
locale=de-DE entry=series filter={episodic=true, productType=Series}
--- 6. A typo resolves and carries its correction; with fuzzy off it does not [C5] ---
prompt "zeig mir die flme"
tolerant product ← "filme" span=(13, 17) tier=preferred locale=de-DE entry=movie
filter={genre=drama|thriller, productType=Movie} correction="flme"
exact EntityResolver.from_file(..., fuzzy=False) → 0 matches, resolved via de-DE
OK: every section held.
The quickstart resolves one word. These are the pieces you reach for next, in the order they usually come up.
Validation is construction. Lexicon.from_file (and from_dict) either
returns a lexicon lexiqr can trust or raises ValidationError naming the entity,
locale, and field at fault. So you can check a tenant's file on the way in — at
deploy time, in an upload handler, in your own tests — without building a
throwaway resolver to find out:
from lexiqr import Lexicon, ValidationError
try:
lexicon = Lexicon.from_file("lexicon.json")
except ValidationError as invalid:
print(f"rejected: {invalid}")
else:
print(f"valid: {sorted(lexicon.entries)} in {lexicon.default_locale}")
valid: ['product'] in de-DE
A Lexicon you already hold goes straight into a resolver —
EntityResolver(lexicon) — so nothing is parsed or validated twice.
A file that is not JSON at all raises MalformedDocumentError. It is a
ValidationError, so the except above already covers it. Catch it by name only
to tell "that file is not a lexicon document" apart from "that lexicon says the
wrong thing" — the distinction the CLI turns into its two exit codes.
EntityResolver(...), from_file and from_dict all accept a fuzzy keyword,
defaulting to True. Pass fuzzy=False for exact-only behaviour. The keyword is
public, semver-governed API.
transform() accepts a prompt of at most 10,000 characters (Unicode code
points), exported as MAX_PROMPT_LENGTH. A longer prompt raises
ValidationError before any matching work happens, so a pasted document is
rejected cheaply instead of taking a request thread with it. Reject or truncate
upstream if your callers can paste arbitrary text.
A single surface form is bounded too: at most 128 characters, exported as
MAX_SURFACE_FORM_LENGTH. That one is enforced when the lexicon loads rather
than when a prompt is matched — see
docs/lexicon-semantic-checks.md. Code that
generates labels should size them against the constant, not against a copy of the
number.
Both limits are fixed parts of the contract, not per-call arguments or configuration knobs. Changing either is a semver-visible change.
serialize_report(report) turns a MatchReport into a canonical string:
sorted keys, no insignificant whitespace, pure ASCII, and the match list in the
report's own order. Two byte-equal serializations mean two equal reports and
nothing else. So you can snapshot a result in your test suite, diff two snapshots
to see real behaviour change, or store one and compare it months later.
deserialize_report(text) is its inverse — the form round-trips.
from lexiqr import serialize_report, deserialize_report
snapshot = serialize_report(resolver.transform("wo ist flooff", locale="de-DE"))
# ... store `snapshot`, compare it later, or check it into your tests
Both functions are public, semver-governed API. The serialized shape can only change on a major release, so a patch or minor upgrade never silently invalidates a stored snapshot.
lexiqr resolves one tenant's lexicon per resolver and deliberately ships no tenant registry. Mapping tenants to resolvers is your composition, not lexiqr's, which keeps it a thin layer you control. The recipe is a cache of resolvers keyed by tenant, each built once:
# Illustrative recipe — not run in CI. Adapt the loader and cache to your stack.
from functools import lru_cache
from pathlib import Path
from lexiqr import EntityResolver, MatchReport
@lru_cache(maxsize=None)
def resolver_for(tenant_id: str) -> EntityResolver:
"""One resolver per tenant, built once and reused across requests."""
lexicon = Path("lexicons") / f"{tenant_id}.lexicon.json"
return EntityResolver.from_file(lexicon)
def resolve(tenant_id: str, prompt: str, locale: str) -> MatchReport:
return resolver_for(tenant_id).transform(prompt, locale)
A resolver is built once and then only read, so one instance per tenant is safe
to share across requests. Size the cache to your tenant count, or swap
lru_cache for whatever eviction your deployment already uses.
lexiqr is built to sit in a request path, so its performance is a stated, CI-enforced contract. Both numbers are measured against the seeded 1,000-surface-form benchmark lexicon:
transform() p95 < 10 msHow it is measured, so you can reproduce it: initialization is timed cold —
one resolver built once, nothing warmed. For transform(), a fixed set of
warm-up calls is discarded, then p95 is taken over a fixed number of timed
iterations. A long-but-under-limit prompt is measured too, so the
10,000-character limit is the only performance cliff rather than a hidden one
before it.
The gate is not the guarantee. The numbers above are the guarantee. The CI perf gate asserts that envelope times a 3× headroom factor (p95 < 30 ms, init < 3 s) on a single fixed runner. Shared CI runners are noisy, and the headroom turns that noise into a re-run rather than a false failure. Matching has to get roughly an order of magnitude slower to trip the gate, so catching subtle drift is not its job. That is why the raw timings are also recorded, un-gated, on every run.
lexiqr runs on Python 3.10, 3.11, 3.12, and 3.13 and follows Semantic Versioning. A version constraint is only as trustworthy as the surface the promise covers, so that surface is named explicitly. Semver governs:
lexiqr package:
EntityResolver and its from_file / from_dict / transform methods,
including the fuzzy keyword.Lexicon, the type EntityResolver takes, with its
validating from_file / from_dict constructors. Under it sits Entry, the
named set of surface forms an entity is keyed by, carrying the entity it
resolves to and the filter it holds. Then SurfaceForms, the shape an entry
holds per locale, and Metadata / MetadataValue, that filter and the values
it may hold.ValidationError and its coordinates
(canonical_id, locale, field), which the CLI renders verbatim, plus
MalformedDocumentError, the subclass raised when a file is not JSON at all.MatchReport, EntityMatch, and ScoreTier,
and the fields a caller reads off them: span, tier, correction, the entry that
answered, and its metadata.serialize_report and consumed by deserialize_report.MAX_PROMPT_LENGTH and
MAX_SURFACE_FORM_LENGTH, whose values are part of the contract.A breaking change to any of these is a major-version change. Everything else — internal modules, private helpers, log wording — can change in a patch. Read the CHANGELOG before upgrading; every release documents what changed.
This is a single-repo project: both the meta-repo (vision and blueprint) and the product repo, with all four C4 containers shipping from here as one wheel.
| Path | Container | What it is |
|---|---|---|
src/lexiqr/ (excl. cli/) | core | The deterministic resolution engine and public typed API |
src/lexiqr/cli/ | cli | lexiqr validate / lexiqr try for lexicon authors |
schema/ | schema | The versioned JSON Schema for lexicon files, plus the shared fixture corpus |
.github/workflows/, pyproject.toml | delivery | CI gates and tag→PyPI trusted publishing |
Guides for using lexiqr:
lexiqr validate / lexiqr try CLI, and its scriptable exit-code contractThe project's cross-container truth:
Two steps, no setup document to drift out of date — uv does the rest:
git clone https://github.com/bmeunier1974/lexiqr.git && cd lexiqr
uv sync # creates the venv and installs lexiqr plus its dev tools
uv run pytest # the same suite CI runs on every push and pull request
CONTRIBUTING.md describes the pull-request gate: lint, strict type-check, and tests on every supported Python. The release process, including the one-time PyPI trusted-publisher registration, lives in RELEASING.md. To report a security issue, see SECURITY.md.
MIT — see LICENSE.
120 commits
3 commits
Python
100.0%