structured-eval 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {structured_eval-0.2.0 → structured_eval-0.3.0}/PKG-INFO +30 -9
- {structured_eval-0.2.0 → structured_eval-0.3.0}/README.md +26 -7
- {structured_eval-0.2.0 → structured_eval-0.3.0}/pyproject.toml +45 -11
- structured_eval-0.3.0/structured_eval/__init__.py +20 -0
- structured_eval-0.3.0/structured_eval/alignment/__init__.py +29 -0
- structured_eval-0.3.0/structured_eval/alignment/base.py +141 -0
- structured_eval-0.3.0/structured_eval/alignment/by_index.py +43 -0
- structured_eval-0.3.0/structured_eval/alignment/by_key.py +138 -0
- structured_eval-0.3.0/structured_eval/alignment/factory.py +44 -0
- structured_eval-0.3.0/structured_eval/alignment/hungarian.py +237 -0
- structured_eval-0.3.0/structured_eval/api.py +158 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/engine/__init__.py +6 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/engine/aggregator.py +61 -4
- structured_eval-0.3.0/structured_eval/engine/evaluator.py +157 -0
- structured_eval-0.3.0/structured_eval/engine/metric_runner.py +127 -0
- structured_eval-0.3.0/structured_eval/engine/parser.py +77 -0
- structured_eval-0.3.0/structured_eval/engine/report_builder.py +138 -0
- structured_eval-0.3.0/structured_eval/engine/tree_builder.py +571 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/formats/__init__.py +6 -0
- structured_eval-0.3.0/structured_eval/formats/base.py +45 -0
- structured_eval-0.3.0/structured_eval/formats/json_parser.py +79 -0
- structured_eval-0.3.0/structured_eval/formats/yaml_parser.py +43 -0
- structured_eval-0.3.0/structured_eval/integrations/__init__.py +11 -0
- structured_eval-0.3.0/structured_eval/integrations/_adapter.py +87 -0
- structured_eval-0.3.0/structured_eval/integrations/deepeval.py +131 -0
- structured_eval-0.3.0/structured_eval/integrations/langsmith.py +148 -0
- structured_eval-0.3.0/structured_eval/llm/__init__.py +36 -0
- structured_eval-0.3.0/structured_eval/llm/base.py +169 -0
- structured_eval-0.3.0/structured_eval/llm/callable.py +66 -0
- structured_eval-0.3.0/structured_eval/llm/chat_model.py +145 -0
- structured_eval-0.3.0/structured_eval/llm/exceptions.py +52 -0
- structured_eval-0.3.0/structured_eval/llm/factory.py +110 -0
- structured_eval-0.3.0/structured_eval/llm/litellm.py +173 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/__init__.py +10 -0
- structured_eval-0.3.0/structured_eval/metrics/array_accuracy.py +45 -0
- structured_eval-0.3.0/structured_eval/metrics/array_cardinality.py +39 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/array_exact_match.py +25 -6
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/array_f1.py +22 -1
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/array_jaccard_similarity.py +29 -8
- structured_eval-0.3.0/structured_eval/metrics/array_precision.py +68 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/array_prf1.py +30 -3
- structured_eval-0.3.0/structured_eval/metrics/array_recall.py +65 -0
- structured_eval-0.3.0/structured_eval/metrics/base.py +319 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/character_f1.py +31 -17
- structured_eval-0.3.0/structured_eval/metrics/composite_score.py +65 -0
- structured_eval-0.3.0/structured_eval/metrics/coverage_leaf_score.py +41 -0
- structured_eval-0.3.0/structured_eval/metrics/date_distance_score.py +85 -0
- structured_eval-0.3.0/structured_eval/metrics/exact.py +36 -0
- structured_eval-0.3.0/structured_eval/metrics/exponential_numeric_score.py +67 -0
- structured_eval-0.3.0/structured_eval/metrics/field_faithfulness.py +57 -0
- structured_eval-0.3.0/structured_eval/metrics/fuzzy.py +107 -0
- structured_eval-0.3.0/structured_eval/metrics/invoker.py +147 -0
- structured_eval-0.3.0/structured_eval/metrics/judge_faithfulness/__init__.py +23 -0
- structured_eval-0.3.0/structured_eval/metrics/judge_faithfulness/criteria.py +111 -0
- structured_eval-0.3.0/structured_eval/metrics/judge_faithfulness/metric.py +274 -0
- structured_eval-0.3.0/structured_eval/metrics/judge_faithfulness/prompt.py +63 -0
- structured_eval-0.3.0/structured_eval/metrics/judge_faithfulness/schemas.py +51 -0
- structured_eval-0.3.0/structured_eval/metrics/levenshtein.py +48 -0
- structured_eval-0.3.0/structured_eval/metrics/mean_score.py +42 -0
- structured_eval-0.3.0/structured_eval/metrics/numeric.py +116 -0
- structured_eval-0.3.0/structured_eval/metrics/numeric_closeness.py +50 -0
- structured_eval-0.3.0/structured_eval/metrics/object_accuracy.py +75 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/object_exact_match.py +21 -5
- structured_eval-0.3.0/structured_eval/metrics/object_f1.py +82 -0
- structured_eval-0.3.0/structured_eval/metrics/object_precision.py +81 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/object_prf1.py +37 -4
- structured_eval-0.3.0/structured_eval/metrics/object_recall.py +80 -0
- structured_eval-0.3.0/structured_eval/metrics/object_type_validity.py +55 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/overall_leaf_score.py +17 -5
- structured_eval-0.3.0/structured_eval/metrics/presence.py +35 -0
- structured_eval-0.3.0/structured_eval/metrics/regex_match.py +74 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/rule_pass_rate/__init__.py +2 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/rule_pass_rate/dsl.py +70 -12
- structured_eval-0.3.0/structured_eval/metrics/rule_pass_rate/engine.py +35 -0
- structured_eval-0.3.0/structured_eval/metrics/rule_pass_rate/metric.py +54 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/schema_validity/__init__.py +2 -0
- structured_eval-0.3.0/structured_eval/metrics/schema_validity/metric.py +60 -0
- structured_eval-0.3.0/structured_eval/metrics/schema_validity/validator.py +182 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/structural_similarity.py +16 -11
- structured_eval-0.3.0/structured_eval/metrics/token_f1.py +101 -0
- structured_eval-0.3.0/structured_eval/metrics/type_match.py +52 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/url_match.py +42 -21
- structured_eval-0.3.0/structured_eval/metrics/utils/__init__.py +8 -0
- structured_eval-0.3.0/structured_eval/metrics/utils/array.py +39 -0
- structured_eval-0.3.0/structured_eval/metrics/utils/calculate.py +95 -0
- structured_eval-0.3.0/structured_eval/metrics/utils/null.py +10 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/utils/number.py +21 -11
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/metrics/utils/object_utils.py +25 -19
- structured_eval-0.3.0/structured_eval/metrics/utils/value.py +23 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/models/__init__.py +17 -13
- structured_eval-0.3.0/structured_eval/models/config.py +196 -0
- structured_eval-0.3.0/structured_eval/models/context.py +34 -0
- structured_eval-0.3.0/structured_eval/models/metrics/__init__.py +19 -0
- structured_eval-0.3.0/structured_eval/models/metrics/collection.py +78 -0
- structured_eval-0.3.0/structured_eval/models/metrics/judge.py +37 -0
- structured_eval-0.3.0/structured_eval/models/metrics/result.py +79 -0
- structured_eval-0.3.0/structured_eval/models/nodes/__init__.py +20 -0
- structured_eval-0.3.0/structured_eval/models/nodes/array_node.py +57 -0
- structured_eval-0.3.0/structured_eval/models/nodes/base.py +146 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/models/nodes/object_node.py +7 -3
- structured_eval-0.3.0/structured_eval/models/nodes/scalar.py +13 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/models/result.py +120 -58
- structured_eval-0.3.0/structured_eval/models/sample.py +27 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/reporting/console.py +44 -6
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/utils/__init__.py +6 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/utils/flatten.py +29 -15
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/utils/paths.py +14 -7
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/utils/structured_diff.py +47 -19
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval.egg-info/PKG-INFO +30 -9
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval.egg-info/SOURCES.txt +17 -1
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval.egg-info/requires.txt +4 -1
- structured_eval-0.2.0/structured_eval/__init__.py +0 -27
- structured_eval-0.2.0/structured_eval/alignment/__init__.py +0 -15
- structured_eval-0.2.0/structured_eval/alignment/base.py +0 -40
- structured_eval-0.2.0/structured_eval/alignment/by_index.py +0 -24
- structured_eval-0.2.0/structured_eval/alignment/by_key.py +0 -73
- structured_eval-0.2.0/structured_eval/alignment/factory.py +0 -28
- structured_eval-0.2.0/structured_eval/alignment/hungarian.py +0 -156
- structured_eval-0.2.0/structured_eval/api.py +0 -79
- structured_eval-0.2.0/structured_eval/engine/evaluator.py +0 -72
- structured_eval-0.2.0/structured_eval/engine/metric_runner.py +0 -69
- structured_eval-0.2.0/structured_eval/engine/parser.py +0 -42
- structured_eval-0.2.0/structured_eval/engine/report_builder.py +0 -68
- structured_eval-0.2.0/structured_eval/engine/tree_builder.py +0 -319
- structured_eval-0.2.0/structured_eval/formats/base.py +0 -19
- structured_eval-0.2.0/structured_eval/formats/json_parser.py +0 -44
- structured_eval-0.2.0/structured_eval/formats/yaml_parser.py +0 -24
- structured_eval-0.2.0/structured_eval/integrations/__init__.py +0 -11
- structured_eval-0.2.0/structured_eval/integrations/_adapter.py +0 -47
- structured_eval-0.2.0/structured_eval/integrations/deepeval.py +0 -74
- structured_eval-0.2.0/structured_eval/integrations/langsmith.py +0 -90
- structured_eval-0.2.0/structured_eval/metrics/array_accuracy.py +0 -28
- structured_eval-0.2.0/structured_eval/metrics/array_cardinality.py +0 -27
- structured_eval-0.2.0/structured_eval/metrics/array_precision.py +0 -38
- structured_eval-0.2.0/structured_eval/metrics/array_recall.py +0 -37
- structured_eval-0.2.0/structured_eval/metrics/base.py +0 -161
- structured_eval-0.2.0/structured_eval/metrics/composite_score.py +0 -47
- structured_eval-0.2.0/structured_eval/metrics/coverage_leaf_score.py +0 -29
- structured_eval-0.2.0/structured_eval/metrics/date_distance_score.py +0 -69
- structured_eval-0.2.0/structured_eval/metrics/exact.py +0 -21
- structured_eval-0.2.0/structured_eval/metrics/exponential_numeric_score.py +0 -52
- structured_eval-0.2.0/structured_eval/metrics/field_faithfulness.py +0 -38
- structured_eval-0.2.0/structured_eval/metrics/fuzzy.py +0 -70
- structured_eval-0.2.0/structured_eval/metrics/invoker.py +0 -90
- structured_eval-0.2.0/structured_eval/metrics/levenshtein.py +0 -21
- structured_eval-0.2.0/structured_eval/metrics/mean_score.py +0 -31
- structured_eval-0.2.0/structured_eval/metrics/numeric.py +0 -91
- structured_eval-0.2.0/structured_eval/metrics/numeric_closeness.py +0 -39
- structured_eval-0.2.0/structured_eval/metrics/object_accuracy.py +0 -49
- structured_eval-0.2.0/structured_eval/metrics/object_f1.py +0 -49
- structured_eval-0.2.0/structured_eval/metrics/object_precision.py +0 -51
- structured_eval-0.2.0/structured_eval/metrics/object_recall.py +0 -46
- structured_eval-0.2.0/structured_eval/metrics/object_type_validity.py +0 -35
- structured_eval-0.2.0/structured_eval/metrics/presence.py +0 -22
- structured_eval-0.2.0/structured_eval/metrics/regex_match.py +0 -57
- structured_eval-0.2.0/structured_eval/metrics/rule_pass_rate/engine.py +0 -24
- structured_eval-0.2.0/structured_eval/metrics/rule_pass_rate/metric.py +0 -34
- structured_eval-0.2.0/structured_eval/metrics/schema_validity/metric.py +0 -38
- structured_eval-0.2.0/structured_eval/metrics/schema_validity/validator.py +0 -119
- structured_eval-0.2.0/structured_eval/metrics/token_f1.py +0 -83
- structured_eval-0.2.0/structured_eval/metrics/type_match.py +0 -35
- structured_eval-0.2.0/structured_eval/metrics/utils/__init__.py +0 -12
- structured_eval-0.2.0/structured_eval/metrics/utils/array.py +0 -31
- structured_eval-0.2.0/structured_eval/metrics/utils/calculate.py +0 -72
- structured_eval-0.2.0/structured_eval/metrics/utils/null.py +0 -20
- structured_eval-0.2.0/structured_eval/models/config.py +0 -124
- structured_eval-0.2.0/structured_eval/models/context.py +0 -25
- structured_eval-0.2.0/structured_eval/models/metric_result.py +0 -121
- structured_eval-0.2.0/structured_eval/models/nodes/__init__.py +0 -13
- structured_eval-0.2.0/structured_eval/models/nodes/array_node.py +0 -32
- structured_eval-0.2.0/structured_eval/models/nodes/base.py +0 -113
- structured_eval-0.2.0/structured_eval/models/nodes/scalar.py +0 -14
- structured_eval-0.2.0/structured_eval/models/sample.py +0 -19
- {structured_eval-0.2.0 → structured_eval-0.3.0}/LICENSE +0 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/setup.cfg +0 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/py.typed +0 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval/reporting/__init__.py +0 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval.egg-info/dependency_links.txt +0 -0
- {structured_eval-0.2.0 → structured_eval-0.3.0}/structured_eval.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: structured-eval
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: The LLM Structured Output Evaluation Framework
|
|
5
5
|
License: Apache-2.0
|
|
6
6
|
Project-URL: Homepage, https://github.com/kirillpechurin/structured-eval
|
|
@@ -33,12 +33,14 @@ Provides-Extra: diff
|
|
|
33
33
|
Requires-Dist: deepdiff>=7.0.0; extra == "diff"
|
|
34
34
|
Provides-Extra: align
|
|
35
35
|
Requires-Dist: scipy>=1.13.0; extra == "align"
|
|
36
|
+
Provides-Extra: litellm
|
|
37
|
+
Requires-Dist: litellm>=1.58.0; extra == "litellm"
|
|
36
38
|
Provides-Extra: deepeval
|
|
37
39
|
Requires-Dist: deepeval>=3.0.0; extra == "deepeval"
|
|
38
40
|
Provides-Extra: langsmith
|
|
39
41
|
Requires-Dist: langsmith>=0.8.0; extra == "langsmith"
|
|
40
42
|
Provides-Extra: all
|
|
41
|
-
Requires-Dist: structured-eval[align,diff,fuzzy,jsonschema,rules,yaml]; extra == "all"
|
|
43
|
+
Requires-Dist: structured-eval[align,diff,fuzzy,jsonschema,litellm,rules,yaml]; extra == "all"
|
|
42
44
|
Dynamic: license-file
|
|
43
45
|
|
|
44
46
|
# structured-eval
|
|
@@ -341,23 +343,42 @@ Learn more about strategies in
|
|
|
341
343
|
Level L5 lets you catch hallucinations by checking each value against a source.
|
|
342
344
|
Note that `expected` is not required for the computation.
|
|
343
345
|
|
|
344
|
-
|
|
346
|
+
Attach a judge to the part of the document worth paying for, and an LLM rules on every field
|
|
347
|
+
beneath it in **one** call — `supported` / `contradicted` / `not_stated`, with a reason:
|
|
345
348
|
|
|
346
349
|
```python
|
|
347
350
|
from structured_eval import evaluate
|
|
348
|
-
from structured_eval.models import EvalConfig
|
|
349
|
-
from structured_eval.metrics import
|
|
351
|
+
from structured_eval.models import EvalConfig, ObjectFieldConfig
|
|
352
|
+
from structured_eval.metrics import JudgeFaithfulness
|
|
350
353
|
|
|
351
354
|
report = evaluate(
|
|
352
|
-
actual={
|
|
355
|
+
actual={
|
|
356
|
+
"course": "Introduction to Python",
|
|
357
|
+
"instructor": {"name": "Dr. Rivera", "title": "professor"},
|
|
358
|
+
},
|
|
353
359
|
expected=None,
|
|
354
|
-
config=EvalConfig(
|
|
355
|
-
|
|
360
|
+
config=EvalConfig(fields={
|
|
361
|
+
"instructor": ObjectFieldConfig(
|
|
362
|
+
metrics=[JudgeFaithfulness(client="qwen/qwen3-235b-a22b-2507")]
|
|
363
|
+
)
|
|
364
|
+
}),
|
|
365
|
+
source="Introduction to Python is run by Dr. Rivera, a teaching assistant.",
|
|
356
366
|
)
|
|
357
367
|
|
|
358
|
-
report.metrics["
|
|
368
|
+
result = report.field_scores["instructor"].metrics["judge_faithfulness"]
|
|
369
|
+
float(result) # 0.5 — one of the two fields is grounded
|
|
370
|
+
result.extra["verdict"]["verdicts"]
|
|
371
|
+
# [{'path': 'instructor.name', 'verdict': 'supported', 'reason': ''},
|
|
372
|
+
# {'path': 'instructor.title', 'verdict': 'contradicted', 'reason': 'the source says
|
|
373
|
+
# teaching assistant'}]
|
|
359
374
|
```
|
|
360
375
|
|
|
376
|
+
`course` is never judged — only the subtree you attached the judge to costs money.
|
|
377
|
+
|
|
378
|
+
Learn more — [judge faithfulness](docs/metrics/catalog/judge-faithfulness.md) and
|
|
379
|
+
[LLM clients](docs/core-concepts/llm-clients.md). For a free, deterministic floor there is
|
|
380
|
+
also [field faithfulness](docs/metrics/catalog/field_faithfulness.md), a substring check.
|
|
381
|
+
|
|
361
382
|
### Logical consistency of values
|
|
362
383
|
|
|
363
384
|
Level L6 offers an interface for describing cross-field business rules with a
|
|
@@ -298,23 +298,42 @@ Learn more about strategies in
|
|
|
298
298
|
Level L5 lets you catch hallucinations by checking each value against a source.
|
|
299
299
|
Note that `expected` is not required for the computation.
|
|
300
300
|
|
|
301
|
-
|
|
301
|
+
Attach a judge to the part of the document worth paying for, and an LLM rules on every field
|
|
302
|
+
beneath it in **one** call — `supported` / `contradicted` / `not_stated`, with a reason:
|
|
302
303
|
|
|
303
304
|
```python
|
|
304
305
|
from structured_eval import evaluate
|
|
305
|
-
from structured_eval.models import EvalConfig
|
|
306
|
-
from structured_eval.metrics import
|
|
306
|
+
from structured_eval.models import EvalConfig, ObjectFieldConfig
|
|
307
|
+
from structured_eval.metrics import JudgeFaithfulness
|
|
307
308
|
|
|
308
309
|
report = evaluate(
|
|
309
|
-
actual={
|
|
310
|
+
actual={
|
|
311
|
+
"course": "Introduction to Python",
|
|
312
|
+
"instructor": {"name": "Dr. Rivera", "title": "professor"},
|
|
313
|
+
},
|
|
310
314
|
expected=None,
|
|
311
|
-
config=EvalConfig(
|
|
312
|
-
|
|
315
|
+
config=EvalConfig(fields={
|
|
316
|
+
"instructor": ObjectFieldConfig(
|
|
317
|
+
metrics=[JudgeFaithfulness(client="qwen/qwen3-235b-a22b-2507")]
|
|
318
|
+
)
|
|
319
|
+
}),
|
|
320
|
+
source="Introduction to Python is run by Dr. Rivera, a teaching assistant.",
|
|
313
321
|
)
|
|
314
322
|
|
|
315
|
-
report.metrics["
|
|
323
|
+
result = report.field_scores["instructor"].metrics["judge_faithfulness"]
|
|
324
|
+
float(result) # 0.5 — one of the two fields is grounded
|
|
325
|
+
result.extra["verdict"]["verdicts"]
|
|
326
|
+
# [{'path': 'instructor.name', 'verdict': 'supported', 'reason': ''},
|
|
327
|
+
# {'path': 'instructor.title', 'verdict': 'contradicted', 'reason': 'the source says
|
|
328
|
+
# teaching assistant'}]
|
|
316
329
|
```
|
|
317
330
|
|
|
331
|
+
`course` is never judged — only the subtree you attached the judge to costs money.
|
|
332
|
+
|
|
333
|
+
Learn more — [judge faithfulness](docs/metrics/catalog/judge-faithfulness.md) and
|
|
334
|
+
[LLM clients](docs/core-concepts/llm-clients.md). For a free, deterministic floor there is
|
|
335
|
+
also [field faithfulness](docs/metrics/catalog/field_faithfulness.md), a substring check.
|
|
336
|
+
|
|
318
337
|
### Logical consistency of values
|
|
319
338
|
|
|
320
339
|
Level L6 offers an interface for describing cross-field business rules with a
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "structured-eval"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "The LLM Structured Output Evaluation Framework"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "Apache-2.0" }
|
|
@@ -51,10 +51,13 @@ jsonschema = ["jsonschema>=4.20.0"]
|
|
|
51
51
|
rules = ["jsonpath-ng>=1.6.0"]
|
|
52
52
|
diff = ["deepdiff>=7.0.0"]
|
|
53
53
|
align = ["scipy>=1.13.0"]
|
|
54
|
+
litellm = ["litellm>=1.58.0"]
|
|
54
55
|
deepeval = ["deepeval>=3.0.0"]
|
|
55
56
|
langsmith = ["langsmith>=0.8.0"]
|
|
57
|
+
# Every capability of structured-eval itself.
|
|
58
|
+
# The host-framework adapters (deepeval / langsmith) are deliberately *not* here.
|
|
56
59
|
all = [
|
|
57
|
-
"structured-eval[yaml,fuzzy,jsonschema,rules,diff,align]"
|
|
60
|
+
"structured-eval[yaml,fuzzy,jsonschema,rules,diff,align,litellm]"
|
|
58
61
|
]
|
|
59
62
|
|
|
60
63
|
[dependency-groups]
|
|
@@ -65,14 +68,18 @@ dev = [
|
|
|
65
68
|
"mypy>=1.10",
|
|
66
69
|
"pre-commit>=4.6.0",
|
|
67
70
|
"pyyaml>=6.0.1",
|
|
68
|
-
"rapidfuzz>=3.0",
|
|
71
|
+
"rapidfuzz>=3.0.0",
|
|
69
72
|
"types-pyyaml>=6.0",
|
|
70
|
-
"jsonschema>=4.
|
|
71
|
-
"types-jsonschema>=4.
|
|
73
|
+
"jsonschema>=4.20.0",
|
|
74
|
+
"types-jsonschema>=4.20.0.20260518",
|
|
72
75
|
"jsonpath-ng>=1.6",
|
|
73
|
-
"deepdiff>=
|
|
74
|
-
"scipy>=1.
|
|
75
|
-
"scipy-stubs>=1.
|
|
76
|
+
"deepdiff>=7.0.0",
|
|
77
|
+
"scipy>=1.13.0",
|
|
78
|
+
"scipy-stubs>=1.13.0",
|
|
79
|
+
"litellm>=1.58.0",
|
|
80
|
+
# Host-framework adapters, needed here so tests/integration/ runs instead of skipping.
|
|
81
|
+
"deepeval>=3.0.0",
|
|
82
|
+
"langsmith>=0.8.0",
|
|
76
83
|
]
|
|
77
84
|
|
|
78
85
|
# ── Ruff ──────────────────────────────────────────────────────────────────────
|
|
@@ -82,6 +89,12 @@ line-length = 88
|
|
|
82
89
|
target-version = "py312"
|
|
83
90
|
|
|
84
91
|
[tool.ruff.lint]
|
|
92
|
+
# Preview is on *only* to reach the three pydoclint rules below. Without
|
|
93
|
+
# `explicit-preview-rules` it would also turn on every other preview rule in the
|
|
94
|
+
# selected families — RUF069 alone fires 152 times on legitimate `score == 1.0`
|
|
95
|
+
# comparisons. With it, only preview rules selected by exact code are enforced.
|
|
96
|
+
preview = true
|
|
97
|
+
explicit-preview-rules = true
|
|
85
98
|
select = [
|
|
86
99
|
"E", # pycodestyle errors
|
|
87
100
|
"W", # pycodestyle warnings
|
|
@@ -98,6 +111,10 @@ select = [
|
|
|
98
111
|
"T20", # flake8-print (no print() in library code)
|
|
99
112
|
"PT", # flake8-pytest-style
|
|
100
113
|
"PERF", # performance anti-patterns
|
|
114
|
+
"D", # pydocstyle (Google convention, see [tool.ruff.lint.pydocstyle])
|
|
115
|
+
"DOC201", # pydoclint: docstring omits a documented return value
|
|
116
|
+
"DOC402", # pydoclint: generator docstring omits Yields
|
|
117
|
+
"DOC501", # pydoclint: docstring omits a raised exception
|
|
101
118
|
"PLR1714",
|
|
102
119
|
"PLW2901", # select only non-noisy Pylint rules
|
|
103
120
|
]
|
|
@@ -118,10 +135,20 @@ indent-style = "space"
|
|
|
118
135
|
skip-magic-trailing-comma = false
|
|
119
136
|
line-ending = "auto"
|
|
120
137
|
|
|
138
|
+
[tool.ruff.lint.pydocstyle]
|
|
139
|
+
convention = "google"
|
|
140
|
+
|
|
141
|
+
[tool.ruff.lint.pydoclint]
|
|
142
|
+
# Google allows omitting `Returns:` when the summary already describes the
|
|
143
|
+
# return value. A one-line docstring is exactly that case, and this codebase
|
|
144
|
+
# leans on it heavily, so don't demand a section that would only restate it.
|
|
145
|
+
ignore-one-line-docstrings = true
|
|
146
|
+
|
|
121
147
|
[tool.ruff.lint.per-file-ignores]
|
|
122
148
|
"tests/**" = [
|
|
123
149
|
"ARG", # unused args common in fixtures/parametrize
|
|
124
150
|
"PLR2004", # magic numbers in test assertions are fine
|
|
151
|
+
"D103", # a test's name and its `parametrize` ids are the documentation
|
|
125
152
|
]
|
|
126
153
|
|
|
127
154
|
# ── Mypy ──────────────────────────────────────────────────────────────────────
|
|
@@ -137,7 +164,14 @@ warn_unreachable = true
|
|
|
137
164
|
|
|
138
165
|
# Optional third-party deps without type stubs (lazy-imported behind extras).
|
|
139
166
|
[[tool.mypy.overrides]]
|
|
140
|
-
module = [
|
|
167
|
+
module = [
|
|
168
|
+
"jsonpath_ng.*",
|
|
169
|
+
"deepeval.*",
|
|
170
|
+
"langsmith.*",
|
|
171
|
+
"deepdiff.*",
|
|
172
|
+
"rapidfuzz.*",
|
|
173
|
+
"litellm.*",
|
|
174
|
+
]
|
|
141
175
|
ignore_missing_imports = true
|
|
142
176
|
|
|
143
177
|
[[tool.mypy.overrides]]
|
|
@@ -161,7 +195,7 @@ addopts = "--import-mode=importlib"
|
|
|
161
195
|
markers = [
|
|
162
196
|
"unit: pure unit tests (single module, no engine)",
|
|
163
197
|
"engine: end-to-end through the Evaluator",
|
|
164
|
-
"integration:
|
|
198
|
+
"integration: real third-party libraries (deepeval/langsmith/litellm)",
|
|
165
199
|
"golden: regression on dataset fixtures",
|
|
166
200
|
"property: invariant / metamorphic tests over generated inputs",
|
|
167
201
|
]
|
|
@@ -174,7 +208,7 @@ branch = true
|
|
|
174
208
|
|
|
175
209
|
[tool.coverage.report]
|
|
176
210
|
show_missing = true
|
|
177
|
-
fail_under =
|
|
211
|
+
fail_under = 98
|
|
178
212
|
omit = ["structured_eval/integrations/*"]
|
|
179
213
|
exclude_lines = [
|
|
180
214
|
"pragma: no cover",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""structured_eval — field-level evaluation of structured LLM outputs.
|
|
2
|
+
|
|
3
|
+
The top level exposes only `evaluate`, `evaluate_batch` and
|
|
4
|
+
`evaluate_consistency`. Everything else is imported from its own subsystem:
|
|
5
|
+
|
|
6
|
+
- `structured_eval.models` — `Sample`, `EvalConfig`, `EvalReport` and the rest
|
|
7
|
+
of the data layer.
|
|
8
|
+
- `structured_eval.metrics` — every metric, the base hierarchy, `resolve_metric`
|
|
9
|
+
and the rule DSL.
|
|
10
|
+
- `structured_eval.alignment` / `.formats` / `.utils` — array alignment,
|
|
11
|
+
parsers, `flatten` / `structured_diff`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from structured_eval.api import evaluate, evaluate_batch, evaluate_consistency
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"evaluate",
|
|
18
|
+
"evaluate_batch",
|
|
19
|
+
"evaluate_consistency",
|
|
20
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Array alignment — pairing actual list items with expected ones.
|
|
2
|
+
|
|
3
|
+
An aligner answers a single question: which actual element does each expected
|
|
4
|
+
element correspond to? Array precision/recall/F1, per-element scores and
|
|
5
|
+
cardinality all read that pairing:
|
|
6
|
+
|
|
7
|
+
- `ArrayAligner` — the interface: implement `align` and you have a strategy.
|
|
8
|
+
- `ByIndexAligner` — pairs by position.
|
|
9
|
+
- `ByKeyAligner` — pairs on a matching key, greedily best-first.
|
|
10
|
+
- `HungarianAligner` — optimal one-to-one assignment, behind the `align` extra.
|
|
11
|
+
- `make_aligner` — builds the strategy an `ArrayFieldConfig` names.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from structured_eval.alignment.base import ArrayAligner, key_value, keyable
|
|
15
|
+
from structured_eval.alignment.by_index import ByIndexAligner
|
|
16
|
+
from structured_eval.alignment.by_key import ByKeyAligner
|
|
17
|
+
from structured_eval.alignment.factory import make_aligner
|
|
18
|
+
from structured_eval.alignment.hungarian import HungarianAligner, Scorer
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ArrayAligner",
|
|
22
|
+
"ByIndexAligner",
|
|
23
|
+
"ByKeyAligner",
|
|
24
|
+
"HungarianAligner",
|
|
25
|
+
"Scorer",
|
|
26
|
+
"key_value",
|
|
27
|
+
"keyable",
|
|
28
|
+
"make_aligner",
|
|
29
|
+
]
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""The `ArrayAligner` interface and the key helpers keyed strategies share."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
7
|
+
|
|
8
|
+
from structured_eval.utils.paths import MISSING, navigate
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from collections.abc import Iterable, Sequence
|
|
12
|
+
|
|
13
|
+
from structured_eval.models.nodes.array_node import ArrayMatchResult
|
|
14
|
+
|
|
15
|
+
# Sentinel for a key that cannot be extracted (absent, or element not a dict).
|
|
16
|
+
_MISSING_KEY = object()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def key_value(element: Any, key: str | None) -> Any:
|
|
20
|
+
"""The alignment key of an element: the whole element, or a named field.
|
|
21
|
+
|
|
22
|
+
Shared by every aligner that pairs on a key (`ByKeyAligner`,
|
|
23
|
+
`HungarianAligner`).
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
element: One array element.
|
|
27
|
+
key: Field path to key on, or `None` to key on the element itself.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
The element itself when `key` is `None`, the field's value when it is
|
|
31
|
+
there, `None` when the field is absent, and a private sentinel when
|
|
32
|
+
`key` is given but the element carries no fields at all. `keyable`
|
|
33
|
+
reads that sentinel back.
|
|
34
|
+
|
|
35
|
+
Example:
|
|
36
|
+
>>> from structured_eval.alignment import key_value
|
|
37
|
+
>>> key_value({"sku": "A-1", "qty": 2}, "sku")
|
|
38
|
+
'A-1'
|
|
39
|
+
>>> key_value({"sku": "A-1"}, "warehouse") is None # field absent
|
|
40
|
+
True
|
|
41
|
+
>>> key_value("A-1", None) # the whole element
|
|
42
|
+
'A-1'
|
|
43
|
+
"""
|
|
44
|
+
if key is None:
|
|
45
|
+
return element
|
|
46
|
+
if isinstance(element, dict):
|
|
47
|
+
value = navigate(element, key)
|
|
48
|
+
return None if value is MISSING else value
|
|
49
|
+
return _MISSING_KEY
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def keyable(values: Iterable[Any]) -> bool:
|
|
53
|
+
"""Could every part of this key be extracted from its element?
|
|
54
|
+
|
|
55
|
+
`key_value` answers a sentinel for an element that carries no fields at all,
|
|
56
|
+
and two sentinels are the same object — scoring one against another reads as
|
|
57
|
+
a perfect match. Every such pair would tie at 1.0 and be claimed in index
|
|
58
|
+
order, degenerating keyed alignment into alignment by position.
|
|
59
|
+
|
|
60
|
+
An element with no key matches nothing, so the aligners ask this first.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
values: The key values of one element, as `key_value` returned them.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
True when every value came from an element that could be keyed.
|
|
67
|
+
|
|
68
|
+
Example:
|
|
69
|
+
>>> from structured_eval.alignment import key_value, keyable
|
|
70
|
+
>>> keyable([key_value({"sku": "A-1"}, "sku")])
|
|
71
|
+
True
|
|
72
|
+
>>> keyable([key_value("A-1", "sku")]) # a scalar has no field to key on
|
|
73
|
+
False
|
|
74
|
+
"""
|
|
75
|
+
return all(value is not _MISSING_KEY for value in values)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def normalize_key(key: str | Sequence[str] | None, owner: str) -> list[str] | None:
|
|
79
|
+
"""One key or many, as the list of field paths every keyed aligner works on.
|
|
80
|
+
|
|
81
|
+
A lone field name becomes a one-field list, so a single-field key is just
|
|
82
|
+
the degenerate composite key.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
key: One field path, several of them, or `None`.
|
|
86
|
+
owner: The aligner's class name, used in the error message.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
The field paths as a list, or `None` — which passes through with its
|
|
90
|
+
meaning intact: key on the whole element.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
ValueError: If `key` is a sequence that names no field at all.
|
|
94
|
+
"""
|
|
95
|
+
if key is None:
|
|
96
|
+
return None
|
|
97
|
+
if isinstance(key, str):
|
|
98
|
+
return [key]
|
|
99
|
+
fields = list(key)
|
|
100
|
+
if not fields:
|
|
101
|
+
raise ValueError(f"{owner}: key must name at least one field")
|
|
102
|
+
return fields
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ArrayAligner(ABC):
|
|
106
|
+
"""Maps actual array items onto expected ones (the only role of a matcher).
|
|
107
|
+
|
|
108
|
+
An aligner decides *who pairs with whom* and nothing else: value scoring of
|
|
109
|
+
the matched pairs happens later, in the array metrics. Implement `align`
|
|
110
|
+
and the strategy is complete.
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
>>> from typing import Any
|
|
114
|
+
>>> from structured_eval.alignment import ArrayAligner
|
|
115
|
+
>>> from structured_eval.models import ArrayMatchResult, ArrayStrategy
|
|
116
|
+
>>> class ReversedAligner(ArrayAligner):
|
|
117
|
+
... def align(self, expected: list[Any],
|
|
118
|
+
... actual: list[Any]) -> ArrayMatchResult:
|
|
119
|
+
... n = min(len(expected), len(actual))
|
|
120
|
+
... return ArrayMatchResult(
|
|
121
|
+
... strategy=ArrayStrategy.BY_INDEX,
|
|
122
|
+
... matched=[(i, len(actual) - 1 - i) for i in range(n)],
|
|
123
|
+
... missed=list(range(n, len(expected))),
|
|
124
|
+
... spurious=list(range(n, len(actual))),
|
|
125
|
+
... )
|
|
126
|
+
>>> ReversedAligner().align(["a", "b"], ["b", "a"]).matched
|
|
127
|
+
[(0, 1), (1, 0)]
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
@abstractmethod
|
|
131
|
+
def align(self, expected: list[Any], actual: list[Any]) -> ArrayMatchResult:
|
|
132
|
+
"""Pair the actual elements with the expected ones.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
expected: The expected list.
|
|
136
|
+
actual: The actual list, as the document has it.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
An `ArrayMatchResult` holding the `(expected_idx, actual_idx)` pairs
|
|
140
|
+
plus the unmatched expected (missed) and actual (spurious) indices.
|
|
141
|
+
"""
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Positional array alignment — the `by_index` strategy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from structured_eval.alignment.base import ArrayAligner
|
|
8
|
+
from structured_eval.models.config import ArrayStrategy
|
|
9
|
+
from structured_eval.models.nodes.array_node import ArrayMatchResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ByIndexAligner(ArrayAligner):
|
|
13
|
+
"""Pairs the i-th expected item with the i-th actual item.
|
|
14
|
+
|
|
15
|
+
For positionally significant lists — steps, time series, rankings. No key
|
|
16
|
+
is compared: the surplus of the longer side is simply unmatched.
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
>>> from structured_eval.alignment import ByIndexAligner
|
|
20
|
+
>>> result = ByIndexAligner().align(["a", "b", "c"], ["a", "x"])
|
|
21
|
+
>>> result.matched
|
|
22
|
+
[(0, 0), (1, 1)]
|
|
23
|
+
>>> result.missed, result.spurious
|
|
24
|
+
([2], [])
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def align(self, expected: list[Any], actual: list[Any]) -> ArrayMatchResult:
|
|
28
|
+
"""Pair equal positions, leaving the longer side's tail unmatched.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
expected: The expected list.
|
|
32
|
+
actual: The actual list.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
An `ArrayMatchResult` pairing `(i, i)` up to the shorter length.
|
|
36
|
+
"""
|
|
37
|
+
n = min(len(expected), len(actual))
|
|
38
|
+
return ArrayMatchResult(
|
|
39
|
+
strategy=ArrayStrategy.BY_INDEX,
|
|
40
|
+
matched=[(i, i) for i in range(n)],
|
|
41
|
+
missed=list(range(n, len(expected))),
|
|
42
|
+
spurious=list(range(n, len(actual))),
|
|
43
|
+
)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Key-based array alignment — the `by_key` strategy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from structured_eval.alignment.base import (
|
|
8
|
+
ArrayAligner,
|
|
9
|
+
key_value,
|
|
10
|
+
keyable,
|
|
11
|
+
normalize_key,
|
|
12
|
+
)
|
|
13
|
+
from structured_eval.metrics.base import BaseMetric, resolve_metric
|
|
14
|
+
from structured_eval.metrics.exact import ExactMatch
|
|
15
|
+
from structured_eval.metrics.invoker import MetricInvoker
|
|
16
|
+
from structured_eval.models.config import ArrayStrategy
|
|
17
|
+
from structured_eval.models.nodes.array_node import ArrayMatchResult
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Sequence
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ByKeyAligner(ArrayAligner):
|
|
24
|
+
"""Pairs items whose keys match, greedily best-first (generalized matching).
|
|
25
|
+
|
|
26
|
+
Extracts a key from each element — the `key` field, or the whole element
|
|
27
|
+
when `key` is `None` — compares keys with `key_metric` and pairs them when
|
|
28
|
+
the score clears `threshold`. Matching by value and matching by similarity
|
|
29
|
+
are both this strategy, differing only in the metric.
|
|
30
|
+
|
|
31
|
+
A composite `key` such as `["sku", "warehouse"]` scores each field with
|
|
32
|
+
`key_metric` and takes their mean, so with the default `ExactMatch` and
|
|
33
|
+
`threshold=1.0` every field must match, while a soft `key_metric` lets a
|
|
34
|
+
strong field carry a weaker one.
|
|
35
|
+
|
|
36
|
+
Pairing is **globally greedy**: every candidate pair clearing the threshold
|
|
37
|
+
is ranked by score, highest first, and claimed one-to-one. A soft key
|
|
38
|
+
therefore picks the strongest available partner rather than the first one
|
|
39
|
+
found, and the outcome does not depend on element order.
|
|
40
|
+
|
|
41
|
+
With an exact key every passing score ties at 1.0, and this reduces to
|
|
42
|
+
first-match. It is the cheap, scipy-free approximation of the optimal
|
|
43
|
+
assignment `HungarianAligner` computes.
|
|
44
|
+
|
|
45
|
+
Example:
|
|
46
|
+
>>> from structured_eval.alignment import ByKeyAligner
|
|
47
|
+
>>> expected = [{"sku": "A-1", "qty": 2}, {"sku": "B-2", "qty": 5}]
|
|
48
|
+
>>> actual = [{"sku": "B-2", "qty": 5}, {"sku": "C-3", "qty": 1}]
|
|
49
|
+
>>> result = ByKeyAligner(key="sku").align(expected, actual)
|
|
50
|
+
>>> result.matched # B-2 pairs across the reordering
|
|
51
|
+
[(1, 0)]
|
|
52
|
+
>>> result.missed, result.spurious
|
|
53
|
+
([0], [1])
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
key: str | Sequence[str] | None = None,
|
|
59
|
+
key_metric: str | BaseMetric | None = None,
|
|
60
|
+
threshold: float = 1.0,
|
|
61
|
+
):
|
|
62
|
+
"""Set what the key is, how it is compared, and how close counts.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
key: Field path to key on, several of them for a composite key, or
|
|
66
|
+
`None` to key on the whole element.
|
|
67
|
+
key_metric: Metric comparing two keys, by instance or registered
|
|
68
|
+
name. Defaults to `ExactMatch`.
|
|
69
|
+
threshold: Key score at which a pair may be claimed.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If `key` is a sequence that names no field at all.
|
|
73
|
+
"""
|
|
74
|
+
self.key = normalize_key(key, self.__class__.__name__)
|
|
75
|
+
metric = ExactMatch() if key_metric is None else resolve_metric(key_metric)
|
|
76
|
+
self.scorer = MetricInvoker(metric)
|
|
77
|
+
self.threshold = threshold
|
|
78
|
+
|
|
79
|
+
def align(self, expected: list[Any], actual: list[Any]) -> ArrayMatchResult:
|
|
80
|
+
"""Claim the best-scoring key pairs one-to-one, best first.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
expected: The expected list.
|
|
84
|
+
actual: The actual list.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
An `ArrayMatchResult` whose pairs are reported in expected order.
|
|
88
|
+
"""
|
|
89
|
+
# Score every (expected, actual) pair on its key; keep those clearing
|
|
90
|
+
# the threshold. Generated in (ei, ai) order so a stable sort breaks
|
|
91
|
+
# score ties by that order (→ exact-key matches reproduce first-match).
|
|
92
|
+
e_keys = [self._key_of(item) for item in expected]
|
|
93
|
+
a_keys = [self._key_of(item) for item in actual]
|
|
94
|
+
candidates: list[tuple[float, int, int]] = []
|
|
95
|
+
for ei, e_key in enumerate(e_keys):
|
|
96
|
+
for ai, a_key in enumerate(a_keys):
|
|
97
|
+
score = self._key_score(e_key, a_key)
|
|
98
|
+
if score >= self.threshold:
|
|
99
|
+
candidates.append((score, ei, ai))
|
|
100
|
+
candidates.sort(key=lambda c: c[0], reverse=True) # best first; ties keep order
|
|
101
|
+
|
|
102
|
+
used_e: set[int] = set()
|
|
103
|
+
used_a: set[int] = set()
|
|
104
|
+
matched: list[tuple[int, int]] = []
|
|
105
|
+
for _score, ei, ai in candidates:
|
|
106
|
+
if ei in used_e or ai in used_a:
|
|
107
|
+
continue
|
|
108
|
+
used_e.add(ei)
|
|
109
|
+
used_a.add(ai)
|
|
110
|
+
matched.append((ei, ai))
|
|
111
|
+
matched.sort() # report pairs in expected order
|
|
112
|
+
|
|
113
|
+
missed = [ei for ei in range(len(expected)) if ei not in used_e]
|
|
114
|
+
spurious = [ai for ai in range(len(actual)) if ai not in used_a]
|
|
115
|
+
return ArrayMatchResult(
|
|
116
|
+
strategy=ArrayStrategy.BY_KEY,
|
|
117
|
+
matched=matched,
|
|
118
|
+
missed=missed,
|
|
119
|
+
spurious=spurious,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# ── key extraction & scoring ────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def _key_of(self, element: Any) -> list[Any]:
|
|
125
|
+
"""The element's key: one value per configured field, or the element."""
|
|
126
|
+
if self.key is None:
|
|
127
|
+
return [element]
|
|
128
|
+
return [key_value(element, field) for field in self.key]
|
|
129
|
+
|
|
130
|
+
def _key_score(self, e_key: list[Any], a_key: list[Any]) -> float:
|
|
131
|
+
"""Mean of the per-field key scores (a one-field key is that score)."""
|
|
132
|
+
if not (keyable(e_key) and keyable(a_key)):
|
|
133
|
+
return 0.0
|
|
134
|
+
total = sum(
|
|
135
|
+
self.scorer.scalar_on_values(a, e)
|
|
136
|
+
for e, a in zip(e_key, a_key, strict=True)
|
|
137
|
+
)
|
|
138
|
+
return total / len(e_key)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Construction of the aligner named by an array field's `strategy`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from structured_eval.alignment.by_index import ByIndexAligner
|
|
8
|
+
from structured_eval.alignment.by_key import ByKeyAligner
|
|
9
|
+
from structured_eval.alignment.hungarian import HungarianAligner
|
|
10
|
+
from structured_eval.models.config import ArrayStrategy
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from structured_eval.alignment.base import ArrayAligner
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def make_aligner(
|
|
17
|
+
strategy: ArrayStrategy = ArrayStrategy.BY_INDEX,
|
|
18
|
+
params: dict[str, Any] | None = None,
|
|
19
|
+
) -> ArrayAligner:
|
|
20
|
+
"""Build the aligner for an array config's `strategy` from its `params`.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
strategy: Which strategy to build.
|
|
24
|
+
params: That aligner's constructor arguments, by name. An unknown key
|
|
25
|
+
surfaces as a `TypeError` from the constructor itself.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
The aligner instance the strategy names.
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
>>> from structured_eval.alignment import make_aligner
|
|
32
|
+
>>> from structured_eval.models import ArrayStrategy
|
|
33
|
+
>>> type(make_aligner()).__name__
|
|
34
|
+
'ByIndexAligner'
|
|
35
|
+
>>> aligner = make_aligner(ArrayStrategy.BY_KEY, {"key": "sku"})
|
|
36
|
+
>>> aligner.align([{"sku": "A"}, {"sku": "B"}], [{"sku": "B"}]).matched
|
|
37
|
+
[(1, 0)]
|
|
38
|
+
"""
|
|
39
|
+
params = params or {}
|
|
40
|
+
if strategy == ArrayStrategy.BY_INDEX:
|
|
41
|
+
return ByIndexAligner()
|
|
42
|
+
if strategy == ArrayStrategy.HUNGARIAN:
|
|
43
|
+
return HungarianAligner(**params)
|
|
44
|
+
return ByKeyAligner(**params)
|