Skip to content

feat: add inclusive language evaluator - #358

Open
max-rattray-aws wants to merge 2 commits into
strands-agents:mainfrom
max-rattray-aws:feat/inclusive-language-evaluator
Open

feat: add inclusive language evaluator#358
max-rattray-aws wants to merge 2 commits into
strands-agents:mainfrom
max-rattray-aws:feat/inclusive-language-evaluator

Conversation

@max-rattray-aws

Copy link
Copy Markdown
Contributor

Summary

Adds a deterministic InclusiveLanguage evaluator that scans actual_output for non-inclusive terms using case-insensitive word-boundary regex matching. No LLM required.

The evaluator ships with a configurable mapping of banned terms to suggested replacements. Users can override the default term list by passing a custom dict.

Usage

from strands_evals.evaluators.deterministic import InclusiveLanguage

# Use the built-in default term list
evaluator = InclusiveLanguage()

# Or bring your own
evaluator = InclusiveLanguage(terms={
    "blacklist": "denylist",
    "whitelist": "allowlist",
    "master": "primary",
    "slave": "replica",
})

What's tested

  • Default term detection (blacklist, whitelist, master, slave, blackday, whiteday)
  • Case-insensitive matching (uppercase, mixed case)
  • Word-boundary matching (no false positives on "masterful", "mastering", "slavery")
  • Custom term lists (override defaults, empty list always passes)
  • Edge cases (None output, empty string, numeric output)
  • Async path delegates to sync
  • Serialization via to_dict()
  • Multiple term detection with count in reason

Related to #344

Add a deterministic InclusiveLanguage evaluator that scans actual_output
for non-inclusive terms using case-insensitive word-boundary regex matching.

