Skip to content

Comparators

Comparators are the algorithms that determine how similar two field values are. Each comparator is optimized for a different data type or comparison strategy, returning a similarity score between 0.0 (completely different) and 1.0 (identical). When you define a StructuredModel, you assign a comparator to each field so Stickler knows how to evaluate that field.


Where the threshold comes from

Every comparator below except LLMComparator takes a threshold, and it can also be set on the field. Both spellings gate the same thing, TP vs FD classification and score clipping:

# These two fields behave identically.
ComparableField(comparator=LevenshteinComparator(threshold=0.8))
ComparableField(comparator=LevenshteinComparator(), threshold=0.8)

Precedence is field, then comparator, then 0.5:

You write Effective threshold
ComparableField(threshold=0.9, comparator=Lev(threshold=0.8)) 0.9, the field wins
ComparableField(comparator=Lev(threshold=0.8)) 0.8, from the comparator
ComparableField(comparator=Lev()) 0.5, not Levenshtein's 0.7
ComparableField() 0.5

The third row is the one to know: a comparator's default threshold is never adopted. Those defaults were chosen for binary_compare(), not as classification cutoffs, and at least one is actively wrong for the job. DateComparator defaults to 1.0 while awarding 0.7 partial credit for a match with no year, so adopting it would clip that feature to zero. If you want a comparator's default to act as the cutoff, name it.

See Thresholds and Metrics for how this interacts with model and runtime match thresholds.


Which Comparator Should I Use?

Comparator Best For Speed Needs AWS? Score Type
ExactComparator IDs, codes, booleans Instant No Binary (0.0 or 1.0)
NormalizedComparator Text where formatting differences are noise Instant No Binary (0.0 or 1.0)
LevenshteinComparator Names, addresses, text with typos Instant No Continuous (0.0--1.0)
NumericComparator Prices, quantities, measurements Instant No Binary (0.0 or 1.0)
DateComparator Date fields with mixed formats, partial dates, ranges Instant No Continuous (0.0--1.0)
FuzzyComparator Flexible text, descriptions, reordered tokens Fast No Continuous (0.0--1.0)
BBoxIoUComparator Bounding boxes, spatial localization Instant No Continuous (0.0--1.0)
ANLSStarComparator Dicts and nested structures whose keys you do not declare Moderate No Continuous (0.0--1.0)
SemanticComparator Meaning-based text similarity Moderate Yes (Bedrock) Continuous (0.0--1.0)
BERTComparator Contextual semantic similarity Moderate No (runs locally) Continuous (0.0--1.0)
LLMComparator Complex semantic evaluation with reasoning Slow Yes (Bedrock) Binary (0.0 or 1.0)

Comparator Details

ExactComparator

Checks for exact string matching. Case, whitespace, and punctuation are significant by default. Returns 1.0 for exact matches and 0.0 otherwise.

When to use: Critical identifiers, status codes, booleans, or any field where partial matches are meaningless.

from stickler import StructuredModel, ComparableField
from stickler import ExactComparator

class Order(StructuredModel):
    order_id: str = ComparableField(
        comparator=ExactComparator(),
        threshold=1.0,
        weight=3.0
    )

Key parameters:

Parameter Default Description
threshold 1.0 Similarity threshold for binary classification
case_sensitive True Whether comparison is case-sensitive

NormalizedComparator

Checks equality after a declared set of text transforms. By default it ignores case, Unicode whitespace, and Unicode punctuation. Punctuation means characters in the Unicode P* categories, so em dashes, curly quotes, and full-width punctuation are handled consistently. Symbols such as $, ±, and emoji remain significant, as do accents. NFC normalization makes composed and decomposed spellings equivalent.

When to use: OCR or LLM output where formatting drift is noise but edit distance is not meaningful, such as "U.S.A." versus "USA".

from stickler import ComparableField, NormalizedComparator, StructuredModel

class Contact(StructuredModel):
    name: str = ComparableField(comparator=NormalizedComparator())

Key parameters:

Parameter Default Description
threshold 1.0 Similarity threshold for binary classification
case_sensitive False Preserve case differences when True
ignore_whitespace True Remove Unicode whitespace
ignore_punctuation True Remove Unicode P* punctuation; symbols remain

