Skip to content

Auto (Zero-Config Evaluation)

stickler.auto

Zero-config evaluation of vanilla pydantic models.

Turn any pydantic.BaseModel (e.g. a Strands agent response_model) into a scored stickler evaluation with a single call, with no StructuredModel subclass, no JSON schema, no per-field configuration:

>>> import stickler
>>> result = stickler.evaluate(ground_truth, prediction)
>>> result.f1, result.field_scores

The comparison config (comparator, threshold, weight per field) is inferred from each field's python type and name. See auto/README.md for the inference rules and precedence.

evaluate, eval_for, EvalResult and EvalSpec are re-exported at the top level, so stickler.evaluate and stickler.auto.evaluate are the same function. InferredSpec and infer_field_config are public but only under stickler.auto.

Which path is this?

This is the inference path: it takes a live Pydantic class and infers a comparator and threshold per field from the type and the field name. The JSON Schema path takes a schema dict and reads structure only, never names, with different thresholds. See Choosing a Configuration Path.

stickler.auto.facade

Public zero-config evaluation surface.

The dead-simple entry point for evaluating structured output from a Strands agent (or any pydantic-producing system):

>>> import stickler
>>> pred = agent.structured_output(Invoice, "Extract the invoice: ...")
>>> result = stickler.evaluate(ground_truth, pred)
>>> print(result.f1, result.recall, result.field_scores)

No StructuredModel subclass, no JSON schema, no x-aws-stickler-* annotations. Both arguments are ordinary pydantic instances; the comparison config is inferred from their class (see :mod:.inference).

For a batch loop, compile once with :func:eval_for and reuse the returned :class:EvalSpec.

stickler.auto.facade.evaluate(ground_truth, prediction, *, weight_hints=False, match_threshold=None)

Evaluate a prediction against ground truth with zero configuration.

ground_truth and prediction must be pydantic instances of the same class (or a compatible superset, so extra/missing fields are tolerated). The comparison config is inferred from their class.

Parameters:

Name Type Description Default
ground_truth BaseModel

The reference instance.

required
prediction BaseModel

The instance to score (e.g. a Strands response_model).

required
weight_hints bool

Enable name-token weight heuristics (default off).

False
match_threshold Optional[float]

The similarity score at or above which an object counts as a match (drives EvalResult.matched and list-element TP/FN classification; does not change similarity scores). Left unset, a StructuredModel's own declaration wins; see :func:eval_for.

None

Returns:

Name Type Description
An EvalResult

class:EvalResult with overall_score, precision,

EvalResult

recall, f1, accuracy, field_scores, matched and

EvalResult

.explain().

Source code in stickler/auto/facade.py
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
def evaluate(
    ground_truth: BaseModel,
    prediction: BaseModel,
    *,
    weight_hints: bool = False,
    match_threshold: Optional[float] = None,
) -> EvalResult:
    """Evaluate a prediction against ground truth with zero configuration.

    ``ground_truth`` and ``prediction`` must be pydantic instances of the same
    class (or a compatible superset, so extra/missing fields are tolerated). The
    comparison config is inferred from their class.

    Args:
        ground_truth: The reference instance.
        prediction: The instance to score (e.g. a Strands ``response_model``).
        weight_hints: Enable name-token weight heuristics (default off).
        match_threshold: The similarity score at or above which an object
            counts as a match (drives ``EvalResult.matched`` and list-element
            TP/FN classification; does not change similarity scores). Left
            unset, a ``StructuredModel``'s own declaration wins; see
            :func:`eval_for`.

    Returns:
        An :class:`EvalResult` with ``overall_score``, ``precision``,
        ``recall``, ``f1``, ``accuracy``, ``field_scores``, ``matched`` and
        ``.explain()``.
    """
    cls = _shared_class(ground_truth, prediction)
    spec = eval_for(
        cls,
        weight_hints=weight_hints,
        match_threshold=match_threshold,
    )
    return spec.evaluate(ground_truth, prediction)

stickler.auto.facade.eval_for(cls, *, weight_hints=False, match_threshold=None)

Compile a reusable :class:EvalSpec for a pydantic class.

Parameters:

Name Type Description Default
cls Type[BaseModel]

The pydantic BaseModel subclass to evaluate instances of. A StructuredModel subclass is used AS CONFIGURED: its explicit comparators/thresholds are respected and nothing is inferred.

required
weight_hints bool

Apply name-token weight heuristics (default off, so weights stay uniform and precision/recall are not skewed by guessed business-criticality). Ignored for StructuredModel classes.

False
match_threshold Optional[float]

The similarity score at or above which an OBJECT counts as a match. It drives EvalResult.matched and, for List[Model] fields, the Hungarian TP/FN/FA classification of each element (at every nesting level). It does NOT change per-field similarity scores or overall_score.

Left unset, a StructuredModel subclass's own declared match_threshold wins, so the AS CONFIGURED promise above holds for this knob too; any other class gets DEFAULT_MATCH_THRESHOLD. Passing a value overrides both.

None
Source code in stickler/auto/facade.py
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
def eval_for(
    cls: Type[BaseModel],
    *,
    weight_hints: bool = False,
    match_threshold: Optional[float] = None,
) -> EvalSpec:
    """Compile a reusable :class:`EvalSpec` for a pydantic class.

    Args:
        cls: The pydantic ``BaseModel`` subclass to evaluate instances of. A
            ``StructuredModel`` subclass is used AS CONFIGURED: its explicit
            comparators/thresholds are respected and nothing is inferred.
        weight_hints: Apply name-token weight heuristics (default off, so
            weights stay uniform and precision/recall are not skewed by guessed
            business-criticality). Ignored for ``StructuredModel`` classes.
        match_threshold: The similarity score at or above which an OBJECT
            counts as a match. It drives ``EvalResult.matched`` and, for
            ``List[Model]`` fields, the Hungarian TP/FN/FA classification of
            each element (at every nesting level). It does NOT change
            per-field similarity scores or ``overall_score``.

            Left unset, a ``StructuredModel`` subclass's own declared
            ``match_threshold`` wins, so the AS CONFIGURED promise above holds
            for this knob too; any other class gets
            ``DEFAULT_MATCH_THRESHOLD``. Passing a value overrides both.
    """
    if isinstance(cls, type) and issubclass(cls, StructuredModel):
        # Explicit configuration wins: never re-infer over a model the user
        # already tuned. weight_hints has nothing to apply to here.
        #
        # `match_threshold` is a ClassVar on StructuredModel defaulting to
        # DEFAULT_MATCH_THRESHOLD, so reading it needs no hasattr guard and an
        # undeclared subclass lands on the same default a plain BaseModel gets.
        resolved = cls.match_threshold if match_threshold is None else match_threshold
        return EvalSpec(cls, cls, weight_hints=False, match_threshold=resolved)
    resolved = DEFAULT_MATCH_THRESHOLD if match_threshold is None else match_threshold
    eval_model = structured_model_for(
        cls,
        weight_hints=weight_hints,
        match_threshold=resolved,
    )
    return EvalSpec(
        cls, eval_model, weight_hints=weight_hints, match_threshold=resolved
    )

stickler.auto.facade.EvalSpec

A compiled, reusable evaluator for one pydantic class.

Build once with :func:eval_for, then call :meth:evaluate per pair. The inferred shadow StructuredModel is cached, so this is the efficient path for evaluating a dataset.

Source code in stickler/auto/facade.py
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
class EvalSpec:
    """A compiled, reusable evaluator for one pydantic class.

    Build once with :func:`eval_for`, then call :meth:`evaluate` per pair. The
    inferred shadow ``StructuredModel`` is cached, so this is the efficient path
    for evaluating a dataset.
    """

    def __init__(
        self,
        source_cls: Type[BaseModel],
        eval_model: Type,
        *,
        weight_hints: bool,
        match_threshold: float = DEFAULT_MATCH_THRESHOLD,
    ):
        self.source_cls = source_cls
        self.eval_model = eval_model
        self._weight_hints = weight_hints
        self._match_threshold = match_threshold

    def evaluate(
        self,
        ground_truth: Union[BaseModel, Dict[str, Any]],
        prediction: Union[BaseModel, Dict[str, Any]],
    ) -> EvalResult:
        """Score a single ground-truth / prediction pair.

        Accepts instances of the source class or plain dicts (e.g. rows loaded
        from a JSON dataset); dicts are validated into the source class first,
        so type coercion and error messages come from the user's own model.
        """
        gt = self.eval_model.from_json(_dump(self._coerce(ground_truth)))
        pred = self.eval_model.from_json(_dump(self._coerce(prediction)))
        raw = gt.compare_with(
            pred, include_confusion_matrix=True, add_derived_metrics=True
        )
        # `gt`/`pred` are retained so `EvalResult.non_matches` can be computed on
        # demand. Passing `document_non_matches=True` here instead would put the
        # cost on every caller: measured on a 40-item document it is ~25ms ->
        # ~50ms, flat regardless of how many fields actually fail, and almost
        # nobody printing a report is in that hot path.
        return EvalResult(raw, self, ground_truth=gt, prediction=pred)

    def _coerce(self, value: Union[BaseModel, Dict[str, Any]]) -> BaseModel:
        if isinstance(value, BaseModel):
            return value
        if isinstance(value, dict):
            return self.source_cls.model_validate(value)
        raise TypeError(
            f"expected a {self.source_cls.__name__} instance or a dict, "
            f"got {type(value).__name__}"
        )

    def explain(self) -> Dict[str, Dict[str, Any]]:
        """Return ``{field: {comparator, threshold, weight, source, why}}``.

        Makes every choice auditable. ``why`` is the ordered provenance trail;
        ``source`` is a coarse label (``type`` / ``name-token`` / ``degrade``,
        or ``explicit`` for a passthrough ``StructuredModel``).
        """
        out: Dict[str, Dict[str, Any]] = {}
        for name, spec in specs_for(
            self.source_cls,
            weight_hints=self._weight_hints,
            match_threshold=self._match_threshold,
        ).items():
            out[name] = {
                "comparator": spec.comparator_name,
                "threshold": spec.threshold,
                "weight": spec.weight,
                "clip_under_threshold": spec.clip_under_threshold,
                "source": spec.source,
                "why": spec.provenance,
            }
        return out

    def _explain_structured(self) -> Dict[str, Dict[str, Any]]:
        """Explain a passthrough StructuredModel from its explicit config."""
        return self.explain()

evaluate(ground_truth, prediction)

Score a single ground-truth / prediction pair.

Accepts instances of the source class or plain dicts (e.g. rows loaded from a JSON dataset); dicts are validated into the source class first, so type coercion and error messages come from the user's own model.

Source code in stickler/auto/facade.py
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
def evaluate(
    self,
    ground_truth: Union[BaseModel, Dict[str, Any]],
    prediction: Union[BaseModel, Dict[str, Any]],
) -> EvalResult:
    """Score a single ground-truth / prediction pair.

    Accepts instances of the source class or plain dicts (e.g. rows loaded
    from a JSON dataset); dicts are validated into the source class first,
    so type coercion and error messages come from the user's own model.
    """
    gt = self.eval_model.from_json(_dump(self._coerce(ground_truth)))
    pred = self.eval_model.from_json(_dump(self._coerce(prediction)))
    raw = gt.compare_with(
        pred, include_confusion_matrix=True, add_derived_metrics=True
    )
    # `gt`/`pred` are retained so `EvalResult.non_matches` can be computed on
    # demand. Passing `document_non_matches=True` here instead would put the
    # cost on every caller: measured on a 40-item document it is ~25ms ->
    # ~50ms, flat regardless of how many fields actually fail, and almost
    # nobody printing a report is in that hot path.
    return EvalResult(raw, self, ground_truth=gt, prediction=pred)

explain()

Return {field: {comparator, threshold, weight, source, why}}.

Makes every choice auditable. why is the ordered provenance trail; source is a coarse label (type / name-token / degrade, or explicit for a passthrough StructuredModel).

Source code in stickler/auto/facade.py
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
def explain(self) -> Dict[str, Dict[str, Any]]:
    """Return ``{field: {comparator, threshold, weight, source, why}}``.

    Makes every choice auditable. ``why`` is the ordered provenance trail;
    ``source`` is a coarse label (``type`` / ``name-token`` / ``degrade``,
    or ``explicit`` for a passthrough ``StructuredModel``).
    """
    out: Dict[str, Dict[str, Any]] = {}
    for name, spec in specs_for(
        self.source_cls,
        weight_hints=self._weight_hints,
        match_threshold=self._match_threshold,
    ).items():
        out[name] = {
            "comparator": spec.comparator_name,
            "threshold": spec.threshold,
            "weight": spec.weight,
            "clip_under_threshold": spec.clip_under_threshold,
            "source": spec.source,
            "why": spec.provenance,
        }
    return out

stickler.auto.facade.EvalResult

Flat, friendly view over a stickler comparison result.

Wraps the nested dict returned by StructuredModel.compare_with and exposes the metrics users actually reach for. The full raw dict is always available via :attr:raw.

Source code in stickler/auto/facade.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
class EvalResult:
    """Flat, friendly view over a stickler comparison result.

    Wraps the nested dict returned by ``StructuredModel.compare_with`` and
    exposes the metrics users actually reach for. The full raw dict is always
    available via :attr:`raw`.
    """

    def __init__(
        self,
        raw: Dict[str, Any],
        spec: "EvalSpec",
        *,
        ground_truth: Any = None,
        prediction: Any = None,
    ):
        self.raw = raw
        self._spec = spec
        # Kept only to compute `non_matches` lazily; see that property.
        self._ground_truth = ground_truth
        self._prediction = prediction
        self._non_matches: Optional[List[Dict[str, Any]]] = None
        cm = raw.get("confusion_matrix", {}) or {}
        derived = (cm.get("overall", {}) or {}).get("derived", {}) or {}
        self.overall_score: float = raw.get("overall_score", 0.0)
        self.field_scores: Dict[str, float] = raw.get("field_scores", {})
        self.precision: float = derived.get("cm_precision", 0.0)
        self.recall: float = derived.get("cm_recall", 0.0)
        self.f1: float = derived.get("cm_f1", 0.0)
        self.accuracy: float = derived.get("cm_accuracy", 0.0)
        self.confusion_matrix: Dict[str, Any] = cm
        # The `match_threshold` knob's model-level verdict: did this pair match?
        #
        # Defined directly rather than read from the engine's former
        # `all_fields_matched` key, which was removed in #287. That key was a
        # quantifier over TOP-LEVEL fields only and did not recurse, so a leaf
        # failure inside a nested field was invisible whenever the nested field's
        # own mean cleared its own threshold. Two external reports (#23, #275)
        # read it as a quantifier over every leaf, which is what the docs said and
        # what the name implies.
        #
        # `overall_score` is the weighted mean over the whole tree, so comparing
        # it against `match_threshold` gives one definition that cannot disagree
        # with the score sitting beside it. The threshold is whatever the spec
        # resolved: the caller's argument, else a StructuredModel's own declared
        # `match_threshold`, else DEFAULT_MATCH_THRESHOLD. See `eval_for`.
        self.matched: bool = bool(self.overall_score >= spec._match_threshold)

    @property
    def non_matches(self) -> List[Dict[str, Any]]:
        """The per-field failure records, computed on first access.

        Not requested during ``evaluate()``. ``document_non_matches=True`` costs
        roughly 2x on a 40-item document, flat whether one field fails or all of
        them, and the callers who want these records are printing a report rather
        than scoring a corpus. Computed here instead, once, and cached.

        Returns an empty list when the pair cannot be recompared (an
        ``EvalResult`` built directly from a raw dict, as some tests do), falling
        back to whatever the raw dict already carries.
        """
        if self._non_matches is not None:
            return self._non_matches

        carried = self.raw.get("non_matches")
        if carried is not None:
            self._non_matches = list(carried)
        elif self._ground_truth is None or self._prediction is None:
            self._non_matches = []
        else:
            detailed = self._ground_truth.compare_with(
                self._prediction,
                include_confusion_matrix=True,
                document_non_matches=True,
            )
            self._non_matches = list(detailed.get("non_matches") or [])
        return self._non_matches

    def explain(self) -> Dict[str, Dict[str, Any]]:
        """Per-field config + provenance, joined with THIS pair's scores.

        Extends :meth:`EvalSpec.explain` with what actually happened for this
        comparison: ``score`` (post-threshold), ``raw_similarity`` (before
        clipping, when the engine reports it), and a human-readable
        ``verdict`` such as ``"raw 0.56 < threshold 0.85 -> clipped to 0.0"``
        so a 0.0 is distinguishable between a near-miss and a total mismatch.
        """
        out = self._spec.explain()
        cm_fields = (self.raw.get("confusion_matrix") or {}).get("fields", {}) or {}
        for name, entry in out.items():
            if "." in name:  # per-pair detail is top-level only
                continue
            if name in self.field_scores:
                entry["score"] = self.field_scores[name]
            detail = cm_fields.get(name)
            if isinstance(detail, dict) and "raw_similarity_score" in detail:
                raw_sim = detail["raw_similarity_score"]
                entry["raw_similarity"] = raw_sim
                score = entry.get("score")
                threshold = entry.get("threshold")
                if (
                    score == 0.0
                    and raw_sim
                    and threshold is not None
                    and raw_sim < threshold
                ):
                    entry["verdict"] = (
                        f"raw {raw_sim:.2f} < threshold {threshold} -> clipped to 0.0"
                    )
        return out

    def __repr__(self) -> str:  # pragma: no cover - display only
        return (
            f"EvalResult(overall_score={self.overall_score:.3f}, "
            f"precision={self.precision:.3f}, recall={self.recall:.3f}, "
            f"f1={self.f1:.3f}, matched={self.matched})"
        )

non_matches property

The per-field failure records, computed on first access.

Not requested during evaluate(). document_non_matches=True costs roughly 2x on a 40-item document, flat whether one field fails or all of them, and the callers who want these records are printing a report rather than scoring a corpus. Computed here instead, once, and cached.

Returns an empty list when the pair cannot be recompared (an EvalResult built directly from a raw dict, as some tests do), falling back to whatever the raw dict already carries.

explain()

Per-field config + provenance, joined with THIS pair's scores.

Extends :meth:EvalSpec.explain with what actually happened for this comparison: score (post-threshold), raw_similarity (before clipping, when the engine reports it), and a human-readable verdict such as "raw 0.56 < threshold 0.85 -> clipped to 0.0" so a 0.0 is distinguishable between a near-miss and a total mismatch.

Source code in stickler/auto/facade.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def explain(self) -> Dict[str, Dict[str, Any]]:
    """Per-field config + provenance, joined with THIS pair's scores.

    Extends :meth:`EvalSpec.explain` with what actually happened for this
    comparison: ``score`` (post-threshold), ``raw_similarity`` (before
    clipping, when the engine reports it), and a human-readable
    ``verdict`` such as ``"raw 0.56 < threshold 0.85 -> clipped to 0.0"``
    so a 0.0 is distinguishable between a near-miss and a total mismatch.
    """
    out = self._spec.explain()
    cm_fields = (self.raw.get("confusion_matrix") or {}).get("fields", {}) or {}
    for name, entry in out.items():
        if "." in name:  # per-pair detail is top-level only
            continue
        if name in self.field_scores:
            entry["score"] = self.field_scores[name]
        detail = cm_fields.get(name)
        if isinstance(detail, dict) and "raw_similarity_score" in detail:
            raw_sim = detail["raw_similarity_score"]
            entry["raw_similarity"] = raw_sim
            score = entry.get("score")
            threshold = entry.get("threshold")
            if (
                score == 0.0
                and raw_sim
                and threshold is not None
                and raw_sim < threshold
            ):
                entry["verdict"] = (
                    f"raw {raw_sim:.2f} < threshold {threshold} -> clipped to 0.0"
                )
    return out

EvalResult attributes

Set in __init__ from the raw comparison dict, so they do not appear in the generated signature above:

Attribute Type Value
overall_score float Weighted average of all field scores
field_scores dict[str, float] Per-field score, after threshold clipping
precision float cm_precision from the overall confusion matrix
recall float cm_recall
f1 float cm_f1
accuracy float cm_accuracy
matched bool overall_score >= match_threshold. A convenience roll-up, not a per-field guarantee: individual fields can be below their thresholds while matched is True. Read field_scores or the confusion matrix for that.
confusion_matrix dict The full confusion-matrix subtree
raw dict The unmodified compare_with() result

Auditing the inferred config

explain() reports what was chosen and why, so an inferred evaluation is never a black box:

result = stickler.evaluate(gt, pred)
result.explain()["invoice_id"]
{'comparator': 'ExactComparator',
 'threshold': 1.0,
 'weight': 1.0,
 'clip_under_threshold': True,
 'source': 'name-token',
 'why': ['type:str -> LevenshteinComparator@0.7',
         'name-token:invoice_id -> ExactComparator@1.0'],
 'score': 0.0,
 'raw_similarity': 0.0}

why is the ordered trail: the type signal fired first, then the name token overrode it. Calling explain() on the EvalSpec instead omits score and raw_similarity, since no pair has been scored yet — use it to review the configuration before running a dataset.

Inference

The rules behind every decision above, plus the precedence between the type signal and the name-token refinement, are documented in src/stickler/auto/README.md.

stickler.auto.inference

Zero-config comparator inference for vanilla pydantic fields.

This module is the "brain" behind :func:stickler.evaluate. Given a single pydantic FieldInfo (the live one from cls.model_fields, never a JSON-schema round-trip) it decides which comparator/threshold/weight the field should be evaluated with, so an unconfigured model still gets a sensible, type-aware evaluation instead of the blind string-edit-distance fallback the raw comparison engine applies to unannotated fields.

Design rules (see auto/README.md):

  • Type first. The python annotation is the highest-value, always-present signal. bool/Enum/Literal -> Exact, int -> Numeric(exact), float -> Numeric(tolerant), date/datetime -> Date, str -> Levenshtein.
  • Name tokens refine. Field-name tokens (id, amount, email ...) sharpen the comparator/threshold on top of the type default.
  • Weights are honest. Business-criticality is not encoded in a vanilla model, so weights default to 1.0. Name-token weight bumps are opt-in via weight_hints=True and always recorded in provenance.
  • Never surprise. Semantic/BERT/LLM comparators are never auto-selected; a comparator that is unavailable in this environment degrades to Levenshtein/Exact and the degrade is recorded.

The public entry point is :func:infer_field_config, which returns an :class:InferredSpec. The builder turns that spec into a (type, Field) tuple for ModelFactory.create_model_from_fields.

stickler.auto.inference.infer_field_config(field_name, field_info, *, weight_hints=False, registry=None, match_threshold=None)

Infer a comparison spec for one pydantic field.

Parameters:

Name Type Description Default
field_name str

The field's name (drives name-token heuristics).

required
field_info FieldInfo

The live FieldInfo from cls.model_fields.

required
weight_hints bool

When True, apply name-token weight bumps. When False (default) all weights stay 1.0 so precision/recall are not skewed by guessed business-criticality.

False
registry Optional[ComparatorRegistry]

Comparator registry for the availability gate. Defaults to the global registry.

None
match_threshold Optional[float]

Object-level match threshold, used as the FIELD threshold for dict-typed fields so they are not exempt from a value the caller set. Defaults to _DICT_FIELD_THRESHOLD.

None

Returns:

Name Type Description
An InferredSpec

class:InferredSpec. Nested BaseModel / List[BaseModel]

InferredSpec

fields are NOT resolved here (the builder detects and recurses on them);

InferredSpec

this function only handles primitive and primitive-list fields.

Source code in stickler/auto/inference.py
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
def infer_field_config(
    field_name: str,
    field_info: FieldInfo,
    *,
    weight_hints: bool = False,
    registry: Optional[ComparatorRegistry] = None,
    match_threshold: Optional[float] = None,
) -> InferredSpec:
    """Infer a comparison spec for one pydantic field.

    Args:
        field_name: The field's name (drives name-token heuristics).
        field_info: The live ``FieldInfo`` from ``cls.model_fields``.
        weight_hints: When True, apply name-token weight bumps. When False
            (default) all weights stay 1.0 so precision/recall are not skewed by
            guessed business-criticality.
        registry: Comparator registry for the availability gate. Defaults to the
            global registry.
        match_threshold: Object-level match threshold, used as the FIELD
            threshold for dict-typed fields so they are not exempt from a value
            the caller set. Defaults to ``_DICT_FIELD_THRESHOLD``.

    Returns:
        An :class:`InferredSpec`. Nested ``BaseModel`` / ``List[BaseModel]``
        fields are NOT resolved here (the builder detects and recurses on them);
        this function only handles primitive and primitive-list fields.
    """
    registry = registry or get_global_registry()
    annotation, was_optional = unwrap_optional(field_info.annotation)
    provenance: List[str] = []
    if was_optional:
        provenance.append("optional: unwrapped to inner type")

    # 1) Type signal (safe, always on).
    comparator, config, threshold, clip = _type_default(
        annotation, provenance, match_threshold
    )

    # 2) Name-token refinement layered on the type default, gated on type
    #    compatibility: a rule whose comparator cannot parse this type keeps
    #    the type default (recorded in provenance) instead of silently
    #    scoring identical values 0.0.
    weight = 1.0
    family = _type_family(annotation)
    rule = _match_name_token(field_name)
    if rule is not None:
        if family is not None and family in rule.applies_to:
            comparator, config, threshold = (
                rule.comparator,
                dict(rule.config),
                rule.threshold,
            )
            provenance.append(
                f"{_NAME_TOKEN_APPLIED}{field_name} -> "
                f"{rule.comparator}@{rule.threshold}"
            )
            if weight_hints and rule.weight_hint != 1.0:
                weight = rule.weight_hint
                provenance.append(
                    f"{_NAME_TOKEN_APPLIED}{field_name} -> weight {rule.weight_hint}"
                )
            # Free-text fields keep partial credit rather than clipping to zero.
            if (
                rule.comparator == "FuzzyComparator"
                and rule.config.get("method") == "token_set_ratio"
            ):
                clip = False
        else:
            provenance.append(
                f"{_NAME_TOKEN_REFUSED}{field_name} matched {rule.comparator} but "
                f"type {_annotation_label(annotation)} is incompatible; "
                "keeping type default"
            )

    # 3) Availability gate (degrade unavailable comparators).
    comparator, config = _gate(comparator, config, registry, provenance)

    return InferredSpec(
        comparator_name=comparator,
        comparator_config=config,
        threshold=threshold,
        weight=weight,
        clip_under_threshold=clip,
        provenance=provenance,
    )

stickler.auto.inference.InferredSpec dataclass

Resolved comparison configuration for a single field.

Everything the builder needs to emit a ComparableField plus the provenance surfaced through EvalResult.explain().

Source code in stickler/auto/inference.py
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
@dataclass
class InferredSpec:
    """Resolved comparison configuration for a single field.

    Everything the builder needs to emit a ``ComparableField`` plus the
    provenance surfaced through ``EvalResult.explain()``.
    """

    comparator_name: str
    comparator_config: Dict[str, Any] = field(default_factory=dict)
    threshold: float = 0.5
    weight: float = 1.0
    clip_under_threshold: bool = True
    # Human-readable trail of how this spec was chosen, e.g.
    # ["type:float -> NumericComparator@0.95", "name-token:amount -> weight 2.5"].
    provenance: List[str] = field(default_factory=list)

    @property
    def source(self) -> str:
        """Coarse origin label for the final comparator decision.

        A name-token rule that MATCHED but was refused as incompatible with the
        field's type did not drive the decision -- the type default did -- and is
        recorded under `_NAME_TOKEN_REFUSED` rather than `_NAME_TOKEN_APPLIED` for
        exactly that reason. Counting it made a `str` field named `issued_date`
        report `name-token` while carrying the plain `LevenshteinComparator@0.7`
        that its type alone produced, telling a reader the name had been honoured
        in the one case where it was explicitly not.
        """
        for entry in reversed(self.provenance):
            if entry.startswith("degrade"):
                return "degrade"
        for entry in self.provenance:
            if entry.startswith("explicit"):
                return "explicit"
            if entry.startswith(_NAME_TOKEN_APPLIED):
                return "name-token"
        return "type"

source property

Coarse origin label for the final comparator decision.

A name-token rule that MATCHED but was refused as incompatible with the field's type did not drive the decision -- the type default did -- and is recorded under _NAME_TOKEN_REFUSED rather than _NAME_TOKEN_APPLIED for exactly that reason. Counting it made a str field named issued_date report name-token while carrying the plain LevenshteinComparator@0.7 that its type alone produced, telling a reader the name had been honoured in the one case where it was explicitly not.