Skip to main content

lexguard

Lexguard

The problem

You're evaluating an AI agent's reply: polite or rude, confident or overclaiming, clean or full of leaked secrets. The obvious move is to grep for a few words, and a single word list breaks fast: a reply that says "could you please fix the fucking bug" contains "please" and swears in the same breath. One list can't tell those apart — it either misses the swearing or scores the reply as polite.

Lexguard is still just word matching. The difference is a second list per concept: words that rule it back out. A check returns whether the concept holds, is simply absent, or is present but denied by the wording around it.

from lexguard import Politeness

print(Politeness("could you send this over when you get a sec?"))
#> True
print(Politeness("send me the report"))
#> False
print(Politeness("could you please fix the fucking bug"))
#> False

The last two both fail, but for different reasons: the second reply never mentions politeness, the third does and then undercuts it. .verdict(text).reason is how you tell those apart:

from lexguard import Politeness

print(Politeness.verdict("could you please fix the fucking bug").reason)
"""
politeness wording present but denied by: "fucking"
fix: add a courteous phrase (please, thanks, could you) and don't undercut it with sarcasm or profanity
"""

Paste your own text into the playground to watch every lexicon score it live in the browser, no install needed.

How it works

A Lexicon is a named set of words and phrases that signal a concept (indicates), the words that rule it back out (rules_out), and a one-sentence remedy for a hit (fix). It's matched by plain substring — no model in the loop, so a check is instant and deterministic. A set ships in the box already, tuned for agent transcripts — slop, sycophancy, leaked secrets, overclaimed confidence, politeness, and more; run lexguard to list every one. To extend one, edit its list: lexguard <name> prints it as source to paste into your own module. See Writing a lexicon.

A Lexicon is callable — Politeness(text) is shorthand for Politeness.matches(text), itself built on a three-valued .signal() (present / absent / denied), with .denied() alongside it for the third case. Reach for those when you just want a plain value. .verdict(text) is the richer pass/fail-plus-.reason built on top, and it's the same call every integration below wraps, whether that's an eval framework or a guardrail.

One lexicon, either turn

A lexicon detects a raw semantic act, not a speaker, so the same lexicon reads whichever turn you point it at. Correction is the act of taking back a previous statement: run it over the user's message to catch them revising their own request, or over the agent's message to catch the agent walking back its own output.

from lexguard import Correction

print(Correction("scratch that, use a set instead"))
#> True
print(Correction("on second thoughts that won't compile, let me change that"))
#> True

The built-ins are grouped into facets of meaning (time, intent, tone, epistemics, safety, and more), never by who is speaking, because who speaks is a wiring choice you make when you hand a turn to a check.

What a match means

Most lexicons are checked for absence — Slop, Confidential, Rudeness are things you don't want, so a match is the failure. A few, like Confirmation and Politeness, are checked for presence instead: they set fail_when_neutral=True, so silence is the failure.

matched? fail_when_neutral=False (default) fail_when_neutral=True
yes ✘ FAIL — e.g. Slop, found ✔ PASS — e.g. Confirmation, said
no ✔ PASS — e.g. Slop, clean ✘ FAIL — e.g. Confirmation, silent

See Writing a lexicon for the full explanation.

Install

uv add lexguard

The core has no dependencies. Each integration is its own extra:

uv add "lexguard[pydantic-evals]"
uv add "lexguard[deepeval]"
uv add "lexguard[inspect-ai]"
uv add "lexguard[pydantic-ai-harness]"

To use it as a dev tool without adding it to any particular project, install it as one:

uv tool install lexguard

Using it without an evals framework

.verdict() is a plain call over a string — nothing here needs Dataset, Case, or pydantic-evals in general, and you get the diagnostic reason for free:

from lexguard import Confidential


def guard(reply: str) -> str:
    verdict = Confidential.verdict(reply)
    if not verdict.passed:
        raise ValueError(verdict.reason)
    return reply


print(guard("send it over, all good"))
#> send it over, all good

A reply that actually leaks something raises with the full diagnosis attached — see Failures tell you what to change below for what that reason text looks like.

Running it inside pydantic-evals

LexguardEvaluator, from lexguard.integrations.evals.pydantic_evals, wraps a lexicon as an evaluator, for when you want the same check running as part of a Dataset alongside everything else.

from pydantic_evals import Case, Dataset

from lexguard import Apology, Postamble, Preamble, Slop, Sycophancy
from lexguard.integrations.evals.pydantic_evals import LexguardEvaluator


async def agent(prompt: str) -> str:
    return "Great question! Let us delve in. Hope this helps!"


dataset = Dataset(
    name="prose",
    cases=[Case(name="explainer", inputs="explain database indexing")],
    evaluators=[
        LexguardEvaluator(Slop),
        LexguardEvaluator(Preamble),
        LexguardEvaluator(Postamble),
        LexguardEvaluator(Sycophancy),
        LexguardEvaluator(Apology),
    ],
)
report = dataset.evaluate_sync(agent)
print(sorted(name for name, result in report.cases[0].assertions.items() if not result.value))
#> ['Postamble', 'Slop', 'Sycophancy']

Each rule checks exactly one lexicon and reports under its own name — a failure always points at exactly what fired.

Running it as a guardrail

lexguard_guard, from lexguard.integrations.guardrails.pydantic_ai, wraps a lexicon (or a Bundle of them) as a pydantic-ai-harness InputGuardrail/OutputGuardrail/ToolGuardrail guard. By default a failed verdict retries, handing the model the failure reason and another attempt; pass on_fail="block" to reject the value outright instead.

from pydantic_ai import Agent, UnexpectedModelBehavior
from pydantic_ai.models.test import TestModel
from pydantic_ai_harness.guardrails import OutputGuardrail

from lexguard import Slop
from lexguard.integrations.guardrails.pydantic_ai import lexguard_guard

agent = Agent(
    TestModel(custom_output_text="Let us delve into the intricate tapestry of caching."),
    capabilities=[OutputGuardrail(guard=lexguard_guard(Slop))],
)
try:
    agent.run_sync("explain caching")
except UnexpectedModelBehavior as exceeded:
    print(exceeded)
    #> Exceeded maximum output retries (1)

A guard can only return one result, so this is the one place a Bundle genuinely combines several lexicons into a single decision — a failure still lists every one that fired.

Failures tell you what to change

Every failing Verdict carries a reason, and every lexicon carries a fix. A Dataset report surfaces the first; called directly, a lexicon hands you the second on its own — enough to feed straight back into the agent for another turn instead of failing the whole run.

from lexguard import Slop

report = Slop.verdict("Let us delve into the intricate tapestry of indexing.")
print(report.reason)
"""
3 slop matches: "delve", "intricate", "tapestry"
  delve -> Let us delve into the intricate tapestry of in…
  intricate -> Let us delve into the intricate tapestry of indexing.
fix: swap for a plain verb or noun, or add these to the sampler ban list
"""

print(f"{Slop.name}: {Slop.fix}" if Slop.matches("caching skips repeated work") else None)
#> None

.hits() splits what it found into .indicated and .ruled_out, so a denied verdict can say what did the denying, not just that it happened:

from lexguard import Politeness

hits = Politeness.hits("could you please fix the fucking bug")
print(sorted(hits.indicated), sorted(hits.ruled_out))
#> ['could you', 'please'] ['fucking']
print(Politeness.verdict("could you please fix the fucking bug").reason)
"""
politeness wording present but denied by: "fucking"
fix: add a courteous phrase (please, thanks, could you) and don't undercut it with sarcasm or profanity
"""

.matches() and .verdict().passed are both exactly True/False — one Slop hit in a sentence and ten in a page fail identically. .density() gives the same indicated/ruled_out split as a real rate — the fraction of words that are hits, always in [0, 1] — instead of a raw count, so it stays comparable once text gets long enough that presence alone stops being the interesting question:

from lexguard import Slop

print(Slop.density("Let us delve into the intricate tapestry of caching.").indicated)
#> 0.3333333333333333

From the command line

lexguard lists the built-in lexicons, one per line; lexguard <name> prints one as Lexicon(...) source to paste into your code.

lexguard

You can pipe it to fzf (or another tool if installed) to get a fuzzy picker.

lexguard | fzf --preview 'lexguard {}'

Or you can drop a shell function in your ~/.zshrc so lg opens a fuzzy picker with a formatted, syntax-highlighted preview (via ruff and bat):

lg() {
  lexguard | fzf --ansi --preview 'lexguard {} | ruff format - | bat -l python --color=always --style=plain'
}

Docs

  • Lexicons: every lexicon that ships in the box, generated from source, one page per group
  • Writing a lexicon for your own domain
  • Agents under test with pydantic-ai
  • Integrations: evals (pydantic-evals, DeepEval, Inspect AI) and guardrails (pydantic-ai-harness), each with its own page

Prior art

The slop word lists overlap heavily with slop-forensics, which derives them statistically rather than by hand. Fold that list into a Slop copy in your own module if you want the empirical version. The abstain semantics, where a lexicon that does not apply records nothing rather than a free pass, is the same idea as a Snorkel labelling function returning None.

Release files for lexguard 0.1.21

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for lexguard 0.1.21
File Size Uploaded
lexguard-0.1.21.tar.gz 1.4 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for lexguard 0.1.21
File Interpreter ABI Platform
lexguard-0.1.21-py3-none-any.whl Python 3 none any Details

Total release size: 1.4 MB

Release files / lexguard-0.1.21.tar.gz

Download URL lexguard-0.1.21.tar.gz
Size 1.4 MB
Tags Source
SHA-256 checksum
How to use checksums
a0be395598f5cc2b17876fbc2e9f3f4b94465c3811bdeaebaf41ed3839a296fc
BLAKE2b-256 checksum
How to use checksums
e11e88d231b610c19345e7567babcce3cb50216f05eb74782af77a1e8f5b28ea
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release files / lexguard-0.1.21-py3-none-any.whl

Download URL lexguard-0.1.21-py3-none-any.whl
Size 41.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9493bc577c54fb9a6c2b4f9e3f266db15b6521ac57f45d6aeaff090d091effb2
BLAKE2b-256 checksum
How to use checksums
7a8e65c036e8e4ce9d8c06ad2e814f84c7eb5b2e0473252ec33d30ef25f2aca2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release history Release notifications | RSS feed

0.1.22

2 release files

This release

0.1.21 This release

2 release files

0.1.20

2 release files

0.1.19

2 release files

0.1.18

2 release files

0.1.12

2 release files

0.1.11

2 release files

0.1.10

2 release files

0.1.9

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page