LevenshteinComparator

Calculates the Levenshtein edit distance between two strings and returns a normalized similarity score: 1.0 - (edit_distance / max_length). Optionally normalizes input by stripping whitespace and lowercasing.

When to use: Names, addresses, free-form text where typos and minor variations are expected. This is the default comparator for string fields.

from stickler import StructuredModel, ComparableField
from stickler import LevenshteinComparator

class Contact(StructuredModel):
    name: str = ComparableField(
        comparator=LevenshteinComparator(threshold=0.8),
        weight=1.5
    )

Key parameters:

Parameter Default Description
threshold 0.7 Similarity threshold for binary classification
normalize True Strip whitespace and lowercase before comparison

NumericComparator

Extracts numeric values from strings or numbers and compares them with configurable tolerance. Handles currency symbols, commas, and accounting notation (e.g., (123) for negative values). Returns 1.0 if the numbers match within tolerance, 0.0 otherwise.

When to use: Prices, quantities, measurements, or any numeric field where small differences are acceptable.

from stickler import StructuredModel, ComparableField
from stickler import NumericComparator

class Invoice(StructuredModel):
    amount: float = ComparableField(
        comparator=NumericComparator(relative_tolerance=0.05),
        weight=2.0
    )

Key parameters:

Parameter Default Description
threshold 1.0 Similarity threshold for binary classification
relative_tolerance 0.0 Relative tolerance (e.g., 0.1 = 10%)
absolute_tolerance 0.0 Absolute tolerance (e.g., 0.01 for cents)
tolerance None Alias for absolute_tolerance (backward compatibility)

DateComparator

Parses both sides as dates (or date ranges) using python-dateutil, then scores the comparison on a tier system: same calendar day after surface-form normalization is 1.0, partial information (year-less, range-vs-single) is configurable, anything else is 0.0. Handles ISO/named-month/slash formats, two-digit years, day-of-week prefixes, and timezone-aware datetimes.

When to use: Any date field where surface form varies (different separators, formats, locales, two- vs four-digit years), or where ground truth and predictions may disagree on year-presence or use ranges.

from stickler import StructuredModel, ComparableField
from stickler.comparators import DateComparator

class Invoice(StructuredModel):
    invoice_date: str = ComparableField(
        comparator=DateComparator(allow_partial_year=True),
        threshold=0.7
    )

Key parameters:

Parameter Default Description
tolerance timedelta(0) Allowed difference for same-day comparisons (single dates only)
dayfirst None Interpretation hint for ambiguous numeric dates
allow_partial_year False If True, year-less ↔ year-bearing pairs with matching m/d score 0.7
range_mode "graded" How range comparisons are scored: "strict", "reject", "contains", or "graded"
precision_mode "exact" How month/day resolution mismatches score (Jan 2024 vs Jan 1, 2024): "exact", "gt_loose", or "overlap"

For the full behavior reference, configuration matrix, and corner cases, see the DateComparator page.


FuzzyComparator

Uses the rapidfuzz library for advanced fuzzy string matching. Supports multiple matching methods including standard ratio, partial matching, and token-based matching that is order-independent.

When to use: Descriptions, product names, or text where word order may vary or partial matches are valuable.

from stickler import StructuredModel, ComparableField
from stickler import FuzzyComparator

class Product(StructuredModel):
    description: str = ComparableField(
        comparator=FuzzyComparator(method="token_sort_ratio"),
        threshold=0.7
    )

Key parameters:

Parameter Default Description
threshold 0.7 Similarity threshold for binary classification
method "ratio" Matching method: "ratio", "partial_ratio", "token_sort_ratio", or "token_set_ratio"
normalize True Strip whitespace and lowercase before comparison

Matching methods explained:

  • ratio -- Standard Levenshtein distance ratio (similar to LevenshteinComparator but using rapidfuzz's optimized implementation).
  • partial_ratio -- Finds the best partial match within the longer string. Good when one value is a substring of the other.
  • token_sort_ratio -- Splits strings into tokens, sorts them, then compares. Handles reordered words (e.g., "John Smith" vs "Smith John").
  • token_set_ratio -- Splits into token sets, comparing the intersection and remainder. Handles extra or missing words.

Dependency

FuzzyComparator requires the rapidfuzz package. Install it with: pip install rapidfuzz


ANLSStarComparator

Scores a dict whose keys you did not declare, giving partial credit instead of a simple pass or fail.

When to use: a field like metadata: Dict[str, Any] holding whatever the extractor returned. Comparing such a field for equality tells you only "identical" or "not", so a prediction that got two keys of three right is indistinguishable from one that got nothing right. This comparator scores the difference.

When not to use: if you know the keys, declare a nested StructuredModel instead. You get the same partial credit plus a score per key, and each key can have its own comparator and threshold. Use ANLSStarComparator for the case where you could not have declared the shape.

A worked example

from typing import Any, Dict

from pydantic import BaseModel

import stickler


class Invoice(BaseModel):
    invoice_id: str
    metadata: Dict[str, Any] = {}


truth = Invoice(
    invoice_id="INV-1042",
    metadata={"vendor": "Acme Corporation", "terms": "Net 30", "po": "PO-88231"},
)
prediction = Invoice(
    invoice_id="INV-1042",
    metadata={"vendor": "Acme Corp", "terms": "Net 30", "po": "PO-88231"},
)

result = stickler.evaluate(truth, prediction)
print(result.field_scores["metadata"])   # 0.8542
print(result.overall_score)              # 0.9271

stickler.evaluate picks this comparator for a dict field automatically, so there is nothing to configure.

Running the same ground truth against a range of predictions shows what the score is actually measuring:

prediction's metadata score
identical, in any key order 1.0000
vendor abbreviated to "Acme Corp" 0.8542
an extra currency key 0.7500
po missing 0.6667
vendor renamed to vendor_name 0.5000
every value wrong, or empty 0.0000

Two things to read out of that table:

  • A missing key and an extra key both cost, because the score is averaged over the union of both key sets. That is why a renamed key (0.5000) scores worse than a simply missing one (0.6667): a rename is charged twice, once as absent from the prediction and once as unexpected in it.
  • Nesting works. Dicts inside dicts, and lists of dicts, are walked recursively, and list elements are paired by best fit rather than by position, so reordering a list of items does not penalise you.

Declaring it explicitly

To set a parameter, name the comparator on the field:

from stickler import ANLSStarComparator, ComparableField, StructuredModel


class Invoice(StructuredModel):
    metadata: dict = ComparableField(
        comparator=ANLSStarComparator(leaf_threshold=0.85)
    )

Key parameters:

Parameter Default Description
threshold 0.7 Score at or above which the whole mapping counts as a match
leaf_threshold 0.5 Cutoff below which a single value's similarity is treated as noise

leaf_threshold is applied to each value as the structure is walked, so it changes the score itself rather than only the verdict. On the abbreviated-vendor example above:

leaf_threshold score
0.5 (default) 0.8542
0.85 0.6667

At 0.85 the abbreviation stops counting at all, so that key contributes nothing.

When to raise it. Character similarity scales with length, so the same cutoff is lenient for a short value and strict for a long one. At the default, a wholly wrong short code still earns half credit:

kind ground truth prediction 0.5 0.7 0.85
state code CA CO 0.5000 0.0000 0.0000
status PAID PEND 0.5000 0.0000 0.0000
vendor name Acme Corporation Acme Corp 0.5625 0.0000 0.0000
description blue widget, 3 inch blue widget 3in 0.7895 0.7895 0.0000

Raise it toward 0.7 if the dict holds short codes, where one wrong character means wrong rather than close. Keep the default if it holds names or free text, where a genuine abbreviation should still count. Note the two columns disagree: 0.7 correctly rejects the wrong status but also discards the correct vendor abbreviation, so if one dict holds both kinds, no single value is right for both. Declare the fields you care about instead.

Do not set it to 0.0: with no cutoff, an unrelated value earns credit for incidental character overlap.

Every value is compared as text, whatever its type

Numbers, dates and identifiers are compared character by character. Incidental overlap in a long value therefore scores high, and scores higher than a genuine near-miss in ordinary text:

key ground truth prediction score
account DE89370400440532013000 DE89370400440532013001 0.9545
invoice_date 2024-01-15 2024-01-16 0.9000
amount 1000000 2000000 0.8571
total 1234.56 1234.57 0.8571
vendor Acme Corporation Acme Corp (truncated) 0.5625

A wrong account number, a date off by a day and a 2x-wrong amount all score above the one row that is a genuine near-miss.

It fails in the other direction too. Values that are numerically equal score below 1.0, or not at all, because their text differs:

ground truth prediction score
5 5.0 0.0000
1000 1000.0 0.6667
Decimal("10.50") Decimal("10.5") 0.8000

This is easy to hit by accident: a ground truth loaded from a database as an integer, against a prediction parsed from JSON as a float, is a perfect extraction scored as a miss. Declaring the field with NumericComparator compares the numbers instead of their spelling.

Lowering leaf_threshold will not separate these: a cutoff high enough to reject the account number also removes the partial credit you wanted. If a value's correctness matters, declare that field so it gets a comparator chosen for its type, such as NumericComparator or DateComparator. If you know some of the keys, declare those in a nested StructuredModel and leave the rest to this comparator.

Values with no JSON form score 0.0. Anything with a JSON representation is compared normally, including date, datetime, time, Decimal, UUID, Enum, set, tuple, bytes and Path. An arbitrary Python object has none, so it cannot be compared: it scores 0.0 whether or not the two sides are equal, warns once per type, and leaves the other keys' credit intact. Convert it to a JSON type before evaluating, or declare a nested StructuredModel if you know its shape.

Performance: walking a structure costs noticeably more than comparing two strings, and the cost multiplies inside a list, where every candidate pair is scored. If a large corpus feels slow, declare a nested StructuredModel for the keys you actually score.


BBoxIoUComparator

Compares two bounding boxes using Intersection over Union (IoU) as the similarity score. Accepts both two-point ([[x1, y1], [x2, y2]]) and flat ([x1, y1, x2, y2]) formats. Coordinates are automatically normalized so that x1 <= x2 and y1 <= y2.

When to use: Evaluating spatial localization accuracy for document fields, signature detection, logo identification, or any use case where you need to measure how well a predicted bounding box overlaps with ground truth.

from stickler import StructuredModel, ComparableField
from stickler.comparators import BBoxIoUComparator

class DocumentField(StructuredModel):
    bbox: list = ComparableField(
        comparator=BBoxIoUComparator(threshold=0.5),
        weight=1.0
    )

Key parameters:

Parameter Default Description
threshold 0.5 IoU threshold for binary match classification

Rich Value Pattern

For end-to-end mAP evaluation with per-field breakdown, use the Bounding Box mAP Metrics feature instead of using BBoxIoUComparator directly.


SemanticComparator

Uses AWS Bedrock Titan embeddings to generate vector representations of text, then computes cosine similarity. Captures meaning rather than surface-level string similarity.

When to use: Text fields where meaning matters more than exact wording. See LLM-as-a-Judge Comparators for a detailed guide.

from stickler import StructuredModel, ComparableField
from stickler import SemanticComparator

class Review(StructuredModel):
    summary: str = ComparableField(
        comparator=SemanticComparator(threshold=0.8),
        weight=1.0
    )

Key parameters:

Parameter Default Description
threshold 0.7 Similarity threshold for binary classification
model_id "amazon.titan-embed-text-v2:0" Bedrock embedding model ID
sim_function "cosine_similarity" Similarity function to use
embedding_function None Optional custom embedding function (bypasses Bedrock)

BERTComparator

Uses the BERTScore metric (via the evaluate library) to calculate contextual semantic similarity. Returns the F1 score component of BERTScore as the similarity measure. Runs entirely locally -- no API calls required.

When to use: Text fields where you need semantic understanding without cloud dependencies. See LLM-as-a-Judge Comparators for a detailed guide.

from stickler import StructuredModel, ComparableField
from stickler import BERTComparator

class Document(StructuredModel):
    summary: str = ComparableField(
        comparator=BERTComparator(threshold=0.85),
        weight=1.0
    )

Key parameters:

Parameter Default Description
threshold 0.7 Similarity threshold for binary classification

The default model is distilbert-base-uncased, loaded globally via the evaluate library.


LLMComparator

Uses a Large Language Model (via AWS Bedrock and the strands-agents library) to perform intelligent semantic comparisons. The LLM receives both values and optional evaluation guidelines, then returns a binary equivalence judgment. This is the most flexible comparator but also the most expensive.

When to use: Complex comparisons that require reasoning, domain-specific logic, or understanding of abbreviations and conventions. See LLM-as-a-Judge Comparators for a detailed guide.

from stickler import StructuredModel, ComparableField
from stickler import LLMComparator

class Address(StructuredModel):
    street: str = ComparableField(
        comparator=LLMComparator(
            model="us.amazon.nova-lite-v1:0",
            eval_guidelines="Consider street abbreviations equivalent (St=Street, Ave=Avenue)"
        ),
        threshold=0.8
    )

Key parameters:

Parameter Default Description
model Required Bedrock model ID string or a strands.models.Model instance
eval_guidelines None Custom guidelines for the LLM to follow during comparison

Dependency

LLMComparator requires the strands-agents package. Install it with: pip install stickler-eval[llm]


Default Comparators by Type

These are the defaults on the JSON Schema path — what from_json_schema picks for a property carrying no x-aws-stickler-comparator. Each property is parsed to a strict Python annotation, the comparator is chosen from that annotation, and the annotation is then widened back to the JSON value type so an invalid extraction scores 0.0 instead of raising. So format, enum and const participate in the choice — while field names never do — even though the field on the built class ends up a plain str. Read the result back with to_json_schema(), not from model_fields.

JSON Schema Type Default Comparator Default Threshold Rationale
string LevenshteinComparator 0.5 Handles typos and minor variations
number NumericComparator 0.5 Tolerates small numeric differences
integer NumericComparator 0.5 Tolerates small numeric differences
boolean ExactComparator 0.5 Must be exactly true or false (Exact returns only 0.0 or 1.0, so the threshold is immaterial)
string + "format": "date" or "date-time" DateComparator 1.0 Parses as date/datetime, so the field compares across formats
string + "enum" or a single-value const ExactComparator 1.0 Parses as an Enum/Literal, so only a listed value is valid
array (primitives) Based on item type Based on item type Inherits from element type
array (objects) Hungarian matching 0.5, pairing elements at 0.7 Optimal pairing of list elements
object Recursive comparison 0.7 Field-by-field nested comparison

A format the schema library does not map to a distinct type ("email", "hostname", "duration") parses as str, so the field keeps LevenshteinComparator at 0.5. To check any of this on your own schema:

StructuredModel.from_json_schema(schema).to_json_schema()["properties"]

The two paths do not share these defaults. Inference — stickler.evaluate, eval_for, from_pydantic — reads field names as well as types and picks different thresholds; see Choosing a Configuration Path. And a bare ComparableField() is a third case again: with no comparator=, it is LevenshteinComparator at 0.5 whatever the annotation.

Nested models: StructuredModel versus plain BaseModel

The object row above applies to a nested StructuredModel. A nested plain pydantic.BaseModel is a different case, and the difference is worth knowing because a plain model is what you already have if you are bringing a schema from elsewhere.

Nested annotation How it is compared Default comparator Per-field detail
StructuredModel recursively, field by field each field's own yes, one row per field
plain BaseModel as one object, key by key ANLSStarComparator no

A plain BaseModel carries no per-field comparison configuration, so there is nothing to score per field and nothing to report per field. It gets the same treatment a Dict[...] field gets, and for the same reason: the whole thing is one object, judged key by key, with partial credit and no score clipping. The elements of a List[plain BaseModel] get it too, so the two shapes agree.

Declaring a comparator overrides that default, exactly as it does for a dict.

A different class is a false discovery

The rule: two objects of different classes are a false discovery, whatever their attributes say. Identical field names and identical values do not make them a match, because the class is part of a value's identity rather than metadata about it. See Classification Logic for the full statement.

What this release enforces, for a plain pydantic.BaseModel and for the elements of a list of them:

Cat(name="rex")             vs  Dog(name="rex")         ->  0.0, fd=1   (not 1.0)
Base(a="x")                 vs  Sub(a="x")              ->  0.0, fd=1
StructuredShape(name="rex") vs  PlainShape(name="rex")  ->  0.0, fd=1

The last row is the mixed case: a StructuredModel on one side and a plain BaseModel on the other are still two different classes, and one is not even the same kind of model. Two StructuredModel instances of the same class are unaffected and keep their per-field breakdown.

Two StructuredModel classes are not covered yet

Pet against Cat scores 0.0 with fd=1 when both are plain BaseModels, and 1.0 with tp=1 when both are StructuredModels. The second is the older behaviour rather than a deliberate exception; the rule is the same for both and the code has caught up on one half so far. Tracked in #327. Annotate the field with a single model type if you need the guarantee today.

The class is part of the value's identity, not incidental to it. A correctly annotated field never sees this, because pydantic refuses a Dog for an Optional[Cat] field when the model is constructed. It applies where you declared that more than one class is allowed (Union[Cat, Dog], Any, object), or where a subclass arrived for its base, which Optional[Base] accepts.

Stickler warns once per field rather than raising, because which class arrives is a property of the prediction, and raising would end a bulk run partway through.

A refused list element is counted but not reported

A refused element is one fd in the confusion matrix, but it produces no entry in non_matches, so the counts and the item-level report disagree:

[Plain(sku='a')]  vs  [Cat(sku='a')]     ->  fd=1,  non_matches: []

An ordinary below-threshold element is reported, so this is specific to a refusal. The item-level report re-derives its own pairing without the field's comparator, which is why it cannot see the refusal. Read the counts, not non_matches, when you need to know whether a list element was refused. Tracked in #332.

The annotation has to name the model

The object-grade default is read from the annotation, so a field that declares no model type keeps the scalar default and is refused rather than scored:

Annotation Result
Optional[LineItem] scored, key by key
List[LineItem] scored, key by key
Optional[Annotated[LineItem, Field(...)]] scored, key by key
Optional[Any], Optional[object] refused: 0.0, fd=1, warning
Union[LineItem, str] refused: 0.0, fd=1, warning
List[Any] refused: 0.0, fd=1, warning

Annotated does not change the answer, in any nesting or spelling. That is worth stating because it is easy to reach by accident: Field(description=...) on an optional field produces Annotated[T, FieldInfo] | None, and pydantic keeps the wrapper on a union arm.

A nested StructuredModel is never refused by this rule, only by the class rule above. It is scored by recursion, so the field's comparator is not what judges it. In a mixed list, the plain elements are refused and the StructuredModel elements are scored:

items: Optional[List[Union[Cat, Note]]] = ComparableField()   # Note is a StructuredModel

[Cat("rex"), Note("a"), Note("b")]  vs  an identical copy  ->  tp=2, fd=1

Refusing looks harsh next to a number, but the number was worse. The scalar default is edit distance over the model's rendered form, and the field names are identical on both sides, so it cannot score low:

LineItem(quantity=2, unit_price=10.5, currency='USD')
  vs LineItem(quantity=9, unit_price=99.9, currency='EUR')   ->  0.8293

0.8293 clears the default threshold, so every value being wrong was reported as a true positive. A mapping in the same position has always been refused for exactly this reason, and plain models now agree with mappings.

The warning names both remedies. Either name the model in the annotation:

address: Optional[PlainAddress] = None          # scored

or declare the comparator, if the annotation genuinely has to stay open:

payload: Optional[Any] = ComparableField(comparator=ANLSStarComparator())

To get field-by-field detail, declare the nested model as a StructuredModel:

class Address(StructuredModel):                    # per-field detail
    city: str = ComparableField(threshold=0.9)
    postcode: str = ComparableField(comparator=ExactComparator())

class Invoice(StructuredModel):
    address: Address = ComparableField()

stickler.evaluate() does this for you: it wraps a plain BaseModel in a generated StructuredModel with inferred comparators, so the zero-config path scores nested models field by field without you declaring anything.


Custom Comparators

You can create your own comparator by extending BaseComparator. The only requirement is implementing the _compare method, which takes two values and returns a float between 0.0 and 1.0.

The BaseComparator Interface

compare is a template method: it applies the shared None policy, then delegates to _compare. You implement _compare; callers call compare.

Declare threshold as Optional[float] = None and put your default in DEFAULT_THRESHOLD. That is what lets a threshold the caller named be told apart from your class default, so ComparableField(comparator=YourComparator(threshold=0.9)) is honoured while a bare YourComparator() leaves the field on its own default.

A comparator that declares a concrete default instead (threshold: float = 1.0) still works, and warns once. Stickler cannot then tell your default from a caller's value, so it treats a threshold equal to your default as unset -- the pre-0.8 behaviour -- rather than silently making the field stricter than either of you asked for.

Pass threshold straight through; do not resolve your default first

Changing the signature but keeping the old resolution is worse than not migrating at all:

# WRONG -- every bare construction now looks caller-named
def __init__(self, threshold: Optional[float] = None):
    super().__init__(threshold if threshold is not None else 0.9)

BaseComparator decides explicitness from what it receives, so this hands it 0.9 for a bare YourComparator(). The field adopts 0.9 as its verdict threshold with clipping on, and nothing warns, because from the inside it is indistinguishable from a caller who asked for 0.9. Forward the parameter unchanged and put the default in DEFAULT_THRESHOLD:

DEFAULT_THRESHOLD = 0.9

def __init__(self, threshold: Optional[float] = None):
    super().__init__(threshold=threshold)
from stickler import BaseComparator

class BaseComparator(ABC):
    #: The threshold to use when the caller does not name one. Override per class.
    DEFAULT_THRESHOLD: float = 0.7

    def __init__(self, threshold: Optional[float] = None):
        # None, not a number, so a threshold the caller named stays
        # distinguishable from one nobody asked for. See below.
        self.threshold_was_set = threshold is not None
        self.threshold = self.DEFAULT_THRESHOLD if threshold is None else threshold

    def compare(self, str1: Any, str2: Any) -> float:
        """Apply the shared None policy, then delegate to _compare."""
        if str1 is None and str2 is None:
            return 1.0
        if str1 is None or str2 is None:
            return 0.0
        return self._compare(str1, str2)

    @abstractmethod
    def _compare(self, str1: Any, str2: Any) -> float:
        """Compare two present values. Neither argument is ever None.

        Args:
            str1: First value, never None
            str2: Second value, never None

        Returns:
            Similarity score between 0.0 and 1.0
        """
        pass

BaseComparator also provides:

  • __call__ -- makes the comparator callable directly (delegates to compare).
  • binary_compare -- converts the continuous similarity score to a (tp, fp) tuple based on the threshold.

None Handling

Every comparator treats None uniformly: two None values are an exact match (1.0); a None compared against a present value is a non-match (0.0). None is a missing value and never equals an empty string, which is a present but empty one.

The policy lives in BaseComparator.compare and nowhere else. You don't write any None-handling code in your comparator -- by the time _compare runs, both arguments are guaranteed not to be None.

Implement _compare, not compare

Overriding compare directly bypasses the None policy and reintroduces exactly the divergence this design prevents. Implement _compare.

A subclass of BaseComparator that does not implement _compare raises TypeError at construction. A subclass of a concrete comparator that supplies compare inherits _compare through the MRO, so it constructs — and emits a UserWarning at class definition, because its compare shadows the template method and can skip the None policy. If you are overriding compare deliberately, define _compare as well and the warning goes quiet (#215).

Example: Custom RegexComparator

import re
from typing import Any
from stickler import BaseComparator

class RegexComparator(BaseComparator):
    """Comparator that checks if a value matches a reference regex pattern."""

    DEFAULT_THRESHOLD = 1.0

    def __init__(self, threshold: Optional[float] = None):
        super().__init__(threshold=threshold)

    def _compare(self, pattern: Any, value: Any) -> float:
        # pattern and value are never None here -- BaseComparator handles that.
        try:
            return 1.0 if re.fullmatch(str(pattern), str(value)) else 0.0
        except re.error:
            return 0.0

Use it like any built-in comparator:

from stickler import StructuredModel, ComparableField

class PhoneRecord(StructuredModel):
    phone: str = ComparableField(
        comparator=RegexComparator(),
        threshold=1.0
    )

Next Steps

For a deep dive into the three AI-powered comparators (SemanticComparator, BERTComparator, and LLMComparator), see LLM-as-a-Judge Comparators.