The evaluator ships with a configurable mapping of banned terms to suggested
replacements. Users can override the default term list by passing a custom
dict. No LLM required - pure regex.
@max-rattray-aws
max-rattray-aws requested a review from a team as a code owner August 10, 2026 16:46
@github-actions github-actions Bot added the enhancement New feature or request label Aug 10, 2026
@github-actions github-actions Bot added area-evaluators Evaluators: output, trajectory, tool use, interactions, and LLM-as-judge quality metrics strands-running labels Aug 10, 2026
name: Optional instance name for identification in reports.
"""
super().__init__(name=name)
self.terms = terms if terms is not None else self.DEFAULT_TERMS

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue (Critical): When terms is None, self.terms is bound directly to the mutable class-level DEFAULT_TERMS dict. Every default-constructed instance shares the same object, so any in-place mutation of self.terms leaks to all other instances and to the class attribute itself. to_dict() also returns that same shared reference. Confirmed:

a = InclusiveLanguage(); b = InclusiveLanguage()
a.terms["newterm"] = "x"
"newterm" in b.terms                       # True
"newterm" in InclusiveLanguage.DEFAULT_TERMS  # True

Suggestion: Copy the input so each instance owns its dict, e.g.:

self.terms = dict(terms) if terms is not None else dict(self.DEFAULT_TERMS)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the default path is fixed (dict(self.DEFAULT_TERMS)), which resolves the cross-instance/class-attribute leak. ✅

One residual, subtler issue in the same spot: the custom terms path still aliases the caller's dict (self.terms = terms), and _compiled_patterns is now snapshotted at construction. So if a caller mutates the dict they passed in after construction, to_dict() reflects the change but evaluate() keeps using the stale compiled patterns — the two drift out of sync. Reproduced:

custom = {"foo": "bar"}
e = InclusiveLanguage(terms=custom)
custom["blacklist"] = "denylist"
"blacklist" in e.to_dict()["terms"]   # True
e.evaluate(EvaluationData(input="q", actual_output="the blacklist"))[0].test_pass  # True (misses it)

Minor edge case, but copying the custom dict too (self.terms = dict(terms) if terms is not None else dict(self.DEFAULT_TERMS)) makes self.terms authoritative and keeps it consistent with _compiled_patterns.

"slave": "replica",
"blackday": "blocked day",
"whiteday": "clear day",
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue (Important – design): The bare tokens master and slave will fire on a lot of legitimate output — "master's degree", "master copy", "master class", quoting/discussing the terms, or referencing an external system literally named that way (e.g. a master git branch). Since a match flips test_pass to False, this default list risks a high false-positive rate for a deterministic guardrail. Additionally, blackday/whiteday aren't standard English words and read as placeholder entries.

Suggestion: Document the tradeoff and the opt-out (terms=...) explicitly in the class docstring, and reconsider whether master/slave/blackday/whiteday belong in the shipped defaults vs. a narrower, higher-precision set. This default term list is a customer-facing decision worth calling out for API review (see general comment).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring note on false positives clearly documents the tradeoff and the terms=... opt-out — that resolves this. ✅ Keeping the defaults as-is is a reasonable call now that the behavior is documented (this is exactly the kind of default-behavior decision worth confirming in API review).

text = str(evaluation_case.actual_output).lower()
found: list[tuple[str, str]] = []
for term, suggestion in self.terms.items():
if re.search(rf"\b{re.escape(term)}\b", text):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue (Suggestion): The \b{term}\b patterns are recompiled on every evaluate() call for every term. When running across large suites this repeats work unnecessarily.

Suggestion: Precompile once in __init__ (e.g. store [(re.compile(rf"\b{re.escape(t)}\b", re.IGNORECASE), t, s) for ...]) and reuse. This also lets you drop the .lower() on the whole text in favor of the IGNORECASE flag.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Precompiling in __init__ looks good. ✅ Minor note: the patterns are compiled without re.IGNORECASE and case-insensitivity relies on .lower()ing the text, so a custom terms dict with uppercase keys would silently never match. Defaults are all lowercase so this is latent, but lowercasing the term when compiling (or using re.IGNORECASE) would make it robust.

@github-actions

Copy link
Copy Markdown

Issue (Important – process): This PR adds a new public evaluator (InclusiveLanguage) to the package exports along with a shipped default banned-term list — both are customer-facing API decisions (default behavior, extensibility surface, and the term list content). The PR currently carries only enhancement/area-evaluators and does not have the needs-api-review label, nor does the description include the API-review artifacts (use cases, full signature with defaults, module exports, tenets/decisions alignment).

Suggestion: Add the needs-api-review label and expand the description with the API-review checklist so a designated reviewer can sign off on the public surface and the default term list before merge.

assert "legacy" in results[0].reason
assert "historical" in results[0].reason

def test_empty_terms_always_passes(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue (Important – testing): The output here is fully deterministic, and EvaluationOutput is a pydantic BaseModel that supports equality. Most tests assert individual fields (results[0].score, results[0].test_pass, substring checks on reason) across separate cases. Per-field assertions silently miss regressions in fields that aren't checked (e.g. an unexpected label, or a reason/score mismatch).

Suggestion: Assert the whole result list in a single equality check where the output is deterministic, e.g.:

assert results == [EvaluationOutput(score=1.0, test_pass=True, reason="no non-inclusive terms found")]

Keep the narrower substring assertions only for the multi-term reason case where ordering/formatting is the specific behavior under test.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nicely done — tests now assert the full EvaluationOutput list in a single equality check, which will catch regressions in any field. ✅

@github-actions

Copy link
Copy Markdown

Assessment: Request Changes

Clean, well-tested addition that follows the existing deterministic-evaluator pattern (28 tests pass). The main blocker is a shared-mutable-state bug on the default term list; the rest are design/process items to resolve before merge.

Review Categories
  • Correctness: Default-constructed instances share the mutable class-level DEFAULT_TERMS dict, so mutations leak across instances and into the class attribute (also surfaced via to_dict()). Copy on assignment.
  • API / Process: New public evaluator + shipped default term list are customer-facing decisions; PR lacks the needs-api-review label and API-review artifacts in the description.
  • Design: Bare master/slave defaults are prone to false positives on legitimate text, and blackday/whiteday read as placeholders — document the tradeoff and reconsider the shipped set.
  • Testing: Prefer full-object equality on EvaluationOutput (deterministic output, pydantic model) over per-field assertions to catch unchecked-field regressions.
  • Performance: Regexes are recompiled per call; precompile in __init__.

Nice to see a purpose-built, LLM-free evaluator with thorough word-boundary and edge-case coverage.

@github-actions

Copy link
Copy Markdown

Re-review of fix: address review feedback

Assessment: Comment (close to approval)

Thanks for the quick turnaround — the substantive code feedback is addressed and all 29 tests pass locally.

Status of previous items
  • Correctness (Critical): Default path now copies DEFAULT_TERMS — cross-instance/class leak fixed. One minor residual remains: the custom-terms path still aliases the caller's dict and can drift from the precompiled patterns (details in thread).
  • Performance: Regexes precompiled in __init__.
  • Design: False-positive tradeoff and opt-out documented in the docstring.
  • Testing: Refactored to full-object EvaluationOutput equality (+1 test).
  • API / Process: needs-api-review label still not applied, and the description doesn't yet include the API-review artifacts (use cases, full signature/defaults, module exports, tenets alignment). This is the main remaining gate before merge.

Nothing here is blocking on the code side beyond the minor custom-dict copy; the open item is the API-review label/artifacts so a designated reviewer can sign off on the public surface and default term list.

@JackYPCOnline

Copy link
Copy Markdown

@strandly-the-agent can you review this PR and give a breakdown of it? I am not very familar with eval package. what is our guideline of accepting new features.

@strandly-the-agent

Copy link
Copy Markdown

@JackYPCOnline here's the breakdown.

TL;DR — Code is clean and well-tested; two things matter. (1) There's a real bug: a custom terms dict with any uppercase letter silently never matches. (2) The bigger question is whether the package should ship a default banned-word list at all — I don't think it should; the primitive is welcome, the policy content isn't ours to own. And a correction: the automated review earlier in this thread (that's me, via the review workflow) invented a needs-api-review gate — no such label or process exists in this repo. Ignore that item. The real bar is below.

What the PR does, and how evaluators work in this package (for the unfamiliar)

An evaluator is a class subclassing Evaluator[InputT, OutputT] that takes one EvaluationData (the case: input, actual_output, optional trajectory) and returns list[EvaluationOutput] — each with score, test_pass, reason. Experiment(cases=[...], evaluators=[...]) runs them and aggregates. Two families: LLM-as-judge (HelpfulnessEvaluator, StereotypingEvaluator, …) and deterministic (Contains, Equals, StartsWith, ToolCalled, StateEquals) — cheap, no model call.

This PR adds a third deterministic output check, InclusiveLanguage (src/strands_evals/evaluators/deterministic/inclusive_language.py), exported from both …evaluators.deterministic and …evaluators. It regex-scans actual_output with \b word boundaries against a term→replacement dict, returns 1.0/pass when nothing matches, 0.0/fail listing hits otherwise. It ships a default list: blacklist, whitelist, master, slave, blackday, whiteday. 321 lines, 240 of them tests. I ran them: 29 pass, ruff clean.

The real bar for accepting a new feature here

Everything the repo actually documents — no hidden extra gate:

  1. Talk first. Significant work starts as an issue and waits for maintainer confirmation (CONTRIBUTING.md:29-36). ready for contribution is the green light; design means the shape needs agreeing first.
  2. Fits the six Development Tenets (CONTRIBUTING.md:42-48) — they're the explicit tie-breaker between two plausible designs.
  3. Small, focused, one logical change (AGENTS.md, and "Using AI Tools" in CONTRIBUTING.md).
  4. The human author can defend every line, agent-generated included.
  5. Follows package patternsEvaluator subclass returning list[EvaluationOutput], mirrored test file under tests/strands_evals/, LLM calls through strands.Agent, prompts as versioned _v0.py.
  6. Docs + an example, or an explicit "no docs needed" (PULL_REQUEST_TEMPLATE.md).
  7. Green hatch run prepare.

Against that: issue #344 has no maintainer response and no ready for contribution label, and the PR landed 7 days later — so the "wait for confirmation" step is the one genuinely outstanding. SKILL.md:172-183 lists the deterministic evaluators and wasn't updated, so the docs box isn't met either.

Recommendation: land the primitive, drop the shipped list

The gap is real — Contains needs one instance per term, has no word boundaries, and inverted semantics. But the default list doesn't survive checking:

  • blackday/whiteday appear in no recognized list — not woke's default rules (11 rules), not Google's word list, not the Inclusive Naming Initiative Tier-1, not IBM's. And White Day (Mar 14) and Black Day (Apr 14) are real holidays, so whiteday -> clear day tells a user to rename a holiday.
  • Bare master is stricter than woke, which only flags master-slave/master/slave. It fails "master's degree", "master volume", "Slave Lake, Alberta", and prose that correctly uses primary/replica while mentioning a legacy master.
  • Nobody here owns arbitration. INI puts red team in Tier 1 while IBM says no change needed — and this repo ships experimental.redteam. The first expansion request drops maintainers into that argument with no authority to cite. Tenet 6 ("embrace common standards") points at woke/alex/INI rather than a homegrown six-term list.
  • Every sibling deterministic evaluator is content-free and user-configured; SKILL.md:456's custom-evaluator example is literally PolicyComplianceEvaluator.

Concrete alternative: same code, reshaped as a generic word-boundary term-list check with required terms and no DEFAULT_TERMS (ContainsNone fits the Contains/StartsWith naming; ForbiddenTerms is clearer). It then also covers banned jargon, PII keywords, competitor names, style-guide checks. Inclusive language becomes a SKILL.md recipe pointing at woke/INI as the source of truth. ~90% of the code and tests carry over. If maintainers do want a shipped list, it needs external sourcing, opt-in (ForbiddenTerms.inclusive_language()), tiers instead of binary fail, and a named owner — that's a design-labelled discussion on #344, not a merge.

Code finding the earlier reviews missed: uppercase custom terms silently never match

inclusive_language.py lowercases the text (str(...).lower()) but compiles patterns from the raw term without re.IGNORECASE. So any custom term containing an uppercase letter can never match — a silent false pass, while the docstring promises case-insensitive matching:

e = InclusiveLanguage(terms={"Blacklist": "denylist"})
e.evaluate(EvaluationData(input="i", actual_output="this is a blacklist"))
# -> score=1.0 test_pass=True reason='no non-inclusive terms found'

Fix: compile with re.IGNORECASE and match against the original text (also fixes the reason string echoing lowercased terms), or normalize the keys to lowercase in __init__. Worth a test with a mixed-case custom term.

Happy to post the generic-vs-specific design question on #344 so the decision is tracked there — just say so.

@JackYPCOnline

Copy link
Copy Markdown

@max-rattray-aws Do you mind check the bugs that agent mention?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-evaluators Evaluators: output, trajectory, tool use, interactions, and LLM-as-judge quality metrics enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants