Strands Evals
Strands Evals ships a
StructuredOutputSimilarity evaluator that scores an agent's structured output field by
field, with stickler as its scoring engine. You write ordinary Strands Evals, and
stickler supplies the comparison: type-aware comparators, order-independent list
matching, and a per-field confusion matrix.
The evaluator lives in Strands Evals, not in stickler. stickler is the optional
stickler extra it depends on.
pip install "strands-agents-evals[stickler]>=1.6.0"
from strands import Agent
from strands_evals import Case, Experiment, eval_task
from strands_evals.evaluators import StructuredOutputReport, StructuredOutputSimilarity
@eval_task()
def extract(case):
agent = Agent(system_prompt="You extract invoice data.", callback_handler=None)
result = agent(case.input, structured_output_model=Invoice)
# A dict passes through EvalTaskHandler untouched; anything else is str()'d,
# which would flatten the structured output into text.
return {"output": result.structured_output}
cases = [Case[str, Invoice](name="doc-1", input=ocr_text, expected_output=label)]
report = Experiment[str, Invoice](
cases=cases,
evaluators=[StructuredOutputSimilarity(Invoice)],
report_cls=StructuredOutputReport, # puts the rollups on the report
).run_evaluations(extract)
report.overall_score # weighted mean across the dataset
report.scores # one weighted score per case
report.per_case() # per-document field scores, in case order
report.metrics().field_metrics # per-field confusion matrix
report.display() # rich table; run_display() is the interactive variant
Why it exists
Strands Evals' deterministic evaluator for structured output is Equals:
whole-object ==, scoring 0.0 or 1.0. On real documents almost nothing matches
labels exactly, so Equals collapses to a near-constant near-zero. It cannot rank
two extractors, detect a regression, or say which field broke.
On a six-document sample where one prediction was perfect, one differed only in case, one had a number slightly off, one missed a field, one hallucinated a line item and one was wrong throughout:
| distinct scores | overall | |
|---|---|---|
Equals |
2 | 0.167 |
| stickler | 4 | 0.754 |
Equals gave 0.0 to five of six, including the one that differed only in
capitalisation. Both rows come from the cell that runs the two evaluators side by side in
Strands_Evals_Evaluator.ipynb
(Equals versus stickler), on the six cases defined just above it.
Two levels of detail
evaluate() returns one EvaluationOutput per case carrying stickler's weighted
overall_score, and reason names the weakest fields. Because that type has four
scalar fields, per-field detail travels on each row's metadata and is read from the
report, which is what report_cls=StructuredOutputReport is for:
report.per_case()returns per-document field scores, in case order.report.metrics()returns stickler's five-category confusion matrix per field path, including nested paths, computed once from the retained comparisons with no second pass over the data. Reach the field table throughreport.metrics().field_metrics.
A rollup covers one schema, so give each output type its own Experiment. A value from
another schema that still validates against this one (it shares field names and meets the
required fields) is scored as this schema; one that does not validate scores 0 and fails
rather than dropping out of the dataset mean as not-applicable. That case carries no field
detail, so it is absent from both rollups
above — report.scores is where you see it. Because the detail is on the report and not on
the evaluator, it survives a round trip through JSON —
StructuredOutputReport.from_file(path).metrics() rolls up a saved report. Reading it
back through EvaluationReport gives the base class, which has no rollups.
field tp fn fa fd prec rec f1
total_amount 4 0 0 2 0.67 1.00 0.80
invoice_date 4 1 0 1 0.80 0.80 0.80
line_items 5 0 1 1 0.71 1.00 0.83
The five categories separate failure modes a single score cannot. FN is a field
the extractor missed, FA one it invented, FD one it got wrong. Those need
different fixes, and Equals reports all three as the same 0.0.
A sharp edge worth knowing
test_pass is a document verdict, not a per-field one. Fields are weighted
uniformly by default, so on a five-field invoice one entirely wrong field costs only
0.2 and the document still clears the 0.7 default.
Separately, a field absent on both sides scores 1.0, because a value the model
correctly left blank is a value it got right. On a sparse extraction schema those
uninformative fields outvote the informative ones, and taken to its conclusion a
prediction that found nothing can clear the threshold on score alone. The evaluator
therefore requires both the weighted score and recall to clear
match_threshold; recall counts only fields that had a value to find, so it reads
0.0 for a blank extraction. Measured, on a 10-field schema with 2 populated:
| prediction | score | recall | test_pass |
|---|---|---|---|
| found nothing | 0.800 | 0.000 | False |
| both correct | 1.000 | 1.000 | True |
| vendor wrong | 0.900 | 1.000 | True |
For a stricter check than the default: raise match_threshold, gate on named fields
from report.per_case(), read fd from report.metrics(), or raise weight on the
fields you expect to be populated, which is usually cleanest because it makes the score
itself reflect what you care about. See
Sparse Objects.
Reading nested rows
Nested rows are counted per matched element pair, not per document. A document with
three line items contributes three to each line_items.* leaf, so on a schema with
several elements per document the child rows total well above the document count.
They are also gated: a pair only contributes child rows if it scored at or above
match_threshold: the evaluator's, for a plain model, or the element class's own, for a
StructuredModel element. Below it, gating treats the pair as atomic and
emits no field breakdown, so the document appears as fd on the list field and is
absent from the child rows. So line_items.sku showing tp=5 on a six-document set of
single-item invoices means five pairs were close enough to look inside, not that the SKU
was right five times out of six.
Nested leaves carry counts and precision/recall/F1 but no mean score, because stickler
emits no per-leaf score for list children
(#249). The row set is
data-dependent, so use .get() rather than indexing.
Notebooks
All three live in
examples/notebooks/. Two
of them import the evaluator, so they need strands-agents-evals[stickler]>=1.6.0.
Strands_Evals_Evaluator.ipynbis the reference. Offline and deterministic, no credentials, covers every feature above on six invoices broken six different ways.Strands_Evals_Offline_Agent.ipynbis the granular walkthrough: the tool spec Strands derives from the model, the raw tool-use JSON that comes back, the parsed object, and where each point was lost. A stored exchange replays through a stub model provider, so the realAgentstructured-output path runs with no credentials and no network call. That stored response is hand-authored to the Bedrock wire format rather than captured, and carries three deliberate extraction errors, so its scores demonstrate the mechanism and measure no model; one command re-records it against your own agent. Scores withstickler.evaluate()directly, so it needs no Strands Evals install, and it is the place to start if you have not seen an agent response before.Strands_Evals_FCC_Live_Agent.ipynbruns the same evaluator against a live agent: five real FCC invoices extracted by Claude Haiku through Bedrock, with the Bedrock call inside the@eval_task()function. It downloads its documents from the HuggingFace datasets server at run time. SetAWS_PROFILEbefore launching Jupyter; it needs Bedrock access.
Status
The StructuredOutputSimilarity evaluator ships in strands-agents-evals 1.6.0 on PyPI:
pip install "strands-agents-evals[stickler]>=1.6.0"
The stickler extra brings stickler-eval and pydantic 2.12, which the evaluator needs to
read a cached value back.
The evaluator's own design notes (why the field detail travels on the report rather than the evaluator, why a rollup covers one schema) live in the Field-Level Structured Output Scoring section of the Strands Evals README.
Two rough edges to know. Experiment.to_dict keeps only cases and
evaluators, so a reloaded experiment and the strands-evals run CLI both write the base
EvaluationReport; read their JSON back through StructuredOutputReport to roll it up. And
to_file serialises with json.dump rather than pydantic, on both the report and the
experiment, so a model with a datetime.date field raises Object of type date is not JSON
serializable. Write it through pydantic instead, which keeps the annotation and reloads
normally:
path.write_text(report.model_dump_json())
StructuredOutputReport.from_file(path).metrics()
Re-annotating the date as str also sidesteps it, but do not: the inferred comparator falls
from DateComparator at threshold 0.95 to LevenshteinComparator at 0.7, so a date wrong by
a month scores 0.9 and clears its threshold, where the date-typed field scores 0.0. Both
edges are harness limitations rather than the evaluator's.